sots-re/findings/subsystems/output-term.md
alex 3b99e255c7 lane C3: ComputeOutput on the turn path -- the money ComputeBudget actually sums
`ServerSystem::ComputeOutput` 0x00751fb0 and the whole of `ComputeOutputFromRates`
0x00751bb0, read from the instruction stream to the next function start. This is the
function lane E1 correctly identified as the real blocker on P01/P02/P03/P05/P06 after
its own chain came out 25/25 on BnkEl and did not unblock them.

The shape, and the fact everything turns on: construction points the build queue and
the ship-repair pass do not spend are redistributed over trade / terraform /
infrastructure, and the TRADE share is added to the money channel. Unspent
infrastructure points cascade into the terraform pool and unspent terraform points
cascade into money -- two hops, not one. So a colony with an empty build queue earns
the same money whichever way its sliders point, which is why the turn path's money
sits within one trade point of the projected path on this corpus.

Eleven addresses, five of them dropped as agreeing duplicates. New: the repair pass
0x00751590 (the B1 double-run side effect, and its round robin is provably a `min`),
the construction-point helper 0x00746830, the queue-demand walker 0x008251e0, the
ideal-suitability resolver 0x00745d60, and two helpers with non-standard conventions
that would be silently wrong read as thiscall -- 0x0074c6f0 takes `this` in ESI and
0x007460b0 takes the system in EBX.

Corrections in place: `income-term.md`'s "leftover science points" are leftover SHIP
CONSTRUCTION points (there is no science channel in this function), its
science-cascade bullet moves from inferred to instruction-verified, and
`output-term.md`'s 0x00751fa8 boundary is padding -- the `ret 8` is at 0x00751fa0.

Measured, with the engine change (sots-engine wip/output2 d3ee453):

    turn1-state -> turn2-state    209 -> 157   closed 52  regressed 0   (was 51 / 0)
    turn2-state -> turn3-state    108 ->  86   closed 22  regressed 0   (was 21 / 0)

Closed 1 per pair, regressed 0. Every prediction in the doc's section 4 held; the one
that was off is named and explained. The strongest check is not the leaf count but
the eleven-save self-check in section 5.3, where every difference between the turn
path and the projected path decomposes to the unit into the build queue's points.
2026-09-08 14:50:50 -04:00

