diff --git a/src/signal_engine/cold_eval.cpp b/src/signal_engine/cold_eval.cpp index 9110999..c1e847d 100644 --- a/src/signal_engine/cold_eval.cpp +++ b/src/signal_engine/cold_eval.cpp @@ -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 { diff --git a/src/signal_engine/executor.cpp b/src/signal_engine/executor.cpp index 2045dbe..4d16c95 100644 --- a/src/signal_engine/executor.cpp +++ b/src/signal_engine/executor.cpp @@ -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 ─────────────────────────────────────────────────── diff --git a/src/signal_engine/executor.h b/src/signal_engine/executor.h index 163ad89..84a53b7 100644 --- a/src/signal_engine/executor.h +++ b/src/signal_engine/executor.h @@ -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. // diff --git a/src/signal_engine/ext_registry.h b/src/signal_engine/ext_registry.h index 0fadfb1..f349159 100644 --- a/src/signal_engine/ext_registry.h +++ b/src/signal_engine/ext_registry.h @@ -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, diff --git a/src/signal_engine/graph_builder.cpp b/src/signal_engine/graph_builder.cpp index 9647b7b..640ac88 100644 --- a/src/signal_engine/graph_builder.cpp +++ b/src/signal_engine/graph_builder.cpp @@ -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; diff --git a/src/signal_engine/node_pool.cpp b/src/signal_engine/node_pool.cpp index 8950bd5..a1edfcf 100644 --- a/src/signal_engine/node_pool.cpp +++ b/src/signal_engine/node_pool.cpp @@ -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++) { diff --git a/src/signal_engine/node_pool.h b/src/signal_engine/node_pool.h index decfd1e..d9f7b81 100644 --- a/src/signal_engine/node_pool.h +++ b/src/signal_engine/node_pool.h @@ -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 diff --git a/src/signal_engine/types.h b/src/signal_engine/types.h index 83682f8..0ebc96d 100644 --- a/src/signal_engine/types.h +++ b/src/signal_engine/types.h @@ -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; diff --git a/test/signal_engine/test_ext_registry.cpp b/test/signal_engine/test_ext_registry.cpp index 454aa45..59343f5 100644 --- a/test/signal_engine/test_ext_registry.cpp +++ b/test/signal_engine/test_ext_registry.cpp @@ -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)); diff --git a/test/signal_engine/test_failure_mode.cpp b/test/signal_engine/test_failure_mode.cpp index c915e3f..0137e3d 100644 --- a/test/signal_engine/test_failure_mode.cpp +++ b/test/signal_engine/test_failure_mode.cpp @@ -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); } }; diff --git a/test/signal_engine/test_health_diagnostics.cpp b/test/signal_engine/test_health_diagnostics.cpp index 8069979..3302bfe 100644 --- a/test/signal_engine/test_health_diagnostics.cpp +++ b/test/signal_engine/test_health_diagnostics.cpp @@ -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); } diff --git a/test/signal_engine/test_signal_engine_phase4.cpp b/test/signal_engine/test_signal_engine_phase4.cpp index 480ceff..eae4514 100644 --- a/test/signal_engine/test_signal_engine_phase4.cpp +++ b/test/signal_engine/test_signal_engine_phase4.cpp @@ -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") { diff --git a/test/signal_engine/test_signal_engine_robustness.cpp b/test/signal_engine/test_signal_engine_robustness.cpp index d48b679..ef02096 100644 --- a/test/signal_engine/test_signal_engine_robustness.cpp +++ b/test/signal_engine/test_signal_engine_robustness.cpp @@ -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")