sots-engine/docs/A2-alliance-and-modcount.md
alex 5e409cfa05 lane A2: S04, the alliance mask -- 80 player-records, 560 fields, 0 mismatches
The spine's fourth phase, read byte-for-byte and implemented:

    almem[i] = (1 << i) | (ALid != -1 ? AL : 0)

with i the player's POSITION IN THE PLAYER VECTOR, not its index field. Both
inputs are on the wire and so is the output, through the turn-record archive,
so the phase is checkable against bytes the original wrote:

    app_turn_record: 11 saves, 80 player-records, 560 fields, 0 mismatches
                     (was 480 fields over six fields; almem is the seventh)

The eight zero masks of the corpus's earliest archived turn are PREDICTED, not
excluded: the archiving phase also runs on load, and the load path does not run
the spine. BuildTurnRecord takes spineRan and models it, so all 80 records are
compared.

Three parts of the rule the corpus cannot separate -- the bit index, the OR,
and the ALid guard -- are pinned in app_alliance with the separating inputs no
save provides, and app_turn_record prints that it could not separate them.

Divergence, closed and regressed reported separately:

    turn1->turn2  default          209 -> 204   closed 5, regressed 0
    turn1->turn2  --commit-blocked 209 -> 189   closed 29, regressed 9  (was 17)
    turn2->turn3  default          108 -> 103   closed 5, regressed 0
    turn2->turn3  --commit-blocked 108 -> 106   closed 13, regressed 11 (was 19)

T36 stays blocked: nine leaves would still be wrong (inc x3, sav x3 behind the
budget; three census leaves behind the design catalogue). It now closes all 24
turnstats leaves on the reference pair, so it becomes a clean +24 once those
two land.

Prediction and falsification committed first in 49ae628.
Gates run separately: clean-room OK; host ctest 43/43. No src/shim touched.
2026-09-08 12:44:00 -04:00

10 KiB
Raw Permalink Blame History

Lane A2 — the alliance mask (S04) and ModCount

