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:
vadimwit
2026-07-13 11:37:37 +01:00
parent a95ad147f7
commit a311895d50
2 changed files with 217 additions and 37 deletions
+59 -5
View File
@@ -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): iiVI in C → match jazz-251-major (excluded); jazz-251-minor top at exactly 156, "same changes"', () => {
check('pin (b): iiVI 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.
// A7E7 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 (A7E7)`)
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 A7E7')
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
+158 -32
View File
@@ -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 24).
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`
}
// 24 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&rsquo;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>
)