From 4cc0009983d9850e9e14965ca279ab840001366e Mon Sep 17 00:00:00 2001 From: vadimwit Date: Fri, 10 Jul 2026 09:10:28 +0100 Subject: [PATCH] feat(theory): noise-tolerant loop detection rewrite (task L-30) detectRepeatingProgression redesigned: consecutive-dup collapse, weak-period candidate rejection (kills self-overlap ghosts structurally), lengths 2-8, <=1 substitution/insertion per cycle with a >=2-exact-occurrences evidence gate, recency-weighted linear coverage scoring. All 17 C-30 fixtures pass plain (smoke 817/817, expectedFail markers removed). Critic PASS: 15 novel generalization probes correct or data-faithful, 0.2-0.7 ms/call, diff confined to the repeating-progression section, fixture contract untouched. Co-Authored-By: Claude Fable 5 --- scripts/loop-fixtures.mjs | 48 +++------------ src/lib/theory.js | 123 ++++++++++++++++++++++++++++++-------- 2 files changed, 106 insertions(+), 65 deletions(-) diff --git a/scripts/loop-fixtures.mjs b/scripts/loop-fixtures.mjs index 9f290c7..868828e 100644 --- a/scripts/loop-fixtures.mjs +++ b/scripts/loop-fixtures.mjs @@ -12,11 +12,12 @@ // · fixture with expectedFail that PASSES → smoke FAILS: stale marker, flip it // L-30's definition of done = every expectedFail marker removed, all green. // -// ─── FAILURE MAP OF THE CURRENT ALGORITHM ──────────────────────────────────── -// (theory.js `detectRepeatingProgression`, every fixture RUN against it 2026-07-10; -// the `today:` comment on each expectedFail fixture is the actual observed output) +// ─── FAILURE MAP OF THE PRE-L-30 ALGORITHM (historical — all fixed by L-30) ── +// (theory.js `detectRepeatingProgression` as of C-30, every fixture RUN against +// it 2026-07-10; L-30 redesigned the function and removed every expectedFail +// marker — all 17 fixtures now pass as plain regression guards) // -// The current matcher: window = last 32 commits; candidate lengths 2–6; EXACT +// The pre-L-30 matcher: window = last 32 commits; candidate lengths 2–6; EXACT // contiguous occurrence counting (scan advances by len on match, by 1 on miss); // requires reps ≥ 2; score = reps × len²; returns the canonical (lexicographically // smallest) rotation of the best candidate. @@ -130,87 +131,54 @@ export const LOOP_FIXTURES = [ expect: null, }, - // ── Expected failures of the current algorithm — the L-30 contract ───────── + // ── Failure modes of the pre-L-30 algorithm — fixed by L-30 ──────────────── { id: 'vamp-2x4', description: 'clean 2-chord vamp played 4× — must still report the pair, not a self-overlap', history: reps(['Am', 'G'], 4), expect: ['Am', 'G'], - // today: returns [Am,Am,G] — the 3-chord self-overlap [Am,G,Am] scores - // 2 reps × 3² = 18 and beats the true pair at 4 × 2² = 16 (failure map #1). - expectedFail: true, }, { id: 'vamp-2-sustained', description: 'sustained 2-chord vamp filling the whole window (×16) — the everyday two-chord jam', history: reps(['Am', 'G'], 16), expect: ['Am', 'G'], - // today: returns [Am,Am,G,Am,G] — a bogus 5-chord self-overlap of the pair - // wins on len² (failure map #1). A musician vamping Am–G sees a fake - // 5-chord progression. - expectedFail: true, }, { id: 'spurious-substitution', description: '3-chord loop ×4 with ONE substituted misdetection (Am read as E7 in the third rep) — must still report the 3-loop', history: ['C', 'Am', 'F', 'C', 'Am', 'F', 'C', 'E7', 'F', 'C', 'Am', 'F'], expect: ['C', 'Am', 'F'], - // today: returns [Am,F,F,C] — the wrong 4-pattern [F,C,Am,F] straddling the - // noise scores 2 × 4² = 32 and beats the real loop's 3 × 3² = 27 - // (failure map #2). This is the user's "doesn't recognize when 3 chords - // return": one bad commit and the display shows a 4-chord ghost. - expectedFail: true, }, { id: 'spurious-2of4', description: '3-chord loop ×4 with an inserted misdetection in two different reps (E7, then Dm7) — realistic sustained noise', history: ['C', 'Am', 'F', 'C', 'Am', 'E7', 'F', 'C', 'Am', 'F', 'C', 'Dm7', 'Am', 'F'], expect: ['C', 'Am', 'F'], - // today: returns [Am,F,C,C] — a wrong 4-pattern beats the true 3-loop once - // noise appears in more than one rep (failure map #3). - expectedFail: true, }, { id: 'dup-commit', description: 'consecutive duplicate commit of the same chord inside an otherwise clean 3-loop (C Am F C C Am F …) — dups must collapse', history: ['C', 'Am', 'F', 'C', 'C', 'Am', 'F', 'C', 'Am', 'F'], expect: ['C', 'Am', 'F'], - // today: returns [Am,F,C,C] — the duplicate mints a wrong 4-pattern - // [C,Am,F,C] at 2 × 16 = 32 vs the real loop's 27 (failure map #4). - // App.jsx:322-324 currently suppresses adjacent dup commits, so this exact - // history can't arise from today's commit layer — the fixture pins L-30's - // "collapse consecutive duplicates before matching" so the detector is safe - // standalone (and safe if L-31 changes the commit layer). - expectedFail: true, }, { id: 'seven-x2', - description: '7-chord loop played 2× — beyond the current length-6 cap', + description: '7-chord loop played 2× — beyond the pre-L-30 length-6 cap', history: reps(['Em', 'G', 'D', 'A', 'Em', 'C', 'B7'], 2), expect: ['Em', 'G', 'D', 'A', 'Em', 'C', 'B7'], - // today: returns [A,Em,C,Em,G,D] — a truncated 6-chord slice of the loop, - // because candidate lengths cap at 6 (failure map #5). The user's - // "5 and then 2 others" = a 7-chord form is structurally undetectable. - expectedFail: true, }, { id: 'eight-x2', - description: '8-chord loop (extended andalusian form) played 2× — beyond the current cap', + description: '8-chord loop (extended andalusian form) played 2× — beyond the pre-L-30 cap', history: reps(['Am', 'G', 'F', 'E7', 'Am', 'C', 'Dm', 'E7'], 2), expect: ['Am', 'G', 'F', 'E7', 'Am', 'C', 'Dm', 'E7'], - // today: returns [Am,C,Am,G,F,E7] — again a wrong 6-chord truncation - // (failure map #5). - expectedFail: true, }, { id: 'section-change', description: 'section change: 4-chord loop A ×3, then 3-chord loop B ×3 — must report B, the loop being played NOW', history: [...reps(['C', 'G', 'Am', 'F'], 3), ...reps(['Dm7', 'G7', 'Cmaj7'], 3)], expect: ['Dm7', 'G7', 'Cmaj7'], - // today: returns [Am,F,C,G] — the STALE loop A: no recency weighting, so the - // old section's 3 × 4² = 48 outscores the current section's 3 × 3² = 27 - // (failure map #6). The display stays stuck on the previous section. - expectedFail: true, }, ] diff --git a/src/lib/theory.js b/src/lib/theory.js index c6ed177..b2c1039 100644 --- a/src/lib/theory.js +++ b/src/lib/theory.js @@ -520,18 +520,60 @@ export function toRomanNumeral(chordName, keyRoot, keyMode) { return isMinorQuality ? rn.toLowerCase() : rn } -// ─── Repeating progression detection ───────────────────────────────────────── +// ─── Repeating progression detection ────────────────────────────────────────── -// Returns true if arr is made of a shorter repeating unit (e.g. [A,B,A,B] → true) -function isPeriodicPattern(arr) { - for (let p = 1; p <= Math.floor(arr.length / 2); p++) { - if (arr.length % p !== 0) continue - const unit = arr.slice(0, p) - if (arr.every((v, i) => v === unit[i % p])) return true +// True if arr has a "weak period" p < arr.length — i.e. arr[i] === arr[i-p] for +// every i ≥ p, meaning arr is a prefix of some p-periodic infinite sequence. +// This rejects not only exact repetitions ([A,B,A,B], p=2) but also self-overlap +// fragments/rotations of a shorter loop ([A,B,A], p=2; [C,G,Am,F,C], p=4) that +// would otherwise mint ghost candidates out of a short vamp. A genuine loop is +// never weak-periodic: a loop whose tail restates its head would produce an +// adjacent duplicate at the cycle seam, which the window collapse removes. +function hasShorterPeriod(arr) { + for (let p = 1; p < arr.length; p++) { + let periodic = true + for (let i = p; i < arr.length; i++) { + if (arr[i] !== arr[i - p]) { periodic = false; break } + } + if (periodic) return true } return false } +// Match one occurrence of `cand` in `win` anchored at `start` (the first chord +// must match exactly), tolerating at most ONE edit per cycle: a substitution +// (one chord misdetected) or an insertion (one foreign chord slipped between two +// loop chords). The remainder after the edit must match exactly. Returns +// { end, matched, editPos } — `matched` = window indices that matched a loop +// chord, `editPos` = window index of the edit (-1 if the occurrence is exact) — +// or null if no match. +function matchLoopOccurrence(win, start, cand) { + if (win[start] !== cand[0]) return null + const matched = [start] + let i = start + 1 + for (let j = 1; j < cand.length; j++) { + if (i >= win.length) return null + if (win[i] === cand[j]) { matched.push(i); i++; continue } + + // First mismatch — the single allowed edit. Fork the two readings; each + // requires the rest of the candidate to match exactly from where it lands. + const exactFrom = (wi, cj) => { + const tail = [] + for (; cj < cand.length; cj++, wi++) { + if (wi >= win.length || win[wi] !== cand[cj]) return null + tail.push(wi) + } + return { end: wi, tail } + } + const ins = exactFrom(i + 1, j) // win[i] is a foreign inserted chord + const sub = exactFrom(i + 1, j + 1) // win[i] is cand[j] misdetected + const hit = ins ?? sub // insertion keeps one more matched chord + if (!hit) return null + return { end: hit.end, matched: [...matched, ...hit.tail], editPos: i } + } + return { end: i, matched, editPos: -1 } +} + // Returns the lexicographically smallest rotation so the same loop always // produces the same string regardless of where in the cycle we currently are. function canonicalize(pattern) { @@ -544,42 +586,73 @@ function canonicalize(pattern) { } /** - * detectRepeatingProgression(history) → chord[] or null + * detectRepeatingProgression(history) → chord[] or null (task L-30) * - * Tests every unique subsequence of every length (not just the tail) so the - * result is stable regardless of where in the loop the musician currently is. - * Returns the canonical (rotation-normalised) form of the best pattern found. + * Finds the loop the musician is playing NOW in the recent chord history. + * Candidates are contiguous slices (lengths 2–8) of the last-32 window with + * consecutive duplicate commits collapsed; candidates that are self-overlaps + * of a shorter period are rejected (see hasShorterPeriod). Each candidate is + * scored by recency-weighted COVERAGE: non-overlapping occurrences are counted + * with at most one substitution or insertion per cycle, every matched chord + * adds its recency weight, every edit subtracts the weight at the edit slot. + * Linear coverage (not reps × len²) means a ghost pattern straddling noise can + * never outscore the true loop, and exponential recency decay means the current + * 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. */ export function detectRepeatingProgression(history) { if (!history || history.length < 6) return null - const win = history.slice(-32) - let best = null, bestScore = 0 + // Collapse consecutive duplicate commits — a chord re-committed back-to-back + // is the same loop slot, not two. Non-adjacent repeats (e.g. Em … Em inside a + // 7-chord form) are meaningful and untouched. + const raw = history.slice(-32) + const win = raw.filter((c, i) => i === 0 || c !== raw[i - 1]) + const n = win.length + if (n < 4) return null // shortest loop (2 chords) × 2 reps - for (let len = 2; len <= 6; len++) { - if (len * 2 > win.length) break + // Recency weight per window slot: newest chord weighs 1, each step back + // decays by 0.9 (half-life ≈ 6.6 chords). + const RECENCY = 0.9 + const weight = Array.from({ length: n }, (_, i) => RECENCY ** (n - 1 - i)) + let best = null + let bestScore = 0 + + for (let len = 2; len <= 8; len++) { + if (len * 2 > n) break const seen = new Set() - for (let start = 0; start <= win.length - len; start++) { + for (let start = 0; start <= n - len; start++) { const candidate = win.slice(start, start + len) const key = candidate.join('\0') if (seen.has(key)) continue seen.add(key) - // A pattern that is itself a repetition of something shorter will be - // found at that shorter length — skip it here to avoid inflating scores. - if (len >= 4 && isPeriodicPattern(candidate)) continue + if (hasShorterPeriod(candidate)) continue - let reps = 0, i = 0 - while (i <= win.length - len) { - if (candidate.every((c, j) => c === win[i + j])) { reps++; i += len } - else i++ + let exactOccurrences = 0 + let score = 0 + let i = 0 + while (i < n) { + const occ = matchLoopOccurrence(win, i, candidate) + // A 2-chord candidate may not take a substitution (1 matched chord is + // no evidence); insertions keep matched === len and stay allowed. + if (occ && occ.matched.length >= 2) { + if (occ.editPos < 0) exactOccurrences++ + for (const p of occ.matched) score += weight[p] + if (occ.editPos >= 0) score -= weight[occ.editPos] + i = occ.end + } else { + i++ + } } - if (reps < 2) continue + if (exactOccurrences < 2) continue // implies occurrences ≥ 2 - const score = reps * len * len // square length — prevents sub-patterns from beating full loop if (score > bestScore) { bestScore = score best = candidate