sots-engine/docs/N-output-term.md
alex 0ebc222f45 lane N: the population -> base-output term, live-verified
Reads the whole colony output chain off the instruction stream (every range
disassembled to the next function start) and compares two of its functions
against the running game.

The population -> output law is linear and is carried by the executable:
output points per head are typeOutputModifier x 1.8 / 500000, and the
three-row population-type table is built in code rather than loaded, so the
imperial (1.0) and civilian (0.33f) modifiers are facts about the binary.

A system's total output is a SUM of three terms, not one multiplicative
chain. The station bonus scales only the imperial term and morale only the
civilian one, so OutputModifiers no longer carries either; they belong to
GroupOutputInputs. The function previously described as the base-output term
is the over-harvest RESOURCE demand, and it is corrected in place.

Live on VM140, both hooks in compare mode over two species and two workloads:
GroupOutput 13,105 calls / 0 divergences; ComputeTotalOutput 11,252 calls /
1 divergence of one ulp, in a value its caller rounds to an integer. Both
functions declare a whole-object Guard: 0 undeclared writes in 24,357 calls,
which is what makes the side-effect-free claim a measurement.

sim::Narrow forces the double rounding a 32-bit x87 build otherwise skips;
without it every civilian row came out one ulp low.

Also fixes ComputeBankruptcyLimits' elimination divisor, which was the
decimal -0.15 rather than the image's widened float -0.15000000596046448.
The two disagree for every maximum income divisible by 3 and for essentially
every empire above ~3,000,000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 12:11:36 -04:00

104 lines
5.4 KiB
Markdown

# N — the population → base-output term
The formula that a colony's whole economy hangs off, and the one `src/app` names as its
largest unmodelled input. It is now read from the instruction stream and compared against the
running game.
## What it is
Output points are **linear in population**:
```
outputPerHead = populationTypeOutputModifier x 1.8 / 500000
```
`1.8` and `500000` are literals in the executable, and so are the imperial (`1.0`) and civilian
(`0.33f`) type modifiers — the three-row population-type table is *built in code*, not loaded
from the data files. Only the slave row's modifier comes from the data. An imperial population
with no station therefore contributes exactly `3.6e-6` output points per head.
A system's total output is a **sum of three independent terms**, not one multiplicative chain:
```
base = overHarvestDemand x speciesResourceOutputFactor
+ (transitResources + availableResources) x stripMineFraction x 0.9
+ imperialOutput + civilianOutput + slaveOutput
total = addictionModifier x base
x playerOutMod x systemOutMod x setupOutputMult x rebOutMod x scOutMod
```
The station bonus multiplies **only** the imperial term; the morale modifier multiplies **only**
the civilian term. The previous model in this module applied both to the whole thing, which is
why `OutputModifiers` no longer carries `morale` or `stations` — they belong to
`GroupOutputInputs`, one population row at a time.
## API
`src/game/sim/colony.h`:
| function | what |
|---|---|
| `PopTypeOf(group, tuning)` | the population-type row: output/income modifiers and the row's population cap (imperial 50,000,000, civilian 20,000,000) |
| `GroupOutput(GroupOutputInputs, tuning)` | one (group, species) row's contribution — the per-capita law |
| `MoraleOutputMultiplier(morale, tuning)` | the civilian morale factor, with both of the original's guards |
| `StripMineFraction(StripMineInputs)` | resource extraction efficiency |
| `OverHarvestDemand(OverHarvestInputs)` | the strip-mining resource demand, which is also a summand of output |
| `SystemBaseOutput(BaseOutputInputs, tuning)` | the three terms summed in the original's association |
| `TotalSystemOutputRaw` / `TotalSystemOutput` | the multiplier tail, unrounded and rounded half-to-even |
## Verified against the running game
Two hooks in `src/shim/hooks/system_output.{h,cpp}`, both **compare** mode, config
`src/shim/shim.cfg.output`:
* `Game::ServerSystem::GroupOutput` — the per-capita law itself. **13,105 calls, 0 divergences**
across two species and two workloads.
* `Game::ServerSystem::ComputeTotalOutput` — the whole sum and multiplier tail. **11,252 calls,
1 divergence**, and that one is a single ulp on one system, in a value the caller rounds to an
integer before using — so it cannot move any number the game stores. It is recorded, not fixed.
Both functions were checked for stores to the game state before being chosen as compare
targets, and each declares a Guard region over the whole system object: **0 undeclared writes in
24,357 calls** turns that check from a claim into a measurement. The neighbouring
`ComputeOutputFromRates` is deliberately *not* hooked — it repairs damaged ships in orbit.
### Coverage — read this before quoting the zero
24,357 calls is **13 distinct system states**. Unexercised, and therefore hypotheses:
* the over-harvest branch (`SRoh` is 0 on every call in the corpus), including its `max(v, 1)`
floor;
* the station factor (no system in the corpus has a station);
* the slave term (no system in the corpus holds slaves);
* both morale branches (every colony sits at 75, strictly between the two thresholds);
* the addiction multiplier and the civilian capacity surplus.
## `sim::Narrow`
A 32-bit x87 build may leave an intermediate in a register at the register's own precision; the
original's x87 runs with its precision-control field at 53 bits and rounds every multiply to
double. The first live run made that visible as a one-ulp low result on **every** civilian row —
4,957 divergences that were all the same defect, and none of them the model's. `sim::Narrow`
forces the round the original performs anyway, and is a no-op on any SSE2 target.
## Correction shipped alongside
`ComputeBankruptcyLimits` used the decimal `-0.15` as the elimination-limit divisor. The image
holds `-0.15000000596046448`, a widened float literal. The two disagree for **every** maximum
income divisible by 3 — from `maxIncome = 3` upward — and for essentially every empire above
about 3,000,000, which is six of the twenty-five bankruptcy records recoverable from the save
corpus. Fixed, with `kBankruptcyInterestDivisor` named in the header and a test at the value
where it first bites.
## What this does and does not unblock
It does **not** unblock `P01`. `ComputeBudget`'s missing input is a system's *money*, which runs
this verified total through a second chain with its own population law (`incomeModifier / 14000`,
no `1.8`) and its own multipliers. Checked against the bankruptcy-limit oracle over the whole
save corpus, that composition reproduces **6 of 25 player-records exactly** — every record whose
owner is the human player or an independent colony — and the single-system misses are short by a
clean factor of 1.1, the AI trade/income difficulty multiplier, which is not on the wire.
So the blocker has moved from the output term to the income tail. `src/app` is unchanged:
5 leaves closed, 0 regressed, on both save pairs.