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:
@@ -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 Am–C–F 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
// Am–C–F 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 (Am–C–F 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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user