Commit graph

209 commits

Author SHA1 Message Date
alex
9ddd6a2f7e merge lane AI2: game/ai/tasks - task type enum, priority table, species creation order; selection loop is a stable sort not a search 2026-09-08 14:45:32 -04:00
alex
29ea2b65fa game/ai: the strategic AI's task vocabulary and its ordering policy
First module of game/ai, and the first piece of Rung B that is not scaffolding. It is the two
halves of the AI's task selection that are pure: the 33-value task type enumeration and the
ranking that decides which goal the AI acts on first.

  * the priority table, verbatim -- higher runs first, and it is the entire default policy;
  * the five overrides, kept out of the table on purpose. The two artifact tasks ignore their
    table entries (1 and 2) and return 1260/1261; a port that only copied the table would rank
    them last instead of fourth and fifth. The two tuned invade priorities are INPUTS
    (TaskPriorityPolicy), not constants, because their loader is not yet identified;
  * Rank() as a stable descending sort. The original sorts a std::list, so stability is the
    behaviour, not a choice -- ties keep creation order;
  * CreationOrder(species, policyNonZero), because that is what breaks the ties. Four arms: the
    NPC species builds nothing, Hiver is the only arm with the gate families, Zuul the only one
    with NodeBore, everyone else shares a fourth. Both defensive families are gated on the
    player's policy value and DefendGateIncoming is Hiver-only on top of that.

193 checks in tests/game_ai, every expected value read off the original's tables rather than
produced by running this code. ctest 46/46 -> 47/47; clean-room check OK.

Derivation: sots-re findings/subsystems/ai-task-system.md (lane AI2), sections 1-3.
2026-09-08 14:43:51 -04:00
lane-b6
0fcbb69ba5 game/sim + app: ship construction -- the build-queue completion bookkeeping, and S11's sub-pass wired in
game/sim/construction.{h,cpp}: Game::ShipRecords (four per-hull-class arrays plus the
per-design vector, sized by ENUMERATION against the wire, not by what the code touches)
and the completion bookkeeping BuildQueue::ProcessTurn performs -- the per-class built
counter, whose indexed increment has EXACTLY ONE writer in the whole image, and the
find-or-append per-design record keyed by the design's object id. RunSystemConstruction
wraps the point pass and keeps each completion's design id, which the point pass alone
does not report.

game/sim/colony: corrected from the instruction stream -- with points <= 0 the entry test
branches to the epilogue, so the REMOVAL SWEEP IS SKIPPED TOO. Carried as a labelled
hypothesis: no corpus save can reach the state that shows it.

app/construction_phase.{h,cpp}: S11's build-queue sub-pass, reported on its own line
because what blocks it is not what blocks the rest of the colony turn. It is blocked on
the per-system output term for points; it is NOT what the archived ship census waits on.

tests/game_sim/test_construction.cpp: 65 checks, including a corpus oracle the campaign
already owned and had not noticed -- zuul-turn16-noderoute -> zuul-turn17-rollpending is a
real consecutive-turn pair in which six orders complete and one is partially advanced.
The test SOLVES for the point total rather than assuming it, so a non-FIFO order, a
per-order budget or skip-instead-of-stop each falsify it.
2026-09-08 14:39:20 -04:00
lane-b6
9f8d75d9cb docs: B6 prediction before the build -- the build queue closes nothing on either reference pair
Written before any code (earned rule 2). Both reference saves carry three BQ frames with
zero orders, every hbq false and the only TurnCommands_v5 block empty, so the pass has
nothing to advance; the archived destroyer is created inside the turn by the AI. Includes
the falsification list and names the oracle pair the campaign already owns.
2026-09-08 14:33:16 -04:00
alex
0f1c007f00 merge lane E1: income chain 25/25 on the BnkEl oracle; difficulty multiplier resolved (turn.cpp union-resolved: tail phases 17, 21, 31) 2026-09-08 13:55:49 -04:00
alex
7c29700ab0 game/sim + app: the output -> money chain, the difficulty multiplier's source, T31
The second chain ComputeBudget needs. The output term (lane N) is a system's
OUTPUT; what the budget and the bankruptcy limits sum is its MONEY, which runs
that total through TradePointsToMoney. Read instruction by instruction, every
range disassembled to the next function start.

The income law is not the output law with a different constant: money per head
is typeIncomeMod / 14000 -- no 1.8, no 500000 -- and it truncates TWICE per
(group, species) row, once inside the per-row term and once after the morale and
addiction factors. Summing the species first and truncating once is wrong on any
colony with more than one species.

The multiplier that was "not on the wire" (formula-gaps.md Q3) resolves to a
three-row table the executable BUILDS IN CODE from .rdata float literals, exactly
like lane N's pop-type table: {int id; float ai[3]; float other[3]}, selected per
player by is-AI && !NPC. Every corpus save carries aidf == 1, whose AI income
column is 1.1f -- the x1.1 the BnkEl oracle measured. Its other two columns are a
fleet-maintenance divisor and a research multiplier, both of which already had a
home in BudgetInputs and no source.

