sots-re/verify/results/compare/l5-budget-boundary.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

3.3 KiB

L5 — ComputeBudget compare at the interest boundary, with a failing control

VM146, 2026-09-08. Input save turn1-state.sav (= Autosave EndTurn - turn2.sav, 64,967 B, sha256 a3f9dc4b49fc669c…). Identical UI path for all three runs.

Runs

run build hooks ComputeBudget dll md5
control l5A-widened off not installed F8DFC4AEE31FA60C5A2BE1E70CC38633
A l5A-widened trace compare F8DFC4AEE31FA60C5A2BE1E70CC38633
B l5B-decimal-CONTROL trace compare 0FCC19DC2B9A769DB5FE2C4DC4683C78

A and B are the same source except kSavingsInterestRate / kDebtInterestRate: widened floats in A, exact decimals in B. That is our side of the compare only.

tracecmp

A   calls 3895   compared 3895   diverged    0   invalid 0   undeclared writes 0
B   calls 2718   compared 2718   diverged 1359   invalid 0   undeclared writes 0

Coverage — distinct states, not call counts

treasury (Sav) multiple of 100 calls (A) calls (B) diverged (B) game savingsInterest exact decimal
0 — 303 303 0 0 0
38,100 yes 1 1 1 380 381
50,000 yes 1,371 1,358 1,358 499 500
92,651 no 28 28 0 926 926
289,688 no 2,192 1,028 0 2,896 2,896

5 distinct treasury states, 2 on the rounding boundary. Compare with the run this replaces: 4,437 calls, 20 distinct states, none on a boundary.

projected split in A: 3,888 turn-path, 7 UI.

Diverging fields in B (per call): savingsInterest 1,359, available 1,359, researchMoney 1,317. Sample diff at Sav = 50,000:

side.budget.after.v.savingsInterest   orig 499     ours 500
side.budget.after.v.available         orig 239091  ours 239092
side.budget.after.v.researchMoney     orig 59772   ours 59773

Live constants, read on every one of A's 3,895 records

savingsInterestRate   0.009999999776482582   = (double)0.01f
debtInterestRate      0.15000000596046448    = (double)0.15f
researchYieldFactor   0.8500000238418579     = (double)0.85f
protectionFactor      3.29999995             = (float)3.3   [slot non-null; file image is zero]

Live per-player flags (all 8 players of turn1-state)

PlyrIdx isAI npc ownedSystems aidf
0 false false 1 1
1 true false 1 1
2 true true 0 1
3 true true 0 1
4 true true 0 1
5 true true 0 1
6 true true 0 1
7 true true 1 1

Exactly one AI-and-not-NPC player; the human is neither; player 7 owns a system and is an NPC, which is why T31 calls it ambiguous.

researchMoney: 9 distinct values across A (0, 24717, 59772, 59922, 60521, 68047, 217752, 218204, 218508). None is a multiple of 40,000, so the research-yield-factor width is unexercised.

Autosaves

run (Autosave EndTurn).sav (Autosave).sav
control a3f9dc4b49fc669c… 62061918176e3441…
A a3f9dc4b49fc669c… 7fc9e6ab46b47794…
B a3f9dc4b49fc669c… ab4ac2d7e2977260…

Pre-turn identical in all three. Post-turn differs by exactly four leaves per pair, all in one shadow empire's AI research pick — not the hook; see findings/subsystems/turn1-to-turn2-nondeterminism.md.