feat(signal-engine): vector-packed sink sugar — :offset and [base mod] pair forms (spec §4.3/§4.4)

This commit is contained in:
w1n5t0n 2026-08-18 13:33:20 +03:00
parent b791dc8b66
commit e1a2e0115c
9 changed files with 719 additions and 34 deletions

26
MAP.md
View file

@ -16,11 +16,15 @@ modulisp/
│ │ │ # (resolve_hardware_input consults ext_registry first) │ │ │ # (resolve_hardware_input consults ext_registry first)
│ │ ├── compiler_pipeline.{h,cpp} # source → tokens → cells → DAG orchestration │ │ ├── compiler_pipeline.{h,cpp} # source → tokens → cells → DAG orchestration
│ │ ├── cold_eval.{h,cpp} + eval_ops.h # constant folding / cold evaluation │ │ ├── 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 │ │ ├── executor.{h,cpp} # tick-time DAG executor
│ │ │ # (+ publish_sink_values post-tick publication) │ │ │ # (+ publish_sink_values post-tick publication)
│ │ ├── ext_registry.{h,cpp} # generic named external-input/sink/command registries │ │ ├── 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 │ │ ├── state_registry.{h,cpp} # defstate slots, live-edit slots
│ │ ├── synth_registry.{h,cpp} # synth graph snapshot registry │ │ ├── synth_registry.{h,cpp} # synth graph snapshot registry
│ │ ├── synth_graph.{h,cpp} # serialisable synth artefact graph │ │ ├── synth_graph.{h,cpp} # serialisable synth artefact graph
@ -34,9 +38,9 @@ modulisp/
│ ├── devtools/ # devtools.{h,cpp} — USEQ_DEVTOOLS-gated telemetry │ ├── devtools/ # devtools.{h,cpp} — USEQ_DEVTOOLS-gated telemetry
│ └── ports/ # IStorage.h, II2CTransport.h, mocks/{MockStorage.h,MockI2CBus.h} │ └── ports/ # IStorage.h, II2CTransport.h, mocks/{MockStorage.h,MockI2CBus.h}
└── test/ └── 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) ├── 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): 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` / `executor::publish_sink_values` writes `SignalEngine::sink_values` /
`sink_dirty` per tick for the firmware to poll. `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.

View file

@ -14,14 +14,19 @@ meson setup build && ninja -C build && meson test -C build
fixed-pool signal DAG (deterministic compile, CSE, bounded diagnostics). fixed-pool signal DAG (deterministic compile, CSE, bounded diagnostics).
- `compiler_pipeline`: parse + graph-mutation transactions (LKG rollback). - `compiler_pipeline`: parse + graph-mutation transactions (LKG rollback).
- `cold_eval`: top-level evaluation — definitions, output assignment, - `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`. - `executor`: tick-time DAG execution + `publish_sink_values`.
- `ext_registry`: generic named external-input / external-sink / - `ext_registry`: generic named external-input / external-sink /
cold-command registries — the firmware-profile seam (see MAP.md). cold-command registries — the firmware-profile seam (see MAP.md). A sink
- `state_registry`, `synth_registry`, `synth_graph`, `diagnostics`. descriptor sets `vec_base_ch`/`vec_mod_ch` to accept the vector sugar.
- `src/utils/`, `src/devtools/`, `src/ports/` — support libraries, gated - `src/utils/`, `src/devtools/`, `src/ports/` — support libraries, gated
telemetry, and hardware port interfaces with host mocks. telemetry, and hardware port interfaces with host mocks.
- `test/` — Catch2 executables over the host subset of the engine. - `test/` — Catch2 executables over the host subset of the engine.
External registers are generic: firmware registers names like `nn/*` or 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.

View file

