ModuLisp/test/signal_engine/test_audit_fixes.cpp

824 lines
32 KiB
C++

// Regression tests for the 2026-07 v1.2.0 audit fixes (A-series findings).
// One TEST_CASE (or section group) per landed finding; see commit messages
// for the finding numbers.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include "src/signal_engine/compiler_pipeline.h"
#include "src/modulisp/lisp/symbol_intern.h"
#include <cstdio>
#include <cstring>
#include <string>
using namespace sig;
namespace {
struct Harness {
SignalEngine engine;
Harness() { engine.init_defaults(120.0, 4); }
EvalResult eval(const std::string& code) {
return eval_cold(code.c_str(), (uint32_t)code.size(), engine);
}
void eval_ok(const std::string& code) {
EvalResult r = eval(code);
INFO("code: " << code);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
INFO("diagnostic: "
<< (r.diagnostics[0].message ? r.diagnostics[0].message : ""));
}
REQUIRE(r.kind != EvalResult::Error);
}
// Execute one sample at time t and return the named output's value.
double sample(int output_index, double t = 0.0, double dt = 0.001) {
double cell_values[MAX_CELLS];
engine.cells.snapshot_values(cell_values, MAX_CELLS);
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
ExecutionContext ctx;
ctx.t = t;
ctx.dt = dt;
ctx.cell_values = cell_values;
ctx.hw_inputs = hw_inputs;
ctx.data_pool = engine.cells.data_pool;
ctx.data_offsets = engine.cells.data_offsets;
ctx.data_lengths = engine.cells.data_lengths;
ctx.prev_outputs = engine.pool.prev_output_values;
ctx.output_values = outputs;
ctx.workspace = workspace;
execute_all_outputs(engine.pool, ctx);
return outputs[output_index];
}
double tick(int output_index, double t, double dt) {
double cell_values[MAX_CELLS];
engine.cells.snapshot_values(cell_values, MAX_CELLS);
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
ExecutionContext ctx{t, dt, cell_values, hw_inputs,
engine.cells.data_pool,
engine.cells.data_offsets,
engine.cells.data_lengths,
engine.pool.prev_output_values,
outputs, workspace};
execute_all_outputs(engine.pool, ctx);
commit_state(engine.pool, workspace);
commit_outputs(engine.pool, outputs);
return outputs[output_index];
}
};
} // namespace
// ── A2: compile failure must not demote the active program (§2.6) ──────────
// failure-model.md §2.6: "Compile-time errors do not consume LKG. A program
// that fails to compile is not promoted, demoted, or substituted; the active
// program is unchanged." The old demotion of outputs[i].valid on compile
// failure also flapped against commit_outputs (which resurrects valid=true),
// corrupting the WASM batch-vis row packing.
TEST_CASE("A2: failed output compile leaves the active program valid",
"[audit][a2]") {
Harness h;
h.eval_ok("(a1 0.75)");
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.sample(0) == Approx(0.75));
// A compile failure on the same output...
EvalResult r = h.eval("(a1 (no-such-fn 1 2))");
REQUIRE(r.kind == EvalResult::Error);
// ...leaves the previous program installed, valid, and running.
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.engine.pool.outputs[0].root_node != NODE_NONE);
REQUIRE(h.sample(0) == Approx(0.75));
}
TEST_CASE("A2b: rejected graph build restores state, source, and capacity",
"[audit][transaction]") {
Harness h;
h.eval_ok("(define tx-dep 1)");
h.eval_ok("(a1 (+ tx-dep (phasor 1 :id \"tx-phase\")))");
h.tick(0, 0.0, 0.0);
h.tick(0, 0.25, 0.25);
REQUIRE(h.sample(0, 0.25) == Approx(1.25));
uint16_t slots_before = h.engine.pool.state_slot_count;
uint16_t registry_before = h.engine.registry.entry_count;
uint16_t update_before = h.engine.pool.state_update_roots[0];
uint16_t live_before = h.engine.pool.live_slot_count;
uint8_t tables_before = h.engine.cells.data_table_count;
EvalResult rejected = h.eval(
"(a1 (+ tx-dep (phasor 2 :id \"tx-phase\") "
"(live-edit 0.5 :id \"rejected-slot\" :min 0 :max 1) "
"[9 8 7] no-such-name))");
REQUIRE(rejected.kind == EvalResult::Error);
REQUIRE(h.engine.pool.state_slot_count == slots_before);
REQUIRE(h.engine.registry.entry_count == registry_before);
REQUIRE(h.engine.pool.state_update_roots[0] == update_before);
REQUIRE(h.engine.pool.live_slot_count == live_before);
REQUIRE(h.engine.cells.data_table_count == tables_before);
// The rejected 2-Hz update did not steal the live 1-Hz state resource.
h.tick(0, 0.5, 0.25);
REQUIRE(h.sample(0, 0.5) == Approx(1.5));
// The failed source was not published: dependency recompilation uses the
// old valid expression and remains healthy.
h.eval_ok("(define tx-dep 2)");
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.sample(0, 0.5) == Approx(2.5));
}
TEST_CASE("A2c: repeated rejected stateful builds do not exhaust state",
"[audit][transaction][capacity]") {
Harness h;
for (int i = 0; i < (int)MAX_STATE_SLOTS + 4; ++i) {
char code[160];
snprintf(code, sizeof(code),
"(a1 (+ (integrate 1 :id \"rejected-%d\") missing-%d))",
i, i);
EvalResult r = h.eval(code);
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(h.engine.pool.state_slot_count == 0);
REQUIRE(h.engine.registry.entry_count == 0);
}
h.eval_ok("(a1 (integrate 1 :id \"healthy-after-rejects\"))");
REQUIRE(h.engine.pool.state_slot_count == 1);
}
TEST_CASE("A2d: side-effecting forms validate arity before publication",
"[audit][transaction][arity]") {
Harness h;
auto& si = SymbolIntern::getInstance();
EvalResult out = h.eval("(a1 0.25 9)");
REQUIRE(out.kind == EvalResult::Error);
REQUIRE(h.engine.pool.outputs[0].root_node == NODE_NONE);
EvalResult def = h.eval("(define arity-y 7 8)");
REQUIRE(def.kind == EvalResult::Error);
SymbolID y = si.intern(String("arity-y"));
REQUIRE(h.engine.cells.cells[y].kind == CellKind::Empty);
EvalResult nested = h.eval("(do (a1 no-such-hidden) (define hidden-y 5))");
REQUIRE(nested.kind == EvalResult::Error);
SymbolID hidden = si.intern(String("hidden-y"));
REQUIRE(h.engine.cells.cells[hidden].kind == CellKind::Empty);
EvalResult defs = h.eval("(defs [defs-first 1 defs-second])");
REQUIRE(defs.kind == EvalResult::Error);
REQUIRE(h.engine.cells.cells[si.intern(String("defs-first"))].kind ==
CellKind::Empty);
REQUIRE(h.engine.cells.cells[si.intern(String("defs-second"))].kind ==
CellKind::Empty);
EvalResult defs_extra = h.eval("(defs [defs-extra 1] 9)");
REQUIRE(defs_extra.kind == EvalResult::Error);
REQUIRE(h.engine.cells.cells[si.intern(String("defs-extra"))].kind ==
CellKind::Empty);
std::string too_many_params = "(defn wide-fn [";
for (uint16_t i = 0; i <= MAX_CALLABLE_PARAMS; i++) {
too_many_params += " p" + std::to_string(i);
}
too_many_params += "] 1)";
EvalResult wide = h.eval(too_many_params);
REQUIRE(wide.kind == EvalResult::Error);
REQUIRE(h.engine.cells.cells[si.intern(String("wide-fn"))].kind ==
CellKind::Empty);
}
TEST_CASE("A2e: bare and compound named-state observations agree",
"[audit][state][observation]") {
Harness h;
h.eval_ok("(defstate observed-c 0 (+ observed-c 1))");
h.eval_ok("(a1 observed-c)");
h.tick(0, 0.0, 1.0);
h.tick(0, 1.0, 1.0);
h.tick(0, 2.0, 1.0);
EvalResult bare = h.eval("observed-c");
EvalResult compound = h.eval("(+ observed-c 0)");
REQUIRE(bare.kind == EvalResult::Number);
REQUIRE(compound.kind == EvalResult::Number);
REQUIRE(bare.number == Approx(compound.number));
REQUIRE(bare.number == Approx(3.0));
}
TEST_CASE("A2f: tokenizer rejects typed delimiter mismatch and long symbols",
"[audit][parser]") {
Harness h;
EvalResult mismatch = h.eval("(define typed-x [1 2))");
REQUIRE(mismatch.kind == EvalResult::Error);
std::string long_name(256, 'x');
EvalResult overlong = h.eval("(define " + long_name + " 1)");
REQUIRE(overlong.kind == EvalResult::Error);
REQUIRE(overlong.diagnostic_count >= 1);
REQUIRE(overlong.diagnostics[0].category == DiagnosticCategory::Syntax);
}
TEST_CASE("A2g: rejected reactive recompile preserves the active graph",
"[audit][transaction][reactive]") {
Harness h;
h.eval_ok("(define reactive-dep 1)");
h.eval_ok("(a1 (+ (phasor 1 :id \"reactive-phase\") reactive-dep))");
h.tick(0, 0.0, 0.0);
h.tick(0, 0.25, 0.25);
REQUIRE(h.sample(0, 0.25) == Approx(1.25));
uint16_t old_root = h.engine.pool.outputs[0].root_node;
uint16_t old_update = h.engine.pool.state_update_roots[0];
// Publishing the function succeeds, but it makes the stored output
// source ill-typed: a function requiring an argument cannot be read as a
// signal. The rejected candidate first encounters the same state id at
// 2 Hz, so this also proves its state-resource mutation is rolled back.
h.eval_ok("(defn reactive-dep [x] x)");
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.engine.pool.outputs[0].root_node == old_root);
REQUIRE(h.engine.pool.state_update_roots[0] == old_update);
h.tick(0, 0.5, 0.25);
REQUIRE(h.sample(0, 0.5) == Approx(1.5));
// Dependencies were retained with the old graph, so restoring a numeric
// cell causes a healthy recompile and publishes the new value.
h.eval_ok("(define reactive-dep 2)");
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.sample(0, 0.5) == Approx(2.5));
}
// ── A4: useq-clear must fully clear defstate resources (§4.5) ──────────────
TEST_CASE("A4: re-defstate after useq-clear gets a fresh, working slot",
"[audit][a4]") {
Harness h;
auto& si = SymbolIntern::getInstance();
SymbolID c = si.intern(String("a4-counter"));
h.eval_ok("(defstate a4-counter 5 (+ a4-counter 1))");
REQUIRE(h.engine.pool.state_slot_count == 1);
h.eval_ok("(useq-clear)");
REQUIRE(h.engine.pool.state_slot_count == 0);
// Cell marker must be gone — the cell no longer refers to a state slot.
REQUIRE(h.engine.cells.cells[c].flags == 0);
// Sources and update roots cleared.
REQUIRE_FALSE(h.engine.state_sources[0].has_source);
REQUIRE(h.engine.pool.state_update_roots[0] == NODE_NONE);
// Re-defstate: fresh slot, fresh init value (not frozen pre-clear state).
h.eval_ok("(defstate a4-counter 10 (+ a4-counter 2))");
REQUIRE(h.engine.pool.state_slot_count == 1);
uint16_t slot = h.engine.cells.cells[c].data_table_id;
REQUIRE(h.engine.pool.state_values[slot] == Approx(10.0));
REQUIRE(h.engine.cells.cells[c].value == Approx(10.0));
}
TEST_CASE("Clear is fresh-session equivalent for compiler-owned storage",
"[audit][clear][session-reset]") {
Harness h;
auto& si = SymbolIntern::getInstance();
SymbolID scalar = si.intern(String("clear-scalar"));
SymbolID callable = si.intern(String("clear-callable"));
h.eval_ok("(define clear-scalar [1 2 3])");
h.eval_ok("(defn clear-callable [x] (+ x 1))");
h.eval_ok("(defstate clear-state 5 (+ clear-state 1))");
h.eval_ok("(a1 (+ (clear-callable 2)"
" (live-edit 0.25 :id \"clear-knob\" :min 0 :max 1)))");
REQUIRE(h.engine.cells.data_table_count > 0);
REQUIRE(h.engine.arena.write_head > 0);
REQUIRE(h.engine.pool.live_slot_count > 0);
REQUIRE(h.engine.pool.node_count > 0);
REQUIRE(h.engine.pool.state_slot_count > 0);
REQUIRE(h.engine.output_sources[0].has_source);
h.engine.state.time_offset = 5.0;
h.engine.state.transport_offset = -3.0;
h.engine.state.is_playing = false;
h.engine.state.current_time = 12.0;
h.engine.state.current_dt = 0.5;
h.engine.state.current_wall_time = 20.0;
h.engine.state.paused_time = 12.0;
h.engine.state.has_pause_anchor = true;
h.engine.state.reset_dt_on_next_tick = false;
uint32_t generation = h.engine.session_generation;
h.eval_ok("(useq-clear)");
REQUIRE(h.engine.session_generation == generation + 1);
REQUIRE(h.engine.cells.cells[scalar].kind == CellKind::Empty);
REQUIRE(h.engine.cells.cells[callable].kind == CellKind::Empty);
REQUIRE(h.engine.cells.callables[callable].source_length == 0);
REQUIRE(h.engine.cells.data_table_count == 0);
REQUIRE(h.engine.arena.write_head == 0);
REQUIRE(h.engine.pool.node_count == 0);
REQUIRE(h.engine.pool.exec_count == 0);
REQUIRE(h.engine.pool.state_slot_count == 0);
REQUIRE(h.engine.pool.live_slot_count == 0);
REQUIRE(h.engine.pool.external_root_count == 0);
REQUIRE(h.engine.registry.entry_count == 0);
REQUIRE_FALSE(h.engine.output_sources[0].has_source);
REQUIRE_FALSE(h.engine.state_sources[0].has_source);
REQUIRE(h.engine.synth_graph.declaration_count() == 0);
REQUIRE(h.engine.pool.output_class[0] == OutputClass::Inactive);
REQUIRE(h.engine.pool.output_input_mask[0] == 0);
REQUIRE(h.engine.state.time_offset == 0.0);
REQUIRE(h.engine.state.transport_offset == 0.0);
REQUIRE(h.engine.state.is_playing);
REQUIRE(h.engine.state.current_time == 0.0);
REQUIRE(h.engine.state.current_dt == 0.0);
REQUIRE(h.engine.state.current_wall_time == 0.0);
REQUIRE(h.engine.state.paused_time == 0.0);
REQUIRE_FALSE(h.engine.state.has_pause_anchor);
REQUIRE(h.engine.state.reset_dt_on_next_tick);
// The same names and resource IDs can be used immediately as fresh
// definitions; no stale callable source or table reference survives.
h.eval_ok("(define clear-scalar [9 8])");
h.eval_ok("(defn clear-callable [x] (* x 2))");
h.eval_ok("(a1 (clear-callable 4))");
REQUIRE(h.engine.cells.cells[scalar].data_table_id == 0);
REQUIRE(h.sample(0) == Approx(8.0));
}
TEST_CASE("Inactive outputs overwrite reused caller buffers with neutral zero",
"[audit][clear][neutral]") {
Harness h;
h.eval_ok("(a1 0.75)");
REQUIRE(h.sample(0) == Approx(0.75));
h.eval_ok("(useq-clear)");
double cell_values[MAX_CELLS] = {};
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS];
double workspace[MAX_TOTAL_NODES] = {};
for (double& output : outputs) output = 0.75;
ExecutionContext ctx;
ctx.cell_values = cell_values;
ctx.hw_inputs = hw_inputs;
ctx.data_pool = h.engine.cells.data_pool;
ctx.data_offsets = h.engine.cells.data_offsets;
ctx.data_lengths = h.engine.cells.data_lengths;
ctx.prev_outputs = h.engine.pool.prev_output_values;
ctx.output_values = outputs;
ctx.workspace = workspace;
execute_all_outputs(h.engine.pool, ctx);
for (double output : outputs) REQUIRE(output == 0.0);
}
// ── A5: scratch eval must not clobber fresh scratch state with live state ──
// state-identity.md §6.6: scratch evals are isolated. The old code compiled
// the scratch expression (which writes init values into freshly-allocated
// scratch slots) and THEN memcpy'd the live pool's state_values over the
// scratch pool, so a stateful expression evaluated via set/eval read a live
// state value that happened to share the same slot index instead of its own
// init.
TEST_CASE("A5: scratch-evaluated stateful expression keeps its init value",
"[audit][a5]") {
Harness h;
// Occupy live state slot 0 with a conspicuous value.
h.eval_ok("(defstate a5-live 99 (+ a5-live 0))");
REQUIRE(h.engine.pool.state_values[0] == Approx(99.0));
// Scratch-eval a fresh integrator (init 0). It allocates scratch slot 0;
// pre-fix this returned 99 (live slot 0 leaked over the fresh init).
EvalResult r = h.eval("(integrate 0)");
REQUIRE(r.kind == EvalResult::Number);
REQUIRE(r.number == Approx(0.0));
// Reading live named state through a scratch eval still works.
EvalResult r2 = h.eval("(+ a5-live 1)");
REQUIRE(r2.kind == EvalResult::Number);
REQUIRE(r2.number == Approx(100.0));
// set with a stateful RHS: same isolation rule.
h.eval_ok("(set a5-x (integrate 0))");
auto& si = SymbolIntern::getInstance();
SymbolID x = si.intern(String("a5-x"));
REQUIRE(h.engine.cells.cells[x].value == Approx(0.0));
// Live state untouched by the scratch evals.
REQUIRE(h.engine.pool.state_values[0] == Approx(99.0));
}
// ── A6: failed defstate must not corrupt the previous binding ───────────────
TEST_CASE("A6: defstate compile failure rolls back cell and state slot",
"[audit][a6]") {
Harness h;
auto& si = SymbolIntern::getInstance();
// Existing plain-number binding survives a failed defstate of same name.
h.eval_ok("(define a6-x 5)");
SymbolID x = si.intern(String("a6-x"));
uint16_t slots_before = h.engine.pool.state_slot_count;
EvalResult r = h.eval("(defstate a6-x 0 (no-such-fn 1))");
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(h.engine.cells.cells[x].kind == CellKind::Number);
REQUIRE(h.engine.cells.cells[x].flags == 0); // not a state cell
REQUIRE(h.engine.cells.cells[x].value == Approx(5.0)); // old value intact
REQUIRE(h.engine.pool.state_slot_count == slots_before); // slot rolled back
// The binding still evaluates as before.
EvalResult r2 = h.eval("(+ a6-x 1)");
REQUIRE(r2.kind == EvalResult::Number);
REQUIRE(r2.number == Approx(6.0));
// A successful re-defstate of an EXISTING state cell that then fails
// keeps the old update program and value.
h.eval_ok("(defstate a6-c 3 (+ a6-c 1))");
SymbolID c = si.intern(String("a6-c"));
uint16_t slot = h.engine.cells.cells[c].data_table_id;
uint16_t old_root = h.engine.pool.state_update_roots[slot];
EvalResult r3 = h.eval("(defstate a6-c 7 (no-such-fn 1))");
REQUIRE(r3.kind == EvalResult::Error);
REQUIRE(h.engine.cells.cells[c].flags == 0x02);
REQUIRE(h.engine.cells.cells[c].data_table_id == slot);
REQUIRE(h.engine.pool.state_update_roots[slot] == old_root);
REQUIRE(h.engine.pool.state_values[slot] == Approx(3.0));
}
// ── A7: set on a defstate cell writes the state slot ────────────────────────
TEST_CASE("A7: set on a defstate cell updates the live state value",
"[audit][a7]") {
Harness h;
auto& si = SymbolIntern::getInstance();
h.eval_ok("(defstate a7-c 3 (+ a7-c 1))");
SymbolID c = si.intern(String("a7-c"));
uint16_t slot = h.engine.cells.cells[c].data_table_id;
REQUIRE(h.engine.pool.state_values[slot] == Approx(3.0));
// Numeric set writes the state slot, keeps the state marker.
h.eval_ok("(set a7-c 42)");
REQUIRE(h.engine.pool.state_values[slot] == Approx(42.0));
REQUIRE(h.engine.cells.cells[c].flags == 0x02);
REQUIRE(h.engine.cells.cells[c].data_table_id == slot);
// Expression set too.
h.eval_ok("(set a7-c (+ 10 5))");
REQUIRE(h.engine.pool.state_values[slot] == Approx(15.0));
// Reads see the new value.
EvalResult r = h.eval("(+ a7-c 0)");
REQUIRE(r.kind == EvalResult::Number);
REQUIRE(r.number == Approx(15.0));
}
// ── A8: duplicate active :id in one program is a compile error (§5.3/§8.1) ──
TEST_CASE("A8: duplicate active :id rejected; cross-kind :id sharing allowed",
"[audit][a8]") {
Harness h;
// Two oscillators updating the same phase resource in one program —
// ambiguous, must be rejected (state-identity.md §5.3).
EvalResult r = h.eval("(a1 (+ (lfo/saw 1 :id \"pA\") (lfo/saw 2 :id \"pA\")))");
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count >= 1);
REQUIRE(r.diagnostics[0].category == DiagnosticCategory::Boundary);
// Same :id across INCOMPATIBLE primitives resolves to disjoint resources
// (§3.5) — toggle and count must not collide on TriggerMemory either.
h.eval_ok("(a2 (+ (toggle (sqr beat) :id \"x\") (count (sqr beat) :id \"x\")))");
// Recompiling the same program with the same :id stays fine (per-build
// detection only).
h.eval_ok("(a3 (lfo/saw 1 :id \"pB\"))");
h.eval_ok("(a3 (lfo/saw 2 :id \"pB\"))");
}
// ── A9: unknown keywords on stateful primitives error out ──────────────────
TEST_CASE("A9: unknown keywords, :fresh, and non-constant :phase are errors",
"[audit][a9]") {
Harness h;
const char* bad_forms[] = {
"(a1 (phasor 1 :bogus 3))",
"(a1 (lfo 1 :bogus 3))",
"(a1 (integrate 1 :bogus 3))",
"(a1 (toggle (sqr beat) :bogus 3))",
"(a1 (count (sqr beat) :bogus 3))",
"(a1 (slew (lfo/saw 1) 1 :bogus 3))",
"(a1 (live-edit 0.5 :id \"k\" :bogus 3))",
"(a1 (phasor 1 :fresh))",
// Non-constant :phase must error, not be silently ignored.
"(a1 (phasor 1 :phase (lfo/saw 1)))",
"(a1 (lfo 1 :phase (lfo/saw 1)))",
};
for (const char* f : bad_forms) {
INFO("form: " << f);
EvalResult r = h.eval(f);
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count >= 1);
REQUIRE(r.diagnostics[0].category == DiagnosticCategory::Type);
}
// Known keywords still work.
h.eval_ok("(a1 (phasor 1 :phase 0.25 :id \"p\"))");
h.eval_ok("(a2 (lfo 2 :wave :saw :pw 0.3))");
}
// ── A10: no unsound Div a a -> 1 fold ───────────────────────────────────────
// Runtime division defines a/0 = 0 (eval_ops.h), so x/x is 0 at x = 0. The
// old fold rewrote (/ x x) to the constant 1 regardless.
TEST_CASE("A10: (/ x x) evaluates per runtime semantics, not folded to 1",
"[audit][a10]") {
Harness h;
// saw(1) is 0 at t=0 → 0/0 must be 0, not 1.
h.eval_ok("(a1 (/ (lfo/saw 1) (lfo/saw 1)))");
REQUIRE(h.sample(0, 0.0) == Approx(0.0));
// Non-zero point still gives 1.
h.eval_ok("(a2 (/ t t))");
REQUIRE(h.sample(1, 0.5) == Approx(1.0));
}
// ── A12: cell-store revision counter for snapshot skipping ──────────────────
TEST_CASE("A12: store_revision bumps on mutating evals", "[audit][a12]") {
Harness h;
uint32_t r0 = h.engine.cells.store_revision;
REQUIRE(r0 >= 1); // init_timing_defaults counts as a mutation
h.eval_ok("(define a12-x 1)");
uint32_t r1 = h.engine.cells.store_revision;
REQUIRE(r1 > r0);
h.eval_ok("(set a12-x 2)");
REQUIRE(h.engine.cells.store_revision > r1);
}
// ── A14: numeric literals are plain decimal with clean boundaries ──────────
TEST_CASE("A14: tokenizer rejects 0x/inf/nan and trailing-symbol numerics",
"[audit][a14]") {
Harness h;
Token tokens[64];
Diagnostic errs[8];
uint8_t err_count = 0;
auto tokenize_one = [&](const char* src) -> Token {
err_count = 0;
uint16_t n = TokenStream::tokenize(src, (uint32_t)strlen(src),
tokens, 64, errs, &err_count);
REQUIRE(n >= 1);
return tokens[0];
};
// strtod special forms must not become numbers.
for (const char* s : {"inf", "nan", "0x10", "-inf", "INF"}) {
INFO("source: " << s);
Token t = tokenize_one(s);
REQUIRE(t.kind == TokenKind::Symbol);
}
// "2x" is one symbol, not number 2 + symbol x.
{
err_count = 0;
const char* s = "2x";
uint16_t n = TokenStream::tokenize(s, 2, tokens, 64, errs, &err_count);
REQUIRE(n == 2); // symbol + EOF
REQUIRE(tokens[0].kind == TokenKind::Symbol);
REQUIRE(tokens[0].span_len == 2);
}
// Ordinary literals still tokenize as numbers.
struct { const char* src; double v; } good[] = {
{"2", 2.0}, {"-1.5", -1.5}, {".5", 0.5}, {"1e3", 1000.0},
{"2.5e-2", 0.025},
};
for (auto& g : good) {
INFO("source: " << g.src);
Token t = tokenize_one(g.src);
REQUIRE(t.kind == TokenKind::Number);
REQUIRE(t.number == Approx(g.v));
}
// Boundary chars like ')' still terminate numbers.
{
const char* s = "(+ 1 2)";
err_count = 0;
uint16_t n = TokenStream::tokenize(s, (uint32_t)strlen(s),
tokens, 64, errs, &err_count);
REQUIRE(n == 6); // ( + 1 2 ) EOF
REQUIRE(tokens[2].kind == TokenKind::Number);
REQUIRE(tokens[3].kind == TokenKind::Number);
}
// End-to-end: (a1 2x) errors instead of silently reading 2.
EvalResult r = h.eval("(a1 (+ 1 2x))");
REQUIRE(r.kind == EvalResult::Error);
}
TEST_CASE("A15: tokenizer preflights ASCII, finite literals, and span capacity",
"[audit][a15][tokenizer]") {
Token tokens[64];
Diagnostic errors[8];
auto rejects = [&](const char* source, uint32_t length) {
uint8_t error_count = 0;
TokenStream::tokenize(source, length, tokens, 64,
errors, &error_count);
return error_count > 0;
};
const char nul_source[] = {'(', 'a', '1', ' ', '1', ')', '\0',
'(', 'a', '2', ' ', '2', ')'};
REQUIRE(rejects(nul_source, sizeof(nul_source)));
const char high_source[] = {';', ' ', static_cast<char>(0x80), '\n',
'(', 'a', '1', ' ', '1', ')'};
REQUIRE(rejects(high_source, sizeof(high_source)));
REQUIRE(rejects("1e9999", 6));
REQUIRE(rejects("+1e9999", 7));
// LEX-001: numeric conversion is bounded by the submitted slice rather
// than by an adjacent byte or an implicit C-string terminator.
const char adjacent_bytes[] = {'1', '2', 'x', '\0'};
uint8_t bounded_error_count = 0;
uint16_t bounded_count = TokenStream::tokenize(
adjacent_bytes, 1, tokens, 64, errors, &bounded_error_count);
REQUIRE(bounded_error_count == 0);
REQUIRE(bounded_count == 2);
REQUIRE(tokens[0].kind == TokenKind::Number);
REQUIRE(tokens[0].span_start == 0);
REQUIRE(tokens[0].span_len == 1);
REQUIRE(tokens[0].number == Approx(1.0));
// Under the instrumented build, the one-byte allocation also provides a
// memory-boundary witness: conversion may not read a terminator beyond it.
char* exact_allocation = new char[1];
exact_allocation[0] = '7';
bounded_error_count = 0;
bounded_count = TokenStream::tokenize(
exact_allocation, 1, tokens, 64, errors, &bounded_error_count);
delete[] exact_allocation;
REQUIRE(bounded_error_count == 0);
REQUIRE(bounded_count == 2);
REQUIRE(tokens[0].kind == TokenKind::Number);
REQUIRE(tokens[0].span_len == 1);
REQUIRE(tokens[0].number == Approx(7.0));
std::string oversized((size_t)UINT16_MAX + 1, ' ');
REQUIRE(rejects(oversized.data(), (uint32_t)oversized.size()));
// Lexical preflight covers the whole submission before the first form
// can publish: a forbidden byte after a valid-looking definition leaves
// the cell table untouched.
SignalEngine engine;
engine.init_defaults(120.0, 4);
const char atomic_source[] = {
'(', 'd', 'e', 'f', 'i', 'n', 'e', ' ',
'a', '1', '5', '-', 'a', 't', 'o', 'm', 'i', 'c', ' ', '1', ')',
'\0',
'(', 'a', '1', ' ', '2', ')'
};
EvalResult result = eval_cold(atomic_source, sizeof(atomic_source), engine);
REQUIRE(result.kind == EvalResult::Error);
SymbolID symbol =
SymbolIntern::getInstance().intern(String("a15-atomic"));
REQUIRE(symbol < MAX_CELLS);
REQUIRE(engine.cells.cells[symbol].kind == CellKind::Empty);
}
TEST_CASE("Compiler pipeline keeps parsing, storage, and graph mutation bounded",
"[compiler][pipeline][transaction]") {
SECTION("ParsedProgram owns one fixed token stream and bounded diagnostics") {
ParsedProgram program;
program.parse("(+ 1 2)", 7);
REQUIRE(program.ok());
REQUIRE_FALSE(program.empty());
REQUIRE(program.stream.peek().kind == TokenKind::LParen);
program.parse("(+ 1", 4);
REQUIRE_FALSE(program.ok());
REQUIRE(program.diagnostic_count <=
ParsedProgram::MAX_PARSE_DIAGNOSTICS);
}
SECTION("source plans validate capacity before the single publication write") {
SourceArena arena;
REQUIRE(arena.store("old", 3) == 0);
const char replacement[] = "xy";
SourceMutationPlan reuse = SourceMutationPlan::prepare(
arena, replacement, 2, 0, 2, 0, 3);
REQUIRE(reuse.status == SourcePlanStatus::Ready);
REQUIRE(reuse.publish(arena) == 0);
REQUIRE(std::memcmp(arena.read(0), "xy", 2) == 0);
arena.write_head = SOURCE_ARENA_SIZE;
SourceMutationPlan full = SourceMutationPlan::prepare(
arena, "z", 1, 0, 1);
REQUIRE(full.status == SourcePlanStatus::CapacityExceeded);
REQUIRE(full.publish(arena) == UINT32_MAX);
}
SECTION("unaccepted graph mutations roll back while accepted ones persist") {
SignalEngine engine;
engine.init_defaults(120.0, 4);
engine.pool.state_slot_count = 1;
engine.pool.state_values[0] = 3.0;
engine.pool.live_slot_count = 1;
engine.pool.live_slots[0].value = 0.25;
const uint32_t original_head = engine.arena.write_head;
{
GraphMutationTransaction transaction(engine);
engine.pool.state_slot_count = 2;
engine.pool.state_values[0] = 99.0;
engine.pool.live_slot_count = 2;
engine.pool.live_slots[0].value = 0.75;
engine.arena.write_head = original_head + 10;
}
REQUIRE(engine.pool.state_slot_count == 1);
REQUIRE(engine.pool.state_values[0] == Approx(3.0));
REQUIRE(engine.pool.live_slot_count == 1);
REQUIRE(engine.pool.live_slots[0].value == Approx(0.25));
REQUIRE(engine.arena.write_head == original_head);
{
GraphMutationTransaction transaction(engine);
engine.pool.state_values[0] = 4.0;
transaction.accept();
}
REQUIRE(engine.pool.state_values[0] == Approx(4.0));
}
}
// NOTE: the A1 case floods the shared symbol interner past MAX_CELLS, which
// makes any fresh name interned after it out-of-range. Keep it LAST.
// ── A1: cell writes must bounds-check symbol IDs >= MAX_CELLS ───────────────
TEST_CASE("A1: defining more names than MAX_CELLS errors instead of OOB write",
"[audit][a1]") {
Harness h;
// Force the interner past MAX_CELLS so subsequent fresh names are
// guaranteed to have out-of-range IDs.
auto& si = SymbolIntern::getInstance();
for (int i = 0; i < (int)MAX_CELLS + 8; i++) {
char buf[32];
snprintf(buf, sizeof(buf), "a1-flood-%d", i);
si.intern(String(buf));
}
SymbolID big = si.intern(String("a1-oob-name"));
REQUIRE(big >= MAX_CELLS);
// Every cell-write form must reject the out-of-range name with an
// Overflow diagnostic — not write out of bounds.
const char* forms[] = {
"(define a1-oob-name 1)",
"(def a1-oob-name 2)",
"(defn a1-oob-name [x] (+ x 1))",
"(set a1-oob-name 3)",
"(defs [a1-oob-name 4])",
"(defstate a1-oob-name 0 (+ a1-oob-name 1))",
};
for (const char* f : forms) {
INFO("form: " << f);
EvalResult r = h.eval(f);
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count >= 1);
REQUIRE(r.diagnostics[0].category == DiagnosticCategory::Overflow);
}
// Engine still works after the rejections.
h.eval_ok("(a1 0.25)");
REQUIRE(h.sample(0) == Approx(0.25));
}