fix(match): index collapsed loop forms — real 12-bar blues now matches its KB entry (task L-60, 1/2)
buildLoopIndex additionally indexes each progression's collapsed form (adjacent (degree,suffix) dedup + wrap), appended after raw entries so every prior matcher winner is preserved except the enumerated country-145 self-attribution. Repairs a pre-existing LIVE-detection bug: a real 12-bar or 8-bar blues stream collapses to fewer names than the raw KB degrees, so it matched NOTHING and the Jam Guide sat empty. JamGuide reads authored plays through the collapsed station's sourceIndex (all four guitar/piano/bass paths). Adds seedableLoop + the round-trip roulette pool (smoke sweep: 4 failures / pool 52). theory.js canonicalize exported. Critic PASS (fix-(a) additivity proven registry-wide; pool reproduced from the spec; bass sourceIndex station 5 -> raw 10 hand-checked). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -506,8 +506,10 @@ flow below `xl` (JamGuide reorders its own children with responsive classes or
|
|||||||
conditional order — no duplicate mounts). The rail **unbounds** below `xl`
|
conditional order — no duplicate mounts). The rail **unbounds** below `xl`
|
||||||
(the max-h/overflow classes are `xl:`-prefixed) and lays out at natural height
|
(the max-h/overflow classes are `xl:`-prefixed) and lays out at natural height
|
||||||
in page flow — a nested scroller inside a scrolling page is a trap on touch.
|
in page flow — a nested scroller inside a scrolling page is a trap on touch.
|
||||||
Cell wrap at ~576px interior: guitar 5/line, piano two-octave cells alone —
|
Cell wrap at ~576px interior: guitar 5/line, piano cells **pair** (a 284px
|
||||||
same behaviour the D-41 gate already verified for the band. The instrument
|
two-octave + a 160px one-octave = 452, or two two-octaves at the 576 boundary) —
|
||||||
|
the same margin-hardened pairing D-51 verified for the bounded column (§4),
|
||||||
|
with more room here, not the "cells ride alone" of the narrower bounded width. The instrument
|
||||||
selector never moves: it lives in the controls bar, global, above everything
|
selector never moves: it lives in the controls bar, global, above everything
|
||||||
at every width.
|
at every width.
|
||||||
|
|
||||||
|
|||||||
+131
-19
@@ -1193,6 +1193,99 @@ if (existsSync(join(ROOT, 'src/components/PianoLickCard.jsx'))) {
|
|||||||
warn('PianoLickCard vocab drift guard', 'src/components/PianoLickCard.jsx not yet authored (D-60)')
|
warn('PianoLickCard vocab drift guard', 'src/components/PianoLickCard.jsx not yet authored (D-60)')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 7d. Jam Roulette round-trip sweep (L-60, jam-roulette.md §2.2) ───────────
|
||||||
|
//
|
||||||
|
// The pool gate, protocol VERBATIM: for each of the 56 progressions, seed its
|
||||||
|
// canonical collapsed form in C, fill a 32-commit window with repetitions and
|
||||||
|
// truncate at EVERY partial-cycle offset (0…len−1); it passes iff
|
||||||
|
// detectRepeatingProgression returns exactly that form at ALL offsets, AND the
|
||||||
|
// collapsed length is 2–8. Steady-state-plus-all-offsets is the honest protocol
|
||||||
|
// (a jam is sampled mid-cycle; a naive "2 clean cycles" feed evicts the 2-name
|
||||||
|
// vamps and passes blues-8bar — §2.2). Expected: 52 passers, exactly 4 excluded
|
||||||
|
// (jazz-blues, blues-quickchange, bossa-blue over-length; blues-8bar's
|
||||||
|
// 1-of-7-offsets self-competition). Independent of match.roundTripPasses so a
|
||||||
|
// detector or seed regression turns smoke red; cross-checked against it below.
|
||||||
|
|
||||||
|
console.log('\nJam Roulette round-trip sweep (L-60):')
|
||||||
|
|
||||||
|
const { seedableLoop, roundTripPasses, buildRoulettePool } = match
|
||||||
|
|
||||||
|
// Verbatim §2.2 protocol — deliberately NOT calling match.roundTripPasses.
|
||||||
|
function sweepPasses(form) {
|
||||||
|
if (!Array.isArray(form) || form.length < 2 || form.length > 8) return false
|
||||||
|
const target = form.join(',')
|
||||||
|
for (let offset = 0; offset < form.length; offset++) {
|
||||||
|
const history = []
|
||||||
|
for (let k = 0; k < 32; k++) history.push(form[(offset + k) % form.length])
|
||||||
|
const detected = detectRepeatingProgression(history)
|
||||||
|
if (!detected || detected.join(',') !== target) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const sweepFails = []
|
||||||
|
let sweepPool = 0
|
||||||
|
let sweepTotal = 0
|
||||||
|
for (const style of Object.keys(kb)) {
|
||||||
|
for (const prog of kb[style].progressions ?? []) {
|
||||||
|
sweepTotal++
|
||||||
|
const form = seedableLoop(prog, 0) // key C — key-independent (§3.3.3)
|
||||||
|
if (form && sweepPasses(form)) sweepPool++
|
||||||
|
else sweepFails.push(`${style}/${prog.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` round-trip sweep: ${sweepFails.length} failures / pool ${sweepPool} (of ${sweepTotal})`)
|
||||||
|
|
||||||
|
check('round-trip sweep: exactly 4 failures, pool 52 of 56 (§2.2 steady-state, all offsets)', () => {
|
||||||
|
assert(sweepTotal === 56, `swept ${sweepTotal} progressions, expected 56`)
|
||||||
|
assert(sweepPool === 52, `pool ${sweepPool} ≠ 52`)
|
||||||
|
assert(sweepFails.length === 4, `${sweepFails.length} failures ≠ 4: [${sweepFails.join(', ')}]`)
|
||||||
|
})
|
||||||
|
|
||||||
|
check('round-trip sweep: the 4 excluded are exactly jazz-blues, blues-quickchange, blues-8bar, bossa-blue', () => {
|
||||||
|
const want = ['blues/blues-8bar', 'blues/blues-quickchange', 'bossa/bossa-blue', 'jazz/jazz-blues']
|
||||||
|
assert(JSON.stringify([...sweepFails].sort()) === JSON.stringify(want),
|
||||||
|
`excluded set [${[...sweepFails].sort().join(', ')}] ≠ [${want.join(', ')}]`)
|
||||||
|
})
|
||||||
|
|
||||||
|
check('match.roundTripPasses agrees with the verbatim sweep on all 56 (drift guard)', () => {
|
||||||
|
for (const style of Object.keys(kb)) {
|
||||||
|
for (const prog of kb[style].progressions ?? []) {
|
||||||
|
const form = seedableLoop(prog, 0)
|
||||||
|
const mine = form ? sweepPasses(form) : false
|
||||||
|
const theirs = form ? roundTripPasses(form) : false
|
||||||
|
assert(mine === theirs, `${style}/${prog.id}: verbatim ${mine} ≠ match.roundTripPasses ${theirs}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
check('buildRoulettePool exposes exactly the 52 passers, all collapsed len 2–8', () => {
|
||||||
|
const pool = buildRoulettePool(kb)
|
||||||
|
let n = 0
|
||||||
|
for (const [, members] of pool.byStyle) {
|
||||||
|
for (const mem of members) {
|
||||||
|
n++
|
||||||
|
assert(mem.collapsedLen >= 2 && mem.collapsedLen <= 8, `${mem.id} collapsedLen ${mem.collapsedLen} out of 2–8`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(n === 52, `pool holds ${n} members ≠ 52`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fix (a): a clean LIVE 12-bar commit stream now matches blues-12bar — the
|
||||||
|
// pre-existing live-detection bug pinned fixed (jam-roulette.md §3.3.1). The live
|
||||||
|
// commit stream is ALREADY collapsed (App.jsx dedupes back-to-back commits), so a
|
||||||
|
// real 12-bar in G commits [G7,C7,G7,D7,C7,G7,D7] per chorus. Before fix (a) this
|
||||||
|
// collapsed loop matched NOTHING → an empty JamGuide; now it detects + matches.
|
||||||
|
check('fix (a) live: a clean collapsed 12-bar commit stream detects + matches blues-12bar (was an empty JamGuide)', () => {
|
||||||
|
const G12_COMMITS = ['G7', 'C7', 'G7', 'D7', 'C7', 'G7', 'D7'] // one chorus, as committed
|
||||||
|
const history = [].concat(...Array.from({ length: 5 }, () => G12_COMMITS)) // 5 choruses
|
||||||
|
const detected = detectRepeatingProgression(history)
|
||||||
|
assert(detected, 'detector returned null for a clean 12-bar commit stream')
|
||||||
|
const m = matchLoopToProgression(detected, index)
|
||||||
|
assert(m.matched && m.id === 'blues-12bar' && m.style === 'blues',
|
||||||
|
`detected ${JSON.stringify(detected)} matched ${m.matched ? m.style + '/' + m.id : 'NONE'}, expected blues/blues-12bar`)
|
||||||
|
})
|
||||||
|
|
||||||
// ─── 8. RelatedProgressions ranking pins (C-50) ───────────────────────────────
|
// ─── 8. RelatedProgressions ranking pins (C-50) ───────────────────────────────
|
||||||
//
|
//
|
||||||
// Pins the L-51-gate-verified ranking outcomes of RelatedProgressions.jsx
|
// Pins the L-51-gate-verified ranking outcomes of RelatedProgressions.jsx
|
||||||
@@ -1201,14 +1294,13 @@ if (existsSync(join(ROOT, 'src/components/PianoLickCard.jsx'))) {
|
|||||||
// reshuffles the top entries turns smoke red instead of silently changing what
|
// reshuffles the top entries turns smoke red instead of silently changing what
|
||||||
// jammers are recommended. Two live pins + one counterfactual:
|
// jammers are recommended. Two live pins + one counterfactual:
|
||||||
//
|
//
|
||||||
// (a) collapsed live 12-bar in A → blues/blues-12bar top at score 152,
|
// (a) collapsed live 12-bar in A → RE-PINNED for L-60 fix (a): the
|
||||||
// annotation 'same changes'. Today matchLoopToProgression does NOT
|
// collapsed-form index now recognizes the loop as blues-12bar, which the
|
||||||
// recognize the collapsed loop (the raw index is collapse-blind — the
|
// id-only rule excludes from its own related list. The new top is
|
||||||
// known D-62 fix (a) gap), so match.matched === false and blues-12bar
|
// blues/blues-8bar at score 92, annotation 'shares I7→V7' (0 shape + 40
|
||||||
// itself ranks as a relative. ⚠ When D-62's collapsed-form indexing
|
// same-style + 36 transitions Δ{5,7,10} + 16 Jaccard·1.0 − 0 length);
|
||||||
// lands in match.js, the match flips to blues-12bar, the id-only
|
// blues-quickchange edges just under at 90 (same terms, |7−9|=2 length
|
||||||
// exclusion removes it from entries, and these pins go red ON PURPOSE —
|
// penalty). This is the exact flip the pre-fix note here foretold.
|
||||||
// re-pin deliberately then.
|
|
||||||
// (b) live ii–V–I in C → match jazz/jazz-251-major (quality overlap wins
|
// (b) live ii–V–I in C → match jazz/jazz-251-major (quality overlap wins
|
||||||
// over jazz-251-minor), which is EXCLUDED from entries; top entry is
|
// over jazz-251-minor), which is EXCLUDED from entries; top entry is
|
||||||
// jazz/jazz-251-minor at 156 'same changes' (100 same canonical shape +
|
// jazz/jazz-251-minor at 156 'same changes' (100 same canonical shape +
|
||||||
@@ -1219,8 +1311,10 @@ if (existsSync(join(ROOT, 'src/components/PianoLickCard.jsx'))) {
|
|||||||
// degrees, scores 39 (36 transitions + 8 Jaccard·0.5 − 5 length, no
|
// degrees, scores 39 (36 transitions + 8 Jaccard·0.5 − 5 length, no
|
||||||
// +100) vs 152 with collapse — proving the mandatory collapse step is
|
// +100) vs 152 with collapse — proving the mandatory collapse step is
|
||||||
// what makes the flagship "this 12-bar IS their 12-bar" relation fire.
|
// what makes the flagship "this 12-bar IS their 12-bar" relation fire.
|
||||||
// The replica-with-collapse must equal the live component score (152),
|
// Post fix (a) blues-12bar is the MATCH (excluded), so the live grounding
|
||||||
// grounding the replica so the 39 can't drift into fiction.
|
// moves to the actual top entry: the replica of blues-8bar (same-style
|
||||||
|
// relative) must equal its live component score (92), grounding the
|
||||||
|
// replica so the 39/152 can't drift into fiction.
|
||||||
|
|
||||||
console.log('\nRelatedProgressions ranking pins (C-50):')
|
console.log('\nRelatedProgressions ranking pins (C-50):')
|
||||||
|
|
||||||
@@ -1253,15 +1347,23 @@ check('collapseChanges(blues-minor) pops the wrap-around pair → 5 units [0,5,0
|
|||||||
})
|
})
|
||||||
|
|
||||||
const rank12 = rankRelatedProgressions(LOOP_12BAR_A)
|
const rank12 = rankRelatedProgressions(LOOP_12BAR_A)
|
||||||
check('pin (a): collapsed 12-bar → matcher NONE today; blues-12bar top at exactly 152, "same changes"', () => {
|
check('pin (a): collapsed 12-bar → matcher now recognizes blues-12bar (fix (a)); it self-excludes, blues-8bar tops at exactly 92, "shares I7→V7"', () => {
|
||||||
assert(rank12, 'ranking returned null for a parseable loop')
|
assert(rank12, 'ranking returned null for a parseable loop')
|
||||||
assert(rank12.match.matched === false,
|
// Re-pinned for L-60 fix (a): the collapsed-form index makes the detected
|
||||||
`match is ${rank12.match.id} — the raw loop index recognized a collapsed loop; D-62 fix (a) has landed: re-pin this section deliberately (blues-12bar now gets excluded by the id-only rule)`)
|
// collapsed 12-bar match blues-12bar itself, which the id-only rule excludes
|
||||||
|
// from its own related list (§8's pre-fix note foretold exactly this flip).
|
||||||
|
assert(rank12.match.matched && rank12.match.id === 'blues-12bar',
|
||||||
|
`match is ${rank12.match.matched ? rank12.match.id : 'NONE'} — fix (a) should make the collapsed 12-bar match blues-12bar`)
|
||||||
|
assert(!rank12.entries.some((e) => e.id === 'blues-12bar'),
|
||||||
|
'blues-12bar leaked into its own related list — the id-only exclusion broke')
|
||||||
const top = rank12.entries[0]
|
const top = rank12.entries[0]
|
||||||
assert(top && top.id === 'blues-12bar' && top.style === 'blues',
|
// blues-8bar: same style (+40) + all 3 loop transitions Δ{5,7,10} present (cap 36)
|
||||||
`top entry is ${top?.style}/${top?.id}, expected blues/blues-12bar`)
|
// + identical rebased degree-set {0,5,10} (Jaccard 1.0 ×16) − |7−7| length = 92.
|
||||||
assert(top.score === 152, `blues-12bar scored ${top.score}, pinned 152 (100 shape + 36 transitions + 16 Jaccard − 0 length)`)
|
// Edges blues-quickchange (90; same terms but |7−9|=2 length penalty).
|
||||||
assert(top.annotation === 'same changes', `annotation '${top.annotation}' ≠ 'same changes'`)
|
assert(top && top.id === 'blues-8bar' && top.style === 'blues',
|
||||||
|
`top entry is ${top?.style}/${top?.id}, expected blues/blues-8bar`)
|
||||||
|
assert(top.score === 92, `blues-8bar scored ${top.score}, pinned 92 (0 shape + 40 style + 36 transitions + 16 Jaccard − 0 length)`)
|
||||||
|
assert(top.annotation === 'shares I7→V7', `annotation '${top.annotation}' ≠ 'shares I7→V7'`)
|
||||||
})
|
})
|
||||||
|
|
||||||
const rank251 = rankRelatedProgressions(LOOP_251_C)
|
const rank251 = rankRelatedProgressions(LOOP_251_C)
|
||||||
@@ -1311,13 +1413,23 @@ function replicaScore(loop, prog, sameStyle, { collapse }) {
|
|||||||
- Math.abs(loop.length - units.length)
|
- Math.abs(loop.length - units.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
check('pin (c): no-collapse counterfactual — raw blues-12bar would score exactly 39 (replica grounded at 152 with collapse)', () => {
|
check('pin (c): no-collapse counterfactual — raw blues-12bar would score exactly 39 vs 152 with collapse; replica grounded against the live top blues-8bar (92)', () => {
|
||||||
const prog = kb.blues.progressions.find((p) => p.id === 'blues-12bar')
|
const prog = kb.blues.progressions.find((p) => p.id === 'blues-12bar')
|
||||||
|
// The flagship "this 12-bar IS their 12-bar" relation: 152 with the mandatory
|
||||||
|
// collapse step, 39 without it — proving collapse is what fires the +100 shape
|
||||||
|
// term. blues-12bar is no longer a live *entry* post fix (a) (it's the match,
|
||||||
|
// excluded), so this is a pure-formula check of the replica, not read off the
|
||||||
|
// component. Both remain literal so a scoring-constant tweak turns smoke red.
|
||||||
const withCollapse = replicaScore(LOOP_12BAR_A, prog, false, { collapse: true })
|
const withCollapse = replicaScore(LOOP_12BAR_A, prog, false, { collapse: true })
|
||||||
assert(withCollapse === 152, `replica with collapse scored ${withCollapse} ≠ 152 — the replica drifted from the §5 formula; fix the replica (or the component changed: re-derive BOTH pins)`)
|
assert(withCollapse === 152, `replica with collapse scored ${withCollapse} ≠ 152 — the replica drifted from the §5 formula; fix the replica (or the component changed: re-derive BOTH pins)`)
|
||||||
assert(rank12.entries[0].score === withCollapse, `replica (${withCollapse}) ≠ live component score (${rank12.entries[0].score}) — the grounding broke`)
|
|
||||||
const noCollapse = replicaScore(LOOP_12BAR_A, prog, false, { collapse: false })
|
const noCollapse = replicaScore(LOOP_12BAR_A, prog, false, { collapse: false })
|
||||||
assert(noCollapse === 39, `no-collapse counterfactual scored ${noCollapse} ≠ 39 (36 transitions + 8 half-Jaccard − 5 length) — the raw/collapsed relationship changed; re-derive the counterfactual`)
|
assert(noCollapse === 39, `no-collapse counterfactual scored ${noCollapse} ≠ 39 (36 transitions + 8 half-Jaccard − 5 length) — the raw/collapsed relationship changed; re-derive the counterfactual`)
|
||||||
|
// Ground the replica against the LIVE component via the actual top entry
|
||||||
|
// (blues-8bar, same-style relative): the replica must reproduce its score.
|
||||||
|
const prog8 = kb.blues.progressions.find((p) => p.id === 'blues-8bar')
|
||||||
|
const w8 = replicaScore(LOOP_12BAR_A, prog8, true, { collapse: true })
|
||||||
|
assert(w8 === 92, `replica of blues-8bar scored ${w8} ≠ 92 — the replica drifted from the §5 formula`)
|
||||||
|
assert(rank12.entries[0].score === w8, `replica (${w8}) ≠ live component top score (${rank12.entries[0].score}) — the grounding broke`)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── 9. Summary + exit code ───────────────────────────────────────────────────
|
// ─── 9. Summary + exit code ───────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -281,6 +281,10 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
quality: qualities[i] ?? 'maj',
|
quality: qualities[i] ?? 'maj',
|
||||||
label: `${noteName}${suffix}`,
|
label: `${noteName}${suffix}`,
|
||||||
rn: prog?.rn?.[i] ?? '',
|
rn: prog?.rn?.[i] ?? '',
|
||||||
|
// Fix (a) remap (jam-roulette.md §3.3.2): the RAW authored-play index this
|
||||||
|
// (possibly collapsed) station reads from. Raw matches have no sourceIndex
|
||||||
|
// → identity (i); collapsed matches carry the projection's map.
|
||||||
|
sourceIndex: prog?.sourceIndex?.[i] ?? i,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (instrument === 'guitar') {
|
if (instrument === 'guitar') {
|
||||||
@@ -288,7 +292,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
const play = Array.isArray(plays) ? plays[0] : null
|
const play = Array.isArray(plays) ? plays[0] : null
|
||||||
const chords = play?.chords ?? []
|
const chords = play?.chords ?? []
|
||||||
for (let i = 0; i < stations.length; i++) {
|
for (let i = 0; i < stations.length; i++) {
|
||||||
stations[i].shape = chords[i]?.shape ?? null
|
stations[i].shape = chords[stations[i].sourceIndex ?? i]?.shape ?? null
|
||||||
}
|
}
|
||||||
} else if (instrument === 'piano') {
|
} else if (instrument === 'piano') {
|
||||||
// Authored piano pack first (L-24): the matched style's first piano play
|
// Authored piano pack first (L-24): the matched style's first piano play
|
||||||
@@ -296,7 +300,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
const pianoPlays = kb[match.style]?.instruments?.piano?.plays?.[prog?.id]
|
const pianoPlays = kb[match.style]?.instruments?.piano?.plays?.[prog?.id]
|
||||||
const play = Array.isArray(pianoPlays) && pianoPlays.length ? pianoPlays[0] : null
|
const play = Array.isArray(pianoPlays) && pianoPlays.length ? pianoPlays[0] : null
|
||||||
const authored = play
|
const authored = play
|
||||||
? stations.map((st, i) => recipeVoicing(play.chords?.[i]?.recipe, st.rootPc, st.quality))
|
? stations.map((st, i) => recipeVoicing(play.chords?.[st.sourceIndex ?? i]?.recipe, st.rootPc, st.quality))
|
||||||
: null
|
: null
|
||||||
// Computed fallback — only built when needed (no pack, or a recipe that
|
// Computed fallback — only built when needed (no pack, or a recipe that
|
||||||
// failed to resolve). Identical to the pre-L-24 computed path.
|
// failed to resolve). Identical to the pre-L-24 computed path.
|
||||||
@@ -408,7 +412,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
|
|||||||
? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; following the chord as it commits.`
|
? `Heard ${detectedProgression.join(' → ')} — no ${activeStyle} pattern matched yet; following the chord as it commits.`
|
||||||
: 'No repeating loop yet — following the chord as it commits.'}
|
: 'No repeating loop yet — following the chord as it commits.'}
|
||||||
</p>
|
</p>
|
||||||
<VoicingBrowser rootPc={liveChord.rootPc} quality={liveChord.type} show={instrument} />
|
<VoicingBrowser rootPc={liveChord.rootPc} quality={liveChord.type} show={instrument} dense />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -582,10 +586,10 @@ function BassGuideRows({ stations = [], activeIndex = -1, keyMode, live = false,
|
|||||||
rootPc={st.rootPc}
|
rootPc={st.rootPc}
|
||||||
quality={st.quality}
|
quality={st.quality}
|
||||||
nextRootPc={stations[(i + 1) % n].rootPc}
|
nextRootPc={stations[(i + 1) % n].rootPc}
|
||||||
pattern={play?.chords?.[i]?.pattern}
|
pattern={play?.chords?.[st.sourceIndex ?? i]?.pattern}
|
||||||
playLabel={play?.label}
|
playLabel={play?.label}
|
||||||
feel={play?.feel}
|
feel={play?.feel}
|
||||||
note={play?.chords?.[i]?.note}
|
note={play?.chords?.[st.sourceIndex ?? i]?.note}
|
||||||
chordLabel={st.label}
|
chordLabel={st.label}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+186
-5
@@ -11,7 +11,7 @@
|
|||||||
// This file is matching/position logic only. All music-theory primitives
|
// This file is matching/position logic only. All music-theory primitives
|
||||||
// (note names, roman numerals) come read-only from theory.js.
|
// (note names, roman numerals) come read-only from theory.js.
|
||||||
|
|
||||||
import { NOTES, NOTES_FLAT, toRomanNumeral } from './theory'
|
import { NOTES, NOTES_FLAT, CHORD_TYPES, toRomanNumeral, canonicalize, detectRepeatingProgression } from './theory'
|
||||||
|
|
||||||
// ─── Chord-name parsing (local — theory.js does not export a pitch-class helper) ─
|
// ─── Chord-name parsing (local — theory.js does not export a pitch-class helper) ─
|
||||||
|
|
||||||
@@ -122,25 +122,108 @@ export function loopToDegrees(loop) {
|
|||||||
return pcs.map(pc => ((pc - tonic) % 12 + 12) % 12)
|
return pcs.map(pc => ((pc - tonic) % 12 + 12) % 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suffix of a KB quality token (the display suffix, '' fallback for out-of-vocab
|
||||||
|
// tokens). Name-collapse compares SUFFIXES, not raw quality tokens: two
|
||||||
|
// out-of-vocab qualities both fall back to '' and realize to equal names, so a
|
||||||
|
// token comparison would under-collapse (jam-roulette.md §3.3.2).
|
||||||
|
function suffixOfQuality(quality) {
|
||||||
|
return CHORD_TYPES[quality]?.suffix ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* collapseProjection(progression) → { degrees, qualities, rn, bars, sourceIndex }
|
||||||
|
*
|
||||||
|
* The key-free collapsed shape of a KB progression (fix (a), jam-roulette.md
|
||||||
|
* §3.3.2 / §3.3.3): dedupe adjacent stations whose (degree, suffix) pairs are
|
||||||
|
* equal, then wrap-dedupe (if the last pair equals the first, drop the last).
|
||||||
|
* The projection mirrors detection's collapsed commit stream:
|
||||||
|
* - rn : the first-of-run roman numeral
|
||||||
|
* - bars : summed per run
|
||||||
|
* - sourceIndex[i] : the first RAW station index of collapsed station i — the
|
||||||
|
* remap every authored-play lookup must go through so a collapsed
|
||||||
|
* match reads the right raw play entry (JamGuide §3.3.2).
|
||||||
|
* Name-collapse is provably key-independent (§3.3.3), so this is computed once
|
||||||
|
* with no key in hand.
|
||||||
|
*/
|
||||||
|
function collapseProjection(progression) {
|
||||||
|
const degrees = Array.isArray(progression?.degrees) ? progression.degrees : []
|
||||||
|
const qualities = Array.isArray(progression?.qualities) ? progression.qualities : []
|
||||||
|
const rn = Array.isArray(progression?.rn) ? progression.rn : []
|
||||||
|
const bars = Array.isArray(progression?.bars) ? progression.bars : []
|
||||||
|
const outDeg = [], outQual = [], outRn = [], outBars = [], sourceIndex = []
|
||||||
|
for (let i = 0; i < degrees.length; i++) {
|
||||||
|
const last = outDeg.length - 1
|
||||||
|
if (last >= 0 && outDeg[last] === degrees[i] && suffixOfQuality(outQual[last]) === suffixOfQuality(qualities[i])) {
|
||||||
|
outBars[last] += bars[i] ?? 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outDeg.push(degrees[i])
|
||||||
|
outQual.push(qualities[i])
|
||||||
|
outRn.push(rn[i] ?? '')
|
||||||
|
outBars.push(bars[i] ?? 0)
|
||||||
|
sourceIndex.push(i)
|
||||||
|
}
|
||||||
|
// Wrap-dedupe — only the boundary pair can merge post-collapse (§3.3.2).
|
||||||
|
if (outDeg.length > 1
|
||||||
|
&& outDeg[outDeg.length - 1] === outDeg[0]
|
||||||
|
&& suffixOfQuality(outQual[outQual.length - 1]) === suffixOfQuality(outQual[0])) {
|
||||||
|
outBars[0] += outBars[outDeg.length - 1]
|
||||||
|
outDeg.pop(); outQual.pop(); outRn.pop(); outBars.pop(); sourceIndex.pop()
|
||||||
|
}
|
||||||
|
return { degrees: outDeg, qualities: outQual, rn: outRn, bars: outBars, sourceIndex }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Precompute a lookup table from a KB registry (kb/index.js default export).
|
* Precompute a lookup table from a KB registry (kb/index.js default export).
|
||||||
* Returns { byCanonical: Map<canonicalDegrees, entry[]> } where each entry is
|
* Returns { byCanonical: Map<canonicalDegrees, entry[]> } where each entry is
|
||||||
* { style, id, progression }. Build once, reuse across matches.
|
* { style, id, progression }. Build once, reuse across matches.
|
||||||
|
*
|
||||||
|
* Fix (a) (jam-roulette.md §3.3.2): each progression is indexed by its RAW
|
||||||
|
* degrees AND — when the collapsed shape differs (11/56 today) — by its
|
||||||
|
* collapsed form, whose `progression` is the collapsed PROJECTION (degrees /
|
||||||
|
* qualities / rn / bars / sourceIndex). Collapsed entries are appended AFTER all
|
||||||
|
* raw entries so matchLoopToProgression's strict-`>` disambiguation keeps every
|
||||||
|
* previously-matching input's winner on ties (behaviour-preserving except the
|
||||||
|
* one enumerated strict win, country-145 → its own collapsed form). This makes
|
||||||
|
* a detected COLLAPSED loop (the live commit stream dedupes back-to-back chords)
|
||||||
|
* match its progression — repairing a pre-existing live-detection miss for real
|
||||||
|
* 12-bar / 8-bar streams and enabling the roulette seed to populate the guide.
|
||||||
*/
|
*/
|
||||||
export function buildLoopIndex(kb) {
|
export function buildLoopIndex(kb) {
|
||||||
const byCanonical = new Map()
|
const byCanonical = new Map()
|
||||||
if (!kb) return { byCanonical }
|
if (!kb) return { byCanonical }
|
||||||
|
const add = (canon, entry) => {
|
||||||
|
if (!byCanonical.has(canon)) byCanonical.set(canon, [])
|
||||||
|
byCanonical.get(canon).push(entry)
|
||||||
|
}
|
||||||
|
const collapsedPending = []
|
||||||
for (const style of Object.keys(kb)) {
|
for (const style of Object.keys(kb)) {
|
||||||
const progs = kb[style]?.progressions
|
const progs = kb[style]?.progressions
|
||||||
if (!Array.isArray(progs)) continue
|
if (!Array.isArray(progs)) continue
|
||||||
for (const progression of progs) {
|
for (const progression of progs) {
|
||||||
if (!Array.isArray(progression.degrees) || !progression.degrees.length) continue
|
if (!Array.isArray(progression.degrees) || !progression.degrees.length) continue
|
||||||
const canon = canonicalDegrees(progression.degrees)
|
add(canonicalDegrees(progression.degrees), { style, id: progression.id, progression })
|
||||||
const entry = { style, id: progression.id, progression }
|
// Collapsed-form entry — only when the collapsed shape differs (a run
|
||||||
if (!byCanonical.has(canon)) byCanonical.set(canon, [])
|
// collapse strictly shortens length, so a length change ⇔ a real collapse).
|
||||||
byCanonical.get(canon).push(entry)
|
const proj = collapseProjection(progression)
|
||||||
|
if (proj.degrees.length && proj.degrees.length !== progression.degrees.length) {
|
||||||
|
const collapsedProg = {
|
||||||
|
...progression,
|
||||||
|
degrees: proj.degrees,
|
||||||
|
qualities: proj.qualities,
|
||||||
|
rn: proj.rn,
|
||||||
|
bars: proj.bars,
|
||||||
|
sourceIndex: proj.sourceIndex,
|
||||||
|
}
|
||||||
|
collapsedPending.push({
|
||||||
|
canon: canonicalDegrees(proj.degrees),
|
||||||
|
entry: { style, id: progression.id, progression: collapsedProg },
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Append collapsed entries after ALL raw entries (the tie-preservation invariant).
|
||||||
|
for (const { canon, entry } of collapsedPending) add(canon, entry)
|
||||||
return { byCanonical }
|
return { byCanonical }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +282,10 @@ export function matchLoopToProgression(loop, kbOrIndex) {
|
|||||||
matched: true,
|
matched: true,
|
||||||
id: best.id,
|
id: best.id,
|
||||||
style: best.style,
|
style: best.style,
|
||||||
|
// For a collapsed-form hit best.progression.degrees IS the collapsed shape
|
||||||
|
// (equal length to the loop by construction, fix (a)), so rotation is
|
||||||
|
// computed against the collapsed degrees — the length-mismatch guard never
|
||||||
|
// silently returns 0 for a genuine collapsed match (jam-roulette.md §3.3.2).
|
||||||
rotation: rotationToCanonicalOrder(degrees, best.progression.degrees),
|
rotation: rotationToCanonicalOrder(degrees, best.progression.degrees),
|
||||||
progression: best.progression,
|
progression: best.progression,
|
||||||
}
|
}
|
||||||
@@ -224,6 +311,100 @@ function rotationToCanonicalOrder(loopDegrees, kbDegrees) {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Jam Roulette — seed realization + the seedable pool (task L-60) ──────────
|
||||||
|
//
|
||||||
|
// jam-roulette.md §3.2 / §2.2. The seed writes the exact loop detection would
|
||||||
|
// commit when the band plays this progression in the rolled key, so the L-31
|
||||||
|
// commit layer treats it like any committed loop.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* seedableLoop(progression, keyRootPc) → string[] | null
|
||||||
|
*
|
||||||
|
* Realize → collapse (+ wrap-dedupe) → canonicalize (§3.2):
|
||||||
|
* 1. Realize each station in the rolled key with SHARP spellings (§2.1 — only
|
||||||
|
* sharp names string-match live detection's noteName path).
|
||||||
|
* 2. Collapse consecutive duplicate NAMES, then drop the last if it equals the
|
||||||
|
* first (the cyclic wrap — the loop's tail flows into its head live).
|
||||||
|
* 3. Canonicalize the rotation with theory.js's own rule, so the seeded string
|
||||||
|
* equals detectRepeatingProgression's canonical output exactly (the L-31
|
||||||
|
* agreement branch compares join(',') strings).
|
||||||
|
* Returns null when the collapsed loop has < 2 names (unrepresentable as a
|
||||||
|
* detected loop — detectRepeatingProgression's min pattern length is 2).
|
||||||
|
*/
|
||||||
|
export function seedableLoop(progression, keyRootPc) {
|
||||||
|
const degrees = Array.isArray(progression?.degrees) ? progression.degrees : []
|
||||||
|
const qualities = Array.isArray(progression?.qualities) ? progression.qualities : []
|
||||||
|
if (!degrees.length) return null
|
||||||
|
const names = degrees.map((deg, i) => {
|
||||||
|
const rootPc = (((keyRootPc + deg) % 12) + 12) % 12
|
||||||
|
return NOTES[rootPc] + suffixOfQuality(qualities[i])
|
||||||
|
})
|
||||||
|
const collapsed = names.filter((n, i) => i === 0 || n !== names[i - 1])
|
||||||
|
if (collapsed.length > 1 && collapsed[0] === collapsed[collapsed.length - 1]) collapsed.pop()
|
||||||
|
if (collapsed.length < 2) return null
|
||||||
|
return canonicalize(collapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* roundTripPasses(canonicalForm) → boolean (jam-roulette.md §2.2)
|
||||||
|
*
|
||||||
|
* The pool gate: a progression is confirmable iff, with a 32-commit window
|
||||||
|
* filled with repetitions of its seeded canonical form (steady state) and
|
||||||
|
* truncated at EVERY partial-cycle offset (0…len−1), detectRepeatingProgression
|
||||||
|
* returns exactly that form at ALL offsets. Steady-state-plus-all-offsets is the
|
||||||
|
* honest protocol — a jam is sampled mid-cycle, not at cycle boundaries, and a
|
||||||
|
* naive "2 clean cycles" feed gets both failure modes wrong (§2.2). Hard length
|
||||||
|
* bounds 2–8 (the detector only sweeps those lengths) are part of the gate.
|
||||||
|
* Key-independent (§3.3.3): the detector consumes only the name stream's
|
||||||
|
* equality structure, so one sweep in any key (the pool builds in C) covers all.
|
||||||
|
*/
|
||||||
|
export function roundTripPasses(canonicalForm) {
|
||||||
|
if (!Array.isArray(canonicalForm)) return false
|
||||||
|
const len = canonicalForm.length
|
||||||
|
if (len < 2 || len > 8) return false
|
||||||
|
const WINDOW = 32
|
||||||
|
const target = canonicalForm.join(',')
|
||||||
|
for (let offset = 0; offset < len; offset++) {
|
||||||
|
const history = []
|
||||||
|
for (let k = 0; k < WINDOW; k++) history.push(canonicalForm[(offset + k) % len])
|
||||||
|
const detected = detectRepeatingProgression(history)
|
||||||
|
if (!detected || detected.join(',') !== target) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy module-level memo (§2.2): one sweep on first roulette open, reused for
|
||||||
|
// every roll. Key-independent, so the pool is computed once in C.
|
||||||
|
let _roulettePoolMemo = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildRoulettePool(kb) → { byStyle: Map<style, member[]> }
|
||||||
|
* member = { style, id, progression, collapsedLen }
|
||||||
|
*
|
||||||
|
* The roulette pool (§2.2): per style, the progressions whose seedable canonical
|
||||||
|
* form passes the round-trip invariant AND lands in the 2–8 length bounds. An
|
||||||
|
* 11th style or a new progression joins automatically. Randomization (weighting,
|
||||||
|
* no-repeat memory, key) lives in the caller (App.rollJam) — this is the pure,
|
||||||
|
* audio-free eligibility set.
|
||||||
|
*/
|
||||||
|
export function buildRoulettePool(kb) {
|
||||||
|
if (_roulettePoolMemo) return _roulettePoolMemo
|
||||||
|
const byStyle = new Map()
|
||||||
|
for (const style of Object.keys(kb ?? {})) {
|
||||||
|
const progs = kb[style]?.progressions
|
||||||
|
if (!Array.isArray(progs)) { byStyle.set(style, []); continue }
|
||||||
|
const eligible = []
|
||||||
|
for (const progression of progs) {
|
||||||
|
const form = seedableLoop(progression, 0) // key C — key-independent (§3.3.3)
|
||||||
|
if (!form || !roundTripPasses(form)) continue
|
||||||
|
eligible.push({ style, id: progression.id, progression, collapsedLen: form.length })
|
||||||
|
}
|
||||||
|
byStyle.set(style, eligible)
|
||||||
|
}
|
||||||
|
_roulettePoolMemo = { byStyle }
|
||||||
|
return _roulettePoolMemo
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Roman-numeral helpers (re-exported for callers that only need matching) ───
|
// ─── Roman-numeral helpers (re-exported for callers that only need matching) ───
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
-1
@@ -576,7 +576,9 @@ function matchLoopOccurrence(win, start, cand) {
|
|||||||
|
|
||||||
// Returns the lexicographically smallest rotation so the same loop always
|
// Returns the lexicographically smallest rotation so the same loop always
|
||||||
// produces the same string regardless of where in the cycle we currently are.
|
// produces the same string regardless of where in the cycle we currently are.
|
||||||
function canonicalize(pattern) {
|
// Exported additively for match.js's seedableLoop / round-trip pool (task L-60,
|
||||||
|
// jam-roulette.md §3.2) — the seed must canonicalize identically to detection.
|
||||||
|
export function canonicalize(pattern) {
|
||||||
let best = pattern
|
let best = pattern
|
||||||
for (let i = 1; i < pattern.length; i++) {
|
for (let i = 1; i < pattern.length; i++) {
|
||||||
const rot = [...pattern.slice(i), ...pattern.slice(0, i)]
|
const rot = [...pattern.slice(i), ...pattern.slice(0, i)]
|
||||||
|
|||||||
Reference in New Issue
Block a user