From c5fe52a491903aa78c1ee618e57e54fdac4de3e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:01:18 +0500 Subject: [PATCH 1/8] =?UTF-8?q?test(lab):=20red=20=E2=80=94=20tap-aware=20?= =?UTF-8?q?fingering=20objective=20(oracle=20labels=20from=20the=20tab)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1 objective reads every note's position as the fretting hand's position. Tapping breaks that: a tapped note is played by the picking hand while the fretting hand stays put. On the whole corpus, no line with tapped notes had its human path in the model's optimum set (against 27% of lines without taps), so tapping is a sharp failure slice of the model — not of the players. Oracle stage only: technique labels come from the tab. Pins: - TabLine::tapped — one NoteMark::Tap flag per note; - technique::tap_aware_cost — v1 unary costs and string changes between neighbours, fretting-hand travel from the previous untapped note (the anchor carries across taps), picking-hand travel at tap_shift from the previous tapped note; equal to v1_cost without taps; hand-computed on the 5 → 8 → tap 12 → 8 → 5 figure (14 tap-blind, 6 tap-aware); - technique::tap_aware_chain — the same objective as a ties::Chain whose states pair a note's candidate with the other hand's last candidate: brute force over position assignments gives the same optimum and optimal-assignment count for every tap mask, every admissible state path scores its assignment exactly, and without taps it is the v1 chain (optimum, count, production path); - LabError::LabelLength for labels that do not cover the line; Chain::from_parts for objectives built outside `ties`. tapped is all-false and the technique functions are todo!() here (7 red). Co-Authored-By: Claude Opus 5 --- lab/src/fingering.rs | 4 + lab/src/lib.rs | 1 + lab/src/problems.rs | 8 ++ lab/src/technique.rs | 66 +++++++++++ lab/src/ties.rs | 17 +++ lab/tests/fingering.rs | 30 ++++- lab/tests/technique.rs | 246 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 lab/src/technique.rs create mode 100644 lab/tests/technique.rs diff --git a/lab/src/fingering.rs b/lab/src/fingering.rs index 4f6b38ce..b389f846 100644 --- a/lab/src/fingering.rs +++ b/lab/src/fingering.rs @@ -137,6 +137,9 @@ pub struct TabLine { /// fretted precedes the line. Taken from the tab — context for tab /// completion, not something MIDI-sourced material carries. pub anchor_fret: Option, + /// Per note, whether the tab marks it tapped (`NoteMark::Tap`) — played by + /// the picking hand on the fretboard, not fretted by the fretting hand. + pub tapped: Vec, } /// Cuts one track into monophonic tablature lines, per voice. @@ -1060,6 +1063,7 @@ impl<'a> LineBuilder<'a> { pitches, human, anchor_fret: self.anchor, + tapped: vec![false; len], }); } } diff --git a/lab/src/lib.rs b/lab/src/lib.rs index c1ce2530..7981fe7c 100644 --- a/lab/src/lib.rs +++ b/lab/src/lib.rs @@ -24,4 +24,5 @@ pub mod manifest; pub mod optir; pub mod problems; pub mod solve; +pub mod technique; pub mod ties; diff --git a/lab/src/problems.rs b/lab/src/problems.rs index f52905b7..50a68a93 100644 --- a/lab/src/problems.rs +++ b/lab/src/problems.rs @@ -58,6 +58,14 @@ pub enum LabError { /// The duplicated onset. onset: u32, }, + /// Per-note labels (e.g. technique flags) do not cover the line. + #[error("{labels} per-note labels for {notes} notes")] + LabelLength { + /// Notes in the line. + notes: usize, + /// Labels supplied. + labels: usize, + }, } /// Problem A: assign one fret per line note so consecutive travel stays diff --git a/lab/src/technique.rs b/lab/src/technique.rs new file mode 100644 index 00000000..7bb89458 --- /dev/null +++ b/lab/src/technique.rs @@ -0,0 +1,66 @@ +//! Technique-aware fingering objectives — oracle stage. +//! +//! The optimality-gap and tie-break audits measured the `v1` objective as if +//! every note were fretted by the fretting hand, so a note's position was the +//! hand's position. Tapping breaks that: a tapped note is played by the picking +//! hand while the fretting hand stays where it was. Lines with tapped notes were +//! the objective's worst slice (on the whole corpus, no such line's human path +//! was in the model's optimum set). +//! +//! This module asks whether telling the model the truth about which hand plays +//! each note explains that slice. The technique labels are taken from the tab +//! (`TabLine::tapped`), not inferred; inference is a later stage. + +use griff_core::event::{FretboardPosition, Pitch, Tuning}; +use griff_core::fretboard::FingeringWeights; + +use crate::problems::LabError; +use crate::ties::Chain; + +/// The `v1` objective with tapped notes attributed to the picking hand: +/// +/// - per note, the `v1` unary cost (`fret·fret − [open]·open_string`); +/// - between consecutive notes, `string_change` when the string changes; +/// - fretting-hand travel: each untapped note pays `position_shift · +/// |Δfret|` from the previous **untapped** note (the anchor carries across +/// taps); +/// - picking-hand travel: each tapped note pays `tap_shift · |Δfret|` from the +/// previous **tapped** note. +/// +/// With no tapped notes it equals `fingering::v1_cost`. `None` when `tapped` +/// does not have one flag per position. +#[must_use] +pub fn tap_aware_cost( + line: &[FretboardPosition], + tapped: &[bool], + weights: &FingeringWeights, + tap_shift: i64, +) -> Option { + let _ = (line, tapped, weights, tap_shift); + todo!("tap-aware cost — green step") +} + +/// [`tap_aware_cost`] as a [`Chain`], so the exact optimum-set DPs apply. +/// +/// Per note the chain's states pair the note's candidate with the candidate of +/// the most recent note played by the *other* hand (or none yet); a transition +/// is admissible only when that carried candidate agrees with the previous +/// state, so state paths and position assignments correspond one to one. +/// Inadmissible transitions carry a cost no optimal path can take. +/// +/// # Errors +/// +/// [`LabError::EmptyLine`] for no pitches; [`LabError::UnpositionablePitch`] +/// when a pitch has no candidate at or below `max_fret`; +/// [`LabError::LabelLength`] when `tapped` does not have one flag per pitch. +pub fn tap_aware_chain( + pitches: &[Pitch], + tuning: &Tuning, + weights: &FingeringWeights, + tap_shift: i64, + tapped: &[bool], + max_fret: u8, +) -> Result { + let _ = (pitches, tuning, weights, tap_shift, tapped, max_fret); + todo!("tap-aware chain — green step") +} diff --git a/lab/src/ties.rs b/lab/src/ties.rs index 2fed893d..4f9a4d4f 100644 --- a/lab/src/ties.rs +++ b/lab/src/ties.rs @@ -73,6 +73,23 @@ pub struct Chain { } impl Chain { + /// A chain from explicit parts, for objectives built outside this module + /// (e.g. [`crate::technique::tap_aware_chain`]). Callers guarantee the + /// shapes: one candidate list per note, matching unary lengths, and + /// `pairwise[i][a][b]` for every candidate pair of consecutive notes. + pub(crate) const fn from_parts( + positions: Vec>, + unary: Vec>, + pairwise: Vec>>, + ) -> Self { + Self { + positions, + unary, + pairwise, + anchor: None, + } + } + /// The production `v1` objective (as `griff_core::fretboard::infer_positions` /// minimizes it) as a chain. /// diff --git a/lab/tests/fingering.rs b/lab/tests/fingering.rs index a3ec919a..7568de0c 100644 --- a/lab/tests/fingering.rs +++ b/lab/tests/fingering.rs @@ -31,8 +31,8 @@ use griff_constraint_lab::{ }; use griff_core::{ event::{ - FretboardPosition, NoteMarks, NotePosition, Pitch, Tempo, Ticks, TimeSignature, Tuning, - Velocity, + FretboardPosition, NoteMark, NoteMarks, NotePosition, Pitch, Tempo, Ticks, TimeSignature, + Tuning, Velocity, }, fretboard::{infer_positions, FingeringWeights, STANDARD_MAX_FRET}, score::{ @@ -114,6 +114,14 @@ fn single(onset: u32, p: u8, position: Option<(u8, u8)>) -> EventGroup { group(vec![note(onset, p, position)]) } +fn tapped_single(onset: u32, p: u8, position: (u8, u8)) -> EventGroup { + let AtomEvent::Note(mut n) = note(onset, p, Some(position)) else { + unreachable!("note builds a note") + }; + n.marks = NoteMarks::empty().with(NoteMark::Tap); + group(vec![AtomEvent::Note(n)]) +} + // ── tablature lines ─────────────────────────────────────────────────────────── /// Voice 0 exercises every cut cause once; voice 1 is one clean line sharing @@ -319,6 +327,24 @@ fn tab_lines_record_the_hand_anchor_before_each_line() { assert_eq!(lines[3].anchor_fret, None, "voice 1 starts fresh"); } +#[test] +fn tab_lines_flag_tapped_notes() { + // D string: fret 5, 8, tapped 12, 8, 5 — a tap-and-pull figure. + let s = score(vec![vec![ + single(0, 55, Some((4, 5))), + single(Q, 58, Some((4, 8))), + tapped_single(2 * Q, 62, (4, 12)), + single(3 * Q, 58, Some((4, 8))), + single(4 * Q, 55, Some((4, 5))), + ]]); + let (lines, _) = tab_lines(&s, 0, &LineCut::v1()).unwrap(); + assert_eq!(lines[0].tapped, vec![false, false, true, false, false]); + let (plain, _) = tab_lines(&cut_fixture(), 0, &LineCut::v1()).unwrap(); + assert!(plain + .iter() + .all(|l| l.tapped.len() == l.pitches.len() && l.tapped.iter().all(|t| !t))); +} + #[test] fn tab_lines_refuse_a_missing_track() { assert_eq!( diff --git a/lab/tests/technique.rs b/lab/tests/technique.rs new file mode 100644 index 00000000..17fd3159 --- /dev/null +++ b/lab/tests/technique.rs @@ -0,0 +1,246 @@ +//! Red → contract tests for technique-aware fingering (`technique`), oracle +//! stage: tap labels come from the tab. +//! +//! Pins, against hand computation and brute force over position assignments: +//! the tap-aware cost carries the fretting-hand anchor across tapped notes and +//! charges the picking hand its own travel; it equals `v1_cost` without taps; +//! its chain encoding scores every assignment identically, has the same +//! optimum and optimal-assignment count, and without taps reproduces the `v1` +//! chain's optimum and production path. + +#![allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + clippy::missing_assert_message, + clippy::indexing_slicing, + clippy::arithmetic_side_effects, + clippy::cast_possible_truncation +)] + +use griff_constraint_lab::{ + fingering::v1_cost, + problems::LabError, + technique::{tap_aware_chain, tap_aware_cost}, + ties::{lexicographic_path, optimum_set, Chain, FEATURES}, +}; +use griff_core::{ + event::{FretboardPosition, Pitch, Tuning}, + fretboard::{FingeringWeights, STANDARD_MAX_FRET}, +}; + +fn pitch(p: u8) -> Pitch { + Pitch::new(p).expect("valid pitch") +} + +fn pitches_of(raw: &[u8]) -> Vec { + raw.iter().map(|&p| pitch(p)).collect() +} + +fn pos(string: u8, fret: u8) -> FretboardPosition { + FretboardPosition { string, fret } +} + +fn weights(fret: i64, open: i64, shift: i64, change: i64) -> FingeringWeights { + FingeringWeights { + fret, + open_string: open, + position_shift: shift, + string_change: change, + } +} + +fn weight_sets() -> Vec { + vec![ + FingeringWeights::v1(), + weights(0, -3, 1, 0), + weights(2, 4, 0, 3), + ] +} + +/// Every position assignment of a line. +fn assignments(pitches: &[Pitch], tuning: &Tuning) -> Vec> { + let mut out = vec![Vec::new()]; + for &p in pitches { + let cands = tuning.candidates(p, STANDARD_MAX_FRET); + out = out + .into_iter() + .flat_map(|prefix| { + cands.iter().map(move |&c| { + let mut next = prefix.clone(); + next.push(c); + next + }) + }) + .collect(); + } + out +} + +/// Every state path of a chain, as candidate indices. +fn state_paths(chain: &Chain) -> Vec> { + let mut out = vec![Vec::new()]; + for note in 0..chain.len() { + let k = chain.candidates(note).len(); + out = out + .into_iter() + .flat_map(|prefix| { + (0..k).map(move |c| { + let mut next = prefix.clone(); + next.push(c); + next + }) + }) + .collect(); + } + out +} + +fn flags(mask: u32, len: usize) -> Vec { + (0..len).map(|i| mask & (1 << i) != 0).collect() +} + +#[test] +fn tapped_notes_do_not_move_the_fretting_hand() { + // D string 5, 8, tap 12, 8, 5 with v1-fit weights (shift 1). + let line = [pos(4, 5), pos(4, 8), pos(4, 12), pos(4, 8), pos(4, 5)]; + let w = weights(0, -3, 1, 0); + assert_eq!(v1_cost(&line, &w), 14, "tap-blind: 3 + 4 + 4 + 3"); + let tapped = [false, false, true, false, false]; + // Fretting hand 5 → 8 → (8, across the tap) → 5: 3 + 0 + 3; first tap free. + assert_eq!(tap_aware_cost(&line, &tapped, &w, 1), Some(6)); + // Two taps: the picking hand pays its own travel, 12 → 15 at tap_shift 2. + let line = [pos(4, 5), pos(4, 12), pos(4, 8), pos(4, 15), pos(4, 5)]; + let tapped = [false, true, false, true, false]; + // Fretting: 5 → 8 (3) → 5 (3); picking: 12 → 15 (3 · 2). + assert_eq!(tap_aware_cost(&line, &tapped, &w, 2), Some(12)); + assert_eq!(tap_aware_cost(&line, &tapped[..4], &w, 2), None); +} + +#[test] +fn tap_aware_cost_charges_string_changes_between_neighbours() { + let w = weights(0, 0, 1, 5); + let line = [pos(4, 7), pos(3, 9), pos(4, 7)]; + // Fretting hand stays at 7 across the tap; two string changes. + assert_eq!( + tap_aware_cost(&line, &[false, true, false], &w, 1), + Some(10) + ); +} + +#[test] +fn tap_aware_cost_without_taps_is_v1_cost() { + let tuning = Tuning::standard_e(); + for w in weight_sets() { + for line in assignments(&pitches_of(&[40, 52, 57, 64]), &tuning) { + assert_eq!( + tap_aware_cost(&line, &[false; 4], &w, 7), + Some(v1_cost(&line, &w)) + ); + } + } +} + +#[test] +fn tap_aware_chain_matches_brute_force_over_assignments() { + let tuning = Tuning::standard_e(); + let lines: [&[u8]; 3] = [&[52, 57, 64, 59], &[47, 55, 62, 55], &[45, 50, 57, 64]]; + for w in weight_sets() { + for raw in lines { + let pitches = pitches_of(raw); + for mask in 0..(1_u32 << pitches.len()) { + let tapped = flags(mask, pitches.len()); + let chain = + tap_aware_chain(&pitches, &tuning, &w, 2, &tapped, STANDARD_MAX_FRET).unwrap(); + let costs: Vec = assignments(&pitches, &tuning) + .iter() + .map(|a| tap_aware_cost(a, &tapped, &w, 2).unwrap()) + .collect(); + let optimum = *costs.iter().min().unwrap(); + let count = costs.iter().filter(|c| **c == optimum).count() as u64; + let set = optimum_set(&chain, None); + assert_eq!(set.optimum, optimum, "{raw:?} {tapped:?} {w:?}"); + assert_eq!(set.count.exact, count, "{raw:?} {tapped:?} {w:?}"); + + let path = lexicographic_path(&chain, &[0; FEATURES], None); + let positions = chain.positions_of(&path).unwrap(); + assert_eq!(tap_aware_cost(&positions, &tapped, &w, 2), Some(optimum)); + // Every admissible state path scores its assignment exactly. + for state in state_paths(&chain) { + let cost = chain.cost(&state).unwrap(); + let assignment = chain.positions_of(&state).unwrap(); + let direct = tap_aware_cost(&assignment, &tapped, &w, 2).unwrap(); + assert!(cost == direct || cost > direct + 1_000_000); + } + } + } + } +} + +#[test] +fn tap_aware_chain_without_taps_is_the_v1_chain() { + let tuning = Tuning::standard_e(); + for w in weight_sets() { + for raw in [ + &[40_u8, 52, 57, 64, 59, 47][..], + &[64, 62, 60, 59, 57, 55][..], + ] { + let pitches = pitches_of(raw); + let aware = tap_aware_chain( + &pitches, + &tuning, + &w, + 3, + &vec![false; raw.len()], + STANDARD_MAX_FRET, + ) + .unwrap(); + let blind = Chain::v1(&pitches, &tuning, &w, STANDARD_MAX_FRET).unwrap(); + let (a, b) = (optimum_set(&aware, None), optimum_set(&blind, None)); + assert_eq!((a.optimum, a.count.exact), (b.optimum, b.count.exact)); + let zero = [0; FEATURES]; + assert_eq!( + aware.positions_of(&lexicographic_path(&aware, &zero, None)), + blind.positions_of(&lexicographic_path(&blind, &zero, None)) + ); + } + } +} + +#[test] +fn tap_aware_chain_refuses_bad_input() { + let tuning = Tuning::standard_e(); + let w = FingeringWeights::v1(); + assert_eq!( + tap_aware_chain(&[], &tuning, &w, 1, &[], STANDARD_MAX_FRET), + Err(LabError::EmptyLine) + ); + assert_eq!( + tap_aware_chain( + &pitches_of(&[40, 45]), + &tuning, + &w, + 1, + &[true], + STANDARD_MAX_FRET + ), + Err(LabError::LabelLength { + notes: 2, + labels: 1 + }) + ); + assert_eq!( + tap_aware_chain( + &pitches_of(&[40, 30]), + &tuning, + &w, + 1, + &[false, true], + STANDARD_MAX_FRET + ), + Err(LabError::UnpositionablePitch { + index: 1, + pitch: 30 + }) + ); +} From ca2ca1b9d70dc33eafaa4224a4490585aeb9ffb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:03:06 +0500 Subject: [PATCH 2/8] =?UTF-8?q?feat(lab):=20green=20=E2=80=94=20tap-aware?= =?UTF-8?q?=20fingering=20objective=20and=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tab_lines records NoteMark::Tap per note alongside the hand anchor. tap_aware_cost walks the line with one travel anchor per hand: untapped notes pay position_shift from the last untapped note, tapped notes pay tap_shift from the last tapped note, and string changes are charged between neighbours as in v1. tap_aware_chain builds the same objective as a ties::Chain: a note's states pair its candidate with the other hand's last candidate (or none yet); a transition keeps that carried candidate when the hand repeats and takes over note i − 1 when the hand switches, and anything else costs an inadmissible sentinel. State paths and position assignments correspond one to one, so optimum_set and lexicographic_path apply unchanged; with no taps the chain is the v1 chain. Suite green (6 technique + 31 fingering + 17 ties + 49 spike/optir); clippy clean. Co-Authored-By: Claude Opus 5 --- lab/src/fingering.rs | 37 +++++++++-- lab/src/technique.rs | 152 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 180 insertions(+), 9 deletions(-) diff --git a/lab/src/fingering.rs b/lab/src/fingering.rs index b389f846..9b9ab0de 100644 --- a/lab/src/fingering.rs +++ b/lab/src/fingering.rs @@ -17,7 +17,7 @@ use std::ops::RangeInclusive; -use griff_core::event::{FretboardPosition, Pitch, Tuning}; +use griff_core::event::{FretboardPosition, NoteMark, Pitch, Tuning}; use griff_core::fretboard::{FingeringWeights, STANDARD_MAX_FRET}; use griff_core::score::{AtomEvent, AtomNote, Score}; use serde::{Deserialize, Serialize}; @@ -262,7 +262,15 @@ pub fn tab_lines( line.flush(cut, &mut lines, &mut stats); continue; } - line.push(onset, note.pitch, orient(position), anchor_here); + line.push( + onset, + note.pitch, + orient(position), + NoteContext { + anchor: anchor_here, + tapped: note.marks.contains(NoteMark::Tap), + }, + ); } line.flush(cut, &mut lines, &mut stats); } @@ -1002,6 +1010,15 @@ pub fn with_string_tiebreak( // ── private helpers ─────────────────────────────────────────────────────────── +/// Per-note context captured while a voice is scanned. +#[derive(Clone, Copy)] +struct NoteContext { + /// The hand anchor before this note's onset. + anchor: Option, + /// Whether the tab marks the note tapped. + tapped: bool, +} + /// Accumulates one tablature line while a voice is scanned. struct LineBuilder<'a> { track: usize, @@ -1009,6 +1026,7 @@ struct LineBuilder<'a> { tuning: &'a Tuning, start_tick: u32, anchor: Option, + tapped: Vec, pitches: Vec, human: Vec, } @@ -1021,6 +1039,7 @@ impl<'a> LineBuilder<'a> { tuning, start_tick: 0, anchor: None, + tapped: Vec::new(), pitches: Vec::new(), human: Vec::new(), } @@ -1030,13 +1049,20 @@ impl<'a> LineBuilder<'a> { self.pitches.is_empty() } - fn push(&mut self, onset: u32, pitch: Pitch, position: FretboardPosition, anchor: Option) { + fn push( + &mut self, + onset: u32, + pitch: Pitch, + position: FretboardPosition, + context: NoteContext, + ) { if self.pitches.is_empty() { self.start_tick = onset; - self.anchor = anchor; + self.anchor = context.anchor; } self.pitches.push(pitch); self.human.push(position); + self.tapped.push(context.tapped); } /// Ends the current line: kept when long enough, otherwise counted as @@ -1048,6 +1074,7 @@ impl<'a> LineBuilder<'a> { } let pitches = std::mem::take(&mut self.pitches); let human = std::mem::take(&mut self.human); + let tapped = std::mem::take(&mut self.tapped); if len < cut.min_notes { stats.short_lines = stats.short_lines.saturating_add(1); stats.short_line_notes = stats.short_line_notes.saturating_add(count(len)); @@ -1063,7 +1090,7 @@ impl<'a> LineBuilder<'a> { pitches, human, anchor_fret: self.anchor, - tapped: vec![false; len], + tapped, }); } } diff --git a/lab/src/technique.rs b/lab/src/technique.rs index 7bb89458..a4dddb93 100644 --- a/lab/src/technique.rs +++ b/lab/src/technique.rs @@ -14,9 +14,17 @@ use griff_core::event::{FretboardPosition, Pitch, Tuning}; use griff_core::fretboard::FingeringWeights; +use crate::fingering::v1_unary; use crate::problems::LabError; use crate::ties::Chain; +/// Cost of an inadmissible chain transition: far above any real line cost, yet +/// small enough that saturating sums of a line's worth of them stay ordered. +const INADMISSIBLE: i64 = i64::MAX / 4; + +/// A chain state: this note's candidate and the other hand's last candidate. +type TapState = (usize, Option); + /// The `v1` objective with tapped notes attributed to the picking hand: /// /// - per note, the `v1` unary cost (`fret·fret − [open]·open_string`); @@ -36,8 +44,31 @@ pub fn tap_aware_cost( weights: &FingeringWeights, tap_shift: i64, ) -> Option { - let _ = (line, tapped, weights, tap_shift); - todo!("tap-aware cost — green step") + if line.len() != tapped.len() { + return None; + } + let mut total = 0_i64; + let mut last_fretted: Option = None; + let mut last_tapped: Option = None; + let mut previous: Option = None; + for (&position, &tap) in line.iter().zip(tapped) { + total = total.saturating_add(v1_unary(position.fret, weights)); + if previous.is_some_and(|p| p.string != position.string) { + total = total.saturating_add(weights.string_change); + } + let (last, weight) = if tap { + (&mut last_tapped, tap_shift) + } else { + (&mut last_fretted, weights.position_shift) + }; + if let Some(q) = *last { + total = total + .saturating_add(weight.saturating_mul(i64::from(q.fret.abs_diff(position.fret)))); + } + *last = Some(position); + previous = Some(position); + } + Some(total) } /// [`tap_aware_cost`] as a [`Chain`], so the exact optimum-set DPs apply. @@ -53,6 +84,9 @@ pub fn tap_aware_cost( /// [`LabError::EmptyLine`] for no pitches; [`LabError::UnpositionablePitch`] /// when a pitch has no candidate at or below `max_fret`; /// [`LabError::LabelLength`] when `tapped` does not have one flag per pitch. +// The v1 builder's inputs plus the tap weight and labels; a parameter struct +// would only rename them. +#[allow(clippy::too_many_arguments)] pub fn tap_aware_chain( pitches: &[Pitch], tuning: &Tuning, @@ -61,6 +95,116 @@ pub fn tap_aware_chain( tapped: &[bool], max_fret: u8, ) -> Result { - let _ = (pitches, tuning, weights, tap_shift, tapped, max_fret); - todo!("tap-aware chain — green step") + if pitches.is_empty() { + return Err(LabError::EmptyLine); + } + if tapped.len() != pitches.len() { + return Err(LabError::LabelLength { + notes: pitches.len(), + labels: tapped.len(), + }); + } + let candidates = pitches + .iter() + .enumerate() + .map(|(index, &pitch)| { + let c = tuning.candidates(pitch, max_fret); + if c.is_empty() { + Err(LabError::UnpositionablePitch { + index, + pitch: pitch.0, + }) + } else { + Ok(c) + } + }) + .collect::, _>>()?; + let n = pitches.len(); + + // The latest note before `i` played by the other hand. + let mut other_note: Vec> = Vec::with_capacity(n); + let mut latest = [None, None]; + for (i, &tap) in tapped.iter().enumerate() { + let hand = usize::from(tap); + other_note.push(latest[1 - hand]); + latest[hand] = Some(i); + } + + // States: (this note's candidate, the other hand's last candidate). + let states: Vec> = (0..n) + .map(|i| { + let others: Vec> = match other_note[i] { + None => vec![None], + Some(o) => (0..candidates[o].len()).map(Some).collect(), + }; + (0..candidates[i].len()) + .flat_map(|c| others.iter().map(move |&o| (c, o))) + .collect() + }) + .collect(); + + let positions = states + .iter() + .enumerate() + .map(|(i, layer)| layer.iter().map(|&(c, _)| candidates[i][c]).collect()) + .collect(); + let unary = states + .iter() + .enumerate() + .map(|(i, layer)| { + layer + .iter() + .map(|&(c, _)| v1_unary(candidates[i][c].fret, weights)) + .collect() + }) + .collect(); + let pairwise = (0..n) + .map(|i| { + let Some(previous) = i.checked_sub(1) else { + return Vec::new(); + }; + states[previous] + .iter() + .map(|&(own_prev, other_prev)| { + states[i] + .iter() + .map(|&(own, other)| { + // The same hand keeps the other hand's carried + // candidate; a hand switch hands over note i − 1. + let (admissible, prev_same) = if tapped[i] == tapped[previous] { + (other == other_prev, Some((previous, own_prev))) + } else { + ( + other == Some(own_prev), + other_note[previous].zip(other_prev), + ) + }; + if !admissible { + return INADMISSIBLE; + } + let here = candidates[i][own]; + let before = candidates[previous][own_prev]; + let mut cost = if here.string == before.string { + 0 + } else { + weights.string_change + }; + if let Some((note, cand)) = prev_same { + let weight = if tapped[i] { + tap_shift + } else { + weights.position_shift + }; + cost = cost.saturating_add(weight.saturating_mul(i64::from( + candidates[note][cand].fret.abs_diff(here.fret), + ))); + } + cost + }) + .collect() + }) + .collect() + }) + .collect(); + Ok(Chain::from_parts(positions, unary, pairwise)) } From 124a8565e77d54f73db2d4f095f654c6b2ba6d49 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:07:14 +0500 Subject: [PATCH 3/8] =?UTF-8?q?feat(lab):=20taps=20runner=20=E2=80=94=20ta?= =?UTF-8?q?p-blind=20vs=20tap-aware=20under=20the=20same=20weights?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fingering_gap taps selects the lines with tapped notes and, for v1-fit and production v1 weights, compares the tap-blind objective with the tap-aware one (tap_shift = position_shift, so costs share units, and tap_shift = 0) on the whole corpus and on holdout songs: lines whose human path lies in the model's optimum set, human excess (per line and per note), unique optima, production-order agreement overall / on tapped / on fretted notes, and the ceiling. An untapped baseline reweighted to the tapped slice's line lengths gives the fair reference, and a control asserts the two objectives agree on every untapped line (0 of 8,890 differ). Co-Authored-By: Claude Opus 5 --- lab/src/bin/fingering_gap.rs | 338 +++++++++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) diff --git a/lab/src/bin/fingering_gap.rs b/lab/src/bin/fingering_gap.rs index 67862c84..9106f66b 100644 --- a/lab/src/bin/fingering_gap.rs +++ b/lab/src/bin/fingering_gap.rs @@ -38,6 +38,15 @@ //! secondary objective on train songs (margin chosen on a validation bucket) //! and reports the tie-break ladder on holdout songs. //! +//! Technique-aware fingering, oracle stage (tap labels from the tab): +//! +//! ```text +//! cargo run --release --bin fingering_gap -- taps --tabs DIR --out DIR +//! ``` +//! +//! compares the tap-blind `v1` objective with the tap-aware one under the same +//! weights on lines with tapped notes, on the whole corpus and on holdout songs. +//! //! `MODELS`: `--v1 NAME=fret,open_string,position_shift,string_change` and //! `--hand NAME=height,open_string,stretch,shift,shift_distance,string_distance`, //! repeatable; default `--v1 v1=1,1,2,1` (the production weights). @@ -62,6 +71,7 @@ use griff_constraint_lab::ir::VarId; use griff_constraint_lab::optir::{ verify_agreement, verify_record, OptProblem, ProblemRecord, SolveRecord, Verdict, }; +use griff_constraint_lab::technique::{tap_aware_chain, tap_aware_cost}; use griff_constraint_lab::ties::{ lexicographic_path, optimum_set, path_matches, train_secondary, Chain, Example, Features, PerceptronConfig, FEATURES, FEATURE_NAMES, @@ -1357,6 +1367,333 @@ fn tiebreak(corpus: Corpus, models: &[Model], out: &Path) -> std::io::Result<()> write_json(&out.join("tiebreak.json"), &report) } +// ── technique-aware fingering (oracle labels) ──────────────────────────────── + +#[derive(Debug, Clone, Default, Serialize)] +struct TapSlice { + lines: usize, + notes: u64, + tapped_notes: u64, + /// Lines whose human path lies in the model's optimum set. + human_in_optimum_set: usize, + /// `cost(human) − optimum` per line, in the model's cost units. + human_excess: Quantiles, + /// Total excess over total notes — comparable across line lengths. + excess_per_note: f64, + unique_optimum_lines: usize, + /// Agreement of the production-order path (lowest candidate on ties). + agree: f64, + agree_tapped: f64, + agree_fretted: f64, + /// Most agreement reachable inside the optimum set. + ceiling: f64, +} + +struct TapLine { + notes: u64, + tapped: u64, + in_set: bool, + excess: i64, + unique: bool, + agree: u64, + agree_tapped: u64, + ceiling: u64, +} + +fn tap_line(line: &Line, weights: &FingeringWeights, tap_shift: Option) -> TapLine { + let tab = &line.tab; + let (chain, human_cost) = match tap_shift { + None => (chain_of(line, weights), v1_cost(&tab.human, weights)), + Some(shift) => ( + tap_aware_chain( + &tab.pitches, + &tab.tuning, + weights, + shift, + &tab.tapped, + STANDARD_MAX_FRET, + ) + .expect("tab lines are positionable and fully labelled"), + tap_aware_cost(&tab.human, &tab.tapped, weights, shift).expect("labels cover the line"), + ), + }; + let set = optimum_set(&chain, Some(&tab.human)); + let range = set.agreement.expect("human positions per note"); + let path = chain + .positions_of(&lexicographic_path(&chain, &[0; FEATURES], None)) + .expect("a path of the chain"); + let matched: Vec = path.iter().zip(&tab.human).map(|(a, h)| a == h).collect(); + TapLine { + notes: tab.human.len() as u64, + tapped: tab.tapped.iter().filter(|t| **t).count() as u64, + in_set: human_cost == set.optimum, + excess: human_cost - set.optimum, + unique: !set.count.saturated && set.count.exact == 1, + agree: matched.iter().filter(|m| **m).count() as u64, + agree_tapped: matched + .iter() + .zip(&tab.tapped) + .filter(|(m, t)| **m && **t) + .count() as u64, + ceiling: range.max as u64, + } +} + +#[allow(clippy::cast_precision_loss)] +fn tap_slice(lines: &[&Line], weights: &FingeringWeights, tap_shift: Option) -> TapSlice { + let rows = par_map(lines, |line| tap_line(line, weights, tap_shift)); + let notes: u64 = rows.iter().map(|r| r.notes).sum(); + let tapped: u64 = rows.iter().map(|r| r.tapped).sum(); + let agree: u64 = rows.iter().map(|r| r.agree).sum(); + let agree_tapped: u64 = rows.iter().map(|r| r.agree_tapped).sum(); + let share = |x: u64, of: u64| x as f64 / of.max(1) as f64; + TapSlice { + lines: rows.len(), + notes, + tapped_notes: tapped, + human_in_optimum_set: rows.iter().filter(|r| r.in_set).count(), + human_excess: quantiles(rows.iter().map(|r| r.excess).collect()), + excess_per_note: rows.iter().map(|r| r.excess as f64).sum::() / notes.max(1) as f64, + unique_optimum_lines: rows.iter().filter(|r| r.unique).count(), + agree: share(agree, notes), + agree_tapped: share(agree_tapped, tapped), + agree_fretted: share(agree - agree_tapped, notes - tapped), + ceiling: share(rows.iter().map(|r| r.ceiling).sum(), notes), + } +} + +/// Line-length bins for the length-matched baseline. +fn length_bin(notes: u64) -> usize { + match notes { + 0..=15 => 0, + 16..=31 => 1, + 32..=63 => 2, + 64..=127 => 3, + _ => 4, + } +} + +/// Untapped lines reweighted to the tapped slice's length distribution — the +/// baseline a tap-aware model should be compared with, since exact line +/// optimality gets rarer as lines grow. +#[derive(Debug, Clone, Default, Serialize)] +struct LengthMatched { + human_in_optimum_set: f64, + excess_per_note: f64, + agree: f64, + ceiling: f64, +} + +#[allow(clippy::cast_precision_loss)] +fn length_matched( + tapped: &[&Line], + untapped: &[&Line], + weights: &FingeringWeights, +) -> LengthMatched { + const BINS: usize = 5; + let mut target_lines = [0_f64; BINS]; + let mut target_notes = [0_f64; BINS]; + for line in tapped { + let n = line.tab.human.len() as u64; + target_lines[length_bin(n)] += 1.0; + target_notes[length_bin(n)] += n as f64; + } + let rows = par_map(untapped, |line| tap_line(line, weights, None)); + let mut lines = [0_f64; BINS]; + let mut in_set = [0_f64; BINS]; + let mut notes = [0_f64; BINS]; + let mut excess = [0_f64; BINS]; + let mut agree = [0_f64; BINS]; + let mut ceiling = [0_f64; BINS]; + for r in &rows { + let b = length_bin(r.notes); + lines[b] += 1.0; + in_set[b] += f64::from(u8::from(r.in_set)); + notes[b] += r.notes as f64; + excess[b] += r.excess as f64; + agree[b] += r.agree as f64; + ceiling[b] += r.ceiling as f64; + } + let (mut m, mut line_weight, mut note_weight) = (LengthMatched::default(), 0.0, 0.0); + for b in 0..BINS { + if lines[b] == 0.0 || target_lines[b] == 0.0 { + continue; + } + m.human_in_optimum_set += target_lines[b] * in_set[b] / lines[b]; + line_weight += target_lines[b]; + m.excess_per_note += target_notes[b] * excess[b] / notes[b]; + m.agree += target_notes[b] * agree[b] / notes[b]; + m.ceiling += target_notes[b] * ceiling[b] / notes[b]; + note_weight += target_notes[b]; + } + m.human_in_optimum_set /= line_weight.max(1.0); + m.excess_per_note /= note_weight.max(1.0); + m.agree /= note_weight.max(1.0); + m.ceiling /= note_weight.max(1.0); + m +} + +#[derive(Serialize)] +struct TapTrial { + weights: String, + model: String, + split: &'static str, + slice: TapSlice, +} + +#[derive(Serialize)] +struct TapsReport { + schema: &'static str, + version: u32, + /// Untapped lines where the tap-aware and tap-blind objectives disagree on + /// the optimum or the production-order path (must be 0). + control_mismatches: usize, + control_lines: usize, + trials: Vec, + /// Per weight set: untapped lines reweighted to the tapped slice's lengths + /// (whole corpus). + length_matched: BTreeMap<&'static str, LengthMatched>, + corpus: CorpusFacts, +} + +#[allow(clippy::cast_precision_loss)] +fn taps(corpus: Corpus, out: &Path) -> std::io::Result<()> { + let tap_lines: Vec<&Line> = corpus + .lines + .iter() + .filter(|l| l.tab.tapped.iter().any(|t| *t)) + .collect(); + let untapped: Vec<&Line> = corpus + .lines + .iter() + .filter(|l| l.tab.tapped.iter().all(|t| !*t)) + .collect(); + let weight_sets = [ + ( + "v1-fit", + FingeringWeights { + fret: 0, + open_string: -3, + position_shift: 1, + string_change: 0, + }, + ), + ("v1", FingeringWeights::v1()), + ]; + + // Control: without taps the two objectives must be the same objective. + let control_mismatches = par_map(&untapped, |line| { + weight_sets.iter().any(|(_, w)| { + let blind = chain_of(line, w); + let aware = tap_aware_chain( + &line.tab.pitches, + &line.tab.tuning, + w, + w.position_shift, + &line.tab.tapped, + STANDARD_MAX_FRET, + ) + .expect("positionable"); + let zero = [0; FEATURES]; + optimum_set(&blind, None).optimum != optimum_set(&aware, None).optimum + || blind.positions_of(&lexicographic_path(&blind, &zero, None)) + != aware.positions_of(&lexicographic_path(&aware, &zero, None)) + }) + }) + .into_iter() + .filter(|m| *m) + .count(); + + let mut trials = Vec::new(); + for (name, w) in &weight_sets { + let models: [(String, Option); 3] = [ + ("tap-blind".into(), None), + ( + format!( + "tap-aware, tap_shift = position_shift ({})", + w.position_shift + ), + Some(w.position_shift), + ), + ("tap-aware, tap_shift = 0".into(), Some(0)), + ]; + for (model, shift) in models { + for (split, lines) in [ + ("whole corpus", tap_lines.clone()), + ( + "holdout songs", + tap_lines + .iter() + .copied() + .filter(|l| l.test) + .collect::>(), + ), + ] { + trials.push(TapTrial { + weights: (*name).to_string(), + model: model.clone(), + split, + slice: tap_slice(&lines, w, shift), + }); + } + } + } + + println!( + "\ncontrol: {control_mismatches} of {} untapped lines differ between tap-blind and tap-aware objectives", + untapped.len() + ); + let mut matched = BTreeMap::new(); + for (name, w) in &weight_sets { + matched.insert(*name, length_matched(&tap_lines, &untapped, w)); + } + println!("\n| weights | model | split | lines | notes (tapped) | human path in optimum set | human excess p50 / p90 | excess per note | unique optimum | agreement | on tapped notes | on fretted notes | ceiling |"); + println!("|---|---|---|---|---|---|---|---|---|---|---|---|---|"); + for t in &trials { + let s = &t.slice; + println!( + "| {} | {} | {} | {} | {} ({}) | {:.1}% | {} / {} | {:.2} | {:.1}% | {:.1}% | {:.1}% | {:.1}% | {:.1}% |", + t.weights, + t.model, + t.split, + s.lines, + s.notes, + s.tapped_notes, + 100.0 * s.human_in_optimum_set as f64 / s.lines.max(1) as f64, + s.human_excess.p50, + s.human_excess.p90, + s.excess_per_note, + 100.0 * s.unique_optimum_lines as f64 / s.lines.max(1) as f64, + 100.0 * s.agree, + 100.0 * s.agree_tapped, + 100.0 * s.agree_fretted, + 100.0 * s.ceiling + ); + } + println!("\nLength-matched untapped baseline (whole corpus, reweighted to the tapped slice's line lengths):"); + println!("\n| weights | human path in optimum set | excess per note | agreement | ceiling |"); + println!("|---|---|---|---|---|"); + for (name, m) in &matched { + println!( + "| {name} | {:.1}% | {:.2} | {:.1}% | {:.1}% |", + 100.0 * m.human_in_optimum_set, + m.excess_per_note, + 100.0 * m.agree, + 100.0 * m.ceiling + ); + } + let report = TapsReport { + schema: "griff.constraint-lab-taps", + version: 1, + control_mismatches, + control_lines: untapped.len(), + trials, + length_matched: matched, + corpus: corpus.facts, + }; + write_json(&out.join("taps.json"), &report) +} + // ── repeat consistency ──────────────────────────────────────────────────────── /// Window of a repeated figure, in notes. @@ -1664,6 +2001,7 @@ fn run() -> Result<(), String> { "repeat-report" => repeat_report(&corpus, &args.models, &args.out), "ties-check" => ties_check(&corpus, &args.models, &args.out), "tiebreak" => tiebreak(corpus, &args.models, &args.out), + "taps" => taps(corpus, &args.out), other => return Err(format!("unknown command {other}")), }; result.map_err(|e| e.to_string()) From ce813a1f68b7926140959629aa8ce3002a308249 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:07:14 +0500 Subject: [PATCH 4/8] docs(lab): technique-aware fingering audit, oracle stage Records the tap-attribution experiment (aggregates only, whole corpus; the 15-line holdout slice is reported but not interpreted): with tap labels from the tab and unchanged weights, the tapped slice reaches the length-matched untapped baseline on excess per note (2.94 -> 1.27 vs 1.26), agreement (39.4% -> 44.3% vs 44.9%) and ceiling (45.7% -> 52.9% vs 54.1%), but its human paths almost never land in the optimum set (1.3% vs 21.0%). 75% of the residual is fretting-hand travel spent keeping tapping figures on one string, and open strings used as pull-off targets make up another 12.5%; legato continuity is the missing term, ahead of any hidden technique inference. Uses "human path in the model's optimum set" rather than "human optimal" throughout. Co-Authored-By: Claude Opus 5 --- .../2026-09-fingering-tap-attribution.md | 164 ++++++++++++++++++ docs/decisions.log.md | 14 ++ lab/README.md | 11 ++ 3 files changed, 189 insertions(+) create mode 100644 docs/audit/2026-09-fingering-tap-attribution.md diff --git a/docs/audit/2026-09-fingering-tap-attribution.md b/docs/audit/2026-09-fingering-tap-attribution.md new file mode 100644 index 00000000..6b1a06f6 --- /dev/null +++ b/docs/audit/2026-09-fingering-tap-attribution.md @@ -0,0 +1,164 @@ +# 2026-09 — Technique-aware fingering, oracle stage: does hand attribution explain the tapping slice? + +A follow-up to the optimality-gap and tie-break audits +([`2026-09-fingering-optimality-gap.md`](2026-09-fingering-optimality-gap.md), +[`2026-09-fingering-tie-break.md`](2026-09-fingering-tie-break.md)). + +**Terminology.** "The human path lies in the model's optimum set" means that +the tab author's fingering happens to minimize the *model's* cost function. +It says nothing about how well anyone plays. When the human path falls +outside the set, the model is missing something the player took into account. + +## Question + +The `v1` objective reads every note's position as the fretting hand's +position. Tapping breaks that assumption. A tapped note is played by the +picking hand while the fretting hand stays where it was. On the whole corpus, +no line with tapped notes had its human path in the `v1-fit` optimum set. + +> **If the model is told which hand plays each note, how much of that failure +> goes away?** + +This is the **oracle stage**: the technique labels come from the tab +(`NoteMark::Tap`), not from inference. Changing the physiology model and the +technique recognition at once would leave one number and two suspects. + +## What was built + +`lab/`, red → green per commit: + +- **`TabLine::tapped`** — one tap flag per note. +- **`technique::tap_aware_cost`** — `v1` per-note costs and string changes + between neighbours, plus two travel terms: + - **fretting hand**: travel from the previous *untapped* note, so its anchor + carries across taps; + - **picking hand**: travel from the previous *tapped* note, at `tap_shift`. + + Without taps it equals `v1_cost`. +- **`technique::tap_aware_chain`** — the same objective as a `ties::Chain`, + so the exact optimum-set DPs of the tie-break audit apply. Each state pairs + a note's candidate with the other hand's last candidate. Transitions that + contradict that carried candidate are inadmissible, which keeps state paths + and position assignments in one-to-one correspondence. +- **`fingering_gap taps`** — tap-blind and tap-aware models under **the same + weights**. With `tap_shift = position_shift`, costs and excesses are in the + same units, so they compare directly. Also reported: an untapped baseline + reweighted to the tapped slice's line lengths. + +## Verification + +- Contract suite: the tap-aware cost is hand-computed on the 5 → 8 → tap 12 + → 8 → 5 figure (14 tap-blind, 6 tap-aware) and equals `v1_cost` without + taps. The chain is checked by brute force over position assignments and all + tap masks: same optimum, same optimal-assignment count, and every + admissible state path scores its assignment exactly. Without taps it is the + `v1` chain. +- **Control on the corpus:** on **0 of 8,890** untapped lines do the + tap-blind and tap-aware objectives disagree on the optimum or the + production-order path. + +## The slice + +- **Whole corpus:** 155 lines with at least one tapped note (1.7% of 9,045 + lines), 14,480 notes (4.4% of 326,130), of which 3,132 are tapped. Tapped + lines are long: 93 notes on average. +- **Holdout songs:** only 15 of these lines. Those numbers are in + `taps.json`, but no conclusion rests on them. +- **Tap labels undercount.** Tab authors often leave tapping unmarked. Some + tapping therefore sits unlabelled in the "untapped" lines, and the counts + above are a lower bound. + +## Results (whole corpus) + +`v1-fit` weights (fret 0, open-string penalty 3, position shift 1, string +change 0): + +| model | human path in optimum set | excess per note | agreement | on tapped notes | ceiling | +|---|---|---|---|---|---| +| tap-blind | 0.0% | 2.94 | 39.4% | 30.7% | 45.7% | +| **tap-aware, `tap_shift` = 1** | 1.3% | **1.27** | **44.3%** | **44.6%** | **52.9%** | +| tap-aware, `tap_shift` = 0 | 3.9% | 1.12 | 39.6% | 23.1% | 62.5% | +| *length-matched untapped lines* | *21.0%* | *1.26* | *44.9%* | — | *54.1%* | + +Production `v1` weights: + +| model | human path in optimum set | excess per note | agreement | ceiling | +|---|---|---|---|---| +| tap-blind | 0.0% | 7.76 | 32.9% | 33.9% | +| tap-aware, `tap_shift` = 2 | 1.3% | 4.87 | 34.9% | 35.7% | +| *length-matched untapped lines* | *13.7%* | *5.03* | *36.6%* | *37.1%* | + +## Reading + +**Hand attribution explains the slice's *excess*, not its *exactness*.** With +the tap labels, the tapped slice reaches the untapped baseline of the same +line lengths on every continuous measure, under both weight sets: + +- excess per note 1.27 against 1.26 (`v1-fit`); +- agreement 44.3% against 44.9%; +- ceiling 52.9% against 54.1%. + +Agreement on the tapped notes themselves rises 14 points. Yet the human path +lands in the optimum set in only 1.3% of tapped lines, against 21.0% of +comparable untapped lines. The human paths are now *about as close* to the +optimum as elsewhere, but almost never *on* it: 153 of 155 lines keep a +positive residual. + +`tap_shift = 0` is the wrong model. A free picking hand flattens the +objective (no line has a unique optimum), raises the ceiling and makes the +tie-break choice on tapped notes worse (23.1%). Right-hand travel is real. + +## Where the residual is + +Human cost minus the tap-aware optimum's cost on the slice (`v1-fit`, +`tap_shift` = 1), by component: + +| component | human | tap-aware optimum | human − optimum | +|---|---|---|---| +| fretting-hand travel | 26,475 | 12,684 | **+13,791 (75%)** | +| picking-hand travel | 4,659 | 2,357 | +2,302 (12.5%) | +| open-string penalty | 2,769 | 450 | +2,319 (12.5%) | + +- **The residual is mostly fretting-hand travel**, and it has a musical + shape. Tapping figures are built **on one string**: tap, pull off to a + fretted note on the same string, often on to an open string. The tab + authors put a tap on the string of the preceding fretted note 1,023 times; + the model's optimum does so 525 times. With string changes free under + `v1-fit`, the model scatters the figure across strings to save fret travel. + The player keeps it on one string, pays the travel, and gets the legato. +- **The open-string residual is the same story.** An open string is a natural + pull-off target in these figures, but `v1-fit` penalizes open strings, + having been fitted mostly on untapped material. + +## Conclusion + +Attributing tapped notes to the picking hand is necessary, and on this slice +it is sufficient to remove the slice-specific penalty. What remains is not a +hand-attribution problem but a **technique-continuity** problem: legato +figures (tap, pull-off, hammer-on) bind notes to one string, and that binding +is missing from the objective. Hidden hand inference should wait until that is +modelled. Otherwise the inference stage would learn to paper over a +continuity cost it cannot see. + +## Limitations + +- Oracle labels from the tab, and tap marks undercount (above). +- Hand attribution is one binary label per note. There are no simultaneous + two-hand notes, no multi-finger picking-hand tapping, and no per-finger + model. +- One tab is taken as ground truth; `v1-fit` weights are reused unchanged + (fitted on all lines); the holdout slice is too small to report on. + +## Follow-ups proposed + +1. **Legato continuity:** carry Guitar Pro's hammer-on, pull-off and legato + spans (`TechniqueSpan`) onto tablature lines and add a same-string + continuity term (or constraint) for notes joined by legato or tap. Measure + the tap slice's exact-optimum rate against the length-matched baseline + again. +2. Only then **hidden technique inference** for MIDI-sourced lines: tap, + hammer-on, pull-off and slide as latent per-note labels, with these + tab-labelled lines as the supervised check. +3. Decide between chord voicing (anchors for MIDI lines, per the tie-break + audit) and full technique-aware fingering as the next production-facing + step. diff --git a/docs/decisions.log.md b/docs/decisions.log.md index 6a25a5ab..f8a01e61 100644 --- a/docs/decisions.log.md +++ b/docs/decisions.log.md @@ -2872,3 +2872,17 @@ Architectural decisions go to [`adr/`](adr/) instead. recovers 3.2 points (47.3%, 28% of the gap). The discriminating information is contextual, so the next Lab subject (chord voicing) is also what makes such anchors available for MIDI-sourced material. + +- 2026-09-17 — In the context of the fingering objective failing completely + on lines with tapped notes (no human path in the model's optimum set), we + decided to **test hand attribution as an oracle first — tap labels from + the tab, same weights, exact DPs — before any hidden technique + inference**, to achieve a clean attribution of that failure, accepting + that the result says nothing yet about MIDI-sourced material. Result + (`docs/audit/2026-09-fingering-tap-attribution.md`): attribution brings + the tapped slice to parity with length-matched untapped lines on excess + per note (2.94 → 1.27 vs 1.26), agreement (39.4% → 44.3% vs 44.9%) and + ceiling, but not on exactness (1.3% vs 21.0% of lines with the human path + in the optimum set); 75% of the residual is fretting-hand travel spent + keeping tapping figures on one string. Legato continuity comes before + technique inference. diff --git a/lab/README.md b/lab/README.md index 681937e5..8a337b4e 100644 --- a/lab/README.md +++ b/lab/README.md @@ -107,6 +107,17 @@ with and without the line's hand anchor. Results: [`../docs/audit/2026-09-fingering-tie-break.md`](../docs/audit/2026-09-fingering-tie-break.md). +## Technique-aware fingering (oracle stage) + +`src/technique.rs` attributes tapped notes (`TabLine::tapped`, from the tab) +to the picking hand: the fretting hand's anchor carries across taps, and the +picking hand pays its own travel. `tap_aware_chain` encodes it for the exact +optimum-set DPs. `fingering_gap taps` compares tap-blind and tap-aware models +under the same weights on lines with tapped notes, with an untapped baseline +reweighted to the same line lengths. + +Results: [`../docs/audit/2026-09-fingering-tap-attribution.md`](../docs/audit/2026-09-fingering-tap-attribution.md). + ## Known spike limits (deliberate) - The reference solver is leaf-checked backtracking with two sound band From de15e1e4ff58edcf3a20fa957d763e8c7de550bf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:12:43 +0500 Subject: [PATCH 5/8] docs(lab): state the tap residual decomposition as non-causal The audit read "75% of the residual is fretting-hand travel" as if it located the problem. It is a decomposition under the current objective: adding a continuity cost would move the optimum and redistribute the residual across the travel and open-string terms. The section now says so, states string continuity (H1) and conditional open strings (H2) as hypotheses for the next oracle stage, and the conclusion and decision record follow suit. Co-Authored-By: Claude Opus 5 --- .../2026-09-fingering-tap-attribution.md | 45 ++++++++++++------- docs/decisions.log.md | 7 +-- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/docs/audit/2026-09-fingering-tap-attribution.md b/docs/audit/2026-09-fingering-tap-attribution.md index 6b1a06f6..08529a1c 100644 --- a/docs/audit/2026-09-fingering-tap-attribution.md +++ b/docs/audit/2026-09-fingering-tap-attribution.md @@ -119,26 +119,37 @@ Human cost minus the tap-aware optimum's cost on the slice (`v1-fit`, | picking-hand travel | 4,659 | 2,357 | +2,302 (12.5%) | | open-string penalty | 2,769 | 450 | +2,319 (12.5%) | -- **The residual is mostly fretting-hand travel**, and it has a musical - shape. Tapping figures are built **on one string**: tap, pull off to a - fretted note on the same string, often on to an open string. The tab - authors put a tap on the string of the preceding fretted note 1,023 times; - the model's optimum does so 525 times. With string changes free under - `v1-fit`, the model scatters the figure across strings to save fret travel. - The player keeps it on one string, pays the travel, and gets the legato. -- **The open-string residual is the same story.** An open string is a natural - pull-off target in these figures, but `v1-fit` penalizes open strings, - having been fitted mostly on untapped material. +This is a **decomposition under the current objective, not a causal +attribution**. The terms interact: adding a cost for, say, string continuity +would move the optimum path and redistribute the residual across all three +components. Read the table as "75% of the residual cost under the current +objective falls on the fretting-hand travel term", not as "75% of the problem +is the fretting hand". + +What the decomposition suggests, as hypotheses for the next stage: + +- **H1, string continuity.** Tapping figures appear to live **on one string**: + tap, pull off to a fretted note on the same string, often on to an open + string. The tab authors put a tap on the string of the preceding fretted + note 1,023 times; the model's optimum does so 525 times. With string + changes free under `v1-fit`, the optimum can scatter a figure across + strings to save fret travel, and a player keeping it on one string would + pay exactly this kind of travel. +- **H2, conditional open strings.** An open string looks like a natural + pull-off target in these figures, while `v1-fit` penalizes open strings + everywhere (it was fitted mostly on untapped material). This points to an + interaction (open target under a pull-off), not to a different global open + weight. ## Conclusion -Attributing tapped notes to the picking hand is necessary, and on this slice -it is sufficient to remove the slice-specific penalty. What remains is not a -hand-attribution problem but a **technique-continuity** problem: legato -figures (tap, pull-off, hammer-on) bind notes to one string, and that binding -is missing from the objective. Hidden hand inference should wait until that is -modelled. Otherwise the inference stage would learn to paper over a -continuity cost it cannot see. +Attributing tapped notes to the picking hand is necessary. On this slice it +is sufficient to remove the slice-specific *excess*, but not to put the human +paths into the optimum set. The residual has a systematic structure that the +objective does not model; H1 and H2 point to technique continuity (legato +figures binding notes to one string) as the candidate, to be tested as an +observed-label oracle before any hidden technique inference. Otherwise an +inference stage could learn to paper over a continuity cost it cannot see. ## Limitations diff --git a/docs/decisions.log.md b/docs/decisions.log.md index f8a01e61..092dbc3a 100644 --- a/docs/decisions.log.md +++ b/docs/decisions.log.md @@ -2883,6 +2883,7 @@ Architectural decisions go to [`adr/`](adr/) instead. the tapped slice to parity with length-matched untapped lines on excess per note (2.94 → 1.27 vs 1.26), agreement (39.4% → 44.3% vs 44.9%) and ceiling, but not on exactness (1.3% vs 21.0% of lines with the human path - in the optimum set); 75% of the residual is fretting-hand travel spent - keeping tapping figures on one string. Legato continuity comes before - technique inference. + in the optimum set). Under the current objective's decomposition, 75% of + the residual cost falls on the fretting-hand travel term; with tapping + figures seemingly kept on one string, legato continuity is the hypothesis + to test next, as an observed-label oracle before technique inference. From f4c1f44fefe56f5cd7bd8bd307748a422b84e665 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:15:19 +0500 Subject: [PATCH 6/8] =?UTF-8?q?docs(lab):=20tap=20slice=20is=20GP3-5=20onl?= =?UTF-8?q?y=20=E2=80=94=20GPIF=20tapping=20is=20not=20imported?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured while preparing the legato stage: the guitarpro 0.4.2 GPIF import never reads the Tapped note property (the beat's tap effect stays a placeholder), so no GP6/GP7 tapping reaches griff, although 31 of the corpus's 145 GPIF files contain it (1,006 Tapped note definitions, 45 LeftHandTapped). The audit now states that the 155-line tap slice is GP3-5 material only and that GP6/7 tapping sits unlabelled inside the "untapped" lines and the length-matched baseline. Co-Authored-By: Claude Opus 5 --- docs/audit/2026-09-fingering-tap-attribution.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/audit/2026-09-fingering-tap-attribution.md b/docs/audit/2026-09-fingering-tap-attribution.md index 08529a1c..2696ca51 100644 --- a/docs/audit/2026-09-fingering-tap-attribution.md +++ b/docs/audit/2026-09-fingering-tap-attribution.md @@ -64,9 +64,15 @@ technique recognition at once would leave one number and two suspects. lines are long: 93 notes on average. - **Holdout songs:** only 15 of these lines. Those numbers are in `taps.json`, but no conclusion rests on them. -- **Tap labels undercount.** Tab authors often leave tapping unmarked. Some - tapping therefore sits unlabelled in the "untapped" lines, and the counts - above are a lower bound. +- **Tap labels undercount — and one cause is the importer.** Tab authors + sometimes leave tapping unmarked. More importantly, the `guitarpro` 0.4.2 + GPIF import never reads the `Tapped` note property: it leaves the beat's + tap effect as a placeholder. As a result, **no GP6/GP7 tapping reaches + griff**, although 31 of the corpus's 145 GPIF files contain it: 1,006 + `Tapped` note definitions, plus 45 `LeftHandTapped`. The 155-line slice is + therefore GP3–5 material only, and GP6/7 tapping sits unlabelled inside the + "untapped" lines and the length-matched baseline. (GPIF deduplicates + repeated notes, so 1,006 is a lower bound on played tapped notes.) ## Results (whole corpus) @@ -153,7 +159,8 @@ inference stage could learn to paper over a continuity cost it cannot see. ## Limitations -- Oracle labels from the tab, and tap marks undercount (above). +- Oracle labels from the tab; tap marks undercount, including all GP6/7 + tapping, which the importer drops (above). - Hand attribution is one binary label per note. There are no simultaneous two-hand notes, no multi-finger picking-hand tapping, and no per-finger model. From 96b115cdc2351a984e67c766bda23ab61f32e7bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 09:27:45 +0500 Subject: [PATCH 7/8] docs(lab): re-measure the tap slice after the GPIF import fix #201 imports GP6/7 tapping. Rerun on this PR's lab code merged locally with main: the slice grows from 155 to 242 lines, and the length-matched baseline had contained that unlabelled tapping (1.26 -> 1.17 excess per note for the original lines). Hand attribution closes most, not all, of the slice's excess (94% on the original lines against the corrected pool, 66% on the added GP6/7 lines against their format, 78% overall); exactness stays out of reach in both formats. The remaining excess concentrates in one-string tapping figures with open-string pull-offs. Stage 2 starts from this re-measured baseline, per format and with a per-song concentration check. Co-Authored-By: Claude Opus 5 --- .../2026-09-fingering-tap-attribution.md | 142 +++++++++++++++++- 1 file changed, 139 insertions(+), 3 deletions(-) diff --git a/docs/audit/2026-09-fingering-tap-attribution.md b/docs/audit/2026-09-fingering-tap-attribution.md index 2696ca51..dd563bd8 100644 --- a/docs/audit/2026-09-fingering-tap-attribution.md +++ b/docs/audit/2026-09-fingering-tap-attribution.md @@ -9,6 +9,14 @@ the tab author's fingering happens to minimize the *model's* cost function. It says nothing about how well anyone plays. When the human path falls outside the set, the model is missing something the player took into account. +> **Re-measured after the GPIF import fix (#201).** GP6/7 tapping now reaches +> the lab, so the slice grows from 155 to 242 lines. The length-matched +> baseline below also contained that unlabelled tapping. Against the corrected +> baseline, hand attribution closes **most, not all**, of the slice's excess. +> The exactness finding stands. The sections from "Question" to "Conclusion" +> are the original measurement, kept as run; see +> [Re-measurement after #201](#re-measurement-after-201). + ## Question The `v1` objective reads every note's position as the fretting hand's @@ -96,6 +104,9 @@ Production `v1` weights: ## Reading +*(Revised after #201: the excess half of this reading does not survive the +corrected baseline; see [Re-measurement after #201](#re-measurement-after-201).)* + **Hand attribution explains the slice's *excess*, not its *exactness*.** With the tap labels, the tapped slice reaches the untapped baseline of the same line lengths on every continuous measure, under both weight sets: @@ -157,10 +168,135 @@ figures binding notes to one string) as the candidate, to be tested as an observed-label oracle before any hidden technique inference. Otherwise an inference stage could learn to paper over a continuity cost it cannot see. +*(Revised after #201: "sufficient to remove the slice-specific excess" does +not hold against the corrected baseline; see below.)* + +## Re-measurement after #201 + +**Setup.** #201 (merged into `main`) imports GPIF `Tapped` and keeps the +hammer-on flag on origin notes only. This PR's lab code was rebuilt on a +local, unpushed merge of `main` (`c028609`) and `fingering_gap taps` rerun +with the same weights. A local per-line dump (not committed) splits the +slice. Rerun on this PR's head, the command reproduces the report above byte +for byte. + +**What changed in the data (whole corpus).** + +- **Lines unchanged.** Line cuts are the same (9,045 lines, same ids). The + 155 original tapped lines (all GP3–5) are unchanged line by line. The + hammer-on fix does not touch this experiment, which has no legato term. +- **87 GP6/7 lines gain tap labels.** They come from 30 files and hold 9,042 + notes, 1,840 of them tapped; 18 of the lines are on holdout songs. The + slice grows to **242 lines, 23,522 notes, 4,972 tapped**. +- **The baseline pool shrinks.** The same 87 lines leave the untapped pool + (8,890 → 8,803 lines). They had been scored there tap-blind, which inflated + the baseline. For the original 155 lines, the `v1-fit` length-matched + baseline moves from 1.26 to **1.17** excess per note, and agreement from + 44.9% to 45.3%. + +**Results (whole corpus, `v1-fit`, tap-aware at `tap_shift` = 1).** Baselines +are untapped lines reweighted to each subset's line lengths, drawn from three +pools: all untapped lines, the same format family, and the same files. + +| subset | model or baseline | human path in optimum set | excess per note | agreement | on tapped notes | ceiling | +|---|---|---|---|---|---|---| +| all 242 | tap-blind | 0.0% | 3.17 | 38.3% | 31.4% | 43.8% | +| all 242 | **tap-aware** | 1.7% | **1.60** | 43.8% | 46.0% | 52.2% | +| all 242 | *baseline: all untapped* | *20.9%* | *1.17* | *45.3%* | — | *54.6%* | +| original 155 (GP3–5) | tap-aware | 1.3% | 1.27 | 44.3% | 44.6% | 52.9% | +| original 155 (GP3–5) | *baseline: all / GP3–5 / same files* | *21.2 / 17.9 / 14.7%* | *1.17 / 1.08 / 0.98* | *45.3 / 45.2 / 48.3%* | — | *54.6 / 54.1 / 56.5%* | +| added 87 (GP6/7) | tap-blind | 0.0% | 3.52 | 36.5% | 32.6% | 40.8% | +| added 87 (GP6/7) | tap-aware | 2.3% | 2.13 | 42.9% | 48.4% | 51.1% | +| added 87 (GP6/7) | *baseline: all / GP6/7 / same files* | *20.4 / 23.7 / 20.7%* | *1.16 / 1.41 / 1.22* | *45.3 / 45.2 / 44.8%* | — | *54.7 / 55.4 / 56.4%* | + +Share of the gap between tap-blind excess and the baseline that tap +attribution closes: + +| subset | baseline pool | share closed | +|---|---|---| +| original 155 | pre-#201 pool, as reported above | 99% | +| original 155 | all untapped / GP3–5 | 94% / 90% | +| added 87 | all untapped / GP6/7 | 59% / 66% | +| all 242 | all untapped | 78% | + +Other results: + +- **Production `v1` weights (all 242).** Tap-blind 7.89, tap-aware + (`tap_shift` = 2) 5.21, baseline 4.92 excess per note. The human path is in + the optimum set for 0.8% of lines against 13.7% of baseline lines. +- **`tap_shift = 0` is still the wrong model.** Agreement on tapped notes + falls to 15.7%, and to 3.2% on the added lines. +- **Holdout songs.** Now 33 lines; they are in `taps.json` but not + interpreted. + +**Concentration.** Two transcriptions of one song (the same song key, both +on training songs) supply three lines of 461–541 notes. Those lines carry +46% of the added lines' residual and 23% of the whole slice's residual. In +them the tab keeps a repeated tapping figure on one string: tap, pull-off to +the open string, two fretted notes. The lead-in is played on an open string +as well. The tap-aware `v1-fit` optimum spreads the same figure over four +strings at higher frets, which avoids the open-string penalty. Without these +two files, the added lines sit at their format's baseline on the continuous +measures: + +- excess per note 1.38 against 1.38; +- agreement 47.8% against 45.4%. + +They are still not on the optimum: 2.4% against 24.4%. + +**Where the residual is (all 242).** Human cost minus the tap-aware optimum's +cost, by component, checked per line against the dump. As above, this is a +decomposition under the current objective, not a causal attribution. + +| component | human | tap-aware optimum | human − optimum | +|---|---|---|---| +| fretting-hand travel | 48,229 | 20,143 | **+28,086 (75%)** | +| picking-hand travel | 7,803 | 3,711 | +4,092 (11%) | +| open-string penalty | 6,624 | 1,158 | +5,466 (15%) | + +Fretting-hand travel keeps its 75% share. Within the added lines the +open-string term takes a larger share: 16%, against 12.5% on the original +lines. A tap on the string of the immediately preceding untapped note occurs +as follows: + +| subset | human | optimum | +|---|---|---| +| all 242 | 1,770 | 832 | +| original 155 | 1,023 | 525 | +| added 87 | 747 | 307 | + +**Revised reading.** + +- **Stands.** Attributing tapped notes to the picking hand is necessary, and + picking-hand travel is real. Exactness remains out of reach. The human path + lies in the optimum set in 1–2% of tapped lines, against 15–24% for every + length-matched untapped baseline. This holds in both format families. +- **Revised.** Hand attribution does **not** fully explain the slice's + excess. It closes most of the gap: 94% on the original lines against the + corrected pool, 66% on the added lines against their own format, 78% on + the whole slice. The earlier near-exact match (1.27 against 1.26) came + partly from unlabelled GP6/7 tapping inside the baseline pool. +- **Consistent with H1/H2.** Where the remaining excess concentrates, it has + the shape H1 and H2 anticipated: one-string tapping figures with + open-string pull-offs. This is an observation on a concentrated subset, + not a test. + +**Consequences for stage 2 (legato continuity).** + +- **Baseline.** The baseline is this re-measurement, not the 155-line + slice: 242 lines and a pool of 8,803 untapped lines. +- **Format.** Report per format family, with format-matched baselines next + to the pooled one. +- **Concentration.** Report a concentration check by song key, for example + leave one song out. One song supplies about a quarter of the residual. +- **Primary target.** Exactness (human path in the optimum set) is the + primary target, ahead of excess. + ## Limitations -- Oracle labels from the tab; tap marks undercount, including all GP6/7 - tapping, which the importer drops (above). +- Oracle labels from the tab; tap marks undercount. The original measurement + also missed all GP6/7 tapping, which the importer dropped until #201 (see + the re-measurement). - Hand attribution is one binary label per note. There are no simultaneous two-hand notes, no multi-finger picking-hand tapping, and no per-finger model. @@ -173,7 +309,7 @@ inference stage could learn to paper over a continuity cost it cannot see. spans (`TechniqueSpan`) onto tablature lines and add a same-string continuity term (or constraint) for notes joined by legato or tap. Measure the tap slice's exact-optimum rate against the length-matched baseline - again. + again, starting from the re-measured slice after #201. 2. Only then **hidden technique inference** for MIDI-sourced lines: tap, hammer-on, pull-off and slide as latent per-note labels, with these tab-labelled lines as the supervised check. From 701285421608b1c243dfe612add81f6db0b74792 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 16:13:00 +0500 Subject: [PATCH 8/8] docs(lab): re-measure the tap slice after the tuplet import fix (#202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This branch now contains #202, so the tap-attribution audit gains a "Re-measurement after #202" section and a pointer in its header. The numbers are from `fingering_gap taps`, rerun on this branch merged with main at e871a44, with the same weights. - Slice: 242 → 226 lines, 25,571 notes (5,260 tapped). - Untapped pool: 8,803 → 8,740 lines. Control: 0 of 8,740 differ. - `v1-fit`, whole corpus, format (human path in optimum set / excess per note / agreement): - tap-blind: 0.0% / 3.20 / 38.5%; - tap-aware: 0.9% / 1.63 / 43.1%; - length-matched baseline: 20.0% / 1.16 / 44.9%. - Share of the excess gap closed: 78% → 77%. The subset split, the format-matched baselines, the concentration check and the residual decomposition came from a local dump keyed to the pre-#202 lines. They were not re-run and are marked as such. The decision-log entry still read "parity" from before #201. It now carries the revised reading: most of the excess gap is closed, not all, and exactness stays out of reach. The slice numbers reproduce the impact sweep recorded before the legato census, figure for figure. Co-Authored-By: Claude Opus 5 --- .../2026-09-fingering-tap-attribution.md | 67 +++++++++++++++++++ docs/decisions.log.md | 4 ++ 2 files changed, 71 insertions(+) diff --git a/docs/audit/2026-09-fingering-tap-attribution.md b/docs/audit/2026-09-fingering-tap-attribution.md index dd563bd8..31d0675f 100644 --- a/docs/audit/2026-09-fingering-tap-attribution.md +++ b/docs/audit/2026-09-fingering-tap-attribution.md @@ -16,6 +16,11 @@ outside the set, the model is missing something the player took into account. > The exactness finding stands. The sections from "Question" to "Conclusion" > are the original measurement, kept as run; see > [Re-measurement after #201](#re-measurement-after-201). +> +> **Re-measured again after the tuplet import fix (#202).** The slice is now +> 226 lines, fewer but longer. All three readings (partial excess closure, +> exactness out of reach, and the whole-slice share closed: 77%) still hold. See +> [Re-measurement after #202](#re-measurement-after-202). ## Question @@ -292,6 +297,68 @@ as follows: - **Primary target.** Exactness (human path in the optimum set) is the primary target, ahead of excess. +## Re-measurement after #202 + +**Setup.** #202 (merged into `main`) corrects the importer's tuplet durations. +Before it, bars with tuplets overflowed into the next bar, and tab lines, +which order notes by onset, interleaved notes from neighbouring bars. For +this section, this PR was merged with `main` at `e871a44` and +`fingering_gap taps` was rerun with the same weights. The slice rule (lines +containing a tapped note) is unchanged. + +**What changed in the data (whole corpus).** + +- **The slice.** 242 → **226** lines, 23,522 → 25,571 notes, 4,972 → 5,260 + tapped notes. Lines no longer break where neighbouring bars used to + interleave, so the slice has fewer but longer lines. +- **The untapped pool.** 8,803 → 8,740 lines. +- **Holdout songs.** 33 → 30 lines. Reported in `taps.json`, not interpreted. +- **Control.** The tap-blind and tap-aware objectives still agree on every + untapped line (0 of 8,740 differ). + +**Results (whole corpus).** + +`v1-fit`. Tap-aware uses `tap_shift` = 1; the baseline is untapped lines +reweighted to the slice's line lengths. + +| model or baseline | human path in optimum set | excess per note | agreement | on tapped notes | ceiling | +|---|---|---|---|---|---| +| tap-blind | 0.0% → 0.0% | 3.17 → 3.20 | 38.3% → 38.5% | 31.4% → 29.0% | 43.8% → 44.1% | +| **tap-aware** | 1.7% → **0.9%** | 1.60 → **1.63** | 43.8% → 43.1% | 46.0% → 45.3% | 52.2% → 52.3% | +| *baseline: all untapped* | *20.9% → 20.0%* | *1.17 → 1.16* | *45.3% → 44.9%* | — | *54.6% → 54.5%* | + +Other results: + +- **Share of the excess gap closed** (tap-blind excess against the pooled + baseline, whole slice): 78% → **77%**. +- **Production `v1` weights.** + - Excess per note: tap-blind 8.10, tap-aware (`tap_shift` = 2) 5.41, + baseline 4.91. + - The human path is in the optimum set for 0.9% of lines, against 13.1% of + baseline lines. +- **`tap_shift = 0` is still the wrong model.** Agreement on tapped notes is + 15.4%. + +**Not re-run.** + +- the split into the original 155 and the added 87 lines; +- the format-matched and same-file baselines; +- the concentration check; +- the residual decomposition by cost component. + +These came from a local per-line dump keyed to the pre-#202 lines, and #202 +changes those lines. They stay as measured after #201. Stage 2 reports its +per-format and concentration checks on the 226-line slice. + +**Reading.** + +- **The #201 revision stands.** Hand attribution closes most of the slice's + excess (77%), not all of it. +- **Exactness remains out of reach.** The human path is in the optimum set in + 0.9% of tapped lines, against 20.0% for the length-matched baseline. +- **Stage 2's baseline is this measurement:** 226 lines against an 8,740-line + pool. + ## Limitations - Oracle labels from the tab; tap marks undercount. The original measurement diff --git a/docs/decisions.log.md b/docs/decisions.log.md index 9cabeb72..77f32320 100644 --- a/docs/decisions.log.md +++ b/docs/decisions.log.md @@ -2969,3 +2969,7 @@ Architectural decisions go to [`adr/`](adr/) instead. the residual cost falls on the fretting-hand travel term; with tapping figures seemingly kept on one string, legato continuity is the hypothesis to test next, as an observed-label oracle before technique inference. + (Revised after the import fixes #201 and #202: GP6/7 tapping now reaches + the slice (226 lines). Attribution closes most, not all, of the excess gap + (3.20 → 1.63 against 1.16, 77%). Exactness stays out of reach, 0.9% against + 20.0% — see the audit's re-measurements.)