// Value-domain census over the real-save corpus. // // test_save.cpp reports 99.99% of every save's items as typed. That number is // indexed by shape, and a save can carry content nobody has modelled and still // score 100% because the novelty lands in a field that was already typed. Two // saves did precisely that: a fleet whose LocID resolves to a trade sector // rather than a star, and a mask reading 253 where twenty earlier saves read // 252. Coverage did not move. // // So this test asks the other question — not "is the field named" but "what has // the corpus ever put in it". It prints the fields that never vary, because a // field constant across the whole corpus tells us about our save set and not // about the game, and a reimplementation cannot be said to handle a field it // has only ever seen hold one value. // // The ratchet is on the count of fields with observed variation: growing the // corpus should exercise more of the format, never less. Reads $SOTS_SAVES_DIR // (owner's data, never in the repo); skips (exit 0) when it is unset. #include #include #include #include #include #include #include "mars/stream/probe.h" #include "mars/stream/save.h" using namespace mars::stream; static int fails = 0; #define CHECK(cond) \ do { \ if (!(cond)) { \ std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ ++fails; \ } \ } while (0) static std::vector list_saves(const std::string& dir) { std::vector out; DIR* d = opendir(dir.c_str()); if (!d) return out; while (dirent* e = readdir(d)) { std::string n = e->d_name; if (n.size() > 4 && n.compare(n.size() - 4, 4, ".sav") == 0) out.push_back(dir + "/" + n); } closedir(d); std::sort(out.begin(), out.end()); return out; } // Fields whose domain is genuinely one value by construction, not by accident: // listing them here is a claim that we know why, and keeps the interesting // constants visible instead of buried. static bool explained_constant(const std::string& path) { static const char* kKnown[] = { ".Version", // the format version of a save we can read is fixed ".GameType", // every corpus save is single-player }; for (const char* k : kKnown) if (path.size() >= std::string(k).size() && path.compare(path.size() - std::string(k).size(), std::string(k).size(), k) == 0) return true; return false; } int main() { const char* dir = std::getenv("SOTS_SAVES_DIR"); if (!dir || !*dir) { std::printf("test_domains: skipped (SOTS_SAVES_DIR unset)\n"); return 0; } std::vector saves = list_saves(dir); if (saves.empty()) { std::printf("test_domains: skipped (no *.sav in %s)\n", dir); return 0; } // One archive across the whole corpus: a domain is a statement about the // corpus, not about any single save. DomainArchive dom; for (const std::string& path : saves) { SaveDocument doc = read_save_file(path); CHECK(doc.count(Issue::Error) == 0); doc.game.io(dom); } size_t varying = 0, constant = 0; std::vector constants; for (const auto& kv : dom.domains) { if (kv.second.samples == 0) continue; if (kv.second.constant()) { ++constant; if (!explained_constant(kv.first)) constants.push_back(kv.first); } else { ++varying; } } std::printf("== value domains over %zu save(s)\n", saves.size()); std::printf(" %zu field(s) observed: %zu vary, %zu constant across the whole corpus\n", varying + constant, varying, constant); std::printf(" unexplained constants (%zu) — each one is a field our corpus never exercised:\n", constants.size()); // The whole list is the working document for whoever goes hunting; the head // of it is enough for a test log. const size_t limit = std::getenv("SOTS_DOMAIN_ALL") ? constants.size() : 40; for (size_t i = 0; i < constants.size() && i < limit; ++i) { const DomainArchive::Domain& d = dom.domains.at(constants[i]); if (d.is_float) std::printf(" %-56s = %g\n", constants[i].c_str(), d.flo); else std::printf(" %-56s = %lld\n", constants[i].c_str(), static_cast(d.lo)); } if (constants.size() > limit) std::printf(" ... and %zu more\n", constants.size() - limit); // Ratchet, not a target. Growing the corpus must exercise more of the // format; a drop means a save was removed or a shape stopped being reached, // and either is worth stopping for. Raise this deliberately, the way the // coverage ratchet is raised — see guides/method-rules.md rule 27. // // 2026-09-09, 22 saves: 724 fields observed, 490 vary, 234 do not. Nearly a // third of the format we call "99.99% typed" has been seen holding exactly // one value. `tscr` was one of those 234 until a one-turn tech moved it. // // 2026-09-09, 43 saves (lane DW): 755 fields observed, 538 vary, 217 do not. // The corpus doubled and the census grew by 31 fields, of which 19 are this // lane's newly typed bodies -- the `usp`/`usc` pair of a name-generator record, // the twelve of a `Sprj` back-engineering project, and the five of a stored // formation point. Only two of those 19 have ever been seen to move // (`FTPShID`, `FTPDesID`), which is exactly why the count of CONSTANTS rose // faster than the count of varying fields: typing a body honestly adds far // more unexercised fields than exercised ones, and this census is the place // that stays visible. const size_t kVaryingBaseline = 538; CHECK(varying >= kVaryingBaseline); std::printf("test_domains: %s (%zu save(s), %d failure(s))\n", fails ? "FAILED" : "ok", saves.size(), fails); return fails ? 1 : 0; }