From c471a5515f19ea0d16c1d1bcb9375cb31774cc10 Mon Sep 17 00:00:00 2001 From: lane Y Date: Tue, 8 Sep 2026 11:39:53 -0400 Subject: [PATCH] lane Y: the standalone's generator against lane Z's calibrated oracle pairs, and the tail's turn record The standalone now models the turn's dominant generator cost -- 16 of a measured 18-22 words -- and lands 4 and 2 short of the two calibrated pairs, which is exactly the per-call-site ledger's split for those turns. The state block is byte-identical; only left differs. The answer to 'does it match the oracle' is no, by a stated amount, and tools/rng_oracle_check.py is the instrument that says so. The tail's last phase is modelled for the six turn-record fields recoverable from the wire and checked against the record the game itself archived: 480 fields over 80 player-records, 0 mismatches. It stays blocked; --commit-blocked shows exactly which five fields are missing and what they cost. By-product, and probably worth more than the phase: the stored bankruptcy elimination limit is injective in the maximum-income sum it is built from, so every save states the per-system output term that blocks ComputeBudget. tools/max_income_oracle.py inverts it -- 25 player-records over the corpus -- and recovers the protection factor as 3.3 from the saves rather than from the data files. It also shows the engine's -0.15 divisor disagrees with the game on 6 of those 25. divergence unchanged: 209->204 and 108->103, 5 closed / 0 regressed on both pairs. --- .../standalone-generator-and-turn-record.md | 296 ++++++++ tools/max_income_oracle.py | 149 ++++ tools/rng_oracle_check.py | 213 ++++++ tools/standalone_report.py | 1 + .../results/standalone/max-income-oracle.json | 689 ++++++++++++++++++ verify/results/standalone/report.txt | 6 +- verify/results/standalone/rng-oracle.json | 51 ++ verify/results/standalone/rng-oracle.txt | 23 + verify/results/standalone/status.json | 134 ++-- 9 files changed, 1504 insertions(+), 58 deletions(-) create mode 100644 findings/control-flow/standalone-generator-and-turn-record.md create mode 100644 tools/max_income_oracle.py create mode 100644 tools/rng_oracle_check.py create mode 100644 verify/results/standalone/max-income-oracle.json create mode 100644 verify/results/standalone/rng-oracle.json create mode 100644 verify/results/standalone/rng-oracle.txt diff --git a/findings/control-flow/standalone-generator-and-turn-record.md b/findings/control-flow/standalone-generator-and-turn-record.md new file mode 100644 index 0000000..0716376 --- /dev/null +++ b/findings/control-flow/standalone-generator-and-turn-record.md @@ -0,0 +1,296 @@ +# The standalone's generator, measured against the oracle — and the tail's turn record + +Lane Y, 2026-09-08. Host work, no VM held. Engine worktree `wip/yield` +(`sots-engine/docs/Y-standalone-rng-and-tail.md` carries the predictions, committed before the +build). Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs. + +**The headline, and it is binary. The standalone's generator does NOT land on the oracle's state.** +It lands **4 words short** on lane Z's turn-4→5 pair and **2 words short** on the turn-5→6 pair, and +those two numbers are exactly the per-call-site ledger's split for those turns. The 624-word state +block is byte-identical in both cases (no boundary is crossed); only `left` differs, 379 against 375 +and 359 against 357. + +| calibrated pair | oracle words | standalone | residual | `left` ours / oracle | state matches | +|---|---|---|---|---|---| +| `z2-endturn` → `z2-autosave` (turn 4→5) | 20 | **16** | **4** | 379 / 375 | **no** | +| `z-t6-endturn` → `z-t6-autosave` (turn 5→6) | 18 | **16** | **2** | 359 / 357 | **no** | + +Tool: `tools/rng_oracle_check.py`; output kept at `verify/results/standalone/rng-oracle.{txt,json}`. + +--- + +## 1. What is now modelled: the trade-raid block, 16 words + +Lane Z established that `ServerTradeManager::GenerateTradeRaidEncounters` 0x00893290 loops the +server's player vector and rolls two `Mars::RNG::Chance` calls per player, at 0x00893426 +(`TRADE_RAID_ODDS_PLAYER`, 0.2f) and 0x00893513 (`TRADE_RAID_ODDS_NPC`, 0.05f), with **no back edge +containing either site**. That is what makes it modellable by a lane holding no game: the cost is a +**bound**, not a fitted observation, and it does not depend on the probabilities — both are strictly +inside (0, 1), so `Chance` takes neither early-out and spends exactly one word. + +The player vector's size is on the wire: it is the `NumPlrs`-counted array, **8** on the Human saves +and 7 on the Zuul ones. `sots-engine/src/app/trade_raid.{h,cpp}` implements the loop, and +`sots-engine/tests/app/test_trade_raid.cpp` pins the count against a scripted generator. + +**The word cost is counted, not assumed.** The implementation increments its counter *inside* the +chance helper, past the two early-outs, rather than once per call. That matters because the three +odds are game-data constants: a data file that pushes one of them to 0 or 1 removes the **draw**, not +just the outcome, and a count-the-calls implementation would be silently one word per player out. +One test case pins exactly that. + +### 1.1 The 16 is now confirmed by a third instrument + +Lane Z's 16 came from two instruments that both watch the running game (a boundary hook and a set of +return-address detours). Subtracting the standalone's structurally-bounded 16 from the **file** +oracle's 20 and 18 leaves **4 and 2** — which is precisely what the per-site table predicts for those +two turns (2 detection words on both, plus 2 research words on turn 5 and 0 on turn 6). So the +trade-raid count is now corroborated by a route that touches no hook at all: file bytes minus a +static bound. + +This is weaker than it looks in one specific way, and it is worth saying: the residual is a +*subtraction*, so it confirms the **sum** 16 + (detection + research), not each term separately. If +the trade-raid block cost 15 and something unmodelled cost 5, the same residual would appear. What +rules that out is the static bound, not this arithmetic. + +## 2. What is not modelled, named rather than netted off + +`TurnResult` now carries `rngUnaccounted` next to `rngWords`, and the report prints them as two +separate facts. The same convention as `closed` / `regressed`, for the same reason. + +| site | owner | cost | why it is not modelled | +|---|---|---|---| +| 0x0050329d | ← `DetectEncounters`, depth 4 | 1/turn on 3 of 3 measured turns | no derived rule; lane I bounds the containing family at `\|contacts\| × \|detectors\|`. Fitting 2/turn to three observations is not a model | +| 0x007929a4 | ← `DetectEncounters`, depth 3 | 1/turn on 3 of 3 | same | +| 0x00587888 | `TechTree::ProcessResearch`+0x1c8 | 0–1 | downstream of the research allocation, which is downstream of `ComputeBudget`, which is blocked on the population → base-output term | +| 0x0088df4f | `ServerPlayer::RollResearchEvent`+0x2f | 0–1 | the standalone's phase P10 holds the gate but the *threshold* reads research progress after the same blocked allocation, so it evaluates false where the game evaluates true | +| 0x008939ee | `ServerTradeManager::CreateRaidEncounter` | 0 or 1 per **successful** roll | undetermined; no roll succeeded on any measured turn | + +**The last row is the one that will bite.** Because the standalone skips the two research draws that +happen *earlier* in the turn than the trade-raid block, its raid rolls consume the wrong words, so +its hit decisions are not the game's. The run reports the hit count and says explicitly that it is +not a prediction. On the two calibrated pairs no roll succeeded in our stream either, so no +target-selection draw was reached; that is luck, not a property. + +**Ordering, stated for the record.** The generator state a save carries encodes only the *count*, so +the residual above is order-independent. Order still matters for two things: the *values* drawn (and +hence every decision taken from them), and the word cost of any bounded-integer draw whose rejection +loop can run more than once. Getting the count right is necessary, not sufficient. + +## 3. `--commit-rng` now has something to commit — and still tells the truth + +Lane S2 left the write-back unimplemented on the reasoning that an advanced-but-incomplete state is +wrong differently from an untouched one. That reasoning is satisfied only for the accounted words, so +the flag discipline is kept and the run now prints, every time: + +* the words consumed, and whether they were written back; +* the unaccounted sites, as a list, never subtracted from the total; +* a standing warning that the committed state is a **lower bound** and that its drawn values are not + the game's. + +By default the generator is still untouched, and `tests/app/test_turn.cpp` still asserts that. + +--- + +## 4. The tail's last phase — the turn record, and it is testable without a game + +Lane K read `StrategyServer::FinalizeTurnRecords` (tail phase 36, `FUN_0078a0e0`, 416 B) field by +field and noted that it also runs on load, so it is "testable without a turn". It is better than +that: **the archive it writes into is on the wire.** It is `/Sim/turnstats/nply[]/hist/stats[]` — +`PlayerTurnStats` in the engine's schema — one element per turn per player, and **every save carries +an element for its own frame**. So the phase can be checked against bytes the original produced. + +### 4.1 Six fields, recovered and confirmed on 80 player-records + +| record field (wire) | lane K's offset | rule | status | +|---|---|---|---| +| `trn` | — | the frame counter | confirmed | +| `col` | `+0x18` (int16 in memory) | the number of owned-system ids | confirmed | +| `sav` | `+0x14` | the player's savings | confirmed | +| `inc` | `+0x0c` | savings **minus previous-turn savings** | confirmed | +| `tch` | `+0x28` (int16) | tech-tree states equal to 4 | confirmed | +| `pop` | `+0x20`/`+0x24` (int64) | Σ over owned systems of `Pop + pbon` | confirmed | + +`sots-engine/tests/app/test_turn_record.cpp` runs this over `$SOTS_SAVES_DIR`: **11 saves, 80 +player-records, 480 fields compared, 0 mismatches, 0 dangling owned-system ids.** The standalone also +runs the same check *inside every turn* and prints it as a phase note, so a future schema change that +breaks the mapping announces itself on the next run rather than at the next audit. + +**Two corrections to `combat-done-tail.md` §5, both from the corpus.** + +1. **`+0x0c` is not "net savings change" in the sense of a budget line** — it is literally + `Sav − PvSav`, and `PvSav` is a wire field stamped before this turn's savings are written. The + distinction matters because it makes the field computable from the save alone. +2. **The wire names `inc` and `tdinc` are the opposite way round from the obvious reading.** `inc` + is `+0x0c` (the savings delta) and `tdinc` is `+0x10` (the trade income lane T found). Reading + `tdinc` as the turn's delta income and `inc` as a general income would put both fields on the + wrong offsets. + +**And one term lane K's reading left open is now pinned.** `Σ (sys->+0x194 + sys->+0x18c)` maps to +the wire pair `Pop + pbon` — the live population plus the pending population bonus. The competing +candidate, `Pop + dcs`, matched **101 of 112** player-records and was rejected; `Pop + pbon` matched +**112 of 112**. Neither `Pop` alone nor `PvPop` alone matches: `PvPop` fits the Human saves and fails +the Zuul ones, and `Pop` does the reverse. A one-save check would have concluded either. + +### 4.2 Five fields it cannot fill, and what each is blocked on + +| field | blocked on | +|---|---| +| `almem` (`+0x08`) | the alliance/vision mask rebuilt by `ProcessTurn` phase 4 — a stub | +| `tdinc` (`+0x10`) | trade income from `ComputeBudget` — blocked behind the per-system money output | +| `bat` (`+0x1a`) | the tail's battle tally, phase 4 — a stub | +| `sacq` / `slost` | counted lists, **count 0 in every save in the corpus** — rule 6 applies: the element meaning is unobserved, not known | +| the per-hull-class census (`+0x2a`..`+0x34`) | each design's hull size and its `0x400` class flag; neither is on the wire, both come from the game data | + +### 4.3 The block is a measurement now, not an argument + +The phase builds the record and does **not** commit it. `--commit-blocked` writes it anyway, so the +claim can be checked. On `turn1-state` → `turn2-state`: + +| run | closed | regressed | +|---|---|---| +| default | **5** | **0** | +| `--commit-blocked` | 29 | 17 | + +and every one of the 17 is in the unmodelled set: `almem` ×8, `inc` ×3, `sav` ×3, and three leaves of +the ship census. Nothing else regresses. On `turn2` → `turn3` the same run is 13 closed / 19 +regressed (17 turnstats + the two `Status` leaves lane S2 already knew about). + +So the honest statement is **not** "committing this makes everything worse" — on the first pair it is +net +7 for the turnstats subsystem and on the second it is net −9. It is: *the record is right in six +fields and wrong in five, and which way the net falls depends on the save*. That is precisely what +netting `closed` and `regressed` into one number would have hidden, and it is why the phase stays +blocked until `ProcessTurn` phase 4 and `ComputeBudget` land. **When they do, phase 36 closes 24 +leaves on the reference pair with nothing left over.** + +--- + +## 5. A by-product worth more than the phase: `BnkEl` inverts to the blocked input + +`ServerPlayer::UpdateBankruptcyLimits` computes +`BnkEl = max(trunc(maxIncome / -0.15000000596046448), -2000000000)` where +`maxIncome = Σ over owned systems of max(ComputeMaxIncome(s), 0)` — the *same* per-system money +output that blocks `ComputeBudget`, phase P01, phase P02 and half the turn record. + +The map from `maxIncome` to `BnkEl` has a slope of about 6.67, so **it is injective and invertible**: +each stored `BnkEl` has at most one integer preimage. Inverted over the corpus: + +| save | player | `BnkEl` | recovered `maxIncome` | `BnkPr / maxIncome` | +|---|---|---|---|---| +| turn1-state | 16 | −1590613 | **238592** | 3.299997485 | +| turn2-state | 16 | −1594593 | **239189** | 3.299997073 | +| turn3-state | 16 | −1598566 | **239785** | 3.299997915 | +| turn1-state | 32 | −1811273 | **271691** | 3.299998896 | +| human-turn2 | 16 | −9064672 | **1359701** | 3.299999779 | +| zuul-turn16 | 16 | −1270619 | **190593** | 3.299995278 | + +25 player-records over the corpus invert cleanly; the 55 players that own nothing carry `BnkEl = 0` +and are reported as such rather than inverted. `tools/max_income_oracle.py` does this over any save, +with `verify/results/standalone/max-income-oracle.json` as the corpus run. **Two things follow:** + +1. **Lane N has a per-save oracle for the term it is chasing.** Every save in the corpus states, for + every player that owns anything, the exact sum of its systems' maximum money outputs — no VM, no + hook, no game. A candidate output formula can be summed over the owned systems and compared to a + number the original wrote. +2. **`BANKRUPTCY_PROTECTION_LIMIT_FACTOR` is 3.3**, recovered from the saves rather than from the + data files. All 25 ratios land in [3.299994751, 3.299999872] — 3.3 minus one truncation. + `combat-done-tail.md` §4.1 correctly flagged the constant as DB-sourced and not a binary literal; + this is an independent measurement of its value. + +### 5.1 The divisor defect is live on the corpus, and it is not one ulp + +`sots-engine/src/game/sim/economy.cpp` divides by `-0.15`. Lane K's correction — the constant in the +image is the double `-0.15000000596046448`, i.e. `(double)(float)-0.15f` — was described as being +"one ulp out on large empires". **That understates it, and this lane's own prediction repeated the +understatement and is corrected here.** + +`trunc(m / -0.15)` and `trunc(m / -0.15000000596046448)` differ for **every `m` divisible by 3**, +starting at `m = 3` (−20 against −19). The cause is not precision loss at scale: `m / 0.15` is an +exact integer whenever `3 | m`, and the two constants straddle it, so one truncates down and the +other does not. On top of that floor, ordinary drift adds more as the value grows: + +| `maxIncome` around | fraction of integers where the two constants disagree | +|---|---| +| 10³ … 1.2 × 10⁶ | **33.3 %** (exactly the multiples of 3) | +| 1.36 × 10⁶ … 2 × 10⁶ | 66.7 % | +| ≥ 3 × 10⁶ | **100 %** | + +On the corpus this is not hypothetical: **6 of the 25 player-records above would get a different +`BnkEl` from the engine's current constant than the game wrote** — both players of `human-turn2` +(`maxIncome` 1359701 and 1556834, neither divisible by 3, both in the 66.7 % band) and of +`human-turn3`, plus `zuul-turn15` player 32 and `zuul-turn16` player 16 (both multiples of 3). +`tools/max_income_oracle.py` flags them. + +**This lane did not fix it**: `src/game/sim/economy.cpp` belongs to another lane in flight. The fix +is one character class — replace the literal `-0.15` in `ComputeBankruptcyLimits` with the +round-tripped float constant — and it wants a test case at `maxIncome = 3`. + +--- + +## 6. What still stands between the standalone and a byte-match, re-ranked + +Ranked by *leaves closed per unit of work*, with what each unblocks. + +| # | blocker | what it costs | what it closes | +|---|---|---|---| +| 1 | **the per-system money output** (population → base output) | one formula, and §5 now hands it a per-save oracle | P01, P02, P05, P06, T31, and 5 of the 11 fields of the turn record. It is upstream of ~82 of the 204 remaining leaves and of 2 of the 4 unaccounted generator words | +| 2 | **`ProcessTurn` phase 4, the alliance/vision mask** | one bit-mask rebuild, no RNG, no events | `almem` on every player's turn record — 8 of the 17 leaves that stop phase 36 committing | +| 3 | **encounter detection** (`ProcessTurn` phase 31) | the largest remaining *generator* gap: 2 words a turn with no derived rule | the last non-research residual against the oracle. Cheapest experiment: bracket `DetectEncounters` with the contact and detector counts in the record, which lane I already specified | +| 4 | **the ship-design catalogue** (hull size + the `0x400` flag) | a data-file read, not a code read | the turn record's census, the ship-record leaves, and part of `T24` | +| 5 | **`ModCount`** | it advances 12–44 times a turn from writers spread across both drivers | 1 leaf, but it is in `Summary.Checksum`'s input, so no byte-match is possible without it | +| 6 | the tail's remaining 34 stubs | large | the rest | + +**The generator specifically.** After the trade-raid block there are exactly **two** classes of +missing word left on a quiet turn: the detection pair (#3) and the research pair (#1). Close either +and the residual halves; close both and the standalone's generator state matches the oracle's on +these two pairs, at which point `/Sim/RNG` — currently 1 leaf of the 204 — closes, and it is the leaf +that gates every *subsequent* turn. + +## 7. What this lane did not do, listed as loudly as the results + +* **Nothing here was compared against the live game.** `verified` in the completion metric stays 0 + for everything this lane touched, and the catalog test enforces that. The turn-record check + compares against **bytes the original wrote**, which is strictly weaker than a live compare and + strictly stronger than nothing; it is labelled that way in the source. +* **The 16-word count is exercised on 8-player saves only.** The two calibrated pairs are the same + game. Lane Z's free prediction — that a 7-player Zuul save should cost 14 rather than 16 at these + two sites — is still untested, and this model would make it testable the moment a Zuul pair with a + known word cost exists. +* **The two raid gates are hypotheses.** The NPC-raid gate is player-independent and was open on 8 of + 8 measured turns; the refugee gate was shut on all 8. Neither is on the wire. If either flips, the + turn costs ±8 words on an 8-player save and the residual moves by that much — which is how it will + announce itself. +* **`sacq` / `slost` have count 0 in every save.** Rule 6: their element meaning is unobserved, and + the turn record model treats them as absent rather than as empty-and-understood. +* **The turn-record self-check compares six fields, not eleven.** A green run over 480 fields is 480 + comparisons of the six fields we hold; it says nothing about the five we do not, and the phase note + lists them every run for that reason. +* **The `BnkEl` inversion assumes the stored value was written by this formula and not clamped.** Any + player whose `maxIncome` is large enough to hit the −2,000,000,000 floor inverts to nothing; none + in the corpus does, and the tool would return an empty preimage rather than a wrong one. +* No shim TU was touched, so no CT111 cross-build was required for this work. + +--- + +## 8. Three questions for whoever holds the VM (earned rule 18) + +Rule 18 landed while this work was in flight and it applies to every residual above. This lane holds +no VM, so everything here was measured against the **files** — the two calibrated pairs and the +eleven-save corpus — rather than derived, but three of the open items are hook-shaped and would take +minutes on the game. + +1. **Bracket `StrategyServer::DetectEncounters` 0x007d7f70** the way lane Z bracketed the tail, with + the contact and detector counts in the record. That settles the 2-word detection pair, which is + the *last* non-research residual between the standalone's generator and the oracle's on a quiet + turn. Lane I already specified the experiment and its falsifiers. +2. **Hook `ServerTradeManager::CreateRaidEncounter` 0x008938a0.** A word count cannot separate "no + roll succeeded" from "the candidate list was empty"; the callee separates them instantly. Until + it does, the trade-raid model's 16 is a bound on a *quiet* turn only, and the first successful + raid moves the count by an unknown 0 or 1. +3. **Run one End Turn on any Zuul save with the site instrument.** The player vector holds 7 there, + so the two trade-raid sites should cost **14** rather than 16. That is the cheapest available + check on what the loop actually iterates, and this lane's model makes it a prediction rather than + an observation: the standalone will report 14 on those saves today. + +And one that is not mine but is next to my work: **the `Player.Status` writer between tail phase 31 +and the autosave** — the phase writes 1, the file carries 4, and it is the leaf that blocked lane +S2's first committed phase. A watchpoint on `ServerPlayer+0x164` across the tail names the writer. diff --git a/tools/max_income_oracle.py b/tools/max_income_oracle.py new file mode 100644 index 0000000..e9a8d12 --- /dev/null +++ b/tools/max_income_oracle.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Recover each player's maximum-income sum from the bankruptcy limit a save carries. + +`ServerPlayer::UpdateBankruptcyLimits` stores + + BnkEl = max( trunc(maxIncome / -0.15000000596046448), -2000000000 ) + BnkPr = max( -trunc(BANKRUPTCY_PROTECTION_LIMIT_FACTOR * maxIncome), BnkEl ) + +where `maxIncome` is the sum, over the player's owned systems, of `max(ComputeMaxIncome(s), 0)` -- +the per-system money output that blocks `ComputeBudget`, both savings phases and half the turn +record. The first map has a slope of about 6.67, so it is injective: each stored `BnkEl` has at +most one integer preimage, and every save in the corpus therefore STATES the blocked term for +every player that owns anything. + +That makes this a per-save oracle for a formula nobody has yet written: sum a candidate +per-system output over the owned systems and compare it with the number here. + +Two caveats the tool reports rather than hides: + * a player whose limit hit the -2,000,000,000 floor inverts to nothing, and is listed as such + rather than given a wrong answer; + * the divisor is the round-tripped float `-0.15000000596046448`, NOT `-0.15`. Using `-0.15` + changes the result for every `maxIncome` divisible by 3, and the tool flags the records where + the two constants disagree. + + tools/max_income_oracle.py # every save in verify/results/saves + tools/max_income_oracle.py PATH.sav ... # named saves + tools/max_income_oracle.py --json OUT +""" +import argparse +import glob +import json +import math +import os +import sys + +RE_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +SAVE_DIR = os.path.join(RE_ROOT, "verify", "results", "saves") +sys.path.insert(0, os.path.join(RE_ROOT, "verify", "save-reader")) +import save_reader as sr # noqa: E402 + +DIVISOR = -0.15000000596046448 # the double in .rdata: (double)(float)-0.15f +NAIVE = -0.15 # what a reimplementation writes by mistake +FLOOR = -2000000000 + + +def kids(n): + return getattr(n, "children", []) or [] + + +def name(n): + return getattr(n, "name", None) + + +def field(n, key): + for c in kids(n): + if name(c) == key: + return getattr(c, "value", None) + return None + + +def invert(bnkel): + """Integer maxIncome values that map to this stored limit, [] when none do.""" + if bnkel == 0: + return [0] + if bnkel <= FLOOR: + return [] # clamped: the preimage is unbounded, so refuse to answer + centre = int(-bnkel * 0.15) + return [m for m in range(max(centre - 8, 0), centre + 9) + if math.trunc(m / DIVISOR) == bnkel] + + +def scan(path): + res = sr.read_save(path) + sim = [c for c in kids(res.tree) if name(c) == "Sim"][0] + ch = kids(sim) + rows = [] + for i, c in enumerate(ch): + if name(c) != "Player": + continue + pid = getattr(ch[i - 1], "value", None) + el, pr = field(c, "BnkEl"), field(c, "BnkPr") + owned = sum(1 for k in kids(c) if name(k) == "OwnId") + if not el: + rows.append({"playerID": pid, "ownedSystems": owned, "BnkEl": el, "BnkPr": pr, + "maxIncome": None, "why": "limit is zero (no owned systems)"}) + continue + cands = invert(el) + if len(cands) != 1: + rows.append({"playerID": pid, "ownedSystems": owned, "BnkEl": el, "BnkPr": pr, + "maxIncome": None, + "why": "clamped at the floor" if el <= FLOOR + else "%d preimages" % len(cands)}) + continue + m = cands[0] + rows.append({ + "playerID": pid, + "ownedSystems": owned, + "BnkEl": el, + "BnkPr": pr, + "maxIncome": m, + "protectionFactor": (-pr / m) if m else None, + "naiveDivisorDisagrees": math.trunc(m / NAIVE) != math.trunc(m / DIVISOR), + }) + return rows + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("saves", nargs="*") + ap.add_argument("--json") + args = ap.parse_args(argv) + + paths = args.saves or sorted(glob.glob(os.path.join(SAVE_DIR, "*.sav"))) + if not paths: + print("no saves given and none in " + SAVE_DIR, file=sys.stderr) + return 2 + + out = {} + factors, disagree, solved = [], 0, 0 + for p in paths: + rows = scan(p) + out[os.path.basename(p)] = rows + print("== " + os.path.basename(p)) + for r in rows: + if r["maxIncome"] is None: + print(" pid=%-5s owns %-3d BnkEl=%-12s -- %s" + % (r["playerID"], r["ownedSystems"], r["BnkEl"], r["why"])) + continue + solved += 1 + factors.append(r["protectionFactor"]) + disagree += 1 if r["naiveDivisorDisagrees"] else 0 + print(" pid=%-5s owns %-3d BnkEl=%-12d maxIncome=%-10d factor=%.9f%s" + % (r["playerID"], r["ownedSystems"], r["BnkEl"], r["maxIncome"], + r["protectionFactor"], + " [-0.15 would differ]" if r["naiveDivisorDisagrees"] else "")) + if factors: + print("\n%d player-record(s) inverted; protection factor in [%.9f, %.9f]" + % (solved, min(factors), max(factors))) + print("%d of them would get a DIFFERENT stored limit from the naive -0.15 divisor" + % disagree) + if args.json: + with open(args.json, "w") as f: + json.dump(out, f, indent=2) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/rng_oracle_check.py b/tools/rng_oracle_check.py new file mode 100644 index 0000000..753fe5e --- /dev/null +++ b/tools/rng_oracle_check.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Does the standalone's generator land where the oracle's did? + +Lane Z left two calibrated pairs -- a pre-turn save, a post-turn save, and a word count +verified from the file bytes alone (`twists = 0`, so the number does not route through +anyone's twist implementation). This tool runs the standalone on the pre-turn save with +`--commit-rng` and compares three things against the post-turn save: + + * the 624-word state block, byte for byte; + * `left`, the count of words still unread in the block; + * the resulting absolute position, which is what a word count actually is. + +The verdict is binary and the residual is stated in words, never as a percentage. A +residual of N means the standalone models N fewer words than the turn really spends, and +lane Z's per-call-site ledger says which sites those are. + + tools/rng_oracle_check.py # both calibrated pairs + tools/rng_oracle_check.py --binary PATH # a sots_turn built elsewhere + tools/rng_oracle_check.py --pair IN OUT WORDS # an ad-hoc pair with a known cost +""" +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile + +RE_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +PAIR_DIR = os.path.join(RE_ROOT, "verify", "results", "shim", "tailrng") +sys.path.insert(0, os.path.join(RE_ROOT, "verify", "save-reader")) +import save_reader as sr # noqa: E402 + +N, M = 624, 397 + +# (pre-turn, post-turn, words the game spent, what it is) +PAIRS = [ + ("z2-endturn.sav", "z2-autosave.sav", 20, "ref-turn2, turn 4 -> 5"), + ("z-t6-endturn.sav", "z-t6-autosave.sav", 18, "ref-turn2, turn 5 -> 6"), +] + +DEFAULT_BINARIES = [ + os.path.expanduser("~/sots-engine-wt-yield/build-host/src/app/sots_turn"), + os.path.expanduser("~/sots-engine/build-host/src/app/sots_turn"), + "/srv/re-lab/build/sots-engine-y/src/app/sots_turn", +] + + +def twist(mt): + mt = list(mt) + for kk in range(N - M): + y = (mt[kk] & 0x80000000) | (mt[kk + 1] & 0x7FFFFFFF) + mt[kk] = mt[kk + M] ^ (y >> 1) ^ (0x9908B0DF if y & 1 else 0) + for kk in range(N - M, N - 1): + y = (mt[kk] & 0x80000000) | (mt[kk + 1] & 0x7FFFFFFF) + mt[kk] = mt[kk + (M - N)] ^ (y >> 1) ^ (0x9908B0DF if y & 1 else 0) + y = (mt[N - 1] & 0x80000000) | (mt[0] & 0x7FFFFFFF) + mt[N - 1] = mt[M - 1] ^ (y >> 1) ^ (0x9908B0DF if y & 1 else 0) + return mt + + +def _find_rng(node, out): + if getattr(node, "name", None) == "RNG": + out.append(node) + for c in getattr(node, "children", []) or []: + _find_rng(c, out) + + +def rng_state(path): + """(mt[624], left) out of a save's generator frame.""" + res = sr.read_save(path) + hits = [] + _find_rng(res.tree, hits) + if not hits: + raise SystemExit("no generator frame in " + path) + n = hits[0] + raw = n.raw or b"".join(c.raw for c in (getattr(n, "children", []) or []) if c.raw) + if not raw: + raise SystemExit("generator frame in %s carries no bytes" % path) + for off in range(0, len(raw) - 2500 + 1): + mt = [int.from_bytes(raw[off + 4 * i:off + 4 * i + 4], "little") for i in range(N)] + left = int.from_bytes(raw[off + 2496:off + 2500], "little", signed=True) + if 0 <= left <= N: + return mt, left + raise SystemExit("cannot parse the generator blob of %s (%d B)" % (path, len(raw))) + + +def words_between(a, b, max_twists=64): + """Words consumed moving from state a to state b, exact across block boundaries.""" + (mta, la), (mtb, lb) = a, b + cur, tw = mta, 0 + while tw <= max_twists and cur != mtb: + cur = twist(cur) + tw += 1 + if cur != mtb: + return None, None + return 624 * tw + (la - lb), tw + + +def find_binary(explicit): + if explicit: + return explicit if os.path.exists(explicit) else None + for p in DEFAULT_BINARIES: + if os.path.exists(p): + return p + return shutil.which("sots_turn") + + +def run_pair(binary, src, oracle, expected, note, workdir): + out_sav = os.path.join(workdir, "post-" + os.path.basename(src)) + metric = os.path.join(workdir, "metric.json") + cmd = [binary, src, "--out", out_sav, "--metric", metric, "--commit-rng"] + proc = subprocess.run(cmd, capture_output=True, text=True) + row = {"input": os.path.basename(src), "oracle": os.path.basename(oracle), + "note": note, "oracleWords": expected, "exit": proc.returncode} + if proc.returncode != 0: + row["error"] = proc.stderr.strip()[:2000] + return row + + before = rng_state(src) + after_oracle = rng_state(oracle) + after_ours = rng_state(out_sav) + + oracle_words, oracle_twists = words_between(before, after_oracle) + our_words, our_twists = words_between(before, after_ours) + + row.update({ + "oracleWordsFromFiles": oracle_words, + "oracleTwists": oracle_twists, + "ourWords": our_words, + "ourTwists": our_twists, + "blockIdentical": after_ours[0] == after_oracle[0], + "leftOurs": after_ours[1], + "leftOracle": after_oracle[1], + "stateMatches": after_ours[0] == after_oracle[0] and after_ours[1] == after_oracle[1], + }) + row["residual"] = None if our_words is None or oracle_words is None else oracle_words - our_words + if os.path.exists(metric): + with open(metric) as f: + m = json.load(f) + row["standaloneRngWords"] = m.get("run", {}).get("rngWords") + row["standaloneRngCommitted"] = m.get("run", {}).get("rngCommitted") + row["unaccounted"] = m.get("run", {}).get("rngUnaccounted", []) + return row + + +def render(rows): + out = [] + out.append("# does the standalone's generator land on the oracle's?\n") + for r in rows: + out.append("## %s -> %s (%s)" % (r["input"], r["oracle"], r["note"])) + if r.get("error"): + out.append(" standalone failed: " + r["error"].splitlines()[0]) + out.append("") + continue + out.append(" oracle spent %s word(s) (%s twist(s), from the files)" + % (r["oracleWordsFromFiles"], r["oracleTwists"])) + out.append(" standalone %s word(s) (metric says %s)" + % (r["ourWords"], r.get("standaloneRngWords"))) + out.append(" residual %s word(s) NOT modelled" % r["residual"]) + out.append(" state block identical: %s left: ours %s, oracle %s" + % ("yes" if r["blockIdentical"] else "no", r["leftOurs"], r["leftOracle"])) + out.append(" GENERATOR STATE MATCHES ORACLE: %s" + % ("YES" if r["stateMatches"] else "no")) + for u in r.get("unaccounted", []): + out.append(" - unaccounted: " + u) + out.append("") + matched = sum(1 for r in rows if r.get("stateMatches")) + out.append("verdict: %d of %d calibrated pair(s) match the oracle's generator state exactly" + % (matched, len(rows))) + return "\n".join(out) + "\n" + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--binary") + ap.add_argument("--pair", nargs=3, metavar=("INPUT", "ORACLE", "WORDS")) + ap.add_argument("--json", help="write the machine-readable result here") + args = ap.parse_args(argv) + + binary = find_binary(args.binary) + if not binary: + print("rng_oracle_check: sots_turn not found; build the host preset first", + file=sys.stderr) + return 2 + + if args.pair: + pairs = [(args.pair[0], args.pair[1], int(args.pair[2]), "ad-hoc")] + else: + pairs = [(os.path.join(PAIR_DIR, a), os.path.join(PAIR_DIR, b), w, n) + for a, b, w, n in PAIRS] + + rows = [] + with tempfile.TemporaryDirectory(prefix="rngoracle-") as wd: + for src, oracle, words, note in pairs: + if not (os.path.exists(src) and os.path.exists(oracle)): + print("missing calibrated pair: %s / %s" % (src, oracle), file=sys.stderr) + return 2 + rows.append(run_pair(binary, src, oracle, words, note, wd)) + + text = render(rows) + print(text, end="") + if args.json: + with open(args.json, "w") as f: + json.dump({"binary": binary, "pairs": rows}, f, indent=2) + # Exit 0 whether or not it matches: this is a measurement, not a gate. A non-zero exit + # is reserved for the tool being unable to measure. + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/standalone_report.py b/tools/standalone_report.py index ed0fc54..e20b036 100644 --- a/tools/standalone_report.py +++ b/tools/standalone_report.py @@ -53,6 +53,7 @@ PAIRS = [ ] DEFAULT_BINARIES = [ + os.path.expanduser("~/sots-engine-wt-yield/build-host/src/app/sots_turn"), os.path.expanduser("~/sots-engine-wt-standalone/build-host/src/app/sots_turn"), os.path.expanduser("~/sots-engine/build-host/src/app/sots_turn"), "/srv/re-lab/build/sots-engine-s2/src/app/sots_turn", diff --git a/verify/results/standalone/max-income-oracle.json b/verify/results/standalone/max-income-oracle.json new file mode 100644 index 0000000..ca80101 --- /dev/null +++ b/verify/results/standalone/max-income-oracle.json @@ -0,0 +1,689 @@ +{ + "human-turn2-orders.sav": [ + { + "playerID": 16, + "ownedSystems": 10, + "BnkEl": -9064672, + "BnkPr": -4487013, + "maxIncome": 1359701, + "protectionFactor": 3.299999779363257, + "naiveDivisorDisagrees": true + }, + { + "playerID": 32, + "ownedSystems": 10, + "BnkEl": -10378892, + "BnkPr": -5137552, + "maxIncome": 1556834, + "protectionFactor": 3.299999871534152, + "naiveDivisorDisagrees": true + }, + { + "playerID": 496, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 512, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 528, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 544, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 560, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + } + ], + "human-turn3-noderoute.sav": [ + { + "playerID": 16, + "ownedSystems": 10, + "BnkEl": -9122659, + "BnkPr": -4515716, + "maxIncome": 1368399, + "protectionFactor": 3.299999488453295, + "naiveDivisorDisagrees": true + }, + { + "playerID": 32, + "ownedSystems": 10, + "BnkEl": -10444699, + "BnkPr": -5170126, + "maxIncome": 1566705, + "protectionFactor": 3.2999996808588725, + "naiveDivisorDisagrees": true + }, + { + "playerID": 496, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 512, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 528, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 544, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 560, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + } + ], + "turn1-state.sav": [ + { + "playerID": 16, + "ownedSystems": 1, + "BnkEl": -1590613, + "BnkPr": -787353, + "maxIncome": 238592, + "protectionFactor": 3.2999974852467813, + "naiveDivisorDisagrees": false + }, + { + "playerID": 32, + "ownedSystems": 1, + "BnkEl": -1811273, + "BnkPr": -896580, + "maxIncome": 271691, + "protectionFactor": 3.299998895804425, + "naiveDivisorDisagrees": false + }, + { + "playerID": 496, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 512, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 528, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 544, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 560, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 576, + "ownedSystems": 1, + "BnkEl": -665806, + "BnkPr": -329574, + "maxIncome": 99871, + "protectionFactor": 3.2999969961250013, + "naiveDivisorDisagrees": false + } + ], + "turn2-state.sav": [ + { + "playerID": 16, + "ownedSystems": 1, + "BnkEl": -1594593, + "BnkPr": -789323, + "maxIncome": 239189, + "protectionFactor": 3.299997073444013, + "naiveDivisorDisagrees": false + }, + { + "playerID": 32, + "ownedSystems": 1, + "BnkEl": -1815833, + "BnkPr": -898837, + "maxIncome": 272375, + "protectionFactor": 3.2999981642955483, + "naiveDivisorDisagrees": false + }, + { + "playerID": 496, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 512, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 528, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 544, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 560, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 576, + "ownedSystems": 1, + "BnkEl": -665806, + "BnkPr": -329574, + "maxIncome": 99871, + "protectionFactor": 3.2999969961250013, + "naiveDivisorDisagrees": false + } + ], + "turn3-state.sav": [ + { + "playerID": 16, + "ownedSystems": 1, + "BnkEl": -1598566, + "BnkPr": -791290, + "maxIncome": 239785, + "protectionFactor": 3.299997914798674, + "naiveDivisorDisagrees": false + }, + { + "playerID": 32, + "ownedSystems": 1, + "BnkEl": -1820206, + "BnkPr": -901002, + "maxIncome": 273031, + "protectionFactor": 3.299998901223671, + "naiveDivisorDisagrees": false + }, + { + "playerID": 496, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 512, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 528, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 544, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 560, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 576, + "ownedSystems": 1, + "BnkEl": -665806, + "BnkPr": -329574, + "maxIncome": 99871, + "protectionFactor": 3.2999969961250013, + "naiveDivisorDisagrees": false + } + ], + "zuul-turn15-orders.sav": [ + { + "playerID": 16, + "ownedSystems": 2, + "BnkEl": -1271166, + "BnkPr": -629227, + "maxIncome": 190675, + "protectionFactor": 3.299997377736987, + "naiveDivisorDisagrees": false + }, + { + "playerID": 32, + "ownedSystems": 2, + "BnkEl": -1820399, + "BnkPr": -901097, + "maxIncome": 273060, + "protectionFactor": 3.299996337801216, + "naiveDivisorDisagrees": true + }, + { + "playerID": 496, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 512, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 528, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 544, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 560, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + } + ], + "zuul-turn16-noderoute.sav": [ + { + "playerID": 16, + "ownedSystems": 2, + "BnkEl": -1270619, + "BnkPr": -628956, + "maxIncome": 190593, + "protectionFactor": 3.2999952778958304, + "naiveDivisorDisagrees": true + }, + { + "playerID": 32, + "ownedSystems": 2, + "BnkEl": -1819586, + "BnkPr": -900695, + "maxIncome": 272938, + "protectionFactor": 3.299998534465703, + "naiveDivisorDisagrees": false + }, + { + "playerID": 496, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 512, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 528, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 544, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 560, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + } + ], + "zuul-turn17-orders2.sav": [ + { + "playerID": 16, + "ownedSystems": 2, + "BnkEl": -1270066, + "BnkPr": -628682, + "maxIncome": 190510, + "protectionFactor": 3.2999947509317096, + "naiveDivisorDisagrees": false + }, + { + "playerID": 32, + "ownedSystems": 2, + "BnkEl": -1818586, + "BnkPr": -900200, + "maxIncome": 272788, + "protectionFactor": 3.2999985336598385, + "naiveDivisorDisagrees": false + }, + { + "playerID": 496, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 512, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 528, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 544, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 560, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + } + ], + "zuul-turn17-rollpending.sav": [ + { + "playerID": 16, + "ownedSystems": 2, + "BnkEl": -1270066, + "BnkPr": -628682, + "maxIncome": 190510, + "protectionFactor": 3.2999947509317096, + "naiveDivisorDisagrees": false + }, + { + "playerID": 32, + "ownedSystems": 2, + "BnkEl": -1818586, + "BnkPr": -900200, + "maxIncome": 272788, + "protectionFactor": 3.2999985336598385, + "naiveDivisorDisagrees": false + }, + { + "playerID": 496, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 512, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 528, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 544, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 560, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + } + ], + "zuul-turn23-fleet23.sav": [ + { + "playerID": 16, + "ownedSystems": 2, + "BnkEl": -1266033, + "BnkPr": -626686, + "maxIncome": 189905, + "protectionFactor": 3.299997367104605, + "naiveDivisorDisagrees": false + }, + { + "playerID": 32, + "ownedSystems": 2, + "BnkEl": -1813746, + "BnkPr": -897804, + "maxIncome": 272062, + "protectionFactor": 3.299997794620344, + "naiveDivisorDisagrees": false + }, + { + "playerID": 496, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 512, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 528, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 544, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 560, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + } + ], + "zuul-turn5-species5.sav": [ + { + "playerID": 16, + "ownedSystems": 2, + "BnkEl": -1277766, + "BnkPr": -632494, + "maxIncome": 191665, + "protectionFactor": 3.2999973912816634, + "naiveDivisorDisagrees": false + }, + { + "playerID": 32, + "ownedSystems": 2, + "BnkEl": -1725413, + "BnkPr": -854079, + "maxIncome": 258812, + "protectionFactor": 3.299997681714913, + "naiveDivisorDisagrees": false + }, + { + "playerID": 496, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 512, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 528, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 544, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + }, + { + "playerID": 560, + "ownedSystems": 0, + "BnkEl": 0, + "BnkPr": 0, + "maxIncome": null, + "why": "limit is zero (no owned systems)" + } + ] +} \ No newline at end of file diff --git a/verify/results/standalone/report.txt b/verify/results/standalone/report.txt index 3f53294..a019b9a 100644 --- a/verify/results/standalone/report.txt +++ b/verify/results/standalone/report.txt @@ -1,9 +1,9 @@ # standalone vs the oracle -generated 2026-09-08T14:30:05Z binary /home/alex/sots-engine-wt-standalone/build-host/src/app/sots_turn +generated 2026-09-08T15:35:00Z binary /home/alex/sots-engine-wt-yield/build-host/src/app/sots_turn -phases: 14/44 of the two turn drivers modelled, 7 committed (implemented 2, partial 5, blocked 7, stub 30) - 2/37 of the post-combat tail modelled +phases: 14/44 of the two turn drivers modelled, 8 committed (implemented 2, partial 6, blocked 6, stub 30) + 3/37 of the post-combat tail modelled ## turn1-state.sav -> turn2-state.sav (real End Turn) baseline (do nothing) 209 leaves diverge diff --git a/verify/results/standalone/rng-oracle.json b/verify/results/standalone/rng-oracle.json new file mode 100644 index 0000000..9b6a1f3 --- /dev/null +++ b/verify/results/standalone/rng-oracle.json @@ -0,0 +1,51 @@ +{ + "binary": "/home/alex/sots-engine-wt-yield/build-host/src/app/sots_turn", + "pairs": [ + { + "input": "z2-endturn.sav", + "oracle": "z2-autosave.sav", + "note": "ref-turn2, turn 4 -> 5", + "oracleWords": 20, + "exit": 0, + "oracleWordsFromFiles": 20, + "oracleTwists": 0, + "ourWords": 16, + "ourTwists": 0, + "blockIdentical": true, + "leftOurs": 379, + "leftOracle": 375, + "stateMatches": false, + "residual": 4, + "standaloneRngWords": 16, + "standaloneRngCommitted": true, + "unaccounted": [ + "encounter detection draws one unit value and one bounded integer per turn on every turn measured (2 words), with no derived rule behind the count -- its bound is the product of the contact and detector counts, so it is left unmodelled", + "the research-allocation draw is downstream of the budget, which is blocked on the per-system money output (0 or 1 word)", + "a successful raid roll may draw one further word to pick its target; no roll succeeded on any measured turn, so the cost of a success is 0 or 1 and undetermined" + ] + }, + { + "input": "z-t6-endturn.sav", + "oracle": "z-t6-autosave.sav", + "note": "ref-turn2, turn 5 -> 6", + "oracleWords": 18, + "exit": 0, + "oracleWordsFromFiles": 18, + "oracleTwists": 0, + "ourWords": 16, + "ourTwists": 0, + "blockIdentical": true, + "leftOurs": 359, + "leftOracle": 357, + "stateMatches": false, + "residual": 2, + "standaloneRngWords": 16, + "standaloneRngCommitted": true, + "unaccounted": [ + "encounter detection draws one unit value and one bounded integer per turn on every turn measured (2 words), with no derived rule behind the count -- its bound is the product of the contact and detector counts, so it is left unmodelled", + "the research-allocation draw is downstream of the budget, which is blocked on the per-system money output (0 or 1 word)", + "a successful raid roll may draw one further word to pick its target; no roll succeeded on any measured turn, so the cost of a success is 0 or 1 and undetermined" + ] + } + ] +} \ No newline at end of file diff --git a/verify/results/standalone/rng-oracle.txt b/verify/results/standalone/rng-oracle.txt new file mode 100644 index 0000000..10f7265 --- /dev/null +++ b/verify/results/standalone/rng-oracle.txt @@ -0,0 +1,23 @@ +# does the standalone's generator land on the oracle's? + +## z2-endturn.sav -> z2-autosave.sav (ref-turn2, turn 4 -> 5) + oracle spent 20 word(s) (0 twist(s), from the files) + standalone 16 word(s) (metric says 16) + residual 4 word(s) NOT modelled + state block identical: yes left: ours 379, oracle 375 + GENERATOR STATE MATCHES ORACLE: no + - unaccounted: encounter detection draws one unit value and one bounded integer per turn on every turn measured (2 words), with no derived rule behind the count -- its bound is the product of the contact and detector counts, so it is left unmodelled + - unaccounted: the research-allocation draw is downstream of the budget, which is blocked on the per-system money output (0 or 1 word) + - unaccounted: a successful raid roll may draw one further word to pick its target; no roll succeeded on any measured turn, so the cost of a success is 0 or 1 and undetermined + +## z-t6-endturn.sav -> z-t6-autosave.sav (ref-turn2, turn 5 -> 6) + oracle spent 18 word(s) (0 twist(s), from the files) + standalone 16 word(s) (metric says 16) + residual 2 word(s) NOT modelled + state block identical: yes left: ours 359, oracle 357 + GENERATOR STATE MATCHES ORACLE: no + - unaccounted: encounter detection draws one unit value and one bounded integer per turn on every turn measured (2 words), with no derived rule behind the count -- its bound is the product of the contact and detector counts, so it is left unmodelled + - unaccounted: the research-allocation draw is downstream of the budget, which is blocked on the per-system money output (0 or 1 word) + - unaccounted: a successful raid roll may draw one further word to pick its target; no roll succeeded on any measured turn, so the cost of a success is 0 or 1 and undetermined + +verdict: 0 of 2 calibrated pair(s) match the oracle's generator state exactly diff --git a/verify/results/standalone/status.json b/verify/results/standalone/status.json index 01f3efe..c4db225 100644 --- a/verify/results/standalone/status.json +++ b/verify/results/standalone/status.json @@ -1,7 +1,7 @@ { "schema": "sots-standalone-status/1", - "generated": "2026-09-08T14:30:05Z", - "binary": "/home/alex/sots-engine-wt-standalone/build-host/src/app/sots_turn", + "generated": "2026-09-08T15:35:00Z", + "binary": "/home/alex/sots-engine-wt-yield/build-host/src/app/sots_turn", "reference": { "input": "turn1-state.sav", "oracle": "turn2-state.sav", @@ -31,24 +31,24 @@ "total": 44, "verified": 0, "implemented": 2, - "partial": 5, - "blocked": 7, + "partial": 6, + "blocked": 6, "stub": 30, "modelled": 14, - "committed": 7 + "committed": 8 }, "tailPhases": { "total": 37, "verified": 0, "implemented": 1, "partial": 0, - "blocked": 1, - "stub": 35, - "modelled": 2, + "blocked": 2, + "stub": 34, + "modelled": 3, "committed": 1 }, "rng": { - "wordsModelled": 0, + "wordsModelled": 16, "wordsPerTurnUnattributed": "18-20 (lane Z, in flight)" }, "pairs": [ @@ -64,18 +64,24 @@ "roundtrip (untouched): byte-identical (591376 bytes)", "", "phases", - " turn drivers (the milestone's denominator): 14 of 44 modelled, 7 committed", - " verified 0 implemented 2 partial 5 blocked 7 stub 30", - " post-combat tail (written to the autosave, tracked separately): 2 of 37 modelled", - " verified 0 implemented 1 partial 0 blocked 1 stub 35", + " turn drivers (the milestone's denominator): 14 of 44 modelled, 8 committed", + " verified 0 implemented 2 partial 6 blocked 6 stub 30", + " post-combat tail (written to the autosave, tracked separately): 3 of 37 modelled", + " verified 0 implemented 1 partial 0 blocked 2 stub 34", "", "this run", " leaves written 7", - " leaves NOT written by a blocked phase 16", - " generator words consumed 0 (state loaded)", + " leaves NOT written by a blocked phase 64", + " generator words consumed 16 (state loaded, left untouched)", + " generator words NOT accounted (never netted off the above):", + " - encounter detection draws one unit value and one bounded integer per turn on every turn measured (2 words), with no derived rule behind the count -- its bound is the product of the contact and detector counts, so it is left unmodelled", + " - the research-allocation draw is downstream of the budget, which is blocked on the per-system money output (0 or 1 word)", + " - a successful raid roll may draw one further word to pick its target; no roll succeeded on any measured turn, so the cost of a success is 0 or 1 and undetermined", + " ! the generator advanced during this run but the save keeps its original state (--commit-rng to write it)", + " ! the modelled words are a LOWER BOUND on the turn's cost, so a committed generator is short by the unaccounted sites below and its drawn VALUES are not the game's", "", - "wrote /tmp/tmpatg5108q/post-turn1-state.sav (65145 bytes gzipped, 591376 inflated)", - "metric -> /tmp/tmpatg5108q/metric-turn1-state.sav.json" + "wrote /tmp/tmp_cz5dsbf/post-turn1-state.sav (65145 bytes gzipped, 591376 inflated)", + "metric -> /tmp/tmp_cz5dsbf/metric-turn1-state.sav.json" ], "coverage": { "input": true, @@ -145,32 +151,38 @@ "standalone": { "schema": "sots-standalone-metric/1", "input": "/home/alex/sots-re/verify/results/saves/turn1-state.sav", - "output": "/tmp/tmpatg5108q/post-turn1-state.sav", + "output": "/tmp/tmp_cz5dsbf/post-turn1-state.sav", "spine": { "total": 44, "verified": 0, "implemented": 2, - "partial": 5, - "blocked": 7, + "partial": 6, + "blocked": 6, "stub": 30, "modelled": 14, - "committed": 7 + "committed": 8 }, "tail": { "total": 37, "verified": 0, "implemented": 1, "partial": 0, - "blocked": 1, - "stub": 35, - "modelled": 2, + "blocked": 2, + "stub": 34, + "modelled": 3, "committed": 1 }, "run": { "leafWrites": 7, - "blockedLeafWrites": 16, - "rngWords": 0, - "rngLoaded": true + "blockedLeafWrites": 64, + "rngWords": 16, + "rngLoaded": true, + "rngCommitted": false, + "rngUnaccounted": [ + "encounter detection draws one unit value and one bounded integer per turn on every turn measured (2 words), with no derived rule behind the count -- its bound is the product of the contact and detector counts, so it is left unmodelled", + "the research-allocation draw is downstream of the budget, which is blocked on the per-system money output (0 or 1 word)", + "a successful raid roll may draw one further word to pick its target; no roll succeeded on any measured turn, so the cost of a success is 0 or 1 and undetermined" + ] }, "phases": [ { @@ -617,11 +629,11 @@ "driver": "StrategyServer::ProcessTurn", "id": "S31", "name": "EncounterDetectionAndStatusRestore", - "status": "blocked", + "status": "partial", "ran": 8, "writes": 0, "blockedWrites": 2, - "rng": 0 + "rng": 16 }, { "driver": "StrategyServer::OnAllCombatDone_Tail", @@ -987,10 +999,10 @@ "driver": "StrategyServer::OnAllCombatDone_Tail", "id": "T36", "name": "FinalizeTurnRecords", - "status": "stub", - "ran": 0, + "status": "blocked", + "ran": 8, "writes": 0, - "blockedWrites": 0, + "blockedWrites": 48, "rng": 0 }, { @@ -1018,18 +1030,24 @@ "roundtrip (untouched): byte-identical (603360 bytes)", "", "phases", - " turn drivers (the milestone's denominator): 14 of 44 modelled, 7 committed", - " verified 0 implemented 2 partial 5 blocked 7 stub 30", - " post-combat tail (written to the autosave, tracked separately): 2 of 37 modelled", - " verified 0 implemented 1 partial 0 blocked 1 stub 35", + " turn drivers (the milestone's denominator): 14 of 44 modelled, 8 committed", + " verified 0 implemented 2 partial 6 blocked 6 stub 30", + " post-combat tail (written to the autosave, tracked separately): 3 of 37 modelled", + " verified 0 implemented 1 partial 0 blocked 2 stub 34", "", "this run", " leaves written 7", - " leaves NOT written by a blocked phase 10", - " generator words consumed 0 (state loaded)", + " leaves NOT written by a blocked phase 58", + " generator words consumed 16 (state loaded, left untouched)", + " generator words NOT accounted (never netted off the above):", + " - encounter detection draws one unit value and one bounded integer per turn on every turn measured (2 words), with no derived rule behind the count -- its bound is the product of the contact and detector counts, so it is left unmodelled", + " - the research-allocation draw is downstream of the budget, which is blocked on the per-system money output (0 or 1 word)", + " - a successful raid roll may draw one further word to pick its target; no roll succeeded on any measured turn, so the cost of a success is 0 or 1 and undetermined", + " ! the generator advanced during this run but the save keeps its original state (--commit-rng to write it)", + " ! the modelled words are a LOWER BOUND on the turn's cost, so a committed generator is short by the unaccounted sites below and its drawn VALUES are not the game's", "", - "wrote /tmp/tmpatg5108q/post-turn2-state.sav (66883 bytes gzipped, 603360 inflated)", - "metric -> /tmp/tmpatg5108q/metric-turn2-state.sav.json" + "wrote /tmp/tmp_cz5dsbf/post-turn2-state.sav (66883 bytes gzipped, 603360 inflated)", + "metric -> /tmp/tmp_cz5dsbf/metric-turn2-state.sav.json" ], "coverage": { "input": true, @@ -1099,32 +1117,38 @@ "standalone": { "schema": "sots-standalone-metric/1", "input": "/home/alex/sots-re/verify/results/saves/turn2-state.sav", - "output": "/tmp/tmpatg5108q/post-turn2-state.sav", + "output": "/tmp/tmp_cz5dsbf/post-turn2-state.sav", "spine": { "total": 44, "verified": 0, "implemented": 2, - "partial": 5, - "blocked": 7, + "partial": 6, + "blocked": 6, "stub": 30, "modelled": 14, - "committed": 7 + "committed": 8 }, "tail": { "total": 37, "verified": 0, "implemented": 1, "partial": 0, - "blocked": 1, - "stub": 35, - "modelled": 2, + "blocked": 2, + "stub": 34, + "modelled": 3, "committed": 1 }, "run": { "leafWrites": 7, - "blockedLeafWrites": 10, - "rngWords": 0, - "rngLoaded": true + "blockedLeafWrites": 58, + "rngWords": 16, + "rngLoaded": true, + "rngCommitted": false, + "rngUnaccounted": [ + "encounter detection draws one unit value and one bounded integer per turn on every turn measured (2 words), with no derived rule behind the count -- its bound is the product of the contact and detector counts, so it is left unmodelled", + "the research-allocation draw is downstream of the budget, which is blocked on the per-system money output (0 or 1 word)", + "a successful raid roll may draw one further word to pick its target; no roll succeeded on any measured turn, so the cost of a success is 0 or 1 and undetermined" + ] }, "phases": [ { @@ -1571,11 +1595,11 @@ "driver": "StrategyServer::ProcessTurn", "id": "S31", "name": "EncounterDetectionAndStatusRestore", - "status": "blocked", + "status": "partial", "ran": 8, "writes": 0, "blockedWrites": 2, - "rng": 0 + "rng": 16 }, { "driver": "StrategyServer::OnAllCombatDone_Tail", @@ -1941,10 +1965,10 @@ "driver": "StrategyServer::OnAllCombatDone_Tail", "id": "T36", "name": "FinalizeTurnRecords", - "status": "stub", - "ran": 0, + "status": "blocked", + "ran": 8, "writes": 0, - "blockedWrites": 0, + "blockedWrites": 48, "rng": 0 }, {