diff --git a/src/mars/stream/probe.h b/src/mars/stream/probe.h index d8a9a33..d2caec1 100644 --- a/src/mars/stream/probe.h +++ b/src/mars/stream/probe.h @@ -21,9 +21,11 @@ // codebase measures how much of the save it actually reads. #pragma once +#include #include #include #include +#include #include #include @@ -242,6 +244,167 @@ private: } }; +// --------------------------------------------------------------------------- +// DomainArchive — which *values* a typed field has ever been seen to hold. +// +// CoverageArchive answers "does a field name this item", and it now answers yes +// for 99.99% of every save we own. That number is indexed by *shape*, so it is +// blind along the axis that keeps catching us out: a save can introduce content +// nobody has ever modelled and still score 100%, because the novelty lands in a +// field that was already typed. Two saves did exactly that — a fleet whose +// `LocID` resolves to a trade sector rather than a star (the first in the +// corpus located anywhere but a star) and a `tscr` of 253 where all twenty +// earlier saves read 252 — and the ratchet did not move a hair. +// +// So this archive walks the same populated shape and records, per field, the +// domain of values the corpus has actually exercised. Its two useful outputs +// are opposites: +// +// * a value outside a field's recorded domain — new content to check, and +// * a field whose domain never varies at all. +// +// The second is the one that has cost us time. `tscr` read 252 in all twenty +// saves and was taken for a fact about the game; it was a fact about our save +// set, and one turn of research moves it. A constant field is a coincidence +// until its writer is found, and a reimplementation cannot be said to handle a +// field it has only ever seen hold one value. +class DomainArchive { +public: + static constexpr bool reading = false, writing = false, building = false; + + struct Domain { + // A small set of distinct values, until it stops being small: past the + // cap we keep the bounds only, because by then the field is a quantity + // rather than an enumeration and the bounds are what carry meaning. + static constexpr size_t kCap = 24; + std::set values; + bool wide = false; + int64_t lo = 0, hi = 0; + double flo = 0, fhi = 0; + size_t samples = 0; + bool is_float = false; + + void observe(int64_t v) { + if (samples == 0) lo = hi = v; + lo = std::min(lo, v); + hi = std::max(hi, v); + if (!wide) { + values.insert(v); + if (values.size() > kCap) { + wide = true; + values.clear(); + } + } + ++samples; + } + void observe_f(double v) { + is_float = true; + if (samples == 0) flo = fhi = v; + flo = std::min(flo, v); + fhi = std::max(fhi, v); + ++samples; + } + // "Constant" means the corpus never exercised it, not that it cannot move. + bool constant() const { + return samples > 0 && (is_float ? flo == fhi : (!wide && values.size() == 1)); + } + }; + + std::map domains; + + void i32(Tag t, int32_t& v) { at(t).observe(v); } + void i64(Tag t, int64_t& v) { at(t).observe(v); } + void b(Tag t, bool& v) { at(t).observe(v ? 1 : 0); } + void f32(Tag t, float& v) { at(t).observe_f(double(v)); } + // A string's domain is its length: the text itself is names and would swamp + // the table, but a field that is empty in every save is the same trap. + void str(Tag t, std::string& v) { at(t).observe(int64_t(v.size())); } + void vec3(Tag t, Vec3& v) { + at(t, ".x").observe_f(double(v.x)); + at(t, ".y").observe_f(double(v.y)); + at(t, ".z").observe_f(double(v.z)); + } + + // Opaque bodies have no fields to speak of; CoverageArchive already counts them. + void any(Tag, Node&) {} + void raw_frame(Tag, Node&) {} + void rest(std::vector&) {} + + // An absent optional is itself an observation: a field that is *never* + // present is as unexercised as one that never varies. + void opt_i32(Tag t, std::optional& v) { + if (v) at(t).observe(*v); + else present_[key(t)] += 0; + } + void opt_f32(Tag t, std::optional& v) { + if (v) at(t).observe_f(double(*v)); + } + void opt_b(Tag t, std::optional& v) { + if (v) at(t).observe(*v ? 1 : 0); + } + void opt_any(Tag, std::optional&) {} + template + void opt_obj(Tag t, std::optional& v) { + if (v) obj(t, *v); + } + + template + void obj(Tag t, T& v) { + const std::string save = cur_; + cur_ = join(cur_, *t.name ? t.name : T::kStreamName); + v.io(*this); + cur_ = save; + } + template + void obj_flex(Tag t, T& v, bool&) { + obj(t, v); + } + template + void carr(Tag t, std::vector& v) { + // Every element folds into one domain for the field: twenty fleets with + // the same LocID kind is the observation we want, not twenty rows. + for (T& e : v) elem_of(t.name, e); + } + template + void carr_flex(Tag t, std::vector& v, bool&) { + carr(t, v); + } + template + void narr(Tag, std::vector& v, F fn) { + for (T& e : v) fn(*this, e); + } + template + void when(bool cond, F body) { + if (cond) body(*this); + } + template + void repeat(const char*, std::vector& v, F fn) { + for (T& e : v) fn(*this, e); + } + +private: + std::string cur_ = "root"; + std::map present_; + + static std::string join(const std::string& a, const char* b) { + return (b && *b) ? a + "." + b : a; + } + std::string key(Tag t) const { return join(cur_, t.name); } + Domain& at(Tag t, const char* suffix = "") { return domains[key(t) + suffix]; } + + void elem_of(const char*, int32_t& v) { domains[cur_].observe(v); } + void elem_of(const char*, float& v) { domains[cur_].observe_f(double(v)); } + void elem_of(const char*, std::string& v) { domains[cur_].observe(int64_t(v.size())); } + void elem_of(const char* tag, Node&) { (void)tag; } + template + void elem_of(const char* tag, T& v) { + const std::string save = cur_; + cur_ = join(cur_, (tag && *tag) ? tag : T::kStreamName); + v.io(*this); + cur_ = save; + } +}; + inline const char* probe_prim_name(SchemaProbe::P p) { switch (p) { case SchemaProbe::P::Unknown: return "?"; diff --git a/tests/mars_stream/CMakeLists.txt b/tests/mars_stream/CMakeLists.txt index 74513b2..787a9df 100644 --- a/tests/mars_stream/CMakeLists.txt +++ b/tests/mars_stream/CMakeLists.txt @@ -12,7 +12,13 @@ add_executable(mars_stream_test_save test_save.cpp) target_link_libraries(mars_stream_test_save PRIVATE mars_stream mars_rng) add_test(NAME mars_stream_save COMMAND mars_stream_test_save) -foreach(_t mars_stream_test_rng mars_stream_test_stream mars_stream_test_save) +# The value-axis companion to test_save's coverage number: what the corpus has +# ever put in each typed field, and which fields it never exercised at all. +add_executable(mars_stream_test_domains test_domains.cpp) +target_link_libraries(mars_stream_test_domains PRIVATE mars_stream) +add_test(NAME mars_stream_domains COMMAND mars_stream_test_domains) + +foreach(_t mars_stream_test_rng mars_stream_test_stream mars_stream_test_save mars_stream_test_domains) target_include_directories(${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_compile_options(${_t} PRIVATE -Wall -Wextra -Wpedantic) endforeach() diff --git a/tests/mars_stream/test_domains.cpp b/tests/mars_stream/test_domains.cpp new file mode 100644 index 0000000..aa9806e --- /dev/null +++ b/tests/mars_stream/test_domains.cpp @@ -0,0 +1,132 @@ +// 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. + 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; +}