feat(signal-engine): vector-packed sink sugar — :offset and [base mod] pair forms (spec §4.3/§4.4)
This commit is contained in:
parent
b791dc8b66
commit
e1a2e0115c
9 changed files with 719 additions and 34 deletions
26
MAP.md
26
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.
|
||||
|
|
|
|||
13
README.md
13
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
478
test/signal_engine/test_sink_vector_sugar.cpp
Normal file
478
test/signal_engine/test_sink_vector_sugar.cpp
Normal 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));
|
||||
}
|
||||
Loading…
Reference in a new issue