feat(kb): schema + validator + smoke for progression levels and licks (task C-20)

Optional additive shapes: progression level (foundation|intermediate)
and per-pack licks (8-word technique vocab, tab notes string 1-6 /
fret 0-15, ids global with progressions). Validator exports checkLick
as a lib for smoke (KB_VALIDATE_AS_LIB guard); smoke 776 -> 787.
Critic PASS (independent sabotage re-proof: injected bad lick exit 1
with named errors; weakened vocab fails smoke 2/787).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-07-08 17:07:10 +01:00
parent 96dda6a3ef
commit 793e60ab05
3 changed files with 302 additions and 3 deletions
+141 -1
View File
@@ -394,7 +394,147 @@ for (const quality of QUALITIES) {
})
}
// ─── 4. Summary + exit code ───────────────────────────────────────────────────
// ─── 4. Lick + level schema validation (C-20) ─────────────────────────────────
//
// Import the REAL validator in lib mode (KB_VALIDATE_AS_LIB skips the full-KB
// run) and exercise its exported checkLick/LEVELS against in-memory fixtures:
// a good lick must pass, and bad-vocab / bad-string-range / duplicate-id licks
// must FAIL — proving the validator's lick rules actually bite.
console.log('\nLick + level schema (validate-kb lib mode):')
process.env.KB_VALIDATE_AS_LIB = '1'
const kbv = await load('scripts/validate-kb.mjs')
const { checkLick, LEVELS, LICK_TECHNIQUES } = kbv
check('validate-kb exports checkLick / LEVELS / LICK_TECHNIQUES in lib mode', () => {
assert(typeof checkLick === 'function', 'checkLick is not a function')
assert(Array.isArray(LEVELS) && LEVELS.join(',') === 'foundation,intermediate',
`LEVELS must be exactly [foundation, intermediate], got ${JSON.stringify(LEVELS)}`)
assert(Array.isArray(LICK_TECHNIQUES) && LICK_TECHNIQUES.length === 8,
`expected the 8-word technique vocab, got ${JSON.stringify(LICK_TECHNIQUES)}`)
for (const t of ['hammer-on', 'pull-off', 'slide', 'bend', 'double-stop', 'ghost-note', 'chromatic-approach', 'vibrato'])
assert(LICK_TECHNIQUES.includes(t), `vocab missing '${t}'`)
})
// A realistic, fully-valid fixture (style-prefixed id, vocab techniques,
// strings 16, frets 015, per-note techniques present in the summary).
const goodLick = () => ({
id: 'blues-box1-roll',
name: 'B.B. box roll',
level: 'foundation',
chordContext: 'over the I7',
techniques: ['bend', 'vibrato'],
tab: [
{ string: 2, fret: 8 },
{ string: 1, fret: 8, technique: 'bend' },
{ string: 1, fret: 10, technique: 'vibrato' },
{ string: 2, fret: 8 },
],
})
check('good in-memory lick fixture PASSES checkLick (0 errors)', () => {
const errs = checkLick('fixture', goodLick(), 'blues', new Set())
assert(errs.length === 0, `expected clean pass, got: ${errs.join('; ')}`)
})
check('bad-vocab lick FAILS (technique outside the fixed vocabulary)', () => {
const lick = goodLick()
lick.techniques = ['bend', 'tapping'] // 'tapping' is not in the vocab
const errs = checkLick('fixture', lick, 'blues', new Set())
assert(errs.length > 0, 'bad vocab was accepted')
assert(errs.some((e) => e.includes("'tapping'")), `no error names 'tapping': ${errs.join('; ')}`)
})
check('bad per-note technique FAILS (vocab enforced on tab notes too)', () => {
const lick = goodLick()
lick.tab[1].technique = 'sweep-picking'
const errs = checkLick('fixture', lick, 'blues', new Set())
assert(errs.some((e) => e.includes("'sweep-picking'")), `per-note vocab not enforced: ${errs.join('; ')}`)
})
check('bad-string-range lick FAILS (string 7 / string 0 rejected)', () => {
for (const bad of [7, 0]) {
const lick = goodLick()
lick.tab[0].string = bad
const errs = checkLick('fixture', lick, 'blues', new Set())
assert(errs.some((e) => e.includes('string must be an integer 16')),
`string ${bad} was accepted: ${errs.join('; ')}`)
}
})
check('bad-fret lick FAILS (fret 16 / negative / non-integer rejected)', () => {
for (const bad of [16, -1, 3.5]) {
const lick = goodLick()
lick.tab[0].fret = bad
const errs = checkLick('fixture', lick, 'blues', new Set())
assert(errs.some((e) => e.includes('fret must be an integer')),
`fret ${bad} was accepted: ${errs.join('; ')}`)
}
})
check('duplicate-id lick FAILS (ids global across progressions AND licks)', () => {
const ids = new Set()
assert(checkLick('fixture', goodLick(), 'blues', ids).length === 0, 'first insert should pass')
const errs = checkLick('fixture', goodLick(), 'blues', ids) // same id again
assert(errs.some((e) => e.includes('duplicate id')), `duplicate id was accepted: ${errs.join('; ')}`)
// Colliding with an existing PROGRESSION id must also fail (shared namespace).
const progIds = new Set(['blues-box1-roll'])
const errs2 = checkLick('fixture', goodLick(), 'blues', progIds)
assert(errs2.some((e) => e.includes('duplicate id')), 'collision with a progression id was accepted')
})
check('wrong style prefix / bad level / empty tab all FAIL', () => {
const wrongPrefix = goodLick(); wrongPrefix.id = 'jazz-box1-roll'
assert(checkLick('fixture', wrongPrefix, 'blues', new Set()).some((e) => e.includes("starting with 'blues-'")),
'wrong style prefix accepted')
const badLevel = goodLick(); badLevel.level = 'advanced'
assert(checkLick('fixture', badLevel, 'blues', new Set()).some((e) => e.includes('level must be one of')),
"level 'advanced' accepted")
const emptyTab = goodLick(); emptyTab.tab = []
assert(checkLick('fixture', emptyTab, 'blues', new Set()).some((e) => e.includes('tab must be a non-empty')),
'empty tab accepted')
})
check('per-note technique missing from techniques[] summary FAILS (card tags stay honest)', () => {
const lick = goodLick()
lick.tab[2].technique = 'slide' // valid vocab, but not in techniques: [bend, vibrato]
const errs = checkLick('fixture', lick, 'blues', new Set())
assert(errs.some((e) => e.includes("must also appear in the lick's techniques[]")),
`summary consistency not enforced: ${errs.join('; ')}`)
})
// Progression `level` is optional in the KB — assert today's KB either omits it
// or uses a legal value (guards P-20's tagging against typos reaching main).
check("every KB progression 'level', when present, is foundation|intermediate", () => {
for (const styleName of styleNames) {
for (const p of kb[styleName]?.progressions ?? []) {
if (p.level !== undefined) {
assert(LEVELS.includes(p.level), `${styleName}/${p.id}: bad level '${p.level}'`)
}
}
}
})
// Same guard for any licks already shipped in the KB: run the REAL packs'
// licks (if any) through checkLick — the registry and the validator must agree.
check('every KB pack licks[] entry (if any) passes checkLick', () => {
const ids = new Set()
for (const styleName of styleNames) {
const instruments = kb[styleName]?.instruments ?? {}
for (const [inst, pack] of Object.entries(instruments)) {
if (pack?.licks === undefined) continue
assert(Array.isArray(pack.licks) && pack.licks.length,
`${styleName}/${inst}: licks, when present, must be a non-empty array`)
for (const lick of pack.licks) {
const errs = checkLick(`${styleName}/${inst} ${lick?.id ?? '?'}`, lick, styleName, ids)
assert(errs.length === 0, errs.join('; '))
}
}
}
})
// ─── 5. Summary + exit code ───────────────────────────────────────────────────
const total = passed + failures.length
console.log('')
+82 -2
View File
@@ -1,5 +1,10 @@
// 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)
//
// 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.
import { readdirSync, existsSync } from 'node:fs'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { dirname, join } from 'node:path'
@@ -16,6 +21,16 @@ const MIN_PROGRESSIONS = 4
const MIN_PLAYS = 2
const MAX_SPAN = 4
// Optional progression/lick difficulty tags (SCHEMA.md — absent = 'foundation').
export const LEVELS = ['foundation', 'intermediate']
// Fixed technique vocabulary for licks — both the techniques[] summary and each
// tab note's optional technique must come from this list (SCHEMA.md).
export const LICK_TECHNIQUES = [
'hammer-on', 'pull-off', 'slide', 'bend',
'double-stop', 'ghost-note', 'chromatic-approach', 'vibrato',
]
const LICK_MAX_FRET = 15
const errors = []
const err = (where, msg) => errors.push(`${where}: ${msg}`)
@@ -105,10 +120,51 @@ function checkBassPlay(where, play, prog) {
})
}
// Pure lick validation (SCHEMA.md "Licks" section). Returns an array of error
// strings (already where-prefixed); mutates seenIds by adding the lick's id so
// ids stay globally unique across ALL progressions and licks (same rule as
// progression ids). Exported for reuse by scripts/smoke.mjs.
export function checkLick(where, lick, style, seenIds) {
const out = []
const e = (msg) => out.push(`${where}: ${msg}`)
if (!lick || typeof lick !== 'object') { e('lick must be an object'); return out }
if (typeof lick.id !== 'string' || !lick.id.startsWith(`${style}-`))
e(`id must be a string starting with '${style}-'`)
else if (seenIds.has(lick.id)) e(`duplicate id '${lick.id}' (ids are global across progressions AND licks)`)
else seenIds.add(lick.id)
if (!lick.name) e('name missing')
if (!LEVELS.includes(lick.level)) e(`level must be one of ${LEVELS.join(' | ')}, got '${lick.level}'`)
if (typeof lick.chordContext !== 'string' || !lick.chordContext)
e("chordContext missing (which chord/station the lick fits, e.g. 'dom7' or 'over the I7')")
const summary = new Set()
if (!Array.isArray(lick.techniques)) e('techniques must be an array (may be empty for a plain-picked lick)')
else for (const t of lick.techniques) {
if (!LICK_TECHNIQUES.includes(t)) e(`unknown technique '${t}' — allowed: ${LICK_TECHNIQUES.join(', ')}`)
summary.add(t)
}
if (!Array.isArray(lick.tab) || !lick.tab.length) { e('tab must be a non-empty ordered array of notes'); return out }
lick.tab.forEach((note, i) => {
const nw = `tab[${i}]`
if (!note || typeof note !== 'object') return e(`${nw} must be an object {string, fret, technique?}`)
if (!Number.isInteger(note.string) || note.string < 1 || note.string > 6)
e(`${nw} string must be an integer 16 (1 = high e, 6 = low E), got ${JSON.stringify(note.string)}`)
if (!Number.isInteger(note.fret) || note.fret < 0 || note.fret > LICK_MAX_FRET)
e(`${nw} fret must be an integer 0${LICK_MAX_FRET}, got ${JSON.stringify(note.fret)}`)
if (note.technique !== undefined) {
if (!LICK_TECHNIQUES.includes(note.technique))
e(`${nw} unknown technique '${note.technique}' — allowed: ${LICK_TECHNIQUES.join(', ')}`)
else if (!summary.has(note.technique))
e(`${nw} technique '${note.technique}' must also appear in the lick's techniques[] summary`)
}
})
return out
}
async function loadModule(path) {
return (await import(pathToFileURL(path).href)).default
}
async function main() {
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) }
@@ -116,7 +172,7 @@ const registry = existsSync(join(KB, 'index.js')) ? await loadModule(join(KB, 'i
if (!registry) err('kb/index.js', 'registry missing')
const allIds = new Set()
let totals = { styles: 0, progressions: 0, plays: 0 }
let totals = { styles: 0, progressions: 0, plays: 0, licks: 0 }
for (const style of styleDirs) {
const dir = join(KB, style)
@@ -147,6 +203,9 @@ for (const style of styleDirs) {
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')
// Optional difficulty tag — absent means 'foundation' (consumer default).
if (p.level !== undefined && !LEVELS.includes(p.level))
err(pw, `level, when present, must be one of ${LEVELS.join(' | ')} — got '${p.level}'`)
}
totals.styles++; totals.progressions += progs.length
@@ -181,6 +240,20 @@ for (const style of styleDirs) {
})
})
}
// Optional structured licks (SCHEMA.md "Licks") — a top-level `licks` key
// on the instrument pack. Absent is fine; when present it must validate.
if (pack.licks !== undefined) {
if (!Array.isArray(pack.licks) || !pack.licks.length) {
err(iw, 'licks, when present, must be a non-empty array')
} else {
pack.licks.forEach((lick, li) => {
const lkw = `${iw} licks[${li}] "${lick?.id ?? '?'}"`
for (const m of checkLick(lkw, lick, style, allIds)) errors.push(m)
})
totals.licks += pack.licks.length
}
}
}
}
@@ -189,4 +262,11 @@ if (errors.length) {
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`)
const lickNote = totals.licks ? `, ${totals.licks} licks` : ''
console.log(`✓ KB valid — ${totals.styles} style(s), ${totals.progressions} progressions, ${totals.plays} plays${lickNote}`)
}
// Run the full validation unless imported as a library (see header comment).
// Safe-by-default: an unset env var always means "run" — the gate can't be
// skipped by a path-comparison quirk.
if (!process.env.KB_VALIDATE_AS_LIB) await main()
+79
View File
@@ -46,12 +46,20 @@ export default [
mode: 'major', // major | minor | dorian | mixolydian | …
songs: ['Autumn Leaves'],
tip: 'One transferable idea.',
level: 'intermediate', // OPTIONAL: 'foundation' | 'intermediate'
},
]
```
48 progressions per style. Cross-check `docs/progression-repertoire.md` §1.
**`level` (optional).** Tags the progression's difficulty for the in-app level
filter. Allowed values: `'foundation'` (the style's bread-and-butter loops) or
`'intermediate'` (secondary dominants, chained iiVs, backdoor, borrowed
chords…). **Omitting the field is fine and means `'foundation'`** — consumers
treat an absent `level` as foundation, so existing styles need no edits. The
validator only checks the value when the field is present.
## Instrument packs
Common envelope:
@@ -126,6 +134,76 @@ Voicings are degree recipes resolved through the chord quality. Degrees: `'1' '3
}
```
## Licks (optional, guitar first)
A style's instrument pack may also teach short, named licks — the ordered-note
phrases the Licks & Techniques cards render. **The whole section is optional**:
a pack without licks is complete and valid.
**How to register licks:** add a `licks` array as one more top-level key on the
instrument pack's default export (next to `styleIntro`/`comping`/`plays`/`improv`
in e.g. `src/data/kb/blues/guitar.js`). No change to `src/data/kb/index.js` is
needed — the registry already exposes the whole pack, and the UI reads
`kb[style].instruments.guitar.licks ?? []`.
```js
export default {
styleIntro: '…',
comping: [ /* … */ ],
plays: { /* … */ },
improv: { /* … */ },
// OPTIONAL — structured licks (this section):
licks: [
{
id: 'blues-box1-bb-answer', // '<style>-<slug>', globally unique
// (shares ONE id namespace with progression ids)
name: 'B.B. box answer phrase',
level: 'foundation', // 'foundation' | 'intermediate' (required)
chordContext: 'over the I7', // which chord/station it fits — free text,
// e.g. 'dom7' or 'over the V7 turnaround'
techniques: ['bend', 'vibrato'], // summary tags, from the fixed vocabulary below
source: 'the B.B. King box, e.g. "The Thrill Is Gone" fills', // recommended attribution
tab: [ // the ORDERED note sequence (played first → last)
{ string: 2, fret: 8 }, // string 1 = high e … 6 = low E
{ string: 1, fret: 8, technique: 'bend' }, // fret 0 (open) … 15
{ string: 1, fret: 10, technique: 'vibrato' }, // technique is optional per note
{ string: 2, fret: 8 },
],
},
],
}
```
Field rules (all enforced by `node scripts/validate-kb.mjs` when `licks` is present):
| Field | Rule |
|---|---|
| `id` | starts with `'<style>-'`; globally unique across **all** progression and lick ids in the whole KB |
| `name` | required, non-empty |
| `level` | `'foundation'` or `'intermediate'` — nothing else |
| `chordContext` | required, non-empty string — tells the player *where* the lick lands |
| `techniques` | array; every entry from the fixed vocabulary below (empty array = plain-picked) |
| `tab` | non-empty ordered array of `{string, fret, technique?}` |
| `tab[].string` | integer 16 (**1 = high e, 6 = low E** — standard tab convention; note `rootStr` in shapes counts the same way) |
| `tab[].fret` | integer 015 (0 = open string) |
| `tab[].technique` | optional; from the vocabulary; must **also** appear in the lick's `techniques[]` summary so card tags stay honest |
| `source` | optional but recommended — name where the lick comes from (checklist: nothing invented) |
**Fixed technique vocabulary** (both for `techniques[]` and per-note `technique`
— the validator rejects anything else):
`hammer-on` · `pull-off` · `slide` · `bend` · `double-stop` · `ghost-note` · `chromatic-approach` · `vibrato`
Like shapes, licks are **key-agnostic in spirit**: write them where they sit in
the style's home position and say in `chordContext` which chord they fit; the
tab renders as absolute string/fret positions.
Note: the older freeform `improv.licks` (prose `{tab/notation, description,
over, source}`) is unchanged and still welcome — it feeds the improv text
section. This top-level `licks` array is the *structured* shape that the lick
cards render and the validator checks.
## Musician checklist (self-review before committing)
- [ ] Plays per progression genuinely differ in register/density/technique
@@ -133,4 +211,5 @@ Voicings are degree recipes resolved through the chord quality. Degrees: `'1' '3
- [ ] The style is recognizable from the rhythm descriptions alone (bossa ≠ jazz with new labels)
- [ ] Every tip teaches a transferable idea (voice leading, register, space), not just "play this"
- [ ] Songs/licks have sources; nothing invented
- [ ] Lick techniques use only the fixed vocabulary; every lick is playable as written (strings 16, frets 015, in order)
- [ ] `node scripts/validate-kb.mjs` green; `npm run build` green