Files
JamBuddy/scripts/smoke.mjs
T
vadimwit b4e8f12539 feat(kb): validator enforces the piano hand-span rule (task C-22)
checkPianoRecipe stacks each hand low-to-high (nearest strictly above,
repeated pc -> octave up) and fails any hand spanning > 15 semitones
(minor 10th, SCHEMA rule 3); errors name style/prog/play/chord/hand/
span; refactored to a pure exported checker surfacing all errors per
recipe. Smoke +6 checks -> 799. Independent gate PASS (injection
surfaces 10 named errors exit 1; weakened gate caught behaviorally).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 00:27:25 +01:00

622 lines
28 KiB
JavaScript
Raw 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'
// 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 } = 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 checkLick — the registry and the validator must agree.
check('every KB pack licks[] entry (if any) passes checkLick', () => {
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`)
for (const lick of pack.licks) {
const errs = checkLick(`${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)`)
})
// ─── 6. 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)`)
process.exit(0)