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>
This commit is contained in:
+75
-1
@@ -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('')
|
||||
|
||||
+45
-10
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user