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 } + * 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) ─── /** diff --git a/src/lib/theory.js b/src/lib/theory.js index caebdb4..7cc629f 100644 --- a/src/lib/theory.js +++ b/src/lib/theory.js @@ -576,7 +576,9 @@ function matchLoopOccurrence(win, start, cand) { // 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) { +// 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 for (let i = 1; i < pattern.length; i++) { const rot = [...pattern.slice(i), ...pattern.slice(0, i)]