feat(signal-engine): 16-channel sink arity, pool capacity 280, ColdArg::vec_len, Arduino-safe kTwoPi rename

This commit is contained in:
w1n5t0n 2026-08-17 15:37:14 +03:00
parent db2031ae9e
commit 1b87f89121
13 changed files with 95 additions and 47 deletions

View file

@ -160,7 +160,7 @@ static void publish_output_graph_plan(SignalEngine& engine,
const GraphBuildResult& result) {
engine.pool.outputs[output_index].root_node = result.root_node;
engine.pool.outputs[output_index].valid = true;
engine.pool.runtime_fallback_mask &= ~((uint64_t)1 << output_index);
engine.pool.runtime_fallback[output_index] = 0;
engine.output_compile_diagnostics[output_index] =
ActiveCompileDiagnostic{};
engine.pool.output_deps[output_index].clear();
@ -2231,8 +2231,7 @@ static EvalResult do_unassign(TokenStream& ts, SignalEngine& engine) {
engine.pool.prev_output_values[output_index] = 0.0;
engine.pool.output_deps[output_index].clear();
engine.output_sources[output_index] = OutputSource{};
engine.pool.runtime_fallback_mask &=
~((uint64_t)1 << output_index);
engine.pool.runtime_fallback[output_index] = 0;
engine.output_compile_diagnostics[output_index] =
ActiveCompileDiagnostic{};
@ -2362,7 +2361,7 @@ static void release_output_slot(SignalEngine& engine, uint16_t slot) {
engine.pool.outputs[slot] = OutputSlot{};
engine.pool.prev_output_values[slot] = 0.0;
engine.pool.output_deps[slot].clear();
engine.pool.runtime_fallback_mask &= ~((uint64_t)1 << slot);
engine.pool.runtime_fallback[slot] = 0;
engine.output_compile_diagnostics[slot] = ActiveCompileDiagnostic{};
engine.registry.begin_context(slot);
engine.registry.commit_context(slot,
@ -2550,10 +2549,11 @@ static EvalResult do_cold_command(const ExternalCommandDesc* desc,
if (!parse_numeric_vector(ts, values, count, error)) return error;
if (count > ColdArg::MAX_VEC) {
return make_cold_arity_error(
"Vector command arguments support at most 8 values",
"Vector command arguments support at most 16 values",
"Shorten the vector");
}
arg.kind = ColdArg::Kind::Vector;
arg.vec_len = (uint8_t)count;
for (uint16_t v = 0; v < count; v++)
arg.vec[v] = (float)values[v];
} else {

View file

@ -18,7 +18,7 @@ OutputHealth output_health(const NodePool& pool, uint16_t output_index) {
if (output_index >= MAX_OUTPUTS) return OutputHealth::Idle;
const OutputSlot& slot = pool.outputs[output_index];
if (!slot.valid || slot.root_node == NODE_NONE) return OutputHealth::Idle;
if (((pool.runtime_fallback_mask >> output_index) & 1u) == 0)
if (!pool.runtime_fallback_bit(output_index))
return OutputHealth::Running;
return slot.has_lkg ? OutputHealth::Fallback : OutputHealth::Error;
}
@ -176,9 +176,10 @@ void execute_all_outputs(const NodePool& pool, ExecutionContext& ctx) {
ctx.workspace[idx] = result;
}
// Read output values with LKG fallback
uint64_t fallback_mask = 0;
// Read output values with LKG fallback. runtime_fallback[] is recomputed
// wholesale on every pass.
for (uint16_t i = 0; i < MAX_OUTPUTS; i++) {
pool.runtime_fallback[i] = 0;
if (pool.outputs[i].root_node != NODE_NONE) {
Sample v = ctx.workspace[pool.outputs[i].root_node];
if (g_failure_mode == FailureMode::LkgFallback &&
@ -187,7 +188,7 @@ void execute_all_outputs(const NodePool& pool, ExecutionContext& ctx) {
// value (or the neutral default when no LKG exists —
// failure-model.md §2.4) and record the fallback.
v = pool.outputs[i].has_lkg ? pool.outputs[i].lkg_value : 0.0;
fallback_mask |= (uint64_t)1 << i;
pool.runtime_fallback[i] = 1;
}
ctx.output_values[i] = v;
} else if (pool.outputs[i].valid) {
@ -199,7 +200,6 @@ void execute_all_outputs(const NodePool& pool, ExecutionContext& ctx) {
ctx.output_values[i] = 0.0;
}
}
pool.runtime_fallback_mask = fallback_mask;
}
// ── External Sink Publication ───────────────────────────────────────────────
@ -237,8 +237,7 @@ void publish_sink_values(SignalEngine& engine, const Sample* output_values) {
void commit_outputs(NodePool& pool, const Sample* output_values) {
for (uint16_t i = 0; i < MAX_OUTPUTS; i++) {
pool.prev_output_values[i] = output_values[i];
bool substituted =
((pool.runtime_fallback_mask >> i) & 1u) != 0;
bool substituted = pool.runtime_fallback_bit(i);
if (pool.outputs[i].root_node != NODE_NONE && !substituted &&
std::isfinite(output_values[i])) {
pool.outputs[i].lkg_value = output_values[i];
@ -257,7 +256,7 @@ void commit_state(NodePool& pool, const Sample* workspace,
if (pool.state_update_roots[s] != NODE_NONE) {
uint16_t owner = pool.state_owner_context[s];
if (owner < MAX_OUTPUTS &&
((pool.runtime_fallback_mask >> owner) & 1u) != 0) {
pool.runtime_fallback_bit(owner)) {
// Stateful nodes owned by an output advance only when that
// output publishes a healthy sample. Otherwise an oscillator
// or integrator could run invisibly behind scalar fallback,
@ -311,7 +310,7 @@ void execute_batch(
const size_t CHUNK = pool.batch_chunk_size;
Sample* regs = pool.batch_workspace.get();
uint64_t fallback_mask = 0;
memset(pool.runtime_fallback, 0, sizeof(pool.runtime_fallback));
for (size_t chunk_start = 0; chunk_start < sample_count; chunk_start += CHUNK) {
size_t chunk_size = std::min(CHUNK, sample_count - chunk_start);
@ -385,7 +384,7 @@ void execute_batch(
Sample v = src[s];
if (!std::isfinite(v)) {
v = lkg;
fallback_mask |= (uint64_t)1 << o;
pool.runtime_fallback[o] = 1;
}
dst[s] = v;
}
@ -400,7 +399,6 @@ void execute_batch(
}
}
}
pool.runtime_fallback_mask = fallback_mask;
}
// ── Output Classification ───────────────────────────────────────────────────

View file

@ -12,7 +12,7 @@ namespace sig {
// LkgFallback (default, spec-mandated): values propagate freely through
// the node graph; a non-finite value reaching an OUTPUT ROOT makes that
// output substitute its last-known-good value (or 0 if none) and marks
// it as being in fallback (see NodePool::runtime_fallback_mask).
// it as being in fallback (see NodePool::runtime_fallback).
// ZeroSquash (legacy): every node's result is clamped to 0.0 when
// non-finite. No fallback, no diagnostic — pre-v1.2 behaviour.
//

View file

@ -18,8 +18,12 @@ namespace sig {
// (useq-clear) and is deliberately NOT touched by SignalEngine session resets.
// reset_registry() exists for tests and explicit profile re-initialisation.
constexpr uint8_t MAX_EXTERNAL_INPUTS = 24;
constexpr uint8_t MAX_EXTERNAL_SINKS = 16;
// Registry capacities sized for the uSEQ+NISPS firmware profile plus headroom:
// 19 meml/* control registers + 8 nn/out* neural outputs = 27 inputs (cap 32,
// aligned with MAX_HW_INPUT_CHANNELS); 32 midi/cc* transports + nn/in = 33
// sinks (cap 40); 14 nn/* commands (cap 16).
constexpr uint8_t MAX_EXTERNAL_INPUTS = 32;
constexpr uint8_t MAX_EXTERNAL_SINKS = 40;
constexpr uint8_t MAX_EXTERNAL_COMMANDS = 16;
// Width of the executor-side hw_inputs[] snapshot array (see
@ -83,14 +87,20 @@ const ExternalCommandDesc* find_external_command(SymbolID cmd);
// SINK_SLOT_BASE..MAX_OUTPUTS-1) so execution order, LKG fallback, and GC
// reachability are the ordinary output machinery — no second mechanism.
constexpr uint8_t MAX_SINK_ARITY = 8;
// 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).
constexpr uint8_t MAX_SINK_ARITY = 16;
constexpr uint8_t MAX_SINK_BINDINGS = 16;
// Pool output slots 0..23 are the named a/d/s outputs; sink channel graphs
// occupy the remainder.
// occupy the remainder. The pool must hold every binding row at full arity
// simultaneously: 24 + 16 × 16 = 280 = MAX_OUTPUTS.
constexpr uint16_t SINK_SLOT_BASE = 24;
static_assert(SINK_SLOT_BASE < MAX_OUTPUTS,
"sink channel slots must fit inside the pool output table");
static_assert(SINK_SLOT_BASE + (uint32_t)MAX_SINK_BINDINGS * MAX_SINK_ARITY
<= MAX_OUTPUTS,
"pool outputs must fit every sink binding at full arity");
struct SinkBinding {
SymbolID sink = SymbolIntern::INVALID_ID;
@ -111,10 +121,12 @@ struct ColdArg {
static constexpr uint8_t MAX_VEC = MAX_SINK_ARITY;
Kind kind = Kind::Int;
// Number of entries in vec[] when kind == Vector (0..MAX_VEC). Without
// it a handler could not validate an exact-width vector argument such as
// nn/add-example's Vector[8] pair, nor tell a short vector from padding.
uint8_t vec_len = 0;
// Number tokens without a fractional part that fit in int32 parse as
// Int; everything numeric else is Number. Names parse as their interned
// SymbolID; bracketed numeric literals parse as Vector.
ColdArg() : integer(0) {}
union {
float number;
int32_t integer;
@ -122,7 +134,6 @@ struct ColdArg {
float vec[MAX_VEC];
};
ColdArg() : integer(0) {}
};
using ColdCommandHandler = bool (*)(SymbolID cmd, const ColdArg* args,

View file

@ -1076,7 +1076,7 @@ uint16_t GraphBuilder::compile_waveform(
"Remove the extra arguments");
}
constexpr Sample TWO_PI = 6.28318530717958647692;
constexpr Sample kTwoPi = 6.28318530717958647692;
const bool is_trig = waveform == sym.sin_ || waveform == sym.cos_ ||
waveform == sym.bsin || waveform == sym.bcos ||
waveform == sym.tan_;
@ -1088,11 +1088,11 @@ uint16_t GraphBuilder::compile_waveform(
if (name_space != OperatorNamespace::Radians &&
name_space != OperatorNamespace::Raw) {
phase_or_angle = pool.make_binop(
NodeOp::Mul, input, pool.make_const(TWO_PI));
NodeOp::Mul, input, pool.make_const(kTwoPi));
}
} else if (name_space == OperatorNamespace::Radians) {
phase_or_angle = pool.make_binop(
NodeOp::Div, input, pool.make_const(TWO_PI));
NodeOp::Div, input, pool.make_const(kTwoPi));
}
uint16_t result = NODE_NONE;

View file

@ -487,7 +487,7 @@ void NodePool::reset() {
memset(output_class, 0, sizeof(output_class));
memset(output_input_mask, 0, sizeof(output_input_mask));
memset(prev_output_values, 0, sizeof(prev_output_values));
runtime_fallback_mask = 0;
memset(runtime_fallback, 0, sizeof(runtime_fallback));
state_update_failure_mask = 0;
memset(state_values, 0, sizeof(state_values));
for (uint16_t s = 0; s < MAX_STATE_SLOTS; s++) {

View file

@ -145,13 +145,18 @@ struct NodePool {
// Cross-output reads use previous-tick values
Sample prev_output_values[MAX_OUTPUTS] = {};
// Runtime fallback tracking (failure-model.md §2.1/§5): bit i is set when
// output i substituted its LKG value on the most recent execution pass
// because a non-finite value reached its root (FailureMode::LkgFallback
// Runtime fallback tracking (failure-model.md §2.1/§5): entry i is set
// when output i substituted its LKG value on the most recent execution
// pass because a non-finite value reached its root (FailureMode::LkgFallback
// only). Recomputed on every pass; mutable because execution paths take
// `const NodePool&` — this is diagnostic bookkeeping, not graph state.
mutable uint64_t runtime_fallback_mask = 0;
static_assert(MAX_OUTPUTS <= 64, "runtime_fallback_mask is 64-bit");
// One byte per output rather than a uint64_t bitmask: the pool output
// table grew past 64 entries when sink channel slots were sized for
// 16 bindings × 16 channels (ext_registry.h).
mutable uint8_t runtime_fallback[MAX_OUTPUTS] = {};
bool runtime_fallback_bit(uint16_t output_index) const {
return output_index < MAX_OUTPUTS && runtime_fallback[output_index] != 0;
}
// Bit s is active when state slot s most recently produced a non-finite
// update candidate. The previous finite state remains installed until

View file

@ -67,7 +67,13 @@ constexpr size_t MAX_STATE_SLOTS = 32;
#endif
constexpr size_t MAX_CALLABLE_PARAMS = 8;
constexpr size_t MAX_OUTPUTS = 42;
// Named a/d/s outputs occupy 0..SINK_SLOT_BASE-1; bound external-sink channel
// graphs take pool slots above that (ext_registry.h). Sized so every binding
// row can hold a full-arity sink at once: SINK_SLOT_BASE(24) +
// MAX_SINK_BINDINGS(16) × MAX_SINK_ARITY(16) = 280 (static_assert beside the
// constants in ext_registry.h). The nn/in neural-input sink needs 16 channels
// (8 bases + 8 modulations, NISPS-USEQ spec §4.2/§4.3).
constexpr size_t MAX_OUTPUTS = 280;
constexpr size_t MAX_SCOPE_DEPTH = 32;
constexpr size_t MAX_LOCAL_BINDINGS = 32;
constexpr size_t MAX_DIAGNOSTICS = 16;

View file

@ -259,6 +259,35 @@ TEST_CASE("external sinks publish evaluated values", "[ext_registry]")
REQUIRE(h.engine.sink_values[0][2] == Approx(0.6));
}
SECTION("full 16-channel sink binds and publishes every channel")
{
// The nn/in neural-input shape (NISPS-USEQ spec §4.2/§4.3): 8 base +
// 8 modulation expressions in one binding. MAX_SINK_ARITY and the
// pool arithmetic in ext_registry.h are sized for this.
REQUIRE(register_external_sink({"nn/in", 16, 0.0f, 1.0f, 200, 7}));
h.eval_ok("(nn/in 0.00 0.05 0.10 0.15 0.20 0.25 0.30 0.35 "
"0.5 0.4 0.3 0.2 0.1 0.0 -0.1 -0.2)");
h.tick_sinks(0.0);
const SinkBinding* binding = h.binding_for("nn/in");
REQUIRE(binding != nullptr);
REQUIRE(binding->arity == 16);
for (uint8_t ch = 0; ch < 16; ch++) {
const double expected = ch < 8 ? 0.05 * ch : 0.5 - 0.1 * (ch - 8);
REQUIRE(h.engine.sink_values[0][ch] == Approx(expected));
}
// Slot plan: 16 pool output slots at/above SINK_SLOT_BASE.
for (uint8_t ch = 0; ch < 16; ch++) {
REQUIRE(binding->value_index[ch] >= SINK_SLOT_BASE);
REQUIRE(binding->value_index[ch] < MAX_OUTPUTS);
}
// Rebinding swaps all 16 channels as one transaction.
h.eval_ok("(nn/in 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0)");
h.tick_sinks(1.0);
REQUIRE(h.engine.sink_values[0][0] == Approx(1.0));
REQUIRE(h.engine.sink_values[0][15] == Approx(0.0));
}
SECTION("wrong arity is a compile error and binds nothing")
{
h.expect_error("(midi/cc74 0.1 0.2)", DiagnosticCategory::Arity);
@ -338,7 +367,6 @@ TEST_CASE("registered cold commands dispatch to the handler", "[ext_registry]")
REQUIRE(register_external_command(internSymbol("meml/cmd"), 1, 2));
set_cold_command_handler(&test_command_handler, &cookie);
const SymbolID cmd_id = internSymbol("meml/cmd");
SECTION("integer and float arguments parse by shape")
{
h.eval_ok("(meml/cmd 3)");
@ -359,8 +387,8 @@ TEST_CASE("registered cold commands dispatch to the handler", "[ext_registry]")
h.eval_ok("(meml/cmd level [1 2 3])");
REQUIRE(g_capture.nargs == 2);
REQUIRE(g_capture.args[0].kind == ColdArg::Kind::Symbol);
REQUIRE(g_capture.args[0].symbol == internSymbol("level"));
REQUIRE(g_capture.args[1].kind == ColdArg::Kind::Vector);
REQUIRE(g_capture.args[1].vec_len == 3);
REQUIRE(g_capture.args[1].vec[0] == Approx(1.0f));
REQUIRE(g_capture.args[1].vec[1] == Approx(2.0f));
REQUIRE(g_capture.args[1].vec[2] == Approx(3.0f));

View file

@ -2,7 +2,7 @@
//
// Mode A (FailureMode::LkgFallback, DEFAULT): a non-finite value reaching an
// output root substitutes the last-known-good value (or 0 with no LKG),
// sets the pool's runtime_fallback_mask bit, and never zeroes per node.
// sets the pool's runtime_fallback[] entry, and never zeroes per node.
// Mode B (FailureMode::ZeroSquash, legacy): every non-finite node result is
// clamped to 0.0; no fallback, no diagnostic.
@ -71,7 +71,7 @@ struct Harness {
}
bool in_fallback(int output_index) const {
return (engine.pool.runtime_fallback_mask >> output_index) & 1;
return engine.pool.runtime_fallback_bit(output_index);
}
};

View file

@ -85,7 +85,7 @@ TEST_CASE("First failure has Error health until a finite root establishes LKG",
// immediately while retaining its finite LKG as a safety net.
h.eval_ok("(a1 7)");
REQUIRE(output_health(h.engine.pool, 0) == OutputHealth::Running);
REQUIRE((h.engine.pool.runtime_fallback_mask & 1u) == 0);
REQUIRE_FALSE(h.engine.pool.runtime_fallback_bit(0));
REQUIRE(h.engine.pool.outputs[0].has_lkg);
}
@ -181,14 +181,14 @@ TEST_CASE("Unassign clears runtime and reactive health with the program",
h.eval_ok("(define health-unassign-dep 1)");
h.eval_ok("(a1 (* health-unassign-dep (* t 1e308)))");
REQUIRE(h.tick(0, 2.0) == Approx(0.0));
REQUIRE((h.engine.pool.runtime_fallback_mask & 1u) != 0);
REQUIRE(h.engine.pool.runtime_fallback_bit(0));
h.eval_ok("(defn health-unassign-dep [x] x)");
REQUIRE(h.engine.output_compile_diagnostics[0].active);
h.eval_ok("(unassign a1)");
REQUIRE(output_health(h.engine.pool, 0) == OutputHealth::Idle);
REQUIRE((h.engine.pool.runtime_fallback_mask & 1u) == 0);
REQUIRE_FALSE(h.engine.pool.runtime_fallback_bit(0));
REQUIRE_FALSE(h.engine.output_compile_diagnostics[0].active);
}

View file

@ -308,7 +308,7 @@ TEST_CASE("Phase 4: non-finite value handling", "[phase4][numerical]") {
double val = h.sample("a1", 0.0);
REQUIRE(std::isfinite(val));
REQUIRE(val == Approx(0.0));
REQUIRE((h.engine.pool.runtime_fallback_mask & 1u) != 0);
REQUIRE(h.engine.pool.runtime_fallback_bit(0));
}
SECTION("legitimate large values stay finite") {
@ -342,7 +342,7 @@ TEST_CASE("Phase 4: non-finite value handling", "[phase4][numerical]") {
double val = h.sample("a1", 0.0);
REQUIRE(std::isfinite(val));
REQUIRE(val == Approx(0.0));
REQUIRE((h.engine.pool.runtime_fallback_mask & 1u) != 0);
REQUIRE(h.engine.pool.runtime_fallback_bit(0));
}
SECTION("chained operations producing intermediate infinities stay finite") {
@ -351,7 +351,7 @@ TEST_CASE("Phase 4: non-finite value handling", "[phase4][numerical]") {
h.assign_ok("a1", "(* (/ 1 0) 5)");
double val = h.sample("a1", 0.0);
REQUIRE(std::isfinite(val));
REQUIRE((h.engine.pool.runtime_fallback_mask & 1u) != 0);
REQUIRE(h.engine.pool.runtime_fallback_bit(0));
}
SECTION("NaN guard on executor output") {

View file

@ -906,7 +906,7 @@ TEST_CASE("Arithmetic edge cases produce finite outputs", "[robustness][edge]")
double v = h.sample("a1", 0.0);
REQUIRE(std::isfinite(v));
REQUIRE(v == Approx(0.0));
REQUIRE((h.engine.pool.runtime_fallback_mask & 1u) != 0);
REQUIRE(h.engine.pool.runtime_fallback_bit(0));
}
SECTION("Modulo by zero activates bootstrap LKG")
@ -916,7 +916,7 @@ TEST_CASE("Arithmetic edge cases produce finite outputs", "[robustness][edge]")
double v = h.sample("a1", 0.0);
REQUIRE(std::isfinite(v));
REQUIRE(v == Approx(0.0));
REQUIRE((h.engine.pool.runtime_fallback_mask & 1u) != 0);
REQUIRE(h.engine.pool.runtime_fallback_bit(0));
}
SECTION("sqrt of negative is finite")