ModuLisp/test/signal_engine/test_ext_registry.cpp

574 lines
20 KiB
C++
Raw Permalink Normal View History

// External registers: named external inputs, external sinks, and cold
// commands (ext_registry.{h,cpp}).
//
// Written at the language boundary like the golden tests: eval source text,
// tick the engine, and assert user-visible values plus the registry seams'
// observable contracts (compile-time LKG independence, dirty publication,
// handler dispatch).
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include <cmath>
#include <cstring>
#include <string>
using namespace sig;
namespace {
struct CommandCapture {
SymbolID cmd = 0;
ColdArg args[MAX_COLD_ARGS] = {};
uint8_t nargs = 0;
void* user = nullptr;
uint32_t calls = 0;
bool accept = true;
};
CommandCapture g_capture = {};
bool test_command_handler(SymbolID cmd, const ColdArg* args, uint8_t nargs,
void* user)
{
g_capture.cmd = cmd;
g_capture.nargs = nargs;
g_capture.user = user;
for (uint8_t i = 0; i < nargs; i++) g_capture.args[i] = args[i];
g_capture.calls++;
return g_capture.accept;
}
struct ExtHarness {
SignalEngine engine;
double cell_values[MAX_CELLS] = {};
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
ExtHarness() {
engine.init_defaults();
reset_registry();
g_capture = CommandCapture{};
}
~ExtHarness() { reset_registry(); }
EvalResult eval(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(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);
}
void expect_error(const std::string& code, DiagnosticCategory category)
{
EvalResult r = eval(code);
INFO("code: " << code);
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count > 0);
bool found = false;
for (uint8_t i = 0; i < r.diagnostic_count; ++i)
if (r.diagnostics[i].category == category) found = true;
INFO("expected category: " << category_to_cstr(category));
REQUIRE(found);
}
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;
}
const SinkBinding* binding_for(const char* sink_name)
{
const SymbolID sink = internSymbol(sink_name);
for (uint8_t i = 0; i < engine.sink_binding_count; i++)
if (engine.sink_bindings[i].sink == sink)
return &engine.sink_bindings[i];
return nullptr;
}
double tick(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);
publish_sink_values(engine, outputs);
const double value = outputs[output_index(output_name)];
commit_outputs(engine.pool, outputs);
return value;
}
// Tick without naming an output; sink publication still runs.
void tick_sinks(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);
publish_sink_values(engine, outputs);
commit_outputs(engine.pool, outputs);
}
};
} // namespace
// ── External inputs ─────────────────────────────────────────────────────────
TEST_CASE("external inputs resolve as graph leaves", "[ext_registry]")
{
ExtHarness h;
SECTION("registered input reads the executor hw snapshot")
{
REQUIRE(register_external_input(
{"meml/joy-x", 3, 1, 0.5f, "[0,1]"}));
h.hw_inputs[3] = 0.25;
h.eval_ok("(a1 (* 2 meml/joy-x))");
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
}
SECTION("registered input usable inside a sink expression")
{
REQUIRE(register_external_input({"nn/out1", 4, 1, 0.5f, "[0,1]"}));
REQUIRE(register_external_sink({"transport/mono", 1, 0.0f, 1.0f, 50, 7}));
h.hw_inputs[4] = 0.125;
h.eval_ok("(transport/mono nn/out1)");
h.tick_sinks(0.0);
REQUIRE(h.engine.sink_binding_count == 1);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.125));
}
SECTION("multi-channel input is selected by a hot 1-based signal")
{
REQUIRE(register_external_input(
{"nn/out", 4, 4, 0.5f, "[0,1]"}));
h.hw_inputs[4] = 0.1;
h.hw_inputs[5] = 0.2;
h.hw_inputs[6] = 0.3;
h.hw_inputs[7] = 0.4;
h.eval_ok("(a1 (nn/out (from-list [1 4] bar)))");
REQUIRE(h.tick("a1", 0.0) == Approx(0.1));
REQUIRE(h.tick("a1", 1.5) == Approx(0.4));
}
2026-09-01 16:00:23 +02:00
SECTION("cold expressions read the latest external-input snapshot")
{
REQUIRE(register_external_input(
{"nn/out", 4, 4, 0.5f, "[0,1]"}));
h.hw_inputs[4] = 0.1;
h.hw_inputs[5] = 0.2;
h.hw_inputs[6] = 0.3;
h.hw_inputs[7] = 0.4;
h.engine.snapshot_cold_hw_inputs(h.hw_inputs, 32);
EvalResult selected = h.eval("(nn/out 4)");
REQUIRE(selected.kind == EvalResult::Number);
REQUIRE(selected.number == Approx(0.4));
EvalResult composed = h.eval("(* 2 (nn/out 2))");
REQUIRE(composed.kind == EvalResult::Number);
REQUIRE(composed.number == Approx(0.4));
EvalResult invalid = h.eval("(nn/out 9)");
REQUIRE(invalid.kind == EvalResult::Number);
REQUIRE(invalid.number == Approx(0.5));
}
SECTION("invalid multi-channel selectors return the declared neutral")
{
REQUIRE(register_external_input(
{"nn/out", 4, 4, 0.5f, "[0,1]"}));
h.eval_ok("(a1 (nn/out 0))");
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
h.eval_ok("(a1 (nn/out 5))");
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
h.eval_ok("(a1 (nn/out (/ 0 0)))");
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
}
SECTION("multi-channel input cannot be read bare")
{
REQUIRE(register_external_input(
{"nn/out", 4, 4, 0.5f, "[0,1]"}));
h.expect_error("(a1 nn/out)", DiagnosticCategory::Arity);
h.expect_error("(a1 (nn/out))", DiagnosticCategory::Arity);
h.expect_error("(a1 (nn/out 1 2))", DiagnosticCategory::Arity);
}
SECTION("invalid descriptors are rejected")
{
REQUIRE(!register_external_input({nullptr, 0, 1, 0.0f, ""}));
REQUIRE(!register_external_input({"", 0, 1, 0.0f, ""}));
REQUIRE(!register_external_input({"bad/chan", 31, 2, 0.0f, ""}));
REQUIRE(register_external_input({"ok/in", 31, 1, 0.0f, ""}));
REQUIRE(register_external_input({"ok/in", 12, 1, 0.0f, ""})); // replace
h.hw_inputs[12] = 0.5;
h.eval_ok("(a1 ok/in)");
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
}
}
// ── External sinks ──────────────────────────────────────────────────────────
TEST_CASE("external sinks publish evaluated values", "[ext_registry]")
{
ExtHarness h;
REQUIRE(register_external_input({"ctl/x", 2, 1, 0.0f, "[0,1]"}));
REQUIRE(register_external_sink({"transport/mono", 1, 0.0f, 1.0f, 50, 7}));
SECTION("assignment evaluates and raises the dirty flag")
{
h.eval_ok("(transport/mono ctl/x)");
REQUIRE(h.engine.sink_binding_count == 1);
h.hw_inputs[2] = 0.5;
h.tick_sinks(0.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.5));
REQUIRE(h.engine.sink_dirty[0]);
h.engine.sink_dirty[0] = false; // firmware acknowledgement
h.tick_sinks(1.0);
REQUIRE(!h.engine.sink_dirty[0]); // unchanged value suppressed
h.hw_inputs[2] = 0.9;
h.tick_sinks(2.0);
REQUIRE(h.engine.sink_dirty[0]);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.9));
}
SECTION("dirty uses one quantisation step as deadband")
{
h.eval_ok("(transport/mono ctl/x)");
h.hw_inputs[2] = 0.5;
h.tick_sinks(0.0);
h.engine.sink_dirty[0] = false;
// step = (1-0)/2^7 = 0.0078125; 0.003 stays inside the deadband
h.hw_inputs[2] = 0.503;
h.tick_sinks(1.0);
REQUIRE(!h.engine.sink_dirty[0]);
// 0.52 - 0.503 = 0.017 exceeds the step
h.hw_inputs[2] = 0.52;
h.tick_sinks(2.0);
REQUIRE(h.engine.sink_dirty[0]);
}
SECTION("multi-channel sink publishes every channel")
{
REQUIRE(register_external_sink({"bus/out", 3, 0.0f, 1.0f, 200, 0}));
h.eval_ok("(bus/out 0.1 0.2 0.3)");
h.tick_sinks(0.0);
const SinkBinding* binding = h.binding_for("bus/out");
REQUIRE(binding != nullptr);
REQUIRE(binding->arity == 3);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.1));
REQUIRE(h.engine.sink_values[0][1] == Approx(0.2));
REQUIRE(h.engine.sink_values[0][2] == Approx(0.3));
// Reassignment keeps one binding and swaps every channel.
h.eval_ok("(bus/out 0.4 0.5 0.6)");
REQUIRE(h.engine.sink_binding_count == 1);
h.tick_sinks(1.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.4));
REQUIRE(h.engine.sink_values[0][1] == Approx(0.5));
REQUIRE(h.engine.sink_values[0][2] == Approx(0.6));
}
SECTION("full 16-channel sink binds and publishes every channel")
{
// The nn/in neural-input shape (NISPS-USEQ spec §4.2/§4.3): 8 base +
// 8 modulation expressions in one binding. MAX_SINK_ARITY and the
// pool arithmetic in ext_registry.h are sized for this.
REQUIRE(register_external_sink({"nn/in", 16, 0.0f, 1.0f, 200, 7}));
h.eval_ok("(nn/in 0.00 0.05 0.10 0.15 0.20 0.25 0.30 0.35 "
"0.5 0.4 0.3 0.2 0.1 0.0 -0.1 -0.2)");
h.tick_sinks(0.0);
const SinkBinding* binding = h.binding_for("nn/in");
REQUIRE(binding != nullptr);
REQUIRE(binding->arity == 16);
for (uint8_t ch = 0; ch < 16; ch++) {
const double expected = ch < 8 ? 0.05 * ch : 0.5 - 0.1 * (ch - 8);
REQUIRE(h.engine.sink_values[0][ch] == Approx(expected));
}
// Slot plan: 16 pool output slots at/above SINK_SLOT_BASE.
for (uint8_t ch = 0; ch < 16; ch++) {
REQUIRE(binding->value_index[ch] >= SINK_SLOT_BASE);
REQUIRE(binding->value_index[ch] < MAX_OUTPUTS);
}
// Rebinding swaps all 16 channels as one transaction.
h.eval_ok("(nn/in 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0)");
h.tick_sinks(1.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(1.0));
REQUIRE(h.engine.sink_values[0][15] == Approx(0.0));
}
SECTION("wrong arity is a compile error and binds nothing")
{
h.expect_error("(transport/mono 0.1 0.2)", DiagnosticCategory::Arity);
h.expect_error("(transport/mono)", DiagnosticCategory::Arity);
REQUIRE(h.engine.sink_binding_count == 0);
}
SECTION("unassign releases the binding")
{
h.eval_ok("(transport/mono ctl/x)");
REQUIRE(h.engine.sink_binding_count == 1);
h.eval_ok("(unassign transport/mono)");
REQUIRE(h.engine.sink_binding_count == 0);
REQUIRE(h.binding_for("transport/mono") == nullptr);
// Idempotent, and outputs keep working afterwards.
h.eval_ok("(unassign transport/mono)");
h.eval_ok("(a1 0.25)");
REQUIRE(h.tick("a1", 0.0) == Approx(0.25));
}
}
TEST_CASE("sink LKG is independent per sink", "[ext_registry]")
{
ExtHarness h;
REQUIRE(register_external_input({"ctl/a", 2, 1, 0.0f, "[0,1]"}));
REQUIRE(register_external_input({"ctl/b", 3, 1, 0.0f, "[0,1]"}));
REQUIRE(register_external_sink({"sink/a", 1, 0.0f, 1.0f, 50, 0}));
REQUIRE(register_external_sink({"sink/b", 1, 0.0f, 1.0f, 50, 0}));
h.hw_inputs[2] = 0.25;
h.hw_inputs[3] = 0.75;
h.eval_ok("(sink/a ctl/a)");
h.eval_ok("(sink/b ctl/b)");
h.eval_ok("(a1 ctl/a)");
h.tick_sinks(0.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.25));
REQUIRE(h.engine.sink_values[1][0] == Approx(0.75));
const SinkBinding* binding_a = h.binding_for("sink/a");
REQUIRE(binding_a != nullptr);
const uint16_t root_before =
h.engine.pool.outputs[binding_a->value_index[0]].root_node;
// A failing edit to sink/a must retain sink/a's published graph while
// sink/b and the outputs keep running.
h.expect_error("(sink/a nosuch/input)", DiagnosticCategory::UndefinedName);
REQUIRE(h.engine.sink_binding_count == 2);
const SinkBinding* after = h.binding_for("sink/a");
REQUIRE(after != nullptr);
REQUIRE(after->arity == 1);
REQUIRE(h.engine.pool.outputs[after->value_index[0]].root_node ==
root_before);
// The retained graph still tracks its input.
h.hw_inputs[2] = 0.5;
h.hw_inputs[3] = 0.8;
h.tick_sinks(1.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.5));
REQUIRE(h.engine.sink_values[1][0] == Approx(0.8));
REQUIRE(h.tick("a1", 2.0) == Approx(0.5));
// A repaired edit replaces the binding.
h.eval_ok("(sink/a 0.125)");
h.tick_sinks(3.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(0.125));
REQUIRE(h.engine.sink_binding_count == 2);
}
TEST_CASE("multi-binding routed sink keeps independent route instances",
"[ext_registry][routed_sink]")
{
ExtHarness h;
REQUIRE(register_external_input({"ctl/value", 2, 1, 0.0f, "[0,1]"}));
REQUIRE(register_external_input({"ctl/cc", 3, 1, 74.0f, "[1,127]"}));
ExternalSinkDesc midi{};
midi.name = "midi/cc";
midi.arity = 2;
midi.min = 0.0f;
midi.max = 1.0f;
midi.max_rate_hz = 50;
midi.quant_bits = 7;
midi.route_ch = 1;
midi.route_min = 1.0f;
midi.route_max = 127.0f;
midi.allow_multiple_bindings = true;
REQUIRE(register_external_sink(midi));
h.hw_inputs[2] = 0.25;
h.hw_inputs[3] = 75.0;
h.eval_ok("(midi/cc 74 ctl/value)");
h.eval_ok("(midi/cc ctl/cc 0.5)");
REQUIRE(h.engine.sink_binding_count == 2);
h.tick_sinks(0.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(74.0));
REQUIRE(h.engine.sink_values[0][1] == Approx(0.25));
REQUIRE(h.engine.sink_values[1][0] == Approx(75.0));
REQUIRE(h.engine.sink_values[1][1] == Approx(0.5));
// The documented patterned route is an ordinary signal expression. Its
// token structure, not whitespace, is the stable route edit identity.
h.eval_ok("(midi/cc (from-list [76 77] bar) 0.125)");
REQUIRE(h.engine.sink_binding_count == 3);
h.eval_ok("(midi/cc (from-list [76 77] bar) 0.25)");
REQUIRE(h.engine.sink_binding_count == 3);
h.tick_sinks(0.5);
REQUIRE(h.engine.sink_values[2][1] == Approx(0.25));
// The leading CC expression is the route identity. Re-entering the same
// route replaces its value graph instead of leaking a duplicate binding.
h.eval_ok("(midi/cc 74 0.75)");
REQUIRE(h.engine.sink_binding_count == 3);
h.tick_sinks(1.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(74.0));
REQUIRE(h.engine.sink_values[0][1] == Approx(0.75));
// A failed edit retains all prior route instances.
h.expect_error("(midi/cc 74 nosuch/input)",
DiagnosticCategory::UndefinedName);
REQUIRE(h.engine.sink_binding_count == 3);
h.tick_sinks(2.0);
REQUIRE(h.engine.sink_values[0][1] == Approx(0.75));
REQUIRE(h.engine.sink_values[1][1] == Approx(0.5));
// Route channels bypass the payload deadband so patterned addressing is
// observed even when the payload itself is unchanged.
h.engine.sink_dirty[0] = false;
h.engine.sink_dirty[1] = false;
h.hw_inputs[3] = 75.001;
h.tick_sinks(3.0);
REQUIRE(!h.engine.sink_dirty[0]);
REQUIRE(h.engine.sink_dirty[1]);
// Ordinary unassignment clears the whole dynamic route family.
h.eval_ok("(unassign midi/cc)");
REQUIRE(h.engine.sink_binding_count == 0);
REQUIRE(h.binding_for("midi/cc") == nullptr);
}
// ── Cold commands ───────────────────────────────────────────────────────────
TEST_CASE("registered cold commands dispatch to the handler", "[ext_registry]")
{
ExtHarness h;
int cookie = 0;
REQUIRE(register_external_command(internSymbol("meml/cmd"), 1, 2));
set_cold_command_handler(&test_command_handler, &cookie);
const SymbolID cmd_id = internSymbol("meml/cmd");
SECTION("integer and float arguments parse by shape")
{
h.eval_ok("(meml/cmd 3)");
REQUIRE(g_capture.calls == 1);
REQUIRE(g_capture.cmd == cmd_id);
REQUIRE(g_capture.user == &cookie);
REQUIRE(g_capture.nargs == 1);
REQUIRE(g_capture.args[0].kind == ColdArg::Kind::Int);
REQUIRE(g_capture.args[0].integer == 3);
h.eval_ok("(meml/cmd 2.5)");
REQUIRE(g_capture.args[0].kind == ColdArg::Kind::Number);
REQUIRE(g_capture.args[0].number == Approx(2.5f));
}
SECTION("symbol and vector arguments parse")
{
h.eval_ok("(meml/cmd level [1 2 3])");
REQUIRE(g_capture.nargs == 2);
REQUIRE(g_capture.args[0].kind == ColdArg::Kind::Symbol);
REQUIRE(g_capture.args[1].kind == ColdArg::Kind::Vector);
REQUIRE(g_capture.args[1].vec_len == 3);
REQUIRE(g_capture.args[1].vec[0] == Approx(1.0f));
REQUIRE(g_capture.args[1].vec[1] == Approx(2.0f));
REQUIRE(g_capture.args[1].vec[2] == Approx(3.0f));
}
SECTION("arity mismatch is an error and never calls the handler")
{
h.expect_error("(meml/cmd)", DiagnosticCategory::Arity);
h.expect_error("(meml/cmd 1 2 3)", DiagnosticCategory::Arity);
REQUIRE(g_capture.calls == 0);
}
SECTION("handler rejection is diagnosed")
{
g_capture.accept = false;
h.expect_error("(meml/cmd 1)", DiagnosticCategory::Runtime);
REQUIRE(g_capture.calls == 1);
}
SECTION("handler runs only after earlier edits compiled")
{
h.eval_ok("(a1 0.25)(meml/cmd 1)");
REQUIRE(g_capture.calls == 1);
REQUIRE(h.tick("a1", 0.0) == Approx(0.25));
// The failing edit stops the sequence before the command form.
g_capture.calls = 0;
h.expect_error("(a1 nosuch/input)(meml/cmd 1)",
DiagnosticCategory::UndefinedName);
REQUIRE(g_capture.calls == 0);
}
SECTION("unregistered heads keep the unknown-form behaviour")
{
EvalResult r = h.eval("(meml/nope 1)");
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(g_capture.calls == 0);
}
}
TEST_CASE("registry reset clears profile state", "[ext_registry]")
{
ExtHarness h;
REQUIRE(register_external_input({"ctl/x", 2, 1, 0.0f, "[0,1]"}));
REQUIRE(register_external_sink({"transport/mono", 1, 0.0f, 1.0f, 50, 7}));
reset_registry();
// Names no longer resolve as inputs or sinks.
h.expect_error("(a1 ctl/x)", DiagnosticCategory::UndefinedName);
EvalResult r = h.eval("(transport/mono 0.5)");
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(h.engine.sink_binding_count == 0);
REQUIRE(cold_command_handler() == nullptr);
}