ModuLisp/test/signal_engine/test_sink_vector_sugar.cpp

479 lines
17 KiB
C++
Raw Permalink Normal View History

// 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({"transport/mono", 1, 0.0f, 1.0f, 50, 7}));
h.eval_ok(kFlat);
h.eval_ok("(transport/mono 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("transport/mono");
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));
}