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

213 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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