feat: extract ModuLisp engine from uSEQ (src-useq @1081ae8)
This commit is contained in:
parent
85f49d58d9
commit
e51c490d09
73 changed files with 50985 additions and 9 deletions
14
.gitignore
vendored
14
.gitignore
vendored
|
|
@ -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
36
ALIGNMENT.md
Normal 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
37
MAP.md
Normal 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
9
library.properties
Normal 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
107
meson.build
Normal 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
916
src/devtools/devtools.cpp
Normal 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
94
src/devtools/devtools.h
Normal 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
|
||||
87
src/modulisp/lisp/symbol_intern.h
Normal file
87
src/modulisp/lisp/symbol_intern.h
Normal 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
61
src/pch.h
Normal 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
34
src/ports/II2CTransport.h
Normal 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
15
src/ports/IStorage.h
Normal 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_
|
||||
134
src/ports/mocks/MockI2CBus.h
Normal file
134
src/ports/mocks/MockI2CBus.h
Normal 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
|
||||
60
src/ports/mocks/MockStorage.h
Normal file
60
src/ports/mocks/MockStorage.h
Normal 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_
|
||||
14
src/signal_engine/build_profile.h
Normal file
14
src/signal_engine/build_profile.h
Normal 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
|
||||
148
src/signal_engine/cell_store.cpp
Normal file
148
src/signal_engine/cell_store.cpp
Normal 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
|
||||
105
src/signal_engine/cell_store.h
Normal file
105
src/signal_engine/cell_store.h
Normal 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
|
||||
3323
src/signal_engine/cold_eval.cpp
Normal file
3323
src/signal_engine/cold_eval.cpp
Normal file
File diff suppressed because it is too large
Load diff
236
src/signal_engine/cold_eval.h
Normal file
236
src/signal_engine/cold_eval.h
Normal 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
|
||||
125
src/signal_engine/compiler_pipeline.cpp
Normal file
125
src/signal_engine/compiler_pipeline.cpp
Normal 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
|
||||
95
src/signal_engine/compiler_pipeline.h
Normal file
95
src/signal_engine/compiler_pipeline.h
Normal 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
|
||||
111
src/signal_engine/diagnostics.cpp
Normal file
111
src/signal_engine/diagnostics.cpp
Normal 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
|
||||
49
src/signal_engine/diagnostics.h
Normal file
49
src/signal_engine/diagnostics.h
Normal 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
|
||||
91
src/signal_engine/eval_ops.h
Normal file
91
src/signal_engine/eval_ops.h
Normal 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
|
||||
447
src/signal_engine/executor.cpp
Normal file
447
src/signal_engine/executor.cpp
Normal 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
|
||||
112
src/signal_engine/executor.h
Normal file
112
src/signal_engine/executor.h
Normal 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
|
||||
4004
src/signal_engine/graph_builder.cpp
Normal file
4004
src/signal_engine/graph_builder.cpp
Normal file
File diff suppressed because it is too large
Load diff
471
src/signal_engine/graph_builder.h
Normal file
471
src/signal_engine/graph_builder.h
Normal 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
|
||||
517
src/signal_engine/node_pool.cpp
Normal file
517
src/signal_engine/node_pool.cpp
Normal 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
|
||||
275
src/signal_engine/node_pool.h
Normal file
275
src/signal_engine/node_pool.h
Normal 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
|
||||
15
src/signal_engine/signal_engine.h
Normal file
15
src/signal_engine/signal_engine.h
Normal 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
|
||||
113
src/signal_engine/state_registry.cpp
Normal file
113
src/signal_engine/state_registry.cpp
Normal 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
|
||||
102
src/signal_engine/state_registry.h
Normal file
102
src/signal_engine/state_registry.h
Normal 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
|
||||
310
src/signal_engine/symbols.def
Normal file
310
src/signal_engine/symbols.def
Normal 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
|
||||
208
src/signal_engine/synth_graph.cpp
Normal file
208
src/signal_engine/synth_graph.cpp
Normal 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
|
||||
331
src/signal_engine/synth_graph.h
Normal file
331
src/signal_engine/synth_graph.h
Normal 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
|
||||
160
src/signal_engine/synth_registry.cpp
Normal file
160
src/signal_engine/synth_registry.cpp
Normal 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
|
||||
116
src/signal_engine/synth_registry.h
Normal file
116
src/signal_engine/synth_registry.h
Normal 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
329
src/signal_engine/token.cpp
Normal 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
54
src/signal_engine/token.h
Normal 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
110
src/signal_engine/types.h
Normal 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
10
src/utils/common.cpp
Normal 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
192
src/utils/common.h
Normal 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_
|
||||
20
src/utils/compiler_config.h
Normal file
20
src/utils/compiler_config.h
Normal 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
52
src/utils/dtostrf.h
Normal 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
82
src/utils/itoa.cpp
Normal 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
46
src/utils/itoa.h
Normal 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
220
src/utils/json_builder.h
Normal 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
303
src/utils/json_cursor.h
Normal 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
424
src/utils/log.cpp
Normal 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
110
src/utils/log.h
Normal 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_
|
||||
28
src/utils/serial_message.h
Normal file
28
src/utils/serial_message.h
Normal 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
887
src/utils/string.cpp
Normal 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
460
src/utils/string.h
Normal 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
59
src/utils/time.h
Normal 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
21260
test/catch.hpp
Normal file
File diff suppressed because it is too large
Load diff
57
test/meson.build
Normal file
57
test/meson.build
Normal 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
|
||||
824
test/signal_engine/test_audit_fixes.cpp
Normal file
824
test/signal_engine/test_audit_fixes.cpp
Normal 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));
|
||||
}
|
||||
598
test/signal_engine/test_builtin_conformance.cpp
Normal file
598
test/signal_engine/test_builtin_conformance.cpp
Normal 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);
|
||||
}
|
||||
193
test/signal_engine/test_compiler_p1.cpp
Normal file
193
test/signal_engine/test_compiler_p1.cpp
Normal 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);
|
||||
}
|
||||
274
test/signal_engine/test_failure_mode.cpp
Normal file
274
test/signal_engine/test_failure_mode.cpp
Normal 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));
|
||||
}
|
||||
}
|
||||
369
test/signal_engine/test_health_diagnostics.cpp
Normal file
369
test/signal_engine/test_health_diagnostics.cpp
Normal 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);
|
||||
}
|
||||
340
test/signal_engine/test_live_edit.cpp
Normal file
340
test/signal_engine/test_live_edit.cpp
Normal 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);
|
||||
}
|
||||
81
test/signal_engine/test_node_pool_traversal.cpp
Normal file
81
test/signal_engine/test_node_pool_traversal.cpp
Normal 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);
|
||||
}
|
||||
168
test/signal_engine/test_output_classification.cpp
Normal file
168
test/signal_engine/test_output_classification.cpp
Normal 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);
|
||||
}
|
||||
598
test/signal_engine/test_resource_reclaim.cpp
Normal file
598
test/signal_engine/test_resource_reclaim.cpp
Normal 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));
|
||||
}
|
||||
3951
test/signal_engine/test_signal_engine.cpp
Normal file
3951
test/signal_engine/test_signal_engine.cpp
Normal file
File diff suppressed because it is too large
Load diff
1906
test/signal_engine/test_signal_engine_golden.cpp
Normal file
1906
test/signal_engine/test_signal_engine_golden.cpp
Normal file
File diff suppressed because it is too large
Load diff
610
test/signal_engine/test_signal_engine_phase4.cpp
Normal file
610
test/signal_engine/test_signal_engine_phase4.cpp
Normal 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));
|
||||
}
|
||||
}
|
||||
999
test/signal_engine/test_signal_engine_robustness.cpp
Normal file
999
test/signal_engine/test_signal_engine_robustness.cpp
Normal 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));
|
||||
}
|
||||
636
test/signal_engine/test_state_identity.cpp
Normal file
636
test/signal_engine/test_state_identity.cpp
Normal 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);
|
||||
}
|
||||
935
test/signal_engine/test_synth_compiler.cpp
Normal file
935
test/signal_engine/test_synth_compiler.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
248
test/signal_engine/test_synth_wasm_abi.cpp
Normal file
248
test/signal_engine/test_synth_wasm_abi.cpp
Normal 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);
|
||||
}
|
||||
479
test/signal_engine/test_ugens.cpp
Normal file
479
test/signal_engine/test_ugens.cpp
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue