diff --git a/campaign/backlog.md b/campaign/backlog.md index fdae886..a8638ba 100644 --- a/campaign/backlog.md +++ b/campaign/backlog.md @@ -52,18 +52,31 @@ So the rungs are: replays the AI's (and human's) orders captured from a real turn, and the output must match that turn's autosave byte-for-byte. This is what "the engine is the game" means when the game's own decision layer is not reproducible from a save. Lane AI1's fallback becomes the primary path. -- **Rung C — a deterministic `game/ai` whose choice is provably inside the original's tie set.** - Our engine need not inherit the original's tiebreak. If the variation is a tie broken per-process - (the leading hypothesis), the original's possible outcomes are a small enumerable set; we pick - canonically and verify that **our choice is one of exactly k outcomes the original can produce, - we can name all k, and every observed original run lands inside that set.** That is stronger - than behavioural equivalence and keeps the byte-match live on every run where the tiebreaks - agree. (User's framing, 2026-09-08: make the AI deterministic and the old approach still pays.) +- **Rung C — `game/ai` as a deterministic function of (save, per-client seed).** The original's AI + is not non-deterministic: it is MT19937 from **one 32-bit word per AI client**, drawn once at + construction from a per-process global generator — and that generator is one we already own bit + for bit. Two strengths: + - **C-exact.** Given the seeds captured from an original run (`sots_turn --ai-seed =`), + our AI's block equals that run's block element for element and the autosave byte-matches, on + **every** captured run. This is the user's framing made literal: make the AI deterministic and + the old approach still pays. **Precondition:** `game/ai` must consume the client stream in the + original's order and count, *including draws whose result never reaches the save* — a per-client + draw ledger is the prerequisite (the site table already shows ~8 words/turn on these boards). + - **C-set.** Where seeds were never captured — the historical `turn2-state.sav`, which **no + process including the original can reproduce** — our canonical pick must be a member of the + outcome set of the one seed-sensitive decision. Lane L4 has the XNC arm (k = 6, four observed, + all inside) and **one arm unaccounted for (`BIO_GnMod`, 2 of 6)**, so "we can name all k" is + **to prove**, not proved. The set is the support of a random draw, not a set of equal-score + candidates. -Mechanism, stated as inference: only **one** of three AI players varies, which looks like a tie -broken by something per-process — most likely iteration order over a pointer-keyed container under -ASLR — rather than a time seed (which would move all three). One probe separates them: read the AI -client's generator state after construction in two processes. Routed to lane L1. +Mechanism, **measured** (lane L1, not inferred): the per-client generator at `StrategyClient+0x134` +is seeded from `NextUInt` on the global generator at `0x00af6e58`, which is **live and per-process**; +the seed is in no save. Only one of three empires *shows* the effect because only one reaches the +candidate walk (lane L4) — the other two are stream-insensitive **by code path**. My earlier +"pointer-ordered tie under ASLR" inference was wrong and is **withdrawn**. + +Rung B is unaffected: `--turn-commands` replay stays the no-AI path, and the canonical pair stays +`turn2-state → turn3-state` until the pin probe promotes the creation turn. ## 3. Engine work to Rung A (ranked by leaves closed per lane) diff --git a/campaign/board.md b/campaign/board.md index f4dc766..f08db8f 100644 --- a/campaign/board.md +++ b/campaign/board.md @@ -344,3 +344,8 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`). | LAB HAZARDS from L2 | meta | verified | high | 100% | 2026-09-08 | (1) **`pgrep -f "tap141i0"` matches QEMU's OWN command line** (`ifname=tap141i0`), so stopping a tcpdump SIGTERMed the VM. Use `pgrep -x tcpdump`. The guest came back clean (fsutil not dirty, same IP). (2) Driving the starmap needs **REAL MOUSE MOTION** - SetCursorPos alone does not move the game's cursor - plus wheel zoom; L2's driver is in verify/harness/l2-ui/. (3) From lane L4: **`move X Y` then `click X Y`** - a bare click is delivered at the PREVIOUS cursor position about half the time, which reads as "the click did nothing" then "the next click did the previous thing" | | FOURTH cross-lane commit sweep - now with plain git commit | meta | verified | high | 100% | 2026-09-08 | Lane L1's board commit swallowed all 22 of lane L2's staged files. Content intact and verified in HEAD; L2 correctly did NOT rewrite history with other lanes live. **This is the fourth instance of the hazard addresses.d/README documents, and it now happens with plain `git commit` too, not just addresses.json** - because a lane stages by path but another lane's commit picks up whatever is in the index. The index is shared per-repo; staging by path does not protect against someone else's commit. Needs a real fix, not another rule | | STRUCTURAL FIX for the commit-sweep hazard (rule 25) | meta | verified | high | 100% | 2026-09-08 | Four instances in one session, the last with plain `git commit` and NO `-A` anywhere - **every lane had staged by path exactly as instructed and it still happened**, because `git add ` puts a file in THE REPO'S ONE INDEX and the next commit takes all of it. Staging by path was never the protection. **THE FIX IS A FORM OF THE COMMAND**: `git commit -m "msg" -- path/one path/two` commits only those paths and leaves the rest of the index untouched - verified in a scratch repo (two files staged, pathspec commit took one, the other stayed staged). Lanes now commit with a pathspec, always. Deeper fix if it recurs: a per-lane CLONE of sots-re, mirroring the per-lane worktree of sots-engine - which is precisely why sots-engine has never had this problem | +| RESOLUTION: the byte-match SURVIVES, in a stronger form | meta | verified | high | 100% | 2026-09-08 | First re-resolver run, `findings/resolutions/2026-09-08-ai-seed-per-process.md`. **The AI is not non-deterministic - it is MT19937 from ONE 32-bit word per client, drawn once at construction from a per-process global, and we own that generator bit for bit.** So game/ai is a deterministic function of (save, per-client seed). Rung C splits: **C-exact** - capture the three seeds and our block matches element for element and the autosave byte-matches ON EVERY RUN (the user's framing made literal), precondition being a per-client DRAW LEDGER so we consume the stream in the original's order and count INCLUDING draws whose result never reaches the save; **C-set** - the fallback for references whose seeds were never logged, like turn2-state.sav, which **no process including the original can reproduce**. Instrument exonerated: three unrelated 32-bit words per process are successive generator outputs, not clock reads, so AI1's call-site reading STANDS and only its "never seeded" reading falls | +| the tie-set claim: rescued as a STATEMENT, open as a RESULT | verify | mapped | high | 80% | 2026-09-08 | And **not on "equal cost"** - the resolver's sharpest correction. The 2000-RP tier-1s are not the picks; the picks are tier-2s at 13k-30k. S(D) is **the support of a random draw**, per save, not a set of equal-score candidates. `BIO_GnMod` (2 of 6 observations) proves a SECOND ARM whose producer is unidentified - likeliest producer A or B gated by a draw, never entering the walk - so **"we can name all k" is TO PROVE**. The four-part statement is in the resolution 3.1: derive S before any sweep; canonical pick = lowest tech id; containment; **and a pinned-seed sweep produces nothing outside S** - part (iv) is what makes it non-tautological | +| THE ONE PROBE: pinned seeds THROUGH the resolver | verify | backlog | — | 0% | 2026-09-08 | The seed SOURCE is not the probe. The resolver probe is only an experiment **if the seed is pinned** - otherwise two runs with two unknown seeds cannot separate "draws from the stream" from "reads something else per-process". Implementation named: L1's `l1seed` Seed detour (return address 0x0078304b, three constants) + L4's instrument + a `left` region over client+0x134 at TryResearchCandidate 0x006c8580; ref-turn2 once as the pin's OWN control; turn1-state twice; then sweep by quit-to-menu/reload (the pin table advances per load, **no relaunch needed**), logging which producer returned non-null for player 512. Predictions committed in the resolution 4. If the four leaves still differ, extend the pin to the unidentified fourth seeded object | +| NEW OPEN RISK: g_GlobalRNG has four other consumers | verify | backlog | — | 0% | 2026-09-08 | Including **RunCombatRound+0x60f** - and **no battle turn has ever been compared across two processes**. Add "twice, two processes" to the battle workload in backlog 5. Lane L2 verified combat lockstep between two clients in ONE game; this is the different question of whether a battle is reproducible across separate runs | +| CORRECTION: neither hooks=off run was a control | verify | verified | high | 100% | 2026-09-08 | Rule 26. Three lanes ran hooks=off on turn1-state and got **three different files**, so no single hooks=off run on that workload ever exonerated anything. And lane L4's "run R2 is byte-identical to the hooks=off control" is a **~1/k COINCIDENCE, not a control** - with an outcome set of size k, agreement by chance is expected. Rule 19 said take the measurement with the instrument removed; it did not say what to do when THE CONTROL ITSELF VARIES | diff --git a/findings/resolutions/2026-09-08-ai-seed-per-process.md b/findings/resolutions/2026-09-08-ai-seed-per-process.md new file mode 100644 index 0000000..032f01f --- /dev/null +++ b/findings/resolutions/2026-09-08-ai-seed-per-process.md @@ -0,0 +1,403 @@ +# Resolution — the AI client seed is fresh per process; the tie hypothesis is dead, the byte-match is not + +- **Type:** resolution (course-setting; no measurement taken here) +- **Trigger:** lane L1's P7 falsified — `findings/subsystems/ai-client-seed-is-per-process.md` +- **Date / author:** 2026-09-08 · re-resolver +- **Touches:** `backlog.md` §2 (Rung C and the mechanism paragraph), board rows 240 / 255 / 331 / + 333 / 335, `determinism-oracle.md` (closing paragraph), `ai-turn-logic.md` §5.1 + P1, + `ai-order-capture.md` §3.1–§3.2, `lane-ai1.json::g_GlobalRNG`, lane L4 and CB briefs, + `method-rules.md` (new rule 25) + +--- + +## 0. Decision + +1. **The variation on `turn1-state → turn2` is a seed effect.** Each AI `StrategyClient`'s + generator is seeded at construction with a 32-bit word that differs in every process. The + coordinator's tiebreak-by-ASLR inference, and lane AI1's all-zero-seed derivation, are both + overturned. The instrument is exonerated (§2). +2. **The byte-match ambition survives, and in a stronger form than the tie-set claim.** The + original's decision layer is MT19937 from that one word — a generator we already own bit for + bit — so the AI is a deterministic function of **(save, per-client seed)**. A deterministic + `game/ai` that *takes the seed as an input* can byte-match **every** run whose seeds were + captured, not only the runs where a tiebreak happens to agree. That answers the user's standing + question — *make it deterministic and the old approach still pays* — with a yes, on the + condition that the seed is treated as part of the input. Rung C is restated accordingly (§5). +3. **The tie-set claim is rescued as a statement and open as a result — and not on "equal + cost".** Where seeds were never captured (the historical `turn2-state.sav`) no one can + reproduce the run — the original included. There the claim is membership in the **outcome set** + of a random pick, S(D), with a four-part verification statement (§3.1). Lane L4 has one arm + (k = 6, XNC) and `BIO_GnMod` proves a second arm exists whose producer is unidentified, so + "we can name all k" is **to prove**, not proved. That is the fallback, "C-set". +4. **One probe, before anything else is redirected: pin the three client seeds and run the + creation turn twice — through L4's resolver instrument, then sweep seeds.** Prediction: + byte-identical autosaves, same pick under the same seed, a measured word cost inside + `TryResearchCandidate`. If it holds, the seed is the only per-process input on that turn, + `turn1-state → turn2` returns to the oracle set (with seeds), `game/ai` has a well-posed exact + target, and the sweep names the `BIO_GnMod` arm. The seed's *source* is not the probe. §3.1, §4. +5. **Rule 19 gets a companion, rule 25:** a control must agree with itself across two fresh + processes before it can exonerate or indict anything, and "one of N moves" says nothing about + mechanism. Draft in §7. + +--- + +## 1. The surprise, stated precisely + +Loading `turn1-state.sav` in two freshly launched processes, with `Mars::RNG::Seed` and +`StrategyApp::RunAI` detoured, produced the same eight records in the same order with the same +net ids — and **three different seeds for the three AI clients in each process, none shared +between processes** (`0x75F692C0 / 0xF2EDAC21 / 0x165A2ADB` vs `0x414F415E / 0x10B94E78 / +0xC218DBF8`). The prediction committed before the build was "identical, and zero". It was wrong on +its first falsifier row. + +The evidence is as strong as it looks. The `Seed` hook records the post-state of the object it +seeded; the one call that takes `seed = 0` gives `mt0..mt2 = 2443250962 / 1093594115 / 1878467924` +in both processes, so the hook reproduces a known input to a known state and did not randomise +anything. The build was fresh (`l1seed-20260908T2206Z`, lane L1's own dist). The workload is +load-only, so no End Turn logic is involved — this is the construction path and nothing else. + +Two further things the raw trace says that the report does not spell out: + +- **The three seeds within one process are unrelated words** (high bits `0x75`, `0xF2`, `0x16`), + not three reads of a clock microseconds apart. They are successive *outputs of a generator*. That + is exactly what AI1 read at `OnMessage+0x955` — `mov ecx,0xaf6e58 ; call NextUInt` — so the call + site reading stands and the "never seeded" reading falls: **`g_GlobalRNG` is live and + per-process.** Something initialises it that a scan of the six direct `RNG_Seed` sites cannot + see — rule 16's shape (an inlined MT init, or a state copy), or the object at 0xaf6e58 is not the + one being drawn. Inference; §4.2 names the cheap check. +- **A fourth per-process seed exists before any AI client** — record #2, `seed = 0xf22aac3a` / + `0x16612cc3`, on a heap object nobody has identified. It is either the global's source, a sibling + consumer of it, or an unrelated per-process generator. On `ref-turn2` it changes nothing + save-visible (seven fresh processes agree there); on other workloads it is unbounded. + +## 2. The instrument — exonerated, and the reason is worth keeping + +Three lanes' instruments cannot be the cause and the seed probe shows why: + +- L5's pair A/B carried **identical hooks** and disagreed; L1's instrumented run **coincided** with + L5's un-instrumented one; L4's runs differed with and without hooks. No hook configuration selects + the pick. +- `ref-turn2 → turn3` reproduces byte-for-byte under every hook set tried (H, L1, L4, F, V) and + under none. The instruments are behaviour-neutral on a workload where the game is + self-consistent. +- The seed probe's built-in control (`seed = 0` → identical state) shows the `Seed` detour itself + does not perturb the object it observes. + +**But no `hooks=off` run on `turn1-state` was a control of anything** — L5's, L1's and L4's +`hooks=off` runs gave three *different* files (`62061918…`, `e43ec1d2…`, `d59bb9f2…`). Each lane +cited its single `hooks=off` run as "the un-instrumented value"; together they show the +un-instrumented game has no single value there. The exoneration above rests on the identical-hooks +pair and on `ref-turn2`, not on those runs. That is the gap in rule 19 — §7. + +## 3. Standing claims, one by one + +| claim | where | verdict | scope / correction | +|---|---|---|---| +| Per-client `Mars::RNG` at `StrategyClient+0x134`; all six AI-band `NextInt` sites plus `cl_Chance`/`cl_RandRange` reach it; **zero AI draws on the strategic generator** | AI1 §5, board 240 (first half) | **survives** | L1's hive run has the strategic trajectory identical to the historical game while the pick varies — the strongest confirmation available | +| "`g_GlobalRNG` is never seeded; every draw returns 0; every AI client is seeded with 0" | AI1 §5.1, board 240 (second half), `lane-ai1.json::g_GlobalRNG` | **overturned** by measurement | The author flagged it as arithmetic and wrote the falsifier that fired ("run-to-run different values mean it is time- or address-seeded"). Rule 2 working as designed. Lane H's counter-indication (rejection loop ran on that generator, impossible for an all-zero state) is now confirmed and should be credited | +| AI1's five consumers of `g_GlobalRNG`: `SNMRunAI`, `RunCombatRound+0x60f`, `0x007c2fa0+0xc84`, `0x0079ea90+0x73` (`Chance`), `0x005b9f00+0xc0` | `lane-ai1.json` | **survives as a list; its meaning flips** | Each is now a **per-process input** to whatever it feeds. `ref-turn2` bounds them to "no save-visible effect on that turn" (seven processes). The combat consumer has never been cross-process tested on a battle turn — §6 | +| "P2 HELD: the AI is fully reproducible from a save alone" | AI1 §8.1, board 255 | **qualified with scope** | True of `ref-turn2 → turn3`, where every AI already holds a target and no decision depended on a draw. False as a general statement, as L5/L1/L4 showed. Already superseded by row 331; the row should say so | +| `turn1-to-turn2-nondeterminism.md` — three runs, four leaves, instrument exonerated | L5 | **survives**; cause now known | Its "what was not established: the cause" is answered: the per-client seed. Its `hooks=off` row is a sample, not a control (§2) | +| Determinism oracle on `ref-turn2 → turn3` | `determinism-oracle.md`, board 50/253 | **survives unchanged** | The closing "Mechanism (inference…)" paragraph must be replaced with the seed finding | +| Coordinator's mechanism: "only one of three varies ⇒ pointer-ordered tie under ASLR, not a time seed which would move all three" | backlog §2 last para, board 331 (5), `determinism-oracle.md` last bullet | **overturned** | All three streams move. A seed is visible only where a decision depends on a draw; 32 and 496 never reach the candidate walk (L4 §3.2), so their picks are stream-insensitive by *code path*, not by luck | +| Rung C = "deterministic `game/ai` whose choice is provably inside the original's **tie set**; k enumerable" | backlog §2, board 333 | **restated** (§5) | The *conclusion* that the outcome space of the one varying decision is small and nameable survives; the *mechanism* is a draw, so it is the support of a random pick, not a tie. And the exact form — byte-match given the seed — is now available and is stronger | +| L4 §3.2: three producers; only 512 reaches `SelectResearchTarget`; candidate vector length 1 = a category `{2,12}`; fallback not taken; k = 6 for the XNC family from data; 4 of 6 observed inside | L4 | **survives** (measured parts) | Its interpretation — "taking whichever member it reaches first … the per-process quantity is order" — is **overturned**: the per-process quantity is the client stream inside `TryResearchCandidate 0x006c8580`. Inference until the draw is seen; the L4 correction in §8 says how | +| L4's engine recommendation (canonical by tech id; claim membership in a nameable set) | L4 §3.2 | **survives as C-set** | Needs the `BIO_GnMod` arm accounted for (2 of 6 observed picks are outside the XNC set), i.e. the category itself is probably drawn | +| Lane Z: "residual outside the two drivers is exactly zero" | `tail-rng-ledger.md` §0/§2 | **qualified with scope** (L1 did it in place) | True of every turn with hives already present; on the creation turn `BeginProcessTurn` draws one `NextInt` per new hive, with the rejection loop live. The standalone's replay interval starts at `BeginProcessTurn` (§6) | +| Rule 19's `hooks=off` control | `method-rules.md` | **qualified** | Valid only on a workload where the un-instrumented game agrees with itself across two fresh processes. Rule 25 (§7) | +| "The decision layer is not deterministic, and does not need to be" | backlog §2 | **qualified** | It *is* deterministic given the seed. "Does not need to be" holds for lockstep (decisions travel as commands); for us the seed is an input, not noise | +| Lockstep MP is consistent with a per-machine AI seed | board 331 (2) | **survives** | Only the machine hosting an AI client draws its seed; peers see commands. Prediction for L2 when convenient: the combat seed (the `RunCombatRound` consumer) must be host-distributed in the SNM stream, or MP battles would desync | + +## 3.1 The coordinator's follow-up, ruled on + +**Is the tie-set claim rescued?** *In form, yes; in content, not yet — and not on the grounds +proposed.* Three rulings: + +1. **Do not re-ground it on "an identical-cost family".** The six *tier-1* XNC techs cost 2000 RP + each, but the observed picks are their *tier-2* successors at 13,000–30,000 RP — L4's own + load-bearing detail is that the costs differ and the resolver still varies. Equal cost is not + what makes the outcome set a set. What makes it a set is that the resolver's output is a + function of the client stream over a finite candidate collection: **S(D) = the support of a + random pick**, and k is the size of that support. Ground the claim on the draw and its bound, + which the probe reads; never on cost. + +2. **"We can name all k" is not claimable today, and `BIO_GnMod` is why.** Two of six observed + picks (L5 run B, and the original session that wrote `turn2-state.sav`) are outside the XNC + six. L4 captured the candidate stream once, in a run that went to producer C. The honest + reading of the six observations is: **S(D) ⊇ XNC-six ∪ {`BIO_GnMod`}**, with the producer of + `BIO_GnMod` unidentified. The likeliest shape, stated as inference: in the `BIO_GnMod` runs + producer A or B returned non-null for player 512 — gated by a draw on the same client stream — + and the walk was never entered; then S(D) = A's outputs ∪ B's outputs ∪ C's arm, and naming k + means reading three producers' gates, not one resolver. Finite, cheap, and *not yet done*. Carry + k = 6 as L4 says: **to prove**. + +3. **The verification statement, exactly.** For a turn T from save X, let D be a decision whose + output depends on the client stream (on `turn1-state`, exactly one: player 512's research + target). Then: + + > **C-set.** (i) *Derivation:* S(D) is listed **before any sweep**, from the code of every + > producer that can set D and from shipped data plus the save (the available-tech set is a + > function of X, so S is per-save, not a constant). (ii) *Canonical pick:* `game/ai` emits a + > fixed member of S(D) — lowest tech id, the quantity the command carries. (iii) *Containment:* + > every observed original run's D is in S(D). (iv) *Falsifiable content:* a sweep of **pinned + > seeds** — the seed is now a knob — produces no D outside S(D), and reaches every member of + > S(D) whose derived probability is not negligible. + + (iii) alone is a tautology if S is just the observed set; (iv) is what makes it a claim. Today + (i) is done for one arm, (iii) holds 6/6 only by admitting `BIO_GnMod` on observation, and (iv) + has not been run. C-set is therefore *rescued as a statement and open as a result*. C-exact + (§5) does not depend on any of this and is the primary form. + +**On "a clean control" (R2 = `hooks=off` bytes).** Not a control — rule 25. Two runs landing on +the same one-of-k pick is a ~1/k event per pair, and with pairs compared across L5, L1 and L4 one +coincidence is expected (L1's instrumented run also coincided with L5's `hooks=off`). It is +*consistent* with neutrality; the exoneration rests on L5's identical-hooks pair and on `ref-turn2` +reproducing under every hook set, as §2 says. + +**Is the next probe inside the resolver rather than at the seed source?** *Yes on the source — it +is not the probe — but the resolver probe is only an experiment if the seed is pinned.* Without +the pin, two runs of L4's instrument with two unknown seeds cannot separate "draws from the +stream" from "reads something else per-process". With it, the same run answers both "what is +per-process about it" (same seed ⇒ same pick settles that it is the stream) and "what does it +iterate" (the `left` delta and the draw's bound name the arm's k). So the one probe is the pin +*through* the resolver, one run, both instruments — §4. + +## 4. The one probe — pinned seeds through the resolver + +**Pin the three AI client seeds to constants; run L4's research instrument with a `left` region +over `client+0x134` at `TryResearchCandidate 0x006c8580`; load `turn1-state.sav` in two fresh +processes; End Turn; compare autosaves and the `aicand`/pick/word-cost lines.** Then sweep seeds. + +Why this and not the alternatives the coordinator listed: + +- *Where the seed comes from* only matters for reproducing files whose seeds were never logged — + which is impossible whatever the source turns out to be. For every future capture the `RunAI` + hook already logs the seed. +- *Whether the outcome space is enumerable* is answered "yes, per decision, from the producers' + gates and the draw's bound" the moment the mechanism is a draw — and it becomes irrelevant to + exactness once the seed is an input. It stays relevant only for C-set, which is the fallback, + and there it needs (i)–(iv) of §3.1, which the sweep supplies. +- *Whether the varying empire's candidate set is tied* is already answered by L4: the vector has + one element. There is nothing to tie. + +The pin probe separates the two hypotheses that are actually live: + +- **H1:** the per-client seed is the *only* per-process input to this turn. Pinned seeds ⇒ + byte-identical `(Autosave).sav` across processes, `(Autosave EndTurn).sav` still `a3f9dc4b…`, + strategic word positions still 192 (hive draws unaffected). +- **H2:** something else per-process feeds the pick or the turn — the fourth seeded object (record + #2), a clock read inside the AI, an address-keyed container. Pinned seeds ⇒ the autosaves still + differ. *Where* they differ localises it: the same four leaves ⇒ H2 is inside the research pick; + anything else ⇒ a per-process input outside the AI, which nobody has predicted. + +**Implementation, for whoever runs it (minutes, not a lane):** lane L1's `l1seed` shim already +detours `RNG_Seed` and sees the seed argument. Add `seed.pin=,,`: when the detour's +return address is `0x0078304b` (the instruction after `call RNG_Seed` at `0x00783046` in the +`StrategyClient` ctor), replace the seed with the next pinned value, in call order (the order is +deterministic — eight records, same order, both runs). Log the replaced and original values. The +existing post-state fields verify the pin: the three `after` states must be identical across +processes, which is the built-in control. Use process 1's own values (`0x75F692C0`, `0xF2EDAC21`, +`0x165A2ADB`) so a match against any file L1 already holds is possible in principle. + +Then: **(i)** `ref-turn2` once with the pin active — expect `978041acd168b56e…`; that turn's +decisions are stream-insensitive and the pin must not change that (rule 19 for the pin itself). +**(ii)** `turn1-state` twice, fresh processes. **(iii)** If (ii) differs in the four known leaves +only, extend the pin to record #2 (return address of whichever site seeds it — the trace has its +`this`) and rerun. That is the whole bisection. + +**(iv)** The sweep, same build: quit to menu and reload advances the pin table (`RunAI` runs again +on every load, so each reload is a fresh sample without a relaunch — ~30 s per sample on L4's +path). Ten to twenty pinned seeds, each logging: which of the three producers returned non-null +for player 512, the `aicand` line, the word cost on `client+0x134` inside `0x006c8580`, and the +pick. That is §3.1's (iv) — the first `BIO_GnMod` sample names its producer, and the word cost and +bound name k for the arm that ran. + +Predictions to commit before the build: H1 holds; same pinned seeds ⇒ same producer, same +`aicand`, same word cost, same pick, byte-identical autosave; different seeds ⇒ same `{2,12}`, +same cost, different pick; `TryResearchCandidate` costs ≥ 1 word on `client+0x134`; the +`BIO_GnMod` samples come from producer A or B, not from the walk; word positions 192 / 211 +unchanged throughout. + +### 4.1 What the probe buys beyond the answer + +- If H1 holds, `turn1-state → turn2` **with pinned seeds** is a byte oracle again — and it is the + **only corpus pair that exercises `BeginProcessTurn`'s hive draw**. The standalone gets its + creation-turn reference back. +- The same run, with L4's research instrument and a `left` region over `client+0x134` around + `TryResearchCandidate`, turns L4's inference into a measurement in one shot (§8). +- CB's C3/C4 runs on `turn1-state` become reproducible captures: seeds + stream + autosave, all + from one process, is the record RB needs. + +### 4.2 Secondary probes, in order, none of them a week + +1. **The seed's source:** one hardware write watchpoint on `0xaf6e58+4` (`mt[0]`) armed at + `Application::Initialize` entry, dropped at first `SNMRunAI`. The hit's return address is the + seeder. Static cross-check for free: scan the image for the MT init multiplier `0x6C078965` at + instruction boundaries — an inlined `init_genrand` that the six-site `RNG_Seed` scan could not + see (rule 16, exactly). +2. **The other four consumers of `g_GlobalRNG`:** entry probes (rule 20), cheapest on a battle + save — §6. +3. **Record #2's owner:** the `Seed` detour already has its `this`; one `NextInt`/`NextFloat` + detour recording `this` (lane H's own suggestion for the client generators) names its consumers. + +## 5. Rung C, restated + +Replace backlog §2's Rung C bullet and the mechanism paragraph with: + +> - **Rung C — `game/ai` as a deterministic function of (save, per-client seed).** The original's +> AI is not non-deterministic; it is MT19937 from one 32-bit word per AI client, drawn once at +> construction (on load, or at `CreateGame`) from a per-process global generator, and the +> generator is one we already own bit for bit. Two verification strengths: +> - **C-exact.** Given the seeds captured from an original run (three words, logged by the +> `RunAI`/`Seed` hook; `sots_turn --ai-seed =,…`), our AI's command block equals +> that run's block element for element and the autosave byte-matches. This holds on **every** +> captured run, because nothing else per-process reaches the turn — that "nothing else" is +> what the pin probe establishes, and it is the user's framing made literal: *make the AI +> deterministic and the old approach still pays.* Precondition: `game/ai` consumes the client +> stream in the original's order and count, including draws whose result never reaches the +> save (the surrender `Chance` at 0 %, the one-shot schedule at `agent+0x36c`, the +> `NextInt` rejection loops). A per-client draw ledger, lane Z's instrument with `this` +> recorded, is the prerequisite; the site table already shows 8 words/turn on these boards. +> - **C-set.** Where seeds were never captured — the historical `turn2-state.sav`, which no +> process including the original can reproduce — our canonical pick is a member of the +> **outcome set** of the one seed-sensitive decision: the image of the client stream under +> the resolver, named from the candidate set and the draw's bound. Lane L4 has the XNC arm +> (k = 6, four observed, all inside) and one arm unaccounted for (`BIO_GnMod`, 2 of 6 +> observations). The claim is "a member of a set we can name", as before; the mechanism is a +> draw, so the set is the support of a random pick, not a set of equal-score candidates. +> +> Mechanism, **measured** (lane L1): the per-client generator at `StrategyClient+0x134` is seeded +> from `NextUInt` on the global generator at `0x00af6e58`, which is live and per-process; the +> seed is not in any save. Only one of three empires' picks *shows* the effect because only one +> reaches the candidate walk (lane L4); the other two are stream-insensitive by code path. The +> earlier "pointer-ordered tie under ASLR" inference was wrong and is withdrawn. + +Two things Rung B keeps regardless: `--turn-commands` replay is still the no-AI path and still the +right thing for RB to build; and the canonical pair stays `turn2-state → turn3-state` until the pin +probe promotes the creation turn. + +## 6. The other two items in the report + +**`BeginProcessTurn` and the standalone.** Yes, the replay interval starts at +`BeginProcessTurn`, not `ProcessTurn`. What else assumed the drivers bound the turn: `sots_turn`'s +"walk all three drivers' phase order" (backlog §1 `app` row) has no slot for it — add a fourth +interval with `RegisterHives` (the five constants L1 read live, the two predicates, `NextInt(10)` +*with its rejection loop*) and the `TickHives` slip. Lane Z's calibration numbers for the +standalone generator (16 vs 20 / 18) were taken on turns where the interval costs 0 and are +unaffected. The pre-turn autosave precedes `BeginProcessTurn`, so any standalone that starts from a +pre-turn save must create the hives itself; only the creation turn shows it, and that turn is the +pair the pin probe restores. + +**`g_GlobalRNG`'s combat consumer.** `RunCombatRound+0x60f` draws on the same per-process +generator — most plausibly the seed for `Mars::CombatSim+0x108`. Lane Z's one instrumented battle +(turn 55, auto-resolved) cost the strategic generator 0 words, in **one** process. No battle turn +has ever been compared across two processes; lane H's `z2-endturn` seven-process agreement was on a +turn with no encounter. So **combat turns are not yet known to be cross-process reproducible**, and +this is the single most likely place the seed effect reappears. It does not touch Rung A (no battle +on the canonical pair). Backlog §5's "a battle with `res->+4` clear" workload should add: run it +twice in two processes, and if it differs, pin the global (`RNG_Seed(0xaf6e58, K)` from the shim +after `Initialize`) and rerun. + +**`hooks=off` and rule 19** — §7. + +## 7. Draft rule 25 for `method-rules.md` + +> ## 25. A control that disagrees with itself is not a control +> +> Lane L5 ran `hooks=off` once on `turn1-state → turn2`, got a third value, and read it as "the +> instrument is not the cause". Lane L1 ran `hooks=off` again and got a fourth; lane L4 a fifth. +> All three were right about the instrument — by luck. None of those runs was a control, because +> the un-instrumented game does not reproduce itself on that workload: the cause was a per-process +> seed on the AI clients, present with no hooks and invisible to all of them. +> +> Two consequences. **(a)** Rule 19 says `hooks=off` in **two** fresh processes. This is why: cite +> the agreement, not the run. If the two disagree, report "the workload is non-deterministic +> un-instrumented" as its own result, and take the control on a workload that does agree with +> itself (`ref-turn2`, `z2-endturn`) or pin the per-process input first. A control workload has to +> be *chosen*, and its self-consistency stated next to the number. +> +> **(b)** "One of N moved" is not evidence about the mechanism. A seed moves every stream; it is +> visible only where a decision depends on a draw. The coordinator inferred "tie, not seed" from +> two stable empires and one moving one; the two stable empires were on a code path that never +> reaches the draw. Count the decisions that depend on draws, not the objects that changed — and +> when the inference has been written into the roadmap, write the probe that would falsify it on +> the same line. + +Ranked by damage: the wrong inference reached backlog §2, `determinism-oracle.md`, two board rows +and two lane briefs for about a day, and was caught by a written prediction before any engine work +followed it. Below rule 24, above nothing yet — append as 25. + +## 8. Course — lane by lane + +**L4 (VM145, live).** Redirect mid-flight. Send: + +> L1's seed probe came back: every AI client's generator is seeded with a fresh per-process word +> (three different seeds per process, none shared across processes), so the varying pick is a +> **seed effect**, and the tie/arrival-order framing in your brief and in §3.2's interpretation is +> withdrawn — your own P10 falsification already pointed there. Your measured facts stand: three +> producers, only 512 reaches the walk, one candidate `{2,12}` that is a category, fallback not +> taken, k = 6 for the XNC arm. What changes is what the second capture is for. Do not chase +> order. Add a region over the **client generator at `client+0x134`** (`left` / `next_index` — +> lane Z's `RngLedger` shape) at entry and exit of `TryResearchCandidate 0x006c8580`, and if the +> `aicand` dump can carry it, the bound of any `NextInt` it makes. Then run the creation turn +> **twice with the seeds pinned** — L1's `l1seed` Seed detour, return address `0x0078304b`, three +> constants — first both processes on the same seeds (prediction: same `aicand`, same word cost, +> same pick, byte-identical autosave), then once on different seeds (prediction: same `{2,12}`, +> same cost, different pick). Then sweep: quit-to-menu and reload advances the pin table, so ten +> to twenty seeds cost ten to twenty loads, no relaunch; log **which producer returned non-null +> for 512** on each — the `BIO_GnMod` arm is almost certainly producer A or B gated by a draw, and +> its first appearance under a known seed names it. That turns "the resolution varies" into "the +> resolution draws n words with bound b, and k = b+1 for this arm; the other arm is producer X +> behind gate G". Carry k = 6 as *to prove* until the sweep has produced `BIO_GnMod` under a known +> seed. Your C-set recommendation survives as the fallback for references whose seeds were never +> logged; the exact form — `game/ai` given the seed — is now the primary Rung C, and the +> per-client draw ledger you would be starting here is its prerequisite. + +**CB (VM146).** Not a redirect; one addition to the capture format and one to the run list. The +capture must carry the **three AI seeds** (`RunAI`'s `rng_seed` per net id) next to the block set +— they are three words and they are the difference between a log file and a reproducible pair. Add +**C5 = C3 with pinned seeds, twice**; if the pin probe holds, C5's block + autosave + seeds is the +first fully reproducible creation-turn record, and RB byte-matching it is the real test of +"deterministic given the stream". P5 as written (C3/C4 differ in exactly one word) stands and is +still worth running unpinned once. + +**RB (engine).** No redirect. Add a `--ai-seed =,…` input alongside +`--turn-commands`, unused until `game/ai` exists, so the capture format and the CLI agree from the +start; nothing else changes. + +**L2 (VM141, multiplayer combat).** Untouched. One prediction to note for later, not now: the +combat-sim seed must travel host → peer in the SNM stream, because `RunCombatRound` draws it from +a per-machine generator. If L2's capture already has the message, it names the consumer for free. + +**L3 (VM144, trade/spy tail draws).** Untouched — its subject is the strategic generator, which +this finding leaves exactly where lane Z put it. + +**VM140 is free.** The pin probe goes there, on the `l1seed` build, before L4's second capture +depends on it. Whoever runs it owns rule 25's first application: two fresh processes, agreement +cited. + +**Docs to correct in place (rule 11):** `determinism-oracle.md` last bullet; +`ai-turn-logic.md` §5.1 (annotate, keep) and P1 (mark falsified, credit the falsifier row); +`ai-order-capture.md` §3.1 last two paragraphs and §3.2's "reaches first" interpretation; +`lane-ai1.json::g_GlobalRNG` prototype (drop "never seeded / every draw returns 0", keep the +consumer list, set status `mapped`); `turn1-to-turn2-nondeterminism.md` "what was not +established" (answered). + +--- + +## Proposed `campaign/board.md` rows + +New row: + +``` +| THE AI SEED IS PER-PROCESS — tie hypothesis withdrawn, Rung C restated as (save, seed) | meta | verified | high | 100% | 2026-09-08 | Resolver, on lane L1's falsified P7 (`findings/resolutions/2026-09-08-ai-seed-per-process.md`). Each AI StrategyClient's generator is seeded at construction from NextUInt on the global generator at 0x00af6e58, which is LIVE and per-process (three unrelated words per process, none shared across processes; the seed=0 record reproduces byte-identically as the built-in control). OVERTURNED: AI1's "every client seeded with 0" (row 240 second half — its own falsifier row fired; lane H's rejection-loop counter-indication was right) and the coordinator's "one-of-three varies => pointer-ordered tie under ASLR" (rows 331(5)/333, backlog §2) — all three streams move; only 512 reaches the candidate walk (L4). SURVIVES: per-client RNG, zero AI draws on the strategic generator, the ref-turn2 oracle, L5's instrument exoneration (on the identical-hooks pair, NOT on hooks=off), L4's measured facts and k=6 XNC arm. **RUNG C RESTATED: game/ai as a deterministic function of (save, per-client seed)** — C-exact (seeds captured => block and autosave byte-match on EVERY run; the user's "make it deterministic and the old approach pays", literally) and C-set (seeds lost => canonical pick is a member of the nameable outcome set of a random draw, not a tie). ONE PROBE, VM140, minutes: pin the three client seeds in the l1seed Seed detour (ret addr 0x0078304b), turn1-state twice in fresh processes; predict byte-identical, positions 192/211 unchanged; if the four leaves still differ, extend the pin to Seed record #2. Holding => turn1->turn2 with seeds is an oracle again and the ONLY corpus pair exercising BeginProcessTurn's hive draw. OPEN: the global generator has four other consumers incl. RunCombatRound+0x60f — no battle turn has ever been compared across two processes. hooks=off on turn1-state gave THREE different files across L5/L1/L4 and was never a control: rule 25 drafted (a control must agree with itself in two fresh processes; "one of N moved" says nothing about mechanism). L4 corrected mid-flight (draw ledger on client+0x134 at 0x006c8580, pinned-seed runs); CB adds seeds to the capture and a pinned C5; RB adds --ai-seed | +``` + +Edits to existing rows (the integrator's `SUPERSEDED` convention): + +- **Row 240** — prefix the notes with: `PARTLY OVERTURNED by lane L1 (resolver 2026-09-08): the per-client RNG and "zero AI draws on the strategic generator" STAND; "never seeded / seeded with 0" is FALSE — the global at 0x00af6e58 is live and per-process. Status back to mapped for the seed half.` +- **Row 255** — prefix: `SCOPED: true of ref-turn2 -> turn3 only (no draw-dependent decision that turn); superseded by rows 331 and the resolver row.` +- **Row 331** — append: `(5) WITHDRAWN 2026-09-08 — it is a seed, not a tie; see the resolver row.` +- **Row 333** — prefix: `RESTATED by the resolver 2026-09-08: the mechanism is a per-process seed, not a tie; the tie-set claim survives only as C-set (fallback for uncaptured seeds); primary Rung C is C-exact given the seed.` +- **Row 335** — append: `CB: capture format carries the three AI seeds; add C5 = C3 with pinned seeds, twice. RB: add --ai-seed.` +- **Row 62** — VM140 holder: the pin probe, before L4's second capture. diff --git a/guides/method-rules.md b/guides/method-rules.md index fb06dc8..11af517 100644 --- a/guides/method-rules.md +++ b/guides/method-rules.md @@ -331,3 +331,24 @@ diff, but it is not the protection — the pathspec on `commit` is. The deeper fix, if this recurs: give each lane its own clone of `sots-re`, the way each already gets its own `git worktree` of `sots-engine` (which is why that repo has never had this problem). Rule 21 covers the worktree side; this is its counterpart for the shared evidence repo. + +## 26. A control must agree with itself before it exonerates anything + +Rule 19 says take the measurement with the instrument removed. It did not say what to do when **the +control itself varies**, and this session that gap produced two wrong readings. + +Three lanes ran `hooks=off` on `turn1-state` and got **three different files**. So no single +`hooks=off` run on that workload was ever a control — it could not exonerate or indict anything. +Separately, one lane observed its instrumented run coming out byte-identical to a `hooks=off` run +and read that as the instrument being clean; with an outcome set of size k that agreement is a +**~1/k coincidence**, not evidence. + +So: **a control must be reproduced in two fresh processes and agree with itself before it is used as +a control.** If it does not agree, the workload is non-deterministic and the honest move is to pin +the source of variation (here: the per-client AI seed) and re-run, not to pick the run that suits. + +Corollary, from the same episode: **"one of N moved" says nothing about mechanism.** Only one of +three AI empires visibly varied, which looked like a tiebreak among near-equal candidates. In fact +all three streams differed every run; two empires never reach the seed-sensitive code path at all. +Before reasoning from *which* items varied, establish whether the others were even exposed to the +mechanism.