diff --git a/docs/Y-standalone-rng-and-tail.md b/docs/Y-standalone-rng-and-tail.md new file mode 100644 index 0000000..6f060e1 --- /dev/null +++ b/docs/Y-standalone-rng-and-tail.md @@ -0,0 +1,204 @@ +# Lane Y — the predictions, written before the build + +Committed before `src/app` was touched, per earned rule 2. Falsification sections included: how each +prediction could be wrong, and the symptom of each way. + +Two things are being predicted: (a) what the standalone's generator does on lane Z's two calibrated +oracle pairs, and (b) whether a turn-record model built from the wire reproduces the record the game +already wrote into every save it made. + +--- + +## P-Y1 — the trade-raid model costs exactly 16 words on an 8-player save + +Lane Z measured `ServerTradeManager::GenerateTradeRaidEncounters` firing two `Mars::RNG::Chance` +rolls for every entry of the server's player vector, and showed that no back-edge contains either +site, so one word per player per site is a **bound**, not an observation. Both probabilities are +strictly inside (0, 1), so `Chance` takes neither early-out and spends exactly one word. + +The reference save's player vector holds **8** entries (`Sim.NumPlrs`; the wire's `players` array is +that same vector). + +> **The standalone, run with `--commit-rng` on `z2-endturn.sav`, consumes exactly 16 generator +> words**, all of them attributed to the trade-raid phase, and the post-turn `left` is +> `395 − 16 = 379`. `mt[624]` is unchanged (no block boundary is crossed). + +Same claim on `z-t6-endturn.sav`: 16 words, `left` `375 − 16 = 359`. + +**Falsification.** + +| how it could be wrong | symptom | +|---|---| +| the loop is not over the server's player vector | a word count that is not 2 × 8 | +| `Chance` is `p >= r` or narrows differently | word count still 16 (the count does not depend on the comparison) — this prediction cannot test it, and says so | +| the NPC-raid gate is not open on this save | 8 words, not 16 | +| the refugee-raid gate *is* open | 24 words | +| the engine's `next_float` disagrees with the original | invisible here; only the count is being tested | + +--- + +## P-Y2 — the residual against the oracle is 4 and 2, not 0 + +The oracle pairs carry a known cost: `z2-endturn` → `z2-autosave` is **20** words, `z-t6-endturn` → +`z-t6-autosave` is **18**. Lane Z's per-site ledger attributes the difference to four sites the +standalone does not model: + +* 0x0050329d (`NextFloat`) and 0x007929a4 (`NextInt`), both under `StrategyServer::DetectEncounters` + — one word each per turn on 3 of 3 measured turns; +* `TechTree::ProcessResearch`+0x1c8 and `ServerPlayer::RollResearchEvent`+0x2f — 0–1 words each, both + downstream of the research allocation, which is downstream of `ComputeBudget`, which is blocked on + the population → base-output term. + +> **The standalone lands 4 words short on the turn-5 pair and 2 words short on the turn-6 pair**, and +> the shortfall is exactly the four sites above: 2 detection + 2 research on turn 5, 2 detection + 0 +> research on turn 6. The generator state therefore does **not** match the oracle on either pair, and +> the answer to "does it land on the oracle's `mt[624]`/`left`" is **no**, by a stated amount. + +**Falsification.** A residual other than 4 and 2 means the site ledger and the file oracle disagree +about this turn, which would be a real contradiction between two of lane Z's three instruments and +would have to be reported as one. A residual of 0 would mean the trade-raid count is coincidentally +absorbing the other sites. + +**Why the shortfall is not closed by guessing.** Modelling "2 detection words per turn" would fit +three observations with no derived rule behind it, and lane I bounds that site's cost at +`0 .. |contacts| × |detectors|`. It is left unmodelled and reported. + +--- + +## P-Y3 — six fields of the turn record are recoverable from the wire + +`StrategyServer::FinalizeTurnRecords` (tail phase 36) fills a per-player record and archives it by +turn. The archive is on the wire: it is `/Sim/turnstats/nply[]/hist/stats[]`, one `stats` element per +turn per player, and every save carries an element for its own `Frame`. + +> **A record built from a save's own state reproduces that save's archive entry for its own turn, on +> every player of every save in the corpus, for these six fields:** +> +> | record field | rule | +> |---|---| +> | `trn` | the frame counter | +> | `col` | the number of owned-system ids | +> | `sav` | the player's savings | +> | `inc` | savings minus previous-turn savings | +> | `tch` | the number of tech-tree states in the completed state | +> | `pop` | summed over owned systems: population plus the pending population bonus | +> +> and it does **not** reproduce `almem`, `tdinc`, `bat`, the acquired/lost system lists, or the +> per-hull-class ship census, because each of those is written by a phase or an input the standalone +> does not hold. + +**Provenance, stated plainly.** The six rules were found by search over the corpus in Python before +this was written, so this is not a blind prediction of the rules. What it *is* a prediction of is +that a C++ transcription over the engine's own typed shapes agrees with them on all 112 +player-records — a test of the transcription and of the shape typing, not of the rules. Two of the +six (`pop`, `inc`) were ambiguous in the Python search and were separated by the corpus: +`pop` = Σ(`Pop` + `pbon`) matched 112/112 while Σ(`Pop` + `dcs`) matched 101/112. + +**Falsification.** Any player-record where the six computed fields differ from the stored ones. +Particularly: a save where a player's owned-system list references a system id the save does not +carry (the sum would silently drop a term). + +--- + +## P-Y4 — committing phase 36 would regress, so it is not committed + +The record the standalone can build is the record for the **input** save's turn, not for the turn it +is about to produce: `sav` after the turn needs `ApplyNetToSavings`, which is blocked, and `almem` +needs the alliance-mask rebuild, which is a stub. + +> **Committing phase 36 would add a new archive element whose `sav`, `inc`, `almem`, `tdinc` and +> class census are all wrong, turning one container-shaped divergence per player into several +> leaf-shaped ones. The divergence report would show a large `regressed` count and a small `closed` +> one.** It is therefore evaluated, reported, and not written. The divergence baseline stays +> 5 closed / 0 regressed on both pairs. + +**Falsification.** If committing it closed more than it broke, the block was too conservative. That +is measurable with `--commit-blocked` and is reported rather than argued. + +--- + +## P-Y5 — the bankruptcy divisor in the engine is one ulp wrong + +`ComputeBankruptcyLimits` divides by `-0.15`. The constant the original loads is the double +`-0.15000000596046448`, which is `(double)(float)-0.15f`. The two differ, and the difference is +visible on a large enough maximum income. + +> **`ftol(maxIncome / -0.15)` and `ftol(maxIncome / -0.15000000596046448)` differ for some +> `maxIncome` reachable in a real game**, and the smallest such value is small enough to be found by +> a linear scan. + +This lane does not fix it: the file is another lane's. It is measured and reported. + +**Falsification.** If no `maxIncome` in the int32 range separates the two constants, the correction +is real but inert and should be recorded as such. + +--- + +# Outcomes + +Written after the run, against the predictions above. Nothing above was edited. + +## P-Y1 — held exactly + +16 words on both calibrated pairs, all attributed to the trade-raid phase, `left` 379 and 359. The +player vector read 8 on both, as the wire says. The falsification table's other branches did not +fire: not 8 (the NPC gate was open), not 24 (the refugee gate was not). + +## P-Y2 — held exactly + +Residual 4 on the turn-4→5 pair and 2 on the turn-5→6 pair, matching the per-call-site split +(2 detection + 2 research, and 2 detection + 0 research). The state block is byte-identical in both +cases and only `left` differs, so the two instruments disagree about nothing. + +**The answer to the milestone question is therefore "no", by 4 words and by 2.** + +One thing this pair of numbers does *not* establish, and the prediction should have said so: the +residual is a subtraction, so it confirms the **sum** of the modelled and unmodelled terms, not each +term separately. The static bound is what carries the 16; this arithmetic corroborates it. + +## P-Y3 — held + +480 fields over 80 player-records across 11 saves, 0 mismatches, 0 dangling owned-system ids. The +same check runs inside every turn and is printed as a phase note. + +## P-Y4 — held, and the shape of the result is more useful than the claim + +`--commit-blocked` closes 29 and regresses 17 on the first reference pair, and closes 13 and +regresses 19 on the second. Every regressed leaf is in the unmodelled set: `almem` x8, `inc` x3, +`sav` x3, three of the ship census. Nothing outside it moved. + +So "committing makes it worse" is **not** what the measurement says: on the first pair the turnstats +subsystem is net +7 and on the second it is net -9. The record is right in six fields and wrong in +five, and which way the net falls depends on the save. The phase stays blocked, and it becomes a +clean +24 the moment the alliance mask and the budget land. + +## P-Y5 — WRONG in its framing, and the correction is the result + +The prediction said the two constants differ "for some maxIncome reachable in a real game" and that +the smallest such value could be found by a linear scan. The scan found it immediately, and the +framing -- inherited from the phrase "one ulp out on large empires" -- was the wrong picture +entirely. + +`trunc(m / -0.15)` and `trunc(m / -0.15000000596046448)` differ for **every m divisible by 3**: 1000 +of the first 3000 integers, smallest `m = 3` giving -20 against -19. It is not precision loss at +scale. `m / 0.15` is an exact integer whenever 3 divides m, and the two constants straddle it. + +And the multiples of 3 are only the floor: the disagreement rate is 33.3% up to about 1.2 million, +66.7% by 1.36 million and 100% at and above 3 million. + +Measured on the corpus by inverting the stored limit: **6 of 25 player-records would get a different +stored value from the engine's current constant than the game wrote.** The defect is live, it is a +whole unit rather than an ulp, and it is a third of all inputs at small values and all of them at +large ones -- the opposite shape from "one ulp on large empires", which would have been harmless. + +The file belongs to another lane in flight, so it is reported and not fixed here. + +## An unpredicted result: the blocked input is recoverable from every save + +The elimination limit is injective in the maximum-income sum it is computed from (slope about 6.67), +so it inverts. Every save in the corpus therefore states, for every player that owns anything, the +exact sum of its systems' maximum money outputs -- which is the term that blocks `ComputeBudget`, +both savings phases, the bankruptcy limits and five fields of the turn record. + +That was not predicted and is the most useful thing this lane found. The same inversion recovers the +protection-limit factor as 3.3 from the saves, independently of the game data files. diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 439622f..741d372 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -5,6 +5,8 @@ add_library(sots_app STATIC phase_catalog.cpp + trade_raid.cpp + turn_record.cpp turn.cpp report.cpp) target_include_directories(sots_app PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..) diff --git a/src/app/phase_catalog.cpp b/src/app/phase_catalog.cpp index 8354336..479c2d1 100644 --- a/src/app/phase_catalog.cpp +++ b/src/app/phase_catalog.cpp @@ -107,11 +107,14 @@ constexpr PhaseDesc kStrategic[] = { "draw-free, and the cheapest unimplemented phase in this table"}, {Driver::Strategic, 29, "S29", "SystemTailFixup", PhaseStatus::Stub, ""}, {Driver::Strategic, 30, "S30", "BuildTeamPartition", PhaseStatus::Stub, ""}, - {Driver::Strategic, 31, "S31", "EncounterDetectionAndStatusRestore", PhaseStatus::Blocked, - "encounter detection is not modelled. The player-status restore that follows it IS -- it " - "writes 1 -- but the value the file carries is 4, so a further writer between this phase " - "and the autosave is unaccounted. Committing the 1 turned two agreeing leaves into " - "disagreeing ones on the turn2->turn3 pair, so it is evaluated and reported instead"}, + {Driver::Strategic, 31, "S31", "EncounterDetectionAndStatusRestore", PhaseStatus::Partial, + "trade-raid generation runs first here and IS modelled: two chances per player, one word " + "each, neither site inside a back edge, so the count is a bound -- it is the turn's " + "dominant generator cost and it is committed under --commit-rng. Detection proper is not " + "modelled and spends two further words. The player-status restore that follows it IS " + "modelled -- it writes 1 -- but the value the file carries is 4, so a further writer " + "between this phase and the autosave is unaccounted. Committing the 1 turned two agreeing " + "leaves into disagreeing ones on the turn2->turn3 pair, so it is evaluated and reported"}, }; // --------------------------------------------------------------------------------------- @@ -209,8 +212,13 @@ constexpr PhaseDesc kTail[] = { {Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""}, {Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""}, {Driver::Tail, 35, "T35", "RebuildPlayerReports", PhaseStatus::Stub, ""}, - {Driver::Tail, 36, "T36", "FinalizeTurnRecords", PhaseStatus::Stub, - "fills every player's turn record and archives it by turn; must stay last"}, + {Driver::Tail, 36, "T36", "FinalizeTurnRecords", PhaseStatus::Blocked, + "fills every player's turn record and archives it by turn; must stay last. Six of its " + "fields are recoverable from the wire and are reproduced -- turn, colony count, savings, " + "the savings delta, the completed-tech count and the summed population -- and the model is " + "self-checked every run against the record the input save already carries for its own " + "turn. Not committed: five further fields of the same record are unmodelled, and savings " + "for the NEW turn comes from a blocked phase"}, }; PhaseTally Tally(const PhaseDesc* p, std::size_t n) { diff --git a/src/app/report.cpp b/src/app/report.cpp index 5d8030f..d1cb062 100644 --- a/src/app/report.cpp +++ b/src/app/report.cpp @@ -75,8 +75,13 @@ void PrintSummary(std::FILE* out, const TurnResult& r) { std::fprintf(out, "\nthis run\n"); std::fprintf(out, " leaves written %d\n", r.leafWrites); std::fprintf(out, " leaves NOT written by a blocked phase %d\n", r.wouldWrite); - std::fprintf(out, " generator words consumed %d (state %s)\n", r.rngWords, - r.rngLoaded ? "loaded" : "UNREADABLE"); + std::fprintf(out, " generator words consumed %d (state %s, %s)\n", r.rngWords, + r.rngLoaded ? "loaded" : "UNREADABLE", + r.rngCommitted ? "WRITTEN BACK" : "left untouched"); + if (!r.rngUnaccounted.empty()) { + std::fprintf(out, " generator words NOT accounted (never netted off the above):\n"); + for (const auto& u : r.rngUnaccounted) std::fprintf(out, " - %s\n", u.c_str()); + } for (const auto& w : r.warnings) std::fprintf(out, " ! %s\n", w.c_str()); } @@ -96,7 +101,11 @@ bool WriteMetricJson(const std::string& path, const TurnResult& r, const std::st f << ",\n"; f << " \"run\": {\"leafWrites\": " << r.leafWrites << ", \"blockedLeafWrites\": " << r.wouldWrite << ", \"rngWords\": " << r.rngWords << ", \"rngLoaded\": " - << (r.rngLoaded ? "true" : "false") << "},\n"; + << (r.rngLoaded ? "true" : "false") << ", \"rngCommitted\": " + << (r.rngCommitted ? "true" : "false") << ", \"rngUnaccounted\": ["; + for (std::size_t i = 0; i < r.rngUnaccounted.size(); ++i) + f << (i ? ", " : "") << '"' << JsonEscape(r.rngUnaccounted[i]) << '"'; + f << "]},\n"; f << " \"phases\": [\n"; bool first = true; for (const auto& rec : r.records) { diff --git a/src/app/trade_raid.cpp b/src/app/trade_raid.cpp new file mode 100644 index 0000000..bc17520 --- /dev/null +++ b/src/app/trade_raid.cpp @@ -0,0 +1,37 @@ +#include "app/trade_raid.h" + +namespace sots::app { +namespace { + +// The chance helper with its word cost reported rather than assumed: the two early-outs +// return WITHOUT drawing, so a probability outside (0, 1) costs nothing. Counting the draws +// instead of the calls is what keeps the ledger honest if a loaded tuning table ever pushes +// one of the three odds to 0 or 1. +bool ChanceCounted(sots::sim::IRandom& rng, float p, int& words) { + if (p <= 0.f) return false; + if (p >= 1.f) return true; + ++words; + return p > rng.NextFloat(); +} + +} // namespace + +bool Chance(sots::sim::IRandom& rng, float p) { + int ignored = 0; + return ChanceCounted(rng, p, ignored); +} + +TradeRaidResult RollTradeRaids(sots::sim::IRandom& rng, int players, const TradeRaidOdds& odds, + const TradeRaidGates& gates) { + TradeRaidResult r; + if (players <= 0) return r; + r.players = players; + for (int i = 0; i < players; ++i) { + if (ChanceCounted(rng, odds.player, r.words)) ++r.playerRaidHits; + if (gates.npcRaids && ChanceCounted(rng, odds.npc, r.words)) ++r.npcRaidHits; + if (gates.refugeeRaids && ChanceCounted(rng, odds.refugee, r.words)) ++r.refugeeRaidHits; + } + return r; +} + +} // namespace sots::app diff --git a/src/app/trade_raid.h b/src/app/trade_raid.h new file mode 100644 index 0000000..232ca87 --- /dev/null +++ b/src/app/trade_raid.h @@ -0,0 +1,55 @@ +// Trade-raid encounter generation -- the strategic turn's dominant RNG consumer. +// +// The original runs this from inside encounter detection, over the server's player vector, +// rolling up to three chances per player. Two of the three roll on every turn measured; the +// third is behind a subsystem that was not present. The important property, and the reason +// this is modelled at all while the rest of encounter detection is not, is that NO BACK EDGE +// contains any of the three roll sites: the cost is one word per player per firing site, as a +// bound rather than as an observation. +// +// The probabilities are simulation constants that come from the game data. Their in-image +// defaults are used when no tuning table is loaded; either way all three are strictly inside +// (0, 1), so the chance helper takes neither of its early-outs and every roll is exactly one +// word. That is why the WORD COUNT is independent of the probabilities and the RAID DECISIONS +// are not. +#pragma once + +#include "game/sim/rng.h" + +namespace sots::app { + +// The chance helper's contract, transcribed: p <= 0 is false with NO draw, p >= 1 is true with +// NO draw, otherwise exactly one draw and the test is `p > r` -- equality is a failure. A NaN +// probability falls through both early-outs and draws. +bool Chance(sots::sim::IRandom& rng, float p); + +struct TradeRaidOdds { + // In-image defaults. A loaded tuning table overrides them; the word cost does not move. + float player = 0.2f; + float npc = 0.05f; + float refugee = 0.05f; +}; + +struct TradeRaidGates { + // The NPC-raid roll is behind a single server-level test that does not depend on the + // player, so it is all-or-nothing for the whole turn. Measured open on 8 of 8 turns + // across two saves; it is not on the wire, so this is a HYPOTHESIS with a stated default. + bool npcRaids = true; + // The refugee-raid roll needs a subsystem manager that was absent on every measured turn. + bool refugeeRaids = false; +}; + +struct TradeRaidResult { + int players = 0; // entries of the player vector the loop visited + int words = 0; // generator words consumed + int playerRaidHits = 0; // rolls that SUCCEEDED -- each one may cost a further word we do + int npcRaidHits = 0; // not model (the target-selection draw), so a non-zero total + int refugeeRaidHits = 0;// here is the signal that the ledger is about to fall short +}; + +// Roll the raid chances for `players` players, in the original's order: player raid, then NPC +// raid, then refugee raid, per player, before moving to the next player. +TradeRaidResult RollTradeRaids(sots::sim::IRandom& rng, int players, const TradeRaidOdds& odds, + const TradeRaidGates& gates); + +} // namespace sots::app diff --git a/src/app/turn.cpp b/src/app/turn.cpp index 94efd0a..9a02f9e 100644 --- a/src/app/turn.cpp +++ b/src/app/turn.cpp @@ -7,6 +7,8 @@ #include #include +#include "app/trade_raid.h" +#include "app/turn_record.h" #include "game/sim/colony.h" #include "game/sim/economy.h" #include "game/sim/numeric.h" @@ -253,6 +255,111 @@ void RunSystemTurn(Sys& s, int playerCount, SystemTotals& t) { (void)playerCount; } +// --------------------------------------------------------------------------------------- +// The tail's last phase: the per-player turn record, and its own self-check +// --------------------------------------------------------------------------------------- + +// The record this phase would archive for the turn just run, and -- separately -- what the +// same model produces for the turn the INPUT save was written at, where the save already +// carries the answer. The second is a check of the model that needs no running game: it is +// the "testable on load" property of this phase. +struct TurnRecordAudit { + int playersChecked = 0; // players whose input-turn record could be compared + int fieldsCompared = 0; + int mismatches = 0; + int dangling = 0; // owned-system ids the save's system table does not carry + std::vector firstMismatches; + int missingArchive = 0; // players with no archive element for the input turn +}; + +TurnRecordAudit AuditTurnRecordsAgainstSave(const SaveGame& game) { + TurnRecordAudit a; + const std::int32_t inputTurn = game.sim.frame; + for (std::size_t i = 0; i < game.sim.players.size(); ++i) { + if (i >= game.sim.turnstats.players.size()) break; + const auto& hist = game.sim.turnstats.players[i].hist; + const auto* stored = FindArchivedRecord(hist, inputTurn); + if (!stored) { + ++a.missingArchive; + continue; + } + int dangling = 0; + const TurnRecord built = BuildTurnRecord(game.sim.players[i].player, game.sim.systems, + inputTurn, &dangling); + a.dangling += dangling; + const TurnRecordDiff d = CompareTurnRecord(built, *stored); + ++a.playersChecked; + a.fieldsCompared += d.compared; + a.mismatches += static_cast(d.mismatches.size()); + for (const auto& m : d.mismatches) + if (a.firstMismatches.size() < 8) + a.firstMismatches.push_back(fmt("player %zu: %s", i, m.c_str())); + } + return a; +} + +void RunFinalizeTurnRecords(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec, + const TurnRecordAudit& audit) { + rec.invocations = static_cast(game.sim.players.size()); + // Six fields per player would be written, plus a new archive element per player. Nothing + // is committed by default: five further fields of the same element are unmodelled, and + // the two the model DOES hold for the new turn -- savings and the income derived from it + // -- are downstream of a blocked phase, so every element written would be wrong in a way + // the untouched save is not. `--commit-blocked` writes them anyway, so the claim that + // committing makes things worse is a measurement rather than an argument. + int archived = 0; + for (std::size_t i = 0; i < game.sim.players.size(); ++i) { + if (i >= game.sim.turnstats.players.size()) break; + auto& hist = game.sim.turnstats.players[i].hist; + if (FindArchivedRecord(hist, game.sim.frame)) continue; // the key is the turn + if (!opt.commitBlocked) { + ++archived; + continue; + } + const TurnRecord built = + BuildTurnRecord(game.sim.players[i].player, game.sim.systems, game.sim.frame); + mars::stream::shapes::PlayerTurnStats s; + s.trn = built.turn; + s.pop = built.population; + s.col = built.colonies; + s.sav = built.savings; + s.inc = built.income; + s.tch = built.completedTech; + // The census is three hull classes wide whether or not the player owns a ship; the + // counts are the unmodelled part, the shape is not. + for (std::int32_t c = 0; c < 3; ++c) { + mars::stream::shapes::ClassStats cs; + cs.cls = c; + s.classes.push_back(cs); + } + hist.stats.push_back(s); + ++archived; + rec.leafWrites += 6; + } + rec.committed = rec.leafWrites > 0; + if (!opt.commitBlocked) rec.wouldWrite = archived * 6; + rec.notes.push_back(fmt("%s %d record(s) for turn %d; 6 modelled field(s) each", + opt.commitBlocked ? "ARCHIVED" : "would archive", archived, + game.sim.frame)); + if (audit.playersChecked) + rec.notes.push_back(fmt("SELF-CHECK on the input turn, where the save carries the " + "answer: %d field(s) over %d player(s), %d mismatch(es)%s", + audit.fieldsCompared, audit.playersChecked, audit.mismatches, + audit.dangling ? " (owned-system ids missing from the table!)" + : "")); + for (const auto& m : audit.firstMismatches) rec.notes.push_back(m); + if (audit.missingArchive) + rec.notes.push_back(fmt("%d player(s) carry no archive element for the input turn", + audit.missingArchive)); + std::size_t nUnmodelled = 0; + const char* const* un = TurnRecord::Unmodelled(nUnmodelled); + for (std::size_t i = 0; i < nUnmodelled; ++i) rec.notes.push_back(fmt("NOT modelled: %s", un[i])); + rec.notes.push_back("and the two fields the model does hold for the NEW turn -- savings and " + "the income derived from it -- come from a blocked phase, so committing " + "this would replace one container-shaped divergence per player with " + "several wrong leaves"); +} + } // namespace // --------------------------------------------------------------------------------------- @@ -297,6 +404,11 @@ void ApplySaveWriterInvariants(SaveGame& game, TurnResult& r) { TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { TurnResult r; + // Taken before any phase runs: the turn-record model is checked against the record the + // INPUT save already carries for its own turn. None of the six fields it reads is written + // by a phase below, but the check is taken first so that stays true by construction. + const TurnRecordAudit recordAudit = AuditTurnRecordsAgainstSave(game); + mars::rng::MT19937 gen(1u); r.rngLoaded = LoadGenerator(game, gen); if (!r.rngLoaded) @@ -374,6 +486,30 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { break; } case 31: { // S31 EncounterDetectionAndStatusRestore + // Trade-raid generation runs FIRST inside this phase, before detection + // proper, and it is the turn's dominant RNG consumer: two chances per entry + // of the player vector, neither inside a back edge, so the count is a bound. + if (r.rngLoaded) { + const int before = rng.words(); + const TradeRaidResult tr = + RollTradeRaids(rng, static_cast(game.sim.players.size()), + TradeRaidOdds{}, TradeRaidGates{}); + rec.rngWords = rng.words() - before; + rec.notes.push_back(fmt("trade raids: %d player(s) x 2 rolls = %d generator " + "word(s), one per roll (neither site sits inside a " + "back edge, so this is a bound)", + tr.players, rec.rngWords)); + rec.notes.push_back("the refugee-raid roll is NOT counted: its subsystem was " + "absent on all 8 measured turns. If it is ever present " + "the turn costs one more word per player"); + if (tr.playerRaidHits || tr.npcRaidHits) + rec.notes.push_back(fmt("%d roll(s) succeeded in THIS run -- a success " + "may cost a further target-selection word that " + "is not modelled. The values drawn here are not " + "the game's: earlier unmodelled draws shift the " + "stream, so the hit COUNT is not a prediction", + tr.playerRaidHits + tr.npcRaidHits)); + } // The status restore: every player that is not an AI, or whose secondary AI // flag is set, goes back to status 1. The secondary flag is not on the wire, // so the AI test alone is used and the difference is reported. @@ -447,6 +583,8 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { rec.leafWrites = 1; rec.committed = true; rec.notes.push_back(fmt("ModCount -> %d", game.sim.modCount)); + } else if (tp[i].index == 36) { + RunFinalizeTurnRecords(game, opt, rec, recordAudit); } r.records.push_back(rec); } @@ -472,14 +610,35 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { r.rngWords += rec.rngWords; } + // The RNG ledger, stated the way the campaign states divergence: what is accounted and + // what is not, never netted into one number. A measured turn on the reference save costs + // 18-22 words; what is modelled here is the trade-raid block and the research-event roll. + r.rngUnaccounted.push_back( + "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"); + r.rngUnaccounted.push_back( + "the research-allocation draw is downstream of the budget, which is blocked on the " + "per-system money output (0 or 1 word)"); + if (r.rngLoaded && r.rngWords > 0) + r.rngUnaccounted.push_back( + "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"); + if (opt.commitRng && r.rngLoaded) { if (!StoreGenerator(game, gen)) r.warnings.push_back("generator state could not be written back"); + else + r.rngCommitted = true; } else if (r.rngWords > 0) { r.warnings.push_back( "the generator advanced during this run but the save keeps its original state " - "(--commit-rng to write it); the turn's full draw count is not yet attributed"); + "(--commit-rng to write it)"); } + if (r.rngLoaded) + r.warnings.push_back( + "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"); return r; } diff --git a/src/app/turn.h b/src/app/turn.h index 6cefe7d..99c403c 100644 --- a/src/app/turn.h +++ b/src/app/turn.h @@ -44,8 +44,13 @@ struct TurnResult { std::vector records; // spine order, with player phases nested after S13 int leafWrites = 0; int wouldWrite = 0; - int rngWords = 0; + int rngWords = 0; // words this run actually consumed -- a LOWER bound on the turn's bool rngLoaded = false; + bool rngCommitted = false; + // Draw sites a real turn spends words at that this model does not reproduce. Kept next to + // rngWords and never subtracted from it: an accounted count and an unaccounted list are + // two different facts, and folding them into one hides which is which. + std::vector rngUnaccounted; std::vector warnings; }; diff --git a/src/app/turn_record.cpp b/src/app/turn_record.cpp new file mode 100644 index 0000000..0021451 --- /dev/null +++ b/src/app/turn_record.cpp @@ -0,0 +1,87 @@ +#include "app/turn_record.h" + +#include + +namespace sots::app { + +namespace { +constexpr const char* kUnmodelled[] = { + "alliance/vision mask -- rebuilt by the alliance-mask phase of the spine, which is a stub", + "trade income -- a budget-derived field, blocked behind the per-system money output", + "battles fought -- written by the tail's battle-tally phase, which is a stub", + "systems acquired / lost this turn -- the two counted lists are never non-empty in the " + "corpus, so their element meaning is unobserved", + "the per-hull-class ship census -- needs each design's hull size and its class flag, " + "neither of which is on the wire; both come from the game data", +}; +} // namespace + +const char* const* TurnRecord::Unmodelled(std::size_t& count) { + count = sizeof(kUnmodelled) / sizeof(kUnmodelled[0]); + return kUnmodelled; +} + +TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p, + const std::vector& systems, + std::int32_t frame, int* danglingOwnedSystems) { + TurnRecord r; + r.turn = frame; + r.savings = p.sav; + // The turn's income is the change in savings across the turn, not a budget line: the + // previous-turn savings word is on the wire and is stamped before this turn's savings are + // written, so the subtraction is exact. + r.income = p.sav - p.pvSav; + r.colonies = static_cast(p.owners.size()); + + for (const auto& st : p.techTree.state) + if (st.st == kTechStateCompleted) ++r.completedTech; + + // The population term is a sum of TWO per-system words: the live population and the + // pending population bonus that has not been folded into it yet. Both are signed 32-bit + // and the accumulator is 64-bit, so the sum is taken in 64-bit and never wraps. + int dangling = 0; + for (std::int32_t id : p.owners) { + const mars::stream::shapes::Sys* sys = nullptr; + for (const auto& e : systems) + if (e.sysID == id) { + sys = &e.sys; + break; + } + if (!sys) { + ++dangling; + continue; + } + r.population += static_cast(sys->pop) + static_cast(sys->pbon); + } + if (danglingOwnedSystems) *danglingOwnedSystems = dangling; + return r; +} + +const mars::stream::shapes::PlayerTurnStats* FindArchivedRecord( + const mars::stream::shapes::PlayerTurnHistory& hist, std::int32_t turn) { + for (const auto& s : hist.stats) + if (s.trn == turn) return &s; + return nullptr; +} + +TurnRecordDiff CompareTurnRecord(const TurnRecord& built, + const mars::stream::shapes::PlayerTurnStats& stored) { + TurnRecordDiff d; + auto cmp = [&](const char* field, long long a, long long b) { + ++d.compared; + if (a != b) { + char buf[160]; + std::snprintf(buf, sizeof buf, "%s: built %lld != stored %lld", field, a, b); + d.mismatches.push_back(buf); + } + }; + cmp("trn", built.turn, stored.trn); + cmp("pop", built.population, stored.pop); + cmp("col", built.colonies, stored.col); + cmp("sav", built.savings, stored.sav); + cmp("inc", built.income, stored.inc); + cmp("tch", built.completedTech, stored.tch); + return d; +} + +} // namespace sots::app diff --git a/src/app/turn_record.h b/src/app/turn_record.h new file mode 100644 index 0000000..66c3927 --- /dev/null +++ b/src/app/turn_record.h @@ -0,0 +1,62 @@ +// The per-player turn record -- the last phase of the post-combat tail. +// +// Each player carries a small "what my empire looked like this turn" summary that the final +// tail phase fills and then copies into a history archive keyed by the turn number. The +// archive IS on the wire: it is the per-player turn-statistics history, one element per turn, +// and every save carries an element for its own frame. That makes this phase testable without +// a running game: build the record from a save's own state and compare it with the element the +// save already holds for that turn. +// +// Six fields are recoverable from the wire and are reproduced here. Five are not, and each is +// named with the reason -- they belong to phases or inputs the standalone does not hold. +#pragma once + +#include +#include +#include + +#include "mars/stream/shapes.h" + +namespace sots::app { + +// The part of a turn record this model can produce. +struct TurnRecord { + std::int32_t turn = 0; // the frame counter + std::int64_t population = 0; // summed over owned systems + std::int32_t colonies = 0; // owned-system count + std::int32_t savings = 0; + std::int32_t income = 0; // savings minus previous-turn savings + std::int32_t completedTech = 0; // tech-tree entries in the completed state + + // Fields the archive element also carries that this model does NOT fill, kept as a + // published list rather than as silence. Each is blocked on something named. + static const char* const* Unmodelled(std::size_t& count); +}; + +// The completed state's value in the tech-tree state word. Named rather than spelled inline +// because it is the one magic number in this file. +constexpr std::int32_t kTechStateCompleted = 4; + +// Build one player's record from the simulation state. `systemsById` is the save's system +// table indexed the way the player's owned-system ids index it. +TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p, + const std::vector& systems, + std::int32_t frame, + // set when an owned-system id is not present in the table, which + // would silently drop a term from the population sum + int* danglingOwnedSystems = nullptr); + +// What the archive element for `turn` holds, if the save carries one for that turn. +const mars::stream::shapes::PlayerTurnStats* FindArchivedRecord( + const mars::stream::shapes::PlayerTurnHistory& hist, std::int32_t turn); + +// A comparison of a built record against a stored one, over the six modelled fields only. +struct TurnRecordDiff { + int compared = 0; + std::vector mismatches; // "field: built != stored" + bool ok() const { return mismatches.empty(); } +}; +TurnRecordDiff CompareTurnRecord(const TurnRecord& built, + const mars::stream::shapes::PlayerTurnStats& stored); + +} // namespace sots::app diff --git a/tests/app/CMakeLists.txt b/tests/app/CMakeLists.txt index cb2eeca..eb8245a 100644 --- a/tests/app/CMakeLists.txt +++ b/tests/app/CMakeLists.txt @@ -11,7 +11,17 @@ add_executable(app_test_turn test_turn.cpp) target_link_libraries(app_test_turn PRIVATE sots_app) add_test(NAME app_turn COMMAND app_test_turn) -foreach(_t app_test_catalog app_test_turn) +# The trade-raid word count is pinned against a scripted generator, so it always runs. +add_executable(app_test_trade_raid test_trade_raid.cpp) +target_link_libraries(app_test_trade_raid PRIVATE sots_app) +add_test(NAME app_trade_raid COMMAND app_test_trade_raid) + +# The turn-record model against the record the game itself archived; needs the owner's saves. +add_executable(app_test_turn_record test_turn_record.cpp) +target_link_libraries(app_test_turn_record PRIVATE sots_app) +add_test(NAME app_turn_record COMMAND app_test_turn_record) + +foreach(_t app_test_catalog app_test_turn app_test_trade_raid app_test_turn_record) target_include_directories(${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_compile_options(${_t} PRIVATE -Wall -Wextra -Wpedantic) endforeach() diff --git a/tests/app/test_trade_raid.cpp b/tests/app/test_trade_raid.cpp new file mode 100644 index 0000000..f919c0c --- /dev/null +++ b/tests/app/test_trade_raid.cpp @@ -0,0 +1,142 @@ +// The trade-raid block is the strategic turn's dominant generator cost, so its WORD COUNT is +// load-bearing in a way its decisions are not. These tests pin the count, the two no-draw +// early-outs of the chance helper, and the roll order -- all against a scripted generator, so +// nothing here depends on the real MT19937. +#include +#include + +#include "app/trade_raid.h" +#include "mars/rng/mt19937.h" + +static int failures = 0; +#define CHECK(c) \ + do { \ + if (!(c)) { \ + std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); \ + ++failures; \ + } \ + } while (0) + +namespace { + +// A generator that hands out a scripted sequence and counts what it handed out. +class Scripted final : public sots::sim::IRandom { +public: + explicit Scripted(std::vector f) : f_(std::move(f)) {} + float NextFloat() override { + ++draws; + return f_.empty() ? 0.f : f_[(cursor_++) % f_.size()]; + } + std::uint32_t NextIntInclusive(std::uint32_t) override { + ++draws; + return 0; + } + std::uint32_t NextUInt32() override { + ++draws; + return 0; + } + int draws = 0; + +private: + std::vector f_; + std::size_t cursor_ = 0; +}; + +} // namespace + +int main() { + using namespace sots::app; + + // --- the chance helper's two no-draw early-outs ------------------------------------ + { + Scripted g({0.5f}); + CHECK(Chance(g, 0.f) == false); + CHECK(Chance(g, -1.f) == false); + CHECK(Chance(g, 1.f) == true); + CHECK(Chance(g, 2.f) == true); + CHECK(g.draws == 0); // four calls, zero words: both early-outs return before drawing + } + // --- the comparison is `p > r`, so equality FAILS and still costs a word ----------- + { + Scripted g({0.25f, 0.25f}); + CHECK(Chance(g, 0.25f) == false); // p == r -> false + CHECK(g.draws == 1); + CHECK(Chance(g, 0.5f) == true); // p > r -> true + CHECK(g.draws == 2); + } + // --- the word count is two per player when the NPC gate is open ------------------- + { + Scripted g({0.9f}); // every roll fails; the count must not depend on that + const TradeRaidResult r = RollTradeRaids(g, 8, TradeRaidOdds{}, TradeRaidGates{}); + CHECK(r.players == 8); + CHECK(r.words == 16); + CHECK(g.draws == 16); + CHECK(r.playerRaidHits == 0); + CHECK(r.npcRaidHits == 0); + CHECK(r.refugeeRaidHits == 0); + } + // --- ... and the count is unchanged when every roll succeeds ----------------------- + { + Scripted g({0.0f}); // every roll succeeds (p > 0) + const TradeRaidResult r = RollTradeRaids(g, 8, TradeRaidOdds{}, TradeRaidGates{}); + CHECK(r.words == 16); + CHECK(r.playerRaidHits == 8); + CHECK(r.npcRaidHits == 8); + } + // --- one per player when the NPC gate is shut, three when the refugee one is open -- + { + Scripted g({0.9f}); + TradeRaidGates gates; + gates.npcRaids = false; + CHECK(RollTradeRaids(g, 7, TradeRaidOdds{}, gates).words == 7); + gates.npcRaids = true; + gates.refugeeRaids = true; + Scripted g2({0.9f}); + CHECK(RollTradeRaids(g2, 7, TradeRaidOdds{}, gates).words == 21); + } + // --- a degenerate player vector costs nothing -------------------------------------- + { + Scripted g({0.9f}); + CHECK(RollTradeRaids(g, 0, TradeRaidOdds{}, TradeRaidGates{}).words == 0); + CHECK(RollTradeRaids(g, -3, TradeRaidOdds{}, TradeRaidGates{}).words == 0); + CHECK(g.draws == 0); + } + // --- odds pinned at 0 or 1 cost NOTHING, and the counter says so ------------------- + // This is the case that would silently break a count-the-calls implementation: a tuning + // table that pushes an odds value out of (0, 1) removes the draw, not just the outcome. + { + Scripted g({0.9f}); + TradeRaidOdds odds; + odds.player = 0.f; + odds.npc = 1.f; + const TradeRaidResult r = RollTradeRaids(g, 8, odds, TradeRaidGates{}); + CHECK(r.words == 0); + CHECK(g.draws == 0); + CHECK(r.playerRaidHits == 0); + CHECK(r.npcRaidHits == 8); + } + // --- against the real generator: 16 words is 16 words ----------------------------- + { + mars::rng::MT19937 gen(12345u); + class Adapt final : public sots::sim::IRandom { + public: + explicit Adapt(mars::rng::MT19937& g) : g_(g) {} + float NextFloat() override { return g_.next_float(); } + std::uint32_t NextIntInclusive(std::uint32_t n) override { + return g_.next_int_inclusive(n); + } + std::uint32_t NextUInt32() override { return g_.next_u32(); } + + private: + mars::rng::MT19937& g_; + } a(gen); + const int before = gen.index(); + const TradeRaidResult r = RollTradeRaids(a, 8, TradeRaidOdds{}, TradeRaidGates{}); + CHECK(r.words == 16); + CHECK(gen.index() - before == 16); + } + + std::printf(failures ? "trade_raid: FAILED (%d)\n" : "trade_raid: ok (%d failures)\n", + failures); + return failures ? 1 : 0; +} diff --git a/tests/app/test_turn_record.cpp b/tests/app/test_turn_record.cpp new file mode 100644 index 0000000..8e5ea4d --- /dev/null +++ b/tests/app/test_turn_record.cpp @@ -0,0 +1,113 @@ +// The turn-record model, checked against the record the game itself wrote. +// +// The last phase of the post-combat tail archives a per-player summary keyed by turn, and the +// archive is on the wire: every save carries an element for its own frame. So the model can be +// checked with no running game and no VM -- build the record from the save's own state and +// compare it with the element the save already holds. +// +// This is the strongest check available to a lane that holds no game: it compares against +// bytes the original produced. It is NOT the same as `verified` in the phase catalog, which +// means "compared against the live game", and nothing here is that. +// +// Reads $SOTS_SAVES_DIR at run time and skips cleanly when it is unset. No .sav enters this +// repo. +#include +#include +#include +#include + +#include + +#include "app/turn_record.h" +#include "mars/stream/save.h" + +static int failures = 0; +#define CHECK(c) \ + do { \ + if (!(c)) { \ + std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); \ + ++failures; \ + } \ + } while (0) + +int main() { + const char* dir = std::getenv("SOTS_SAVES_DIR"); + if (!dir || !*dir) { + std::printf("app_test_turn_record: SOTS_SAVES_DIR unset, skipped\n"); + return 0; + } + DIR* d = opendir(dir); + if (!d) { + std::fprintf(stderr, "app_test_turn_record: cannot open %s\n", dir); + return 1; + } + std::vector saves; + while (struct dirent* e = readdir(d)) { + const std::string n = e->d_name; + if (n.size() > 4 && n.compare(n.size() - 4, 4, ".sav") == 0) + saves.push_back(std::string(dir) + "/" + n); + } + closedir(d); + if (saves.empty()) { + std::printf("app_test_turn_record: no .sav in %s, skipped\n", dir); + return 0; + } + + int files = 0, players = 0, fields = 0, dangling = 0, noArchive = 0; + for (const std::string& path : saves) { + mars::stream::SaveDocument doc; + try { + doc = mars::stream::read_save_file(path); + } catch (const std::exception& ex) { + std::printf(" %s: unreadable (%s), skipped\n", path.c_str(), ex.what()); + continue; + } + if (doc.count(mars::stream::Issue::Error)) { + std::printf(" %s: parse errors, skipped\n", path.c_str()); + continue; + } + ++files; + const auto& sim = doc.game.sim; + int filePlayers = 0, fileFields = 0, fileBad = 0; + for (std::size_t i = 0; i < sim.players.size(); ++i) { + if (i >= sim.turnstats.players.size()) break; + const auto* stored = + sots::app::FindArchivedRecord(sim.turnstats.players[i].hist, sim.frame); + if (!stored) { + ++noArchive; + continue; + } + int dang = 0; + const sots::app::TurnRecord built = + sots::app::BuildTurnRecord(sim.players[i].player, sim.systems, sim.frame, &dang); + dangling += dang; + // An owned-system id the save's table does not carry would drop a term from the + // population sum without any other symptom, so it is a failure, not a note. + CHECK(dang == 0); + const sots::app::TurnRecordDiff diff = sots::app::CompareTurnRecord(built, *stored); + ++filePlayers; + fileFields += diff.compared; + for (const auto& m : diff.mismatches) { + std::printf("FAIL %s player %zu: %s\n", path.c_str(), i, m.c_str()); + ++failures; + ++fileBad; + } + } + players += filePlayers; + fields += fileFields; + std::printf(" %s: turn %d, %d player-record(s), %d field(s), %d mismatch(es)\n", + path.c_str(), sim.frame, filePlayers, fileFields, fileBad); + } + + // The check is worthless if it compared nothing -- a green run over zero records is the + // failure mode this campaign has paid for twice. + CHECK(files > 0); + CHECK(players > 0); + CHECK(fields == players * 6); + + std::printf("app_test_turn_record: %d save(s), %d player-record(s), %d field(s) compared, " + "%d player(s) with no archive element, %d dangling owned-system id(s), " + "%d failure(s)\n", + files, players, fields, noArchive, dangling, failures); + return failures ? 1 : 0; +}