sots-engine/src/app/report.cpp
alex 98257b9e82 RB: sots_turn --turn-commands replays a recorded command stream, and ModCount closes
The milestone needs a second input. The AI is a client, not part of the sim: it decides once,
on one machine, and its decisions reach the server as commands. A save carries the board and
half the input, which is why our turn wrote ModCount 14 where the original writes 24 -- the
missing ten ARE the turn's command stream.

  * `game/ai/apply_order` -- the thirty-step schedule the original drains a batch in: twenty-
    seven per-LIST steps (every player's elements of one list before the next list starts) with
    three per-PLAYER gate loops spliced in at step 10, 29 and 30. Neither list order nor member
    order, and both facts are asserted so a port that sorted cannot pass.
  * `game/ai/command_capture` -- a `.tcb` recorded turn: gates, list lengths, elements in wire
    order, per-client seeds, and `?` for a field the instrument could not read. An element count
    that disagrees with its declaration is REJECTED, because a counter quietly one short is
    indistinguishable from a turn that issued one fewer command.
  * `app/command_replay` -- applies it before the drivers, where the End-Turn dispatcher does.
    Every command is CHARGED; only the ones whose subsystem we hold are APPLIED; the rest are
    declined with the named gap, or marked incomplete when the capture itself lacks the payload.
  * `--turn-commands`, `--replay-count-only`, `--replay-recorded-names`, `--ai-seed`.

Measured on a fresh build directory, canonical pair turn2-state -> turn3-state:
  108 -> 62, closed 46, regressed 0  (was 108 -> 63, closed 45) -- /Sim/ModCount now reads the
  original's 24, decomposed as 2 drivers + 4 research-rate gates + build + rates + list 10 +
  two list-14 + fleet move, with the list-23 population element free.

turn1-state replayed against the SAME run's autosave closes 7 (ModCount and all six research
leaves); against the historical turn2-state it closes 6 and leaves player 512's research pick
diverging -- which is correct, because that recording is from a process that picked differently.

One prediction was falsified and it paid for itself: the first run regressed two leaves because
the rates element's MEMORY field order is not its wire order. The converter no longer claims a
mapping it cannot support.

Two new addresses (the second and third gate-loop heads) via ghidra/addresses.d/lane-rb.json;
header regenerated, never hand-resolved.
2026-09-08 18:57:46 -04:00

165 lines
8 KiB
C++

