sots-engine/docs/PL-players-residual.md
alex 26b041106f PL: decompose the /Sim/players residual; S00 PvSav snapshot; T31 recovers its difficulty column from the save; the bankruptcy protection factor is a widened float
Closed 2 on the reference pair and 4 on pair 2, regressed 0, with no operator
input. Measured, both pairs, never netted.

- S00 stamps PvSav from Sav on every live player, before any phase can move it.
  0 closed on pair 1 (a no-op there), 2 on pair 2.
- T31 recovers the per-player difficulty column by recomputing BnkEl from the
  colony state the input save was written from and comparing against the BnkEl
  the save carries. That removes the --ai-player flag as a blocker and turns the
  phase's self-check into a real one: it used to compare its POST-turn result
  against the PRE-turn stored value, so its 6-of-8 only ever covered the six
  players whose limit does not move. The load-time check passes for every live
  player of all eleven corpus saves. T31 Blocked -> Partial; BnkPr still needs
  the tuning constant.
- BANKRUPTCY_PROTECTION_LIMIT_FACTOR is multiplied in as fmul dword ptr, so it
  is a float32 in the image; the engine narrows it now. Zero leaves move on this
  corpus -- all seven of its BnkPr records land where the two constants agree --
  and three hand-written test expectations moved (rule 23).

docs/PL-players-residual.md carries the decomposition, the predictions written
before the build, where they were wrong, and the ranked remainder.
2026-09-08 16:34:40 -04:00

299 lines
21 KiB
Markdown
Raw Permalink 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.

