Files
vadimwit 3469bafa0d 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>
2026-07-13 12:04:34 +01:00

1587 lines
83 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Smoke-test harness — asserts the KB registry and the loop matcher are wired
// correctly before a merge. Imports the REAL src/data/kb/index.js and
// src/lib/match.js (no mocks), runs structural + behavioural checks, and exits
// non-zero on any failure so CI can gate on it.
//
// Run: node scripts/smoke.mjs (exit 1 on any failure, 0 on all-pass)
//
// Style mirrors scripts/validate-kb.mjs: plain Node ESM, node: imports, a flat
// list of checks with ✓/✗ per check, a summary line, and process.exit().
import { register } from 'node:module'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { dirname, join } from 'node:path'
import { existsSync } from 'node:fs'
// match.js imports './theory' extensionless (resolved by Vite at build time, but
// raw Node ESM requires the extension). Register a tiny resolve hook that retries
// a failed extensionless relative specifier with '.js' appended, so we can import
// the REAL match.js unmodified (it's locked to task L-01 — we must not touch it).
register(
'data:text/javascript,' +
encodeURIComponent(`
export async function resolve(specifier, context, next) {
try {
return await next(specifier, context)
} catch (e) {
if (/^\\.{1,2}\\//.test(specifier) && !/\\.[mc]?js$/.test(specifier)) {
return await next(specifier + '.js', context)
}
throw e
}
}
`),
import.meta.url,
)
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const load = async (rel) => (await import(pathToFileURL(join(ROOT, rel)).href))
// ─── Tiny assertion harness ───────────────────────────────────────────────────
let passed = 0
const failures = []
function check(label, fn) {
try {
fn()
passed++
console.log(` ✓ ${label}`)
} catch (e) {
failures.push(`${label}: ${e.message}`)
console.log(` ✗ ${label}${e.message}`)
}
}
function warn(label, msg) {
console.log(` ⚠ ${label}${msg} (skipped)`)
}
function assert(cond, msg) {
if (!cond) throw new Error(msg)
}
// ─── Load the real modules ────────────────────────────────────────────────────
const kb = (await load('src/data/kb/index.js')).default
const match = await load('src/lib/match.js')
const { buildLoopIndex, matchLoopToProgression, findLoopPosition } = match
const theory = await load('src/lib/theory.js')
const { CHORD_TYPES, detectRepeatingProgression } = theory
const piano = await load('src/lib/piano.js')
const { pianoVoicing, pianoVoicingChain, voicingToneSet, hasTrueSeventh } = piano
// ─── 1. Registry integrity ────────────────────────────────────────────────────
console.log('\nRegistry integrity:')
check('KB default export is a non-empty object with ≥1 style', () => {
assert(kb && typeof kb === 'object', 'kb default export is not an object')
assert(Object.keys(kb).length >= 1, 'kb has no styles')
})
const styleNames = kb && typeof kb === 'object' ? Object.keys(kb) : []
const allIds = new Map() // id → style (for uniqueness across the whole KB)
for (const styleName of styleNames) {
const style = kb[styleName]
check(`style '${styleName}' has meta, progressions[], instruments.guitar`, () => {
assert(style && typeof style === 'object', 'style entry is not an object')
assert(style.meta && typeof style.meta === 'object', 'missing meta')
assert(Array.isArray(style.progressions) && style.progressions.length > 0, 'progressions must be a non-empty array')
assert(style.instruments && typeof style.instruments === 'object', 'missing instruments')
assert(style.instruments.guitar && typeof style.instruments.guitar === 'object', 'missing instruments.guitar')
})
const progs = Array.isArray(style?.progressions) ? style.progressions : []
for (const p of progs) {
check(`'${styleName}' progression '${p?.id ?? '?'}' has consistent degrees/qualities/rn/bars + unique id`, () => {
assert(typeof p.id === 'string' && p.id.length > 0, 'progression id missing')
assert(!allIds.has(p.id), `duplicate id '${p.id}' (also in style '${allIds.get(p.id)}')`)
allIds.set(p.id, styleName)
const n = Array.isArray(p.degrees) ? p.degrees.length : 0
assert(n > 0, 'degrees missing/empty')
for (const [field, arr] of [['qualities', p.qualities], ['rn', p.rn], ['bars', p.bars]]) {
assert(Array.isArray(arr), `${field} is not an array`)
assert(arr.length === n, `${field} length ${arr.length} ≠ degrees length ${n}`)
}
})
}
}
// ─── 2. Matcher correctness on sample loops ───────────────────────────────────
console.log('\nMatcher correctness:')
const index = buildLoopIndex(kb)
check('buildLoopIndex returns a { byCanonical: Map } shape', () => {
assert(index && index.byCanonical instanceof Map, 'byCanonical is not a Map')
assert(index.byCanonical.size > 0, 'index is empty')
})
// Helper: rotate a degree array by r so it starts at index r.
const rotate = (arr, r) => arr.map((_, i) => arr[(r + i) % arr.length])
// Helper: re-base a degree array so its first element is 0 (the loop "shape").
const rebase = (arr) => {
const base = arr[0]
return arr.map((d) => (((d - base) % 12) + 12) % 12)
}
// --- iiVI → some jazz 2-5-1, matched:true; derive the expected id from KB ---
// (don't hardcode 'jazz-251-major' — look up what the loop actually maps to and
// assert its structural truth: the matched progression's degrees rotate to the
// loop's degree shape.)
{
const loop = ['Dm7', 'G7', 'Cmaj7'] // iiVI in C → degree shape [0,5,10]
const r = matchLoopToProgression(loop, index)
check('iiVI [Dm7,G7,Cmaj7] matches a progression (matched:true)', () => {
assert(r.matched === true, `expected matched:true, got ${JSON.stringify(r)}`)
assert(typeof r.id === 'string' && r.id.length > 0, 'matched but no id')
assert(kb[r.style], `matched style '${r.style}' not in KB`)
})
check("iiVI maps to a 2-5-1-shaped progression (derived from KB)", () => {
// Structural: the matched progression's degrees, rotated by the reported
// rotation to align with the loop, must equal the loop's own degree shape.
const loopShape = rebase([2, 7, 0]) // Dm7 G7 Cmaj7 pcs → re-based shape [0,5,10]
const kbDeg = r.progression.degrees
const rotatedKbShape = rebase(rotate(kbDeg, r.rotation))
assert(rotatedKbShape.join(',') === loopShape.join(','),
`KB degrees ${kbDeg} rotated by ${r.rotation}${rotatedKbShape} ≠ loop shape ${loopShape}`)
// Soft: this loop is a major 2-5-1, expect the major-quality (maj7) candidate.
assert(/251/.test(r.id) || r.progression.qualities.includes('maj7'),
`expected a 2-5-1-shaped id, got '${r.id}'`)
})
// Remember the id for the rotation test below (derived, not hardcoded).
globalThis.__ii_v_i_id = r.id
}
// --- A rotation of iiVI matches the SAME id with non-zero rotation ----------
{
const loop = ['G7', 'Cmaj7', 'Dm7'] // VIii: same loop, rotated by 1
const r = matchLoopToProgression(loop, index)
check('rotation [G7,Cmaj7,Dm7] matches the SAME id as iiVI', () => {
assert(r.matched === true, `expected matched:true, got ${JSON.stringify(r)}`)
assert(r.id === globalThis.__ii_v_i_id,
`rotation matched '${r.id}', expected same id '${globalThis.__ii_v_i_id}'`)
})
check('rotation reports a non-zero rotation index', () => {
assert(r.rotation !== 0, `expected non-zero rotation, got ${r.rotation}`)
assert(r.rotation > 0 && r.rotation < loop.length, `rotation ${r.rotation} out of range`)
// The rotation index should point at the loop slot aligning with KB degrees[0].
// KB iiVI starts on ii (Dm7); Dm7 is at loop index 2 here.
assert(loop[r.rotation] === 'Dm7', `loop[${r.rotation}] is '${loop[r.rotation]}', expected 'Dm7'`)
})
}
// --- IVviIV axis loop → axis progression (pop may be absent) ----------------
{
const loop = ['C', 'G', 'Am', 'F'] // IVviIV, degree shape [0,7,9,5]
const r = matchLoopToProgression(loop, index)
const loopShape = rebase([0, 7, 9, 5])
if (r.matched) {
check('axis [C,G,Am,F] matches an axis-shaped progression', () => {
assert(kb[r.style], `matched style '${r.style}' not in KB`)
// Whatever style owns the axis, its degrees must rotate to the loop shape.
const rotatedKbShape = rebase(rotate(r.progression.degrees, r.rotation))
assert(rotatedKbShape.join(',') === loopShape.join(','),
`matched '${r.id}' degrees ${r.progression.degrees} rotated by ${r.rotation} don't match axis shape ${loopShape}`)
// The axis is the four-chord IVviIV; sanity-check it's a 4-chord loop.
assert(r.progression.degrees.length === 4, `expected a 4-chord axis, got length ${r.progression.degrees.length}`)
})
} else {
warn('axis [C,G,Am,F] match', 'no axis-shaped 4-chord progression in current KB')
}
}
// --- Chromatic / garbage loop → matched:false, no throw -----------------------
{
check('chromatic loop [C,C#,D] returns matched:false cleanly (no throw)', () => {
const r = matchLoopToProgression(['C', 'C#', 'D'], index)
assert(r && r.matched === false, `expected matched:false, got ${JSON.stringify(r)}`)
assert(r.id === null, `expected id:null on no-match, got ${r.id}`)
})
}
// --- findLoopPosition: in-range index for a known loop, -1 for no-match --------
{
const loop = ['Dm7', 'G7', 'Cmaj7']
check('findLoopPosition returns an in-range index for a known loop', () => {
const history = ['Dm7', 'G7'] // player is on G7
const pos = findLoopPosition(history, loop)
assert(Number.isInteger(pos), `expected an integer, got ${pos}`)
assert(pos >= 0 && pos < loop.length, `index ${pos} out of range [0,${loop.length})`)
assert(loop[pos] === 'G7', `loop[${pos}] is '${loop[pos]}', expected 'G7'`)
})
check('findLoopPosition returns -1 when the last chord is not in the loop', () => {
const pos = findLoopPosition(['Ebmaj7'], loop)
assert(pos === -1, `expected -1, got ${pos}`)
})
check('findLoopPosition returns -1 on empty inputs', () => {
assert(findLoopPosition([], loop) === -1, 'empty history should be -1')
assert(findLoopPosition(['Dm7'], []) === -1, 'empty loop should be -1')
})
}
// ─── 3. Piano voicing resolver (C-10) ─────────────────────────────────────────
//
// Exercise src/lib/piano.js across every CHORD_TYPES quality (all 14) at a
// couple of roots, for each forced style + the default. Expected facts are
// derived from the resolver/theory (voicingToneSet / hasTrueSeventh / the chord
// intervals), never hardcoded as brittle note arrays.
console.log('\nPiano resolver:')
const QUALITIES = Object.keys(CHORD_TYPES) // all 14
const PIANO_ROOTS = [0, 7] // C and G
const PIANO_STYLES = ['root', 'shell', 'rootlessA', 'rootlessB', undefined] // undefined = default
const mod12 = (n) => (((n % 12) + 12) % 12)
const styleName = (s) => s ?? 'default'
check(`CHORD_TYPES exposes all 14 qualities for the piano sweep`, () => {
assert(QUALITIES.length === 14, `expected 14 CHORD_TYPES, got ${QUALITIES.length}`)
})
// --- 3a. Per (quality × root × style): valid shape, no wrong notes, 3rd & 7th --
for (const quality of QUALITIES) {
const ints = CHORD_TYPES[quality].intervals
const hasReal3rd = ints.some((i) => i === 3 || i === 4)
// The "real 3rd" target pc: minor/major 3rd if present, else the suspension
// tone (sus2 → 2, sus4 → 5) that stands in the 3rd slot.
const susTone = ints[1] // sus2 → 2, sus4 → 5 (index-1 stand-in for the 3rd)
const trueSeventh = hasTrueSeventh(quality) // interval 10 or 11 present
for (const rootPc of PIANO_ROOTS) {
// expected 3rd-slot pc (absolute pc)
const third3 = mod12(rootPc + 3)
const third4 = mod12(rootPc + 4)
const susPc = mod12(rootPc + susTone)
const seventhInt = ints.find((i) => i === 10 || i === 11)
const seventhPc = seventhInt === undefined ? null : mod12(rootPc + seventhInt)
for (const style of PIANO_STYLES) {
const chord = { rootPc, quality }
const opts = style === undefined ? {} : { style }
const tag = `${quality}@${rootPc} [${styleName(style)}]`
check(`${tag}: valid voicing shape`, () => {
const v = pianoVoicing(chord, opts)
assert(v && typeof v === 'object', 'no voicing object')
assert(Array.isArray(v.notes) && v.notes.length > 0, 'notes must be a non-empty array')
assert(v.notes.every((n) => Number.isFinite(n)), 'notes must all be numbers')
assert(Array.isArray(v.pcs) && v.pcs.length > 0, 'pcs must be a non-empty array')
assert(typeof v.bass === 'number', 'bass must be a number')
assert(typeof v.style === 'string' && v.style.length > 0, 'style must be a non-empty string')
assert(typeof v.label === 'string' && v.label.length > 0, 'label must be a non-empty string')
})
check(`${tag}: no wrong notes (pcs ⊆ voicingToneSet)`, () => {
const v = pianoVoicing(chord, opts)
// Scope the legal-tone set to the voicing's ACTUAL produced style.
const legal = voicingToneSet({ rootPc, quality }, v.style)
for (const pc of v.pcs) {
assert(legal.has(pc), `pc ${pc} not in voicingToneSet(${quality}, ${v.style}) {${[...legal].sort((a, b) => a - b)}}`)
}
})
check(`${tag}: real 3rd (or suspension) present`, () => {
const v = pianoVoicing(chord, opts)
if (hasReal3rd) {
assert(v.pcs.includes(third3) || v.pcs.includes(third4),
`expected a 3rd (pc ${third3} or ${third4}) in pcs {${v.pcs}}`)
} else {
// sus2/sus4 — the 2 or 4 stands in the 3rd slot.
assert(v.pcs.includes(susPc),
`expected suspension tone pc ${susPc} in pcs {${v.pcs}}`)
}
})
check(`${tag}: true 7th present in shell/rootless`, () => {
const v = pianoVoicing(chord, opts)
const isSeventhVoicing = v.style === 'shell' || v.style === 'rootlessA' || v.style === 'rootlessB'
if (trueSeventh && isSeventhVoicing) {
assert(v.pcs.includes(seventhPc),
`${v.style} of a true-7th chord must include the 7th pc ${seventhPc}, got {${v.pcs}}`)
}
})
check(`${tag}: notes within [0,36] and bass == min(notes)`, () => {
const v = pianoVoicing(chord, opts)
assert(v.notes.every((n) => n >= 0 && n <= 36),
`notes ${JSON.stringify(v.notes)} out of [0,36]`)
assert(v.bass === Math.min(...v.notes),
`bass ${v.bass} ≠ min(notes) ${Math.min(...v.notes)}`)
})
}
}
}
// --- 3b. No duplicate absolute note within a single voicing (sus2 nit watch) ---
// Sweep every quality × style (forced + default). A duplicate absolute note in
// one voicing's `notes` is a defect surfaced as a ✗ (L-10 gate flagged a
// forced-rootless sus2 collision). If the resolver was since deduped, this
// passes. We report WHICH quality/style collides so it's visible.
{
const dupes = []
for (const quality of QUALITIES) {
for (const rootPc of PIANO_ROOTS) {
for (const style of PIANO_STYLES) {
const opts = style === undefined ? {} : { style }
const v = pianoVoicing({ rootPc, quality }, opts)
if (v.notes.length !== new Set(v.notes).size) {
dupes.push(`${quality}@${rootPc} [${styleName(style)}] notes=${JSON.stringify(v.notes)}`)
}
}
}
}
check('no duplicate absolute note within any single voicing (all qualities × styles)', () => {
assert(dupes.length === 0,
`duplicate-note voicing(s) found: ${dupes.join('; ')}`)
})
}
// --- 3c. Determinism: same input → identical notes -----------------------------
{
check('pianoVoicing is deterministic (same input → identical notes)', () => {
for (const quality of QUALITIES) {
for (const rootPc of PIANO_ROOTS) {
for (const style of PIANO_STYLES) {
const opts = style === undefined ? {} : { style }
const a = pianoVoicing({ rootPc, quality }, opts)
const b = pianoVoicing({ rootPc, quality }, opts)
assert(a.notes.join(',') === b.notes.join(','),
`non-deterministic notes for ${quality}@${rootPc} [${styleName(style)}]: ${a.notes} vs ${b.notes}`)
}
}
}
})
}
// --- 3d. Voice-leading sanity: chain pcs == per-chord unthreaded pcs ------------
// Threading re-registers (slides whole shapes by octaves) but never changes the
// pitch CONTENT — each chained voicing's pcs must equal the same chord voiced
// alone (default style), as a set.
{
const chords = [
{ rootPc: 2, quality: 'min7' }, // Dm7
{ rootPc: 7, quality: 'dom7' }, // G7
{ rootPc: 0, quality: 'maj7' }, // Cmaj7
]
const chain = pianoVoicingChain(chords)
const pcSet = (arr) => [...new Set(arr)].sort((a, b) => a - b).join(',')
check('pianoVoicingChain(iiVI) returns 3 voicings', () => {
assert(Array.isArray(chain) && chain.length === 3,
`expected 3 voicings, got ${Array.isArray(chain) ? chain.length : typeof chain}`)
})
check('chain pcs match the per-chord unthreaded pcs (threading never changes pitch content)', () => {
chords.forEach((chord, i) => {
const solo = pianoVoicing(chord) // same default style, no prev
assert(pcSet(chain[i].pcs) === pcSet(solo.pcs),
`chord ${i} (${chord.quality}@${chord.rootPc}): chain pcs {${pcSet(chain[i].pcs)}} ≠ unthreaded {${pcSet(solo.pcs)}}`)
})
})
}
// ─── 4. Lick + level schema validation (C-20) ─────────────────────────────────
//
// Import the REAL validator in lib mode (KB_VALIDATE_AS_LIB skips the full-KB
// run) and exercise its exported checkLick/LEVELS against in-memory fixtures:
// a good lick must pass, and bad-vocab / bad-string-range / duplicate-id licks
// must FAIL — proving the validator's lick rules actually bite.
console.log('\nLick + level schema (validate-kb lib mode):')
process.env.KB_VALIDATE_AS_LIB = '1'
const kbv = await load('scripts/validate-kb.mjs')
const { checkLick, LEVELS, LICK_TECHNIQUES } = kbv
check('validate-kb exports checkLick / LEVELS / LICK_TECHNIQUES in lib mode', () => {
assert(typeof checkLick === 'function', 'checkLick is not a function')
assert(Array.isArray(LEVELS) && LEVELS.join(',') === 'foundation,intermediate',
`LEVELS must be exactly [foundation, intermediate], got ${JSON.stringify(LEVELS)}`)
assert(Array.isArray(LICK_TECHNIQUES) && LICK_TECHNIQUES.length === 8,
`expected the 8-word technique vocab, got ${JSON.stringify(LICK_TECHNIQUES)}`)
for (const t of ['hammer-on', 'pull-off', 'slide', 'bend', 'double-stop', 'ghost-note', 'chromatic-approach', 'vibrato'])
assert(LICK_TECHNIQUES.includes(t), `vocab missing '${t}'`)
})
// A realistic, fully-valid fixture (style-prefixed id, vocab techniques,
// strings 16, frets 015, per-note techniques present in the summary).
const goodLick = () => ({
id: 'blues-box1-roll',
name: 'B.B. box roll',
level: 'foundation',
chordContext: 'over the I7',
techniques: ['bend', 'vibrato'],
tab: [
{ string: 2, fret: 8 },
{ string: 1, fret: 8, technique: 'bend' },
{ string: 1, fret: 10, technique: 'vibrato' },
{ string: 2, fret: 8 },
],
})
check('good in-memory lick fixture PASSES checkLick (0 errors)', () => {
const errs = checkLick('fixture', goodLick(), 'blues', new Set())
assert(errs.length === 0, `expected clean pass, got: ${errs.join('; ')}`)
})
check('bad-vocab lick FAILS (technique outside the fixed vocabulary)', () => {
const lick = goodLick()
lick.techniques = ['bend', 'tapping'] // 'tapping' is not in the vocab
const errs = checkLick('fixture', lick, 'blues', new Set())
assert(errs.length > 0, 'bad vocab was accepted')
assert(errs.some((e) => e.includes("'tapping'")), `no error names 'tapping': ${errs.join('; ')}`)
})
check('bad per-note technique FAILS (vocab enforced on tab notes too)', () => {
const lick = goodLick()
lick.tab[1].technique = 'sweep-picking'
const errs = checkLick('fixture', lick, 'blues', new Set())
assert(errs.some((e) => e.includes("'sweep-picking'")), `per-note vocab not enforced: ${errs.join('; ')}`)
})
check('bad-string-range lick FAILS (string 7 / string 0 rejected)', () => {
for (const bad of [7, 0]) {
const lick = goodLick()
lick.tab[0].string = bad
const errs = checkLick('fixture', lick, 'blues', new Set())
assert(errs.some((e) => e.includes('string must be an integer 16')),
`string ${bad} was accepted: ${errs.join('; ')}`)
}
})
check('bad-fret lick FAILS (fret 16 / negative / non-integer rejected)', () => {
for (const bad of [16, -1, 3.5]) {
const lick = goodLick()
lick.tab[0].fret = bad
const errs = checkLick('fixture', lick, 'blues', new Set())
assert(errs.some((e) => e.includes('fret must be an integer')),
`fret ${bad} was accepted: ${errs.join('; ')}`)
}
})
check('duplicate-id lick FAILS (ids global across progressions AND licks)', () => {
const ids = new Set()
assert(checkLick('fixture', goodLick(), 'blues', ids).length === 0, 'first insert should pass')
const errs = checkLick('fixture', goodLick(), 'blues', ids) // same id again
assert(errs.some((e) => e.includes('duplicate id')), `duplicate id was accepted: ${errs.join('; ')}`)
// Colliding with an existing PROGRESSION id must also fail (shared namespace).
const progIds = new Set(['blues-box1-roll'])
const errs2 = checkLick('fixture', goodLick(), 'blues', progIds)
assert(errs2.some((e) => e.includes('duplicate id')), 'collision with a progression id was accepted')
})
check('wrong style prefix / bad level / empty tab all FAIL', () => {
const wrongPrefix = goodLick(); wrongPrefix.id = 'jazz-box1-roll'
assert(checkLick('fixture', wrongPrefix, 'blues', new Set()).some((e) => e.includes("starting with 'blues-'")),
'wrong style prefix accepted')
const badLevel = goodLick(); badLevel.level = 'advanced'
assert(checkLick('fixture', badLevel, 'blues', new Set()).some((e) => e.includes('level must be one of')),
"level 'advanced' accepted")
const emptyTab = goodLick(); emptyTab.tab = []
assert(checkLick('fixture', emptyTab, 'blues', new Set()).some((e) => e.includes('tab must be a non-empty')),
'empty tab accepted')
})
check('per-note technique missing from techniques[] summary FAILS (card tags stay honest)', () => {
const lick = goodLick()
lick.tab[2].technique = 'slide' // valid vocab, but not in techniques: [bend, vibrato]
const errs = checkLick('fixture', lick, 'blues', new Set())
assert(errs.some((e) => e.includes("must also appear in the lick's techniques[]")),
`summary consistency not enforced: ${errs.join('; ')}`)
})
// Progression `level` is optional in the KB — assert today's KB either omits it
// or uses a legal value (guards P-20's tagging against typos reaching main).
check("every KB progression 'level', when present, is foundation|intermediate", () => {
for (const styleName of styleNames) {
for (const p of kb[styleName]?.progressions ?? []) {
if (p.level !== undefined) {
assert(LEVELS.includes(p.level), `${styleName}/${p.id}: bad level '${p.level}'`)
}
}
}
})
// Same guard for any licks already shipped in the KB: run the REAL packs'
// licks (if any) through the instrument-routed checker — piano licks are
// degree-based (checkPianoLick, §5c below), everything else is tab-based
// checkLick — mirroring the validator's own routing.
check('every KB pack licks[] entry (if any) passes its instrument\'s lick check', () => {
const ids = new Set()
for (const styleName of styleNames) {
const instruments = kb[styleName]?.instruments ?? {}
for (const [inst, pack] of Object.entries(instruments)) {
if (pack?.licks === undefined) continue
assert(Array.isArray(pack.licks) && pack.licks.length,
`${styleName}/${inst}: licks, when present, must be a non-empty array`)
const checkInstLick = inst === 'piano' ? kbv.checkPianoLick : checkLick
for (const lick of pack.licks) {
const errs = checkInstLick(`${styleName}/${inst} ${lick?.id ?? '?'}`, lick, styleName, ids)
assert(errs.length === 0, errs.join('; '))
}
}
}
})
// ─── 5. Piano hand-span rule (C-22) ───────────────────────────────────────────
//
// SCHEMA.md rule 3: one hand per recipe stays within a 10th. The validator
// enforces span ≤ MAX_HAND_SPAN (15 semitones, a minor 10th) by stacking the
// recipe's degrees low→high (each note in the nearest position above the
// previous — the documented jazz/piano.js convention). Prove the rule bites on
// synthetic fixtures, then run every REAL piano pack recipe through the check.
console.log('\nPiano hand-span rule (validate-kb lib mode):')
const { checkPianoRecipe, MAX_HAND_SPAN } = kbv
check('validate-kb exports checkPianoRecipe / MAX_HAND_SPAN (= 15, a minor 10th)', () => {
assert(typeof checkPianoRecipe === 'function', 'checkPianoRecipe is not a function')
assert(MAX_HAND_SPAN === 15, `MAX_HAND_SPAN must be 15 (minor 10th), got ${MAX_HAND_SPAN}`)
})
// dom7 LH ['1','7','3'] stacks 0 → 10 → 16 (the 3rd must sit ABOVE the ♭7):
// span 16 = a major 10th — one semitone past the rule. Must FAIL, and the
// error must name the hand and the computed span.
check('synthetic 16-semitone hand (dom7 LH [1 7 3]) FAILS with the span error', () => {
const errs = checkPianoRecipe('fixture', { recipe: { LH: ['1', '7', '3'] } }, 'dom7')
assert(errs.length > 0, 'a 16-semitone hand was accepted')
assert(errs.some((e) => e.includes('LH') && e.includes('spans 16')),
`no error names LH + span 16: ${errs.join('; ')}`)
})
// dom7 LH ['1','7','#9'] stacks 0 → 10 → 15: span exactly 15 (the 7♯9 sound).
// The boundary is legal — SCHEMA's "within a 10th" includes the minor 10th.
check('synthetic 15-semitone hand (dom7 LH [1 7 #9]) passes (boundary is legal)', () => {
const errs = checkPianoRecipe('fixture', { recipe: { LH: ['1', '7', '#9'] } }, 'dom7')
assert(errs.length === 0, `span-15 boundary rejected: ${errs.join('; ')}`)
})
// The rule must bite on the RIGHT hand too, and a legal LH must not mask it.
check('RH is checked independently (LH [1] fine, RH [1 7 3] fails naming RH)', () => {
const errs = checkPianoRecipe('fixture', { recipe: { LH: ['1'], RH: ['1', '7', '3'] } }, 'dom7')
assert(errs.some((e) => e.includes('RH') && e.includes('spans 16')),
`RH span not enforced: ${errs.join('; ')}`)
assert(!errs.some((e) => e.includes('LH')), `legal LH wrongly flagged: ${errs.join('; ')}`)
})
// P-22's widest verified voicing: ø11 rootless ['3','5','7','11'] on half_dim
// stacks 3 → 6 → 10 → 17: span 14. It must stay legal — that's why the
// constant is 15, not 12 or 16.
check("jazz's widest voicing (half_dim LH [3 5 7 11], span 14) stays legal", () => {
const errs = checkPianoRecipe('fixture', { recipe: { LH: ['3', '5', '7', '11'] } }, 'half_dim')
assert(errs.length === 0, `the verified 14-span ø11 voicing was rejected: ${errs.join('; ')}`)
})
// Live-KB guard: every piano recipe in every registered pack (present and
// future — e.g. the incoming gospel piano cell) passes checkPianoRecipe.
check('every KB piano pack recipe passes checkPianoRecipe (span ≤ 15 everywhere)', () => {
let recipes = 0
for (const styleName of styleNames) {
const pack = kb[styleName]?.instruments?.piano
if (!pack) continue
const progById = Object.fromEntries((kb[styleName].progressions ?? []).map((p) => [p.id, p]))
for (const [pid, plays] of Object.entries(pack.plays ?? {})) {
const prog = progById[pid]
assert(prog, `${styleName}/piano plays key '${pid}' is not a progression of this style`)
plays.forEach((play, pi) => {
(play.chords ?? []).forEach((step, ci) => {
recipes++
const where = `${styleName}/piano ${pid} play[${pi}] "${play.label ?? '?'}" chord[${ci}]`
const errs = checkPianoRecipe(where, step, prog.qualities[ci])
assert(errs.length === 0, errs.join('; '))
})
})
}
}
console.log(` (${recipes} live piano recipes checked)`)
})
// ─── 5b. Bass play schema (C-41) ──────────────────────────────────────────────
//
// Same lib-mode pattern as §4/§5: exercise the exported checkBassPlay against
// in-memory fixtures — a realistic boogie play must pass, and each malformed
// variant must FAIL with the specific error — proving the bass rules bite
// before any bass cell (P-41) is authored against them.
console.log('\nBass play schema (validate-kb lib mode):')
const { checkBassPlay, MIN_PLAYS_BASS, BASS_APPROACHES, BASS_MAX_OFFSET } = kbv
check('validate-kb exports checkBassPlay / MIN_PLAYS_BASS(=1) / BASS_APPROACHES / BASS_MAX_OFFSET(=19)', () => {
assert(typeof checkBassPlay === 'function', 'checkBassPlay is not a function')
assert(MIN_PLAYS_BASS === 1, `MIN_PLAYS_BASS must be 1 (SCHEMA bass coverage floor), got ${MIN_PLAYS_BASS}`)
assert(Array.isArray(BASS_APPROACHES) && BASS_APPROACHES.join(',') === 'chrom-below,chrom-above,fifth-of-next',
`BASS_APPROACHES must be exactly [chrom-below, chrom-above, fifth-of-next], got ${JSON.stringify(BASS_APPROACHES)}`)
assert(BASS_MAX_OFFSET === 19, `BASS_MAX_OFFSET must be 19 (an octave + a fifth), got ${BASS_MAX_OFFSET}`)
})
// A 2-step I7→IV7 fixture progression; the IV7 gets 2 bars (exercises the
// per-step beat range). The good play: classic boogie cell + a chromatic walk.
const bassProg = () => ({
id: 'blues-fixture', rn: ['I7', 'IV7'], degrees: [0, 5],
qualities: ['dom7', 'dom7'], bars: [1, 2],
})
const goodBassPlay = () => ({
label: 'Boogie cell',
level: 'foundation',
feel: 'swung 8ths, locked with the kick',
tips: 'The same cell moves to the IV unchanged — degrees, not frets.',
chords: [
{
pattern: [
{ deg: '1', beat: 1 },
{ deg: '3', beat: 2 },
{ deg: '5', beat: 3, technique: 'ghost-note' },
{ approach: 'chrom-below', beat: 4 },
],
note: 'walk up into the IV',
},
{
pattern: [
{ deg: '1', beat: 1 },
{ deg: '6', beat: 3 },
{ deg: 'b7', beat: 5 },
{ deg: '1', octave: 1, beat: 7 },
{ approach: 'fifth-of-next', beat: 8 },
],
},
],
})
check('good boogie fixture PASSES checkBassPlay (0 errors)', () => {
const errs = checkBassPlay('fixture', goodBassPlay(), bassProg())
assert(errs.length === 0, `expected clean pass, got: ${errs.join('; ')}`)
})
check("unresolvable degree FAILS ('2' is not a bass degree)", () => {
const play = goodBassPlay()
play.chords[0].pattern[1] = { deg: '2', beat: 2 }
const errs = checkBassPlay('fixture', play, bassProg())
assert(errs.some((e) => e.includes("unresolvable degree '2'")), `deg '2' was accepted: ${errs.join('; ')}`)
})
check('numeric deg FAILS (degrees are strings, one convention with piano)', () => {
const play = goodBassPlay()
play.chords[0].pattern[0] = { deg: 1, beat: 1 }
const errs = checkBassPlay('fixture', play, bassProg())
assert(errs.some((e) => e.includes('deg must be a degree STRING')), `numeric deg accepted: ${errs.join('; ')}`)
})
check("octave cap bites (b7 octave 1 = 22 semitones > 19) and bad octave values FAIL", () => {
const play = goodBassPlay()
play.chords[1].pattern[3] = { deg: 'b7', octave: 1, beat: 7 }
const errs = checkBassPlay('fixture', play, bassProg())
assert(errs.some((e) => e.includes(`max ${BASS_MAX_OFFSET}`)), `22-semitone offset accepted: ${errs.join('; ')}`)
const play2 = goodBassPlay()
play2.chords[1].pattern[3] = { deg: '1', octave: 2, beat: 7 }
const errs2 = checkBassPlay('fixture', play2, bassProg())
assert(errs2.some((e) => e.includes('octave, when present, must be 0 or 1')), `octave 2 accepted: ${errs2.join('; ')}`)
})
check('empty pattern FAILS', () => {
const play = goodBassPlay()
play.chords[0].pattern = []
const errs = checkBassPlay('fixture', play, bassProg())
assert(errs.some((e) => e.includes('pattern must be a non-empty ordered array')), `empty pattern accepted: ${errs.join('; ')}`)
})
check("rootless pattern FAILS (every pattern must state '1')", () => {
const play = goodBassPlay()
play.chords[0].pattern = [{ deg: '3', beat: 1 }, { deg: '5', beat: 2 }]
const errs = checkBassPlay('fixture', play, bassProg())
assert(errs.some((e) => e.includes("never states the root ('1')")), `rootless pattern accepted: ${errs.join('; ')}`)
})
check('approach notes must CLOSE the pattern (deg after approach fails)', () => {
const play = goodBassPlay()
play.chords[0].pattern = [{ deg: '1', beat: 1 }, { approach: 'chrom-below', beat: 2 }, { deg: '5', beat: 3 }]
const errs = checkBassPlay('fixture', play, bassProg())
assert(errs.some((e) => e.includes('deg note after an approach')), `mid-pattern approach accepted: ${errs.join('; ')}`)
})
check("unknown approach type FAILS ('tritone-sub' is not in the typed set)", () => {
const play = goodBassPlay()
play.chords[0].pattern[3] = { approach: 'tritone-sub', beat: 4 }
const errs = checkBassPlay('fixture', play, bassProg())
assert(errs.some((e) => e.includes("unknown approach 'tritone-sub'")), `unknown approach accepted: ${errs.join('; ')}`)
})
check('beat range + ordering bite (beat 9 on a 2-bar step; decreasing beats)', () => {
const play = goodBassPlay()
play.chords[1].pattern[4] = { approach: 'fifth-of-next', beat: 9 } // 2 bars → beat < 9
const errs = checkBassPlay('fixture', play, bassProg())
assert(errs.some((e) => e.includes('beat must be a number in [1, 9)')), `beat 9 on a 2-bar step accepted: ${errs.join('; ')}`)
const play2 = goodBassPlay()
play2.chords[0].pattern[2] = { deg: '5', beat: 1.5 } // after beat 2 — decreasing
const errs2 = checkBassPlay('fixture', play2, bassProg())
assert(errs2.some((e) => e.includes('beats must be non-decreasing')), `decreasing beats accepted: ${errs2.join('; ')}`)
})
check('chords length ≠ progression length FAILS; missing feel FAILS; bad technique FAILS', () => {
const short = goodBassPlay(); short.chords = short.chords.slice(0, 1)
assert(checkBassPlay('fixture', short, bassProg()).some((e) => e.includes('chords length 1 ≠ progression length 2')),
'short chords array accepted')
const noFeel = goodBassPlay(); delete noFeel.feel
assert(checkBassPlay('fixture', noFeel, bassProg()).some((e) => e.includes('feel required')),
'missing feel accepted')
const badTech = goodBassPlay(); badTech.chords[0].pattern[2].technique = 'slap-pop'
assert(checkBassPlay('fixture', badTech, bassProg()).some((e) => e.includes("unknown technique 'slap-pop'")),
'off-vocabulary technique accepted')
})
check('density cap bites (> 8 notes per bar is not intermediate)', () => {
const play = goodBassPlay()
play.chords[0].pattern = Array.from({ length: 9 }, () => ({ deg: '1' }))
const errs = checkBassPlay('fixture', play, bassProg())
assert(errs.some((e) => e.includes('8ths density cap')), `9 notes in one bar accepted: ${errs.join('; ')}`)
})
// Live-KB guard (future-proofs P-41): every bass pack play in the registry
// passes checkBassPlay. Zero bass cells today — the loop is a no-op until the
// first bass.js registers, then it gates it exactly like the validator does.
check('every KB bass pack play passes checkBassPlay', () => {
let bassPlays = 0
for (const styleName of styleNames) {
const pack = kb[styleName]?.instruments?.bass
if (!pack) continue
const progById = Object.fromEntries((kb[styleName].progressions ?? []).map((p) => [p.id, p]))
for (const [pid, plays] of Object.entries(pack.plays ?? {})) {
const prog = progById[pid]
assert(prog, `${styleName}/bass plays key '${pid}' is not a progression of this style`)
plays.forEach((play, pi) => {
bassPlays++
const errs = checkBassPlay(`${styleName}/bass ${pid} play[${pi}] "${play.label ?? '?'}"`, play, prog)
assert(errs.length === 0, errs.join('; '))
})
}
}
console.log(` (${bassPlays} live bass plays checked)`)
})
// ─── 5c. Piano lick schema (C-60) ─────────────────────────────────────────────
//
// Same lib-mode pattern as §4/§5/§5b: exercise the exported checkPianoLick
// against in-memory fixtures — a realistic enclosure lick must pass, and each
// malformed variant must FAIL with the specific error — proving the piano-lick
// rules bite before any piano licks (P-60) are authored against them.
console.log('\nPiano lick schema (validate-kb lib mode):')
const { checkPianoLick, PIANO_LICK_TECHNIQUES, PIANO_LICK_APPROACHES, PIANO_LICK_MAX_OFFSET } = kbv
check('validate-kb exports checkPianoLick / PIANO_LICK_TECHNIQUES / PIANO_LICK_APPROACHES / PIANO_LICK_MAX_OFFSET(=25)', () => {
assert(typeof checkPianoLick === 'function', 'checkPianoLick is not a function')
assert(Array.isArray(PIANO_LICK_TECHNIQUES)
&& PIANO_LICK_TECHNIQUES.join(',') === 'slide,double-stop,ghost-note,grace-note',
`PIANO_LICK_TECHNIQUES must be exactly [slide, double-stop, ghost-note, grace-note], got ${JSON.stringify(PIANO_LICK_TECHNIQUES)}`)
assert(Array.isArray(PIANO_LICK_APPROACHES) && PIANO_LICK_APPROACHES.join(',') === 'chrom-below,chrom-above',
`PIANO_LICK_APPROACHES must be exactly [chrom-below, chrom-above] (no fifth-of-next — licks have no next station), got ${JSON.stringify(PIANO_LICK_APPROACHES)}`)
assert(PIANO_LICK_MAX_OFFSET === 25,
`PIANO_LICK_MAX_OFFSET must be 25 (root in the bottom octave: 11 + 25 = 36, MiniPiano's top key), got ${PIANO_LICK_MAX_OFFSET}`)
})
// Vocabulary-family consistency: every piano word except the piano-specific
// 'grace-note' must also be a guitar lick word WITH THE SAME SPELLING — one
// vocabulary family, not a third counting scheme. (The guitar set-equality
// guard in §7b is untouched: LICK_TECHNIQUES itself did not change.)
check("piano vocab ⊂ guitar vocab + 'grace-note' (shared words, one spelling)", () => {
const guitar = new Set(LICK_TECHNIQUES)
const strays = PIANO_LICK_TECHNIQUES.filter((t) => t !== 'grace-note' && !guitar.has(t))
assert(strays.length === 0,
`piano technique word(s) [${strays}] are neither 'grace-note' nor in LICK_TECHNIQUES — shared words must keep the guitar spelling`)
assert(!guitar.has('grace-note'),
"guitar vocab now contains 'grace-note' — it was piano-specific; update SCHEMA + this guard deliberately")
})
// A realistic, fully-valid fixture: a bebop enclosure into the 3rd over min7.
// Offsets: '5'@1 → 19; approaches target '3'@1 → 15, deriving 16 and 14;
// final deg '3'@1 → 15. All in [0, 25]; beats non-decreasing.
const goodPianoLick = () => ({
id: 'jazz-enclosure-into-3',
name: 'Bebop enclosure into the 3rd',
level: 'intermediate',
chordContext: 'over the ii7',
quality: 'min7',
techniques: ['grace-note'],
source: 'Barry Harris workshop vocabulary',
notes: [
{ deg: '5', octave: 1, beat: 1 },
{ approach: 'chrom-above', beat: 2 },
{ approach: 'chrom-below', beat: 2.5 },
{ deg: '3', octave: 1, beat: 3, technique: 'grace-note' },
],
})
check('good enclosure fixture PASSES checkPianoLick (0 errors)', () => {
const errs = checkPianoLick('fixture', goodPianoLick(), 'jazz', new Set())
assert(errs.length === 0, `expected clean pass, got: ${errs.join('; ')}`)
})
check("missing / unknown quality FAILS (degrees need a machine context, chordContext is prose)", () => {
const noQ = goodPianoLick(); delete noQ.quality
assert(checkPianoLick('fixture', noQ, 'jazz', new Set()).some((e) => e.includes('quality must be a CHORD_TYPES key')),
'missing quality accepted')
const badQ = goodPianoLick(); badQ.quality = 'minor7' // not a CHORD_TYPES key
assert(checkPianoLick('fixture', badQ, 'jazz', new Set()).some((e) => e.includes('quality must be a CHORD_TYPES key')),
"quality 'minor7' accepted")
})
check("unresolvable degree FAILS ('7' resolves on min7 but '2' never does)", () => {
const lick = goodPianoLick()
lick.notes[0] = { deg: '2', beat: 1 }
const errs = checkPianoLick('fixture', lick, 'jazz', new Set())
assert(errs.some((e) => e.includes("unresolvable degree '2'")), `deg '2' accepted: ${errs.join('; ')}`)
})
check('terminal approach FAILS (targets the next deg — the final note must be a deg)', () => {
const lick = goodPianoLick()
lick.notes.push({ approach: 'chrom-below', beat: 4 })
const errs = checkPianoLick('fixture', lick, 'jazz', new Set())
assert(errs.some((e) => e.includes('approach cannot close a piano lick')), `terminal approach accepted: ${errs.join('; ')}`)
})
check('consecutive SAME-type approaches FAIL (identical derived pitch); the enclosure (alternating) passes', () => {
const lick = goodPianoLick()
lick.notes[2] = { approach: 'chrom-above', beat: 2.5 } // above, above
const errs = checkPianoLick('fixture', lick, 'jazz', new Set())
assert(errs.some((e) => e.includes('consecutive')), `same-type approach pair accepted: ${errs.join('; ')}`)
// and the alternating original stays clean (already asserted above, but the contrast is the point)
assert(checkPianoLick('fixture', goodPianoLick(), 'jazz', new Set()).length === 0, 'alternating enclosure rejected')
})
check('chrom-below of a root-position target FAILS (derives 1, below the window)', () => {
const lick = goodPianoLick()
lick.notes = [{ approach: 'chrom-below', beat: 1 }, { deg: '1', beat: 2 }]
const errs = checkPianoLick('fixture', lick, 'jazz', new Set())
assert(errs.some((e) => e.includes('derives 1')), `sub-window approach accepted: ${errs.join('; ')}`)
})
check("range cap bites both ways: deg '5' octave 2 (=31) and chrom-above of a 25-offset target (=26) FAIL", () => {
const lick = goodPianoLick()
lick.notes[0] = { deg: '5', octave: 2, beat: 1 } // 7 + 24 = 31 > 25
const errs = checkPianoLick('fixture', lick, 'jazz', new Set())
assert(errs.some((e) => e.includes(`max ${PIANO_LICK_MAX_OFFSET}`)), `31-semitone deg accepted: ${errs.join('; ')}`)
const lick2 = goodPianoLick() // b9 @ octave 2 = 25 (legal boundary); chrom-above derives 26
lick2.quality = 'dom7'
lick2.notes = [{ approach: 'chrom-above', beat: 1 }, { deg: 'b9', octave: 2, beat: 2 }]
const errs2 = checkPianoLick('fixture', lick2, 'jazz', new Set())
assert(errs2.some((e) => e.includes('derived pitch sits 26')), `26-semitone derived approach accepted: ${errs2.join('; ')}`)
const lick3 = goodPianoLick() // the 25 boundary itself is legal: high root via octave 2 + b9… use deg '1' octave 2 = 24 and chrom-above = 25
lick3.notes = [{ approach: 'chrom-above', beat: 1 }, { deg: '1', octave: 2, beat: 2 }]
assert(checkPianoLick('fixture', lick3, 'jazz', new Set()).length === 0,
'the 25-semitone boundary (chrom-above of the double-octave root) was rejected — cap off by one')
})
check('bad octave values FAIL (3 and non-integers rejected; approaches take no octave)', () => {
const lick = goodPianoLick()
lick.notes[0] = { deg: '5', octave: 3, beat: 1 }
assert(checkPianoLick('fixture', lick, 'jazz', new Set()).some((e) => e.includes('octave, when present, must be 0, 1 or 2')),
'octave 3 accepted')
const lick2 = goodPianoLick()
lick2.notes[1] = { approach: 'chrom-above', octave: 1, beat: 2 }
assert(checkPianoLick('fixture', lick2, 'jazz', new Set()).some((e) => e.includes('octave applies to deg notes only')),
'octave on an approach accepted')
})
check("guitar-only technique words FAIL on piano ('bend' in summary; 'vibrato' per-note; summary honesty)", () => {
const lick = goodPianoLick()
lick.techniques = ['grace-note', 'bend']
assert(checkPianoLick('fixture', lick, 'jazz', new Set()).some((e) => e.includes("unknown piano technique 'bend'")),
"'bend' accepted on keys")
const lick2 = goodPianoLick()
lick2.notes[3].technique = 'vibrato'
assert(checkPianoLick('fixture', lick2, 'jazz', new Set()).some((e) => e.includes("unknown piano technique 'vibrato'")),
"'vibrato' accepted on keys")
const lick3 = goodPianoLick()
lick3.notes[3].technique = 'slide' // valid word, but not in techniques: [grace-note]
assert(checkPianoLick('fixture', lick3, 'jazz', new Set()).some((e) => e.includes("must also appear in the lick's techniques[]")),
'summary honesty not enforced')
})
check('beat range + ordering bite (beat 9; decreasing beats); density cap bites (17 notes)', () => {
const lick = goodPianoLick()
lick.notes[3].beat = 9
assert(checkPianoLick('fixture', lick, 'jazz', new Set()).some((e) => e.includes('beat must be a number in [1, 9)')),
'beat 9 accepted')
const lick2 = goodPianoLick()
lick2.notes[3].beat = 2.25 // after 2.5 — decreasing
assert(checkPianoLick('fixture', lick2, 'jazz', new Set()).some((e) => e.includes('beats must be non-decreasing')),
'decreasing beats accepted')
const lick3 = goodPianoLick()
lick3.notes = Array.from({ length: 17 }, () => ({ deg: '1' }))
assert(checkPianoLick('fixture', lick3, 'jazz', new Set()).some((e) => e.includes('17 notes > 16')),
'17-note lick accepted')
})
check('duplicate id FAILS (shared namespace with progressions and guitar licks)', () => {
const ids = new Set()
assert(checkPianoLick('fixture', goodPianoLick(), 'jazz', ids).length === 0, 'first insert should pass')
const errs = checkPianoLick('fixture', goodPianoLick(), 'jazz', ids)
assert(errs.some((e) => e.includes('duplicate id')), `duplicate id accepted: ${errs.join('; ')}`)
})
// (The PianoLickCard vocab drift guard lives in §7b-piano below — it needs the
// jsx load hook, which is registered in §7.)
// ─── 6. Loop-detection truth fixtures (C-30) ──────────────────────────────────
//
// Run every scripts/loop-fixtures.mjs case against the REAL
// detectRepeatingProgression. Comparison is rotation-canonical on BOTH sides
// (the fixture module replicates theory.js's private `canonicalize`), so a
// correct loop reported from any rotation passes. Semantics:
// · no expectedFail + mismatch → ✗ smoke failure (regression in what works)
// · expectedFail + mismatch → ⚠ annotated expected-fail (printed, counted,
// NOT a failure — this is L-30's todo list)
// · expectedFail + MATCH → ✗ smoke failure: stale marker — the fixture
// now passes, L-30 must flip expectedFail off
//
// The failure map of the current algorithm lives in the fixture file header.
console.log('\nLoop-detection fixtures (C-30):')
const { LOOP_FIXTURES, canonicalLoop } = await load('scripts/loop-fixtures.mjs')
let expectedFails = 0
check('loop-fixtures module exports a non-empty fixture array + canonicalLoop', () => {
assert(Array.isArray(LOOP_FIXTURES) && LOOP_FIXTURES.length > 0, 'LOOP_FIXTURES missing/empty')
assert(typeof canonicalLoop === 'function', 'canonicalLoop is not a function')
const ids = new Set(LOOP_FIXTURES.map((f) => f.id))
assert(ids.size === LOOP_FIXTURES.length, 'duplicate fixture ids')
// canonicalLoop must be rotation-invariant (replica sanity: all rotations of a
// loop normalise to the same key — the property the comparisons rely on).
const key = canonicalLoop(['C', 'Am', 'F']).join(',')
assert(canonicalLoop(['Am', 'F', 'C']).join(',') === key && canonicalLoop(['F', 'C', 'Am']).join(',') === key,
'canonicalLoop is not rotation-invariant')
})
const loopKey = (loop) => (loop === null ? 'null' : canonicalLoop(loop).join(','))
for (const f of LOOP_FIXTURES ?? []) {
const got = detectRepeatingProgression(f.history)
const gotKey = loopKey(Array.isArray(got) ? got : null)
const wantKey = loopKey(f.expect)
const matches = gotKey === wantKey
const label = `loop fixture '${f.id}' — ${f.description}`
if (!f.expectedFail) {
check(label, () => {
assert(matches, `got [${gotKey}], expected [${wantKey}]`)
})
} else if (!matches) {
// Documented current-algorithm failure — annotated, never silently skipped.
expectedFails++
console.log(` ⚠ EXPECTED-FAIL ${label}\n today: [${gotKey}] · contract (L-30): [${wantKey}]`)
} else {
// The algorithm now satisfies this contract — the marker is stale and MUST
// be removed (L-30's DoD is zero expectedFail markers). Fail loudly.
check(`${label} [STALE expectedFail marker]`, () => {
assert(false, `fixture PASSES (got [${gotKey}]) but is still marked expectedFail — remove the marker in scripts/loop-fixtures.mjs`)
})
}
}
if (expectedFails) {
console.log(` (${expectedFails} annotated expected-fail(s) — the current detectRepeatingProgression's known gaps, awaiting L-30)`)
}
// ─── 7. Hand-sync drift guards (C-40) ─────────────────────────────────────────
//
// Two pieces of logic/data are deliberately duplicated by hand across files
// (src/ must not import from scripts/, so validator ↔ component pairs can't
// share a module). These guards turn silent drift into a red smoke run.
//
// (a) resolveDegree — scripts/validate-kb.mjs holds the KB contract's reference
// implementation; src/components/JamGuide.jsx carries a hand-synced copy
// (its header says "keep the two in sync by hand"). If they drift, authored
// piano recipes validate against one rule and RENDER with another — wrong
// pitches, no error. Guard: a LITERAL 16-degree × 14-quality truth table
// (pinned values, NOT derived from CHORD_TYPES at runtime — deriving would
// let the expectation co-move with the very code under test) asserted
// against BOTH implementations for every pair.
// · The validator's resolveDegree is unexported; it is probed BEHAVIOURALLY
// through the exported checkPianoRecipe: recipe LH ['1','1','1', deg]
// stacks 0 → 12 → 24 → 24 + offset (a repeated pc climbs an octave —
// stackHand's documented convention), so the span error always fires
// (span 2536 > MAX_HAND_SPAN 15) and REPORTS the span, from which the
// offset is recovered exactly: offset = span 24 (36 → 0). An
// unresolvable degree instead yields the 'unresolvable degree' error.
// · JamGuide.jsx's copy IS directly comparable (since 2026-07-10):
// L-40's ride-along exported `resolveDegree` (the C-40 follow-up), so
// this section imports the real component module under the jsx load
// hook below and sweeps the copy against the same pinned table, cell
// for cell. A '.jsx'-retry resolve hook (registered next to the load
// hook) lets JamGuide's extensionless component imports
// ('./GlanceRail', './BassPatternCard'…) resolve under raw Node ESM —
// the top-of-file hook only retries '.js'.
//
// (b) Technique vocabulary — validate-kb.mjs LICK_TECHNIQUES (the schema gate)
// vs LickCard.jsx TECHNIQUE_VOCAB (the renderer's glyph vocabulary). A word
// added to one but not the other means licks that validate but render with
// no glyph, or dead glyph code. Guard: import BOTH and assert set-equality.
// LickCard.jsx is JSX, so a node:module load hook transpiles .jsx on the
// fly via esbuild (already in node_modules as Vite's transpiler).
console.log('\nHand-sync drift guards (C-40):')
// -- jsx load hook: lets Node import component files (esbuild transform) -------
register(
'data:text/javascript,' +
encodeURIComponent(`
import { readFileSync } from 'node:fs'
const esbuildP = import(${JSON.stringify(import.meta.resolve('esbuild'))})
export async function load(url, context, next) {
if (url.endsWith('.jsx')) {
const { transform } = await esbuildP
const src = readFileSync(new URL(url), 'utf8')
const { code } = await transform(src, { loader: 'jsx', jsx: 'automatic', jsxImportSource: 'react' })
return { format: 'module', source: code, shortCircuit: true }
}
return next(url, context)
}
`),
import.meta.url,
)
// -- '.jsx' resolve retry: component files import siblings extensionless -------
// (e.g. JamGuide.jsx does `import GlanceRail from './GlanceRail'`; the top-of-
// file hook retries '.js' only). Registered last so it runs FIRST and catches
// the whole chain's failure, then retries with '.jsx' appended.
register(
'data:text/javascript,' +
encodeURIComponent(`
export async function resolve(specifier, context, next) {
try {
return await next(specifier, context)
} catch (e) {
if (/^\\.{1,2}\\//.test(specifier) && !/\\.([mc]?js|jsx)$/.test(specifier)) {
try { return await next(specifier + '.jsx', context) } catch { throw e }
}
throw e
}
}
`),
import.meta.url,
)
// -- (a) resolveDegree truth table ---------------------------------------------
// Quality order for the per-quality rows below (must stay CHORD_TYPES' keys —
// a new chord quality MUST extend this table, and the check enforces that).
const Q14 = ['maj', 'min', 'dom7', 'maj7', 'min7', 'dim', 'dim7', 'half_dim', 'aug', 'sus4', 'sus2', 'maj6', 'min6', 'add9']
// resolveDegree(deg, quality) → pitch-class offset from the chord root.
// Scalar = same for all 14 qualities (the fixed-offset degrees); array = one
// value per Q14 entry ('3'/'5'/'7' resolve through the quality's intervals).
// '2' is the canary row: not a legal degree, must stay null everywhere.
const DEGREE_TABLE = {
'1': 0,
'b9': 1,
'9': 2,
'#9': 3,
'11': 5,
'#11': 6,
'b5': 6,
'b13': 8,
'13': 9,
'6': 9,
'b3': 3,
'b7': 10,
// maj min dom7 maj7 min7 dim dim7 ø aug sus4 sus2 maj6 min6 add9
'3': [ 4, 3, 4, 4, 3, 3, 3, 3, 4, 5, 2, 4, 3, 4 ],
'5': [ 7, 7, 7, 7, 7, 6, 6, 6, 8, 7, 7, 7, 7, 7 ],
'7': [ null, null, 10, 11, 10, null, 9, 10, null, null, null, 9, 9, null],
'2': null,
}
// Behavioural probe of the validator's unexported resolveDegree (see header).
function probeValidatorResolveDegree(deg, quality) {
const errs = checkPianoRecipe('probe', { recipe: { LH: ['1', '1', '1', deg] } }, quality)
if (errs.some((e) => e.includes(`unresolvable degree '${deg}'`))) return null
const m = errs.map((e) => /spans (\d+) semitones/.exec(e)).find(Boolean)
if (!m) throw new Error(`probe(${deg}, ${quality}) got neither an 'unresolvable degree' nor a span error — checkPianoRecipe/stackHand changed shape; re-derive the probe. Errors: ${JSON.stringify(errs)}`)
const offset = Number(m[1]) - 24
return offset === 12 ? 0 : offset // '1'-repeat lands an octave up: span 36 ⇒ offset 0
}
check('truth table covers 16 degrees × all 14 CHORD_TYPES qualities', () => {
const degs = Object.keys(DEGREE_TABLE)
assert(degs.length === 16, `expected 16 degrees, table has ${degs.length}`)
assert(Q14.join(',') === QUALITIES.join(','),
`Q14 ≠ CHORD_TYPES keys — a quality was added/renamed; extend DEGREE_TABLE. Q14: ${Q14} · CHORD_TYPES: ${QUALITIES}`)
for (const [deg, row] of Object.entries(DEGREE_TABLE))
if (Array.isArray(row)) assert(row.length === 14, `row '${deg}' has ${row.length} entries, expected 14`)
})
// JamGuide's hand-synced copy, imported directly (exported by the L-40
// ride-along; transpiled by the jsx load hook above).
const jamGuideMod = await load('src/components/JamGuide.jsx')
check('JamGuide.jsx exports resolveDegree (L-40 ride-along) so the drift guard can sweep it directly', () => {
assert(typeof jamGuideMod.resolveDegree === 'function',
`resolveDegree is ${typeof jamGuideMod.resolveDegree}, expected an exported function — the export at JamGuide.jsx (see its header) was removed; the drift guard lost its direct probe`)
})
for (const [deg, row] of Object.entries(DEGREE_TABLE)) {
check(`validator resolveDegree('${deg}') matches the pinned table for all 14 qualities`, () => {
Q14.forEach((quality, qi) => {
const want = Array.isArray(row) ? row[qi] : row
const got = probeValidatorResolveDegree(deg, quality)
assert(got === want,
`resolveDegree('${deg}', '${quality}') drifted: validator says ${got}, pinned table says ${want} — re-sync scripts/validate-kb.mjs ↔ src/components/JamGuide.jsx (hand-synced pair) or fix the table if the contract legitimately changed`)
})
})
check(`JamGuide resolveDegree('${deg}') matches the pinned table for all 14 qualities`, () => {
Q14.forEach((quality, qi) => {
const want = Array.isArray(row) ? row[qi] : row
const got = jamGuideMod.resolveDegree(String(deg), quality) ?? null
assert(got === want,
`resolveDegree('${deg}', '${quality}') drifted: JamGuide.jsx says ${got}, pinned table says ${want} — re-sync src/components/JamGuide.jsx ↔ scripts/validate-kb.mjs (hand-synced pair) or fix the table if the contract legitimately changed`)
})
})
}
// -- (b) technique vocab set-equality -------------------------------------------
const lickCardMod = await load('src/components/LickCard.jsx')
check('LickCard.jsx imports under the jsx hook and exports TECHNIQUE_VOCAB', () => {
assert(Array.isArray(lickCardMod.TECHNIQUE_VOCAB), 'TECHNIQUE_VOCAB is not an exported array')
assert(lickCardMod.TECHNIQUE_VOCAB.length > 0, 'TECHNIQUE_VOCAB is empty')
})
check('TECHNIQUE_VOCAB (LickCard.jsx) ≡ LICK_TECHNIQUES (validate-kb.mjs) as sets', () => {
const vocab = new Set(lickCardMod.TECHNIQUE_VOCAB ?? [])
const schema = new Set(LICK_TECHNIQUES)
const missingInCard = [...schema].filter((t) => !vocab.has(t))
const missingInSchema = [...vocab].filter((t) => !schema.has(t))
assert(missingInCard.length === 0 && missingInSchema.length === 0,
`technique vocab drift — in validator but not LickCard: [${missingInCard}] · in LickCard but not validator: [${missingInSchema}] — the two lists are hand-synced; add the word to BOTH or neither`)
})
// -- (b-piano) piano technique vocab set-equality (C-60 forward guard) ----------
// Once D-60's PianoLickCard.jsx lands, its exported PIANO_TECHNIQUE_VOCAB must
// be set-equal to the validator's PIANO_LICK_TECHNIQUES — the same hand-sync
// rule as LickCard. Conditional so smoke stays green until the component
// exists, then bites automatically (D-60's DoD includes making this pass).
if (existsSync(join(ROOT, 'src/components/PianoLickCard.jsx'))) {
const pianoCardMod = await load('src/components/PianoLickCard.jsx')
check('PIANO_TECHNIQUE_VOCAB (PianoLickCard.jsx) ≡ PIANO_LICK_TECHNIQUES (validate-kb.mjs) as sets', () => {
const vocab = new Set(pianoCardMod.PIANO_TECHNIQUE_VOCAB ?? [])
const schema = new Set(PIANO_LICK_TECHNIQUES)
const missingInCard = [...schema].filter((t) => !vocab.has(t))
const missingInSchema = [...vocab].filter((t) => !schema.has(t))
assert(missingInCard.length === 0 && missingInSchema.length === 0,
`piano technique vocab drift — in validator but not PianoLickCard: [${missingInCard}] · in PianoLickCard but not validator: [${missingInSchema}] — hand-synced; add the word to BOTH or neither`)
})
} else {
warn('PianoLickCard vocab drift guard', 'src/components/PianoLickCard.jsx not yet authored (D-60)')
}
// ─── 7d. Jam Roulette round-trip sweep (L-60, jam-roulette.md §2.2) ───────────
//
// The pool gate, protocol VERBATIM: for each of the 56 progressions, seed its
// canonical collapsed form in C, fill a 32-commit window with repetitions and
// truncate at EVERY partial-cycle offset (0…len1); it passes iff
// detectRepeatingProgression returns exactly that form at ALL offsets, AND the
// collapsed length is 28. Steady-state-plus-all-offsets is the honest protocol
// (a jam is sampled mid-cycle; a naive "2 clean cycles" feed evicts the 2-name
// vamps and passes blues-8bar — §2.2). Expected: 52 passers, exactly 4 excluded
// (jazz-blues, blues-quickchange, bossa-blue over-length; blues-8bar's
// 1-of-7-offsets self-competition). Independent of match.roundTripPasses so a
// detector or seed regression turns smoke red; cross-checked against it below.
console.log('\nJam Roulette round-trip sweep (L-60):')
const { seedableLoop, roundTripPasses, buildRoulettePool } = match
// Verbatim §2.2 protocol — deliberately NOT calling match.roundTripPasses.
function sweepPasses(form) {
if (!Array.isArray(form) || form.length < 2 || form.length > 8) return false
const target = form.join(',')
for (let offset = 0; offset < form.length; offset++) {
const history = []
for (let k = 0; k < 32; k++) history.push(form[(offset + k) % form.length])
const detected = detectRepeatingProgression(history)
if (!detected || detected.join(',') !== target) return false
}
return true
}
const sweepFails = []
let sweepPool = 0
let sweepTotal = 0
for (const style of Object.keys(kb)) {
for (const prog of kb[style].progressions ?? []) {
sweepTotal++
const form = seedableLoop(prog, 0) // key C — key-independent (§3.3.3)
if (form && sweepPasses(form)) sweepPool++
else sweepFails.push(`${style}/${prog.id}`)
}
}
console.log(` round-trip sweep: ${sweepFails.length} failures / pool ${sweepPool} (of ${sweepTotal})`)
check('round-trip sweep: exactly 4 failures, pool 52 of 56 (§2.2 steady-state, all offsets)', () => {
assert(sweepTotal === 56, `swept ${sweepTotal} progressions, expected 56`)
assert(sweepPool === 52, `pool ${sweepPool} ≠ 52`)
assert(sweepFails.length === 4, `${sweepFails.length} failures ≠ 4: [${sweepFails.join(', ')}]`)
})
check('round-trip sweep: the 4 excluded are exactly jazz-blues, blues-quickchange, blues-8bar, bossa-blue', () => {
const want = ['blues/blues-8bar', 'blues/blues-quickchange', 'bossa/bossa-blue', 'jazz/jazz-blues']
assert(JSON.stringify([...sweepFails].sort()) === JSON.stringify(want),
`excluded set [${[...sweepFails].sort().join(', ')}] ≠ [${want.join(', ')}]`)
})
check('match.roundTripPasses agrees with the verbatim sweep on all 56 (drift guard)', () => {
for (const style of Object.keys(kb)) {
for (const prog of kb[style].progressions ?? []) {
const form = seedableLoop(prog, 0)
const mine = form ? sweepPasses(form) : false
const theirs = form ? roundTripPasses(form) : false
assert(mine === theirs, `${style}/${prog.id}: verbatim ${mine} ≠ match.roundTripPasses ${theirs}`)
}
}
})
check('buildRoulettePool exposes exactly the 52 passers, all collapsed len 28', () => {
const pool = buildRoulettePool(kb)
let n = 0
for (const [, members] of pool.byStyle) {
for (const mem of members) {
n++
assert(mem.collapsedLen >= 2 && mem.collapsedLen <= 8, `${mem.id} collapsedLen ${mem.collapsedLen} out of 28`)
}
}
assert(n === 52, `pool holds ${n} members ≠ 52`)
})
// Fix (a): a clean LIVE 12-bar commit stream now matches blues-12bar — the
// pre-existing live-detection bug pinned fixed (jam-roulette.md §3.3.1). The live
// commit stream is ALREADY collapsed (App.jsx dedupes back-to-back commits), so a
// real 12-bar in G commits [G7,C7,G7,D7,C7,G7,D7] per chorus. Before fix (a) this
// collapsed loop matched NOTHING → an empty JamGuide; now it detects + matches.
check('fix (a) live: a clean collapsed 12-bar commit stream detects + matches blues-12bar (was an empty JamGuide)', () => {
const G12_COMMITS = ['G7', 'C7', 'G7', 'D7', 'C7', 'G7', 'D7'] // one chorus, as committed
const history = [].concat(...Array.from({ length: 5 }, () => G12_COMMITS)) // 5 choruses
const detected = detectRepeatingProgression(history)
assert(detected, 'detector returned null for a clean 12-bar commit stream')
const m = matchLoopToProgression(detected, index)
assert(m.matched && m.id === 'blues-12bar' && m.style === 'blues',
`detected ${JSON.stringify(detected)} matched ${m.matched ? m.style + '/' + m.id : 'NONE'}, expected blues/blues-12bar`)
})
// ─── 8. RelatedProgressions ranking pins (C-50) ───────────────────────────────
//
// Pins the L-51-gate-verified ranking outcomes of RelatedProgressions.jsx
// (rankRelatedProgressions / collapseChanges, exported for exactly this) so a
// scoring-constant tweak, a collapse regression, or a KB addition that
// reshuffles the top entries turns smoke red instead of silently changing what
// jammers are recommended. Two live pins + one counterfactual:
//
// (a) collapsed live 12-bar in A → RE-PINNED for L-60 fix (a): the
// collapsed-form index now recognizes the loop as blues-12bar, which the
// id-only rule excludes from its own related list. The new top is
// blues/blues-8bar at score 92, annotation 'shares I7→V7' (0 shape + 40
// same-style + 36 transitions Δ{5,7,10} + 16 Jaccard·1.0 0 length);
// blues-quickchange edges just under at 90 (same terms, |79|=2 length
// penalty). This is the exact flip the pre-fix note here foretold.
// (b) live iiVI in C → match jazz/jazz-251-major (quality overlap wins
// over jazz-251-minor), which is EXCLUDED from entries; top entry is
// jazz/jazz-251-minor at 156 'same changes' (100 same canonical shape +
// 40 same style + 0 shared transitions — every quality differs — + 16
// Jaccard 0 length).
// (c) no-collapse counterfactual: an INDEPENDENT replica of the §5 scoring
// formula (docs/design/one-screen.md), fed blues-12bar's RAW bar-per-bar
// degrees, scores 39 (36 transitions + 8 Jaccard·0.5 5 length, no
// +100) vs 152 with collapse — proving the mandatory collapse step is
// what makes the flagship "this 12-bar IS their 12-bar" relation fire.
// Post fix (a) blues-12bar is the MATCH (excluded), so the live grounding
// moves to the actual top entry: the replica of blues-8bar (same-style
// relative) must equal its live component score (92), grounding the
// replica so the 39/152 can't drift into fiction.
console.log('\nRelatedProgressions ranking pins (C-50):')
const relMod = await load('src/components/RelatedProgressions.jsx')
const { rankRelatedProgressions, collapseChanges, siblingRole } = relMod
const { loopToDegrees, canonicalDegrees } = match
const LOOP_12BAR_A = ['A7', 'D7', 'A7', 'E7', 'D7', 'A7', 'E7'] // detection-collapsed 12-bar
const LOOP_251_C = ['Dm7', 'G7', 'Cmaj7'] // live iiVI
check('RelatedProgressions exports rankRelatedProgressions + collapseChanges', () => {
assert(typeof rankRelatedProgressions === 'function', 'rankRelatedProgressions is not an exported function')
assert(typeof collapseChanges === 'function', 'collapseChanges is not an exported function')
})
check('collapseChanges(blues-12bar) → 7 units [0,5,0,7,5,0,7] (raw 12 bars collapse to the detection form)', () => {
const prog = kb.blues.progressions.find((p) => p.id === 'blues-12bar')
assert(prog, 'blues-12bar missing from the registry')
const units = collapseChanges(prog)
const degs = units.map((u) => u.deg).join(',')
assert(degs === '0,5,0,7,5,0,7', `collapsed degrees [${degs}] ≠ [0,5,0,7,5,0,7]`)
assert(units.every((u) => u.quality === 'dom7'), 'collapsed qualities are not all dom7')
})
check('collapseChanges(blues-minor) pops the wrap-around pair → 5 units [0,5,0,8,7]', () => {
const prog = kb.blues.progressions.find((p) => p.id === 'blues-minor')
assert(prog, 'blues-minor missing from the registry')
const degs = collapseChanges(prog).map((u) => u.deg).join(',')
assert(degs === '0,5,0,8,7', `collapsed degrees [${degs}] ≠ [0,5,0,8,7] — the cycle wrap (last unit === first unit) must collapse too`)
})
const rank12 = rankRelatedProgressions(LOOP_12BAR_A)
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]
// blues-8bar: same style (+40) + all 3 loop transitions Δ{5,7,10} present (cap 36)
// + identical rebased degree-set {0,5,10} (Jaccard 1.0 ×16) |77| length = 92.
// Edges blues-quickchange (90; same terms but |79|=2 length penalty).
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)`)
// 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); 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)`)
// 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`)
})
// Independent §5-formula replica (expectation-side reimplementation, same
// philosophy as §7's pinned truth table — deliberately NOT calling the
// component's private helpers). `collapse: false` reproduces the pre-L-51
// bug the mandatory collapse step exists to prevent.
function replicaScore(loop, prog, sameStyle, { collapse }) {
const SUFFIX_Q = { '': 'maj', 'm': 'min', '7': 'dom7', 'maj7': 'maj7', 'm7': 'min7' } // enough for the pinned loops
const loopDeg = loopToDegrees(loop)
const loopCanon = canonicalDegrees(loopDeg)
const trans = (units) => units.map((u, i) => {
const b = units[(i + 1) % units.length]
return { d: (((b.deg - u.deg) % 12) + 12) % 12, qa: u.quality, qb: b.quality }
})
const loopT = trans(loop.map((name, i) => ({ deg: loopDeg[i], quality: SUFFIX_Q[name.match(/^[A-G][b#]?(.*)$/)[1]] ?? null })))
const units = collapse
? collapseChanges(prog)
: prog.degrees.map((d, i) => ({ deg: d, quality: prog.qualities[i] ?? null }))
const pCanon = canonicalDegrees(units.map((u) => u.deg))
const seen = new Set()
let shared = 0
for (const t of trans(units)) {
const k = `${t.d}|${t.qa}|${t.qb}`
if (seen.has(k)) continue
seen.add(k)
if (loopT.some((l) => l.d === t.d && (l.qa == null || t.qa == null || l.qa === t.qa) && (l.qb == null || t.qb == null || l.qb === t.qb))) shared++
}
const A = new Set(loopCanon.split(',')), B = new Set(pCanon.split(','))
const inter = [...A].filter((x) => B.has(x)).length
return (pCanon === loopCanon ? 100 : 0) + (sameStyle ? 40 : 0)
+ Math.min(shared * 12, 36) + (inter / (A.size + B.size - inter)) * 16
- Math.abs(loop.length - units.length)
}
check('pin (c): no-collapse counterfactual — raw blues-12bar would score exactly 39 vs 152 with collapse; replica grounded against the live top blues-8bar (92)', () => {
const prog = kb.blues.progressions.find((p) => p.id === 'blues-12bar')
// The flagship "this 12-bar IS their 12-bar" relation: 152 with the mandatory
// collapse step, 39 without it — proving collapse is what fires the +100 shape
// term. blues-12bar is no longer a live *entry* post fix (a) (it's the match,
// excluded), so this is a pure-formula check of the replica, not read off the
// component. Both remain literal so a scoring-constant tweak turns smoke red.
const withCollapse = replicaScore(LOOP_12BAR_A, prog, false, { collapse: true })
assert(withCollapse === 152, `replica with collapse scored ${withCollapse} ≠ 152 — the replica drifted from the §5 formula; fix the replica (or the component changed: re-derive BOTH pins)`)
const noCollapse = replicaScore(LOOP_12BAR_A, prog, false, { collapse: false })
assert(noCollapse === 39, `no-collapse counterfactual scored ${noCollapse} ≠ 39 (36 transitions + 8 half-Jaccard 5 length) — the raw/collapsed relationship changed; re-derive the counterfactual`)
// Ground the replica against the LIVE component via the actual top entry
// (blues-8bar, same-style relative): the replica must reproduce its score.
const prog8 = kb.blues.progressions.find((p) => p.id === 'blues-8bar')
const w8 = replicaScore(LOOP_12BAR_A, prog8, true, { collapse: true })
assert(w8 === 92, `replica of blues-8bar scored ${w8} ≠ 92 — the replica drifted from the §5 formula`)
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')
})
// ─── 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 ───────────────────────────────────────────────────
const total = passed + failures.length
console.log('')
if (failures.length) {
console.error(`✗ Smoke test FAILED — ${failures.length}/${total} check(s) failed:\n`)
for (const f of failures) console.error(' ' + f)
process.exit(1)
}
console.log(`✓ Smoke test passed — ${passed}/${total} checks green (${styleNames.length} styles, ${allIds.size} progressions)`
+ (expectedFails ? ` · ${expectedFails} annotated loop-fixture expected-fail(s) awaiting L-30` : ''))
process.exit(0)