The notation-first evidence gate
A rule that cannot fail is a suggestion. This is /perfect-stem-read turned into code that returns a non-zero exit status: notation generates the claims, isolated stems verify them, mixed audio never assigns identity, and a disagreement stays a disagreement.
Built 2026-09-08 as ~/.claude/skills/transcription-repair/tr_verify.py. 24 selftests pass. 2,784 real notation claims adjudicated against five isolated stems in 38.7 s.
1The architecture already existed, in prose
/perfect-stem-read on this Mac already states every point of the brief. Line 291 gives the order, line 151 forbids feeding a chart-derived lane label back into the stem classifier, line 17 says preserve the disagreement.
It is 291 lines and it returns no exit status. That is the whole gap, and it is the gap named in the brief itself: written rules were insufficient because the workflow could ignore them. So this module is that document's enforcement arm rather than a new design. It imports tr_ledger for notation and tr_audio for measurement, and adds no reader and no detector of its own.
2Eight rules, every one executable
| Rule | What it refuses | Its selftest |
|---|---|---|
| R1 | A claim with no notation origin. There is deliberately no from_audio() constructor | an audio-origin claim returns UNRESOLVED |
| R2 | Mixed audio assigning identity for a lane an isolated stem covers. assign_identity() raises, and no flag softens it | raises for lane 38 when a snare stem exists |
| R3 | Any resolver at all. Four verdicts, and nothing collapses UNRESOLVED | resolved_by_inference is structurally 0 |
| R4 | A verdict without provenance: stem path, sha256, time, dB, nearest onset | decided verdicts all carry a source hash |
| R5 | An unqualified index into a multi-entry source | refuses [0] of 12 sync entries |
| R6 | A bar-fill walker unproved on grace notes and tuplets | a walker counting grace rhythms fails |
| R7 | An empty claim set passing as a clean run | zero verdicts is a hard failure |
| R8 | Position units guessed rather than stated | beat 3 of 4/4 lands at the bar midpoint |
R5 through R8 are not in the brief. Each exists because it caught a real error, and three of those errors were inside this module.
3Running it found three bugs in itself
The first real-data run returned 0 claims and reported a clean pass. The second returned 399 of 521 kicks CONTRADICTED, which no drum tab could survive.
| Bug | Symptom | Cause |
|---|---|---|
| track filter | 0 claims, and a clean pass | --track passed a string; tr_ledger compares ids numerically |
| bar index base | every claim off by one bar | tr_ledger emits 0-based bars; the driver assumed 1-based |
| position units | 399 false CONTRADICTED | NOTE_VALUE is denominated in whole notes, Quarter = 1/4. The driver divided by quarters-per-bar, a 4x error |
The units bug is the same shape a peer session reported this morning about instance counts against beat-reference counts. A histogram tells the truth only when both sides carry the same unit.
Prose could not have found any of the three. They surfaced because the module ran against real audio and produced a number implausible enough to check. That is the strongest argument for the executable form, and it arrived by accident.
4The real run
2,784 notation claims from HANDS-s6857183-Brandon-edit.gp against the five Moises stems, on the metronome-anchored clock.
NO_STEM 0. Every written lane had an isolated witness available.
Every tom lane abstains on identity
Lanes 41, 45 and 47 return UNRESOLVED with no SUPPORTED at all, and 48 and 50 carry only CONTRADICTED and UNRESOLVED. That is R2 and the separability note doing their job: the toms stem is one channel, so it places a stroke in time and cannot say which drum was struck. The bar 55 collision behaves the same way, which is the correct answer rather than a failure.
5It corrects one of my own published claims
My audit page said bar 1's written crash loses its support, reading 8.27 percent cymbal energy against a 33 percent floor.
The gate disagrees. With units correct the crash sits at 40.703 s, and the ride-profile detector finds an onset 27 ms away, so it reads SUPPORTED. My earlier figure was a band-magnitude share across a trimmed window, which measures loudness rather than attack presence. Onset proximity is the more direct test of whether something was struck, so the crash stands and my withdrawal of it is itself withdrawn.
6What it would and would not have caught today
| Today's error | Caught by | Verdict |
|---|---|---|
12.50 s sync error, from indexing 12 video entries with [0] | R5 | caught, and R5 is not in the brief |
| Missing intro, from an 8:42 versus 9:05 master mismatch | tr_gate.recording_identity, which already existed | not caught by R1 to R4 |
| 43 overfilled bars, from counting grace-note rhythms | R6 | caught, and R6 is not in the brief |
The brief's four rules caught none of today's three, because none was an unsupported musical claim. All three were bugs in reading data. The brief does cover the historical Watermelon class: the coda counts of 81, 86 and 47 toms, the ghost sweeps, and every instance of a pitch in one stem being read as proof that instrument played it.
That is the useful correction. An evidence gate on audio stops a workflow from asserting music it cannot hear. It does not stop a workflow from misreading its own inputs, and today every failure was the second kind.
7The wrapper, which is the half that stops things
The brief asked for a wrapper that prevents the Guitar Pro editing command from executing unless every proposed change passes first. My first pass built only the measurement and reported it as done. A gate caught that as drift, correctly. tr_edit_lock.py is the missing half, and 17 of its own selftests pass.
What makes it a lock rather than a report
run_locked() owns the subprocess call. A caller cannot obtain the command and run it separately, because the command is never returned. It executes inside the lock or it does not execute. A selftest proves this by pointing the command at a marker file and confirming the file is absent after a refusal.
| Operation | Requires | Reasoning |
|---|---|---|
| ADD | SUPPORTED | write a note only where the stem has an attack |
| REMOVE | CONTRADICTED | delete a note only where the stem is silent |
| MOVE | CONTRADICTED at origin and SUPPORTED at destination | a move is a remove plus an add, so it needs both ends adjudicated |
| RELABEL | SUPPORTED and a lane the stem can separate | renaming which drum a note is needs a witness that can tell them apart |
UNRESOLVED and NO_STEM permit nothing. Four refusals sit underneath: L1 a change with no verdict, L2 a verdict whose source sha256 no longer matches the file on disk, L3 a verdict that does not permit its operation, L4 an empty plan passing as a clean run.
8Run against the real edits of this session
The 15-note removal plan that this session actually applied was reconstructed from the difference between the parent file and the head, then handed to the lock with real verdicts measured from the five stems. The lock opened on all fifteen. Every one reads CONTRADICTED, meaning the stem is silent where the note was written, which is the only verdict that permits a removal.
The same lock refuses the bar 55 tom relabel that has been blocked all day, and it refuses it for the stated reason rather than by a rule someone remembered: the toms stem is one channel carrying every tom, so separates_siblings is false and RELABEL cannot execute against it.
So the lock permits the edit that was right and refuses the edit that was never settled. Neither outcome was hand-tuned, and both fall out of the verdict table above.