// 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 #include #include 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(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)); } 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); }