Lane A2, 2026-09-08. Branch wip/alliance, off main b2bad30 (lane N's output term already merged). Host + static only; lane A2 holds no VM.

This section is the prediction, written before the implementation, per earned rule 2. Everything below the horizontal rule at §3 is the result.


1. The prediction — S04, the alliance / shared-vision mask

The spine's fourth phase is a per-player pre-pass that rebuilds one word of each player's turn record from scratch, every turn, before anything else in the turn can read it. Read from the instruction stream (not the decompiler), the phase is:

for i in 0 .. numPlayers-1:            # i is the player's POSITION IN THE SERVER VECTOR
    p   = players[i]
    rec = p.turnRecord
    rec.allianceMask  = 0
    rec.allianceMask |= (1 << i)
    if p.allianceId != -1:
        rec.allianceMask |= p.allianceMembers

so, as one expression,

almem[i] = (1u << i) | (alid[i] != -1 ? al[i] : 0)

Both inputs are on the wire: they are the two leading ints of the player's second Team item (ALid, AL). The output is on the wire too — it is the almem field of the player's turn-statistics element for the turn.

The prediction, in the form the test will check it: over the whole eleven-save corpus, for every player of every save, for the archive element whose trn equals the save's own frame,

almem == (1 << playerVectorIndex) | (ALid != -1 ? AL : 0)

with zero mismatches, and with playerVectorIndex being the player's ordinal position in /Sim/players — not its PlyrIdx.

Committing this closes the 8 almem leaves that lane Y measured as regressing T36, and moves T36 from "right in six fields, wrong in five" to "right in seven fields, wrong in four".

1.1 Why the bit index is the vector position and not PlyrIdx

The shift count is the loop induction variable, which is the index used to subscript the player vector on the same iteration. Nothing loads PlyrIdx anywhere in the phase. Lane T recorded this correctly and it is restated here because it is the single most likely place for a reimplementation to be quietly wrong: every other per-player index in the tail (the turn-results array, the battle tally, the archive key) uses PlyrIdx, and this one does not.

1.2 What this phase does NOT do

It does not read, and does not write, NA or CF — the other two ints of the same Team item. It does not consult the game's alliance-enabled flag (EnAl). It writes exactly one word.

2. Falsification

Each row is a way the model above can be wrong, and the symptom that would show it.

# how the model could be wrong symptom
F1 the bit is 1 << PlyrIdx, not 1 << vectorIndex on any save where the two orders differ, almem's low bit pattern is off by a permutation. Zero mismatches on a save where they agree proves nothing — the check must report how many saves actually separate the two
F2 the guard is on something other than ALid != -1 (e.g. on AL != 0, or on the game's EnAl) indistinguishable while no player is in an alliance. Only a save with a live alliance can separate them
F3 AL is a mask over PlyrIdx bits while the self-bit is a vector-position bit almem would mix two index spaces. Same blind spot as F2: unobservable without an alliance
F4 phase 36 (FinalizeTurnRecords) overwrites almem after phase 4 sets it mismatches everywhere, and the stored value would correlate with something else entirely
F5 the archived element for the save's own frame was written by the load path rather than by the tail (FinalizeTurnRecords runs in both) the self-bit would be missing, because the load path does not run spine phase 4. Expect almem == 0, or a value that is stale by one turn
F6 the shift wraps shl masks its count to 5 bits, so a 32nd player would set bit 0. No save has more than 8 players; the model is untested above 31 and says so
F7 the corpus never exercises the alliance term at all then this lane has closed the self-bit and nothing else, and must say so under rule 6 rather than claim the rule

F7 is the one I expect to bite. The prior is that no save in the corpus has a player in an alliance, in which case the measured agreement tests only 1 << i, and the |= AL arm is a hypothesis read from the instruction stream with no exercised evidence. That is a real result and it is a smaller one than "the alliance mask is closed", and it will be reported as the smaller one.

3. The prediction — ModCount

ModCount is a modification counter, not a turn number and not a phase counter, and the count a turn adds is a function of the command stream, not of the pre-turn state.

The prediction:

  1. Every writer is an entry-block increment inside a StrategySim command-application method — the OnCommand_* family. The bump is unconditional and happens before the handler validates its arguments, so a command that fails validation still advances the counter.
  2. The two turn drivers each add exactly 1.
  3. ProcessTurn phase 1 adds 1 per system that passes its gate; the gate is closed on all eleven saves, so it contributes 0 to every measured turn.
  4. Therefore ModCount is not derivable from the pre-turn save, and S00/T00's two bumps are the only part of it this standalone can ever produce without modelling the AI's order stream. The remaining 10–42 bumps a turn are commands.

Falsification for §3: if the delta were a function of the pre-turn state, then two saves with the same state shape would show the same delta. turn1→turn2 and turn2→turn3 both show exactly 12, which is consistent with a state function and is the strongest evidence against my reading; a third pair from the same game showing a different delta with an unchanged board would settle it. The counter-evidence I already hold is that human-turn2→turn3 is 28 and zuul-turn16→turn17 is 16 on boards whose system and player counts are identical (28 systems, 7 players) — so it is not a function of the board size. If someone finds a closed-form rule over the pre-turn save that fits all eleven saves, this section is wrong.


4. Result

Everything above this line was committed (49ae628) before a line of the implementation existed.

4.1 The divergence delta — closed and regressed, never netted

tools/standalone_report.py, both real End-Turn pairs, on wip/alliance:

pair run baseline after closed regressed
turn1 → turn2 default 209 204 5 0
turn1 → turn2 --commit-blocked 209 189 29 9 (was 17)
turn2 → turn3 default 108 103 5 0
turn2 → turn3 --commit-blocked 108 106 13 11 (was 19)

The default run is unchanged at 5 closed / 0 regressed, and that is the honest headline. S04 writes no save leaf of its own: the alliance mask reaches the wire only through T36, and T36 is still blocked.

What moved is T36's cost of committing. The 8 almem regressions are gone, and the regression set on the reference pair is now exactly:

inc x3   sav x3        <- the budget: blocked behind the per-system money output
shpt[0] shpt[2] satt[0] <- the ship census: needs each design's hull size and 0x400 class flag

Nine leaves, from two named blockers, both owned by other lanes in flight. On the reference pair T36 now closes all 24 /Sim/turnstats leaves. When the budget and the design catalogue land, T36 is a clean +24 with nothing left over — the same conclusion lane Y reached, with the alliance third of it now paid for.

One thing lane Y wrote is no longer true and is corrected here: "which way the net falls depends on the save". It does not any more — --commit-blocked is net positive on both pairs (+20 and +2, against +12 and −9 before). T36 nevertheless stays blocked, because regressed > 0 means nine leaves would be confidently wrong, and that is the whole rule the status exists to enforce.

4.2 The alliance rule — measured

almem == (1 << vectorIndex) | (ALid != -1 ? AL : 0), against bytes the game wrote: 11 saves, 80 player-records, 560 fields compared, 0 mismatches (app_turn_record, up from 480 fields). 14 records carry a live alliance id, so falsifier F7 did not bite — the alliance term is exercised.

F5 fired, and it was the model, not a miss. All eight almem values of turn1-state.sav are zero where the rule says otherwise, because the archiving phase also runs on load and the load path does not run the spine. The engine states that as a prediction (spineRan), so all 80 records compare rather than 72.

F1, F2 and F3 are all unfalsifiable on this corpus and the run says so every time. Every player in every save has PlyrIdx == vectorIndex; every observed alliance mask already contains its own member's bit; and AL == 0 exactly when ALid == -1. app_alliance pins all three with the separating inputs no save provides.

4.3 ModCount — the prediction stands, and the mechanism is named

§3's reading is confirmed by the instruction stream and sharpened: the counter is Game::StrategySim + 0x4, and the per-turn delta is 2 + one per command applied out of every player's TurnCommands block. The flush is StrategyServer::ApplyAllTurnCommands, called from the same message handler that calls both turn drivers, and it walks a vector<TurnCommands> whose 0x1b4 stride independently confirms the block layout the campaign already recovered.

So S00 + T00's two bumps really are all the standalone can produce, and the remaining 10–42 are downstream of the AI's order generation. ModCount is not the next leaf to chase. The watchpoint that would settle the residual in one turn is specified in sots-re/findings/control-flow/alliance-mask-and-modcount.md §3, with its own written prediction (exactly 12 hits on the reference workload) and four falsifiers.