From 7181a138e42e9488271b189bb1043efba66010e2 Mon Sep 17 00:00:00 2001 From: vadimwit Date: Fri, 12 Jun 2026 12:46:08 +0100 Subject: [PATCH] kb: bootstrap foundation + jazz guitar gold standard SCHEMA.md authoring contract, validate-kb.mjs quality gate (pitch-class verification of shapes against chord qualities), registry, and the jazz style: 5 progressions x 2 guitar plays (shells + drop-2), comping rhythms, improv guidance, sourced licks. Validator and vite build green. Co-Authored-By: Claude Fable 5 --- docs/kb-backlog.md | 4 +- scripts/validate-kb.mjs | 189 +++++++++++++++++++++++++++ src/data/kb/SCHEMA.md | 133 +++++++++++++++++++ src/data/kb/index.js | 13 ++ src/data/kb/jazz/guitar.js | 216 +++++++++++++++++++++++++++++++ src/data/kb/jazz/meta.js | 7 + src/data/kb/jazz/progressions.js | 57 ++++++++ 7 files changed, 617 insertions(+), 2 deletions(-) create mode 100644 scripts/validate-kb.mjs create mode 100644 src/data/kb/SCHEMA.md create mode 100644 src/data/kb/index.js create mode 100644 src/data/kb/jazz/guitar.js create mode 100644 src/data/kb/jazz/meta.js create mode 100644 src/data/kb/jazz/progressions.js diff --git a/docs/kb-backlog.md b/docs/kb-backlog.md index 5ab01fc..55a75c9 100644 --- a/docs/kb-backlog.md +++ b/docs/kb-backlog.md @@ -6,13 +6,13 @@ The queue for the `/kb-expand` loop. One cell per session, top-to-bottom. Protoc | # | Cell | Status | |---|---|---| -| 0 | Bootstrap: `src/data/kb/` + `SCHEMA.md` + `scripts/validate-kb.mjs` + `kb/index.js` + **jazz/guitar gold standard** | todo | +| 0 | Bootstrap: `src/data/kb/` + `SCHEMA.md` + `scripts/validate-kb.mjs` + `kb/index.js` + **jazz/guitar gold standard** | done (2026-06-12, iteration 1) | ## Guitar | # | Style | Status | |---|---|---| -| 1 | Jazz (part of bootstrap) | todo | +| 1 | Jazz (part of bootstrap) | done (2026-06-12, 5 progressions × 2 plays, validator ✓) | | 2 | Blues | todo | | 3 | Rock | todo | | 4 | Bossa Nova | todo | diff --git a/scripts/validate-kb.mjs b/scripts/validate-kb.mjs new file mode 100644 index 0000000..28b5494 --- /dev/null +++ b/scripts/validate-kb.mjs @@ -0,0 +1,189 @@ +// KB quality gate — validates src/data/kb/ against the contract in src/data/kb/SCHEMA.md. +// Run: node scripts/validate-kb.mjs (exit 1 on any error) +import { readdirSync, existsSync } from 'node:fs' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { dirname, join } from 'node:path' +import { CHORD_TYPES } from '../src/lib/theory.js' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const KB = join(ROOT, 'src', 'data', 'kb') + +const MODES = ['major', 'minor', 'dorian', 'phrygian', 'lydian', 'mixolydian'] +const OPEN_PC = [4, 9, 2, 7, 11, 4] // EADGBe low-E first +const PERFECT_FIFTH = 7 +const BASS_TOKENS = ['R', 'b3', '3', '5', '6', 'b7', '7', '9', 'O', 'chrom>', 'chrom<', '5>', 'x', '-'] +const MIN_PROGRESSIONS = 4 +const MIN_PLAYS = 2 +const MAX_SPAN = 4 + +const errors = [] +const err = (where, msg) => errors.push(`${where}: ${msg}`) + +// Resolve a degree string ('3', 'b9', '13'…) to a pitch class relative to the +// chord root, through the quality's intervals where the degree is quality-dependent. +function resolveDegree(deg, quality) { + const iv = CHORD_TYPES[quality].intervals + const fixed = { 1: 0, b9: 1, 9: 2, '#9': 3, 11: 5, '#11': 6, b5: 6, b13: 8, 13: 9, 6: 9, b3: 3, b7: 10 } + if (deg === '3') return iv.find(i => i === 3 || i === 4) ?? iv.find(i => i === 2 || i === 5) ?? null + if (deg === '5') return iv.find(i => i === 6 || i === 7 || i === 8) ?? null + if (deg === '7') return iv.find(i => i === 9 || i === 10 || i === 11) ?? null + return fixed[deg] ?? null +} + +function checkGuitarShape(where, chordStep, quality) { + const { shape, extensions = [] } = chordStep + if (!shape) return err(where, 'missing shape') + const strings = shape.offsets ?? shape.frets + if (!Array.isArray(strings) || strings.length !== 6) + return err(where, 'offsets/frets must be an array of 6 (low E first)') + const isMovable = !!shape.offsets + + if (isMovable) { + if (!(shape.rootStr >= 1 && shape.rootStr <= 6)) return err(where, `bad rootStr ${shape.rootStr}`) + if (strings[6 - shape.rootStr] !== 0) return err(where, 'offset on the root string must be 0') + } else { + if (!(shape.onlyRoot >= 0 && shape.onlyRoot <= 11)) return err(where, 'open shape needs onlyRoot (pc 0-11)') + } + + const fretted = strings.filter(f => f !== 'x') + if (fretted.some(f => !Number.isInteger(f) || f < -2 || f > 15)) + return err(where, `bad fret values: ${JSON.stringify(strings)}`) + const nonOpen = fretted.filter(f => f !== 0) + if (nonOpen.length && Math.max(...nonOpen) - Math.min(...nonOpen) > MAX_SPAN) + return err(where, `fret span > ${MAX_SPAN} — not intermediate-friendly`) + + // Pitch-class verification: every sounded note must belong to the chord + // (quality intervals + declared extensions); defining tones must be present. + const iv = CHORD_TYPES[quality].intervals + const allowed = new Set(iv) + for (const ext of extensions) { + const pc = resolveDegree(ext, quality) + if (pc === null) return err(where, `unresolvable extension '${ext}' for ${quality}`) + allowed.add(pc) + } + const rootRel = isMovable ? (12 - OPEN_PC[6 - shape.rootStr]) % 12 : null + const sounded = new Set() + strings.forEach((f, i) => { + if (f === 'x') return + const pc = isMovable + ? (OPEN_PC[i] + rootRel + f + 24) % 12 + : (OPEN_PC[i] + f - shape.onlyRoot + 24) % 12 + sounded.add(pc) + }) + for (const pc of sounded) + if (!allowed.has(pc)) return err(where, `sounded pc ${pc} is not in ${quality} (+ext) — shape misspells the chord`) + const required = iv.filter(i => i !== PERFECT_FIFTH && !(chordStep.rootless && i === 0)) + for (const pc of required) + if (!sounded.has(pc)) return err(where, `defining tone pc ${pc} of ${quality} missing from shape`) +} + +function checkPianoRecipe(where, chordStep, quality) { + const { recipe } = chordStep + if (!recipe) return err(where, 'missing recipe') + 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`) + for (const d of degs) + if (resolveDegree(d, quality) === null) err(where, `unresolvable degree '${d}' for ${quality}`) + } + if (recipe.LH === undefined && recipe.RH === undefined) err(where, 'recipe needs LH and/or RH') +} + +function checkBassPlay(where, play, prog) { + const totalBars = prog.bars.reduce((a, b) => a + b, 0) + if (!Array.isArray(play.bars) || play.bars.length !== totalBars) + return err(where, `bars length ${play.bars?.length} ≠ progression total ${totalBars}`) + play.bars.forEach((bar, i) => { + if (!Array.isArray(bar.beats) || !bar.beats.length) return err(`${where} bar ${i}`, 'missing beats') + for (const b of bar.beats) + if (!BASS_TOKENS.includes(b)) err(`${where} bar ${i}`, `unknown beat token '${b}'`) + }) +} + +async function loadModule(path) { + return (await import(pathToFileURL(path).href)).default +} + +const styleDirs = readdirSync(KB, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name) +if (!styleDirs.length) { console.error('No style folders in src/data/kb/'); process.exit(1) } + +const registry = existsSync(join(KB, 'index.js')) ? await loadModule(join(KB, 'index.js')) : null +if (!registry) err('kb/index.js', 'registry missing') + +const allIds = new Set() +let totals = { styles: 0, progressions: 0, plays: 0 } + +for (const style of styleDirs) { + const dir = join(KB, style) + const w = `kb/${style}` + if (registry && !registry[style]) err('kb/index.js', `style '${style}' not registered`) + + const meta = existsSync(join(dir, 'meta.js')) ? await loadModule(join(dir, 'meta.js')) : null + if (!meta) { err(w, 'meta.js missing'); continue } + if (meta.id !== style) err(`${w}/meta.js`, `id '${meta.id}' ≠ folder '${style}'`) + for (const f of ['label', 'feel', 'character']) if (!meta[f]) err(`${w}/meta.js`, `missing ${f}`) + + const progs = existsSync(join(dir, 'progressions.js')) ? await loadModule(join(dir, 'progressions.js')) : null + if (!Array.isArray(progs) || !progs.length) { err(w, 'progressions.js missing/empty'); continue } + if (progs.length < MIN_PROGRESSIONS) err(w, `${progs.length} progressions < ${MIN_PROGRESSIONS}`) + + const progById = {} + for (const p of progs) { + const pw = `${w}/progressions.js [${p.id}]` + if (!p.id?.startsWith(`${style}-`)) err(pw, `id must start with '${style}-'`) + if (allIds.has(p.id)) err(pw, 'duplicate id'); allIds.add(p.id) + progById[p.id] = p + const n = p.degrees?.length + if (!n) { err(pw, 'degrees missing'); continue } + for (const [field, arr] of [['rn', p.rn], ['qualities', p.qualities], ['bars', p.bars]]) + if (!Array.isArray(arr) || arr.length !== n) err(pw, `${field} length ≠ degrees length`) + if (p.degrees.some(d => !Number.isInteger(d) || d < 0 || d > 11)) err(pw, 'degrees must be ints 0-11') + for (const q of p.qualities ?? []) if (!CHORD_TYPES[q]) err(pw, `unknown quality '${q}'`) + if (!MODES.includes(p.mode)) err(pw, `unknown mode '${p.mode}'`) + if (!Array.isArray(p.songs) || !p.songs.length) err(pw, 'songs missing') + if (!p.tip) err(pw, 'tip missing') + } + totals.styles++; totals.progressions += progs.length + + for (const inst of ['guitar', 'piano', 'bass']) { + const file = join(dir, `${inst}.js`) + if (!existsSync(file)) continue + const pack = await loadModule(file) + const iw = `${w}/${inst}.js` + if (!pack.styleIntro) err(iw, 'styleIntro missing') + if (!Array.isArray(pack.comping) || !pack.comping.length) err(iw, 'comping missing') + if (inst !== 'bass' && (!pack.improv?.scales?.length || !pack.improv?.targetNotes)) + err(iw, 'improv.scales / improv.targetNotes required') + + for (const p of progs) + if ((pack.plays?.[p.id]?.length ?? 0) < MIN_PLAYS) + err(iw, `progression '${p.id}' has < ${MIN_PLAYS} plays`) + + for (const [pid, plays] of Object.entries(pack.plays ?? {})) { + const prog = progById[pid] + if (!prog) { err(iw, `plays key '${pid}' is not a progression of this style`); continue } + plays.forEach((play, pi) => { + const lw = `${iw} ${pid} play[${pi}] "${play.label ?? '?'}"` + if (!play.label || !play.level || !play.tips) err(lw, 'label/level/tips required') + totals.plays++ + if (inst === 'bass') return checkBassPlay(lw, play, prog) + if (!Array.isArray(play.chords) || play.chords.length !== prog.degrees.length) + return err(lw, `chords length ≠ progression length ${prog.degrees.length}`) + 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]) + }) + }) + } + } +} + +if (errors.length) { + console.error(`✗ KB validation failed — ${errors.length} error(s):\n`) + for (const e of errors) console.error(' ' + e) + process.exit(1) +} +console.log(`✓ KB valid — ${totals.styles} style(s), ${totals.progressions} progressions, ${totals.plays} plays`) diff --git a/src/data/kb/SCHEMA.md b/src/data/kb/SCHEMA.md new file mode 100644 index 0000000..e662dc1 --- /dev/null +++ b/src/data/kb/SCHEMA.md @@ -0,0 +1,133 @@ +# KB Authoring Contract + +Every knowledgebase cell must conform to this schema and pass `node scripts/validate-kb.mjs`. The gold-standard exemplar is `jazz/` — imitate it. Background and rationale: `docs/kb-plan.md`. + +## Layout + +``` +src/data/kb/