diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 54280da..78dbd3d 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -1319,7 +1319,7 @@ check('fix (a) live: a clean collapsed 12-bar commit stream detects + matches bl console.log('\nRelatedProgressions ranking pins (C-50):') const relMod = await load('src/components/RelatedProgressions.jsx') -const { rankRelatedProgressions, collapseChanges } = relMod +const { rankRelatedProgressions, collapseChanges, siblingRole } = relMod const { loopToDegrees, canonicalDegrees } = match const LOOP_12BAR_A = ['A7', 'D7', 'A7', 'E7', 'D7', 'A7', 'E7'] // detection-collapsed 12-bar @@ -1347,13 +1347,18 @@ 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 now recognizes blues-12bar (fix (a)); it self-excludes, blues-8bar tops at exactly 92, "shares I7→V7"', () => { +check('pin (a): collapsed 12-bar → matcher recognizes blues-12bar (fix (a)); it self-excludes, activeStyle=blues, blues-8bar tops the same-style primary at exactly 92, role "shorter form"', () => { assert(rank12, 'ranking returned null for a parseable loop') // 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`) + // L-72 same-style-first: the match's style is the active style; the panel + // leads with SAME-STYLE ONLY siblings (finding-A → secondary dropped). + assert(rank12.activeStyle === 'blues', `activeStyle is ${rank12.activeStyle}, expected blues`) + assert(rank12.secondary.length === 0, 'secondary is non-empty — finding-A drops cross-style when a style is locked') + assert(rank12.primary.every((e) => e.style === 'blues'), 'a non-blues entry leaked into the same-style primary') 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] @@ -1363,21 +1368,30 @@ check('pin (a): collapsed 12-bar → matcher now recognizes blues-12bar (fix (a) 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'`) + // L-72 §3: same-mode (major), 8 bars < 12 → role "shorter form" (was the §5 + // annotation 'shares I7→V7'). Genuine relationship (shares Δ{5,7,10}), so a + // role IS emitted (finding-B gate passes). + assert(top.role === 'shorter form', `role '${top.role}' ≠ 'shorter form'`) }) const rank251 = rankRelatedProgressions(LOOP_251_C) -check('pin (b): ii–V–I in C → match jazz-251-major (excluded); jazz-251-minor top at exactly 156, "same changes"', () => { +check('pin (b): ii–V–I in C → match jazz-251-major (excluded); activeStyle=jazz, jazz-251-minor top at exactly 156, role "minor version"', () => { assert(rank251, 'ranking returned null for a parseable loop') assert(rank251.match.matched && rank251.match.id === 'jazz-251-major', `match is ${rank251.match.matched ? rank251.match.id : 'NONE'}, expected jazz-251-major (quality-overlap disambiguation)`) + assert(rank251.activeStyle === 'jazz', `activeStyle is ${rank251.activeStyle}, expected jazz`) + assert(rank251.secondary.length === 0, 'secondary is non-empty — finding-A drops cross-style when a style is locked') + assert(rank251.primary.every((e) => e.style === 'jazz'), 'a non-jazz entry leaked into the same-style primary') assert(!rank251.entries.some((e) => e.id === 'jazz-251-major'), 'the matched progression leaked into its own related list — the id-only exclusion broke') const top = rank251.entries[0] assert(top && top.id === 'jazz-251-minor' && top.style === 'jazz', `top entry is ${top?.style}/${top?.id}, expected jazz/jazz-251-minor`) assert(top.score === 156, `jazz-251-minor scored ${top.score}, pinned 156 (100 shape + 40 style + 0 transitions + 16 Jaccard − 0 length)`) - assert(top.annotation === 'same changes', `annotation '${top.annotation}' ≠ 'same changes'`) + // L-72 §3: mode differs (minor vs the active major) → role "minor version" + // (was the §5 annotation 'same changes'). Genuine relationship (same changes), + // so the finding-B gate passes even though every quality differs. + assert(top.role === 'minor version', `role '${top.role}' ≠ 'minor version'`) assert(rank251.entries.length <= 5, `${rank251.entries.length} entries > max 5`) }) @@ -1432,6 +1446,46 @@ check('pin (c): no-collapse counterfactual — raw blues-12bar would score exact assert(rank12.entries[0].score === w8, `replica (${w8}) ≠ live component top score (${rank12.entries[0].score}) — the grounding broke`) }) +// L-72 finding-B (Critic gate hardening): the `genuine` gate must SUPPRESS the +// role phrase on a same-style sibling that shares NO transitions with the loop +// and is not same-changes — otherwise we'd print a false "variation" claim. +// A7–E7 matches funk; funk-smooth-loop surfaces via the floor-0 same-style rule +// but is a distant sibling (0 shared, not same-changes). siblingRole() ALONE +// would still label it (mode/bars differ) — the assertion below proves the GATE +// is what nulls it. Removing `&& genuine` at the call site turns this red. +check('finding-B: a distant same-style sibling (0 shared transitions, not same-changes) carries NO role, even though siblingRole() alone would label it', () => { + const rf = rankRelatedProgressions(['A7', 'E7']) + assert(rf && rf.activeStyle === 'funk', `activeStyle is ${rf?.activeStyle}, expected funk (A7–E7)`) + const activeProg = kb.funk.progressions.find((p) => p.id === rf.match.id) + assert(activeProg, `could not resolve the active funk progression ${rf.match.id}`) + const distant = rf.primary.find((e) => e.id === 'funk-smooth-loop') + assert(distant, 'funk-smooth-loop should surface as a same-style sibling (floor relaxed to 0)') + assert(distant.sameChanges === false, 'precondition: funk-smooth-loop must NOT be same-changes vs A7–E7') + assert(siblingRole(distant.progression, activeProg) !== null, + 'precondition: siblingRole() ALONE would label funk-smooth-loop — the genuine gate is what must suppress it') + assert(distant.role === null, + `finding-B breached: distant sibling carries role '${distant.role}' — the \`genuine\` gate was dropped (false variation claim)`) +}) + +// L-72 finding-A / no-match fallback (Critic gate hardening, design §6): when the +// loop matches NO KB progression, activeStyle is null and the panel is the +// pre-L-72 flat cross-style list — secondary always empty, cross-style rows keep +// their §5 annotation, and NO role phrases appear. This 8-chord A blues loop is +// not a canonical collapsed form, so it stays unmatched while still drawing +// cross-style relatives. Bites if the null path grows an activeStyle default or +// leaks roles onto cross-style rows. +check('no-match fallback: unmatched loop → activeStyle null, secondary empty, flat cross-style list with annotations (no roles)', () => { + const rNo = rankRelatedProgressions(['A7', 'D7', 'A7', 'A7', 'E7', 'D7', 'A7', 'E7']) + assert(rNo, 'ranking returned null for a parseable loop') + assert(rNo.match.matched === false, `precondition: expected NO match, got ${rNo.match.id}`) + assert(rNo.activeStyle === null, `no-match: activeStyle must be null, got ${rNo.activeStyle}`) + assert(rNo.secondary.length === 0, 'no-match: secondary must be empty') + assert(rNo.primary.length > 0, 'precondition: expected cross-style relatives above the floor') + assert(rNo.primary.every((e) => e.role === null), 'no-match: cross-style rows must carry NO role phrase') + assert(rNo.primary.every((e) => typeof e.annotation === 'string' && e.annotation.length > 0), + 'no-match: cross-style rows must keep their §5 annotation') +}) + // ─── 9. Summary + exit code ─────────────────────────────────────────────────── const total = passed + failures.length diff --git a/src/components/RelatedProgressions.jsx b/src/components/RelatedProgressions.jsx index dbfaf6d..39357d2 100644 --- a/src/components/RelatedProgressions.jsx +++ b/src/components/RelatedProgressions.jsx @@ -137,13 +137,69 @@ function degreeSetJaccard(aCanon, bCanon) { return union ? inter / union : 0 } +// Summed bar count of a progression (the total form length), or null when the +// KB entry carries no `bars` array. Used only by siblingRole (§3 rules 2–4). +function totalBars(prog) { + const bars = Array.isArray(prog?.bars) ? prog.bars : null + if (!bars) return null + return bars.reduce((sum, n) => sum + (typeof n === 'number' ? n : 0), 0) +} + /** - * rankRelatedProgressions(loop, kbRegistry?) → { match, entries } | null + * siblingRole(sibling, active) → role phrase | null (same-style-first §3) * - * The §5 ranking. Returns null when the loop yields no degrees (no loop / - * unparseable chord names) — the caller renders the idle state. `entries` is - * score-desc (stable by style, id), floored at RELATED_SCORE_FLOOR, at most - * RELATED_MAX_ENTRIES, never padded. Exported for smoke coverage. + * A short character phrase framing a same-style `sibling` against the `active` + * (matched) progression, derived ONLY from KB `mode` / `bars` / `qualities`. + * First rule that fires: + * 1. mode differs → "{mode} version" (minor/major, else the name) + * 2. same mode, fewer bars → "shorter form" + * 3. same mode, more bars → "extended form" + * 4. same mode & length, a quality the active lacks → "reharmonized" + * 5. otherwise → null (honest: name + level only) + * Returns null when `active` is unresolved. The finding-B gate (a sibling that + * shares NO genuine relationship with the played loop) is applied at the call + * site — see the `genuine` guard in rankRelatedProgressions. + */ +export function siblingRole(sibling, active) { + if (!sibling || !active) return null + const sMode = sibling.mode ?? null + const aMode = active.mode ?? null + // 1. mode differs → the mode-flavoured version. + if (sMode && aMode && sMode !== aMode) { + if (sMode === 'minor') return 'minor version' + if (sMode === 'major') return 'major version' + return `${sMode} version` + } + // 2–4 only compare within a shared mode (or when both modes are absent). + if (sMode !== aMode) return null + const sBars = totalBars(sibling) + const aBars = totalBars(active) + if (sBars != null && aBars != null) { + if (sBars < aBars) return 'shorter form' + if (sBars > aBars) return 'extended form' + } + // 4. same mode & length: a colour the active progression lacks. + const aQ = new Set(Array.isArray(active.qualities) ? active.qualities : []) + const sQ = Array.isArray(sibling.qualities) ? sibling.qualities : [] + if (aQ.size && sQ.some(q => !aQ.has(q))) return 'reharmonized' + return null +} + +/** + * rankRelatedProgressions(loop, kbRegistry?) → + * { match, activeStyle, activeStyleLabel, primary, secondary, entries } | null + * + * The §5 ranking, extended for same-style-first (L-72, docs/design/related- + * same-style.md). Returns null when the loop yields no degrees. When the loop + * matches a KB progression, `activeStyle = match.style` and the panel leads with + * that style's OTHER progressions (same-style siblings) — floor relaxed to 0, + * scorer order kept, each carrying a `role` phrase (siblingRole, §3). Per the + * Maestro finding-A resolution, when a style is locked the panel shows SAME-STYLE + * ONLY (`secondary` stays empty — no cross-style section). When `!match.matched` + * (`activeStyle == null`) the pre-existing cross-style flat list is returned + * unchanged (floor RELATED_SCORE_FLOOR, annotations). `entries` = + * `primary.concat(secondary)` for back-compat with `entries[0]` reads. + * Exported for smoke coverage. */ export function rankRelatedProgressions(loop, kbRegistry = kb) { const loopDeg = loopToDegrees(loop) @@ -153,6 +209,17 @@ export function rankRelatedProgressions(loop, kbRegistry = kb) { const match = matchLoopToProgression(loop, index) const loopT = transitionsOf(loopUnitsOf(loop, loopDeg)) + // §1: the active style IS the component's own match (roulette seed + live + // detection both route through the L-60 collapsed index). No prop needed. + const activeStyle = match.matched ? match.style : null + const activeStyleLabel = activeStyle + ? kbRegistry[activeStyle]?.meta?.label ?? activeStyle + : null + // The raw active KB entry — authoritative mode/bars for siblingRole (§3). + const activeProg = activeStyle + ? kbRegistry[activeStyle]?.progressions?.find(p => p.id === match.id) ?? null + : null + const entries = [] for (const style of Object.keys(kbRegistry)) { const styleLabel = kbRegistry[style]?.meta?.label ?? style @@ -166,7 +233,7 @@ export function rankRelatedProgressions(loop, kbRegistry = kb) { const pCanon = canonicalDegrees(collapsed.map(u => u.deg)) const shared = sharedTransitions(loopT, transitionsOf(collapsed)) const sameChanges = pCanon === loopCanon - const sameStyle = match.matched && style === match.style + const sameStyle = activeStyle != null && style === activeStyle let score = 0 if (sameChanges) score += SCORE_SAME_CHANGES @@ -174,16 +241,26 @@ export function rankRelatedProgressions(loop, kbRegistry = kb) { score += Math.min(shared.length * SCORE_PER_TRANSITION, TRANSITION_CAP) score += degreeSetJaccard(loopCanon, pCanon) * SCORE_JACCARD score -= Math.abs(loop.length - collapsed.length) - if (score < RELATED_SCORE_FLOOR) continue // never pad with junk + // §2/§4: same-style siblings bypass the floor (never "junk" — they are the + // "other options" the user asked for); cross-style keeps the floor. + const floor = sameStyle ? 0 : RELATED_SCORE_FLOOR + if (score < floor) continue - // §5 annotation: why this entry is here (survivors always have one — - // below the floor nothing scores on Jaccard − length alone). + // §5 annotation (cross-style rows): why this entry is here. const annotation = sameChanges ? 'same changes' : shared.length ? `shares ${shared[0].rnFrom || '?'}→${shared[0].rnTo || '?'}` : 'same style' + // §3 role (same-style rows only). finding-B gate: emit a phrase only when + // there is a genuine relationship to the played loop — identical changes + // OR at least one shared (Δ, quality→quality) transition. A distantly- + // related same-style sibling (neither) gets no false "extended form" / + // "reharmonized" label — just its name + level. + const genuine = sameChanges || shared.length > 0 + const role = sameStyle && genuine ? siblingRole(prog, activeProg) : null + entries.push({ style, styleLabel, @@ -191,7 +268,10 @@ export function rankRelatedProgressions(loop, kbRegistry = kb) { name: prog.name, level: prog.level === 'intermediate' ? 'intermediate' : 'foundation', // untagged counts foundation score, + sameChanges, + sameStyle, annotation, + role, progression: prog, }) } @@ -200,7 +280,25 @@ export function rankRelatedProgressions(loop, kbRegistry = kb) { entries.sort( (a, b) => b.score - a.score || a.style.localeCompare(b.style) || a.id.localeCompare(b.id) ) - return { match, entries: entries.slice(0, RELATED_MAX_ENTRIES) } + + // §2 + finding-A: with a style locked, primary = same-style siblings only + // (cap 5), secondary dropped. Without a lock, the flat cross-style list. + let primary, secondary + if (activeStyle != null) { + primary = entries.filter(e => e.sameStyle).slice(0, RELATED_MAX_ENTRIES) + secondary = [] // finding-A: no cross-style section when a style is locked + } else { + primary = entries.slice(0, RELATED_MAX_ENTRIES) + secondary = [] + } + return { + match, + activeStyle, + activeStyleLabel, + primary, + secondary, + entries: primary.concat(secondary), + } } // ─── Presentational bits ────────────────────────────────────────────────────── @@ -283,6 +381,8 @@ export default function RelatedProgressions({ loop, keyInfo, onChordClick }) { ) } + const { activeStyle, activeStyleLabel, primary } = ranked + return (
- {ranked.entries.length === 0 ? ( - // Loop, but nothing clears the floor — honest, never padded (§5). -

- Nothing in the songbook genuinely relates to this loop yet. -

+ {primary.length === 0 ? ( + activeStyle != null ? ( + // Locked to a style with no siblings (§5 edge). Honest, never padded. +

+ You’re on the only {activeStyleLabel} loop in the songbook. +

+ ) : ( + // Loop, but nothing clears the floor — honest, never padded (§5). +

+ Nothing in the songbook genuinely relates to this loop yet. +

+ ) ) : ( - + <> + {/* §2/§3: when a style is locked, lead with its OTHER progressions + reframed as variations to try — same-style only (finding-A). */} + {activeStyle != null && ( +

+ Try these in {activeStyleLabel} +

+ )} + + )}
)