diff --git a/scripts/lint-cpp.sh b/scripts/lint-cpp.sh index 2f13572..6e5569f 100755 --- a/scripts/lint-cpp.sh +++ b/scripts/lint-cpp.sh @@ -10,11 +10,19 @@ # applies to decimal literals consumed at runtime. # Warns; non-zero only if NISPS_LINT_STRICT=1. # -# 2. FAIL: heap allocation primitives in audio paths (nisps/dsp/, nisps/engines/, -# nisps/ml/, nisps/modes/). Forbidden patterns: -# - std::vector -# - bare `new ` / `new(` -# - malloc( +# 2. FAIL: heap allocation primitives anywhere under nisps/ EXCEPT +# nisps/wasm/ (the host/browser binding layer, where heap is legitimate). +# Exclusion-based on purpose: a newly added nisps/ subdirectory is +# scanned by default instead of silently skipped (an older include-list +# missed nisps/pipeline/ and nisps/core/ entirely). Forbidden patterns: +# - allocating STL containers (std::vector/string/deque/list/map/set/ +# unordered_*/function; std::string_view is fine and not matched) +# - std::make_unique / std::make_shared +# - `new` in any spelling (`new T`, `new(...)`, nothrow, placement) +# - C allocators: malloc/calloc/realloc/aligned_alloc/strdup/strndup +# Comments and string literals are STRIPPED before matching (incl. +# multi-line /* */ blocks), so a real allocation with a trailing comment +# cannot hide, and prose mentioning std::vector cannot false-flag. # Files matching */tests/* are exempt — they are host-only. # SOLE allowlisted file: nisps/ml/dynamic_storage.hpp — the runtime-shaped # MLP storage (one arena allocation at construction). It is compile-time @@ -118,18 +126,65 @@ audit_float_suffix() { # 2. Heap-alloc audit — fail. # --------------------------------------------------------------------------- audit_heap_alloc() { - local subdirs=("$NISPS_DIR/dsp" "$NISPS_DIR/engines" "$NISPS_DIR/ml" "$NISPS_DIR/modes") - local pat='\bstd::vector\b|\bnew[ \t]*\(|\bnew[ \t]+[A-Za-z_]|\bmalloc[ \t]*\(' - local hits - hits=$(grep -REn "$pat" \ - --include='*.hpp' --include='*.cpp' \ - --exclude-dir=build --exclude-dir=tests \ - --exclude='dynamic_storage.hpp' \ - "${subdirs[@]}" 2>/dev/null \ - | grep -v ' *//' \ - || true) + # Heap primitives forbidden in the platform-neutral core (see header §2). + # Perl regex, applied AFTER comment/string stripping below. + local pat='\bstd::(vector|string|deque|list|forward_list|map|multimap|set|multiset|unordered_map|unordered_set|function)\b|\bmake_(unique|shared)\b|\bnew[ \t]*\(|\bnew[ \t]+[A-Za-z_:]|\b(malloc|calloc|realloc|aligned_alloc|strdup|strndup)[ \t]*\(' + + # Everything under nisps/ except the wasm/ binding layer, tests, and + # build artifacts. Exclusion-based so new subdirectories are covered by + # default. + local files + mapfile -t files < <(find "$NISPS_DIR" -type f \( -name '*.hpp' -o -name '*.cpp' \) \ + -not -path '*/wasm/*' \ + -not -path '*/tests/*' \ + -not -path '*/build/*' | sort) + + local hits="" file file_hits + for file in "${files[@]}"; do + # SOLE allowlisted file (see header §2); guarded by the + # NISPS_TARGET_EMBEDDED check below. + if [[ "$file" == "$NISPS_DIR/ml/dynamic_storage.hpp" ]]; then + continue + fi + # Strip string literals, then // and /* */ comments (with cross-line + # block-comment state), THEN match — a trailing `// grow buffer` + # comment can no longer hide a real allocation on the same line. + file_hits=$(NISPS_HEAP_PAT="$pat" perl -ne ' + my $line = $_; + chomp $line; + $line =~ s{"(?:[^"\\]|\\.)*"}{""}g; # string literals + if ($in_block) { + if ($line =~ s{^.*?\*/}{}) { $in_block = 0; } else { next; } + } + while (1) { + my $pl = index($line, "//"); + my $pb = index($line, "/*"); + last if $pl < 0 && $pb < 0; + if ($pb < 0 || ($pl >= 0 && $pl < $pb)) { + $line = substr($line, 0, $pl); # // line comment + last; + } + my $pe = index($line, "*/", $pb + 2); + if ($pe < 0) { # /* opens a block + $line = substr($line, 0, $pb); + $in_block = 1; + last; + } + $line = substr($line, 0, $pb) . " " . substr($line, $pe + 2); + } + if ($line =~ /$ENV{NISPS_HEAP_PAT}/o) { + $line =~ s/^\s+//; + print "$ARGV:$.: $line\n"; + } + ' "$file" 2>/dev/null || true) + if [[ -n "$file_hits" ]]; then + hits+="$file_hits"$'\n' + fi + done + hits="${hits%$'\n'}" + if [[ -n "$hits" ]]; then - echo "[lint-cpp] FAIL: heap allocation in audio path:" + echo "[lint-cpp] FAIL: heap allocation in nisps/ core:" echo "$hits" | sed 's/^/ /' fails=$((fails + 1)) fi diff --git a/scripts/parity-check.sh b/scripts/parity-check.sh index 4b543e5..e3d0c45 100755 --- a/scripts/parity-check.sh +++ b/scripts/parity-check.sh @@ -64,12 +64,13 @@ echo "[parity-check] running WASM..." node "$TESTS_DIR/parity_wasm.mjs" "$WASM_OUT" echo "[parity-check] diffing (tolerance=$TOL)..." -node "$TESTS_DIR/parity_diff.mjs" "$NATIVE_OUT" "$WASM_OUT" "$TOL" -status=$? - -if [[ $status -eq 0 ]]; then +# The node invocation must live in the `if` condition: under `set -e` a bare +# failing command would kill the script before any FAIL line could print. +if node "$TESTS_DIR/parity_diff.mjs" "$NATIVE_OUT" "$WASM_OUT" "$TOL"; then echo "[parity-check] PASS" else + status=$? echo "[parity-check] FAIL (exit=$status)" >&2 + exit "$status" fi -exit $status +exit 0 diff --git a/tests/cpp/parity_check.cpp b/tests/cpp/parity_check.cpp index d13ba88..19fac10 100644 --- a/tests/cpp/parity_check.cpp +++ b/tests/cpp/parity_check.cpp @@ -11,32 +11,36 @@ // script `scripts/parity-check.sh` then runs both and float32-diffs the // outputs with a 1e-5 tolerance. // -// What we cover -// ------------- -// 1. ML: seed=42, draw_weights(0.5), set_input(0.25, 0.75), process. -// → 126 outputs + 12 weights sampled at known offsets. -// 2. ML training: 3 examples added, train(0.3, 50, 0), capture loss + outputs. -// 3. PAFSynth engine: seed-equivalent setup (params=0.5), 128-sample run on -// silence, capture L+R averages. -// 4. ChannelStrip engine: identical methodology. +// What we cover — seven stages (this file is the AUTHORITATIVE stage list) +// ------------------------------------------------------------------------ +// The `---- Stage N ----` sections in main() below are the source of truth +// for the payload layout; parity_wasm.mjs replays the identical sequence via +// the C ABI, and parity_diff.mjs names the leading payload sections in its +// error context. Summary: +// 1. ML inference: seed=42 (widened as in bindings.cpp), draw_weights(0.5), +// process at (0.25, 0.75) → 126 outputs + 12 probed weights (kProbeIdx). +// 2. ML training: 3 examples, train(0.3, 50, 0) → 126 outputs + final loss. +// 3. PAFSynth engine: params=0.5, 128-sample run on silence → L+R means. +// 4. ChannelStrip engine: same methodology on a 0.25 step input. +// 5. FeedbackController ("Down Action"): RandomiseOutputs static-output +// draws, RandomiseMlp snapshot/restore, and the ExploreAndPlace +// lifecycle (explore→reroll→nudge→undo→place→commit). +// 6. Geometric dislike (Avoid mode): scripted likes + dislikes drive the +// push-away training path → counts, outputs, probed weights. +// 7. Pipelines + curves (one-core-engine P4): InputChain (2 configs), +// OutputChain (2 configs, freeze mask), and the curve catalog, all on +// deterministic rational traces. // // We use the EXACT SAME compile-time MLP architecture as the WASM build: // MLP<32, 10, 14, 18, 126> (32-input max for mix-and-match; see bindings.cpp) // -// Output blob format -// ------------------ +// Output blob format (v5 — see kVersion below; bump BOTH drivers together) +// ------------------------------------------------------------------------ // uint32 magic = 'NPRT' = 0x5450524E -// uint32 version = 1 +// uint32 version = 5 // uint32 n_floats -// float32[n_floats] payload -// -// Stable order of payload (concatenated): -// * 126 floats: outputs after stage 1 (post-process at (0.25, 0.75)) -// * 12 floats: weights sampled at fixed indices (see kProbeIdx below) -// * 126 floats: outputs after stage 2 (post-train, re-process) -// * 1 float : final training loss -// * 2 floats: PAFSynth L mean, R mean (over 128 samples) -// * 2 floats: ChannelStrip L mean, R mean +// float32[n_floats] payload — the per-stage pushes in main(), concatenated +// in order. // // Why not bit-perfect // -------------------