sots-re/findings/subsystems/ai-client-seed-is-per-process.md
alex 4f805e381a L1: CDiff crosses at frame 50, and the AI client seed is fresh in every process
Two results from the same VM session, both of which needed a workload the
corpus cannot supply.

CDiff, played forward. ref-turn2 loaded and 49 End Turns driven through the UI
helper to frame 51, with the entry hook emitting one record per turn:

  50 calls, frames 2..51, EXACTLY TWO STORES
    frame  2  -1 -> 0
    frame 50   0 -> 1     <- the modelled tier transition, at the modelled frame
  and 47 turns between them on which the writer ran and wrote NOTHING

predict_path was computed at entry from the transcribed threshold table on all
fifty turns and agreed with the cdiff region on every one. Frame >= 100 is still
a code read and is not claimed as a measurement.

The AI seed probe (asked for by the coordinator, ranked above CDiff). Hooks on
Mars::RNG::Seed and StrategyApp::RunAI, two launches from turn1-state, load
only. Every AI client seed differs between processes -- net 32, 496 and 512 all
move -- while the record structure is identical and one Seed call with seed=0
produces a byte-identical state in both runs.

So the turn1-state -> turn2 nondeterminism is a SEED effect, not the ordering
effect predicted, and the 'one of three varies' observation is explained by two
of the three empires having a research pick that is robust to the stream. This
falsifies lane AI1's 'every draw from the static generator returns 0', which
that lane had explicitly flagged as arithmetic rather than measurement.

Where the seed comes from is NOT established; the finding names the one hook
that would settle it and the six values it must reproduce.

VM140 left as found: the 8-file save set with its oracle bytes intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 18:23:19 -04:00

113 lines
7 KiB
Markdown

