ModuLisp/test/signal_engine/test_signal_engine_phase4.cpp

610 lines
21 KiB
C++

// Phase 4: Reactivity, LKG, and failure model hardening tests.
//
// Proves that edits never stop unrelated outputs. Covers dependency cascading,
// compile error preservation, runtime non-finite behaviour, LKG behaviour,
// output lifecycle, and function-cell interactions.
//
// This file is deliberately separate from test_signal_engine_golden.cpp to
// avoid merge conflicts with concurrent Phase 5 work.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include <cmath>
#include <cstring>
#include <string>
#include <vector>
#include <initializer_list>
using namespace sig;
namespace {
// ── GoldenHarness (copied from test_signal_engine_golden.cpp) ──────────────
// Minimal copy of the harness needed for Phase 4 tests.
struct Sample {
double t;
double expected;
double tolerance = 1e-9;
};
struct GoldenHarness {
SignalEngine engine;
double cell_values[MAX_CELLS] = {};
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
explicit GoldenHarness(double bpm = 120.0, int beats_per_bar = 4)
{
engine.init_defaults(bpm, beats_per_bar);
}
EvalResult eval_result(const std::string& code)
{
return eval_cold(code.c_str(), static_cast<uint32_t>(code.size()), engine);
}
void eval_ok(const std::string& code)
{
EvalResult r = eval_result(code);
INFO("code: " << code);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
INFO("diagnostic: " << (r.diagnostics[0].message ? r.diagnostics[0].message : ""));
INFO("suggestion: " << (r.diagnostics[0].suggestion ? r.diagnostics[0].suggestion : ""));
}
REQUIRE(r.kind != EvalResult::Error);
engine.pool.rebuild_execution_order();
}
void assign_ok(const char* output, const char* expr)
{
eval_ok(std::string("(") + output + " " + expr + ")");
}
uint16_t output_index(const char* output_name)
{
SymbolID sym = internSymbol(output_name);
uint16_t idx = GraphBuilder::resolve_output_index(sym);
REQUIRE(idx != NODE_NONE);
return idx;
}
double sample(const char* output_name, double t)
{
std::memset(outputs, 0, sizeof(outputs));
std::memset(workspace, 0, sizeof(workspace));
engine.cells.snapshot_values(cell_values, MAX_CELLS);
ExecutionContext ctx;
ctx.t = t;
ctx.dt = 0.0;
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(output_name)];
}
double tick(const char* output_name, double t)
{
double value = sample(output_name, t);
commit_outputs(engine.pool, outputs);
return value;
}
std::vector<double> sample_window(const char* output,
double t_start, double t_end,
size_t count)
{
std::vector<double> result;
result.reserve(count);
for (size_t i = 0; i < count; ++i) {
double t_val = t_start + (t_end - t_start)
* static_cast<double>(i) / static_cast<double>(count - 1);
result.push_back(sample(output, t_val));
}
return result;
}
std::vector<double> tick_sequence(const char* output,
std::initializer_list<double> times)
{
std::vector<double> result;
result.reserve(times.size());
for (double t_val : times) {
result.push_back(tick(output, t_val));
}
return result;
}
};
} // namespace
// ============================================================================
// Phase 4.1: Deep dependency cascading
// ============================================================================
TEST_CASE("Phase 4: deep dependency cascading", "[phase4][reactivity]") {
SECTION("3-level expression cell chain propagates") {
GoldenHarness h;
h.eval_ok("(define x 1)");
h.eval_ok("(define y (+ x 10))");
h.eval_ok("(define z (* y 2))");
h.assign_ok("a1", "z");
REQUIRE(h.sample("a1", 0.0) == Approx(22.0)); // (1+10)*2
h.eval_ok("(define x 5)");
REQUIRE(h.sample("a1", 0.0) == Approx(30.0)); // (5+10)*2
}
SECTION("multiple outputs depending on same cell all update") {
GoldenHarness h;
h.eval_ok("(define freq 440)");
h.assign_ok("a1", "freq");
h.assign_ok("a2", "(* freq 2)");
REQUIRE(h.sample("a1", 0.0) == Approx(440.0));
REQUIRE(h.sample("a2", 0.0) == Approx(880.0));
h.eval_ok("(define freq 220)");
REQUIRE(h.sample("a1", 0.0) == Approx(220.0));
REQUIRE(h.sample("a2", 0.0) == Approx(440.0));
}
SECTION("time-varying expression cell updates propagate") {
GoldenHarness h;
h.eval_ok("(define a (+ t 1))");
h.eval_ok("(define b (* a 2))");
h.assign_ok("a1", "b");
// At t=0: a=1, b=2
REQUIRE(h.sample("a1", 0.0) == Approx(2.0));
// At t=0.5: a=1.5, b=3
REQUIRE(h.sample("a1", 0.5) == Approx(3.0));
// Redefine a: a = (+ t 10)
h.eval_ok("(define a (+ t 10))");
// At t=0: a=10, b=20
REQUIRE(h.sample("a1", 0.0) == Approx(20.0));
}
SECTION("4-level chain propagates on root change") {
GoldenHarness h;
h.eval_ok("(define p 2)");
h.eval_ok("(define q (+ p 1))"); // q=3
h.eval_ok("(define r (* q 10))"); // r=30
h.eval_ok("(define s (- r 5))"); // s=25
h.assign_ok("a1", "s");
REQUIRE(h.sample("a1", 0.0) == Approx(25.0));
h.eval_ok("(define p 10)");
// q=11, r=110, s=105
REQUIRE(h.sample("a1", 0.0) == Approx(105.0));
}
SECTION("redefining middle of chain propagates downstream only") {
GoldenHarness h;
h.eval_ok("(define x 1)");
h.eval_ok("(define y (* x 3))"); // y=3
h.eval_ok("(define z (+ y 100))"); // z=103
h.assign_ok("a1", "y");
h.assign_ok("a2", "z");
REQUIRE(h.sample("a1", 0.0) == Approx(3.0));
REQUIRE(h.sample("a2", 0.0) == Approx(103.0));
// Redefine y to a constant (breaks link to x)
h.eval_ok("(define y 50)");
REQUIRE(h.sample("a1", 0.0) == Approx(50.0));
REQUIRE(h.sample("a2", 0.0) == Approx(150.0));
}
}
// ============================================================================
// Phase 4.2: Compile error isolation and recovery
// ============================================================================
TEST_CASE("Phase 4: compile error isolation and recovery", "[phase4][failure]") {
SECTION("compile error preserves OTHER running outputs") {
GoldenHarness h;
h.assign_ok("a1", "beat");
h.assign_ok("a2", "bar");
// At 120 bpm: beat at t=0.25 → 0.5, bar at t=0.25 → 0.125
REQUIRE(h.sample("a2", 0.25) == Approx(0.125));
EvalResult bad = h.eval_result("(a1 (unknown-symbol))");
REQUIRE(bad.kind == EvalResult::Error);
// a1 should still run beat, a2 still runs bar
REQUIRE(h.sample("a1", 0.25) == Approx(0.5));
REQUIRE(h.sample("a2", 0.25) == Approx(0.125));
}
SECTION("sequential errors don't lose original program") {
GoldenHarness h;
h.assign_ok("a1", "beat");
REQUIRE(h.sample("a1", 0.125) == Approx(0.25));
h.eval_result("(a1 (error1))");
REQUIRE(h.sample("a1", 0.125) == Approx(0.25)); // still beat
h.eval_result("(a1 (error2))");
REQUIRE(h.sample("a1", 0.125) == Approx(0.25)); // STILL beat
}
SECTION("valid eval after error replaces the program") {
GoldenHarness h;
h.assign_ok("a1", "beat");
REQUIRE(h.sample("a1", 0.125) == Approx(0.25));
h.eval_result("(a1 (error1))"); // error
REQUIRE(h.sample("a1", 0.125) == Approx(0.25));
h.assign_ok("a1", "bar"); // valid replacement
REQUIRE(h.sample("a1", 0.5) == Approx(0.25)); // now runs bar
}
SECTION("error on cell redefinition doesn't crash outputs using that cell") {
GoldenHarness h;
h.eval_ok("(define x 10)");
h.assign_ok("a1", "x");
h.assign_ok("a2", "(+ x 5)");
REQUIRE(h.sample("a1", 0.0) == Approx(10.0));
REQUIRE(h.sample("a2", 0.0) == Approx(15.0));
// Redefine x — dependency recompilation should succeed
h.eval_ok("(define x 20)");
REQUIRE(h.sample("a1", 0.0) == Approx(20.0));
REQUIRE(h.sample("a2", 0.0) == Approx(25.0));
}
SECTION("three outputs: error on one preserves the other two") {
GoldenHarness h;
h.assign_ok("a1", "beat");
h.assign_ok("a2", "bar");
h.assign_ok("a3", "t");
EvalResult bad = h.eval_result("(a2 (nonexistent-func beat))");
REQUIRE(bad.kind == EvalResult::Error);
// a1 and a3 must still work; a2 should still run its old program (bar)
REQUIRE(h.sample("a1", 0.25) == Approx(0.5));
REQUIRE(h.sample("a2", 0.25) == Approx(0.125));
REQUIRE(h.sample("a3", 0.25) == Approx(0.25));
}
SECTION("error then valid then error keeps second valid program") {
GoldenHarness h;
h.assign_ok("a1", "beat");
REQUIRE(h.sample("a1", 0.125) == Approx(0.25));
h.eval_result("(a1 (err1))"); // error - keeps beat
h.assign_ok("a1", "bar"); // valid - replaces with bar
REQUIRE(h.sample("a1", 0.5) == Approx(0.25)); // bar
h.eval_result("(a1 (err2))"); // error - keeps bar
REQUIRE(h.sample("a1", 0.5) == Approx(0.25)); // still bar
}
}
// ============================================================================
// Phase 4.3: Non-finite value handling
// ============================================================================
TEST_CASE("Phase 4: non-finite value handling", "[phase4][numerical]") {
SECTION("division by zero substitutes bootstrap LKG") {
GoldenHarness h;
h.assign_ok("a1", "(/ 1 0)");
double val = h.sample("a1", 0.0);
REQUIRE(std::isfinite(val));
REQUIRE(val == Approx(0.0));
REQUIRE(h.engine.pool.runtime_fallback_bit(0));
}
SECTION("legitimate large values stay finite") {
GoldenHarness h;
h.assign_ok("a1", "(/ 1 0.000001)");
double val = h.sample("a1", 0.0);
REQUIRE(std::isfinite(val));
REQUIRE(val == Approx(1000000.0));
}
SECTION("sqrt of negative returns finite") {
GoldenHarness h;
h.assign_ok("a1", "(sqrt (- 0 1))");
double val = h.sample("a1", 0.0);
REQUIRE(std::isfinite(val));
// sqrt(fabs(-1)) = sqrt(1) = 1
REQUIRE(val == Approx(1.0));
}
SECTION("extreme time values don't crash") {
GoldenHarness h;
h.assign_ok("a1", "beat");
REQUIRE(std::isfinite(h.sample("a1", 1e10)));
REQUIRE(std::isfinite(h.sample("a1", -1.0)));
REQUIRE(std::isfinite(h.sample("a1", 0.0)));
}
SECTION("mod by zero substitutes bootstrap LKG") {
GoldenHarness h;
h.assign_ok("a1", "(% 5 0)");
double val = h.sample("a1", 0.0);
REQUIRE(std::isfinite(val));
REQUIRE(val == Approx(0.0));
REQUIRE(h.engine.pool.runtime_fallback_bit(0));
}
SECTION("chained operations producing intermediate infinities stay finite") {
GoldenHarness h;
// The non-finite intermediate reaches the root and activates LKG.
h.assign_ok("a1", "(* (/ 1 0) 5)");
double val = h.sample("a1", 0.0);
REQUIRE(std::isfinite(val));
REQUIRE(h.engine.pool.runtime_fallback_bit(0));
}
SECTION("NaN guard on executor output") {
GoldenHarness h;
// tan(pi/2) could produce very large values or NaN depending on precision
// The executor should guard against non-finite results
h.assign_ok("a1", "(tan (* t 3.14159265))");
// At t near 0.5, tan approaches infinity
double val = h.sample("a1", 0.4999999);
REQUIRE(std::isfinite(val));
}
}
// ============================================================================
// Phase 4.4: Output reassignment lifecycle
// ============================================================================
TEST_CASE("Phase 4: output reassignment lifecycle", "[phase4][lifecycle]") {
SECTION("reassigning output replaces the graph") {
GoldenHarness h;
h.assign_ok("a1", "beat");
REQUIRE(h.sample("a1", 0.125) == Approx(0.25));
h.assign_ok("a1", "bar");
REQUIRE(h.sample("a1", 0.5) == Approx(0.25)); // bar, not beat
}
SECTION("multiple rapid reassignments: last one wins") {
GoldenHarness h;
h.assign_ok("a1", "beat");
h.assign_ok("a1", "bar");
h.assign_ok("a1", "(+ beat bar)");
h.assign_ok("a1", "t");
REQUIRE(h.sample("a1", 1.25) == Approx(1.25)); // last assignment wins
}
SECTION("reassignment after tick updates prev correctly") {
GoldenHarness h;
h.assign_ok("a1", "0.75");
h.tick("a1", 0.0); // commits 0.75
h.assign_ok("a1", "0.5");
double val = h.tick("a1", 0.0);
REQUIRE(val == Approx(0.5));
}
SECTION("LKG value tracks last committed output") {
GoldenHarness h;
h.assign_ok("a1", "0.75");
h.tick("a1", 0.0); // commits 0.75 as lkg
// Now break the output
EvalResult bad = h.eval_result("(a1 (nonexistent))");
REQUIRE(bad.kind == EvalResult::Error);
// a1 should still produce 0.75 (from preserved graph — root_node intact)
REQUIRE(h.sample("a1", 0.0) == Approx(0.75));
}
SECTION("unassigned output produces zero") {
GoldenHarness h;
// a1 was never assigned
double val = h.sample("a1", 0.0);
REQUIRE(val == Approx(0.0));
}
SECTION("reassignment from time-varying to constant") {
GoldenHarness h;
h.assign_ok("a1", "beat");
REQUIRE(h.sample("a1", 0.25) == Approx(0.5));
h.assign_ok("a1", "42");
REQUIRE(h.sample("a1", 0.0) == Approx(42.0));
REQUIRE(h.sample("a1", 0.5) == Approx(42.0));
REQUIRE(h.sample("a1", 1.0) == Approx(42.0));
}
SECTION("reassignment from constant to time-varying") {
GoldenHarness h;
h.assign_ok("a1", "42");
REQUIRE(h.sample("a1", 0.0) == Approx(42.0));
h.assign_ok("a1", "t");
REQUIRE(h.sample("a1", 0.0) == Approx(0.0));
REQUIRE(h.sample("a1", 1.5) == Approx(1.5));
}
}
// ============================================================================
// Phase 4.5: Function-cell cross-references
// ============================================================================
TEST_CASE("Phase 4: function-cell cross-references", "[phase4][reactivity]") {
SECTION("function referencing a cell updates when cell changes") {
GoldenHarness h;
h.eval_ok("(define scale 2)");
h.eval_ok("(defn scaled [x] (* x scale))");
h.assign_ok("a1", "(scaled beat)");
// At t=0.125, beat=0.25, scaled=0.25*2=0.5
REQUIRE(h.sample("a1", 0.125) == Approx(0.5));
h.eval_ok("(define scale 4)");
// scaled now uses scale=4, so 0.25*4=1.0
REQUIRE(h.sample("a1", 0.125) == Approx(1.0));
}
SECTION("function calling another function, inner redefined") {
GoldenHarness h;
h.eval_ok("(defn inner [x] (* x 2))");
h.eval_ok("(defn outer [x] (+ (inner x) 100))");
h.assign_ok("a1", "(outer 5)");
// inner(5) = 10, outer(5) = 110
REQUIRE(h.sample("a1", 0.0) == Approx(110.0));
h.eval_ok("(defn inner [x] (* x 3))");
// inner(5) = 15, outer(5) = 115
REQUIRE(h.sample("a1", 0.0) == Approx(115.0));
}
SECTION("nested same-function call is not recursive") {
GoldenHarness h;
h.eval_ok("(defn dbl [x] (* x 2))");
h.assign_ok("a1", "(dbl (dbl 3))");
REQUIRE(h.sample("a1", 0.0) == Approx(12.0));
}
SECTION("cell used by multiple functions, all callers update") {
GoldenHarness h;
h.eval_ok("(define base 10)");
h.eval_ok("(defn add-base [x] (+ x base))");
h.eval_ok("(defn mul-base [x] (* x base))");
h.assign_ok("a1", "(add-base 5)");
h.assign_ok("a2", "(mul-base 5)");
REQUIRE(h.sample("a1", 0.0) == Approx(15.0));
REQUIRE(h.sample("a2", 0.0) == Approx(50.0));
h.eval_ok("(define base 20)");
REQUIRE(h.sample("a1", 0.0) == Approx(25.0));
REQUIRE(h.sample("a2", 0.0) == Approx(100.0));
}
SECTION("function redefinition propagates to output using it") {
GoldenHarness h;
h.eval_ok("(defn f [x] (* x 2))");
h.assign_ok("a1", "(f 5)");
REQUIRE(h.sample("a1", 0.0) == Approx(10.0));
h.eval_ok("(defn f [x] (+ x 100))");
REQUIRE(h.sample("a1", 0.0) == Approx(105.0));
}
SECTION("function and cell combined: function with cell arg, cell changes") {
GoldenHarness h;
h.eval_ok("(define offset 100)");
h.eval_ok("(defn shifted [x] (+ x offset))");
h.assign_ok("a1", "(shifted beat)");
// At t=0.125, beat=0.25, shifted=100.25
REQUIRE(h.sample("a1", 0.125) == Approx(100.25));
// Change offset
h.eval_ok("(define offset 200)");
REQUIRE(h.sample("a1", 0.125) == Approx(200.25));
}
}
// ============================================================================
// Phase 4.6: Cross-output reads (prev) and LKG interactions
// ============================================================================
TEST_CASE("Phase 4: cross-output reads and LKG", "[phase4][lifecycle]") {
SECTION("prev reads previous tick value") {
GoldenHarness h;
h.eval_ok("(a1 10) (a2 (prev a1))");
auto seq = h.tick_sequence("a2", {0.0, 0.001, 0.002});
// First tick: prev a1 is 0 (no prior)
REQUIRE(seq[0] == Approx(0.0));
// After first tick, a1 committed 10, so prev a1 = 10
REQUIRE(seq[1] == Approx(10.0));
REQUIRE(seq[2] == Approx(10.0));
}
SECTION("self-reference via prev accumulates") {
GoldenHarness h;
h.eval_ok("(a1 (+ (prev a1) 1))");
auto seq = h.tick_sequence("a1", {0.0, 0.001, 0.002, 0.003});
REQUIRE(seq[0] == Approx(1.0)); // prev starts at 0, + 1 = 1
REQUIRE(seq[1] == Approx(2.0));
REQUIRE(seq[2] == Approx(3.0));
REQUIRE(seq[3] == Approx(4.0));
}
SECTION("reassigning output resets its graph but prev is from last commit") {
GoldenHarness h;
h.assign_ok("a1", "0.5");
h.tick("a1", 0.0); // commit 0.5
h.assign_ok("a1", "(+ (prev a1) 0.1)");
// prev a1 was 0.5 from the last tick
double val = h.tick("a1", 0.0);
REQUIRE(val == Approx(0.6));
}
}
// ============================================================================
// Phase 4.7: Multiple outputs with shared dependencies
// ============================================================================
TEST_CASE("Phase 4: shared dependencies across outputs", "[phase4][reactivity]") {
SECTION("cell change triggers recompilation of all dependent outputs") {
GoldenHarness h;
h.eval_ok("(define gain 1.0)");
h.assign_ok("a1", "(* beat gain)");
h.assign_ok("a2", "(* bar gain)");
h.assign_ok("a3", "(* t gain)");
// At t=0.25, bpm=120: beat=0.5, bar=0.125, t=0.25
REQUIRE(h.sample("a1", 0.25) == Approx(0.5));
REQUIRE(h.sample("a2", 0.25) == Approx(0.125));
REQUIRE(h.sample("a3", 0.25) == Approx(0.25));
h.eval_ok("(define gain 2.0)");
REQUIRE(h.sample("a1", 0.25) == Approx(1.0));
REQUIRE(h.sample("a2", 0.25) == Approx(0.25));
REQUIRE(h.sample("a3", 0.25) == Approx(0.5));
}
SECTION("independent cells affect only their outputs") {
GoldenHarness h;
h.eval_ok("(define x 10)");
h.eval_ok("(define y 20)");
h.assign_ok("a1", "x");
h.assign_ok("a2", "y");
h.eval_ok("(define x 99)");
REQUIRE(h.sample("a1", 0.0) == Approx(99.0));
REQUIRE(h.sample("a2", 0.0) == Approx(20.0)); // y unchanged
}
SECTION("output with no cell deps is unaffected by cell changes") {
GoldenHarness h;
h.eval_ok("(define x 10)");
h.assign_ok("a1", "x");
h.assign_ok("a2", "beat"); // no cell dependency
h.eval_ok("(define x 99)");
REQUIRE(h.sample("a1", 0.0) == Approx(99.0));
// a2 should be completely unaffected
REQUIRE(h.sample("a2", 0.25) == Approx(0.5));
}
}