ModuLisp/test/signal_engine/test_state_identity.cpp

637 lines
22 KiB
C++
Raw Permalink Normal View History

// State-identity golden tests.
//
// These tests verify that the StateResourceRegistry and :id keyword system
// preserves state across recompilation, forks state for different identities,
// shares state across operator-compatible changes, and isolates state for
// incompatible resource kinds.
#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 {
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] = {};
double prev_t = 0.0;
double last_dt = 0.0;
bool has_ticked = false;
bool state_committed_this_step = false;
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);
last_dt = t - prev_t;
if (engine.pool.state_slot_count > 0 &&
has_ticked && t != prev_t && !state_committed_this_step)
{
ExecutionContext state_ctx;
state_ctx.t = t;
state_ctx.dt = last_dt;
state_ctx.cell_values = cell_values;
state_ctx.hw_inputs = hw_inputs;
state_ctx.data_pool = engine.cells.data_pool;
state_ctx.data_offsets = engine.cells.data_offsets;
state_ctx.data_lengths = engine.cells.data_lengths;
state_ctx.prev_outputs = engine.pool.prev_output_values;
state_ctx.output_values = outputs;
state_ctx.workspace = workspace;
execute_all_outputs(engine.pool, state_ctx);
commit_state(engine.pool, workspace);
state_committed_this_step = true;
std::memset(workspace, 0, sizeof(workspace));
std::memset(outputs, 0, sizeof(outputs));
}
ExecutionContext ctx;
ctx.t = t;
ctx.dt = last_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(output_name)];
}
double tick(const char* output_name, double t)
{
if (t != prev_t) {
state_committed_this_step = false;
}
double value = sample(output_name, t);
commit_outputs(engine.pool, outputs);
has_ticked = true;
prev_t = t;
return value;
}
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
// ============================================================================
// Test 1: Reorder with same :id preserves state
// ============================================================================
TEST_CASE("State identity: reorder with same :id preserves state",
"[golden][state_identity]") {
GoldenHarness h;
h.eval_ok("(a1 (phasor 1 :id \"p\"))");
// Tick several times to accumulate phase.
// phasor(1) at dt=0.01 increments phase by 0.01 each tick.
h.tick("a1", 0.0);
h.tick("a1", 0.01);
h.tick("a1", 0.02);
h.tick("a1", 0.03);
h.tick("a1", 0.04);
// Read the accumulated phase from the state slot directly.
// After 4 increments of dt=0.01 at freq=1, phase should be ~0.04.
double phase_before = h.engine.pool.state_values[0];
INFO("phase_before: " << phase_before);
REQUIRE(phase_before > 0.01); // definitely accumulated
// Recompile the exact same expression — state must survive.
h.eval_ok("(a1 (phasor 1 :id \"p\"))");
double phase_after = h.engine.pool.state_values[0];
INFO("phase_after: " << phase_after);
REQUIRE(phase_after == Approx(phase_before).margin(1e-12));
// Verify the phasor continues from where it was — the next tick should
// produce a value close to phase_before (not reset to 0).
double val = h.tick("a1", 0.05);
REQUIRE(val > phase_before * 0.5); // not reset to zero
REQUIRE(val == Approx(phase_before + 0.01).margin(1e-6)); // advanced by one dt
}
// ============================================================================
// Test 2: Different :id forks state
// ============================================================================
TEST_CASE("State identity: different :id forks state",
"[golden][state_identity]") {
GoldenHarness h;
// Two phasors with different :ids should get different registry entries.
h.eval_ok("(a1 (phasor 1 :id \"alpha\"))");
h.eval_ok("(a2 (phasor 2 :id \"beta\"))");
// Registry should have at least 2 entries.
REQUIRE(h.engine.registry.entry_count >= 2);
// Tick to accumulate different phases.
h.tick("a1", 0.0);
h.tick("a1", 0.01);
h.tick("a2", 0.0);
h.tick("a2", 0.01);
// The two phasors have different frequencies, so state values should differ.
// Find the slots for each :id.
uint16_t slot_alpha = NODE_NONE;
uint16_t slot_beta = NODE_NONE;
for (uint16_t i = 0; i < h.engine.registry.entry_count; i++) {
auto& e = h.engine.registry.entries[i];
if (e.key.state_id == internSymbol("alpha") &&
e.key.kind == ResourceKind::OscillatorPhase) {
slot_alpha = e.slot_index;
}
if (e.key.state_id == internSymbol("beta") &&
e.key.kind == ResourceKind::OscillatorPhase) {
slot_beta = e.slot_index;
}
}
REQUIRE(slot_alpha != NODE_NONE);
REQUIRE(slot_beta != NODE_NONE);
REQUIRE(slot_alpha != slot_beta);
// The two slots hold independent values — verify they are distinct.
// Both phasors were ticked with the same times but at different
// frequencies, so their accumulated phases must differ.
REQUIRE(h.engine.pool.state_values[slot_alpha] !=
h.engine.pool.state_values[slot_beta]);
}
// ============================================================================
// Test 3: Operator-compatible change preserves phase
// ============================================================================
TEST_CASE("State identity: operator-compatible change preserves phase",
"[golden][state_identity]") {
GoldenHarness h;
// saw and tri-osc both use ResourceKind::OscillatorPhase, so sharing
// an :id between them should resolve to the same state slot.
h.eval_ok("(a1 (lfo/saw 1 :id \"x\"))");
// Accumulate phase.
h.tick("a1", 0.0);
h.tick("a1", 0.01);
h.tick("a1", 0.02);
h.tick("a1", 0.03);
// Record the accumulated phase.
// Find the slot for :id "x" with OscillatorPhase kind.
uint16_t slot = NODE_NONE;
for (uint16_t i = 0; i < h.engine.registry.entry_count; i++) {
auto& e = h.engine.registry.entries[i];
if (e.key.state_id == internSymbol("x") &&
e.key.kind == ResourceKind::OscillatorPhase) {
slot = e.slot_index;
break;
}
}
REQUIRE(slot != NODE_NONE);
double phase_before = h.engine.pool.state_values[slot];
INFO("phase_before: " << phase_before);
REQUIRE(phase_before > 0.0);
// Recompile as tri-osc with the same :id.
// The OscillatorPhase slot should be reused (same key).
h.eval_ok("(a1 (lfo/tri 1 :id \"x\"))");
double phase_after = h.engine.pool.state_values[slot];
INFO("phase_after: " << phase_after);
REQUIRE(phase_after == Approx(phase_before).margin(1e-12));
}
// ============================================================================
// Test 4: Incompatible change gets separate resources
// ============================================================================
TEST_CASE("State identity: incompatible change gets separate resources",
"[golden][state_identity]") {
GoldenHarness h;
// phasor uses ResourceKind::OscillatorPhase; count uses Counter +
// TriggerMemory + ResetLatch. Even with the same :id "x", they
// should resolve to different registry entries / slots.
h.eval_ok("(a1 (phasor 1 :id \"x\"))");
uint16_t entries_after_phasor = h.engine.registry.entry_count;
INFO("entries after phasor: " << entries_after_phasor);
REQUIRE(entries_after_phasor >= 1);
// count with same :id "x" — needs Counter, TriggerMemory, ResetLatch.
// Use a1 on a different output to avoid overwriting.
// We compile as a separate output so both co-exist.
h.eval_ok("(a2 (count (sqr beat) :id \"x\"))");
uint16_t entries_after_count = h.engine.registry.entry_count;
INFO("entries after count: " << entries_after_count);
// count allocates 3 slots (Counter, TriggerMemory, ResetLatch).
// phasor allocated 1 (OscillatorPhase). Total should be at least 4.
REQUIRE(entries_after_count >= entries_after_phasor + 3);
// Verify the phasor slot is distinct from every count slot.
uint16_t phasor_slot = NODE_NONE;
std::vector<uint16_t> count_slots;
for (uint16_t i = 0; i < h.engine.registry.entry_count; i++) {
auto& e = h.engine.registry.entries[i];
if (e.key.state_id == internSymbol("x")) {
if (e.key.kind == ResourceKind::OscillatorPhase) {
phasor_slot = e.slot_index;
} else {
count_slots.push_back(e.slot_index);
}
}
}
REQUIRE(phasor_slot != NODE_NONE);
REQUIRE(count_slots.size() == 3);
for (auto s : count_slots) {
REQUIRE(s != phasor_slot);
}
}
// ============================================================================
// Test 5: Init not replayed on recompile
// ============================================================================
TEST_CASE("State identity: init not replayed on recompile",
"[golden][state_identity]") {
GoldenHarness h;
// Compile a phasor with :phase 0.0 and :id "p", then accumulate.
h.eval_ok("(a1 (phasor 2 :id \"p\" :phase 0.0))");
h.tick("a1", 0.0);
h.tick("a1", 0.01);
h.tick("a1", 0.02);
h.tick("a1", 0.03);
h.tick("a1", 0.04);
// Phase should have accumulated significantly (freq=2, 4 ticks of dt=0.01).
double phase_before = h.engine.pool.state_values[0];
INFO("phase_before: " << phase_before);
REQUIRE(phase_before > 0.01);
// Recompile the same expression — :phase 0.0 is an init hint, NOT a
// reset command. The registry should find the existing slot and skip
// re-initialization.
h.eval_ok("(a1 (phasor 2 :id \"p\" :phase 0.0))");
double phase_after = h.engine.pool.state_values[0];
INFO("phase_after: " << phase_after);
REQUIRE(phase_after == Approx(phase_before).margin(1e-12));
}
// ============================================================================
// Test 6: Anonymous slots still work
// ============================================================================
TEST_CASE("State identity: anonymous slots still work",
"[golden][state_identity]") {
GoldenHarness h;
// No :id — should allocate state slots via the old anonymous path.
h.eval_ok("(a1 (phasor 1))");
REQUIRE(h.engine.pool.state_slot_count >= 1);
// Tick several times and verify the phasor accumulates phase.
h.tick("a1", 0.0);
h.tick("a1", 0.01);
h.tick("a1", 0.02);
h.tick("a1", 0.03);
// After multiple ticks at freq=1, state should have accumulated.
double phase = h.engine.pool.state_values[0];
INFO("accumulated anonymous phase: " << phase);
REQUIRE(phase > 0.01);
// Verify tick produces monotonically increasing values.
double v3 = h.tick("a1", 0.04);
double v4 = h.tick("a1", 0.05);
REQUIRE(v4 > v3);
}
// ============================================================================
// Test 7: useq-clear resets registry
// ============================================================================
TEST_CASE("State identity: useq-clear resets registry",
"[golden][state_identity]") {
GoldenHarness h;
// Build up some registry state.
h.eval_ok("(a1 (phasor 1 :id \"p1\"))");
h.eval_ok("(a2 (phasor 2 :id \"p2\"))");
// Tick to accumulate.
h.tick("a1", 0.0);
h.tick("a1", 0.01);
h.tick("a2", 0.0);
h.tick("a2", 0.01);
REQUIRE(h.engine.registry.entry_count >= 2);
REQUIRE(h.engine.pool.state_slot_count >= 2);
// useq-clear should reset the registry.
h.eval_ok("(useq-clear)");
REQUIRE(h.engine.registry.entry_count == 0);
}
// ============================================================================
// Additional: Named :id across multiple recompiles preserves continuity
// ============================================================================
TEST_CASE("State identity: multiple recompiles with same :id are stable",
"[golden][state_identity]") {
GoldenHarness h;
h.eval_ok("(a1 (phasor 1 :id \"stable\"))");
// Tick 10 times.
for (int i = 0; i < 10; i++) {
h.tick("a1", i * 0.01);
}
double phase_a = h.engine.pool.state_values[0];
// Recompile 5 times. Phase must not reset.
for (int i = 0; i < 5; i++) {
h.eval_ok("(a1 (phasor 1 :id \"stable\"))");
double phase_now = h.engine.pool.state_values[0];
REQUIRE(phase_now == Approx(phase_a).margin(1e-12));
}
}
// ============================================================================
// Additional: Registry entry_count does not grow on re-resolve
// ============================================================================
TEST_CASE("State identity: re-resolve does not grow entry count",
"[golden][state_identity]") {
GoldenHarness h;
h.eval_ok("(a1 (phasor 1 :id \"re\"))");
uint16_t count_after_first = h.engine.registry.entry_count;
// Recompile same expression multiple times.
h.eval_ok("(a1 (phasor 1 :id \"re\"))");
h.eval_ok("(a1 (phasor 1 :id \"re\"))");
h.eval_ok("(a1 (phasor 1 :id \"re\"))");
REQUIRE(h.engine.registry.entry_count == count_after_first);
}
TEST_CASE("State identity: one update writer owns an explicit id",
"[golden][state_identity][ownership]") {
GoldenHarness h;
h.eval_ok("(a1 (phasor 1 :id \"owned-phase\"))");
uint16_t entries_before = h.engine.registry.entry_count;
uint16_t slots_before = h.engine.pool.state_slot_count;
EvalResult conflict =
h.eval_result("(a2 (phasor 2 :id \"owned-phase\"))");
REQUIRE(conflict.kind == EvalResult::Error);
REQUIRE(conflict.diagnostic_count >= 1);
REQUIRE(conflict.diagnostics[0].category ==
DiagnosticCategory::Boundary);
REQUIRE(h.engine.pool.outputs[h.output_index("a2")].root_node ==
NODE_NONE);
REQUIRE(h.engine.registry.entry_count == entries_before);
REQUIRE(h.engine.pool.state_slot_count == slots_before);
// Recompiling the owning program is still legal and preserves state.
h.eval_ok("(a1 (phasor 3 :id \"owned-phase\"))");
REQUIRE(h.engine.registry.entry_count == entries_before);
REQUIRE(h.engine.pool.state_slot_count == slots_before);
}
TEST_CASE("State identity: retired program state slots are reused",
"[golden][state_identity][reclaim]") {
GoldenHarness h;
h.eval_ok("(a1 (phasor 1 :id \"retired-phase\"))");
REQUIRE(h.engine.registry.entry_count == 1);
REQUIRE(h.engine.pool.state_slot_count == 1);
uint16_t retired_slot = h.engine.registry.entries[0].slot_index;
// Publishing a pure replacement retires the old program's only writer.
h.eval_ok("(a1 0.5)");
REQUIRE(h.engine.registry.entry_count == 0);
REQUIRE(h.engine.registry.free_slot_count == 0);
REQUIRE(h.engine.pool.state_slot_count == 0);
// A different program starts again at the compacted dense slot zero, so
// repeated edit/replacement cycles do not exhaust fixed firmware storage.
h.eval_ok("(a2 (integrate 1 :id \"new-integrator\"))");
REQUIRE(h.engine.registry.entry_count == 1);
REQUIRE(h.engine.registry.entries[0].slot_index == retired_slot);
REQUIRE(h.engine.registry.free_slot_count == 0);
REQUIRE(h.engine.pool.state_slot_count == 1);
}
// ============================================================================
// Projection fork: simulate save/restore cycle and verify invariants
// ============================================================================
TEST_CASE("State identity: projection fork preserves live state",
"[golden][state_identity][projection]") {
GoldenHarness h;
h.eval_ok("(a1 (phasor 1 :id \"proj-p\"))");
h.tick("a1", 0.0);
h.tick("a1", 0.01);
h.tick("a1", 0.02);
// Snapshot live state before projection
double live_state[MAX_STATE_SLOTS];
memcpy(live_state, h.engine.pool.state_values, sizeof(live_state));
uint16_t live_slot_count = h.engine.pool.state_slot_count;
uint16_t live_registry_count = h.engine.registry.entry_count;
double live_prev_outputs[MAX_OUTPUTS];
memcpy(live_prev_outputs, h.engine.pool.prev_output_values, sizeof(live_prev_outputs));
// Simulate projection fork: save → install fork → advance → restore
// Save
double saved_state[MAX_STATE_SLOTS];
uint16_t saved_slot_count = h.engine.pool.state_slot_count;
StateResourceRegistry saved_registry = h.engine.registry;
double saved_prev_outputs[MAX_OUTPUTS];
memcpy(saved_state, h.engine.pool.state_values, sizeof(saved_state));
memcpy(saved_prev_outputs, h.engine.pool.prev_output_values, sizeof(saved_prev_outputs));
// Execute several projection samples (advances state in the engine)
for (int s = 0; s < 10; s++) {
double t = 0.03 + s * 0.01;
std::memset(h.outputs, 0, sizeof(h.outputs));
std::memset(h.workspace, 0, sizeof(h.workspace));
h.engine.cells.snapshot_values(h.cell_values, MAX_CELLS);
ExecutionContext ctx;
ctx.t = t;
ctx.dt = 0.01;
ctx.cell_values = h.cell_values;
ctx.hw_inputs = h.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 = h.outputs;
ctx.workspace = h.workspace;
execute_all_outputs(h.engine.pool, ctx);
commit_state(h.engine.pool, h.workspace);
commit_outputs(h.engine.pool, h.outputs);
}
// State has been mutated by projection execution
REQUIRE(h.engine.pool.state_values[0] != live_state[0]);
// Restore live state
memcpy(h.engine.pool.state_values, saved_state, sizeof(saved_state));
h.engine.pool.state_slot_count = saved_slot_count;
h.engine.registry = saved_registry;
memcpy(h.engine.pool.prev_output_values, saved_prev_outputs, sizeof(saved_prev_outputs));
// Verify live state is unchanged
REQUIRE(h.engine.pool.state_slot_count == live_slot_count);
REQUIRE(h.engine.registry.entry_count == live_registry_count);
for (uint16_t i = 0; i < live_slot_count; i++) {
REQUIRE(h.engine.pool.state_values[i] == Approx(live_state[i]));
}
for (uint16_t i = 0; i < MAX_OUTPUTS; i++) {
REQUIRE(h.engine.pool.prev_output_values[i] == Approx(live_prev_outputs[i]));
}
}
TEST_CASE("State identity: repeated projection doesn't grow registry",
"[golden][state_identity][projection]") {
GoldenHarness h;
h.eval_ok("(a1 (phasor 1 :id \"rp\"))");
h.tick("a1", 0.0);
uint16_t initial_entries = h.engine.registry.entry_count;
uint16_t initial_slots = h.engine.pool.state_slot_count;
// Simulate 5 projection fork cycles
for (int cycle = 0; cycle < 5; cycle++) {
double saved_state[MAX_STATE_SLOTS];
uint16_t saved_slot_count = h.engine.pool.state_slot_count;
memcpy(saved_state, h.engine.pool.state_values, sizeof(saved_state));
// Advance 3 samples in "fork"
for (int s = 0; s < 3; s++) {
double t = 0.01 * (cycle * 3 + s + 1);
std::memset(h.outputs, 0, sizeof(h.outputs));
std::memset(h.workspace, 0, sizeof(h.workspace));
h.engine.cells.snapshot_values(h.cell_values, MAX_CELLS);
ExecutionContext ctx;
ctx.t = t;
ctx.dt = 0.01;
ctx.cell_values = h.cell_values;
ctx.hw_inputs = h.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 = h.outputs;
ctx.workspace = h.workspace;
execute_all_outputs(h.engine.pool, ctx);
commit_state(h.engine.pool, h.workspace);
}
// Restore
memcpy(h.engine.pool.state_values, saved_state, sizeof(saved_state));
h.engine.pool.state_slot_count = saved_slot_count;
}
REQUIRE(h.engine.registry.entry_count == initial_entries);
REQUIRE(h.engine.pool.state_slot_count == initial_slots);
}