From ce24d5490f11457da0c472e885d58eb74523f64a Mon Sep 17 00:00:00 2001 From: vadimwit Date: Fri, 10 Jul 2026 09:28:52 +0100 Subject: [PATCH] =?UTF-8?q?fix(app):=20progression=20commit-layer=20tuning?= =?UTF-8?q?=20=E2=80=94=20survive=20fills,=20replace=20on=20evidence=20(ta?= =?UTF-8?q?sk=20L-31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COMMIT_VOTES=2 (unchanged, ~one bar), REPLACE_VOTES=3 (a transient or flapping detection can never displace a committed loop; section change replaces one commit later), NULL_CLEAR=6 (post-L-30, nulls only start ~28 commits after the loop last played — traced exactly). Vote ref now {committedKey, candidateKey, candidateCount}; agreement resets rivals. Diff = one hunk in the progression useEffect; audio-contract grep clean. Ride-along: theory.js docstring overclaim softened (L-30 gate finding b), comment-only. Critic PASS (independent re-trace of all five scenarios + 3 hostile traces; REPLACE_VOTES=3 proven load-bearing). Co-Authored-By: Claude Fable 5 --- src/App.jsx | 53 +++++++++++++++++++++++++++++++++++++++++------ src/lib/theory.js | 6 ++++-- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index c5e965d..ec12567 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -151,24 +151,65 @@ export default function App() { chromaIdxRef.current = 0 }, [config.chromaSmooth]) - // ── Detect progression — require 2 consecutive identical results to commit ──── + // ── Progression commit layer (task L-31) ───────────────────────────────────── + // Runs once per chord commit. progressionVoteRef holds + // { committedKey, candidateKey, candidateCount } — null until first evidence. + // + // Thresholds are in chord-commits (one detector run each): + // · COMMIT_VOTES = 2 — first commit needs 2 consecutive identical detections + // (≈ one bar). Post-L-30 detection is stable on clean loops so this lands + // immediately; a noisy 2-rep history flaps through data-faithful sub-cycles + // that never repeat twice in a row, so noise can't commit early. + // · REPLACE_VOTES = 3 — replacing a committed loop needs 3 consecutive + // detections of the SAME new loop: one transient detection (or an + // alternating flap) must never displace the loop the musician is still in; + // a genuine section change is detected consistently and just lands one + // commit later than a first commit would. + // · NULL_CLEAR = 6 — the detector only returns null once the loop has + // aged out of its 32-commit window (it needs 2 exact in-window + // occurrences): traced, that is ~28 commits of foreign material after + // the loop last played. Fills, turnarounds and window-boundary resumes + // yield non-null sub-cycle detections rather than nulls (traced), so + // they can NEVER clear a committed loop — a new established loop + // replaces via REPLACE_VOTES instead. A null run therefore means the + // jam truly left loop-land ~30 commits ago; 6 more (≈ two bars of + // structureless playing) confirms it wasn't a flicker before the + // display goes dark. (The old value 4 was sized as if nulls happened + // during fills — post-L-30 they don't.) + const COMMIT_VOTES = 2 + const REPLACE_VOTES = 3 + const NULL_CLEAR = 6 useEffect(() => { const detected = detectRepeatingProgression(chordHistory) + const vote = progressionVoteRef.current if (!detected) { progressionMissRef.current++ - // Clear stale loop after 4 chord changes with no pattern found - if (progressionMissRef.current >= 4) { + if (progressionMissRef.current >= NULL_CLEAR) { setDetectedProgression(null) progressionVoteRef.current = null } return } - progressionMissRef.current = 0 + progressionMissRef.current = 0 // any detected structure keeps the committed loop alive const key = detected.join(',') - if (progressionVoteRef.current === key) { + + if (vote && vote.committedKey === key) { + // Agreement with the committed loop — refresh it, drop any pending rival. setDetectedProgression(detected) + vote.candidateKey = null + vote.candidateCount = 0 + return + } + + const committedKey = vote ? vote.committedKey : null + if (vote && vote.candidateKey === key) { + vote.candidateCount++ } else { - progressionVoteRef.current = key + progressionVoteRef.current = { committedKey, candidateKey: key, candidateCount: 1 } + } + if (progressionVoteRef.current.candidateCount >= (committedKey ? REPLACE_VOTES : COMMIT_VOTES)) { + setDetectedProgression(detected) + progressionVoteRef.current = { committedKey: key, candidateKey: null, candidateCount: 0 } } }, [chordHistory]) diff --git a/src/lib/theory.js b/src/lib/theory.js index b2c1039..caebdb4 100644 --- a/src/lib/theory.js +++ b/src/lib/theory.js @@ -600,8 +600,10 @@ function canonicalize(pattern) { * section outscores a longer stale one. Requires ≥2 EXACT occurrences: an * edit-tolerant occurrence corroborates a loop but cannot establish it — a * loop means the sequence came back exactly, and a ghost slice that absorbs a - * noise chord into itself occurs exactly only once by construction. Returns - * the canonical (rotation-normalised) best pattern. + * noise chord into itself rarely recurs exactly (only phase-locked corruption + * of the same slot by the same chord can make one recur — and such data is + * genuinely periodic at that longer length). Returns the canonical + * (rotation-normalised) best pattern. */ export function detectRepeatingProgression(history) { if (!history || history.length < 6) return null