stream: DomainArchive, the value-axis companion to the coverage ratchet

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.
This commit is contained in:
alex 2026-09-08 21:57:43 -04:00
parent 0c6d0a3573
commit e62d974601
3 changed files with 302 additions and 1 deletions

View file

@ -21,9 +21,11 @@
// codebase measures how much of the save it actually reads. // codebase measures how much of the save it actually reads.
#pragma once #pragma once
#include <algorithm>
#include <cstdint> #include <cstdint>
#include <map> #include <map>
#include <optional> #include <optional>
#include <set>
#include <string> #include <string>
#include <vector> #include <vector>
@ -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<int64_t> 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<std::string, Domain> 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<Node>&) {}
// 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<int32_t>& v) {
if (v) at(t).observe(*v);
else present_[key(t)] += 0;
}
void opt_f32(Tag t, std::optional<float>& v) {
if (v) at(t).observe_f(double(*v));
}
void opt_b(Tag t, std::optional<bool>& v) {
if (v) at(t).observe(*v ? 1 : 0);
}
void opt_any(Tag, std::optional<Node>&) {}
template <class T>
void opt_obj(Tag t, std::optional<T>& v) {
if (v) obj(t, *v);
}
template <class T>
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 <class T>
void obj_flex(Tag t, T& v, bool&) {
obj(t, v);
}
template <class T>
void carr(Tag t, std::vector<T>& 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 <class T>
void carr_flex(Tag t, std::vector<T>& v, bool&) {
carr(t, v);
}
template <class T, class F>
void narr(Tag, std::vector<T>& v, F fn) {
for (T& e : v) fn(*this, e);
}
template <class F>
void when(bool cond, F body) {
if (cond) body(*this);
}
template <class T, class F>
void repeat(const char*, std::vector<T>& v, F fn) {
for (T& e : v) fn(*this, e);
}
private:
std::string cur_ = "root";
std::map<std::string, size_t> 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 <class T>
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) { inline const char* probe_prim_name(SchemaProbe::P p) {
switch (p) { switch (p) {
case SchemaProbe::P::Unknown: return "?"; case SchemaProbe::P::Unknown: return "?";

View file

@ -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) target_link_libraries(mars_stream_test_save PRIVATE mars_stream mars_rng)
add_test(NAME mars_stream_save COMMAND mars_stream_test_save) 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_include_directories(${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(${_t} PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(${_t} PRIVATE -Wall -Wextra -Wpedantic)
endforeach() endforeach()

View file

@ -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 <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;
}