test(smoke): drift guards for resolveDegree + technique vocab (task C-40)

Smoke 836/836: a literal 16x14 degree truth table (all 224 cells
independently recomputed at the gate) pins the validator resolveDegree
behaviourally via span-probe recipes; technique vocab set-equality
between validate-kb LICK_TECHNIQUES and LickCard TECHNIQUE_VOCAB via a
scoped .jsx esbuild load hook. Both guards sabotage-proven twice
(author + gate, different perturbations). Ride-along: loop-fixtures
header line-ref fixed to App.jsx:363-365. Critic PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-07-10 17:53:41 +01:00
parent 6f59add11b
commit 090d3a9cbf
2 changed files with 138 additions and 2 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
//
// Each fixture is a realistic `chordHistory` as App.jsx commits it (plain chord
// name strings from matchChordFromChroma — "C", "Am", "G7", "Cmaj7"…; the commit
// layer at App.jsx:322-324 suppresses ADJACENT duplicates, so histories here are
// layer at App.jsx:363-365 suppresses ADJACENT duplicates, so histories here are
// adjacent-dup-free except the one fixture that deliberately tests dups) plus the
// loop a musician would say they are playing (`expect`, or null for "no loop").
//
+137 -1
View File
@@ -669,7 +669,143 @@ if (expectedFails) {
console.log(` (${expectedFails} annotated expected-fail(s) — the current detectRepeatingProgression's known gaps, awaiting L-30)`)
}
// ─── 7. Summary + exit code ───────────────────────────────────────────────────
// ─── 7. Hand-sync drift guards (C-40) ─────────────────────────────────────────
//
// Two pieces of logic/data are deliberately duplicated by hand across files
// (src/ must not import from scripts/, so validator ↔ component pairs can't
// share a module). These guards turn silent drift into a red smoke run.
//
// (a) resolveDegree — scripts/validate-kb.mjs holds the KB contract's reference
// implementation; src/components/JamGuide.jsx carries a hand-synced copy
// (its header says "keep the two in sync by hand"). If they drift, authored
// piano recipes validate against one rule and RENDER with another — wrong
// pitches, no error. Guard: a LITERAL 16-degree × 14-quality truth table
// (pinned values, NOT derived from CHORD_TYPES at runtime — deriving would
// let the expectation co-move with the very code under test) asserted
// against the validator's implementation for every pair.
// · The validator's resolveDegree is unexported; it is probed BEHAVIOURALLY
// through the exported checkPianoRecipe: recipe LH ['1','1','1', deg]
// stacks 0 → 12 → 24 → 24 + offset (a repeated pc climbs an octave —
// stackHand's documented convention), so the span error always fires
// (span 2536 > MAX_HAND_SPAN 15) and REPORTS the span, from which the
// offset is recovered exactly: offset = span 24 (36 → 0). An
// unresolvable degree instead yields the 'unresolvable degree' error.
// · JamGuide.jsx's copy is NOT directly comparable today — the function is
// module-scoped, unexported (checked 2026-07-10), and no probe reaches
// it through the default export. Follow-up filed for the board: an
// export-only line on JamGuide's resolveDegree (Luthier — L-40 already
// locks JamGuide.jsx) lets this section sweep both copies directly.
// Until then this table pins the CONTRACT side; any validator drift
// goes red here, and the JamGuide copy is one `export` away from the
// same sweep.
//
// (b) Technique vocabulary — validate-kb.mjs LICK_TECHNIQUES (the schema gate)
// vs LickCard.jsx TECHNIQUE_VOCAB (the renderer's glyph vocabulary). A word
// added to one but not the other means licks that validate but render with
// no glyph, or dead glyph code. Guard: import BOTH and assert set-equality.
// LickCard.jsx is JSX, so a node:module load hook transpiles .jsx on the
// fly via esbuild (already in node_modules as Vite's transpiler).
console.log('\nHand-sync drift guards (C-40):')
// -- jsx load hook: lets Node import component files (esbuild transform) -------
register(
'data:text/javascript,' +
encodeURIComponent(`
import { readFileSync } from 'node:fs'
const esbuildP = import(${JSON.stringify(import.meta.resolve('esbuild'))})
export async function load(url, context, next) {
if (url.endsWith('.jsx')) {
const { transform } = await esbuildP
const src = readFileSync(new URL(url), 'utf8')
const { code } = await transform(src, { loader: 'jsx', jsx: 'automatic', jsxImportSource: 'react' })
return { format: 'module', source: code, shortCircuit: true }
}
return next(url, context)
}
`),
import.meta.url,
)
// -- (a) resolveDegree truth table ---------------------------------------------
// Quality order for the per-quality rows below (must stay CHORD_TYPES' keys —
// a new chord quality MUST extend this table, and the check enforces that).
const Q14 = ['maj', 'min', 'dom7', 'maj7', 'min7', 'dim', 'dim7', 'half_dim', 'aug', 'sus4', 'sus2', 'maj6', 'min6', 'add9']
// resolveDegree(deg, quality) → pitch-class offset from the chord root.
// Scalar = same for all 14 qualities (the fixed-offset degrees); array = one
// value per Q14 entry ('3'/'5'/'7' resolve through the quality's intervals).
// '2' is the canary row: not a legal degree, must stay null everywhere.
const DEGREE_TABLE = {
'1': 0,
'b9': 1,
'9': 2,
'#9': 3,
'11': 5,
'#11': 6,
'b5': 6,
'b13': 8,
'13': 9,
'6': 9,
'b3': 3,
'b7': 10,
// maj min dom7 maj7 min7 dim dim7 ø aug sus4 sus2 maj6 min6 add9
'3': [ 4, 3, 4, 4, 3, 3, 3, 3, 4, 5, 2, 4, 3, 4 ],
'5': [ 7, 7, 7, 7, 7, 6, 6, 6, 8, 7, 7, 7, 7, 7 ],
'7': [ null, null, 10, 11, 10, null, 9, 10, null, null, null, 9, 9, null],
'2': null,
}
// Behavioural probe of the validator's unexported resolveDegree (see header).
function probeValidatorResolveDegree(deg, quality) {
const errs = checkPianoRecipe('probe', { recipe: { LH: ['1', '1', '1', deg] } }, quality)
if (errs.some((e) => e.includes(`unresolvable degree '${deg}'`))) return null
const m = errs.map((e) => /spans (\d+) semitones/.exec(e)).find(Boolean)
if (!m) throw new Error(`probe(${deg}, ${quality}) got neither an 'unresolvable degree' nor a span error — checkPianoRecipe/stackHand changed shape; re-derive the probe. Errors: ${JSON.stringify(errs)}`)
const offset = Number(m[1]) - 24
return offset === 12 ? 0 : offset // '1'-repeat lands an octave up: span 36 ⇒ offset 0
}
check('truth table covers 16 degrees × all 14 CHORD_TYPES qualities', () => {
const degs = Object.keys(DEGREE_TABLE)
assert(degs.length === 16, `expected 16 degrees, table has ${degs.length}`)
assert(Q14.join(',') === QUALITIES.join(','),
`Q14 ≠ CHORD_TYPES keys — a quality was added/renamed; extend DEGREE_TABLE. Q14: ${Q14} · CHORD_TYPES: ${QUALITIES}`)
for (const [deg, row] of Object.entries(DEGREE_TABLE))
if (Array.isArray(row)) assert(row.length === 14, `row '${deg}' has ${row.length} entries, expected 14`)
})
for (const [deg, row] of Object.entries(DEGREE_TABLE)) {
check(`validator resolveDegree('${deg}') matches the pinned table for all 14 qualities`, () => {
Q14.forEach((quality, qi) => {
const want = Array.isArray(row) ? row[qi] : row
const got = probeValidatorResolveDegree(deg, quality)
assert(got === want,
`resolveDegree('${deg}', '${quality}') drifted: validator says ${got}, pinned table says ${want} — re-sync scripts/validate-kb.mjs ↔ src/components/JamGuide.jsx (hand-synced pair) or fix the table if the contract legitimately changed`)
})
})
}
// -- (b) technique vocab set-equality -------------------------------------------
const lickCardMod = await load('src/components/LickCard.jsx')
check('LickCard.jsx imports under the jsx hook and exports TECHNIQUE_VOCAB', () => {
assert(Array.isArray(lickCardMod.TECHNIQUE_VOCAB), 'TECHNIQUE_VOCAB is not an exported array')
assert(lickCardMod.TECHNIQUE_VOCAB.length > 0, 'TECHNIQUE_VOCAB is empty')
})
check('TECHNIQUE_VOCAB (LickCard.jsx) ≡ LICK_TECHNIQUES (validate-kb.mjs) as sets', () => {
const vocab = new Set(lickCardMod.TECHNIQUE_VOCAB ?? [])
const schema = new Set(LICK_TECHNIQUES)
const missingInCard = [...schema].filter((t) => !vocab.has(t))
const missingInSchema = [...vocab].filter((t) => !schema.has(t))
assert(missingInCard.length === 0 && missingInSchema.length === 0,
`technique vocab drift — in validator but not LickCard: [${missingInCard}] · in LickCard but not validator: [${missingInSchema}] — the two lists are hand-synced; add the word to BOTH or neither`)
})
// ─── 8. Summary + exit code ───────────────────────────────────────────────────
const total = passed + failures.length
console.log('')