diff --git a/findings/control-flow/pinned-seed-rich-turn.md b/findings/control-flow/pinned-seed-rich-turn.md new file mode 100644 index 0000000..fd4abb1 --- /dev/null +++ b/findings/control-flow/pinned-seed-rich-turn.md @@ -0,0 +1,202 @@ +# The pinned-seed probe on a rich AI turn — is the client seed the *only* per-process input? + +- **Owner / date:** lane BP · 2026-09-09 · guest **VM146** (`sots-re-win10-146`, `re@192.168.10.146`) +- **Held:** VM146, from the time this prediction was committed. VM140 (reference), VM141 (lane AZ) + and VM144 (lane AP) untouched. +- **Trigger:** `findings/resolutions/2026-09-09-seed-exposure-is-a-predicate.md` §6, **probe 2** — + the first of the three probes, because it can invalidate C-exact. +- **Build:** `bp-989c692-20260909T0505Z`, built in `/srv/re-lab/build/sots-engine-bp` after + `ssh spicy 'rm -rf …'` of the whole tree (rule 24), from a lane-private `git worktree` at + `~/sots-engine-bp` (branch `wip/bp`) with no local `build*` shipped (rule 21). Deployed to + `C:\SOTS\shimdist-bp`. +- **Input:** `verify/results/saves/ad-turn27-two-raiders.sav`, sha256 + `1c8baa27680809d585ab1728391e82729642d32aaedffd5bf95b616f0a11a436` (lane AD's published value; + re-verified on this guest before the first run — §3.1). + +--- + +## 0. The question, in one paragraph + +The campaign holds that the original's AI is a deterministic function of **(save, per-client +seed)** — "C-exact" — and that its apparent non-determinism is one 32-bit word drawn per AI client +at construction from a per-process global (resolution 2026-09-08, board row 326). Row 360 proved +that *pinning* those seeds collapses three processes to one autosave. **It proved it on +`turn1-state`, an early-game turn, and nowhere else.** `ad-turn27-two-raiders.sav` is the richest +AI turn this campaign owns — thirteen ships complete into five new fleets, a design is created, a +system is colonised — and two `hooks=off` processes on it differ in **94 leaves** +(`raid-gate-multiplicity.md` §5). The resolver established that that variation is **not** the +design content (the two `Bravestar Mk 3` bodies are byte-identical apart from `DesID`) and **not** +a research pick (`ResTNm` held for Player[32]); it is the build / fleet-assignment / colony layer +and the id allocation downstream of it, **and its consumer is unidentified**. So reading 3 — *a +non-seed per-process input* — is not excluded on rich states. This probe decides it. + +--- + +## 1. The prediction, committed before the run + +> **P1 (the headline).** Two fresh processes loading `ad-turn27-two-raiders.sav` with the AI client +> seeds pinned by the constructor-argument overwrite, one End Turn each, write **byte-identical** +> `(Autosave).sav` files, and `state_checksum.py --floats bits --mask none` prints **IDENTICAL** +> over roughly 67,000 leaves. +> +> **P2.** The `(Autosave EndTurn).sav` (pre-turn snapshot, written before the AI runs) is +> byte-identical between the two processes as well. This is the weaker half and it is expected to +> hold whatever P1 does: lane AD saw the pre-turn autosave agree bit-for-bit across three processes +> and two configurations, so the load path is already known to be deterministic. +> +> **P3.** The pinned pair also reproduces run L — the unpinned `aiseed=log` run whose observed +> seeds the pin uses (§2.3). This is CB's C3/C5a/C5b shape applied to a rich turn: three processes, +> three sets of natural seeds, one autosave. Weaker than P1 (it can fail for a reason that leaves +> P1 intact — see the falsifiers) and worth much more if it holds, because it says the pinned run +> reproduces **a turn that actually happened** rather than a synthetic one. +> +> **P4 (the exposure predicate, tested in passing).** `ad-turn27` is exposed on all three of the +> resolution's counts for Player[32] — a design is created, thirteen ships complete, a system is +> colonised. If P1 holds, then **an exposed turn is reproducible once the seed is pinned**, which +> is the whole content of C-exact on the turns that matter. +> +> **P5.** The AI command blocks dumped by `aiorders` are identical between the two processes, +> element for element, on every list of every block. If P1 fails, P5 is the localiser: blocks +> identical + autosaves different puts the non-seed input **downstream of the AI's decision**; +> blocks different puts it **inside** it. + +### 1.1 How this could be wrong, and the symptom of each way + +| way | symptom | +|---|---| +| **Reading 3 is alive** — some per-process input other than the seed feeds this turn | P1 fails. The leaf diff localises it. **This is a resolution trigger, not a lane result:** the resolution says so in as many words, so this lane localises by sub-tree, names the leaves, and stops. | +| The pin is **half-applied** — a client with a net id not in the list passes through unpinned | The two processes differ, for a reason that has nothing to do with reading 3. Defended by construction: the wildcard `*=` is set *as well as* every observed id, so no client can pass through, and `shim.log` prints `aiseed call=… observed=… used=… pinned=1` for every single call. **Every call must read `pinned=1`.** A run with even one `pinned=0` is void. | +| The pin is **not applied at all** — `aiseed=pin` with no values | `shim.log` prints `aiseed: PIN MODE WITH NO PINS -- every seed passes through unchanged, so this run is NOT pinned and must not be reported as one`. Checked before either autosave is read. | +| The **wrong mechanism** was used | `airng.pin_seed` re-seeds the generator at bracket entry and is a *declared perturbation*; it would test a different thing. This lane does not set it. `airng=off` is explicit in both configs. | +| The **instrument** perturbs the turn (rule 19) | Not separable here, and stated rather than hidden: a pin *cannot* be applied by a process with no hooks, because `hooks=off` returns from `Shim_Init` before the aiseed module is installed. §2.2 lists exactly what is installed. Both processes carry the identical instrument, so the A/B comparison is internally valid; what it cannot do is speak about the un-instrumented game. | +| A stale binary (rule 24) | The build tree was `rm -rf`'d on CT111 before the rsync; `BUILD_ID` is checked in `shim.log` on every run and quoted in §3. | +| The **click path** lands somewhere else | Every dialog step is screenshotted and verified; no sleep-and-click. Row positions are re-derived from a screenshot of the Load dialog, not carried over from another lane. | + +### 1.2 What this probe cannot decide, whatever it says + +It is a statement about **one save, one procedure, one build**. Rule 26 corollary (c): a control +that agrees certifies a *procedure*, not a state. If P1 holds it says "on this turn, with these +seven clients pinned, nothing else per-process reached the save" — it does **not** say the AI is +deterministic, and it does not transfer to a turn that reaches a consumer this one does not. In +particular the 3–40-turn fuse at `0x0069dbb0` cannot fire inside one turn of a load and is +therefore **untested by this probe**, exactly as it was untested by AR's two-turn pair. + +--- + +## 2. The procedure + +### 2.1 Why the constructor-argument overwrite and not `airng.pin_seed` + +`aiseed=pin` detours `Game::StrategyApp::RunAI` and replaces its **fourth stack argument** — the +word the `StrategyClient` constructor is about to hand to `RNG_Seed` — before the callee reads it. +The client's generator is therefore *constructed* from the pinned word and its whole stream, from +the first draw, is the stream that word implies. `airng.pin_seed` (lane PAR) re-seeds an already +constructed generator at bracket entry; it discards whatever the client drew before that point and +is a **declared perturbation**. The resolution names the first and forbids the second. Getting this +wrong would invalidate the probe, so it is stated here rather than left to the config. + +### 2.2 The hook set, stated in full — and a defect in the mechanism this lane inherited + +`aiseed` lives in the `ai_orders` module, and `install_ai_orders()` is only reached when +`hooks != off`. So **there is no such thing as a `hooks=off` pinned run.** The minimum instrument +that can carry a pin, and the one both processes carry, is: + +* the `Mars::Application::Initialize` detour (the shim's own entry point); +* the **seven draw-site detours** — installed unconditionally whenever `hooks != off`; +* **one** `aiorders` detour on `StrategySim::ApplyTurnCommandBatch` (`aiprobes=off`, so none of the + entry probes; lane H's non-neutral probe index 8 is not installed); +* **one** `aiseed` detour on `StrategyApp::RunAI`. + +Every template hook is off **by name**. That matters more than it looks: +`trace::Config::mode_for` falls through to `default_mode` for any hook **not** named, and +`default_mode` under `hooks=trace` is `trace` — so **a hook omitted from the list is silently left +installed.** + +> **Inherited defect, reported not worked around.** `src/shim/shim.cfg.cbpin` — the file the +> resolution points at — names 20 template hooks and **omits six that existed when it was +> written**: `Game::StrategyServer::BeginProcessTurn`, the three `Game::SVSOSwarmQueen::*` hooks, +> `Game::SVSOSlaversRefuel::UpdateDifficultyTier` and `Mars::RNG::Seed`. Under `hooks=trace` those +> six default to trace mode, so lane CB's pinned runs C5a/C5b carried a larger instrument than +> their config comment implies. It does not damage CB's result — both processes carried the same +> six, and CB's conclusion rests on an A/B agreement — but the next lane to copy that file inherits +> them. `shim.cfg.bppin` names **all 28** registered hooks. +> +> **Second, smaller defect in the same file.** Its header comment wraps onto a line that is *not* +> commented: line 4 of `shim.cfg.cbpin` reads `aiseed.values=32=e70a4703,496=0c63ca36,512=372be4df\`.` +> — a live config line ending in a stray backtick and a full stop, four lines above the real one. +> Harmless if the parser is last-wins, and nobody has checked which it is. Reported so it can be +> fixed rather than rediscovered. + +### 2.3 Three runs, not two + +The resolution costs the probe at two loads. This lane takes three, and the extra one is run **L**: + +| run | config | seeds | End Turns | purpose | +|---|---|---|---|---| +| **L** | `shim.cfg.bplog` | `aiseed=log` — observed, **not** changed | 1 | enumerate the net ids and their *natural* seeds; produce a natural reference autosave | +| **A** | `shim.cfg.bppin` | pinned to L's observed values, every id **plus** the wildcard | 1 | the probe | +| **B** | `shim.cfg.bppin` | identical | 1 | the probe, second fresh process | + +Why the third run is worth its nine minutes: + +1. **Nobody has ever listed this save's AI net ids.** The save has seven non-human players (§2.4) + but the number of players is not the number of `StrategyClient`s. The alternative the resolution + allows — the bare wildcard — pins every client to the *same* word, which is a valid intervention + but not a natural one. +2. **It makes the pin reproduce a turn that happened.** Lane CB's rule: pin to values an unpinned + run of the same save observed for itself. P3 is then a real prediction rather than a definition. +3. **Two runs agreeing on a `k > 1` workload is a `1/k` coincidence; three is `1/k²`.** That is + exactly the argument CB used to justify its own third run, and it applies here with more force + because nobody knows `k` on a build turn. + +`shim.cfg.bplog` and `shim.cfg.bppin` are **byte-identical except the `aiseed=` line and the +values**, so run L is comparable with A and B. + +### 2.4 The exposure facts, read off the input save before any run + +Rule 26 corollary (c) says to write the exposure facts next to the hashes. Read from +`ad-turn27-two-raiders.sav` with `verify/save-reader/save_reader.py --dump --json`: + +| PlyrIdx | PlayerID | name | `ResTNm` at turn start | `NumOwn` | `NumDes` | +|---:|---:|---|---|---:|---:| +| 0 | 16 | `re` (**human**) | `''` | 3 | 6 | +| 1 | **32** | **The Eternal Empire** (Tarka) | **`BIO_TerBac`** (held) | **15** | **46** | +| 2 | 496 | Spengler (RebelAI) | `DRV_PlsFiss` | 0 | 0 | +| 3 | 512 | Spengler (RebelAI) | `XNC_TrnsMorr2` | 0 | 0 | +| 4 | 528 | Alien Menace | `''` | 0 | 19 | +| 5 | 544 | Peacekeeper Enforcer | `''` | 0 | 1 | +| 6 | 560 | Von Neumann | `''` | 0 | 6 | +| 7 | 576 | Independent Colony | `''` | 0 | 1 | + +Ships completing this turn: **13**, into five new fleets (`ShipIDs` +13 −1), with `NumDes` 46→47 +(`Bravestar Mk 3`) and `NumOwn` 15→16 — measured by the resolver from the 27→28 transition, quoted +here as the prior it is, and re-derivable from this lane's own output saves. + +**One thing this table says that the resolution's §3 does not.** The resolution reports "AD's +variation was not a research pick — Player[32] entered turn 28 with `ResTNm = 'BIO_TerBac'`", which +is correct. But **four other AI players enter this turn with `ResTNm == ''`** (528/544/560/576) and +**none of them contributed a leaf to AD's 94** — every one of the 94 is Player[32]'s or the global +id counters'. All four have `NumOwn == 0`. So the predicate as written in rule 26 corollary (c) — +"`ResTNm == ''` names a pick turn" — is **necessary and not sufficient**: a player with no colonies +has no research income and does not reach the pick, whatever its `ResTNm` says. That is a +measurement on the input save, independent of how the runs come out, and it is offered as a +refinement of the predicate rather than a correction of the resolution's Player[32] claim, which +stands. + +--- + +## 3. The result + +*(Filled after the runs. This section is empty in the commit that carries the prediction.)* + +--- + +## 4. Verdict + +*(Filled after the runs.)* + +--- + +## Proposed board rows + +*(Filled after the runs.)*