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:
+141
-1
@@ -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 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 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
@@ -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 1–6 (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()
|
||||
|
||||
@@ -46,12 +46,20 @@ export default [
|
||||
mode: 'major', // major | minor | dorian | mixolydian | …
|
||||
songs: ['Autumn Leaves'],
|
||||
tip: 'One transferable idea.',
|
||||
level: 'intermediate', // OPTIONAL: 'foundation' | 'intermediate'
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
4–8 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 ii–Vs, 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 1–6 (**1 = high e, 6 = low E** — standard tab convention; note `rootStr` in shapes counts the same way) |
|
||||
| `tab[].fret` | integer 0–15 (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 1–6, frets 0–15, in order)
|
||||
- [ ] `node scripts/validate-kb.mjs` green; `npm run build` green
|
||||
|
||||
Reference in New Issue
Block a user