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

6.8 KiB
Raw Blame History

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.