Verified against the 25-record BnkEl oracle, which inverts the stored limit to
the true sum and therefore needs no VM: 6/25 before, 25/25 after. Falsified three
ways -- moving the AI flag off the AI players costs 11 records, moving it onto the
humans costs the other 11, and forcing one species' resource pair on all of them
costs every Zuul record.

app: T31 UpdateBankruptcyLimits now runs the whole roll-up and self-checks it
every run against the BnkEl the input save already carries -- 8 of 8 players on
turn1-state with --ai-player 1. It stays blocked on two things that are not the
formula: ServerPlayer+0xf9 (is this player AI?) is a game-setup input the save
does not carry, and BnkPr needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data
files. Committing it closes nothing on the reference pair -- the limits move
because the CIVILIAN POPULATION grows and that growth is not committed -- so
measured with --commit-blocked=T31 --ai-player 1: 0 closed, 0 REGRESSED, i.e.
209 -> 204 and 108 -> 103 unchanged.

P01 is NOT unblocked, and the roadmap's item 1 was wrong about that: ComputeBudget
takes its per-system money from ComputeOutput with the system's OWN rate sliders,
not from ComputeMaxIncome. Only its projected mode uses the max-income form. On
the turn path the repair pass runs and the unspent-industry and
unspent-terraforming cascades into the money channel are live, so the proof that
both are zero does not apply. The catalog text says so now.

