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:
+186
-5
@@ -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<canonicalDegrees, entry[]> } 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<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) ───
|
||||
|
||||
/**
|
||||
|
||||
+3
-1
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user