# PL — decomposing the `/Sim/players` residual
Every lane so far attacked a named subsystem and reported which leaves fell out. This one runs
the other way: start from the residual the metric reports, group it by **mechanism**, and say
what the remaining leaves actually are. Closing leaves is secondary to the decomposition.
Binary re-measured, not inherited. `sots_turn` built from `main` (`aabd8a3`) in this worktree,
`tools/standalone_report.py --binary …`:
| run | pair 1 residual | pair 2 residual | `/Sim/players` share |
|---|---:|---:|---:|
| `main` (`aabd8a3`), no options | **128** | **69** | **54 / 24** |
| `main`, `--commit-blocked=T31 --ai-player 1` | 126 | 67 | 52 / 22 |
| **this lane**, no options | **126** | **65** | **52 / 20** |
| this lane, `--data ROOT --commit-blocked=P11 --ai-player 1 --ai-player 2 --ai-player 3` | **122** | **62** | **48 / 17** |
`closed` / `regressed` on the reference pairs, never netted: this lane closes **2 on pair 1 and
4 on pair 2 with no options at all, and regresses 0**; with a data root, **87 / 0** and **46 /
0** against the 209 and 108 baselines.
Two corrections to the record before anything else:
* The stale `status.json` breakdown said `/Sim/players` 55. It is **54** on pair 1 and **24**
on pair 2.
* **The published 128 / 69 overstates what is unmodelled.** Seven of those leaves belong to a
phase that is already written and only wants the game's data directory — see §3. The metric
harness invokes `sots_turn` with no `--data`, so it measures the engine *plus* that missing
argument. Handing it a root is a one-line harness change worth 4 + 3 leaves today, and more
once the tuning table is wired (§6, PL-3).
---
## 1. The decomposition
The first fact, and the one that reframes the rest: **53 of the 54 leaves on pair 1 and 23 of
the 24 on pair 2 are values our turn writes back unchanged from the input save.** Only one leaf
in the whole block — player 32's `Sav` — is a number we computed and got wrong. The residual is
almost entirely *unmodelled*, not *mismodelled*.
| # | mechanism | pair 1 | pair 2 | rung | blocked on |
|---|---|---:|---:|---|---|
| M1 | **AI research orders** — `ResRate`, `ResTNm`, `ResErrRoll`, and the tech-tree state they move (`St[n]`, `TResDone[n]`, `Tbd[n]`) | 12 | 2 | **B** | `game/ai`: the AI picks a target *during* the turn |
| M2 | **AI ship construction** — `ShipRecs/*`, `Maint`, `FNG/FNGNum`, `NumDes` + the new `Des[…]` frame, `lboid`, and player 32's `Sav`/`PvSav` | 15 | 9 | **B** | lane B6: no build order exists at load |
| M3 | **Observation records refreshed by the AI's own new design** — `odes`, `owep`, `otch/*/otnL` (phase T34 `RecordObservedDesigns`, stub) | 9 | 1 | **B** | downstream of M2 — the observed design *is* the design the AI just made |
| M4 | **Events** — `Events/EvNxID` + the bucket frames | 8 | 6 | A (4/3) + B (4/3) | half is the human's `EVENT_NO_RESEARCH`, implemented in P11 and gated only on a **game-data root**; half is the AI's `EVENT_SHIPS_BUILT`/`EVENT_RESEARCH_OVERBUDGET` (M2/M1) |
| M5 | **Bankruptcy limits** — `BnkEl`, `BnkPr` | 4 | 4 | **A** | `BnkEl`: **closed here** (PL-2, 2 per pair). `BnkPr`: needs one tuning constant from the data files, and the loader that would fill it is not wired |
| M6 | **`Status`** — 0 → 4 on four of eight players | 4 | 0 | A? | the **predicate** is not identifiable from this corpus (§4) |
| M7 | **`PvSav`** — the S00 snapshot, for players M2 does not touch | 0 | 2 | **A** | nothing — **closed here** (PL-1) |
| M8 | **A monster faction's one-off design** — player 528 `NumDes` 18→19 + `Des[1712 "Refugee Trade Ship"]` | 2 | 0 | B? | unexplained; a non-empire faction creates a design on turn 1→2 and never again |
| | **total** | **54** | **24** | | |
Per-mechanism evidence is in §2–§4; what this lane closed is §5, the ranked remainder §8.
### Rung split of the block
| | pair 1 | pair 2 |
|---|---:|---:|
| Rung B (needs the AI's own orders): M1, M2, M3, half of M4, M8 | 44 | 15 |
| Rung A, implemented, gated on a **game-data root** (`--data`): half of M4 | 4 | 3 |
| Rung A, closed by this lane: `BnkEl` ×2, `PvSav` | 2 | 4 |
| Rung A, blocked on a named unread/unwired thing: `BnkPr` | 2 | 2 |
| not identifiable from the corpus: `Status` | 2 | 0 |
(`Status` is 4 leaves in the block, but two of them — players 496 and 512 — are on shadow
empires that also have M1 leaves; the row above counts each leaf once, under the *first*
mechanism that has to land. `Status` for the human and for the AI empire is the irreducible
pair.)
**Four fifths of the `/Sim/players` residual is Rung B**, and it all hangs off two decisions by
one AI player: pick a research target, and queue one destroyer. The Rung-A part is small, and
most of what is left of it is *already written* — it needs the harness handed the game's data
directory, not more reverse engineering.
---
## 2. M1 / M2 / M3 — the three faces of one AI turn (Rung B)
Player 32 "Fane Lao" is the only AI empire that owns colonies, and it does two things in the
turn that our engine cannot: it assigns research, and it builds a ship. Everything in M1, M2
and M3 is a consequence.
Measured, from the state-checksum tree over the three saves:
* `ResTNm` `''` → `IND_Waldo` and `ResRate` `0.25` → `0.800000011920929` — the AI's own order.
The two "Singularity" shadow empires do the same (`DRV_PlsFiss`, `BIO_GnMod`), and their
`ResErrRoll` flips `False` → `True`. `St[106]` 2 → 3 and `TResDone[106]` 0 → 2879 → 5768 then
follow from the allocation.
* `PvSav` 50,000 → 38,100 and again 92,651 → 80,751: **11,900 leaves the treasury before the
first spine phase on both turns.** That is lane B6's queue-time deduction of a build order the
input file does not contain, and it is why player 32's `PvSav` does not close in §5.
* The new design is `Des[18 "Honor Lance"]`, and the observation records name it exactly:
`odes/.[1]` holds `odid=18, opid=32` — **the AI observes its own new design**. `owep/.[1]` is
`bal_gauss`, that design's weapon, and the three `otch` entries whose `otnL` moves from 1 to 2
are `DRV_Fissn`, `WEP_GsDrvr`, `DRV_Hyper` — that design's technologies. So T34
`RecordObservedDesigns` is not an intel pass over enemy fleets at all on this workload; it is
a self-registration triggered by design creation.
* On pair 2 the same design is built again (`srb[0]` 1→2, another `EVENT_SHIPS_BUILT`), and this
time `odes/.[1]/otnL` moves 2→3 while the `otch` entries stay at 2. **`odes` is re-stamped on
build; `otch` is re-stamped on design creation.** One workload, two different refresh rules —
worth recording before anyone models T34 from the pair-1 numbers alone.
None of this is closable without `game/ai`. It is listed here so the next lane does not
re-derive it: **36 of the 54 leaves on pair 1 are one AI player's research pick and one
destroyer**, and four more are the events those two decisions post.
## 3. M4 — events
The corpus posts exactly two events on pair 1 and three on pair 2, and the split is clean:
| player | event | rung |
|---|---|---|
| 0 (human) | `EVENT_NO_RESEARCH`, every turn | **A** — P11 implements it; blocked on the string table |
| 1 (AI) | `EVENT_SHIPS_BUILT` (both turns), `EVENT_RESEARCH_OVERBUDGET` (turn 3) | B — M2 and M1 |
P11's own note is precise about it: *"4 event(s) NOT posted: no string table."* Lane EV measured
`--commit-blocked=P11 --data ROOT --ai-player 1 --ai-player 2 --ai-player 3` at **4 closed on
pair 1, 3 on pair 2, 0 regressed**. Those leaves are in today's residual only because
`tools/standalone_report.py` invokes the binary with no `--data` root.
## 4. M6 — `Status`, and why it is not identifiable here
`Status` goes 0 → 4 on turn 1→2 for players 16, 32, 496 and 512, and stays 0 for 528, 544, 560
and 576. It then stays 4 on turn 2→3, which is why the leaf does not appear in pair 2's residual
at all.
The trouble is that the two sets are **perfectly correlated with species**: the four that take
the 4 are species 0 and 2, the four that do not are all species 4. Sweeping every scalar field
of the player record on `turn1-state`, this many of them split the roster exactly the same
way — `ReqCL`, `AMine`, `CnTrd`, `CnVItl`, `cdp`, `hadvs`, `harcc`, `hgs`, `PvMA`, `CstE`,
`CstR`, `CstT`, `MinRate`, `MaxOH`, `NPTrk`, `PrGtTrf`, `TerraMod`, `pddm` — eighteen of them.
Any one of them "fits". Only two corpus saves carry a non-zero `Status` at all, so there is one observation of one
transition on one roster: **any predicate chosen here is a one-bit fit on eight correlated
players, which is exactly the shape rule 23 was written for.**
`ReqCL` is the most plausible on name and on meaning — `Status = 4` is written by
`MarkPlayerTurnEnded` from the End-Turn submission paths (W2), and `ReqCL` reads as "a client
must submit for this player" — but it is a hypothesis with a workload attached, not a finding.
**The probe that settles it** (rule 18, rule 20): an entry probe on the writer W2 located at
`OnMessage+0xa15`, capturing the player index on every call for one turn. It names the set
directly and costs one VM session. Instrument the *entry*, not the count.
## 5. What this lane changed — measured
Predictions are in §6, written and committed before the build (`c852965`). All three held; the
one thing that came out differently is recorded in §7 rather than quietly corrected.
| change | pair 1 closed | pair 2 closed | regressed | leaves |
|---|---:|---:|---:|---|
| **PL-1** S00 stamps `PvSav` from `Sav` | 0 | 2 | 0 | `Player[16]/PvSav`, `Player[576]/PvSav` |
| **PL-2** T31 recovers its difficulty column from the input save | 2 | 2 | 0 | `Player[16]/BnkEl`, `Player[32]/BnkEl` on both pairs |
| **PL-3** the protection factor is narrowed to float32 | 0 | 0 | 0 | none on this corpus, by construction |
| total | **2** | **4** | **0** | |
Independently of the leaf count, PL-2 turns T31's self-check into a real one. It used to report
"BnkEl reproduced for 6 of 8" on `turn1-state` **regardless of the AI roster**, because it was
comparing its *post-turn* computation against the *pre-turn* stored value — so the only players
it could ever "reproduce" were the six whose limit does not move at all. That is thin coverage
of exactly the shape rule 15 describes, and the phase's own catalog text claimed "8 of 8 with
the AI flag supplied", which the binary never printed. The check is now taken at load, against
the colony state the save was written from, and it passes **for every live player of all eleven
corpus saves** — 7/7 or 8/8 on each. The whole max-income chain is now verified on eleven saves
instead of two.
The column identification resolves to **1 AI and 1 non-AI on every one of the eleven saves**,
with the rest ambiguous, and needs no `--ai-player`.
## 6. Predictions, written before the build (rule 2)
### PL-1 — `PvSav` is S00's snapshot of `Sav`
`PvSav(turn N+1) == Sav(turn N)` holds exactly for players 16 and 576 across all three saves
(50,000 → 50,000 → 289,688 against `Sav` 50,000 → 289,688 → 532,369; and 0 → 0 → 98,871 against
`Sav` 0 → 98,871 → 198,730). For player 32 it is `Sav(turn N) − 11,900`, the M2 deduction.
**Predicted:** pair 1 — 0 closed, 0 regressed (every player's `Sav` already equals its `PvSav`
in `turn1-state`, so the snapshot is a no-op there). Pair 2 — **2 closed** (`Player[16]/PvSav`,
`Player[576]/PvSav`), **0 regressed**; player 32 stays open, short by exactly 11,900.
**Falsification.** If the snapshot were taken later than S00 — say after P02 writes savings —
player 16's `turn3` `PvSav` would be its *turn 3* `Sav` (532,369), not its turn 2 `Sav`
(289,688). It is 289,688. If the snapshot were per-player conditional, some player other than 32
would show an offset; none does. If it regresses anything, the symptom is a player whose
`PvSav` and `Sav` differ in the input for a reason that is not M2 — there is none in the corpus,
so a regression here means the field is not a snapshot at all.
### PL-2 — T31's difficulty column is recoverable from the input save
T31 is blocked on `ServerPlayer+0xf9`, the per-player "is AI" flag, which the save does not
carry and which is worth ×1.1 on that player's max income. The operator supplies it with
`--ai-player N`.
But the input save **already carries the answer**: its `BnkEl` was written by the original at
the end of the previous turn from the colony state the save holds. Computing `BnkEl` from that
state under both columns and comparing against the stored value identifies the column — for
every player whose max income is non-zero, since the two columns differ by 10%, far more than
one truncation.
**Predicted:** on `turn1-state`, the column resolves to non-AI for player 0 and AI for player 1;
it is *ambiguous* (both columns give `BnkEl = 0`) for the five players that own nothing, where
the value is 0 either way. Player 7's column is unknown before measuring. With the column
identified from the save and `BnkEl` committed, the phase closes **2 leaves per pair, 0
regressed** — the same as `--commit-blocked=T31 --ai-player 1` measures today, but with no
operator input.
**Falsification.** (a) If the AI flag does more than select a ×1.1 income column, the identified
column will still reproduce the *stored* value while the *post-turn* value diverges; symptom —
`BnkEl` closes on pair 1 and regresses on pair 2, or the other way round. (b) Player 576's
`BnkEl` is **constant** at −665,806 across all three saves although its `Sav` grows; if its
column is identified and its post-turn max income has moved, committing regresses that leaf.
Symptom — 2 closed and 1 regressed. (c) If the pre-turn computation reproduces *neither*
column's value for a player, the phase must abstain for that player, and the leaf stays
pass-through — no regression, but no close either.
### PL-3 — the protection factor is a widened float (rule 23, read the four bytes)
`ComputeBankruptcyLimits` multiplies `BANKRUPTCY_PROTECTION_LIMIT_FACTOR` in as a `double`. The
image does not. In `ServerPlayer::UpdateBankruptcyLimits` the two constants are loaded by
**different opcodes**, and that is the whole point:
```
DB 45 FC fild dword [ebp-4] ; maxIncome, still on the stack afterwards
DD 05 30 EC A2 00 fld qword [0x00a2ec30] ; DD /0 = m64fp -- a DOUBLE in .rdata
D8 F9 fdivr st(0), st(1) ; maxIncome / that double
E8 … call ftol ; -> BnkEl, then the -2e9 clamp
8B 0D DC DF AE 00 mov ecx, [0x00aedfdc] ; the tuning pointer slot
D8 09 fmul dword [ecx] ; D8 /1 = m32fp -- a FLOAT32
E8 … call ftol
F7 D8 neg eax ; -> BnkPr
```
The `.rdata` double at `0x00a2ec30` reads `00 00 00 40 33 33 C3 BF` = **−0.15000000596046448**,
i.e. `(double)(float)-0.15` — already handled. The protection factor is the other case: it is
read `dword ptr`, so whatever decimal the data file carries is **narrowed to float32 before the
multiply**, and our `double` multiply is wrong at every boundary.
**Predicted:** 0 leaves move on this corpus — all seven non-zero `BnkPr` records invert to a max
income where `3.3` and `(double)3.3f` truncate to the same integer. Three hand-written test
expectations move by one or twenty (`-3300 → -3299`, `-330 → -329`,
`-1320000000 → -1319999980`). The two constants disagree on roughly **1.1% of max-income values
at the corpus's empire size**, rising with empire size, so this is a fix the corpus cannot see —
report it as thin coverage, not as verified.
**Falsification.** If the factor were a `double` in the image the opcode would be `DC /1`, not
`D8 /1`. If some *other* call site multiplies the same global as a qword, the storage is a
double and this is wrong; the check is a cross-reference sweep on `0x00aedfdc`.
---
## 7. Where the predictions were wrong
**"Ambiguous" is not a synonym for "owns nothing."** PL-2 predicted the ambiguous bucket would
be the five players with no colonies. It is six, and the extra one is `Player[576] "Independent
Colony"`, which owns a system and carries a non-zero `BnkEl` of −665,806. The reason is in the
difficulty table itself: the AI row is selected by `isAI && !npc`, and player 576 is an NPC, so
both columns hand it the same row. The note the phase prints and the header comment in
`game/sim/player_turn.h` were both corrected to say so — an ambiguous player is one where the
two columns *agree*, which happens for two different reasons.
That also disposed of falsification (b): player 576's limit is constant across all three saves,
and had the identification been wrong about it the commit would have regressed that leaf.
It did not; 576's post-turn max income reproduces the same −665,806.
Falsification (a) and (c) did not fire: no player of any corpus save came out `Unidentified`,
and no leaf regressed on either pair.
---
**The float/double disagreement rate is 10%, not "roughly 1.1%".** PL-3 estimated the rate from
the size of the gap (`3.3 - (double)3.3f` is 4.768e-8, which at a max income of 240,000 is about
0.011 of a unit, so ~1.1% of values should straddle an integer). Measured over every integer max
income from 230,000 to 250,000: **2,000 of 20,000 disagree — exactly 10%, and exactly the
multiples of ten.** The estimate was wrong because it assumed the products are uniformly
distributed against the integer grid and they are not: `3.3 x 10k` is an exact integer in
decimal for every k, so the decimal lands *on* the boundary at every tenth value and the
image's float lands just below it. The first disagreement is at `maxIncome = 10`
(33.0 against 32.99999952), not somewhere out in the hundreds of thousands.
That makes the fix meaningfully larger than predicted and the corpus' silence about it
correspondingly more suspicious: seven records, none of them a multiple of ten.
---
## 8. The ranked remainder
Ranked by leaves per unit of effort, both pairs summed, with the blocker named. Nothing here is
"more analysis of `/Sim/players`" — every row is a named thing somewhere else.
| rank | item | leaves (p1 + p2) | effort | what it needs |
|---|---|---:|---|---|
| 1 | **give the metric harness a data root** | 4 + 3 = **7** | one line in `tools/standalone_report.py` | `--data <extracted gob> --commit-blocked=P11 --ai-player 1 --ai-player 2 --ai-player 3`. Already measured end to end on CT111 against `/srv/re-lab/gob-extract`: **87 closed / 0 regressed** on pair 1 and **46 / 0** on pair 2, residual 122 and 62. It closes nothing new in the engine; it stops the metric reporting an argument as if it were a gap |
| 2 | **wire `StrategyVars.txt` into a `TuningTable` and set `opt.haveTuning`** | 2 + 2 = **4** | small, contained | `TurnOptions::haveTuning` is **read in three places and never assigned** — the field is dead. `game/config`'s loader exists and the data root already parses; nothing else is missing. Unblocks `BnkPr` in T31 (and, per rule 23, it must be narrowed to float32 on the way in — that is done, PL-3). Also unblocks whatever `RunCivilianGrowth` does with the flag |
| 3 | **`game/ai`: research target selection** | 12 + 2 = **14** | a module | `ResRate`/`ResTNm` are the AI's own orders, and every tech-tree leaf follows from them. Three players do it per turn (the AI empire and both Singularity shadows) |
| 4 | **`game/ai`: the build order** | 15 + 9 = **24**, plus M3's 10 | a module | one destroyer, queued during the turn, deducted 11,900 from the treasury *before* spine phase 0. It is the single largest cause in the block and lane B6 already proved it cannot come from the save |
| 5 | **T34 `RecordObservedDesigns`** | (10, downstream of 4) | small once 4 lands | the mechanism is now readable off the corpus (§2): a design's creator registers the design in its own `odes`, its weapons in `owep`, and re-stamps `otnL` on the design's techs in `otch`; `odes` re-stamps on *build*, `otch` on *creation*. Two different refresh rules from one workload — treat both as hypotheses until a second workload exists |
| 6 | **`Status`'s predicate** | 4 + 0 = **4** | one VM session | an entry probe on W2's writer at `OnMessage+0xa15`, capturing the player index per call (§4). Not a reading problem — eighteen fields fit the one observation the corpus offers |
| 7 | **M8: player 528's one-off design** | 2 + 0 = **2** | unknown | a non-empire faction ("Alien Menace") creates `Des[1712 "Refugee Trade Ship"]` on turn 1→2 and never again, and the id it takes is the first of the three `NMnx` issues that turn. Nothing in the corpus explains what triggers it. Lowest rank because two leaves is not worth a hunt until something else brings us into that code |
**What is NOT on this list, deliberately:** more decomposition of `/Sim/players`. The block is
now fully attributed — every one of the 54 and 24 leaves belongs to a row above.