memlnaut-nisps/nisps/wasm
monkey-w1n5t0n dbe0f5d8ba fix(ml): one named example capacity; train() and trainAsync() no longer diverge
Phase 2, S35. Two real defects from one root cause, both confirmed by trace
rather than taken from the audit:

1. Divergence. WasmIML built its TS Dataset mirror with a cap of 100 while
   every addExample() ALSO pushed into the C++ FIFO ring, capped at 128. Since
   train() reads the C++ ring and trainAsync() reads the TS mirror, past 100
   examples the two trained on different datasets — silently.
2. Latent OOB read. nisps_ml_train sizes its sample-weight span by the C++
   side's example_count() (up to 128), but wasm-iml.ts allocates that heap
   buffer from the TS dataset's size (<=100). Once the ring exceeds the mirror,
   the span reads past the end of the caller's allocation.

Fix: name the capacity ONCE as nisps::ml::kDefaultMaxExamples = 128, used by
FixedStorage's default template arg, DynamicStorage's default ctor arg, and the
MLP<> alias (which is the only real FixedStorage instantiation path and carried
its own independent 128 literal — the last copy of this dual truth). Expose it
through nisps_ml_describe and have the TS side read it instead of hardcoding.
Dataset's constructor default is removed entirely: a default was what invited
this bug class, and the sole call site now always supplies the describe() value.

ABI NOTE: this extends nisps_ml_describe from a 6-int to a 7-int descriptor.
nisps_ml_describe always writes 7 ints regardless of the caller's buffer, so
every call site had to grow in the same change or it would overflow the WASM
heap by 4 bytes per call. All five sites updated: three in wasm-iml.ts (init
defaults, init per-instance, reshape re-describe — the finding said there were
two), one in wasm-worker.ts, one in tests/cpp/parity_wasm.mjs. The parity
harness's expected-dims check now also pins the new max_examples slot.

Regression test: tests/cpp/test_mlp_storage_defaults.cpp — pins the two storage
policies to one constant, and drives MLPCore<DynamicStorage> exactly as
bindings.cpp does past the old TS cap, asserting it saturates at 128 and not at
100. Fail-before/pass-after confirmed by temporarily setting the constant to
100: 2 failures, named. Reverted: green.

Audit correction: the cited dataset.ts:81 is the FIFO eviction check; the
hardcoded default was at dataset.ts:45.

Gates: run-all-tests.sh ALL GREEN, parity PASS.
2026-07-21 13:22:38 +02:00
..
bindings.cpp fix(ml): one named example capacity; train() and trainAsync() no longer diverge 2026-07-21 13:22:38 +02:00
README.md refactor(wasm): delete 12 dead C-API entries and the weights-publish channel 2026-07-21 12:48:50 +02:00

nisps/wasm

Emscripten target that exposes nisps/ml (MLP) and nisps/engines (audio engines) to the browser apps via a flat C ABI.

This directory is a leaf — it does not export headers for inclusion by other C++ code. The only artifact is bindings.cpp plus the build script that turns it into manifold/public/nisps.{wasm,js} (with a transitional copy to playground/public/ until P1 of docs/specs/plans/one-core-engine-refactor.md retires the playground).

Building

scripts/build-wasm.sh

Requires emcc (Emscripten). The script defaults to /usr/lib/emscripten/emcc and respects an EMCC env var override.

Output:

  • manifold/public/nisps.wasm — the compiled module.
  • manifold/public/nisps.js — Emscripten glue (factory function createNispsModule, MODULARIZE=1).

Both files are committed (so the browser apps work from a fresh clone without a C++ toolchain). Re-run build-wasm.sh after changes to nisps/{core,ml,engines,wasm}.

Architecture (runtime-shaped since one-core-engine P2)

The browser MLP is MLPCore<DynamicStorage> (nisps/ml/dynamic_storage.hpp): nisps_ml_create(input, output, hidden[3], n, seed) HONOURS its dimensions. The 4-layer topology (ReLU×3 + Sigmoid) is fixed; only the dimensions are runtime, capped at 4096 per dim. Non-positive/null arguments fall back to the historical defaults:

32 inputs → [10, 14, 18] hidden → 126 outputs

nisps_ml_reshape(ml, in, out, hidden, n, spread) constructs a new net at the requested shape, warm-starts it by copying the overlapping weight region (nisps/ml/warm_start.hpp), and swaps it in. The C-side dataset and the feedback controller state RESET on reshape (front-end shows a confirm modal). Heap is used only at create/reshape time, never per-call; the firmware target never compiles the dynamic storage at all (#error under NISPS_TARGET_EMBEDDED).

C API surface

See bindings.cpp for the full list. Summary:

Group Functions
ML life nisps_ml_create, nisps_ml_destroy, nisps_ml_reshape
ML I/O nisps_ml_set_input, nisps_ml_process, nisps_ml_outputs, nisps_ml_infer_batch
Training nisps_ml_add_example, nisps_ml_train, nisps_ml_eval_loss, nisps_ml_clear_examples
Weights nisps_ml_weight_count, nisps_ml_get_weights, nisps_ml_set_weights, nisps_ml_draw_weights
Diag nisps_ml_get_layer_stats, nisps_ml_describe
Engines nisps_engine_create, nisps_engine_destroy, nisps_engine_set_params, nisps_engine_process_block

Engine-id strings follow the C++ engine_id() constexpr accessors: thru, paf_synth, channel_strip, xiasri, verb_fx, memlcelium, breakor, elysiamorf, analysis. Unknown ids fall back to thru (silent passthrough).