sots-engine/docs/PAR-predictions.md
alex e1995e6b11 PAR: roll parity -- the AI's per-turn RNG word count is NOT fixed, at any scope
New shim module `ai_rng`: a bracket on StrategyClient::OnResumePlaying over the per-client
generator at +0x134, with two independent measurements per bracket (an observer on the existing
seven draw-site detours, and `left` read straight off the object) plus AI-call-site attribution
through the cl_* facades and an optional seed pin.

Measured on VM140/VM145, six runs, five fresh processes, unpinned autosaves byte-identical to the
published oracle:

  turn2->turn3   human 0, AI 32 -> 3 words, AI 496 -> 0, AI 512 -> 0
  turn1->turn2   human 0, AI 32 -> 7 words, AI 496 -> 0, AI 512 -> 1 word ... or 3

The last row is the answer: 512 makes ONE cl_RandRange call (the research-target tie-break at
0x006a8495, phase 18) and that one call cost 1 word in one process and 3 in another, because
RNG_NextInt is an unbounded rejection loop. The count is not fixed across clients, across turns,
or across processes with the path held fixed.

Also found: cl_RandFloat 0x00579c70, a third cl_* RNG facade that reaches RNG_NextFloat by a TAIL
JUMP and so leaves no rel32 edge for a call-graph sweep -- which is why ai-turn-logic.md 5's "zero
NextFloat calls from the AI module" reads as true when it is not.

The instrument caught its own defect: RNG_Chance calls RNG_NextFloat and both are detoured, so a
drawn word was reported twice. left_delta was never affected, which is the point of having two
measurements.

Gates run separately on a fresh build directory: clean_room_check OK, host ctest 55/55, CT111 shim
cross-build OK.
2026-09-08 19:32:19 -04:00