@ -2396,6 +2396,44 @@ static void unassign_sink(SignalEngine& engine, SymbolID sink_sym) {
engine.sink_binding_count--; 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, static EvalResult do_sink_assign(SymbolID sink_sym, TokenStream& ts,
SignalEngine& engine, const char* source, SignalEngine& engine, const char* source,
SharedLiveEditIDs* shared_ids = nullptr) { 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 // 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); GraphMutationTransaction graph_transaction(engine);
SinkChannelCandidate candidates[MAX_SINK_ARITY]; 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( return make_cold_arity_error(
"Wrong number of sink arguments", "Wrong number of sink arguments",
"The sink descriptor fixes the argument count"); "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 // Publication: retire previous slots the smaller new binding no longer

View file

@ -60,6 +60,14 @@ bool register_external_sink(const ExternalSinkDesc& desc) {
if (!valid_name(desc.name)) return false; if (!valid_name(desc.name)) return false;
if (desc.arity == 0 || desc.arity > MAX_SINK_ARITY) return false; if (desc.arity == 0 || desc.arity > MAX_SINK_ARITY) return false;
if (desc.min > desc.max) 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)); const SymbolID name_id = internSymbol(String(desc.name));
for (uint8_t i = 0; i < g_sink_count; i++) { for (uint8_t i = 0; i < g_sink_count; i++) {

View file

@ -50,6 +50,18 @@ struct ExternalSinkDesc {
float max; float max;
uint32_t max_rate_hz; // profile transport ceiling; enforced firmware-side uint32_t max_rate_hz; // profile transport ceiling; enforced firmware-side
uint16_t quant_bits; // 0 = unquantised; else step = (max-min)/2^bits 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 { struct ExternalCommandDesc {
@ -88,7 +100,8 @@ const ExternalCommandDesc* find_external_command(SymbolID cmd);
// reachability are the ordinary output machinery — no second mechanism. // reachability are the ordinary output machinery — no second mechanism.
// 16 channel expressions per binding: the nn/in neural-input sink binds // 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_ARITY = 16;
constexpr uint8_t MAX_SINK_BINDINGS = 16; constexpr uint8_t MAX_SINK_BINDINGS = 16;

View file

@ -157,6 +157,7 @@ SYM(kw_phase, ":phase", none)
SYM(kw_pw, ":pw", none) SYM(kw_pw, ":pw", none)
SYM(kw_id, ":id", none) SYM(kw_id, ":id", none)
SYM(kw_fresh, ":fresh", none) SYM(kw_fresh, ":fresh", none)
SYM(kw_offset, ":offset", none)
SYM(kw_attack, ":attack", none) SYM(kw_attack, ":attack", none)
SYM(kw_release, ":release", none) SYM(kw_release, ":release", none)
SYM(kw_reset, ":reset", none) SYM(kw_reset, ":reset", none)

View file

@ -43,6 +43,7 @@ test_env = [
[ 'signal_engine/test_synth_compiler.cpp', 'test_synth_compiler', 'synth_compiler_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_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_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 foreach t : test_env

View file

@ -194,6 +194,7 @@ static const ClassifiedOnly kClassifiedOnly[] = {
{":min", "none", nullptr}, {":max", "none", nullptr}, {":min", "none", nullptr}, {":max", "none", nullptr},
{":options", "none", nullptr}, {":step", "none", nullptr}, {":options", "none", nullptr}, {":step", "none", nullptr},
{":precision", "none", nullptr}, {":precision", "none", nullptr},
{":offset", "none", nullptr},
}; };
struct SemanticSnapshot { struct SemanticSnapshot {

View file

@ -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 <cmath>
#include <cstring>
#include <string>
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<uint32_t>(code.size()),
engine);
}
void eval_ok(const std::string& code)
{
EvalResult r = eval(code);
INFO("code: " << code);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
INFO("diagnostic: "
<< (r.diagnostics[0].message ? r.diagnostics[0].message
: ""));
}
REQUIRE(r.kind != EvalResult::Error);
}
void expect_error(const std::string& code, DiagnosticCategory category)
{
EvalResult r = eval(code);
INFO("code: " << code);
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count > 0);
bool found = false;
for (uint8_t i = 0; i < r.diagnostic_count; ++i)
if (r.diagnostics[i].category == category) found = true;
INFO("expected category: " << category_to_cstr(category));
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));
}