feat(kb): bass play schema — degree-based patterns with typed approach notes (task C-41)

Key-agnostic bass plays (piano-recipe language): deg XOR typed
approach (chrom-below/above, fifth-of-next — pitch derived from the
next station, never authored), octave 0-1 with a 19-semitone cap
(exactly fret 15 on E-A-D-G), root rule, terminal approaches,
non-decreasing beats, MIN_PLAYS_BASS=1. Legacy BASS_TOKENS stub
replaced (zero references). Smoke 849/849 with 13 new checks;
sabotage-proven twice (author + gate, different malformations).
Ride-along: SCHEMA documents the resolveDegree 7-on-6-chords quirk.
Critic PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-07-10 18:11:02 +01:00
parent 7d1f64c718
commit 996218f39c
3 changed files with 325 additions and 22 deletions
+162
View File
@@ -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
+93 -15
View File
@@ -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 EADG, frets 015.
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 EADG within frets 015 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 EADG)`)
}
}
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) => {
+70 -7
View File
@@ -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: { '<progression-id>': [ <play>, <play> ] }, // ≥2 plays per progression
plays: { '<progression-id>': [ <play>, <play> ] }, // ≥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 **EADG**, 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 015; 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 EADG within frets 015 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 16, frets 015, 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