feat: extract ModuLisp engine from uSEQ (src-useq @1081ae8)

This commit is contained in:
w1n5t0n 2026-08-17 14:38:51 +03:00
parent 85f49d58d9
commit e51c490d09
73 changed files with 50985 additions and 9 deletions

14
.gitignore vendored
View file

@ -11,15 +11,6 @@
*.gch
*.pch
# Compiled Dynamic libraries
*.so
*.dylib
*.dll
# Fortran module files
*.mod
*.smod
# Compiled Static libraries
*.lai
*.la
@ -30,3 +21,8 @@
*.exe
*.out
*.app
# Build dirs / tooling
build/
.pio/
compile_commands.json

36
ALIGNMENT.md Normal file
View file

@ -0,0 +1,36 @@
# ALIGNMENT — ModuLisp standalone repo
## Mission
Give the ModuLisp engine — the portable language + DAG signal-engine core of
uSEQ — its own repository so it can evolve and be tested independently of the
firmware, and be consumed two ways:
1. **Host builds** (`meson setup build && ninja -C build && meson test -C
build`) — the unit-test surface for engine work.
2. **Arduino library** (via `library.properties`) — included by the RP2350
uSEQ firmware.
The uSEQ tree remains the system of record for the full product; this repo is
the engine core only. Divergence policy: changes land here first (with tests),
and the uSEQ firmware updates its consumption, not the other way round — until
uSEQ's own copy is retired.
## Open questions
1. **License.** The uSEQ tree carries `license/cern_ohl_s_v2.txt` (a hardware
license, wrong fit for C++ sources). No software license has been chosen for
this extracted engine; nothing here is licensed for redistribution yet.
2. **Test exclusions.** `test_flash_storage.cpp`, `test_wasm_wrapper_projection.cpp`,
and `test_osc_sine_nodedef.cpp` stay behind in uSEQ because they link the
firmware, wasm, and nodedef modules respectively (details in README.md). If
those modules are ever extracted, their tests should follow.
3. **Upstream sync.** This extraction was a one-time copy of the working tree
at src-useq git `1081ae8` (2026-08-17). There is no automation keeping this
repo in sync with later uSEQ engine changes; the first divergence will need
a cherry-pick/replay decision.
4. **`modulisp/` crate is a stub.** Only `modulisp/lisp/symbol_intern.h` lives
there today. The expected growth path is that language-level machinery
moves out of `signal_engine/` into `modulisp/` over time; the sibling
directory layout under `src/` exists to keep relative includes stable while
that happens.

37
MAP.md Normal file
View file

@ -0,0 +1,37 @@
# MAP — ModuLisp repo inventory
```
modulisp/
├── meson.build # project root: useq_utils → useq_devtools → useq_signal_engine static libs
├── library.properties # Arduino library manifest (ModuLisp 0.1.0, includes=src/signal_engine/signal_engine.h)
├── src/
│ ├── pch.h # precompiled header (std includes)
│ ├── modulisp/
│ │ └── lisp/symbol_intern.h # header-only symbol interning table
│ ├── signal_engine/ # 11 .cpp + 12 .h + symbols.def
│ │ ├── token.{h,cpp} # tokenizer
│ │ ├── cell_store.{h,cpp} # S-expression cell arena
│ │ ├── node_pool.{h,cpp} # fixed-pool DAG node storage + traversal
│ │ ├── graph_builder.{h,cpp} # cell graph → node DAG compiler
│ │ ├── compiler_pipeline.{h,cpp} # source → tokens → cells → DAG orchestration
│ │ ├── cold_eval.{h,cpp} + eval_ops.h # constant folding / cold evaluation
│ │ ├── executor.{h,cpp} # tick-time DAG executor
│ │ ├── 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
│ │ ├── diagnostics.{h,cpp} # health/persistent-output diagnostics
│ │ ├── signal_engine.h # umbrella header
│ │ ├── types.h, build_profile.h # shared types, build tuning knobs
│ │ └── symbols.def # builtin symbol catalogue (X-macro list)
│ ├── utils/ # string.{h,cpp}, common.{h,cpp}, itoa.{h,cpp}, log.{h,cpp},
│ │ # json_builder.h, json_cursor.h, dtostrf.h, serial_message.h,
│ │ # time.h, compiler_config.h
│ ├── devtools/ # devtools.{h,cpp} — USEQ_DEVTOOLS-gated telemetry
│ └── ports/ # IStorage.h, II2CTransport.h, mocks/{MockStorage.h,MockI2CBus.h}
└── test/
├── meson.build # 17 Catch2 executables, fresh file
├── catch.hpp # vendored Catch2 v2 (from src-useq/test/catch.hpp)
└── signal_engine/ # 17 test .cpp (host-only subset; exclusions in README.md)
```
Build artifacts: `build/` (meson/ninja). 3 libs + 17 test executables.

9
library.properties Normal file
View file

@ -0,0 +1,9 @@
name=ModuLisp
version=0.1.0
author=Dimi (w1n5t0n)
maintainer=Dimi (w1n5t0n) <w1n5t0n@lnfinitemonkeys.org>
sentence=ModuLisp live-coding signal engine extracted from uSEQ
paragraph=ModuLisp language, DAG signal engine, external-input/sink registries
url=https://code.lnfinitemonkeys.org/w1n5t0n/modulisp
includes=src/signal_engine/signal_engine.h
architectures=*

107
meson.build Normal file
View file

@ -0,0 +1,107 @@
# ModuLisp — standalone build of the portable engine extracted from uSEQ.
#
# Provenance: extracted from /home/w1n5t0n/src/useq-perform/src-useq @ git
# 1081ae8 (working-tree state, 2026-08-17). Library structure, flags and
# source lists mirror src-useq/meson.build; the firmware library and every
# firmware/WASM/nodedef-dependent test are intentionally absent.
project('modulisp', 'cpp',
default_options : ['cpp_std=c++17'],
)
# Add global compiler arguments with sensible warning configuration
# (identical to src-useq/meson.build).
common_warning_args = [
'-Wall', # Enable most warnings
'-Wextra', # Enable extra warnings
'-Wpedantic', # Pedantic warnings for ISO C++ compliance
'-Wshadow', # Warn about variable shadowing
'-Wunused', # Warn about unused variables/functions
'-Wuninitialized', # Warn about uninitialized variables
'-Wdouble-promotion', # Warn about float to double promotions
'-Wformat=2', # Strict format string checking
'-Wnull-dereference', # Warn about null pointer dereferences
]
add_global_arguments(common_warning_args, language : 'cpp')
# Define include directories
src_inc = include_directories('src')
# Define precompiled header for faster compilation
pch_file = 'src/pch.h'
# Host (non-Arduino) compile flags, mirroring src-useq with the always-on
# test configuration baked in: signal engine + devtools telemetry enabled.
standalone_args = [
'-DUSE_OWN_ARDUINO_STR',
'-DUSE_STD_IO',
'-DNO_ETL',
'-D__not_in_flash(section)=',
'-D__not_in_flash_func(x)=',
'-DENABLE_SIGNAL_ENGINE',
'-DUSEQ_DEVTOOLS=1',
]
# === STATIC LIBRARIES FOR PROPER DEPENDENCY TRACKING ===
# Utils library - lowest level, no dependencies
utils_lib = static_library('useq_utils',
'src/utils/string.cpp',
'src/utils/common.cpp',
'src/utils/itoa.cpp',
'src/utils/log.cpp',
include_directories : src_inc,
cpp_args : standalone_args,
cpp_pch : pch_file,
)
# Create a dependency object for utils
utils_dep = declare_dependency(
link_with : utils_lib,
include_directories : src_inc
)
# Devtools library - compile-time-gated telemetry instrumentation
devtools_inc = include_directories('src/devtools')
devtools_lib = static_library('useq_devtools',
'src/devtools/devtools.cpp',
include_directories : [src_inc, devtools_inc],
cpp_args : standalone_args,
cpp_pch : pch_file,
dependencies : utils_dep,
)
devtools_dep = declare_dependency(
link_with : devtools_lib,
include_directories : [src_inc, devtools_inc],
dependencies : utils_dep
)
# Signal engine library - depends on utils (symbol_intern.h is header-only)
signal_engine_lib = static_library('useq_signal_engine',
'src/signal_engine/diagnostics.cpp',
'src/signal_engine/token.cpp',
'src/signal_engine/cell_store.cpp',
'src/signal_engine/node_pool.cpp',
'src/signal_engine/executor.cpp',
'src/signal_engine/graph_builder.cpp',
'src/signal_engine/compiler_pipeline.cpp',
'src/signal_engine/cold_eval.cpp',
'src/signal_engine/state_registry.cpp',
'src/signal_engine/synth_registry.cpp',
'src/signal_engine/synth_graph.cpp',
include_directories : src_inc,
cpp_args : standalone_args,
cpp_pch : pch_file,
dependencies : [utils_dep, devtools_dep],
)
signal_engine_dep = declare_dependency(
link_with : signal_engine_lib,
include_directories : src_inc,
dependencies : [utils_dep, devtools_dep]
)
# Add test subdirectory
subdir('test')

916
src/devtools/devtools.cpp Normal file
View file

@ -0,0 +1,916 @@
#include "devtools.h"
#if USEQ_DEVTOOLS
#include "../signal_engine/signal_engine.h"
#include "../utils/json_builder.h"
#include "../utils/json_cursor.h"
#include "../utils/log.h"
#include <cstring>
#include <climits>
#if defined(ARDUINO_ARCH_RP2040)
extern "C" {
extern uint8_t __StackBottom;
extern uint8_t __StackTop;
}
#endif
#ifdef USE_STD_IO
#include <chrono>
static uint32_t dt_micros() {
static const auto start = std::chrono::steady_clock::now();
auto now = std::chrono::steady_clock::now();
return static_cast<uint32_t>(
std::chrono::duration_cast<std::chrono::microseconds>(now - start).count());
}
#else
#include <Arduino.h>
static uint32_t dt_micros() { return micros(); }
#endif
// ── Output name table ──────────────────────────────────────────────────────
static const char* output_name(uint16_t idx) {
static const char* names[] = {
"a1","a2","a3","a4","a5","a6","a7","a8",
"d1","d2","d3","d4","d5","d6","d7","d8",
"s1","s2","s3","s4","s5","s6","s7","s8",
};
if (idx < 24) return names[idx];
return "?";
}
// ── NodeOp name table ──────────────────────────────────────────────────────
static const char* node_op_name(sig::NodeOp op) {
switch (op) {
case sig::NodeOp::Const: return "Const";
case sig::NodeOp::RawTimeLoad: return "RawTimeLoad";
case sig::NodeOp::CellLoad: return "CellLoad";
case sig::NodeOp::InputLoad: return "InputLoad";
case sig::NodeOp::PrevOutputLoad: return "PrevOutputLoad";
case sig::NodeOp::Add: return "Add";
case sig::NodeOp::Sub: return "Sub";
case sig::NodeOp::Mul: return "Mul";
case sig::NodeOp::Div: return "Div";
case sig::NodeOp::Mod: return "Mod";
case sig::NodeOp::Expt: return "Expt";
case sig::NodeOp::Min: return "Min";
case sig::NodeOp::Max: return "Max";
case sig::NodeOp::Neg: return "Neg";
case sig::NodeOp::Abs: return "Abs";
case sig::NodeOp::Floor: return "Floor";
case sig::NodeOp::Ceil: return "Ceil";
case sig::NodeOp::Frac: return "Frac";
case sig::NodeOp::Sqrt: return "Sqrt";
case sig::NodeOp::Clamp: return "Clamp";
case sig::NodeOp::Sin: return "Sin";
case sig::NodeOp::Cos: return "Cos";
case sig::NodeOp::Tan: return "Tan";
case sig::NodeOp::USin: return "USin";
case sig::NodeOp::UCos: return "UCos";
case sig::NodeOp::Tri: return "Tri";
case sig::NodeOp::Sqr: return "Sqr";
case sig::NodeOp::Pulse: return "Pulse";
case sig::NodeOp::CmpGt: return "CmpGt";
case sig::NodeOp::CmpLt: return "CmpLt";
case sig::NodeOp::CmpGe: return "CmpGe";
case sig::NodeOp::CmpLe: return "CmpLe";
case sig::NodeOp::CmpEq: return "CmpEq";
case sig::NodeOp::Not: return "Not";
case sig::NodeOp::And: return "And";
case sig::NodeOp::Or: return "Or";
case sig::NodeOp::Select: return "Select";
case sig::NodeOp::VecIndex: return "VecIndex";
case sig::NodeOp::VecLerp: return "VecLerp";
case sig::NodeOp::BiToUni: return "BiToUni";
case sig::NodeOp::UniToBi: return "UniToBi";
case sig::NodeOp::Scale: return "Scale";
case sig::NodeOp::Lerp: return "Lerp";
case sig::NodeOp::HashIndex: return "HashIndex";
case sig::NodeOp::LoadState: return "LoadState";
case sig::NodeOp::LoadDt: return "LoadDt";
case sig::NodeOp::SlotLoad: return "SlotLoad";
}
return "Unknown";
}
static const char* health_name(const sig::NodePool& pool,
uint16_t output_index) {
return sig::output_health_to_cstr(
sig::output_health(pool, output_index));
}
static bool op_has_imm(sig::NodeOp op) {
return op == sig::NodeOp::Const || op == sig::NodeOp::CellLoad ||
op == sig::NodeOp::InputLoad || op == sig::NodeOp::PrevOutputLoad ||
op == sig::NodeOp::VecIndex || op == sig::NodeOp::VecLerp ||
op == sig::NodeOp::LoadState || op == sig::NodeOp::SlotLoad;
}
static bool op_is_binary(sig::NodeOp op) {
return (op >= sig::NodeOp::Add && op <= sig::NodeOp::Max) ||
(op >= sig::NodeOp::CmpGt && op <= sig::NodeOp::CmpEq) ||
op == sig::NodeOp::And || op == sig::NodeOp::Or ||
op == sig::NodeOp::Pulse;
}
static bool op_is_ternary(sig::NodeOp op) {
return op == sig::NodeOp::Select || op == sig::NodeOp::Scale ||
op == sig::NodeOp::Lerp || op == sig::NodeOp::Clamp;
}
// ══════════════════════════════════════════════════════════════════════════
// State
// ══════════════════════════════════════════════════════════════════════════
namespace {
enum Channel : uint8_t {
CH_TICK, CH_GRAPH, CH_STATE, CH_EVAL, CH_RESOURCES, CH_IO, CH_PROTOCOL, CH_COUNT
};
enum Mode : uint8_t { OFF, POLL, STREAM, EVENTS };
struct DevToolsState {
sig::SignalEngine* engine = nullptr;
Mode modes[CH_COUNT] = {};
uint32_t stream_interval_us = 100000;
struct {
uint32_t tick_start_us = 0;
uint32_t phase_start_us = 0;
uint32_t phase_durations[8] = {};
const char* phase_names[8] = {};
uint8_t phase_count = 0;
uint32_t tick_total_us = 0;
uint32_t tick_count = 0;
uint32_t win_min = UINT32_MAX;
uint32_t win_max = 0;
uint32_t win_sum = 0;
uint32_t win_count = 0;
} tick;
struct {
uint32_t start_us = 0;
uint32_t last_us = 0;
uint32_t max_us = 0;
uint32_t count = 0;
uint32_t error_count = 0;
} eval;
struct Event {
uint32_t ts_us = 0;
const char* channel = nullptr;
const char* message = nullptr;
const char* detail_str = nullptr;
int detail_int = 0;
bool has_int = false;
};
Event events[32] = {};
uint8_t ev_head = 0;
uint8_t ev_count = 0;
struct Counter { const char* name = nullptr; uint32_t value = 0; };
Counter counters[16] = {};
uint8_t counter_count = 0;
struct Gauge { const char* name = nullptr; uint32_t value = 0; uint32_t capacity = 0; };
Gauge gauges[16] = {};
uint8_t gauge_count = 0;
struct {
uint32_t heap_free = 0;
uint32_t heap_min_free = UINT32_MAX;
uint32_t core0_stack_capacity = 0;
uint32_t core0_stack_high_water = 0;
uintptr_t core0_stack_fill_end = 0;
bool core0_stack_initialized = false;
bool core0_stack_margin_intact = true;
} memory;
uint32_t last_stream_us = 0;
};
static DevToolsState s;
constexpr uint32_t TICK_WINDOW_SIZE = 100;
} // anonymous namespace
// ══════════════════════════════════════════════════════════════════════════
// Collection
// ══════════════════════════════════════════════════════════════════════════
namespace dt {
namespace {
void sample_core0_stack() {
#if defined(ARDUINO_ARCH_RP2040)
if (s.memory.core0_stack_initialized) {
constexpr uintptr_t SDK_STACK_GUARD_BYTES = 32;
constexpr uint8_t STACK_PATTERN = 0xA5;
const uintptr_t bottom = reinterpret_cast<uintptr_t>(&__StackBottom);
const uintptr_t top = reinterpret_cast<uintptr_t>(&__StackTop);
const uintptr_t sample_begin = bottom + SDK_STACK_GUARD_BYTES;
uintptr_t first_touched = sample_begin;
while (first_touched < s.memory.core0_stack_fill_end &&
*reinterpret_cast<volatile uint8_t*>(first_touched) ==
STACK_PATTERN) {
first_touched++;
}
const uint32_t used = static_cast<uint32_t>(top - first_touched);
if (used > s.memory.core0_stack_high_water)
s.memory.core0_stack_high_water = used;
s.memory.core0_stack_margin_intact =
*reinterpret_cast<volatile uint8_t*>(sample_begin) ==
STACK_PATTERN;
}
#endif
}
} // namespace
void init(sig::SignalEngine* engine) {
s = DevToolsState{};
s.engine = engine;
#if defined(ARDUINO_ARCH_RP2040)
// The RP2040 SDK may configure the lowest aligned 32-byte stack
// subregion as an MPU guard. Keep the watermark outside that region.
constexpr uintptr_t SDK_STACK_GUARD_BYTES = 32;
constexpr uintptr_t STACK_SAMPLE_GUARD = 128;
constexpr uint8_t STACK_PATTERN = 0xA5;
uintptr_t stack_pointer = 0;
asm volatile("mov %0, sp" : "=r"(stack_pointer));
const uintptr_t bottom = reinterpret_cast<uintptr_t>(&__StackBottom);
const uintptr_t top = reinterpret_cast<uintptr_t>(&__StackTop);
s.memory.core0_stack_capacity = static_cast<uint32_t>(top - bottom);
const uintptr_t sample_begin = bottom + SDK_STACK_GUARD_BYTES;
if (stack_pointer > sample_begin + STACK_SAMPLE_GUARD &&
stack_pointer <= top) {
const uintptr_t fill_end = stack_pointer - STACK_SAMPLE_GUARD;
std::memset(reinterpret_cast<void*>(sample_begin), STACK_PATTERN,
fill_end - sample_begin);
s.memory.core0_stack_fill_end = fill_end;
s.memory.core0_stack_initialized = true;
}
#endif
sample_runtime_memory();
}
void sample_runtime_memory() {
int heap = free_heap();
if (heap < 0) heap = 0;
s.memory.heap_free = static_cast<uint32_t>(heap);
if (s.memory.heap_free < s.memory.heap_min_free)
s.memory.heap_min_free = s.memory.heap_free;
}
void tick_begin() {
s.tick.tick_start_us = dt_micros();
s.tick.phase_start_us = s.tick.tick_start_us;
s.tick.phase_count = 0;
}
void mark(const char* phase) {
uint32_t now = dt_micros();
if (s.tick.phase_count < 8) {
s.tick.phase_durations[s.tick.phase_count] = now - s.tick.phase_start_us;
s.tick.phase_names[s.tick.phase_count] = phase;
s.tick.phase_count++;
}
s.tick.phase_start_us = now;
}
void tick_end() {
s.tick.tick_total_us = dt_micros() - s.tick.tick_start_us;
s.tick.tick_count++;
uint32_t t = s.tick.tick_total_us;
if (t < s.tick.win_min) s.tick.win_min = t;
if (t > s.tick.win_max) s.tick.win_max = t;
s.tick.win_sum += t;
s.tick.win_count++;
if (s.tick.win_count >= TICK_WINDOW_SIZE) {
s.tick.win_min = UINT32_MAX;
s.tick.win_max = 0;
s.tick.win_sum = 0;
s.tick.win_count = 0;
}
}
void eval_begin() {
s.eval.start_us = dt_micros();
event("eval", "begin");
}
void eval_end(bool success) {
s.eval.last_us = dt_micros() - s.eval.start_us;
if (s.eval.last_us > s.eval.max_us) s.eval.max_us = s.eval.last_us;
s.eval.count++;
count("eval_count");
if (!success) s.eval.error_count++;
event("eval", success ? "done" : "error",
static_cast<int>(s.eval.last_us));
}
void event(const char* channel, const char* message, const char* detail) {
auto& e = s.events[s.ev_head];
e.ts_us = dt_micros();
e.channel = channel;
e.message = message;
e.detail_str = detail;
e.has_int = false;
s.ev_head = (s.ev_head + 1) & 31;
if (s.ev_count < 32) s.ev_count++;
}
void event(const char* channel, const char* message, int detail) {
auto& e = s.events[s.ev_head];
e.ts_us = dt_micros();
e.channel = channel;
e.message = message;
e.detail_str = nullptr;
e.detail_int = detail;
e.has_int = true;
s.ev_head = (s.ev_head + 1) & 31;
if (s.ev_count < 32) s.ev_count++;
}
void count(const char* name) {
for (uint8_t i = 0; i < s.counter_count; ++i) {
if (std::strcmp(s.counters[i].name, name) == 0) {
s.counters[i].value++;
return;
}
}
if (s.counter_count < 16) {
s.counters[s.counter_count] = {name, 1};
s.counter_count++;
}
}
void gauge(const char* name, uint32_t value) {
for (uint8_t i = 0; i < s.gauge_count; ++i) {
if (std::strcmp(s.gauges[i].name, name) == 0) {
s.gauges[i].value = value;
return;
}
}
if (s.gauge_count < 16) {
s.gauges[s.gauge_count] = {name, value, 0};
s.gauge_count++;
}
}
void gauge(const char* name, uint32_t value, uint32_t capacity) {
for (uint8_t i = 0; i < s.gauge_count; ++i) {
if (std::strcmp(s.gauges[i].name, name) == 0) {
s.gauges[i].value = value;
s.gauges[i].capacity = capacity;
return;
}
}
if (s.gauge_count < 16) {
s.gauges[s.gauge_count] = {name, value, capacity};
s.gauge_count++;
}
}
// ══════════════════════════════════════════════════════════════════════════
// JSON helpers
// ══════════════════════════════════════════════════════════════════════════
static void send_json(WriteFn write_fn, const String& json) {
write_fn(json.c_str(), json.length());
}
static const char* mode_str(Mode m) {
switch (m) {
case OFF: return "off";
case POLL: return "poll";
case STREAM: return "stream";
case EVENTS: return "events";
}
return "off";
}
static Mode parse_mode(const char* str) {
if (!str) return OFF;
if (strcmp(str, "poll") == 0) return POLL;
if (strcmp(str, "stream") == 0) return STREAM;
if (strcmp(str, "events") == 0) return EVENTS;
return OFF;
}
static size_t extract_str(const char* json, size_t len,
const char* key, char* out, size_t out_sz) {
if (!out || out_sz == 0) return 0;
out[0] = '\0';
useq::json::ObjectCursor object(json, len);
useq::json::Value value;
return object.find(key, value) &&
useq::json::copy_string(value, out, out_sz)
? strlen(out)
: 0;
}
static int extract_int(const char* json, size_t len, const char* key, int def) {
useq::json::ObjectCursor object(json, len);
useq::json::Value value;
int parsed = def;
return object.find(key, value) && useq::json::integer(value, parsed)
? parsed
: def;
}
// ══════════════════════════════════════════════════════════════════════════
// Channel serializers
// ══════════════════════════════════════════════════════════════════════════
static String serialize_tick() {
JsonBuilder j;
j.object_begin()
.field("tick_count", static_cast<int>(s.tick.tick_count))
.field("last_total_us", static_cast<int>(s.tick.tick_total_us));
{
JsonBuilder phases;
phases.object_begin();
for (uint8_t i = 0; i < s.tick.phase_count; ++i) {
if (s.tick.phase_names[i])
phases.field(s.tick.phase_names[i], static_cast<int>(s.tick.phase_durations[i]));
}
phases.object_end();
j.field_raw("phases", phases.build());
}
uint32_t avg = s.tick.win_count > 0 ? s.tick.win_sum / s.tick.win_count : 0;
j.field("win_min_us", static_cast<int>(s.tick.win_min == UINT32_MAX ? 0 : s.tick.win_min))
.field("win_max_us", static_cast<int>(s.tick.win_max))
.field("win_avg_us", static_cast<int>(avg))
.field("win_count", static_cast<int>(s.tick.win_count));
j.object_end();
return j.build();
}
static String serialize_graph(const char* output_filter) {
if (!s.engine) return "[]";
const auto& pool = s.engine->pool;
JsonBuilder j;
j.array_begin_unkeyed();
for (uint16_t oi = 0; oi < 24; ++oi) {
const auto& slot = pool.outputs[oi];
if (slot.root_node == sig::NODE_NONE) continue;
const char* name = output_name(oi);
if (output_filter && output_filter[0] != '\0' && strcmp(output_filter, name) != 0)
continue;
j.object_begin()
.field("name", name)
.field("health", health_name(pool, oi))
.field("root", static_cast<int>(slot.root_node));
// Nodes
j.array_begin("nodes");
for (uint16_t ei = 0; ei < pool.exec_count; ++ei) {
uint16_t ni = pool.exec_order[ei];
if (ni >= pool.node_count) continue;
const auto& node = pool.nodes[ni];
j.object_begin()
.field("id", static_cast<int>(ni))
.field("op", node_op_name(node.op));
if (op_has_imm(node.op))
j.field("imm", static_cast<int>(node.imm));
if (node.input_a != sig::NODE_NONE)
j.field("a", static_cast<int>(node.input_a));
if ((op_is_binary(node.op) || op_is_ternary(node.op)) &&
node.input_b != sig::NODE_NONE)
j.field("b", static_cast<int>(node.input_b));
if (op_is_ternary(node.op) && node.input_c != sig::NODE_NONE)
j.field("c", static_cast<int>(node.input_c));
j.object_end();
}
j.array_end();
// Source text
if (oi < sig::MAX_OUTPUTS && s.engine->output_sources[oi].has_source) {
const auto& os = s.engine->output_sources[oi];
const char* src = s.engine->arena.read(os.arena_offset);
if (src)
j.field("source", String(src, static_cast<unsigned int>(os.arena_length)));
}
// Dependencies
const auto& deps = pool.output_deps[oi];
if (deps.count > 0) {
JsonBuilder da;
da.array_begin_unkeyed();
for (uint8_t d = 0; d < deps.count; ++d) {
const String& sym = getSymbolString(deps.cells[d]);
if (sym.length() > 0)
da.field("", sym); // unkeyed string won't work; use raw
}
da.array_end();
j.field_raw("deps", da.build());
}
j.object_end();
}
j.array_end();
return j.build();
}
static String serialize_state() {
if (!s.engine) return "{}";
const auto& engine = *s.engine;
JsonBuilder j;
j.object_begin()
.field("is_playing", engine.state.is_playing);
// Cells
j.array_begin("cells");
for (uint16_t i = 1; i < sig::MAX_CELLS; ++i) {
const auto& cell = engine.cells.cells[i];
if (cell.kind == sig::CellKind::Empty) continue;
const String& name = getSymbolString(static_cast<sig::SymbolID>(i));
j.object_begin().field("name", name);
switch (cell.kind) {
case sig::CellKind::Number: j.field("kind", "number"); break;
case sig::CellKind::Data: j.field("kind", "data"); break;
case sig::CellKind::Callable: j.field("kind", "callable"); break;
case sig::CellKind::Nil: j.field("kind", "nil"); break;
default: break;
}
j.object_end();
}
j.array_end();
// Outputs
j.array_begin("outputs");
for (uint16_t i = 0; i < 24; ++i) {
j.object_begin()
.field("name", output_name(i))
.field("health", health_name(engine.pool, i))
.object_end();
}
j.array_end();
// State slots
if (engine.pool.state_slot_count > 0) {
j.array_begin("state_slots");
for (uint16_t i = 0; i < engine.pool.state_slot_count; ++i) {
j.object_begin()
.field("id", static_cast<int>(i))
.object_end();
}
j.array_end();
}
j.object_end();
return j.build();
}
static String serialize_eval_timing() {
JsonBuilder j;
j.object_begin()
.field("last_us", static_cast<int>(s.eval.last_us))
.field("max_us", static_cast<int>(s.eval.max_us))
.field("count", static_cast<int>(s.eval.count))
.field("error_count", static_cast<int>(s.eval.error_count))
.object_end();
return j.build();
}
static String serialize_eval() {
JsonBuilder j;
j.object_begin()
.field("last_us", static_cast<int>(s.eval.last_us))
.field("max_us", static_cast<int>(s.eval.max_us))
.field("count", static_cast<int>(s.eval.count))
.field("error_count", static_cast<int>(s.eval.error_count));
{
JsonBuilder events;
events.array_begin_unkeyed();
uint8_t count = s.ev_count < 32 ? s.ev_count : 32;
for (uint8_t i = 0; i < count; ++i) {
uint8_t idx = (s.ev_head - 1 - i) & 31;
const auto& e = s.events[idx];
if (!e.channel) continue;
events.object_begin()
.field("ts_us", static_cast<int>(e.ts_us))
.field("channel", e.channel)
.field("message", e.message);
if (e.has_int)
events.field("detail", e.detail_int);
else if (e.detail_str)
events.field("detail", e.detail_str);
events.object_end();
}
events.array_end();
j.field_raw("events", events.build());
}
j.object_end();
return j.build();
}
static String serialize_resources() {
sample_runtime_memory();
sample_core0_stack();
JsonBuilder j;
j.object_begin()
.field("heap_free", static_cast<int>(s.memory.heap_free))
.field("heap_min_free", static_cast<int>(s.memory.heap_min_free))
.field("core0_stack_margin_intact",
s.memory.core0_stack_margin_intact);
{
JsonBuilder stack;
stack.object_begin()
.field("initialized", s.memory.core0_stack_initialized)
.field("used", static_cast<int>(s.memory.core0_stack_high_water))
.field("capacity", static_cast<int>(s.memory.core0_stack_capacity))
.object_end();
j.field_raw("core0_stack", stack.build());
}
if (s.engine) {
uint16_t cells_used = 0;
for (uint16_t i = 0; i < sig::MAX_CELLS; ++i) {
if (s.engine->cells.cells[i].kind != sig::CellKind::Empty)
cells_used++;
}
uint16_t data_entries_used = 0;
if (s.engine->cells.data_table_count > 0) {
const uint16_t last = s.engine->cells.data_table_count - 1;
data_entries_used = static_cast<uint16_t>(
s.engine->cells.data_offsets[last] +
s.engine->cells.data_lengths[last]);
}
auto ratio = [&j](const char* name, uint32_t used,
uint32_t capacity) {
JsonBuilder value;
value.object_begin()
.field("used", static_cast<int>(used))
.field("capacity", static_cast<int>(capacity))
.object_end();
j.field_raw(name, value.build());
};
ratio("nodes", s.engine->pool.node_count, sig::MAX_TOTAL_NODES);
ratio("arena", s.engine->arena.write_head, sig::SOURCE_ARENA_SIZE);
ratio("cells", cells_used, sig::MAX_CELLS);
ratio("data_entries", data_entries_used, sig::MAX_DATA_ENTRIES);
ratio("state_slots", s.engine->pool.state_slot_count,
sig::MAX_STATE_SLOTS);
ratio("live_slots", s.engine->pool.live_slot_count,
sig::MAX_LIVE_SLOTS);
#if USEQ_HAS_SYNTH_ENGINE
ratio("synth_declarations", s.engine->synth_graph.declaration_count(),
sig::MAX_SYNTH_DECLARATIONS);
ratio("synth_controls", s.engine->synth_graph.control_count(),
sig::MAX_SYNTH_CONTROLS);
#endif
}
for (uint8_t i = 0; i < s.gauge_count; ++i) {
if (s.gauges[i].capacity > 0) {
JsonBuilder g;
g.object_begin()
.field("used", static_cast<int>(s.gauges[i].value))
.field("capacity", static_cast<int>(s.gauges[i].capacity))
.object_end();
j.field_raw(s.gauges[i].name, g.build());
} else {
j.field(s.gauges[i].name, static_cast<int>(s.gauges[i].value));
}
}
j.object_end();
return j.build();
}
static String serialize_io() {
if (!s.engine) return "{}";
JsonBuilder j;
j.object_begin();
j.array_begin("outputs");
for (uint16_t i = 0; i < 24; ++i) {
j.object_begin()
.field("name", output_name(i))
.object_end();
}
j.array_end();
j.object_end();
return j.build();
}
static String serialize_protocol() {
JsonBuilder j;
j.object_begin();
for (uint8_t i = 0; i < s.counter_count; ++i)
j.field(s.counters[i].name, static_cast<int>(s.counters[i].value));
if (s.tick.tick_count > 0 && s.tick.win_count > 0) {
uint32_t avg_us = s.tick.win_sum / s.tick.win_count;
uint32_t uptime_s = static_cast<uint32_t>(
(static_cast<uint64_t>(s.tick.tick_count) * avg_us) / 1000000ULL);
j.field("uptime_s", static_cast<int>(uptime_s));
}
j.object_end();
return j.build();
}
// ══════════════════════════════════════════════════════════════════════════
// Protocol handler
// ══════════════════════════════════════════════════════════════════════════
static const struct { const char* name; Channel ch; } s_ch_map[] = {
{"tick", CH_TICK}, {"graph", CH_GRAPH}, {"state", CH_STATE},
{"eval", CH_EVAL}, {"resources", CH_RESOURCES},
{"io", CH_IO}, {"protocol", CH_PROTOCOL},
};
static Channel parse_channel_name(const char* name) {
for (const auto& m : s_ch_map)
if (strcmp(name, m.name) == 0) return m.ch;
return CH_COUNT;
}
static void handle_capabilities(const char* json, size_t len, WriteFn write_fn) {
char req_id[64] = {};
extract_str(json, len, "requestId", req_id, sizeof(req_id));
static const struct { const char* name; const char* modes; const char* desc; } ch_info[] = {
{"tick", "[\"off\",\"poll\",\"stream\"]", "Per-phase tick timing (us)"},
{"graph", "[\"off\",\"poll\"]", "Signal graph topology and node values"},
{"state", "[\"off\",\"poll\"]", "Cells, outputs, health, LKG"},
{"eval", "[\"off\",\"events\"]", "Compilation events and recompile cascades"},
{"resources", "[\"off\",\"poll\",\"stream\"]", "Heap, node pool, arena utilization"},
{"io", "[\"off\",\"poll\",\"stream\"]", "Hardware input and output values"},
{"protocol", "[\"off\",\"poll\",\"stream\"]", "Message counters, drops, backpressure"},
};
JsonBuilder j;
j.object_begin()
.field("type", "response")
.field("requestId", req_id)
.field("success", true)
.field("devtools", true);
j.array_begin("channels");
for (int i = 0; i < CH_COUNT; ++i) {
j.object_begin()
.field("name", ch_info[i].name)
.field_raw("modes", ch_info[i].modes)
.field("current", mode_str(s.modes[i]))
.field("description", ch_info[i].desc)
.object_end();
}
j.array_end();
j.object_end();
send_json(write_fn, j.build());
}
static void handle_configure(const char* json, size_t len, WriteFn write_fn) {
char req_id[64] = {};
extract_str(json, len, "requestId", req_id, sizeof(req_id));
for (const auto& cm : s_ch_map) {
char mode_buf[16] = {};
if (extract_str(json, len, cm.name, mode_buf, sizeof(mode_buf)) > 0)
s.modes[cm.ch] = parse_mode(mode_buf);
}
int rate = extract_int(json, len, "streamRateHz", 0);
if (rate >= 1 && rate <= 100)
s.stream_interval_us = 1000000 / static_cast<uint32_t>(rate);
JsonBuilder j;
j.object_begin()
.field("type", "response")
.field("requestId", req_id)
.field("success", true);
{
JsonBuilder ch;
ch.object_begin();
for (const auto& cm : s_ch_map)
ch.field(cm.name, mode_str(s.modes[cm.ch]));
ch.object_end();
j.field_raw("channels", ch.build());
}
j.object_end();
send_json(write_fn, j.build());
}
static void handle_query(const char* json, size_t len, WriteFn write_fn) {
char req_id[64] = {};
char channel[32] = {};
char output[8] = {};
extract_str(json, len, "requestId", req_id, sizeof(req_id));
extract_str(json, len, "channel", channel, sizeof(channel));
extract_str(json, len, "output", output, sizeof(output));
String data;
Channel ch = parse_channel_name(channel);
switch (ch) {
case CH_TICK: data = serialize_tick(); break;
case CH_GRAPH: data = serialize_graph(output); break;
case CH_STATE: data = serialize_state(); break;
case CH_EVAL:
data = strcmp(output, "timing") == 0
? serialize_eval_timing() : serialize_eval();
break;
case CH_RESOURCES: data = serialize_resources(); break;
case CH_IO: data = serialize_io(); break;
case CH_PROTOCOL: data = serialize_protocol(); break;
default: {
JsonBuilder err;
err.object_begin()
.field("type", "response")
.field("requestId", req_id)
.field("success", false)
.field("error", "unknown channel")
.object_end();
send_json(write_fn, err.build());
return;
}
}
JsonBuilder j;
j.object_begin()
.field("type", "response")
.field("requestId", req_id)
.field("success", true)
.field("channel", channel)
.field_raw("data", data)
.object_end();
send_json(write_fn, j.build());
}
static void handle_status(const char* json, size_t len, WriteFn write_fn) {
char req_id[64] = {};
extract_str(json, len, "requestId", req_id, sizeof(req_id));
JsonBuilder j;
j.object_begin()
.field("type", "response")
.field("requestId", req_id)
.field("success", true);
{
JsonBuilder ch;
ch.object_begin();
for (const auto& cm : s_ch_map)
ch.field(cm.name, mode_str(s.modes[cm.ch]));
ch.object_end();
j.field_raw("channels", ch.build());
}
j.field("streamRateHz", static_cast<int>(1000000 / s.stream_interval_us));
j.object_end();
send_json(write_fn, j.build());
}
bool handle_debug_message(const char* json, size_t len, WriteFn write_fn) {
char action[32] = {};
extract_str(json, len, "action", action, sizeof(action));
if (strcmp(action, "capabilities") == 0) { handle_capabilities(json, len, write_fn); return true; }
if (strcmp(action, "configure") == 0) { handle_configure(json, len, write_fn); return true; }
if (strcmp(action, "query") == 0) { handle_query(json, len, write_fn); return true; }
if (strcmp(action, "status") == 0) { handle_status(json, len, write_fn); return true; }
return false;
}
void emit_streaming(WriteFn write_fn, bool can_write) {
if (!can_write) return;
uint32_t now = dt_micros();
if (now - s.last_stream_us < s.stream_interval_us) return;
s.last_stream_us = now;
auto emit = [&](Channel ch, const char* name, String (*fn)()) {
if (s.modes[ch] != STREAM) return;
JsonBuilder j;
j.object_begin()
.field("type", "debug")
.field("channel", name)
.field_raw("data", fn())
.object_end();
send_json(write_fn, j.build());
};
emit(CH_TICK, "tick", serialize_tick);
emit(CH_RESOURCES, "resources", serialize_resources);
emit(CH_IO, "io", serialize_io);
emit(CH_PROTOCOL, "protocol", serialize_protocol);
}
} // namespace dt
#endif // USEQ_DEVTOOLS

94
src/devtools/devtools.h Normal file
View file

@ -0,0 +1,94 @@
#ifndef DEVTOOLS_H
#define DEVTOOLS_H
// DevTools: compile-time-gated instrumentation for firmware telemetry.
//
// When USEQ_DEVTOOLS is defined: collects timing, events, counters, gauges
// into fixed-size buffers and exposes them via the "debug" wire protocol.
// When not defined: every function is an empty inline stub that vanishes at -O1.
//
// See docs/specs/devtools.md for the full specification.
#include <cstdint>
#include <cstddef>
// Forward-declare SignalEngine so devtools can accept it without pulling headers
namespace sig { struct SignalEngine; }
namespace dt {
#if USEQ_DEVTOOLS
// ── Tick Profiling ─────────────────────────────────────────────────────────
void tick_begin();
void mark(const char* phase);
void tick_end();
// Measure the compiler/evaluator interval around one wire-level eval. The
// duration excludes serial parsing and response emission, and is retained for
// poll-based target acceptance.
void eval_begin();
void eval_end(bool success);
// ── Events ─────────────────────────────────────────────────────────────────
void event(const char* channel, const char* message,
const char* detail = nullptr);
void event(const char* channel, const char* message, int detail);
// ── Counters (monotonic) ───────────────────────────────────────────────────
void count(const char* name);
// ── Gauges (point-in-time) ─────────────────────────────────────────────────
void gauge(const char* name, uint32_t value);
void gauge(const char* name, uint32_t value, uint32_t capacity);
// ── Initialization ─────────────────────────────────────────────────────────
// Called once from Firmware::init(). Stores the engine pointer for
// graph/state queries. Must be called before any other dt:: function.
void init(sig::SignalEngine* engine);
// Sample heap usage into the monotonic runtime low-water record. The compiler
// calls this immediately after its largest transient allocations; the
// firmware tick samples steady state. The retained stack canary is scanned
// when the resources channel is queried or emitted.
void sample_runtime_memory();
// ── Protocol Integration ───────────────────────────────────────────────────
// Called by SerialProtocol::dispatch_message() for type "debug".
// write_fn emits a JSON string to serial (caller provides the function).
// Returns true if the message was handled.
using WriteFn = void(*)(const char*, size_t);
bool handle_debug_message(const char* json, size_t len, WriteFn write_fn);
// Called once per tick after tick_end(). Emits data for streaming channels.
// Checks backpressure via can_write before emitting.
void emit_streaming(WriteFn write_fn, bool can_write);
#else // !USEQ_DEVTOOLS — everything compiles away
inline void tick_begin() {}
inline void mark(const char*) {}
inline void tick_end() {}
inline void eval_begin() {}
inline void eval_end(bool) {}
inline void event(const char*, const char*, const char* = nullptr) {}
inline void event(const char*, const char*, int) {}
inline void count(const char*) {}
inline void gauge(const char*, uint32_t) {}
inline void gauge(const char*, uint32_t, uint32_t) {}
inline void init(sig::SignalEngine*) {}
inline void sample_runtime_memory() {}
using WriteFn = void(*)(const char*, size_t);
inline bool handle_debug_message(const char*, size_t, WriteFn) { return false; }
inline void emit_streaming(WriteFn, bool) {}
#endif // USEQ_DEVTOOLS
} // namespace dt
#endif // DEVTOOLS_H

View file

@ -0,0 +1,87 @@
#ifndef SYMBOL_INTERN_H_
#define SYMBOL_INTERN_H_
#include "../../utils/string.h"
#include <map> // Using std::map instead of unordered_map due to arduino::String compatibility
#include <vector>
#include <cstdint>
// Symbol interning system for fast symbol comparison
// Instead of comparing strings, we compare integer IDs
class SymbolIntern {
public:
using SymbolID = uint32_t;
static constexpr SymbolID INVALID_ID = 0;
// Get the singleton instance
static SymbolIntern& getInstance() {
static SymbolIntern instance;
return instance;
}
// Intern a symbol and get its ID
// If the symbol already exists, returns the existing ID
SymbolID intern(const String& symbol) {
auto it = symbol_to_id.find(symbol);
if (it != symbol_to_id.end()) {
return it->second;
}
// Allocate new ID
SymbolID id = next_id++;
symbol_to_id[symbol] = id;
// Store the string for reverse lookup if needed
if (id >= id_to_symbol.size()) {
id_to_symbol.resize(id + 1);
}
id_to_symbol[id] = symbol;
return id;
}
// Get the string for a symbol ID (for debugging/display)
const String& getString(SymbolID id) const {
static const String empty_string;
if (id == INVALID_ID || id >= id_to_symbol.size()) {
return empty_string;
}
return id_to_symbol[id];
}
// Get ID without interning (returns INVALID_ID if not found)
SymbolID getID(const String& symbol) const {
auto it = symbol_to_id.find(symbol);
return (it != symbol_to_id.end()) ? it->second : INVALID_ID;
}
private:
SymbolIntern() : next_id(1) {
// Reserve space for common case
id_to_symbol.reserve(256);
// Note: std::map doesn't have reserve()
}
std::map<String, SymbolID> symbol_to_id;
std::vector<String> id_to_symbol;
SymbolID next_id;
// Prevent copying
SymbolIntern(const SymbolIntern&) = delete;
SymbolIntern& operator=(const SymbolIntern&) = delete;
};
// Convenience functions for quick access
inline SymbolIntern::SymbolID internSymbol(const String& symbol) {
return SymbolIntern::getInstance().intern(symbol);
}
inline SymbolIntern::SymbolID internSymbol(const char* str, size_t len) {
return SymbolIntern::getInstance().intern(String(str, (unsigned int)len));
}
inline const String& getSymbolString(SymbolIntern::SymbolID id) {
return SymbolIntern::getInstance().getString(id);
}
#endif // SYMBOL_INTERN_H_

61
src/pch.h Normal file
View file

@ -0,0 +1,61 @@
// Precompiled header for uSEQ project
// Contains stable system includes to speed up compilation
#ifndef USEQ_PCH_H
#define USEQ_PCH_H
// Standard Library - Containers
#include <array>
#include <deque>
#include <map>
#include <queue>
#include <set>
#include <stack>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
// Standard Library - Memory Management
#include <memory>
// Standard Library - Algorithms & Utilities
#include <algorithm>
#include <functional>
#include <optional>
#include <tuple>
#include <utility>
#include <variant>
// Standard Library - Numeric
#include <cmath>
#include <cstdint>
#include <limits>
#include <numeric>
// Standard Library - I/O
#include <fstream>
#include <iomanip>
#include <iostream>
#include <sstream>
// Standard Library - Time & Chrono
#include <chrono>
#include <ctime>
// Standard Library - Other
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <exception>
#include <stdexcept>
#include <type_traits>
#include <typeinfo>
// Conditional Arduino headers (if building for Arduino)
#ifdef ARDUINO
#include <Arduino.h>
#endif
#endif // USEQ_PCH_H

34
src/ports/II2CTransport.h Normal file
View file

@ -0,0 +1,34 @@
#ifndef PORTS_II2C_TRANSPORT_H
#define PORTS_II2C_TRANSPORT_H
#include <cstddef>
#include <cstdint>
namespace firmware
{
using I2CReceiveCallback = void (*)(void* context, const uint8_t* data,
size_t length);
using I2CRequestCallback = size_t (*)(void* context, uint8_t* data, size_t capacity);
// The hardware adapter owns bus mechanics. I2CNetwork owns discovery,
// protocol framing, buffering, and expander state. This keeps the latter
// executable in native tests without emulating Arduino's Wire globals.
struct II2CTransport
{
virtual ~II2CTransport() = default;
virtual bool begin_host(uint8_t sda_pin, uint8_t scl_pin) = 0;
virtual bool begin_client(uint8_t address, uint8_t sda_pin, uint8_t scl_pin,
void* callback_context,
I2CReceiveCallback receive_callback,
I2CRequestCallback request_callback) = 0;
virtual bool probe(uint8_t address) = 0;
virtual bool write(uint8_t address, const uint8_t* data, size_t length) = 0;
virtual size_t request(uint8_t address, uint8_t* data, size_t capacity) = 0;
};
} // namespace firmware
#endif // PORTS_II2C_TRANSPORT_H

15
src/ports/IStorage.h Normal file
View file

@ -0,0 +1,15 @@
#ifndef ISTORAGE_H_
#define ISTORAGE_H_
#include <cstddef>
#include <cstdint>
struct IStorage
{
virtual ~IStorage() = default;
virtual bool write(uint32_t offset, const uint8_t* data, size_t len) = 0;
virtual bool read(uint32_t offset, uint8_t* data, size_t len) = 0;
virtual bool erase(uint32_t offset, size_t len) = 0;
};
#endif // ISTORAGE_H_

View file

@ -0,0 +1,134 @@
#ifndef PORTS_MOCKS_MOCK_I2C_BUS_H
#define PORTS_MOCKS_MOCK_I2C_BUS_H
#include "../II2CTransport.h"
#include <array>
namespace firmware
{
class MockI2CBus
{
public:
static constexpr size_t ADDRESS_COUNT = 127;
struct Endpoint
{
bool attached = false;
void* context = nullptr;
I2CReceiveCallback receive_callback = nullptr;
I2CRequestCallback request_callback = nullptr;
};
bool attach(uint8_t address, void* context, I2CReceiveCallback receive_callback,
I2CRequestCallback request_callback)
{
if (address == 0 || address >= ADDRESS_COUNT ||
receive_callback == nullptr || request_callback == nullptr)
{
return false;
}
endpoints_[address] = { true, context, receive_callback, request_callback };
return true;
}
bool probe(uint8_t address) const
{
return address < ADDRESS_COUNT && endpoints_[address].attached &&
!nack_[address];
}
bool write(uint8_t address, const uint8_t* data, size_t length)
{
if (!probe(address) || data == nullptr || length == 0)
{
return false;
}
++write_count_;
endpoints_[address].receive_callback(endpoints_[address].context, data,
length);
return true;
}
size_t request(uint8_t address, uint8_t* data, size_t capacity)
{
if (!probe(address) || data == nullptr || capacity == 0)
{
return 0;
}
return endpoints_[address].request_callback(endpoints_[address].context,
data, capacity);
}
void set_nack(uint8_t address, bool nack)
{
if (address < ADDRESS_COUNT)
{
nack_[address] = nack;
}
}
size_t write_count() const { return write_count_; }
private:
std::array<Endpoint, ADDRESS_COUNT> endpoints_ = {};
std::array<bool, ADDRESS_COUNT> nack_ = {};
size_t write_count_ = 0;
};
class MockI2CTransport final : public II2CTransport
{
public:
explicit MockI2CTransport(MockI2CBus& bus) : bus_(bus) {}
bool begin_host(uint8_t sda_pin, uint8_t scl_pin) override
{
host_started_ = true;
sda_pin_ = sda_pin;
scl_pin_ = scl_pin;
return true;
}
bool begin_client(uint8_t address, uint8_t sda_pin, uint8_t scl_pin,
void* callback_context, I2CReceiveCallback receive_callback,
I2CRequestCallback request_callback) override
{
address_ = address;
sda_pin_ = sda_pin;
scl_pin_ = scl_pin;
client_started_ = bus_.attach(address, callback_context, receive_callback,
request_callback);
return client_started_;
}
bool probe(uint8_t address) override { return bus_.probe(address); }
bool write(uint8_t address, const uint8_t* data, size_t length) override
{
return bus_.write(address, data, length);
}
size_t request(uint8_t address, uint8_t* data, size_t capacity) override
{
return bus_.request(address, data, capacity);
}
bool host_started() const { return host_started_; }
bool client_started() const { return client_started_; }
uint8_t address() const { return address_; }
uint8_t sda_pin() const { return sda_pin_; }
uint8_t scl_pin() const { return scl_pin_; }
private:
MockI2CBus& bus_;
bool host_started_ = false;
bool client_started_ = false;
uint8_t address_ = 0;
uint8_t sda_pin_ = 0;
uint8_t scl_pin_ = 0;
};
} // namespace firmware
#endif // PORTS_MOCKS_MOCK_I2C_BUS_H

View file

@ -0,0 +1,60 @@
#ifndef MOCKSTORAGE_H_
#define MOCKSTORAGE_H_
#include "../IStorage.h"
#include <cstring>
#include <vector>
class MockStorage : public IStorage
{
public:
explicit MockStorage(size_t size = 64 * 1024) : buffer_(size, 0) {}
~MockStorage() override = default;
bool write(uint32_t offset, const uint8_t* data, size_t len) override
{
if (offset + len > buffer_.size())
{
return false; // Out of bounds
}
std::memcpy(buffer_.data() + offset, data, len);
return true;
}
bool read(uint32_t offset, uint8_t* data, size_t len) override
{
if (offset + len > buffer_.size())
{
return false; // Out of bounds
}
std::memcpy(data, buffer_.data() + offset, len);
return true;
}
bool erase(uint32_t offset, size_t len) override
{
if (offset + len > buffer_.size())
{
return false; // Out of bounds
}
std::memset(buffer_.data() + offset, 0, len);
return true;
}
// Test helpers
size_t size() const { return buffer_.size(); }
const uint8_t* data() const { return buffer_.data(); }
void clear() { std::fill(buffer_.begin(), buffer_.end(), 0); }
void resize(size_t new_size) { buffer_.resize(new_size, 0); }
private:
std::vector<uint8_t> buffer_;
};
#endif // MOCKSTORAGE_H_

View file

@ -0,0 +1,14 @@
#ifndef SIGNAL_ENGINE_BUILD_PROFILE_H
#define SIGNAL_ENGINE_BUILD_PROFILE_H
// The synth compiler and host artefact graph belong to the desktop/WASM
// profile. Firmware and the native firmware-capacity harness deliberately do
// not expose or retain that domain. Derive this capability from the target so
// an Arduino build cannot accidentally opt it back in with a stale flag.
#if defined(ARDUINO) || defined(USEQ_FIRMWARE_PROFILE)
#define USEQ_HAS_SYNTH_ENGINE 0
#else
#define USEQ_HAS_SYNTH_ENGINE 1
#endif
#endif // SIGNAL_ENGINE_BUILD_PROFILE_H

View file

@ -0,0 +1,148 @@
#include "cell_store.h"
#include "../modulisp/lisp/symbol_intern.h"
#include <cstring>
namespace sig {
// ── SourceArena ─────────────────────────────────────────────────────────────
uint32_t SourceArena::store(const char* text, uint32_t length) {
if (write_head > SOURCE_ARENA_SIZE ||
length > SOURCE_ARENA_SIZE - write_head) return UINT32_MAX;
uint32_t offset = write_head;
memcpy(data + write_head, text, length);
write_head += length;
return offset;
}
uint32_t SourceArena::store_reuse(uint32_t existing_offset,
uint32_t existing_length,
const char* text, uint32_t length) {
// A source slot owns its whole previous region. A shorter replacement can
// occupy that same region without consuming any more arena space.
bool existing_region_fits =
existing_offset <= SOURCE_ARENA_SIZE &&
existing_length <= SOURCE_ARENA_SIZE - existing_offset;
if (existing_region_fits && length <= existing_length) {
if (length > 0) {
// Recompilation can read the old source directly from the arena,
// so use memmove for the same-region case as well.
memmove(data + existing_offset, text, length);
}
return existing_offset;
}
return store(text, length);
}
const char* SourceArena::read(uint32_t offset) const {
if (offset >= SOURCE_ARENA_SIZE) return nullptr;
return data + offset;
}
void SourceArena::reset() {
memset(data, 0, sizeof(data));
write_head = 0;
}
// ── CellStore ───────────────────────────────────────────────────────────────
uint16_t CellStore::store_data_table(const Sample* values, uint16_t count) {
// Content-intern: identical source text compiles to identical tables, and
// tables are immutable after storage, so recompiles (on_cell_changed,
// output reassign) must reuse the existing table instead of appending a
// duplicate — otherwise every recompile of a program containing a vector
// literal leaks a table until the pool is exhausted (MAX_DATA_TABLES is
// 32 on firmware). Cold path only; linear scan over <= MAX_DATA_TABLES.
for (uint16_t t = 0; t < data_table_count; t++) {
if (data_lengths[t] != count) continue;
if (memcmp(data_pool + data_offsets[t], values,
count * sizeof(Sample)) == 0) {
return t;
}
}
if (data_table_count >= MAX_DATA_TABLES) return UINT8_MAX;
// Find where to append in the pool
uint16_t pool_offset = 0;
if (data_table_count > 0) {
uint16_t last = data_table_count - 1;
pool_offset = data_offsets[last] + data_lengths[last];
}
if (pool_offset + count > MAX_DATA_ENTRIES) return UINT8_MAX;
uint16_t table_id = data_table_count;
data_offsets[table_id] = pool_offset;
data_lengths[table_id] = count;
memcpy(data_pool + pool_offset, values, count * sizeof(Sample));
data_table_count++;
return table_id;
}
const Sample* CellStore::get_data_table(uint16_t table_id, uint16_t& out_length) const {
if (table_id >= data_table_count) {
out_length = 0;
return nullptr;
}
out_length = data_lengths[table_id];
return data_pool + data_offsets[table_id];
}
void CellStore::snapshot_values(Sample* out, size_t max_count) const {
size_t n = max_count < MAX_CELLS ? max_count : MAX_CELLS;
for (size_t i = 0; i < n; i++) {
out[i] = cells[i].value;
}
}
void CellStore::reset(Sample bpm, int beats_per_bar,
int bars_per_phrase, int phrases_per_section) {
// Keep the monotonic store revision across resets so cached snapshots can
// never mistake a freshly-cleared store for their previous generation.
uint32_t next_revision = store_revision + 1;
if (next_revision == 0) next_revision = 1;
for (size_t i = 0; i < MAX_CELLS; i++) {
cells[i] = Cell{};
callables[i] = CallableInfo{};
}
memset(data_pool, 0, sizeof(data_pool));
memset(data_offsets, 0, sizeof(data_offsets));
memset(data_lengths, 0, sizeof(data_lengths));
data_table_count = 0;
store_revision = next_revision;
init_timing_defaults(bpm, beats_per_bar, bars_per_phrase,
phrases_per_section);
}
void CellStore::init_timing_defaults(Sample bpm, int beats_per_bar,
int bars_per_phrase, int phrases_per_section) {
store_revision++; // A12: cell values change below
auto& si = SymbolIntern::getInstance();
SymbolID bpm_sym = si.intern("bpm");
cells[bpm_sym].kind = CellKind::Number;
cells[bpm_sym].value = bpm;
cells[bpm_sym].revision = 1;
SymbolID bpb_sym = si.intern("beats-per-bar");
cells[bpb_sym].kind = CellKind::Number;
cells[bpb_sym].value = (Sample)beats_per_bar;
cells[bpb_sym].revision = 1;
SymbolID bpp_sym = si.intern("bars-per-phrase");
cells[bpp_sym].kind = CellKind::Number;
cells[bpp_sym].value = (Sample)bars_per_phrase;
cells[bpp_sym].revision = 1;
SymbolID pps_sym = si.intern("phrases-per-section");
cells[pps_sym].kind = CellKind::Number;
cells[pps_sym].value = (Sample)phrases_per_section;
cells[pps_sym].revision = 1;
}
} // namespace sig

View file

@ -0,0 +1,105 @@
#ifndef SIGNAL_ENGINE_CELL_STORE_H
#define SIGNAL_ENGINE_CELL_STORE_H
#include "types.h"
namespace sig {
// ── Cell Table ──────────────────────────────────────────────────────────────
// Replaces Value (88 bytes) + Environment (string-keyed map) with
// Cell (16 bytes) + flat array indexed by SymbolID.
enum class CellKind : uint8_t {
Empty, // unused slot
Number, // Sample constant
Data, // numeric array (vectors, step patterns, scale tables)
Callable, // user function template (params + body token offset)
Nil // explicitly nil
};
struct Cell {
CellKind kind = CellKind::Empty;
uint8_t flags = 0; // 0x01 = frozen
uint16_t data_table_id = 0; // for Data cells: index into shared data pool
uint32_t revision = 0; // bumped on every change; dirty detection
Sample value = 0.0; // Number: the value. Data: length as Sample.
};
// sizeof(Cell) == 16 bytes
struct CallableInfo {
SymbolID params[MAX_CALLABLE_PARAMS] = {};
uint8_t param_count = 0;
uint8_t pad[3] = {};
uint32_t source_offset = 0; // byte offset into source arena
uint32_t source_length = 0; // byte length of body source text
};
// sizeof(CallableInfo) == 24 bytes (with 4-byte SymbolID: 32+4+4+4 = 44... adjust)
// ── Source Arena ────────────────────────────────────────────────────────────
// Append-only string buffer for callable body text.
struct SourceArena {
char data[SOURCE_ARENA_SIZE] = {};
uint32_t write_head = 0;
// Store source text, return offset. Returns UINT32_MAX on overflow.
uint32_t store(const char* text, uint32_t length);
// Re-store source text for a slot. Reuses the slot's existing region when
// the new text fits; otherwise appends a fresh region with store().
// Returns UINT32_MAX when the fresh region does not fit.
uint32_t store_reuse(uint32_t existing_offset, uint32_t existing_length,
const char* text, uint32_t length);
// Read back source text.
const char* read(uint32_t offset) const;
// Reset the arena (e.g. after useq-clear).
void reset();
};
// ── Cell Store ──────────────────────────────────────────────────────────────
struct CellStore {
Cell cells[MAX_CELLS] = {};
CallableInfo callables[MAX_CELLS] = {}; // parallel array; valid when kind==Callable
// Store-wide revision (A12). Bumped whenever cell values may have changed
// (every cold eval, timing init, flash load). Lets per-tick consumers skip
// re-snapshotting all MAX_CELLS values when nothing changed — measured at
// ~40% of the firmware engine tick. Overcounting (bumping without an
// actual change) is safe; missing a bump is not, so bumps happen at the
// coarse mutation entry points rather than per cell write.
uint32_t store_revision = 1;
// Shared data pool
Sample data_pool[MAX_DATA_ENTRIES] = {};
uint16_t data_offsets[MAX_DATA_TABLES] = {};
uint16_t data_lengths[MAX_DATA_TABLES] = {};
uint8_t data_table_count = 0;
// Store a new data table. Returns table ID, or UINT8_MAX on overflow.
uint16_t store_data_table(const Sample* values, uint16_t count);
// Get data table pointer and length.
const Sample* get_data_table(uint16_t table_id, uint16_t& out_length) const;
// Snapshot cell numeric values for executor (copies cell[i].value for all).
void snapshot_values(Sample* out, size_t max_count) const;
// Clear all session-owned definitions, callable metadata, and immutable
// data tables, then restore only the well-known timing cells. Used by a
// full session reset (`useq-clear`) and by engine initialisation.
void reset(Sample bpm = 120.0, int beats_per_bar = 4,
int bars_per_phrase = 4, int phrases_per_section = 4);
// Convenience: initialise the four well-known timing cells.
// bpm (default 120), beats-per-bar (default 4),
// bars-per-phrase (default 4), phrases-per-section (default 4).
void init_timing_defaults(Sample bpm = 120.0, int beats_per_bar = 4,
int bars_per_phrase = 4, int phrases_per_section = 4);
};
} // namespace sig
#endif // SIGNAL_ENGINE_CELL_STORE_H

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,236 @@
#ifndef SIGNAL_ENGINE_COLD_EVAL_H
#define SIGNAL_ENGINE_COLD_EVAL_H
#include "types.h"
#include "cell_store.h"
#include "node_pool.h"
#include "state_registry.h"
#if USEQ_HAS_SYNTH_ENGINE
#include "synth_graph.h"
#endif
#include "diagnostics.h"
namespace sig {
// ── Engine State ───────────────────────────────────────────────────────────
// Transport and timing state managed by cold-path commands.
// Clock source for tempo: internal (free-running) or driven by a gate input.
// Stored as uint8_t so EngineState stays POD-friendly across the engine/firmware
// boundary; the firmware-side ClockSync module interprets the values.
// 0 = internal, 1 = external on input I1, 2 = external on input I2
struct EngineState {
Sample time_offset = 0.0;
// Internal transport-origin correction. Kept separate from the
// user-visible time_offset reported over the wire.
Sample transport_offset = 0.0;
bool is_playing = true;
Sample current_time = 0.0;
Sample current_dt = 0.0;
Sample current_wall_time = 0.0;
Sample paused_time = 0.0;
bool has_pause_anchor = false;
bool reset_dt_on_next_tick = true;
// Translate a host/hardware monotonic clock into session-logical time.
// Paused time is an anchor, not a wall clock that continues invisibly.
Sample logical_time(Sample wall_time) const {
return (!is_playing && has_pause_anchor)
? paused_time : wall_time + time_offset + transport_offset;
}
void pause() {
if (is_playing) {
paused_time = current_time;
has_pause_anchor = true;
}
is_playing = false;
current_dt = 0.0;
}
void play() {
if (!is_playing) {
if (has_pause_anchor) {
transport_offset =
paused_time - current_wall_time - time_offset;
current_time = paused_time;
}
is_playing = true;
reset_dt_on_next_tick = true;
current_dt = 0.0;
}
}
void rewind() {
// At the current wall instant, wall + user offset + transport-origin
// correction must equal logical zero.
transport_offset = -current_wall_time - time_offset;
current_time = 0.0;
current_dt = 0.0;
reset_dt_on_next_tick = true;
if (!is_playing) {
paused_time = 0.0;
has_pause_anchor = true;
}
}
void stop() {
rewind();
paused_time = 0.0;
has_pause_anchor = true;
is_playing = false;
}
uint8_t clock_source = 0; // see comment above
uint8_t ext_clock_divisor = 1; // pulses per beat (MIDI: typically 1, 2, 4, 24)
bool ext_clock_reset = false; // one-shot: firmware resets tracker, then clears
};
// ── Output Source Storage ───────────────────────────────────────────────────
// Stores the source text for each output expression (needed for recompilation).
struct OutputSource {
uint32_t arena_offset = 0;
uint32_t arena_length = 0;
bool has_source = false;
};
// ── Eval Result ─────────────────────────────────────────────────────────────
struct EvalResult {
enum Kind : uint8_t { Number, Text, DataRef, Ok, Error } kind = Ok;
Sample number = 0.0;
const char* text = nullptr;
uint16_t text_length = 0;
Diagnostic diagnostics[8] = {};
uint8_t diagnostic_count = 0;
};
// ── SignalEngine ────────────────────────────────────────────────────────────
// Bundles all persistent engine state that was previously spread across
// separate globals and local variables.
// ── State Update Source ─────────────────────────────────────────────────────
// Stores the source text and dependency info for a state update expression
// (needed for recompilation when cells change).
struct StateUpdateSource {
uint32_t arena_offset = 0;
uint32_t arena_length = 0;
bool has_source = false;
// Named defstate attribution for runtime/reactive diagnostics. Anonymous
// output-owned state leaves this as INVALID_ID (0).
CellIndex state_symbol = 0;
CellIndex dep_cells[MAX_OUTPUT_DEPS] = {};
uint8_t dep_count = 0;
};
// A rejected background recompile is not the result of the current eval's
// output form, so its diagnostic must outlive EvalResult. The indexed owner
// (output slot or named-state slot) plus triggered_by forms the chain of
// blame while the previous compiled consumer remains published.
struct ActiveCompileDiagnostic {
bool active = false;
SymbolID triggered_by = 0;
Diagnostic diagnostic = {};
};
struct SignalEngine {
CellStore cells;
SourceArena arena;
NodePool pool;
EngineState state;
OutputSource output_sources[MAX_OUTPUTS] = {};
StateUpdateSource state_sources[MAX_STATE_SLOTS] = {};
ActiveCompileDiagnostic output_compile_diagnostics[MAX_OUTPUTS] = {};
ActiveCompileDiagnostic state_compile_diagnostics[MAX_STATE_SLOTS] = {};
StateResourceRegistry registry;
NodePool scratch_pool;
char eval_text_buf[512] = {};
#if USEQ_HAS_SYNTH_ENGINE
// ── Host synth compiler domain (synth-nodes.md) ─────────────────────
// Published synth artefacts: identity-keyed declarations + control
// channel table, sharing one compiler revision. Each top-level synth form
// advances atomically; earlier forms survive a later sibling's failure.
SynthGraph synth_graph;
// Pending wrapper-injected state identity (state-identity.md §2.2:
// `with-state-id` and `:id` normalise to the same internal identity
// annotation). Set by the cold-path `with-state-id` handler before it
// evaluates its wrapped form, consumed first-synth-wins by do_synth
// when the form carries no explicit :name/:id, and always restored
// when the wrapper handler returns. Never inspected on the hot path.
char pending_state_identity[MAX_SYNTH_IDENTITY] = {};
bool has_pending_state_identity = false;
// Ordinal of anonymous synth declarations within the current cold
// eval, reset at eval_cold entry. The anonymous fallback identity is
// "::anon-<ordinal>" so recompiling the same program reuses its
// identity instead of leaking one per eval (state-identity.md §2.5).
uint16_t eval_anon_synth_ordinal = 0;
#endif
// Increments on each user-visible full-session clear. Wrappers with
// compiler caches use this to discard references outside SignalEngine.
uint32_t session_generation = 0;
void init_defaults(Sample bpm = 120.0, int beats_per_bar = 4,
int bars_per_phrase = 4, int phrases_per_section = 4);
// Reset all compiler/runtime storage owned by a livecoding session while
// leaving transport state and any persistent flash image untouched.
void reset_session_storage(Sample bpm = 120.0, int beats_per_bar = 4,
int bars_per_phrase = 4,
int phrases_per_section = 4,
bool publish_session_clear = true);
};
// ── Cold-Path Evaluation ────────────────────────────────────────────────────
// Handles everything that isn't signal sampling: define, defn, set-bpm, etc.
EvalResult eval_cold(const char* source, uint32_t length, SignalEngine& engine);
// ── Top-Level Expression Evaluation ────────────────────────────────────────
// Compile and execute a signal expression in scratch isolation. Used for
// top-level queries (bare symbols, unknown forms, vector eval). Does not
// commit state or mutate live output programs.
EvalResult eval_expression(const char* source, uint32_t length,
SignalEngine& engine);
// ── Bulk Recompilation ──────────────────────────────────────────────────────
// Recompile all outputs that have stored source text. Used after flash load
// to rebuild signal graphs from persisted source.
//
// Idempotent: the node pool uses hash-consing (CSE), so recompiling the same
// expression twice yields the same node indices. A gc pass after rebuild
// reclaims any stale nodes left from a previous compilation.
void recompile_all_outputs(SignalEngine& engine);
// ── Dependency Tracking ─────────────────────────────────────────────────────
// When a cell changes, recompile outputs that depend on it.
void on_cell_changed(SymbolID cell_id, SignalEngine& engine);
#if USEQ_HAS_SYNTH_ENGINE
// ── Host synth GC integration ───────────────────────────────────────────────
// Register every committed synth control root with the NodePool's external
// roots array so the next GC pass keeps them reachable and remaps their
// indices. Callers that invoke pool.gc_unreachable_nodes() directly must
// call this first. Safe to call repeatedly; clears and re-registers each
// time so stale indices do not linger after a synth-graph edit.
void register_synth_external_roots(SignalEngine& engine);
// After a GC pass, copy the remapped external root indices back into the
// synth_graph.controls[] table. Pairs with register_synth_external_roots().
void commit_synth_external_roots(SignalEngine& engine);
#endif
} // namespace sig
#endif // SIGNAL_ENGINE_COLD_EVAL_H

View file

@ -0,0 +1,125 @@
#include "compiler_pipeline.h"
#include "cold_eval.h"
#include "executor.h"
#include <cstring>
namespace sig {
bool source_region_can_store(const SourceArena& arena,
uint32_t previous_offset,
uint32_t previous_length,
uint32_t new_length) {
const bool existing_region_fits =
previous_offset <= SOURCE_ARENA_SIZE &&
previous_length <= SOURCE_ARENA_SIZE - previous_offset;
if (existing_region_fits && new_length <= previous_length) return true;
return arena.write_head <= SOURCE_ARENA_SIZE &&
new_length <= SOURCE_ARENA_SIZE - arena.write_head;
}
void ParsedProgram::parse(const char* source, uint32_t length) {
stream.count = 0;
stream.pos = 0;
diagnostic_count = 0;
stream.count = TokenStream::tokenize(
source, length, stream.tokens, MAX_TOKENS,
diagnostics, &diagnostic_count);
stream.pos = 0;
}
SourceMutationPlan SourceMutationPlan::prepare(
const SourceArena& arena,
const char* source,
uint32_t source_length,
uint32_t span_start,
uint32_t span_end,
uint32_t previous_offset,
uint32_t previous_length) {
SourceMutationPlan plan;
plan.previous_offset = previous_offset;
plan.previous_length = previous_length;
if (!source) return plan;
if (span_end <= span_start || span_start > source_length ||
span_end > source_length) {
plan.status = SourcePlanStatus::InvalidSpan;
return plan;
}
plan.bytes = source + span_start;
plan.length = span_end - span_start;
plan.status = source_region_can_store(
arena, previous_offset, previous_length, plan.length)
? SourcePlanStatus::Ready
: SourcePlanStatus::CapacityExceeded;
return plan;
}
uint32_t SourceMutationPlan::publish(SourceArena& arena) const {
if (status != SourcePlanStatus::Ready) return UINT32_MAX;
return arena.store_reuse(
previous_offset, previous_length, bytes, length);
}
GraphMutationTransaction::~GraphMutationTransaction() {
rollback();
}
void GraphMutationTransaction::begin(SignalEngine& engine) {
if (active_) rollback();
engine_ = &engine;
registry_ = engine.registry;
data_table_count_ = engine.cells.data_table_count;
state_slot_count_ = engine.pool.state_slot_count;
live_slot_count_ = engine.pool.live_slot_count;
arena_write_head_ = engine.arena.write_head;
std::memcpy(engine.scratch_pool.state_values, engine.pool.state_values,
sizeof(engine.pool.state_values));
std::memcpy(engine.scratch_pool.state_update_roots,
engine.pool.state_update_roots,
sizeof(engine.pool.state_update_roots));
std::memcpy(engine.scratch_pool.state_owner_context,
engine.pool.state_owner_context,
sizeof(engine.pool.state_owner_context));
std::memcpy(engine.scratch_pool.live_slots, engine.pool.live_slots,
sizeof(engine.pool.live_slots));
active_ = true;
}
void GraphMutationTransaction::rollback() {
if (!active_ || !engine_) return;
SignalEngine& engine = *engine_;
engine.registry = registry_;
engine.cells.data_table_count = data_table_count_;
engine.pool.state_slot_count = state_slot_count_;
engine.pool.live_slot_count = live_slot_count_;
engine.arena.write_head = arena_write_head_;
std::memcpy(engine.pool.state_values, engine.scratch_pool.state_values,
sizeof(engine.pool.state_values));
std::memcpy(engine.pool.state_update_roots,
engine.scratch_pool.state_update_roots,
sizeof(engine.pool.state_update_roots));
std::memcpy(engine.pool.state_owner_context,
engine.scratch_pool.state_owner_context,
sizeof(engine.pool.state_owner_context));
std::memcpy(engine.pool.live_slots, engine.scratch_pool.live_slots,
sizeof(engine.pool.live_slots));
#if USEQ_HAS_SYNTH_ENGINE
register_synth_external_roots(engine);
#endif
engine.pool.gc_unreachable_nodes();
#if USEQ_HAS_SYNTH_ENGINE
commit_synth_external_roots(engine);
#endif
engine.pool.rebuild_execution_order();
classify_outputs(engine.pool);
active_ = false;
}
} // namespace sig

View file

@ -0,0 +1,95 @@
#ifndef SIGNAL_ENGINE_COMPILER_PIPELINE_H
#define SIGNAL_ENGINE_COMPILER_PIPELINE_H
#include "cell_store.h"
#include "diagnostics.h"
#include "state_registry.h"
#include "token.h"
namespace sig {
struct SignalEngine;
// Fixed-capacity parse IR. Tokenization writes directly into the retained
// stream, avoiding the old MAX_TOKENS temporary followed by a second copy.
// Diagnostics and token storage are bounded identically on every target.
struct ParsedProgram {
static constexpr uint8_t MAX_PARSE_DIAGNOSTICS = 8;
TokenStream stream = {};
Diagnostic diagnostics[MAX_PARSE_DIAGNOSTICS] = {};
uint8_t diagnostic_count = 0;
void parse(const char* source, uint32_t length);
bool ok() const { return diagnostic_count == 0; }
bool empty() const { return stream.count == 0 || stream.at_end(); }
};
enum class SourcePlanStatus : uint8_t {
NoSource,
Ready,
InvalidSpan,
CapacityExceeded,
};
bool source_region_can_store(const SourceArena& arena,
uint32_t previous_offset,
uint32_t previous_length,
uint32_t new_length);
// Immutable plan for the source-arena part of a compiler publication. Span
// resolution and capacity validation happen before graph mutation; publish()
// is therefore the only write and has no policy decisions left to make.
struct SourceMutationPlan {
SourcePlanStatus status = SourcePlanStatus::NoSource;
const char* bytes = nullptr;
uint32_t length = 0;
uint32_t previous_offset = UINT32_MAX;
uint32_t previous_length = 0;
static SourceMutationPlan prepare(
const SourceArena& arena,
const char* source,
uint32_t source_length,
uint32_t span_start,
uint32_t span_end,
uint32_t previous_offset = UINT32_MAX,
uint32_t previous_length = 0);
bool has_source() const { return status == SourcePlanStatus::Ready; }
uint32_t publish(SourceArena& arena) const;
};
// Bounded checkpoint for graph-building side effects. GraphBuilder interns
// directly into the live fixed-capacity pool, so candidate construction is a
// mutation stage, not a pure tree allocation. This transaction snapshots the
// mutable metadata and uses SignalEngine::scratch_pool for the fixed arrays.
// Unless accept() is called after publication, destruction restores the last
// published graph and capacity.
class GraphMutationTransaction {
public:
GraphMutationTransaction() = default;
explicit GraphMutationTransaction(SignalEngine& engine) { begin(engine); }
~GraphMutationTransaction();
GraphMutationTransaction(const GraphMutationTransaction&) = delete;
GraphMutationTransaction& operator=(const GraphMutationTransaction&) = delete;
void begin(SignalEngine& engine);
void accept() { active_ = false; }
void rollback();
bool active() const { return active_; }
private:
SignalEngine* engine_ = nullptr;
StateResourceRegistry registry_ = {};
uint8_t data_table_count_ = 0;
uint16_t state_slot_count_ = 0;
uint16_t live_slot_count_ = 0;
uint32_t arena_write_head_ = 0;
bool active_ = false;
};
} // namespace sig
#endif // SIGNAL_ENGINE_COMPILER_PIPELINE_H

View file

@ -0,0 +1,111 @@
#include "diagnostics.h"
#include "cell_store.h"
#include "../modulisp/lisp/symbol_intern.h"
#include <cstring>
#include <algorithm>
namespace sig {
const char* severity_to_cstr(DiagnosticSeverity s) {
switch (s) {
case DiagnosticSeverity::Warning: return "warning";
case DiagnosticSeverity::Error: return "error";
}
return "error";
}
const char* category_to_cstr(DiagnosticCategory c) {
switch (c) {
case DiagnosticCategory::Syntax: return "syntax";
case DiagnosticCategory::UndefinedName: return "undefinedName";
case DiagnosticCategory::Arity: return "arity";
case DiagnosticCategory::Type: return "type";
case DiagnosticCategory::Boundary: return "boundary";
case DiagnosticCategory::Arithmetic: return "arithmetic";
case DiagnosticCategory::Runtime: return "runtime";
case DiagnosticCategory::Overflow: return "overflow";
}
return "runtime";
}
// ── Levenshtein distance (single-row DP, max symbol length 63) ──────────────
static int levenshtein(const char* s1, int len1, const char* s2, int len2) {
if (len1 == 0) return len2;
if (len2 == 0) return len1;
constexpr int MAX_LEN = 64;
if (len2 >= MAX_LEN) len2 = MAX_LEN - 1;
int row[MAX_LEN];
for (int i = 0; i <= len2; i++) row[i] = i;
for (int i = 1; i <= len1; i++) {
int prev = i - 1;
row[0] = i;
for (int j = 1; j <= len2; j++) {
int temp = row[j];
if (s1[i - 1] == s2[j - 1])
row[j] = prev;
else
row[j] = 1 + std::min({prev, row[j], row[j - 1]});
prev = temp;
}
}
return row[len2];
}
// Well-known built-in symbol names that should be suggested even when
// they are not in the cell table (temporal phasors, operators, etc.)
static const char* const builtin_names[] = {
"beat", "bar", "phrase", "section", "beat-num", "bar-num",
"bpm", "beats-per-bar", "bars-per-phrase", "phrases-per-section",
"sin", "cos", "tan", "abs", "floor", "ceil", "sqrt", "neg", "frac",
"usin", "ucos", "bi-to-uni", "uni-to-bi",
"min", "max", "pow", "expt", "mod", "pulse", "clamp", "lerp", "scale",
"tri", "sqr", "step", "gates", "trigs", "euclid", "eu",
"seq", "from-list", "interp", "flatseq", "dm", "range", "gatesw",
"random", "index-rand", "loop-at", "rpulse", "rstep", "ridx", "rwarp",
"time-as", "fast", "slow", "offset", "shift",
"if", "let", "do", "for", "while", "fn", "lambda",
"not", "and", "or",
"define", "def", "defn", "set", "input", "t",
nullptr
};
SymbolID find_fuzzy_match(SymbolID unknown_sym, const CellStore& cells) {
auto& si = SymbolIntern::getInstance();
const String& name = si.getString(unknown_sym);
if (name.length() == 0) return SymbolIntern::INVALID_ID;
const char* name_cstr = name.c_str();
int name_len = (int)name.length();
SymbolID best = SymbolIntern::INVALID_ID;
int best_dist = 3; // threshold: only suggest if distance < 3
// Search defined cells
for (uint16_t i = 1; i < MAX_CELLS; i++) {
if (cells.cells[i].kind == CellKind::Empty) continue;
const String& candidate = si.getString((SymbolID)i);
if (candidate.length() == 0) continue;
int d = levenshtein(name_cstr, name_len,
candidate.c_str(), (int)candidate.length());
if (d > 0 && d < best_dist) {
best_dist = d;
best = (SymbolID)i;
}
}
// Search well-known built-in names
for (int k = 0; builtin_names[k] != nullptr; k++) {
const char* cand = builtin_names[k];
int cand_len = (int)strlen(cand);
int d = levenshtein(name_cstr, name_len, cand, cand_len);
if (d > 0 && d < best_dist) {
best_dist = d;
best = si.intern(cand);
}
}
return best;
}
} // namespace sig

View file

@ -0,0 +1,49 @@
#ifndef SIGNAL_ENGINE_DIAGNOSTICS_H
#define SIGNAL_ENGINE_DIAGNOSTICS_H
#include "types.h"
namespace sig {
// Forward declaration
struct CellStore;
// ── Diagnostic Types ────────────────────────────────────────────────────────
// Compatible with the existing diagnostic.h types but using borrowed,
// static-lifetime strings (no heap allocation for messages). Producers MUST
// NOT pass stack buffers or temporary String storage: Diagnostic is copied by
// value across builder/eval/active-health boundaries without copying text.
enum class DiagnosticSeverity : uint8_t { Warning, Error };
enum class DiagnosticCategory : uint8_t {
Syntax, // parse errors
UndefinedName, // unknown symbol
Arity, // wrong argument count
Type, // wrong argument type
Boundary, // side effect in signal context
Arithmetic, // division by zero, NaN
Runtime, // loop budget, recursion depth
Overflow // value out of range
};
struct Diagnostic {
DiagnosticSeverity severity = DiagnosticSeverity::Error;
DiagnosticCategory category = DiagnosticCategory::Runtime;
uint16_t span_start = 0;
uint16_t span_len = 0;
const char* message = nullptr; // borrowed static-lifetime text
const char* suggestion = nullptr; // borrowed static-lifetime text
};
const char* severity_to_cstr(DiagnosticSeverity s);
const char* category_to_cstr(DiagnosticCategory c);
// ── Fuzzy matching ──────────────────────────────────────────────────────────
// Returns the SymbolID of the closest match, or INVALID_ID if none is close enough.
// Searches defined cells and well-known built-in symbols.
SymbolID find_fuzzy_match(SymbolID unknown_sym, const CellStore& cells);
} // namespace sig
#endif // SIGNAL_ENGINE_DIAGNOSTICS_H

View file

@ -0,0 +1,91 @@
#ifndef SIGNAL_ENGINE_EVAL_OPS_H
#define SIGNAL_ENGINE_EVAL_OPS_H
// Pure-math NodeOp evaluation — shared by executor (hot path) and constant
// folding (node_pool). Load ops (Const, CellLoad, InputLoad, etc.) and
// data ops (VecIndex, VecLerp) are NOT included here; they require runtime
// context and stay in executor.cpp.
#include "node_pool.h"
#include <cmath>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
namespace sig {
static inline Sample eval_unary_op(NodeOp op, Sample a) {
switch (op) {
case NodeOp::Neg: return -a;
case NodeOp::Abs: return fabs(a);
case NodeOp::Floor: return floor(a);
case NodeOp::Ceil: return ceil(a);
case NodeOp::Frac: return a - floor(a);
case NodeOp::Sqrt: return sqrt(fabs(a));
case NodeOp::Sin: return sin(a);
case NodeOp::Cos: return cos(a);
case NodeOp::Tan: return tan(a);
case NodeOp::USin: return (sin(a * 2.0 * M_PI) + 1.0) * 0.5;
case NodeOp::UCos: return (cos(a * 2.0 * M_PI) + 1.0) * 0.5;
case NodeOp::Tri: return 1.0 - fabs(2.0 * (a - floor(a)) - 1.0);
case NodeOp::Sqr: return ((a - floor(a)) < 0.5) ? 1.0 : 0.0;
case NodeOp::Not: return (a == 0.0) ? 1.0 : 0.0;
case NodeOp::BiToUni: return (a + 1.0) * 0.5;
case NodeOp::UniToBi: return a * 2.0 - 1.0;
case NodeOp::HashIndex: {
uint32_t v = (uint32_t)(int32_t)a;
v = ((v >> 16) ^ v) * 0x45d9f3bu;
v = ((v >> 16) ^ v) * 0x45d9f3bu;
v = (v >> 16) ^ v;
return (Sample)(v & 0x7fffffffu) / (Sample)0x7fffffffu;
}
default: return 0.0;
}
}
static inline Sample eval_binary_op(NodeOp op, Sample a, Sample b) {
switch (op) {
case NodeOp::Add: return a + b;
case NodeOp::Sub: return a - b;
case NodeOp::Mul: return a * b;
// Preserve IEEE non-finite results. The output boundary owns recovery:
// default mode substitutes LKG and records unhealthy state; legacy
// ZeroSquash mode clamps the same result per node. Returning zero here
// would erase the runtime error before either policy can observe it.
case NodeOp::Div: return a / b;
case NodeOp::Mod: return fmod(a, b);
case NodeOp::Expt: return pow(a, b);
case NodeOp::Min: return (a < b) ? a : b;
case NodeOp::Max: return (a > b) ? a : b;
case NodeOp::Pulse: return ((a - floor(a)) < b) ? 1.0 : 0.0;
case NodeOp::CmpGt: return (a > b) ? 1.0 : 0.0;
case NodeOp::CmpLt: return (a < b) ? 1.0 : 0.0;
case NodeOp::CmpGe: return (a >= b) ? 1.0 : 0.0;
case NodeOp::CmpLe: return (a <= b) ? 1.0 : 0.0;
case NodeOp::CmpEq: return (a == b) ? 1.0 : 0.0;
case NodeOp::And: return (a != 0.0 && b != 0.0) ? 1.0 : 0.0;
case NodeOp::Or: return (a != 0.0 || b != 0.0) ? 1.0 : 0.0;
default: return 0.0;
}
}
// Convention: ternary ops take their "main" argument LAST so that the
// thing-being-affected can be a nested expression at the end of a lisp form.
// (clamp lo hi value) — value is last
// (scale min max value) — value is last
// (lerp a b t) — interpolation parameter t is last
// (if cond then else) — condition is first (Select)
static inline Sample eval_ternary_op(NodeOp op, Sample a, Sample b, Sample c) {
switch (op) {
case NodeOp::Clamp: return (c < a) ? a : (c > b) ? b : c; // (clamp lo hi value)
case NodeOp::Lerp: return a + (b - a) * c;
case NodeOp::Scale: return c * (b - a) + a;
case NodeOp::Select: return (a != 0.0) ? b : c;
default: return 0.0;
}
}
} // namespace sig
#endif // SIGNAL_ENGINE_EVAL_OPS_H

View file

@ -0,0 +1,447 @@
#include "executor.h"
#include "eval_ops.h"
#include <cmath>
#include <cstring>
#include <algorithm>
namespace sig {
// ── Failure Mode ────────────────────────────────────────────────────────────
static FailureMode g_failure_mode = FailureMode::LkgFallback;
void set_failure_mode(FailureMode mode) { g_failure_mode = mode; }
FailureMode get_failure_mode() { return g_failure_mode; }
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)
return OutputHealth::Running;
return slot.has_lkg ? OutputHealth::Fallback : OutputHealth::Error;
}
const char* output_health_to_cstr(OutputHealth health) {
switch (health) {
case OutputHealth::Idle: return "idle";
case OutputHealth::Running: return "running";
case OutputHealth::Fallback: return "fallback";
case OutputHealth::Error: return "error";
}
return "idle";
}
// ── Single-node evaluation ──────────────────────────────────────────────────
// Load ops and data ops need runtime context and are handled here directly.
// Pure-math ops delegate to the shared eval_ops.h functions.
static inline Sample eval_node(
const Node& n,
Sample a, Sample b, Sample c,
Sample t,
Sample dt,
const Sample* cell_values,
const Sample* hw_inputs,
const Sample* data_pool,
const uint16_t* data_offsets,
const uint16_t* data_lengths,
const Sample* prev_output_values,
const Sample* state_values
) {
switch (n.op) {
// ── Load ops (runtime context required) ─────────────────────────
case NodeOp::Const: return n.imm;
case NodeOp::RawTimeLoad: return t;
case NodeOp::LoadState: return state_values[(uint16_t)n.imm];
case NodeOp::LoadDt: return dt;
case NodeOp::CellLoad: return cell_values[(uint16_t)n.imm];
case NodeOp::InputLoad: return hw_inputs[(uint16_t)n.imm];
case NodeOp::PrevOutputLoad:
return prev_output_values[(uint16_t)n.imm];
case NodeOp::SlotLoad: return 0.0; // handled in execution loop
// ── Data ops (need data_pool arrays) ────────────────────────────
case NodeOp::VecIndex: {
uint16_t tid = (uint16_t)n.imm;
uint16_t off = data_offsets[tid];
uint16_t len = data_lengths[tid];
if (len == 0) return 0.0;
int index = ((int)floor(a) % len + len) % len;
return data_pool[off + index];
}
case NodeOp::VecLerp: {
uint16_t tid = (uint16_t)n.imm;
uint16_t off = data_offsets[tid];
uint16_t len = data_lengths[tid];
if (len <= 1) return (len == 1) ? data_pool[off] : 0.0;
Sample scaled = a * (len - 1);
int i0 = (int)floor(scaled);
if (i0 < 0) i0 = 0;
if (i0 >= len - 1) i0 = len - 2;
int i1 = i0 + 1;
// frac relative to the (possibly clamped) i0, not floor(scaled).
// At integral phase == 1.0, scaled == len-1 and i0 is clamped to
// len-2, so frac == 1.0 and we correctly return the LAST element
// (data[len-1]); using floor(scaled) here yielded frac == 0 and the
// wrong element (data[len-2]). Also gracefully clamps phase > 1.
Sample frac = scaled - i0;
if (frac < 0.0) frac = 0.0;
if (frac > 1.0) frac = 1.0;
return data_pool[off + i0] + (data_pool[off + i1] - data_pool[off + i0]) * frac;
}
// ── Pure-math ops (shared with constant folding) ────────────────
// Unary
case NodeOp::Neg:
case NodeOp::Abs:
case NodeOp::Floor:
case NodeOp::Ceil:
case NodeOp::Frac:
case NodeOp::Sqrt:
case NodeOp::Sin:
case NodeOp::Cos:
case NodeOp::Tan:
case NodeOp::USin:
case NodeOp::UCos:
case NodeOp::Tri:
case NodeOp::Sqr:
case NodeOp::Not:
case NodeOp::BiToUni:
case NodeOp::UniToBi:
case NodeOp::HashIndex:
return eval_unary_op(n.op, a);
// Binary
case NodeOp::Add:
case NodeOp::Sub:
case NodeOp::Mul:
case NodeOp::Div:
case NodeOp::Mod:
case NodeOp::Expt:
case NodeOp::Min:
case NodeOp::Max:
case NodeOp::Pulse:
case NodeOp::CmpGt:
case NodeOp::CmpLt:
case NodeOp::CmpGe:
case NodeOp::CmpLe:
case NodeOp::CmpEq:
case NodeOp::And:
case NodeOp::Or:
return eval_binary_op(n.op, a, b);
// Ternary
case NodeOp::Clamp:
case NodeOp::Lerp:
case NodeOp::Scale:
case NodeOp::Select:
return eval_ternary_op(n.op, a, b, c);
default: return 0.0;
}
}
// ── Single-Sample Execution ─────────────────────────────────────────────────
void execute_all_outputs(const NodePool& pool, ExecutionContext& ctx) {
for (uint16_t i = 0; i < pool.exec_count; i++) {
uint16_t idx = pool.exec_order[i];
const Node& n = pool.nodes[idx];
Sample result;
if (n.op == NodeOp::SlotLoad) {
uint16_t slot_idx = (uint16_t)n.imm;
result = (slot_idx < pool.live_slot_count)
? pool.live_slots[slot_idx].value : 0.0;
} else {
Sample a = (n.input_a != NODE_NONE) ? ctx.workspace[n.input_a] : 0.0;
Sample b = (n.input_b != NODE_NONE) ? ctx.workspace[n.input_b] : 0.0;
Sample c = (n.input_c != NODE_NONE) ? ctx.workspace[n.input_c] : 0.0;
result = eval_node(n, a, b, c, ctx.t, ctx.dt,
ctx.cell_values, ctx.hw_inputs,
ctx.data_pool, ctx.data_offsets, ctx.data_lengths,
ctx.prev_outputs, pool.state_values);
}
// Per-node NaN/Inf guard — legacy ZeroSquash mode only. In
// LkgFallback mode non-finite values propagate to the output root,
// where the LKG substitution below declares the failure
// (failure-model.md §3.1).
if (g_failure_mode == FailureMode::ZeroSquash && !std::isfinite(result))
result = 0.0;
ctx.workspace[idx] = result;
}
// Read output values with LKG fallback
uint64_t fallback_mask = 0;
for (uint16_t i = 0; i < MAX_OUTPUTS; i++) {
if (pool.outputs[i].root_node != NODE_NONE) {
Sample v = ctx.workspace[pool.outputs[i].root_node];
if (g_failure_mode == FailureMode::LkgFallback &&
!std::isfinite(v)) {
// Non-finite at the root: substitute the last-known-good
// 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;
}
ctx.output_values[i] = v;
} else if (pool.outputs[i].valid) {
// No graph assigned but we have a last-known-good value — use it
ctx.output_values[i] = pool.outputs[i].lkg_value;
} else {
// Inactive outputs are always the compiler/runtime neutral value,
// even when a caller reuses a dirty output buffer.
ctx.output_values[i] = 0.0;
}
}
pool.runtime_fallback_mask = fallback_mask;
}
// ── Post-Tick Commit ───────────────────────────────────────────────────────
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;
if (pool.outputs[i].root_node != NODE_NONE && !substituted &&
std::isfinite(output_values[i])) {
pool.outputs[i].lkg_value = output_values[i];
pool.outputs[i].has_lkg = true;
}
}
}
// ── Post-Tick State Commit ────────────────────────────────────────────────
void commit_state(NodePool& pool, const Sample* workspace,
const uint16_t* failed_owner_contexts,
uint16_t failed_owner_count) {
for (uint16_t s = 0; s < pool.state_slot_count; ++s) {
uint64_t state_bit = (uint64_t)1 << s;
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) {
// 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,
// making recovery jump to state the listener never heard.
continue;
}
bool owner_failed = false;
for (uint16_t i = 0; i < failed_owner_count; i++) {
if (failed_owner_contexts[i] == owner) {
owner_failed = true;
break;
}
}
if (owner_failed) continue;
Sample v = workspace[pool.state_update_roots[s]];
// Never commit non-finite state: in LkgFallback mode NaN/Inf can
// flow through the workspace, and a poisoned state slot would
// never recover. Keep the previous (finite) value instead.
if (std::isfinite(v)) {
pool.state_values[s] = v;
pool.state_update_failure_mask &= ~state_bit;
} else {
pool.state_update_failure_mask |= state_bit;
}
} else {
pool.state_update_failure_mask &= ~state_bit;
}
}
if (pool.state_slot_count < 64) {
uint64_t live_bits = pool.state_slot_count == 0
? 0 : (((uint64_t)1 << pool.state_slot_count) - 1);
pool.state_update_failure_mask &= live_bits;
}
}
// ── Batched Execution ───────────────────────────────────────────────────────
void execute_batch(
const NodePool& pool,
const Sample* t_array,
size_t sample_count,
const Sample* cell_values,
const Sample* hw_inputs,
const Sample* data_pool,
const uint16_t* data_offsets,
const uint16_t* data_lengths,
Sample* output_buffer,
uint16_t num_outputs
) {
if (!pool.batch_workspace) return;
const size_t CHUNK = pool.batch_chunk_size;
Sample* regs = pool.batch_workspace.get();
uint64_t fallback_mask = 0;
for (size_t chunk_start = 0; chunk_start < sample_count; chunk_start += CHUNK) {
size_t chunk_size = std::min(CHUNK, sample_count - chunk_start);
for (uint16_t ni = 0; ni < pool.exec_count; ni++) {
uint16_t idx = pool.exec_order[ni];
const Node& n = pool.nodes[idx];
Sample* reg_out = regs + (size_t)idx * CHUNK;
if (n.op == NodeOp::SlotLoad) {
uint16_t slot_idx = (uint16_t)n.imm;
Sample val = (slot_idx < pool.live_slot_count)
? pool.live_slots[slot_idx].value : 0.0;
for (size_t s = 0; s < chunk_size; s++) reg_out[s] = val;
} else if (n.flags & FLAG_TIME_INVARIANT) {
// Compute once, broadcast.
// Read input values from registers — time-invariant inputs
// were already computed and are the same for every sample,
// so reading at index 0 is sufficient.
Sample a = (n.input_a != NODE_NONE) ? regs[(size_t)n.input_a * CHUNK] : 0.0;
Sample b = (n.input_b != NODE_NONE) ? regs[(size_t)n.input_b * CHUNK] : 0.0;
Sample c = (n.input_c != NODE_NONE) ? regs[(size_t)n.input_c * CHUNK] : 0.0;
Sample val = eval_node(n, a, b, c, 0.0, 0.0,
cell_values, hw_inputs,
data_pool, data_offsets, data_lengths,
pool.prev_output_values,
pool.state_values);
if (g_failure_mode == FailureMode::ZeroSquash &&
!std::isfinite(val)) val = 0.0;
for (size_t s = 0; s < chunk_size; s++) reg_out[s] = val;
} else {
for (size_t s = 0; s < chunk_size; s++) {
Sample t = t_array[chunk_start + s];
Sample a = (n.input_a != NODE_NONE) ? regs[(size_t)n.input_a * CHUNK + s] : 0.0;
Sample b = (n.input_b != NODE_NONE) ? regs[(size_t)n.input_b * CHUNK + s] : 0.0;
Sample c = (n.input_c != NODE_NONE) ? regs[(size_t)n.input_c * CHUNK + s] : 0.0;
Sample result = eval_node(n, a, b, c, t, 0.0,
cell_values, hw_inputs,
data_pool, data_offsets, data_lengths,
pool.prev_output_values,
pool.state_values);
if (g_failure_mode == FailureMode::ZeroSquash &&
!std::isfinite(result)) result = 0.0;
reg_out[s] = result;
}
}
}
// Copy output values for this chunk.
// Row-packing MUST match how callers count active outputs and build
// their index_to_row map. Callers (wasm_wrapper.cpp) and the other
// batch paths (execute_batch_sequential, project_from_fork) all key on
// outputs[o].valid, so we do too. Keying on root_node != NODE_NONE here
// diverged from that during the post-compile-fail window (valid==false
// but root_node preserved), which mislabelled one output's samples as
// another's. An output with valid && root_node==NODE_NONE holds only an
// LKG scalar and has no register row, so fall back to its lkg_value.
uint16_t out_idx = 0;
for (uint16_t o = 0; o < MAX_OUTPUTS && out_idx < num_outputs; o++) {
if (pool.outputs[o].valid) {
Sample* dst = output_buffer + (size_t)out_idx * sample_count + chunk_start;
if (pool.outputs[o].root_node != NODE_NONE) {
Sample* src = regs + (size_t)pool.outputs[o].root_node * CHUNK;
if (g_failure_mode == FailureMode::LkgFallback) {
// Non-finite at the root → substitute LKG per sample
// and record the fallback (failure-model.md §10.2).
Sample lkg = pool.outputs[o].lkg_value;
for (size_t s = 0; s < chunk_size; s++) {
Sample v = src[s];
if (!std::isfinite(v)) {
v = lkg;
fallback_mask |= (uint64_t)1 << o;
}
dst[s] = v;
}
} else {
memcpy(dst, src, chunk_size * sizeof(Sample));
}
} else {
Sample lkg = pool.outputs[o].lkg_value;
for (size_t s = 0; s < chunk_size; s++) dst[s] = lkg;
}
out_idx++;
}
}
}
pool.runtime_fallback_mask = fallback_mask;
}
// ── Output Classification ───────────────────────────────────────────────────
struct ClassifyResult {
bool has_state; // LoadState, LoadDt, or PrevOutputLoad
bool has_input; // InputLoad
uint32_t input_mask; // bitmask of hw input channels
};
static void classify_node_tree(const NodePool& pool, uint16_t root, ClassifyResult& result) {
if (root == NODE_NONE || root >= pool.node_count) return;
bool visited[MAX_TOTAL_NODES] = {};
uint16_t stack[MAX_TOTAL_NODES];
uint16_t sp = 0;
stack[sp++] = root;
while (sp > 0) {
uint16_t idx = stack[--sp];
if (idx == NODE_NONE || idx >= pool.node_count) continue;
if (visited[idx]) continue;
visited[idx] = true;
const Node& n = pool.nodes[idx];
switch (n.op) {
case NodeOp::LoadState:
case NodeOp::LoadDt:
case NodeOp::PrevOutputLoad:
result.has_state = true;
break;
case NodeOp::InputLoad:
result.has_input = true;
if ((uint16_t)n.imm < 32)
result.input_mask |= (1u << (uint16_t)n.imm);
break;
default:
break;
}
// Check visited before pushing to avoid stack overflow in dense DAGs
if (n.input_a != NODE_NONE && !visited[n.input_a] && sp < MAX_TOTAL_NODES) stack[sp++] = n.input_a;
if (n.input_b != NODE_NONE && !visited[n.input_b] && sp < MAX_TOTAL_NODES) stack[sp++] = n.input_b;
if (n.input_c != NODE_NONE && !visited[n.input_c] && sp < MAX_TOTAL_NODES) stack[sp++] = n.input_c;
}
}
void classify_outputs(NodePool& pool) {
// First pass: classify each output by its own node tree only
for (uint16_t i = 0; i < MAX_OUTPUTS; i++) {
if (!pool.outputs[i].valid || pool.outputs[i].root_node == NODE_NONE) {
pool.output_class[i] = OutputClass::Inactive;
pool.output_input_mask[i] = 0;
continue;
}
ClassifyResult cr = {};
classify_node_tree(pool, pool.outputs[i].root_node, cr);
if (cr.has_state) {
pool.output_class[i] = OutputClass::Stateful;
} else if (cr.has_input) {
pool.output_class[i] = OutputClass::InputDep;
} else {
pool.output_class[i] = OutputClass::Pure;
}
pool.output_input_mask[i] = cr.input_mask;
}
// If any state slots exist, outputs that reference LoadState/LoadDt are
// already marked Stateful. State update roots are part of the stateful
// outputs' computation — they don't pollute pure outputs.
}
} // namespace sig

View file

@ -0,0 +1,112 @@
#ifndef SIGNAL_ENGINE_EXECUTOR_H
#define SIGNAL_ENGINE_EXECUTOR_H
#include "node_pool.h"
#include "cell_store.h"
namespace sig {
// ── Failure Mode (failure-model.md §2.1/§3.1) ───────────────────────────────
// Global runtime policy for non-finite values (NaN/Inf) during execution.
//
// 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).
// ZeroSquash (legacy): every node's result is clamped to 0.0 when
// non-finite. No fallback, no diagnostic — pre-v1.2 behaviour.
//
// The mode is engine-global (not per-output); configurable over the serial
// wire protocol ("set-failure-mode") and the WASM export
// useq_set_failure_mode(). See docs/specs/failure-model.md §3.
enum class FailureMode : uint8_t {
LkgFallback = 0,
ZeroSquash = 1,
};
void set_failure_mode(FailureMode mode);
FailureMode get_failure_mode();
// Output assignment and last-good existence are independent. In
// particular, an active program whose first sample is non-finite is Error,
// while the same failure after a finite commit is Fallback.
enum class OutputHealth : uint8_t {
Idle,
Running,
Fallback,
Error,
};
OutputHealth output_health(const NodePool& pool, uint16_t output_index);
const char* output_health_to_cstr(OutputHealth health);
// ── Execution Context ──────────────────────────────────────────────────────
// Bundles every per-tick datum the executor needs, replacing the previous
// 10-parameter execute_all_outputs signature.
struct ExecutionContext {
Sample t;
Sample dt; // time delta since last tick
const Sample* cell_values;
const Sample* hw_inputs;
const Sample* data_pool;
const uint16_t* data_offsets;
const uint16_t* data_lengths;
const Sample* prev_outputs;
Sample* output_values; // out [MAX_OUTPUTS]
Sample* workspace; // scratch [MAX_TOTAL_NODES]
};
// ── Single-Sample Execution ─────────────────────────────────────────────────
// One forward pass through topologically-sorted nodes.
// Uses LKG fallback for outputs with a current graph and a finite committed
// previous value; a first-ever failure emits neutral zero and remains Error.
void execute_all_outputs(const NodePool& pool, ExecutionContext& ctx);
// ── Post-Tick Commit ────────────────────────────────────────────────────────
// After execute_all_outputs, call this to:
// 1. Copy output_values → pool.prev_output_values (for next tick's PrevOutputLoad)
// 2. Promote only finite, non-substituted roots to lkg_value / has_lkg
// This mutates pool state, so it is NOT used in the batch/visualization path.
void commit_outputs(NodePool& pool, const Sample* output_values);
// ── Post-Tick State Commit ─────────────────────────────────────────────────
// After execute_all_outputs, call this to update state slots from their
// update graphs. State update roots must already have been executed as
// part of the node graph (they share the workspace). Non-finite candidates
// retain the previous value and publish state_update_failure_mask until a
// later finite candidate clears it.
void commit_state(NodePool& pool, const Sample* workspace,
const uint16_t* failed_owner_contexts = nullptr,
uint16_t failed_owner_count = 0);
// ── Batched Execution (WASM Visualization) ──────────────────────────────────
// SOA execution across a time window for efficient visualization.
void execute_batch(
const NodePool& pool,
const Sample* t_array,
size_t sample_count,
const Sample* cell_values,
const Sample* hw_inputs,
const Sample* data_pool,
const uint16_t* data_offsets,
const uint16_t* data_lengths,
Sample* output_buffer, // [num_outputs × sample_count], row-major
uint16_t num_outputs
);
// ── Output Classification ───────────────────────────────────────────────────
// Walk each output's node graph to determine OutputClass and input dependency
// bitmask. Call after compilation or graph changes. Writes directly into
// pool.output_class[] and pool.output_input_mask[].
void classify_outputs(NodePool& pool);
} // namespace sig
#endif // SIGNAL_ENGINE_EXECUTOR_H

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,471 @@
#ifndef SIGNAL_ENGINE_GRAPH_BUILDER_H
#define SIGNAL_ENGINE_GRAPH_BUILDER_H
#include "types.h"
#include "token.h"
#include "node_pool.h"
#include "cell_store.h"
#include "state_registry.h"
#include "diagnostics.h"
#include <cstring>
namespace sig {
// ── Cross-output live-edit ID tracking ──────────────────────────────────────
// Shared across all build_output_graph calls within one eval batch.
// Detects duplicate :id across separate output compilations.
struct SharedLiveEditIDs {
char ids[MAX_LIVE_SLOTS][MAX_LIVE_SLOT_ID] = {};
uint16_t count = 0;
void clear() { count = 0; }
bool contains(const char* id) const {
for (uint16_t i = 0; i < count; i++) {
if (strncmp(ids[i], id, MAX_LIVE_SLOT_ID) == 0) return true;
}
return false;
}
void add(const char* id) {
if (count < MAX_LIVE_SLOTS) {
const size_t length = strnlen(id, MAX_LIVE_SLOT_ID - 1);
memcpy(ids[count], id, length);
ids[count][length] = '\0';
count++;
}
}
};
// ── Scope (local bindings for let, lambda params, for variables) ────────────
struct Scope {
struct Binding {
SymbolID name;
uint16_t node_index;
};
Binding locals[MAX_LOCAL_BINDINGS] = {};
uint8_t local_count = 0;
Scope* parent = nullptr;
// Returns false if the local-binding pool is full (caller must report a
// diagnostic — a dropped binding would silently resolve to the wrong value).
bool bind(SymbolID name, uint16_t node_index);
const Binding* find(SymbolID name) const;
};
// ── Time Context ────────────────────────────────────────────────────────────
struct TimeContext {
uint16_t t_node; // node index representing "current t" (may be transformed)
};
// ── Bounded graph candidate IR ─────────────────────────────────────────────
// A successful build identifies a root and its complete retained dependency
// set, but is not published by GraphBuilder. Callers keep it behind a
// GraphMutationTransaction until source capacity and enclosing-form validation
// succeed, then install it through the cold evaluator's publication stage.
struct GraphBuildResult {
uint16_t root_node = NODE_NONE;
Diagnostic diagnostics[MAX_DIAGNOSTICS] = {};
uint8_t diagnostic_count = 0;
bool has_error = false;
// Cell dependencies discovered during graph compilation
CellIndex dep_cells[MAX_OUTPUT_DEPS] = {};
uint8_t dep_count = 0;
};
// ── Graph Builder ───────────────────────────────────────────────────────────
struct StateResourceRegistry;
enum class SymbolCategory : uint8_t {
none,
arith,
cmp,
logic,
unary,
binary,
ternary,
time_warp,
control,
signal,
side_effect,
waveform
};
enum class OperatorInputDomain : uint8_t { None, Angle, Phase, Scalar };
enum class OperatorOutputRange : uint8_t { None, Unipolar, Bipolar };
enum class OperatorRegime : uint8_t { None, Pure, PureShaper, Stateful, TopLevel };
enum class ColdEvaluable : uint8_t { No, Yes };
enum class BareIdentity : uint8_t {
Native,
NormalizedUnipolar,
NormalizedBipolar,
UnipolarShaper,
LfoUnipolar,
LfoBipolar
};
enum class OperatorNamespace : uint8_t {
None,
Normalized,
Radians,
Unipolar,
Bipolar,
Lfo,
BipolarLfo,
Osc,
Cold,
Raw,
Unknown,
Malformed
};
struct OperatorDeclaration {
SymbolID symbol = SymbolIntern::INVALID_ID;
const char* spelling = nullptr;
SymbolCategory category = SymbolCategory::none;
OperatorInputDomain input_domain = OperatorInputDomain::None;
OperatorOutputRange output_range = OperatorOutputRange::None;
OperatorRegime regime = OperatorRegime::None;
ColdEvaluable cold_evaluable = ColdEvaluable::No;
BareIdentity bare_identity = BareIdentity::Native;
};
struct NamespacedOperator {
OperatorNamespace name_space = OperatorNamespace::None;
SymbolID base_symbol = SymbolIntern::INVALID_ID;
const OperatorDeclaration* declaration = nullptr;
uint16_t namespace_span_len = 0;
};
struct GraphBuilder {
NodePool& pool;
CellStore& cells;
const SourceArena& source;
const char* source_base = nullptr; // raw tokenized text for string resolution
StateResourceRegistry* registry = nullptr;
// Diagnostics output
Diagnostic diagnostics[MAX_DIAGNOSTICS] = {};
uint8_t diagnostic_count = 0;
bool has_error = false;
// Dependency tracking (populated during build)
CellIndex dep_cells[MAX_OUTPUT_DEPS] = {};
uint8_t dep_count = 0;
// Recursion guard for inline stack
SymbolID inline_stack[MAX_INLINE_DEPTH] = {};
uint8_t inline_depth = 0;
// Syntactic nesting-depth guard for compile_expr recursion (RP2040 stack
// protection — see MAX_COMPILE_DEPTH in types.h).
uint16_t compile_depth = 0;
// Live-edit: slot count at build start (to detect fresh allocations vs pre-existing)
uint16_t live_slot_count_at_start = 0;
// Live-edit ids seen during this build (duplicate detection within one graph).
// Capped at 64 per single output — a single output won't have 256 live-edits.
static constexpr uint16_t MAX_IDS_PER_BUILD = 64;
char live_edit_ids_seen[MAX_IDS_PER_BUILD][MAX_LIVE_SLOT_ID] = {};
uint16_t live_edit_ids_count = 0;
// Cross-output shared ID tracking (set by build_output_graph when provided)
SharedLiveEditIDs* shared_live_edit_ids = nullptr;
struct LiveSlotUndo {
uint16_t slot_index = 0;
Sample value = 0.0;
Sample min_val = 0.0;
Sample max_val = 1.0;
Sample seed = 0.0;
};
static constexpr uint16_t MAX_LIVE_SLOT_UNDO =
MAX_LIVE_SLOTS < MAX_IDS_PER_BUILD ? MAX_LIVE_SLOTS : MAX_IDS_PER_BUILD;
LiveSlotUndo live_slot_undo[MAX_LIVE_SLOT_UNDO] = {};
uint16_t live_slot_undo_count = 0;
// Context flag: when true, compile_live_edit emits an error
bool reject_live_edit = false;
// Anonymous state identity (state-identity.md §2.5): which program this
// build compiles (output index, or MAX_OUTPUTS + state slot for defstate
// update graphs), and the ordinal of the next anonymous state allocation
// within this build. Together they form a structural/positional key so
// recompiles of the same program REUSE state slots via the registry
// instead of leaking a fresh slot per compile.
uint16_t anon_state_context = ANON_STATE_CONTEXT_NONE;
uint16_t anon_state_ordinal = 0;
// Duplicate-active-:id detection (state-identity.md §5.3/§8.1): slots
// claimed with an update root during THIS build. Two stateful forms in
// one compiled graph resolving to the same StateResourceKey would both
// install update roots on the same slot — ambiguous, must be rejected.
bool slot_claimed_this_build[MAX_STATE_SLOTS] = {};
// ── Well-known symbol IDs (populated at init) ───────────────────────
// Generated from symbols.def — do not edit by hand.
struct Symbols {
#define SYM(field, str, cat) SymbolID field;
#include "symbols.def"
#undef SYM
};
static Symbols sym;
static void init_symbols();
static bool symbols_initialized;
static constexpr uint16_t MAX_OPERATOR_DECLARATIONS = 192;
static OperatorDeclaration operator_declarations[MAX_OPERATOR_DECLARATIONS];
static uint16_t operator_declaration_count;
static const OperatorDeclaration* find_operator_declaration(SymbolID symbol);
static NamespacedOperator resolve_namespaced_operator(SymbolID symbol);
static bool namespace_applies(OperatorNamespace name_space,
const OperatorDeclaration& declaration);
// ── Form dispatch table ─────────────────────────────────────────────
struct FormEntry {
SymbolID sym;
uint16_t (GraphBuilder::*handler)(TokenStream&, Scope&, TimeContext&);
};
static constexpr uint16_t FORM_TABLE_CAPACITY = 64;
static FormEntry form_table[FORM_TABLE_CAPACITY];
static uint16_t form_table_count;
static bool form_table_sorted;
static void init_form_table();
// ── Construction ────────────────────────────────────────────────────
GraphBuilder(NodePool& pool, CellStore& cells, const SourceArena& source);
// ── Compilation entry points ────────────────────────────────────────
uint16_t compile_expr(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_inline_lambda_call(TokenStream& ts, Scope& scope,
TimeContext& ctx);
uint16_t compile_symbol(SymbolID sym, Scope& scope, TimeContext& ctx,
uint16_t span_start, uint16_t span_len);
uint16_t compile_form(SymbolID op, TokenStream& ts, Scope& scope,
TimeContext& ctx, Token op_tok);
uint16_t compile_namespaced_form(const NamespacedOperator& resolved,
TokenStream& ts, Scope& scope,
TimeContext& ctx, Token op_tok);
// ── Form handlers ───────────────────────────────────────────────────
// Time transforms
uint16_t compile_fast(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_slow(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_offset(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_loop_at(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_eval_at_time(TokenStream& ts, Scope& scope, TimeContext& ctx);
// Control flow
uint16_t compile_if(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_let(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_do(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_for(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_while_gate(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_lambda(TokenStream& ts, Scope& scope, TimeContext& ctx);
// Output feedback
uint16_t compile_prev(TokenStream& ts, Scope& scope, TimeContext& ctx);
// User-defined function call
uint16_t compile_call(SymbolID fn_sym, TokenStream& ts, Scope& scope,
TimeContext& ctx, Token op_tok);
// Arithmetic and math
uint16_t compile_variadic_arithmetic(SymbolID op, TokenStream& ts,
Scope& scope, TimeContext& ctx);
uint16_t compile_comparison(SymbolID op, TokenStream& ts,
Scope& scope, TimeContext& ctx);
uint16_t compile_logic(SymbolID op, TokenStream& ts,
Scope& scope, TimeContext& ctx);
uint16_t compile_unary_math(NodeOp op, TokenStream& ts,
Scope& scope, TimeContext& ctx);
uint16_t compile_binary_math(NodeOp op, TokenStream& ts,
Scope& scope, TimeContext& ctx);
uint16_t compile_binary_math_swapped(NodeOp op, TokenStream& ts,
Scope& scope, TimeContext& ctx);
uint16_t compile_ternary_math(NodeOp op, TokenStream& ts,
Scope& scope, TimeContext& ctx);
// Domain-specific signal functions
uint16_t compile_step(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_gates(SymbolID op, TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_euclid(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_seq(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_interp(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_dm(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_gatesw(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_range(TokenStream& ts, Scope& scope, TimeContext& ctx);
// Ratio-rhythm functions
uint16_t compile_rpulse(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_rstep(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_ridx(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_rwarp(TokenStream& ts, Scope& scope, TimeContext& ctx);
// State
uint16_t compile_integrate(TokenStream& ts, Scope& scope, TimeContext& ctx);
// UGens
uint16_t compile_phasor(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_lfo(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_blfo(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_lfo_sin(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_lfo_tri(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_lfo_saw(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_lfo_sqr(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_slew(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_one_pole(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_env_follow(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_sah(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_noise(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_toggle(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_count(TokenStream& ts, Scope& scope, TimeContext& ctx);
// UGen helpers
uint16_t alloc_state_slot(Sample init_value);
uint16_t report_unknown_ugen_keyword(const Token& kw);
uint16_t resolve_or_alloc(StateID state_id, ResourceKind kind, uint8_t role,
Sample init_value);
uint16_t build_lfo(TokenStream& ts, Scope& scope, TimeContext& ctx, uint16_t default_wave);
uint16_t build_osc_output(uint16_t state_load, uint16_t wave_type, uint16_t pw_node);
uint16_t compile_lfo_namespace(SymbolID waveform, bool bipolar,
TokenStream& ts, Scope& scope,
TimeContext& ctx, Token op_tok);
uint16_t compile_waveform(SymbolID waveform, OperatorNamespace name_space,
TokenStream& ts, Scope& scope,
TimeContext& ctx, Token op_tok);
uint16_t compile_cold_namespace(SymbolID op, TokenStream& ts,
Scope& scope, TimeContext& ctx,
Token op_tok);
// Live-edit
uint16_t compile_live_edit(TokenStream& ts, Scope& scope, TimeContext& ctx);
void remember_live_slot(uint16_t slot_index);
void rollback_live_slots();
// Random / hash
uint16_t compile_random(TokenStream& ts, Scope& scope, TimeContext& ctx);
uint16_t compile_index_rand(TokenStream& ts, Scope& scope, TimeContext& ctx);
// Vector literal [1 2 3]
uint16_t compile_vector_literal(TokenStream& ts, Scope& scope, TimeContext& ctx);
// ── Temporal templates ──────────────────────────────────────────────
uint16_t expand_beat(TimeContext& ctx);
uint16_t expand_bar(TimeContext& ctx);
uint16_t expand_phrase(TimeContext& ctx);
uint16_t expand_section(TimeContext& ctx);
uint16_t expand_beat_num(TimeContext& ctx);
uint16_t expand_bar_num(TimeContext& ctx);
// ── Collection resolution (for `for` loops) ─────────────────────────
struct Collection {
uint16_t element_nodes[64] = {};
uint16_t count = 0;
bool ok = false;
};
Collection resolve_collection(TokenStream& ts, Scope& scope, TimeContext& ctx);
Collection resolve_literal_collection(TokenStream& ts, Scope& scope,
TimeContext& ctx);
Collection resolve_symbol_collection(TokenStream& ts);
Collection resolve_range_collection(TokenStream& ts, Scope& scope,
TimeContext& ctx);
void fill_range_collection(Collection& collection, Sample start,
Sample end, Sample step);
// ── Data table resolution ───────────────────────────────────────────
struct DataRef {
uint16_t table_id;
uint16_t length;
bool ok;
};
DataRef resolve_data_table(TokenStream& ts, Scope& scope, TimeContext& ctx);
// ── Dependency tracking ─────────────────────────────────────────────
void add_dependency(SymbolID sym);
// ── Inline stack ────────────────────────────────────────────────────
bool is_in_inline_stack(SymbolID sym) const;
void push_inline_stack(SymbolID sym);
void pop_inline_stack();
// ── Inline expression cell ──────────────────────────────────────────
uint16_t inline_expression_cell(SymbolID sym, const CallableInfo& info,
Scope& scope, TimeContext& ctx);
// ── Error reporting ─────────────────────────────────────────────────
uint16_t report_error(const Token& tok, const char* message,
const char* suggestion = nullptr);
uint16_t report_error_at(uint16_t span_start, uint16_t span_len,
const char* message, const char* suggestion = nullptr);
// Category-aware error reporting — prefer these over the Runtime-defaulting overloads
uint16_t report_error_cat(DiagnosticCategory cat, const Token& tok,
const char* message, const char* suggestion = nullptr);
uint16_t report_error_at_cat(DiagnosticCategory cat,
uint16_t span_start, uint16_t span_len,
const char* message, const char* suggestion = nullptr);
uint16_t report_error_with_fuzzy_match(SymbolID sym,
uint16_t span_start, uint16_t span_len);
uint16_t report_warning(uint16_t span_start, uint16_t span_len,
const char* message, const char* suggestion = nullptr);
// ── Helpers ─────────────────────────────────────────────────────────
bool is_const(uint16_t node_idx) const;
Sample const_value(uint16_t node_idx) const;
bool is_side_effect_form(SymbolID op) const;
bool is_arithmetic_op(SymbolID op) const;
bool is_comparison_op(SymbolID op) const;
bool is_logic_op(SymbolID op) const;
bool is_unary_math(SymbolID op) const;
bool is_binary_math(SymbolID op) const;
bool is_ternary_math(SymbolID op) const;
static bool is_output_symbol(SymbolID op);
static uint16_t resolve_output_index(SymbolID op);
static uint16_t resolve_hardware_input(SymbolID sym);
NodeOp arithmetic_sym_to_op(SymbolID op) const;
NodeOp comparison_sym_to_op(SymbolID op) const;
NodeOp unary_sym_to_op(SymbolID op) const;
// Skip past a complete form in the token stream (for deferred parsing).
static void skip_form(TokenStream& ts);
void report_oversized_vector(TokenStream& ts, const Token& excess);
};
// Shared "Too many state variables (max N)" message with the build's actual
// MAX_STATE_SLOTS cap baked in (16 on firmware, 32 on desktop/WASM).
const char* state_slots_exhausted_msg();
// ── Top-level build function ────────────────────────────────────────────────
GraphBuildResult build_output_graph(
NodePool& pool,
TokenStream& ts,
CellStore& cells,
const SourceArena& source,
const char* source_base = nullptr,
StateResourceRegistry* registry = nullptr,
SharedLiveEditIDs* shared_ids = nullptr,
uint16_t anon_state_context = ANON_STATE_CONTEXT_NONE
);
} // namespace sig
#endif // SIGNAL_ENGINE_GRAPH_BUILDER_H

View file

@ -0,0 +1,517 @@
#include "node_pool.h"
#include "eval_ops.h"
#include <cstring>
#include <cmath>
#include <algorithm>
namespace sig {
// ── Constant folding evaluation ─────────────────────────────────────────────
// Thin wrappers that delegate to the shared eval_ops.h functions.
Sample eval_unary(NodeOp op, Sample a) { return eval_unary_op(op, a); }
Sample eval_binop(NodeOp op, Sample a, Sample b) { return eval_binary_op(op, a, b); }
Sample eval_ternary(NodeOp op, Sample a, Sample b, Sample c) { return eval_ternary_op(op, a, b, c); }
// ── OutputDeps ──────────────────────────────────────────────────────────────
void OutputDeps::clear() {
count = 0;
slot_count = 0;
}
void OutputDeps::add(CellIndex cell_index) {
// Deduplicate
for (uint8_t i = 0; i < count; i++) {
if (cells[i] == cell_index) return;
}
if (count < MAX_OUTPUT_DEPS) {
cells[count++] = cell_index;
}
}
bool OutputDeps::contains(SymbolID sym) const {
if (sym >= MAX_CELLS) return false;
for (uint8_t i = 0; i < count; i++) {
if (cells[i] == static_cast<CellIndex>(sym)) return true;
}
return false;
}
void OutputDeps::add_slot(uint16_t slot_index) {
// Deduplicate
for (uint16_t i = 0; i < slot_count; i++) {
if (slots[i] == slot_index) return;
}
if (slot_count < MAX_LIVE_SLOTS) {
slots[slot_count++] = slot_index;
}
}
bool OutputDeps::contains_slot(uint16_t slot_index) const {
for (uint16_t i = 0; i < slot_count; i++) {
if (slots[i] == slot_index) return true;
}
return false;
}
// ── Hashing for CSE ─────────────────────────────────────────────────────────
static uint32_t hash_node(const Node& n) {
// FNV-1a hash of the node's identity fields
uint32_t h = 2166136261u;
auto mix = [&](uint8_t byte) { h ^= byte; h *= 16777619u; };
mix((uint8_t)n.op);
mix((uint8_t)(n.input_a & 0xFF));
mix((uint8_t)(n.input_a >> 8));
mix((uint8_t)(n.input_b & 0xFF));
mix((uint8_t)(n.input_b >> 8));
mix((uint8_t)(n.input_c & 0xFF));
mix((uint8_t)(n.input_c >> 8));
// Hash the immediate value bytes
const uint8_t* imm_bytes = reinterpret_cast<const uint8_t*>(&n.imm);
for (int i = 0; i < 8; i++) mix(imm_bytes[i]);
return h;
}
static bool nodes_equal(const Node& a, const Node& b) {
return a.op == b.op
&& a.input_a == b.input_a
&& a.input_b == b.input_b
&& a.input_c == b.input_c
&& memcmp(&a.imm, &b.imm, sizeof(Sample)) == 0;
}
// ── NodePool ────────────────────────────────────────────────────────────────
uint16_t NodePool::intern_node(const Node& n) {
uint32_t h = hash_node(n);
uint32_t slot = h % CSE_TABLE_SIZE;
// Open-addressing probe
for (uint32_t probe = 0; probe < CSE_TABLE_SIZE; probe++) {
uint32_t idx = (slot + probe) % CSE_TABLE_SIZE;
if (cse_hashes[idx] == 0) {
// Empty slot — insert
if (node_count >= MAX_TOTAL_NODES) return NODE_NONE; // pool full
uint16_t ni = node_count++;
nodes[ni] = n;
cse_hashes[idx] = h | 1; // ensure non-zero (mark occupied)
cse_indices[idx] = ni;
return ni;
}
if (cse_hashes[idx] == (h | 1) && nodes_equal(nodes[cse_indices[idx]], n)) {
return cse_indices[idx]; // CSE hit
}
}
// Table full (shouldn't happen with 2x load factor)
if (node_count >= MAX_TOTAL_NODES) return NODE_NONE;
uint16_t ni = node_count++;
nodes[ni] = n;
return ni;
}
uint16_t NodePool::make_const(Sample value) {
Node n;
n.op = NodeOp::Const;
n.flags = FLAG_TIME_INVARIANT;
n.imm = value;
return intern_node(n);
}
uint16_t NodePool::make_raw_time_load() {
Node n;
n.op = NodeOp::RawTimeLoad;
n.flags = 0; // time-varying by definition
return intern_node(n);
}
uint16_t NodePool::make_cell_load(SymbolID cell_id) {
Node n;
n.op = NodeOp::CellLoad;
n.flags = FLAG_TIME_INVARIANT; // cells don't change per-sample
n.imm = (Sample)cell_id;
return intern_node(n);
}
uint16_t NodePool::make_input_load(uint16_t input_index) {
Node n;
n.op = NodeOp::InputLoad;
n.flags = 0; // hardware inputs can change per-sample
n.imm = (Sample)input_index;
return intern_node(n);
}
uint16_t NodePool::make_prev_output_load(uint16_t output_index) {
Node n;
n.op = NodeOp::PrevOutputLoad;
n.flags = 0;
n.imm = (Sample)output_index;
return intern_node(n);
}
uint16_t NodePool::make_state_load(uint16_t state_slot) {
Node n;
n.op = NodeOp::LoadState;
n.flags = 0; // state is time-varying (changes each tick)
n.imm = (Sample)state_slot;
return intern_node(n);
}
uint16_t NodePool::make_slot_load(uint16_t slot_index) {
Node n;
n.op = NodeOp::SlotLoad;
n.flags = 0; // live slots change externally per tick
n.imm = (Sample)slot_index;
return intern_node(n);
}
int16_t NodePool::find_live_slot(const char* id) const {
for (uint16_t i = 0; i < live_slot_count; i++) {
if (strncmp(live_slots[i].id, id, MAX_LIVE_SLOT_ID) == 0) {
return (int16_t)i;
}
}
return -1;
}
int16_t NodePool::alloc_live_slot(const char* id, Sample seed, Sample min_val, Sample max_val,
SlotVariant variant, Sample step, int precision,
uint16_t owner_context) {
int16_t existing = find_live_slot(id);
if (existing >= 0) {
live_slots[existing].min_val = min_val;
live_slots[existing].max_val = max_val;
live_slots[existing].seed = seed;
live_slots[existing].variant = variant;
live_slots[existing].step = step;
live_slots[existing].precision = precision;
// Reclamp existing value to new bounds (numeric only)
if (variant == SlotVariant::Numeric) {
Sample& v = live_slots[existing].value;
if (v < min_val) v = min_val;
if (v > max_val) v = max_val;
} else if (variant == SlotVariant::Boolean) {
Sample& v = live_slots[existing].value;
v = (v != 0.0) ? 1.0 : 0.0;
}
// Keyword: value is index into options, validated by caller
return existing;
}
if (live_slot_count >= MAX_LIVE_SLOTS) return -1;
uint16_t idx = live_slot_count++;
strncpy(live_slots[idx].id, id, MAX_LIVE_SLOT_ID - 1);
live_slots[idx].id[MAX_LIVE_SLOT_ID - 1] = '\0';
live_slots[idx].owner_context = owner_context;
live_slots[idx].value = seed;
live_slots[idx].min_val = min_val;
live_slots[idx].max_val = max_val;
live_slots[idx].seed = seed;
live_slots[idx].variant = variant;
live_slots[idx].step = step;
live_slots[idx].precision = precision;
return (int16_t)idx;
}
void NodePool::set_live_slot_value(const char* id, Sample value) {
int16_t idx = find_live_slot(id);
if (idx < 0) return;
set_live_slot_value_by_index((uint16_t)idx, value);
}
void NodePool::set_live_slot_value_by_index(uint16_t idx, Sample value) {
// Bounds-check against the live slot capacity. Out-of-range indices are
// ignored (wire-protocol §6.5: garbage/stale slot_index → skip).
if (idx >= live_slot_count) return;
// §5.9: reject non-finite numbers — slot retains previous value
if (!std::isfinite(value)) return;
switch (live_slots[idx].variant) {
case SlotVariant::Numeric: {
// Clamp to [min, max]
Sample clamped = value;
if (clamped < live_slots[idx].min_val) clamped = live_slots[idx].min_val;
if (clamped > live_slots[idx].max_val) clamped = live_slots[idx].max_val;
live_slots[idx].value = clamped;
break;
}
case SlotVariant::Boolean:
// Cast to 0.0 or 1.0
live_slots[idx].value = (value != 0.0) ? 1.0 : 0.0;
break;
case SlotVariant::Keyword: {
// Validate against options vector length
int index = (int)value;
if (index < 0 || index >= (int)live_slots[idx].options_count) return;
live_slots[idx].value = (Sample)index;
break;
}
}
}
uint16_t NodePool::make_dt_load() {
Node n;
n.op = NodeOp::LoadDt;
n.flags = 0; // dt varies per tick
return intern_node(n);
}
uint16_t NodePool::make_unary(NodeOp op, uint16_t a) {
if (a == NODE_NONE) return NODE_NONE;
const Node& na = get(a);
// Constant folding
if (na.op == NodeOp::Const) {
return make_const(eval_unary(op, na.imm));
}
uint8_t flags = na.flags & FLAG_TIME_INVARIANT;
Node n;
n.op = op;
n.flags = flags;
n.input_a = a;
return intern_node(n);
}
uint16_t NodePool::make_binop(NodeOp op, uint16_t a, uint16_t b) {
if (a == NODE_NONE || b == NODE_NONE) return NODE_NONE;
const Node& na = get(a);
const Node& nb = get(b);
// Constant folding
if (na.op == NodeOp::Const && nb.op == NodeOp::Const) {
return make_const(eval_binop(op, na.imm, nb.imm));
}
// Algebraic simplifications
if (op == NodeOp::Add && nb.op == NodeOp::Const && nb.imm == 0.0) return a;
if (op == NodeOp::Add && na.op == NodeOp::Const && na.imm == 0.0) return b;
if (op == NodeOp::Mul && nb.op == NodeOp::Const && nb.imm == 1.0) return a;
if (op == NodeOp::Mul && na.op == NodeOp::Const && na.imm == 1.0) return b;
// Do not fold x*0 or x-x without a proof that x is finite. IEEE-754
// gives NaN for Inf*0, NaN*0, Inf-Inf, and NaN-NaN; replacing those
// results with zero would suppress the output-root failure signal and
// change LKG/health semantics. The all-constant case above remains safe
// because it is evaluated through the same primitive as the hot path.
// NOTE: no `Div a a -> 1` fold (A10): IEEE 0/0 and Inf/Inf are NaN and
// must remain visible to output-root health/LKG handling. The constant/
// constant case is already folded through eval_binop above.
if (op == NodeOp::Div && nb.op == NodeOp::Const && nb.imm == 1.0) return a;
uint8_t flags = 0;
if ((na.flags & FLAG_TIME_INVARIANT) && (nb.flags & FLAG_TIME_INVARIANT)) {
flags |= FLAG_TIME_INVARIANT;
}
Node n;
n.op = op;
n.flags = flags;
n.input_a = a;
n.input_b = b;
return intern_node(n);
}
uint16_t NodePool::make_ternary(NodeOp op, uint16_t a, uint16_t b, uint16_t c) {
if (a == NODE_NONE || b == NODE_NONE || c == NODE_NONE) return NODE_NONE;
const Node& na = get(a);
const Node& nb = get(b);
const Node& nc = get(c);
// Constant folding
if (na.op == NodeOp::Const && nb.op == NodeOp::Const && nc.op == NodeOp::Const) {
return make_const(eval_ternary(op, na.imm, nb.imm, nc.imm));
}
// Select with constant condition
if (op == NodeOp::Select && na.op == NodeOp::Const) {
return (na.imm != 0.0) ? b : c;
}
uint8_t flags = 0;
if ((na.flags & FLAG_TIME_INVARIANT) && (nb.flags & FLAG_TIME_INVARIANT)
&& (nc.flags & FLAG_TIME_INVARIANT)) {
flags |= FLAG_TIME_INVARIANT;
}
Node n;
n.op = op;
n.flags = flags;
n.input_a = a;
n.input_b = b;
n.input_c = c;
return intern_node(n);
}
uint16_t NodePool::make_select(uint16_t cond, uint16_t true_val, uint16_t false_val) {
return make_ternary(NodeOp::Select, cond, true_val, false_val);
}
// ── Topological sort ────────────────────────────────────────────────────────
static void mark_reachable_nodes(const NodePool& pool, bool* marked) {
memset(marked, 0, MAX_TOTAL_NODES * sizeof(bool));
uint16_t stack[MAX_TOTAL_NODES];
uint16_t stack_top = 0;
// Mark on discovery, before enqueueing. Mark-on-pop allows a shared child
// to occupy the pending stack once per incoming edge; a high-sharing DAG
// can then fill a MAX_TOTAL_NODES stack with duplicates and silently drop
// a genuinely undiscovered dependency. With discovery marking, every
// valid node is pushed at most once, so stack_top is bounded by node_count
// (and node_count itself is bounded by MAX_TOTAL_NODES).
auto discover = [&](uint16_t child) {
if (child == NODE_NONE || child >= pool.node_count || marked[child]) return;
marked[child] = true;
stack[stack_top++] = child;
};
for (uint16_t o = 0; o < MAX_OUTPUTS; o++) {
discover(pool.outputs[o].root_node);
}
// Include state update roots in reachability
for (uint16_t s = 0; s < pool.state_slot_count; s++) {
discover(pool.state_update_roots[s]);
}
#if USEQ_HAS_SYNTH_ENGINE
// External roots are executable synth-control roots, not merely GC pins.
for (uint16_t e = 0; e < pool.external_root_count; e++) {
discover(pool.external_roots[e]);
}
#endif
while (stack_top > 0) {
uint16_t idx = stack[--stack_top];
const Node& n = pool.nodes[idx];
discover(n.input_a);
discover(n.input_b);
discover(n.input_c);
}
}
void NodePool::rebuild_execution_order() {
// Mark reachable nodes from every executable/publication root.
bool reachable[MAX_TOTAL_NODES];
mark_reachable_nodes(*this, reachable);
// Topological sort via Kahn's algorithm on reachable nodes
// Since node indices are allocated in dependency order (inputs before outputs),
// a simple forward scan of reachable nodes IS a valid topological order.
exec_count = 0;
for (uint16_t i = 0; i < node_count; i++) {
if (reachable[i]) {
exec_order[exec_count++] = i;
}
}
}
void NodePool::gc_unreachable_nodes() {
// 1. Mark reachable from output roots
bool live[MAX_TOTAL_NODES];
mark_reachable_nodes(*this, live);
// 2. Build remap table and compact live nodes to front
uint16_t remap[MAX_TOTAL_NODES];
memset(remap, 0xFF, sizeof(remap)); // NODE_NONE default
uint16_t new_count = 0;
for (uint16_t i = 0; i < node_count; i++) {
if (live[i]) {
remap[i] = new_count;
if (new_count != i) nodes[new_count] = nodes[i];
new_count++;
}
}
// 3. Update references in compacted nodes
for (uint16_t i = 0; i < new_count; i++) {
if (nodes[i].input_a != NODE_NONE) nodes[i].input_a = remap[nodes[i].input_a];
if (nodes[i].input_b != NODE_NONE) nodes[i].input_b = remap[nodes[i].input_b];
if (nodes[i].input_c != NODE_NONE) nodes[i].input_c = remap[nodes[i].input_c];
}
// 4. Update output roots
for (uint16_t o = 0; o < MAX_OUTPUTS; o++) {
if (outputs[o].root_node != NODE_NONE)
outputs[o].root_node = remap[outputs[o].root_node];
}
// 4b. Update state update roots
for (uint16_t s = 0; s < state_slot_count; s++) {
if (state_update_roots[s] != NODE_NONE)
state_update_roots[s] = remap[state_update_roots[s]];
}
#if USEQ_HAS_SYNTH_ENGINE
// 4c. Update host synth control roots.
for (uint16_t e = 0; e < external_root_count; e++) {
if (external_roots[e] != NODE_NONE)
external_roots[e] = remap[external_roots[e]];
}
#endif
node_count = new_count;
// 5. Rebuild CSE table from scratch (indices changed)
memset(cse_hashes, 0, sizeof(cse_hashes));
memset(cse_indices, 0, sizeof(cse_indices));
for (uint16_t i = 0; i < node_count; i++) {
uint32_t h = hash_node(nodes[i]);
uint32_t slot = h % CSE_TABLE_SIZE;
for (uint32_t probe = 0; probe < CSE_TABLE_SIZE; probe++) {
uint32_t idx = (slot + probe) % CSE_TABLE_SIZE;
if (cse_hashes[idx] == 0) {
cse_hashes[idx] = h | 1;
cse_indices[idx] = i;
break;
}
}
}
}
void NodePool::reset() {
for (uint16_t i = 0; i < MAX_TOTAL_NODES; i++) nodes[i] = Node{};
node_count = 0;
memset(exec_order, 0, sizeof(exec_order));
exec_count = 0;
memset(cse_hashes, 0, sizeof(cse_hashes));
memset(cse_indices, 0, sizeof(cse_indices));
for (uint16_t i = 0; i < MAX_OUTPUTS; i++) {
outputs[i] = OutputSlot{};
output_deps[i] = OutputDeps{};
}
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;
state_update_failure_mask = 0;
memset(state_values, 0, sizeof(state_values));
for (uint16_t s = 0; s < MAX_STATE_SLOTS; s++) {
state_update_roots[s] = NODE_NONE;
state_owner_context[s] = NODE_NONE;
}
state_slot_count = 0;
for (uint16_t i = 0; i < MAX_LIVE_SLOTS; i++)
live_slots[i] = LiveSlot{};
live_slot_count = 0;
#if USEQ_HAS_SYNTH_ENGINE
memset(external_roots, 0, sizeof(external_roots));
external_root_count = 0;
#endif
}
void NodePool::allocate_batch_workspace() {
if (!batch_workspace) {
batch_workspace.reset(new Sample[MAX_TOTAL_NODES * batch_chunk_size]);
}
}
void NodePool::free_batch_workspace() {
batch_workspace.reset();
}
} // namespace sig

View file

@ -0,0 +1,275 @@
#ifndef SIGNAL_ENGINE_NODE_POOL_H
#define SIGNAL_ENGINE_NODE_POOL_H
#include "types.h"
#include <memory>
namespace sig {
constexpr uint16_t LIVE_SLOT_OWNER_NONE = 0xFFFF;
// ── Node Operations ─────────────────────────────────────────────────────────
enum class NodeOp : uint8_t {
// Constants and loads
Const, // imm = value
RawTimeLoad, // loads the single 't' input
CellLoad, // imm = cell_id
InputLoad, // imm = input_index (hardware input channel)
PrevOutputLoad, // imm = output_index; previous tick's value
// Binary arithmetic
Add, Sub, Mul, Div, Mod, Expt, Min, Max,
// Unary math
Neg, Abs, Floor, Ceil, Frac, Sqrt, Clamp,
// Trigonometry
Sin, Cos, Tan,
// Domain waveforms (operate on phase [0,1))
USin, UCos,
Tri, Sqr, Pulse,
// Comparison (1.0 true, 0.0 false)
CmpGt, CmpLt, CmpGe, CmpLe, CmpEq,
// Logic
Not, And, Or,
// Control flow
Select, // input_a=cond, input_b=true_val, input_c=false_val
// Vector/data
VecIndex, // input_a=fractional_index, imm=table_id; floor, wrap
VecLerp, // input_a=fractional_index, imm=table_id; lerp
// Range conversion
BiToUni, // [-1,1] → [0,1]
UniToBi, // [0,1] → [-1,1]
Scale, // 3 inputs: value, out_min, out_max ([0,1]→[min,max])
Lerp, // 3 inputs: a, b, t → a + (b-a)*t
// Deterministic hash (pure function of input)
HashIndex, // input_a = index; deterministic hash → [0,1]
// Cross-sample state
LoadState, // imm = state_slot; reads state_values[slot]
LoadDt, // reads dt (time delta since last tick)
// Live-edit slots (externally-driven inputs from editor UI)
SlotLoad, // imm = live_slot_index; reads live_slots[slot].value
};
// ── Node ────────────────────────────────────────────────────────────────────
struct Node {
NodeOp op = NodeOp::Const;
uint8_t flags = 0; // FLAG_TIME_INVARIANT etc.
uint16_t input_a = NODE_NONE;
uint16_t input_b = NODE_NONE;
uint16_t input_c = NODE_NONE;
Sample imm = 0.0; // immediate value
uint16_t span_start = 0; // source location for diagnostics
uint16_t span_len = 0;
};
// sizeof(Node) == 20 bytes
// ── Output Slot ─────────────────────────────────────────────────────────────
struct OutputSlot {
uint16_t root_node = NODE_NONE;
Sample lkg_value = 0.0; // last known good output value
// `valid` is retained as the active-assignment bit for source/API
// compatibility. It does NOT mean that a healthy sample has been
// observed: a freshly compiled program is active before its first tick.
bool valid = false;
bool has_lkg = false; // at least one finite root was committed
uint8_t pad[6] = {};
};
// ── Per-Output Dependencies ─────────────────────────────────────────────────
// Populated during graph construction. Used for dirty recompilation.
struct OutputDeps {
CellIndex cells[MAX_OUTPUT_DEPS] = {};
uint8_t count = 0;
// Live-edit slot indices referenced by this output's graph.
// slot_count must be wide enough to actually reach MAX_LIVE_SLOTS: on the
// WASM build MAX_LIVE_SLOTS == 256, which a uint8_t can never represent, so
// a `slot_count < MAX_LIVE_SLOTS` guard with a uint8_t counter is always
// true and never fires. uint16_t covers both the firmware (16) and WASM
// (256) capacities.
uint16_t slots[MAX_LIVE_SLOTS] = {};
uint16_t slot_count = 0;
void clear();
void add(CellIndex cell_index);
bool contains(SymbolID sym) const;
void add_slot(uint16_t slot_index);
bool contains_slot(uint16_t slot_index) const;
};
// ── Node Pool ───────────────────────────────────────────────────────────────
struct NodePool {
Node nodes[MAX_TOTAL_NODES] = {};
uint16_t node_count = 0;
// Bounds-checked node accessor. Returns a static null node for
// NODE_NONE or out-of-range indices, avoiding undefined behaviour.
const Node& get(uint16_t idx) const {
static const Node null_node{};
if (idx == NODE_NONE || idx >= node_count) return null_node;
return nodes[idx];
}
// Hash-cons CSE table
uint32_t cse_hashes[CSE_TABLE_SIZE] = {};
uint16_t cse_indices[CSE_TABLE_SIZE] = {};
// Topologically sorted execution order
uint16_t exec_order[MAX_TOTAL_NODES] = {};
uint16_t exec_count = 0;
// Per-output metadata
OutputSlot outputs[MAX_OUTPUTS] = {};
OutputDeps output_deps[MAX_OUTPUTS] = {};
// Per-output classification (recomputed after each eval)
OutputClass output_class[MAX_OUTPUTS] = {};
uint32_t output_input_mask[MAX_OUTPUTS] = {}; // bitmask of hw input channels referenced
// 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
// 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");
// Bit s is active when state slot s most recently produced a non-finite
// update candidate. The previous finite state remains installed until
// that same update root recovers. This is separate from output fallback:
// a named defstate can fail while every consuming output remains finite.
uint64_t state_update_failure_mask = 0;
static_assert(MAX_STATE_SLOTS <= 64,
"state_update_failure_mask is 64-bit");
// ── Cross-sample state ──────────────────────────────────────────────
Sample state_values[MAX_STATE_SLOTS] = {}; // current state (read during execution)
uint16_t state_update_roots[MAX_STATE_SLOTS] = {}; // root node for each state's update expr (init to 0, set to NODE_NONE by init)
// Compiler context owning the sole update writer. Output contexts are
// 0..MAX_OUTPUTS-1; other live state sources use disjoint contexts.
// Runtime fallback freezes only state owned by its failed output.
uint16_t state_owner_context[MAX_STATE_SLOTS] = {};
uint16_t state_slot_count = 0;
// ── Live-edit slots (externally written by editor UI) ─────────────
enum class SlotVariant : uint8_t { Numeric = 0, Boolean = 1, Keyword = 2 };
struct LiveSlot {
char id[MAX_LIVE_SLOT_ID] = {};
uint16_t owner_context = LIVE_SLOT_OWNER_NONE;
Sample value = 0.0;
Sample min_val = 0.0;
Sample max_val = 1.0;
Sample seed = 0.0;
SlotVariant variant = SlotVariant::Numeric;
Sample step = 0.0;
int precision = -1; // -1 means unset
char options[MAX_LIVE_SLOT_OPTIONS][MAX_LIVE_SLOT_OPTION_LEN] = {};
uint8_t options_count = 0;
};
LiveSlot live_slots[MAX_LIVE_SLOTS] = {};
uint16_t live_slot_count = 0;
int16_t find_live_slot(const char* id) const;
int16_t alloc_live_slot(const char* id, Sample seed, Sample min_val, Sample max_val,
SlotVariant variant = SlotVariant::Numeric,
Sample step = 0.0, int precision = -1,
uint16_t owner_context = LIVE_SLOT_OWNER_NONE);
void set_live_slot_value(const char* id, Sample value);
// Fast-path write addressed by array index (wire-protocol §6.5 binary
// INPUT_SET). `idx` is a direct index into live_slots[0..live_slot_count).
// Out-of-range indices are ignored. Applies the same clamp / variant
// coercion as set_live_slot_value(), but skips the string lookup so it is
// allocation-free and cheap on the serial RX hot path.
void set_live_slot_value_by_index(uint16_t idx, Sample value);
// WASM batch workspace (heap-allocated once at init, null on firmware)
std::unique_ptr<Sample[]> batch_workspace;
uint16_t batch_chunk_size = BATCH_CHUNK_SIZE;
#if USEQ_HAS_SYNTH_ENGINE
// ── External root registration (host synth control roots) ───────────
// Some compiled roots live outside the per-output table — notably
// synth control channel expressions (synth-nodes.md §7.2). These roots
// must participate in GC reachability and remap exactly like output
// roots, otherwise forced GC after a synth eval drops the control
// expressions (VAL-COMP-011). External clients register root indices
// here; the GC walks and remaps them. The pool itself does not attach
// semantics to these slots.
static constexpr uint16_t MAX_EXTERNAL_ROOTS = MAX_SYNTH_CONTROL_ROOTS;
uint16_t external_roots[MAX_EXTERNAL_ROOTS] = {};
uint16_t external_root_count = 0;
void clear_external_roots() { external_root_count = 0; }
bool register_external_root(uint16_t node_idx) {
if (external_root_count >= MAX_EXTERNAL_ROOTS) return false;
external_roots[external_root_count++] = node_idx;
return true;
}
#endif
// ── Node construction (with CSE + constant folding) ─────────────────
uint16_t make_const(Sample value);
uint16_t make_raw_time_load();
uint16_t make_cell_load(SymbolID cell_id);
uint16_t make_input_load(uint16_t input_index);
uint16_t make_prev_output_load(uint16_t output_index);
uint16_t make_state_load(uint16_t state_slot);
uint16_t make_slot_load(uint16_t slot_index);
uint16_t make_dt_load();
uint16_t make_unary(NodeOp op, uint16_t a);
uint16_t make_binop(NodeOp op, uint16_t a, uint16_t b);
uint16_t make_ternary(NodeOp op, uint16_t a, uint16_t b, uint16_t c);
uint16_t make_select(uint16_t cond, uint16_t true_val, uint16_t false_val);
// ── Pool operations ─────────────────────────────────────────────────
// Hash-cons: intern a node, return index (reuse if identical exists).
uint16_t intern_node(const Node& n);
// Rebuild topological execution order from live output roots.
void rebuild_execution_order();
// Remove nodes not reachable from any output root.
void gc_unreachable_nodes();
// Reset the entire pool.
void reset();
// Allocate batch workspace (call once at init for WASM builds).
void allocate_batch_workspace();
void free_batch_workspace();
};
// ── Constant folding helpers ────────────────────────────────────────────────
Sample eval_unary(NodeOp op, Sample a);
Sample eval_binop(NodeOp op, Sample a, Sample b);
Sample eval_ternary(NodeOp op, Sample a, Sample b, Sample c);
} // namespace sig
#endif // SIGNAL_ENGINE_NODE_POOL_H

View file

@ -0,0 +1,15 @@
#ifndef SIGNAL_ENGINE_H
#define SIGNAL_ENGINE_H
// Convenience header: includes all signal engine components.
#include "types.h"
#include "diagnostics.h"
#include "token.h"
#include "cell_store.h"
#include "node_pool.h"
#include "graph_builder.h"
#include "executor.h"
#include "cold_eval.h"
#endif // SIGNAL_ENGINE_H

View file

@ -0,0 +1,113 @@
#include "state_registry.h"
#include <cstring>
namespace sig {
uint16_t StateResourceRegistry::resolve(const StateResourceKey& key,
Sample init_value,
Sample* state_values,
uint16_t& state_slot_count,
uint16_t owner_context) {
last_owner_conflict = false;
// Search for existing entry
for (uint16_t i = 0; i < entry_count; i++) {
if (entries[i].key == key) {
if (owner_context != ANON_STATE_CONTEXT_NONE &&
entries[i].owner_context != ANON_STATE_CONTEXT_NONE &&
entries[i].owner_context != owner_context) {
last_owner_conflict = true;
return NODE_NONE;
}
if (entries[i].owner_context == ANON_STATE_CONTEXT_NONE) {
entries[i].owner_context = owner_context;
}
entries[i].active = true;
return entries[i].slot_index;
}
}
// New entry — prefer a slot retired by a previously published graph.
if (entry_count >= MAX_STATE_SLOTS) return NODE_NONE;
uint16_t slot = take_free_slot();
if (slot == NODE_NONE) {
if (state_slot_count >= MAX_STATE_SLOTS) return NODE_NONE;
slot = state_slot_count++;
}
state_values[slot] = init_value;
entries[entry_count].key = key;
entries[entry_count].slot_index = slot;
entries[entry_count].owner_context = owner_context;
entries[entry_count].init_value = init_value;
entries[entry_count].active = true;
entry_count++;
return slot;
}
void StateResourceRegistry::begin_context(uint16_t owner_context) {
if (owner_context == ANON_STATE_CONTEXT_NONE) return;
for (uint16_t i = 0; i < entry_count; i++) {
if (entries[i].owner_context == owner_context) {
entries[i].active = false;
}
}
}
void StateResourceRegistry::commit_context(
uint16_t owner_context, uint16_t* state_update_roots,
uint16_t* state_owner_contexts) {
if (owner_context == ANON_STATE_CONTEXT_NONE) return;
uint16_t write = 0;
for (uint16_t read = 0; read < entry_count; read++) {
StateResourceEntry& entry = entries[read];
if (entry.owner_context == owner_context && !entry.active) {
if (entry.slot_index < MAX_STATE_SLOTS) {
state_update_roots[entry.slot_index] = NODE_NONE;
state_owner_contexts[entry.slot_index] =
ANON_STATE_CONTEXT_NONE;
// Each live registry entry owns a unique slot. Avoid adding a
// duplicate defensively so a malformed legacy image cannot
// make the same slot available twice.
bool already_free = false;
for (uint16_t f = 0; f < free_slot_count; f++) {
if (free_slots[f] == entry.slot_index) {
already_free = true;
break;
}
}
if (!already_free && free_slot_count < MAX_STATE_SLOTS) {
free_slots[free_slot_count++] = entry.slot_index;
}
}
continue;
}
if (write != read) entries[write] = entries[read];
write++;
}
entry_count = write;
}
uint16_t StateResourceRegistry::take_free_slot() {
if (free_slot_count == 0) return NODE_NONE;
return free_slots[--free_slot_count];
}
void StateResourceRegistry::mark_all_inactive() {
for (uint16_t i = 0; i < entry_count; i++) {
entries[i].active = false;
}
}
void StateResourceRegistry::clear() {
for (uint16_t i = 0; i < MAX_STATE_SLOTS; i++) {
entries[i] = StateResourceEntry{};
free_slots[i] = NODE_NONE;
}
entry_count = 0;
free_slot_count = 0;
last_owner_conflict = false;
}
} // namespace sig

View file

@ -0,0 +1,102 @@
#ifndef SIGNAL_ENGINE_STATE_REGISTRY_H
#define SIGNAL_ENGINE_STATE_REGISTRY_H
#include "types.h"
namespace sig {
using StateID = SymbolID;
// ── Anonymous structural identity (state-identity.md §2.5) ─────────────────
// Stateful expressions without an explicit :id get a synthetic StateID derived
// from structural context: which program is being compiled (output index, or
// MAX_OUTPUTS + state slot for defstate update graphs) plus the ordinal
// position of the allocation within that compile. Compilation is
// deterministic over source text, so recompiling a program resolves to the
// same keys and REUSES its slots instead of leaking a fresh slot per
// recompile. Synthetic IDs live far above any interned SymbolID, so they can
// never collide with user-supplied :id symbols.
constexpr StateID ANON_STATE_ID_BASE = 0x80000000u;
constexpr uint16_t ANON_STATE_CONTEXT_NONE = 0xFFFF;
inline StateID make_anon_state_id(uint16_t context, uint16_t ordinal) {
return ANON_STATE_ID_BASE | ((StateID)context << 12) | (StateID)ordinal;
}
enum class ResourceKind : uint8_t {
OscillatorPhase,
TriggerMemory,
HeldValue,
ToggleState,
Counter,
ResetLatch,
SlewAccumulator,
Integrator,
OnePole,
EnvelopeFollower,
NoiseCounter,
};
struct StateResourceKey {
StateID state_id;
ResourceKind kind;
uint8_t role;
bool operator==(const StateResourceKey& other) const {
return state_id == other.state_id &&
kind == other.kind &&
role == other.role;
}
};
struct StateResourceEntry {
StateResourceKey key;
uint16_t slot_index;
// Compiler context that owns the sole update writer for this resource.
// Output indices, defstate update contexts, and synth-control contexts
// inhabit one namespace; ANON_STATE_CONTEXT_NONE is reserved for legacy
// callers that do not publish a live graph.
uint16_t owner_context;
Sample init_value;
bool active;
};
struct StateResourceRegistry {
StateResourceEntry entries[MAX_STATE_SLOTS];
uint16_t entry_count = 0;
uint16_t free_slots[MAX_STATE_SLOTS];
uint16_t free_slot_count = 0;
bool last_owner_conflict = false;
// Resolve a key to a dense state slot index. If the key already exists,
// returns its slot and preserves accumulated state. If new, allocates a
// fresh slot and writes init_value. Returns NODE_NONE on overflow.
uint16_t resolve(const StateResourceKey& key, Sample init_value,
Sample* state_values, uint16_t& state_slot_count,
uint16_t owner_context = ANON_STATE_CONTEXT_NONE);
// Begin/commit publication for one compiler context. Existing resources
// owned by the context are marked unseen; resolving them reactivates
// them. On commit, resources no longer present are retired and their
// slots become reusable. A rejected build restores the registry snapshot
// instead of calling commit_context().
void begin_context(uint16_t owner_context);
void commit_context(uint16_t owner_context,
uint16_t* state_update_roots,
uint16_t* state_owner_contexts);
// Reclaimed UGen slots can also host a later named defstate. Returns
// NODE_NONE when there is no reusable hole.
uint16_t take_free_slot();
// Mark all entries inactive. Used before recompilation — entries that
// remain inactive after compilation are candidates for GC.
void mark_all_inactive();
// Full reset (useq-clear).
void clear();
};
} // namespace sig
#endif // SIGNAL_ENGINE_STATE_REGISTRY_H

View file

@ -0,0 +1,310 @@
// symbols.def X-macro symbol table for the signal engine graph builder.
// Each line: SYM(field_name, "lisp-string", category)
//
// Categories:
// none bare symbol, no special dispatch
// arith variadic arithmetic operator
// cmp comparison operator
// logic logic operator
// unary unary math function
// binary binary math function
// ternary ternary math function
// time_warp time transform
// control control flow
// signal domain signal function with custom compilation
// side_effect side-effect forms (error in signal context)
// waveform unary phase-to-value waveform functions
//
// Namespace metadata is declared with OP_META after the symbol inventory.
// Each row: OP_META(field, input-domain, output-range, regime,
// cold-evaluable, bare-identity)
// The compiler and reference-data generator derive namespace applicability
// from these declarations; neither owns a hand-curated applicability list.
// Temporal / time symbols
SYM(t, "t", none)
SYM(beat, "beat", none)
SYM(bar, "bar", none)
SYM(phrase, "phrase", none)
SYM(section, "section", none)
SYM(beat_num, "beat-num", none)
SYM(bar_num, "bar-num", none)
// Timing parameters
SYM(bpm, "bpm", none)
SYM(beats_per_bar, "beats-per-bar", none)
SYM(bars_per_phrase, "bars-per-phrase", none)
SYM(phrases_per_section, "phrases-per-section", none)
// Arithmetic operators
SYM(plus, "+", arith)
SYM(minus, "-", arith)
SYM(star, "*", arith)
SYM(slash, "/", arith)
SYM(mod_pct, "%", arith)
// Comparison operators
SYM(gt, ">", cmp)
SYM(lt, "<", cmp)
SYM(ge, ">=", cmp)
SYM(le, "<=", cmp)
SYM(eq, "=", cmp)
// Logic operators
SYM(not_, "not", logic)
SYM(and_, "and", logic)
SYM(or_, "or", logic)
// Time transforms
SYM(time_as, "time-as", time_warp)
SYM(fast, "fast", time_warp)
SYM(slow, "slow", time_warp)
SYM(offset, "offset", time_warp)
SYM(shift, "shift", time_warp)
// Control flow
SYM(if_, "if", control)
SYM(let_, "let", control)
SYM(do_, "do", control)
SYM(for_, "for", control)
SYM(while_, "while", control)
SYM(fn, "fn", control)
SYM(lambda, "lambda", control)
SYM(scope, "scope", control)
// Unary math functions
SYM(sin_, "sin", unary)
SYM(cos_, "cos", unary)
SYM(tan_, "tan", unary)
SYM(abs_, "abs", unary)
SYM(floor_, "floor", unary)
SYM(ceil_, "ceil", unary)
SYM(sqrt_, "sqrt", unary)
SYM(neg, "neg", unary)
SYM(frac_, "frac", unary)
SYM(bsin, "bsin", unary)
SYM(bcos, "bcos", unary)
SYM(bi_to_uni, "bi-to-uni", unary)
SYM(b_to_u, "b>u", unary)
SYM(uni_to_bi, "uni-to-bi", unary)
SYM(u_to_b, "u>b", unary)
// Binary math functions
SYM(min_, "min", arith)
SYM(max_, "max", arith)
SYM(pow_, "pow", binary)
SYM(expt, "expt", binary)
SYM(mod_, "mod", binary)
SYM(pulse, "pulse", binary)
// Ternary math functions
SYM(clamp, "clamp", ternary)
SYM(lerp, "lerp", ternary)
SYM(scale, "scale", ternary)
// Unary phase-to-value waveform functions
SYM(tri, "tri", waveform)
SYM(sqr, "sqr", waveform)
SYM(saw, "saw", waveform)
// Domain signal functions
SYM(step, "step", signal)
SYM(gates, "gates", signal)
SYM(trigs, "trigs", signal)
SYM(euclid, "euclid", signal)
SYM(eu, "eu", signal)
SYM(seq, "seq", signal)
SYM(from_list, "from-list", signal)
SYM(interp, "interp", signal)
SYM(flatseq, "flatseq", signal)
SYM(dm, "dm", signal)
SYM(range, "range", signal)
SYM(gatesw, "gatesw", signal)
SYM(random_, "random", signal)
SYM(index_rand, "index-rand", signal)
SYM(loop_at, "loop-at", signal)
SYM(eval_at_time, "eval-at-time", signal)
SYM(rpulse, "rpulse", signal)
SYM(rstep, "rstep", signal)
SYM(ridx, "ridx", signal)
SYM(rwarp, "rwarp", signal)
// State forms
SYM(defstate, "defstate", side_effect)
SYM(integrate, "integrate", signal)
SYM(dt, "dt", none)
// UGen forms (state-bearing unit generators)
// Primary names
SYM(phasor_, "phasor", signal)
SYM(lfo, "lfo", signal)
SYM(blfo, "blfo", signal)
SYM(slew, "slew", signal)
SYM(one_pole, "one-pole", signal)
SYM(env_follow, "env-follow", signal)
SYM(sah, "sah", signal)
SYM(noise, "noise", signal)
SYM(toggle, "toggle", signal)
SYM(count, "count", signal)
// Compatibility aliases unrelated to the v1.2 namespace cut.
SYM(envelope_follower, "envelope-follower", signal)
SYM(latch, "latch", signal)
// UGen keyword symbols
SYM(kw_wave, ":wave", none)
SYM(kw_phase, ":phase", none)
SYM(kw_pw, ":pw", none)
SYM(kw_id, ":id", none)
SYM(kw_fresh, ":fresh", none)
SYM(kw_attack, ":attack", none)
SYM(kw_release, ":release", none)
SYM(kw_reset, ":reset", none)
SYM(kw_sin, ":sin", none)
SYM(kw_cos, ":cos", none)
SYM(kw_tri, ":tri", none)
SYM(kw_saw_kw, ":saw", none)
SYM(kw_sqr, ":sqr", none)
// Side-effect forms (error in signal context)
SYM(define, "define", side_effect)
SYM(def, "def", side_effect)
SYM(defn, "defn", side_effect)
SYM(defun, "defun", side_effect)
SYM(defs, "defs", side_effect)
SYM(set, "set", side_effect)
SYM(unassign, "unassign", side_effect)
SYM(zeros_, "zeros", side_effect)
SYM(get_expr, "get-expr", side_effect)
SYM(set_bpm, "set-bpm", side_effect)
SYM(set_time_sig, "set-time-sig", side_effect)
SYM(useq_clear, "useq-clear", side_effect)
SYM(set_time_offset, "useq-set-time-offset", side_effect)
SYM(nudge_time, "useq-nudge-time", side_effect)
SYM(useq_play, "useq-play", side_effect)
SYM(useq_pause, "useq-pause", side_effect)
SYM(useq_stop, "useq-stop", side_effect)
SYM(useq_rewind, "useq-rewind", side_effect)
SYM(set_clock_ext, "set-clock-ext", side_effect)
SYM(set_clock_int, "set-clock-int", side_effect)
SYM(get_clock_source, "get-clock-source", side_effect)
SYM(reset_clock_ext, "reset-clock-ext", side_effect)
SYM(reset_clock_int, "reset-clock-int", side_effect)
// Derived timing
SYM(beat_dur, "beat-dur", none)
SYM(bar_dur, "bar-dur", none)
// Output feedback
SYM(prev, "prev", none)
// Live-edit
SYM(live_edit, "live-edit", signal)
// Misc
SYM(input, "input", none)
SYM(quote, "quote", none)
#if USEQ_HAS_SYNTH_ENGINE
// Synth form (top-level only, synth-nodes.md)
// `synth` is a top-level declaration form, not a signal-context operator.
// Inside a signal expression it is a Boundary-category error
// (VAL-COMP-007). Keywords :freq / :amp / :name / :version / :id parameterise
// the declaration; :id is the hidden-identity injection point supplied by
// the editor payload builder.
SYM(synth, "synth", side_effect)
SYM(kw_freq, ":freq", none)
SYM(kw_amp, ":amp", none)
SYM(kw_name, ":name", none)
SYM(kw_version, ":version", none)
#endif
// Live-edit-only metadata. These are explicit inventory entries even though
// the generic symbol interner would otherwise create them on demand.
SYM(kw_min, ":min", none)
SYM(kw_max, ":max", none)
SYM(kw_options, ":options", none)
SYM(kw_step, ":step", none)
SYM(kw_precision, ":precision", none)
// Operator namespace metadata
// Tokens map directly to the enum members in graph_builder.h and to the
// generator's string vocabulary. Undeclared axes are `None`; `Native` means
// the bare spelling keeps the engine-native lowering.
#ifndef OP_META
#define OP_META(field, domain, range, regime, cold, identity)
#define USEQ_SYMBOLS_DEF_UNDEF_OP_META
#endif
OP_META(plus, None, None, Pure, Yes, Native)
OP_META(minus, None, None, Pure, Yes, Native)
OP_META(star, None, None, Pure, Yes, Native)
OP_META(slash, None, None, Pure, Yes, Native)
OP_META(mod_pct, None, None, Pure, Yes, Native)
OP_META(min_, None, None, Pure, Yes, Native)
OP_META(max_, None, None, Pure, Yes, Native)
OP_META(gt, None, Unipolar, Pure, Yes, Native)
OP_META(lt, None, Unipolar, Pure, Yes, Native)
OP_META(ge, None, Unipolar, Pure, Yes, Native)
OP_META(le, None, Unipolar, Pure, Yes, Native)
OP_META(eq, None, Unipolar, Pure, Yes, Native)
OP_META(not_, None, Unipolar, Pure, Yes, Native)
OP_META(and_, None, Unipolar, Pure, Yes, Native)
OP_META(or_, None, Unipolar, Pure, Yes, Native)
OP_META(sin_, Phase, Bipolar, PureShaper, Yes, NormalizedUnipolar)
OP_META(cos_, Phase, Bipolar, PureShaper, Yes, NormalizedUnipolar)
OP_META(bsin, Phase, Bipolar, PureShaper, Yes, NormalizedBipolar)
OP_META(bcos, Phase, Bipolar, PureShaper, Yes, NormalizedBipolar)
OP_META(tan_, Angle, None, Pure, Yes, Native)
OP_META(abs_, Scalar, None, Pure, Yes, Native)
OP_META(floor_, Scalar, None, Pure, Yes, Native)
OP_META(ceil_, Scalar, None, Pure, Yes, Native)
OP_META(sqrt_, Scalar, None, Pure, Yes, Native)
OP_META(neg, Scalar, None, Pure, Yes, Native)
OP_META(frac_, Scalar, Unipolar, Pure, Yes, Native)
OP_META(bi_to_uni, Scalar, Unipolar, Pure, Yes, Native)
OP_META(b_to_u, Scalar, Unipolar, Pure, Yes, Native)
OP_META(uni_to_bi, Scalar, Bipolar, Pure, Yes, Native)
OP_META(u_to_b, Scalar, Bipolar, Pure, Yes, Native)
OP_META(pow_, Scalar, None, Pure, Yes, Native)
OP_META(expt, Scalar, None, Pure, Yes, Native)
OP_META(mod_, Scalar, None, Pure, Yes, Native)
OP_META(pulse, Phase, Unipolar, PureShaper, Yes, UnipolarShaper)
OP_META(clamp, Scalar, None, Pure, Yes, Native)
OP_META(lerp, Scalar, None, Pure, Yes, Native)
OP_META(scale, Scalar, None, Pure, Yes, Native)
OP_META(tri, Phase, Unipolar, PureShaper, Yes, UnipolarShaper)
OP_META(sqr, Phase, Unipolar, PureShaper, Yes, UnipolarShaper)
OP_META(saw, Phase, Unipolar, PureShaper, Yes, UnipolarShaper)
OP_META(random_, None, Unipolar, Pure, Yes, Native)
OP_META(index_rand, Scalar, Unipolar, Pure, Yes, Native)
OP_META(phasor_, Scalar, Unipolar, Stateful, No, Native)
OP_META(lfo, Scalar, Unipolar, Stateful, No, LfoUnipolar)
OP_META(blfo, Scalar, Bipolar, Stateful, No, LfoBipolar)
OP_META(slew, Scalar, None, Stateful, No, Native)
OP_META(one_pole, Scalar, None, Stateful, No, Native)
OP_META(env_follow, Scalar, Unipolar, Stateful, No, Native)
OP_META(sah, Scalar, None, Stateful, No, Native)
OP_META(noise, None, Bipolar, Stateful, No, Native)
OP_META(toggle, Scalar, Unipolar, Stateful, No, Native)
OP_META(count, Scalar, None, Stateful, No, Native)
#ifdef USEQ_SYMBOLS_DEF_UNDEF_OP_META
#undef OP_META
#undef USEQ_SYMBOLS_DEF_UNDEF_OP_META
#endif
#if USEQ_HAS_SYNTH_ENGINE
// State-identity wrapper (state-identity.md §6.3)
// `(with-state-id "<id>" <form>)` is the hidden identity wrapper the editor
// payload builder injects around anonymous stateful forms. The runtime
// treats it as a transparent passthrough: the wrapper evaluates its second
// argument (the wrapped form) and ignores the identity string, which has
// already been used by the editor to assign stable identity. Without this
// passthrough every anonymous synth form would compile as "Unknown name".
SYM(with_state_id, "with-state-id", control)
#endif

View file

@ -0,0 +1,208 @@
#include "synth_graph.h"
#include "cold_eval.h"
#include <cstring>
#include <cstdio>
namespace sig {
// ── Internal scratch buffer for serialised artefacts ───────────────────────
// The WASM ABI returns const char* snapshots that are stable until the next
// eval, mirroring useq_last_diagnostics(). We back them with a static
// thread-local-ish buffer (single-threaded engine on both firmware and WASM).
static char g_artifact_scratch[SYNTH_ARTIFACT_JSON_CAP];
bool synth_graph_render_json(const SynthGraph& graph, char* out, uint32_t cap) {
if (!out || cap == 0) return false;
char* p = out;
const char* end = out + cap;
auto emit = [&](const char* s) -> bool {
size_t n = std::strlen(s);
if (p + n + 1 >= end) return false;
std::memcpy(p, s, n);
p += n;
return true;
};
auto emit_quoted = [&](const char* s) -> bool {
if (!emit("\"")) return false;
// Escape nothing fancy for now: identities and def names are
// restricted to alphanumerics, '/', '-' and the editor sidecar
// "::" prefix. A defensive scan still strips any stray control
// characters or embedded quotes.
for (const char* c = s; *c; ++c) {
char ch = *c;
if (ch == '"' || ch == '\\') {
if (p + 3 >= end) return false;
*p++ = '\\';
*p++ = ch;
} else if ((unsigned char)ch < 0x20) {
if (p + 2 >= end) return false;
*p++ = ' ';
} else {
if (p + 2 >= end) return false;
*p++ = ch;
}
}
if (!emit("\"")) return false;
return true;
};
// Single canonical render pass. The public schema is intentionally
// narrow (VAL-COMP-012): revision, declarations[], controls[] keyed by
// stable identity / param name. Internal GC-remapped node indices are
// never serialised.
if (!emit("{\"revision\":")) return false;
{
char buf[16];
std::snprintf(buf, sizeof(buf), "%lu", (unsigned long)graph.revision);
if (!emit(buf)) return false;
}
if (!emit(",\"declarations\":[")) return false;
for (uint16_t i = 0; i < graph.declaration_count(); i++) {
const SynthDeclaration& d = graph.declarations[i];
if (i > 0 && !emit(",")) return false;
if (!emit("{\"identity\":")) return false;
if (!emit_quoted(d.identity)) return false;
if (!emit(",\"def\":")) return false;
if (!emit_quoted(d.def_name)) return false;
if (!emit(",\"version\":")) return false;
{
char buf[16];
std::snprintf(buf, sizeof(buf), "%u", (unsigned)d.def_version);
if (!emit(buf)) return false;
}
if (!emit(",\"audio_inputs\":")) return false;
{
char buf[16];
std::snprintf(buf, sizeof(buf), "%u", (unsigned)d.audio_inputs);
if (!emit(buf)) return false;
}
if (!emit(",\"audio_outputs\":")) return false;
{
char buf[16];
std::snprintf(buf, sizeof(buf), "%u", (unsigned)d.audio_outputs);
if (!emit(buf)) return false;
}
if (!emit("}")) return false;
}
if (!emit("],\"controls\":[")) return false;
for (uint16_t i = 0; i < graph.control_count(); i++) {
const SynthControlChannel& c = graph.controls[i];
const SynthDeclaration* declaration =
graph.declaration_for_control(i);
const NodeDefParam* parameter = graph.parameter_for_control(i);
if (!declaration || !parameter) return false;
if (i > 0 && !emit(",")) return false;
if (!emit("{\"identity\":")) return false;
if (!emit_quoted(declaration->identity)) return false;
if (!emit(",\"param\":")) return false;
if (!emit_quoted(parameter->name)) return false;
if (!emit(",\"rate\":")) return false;
if (!emit(c.rate_class == SynthRateClass::Block ? "\"block\"" : "\"fast\"")) return false;
if (!emit(",\"smoothing\":")) return false;
const char* sm = "step";
switch (c.smoothing_class) {
case SynthSmoothingClass::Step: sm = "step"; break;
case SynthSmoothingClass::Linear: sm = "linear"; break;
case SynthSmoothingClass::Slew: sm = "slew"; break;
case SynthSmoothingClass::Latch: sm = "latch"; break;
}
if (!emit_quoted(sm)) return false;
if (!emit("}")) return false;
}
if (!emit("],\"connections\":[")) return false;
for (uint16_t i = 0; i < graph.connection_count(); i++) {
const SynthConnection& c = graph.connections[i];
if (i > 0 && !emit(",")) return false;
if (!emit("{\"from\":")) return false;
if (!emit_quoted(c.from)) return false;
if (!emit(",\"to\":")) return false;
if (!emit_quoted(c.to)) return false;
if (!emit(",\"port\":")) return false;
if (!emit_quoted(c.port)) return false;
if (!emit(",\"port_index\":")) return false;
{
char buf[16];
std::snprintf(buf, sizeof(buf), "%u", (unsigned)c.port_index);
if (!emit(buf)) return false;
}
if (!emit("}")) return false;
}
if (!emit("]}")) return false;
if ((uint32_t)(p - out) >= cap) return false;
*p = '\0';
return true;
}
const char* synth_graph_render_json_scratch(const SynthGraph& graph) {
if (!synth_graph_render_json(graph, g_artifact_scratch,
SYNTH_ARTIFACT_JSON_CAP)) {
// Truncation / failure: emit a minimal valid JSON sentinel so the
// consumer never sees invalid JSON over the wire.
std::snprintf(g_artifact_scratch, sizeof(g_artifact_scratch),
"{\"revision\":%lu,\"error\":true}",
(unsigned long)graph.revision);
}
return g_artifact_scratch;
}
// ── Versioned ABI surface (VAL-COMP-015) ───────────────────────────────────
bool synth_artifacts_supports_abi(uint16_t consumer_abi_version) {
// The current engine advertises exactly one ABI version. Consumers
// built against any other version must be rejected explicitly so we
// never let a newer or older consumer misread the payload layout.
return consumer_abi_version == SYNTH_ARTIFACT_ABI_VERSION;
}
bool synth_artifacts_render_abi_wrapper(const SignalEngine& engine,
uint16_t consumer_abi_version,
char* out, uint32_t cap) {
if (!out || cap == 0) return false;
// Reject incompatible consumers up front with a minimal valid-JSON
// error envelope. The caller MUST NOT interpret the body bytes when
// this function returns false.
if (!synth_artifacts_supports_abi(consumer_abi_version)) {
// The error envelope includes the engine's advertised ABI version
// and the rejected consumer version so the diagnostics surfaced
// to the user are actionable.
char err[96];
std::snprintf(err, sizeof(err),
"{\"abi\":%u,\"abi_error\":true,"
"\"engine_abi\":%u,\"consumer_abi\":%u}",
(unsigned)SYNTH_ARTIFACT_ABI_VERSION,
(unsigned)SYNTH_ARTIFACT_ABI_VERSION,
(unsigned)consumer_abi_version);
if (std::strlen(err) + 1 > cap) return false;
std::memcpy(out, err, std::strlen(err) + 1);
return false;
}
// Render the body into a scratch buffer first so we can prepend the
// `abi` marker without a duplicate copy of the engine state.
static char body_scratch[SYNTH_ARTIFACT_JSON_CAP];
const char* body = synth_graph_render_json_scratch(engine.synth_graph);
if (!body) return false;
// synth_graph_render_json_scratch returns a pointer into a separate
// scratch buffer; copy into body_scratch so the wrap below is stable
// even if we ever recurse into the scratch again.
std::strncpy(body_scratch, body, SYNTH_ARTIFACT_JSON_CAP - 1);
body_scratch[SYNTH_ARTIFACT_JSON_CAP - 1] = '\0';
// Wrap with the `abi` marker. We need to drop the leading '{' of the
// body so we don't produce `{{...}}`.
const char* body_open = body_scratch[0] == '{' ? body_scratch + 1
: body_scratch;
int written = std::snprintf(out, cap, "{\"abi\":%u,%s",
(unsigned)SYNTH_ARTIFACT_ABI_VERSION,
body_open);
if (written < 0 || (uint32_t)written >= cap) return false;
return true;
}
const char* synth_artifacts_json(const SignalEngine& engine) {
return synth_graph_render_json_scratch(engine.synth_graph);
}
} // namespace sig

View file

@ -0,0 +1,331 @@
#ifndef SIGNAL_ENGINE_SYNTH_GRAPH_H
#define SIGNAL_ENGINE_SYNTH_GRAPH_H
#include "types.h"
#include "synth_registry.h"
#include "diagnostics.h"
#include <cstdint>
#include <cstring>
namespace sig {
// ── Synth Patch Graph and Control Table (synth-nodes.md §7.2) ──────────────
//
// Public artefacts exposed to the host after a successful synth eval. Both
// the patch graph (node instances, def names/versions, audio-port routing,
// fades) and the control channel table (one entry per bound param) are
// versioned together under one compiler revision so consumers can detect
// coherent updates (VAL-COMP-009). Failed evals retain the previous
// successful artefacts and do not advance the revision (VAL-COMP-010).
//
// Public identifiers are stable identity strings supplied by the editor
// (hidden :id or explicit :name). Internal GC-remapped node indices are
// never exposed in the serialised artefact (VAL-COMP-012).
// ── Identity limits ────────────────────────────────────────────────────────
// Identity strings are short editor-sidecar IDs (e.g. "::a3f1" or "lead").
constexpr uint16_t MAX_SYNTH_IDENTITY = 32;
// ── Patch-graph declaration entry ──────────────────────────────────────────
struct SynthDeclaration {
char identity[MAX_SYNTH_IDENTITY] = {};
char def_name[MAX_NODEDEF_NAME] = {};
uint16_t def_version = 0;
uint16_t audio_inputs = 0;
uint16_t audio_outputs = 0;
bool voice_fanout = false;
// The index of this declaration's entry within the control table's
// per-declaration slice. Public artefacts reference declarations by
// identity only; this index is an internal helper, not serialised.
uint16_t first_control_index = 0;
uint16_t control_count = 0;
};
// ── Control channel table entry ────────────────────────────────────────────
// One row per bound (declaration, param [, voice]) triple. The host reads
// the param expression's compiled root_node out of the live NodePool each
// control block. The root_node value is internal and must NOT appear in
// the serialised public artefact (VAL-COMP-012). Ownership is represented by
// the declaration's dense first_control_index/control_count range. Parameter
// names are resolved from the immutable NodeDef descriptor by param_index.
// These two indices avoid repeated identity and parameter strings per row.
struct SynthControlChannel {
uint8_t param_index = 0;
SynthRateClass rate_class = SynthRateClass::Block;
SynthSmoothingClass smoothing_class = SynthSmoothingClass::Step;
// Compiled control root node index. Internal: not serialised. The host
// samples this root via the NodePool at each control block.
uint16_t root_node = NODE_NONE;
// Stable compiler ownership and reactive-recompile metadata. Internal;
// never exposed through the host artefact.
uint16_t owner_context = 0;
uint32_t source_offset = 0;
uint32_t source_length = 0;
CellIndex dep_cells[MAX_OUTPUT_DEPS] = {};
uint8_t dep_count = 0;
// Root-level non-finite containment for the audio control producer.
Sample lkg_value = 0.0;
bool has_lkg = false;
// One fixed reactive-compile diagnostic slot per published control on the
// WASM/desktop synth host. Keeping it on the dense row makes replacement,
// removal, rollback, and artifact-order remapping atomic. Firmware does
// not implement the synth host (synth-nodes.md §6.1), so it must not spend
// scarce RP2040 SRAM on an ABI that cannot be observed there.
#if !defined(ARDUINO) && !defined(USEQ_FIRMWARE_PROFILE)
struct CompileDiagnostic {
const char* message = nullptr;
const char* suggestion = nullptr;
// Cell-backed symbols are bounded by MAX_CELLS before mutation, so a
// 16-bit cause preserves every possible trigger without paying for
// the interner's unbounded 32-bit ID in all 512 control rows.
uint16_t triggered_by = 0;
uint16_t span_start = 0;
uint16_t span_len = 0;
DiagnosticSeverity severity = DiagnosticSeverity::Error;
DiagnosticCategory category = DiagnosticCategory::Runtime;
void clear() { *this = CompileDiagnostic{}; }
bool active() const { return message != nullptr; }
void publish(SymbolID cause, const Diagnostic& diagnostic) {
// A non-null message is the active marker. Every compiler
// diagnostic is expected to have text; preserve the slot even if
// a future producer violates that expectation.
message = diagnostic.message ? diagnostic.message : "";
suggestion = diagnostic.suggestion;
triggered_by = static_cast<uint16_t>(cause);
span_start = diagnostic.span_start;
span_len = diagnostic.span_len;
severity = diagnostic.severity;
category = diagnostic.category;
}
} compile_diagnostic;
#endif
};
static_assert(MAX_NODEDEF_PARAMS <= UINT8_MAX,
"synth parameter indices must cover every NodeDef parameter");
#if !defined(ARDUINO) && !defined(USEQ_FIRMWARE_PROFILE)
static_assert(MAX_CELLS <= UINT16_MAX,
"synth diagnostic trigger IDs must cover every mutable cell");
#endif
struct SynthConnection {
char from[MAX_SYNTH_IDENTITY] = {};
char to[MAX_SYNTH_IDENTITY] = {};
char port[MAX_NODEDEF_NAME] = {};
uint16_t port_index = 0;
};
// ── Capacity ───────────────────────────────────────────────────────────────
// The compiler and app share a 64-node ceiling. Current osc/sine has one
// audio input, so one edge per declaration covers the shipped registry.
constexpr uint16_t MAX_SYNTH_DECLARATIONS = SYNTH_MAX_NODES;
constexpr uint16_t MAX_SYNTH_CONTROLS = MAX_SYNTH_CONTROL_ROOTS;
constexpr uint16_t MAX_SYNTH_CONNECTIONS = MAX_SYNTH_DECLARATIONS;
#if !defined(ARDUINO) && !defined(USEQ_FIRMWARE_PROFILE)
static_assert(MAX_SYNTH_CONTROLS ==
MAX_SYNTH_DECLARATIONS * MAX_NODEDEF_PARAMS,
"host synth storage must cover the descriptor ceiling");
#endif
// Bounds recursive audio-routing compilation and its cold-path stack use.
constexpr uint16_t MAX_SYNTH_NESTING = 16;
// ── Compiler revision ──────────────────────────────────────────────────────
// One shared counter covers graph and control table. It advances ONLY when
// a full eval unit commits successfully; failed evals retain the previous
// revision (VAL-COMP-008, VAL-COMP-009, VAL-COMP-010). Revision 0 is the
// "empty graph" sentinel.
using SynthRevision = uint32_t;
// ── Patch graph container ──────────────────────────────────────────────────
struct SynthGraph {
SynthDeclaration declarations[MAX_SYNTH_DECLARATIONS];
SynthControlChannel controls[MAX_SYNTH_CONTROLS];
SynthConnection connections[MAX_SYNTH_CONNECTIONS];
uint16_t declaration_count_value = 0;
uint16_t control_count_value = 0;
uint16_t connection_count_value = 0;
SynthRevision revision = 0;
// ── Accessors ─────────────────────────────────────────────────────────
uint16_t declaration_count() const { return declaration_count_value; }
uint16_t control_count() const { return control_count_value; }
uint16_t connection_count() const { return connection_count_value; }
const SynthDeclaration* declaration_for_control(
uint16_t control_index) const {
if (control_index >= control_count_value) return nullptr;
// Declaration control slices are appended in declaration order and
// remain dense after replacement/removal compaction. Search those
// ordered ranges logarithmically; this is used by every synth-control
// lookup during compilation and artifact inspection.
uint16_t first_decl = 0;
uint16_t past_last_decl = declaration_count_value;
while (first_decl < past_last_decl) {
const uint16_t middle = static_cast<uint16_t>(
first_decl + (past_last_decl - first_decl) / 2);
const SynthDeclaration& declaration = declarations[middle];
const uint32_t first = declaration.first_control_index;
const uint32_t past_last = first + declaration.control_count;
if (control_index < first) {
past_last_decl = middle;
} else if (control_index >= past_last) {
first_decl = static_cast<uint16_t>(middle + 1);
} else {
return &declaration;
}
}
return nullptr;
}
const NodeDefParam* parameter_for_control(uint16_t control_index) const {
const SynthDeclaration* declaration =
declaration_for_control(control_index);
if (!declaration) return nullptr;
const NodeDefDescriptor* descriptor = synth_registry_find(
declaration->def_name, declaration->def_version);
if (!descriptor) return nullptr;
const uint8_t parameter_index = controls[control_index].param_index;
if (parameter_index >= descriptor->param_count ||
parameter_index >= MAX_NODEDEF_PARAMS) {
return nullptr;
}
return &descriptor->params[parameter_index];
}
// Reset to empty. Used by (useq-clear) and at startup. Does NOT advance
// the revision: an empty graph is a valid committed state, so callers
// that want to bump the revision should call advance_revision() after.
void clear_no_revision() {
declaration_count_value = 0;
control_count_value = 0;
connection_count_value = 0;
}
void clear_and_advance() {
clear_no_revision();
advance_revision();
}
void advance_revision() { revision++; }
// Find a declaration by identity. Returns nullptr if absent.
SynthDeclaration* find(const char* identity) {
if (!identity) return nullptr;
for (uint16_t i = 0; i < declaration_count_value; i++) {
if (std::strcmp(declarations[i].identity, identity) == 0)
return &declarations[i];
}
return nullptr;
}
const SynthDeclaration* find(const char* identity) const {
if (!identity) return nullptr;
for (uint16_t i = 0; i < declaration_count_value; i++) {
if (std::strcmp(declarations[i].identity, identity) == 0)
return &declarations[i];
}
return nullptr;
}
// Append a blank declaration slot. Returns nullptr if capacity is full.
SynthDeclaration* append_declaration() {
if (declaration_count_value >= MAX_SYNTH_DECLARATIONS) return nullptr;
return &declarations[declaration_count_value++];
}
// Append a control channel. Returns nullptr if capacity is full.
SynthControlChannel* append_control() {
if (control_count_value >= MAX_SYNTH_CONTROLS) return nullptr;
// Dense-table compaction leaves retired bytes above the logical end.
// A newly appended control is a new subject, so it must not inherit
// the prior row's LKG, dependencies, or reactive diagnostic slot.
SynthControlChannel& control = controls[control_count_value++];
control = SynthControlChannel{};
return &control;
}
SynthConnection* append_connection() {
if (connection_count_value >= MAX_SYNTH_CONNECTIONS) return nullptr;
return &connections[connection_count_value++];
}
};
// ── Public artefact serialisation (VAL-COMP-012) ───────────────────────────
//
// Renders a JSON snapshot of the current synth graph suitable for WASM ABI
// consumption. The schema is intentionally narrow: only stable identity
// strings, def names/versions, parameter names, and rate/smoothing classes
// are emitted. Internal GC-remapped node indices are never serialised.
//
// The output is written into the supplied buffer and is null-terminated.
// Returns false if the buffer is too small (the caller should provide at
// least SYNTH_ARTIFACT_JSON_CAP bytes).
constexpr uint32_t SYNTH_ARTIFACT_JSON_CAP = 32768;
bool synth_graph_render_json(const SynthGraph& graph, char* out, uint32_t cap);
// Convenience wrapper that renders into the engine's own scratch buffer.
// The returned pointer is valid until the next call to this function or
// until the engine is destroyed.
const char* synth_graph_render_json_scratch(const SynthGraph& graph);
// ── Versioned synth artefact ABI (VAL-COMP-015) ────────────────────────────
//
// The synth artefact payload carries an `abi` version marker so future
// consumers can reject incompatible bundles explicitly. The native engine
// advertises a single canonical ABI version; any consumer built against a
// different version must refuse to read the payload.
//
// Version history:
// 1 — single-node declarations + controls; no routable audio graph.
// 2 — required connections[] and multi-node patch-graph semantics.
constexpr uint16_t SYNTH_ARTIFACT_ABI_VERSION = 2;
/**
* Return true iff the engine's synth-artefact ABI can serve a consumer
* built against the supplied `consumer_abi_version`. The current engine
* accepts only its own declared ABI; future versions may accept a range.
*
* Callers MUST consult this helper before interpreting the body bytes of
* `synth_artifacts_json` / `useq_synth_artifacts`. Incompatible consumers
* receive a minimal error object instead of the artefact body (see
* `synth_artifacts_render_abi_wrapper`).
*/
bool synth_artifacts_supports_abi(uint16_t consumer_abi_version);
/**
* Render the versioned synth artefact payload into `out` for a consumer
* built against `consumer_abi_version`.
*
* On success the buffer contains a JSON object shaped:
* {"abi":<version>,"revision":N,"declarations":[...],"controls":[...]}
* and the function returns true.
*
* If the consumer ABI version is unsupported, the buffer is filled with a
* minimal JSON error object (still valid JSON) and the function returns
* false. The caller MUST NOT interpret the body bytes when this function
* returns false the only safe interpretation is the `abi_error` field.
*
* This helper is the native counterpart of the WASM `useq_synth_artifacts`
* wrapper. Mirrors its byte shape exactly so native and WASM consumers
* observe identical payloads.
*/
bool synth_artifacts_render_abi_wrapper(const struct SignalEngine& engine,
uint16_t consumer_abi_version,
char* out, uint32_t cap);
// ── Engine-level accessor ──────────────────────────────────────────────────
// Returns the engine's published synth artefact snapshot as JSON. Mirrors
// the useq_last_diagnostics() pattern: the pointer is stable until the
// next eval. Equivalent to synth_graph_render_json_scratch(engine.synth_graph).
const char* synth_artifacts_json(const struct SignalEngine& engine);
} // namespace sig
#endif // SIGNAL_ENGINE_SYNTH_GRAPH_H

View file

@ -0,0 +1,160 @@
#include "synth_registry.h"
#include <cstring>
#include <algorithm>
namespace sig {
// ── Static NodeDef table ───────────────────────────────────────────────────
//
// The current proof set contains osc/sine version 2, with:
// - one audio input (`fm`, per-sample Hz offset), one mono output
// - :freq: block rate, step smoothing, default 440 Hz
// - :amp: block rate, declared smoothing (linear, implemented by def),
// default 0.2
//
// The compiler consults this table to validate def names, versions,
// parameters, and defaults. The host/worklet consult it for transport and
// zone layout (out of scope for this feature).
static const NodeDefDescriptor kRegistryTable[] = {
{
"osc/sine",
2, // version (v2 adds the fm audio-input contract)
1, // audio_inputs
1, // audio_outputs (mono)
false, // voice_fanout
{
// params[0]: freq
{
"freq",
440.0,
SynthRateClass::Block,
SynthSmoothingClass::Step,
},
// params[1]: amp
{
"amp",
0.2,
SynthRateClass::Block,
SynthSmoothingClass::Linear,
},
},
2, // param_count
{
"fm",
},
// Convenience defaults surfaced for the form grammar:
440.0, // freq_default
0.2, // amp_default
},
};
static constexpr uint16_t kRegistryCount =
sizeof(kRegistryTable) / sizeof(kRegistryTable[0]);
const NodeDefDescriptor* synth_registry_table(uint16_t& count_out) {
count_out = kRegistryCount;
return kRegistryTable;
}
const NodeDefDescriptor* synth_registry_find(const char* name, uint16_t version) {
if (!name) return nullptr;
const NodeDefDescriptor* best = nullptr;
uint16_t best_version = 0;
for (uint16_t i = 0; i < kRegistryCount; i++) {
const NodeDefDescriptor& d = kRegistryTable[i];
if (std::strcmp(d.name, name) != 0) continue;
if (version != 0 && d.version != version) continue;
// version=0 means "any / latest": pick the highest available.
if (version == 0) {
if (best == nullptr || d.version > best_version) {
best = &d;
best_version = d.version;
}
} else {
return &d;
}
}
return best;
}
const NodeDefParam* nodedef_find_param(const NodeDefDescriptor* def,
const char* param_name) {
if (!def || !param_name) return nullptr;
for (uint16_t i = 0; i < def->param_count; i++) {
if (std::strcmp(def->params[i].name, param_name) == 0) {
return &def->params[i];
}
}
return nullptr;
}
int16_t nodedef_find_audio_input(const NodeDefDescriptor* def,
const char* input_name) {
if (!def || !input_name) return -1;
for (uint16_t i = 0; i < def->audio_inputs &&
i < MAX_NODEDEF_AUDIO_INPUTS; i++) {
if (def->audio_input_names[i] &&
std::strcmp(def->audio_input_names[i], input_name) == 0) {
return (int16_t)i;
}
}
return -1;
}
// ── Tiny Levenshtein for parameter suggestion ──────────────────────────────
// Mirrors the algorithm in diagnostics.cpp. We keep this local rather than
// reusing find_fuzzy_match() because the candidate pool here is the def's
// declared params (short, fixed), not the cell table.
static int param_levenshtein(const char* s1, const char* s2) {
int len1 = (int)std::strlen(s1);
int len2 = (int)std::strlen(s2);
if (len1 == 0) return len2;
if (len2 == 0) return len1;
if (len2 > 32) len2 = 32;
int row[33];
for (int j = 0; j <= len2; j++) row[j] = j;
for (int i = 1; i <= len1; i++) {
int prev = i - 1;
row[0] = i;
for (int j = 1; j <= len2; j++) {
int temp = row[j];
int cost = (s1[i - 1] == s2[j - 1]) ? 0 : 1;
row[j] = (std::min({prev + cost, row[j] + 1, row[j - 1] + 1}));
prev = temp;
}
}
return row[len2];
}
const char* nodedef_suggest_param(const NodeDefDescriptor* def,
const char* candidate) {
if (!def || !candidate) return nullptr;
int candidate_len = (int)std::strlen(candidate);
if (candidate_len == 0) return nullptr;
const char* best = nullptr;
int best_dist = 3; // threshold: only suggest if distance < 3
for (uint16_t i = 0; i < def->param_count; i++) {
const char* name = def->params[i].name;
int d = param_levenshtein(candidate, name);
if (d > 0 && d < best_dist) {
best_dist = d;
best = name;
}
// Prefix / case-insensitive near-miss (e.g. "amplitude" → "amp"):
// if the candidate starts with the param name or vice versa, count
// it as a strong match.
size_t name_len = std::strlen(name);
size_t min_len = std::min(name_len, (size_t)candidate_len);
if (min_len >= 2 && std::strncmp(candidate, name, min_len) == 0) {
// Strong prefix match — prefer it.
return name;
}
}
return best;
}
} // namespace sig

View file

@ -0,0 +1,116 @@
#ifndef SIGNAL_ENGINE_SYNTH_REGISTRY_H
#define SIGNAL_ENGINE_SYNTH_REGISTRY_H
#include "types.h"
#include <cstdint>
namespace sig {
// ── Synth NodeDef Registry (synth-nodes.md §2) ─────────────────────────────
//
// Source-agnostic metadata describing each audio-rate NodeDef the host knows
// how to instantiate. The registry is a small static table (currently just
// osc/sine); the language/compiler only ever consumes the contract metadata,
// never the DSP implementation.
//
// The registry owns:
// - def name (namespaced string like "osc/sine")
// - def version (positive integer; 0 means "latest")
// - named audio input ports and audio output count
// - parameter contract for :freq and :amp
//
// The v1 library is intentionally minimal: the compiler validates against
// this table and produces precise diagnostics for unknown defs, unknown
// parameters, or unsupported versions.
// ── Rate class (synth-nodes.md §2.3) ───────────────────────────────────────
enum class SynthRateClass : uint8_t {
Block, // sampled once per audio block
Fast, // declared higher control rate (points-per-block)
};
// ── Smoothing class (synth-nodes.md §2.4) ──────────────────────────────────
enum class SynthSmoothingClass : uint8_t {
Step, // hold until next control point (default for pitch/freq)
Linear,// ramp to next value (implemented inside the def)
Slew, // exponential approach (implemented inside the def)
Latch, // event-like edge records (gates, triggers)
};
// ── Parameter contract per NodeDef ─────────────────────────────────────────
struct NodeDefParam {
const char* name; // e.g. "freq", "amp"
Sample default_value; // static default; omitted bindings take this
SynthRateClass rate_class;
SynthSmoothingClass smoothing_class;
};
// Maximum parameters per NodeDef. osc/sine has 2 (freq, amp). We allow a few
// more so the registry can describe slightly richer defs without a refactor.
constexpr uint16_t MAX_NODEDEF_PARAMS = 8;
// Maximum named audio inputs per NodeDef. Audio inputs accept node references
// and compile into patch edges; they are never sampled control parameters.
constexpr uint16_t MAX_NODEDEF_AUDIO_INPUTS = 8;
// Maximum def entries in the static registry. M1 ships exactly one (osc/sine).
constexpr uint16_t MAX_NODEDEF_ENTRIES = 8;
// Maximum length of a NodeDef name string (namespaced identifiers are short).
constexpr uint16_t MAX_NODEDEF_NAME = 32;
struct NodeDefDescriptor {
const char* name; // e.g. "osc/sine"
uint16_t version; // e.g. 1
uint16_t audio_inputs; // osc/sine v2 has one named input: fm
uint16_t audio_outputs; // osc/sine has 1 (mono)
bool voice_fanout; // osc/sine does not vector-fan-out in M1
NodeDefParam params[MAX_NODEDEF_PARAMS];
uint16_t param_count;
const char* audio_input_names[MAX_NODEDEF_AUDIO_INPUTS];
// Convenience accessors used by the compiler. freq_default and amp_default
// are surfaced directly because the synth form grammar is hard-wired to
// the freq/amp pair for M1.
Sample freq_default; // registry-declared default for :freq
Sample amp_default; // registry-declared default for :amp
};
// ── Static registry accessor ───────────────────────────────────────────────
//
// Returns the canonical table of known NodeDefs. The table is a static
// constant (no dynamic registration in M1). The pointer is valid for the
// lifetime of the program.
const NodeDefDescriptor* synth_registry_table(uint16_t& count_out);
// Look up a NodeDef by (name, version). version=0 means "any / latest".
// Returns nullptr if no def matches both name and version. When version=0
// the highest available version for the given name is returned.
const NodeDefDescriptor* synth_registry_find(const char* name, uint16_t version);
// Look up a parameter descriptor by name on a specific NodeDef.
const NodeDefParam* nodedef_find_param(const NodeDefDescriptor* def,
const char* param_name);
// Return a zero-based audio-input port index, or -1 when the name is not an
// input on this NodeDef.
int16_t nodedef_find_audio_input(const NodeDefDescriptor* def,
const char* input_name);
// Fuzzy-match a parameter name against the declared params. Returns the
// best candidate name (or nullptr if none is close enough). Used for the
// "did you mean" suggestion in unknown-parameter diagnostics.
const char* nodedef_suggest_param(const NodeDefDescriptor* def,
const char* candidate);
// ── Patch-graph capacity ───────────────────────────────────────────────────
// Shared with the app-side MAX_SYNTH_NODES gate.
constexpr uint16_t SYNTH_MAX_NODES = 64;
} // namespace sig
#endif // SIGNAL_ENGINE_SYNTH_REGISTRY_H

329
src/signal_engine/token.cpp Normal file
View file

@ -0,0 +1,329 @@
#include "token.h"
#include "../modulisp/lisp/symbol_intern.h"
#include <cmath>
#include <cstring>
#include <cstdlib>
namespace sig {
// ── TokenStream methods ─────────────────────────────────────────────────────
Token TokenStream::peek() const {
if (pos < count) return tokens[pos];
Token eof;
eof.kind = TokenKind::Eof;
return eof;
}
Token TokenStream::consume() {
if (pos < count) return tokens[pos++];
Token eof;
eof.kind = TokenKind::Eof;
return eof;
}
bool TokenStream::expect(TokenKind kind) {
if (pos < count && tokens[pos].kind == kind) {
pos++;
return true;
}
return false;
}
void TokenStream::rewind(uint16_t position) {
pos = position;
}
bool TokenStream::at_end() const {
return pos >= count || tokens[pos].kind == TokenKind::Eof;
}
// ── Tokenizer ───────────────────────────────────────────────────────────────
static bool is_symbol_char(char c) {
if (c <= ' ') return false;
switch (c) {
case '(': case ')': case '[': case ']':
case '"': case '\'': case ';': case ',':
return false;
default:
return true;
}
}
static bool is_digit(char c) { return c >= '0' && c <= '9'; }
// A14: strtod accepts hex (0x10), "inf" and "nan" forms — ModuLisp numeric
// literals are plain decimal only. Restrict number tokens to decimal
// characters so those forms tokenize as symbols (→ UndefinedName) instead of
// silently becoming numbers.
static bool is_plain_number_text(const char* s, uint32_t len) {
for (uint32_t k = 0; k < len; k++) {
char c = s[k];
if (!(is_digit(c) || c == '.' || c == '-' || c == '+' ||
c == 'e' || c == 'E'))
return false;
}
return true;
}
uint16_t TokenStream::tokenize(const char* source, uint32_t length,
Token* out, uint16_t max_tokens,
Diagnostic* errors, uint8_t* error_count) {
uint16_t count = 0;
uint32_t i = 0;
bool overflowed = false;
TokenKind delimiter_stack[MAX_TOKENS] = {};
uint16_t delimiter_depth = 0;
auto emit = [&](Token t) {
if (count < max_tokens) {
out[count++] = t;
} else {
// Buffer full: record the overflow instead of silently truncating.
overflowed = true;
}
};
auto emit_error = [&](uint32_t pos, uint16_t len, const char* msg,
const char* suggestion = nullptr) {
if (!error_count || *error_count >= 8) return;
if (errors) {
errors[*error_count] = {
DiagnosticSeverity::Error, DiagnosticCategory::Syntax,
(uint16_t)pos, len, msg, suggestion
};
}
(*error_count)++;
};
// Token spans are represented by uint16_t. Reject a submission that
// cannot be represented before scanning so no later cast can wrap a
// diagnostic or source slice onto unrelated bytes.
if (length > UINT16_MAX) {
emit_error(0, UINT16_MAX,
"Program is too large for source-span tracking",
"Split the submission into smaller forms");
Token eof;
eof.kind = TokenKind::Eof;
eof.span_start = UINT16_MAX;
if (max_tokens > 0) out[count++] = eof;
return count;
}
if (!source && length > 0) {
emit_error(0, 0, "Source buffer is null");
return count;
}
// The language source profile is ASCII. Preflight the complete byte
// string, including comments and string literals, before emitting any
// usable token. NUL is not whitespace and high bytes are never decoded
// through implementation-defined signed-char behaviour.
for (uint32_t j = 0; j < length; ++j) {
const unsigned char byte =
static_cast<unsigned char>(source[j]);
if (byte == 0 || byte >= 0x80) {
emit_error(j, 1,
byte == 0
? "NUL is not allowed in source"
: "Source must contain ASCII bytes only");
Token eof;
eof.kind = TokenKind::Eof;
eof.span_start = static_cast<uint16_t>(length);
if (max_tokens > 0) out[count++] = eof;
return count;
}
}
while (i < length) {
char c = source[i];
// Skip whitespace
if (c <= ' ') { i++; continue; }
// Skip comments
if (c == ';') {
while (i < length && source[i] != '\n') i++;
continue;
}
// Parens and brackets
if (c == '(') {
Token t; t.kind = TokenKind::LParen; t.span_start = (uint16_t)i; t.span_len = 1;
emit(t);
if (delimiter_depth < MAX_TOKENS) {
delimiter_stack[delimiter_depth++] = TokenKind::RParen;
} else {
emit_error(i, 1, "Delimiter nesting is too deep");
}
i++;
continue;
}
if (c == ')') {
Token t; t.kind = TokenKind::RParen; t.span_start = (uint16_t)i; t.span_len = 1;
emit(t);
if (delimiter_depth == 0) {
emit_error(i, 1, "Unexpected closing delimiter");
} else if (delimiter_stack[delimiter_depth - 1] !=
TokenKind::RParen) {
emit_error(i, 1, "Mismatched closing delimiter",
"Close '[' with ']'");
delimiter_depth--;
} else {
delimiter_depth--;
}
i++;
continue;
}
if (c == '[') {
Token t; t.kind = TokenKind::LBracket; t.span_start = (uint16_t)i; t.span_len = 1;
emit(t);
if (delimiter_depth < MAX_TOKENS) {
delimiter_stack[delimiter_depth++] = TokenKind::RBracket;
} else {
emit_error(i, 1, "Delimiter nesting is too deep");
}
i++;
continue;
}
if (c == ']') {
Token t; t.kind = TokenKind::RBracket; t.span_start = (uint16_t)i; t.span_len = 1;
emit(t);
if (delimiter_depth == 0) {
emit_error(i, 1, "Unexpected closing delimiter");
} else if (delimiter_stack[delimiter_depth - 1] !=
TokenKind::RBracket) {
emit_error(i, 1, "Mismatched closing delimiter",
"Close '(' with ')'");
delimiter_depth--;
} else {
delimiter_depth--;
}
i++;
continue;
}
// String literal
if (c == '"') {
uint32_t start = i;
i++; // skip opening quote
uint32_t str_start = i;
while (i < length && source[i] != '"') {
if (source[i] == '\\' && i + 1 < length) i++; // skip escape
i++;
}
if (i >= length) {
emit_error((uint32_t)start, (uint16_t)(i - start),
"Unterminated string");
break;
}
Token t;
t.kind = TokenKind::String;
t.span_start = (uint16_t)start;
t.span_len = (uint16_t)(i + 1 - start);
t.string.offset = str_start;
t.string.length = (uint16_t)(i - str_start);
emit(t);
i++; // skip closing quote
continue;
}
// Numbers and symbols share one bounded token path. Scan only within
// the submitted slice, then convert the NUL-terminated copy below;
// strtod must never receive the caller's potentially unterminated
// source buffer directly.
if (is_symbol_char(c)) {
uint32_t start = i;
while (i < length && is_symbol_char(source[i])) i++;
// Intern the symbol
// We need a temporary null-terminated string
char buf[256];
uint32_t sym_len = i - start;
if (sym_len >= sizeof(buf)) {
uint16_t diag_len = sym_len > UINT16_MAX
? UINT16_MAX : (uint16_t)sym_len;
emit_error(start, diag_len, "Symbol is too long",
"Use a name shorter than 256 bytes");
Token t;
t.kind = TokenKind::Error;
t.span_start = (uint16_t)start;
t.span_len = diag_len;
emit(t);
continue;
}
memcpy(buf, source + start, sym_len);
buf[sym_len] = '\0';
// Check if it's actually a number (e.g., "-1" when preceded by
// space). Restricted to plain decimal text (A14) so strtod's
// inf/nan/hex forms stay symbols.
char* end_ptr = nullptr;
Sample val = strtod(buf, &end_ptr);
if (end_ptr == buf + sym_len &&
is_plain_number_text(buf, sym_len)) {
if (!std::isfinite(val)) {
emit_error(start, (uint16_t)sym_len,
"Numeric literal is outside the finite binary64 range",
"Use a smaller finite decimal literal");
Token t;
t.kind = TokenKind::Error;
t.span_start = (uint16_t)start;
t.span_len = (uint16_t)sym_len;
emit(t);
continue;
}
Token t;
t.kind = TokenKind::Number;
t.span_start = (uint16_t)start;
t.span_len = (uint16_t)sym_len;
t.number = val;
emit(t);
continue;
}
Token t;
t.kind = TokenKind::Symbol;
t.span_start = (uint16_t)start;
t.span_len = (uint16_t)sym_len;
t.symbol = SymbolIntern::getInstance().intern(String(buf));
emit(t);
continue;
}
// Unknown character
emit_error(i, 1, "Unexpected character");
i++;
}
// Append EOF
Token eof;
eof.kind = TokenKind::Eof;
eof.span_start = (uint16_t)length;
eof.span_len = 0;
emit(eof);
// EOF is not an implicit closing delimiter. The parser's expect() calls
// are deliberately non-fatal for recovery, so validate the lexical
// delimiter balance here before a malformed form can compile as valid.
if (delimiter_depth > 0) {
emit_error(length, 0, "Unclosed form",
"Add the missing closing delimiter(s)");
}
// If the token buffer overflowed we dropped tokens (including, possibly,
// the EOF). Emit a clear diagnostic instead of silently truncating the
// program — silent truncation produces baffling parse errors downstream.
if (overflowed) {
// Ensure the stream is still well-formed for the parser: force the
// final slot to EOF so callers don't read past valid tokens.
if (count > 0 && out[count - 1].kind != TokenKind::Eof) {
out[count - 1] = eof;
}
emit_error(0, (uint16_t)(length > 0xFFFF ? 0xFFFF : length),
"Program too large (token limit exceeded)");
}
return count;
}
} // namespace sig

54
src/signal_engine/token.h Normal file
View file

@ -0,0 +1,54 @@
#ifndef SIGNAL_ENGINE_TOKEN_H
#define SIGNAL_ENGINE_TOKEN_H
#include "types.h"
#include "diagnostics.h"
namespace sig {
// ── Token Types ─────────────────────────────────────────────────────────────
enum class TokenKind : uint8_t {
LParen, RParen, LBracket, RBracket,
Number, Symbol, String,
Eof, Error
};
struct Token {
TokenKind kind = TokenKind::Eof;
uint8_t pad = 0;
uint16_t span_start = 0;
uint16_t span_len = 0;
union {
Sample number;
SymbolID symbol;
struct { uint32_t offset; uint16_t length; } string;
};
Token() : number(0.0) {}
};
// sizeof(Token) == 16 bytes
// ── Token Stream ────────────────────────────────────────────────────────────
struct TokenStream {
Token tokens[MAX_TOKENS];
uint16_t count = 0;
uint16_t pos = 0;
Token peek() const;
Token consume();
bool expect(TokenKind kind);
void rewind(uint16_t position);
bool at_end() const;
// Zero-allocation tokenizer.
// Returns number of tokens written. Parse errors are appended to `errors`.
static uint16_t tokenize(const char* source, uint32_t length,
Token* out, uint16_t max_tokens,
Diagnostic* errors, uint8_t* error_count);
};
} // namespace sig
#endif // SIGNAL_ENGINE_TOKEN_H

110
src/signal_engine/types.h Normal file
View file

@ -0,0 +1,110 @@
#ifndef SIGNAL_ENGINE_TYPES_H
#define SIGNAL_ENGINE_TYPES_H
#include <cstdint>
#include <cstddef>
#include <type_traits>
#include "build_profile.h"
#include "../modulisp/lisp/symbol_intern.h"
namespace sig {
using SymbolID = SymbolIntern::SymbolID;
// ── Numeric Width ───────────────────────────────────────────────────────────
// Sample is the engine's numeric scalar: signal values, cell values, time
// (t/dt), node results, and output samples all use it. One control point for
// the width so a measured firmware decision (software double vs hardware
// float on RP2040, workspace SRAM halves) can be made without touching call
// sites. Default stays double: phasor time accumulates over long sessions and
// float's 24-bit mantissa degrades beat phase measurably.
//
// Known costs of enabling USEQ_SAMPLE_F32 (deliberately not hidden):
// - double literals (0.5) will promote under -Wdouble-promotion; the build
// keeps a zero-warning policy, so suffixes/casts must be fixed then.
// - Unqualified math calls (floor, sin) resolve to double overloads unless
// std::-qualified; qualify before flipping.
// - Flash persistence layout and the WASM C ABI are double today; they
// convert at their boundary or version their formats.
#if defined(USEQ_SAMPLE_F32)
using Sample = float;
#else
using Sample = double;
#endif
static_assert(std::is_floating_point_v<Sample>,
"Sample must be a floating-point type");
// Firmware builds use smaller limits to fit within the RP2040's 264 KB SRAM.
// USEQ_FIRMWARE_PROFILE selects the same retained capacities in a native
// process so capacity and endurance workloads can run without Arduino I/O.
// It is a resource-profile simulation, not target timing evidence.
// WASM/desktop builds keep generous limits since memory is plentiful.
// MAX_CELLS must exceed the number of built-in symbols (~177) since cells
// are indexed by SymbolID and user-defined names follow the built-ins.
#if defined(ARDUINO) || defined(USEQ_FIRMWARE_PROFILE)
constexpr size_t MAX_CELLS = 256;
constexpr size_t MAX_TOTAL_NODES = 360;
constexpr size_t MAX_DATA_ENTRIES = 512;
constexpr size_t MAX_DATA_TABLES = 32;
constexpr size_t MAX_LIVE_SLOTS = 16;
constexpr size_t MAX_LIVE_SLOT_OPTIONS = 8;
constexpr size_t SOURCE_ARENA_SIZE = 4096;
constexpr size_t CSE_TABLE_SIZE = MAX_TOTAL_NODES;
constexpr size_t MAX_OUTPUT_DEPS = 32;
constexpr size_t MAX_STATE_SLOTS = 16;
#else
constexpr size_t MAX_CELLS = 512;
constexpr size_t MAX_TOTAL_NODES = 1024;
constexpr size_t MAX_DATA_ENTRIES = 2048;
constexpr size_t MAX_DATA_TABLES = 64;
constexpr size_t MAX_LIVE_SLOTS = 256;
constexpr size_t MAX_LIVE_SLOT_OPTIONS = 16;
constexpr size_t SOURCE_ARENA_SIZE = 16384;
constexpr size_t CSE_TABLE_SIZE = MAX_TOTAL_NODES * 2;
constexpr size_t MAX_OUTPUT_DEPS = 64;
constexpr size_t MAX_STATE_SLOTS = 32;
#endif
constexpr size_t MAX_CALLABLE_PARAMS = 8;
constexpr size_t MAX_OUTPUTS = 42;
constexpr size_t MAX_SCOPE_DEPTH = 32;
constexpr size_t MAX_LOCAL_BINDINGS = 32;
constexpr size_t MAX_DIAGNOSTICS = 16;
constexpr size_t MAX_INLINE_DEPTH = 16;
// Max syntactic nesting depth for compile_expr recursion. compile_expr recurses
// on nested forms on the small RP2040 stack (inside tick() for live edits), so a
// pathologically deep program could overflow the hardware stack. This bound
// aborts compilation with a diagnostic well before that happens.
constexpr size_t MAX_COMPILE_DEPTH = 64;
constexpr size_t MAX_LIVE_SLOT_ID = 32;
constexpr size_t MAX_LIVE_SLOT_OPTION_LEN = 32;
constexpr size_t MAX_TOKENS = 256;
constexpr size_t BATCH_CHUNK_SIZE = 256;
#if USEQ_HAS_SYNTH_ENGINE
constexpr size_t MAX_SYNTH_CONTROL_ROOTS = 512;
#endif
// Persistent dependency records refer only to entries in CellStore, not to
// the unbounded symbol-interner namespace. A compact index therefore covers
// every valid dependency in every build profile without narrowing the public
// definition capacity.
using CellIndex = uint16_t;
static_assert(MAX_CELLS <= static_cast<size_t>(UINT16_MAX) + 1,
"CellIndex must represent every retained cell");
constexpr uint16_t NODE_NONE = 0xFFFF;
constexpr uint8_t FLAG_TIME_INVARIANT = 0x01;
// ── Output Classification (visualisation.md §4) ────────────────────────────
enum class OutputClass : uint8_t {
Inactive = 0, // no graph assigned
Pure = 1, // closed-form function of t only (+ cells, data)
InputDep = 2, // references hardware inputs but no cross-sample state
Stateful = 3, // uses LoadState, LoadDt, or PrevOutputLoad
};
} // namespace sig
#endif // SIGNAL_ENGINE_TYPES_H

10
src/utils/common.cpp Normal file
View file

@ -0,0 +1,10 @@
#include "common.h"
/* C++ prototypes */
long map(long x, long in_min, long in_max, long out_min, long out_max)
{
return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
}
uint16_t makeWord(uint16_t w) { return w; }
uint16_t makeWord(uint8_t h, uint8_t l) { return (h << 8) | l; }

192
src/utils/common.h Normal file
View file

@ -0,0 +1,192 @@
#ifndef COMMON_H_
#define COMMON_H_
#pragma once
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
void yield(void);
typedef enum
{
LOW = 0,
HIGH = 1,
CHANGE = 2,
FALLING = 3,
RISING = 4,
} PinStatus;
typedef enum
{
INPUT = 0x0,
OUTPUT = 0x1,
INPUT_PULLUP = 0x2,
INPUT_PULLDOWN = 0x3,
OUTPUT_OPENDRAIN = 0x4,
} PinMode;
typedef enum
{
LSBFIRST = 0,
MSBFIRST = 1,
} BitOrder;
#define PI 3.1415926535897932384626433832795
#define HALF_PI 1.5707963267948966192313216916398
#define TWO_PI 6.283185307179586476925286766559
#define DEG_TO_RAD 0.017453292519943295769236907684886
#define RAD_TO_DEG 57.295779513082320876798154814105
#define EULER 2.718281828459045235360287471352
#define SERIAL 0x0
#define DISPLAY 0x1
#ifndef constrain
#define constrain(amt, low, high) \
((amt) < (low) ? (low) : ((amt) > (high) ? (high) : (amt)))
#endif
#ifndef radians
#define radians(deg) ((deg) * DEG_TO_RAD)
#endif
#ifndef degrees
#define degrees(rad) ((rad) * RAD_TO_DEG)
#endif
#ifndef sq
#define sq(x) ((x) * (x))
#endif
typedef void (*voidFuncPtr)(void);
typedef void (*voidFuncPtrParam)(void*);
// interrupts() / noInterrupts() must be defined by the core
#define lowByte(w) ((uint8_t)((w) & 0xff))
#define highByte(w) ((uint8_t)((w) >> 8))
#define bitRead(value, bit) (((value) >> (bit)) & 0x01)
#define bitSet(value, bit) ((value) |= (1UL << (bit)))
#define bitClear(value, bit) ((value) &= ~(1UL << (bit)))
#define bitToggle(value, bit) ((value) ^= (1UL << (bit)))
#define bitWrite(value, bit, bitvalue) \
((bitvalue) ? bitSet((value), (bit)) : bitClear((value), (bit)))
#ifndef bit
#define bit(b) (1UL << (b))
#endif
/* TODO: request for removal */
typedef bool boolean;
typedef uint8_t byte;
typedef uint16_t word;
void init(void);
void initVariant(void);
#ifndef HOST
int atexit(void (*func)()) __attribute__((weak));
#endif
// The Arduino core's own `int main()` declaration is deliberately not
// mirrored here: nothing in this tree declares or calls main through this
// shim, and declaring ::main inside extern "C" is ill-formed.
#ifdef EXTENDED_PIN_MODE
// Platforms who want to declare more than 256 pins need to define
// EXTENDED_PIN_MODE globally
typedef uint32_t pin_size_t;
#else
typedef uint8_t pin_size_t;
#endif
void pinMode(pin_size_t pinNumber, PinMode pinMode);
void digitalWrite(pin_size_t pinNumber, PinStatus status);
PinStatus digitalRead(pin_size_t pinNumber);
int analogRead(pin_size_t pinNumber);
void analogReference(uint8_t mode);
void analogWrite(pin_size_t pinNumber, int value);
unsigned long millis(void);
unsigned long micros(void);
void delay(unsigned long);
void delayMicroseconds(unsigned int us);
unsigned long pulseIn(pin_size_t pin, uint8_t state, unsigned long timeout);
unsigned long pulseInLong(pin_size_t pin, uint8_t state, unsigned long timeout);
void shiftOut(pin_size_t dataPin, pin_size_t clockPin, BitOrder bitOrder,
uint8_t val);
uint8_t shiftIn(pin_size_t dataPin, pin_size_t clockPin, BitOrder bitOrder);
void attachInterrupt(pin_size_t interruptNumber, voidFuncPtr callback,
PinStatus mode);
void attachInterruptParam(pin_size_t interruptNumber, voidFuncPtrParam callback,
PinStatus mode, void* param);
void detachInterrupt(pin_size_t interruptNumber);
void setup(void);
void loop(void);
#ifdef __cplusplus
} // extern "C"
#endif
#ifdef __cplusplus
template <class T, class L>
auto min(const T& a, const L& b) -> decltype((b < a) ? b : a)
{
return (b < a) ? b : a;
}
template <class T, class L>
auto max(const T& a, const L& b) -> decltype((b < a) ? b : a)
{
return (a < b) ? b : a;
}
#else
#ifndef min
#define min(a, b) \
({ \
__typeof__(a) _a = (a); \
__typeof__(b) _b = (b); \
_a < _b ? _a : _b; \
})
#endif
#ifndef max
#define max(a, b) \
({ \
__typeof__(a) _a = (a); \
__typeof__(b) _b = (b); \
_a > _b ? _a : _b; \
})
#endif
#endif
#ifdef __cplusplus
/* C++ prototypes */
uint16_t makeWord(uint16_t w);
uint16_t makeWord(byte h, byte l);
#define word(...) makeWord(__VA_ARGS__)
unsigned long pulseIn(uint8_t pin, uint8_t state, unsigned long timeout = 1000000L);
unsigned long pulseInLong(uint8_t pin, uint8_t state,
unsigned long timeout = 1000000L);
void tone(uint8_t _pin, unsigned int frequency, unsigned long duration = 0);
void noTone(uint8_t _pin);
// WMath prototypes
long random(long);
long random(long, long);
void randomSeed(unsigned long);
long map(long, long, long, long, long);
#endif // __cplusplus
#endif // COMMON_H_

View file

@ -0,0 +1,20 @@
#ifndef COMPILER_CONFIG_H_
#define COMPILER_CONFIG_H_
// Warning suppression macros — used by dtostrf.h and third-party headers.
#define USEQ_SUPPRESS_WARNINGS_PUSH \
_Pragma("GCC diagnostic push") _Pragma("GCC diagnostic ignored \"-Wall\"") \
_Pragma("GCC diagnostic ignored \"-Wextra\"") \
_Pragma("GCC diagnostic ignored \"-Wpedantic\"")
#define USEQ_SUPPRESS_WARNINGS_POP _Pragma("GCC diagnostic pop")
#define USEQ_SUPPRESS_FORMAT_WARNINGS_PUSH \
_Pragma("GCC diagnostic push") \
_Pragma("GCC diagnostic ignored \"-Wformat-nonliteral\"") \
_Pragma("GCC diagnostic ignored \"-Wdeprecated-declarations\"")
#define USEQ_SUPPRESS_FORMAT_WARNINGS_POP _Pragma("GCC diagnostic pop")
#endif // COMPILER_CONFIG_H_

52
src/utils/dtostrf.h Normal file
View file

@ -0,0 +1,52 @@
#ifndef DTOSTRF_H_
#define DTOSTRF_H_
/*
dtostrf - Emulation for dtostrf function from avr-libc
Copyright (c) 2015 Arduino LLC. All rights reserved.
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
*/
#pragma once
#if !defined(ARDUINO_ARCH_AVR)
#ifdef __cplusplus
extern "C"
{
#endif
// char *dtostrf(double val, signed char width, unsigned char prec, char *sout);
#include <cstdio>
char* dtostrf(double val, signed char width, unsigned char prec, char* sout)
{
asm(".global _printf_float");
#include "compiler_config.h"
USEQ_SUPPRESS_FORMAT_WARNINGS_PUSH
char fmt[20];
sprintf(fmt, "%%%d.%df", width, prec);
sprintf(sout, fmt, val);
return sout;
USEQ_SUPPRESS_FORMAT_WARNINGS_POP
}
#ifdef __cplusplus
}
#endif
#endif
#endif // DTOSTRF_H_

82
src/utils/itoa.cpp Normal file
View file

@ -0,0 +1,82 @@
#include "itoa.h"
/*
* Copyright (c) 2020 Arduino. All rights reserved.
*/
#ifdef ARDUINO
#else
/**************************************************************************************
* INCLUDE
**************************************************************************************/
#include "itoa.h"
#include <stdexcept>
#include <string>
#include <stdio.h>
/**************************************************************************************
* FUNCTION IMPLEMENTATION
**************************************************************************************/
std::string radixToFmtString(int const radix)
{
if (radix == 8)
return std::string("%o");
else if (radix == 10)
return std::string("%d");
else if (radix == 16)
return std::string("%X");
else
{
#ifndef ARDUINO
throw std::runtime_error("Invalid radix.");
#endif
return "";
}
}
char* itoa(int value, char* str, int radix)
{
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
#pragma GCC diagnostic ignored "-Wformat-nonliteral"
sprintf(str, radixToFmtString(radix).c_str(), value);
#pragma GCC diagnostic pop
return str;
}
char* ltoa(long value, char* str, int radix)
{
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
#pragma GCC diagnostic ignored "-Wformat-nonliteral"
sprintf(str, radixToFmtString(radix).c_str(), value);
#pragma GCC diagnostic pop
return str;
}
char* utoa(unsigned value, char* str, int radix)
{
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
#pragma GCC diagnostic ignored "-Wformat-nonliteral"
sprintf(str, radixToFmtString(radix).c_str(), value);
#pragma GCC diagnostic pop
return str;
}
char* ultoa(unsigned long value, char* str, int radix)
{
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
#pragma GCC diagnostic ignored "-Wformat-nonliteral"
sprintf(str, radixToFmtString(radix).c_str(), value);
#pragma GCC diagnostic pop
return str;
}
#endif // ifdef ARDUINO

46
src/utils/itoa.h Normal file
View file

@ -0,0 +1,46 @@
#ifndef ITOA_H_
#define ITOA_H_
/*
Copyright (c) 2016 Arduino LLC. All right reserved.
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
*/
#ifdef ARDUINO
#else
#pragma once
// Standard C functions required in Arduino API
// If these functions are not provided by the standard library, the
// core should supply an implementation of them.
#ifdef __cplusplus
extern "C"
{
#endif
extern char* itoa(int value, char* string, int radix);
extern char* ltoa(long value, char* string, int radix);
extern char* utoa(unsigned value, char* string, int radix);
extern char* ultoa(unsigned long value, char* string, int radix);
#ifdef __cplusplus
} // extern "C"
#endif
#endif // ifdef ARDUINO
#endif // ITOA_H_

220
src/utils/json_builder.h Normal file
View file

@ -0,0 +1,220 @@
#ifndef JSON_BUILDER_H_
#define JSON_BUILDER_H_
#include "string.h"
#include <cstdio>
/**
* @brief Lightweight fluent JSON builder using the project's String type.
*
* Hand-rolled string concatenation behind a clean API so callers don't
* embed raw JSON punctuation. If we ever adopt ArduinoJson or similar,
* only this file needs to change.
*
* Usage:
* String json = JsonBuilder()
* .object_begin()
* .field("success", true)
* .field("mode", String("json"))
* .field("count", 42)
* .field_null("meta")
* .array_begin("items")
* .object_begin()
* .field("index", 1)
* .field("name", String("ssin1"))
* .object_end()
* .array_end()
* .object_end()
* .build();
*/
class JsonBuilder
{
public:
JsonBuilder() { m_buf.reserve(128); }
// ---- structure ----
JsonBuilder& object_begin()
{
maybe_comma();
m_buf += '{';
push_scope();
return *this;
}
JsonBuilder& object_end()
{
pop_scope();
m_buf += '}';
return *this;
}
JsonBuilder& array_begin(const String& key)
{
maybe_comma();
append_key(key);
m_buf += '[';
push_scope();
return *this;
}
JsonBuilder& array_begin_unkeyed()
{
maybe_comma();
m_buf += '[';
push_scope();
return *this;
}
JsonBuilder& array_end()
{
pop_scope();
m_buf += ']';
return *this;
}
// ---- keyed fields ----
JsonBuilder& field(const String& key, const String& value)
{
maybe_comma();
append_key(key);
m_buf += '"';
append_escaped(value);
m_buf += '"';
return *this;
}
JsonBuilder& field(const String& key, const char* value)
{
return field(key, String(value));
}
JsonBuilder& field(const String& key, bool value)
{
maybe_comma();
append_key(key);
m_buf += value ? "true" : "false";
return *this;
}
JsonBuilder& field(const String& key, int value)
{
maybe_comma();
append_key(key);
char numbuf[16];
snprintf(numbuf, sizeof(numbuf), "%d", value);
m_buf += numbuf;
return *this;
}
JsonBuilder& field_null(const String& key)
{
maybe_comma();
append_key(key);
m_buf += "null";
return *this;
}
/** Insert a pre-built JSON fragment as the value for @p key. */
JsonBuilder& field_raw(const String& key, const String& raw_json)
{
maybe_comma();
append_key(key);
m_buf += raw_json;
return *this;
}
// ---- terminal ----
String build() const { return m_buf; }
private:
static constexpr int MAX_DEPTH = 8;
String m_buf;
bool m_need_comma[MAX_DEPTH] = {};
int m_depth = 0;
void push_scope()
{
if (m_depth < MAX_DEPTH)
{
m_need_comma[m_depth] = false;
++m_depth;
}
}
void pop_scope()
{
if (m_depth > 0)
{
--m_depth;
}
// After closing a scope, the parent scope needs a comma before the next element
if (m_depth > 0)
{
m_need_comma[m_depth - 1] = true;
}
}
void maybe_comma()
{
if (m_depth > 0 && m_need_comma[m_depth - 1])
{
m_buf += ',';
}
if (m_depth > 0)
{
m_need_comma[m_depth - 1] = true;
}
}
void append_key(const String& key)
{
m_buf += '"';
append_escaped(key);
m_buf += "\":";
}
void append_escaped(const String& s)
{
for (unsigned int i = 0; i < s.length(); ++i)
{
char c = s[i];
switch (c)
{
case '"':
m_buf += "\\\"";
break;
case '\\':
m_buf += "\\\\";
break;
case '\n':
m_buf += "\\n";
break;
case '\r':
m_buf += "\\r";
break;
case '\t':
m_buf += "\\t";
break;
default:
if (static_cast<unsigned char>(c) < 0x20)
{
char ubuf[7];
snprintf(ubuf, sizeof(ubuf), "\\u%04X",
static_cast<unsigned char>(c));
m_buf += ubuf;
}
else
{
m_buf += c;
}
break;
}
}
}
};
#endif // JSON_BUILDER_H_

303
src/utils/json_cursor.h Normal file
View file

@ -0,0 +1,303 @@
#pragma once
#include <cstddef>
#include <climits>
#include <cstdlib>
#include <cstring>
namespace useq::json {
enum class Kind : unsigned char {
Invalid,
String,
Number,
Object,
Array,
Boolean,
Null,
};
struct Value {
const char* data = nullptr;
size_t size = 0;
Kind kind = Kind::Invalid;
explicit operator bool() const { return data != nullptr; }
};
namespace detail {
inline bool is_space(char c) {
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
}
inline const char* skip_space(const char* cursor, const char* end) {
while (cursor < end && is_space(*cursor)) ++cursor;
return cursor;
}
inline const char* scan_string(const char* cursor, const char* end) {
if (cursor >= end || *cursor != '"') return nullptr;
++cursor;
bool escaped = false;
while (cursor < end) {
const char c = *cursor++;
if (escaped) {
escaped = false;
} else if (c == '\\') {
escaped = true;
} else if (c == '"') {
return cursor;
} else if (static_cast<unsigned char>(c) < 0x20) {
return nullptr;
}
}
return nullptr;
}
inline const char* scan_compound(const char* cursor, const char* end) {
const char opening = *cursor;
const char closing = opening == '{' ? '}' : ']';
size_t depth = 0;
while (cursor < end) {
if (*cursor == '"') {
cursor = scan_string(cursor, end);
if (!cursor) return nullptr;
continue;
}
if (*cursor == opening) {
++depth;
} else if (*cursor == closing) {
if (--depth == 0) return cursor + 1;
} else if (*cursor == '{' || *cursor == '[') {
const char* nested = scan_compound(cursor, end);
if (!nested) return nullptr;
cursor = nested;
continue;
}
++cursor;
}
return nullptr;
}
inline bool scan_value(const char* cursor, const char* end,
Value& value, const char*& after) {
cursor = skip_space(cursor, end);
if (cursor >= end) return false;
const char* value_end = nullptr;
Kind kind = Kind::Invalid;
if (*cursor == '"') {
kind = Kind::String;
value_end = scan_string(cursor, end);
} else if (*cursor == '{') {
kind = Kind::Object;
value_end = scan_compound(cursor, end);
} else if (*cursor == '[') {
kind = Kind::Array;
value_end = scan_compound(cursor, end);
} else {
value_end = cursor;
while (value_end < end && !is_space(*value_end) &&
*value_end != ',' && *value_end != '}' && *value_end != ']') {
++value_end;
}
if (value_end == cursor) return false;
const size_t length = static_cast<size_t>(value_end - cursor);
if (length == 4 && std::memcmp(cursor, "true", 4) == 0) {
kind = Kind::Boolean;
} else if (length == 5 && std::memcmp(cursor, "false", 5) == 0) {
kind = Kind::Boolean;
} else if (length == 4 && std::memcmp(cursor, "null", 4) == 0) {
kind = Kind::Null;
} else {
kind = Kind::Number;
}
}
if (!value_end) return false;
value = {cursor, static_cast<size_t>(value_end - cursor), kind};
after = value_end;
return true;
}
} // namespace detail
inline bool copy_string(const Value& value, char* out, size_t capacity,
bool* truncated = nullptr) {
if (truncated) *truncated = false;
if (!out || capacity == 0 || value.kind != Kind::String || value.size < 2) {
if (out && capacity) out[0] = '\0';
return false;
}
const char* cursor = value.data + 1;
const char* end = value.data + value.size - 1;
size_t written = 0;
bool overflow = false;
while (cursor < end) {
char c = *cursor++;
if (c == '\\') {
if (cursor >= end) return false;
switch (*cursor++) {
case '"': c = '"'; break;
case '\\': c = '\\'; break;
case '/': c = '/'; break;
case 'b': c = '\b'; break;
case 'f': c = '\f'; break;
case 'n': c = '\n'; break;
case 'r': c = '\r'; break;
case 't': c = '\t'; break;
default: return false; // Unicode escapes are not in the wire subset.
}
}
if (written + 1 < capacity) out[written++] = c;
else overflow = true;
}
out[written] = '\0';
if (truncated) *truncated = overflow;
return !overflow;
}
inline bool number(const Value& value, double& result) {
if (value.kind != Kind::Number || value.size == 0 || value.size >= 64) {
return false;
}
char buffer[64];
std::memcpy(buffer, value.data, value.size);
buffer[value.size] = '\0';
char* after = nullptr;
result = std::strtod(buffer, &after);
return after == buffer + value.size;
}
inline bool integer(const Value& value, int& result) {
double parsed = 0.0;
if (!number(value, parsed)) return false;
if (parsed != parsed) return false; // NaN
if (parsed < static_cast<double>(INT_MIN) ||
parsed > static_cast<double>(INT_MAX)) return false;
const int converted = static_cast<int>(parsed);
if (static_cast<double>(converted) != parsed) return false;
result = converted;
return true;
}
inline bool boolean(const Value& value, bool& result) {
if (value.kind != Kind::Boolean) return false;
result = value.size == 4;
return true;
}
class ObjectCursor {
public:
ObjectCursor(const char* json, size_t size)
: begin_(json), cursor_(json), end_(json ? json + size : nullptr) {
if (!json) return;
cursor_ = detail::skip_space(cursor_, end_);
if (cursor_ >= end_ || *cursor_ != '{') return;
++cursor_;
valid_ = true;
}
explicit ObjectCursor(const Value& object)
: ObjectCursor(object.data, object.size) {
if (object.kind != Kind::Object) valid_ = false;
}
bool next(Value& key, Value& value) {
if (!valid_ || finished_) return false;
cursor_ = detail::skip_space(cursor_, end_);
if (cursor_ < end_ && *cursor_ == '}') {
finished_ = true;
++cursor_;
return false;
}
const char* key_end = detail::scan_string(cursor_, end_);
if (!key_end) return fail();
key = {cursor_, static_cast<size_t>(key_end - cursor_), Kind::String};
cursor_ = detail::skip_space(key_end, end_);
if (cursor_ >= end_ || *cursor_ != ':') return fail();
++cursor_;
const char* after = nullptr;
if (!detail::scan_value(cursor_, end_, value, after)) return fail();
cursor_ = detail::skip_space(after, end_);
if (cursor_ < end_ && *cursor_ == ',') {
++cursor_;
} else if (cursor_ >= end_ || *cursor_ != '}') {
return fail();
}
return true;
}
bool valid() const { return valid_; }
bool finished() const { return finished_; }
bool find(const char* name, Value& value) {
if (!name || !valid_ || !begin_ || !end_) return false;
ObjectCursor scan(begin_, static_cast<size_t>(end_ - begin_));
Value key;
char decoded[80];
while (scan.next(key, value)) {
if (copy_string(key, decoded, sizeof(decoded)) &&
std::strcmp(decoded, name) == 0) {
return true;
}
}
return false;
}
private:
bool fail() {
valid_ = false;
return false;
}
const char* begin_ = nullptr;
const char* cursor_ = nullptr;
const char* end_ = nullptr;
bool valid_ = false;
bool finished_ = false;
};
class ArrayCursor {
public:
explicit ArrayCursor(const Value& array)
: cursor_(array.data), end_(array.data ? array.data + array.size : nullptr) {
if (array.kind != Kind::Array || !cursor_ || cursor_ >= end_ ||
*cursor_ != '[') return;
++cursor_;
valid_ = true;
}
bool next(Value& value) {
if (!valid_ || finished_) return false;
cursor_ = detail::skip_space(cursor_, end_);
if (cursor_ < end_ && *cursor_ == ']') {
finished_ = true;
++cursor_;
return false;
}
const char* after = nullptr;
if (!detail::scan_value(cursor_, end_, value, after)) return fail();
cursor_ = detail::skip_space(after, end_);
if (cursor_ < end_ && *cursor_ == ',') {
++cursor_;
} else if (cursor_ >= end_ || *cursor_ != ']') {
return fail();
}
return true;
}
bool valid() const { return valid_; }
bool finished() const { return finished_; }
private:
bool fail() {
valid_ = false;
return false;
}
const char* cursor_ = nullptr;
const char* end_ = nullptr;
bool valid_ = false;
bool finished_ = false;
};
} // namespace useq::json

424
src/utils/log.cpp Normal file
View file

@ -0,0 +1,424 @@
#include "log.h"
#include "json_builder.h"
#include <cstdio>
#include <iostream>
namespace
{
struct JsonProtocolState
{
bool json_mode_enabled = false;
bool request_active = false;
String request_id = "";
String text_buffer = "";
};
JsonProtocolState& state()
{
static JsonProtocolState s;
return s;
}
#ifdef ARDUINO
void write_serial_json(const String& payload)
{
if (!Serial.availableForWrite())
{
return;
}
Serial.write(SerialMsg::message_begin_marker);
Serial.write((u_int8_t)SerialMsg::serial_message_types::JSON);
Serial.println(payload);
}
#else
void write_serial_json(const String&)
{
// Desktop build has no serial transport; JSON responses are ignored.
}
#endif
} // namespace
namespace Protocol
{
void enable_json_mode() { state().json_mode_enabled = true; }
void disable_json_mode()
{
state().json_mode_enabled = false;
state().request_active = false;
state().text_buffer = "";
state().request_id = "";
}
bool json_mode_enabled() { return state().json_mode_enabled; }
void begin_request(const String& request_id)
{
state().request_active = true;
state().request_id = request_id;
state().text_buffer = "";
}
void finish_request()
{
state().request_active = false;
state().request_id = "";
state().text_buffer = "";
}
bool request_active() { return state().request_active; }
void append_request_text(const String& line)
{
auto& json_state = state();
if (!json_state.request_active)
{
return;
}
if (json_state.text_buffer.length() > 0)
{
json_state.text_buffer += "\n";
}
json_state.text_buffer += line;
}
String consume_request_text()
{
String copy = state().text_buffer;
state().text_buffer = "";
return copy;
}
void send_json_response(bool success, const String& text,
const std::optional<String>& meta,
const String& request_id,
const std::optional<String>& diagnostics_json)
{
JsonBuilder b;
b.object_begin()
.field("type", "response")
.field("success", success)
.field("console", text)
.field("text", text);
if (meta && meta->length() > 0)
{
b.field_raw("meta", *meta);
}
else
{
b.field_null("meta");
}
if (diagnostics_json && diagnostics_json->length() > 0)
{
b.field_raw("diagnostics", *diagnostics_json);
}
b.field("requestId", request_id)
.object_end();
write_serial_json(b.build());
}
void send_json_error(const String& request_id, const String& message)
{
send_json_response(false, message, std::nullopt, request_id);
}
void send_raw_json(const String& payload) { write_serial_json(payload); }
} // namespace Protocol
void message_editor(const String& s)
{
#ifdef ARDUINO
if (Protocol::request_active())
{
Protocol::append_request_text(s);
return;
}
if (Protocol::json_mode_enabled())
{
Protocol::send_json_response(true, s, std::nullopt, String());
return;
}
// Legacy framed-text (TEXT/MSG_TO_EDITOR bytes) removed per wire-protocol
// spec §5.6. firmware::Firmware always enables json_mode before reaching
// this point, so the branch above is the live path.
#else
std::cout << "[EDITOR] " << s.c_str() << std::endl;
#endif
}
void println(const String& s)
{
#ifdef ARDUINO
if (Protocol::request_active())
{
Protocol::append_request_text(s);
return;
}
if (Protocol::json_mode_enabled())
{
Protocol::send_json_response(true, s, std::nullopt, String());
return;
}
// Legacy framed-text (TEXT byte) removed per wire-protocol spec §5.6.
// firmware::Firmware always enables json_mode before reaching this point,
// so the branch above is the live path.
#else
std::cout << s.c_str() << std::endl;
#endif
}
// ERRORS
std::vector<String> error_msg_q = {};
void report_error(const String& s) { error_msg_q.push_back(s); }
void report_generic_error(const String& s)
{
report_error(String("**Error**: ") + s);
}
void report_runtime_error(const String& s)
{
report_error((String) "**Runtime Error**: " + s);
}
void report_user_warning(const String& s) { report_error("**Warning**: " + s); }
void report_evaluation_error(const String& error_msg, String atom)
{
String msg = "**Evaluation Error**: While evaluating atom " + atom +
", the following error ocurred:\n " + error_msg;
report_error(msg);
}
String comp_to_string(NumArgsComparison comp)
{
switch (comp)
{
case NumArgsComparison::EqualTo:
{
return "exactly";
}
case NumArgsComparison::AtLeast:
{
return "at least";
}
case NumArgsComparison::AtMost:
{
return "at most";
}
case NumArgsComparison::Between:
{
return "between";
}
default:
return "<comparison not found>";
}
return "";
}
void report_error_wrong_num_args(const String& function_name, int num_received,
NumArgsComparison expected_comp, int num,
int num2 = 0)
{
String expected_comparison_str = comp_to_string(expected_comp);
String received_num_str = String(num_received);
expected_comparison_str += " " + String(num);
if (expected_comp == NumArgsComparison::Between)
{
expected_comparison_str += " and " + String(num2);
}
report_error("(`" + function_name + "`) Wrong number of arguments: expected " +
expected_comparison_str + " but received " + received_num_str +
" instead.");
}
void report_error_arg_is_error(const String& function_name, int num,
const String& received_val_str)
{
String msg = "(`" + function_name + "`) Argument #" + String(num) +
" evaluates to an error:";
msg += "\n " + received_val_str;
report_error(msg);
}
void report_error_wrong_all_pred(const String& function_name, int num,
const String& expected_str,
const String& received_val_str)
{
String msg = "(`" + function_name + "`) All arguments should evaluate to " +
expected_str + ", but argument #" + String(num) + " does not:";
msg += "\n " + received_val_str;
report_error(msg);
}
void report_error_wrong_specific_pred(const String& function_name, int num,
const String& expected_str,
const String& received_val_str)
{
String msg = "(`" + function_name + "`) Argument #" + String(num) +
" should evaluate to " + expected_str + ", but instead it is:";
msg += "\n " + received_val_str;
report_error(msg);
}
void report_error_atom_not_defined(const String& atom)
{
report_error("Atom **" + atom + "** not defined.");
}
void report_custom_function_error(const String& function_name, const String& msg)
{
report_error("(`" + function_name + "`) " + msg);
}
int free_heap() {
#ifdef ARDUINO
return rp2040.getFreeHeap();
#else
return 1000000; // Dummy value for non-Arduino environments
#endif
}
// DebugLogger
#if USEQ_DEBUG
std::unordered_set<String> DebugLogger::mutes = {};
std::unordered_set<String> DebugLogger::solos = { "uSEQ::eval_at_time" };
bool DebugLogger::print_free_heap = false;
int DebugLogger::m_level = -1;
String DebugLogger::m_spaces = "";
void DebugLogger::inc_level()
{
m_level += 1;
update_spaces();
}
void DebugLogger::dec_level()
{
m_level -= 1;
update_spaces();
}
DebugLogger::DebugLogger(String s) : m_name(s)
{
start_heap = free_heap();
inc_level();
debug_print("[DBG] " + m_name + " START...");
inc_level();
if (print_free_heap)
{
debug_print("free heap (start): " + String(start_heap) + " bytes");
}
}
DebugLogger::~DebugLogger()
{
int end_heap = free_heap();
dec_level();
debug_print("[DBG] " + m_name + " END.");
if (print_free_heap)
{
debug_print(m_spaces + "free heap (end): " + String(end_heap) +
" bytes");
debug_print(m_spaces + "heap difference: " + String(end_heap - start_heap) +
" bytes");
}
dec_level();
}
void DebugLogger::filtered_log(const String& message)
{
bool should_print = true;
// If solos have been specified and this isn't in them, don't print
if (solos.size() > 0 && !solos.count(m_name))
{
should_print = false;
}
// Otherwise, print as long as it's not in the mutes
else if (mutes.count(m_name))
{
should_print = false;
}
if (should_print)
{
debug_print("[" + m_name + "] " + addIndentAfterNewlines(message));
}
}
void DebugLogger::operator()(const String& message) { filtered_log(message); }
void DebugLogger::debug_print(const String& s)
{
print(m_spaces + s);
print("\n");
}
String DebugLogger::addIndentAfterNewlines(const String& input)
{
String result;
int lastIndex = 0;
int currentIndex = 0;
// Search for newline characters and modify the string
while ((currentIndex = input.indexOf('\n', lastIndex)) != -1)
{
// Add the current segment plus newline
result += input.substring(lastIndex, currentIndex + 1);
// Add spaces after the newline
result += m_spaces;
// Move past the newline character
lastIndex = currentIndex + 1;
}
// Add the last part of the string if any
result += input.substring(lastIndex);
return result;
}
void DebugLogger::update_spaces()
{
String s = "";
if (m_level > 0)
{
for (int i = 0; i < m_level; i++)
{
s += " ";
}
m_spaces = s;
}
else
{
m_spaces = "";
}
}
#endif // USEQ_DEBUG

110
src/utils/log.h Normal file
View file

@ -0,0 +1,110 @@
#ifndef LOG_H_
#define LOG_H_
#include "serial_message.h"
#include "string.h"
#include <optional>
#include <vector>
extern std::vector<String> error_msg_q;
void message_editor(const String& s);
void println(const String& s);
namespace Protocol
{
void enable_json_mode();
void disable_json_mode();
bool json_mode_enabled();
void begin_request(const String& request_id);
void finish_request();
bool request_active();
void append_request_text(const String& line);
String consume_request_text();
void send_json_response(bool success, const String& text,
const std::optional<String>& meta,
const String& request_id,
const std::optional<String>& diagnostics_json = std::nullopt);
void send_json_error(const String& request_id, const String& message);
void send_raw_json(const String& payload);
} // namespace Protocol
void debug(String s);
void report_error(const String& s);
void report_generic_error(const String& s);
void report_runtime_error(const String& s);
void report_user_warning(const String& s);
enum class UserError
{
WrongNumArgs,
SpecificArgType,
AllArgsType
};
enum class NumArgsComparison
{
EqualTo,
AtLeast,
AtMost,
Between
};
void report_error_wrong_num_args(const String& function_name, int num_received,
NumArgsComparison comp, int num, int num2);
void report_error_arg_is_error(const String& function_name, int num,
const String& received_val_str);
void report_error_wrong_all_pred(const String& function_name, int num,
const String& expected_str,
const String& received_val_str);
void report_error_wrong_specific_pred(const String& function_name, int num,
const String& expected_str,
const String& received_val_str);
void report_error_atom_not_defined(const String& atom);
void report_custom_function_error(const String& function_name, const String& msg);
int free_heap();
#if USEQ_DEBUG
#include <unordered_set>
class DebugLogger
{
public:
DebugLogger(String name);
~DebugLogger();
// These are the same, log is more explicit
void log(const String& message);
void operator()(const String& message);
static bool print_free_heap;
static std::unordered_set<String> mutes;
static std::unordered_set<String> solos;
private:
String m_name;
void dbg_print(const String& s);
int start_heap = 0;
String addIndentAfterNewlines(const String& input);
static int m_level;
static String m_spaces;
static void update_spaces();
static void inc_level();
static void dec_level();
};
#endif // USEQ_DEBUG
#endif // LOG_H_

View file

@ -0,0 +1,28 @@
#ifndef SERIAL_MESSAGE_H
#define SERIAL_MESSAGE_H
#include <cstdint>
namespace SerialMsg
{
constexpr uint8_t message_begin_marker = 31;
enum serial_message_types
{
// TEXT (0x20) and MSG_TO_EDITOR (0x64) removed: replaced by
// {type:"log",...} JSON envelope per wire-protocol spec §5.6.
// message_end_marker (0x03) removed: never sent by firmware::Firmware.
// execute_now_marker ('@') removed: wire is immediate-only per spec §1.1.
JSON = 101,
STREAM = 0,
// INPUT_SET (wire-protocol §6.5): editor → device high-rate batched
// live-edit slot writes. Frame: [0x1F][0x01][count:u16-LE]
// [(slot_index:u16-LE, value:f64-LE) × count]
INPUT_SET = 1
};
constexpr unsigned long serial_message_rate_limit = 1000000 / 100 /*Hz*/;
}; // namespace SerialMsg
#endif

887
src/utils/string.cpp Normal file
View file

@ -0,0 +1,887 @@
/*
String library for Wiring & Arduino
...mostly rewritten by Paul Stoffregen...
Copyright (c) 2009-10 Hernando Barragan. All rights reserved.
Copyright 2011, Paul Stoffregen, paul@pjrc.com
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
*/
#include "string.h"
#include <climits>
#include <cmath>
#ifdef ARDUINO
// nothing
#elif defined(USE_STD_STR)
// nothing
#elif defined(USE_ARDUINO_STR) || defined(USE_OWN_ARDUINO_STR)
/*
String library for Wiring & Arduino
...mostly rewritten by Paul Stoffregen...
Copyright (c) 2009-10 Hernando Barragan. All rights reserved.
Copyright 2011, Paul Stoffregen, paul@pjrc.com
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
*/
#include "common.h"
#include "dtostrf.h"
#include "itoa.h"
#include "string.h"
#include <float.h>
namespace arduino
{
/*********************************************/
/* Static Member Initialisation */
/*********************************************/
size_t const String::FLT_MAX_DECIMAL_PLACES;
size_t const String::DBL_MAX_DECIMAL_PLACES;
/*********************************************/
/* Constructors */
/*********************************************/
String::String(const char* cstr)
{
init();
if (cstr)
copy(cstr, static_cast<unsigned int>(strlen(cstr)));
}
String::String(const char* cstr, unsigned int length)
{
init();
if (cstr)
copy(cstr, length);
}
String::String(const String& value)
{
init();
*this = value;
}
//
// String::String(const __FlashStringHelper *pstr)
//{
// init();
// *this = pstr;
//}
String::String(String&& rval)
: buffer(rval.buffer), capacity(rval.capacity), len(rval.len)
{
rval.buffer = NULL;
rval.capacity = 0;
rval.len = 0;
}
String::String(char c)
{
init();
char buf[2];
buf[0] = c;
buf[1] = 0;
*this = buf;
}
String::String(unsigned char value, unsigned char base)
{
init();
char buf[1 + 8 * sizeof(unsigned char)];
utoa(value, buf, base);
*this = buf;
}
String::String(int value, unsigned char base)
{
init();
char buf[2 + 8 * sizeof(int)];
itoa(value, buf, base);
*this = buf;
}
String::String(unsigned int value, unsigned char base)
{
init();
char buf[1 + 8 * sizeof(unsigned int)];
utoa(value, buf, base);
*this = buf;
}
String::String(long value, unsigned char base)
{
init();
char buf[2 + 8 * sizeof(long)];
ltoa(value, buf, base);
*this = buf;
}
String::String(unsigned long value, unsigned char base)
{
init();
char buf[1 + 8 * sizeof(unsigned long)];
ultoa(value, buf, base);
*this = buf;
}
String::String(float value, unsigned char decimalPlaces)
{
static size_t const FLOAT_BUF_SIZE = FLT_MAX_10_EXP + FLT_MAX_DECIMAL_PLACES +
1 /* '-' */ + 1 /* '.' */ + 1 /* '\0' */;
init();
char buf[FLOAT_BUF_SIZE];
decimalPlaces =
min(decimalPlaces, static_cast<unsigned char>(FLT_MAX_DECIMAL_PLACES));
*this = dtostrf(value, static_cast<signed char>(decimalPlaces + 2),
decimalPlaces, buf);
}
String::String(double value, unsigned char decimalPlaces)
{
static size_t const DOUBLE_BUF_SIZE = DBL_MAX_10_EXP + DBL_MAX_DECIMAL_PLACES +
1 /* '-' */ + 1 /* '.' */ + 1 /* '\0' */;
init();
char buf[DOUBLE_BUF_SIZE];
decimalPlaces =
min(decimalPlaces, static_cast<unsigned char>(DBL_MAX_DECIMAL_PLACES));
// Check if the value is a whole number (no fractional part)
if (value == floor(value) && value >= INT_MIN && value <= INT_MAX)
{
// Format as integer (no decimal places)
*this = dtostrf(value, 1, 0, buf);
}
else
{
// Format with specified decimal places
*this = dtostrf(value, static_cast<signed char>(decimalPlaces + 2),
decimalPlaces, buf);
}
}
String::~String()
{
if (buffer)
free(buffer);
}
/*********************************************/
/* Memory Management */
/*********************************************/
inline void String::init(void)
{
buffer = NULL;
capacity = 0;
len = 0;
}
void String::invalidate(void)
{
if (buffer)
free(buffer);
buffer = NULL;
capacity = len = 0;
}
bool String::reserve(unsigned int size)
{
if (buffer && capacity >= size)
return 1;
if (changeBuffer(size))
{
if (len == 0)
buffer[0] = 0;
return true;
}
return false;
}
bool String::changeBuffer(unsigned int maxStrLen)
{
char* newbuffer = (char*)realloc(buffer, maxStrLen + 1);
if (newbuffer)
{
buffer = newbuffer;
capacity = maxStrLen;
return true;
}
return false;
}
/*********************************************/
/* Copy and Move */
/*********************************************/
String& String::copy(const char* cstr, unsigned int length)
{
if (!reserve(length))
{
invalidate();
return *this;
}
len = length;
memcpy(buffer, cstr, length);
buffer[len] = '\0';
return *this;
}
//
// String & String::copy(const __FlashStringHelper *pstr, unsigned int length)
//{
// if (!reserve(length)) {
// invalidate();
// return *this;
// }
// len = length;
// strcpy_P(buffer, (PGM_P)pstr);
// return *this;
//}
void String::move(String& rhs)
{
if (this != &rhs)
{
free(buffer);
buffer = rhs.buffer;
len = rhs.len;
capacity = rhs.capacity;
rhs.buffer = NULL;
rhs.len = 0;
rhs.capacity = 0;
}
}
String& String::operator=(const String& rhs)
{
if (this == &rhs)
return *this;
if (rhs.buffer)
copy(rhs.buffer, rhs.len);
else
invalidate();
return *this;
}
String& String::operator=(String&& rval)
{
move(rval);
return *this;
}
String& String::operator=(const char* cstr)
{
if (cstr)
copy(cstr, static_cast<unsigned int>(strlen(cstr)));
else
invalidate();
return *this;
}
//
// String & String::operator = (const __FlashStringHelper *pstr)
//{
// if (pstr) copy(pstr, strlen_P((PGM_P)pstr));
// else invalidate();
//
// return *this;
//}
/*********************************************/
/* concat */
/*********************************************/
bool String::concat(const String& s) { return concat(s.buffer, s.len); }
bool String::concat(const char* cstr, unsigned int length)
{
unsigned int newlen = len + length;
if (!cstr)
return false;
if (length == 0)
return true;
if (!reserve(newlen))
return false;
memcpy(buffer + len, cstr, length);
len = newlen;
buffer[len] = '\0';
return true;
}
bool String::concat(const char* cstr)
{
if (!cstr)
return false;
return concat(cstr, static_cast<unsigned int>(strlen(cstr)));
}
bool String::concat(char c) { return concat(&c, 1); }
bool String::concat(unsigned char num)
{
char buf[1 + 3 * sizeof(unsigned char)];
itoa(num, buf, 10);
return concat(buf);
}
bool String::concat(int num)
{
char buf[2 + 3 * sizeof(int)];
itoa(num, buf, 10);
return concat(buf);
}
bool String::concat(unsigned int num)
{
char buf[1 + 3 * sizeof(unsigned int)];
utoa(num, buf, 10);
return concat(buf);
}
bool String::concat(long num)
{
char buf[2 + 3 * sizeof(long)];
ltoa(num, buf, 10);
return concat(buf);
}
bool String::concat(unsigned long num)
{
char buf[1 + 3 * sizeof(unsigned long)];
ultoa(num, buf, 10);
return concat(buf);
}
bool String::concat(float num)
{
char buf[20];
char* string = dtostrf(num, 4, 2, buf);
return concat(string);
}
bool String::concat(double num)
{
char buf[20];
char* string = dtostrf(num, 4, 2, buf);
return concat(string);
}
//
// bool String::concat(const __FlashStringHelper * str)
//{
// if (!str) return false;
// int length = strlen_P((const char *) str);
// if (length == 0) return true;
// unsigned int newlen = len + length;
// if (!reserve(newlen)) return false;
// strcpy_P(buffer + len, (const char *) str);
// len = newlen;
// return true;
//}
/*********************************************/
/* Concatenate */
/*********************************************/
StringSumHelper& operator+(const StringSumHelper& lhs, const String& rhs)
{
StringSumHelper& a = const_cast<StringSumHelper&>(lhs);
if (!a.concat(rhs.buffer, rhs.len))
a.invalidate();
return a;
}
StringSumHelper& operator+(const StringSumHelper& lhs, const char* cstr)
{
StringSumHelper& a = const_cast<StringSumHelper&>(lhs);
if (!cstr || !a.concat(cstr))
a.invalidate();
return a;
}
StringSumHelper& operator+(const StringSumHelper& lhs, char c)
{
StringSumHelper& a = const_cast<StringSumHelper&>(lhs);
if (!a.concat(c))
a.invalidate();
return a;
}
StringSumHelper& operator+(const StringSumHelper& lhs, unsigned char num)
{
StringSumHelper& a = const_cast<StringSumHelper&>(lhs);
if (!a.concat(num))
a.invalidate();
return a;
}
StringSumHelper& operator+(const StringSumHelper& lhs, int num)
{
StringSumHelper& a = const_cast<StringSumHelper&>(lhs);
if (!a.concat(num))
a.invalidate();
return a;
}
StringSumHelper& operator+(const StringSumHelper& lhs, unsigned int num)
{
StringSumHelper& a = const_cast<StringSumHelper&>(lhs);
if (!a.concat(num))
a.invalidate();
return a;
}
StringSumHelper& operator+(const StringSumHelper& lhs, long num)
{
StringSumHelper& a = const_cast<StringSumHelper&>(lhs);
if (!a.concat(num))
a.invalidate();
return a;
}
StringSumHelper& operator+(const StringSumHelper& lhs, unsigned long num)
{
StringSumHelper& a = const_cast<StringSumHelper&>(lhs);
if (!a.concat(num))
a.invalidate();
return a;
}
StringSumHelper& operator+(const StringSumHelper& lhs, float num)
{
StringSumHelper& a = const_cast<StringSumHelper&>(lhs);
if (!a.concat(num))
a.invalidate();
return a;
}
StringSumHelper& operator+(const StringSumHelper& lhs, double num)
{
StringSumHelper& a = const_cast<StringSumHelper&>(lhs);
if (!a.concat(num))
a.invalidate();
return a;
}
//
// StringSumHelper & operator + (const StringSumHelper &lhs, const
// __FlashStringHelper *rhs)
//{
// StringSumHelper &a = const_cast<StringSumHelper&>(lhs);
// if (!a.concat(rhs)) a.invalidate();
// return a;
//}
/*********************************************/
/* Comparison */
/*********************************************/
int String::compareTo(const String& s) const
{
if (!buffer || !s.buffer)
{
if (s.buffer && s.len > 0)
return 0 - *(unsigned char*)s.buffer;
if (buffer && len > 0)
return *(unsigned char*)buffer;
return 0;
}
return strcmp(buffer, s.buffer);
}
int String::compareTo(const char* cstr) const
{
if (!buffer || !cstr)
{
if (cstr && *cstr)
return 0 - *(unsigned char*)cstr;
if (buffer && len > 0)
return *(unsigned char*)buffer;
return 0;
}
return strcmp(buffer, cstr);
}
bool String::equals(const String& s2) const
{
return (len == s2.len && compareTo(s2) == 0);
}
bool String::equals(const char* cstr) const
{
if (len == 0)
return (cstr == NULL || *cstr == 0);
if (cstr == NULL)
return buffer[0] == 0;
return strcmp(buffer, cstr) == 0;
}
bool String::equalsIgnoreCase(const String& s2) const
{
if (this == &s2)
return true;
if (len != s2.len)
return false;
if (len == 0)
return true;
const char* p1 = buffer;
const char* p2 = s2.buffer;
while (*p1)
{
if (tolower(*p1++) != tolower(*p2++))
return false;
}
return true;
}
bool String::startsWith(const String& s2) const
{
if (len < s2.len)
return false;
return startsWith(s2, 0);
}
bool String::startsWith(const String& s2, unsigned int offset) const
{
if (offset > len - s2.len || !buffer || !s2.buffer)
return false;
return strncmp(&buffer[offset], s2.buffer, s2.len) == 0;
}
bool String::endsWith(const String& s2) const
{
if (len < s2.len || !buffer || !s2.buffer)
return false;
return strcmp(&buffer[len - s2.len], s2.buffer) == 0;
}
/*********************************************/
/* Character Access */
/*********************************************/
char String::charAt(unsigned int loc) const { return operator[](loc); }
void String::setCharAt(unsigned int loc, char c)
{
if (loc < len)
buffer[loc] = c;
}
char& String::operator[](unsigned int index)
{
static char dummy_writable_char;
if (index >= len || !buffer)
{
dummy_writable_char = 0;
return dummy_writable_char;
}
return buffer[index];
}
char String::operator[](unsigned int index) const
{
if (index >= len || !buffer)
return 0;
return buffer[index];
}
void String::getBytes(unsigned char* buf, unsigned int bufsize,
unsigned int index) const
{
if (!bufsize || !buf)
return;
if (index >= len)
{
buf[0] = 0;
return;
}
unsigned int n = bufsize - 1;
if (n > len - index)
n = len - index;
strncpy((char*)buf, buffer + index, n);
buf[n] = 0;
}
/*********************************************/
/* Search */
/*********************************************/
int String::indexOf(char c) const { return indexOf(c, 0); }
int String::indexOf(char ch, unsigned int fromIndex) const
{
if (fromIndex >= len)
return -1;
const char* temp = strchr(buffer + fromIndex, ch);
if (temp == NULL)
return -1;
return static_cast<int>(temp - buffer);
}
int String::indexOf(const String& s2) const { return indexOf(s2, 0); }
int String::indexOf(const String& s2, unsigned int fromIndex) const
{
if (fromIndex >= len)
return -1;
const char* found = strstr(buffer + fromIndex, s2.buffer);
if (found == NULL)
return -1;
return static_cast<int>(found - buffer);
}
int String::lastIndexOf(char theChar) const { return lastIndexOf(theChar, len - 1); }
int String::lastIndexOf(char ch, unsigned int fromIndex) const
{
if (fromIndex >= len)
return -1;
char tempchar = buffer[fromIndex + 1];
buffer[fromIndex + 1] = '\0';
char* temp = strrchr(buffer, ch);
buffer[fromIndex + 1] = tempchar;
if (temp == NULL)
return -1;
return static_cast<int>(temp - buffer);
}
int String::lastIndexOf(const String& s2) const
{
return lastIndexOf(s2, len - s2.len);
}
int String::lastIndexOf(const String& s2, unsigned int fromIndex) const
{
if (s2.len == 0 || len == 0 || s2.len > len)
return -1;
if (fromIndex >= len)
fromIndex = len - 1;
int found = -1;
for (char* p = buffer; p <= buffer + fromIndex; p++)
{
p = strstr(p, s2.buffer);
if (!p)
break;
if ((unsigned int)(p - buffer) <= fromIndex)
found = static_cast<int>(p - buffer);
}
return found;
}
String String::substring(unsigned int left, unsigned int right) const
{
if (left > right)
{
unsigned int temp = right;
right = left;
left = temp;
}
String out;
if (left >= len)
return out;
if (right > len)
right = len;
out.copy(buffer + left, right - left);
return out;
}
/*********************************************/
/* Modification */
/*********************************************/
void String::replace(char find, char replace)
{
if (!buffer)
return;
for (char* p = buffer; *p; p++)
{
if (*p == find)
*p = replace;
}
}
void String::replace(const String& find, const String& replace)
{
if (len == 0 || find.len == 0)
return;
int diff = replace.len - find.len;
char* readFrom = buffer;
char* foundAt;
if (diff == 0)
{
while ((foundAt = strstr(readFrom, find.buffer)) != NULL)
{
memcpy(foundAt, replace.buffer, replace.len);
readFrom = foundAt + replace.len;
}
}
else if (diff < 0)
{
unsigned int size = len; // compute size needed for result
diff = 0 - diff;
while ((foundAt = strstr(readFrom, find.buffer)) != NULL)
{
readFrom = foundAt + find.len;
size -= diff;
}
if (size == len)
return;
int index = len - 1;
while (index >= 0 && (index = lastIndexOf(find, index)) >= 0)
{
readFrom = buffer + index + find.len;
memmove(readFrom - diff, readFrom, len - (readFrom - buffer));
len -= diff;
buffer[len] = 0;
memcpy(buffer + index, replace.buffer, replace.len);
index--;
}
}
else
{
unsigned int size = len; // compute size needed for result
while ((foundAt = strstr(readFrom, find.buffer)) != NULL)
{
readFrom = foundAt + find.len;
size += diff;
}
if (size == len)
return;
if (size > capacity && !changeBuffer(size))
return; // XXX: tell user!
int index = len - 1;
while (index >= 0 && (index = lastIndexOf(find, index)) >= 0)
{
readFrom = buffer + index + find.len;
memmove(readFrom + diff, readFrom, len - (readFrom - buffer));
len += diff;
buffer[len] = 0;
memcpy(buffer + index, replace.buffer, replace.len);
index--;
}
}
}
void String::remove(unsigned int index)
{
// Pass the biggest integer as the count. The remove method
// below will take care of truncating it at the end of the
// string.
remove(index, (unsigned int)-1);
}
void String::remove(unsigned int index, unsigned int count)
{
if (index >= len)
{
return;
}
if (count <= 0)
{
return;
}
if (count > len - index)
{
count = len - index;
}
char* writeTo = buffer + index;
len = len - count;
memmove(writeTo, buffer + index + count, len - index);
buffer[len] = 0;
}
void String::toLowerCase(void)
{
if (!buffer)
return;
for (char* p = buffer; *p; p++)
{
*p = static_cast<char>(tolower(*p));
}
}
void String::toUpperCase(void)
{
if (!buffer)
return;
for (char* p = buffer; *p; p++)
{
*p = static_cast<char>(toupper(*p));
}
}
void String::trim(void)
{
if (!buffer || len == 0)
return;
char* begin = buffer;
while (isspace(*begin))
begin++;
char* end = buffer + len - 1;
while (isspace(*end) && end >= begin)
end--;
len = static_cast<unsigned int>(end + 1 - begin);
if (begin > buffer)
memmove(buffer, begin, len);
buffer[len] = 0;
}
/*********************************************/
/* Parsing / Conversion */
/*********************************************/
long String::toInt(void) const
{
if (buffer)
return atol(buffer);
return 0;
}
float String::toFloat(void) const { return float(toDouble()); }
double String::toDouble(void) const
{
if (buffer)
return atof(buffer);
return 0;
}
} // namespace arduino
#endif // defined(ARDUINO)

460
src/utils/string.h Normal file
View file

@ -0,0 +1,460 @@
#ifndef STRING_H_
#define STRING_H_
/*
String library for Wiring & Arduino
...mostly rewritten by Paul Stoffregen...
Copyright (c) 2009-10 Hernando Barragan. All right reserved.
Copyright 2011, Paul Stoffregen, paul@pjrc.com
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
*/
#if defined(ARDUINO)
#include <Arduino.h>
#elif defined(USE_STD_STR)
#include <climits>
#include <cmath>
#include <string>
class Extended_STD_String : public std::string
{
public:
// Inherit all constructors from std::string
using std::string::string;
// Default constructor
Extended_STD_String() : std::string() {}
// Constructor from std::string
Extended_STD_String(const std::string& str) : std::string(str) {}
Extended_STD_String(std::string&& str) : std::string(std::move(str)) {}
// Arduino String compatibility constructors
Extended_STD_String(char c) : std::string(1, c) {}
Extended_STD_String(int value) : std::string(std::to_string(value)) {}
Extended_STD_String(long value) : std::string(std::to_string(value)) {}
Extended_STD_String(unsigned int value) : std::string(std::to_string(value)) {}
Extended_STD_String(unsigned long value) : std::string(std::to_string(value)) {}
Extended_STD_String(float value) : std::string(std::to_string(value)) {}
Extended_STD_String(double value) : std::string()
{
// Check if the value is a whole number (no fractional part)
if (value == std::floor(value) && value >= INT_MIN && value <= INT_MAX)
{
// Format as integer (no decimal places)
*this = std::to_string(static_cast<int>(value));
}
else
{
// Format with default precision
*this = std::to_string(value);
}
}
// Assignment operators to handle std::string results
Extended_STD_String& operator=(const std::string& str)
{
std::string::assign(str);
return *this;
}
// Concatenation operators that return Extended_STD_String
Extended_STD_String operator+(const Extended_STD_String& rhs) const
{
return Extended_STD_String(std::string(*this) + std::string(rhs));
}
Extended_STD_String operator+(const std::string& rhs) const
{
return Extended_STD_String(std::string(*this) + rhs);
}
Extended_STD_String operator+(const char* rhs) const
{
return Extended_STD_String(std::string(*this) + rhs);
}
// Arduino String compatibility methods
int indexOf(char ch) const
{
size_t pos = find(ch);
return (pos == std::string::npos) ? -1 : static_cast<int>(pos);
}
int indexOf(const std::string& str) const
{
size_t pos = find(str);
return (pos == std::string::npos) ? -1 : static_cast<int>(pos);
}
int indexOf(const char* str) const
{
size_t pos = find(str);
return (pos == std::string::npos) ? -1 : static_cast<int>(pos);
}
// Define a method named substring
std::string substring(size_t pos, size_t len) const { return substr(pos, len); }
std::string substring(size_t len) const { return substr(len); }
};
// Global operators for const char* + Extended_STD_String
inline Extended_STD_String operator+(const char* lhs, const Extended_STD_String& rhs)
{
return Extended_STD_String(std::string(lhs) + std::string(rhs));
}
inline Extended_STD_String operator+(const std::string& lhs,
const Extended_STD_String& rhs)
{
return Extended_STD_String(lhs + std::string(rhs));
}
using String = Extended_STD_String;
#elif defined(USE_OWN_ARDUINO_STR)
/*
String library for Wiring & Arduino
...mostly rewritten by Paul Stoffregen...
Copyright (c) 2009-10 Hernando Barragan. All right reserved.
Copyright 2011, Paul Stoffregen, paul@pjrc.com
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
*/
#ifdef __cplusplus
#ifndef __ARDUINO_STRINGS__
#define __ARDUINO_STRINGS__
#include <cstdint>
#include <ctype.h>
#include <stdlib.h>
#include <string.h>
// #if defined(__AVR__)
// #include "avr/pgmspace.h"
// #else
// #include "deprecated-avr-comp/avr/pgmspace.h"
// #endif
namespace arduino
{
// When compiling programs with this class, the following gcc parameters
// dramatically increase performance and memory (RAM) efficiency, typically
// with little or no increase in code size.
// -felide-constructors
// -std=c++0x
// class __FlashStringHelper;
// #define F(string_literal) (reinterpret_cast<const __FlashStringHelper
// *>(PSTR(string_literal)))
// An inherited class for holding the result of a concatenation. These
// result objects are assumed to be writable by subsequent concatenations.
class StringSumHelper;
// The string class
class String
{
friend class StringSumHelper;
// use a function pointer to allow for "if (s)" without the
// complications of an operator bool(). for more information, see:
// http://www.artima.com/cppsource/safebool.html
typedef void (String::*StringIfHelperType)() const;
void StringIfHelper() const {}
static size_t const FLT_MAX_DECIMAL_PLACES = 10;
static size_t const DBL_MAX_DECIMAL_PLACES = FLT_MAX_DECIMAL_PLACES;
public:
// constructors
// creates a copy of the initial value.
// if the initial value is null or invalid, or if memory allocation
// fails, the string will be marked as invalid (i.e. "if (s)" will
// be false).
String(const char* cstr = "");
String(const char* cstr, unsigned int length);
String(const uint8_t* cstr, unsigned int length)
: String((const char*)cstr, length)
{
}
String(const String& str);
// String(const __FlashStringHelper *str);
String(String&& rval);
explicit String(char c);
explicit String(unsigned char, unsigned char base = 10);
explicit String(int, unsigned char base = 10);
explicit String(unsigned int, unsigned char base = 10);
explicit String(long, unsigned char base = 10);
explicit String(unsigned long, unsigned char base = 10);
explicit String(float, unsigned char decimalPlaces = 2);
explicit String(double, unsigned char decimalPlaces = 2);
~String(void);
// memory management
// return true on success, false on failure (in which case, the string
// is left unchanged). reserve(0), if successful, will validate an
// invalid string (i.e., "if (s)" will be true afterwards)
bool reserve(unsigned int size);
inline unsigned int length(void) const { return len; }
// creates a copy of the assigned value. if the value is null or
// invalid, or if the memory allocation fails, the string will be
// marked as invalid ("if (s)" will be false).
String& operator=(const String& rhs);
String& operator=(const char* cstr);
// String & operator = (const __FlashStringHelper *str);
String& operator=(String&& rval);
// concatenate (works w/ built-in types)
// returns true on success, false on failure (in which case, the string
// is left unchanged). if the argument is null or invalid, the
// concatenation is considered unsuccessful.
bool concat(const String& str);
bool concat(const char* cstr);
bool concat(const char* cstr, unsigned int length);
bool concat(const uint8_t* cstr, unsigned int length)
{
return concat((const char*)cstr, length);
}
bool concat(char c);
bool concat(unsigned char num);
bool concat(int num);
bool concat(unsigned int num);
bool concat(long num);
bool concat(unsigned long num);
bool concat(float num);
bool concat(double num);
// bool concat(const __FlashStringHelper * str);
// if there's not enough memory for the concatenated value, the string
// will be left unchanged (but this isn't signalled in any way)
String& operator+=(const String& rhs)
{
concat(rhs);
return (*this);
}
String& operator+=(const char* cstr)
{
concat(cstr);
return (*this);
}
String& operator+=(char c)
{
concat(c);
return (*this);
}
String& operator+=(unsigned char num)
{
concat(num);
return (*this);
}
String& operator+=(int num)
{
concat(num);
return (*this);
}
String& operator+=(unsigned int num)
{
concat(num);
return (*this);
}
String& operator+=(long num)
{
concat(num);
return (*this);
}
String& operator+=(unsigned long num)
{
concat(num);
return (*this);
}
String& operator+=(float num)
{
concat(num);
return (*this);
}
String& operator+=(double num)
{
concat(num);
return (*this);
}
// String & operator += (const __FlashStringHelper *str){concat(str); return
//(*this);}
friend StringSumHelper& operator+(const StringSumHelper& lhs, const String& rhs);
friend StringSumHelper& operator+(const StringSumHelper& lhs, const char* cstr);
friend StringSumHelper& operator+(const StringSumHelper& lhs, char c);
friend StringSumHelper& operator+(const StringSumHelper& lhs, unsigned char num);
friend StringSumHelper& operator+(const StringSumHelper& lhs, int num);
friend StringSumHelper& operator+(const StringSumHelper& lhs, unsigned int num);
friend StringSumHelper& operator+(const StringSumHelper& lhs, long num);
friend StringSumHelper& operator+(const StringSumHelper& lhs, unsigned long num);
friend StringSumHelper& operator+(const StringSumHelper& lhs, float num);
friend StringSumHelper& operator+(const StringSumHelper& lhs, double num);
// friend StringSumHelper & operator + (const StringSumHelper &lhs, const
//__FlashStringHelper *rhs);
// comparison (only works w/ Strings and "strings")
operator StringIfHelperType() const
{
return buffer ? &String::StringIfHelper : 0;
}
int compareTo(const String& s) const;
int compareTo(const char* cstr) const;
bool equals(const String& s) const;
bool equals(const char* cstr) const;
friend bool operator==(const String& a, const String& b) { return a.equals(b); }
friend bool operator==(const String& a, const char* b) { return a.equals(b); }
friend bool operator==(const char* a, const String& b) { return b == a; }
friend bool operator<(const String& a, const String& b)
{
return a.compareTo(b) < 0;
}
friend bool operator<(const String& a, const char* b)
{
return a.compareTo(b) < 0;
}
friend bool operator<(const char* a, const String& b)
{
return b.compareTo(a) > 0;
}
friend bool operator!=(const String& a, const String& b) { return !(a == b); }
friend bool operator!=(const String& a, const char* b) { return !(a == b); }
friend bool operator!=(const char* a, const String& b) { return !(a == b); }
friend bool operator>(const String& a, const String& b) { return b < a; }
friend bool operator>(const String& a, const char* b) { return b < a; }
friend bool operator>(const char* a, const String& b) { return b < a; }
friend bool operator<=(const String& a, const String& b) { return !(b < a); }
friend bool operator<=(const String& a, const char* b) { return !(b < a); }
friend bool operator<=(const char* a, const String& b) { return !(b < a); }
friend bool operator>=(const String& a, const String& b) { return !(a < b); }
friend bool operator>=(const String& a, const char* b) { return !(a < b); }
friend bool operator>=(const char* a, const String& b) { return !(a < b); }
bool equalsIgnoreCase(const String& s) const;
bool startsWith(const String& prefix) const;
bool startsWith(const String& prefix, unsigned int offset) const;
bool endsWith(const String& suffix) const;
// character access
char charAt(unsigned int index) const;
void setCharAt(unsigned int index, char c);
char operator[](unsigned int index) const;
char& operator[](unsigned int index);
void getBytes(unsigned char* buf, unsigned int bufsize,
unsigned int index = 0) const;
void toCharArray(char* buf, unsigned int bufsize, unsigned int index = 0) const
{
getBytes((unsigned char*)buf, bufsize, index);
}
const char* c_str() const { return buffer; }
char* begin() { return buffer; }
char* end() { return buffer + length(); }
const char* begin() const { return c_str(); }
const char* end() const { return c_str() + length(); }
// search
int indexOf(char ch) const;
int indexOf(char ch, unsigned int fromIndex) const;
int indexOf(const String& str) const;
int indexOf(const String& str, unsigned int fromIndex) const;
int lastIndexOf(char ch) const;
int lastIndexOf(char ch, unsigned int fromIndex) const;
int lastIndexOf(const String& str) const;
int lastIndexOf(const String& str, unsigned int fromIndex) const;
String substring(unsigned int beginIndex) const
{
return substring(beginIndex, len);
};
String substring(unsigned int beginIndex, unsigned int endIndex) const;
// modification
void replace(char find, char replace);
void replace(const String& find, const String& replace);
void remove(unsigned int index);
void remove(unsigned int index, unsigned int count);
void toLowerCase(void);
void toUpperCase(void);
void trim(void);
// parsing/conversion
long toInt(void) const;
float toFloat(void) const;
double toDouble(void) const;
protected:
char* buffer; // the actual char array
unsigned int capacity; // the array length minus one (for the '\0')
unsigned int len; // the String length (not counting the '\0')
protected:
void init(void);
void invalidate(void);
bool changeBuffer(unsigned int maxStrLen);
// copy and move
String& copy(const char* cstr, unsigned int length);
// String & copy(const __FlashStringHelper *pstr, unsigned int length);
void move(String& rhs);
};
class StringSumHelper : public String
{
public:
StringSumHelper(const String& s) : String(s) {}
StringSumHelper(const char* p) : String(p) {}
StringSumHelper(char c) : String(c) {}
StringSumHelper(unsigned char num) : String(num) {}
StringSumHelper(int num) : String(num) {}
StringSumHelper(unsigned int num) : String(num) {}
StringSumHelper(long num) : String(num) {}
StringSumHelper(unsigned long num) : String(num) {}
StringSumHelper(float num) : String(num) {}
StringSumHelper(double num) : String(num) {}
};
} // namespace arduino
// using arduino::__FlashStringHelper;
using arduino::String;
#endif // __cplusplus
#endif // __ARDUINO_STRINGS__
#endif // defined(ARDUINO) etc
#endif // STRING_H_

59
src/utils/time.h Normal file
View file

@ -0,0 +1,59 @@
#ifndef TIME_H_
#define TIME_H_
// Platform-independent system time utility.
// Returns seconds since boot as a double.
#if defined(__EMSCRIPTEN__)
#include <emscripten.h>
inline double get_system_time_seconds()
{
return emscripten_get_now() / 1000.0;
}
#elif defined(USE_STD_IO)
#include <chrono>
// Test override: when non-null, get_system_time_seconds() returns *this
// instead of the real clock. Set from test harnesses for deterministic time.
inline double* g_test_time_ptr = nullptr;
inline double get_system_time_seconds()
{
if (g_test_time_ptr) return *g_test_time_ptr;
static const auto start = std::chrono::steady_clock::now();
auto now = std::chrono::steady_clock::now();
std::chrono::duration<double> elapsed = now - start;
return elapsed.count();
}
#else // Arduino / RP2040
#include <Arduino.h>
#if defined(ARDUINO_ARCH_RP2040)
#include <hardware/timer.h>
inline double get_system_time_seconds()
{
// micros() on the Philhower core returns uint32_t and wraps to 0 after
// ~71.6 minutes, snapping engine time back to boot mid-performance (A3).
// The RP2040 hardware timer is 64-bit; use it directly.
return (double)time_us_64() / 1e6;
}
#else
inline double get_system_time_seconds()
{
return micros() / 1e6;
}
#endif
#endif
#endif // TIME_H_

21260
test/catch.hpp Normal file

File diff suppressed because it is too large Load diff

57
test/meson.build Normal file
View file

@ -0,0 +1,57 @@
# Test configuration for the standalone ModuLisp engine.
# Fresh file: mirrors the signal-engine section of src-useq/test/meson.build
# (same args, pch, timeouts); firmware/wasm/nodedef targets are not carried
# over because those modules are not part of this repository.
# Define precompiled header path (relative to test directory)
test_pch_file = '../src/pch.h'
# Test compiler arguments (mirroring src-useq test_args)
test_args = [
'-DUSE_OWN_ARDUINO_STR',
'-DUSE_STD_IO',
'-DNO_ETL',
'-D__not_in_flash(section)=',
'-D__not_in_flash_func(x)=',
'-DENABLE_SIGNAL_ENGINE',
'-std=c++17'
]
# Include directories: repo root so tests can `#include "src/..."`.
test_inc = include_directories('..')
# Math library dependency
math_dep = declare_dependency(link_args : ['-lm'])
test_env = [
[ 'signal_engine/test_signal_engine.cpp', 'test_signal_engine', 'signal_engine_test', 0 ],
[ 'signal_engine/test_signal_engine_golden.cpp', 'test_signal_engine_golden', 'signal_engine_golden_test', 0 ],
[ 'signal_engine/test_signal_engine_phase4.cpp', 'test_signal_engine_phase4', 'signal_engine_phase4_test', 0 ],
[ 'signal_engine/test_live_edit.cpp', 'test_live_edit', 'live_edit_test', 0 ],
[ 'signal_engine/test_signal_engine_robustness.cpp', 'test_signal_engine_robustness', 'signal_engine_robustness_test', 120 ],
[ 'signal_engine/test_output_classification.cpp', 'test_output_classification', 'output_classification_test', 0 ],
[ 'signal_engine/test_ugens.cpp', 'test_ugens', 'ugen_test', 0 ],
[ 'signal_engine/test_state_identity.cpp', 'test_state_identity', 'state_identity_test', 0 ],
[ 'signal_engine/test_resource_reclaim.cpp', 'test_resource_reclaim', 'resource_reclaim_test', 0 ],
[ 'signal_engine/test_node_pool_traversal.cpp', 'test_node_pool_traversal', 'node_pool_traversal_test', 0 ],
[ 'signal_engine/test_compiler_p1.cpp', 'test_compiler_p1', 'compiler_p1_test', 0 ],
[ 'signal_engine/test_audit_fixes.cpp', 'test_audit_fixes', 'audit_fixes_test', 60 ],
[ 'signal_engine/test_builtin_conformance.cpp', 'test_builtin_conformance', 'builtin_conformance_test', 120 ],
[ 'signal_engine/test_failure_mode.cpp', 'test_failure_mode', 'failure_mode_test', 60 ],
[ 'signal_engine/test_health_diagnostics.cpp', 'test_health_diagnostics', 'health_diagnostics_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 ],
]
foreach t : test_env
exe = executable(
t[1],
sources : [t[0]],
include_directories : test_inc,
cpp_args : test_args,
cpp_pch : test_pch_file,
dependencies : [signal_engine_dep, math_dep],
)
test(t[2], exe, timeout : t[3])
endforeach

View file

@ -0,0 +1,824 @@
// Regression tests for the 2026-07 v1.2.0 audit fixes (A-series findings).
// One TEST_CASE (or section group) per landed finding; see commit messages
// for the finding numbers.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include "src/signal_engine/compiler_pipeline.h"
#include "src/modulisp/lisp/symbol_intern.h"
#include <cstdio>
#include <cstring>
#include <string>
using namespace sig;
namespace {
struct Harness {
SignalEngine engine;
Harness() { engine.init_defaults(120.0, 4); }
EvalResult eval(const std::string& code) {
return eval_cold(code.c_str(), (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);
}
// Execute one sample at time t and return the named output's value.
double sample(int output_index, double t = 0.0, double dt = 0.001) {
double cell_values[MAX_CELLS];
engine.cells.snapshot_values(cell_values, MAX_CELLS);
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
ExecutionContext ctx;
ctx.t = t;
ctx.dt = dt;
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);
return outputs[output_index];
}
double tick(int output_index, double t, double dt) {
double cell_values[MAX_CELLS];
engine.cells.snapshot_values(cell_values, MAX_CELLS);
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
ExecutionContext ctx{t, dt, cell_values, hw_inputs,
engine.cells.data_pool,
engine.cells.data_offsets,
engine.cells.data_lengths,
engine.pool.prev_output_values,
outputs, workspace};
execute_all_outputs(engine.pool, ctx);
commit_state(engine.pool, workspace);
commit_outputs(engine.pool, outputs);
return outputs[output_index];
}
};
} // namespace
// ── A2: compile failure must not demote the active program (§2.6) ──────────
// failure-model.md §2.6: "Compile-time errors do not consume LKG. A program
// that fails to compile is not promoted, demoted, or substituted; the active
// program is unchanged." The old demotion of outputs[i].valid on compile
// failure also flapped against commit_outputs (which resurrects valid=true),
// corrupting the WASM batch-vis row packing.
TEST_CASE("A2: failed output compile leaves the active program valid",
"[audit][a2]") {
Harness h;
h.eval_ok("(a1 0.75)");
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.sample(0) == Approx(0.75));
// A compile failure on the same output...
EvalResult r = h.eval("(a1 (no-such-fn 1 2))");
REQUIRE(r.kind == EvalResult::Error);
// ...leaves the previous program installed, valid, and running.
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.engine.pool.outputs[0].root_node != NODE_NONE);
REQUIRE(h.sample(0) == Approx(0.75));
}
TEST_CASE("A2b: rejected graph build restores state, source, and capacity",
"[audit][transaction]") {
Harness h;
h.eval_ok("(define tx-dep 1)");
h.eval_ok("(a1 (+ tx-dep (phasor 1 :id \"tx-phase\")))");
h.tick(0, 0.0, 0.0);
h.tick(0, 0.25, 0.25);
REQUIRE(h.sample(0, 0.25) == Approx(1.25));
uint16_t slots_before = h.engine.pool.state_slot_count;
uint16_t registry_before = h.engine.registry.entry_count;
uint16_t update_before = h.engine.pool.state_update_roots[0];
uint16_t live_before = h.engine.pool.live_slot_count;
uint8_t tables_before = h.engine.cells.data_table_count;
EvalResult rejected = h.eval(
"(a1 (+ tx-dep (phasor 2 :id \"tx-phase\") "
"(live-edit 0.5 :id \"rejected-slot\" :min 0 :max 1) "
"[9 8 7] no-such-name))");
REQUIRE(rejected.kind == EvalResult::Error);
REQUIRE(h.engine.pool.state_slot_count == slots_before);
REQUIRE(h.engine.registry.entry_count == registry_before);
REQUIRE(h.engine.pool.state_update_roots[0] == update_before);
REQUIRE(h.engine.pool.live_slot_count == live_before);
REQUIRE(h.engine.cells.data_table_count == tables_before);
// The rejected 2-Hz update did not steal the live 1-Hz state resource.
h.tick(0, 0.5, 0.25);
REQUIRE(h.sample(0, 0.5) == Approx(1.5));
// The failed source was not published: dependency recompilation uses the
// old valid expression and remains healthy.
h.eval_ok("(define tx-dep 2)");
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.sample(0, 0.5) == Approx(2.5));
}
TEST_CASE("A2c: repeated rejected stateful builds do not exhaust state",
"[audit][transaction][capacity]") {
Harness h;
for (int i = 0; i < (int)MAX_STATE_SLOTS + 4; ++i) {
char code[160];
snprintf(code, sizeof(code),
"(a1 (+ (integrate 1 :id \"rejected-%d\") missing-%d))",
i, i);
EvalResult r = h.eval(code);
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(h.engine.pool.state_slot_count == 0);
REQUIRE(h.engine.registry.entry_count == 0);
}
h.eval_ok("(a1 (integrate 1 :id \"healthy-after-rejects\"))");
REQUIRE(h.engine.pool.state_slot_count == 1);
}
TEST_CASE("A2d: side-effecting forms validate arity before publication",
"[audit][transaction][arity]") {
Harness h;
auto& si = SymbolIntern::getInstance();
EvalResult out = h.eval("(a1 0.25 9)");
REQUIRE(out.kind == EvalResult::Error);
REQUIRE(h.engine.pool.outputs[0].root_node == NODE_NONE);
EvalResult def = h.eval("(define arity-y 7 8)");
REQUIRE(def.kind == EvalResult::Error);
SymbolID y = si.intern(String("arity-y"));
REQUIRE(h.engine.cells.cells[y].kind == CellKind::Empty);
EvalResult nested = h.eval("(do (a1 no-such-hidden) (define hidden-y 5))");
REQUIRE(nested.kind == EvalResult::Error);
SymbolID hidden = si.intern(String("hidden-y"));
REQUIRE(h.engine.cells.cells[hidden].kind == CellKind::Empty);
EvalResult defs = h.eval("(defs [defs-first 1 defs-second])");
REQUIRE(defs.kind == EvalResult::Error);
REQUIRE(h.engine.cells.cells[si.intern(String("defs-first"))].kind ==
CellKind::Empty);
REQUIRE(h.engine.cells.cells[si.intern(String("defs-second"))].kind ==
CellKind::Empty);
EvalResult defs_extra = h.eval("(defs [defs-extra 1] 9)");
REQUIRE(defs_extra.kind == EvalResult::Error);
REQUIRE(h.engine.cells.cells[si.intern(String("defs-extra"))].kind ==
CellKind::Empty);
std::string too_many_params = "(defn wide-fn [";
for (uint16_t i = 0; i <= MAX_CALLABLE_PARAMS; i++) {
too_many_params += " p" + std::to_string(i);
}
too_many_params += "] 1)";
EvalResult wide = h.eval(too_many_params);
REQUIRE(wide.kind == EvalResult::Error);
REQUIRE(h.engine.cells.cells[si.intern(String("wide-fn"))].kind ==
CellKind::Empty);
}
TEST_CASE("A2e: bare and compound named-state observations agree",
"[audit][state][observation]") {
Harness h;
h.eval_ok("(defstate observed-c 0 (+ observed-c 1))");
h.eval_ok("(a1 observed-c)");
h.tick(0, 0.0, 1.0);
h.tick(0, 1.0, 1.0);
h.tick(0, 2.0, 1.0);
EvalResult bare = h.eval("observed-c");
EvalResult compound = h.eval("(+ observed-c 0)");
REQUIRE(bare.kind == EvalResult::Number);
REQUIRE(compound.kind == EvalResult::Number);
REQUIRE(bare.number == Approx(compound.number));
REQUIRE(bare.number == Approx(3.0));
}
TEST_CASE("A2f: tokenizer rejects typed delimiter mismatch and long symbols",
"[audit][parser]") {
Harness h;
EvalResult mismatch = h.eval("(define typed-x [1 2))");
REQUIRE(mismatch.kind == EvalResult::Error);
std::string long_name(256, 'x');
EvalResult overlong = h.eval("(define " + long_name + " 1)");
REQUIRE(overlong.kind == EvalResult::Error);
REQUIRE(overlong.diagnostic_count >= 1);
REQUIRE(overlong.diagnostics[0].category == DiagnosticCategory::Syntax);
}
TEST_CASE("A2g: rejected reactive recompile preserves the active graph",
"[audit][transaction][reactive]") {
Harness h;
h.eval_ok("(define reactive-dep 1)");
h.eval_ok("(a1 (+ (phasor 1 :id \"reactive-phase\") reactive-dep))");
h.tick(0, 0.0, 0.0);
h.tick(0, 0.25, 0.25);
REQUIRE(h.sample(0, 0.25) == Approx(1.25));
uint16_t old_root = h.engine.pool.outputs[0].root_node;
uint16_t old_update = h.engine.pool.state_update_roots[0];
// Publishing the function succeeds, but it makes the stored output
// source ill-typed: a function requiring an argument cannot be read as a
// signal. The rejected candidate first encounters the same state id at
// 2 Hz, so this also proves its state-resource mutation is rolled back.
h.eval_ok("(defn reactive-dep [x] x)");
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.engine.pool.outputs[0].root_node == old_root);
REQUIRE(h.engine.pool.state_update_roots[0] == old_update);
h.tick(0, 0.5, 0.25);
REQUIRE(h.sample(0, 0.5) == Approx(1.5));
// Dependencies were retained with the old graph, so restoring a numeric
// cell causes a healthy recompile and publishes the new value.
h.eval_ok("(define reactive-dep 2)");
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.sample(0, 0.5) == Approx(2.5));
}
// ── A4: useq-clear must fully clear defstate resources (§4.5) ──────────────
TEST_CASE("A4: re-defstate after useq-clear gets a fresh, working slot",
"[audit][a4]") {
Harness h;
auto& si = SymbolIntern::getInstance();
SymbolID c = si.intern(String("a4-counter"));
h.eval_ok("(defstate a4-counter 5 (+ a4-counter 1))");
REQUIRE(h.engine.pool.state_slot_count == 1);
h.eval_ok("(useq-clear)");
REQUIRE(h.engine.pool.state_slot_count == 0);
// Cell marker must be gone — the cell no longer refers to a state slot.
REQUIRE(h.engine.cells.cells[c].flags == 0);
// Sources and update roots cleared.
REQUIRE_FALSE(h.engine.state_sources[0].has_source);
REQUIRE(h.engine.pool.state_update_roots[0] == NODE_NONE);
// Re-defstate: fresh slot, fresh init value (not frozen pre-clear state).
h.eval_ok("(defstate a4-counter 10 (+ a4-counter 2))");
REQUIRE(h.engine.pool.state_slot_count == 1);
uint16_t slot = h.engine.cells.cells[c].data_table_id;
REQUIRE(h.engine.pool.state_values[slot] == Approx(10.0));
REQUIRE(h.engine.cells.cells[c].value == Approx(10.0));
}
TEST_CASE("Clear is fresh-session equivalent for compiler-owned storage",
"[audit][clear][session-reset]") {
Harness h;
auto& si = SymbolIntern::getInstance();
SymbolID scalar = si.intern(String("clear-scalar"));
SymbolID callable = si.intern(String("clear-callable"));
h.eval_ok("(define clear-scalar [1 2 3])");
h.eval_ok("(defn clear-callable [x] (+ x 1))");
h.eval_ok("(defstate clear-state 5 (+ clear-state 1))");
h.eval_ok("(a1 (+ (clear-callable 2)"
" (live-edit 0.25 :id \"clear-knob\" :min 0 :max 1)))");
REQUIRE(h.engine.cells.data_table_count > 0);
REQUIRE(h.engine.arena.write_head > 0);
REQUIRE(h.engine.pool.live_slot_count > 0);
REQUIRE(h.engine.pool.node_count > 0);
REQUIRE(h.engine.pool.state_slot_count > 0);
REQUIRE(h.engine.output_sources[0].has_source);
h.engine.state.time_offset = 5.0;
h.engine.state.transport_offset = -3.0;
h.engine.state.is_playing = false;
h.engine.state.current_time = 12.0;
h.engine.state.current_dt = 0.5;
h.engine.state.current_wall_time = 20.0;
h.engine.state.paused_time = 12.0;
h.engine.state.has_pause_anchor = true;
h.engine.state.reset_dt_on_next_tick = false;
uint32_t generation = h.engine.session_generation;
h.eval_ok("(useq-clear)");
REQUIRE(h.engine.session_generation == generation + 1);
REQUIRE(h.engine.cells.cells[scalar].kind == CellKind::Empty);
REQUIRE(h.engine.cells.cells[callable].kind == CellKind::Empty);
REQUIRE(h.engine.cells.callables[callable].source_length == 0);
REQUIRE(h.engine.cells.data_table_count == 0);
REQUIRE(h.engine.arena.write_head == 0);
REQUIRE(h.engine.pool.node_count == 0);
REQUIRE(h.engine.pool.exec_count == 0);
REQUIRE(h.engine.pool.state_slot_count == 0);
REQUIRE(h.engine.pool.live_slot_count == 0);
REQUIRE(h.engine.pool.external_root_count == 0);
REQUIRE(h.engine.registry.entry_count == 0);
REQUIRE_FALSE(h.engine.output_sources[0].has_source);
REQUIRE_FALSE(h.engine.state_sources[0].has_source);
REQUIRE(h.engine.synth_graph.declaration_count() == 0);
REQUIRE(h.engine.pool.output_class[0] == OutputClass::Inactive);
REQUIRE(h.engine.pool.output_input_mask[0] == 0);
REQUIRE(h.engine.state.time_offset == 0.0);
REQUIRE(h.engine.state.transport_offset == 0.0);
REQUIRE(h.engine.state.is_playing);
REQUIRE(h.engine.state.current_time == 0.0);
REQUIRE(h.engine.state.current_dt == 0.0);
REQUIRE(h.engine.state.current_wall_time == 0.0);
REQUIRE(h.engine.state.paused_time == 0.0);
REQUIRE_FALSE(h.engine.state.has_pause_anchor);
REQUIRE(h.engine.state.reset_dt_on_next_tick);
// The same names and resource IDs can be used immediately as fresh
// definitions; no stale callable source or table reference survives.
h.eval_ok("(define clear-scalar [9 8])");
h.eval_ok("(defn clear-callable [x] (* x 2))");
h.eval_ok("(a1 (clear-callable 4))");
REQUIRE(h.engine.cells.cells[scalar].data_table_id == 0);
REQUIRE(h.sample(0) == Approx(8.0));
}
TEST_CASE("Inactive outputs overwrite reused caller buffers with neutral zero",
"[audit][clear][neutral]") {
Harness h;
h.eval_ok("(a1 0.75)");
REQUIRE(h.sample(0) == Approx(0.75));
h.eval_ok("(useq-clear)");
double cell_values[MAX_CELLS] = {};
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS];
double workspace[MAX_TOTAL_NODES] = {};
for (double& output : outputs) output = 0.75;
ExecutionContext ctx;
ctx.cell_values = cell_values;
ctx.hw_inputs = hw_inputs;
ctx.data_pool = h.engine.cells.data_pool;
ctx.data_offsets = h.engine.cells.data_offsets;
ctx.data_lengths = h.engine.cells.data_lengths;
ctx.prev_outputs = h.engine.pool.prev_output_values;
ctx.output_values = outputs;
ctx.workspace = workspace;
execute_all_outputs(h.engine.pool, ctx);
for (double output : outputs) REQUIRE(output == 0.0);
}
// ── A5: scratch eval must not clobber fresh scratch state with live state ──
// state-identity.md §6.6: scratch evals are isolated. The old code compiled
// the scratch expression (which writes init values into freshly-allocated
// scratch slots) and THEN memcpy'd the live pool's state_values over the
// scratch pool, so a stateful expression evaluated via set/eval read a live
// state value that happened to share the same slot index instead of its own
// init.
TEST_CASE("A5: scratch-evaluated stateful expression keeps its init value",
"[audit][a5]") {
Harness h;
// Occupy live state slot 0 with a conspicuous value.
h.eval_ok("(defstate a5-live 99 (+ a5-live 0))");
REQUIRE(h.engine.pool.state_values[0] == Approx(99.0));
// Scratch-eval a fresh integrator (init 0). It allocates scratch slot 0;
// pre-fix this returned 99 (live slot 0 leaked over the fresh init).
EvalResult r = h.eval("(integrate 0)");
REQUIRE(r.kind == EvalResult::Number);
REQUIRE(r.number == Approx(0.0));
// Reading live named state through a scratch eval still works.
EvalResult r2 = h.eval("(+ a5-live 1)");
REQUIRE(r2.kind == EvalResult::Number);
REQUIRE(r2.number == Approx(100.0));
// set with a stateful RHS: same isolation rule.
h.eval_ok("(set a5-x (integrate 0))");
auto& si = SymbolIntern::getInstance();
SymbolID x = si.intern(String("a5-x"));
REQUIRE(h.engine.cells.cells[x].value == Approx(0.0));
// Live state untouched by the scratch evals.
REQUIRE(h.engine.pool.state_values[0] == Approx(99.0));
}
// ── A6: failed defstate must not corrupt the previous binding ───────────────
TEST_CASE("A6: defstate compile failure rolls back cell and state slot",
"[audit][a6]") {
Harness h;
auto& si = SymbolIntern::getInstance();
// Existing plain-number binding survives a failed defstate of same name.
h.eval_ok("(define a6-x 5)");
SymbolID x = si.intern(String("a6-x"));
uint16_t slots_before = h.engine.pool.state_slot_count;
EvalResult r = h.eval("(defstate a6-x 0 (no-such-fn 1))");
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(h.engine.cells.cells[x].kind == CellKind::Number);
REQUIRE(h.engine.cells.cells[x].flags == 0); // not a state cell
REQUIRE(h.engine.cells.cells[x].value == Approx(5.0)); // old value intact
REQUIRE(h.engine.pool.state_slot_count == slots_before); // slot rolled back
// The binding still evaluates as before.
EvalResult r2 = h.eval("(+ a6-x 1)");
REQUIRE(r2.kind == EvalResult::Number);
REQUIRE(r2.number == Approx(6.0));
// A successful re-defstate of an EXISTING state cell that then fails
// keeps the old update program and value.
h.eval_ok("(defstate a6-c 3 (+ a6-c 1))");
SymbolID c = si.intern(String("a6-c"));
uint16_t slot = h.engine.cells.cells[c].data_table_id;
uint16_t old_root = h.engine.pool.state_update_roots[slot];
EvalResult r3 = h.eval("(defstate a6-c 7 (no-such-fn 1))");
REQUIRE(r3.kind == EvalResult::Error);
REQUIRE(h.engine.cells.cells[c].flags == 0x02);
REQUIRE(h.engine.cells.cells[c].data_table_id == slot);
REQUIRE(h.engine.pool.state_update_roots[slot] == old_root);
REQUIRE(h.engine.pool.state_values[slot] == Approx(3.0));
}
// ── A7: set on a defstate cell writes the state slot ────────────────────────
TEST_CASE("A7: set on a defstate cell updates the live state value",
"[audit][a7]") {
Harness h;
auto& si = SymbolIntern::getInstance();
h.eval_ok("(defstate a7-c 3 (+ a7-c 1))");
SymbolID c = si.intern(String("a7-c"));
uint16_t slot = h.engine.cells.cells[c].data_table_id;
REQUIRE(h.engine.pool.state_values[slot] == Approx(3.0));
// Numeric set writes the state slot, keeps the state marker.
h.eval_ok("(set a7-c 42)");
REQUIRE(h.engine.pool.state_values[slot] == Approx(42.0));
REQUIRE(h.engine.cells.cells[c].flags == 0x02);
REQUIRE(h.engine.cells.cells[c].data_table_id == slot);
// Expression set too.
h.eval_ok("(set a7-c (+ 10 5))");
REQUIRE(h.engine.pool.state_values[slot] == Approx(15.0));
// Reads see the new value.
EvalResult r = h.eval("(+ a7-c 0)");
REQUIRE(r.kind == EvalResult::Number);
REQUIRE(r.number == Approx(15.0));
}
// ── A8: duplicate active :id in one program is a compile error (§5.3/§8.1) ──
TEST_CASE("A8: duplicate active :id rejected; cross-kind :id sharing allowed",
"[audit][a8]") {
Harness h;
// Two oscillators updating the same phase resource in one program —
// ambiguous, must be rejected (state-identity.md §5.3).
EvalResult r = h.eval("(a1 (+ (lfo/saw 1 :id \"pA\") (lfo/saw 2 :id \"pA\")))");
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count >= 1);
REQUIRE(r.diagnostics[0].category == DiagnosticCategory::Boundary);
// Same :id across INCOMPATIBLE primitives resolves to disjoint resources
// (§3.5) — toggle and count must not collide on TriggerMemory either.
h.eval_ok("(a2 (+ (toggle (sqr beat) :id \"x\") (count (sqr beat) :id \"x\")))");
// Recompiling the same program with the same :id stays fine (per-build
// detection only).
h.eval_ok("(a3 (lfo/saw 1 :id \"pB\"))");
h.eval_ok("(a3 (lfo/saw 2 :id \"pB\"))");
}
// ── A9: unknown keywords on stateful primitives error out ──────────────────
TEST_CASE("A9: unknown keywords, :fresh, and non-constant :phase are errors",
"[audit][a9]") {
Harness h;
const char* bad_forms[] = {
"(a1 (phasor 1 :bogus 3))",
"(a1 (lfo 1 :bogus 3))",
"(a1 (integrate 1 :bogus 3))",
"(a1 (toggle (sqr beat) :bogus 3))",
"(a1 (count (sqr beat) :bogus 3))",
"(a1 (slew (lfo/saw 1) 1 :bogus 3))",
"(a1 (live-edit 0.5 :id \"k\" :bogus 3))",
"(a1 (phasor 1 :fresh))",
// Non-constant :phase must error, not be silently ignored.
"(a1 (phasor 1 :phase (lfo/saw 1)))",
"(a1 (lfo 1 :phase (lfo/saw 1)))",
};
for (const char* f : bad_forms) {
INFO("form: " << f);
EvalResult r = h.eval(f);
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count >= 1);
REQUIRE(r.diagnostics[0].category == DiagnosticCategory::Type);
}
// Known keywords still work.
h.eval_ok("(a1 (phasor 1 :phase 0.25 :id \"p\"))");
h.eval_ok("(a2 (lfo 2 :wave :saw :pw 0.3))");
}
// ── A10: no unsound Div a a -> 1 fold ───────────────────────────────────────
// Runtime division defines a/0 = 0 (eval_ops.h), so x/x is 0 at x = 0. The
// old fold rewrote (/ x x) to the constant 1 regardless.
TEST_CASE("A10: (/ x x) evaluates per runtime semantics, not folded to 1",
"[audit][a10]") {
Harness h;
// saw(1) is 0 at t=0 → 0/0 must be 0, not 1.
h.eval_ok("(a1 (/ (lfo/saw 1) (lfo/saw 1)))");
REQUIRE(h.sample(0, 0.0) == Approx(0.0));
// Non-zero point still gives 1.
h.eval_ok("(a2 (/ t t))");
REQUIRE(h.sample(1, 0.5) == Approx(1.0));
}
// ── A12: cell-store revision counter for snapshot skipping ──────────────────
TEST_CASE("A12: store_revision bumps on mutating evals", "[audit][a12]") {
Harness h;
uint32_t r0 = h.engine.cells.store_revision;
REQUIRE(r0 >= 1); // init_timing_defaults counts as a mutation
h.eval_ok("(define a12-x 1)");
uint32_t r1 = h.engine.cells.store_revision;
REQUIRE(r1 > r0);
h.eval_ok("(set a12-x 2)");
REQUIRE(h.engine.cells.store_revision > r1);
}
// ── A14: numeric literals are plain decimal with clean boundaries ──────────
TEST_CASE("A14: tokenizer rejects 0x/inf/nan and trailing-symbol numerics",
"[audit][a14]") {
Harness h;
Token tokens[64];
Diagnostic errs[8];
uint8_t err_count = 0;
auto tokenize_one = [&](const char* src) -> Token {
err_count = 0;
uint16_t n = TokenStream::tokenize(src, (uint32_t)strlen(src),
tokens, 64, errs, &err_count);
REQUIRE(n >= 1);
return tokens[0];
};
// strtod special forms must not become numbers.
for (const char* s : {"inf", "nan", "0x10", "-inf", "INF"}) {
INFO("source: " << s);
Token t = tokenize_one(s);
REQUIRE(t.kind == TokenKind::Symbol);
}
// "2x" is one symbol, not number 2 + symbol x.
{
err_count = 0;
const char* s = "2x";
uint16_t n = TokenStream::tokenize(s, 2, tokens, 64, errs, &err_count);
REQUIRE(n == 2); // symbol + EOF
REQUIRE(tokens[0].kind == TokenKind::Symbol);
REQUIRE(tokens[0].span_len == 2);
}
// Ordinary literals still tokenize as numbers.
struct { const char* src; double v; } good[] = {
{"2", 2.0}, {"-1.5", -1.5}, {".5", 0.5}, {"1e3", 1000.0},
{"2.5e-2", 0.025},
};
for (auto& g : good) {
INFO("source: " << g.src);
Token t = tokenize_one(g.src);
REQUIRE(t.kind == TokenKind::Number);
REQUIRE(t.number == Approx(g.v));
}
// Boundary chars like ')' still terminate numbers.
{
const char* s = "(+ 1 2)";
err_count = 0;
uint16_t n = TokenStream::tokenize(s, (uint32_t)strlen(s),
tokens, 64, errs, &err_count);
REQUIRE(n == 6); // ( + 1 2 ) EOF
REQUIRE(tokens[2].kind == TokenKind::Number);
REQUIRE(tokens[3].kind == TokenKind::Number);
}
// End-to-end: (a1 2x) errors instead of silently reading 2.
EvalResult r = h.eval("(a1 (+ 1 2x))");
REQUIRE(r.kind == EvalResult::Error);
}
TEST_CASE("A15: tokenizer preflights ASCII, finite literals, and span capacity",
"[audit][a15][tokenizer]") {
Token tokens[64];
Diagnostic errors[8];
auto rejects = [&](const char* source, uint32_t length) {
uint8_t error_count = 0;
TokenStream::tokenize(source, length, tokens, 64,
errors, &error_count);
return error_count > 0;
};
const char nul_source[] = {'(', 'a', '1', ' ', '1', ')', '\0',
'(', 'a', '2', ' ', '2', ')'};
REQUIRE(rejects(nul_source, sizeof(nul_source)));
const char high_source[] = {';', ' ', static_cast<char>(0x80), '\n',
'(', 'a', '1', ' ', '1', ')'};
REQUIRE(rejects(high_source, sizeof(high_source)));
REQUIRE(rejects("1e9999", 6));
REQUIRE(rejects("+1e9999", 7));
// LEX-001: numeric conversion is bounded by the submitted slice rather
// than by an adjacent byte or an implicit C-string terminator.
const char adjacent_bytes[] = {'1', '2', 'x', '\0'};
uint8_t bounded_error_count = 0;
uint16_t bounded_count = TokenStream::tokenize(
adjacent_bytes, 1, tokens, 64, errors, &bounded_error_count);
REQUIRE(bounded_error_count == 0);
REQUIRE(bounded_count == 2);
REQUIRE(tokens[0].kind == TokenKind::Number);
REQUIRE(tokens[0].span_start == 0);
REQUIRE(tokens[0].span_len == 1);
REQUIRE(tokens[0].number == Approx(1.0));
// Under the instrumented build, the one-byte allocation also provides a
// memory-boundary witness: conversion may not read a terminator beyond it.
char* exact_allocation = new char[1];
exact_allocation[0] = '7';
bounded_error_count = 0;
bounded_count = TokenStream::tokenize(
exact_allocation, 1, tokens, 64, errors, &bounded_error_count);
delete[] exact_allocation;
REQUIRE(bounded_error_count == 0);
REQUIRE(bounded_count == 2);
REQUIRE(tokens[0].kind == TokenKind::Number);
REQUIRE(tokens[0].span_len == 1);
REQUIRE(tokens[0].number == Approx(7.0));
std::string oversized((size_t)UINT16_MAX + 1, ' ');
REQUIRE(rejects(oversized.data(), (uint32_t)oversized.size()));
// Lexical preflight covers the whole submission before the first form
// can publish: a forbidden byte after a valid-looking definition leaves
// the cell table untouched.
SignalEngine engine;
engine.init_defaults(120.0, 4);
const char atomic_source[] = {
'(', 'd', 'e', 'f', 'i', 'n', 'e', ' ',
'a', '1', '5', '-', 'a', 't', 'o', 'm', 'i', 'c', ' ', '1', ')',
'\0',
'(', 'a', '1', ' ', '2', ')'
};
EvalResult result = eval_cold(atomic_source, sizeof(atomic_source), engine);
REQUIRE(result.kind == EvalResult::Error);
SymbolID symbol =
SymbolIntern::getInstance().intern(String("a15-atomic"));
REQUIRE(symbol < MAX_CELLS);
REQUIRE(engine.cells.cells[symbol].kind == CellKind::Empty);
}
TEST_CASE("Compiler pipeline keeps parsing, storage, and graph mutation bounded",
"[compiler][pipeline][transaction]") {
SECTION("ParsedProgram owns one fixed token stream and bounded diagnostics") {
ParsedProgram program;
program.parse("(+ 1 2)", 7);
REQUIRE(program.ok());
REQUIRE_FALSE(program.empty());
REQUIRE(program.stream.peek().kind == TokenKind::LParen);
program.parse("(+ 1", 4);
REQUIRE_FALSE(program.ok());
REQUIRE(program.diagnostic_count <=
ParsedProgram::MAX_PARSE_DIAGNOSTICS);
}
SECTION("source plans validate capacity before the single publication write") {
SourceArena arena;
REQUIRE(arena.store("old", 3) == 0);
const char replacement[] = "xy";
SourceMutationPlan reuse = SourceMutationPlan::prepare(
arena, replacement, 2, 0, 2, 0, 3);
REQUIRE(reuse.status == SourcePlanStatus::Ready);
REQUIRE(reuse.publish(arena) == 0);
REQUIRE(std::memcmp(arena.read(0), "xy", 2) == 0);
arena.write_head = SOURCE_ARENA_SIZE;
SourceMutationPlan full = SourceMutationPlan::prepare(
arena, "z", 1, 0, 1);
REQUIRE(full.status == SourcePlanStatus::CapacityExceeded);
REQUIRE(full.publish(arena) == UINT32_MAX);
}
SECTION("unaccepted graph mutations roll back while accepted ones persist") {
SignalEngine engine;
engine.init_defaults(120.0, 4);
engine.pool.state_slot_count = 1;
engine.pool.state_values[0] = 3.0;
engine.pool.live_slot_count = 1;
engine.pool.live_slots[0].value = 0.25;
const uint32_t original_head = engine.arena.write_head;
{
GraphMutationTransaction transaction(engine);
engine.pool.state_slot_count = 2;
engine.pool.state_values[0] = 99.0;
engine.pool.live_slot_count = 2;
engine.pool.live_slots[0].value = 0.75;
engine.arena.write_head = original_head + 10;
}
REQUIRE(engine.pool.state_slot_count == 1);
REQUIRE(engine.pool.state_values[0] == Approx(3.0));
REQUIRE(engine.pool.live_slot_count == 1);
REQUIRE(engine.pool.live_slots[0].value == Approx(0.25));
REQUIRE(engine.arena.write_head == original_head);
{
GraphMutationTransaction transaction(engine);
engine.pool.state_values[0] = 4.0;
transaction.accept();
}
REQUIRE(engine.pool.state_values[0] == Approx(4.0));
}
}
// NOTE: the A1 case floods the shared symbol interner past MAX_CELLS, which
// makes any fresh name interned after it out-of-range. Keep it LAST.
// ── A1: cell writes must bounds-check symbol IDs >= MAX_CELLS ───────────────
TEST_CASE("A1: defining more names than MAX_CELLS errors instead of OOB write",
"[audit][a1]") {
Harness h;
// Force the interner past MAX_CELLS so subsequent fresh names are
// guaranteed to have out-of-range IDs.
auto& si = SymbolIntern::getInstance();
for (int i = 0; i < (int)MAX_CELLS + 8; i++) {
char buf[32];
snprintf(buf, sizeof(buf), "a1-flood-%d", i);
si.intern(String(buf));
}
SymbolID big = si.intern(String("a1-oob-name"));
REQUIRE(big >= MAX_CELLS);
// Every cell-write form must reject the out-of-range name with an
// Overflow diagnostic — not write out of bounds.
const char* forms[] = {
"(define a1-oob-name 1)",
"(def a1-oob-name 2)",
"(defn a1-oob-name [x] (+ x 1))",
"(set a1-oob-name 3)",
"(defs [a1-oob-name 4])",
"(defstate a1-oob-name 0 (+ a1-oob-name 1))",
};
for (const char* f : forms) {
INFO("form: " << f);
EvalResult r = h.eval(f);
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count >= 1);
REQUIRE(r.diagnostics[0].category == DiagnosticCategory::Overflow);
}
// Engine still works after the rejections.
h.eval_ok("(a1 0.25)");
REQUIRE(h.sample(0) == Approx(0.25));
}

View file

@ -0,0 +1,598 @@
// Exhaustive, table-driven conformance for the compiler's recognised symbol
// and form surface. symbols.def is the inventory under test: adding a symbol
// there without classifying and exercising it here is a test failure.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include <cstring>
#include <string>
using namespace sig;
namespace {
struct ManifestEntry {
const char* name;
const char* category;
};
static const ManifestEntry kManifest[] = {
#define SYM(field, spelling, category) {spelling, #category},
#include "src/signal_engine/symbols.def"
#undef SYM
};
struct SignalCase {
const char* name;
const char* category;
const char* valid;
const char* too_few;
const char* too_many;
};
// One row for every form callable by GraphBuilder. Optional/variadic forms
// use nullptr where a lower or upper arity does not exist.
static const SignalCase kSignalCases[] = {
{"+", "arith", "(+ 1 2)", nullptr, nullptr},
{"-", "arith", "(- 3 2)", "(-)", nullptr},
{"*", "arith", "(* 3 2)", nullptr, nullptr},
{"/", "arith", "(/ 6 2)", "(/)", nullptr},
{"%", "arith", "(% 7 3)", "(% 7)", nullptr},
{">", "cmp", "(> 2 1)", "(> 1)", "(> 2 1 0)"},
{"<", "cmp", "(< 1 2)", "(< 1)", "(< 1 2 3)"},
{">=", "cmp", "(>= 2 2)", "(>= 2)", "(>= 2 2 1)"},
{"<=", "cmp", "(<= 2 2)", "(<= 2)", "(<= 2 2 1)"},
{"=", "cmp", "(= 2 2)", "(= 2)", "(= 2 2 1)"},
{"not", "logic", "(not 0)", "(not)", "(not 0 1)"},
{"and", "logic", "(and 1 1)", "(and 1)", "(and 1 1 1)"},
{"or", "logic", "(or 0 1)", "(or 0)", "(or 0 1 1)"},
{"time-as", "time_warp", "(time-as 1 beat)", "(time-as 1)", "(time-as 1 beat 2)"},
{"fast", "time_warp", "(fast 2 beat)", "(fast 2)", "(fast 2 beat 3)"},
{"slow", "time_warp", "(slow 2 beat)", "(slow 2)", "(slow 2 beat 3)"},
{"offset", "time_warp", "(offset 1 beat)", "(offset 1)", "(offset 1 beat 3)"},
{"shift", "time_warp", "(shift 1 beat)", "(shift 1)", "(shift 1 beat 3)"},
{"if", "control", "(if 1 2 3)", "(if 1)", "(if 1 2 3 4)"},
{"let", "control", "(let [x 1] x)", "(let [x])", nullptr},
{"do", "control", "(do 1 2)", nullptr, nullptr},
{"for", "control", "(for x [1 2] x)", "(for x [1 2])", "(for x [1 2] x 9)"},
{"while", "control", "(while 1 2)", "(while 1)", "(while 1 2 3)"},
{"fn", "control", "((fn [x] x) 1)", "((fn [x] x))", "((fn [x] x) 1 2)"},
{"lambda", "control", "((lambda [x] x) 1)", "((lambda [x] x))", "((lambda [x] x) 1 2)"},
{"scope", "control", "(scope 1 2)", nullptr, nullptr},
{"sin", "unary", "(sin 1)", "(sin)", "(sin 1 2)"},
{"cos", "unary", "(cos 1)", "(cos)", "(cos 1 2)"},
{"tan", "unary", "(tan 1)", "(tan)", "(tan 1 2)"},
{"abs", "unary", "(abs -1)", "(abs)", "(abs 1 2)"},
{"floor", "unary", "(floor 1.5)", "(floor)", "(floor 1 2)"},
{"ceil", "unary", "(ceil 1.5)", "(ceil)", "(ceil 1 2)"},
{"sqrt", "unary", "(sqrt 4)", "(sqrt)", "(sqrt 4 2)"},
{"neg", "unary", "(neg 1)", "(neg)", "(neg 1 2)"},
{"frac", "unary", "(frac 1.5)", "(frac)", "(frac 1 2)"},
{"bsin", "unary", "(bsin beat)", "(bsin)", "(bsin beat 2)"},
{"bcos", "unary", "(bcos beat)", "(bcos)", "(bcos beat 2)"},
{"bi-to-uni", "unary", "(bi-to-uni -1)", "(bi-to-uni)", "(bi-to-uni 1 2)"},
{"b>u", "unary", "(b>u -1)", "(b>u)", "(b>u 1 2)"},
{"uni-to-bi", "unary", "(uni-to-bi 0.5)", "(uni-to-bi)", "(uni-to-bi 1 2)"},
{"u>b", "unary", "(u>b 0.5)", "(u>b)", "(u>b 1 2)"},
{"min", "arith", "(min 1 2 3)", "(min)", nullptr},
{"max", "arith", "(max 1 2 3)", "(max)", nullptr},
{"pow", "binary", "(pow 2 3)", "(pow 2)", "(pow 2 3 4)"},
{"expt", "binary", "(expt 2 3)", "(expt 2)", "(expt 2 3 4)"},
{"mod", "binary", "(mod 7 3)", "(mod 7)", "(mod 7 3 2)"},
{"pulse", "binary", "(pulse 0.5 beat)", "(pulse 0.5)", "(pulse 0.5 beat 1)"},
{"clamp", "ternary", "(clamp 0 1 0.5)", "(clamp 0 1)", "(clamp 0 1 0.5 2)"},
{"lerp", "ternary", "(lerp 0 1 0.5)", "(lerp 0 1)", "(lerp 0 1 0.5 2)"},
{"scale", "ternary", "(scale 0.5 0 10)", "(scale 0.5 0)", "(scale 0.5 0 10 2)"},
{"tri", "waveform", "(tri beat)", "(tri)", "(tri 0.5 beat)"},
{"sqr", "waveform", "(sqr beat)", "(sqr)", "(sqr 0.5 beat)"},
{"saw", "waveform", "(saw beat)", "(saw)", "(saw 0.5 beat)"},
{"step", "signal", "(step [1 2] beat)", "(step)", "(step [1 2] beat 1)"},
{"gates", "signal", "(gates [1 0] beat)", "(gates)", "(gates [1] 0.5 beat 1)"},
{"trigs", "signal", "(trigs [1 0] beat)", "(trigs)", "(trigs [1] 0.5 beat 1)"},
{"euclid", "signal", "(euclid 3 8 beat)", "(euclid 3)", "(euclid 3 8 0.5 1 beat 9)"},
{"eu", "signal", "(eu 3 8 beat)", "(eu 3)", "(eu 3 8 0.5 1 beat 9)"},
{"seq", "signal", "(seq [1 2] beat)", "(seq)", "(seq [1 2] beat 1)"},
{"from-list", "signal", "(from-list [1 2] beat)", "(from-list)", "(from-list [1 2] beat 1)"},
{"interp", "signal", "(interp [1 2] beat)", "(interp)", "(interp [1 2] beat 1)"},
{"flatseq", "signal", "(flatseq [1 2] beat)", "(flatseq)", "(flatseq [1 2] beat 1)"},
{"dm", "signal", "(dm 1 0 2)", "(dm 1 0)", "(dm 1 0 2 3)"},
{"range", "signal", "(range 4)", "(range)", "(range 0 4 1 2)"},
{"gatesw", "signal", "(gatesw [9 0] beat)", "(gatesw)", "(gatesw [9] beat 1)"},
{"random", "signal", "(random 0 1)", nullptr, "(random 0 1 2)"},
{"index-rand", "signal", "(index-rand 1 0 1)", "(index-rand)", "(index-rand 1 0 1 2)"},
{"loop-at", "signal", "(loop-at 1 beat)", "(loop-at 1)", "(loop-at 1 beat 2)"},
{"eval-at-time", "signal", "(eval-at-time 1 beat)", "(eval-at-time 1)", "(eval-at-time 1 beat 2)"},
{"rpulse", "signal", "(rpulse [1 2] 0.5 beat)", "(rpulse [1 2])", "(rpulse [1 2] 0.5 beat 1)"},
{"rstep", "signal", "(rstep [1 2] beat)", "(rstep)", "(rstep [1 2] beat 1)"},
{"ridx", "signal", "(ridx [1 2] beat)", "(ridx)", "(ridx [1 2] beat 1)"},
{"rwarp", "signal", "(rwarp [1 2] beat)", "(rwarp)", "(rwarp [1 2] beat 1)"},
{"integrate", "signal", "(integrate 1 :id \"catalogue-integrate\")", "(integrate)", "(integrate 1 2)"},
{"phasor", "signal", "(phasor 1 :id \"catalogue-phasor\")", "(phasor)", "(phasor 1 2)"},
{"lfo", "signal", "(lfo 1 :id \"catalogue-lfo\")", "(lfo)", "(lfo 1 2)"},
{"blfo", "signal", "(blfo 1 :id \"catalogue-blfo\")", "(blfo)", "(blfo 1 2)"},
{"slew", "signal", "(slew 1 2 :id \"catalogue-slew\")", "(slew 1)", "(slew 1 2 3)"},
{"one-pole", "signal", "(one-pole 1 2 :id \"catalogue-one-pole\")", "(one-pole 1)", "(one-pole 1 2 3)"},
{"env-follow", "signal", "(env-follow 1 :id \"catalogue-env\")", "(env-follow)", "(env-follow 1 2 3 4)"},
{"sah", "signal", "(sah 1 1 :id \"catalogue-sah\")", "(sah 1)", "(sah 1 1 2)"},
{"noise", "signal", "(noise :id \"catalogue-noise\")", nullptr, "(noise 1)"},
{"toggle", "signal", "(toggle 1 :id \"catalogue-toggle\")", "(toggle)", "(toggle 1 2)"},
{"count", "signal", "(count 1 :id \"catalogue-count\")", "(count)", "(count 1 2)"},
{"envelope-follower", "signal", "(envelope-follower 1 :id \"catalogue-envelope\")", "(envelope-follower)", "(envelope-follower 1 2 3 4)"},
{"latch", "signal", "(latch 1 1 :id \"catalogue-latch\")", "(latch 1)", "(latch 1 1 2)"},
{"live-edit", "signal", "(live-edit 0.5 :id \"catalogue-live\" :min 0 :max 1)", "(live-edit)", "(live-edit 0.5 :id \"x\" :min 0 :max 1 2)"},
};
struct TopLevelCase {
const char* name;
const char* category;
const char* prelude;
const char* valid;
const char* invalid;
};
static const TopLevelCase kTopLevelCases[] = {
{"defstate", "side_effect", nullptr, "(defstate catalogue-state 0 (+ catalogue-state 1))", "(defstate catalogue-state 0)"},
{"define", "side_effect", nullptr, "(define catalogue-a 1)", "(define catalogue-a)"},
{"def", "side_effect", nullptr, "(def catalogue-b 1)", "(def catalogue-b)"},
{"defn", "side_effect", nullptr, "(defn catalogue-f [x] x)", "(defn catalogue-f [x])"},
{"defun", "side_effect", nullptr, "(defun catalogue-g [x] x)", "(defun catalogue-g [x])"},
{"defs", "side_effect", nullptr, "(defs [catalogue-c 1 catalogue-d 2])", "(defs [catalogue-c])"},
{"set", "side_effect", nullptr, "(set catalogue-e 1)", "(set catalogue-e)"},
{"unassign", "side_effect", "(a2 0.5)", "(unassign a2)", "(unassign a2 a3)"},
{"zeros", "side_effect", nullptr, "(zeros 4)", "(zeros)"},
{"get-expr", "side_effect", "(defn catalogue-h [x] x)", "(get-expr catalogue-h)", "(get-expr catalogue-h extra)"},
{"set-bpm", "side_effect", nullptr, "(set-bpm 123)", "(set-bpm)"},
{"set-time-sig", "side_effect", nullptr, "(set-time-sig 3 4)", "(set-time-sig 6 8)"},
{"useq-clear", "side_effect", nullptr, "(useq-clear)", "(useq-clear 1)"},
{"useq-set-time-offset", "side_effect", nullptr, "(useq-set-time-offset 1)", "(useq-set-time-offset)"},
{"useq-nudge-time", "side_effect", nullptr, "(useq-nudge-time 0.1)", "(useq-nudge-time)"},
{"useq-play", "side_effect", nullptr, "(useq-play)", "(useq-play 1)"},
{"useq-pause", "side_effect", nullptr, "(useq-pause)", "(useq-pause 1)"},
{"useq-stop", "side_effect", nullptr, "(useq-stop)", "(useq-stop 1)"},
{"useq-rewind", "side_effect", nullptr, "(useq-rewind)", "(useq-rewind 1)"},
{"set-clock-ext", "side_effect", nullptr, "(set-clock-ext 1 4)", "(set-clock-ext 3)"},
{"set-clock-int", "side_effect", nullptr, "(set-clock-int)", "(set-clock-int 1)"},
{"get-clock-source", "side_effect", nullptr, "(get-clock-source)", "(get-clock-source 1)"},
{"reset-clock-ext", "side_effect", nullptr, "(reset-clock-ext)", "(reset-clock-ext 1)"},
{"reset-clock-int", "side_effect", nullptr, "(reset-clock-int)", "(reset-clock-int 1)"},
{"synth", "side_effect", nullptr, "(synth \"osc/sine\" :freq 440)", "(synth)"},
{"with-state-id", "control", nullptr, "(with-state-id \"catalogue-wrapper\" (synth \"osc/sine\" :freq 440))", "(with-state-id \"catalogue-wrapper\")"},
};
struct ClassifiedOnly {
const char* name;
const char* category;
const char* valid_bare;
};
// `none` means the symbol is a leaf, keyword, or explicitly-special form,
// never an untested callable dispatch category.
static const ClassifiedOnly kClassifiedOnly[] = {
{"t", "none", "t"}, {"beat", "none", "beat"},
{"bar", "none", "bar"}, {"phrase", "none", "phrase"},
{"section", "none", "section"}, {"beat-num", "none", "beat-num"},
{"bar-num", "none", "bar-num"}, {"bpm", "none", "bpm"},
{"beats-per-bar", "none", "beats-per-bar"},
{"bars-per-phrase", "none", "bars-per-phrase"},
{"phrases-per-section", "none", "phrases-per-section"},
{"dt", "none", "dt"}, {"beat-dur", "none", "beat-dur"},
{"bar-dur", "none", "bar-dur"},
{"prev", "none", "(prev a1)"}, {"input", "none", "(input 0)"},
{"quote", "none", nullptr},
{":wave", "none", nullptr}, {":phase", "none", nullptr},
{":pw", "none", nullptr}, {":id", "none", nullptr},
{":fresh", "none", nullptr}, {":attack", "none", nullptr},
{":release", "none", nullptr}, {":reset", "none", nullptr},
{":sin", "none", nullptr}, {":cos", "none", nullptr},
{":tri", "none", nullptr},
{":saw", "none", nullptr}, {":sqr", "none", nullptr},
{":freq", "none", nullptr}, {":amp", "none", nullptr},
{":name", "none", nullptr}, {":version", "none", nullptr},
{":min", "none", nullptr}, {":max", "none", nullptr},
{":options", "none", nullptr}, {":step", "none", nullptr},
{":precision", "none", nullptr},
};
struct SemanticSnapshot {
uint16_t root;
bool valid;
double sample;
OutputSource source;
OutputDeps deps;
uint16_t state_slots;
uint16_t live_slots;
uint16_t registry_entries;
uint8_t data_tables;
uint32_t arena_head;
SynthRevision synth_revision;
};
static EvalResult eval(SignalEngine& engine, const std::string& code) {
return eval_cold(code.c_str(), (uint32_t)code.size(), engine);
}
static double execute_output(SignalEngine& engine, uint16_t output_index,
const double* hardware_inputs = nullptr,
bool commit = false) {
double cells[MAX_CELLS] = {};
double zero_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
engine.cells.snapshot_values(cells, MAX_CELLS);
ExecutionContext ctx{0.25, 0.001, cells,
hardware_inputs ? hardware_inputs : zero_inputs,
engine.cells.data_pool, engine.cells.data_offsets,
engine.cells.data_lengths,
engine.pool.prev_output_values, outputs, workspace};
execute_all_outputs(engine.pool, ctx);
const double result = outputs[output_index];
if (commit) commit_outputs(engine.pool, outputs);
return result;
}
static double sample_a1(SignalEngine& engine) {
return execute_output(engine, 0);
}
static SemanticSnapshot snapshot(SignalEngine& engine) {
SemanticSnapshot s{};
s.root = engine.pool.outputs[0].root_node;
s.valid = engine.pool.outputs[0].valid;
s.sample = sample_a1(engine);
s.source = engine.output_sources[0];
s.deps = engine.pool.output_deps[0];
s.state_slots = engine.pool.state_slot_count;
s.live_slots = engine.pool.live_slot_count;
s.registry_entries = engine.registry.entry_count;
s.data_tables = engine.cells.data_table_count;
s.arena_head = engine.arena.write_head;
s.synth_revision = engine.synth_graph.revision;
return s;
}
static void require_same_semantics(const SemanticSnapshot& before,
SignalEngine& engine) {
REQUIRE(engine.pool.outputs[0].root_node == before.root);
REQUIRE(engine.pool.outputs[0].valid == before.valid);
REQUIRE(sample_a1(engine) == Approx(before.sample));
REQUIRE(std::memcmp(&engine.output_sources[0], &before.source,
sizeof(before.source)) == 0);
REQUIRE(std::memcmp(&engine.pool.output_deps[0], &before.deps,
sizeof(before.deps)) == 0);
REQUIRE(engine.pool.state_slot_count == before.state_slots);
REQUIRE(engine.pool.live_slot_count == before.live_slots);
REQUIRE(engine.registry.entry_count == before.registry_entries);
REQUIRE(engine.cells.data_table_count == before.data_tables);
REQUIRE(engine.arena.write_head == before.arena_head);
REQUIRE(engine.synth_graph.revision == before.synth_revision);
}
static size_t catalogue_count(const ManifestEntry& manifest) {
size_t count = 0;
for (const auto& row : kSignalCases)
if (std::strcmp(row.name, manifest.name) == 0 &&
std::strcmp(row.category, manifest.category) == 0) ++count;
for (const auto& row : kTopLevelCases)
if (std::strcmp(row.name, manifest.name) == 0 &&
std::strcmp(row.category, manifest.category) == 0) ++count;
for (const auto& row : kClassifiedOnly)
if (std::strcmp(row.name, manifest.name) == 0 &&
std::strcmp(row.category, manifest.category) == 0) ++count;
return count;
}
static size_t manifest_count(const char* name, const char* category) {
size_t count = 0;
for (const auto& entry : kManifest) {
if (std::strcmp(name, entry.name) == 0 &&
std::strcmp(category, entry.category) == 0) ++count;
}
return count;
}
} // namespace
TEST_CASE("Every symbols.def entry has one executable catalogue disposition",
"[builtins][catalogue]") {
for (const auto& entry : kManifest) {
INFO("symbol: " << entry.name << " category: " << entry.category);
REQUIRE(manifest_count(entry.name, entry.category) == 1);
REQUIRE(catalogue_count(entry) == 1);
}
for (const auto& row : kSignalCases)
REQUIRE(manifest_count(row.name, row.category) == 1);
for (const auto& row : kTopLevelCases)
REQUIRE(manifest_count(row.name, row.category) == 1);
for (const auto& row : kClassifiedOnly)
REQUIRE(manifest_count(row.name, row.category) == 1);
}
TEST_CASE("Every compiler-dispatched form accepts its documented witness",
"[builtins][catalogue][positive]") {
for (const auto& row : kSignalCases) {
DYNAMIC_SECTION(row.name << " accepts " << row.valid) {
SignalEngine engine;
engine.init_defaults(120.0, 4);
const std::string assignment =
std::string("(a1 ") + row.valid + ")";
EvalResult r = eval(engine, assignment);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
const char* message = r.diagnostics[0].message;
INFO("diagnostic: " << (message ? message : ""));
}
REQUIRE(r.kind != EvalResult::Error);
}
}
for (const auto& row : kClassifiedOnly) {
if (!row.valid_bare) continue;
DYNAMIC_SECTION(row.name << " resolves as a leaf/special form") {
SignalEngine engine;
engine.init_defaults(120.0, 4);
REQUIRE(eval(engine, row.valid_bare).kind != EvalResult::Error);
}
}
}
TEST_CASE("Arity rejection is failure-atomic for every bounded form",
"[builtins][arity][transaction]") {
for (const auto& row : kSignalCases) {
const char* rejected[] = {row.too_few, row.too_many};
for (const char* code : rejected) {
if (!code) continue;
DYNAMIC_SECTION(row.name << " rejects " << code) {
SignalEngine engine;
engine.init_defaults(120.0, 4);
REQUIRE(eval(engine, "(a1 0.375)").kind != EvalResult::Error);
SemanticSnapshot before = snapshot(engine);
std::string assignment = std::string("(a1 ") + code + ")";
EvalResult r = eval(engine, assignment);
INFO("candidate: " << assignment);
REQUIRE(r.kind == EvalResult::Error);
require_same_semantics(before, engine);
}
}
}
}
TEST_CASE("Top-level surfaces validate before mutation and are forbidden in signals",
"[builtins][top-level][boundary][transaction]") {
for (const auto& row : kTopLevelCases) {
DYNAMIC_SECTION(row.name << " valid, invalid, and boundary cases") {
SignalEngine valid_engine;
valid_engine.init_defaults(120.0, 4);
if (row.prelude)
REQUIRE(eval(valid_engine, row.prelude).kind != EvalResult::Error);
EvalResult ok = eval(valid_engine, row.valid);
if (ok.kind == EvalResult::Error && ok.diagnostic_count > 0) {
const char* message = ok.diagnostics[0].message;
INFO("diagnostic: " << (message ? message : ""));
}
REQUIRE(ok.kind != EvalResult::Error);
SignalEngine rejected_engine;
rejected_engine.init_defaults(120.0, 4);
REQUIRE(eval(rejected_engine, "(a1 0.375)").kind != EvalResult::Error);
SemanticSnapshot before = snapshot(rejected_engine);
REQUIRE(eval(rejected_engine, row.invalid).kind == EvalResult::Error);
require_same_semantics(before, rejected_engine);
SignalEngine nested_engine;
nested_engine.init_defaults(120.0, 4);
REQUIRE(eval(nested_engine, "(a1 0.375)").kind != EvalResult::Error);
SemanticSnapshot nested_before = snapshot(nested_engine);
std::string nested = std::string("(a1 ") + row.valid + ")";
REQUIRE(eval(nested_engine, nested).kind == EvalResult::Error);
require_same_semantics(nested_before, nested_engine);
}
}
}
TEST_CASE("Builtin types and UGen keywords reject incoherent inputs atomically",
"[builtins][type][keyword][transaction]") {
static const char* rejected[] = {
"(input beat)",
"(step 1 beat)",
"(range beat)",
"(phasor 1 :phase beat)",
"(lfo 1 :wave :not-a-wave)",
"(live-edit 0.5 :id 1 :min 0 :max 1)",
"(live-edit 0.5 :id \"bad-name\" :min 0 :max 1 :name 1)",
"(live-edit 0.5 :id \"bad-step\" :min 0 :max 1 :step 0)",
"(live-edit 0.5 :id \"bad-precision\" :min 0 :max 1 :precision 1.5)",
"(live-edit 0.5 :id \"bad-options\" :min 0 :max 1 :options [:a])",
"(live-edit :a :id \"missing-seed-option\" :options [:b])",
"(live-edit :a :id \"bad-option-type\" :options [:a 1])",
"(live-edit :a :id \"full-unknown\" :min 0 :max 1 :name \"A\" "
":options [:a] :step 0.1 :precision 2 :bogus 1)",
"(integrate 1 :bogus 2)",
"(phasor 1 :bogus 2)",
"(lfo 1 :bogus 2)",
"(slew 1 2 :bogus 2)",
"(one-pole 1 2 :bogus 2)",
"(env-follow 1 :bogus 2)",
"(sah 1 1 :bogus 2)",
"(noise :bogus 2)",
"(toggle 1 :bogus 2)",
"(count 1 :bogus 2)",
"(lfo/sin 1 :bogus 2)",
"(lfo/tri 1 :bogus 2)",
"(lfo/saw 1 :bogus 2)",
"(lfo/sqr 1 :bogus 2)",
"(envelope-follower 1 :bogus 2)",
"(latch 1 1 :bogus 2)",
"(live-edit 0.5 :id \"bad-kw\" :min 0 :max 1 :bogus 2)",
};
for (const char* code : rejected) {
DYNAMIC_SECTION(code) {
SignalEngine engine;
engine.init_defaults(120.0, 4);
REQUIRE(eval(engine, "(a1 0.375)").kind != EvalResult::Error);
SemanticSnapshot before = snapshot(engine);
std::string assignment = std::string("(a1 ") + code + ")";
REQUIRE(eval(engine, assignment).kind == EvalResult::Error);
require_same_semantics(before, engine);
}
}
}
TEST_CASE("Duplicate UGen and live-edit keywords are rejected atomically",
"[builtins][keyword][duplicate][transaction]") {
static const char* rejected[] = {
"(integrate 1 :id \"a\" :id \"b\")",
"(phasor 1 :phase 0 :phase 0.5)",
"(phasor 1 :id \"a\" :id \"b\")",
"(lfo 1 :wave :sin :wave :tri)",
"(lfo 1 :phase 0 :phase 0.5)",
"(lfo 1 :pw 0.25 :pw 0.75)",
"(lfo 1 :id \"a\" :id \"b\")",
"(slew 1 2 :id \"a\" :id \"b\")",
"(one-pole 1 2 :id \"a\" :id \"b\")",
"(env-follow 1 :id \"a\" :id \"b\")",
"(sah 1 1 :id \"a\" :id \"b\")",
"(noise :id \"a\" :id \"b\")",
"(toggle 1 :id \"a\" :id \"b\")",
"(count 1 :reset 0 :reset 1)",
"(count 1 :id \"a\" :id \"b\")",
"(live-edit 0.5 :id \"a\" :id \"b\" :min 0 :max 1)",
"(live-edit 0.5 :id \"a\" :min 0 :min -1 :max 1)",
"(live-edit 0.5 :id \"a\" :min 0 :max 1 :max 2)",
"(live-edit 0.5 :id \"a\" :min 0 :max 1 :name \"x\" :name \"y\")",
"(live-edit 0.5 :id \"a\" :min 0 :max 1 :step 0.1 :step 0.2)",
"(live-edit 0.5 :id \"a\" :min 0 :max 1 :precision 2 :precision 3)",
"(live-edit :a :id \"a\" :options [:a] :options [:a :b])",
};
for (const char* code : rejected) {
DYNAMIC_SECTION(code) {
SignalEngine engine;
engine.init_defaults(120.0, 4);
REQUIRE(eval(engine, "(a1 0.375)").kind != EvalResult::Error);
SemanticSnapshot before = snapshot(engine);
const std::string assignment = std::string("(a1 ") + code + ")";
REQUIRE(eval(engine, assignment).kind == EvalResult::Error);
require_same_semantics(before, engine);
}
}
}
TEST_CASE("Duplicate synth keywords are rejected atomically",
"[builtins][synth][keyword][duplicate][transaction]") {
static const char* rejected[] = {
"(synth \"osc/sine\" :freq 440 :freq 880)",
"(synth \"osc/sine\" :freq 440 :amp 0.1 :amp 0.2)",
"(synth \"osc/sine\" :name \"a\" :name \"b\" :freq 440)",
"(synth \"osc/sine\" :version 2 :version 2 :freq 440)",
"(synth \"osc/sine\" :id \"a\" :id \"b\" :freq 440)",
};
for (const char* code : rejected) {
DYNAMIC_SECTION(code) {
SignalEngine engine;
engine.init_defaults(120.0, 4);
REQUIRE(eval(engine, "(a1 0.375)").kind != EvalResult::Error);
SemanticSnapshot before = snapshot(engine);
REQUIRE(eval(engine, code).kind == EvalResult::Error);
require_same_semantics(before, engine);
}
}
}
TEST_CASE("All four hardware-input leaves preserve their channel mapping",
"[builtins][inputs][leaves]") {
struct InputCase { const char* name; uint16_t index; double value; };
static const InputCase inputs[] = {
{"in1", 0, 0.125}, {"in2", 1, 0.25},
{"ain1", 8, 0.625}, {"ain2", 9, 0.875},
};
for (const auto& input : inputs) {
DYNAMIC_SECTION(input.name << " maps to hardware channel " << input.index) {
SignalEngine engine;
engine.init_defaults(120.0, 4);
REQUIRE(eval(engine, std::string("(a1 ") + input.name + ")").kind !=
EvalResult::Error);
double hardware_inputs[32] = {};
hardware_inputs[input.index] = input.value;
REQUIRE(execute_output(engine, 0, hardware_inputs) ==
Approx(input.value));
REQUIRE((engine.pool.output_input_mask[0] &
(uint32_t{1} << input.index)) != 0);
}
}
}
TEST_CASE("All 24 output sinks support assignment, bare reads, and unassignment",
"[builtins][outputs][prev][unassign]") {
for (const char prefix : {'a', 'd', 's'}) {
for (int n = 1; n <= 8; ++n) {
DYNAMIC_SECTION(prefix << n) {
SignalEngine engine;
engine.init_defaults(120.0, 4);
std::string name;
name += prefix;
name += char('0' + n);
REQUIRE(eval(engine, "(" + name + " 0.5)").kind != EvalResult::Error);
uint16_t index = GraphBuilder::resolve_output_index(internSymbol(name.c_str()));
REQUIRE(index != NODE_NONE);
REQUIRE(engine.pool.outputs[index].valid);
const std::string reader = name == "a1" ? "a2" : "a1";
const uint16_t reader_index = GraphBuilder::resolve_output_index(
internSymbol(reader.c_str()));
REQUIRE(eval(engine, "(" + reader + " " + name + ")").kind !=
EvalResult::Error);
// Bare output names are previous-committed-sample reads. The
// first tick commits the target; the second observes 0.5.
execute_output(engine, reader_index, nullptr, true);
REQUIRE(execute_output(engine, reader_index, nullptr, true) ==
Approx(0.5));
REQUIRE(eval(engine, "(unassign " + name + ")").kind != EvalResult::Error);
REQUIRE_FALSE(engine.pool.outputs[index].valid);
REQUIRE(engine.pool.outputs[index].root_node == NODE_NONE);
REQUIRE(engine.pool.outputs[index].lkg_value == 0.0);
REQUIRE(engine.pool.prev_output_values[index] == 0.0);
REQUIRE_FALSE(engine.output_sources[index].has_source);
REQUIRE(engine.pool.output_deps[index].count == 0);
}
}
}
}
TEST_CASE("The four formerly ambiguous surfaces have one explicit meaning",
"[builtins][paper-blockers]") {
SignalEngine engine;
engine.init_defaults(120.0, 4);
REQUIRE(eval(engine, "(a1 (tri beat))").kind != EvalResult::Error);
REQUIRE(eval(engine, "(a1 (tri 0.5 beat))").kind == EvalResult::Error);
REQUIRE(eval(engine, "(a1 (sqr beat))").kind != EvalResult::Error);
REQUIRE(eval(engine, "(a1 (sqr 0.5 beat))").kind == EvalResult::Error);
REQUIRE(eval(engine, "(a2 (% 7))").kind == EvalResult::Error);
REQUIRE(eval(engine, "(a2 (% 7 3))").kind != EvalResult::Error);
REQUIRE(eval(engine, "(a2 (% 20 6 4))").kind != EvalResult::Error);
REQUIRE(eval(engine, "(a2 (min 1))").kind != EvalResult::Error);
REQUIRE(eval(engine, "(a2 (max 1 2 3))").kind != EvalResult::Error);
auto& si = SymbolIntern::getInstance();
SymbolID bpb = si.intern("beats-per-bar");
REQUIRE(eval(engine, "(set-time-sig 3 4)").kind != EvalResult::Error);
REQUIRE(engine.cells.cells[bpb].value == 3.0);
REQUIRE(eval(engine, "(set-time-sig 6 8)").kind == EvalResult::Error);
REQUIRE(engine.cells.cells[bpb].value == 3.0);
REQUIRE(eval(engine, "(set-time-sig 0 4)").kind == EvalResult::Error);
REQUIRE(eval(engine, "(set-time-sig -3 4)").kind == EvalResult::Error);
REQUIRE(eval(engine, "(set-time-sig 3.5 4)").kind == EvalResult::Error);
REQUIRE(engine.cells.cells[bpb].value == 3.0);
REQUIRE(eval(engine, "(a3 0.75)").kind != EvalResult::Error);
REQUIRE(eval(engine, "(unassign a3)").kind != EvalResult::Error);
REQUIRE_FALSE(engine.pool.outputs[2].valid);
REQUIRE(eval(engine, "(unassign not-an-output)").kind == EvalResult::Error);
REQUIRE(eval(engine, "(unassign)").kind == EvalResult::Error);
}

View file

@ -0,0 +1,193 @@
// P1 compiler correctness regression tests (v1.2.0 release audit).
//
// Three "silent wrong value" compiler bugs that must never ship — each is a
// case where the compiler quietly substituted a wrong value instead of either
// computing the right one or failing loudly:
//
// P1a: Time-varying elements inside a data-vector (step/seq/gates/interp/…)
// were silently replaced with 0. A vector lowers to a static double
// table read by index, so a per-slot signal can't be represented — it
// must be a compile error, not a silent zero (values-types.md §1.7).
// `for` iterates over element *nodes* and still supports time-varying
// elements, so it is unaffected.
//
// P1b: `(define x N)` over a name that was previously a `defstate` cell was
// silently ignored: the stale state marker (flags 0x02) survived, so
// graph_builder kept emitting a state_load from the old slot and the new
// value never took effect. define must establish a fresh binding.
//
// P1c: `let` bindings past the 32-binding pool limit were silently dropped,
// so a later reference resolved to the wrong value (or a global). It
// must be a diagnostic instead.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include <cstdio>
#include <cstring>
#include <string>
using namespace sig;
namespace {
struct P1Harness {
SignalEngine engine;
P1Harness() { engine.init_defaults(120.0, 4); }
EvalResult eval(const std::string& code) {
return eval_cold(code.c_str(), (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);
}
// Execute one sample at t=0 and return the named output's value.
double sample(const char* output_name) {
double cell_values[MAX_CELLS];
engine.cells.snapshot_values(cell_values, MAX_CELLS);
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
ExecutionContext ctx;
ctx.t = 0.0;
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);
SymbolID sym = internSymbol(output_name);
uint16_t idx = GraphBuilder::resolve_output_index(sym);
REQUIRE(idx != NODE_NONE);
return outputs[idx];
}
const Cell& cell(const char* name) {
return engine.cells.cells[internSymbol(name)];
}
};
} // namespace
// ============================================================================
// P1a: time-varying vector elements must be a compile error, not a silent 0
// ============================================================================
TEST_CASE("P1a: time-varying element in a step vector is a compile error",
"[p1][vector]") {
P1Harness h;
EvalResult r = h.eval("(a1 (step [1 (sin beat) 3] beat))");
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count >= 1);
REQUIRE(r.diagnostics[0].category == DiagnosticCategory::Type);
// The output must NOT be installed with silently-zeroed data.
REQUIRE_FALSE(h.engine.pool.outputs[0].valid);
}
TEST_CASE("P1a: a bare time-varying vector literal is a compile error",
"[p1][vector]") {
P1Harness h;
EvalResult r = h.eval("(a1 [1 (sin beat) 3])");
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count >= 1);
REQUIRE(r.diagnostics[0].category == DiagnosticCategory::Type);
}
TEST_CASE("P1a: constant vectors still compile and sample correctly",
"[p1][vector]") {
P1Harness h;
h.eval_ok("(a1 (step [1 2 3] beat))");
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.sample("a1") == Approx(1.0)); // step value at phase 0
}
TEST_CASE("P1a: `for` still supports time-varying elements (per-slot signals)",
"[p1][vector]") {
P1Harness h;
// for iterates over element nodes, so a time-varying element is valid here.
h.eval_ok("(a1 (for i [1 (sin beat) 3] i))");
REQUIRE(h.engine.pool.outputs[0].valid);
}
// ============================================================================
// P1b: define over a defstate cell must take effect (clear the state marker)
// ============================================================================
TEST_CASE("P1b: (define x N) over a defstate cell is honoured", "[p1][define]") {
P1Harness h;
h.eval_ok("(defstate x 5 (+ x 1))");
REQUIRE(h.cell("x").flags == 0x02); // state marker set by defstate
REQUIRE(h.cell("x").kind == CellKind::Number);
h.eval_ok("(a1 x)");
// Redefine x as a plain constant — this must sever the state association.
h.eval_ok("(define x 42)");
REQUIRE(h.cell("x").flags == 0); // stale state marker cleared
REQUIRE(h.cell("x").value == Approx(42.0));
REQUIRE(h.cell("x").kind == CellKind::Number);
// The dependent output must recompile to read the new constant, not the
// old state slot.
REQUIRE(h.sample("a1") == Approx(42.0));
}
TEST_CASE("P1b: define over defstate then re-defstate reuses a fresh binding",
"[p1][define]") {
P1Harness h;
h.eval_ok("(defstate x 5 (+ x 1))");
h.eval_ok("(define x 7)");
REQUIRE(h.cell("x").flags == 0);
// A subsequent defstate re-establishes state semantics cleanly.
h.eval_ok("(defstate x 9 (+ x 1))");
REQUIRE(h.cell("x").flags == 0x02);
}
// ============================================================================
// P1c: let bindings past the pool limit must diagnose, not silently drop
// ============================================================================
TEST_CASE("P1c: a let with exactly 32 bindings compiles", "[p1][let]") {
P1Harness h;
std::string code = "(a1 (let [";
for (int i = 0; i < 32; i++) {
code += "v" + std::to_string(i) + " " + std::to_string(i) + " ";
}
code += "] v0))";
h.eval_ok(code);
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.sample("a1") == Approx(0.0)); // v0 == 0
}
TEST_CASE("P1c: a let with 33 bindings is a compile error", "[p1][let]") {
P1Harness h;
std::string code = "(a1 (let [";
for (int i = 0; i < 33; i++) {
code += "v" + std::to_string(i) + " " + std::to_string(i) + " ";
}
code += "] v0))";
EvalResult r = h.eval(code);
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count >= 1);
REQUIRE(r.diagnostics[0].category == DiagnosticCategory::Overflow);
REQUIRE_FALSE(h.engine.pool.outputs[0].valid);
}

View file

@ -0,0 +1,274 @@
// Failure-mode tests — docs/specs/failure-model.md §2.1/§3/§5.
//
// 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.
// Mode B (FailureMode::ZeroSquash, legacy): every non-finite node result is
// clamped to 0.0; no fallback, no diagnostic.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include "src/modulisp/lisp/symbol_intern.h"
#include <cmath>
#include <cstring>
#include <string>
using namespace sig;
namespace {
struct Harness {
SignalEngine engine;
Harness() { engine.init_defaults(120.0, 4); }
~Harness() {
// The failure mode is engine-global; restore the default so test
// ordering can't leak ZeroSquash into other cases.
set_failure_mode(FailureMode::LkgFallback);
}
void eval_ok(const std::string& code) {
EvalResult r = eval_cold(code.c_str(), (uint32_t)code.size(), engine);
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);
engine.pool.rebuild_execution_order();
}
// Execute one sample at time t; optionally commit (tick semantics).
double sample(int output_index, double t, bool commit = false) {
double cell_values[MAX_CELLS];
engine.cells.snapshot_values(cell_values, MAX_CELLS);
double hw_inputs[32] = {};
double outputs_arr[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
ExecutionContext ctx;
ctx.t = t;
ctx.dt = 0.001;
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_arr;
ctx.workspace = workspace;
execute_all_outputs(engine.pool, ctx);
if (commit) {
commit_state(engine.pool, workspace);
commit_outputs(engine.pool, outputs_arr);
}
return outputs_arr[output_index];
}
bool in_fallback(int output_index) const {
return (engine.pool.runtime_fallback_mask >> output_index) & 1;
}
};
// (* (* t 1e308) 1e308) overflows to +inf for t > 0 and is exactly 0.0
// (finite) at t == 0 — a time-phase-dependent runtime error (§1.7) that
// cannot be constant-folded away.
const char* OVERFLOW_PROG = "(a1 (* (* t 1e308) 1e308))";
} // namespace
TEST_CASE("Mode A default: non-finite at root falls back to LKG",
"[failure-mode][lkg]") {
Harness h;
REQUIRE(get_failure_mode() == FailureMode::LkgFallback);
// Establish an LKG value of 0.75 on a1.
h.eval_ok("(a1 0.75)");
h.sample(0, 0.0, /*commit=*/true);
REQUIRE(h.engine.pool.outputs[0].lkg_value == Approx(0.75));
// Replace with a program that overflows to +inf at t > 0.
h.eval_ok(OVERFLOW_PROG);
double v = h.sample(0, 1.0);
REQUIRE(std::isfinite(v));
REQUIRE(v == Approx(0.75)); // LKG substituted, not 0.0
REQUIRE(h.in_fallback(0)); // fallback recorded for diagnostics
// A healthy sample (t == 0 → finite 0.0) clears the fallback bit.
double v2 = h.sample(0, 0.0);
REQUIRE(v2 == Approx(0.0));
REQUIRE_FALSE(h.in_fallback(0));
}
TEST_CASE("Mode A bootstrap: non-finite with no LKG yields neutral default",
"[failure-mode][lkg]") {
Harness h;
// Never-committed output: no LKG exists (valid == false).
h.eval_ok(OVERFLOW_PROG);
double v = h.sample(0, 1.0);
REQUIRE(v == Approx(0.0)); // neutral default (§2.4)
REQUIRE(h.in_fallback(0));
}
TEST_CASE("Optimizer preserves non-finite failure observability",
"[failure-mode][optimizer][audit]") {
Harness h;
h.eval_ok("(a1 0.75)");
h.sample(0, 0.0, /*commit=*/true);
SECTION("dynamic x multiplied by zero") {
h.eval_ok("(a1 (* (expt (- 0 t) 0.5) 0))");
REQUIRE(h.sample(0, 1.0) == Approx(0.75));
REQUIRE(h.in_fallback(0));
}
SECTION("dynamic x subtracted from itself") {
h.eval_ok("(a1 (- (expt (- 0 t) 0.5) (expt (- 0 t) 0.5)))");
REQUIRE(h.sample(0, 1.0) == Approx(0.75));
REQUIRE(h.in_fallback(0));
}
SECTION("constant division by zero") {
h.eval_ok("(a1 (/ 1 0))");
REQUIRE(h.sample(0, 0.0) == Approx(0.75));
REQUIRE(h.in_fallback(0));
}
SECTION("constant modulo by zero") {
h.eval_ok("(a1 (% 1 0))");
REQUIRE(h.sample(0, 0.0) == Approx(0.75));
REQUIRE(h.in_fallback(0));
}
}
TEST_CASE("Mode A: fallback does not poison LKG or other outputs",
"[failure-mode][lkg]") {
Harness h;
h.eval_ok("(a1 0.5)");
h.eval_ok("(a2 0.25)");
h.sample(0, 0.0, /*commit=*/true);
h.eval_ok(OVERFLOW_PROG);
double v = h.sample(0, 2.0, /*commit=*/true);
REQUIRE(v == Approx(0.5));
// Committing the substituted value must not overwrite LKG with garbage.
REQUIRE(h.engine.pool.outputs[0].lkg_value == Approx(0.5));
REQUIRE(std::isfinite(h.engine.pool.prev_output_values[0]));
// Other outputs are unaffected (§10.1 per-output isolation).
REQUIRE_FALSE(h.in_fallback(1));
REQUIRE(h.sample(1, 2.0) == Approx(0.25));
}
TEST_CASE("Mode A: healthy reassignment returns output to running",
"[failure-mode][lkg]") {
Harness h;
h.eval_ok("(a1 0.75)");
h.sample(0, 0.0, /*commit=*/true);
h.eval_ok(OVERFLOW_PROG);
h.sample(0, 1.0);
REQUIRE(h.in_fallback(0));
h.eval_ok("(a1 0.125)");
REQUIRE(h.sample(0, 1.0) == Approx(0.125));
REQUIRE_FALSE(h.in_fallback(0)); // fallback → running (§5.2)
}
TEST_CASE("Mode A: non-finite state update keeps previous state value",
"[failure-mode][lkg][state]") {
Harness h;
// A state slot whose update expression overflows at t > 0.
h.eval_ok("(a1 (slew (* (* t 1e308) 1e308) 0.5))");
h.sample(0, 0.0, /*commit=*/true);
double before = h.engine.pool.state_values[0];
REQUIRE(std::isfinite(before));
h.sample(0, 1.0, /*commit=*/true);
// The poisoned update must not have been committed.
REQUIRE(std::isfinite(h.engine.pool.state_values[0]));
}
TEST_CASE("Mode A: fallback freezes state owned by the failed output",
"[failure-mode][lkg][state][ownership]") {
Harness h;
h.eval_ok(
"(a1 (+ (* (* t 1e308) 1e308) "
" (phasor 1 :id \"frozen-phase\")))");
REQUIRE(h.engine.pool.state_slot_count == 1);
double initial = h.engine.pool.state_values[0];
h.sample(0, 1.0, /*commit=*/true);
REQUIRE(h.in_fallback(0));
REQUIRE(h.engine.pool.state_values[0] == Approx(initial));
h.sample(0, 2.0, /*commit=*/true);
REQUIRE(h.engine.pool.state_values[0] == Approx(initial));
// Once the same owner publishes healthy samples, its state resumes from
// the last value the listener actually heard rather than jumping ahead.
h.eval_ok("(a1 (phasor 1 :id \"frozen-phase\"))");
h.sample(0, 3.0, /*commit=*/true);
REQUIRE_FALSE(h.in_fallback(0));
REQUIRE(h.engine.pool.state_values[0] > initial);
}
TEST_CASE("Mode B legacy: non-finite squashes to zero per node",
"[failure-mode][zero-squash]") {
Harness h;
set_failure_mode(FailureMode::ZeroSquash);
h.eval_ok("(a1 0.75)");
h.sample(0, 0.0, /*commit=*/true);
h.eval_ok(OVERFLOW_PROG);
double v = h.sample(0, 1.0);
REQUIRE(v == Approx(0.0)); // squashed, NOT the 0.75 LKG
REQUIRE_FALSE(h.in_fallback(0)); // no fallback recorded in Mode B
}
TEST_CASE("Batch execution honours the failure mode",
"[failure-mode][batch]") {
Harness h;
h.engine.pool.allocate_batch_workspace();
h.eval_ok("(a1 0.75)");
h.sample(0, 0.0, /*commit=*/true);
h.eval_ok(OVERFLOW_PROG);
double cell_values[MAX_CELLS];
h.engine.cells.snapshot_values(cell_values, MAX_CELLS);
double hw_inputs[32] = {};
const double t_array[3] = {0.0, 1.0, 2.0};
double out_buf[3] = {};
SECTION("Mode A: per-sample LKG substitution + fallback mask") {
execute_batch(h.engine.pool, t_array, 3, cell_values, hw_inputs,
h.engine.cells.data_pool, h.engine.cells.data_offsets,
h.engine.cells.data_lengths, out_buf, 1);
REQUIRE(out_buf[0] == Approx(0.0)); // finite sample untouched
REQUIRE(out_buf[1] == Approx(0.75)); // inf → LKG
REQUIRE(out_buf[2] == Approx(0.75));
REQUIRE(h.in_fallback(0));
}
SECTION("Mode B: per-sample zero squash") {
set_failure_mode(FailureMode::ZeroSquash);
execute_batch(h.engine.pool, t_array, 3, cell_values, hw_inputs,
h.engine.cells.data_pool, h.engine.cells.data_offsets,
h.engine.cells.data_lengths, out_buf, 1);
REQUIRE(out_buf[1] == Approx(0.0));
REQUIRE(out_buf[2] == Approx(0.0));
REQUIRE_FALSE(h.in_fallback(0));
}
}

View file

@ -0,0 +1,369 @@
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include "src/modulisp/lisp/symbol_intern.h"
#include <cmath>
#include <cstring>
#include <string>
using namespace sig;
namespace {
struct Harness {
SignalEngine engine;
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
Harness() {
engine.init_defaults(120.0, 4);
set_failure_mode(FailureMode::LkgFallback);
}
~Harness() { set_failure_mode(FailureMode::LkgFallback); }
EvalResult eval(const std::string& code) {
return eval_cold(code.c_str(), (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);
}
double tick(uint16_t output_index, double t, double dt = 0.001) {
double cells[MAX_CELLS];
double inputs[32] = {};
engine.cells.snapshot_values(cells, MAX_CELLS);
for (double& value : outputs) value = 0.0;
ExecutionContext ctx{t, dt, cells, inputs,
engine.cells.data_pool,
engine.cells.data_offsets,
engine.cells.data_lengths,
engine.pool.prev_output_values,
outputs, workspace};
execute_all_outputs(engine.pool, ctx);
commit_state(engine.pool, workspace);
commit_outputs(engine.pool, outputs);
return outputs[output_index];
}
};
constexpr const char* OVERFLOW = "(a1 (* (* t 1e308) 1e308))";
} // namespace
TEST_CASE("First failure has Error health until a finite root establishes LKG",
"[health][output][lkg]") {
Harness h;
REQUIRE(output_health(h.engine.pool, 0) == OutputHealth::Idle);
h.eval_ok(OVERFLOW);
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE_FALSE(h.engine.pool.outputs[0].has_lkg);
REQUIRE(output_health(h.engine.pool, 0) == OutputHealth::Running);
REQUIRE(h.tick(0, 1.0) == Approx(0.0));
REQUIRE_FALSE(h.engine.pool.outputs[0].has_lkg);
REQUIRE(output_health(h.engine.pool, 0) == OutputHealth::Error);
REQUIRE(h.tick(0, 0.0) == Approx(0.0));
REQUIRE(h.engine.pool.outputs[0].has_lkg);
REQUIRE(output_health(h.engine.pool, 0) == OutputHealth::Running);
REQUIRE(h.tick(0, 1.0) == Approx(0.0));
REQUIRE(output_health(h.engine.pool, 0) == OutputHealth::Fallback);
// Successful replacement clears the superseded program's runtime health
// 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(h.engine.pool.outputs[0].has_lkg);
}
TEST_CASE("Non-finite named-state update holds value and clears its diagnostic on recovery",
"[health][state][runtime]") {
Harness h;
auto& symbols = SymbolIntern::getInstance();
SymbolID state_symbol = symbols.intern(String("health-state"));
h.eval_ok("(define health-rate 1)");
h.eval_ok("(defstate health-state 0 (/ 1 health-rate))");
uint16_t slot = h.engine.cells.cells[state_symbol].data_table_id;
REQUIRE(h.engine.state_sources[slot].state_symbol == state_symbol);
h.tick(0, 0.0);
REQUIRE(h.engine.pool.state_values[slot] == Approx(1.0));
REQUIRE((h.engine.pool.state_update_failure_mask &
((uint64_t)1 << slot)) == 0);
h.eval_ok("(define health-rate 0)");
double held = h.engine.pool.state_values[slot];
h.tick(0, 1.0);
REQUIRE(h.engine.pool.state_values[slot] == Approx(held));
REQUIRE((h.engine.pool.state_update_failure_mask &
((uint64_t)1 << slot)) != 0);
h.eval_ok("(define health-rate 2)");
h.tick(0, 2.0);
REQUIRE(h.engine.pool.state_values[slot] == Approx(0.5));
REQUIRE((h.engine.pool.state_update_failure_mask &
((uint64_t)1 << slot)) == 0);
}
TEST_CASE("Rejected reactive output candidate is attributed and clears on repair",
"[health][reactive][output]") {
Harness h;
auto& symbols = SymbolIntern::getInstance();
SymbolID dep = symbols.intern(String("health-reactive-dep"));
h.eval_ok("(define health-reactive-dep 1)");
h.eval_ok("(a1 (+ health-reactive-dep 0.25))");
uint16_t old_root = h.engine.pool.outputs[0].root_node;
REQUIRE(h.tick(0, 0.0) == Approx(1.25));
h.eval_ok("(defn health-reactive-dep [x] x)");
const ActiveCompileDiagnostic& active =
h.engine.output_compile_diagnostics[0];
REQUIRE(active.active);
REQUIRE(active.triggered_by == dep);
REQUIRE(active.diagnostic.message != nullptr);
REQUIRE(h.engine.pool.outputs[0].root_node == old_root);
REQUIRE(h.tick(0, 1.0) == Approx(1.25));
h.eval_ok("(define health-reactive-dep 2)");
REQUIRE_FALSE(h.engine.output_compile_diagnostics[0].active);
REQUIRE(h.tick(0, 2.0) == Approx(2.25));
}
TEST_CASE("Rejected reactive state update is attributed and keeps its prior writer",
"[health][reactive][state]") {
Harness h;
auto& symbols = SymbolIntern::getInstance();
SymbolID dep = symbols.intern(String("health-state-dep"));
SymbolID state_symbol = symbols.intern(String("health-reactive-state"));
h.eval_ok("(define health-state-dep 1)");
h.eval_ok("(defstate health-reactive-state 0 (+ health-reactive-state health-state-dep))");
uint16_t slot = h.engine.cells.cells[state_symbol].data_table_id;
uint16_t old_root = h.engine.pool.state_update_roots[slot];
h.tick(0, 0.0);
REQUIRE(h.engine.pool.state_values[slot] == Approx(1.0));
h.eval_ok("(defn health-state-dep [x] x)");
const ActiveCompileDiagnostic& active =
h.engine.state_compile_diagnostics[slot];
REQUIRE(active.active);
REQUIRE(active.triggered_by == dep);
REQUIRE(active.diagnostic.message != nullptr);
REQUIRE(h.engine.pool.state_update_roots[slot] == old_root);
h.tick(0, 1.0);
REQUIRE(h.engine.pool.state_values[slot] == Approx(2.0));
h.eval_ok("(define health-state-dep 2)");
REQUIRE_FALSE(h.engine.state_compile_diagnostics[slot].active);
h.tick(0, 2.0);
REQUIRE(h.engine.pool.state_values[slot] == Approx(4.0));
}
TEST_CASE("Unassign clears runtime and reactive health with the program",
"[health][unassign][transaction]") {
Harness h;
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);
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.output_compile_diagnostics[0].active);
}
TEST_CASE("State compaction remaps failure health and attributed source together",
"[health][state][compaction]") {
Harness h;
auto& symbols = SymbolIntern::getInstance();
SymbolID dropped = symbols.intern(String("health-remap-dropped"));
SymbolID kept = symbols.intern(String("health-remap-kept"));
h.eval_ok("(define health-remap-rate 0)");
h.eval_ok("(defstate health-remap-dropped 0 (+ health-remap-dropped 1))");
h.eval_ok("(defstate health-remap-kept 5 (/ 1 health-remap-rate))");
h.eval_ok("(a1 health-remap-dropped)");
h.eval_ok("(a2 health-remap-kept)");
const uint16_t old_kept_slot = h.engine.cells.cells[kept].data_table_id;
REQUIRE(old_kept_slot == 1);
h.tick(0, 0.0);
REQUIRE((h.engine.pool.state_update_failure_mask &
((uint64_t)1 << old_kept_slot)) != 0);
// Retiring the lower slot compacts the still-failing state to slot zero.
h.eval_ok("(define health-remap-dropped 7)");
REQUIRE(h.engine.cells.cells[dropped].flags == 0);
REQUIRE(h.engine.pool.state_slot_count == 1);
REQUIRE(h.engine.cells.cells[kept].data_table_id == 0);
REQUIRE(h.engine.state_sources[0].state_symbol == kept);
REQUIRE((h.engine.pool.state_update_failure_mask & 1u) != 0);
REQUIRE((h.engine.pool.state_update_failure_mask & ~uint64_t{1}) == 0);
const StateUpdateSource& source = h.engine.state_sources[0];
const char* text = h.engine.arena.read(source.arena_offset);
REQUIRE(text != nullptr);
REQUIRE(std::string(text, source.arena_length) ==
"(/ 1 health-remap-rate)");
h.eval_ok("(define health-remap-rate 2)");
h.tick(1, 1.0);
REQUIRE(h.engine.pool.state_values[0] == Approx(0.5));
REQUIRE(h.engine.pool.state_update_failure_mask == 0);
}
TEST_CASE("Live-edit option reorder, rejected variant change, and reclamation are atomic",
"[health][live-edit][transaction]") {
Harness h;
h.eval_ok("(a1 (live-edit :beta :id \"health-mode\" :options [:alpha :beta]))");
REQUIRE(h.engine.pool.live_slot_count == 1);
h.engine.pool.set_live_slot_value("health-mode", 1.0);
REQUIRE(h.tick(0, 0.0) == Approx(1.0));
h.eval_ok("(a1 (live-edit :alpha :id \"health-mode\" :options [:beta :alpha]))");
const NodePool::LiveSlot before = h.engine.pool.live_slots[0];
REQUIRE(before.variant == NodePool::SlotVariant::Keyword);
REQUIRE(before.value == Approx(0.0));
REQUIRE(std::string(before.options[0]) == ":beta");
EvalResult rejected = h.eval(
"(a1 (live-edit 0.5 :id \"health-mode\" :min 0 :max 1) 99)");
REQUIRE(rejected.kind == EvalResult::Error);
REQUIRE(std::memcmp(&h.engine.pool.live_slots[0], &before,
sizeof(before)) == 0);
REQUIRE(h.tick(0, 1.0) == Approx(0.0));
h.eval_ok("(unassign a1)");
REQUIRE(h.engine.pool.live_slot_count == 0);
h.eval_ok("(a1 (live-edit 0.25 :id \"health-mode\" :min 0 :max 1))");
REQUIRE(h.engine.pool.live_slot_count == 1);
REQUIRE(h.engine.pool.live_slots[0].variant ==
NodePool::SlotVariant::Numeric);
REQUIRE(h.engine.pool.live_slots[0].value == Approx(0.25));
}
TEST_CASE("One dependency mutation rejects every affected consumer independently",
"[health][reactive][multi-consumer]") {
Harness h;
h.eval_ok("(define health-shared-dep 2)");
h.eval_ok("(a1 (+ health-shared-dep 1))");
h.eval_ok("(a2 (* health-shared-dep 3))");
const uint16_t root_a1 = h.engine.pool.outputs[0].root_node;
const uint16_t root_a2 = h.engine.pool.outputs[1].root_node;
REQUIRE(h.tick(0, 0.0) == Approx(3.0));
REQUIRE(h.outputs[1] == Approx(6.0));
h.eval_ok("(defn health-shared-dep [x] x)");
REQUIRE(h.engine.output_compile_diagnostics[0].active);
REQUIRE(h.engine.output_compile_diagnostics[1].active);
REQUIRE(h.engine.pool.outputs[0].root_node == root_a1);
REQUIRE(h.engine.pool.outputs[1].root_node == root_a2);
REQUIRE(h.tick(0, 1.0) == Approx(3.0));
REQUIRE(h.outputs[1] == Approx(6.0));
}
TEST_CASE("Synth-control reactive slots retain LKG and follow artifact lifecycle",
"[health][reactive][synth-control]") {
Harness h;
SymbolID cause = SymbolIntern::getInstance().intern(
String("health-synth-dep"));
h.eval_ok("(define health-synth-dep 100)");
h.eval_ok("(synth \"osc/sine\" :name \"diag-a\" :freq health-synth-dep :amp health-synth-dep)");
h.eval_ok("(synth \"osc/sine\" :name \"diag-b\" :freq health-synth-dep :amp health-synth-dep)");
REQUIRE(h.engine.synth_graph.control_count() == 4);
uint16_t old_roots[MAX_SYNTH_CONTROLS] = {};
for (uint16_t i = 0; i < 4; ++i) {
SynthControlChannel& control = h.engine.synth_graph.controls[i];
old_roots[i] = control.root_node;
control.lkg_value = 10.0 + i;
control.has_lkg = true;
}
h.eval_ok("(defn health-synth-dep [x] x)");
for (uint16_t i = 0; i < 4; ++i) {
const SynthControlChannel& control = h.engine.synth_graph.controls[i];
REQUIRE(control.root_node == old_roots[i]);
REQUIRE(control.has_lkg);
REQUIRE(control.lkg_value == Approx(10.0 + i));
REQUIRE(control.compile_diagnostic.active());
REQUIRE(control.compile_diagnostic.triggered_by == cause);
REQUIRE(control.compile_diagnostic.message != nullptr);
}
const SynthDeclaration* first_owner =
h.engine.synth_graph.declaration_for_control(0);
REQUIRE(first_owner != nullptr);
REQUIRE(std::string(first_owner->identity) == "diag-a");
const NodeDefParam* first_parameter =
h.engine.synth_graph.parameter_for_control(0);
const NodeDefParam* second_parameter =
h.engine.synth_graph.parameter_for_control(1);
REQUIRE(first_parameter != nullptr);
REQUIRE(second_parameter != nullptr);
REQUIRE(std::string(first_parameter->name) == "freq");
REQUIRE(std::string(second_parameter->name) == "amp");
const SynthDeclaration* third_owner =
h.engine.synth_graph.declaration_for_control(2);
REQUIRE(third_owner != nullptr);
REQUIRE(std::string(third_owner->identity) == "diag-b");
// Direct replacement clears only that declaration's subjects. Dense
// artifact order moves the surviving failed declaration ahead of it.
h.eval_ok("(synth \"osc/sine\" :name \"diag-a\" :freq 220 :amp 0.2)");
REQUIRE(h.engine.synth_graph.control_count() == 4);
first_owner = h.engine.synth_graph.declaration_for_control(0);
REQUIRE(first_owner != nullptr);
REQUIRE(std::string(first_owner->identity) == "diag-b");
REQUIRE(h.engine.synth_graph.controls[0].compile_diagnostic.active());
REQUIRE(h.engine.synth_graph.controls[1].compile_diagnostic.active());
third_owner = h.engine.synth_graph.declaration_for_control(2);
REQUIRE(third_owner != nullptr);
REQUIRE(std::string(third_owner->identity) == "diag-a");
REQUIRE_FALSE(h.engine.synth_graph.controls[2].compile_diagnostic.active());
REQUIRE_FALSE(h.engine.synth_graph.controls[3].compile_diagnostic.active());
h.eval_ok("(define health-synth-dep 200)");
REQUIRE_FALSE(h.engine.synth_graph.controls[0].compile_diagnostic.active());
REQUIRE_FALSE(h.engine.synth_graph.controls[1].compile_diagnostic.active());
// Removing one optional control removes its diagnostic slot; full clear
// removes every synth-control subject.
h.eval_ok("(define health-synth-amp 0.5)");
h.eval_ok("(synth \"osc/sine\" :name \"diag-b\" :freq 330 :amp health-synth-amp)");
h.eval_ok("(defn health-synth-amp [x] x)");
REQUIRE(h.engine.synth_graph.controls[3].compile_diagnostic.active());
h.eval_ok("(synth \"osc/sine\" :name \"diag-b\" :freq 330)");
REQUIRE(h.engine.synth_graph.control_count() == 3);
for (uint16_t i = 0; i < 3; ++i)
REQUIRE_FALSE(h.engine.synth_graph.controls[i].compile_diagnostic.active());
h.eval_ok("(define health-synth-clear 440)");
h.eval_ok("(synth \"osc/sine\" :name \"diag-clear\" :freq health-synth-clear)");
h.eval_ok("(defn health-synth-clear [x] x)");
REQUIRE(h.engine.synth_graph.controls[3].compile_diagnostic.active());
h.eval_ok("(useq-clear)");
REQUIRE(h.engine.synth_graph.control_count() == 0);
}

View file

@ -0,0 +1,340 @@
// Live-edit state identity and slot management tests.
//
// Covers: live-edit slot allocation, value clamping, duplicate :id detection,
// cross-output :id uniqueness, keyword validation, MAX_LIVE_SLOTS cap,
// eager-consume head rejection, defstate :initial rejection, and
// dead-slot warning.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/cold_eval.h"
#include "src/signal_engine/graph_builder.h"
#include "src/signal_engine/executor.h"
#include <cstring>
#include <cmath>
// ── Test harness ────────────────────────────────────────────────────────────
struct LiveEditHarness {
sig::SignalEngine engine;
LiveEditHarness() {
sig::GraphBuilder::init_symbols();
engine.init_defaults();
}
sig::EvalResult eval(const char* code) {
return sig::eval_cold(code, (uint32_t)strlen(code), engine);
}
double tick(double t) {
double output_values[sig::MAX_OUTPUTS] = {};
double node_values[sig::MAX_TOTAL_NODES];
double cell_values[sig::MAX_CELLS];
engine.cells.snapshot_values(cell_values, sig::MAX_CELLS);
double hw_inputs[32] = {};
sig::ExecutionContext ctx;
ctx.t = t;
ctx.dt = 0.001;
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 = output_values;
ctx.workspace = node_values;
sig::execute_all_outputs(engine.pool, ctx);
return output_values[0]; // a1
}
};
// ── Basic slot allocation ───────────────────────────────────────────────────
TEST_CASE("live-edit allocates a slot and returns seed value", "[live-edit]")
{
LiveEditHarness h;
auto r = h.eval("(a1 (live-edit 0.5 :id \"x\" :min 0 :max 1))");
REQUIRE(r.kind != sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slot_count == 1);
REQUIRE(std::string(h.engine.pool.live_slots[0].id) == "x");
REQUIRE(h.engine.pool.live_slots[0].value == Approx(0.5));
double v = h.tick(0.0);
REQUIRE(v == Approx(0.5));
}
TEST_CASE("set_live_slot_value clamps to [min,max]", "[live-edit]")
{
LiveEditHarness h;
h.eval("(a1 (live-edit 0.5 :id \"x\" :min 0 :max 1))");
h.engine.pool.set_live_slot_value("x", 2.0);
REQUIRE(h.engine.pool.live_slots[0].value == Approx(1.0));
h.engine.pool.set_live_slot_value("x", -1.0);
REQUIRE(h.engine.pool.live_slots[0].value == Approx(0.0));
}
TEST_CASE("set_live_slot_value ignores unknown ID", "[live-edit]")
{
LiveEditHarness h;
h.eval("(a1 (live-edit 0.5 :id \"x\" :min 0 :max 1))");
// Should not crash or modify any slot
h.engine.pool.set_live_slot_value("nonexistent", 0.7);
REQUIRE(h.engine.pool.live_slots[0].value == Approx(0.5));
}
TEST_CASE("boolean live-edit preserves its variant and coerces writes", "[live-edit][variant]")
{
LiveEditHarness h;
auto r = h.eval("(a1 (live-edit true :id \"gate\"))");
REQUIRE(r.kind != sig::EvalResult::Error);
const auto& slot = h.engine.pool.live_slots[0];
REQUIRE(slot.variant == sig::NodePool::SlotVariant::Boolean);
REQUIRE(slot.seed == Approx(1.0));
REQUIRE(h.tick(0.0) == Approx(1.0));
h.engine.pool.set_live_slot_value("gate", 0.0);
REQUIRE(h.tick(0.0) == Approx(0.0));
h.engine.pool.set_live_slot_value("gate", -9.0);
REQUIRE(h.tick(0.0) == Approx(1.0));
}
TEST_CASE("keyword live-edit preserves options and validates writes", "[live-edit][variant]")
{
LiveEditHarness h;
auto r = h.eval(
"(a1 (live-edit :up :id \"direction\" :options [:left :up :right]))");
REQUIRE(r.kind != sig::EvalResult::Error);
const auto& slot = h.engine.pool.live_slots[0];
REQUIRE(slot.variant == sig::NodePool::SlotVariant::Keyword);
REQUIRE(slot.options_count == 3);
REQUIRE(std::string(slot.options[0]) == ":left");
REQUIRE(std::string(slot.options[1]) == ":up");
REQUIRE(std::string(slot.options[2]) == ":right");
REQUIRE(slot.seed == Approx(1.0));
REQUIRE(h.tick(0.0) == Approx(1.0));
h.engine.pool.set_live_slot_value("direction", 2.0);
REQUIRE(h.tick(0.0) == Approx(2.0));
h.engine.pool.set_live_slot_value("direction", 9.0);
REQUIRE(h.tick(0.0) == Approx(2.0));
}
TEST_CASE("live-edit step and precision metadata reach the runtime slot", "[live-edit][metadata]")
{
LiveEditHarness h;
auto r = h.eval(
"(a1 (live-edit 0.5 :id \"fine\" :min 0 :max 1 "
":name \"Fine control\" :step 0.01 :precision 2))");
REQUIRE(r.kind != sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slots[0].step == Approx(0.01));
REQUIRE(h.engine.pool.live_slots[0].precision == 2);
}
TEST_CASE("keyword live-edit reorders options without changing the selected keyword",
"[live-edit][variant][recompile]")
{
LiveEditHarness h;
REQUIRE(h.eval(
"(a1 (live-edit :up :id \"direction\" :options [:up :down]))").kind !=
sig::EvalResult::Error);
h.engine.pool.set_live_slot_value("direction", 1.0); // :down
REQUIRE(h.eval(
"(a1 (live-edit :up :id \"direction\" :options [:down :up]))").kind !=
sig::EvalResult::Error);
const auto& slot = h.engine.pool.live_slots[0];
REQUIRE(std::string(slot.options[(int)slot.value]) == ":down");
REQUIRE(slot.value == Approx(0.0));
}
TEST_CASE("keyword live-edit without options is repaired to a singleton", "[live-edit][variant]")
{
LiveEditHarness h;
auto r = h.eval("(a1 (live-edit :solo :id \"mode\"))");
REQUIRE(r.kind != sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slots[0].options_count == 1);
REQUIRE(std::string(h.engine.pool.live_slots[0].options[0]) == ":solo");
REQUIRE(h.engine.pool.live_slots[0].value == Approx(0.0));
}
// ── Cross-output duplicate :id detection (useq-ef7) ────────────────────────
TEST_CASE("duplicate :id in same output is rejected", "[live-edit][ef7]")
{
LiveEditHarness h;
auto r = h.eval(
"(a1 (+ (live-edit 0.5 :id \"dup\" :min 0 :max 1)"
" (live-edit 0.3 :id \"dup\" :min 0 :max 1)))");
REQUIRE(r.kind == sig::EvalResult::Error);
}
TEST_CASE("cross-output duplicate :id is rejected", "[live-edit][ef7]")
{
LiveEditHarness h;
// First output succeeds
auto r1 = h.eval("(a1 (live-edit 0.5 :id \"shared\" :min 0 :max 1))");
REQUIRE(r1.kind != sig::EvalResult::Error);
// Second output with same :id in same eval batch should fail.
// We need both in the same eval_cold call for the cross-output check.
LiveEditHarness h2;
auto r2 = h2.eval(
"(a1 (live-edit 0.5 :id \"x\" :min 0 :max 1))"
"(a2 (live-edit 0.5 :id \"x\" :min 0 :max 1))");
REQUIRE(r2.kind == sig::EvalResult::Error);
}
TEST_CASE("cross-output duplicate :id is rejected across separate eval calls",
"[live-edit][ownership]")
{
LiveEditHarness h;
auto first = h.eval("(a1 (live-edit 0.5 :id \"owned\" :min 0 :max 1))");
REQUIRE(first.kind != sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slot_count == 1);
auto duplicate = h.eval("(a2 (live-edit 0.25 :id \"owned\" :min 0 :max 1))");
REQUIRE(duplicate.kind == sig::EvalResult::Error);
REQUIRE(duplicate.diagnostic_count > 0);
REQUIRE(std::string(duplicate.diagnostics[0].message).find("another signal") !=
std::string::npos);
REQUIRE(h.engine.pool.outputs[1].root_node == sig::NODE_NONE);
REQUIRE(h.engine.pool.live_slot_count == 1);
REQUIRE(h.tick(0.0) == Approx(0.5));
}
TEST_CASE("different :ids across outputs are allowed", "[live-edit][ef7]")
{
LiveEditHarness h;
auto r = h.eval(
"(a1 (live-edit 0.5 :id \"knob1\" :min 0 :max 1))"
"(a2 (live-edit 0.3 :id \"knob2\" :min 0 :max 1))");
REQUIRE(r.kind != sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slot_count == 2);
}
// ── MAX_LIVE_SLOTS bump (useq-eh9) ─────────────────────────────────────────
TEST_CASE("MAX_LIVE_SLOTS is 256", "[live-edit][eh9]")
{
REQUIRE(sig::MAX_LIVE_SLOTS == 256);
}
TEST_CASE("replacing one output with fresh live-edit IDs reclaims old slots",
"[live-edit][reclaim]")
{
LiveEditHarness h;
for (size_t i = 0; i < sig::MAX_LIVE_SLOTS + 32; i++) {
std::string code = "(a1 (live-edit 0.5 :id \"knob-" +
std::to_string(i) + "\" :min 0 :max 1))";
auto r = h.eval(code.c_str());
INFO("iteration " << i);
REQUIRE(r.kind != sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slot_count == 1);
REQUIRE(std::string(h.engine.pool.live_slots[0].id) ==
"knob-" + std::to_string(i));
}
REQUIRE(h.tick(0.0) == Approx(0.5));
}
TEST_CASE("rejected live-edit replacement restores slot metadata and value",
"[live-edit][rollback]")
{
LiveEditHarness h;
REQUIRE(h.eval("(a1 (live-edit 0.5 :id \"stable\" :min 0 :max 1))").kind !=
sig::EvalResult::Error);
h.engine.pool.set_live_slot_value("stable", 0.8);
auto rejected = h.eval(
"(a1 (+ (live-edit 5 :id \"stable\" :min 4 :max 6)"
" (live-edit 5 :id \"stable\" :min 4 :max 6)))");
REQUIRE(rejected.kind == sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slot_count == 1);
REQUIRE(h.engine.pool.live_slots[0].min_val == Approx(0.0));
REQUIRE(h.engine.pool.live_slots[0].max_val == Approx(1.0));
REQUIRE(h.engine.pool.live_slots[0].seed == Approx(0.5));
REQUIRE(h.engine.pool.live_slots[0].value == Approx(0.8));
REQUIRE(h.tick(0.0) == Approx(0.8));
}
// ── Warning #3: slot allocated but never read (useq-ijk) ───────────────────
// TODO: Full cross-output dead-slot detection requires post-compilation analysis
// across all outputs. This test covers the within-single-output case where the
// live-edit value is discarded by the enclosing form.
TEST_CASE("warning emitted when live-edit slot is unused in output", "[live-edit][ijk]")
{
// This test checks the within-single-output dead-slot warning.
// (do (live-edit ...) 1.0) — live-edit is allocated but discarded.
LiveEditHarness h;
auto r = h.eval("(a1 (do (live-edit 0.5 :id \"unused\" :min 0 :max 1) 1.0))");
// Compilation should succeed (it's a warning, not an error)
REQUIRE(r.kind != sig::EvalResult::Error);
// The warning is emitted during compilation, but successful publication
// immediately reclaims the unreachable slot.
REQUIRE(h.engine.pool.live_slot_count == 0);
// The output value should be 1.0 (the last form in do)
double v = h.tick(0.0);
REQUIRE(v == Approx(1.0));
}
// ── Eager-consume head rejection (useq-tz9) ────────────────────────────────
TEST_CASE("live-edit rejected as argument of set-bpm", "[live-edit][tz9]")
{
LiveEditHarness h;
auto r = h.eval("(set-bpm (live-edit 120 :id \"bpm\" :min 60 :max 240))");
REQUIRE(r.kind == sig::EvalResult::Error);
// Check that the error message mentions live-edit
REQUIRE(r.diagnostic_count > 0);
REQUIRE(std::string(r.diagnostics[0].message).find("live-edit") != std::string::npos);
}
TEST_CASE("live-edit rejected as argument of set-time-sig", "[live-edit][tz9]")
{
LiveEditHarness h;
auto r = h.eval("(set-time-sig (live-edit 4 :id \"ts\" :min 2 :max 8))");
REQUIRE(r.kind == sig::EvalResult::Error);
REQUIRE(r.diagnostic_count > 0);
REQUIRE(std::string(r.diagnostics[0].message).find("live-edit") != std::string::npos);
}
TEST_CASE("non-live-edit args to set-bpm still work", "[live-edit][tz9]")
{
LiveEditHarness h;
auto r = h.eval("(set-bpm 140)");
REQUIRE(r.kind != sig::EvalResult::Error);
}
// ── defstate :initial rejection (useq-726) ──────────────────────────────────
TEST_CASE("live-edit rejected as defstate initial value", "[live-edit][726]")
{
LiveEditHarness h;
auto r = h.eval("(defstate mystate (live-edit 0 :id \"init\" :min 0 :max 10) (+ mystate 1))");
REQUIRE(r.kind == sig::EvalResult::Error);
REQUIRE(r.diagnostic_count > 0);
REQUIRE(std::string(r.diagnostics[0].message).find("live-edit") != std::string::npos);
}
TEST_CASE("defstate with normal initial value still works", "[live-edit][726]")
{
LiveEditHarness h;
auto r = h.eval("(defstate counter 0 (+ counter 1))");
REQUIRE(r.kind != sig::EvalResult::Error);
}
TEST_CASE("live-edit in defstate update expression is allowed", "[live-edit][726]")
{
LiveEditHarness h;
auto r = h.eval("(defstate x 0 (live-edit 0.5 :id \"update\" :min 0 :max 1))");
REQUIRE(r.kind != sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slot_count == 1);
}

View file

@ -0,0 +1,81 @@
// Adversarial fixed-stack traversal tests for NodePool reachability.
//
// A high-sharing DAG can have many more incoming edges than nodes. A traversal
// that marks nodes only when popped may enqueue the same shared child hundreds
// of times, fill its MAX_TOTAL_NODES stack, and silently drop an undiscovered
// dependency. Mark-on-discovery keeps the stack bounded by unique nodes.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/node_pool.h"
using namespace sig;
namespace {
void build_duplicate_push_adversary(NodePool& pool) {
pool.reset();
Node leaf;
leaf.op = NodeOp::Const;
leaf.flags = FLAG_TIME_INVARIANT;
leaf.imm = 0.0;
pool.nodes[0] = leaf;
// input_c is visited first by the LIFO walk, so the traversal descends the
// entire chain while two duplicate references to node 0 accumulate at
// every level. The old mark-on-pop traversal filled its fixed stack around
// half way down and dropped the next chain node.
for (uint16_t i = 1; i < MAX_TOTAL_NODES; i++) {
Node node;
node.op = NodeOp::Select;
node.input_a = 0;
node.input_b = 0;
node.input_c = (uint16_t)(i - 1);
pool.nodes[i] = node;
}
pool.node_count = MAX_TOTAL_NODES;
pool.outputs[0].root_node = (uint16_t)(MAX_TOTAL_NODES - 1);
pool.outputs[0].valid = true;
}
void require_complete_forward_order(const NodePool& pool) {
REQUIRE(pool.exec_count == MAX_TOTAL_NODES);
for (uint16_t i = 0; i < MAX_TOTAL_NODES; i++) {
INFO("execution position " << i);
REQUIRE(pool.exec_order[i] == i);
}
}
} // namespace
TEST_CASE("execution-order traversal does not drop a high-sharing dependency",
"[node_pool][traversal][adversarial]") {
NodePool pool;
build_duplicate_push_adversary(pool);
pool.rebuild_execution_order();
require_complete_forward_order(pool);
}
TEST_CASE("GC preserves a full high-sharing reachable DAG",
"[node_pool][gc][adversarial]") {
NodePool pool;
build_duplicate_push_adversary(pool);
pool.gc_unreachable_nodes();
REQUIRE(pool.node_count == MAX_TOTAL_NODES);
REQUIRE(pool.outputs[0].root_node == MAX_TOTAL_NODES - 1);
for (uint16_t i = 1; i < MAX_TOTAL_NODES; i++) {
INFO("node " << i);
REQUIRE(pool.nodes[i].input_a == 0);
REQUIRE(pool.nodes[i].input_b == 0);
REQUIRE(pool.nodes[i].input_c == i - 1);
}
pool.rebuild_execution_order();
require_complete_forward_order(pool);
}

View file

@ -0,0 +1,168 @@
// Output Classification golden tests
// Validates that the compiler correctly classifies outputs as
// Pure, InputDep, or Stateful per visualisation.md §4.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include <cstring>
using namespace sig;
// Helper: compile an expression into output a1, return its classification
static OutputClass classify(const char* expr) {
SignalEngine engine;
engine.init_defaults();
char wrapped[4096];
snprintf(wrapped, sizeof(wrapped), "(a1 %s)", expr);
EvalResult r = eval_cold(wrapped, (uint32_t)strlen(wrapped), engine);
REQUIRE(r.kind != EvalResult::Error);
return engine.pool.output_class[0];
}
// Helper: classify and also return the input mask
static std::pair<OutputClass, uint32_t> classify_with_mask(const char* expr) {
SignalEngine engine;
engine.init_defaults();
char wrapped[4096];
snprintf(wrapped, sizeof(wrapped), "(a1 %s)", expr);
EvalResult r = eval_cold(wrapped, (uint32_t)strlen(wrapped), engine);
REQUIRE(r.kind != EvalResult::Error);
return { engine.pool.output_class[0], engine.pool.output_input_mask[0] };
}
// ── Pure expressions ────────────────────────────────────────────────────────
TEST_CASE("Classification: constant is pure", "[classification]") {
REQUIRE(classify("0.5") == OutputClass::Pure);
}
TEST_CASE("Classification: arithmetic on constants is pure", "[classification]") {
REQUIRE(classify("(+ 1 2)") == OutputClass::Pure);
}
TEST_CASE("Classification: function of t is pure", "[classification]") {
REQUIRE(classify("(sin (* t 2))") == OutputClass::Pure);
}
TEST_CASE("Classification: sin of beat (time-derived) is pure", "[classification]") {
REQUIRE(classify("(sin beat)") == OutputClass::Pure);
}
TEST_CASE("Classification: nested time transforms are pure", "[classification]") {
REQUIRE(classify("(fast 2 (sin beat))") == OutputClass::Pure);
}
TEST_CASE("Classification: seq of constants is pure", "[classification]") {
REQUIRE(classify("(seq [0.2 0.5 0.8])") == OutputClass::Pure);
}
TEST_CASE("Classification: euclid pattern is pure", "[classification]") {
REQUIRE(classify("(euclid 3 8 beat)") == OutputClass::Pure);
}
TEST_CASE("Classification: step with data table is pure", "[classification]") {
REQUIRE(classify("(step [0.2 0.5 0.8])") == OutputClass::Pure);
}
TEST_CASE("Classification: if with pure branches is pure", "[classification]") {
REQUIRE(classify("(if (> beat 0.5) 1.0 0.0)") == OutputClass::Pure);
}
// ── Input-dependent expressions ─────────────────────────────────────────────
TEST_CASE("Classification: hardware input is input-dep", "[classification]") {
auto [cls, mask] = classify_with_mask("ain1");
REQUIRE(cls == OutputClass::InputDep);
REQUIRE((mask & (1u << 8)) != 0); // ain1 = INP_AI1 (input channel 8)
}
TEST_CASE("Classification: expression using hardware input is input-dep", "[classification]") {
auto [cls, mask] = classify_with_mask("(* ain1 (sin beat))");
REQUIRE(cls == OutputClass::InputDep);
}
TEST_CASE("Classification: multiple inputs tracked in mask", "[classification]") {
auto [cls, mask] = classify_with_mask("(+ ain1 ain2)");
REQUIRE(cls == OutputClass::InputDep);
REQUIRE((mask & 0x300u) == 0x300u); // ain1=INP_AI1(ch8) + ain2=INP_AI2(ch9) → bits 8,9
}
TEST_CASE("Classification: if with input condition is input-dep", "[classification]") {
auto [cls, mask] = classify_with_mask("(if (> ain1 0.5) 1.0 0.0)");
REQUIRE(cls == OutputClass::InputDep);
}
// ── Stateful expressions ────────────────────────────────────────────────────
TEST_CASE("Classification: integrate is stateful", "[classification]") {
REQUIRE(classify("(integrate 0.1)") == OutputClass::Stateful);
}
TEST_CASE("Classification: expression using prev is stateful", "[classification]") {
REQUIRE(classify("(prev a1)") == OutputClass::Stateful);
}
// ── Multi-output classification ─────────────────────────────────────────────
TEST_CASE("Classification: multiple outputs classified independently", "[classification]") {
SignalEngine engine;
engine.init_defaults();
// a1 = pure (sin of t)
const char* src1 = "(a1 (sin beat))";
eval_cold(src1, (uint32_t)strlen(src1), engine);
// a2 = input-dep
const char* src2 = "(a2 ain1)";
eval_cold(src2, (uint32_t)strlen(src2), engine);
// a3 = stateful
const char* src3 = "(a3 (integrate 0.01))";
eval_cold(src3, (uint32_t)strlen(src3), engine);
REQUIRE(engine.pool.output_class[0] == OutputClass::Pure);
REQUIRE(engine.pool.output_class[1] == OutputClass::InputDep);
REQUIRE(engine.pool.output_class[2] == OutputClass::Stateful);
}
// ── Inactive outputs ────────────────────────────────────────────────────────
TEST_CASE("Classification: unassigned output is inactive", "[classification]") {
SignalEngine engine;
engine.init_defaults();
// Only assign a1, leave a2 unassigned
const char* src = "(a1 0.5)";
eval_cold(src, (uint32_t)strlen(src), engine);
REQUIRE(engine.pool.output_class[0] == OutputClass::Pure);
REQUIRE(engine.pool.output_class[1] == OutputClass::Inactive);
}
// ── Reclassification on recompile ───────────────────────────────────────────
TEST_CASE("Classification: reclassifies when expression changes", "[classification]") {
SignalEngine engine;
engine.init_defaults();
// Start pure
const char* src1 = "(a1 (sin beat))";
eval_cold(src1, (uint32_t)strlen(src1), engine);
REQUIRE(engine.pool.output_class[0] == OutputClass::Pure);
// Change to input-dep
const char* src2 = "(a1 ain1)";
eval_cold(src2, (uint32_t)strlen(src2), engine);
REQUIRE(engine.pool.output_class[0] == OutputClass::InputDep);
// Change to stateful
const char* src3 = "(a1 (integrate 0.1))";
eval_cold(src3, (uint32_t)strlen(src3), engine);
REQUIRE(engine.pool.output_class[0] == OutputClass::Stateful);
}

View file

@ -0,0 +1,598 @@
// Resource-reclamation regression tests (release audit F3/F4/F5/F8).
//
// Live coding means recompiling the same programs over and over. These tests
// lock in the invariant that recompiles RECLAIM (or reuse) every fixed-pool
// resource they consume — state slots, data tables, graph nodes, and the
// source arena — so that ordinary performance workflows can never exhaust a
// pool and silently kill or revert an output:
//
// F3: anonymous stateful UGens must reuse state slots across recompiles
// (structural identity via the StateResourceRegistry), and vector
// literals must reuse data tables (content-interning in CellStore).
// F4: on_cell_changed must gc unreachable nodes like every sibling
// recompile path, or live cell edits exhaust the node pool.
// F5: source-arena exhaustion must fail the eval with an explicit
// diagnostic instead of installing a graph whose stored source text is
// stale (which silently reverts the output on the next recompile).
// F8: on_cell_changed must refresh the output's dependency list, or an
// output stops reacting to cells introduced by a redefinition.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include <cstdio>
#include <cstring>
#include <string>
using namespace sig;
namespace {
struct ReclaimHarness {
SignalEngine engine;
ReclaimHarness() { engine.init_defaults(120.0, 4); }
EvalResult eval(const std::string& code) {
return eval_cold(code.c_str(), (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);
}
// Execute one sample at t=0 and return the named output's value.
double sample(const char* output_name) {
double cell_values[MAX_CELLS];
engine.cells.snapshot_values(cell_values, MAX_CELLS);
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
ExecutionContext ctx;
ctx.t = 0.0;
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);
SymbolID sym = internSymbol(output_name);
uint16_t idx = GraphBuilder::resolve_output_index(sym);
REQUIRE(idx != NODE_NONE);
return outputs[idx];
}
};
} // namespace
// ============================================================================
// F3: anonymous stateful UGens reuse state slots across recompiles
// ============================================================================
TEST_CASE("Reclaim: 100 re-evals of an anonymous lfo keep state slots bounded",
"[reclaim][state_slots]") {
ReclaimHarness h;
h.eval_ok("(a1 (lfo 2))");
uint16_t slots_after_first = h.engine.pool.state_slot_count;
REQUIRE(slots_after_first >= 1);
for (int i = 0; i < 100; i++) {
EvalResult r = h.eval("(a1 (lfo 2))");
INFO("iteration " << i);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
INFO("diagnostic: "
<< (r.diagnostics[0].message ? r.diagnostics[0].message : ""));
}
REQUIRE(r.kind != EvalResult::Error);
}
// Recompiling the identical program must resolve to the SAME slots via
// the registry's structural key — not allocate a fresh slot per eval.
REQUIRE(h.engine.pool.state_slot_count == slots_after_first);
REQUIRE(h.engine.pool.outputs[0].valid);
}
TEST_CASE("Reclaim: anonymous state value survives re-eval of identical source",
"[reclaim][state_slots]") {
ReclaimHarness h;
h.eval_ok("(a1 (phasor 1))");
REQUIRE(h.engine.pool.state_slot_count >= 1);
// Simulate accumulated phase, then recompile the same program.
h.engine.pool.state_values[0] = 0.625;
h.eval_ok("(a1 (phasor 1))");
// Structural identity: the recompiled graph reads the same slot and the
// accumulated value is preserved (init values are hints, not resets).
REQUIRE(h.engine.pool.state_values[0] == Approx(0.625));
}
TEST_CASE("Reclaim: distinct anonymous UGens across outputs get distinct slots",
"[reclaim][state_slots]") {
ReclaimHarness h;
h.eval_ok("(a1 (phasor 1))");
uint16_t after_a1 = h.engine.pool.state_slot_count;
h.eval_ok("(a2 (phasor 1))");
uint16_t after_a2 = h.engine.pool.state_slot_count;
// Different output => different structural context => independent state.
REQUIRE(after_a2 > after_a1);
// But re-evaluating either output stays bounded.
h.eval_ok("(a1 (phasor 1))");
h.eval_ok("(a2 (phasor 1))");
REQUIRE(h.engine.pool.state_slot_count == after_a2);
}
// ============================================================================
// F3 + F4: cell sweeps through on_cell_changed keep tables and nodes bounded
// ============================================================================
TEST_CASE("Reclaim: 300-step cell sweep keeps data tables and nodes bounded",
"[reclaim][tables][nodes]") {
ReclaimHarness h;
h.eval_ok("(define off 0)");
h.eval_ok("(a1 (+ off (step [1 2 3] beat)))");
uint8_t tables_after_first = h.engine.cells.data_table_count;
uint16_t nodes_after_first = h.engine.pool.node_count;
REQUIRE(tables_after_first >= 1);
for (int i = 1; i <= 300; i++) {
char buf[64];
snprintf(buf, sizeof(buf), "(define off %d)", i);
h.eval_ok(buf);
// Every dependent recompile must reuse the [1 2 3] table
// (content-interning) instead of appending a duplicate.
REQUIRE(h.engine.cells.data_table_count == tables_after_first);
// And on_cell_changed must gc the orphaned old graph. The bound is
// loose (the changed constant makes node counts wobble by a node or
// two) but must not grow linearly with edits — pre-fix this reached
// 434 nodes after 300 edits and 360 (the firmware cap) after ~237.
REQUIRE(h.engine.pool.node_count <= nodes_after_first + 8);
}
// The output still compiles and tracks the swept cell.
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.sample("a1") == Approx(301.0)); // off=300 + step value 1 at t=0
}
TEST_CASE("Reclaim: anonymous UGen output survives repeated dependency edits",
"[reclaim][state_slots][nodes]") {
ReclaimHarness h;
// A stateful output that depends on a cell: every (define rate N)
// triggers an on_cell_changed recompile of a graph with an anonymous lfo.
h.eval_ok("(define rate 1)");
h.eval_ok("(a1 (lfo rate))");
uint16_t slots_after_first = h.engine.pool.state_slot_count;
uint16_t nodes_after_first = h.engine.pool.node_count;
for (int i = 2; i <= 100; i++) {
char buf[64];
snprintf(buf, sizeof(buf), "(define rate %d)", i);
h.eval_ok(buf);
}
REQUIRE(h.engine.pool.state_slot_count == slots_after_first);
REQUIRE(h.engine.pool.node_count <= nodes_after_first + 8);
REQUIRE(h.engine.pool.outputs[0].valid);
}
TEST_CASE("Reclaim: defstate replacement releases named and nested state resources",
"[reclaim][defstate]") {
ReclaimHarness h;
for (int i = 0; i < 48; i++) {
h.eval_ok("(defstate lifecycle-x 0 (+ lifecycle-x (integrate 1)))");
REQUIRE(h.engine.pool.state_slot_count >= 2);
if (i == 0) h.eval_ok("(a1 lifecycle-x)");
char replacement[80];
if ((i & 1) == 0) {
snprintf(replacement, sizeof(replacement),
"(define lifecycle-x %d)", i + 10);
} else {
snprintf(replacement, sizeof(replacement),
"(defs [lifecycle-x %d])", i + 10);
}
h.eval_ok(replacement);
INFO("iteration " << i);
REQUIRE(h.engine.pool.state_slot_count == 0);
REQUIRE(h.engine.registry.entry_count == 0);
REQUIRE(h.sample("a1") == Approx((double)i + 10.0));
}
}
TEST_CASE("Reclaim: state backing retained LKG graph survives failed reactive recompile",
"[reclaim][defstate][lkg]") {
ReclaimHarness h;
h.eval_ok("(defstate retained-x 2 (+ retained-x 1))");
h.eval_ok("(a1 retained-x)");
REQUIRE(h.engine.pool.state_slot_count == 1);
uint16_t old_root = h.engine.pool.outputs[0].root_node;
h.eval_ok("(define retained-x (no-such-function 1))");
REQUIRE(h.engine.pool.outputs[0].root_node == old_root);
REQUIRE(h.engine.pool.state_slot_count == 1);
REQUIRE(h.engine.pool.state_update_roots[0] != NODE_NONE);
REQUIRE(h.sample("a1") == Approx(2.0));
h.eval_ok("(a1 99)");
REQUIRE(h.engine.pool.state_slot_count == 0);
REQUIRE(h.sample("a1") == Approx(99.0));
}
TEST_CASE("Reclaim: state compaction remaps nested registry and live-edit owners",
"[reclaim][defstate][live-edit]") {
ReclaimHarness h;
h.eval_ok("(defstate compact-a 0 (+ compact-a 1))");
h.eval_ok("(a1 compact-a)");
h.eval_ok(
"(defstate compact-b 10 (+ compact-b "
"(integrate (live-edit 1 :id \"compact-rate\" :min 0 :max 2))))");
h.eval_ok("(a2 compact-b)");
REQUIRE(h.engine.pool.state_slot_count == 3);
REQUIRE(h.engine.registry.entry_count == 1);
REQUIRE(h.engine.pool.live_slot_count == 1);
h.eval_ok("(define compact-a 7)");
REQUIRE(h.engine.pool.state_slot_count == 2);
REQUIRE(h.engine.registry.entry_count == 1);
REQUIRE(h.engine.pool.live_slot_count == 1);
REQUIRE(h.engine.cells.cells[internSymbol("compact-b")].data_table_id == 0);
REQUIRE(h.engine.pool.state_owner_context[0] == MAX_OUTPUTS);
REQUIRE(h.engine.pool.state_owner_context[1] == MAX_OUTPUTS);
REQUIRE(h.engine.registry.entries[0].owner_context == MAX_OUTPUTS);
REQUIRE(h.engine.pool.live_slots[0].owner_context == MAX_OUTPUTS);
h.eval_ok(
"(defstate compact-b 10 (+ compact-b "
"(integrate (live-edit 1 :id \"compact-rate\" :min 0 :max 2))))");
REQUIRE(h.engine.pool.state_slot_count == 2);
REQUIRE(h.engine.registry.entry_count == 1);
REQUIRE(h.engine.pool.live_slot_count == 1);
REQUIRE(h.sample("a1") == Approx(7.0));
REQUIRE(h.sample("a2") == Approx(10.0));
}
TEST_CASE("Reclaim: nonnumeric vector define is rejected atomically",
"[reclaim][vectors]") {
ReclaimHarness h;
h.eval_ok("(define lifecycle-v [7 8])");
SymbolID sym = internSymbol("lifecycle-v");
Cell before = h.engine.cells.cells[sym];
uint8_t tables_before = h.engine.cells.data_table_count;
EvalResult rejected = h.eval("(define lifecycle-v [1 nope 2])");
REQUIRE(rejected.kind == EvalResult::Error);
REQUIRE(rejected.diagnostic_count >= 1);
REQUIRE(std::string(rejected.diagnostics[0].message).find("numeric") !=
std::string::npos);
REQUIRE(h.engine.cells.data_table_count == tables_before);
REQUIRE(h.engine.cells.cells[sym].kind == before.kind);
REQUIRE(h.engine.cells.cells[sym].data_table_id == before.data_table_id);
REQUIRE(h.engine.cells.cells[sym].value == Approx(before.value));
uint16_t length = 0;
const double* values = h.engine.cells.get_data_table(before.data_table_id, length);
REQUIRE(values != nullptr);
REQUIRE(length == 2);
REQUIRE(values[0] == Approx(7.0));
REQUIRE(values[1] == Approx(8.0));
}
TEST_CASE("Reclaim: nonnumeric vector in defs preserves previous binding",
"[reclaim][vectors][defs]") {
ReclaimHarness h;
h.eval_ok("(define lifecycle-v2 42)");
SymbolID sym = internSymbol("lifecycle-v2");
Cell before = h.engine.cells.cells[sym];
uint8_t tables_before = h.engine.cells.data_table_count;
EvalResult rejected = h.eval("(defs [lifecycle-v2 [1 nope 2]])");
REQUIRE(rejected.kind == EvalResult::Error);
REQUIRE(h.engine.cells.data_table_count == tables_before);
REQUIRE(h.engine.cells.cells[sym].kind == before.kind);
REQUIRE(h.engine.cells.cells[sym].value == Approx(42.0));
}
TEST_CASE("Reclaim: hundreds of identical output re-evals reuse source arena",
"[reclaim][arena]") {
ReclaimHarness h;
// Long enough that the old append-only behavior exhausts the desktop
// arena during this loop (and exhausts the firmware-sized arena much
// earlier), while still being a small, ordinary output expression.
const std::string code = "(a1 (+ (sin beat) 123456789))";
h.eval_ok(code);
const uint32_t head_after_first = h.engine.arena.write_head;
REQUIRE(head_after_first > 0);
for (int i = 0; i < 600; i++) {
EvalResult r = h.eval(code);
INFO("iteration " << i);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
INFO("diagnostic: "
<< (r.diagnostics[0].message ? r.diagnostics[0].message : ""));
}
REQUIRE(r.kind != EvalResult::Error);
}
REQUIRE(h.engine.arena.write_head == head_after_first);
REQUIRE(h.engine.pool.outputs[0].valid);
}
TEST_CASE("Reclaim: legal longer replacements keep all source owners bounded",
"[reclaim][arena][replacement]") {
ReclaimHarness h;
const std::string callable_short = "(define lifecycle-x (+ 1 2))";
const std::string callable_long =
"(define lifecycle-x (+ 1 2 333333 444444))";
const std::string function_short =
"(defn lifecycle-f [x] (+ x 1))";
const std::string function_long =
"(defn lifecycle-f [x] (+ x 1 222222 333333))";
const std::string state_short =
"(defstate lifecycle-s 0 (+ lifecycle-s 1))";
const std::string state_long =
"(defstate lifecycle-s 0 (+ lifecycle-s 1 222222 333333))";
const std::string output_short =
"(a1 (+ lifecycle-x (lifecycle-f 1)))";
const std::string output_long =
"(a1 (+ lifecycle-x (lifecycle-f 1) 222222 333333))";
auto install = [&](bool longer) {
h.eval_ok(longer ? callable_long : callable_short);
h.eval_ok(longer ? function_long : function_short);
h.eval_ok(longer ? state_long : state_short);
h.eval_ok(longer ? output_long : output_short);
};
install(false);
const uint32_t short_live_bytes = h.engine.arena.write_head;
install(true);
const uint32_t long_live_bytes = h.engine.arena.write_head;
REQUIRE(long_live_bytes > short_live_bytes);
REQUIRE(long_live_bytes < SOURCE_ARENA_SIZE);
// Cumulative replacement text exceeds the desktop arena many times and
// the 4 KiB firmware arena much earlier. N, N+1, and continued reuse all
// remain bounded by the four currently-published sources.
const uint32_t replacements =
static_cast<uint32_t>(SOURCE_ARENA_SIZE / 8 + 1);
for (uint32_t i = 0; i < replacements; i++) {
INFO("replacement " << i);
bool longer = (i & 1u) != 0;
install(longer);
REQUIRE(h.engine.arena.write_head ==
(longer ? long_live_bytes : short_live_bytes));
}
install(false); // N+1 after historical bytes exceed the fixed store.
REQUIRE(h.engine.arena.write_head == short_live_bytes);
REQUIRE(h.engine.pool.outputs[0].valid);
}
TEST_CASE("Reclaim: callable retirement cannot alias a compacted live source",
"[reclaim][arena][ownership]") {
ReclaimHarness h;
h.eval_ok("(define retired (+ 10 20 30))");
h.eval_ok("(a1 (+ 1000 2))");
const uint32_t with_callable = h.engine.arena.write_head;
// Retiring the callable removes its metadata before compaction. A later
// definition must append/recompact, not reuse the stale old offset and
// overwrite a1's now-relocated source.
h.eval_ok("(define retired 7)");
REQUIRE(h.engine.arena.write_head < with_callable);
h.eval_ok("(define retired (+ 1 2 3 4 5))");
REQUIRE(h.sample("a1") == Approx(1002.0));
h.eval_ok("(define unrelated 9)");
REQUIRE(h.sample("a1") == Approx(1002.0));
}
TEST_CASE("Reclaim: mixed owners remain readable after relocation and recompile",
"[reclaim][arena][ownership][reactive][synth]") {
ReclaimHarness h;
h.eval_ok("(define retired-prefix (+ 10 20 30))");
h.eval_ok("(define reactive-source 1)");
h.eval_ok("(defstate mixed-state 0 (+ mixed-state 1))");
h.eval_ok("(a1 (+ reactive-source mixed-state))");
h.eval_ok(
"(synth \"osc/sine\" :name \"mixed-synth\" "
":freq (+ reactive-source beat) :amp (+ 0.1 (* 0.01 bar)))");
const uint32_t before_retirement = h.engine.arena.write_head;
h.eval_ok("(define retired-prefix 7)");
REQUIRE(h.engine.arena.write_head < before_retirement);
auto require_live_region = [&](uint32_t offset, uint32_t length) {
REQUIRE(length > 0);
REQUIRE(offset < h.engine.arena.write_head);
REQUIRE(length <= h.engine.arena.write_head - offset);
REQUIRE(h.engine.arena.read(offset) != nullptr);
};
SymbolID mixed_state = internSymbol("mixed-state");
REQUIRE(h.engine.cells.cells[mixed_state].flags == 0x02);
uint16_t state_slot = h.engine.cells.cells[mixed_state].data_table_id;
REQUIRE(state_slot < h.engine.pool.state_slot_count);
const StateUpdateSource& state_source = h.engine.state_sources[state_slot];
REQUIRE(state_source.has_source);
require_live_region(state_source.arena_offset, state_source.arena_length);
REQUIRE(h.engine.output_sources[0].has_source);
require_live_region(h.engine.output_sources[0].arena_offset,
h.engine.output_sources[0].arena_length);
REQUIRE(h.engine.synth_graph.control_count() == 2);
for (uint16_t i = 0; i < h.engine.synth_graph.control_count(); i++) {
const SynthControlChannel& control = h.engine.synth_graph.controls[i];
require_live_region(control.source_offset, control.source_length);
REQUIRE(control.root_node != NODE_NONE);
REQUIRE(control.root_node < h.engine.pool.node_count);
}
// Relocated output and synth-control source must still drive reactive
// recompilation when their shared dependency changes.
h.eval_ok("(define reactive-source 2)");
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.sample("a1") == Approx(2.0));
REQUIRE(h.engine.synth_graph.control_count() == 2);
for (uint16_t i = 0; i < h.engine.synth_graph.control_count(); i++) {
const SynthControlChannel& control = h.engine.synth_graph.controls[i];
require_live_region(control.source_offset, control.source_length);
REQUIRE(control.root_node != NODE_NONE);
REQUIRE(control.root_node < h.engine.pool.node_count);
}
}
TEST_CASE("Reclaim: do and scope compact between child transactions",
"[reclaim][arena][do][scope][capacity]") {
ReclaimHarness h;
h.eval_ok("(define retired-in-do (+ 1 2 3 4))");
h.engine.arena.write_head = SOURCE_ARENA_SIZE - 2;
h.eval_ok(
"(do (define retired-in-do 7) "
"(define published-in-do (+ 100 20 3)))");
REQUIRE(h.engine.cells.cells[internSymbol("retired-in-do")].kind ==
CellKind::Number);
REQUIRE(h.engine.cells.cells[internSymbol("published-in-do")].kind ==
CellKind::Callable);
REQUIRE(h.engine.arena.write_head < SOURCE_ARENA_SIZE / 4);
h.engine.arena.write_head = SOURCE_ARENA_SIZE - 2;
h.eval_ok(
"(scope (define published-in-do 8) "
"(define published-in-scope (+ 200 30 4)))");
REQUIRE(h.engine.cells.cells[internSymbol("published-in-do")].kind ==
CellKind::Number);
REQUIRE(h.engine.cells.cells[internSymbol("published-in-scope")].kind ==
CellKind::Callable);
REQUIRE(h.engine.arena.write_head < SOURCE_ARENA_SIZE / 4);
// Both newly stored callables remain usable after a further compaction.
h.eval_ok("(a1 (+ published-in-scope 1))");
REQUIRE(h.sample("a1") == Approx(235.0));
}
// ============================================================================
// F5: source-arena exhaustion fails loudly instead of reverting outputs
// ============================================================================
TEST_CASE("Reclaim: arena exhaustion fails the eval and never reverts the output",
"[reclaim][arena]") {
ReclaimHarness h;
h.eval_ok("(define off 1)");
h.eval_ok("(a1 (+ off 111))");
REQUIRE(h.sample("a1") == Approx(112.0));
// Exhaust the arena, then try to install a new program.
h.engine.arena.write_head = SOURCE_ARENA_SIZE - 4;
EvalResult r = h.eval("(a1 (+ off 9999))");
// Must be an explicit error, not a silent success...
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count >= 1);
REQUIRE(r.diagnostics[0].message != nullptr);
REQUIRE(std::string(r.diagnostics[0].message).find("storage") !=
std::string::npos);
// ...and the OLD program must still be the active one.
REQUIRE(h.sample("a1") == Approx(112.0));
// The killer pre-fix symptom: a later dependency change recompiled the
// output from stale source text, silently REVERTING it to the rejected
// program's predecessor with mismatched semantics. Now the old program
// is still the honestly-active one and follows its dependencies.
h.eval_ok("(define off 2)");
REQUIRE(h.sample("a1") == Approx(113.0)); // old program, new off
REQUIRE(h.engine.pool.outputs[0].valid);
}
TEST_CASE("Reclaim: rejected shorter output candidate preserves recompilation source",
"[reclaim][arena][rollback]") {
ReclaimHarness h;
h.eval_ok("(define lifecycle-off 1)");
h.eval_ok("(a1 (+ lifecycle-off 100))");
REQUIRE(h.sample("a1") == Approx(101.0));
EvalResult rejected = h.eval("(a1 nope)");
REQUIRE(rejected.kind == EvalResult::Error);
REQUIRE(h.sample("a1") == Approx(101.0));
h.eval_ok("(define lifecycle-off 2)");
REQUIRE(h.sample("a1") == Approx(102.0));
}
TEST_CASE("Reclaim: arena exhaustion fails define/defn/defstate loudly",
"[reclaim][arena]") {
ReclaimHarness h;
h.eval_ok("(define x (+ 1 2))");
h.engine.arena.write_head = SOURCE_ARENA_SIZE - 2;
EvalResult r1 = h.eval("(define y (+ 3 4))");
REQUIRE(r1.kind == EvalResult::Error);
EvalResult r2 = h.eval("(defn f [a] (+ a 1))");
REQUIRE(r2.kind == EvalResult::Error);
EvalResult r3 = h.eval("(defstate c 0 (+ c 1))");
REQUIRE(r3.kind == EvalResult::Error);
// x's stored source is untouched and still usable.
EvalResult get = h.eval("(get-expr x)");
REQUIRE(get.kind == EvalResult::Text);
}
// ============================================================================
// F8: on_cell_changed refreshes the dependency list
// ============================================================================
TEST_CASE("Reclaim: output tracks cells introduced by a redefinition",
"[reclaim][deps]") {
ReclaimHarness h;
h.eval_ok("(define x 1)");
h.eval_ok("(define y 10)");
h.eval_ok("(a1 (* x 2))");
REQUIRE(h.sample("a1") == Approx(2.0));
// Redefine x from a number to an expression referencing y. The
// on_cell_changed recompile of a1 must pick up the NEW dep set {y,...},
// not keep the stale {x}-era list.
h.eval_ok("(define x (+ y 1))");
REQUIRE(h.sample("a1") == Approx(22.0));
// Pre-fix, this change was invisible to a1 forever.
h.eval_ok("(define y 20)");
REQUIRE(h.sample("a1") == Approx(42.0));
// And it keeps tracking on further edits.
h.eval_ok("(define y 30)");
REQUIRE(h.sample("a1") == Approx(62.0));
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,610 @@
// Phase 4: Reactivity, LKG, and failure model hardening tests.
//
// Proves that edits never stop unrelated outputs. Covers dependency cascading,
// compile error preservation, runtime non-finite behaviour, LKG behaviour,
// output lifecycle, and function-cell interactions.
//
// This file is deliberately separate from test_signal_engine_golden.cpp to
// avoid merge conflicts with concurrent Phase 5 work.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include <cmath>
#include <cstring>
#include <string>
#include <vector>
#include <initializer_list>
using namespace sig;
namespace {
// ── GoldenHarness (copied from test_signal_engine_golden.cpp) ──────────────
// Minimal copy of the harness needed for Phase 4 tests.
struct Sample {
double t;
double expected;
double tolerance = 1e-9;
};
struct GoldenHarness {
SignalEngine engine;
double cell_values[MAX_CELLS] = {};
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
explicit GoldenHarness(double bpm = 120.0, int beats_per_bar = 4)
{
engine.init_defaults(bpm, beats_per_bar);
}
EvalResult eval_result(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_result(code);
INFO("code: " << code);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
INFO("diagnostic: " << (r.diagnostics[0].message ? r.diagnostics[0].message : ""));
INFO("suggestion: " << (r.diagnostics[0].suggestion ? r.diagnostics[0].suggestion : ""));
}
REQUIRE(r.kind != EvalResult::Error);
engine.pool.rebuild_execution_order();
}
void assign_ok(const char* output, const char* expr)
{
eval_ok(std::string("(") + output + " " + expr + ")");
}
uint16_t output_index(const char* output_name)
{
SymbolID sym = internSymbol(output_name);
uint16_t idx = GraphBuilder::resolve_output_index(sym);
REQUIRE(idx != NODE_NONE);
return idx;
}
double sample(const char* output_name, 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);
return outputs[output_index(output_name)];
}
double tick(const char* output_name, double t)
{
double value = sample(output_name, t);
commit_outputs(engine.pool, outputs);
return value;
}
std::vector<double> sample_window(const char* output,
double t_start, double t_end,
size_t count)
{
std::vector<double> result;
result.reserve(count);
for (size_t i = 0; i < count; ++i) {
double t_val = t_start + (t_end - t_start)
* static_cast<double>(i) / static_cast<double>(count - 1);
result.push_back(sample(output, t_val));
}
return result;
}
std::vector<double> tick_sequence(const char* output,
std::initializer_list<double> times)
{
std::vector<double> result;
result.reserve(times.size());
for (double t_val : times) {
result.push_back(tick(output, t_val));
}
return result;
}
};
} // namespace
// ============================================================================
// Phase 4.1: Deep dependency cascading
// ============================================================================
TEST_CASE("Phase 4: deep dependency cascading", "[phase4][reactivity]") {
SECTION("3-level expression cell chain propagates") {
GoldenHarness h;
h.eval_ok("(define x 1)");
h.eval_ok("(define y (+ x 10))");
h.eval_ok("(define z (* y 2))");
h.assign_ok("a1", "z");
REQUIRE(h.sample("a1", 0.0) == Approx(22.0)); // (1+10)*2
h.eval_ok("(define x 5)");
REQUIRE(h.sample("a1", 0.0) == Approx(30.0)); // (5+10)*2
}
SECTION("multiple outputs depending on same cell all update") {
GoldenHarness h;
h.eval_ok("(define freq 440)");
h.assign_ok("a1", "freq");
h.assign_ok("a2", "(* freq 2)");
REQUIRE(h.sample("a1", 0.0) == Approx(440.0));
REQUIRE(h.sample("a2", 0.0) == Approx(880.0));
h.eval_ok("(define freq 220)");
REQUIRE(h.sample("a1", 0.0) == Approx(220.0));
REQUIRE(h.sample("a2", 0.0) == Approx(440.0));
}
SECTION("time-varying expression cell updates propagate") {
GoldenHarness h;
h.eval_ok("(define a (+ t 1))");
h.eval_ok("(define b (* a 2))");
h.assign_ok("a1", "b");
// At t=0: a=1, b=2
REQUIRE(h.sample("a1", 0.0) == Approx(2.0));
// At t=0.5: a=1.5, b=3
REQUIRE(h.sample("a1", 0.5) == Approx(3.0));
// Redefine a: a = (+ t 10)
h.eval_ok("(define a (+ t 10))");
// At t=0: a=10, b=20
REQUIRE(h.sample("a1", 0.0) == Approx(20.0));
}
SECTION("4-level chain propagates on root change") {
GoldenHarness h;
h.eval_ok("(define p 2)");
h.eval_ok("(define q (+ p 1))"); // q=3
h.eval_ok("(define r (* q 10))"); // r=30
h.eval_ok("(define s (- r 5))"); // s=25
h.assign_ok("a1", "s");
REQUIRE(h.sample("a1", 0.0) == Approx(25.0));
h.eval_ok("(define p 10)");
// q=11, r=110, s=105
REQUIRE(h.sample("a1", 0.0) == Approx(105.0));
}
SECTION("redefining middle of chain propagates downstream only") {
GoldenHarness h;
h.eval_ok("(define x 1)");
h.eval_ok("(define y (* x 3))"); // y=3
h.eval_ok("(define z (+ y 100))"); // z=103
h.assign_ok("a1", "y");
h.assign_ok("a2", "z");
REQUIRE(h.sample("a1", 0.0) == Approx(3.0));
REQUIRE(h.sample("a2", 0.0) == Approx(103.0));
// Redefine y to a constant (breaks link to x)
h.eval_ok("(define y 50)");
REQUIRE(h.sample("a1", 0.0) == Approx(50.0));
REQUIRE(h.sample("a2", 0.0) == Approx(150.0));
}
}
// ============================================================================
// Phase 4.2: Compile error isolation and recovery
// ============================================================================
TEST_CASE("Phase 4: compile error isolation and recovery", "[phase4][failure]") {
SECTION("compile error preserves OTHER running outputs") {
GoldenHarness h;
h.assign_ok("a1", "beat");
h.assign_ok("a2", "bar");
// At 120 bpm: beat at t=0.25 → 0.5, bar at t=0.25 → 0.125
REQUIRE(h.sample("a2", 0.25) == Approx(0.125));
EvalResult bad = h.eval_result("(a1 (unknown-symbol))");
REQUIRE(bad.kind == EvalResult::Error);
// a1 should still run beat, a2 still runs bar
REQUIRE(h.sample("a1", 0.25) == Approx(0.5));
REQUIRE(h.sample("a2", 0.25) == Approx(0.125));
}
SECTION("sequential errors don't lose original program") {
GoldenHarness h;
h.assign_ok("a1", "beat");
REQUIRE(h.sample("a1", 0.125) == Approx(0.25));
h.eval_result("(a1 (error1))");
REQUIRE(h.sample("a1", 0.125) == Approx(0.25)); // still beat
h.eval_result("(a1 (error2))");
REQUIRE(h.sample("a1", 0.125) == Approx(0.25)); // STILL beat
}
SECTION("valid eval after error replaces the program") {
GoldenHarness h;
h.assign_ok("a1", "beat");
REQUIRE(h.sample("a1", 0.125) == Approx(0.25));
h.eval_result("(a1 (error1))"); // error
REQUIRE(h.sample("a1", 0.125) == Approx(0.25));
h.assign_ok("a1", "bar"); // valid replacement
REQUIRE(h.sample("a1", 0.5) == Approx(0.25)); // now runs bar
}
SECTION("error on cell redefinition doesn't crash outputs using that cell") {
GoldenHarness h;
h.eval_ok("(define x 10)");
h.assign_ok("a1", "x");
h.assign_ok("a2", "(+ x 5)");
REQUIRE(h.sample("a1", 0.0) == Approx(10.0));
REQUIRE(h.sample("a2", 0.0) == Approx(15.0));
// Redefine x — dependency recompilation should succeed
h.eval_ok("(define x 20)");
REQUIRE(h.sample("a1", 0.0) == Approx(20.0));
REQUIRE(h.sample("a2", 0.0) == Approx(25.0));
}
SECTION("three outputs: error on one preserves the other two") {
GoldenHarness h;
h.assign_ok("a1", "beat");
h.assign_ok("a2", "bar");
h.assign_ok("a3", "t");
EvalResult bad = h.eval_result("(a2 (nonexistent-func beat))");
REQUIRE(bad.kind == EvalResult::Error);
// a1 and a3 must still work; a2 should still run its old program (bar)
REQUIRE(h.sample("a1", 0.25) == Approx(0.5));
REQUIRE(h.sample("a2", 0.25) == Approx(0.125));
REQUIRE(h.sample("a3", 0.25) == Approx(0.25));
}
SECTION("error then valid then error keeps second valid program") {
GoldenHarness h;
h.assign_ok("a1", "beat");
REQUIRE(h.sample("a1", 0.125) == Approx(0.25));
h.eval_result("(a1 (err1))"); // error - keeps beat
h.assign_ok("a1", "bar"); // valid - replaces with bar
REQUIRE(h.sample("a1", 0.5) == Approx(0.25)); // bar
h.eval_result("(a1 (err2))"); // error - keeps bar
REQUIRE(h.sample("a1", 0.5) == Approx(0.25)); // still bar
}
}
// ============================================================================
// Phase 4.3: Non-finite value handling
// ============================================================================
TEST_CASE("Phase 4: non-finite value handling", "[phase4][numerical]") {
SECTION("division by zero substitutes bootstrap LKG") {
GoldenHarness h;
h.assign_ok("a1", "(/ 1 0)");
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);
}
SECTION("legitimate large values stay finite") {
GoldenHarness h;
h.assign_ok("a1", "(/ 1 0.000001)");
double val = h.sample("a1", 0.0);
REQUIRE(std::isfinite(val));
REQUIRE(val == Approx(1000000.0));
}
SECTION("sqrt of negative returns finite") {
GoldenHarness h;
h.assign_ok("a1", "(sqrt (- 0 1))");
double val = h.sample("a1", 0.0);
REQUIRE(std::isfinite(val));
// sqrt(fabs(-1)) = sqrt(1) = 1
REQUIRE(val == Approx(1.0));
}
SECTION("extreme time values don't crash") {
GoldenHarness h;
h.assign_ok("a1", "beat");
REQUIRE(std::isfinite(h.sample("a1", 1e10)));
REQUIRE(std::isfinite(h.sample("a1", -1.0)));
REQUIRE(std::isfinite(h.sample("a1", 0.0)));
}
SECTION("mod by zero substitutes bootstrap LKG") {
GoldenHarness h;
h.assign_ok("a1", "(% 5 0)");
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);
}
SECTION("chained operations producing intermediate infinities stay finite") {
GoldenHarness h;
// The non-finite intermediate reaches the root and activates LKG.
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);
}
SECTION("NaN guard on executor output") {
GoldenHarness h;
// tan(pi/2) could produce very large values or NaN depending on precision
// The executor should guard against non-finite results
h.assign_ok("a1", "(tan (* t 3.14159265))");
// At t near 0.5, tan approaches infinity
double val = h.sample("a1", 0.4999999);
REQUIRE(std::isfinite(val));
}
}
// ============================================================================
// Phase 4.4: Output reassignment lifecycle
// ============================================================================
TEST_CASE("Phase 4: output reassignment lifecycle", "[phase4][lifecycle]") {
SECTION("reassigning output replaces the graph") {
GoldenHarness h;
h.assign_ok("a1", "beat");
REQUIRE(h.sample("a1", 0.125) == Approx(0.25));
h.assign_ok("a1", "bar");
REQUIRE(h.sample("a1", 0.5) == Approx(0.25)); // bar, not beat
}
SECTION("multiple rapid reassignments: last one wins") {
GoldenHarness h;
h.assign_ok("a1", "beat");
h.assign_ok("a1", "bar");
h.assign_ok("a1", "(+ beat bar)");
h.assign_ok("a1", "t");
REQUIRE(h.sample("a1", 1.25) == Approx(1.25)); // last assignment wins
}
SECTION("reassignment after tick updates prev correctly") {
GoldenHarness h;
h.assign_ok("a1", "0.75");
h.tick("a1", 0.0); // commits 0.75
h.assign_ok("a1", "0.5");
double val = h.tick("a1", 0.0);
REQUIRE(val == Approx(0.5));
}
SECTION("LKG value tracks last committed output") {
GoldenHarness h;
h.assign_ok("a1", "0.75");
h.tick("a1", 0.0); // commits 0.75 as lkg
// Now break the output
EvalResult bad = h.eval_result("(a1 (nonexistent))");
REQUIRE(bad.kind == EvalResult::Error);
// a1 should still produce 0.75 (from preserved graph — root_node intact)
REQUIRE(h.sample("a1", 0.0) == Approx(0.75));
}
SECTION("unassigned output produces zero") {
GoldenHarness h;
// a1 was never assigned
double val = h.sample("a1", 0.0);
REQUIRE(val == Approx(0.0));
}
SECTION("reassignment from time-varying to constant") {
GoldenHarness h;
h.assign_ok("a1", "beat");
REQUIRE(h.sample("a1", 0.25) == Approx(0.5));
h.assign_ok("a1", "42");
REQUIRE(h.sample("a1", 0.0) == Approx(42.0));
REQUIRE(h.sample("a1", 0.5) == Approx(42.0));
REQUIRE(h.sample("a1", 1.0) == Approx(42.0));
}
SECTION("reassignment from constant to time-varying") {
GoldenHarness h;
h.assign_ok("a1", "42");
REQUIRE(h.sample("a1", 0.0) == Approx(42.0));
h.assign_ok("a1", "t");
REQUIRE(h.sample("a1", 0.0) == Approx(0.0));
REQUIRE(h.sample("a1", 1.5) == Approx(1.5));
}
}
// ============================================================================
// Phase 4.5: Function-cell cross-references
// ============================================================================
TEST_CASE("Phase 4: function-cell cross-references", "[phase4][reactivity]") {
SECTION("function referencing a cell updates when cell changes") {
GoldenHarness h;
h.eval_ok("(define scale 2)");
h.eval_ok("(defn scaled [x] (* x scale))");
h.assign_ok("a1", "(scaled beat)");
// At t=0.125, beat=0.25, scaled=0.25*2=0.5
REQUIRE(h.sample("a1", 0.125) == Approx(0.5));
h.eval_ok("(define scale 4)");
// scaled now uses scale=4, so 0.25*4=1.0
REQUIRE(h.sample("a1", 0.125) == Approx(1.0));
}
SECTION("function calling another function, inner redefined") {
GoldenHarness h;
h.eval_ok("(defn inner [x] (* x 2))");
h.eval_ok("(defn outer [x] (+ (inner x) 100))");
h.assign_ok("a1", "(outer 5)");
// inner(5) = 10, outer(5) = 110
REQUIRE(h.sample("a1", 0.0) == Approx(110.0));
h.eval_ok("(defn inner [x] (* x 3))");
// inner(5) = 15, outer(5) = 115
REQUIRE(h.sample("a1", 0.0) == Approx(115.0));
}
SECTION("nested same-function call is not recursive") {
GoldenHarness h;
h.eval_ok("(defn dbl [x] (* x 2))");
h.assign_ok("a1", "(dbl (dbl 3))");
REQUIRE(h.sample("a1", 0.0) == Approx(12.0));
}
SECTION("cell used by multiple functions, all callers update") {
GoldenHarness h;
h.eval_ok("(define base 10)");
h.eval_ok("(defn add-base [x] (+ x base))");
h.eval_ok("(defn mul-base [x] (* x base))");
h.assign_ok("a1", "(add-base 5)");
h.assign_ok("a2", "(mul-base 5)");
REQUIRE(h.sample("a1", 0.0) == Approx(15.0));
REQUIRE(h.sample("a2", 0.0) == Approx(50.0));
h.eval_ok("(define base 20)");
REQUIRE(h.sample("a1", 0.0) == Approx(25.0));
REQUIRE(h.sample("a2", 0.0) == Approx(100.0));
}
SECTION("function redefinition propagates to output using it") {
GoldenHarness h;
h.eval_ok("(defn f [x] (* x 2))");
h.assign_ok("a1", "(f 5)");
REQUIRE(h.sample("a1", 0.0) == Approx(10.0));
h.eval_ok("(defn f [x] (+ x 100))");
REQUIRE(h.sample("a1", 0.0) == Approx(105.0));
}
SECTION("function and cell combined: function with cell arg, cell changes") {
GoldenHarness h;
h.eval_ok("(define offset 100)");
h.eval_ok("(defn shifted [x] (+ x offset))");
h.assign_ok("a1", "(shifted beat)");
// At t=0.125, beat=0.25, shifted=100.25
REQUIRE(h.sample("a1", 0.125) == Approx(100.25));
// Change offset
h.eval_ok("(define offset 200)");
REQUIRE(h.sample("a1", 0.125) == Approx(200.25));
}
}
// ============================================================================
// Phase 4.6: Cross-output reads (prev) and LKG interactions
// ============================================================================
TEST_CASE("Phase 4: cross-output reads and LKG", "[phase4][lifecycle]") {
SECTION("prev reads previous tick value") {
GoldenHarness h;
h.eval_ok("(a1 10) (a2 (prev a1))");
auto seq = h.tick_sequence("a2", {0.0, 0.001, 0.002});
// First tick: prev a1 is 0 (no prior)
REQUIRE(seq[0] == Approx(0.0));
// After first tick, a1 committed 10, so prev a1 = 10
REQUIRE(seq[1] == Approx(10.0));
REQUIRE(seq[2] == Approx(10.0));
}
SECTION("self-reference via prev accumulates") {
GoldenHarness h;
h.eval_ok("(a1 (+ (prev a1) 1))");
auto seq = h.tick_sequence("a1", {0.0, 0.001, 0.002, 0.003});
REQUIRE(seq[0] == Approx(1.0)); // prev starts at 0, + 1 = 1
REQUIRE(seq[1] == Approx(2.0));
REQUIRE(seq[2] == Approx(3.0));
REQUIRE(seq[3] == Approx(4.0));
}
SECTION("reassigning output resets its graph but prev is from last commit") {
GoldenHarness h;
h.assign_ok("a1", "0.5");
h.tick("a1", 0.0); // commit 0.5
h.assign_ok("a1", "(+ (prev a1) 0.1)");
// prev a1 was 0.5 from the last tick
double val = h.tick("a1", 0.0);
REQUIRE(val == Approx(0.6));
}
}
// ============================================================================
// Phase 4.7: Multiple outputs with shared dependencies
// ============================================================================
TEST_CASE("Phase 4: shared dependencies across outputs", "[phase4][reactivity]") {
SECTION("cell change triggers recompilation of all dependent outputs") {
GoldenHarness h;
h.eval_ok("(define gain 1.0)");
h.assign_ok("a1", "(* beat gain)");
h.assign_ok("a2", "(* bar gain)");
h.assign_ok("a3", "(* t gain)");
// At t=0.25, bpm=120: beat=0.5, bar=0.125, t=0.25
REQUIRE(h.sample("a1", 0.25) == Approx(0.5));
REQUIRE(h.sample("a2", 0.25) == Approx(0.125));
REQUIRE(h.sample("a3", 0.25) == Approx(0.25));
h.eval_ok("(define gain 2.0)");
REQUIRE(h.sample("a1", 0.25) == Approx(1.0));
REQUIRE(h.sample("a2", 0.25) == Approx(0.25));
REQUIRE(h.sample("a3", 0.25) == Approx(0.5));
}
SECTION("independent cells affect only their outputs") {
GoldenHarness h;
h.eval_ok("(define x 10)");
h.eval_ok("(define y 20)");
h.assign_ok("a1", "x");
h.assign_ok("a2", "y");
h.eval_ok("(define x 99)");
REQUIRE(h.sample("a1", 0.0) == Approx(99.0));
REQUIRE(h.sample("a2", 0.0) == Approx(20.0)); // y unchanged
}
SECTION("output with no cell deps is unaffected by cell changes") {
GoldenHarness h;
h.eval_ok("(define x 10)");
h.assign_ok("a1", "x");
h.assign_ok("a2", "beat"); // no cell dependency
h.eval_ok("(define x 99)");
REQUIRE(h.sample("a1", 0.0) == Approx(99.0));
// a2 should be completely unaffected
REQUIRE(h.sample("a2", 0.25) == Approx(0.5));
}
}

View file

@ -0,0 +1,999 @@
// Signal engine robustness and boundary tests.
//
// Deterministic regression tests for resource limits, edge conditions,
// and compiler robustness. Every test here must be reproducible without
// random seeds or timing dependencies.
//
// Categories:
// [robustness][pool] — node pool overflow and recovery
// [robustness][arena] — source arena limits
// [robustness][inline] — inline depth limits
// [robustness][for] — for-loop unroll limits
// [robustness][data] — data pool overflow
// [robustness][cse] — CSE / hash-consing correctness
// [robustness][edge] — edge-case inputs that must not crash
// [robustness][sampling] — post-compilation sampling safety
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include <cmath>
#include <cstring>
#include <string>
#include <vector>
using namespace sig;
// ── Shared Harness ──────────────────────────────────────────────────────────
// Mirrors GoldenHarness from test_signal_engine_golden.cpp.
namespace {
struct RobustHarness {
SignalEngine engine;
double cell_values[MAX_CELLS] = {};
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
explicit RobustHarness(double bpm = 120.0, int beats_per_bar = 4)
{
engine.init_defaults(bpm, beats_per_bar);
}
EvalResult eval_result(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_result(code);
INFO("code: " << code);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
INFO("diagnostic: " << (r.diagnostics[0].message ? r.diagnostics[0].message : ""));
INFO("suggestion: " << (r.diagnostics[0].suggestion ? r.diagnostics[0].suggestion : ""));
}
REQUIRE(r.kind != EvalResult::Error);
engine.pool.rebuild_execution_order();
}
double sample(const char* output_name, 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.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);
SymbolID sym = internSymbol(output_name);
uint16_t idx = GraphBuilder::resolve_output_index(sym);
if (idx == NODE_NONE) return 0.0;
return outputs[idx];
}
double tick(const char* output_name, double t)
{
double value = sample(output_name, t);
commit_outputs(engine.pool, outputs);
return value;
}
bool is_error(const std::string& code)
{
EvalResult r = eval_result(code);
return r.kind == EvalResult::Error;
}
bool is_ok(const std::string& code)
{
return !is_error(code);
}
void run_samples(const char* output, int count)
{
for (int i = 0; i < count; i++) {
double t = (double)i * 0.001;
double v = tick(output, t);
INFO("sample " << i << " t=" << t << " v=" << v);
REQUIRE(std::isfinite(v));
}
}
};
} // anonymous namespace
// =============================================================================
// 1. Node Pool Limits
// =============================================================================
TEST_CASE("Node pool handles large expressions gracefully", "[robustness][pool]")
{
SECTION("Deep binary tree fills pool without crashing")
{
RobustHarness h;
// Build: (+ (+ (+ ... beat 0.1) 0.2) 0.3) — many unique binop nodes
// Each level adds a unique constant and a binop node = ~2 nodes/level.
// MAX_TOTAL_NODES = 1024, so 400 levels should push toward the limit.
std::string expr = "beat";
for (int i = 0; i < 400; i++) {
expr = "(+ " + expr + " " + std::to_string(i * 0.001) + ")";
}
expr = "(a1 " + expr + ")";
// May succeed or error (pool full) — must not crash
EvalResult r = h.eval_result(expr);
(void)r;
// Engine must remain usable after potential overflow
h.eval_ok("(a2 beat)");
double v = h.tick("a2", 0.5);
REQUIRE(std::isfinite(v));
}
SECTION("After large compilation, small one still works")
{
RobustHarness h;
// Fill up with a complex expression
std::string complex = "(+ (+ (+ (+ (+ beat (sin beat)) (cos bar)) (* t 2)) (/ bar 3)) phrase)";
h.eval_result("(a1 " + complex + ")");
h.eval_result("(a2 " + complex + ")");
h.eval_result("(a3 " + complex + ")");
// Now a simple one should still work
h.eval_ok("(a4 0.5)");
double v = h.tick("a4", 0.0);
REQUIRE(v == Approx(0.5));
}
SECTION("Pool overflow returns NODE_NONE, not crash")
{
RobustHarness h;
// Create many unique expressions to exhaust the pool.
// Each unique float constant + unique unary/binop is a distinct node.
bool had_error = false;
for (int i = 0; i < 200 && !had_error; i++) {
// Each iteration: a unique constant + unary + binop = ~3 new nodes
std::string expr = "(a1 (+ (sin " + std::to_string(i * 0.0137) +
") (cos " + std::to_string(i * 0.0253) + ")))";
EvalResult r = h.eval_result(expr);
if (r.kind == EvalResult::Error) had_error = true;
}
// Engine must still function
h.eval_ok("(a1 1.0)");
double v = h.tick("a1", 0.0);
REQUIRE(std::isfinite(v));
}
}
// =============================================================================
// 2. Source Arena Limits
// =============================================================================
TEST_CASE("Source arena handles large inputs", "[robustness][arena]")
{
SECTION("Many sequential defines fill arena without crashing")
{
RobustHarness h;
// SOURCE_ARENA_SIZE = 16384. Each defn stores body text.
// Define many small functions until we approach the limit.
bool overflow_seen = false;
for (int i = 0; i < 300 && !overflow_seen; i++) {
std::string name = "fn_" + std::to_string(i);
std::string code = "(defn " + name + " [x] (+ x " +
std::to_string(i) + "))";
EvalResult r = h.eval_result(code);
// Should either succeed or fail cleanly
if (r.kind == EvalResult::Error) overflow_seen = true;
}
// Engine must still accept simple output assignments
h.eval_ok("(a1 beat)");
double v = h.tick("a1", 0.25);
REQUIRE(std::isfinite(v));
}
SECTION("Very long expression string near uint16 span limits")
{
RobustHarness h;
// Build an expression with many terms to create a long source string.
// MAX_TOKENS = 256 limits token count, so we may hit that first.
std::string long_expr = "(a1 (+";
for (int i = 0; i < 100; i++) {
long_expr += " " + std::to_string(i * 0.01);
}
long_expr += "))";
// Should not crash — may error due to token limit or arity
EvalResult r = h.eval_result(long_expr);
(void)r;
// Engine must still work
h.eval_ok("(a1 0.5)");
double v = h.tick("a1", 0.0);
REQUIRE(v == Approx(0.5));
}
}
// =============================================================================
// 3. Inline Depth Limits
// =============================================================================
TEST_CASE("Inline depth limit produces clean diagnostic", "[robustness][inline]")
{
SECTION("Chain of nested function calls hits inline depth limit")
{
RobustHarness h;
// Create a chain: f0 calls f1, f1 calls f2, ..., f(N-1) calls fN
// MAX_INLINE_DEPTH = 16, so a chain of 20 should exceed it.
int chain_length = 20;
// Base function: the deepest one just returns its arg
h.eval_ok("(defn chain_0 [x] x)");
// Build the chain
for (int i = 1; i <= chain_length; i++) {
std::string prev = "chain_" + std::to_string(i - 1);
std::string curr = "chain_" + std::to_string(i);
std::string code = "(defn " + curr + " [x] (" + prev + " x))";
h.eval_ok(code);
}
// Calling the deepest function should hit the inline limit
std::string deep_call = "(a1 (chain_" + std::to_string(chain_length) + " beat))";
EvalResult r = h.eval_result(deep_call);
INFO("deep call: " << deep_call);
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count > 0);
// The error message should mention depth or nesting
bool found_depth_msg = false;
for (uint8_t i = 0; i < r.diagnostic_count; i++) {
if (r.diagnostics[i].message &&
std::string(r.diagnostics[i].message).find("deep") != std::string::npos) {
found_depth_msg = true;
}
}
REQUIRE(found_depth_msg);
}
SECTION("Chain just at the limit still works")
{
RobustHarness h;
// Build a chain of length MAX_INLINE_DEPTH - 1 (should just fit)
int chain_length = MAX_INLINE_DEPTH - 1; // 15
h.eval_ok("(defn ok_0 [x] x)");
for (int i = 1; i <= chain_length; i++) {
std::string prev = "ok_" + std::to_string(i - 1);
std::string curr = "ok_" + std::to_string(i);
h.eval_ok("(defn " + curr + " [x] (" + prev + " x))");
}
std::string call = "(a1 (ok_" + std::to_string(chain_length) + " beat))";
EvalResult r = h.eval_result(call);
// Should succeed — chain length equals depth limit minus 1
// (Each call adds one to inline_depth; the check is >=, so depth 15
// with limit 16 should pass.)
INFO("chain at limit: " << call);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
INFO("error: " << (r.diagnostics[0].message ? r.diagnostics[0].message : "(null)"));
}
// Even if it errors, it must not crash
REQUIRE(std::isfinite(h.tick("a1", 0.5)));
}
SECTION("Direct recursion is caught cleanly")
{
RobustHarness h;
h.eval_ok("(defn recurse [x] (recurse x))");
EvalResult r = h.eval_result("(a1 (recurse beat))");
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count > 0);
// Should mention recursion
bool mentions_recursive = false;
for (uint8_t i = 0; i < r.diagnostic_count; i++) {
if (r.diagnostics[i].message) {
std::string msg(r.diagnostics[i].message);
if (msg.find("itself") != std::string::npos ||
msg.find("recursive") != std::string::npos ||
msg.find("recursi") != std::string::npos) {
mentions_recursive = true;
}
}
}
REQUIRE(mentions_recursive);
}
SECTION("Mutual recursion is caught cleanly")
{
RobustHarness h;
h.eval_ok("(defn ping [x] (pong x))");
h.eval_ok("(defn pong [x] (ping x))");
EvalResult r = h.eval_result("(a1 (ping beat))");
REQUIRE(r.kind == EvalResult::Error);
}
}
// =============================================================================
// 4. For-Loop Unroll Limits
// =============================================================================
TEST_CASE("For-loop unroll limits are enforced", "[robustness][for]")
{
// Collection capacity is 64 elements (Collection::element_nodes[64]).
SECTION("For over exactly 64-element range compiles")
{
RobustHarness h;
// (range 0 64) produces 64 elements [0..63]
h.eval_ok("(a1 (for x (range 0 64) x))");
// The result should be the last element value (63.0)
double v = h.sample("a1", 0.0);
REQUIRE(v == Approx(63.0));
}
SECTION("For over 65-element range gets capped at 64")
{
RobustHarness h;
// (range 0 65) attempts 65 elements but collection cap is 64
// The for loop should still succeed with 64 elements (capped)
EvalResult r = h.eval_result("(a1 (for x (range 0 65) x))");
// Should succeed — collection silently caps at 64
if (r.kind != EvalResult::Error) {
double v = h.sample("a1", 0.0);
// Last element should be 63 (index 63 from 0..63)
REQUIRE(v == Approx(63.0));
}
// Either way, must not crash
}
SECTION("For with literal vector of 64 elements")
{
RobustHarness h;
std::string vec = "[";
for (int i = 0; i < 64; i++) {
if (i > 0) vec += " ";
vec += std::to_string(i);
}
vec += "]";
std::string code = "(a1 (for x " + vec + " x))";
h.eval_ok(code);
double v = h.sample("a1", 0.0);
REQUIRE(v == Approx(63.0));
}
SECTION("For with empty collection returns 0")
{
RobustHarness h;
// Empty vector
h.eval_ok("(a1 (for x [] 42))");
double v = h.sample("a1", 0.0);
REQUIRE(v == Approx(0.0));
}
SECTION("For with range(0 0) returns 0")
{
RobustHarness h;
h.eval_ok("(a1 (for x (range 0 0) 99))");
double v = h.sample("a1", 0.0);
REQUIRE(v == Approx(0.0));
}
SECTION("Negative-step range is capped at 64")
{
RobustHarness h;
// (range 100 0 -1) produces 100 elements — should cap at 64
EvalResult r = h.eval_result("(a1 (for x (range 100 0 -1) x))");
if (r.kind != EvalResult::Error) {
double v = h.sample("a1", 0.0);
REQUIRE(std::isfinite(v));
}
}
}
TEST_CASE("Oversized vector diagnostics have stable text and recover",
"[robustness][data][diagnostics]")
{
RobustHarness h;
h.eval_ok("(a1 0.25)");
std::string vector = "[";
for (int i = 1; i <= 68; i++) {
if (i > 1) vector += " ";
vector += std::to_string(i);
}
vector += "]";
EvalResult rejected = h.eval_result(
"(a1 (step " + vector + " beat))");
REQUIRE(rejected.kind == EvalResult::Error);
REQUIRE(rejected.diagnostic_count > 0);
REQUIRE(rejected.diagnostics[0].category == DiagnosticCategory::Overflow);
REQUIRE(rejected.diagnostics[0].message != nullptr);
REQUIRE(std::string(rejected.diagnostics[0].message).find("64") !=
std::string::npos);
// The rejected replacement keeps the prior output and the next unrelated
// publication/sampling sequence remains usable.
REQUIRE(h.tick("a1", 0.0) == Approx(0.25));
h.eval_ok("(a2 0.5)");
REQUIRE(h.tick("a2", 0.1) == Approx(0.5));
}
// =============================================================================
// 5. Data Pool Overflow
// =============================================================================
TEST_CASE("Data pool overflow is handled gracefully", "[robustness][data]")
{
SECTION("Many data tables exhaust MAX_DATA_TABLES")
{
RobustHarness h;
// MAX_DATA_TABLES = 64, MAX_DATA_ENTRIES = 2048
// Each small table uses 1 slot. Exhaust by creating > 64 tables.
bool overflow_seen = false;
for (int i = 0; i < 80 && !overflow_seen; i++) {
std::string name = "dt_" + std::to_string(i);
std::string code = "(define " + name + " [1 2 3])";
EvalResult r = h.eval_result(code);
if (r.kind == EvalResult::Error) overflow_seen = true;
}
// Engine must remain usable
h.eval_ok("(a1 beat)");
double v = h.tick("a1", 0.5);
REQUIRE(std::isfinite(v));
}
SECTION("Large data tables exhaust MAX_DATA_ENTRIES")
{
RobustHarness h;
// MAX_DATA_ENTRIES = 2048. Create tables that collectively exceed that.
bool overflow_seen = false;
for (int i = 0; i < 10 && !overflow_seen; i++) {
std::string name = "big_" + std::to_string(i);
std::string vec = "[";
// 300 entries per table, 10 tables = 3000 > 2048
for (int j = 0; j < 300; j++) {
if (j > 0) vec += " ";
vec += std::to_string(j * 0.01);
}
vec += "]";
std::string code = "(define " + name + " " + vec + ")";
EvalResult r = h.eval_result(code);
if (r.kind == EvalResult::Error) overflow_seen = true;
}
// Engine must still function
h.eval_ok("(a1 0.5)");
double v = h.tick("a1", 0.0);
REQUIRE(v == Approx(0.5));
}
}
// =============================================================================
// 6. CSE Stability
// =============================================================================
TEST_CASE("CSE produces deterministic results", "[robustness][cse]")
{
SECTION("Same expression compiled twice yields identical node counts")
{
RobustHarness h;
h.eval_ok("(a1 (+ (sin beat) (cos bar)))");
uint16_t count1 = h.engine.pool.node_count;
// Recompile the same expression to a2
h.eval_ok("(a2 (+ (sin beat) (cos bar)))");
uint16_t count2 = h.engine.pool.node_count;
// CSE should reuse all nodes — count should not change
// (the expression produces the same nodes, so CSE deduplicates)
REQUIRE(count2 == count1);
}
SECTION("beat references are shared via hash-consing")
{
RobustHarness h;
h.eval_ok("(a1 (+ beat beat))");
// The two beat references should resolve to the same node
// Verify by checking the root's input_a == input_b
uint16_t root = h.engine.pool.outputs[
GraphBuilder::resolve_output_index(internSymbol("a1"))
].root_node;
REQUIRE(root != NODE_NONE);
const Node& root_node = h.engine.pool.nodes[root];
// The root should be Add with identical inputs (beat shared)
if (root_node.op == NodeOp::Add) {
REQUIRE(root_node.input_a == root_node.input_b);
}
// If constant-folded or optimized differently, that's also fine
}
SECTION("Identical subexpressions share nodes across outputs")
{
RobustHarness h;
h.eval_ok("(a1 (sin beat))");
uint16_t count_after_a1 = h.engine.pool.node_count;
h.eval_ok("(a2 (sin beat))");
uint16_t count_after_a2 = h.engine.pool.node_count;
// No new nodes should be created since (sin beat) already exists
REQUIRE(count_after_a2 == count_after_a1);
}
}
// =============================================================================
// 7. Edge Cases That Must Not Crash
// =============================================================================
TEST_CASE("Edge-case inputs never crash", "[robustness][edge]")
{
SECTION("Empty input")
{
RobustHarness h;
EvalResult r = h.eval_result("");
// Empty input should return Ok (no-op)
REQUIRE(r.kind != EvalResult::Error);
}
SECTION("Whitespace only")
{
RobustHarness h;
EvalResult r = h.eval_result(" ");
REQUIRE(r.kind != EvalResult::Error);
}
SECTION("Newlines and tabs only")
{
RobustHarness h;
EvalResult r1 = h.eval_result("\n\n\n");
REQUIRE(r1.kind != EvalResult::Error);
EvalResult r2 = h.eval_result("\t\t");
REQUIRE(r2.kind != EvalResult::Error);
}
SECTION("Very deeply nested parentheses")
{
RobustHarness h;
// (((((1))))) — deeply nested but well-formed parens around a number
// The tokenizer and parser should handle this (or error cleanly)
EvalResult r = h.eval_result("(((((1)))))");
// We don't care if it errors — just must not crash
(void)r;
}
SECTION("Unmatched opening parens")
{
RobustHarness h;
EvalResult r = h.eval_result("(+ 1");
// Should produce a parse error, not crash
(void)r;
// Engine must remain usable
h.eval_ok("(a1 0.5)");
REQUIRE(std::isfinite(h.tick("a1", 0.0)));
}
SECTION("Unmatched closing parens")
{
RobustHarness h;
EvalResult r = h.eval_result("+ 1)");
(void)r;
h.eval_ok("(a1 0.5)");
REQUIRE(std::isfinite(h.tick("a1", 0.0)));
}
SECTION("Only opening parens")
{
RobustHarness h;
EvalResult r = h.eval_result("((((");
(void)r;
h.eval_ok("(a1 0.5)");
REQUIRE(std::isfinite(h.tick("a1", 0.0)));
}
SECTION("Only closing parens")
{
RobustHarness h;
EvalResult r = h.eval_result("))))");
(void)r;
h.eval_ok("(a1 0.5)");
REQUIRE(std::isfinite(h.tick("a1", 0.0)));
}
SECTION("Very long symbol name")
{
RobustHarness h;
// Create a symbol name that's 500 characters long
std::string long_name(500, 'x');
std::string code = "(define " + long_name + " 42)";
EvalResult r = h.eval_result(code);
// May succeed or fail — must not crash
(void)r;
}
SECTION("Null byte in source text")
{
RobustHarness h;
// Source text with an embedded null byte
// eval_cold takes a length, so null byte shouldn't matter
std::string code = "(a1 0.5)";
// Insert a null byte in the middle
code[3] = '\0';
EvalResult r = h.eval_result(code);
// May produce an error but must not crash
(void)r;
}
SECTION("Non-ASCII bytes in source text")
{
RobustHarness h;
// UTF-8 encoded string with non-ASCII characters
EvalResult r = h.eval_result("(a1 \xc3\xa9)"); // e with accent
(void)r;
// Engine remains usable
h.eval_ok("(a1 0.5)");
}
SECTION("Empty list ()")
{
RobustHarness h;
EvalResult r = h.eval_result("()");
(void)r;
}
SECTION("Nested empty lists")
{
RobustHarness h;
EvalResult r = h.eval_result("(())");
(void)r;
}
SECTION("Expression with many extra arguments")
{
RobustHarness h;
// Variadic + with 50 arguments
std::string code = "(a1 (+";
for (int i = 0; i < 50; i++) {
code += " " + std::to_string(i * 0.01);
}
code += "))";
EvalResult r = h.eval_result(code);
if (r.kind != EvalResult::Error) {
double v = h.sample("a1", 0.0);
REQUIRE(std::isfinite(v));
}
}
SECTION("Boolean-like edge expressions")
{
RobustHarness h;
h.eval_ok("(a1 (if 0 1 0))");
REQUIRE(h.sample("a1", 0.0) == Approx(0.0));
h.eval_ok("(a1 (if 1 1 0))");
REQUIRE(h.sample("a1", 0.0) == Approx(1.0));
}
SECTION("Deeply nested unary operations")
{
RobustHarness h;
// (abs (neg (abs (neg ... beat))))
std::string expr = "beat";
for (int i = 0; i < 40; i++) {
expr = (i % 2 == 0) ? "(abs " + expr + ")" : "(neg " + expr + ")";
}
EvalResult r = h.eval_result("(a1 " + expr + ")");
if (r.kind != EvalResult::Error) {
double v = h.sample("a1", 0.5);
REQUIRE(std::isfinite(v));
}
}
}
// =============================================================================
// 8. Post-Compilation Sampling Safety
// =============================================================================
TEST_CASE("Sampling after compilation produces finite values", "[robustness][sampling]")
{
SECTION("Many rapid samples do not crash")
{
RobustHarness h;
h.eval_ok("(a1 (+ (sin (* beat 6.28)) (cos (* bar 3.14))))");
// Run 10000 samples at various time values
for (int i = 0; i < 10000; i++) {
double t = (double)i * 0.0001;
double v = h.sample("a1", t);
if (i % 1000 == 0) {
INFO("sample " << i << " t=" << t << " v=" << v);
}
REQUIRE(std::isfinite(v));
}
}
SECTION("Sampling with extreme time values")
{
RobustHarness h;
h.eval_ok("(a1 (sin beat))");
// Very large time values
double v1 = h.sample("a1", 1e10);
REQUIRE(std::isfinite(v1));
// Very small positive time
double v2 = h.sample("a1", 1e-15);
REQUIRE(std::isfinite(v2));
// Negative time
double v3 = h.sample("a1", -1.0);
REQUIRE(std::isfinite(v3));
// Zero
double v4 = h.sample("a1", 0.0);
REQUIRE(std::isfinite(v4));
}
SECTION("Recompilation mid-stream produces finite values")
{
RobustHarness h;
h.eval_ok("(a1 (sin beat))");
// Sample some
h.run_samples("a1", 100);
// Redefine while sampling
h.eval_ok("(a1 (* beat 0.5))");
// Continue sampling — no stale references should crash
h.run_samples("a1", 100);
// Redefine again
h.eval_ok("(a1 (if (> beat 0.5) 1.0 0.0))");
h.run_samples("a1", 100);
}
SECTION("Sampling unassigned output returns finite value")
{
RobustHarness h;
// a3 was never assigned — should return 0 or LKG value, not crash
double v = h.sample("a3", 0.5);
REQUIRE(std::isfinite(v));
}
}
// =============================================================================
// 9. Token Limit
// =============================================================================
TEST_CASE("Token limit is handled gracefully", "[robustness][edge]")
{
SECTION("Expression exceeding MAX_TOKENS is rejected cleanly")
{
RobustHarness h;
// MAX_TOKENS = 256. Build an expression with many tokens.
// Each "(+ x" adds 3 tokens. 100 nestings = 300+ tokens.
std::string code = "0";
for (int i = 0; i < 100; i++) {
code = "(+ " + code + " 1)";
}
code = "(a1 " + code + ")";
EvalResult r = h.eval_result(code);
// May truncate or error — must not crash
(void)r;
// Engine remains usable
h.eval_ok("(a1 0.5)");
double v = h.tick("a1", 0.0);
REQUIRE(v == Approx(0.5));
}
}
// =============================================================================
// 10. GC + Recompilation Stability
// =============================================================================
TEST_CASE("GC and recompilation maintain engine consistency", "[robustness][pool]")
{
SECTION("Repeated reassignment with GC produces consistent values")
{
RobustHarness h;
for (int round = 0; round < 50; round++) {
double val = (double)round * 0.02;
std::string code = "(a1 " + std::to_string(val) + ")";
h.eval_ok(code);
double v = h.sample("a1", 0.0);
INFO("round " << round << " expected " << val << " got " << v);
REQUIRE(v == Approx(val).margin(1e-9));
}
}
SECTION("Multiple outputs reassigned in parallel stay independent")
{
RobustHarness h;
h.eval_ok("(a1 0.1)");
h.eval_ok("(a2 0.2)");
h.eval_ok("(a3 0.3)");
REQUIRE(h.sample("a1", 0.0) == Approx(0.1));
REQUIRE(h.sample("a2", 0.0) == Approx(0.2));
REQUIRE(h.sample("a3", 0.0) == Approx(0.3));
// Reassign a2 — a1 and a3 must not change
h.eval_ok("(a2 0.9)");
REQUIRE(h.sample("a1", 0.0) == Approx(0.1));
REQUIRE(h.sample("a2", 0.0) == Approx(0.9));
REQUIRE(h.sample("a3", 0.0) == Approx(0.3));
}
}
// =============================================================================
// 11. Scope Depth
// =============================================================================
TEST_CASE("Scope depth limits are respected", "[robustness][inline]")
{
SECTION("Deeply nested let expressions")
{
RobustHarness h;
// Build: (let [a 1] (let [b 2] (let [c 3] ... (+ a b))))
// MAX_SCOPE_DEPTH = 32; MAX_LOCAL_BINDINGS = 32
std::string code = "beat";
for (int i = 0; i < 30; i++) {
std::string var = "v" + std::to_string(i);
code = "(let [" + var + " " + std::to_string(i * 0.1) + "] (+ " + var + " " + code + "))";
}
code = "(a1 " + code + ")";
EvalResult r = h.eval_result(code);
// May succeed or error — must not crash or stack overflow
if (r.kind != EvalResult::Error) {
double v = h.sample("a1", 0.5);
REQUIRE(std::isfinite(v));
}
}
}
// =============================================================================
// 12. Division and Arithmetic Safety
// =============================================================================
TEST_CASE("Arithmetic edge cases produce finite outputs", "[robustness][edge]")
{
SECTION("Division by zero activates bootstrap LKG")
{
RobustHarness h;
h.eval_ok("(a1 (/ 1 0))");
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);
}
SECTION("Modulo by zero activates bootstrap LKG")
{
RobustHarness h;
h.eval_ok("(a1 (% 5 0))");
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);
}
SECTION("sqrt of negative is finite")
{
RobustHarness h;
h.eval_ok("(a1 (sqrt -1))");
double v = h.sample("a1", 0.0);
// sqrt(fabs(-1)) = 1.0
REQUIRE(std::isfinite(v));
}
SECTION("pow with extreme exponents stays finite")
{
RobustHarness h;
h.eval_ok("(a1 (pow 2 1000))");
double v = h.sample("a1", 0.0);
// pow(1000, 2) due to reversed args: should be finite (1e6)
REQUIRE(std::isfinite(v));
}
SECTION("Overflow clamped or finite")
{
RobustHarness h;
h.eval_ok("(a1 (* 1e200 1e200))");
double v = h.sample("a1", 0.0);
// This may be Inf from constant folding — check
// Actually, the output executor should clamp. Let's just verify no crash.
(void)v;
}
}
// =============================================================================
// 13. Consecutive Evals Stability
// =============================================================================
TEST_CASE("Hundreds of sequential evals do not degrade engine", "[robustness][pool]")
{
RobustHarness h;
// Alternate between different expression shapes
for (int i = 0; i < 200; i++) {
std::string code;
switch (i % 5) {
case 0: code = "(a1 (sin beat))"; break;
case 1: code = "(a1 (* beat 0.5))"; break;
case 2: code = "(a1 (if (> beat 0.5) 1.0 0.0))"; break;
case 3: code = "(a1 (+ (sin t) 0.5))"; break;
case 4: code = "(a1 (tri bar))"; break;
}
h.eval_ok(code);
double v = h.tick("a1", (double)i * 0.001);
REQUIRE(std::isfinite(v));
}
// Node pool should stay reasonable thanks to GC
INFO("final node count: " << h.engine.pool.node_count);
REQUIRE(h.engine.pool.node_count < MAX_TOTAL_NODES);
}
// =============================================================================
// 14. Cross-Output Dependency Isolation
// =============================================================================
TEST_CASE("Output errors do not corrupt other outputs", "[robustness][edge]")
{
RobustHarness h;
// Set up a valid output
h.eval_ok("(a1 (sin beat))");
// Try to assign an invalid expression to a2
EvalResult r = h.eval_result("(a2 (undefined_fn beat))");
REQUIRE(r.kind == EvalResult::Error);
// a1 must still work correctly — finite and deterministic
double v1a = h.tick("a1", 0.25);
REQUIRE(std::isfinite(v1a));
double v1b = h.sample("a1", 0.25);
REQUIRE(v1a == Approx(v1b).margin(1e-9));
}

View file

@ -0,0 +1,636 @@
// State-identity golden tests.
//
// These tests verify that the StateResourceRegistry and :id keyword system
// preserves state across recompilation, forks state for different identities,
// shares state across operator-compatible changes, and isolates state for
// incompatible resource kinds.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include <cmath>
#include <cstring>
#include <string>
#include <vector>
#include <initializer_list>
using namespace sig;
namespace {
struct Sample {
double t;
double expected;
double tolerance = 1e-9;
};
struct GoldenHarness {
SignalEngine engine;
double cell_values[MAX_CELLS] = {};
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
double prev_t = 0.0;
double last_dt = 0.0;
bool has_ticked = false;
bool state_committed_this_step = false;
explicit GoldenHarness(double bpm = 120.0, int beats_per_bar = 4)
{
engine.init_defaults(bpm, beats_per_bar);
}
EvalResult eval_result(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_result(code);
INFO("code: " << code);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
INFO("diagnostic: " << (r.diagnostics[0].message ? r.diagnostics[0].message : ""));
INFO("suggestion: " << (r.diagnostics[0].suggestion ? r.diagnostics[0].suggestion : ""));
}
REQUIRE(r.kind != EvalResult::Error);
engine.pool.rebuild_execution_order();
}
void assign_ok(const char* output, const char* expr)
{
eval_ok(std::string("(") + output + " " + expr + ")");
}
uint16_t output_index(const char* output_name)
{
SymbolID sym = internSymbol(output_name);
uint16_t idx = GraphBuilder::resolve_output_index(sym);
REQUIRE(idx != NODE_NONE);
return idx;
}
double sample(const char* output_name, double t)
{
std::memset(outputs, 0, sizeof(outputs));
std::memset(workspace, 0, sizeof(workspace));
engine.cells.snapshot_values(cell_values, MAX_CELLS);
last_dt = t - prev_t;
if (engine.pool.state_slot_count > 0 &&
has_ticked && t != prev_t && !state_committed_this_step)
{
ExecutionContext state_ctx;
state_ctx.t = t;
state_ctx.dt = last_dt;
state_ctx.cell_values = cell_values;
state_ctx.hw_inputs = hw_inputs;
state_ctx.data_pool = engine.cells.data_pool;
state_ctx.data_offsets = engine.cells.data_offsets;
state_ctx.data_lengths = engine.cells.data_lengths;
state_ctx.prev_outputs = engine.pool.prev_output_values;
state_ctx.output_values = outputs;
state_ctx.workspace = workspace;
execute_all_outputs(engine.pool, state_ctx);
commit_state(engine.pool, workspace);
state_committed_this_step = true;
std::memset(workspace, 0, sizeof(workspace));
std::memset(outputs, 0, sizeof(outputs));
}
ExecutionContext ctx;
ctx.t = t;
ctx.dt = last_dt;
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);
return outputs[output_index(output_name)];
}
double tick(const char* output_name, double t)
{
if (t != prev_t) {
state_committed_this_step = false;
}
double value = sample(output_name, t);
commit_outputs(engine.pool, outputs);
has_ticked = true;
prev_t = t;
return value;
}
std::vector<double> tick_sequence(const char* output,
std::initializer_list<double> times)
{
std::vector<double> result;
result.reserve(times.size());
for (double t_val : times) {
result.push_back(tick(output, t_val));
}
return result;
}
};
} // namespace
// ============================================================================
// Test 1: Reorder with same :id preserves state
// ============================================================================
TEST_CASE("State identity: reorder with same :id preserves state",
"[golden][state_identity]") {
GoldenHarness h;
h.eval_ok("(a1 (phasor 1 :id \"p\"))");
// Tick several times to accumulate phase.
// phasor(1) at dt=0.01 increments phase by 0.01 each tick.
h.tick("a1", 0.0);
h.tick("a1", 0.01);
h.tick("a1", 0.02);
h.tick("a1", 0.03);
h.tick("a1", 0.04);
// Read the accumulated phase from the state slot directly.
// After 4 increments of dt=0.01 at freq=1, phase should be ~0.04.
double phase_before = h.engine.pool.state_values[0];
INFO("phase_before: " << phase_before);
REQUIRE(phase_before > 0.01); // definitely accumulated
// Recompile the exact same expression — state must survive.
h.eval_ok("(a1 (phasor 1 :id \"p\"))");
double phase_after = h.engine.pool.state_values[0];
INFO("phase_after: " << phase_after);
REQUIRE(phase_after == Approx(phase_before).margin(1e-12));
// Verify the phasor continues from where it was — the next tick should
// produce a value close to phase_before (not reset to 0).
double val = h.tick("a1", 0.05);
REQUIRE(val > phase_before * 0.5); // not reset to zero
REQUIRE(val == Approx(phase_before + 0.01).margin(1e-6)); // advanced by one dt
}
// ============================================================================
// Test 2: Different :id forks state
// ============================================================================
TEST_CASE("State identity: different :id forks state",
"[golden][state_identity]") {
GoldenHarness h;
// Two phasors with different :ids should get different registry entries.
h.eval_ok("(a1 (phasor 1 :id \"alpha\"))");
h.eval_ok("(a2 (phasor 2 :id \"beta\"))");
// Registry should have at least 2 entries.
REQUIRE(h.engine.registry.entry_count >= 2);
// Tick to accumulate different phases.
h.tick("a1", 0.0);
h.tick("a1", 0.01);
h.tick("a2", 0.0);
h.tick("a2", 0.01);
// The two phasors have different frequencies, so state values should differ.
// Find the slots for each :id.
uint16_t slot_alpha = NODE_NONE;
uint16_t slot_beta = NODE_NONE;
for (uint16_t i = 0; i < h.engine.registry.entry_count; i++) {
auto& e = h.engine.registry.entries[i];
if (e.key.state_id == internSymbol("alpha") &&
e.key.kind == ResourceKind::OscillatorPhase) {
slot_alpha = e.slot_index;
}
if (e.key.state_id == internSymbol("beta") &&
e.key.kind == ResourceKind::OscillatorPhase) {
slot_beta = e.slot_index;
}
}
REQUIRE(slot_alpha != NODE_NONE);
REQUIRE(slot_beta != NODE_NONE);
REQUIRE(slot_alpha != slot_beta);
// The two slots hold independent values — verify they are distinct.
// Both phasors were ticked with the same times but at different
// frequencies, so their accumulated phases must differ.
REQUIRE(h.engine.pool.state_values[slot_alpha] !=
h.engine.pool.state_values[slot_beta]);
}
// ============================================================================
// Test 3: Operator-compatible change preserves phase
// ============================================================================
TEST_CASE("State identity: operator-compatible change preserves phase",
"[golden][state_identity]") {
GoldenHarness h;
// saw and tri-osc both use ResourceKind::OscillatorPhase, so sharing
// an :id between them should resolve to the same state slot.
h.eval_ok("(a1 (lfo/saw 1 :id \"x\"))");
// Accumulate phase.
h.tick("a1", 0.0);
h.tick("a1", 0.01);
h.tick("a1", 0.02);
h.tick("a1", 0.03);
// Record the accumulated phase.
// Find the slot for :id "x" with OscillatorPhase kind.
uint16_t slot = NODE_NONE;
for (uint16_t i = 0; i < h.engine.registry.entry_count; i++) {
auto& e = h.engine.registry.entries[i];
if (e.key.state_id == internSymbol("x") &&
e.key.kind == ResourceKind::OscillatorPhase) {
slot = e.slot_index;
break;
}
}
REQUIRE(slot != NODE_NONE);
double phase_before = h.engine.pool.state_values[slot];
INFO("phase_before: " << phase_before);
REQUIRE(phase_before > 0.0);
// Recompile as tri-osc with the same :id.
// The OscillatorPhase slot should be reused (same key).
h.eval_ok("(a1 (lfo/tri 1 :id \"x\"))");
double phase_after = h.engine.pool.state_values[slot];
INFO("phase_after: " << phase_after);
REQUIRE(phase_after == Approx(phase_before).margin(1e-12));
}
// ============================================================================
// Test 4: Incompatible change gets separate resources
// ============================================================================
TEST_CASE("State identity: incompatible change gets separate resources",
"[golden][state_identity]") {
GoldenHarness h;
// phasor uses ResourceKind::OscillatorPhase; count uses Counter +
// TriggerMemory + ResetLatch. Even with the same :id "x", they
// should resolve to different registry entries / slots.
h.eval_ok("(a1 (phasor 1 :id \"x\"))");
uint16_t entries_after_phasor = h.engine.registry.entry_count;
INFO("entries after phasor: " << entries_after_phasor);
REQUIRE(entries_after_phasor >= 1);
// count with same :id "x" — needs Counter, TriggerMemory, ResetLatch.
// Use a1 on a different output to avoid overwriting.
// We compile as a separate output so both co-exist.
h.eval_ok("(a2 (count (sqr beat) :id \"x\"))");
uint16_t entries_after_count = h.engine.registry.entry_count;
INFO("entries after count: " << entries_after_count);
// count allocates 3 slots (Counter, TriggerMemory, ResetLatch).
// phasor allocated 1 (OscillatorPhase). Total should be at least 4.
REQUIRE(entries_after_count >= entries_after_phasor + 3);
// Verify the phasor slot is distinct from every count slot.
uint16_t phasor_slot = NODE_NONE;
std::vector<uint16_t> count_slots;
for (uint16_t i = 0; i < h.engine.registry.entry_count; i++) {
auto& e = h.engine.registry.entries[i];
if (e.key.state_id == internSymbol("x")) {
if (e.key.kind == ResourceKind::OscillatorPhase) {
phasor_slot = e.slot_index;
} else {
count_slots.push_back(e.slot_index);
}
}
}
REQUIRE(phasor_slot != NODE_NONE);
REQUIRE(count_slots.size() == 3);
for (auto s : count_slots) {
REQUIRE(s != phasor_slot);
}
}
// ============================================================================
// Test 5: Init not replayed on recompile
// ============================================================================
TEST_CASE("State identity: init not replayed on recompile",
"[golden][state_identity]") {
GoldenHarness h;
// Compile a phasor with :phase 0.0 and :id "p", then accumulate.
h.eval_ok("(a1 (phasor 2 :id \"p\" :phase 0.0))");
h.tick("a1", 0.0);
h.tick("a1", 0.01);
h.tick("a1", 0.02);
h.tick("a1", 0.03);
h.tick("a1", 0.04);
// Phase should have accumulated significantly (freq=2, 4 ticks of dt=0.01).
double phase_before = h.engine.pool.state_values[0];
INFO("phase_before: " << phase_before);
REQUIRE(phase_before > 0.01);
// Recompile the same expression — :phase 0.0 is an init hint, NOT a
// reset command. The registry should find the existing slot and skip
// re-initialization.
h.eval_ok("(a1 (phasor 2 :id \"p\" :phase 0.0))");
double phase_after = h.engine.pool.state_values[0];
INFO("phase_after: " << phase_after);
REQUIRE(phase_after == Approx(phase_before).margin(1e-12));
}
// ============================================================================
// Test 6: Anonymous slots still work
// ============================================================================
TEST_CASE("State identity: anonymous slots still work",
"[golden][state_identity]") {
GoldenHarness h;
// No :id — should allocate state slots via the old anonymous path.
h.eval_ok("(a1 (phasor 1))");
REQUIRE(h.engine.pool.state_slot_count >= 1);
// Tick several times and verify the phasor accumulates phase.
h.tick("a1", 0.0);
h.tick("a1", 0.01);
h.tick("a1", 0.02);
h.tick("a1", 0.03);
// After multiple ticks at freq=1, state should have accumulated.
double phase = h.engine.pool.state_values[0];
INFO("accumulated anonymous phase: " << phase);
REQUIRE(phase > 0.01);
// Verify tick produces monotonically increasing values.
double v3 = h.tick("a1", 0.04);
double v4 = h.tick("a1", 0.05);
REQUIRE(v4 > v3);
}
// ============================================================================
// Test 7: useq-clear resets registry
// ============================================================================
TEST_CASE("State identity: useq-clear resets registry",
"[golden][state_identity]") {
GoldenHarness h;
// Build up some registry state.
h.eval_ok("(a1 (phasor 1 :id \"p1\"))");
h.eval_ok("(a2 (phasor 2 :id \"p2\"))");
// Tick to accumulate.
h.tick("a1", 0.0);
h.tick("a1", 0.01);
h.tick("a2", 0.0);
h.tick("a2", 0.01);
REQUIRE(h.engine.registry.entry_count >= 2);
REQUIRE(h.engine.pool.state_slot_count >= 2);
// useq-clear should reset the registry.
h.eval_ok("(useq-clear)");
REQUIRE(h.engine.registry.entry_count == 0);
}
// ============================================================================
// Additional: Named :id across multiple recompiles preserves continuity
// ============================================================================
TEST_CASE("State identity: multiple recompiles with same :id are stable",
"[golden][state_identity]") {
GoldenHarness h;
h.eval_ok("(a1 (phasor 1 :id \"stable\"))");
// Tick 10 times.
for (int i = 0; i < 10; i++) {
h.tick("a1", i * 0.01);
}
double phase_a = h.engine.pool.state_values[0];
// Recompile 5 times. Phase must not reset.
for (int i = 0; i < 5; i++) {
h.eval_ok("(a1 (phasor 1 :id \"stable\"))");
double phase_now = h.engine.pool.state_values[0];
REQUIRE(phase_now == Approx(phase_a).margin(1e-12));
}
}
// ============================================================================
// Additional: Registry entry_count does not grow on re-resolve
// ============================================================================
TEST_CASE("State identity: re-resolve does not grow entry count",
"[golden][state_identity]") {
GoldenHarness h;
h.eval_ok("(a1 (phasor 1 :id \"re\"))");
uint16_t count_after_first = h.engine.registry.entry_count;
// Recompile same expression multiple times.
h.eval_ok("(a1 (phasor 1 :id \"re\"))");
h.eval_ok("(a1 (phasor 1 :id \"re\"))");
h.eval_ok("(a1 (phasor 1 :id \"re\"))");
REQUIRE(h.engine.registry.entry_count == count_after_first);
}
TEST_CASE("State identity: one update writer owns an explicit id",
"[golden][state_identity][ownership]") {
GoldenHarness h;
h.eval_ok("(a1 (phasor 1 :id \"owned-phase\"))");
uint16_t entries_before = h.engine.registry.entry_count;
uint16_t slots_before = h.engine.pool.state_slot_count;
EvalResult conflict =
h.eval_result("(a2 (phasor 2 :id \"owned-phase\"))");
REQUIRE(conflict.kind == EvalResult::Error);
REQUIRE(conflict.diagnostic_count >= 1);
REQUIRE(conflict.diagnostics[0].category ==
DiagnosticCategory::Boundary);
REQUIRE(h.engine.pool.outputs[h.output_index("a2")].root_node ==
NODE_NONE);
REQUIRE(h.engine.registry.entry_count == entries_before);
REQUIRE(h.engine.pool.state_slot_count == slots_before);
// Recompiling the owning program is still legal and preserves state.
h.eval_ok("(a1 (phasor 3 :id \"owned-phase\"))");
REQUIRE(h.engine.registry.entry_count == entries_before);
REQUIRE(h.engine.pool.state_slot_count == slots_before);
}
TEST_CASE("State identity: retired program state slots are reused",
"[golden][state_identity][reclaim]") {
GoldenHarness h;
h.eval_ok("(a1 (phasor 1 :id \"retired-phase\"))");
REQUIRE(h.engine.registry.entry_count == 1);
REQUIRE(h.engine.pool.state_slot_count == 1);
uint16_t retired_slot = h.engine.registry.entries[0].slot_index;
// Publishing a pure replacement retires the old program's only writer.
h.eval_ok("(a1 0.5)");
REQUIRE(h.engine.registry.entry_count == 0);
REQUIRE(h.engine.registry.free_slot_count == 0);
REQUIRE(h.engine.pool.state_slot_count == 0);
// A different program starts again at the compacted dense slot zero, so
// repeated edit/replacement cycles do not exhaust fixed firmware storage.
h.eval_ok("(a2 (integrate 1 :id \"new-integrator\"))");
REQUIRE(h.engine.registry.entry_count == 1);
REQUIRE(h.engine.registry.entries[0].slot_index == retired_slot);
REQUIRE(h.engine.registry.free_slot_count == 0);
REQUIRE(h.engine.pool.state_slot_count == 1);
}
// ============================================================================
// Projection fork: simulate save/restore cycle and verify invariants
// ============================================================================
TEST_CASE("State identity: projection fork preserves live state",
"[golden][state_identity][projection]") {
GoldenHarness h;
h.eval_ok("(a1 (phasor 1 :id \"proj-p\"))");
h.tick("a1", 0.0);
h.tick("a1", 0.01);
h.tick("a1", 0.02);
// Snapshot live state before projection
double live_state[MAX_STATE_SLOTS];
memcpy(live_state, h.engine.pool.state_values, sizeof(live_state));
uint16_t live_slot_count = h.engine.pool.state_slot_count;
uint16_t live_registry_count = h.engine.registry.entry_count;
double live_prev_outputs[MAX_OUTPUTS];
memcpy(live_prev_outputs, h.engine.pool.prev_output_values, sizeof(live_prev_outputs));
// Simulate projection fork: save → install fork → advance → restore
// Save
double saved_state[MAX_STATE_SLOTS];
uint16_t saved_slot_count = h.engine.pool.state_slot_count;
StateResourceRegistry saved_registry = h.engine.registry;
double saved_prev_outputs[MAX_OUTPUTS];
memcpy(saved_state, h.engine.pool.state_values, sizeof(saved_state));
memcpy(saved_prev_outputs, h.engine.pool.prev_output_values, sizeof(saved_prev_outputs));
// Execute several projection samples (advances state in the engine)
for (int s = 0; s < 10; s++) {
double t = 0.03 + s * 0.01;
std::memset(h.outputs, 0, sizeof(h.outputs));
std::memset(h.workspace, 0, sizeof(h.workspace));
h.engine.cells.snapshot_values(h.cell_values, MAX_CELLS);
ExecutionContext ctx;
ctx.t = t;
ctx.dt = 0.01;
ctx.cell_values = h.cell_values;
ctx.hw_inputs = h.hw_inputs;
ctx.data_pool = h.engine.cells.data_pool;
ctx.data_offsets = h.engine.cells.data_offsets;
ctx.data_lengths = h.engine.cells.data_lengths;
ctx.prev_outputs = h.engine.pool.prev_output_values;
ctx.output_values = h.outputs;
ctx.workspace = h.workspace;
execute_all_outputs(h.engine.pool, ctx);
commit_state(h.engine.pool, h.workspace);
commit_outputs(h.engine.pool, h.outputs);
}
// State has been mutated by projection execution
REQUIRE(h.engine.pool.state_values[0] != live_state[0]);
// Restore live state
memcpy(h.engine.pool.state_values, saved_state, sizeof(saved_state));
h.engine.pool.state_slot_count = saved_slot_count;
h.engine.registry = saved_registry;
memcpy(h.engine.pool.prev_output_values, saved_prev_outputs, sizeof(saved_prev_outputs));
// Verify live state is unchanged
REQUIRE(h.engine.pool.state_slot_count == live_slot_count);
REQUIRE(h.engine.registry.entry_count == live_registry_count);
for (uint16_t i = 0; i < live_slot_count; i++) {
REQUIRE(h.engine.pool.state_values[i] == Approx(live_state[i]));
}
for (uint16_t i = 0; i < MAX_OUTPUTS; i++) {
REQUIRE(h.engine.pool.prev_output_values[i] == Approx(live_prev_outputs[i]));
}
}
TEST_CASE("State identity: repeated projection doesn't grow registry",
"[golden][state_identity][projection]") {
GoldenHarness h;
h.eval_ok("(a1 (phasor 1 :id \"rp\"))");
h.tick("a1", 0.0);
uint16_t initial_entries = h.engine.registry.entry_count;
uint16_t initial_slots = h.engine.pool.state_slot_count;
// Simulate 5 projection fork cycles
for (int cycle = 0; cycle < 5; cycle++) {
double saved_state[MAX_STATE_SLOTS];
uint16_t saved_slot_count = h.engine.pool.state_slot_count;
memcpy(saved_state, h.engine.pool.state_values, sizeof(saved_state));
// Advance 3 samples in "fork"
for (int s = 0; s < 3; s++) {
double t = 0.01 * (cycle * 3 + s + 1);
std::memset(h.outputs, 0, sizeof(h.outputs));
std::memset(h.workspace, 0, sizeof(h.workspace));
h.engine.cells.snapshot_values(h.cell_values, MAX_CELLS);
ExecutionContext ctx;
ctx.t = t;
ctx.dt = 0.01;
ctx.cell_values = h.cell_values;
ctx.hw_inputs = h.hw_inputs;
ctx.data_pool = h.engine.cells.data_pool;
ctx.data_offsets = h.engine.cells.data_offsets;
ctx.data_lengths = h.engine.cells.data_lengths;
ctx.prev_outputs = h.engine.pool.prev_output_values;
ctx.output_values = h.outputs;
ctx.workspace = h.workspace;
execute_all_outputs(h.engine.pool, ctx);
commit_state(h.engine.pool, h.workspace);
}
// Restore
memcpy(h.engine.pool.state_values, saved_state, sizeof(saved_state));
h.engine.pool.state_slot_count = saved_slot_count;
}
REQUIRE(h.engine.registry.entry_count == initial_entries);
REQUIRE(h.engine.pool.state_slot_count == initial_slots);
}

View file

@ -0,0 +1,935 @@
// Synth compiler domain tests (synth-nodes.md / VAL-COMP-001..012,018,019).
//
// These tests verify the transactional top-level synth declaration domain:
// - Minimum and amplitude synth forms compile with registry defaults and stable identity
// - Unknown defs, invalid parameters, malformed forms, nesting, and over-capacity
// declarations fail with precise diagnostics
// - Each top-level form is transactional and one shared revision covers its
// graph and control table publication
// - Control expression roots remain executable after GC
// - Public artefacts avoid internal remapped node indices
// - Native and generated execution support literal, time-dependent, and
// input-dependent controls
//
// The harness mirrors test_state_identity.cpp's GoldenHarness shape so the
// synth compilation path is exercised through the real eval_cold entry point.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include <cmath>
#include <cstring>
#include <string>
#include <vector>
using namespace sig;
namespace {
struct SynthHarness {
SignalEngine engine;
double cell_values[MAX_CELLS] = {};
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
SynthHarness()
{
engine.init_defaults();
}
EvalResult eval(const std::string& code)
{
return eval_cold(code.c_str(), static_cast<uint32_t>(code.size()), engine);
}
// Run a GC pass that preserves synth control roots. Direct
// pool.gc_unreachable_nodes() would orphan synth control expressions
// (VAL-COMP-011); this helper mirrors the engine's own GC integration.
void gc()
{
register_synth_external_roots(engine);
engine.pool.gc_unreachable_nodes();
commit_synth_external_roots(engine);
engine.pool.rebuild_execution_order();
}
bool eval_ok(const std::string& code)
{
EvalResult r = eval(code);
INFO("code: " << code);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
for (uint8_t i = 0; i < r.diagnostic_count; i++) {
INFO("diagnostic[" << i << "]: "
<< (r.diagnostics[i].message ? r.diagnostics[i].message : ""));
INFO("suggestion[" << i << "]: "
<< (r.diagnostics[i].suggestion ? r.diagnostics[i].suggestion : ""));
}
}
return r.kind != EvalResult::Error;
}
bool eval_fails(const std::string& code)
{
EvalResult r = eval(code);
return r.kind == EvalResult::Error;
}
double sample_control(uint16_t index, double t = 0.0)
{
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);
REQUIRE(index < engine.synth_graph.control_count());
uint16_t root = engine.synth_graph.controls[index].root_node;
REQUIRE(root != NODE_NONE);
REQUIRE(root < engine.pool.node_count);
return workspace[root];
}
// Returns the first diagnostic message produced by an eval (or "" if none).
std::string first_message(const std::string& code)
{
EvalResult r = eval(code);
if (r.diagnostic_count == 0) return "";
return r.diagnostics[0].message ? r.diagnostics[0].message : "";
}
// Returns the first diagnostic suggestion produced by an eval.
std::string first_suggestion(const std::string& code)
{
EvalResult r = eval(code);
if (r.diagnostic_count == 0) return "";
return r.diagnostics[0].suggestion ? r.diagnostics[0].suggestion : "";
}
};
// Read the engine's published synth artefact JSON snapshot. The returned
// pointer is stable until the next eval. Returns "" if no synth artefact
// has been published.
std::string snapshot_synth_artifacts(const SynthHarness& h)
{
const char* json = synth_artifacts_json(h.engine);
return json ? std::string(json) : std::string();
}
} // namespace
// ============================================================================
// VAL-COMP-001: Minimum sine form compiles
// ============================================================================
TEST_CASE("synth: minimum sine form compiles as identity-keyed declaration",
"[synth][val-comp-001]") {
SynthHarness h;
REQUIRE(h.eval_ok("(synth \"osc/sine\" :freq 440)"));
// The patch graph must contain exactly one synth declaration.
const auto& graph = h.engine.synth_graph;
REQUIRE(graph.declaration_count() == 1);
// The single declaration must be osc/sine.
const auto& decl = graph.declarations[0];
REQUIRE(decl.def_name == std::string("osc/sine"));
REQUIRE(decl.def_version == 2);
REQUIRE(decl.audio_inputs == 1);
// The identity must be non-empty (hidden or explicit). Hidden identity
// is supplied by the payload builder, so an anonymous form must still
// receive one.
REQUIRE(decl.identity != nullptr);
REQUIRE(decl.identity[0] != '\0');
REQUIRE(std::string(decl.identity).size() > 0);
}
TEST_CASE("synth: osc namespace is registry-backed synth sugar",
"[synth][namespaces]") {
SynthHarness h;
REQUIRE(h.eval_ok("(osc/sine :name \"lead\" :freq 440 :amp 0.1)"));
REQUIRE(h.engine.synth_graph.declaration_count() == 1);
const auto& decl = h.engine.synth_graph.declarations[0];
REQUIRE(decl.def_name == std::string("osc/sine"));
REQUIRE(decl.identity == std::string("lead"));
REQUIRE(h.eval_fails("(osc/unknown :freq 440)"));
REQUIRE(h.first_message("(osc/unknown :freq 440)").find("osc/unknown") !=
std::string::npos);
}
// ============================================================================
// VAL-COMP-002: Amplitude form compiles with bound frequency and amplitude
// ============================================================================
TEST_CASE("synth: amplitude form compiles with both controls",
"[synth][val-comp-002]") {
SynthHarness h;
REQUIRE(h.eval_ok("(synth \"osc/sine\" :freq 440 :amp 0.1)"));
const auto& graph = h.engine.synth_graph;
REQUIRE(graph.declaration_count() == 1);
REQUIRE(graph.control_count() == 2);
// Both :freq and :amp must be bound control channels.
bool has_freq = false, has_amp = false;
for (uint16_t i = 0; i < graph.control_count(); i++) {
const NodeDefParam* parameter = graph.parameter_for_control(i);
REQUIRE(parameter != nullptr);
if (parameter->name == std::string("freq")) has_freq = true;
if (parameter->name == std::string("amp")) has_amp = true;
}
REQUIRE(has_freq);
REQUIRE(has_amp);
}
// ============================================================================
// VAL-COMP-003: Omitted amplitude uses registry default; freq default is 440
// ============================================================================
TEST_CASE("synth: omitted amplitude uses registry default 0.2",
"[synth][val-comp-003]") {
SynthHarness h;
// Omit :amp entirely — no control channel should be allocated for amp.
REQUIRE(h.eval_ok("(synth \"osc/sine\" :freq 440)"));
const auto& graph = h.engine.synth_graph;
REQUIRE(graph.declaration_count() == 1);
REQUIRE(graph.control_count() == 1);
// The single channel must be freq.
const NodeDefParam* parameter = graph.parameter_for_control(0);
REQUIRE(parameter != nullptr);
REQUIRE(parameter->name == std::string("freq"));
// The NodeDef registry must declare freq default 440 and amp default 0.2.
const NodeDefDescriptor* sine = synth_registry_find("osc/sine", 2);
REQUIRE(sine != nullptr);
REQUIRE(sine->freq_default == Approx(440.0));
REQUIRE(sine->amp_default == Approx(0.2));
}
TEST_CASE("synth: optional-control replacement keeps source use live-bounded",
"[synth][reclaim][arena]") {
SynthHarness h;
const std::string without_amp =
"(synth \"osc/sine\" :name \"bounded\" :freq (+ 400 beat))";
const std::string with_amp =
"(synth \"osc/sine\" :name \"bounded\" :freq (+ 400 beat) "
":amp (+ 0.1 (* 0.01 bar)))";
REQUIRE(h.eval_ok(without_amp));
const uint32_t one_control_bytes = h.engine.arena.write_head;
REQUIRE(one_control_bytes > 0);
// N successful absent/present replacements exceed the arena capacity
// under the old append-only lifecycle. Every iteration has only one live
// identity and at most two live control sources.
const uint32_t replacements =
static_cast<uint32_t>(SOURCE_ARENA_SIZE / 8 + 1);
uint32_t two_control_bytes = 0;
for (uint32_t i = 0; i < replacements; i++) {
INFO("replacement " << i);
REQUIRE(h.eval_ok(with_amp));
REQUIRE(h.engine.synth_graph.declaration_count() == 1);
REQUIRE(h.engine.synth_graph.control_count() == 2);
if (i == 0) two_control_bytes = h.engine.arena.write_head;
REQUIRE(h.engine.arena.write_head == two_control_bytes);
REQUIRE(h.eval_ok(without_amp));
REQUIRE(h.engine.synth_graph.control_count() == 1);
REQUIRE(h.engine.arena.write_head == one_control_bytes);
}
// N+1 and reuse remain successful after cumulative replacement text far
// exceeds the fixed arena. A longer same-slot edit may append while
// staging, then compacts back to exactly the current live source bytes.
REQUIRE(h.eval_ok(with_amp));
REQUIRE(h.engine.arena.write_head == two_control_bytes);
REQUIRE(h.eval_ok(
"(synth \"osc/sine\" :name \"bounded\" "
":freq (+ 400 (* beat 2) (* bar 3)) "
":amp (+ 0.1 (* 0.01 bar)))"));
const uint32_t longer_live_bytes = h.engine.arena.write_head;
REQUIRE(longer_live_bytes > two_control_bytes);
REQUIRE(longer_live_bytes < SOURCE_ARENA_SIZE);
REQUIRE(h.eval_ok(without_amp));
REQUIRE(h.engine.arena.write_head == one_control_bytes);
}
// ============================================================================
// VAL-COMP-004: Supplied stable identity is authoritative
// ============================================================================
TEST_CASE("synth: explicit identity is preserved across edits",
"[synth][val-comp-004]") {
SynthHarness h;
// First eval: explicit identity via :name
REQUIRE(h.eval_ok("(synth \"osc/sine\" :name \"lead\" :freq 440)"));
std::string id_first = h.engine.synth_graph.declarations[0].identity;
REQUIRE(id_first == std::string("lead"));
// Second eval: change frequency. Identity must remain "lead" — not
// replaced by source text, range, ordinal, or hash identity.
REQUIRE(h.eval_ok("(synth \"osc/sine\" :name \"lead\" :freq 660)"));
std::string id_second = h.engine.synth_graph.declarations[0].identity;
REQUIRE(id_second == id_first);
REQUIRE(id_second == std::string("lead"));
}
// ============================================================================
// VAL-COMP-005: Invalid def names fail clearly
// ============================================================================
TEST_CASE("synth: invalid def names fail with no commit",
"[synth][val-comp-005]") {
SynthHarness h;
// Non-string def name: must fail without committing.
SECTION("non-string def name") {
REQUIRE(h.eval_fails("(synth 440 :freq 440)"));
}
SECTION("unknown def name") {
REQUIRE(h.eval_fails("(synth \"osc/unknown\" :freq 440)"));
// Diagnostic should mention the unknown def.
std::string msg = h.first_message("(synth \"osc/unknown\" :freq 440)");
REQUIRE(msg.find("osc/unknown") != std::string::npos);
}
SECTION("unavailable def version") {
// Asking for an explicit version that does not exist must fail.
REQUIRE(h.eval_fails("(synth \"osc/sine\" :version 99 :freq 440)"));
}
// After every failure path the synth graph must be empty (no commit).
REQUIRE(h.engine.synth_graph.declaration_count() == 0);
}
// ============================================================================
// VAL-COMP-006: Invalid parameters fail clearly
// ============================================================================
TEST_CASE("synth: invalid parameters produce precise diagnostics",
"[synth][val-comp-006]") {
SynthHarness h;
SECTION("unknown parameter with fuzzy suggestion") {
REQUIRE(h.eval_fails("(synth \"osc/sine\" :freq 440 :amplitude 0.1)"));
std::string sug = h.first_suggestion(
"(synth \"osc/sine\" :freq 440 :amplitude 0.1)");
// The suggestion must point toward the correct parameter name.
REQUIRE(sug.find("amp") != std::string::npos);
}
SECTION("duplicate parameter") {
REQUIRE(h.eval_fails("(synth \"osc/sine\" :freq 440 :freq 880)"));
std::string msg = h.first_message(
"(synth \"osc/sine\" :freq 440 :freq 880)");
REQUIRE(msg.find("freq") != std::string::npos);
}
SECTION("missing value") {
REQUIRE(h.eval_fails("(synth \"osc/sine\" :freq)"));
}
SECTION("malformed pair (keyword then keyword)") {
REQUIRE(h.eval_fails("(synth \"osc/sine\" :freq :amp 0.1)"));
}
SECTION("missing required :freq") {
REQUIRE(h.eval_fails("(synth \"osc/sine\" :amp 0.1)"));
std::string msg = h.first_message("(synth \"osc/sine\" :amp 0.1)");
REQUIRE(msg.find("freq") != std::string::npos);
}
SECTION("empty explicit identity") {
REQUIRE(h.eval_fails(
"(synth \"osc/sine\" :name \"\" :freq 440)"));
std::string msg = h.first_message(
"(synth \"osc/sine\" :name \"\" :freq 440)");
REQUIRE(msg.find("identity") != std::string::npos);
}
}
// ============================================================================
// VAL-COMP-007: Synth is top-level-only
// ============================================================================
TEST_CASE("synth: nested synth form is rejected",
"[synth][val-comp-007]") {
SynthHarness h;
// A synth form appears inside an output assignment — must be rejected
// as boundary violation. The synth graph must remain empty.
REQUIRE(h.eval_fails("(a1 (synth \"osc/sine\" :freq 440))"));
REQUIRE(h.engine.synth_graph.declaration_count() == 0);
}
// ============================================================================
// VAL-COMP-008: Multi-form eval is a sequence of form transactions
// ============================================================================
TEST_CASE("synth: multi-form eval retains earlier committed forms",
"[synth][val-comp-008]") {
SynthHarness h;
// First: a successful eval establishes baseline artefacts at revision 1.
REQUIRE(h.eval_ok("(synth \"osc/sine\" :name \"lead\" :freq 440)"));
uint32_t rev_after_first = h.engine.synth_graph.revision;
uint16_t decl_count = h.engine.synth_graph.declaration_count();
// Second: a multi-form eval where a LATER form fails. The first child is
// already committed; evaluation stops before any later sibling.
REQUIRE(h.eval_fails(
"(do (synth \"osc/sine\" :name \"lead\" :freq 880) "
" (synth \"osc/unknown\" :freq 110))"));
REQUIRE(h.engine.synth_graph.revision > rev_after_first);
REQUIRE(h.engine.synth_graph.declaration_count() == decl_count);
const NodeDefParam* parameter =
h.engine.synth_graph.parameter_for_control(0);
REQUIRE(parameter != nullptr);
REQUIRE(std::string(parameter->name) == "freq");
uint16_t freq_root = h.engine.synth_graph.controls[0].root_node;
REQUIRE(freq_root != NODE_NONE);
REQUIRE(h.engine.pool.nodes[freq_root].op == NodeOp::Const);
REQUIRE(h.engine.pool.nodes[freq_root].imm == Approx(880.0));
}
// ============================================================================
// VAL-COMP-009: Successful artefacts share one revision
// ============================================================================
TEST_CASE("synth: graph and control table share one revision",
"[synth][val-comp-009]") {
SynthHarness h;
REQUIRE(h.eval_ok("(synth \"osc/sine\" :freq 440 :amp 0.1)"));
uint32_t graph_rev = h.engine.synth_graph.revision;
// The control table is published in the same graph structure; the
// public JSON snapshot reports the revision for both.
std::string snap = snapshot_synth_artifacts(h);
REQUIRE(snap.find("\"revision\"") != std::string::npos);
REQUIRE(snap.find(std::to_string(graph_rev)) != std::string::npos);
}
// ============================================================================
// VAL-COMP-010: Failed eval preserves last successful artefacts
// ============================================================================
TEST_CASE("synth: failed eval preserves previous artefacts",
"[synth][val-comp-010]") {
SynthHarness h;
REQUIRE(h.eval_ok("(synth \"osc/sine\" :name \"lead\" :freq 440)"));
uint32_t rev_ok = h.engine.synth_graph.revision;
std::string snap_ok = snapshot_synth_artifacts(h);
// A subsequent failed eval must not advance revision or change the
// published artefact snapshot.
REQUIRE(h.eval_fails("(synth \"osc/unknown\" :freq 440)"));
REQUIRE(h.engine.synth_graph.revision == rev_ok);
REQUIRE(snapshot_synth_artifacts(h) == snap_ok);
}
TEST_CASE("synth: rejected control compilation restores graph resources",
"[synth][transaction][state_identity]") {
SynthHarness h;
REQUIRE(h.eval_ok(
"(synth \"osc/sine\" :name \"lead\" "
":freq (phasor 1 :id \"synth-phase\") :amp 0.2)"));
uint32_t rev_ok = h.engine.synth_graph.revision;
std::string snap_ok = snapshot_synth_artifacts(h);
uint16_t slots_ok = h.engine.pool.state_slot_count;
uint16_t entries_ok = h.engine.registry.entry_count;
uint16_t update_ok = h.engine.pool.state_update_roots[0];
// The first binding encounters and attempts to rewrite the existing
// state resource before the later undefined binding rejects the form.
REQUIRE(h.eval_fails(
"(synth \"osc/sine\" :name \"lead\" "
":freq (phasor 2 :id \"synth-phase\") :amp missing-control)"));
REQUIRE(h.engine.synth_graph.revision == rev_ok);
REQUIRE(snapshot_synth_artifacts(h) == snap_ok);
REQUIRE(h.engine.pool.state_slot_count == slots_ok);
REQUIRE(h.engine.registry.entry_count == entries_ok);
REQUIRE(h.engine.pool.state_update_roots[0] == update_ok);
}
TEST_CASE("synth: nested success cannot overwrite outer rollback image",
"[synth][transaction][nested][rollback]") {
SynthHarness h;
REQUIRE(h.eval_ok(
"(synth \"osc/sine\" :name \"lead\" "
":freq (phasor 1 :id \"lead-phase\") :amp 0.2)"));
const uint32_t revision = h.engine.synth_graph.revision;
const std::string artifact = snapshot_synth_artifacts(h);
const uint16_t slots = h.engine.pool.state_slot_count;
const uint16_t entries = h.engine.registry.entry_count;
const uint16_t update_root = h.engine.pool.state_update_roots[0];
const double state_value = h.engine.pool.state_values[0];
REQUIRE(h.eval_fails(
"(synth \"osc/sine\" :name \"lead\" "
":freq (phasor 2 :id \"lead-phase\") "
":fm (synth \"osc/sine\" :name \"child\" :freq 3) "
":amp missing-control)"));
REQUIRE(h.engine.synth_graph.revision == revision);
REQUIRE(snapshot_synth_artifacts(h) == artifact);
REQUIRE(h.engine.synth_graph.find("child") == nullptr);
REQUIRE(h.engine.pool.state_slot_count == slots);
REQUIRE(h.engine.registry.entry_count == entries);
REQUIRE(h.engine.pool.state_update_roots[0] == update_root);
REQUIRE(h.engine.pool.state_values[0] == Approx(state_value));
}
// ============================================================================
// VAL-COMP-011: Synth control roots survive GC
// ============================================================================
TEST_CASE("synth: control roots remain executable after GC",
"[synth][val-comp-011]") {
SynthHarness h;
REQUIRE(h.eval_ok("(synth \"osc/sine\" :name \"lead\" :freq 440)"));
// Force a garbage collection pass on the engine pool. The synth-aware GC
// path preserves control roots registered via external_roots[].
h.gc();
// The synth control roots must still be present in the control table
// and still compile to a valid node index.
const auto& graph = h.engine.synth_graph;
REQUIRE(graph.control_count() == 1);
for (uint16_t i = 0; i < graph.control_count(); i++) {
REQUIRE(graph.controls[i].root_node != NODE_NONE);
REQUIRE(graph.controls[i].root_node < h.engine.pool.node_count);
}
}
// ============================================================================
// VAL-COMP-012: Public artefacts use stable identifiers (no internal indices)
// ============================================================================
TEST_CASE("synth: public artefacts use stable identifiers",
"[synth][val-comp-012]") {
SynthHarness h;
REQUIRE(h.eval_ok("(synth \"osc/sine\" :name \"lead\" :freq 440 :amp 0.1)"));
std::string snap = snapshot_synth_artifacts(h);
// The serialised artefact must expose the user-visible identity ("lead")
// and must NOT expose internal GC-remapped node indices. We assert that
// every declaration entry has an "identity" field and no entry exposes
// internal "node_index" / "remapped_index" keys.
REQUIRE(snap.find("\"identity\"") != std::string::npos);
REQUIRE(snap.find("\"lead\"") != std::string::npos);
REQUIRE(snap.find("node_index") == std::string::npos);
REQUIRE(snap.find("remapped") == std::string::npos);
}
// ============================================================================
// VAL-COMP-018: Dynamic control expressions remain executable after commit/GC
// ============================================================================
TEST_CASE("synth: time-dependent and input-dependent controls compile",
"[synth][val-comp-018]") {
SynthHarness h;
SECTION("time-dependent freq expr") {
REQUIRE(h.eval_ok("(synth \"osc/sine\" :freq (* 220 (sin bar)))"));
const auto& graph = h.engine.synth_graph;
REQUIRE(graph.control_count() == 1);
// Force GC; the control root must remain executable.
h.gc();
REQUIRE(graph.controls[0].root_node != NODE_NONE);
REQUIRE(graph.controls[0].root_node < h.engine.pool.node_count);
}
SECTION("input-dependent freq expr") {
REQUIRE(h.eval_ok("(synth \"osc/sine\" :freq (* 220 (+ 1 ain1)))"));
const auto& graph = h.engine.synth_graph;
REQUIRE(graph.control_count() == 1);
h.gc();
REQUIRE(graph.controls[0].root_node != NODE_NONE);
}
SECTION("amp dependent on cell") {
REQUIRE(h.eval_ok("(define env 0.5)"));
REQUIRE(h.eval_ok("(synth \"osc/sine\" :freq 440 :amp env)"));
const auto& graph = h.engine.synth_graph;
REQUIRE(graph.control_count() == 2);
// Changing the cell must keep the synth control table intact.
REQUIRE(h.eval_ok("(define env 0.9)"));
REQUIRE(h.engine.synth_graph.control_count() == 2);
}
}
// ============================================================================
// VAL-COMP-019: bounded M2 declaration capacity fails transactionally
// ============================================================================
TEST_CASE("synth: M2 declaration capacity fails transactionally",
"[synth][val-comp-019]") {
SynthHarness h;
for (uint16_t i = 0; i < MAX_SYNTH_DECLARATIONS; i++) {
REQUIRE(h.eval_ok("(synth \"osc/sine\" :name \"n" +
std::to_string(i) + "\" :freq 440)"));
}
REQUIRE(h.engine.synth_graph.declaration_count() ==
MAX_SYNTH_DECLARATIONS);
SynthRevision rev_baseline = h.engine.synth_graph.revision;
std::string snapshot = snapshot_synth_artifacts(h);
REQUIRE(h.eval_fails(
"(synth \"osc/sine\" :name \"overflow\" :freq 110)"));
REQUIRE(h.engine.synth_graph.revision == rev_baseline);
REQUIRE(snapshot_synth_artifacts(h) == snapshot);
REQUIRE(h.eval_ok(
"(synth \"osc/sine\" :name \"n0\" :freq 660)"));
REQUIRE(h.engine.synth_graph.declaration_count() ==
MAX_SYNTH_DECLARATIONS);
}
TEST_CASE("synth: named FM routing publishes an ABI-2 connection",
"[synth][routing][m2]") {
SynthHarness h;
REQUIRE(h.eval_ok(
"(synth \"osc/sine\" :name \"lfo\" :freq 2)"));
REQUIRE(h.eval_ok(
"(synth \"osc/sine\" :name \"carrier\" :freq 440 "
":fm (node \"lfo\"))"));
const SynthGraph& graph = h.engine.synth_graph;
REQUIRE(graph.declaration_count() == 2);
REQUIRE(graph.connection_count() == 1);
REQUIRE(std::string(graph.connections[0].from) == "lfo");
REQUIRE(std::string(graph.connections[0].to) == "carrier");
REQUIRE(std::string(graph.connections[0].port) == "fm");
REQUIRE(graph.connections[0].port_index == 0);
std::string json = snapshot_synth_artifacts(h);
REQUIRE(json.find("\"connections\"") != std::string::npos);
REQUIRE(json.find("\"port_index\":0") != std::string::npos);
}
TEST_CASE("synth: nested FM source is declared before its connection commits",
"[synth][routing][nested][m2]") {
SynthHarness h;
REQUIRE(h.eval_ok(
"(synth \"osc/sine\" :name \"carrier\" :freq 440 "
":fm (synth \"osc/sine\" :name \"lfo\" :freq 2))"));
REQUIRE(h.engine.synth_graph.declaration_count() == 2);
REQUIRE(h.engine.synth_graph.connection_count() == 1);
REQUIRE(std::string(h.engine.synth_graph.connections[0].from) == "lfo");
REQUIRE(std::string(h.engine.synth_graph.connections[0].to) == "carrier");
REQUIRE(h.engine.synth_graph.revision == 1);
}
TEST_CASE("synth: routing failures preserve the complete prior artefact",
"[synth][routing][transaction][m2]") {
SynthHarness h;
REQUIRE(h.eval_ok(
"(synth \"osc/sine\" :name \"a\" :freq 2)"));
REQUIRE(h.eval_ok(
"(synth \"osc/sine\" :name \"b\" :freq 440 :fm (node \"a\"))"));
SynthRevision baseline_revision = h.engine.synth_graph.revision;
std::string baseline = snapshot_synth_artifacts(h);
SECTION("unknown endpoint") {
REQUIRE(h.eval_fails(
"(synth \"osc/sine\" :name \"b\" :freq 440 "
":fm (node \"missing\"))"));
}
SECTION("cross-eval cycle") {
REQUIRE(h.eval_fails(
"(synth \"osc/sine\" :name \"a\" :freq 2 "
":fm (node \"b\"))"));
}
SECTION("arbitrary expression is not an audio endpoint") {
REQUIRE(h.eval_fails(
"(synth \"osc/sine\" :name \"b\" :freq 440 :fm (+ 1 2))"));
}
REQUIRE(h.engine.synth_graph.revision == baseline_revision);
REQUIRE(snapshot_synth_artifacts(h) == baseline);
}
TEST_CASE("synth: destination update replaces only its incoming edge",
"[synth][routing][update][m2]") {
SynthHarness h;
REQUIRE(h.eval_ok("(synth \"osc/sine\" :name \"a\" :freq 2)"));
REQUIRE(h.eval_ok("(synth \"osc/sine\" :name \"c\" :freq 3)"));
REQUIRE(h.eval_ok(
"(synth \"osc/sine\" :name \"b\" :freq 440 :fm (node \"a\"))"));
REQUIRE(h.engine.synth_graph.connection_count() == 1);
REQUIRE(h.eval_ok(
"(synth \"osc/sine\" :name \"b\" :freq 440 :fm (node \"c\"))"));
REQUIRE(h.engine.synth_graph.connection_count() == 1);
REQUIRE(std::string(h.engine.synth_graph.connections[0].from) == "c");
REQUIRE(std::string(h.engine.synth_graph.connections[0].to) == "b");
}
TEST_CASE("synth: control ownership is stable across parameter reordering",
"[synth][state_identity][m2]") {
SynthHarness h;
REQUIRE(h.eval_ok(
"(synth \"osc/sine\" :name \"lead\" "
":freq (phasor 1) :amp (phasor 2))"));
uint16_t slots = h.engine.pool.state_slot_count;
uint16_t freq_context = h.engine.synth_graph.controls[0].owner_context;
uint16_t amp_context = h.engine.synth_graph.controls[1].owner_context;
REQUIRE(freq_context != amp_context);
REQUIRE(h.eval_ok(
"(synth \"osc/sine\" :name \"lead\" "
":amp (phasor 2) :freq (phasor 1))"));
REQUIRE(h.engine.pool.state_slot_count == slots);
const SynthControlChannel* freq = nullptr;
const SynthControlChannel* amp = nullptr;
for (uint16_t i = 0; i < h.engine.synth_graph.control_count(); i++) {
const SynthControlChannel& control =
h.engine.synth_graph.controls[i];
const NodeDefParam* parameter =
h.engine.synth_graph.parameter_for_control(i);
REQUIRE(parameter != nullptr);
if (std::string(parameter->name) == "freq") freq = &control;
if (std::string(parameter->name) == "amp") amp = &control;
}
REQUIRE(freq != nullptr);
REQUIRE(amp != nullptr);
REQUIRE(freq->owner_context == freq_context);
REQUIRE(amp->owner_context == amp_context);
}
TEST_CASE("synth: dynamic control roots execute and react to cell changes",
"[synth][reactive][execution][m2]") {
SynthHarness h;
REQUIRE(h.eval_ok("(define base 220)"));
REQUIRE(h.eval_ok(
"(synth \"osc/sine\" :name \"lead\" :freq (+ base (* 10 bar)))"));
REQUIRE(h.sample_control(0, 0.0) == Approx(220.0));
REQUIRE(h.sample_control(0, 1.0) == Approx(225.0));
REQUIRE(h.eval_ok("(define base 330)"));
REQUIRE(h.sample_control(0, 0.0) == Approx(330.0));
REQUIRE(h.engine.synth_graph.controls[0].source_length > 0);
}
TEST_CASE("synth: malformed trailing input and invalid version are atomic",
"[synth][adversarial][transaction]") {
SynthHarness h;
REQUIRE(h.eval_ok(
"(synth \"osc/sine\" :name \"lead\" :freq 440)"));
std::string baseline = snapshot_synth_artifacts(h);
SynthRevision revision = h.engine.synth_graph.revision;
REQUIRE(h.eval_fails(
"(synth \"osc/sine\" :name \"lead\" :freq 880 999)"));
REQUIRE(h.eval_fails(
"(synth \"osc/sine\" :version 2.5 :name \"lead\" :freq 880)"));
REQUIRE(snapshot_synth_artifacts(h) == baseline);
REQUIRE(h.engine.synth_graph.revision == revision);
}
// ============================================================================
// Hidden identity: anonymous synth gets a stable hidden id from the payload
// builder. The compiler just needs to accept it and retain it.
// ============================================================================
TEST_CASE("synth: anonymous form retains supplied hidden identity",
"[synth][synth-anon-identity]") {
SynthHarness h;
// A hidden :id keyword mirrors what the payload builder injects for an
// anonymous synth. The compiler must accept it and treat it as the
// authoritative identity.
REQUIRE(h.eval_ok("(synth \"osc/sine\" :id \"::anon-1\" :freq 440)"));
REQUIRE(std::string(h.engine.synth_graph.declarations[0].identity)
== std::string("::anon-1"));
// Re-evaluating with the same hidden id must be treated as an
// update-in-place rather than capacity overflow.
REQUIRE(h.eval_ok("(synth \"osc/sine\" :id \"::anon-1\" :freq 880)"));
REQUIRE(h.engine.synth_graph.declaration_count() == 1);
}
// ============================================================================
// with-state-id wrapper identity (ergo e58f128f). The editor payload builder
// wraps anonymous top-level synth forms in `(with-state-id "<id>" ...)`.
// Per state-identity.md §2.2 the wrapper and `:id` normalise to the same
// internal identity annotation, so the wrapper id must become the synth's
// identity when the form carries no explicit :name/:id. Resolution order:
// explicit :name/:id > wrapper id > anonymous per-eval ordinal fallback.
// ============================================================================
TEST_CASE("synth: with-state-id wrapper id becomes anonymous synth identity",
"[synth][with-state-id][synth-anon-identity]") {
SynthHarness h;
// First eval instantiates under the wrapper-supplied identity.
REQUIRE(h.eval_ok(
"(with-state-id \"sid-A\" (synth \"osc/sine\" :freq 440))"));
REQUIRE(h.engine.synth_graph.declaration_count() == 1);
REQUIRE(std::string(h.engine.synth_graph.declarations[0].identity)
== std::string("sid-A"));
// Re-eval with a changed param must be update-in-place — same identity,
// no "another identity already active" capacity error — across at
// least 3 re-evals (M1 acceptance, synth-nodes.md §5.1/§5.5).
REQUIRE(h.eval_ok(
"(with-state-id \"sid-A\" (synth \"osc/sine\" :freq 660))"));
REQUIRE(h.engine.synth_graph.declaration_count() == 1);
REQUIRE(std::string(h.engine.synth_graph.declarations[0].identity)
== std::string("sid-A"));
REQUIRE(h.eval_ok(
"(with-state-id \"sid-A\" (synth \"osc/sine\" :freq 660))"));
REQUIRE(h.eval_ok(
"(with-state-id \"sid-A\" (synth \"osc/sine\" :freq 550))"));
REQUIRE(h.engine.synth_graph.declaration_count() == 1);
REQUIRE(std::string(h.engine.synth_graph.declarations[0].identity)
== std::string("sid-A"));
}
TEST_CASE("synth: explicit :name takes precedence over with-state-id wrapper",
"[synth][with-state-id][val-comp-004]") {
SynthHarness h;
// The user-visible :name is authoritative; the wrapper id is sidecar
// metadata (synth-nodes.md §5.1: :name is sugar for the state identity).
REQUIRE(h.eval_ok(
"(with-state-id \"sid-B\" "
"(synth \"osc/sine\" :name \"lead\" :freq 440))"));
REQUIRE(h.engine.synth_graph.declaration_count() == 1);
REQUIRE(std::string(h.engine.synth_graph.declarations[0].identity)
== std::string("lead"));
// Re-eval under the same wrapper + name stays one declaration.
REQUIRE(h.eval_ok(
"(with-state-id \"sid-B\" "
"(synth \"osc/sine\" :name \"lead\" :freq 660))"));
REQUIRE(h.engine.synth_graph.declaration_count() == 1);
REQUIRE(std::string(h.engine.synth_graph.declarations[0].identity)
== std::string("lead"));
}
TEST_CASE("synth: wrapper id does not leak past its wrapped form",
"[synth][with-state-id]") {
SynthHarness h;
// A named synth under the wrapper consumes the pending wrapper id
// (first-synth-wins); the id must be cleared when the wrapper form
// ends either way. A later anonymous synth (after clearing the graph)
// must fall back to the anonymous scheme, not inherit "sid-C".
REQUIRE(h.eval_ok(
"(with-state-id \"sid-C\" "
"(synth \"osc/sine\" :name \"lead\" :freq 440))"));
REQUIRE(h.eval_ok("(useq-clear)"));
REQUIRE(h.eval_ok("(synth \"osc/sine\" :freq 220)"));
REQUIRE(h.engine.synth_graph.declaration_count() == 1);
REQUIRE(std::string(h.engine.synth_graph.declarations[0].identity)
!= std::string("sid-C"));
}
TEST_CASE("synth: useq-clear publishes one empty-graph revision",
"[synth][clear][revision]") {
SynthHarness h;
REQUIRE(h.eval_ok("(synth \"osc/sine\" :freq 440)"));
SynthRevision before = h.engine.synth_graph.revision;
REQUIRE(h.eval_ok("(useq-clear)"));
REQUIRE(h.engine.synth_graph.declaration_count() == 0);
REQUIRE(h.engine.synth_graph.control_count() == 0);
REQUIRE(h.engine.synth_graph.revision == before + 1);
// A later form's error cannot resurrect the pre-clear graph: its control
// roots referred to the node pool that clear has reclaimed.
REQUIRE_FALSE(h.eval_ok("(synth \"osc/sine\" :freq 220) (useq-clear) "
"(set-bpm nope)"));
REQUIRE(h.engine.synth_graph.declaration_count() == 0);
REQUIRE(h.engine.synth_graph.control_count() == 0);
REQUIRE(h.engine.pool.external_root_count == 0);
}
TEST_CASE("synth: unwrapped anonymous synth re-eval updates in place",
"[synth][synth-anon-identity]") {
SynthHarness h;
// state-identity.md §2.5: the anonymous fallback derives from the
// ordinal position within the compile, so re-evaluating the same
// program reuses the identity instead of leaking one per eval (and,
// in M1, instead of failing the single-node capacity check).
REQUIRE(h.eval_ok("(synth \"osc/sine\" :freq 440)"));
std::string first_id = h.engine.synth_graph.declarations[0].identity;
REQUIRE(h.eval_ok("(synth \"osc/sine\" :freq 660)"));
REQUIRE(h.engine.synth_graph.declaration_count() == 1);
REQUIRE(std::string(h.engine.synth_graph.declarations[0].identity)
== first_id);
}
// ============================================================================
// GC pairing: param-only re-evals must not leak pool nodes. Before the
// commit-time GC in eval_cold, 40 re-evals grew node_count 12 -> 88 with
// zero reclamation (ergo 72ff4fa5); the pool (MAX_TOTAL_NODES) would fill
// after ~100 edits and unrelated compiles would start failing.
// ============================================================================
TEST_CASE("synth: param re-evals reclaim replaced control graphs",
"[synth][synth-gc-pairing]") {
SynthHarness h;
// Establish the steady-state shape first, then capture the baseline.
REQUIRE(h.eval_ok("(synth \"osc/sine\" :name \"lead\" "
":freq (+ 0 (* 2 bar)))"));
uint16_t baseline = h.engine.pool.node_count;
for (int i = 1; i <= 40; i++) {
std::string code = "(synth \"osc/sine\" :name \"lead\" :freq (+ " +
std::to_string(i) + " (* 2 bar)))";
REQUIRE(h.eval_ok(code));
}
// Each re-eval compiles a fresh param graph; commit-time GC must
// reclaim the replaced one so the pool stays bounded near baseline
// instead of growing linearly.
REQUIRE(h.engine.pool.node_count <= baseline + 8);
// The surviving declaration and its control roots must stay valid.
REQUIRE(h.engine.synth_graph.declaration_count() == 1);
for (uint16_t i = 0; i < h.engine.synth_graph.control_count(); i++) {
REQUIRE(h.engine.synth_graph.controls[i].root_node
< h.engine.pool.node_count);
}
}

View file

@ -0,0 +1,248 @@
// Synth WASM ABI descriptor and Worker-response correlation tests
// (synth-nodes.md §7.2 / VAL-COMP-013..017).
//
// These tests cover the versioned synth artefact ABI surface exposed from
// the generated interpreter WASM bundle:
//
// VAL-COMP-015: The synth artefact ABI is versioned and rejects
// incompatible consumers.
// VAL-COMP-016: Native and generated-WASM compilation produce
// equivalent normalised artefacts for the synth corpus.
//
// The Worker-response atomicity (VAL-COMP-013/014) is covered by the
// root Mocha integration tests. The "fresh root assets load the current
// interpreter ABI" assertion (VAL-COMP-017) is exercised by the root
// rebuild + agent-browser validator.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include "src/signal_engine/synth_graph.h"
#include "src/signal_engine/synth_registry.h"
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
using namespace sig;
namespace {
struct SynthHarness {
SignalEngine engine;
SynthHarness() { engine.init_defaults(); }
EvalResult eval(const std::string& code)
{
return eval_cold(code.c_str(), static_cast<uint32_t>(code.size()), engine);
}
bool eval_ok(const std::string& code)
{
EvalResult r = eval(code);
INFO("code: " << code);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
for (uint8_t i = 0; i < r.diagnostic_count; i++) {
INFO("diagnostic[" << i << "]: "
<< (r.diagnostics[i].message ? r.diagnostics[i].message : ""));
}
}
return r.kind != EvalResult::Error;
}
bool eval_fails(const std::string& code)
{
EvalResult r = eval(code);
return r.kind == EvalResult::Error;
}
};
std::string snapshot_synth_artifacts(const SynthHarness& h)
{
const char* json = synth_artifacts_json(h.engine);
return json ? std::string(json) : std::string();
}
} // namespace
// ============================================================================
// VAL-COMP-015: Synth artefact ABI is versioned
// ============================================================================
TEST_CASE("synth: artefact ABI exposes a version marker",
"[synth][val-comp-015]") {
SynthHarness h;
// The published artefact JSON must carry an `abi` field naming the
// version the engine's synth-graph module advertises. Consumers read
// this before touching the payload.
//
// NOTE: the wasm wrapper wraps the body with an `abi` field. Native
// callers reach the same surface through synth_artifacts_supports_abi()
// which returns true for the engine's declared version and false for
// incompatible consumers.
REQUIRE(SYNTH_ARTIFACT_ABI_VERSION > 0);
SECTION("supports current abi") {
REQUIRE(synth_artifacts_supports_abi(SYNTH_ARTIFACT_ABI_VERSION));
}
SECTION("rejects incompatible abi") {
// A future consumer built against abi=99 must be rejected explicitly
// rather than silently misreading the layout.
REQUIRE_FALSE(synth_artifacts_supports_abi(SYNTH_ARTIFACT_ABI_VERSION + 1));
REQUIRE_FALSE(synth_artifacts_supports_abi(99));
REQUIRE_FALSE(synth_artifacts_supports_abi(0));
}
}
TEST_CASE("synth: wasm abi wrapper marks the snapshot with abi version",
"[synth][val-comp-015]") {
// Mirrors the wrapper that useq_synth_artifacts() applies in the WASM
// bundle. The C helper wraps the raw synth_artifacts_json body with an
// `abi` marker so a consumer can reject incompatible bundles up front.
SynthHarness h;
REQUIRE(h.eval_ok("(synth \"osc/sine\" :name \"lead\" :freq 440)"));
char buf[SYNTH_ARTIFACT_JSON_CAP + 64];
bool ok = synth_artifacts_render_abi_wrapper(
h.engine, SYNTH_ARTIFACT_ABI_VERSION, buf, sizeof(buf));
REQUIRE(ok);
std::string wrapped(buf);
REQUIRE(wrapped.find("\"abi\":") != std::string::npos);
REQUIRE(wrapped.find(std::to_string(SYNTH_ARTIFACT_ABI_VERSION))
!= std::string::npos);
// An incompatible consumer-version must be rejected by the helper up
// front (the wrapper still renders a minimal error object so the wire
// shape stays valid JSON).
bool rejected = synth_artifacts_render_abi_wrapper(
h.engine, SYNTH_ARTIFACT_ABI_VERSION + 7, buf, sizeof(buf));
REQUIRE_FALSE(rejected);
}
TEST_CASE("synth: maximum accepted shipped graph fits artifact capacity",
"[synth][capacity][serialization]") {
SynthHarness h;
for (uint16_t i = 0; i < SYNTH_MAX_NODES; i++) {
char id[32];
std::snprintf(id, sizeof(id), "node-%026u", (unsigned)i);
std::string code = "(synth \"osc/sine\" :name \"";
code += id;
code += "\" :freq 440 :amp 0.25)";
INFO("declaration " << i);
REQUIRE(h.eval_ok(code));
}
REQUIRE(h.engine.synth_graph.declaration_count() == SYNTH_MAX_NODES);
REQUIRE(h.engine.synth_graph.control_count() == SYNTH_MAX_NODES * 2);
char body[SYNTH_ARTIFACT_JSON_CAP];
REQUIRE(synth_graph_render_json(
h.engine.synth_graph, body, sizeof(body)));
REQUIRE(std::string(body).find("\"error\"") == std::string::npos);
char wrapped[SYNTH_ARTIFACT_JSON_CAP + 64];
REQUIRE(synth_artifacts_render_abi_wrapper(
h.engine, SYNTH_ARTIFACT_ABI_VERSION,
wrapped, sizeof(wrapped)));
REQUIRE(std::string(wrapped).find("artifact_error") == std::string::npos);
}
// ============================================================================
// VAL-COMP-016: Native and WASM corpus equivalence
// ============================================================================
TEST_CASE("synth: native and wasm-shaped artefacts agree on the corpus",
"[synth][val-comp-016]") {
// The generated WASM bundle serialises through synth_artifacts_json()
// (the same path as synth_graph_render_json_scratch). The native test
// path therefore exercises the exact bytes the WASM consumer will see.
//
// We verify that for every corpus form the serialised snapshot reports
// the same revision as the engine's live synth_graph and the same
// declaration/control identity names. Failed forms leave the previous
// artefact snapshot unchanged on both sides.
SynthHarness h;
struct CorpusCase {
const char* label;
const char* code;
bool should_commit;
uint16_t expected_decl_count;
uint16_t expected_ctl_count;
};
CorpusCase corpus[] = {
{ "minimum sine", "(synth \"osc/sine\" :freq 440)", true, 1, 1 },
{ "amp form", "(synth \"osc/sine\" :freq 440 :amp 0.1)", true, 1, 2 },
{ "named identity", "(synth \"osc/sine\" :name \"lead\" :freq 440)", true, 1, 1 },
{ "unknown def", "(synth \"osc/unknown\" :freq 440)", false, 0, 0 },
{ "missing required", "(synth \"osc/sine\" :amp 0.1)", false, 0, 0 },
{ "duplicate param", "(synth \"osc/sine\" :freq 440 :freq 880)", false, 0, 0 },
};
SynthRevision last_committed_rev = 0;
std::string last_committed_snap;
for (const auto& tc : corpus) {
// Reset the engine between corpus rows so each form starts from a
// clean slate and we can assert byte-stable snapshots.
SynthHarness local;
bool ok = local.eval_ok(tc.code);
REQUIRE(ok == tc.should_commit);
if (tc.should_commit) {
REQUIRE(local.engine.synth_graph.declaration_count()
== tc.expected_decl_count);
REQUIRE(local.engine.synth_graph.control_count()
== tc.expected_ctl_count);
// The snapshot must mention the registry-known def name and the
// parameter name(s) for every bound control.
std::string snap = snapshot_synth_artifacts(local);
REQUIRE(snap.find("osc/sine") != std::string::npos);
REQUIRE(snap.find("\"freq\"") != std::string::npos);
// The serialised revision must equal the live engine revision.
std::string rev_token =
"\"revision\":" + std::to_string(local.engine.synth_graph.revision);
REQUIRE(snap.find(rev_token) != std::string::npos);
last_committed_rev = local.engine.synth_graph.revision;
last_committed_snap = snap;
} else {
// A failed form must not have advanced the revision in this
// fresh local harness — the engine starts at revision 0.
REQUIRE(local.engine.synth_graph.revision == 0);
REQUIRE(local.engine.synth_graph.declaration_count() == 0);
}
}
// Sanity: at least one form in the corpus committed and produced a
// non-empty snapshot so the equivalence assertion is meaningful.
REQUIRE(last_committed_rev > 0);
REQUIRE_FALSE(last_committed_snap.empty());
}
TEST_CASE("synth: failed later form preserves prior committed snapshot",
"[synth][val-comp-016]") {
// Mirrors the WASM differential scenario: a successful synth form
// publishes a snapshot at revision R; a subsequent failed form must
// leave the snapshot byte-equal to the post-success bytes and the
// engine revision unchanged.
SynthHarness h;
REQUIRE(h.eval_ok("(synth \"osc/sine\" :name \"lead\" :freq 440 :amp 0.1)"));
SynthRevision rev_ok = h.engine.synth_graph.revision;
std::string snap_ok = snapshot_synth_artifacts(h);
REQUIRE(h.eval_fails("(synth \"osc/unknown\" :freq 110)"));
REQUIRE(h.engine.synth_graph.revision == rev_ok);
REQUIRE(snapshot_synth_artifacts(h) == snap_ok);
}

View file

@ -0,0 +1,479 @@
// UGen tests: phasor, lfo, slew, one-pole, env-follow, sah, noise, toggle, count
//
// Tests exercise state-slot allocation, cross-tick accumulation, keyword
// parsing, and alias resolution.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include <cmath>
#include <cstring>
#include <string>
#include <vector>
#include <initializer_list>
using namespace sig;
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
namespace {
struct GoldenHarness {
SignalEngine engine;
double cell_values[MAX_CELLS] = {};
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
double prev_t = 0.0;
double last_dt = 0.0;
bool has_ticked = false;
bool state_committed_this_step = false;
explicit GoldenHarness(double bpm = 120.0, int beats_per_bar = 4) {
engine.init_defaults(bpm, beats_per_bar);
}
EvalResult eval_result(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_result(code);
INFO("code: " << code);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
INFO("diagnostic: " << (r.diagnostics[0].message ? r.diagnostics[0].message : ""));
INFO("suggestion: " << (r.diagnostics[0].suggestion ? r.diagnostics[0].suggestion : ""));
}
REQUIRE(r.kind != EvalResult::Error);
engine.pool.rebuild_execution_order();
}
void assign_ok(const char* output, const char* expr) {
eval_ok(std::string("(") + output + " " + expr + ")");
}
uint16_t output_index(const char* output_name) {
SymbolID sym = internSymbol(output_name);
uint16_t idx = GraphBuilder::resolve_output_index(sym);
REQUIRE(idx != NODE_NONE);
return idx;
}
double sample(const char* output_name, double t) {
std::memset(outputs, 0, sizeof(outputs));
std::memset(workspace, 0, sizeof(workspace));
engine.cells.snapshot_values(cell_values, MAX_CELLS);
last_dt = t - prev_t;
if (engine.pool.state_slot_count > 0 &&
has_ticked && t != prev_t && !state_committed_this_step)
{
ExecutionContext state_ctx;
state_ctx.t = t;
state_ctx.dt = last_dt;
state_ctx.cell_values = cell_values;
state_ctx.hw_inputs = hw_inputs;
state_ctx.data_pool = engine.cells.data_pool;
state_ctx.data_offsets = engine.cells.data_offsets;
state_ctx.data_lengths = engine.cells.data_lengths;
state_ctx.prev_outputs = engine.pool.prev_output_values;
state_ctx.output_values = outputs;
state_ctx.workspace = workspace;
execute_all_outputs(engine.pool, state_ctx);
commit_state(engine.pool, workspace);
state_committed_this_step = true;
std::memset(workspace, 0, sizeof(workspace));
std::memset(outputs, 0, sizeof(outputs));
}
ExecutionContext ctx;
ctx.t = t;
ctx.dt = last_dt;
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);
return outputs[output_index(output_name)];
}
double tick(const char* output_name, double t) {
if (t != prev_t) state_committed_this_step = false;
double value = sample(output_name, t);
commit_outputs(engine.pool, outputs);
has_ticked = true;
prev_t = t;
return value;
}
};
} // anonymous namespace
// ── phasor ─────────────────────────────────────────────────────────────────
TEST_CASE("UGen: phasor produces phase ramp [0,1)", "[ugens][phasor]") {
GoldenHarness h;
h.assign_ok("a1", "(phasor 1.0)");
REQUIRE(h.tick("a1", 0.0) == Approx(0.0));
REQUIRE(h.tick("a1", 0.1) == Approx(0.1));
REQUIRE(h.tick("a1", 0.2) == Approx(0.2));
}
TEST_CASE("UGen: phasor wraps at 1.0", "[ugens][phasor]") {
GoldenHarness h;
h.assign_ok("a1", "(phasor 1.0)");
// Tick through one full cycle (10 × dt=0.1 = 1.0s)
for (int i = 0; i < 10; i++) {
h.tick("a1", i * 0.1);
}
// After 1.0s: phase = frac(1.0) = 0.0
REQUIRE(h.tick("a1", 1.0) == Approx(0.0).margin(1e-10));
}
TEST_CASE("UGen: phasor with :phase init", "[ugens][phasor]") {
GoldenHarness h;
h.assign_ok("a1", "(phasor 1.0 :phase 0.5)");
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
}
TEST_CASE("UGen: phasor at 2Hz", "[ugens][phasor]") {
GoldenHarness h;
h.assign_ok("a1", "(phasor 2.0)");
REQUIRE(h.tick("a1", 0.0) == Approx(0.0));
// dt=0.1, state = frac(0 + 2.0*0.1) = 0.2
REQUIRE(h.tick("a1", 0.1) == Approx(0.2));
}
// ── lfo ─────────────────────────────────────────────────────────────────────
TEST_CASE("UGen: lfo default wave is sine", "[ugens][lfo]") {
GoldenHarness h;
h.assign_ok("a1", "(lfo 1.0)");
// usin(0) = (sin(0)+1)/2 = 0.5
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
// After dt=0.25: phase = 0.25, usin(0.25) = (sin(2π*0.25)+1)/2 = 1.0
REQUIRE(h.tick("a1", 0.25) == Approx(1.0));
}
TEST_CASE("UGen: lfo :wave :saw produces phasor output", "[ugens][lfo]") {
GoldenHarness h;
h.assign_ok("a1", "(lfo 1.0 :wave :saw)");
REQUIRE(h.tick("a1", 0.0) == Approx(0.0));
REQUIRE(h.tick("a1", 0.1) == Approx(0.1));
REQUIRE(h.tick("a1", 0.2) == Approx(0.2));
}
TEST_CASE("UGen: lfo :wave :tri produces triangle", "[ugens][lfo]") {
GoldenHarness h;
h.assign_ok("a1", "(lfo 1.0 :wave :tri)");
REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); // tri(0) = 0
// After dt=0.25: phase=0.25, tri(0.25) = 1 - |2*0.25 - 1| = 0.5
REQUIRE(h.tick("a1", 0.25) == Approx(0.5));
}
TEST_CASE("UGen: lfo :wave :sqr produces square", "[ugens][lfo]") {
GoldenHarness h;
h.assign_ok("a1", "(lfo 1.0 :wave :sqr)");
// phase=0 → frac(0)=0 < 0.5 → 1
REQUIRE(h.tick("a1", 0.0) == Approx(1.0));
// phase=0.1 → < 0.5 → 1
REQUIRE(h.tick("a1", 0.1) == Approx(1.0));
}
TEST_CASE("UGen: lfo :sqr with :pw", "[ugens][lfo]") {
GoldenHarness h;
h.assign_ok("a1", "(lfo 1.0 :wave :sqr :pw 0.25)");
// phase=0 < 0.25 → high
REQUIRE(h.tick("a1", 0.0) == Approx(1.0));
// phase=0.1 < 0.25 → still high
REQUIRE(h.tick("a1", 0.1) == Approx(1.0));
// phase=0.3 > 0.25 → low
h.tick("a1", 0.2);
REQUIRE(h.tick("a1", 0.3) == Approx(0.0));
}
// ── namespaced lfo waveforms ────────────────────────────────────────────────
TEST_CASE("UGen: lfo/sin is a unipolar sine lfo", "[ugens][lfo][namespace]") {
GoldenHarness h;
h.assign_ok("a1", "(lfo/sin 1.0)");
REQUIRE(h.tick("a1", 0.0) == Approx(0.5)); // usin(0) = 0.5
}
TEST_CASE("UGen: lfo/saw is a saw lfo", "[ugens][lfo][namespace]") {
GoldenHarness h;
h.assign_ok("a1", "(lfo/saw 1.0)");
REQUIRE(h.tick("a1", 0.0) == Approx(0.0));
REQUIRE(h.tick("a1", 0.1) == Approx(0.1));
}
TEST_CASE("UGen: lfo/tri is a triangle lfo", "[ugens][lfo][namespace]") {
GoldenHarness h;
h.assign_ok("a1", "(lfo/tri 1.0)");
REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); // tri(0) = 0
}
TEST_CASE("UGen: lfo/sqr is a square lfo", "[ugens][lfo][namespace]") {
GoldenHarness h;
h.assign_ok("a1", "(lfo/sqr 1.0)");
REQUIRE(h.tick("a1", 0.0) == Approx(1.0)); // phase < 0.5 → 1
}
// ── slew ────────────────────────────────────────────────────────────────────
TEST_CASE("UGen: slew limits rate of change", "[ugens][slew]") {
GoldenHarness h;
// Use ain1 (bare symbol, not function call) as input
h.assign_ok("a1", "(slew ain1 10.0)");
h.hw_inputs[8] = 0.0; // ain1 = INP_AI1 (index 8)
REQUIRE(h.tick("a1", 0.0) == Approx(0.0));
// Jump target to 1.0, dt=0.1, max_step = 10*0.1 = 1.0
h.hw_inputs[8] = 1.0;
REQUIRE(h.tick("a1", 0.1) == Approx(1.0)); // delta=1.0 ≤ step=1.0
}
TEST_CASE("UGen: slew clamps when target changes too fast", "[ugens][slew]") {
GoldenHarness h;
h.assign_ok("a1", "(slew ain1 1.0)");
h.hw_inputs[8] = 0.0;
REQUIRE(h.tick("a1", 0.0) == Approx(0.0));
// Target jumps to 10, max_step = 1.0*0.1 = 0.1
h.hw_inputs[8] = 10.0;
double val = h.tick("a1", 0.1);
REQUIRE(val == Approx(0.1));
val = h.tick("a1", 0.2);
REQUIRE(val == Approx(0.2));
}
// ── one-pole ────────────────────────────────────────────────────────────────
TEST_CASE("UGen: one-pole low-pass filter", "[ugens][one-pole]") {
GoldenHarness h;
h.assign_ok("a1", "(one-pole ain1 10.0)");
h.hw_inputs[8] = 1.0;
REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); // initial state = 0
// dt=0.01, alpha = min(1, 2*pi*10*0.01) ≈ 0.628
double val = h.tick("a1", 0.01);
double alpha = std::min(1.0, 2.0 * M_PI * 10.0 * 0.01);
REQUIRE(val == Approx(alpha).margin(0.01));
}
// ── env-follow ──────────────────────────────────────────────────────────────
TEST_CASE("UGen: env-follow tracks absolute input", "[ugens][env-follow]") {
GoldenHarness h;
h.assign_ok("a1", "(env-follow ain1 100.0 10.0)");
h.hw_inputs[8] = 1.0;
REQUIRE(h.tick("a1", 0.0) == Approx(0.0));
// dt=0.01, attack=100, a_coeff = min(1, 100*0.01) = 1.0
// diff = 1.0 - 0 = 1.0, new = 0 + 1.0*1.0 = 1.0
double val = h.tick("a1", 0.01);
REQUIRE(val == Approx(1.0));
}
TEST_CASE("UGen: envelope-follower is alias", "[ugens][env-follow][alias]") {
GoldenHarness h;
h.assign_ok("a1", "(envelope-follower ain1 100.0 10.0)");
h.hw_inputs[8] = 0.5;
REQUIRE(h.tick("a1", 0.0) == Approx(0.0));
}
// ── sah (sample-and-hold) ───────────────────────────────────────────────────
TEST_CASE("UGen: sah samples on rising edge", "[ugens][sah]") {
GoldenHarness h;
h.assign_ok("a1", "(sah ain1 ain2)");
h.hw_inputs[8] = 0.42; // input
h.hw_inputs[9] = 0.0; // trigger low
REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); // initial = 0, no trigger
// Trigger rises: ain2 goes from 0 → 1 (> 0.5)
h.hw_inputs[9] = 1.0;
double val = h.tick("a1", 0.1);
REQUIRE(val == Approx(0.42)); // sampled the input
// Trigger stays high, input changes — should hold
h.hw_inputs[8] = 0.99;
val = h.tick("a1", 0.2);
REQUIRE(val == Approx(0.42)); // still holding
// Trigger goes low, then high again with new value
h.hw_inputs[9] = 0.0;
h.tick("a1", 0.3);
h.hw_inputs[8] = 0.77;
h.hw_inputs[9] = 1.0;
val = h.tick("a1", 0.4);
REQUIRE(val == Approx(0.77)); // sampled new value
}
TEST_CASE("UGen: latch is alias for sah", "[ugens][sah][alias]") {
GoldenHarness h;
h.assign_ok("a1", "(latch ain1 ain2)");
h.hw_inputs[8] = 0.5;
h.hw_inputs[9] = 0.0;
h.tick("a1", 0.0);
h.hw_inputs[9] = 1.0;
REQUIRE(h.tick("a1", 0.1) == Approx(0.5));
}
// ── noise ───────────────────────────────────────────────────────────────────
TEST_CASE("UGen: noise produces values in [-1,1]", "[ugens][noise]") {
GoldenHarness h;
h.assign_ok("a1", "(noise)");
bool found_positive = false;
bool found_negative = false;
for (int i = 0; i < 20; i++) {
double val = h.tick("a1", i * 0.01);
REQUIRE(val >= -1.0);
REQUIRE(val <= 1.0);
if (val > 0.1) found_positive = true;
if (val < -0.1) found_negative = true;
}
REQUIRE(found_positive);
REQUIRE(found_negative);
}
TEST_CASE("UGen: noise is deterministic", "[ugens][noise]") {
GoldenHarness h1, h2;
h1.assign_ok("a1", "(noise)");
h2.assign_ok("a1", "(noise)");
for (int i = 0; i < 10; i++) {
double v1 = h1.tick("a1", i * 0.01);
double v2 = h2.tick("a1", i * 0.01);
REQUIRE(v1 == Approx(v2));
}
}
// ── toggle ──────────────────────────────────────────────────────────────────
TEST_CASE("UGen: toggle flips on rising edge", "[ugens][toggle]") {
GoldenHarness h;
h.assign_ok("a1", "(toggle ain1)");
h.hw_inputs[8] = 0.0;
REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); // initial = 0
// Trigger rises → toggle: 0 → 1
h.hw_inputs[8] = 1.0;
REQUIRE(h.tick("a1", 0.1) == Approx(1.0));
// Trigger stays high → no change
REQUIRE(h.tick("a1", 0.2) == Approx(1.0));
// Trigger goes low → no change
h.hw_inputs[8] = 0.0;
REQUIRE(h.tick("a1", 0.3) == Approx(1.0));
// Trigger rises again → toggle: 1 → 0
h.hw_inputs[8] = 1.0;
REQUIRE(h.tick("a1", 0.4) == Approx(0.0));
// And again → 0 → 1
h.hw_inputs[8] = 0.0;
h.tick("a1", 0.5);
h.hw_inputs[8] = 1.0;
REQUIRE(h.tick("a1", 0.6) == Approx(1.0));
}
// ── count ───────────────────────────────────────────────────────────────────
TEST_CASE("UGen: count increments on trigger", "[ugens][count]") {
GoldenHarness h;
h.assign_ok("a1", "(count ain1)");
h.hw_inputs[8] = 0.0;
REQUIRE(h.tick("a1", 0.0) == Approx(0.0));
// Rising edge → count = 1
h.hw_inputs[8] = 1.0;
REQUIRE(h.tick("a1", 0.1) == Approx(1.0));
// Still high → no increment
REQUIRE(h.tick("a1", 0.2) == Approx(1.0));
// Low → no increment
h.hw_inputs[8] = 0.0;
REQUIRE(h.tick("a1", 0.3) == Approx(1.0));
// Rising again → count = 2
h.hw_inputs[8] = 1.0;
REQUIRE(h.tick("a1", 0.4) == Approx(2.0));
}
TEST_CASE("UGen: count with :reset", "[ugens][count]") {
GoldenHarness h;
h.assign_ok("a1", "(count ain1 :reset ain2)");
h.hw_inputs[8] = 0.0; // trigger
h.hw_inputs[9] = 0.0; // reset
h.tick("a1", 0.0);
// Count up: 2 triggers
h.hw_inputs[8] = 1.0;
h.tick("a1", 0.1);
h.hw_inputs[8] = 0.0;
h.tick("a1", 0.2);
h.hw_inputs[8] = 1.0;
REQUIRE(h.tick("a1", 0.3) == Approx(2.0));
// Reset with rising edge on ain2
h.hw_inputs[9] = 1.0;
REQUIRE(h.tick("a1", 0.4) == Approx(0.0)); // count reset to 0
// Count again after reset: trigger goes low then high
h.hw_inputs[8] = 0.0;
h.hw_inputs[9] = 0.0;
h.tick("a1", 0.5); // trigger low, reset low
h.hw_inputs[8] = 1.0;
REQUIRE(h.tick("a1", 0.6) == Approx(1.0));
}
// ── Classification ──────────────────────────────────────────────────────────
TEST_CASE("Classification: UGens are stateful", "[ugens][classification]") {
GoldenHarness h;
h.assign_ok("a1", "(phasor 1.0)");
REQUIRE(h.engine.pool.output_class[h.output_index("a1")] == OutputClass::Stateful);
GoldenHarness h2;
h2.assign_ok("a1", "(lfo 1.0)");
REQUIRE(h2.engine.pool.output_class[h2.output_index("a1")] == OutputClass::Stateful);
GoldenHarness h3;
h3.assign_ok("a1", "(noise)");
REQUIRE(h3.engine.pool.output_class[h3.output_index("a1")] == OutputClass::Stateful);
}