diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index ee0c04d..e016b4f 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -534,7 +534,81 @@ check('every KB pack licks[] entry (if any) passes checkLick', () => { } }) -// ─── 5. Summary + exit code ─────────────────────────────────────────────────── +// ─── 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('') diff --git a/scripts/validate-kb.mjs b/scripts/validate-kb.mjs index d56e8a0..ba67f40 100644 --- a/scripts/validate-kb.mjs +++ b/scripts/validate-kb.mjs @@ -2,9 +2,9 @@ // Run: node scripts/validate-kb.mjs (exit 1 on any error) // // Lib mode: scripts/smoke.mjs imports this file with KB_VALIDATE_AS_LIB=1 set to -// reuse the exported pure checks (checkLick, LEVELS, LICK_TECHNIQUES) against -// in-memory fixtures — same logic, no copy. When the env var is absent the -// script runs the full KB validation as before. +// reuse the exported pure checks (checkLick, checkPianoRecipe, LEVELS, +// LICK_TECHNIQUES, MAX_HAND_SPAN) against in-memory fixtures — same logic, no +// copy. When the env var is absent the script runs the full KB validation as before. import { readdirSync, existsSync } from 'node:fs' import { fileURLToPath, pathToFileURL } from 'node:url' import { dirname, join } from 'node:path' @@ -95,18 +95,53 @@ function checkGuitarShape(where, chordStep, quality) { if (!sounded.has(pc)) return err(where, `defining tone pc ${pc} of ${quality} missing from shape`) } -function checkPianoRecipe(where, chordStep, quality) { +// Piano hand-span rule (SCHEMA.md rule 3: "one hand per recipe stays within a +// 10th"). Enforced as ≤ 15 semitones — a minor 10th, the widest reading of +// "a 10th" — so the hand-verified 14-semitone ø11 rootless voicing in +// jazz/piano.js (P-22) stays legal while anything wider fails. Task C-22. +export const MAX_HAND_SPAN = 15 + +// Resolve one hand's degree list to stacked absolute semitone offsets per the +// documented convention (src/data/kb/jazz/piano.js header, ~line 14): order +// inside a hand = voicing order low→high, each note placed in the nearest +// position strictly above the previous (a repeated pitch class = octave up). +// Returns null if any degree is unresolvable (reported separately by caller). +function stackHand(degs, quality) { + const notes = [] + for (const d of degs) { + const pc = resolveDegree(d, quality) + if (pc === null) return null + if (!notes.length) { notes.push(pc); continue } + const prev = notes[notes.length - 1] + const step = (pc - (prev % 12) + 12) % 12 + notes.push(prev + (step === 0 ? 12 : step)) + } + return notes +} + +// Pure piano-recipe validation. Returns an array of where-prefixed error +// strings (empty = valid). Exported for reuse by scripts/smoke.mjs (lib mode). +export function checkPianoRecipe(where, chordStep, quality) { + const out = [] + const e = (msg) => out.push(`${where}: ${msg}`) + if (!CHORD_TYPES[quality]) { e(`unknown quality '${quality}'`); return out } const { recipe } = chordStep - if (!recipe) return err(where, 'missing recipe') + if (!recipe) { e('missing recipe'); return out } for (const hand of ['LH', 'RH']) { const degs = recipe[hand] if (degs === undefined) continue - if (!Array.isArray(degs) || !degs.length) return err(where, `${hand} must be a non-empty array`) - if (degs.length > 5) return err(where, `${hand} has ${degs.length} notes — one hand, max 5`) + if (!Array.isArray(degs) || !degs.length) { e(`${hand} must be a non-empty array`); continue } + if (degs.length > 5) e(`${hand} has ${degs.length} notes — one hand, max 5`) for (const d of degs) - if (resolveDegree(d, quality) === null) err(where, `unresolvable degree '${d}' for ${quality}`) + if (resolveDegree(d, quality) === null) e(`unresolvable degree '${d}' for ${quality}`) + const stacked = stackHand(degs, quality) + if (stacked === null) continue // unresolvable degree already reported + const span = stacked[stacked.length - 1] - stacked[0] + if (span > MAX_HAND_SPAN) + e(`${hand} [${degs.join(' ')}] spans ${span} semitones stacked low→high — max ${MAX_HAND_SPAN} (a minor 10th; SCHEMA rule 3, one hand within a 10th)`) } - if (recipe.LH === undefined && recipe.RH === undefined) err(where, 'recipe needs LH and/or RH') + if (recipe.LH === undefined && recipe.RH === undefined) e('recipe needs LH and/or RH') + return out } function checkBassPlay(where, play, prog) { @@ -236,7 +271,7 @@ for (const style of styleDirs) { play.chords.forEach((step, ci) => { const cw = `${lw} chord[${ci}] (${prog.rn[ci]})` if (inst === 'guitar') checkGuitarShape(cw, step, prog.qualities[ci]) - else checkPianoRecipe(cw, step, prog.qualities[ci]) + else for (const m of checkPianoRecipe(cw, step, prog.qualities[ci])) errors.push(m) }) }) }