sots-re/findings/subsystems/rung-b-rich-turn.md
alex c4ea347ace lane CV: localise the ModCount residual to lists 1/7/12; tool regression check
The canonical pair on the same binary puts ModCount on 12 -> 24 exactly (zero
residual), so the cost table is right on the lists that pair exercises and the
rich turn's residual of 2 must sit in lists 1, 7 or 12. The only uniform
per-element explanation is list 7 (colonisation) at 2 bumps -- fitted to one
observation, recorded as a prediction with its falsifier.

Also records the regression check on tools/aiorders_to_tcb.py: 8 shallow logs
byte-identical, 4 lane-CB logs corrected (they were deep dumps whose payloads
the converter had been discarding), and the canonical pair's replay unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-09 09:31:58 -04:00

40 KiB
Raw Permalink Blame History

Rung B on a rich turn — the replay runs, consumes the whole stream, and closes one leaf

  • Type: subsystem / engine measurement
  • Owner / date: lane CV · 2026-09-09 · HOST ONLY. No VM was touched. VM140 (reference, exclusive), VM141, VM145 and VM146 were not approached, not pinged and not logged into.
  • Trigger: campaign/backlog.md §2 Rung B — "byte-match given a recorded command stream" — against the campaign's first replayable rich-turn record, findings/control-flow/ai-visit-order-probe.md §4.2 (lane BR, run 2).
  • Consumes: lane BR's deep capture (verify/results/shim/br/BR2-deep-aiorders.txt), lane BP's output save bp-pinB-turn28.sav (724528ff…, byte-identical to BR's run 2, verified by BR with cmp), lane BT's state_checksum --relabel-new-ids (findings/subsystems/relabel-new-ids.md), the 2026-09-09 fleet-id-order resolution §6.
  • Touches: tools/aiorders_to_tcb.py (a deep-record reader, §2.2), verify/results/turncommands/br2-turn27-*.tcb, verify/results/standalone/cv/*, this file. No sots-engine code was changed. No engine phase was implemented (scope discipline).

0. Verdict

Outcome 3 of the four: a short, named leaf list. The replay runs, it consumes the whole recorded stream (85 commands over 8 blocks, 81 elements — element-for-element the block BR captured), and it is not byte-identical and not identical modulo the relabelling.

state_checksum --floats bits --mask none reports DIVERGED: 1092 leaf difference(s) against bp-pinB-turn28.sav. The do-nothing baseline (the input save compared with the same target) is 1166. So the engine's turn plus the replay closes 80 leaves and regresses 6, and the command stream's own contribution to the state is exactly one leaf: /Sim/ModCount, which moves from 1430 (drivers alone) to 1500 against a target of 1502.

Outcomes 1 and 2 were not merely missed — they are unreachable on this save for two reasons that have nothing to do with the turn, and both are named below (§4).

The 1092 leaves group into thirteen subsystems with nothing left over; the ranked worklist is §5, and it is the point of this document.


1. Predictions, and how they fared

Written before the first run, from reading command_replay.cpp and the capture format (rule 2). They are weak predictions — this lane is a measurement, not a hypothesis test — and they are recorded so the numbers below cannot be mistaken for a fit.

# prediction verdict
P1 The replay will run, not fail to parse: --turn-commands reads a .tcb, and tools/aiorders_to_tcb.py already converts an aiorders dump. The deep dump adds records, and adding records to a line-oriented format does not break a reader that skips what it does not know. HELD. Outcome 4 does not apply. The existing converter parses the deep log unchanged; what it drops is the deep payload, which §2.2 fixes.
P2 The result will be outcome 3, because command_replay.cpp holds an applier for exactly one of the twenty-seven lists (list 5, system rates) and one gate (research rate). HELD, and worse than predicted: list 5's applier never fires, because the capture cannot type seven of its eight fields (§3.3).
P3 --relabel-new-ids will refuse, by guard G3, because our engine mints no client-side fleet ids at all. HELD, verbatim (§4.2).
P4 ModCount will land close to the target: the counter is a property of the stream and the stream is complete. HELD with a residual of 2 (§3.4).

2. What was run

2.1 Build (rule 21, rule 24)

A fresh detached worktree of sots-engine at e7e2bd6 (ai: the gather order decides fleet names too, and not only ours), in this lane's own scratch directory. No other lane's worktree or build directory was touched, and build-host/build-shim were rm -rf'd before configuring.

git -C ~/sots-engine worktree add <scratch>/cv-engine HEAD --detach
rm -rf build-host build-shim
cmake --preset host
cmake --build --preset host -j 8      # 253/253, clean

ctest --preset host with SOTS_SAVES_DIR pointed at the corpus is 57/59, and the two failures are pre-existing and not this lane's — mars_stream_save and app_turn, both failing on the same thing: the typed writer is not byte-exact on 12 of the 43 corpus saves (§4.1). The denominator is 59, not a smaller number, so this is a real-failure signature and not a broken configure (rule 24).

2.2 The capture: one reader change, and why it was in scope

tools/aiorders_to_tcb.py parsed lane BR's deep dump without complaint, but it read only the shallow aielem window. The deep run's extra records — 35 aivec rows and 3 aistr rows — were silently dropped, so every route and ship vector arrived as vN (a length with no values), which is exactly the shape a shallow capture produces. A deep capture that converts to a shallow .tcb is a harness comparing nothing (rule 1), so the reader was extended:

  • aivec blk= pid= list= idx= at=wN … count=N ints=[…] is consulted only at a word the existing field map already types as a vector (("v", k)), so it lands on the field the map has, and does not invent one. Where it lands anywhere else it is written into the .tcb as a # comment and reported on stderr — a real observation that this tool cannot place.
  • The begin/end pointer pair and the followed count are cross-checked; a disagreement keeps the length-only form rather than guessing.
  • aistr rows (list 1's design names) are likewise recorded as comments. They are not promoted to fields: only the design object's name was followed, so the element is still incomplete, and claiming otherwise would flip CapturedElement::complete on a payload we do not have.

That this change reached the replayer is measured, not assumed (§3.2).

uv run python3 tools/aiorders_to_tcb.py verify/results/shim/br/BR2-deep-aiorders.txt \
  -o verify/results/turncommands/br2-turn27-deep.tcb \
  --input ad-turn27-two-raiders.sav \
  --seed 32=0x156ebbbd --seed 496=0xfe7b2826 --seed 512=0x0ed341d1

The shallow conversion of the same log is kept beside it as verify/results/turncommands/br2-turn27-shallow.tcb, because the difference between the two is the control in §3.2.

2.3 The data root

--data needs an extracted tree, not a .gob, and none existed on the host. 1,103 text files were extracted from /tmp/sots-gob/sots.gob (Weapons/, Species/*/sections/, Species/*/weapons/, TechTree/MasterTechList.tech) plus Locale/EN/Strings.csv from sots_local_en.gob, into the lane's scratch directory. The engine reports 885 section(s) over 7 race(s), 5196 string(s), 46 load problem(s). Nothing was written into ~/sots-engine (clean-room).

Without a data root the run reports 71 blocked leaves instead of 119; with it, the census phase evaluates. Both were run; the numbers in this document are the with-data ones.

2.4 Regression check on the shared tool — it changes four other lanes' logs, and it should

tools/aiorders_to_tcb.py is used by other lanes, so the change of §2.2 was run against every aiorders log in the repo and diffed against the pre-change module:

logs result
bp/{A,B,L}-aiorders.txt, br/BR-aiorders.txt, aiorders/l4-*.txt (8 logs) byte-identical output — they are shallow dumps and carry no deep rows
aiorders/cb-c{1,3,5a,5b}-*.txt (4 logs) changed, and the change is a correction

The four CB logs were deep dumps all along, and the converter had been discarding their payloads. The only field changes are list 8's route and list 10's ship vector going from v1 to v1:272 / v1:1728 — which is exactly what tools/tcb_from_json.py already emits for the same capture, so the two converters now agree on every block, list, gate and element line but one. That one is list 23: tcb_from_json.py's committed output for the canonical pair carries a v6:12422480,1,2,… where its own current UNMAPPED_HEAD[23] = (1, 1) would emit ? — i.e. the committed cb-turn2to3.tcb predates the current tool. It makes no behavioural difference (both shapes leave a ? in the element, so it is incomplete either way) and it is another lane's artefact: recorded here, not touched.

The canonical pair is provably unaffected. sots_turn turn2-state.sav --turn-commands with the committed cb-turn2to3.tcb and with a freshly regenerated one produce a byte-identical save, with identical dispositions (11 commands, 10 bumps, 4 applied / 5 declined / 2 incomplete).

2.5 The runs

T=<scratch>/cv-engine/build-host/src/app/sots_turn
S=~/sots-re/verify/results/saves

# the measurement
$T $S/ad-turn27-two-raiders.sav --data ./sotsdata \
   --turn-commands verify/results/turncommands/br2-turn27-deep.tcb \
   --out r1-deep.sav --metric r1.json

# the controls
$T $S/ad-turn27-two-raiders.sav --data ./sotsdata --out r0-nocmd.sav            # no stream
$T $S/ad-turn27-two-raiders.sav --data ./sotsdata --turn-commands …deep.tcb \
   --replay-count-only --out r4-countonly.sav
$T $S/ad-turn27-two-raiders.sav --data ./sotsdata --turn-commands …shallow.tcb --out r5-shallow.sav
$T $S/ad-turn27-two-raiders.sav --data ./sotsdata --turn-commands …deep.tcb --commit-blocked --out r2.sav
$T $S/ad-turn27-two-raiders.sav --data ./sotsdata --turn-commands …deep.tcb --commit-rng     --out r3.sav

3. The guard set — this harness is not comparing nothing

Rule 1 is the failure this lane was most exposed to. Six checks, every one run, every one reported.

3.1 The stream was consumed, and its size is the block's size

turn-commands: br2-turn27-deep.tcb -- 8 block(s), 3 seed(s)
turn commands: 8 block(s), 4 submitting, 85 command(s)
  ModCount bumps charged  70
  applied 4   transcribed 0   declined 36   incomplete 45
  leaves written by commands  0

85 = 81 elements + 4 gates. 81 is lane BR's own element count for the End-Turn batch, and the per-list breakdown the replay walks — {1:3, 3:19, 5:15, 7:2, 8:3, 10:4, 12:12, 14:8, 23:15} for player 32 — is BR's §1 P4 list, element for element. The four gates are the research-rate gate in blocks 0/1/2/3 (players 16, 32, 496, 512). Nothing in the block was dropped and nothing was invented.

3.2 The deep payload reached the replayer — the dispositions move

The deep and shallow captures of the same run, replayed identically:

capture applied transcribed declined incomplete
shallow (vN, length only) 4 0 29 52
deep (vN:a,b,c) 4 0 36 45

Exactly seven elements move from incomplete to declined: list 8's three routes and list 10's four ship vectors. That is the whole of what the deep dump followed onto a mapped field, and it is the substantive result of the deep capture for this rung: on those seven commands the gap is now the engine, not the record. ("Declined" means the engine names the missing subsystem; "incomplete" means the capture could not carry the payload.)

3.3 The one modelled applier is starved by the capture, and that is a finding

command_replay.cpp holds exactly one list applier — list 5, the per-system planetary-budget sliders — and it fired zero times, on all fifteen elements:

  6  1  32  1  incomplete   list 5 system rates [0..14]
        the capture does not carry all eight fields of the rates frame

The reason is in the converter's own comment: the shim's 48-byte memory window shows only one non-zero word in a list-5 element, at index 2, and the wire frame's member order is not the memory order, so six of the seven sliders are ?. The only list this engine can apply is the only list the memory dump cannot type. The fix is not engine work — it is one UI run (push two different sliders to two different values and read the permutation off), or a capture converted from a save's own TurnCommands block where every field is named.

3.4 The output is not the input, and it is not the no-stream output

file sha256 /Sim/ModCount
ad-turn27-two-raiders.sav (input) 1c8baa27… 1428
bp-turn28-pre.sav (the game's own pre-turn resave) 16f63adb… 1428
r0-nocmd.sav (engine turn, no stream) 1437c5a0… 1430
r1-deep.sav (engine turn + replay) 99ee2437… 1500
bp-pinB-turn28.sav (target) 724528ff… 1502

cmp r0-nocmd.sav r1-deep.sav differs; state_checksum r1 r0 reports exactly one leaf, /Sim/ModCount: 1500 -> 1430. So the stream reached the save, and its entire effect on the state is the counter.

The counter's arithmetic, in full. Target delta 1502 - 1428 = 74. Engine: 2 driver bumps (T00 IncrementModCount, measured as the r0 delta) + 70 charged by the stream = 72. The 70 is 85 - 15: the fifteen list-23 population commands are charged 0 by the engine's cost table. Residual: 2. No bumpsExact = false warning fired, so no gate with an unknown cost was set.

The residual is localised, by running the canonical pair on the same binary. turn2-state.sav with lane CB's cb-turn2to3.tcb gives ModCount 12 → 24, and turn3-state.sav holds 24: exact, zero residual, confirming CB's own prediction. So the cost table is right on every list that pair exercises. That pair carries lists 3, 5, 8, 10, 14, 23; the rich turn adds lists 1 (3 elements), 7 (2), and 12 (12), and nothing else. The missing 2 bumps must therefore sit in lists 1, 7 or 12 — and the only uniform per-element explanation is list 7 (colonisation) at 2 bumps per command, because 3 ∤ 2 and 12 ∤ 2.

That is a constant fitted to one observation (rule 23), so it is a prediction, not a result, and its falsifier is cheap and named: any capture with a colonize count other than 2. If list 7 costs 2, its residual scales with that count; if the extra 2 is a mixed defect across lists 1 and 12, it will not.

3.5 --replay-count-only produces the identical save

cmp r1-deep.sav r4-countonly.sav → byte-identical. That is the control ReplayOptions::countOnly was built for, and it confirms the report's own leaves written by commands 0 rather than taking it on trust.

3.6 The reader refuses a capture that lost an element, and flags the wrong board

Deleting one elem line from the .tcb:

turn-commands: block 1 list 10 declares 4 element(s) and carries 3
refusing to replay a capture that did not parse; a lost element is a counter that is
quietly short by one
$ echo $?
2

Replaying the capture against turn2-state.sav:

turn-commands: ! this capture was taken on 'ad-turn27-two-raiders.sav' and is being replayed
against 'turn2-state.sav'. A capture belongs to ONE board; the counter will be charged either
way and the number will be wrong.

Both guards fire. A silently-empty comparison is not the failure mode here.


4. Why outcomes 1 and 2 are unreachable on this save, independent of the turn

4.1 The engine's own writer is not byte-exact on this save family — one item, 12 bytes

$ sots_turn ad-turn27-two-raiders.sav --roundtrip
roundtrip (untouched): DIFFERS (1052288 bytes)      # the file inflates to 1052300

mars_stream_test_save localises it: round trip: tree identical, typed DIFFERS, first divergence at 0x3f02, 1052288 vs 1052300 — exactly 12 bytes, exactly one item. The bytes at that offset are a Game::SpecialProjectNameGen record:

0x3ef2  00 00 75 73 6e 63 01 00 00 00 03 00 00 00 75 73   ..usnc.........us
0x3f02  70 00 00 00 00 00 03 00 00 00 75 73 63 01 00 00   p.........usc...

ad-turn27-two-raiders.sav has 103 name-gen records; 102 carry usnc = 0 and one carries usnc = 1, and that one is followed by a usp item and then a usc item. The codec (src/mars/stream/shapes.h, struct ProjectName) reads the usnc count and then one item per element:

ar.narr(A("usnc"), used, [](Ar& a, Node& e) { a.any(A("usc"), e); });
//   comment on the member: "usnc is 0 in every save available"

That comment is rule 6's hypothesis, and this save falsifies it: measured, usnc == 0 in 102 of the 103 records (and the writer's Nm follows immediately), usnc == 1 in one, and that one is followed by usp and usc before Nm. The generated schema already lists both (k_Game__SpecialProjectNameGen = NNms2, usnc, usp, usc, Nm, Ntg, with usnc and usp both marked computed). The engine never reads or writes the usp item, and an independent reader — state_checksum.py's, which does name it — reports it in the comparison as a leaf of its own, /Sim/sprjs/usp: only-in-B. Two implementations agree on which item is missing.

All 12 failing corpus saves lose exactly 12 bytes, each at an offset in the sprjs band (0x3f02–0x43a6), so it is one defect, one item, twelve files — not twelve problems.

Consequence for this rung. Byte-identity (outcome 1) is impossible on ad-turn27 and on the 11 other corpus saves with the same shape, before a single turn phase runs. This is a two-line fix in the wire codec — but it is a change to a schema-verified, 100%-coverage module that several lanes and the determinism oracle depend on, and re-proving byte-neutrality across the corpus is the job, not the edit. Named and stopped, per scope discipline. It is item 0 of the worklist.

(This also explains one of the six "regressed" leaves in §0: /Sim/sprjs/usp agreed between the input and the target and is dropped by our writer.)

4.2 --relabel-new-ids refuses, correctly, and the refusal is the informative result

$ uv run python3 verify/state-checksum/state_checksum.py \
    r1-deep.sav verify/results/saves/bp-pinB-turn28.sav --floats bits --mask none \
    --relabel-new-ids verify/results/saves/ad-turn27-two-raiders.sav

relabel: pre-turn save verify/results/saves/ad-turn27-two-raiders.sav
relabel: REFUSED -- the new client-minted fleet ids differ between the two saves -- A minted [],
         B minted [1970, 1986, 2002]; only in B [1970, 1986, 2002].  That is a difference in what
         was allocated, not in how it was labelled, so no relabelling is defensible
relabel: NO relabelling was applied; the comparison below is the ordinary one, and nothing has
         been absorbed.
DIVERGED: 1092 leaf difference(s)

Identical output with bp-turn28-pre.sav as the pre-turn save. Guard G3, by cardinality: our engine minted zero client fleets. Outcome 2 ("identical modulo the relabelling") is not available because there is nothing to relabel — the relabelling quotients out which new fleet got which id, and this engine creates no new fleet at all. Lane BT's tool did exactly what §5 of its own write-up says it will: it refused rather than degrading, printed the reason, and ran the ordinary comparison, so nothing was absorbed. The 1092 above is byte-for-byte the number the tool reports without the flag.

The residue really is insulated from the replay, as the resolution says — it just cannot be demonstrated yet, because demonstrating it requires the engine to mint the ids in the first place. When list 10 has an applier that takes the ids off the wire in block order (the resolution §6's "replay applies ids off the wire and is insulated by construction"), this comparison becomes the one that either passes or refuses for a real reason.

4.3 The tail's sim draw does fire on this workload

The backlog's 2026-09-09 correction warns that replay runs the post-combat tail and that the tail carries a sim draw gated on tscr, and notes the canonical pair is untouched because tscr = 252 there. On ad-turn27-two-raiders.sav every trade sector reads tscr = 253 — measured, six sectors, Trade[752…832]. This is lane AD's own two-raider state, for which findings/control-flow/raid-gate-multiplicity.md measured TradeManager::Slot13RngCalleeA entered 2× and OnAllCombatDone_Tail costing 2 strategic words at 0x00820e18.

So this is precisely the case the correction was written for: the generator model matters on this replay and the engine does not model the site (S02 TradeManagerTurn and T23 TradeManagerEndOfTurnHooks are stubs). sots_turn consumes 16 words on S31 and leaves the saved generator untouched by default; --commit-rng writes its own 16 words and the leaf count is unchanged at 1092, because the words are the wrong words. /Sim/RNG/. is 1 leaf and it stays.


5. The ranked worklist — 1092 leaves, thirteen subsystems, nothing ungrouped

Full listing: verify/results/standalone/cv/cv-leaf-worklist.txt. Every leaf is in exactly one group and the residual is 0.

rank leaves subsystem phase(s) / tag independently closable?
1 539 per-system morale event ring (cme2) S01 SystemPrePassMoraleAndAbandon (stub), T13 UpdateTreasuryMorale (stub), T14 UpdateForeignFleetMorale (stub) No — see below
2 154 colony growth, repair, bonuses (Pv*, Pop, Infra, RepCur/Max, pbon, ibon, ntdev, Rts) S11 SystemTurn (partial), T19 DrainInfraTerraformQueue (stub), T21 (partial) Mostly yes
3 150 fleet objects: position, route, range, layout, health S07 NodeSpaceTravel, S08 FleetMovement, S09/S10, T10 — all stubs Partly
4 79 observed designs / techs / weapons (odes, otch, owep) T34 RecordObservedDesigns (stub) Yes
5 30 player money, events, designs, counters (Sav, BnkEl/Pr, Maint, NumDes, NumOwn, ResTNm, nmeid, lboid, FNGNum, designs/Des[]) P01/P02 (partial, budget-blocked), P05 ProcessResearch (blocked), command list 1 No
6 23 visibility remainder (TShn) known open item from lane E3 — gate proven NOT AFlags No (watchpoint specified)
6= 23 system↔fleet membership (Sys.Flt[], NumFlts) command list 10 + S08 No
8 20 ship census records (ShipRecs/sr*) S17 ShipStatCacheRefresh, T33 ShipManagerEndOfTurnHooks — stubs Partly
8= 20 build queues (BQ/ords) command list 3 — no phase in this engine builds a ship No
10 15 other system leaves (Bats2, DefF, flags, OID/PID) S15/S16, T28/T30 — stubs —
11 13 combat reports (crep) and /CD partly the known re-save delta (§5.3) —
12 10 master id lists, counters, generator (ShipIDs/FleetIDs/DesignIDs, ModCount, NMnx, Act, cmbtid, RNG) id allocation + the generator model No
13 8 turn record archive (turnstats) T36 FinalizeTurnRecords (blocked, would=104) No
14 6 trade manager (fwarn, tsflt) S02, T23, T18/T32 — stubs —
15 1 save-writer defect (sprjs/usp) §4.1 Yes, two lines
16 1 derived /Summary/Checksum unmodelled; masked by --mask resave elsewhere No

5.1 Which single module would close the most — and the honest caveat

By leaf count the answer is the morale event ring, at 539 leaves — half the total. But the count needs decoding before it is used as a ranking, because cme2 is a fixed-size rolling window: every system carries exactly 11 slots (158 events across 28 systems in the input, the target and our output alike). Appending one event shifts all eleven, so a handful of appended events produces hundreds of positional leaves.

Decoded against the target, the turn appended 27 events across 10 systems (7 systems got 3, 3 got 2), all stamped mtr = 28, of exactly three kinds:

× mtp mfx.mv mdsc what it needs
10 12 +2 Colony established on Avalon. colonisation actually happening — command list 7 + T16 ResolveArrivedColonizers
10 20 +2 Imperial savings of 5,000,000 or more. the imperial savings figure — P01/P02, backlog item 1 (the same system's pre-turn event was the 15,000,000 band, so the AI's spend moved it down a band)
7 16 −1 Population limits inhibit freedom. the colony population/limit term — S11, backlog item 1b

So: the morale ring is the reporting surface of three subsystems it does not own. The ring mechanics (append, cap at 11, the mid counter — which reaches 324 in the target from 315 in the input, and mtr = turn) are trivial; the event rules are the work, and they are the same three things the backlog already ranks first.

The single module with the best leaves-per-unit-of-new-work is therefore rank 4: T34 RecordObservedDesigns — 79 leaves, one stub phase, no upstream dependency. The observed-design and observed-tech lists are derived from state we already hold; nothing in them waits on the budget, on ship construction or on the AI. It is the largest independently closable item in the list.

Second-best on that measure is S11's colony half (rank 2, 154 leaves), which the backlog already has as item 1b, and which this run gives a sharp new fact about — see §5.2.

5.2 A measured correction: Pv* is not "last turn's value"

The Pv-prefixed system fields read like a previous-turn snapshot, which would make them cheap (copy before the turn). They are not. On the ten systems where our value disagrees:

Sys[80 "Kam’Kir"]/PvInfra  target 1.03            pre-turn Infra 1.0   post-turn Infra 1.0
Sys[80 "Kam’Kir"]/PvPop    target 617,999,994     pre-turn Pop 600,000,000   post-turn Pop 600,000,000

600,000,000 × 1.03 = 618,000,000, and 1.0 × 1.03 = 1.03. Pv* holds the growth result before whatever clamps it back — the uncapped projection — on a colony sitting at its cap. PvSuit (5/5) and PvRes (4/4) do equal the pre-turn current value, so the field family is mixed and must not be modelled with one rule. This belongs in id-allocation.md's neighbour, the colony notes, and it is a prerequisite for closing rank 2.

5.3 Closed and regressed, reported separately (never netted)

Against the do-nothing baseline (input vs target, 1166 leaves):

  • closed: 80
  • regressed: 6 — and all six are named:
    • /Sim/sprjs/usp — the writer defect of §4.1, not a turn phase.
    • five ntdev leaves, Sys[160/400/416/432/448]: the engine writes 1 where both the input and the target hold 0. On the same run ntdev was closed on thirteen other systems (26 → 27). So S11's ntdev rule is right for the systems that develop and wrong for five that do not — a one-predicate bug with a five-leaf price, and the cheapest single fix in this document.

The stream itself closed 0 additional leaves and regressed 0 relative to the no-stream run; its whole contribution is the counter (§3.4).

5.4 What the capture already holds that the engine declines

Three of the target's leaves are /Sim/players/Player[32]/designs/Des[2018 "Egg Thief Mk 4"], Des[2034 "Egg Thief Mk 5"], Des[2050 "Bravestar Mk 3"] — exactly the three names lane BR's aistr rows recorded, and their ids are the same node-2 counter that minted the fleets (123/124/125 for 1970/1986/2002; 126/127/128 for 2018/2034/2050 — one counter across object kinds, as lane BT's §4.3 warned). The capture carries the design names and the engine declines list 1 for want of a design-registration path and an id allocator. That is a small, well-evidenced item that the deep capture has now made testable, and it is a precondition for NumDes 46 → 49 and for the three DesignIDs[] entries.

5.5 Variants, for the record

run leaves vs target
--turn-commands (the measurement) 1092
no stream 1092
--turn-commands --commit-rng 1092
--turn-commands --commit-blocked 1097 (worse by 5 — the blocked phases' outputs are blocked for a reason)
input, untouched 1166

6. What this lane did NOT establish

  1. Nothing about a turn other than this one, a route other than load, a build other than e7e2bd6, or a target other than 724528ff…. The other member of the outcome set (e913ff41… / bp-pinA-turn28.sav) was not compared; on the evidence of §4.2 it would refuse identically and the leaf count would differ only inside BP's 35-leaf transposition.
  2. No engine phase was implemented and no engine code was changed. The usp codec defect (§4.1) is named, not fixed.
  3. The ModCount residual of 2 is not explained. It is measured, bounded to lists 1/7/12 by the canonical pair's zero residual, and given one uniform candidate (list 7 at 2 bumps) that is fitted to a single observation and must be tested against a capture with a different colonize count before it is believed.
  4. The morale ring's three event rules are named from their mdsc strings and their mtp codes, not decoded from the binary. mtp 12 / 16 / 20 are three of the codes; the full table is not read, and src/game/sim/colony.h names only the addiction codes 0x1b–0x1d.
  5. The list-5 slider permutation was not resolved (§3.3). The experiment that settles it is a UI run, not analysis.
  6. /Summary/Checksum remains unmodelled — masked elsewhere, reported here, not solved.
  7. The 46 data-root load problems were not triaged. They do not affect any leaf in §5, but they are unexamined.

7. Reproducing every number here

cd ~/sots-re
# 1. capture -> .tcb (already committed; this regenerates it)
uv run python3 tools/aiorders_to_tcb.py verify/results/shim/br/BR2-deep-aiorders.txt \
  -o /tmp/br2-deep.tcb --input ad-turn27-two-raiders.sav \
  --seed 32=0x156ebbbd --seed 496=0xfe7b2826 --seed 512=0x0ed341d1
diff /tmp/br2-deep.tcb verify/results/turncommands/br2-turn27-deep.tcb

# 2. build (fresh worktree, fresh build dir -- rules 21 and 24)
git -C ~/sots-engine worktree add /tmp/cv-engine e7e2bd6 --detach
cd /tmp/cv-engine && rm -rf build-host && cmake --preset host && cmake --build --preset host

# 3. replay
build-host/src/app/sots_turn ~/sots-re/verify/results/saves/ad-turn27-two-raiders.sav \
  --data <extracted-data-root> \
  --turn-commands ~/sots-re/verify/results/turncommands/br2-turn27-deep.tcb \
  --out /tmp/r1-deep.sav

# 4. compare
cd ~/sots-re
uv run python3 verify/state-checksum/state_checksum.py \
  /tmp/r1-deep.sav verify/results/saves/bp-pinB-turn28.sav \
  --floats bits --mask none --limit 100000            # DIVERGED: 1092
uv run python3 verify/state-checksum/state_checksum.py \
  /tmp/r1-deep.sav verify/results/saves/bp-pinB-turn28.sav \
  --floats bits --mask none --relabel-new-ids verify/results/saves/ad-turn27-two-raiders.sav
                                                       # REFUSED (G3), then DIVERGED: 1092

Artefacts: verify/results/turncommands/br2-turn27-{deep,shallow}.tcb, verify/results/standalone/cv/{cv-replay-vs-bp-pinB,cv-input-vs-bp-pinB,cv-noreplay-vs-bp-pinB,cv-leaf-worklist,cv-replay-run.log}.


Proposed board rows

New row:

| RUNG B ON A RICH TURN: THE REPLAY RUNS AND CONSUMES THE WHOLE STREAM; 1092 LEAVES, THIRTEEN NAMED SUBSYSTEMS, ZERO UNGROUPED | engine | verified | high | 100% | 2026-09-09 | Lane CV, HOST ONLY (no VM; 140/141/145/146 untouched). First replay of a RICH turn's command stream: lane BR's deep block (`BR2-deep-aiorders.txt`) converted to `.tcb` and replayed by `sots_turn --turn-commands` against `ad-turn27-two-raiders.sav` (`1c8baa27...`), compared with `bp-pinB-turn28.sav` (`724528ff...` = BR's own output, cmp-verified). **VERDICT: OUTCOME 3 -- a named leaf list, not a byte match.** `state_checksum --floats bits --mask none` = **DIVERGED: 1092 leaf difference(s)**; the do-nothing baseline (input vs target) is **1166**, so the turn+replay CLOSES 80 and REGRESSES 6 (never netted). The stream's entire contribution to the state is ONE leaf: `/Sim/ModCount` 1430 -> 1500 against a target of 1502 (85 commands = 81 elements + 4 gates, 70 bumps charged = 85 - the 15 list-23 population commands the cost table charges 0; 2 driver bumps; **residual 2**). **THE RESIDUAL IS LOCALISED:** the same binary on the CANONICAL pair (`turn2-state.sav` + lane CB's tcb) puts `ModCount` on **12 -> 24 EXACTLY, zero residual**, matching `turn3-state.sav` and confirming CB's own prediction -- so the cost table is right on lists 3/5/8/10/14/23, and the missing 2 must sit in lists **1 (3 elements), 7 (2), 12 (12)**, the only three the rich turn adds. The only UNIFORM per-element explanation is **list 7 (colonisation) at 2 bumps per command** (3 does not divide 2, nor does 12) -- a constant fitted to ONE observation (rule 23), so it is a PREDICTION with a named cheap falsifier: any capture with a colonize count other than 2. GUARD SET, ALL SIX RUN (rule 1): (a) 85/81 matches BR's block element-for-element and per-list; (b) the DEEP payload provably reached the replayer -- shallow vs deep captures of the same run move exactly 7 elements from `incomplete` to `declined` (list 8's 3 routes, list 10's 4 ship vectors), i.e. on those seven the gap is now the ENGINE not the record; (c) `--replay-count-only` produces a BYTE-IDENTICAL save, confirming `leaves written by commands 0`; (d) deleting one `elem` line makes the reader REFUSE with exit 2; (e) replaying against another board warns; (f) output != input and != the no-stream output. TWO BLOCKERS PUT OUTCOMES 1 AND 2 OUT OF REACH BEFORE THE TURN RUNS: **(1) the engine's typed writer is not byte-exact on this save** -- `--roundtrip` DIFFERS by exactly 12 bytes = one item, at 0x3f02: `struct ProjectName` reads `usnc` as a count of ONE item per element (`a.any("usc")`, commented "usnc is 0 in every save available") but the wire pair is `usp` THEN `usc`, and this save has one record with `usnc == 1`. The generated schema already lists both. 12 of 43 corpus saves fail the same way; `mars_stream_save` and `app_turn` fail on it in ctest (57/59, pre-existing, denominator intact). Surfaces as `/Sim/sprjs/usp: only-in-B`. NAMED, NOT FIXED (schema-verified module, byte-neutrality must be re-proved). **(2) `--relabel-new-ids` REFUSES by guard G3** -- "A minted [], B minted [1970, 1986, 2002]" -- because this engine mints NO client fleet ids at all; the refusal applies nothing and the 1092 is byte-identically the no-flag number. Outcome 2 needs list 10's applier first. **THE TAIL'S SIM DRAW FIRES HERE:** every trade sector in this save reads `tscr = 253`, not the corpus's 252 -- this IS lane AD's two-raider state, measured at 2 entries / 2 strategic words at 0x00820e18 -- so the backlog's Rung B correction is load-bearing on this workload and `S02`/`T23` are stubs; `--commit-rng` leaves the count at 1092 because the 16 words are the wrong words. RANKED WORKLIST (full list in the finding, residual 0): morale event ring `cme2` **539**; colony growth/repair/bonuses **154**; fleet objects **150**; observed designs/techs **79**; player money+counters **30**; `TShn` **23**; system<->fleet membership **23**; ShipRecs **20**; build queues **20**; other system **15**; crep/CD **13**; master id lists+generator **10**; turnstats **8**; trade manager **6**; writer defect **1**; Checksum **1**. **THE 539 MUST BE DECODED BEFORE IT IS RANKED:** `cme2` is a FIXED 11-SLOT RING (158 events in input, output and target alike), so the turn's **27 appended events across 10 systems** shift every slot. The 27 are three kinds only: 10x `mtp=12` "Colony established on Avalon." (+2), 10x `mtp=20` "Imperial savings of 5,000,000 or more." (+2, down a band from the pre-turn 15,000,000), 7x `mtp=16` "Population limits inhibit freedom." (-1). So the ring is the REPORTING SURFACE of colonisation, the savings chain and the population term -- it owns none of them. **THE LARGEST INDEPENDENTLY CLOSABLE MODULE IS `T34 RecordObservedDesigns` (stub): 79 leaves, no upstream dependency.** TWO MEASURED CORRECTIONS: **`Pv*` is NOT a previous-turn snapshot** -- target `PvInfra` 1.03 and `PvPop` 617,999,994 where both pre- and post-turn `Infra`/`Pop` are 1.0 / 600,000,000 (= current x 1.03), so `Pv*` holds the UNCAPPED GROWTH PROJECTION on a capped colony, while `PvSuit` (5/5) and `PvRes` (4/4) DO equal the pre-turn current: the family is mixed and must not get one rule. And **five of the six regressions are `ntdev`** on Sys[160/400/416/432/448], where the engine writes 1 and both input and target hold 0, while the SAME rule closed 26->27 on thirteen other systems -- a one-predicate bug, the cheapest fix here. Also: the capture already holds the three design NAMES the target creates (`Egg Thief Mk 4/5`, `Bravestar Mk 3` = Des[2018/2034/2050], node-2 counters 126/127/128 continuing the fleets' 123/124/125 -- one counter across object kinds, lane BT §4.3), and list 1 is declined for want of a design-registration path. AND: **the one list `command_replay.cpp` CAN apply -- list 5, the planetary sliders -- fired ZERO times on all 15 elements**, because the shim's memory window cannot type 7 of its 8 fields; the settling experiment is one UI run (two different sliders to two different values), not engine work. TOOL CHANGE: `tools/aiorders_to_tcb.py` now reads the deep dump's `aivec`/`aistr` rows, consulting them ONLY at a word the existing field map already types as a vector, cross-checking the begin/end length against the followed count, and recording anything it cannot place as a `#` comment plus a stderr line (12 list-12 vectors, 15 list-23 vectors, 3 list-1 design names, 1 list-3 vector) rather than inventing a field mapping. REGRESSION-CHECKED against every aiorders log in the repo: 8 shallow logs byte-identical, and **the 4 lane-CB logs were deep dumps whose payloads this converter had been silently discarding** -- the only field changes are list 8's route and list 10's ship vector going `v1` -> `v1:272`/`v1:1728`, which is EXACTLY what `tools/tcb_from_json.py` already emits for the same capture, so the two converters now agree everywhere but list 23 (where the committed `cb-turn2to3.tcb` predates the current `tcb_from_json.py`'s own `UNMAPPED_HEAD[23]`; no behavioural difference, recorded not touched). The canonical pair's replay is byte-identical under the committed tcb and a regenerated one. Artefacts: `findings/subsystems/rung-b-rich-turn.md`, `verify/results/turncommands/br2-turn27-{deep,shallow}.tcb`, `verify/results/standalone/cv/*` |

Edits to existing rows:

  • backlog.md §2, the Rung B bullet — append: 2026-09-09, lane CV, measured. The replay RUNS on the first rich-turn record and consumes it whole (85 commands / 81 elements / 70 bumps), and the result is a worklist, not a match: 1092 leaves against 724528ff...from a 1166-leaf do-nothing baseline, 80 closed / 6 regressed, and the stream's entire effect on the state is/Sim/ModCount(1430 -> 1500, target 1502, residual 2). Two blockers sit UPSTREAM of the turn and must be cleared before any byte-match claim on this save family: the typed writer drops oneuspitem inGame::SpecialProjectNameGen(12 saves of 43), and--relabel-new-idsrefuses because the engine mints no client fleet ids. **And the tail'stscr gate is TRUE here (253, not 252) -- the correction above is load-bearing on exactly this workload.**
  • backlog.md §3 item 8 (Rung B) — append: The --turn-commands path is built and works; what it lacks is appliers. 26 of 27 lists decline or are incomplete, and the one that has an applier (list 5) cannot fire because the memory capture cannot type its fields.
  • backlog.md §3 item 1b (S11 civilian growth) — append: ``Pv*is not a previous-turn snapshot: onad-turn27it holds the uncapped growth projection (current x 1.03) forPvPopandPvInfra, while PvSuit/PvRes do hold the pre-turn current. See rung-b-rich-turn.md §5.2.
  • backlog.md §4 / new engine item — T34 RecordObservedDesigns is the largest independently closable stub measured against a rich turn: 79 leaves, no upstream dependency.
  • A new mars/stream row — the Game::SpecialProjectNameGen element is a usp/usc PAIR, not a single usc; the codec's "usnc is 0 in every save available" comment is falsified by ad-turn27-two-raiders.sav. 12 of 43 corpus saves round-trip 12 bytes short. Fixing it must re-prove byte-neutrality over the whole corpus.
  • verify/results/saves/certified-pairs.md — no new row. This lane certifies nothing; it measures an engine against an already-recorded pair.
  • Row 62 (guest ledger) — VM140/141/145/146 unchanged by this lane; it took no guest.