feat(theory): suggestSubstitutions engine for the Try-this feature (task L-73)

Additive suggestSubstitutions({rootPc,quality}, keyInfo, opts) returns
up to 4 honest, correctly-spelled chord alternatives in the detected
key: relative/diatonic-third, borrowed-minor iv (flat-spelled b6),
diatonic extension colour, and the secondary dominant of the next
chord — each with a plain teaching why. A sabotage-proven smoke
truth-table pins the tables (Am-C-F both readings, the Rule-D blues
moves). Critic returned once (Rule C self-suggested the sounding chord
on 7th inputs: Dm7->Dm7, plus a latent add9-on-minor mis-spelling),
fixed (skip self-quality extension; omit category C when nothing new
to add), PASS on scoped re-gate. Smoke 903/903; theory.js purely
additive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-07-13 12:04:34 +01:00
parent 0b75ebdce1
commit 3469bafa0d
2 changed files with 256 additions and 0 deletions
+86
View File
@@ -1486,6 +1486,92 @@ check('no-match fallback: unmatched loop → activeStyle null, secondary empty,
'no-match: cross-style rows must keep their §5 annotation') 'no-match: cross-style rows must keep their §5 annotation')
}) })
// ─── 8b. "Try this" substitution engine truth-table (task L-73) ───────────────
//
// Pins docs/design/try-this-subs.md §3 (the AmCF worked tables) against the
// REAL theory.suggestSubstitutions. Asserts the FULL ordered candidate list
// (label + category), not just counts — so flipping any rule constant (e.g. the
// relative offset 9→8, the borrowed-iv +5 gate, the 2nd-dom +7) turns this red
// and names it. Categories order: relative · borrowed · extension · 2nd-dom, cap 4.
console.log('\n"Try this" substitution engine (§3 truth-tables):')
const { suggestSubstitutions } = theory
// Compact "Label[category]" projection — the shape the pins compare.
const subShape = (arr) => arr.map((s) => `${s.label}[${s.category}]`)
const F_MAJ = { rootPc: 5, quality: 'maj' } // the F under the playhead
const AM_MIN = { rootPc: 9, quality: 'min' }
const C_MAJ = { rootPc: 0, quality: 'maj' }
check('F/maj in A MINOR (next Am) → [Dm(rel), Fmaj7(ext), E7(2nd-dom)] — borrowed iv SUPPRESSED', () => {
const got = subShape(suggestSubstitutions(F_MAJ, { root: 'A', mode: 'minor' }, { nextRootPc: 9 }))
const want = ['Dm[relative]', 'Fmaj7[extension]', 'E7[secondary_dominant]']
assert(JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`)
assert(!got.some((s) => s.startsWith('Fm[')), 'Fm must NOT appear under a minor reading (F is ♭VI, not IV)')
})
check('F/maj in C MAJOR (next Am) → [Dm, Fm(borrowed), Fmaj7, E7] — cap 4', () => {
const got = subShape(suggestSubstitutions(F_MAJ, { root: 'C', mode: 'major' }, { nextRootPc: 9 }))
const want = ['Dm[relative]', 'Fm[borrowed]', 'Fmaj7[extension]', 'E7[secondary_dominant]']
assert(JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`)
})
check('Rule D: Am/min → next C(0) yields G7 (V7 of C)', () => {
const got = subShape(suggestSubstitutions(AM_MIN, { root: 'C', mode: 'major' }, { nextRootPc: 0 }))
const want = ['C[relative]', 'Am7[extension]', 'G7[secondary_dominant]']
assert(JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`)
})
check('Rule D: C/maj → next F(5) yields C7 (V7 of F — the bluesy I7→IV, same-root but different chord)', () => {
const got = subShape(suggestSubstitutions(C_MAJ, { root: 'C', mode: 'major' }, { nextRootPc: 5 }))
const want = ['Am[relative]', 'Cmaj7[extension]', 'C7[secondary_dominant]']
assert(JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`)
})
check('borrowed-iv why spells the ♭6 FLAT (A→Ab), never the sharp G#', () => {
const subs = suggestSubstitutions(F_MAJ, { root: 'C', mode: 'major' }, { nextRootPc: 9 })
const fm = subs.find((s) => s.category === 'borrowed')
assert(fm && /A→Ab/.test(fm.why), `borrowed why must contain the flat A→Ab, got: ${fm && fm.why}`)
assert(fm && !/G#/.test(fm.why), 'borrowed why must not spell the ♭6 as the sharp G#')
})
check('extension why names the added tone by scale degree ("E is C\'s 3rd (the mediant)")', () => {
const subs = suggestSubstitutions(F_MAJ, { root: 'C', mode: 'major' }, { nextRootPc: 9 })
const ext = subs.find((s) => s.category === 'extension')
assert(ext && /3rd \(the mediant\)/.test(ext.why), `extension why: ${ext && ext.why}`)
})
check('Rule C never re-emits the input chord: Dm7 in C major → [F(rel)] (min7 extension self-skipped, NO add9-on-minor)', () => {
const chord = { rootPc: 2, quality: 'min7' }
const subs = suggestSubstitutions(chord, { root: 'C', mode: 'major' }, {})
assert(JSON.stringify(subShape(subs)) === JSON.stringify(['F[relative]']), `got ${JSON.stringify(subShape(subs))}`)
assert(!subs.some((s) => s.category === 'extension'), 'extension must be OMITTED when the only fit is the chord already sounding')
assert(!subs.some((s) => s.rootPc === chord.rootPc && s.quality === chord.quality),
'no suggestion may equal the input chord {rootPc,quality}')
assert(!subs.some((s) => s.quality === 'add9'), 'a MAJOR add9 must never be suggested for a minor-family chord (would raise the 3rd)')
})
check('Rule C on a 7th input adds a DIFFERENT colour: Cmaj7 in C major → [Am(rel), Cadd9(ext)] (not Cmaj7)', () => {
const chord = { rootPc: 0, quality: 'maj7' }
const subs = suggestSubstitutions(chord, { root: 'C', mode: 'major' }, {})
assert(JSON.stringify(subShape(subs)) === JSON.stringify(['Am[relative]', 'Cadd9[extension]']), `got ${JSON.stringify(subShape(subs))}`)
const ext = subs.find((s) => s.category === 'extension')
assert(ext && !(ext.rootPc === chord.rootPc && ext.quality === chord.quality),
'the extension must not be the input chord itself')
})
check('no key (!keyInfo.root) → [] (idle, never a fabricated suggestion)', () => {
assert(JSON.stringify(suggestSubstitutions(F_MAJ, {}, {})) === '[]', 'expected [] with empty keyInfo')
assert(JSON.stringify(suggestSubstitutions(F_MAJ, null, {})) === '[]', 'expected [] with null keyInfo')
})
check('Rule D omitted when no next chord is known (opts.nextRootPc absent)', () => {
const got = subShape(suggestSubstitutions(F_MAJ, { root: 'C', mode: 'major' }, {}))
assert(!got.some((s) => s.endsWith('[secondary_dominant]')), `2nd-dom must be absent, got ${JSON.stringify(got)}`)
})
// ─── 9. Summary + exit code ─────────────────────────────────────────────────── // ─── 9. Summary + exit code ───────────────────────────────────────────────────
const total = passed + failures.length const total = passed + failures.length
+170
View File
@@ -746,3 +746,173 @@ export function transposeChord(chordName, semitones) {
export function transposeProgression(chords, semitones) { export function transposeProgression(chords, semitones) {
return chords.map(c => transposeChord(c, semitones)) return chords.map(c => transposeChord(c, semitones))
} }
// ─── "Try this" — key-aware substitution nudge (task L-73) ────────────────────
//
// Curated, learnable chord-substitution engine for the Jam Guide dashboard.
// Spec: docs/design/try-this-subs.md §2 (the 4 category rules) + §3 (the worked
// AmCF truth-tables). For the chord under the playhead, in the detected key,
// returns up to 4 alternatives — each with one plain sentence that teaches WHY
// it works. This is NOT the context-free colour-swap grid in education.js; this
// one is key-aware and changes the root (relative / secondary dominant).
//
// All-in-module: reuses NOTES, NOTES_FLAT, noteName, noteIndex, CHORD_TYPES,
// getChordsInKey, getScale, intervalName, toRomanNumeral, chordTonePcs. It does
// NOT import match.js's chordRootPC (that would be circular) — it uses theory's
// own noteIndex(keyInfo.root) for the key root pc.
// Chord-quality families the rules branch on.
const SUB_MAJOR_FAMILY = ['maj', 'maj7', 'maj6', 'add9']
const SUB_MINOR_FAMILY = ['min', 'min7', 'min6']
// Key modes with a major tonic ("major-ish") — the only readings under which the
// borrowed-iv (Rule B) is honest. A minor/dorian/phrygian reading suppresses it.
const SUB_MAJORISH_MODES = ['major', 'lydian', 'mixolydian']
// Scale-degree vocabulary for the extension why-copy ("E is C's 3rd (the mediant)").
const DEGREE_ORDINALS = ['root', '2nd', '3rd', '4th', '5th', '6th', '7th']
const DEGREE_NAMES = ['tonic', 'supertonic', 'mediant', 'subdominant', 'dominant', 'submediant', 'leading tone']
// Name a pitch class by its scale degree in the key, e.g. "3rd (the mediant)".
// Returns null if the pc is not diatonic (Rule C never calls it off-scale).
function subDegreeWord(pc, keyRootPc, mode) {
const scale = SCALES[mode] ?? SCALES.major
const semi = ((((pc - keyRootPc) % 12) + 12) % 12)
const idx = scale.indexOf(semi)
if (idx < 0) return null
return `${DEGREE_ORDINALS[idx]} (the ${DEGREE_NAMES[idx]})`
}
/**
* suggestSubstitutions({ rootPc, quality }, keyInfo, opts = {})
* → [{ rootPc, quality, label, why, category }]
*
* Categories, always in this order (softest → boldest), capped at 4:
* relative · borrowed · extension · secondary_dominant
* Returns [] when there is no key (`!keyInfo?.root`) — the UI shows an idle line.
*
* `label = NOTES[rootPc] + CHORD_TYPES[quality].suffix`. `opts.nextRootPc` (the
* next loop station's root pc) gates Rule D. See docs/design/try-this-subs.md §2.
*
* Sanity (AmCF loop, §3):
* F/maj (5) in A minor, next Am (9) → [Dm(rel), Fmaj7(ext), E7(2nd-dom)] (no Fm)
* F/maj (5) in C major, next Am (9) → [Dm, Fm(borrowed), Fmaj7, E7] (cap 4)
* Am/min(9) → next C (0): Rule D = G7 ; C/maj(0) → next F (5): Rule D = C7
*/
export function suggestSubstitutions({ rootPc, quality } = {}, keyInfo, opts = {}) {
if (!keyInfo?.root) return []
const keyRootPc = noteIndex(keyInfo.root)
if (keyRootPc < 0 || rootPc == null || quality == null) return []
const mode = keyInfo.mode ?? 'major'
const r = ((rootPc % 12) + 12) % 12
const origLabel = NOTES[r] + (CHORD_TYPES[quality]?.suffix ?? '')
const isMajorFam = SUB_MAJOR_FAMILY.includes(quality)
const isMinorFam = SUB_MINOR_FAMILY.includes(quality)
// Diatonic chord-name set + scale pitch-class set for the gates.
const diatonic = new Set(getChordsInKey(keyInfo.root, mode))
const scaleInts = SCALES[mode] ?? SCALES.major
const scalePcs = new Set(scaleInts.map(i => (keyRootPc + i) % 12))
const mk = (candRootPc, candQuality, why, category) => {
const pc = ((candRootPc % 12) + 12) % 12
return {
rootPc: pc,
quality: candQuality,
label: NOTES[pc] + (CHORD_TYPES[candQuality]?.suffix ?? ''),
why,
category,
}
}
const out = []
// ── Rule A — relative / diatonic-third sub (softest). Circle: inner ring. ──
// major-family → relative minor (root+9); minor-family → relative major (root+3).
// Emit only if the candidate is diatonic in the key.
{
let candRootPc = null, candQuality = null
if (isMajorFam) { candRootPc = (r + 9) % 12; candQuality = 'min' }
else if (isMinorFam) { candRootPc = (r + 3) % 12; candQuality = 'maj' }
if (candRootPc !== null) {
const label = NOTES[candRootPc] + (CHORD_TYPES[candQuality].suffix)
if (diatonic.has(label)) {
// Shared tones = intersection of the two triads (root & 3rd of the original).
const origTones = new Set(chordTonePcs(r, quality))
const shared = chordTonePcs(candRootPc, candQuality).filter(t => origTones.has(t))
const sharedNames = shared.map(t => noteName(t)).join(' & ')
const relWord = isMajorFam ? 'minor' : 'major'
const pull = isMajorFam ? 'softer' : 'brighter'
const rn = toRomanNumeral(label, keyInfo.root, mode)
out.push(mk(candRootPc, candQuality,
`${label} is ${origLabel}'s relative ${relWord} — shares ${sharedNames}. `
+ `In this key it's the ${rn}: a ${pull} pull, same family. `
+ `(Circle: its inner-ring relative.)`,
'relative'))
}
}
}
// ── Rule B — borrowed iv (the "Creep" move), conditional. NOT a circle step. ──
// Only under a major-ish reading, on the IV (root === keyRoot+5), major-family.
if (isMajorFam && SUB_MAJORISH_MODES.includes(mode) && r === (keyRootPc + 5) % 12) {
const n6 = noteName(keyRootPc + 9) // natural 6th of the key
const nb6 = noteName(keyRootPc + 8, true) // ♭6 — spelled FLAT (A→A♭, never G#)
out.push(mk(r, 'min',
`Borrow ${NOTES[r]}m (the iv) from the parallel minor — ${n6}${nb6} adds that `
+ `wistful pull home. The 'Creep' move.`,
'borrowed'))
}
// ── Rule C — extension / colour (same function, one diatonic colour tone). ──
// Vertical colour, NOT a circle step. Pick the first extension whose added tone
// is diatonic; omit the category if none qualifies.
//
// Two guards keep the suggestion an honest ALTERNATIVE, not the chord already
// sounding (the common case — jazz stations are min7/maj7/dom7):
// (a) skip any candidate whose quality === the input quality — the chord
// already carries that colour (a min7 input never re-emits min7).
// (b) minor-family offers ONLY min7. CHORD_TYPES.add9 = [0,2,4,7] is a MAJOR
// add9 (interval 4 = major 3rd), so applying it to a minor chord would
// raise the 3rd (Dm → D, F♮→F♯) = a wrong-note suggestion. Never do it.
{
let opts2 = null
if (isMajorFam) opts2 = [['maj7', 11], ['add9', 2], ['maj6', 9]]
else if (isMinorFam) opts2 = [['min7', 10]]
else if (quality === 'dom7') opts2 = [['sus4', 5]]
if (opts2) {
const pick = opts2.find(([q, interval]) =>
q !== quality && scalePcs.has((r + interval) % 12))
if (pick) {
const [extQuality, interval] = pick
const addedPc = (r + interval) % 12
const addedNote = noteName(addedPc)
const rn = toRomanNumeral(origLabel, keyInfo.root, mode)
const dw = subDegreeWord(addedPc, keyRootPc, mode)
out.push(mk(r, extQuality,
`Add the ${intervalName(interval).toLowerCase()} (${addedNote}) — same ${rn}, lusher. `
+ `${addedNote} is ${keyInfo.root}'s own ${dw}, so it stays in the family.`,
'extension'))
}
}
}
// ── Rule D — secondary dominant of the next chord (boldest). Circle move. ──
// V7 of the next loop chord = (nextRootPc+7) dom7. Requires a known next chord;
// omit when the candidate is the identical chord already sounding.
if (opts.nextRootPc != null) {
const nextPc = ((opts.nextRootPc % 12) + 12) % 12
const candRootPc = (nextPc + 7) % 12
const isSameChord = candRootPc === r && quality === 'dom7'
if (!isSameChord) {
const leadingTone = noteName(candRootPc + 4) // dom7's 3rd = ascending leading tone (SHARP)
const nextName = noteName(nextPc)
out.push(mk(candRootPc, 'dom7',
`Swap for ${NOTES[candRootPc]}7, the V7 of ${nextName} — its 3rd (${leadingTone}) leans `
+ `a half-step into ${nextName}, pulling the loop around. One step clockwise on the circle.`,
'secondary_dominant'))
}
}
return out.slice(0, 4)
}