sots-engine/src/app/turn_record.cpp
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

87 lines
3.3 KiB
C++

#include "app/turn_record.h"
#include <cstdio>
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<mars::stream::shapes::SysEntry>& 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<std::int32_t>(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<std::int64_t>(sys->pop) + static_cast<std::int64_t>(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