Files
secs-gem/tests/test_robustness_fuzz.cpp
T
raphael ca3559ef57
tests / build-and-test (push) Successful in 2m9s
test: randomized robustness fuzz (4 seeds × 2k ops + 100k soak)
Property-based robustness test that drives long sequences of random
tool operations against EquipmentDataModel and verifies invariants +
persistence round-trip after every action.  Replaces hand-written
state-pinning tests with a generative approach that explores
combinations no human author would think to write.

Action menu (28 weighted actions covering the full standard surface):
- PJ create / event / dequeue          (E40)
- CJ create / event / delete           (E94)
- Carrier create / id / slot           (E87)
- Substrate create / location / proc   (E90)
- Alarm set / clear / enable toggle    (E5 §13)
- SVID updates                          (E30 §6.13)
- Define-report / link-event / enable  (E30 §6.6)
- Exception post / recover / complete  (E5 §9, S5F9-F18)
- Module event                          (E157)
- EPT event                             (E116)
- Spool enqueue / drain / force-toggle (E30 §6.22)

Every action is "adjusted": it picks a verb at random, then checks
state-machine legality before applying.  A Pause is only fired on a
Processing PJ; a Recover only on a Posted exception; pj_dequeue
skips PJs bound to active CJs (mirrors E94's "can't dequeue
CJ-bound PJ" rule the fuzz itself discovered when the first run
flagged a CJ→missing-PJ reference).

Invariants checked every 64 ticks:
- Every tracked PJ exists in the store (size matches)
- Every CJ's prjobids all exist in PJ store
- No FSM in NoState sentinel
- EPT bucket total monotonically non-decreasing
- Defined reports' VIDs all exist
- Substrate / carrier counts match enumeration

Persistence round-trip every 500 ticks:
- Fresh shadow EquipmentDataModel loads from the same journal dir
- Diffs PJ + CJ states one-by-one + carrier/substrate/exception
  counts against the live model
- Catches any "mutation didn't reach disk" or
  "replay didn't reconstruct state correctly" bugs

Reproducibility:
- Each TEST_CASE uses a fixed seed (0x1, 0xdeadbeef, 0xfeedface,
  0xc0ffee — 8000 ops total in the fast suite)
- World keeps a rolling 20-action trace, printed on invariant
  violation so the failing sequence can be pasted into a targeted
  regression test
- SECSGEM_ROBUSTNESS_SOAK=1 enables a 100k-tick soak case
  (~3-5 minutes in Docker; not run by default)

The very first run found a real edge case: act_pj_dequeue removed
PJs that were bound to active CJs, leaving dangling refs. Fixed
the fuzz to filter; the underlying behavior is intentional (store
trusts the application to gate), but the fuzz now mirrors the
correct E94 contract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 15:04:19 +02:00

