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

55 lines
2.9 KiB
Markdown

# `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.