Two things the 25/25 does NOT cover, and they are labelled in the code: the
suitability money cost is multiplied by zero on every corpus colony (all sit at
their species' ideal), and the slave, addiction, morale, station and
capacity-surplus branches are unexercised.
2026-09-08 13:49:28 -04:00
alex
06fe75880c sync generated header (1019 entries) after visibility merge 2026-09-08 13:42:52 -04:00
alex
c51b8d0c39 merge lane E3: nve visibility record; 46 leaves closed 0 regressed; the gate is AFlags 2026-09-08 13:40:06 -04:00
alex
a26cd183f1 lane H: entry-probe module, ProcessTeamRecord hook, probes= config, and the outcome of the five predictions 2026-09-08 13:38:59 -04:00
alex
a1910becb0 lane E3: the per-system visibility record, the explored sweep and the system observed-stamp
Three phases, one input. A star system carries four per-player masks and three of
them agree on nearly every system of every save the corpus holds, so a model built
on the wrong one looks right until it does not. The gate is the DERIVED
active-presence mask -- fleet-here OR gate-here OR owner, recomputed on every
arrival and departure -- not the sticky one and not the explored one.

  S29 SystemObservedStamp   the system's own last-observed turn (whole function)
  T17 RebuildPlayerViewTree the per-(system, player) observation record: who saw
                            the system, on what turn, and what encounter was there
  T21 UpdateSurveyAndStats  the explored sweep: seen this turn implies surveyed

game/sim/visibility is pure and knows nothing about save shapes; app/visibility_phase
wires it to them. The mask is READ FROM THE SAVE and never rebuilt: neither reference
pair moves a mask leaf, so the loaded value is the value these phases would see, and
rebuilding it from an unmodelled movement pass would be a change with no evidence.

Measured, closed and regressed reported separately and never netted:

  turn1-state -> turn2-state    209 -> 158   closed 51, regressed 0
  turn2-state -> turn3-state    108 ->  87   closed 21, regressed 0

of which this lane closed 46 and 16 (the rest were already closed at main). The 46
are the brief's 32-leaf target in full -- 8 record counts, 8 player ids, 8 turn
stamps, 8 encounter ids -- plus 8 system stamps and 6 explored masks.

Three further pairs the model was never fitted to, all zero regressions:

  human-turn2 -> human-turn3    353 -> 311   closed 42   (a different game, 21 systems)
  zuul15 -> zuul16              276 -> 264   closed 12
  zuul16 -> zuul17              341 -> 329   closed 12

The corpus's one discriminating row is a host test rather than a comment: a system
whose last visiting fleet has gone carries the sticky and explored bits set, the
active bit clear, and a stamp frozen a turn behind. The test asserts the freeze AND
asserts what the wrong gate would have produced, so a future edit that swaps the
mask fails loudly instead of quietly agreeing with five saves.

Labelled hypothesis, with the workload named in the header: the encounter id is
recovered from the encounter fleet at the system, because the field the original
reads is set once at map generation and is not on the wire. It agrees on all six
encounter fleets in the corpus and no save can separate it -- none kills an
encounter while leaving its system visible.

Not written, deliberately: the colony-ownership stamp that moves beside these.
Its gate is demonstrably NOT the active mask (one system in the corpus has a zero
mask and moves it anyway), the formula is not held, so it is reported, not written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 13:38:12 -04:00
alex
54b0353067 merge lane E2: ship census into BuildTurnRecord; phase-granular --commit-blocked; T36 blocked on budget + ship construction 2026-09-08 13:26:10 -04:00
alex
5b93a4f959 lane E2: wire the ship census into the turn record; T36 still blocked, now on two named things
The tail's last phase archives a per-player record whose 13 modelled fields were 7.
The six ship counts join them: every player's designs (normal and legacy, one id space)
are classified against the section catalog, and the global fleet list is walked keyed by
Flt.PID against each player's OBJECT id, not its vector position.

The census cannot come from a save. A design's hull size and its defence-platform flag
are recomputed from the section catalog whenever the design changes and are never written
down, so the standalone grows a data root -- `--data DIR`, or $SOTS_DATA_DIR. No game data
is embedded, and without a root the six counters report themselves unmodelled instead of
being written as six zeros that a wrong model would also produce.

`--commit-blocked=IDS` and `--commit-blocked-except=IDS` narrow the commit switch to named
phase ids. All-or-nothing across every blocked phase reports one closed count and one
regressed count for all of them at once, which is the netting the campaign does not do.

MEASURED, closed and regressed never netted, state_checksum leaves:

  turn1 -> turn2  default              209 -> 204  closed 5   regressed 0   (unchanged)
  turn1 -> turn2  --commit-blocked=T36  no data    closed 29  regressed 9
  turn1 -> turn2  --commit-blocked=T36  with data  closed 29  regressed 7
  turn2 -> turn3  default              108 -> 103  closed 5   regressed 0   (unchanged)
  turn2 -> turn3  --commit-blocked=T36  with data  closed 13  regressed 7

The prediction written before the code said the regressed list would fall to 6 and 8 under
a full --commit-blocked. It fell to 7 and 9. The prediction's first falsification case is
what happened: two census leaves closed and the third did not, because the census is of the
fleet list as it stands and no phase the standalone runs creates a ship. The archived count
is higher than ours by exactly one destroyer on BOTH pairs for the one player whose build
queue completes that turn, while the self-check on the input turn is exact. That leaf is
short by the turn's construction, not wrong about classification.

app_test_turn_record now compares the six counters against the record the game archived:
11 saves, 80 player-records, 1040 fields, 480 of them census leaves, 0 mismatches. That is
lane D2's 480/480 reproduced through this code path, which visits a design's slots in the
original's in-memory order (mission, command, engine) rather than the wire's.

T36 stays Blocked, and on two named things, neither of them in this phase:
sav and inc come from P01/P02, blocked on the per-system money output; and shpt[0] is short
by the ships the turn builds. The archived record is one struct on the wire, so those words
cannot be left out while the rest is written -- committing is all-or-nothing at the record,
and there is no field-granular knob that could change that. Seven confidently-wrong leaves
are not worth 29 that later lanes close for free.

COVERAGE, as loudly as the verdict: only 32 of the 480 archived census leaves are nonzero
anywhere in the corpus -- per leaf (cls0 shpt/satt, cls1 shpt/satt, cls2 shpt/satt) =
18/3, 0/0, 11/0. cls1 entirely and satt for cls2 have never been observed nonzero: three of
the six counters are unexercised hypotheses. The four loss/kill words of each group are zero
throughout and are written as zeros with no model behind them. Hull size is an assignment in
slot order, and design rule A6 means no save can tell the memory order from the wire order.
verified stays 0: nothing here was compared against a running game.

Gates, separately: clean_room_check OK; host ctest 45/45 without SOTS_SAVES_DIR and 45/45
with it. No src/shim file touched; the shim cross-build was NOT run (no i686 mingw here).
2026-09-08 13:25:17 -04:00
alex
0592104f4b sync generated header (986 entries) after alliance and design merges 2026-09-08 12:51:13 -04:00
alex
5a6a3155dc merge lane D2: ship-design persistence, hull class and defence-platform flag; census verified 480 leaves 2026-09-08 12:48:44 -04:00
alex
559d3e22a8 merge lane A2: alliance mask rule; ModCount writers enumerated 2026-09-08 12:48:44 -04:00
alex
8180792462 lane D2: aggregate hull size in the record's MEMORY slot order, not its wire order
The design record's section array is [mission, command, engine]; the wire is
[command, mission, engine]. Hull size is an assignment inside the original's
per-section loop, and that loop runs over the array, so the last resolved
section in MEMORY order wins -- engine, else command, else mission.

The first cut of this walked the wire order, which gives a different answer for
a design with an empty engine slot and both other slots filled. Rule A3 makes
that shape invalid, so no design in the corpus can tell the two apart and the
480/480 census result is unchanged either way -- which is exactly why the order
is now a named constant with the reasoning attached instead of whichever loop
was already to hand.

derive_stats takes hull_size after its loop for the same reason; the
defence-platform flag is an OR and stays in the loop.

52 design unit tests (the mixed-class case now pins memory order and adds an
engine-slot case), census still 480/480 on 11 saves, realdata still 127/127 and
197/197. host ctest 43/43, clean-room OK.
2026-09-08 12:47:14 -04:00
alex
f177a5fdd0 lane D2: hull size + the defence-platform flag; the ship census reproduces 480/480
The two derived words the per-player turn record's ship census counts by, and
the design serializers that three lanes had been told did not exist.

HOW DESIGNS PERSIST. Game::ShipDesign derives from Game::ShipDesignDef and
reaches IStreamable through adjustor thunks, so a design is written by TWO
serializers: the base emits FAIDes/DHide/DWep/DName and exactly three section
frames (command, mission, engine on the wire), the derived one appends Dtc, the
Dwgv flag and, only when that flag is set, a weapon-group frame. The earlier
"the writer makes no stream call at all" note named an address that is in no
vftable at all. Corrected in shapes.h.

THREE sections, not five. The "two reserved slots" were Dtc and Dwgv swept into
the section list by the reference reader's catch-all tail; the constructor
builds a three-element array. design.h's comment is corrected and the fixture
loader now accepts 3-5 raw_slots so old fixtures still load; the array keeps
five inert entries deliberately, since touching the slot enum reaches rules.cpp
and another lane's tests for no behavioural gain.

DWep and Dwgv are bools, not ints -- both writers call the bool primitive. With
four-character tags a bool item and an int item are the same size on the wire
and 0/1 the same bytes, so no save can tell them apart. Byte-neutral: the typed
round trip is still byte-identical on all 11 saves at 100% named coverage.

HULL SIZE is the section_class of the last resolved section in memory slot
order, mapped Destroyer/Cruiser/Dreadnought -> 0/1/2 case-insensitively, with
absent or unrecognised meaning 0 rather than an error. The DEFENCE-PLATFORM
flag is one bit of a 64-bit role-flag word OR-ed across the design's sections.
Neither is on the wire; both are rebuilt from the section catalog.

MEASURED, not assumed: the new game_design_census test rebuilds the six census
counters per player and compares them against the record the game archived for
each save's own frame. 11 saves, 503 designs, 480 leaves, 0 mismatched, 0 ships
with an unresolvable design, 0 designs where first- and last-resolved section
disagree on hull size. COVERAGE IS THIN AND THE TEST SAYS SO: only 32 of the
480 leaves are nonzero, and three of the six census leaves (both cruiser rows
and dreadnought platforms) are never exercised by any save in the corpus -- the
test prints the per-leaf nonzero counts and names them unexercised rather than
verified.

Nothing is wired into the turn record: src/app is another lane's this cycle, so
this is evaluated and reported, not written.

host ctest 43/43 (was 42/42; +1, skips cleanly without the env). With a data
root set, game_data_realdata and mars_text_realdata fail identically on main --
both are the absent Locale/EN/Strings.csv, not this change. clean-room OK.
Reference readers fixed openly in the RE repo: save_reader 49/49,
design rules 32/32, stock_designs.json regenerated (raw_slots 5->3 and dWep
int->bool are the only field changes across all 127 designs).
2026-09-08 12:44:41 -04:00
alex
0f9400a238 merge lane P2: nav path classifier (not a finder); waypoint type 2 is the Liir drive; three failure bits 2026-09-08 12:44:40 -04:00
alex
5e409cfa05 lane A2: S04, the alliance mask -- 80 player-records, 560 fields, 0 mismatches
The spine's fourth phase, read byte-for-byte and implemented:

    almem[i] = (1 << i) | (ALid != -1 ? AL : 0)

with i the player's POSITION IN THE PLAYER VECTOR, not its index field. Both
inputs are on the wire and so is the output, through the turn-record archive,
so the phase is checkable against bytes the original wrote:

    app_turn_record: 11 saves, 80 player-records, 560 fields, 0 mismatches
                     (was 480 fields over six fields; almem is the seventh)

The eight zero masks of the corpus's earliest archived turn are PREDICTED, not
excluded: the archiving phase also runs on load, and the load path does not run
the spine. BuildTurnRecord takes spineRan and models it, so all 80 records are
compared.

Three parts of the rule the corpus cannot separate -- the bit index, the OR,
and the ALid guard -- are pinned in app_alliance with the separating inputs no
save provides, and app_turn_record prints that it could not separate them.

Divergence, closed and regressed reported separately:

    turn1->turn2  default          209 -> 204   closed 5, regressed 0
    turn1->turn2  --commit-blocked 209 -> 189   closed 29, regressed 9  (was 17)
    turn2->turn3  default          108 -> 103   closed 5, regressed 0
    turn2->turn3  --commit-blocked 108 -> 106   closed 13, regressed 11 (was 19)

T36 stays blocked: nine leaves would still be wrong (inc x3, sav x3 behind the
budget; three census leaves behind the design catalogue). It now closes all 24
turnstats leaves on the reference pair, so it becomes a clean +24 once those
two land.

Prediction and falsification committed first in 49ae628.
Gates run separately: clean-room OK; host ctest 43/43. No src/shim touched.
2026-09-08 12:44:00 -04:00
alex
89f5d2f34f lane P2: fleet path planning (game/nav)
The strategic layer does not search for a route: the player or the AI picks the
destinations and the engine classifies each consecutive pair, deciding the waypoint
kind and whether the order is legal. This models that classifier as pure functions.

The waypoint kind of any leg that is neither a gate transit nor a node route is a
pure function of the owning species -- which is the whole answer to why kind 2 has
never been observed. Kind 2 is the Liir drive; the two node-drive races are Human
and Zuul, both of which map to kind 3, and every observation so far was taken on
one of those two.

Also modelled: the three refusal bits versus the nine advisory ones, the gate
transit that waives the grounded-fleet refusal, the projection radius that splits
gate-to-gate from gate-to-gateless, the single-hop node line lookup and bore, and
the fuel check whose range is squared at full precision while the distance is
narrowed -- the one floating-point asymmetry here that flips a decision.

The leading-destination drop is reproduced with its original off-by-one behind an
explicit flag rather than silently fixed.

120 hand-computed checks. Host ctest 43/43; clean-room check OK.
2026-09-08 12:43:11 -04:00
alex
a01305fcf2 lane H: P2b-alt, a competing prediction from the callee bodies (still before any run) 2026-09-08 12:31:29 -04:00
alex
1932377b92 lane H: five probe predictions, committed before the build 2026-09-08 12:23:42 -04:00
alex
49ae628220 lane A2: prediction for the alliance mask and ModCount, written before the build 2026-09-08 12:23:06 -04:00
alex
b2bad30f6c merge lane N: system output term (24,357 guarded calls, 0 undeclared writes); bankruptcy divisor fixed 2026-09-08 12:12:56 -04:00
alex
0ebc222f45 lane N: the population -> base-output term, live-verified
Reads the whole colony output chain off the instruction stream (every range
disassembled to the next function start) and compares two of its functions
against the running game.

The population -> output law is linear and is carried by the executable:
output points per head are typeOutputModifier x 1.8 / 500000, and the
three-row population-type table is built in code rather than loaded, so the
imperial (1.0) and civilian (0.33f) modifiers are facts about the binary.

A system's total output is a SUM of three terms, not one multiplicative
chain. The station bonus scales only the imperial term and morale only the
civilian one, so OutputModifiers no longer carries either; they belong to
GroupOutputInputs. The function previously described as the base-output term
is the over-harvest RESOURCE demand, and it is corrected in place.

Live on VM140, both hooks in compare mode over two species and two workloads:
GroupOutput 13,105 calls / 0 divergences; ComputeTotalOutput 11,252 calls /
1 divergence of one ulp, in a value its caller rounds to an integer. Both
functions declare a whole-object Guard: 0 undeclared writes in 24,357 calls,
which is what makes the side-effect-free claim a measurement.

sim::Narrow forces the double rounding a 32-bit x87 build otherwise skips;
without it every civilian row came out one ulp low.

Also fixes ComputeBankruptcyLimits' elimination divisor, which was the
decimal -0.15 rather than the image's widened float -0.15000000596046448.
The two disagree for every maximum income divisible by 3 and for essentially
every empire above ~3,000,000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 12:11:36 -04:00
alex
6f777c2541 sync generated header (875 entries) after retreat-pipeline merge 2026-09-08 12:04:40 -04:00
alex
419cbca824 merge lane B5: retreat pipeline (not a combat pipeline); game/combat/retreat planner; OID allocation closed 2026-09-08 12:01:30 -04:00
alex
ae170ecdfe B5: the post-battle retreat rules as a pure planner (game/combat)
The strategic half of a battle: where beaten fleets go, which fleets split,
which are left empty, and who learns the system they were beaten at. Draw-free
end to end -- the whole sub-tree contains no random draw -- so this is a pure
function of its inputs and needs no generator.

What is modelled:
  * the destination search: three independent nearest-system passes (owned /
    no hostile presence / anything), each with its own best-so-far, over
    squared float32 distances with a strict comparison. The independence is
    load-bearing: a nearer system rejected by one predicate must not spoil
    that pass's best, and a single-loop version gets it wrong.
  * the hostility mask, including the rule that a system captured on the
    current turn loses its owner's ceasefire cover.
  * per-ship eligibility: already-departed, encounter-faction exclusions (one
    hard-coded id plus a data-driven bitmask), and the dead-drive gate, which
    tests against a single-precision epsilon rather than zero and which the
    gate species skips because it does not fly out.
  * grouping on all four key words (owner, destination, mode, variant).
  * whole-versus-partial: a fleet runs whole only when every one of its ships
    is in the group; otherwise the group gets one new fleet and the leftover
    ships move into it, while ships of a wholly-retreating fleet stay put.
  * the emptied-fleet list, which matters because destroying a fleet aborts
    every intercept aimed at it.

Deliberately NOT modelled: applying the plan. Creating a fleet mints an object
id from a monotonic counter and appends to the master fleet list, and both of
those are saved state; that belongs above this layer, where the object store
lives. Keeping the decision separate is what makes it host-testable.

53 hand-computed checks. ctest 42/42, clean-room check OK.
2026-09-08 11:52:57 -04:00
alex
8c23f6bd74 merge lane Y: trade-raid RNG modelled under --commit-rng; T36 turn-record recovered (480 fields, 0 mismatches); generator gap measured at 4 and 2 words 2026-09-08 11:41:47 -04:00
lane Y
5cd301dcf5 Y: correct the divisor-defect numbers -- 6 of 25 corpus records, and the rate rises with magnitude 2026-09-08 11:39:36 -04:00
lane Y
655034fb51 Y: outcomes -- four predictions held, one was wrong in its framing and the correction is the result 2026-09-08 11:37:06 -04:00
lane Y
a98ae6db5b Y: model the turn's dominant generator cost, and the tail's turn record
The trade-raid block is 16 of a measured turn's 18-22 generator words and it is the
first thing the standalone can model as a COUNT rather than as a formula: two chance
rolls per entry of the player vector, neither site inside a back edge, both
probabilities strictly inside (0,1) so neither early-out fires. src/app/trade_raid
implements it with the word cost reported rather than assumed, so a tuning table that
pushes an odds value to 0 or 1 removes the draw and the ledger says so.

Against lane Z's two calibrated oracle pairs the standalone now consumes 16 words and
lands 4 and 2 short, which is exactly the per-call-site ledger's split for those two
turns. It does NOT match the oracle's state, and the report says which sites are
missing instead of netting them off the total.

The tail's last phase -- the per-player turn record -- is modelled for the six fields
that are recoverable from the wire, and self-checked every run against the record the
input save already carries for its own turn: 480 fields over 80 player-records across
the corpus, 0 mismatches. It is not committed. Under --commit-blocked it closes 24
container-shaped divergences on the reference pair and opens 17 leaf-shaped ones, all
of them in the five fields the model does not hold, so the block is a measurement now
rather than an argument.

phases 14/44 of the two turn drivers (8 committed), 3/37 of the tail.
divergence unchanged: 209->204 and 108->103, 5 closed / 0 regressed on both.
2026-09-08 11:33:36 -04:00
lane Y
20e5112398 Y: predictions for the RNG model and the tail turn record, written before the build 2026-09-08 11:21:00 -04:00
alex
b48d860f8a merge lane Z: per-turn RNG ledger (header regenerated, CMakeLists union-resolved) 2026-09-08 10:58:10 -04:00
alex
2a9b97dcee Z: per-call-site draw ledger -- seven entry points, keyed by return address
The boundary ledger says which phase spends a turn's words; this says which
call site. Each entry point is detoured with its verified prototype and records
__builtin_return_address(0) plus the word cost from left before/after -- two
4-byte reads, no record per draw, since NextFloat alone has 109 call sites.

Each draw is tagged with WHICH generator it came from. That is not a detail:
the first build counted every Mars::RNG instance in the process and reported 44
words against a bracket of 18. The StrategyClient's generator at +0x134 draws 8
times a turn and must not be in the strategic total.

Also hooks EncounterDetect::AssignContacts, lane I's one inlined-draw site in
ProcessTurn's closure -- invisible to both a call-graph sweep and to the entry-
point detours, so only a boundary hook can see it. It did not fire on this
workload, which is consistent with the site sums reconciling exactly.
2026-09-08 10:42:05 -04:00
alex
ce12a5a0b2 merge lane S2: src/app standalone turn runner; phase catalog across all three drivers; divergence + completion metric 2026-09-08 10:36:50 -04:00
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
alex
2f4fcfcb21 Z: P9 outcome -- turn 64, one word, every clause held 2026-09-08 10:24:17 -04:00
alex
0b5679cf30 Z: P10 outcome -- wrong, and the falsification is the result 2026-09-08 10:15:06 -04:00
alex
d7e2080fb6 Z: P10 -- what Auto Resolve should cost, written before the click
The turn-54 End Turn stopped on a Von Neumann encounter at Gallandro. That is
the workload the finding says does not exist and lane J asks for: every
encounter measured so far had the no-battle flag set, so the combat resolver
has never run under an instrument.

Three falsifications, and the interesting one is the third: a non-zero cost
that does not appear under ApplyEncounterResult means combat proper is drawing,
which nothing hooks, and the bracket residual goes positive for the first time.
2026-09-08 10:12:09 -04:00
alex
bcf429740c merge lane I: MT19937 float_range/range_from/int_range_bell; RNG entry-point word-cost table 2026-09-08 10:04:43 -04:00
alex
dc43f93910 mars::rng: the seven draw entry points, with their word costs
A per-turn RNG budget is only as good as the entry-point table, and ours had
three of the seven. Adds the two that are modellable and documents the rest.

  float_range(lo, hi)     exactly one word.  Narrows TWICE -- the scaled product
                          is stored to a 4-byte float before lo is added, and the
                          sum is stored again.  Evaluating in double and narrowing
                          once disagrees on a measurable fraction of words, and the
                          test asserts the two models are distinguishable so the
                          shortcut cannot creep back.
  int_range_bell(lo, hi)  AT LEAST TWO words.  Triangular, not uniform: the span is
                          split into h/2 and h - h/2 (truncating toward zero) and
                          each half drawn inclusively, first half first.  The bounds
                          reach the draw as unsigned, so an inverted range yields a
                          huge first bound rather than an empty one; reproduced, not
                          corrected.

Documented but deliberately not modelled: a truncated-normal integer range built
on rejection sampling around a Box-Muller pair.  It costs TWO WORDS PER ATTEMPT
and the attempt count is unbounded, and predicting its stream position needs log,
sqrt and cos to agree bit for bit with the original CRT.  Nothing in the strategic
turn reaches it.  It is recorded so a ledger that meets it does not score its two
words as one draw.

Also recorded in docs/mars-rng.md, because each is a way a word budget goes wrong:

  * two calling conventions for one generator -- three entry points take the state
    block (the object plus four bytes) and four take the object itself, and one
    caller uses both within forty bytes of itself;
  * two different divisors in the same image, 1/(2^32 - 1) for the unit draw and
    2^-32 (with a +0.5 offset on the word) for the normal path;
  * the unit draw is inlined at twenty-eight sites across eleven functions, so any
    budget assembled by counting calls is a LOWER BOUND.  Exactly one of those
    eleven is reachable from the strategic turn driver.

Host ctest 36/36 and tools/clean_room_check.sh run as separate commands, both
clean.  No src/shim change, so no cross-build is implicated.
2026-09-08 09:59:05 -04:00
alex
fe39c82bea Z: P9 -- the first node-line draw lands on turn 64 and costs exactly 1 word
Written with the run in flight at turn 34. min_life is decrementing by exactly
1 per turn (43 down to 30), so the traffic term contributes nothing on this map
and the oldest mortal line expires 30 turns out.

Four ways it can be wrong, each with its own symptom. Every prediction this
lane has made so far compared 0 against 0; this one does not.
2026-09-08 09:50:21 -04:00
alex
6589a3985f Z: the run, and one falsified prediction of my own
Eight End Turns across two saves. A turn costs 18-22 generator words, all of
it inside StrategyServer::ProcessTurn; the tail costs 0; the residual outside
the two drivers is exactly 0 on every complete bracket. The generator does not
move between turns at all, so the interval a standalone has to reproduce is
closed at both ends.

Checked against the save files, not just against itself: the turn-6 autosave
pair gives 18 words read from the two Sim.RNG blobs, with twists == 0 -- so
that number never passes through a twist implementation and the agreement is
about the game rather than about two copies of one algorithm.

P6 was wrong. S+0x8 advances 12-14 times per turn, not twice; both drivers are
hooked so the other increments come from somewhere unidentified. Lane K's 'at
least twice' was right and its conclusion is strengthened.

Node-line decay still has not fired, and the hook now says how far away it is
rather than that it did not happen: 51 of 53 lines are permanent, the mortal
ones are dug ~1/turn by the Zuul, and each is ~40 turns from expiry.
2026-09-08 09:43:46 -04:00
alex
496a5124c9 Z: measure the strategic RNG, do not assume it
The turn's RNG cost has never been measured end to end. combat-done-tail.md
found two draw sites in OnAllCombatDone_Tail that nothing models and that both
run before the autosave, so a reimplementation that reproduces both ProcessTurn
functions exactly still diverges the first turn a node line expires.

RngLedger recovers an ABSOLUTE WORD POSITION from (mt[624], left) alone, by
indexing the forward-only chain of blocks the twist generates. Word deltas
between any two observations are then exact -- across twists, across NextInt
rejection loops, and across draws nobody hooked. That last point is not
theoretical: the image has four draw entry points, one of which (NextUInt
0x004f7670) appears in no previous lane's primitive set, plus inlined draws in
twelve functions. A primitive-counting hook would have undercounted silently.

Six nested trace hooks bracket one End Turn between the two autosaves and
attribute the words: the two turn drivers, the two tail phases that can draw,
and ProcessNodeSpaceTravel because it runs twice a turn. NodeLineDecay carries
a real model -- one word per expired node line under NodePath::RemainingLife --
so compare mode checks the count rather than reporting it.

Corrections from the instruction stream, both load-bearing:
  * StrategyHost::Autosave is ret 8, not ret 4, and returns the std::string* in
    EAX. A void-returning hook would have dropped it at both call sites.
  * node-line decay's 0x20000-fleet skip runs AFTER the Chance(0.5f) call, not
    before, so it cannot change the draw count -- combat-done-tail.md reads as
    if it gated the roll.

fpu.sample_turn releases StrategyServer::ProcessTurn, which the fpu sampler and
this ledger both want and MinHook grants to one of them. Default on: no
existing run changes behaviour.

Host ctest 37/37; shim cross-built on CT111; clean-room check OK.
2026-09-08 09:16:24 -04:00
alex
db0065f8c5 CONTRIBUTING: note uv tool install cmake as the local workaround 2026-09-08 09:07:47 -04:00
alex
aae2369a47 sync generated header (756 addresses) after turncommands merge 2026-09-08 09:07:14 -04:00
alex
7f0b2a7c0a merge lane Q: TurnCommands_v5 typed; named coverage 100% on all 11 saves; NVs PID tag fix (round-trip defect present at main) 2026-09-08 09:04:20 -04:00
alex
c662f73b69 lane Q: type TurnCommands_v5 -- named coverage to 100% on all 11 saves
The last opaque block of the save format. Reconciles lane W's provable negative
against lane O's issued-order saves: the two disagreed because three different
things were being compared as one.

Read out of Game::TurnCommands::Write (0x00842540, 764 bytes, no loops of its
own). Two halves:

  * a PROLOGUE of six flag-gated groups. Each is a WriteBool on a member,
    followed only when set by that command's payload. Write order is NOT offset
    order -- each gate sits after its payload in the struct -- so the class is
    one of the 89 whose offset-sorted layout view cannot be aligned to the wire.
    That, not a branch, is why the sorted view showed an i32 where the save has
    a bool at item 4.
  * TWENTY-SEVEN std::list<T> members (0x70..0x1a8, stride 0xc, allocator-last),
    each written by its own helper as WriteInt(size) then size element records.
    All 27 are always written, so an empty list still costs one zero int.

So the recovered 44 items are 17 member writes plus ONE ITEM PER CONTAINER CALL
SITE: the linear recovery keeps the call site, guesses its kind from an element
field it could resolve, and drops the count word. 44 - 17 = 27 = the number of
lists. The "27 trailing ints with only 22 i32 slots" objection is that same 27
seen from both sides -- a kind mismatch in the table, not a structural
impossibility. And 8 prologue items + 27 zero counts = the 35-item block every
no-orders save carries bit-identically.

Item arithmetic closes to the unit on all five distinct workloads (35/38/41/61/
123). One correction to the provisional layout: the fleet-move element ends in a
COUNTED route vector {fleetId, nHops, nHops x systemId}, not a fixed quadruple --
with a four-item element the writer would need 26 lists on one save and 28 on
another, and it has 27.

Conformance: the generic check cannot be used here. Every tag is "." so the LCS
degenerates to a strict positional compare in which any primitive disagreement is
fatal, and the table's tail describes elements where the wire has counts. A
dedicated check states what is checkable instead: the 17-item prologue item for
item (17/17, and SchemaProbe takes every branch, so this is real evidence that
the conditional structure read from the instruction stream is the one the
recovery flattened) and the tail count (27 lists vs 27 table items). The tail is
reported as wire-only, never claimed as matched.
  86 shapes / 838 items -> 87 / 856, still 0 MISMATCH.

Twenty-two of the 27 lists are HYPOTHESES: the scalar sequence comes straight off
the helper, but no save exercises them. Nested element bodies not otherwise
modelled here (ShipDesignDef, FleetLayout, WeaponGroups, DefenceLayout,
RaidTargets) are carried as opaque Nodes rather than guessed at, so a wrong body
cannot desynchronise a reader. select() matches the exact ".TurnCommands_v5"
suffix, so a future _v6 falls back to the carried Node.

SECOND DEFECT, present at main and unrelated to this block: zuul-turn23-fleet23
is the first save with a non-empty NVs list, and its element's leading id was
typed positionally as "." where the real tag is PID. The typed round trip on that
save differed at 0x89c14. Rule 6 exactly -- a path no save exercised was a
hypothesis flying as a fact. Fixed, and independently corroborated: the recovered
table for Game::ServerSystem names that item PID, and the Sys row moves from
102 matched / 1 wire-only / 3 shape-only to 103 / 0 / 2.

Coverage (CoverageArchive typed-vs-carried, not round-trip success): all eleven
saves 99.7-99.9% -> 100.0%; opaque items 37/43/63/132 -> 2 everywhere, and those
two are the deliberately-carried MT19937 block. Nothing else in any save we hold
is untyped. Ratchet 99.8 -> 99.99. Round trip byte-identical on all eleven
(newly so on zuul-turn23-fleet23).

test_save now asserts, on every real save, that each TurnCommands_v5 block is
consumed by the prologue plus the 27 lists with nothing left over -- the
item-granular statement a wrong list count or element width breaks first.

clean-room OK; host ctest 36/36; test_save 11 saves 0 failures. src/shim/ not
touched, so no cross-build was needed.
2026-09-08 09:03:11 -04:00
alex
45a7a8c7b8 Z: prediction for the per-turn RNG ledger, written before the build
Six nested trace hooks bracket one End Turn between the two autosaves and
attribute every word the strategic generator consumes to a phase. Position is
recovered from (mt[624], left) alone via a forward-only block chain, so word
deltas are exact across twists and across NextInt rejection loops.

Eight predictions with their falsifications, including the two that matter:
the tail runs on a no-combat turn (lane K inferred it), and a quiet turn's
residual outside the two drivers is zero.
2026-09-08 09:02:32 -04:00