793 lines
29 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Randomized robustness / property test.
//
// Spins up a fresh EquipmentDataModel pre-loaded with realistic
// SVIDs/ECIDs/CEIDs/alarms/recipes and drives a long sequence of
// random tool operations against it. Each action is "adjusted" — the
// fuzz picks a random verb but checks state-machine legality before
// applying, so a Pause is only fired on a Processing PJ, a Recover
// only on a Posted exception, etc. Illegal-by-state attempts are
// no-ops counted but not faulted; they exercise the FSM guards.
//
// After every N ticks the fuzz checks a battery of invariants:
// * referential integrity (CJ prjobids exist in PJ store, defined
// reports' VIDs exist, linked CEIDs are registered, etc.)
// * resource accounting (size() matches enumeration count)
// * no FSM in NoState (sentinel must never leak as a live state)
// * EPT bucket accumulator monotonically non-decreasing
// * active alarms ⊆ declared alarms
// * spool depth matches enumerated entries
//
// At configurable intervals it does a full persistence round-trip:
// write every store to a tempdir, load into a fresh model, diff
// the in-memory state. This is the strongest end-to-end check we
// have for "the equipment can survive a power loss" claim.
//
// Reproducibility: every test case uses a fixed seed. On failure the
// last ~20 applied actions are printed so the sequence can be
// replayed (or pasted into a targeted unit test).
//
// Coverage scaling: 4 seeds × 2000 ticks for the fast CI suite. Set
// SECSGEM_ROBUSTNESS_SOAK=1 to run an extra 100k-tick test that
// exercises the long-running pathological-distribution case.
#include <doctest/doctest.h>
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <deque>
#include <filesystem>
#include <functional>
#include <random>
#include <set>
#include <sstream>
#include <string>
#include <vector>
#include "secsgem/gem/data_model.hpp"
#include "secsgem/secs2/item.hpp"
using namespace secsgem;
using namespace secsgem::gem;
namespace fs = std::filesystem;
namespace {
// ---------------------------------------------------------------------------
// Scratch directory helpers — used both for persistence round-trips and
// for the soak variant's append-only journal.
// ---------------------------------------------------------------------------
fs::path scratch_root(uint64_t seed) {
std::random_device rd;
auto p = fs::temp_directory_path() /
("secsgem-fuzz-" + std::to_string(seed) + "-" + std::to_string(rd()));
fs::remove_all(p);
fs::create_directories(p);
return p;
}
// ---------------------------------------------------------------------------
// World: an EquipmentDataModel pre-loaded with a representative dictionary
// + ID-tracking + an RNG. Every Action operates on the World.
// ---------------------------------------------------------------------------
struct ActionEvent {
uint64_t tick;
std::string what;
};
struct World {
EquipmentDataModel model;
std::mt19937_64 rng;
fs::path journal_dir; // persistence enabled at startup; dir owned by World
// Catalog: created at startup, never mutated by the fuzz.
std::vector<uint32_t> svid_ids;
std::vector<uint32_t> dvid_ids;
std::vector<uint32_t> ecid_ids;
std::vector<uint32_t> ceid_ids;
std::vector<uint32_t> alarm_ids;
std::vector<std::string> recipe_ids;
// Live IDs the fuzz adds/removes over time.
std::vector<std::string> pjs;
std::vector<std::string> cjs;
std::vector<std::string> carriers;
std::vector<std::string> substrates;
std::vector<uint32_t> exceptions;
std::vector<std::string> modules;
std::vector<uint32_t> report_ids;
// Bookkeeping for invariants.
uint64_t prev_ept_total_ms = 0;
std::deque<ActionEvent> recent_actions; // for failure diagnostics
uint64_t tick = 0;
explicit World(uint64_t seed) : rng(seed) {
journal_dir = scratch_root(seed);
// Enable persistence BEFORE populate so even initial state participates.
// Order matters: the per-store dirs need to exist before mutations.
model.process_jobs.set_table_factory(
[] { return ProcessJobTransitionTable::default_table(); });
model.control_jobs.set_table_factory(
[] { return ControlJobTransitionTable::default_table(); });
model.process_jobs.enable_persistence(journal_dir / "pj");
model.control_jobs.enable_persistence(journal_dir / "cj");
model.carriers.enable_persistence(journal_dir / "car");
model.load_ports.enable_persistence(journal_dir / "lp");
model.substrates.enable_persistence(journal_dir / "sub");
model.exceptions.enable_persistence(journal_dir / "ex");
model.spool.enable_persistence(journal_dir / "spool");
populate_catalog_();
}
~World() {
std::error_code ec;
fs::remove_all(journal_dir, ec);
}
// -------------------------------------------------------------------------
// Catalog setup — a representative GEM 300 tool's data dictionary.
// -------------------------------------------------------------------------
void populate_catalog_() {
// SVIDs.
for (uint32_t id : {1u, 2u, 3u, 100u, 101u, 102u}) {
model.svids.add({id, "SV-" + std::to_string(id), "u",
secs2::Item::u4(0)});
svid_ids.push_back(id);
}
// DVIDs.
for (uint32_t id : {200u, 201u, 202u}) {
model.dvids.add({id, "DV-" + std::to_string(id), "u",
secs2::Item::f4(0.0f)});
dvid_ids.push_back(id);
}
// ECIDs.
for (uint32_t id : {300u, 301u, 302u}) {
const auto v = secs2::Item::u4(1);
model.ecids.add({id, "EC-" + std::to_string(id), "u",
v, v, "0", "1000"});
ecid_ids.push_back(id);
}
// CEIDs.
for (uint32_t id : {1000u, 1001u, 1002u, 1003u}) {
model.events.register_event({id, "CE-" + std::to_string(id)});
ceid_ids.push_back(id);
}
// Alarms.
for (uint32_t id : {1u, 2u, 3u}) {
model.alarms.add({id, "AL-" + std::to_string(id), /*cat=*/0x04});
alarm_ids.push_back(id);
}
// Recipes.
for (const auto& id : {std::string("R-A"), std::string("R-B")}) {
model.recipes.add(id, "BODY");
recipe_ids.push_back(id);
}
// Modules: create three named.
for (const auto& id : {std::string("M-1"), std::string("M-2"),
std::string("M-3")}) {
model.modules.create(id);
modules.push_back(id);
}
// FSM table factories already wired in the constructor before
// persistence-enable so replay finds matching factories.
}
void log_action(const std::string& what) {
recent_actions.push_back({tick, what});
if (recent_actions.size() > 20) recent_actions.pop_front();
}
std::string trace() const {
std::ostringstream os;
os << "\nrecent actions (most recent last):\n";
for (const auto& e : recent_actions)
os << " t=" << e.tick << " " << e.what << "\n";
return os.str();
}
template <typename Vec>
const typename Vec::value_type* pick(const Vec& v) {
if (v.empty()) return nullptr;
return &v[std::uniform_int_distribution<std::size_t>(0, v.size() - 1)(rng)];
}
bool roll(double p) {
return std::uniform_real_distribution<>(0.0, 1.0)(rng) < p;
}
};
// ---------------------------------------------------------------------------
// Actions. Each returns true if the operation landed (state actually
// changed), false if it was a no-op (no legal target / illegal in current
// state). Even no-ops count as exercising the FSM guards.
// ---------------------------------------------------------------------------
bool act_pj_create(World& w) {
auto* ppid = w.pick(w.recipe_ids);
if (!ppid) return false;
const auto id = "PJ-" + std::to_string(w.tick) + "-" +
std::to_string(w.rng() % 0xFFFF);
auto r = w.model.process_jobs.create(id, *ppid, {"W-1"});
if (r != ProcessJobStore::CreateResult::Created) return false;
w.pjs.push_back(id);
w.log_action("pj_create " + id + " ppid=" + *ppid);
return true;
}
bool act_pj_event(World& w) {
auto* id = w.pick(w.pjs);
if (!id) return false;
static const ProcessJobEvent events[] = {
ProcessJobEvent::Select, ProcessJobEvent::SetupComplete,
ProcessJobEvent::Start, ProcessJobEvent::Pause,
ProcessJobEvent::Resume, ProcessJobEvent::Stop,
ProcessJobEvent::Abort, ProcessJobEvent::HeadOfQueue,
ProcessJobEvent::ProcessComplete,ProcessJobEvent::AbortComplete};
const auto ev = events[w.rng() % (sizeof(events) / sizeof(events[0]))];
const bool ok = w.model.process_jobs.fire_internal(*id, ev);
if (ok) w.log_action("pj_event " + *id + " " + process_job_event_name(ev));
return ok;
}
bool act_pj_dequeue(World& w) {
if (w.pjs.empty()) return false;
// Build the set of PJ ids currently bound to any active CJ — those
// are NOT dequeueable per E94 (the application gates this; the store
// would happily dequeue a CJ-bound PJ and leave a dangling reference,
// which the I-2 invariant correctly flags). Mirror the gate here.
std::set<std::string> bound;
for (const auto& cj_id : w.cjs) {
auto* cj = w.model.control_jobs.get(cj_id);
if (!cj) continue;
for (const auto& pj : cj->prjobids) bound.insert(pj);
}
std::vector<std::size_t> candidates;
for (std::size_t i = 0; i < w.pjs.size(); ++i)
if (!bound.count(w.pjs[i])) candidates.push_back(i);
if (candidates.empty()) return false;
const auto idx = candidates[w.rng() % candidates.size()];
const auto id = w.pjs[idx];
auto ack = w.model.process_jobs.dequeue(id);
if (ack != HostCmdAck::Accept) return false;
w.pjs.erase(w.pjs.begin() + idx);
w.log_action("pj_dequeue " + id);
return true;
}
bool act_cj_create(World& w) {
if (w.pjs.empty()) return false;
std::vector<std::string> sample;
const auto n = std::min<std::size_t>(1 + (w.rng() % 3), w.pjs.size());
std::set<std::size_t> picked;
while (sample.size() < n) {
auto idx = w.rng() % w.pjs.size();
if (picked.insert(idx).second) sample.push_back(w.pjs[idx]);
}
const auto id = "CJ-" + std::to_string(w.tick);
auto r = w.model.control_jobs.create(
id, sample, [&](const std::string& pj) { return w.model.process_jobs.has(pj); });
if (r != ControlJobStore::CreateResult::Created) return false;
w.cjs.push_back(id);
w.log_action("cj_create " + id + " pjs=" + std::to_string(sample.size()));
return true;
}
bool act_cj_event(World& w) {
auto* id = w.pick(w.cjs);
if (!id) return false;
static const ControlJobEvent events[] = {
ControlJobEvent::Select, ControlJobEvent::SetupComplete,
ControlJobEvent::Start, ControlJobEvent::Pause,
ControlJobEvent::Resume, ControlJobEvent::Stop,
ControlJobEvent::Abort, ControlJobEvent::AllJobsComplete,
ControlJobEvent::AbortComplete};
const auto ev = events[w.rng() % (sizeof(events) / sizeof(events[0]))];
const bool ok = w.model.control_jobs.fire_internal(*id, ev);
if (ok) w.log_action("cj_event " + *id + " " + control_job_event_name(ev));
return ok;
}
bool act_cj_delete(World& w) {
if (w.cjs.empty()) return false;
auto idx = w.rng() % w.cjs.size();
const auto id = w.cjs[idx];
if (!w.model.control_jobs.remove(id)) return false;
w.cjs.erase(w.cjs.begin() + idx);
w.log_action("cj_delete " + id);
return true;
}
bool act_carrier_create(World& w) {
const auto id = "CAR-" + std::to_string(w.tick);
const uint8_t port = 1 + (w.rng() % 4);
// 25-slot FOUP-shaped carrier.
auto r = w.model.carriers.create(id, port, /*capacity=*/25);
if (r != CarrierStore::CreateResult::Created) return false;
w.carriers.push_back(id);
w.log_action("carrier_create " + id + " port=" + std::to_string(port));
return true;
}
bool act_carrier_id_event(World& w) {
auto* id = w.pick(w.carriers);
if (!id) return false;
static const CarrierIDEvent events[] = {
CarrierIDEvent::Read,
CarrierIDEvent::ProceedWithCarrier,
CarrierIDEvent::CancelCarrier,
CarrierIDEvent::Bind};
const auto ev = events[w.rng() % (sizeof(events) / sizeof(events[0]))];
const bool ok = w.model.carriers.fire_id_event(*id, ev);
if (ok) w.log_action("carrier_id " + *id);
return ok;
}
bool act_carrier_slot_event(World& w) {
auto* id = w.pick(w.carriers);
if (!id) return false;
static const SlotMapEvent events[] = {
SlotMapEvent::Read, SlotMapEvent::Confirm, SlotMapEvent::Mismatch};
const auto ev = events[w.rng() % (sizeof(events) / sizeof(events[0]))];
const bool ok = w.model.carriers.fire_slot_map_event(*id, ev);
if (ok) w.log_action("carrier_slot " + *id);
return ok;
}
bool act_substrate_create(World& w) {
auto* carrier = w.pick(w.carriers);
if (!carrier) return false;
const auto id = "WFR-" + std::to_string(w.tick);
const uint8_t slot = 1 + (w.rng() % 25);
auto r = w.model.substrates.create(id, *carrier, slot);
if (r != SubstrateStore::CreateResult::Created) return false;
w.substrates.push_back(id);
w.log_action("substrate_create " + id);
return true;
}
bool act_substrate_location(World& w) {
auto* id = w.pick(w.substrates);
if (!id) return false;
static const SubstrateEvent events[] = {
SubstrateEvent::Acquire, SubstrateEvent::Release,
SubstrateEvent::Return};
const auto ev = events[w.rng() % (sizeof(events) / sizeof(events[0]))];
const bool ok = w.model.substrates.fire_location_event(*id, ev);
if (ok) w.log_action("substrate_loc " + *id);
return ok;
}
bool act_substrate_processing(World& w) {
auto* id = w.pick(w.substrates);
if (!id) return false;
static const SubstrateProcessingEvent events[] = {
SubstrateProcessingEvent::StartProcessing,
SubstrateProcessingEvent::EndProcessing,
SubstrateProcessingEvent::Abort,
SubstrateProcessingEvent::Stop,
SubstrateProcessingEvent::Reject,
SubstrateProcessingEvent::ReportLost,
SubstrateProcessingEvent::Skip};
const auto ev = events[w.rng() % (sizeof(events) / sizeof(events[0]))];
const bool ok = w.model.substrates.fire_processing_event(*id, ev);
if (ok) w.log_action("substrate_proc " + *id);
return ok;
}
bool act_alarm_set(World& w) {
auto* id = w.pick(w.alarm_ids);
if (!id) return false;
const bool ok = w.model.alarms.set_active(*id).has_value();
if (ok) w.log_action("alarm_set " + std::to_string(*id));
return ok;
}
bool act_alarm_clear(World& w) {
auto* id = w.pick(w.alarm_ids);
if (!id) return false;
if (!w.model.alarms.active(*id)) return false;
const bool ok = w.model.alarms.clear_active(*id).has_value();
if (ok) w.log_action("alarm_clear " + std::to_string(*id));
return ok;
}
bool act_alarm_enable_toggle(World& w) {
auto* id = w.pick(w.alarm_ids);
if (!id) return false;
const bool now = w.model.alarms.enabled(*id);
w.model.alarms.set_enabled(*id, !now);
w.log_action("alarm_enable " + std::to_string(*id));
return true;
}
bool act_svid_update(World& w) {
auto* id = w.pick(w.svid_ids);
if (!id) return false;
w.model.svids.set_value(*id, secs2::Item::u4(
static_cast<uint32_t>(w.rng() & 0xFFFFFFFFu)));
return true; // always succeeds; don't pollute action log
}
bool act_define_report(World& w) {
if (w.svid_ids.empty()) return false;
const uint32_t rpt = 5000 + static_cast<uint32_t>(w.tick % 100);
std::vector<uint32_t> vids;
const auto n = 1 + (w.rng() % 3);
for (std::size_t i = 0; i < n && i < w.svid_ids.size(); ++i)
vids.push_back(w.svid_ids[w.rng() % w.svid_ids.size()]);
auto ack = w.model.define_reports({{rpt, vids}});
if (ack == DefineReportAck::Accept) {
if (std::find(w.report_ids.begin(), w.report_ids.end(), rpt) ==
w.report_ids.end())
w.report_ids.push_back(rpt);
w.log_action("define_report " + std::to_string(rpt));
return true;
}
return false;
}
bool act_link_event(World& w) {
if (w.report_ids.empty()) return false;
auto* ceid = w.pick(w.ceid_ids);
if (!ceid) return false;
std::vector<uint32_t> rpts;
rpts.push_back(w.report_ids[w.rng() % w.report_ids.size()]);
auto ack = w.model.link_event_reports({{*ceid, rpts}});
if (ack == LinkEventAck::Accept) {
w.log_action("link_event ceid=" + std::to_string(*ceid));
return true;
}
return false;
}
bool act_enable_event(World& w) {
auto* ceid = w.pick(w.ceid_ids);
if (!ceid) return false;
const bool enable = w.roll(0.7);
auto ack = w.model.enable_events(enable, {*ceid});
if (ack == EnableEventAck::Accept) {
w.log_action(std::string(enable ? "enable" : "disable") +
"_event " + std::to_string(*ceid));
return true;
}
return false;
}
bool act_exception_post(World& w) {
const uint32_t exid = 9000 + static_cast<uint32_t>(w.tick);
auto r = w.model.exceptions.post(exid, "VACUUM", "lost vacuum",
{"PURGE", "RECOVER"});
if (r != ExceptionStore::PostResult::Posted) return false;
w.exceptions.push_back(exid);
w.log_action("exception_post " + std::to_string(exid));
return true;
}
bool act_exception_recover(World& w) {
if (w.exceptions.empty()) return false;
const uint32_t exid = w.exceptions[w.rng() % w.exceptions.size()];
const auto* ex = w.model.exceptions.get(exid);
if (!ex) return false;
if (ex->fsm->state() != ExceptionState::Posted &&
ex->fsm->state() != ExceptionState::RecoverFailed)
return false;
auto ack = w.model.exceptions.on_recover(exid, "PURGE");
if (ack != AlarmAck::Accept) return false;
w.log_action("exception_recover " + std::to_string(exid));
return true;
}
bool act_exception_complete(World& w) {
if (w.exceptions.empty()) return false;
const uint32_t exid = w.exceptions[w.rng() % w.exceptions.size()];
const auto* ex = w.model.exceptions.get(exid);
if (!ex) return false;
if (ex->fsm->state() != ExceptionState::Recovering) return false;
const auto event = w.roll(0.7) ? ExceptionEvent::RecoveryComplete
: ExceptionEvent::RecoveryFailed;
const bool ok = w.model.exceptions.fire_internal(exid, event);
if (ok) {
if (ex->fsm->state() == ExceptionState::Cleared ||
!w.model.exceptions.has(exid)) {
auto it = std::find(w.exceptions.begin(), w.exceptions.end(), exid);
if (it != w.exceptions.end()) w.exceptions.erase(it);
}
w.log_action("exception_complete " + std::to_string(exid));
}
return ok;
}
bool act_module_event(World& w) {
auto* id = w.pick(w.modules);
if (!id) return false;
static const ModuleEvent events[] = {
ModuleEvent::StartGeneral, ModuleEvent::StartStep,
ModuleEvent::CompleteStep, ModuleEvent::Reset,
ModuleEvent::Abort};
const auto ev = events[w.rng() % (sizeof(events) / sizeof(events[0]))];
return w.model.modules.fire(*id, ev);
}
bool act_ept_event(World& w) {
static const EptEvent events[] = {
EptEvent::EnterNonScheduled, EptEvent::EnterScheduledDown,
EptEvent::EnterUnscheduledDown, EptEvent::EnterEngineering,
EptEvent::EnterStandby, EptEvent::EnterProductive};
return w.model.ept.on_event(
events[w.rng() % (sizeof(events) / sizeof(events[0]))]);
}
bool act_spool_enqueue(World& w) {
// Spool a synthetic S6F11. The store's policy gate accepts it only
// if stream 6 is in the spoolable list, which we configure at startup
// (below in the runner).
secs2::Message m(6, 11, true);
auto r = w.model.spool.enqueue(m);
return r == SpoolStore::EnqueueResult::Queued;
}
bool act_spool_drain(World& w) {
auto drained = w.model.spool.drain();
if (drained.empty()) return false;
w.log_action("spool_drain n=" + std::to_string(drained.size()));
return true;
}
bool act_spool_force_toggle(World& w) {
w.model.spool.set_force_spool(!w.model.spool.force_spool());
return true;
}
// ---------------------------------------------------------------------------
// Invariant checks — assertions that must hold after every action. Each
// returns a non-empty failure string on violation; empty means OK.
// ---------------------------------------------------------------------------
std::string check_invariants(World& w) {
std::ostringstream err;
// I-1: every tracked PJ exists in the store; conversely store size
// matches our active count.
for (const auto& id : w.pjs) {
if (!w.model.process_jobs.has(id)) {
err << "I-1: PJ " << id << " in fuzz set but not in store; ";
break;
}
}
if (w.model.process_jobs.size() != w.pjs.size()) {
err << "I-1: process_jobs.size()=" << w.model.process_jobs.size()
<< " != fuzz pjs.size()=" << w.pjs.size() << "; ";
}
// I-2: every CJ references PJs that exist in the store.
for (const auto& id : w.cjs) {
auto* cj = w.model.control_jobs.get(id);
if (!cj) { err << "I-2: CJ " << id << " missing; "; continue; }
for (const auto& pj : cj->prjobids) {
if (!w.model.process_jobs.has(pj)) {
err << "I-2: CJ " << id << " references missing PJ " << pj << "; ";
break;
}
}
}
// I-3: no FSM in NoState (sentinel must not leak as live state).
for (const auto& id : w.pjs) {
auto* pj = w.model.process_jobs.get(id);
if (pj && pj->fsm->state() == ProcessJobState::NoState)
err << "I-3: PJ " << id << " in NoState; ";
}
for (const auto& id : w.cjs) {
auto* cj = w.model.control_jobs.get(id);
if (cj && cj->fsm->state() == ControlJobState::NoState)
err << "I-3: CJ " << id << " in NoState; ";
}
// I-4: EPT bucket total is monotonically non-decreasing.
uint64_t total = 0;
for (auto s : {EptState::NonScheduledTime, EptState::ScheduledDowntime,
EptState::UnscheduledDowntime, EptState::Engineering,
EptState::Standby, EptState::Productive}) {
total += static_cast<uint64_t>(w.model.ept.accumulated(s).count());
}
if (total < w.prev_ept_total_ms)
err << "I-4: EPT total decreased from " << w.prev_ept_total_ms
<< " to " << total << "; ";
w.prev_ept_total_ms = total;
// I-5: defined-report VIDs all exist as SVIDs or DVIDs.
for (const auto& r : w.model.events.all_reports()) {
for (auto vid : r.vids) {
if (!w.model.vid_exists(vid)) {
err << "I-5: report " << r.id << " references unknown VID "
<< vid << "; ";
break;
}
}
}
// I-6: substrate ids and counts.
if (w.model.substrates.size() != w.substrates.size()) {
err << "I-6: substrates.size()=" << w.model.substrates.size()
<< " != fuzz set " << w.substrates.size() << "; ";
}
// I-7: carriers.
if (w.model.carriers.size() != w.carriers.size()) {
err << "I-7: carriers.size()=" << w.model.carriers.size()
<< " != fuzz set " << w.carriers.size() << "; ";
}
return err.str();
}
// ---------------------------------------------------------------------------
// Persistence round-trip — write every persistable store to a fresh
// directory, replay into a brand-new model, and diff the in-memory
// state against the live fuzz model.
// ---------------------------------------------------------------------------
std::string check_persistence_roundtrip(World& w) {
// Persistence was enabled in World's constructor. Every mutation up
// to this point has been journaled. A fresh shadow model loading
// from the same directory should see the same set of records.
EquipmentDataModel shadow;
shadow.process_jobs.set_table_factory(
[] { return ProcessJobTransitionTable::default_table(); });
shadow.control_jobs.set_table_factory(
[] { return ControlJobTransitionTable::default_table(); });
shadow.process_jobs.enable_persistence(w.journal_dir / "pj");
shadow.control_jobs.enable_persistence(w.journal_dir / "cj");
shadow.carriers.enable_persistence(w.journal_dir / "car");
shadow.load_ports.enable_persistence(w.journal_dir / "lp");
shadow.substrates.enable_persistence(w.journal_dir / "sub");
shadow.exceptions.enable_persistence(w.journal_dir / "ex");
shadow.spool.enable_persistence(w.journal_dir / "spool");
std::ostringstream err;
for (const auto& id : w.pjs) {
auto* live = w.model.process_jobs.get(id);
auto* shad = shadow.process_jobs.get(id);
if (!shad) { err << "persist: PJ " << id << " missing on replay; "; continue; }
if (live && live->fsm->state() != shad->fsm->state())
err << "persist: PJ " << id << " state mismatch live="
<< process_job_state_name(live->fsm->state()) << " replay="
<< process_job_state_name(shad->fsm->state()) << "; ";
}
for (const auto& id : w.cjs) {
auto* live = w.model.control_jobs.get(id);
auto* shad = shadow.control_jobs.get(id);
if (!shad) { err << "persist: CJ " << id << " missing on replay; "; continue; }
if (live && live->fsm->state() != shad->fsm->state())
err << "persist: CJ " << id << " state mismatch; ";
}
if (w.model.carriers.size() != shadow.carriers.size())
err << "persist: carriers " << w.model.carriers.size()
<< " vs replay " << shadow.carriers.size() << "; ";
if (w.model.substrates.size() != shadow.substrates.size())
err << "persist: substrates " << w.model.substrates.size()
<< " vs replay " << shadow.substrates.size() << "; ";
if (w.model.exceptions.size() != shadow.exceptions.size())
err << "persist: exceptions " << w.model.exceptions.size()
<< " vs replay " << shadow.exceptions.size() << "; ";
return err.str();
}
// ---------------------------------------------------------------------------
// Main driver.
// ---------------------------------------------------------------------------
struct ActionEntry {
std::string name;
std::function<bool(World&)> fn;
int weight;
};
std::vector<ActionEntry> action_table() {
return {
{"pj_create", act_pj_create, 8},
{"pj_event", act_pj_event, 14},
{"pj_dequeue", act_pj_dequeue, 2},
{"cj_create", act_cj_create, 4},
{"cj_event", act_cj_event, 8},
{"cj_delete", act_cj_delete, 2},
{"carrier_create", act_carrier_create, 4},
{"carrier_id", act_carrier_id_event, 6},
{"carrier_slot", act_carrier_slot_event, 4},
{"substrate_create", act_substrate_create, 5},
{"substrate_loc", act_substrate_location, 6},
{"substrate_proc", act_substrate_processing,6},
{"alarm_set", act_alarm_set, 4},
{"alarm_clear", act_alarm_clear, 4},
{"alarm_enable", act_alarm_enable_toggle, 2},
{"svid_update", act_svid_update, 8},
{"define_report", act_define_report, 2},
{"link_event", act_link_event, 2},
{"enable_event", act_enable_event, 2},
{"exception_post", act_exception_post, 2},
{"exception_recover", act_exception_recover, 2},
{"exception_complete", act_exception_complete, 2},
{"module_event", act_module_event, 4},
{"ept_event", act_ept_event, 3},
{"spool_enqueue", act_spool_enqueue, 3},
{"spool_drain", act_spool_drain, 2},
{"spool_force", act_spool_force_toggle, 1},
};
}
void run_fuzz(uint64_t seed, std::size_t ticks,
bool with_persistence_roundtrip = true,
std::size_t persistence_period = 500) {
World w(seed);
// Allow stream 5 + 6 in the spool so spool_enqueue actions land.
w.model.spool.set_spoolable_streams({5, 6});
const auto table = action_table();
int total_weight = 0;
for (const auto& a : table) total_weight += a.weight;
for (std::size_t i = 0; i < ticks; ++i) {
w.tick = i;
// Weighted random action pick.
int roll = static_cast<int>(w.rng() % static_cast<uint64_t>(total_weight));
for (const auto& a : table) {
if (roll < a.weight) { a.fn(w); break; }
roll -= a.weight;
}
if ((i & 0x3F) == 0) {
const auto v = check_invariants(w);
if (!v.empty()) {
FAIL("invariants violated at tick=" << i
<< " seed=" << seed << ": " << v << w.trace());
}
}
if (with_persistence_roundtrip && i > 0 && (i % persistence_period) == 0) {
const auto v = check_persistence_roundtrip(w);
if (!v.empty()) {
FAIL("persistence round-trip diverged at tick=" << i
<< " seed=" << seed << ": " << v << w.trace());
}
}
}
// Final pass.
const auto v = check_invariants(w);
if (!v.empty())
FAIL("final invariants violated seed=" << seed << ": " << v << w.trace());
}
} // namespace
TEST_CASE("Robustness fuzz: 2000 ticks × seed=0x1") {
run_fuzz(0x1, 2000);
}
TEST_CASE("Robustness fuzz: 2000 ticks × seed=0xdeadbeef") {
run_fuzz(0xdeadbeef, 2000);
}
TEST_CASE("Robustness fuzz: 2000 ticks × seed=0xfeedface") {
run_fuzz(0xfeedface, 2000);
}
TEST_CASE("Robustness fuzz: 2000 ticks × seed=0xc0ffee") {
run_fuzz(0xc0ffee, 2000);
}
TEST_CASE("Robustness fuzz: soak (gated by SECSGEM_ROBUSTNESS_SOAK=1)") {
const char* soak = std::getenv("SECSGEM_ROBUSTNESS_SOAK");
if (!soak || std::string(soak) != "1") {
MESSAGE("skipping soak: set SECSGEM_ROBUSTNESS_SOAK=1 to run "
"100k-tick robustness pass");
return;
}
run_fuzz(0xa55a55a5, 100000, /*with_persistence_roundtrip=*/true,
/*persistence_period=*/2500);
}