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

175 lines
10 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.

# 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.