diff --git a/MAP.md b/MAP.md index 6c30766..8ec3bfa 100644 --- a/MAP.md +++ b/MAP.md @@ -8,14 +8,19 @@ modulisp/ │ ├── pch.h # precompiled header (std includes) │ ├── modulisp/ │ │ └── lisp/symbol_intern.h # header-only symbol interning table -│ ├── signal_engine/ # 11 .cpp + 12 .h + symbols.def +│ ├── signal_engine/ # 12 .cpp + 13 .h + symbols.def │ │ ├── token.{h,cpp} # tokenizer │ │ ├── cell_store.{h,cpp} # S-expression cell arena │ │ ├── node_pool.{h,cpp} # fixed-pool DAG node storage + traversal │ │ ├── graph_builder.{h,cpp} # cell graph → node DAG compiler +│ │ │ # (resolve_hardware_input consults ext_registry first) │ │ ├── compiler_pipeline.{h,cpp} # source → tokens → cells → DAG orchestration │ │ ├── cold_eval.{h,cpp} + eval_ops.h # constant folding / cold evaluation +│ │ │ # (top-level sink forms + cold commands dispatch here) │ │ ├── executor.{h,cpp} # tick-time DAG executor +│ │ │ # (+ publish_sink_values post-tick publication) +│ │ ├── ext_registry.{h,cpp} # generic named external-input/sink/command registries +│ │ │ # (firmware profile seam; no nn/* or midi/* builtins) │ │ ├── state_registry.{h,cpp} # defstate slots, live-edit slots │ │ ├── synth_registry.{h,cpp} # synth graph snapshot registry │ │ ├── synth_graph.{h,cpp} # serialisable synth artefact graph @@ -29,9 +34,20 @@ modulisp/ │ ├── devtools/ # devtools.{h,cpp} — USEQ_DEVTOOLS-gated telemetry │ └── ports/ # IStorage.h, II2CTransport.h, mocks/{MockStorage.h,MockI2CBus.h} └── test/ - ├── meson.build # 17 Catch2 executables, fresh file + ├── meson.build # 18 Catch2 executables, fresh file ├── catch.hpp # vendored Catch2 v2 (from src-useq/test/catch.hpp) - └── signal_engine/ # 17 test .cpp (host-only subset; exclusions in README.md) + └── signal_engine/ # 18 test .cpp (host-only subset; exclusions in README.md) ``` -Build artifacts: `build/` (meson/ninja). 3 libs + 17 test executables. +External-register seam (NISPS-USEQ spec §3, generic mechanism only): +`ext_registry.{h,cpp}` holds firmware-profile registrations for named +external inputs, external sinks, and cold commands. `graph_builder` resolves +registered input names before the builtin board inputs; `cold_eval` binds +registered sinks with ordinary top-level assignment (channels publish as pool +output slots ≥ SINK_SLOT_BASE, so per-sink LKG rides the existing +GraphMutationTransaction machinery) and dispatches registered commands to the +installed handler after the submission's earlier forms compiled. +`executor::publish_sink_values` writes `SignalEngine::sink_values` / +`sink_dirty` per tick for the firmware to poll. + +Build artifacts: `build/` (meson/ninja). 3 libs + 18 test executables. diff --git a/README.md b/README.md index 1415ebc..039ac0b 100644 --- a/README.md +++ b/README.md @@ -1 +1,27 @@ -# ModuLisp \ No newline at end of file +# ModuLisp + +Standalone build of the portable uSEQ signal engine. Meson + ninja: + +``` +meson setup build && ninja -C build && meson test -C build +``` + +## Module map + +- `src/modulisp/lisp/` — header-only symbol interning (`symbol_intern.h`). +- `src/signal_engine/` — the language core: + - `token` → `cell_store` → `graph_builder` → `node_pool`: source text to + fixed-pool signal DAG (deterministic compile, CSE, bounded diagnostics). + - `compiler_pipeline`: parse + graph-mutation transactions (LKG rollback). + - `cold_eval`: top-level evaluation — definitions, output assignment, + external sink assignment, cold commands, transport control. + - `executor`: tick-time DAG execution + `publish_sink_values`. + - `ext_registry`: generic named external-input / external-sink / + cold-command registries — the firmware-profile seam (see MAP.md). + - `state_registry`, `synth_registry`, `synth_graph`, `diagnostics`. +- `src/utils/`, `src/devtools/`, `src/ports/` — support libraries, gated + telemetry, and hardware port interfaces with host mocks. +- `test/` — Catch2 executables over the host subset of the engine. + +External registers are generic: firmware registers names like `nn/*` or +`midi/*` at boot; the engine ships no such builtins. diff --git a/meson.build b/meson.build index 3721d6f..b8b9a4c 100644 --- a/meson.build +++ b/meson.build @@ -86,6 +86,7 @@ signal_engine_lib = static_library('useq_signal_engine', 'src/signal_engine/node_pool.cpp', 'src/signal_engine/executor.cpp', 'src/signal_engine/graph_builder.cpp', + 'src/signal_engine/ext_registry.cpp', 'src/signal_engine/compiler_pipeline.cpp', 'src/signal_engine/cold_eval.cpp', 'src/signal_engine/state_registry.cpp', diff --git a/src/signal_engine/cold_eval.cpp b/src/signal_engine/cold_eval.cpp index c733840..9110999 100644 --- a/src/signal_engine/cold_eval.cpp +++ b/src/signal_engine/cold_eval.cpp @@ -50,6 +50,13 @@ void SignalEngine::reset_session_storage(Sample bpm, int beats_per_bar, state_sources[i] = StateUpdateSource{}; state_compile_diagnostics[i] = ActiveCompileDiagnostic{}; } + // Sink bindings are live-code-owned session state (the registry itself is + // firmware-profile state and survives a session clear). + for (uint16_t i = 0; i < MAX_SINK_BINDINGS; i++) + sink_bindings[i] = SinkBinding{}; + sink_binding_count = 0; + memset(sink_values, 0, sizeof(sink_values)); + memset(sink_dirty, 0, sizeof(sink_dirty)); registry.clear(); #if USEQ_HAS_SYNTH_ENGINE if (publish_session_clear) { @@ -2184,18 +2191,35 @@ static EvalResult do_reset_clock_int(TokenStream& ts, EngineState& state) { // ── Output assignment ─────────────────────────────────────────────────────── +static void unassign_sink(SignalEngine& engine, SymbolID sink_sym); + static EvalResult do_unassign(TokenStream& ts, SignalEngine& engine) { Token output_tok = ts.consume(); - if (output_tok.kind != TokenKind::Symbol || - !GraphBuilder::is_output_symbol(output_tok.symbol)) { - return make_error("unassign needs one output name", + if (output_tok.kind != TokenKind::Symbol) { + return make_error("unassign needs one output or sink name", + "Try: (unassign a1)"); + } + const bool is_sink = external_sink_registered(output_tok.symbol); + if (!is_sink && !GraphBuilder::is_output_symbol(output_tok.symbol)) { + return make_error("unassign needs one output or sink name", "Try: (unassign a1)"); } if (ts.peek().kind != TokenKind::RParen) { - return make_error("unassign accepts exactly one output name", + return make_error("unassign accepts exactly one name", "Try: (unassign a1)"); } + // External sinks leave their binding; the next tick publishes nothing + // for them and the firmware falls back to its profile-neutral value. + if (is_sink) { + unassign_sink(engine, output_tok.symbol); + reclaim_unowned_resources(engine); + engine.pool.rebuild_execution_order(); + classify_outputs(engine.pool); + return make_ok(); + } + + const uint16_t output_index = GraphBuilder::resolve_output_index(output_tok.symbol); @@ -2310,6 +2334,248 @@ static EvalResult do_output_assign(SymbolID output_sym, TokenStream& ts, return make_ok(); } +// ── External sink assignment (ext_registry.h, spec §3.3/§3.5/§7.4) ────────── + +static EvalResult make_cold_arity_error(const char* message, + const char* suggestion) { + EvalResult r; + r.kind = EvalResult::Error; + r.diagnostics[r.diagnostic_count++] = { + DiagnosticSeverity::Error, DiagnosticCategory::Arity, + 0, 0, message, suggestion + }; + return r; +} + +// Compact per-channel candidate kept between graph construction and the +// single publication point (a full GraphBuildResult per channel would hold +// diagnostic storage that is only needed on the failing build). +struct SinkChannelCandidate { + uint16_t root_node = NODE_NONE; + CellIndex dep_cells[MAX_OUTPUT_DEPS] = {}; + uint8_t dep_count = 0; +}; + +// Tear down one pool output slot the way (unassign a1) does, retiring the +// state/live resources its context owned. Used for sink channel slots. +static void release_output_slot(SignalEngine& engine, uint16_t slot) { + engine.pool.outputs[slot] = OutputSlot{}; + engine.pool.prev_output_values[slot] = 0.0; + engine.pool.output_deps[slot].clear(); + engine.pool.runtime_fallback_mask &= ~((uint64_t)1 << slot); + engine.output_compile_diagnostics[slot] = ActiveCompileDiagnostic{}; + engine.registry.begin_context(slot); + engine.registry.commit_context(slot, + engine.pool.state_update_roots, + engine.pool.state_owner_context); +} + +static void unassign_sink(SignalEngine& engine, SymbolID sink_sym) { + uint8_t index = engine.sink_binding_count; + for (uint8_t i = 0; i < engine.sink_binding_count; i++) { + if (engine.sink_bindings[i].sink == sink_sym) { + index = i; + break; + } + } + if (index == engine.sink_binding_count) return; // not bound — idempotent + + const SinkBinding binding = engine.sink_bindings[index]; + for (uint8_t ch = 0; ch < binding.arity; ch++) + release_output_slot(engine, binding.value_index[ch]); + + // Swap-remove so rows stay dense; the value/dirty rows follow their + // binding. Consumers key off the sink symbol, not the row index. + const uint8_t last = engine.sink_binding_count - 1; + engine.sink_bindings[index] = engine.sink_bindings[last]; + memcpy(engine.sink_values[index], engine.sink_values[last], + sizeof(engine.sink_values[index])); + engine.sink_dirty[index] = engine.sink_dirty[last]; + engine.sink_bindings[last] = SinkBinding{}; + memset(engine.sink_values[last], 0, sizeof(engine.sink_values[last])); + engine.sink_dirty[last] = false; + engine.sink_binding_count--; +} + +static EvalResult do_sink_assign(SymbolID sink_sym, TokenStream& ts, + SignalEngine& engine, const char* source, + SharedLiveEditIDs* shared_ids = nullptr) { + const ExternalSinkDesc* desc = find_external_sink(sink_sym); + + // Existing binding for this sink is the per-sink LKG baseline: nothing + // below touches it unless every channel compiles. + uint8_t binding_index = engine.sink_binding_count; // append position + bool rebinding = false; + SinkBinding previous = {}; + for (uint8_t i = 0; i < engine.sink_binding_count; i++) { + if (engine.sink_bindings[i].sink == sink_sym) { + binding_index = i; + rebinding = true; + previous = engine.sink_bindings[i]; + break; + } + } + if (!rebinding && engine.sink_binding_count >= MAX_SINK_BINDINGS) { + return make_error("Too many sink assignments — binding slots are full", + "Unassign a sink first with (unassign name)"); + } + + // Channel slot plan: reuse this sink's previous slots in order (stable + // indices keep anonymous state identity alive across reassignment, like + // a named output keeps its index), then take free pool output slots + // above the named a/d/s range. + uint16_t slots[MAX_SINK_ARITY] = {}; + uint8_t planned = 0; + for (uint16_t s = SINK_SLOT_BASE; + s < MAX_OUTPUTS && planned < desc->arity; s++) { + if (engine.pool.outputs[s].root_node != NODE_NONE) continue; + bool taken = false; + for (uint8_t p = 0; p < planned; p++) { + if (slots[p] == s) { + taken = true; + break; + } + } + if (!taken) slots[planned++] = s; + } + if (planned < desc->arity) { + return make_error( + "Sink channel slots are exhausted", + "Free sink channels with (unassign name) or use fewer sinks"); + } + + // One transaction across every channel: a failure in any channel rolls + // the whole form back to the previous published binding. + GraphMutationTransaction graph_transaction(engine); + SinkChannelCandidate candidates[MAX_SINK_ARITY]; + for (uint8_t ch = 0; ch < desc->arity; ch++) { + if (ts.peek().kind == TokenKind::RParen || ts.at_end()) { + return make_cold_arity_error( + "Wrong number of sink arguments", + "The sink descriptor fixes the argument count"); + } + engine.registry.begin_context(slots[ch]); + GraphBuildResult result = build_output_graph( + engine.pool, ts, engine.cells, engine.arena, source, + &engine.registry, shared_ids, slots[ch]); + if (result.has_error) { + EvalResult r; + r.kind = EvalResult::Error; + memcpy(r.diagnostics, result.diagnostics, + result.diagnostic_count * sizeof(Diagnostic)); + r.diagnostic_count = result.diagnostic_count; + return r; + } + candidates[ch].root_node = result.root_node; + candidates[ch].dep_count = result.dep_count; + memcpy(candidates[ch].dep_cells, result.dep_cells, + result.dep_count * sizeof(CellIndex)); + } + if (ts.peek().kind != TokenKind::RParen) { + return make_cold_arity_error( + "Wrong number of sink arguments", + "The sink descriptor fixes the argument count"); + } + + // Publication: retire previous slots the smaller new binding no longer + // uses, then install every channel root as one coherent state change. + if (rebinding) { + for (uint8_t ch = desc->arity; ch < previous.arity; ch++) + release_output_slot(engine, previous.value_index[ch]); + } + + SinkBinding binding; + binding.sink = sink_sym; + binding.arity = desc->arity; + for (uint8_t ch = 0; ch < desc->arity; ch++) { + GraphBuildResult plan; + plan.root_node = candidates[ch].root_node; + plan.dep_count = candidates[ch].dep_count; + memcpy(plan.dep_cells, candidates[ch].dep_cells, + plan.dep_count * sizeof(CellIndex)); + publish_output_graph_plan(engine, slots[ch], plan); + binding.value_index[ch] = slots[ch]; + } + engine.sink_bindings[binding_index] = binding; + if (!rebinding) engine.sink_binding_count++; + + graph_transaction.accept(); + reclaim_unowned_resources(engine); + engine.pool.rebuild_execution_order(); + classify_outputs(engine.pool); + + return make_ok(); +} + +// ── Registered cold commands (ext_registry.h, spec §2.3/§6.1) ─────────────── + +static EvalResult do_cold_command(const ExternalCommandDesc* desc, + TokenStream& ts) { + ColdCommandHandler handler = cold_command_handler(); + if (handler == nullptr) { + return make_error("No command handler is installed", + "The firmware profile must install one with " + "set_cold_command_handler()"); + } + + ColdArg args[MAX_COLD_ARGS]; + uint8_t nargs = 0; + while (ts.peek().kind != TokenKind::RParen && !ts.at_end()) { + if (nargs >= MAX_COLD_ARGS) { + return make_cold_arity_error( + "Too many command arguments", + "A command accepts at most 8 arguments"); + } + const Token tok = ts.peek(); + ColdArg& arg = args[nargs]; + if (tok.kind == TokenKind::Number) { + ts.consume(); + const Sample value = tok.number; + if (value == std::floor(value) && value >= INT32_MIN && + value <= INT32_MAX) { + arg.kind = ColdArg::Kind::Int; + arg.integer = (int32_t)value; + } else { + arg.kind = ColdArg::Kind::Number; + arg.number = (float)value; + } + } else if (tok.kind == TokenKind::Symbol) { + ts.consume(); + arg.kind = ColdArg::Kind::Symbol; + arg.symbol = tok.symbol; + } else if (tok.kind == TokenKind::LBracket) { + Sample values[64]; + uint16_t count = 0; + EvalResult error; + if (!parse_numeric_vector(ts, values, count, error)) return error; + if (count > ColdArg::MAX_VEC) { + return make_cold_arity_error( + "Vector command arguments support at most 8 values", + "Shorten the vector"); + } + arg.kind = ColdArg::Kind::Vector; + for (uint16_t v = 0; v < count; v++) + arg.vec[v] = (float)values[v]; + } else { + ts.consume(); + return make_error("Unsupported command argument", + "Use a number, a name, or a numeric vector"); + } + nargs++; + } + + if (nargs < desc->arity_min || nargs > desc->arity_max) { + return make_cold_arity_error("Wrong number of command arguments", + "Check the command's argument count"); + } + + if (!handler(desc->cmd, args, nargs, cold_command_user())) { + return make_error("The command handler rejected this call", + "Check the command's arguments"); + } + return make_ok(); +} + // ── Scratch-isolated expression evaluation ───────────────────────────────── EvalResult eval_expression(const char* source, uint32_t length, @@ -2944,6 +3210,28 @@ static EvalResult eval_form(TokenStream& ts, SignalEngine& engine, return r; } + // External sink assignment (spec §3.3/§7.4): a registered sink name + // binds its channel expressions with an ordinary top-level form. + if (external_sink_registered(op)) { + EvalResult r = do_sink_assign(op, ts, engine, source, shared_ids); + ts.expect(TokenKind::RParen); + return r; + } + + // Registered cold command (spec §2.3/§6.1 generic hook): dispatch + // parsed constant arguments to the firmware handler. eval_cold runs + // forms as a sequence of per-form transactions that stops at the + // first error, so the handler only ever observes a call after every + // earlier edit in the same submission compiled and published. Heads + // that are neither outputs, sinks, nor commands keep the unknown + // expression fallback below. + const ExternalCommandDesc* command = find_external_command(op); + if (command != nullptr) { + EvalResult r = do_cold_command(command, ts); + ts.expect(TokenKind::RParen); + return r; + } + // do / scope — evaluate children sequentially if (op == sym.do_ || op == sym.scope) { EvalResult last = make_ok(); diff --git a/src/signal_engine/cold_eval.h b/src/signal_engine/cold_eval.h index 69173c3..5caabdd 100644 --- a/src/signal_engine/cold_eval.h +++ b/src/signal_engine/cold_eval.h @@ -4,6 +4,7 @@ #include "types.h" #include "cell_store.h" #include "node_pool.h" +#include "ext_registry.h" #include "state_registry.h" #if USEQ_HAS_SYNTH_ENGINE #include "synth_graph.h" @@ -146,6 +147,17 @@ struct SignalEngine { ActiveCompileDiagnostic state_compile_diagnostics[MAX_STATE_SLOTS] = {}; StateResourceRegistry registry; + + // ── External sink bindings (ext_registry.h, spec §3.3/§3.5/§7.4) ───── + // One entry per assigned external sink. Each channel's graph occupies a + // real pool output slot (SinkBinding::value_index) so execution, LKG + // fallback and GC reuse the ordinary output machinery. sink_values holds + // the last published per-channel values; sink_dirty latches when a value + // moves beyond one quantisation step and is cleared by the consumer. + SinkBinding sink_bindings[MAX_SINK_BINDINGS] = {}; + uint8_t sink_binding_count = 0; + Sample sink_values[MAX_SINK_BINDINGS][MAX_SINK_ARITY] = {}; + bool sink_dirty[MAX_SINK_BINDINGS] = {}; NodePool scratch_pool; char eval_text_buf[512] = {}; diff --git a/src/signal_engine/executor.cpp b/src/signal_engine/executor.cpp index f1c3ec4..2045dbe 100644 --- a/src/signal_engine/executor.cpp +++ b/src/signal_engine/executor.cpp @@ -1,4 +1,5 @@ #include "executor.h" +#include "cold_eval.h" #include "eval_ops.h" #include #include @@ -201,6 +202,36 @@ void execute_all_outputs(const NodePool& pool, ExecutionContext& ctx) { pool.runtime_fallback_mask = fallback_mask; } +// ── External Sink Publication ─────────────────────────────────────────────── + +void publish_sink_values(SignalEngine& engine, const Sample* output_values) { + for (uint8_t b = 0; b < engine.sink_binding_count; b++) { + const SinkBinding& binding = engine.sink_bindings[b]; + const ExternalSinkDesc* desc = find_external_sink(binding.sink); + + // One quantisation step of the descriptor's range; a missing + // descriptor (profile re-registered without the sink) degrades to + // any-change dirtying rather than silencing the sink. + Sample step = 0.0; + if (desc != nullptr && desc->quant_bits > 0 && + (Sample)desc->max > (Sample)desc->min) { + const Sample levels = std::ldexp(1.0, (int)desc->quant_bits); + step = ((Sample)desc->max - (Sample)desc->min) / levels; + } + + bool dirty = false; + for (uint8_t ch = 0; ch < binding.arity; ch++) { + const Sample value = output_values[binding.value_index[ch]]; + const Sample previous = engine.sink_values[b][ch]; + Sample delta = value - previous; + if (delta < 0.0) delta = -delta; + if (step > 0.0 ? delta > step : value != previous) dirty = true; + engine.sink_values[b][ch] = value; + } + if (dirty) engine.sink_dirty[b] = true; + } +} + // ── Post-Tick Commit ─────────────────────────────────────────────────────── void commit_outputs(NodePool& pool, const Sample* output_values) { diff --git a/src/signal_engine/executor.h b/src/signal_engine/executor.h index ee51c79..163ad89 100644 --- a/src/signal_engine/executor.h +++ b/src/signal_engine/executor.h @@ -105,6 +105,17 @@ void execute_batch( // bitmask. Call after compilation or graph changes. Writes directly into // pool.output_class[] and pool.output_input_mask[]. +struct SignalEngine; + +// ── External Sink Publication ─────────────────────────────────────────────── +// After execute_all_outputs (and commit), copy each bound sink channel's +// evaluated value from the output buffer into SignalEngine::sink_values and +// latch sink_dirty when any channel moved beyond one quantisation step of +// its descriptor (quant_bits; unquantised sinks dirty on any change). The +// firmware polls and clears the flags; rate limiting stays firmware-side. + +void publish_sink_values(SignalEngine& engine, const Sample* output_values); + void classify_outputs(NodePool& pool); } // namespace sig diff --git a/src/signal_engine/ext_registry.cpp b/src/signal_engine/ext_registry.cpp new file mode 100644 index 0000000..f487647 --- /dev/null +++ b/src/signal_engine/ext_registry.cpp @@ -0,0 +1,146 @@ +#include "ext_registry.h" + +#include "../modulisp/lisp/symbol_intern.h" + +#include + +namespace sig { + +namespace { + +struct StoredInput { + ExternalInputDesc desc = {}; + SymbolID name_id = SymbolIntern::INVALID_ID; +}; + +struct StoredSink { + ExternalSinkDesc desc = {}; + SymbolID name_id = SymbolIntern::INVALID_ID; +}; + +StoredInput g_inputs[MAX_EXTERNAL_INPUTS] = {}; +StoredSink g_sinks[MAX_EXTERNAL_SINKS] = {}; +ExternalCommandDesc g_commands[MAX_EXTERNAL_COMMANDS] = {}; +uint8_t g_input_count = 0; +uint8_t g_sink_count = 0; +uint8_t g_command_count = 0; + +ColdCommandHandler g_cold_handler = nullptr; +void* g_cold_user = nullptr; + +bool valid_name(const char* name) { + return name != nullptr && name[0] != '\0'; +} + +} // namespace + +bool register_external_input(const ExternalInputDesc& desc) { + if (!valid_name(desc.name)) return false; + if (desc.channels == 0) return false; + if (desc.hw_index >= MAX_HW_INPUT_CHANNELS || + (uint32_t)desc.hw_index + desc.channels > MAX_HW_INPUT_CHANNELS) { + return false; + } + + const SymbolID name_id = internSymbol(String(desc.name)); + for (uint8_t i = 0; i < g_input_count; i++) { + if (g_inputs[i].name_id == name_id) { + g_inputs[i].desc = desc; + return true; + } + } + if (g_input_count >= MAX_EXTERNAL_INPUTS) return false; + g_inputs[g_input_count].desc = desc; + g_inputs[g_input_count].name_id = name_id; + g_input_count++; + return true; +} + +bool register_external_sink(const ExternalSinkDesc& desc) { + if (!valid_name(desc.name)) return false; + if (desc.arity == 0 || desc.arity > MAX_SINK_ARITY) return false; + if (desc.min > desc.max) return false; + + const SymbolID name_id = internSymbol(String(desc.name)); + for (uint8_t i = 0; i < g_sink_count; i++) { + if (g_sinks[i].name_id == name_id) { + g_sinks[i].desc = desc; + return true; + } + } + if (g_sink_count >= MAX_EXTERNAL_SINKS) return false; + g_sinks[g_sink_count].desc = desc; + g_sinks[g_sink_count].name_id = name_id; + g_sink_count++; + return true; +} + +bool register_external_command(SymbolID cmd, uint8_t arity_min, + uint8_t arity_max) { + if (cmd == SymbolIntern::INVALID_ID) return false; + if (arity_min > arity_max || arity_max > MAX_COLD_ARGS) return false; + + for (uint8_t i = 0; i < g_command_count; i++) { + if (g_commands[i].cmd == cmd) { + g_commands[i].arity_min = arity_min; + g_commands[i].arity_max = arity_max; + return true; + } + } + if (g_command_count >= MAX_EXTERNAL_COMMANDS) return false; + g_commands[g_command_count] = ExternalCommandDesc{cmd, arity_min, + arity_max}; + g_command_count++; + return true; +} + +void reset_registry() { + memset(g_inputs, 0, sizeof(g_inputs)); + memset(g_sinks, 0, sizeof(g_sinks)); + memset(g_commands, 0, sizeof(g_commands)); + g_input_count = 0; + g_sink_count = 0; + g_command_count = 0; + g_cold_handler = nullptr; + g_cold_user = nullptr; +} + +const ExternalInputDesc* find_external_input(SymbolID name) { + for (uint8_t i = 0; i < g_input_count; i++) { + if (g_inputs[i].name_id == name) return &g_inputs[i].desc; + } + return nullptr; +} + +uint16_t external_input_index_for(SymbolID name) { + const ExternalInputDesc* desc = find_external_input(name); + return desc ? desc->hw_index : NODE_NONE; +} + +const ExternalSinkDesc* find_external_sink(SymbolID name) { + for (uint8_t i = 0; i < g_sink_count; i++) { + if (g_sinks[i].name_id == name) return &g_sinks[i].desc; + } + return nullptr; +} + +bool external_sink_registered(SymbolID name) { + return find_external_sink(name) != nullptr; +} + +const ExternalCommandDesc* find_external_command(SymbolID cmd) { + for (uint8_t i = 0; i < g_command_count; i++) { + if (g_commands[i].cmd == cmd) return &g_commands[i]; + } + return nullptr; +} + +void set_cold_command_handler(ColdCommandHandler handler, void* user) { + g_cold_handler = handler; + g_cold_user = user; +} + +ColdCommandHandler cold_command_handler() { return g_cold_handler; } +void* cold_command_user() { return g_cold_user; } + +} // namespace sig diff --git a/src/signal_engine/ext_registry.h b/src/signal_engine/ext_registry.h new file mode 100644 index 0000000..0fadfb1 --- /dev/null +++ b/src/signal_engine/ext_registry.h @@ -0,0 +1,137 @@ +#ifndef SIGNAL_ENGINE_EXT_REGISTRY_H +#define SIGNAL_ENGINE_EXT_REGISTRY_H + +#include "types.h" + +namespace sig { + +// ── Generic external registers (NISPS-USEQ spec §3) ───────────────────────── +// +// A firmware profile owns the names and metadata of everything that surrounds +// the engine: named external inputs (producers stage latest-value state that +// signal graphs read as leaves), named external sinks (live code binds +// evaluated signals to transports), and discrete cold commands. The engine +// provides only the generic registration and dispatch seam — no nn/* or +// midi/* name is compiled in; those arrive via registration at firmware boot. +// +// Registration is profile state, not livecoding session state: it survives +// (useq-clear) and is deliberately NOT touched by SignalEngine session resets. +// reset_registry() exists for tests and explicit profile re-initialisation. + +constexpr uint8_t MAX_EXTERNAL_INPUTS = 24; +constexpr uint8_t MAX_EXTERNAL_SINKS = 16; +constexpr uint8_t MAX_EXTERNAL_COMMANDS = 16; + +// Width of the executor-side hw_inputs[] snapshot array (see +// ExecutionContext::hw_inputs). Registered descriptors must address channels +// inside this span. +constexpr uint16_t MAX_HW_INPUT_CHANNELS = 32; + +// ── External input registers (spec §3.1/§3.2) ─────────────────────────────── + +struct ExternalInputDesc { + const char* name; // static-lifetime spelling, e.g. "nn/out1" + uint16_t hw_index; // first hw_inputs[] channel behind the name + uint8_t channels; // consecutive channels the register spans + float neutral; // profile-neutral value before first update + const char* units_or_range; // static metadata for UI/diagnostics +}; + +// ── External sinks (spec §3.3/§3.5/§7.4) ──────────────────────────────────── + +struct ExternalSinkDesc { + const char* name; // static-lifetime spelling, e.g. "midi/cc74" + uint8_t arity; // exact argument count of the sink form + float min; // accepted range (deadband scaling + metadata) + float max; + uint32_t max_rate_hz; // profile transport ceiling; enforced firmware-side + uint16_t quant_bits; // 0 = unquantised; else step = (max-min)/2^bits +}; + +struct ExternalCommandDesc { + SymbolID cmd; + uint8_t arity_min; + uint8_t arity_max; +}; + +// ── Registration / lookup ─────────────────────────────────────────────────── +// Re-registering a name replaces its descriptor in place (idempotent profile +// init). Return false on a full registry or an invalid descriptor. Lookup is +// by SymbolID-interned name: registration interns desc->name, and the +// tokenizer interns the same spelling to the same ID. + +bool register_external_input(const ExternalInputDesc& desc); +bool register_external_sink(const ExternalSinkDesc& desc); +bool register_external_command(SymbolID cmd, uint8_t arity_min, + uint8_t arity_max); + +// Clears inputs, sinks, commands, and the cold-command handler. +void reset_registry(); + +const ExternalInputDesc* find_external_input(SymbolID name); +// Hardware-input channel for a registered name, or NODE_NONE. This is the +// compiler-facing lookup used by GraphBuilder::resolve_hardware_input. +uint16_t external_input_index_for(SymbolID name); + +const ExternalSinkDesc* find_external_sink(SymbolID name); +bool external_sink_registered(SymbolID name); +const ExternalCommandDesc* find_external_command(SymbolID cmd); + +// ── Sink bindings ─────────────────────────────────────────────────────────── +// One entry per assigned external sink, owned by SignalEngine. Each channel's +// compiled graph is published as a real pool output slot (indices +// SINK_SLOT_BASE..MAX_OUTPUTS-1) so execution order, LKG fallback, and GC +// reachability are the ordinary output machinery — no second mechanism. + +constexpr uint8_t MAX_SINK_ARITY = 8; +constexpr uint8_t MAX_SINK_BINDINGS = 16; + +// Pool output slots 0..23 are the named a/d/s outputs; sink channel graphs +// occupy the remainder. +constexpr uint16_t SINK_SLOT_BASE = 24; +static_assert(SINK_SLOT_BASE < MAX_OUTPUTS, + "sink channel slots must fit inside the pool output table"); + +struct SinkBinding { + SymbolID sink = SymbolIntern::INVALID_ID; + uint16_t value_index[MAX_SINK_ARITY] = {}; // pool output slot per channel + uint8_t arity = 0; +}; + +// ── Cold command handler (spec §2.3/§6.1 — generic hook) ──────────────────── +// A top-level form whose head is a registered command symbol parses its +// constant arguments into ColdArg values and calls the installed handler +// after every earlier form in the same eval compiled successfully. The +// handler must not re-enter the evaluator. + +constexpr uint8_t MAX_COLD_ARGS = 8; + +struct ColdArg { + enum class Kind : uint8_t { Int, Number, Symbol, Vector }; + static constexpr uint8_t MAX_VEC = MAX_SINK_ARITY; + + Kind kind = Kind::Int; + + // Number tokens without a fractional part that fit in int32 parse as + // Int; everything numeric else is Number. Names parse as their interned + // SymbolID; bracketed numeric literals parse as Vector. + union { + float number; + int32_t integer; + SymbolID symbol; + float vec[MAX_VEC]; + }; + + ColdArg() : integer(0) {} +}; + +using ColdCommandHandler = bool (*)(SymbolID cmd, const ColdArg* args, + uint8_t nargs, void* user); + +void set_cold_command_handler(ColdCommandHandler handler, void* user); +ColdCommandHandler cold_command_handler(); +void* cold_command_user(); + +} // namespace sig + +#endif // SIGNAL_ENGINE_EXT_REGISTRY_H diff --git a/src/signal_engine/graph_builder.cpp b/src/signal_engine/graph_builder.cpp index 162b7bf..9647b7b 100644 --- a/src/signal_engine/graph_builder.cpp +++ b/src/signal_engine/graph_builder.cpp @@ -1,4 +1,5 @@ #include "graph_builder.h" +#include "ext_registry.h" #include "compiler_pipeline.h" #include "../modulisp/lisp/symbol_intern.h" #include @@ -568,6 +569,12 @@ uint16_t GraphBuilder::resolve_output_index(SymbolID op) { } uint16_t GraphBuilder::resolve_hardware_input(SymbolID sym_id) { + // Registered external-input registers (spec §3.1) resolve before the + // built-in board names so a firmware profile owns the meaning of the + // hardware input space; unregistered names fall through unchanged. + const uint16_t external = external_input_index_for(sym_id); + if (external != NODE_NONE) return external; + const String& name = getSymbolString(sym_id); if (name == "in1") return 0; // INP_I1 if (name == "in2") return 1; // INP_I2 diff --git a/src/signal_engine/signal_engine.h b/src/signal_engine/signal_engine.h index f48771f..a933a1d 100644 --- a/src/signal_engine/signal_engine.h +++ b/src/signal_engine/signal_engine.h @@ -10,6 +10,7 @@ #include "node_pool.h" #include "graph_builder.h" #include "executor.h" +#include "ext_registry.h" #include "cold_eval.h" #endif // SIGNAL_ENGINE_H diff --git a/test/meson.build b/test/meson.build index 5c26530..4f84b05 100644 --- a/test/meson.build +++ b/test/meson.build @@ -42,6 +42,7 @@ test_env = [ [ 'signal_engine/test_health_diagnostics.cpp', 'test_health_diagnostics', 'health_diagnostics_test', 60 ], [ 'signal_engine/test_synth_compiler.cpp', 'test_synth_compiler', 'synth_compiler_test', 60 ], [ 'signal_engine/test_synth_wasm_abi.cpp', 'test_synth_wasm_abi', 'synth_wasm_abi_test', 60 ], + [ 'signal_engine/test_ext_registry.cpp', 'test_ext_registry', 'ext_registry_test', 60 ], ] foreach t : test_env diff --git a/test/signal_engine/test_ext_registry.cpp b/test/signal_engine/test_ext_registry.cpp new file mode 100644 index 0000000..454aa45 --- /dev/null +++ b/test/signal_engine/test_ext_registry.cpp @@ -0,0 +1,416 @@ +// 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({"midi/cc74", 1, 0.0f, 1.0f, 50, 7})); + h.hw_inputs[4] = 0.125; + h.eval_ok("(midi/cc74 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("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({"midi/cc74", 1, 0.0f, 1.0f, 50, 7})); + + SECTION("assignment evaluates and raises the dirty flag") + { + h.eval_ok("(midi/cc74 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("(midi/cc74 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("wrong arity is a compile error and binds nothing") + { + h.expect_error("(midi/cc74 0.1 0.2)", DiagnosticCategory::Arity); + h.expect_error("(midi/cc74)", DiagnosticCategory::Arity); + REQUIRE(h.engine.sink_binding_count == 0); + } + + SECTION("unassign releases the binding") + { + h.eval_ok("(midi/cc74 ctl/x)"); + REQUIRE(h.engine.sink_binding_count == 1); + h.eval_ok("(unassign midi/cc74)"); + REQUIRE(h.engine.sink_binding_count == 0); + REQUIRE(h.binding_for("midi/cc74") == nullptr); + + // Idempotent, and outputs keep working afterwards. + h.eval_ok("(unassign midi/cc74)"); + 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); +} + +// ── 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[0].symbol == internSymbol("level")); + REQUIRE(g_capture.args[1].kind == ColdArg::Kind::Vector); + 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({"midi/cc74", 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("(midi/cc74 0.5)"); + REQUIRE(r.kind == EvalResult::Error); + REQUIRE(h.engine.sink_binding_count == 0); + REQUIRE(cold_command_handler() == nullptr); +}