memlnaut-nisps/.github/workflows/ci.yml
monkey-w1n5t0n a77770f95d feat: curve truth, DriverConfig, real telemetry, engine benchmark
Four items from one workflow, committed together because their build and CI
wiring genuinely interleaves — nisps/CMakeLists.txt, run-all-tests.sh and
ci.yml each carry hunks from two of them, and the stage renumbering (1/5 ->
1/6) touches every line. Splitting would produce commits that do not build,
which is worse than a commit that does four things and says so.

S26 part 2 — the curve declaration now matches reality. params[].curve stays
the mode-wide DEFAULT; a voice_spaces entry may now be {name, curve_overrides}
declaring only the slots where THAT voice space deviates. The 6 modes with one
voice space are byte-identical. The values were derived MECHANICALLY by a new
codegen/curve-audit.ts that models the four idioms a p[N]*p[N] regex misses
(alias form, memlcelium's implicit-counter sq() lambda, loop-generated indices,
smooth_params_), inlines helpers, and RAISES rather than guessing when it
cannot reduce an expression. A drift gate cross-checks 1179 (voice space x
param) slots against engine source on every run and was proved to fail loudly
on three drift classes. Application stays in the engine: nisps/engines,
nisps/pipeline and nisps/core are untouched, generated output is pure insertion
(755 insertions, 0 deletions), and the rebuilt nisps.wasm was byte-identical.

S4 / 7.2 — firmware reads the active mode's driver config at mode start, and
mic/line is real. My brief assumed the engine owns this; the code disagreed and
the code was right. sound_analysis_midi's EngineT is NoOpEngine — the mic lives
on a separately-composed AnalysisEngine member — so engine-level wiring would
have compiled, passed every gate, and left the one mic mode on line input.
Hence a mode-level seam defaulting to engine().driver_config(). Separately,
DriverConfig's defaults (line_level 0, output_volume 1.0) had drifted from
memllib's actual 3/0.8 because nothing had ever read them; wiring them as-is
would have made every silent mode louder and its line input maximally
insensitive — a behaviour change disguised as plumbing. Now pinned by a test.
Also: GetSysClockSpeed() panic()s on unsupported sample rates and runs on the
first line of setup(), so sample_rate needed a fallback ahead of clock setup.
CI's firmware env list gains soundanalysismidi — it is the only mic variant and
nothing else compiles that path.

Plan 5e — telemetry is real. A loss_history C-API entry across the full 5-layer
chain lets the browser read the per-iteration loss the core already records.
The audit named one fabrication site; there were two — wasm-iml.ts's
synchronous train() published lossHistory: [loss] as well. A third, ctx.loss,
was not merely dead but actively synthetic (fallbacks of prev * 0.82 and a
literal 0.5, rendered by nothing) and is deleted. The firmware buffer stays
untouched, per the L25 call. EngineApi.lossHistory() reads spine state rather
than the MLP handle, because trainAsync() fits on the worker's mirror net and
the handle would give a subtly-wrong second answer.

Plan 5f — engine throughput is measurable. One source compiled twice (CMake
natively, emcc for WASM) so the targets compare directly and no WASM export is
added. Sequencers are driven into a working state, and every row prints its own
working-state evidence so a number produced by an idle engine is visible rather
than plausible. Reports, never asserts: a wall-clock threshold on shared
hardware is meaningless or flaky, same call as the firmware size job.

ALIGNMENT: the telemetry defect is deleted (built, not deferred); the
performance defect is rewritten to what is actually left — these are HOST
numbers, and nothing measures the RP2350 at 150 MHz, which is the target the
mission's constraint is about. Q4 (memllib ownership) and Q5 (legacy feedback
modes) are closed.

Corrections to my own earlier claims, both found by agents contradicting the
brief: manifold/ONBOARDING.md was NOT "now accurate" — its primitives list
still named five deleted primitives and cited a seededGradient() that does not
exist. And the parity harness misses the sequencer engines because it runs 128
frames while their sequencers evaluate every 400-500 samples, NOT because
all-params-0.5 fails to trigger them (it does trigger: 0.5 maps to ratio 2,
firing three times per bar). The fix is a longer window, not different params.

