From e1a2e0115c4c18563fd3b054c5a2f1204135e628 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Tue, 18 Aug 2026 13:33:20 +0300 Subject: [PATCH] =?UTF-8?q?feat(signal-engine):=20vector-packed=20sink=20s?= =?UTF-8?q?ugar=20=E2=80=94=20:offset=20and=20[base=20mod]=20pair=20forms?= =?UTF-8?q?=20(spec=20=C2=A74.3/=C2=A74.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MAP.md | 26 +- README.md | 13 +- src/signal_engine/cold_eval.cpp | 210 +++++++- src/signal_engine/ext_registry.cpp | 8 + src/signal_engine/ext_registry.h | 15 +- src/signal_engine/symbols.def | 1 + test/meson.build | 1 + .../test_builtin_conformance.cpp | 1 + test/signal_engine/test_sink_vector_sugar.cpp | 478 ++++++++++++++++++ 9 files changed, 719 insertions(+), 34 deletions(-) create mode 100644 test/signal_engine/test_sink_vector_sugar.cpp diff --git a/MAP.md b/MAP.md index 8ec3bfa..82a2153 100644 --- a/MAP.md +++ b/MAP.md @@ -16,11 +16,15 @@ modulisp/ │ │ │ # (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) +│ │ │ # (top-level sink forms + cold commands dispatch here; +│ │ │ # vector-packed sink sugar — [base…] :offset [mod…] +│ │ │ # and [[base mod] …] pairs — expands in do_sink_assign) │ │ ├── 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) +│ │ │ # (firmware profile seam; no nn/* or midi/* builtins; +│ │ │ # ExternalSinkDesc.vec_base_ch/vec_mod_ch opt a sink +│ │ │ # into the §4.3/§4.4 vector sugar) │ │ ├── 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 @@ -34,9 +38,9 @@ modulisp/ │ ├── devtools/ # devtools.{h,cpp} — USEQ_DEVTOOLS-gated telemetry │ └── ports/ # IStorage.h, II2CTransport.h, mocks/{MockStorage.h,MockI2CBus.h} └── test/ - ├── meson.build # 18 Catch2 executables, fresh file + ├── meson.build # 19 Catch2 executables, fresh file ├── catch.hpp # vendored Catch2 v2 (from src-useq/test/catch.hpp) - └── signal_engine/ # 18 test .cpp (host-only subset; exclusions in README.md) + └── signal_engine/ # 19 test .cpp (host-only subset; exclusions in README.md) ``` External-register seam (NISPS-USEQ spec §3, generic mechanism only): @@ -50,4 +54,16 @@ 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. +Sink vector-packing sugar (spec §4.3/§4.4, generic mechanism): a descriptor +with `vec_base_ch`/`vec_mod_ch` set (both > 0, summing to `arity`) lets the +same sink form be written `[b…]` optionally followed by +`:offset [m…]`, or as `vec_base_ch` entries that are scalars (modulation 0, +compiled as a const-0 node) or `[base modulation]` pairs. `:offset` and pair +entries are mutually exclusive (§4.5); wrong counts, unknown keywords, and +non-vector `:offset` values are compile errors inside the per-sink +transaction, so a bad form keeps the sink's previous binding (LKG). The +three forms bind identical channel graphs — expansion happens in +`cold_eval::do_sink_assign` before per-channel compilation; plain sinks +(both fields zero) parse flat expressions only, byte-identical to before. + +Build artifacts: `build/` (meson/ninja). 3 libs + 19 test executables. diff --git a/README.md b/README.md index 039ac0b..57122bb 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,19 @@ meson setup build && ninja -C build && meson test -C build 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. + external sink assignment (including the descriptor-gated vector sugar: + `[base…] :offset [mod…]` and `[[base mod] …]` pairs expand to the same + per-channel graphs as the flat form, spec §4.3–§4.5), 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`. + cold-command registries — the firmware-profile seam (see MAP.md). A sink + descriptor sets `vec_base_ch`/`vec_mod_ch` to accept the vector sugar. - `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. +`midi/*` at boot; the engine ships no such builtins. A sink registered with +vector packing accepts its channels flat or sugar-packed — all forms bind +identically and publish through the same pool output slots. diff --git a/src/signal_engine/cold_eval.cpp b/src/signal_engine/cold_eval.cpp index c1e847d..4e4d851 100644 --- a/src/signal_engine/cold_eval.cpp +++ b/src/signal_engine/cold_eval.cpp @@ -2396,6 +2396,44 @@ static void unassign_sink(SignalEngine& engine, SymbolID sink_sym) { engine.sink_binding_count--; } +// Compile channel `ch` of a sink binding from the next expression in the +// stream. The flat form's step — and every sugar slot (base, :offset, and +// pair entries) — goes through here, so all forms share one compilation and +// diagnostic path. Returns Error carrying the build's diagnostics on failure. +static EvalResult compile_sink_channel(SignalEngine& engine, TokenStream& ts, + const char* source, + SharedLiveEditIDs* shared_ids, + const uint16_t* slots, uint8_t ch, + SinkChannelCandidate& candidate) { + 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; + } + candidate.root_node = result.root_node; + candidate.dep_count = result.dep_count; + memcpy(candidate.dep_cells, result.dep_cells, + result.dep_count * sizeof(CellIndex)); + return make_ok(); +} + +// A scalar channel entry's implicit modulation (spec §4.4): a const-0 node, +// exactly what writing the literal 0 compiles to. +static void compile_sink_zero_channel(SignalEngine& engine, + const uint16_t* slots, uint8_t ch, + SinkChannelCandidate& candidate) { + engine.registry.begin_context(slots[ch]); + candidate.root_node = engine.pool.make_const(0.0); + candidate.dep_count = 0; +} + static EvalResult do_sink_assign(SymbolID sink_sym, TokenStream& ts, SignalEngine& engine, const char* source, SharedLiveEditIDs* shared_ids = nullptr) { @@ -2444,36 +2482,160 @@ static EvalResult do_sink_assign(SymbolID sink_sym, TokenStream& ts, } // One transaction across every channel: a failure in any channel rolls - // the whole form back to the previous published binding. + // the whole form back to the previous published binding. Every accepted + // form compiles its channels here, in stream order, into the same + // per-channel candidates. 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()) { + + const uint8_t base_ch = desc->vec_base_ch; + const uint8_t mod_ch = desc->vec_mod_ch; + + if (base_ch != 0 && ts.peek().kind == TokenKind::LBracket) { + // Vector-packing sugar (NISPS-USEQ spec §4.3/§4.4). A leading '[' + // selects the sugar; the flat form of a vector-packed sink starts + // with an ordinary expression. Channels compile in stream order — + // base i, then its modulation (a synthesized const 0 for scalar + // entries), then the :offset vector's — so the slot plan, + // publication, and LKG behaviour are the flat form's. + bool saw_pair = false; + ts.consume(); // '[' + for (uint8_t i = 0; i < base_ch; i++) { + const Token tok = ts.peek(); + if (tok.kind == TokenKind::RBracket || + tok.kind == TokenKind::RParen || ts.at_end()) { + return make_synth_error_at( + tok, DiagnosticCategory::Arity, + "Too few entries in the sink channel vector", + "The sink descriptor fixes the channel count"); + } + if (tok.kind != TokenKind::LBracket) { + // Scalar entry: base = expression, modulation = 0. + EvalResult r = compile_sink_channel( + engine, ts, source, shared_ids, slots, i, candidates[i]); + if (r.kind == EvalResult::Error) return r; + compile_sink_zero_channel(engine, slots, base_ch + i, + candidates[base_ch + i]); + continue; + } + // [base modulation] pair entry (spec §4.4): exactly two entries. + saw_pair = true; + ts.consume(); // '[' + if (ts.peek().kind == TokenKind::RBracket) { + return make_synth_error_at( + ts.peek(), DiagnosticCategory::Arity, + "A channel entry vector needs exactly two expressions", + "Use [base modulation], or a scalar for modulation 0"); + } + EvalResult r = compile_sink_channel( + engine, ts, source, shared_ids, slots, i, candidates[i]); + if (r.kind == EvalResult::Error) return r; + if (ts.peek().kind == TokenKind::RBracket) { + return make_synth_error_at( + ts.peek(), DiagnosticCategory::Arity, + "A channel entry vector needs exactly two expressions", + "Use [base modulation], or a scalar for modulation 0"); + } + r = compile_sink_channel(engine, ts, source, shared_ids, slots, + base_ch + i, candidates[base_ch + i]); + if (r.kind == EvalResult::Error) return r; + if (ts.peek().kind != TokenKind::RBracket) { + return make_synth_error_at( + ts.peek(), DiagnosticCategory::Arity, + "A channel entry vector needs exactly two expressions", + "Use [base modulation], or a scalar for modulation 0"); + } + ts.consume(); // ']' + } + if (ts.peek().kind != TokenKind::RBracket) { + return make_synth_error_at( + ts.peek(), DiagnosticCategory::Arity, + "Too many entries in the sink channel vector", + "The sink descriptor fixes the channel count"); + } + ts.consume(); // ']' + + // Optional :offset vector (spec §4.3); omitted means all-zero mods. + if (ts.peek().kind == TokenKind::Symbol) { + const String& spelling = + SymbolIntern::getInstance().getString(ts.peek().symbol); + if (spelling.length() > 0 && spelling[0] == ':') { + if (saw_pair) { + return make_synth_error_at( + ts.peek(), DiagnosticCategory::Syntax, + ":offset cannot be combined with " + "[base modulation] entries", + "Use either :offset or paired entries, not both"); + } + if (ts.peek().symbol != GraphBuilder::sym.kw_offset) { + return make_synth_error_at( + ts.peek(), DiagnosticCategory::Syntax, + "Unknown keyword in this sink form", + "Only :offset is accepted in this position"); + } + ts.consume(); // ':offset' + if (ts.peek().kind != TokenKind::LBracket) { + return make_synth_error_at( + ts.peek(), DiagnosticCategory::Syntax, + ":offset needs a vector of modulation expressions", + "Try :offset [m0 m1 ...]"); + } + ts.consume(); // '[' + for (uint8_t j = 0; j < mod_ch; j++) { + const Token tok = ts.peek(); + if (tok.kind == TokenKind::RBracket || + tok.kind == TokenKind::RParen || ts.at_end()) { + return make_synth_error_at( + tok, DiagnosticCategory::Arity, + "Too few entries in the :offset vector", + "The sink descriptor fixes the channel count"); + } + if (tok.kind == TokenKind::LBracket) { + return make_synth_error_at( + tok, DiagnosticCategory::Syntax, + ":offset cannot be combined with " + "[base modulation] entries", + "Use either :offset or paired entries, not both"); + } + EvalResult r = compile_sink_channel( + engine, ts, source, shared_ids, slots, base_ch + j, + candidates[base_ch + j]); + if (r.kind == EvalResult::Error) return r; + } + if (ts.peek().kind != TokenKind::RBracket) { + return make_synth_error_at( + ts.peek(), DiagnosticCategory::Arity, + "Too many entries in the :offset vector", + "The sink descriptor fixes the channel count"); + } + ts.consume(); // ']' + } + } + if (ts.peek().kind != TokenKind::RParen) { + return make_synth_error_at( + ts.peek(), DiagnosticCategory::Arity, + "Unexpected value after the sink channel vector", + "The vector form takes no further arguments; modulations " + "go in :offset"); + } + } else { + // Flat form — plain sinks always; vector-packed sinks may use it + // unchanged. + 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"); + } + EvalResult r = compile_sink_channel( + engine, ts, source, shared_ids, slots, ch, candidates[ch]); + if (r.kind == EvalResult::Error) return r; + } + if (ts.peek().kind != TokenKind::RParen) { 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 diff --git a/src/signal_engine/ext_registry.cpp b/src/signal_engine/ext_registry.cpp index f487647..dedaee5 100644 --- a/src/signal_engine/ext_registry.cpp +++ b/src/signal_engine/ext_registry.cpp @@ -60,6 +60,14 @@ 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; + // Vector-packing: all-or-nothing, and the split must cover the arity + // (leading base channels + trailing modulation channels). + const bool packed = desc.vec_base_ch != 0 || desc.vec_mod_ch != 0; + if (packed && (desc.vec_base_ch == 0 || desc.vec_mod_ch == 0)) return false; + if (packed && + (uint32_t)desc.vec_base_ch + desc.vec_mod_ch != desc.arity) { + return false; + } const SymbolID name_id = internSymbol(String(desc.name)); for (uint8_t i = 0; i < g_sink_count; i++) { diff --git a/src/signal_engine/ext_registry.h b/src/signal_engine/ext_registry.h index f349159..3fb40f8 100644 --- a/src/signal_engine/ext_registry.h +++ b/src/signal_engine/ext_registry.h @@ -50,6 +50,18 @@ struct ExternalSinkDesc { 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 + + // Vector-packing sugar (NISPS-USEQ spec §4.3/§4.4). Both non-zero marks a + // sink whose `arity` flat expressions may instead be written as one + // [base × vec_base_ch] vector optionally followed by + // `:offset [mod × vec_mod_ch]`, or as vec_base_ch channel entries that + // are scalars (modulation 0) or [base modulation] pairs — cold_eval + // expands these to the same per-channel graphs before publication. + // Both zero = plain sink: exactly `arity` flat expressions, unchanged. + // Registration validates: either both zero, or both > 0 and summing to + // arity (vec_base_ch leading base channels, vec_mod_ch trailing mods). + uint8_t vec_base_ch = 0; + uint8_t vec_mod_ch = 0; }; struct ExternalCommandDesc { @@ -88,7 +100,8 @@ const ExternalCommandDesc* find_external_command(SymbolID cmd); // reachability are the ordinary output machinery — no second mechanism. // 16 channel expressions per binding: the nn/in neural-input sink binds -// 8 base + 8 modulation expressions in one form (NISPS-USEQ spec §4.2/§4.3). +// 8 base + 8 modulation expressions in one form (NISPS-USEQ spec §4.2/§4.3; +// with the §4.3/§4.4 sugar those arrive as vectors/pairs, still 16 graphs). constexpr uint8_t MAX_SINK_ARITY = 16; constexpr uint8_t MAX_SINK_BINDINGS = 16; diff --git a/src/signal_engine/symbols.def b/src/signal_engine/symbols.def index 7126230..7eb41bf 100644 --- a/src/signal_engine/symbols.def +++ b/src/signal_engine/symbols.def @@ -157,6 +157,7 @@ SYM(kw_phase, ":phase", none) SYM(kw_pw, ":pw", none) SYM(kw_id, ":id", none) SYM(kw_fresh, ":fresh", none) +SYM(kw_offset, ":offset", none) SYM(kw_attack, ":attack", none) SYM(kw_release, ":release", none) SYM(kw_reset, ":reset", none) diff --git a/test/meson.build b/test/meson.build index 4f84b05..c1a6536 100644 --- a/test/meson.build +++ b/test/meson.build @@ -43,6 +43,7 @@ test_env = [ [ '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 ], + [ 'signal_engine/test_sink_vector_sugar.cpp', 'test_sink_vector_sugar', 'sink_vector_sugar_test', 60 ], ] foreach t : test_env diff --git a/test/signal_engine/test_builtin_conformance.cpp b/test/signal_engine/test_builtin_conformance.cpp index 29a255d..115a0a4 100644 --- a/test/signal_engine/test_builtin_conformance.cpp +++ b/test/signal_engine/test_builtin_conformance.cpp @@ -194,6 +194,7 @@ static const ClassifiedOnly kClassifiedOnly[] = { {":min", "none", nullptr}, {":max", "none", nullptr}, {":options", "none", nullptr}, {":step", "none", nullptr}, {":precision", "none", nullptr}, + {":offset", "none", nullptr}, }; struct SemanticSnapshot { diff --git a/test/signal_engine/test_sink_vector_sugar.cpp b/test/signal_engine/test_sink_vector_sugar.cpp new file mode 100644 index 0000000..083b7cf --- /dev/null +++ b/test/signal_engine/test_sink_vector_sugar.cpp @@ -0,0 +1,478 @@ +// Vector-packing sugar for external sinks (NISPS-USEQ spec §4.3/§4.4): a +// descriptor with vec_base_ch/vec_mod_ch set accepts its channel expressions +// flat, as [base…] with optional :offset [mod…], or as [[base mod] scalar …]. +// +// Written at the language boundary like test_ext_registry.cpp: eval source +// text, tick the engine, and assert the user-visible contract — identical +// sink_values rows across forms, arity/exclusivity compile errors, per-sink +// LKG across a failed sugar edit, and unchanged plain-sink behaviour. + +#define CATCH_CONFIG_MAIN +#include "../catch.hpp" + +#include "src/signal_engine/signal_engine.h" + +#include +#include +#include + +using namespace sig; + +namespace { + +struct SugarHarness { + SignalEngine engine; + double cell_values[MAX_CELLS] = {}; + double hw_inputs[32] = {}; + double outputs[MAX_OUTPUTS] = {}; + double workspace[MAX_TOTAL_NODES] = {}; + + SugarHarness() { + engine.init_defaults(); + reset_registry(); + } + + ~SugarHarness() { 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)); + if (!found && r.diagnostics[0].message) { + INFO("first diagnostic: " << r.diagnostics[0].message); + } + REQUIRE(found); + } + + 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; + } + + uint8_t binding_row(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 i; + return MAX_SINK_BINDINGS; // sentinel: not bound + } + + 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); + } +}; + +// One 8+8 packed sink named for what it tests; bases 0.1..0.8, mods +// 0.01..0.08. Mirrors the nn/in firmware shape (spec §4.2/§4.3). +constexpr float kBases[8] = {0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f, 0.8f}; +constexpr float kMods[8] = {0.01f, 0.02f, 0.03f, 0.04f, + 0.05f, 0.06f, 0.07f, 0.08f}; + +const char* kFlat = + "(vec/in 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 " + "0.01 0.02 0.03 0.04 0.05 0.06 0.07 0.08)"; +const char* kOffset = + "(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8] " + ":offset [0.01 0.02 0.03 0.04 0.05 0.06 0.07 0.08])"; +const char* kPairs = + "(vec/in [[0.1 0.01] [0.2 0.02] [0.3 0.03] [0.4 0.04] " + "[0.5 0.05] [0.6 0.06] [0.7 0.07] [0.8 0.08]])"; + +} // namespace + +// ── Descriptor validation (registration seam) ─────────────────────────────── + +TEST_CASE("vector-packing descriptor fields validate at registration", + "[sink_sugar][ext_registry]") +{ + SugarHarness h; + + SECTION("both zero (defaults) keeps today's plain sink") + { + REQUIRE(register_external_sink({"plain/out", 3, 0.0f, 1.0f, 50, 7})); + const ExternalSinkDesc* desc = + find_external_sink(internSymbol("plain/out")); + REQUIRE(desc != nullptr); + REQUIRE(desc->vec_base_ch == 0); + REQUIRE(desc->vec_mod_ch == 0); + } + + SECTION("base + mod channels must both be set and sum to arity") + { + REQUIRE(register_external_sink( + {"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8})); + const ExternalSinkDesc* desc = + find_external_sink(internSymbol("vec/in")); + REQUIRE(desc != nullptr); + REQUIRE(desc->vec_base_ch == 8); + REQUIRE(desc->vec_mod_ch == 8); + } + + SECTION("half-packed descriptors are rejected") + { + REQUIRE(!register_external_sink( + {"bad/in", 16, 0.0f, 1.0f, 200, 7, 8, 0})); + REQUIRE(!register_external_sink( + {"bad/in", 16, 0.0f, 1.0f, 200, 7, 0, 8})); + REQUIRE(external_sink_registered(internSymbol("bad/in")) == false); + } + + SECTION("the split must cover the arity exactly") + { + REQUIRE(!register_external_sink( + {"bad/in", 16, 0.0f, 1.0f, 200, 7, 7, 8})); // 15 of 16 + REQUIRE(!register_external_sink( + {"bad/in", 12, 0.0f, 1.0f, 200, 7, 8, 8})); // 16 of 12 + REQUIRE(external_sink_registered(internSymbol("bad/in")) == false); + } +} + +// ── Accepted forms ────────────────────────────────────────────────────────── + +TEST_CASE("three forms of one binding publish identical sink rows", + "[sink_sugar]") +{ + SugarHarness h; + REQUIRE(register_external_sink( + {"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8})); + + SECTION("(a) flat, :offset, and pair forms agree") + { + h.eval_ok(kFlat); + h.tick_sinks(0.0); + const SinkBinding* binding = h.binding_for("vec/in"); + REQUIRE(binding != nullptr); + REQUIRE(binding->arity == 16); + + double expected[16]; + for (uint8_t ch = 0; ch < 16; ch++) + expected[ch] = h.engine.sink_values[0][ch]; + for (uint8_t i = 0; i < 8; i++) { + REQUIRE(expected[i] == Approx(kBases[i])); + REQUIRE(expected[8 + i] == Approx(kMods[i])); + } + + h.eval_ok(kOffset); + REQUIRE(h.engine.sink_binding_count == 1); + h.tick_sinks(1.0); + for (uint8_t ch = 0; ch < 16; ch++) + REQUIRE(h.engine.sink_values[0][ch] == Approx(expected[ch])); + + h.eval_ok(kPairs); + REQUIRE(h.engine.sink_binding_count == 1); + h.tick_sinks(2.0); + for (uint8_t ch = 0; ch < 16; ch++) + REQUIRE(h.engine.sink_values[0][ch] == Approx(expected[ch])); + + // Every channel still occupies an ordinary sink pool slot. + for (uint8_t ch = 0; ch < 16; ch++) { + REQUIRE(binding->value_index[ch] >= SINK_SLOT_BASE); + REQUIRE(binding->value_index[ch] < MAX_OUTPUTS); + } + } + + SECTION("(a2) a base-only vector defaults every modulation to zero") + { + h.eval_ok("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8])"); + h.tick_sinks(0.0); + for (uint8_t i = 0; i < 8; i++) { + REQUIRE(h.engine.sink_values[0][i] == Approx(kBases[i])); + REQUIRE(h.engine.sink_values[0][8 + i] == Approx(0.0)); + } + } + + SECTION("(a3) a mixed pair vector binds scalar and paired entries") + { + h.eval_ok("(vec/in [[0.1 0.01] 0.2 0.3 [0.4 0.04] 0.5 0.6 0.7 0.8])"); + h.tick_sinks(0.0); + for (uint8_t i = 0; i < 8; i++) { + REQUIRE(h.engine.sink_values[0][i] == Approx(kBases[i])); + const float mod = (i == 0 || i == 3) ? kMods[i] : 0.0f; + REQUIRE(h.engine.sink_values[0][8 + i] == Approx(mod)); + } + } +} + +TEST_CASE("entries may be arbitrary expressions in every form", "[sink_sugar]") +{ + SugarHarness h; + REQUIRE(register_external_input({"ctl/x", 2, 1, 0.0f, "[0,1]"})); + REQUIRE(register_external_sink( + {"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8})); + h.hw_inputs[2] = 0.25; + + SECTION("(e1) flat form") + { + h.eval_ok("(vec/in (+ ctl/x 0.1) 0.2 0.3 0.4 0.5 0.6 0.7 0.8 " + "(- ctl/x 0.05) 0.02 0.03 0.04 0.05 0.06 0.07 0.08)"); + h.tick_sinks(0.0); + REQUIRE(h.engine.sink_values[0][0] == Approx(0.35)); + REQUIRE(h.engine.sink_values[0][8] == Approx(0.20)); + } + + SECTION("(e2) :offset form, expressions in both vectors") + { + h.eval_ok("(vec/in [(+ ctl/x 0.1) 0.2 0.3 0.4 0.5 0.6 0.7 0.8] " + ":offset [(- ctl/x 0.05) (* ctl/x 8) 0.03 0.04 " + "0.05 0.06 0.07 0.08])"); + h.tick_sinks(0.0); + REQUIRE(h.engine.sink_values[0][0] == Approx(0.35)); + REQUIRE(h.engine.sink_values[0][8] == Approx(0.20)); + REQUIRE(h.engine.sink_values[0][9] == Approx(2.0)); + } + + SECTION("(e3) pair form, expressions in both slots of a pair") + { + h.eval_ok("(vec/in [[(+ ctl/x 0.1) (- ctl/x 0.05)] " + "[(* ctl/x 2) 0.5] 0.3 0.4 0.5 0.6 0.7 0.8])"); + h.tick_sinks(0.0); + REQUIRE(h.engine.sink_values[0][0] == Approx(0.35)); + REQUIRE(h.engine.sink_values[0][8] == Approx(0.20)); + REQUIRE(h.engine.sink_values[0][1] == Approx(0.5)); + REQUIRE(h.engine.sink_values[0][9] == Approx(0.5)); + // scalar entries still take the implicit zero modulation + REQUIRE(h.engine.sink_values[0][10] == Approx(0.0)); + } +} + +// ── Rejected forms ────────────────────────────────────────────────────────── + +TEST_CASE("sugar arity errors are compile errors that bind nothing", + "[sink_sugar][arity]") +{ + SugarHarness h; + REQUIRE(register_external_sink( + {"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8})); + + SECTION("(c1) base vector with 7 entries") + { + h.expect_error("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7] " + ":offset [0.01 0.02 0.03 0.04 0.05 0.06 0.07 0.08])", + DiagnosticCategory::Arity); + } + + SECTION("(c2) :offset vector with 9 entries") + { + h.expect_error("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8] " + ":offset [0.01 0.02 0.03 0.04 0.05 0.06 0.07 0.08 0.09])", + DiagnosticCategory::Arity); + } + + SECTION("(c3) pair entry with three entries") + { + h.expect_error("(vec/in [[0.1 0.01 0.9] 0.2 0.3 0.4 0.5 0.6 0.7 0.8])", + DiagnosticCategory::Arity); + } + + SECTION("(c3b) pair entry with one entry") + { + h.expect_error("(vec/in [[0.1] 0.2 0.3 0.4 0.5 0.6 0.7 0.8])", + DiagnosticCategory::Arity); + } + + SECTION("(c4) outer vector with 7 entries") + { + h.expect_error("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7])", + DiagnosticCategory::Arity); + } + + SECTION("(c5) flat form with 15 expressions") + { + h.expect_error("(vec/in 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 " + "0.01 0.02 0.03 0.04 0.05 0.06 0.07)", + DiagnosticCategory::Arity); + } + + SECTION("unknown keyword in the sink form") + { + h.expect_error("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8] " + ":modulation [0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1])", + DiagnosticCategory::Syntax); + } + + SECTION(":offset value that is not a vector") + { + h.expect_error("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8] " + ":offset 0.1)", + DiagnosticCategory::Syntax); + } + + SECTION("trailing value after the channel vector") + { + h.expect_error("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8] 0.9)", + DiagnosticCategory::Arity); + } + + REQUIRE(h.binding_for("vec/in") == nullptr); + REQUIRE(h.engine.sink_binding_count == 0); +} + +TEST_CASE(":offset and pair entries are mutually exclusive", "[sink_sugar]") +{ + SugarHarness h; + REQUIRE(register_external_sink( + {"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8})); + + SECTION("(b1) a pair entry plus :offset") + { + h.expect_error("(vec/in [[0.1 0.01] 0.2 0.3 0.4 0.5 0.6 0.7 0.8] " + ":offset [0.01 0.02 0.03 0.04 0.05 0.06 0.07 0.08])", + DiagnosticCategory::Syntax); + } + + SECTION("(b2) a nested pair inside the :offset vector") + { + h.expect_error("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8] " + ":offset [[0.01 0.02] 0.02 0.03 0.04 " + "0.05 0.06 0.07 0.08])", + DiagnosticCategory::Syntax); + } + + REQUIRE(h.engine.sink_binding_count == 0); +} + +// ── LKG and lifecycle ─────────────────────────────────────────────────────── + +TEST_CASE("a bad sugar edit keeps the prior binding while another sink stays " + "live", + "[sink_sugar][lkg]") +{ + SugarHarness h; + REQUIRE(register_external_input({"ctl/x", 2, 1, 0.0f, "[0,1]"})); + REQUIRE(register_external_sink( + {"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8})); + REQUIRE(register_external_sink({"midi/cc74", 1, 0.0f, 1.0f, 50, 7})); + + h.eval_ok(kFlat); + h.eval_ok("(midi/cc74 ctl/x)"); + h.hw_inputs[2] = 0.5; + h.tick_sinks(0.0); + + const uint8_t vec_row = h.binding_row("vec/in"); + const uint8_t cc_row = h.binding_row("midi/cc74"); + REQUIRE(vec_row != MAX_SINK_BINDINGS); + REQUIRE(cc_row != MAX_SINK_BINDINGS); + + double lkg[16]; + for (uint8_t ch = 0; ch < 16; ch++) + lkg[ch] = h.engine.sink_values[vec_row][ch]; + + // Exclusivity violation (spec §4.5): the whole form fails. + h.expect_error("(vec/in [[0.9 0.01] 0.2 0.3 0.4 0.5 0.6 0.7 0.8] " + ":offset [0.01 0.02 0.03 0.04 0.05 0.06 0.07 0.08])", + DiagnosticCategory::Syntax); + REQUIRE(h.engine.sink_binding_count == 2); + + // The failed edit retained vec/in's row; the other sink kept publishing. + h.hw_inputs[2] = 0.75; + h.tick_sinks(1.0); + for (uint8_t ch = 0; ch < 16; ch++) + REQUIRE(h.engine.sink_values[vec_row][ch] == Approx(lkg[ch])); + REQUIRE(h.engine.sink_values[cc_row][0] == Approx(0.75)); + + // A corrected sugar edit then replaces the retained binding atomically. + h.eval_ok("(vec/in [0.8 0.8 0.8 0.8 0.8 0.8 0.8 0.8])"); + h.tick_sinks(2.0); + for (uint8_t i = 0; i < 8; i++) { + REQUIRE(h.engine.sink_values[vec_row][i] == Approx(0.8)); + REQUIRE(h.engine.sink_values[vec_row][8 + i] == Approx(0.0)); + } +} + +TEST_CASE("unassign then rebind via sugar", "[sink_sugar]") +{ + SugarHarness h; + REQUIRE(register_external_sink( + {"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8})); + + h.eval_ok(kFlat); + h.tick_sinks(0.0); + REQUIRE(h.engine.sink_binding_count == 1); + + h.eval_ok("(unassign vec/in)"); + REQUIRE(h.engine.sink_binding_count == 0); + REQUIRE(h.binding_for("vec/in") == nullptr); + + h.eval_ok(kPairs); + REQUIRE(h.engine.sink_binding_count == 1); + const SinkBinding* binding = h.binding_for("vec/in"); + REQUIRE(binding != nullptr); + REQUIRE(binding->arity == 16); + h.tick_sinks(1.0); + for (uint8_t i = 0; i < 8; i++) { + REQUIRE(h.engine.sink_values[0][i] == Approx(kBases[i])); + REQUIRE(h.engine.sink_values[0][8 + i] == Approx(kMods[i])); + } +} + +// ── Plain sinks are untouched ─────────────────────────────────────────────── + +TEST_CASE("plain non-packed sinks reject the sugar forms", "[sink_sugar]") +{ + SugarHarness h; + REQUIRE(register_external_sink({"bus/out", 3, 0.0f, 1.0f, 50, 0})); + + // The sugar is descriptor-gated: a plain sink parses arguments as flat + // expressions, so the sugar shapes fail as they always did — the + // :offset keyword is an unknown name in expression position, and a + // nested pair vector compiles to a plain vector expression (its + // length), leaving too few flat arguments. + h.expect_error("(bus/out [1 2] :offset [3 4])", + DiagnosticCategory::UndefinedName); + h.expect_error("(bus/out [[1 2] 3])", DiagnosticCategory::Arity); + REQUIRE(h.engine.sink_binding_count == 0); + + // The flat form still binds exactly as before. + 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][2] == Approx(0.3)); +}