fix(app): progression commit-layer tuning — survive fills, replace on evidence (task L-31)

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 <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-07-10 09:28:52 +01:00
parent c78baf4943
commit ce24d5490f
2 changed files with 51 additions and 8 deletions
+47 -6
View File
@@ -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])
+4 -2
View File
@@ -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