115 lines
6.8 KiB
Markdown
Raw 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 PAR — predictions, written before the build
Question, as redirected by the coordinator: **is there *roll* parity?** Does one run of an AI client
consume a **fixed number of RNG words regardless of the path it takes** — the classic lockstep
discipline of drawing unconditionally so peers stay aligned?
Instrument: a bracket on `StrategyClient::OnResumePlaying 0x00777480` (the whole AI turn runs inside
it), measuring the per-client generator at `StrategyClient+0x134` two ways — the observer sum over
the seven hooked entry points, and the raw `left` delta on the object — plus per-return-address
attribution inside the bracket. Optional `airng.pin_seed` re-seeds that generator at bracket entry
so two runs start from an identical state and any remaining difference is *path*, not seed.
All predictions below are written before the instrument was built and before any run.
---
## P1 — the count is **not** constant across clients on the same turn
On `turn2-state.sav` there are three AI clients: **32** (owns a homeworld, fleets, a build queue) and
**496** / **512** (two Singularity shadow empires that own nothing at all — `Sav = 0`, no colonies,
no fleets, no systems, per `ai-order-emission.md` §2).
**Prediction:** client 32's bracket consumes **strictly more** words than 496's and 512's, and 496
and 512 consume the **same** count as each other.
*Falsified if:* all three consume the same count ⇒ the AI really does draw unconditionally and the
lockstep hypothesis survives its first and cheapest test. That would be the strong result.
*Also falsified, differently, if:* 496 and 512 differ from each other ⇒ the count depends on
something finer than "owns nothing", and the property is not even constant across identical boards.
## P2 — the human client's bracket is exactly 0 words
`OnResumePlaying` runs for the human client too; the AI branch is `if (this->+0x12c)`.
**Prediction:** the human client (pid 16) shows `agent=0` and **0 words**.
*Falsified if:* it draws ⇒ there is client-side RNG on the human path and the bracket is not
measuring what I think it is.
## P3 — the surrender roll costs **0** words, not 1
`RNG_Chance 0x008e6dd0` is documented `verified` as returning at `p <= 0` and `p >= 1` **without a
draw**. Process Turn phase 12 is `cl_Chance` on the surrender probability, which is 0 on a healthy
empire.
**Prediction:** no bracket on the reference board attributes a word to phase 12's return address.
*Falsified if:* a `Chance` row appears with `words > 0` ⇒ the surrender probability is non-zero on
this board, which is itself interesting.
*Why it matters:* the resolver's list of "draws whose result never reaches the save" names "the
surrender `Chance` at 0 %". If `Chance` early-outs, that entry is **wrong** and the ~8 words/turn
figure needs re-deriving. This is a rule-11 correction if it lands.
## P4 — the count is seed-dependent even when the path is fixed
`RNG_NextInt 0x004271c0` is documented `verified` as a **rejection loop**: mask = smallest `2^k-1 >=
n`, redraw while `(y & mask) > n`. So a `NextInt` whose bound is not `2^k-1` costs 1 word with
probability `(n+1)/(mask+1)` and more otherwise.
**Prediction:** with the *same save and the same path*, pinning two different seeds produces two
different bracket totals for at least one client, differing by a small number of words.
*Falsified if:* the total is invariant under the pinned seed ⇒ either no `NextInt` with a
non-power-of-two bound runs on this path, or `NextInt`'s body is not what the header says.
*Why it matters:* it separates the two things that could be meant by "fixed count". Seed-dependence
is **harmless** for a reimplementation (we reproduce the rejection loop bit-for-bit).
Path-dependence is the fatal one. P1 and P4 must be read together: only P1 tests the hypothesis.
## P5 — the one-shot schedule draw is **outside** this bracket
`0x0069dbb0` (`if (agent->+0x36c == 0) sched = turn + 3 + NextInt(0..37)`, two `NextInt` sites at
`0x0069dbfd`/`0x0069dc29`) sits in the **Prepare Turn** body, and `SEAIPrepareTurn` is raised only at
agent construction (`RunAI` / `CreateGame`), never per turn.
**Prediction:** those two return addresses never appear in an `OnResumePlaying` bracket; they fire
once per agent per process, on load. So the *load* consumes words that no turn does, and a
per-turn count that ignored the load would be wrong about the generator's position.
*Falsified if:* they appear in a bracket ⇒ Prepare Turn is reachable per-turn after all and
`ai-turn-logic.md` §1's "not a per-turn event" is wrong.
## P6 — the residual: `left`-delta will exceed the observer sum on at least one bracket
Rule 16: inlined draws are invisible to a call-graph sweep, and the seven entry-point detours are a
call-graph sweep by another name. The bracket also reads `left` on the object directly.
**Prediction:** for the AI clients the two agree exactly (lane I's inlined-draw scan found no hit in
the AI band 0x00680000–0x006e0000), and the instrument prints the residual on every bracket so a
disagreement cannot hide.
*Falsified if:* a residual appears ⇒ there is an inlined draw on the AI path that the campaign's
draw-site inventory does not have, and the whole per-site table is a lower bound.
## P7 — the overall verdict, predicted
Static reading already shows at least three genuinely conditional constructs on the AI path
(`Chance`'s two early-outs, `NextInt`'s rejection loop, the `+0x36c` one-shot guard) and the task
system is a *variable-length list* dispatched twice.
**Prediction: the answer is (d) genuinely state-dependent, not (a)/(b)/(c)** — but with a useful
qualification: the *variation is small and localised*, so the engine's problem is not "the count is
unpredictable" but "the count is a function of the decisions", i.e. `game/ai` must get the decisions
right after all, and C-exact does **not** get cheaper.
*Falsified if:* P1 comes back with all three clients equal.
---
## What would make me wrong in a way I would not notice
* **Rule 26.** A single run is not a control. Every configuration below is run in **two fresh
processes** and must agree with itself before it is used. The canonical pair is
`turn2-state → turn3-state`; `turn1-state` is known non-deterministic and is used only *with*
pinned seeds.
* **Rule 19.** The bracket detour is a MinHook detour on a function that runs the AI turn. If it
perturbs, the autosave moves. The instrumented run's autosave is compared against the published
oracle (`bb4fd9ac…` / `978041ac…`) on the unpinned configuration; a pinned run is *expected* to
differ and is never compared against the oracle.
* **Rule 20.** A bracket that shows 0 words for a client cannot distinguish "the client did not run"
from "it ran and drew nothing". The bracket therefore records entry/exit unconditionally and
prints a row even when the delta is 0, and records whether `agent != 0`.