417 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# The population → base-output term (lane N, 2026-09-08)
The formula that blocks `P01 P02 P03 P05 P06` in `src/app`. Read from the instruction stream,
then hooked live on VM140.
Evidence: own `objdump -d` pass over `Sword of the Stars.exe`, every range disassembled **to the
next function start** and the real boundary found (rule 17). Field names per
`findings/objects/struct-recovery.md`.
---
## 0. Correction to `strategic-turn-internals.md` §3.3
That section says:
> ```
> total = round( BaseOutput × mods ) // 0x00750480:
> BaseOutput from pop (0x007483b0: (pbon+Pop)/… resource-availability factor,
> capped by species +0x4c)
> ```
**0x007483b0 is not the population→output term.** It is the *strip-mining / over-harvest resource
demand*, its `P` is `Res` (+0x68) and not `Pop`, and species `+0x4c` is **added**, not a cap — the
cap is the available resource stock. `(pbon+Pop)` enters it only as a `clamp01((pbon+Pop)×1e-5)`
scale factor, and only when the over-harvest rate `SRoh > 0`. The line is corrected in place below
and in `strategic-turn-internals.md`.
The real population→output term is `PopOutput(0) + PopOutput(1) + SlaveOutput()` inside
0x00750480, and it is far simpler than the note implied: **output points are linear in population**,
`pop × typeMod × 1.8 / 500000`.
---
## 1. The call chain
| addr | name | conv / boundary | note |
|---|---|---|---|
| 0x00751bb0 | `ServerSystem::ComputeOutputFromRates(int out[12], OutputRates* rates)` | thiscall `ret 8`, ends 0x00751fa0 | **repairs ships in orbit** — not compare-safe |
| 0x00750480 | `ServerSystem::ComputeTotalOutput(double SRoh)` | thiscall `ret 8`, ends 0x007505a5 | **side-effect free** — the compare target |
| 0x00747d30 | `ServerSystem::StripMineFraction()` | thiscall, returns float, ends 0x00747ddd | |
| 0x007483b0 | `ServerSystem::OverHarvestDemand(double SRoh)` | thiscall `ret 8`, ends 0x007484c4 (Ghidra size 276 = correct here) | |
| 0x0074d8f0 | `ServerSystem::PopOutput(int groupType)` | thiscall `ret 4`, ends 0x0074da01 | |
| 0x0074b880 | `ServerSystem::SlaveOutput()` | thiscall, ends 0x0074b901 | |
| 0x0074b7a0 | `ServerSystem::GroupOutput(int groupType, int species, double count)` | thiscall `ret 0x10`, ends 0x0074b871 | **the per-capita term** |
| 0x00747ba0 | `ServerSystem::GroupPopulation(int groupType, int species)` → int64 | thiscall `ret 8` | |
| 0x00746910 | `ServerSystem::MoraleOutputMod(int species)` → double | thiscall `ret 4` | |
| 0x00535e00 | `PopTypeRow(int t)` | cdecl | table at .bss 0x00b104e8, stride 0x30, 3 rows |
| 0x00535ca0 | `InitPopTypeTable()` | — | **the table is built in code**, see §3 |
| 0x00545cc0 | `SpeciesDef(int sp)` | cdecl | table at .bss 0x00b10a00, stride 0x184, 7 rows |
| 0x008e5680 | `signed_cbrt(double)` | cdecl | `x>=0 ? pow(x,1/3) : -pow(-x,1/3)`; exponent is the double 1/3 at 0x00a3a7c8 |
(Boundary corrected by lane C3: the `ret 8` is at 0x00751fa0 and 0x00751fa3..0x00751faf is
`int3` padding, so the 0x00751fa8 above was padding, not code.)
`ComputeOutputFromRates` is reached from `ComputeOutput` 0x00751fb0 (projected) and
`ComputeMaxIncome` 0x007521c0 (max), which are what `ComputeBudget` 0x00863030 and
`UpdateBankruptcyLimits` 0x00818600 call.
---
## 2. The formula
### 2.1 `ComputeTotalOutput(SRoh)` — 0x00750480
`SRoh` is the **fifth float of the normalised `OutputRates`** (`{SRt, SRsc, SRtf, SRi, SRoh, SRs,
int SRnr}`, 0x1c), read at `[ebp-0x4c]` after `NormaliseOutputRates`. It is the over-harvest rate.
```
if (sys->PID == 0) return 0.0 // +0x100
if (sys->rbfl != 0) return 0.0 // +0x1dc, any rebelling species
resAvail = sys->Res // +0x68
if (PID && PID->[0x138]) resAvail += sys->MRes + sys->ARes2 // +0x70 + +0x6c (the AMine flag)
resTerm = (double)(sys->TRes + resAvail) // +0x74
* (double)StripMineFraction() // float, narrowed
* 0.9 // .rdata 0x00a05d48
baseTerm = OverHarvestDemand(SRoh) * (double)(float)SpeciesDef(PID->Species)->f50 // +0x50
popTerm = PopOutput(0) + 0.0 // .rdata 0x009e1e68 == 0.0
+ PopOutput(1)
+ SlaveOutput()
sum = baseTerm + resTerm + popTerm // in that association:
// ((y + x1 + (x0 + 0.0)) + (baseTerm + resTerm))
mod = (AddictionPhase(sys, sp) >= 3) ? ADDICTION_OUTPUT_MOD : 1.0
sp = PID ? (sys->indi ? sys->indi->[4] : PID->Species) : -1
sum *= PID->[0x124] // p.OutMod
sum *= sys->OutMod // +0x7c
sum *= PID->[0x224] // the game-setup handicap output multiplier
sum *= PID->[0x128] // p.RebOutMod
sum *= PID->[0x12c] // p.ScOutMod
return mod * sum
```
Every multiply is 80-bit x87 with no intermediate store, so the whole tail is one long-double
chain; only the two `(double)(float)` narrowings above are real roundings.
### 2.2 The population term — `PopOutput` 0x0074d8f0 and `GroupOutput` 0x0074b7a0
```
PopOutput(t): // t = 0 imperial, 1 civilian
sum = 0
for sp in 0..6:
n = GroupPopulation(t, sp) // int64
surplus = 0
if (PID && sys->indi == 0 && t == 1 && sp == PID->Species):
MaxPop(sys, PID, sp, &A, 0, 0) // 0x0074a6d0, out slot 4
MaxPop(sys, PID, sp, 0, 0, &B) // out slot 6
surplus = (B > A) ? (B - A) : 0
total = surplus + n
if (total > 0) sum += GroupOutput(t, sp, (double)total)
return sum
SlaveOutput(): // t = 2, no station factor, no morale
sum = 0
for sp in 0..6:
s = SlaveCount(sp) // 0x0074b610, int64
if (s > 0) sum += max(0.0, POPTYPE[2].out * 1.8 * ((double)s / 500000.0))
return sum
GroupOutput(t, sp, count):
if (!(count > 0.0)) return 0.0
q = count / 500000.0 // .rdata 0x00a1fb00 = 500000.0
sf = 1.0
if (PID != 0 && t == 0):
k = StationCount(sys, PID, 0) // 0x00815c10 -> 0x00815b10(.., 1)
b = (STATION_BONUS_IMPERIAL_OUTPUT > 0.0) ? STATION_BONUS_IMPERIAL_OUTPUT : 0.0
sf = 1.0 + (double)k * b
mo = (t == 1) ? MoraleOutputMod(sp) : 1.0
v = (double)POPTYPE[t].out * (sf * 1.8) * mo * q // 1.8 = .rdata 0x00a1faf8
return max(v, 0.0)
```
**`GroupPopulation(t, sp)`** (0x00747ba0):
`t == 0` → `(sp == (sys->indi ? sys->indi->[4] : PID->Species)) ? (sys->pbon + sys->Pop) : 0`,
sign-extended to int64. Otherwise → `Population::Count(sys->pbon2 /*+0x1b4*/, t, sp) +
Population::Count(sys->Pop2 /*+0x1a0*/, t, sp)`.
**`MoraleOutputMod(sp)`** (0x00746910): `1.0` if no owner, if `sys->indi != 0`, or if
`sys->cm.value[sp] == 0` (the `Morale` int[7] at +0x120). Else, with `m = cm.value[sp]`:
`m >= MORALE_INCREASE_OUTPUT` → `MORALE_INCREASE_OUTPUT_MOD` if that is `> 0`, else `1.0`;
`m <= MORALE_DECREASE_OUTPUT` → `MORALE_DECREASE_OUTPUT_MOD` if that is `> 0`, else `1.0`;
otherwise `1.0`.
**So the per-capita output rate is `typeMod × 1.8 / 500000`** — for an imperial population with no
stations that is exactly `3.6e-6` output points per head.
### 2.3 The two resource terms
`StripMineFraction()` 0x00747d30, returns float:
```
Fi = (double)float32(sys->ibon + sys->Infra) // +0x198 + +0x190, narrowed
V = signed_cbrt((sys->pbon + sys->Pop) / 100.0) * 0.01 // 100.0 @0x009e20e8, 0.01 @0x00a1a448
R = clamp01(V)
if (0.0001 + R >= 1.0) R = Fi // 0.0001 @0x00a1fa18
return float32( (R < Fi) ? R : Fi )
```
`OverHarvestDemand(SRoh)` 0x007483b0, `ret 8`, returns double — the corrected §3.3 row:
```
resAvail = sys->Res; if (PID && PID->[0x138]) resAvail += sys->MRes + sys->ARes2
P = (double)resAvail
B = 0.0
if (SRoh > 0.0):
S = sys->pbon + sys->Pop // int add, may wrap
R = clamp01((double)S * 1e-5) // 1e-5 @0x009faa28
B = max(SRoh * P * R, 1.0) // floor of 1.0, not 0.0
D = PID ? (double)(int)SpeciesDef(PID->Species)->f4c : 0.0 // +0x4c, an int
return min(P, max(D + B, 0.0))
```
`ComputeOutputFromRates` calls it a **second** time for the resource ledger:
`need = ftol(OverHarvestDemand(SRoh)) + CivilianConsumption()` (0x0074c6f0);
`out[2] = min(resAvail, max(need, 0))`; `out[1] = min(out[2], ftol(OverHarvestDemand(SRoh)))`
— which confirms B4's correction that `out[1]` is `min(out[2], stripMineDemand)` and names the
demand.
---
## 3. Where each input comes from — and this is the useful part
`InitPopTypeTable` 0x00535ca0 builds the 3-row table **in code**, from x87 literals, with only
three fields taken from GlobalConst slots. Read out of the initialiser (the function keeps six
values on the x87 stack and rotates them with `fxch`, so the rows are not adjacent in the
listing):
| row | +0 | +4 | +8 (max pop) | +0xc | **+0x10 `out`** | +0x14 `income` | +0x18 | +0x1c | +0x20 |
|---|---|---|---|---|---|---|---|---|---|
| 0 imperial | 0 | 1.0 | **50 000 000** | 0 | **1.0** | 1.0 | 1.0 | 1.0 | 1.0 |
| 1 civilian | 1 | 0.25 | **20 000 000** | 0 | **0.33** | 0.33 | 1.0 | 0.5 | 2.0 |
| 2 slaves | 2 | 0.0 | 0 | 0 | **`SLAVES_OUTPUT_MOD`** | `SLAVES_INCOME_MOD` | `SLAVES_REPAIR_MOD` | 0.0 | — |
(the 50 000 000 confirms B4's "the 50,000,000 cap"; the GlobalConst slot pointers are
0x00ae2e88 → 0x00b0e9b0 out, 0x00ae2e84 → 0x00b0e9ac income, 0x00ae2e8c → 0x00b0e9b4 repair.)
So the whole population→output term is **hard-coded in the executable** for imperial and civilian
population. The data-file dependency of §2 reduces to this closed list:
| # | value | where | needed when |
|---|---|---|---|
| 1 | `STATION_BONUS_IMPERIAL_OUTPUT` | slot 0x00af08f4 → 0x00af08f0, **in the file image as `0.1f`** | any imperial pop with ≥1 station |
| 2 | `MORALE_INCREASE_OUTPUT` (int) | slot 0x00aec784 | civilian pop with non-zero morale |
| 3 | `MORALE_INCREASE_OUTPUT_MOD` (float) | slot 0x00aec78c | ditto |
| 4 | `MORALE_DECREASE_OUTPUT` (int) | slot 0x00aec794 | ditto |
| 5 | `MORALE_DECREASE_OUTPUT_MOD` (float) | slot 0x00aec79c | ditto |
| 6 | `SLAVES_OUTPUT_MOD` (float) | 0x00b0e9b0 | any slaves |
| 7 | `ADDICTION_OUTPUT_MOD` (float) | slot 0x00aeca50 → 0x00aeca4c | addiction phase ≥ 3 |
| 8 | `SpeciesDef[sp]->f4c` (int) | 0x00b10a00 + sp·0x184 + 0x4c | always (the base resource demand) |
| 9 | `SpeciesDef[sp]->f50` (float) | +0x50 | always |
Everything else — `1.8`, `500000`, `0.9`, `1.0`, `0.33`, `1e-5`, `0.01`, `100.0`, `1/3`, `0.0001`
— is a `.rdata` literal, i.e. a fact about the algorithm, not about the shipped data.
**Nine numbers.** That is the whole tuning-table surface of this term.
---
## 4. PREDICTION — written before the run
Build `output-<sha>`, hook `Game::ServerSystem::ComputeTotalOutput` (0x00750480) in **compare**
mode. Chosen over `ComputeOutputFromRates` deliberately: that one repairs ships in orbit and is
not compare-safe. `ComputeTotalOutput` and every one of its callees were checked for stores to
the game state and are read-only (0x0074a6d0's only writes are through its int64 out-parameters).
### 4.1 What I expect
1. **Call count.** `ComputeOutput`/`ComputeMaxIncome` are called from the UI as well as the turn,
so the count will be dominated by UI polling, exactly as `ComputeBudget`'s 4,437 were. I expect
**hundreds to thousands of calls, and only a few tens of distinct system states** — and I will
report distinct states, not the call count (rule 15).
2. **Zero divergences on the returned double**, compared as exact IEEE-754 bits.
3. `SRoh == 0.0` on **every** call from all 11 saves (all 28 systems in `turn1-state.sav` carry
`SRoh = 0.0`), so the `SRoh > 0` branch of `OverHarvestDemand` will be **unexercised**. I will
flag it as a hypothesis (rule 6) rather than claim it.
4. **`POPTYPE[0].out == 1.0f`, `POPTYPE[1].out == 0.33f`, `POPTYPE[1].maxpop == 20000000`,
`POPTYPE[0].maxpop == 50000000`** read live out of 0x00b104e8. If any of these differ from the
initialiser I read, my reading of the `fxch` rotation is wrong and everything in §3 is suspect.
5. `STATION_BONUS_IMPERIAL_OUTPUT == 0.1f` live (the file image value survives the data load).
### 4.2 Numbers, from `turn1-state.sav`, before the run
Gamma Cephei (`Idx 4`, human homeworld): `Pop = pbon = 1e9`, `Pop2 = {type 1, species 0, 5e8}`,
`Res 5000`, `MRes 155`, `ARes2 0`, `TRes 0`, `Infra = ibon = 1.0`, `OutMod 1.0`, `cm[0] = 75`,
`SRoh 0`, `rbfl 0`.
* `GroupPopulation(0, 0) = 2e9` → `GroupOutput(0,0,2e9) = 1.0 × sf × 1.8 × (2e9/5e5)` =
**7200 × sf**, `sf = 1 + 0.1k` for `k` stations.
* `GroupOutput(1, 0, 5e8 + surplus) = 0.33 × 1.8 × mo × ((5e8+surplus)/5e5)` = **594 × mo** plus
the surplus term.
* `StripMineFraction`: `cbrt(2e9/100) × 0.01 = 2.7144…` → clamps to `1.0`; `Fi = 2.0`;
`min(1.0, 2.0) = 1.0`. So `resTerm = (0 + resAvail) × 1.0 × 0.9` = **4500** without the AMine
flag, **4639.5** with it. *This is a live discriminator for `PID->[0x138]`.*
* `baseTerm = min(resAvail, max(f4c, 0)) × f50` (the `SRoh = 0` form).
So the returned total for Gamma Cephei should be
`(baseTerm + resTerm + 7200·sf + 594·mo) × p.OutMod × 1.0 × p.f224 × p.RebOutMod × p.ScOutMod`,
and with the four player multipliers at 1.0 and no stations it should sit in the low five figures.
**The single strongest check**: `total` must be *linear* in `Pop`. `Koa'Vo` has `Pop = 1.2e8`,
`pbon = 0` → imperial term `1.2e8/5e5 × 1.8 = 432`, exactly `2000/7200` of Gamma Cephei's, with
`Res 3394` giving `resTerm = 3054.6`. Two colonies, one ratio, no fitted parameter.
### 4.3 Falsification — how this model could be wrong, and the symptom of each
| way it could be wrong | symptom in the run |
|---|---|
| `[ecx+0x68]` in 0x007483b0 is not `Res` — I matched the offsets to `struct-recovery.md`'s memory column, which is 8 more than the wire column | the AMine-gated `+0x70 + +0x6c` sum would not match `MRes + ARes2`; `ours` diverges on **every** call by a fixed additive amount, and the traced `resAvail` will not equal any save field |
| my `fxch`-rotation reading of `InitPopTypeTable` is off by one register | `POPTYPE[1].out` reads live as `0.25` or `0.5` or `2.0` rather than `0.33`; the civilian term is then wrong by a constant ratio and only civilian-bearing systems diverge |
| the `max(v, 1.0)` I read at 0x74844c–0x748451 is really `min` | invisible here, because `SRoh = 0` on every save — so this is **explicitly unverified** and stays a hypothesis |
| `GroupOutput`'s station factor applies to civilians too (I read the `t == 0` test at 0x74b7e0 as excluding them) | systems with stations diverge; systems without do not — so a divergence that correlates with `NumSnF > 0` names this |
| the `MoraleOutputMod` thresholds are inclusive the other way (`>` vs `>=`) | a divergence only on systems whose `cm[sp]` sits exactly on a threshold; likely unexercised, so it will be reported as a hypothesis |
| the association of the additive chain differs (x87 is not associative) | a divergence in the last 1–2 ulps only, on the largest colonies |
| `p.f224` / `RebOutMod` / `ScOutMod` are not all 1.0 in these saves | `ours` diverges by a clean multiplicative factor on every call of one player — which identifies the slot rather than refuting the model |
A run with **0 divergences and only 2–3 distinct colony states** would be a weak result and will be
reported as one. The strong form is the `turn1 → turn2 → turn3` and Zuul `turn15 → turn23`
progressions, where `Pop`, `Res` and `Infra` all move.
---
---
## 5. The live result
Two builds, two workloads, both on VM140. Hook set: `Game::ServerSystem::GroupOutput` and
`Game::ServerSystem::ComputeTotalOutput`, both in **compare** mode, every other hook off.
Traces `verify/traces/output-*.jsonl.gz`, reports `verify/results/compare/output-*.md`.
| run | build | workload | calls | compared | diverged | undeclared writes |
|---|---|---|---:|---:|---:|---:|
| 1 | `output-b48d860-20260908T1527Z` | `ref-turn2` load + one End Turn (Human, turn 2 -> 3) | 14,853 | 14,853 | 4,957 | **0** |
| 2 | `output2-b48d860-20260908T1553Z` | `zuul-turn5` load + one End Turn (Zuul, turn 5 -> 6) | 11,966 | 11,966 | **0** | **0** |
| 2 (cont.) | same | + `ref-turn2` reloaded | 24,357 | 24,357 | **1** | **0** |
Run 1's 4,957 divergences were **every one of them exactly one ulp**, and every one of them on
the civilian population row. The cause was not the model: it was that a 32-bit x87 build of
`game::sim` is allowed to leave an intermediate in a register at the register's own precision,
while the original's x87 rounds every multiply to double (its control word is 0x127f). Run 2
carries `sim::Narrow`, which forces the round the original performs anyway, and the population
term then matches **bit for bit on 13,105 calls across two species**.
### 5.1 Every prediction in §4.1, checked
| predicted | measured | verdict |
|---|---|---|
| `POPTYPE[0].out == 1.0f` | `1.0` | HELD |
| `POPTYPE[1].out == 0.33f` | `0.330000013` | HELD |
| `POPTYPE[0].maxpop == 50 000 000` | `50000000` | HELD |
| `POPTYPE[1].maxpop == 20 000 000` | `20000000` | HELD |
| `POPTYPE[2].maxpop == 0` | `0` | HELD |
| `STATION_BONUS_IMPERIAL_OUTPUT == 0.1f` | `0.100000001` | HELD |
| `SRoh == 0` on every call | 0 on all 24,357 | HELD (and the over-harvest branch is therefore **unexercised** -- see §5.3) |
| zero divergences on the returned double | 0 of 13,105 on `GroupOutput`, 1 of 11,252 on `ComputeTotalOutput` | HELD, with one residual |
| hundreds-to-thousands of calls, few distinct states | 24,357 calls, **13 distinct system states** | HELD, and the coverage is thin exactly as forecast |
So the reading of `InitPopTypeTable`'s x87 rotation was right: the imperial and civilian output
modifiers really are literals in the executable, and the shipped data changes neither.
Values the data files DO supply, read out of the running process (these were unknown before
this run):
| key | value |
|---|---|
| `STATION_BONUS_IMPERIAL_OUTPUT` | `0.1f` |
| `MORALE_INCREASE_OUTPUT` / `_MOD` | `85` / `1.5f` |
| `MORALE_DECREASE_OUTPUT` / `_MOD` | `20` / `0.5f` |
| `SLAVES_OUTPUT_MOD` | `3.0f` |
| `SpeciesDef +0x4c` (base resource demand) | **0** for Human and Tarkas, **10** for Zuul |
| `SpeciesDef +0x50` (resource output factor) | **10** for Human and Tarkas, **40** for Zuul |
The last two are the reason a species table is not optional: they differ by species and they
scale a whole term. Every colony in the corpus carries `cm = 75`, which sits strictly between
20 and 85, so **the morale multiplier is 1.0 on every call the corpus makes** -- both morale
branches are unexercised and stay hypotheses.
### 5.2 The prediction that was wrong, and it was mine, not the model's
§4.2 said Gamma Cephei's strip-mine fraction would be `min(1.0, 2.0) = 1.0` and its resource
term 4500. It is **2.0** and **9000**. The branch at 0x00747db4 is a *substitution*, not a
clamp: once the population term reaches `1 - 1e-4` the infrastructure term replaces it outright
rather than capping it, so a colony with a pending infrastructure bonus extracts at better than
unity. The disassembly said so and I mis-evaluated my own reading of it by hand. The model in
the code was right from the start; the arithmetic in the prediction was not. Caught by the very
first host test, before any VM time was spent.
### 5.3 What this run did NOT cover -- read this before quoting the zero
24,357 calls sounds like a lot. It is **13 distinct system states** and **12 distinct
`GroupOutput` rows**:
* **5 colonies** in total across both workloads (Gamma Cephei, Ke'Dolarra, Koa'Vo, Gallandro
and three more Zuul systems), and 4,832 of run 1's calls are the UI polling one of them.
* **`SRoh` is 0 on every single call**, so `OverHarvestDemand`'s whole `rate > 0` branch --
including the `max(v, 1.0)` floor, which is the one place I could most easily have read a
`min` as a `max` -- is **untested**. It is a hypothesis, not a finding.
* **The station factor is untested.** No system in either workload has a station, so
`stationFactor` was 1.0 on all 13,105 rows and the hook's own `ours` assumed zero stations.
A save with an imperial station would test it; none exists.
* **The slave term is untested.** `slaveGroupPop` was 0 on every call, including the Zuul
workload, so `SLAVES_OUTPUT_MOD = 3.0` was read but never used.
* **The morale multiplier is untested** (see above): every colony sits at 75.
* **The addiction multiplier is untested**: no colony reached phase 3.
* **The civilian capacity surplus is untested**: no colony was at its cap.
* Zuul have no civilian population at all, so run 2 exercises **only** the imperial row --
which is why its 11,966 calls found zero divergences even before you account for the fix.
The honest summary is: the *imperial* and *civilian* population laws are verified hard, on two
species and two workloads; every conditional hanging off them is unexercised and labelled.
### 5.4 The one residual
`ComputeTotalOutput` diverges on exactly one of 11,252 calls in run 2: Koa'Vo, the independent
colony, `9248.450318530804` against `9248.450318530802`. One ulp, in the five-multiplier tail
(`OutMod` there is 2.18500018, the only non-trivial one in the corpus). It is **immaterial to
every consumer**: the caller rounds this value half-to-even to an integer before splitting it
across the output channels, and 9248.4503 rounds to 9248 either way. It is recorded rather than
papered over, and it is not fixed.
---
## 6. What this unblocks, and what it does not
The brief's premise was that this term blocks `P01 P02 P03 P05 P06`. It is now verified -- and
that turns out **not** to be enough to unblock them, for a reason worth stating plainly.
`ComputeBudget`'s missing input is not a system's *output*; it is a system's **money**, which is
`ComputeMaxIncome(s) = max(ComputeOutputRates(s, maxMods)[3], 0)`. That runs the verified output
total through a second chain -- `TradePointsToMoney` -- which has its own population law
(`typeIncomeModifier / 14000`, no 1.8 factor, a different table column) and its own multipliers.
Lane Y's `tools/max_income_oracle.py` inverts the stored `BnkEl` to state the true
`Sum max(ComputeMaxIncome(s), 0)` for 25 player-records across the corpus. `tools/max_income_predict.py`
(new, this lane) computes the same number from colony state using the verified output term plus
that income chain and compares. Result:
```
6 of 25 records MATCH exactly; 19 differ.
```
The six that match are every record whose owner is the human player or the independent colony --
including `turn1/turn2/turn3-state` for both -- and they match to the unit on a five-figure
number, three of them on a moving workload. The nineteen that differ are all **AI-owned**, and
the single-system ones differ by a clean **factor of exactly 1.1** (`246992 x 1.1 = 271691`).
That factor is not on the wire: it is `DifficultyMods(owner)->+4`, the AI trade/income
difficulty multiplier that `TradePointsToMoney` applies and that `formula-gaps.md` Q3 already
names as unresolved. The multi-system AI empires differ by more, so there is at least one
further term there (candidates, in order: trade routes, stations, the suitability money cost on
non-ideal planets).
**So the blocker moves rather than clears**: the population -> base-output term is closed, and
what now stands between `src/app` and `systemIncome` is the income tail, not the output term.
That is a smaller and much better-specified target, and the 25-record oracle makes it falsifiable
without a VM.