sots-engine/docs/S-standalone.md
alex 39c01422f7 src/app: the standalone -- load a save, run a turn, write a save
`sots_turn` loads a save through the engine's own reader, walks the published
phase order of all three turn drivers, runs what we hold, prints what we do
not, and writes the result back through the engine's own writer.

The phase catalog carries all 32 + 12 + 37 phases whether or not they are
implemented, so an unimplemented phase is a named no-op that appears in the run
log rather than a silent absence. 14 of the 44 turn-driver phases are modelled,
7 commit anything, 2 of the 37 tail phases are modelled.

Modelled but NOT committed is a first-class state. A phase whose formula we hold
and whose inputs we do not is evaluated, reported, and left unwritten unless
--commit-blocked is passed. That distinction was earned: committing phase 31's
player-status restore regressed two leaves that had agreed with the oracle
before the turn, because the phase writes 1 and the file carries 4.

Measured against the game's own post-turn saves, leaves localised by
state_checksum.py with coverage proved by re-serialisation:

  turn1-state -> turn2-state   209 -> 204 diverging, closed 5, regressed 0
  turn2-state -> turn3-state   108 -> 103 diverging, closed 5, regressed 0

Two tests: app_catalog (the tables stay complete and nothing claims to be
verified against a live game) and app_turn (11 saves driven; an untouched load
re-serialises byte-identically, a turn leaves the file re-readable, and no
blocked or stub phase writes anything). Skips cleanly without SOTS_SAVES_DIR.

ctest 38/38, clean-room OK. src/shim untouched. docs/S-standalone.md has the
full gap list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 10:35:45 -04:00

12 KiB
Raw Blame History

src/app — the standalone, and exactly how far it is from the byte-match

Lane S2, 2026-09-08. Branch wip/standalone.

The campaign's north star is a functional reimplementation, and the first milestone that makes it falsifiable is:

the standalone loads a save, runs one strategic turn, and writes an autosave that byte-matches what the original produces from the same state.

This document says what sots_turn does today, what it deliberately does not do, and what stands between it and that byte-match. The short answer is on the first line of the metric: 14 of the 44 turn-driver phases are modelled, 7 of them commit anything, and the reference turn closes 5 of 209 diverging leaves. Everything below is the detail behind those numbers.


1. What it does end to end, today

$ sots_turn SAVE --roundtrip --phases --out POST.sav --metric metric.json
  1. Loads the save through mars::stream::read_save_file — the engine's own reader, the one with 100 % named coverage on all eleven saves in the corpus.
  2. Proves the foundation with --roundtrip: re-serialises the untouched parse and compares byte for byte against the inflated stream. If that fails, nothing after it means anything, so the run stops.
  3. Walks the whole published phase order of all three drivers — the host steps, then StrategyServer::ProcessTurn's 32 phases, with ServerPlayer::ProcessTurn's 12 nested in at phase 13, then OnAllCombatDone_Tail's 37 — running what we hold and printing what we do not.
  4. Writes the post-turn state back through mars::stream::write_save + gzip.
  5. Emits a completion metric as JSON for the campaign dashboard.

It reads no game data. The save path is an argument; the test reads $SOTS_SAVES_DIR and skips cleanly when it is unset. No .sav is in this repo and none is written into it.

2. The design decision that matters: blocked phases do not commit

Several phases are ones whose formula is verified and whose inputs are not modelled. The budget roll-up is the type case: ComputeBudget compared clean over 4,284 live calls, but one of its inputs is the money output of each owned system, and that needs the population → base-output term the colony model explicitly declares unresolved.

Running such a phase and writing its result would be worse than not running it. It replaces a leaf that may currently agree with the oracle by construction with one that is confidently wrong, and the divergence count then measures how much code we ran rather than how much we know. So a blocked phase is evaluated, reported, and not written unless the operator passes --commit-blocked.