# The AI client seed is fresh in every process — the `turn1-state → turn2` variation is a seed effect
- **Type:** subsystem (live measurement)
- **Status:** **verified** — the same save loaded in two freshly launched processes, with the seeding
primitive itself hooked
- **Confidence:** high on the fact (every AI seed differs, structure otherwise identical); the
*source* of the seed is **not** identified — §4
- **Owner / date:** lane L1 · 2026-09-08 · VM140
- **Instrument:** `sots-engine` `wip/l1`, `Mars::RNG::Seed` + `Game::StrategyApp::RunAI` hooks,
config `shim.cfg.l1seed`, build `l1seed-20260908T2206Z`
- **Answers:** the probe requested against
`findings/subsystems/turn1-to-turn2-nondeterminism.md` (lane L5) —
*"is the varying research pick a seed effect or an ordering effect?"*
- **Corrects:** `ghidra/addresses.d/lane-ai1.json` — `g_UnseededStaticRNG`'s "every draw returns 0"
---
## 0. The answer, and it is not the one that was predicted
**Seed effect.** Every AI client's generator is seeded with a **different value in every process**.
Two launches, same input (`l1-turn1.sav` = `verify/results/saves/turn1-state.sav`), loaded only — no
End Turn, because `StrategyApp::RunAI` and the client constructor run on load:
| `RunAI` call | player net id | process 1 `rng_seed` | process 2 `rng_seed` |
|---|---|---|---|
| 1 | 32 | `0x75F692C0` | `0x414F415E` |
| 2 | 496 | `0xF2EDAC21` | `0x10B94E78` |
| 3 | **512** (the empire lane L5 saw vary) | `0x165A2ADB` | `0xC218DBF8` |
Each value reappears one record earlier as the `seed` argument of an `Mars::RNG::Seed` call on a
fresh object, which is the `StrategyClient+0x134` generator being constructed. **Both my prediction
and the coordinator's expectation were that these would be identical.** They are not.
**The structure is identical, which is what makes the difference legible.** Both processes emit
exactly 8 records in exactly the same order — four `Seed` calls interleaved with three `RunAI` calls,
same net ids, same `personality = 0`, same `ai_data = "SavedGames/l1-turn1.sav"`. Nothing about
*which* objects are seeded or *when* varies. Only the values do.
**And one of the four seeds is deterministic**, which rules out "the hook perturbs it" and gives the
comparison a built-in control: the first `Seed` call takes `seed = 0x00000000` in both processes and
produces a byte-identical state (`mt0 = 2443250962`, `mt1 = 1093594115`, `mt2 = 1878467924`,
`left = 624`). A run in which the instrument randomised things would not have left that one alone.
## 1. Why only one of three empires' picks moves
Lane L5's puzzle was *"a time-seeded per-client RNG would move all three"*. That inference does not
hold, and the data says why: **all three seeds do move.** A different seed only becomes visible in the
save where the decision it feeds actually depends on the draw. Two of the three AI players evidently
have a unique best research candidate — a different random stream picks the same one — and the third
does not.
So the observation "one of three varies" is not evidence for an ordering effect at all. It is
evidence that **two of the three decisions are robust to the stream and one is not**, which is a
statement about the candidate sets and not about the generator.
## 2. What this settles for the campaign
* **The `turn1-state → turn2` pair is unfixable as a byte oracle by any amount of care with the
instrument.** The input to the varying decision is not in the save, is not the strategic generator,
and is fresh per process. `determinism-oracle.md` is now scoped to `ref-turn2 → turn 3`.
* **A standalone can never match a per-process seed.** For anything downstream of an AI client's
generator, the reachable target is *a* legal outcome, not *the* recorded one. Which leaves is
exactly what lane PL put on Rung B for these shadow empires — the corroboration is now mechanical
rather than statistical.
* **The strategic generator is untouched by this.** Lane L1's hive run reproduced the historical word
positions and `NextQ` values exactly (`hive-creation-rng.md` §2.2). Two generators, two different
determinism stories, and only one of them is in the save.
## 3. Correction to `lane-ai1.json`
`g_UnseededStaticRNG`'s entry reads:
> *its only static initialiser writes the `Mars::IStreamable` vftable, NOT the `Mars::RNG` vftable
> that `RNG_Seed` installs: none of the six `RNG_Seed` call sites in the image targets it, so its
> `mt[624]` is the zero-initialised BSS array and `left` is 0. An all-zero MT19937 state is a fixed
> point of the twist, so **EVERY draw from it returns 0**. Five consumers: SNMRunAI (the AI client
> seed, OnMessage+0x955) …*
The arithmetic is right and the conclusion is **falsified by measurement**: the seed `SNMRunAI` hands
to `RunAI` is neither 0 nor constant. That entry was explicitly flagged by its own author as "the
all-zero fixed point is arithmetic, not measurement", with a prediction attached — this is that
prediction coming back negative, which is the best possible outcome for having written it down.
Either the object `OnMessage+0x955` draws from is not the one AI1 identified, or that object is
seeded by a path outside the six `RNG_Seed` call sites. §4 says how to tell.
## 4. What is NOT established, and the cheapest next step
* **Where the seed comes from.** This lane hooked `RNG_Seed` and `RunAI`; neither says what fed
`RunAI`'s fourth argument. The candidates are a time source (`timeGetTime`/`GetTickCount`, the
usual suspects for a per-process value) and a generator seeded from one.
**The next probe is one hook**: `StrategyNetworkClient::OnMessage` around `+0x955`, or simply a
breakpoint-style read of whatever object that site draws from, in the same two-process shape. The
seeds recorded here (`0x75F692C0` / `0x414F415E` …) are the values it must reproduce.
* **Whether the seed varies within one process.** Both runs seed three clients with three different
values, so *something* advances between them — consistent with successive draws from one generator
and equally with three reads of a clock.
* **Whether any of this reaches the strategic generator.** It does not on the measured turns
(`hive-creation-rng.md` §6.2), but that is two turns of one save.
* **The other four `Seed` calls' owners.** Only the three that immediately precede a `RunAI` are
identified. The `seed = 0` one is unidentified and is the only deterministic one, which makes it
the most interesting of the rest.
* **`is_strategic_generator` reads `false` on every record** in these runs — correctly, but only
because this config installs no turn-driver hook, so the shim never learned the server pointer.
It is not evidence that the strategic generator is never seeded here.
## 5. Files
- `verify/results/shim/l1/trace-seed-proc1.jsonl`, `trace-seed-proc2.jsonl` — the two traces
- `verify/results/shim/l1/seed-two-processes.txt` — both rendered side by side
- `verify/results/shim/l1/seedrep.py` — the reader
- Prediction, committed before the build: `sots-engine/docs/L1-predictions.md` §P7