The coverage ratchet reads `pct >= 99.99`, and with the last carried bodies typed it cannot usefully be raised: the only opaque items left are the MT19937 block's two in every save, so the number is 2/items and it still ranges from 99.99400 on a 532 KB save to 99.99746 on a 1.17 MB one. Moving the bar to 99.995 would fail a save that types perfectly and pass a save twice its size that had picked up a whole new opaque body. A percentage ratchet on a fixed-size debt is a ratchet on save size. So the percentage stays where it is and a stronger line goes beside it: the opaque set must be exactly the two RNG items. That breaks on the save that has the new body, and `opaque_by_tag` names it. test_domains: 43 saves give 755 fields observed, 538 varying, 217 constant, against a baseline of 490 measured at 22 saves. Nineteen of the new fields are the bodies typed in the previous commit and only two of them (FTPShID, FTPDesID) have ever been seen to move -- which is why the constants grew faster than the varying fields, and is worth leaving visible.
142 lines
6.2 KiB
C++
142 lines
6.2 KiB
C++
// 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 <algorithm>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <dirent.h>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#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<std::string> list_saves(const std::string& dir) {
|
|
std::vector<std::string> 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<std::string> 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<std::string> 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<long long>(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;
|
|
}
|