This is not theoretical. S31's player-status restore was implemented and committed in the first version of this lane; the comparison tool immediately reported two regressed leaves on the turn2 → turn3 pair — two Player.Status words that agreed with the oracle before the turn and disagreed after it. The phase writes 1; the file carries 4; a writer between the phase and the autosave is unaccounted. S31 is now blocked, and the measured fact is recorded in its catalog note rather than papered over.

The same reasoning governs the generator. The turn consumes roughly 18–20 words that nothing here models, so the advanced state would be wrong in a different way from the untouched state. The untouched state at least tells the truth. --commit-rng exists for the day the ledger closes.

3. The phase catalog

src/app/phase_catalog.{h,cpp} is the roadmap and the ledger at once. Every phase of every driver is in it whether or not it is implemented, with one of five statuses:

status meaning commits?
verified implemented here and compared against the live game yes
implemented implemented from an instruction-verified reading, not yet compared live yes
partial part implemented and committed, part stubbed; the note says which yes, the part
blocked formula held, an input is not modelled no (unless forced)
stub a named no-op no

Nothing is verified. That is deliberate: in this table verified means "compared against the live game", and lane S2 holds no VM. app_test_catalog asserts verified == 0 so the claim cannot drift upward by accident.

3.1 Current state

driver phases modelled committed
host steps (outside the milestone's denominator) 2 2 2
StrategyServer::ProcessTurn 32 4 3
ServerPlayer::ProcessTurn 12 10 4
the milestone's denominator 44 14 7
OnAllCombatDone_Tail 37 2 1

3.2 What actually runs

Committed

phase what it does
H00 BeginProcessTurn advances the frame counter — the turn number the whole game displays
H01 SaveWriterInvariants the summary's turn number is the frame counter; an identity that holds across the whole corpus
S00 SnapshotPreviousTurn the modification-counter bump (the shadow-word snapshot is not modelled)
S11 SystemTurn game::sim::ProcessColonyTurn, committing the parts that need neither the tuning table nor a carrying capacity
P07 ClearTimedResearchAccumulators zeroes the three timed-research accumulators that are on the wire
P08 DecayRebellionOutputModifier rebel AI only: −0.04f per turn, clamped to [1, 2]
P09 AccumulateTimedResearchBonuses the timed research-bonus vector, iterated last → first, which is load-bearing because float addition is not associative
P10 ConsumeResearchRollPending the strict 0.5f < progress/cost test and the in-branch flag clear
T00 IncrementModCount the tail's own bump of the same counter

Evaluated and reported, not committed: P01 P02 P03 P05 P06 P11 S31 T31.

Everything else is a named no-op that prints itself in --phases.

3.3 One hypothesis, and how it is being tested

ProcessColonyTurn takes stable as an input — in the original it is a callee's verdict. The standalone's stand-in is owned && !abandoned && !destroyed, and it is labelled a hypothesis in the code and in the run log, because rule 6 says a path no evidence exercises is a hypothesis.

It is a testable one: stable drives the turns-developing counter, which is a named leaf. On the reference pair it judged 3 of 28 systems stable and closed exactly the 3 ntdev leaves the oracle moved. It then closed the same 3 on the turn2 → turn3 pair — a different workload, which is what makes it evidence about the model rather than about the recording. Six independent agreements, zero disagreements, is not proof, and it is written down as such.

4. The divergence report

Measured by sots-re/tools/standalone_report.py, which drives sots_turn over each consecutive-turn pair in the corpus and diffs the result against the game's own post-turn save using verify/state-checksum/state_checksum.py. That tool localises to named leaves and proves its own coverage by re-serialising the parse back to bytes, so nothing can hide from it.

Reference pair turn1-state.sav → turn2-state.sav (a real End Turn):

baseline (a standalone that does nothing)   209 leaves diverge
after one standalone turn                   204 leaves diverge
closed 5, regressed 0

Closed: /Summary/Turn, /Sim/Frame, and ntdev on Gamma Cephei, Ke'Dolarra and Koa'Vo. The second pair turn2 → turn3 closes the same five out of a baseline of 108, with zero regressions.

regressed is reported next to closed on purpose. A leaf that agreed before the turn and disagrees after it is a phase doing damage, and netting it off against the closures would hide exactly the failure this scaffold is built to catch.

4.1 Where the remaining 204 live

subsystem leaves what it is
/Sim/players 82 savings, bankruptcy limits, research state, the turn's event buckets, per-player AI bookkeeping
/Sim/systems 80 see below
/Sim/turnstats 24 the per-turn statistics archive — written by the tail's last phase
/Sim/SvSctOb 8 the script-object encounter tree
singletons 10 ModCount, RNG, Checksum, NMnx, cmbtid, the four id lists, NumFlts

The system side is not 80 unrelated facts. It is a handful of mechanisms:

  • 32 leaves — one new nve record on each of 8 systems (NVE + EPid/ETS/Eid). One mechanism, eight repetitions: the per-player system-visibility record. Spine phase 24 or tail phase 21.
  • 18 leaves — TShn and ltis moving 1 → 2 on 8–10 systems. Both look like per-turn counters and both are unattributed; nothing in the campaign names their writer. They are the cheapest measured candidates on the board and they are deliberately not implemented, because "it went up by one across one turn" is a hypothesis, not a reading.
  • the rest: population growth, reputation, the output-rate re-normalisation, one new fleet.

The player side is dominated by three things: Sav (blocked behind the system-income term), the bankruptcy limits (blocked behind the same term), and the event buckets (blocked behind the localised event-text table).

5. What stands between this and the byte-match

In the order they must be solved, not in order of size.

  1. The RNG ledger. 18–20 words are consumed per turn and none are modelled. The generator is saved state, so a byte-match is arithmetically impossible until every draw is attributed — including the two draw sites in the post-combat tail, which happen after the spine has finished and before the file is written. Lane I proved the search space closes (exactly 22 draw sites in the spine's 1,426-function closure, plus an image-wide scan for inlined draws); lane Z is measuring the attribution. Nothing in src/app can close this.
  2. The population → output term. One unresolved formula blocks P01, P02, P05, P06 and T31 — that is 5 of the 44 phases and the single largest cluster of player-side leaves. It is the highest-value unresolved formula in the campaign for this milestone.
  3. The post-combat tail. 37 phases, none implemented, and it is the driver the autosave is written from. turnstats, the bankruptcy limits, the observed-design records and the player reports all live there.
  4. Summary.Checksum. Its algorithm is unknown. It is one leaf, and it is the last leaf: whatever it hashes, it cannot be right until everything it hashes is right.
  5. The Player.Status writer. The phase writes 1, the file carries 4, a load resets to 0. Four leaves, and a small, self-contained question.

There is also a floating-point requirement that is already measured rather than assumed: intermediates must be held at 53 bits and narrowed to float32 only on store, with round-to-nearest. 24-bit precision and round-up each move named leaves on this very corpus. game::sim already follows that rule; anything added to src/app must too.

6. Tests

test what it holds
app_catalog the three tables are complete, contiguous, uniquely named, and 32 + 12 = 44; every non-stub carries a note; nothing claims to be verified
app_turn over every save in $SOTS_SAVES_DIR: an untouched load re-serialises byte-identically; a turn leaves the file re-readable and re-serialisable; the modelled counters moved; no blocked or stub phase wrote anything; the generator is untouched by default. Skips cleanly when the variable is unset

7. What this lane did not do

  • It did not run the game. Lane S2 holds no VM; every comparison here is against saves the game already wrote.
  • It did not touch src/shim/, so no cross-build was required.
  • It did not implement a phase on a guess. Two obvious "+1 per turn" system counters were left alone for exactly that reason, and they are named in §4.1 so the next lane can close them properly.