diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index ed87977..37840c7 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -608,6 +608,168 @@ check('every KB piano pack recipe passes checkPianoRecipe (span ≤ 15 everywher console.log(` (${recipes} live piano recipes checked)`) }) +// ─── 5b. Bass play schema (C-41) ────────────────────────────────────────────── +// +// Same lib-mode pattern as §4/§5: exercise the exported checkBassPlay against +// in-memory fixtures — a realistic boogie play must pass, and each malformed +// variant must FAIL with the specific error — proving the bass rules bite +// before any bass cell (P-41) is authored against them. + +console.log('\nBass play schema (validate-kb lib mode):') + +const { checkBassPlay, MIN_PLAYS_BASS, BASS_APPROACHES, BASS_MAX_OFFSET } = kbv + +check('validate-kb exports checkBassPlay / MIN_PLAYS_BASS(=1) / BASS_APPROACHES / BASS_MAX_OFFSET(=19)', () => { + assert(typeof checkBassPlay === 'function', 'checkBassPlay is not a function') + assert(MIN_PLAYS_BASS === 1, `MIN_PLAYS_BASS must be 1 (SCHEMA bass coverage floor), got ${MIN_PLAYS_BASS}`) + assert(Array.isArray(BASS_APPROACHES) && BASS_APPROACHES.join(',') === 'chrom-below,chrom-above,fifth-of-next', + `BASS_APPROACHES must be exactly [chrom-below, chrom-above, fifth-of-next], got ${JSON.stringify(BASS_APPROACHES)}`) + assert(BASS_MAX_OFFSET === 19, `BASS_MAX_OFFSET must be 19 (an octave + a fifth), got ${BASS_MAX_OFFSET}`) +}) + +// A 2-step I7→IV7 fixture progression; the IV7 gets 2 bars (exercises the +// per-step beat range). The good play: classic boogie cell + a chromatic walk. +const bassProg = () => ({ + id: 'blues-fixture', rn: ['I7', 'IV7'], degrees: [0, 5], + qualities: ['dom7', 'dom7'], bars: [1, 2], +}) +const goodBassPlay = () => ({ + label: 'Boogie cell', + level: 'foundation', + feel: 'swung 8ths, locked with the kick', + tips: 'The same cell moves to the IV unchanged — degrees, not frets.', + chords: [ + { + pattern: [ + { deg: '1', beat: 1 }, + { deg: '3', beat: 2 }, + { deg: '5', beat: 3, technique: 'ghost-note' }, + { approach: 'chrom-below', beat: 4 }, + ], + note: 'walk up into the IV', + }, + { + pattern: [ + { deg: '1', beat: 1 }, + { deg: '6', beat: 3 }, + { deg: 'b7', beat: 5 }, + { deg: '1', octave: 1, beat: 7 }, + { approach: 'fifth-of-next', beat: 8 }, + ], + }, + ], +}) + +check('good boogie fixture PASSES checkBassPlay (0 errors)', () => { + const errs = checkBassPlay('fixture', goodBassPlay(), bassProg()) + assert(errs.length === 0, `expected clean pass, got: ${errs.join('; ')}`) +}) + +check("unresolvable degree FAILS ('2' is not a bass degree)", () => { + const play = goodBassPlay() + play.chords[0].pattern[1] = { deg: '2', beat: 2 } + const errs = checkBassPlay('fixture', play, bassProg()) + assert(errs.some((e) => e.includes("unresolvable degree '2'")), `deg '2' was accepted: ${errs.join('; ')}`) +}) + +check('numeric deg FAILS (degrees are strings, one convention with piano)', () => { + const play = goodBassPlay() + play.chords[0].pattern[0] = { deg: 1, beat: 1 } + const errs = checkBassPlay('fixture', play, bassProg()) + assert(errs.some((e) => e.includes('deg must be a degree STRING')), `numeric deg accepted: ${errs.join('; ')}`) +}) + +check("octave cap bites (b7 octave 1 = 22 semitones > 19) and bad octave values FAIL", () => { + const play = goodBassPlay() + play.chords[1].pattern[3] = { deg: 'b7', octave: 1, beat: 7 } + const errs = checkBassPlay('fixture', play, bassProg()) + assert(errs.some((e) => e.includes(`max ${BASS_MAX_OFFSET}`)), `22-semitone offset accepted: ${errs.join('; ')}`) + const play2 = goodBassPlay() + play2.chords[1].pattern[3] = { deg: '1', octave: 2, beat: 7 } + const errs2 = checkBassPlay('fixture', play2, bassProg()) + assert(errs2.some((e) => e.includes('octave, when present, must be 0 or 1')), `octave 2 accepted: ${errs2.join('; ')}`) +}) + +check('empty pattern FAILS', () => { + const play = goodBassPlay() + play.chords[0].pattern = [] + const errs = checkBassPlay('fixture', play, bassProg()) + assert(errs.some((e) => e.includes('pattern must be a non-empty ordered array')), `empty pattern accepted: ${errs.join('; ')}`) +}) + +check("rootless pattern FAILS (every pattern must state '1')", () => { + const play = goodBassPlay() + play.chords[0].pattern = [{ deg: '3', beat: 1 }, { deg: '5', beat: 2 }] + const errs = checkBassPlay('fixture', play, bassProg()) + assert(errs.some((e) => e.includes("never states the root ('1')")), `rootless pattern accepted: ${errs.join('; ')}`) +}) + +check('approach notes must CLOSE the pattern (deg after approach fails)', () => { + const play = goodBassPlay() + play.chords[0].pattern = [{ deg: '1', beat: 1 }, { approach: 'chrom-below', beat: 2 }, { deg: '5', beat: 3 }] + const errs = checkBassPlay('fixture', play, bassProg()) + assert(errs.some((e) => e.includes('deg note after an approach')), `mid-pattern approach accepted: ${errs.join('; ')}`) +}) + +check("unknown approach type FAILS ('tritone-sub' is not in the typed set)", () => { + const play = goodBassPlay() + play.chords[0].pattern[3] = { approach: 'tritone-sub', beat: 4 } + const errs = checkBassPlay('fixture', play, bassProg()) + assert(errs.some((e) => e.includes("unknown approach 'tritone-sub'")), `unknown approach accepted: ${errs.join('; ')}`) +}) + +check('beat range + ordering bite (beat 9 on a 2-bar step; decreasing beats)', () => { + const play = goodBassPlay() + play.chords[1].pattern[4] = { approach: 'fifth-of-next', beat: 9 } // 2 bars → beat < 9 + const errs = checkBassPlay('fixture', play, bassProg()) + assert(errs.some((e) => e.includes('beat must be a number in [1, 9)')), `beat 9 on a 2-bar step accepted: ${errs.join('; ')}`) + const play2 = goodBassPlay() + play2.chords[0].pattern[2] = { deg: '5', beat: 1.5 } // after beat 2 — decreasing + const errs2 = checkBassPlay('fixture', play2, bassProg()) + assert(errs2.some((e) => e.includes('beats must be non-decreasing')), `decreasing beats accepted: ${errs2.join('; ')}`) +}) + +check('chords length ≠ progression length FAILS; missing feel FAILS; bad technique FAILS', () => { + const short = goodBassPlay(); short.chords = short.chords.slice(0, 1) + assert(checkBassPlay('fixture', short, bassProg()).some((e) => e.includes('chords length 1 ≠ progression length 2')), + 'short chords array accepted') + const noFeel = goodBassPlay(); delete noFeel.feel + assert(checkBassPlay('fixture', noFeel, bassProg()).some((e) => e.includes('feel required')), + 'missing feel accepted') + const badTech = goodBassPlay(); badTech.chords[0].pattern[2].technique = 'slap-pop' + assert(checkBassPlay('fixture', badTech, bassProg()).some((e) => e.includes("unknown technique 'slap-pop'")), + 'off-vocabulary technique accepted') +}) + +check('density cap bites (> 8 notes per bar is not intermediate)', () => { + const play = goodBassPlay() + play.chords[0].pattern = Array.from({ length: 9 }, () => ({ deg: '1' })) + const errs = checkBassPlay('fixture', play, bassProg()) + assert(errs.some((e) => e.includes('8ths density cap')), `9 notes in one bar accepted: ${errs.join('; ')}`) +}) + +// Live-KB guard (future-proofs P-41): every bass pack play in the registry +// passes checkBassPlay. Zero bass cells today — the loop is a no-op until the +// first bass.js registers, then it gates it exactly like the validator does. +check('every KB bass pack play passes checkBassPlay', () => { + let bassPlays = 0 + for (const styleName of styleNames) { + const pack = kb[styleName]?.instruments?.bass + 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}/bass plays key '${pid}' is not a progression of this style`) + plays.forEach((play, pi) => { + bassPlays++ + const errs = checkBassPlay(`${styleName}/bass ${pid} play[${pi}] "${play.label ?? '?'}"`, play, prog) + assert(errs.length === 0, errs.join('; ')) + }) + } + } + console.log(` (${bassPlays} live bass plays checked)`) +}) + // ─── 6. Loop-detection truth fixtures (C-30) ────────────────────────────────── // // Run every scripts/loop-fixtures.mjs case against the REAL diff --git a/scripts/validate-kb.mjs b/scripts/validate-kb.mjs index ba67f40..fa2297a 100644 --- a/scripts/validate-kb.mjs +++ b/scripts/validate-kb.mjs @@ -2,9 +2,10 @@ // 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, 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. +// reuse the exported pure checks (checkLick, checkPianoRecipe, checkBassPlay, +// LEVELS, LICK_TECHNIQUES, MAX_HAND_SPAN, MIN_PLAYS_BASS, BASS_APPROACHES, +// BASS_MAX_OFFSET) 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,9 +17,11 @@ 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 +// Bass floor is 1, not 2 (SCHEMA.md "Bass play"): the band wants one bassline +// at a time, and rule 4's "idiomatically different" bar invites filler at 2. +export const MIN_PLAYS_BASS = 1 const MAX_SPAN = 4 // Optional progression/lick difficulty tags (SCHEMA.md — absent = 'foundation'). @@ -144,15 +147,89 @@ export function checkPianoRecipe(where, chordStep, quality) { return out } -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}'`) +// ── Bass plays (SCHEMA.md "Bass play") ──────────────────────────────────────── +// Degree-based per-station patterns: one chords[] entry per progression step, +// each a non-empty ORDERED pattern of {deg,…} chord/color tones (resolved +// through the step's quality — a degree can't misspell a pitch class) and +// typed {approach,…} notes whose pitch is DERIVED from the next station's +// root, so the validator can allow the non-chord tone without blessing +// arbitrary chromatics. Data never encodes strings/frets (key-agnostic, +// hard rule 1); the renderer places patterns on E–A–D–G, frets 0–15. +export const BASS_APPROACHES = ['chrom-below', 'chrom-above', 'fifth-of-next'] +// Widest legal offset above the root: an octave + a fifth keeps every pattern +// placeable on E–A–D–G within frets 0–15 in one position. +export const BASS_MAX_OFFSET = 19 +const BASS_BEATS_PER_BAR = 4 // patterns are notated in 4 — 12/8 is `feel` +const BASS_MAX_NOTES_PER_BAR = 8 // straight-8ths density cap (rule 3) + +// Pure bass-play validation. Returns an array of where-prefixed error strings +// (empty = valid). Exported for reuse by scripts/smoke.mjs (lib mode). +export function checkBassPlay(where, play, prog) { + const out = [] + const e = (msg) => out.push(`${where}: ${msg}`) + if (typeof play.feel !== 'string' || !play.feel) + e("feel required — the groove in one line (e.g. 'swung 8ths, locked with the kick')") + if (play.level !== undefined && !LEVELS.includes(play.level)) + e(`level must be one of ${LEVELS.join(' | ')}, got '${play.level}'`) + if (!Array.isArray(play.chords) || play.chords.length !== prog.degrees.length) { + e(`chords length ${play.chords?.length} ≠ progression length ${prog.degrees.length}`) + return out + } + play.chords.forEach((step, ci) => { + const cw = `chord[${ci}] (${prog.rn?.[ci] ?? ci})` + const quality = prog.qualities[ci] + if (!CHORD_TYPES[quality]) return e(`${cw}: unknown quality '${quality}'`) + const bars = prog.bars?.[ci] ?? 1 + const pat = step?.pattern + if (!Array.isArray(pat) || !pat.length) + return e(`${cw}: pattern must be a non-empty ordered array of notes`) + if (pat.length > BASS_MAX_NOTES_PER_BAR * bars) + e(`${cw}: ${pat.length} notes > ${BASS_MAX_NOTES_PER_BAR * bars} (8ths density cap over ${bars} bar(s)) — not intermediate-friendly`) + let approachSeen = false + let rootSeen = false + let lastBeat = -Infinity + pat.forEach((n, ni) => { + const nw = `${cw} pattern[${ni}]` + if (!n || typeof n !== 'object') return e(`${nw}: note must be an object ({deg,…} or {approach,…})`) + const isDeg = n.deg !== undefined + const isApproach = n.approach !== undefined + if (isDeg === isApproach) return e(`${nw}: exactly one of deg | approach per note`) + if (isApproach) { + approachSeen = true + if (!BASS_APPROACHES.includes(n.approach)) + e(`${nw}: unknown approach '${n.approach}' — allowed: ${BASS_APPROACHES.join(', ')}`) + if (n.octave !== undefined) + e(`${nw}: octave applies to deg notes only (the renderer places approaches beside the next root)`) + } else { + if (approachSeen) + e(`${nw}: deg note after an approach — approach notes must close the pattern (they lead into the next chord)`) + if (typeof n.deg !== 'string') { + e(`${nw}: deg must be a degree STRING ('1', 'b7', …), got ${JSON.stringify(n.deg)}`) + } else { + const pc = resolveDegree(n.deg, quality) + if (pc === null) e(`${nw}: unresolvable degree '${n.deg}' for ${quality}`) + if (n.deg === '1') rootSeen = true + if (n.octave !== undefined && n.octave !== 0 && n.octave !== 1) + e(`${nw}: octave, when present, must be 0 or 1 — got ${JSON.stringify(n.octave)}`) + else if (pc !== null && pc + 12 * (n.octave === 1 ? 1 : 0) > BASS_MAX_OFFSET) + e(`${nw}: '${n.deg}' octave ${n.octave} sits ${pc + 12} semitones above the root — max ${BASS_MAX_OFFSET} (an octave + a fifth; keeps the pattern in one position on E–A–D–G)`) + } + } + if (n.technique !== undefined && !LICK_TECHNIQUES.includes(n.technique)) + e(`${nw}: unknown technique '${n.technique}' — allowed: ${LICK_TECHNIQUES.join(', ')}`) + if (n.beat !== undefined) { + const maxBeat = BASS_BEATS_PER_BAR * bars + if (typeof n.beat !== 'number' || !(n.beat >= 1 && n.beat < maxBeat + 1)) + e(`${nw}: beat must be a number in [1, ${maxBeat + 1}) for a ${bars}-bar step, got ${JSON.stringify(n.beat)}`) + else if (n.beat < lastBeat) + e(`${nw}: beat ${n.beat} < previous beat ${lastBeat} — beats must be non-decreasing in pattern order`) + else lastBeat = n.beat + } + }) + if (!rootSeen) + e(`${cw}: pattern never states the root ('1') — a bassline grounds the chord (SCHEMA "Bass play" root rule)`) }) + return out } // Pure lick validation (SCHEMA.md "Licks" section). Returns an array of error @@ -254,9 +331,10 @@ for (const style of styleDirs) { if (inst !== 'bass' && (!pack.improv?.scales?.length || !pack.improv?.targetNotes)) err(iw, 'improv.scales / improv.targetNotes required') + const minPlays = inst === 'bass' ? MIN_PLAYS_BASS : MIN_PLAYS for (const p of progs) - if ((pack.plays?.[p.id]?.length ?? 0) < MIN_PLAYS) - err(iw, `progression '${p.id}' has < ${MIN_PLAYS} plays`) + if ((pack.plays?.[p.id]?.length ?? 0) < minPlays) + err(iw, `progression '${p.id}' has < ${minPlays} play(s)`) for (const [pid, plays] of Object.entries(pack.plays ?? {})) { const prog = progById[pid] @@ -265,7 +343,7 @@ for (const style of styleDirs) { 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 (inst === 'bass') { for (const m of checkBassPlay(lw, play, prog)) errors.push(m); return } 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) => { diff --git a/src/data/kb/SCHEMA.md b/src/data/kb/SCHEMA.md index 560d0fd..be79e83 100644 --- a/src/data/kb/SCHEMA.md +++ b/src/data/kb/SCHEMA.md @@ -68,7 +68,7 @@ Common envelope: export default { styleIntro: '2-3 sentences on this instrument's role in the style.', comping: [{ label, rhythm, description }], // ≥1 named rhythm - plays: { '': [ , ] }, // ≥2 plays per progression + plays: { '': [ , ] }, // ≥2 plays per progression (bass: ≥1 — see Bass play) improv: { // guitar/piano; optional for bass scales: [{ over: 'ii7', scale: 'dorian', why }], targetNotes: '…', @@ -108,6 +108,14 @@ export default { Voicings are degree recipes resolved through the chord quality. Degrees: `'1' '3' '5' '7'` resolve per quality (e.g. `'3'` → ♭3 for min7); altered/extended degrees are explicit: `'b9' '9' '#9' '11' '#11' 'b13' '13' '6'`. +> **Resolver quirk (both hand-synced `resolveDegree` copies — +> `scripts/validate-kb.mjs` and `src/components/JamGuide.jsx`):** on +> `maj6`/`min6` the degree `'7'` resolves to **9 semitones, i.e. the 6th** +> (those qualities have no 7th in their interval set, and the resolver takes +> the top stack tone as the "7 slot"). It never misspells the chord, but when +> you mean the 6th, write `'6'` explicitly — don't lean on `'7'`. This applies +> equally to bass patterns below, which share the same resolver. + ```js { label: 'Rootless A/B alternation', @@ -123,17 +131,71 @@ Voicings are degree recipes resolved through the chord quality. Degrees: `'1' '3 ### Bass play +A bass play is per-station patterns: `chords` has **one entry per progression +step** (like guitar/piano plays), each carrying the ordered notes the bassist +plays over that chord. + +Patterns are **degree-based** — the piano-recipe language — not string/fret +tab. Why: hard rule 1. A bass play renders over every station of a *detected* +loop in the *detected* key, so the data must transpose automatically; and a +degree either resolves through the chord quality or it doesn't — you cannot +misspell a pitch class, only mislabel your intent. Fret tab was considered and +rejected: absolute frets are key-specific (licks are the one documented +exception, and they say so), and a movable-fret variant breaks at the nut — +exactly where bass lives, on open strings. The open-string/position idiom +belongs in authoring prose: the optional `position` hint. + +Rendering convention (for the per-station pattern card): standard 4-string +tuning **E–A–D–G**, strings numbered **1 = G (highest) … 4 = E (lowest)** — +the same "1 = highest string" convention as lick tab and `rootStr`, so no +third counting scheme exists in this codebase. The renderer places each +pattern in the lowest playable position within frets 0–15; the data itself +never encodes strings or frets. + ```js { - label: 'Walking, chromatic approach', - level: 'intermediate', - bars: [{ beats: ['R', '3', '5', 'chrom>'] }], // per bar of the progression - // beat tokens: R 3 5 7 (chord degrees) · 'chrom>' / 'chrom<' (chromatic into next root - // from below/above) · '5>' (dominant approach) · 'x' (ghost) · '-' (hold) - tips: '…', + label: 'Boogie cell', + level: 'foundation', + feel: 'swung 8ths, locked with the kick', // REQUIRED — the groove in one line + chords: [ // one per progression step + { + pattern: [ // the ORDERED notes (played first → last) + { deg: '1', beat: 1 }, // chord/color tone, resolved through the quality + { deg: '3', beat: 2 }, + { deg: '5', beat: 3, technique: 'ghost-note' }, // technique optional (lick vocabulary) + { approach: 'chrom-below', beat: 4 }, // leads into the NEXT station's root + ], + note: 'walk up into the IV', // optional, as in guitar/piano plays + }, + // … + ], + position: 'first five frets; open E and A when the key allows', // OPTIONAL prose hint + tips: 'Transferable idea.', } ``` +Field rules (all enforced by `node scripts/validate-kb.mjs` when a `bass.js` +pack exists — a style without one is complete and valid): + +| Field | Rule | +|---|---| +| `feel` | required non-empty string — bass is a rhythm role; the pattern alone doesn't say swung vs straight | +| `chords` | array, exactly one entry per progression step | +| `pattern` | non-empty ordered array; ≤ 8 notes per bar of its step (straight-8ths density cap — rule 3) | +| note shape | exactly one of `deg` \| `approach` per note | +| `deg` | a degree **string** from the piano-recipe language above, resolved through the step's quality (`'1' '3' '5' '7'` quality-resolved; `'b3' '6' 'b7' 'b9' '9' '#9' '11' '#11' 'b5' 'b13' '13'` explicit). The maj6/min6 `'7'` quirk above applies — write `'6'` | +| root rule | every pattern states `'1'` at least once — a bassline grounds the chord (a deliberately rootless play needs a schema change, not silence) | +| `octave` | optional on `deg` notes, `0` (default) or `1`; the resolved offset `deg + 12·octave` must stay **≤ 19 semitones** (an octave + a fifth) so every pattern sits on E–A–D–G within frets 0–15 in one position | +| `approach` | `'chrom-below'` (next root − 1 semitone) · `'chrom-above'` (next root + 1) · `'fifth-of-next'` (next root + 7). The pitch is **derived from the next station's root** (last step wraps to the first), never authored — that's why the validator can allow a non-chord tone here while untyped chromatics stay illegal. Approach notes must be the **final note(s)** of the pattern — they lead into the next chord | +| `beat` | optional number, `1 ≤ beat < 4·bars + 1` for that step (patterns are notated in 4 — a 12/8 shuffle is `feel`, not extra beats); non-decreasing in pattern order (equal beats = a dyad) | +| `technique` | optional per note, from the fixed lick technique vocabulary (see Licks) | + +**Coverage: every progression of the style needs ≥ 1 bass play** — deliberately +1, not the guitar/piano 2. The band wants ONE bassline at a time, and rule 4's +"idiomatically different" bar is hard to clear twice per progression without +filler. A second play is welcome where a genuinely different lane exists +(two-feel vs walking, say) — the validator sets a floor, not a ceiling. + ## Licks (optional, guitar first) A style's instrument pack may also teach short, named licks — the ordered-note @@ -212,4 +274,5 @@ cards render and the validator checks. - [ ] 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) +- [ ] Bass patterns state the root, keep approaches typed and terminal, and stay within an octave + a fifth of the root - [ ] `node scripts/validate-kb.mjs` green; `npm run build` green