#include "app/report.h"
#include <fstream>
namespace sots::app {
namespace {
std::string JsonEscape(const std::string& s) {
std::string o;
for (char c : s) {
switch (c) {
case '"': o += "\\\""; break;
case '\\': o += "\\\\"; break;
case '\n': o += "\\n"; break;
case '\r': o += "\\r"; break;
case '\t': o += "\\t"; break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
char b[8];
std::snprintf(b, sizeof b, "\\u%04x", c);
o += b;
} else {
o += c;
}
}
}
return o;
}
void TallyJson(std::ofstream& f, const char* key, const PhaseTally& t) {
f << " \"" << key << "\": {\"total\": " << t.total << ", \"verified\": " << t.verified
<< ", \"implemented\": " << t.implemented << ", \"partial\": " << t.partial
<< ", \"blocked\": " << t.blocked << ", \"stub\": " << t.stub
<< ", \"modelled\": " << t.modelled() << ", \"committed\": " << t.committed() << "}";
}
} // namespace
void PrintPhaseLog(std::FILE* out, const TurnResult& r, bool verbose) {
Driver current = static_cast<Driver>(-1);
for (const auto& rec : r.records) {
if (rec.desc->driver != current) {
current = rec.desc->driver;
std::fprintf(out, "\n%s\n", DriverName(current));
}
const char* indent = current == Driver::Player ? " " : " ";
std::fprintf(out, "%s%c %-4s %-38s %-12s", indent, StatusGlyph(rec.desc->status),
rec.desc->id, rec.desc->name, StatusName(rec.desc->status));
if (rec.invocations || rec.leafWrites || rec.wouldWrite || rec.rngWords) {
std::fprintf(out, " ran=%-5d writes=%-5d", rec.invocations, rec.leafWrites);
if (rec.wouldWrite) std::fprintf(out, " would=%-4d", rec.wouldWrite);
if (rec.rngWords) std::fprintf(out, " rng=%d", rec.rngWords);
}
std::fprintf(out, "\n");
for (const auto& n : rec.notes) std::fprintf(out, "%s - %s\n", indent, n.c_str());
if (verbose && rec.desc->note[0])
std::fprintf(out, "%s # %s\n", indent, rec.desc->note);
}
}
void PrintSummary(std::FILE* out, const TurnResult& r) {
const PhaseTally s = TallySpine();
const PhaseTally t = TallyTail();
std::fprintf(out, "\nphases\n");
std::fprintf(out,
" turn drivers (the milestone's denominator): %d of %d modelled, %d committed\n"
" verified %d implemented %d partial %d blocked %d stub %d\n",
s.modelled(), s.total, s.committed(), s.verified, s.implemented, s.partial,
s.blocked, s.stub);
std::fprintf(out,
" post-combat tail (written to the autosave, tracked separately): %d of %d "
"modelled\n"
" verified %d implemented %d partial %d blocked %d stub %d\n",
t.modelled(), t.total, t.verified, t.implemented, t.partial, t.blocked, t.stub);
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, %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());
}
void PrintCommandReplay(std::FILE* out, const TurnResult& r) {
const ReplayReport& c = r.commandReplay;
if (!c.ran) {
std::fprintf(out,
"\nturn commands: none supplied (--turn-commands FILE). The modification "
"counter can only reach the two driver bumps without a stream; the rest of\n"
" its per-turn delta is the commands, and a save does not "
"carry them.\n");
return;
}
std::fprintf(out, "\nturn commands: %d block(s), %d submitting, %d command(s)\n", c.blocks,
c.submittingBlocks, c.commands);
std::fprintf(out, " ModCount bumps charged %d%s\n", c.bumps,
c.bumpsExact ? "" : " (LOWER BOUND -- see the warning below)");
std::fprintf(out, " applied %d transcribed %d declined %d incomplete %d\n", c.applied,
c.transcribed, c.declined, c.incomplete);
std::fprintf(out, " leaves written by commands %d\n", c.leafWrites);
std::fprintf(out, "\n step blk player cost disposition command\n");
for (const auto& e : c.log) {
const char* d = e.disposition == ReplayDisposition::Applied ? "applied"
: e.disposition == ReplayDisposition::Transcribed ? "transcribed"
: e.disposition == ReplayDisposition::Incomplete ? "incomplete"
: "declined";
std::fprintf(out, " %4d %3d %6d %4d %-12s %s\n", e.step, e.blockIndex, e.playerId,
e.bumps, d, e.what.c_str());
if (!e.reason.empty()) std::fprintf(out, " %s\n",
e.reason.c_str());
}
for (const auto& w : c.warnings) std::fprintf(out, " ! %s\n", w.c_str());
}
bool WriteMetricJson(const std::string& path, const TurnResult& r, const std::string& inputName,
const std::string& outputName) {
std::ofstream f(path);
if (!f) return false;
const PhaseTally s = TallySpine();
const PhaseTally t = TallyTail();
f << "{\n";
f << " \"schema\": \"sots-standalone-metric/1\",\n";
f << " \"input\": \"" << JsonEscape(inputName) << "\",\n";
f << " \"output\": \"" << JsonEscape(outputName) << "\",\n";
TallyJson(f, "spine", s);
f << ",\n";
TallyJson(f, "tail", t);
f << ",\n";
f << " \"run\": {\"leafWrites\": " << r.leafWrites << ", \"blockedLeafWrites\": "
<< r.wouldWrite << ", \"rngWords\": " << r.rngWords << ", \"rngLoaded\": "
<< (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";
// The command stream's own numbers, kept apart from the run's: a bump charged for a command
// we declined to apply is a real bump and a fake model, and netting them would hide that.
const ReplayReport& c = r.commandReplay;
f << " \"turnCommands\": {\"supplied\": " << (c.ran ? "true" : "false")
<< ", \"blocks\": " << c.blocks << ", \"submittingBlocks\": " << c.submittingBlocks
<< ", \"commands\": " << c.commands << ", \"modCountBumps\": " << c.bumps
<< ", \"modCountExact\": " << (c.bumpsExact ? "true" : "false")
<< ", \"applied\": " << c.applied << ", \"transcribed\": " << c.transcribed
<< ", \"declined\": " << c.declined << ", \"incomplete\": " << c.incomplete
<< ", \"leafWrites\": " << c.leafWrites << "},\n";
f << " \"phases\": [\n";
bool first = true;
for (const auto& rec : r.records) {
if (!first) f << ",\n";
first = false;
f << " {\"driver\": \"" << DriverName(rec.desc->driver) << "\", \"id\": \""
<< rec.desc->id << "\", \"name\": \"" << rec.desc->name << "\", \"status\": \""
<< StatusName(rec.desc->status) << "\", \"ran\": " << rec.invocations
<< ", \"writes\": " << rec.leafWrites << ", \"blockedWrites\": " << rec.wouldWrite
<< ", \"rng\": " << rec.rngWords << "}";
}
f << "\n ]\n}\n";
return static_cast<bool>(f);
}
} // namespace sots::app