From 8d74e54f5a7f66ffdcc9a40a588ef7e1c874192a Mon Sep 17 00:00:00 2001
From: vadimwit
Date: Sat, 11 Jul 2026 19:48:14 +0100
Subject: [PATCH] =?UTF-8?q?fix(match):=20index=20collapsed=20loop=20forms?=
=?UTF-8?q?=20=E2=80=94=20real=2012-bar=20blues=20now=20matches=20its=20KB?=
=?UTF-8?q?=20entry=20(task=20L-60,=201/2)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
docs/design/one-screen.md | 6 +-
scripts/smoke.mjs | 150 ++++++++++++++++++++++++----
src/components/JamGuide.jsx | 14 ++-
src/lib/match.js | 191 +++++++++++++++++++++++++++++++++++-
src/lib/theory.js | 4 +-
5 files changed, 333 insertions(+), 32 deletions(-)
diff --git a/docs/design/one-screen.md b/docs/design/one-screen.md
index ed8900c..ddbff79 100644
--- a/docs/design/one-screen.md
+++ b/docs/design/one-screen.md
@@ -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`
(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.
-Cell wrap at ~576px interior: guitar 5/line, piano two-octave cells alone —
-same behaviour the D-41 gate already verified for the band. The instrument
+Cell wrap at ~576px interior: guitar 5/line, piano cells **pair** (a 284px
+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
at every width.
diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs
index 6376247..54280da 100644
--- a/scripts/smoke.mjs
+++ b/scripts/smoke.mjs
@@ -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)')
}
+// ─── 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) ───────────────────────────────
//
// 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
// jammers are recommended. Two live pins + one counterfactual:
//
-// (a) collapsed live 12-bar in A → blues/blues-12bar top at score 152,
-// annotation 'same changes'. Today matchLoopToProgression does NOT
-// recognize the collapsed loop (the raw index is collapse-blind — the
-// known D-62 fix (a) gap), so match.matched === false and blues-12bar
-// itself ranks as a relative. ⚠ When D-62's collapsed-form indexing
-// lands in match.js, the match flips to blues-12bar, the id-only
-// exclusion removes it from entries, and these pins go red ON PURPOSE —
-// re-pin deliberately then.
+// (a) collapsed live 12-bar in A → RE-PINNED for L-60 fix (a): the
+// collapsed-form index now recognizes the loop as blues-12bar, which the
+// id-only rule excludes from its own related list. The new top is
+// blues/blues-8bar at score 92, annotation 'shares I7→V7' (0 shape + 40
+// same-style + 36 transitions Δ{5,7,10} + 16 Jaccard·1.0 − 0 length);
+// blues-quickchange edges just under at 90 (same terms, |7−9|=2 length
+// penalty). This is the exact flip the pre-fix note here foretold.
// (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
// 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
// +100) vs 152 with collapse — proving the mandatory collapse step is
// what makes the flagship "this 12-bar IS their 12-bar" relation fire.
-// The replica-with-collapse must equal the live component score (152),
-// grounding the replica so the 39 can't drift into fiction.
+// Post fix (a) blues-12bar is the MATCH (excluded), so the live grounding
+// 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):')
@@ -1253,15 +1347,23 @@ check('collapseChanges(blues-minor) pops the wrap-around pair → 5 units [0,5,0
})
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.match.matched === false,
- `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)`)
+ // Re-pinned for L-60 fix (a): the collapsed-form index makes the detected
+ // 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]
- assert(top && top.id === 'blues-12bar' && top.style === 'blues',
- `top entry is ${top?.style}/${top?.id}, expected blues/blues-12bar`)
- assert(top.score === 152, `blues-12bar scored ${top.score}, pinned 152 (100 shape + 36 transitions + 16 Jaccard − 0 length)`)
- assert(top.annotation === 'same changes', `annotation '${top.annotation}' ≠ 'same changes'`)
+ // blues-8bar: same style (+40) + all 3 loop transitions Δ{5,7,10} present (cap 36)
+ // + identical rebased degree-set {0,5,10} (Jaccard 1.0 ×16) − |7−7| length = 92.
+ // Edges blues-quickchange (90; same terms but |7−9|=2 length penalty).
+ 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)
@@ -1311,13 +1413,23 @@ function replicaScore(loop, prog, sameStyle, { collapse }) {
- 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')
+ // 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 })
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 })
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 ───────────────────────────────────────────────────
diff --git a/src/components/JamGuide.jsx b/src/components/JamGuide.jsx
index aa573a7..762d98d 100644
--- a/src/components/JamGuide.jsx
+++ b/src/components/JamGuide.jsx
@@ -281,6 +281,10 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
quality: qualities[i] ?? 'maj',
label: `${noteName}${suffix}`,
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') {
@@ -288,7 +292,7 @@ export default function JamGuide({ detectedProgression, keyInfo, chordHistory =
const play = Array.isArray(plays) ? plays[0] : null
const chords = play?.chords ?? []
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') {
// 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 play = Array.isArray(pianoPlays) && pianoPlays.length ? pianoPlays[0] : null
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
// Computed fallback — only built when needed (no pack, or a recipe that
// 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.`
: 'No repeating loop yet — following the chord as it commits.'}
-
+
)
) : (
@@ -582,10 +586,10 @@ function BassGuideRows({ stations = [], activeIndex = -1, keyMode, live = false,
rootPc={st.rootPc}
quality={st.quality}
nextRootPc={stations[(i + 1) % n].rootPc}
- pattern={play?.chords?.[i]?.pattern}
+ pattern={play?.chords?.[st.sourceIndex ?? i]?.pattern}
playLabel={play?.label}
feel={play?.feel}
- note={play?.chords?.[i]?.note}
+ note={play?.chords?.[st.sourceIndex ?? i]?.note}
chordLabel={st.label}
/>
))}
diff --git a/src/lib/match.js b/src/lib/match.js
index 5b0d1b3..97d937e 100644
--- a/src/lib/match.js
+++ b/src/lib/match.js
@@ -11,7 +11,7 @@
// This file is matching/position logic only. All music-theory primitives
// (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) ─
@@ -122,25 +122,108 @@ export function loopToDegrees(loop) {
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).
* Returns { byCanonical: Map } where each entry is
* { 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) {
const byCanonical = new Map()
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)) {
const progs = kb[style]?.progressions
if (!Array.isArray(progs)) continue
for (const progression of progs) {
if (!Array.isArray(progression.degrees) || !progression.degrees.length) continue
- const canon = canonicalDegrees(progression.degrees)
- const entry = { style, id: progression.id, progression }
- if (!byCanonical.has(canon)) byCanonical.set(canon, [])
- byCanonical.get(canon).push(entry)
+ add(canonicalDegrees(progression.degrees), { style, id: progression.id, progression })
+ // Collapsed-form entry — only when the collapsed shape differs (a run
+ // collapse strictly shortens length, so a length change ⇔ a real collapse).
+ 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 }
}
@@ -199,6 +282,10 @@ export function matchLoopToProgression(loop, kbOrIndex) {
matched: true,
id: best.id,
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),
progression: best.progression,
}
@@ -224,6 +311,100 @@ function rotationToCanonicalOrder(loopDegrees, kbDegrees) {
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