Coverage is indexed by shape and answers 'does a field name this item'. Two saves carrying content nobody had modelled scored 100% anyway, because the novelty landed in fields that were already typed. This asks what the corpus has ever actually put in each field. Over 22 saves: 724 fields observed, 490 vary, 234 constant. A third of the format we call 99.99% typed has been seen holding exactly one value.
132 lines
5.5 KiB
C++
132 lines
5.5 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.
|
|
const size_t kVaryingBaseline = 490;
|
|
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;
|
|
}
|