sots-re/findings/subsystems/turn1-to-turn2-nondeterminism.md
alex ea7881acb3 L5: the money chain's float widths, measured live at a boundary
Four float constants in ComputeBudget have a width a C++ port gets wrong by
default, and the corpus exercises the boundary of exactly one. That one is now
measured against the running game, with a control build that must fail and does.

  savings interest   (double)0.01f   boundary: treasury a multiple of 100   IN CORPUS
  debt interest      (double)0.15f   boundary: a negative treasury          not in corpus
  research yield     (double)0.85f   boundary: research money mult of 40k   not in corpus
  the three ResMod   summed in f32   boundary: two of three non-zero        not in corpus

Three constants sitting beside those, in the same expressions, are EXACT doubles
and must not be "corrected" -- so "widen every literal" is its own defect and the
only safe procedure is to read each operand. Same pattern elsewhere in the chain:
ComputeOutputFromRates multiplies by an exact 1.5 then a widened 1.2f two
instructions later, and NormaliseOutputRates holds 1e-4 at BOTH widths.

Live result (VM146, turn1-state.sav, three runs): the game pays 499 interest on a
treasury of 50,000 and 380 on 38,100; the exact decimals pay 500 and 381. Every
divergence in the control build lands on a multiple of 100 and nothing else
diverges at all. Coverage stated as distinct states, not calls: 5 distinct
treasuries, 2 on the boundary -- against the earlier green run's 20 distinct
states, none on a boundary.

BANKRUPTCY_PROTECTION_LIMIT_FACTOR read live: 3.29999995 = (float)3.3. Its file
image is zero because the loader fills it at run time, so lane PL-3 could read the
width and had to assume the value. The assumption was right and is now measured.

Falsified: the difficulty-mods record does not sit inline at ServerPlayer+0x36c;
that field is a heap pointer on all eight players. Recorded as a hypothesis with
the measurement, not as a fact.

Second finding, from the rule-19 control: turn1-state -> turn2 is NOT a
deterministic pair. Three runs gave three post-turn autosaves differing in exactly
four leaves -- one Singularity shadow empire's research pick and the derived
checksum. Two of the three runs carried identical hooks, and the un-instrumented
run was a third value, so this is the game and not the instrument. The
determinism oracle stands for ref-turn2 -> turn3 and does not generalise to its
neighbour; no lane should use this pair as a byte-match oracle.
2026-09-08 17:49:35 -04:00

2.9 KiB

turn1-state → turn2 is not a deterministic pair

Lane L5, 2026-09-08, VM146. A by-product of a rule-19 control, and a caution for any lane that reaches for this pair as an oracle.

What was measured

Three End Turns from the same input save, through the identical UI path, in three freshly launched processes:

run shim (Autosave).sav sha256[0:16] Player[512 "Singularity"]/ResTNm
control hooks=off (no MinHook at all) 62061918176e3441 XNC_TrnsLir2
A ComputeBudget=compare 7fc9e6ab46b47794 XNC_TrnsHvr2
B same hooks, different arithmetic on OUR side only ab4ac2d7e2977260 BIO_GnMod

Every pairwise diff is exactly four leaves: that research-target name, two of that player's tech-tree status words, and the derived /Summary/Checksum. Nothing else in the state moves.

Why this is not the instrument

  • Runs A and B carry identical hooks. They differ only in a constant on our side of the compare, and compare mode runs ours on scratch copies and never lets it touch game memory. A hook-induced perturbation cannot produce A ≠ B.
  • The hooks=off control is a third value, not one of the other two.
  • Run B reproduced ab4ac2d7e2977260…, which is the hash running-the-game.md records for the original live session's Autosave - turn2.sav. So the un-instrumented behaviour is inside the observed spread.
  • All three runs wrote a byte-identical pre-turn autosave (a3f9dc4b49fc669c…). The loader and the writer are deterministic; only the turn's AI decision is not.

Why it matters

determinism-oracle.md established byte-reproducibility on ref-turn2 → turn3 — five runs, two processes, one pair of hashes. That result is sound and is not contradicted here. What is new is that it does not generalise: the neighbouring pair in the same game is unstable, in one shadow empire's AI research pick.

Consequences:

  • Do not use turn1-state → turn2 as a byte-match oracle. Use ref-turn2 → turn3.
  • A standalone comparison against a turn1-state → turn2 post-save is comparing against one draw from a distribution. The four unstable leaves are ResTNm and two St[n] words on Player[512], plus Checksum.
  • This corroborates lane PL's placement of ResTNm / ResErrRoll on Rung B for the Singularity shadow empires: those leaves are not merely AI-dependent, they are not reproducible, so no amount of modelling the AI's rules will close them without also reproducing whatever the pick is actually keyed on.

What was not established

The cause. The candidates are the shadow empires' ResErrRoll (a research error roll that lane PL saw flip False → True, i.e. an RNG consumer on exactly this player class) and anything in the AI path keyed on wall-clock time or frame count rather than on the saved generator. Distinguishing them is a probe on the pick site, not another End Turn.