Gates: run-all-tests.sh ALL GREEN — 4/4 ctest, parity PASS, lint clean, curve
drift 1179 slots ok, 39 e2e (was 33). Firmware: 5 envs built including the mic
variant.
2026-07-21 22:02:23 +02:00

234 lines
9 KiB
YAML

name: CI
# Stream 11 verification pipeline.
#
# Three parallel jobs:
# * cpp-tests — builds nisps host C++ tests, builds nisps.wasm, runs
# the parity check, runs the lint script, and reports
# per-engine throughput on both host targets.
# * manifold-tests — typechecks the React manifold app, runs bun unit
# tests, builds the production bundle, runs Playwright
# e2e tests.
#
# * firmware-build — compiles three representative PlatformIO envs for the
# RP2350 target.
#
# Firmware entered CI for the first time with the Phase 4 PlatformIO migration
# (plan §5, S9). It was previously excluded because arduino-cli + the rp2040
# board package cost ~2 minutes per run for "low verification value" — an
# assessment that did not survive contact: the SelfTest variant sat broken for
# an unknown period, calling a DisplayDriver method that did not exist at the
# pinned memllib commit, and nothing noticed because nothing built it. PIO's
# toolchain caches cleanly, so the cost is now a cache restore.
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
cpp-tests:
name: C++ tests + WASM + parity + lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install build deps
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
cmake ninja-build build-essential perl
- name: Setup Emscripten
# mymindstorm/setup-emsdk caches the SDK between runs. Pin to a
# known-working version; bump deliberately.
uses: mymindstorm/setup-emsdk@v14
with:
version: '3.1.69'
actions-cache-folder: 'emsdk-cache'
- name: Verify emcc
run: emcc --version
- name: Setup Node (for parity_wasm.mjs)
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build C++ tests + run ctest
env:
# CI is non-interactive — turn off colour for log readability.
CMAKE_BUILD_PARALLEL_LEVEL: '4'
run: bash scripts/build-cpp-tests.sh
# Freshness gate. manifold/public/nisps.{js,wasm} are TRACKED artifacts:
# the VPS webhook builds only manifold/, so vite copies whatever is
# committed into the production bundle. Prove the committed artifact
# still agrees with the C++ we just built, BEFORE the rebuild below
# overwrites it. A stale commit fails here instead of silently shipping.
- name: WASM freshness gate (committed artifact vs native)
env:
NISPS_PARITY_NO_BUILD: '1'
run: bash scripts/parity-check.sh
- name: Build WASM
# setup-emsdk puts emcc on PATH; build-wasm.sh picks it up via
# `command -v emcc`. No EMCC override needed.
run: bash scripts/build-wasm.sh
- name: Parity check (native vs WASM)
env:
NISPS_PARITY_NO_BUILD: '1' # we just built; don't re-build
run: bash scripts/parity-check.sh
- name: Lint
run: bash scripts/lint-cpp.sh
# Throughput is reported, not asserted — the same call the firmware
# flash/RAM step makes below, for a stronger reason: a wall-clock
# threshold on a shared runner is either slack enough to be meaningless
# or tight enough to fail on someone else's noisy neighbour. What this
# buys is (a) a per-commit ns/sample + realtime-factor record in the log,
# where a 2-3x regression — the failure mode ALIGNMENT defect 5 names —
# is unmissable even at runner noise levels, (b) proof the bench still
# builds and runs on BOTH targets, and (c) the wasm/native ratio.
# Runner noise makes small moves unreadable; for a real comparison run
# scripts/bench-engines.sh --compare locally.
- name: Engine throughput report (native + WASM, not asserted)
run: bash scripts/bench-engines.sh --target-ms 60
- name: Upload parity blobs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: parity-blobs
path: |
tests/cpp/parity_native.bin
tests/cpp/parity_wasm.bin
if-no-files-found: ignore
retention-days: 7
manifold-tests:
name: Manifold typecheck + unit + e2e
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# Enforces the "schema changes ship with regenerated C++ AND TypeScript
# in the same change" rule. Regenerate from schemas/ and fail if the
# committed outputs differ; then run the codegen golden test.
- name: Codegen is committed and idempotent
run: |
cd codegen
bun install --frozen-lockfile
bun run generate.ts
bun run generate-midi-devices.ts
bun run tests/golden_test.ts
# The schemas' per-voice-space `curve` declarations are DESCRIPTIVE
# of nisps/engines/*.hpp. Prove they still describe it.
bun run tests/curve_drift_test.ts
cd ..
GEN_DIRS="nisps/modes/generated nisps/midi/generated nisps/ml/generated \
manifold/src/modes/generated manifold/src/midi-devices/generated"
git add -N $GEN_DIRS
if ! git diff --exit-code -- $GEN_DIRS; then
echo "::error::Generated outputs are stale. Run codegen and commit the result." >&2
exit 1
fi
- name: Install manifold deps
working-directory: manifold
run: bun install --frozen-lockfile
- name: Typecheck
working-directory: manifold
run: bun run typecheck
- name: Unit tests
working-directory: manifold
run: bun run test
- name: Build manifold bundle
working-directory: manifold
run: bun run build
- name: Install Playwright browsers
working-directory: manifold
run: bunx playwright install --with-deps chromium
- name: Run Playwright tests
working-directory: manifold
run: bunx playwright test
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: manifold/playwright-report/
retention-days: 7
firmware-build:
name: Firmware (RP2350, PlatformIO)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
# The platform wrapper and the arduino-pico framework are fetched from
# git and total ~1-2 GB. Key on platformio.ini because that file pins
# both versions — change a pin, get a fresh toolchain.
- name: Cache PlatformIO toolchain
uses: actions/cache@v4
with:
path: |
~/.platformio
~/.cache/pip
key: pio-${{ runner.os }}-${{ hashFiles('firmware/MEMLNaut-NISPS/platformio.ini') }}
restore-keys: pio-${{ runner.os }}-
- name: Install PlatformIO
run: pip install --upgrade platformio
# Four representative envs rather than all sixteen: they differ mostly in
# which mode type is instantiated, so a compile break is usually common to
# all. slpworkshop is the shipped default, pafsynth is the heaviest RAM
# user, selftest takes the separate NISPS_SELFTEST fork through main.cpp
# (the one that broke unnoticed before), and soundanalysismidi is the ONLY
# microphone variant — the sole mode whose DriverConfig differs from its
# engine's, via an on_driver_config() override. Drop it and the mic path
# stops being compiled by anything.
- name: Build representative firmware variants
working-directory: firmware/MEMLNaut-NISPS
run: pio run -e slpworkshop -e pafsynth -e selftest -e soundanalysismidi
# Sizes are reported, not asserted. A threshold would either be slack
# enough to be meaningless or tight enough to fail on unrelated work;
# this puts the numbers in the log so a jump is visible in review.
# Flash = .text + .rodata, RAM = .data + .bss + vector table +
# uninitialized — matching arduino-cli's convention, NOT PlatformIO's
# own console line, which double-counts .data on this board.
- name: Report flash/RAM per variant
working-directory: firmware/MEMLNaut-NISPS
run: |
SIZE=$(find ~/.platformio/packages -name 'arm-none-eabi-size' | head -1)
for e in slpworkshop pafsynth selftest soundanalysismidi; do
"$SIZE" -A ".pio/build/$e/firmware.elf" | awk -v e="$e" '
/^\.text/{t=$2} /^\.rodata/{r=$2} /^\.data/{d=$2}
/^\.bss/{b=$2} /^\.ram_vector_table/{v=$2}
/^\.uninitialized_data/{u=$2}
END{printf "%-14s flash=%-8d ram=%-8d\n", e, t+r, d+b+v+u}'
done