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:
parent
0c6d0a3573
commit
e62d974601
3 changed files with 302 additions and 1 deletions
|
|
@ -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 "?";
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
132
tests/mars_stream/test_domains.cpp
Normal file
132
tests/mars_stream/test_domains.cpp
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue