a311895d50
When the loop matches a KB style (rolled or live-detected),
RelatedProgressions leads with that style's other progressions under a
"Try these in {style}" header, labelled by data-derived character
(minor version / shorter form / extended form / reharmonized) computed
from mode/bars/qualities — no cross-style jumping. The role phrase is
suppressed unless the sibling is genuinely related (same changes or a
shared transition), so no false "variation" claim. When no style is
locked, today's cross-style list is preserved byte-for-byte. Smoke
893/893: §8 re-pinned (scores stable 92/156, labels moved) plus two
gate-added durable assertions (finding-B suppression + no-match
fallback). Critic PASS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1501 lines
78 KiB
JavaScript
1501 lines
78 KiB
JavaScript
// 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)
|
||
}
|
||
|
||
// --- ii–V–I → 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'] // ii–V–I in C → degree shape [0,5,10]
|
||
const r = matchLoopToProgression(loop, index)
|
||
|
||
check('ii–V–I [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("ii–V–I 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 ii–V–I matches the SAME id with non-zero rotation ----------
|
||
{
|
||
const loop = ['G7', 'Cmaj7', 'Dm7'] // V–I–ii: same loop, rotated by 1
|
||
const r = matchLoopToProgression(loop, index)
|
||
|
||
check('rotation [G7,Cmaj7,Dm7] matches the SAME id as ii–V–I', () => {
|
||
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 ii–V–I 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'`)
|
||
})
|
||
}
|
||
|
||
// --- I–V–vi–IV axis loop → axis progression (pop may be absent) ----------------
|
||
{
|
||
const loop = ['C', 'G', 'Am', 'F'] // I–V–vi–IV, 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 I–V–vi–IV; 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(ii–V–I) 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 1–6, frets 0–15, 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 1–6')),
|
||
`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 25–36 > 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…len−1); it passes iff
|
||
// detectRepeatingProgression returns exactly that form at ALL offsets, AND the
|
||
// collapsed length is 2–8. 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 2–8', () => {
|
||
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 2–8`)
|
||
}
|
||
}
|
||
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, |7−9|=2 length
|
||
// penalty). This is the exact flip the pre-fix note here foretold.
|
||
// (b) live ii–V–I 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 ii–V–I
|
||
|
||
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) − |7−7| length = 92.
|
||
// Edges blues-quickchange (90; same terms but |7−9|=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): ii–V–I 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.
|
||
// A7–E7 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 (A7–E7)`)
|
||
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 A7–E7')
|
||
assert(siblingRole(distant.progression, activeProg) !== null,
|
||
'precondition: siblingRole() ALONE would label funk-smooth-loop — the genuine gate is what must suppress it')
|
||
assert(distant.role === null,
|
||
`finding-B breached: distant sibling carries role '${distant.role}' — the \`genuine\` gate was dropped (false variation claim)`)
|
||
})
|
||
|
||
// L-72 finding-A / no-match fallback (Critic gate hardening, design §6): when the
|
||
// loop matches NO KB progression, activeStyle is null and the panel is the
|
||
// pre-L-72 flat cross-style list — secondary always empty, cross-style rows keep
|
||
// their §5 annotation, and NO role phrases appear. This 8-chord A blues loop is
|
||
// not a canonical collapsed form, so it stays unmatched while still drawing
|
||
// cross-style relatives. Bites if the null path grows an activeStyle default or
|
||
// leaks roles onto cross-style rows.
|
||
check('no-match fallback: unmatched loop → activeStyle null, secondary empty, flat cross-style list with annotations (no roles)', () => {
|
||
const rNo = rankRelatedProgressions(['A7', 'D7', 'A7', 'A7', 'E7', 'D7', 'A7', 'E7'])
|
||
assert(rNo, 'ranking returned null for a parseable loop')
|
||
assert(rNo.match.matched === false, `precondition: expected NO match, got ${rNo.match.id}`)
|
||
assert(rNo.activeStyle === null, `no-match: activeStyle must be null, got ${rNo.activeStyle}`)
|
||
assert(rNo.secondary.length === 0, 'no-match: secondary must be empty')
|
||
assert(rNo.primary.length > 0, 'precondition: expected cross-style relatives above the floor')
|
||
assert(rNo.primary.every((e) => e.role === null), 'no-match: cross-style rows must carry NO role phrase')
|
||
assert(rNo.primary.every((e) => typeof e.annotation === 'string' && e.annotation.length > 0),
|
||
'no-match: cross-style rows must keep their §5 annotation')
|
||
})
|
||
|
||
// ─── 9. Summary + exit code ───────────────────────────────────────────────────
|
||
|
||
const total = passed + failures.length
|
||
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)
|