feat(related): same-style-first variations when a style is active (task L-72)
When the loop matches a KB style (rolled or live-detected),
RelatedProgressions leads with that style's other progressions under a
"Try these in {style}" header, labelled by data-derived character
(minor version / shorter form / extended form / reharmonized) computed
from mode/bars/qualities — no cross-style jumping. The role phrase is
suppressed unless the sibling is genuinely related (same changes or a
shared transition), so no false "variation" claim. When no style is
locked, today's cross-style list is preserved byte-for-byte. Smoke
893/893: §8 re-pinned (scores stable 92/156, labels moved) plus two
gate-added durable assertions (finding-B suppression + no-match
fallback). Critic PASS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+59
-5
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<section
|
||||
className="rounded-2xl border border-border bg-panel p-3"
|
||||
@@ -292,29 +392,55 @@ export default function RelatedProgressions({ loop, keyInfo, onChordClick }) {
|
||||
Related progressions · from the songbook
|
||||
{keyInfo?.root ? ` · in ${keyInfo.root}` : ''}
|
||||
</h4>
|
||||
{ranked.entries.length === 0 ? (
|
||||
// Loop, but nothing clears the floor — honest, never padded (§5).
|
||||
<p className="text-sm text-gray-500">
|
||||
Nothing in the songbook genuinely relates to this loop yet.
|
||||
</p>
|
||||
{primary.length === 0 ? (
|
||||
activeStyle != null ? (
|
||||
// Locked to a style with no siblings (§5 edge). Honest, never padded.
|
||||
<p className="text-sm text-gray-500">
|
||||
You’re on the only {activeStyleLabel} loop in the songbook.
|
||||
</p>
|
||||
) : (
|
||||
// Loop, but nothing clears the floor — honest, never padded (§5).
|
||||
<p className="text-sm text-gray-500">
|
||||
Nothing in the songbook genuinely relates to this loop yet.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
{ranked.entries.map(entry => (
|
||||
<li key={`${entry.style}-${entry.id}`} className="min-w-0">
|
||||
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
|
||||
<span className="text-sm font-semibold text-gray-100">{entry.name}</span>
|
||||
<span className="text-xs text-gray-500">{entry.styleLabel}</span>
|
||||
<LevelBadge level={entry.level} />
|
||||
<span className="text-[10px] text-gray-500">{entry.annotation}</span>
|
||||
</div>
|
||||
<ChordChain
|
||||
progression={entry.progression}
|
||||
keyRoot={keyRoot}
|
||||
onChordClick={onChordClick}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
{/* §2/§3: when a style is locked, lead with its OTHER progressions
|
||||
reframed as variations to try — same-style only (finding-A). */}
|
||||
{activeStyle != null && (
|
||||
<p className="mb-2 text-xs font-medium text-gray-300">
|
||||
Try these in {activeStyleLabel}
|
||||
</p>
|
||||
)}
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
{primary.map(entry => (
|
||||
<li key={`${entry.style}-${entry.id}`} className="min-w-0">
|
||||
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
|
||||
<span className="text-sm font-semibold text-gray-100">{entry.name}</span>
|
||||
{/* Same-style rows share the header's style — hide the redundant
|
||||
label; cross-style rows keep it. */}
|
||||
{activeStyle == null && (
|
||||
<span className="text-xs text-gray-500">{entry.styleLabel}</span>
|
||||
)}
|
||||
<LevelBadge level={entry.level} />
|
||||
{/* Same-style rows show the role phrase (omitted when null,
|
||||
finding-B); cross-style rows keep the §5 annotation. */}
|
||||
{activeStyle != null
|
||||
? entry.role && (
|
||||
<span className="text-[10px] text-gray-500">{entry.role}</span>
|
||||
)
|
||||
: <span className="text-[10px] text-gray-500">{entry.annotation}</span>}
|
||||
</div>
|
||||
<ChordChain
|
||||
progression={entry.progression}
|
||||
keyRoot={keyRoot}
|
||||
onChordClick={onChordClick}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user