From 9b4777eeb5a6f0eb75728cd32126891edf9dfb92 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 07:24:55 +0500 Subject: [PATCH 01/10] =?UTF-8?q?test(lab):=20red=20=E2=80=94=20optimum-se?= =?UTF-8?q?t=20analysis=20and=20learned=20secondary=20tie-break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The optimality-gap audit (#197) found the fitted v1 objective under-discriminative: on holdout songs the DP's fixed tie-break keeps 44.1% agreement with tab authors while the optimum set contains 55.5%. This opens the follow-up: measure that set exactly with chain DPs instead of an external solver, and learn a human-blind tie-break inside it. Pins, against brute force on exhaustive small families: - Chain::v1 mirrors the production objective path by path; - optimum_set: optimum, exact path count (saturating u64 with an exact natural log), and least / most / expected agreement with a reference among optimal paths, plus a most-agreeing optimal path — the achievable learning target; - lexicographic_path is primary- then secondary-optimal, reproduces the production DP path exactly with zero secondary weights, and with loss augmentation finds the least-agreeing optimal path; - path_features sums the per-note and per-transition secondary features; - train_secondary (averaged, loss-augmented structured perceptron over integer weights) converges on a separable tie-break, generalizes to held-out lines, is deterministic, and makes no update when the target is already chosen. All new functions are todo!() stubs (13 red); the existing 78 lab tests stay green. Co-Authored-By: Claude Opus 5 --- lab/src/lib.rs | 4 +- lab/src/ties.rs | 244 ++++++++++++++++++++++ lab/tests/ties.rs | 501 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 748 insertions(+), 1 deletion(-) create mode 100644 lab/src/ties.rs create mode 100644 lab/tests/ties.rs diff --git a/lab/src/lib.rs b/lab/src/lib.rs index 72eb86e..c1ce253 100644 --- a/lab/src/lib.rs +++ b/lab/src/lib.rs @@ -10,7 +10,8 @@ //! //! The optimization phase ([`optir`], [`fingering`]) adds an objective to //! the IR and measures the production fingering DP and a hand-position model -//! against an external optimum and against human tablature. +//! against an external optimum and against human tablature; [`ties`] +//! analyses the optimum set exactly and learns a secondary tie-break. //! //! Shape: typed problem → solver-neutral IR → `MiniZinc` emission + an exact //! in-repo reference solver → archived manifests. Research tooling only: @@ -23,3 +24,4 @@ pub mod manifest; pub mod optir; pub mod problems; pub mod solve; +pub mod ties; diff --git a/lab/src/ties.rs b/lab/src/ties.rs new file mode 100644 index 0000000..3b695f6 --- /dev/null +++ b/lab/src/ties.rs @@ -0,0 +1,244 @@ +//! Exact analysis of a fingering objective's **optimum set**, and a learned +//! **secondary objective** that breaks its ties. +//! +//! The optimality-gap audit (`docs/audit/2026-09-fingering-optimality-gap.md`) +//! found the production DP exact but its fitted weights under-discriminative: +//! many fingerings tie at the optimum, and the DP's fixed tie-break keeps far +//! less agreement with tab authors than the optimum set contains. This module +//! measures that set exactly — how many optimal paths, and the least, most and +//! expected agreement with a reference among them — with chain DPs instead of +//! an external solver, and learns a human-blind tie-break over it: primary cost +//! first, a learned secondary cost second, both minimized lexicographically. +//! +//! Research tooling only (lab crate); nothing here is a production dependency. + +use griff_core::event::{FretboardPosition, Pitch, Tuning}; +use griff_core::fretboard::FingeringWeights; + +use crate::problems::LabError; + +/// Number of secondary features ([`FEATURE_NAMES`]). +pub const FEATURES: usize = 20; + +/// Secondary feature names, in [`Features`] order. Per note: `fret`, `open`, +/// one-hot `string_1` … `string_7` (strings above 7 count as 7). Per +/// transition (Δ = this note − previous note): `fret_distance` |Δfret|, +/// `string_distance` |Δstring|, `string_change` [Δstring ≠ 0], `same_fret` +/// [Δfret = 0, both fretted], `span_over_3` / `span_over_5` [|Δfret| > 3 / 5, +/// both fretted], `open_transition` [either open], `diagonal` [Δstring ≠ 0 and +/// Δfret ≠ 0], `toward_high_string` [Δstring < 0], `fret_up` [Δfret > 0], +/// `box_move` [Δstring and Δfret nonzero with the same sign]. +pub const FEATURE_NAMES: [&str; FEATURES] = [ + "fret", + "open", + "string_1", + "string_2", + "string_3", + "string_4", + "string_5", + "string_6", + "string_7", + "fret_distance", + "string_distance", + "string_change", + "same_fret", + "span_over_3", + "span_over_5", + "open_transition", + "diagonal", + "toward_high_string", + "fret_up", + "box_move", +]; + +/// A feature vector (or a weight vector over it). +pub type Features = [i64; FEATURES]; + +/// A chain-structured fingering objective over one line: per note the +/// candidate positions (in [`Tuning::candidates`] order) with unary costs, and +/// a cost for every transition between consecutive candidates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Chain { + positions: Vec>, + unary: Vec>, + /// `pairwise[i][a][b]`: candidate `a` of note `i − 1` to candidate `b` of + /// note `i`; `pairwise[0]` is empty. + pairwise: Vec>>, +} + +impl Chain { + /// The production `v1` objective (as `griff_core::fretboard::infer_positions` + /// minimizes it) as a chain. + /// + /// # Errors + /// + /// [`LabError::EmptyLine`] for no pitches; [`LabError::UnpositionablePitch`] + /// when a pitch has no candidate at or below `max_fret`. + pub fn v1( + pitches: &[Pitch], + tuning: &Tuning, + weights: &FingeringWeights, + max_fret: u8, + ) -> Result { + let _ = (pitches, tuning, weights, max_fret); + todo!("v1 chain — green step") + } + + /// Notes in the line. + #[must_use] + pub fn len(&self) -> usize { + self.positions.len() + } + + /// `true` when the line has no notes (not constructible via [`Chain::v1`]). + #[must_use] + pub fn is_empty(&self) -> bool { + self.positions.is_empty() + } + + /// Candidate positions of note `note` (empty when out of range). + #[must_use] + pub fn candidates(&self, note: usize) -> &[FretboardPosition] { + self.positions.get(note).map_or(&[], Vec::as_slice) + } + + /// Primary cost of a path given as one candidate index per note; `None` + /// for a ragged path or an out-of-range index. + #[must_use] + pub fn cost(&self, path: &[usize]) -> Option { + let _ = path; + todo!("chain cost — green step") + } + + /// The positions a path selects; `None` as for [`Chain::cost`]. + #[must_use] + pub fn positions_of(&self, path: &[usize]) -> Option> { + let _ = path; + todo!("path positions — green step") + } +} + +/// A path count: exact while it fits `u64`, with its natural logarithm always. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PathCount { + /// The count, saturated at `u64::MAX`. + pub exact: u64, + /// `true` when the true count exceeds `u64::MAX`. + pub saturated: bool, + /// Natural logarithm of the true count. + pub ln: f64, +} + +/// Agreement with a reference over the optimum set. +#[derive(Debug, Clone, PartialEq)] +pub struct AgreementRange { + /// Fewest reference matches of any optimal path. + pub min: usize, + /// Most reference matches of any optimal path — the ceiling any + /// tie-break can reach. + pub max: usize, + /// Expected matches when an optimal path is drawn uniformly at random. + pub expected: f64, + /// An optimal path attaining `max` (ties: lowest candidate indices, as + /// the production DP breaks them) — the achievable target for learning. + pub best_path: Vec, +} + +/// The optimum set of a chain. +#[derive(Debug, Clone, PartialEq)] +pub struct OptimumSet { + /// The optimal primary cost. + pub optimum: i64, + /// How many paths attain it. + pub count: PathCount, + /// Agreement with the reference, when one of the chain's length was given. + pub agreement: Option, +} + +/// Exactly analyses the optimum set of `chain` (forward/backward counting and +/// lexicographic DPs; no search). `reference` positions are compared per note; +/// a reference of a different length yields `agreement: None`. +#[must_use] +pub fn optimum_set(chain: &Chain, reference: Option<&[FretboardPosition]>) -> OptimumSet { + let _ = (chain, reference); + todo!("optimum set — green step") +} + +/// Reference matches of a path; `None` for a ragged path or reference. +#[must_use] +pub fn path_matches( + chain: &Chain, + path: &[usize], + reference: &[FretboardPosition], +) -> Option { + let _ = (chain, path, reference); + todo!("path matches — green step") +} + +/// Secondary features of a path, summed over notes and transitions +/// ([`FEATURE_NAMES`]); `None` as for [`Chain::cost`]. +#[must_use] +pub fn path_features(chain: &Chain, path: &[usize]) -> Option { + let _ = (chain, path); + todo!("path features — green step") +} + +/// The path minimizing `(primary cost, secondary cost)` lexicographically, +/// secondary cost = `weights · features` (+ `margin` per note that matches the +/// reference when `augment = Some((reference, margin))` — loss-augmented +/// inference: it prefers cheap paths that *disagree*). Remaining ties keep the +/// lowest candidate indices, so zero weights and no augmentation reproduce the +/// production DP's path exactly. +#[must_use] +pub fn lexicographic_path( + chain: &Chain, + weights: &Features, + augment: Option<(&[FretboardPosition], i64)>, +) -> Vec { + let _ = (chain, weights, augment); + todo!("lexicographic path — green step") +} + +/// One training line: its primary chain and the tab author's positions. +#[derive(Debug, Clone)] +pub struct Example { + /// The primary objective over the line. + pub chain: Chain, + /// The tab author's positions, one per note. + pub human: Vec, +} + +/// Perceptron settings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PerceptronConfig { + /// Maximum passes over the examples. + pub epochs: usize, + /// Loss augmentation per agreeing note during training (0 = plain + /// perceptron). + pub margin: i64, +} + +/// A trained secondary objective. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrainedSecondary { + /// Averaged weights (the running sum of per-example weights — the same + /// argmin as the average, kept in integers). + pub weights: Features, + /// Updates made. + pub updates: u64, + /// Epochs run (fewer than configured when an epoch made no update). + pub epochs: usize, +} + +/// Learns secondary weights with an averaged, loss-augmented structured +/// perceptron **inside the primary optimum set**. The target per example is +/// the achievable one — [`AgreementRange::best_path`], not the human path, +/// which is often not primary-optimal. When the (augmented) prediction agrees +/// with the tab author less than the target does, the weights move by +/// `features(prediction) − features(target)`. Deterministic: examples in the +/// given order, integer arithmetic. +#[must_use] +pub fn train_secondary(examples: &[Example], config: &PerceptronConfig) -> TrainedSecondary { + let _ = (examples, config); + todo!("secondary perceptron — green step") +} diff --git a/lab/tests/ties.rs b/lab/tests/ties.rs new file mode 100644 index 0000000..1d3ff94 --- /dev/null +++ b/lab/tests/ties.rs @@ -0,0 +1,501 @@ +//! Red → contract tests for the optimum-set analysis and the learned +//! secondary objective (`ties`). +//! +//! Pins, against brute force on exhaustive small families: the chain mirrors +//! the production `v1` objective; the optimum set's optimum, exact path count, +//! and least / most / expected agreement with a reference; the lexicographic +//! DP is primary- then secondary-optimal and, with zero secondary weights, +//! reproduces the production DP path exactly; loss augmentation finds the +//! least-agreeing optimal path; counts saturate with an exact logarithm; and +//! the perceptron learns a separable tie-break deterministically. + +#![allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + clippy::missing_assert_message, + clippy::indexing_slicing, + clippy::arithmetic_side_effects, + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::float_cmp +)] + +use griff_constraint_lab::{ + fingering::v1_cost, + problems::LabError, + ties::{ + lexicographic_path, optimum_set, path_features, path_matches, train_secondary, Chain, + Example, Features, PerceptronConfig, FEATURES, FEATURE_NAMES, + }, +}; +use griff_core::{ + event::{FretboardPosition, Pitch, Tuning}, + fretboard::{infer_positions, 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_string: i64, + position_shift: i64, + string_change: i64, +) -> FingeringWeights { + FingeringWeights { + fret, + open_string, + position_shift, + string_change, + } +} + +fn weight_sets() -> Vec { + vec![ + FingeringWeights::v1(), + weights(0, -3, 1, 0), + weights(0, 0, 0, 0), + weights(2, 4, 0, 3), + ] +} + +fn sequences(alphabet: &[u8], len: usize) -> Vec> { + let mut out = vec![Vec::new()]; + for _ in 0..len { + out = out + .into_iter() + .flat_map(|prefix| { + alphabet.iter().map(move |&a| { + let mut next = prefix.clone(); + next.push(a); + next + }) + }) + .collect(); + } + out +} + +/// Every path of a chain, as candidate indices. +fn all_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 lcg_lines(count: usize, len: usize, lo: u8, hi: u8) -> Vec> { + let mut state: u64 = 0x2545_f491_4f6c_dd1d; + let span = u64::from(hi - lo + 1); + (0..count) + .map(|_| { + (0..len) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + pitch(lo + (state % span) as u8) + }) + .collect() + }) + .collect() +} + +fn dot(w: &Features, f: &Features) -> i128 { + w.iter() + .zip(f) + .map(|(a, b)| i128::from(*a) * i128::from(*b)) + .sum() +} + +const ALPHABET: [u8; 6] = [40, 47, 52, 57, 59, 64]; + +// ── chain ───────────────────────────────────────────────────────────────────── + +#[test] +fn chain_v1_costs_every_path_like_v1_cost() { + let tuning = Tuning::standard_e(); + for w in weight_sets() { + for raw in sequences(&ALPHABET, 3) { + let chain = Chain::v1(&pitches_of(&raw), &tuning, &w, STANDARD_MAX_FRET).unwrap(); + assert_eq!(chain.len(), 3); + for path in all_paths(&chain) { + let positions = chain.positions_of(&path).unwrap(); + assert_eq!(chain.cost(&path), Some(v1_cost(&positions, &w))); + } + assert_eq!(chain.cost(&[0, 0]), None); + assert_eq!(chain.cost(&[0, 0, 99]), None); + } + } +} + +#[test] +fn chain_candidates_follow_tuning_order() { + let tuning = Tuning::standard_e(); + let p = pitches_of(&[52]); + let chain = Chain::v1(&p, &tuning, &FingeringWeights::v1(), STANDARD_MAX_FRET).unwrap(); + assert_eq!( + chain.candidates(0), + tuning.candidates(p[0], STANDARD_MAX_FRET).as_slice() + ); + assert!(chain.candidates(1).is_empty()); +} + +#[test] +fn chain_v1_refuses_empty_and_unpositionable_lines() { + let tuning = Tuning::standard_e(); + let w = FingeringWeights::v1(); + assert_eq!( + Chain::v1(&[], &tuning, &w, STANDARD_MAX_FRET), + Err(LabError::EmptyLine) + ); + assert_eq!( + Chain::v1(&pitches_of(&[40, 30]), &tuning, &w, STANDARD_MAX_FRET), + Err(LabError::UnpositionablePitch { + index: 1, + pitch: 30 + }) + ); +} + +// ── optimum set ─────────────────────────────────────────────────────────────── + +#[test] +fn optimum_set_matches_brute_force() { + let tuning = Tuning::standard_e(); + for w in weight_sets() { + for len in 1..=3 { + for raw in sequences(&ALPHABET, len) { + let chain = Chain::v1(&pitches_of(&raw), &tuning, &w, STANDARD_MAX_FRET).unwrap(); + let paths = all_paths(&chain); + let costs: Vec = paths.iter().map(|p| chain.cost(p).unwrap()).collect(); + let optimum = *costs.iter().min().unwrap(); + let optimal: Vec<&Vec> = paths + .iter() + .zip(&costs) + .filter(|(_, c)| **c == optimum) + .map(|(p, _)| p) + .collect(); + + let bare = optimum_set(&chain, None); + assert_eq!(bare.optimum, optimum, "{raw:?} {w:?}"); + assert_eq!(bare.count.exact, optimal.len() as u64); + assert!(!bare.count.saturated); + assert!((bare.count.ln - (optimal.len() as f64).ln()).abs() < 1e-9); + assert_eq!(bare.agreement, None); + + // Every path of the line doubles as a reference. + for reference_path in &paths { + let reference = chain.positions_of(reference_path).unwrap(); + let matches: Vec = optimal + .iter() + .map(|p| path_matches(&chain, p, &reference).unwrap()) + .collect(); + let set = optimum_set(&chain, Some(&reference)); + let range = set.agreement.expect("reference of the chain's length"); + assert_eq!(range.min, *matches.iter().min().unwrap()); + assert_eq!(range.max, *matches.iter().max().unwrap()); + let mean = matches.iter().sum::() as f64 / matches.len() as f64; + assert!((range.expected - mean).abs() < 1e-9); + assert_eq!(chain.cost(&range.best_path), Some(optimum)); + assert_eq!( + path_matches(&chain, &range.best_path, &reference), + Some(range.max) + ); + } + assert_eq!( + optimum_set(&chain, Some(&[pos(1, 0)][..0])).agreement, + None, + "a reference of another length gives no agreement" + ); + } + } + } +} + +#[test] +fn optimum_set_matches_brute_force_on_longer_lines() { + let tuning = Tuning::standard_e(); + for w in weight_sets() { + for pitches in lcg_lines(10, 6, 40, 70) { + let chain = Chain::v1(&pitches, &tuning, &w, STANDARD_MAX_FRET).unwrap(); + let paths = all_paths(&chain); + let reference = chain.positions_of(&paths[paths.len() / 2]).unwrap(); + let optimum = paths.iter().map(|p| chain.cost(p).unwrap()).min().unwrap(); + let matches: Vec = paths + .iter() + .filter(|p| chain.cost(p) == Some(optimum)) + .map(|p| path_matches(&chain, p, &reference).unwrap()) + .collect(); + let set = optimum_set(&chain, Some(&reference)); + assert_eq!(set.optimum, optimum); + assert_eq!(set.count.exact, matches.len() as u64); + let range = set.agreement.unwrap(); + assert_eq!(range.min, *matches.iter().min().unwrap()); + assert_eq!(range.max, *matches.iter().max().unwrap()); + } + } +} + +#[test] +fn path_counts_saturate_with_an_exact_logarithm() { + // E3 has three candidates in Standard E; with zero weights every path ties. + let tuning = Tuning::standard_e(); + let zero = weights(0, 0, 0, 0); + let exact = Chain::v1(&vec![pitch(52); 30], &tuning, &zero, STANDARD_MAX_FRET).unwrap(); + let count = optimum_set(&exact, None).count; + assert_eq!(count.exact, 3_u64.pow(30)); + assert!(!count.saturated); + + let huge = Chain::v1(&vec![pitch(52); 60], &tuning, &zero, STANDARD_MAX_FRET).unwrap(); + let count = optimum_set(&huge, None).count; + assert!(count.saturated); + assert_eq!(count.exact, u64::MAX); + assert!((count.ln - 60.0 * 3.0_f64.ln()).abs() < 1e-9); +} + +// ── lexicographic tie-break ─────────────────────────────────────────────────── + +fn production_path( + chain: &Chain, + pitches: &[Pitch], + w: &FingeringWeights, +) -> Vec { + let _ = chain; + infer_positions(pitches, &Tuning::standard_e(), w, STANDARD_MAX_FRET) + .into_iter() + .map(Option::unwrap) + .collect() +} + +#[test] +fn zero_secondary_reproduces_the_production_path() { + let tuning = Tuning::standard_e(); + let zero: Features = [0; FEATURES]; + for w in weight_sets() { + for raw in sequences(&ALPHABET, 3) { + let pitches = pitches_of(&raw); + let chain = Chain::v1(&pitches, &tuning, &w, STANDARD_MAX_FRET).unwrap(); + let path = lexicographic_path(&chain, &zero, None); + assert_eq!( + chain.positions_of(&path).unwrap(), + production_path(&chain, &pitches, &w), + "{raw:?} {w:?}" + ); + } + for pitches in lcg_lines(40, 24, 40, 76) { + let chain = Chain::v1(&pitches, &tuning, &w, STANDARD_MAX_FRET).unwrap(); + let path = lexicographic_path(&chain, &zero, None); + assert_eq!( + chain.positions_of(&path).unwrap(), + production_path(&chain, &pitches, &w) + ); + } + } +} + +#[test] +fn lexicographic_path_is_primary_then_secondary_optimal() { + let tuning = Tuning::standard_e(); + let mut secondary: Features = [0; FEATURES]; + for (i, w) in secondary.iter_mut().enumerate() { + *w = (i as i64 % 7) - 3; + } + for w in weight_sets() { + for raw in sequences(&ALPHABET, 3) { + let chain = Chain::v1(&pitches_of(&raw), &tuning, &w, STANDARD_MAX_FRET).unwrap(); + let best = all_paths(&chain) + .iter() + .map(|p| { + ( + chain.cost(p).unwrap(), + dot(&secondary, &path_features(&chain, p).unwrap()), + ) + }) + .min() + .unwrap(); + let path = lexicographic_path(&chain, &secondary, None); + assert_eq!( + ( + chain.cost(&path).unwrap(), + dot(&secondary, &path_features(&chain, &path).unwrap()) + ), + best + ); + } + } +} + +#[test] +fn loss_augmentation_finds_the_least_agreeing_optimal_path() { + let tuning = Tuning::standard_e(); + let zero: Features = [0; FEATURES]; + for w in weight_sets() { + for pitches in lcg_lines(30, 5, 40, 70) { + let chain = Chain::v1(&pitches, &tuning, &w, STANDARD_MAX_FRET).unwrap(); + let reference = production_path(&chain, &pitches, &w); + let set = optimum_set(&chain, Some(&reference)); + let path = lexicographic_path(&chain, &zero, Some((&reference, 1))); + assert_eq!(chain.cost(&path), Some(set.optimum)); + assert_eq!( + path_matches(&chain, &path, &reference), + Some(set.agreement.unwrap().min) + ); + } + } +} + +// ── features ────────────────────────────────────────────────────────────────── + +fn feature(f: &Features, name: &str) -> i64 { + f[FEATURE_NAMES + .iter() + .position(|n| *n == name) + .expect("known feature")] +} + +#[test] +fn path_features_are_summed_over_notes_and_transitions() { + // E3 on (6,12), A3 on (5,12), E4 open on (1,0). + let tuning = Tuning::standard_e(); + let pitches = pitches_of(&[52, 57, 64]); + let chain = Chain::v1( + &pitches, + &tuning, + &FingeringWeights::v1(), + STANDARD_MAX_FRET, + ) + .unwrap(); + let want = [pos(6, 12), pos(5, 12), pos(1, 0)]; + let path: Vec = want + .iter() + .enumerate() + .map(|(i, p)| chain.candidates(i).iter().position(|c| c == p).unwrap()) + .collect(); + let f = path_features(&chain, &path).unwrap(); + assert_eq!(feature(&f, "fret"), 24); + assert_eq!(feature(&f, "open"), 1); + assert_eq!(feature(&f, "string_6"), 1); + assert_eq!(feature(&f, "string_5"), 1); + assert_eq!(feature(&f, "string_1"), 1); + // (6,12)→(5,12): Δs −1, Δf 0. (5,12)→(1,0): Δs −4, Δf −12, involves an open string. + assert_eq!(feature(&f, "fret_distance"), 12); + assert_eq!(feature(&f, "string_distance"), 5); + assert_eq!(feature(&f, "string_change"), 2); + assert_eq!(feature(&f, "same_fret"), 1); + assert_eq!(feature(&f, "span_over_3"), 0); + assert_eq!(feature(&f, "open_transition"), 1); + assert_eq!(feature(&f, "diagonal"), 1); + assert_eq!(feature(&f, "toward_high_string"), 2); + assert_eq!(feature(&f, "fret_up"), 0); + assert_eq!(feature(&f, "box_move"), 1); + assert_eq!(path_features(&chain, &path[..2]), None); +} + +#[test] +fn path_matches_counts_equal_positions() { + let tuning = Tuning::standard_e(); + let chain = Chain::v1( + &pitches_of(&[52, 57]), + &tuning, + &FingeringWeights::v1(), + STANDARD_MAX_FRET, + ) + .unwrap(); + let path = lexicographic_path(&chain, &[0; FEATURES], None); + let positions = chain.positions_of(&path).unwrap(); + assert_eq!(path_matches(&chain, &path, &positions), Some(2)); + assert_eq!(path_matches(&chain, &path, &positions[..1]), None); +} + +// ── perceptron ──────────────────────────────────────────────────────────────── + +/// Zero primary weights make every fingering tie; the "tab author" always +/// takes the highest-numbered (lowest-pitched) string — separable by the +/// string one-hot features. +fn separable_examples(lines: &[Vec]) -> Vec { + let tuning = Tuning::standard_e(); + lines + .iter() + .map(|pitches| { + let chain = + Chain::v1(pitches, &tuning, &weights(0, 0, 0, 0), STANDARD_MAX_FRET).unwrap(); + let human = (0..chain.len()) + .map(|i| *chain.candidates(i).iter().max_by_key(|c| c.string).unwrap()) + .collect(); + Example { chain, human } + }) + .collect() +} + +#[test] +fn perceptron_learns_a_separable_tie_break() { + let train = separable_examples(&lcg_lines(40, 8, 45, 64)); + let config = PerceptronConfig { + epochs: 20, + margin: 1, + }; + let trained = train_secondary(&train, &config); + assert!(trained.updates > 0); + assert!( + trained.epochs < config.epochs, + "separable data converges: an epoch without updates" + ); + let (mut agree, mut notes) = (0, 0); + for ex in separable_examples(&lcg_lines(10, 12, 45, 64)) { + let path = lexicographic_path(&ex.chain, &trained.weights, None); + agree += path_matches(&ex.chain, &path, &ex.human).unwrap(); + notes += ex.chain.len(); + } + assert!( + agree * 10 >= notes * 9, + "held-out agreement {agree}/{notes} below 90%" + ); + assert_eq!(train_secondary(&train, &config), trained, "deterministic"); +} + +#[test] +fn perceptron_makes_no_update_when_the_target_is_already_chosen() { + // With the production tie-break as the "author", zero weights already agree. + let tuning = Tuning::standard_e(); + let w = weights(0, -3, 1, 0); + let examples: Vec = lcg_lines(20, 8, 40, 70) + .into_iter() + .map(|pitches| { + let chain = Chain::v1(&pitches, &tuning, &w, STANDARD_MAX_FRET).unwrap(); + let human = production_path(&chain, &pitches, &w); + Example { chain, human } + }) + .collect(); + let trained = train_secondary( + &examples, + &PerceptronConfig { + epochs: 5, + margin: 0, + }, + ); + assert_eq!(trained.updates, 0); + assert_eq!(trained.epochs, 1); + assert_eq!(trained.weights, [0; FEATURES]); +} From 69a9098e6770b2147c39fa6c40cd5be984a8351e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 07:27:47 +0500 Subject: [PATCH 02/10] =?UTF-8?q?feat(lab):=20green=20=E2=80=94=20exact=20?= =?UTF-8?q?optimum-set=20analysis=20and=20learned=20secondary=20tie-break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ties::Chain holds the v1 objective per line (candidates in production order, unary and pairwise costs). optimum_set solves forward and backward cost tables with path counts (saturating u64 plus an exact natural log via log-add), marks nodes and edges on optimal paths by f + g == optimum, and reads off: the optimal-path count; least and most reference agreement by min/max DPs over optimal edges, with a most-agreeing optimal path as the achievable learning target; and the expected agreement under a uniform draw from the optimum set (paths through a candidate / total, in log space). lexicographic_path minimizes (primary i64, secondary i128) with strict comparisons, so zero secondary weights reproduce the production DP path exactly; loss augmentation adds a margin per reference-matching note. train_secondary is an averaged structured perceptron over integer weights inside the primary optimum set, updating toward the achievable target and stopping after an epoch without updates. Contract suite green (13 ties + 78 existing); clippy clean at the crate's deny level (pedantic too_many_lines warnings only). Co-Authored-By: Claude Opus 5 --- lab/src/fingering.rs | 2 +- lab/src/ties.rs | 567 +++++++++++++++++++++++++++++++++++++++++-- lab/tests/ties.rs | 7 +- 3 files changed, 556 insertions(+), 20 deletions(-) diff --git a/lab/src/fingering.rs b/lab/src/fingering.rs index 8177ec7..cf64098 100644 --- a/lab/src/fingering.rs +++ b/lab/src/fingering.rs @@ -1047,7 +1047,7 @@ fn count(n: usize) -> u64 { } /// The `v1` per-note cost (mirrors production `candidate_cost`). -fn v1_unary(fret: u8, weights: &FingeringWeights) -> i64 { +pub(crate) fn v1_unary(fret: u8, weights: &FingeringWeights) -> i64 { let base = weights.fret.saturating_mul(i64::from(fret)); if fret == 0 { base.saturating_sub(weights.open_string) diff --git a/lab/src/ties.rs b/lab/src/ties.rs index 3b695f6..fe3b1cc 100644 --- a/lab/src/ties.rs +++ b/lab/src/ties.rs @@ -15,6 +15,7 @@ use griff_core::event::{FretboardPosition, Pitch, Tuning}; use griff_core::fretboard::FingeringWeights; +use crate::fingering::v1_unary; use crate::problems::LabError; /// Number of secondary features ([`FEATURE_NAMES`]). @@ -80,8 +81,54 @@ impl Chain { weights: &FingeringWeights, max_fret: u8, ) -> Result { - let _ = (pitches, tuning, weights, max_fret); - todo!("v1 chain — green step") + if pitches.is_empty() { + return Err(LabError::EmptyLine); + } + let mut positions: Vec> = Vec::with_capacity(pitches.len()); + let mut unary = Vec::with_capacity(pitches.len()); + let mut pairwise = Vec::with_capacity(pitches.len()); + for (index, &pitch) in pitches.iter().enumerate() { + let candidates = tuning.candidates(pitch, max_fret); + if candidates.is_empty() { + return Err(LabError::UnpositionablePitch { + index, + pitch: pitch.0, + }); + } + unary.push( + candidates + .iter() + .map(|c| v1_unary(c.fret, weights)) + .collect(), + ); + pairwise.push(positions.last().map_or_else(Vec::new, |previous| { + previous + .iter() + .map(|a| { + candidates + .iter() + .map(|b| { + let shift = weights + .position_shift + .saturating_mul(i64::from(a.fret.abs_diff(b.fret))); + let change = if a.string == b.string { + 0 + } else { + weights.string_change + }; + shift.saturating_add(change) + }) + .collect() + }) + .collect() + })); + positions.push(candidates); + } + Ok(Self { + positions, + unary, + pairwise, + }) } /// Notes in the line. @@ -106,15 +153,30 @@ impl Chain { /// for a ragged path or an out-of-range index. #[must_use] pub fn cost(&self, path: &[usize]) -> Option { - let _ = path; - todo!("chain cost — green step") + if path.len() != self.len() { + return None; + } + let mut total = 0_i64; + for (note, &c) in path.iter().enumerate() { + total = total.saturating_add(*self.unary.get(note)?.get(c)?); + if let Some(previous) = note.checked_sub(1) { + let a = *path.get(previous)?; + total = total.saturating_add(*self.pairwise.get(note)?.get(a)?.get(c)?); + } + } + Some(total) } /// The positions a path selects; `None` as for [`Chain::cost`]. #[must_use] pub fn positions_of(&self, path: &[usize]) -> Option> { - let _ = path; - todo!("path positions — green step") + if path.len() != self.len() { + return None; + } + path.iter() + .enumerate() + .map(|(note, &c)| self.positions.get(note)?.get(c).copied()) + .collect() } } @@ -160,8 +222,42 @@ pub struct OptimumSet { /// a reference of a different length yields `agreement: None`. #[must_use] pub fn optimum_set(chain: &Chain, reference: Option<&[FretboardPosition]>) -> OptimumSet { - let _ = (chain, reference); - todo!("optimum set — green step") + let n = chain.len(); + if n == 0 { + return OptimumSet { + optimum: 0, + count: Count::one().into(), + agreement: reference.filter(|r| r.is_empty()).map(|_| AgreementRange { + min: 0, + max: 0, + expected: 0.0, + best_path: Vec::new(), + }), + }; + } + let (forward, backward) = cost_tables(chain); + let last = n - 1; + let optimum = forward.cost[last].iter().copied().min().unwrap_or(0); + let mut total = Count::zero(); + for (c, &cost) in forward.cost[last].iter().enumerate() { + if cost == optimum { + total = total.add(forward.count[last][c]); + } + } + let solved = Solved { + forward, + backward, + optimum, + total, + }; + let agreement = reference + .filter(|r| r.len() == n) + .map(|r| agreement_range(chain, &solved, r)); + OptimumSet { + optimum, + count: total.into(), + agreement, + } } /// Reference matches of a path; `None` for a ragged path or reference. @@ -171,16 +267,32 @@ pub fn path_matches( path: &[usize], reference: &[FretboardPosition], ) -> Option { - let _ = (chain, path, reference); - todo!("path matches — green step") + if path.len() != chain.len() || reference.len() != chain.len() { + return None; + } + let positions = chain.positions_of(path)?; + Some( + positions + .iter() + .zip(reference) + .filter(|(a, b)| a == b) + .count(), + ) } /// Secondary features of a path, summed over notes and transitions /// ([`FEATURE_NAMES`]); `None` as for [`Chain::cost`]. #[must_use] pub fn path_features(chain: &Chain, path: &[usize]) -> Option { - let _ = (chain, path); - todo!("path features — green step") + let positions = chain.positions_of(path)?; + let mut total = [0_i64; FEATURES]; + for (note, position) in positions.iter().enumerate() { + add_features(&mut total, ¬e_features(*position)); + if let Some(previous) = note.checked_sub(1).and_then(|i| positions.get(i)) { + add_features(&mut total, &transition_features(*previous, *position)); + } + } + Some(total) } /// The path minimizing `(primary cost, secondary cost)` lexicographically, @@ -195,8 +307,84 @@ pub fn lexicographic_path( weights: &Features, augment: Option<(&[FretboardPosition], i64)>, ) -> Vec { - let _ = (chain, weights, augment); - todo!("lexicographic path — green step") + let n = chain.len(); + if n == 0 { + return Vec::new(); + } + let reference = augment.filter(|(r, _)| r.len() == n); + // best[i][c] = lexicographically least (primary, secondary) of a prefix ending + // at candidate c of note i; parent[i][c] its predecessor. Strict `<` keeps the + // lowest index on ties, as the production DP does. + let mut best: Vec> = Vec::with_capacity(n); + let mut parent: Vec> = Vec::with_capacity(n); + for note in 0..n { + let candidates = chain.candidates(note); + let mut layer = Vec::with_capacity(candidates.len()); + let mut parents = Vec::with_capacity(candidates.len()); + for (c, position) in candidates.iter().enumerate() { + let unary = chain + .unary + .get(note) + .and_then(|u| u.get(c)) + .copied() + .unwrap_or(0); + let mut secondary = dot(weights, ¬e_features(*position)); + if let Some((r, margin)) = reference { + if r.get(note) == Some(position) { + secondary = secondary.saturating_add(i128::from(margin)); + } + } + let (from, parent_index) = match note.checked_sub(1) { + None => ((0_i64, 0_i128), 0), + Some(previous) => { + let mut chosen: Option<(Lexi, usize)> = None; + for (a, (prev_value, prev_position)) in best[previous] + .iter() + .zip(chain.candidates(previous)) + .enumerate() + { + let transition = chain + .pairwise + .get(note) + .and_then(|p| p.get(a)) + .and_then(|p| p.get(c)) + .copied() + .unwrap_or(0); + let value = ( + prev_value.0.saturating_add(transition), + prev_value.1.saturating_add(dot( + weights, + &transition_features(*prev_position, *position), + )), + ); + if chosen.is_none_or(|(v, _)| value < v) { + chosen = Some((value, a)); + } + } + chosen.unwrap_or(((0, 0), 0)) + } + }; + layer.push(( + from.0.saturating_add(unary), + from.1.saturating_add(secondary), + )); + parents.push(parent_index); + } + best.push(layer); + parent.push(parents); + } + let mut c = 0; + for (index, value) in best[n - 1].iter().enumerate() { + if *value < best[n - 1][c] { + c = index; + } + } + let mut path = vec![0; n]; + for note in (0..n).rev() { + path[note] = c; + c = parent[note][c]; + } + path } /// One training line: its primary chain and the tab author's positions. @@ -239,6 +427,353 @@ pub struct TrainedSecondary { /// given order, integer arithmetic. #[must_use] pub fn train_secondary(examples: &[Example], config: &PerceptronConfig) -> TrainedSecondary { - let _ = (examples, config); - todo!("secondary perceptron — green step") + // The achievable target per example: a most-agreeing optimal path. + let prepared: Vec<(&Example, usize, Features)> = examples + .iter() + .filter_map(|example| { + let range = optimum_set(&example.chain, Some(&example.human)).agreement?; + let target = path_features(&example.chain, &range.best_path)?; + Some((example, range.max, target)) + }) + .collect(); + let mut weights = [0_i64; FEATURES]; + let mut sum = [0_i64; FEATURES]; + let mut updates = 0_u64; + let mut epochs = 0; + while epochs < config.epochs { + epochs += 1; + let mut changed = false; + for (example, target_matches, target_features) in &prepared { + let augment = (config.margin != 0).then_some((example.human.as_slice(), config.margin)); + let predicted = lexicographic_path(&example.chain, &weights, augment); + let matches = path_matches(&example.chain, &predicted, &example.human).unwrap_or(0); + if matches < *target_matches { + if let Some(features) = path_features(&example.chain, &predicted) { + for ((w, f), t) in weights.iter_mut().zip(features).zip(target_features) { + *w = w.saturating_add(f.saturating_sub(*t)); + } + updates = updates.saturating_add(1); + changed = true; + } + } + for (s, w) in sum.iter_mut().zip(weights) { + *s = s.saturating_add(w); + } + } + if !changed { + break; + } + } + TrainedSecondary { + weights: sum, + updates, + epochs, + } +} + +// ── private machinery ───────────────────────────────────────────────────────── + +/// A path count carried through the DPs: exact (saturating) and in logs. +#[derive(Debug, Clone, Copy)] +struct Count { + exact: u64, + saturated: bool, + ln: f64, +} + +impl Count { + const fn zero() -> Self { + Self { + exact: 0, + saturated: false, + ln: f64::NEG_INFINITY, + } + } + + const fn one() -> Self { + Self { + exact: 1, + saturated: false, + ln: 0.0, + } + } + + fn add(self, other: Self) -> Self { + let (exact, overflow) = self.exact.overflowing_add(other.exact); + Self { + exact: if overflow { u64::MAX } else { exact }, + saturated: self.saturated || other.saturated || overflow, + ln: log_add(self.ln, other.ln), + } + } + + fn mul(self, other: Self) -> Self { + let product = self.exact.checked_mul(other.exact); + Self { + exact: product.unwrap_or(u64::MAX), + saturated: self.saturated || other.saturated || product.is_none(), + ln: self.ln + other.ln, + } + } +} + +impl From for PathCount { + fn from(c: Count) -> Self { + Self { + exact: c.exact, + saturated: c.saturated, + ln: c.ln, + } + } +} + +/// `ln(e^a + e^b)` without overflow. +fn log_add(a: f64, b: f64) -> f64 { + if a == f64::NEG_INFINITY { + return b; + } + if b == f64::NEG_INFINITY { + return a; + } + let (hi, lo) = if a >= b { (a, b) } else { (b, a) }; + hi + (lo - hi).exp().ln_1p() +} + +/// A lexicographic `(primary, secondary)` cost. +type Lexi = (i64, i128); + +/// Most reference matches reaching a candidate along optimal edges, with the +/// predecessor attaining it; `None` off the optimum. +type MostMatches = Option<(usize, usize)>; + +/// The solved cost tables of a chain with its optimum and optimal-path count. +struct Solved { + forward: Table, + backward: Table, + optimum: i64, + total: Count, +} + +/// Per note, per candidate: least cost and how many (sub)paths attain it. +struct Table { + cost: Vec>, + count: Vec>, +} + +/// Forward table (prefix ending at a candidate, its unary included) and +/// backward table (suffix after a candidate, its unary excluded). +fn cost_tables(chain: &Chain) -> (Table, Table) { + let n = chain.len(); + let mut forward = Table { + cost: Vec::with_capacity(n), + count: Vec::with_capacity(n), + }; + for note in 0..n { + let k = chain.candidates(note).len(); + let mut costs = Vec::with_capacity(k); + let mut counts = Vec::with_capacity(k); + for c in 0..k { + let unary = chain.unary[note][c]; + if note == 0 { + costs.push(unary); + counts.push(Count::one()); + continue; + } + let mut least = i64::MAX; + let mut count = Count::zero(); + for a in 0..chain.candidates(note - 1).len() { + let value = forward.cost[note - 1][a].saturating_add(chain.pairwise[note][a][c]); + if value < least { + least = value; + count = forward.count[note - 1][a]; + } else if value == least { + count = count.add(forward.count[note - 1][a]); + } + } + costs.push(least.saturating_add(unary)); + counts.push(count); + } + forward.cost.push(costs); + forward.count.push(counts); + } + + let mut backward = Table { + cost: vec![Vec::new(); n], + count: vec![Vec::new(); n], + }; + for note in (0..n).rev() { + let k = chain.candidates(note).len(); + if note + 1 == n { + backward.cost[note] = vec![0; k]; + backward.count[note] = vec![Count::one(); k]; + continue; + } + let mut costs = Vec::with_capacity(k); + let mut counts = Vec::with_capacity(k); + for a in 0..k { + let mut least = i64::MAX; + let mut count = Count::zero(); + for b in 0..chain.candidates(note + 1).len() { + let value = chain.pairwise[note + 1][a][b] + .saturating_add(chain.unary[note + 1][b]) + .saturating_add(backward.cost[note + 1][b]); + if value < least { + least = value; + count = backward.count[note + 1][b]; + } else if value == least { + count = count.add(backward.count[note + 1][b]); + } + } + costs.push(least); + counts.push(count); + } + backward.cost[note] = costs; + backward.count[note] = counts; + } + (forward, backward) +} + +/// Least / most / expected reference agreement over the optimal paths, with a +/// most-agreeing optimal path. +#[allow(clippy::cast_precision_loss)] +fn agreement_range( + chain: &Chain, + solved: &Solved, + reference: &[FretboardPosition], +) -> AgreementRange { + let Solved { + forward, + backward, + optimum, + total, + } = solved; + let (optimum, total) = (*optimum, *total); + let n = chain.len(); + let on_optimum = |note: usize, c: usize| { + forward.cost[note][c].saturating_add(backward.cost[note][c]) == optimum + }; + let matches = + |note: usize, c: usize| usize::from(chain.candidates(note).get(c) == reference.get(note)); + + // Expected agreement: P(candidate c at note i) = paths through it / total. + let mut expected = 0.0; + for note in 0..n { + for c in 0..chain.candidates(note).len() { + if matches(note, c) == 1 && on_optimum(note, c) { + let through = forward.count[note][c].mul(backward.count[note][c]); + expected += (through.ln - total.ln).exp(); + } + } + } + + // Min / max agreement along optimal edges only. + let mut low: Vec>> = Vec::with_capacity(n); + let mut high: Vec> = Vec::with_capacity(n); + for note in 0..n { + let k = chain.candidates(note).len(); + let mut low_layer = Vec::with_capacity(k); + let mut high_layer = Vec::with_capacity(k); + for c in 0..k { + if !on_optimum(note, c) { + low_layer.push(None); + high_layer.push(None); + continue; + } + let here = matches(note, c); + if note == 0 { + low_layer.push(Some(here)); + high_layer.push(Some((here, 0))); + continue; + } + let mut lo: Option = None; + let mut hi: MostMatches = None; + for a in 0..chain.candidates(note - 1).len() { + let edge_on_optimum = forward.cost[note - 1][a] + .saturating_add(chain.pairwise[note][a][c]) + .saturating_add(chain.unary[note][c]) + .saturating_add(backward.cost[note][c]) + == optimum; + if !edge_on_optimum { + continue; + } + if let Some(value) = low[note - 1][a] { + lo = Some(lo.map_or(value, |l| l.min(value))); + } + if let Some((value, _)) = high[note - 1][a] { + if hi.is_none_or(|(h, _)| value > h) { + hi = Some((value, a)); + } + } + } + low_layer.push(lo.map(|l| l + here)); + high_layer.push(hi.map(|(h, a)| (h + here, a))); + } + low.push(low_layer); + high.push(high_layer); + } + + let last = n - 1; + let min = low[last].iter().flatten().copied().min().unwrap_or(0); + let mut end: Option<(usize, usize)> = None; + for (c, cell) in high[last].iter().enumerate() { + if let Some((value, _)) = cell { + if end.is_none_or(|(best, _)| *value > best) { + end = Some((*value, c)); + } + } + } + let (max, mut c) = end.unwrap_or((0, 0)); + let mut best_path = vec![0; n]; + for note in (0..n).rev() { + best_path[note] = c; + c = high[note][c].map_or(0, |(_, parent)| parent); + } + AgreementRange { + min, + max, + expected, + best_path, + } +} + +fn dot(weights: &Features, features: &Features) -> i128 { + weights + .iter() + .zip(features) + .map(|(w, f)| i128::from(*w).saturating_mul(i128::from(*f))) + .fold(0, i128::saturating_add) +} + +fn add_features(total: &mut Features, part: &Features) { + for (t, p) in total.iter_mut().zip(part) { + *t = t.saturating_add(*p); + } +} + +fn note_features(position: FretboardPosition) -> Features { + let mut f = [0_i64; FEATURES]; + f[0] = i64::from(position.fret); + f[1] = i64::from(position.fret == 0); + let string = usize::from(position.string.clamp(1, 7)); + f[1 + string] = 1; + f +} + +fn transition_features(from: FretboardPosition, to: FretboardPosition) -> Features { + let ds = i64::from(to.string) - i64::from(from.string); + let df = i64::from(to.fret) - i64::from(from.fret); + let fretted = from.fret > 0 && to.fret > 0; + let mut f = [0_i64; FEATURES]; + f[9] = df.abs(); + f[10] = ds.abs(); + f[11] = i64::from(ds != 0); + f[12] = i64::from(df == 0 && fretted); + f[13] = i64::from(df.abs() > 3 && fretted); + f[14] = i64::from(df.abs() > 5 && fretted); + f[15] = i64::from(from.fret == 0 || to.fret == 0); + f[16] = i64::from(ds != 0 && df != 0); + f[17] = i64::from(ds < 0); + f[18] = i64::from(df > 0); + f[19] = i64::from(ds != 0 && df != 0 && ds.signum() == df.signum()); + f } diff --git a/lab/tests/ties.rs b/lab/tests/ties.rs index 1d3ff94..19ff8bb 100644 --- a/lab/tests/ties.rs +++ b/lab/tests/ties.rs @@ -18,7 +18,8 @@ clippy::arithmetic_side_effects, clippy::cast_possible_truncation, clippy::cast_precision_loss, - clippy::float_cmp + clippy::float_cmp, + clippy::cast_possible_wrap )] use griff_constraint_lab::{ @@ -263,12 +264,12 @@ fn path_counts_saturate_with_an_exact_logarithm() { // E3 has three candidates in Standard E; with zero weights every path ties. let tuning = Tuning::standard_e(); let zero = weights(0, 0, 0, 0); - let exact = Chain::v1(&vec![pitch(52); 30], &tuning, &zero, STANDARD_MAX_FRET).unwrap(); + let exact = Chain::v1(&[pitch(52); 30], &tuning, &zero, STANDARD_MAX_FRET).unwrap(); let count = optimum_set(&exact, None).count; assert_eq!(count.exact, 3_u64.pow(30)); assert!(!count.saturated); - let huge = Chain::v1(&vec![pitch(52); 60], &tuning, &zero, STANDARD_MAX_FRET).unwrap(); + let huge = Chain::v1(&[pitch(52); 60], &tuning, &zero, STANDARD_MAX_FRET).unwrap(); let count = optimum_set(&huge, None).count; assert!(count.saturated); assert_eq!(count.exact, u64::MAX); From 0978e00b760479c8cd506440691d721096d47de0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 07:32:10 +0500 Subject: [PATCH 03/10] feat(lab): ties-check and tiebreak runner commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ties-check compares the exact optimum-set DPs with verified CP-SAT records (optimum and agreement ceiling per line). tiebreak reports the tie-break ladder for a v1 primary — floor, uniform over optima, production tie-break, learned tie-break, ceiling — plus optimal-path counts and unique-optimum lines; it trains the secondary on train songs with a song-level validation bucket for the margin and evaluates on holdout songs. Lines carry their holdout bucket, and the ladder counts lines where the learned tie-break changes the production path. First runs on the corrected corpus: the exact DPs agree with CP-SAT on all 1,954 holdout lines for v1 and v1-fit (optimum and ceiling). The learned tie-break changed 0 of 7,091 train lines — traced to the training setup, not the DP (fixed targets conflict across lines; margins far below the weight scale), addressed next. Co-Authored-By: Claude Opus 5 --- lab/src/bin/fingering_gap.rs | 373 ++++++++++++++++++++++++++++++++++- 1 file changed, 372 insertions(+), 1 deletion(-) diff --git a/lab/src/bin/fingering_gap.rs b/lab/src/bin/fingering_gap.rs index 60b5180..971db2c 100644 --- a/lab/src/bin/fingering_gap.rs +++ b/lab/src/bin/fingering_gap.rs @@ -26,6 +26,18 @@ //! cargo run --release --bin fingering_gap -- repeat-report --tabs DIR --out DIR [MODELS] //! ``` //! +//! Optimum-set analysis and a learned secondary tie-break (exact DPs): +//! +//! ```text +//! cargo run --release --bin fingering_gap -- ties-check --tabs DIR --out DIR --v1 NAME=… +//! cargo run --release --bin fingering_gap -- tiebreak --tabs DIR --out DIR --v1 NAME=… +//! ``` +//! +//! `ties-check` compares the exact DP optimum and agreement ceiling with the +//! verified CP-SAT records in `OUT/NAME.cpsat.jsonl`; `tiebreak` learns a +//! secondary objective on train songs (margin chosen on a validation bucket) +//! and reports the tie-break ladder 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). @@ -50,6 +62,10 @@ use griff_constraint_lab::ir::VarId; use griff_constraint_lab::optir::{ verify_agreement, verify_record, OptProblem, ProblemRecord, SolveRecord, Verdict, }; +use griff_constraint_lab::ties::{ + lexicographic_path, optimum_set, path_matches, train_secondary, Chain, Example, Features, + PerceptronConfig, FEATURES, FEATURE_NAMES, +}; use griff_core::event::FretboardPosition; use griff_core::fretboard::{infer_positions, FingeringWeights, STANDARD_MAX_FRET}; use griff_core::gp::import_gp_score; @@ -65,6 +81,8 @@ struct Line { id: String, file: usize, test: bool, + /// Song-level holdout bucket in `0..HOLDOUT_BUCKETS` (0 = test). + bucket: u64, tab: TabLine, } @@ -118,7 +136,8 @@ fn load(tabs: &Path, cut: &LineCut) -> std::io::Result { let bytes = fs::read(path)?; corpus_hash.extend_from_slice(&fnv1a64(&bytes).to_le_bytes()); let key = song_key(&name); - let test = holdout_bucket(&key, HOLDOUT_BUCKETS) == 0; + let bucket = holdout_bucket(&key, HOLDOUT_BUCKETS); + let test = bucket == 0; names.push(name); let Ok(score) = import_gp_score(&bytes) else { import_failures += 1; @@ -138,6 +157,7 @@ fn load(tabs: &Path, cut: &LineCut) -> std::io::Result { ), file, test, + bucket, tab, })); } @@ -215,6 +235,16 @@ impl Model { } } + fn description_short(&self) -> String { + match self { + Self::V1 { name, weights: w } => format!( + "{name} (fret {}, open_string {}, position_shift {}, string_change {})", + w.fret, w.open_string, w.position_shift, w.string_change + ), + other => other.name().to_string(), + } + } + fn describe(&self) -> String { match self { Self::LowestFret => "lowest fret per note".into(), @@ -957,6 +987,345 @@ fn print_oracle(oracle: &BTreeMap) { } } +// ── optimum sets and the learned tie-break ──────────────────────────────────── + +fn v1_weights(model: &Model) -> Option { + match model { + Model::V1 { weights, .. } => Some(*weights), + Model::LowestFret | Model::Hand { .. } => None, + } +} + +fn chain_of(line: &Line, weights: &FingeringWeights) -> Chain { + Chain::v1( + &line.tab.pitches, + &line.tab.tuning, + weights, + STANDARD_MAX_FRET, + ) + .expect("tab lines only hold positionable pitches") +} + +#[derive(Debug, Clone, Default, Serialize)] +struct TiesCheck { + records: usize, + compared: usize, + optimum_equal: usize, + optimum_differs: usize, + ceiling_equal: usize, + ceiling_differs: usize, + skipped_unverified: usize, +} + +/// The exact DPs against the verified CP-SAT optima and agreement passes. +fn ties_check(corpus: &Corpus, models: &[Model], out: &Path) -> std::io::Result<()> { + let mut checks = BTreeMap::new(); + for model in models { + let Some(weights) = v1_weights(model) else { + continue; + }; + let records = read_records(&out.join(format!("{}.cpsat.jsonl", model.name())))?; + let rows = par_map(&corpus.lines, |line| { + let record = records.get(&line.id)?; + let (problem, vpn) = model.problem(&line.tab)?; + let Verdict::Proven { optimum } = verify_record(&problem, record) else { + return Some(None); + }; + let ceiling = record.agreement.as_ref().and_then(|pass| { + verify_agreement(&problem, &human_reference(&line.tab, vpn), optimum, pass).ok() + })?; + let set = optimum_set(&chain_of(line, &weights), Some(&line.tab.human)); + let max = set.agreement.map_or(0, |a| a.max) as u64; + Some(Some((set.optimum == optimum, max == ceiling))) + }); + let mut check = TiesCheck { + records: records.len(), + ..TiesCheck::default() + }; + for row in rows.into_iter().flatten() { + let Some((optimum_ok, ceiling_ok)) = row else { + check.skipped_unverified += 1; + continue; + }; + check.compared += 1; + if optimum_ok { + check.optimum_equal += 1; + } else { + check.optimum_differs += 1; + } + if ceiling_ok { + check.ceiling_equal += 1; + } else { + check.ceiling_differs += 1; + } + } + println!( + "{}: {} records, {} compared — optimum equal {} / differs {}, ceiling equal {} / differs {}, unverified {}", + model.name(), + check.records, + check.compared, + check.optimum_equal, + check.optimum_differs, + check.ceiling_equal, + check.ceiling_differs, + check.skipped_unverified + ); + checks.insert(model.name().to_string(), check); + } + write_json(&out.join("ties-check.json"), &checks) +} + +#[derive(Debug, Clone, Default, Serialize)] +struct Ladder { + lines: usize, + notes: u64, + human_optimal_lines: usize, + unique_optimum_lines: usize, + ln_count: Quantiles, + floor: f64, + uniform: f64, + production: f64, + learned: Option, + /// Lines where the learned tie-break picks a different path than production. + learned_changed_lines: Option, + ceiling: f64, +} + +struct LineTies { + notes: u64, + human_optimal: bool, + unique: bool, + ln_count_milli: i64, + min: u64, + expected: f64, + production: u64, + max: u64, +} + +#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] +fn line_ties(line: &Line, weights: &FingeringWeights) -> LineTies { + let chain = chain_of(line, weights); + let human = &line.tab.human; + let set = optimum_set(&chain, Some(human)); + let range = set.agreement.clone().expect("human positions per note"); + let production = lexicographic_path(&chain, &[0; FEATURES], None); + LineTies { + notes: human.len() as u64, + human_optimal: v1_cost(human, weights) == set.optimum, + unique: !set.count.saturated && set.count.exact == 1, + ln_count_milli: (set.count.ln * 1000.0).round() as i64, + min: range.min as u64, + expected: range.expected, + production: path_matches(&chain, &production, human).unwrap_or(0) as u64, + max: range.max as u64, + } +} + +#[allow(clippy::cast_precision_loss)] +fn ladder(lines: &[&Line], weights: &FingeringWeights, learned: Option<&Features>) -> Ladder { + let rows = par_map(lines, |line| { + let ties = line_ties(line, weights); + let learned_matches = learned.map(|w| { + let chain = chain_of(line, weights); + let path = lexicographic_path(&chain, w, None); + let production = lexicographic_path(&chain, &[0; FEATURES], None); + ( + path_matches(&chain, &path, &line.tab.human).unwrap_or(0) as u64, + path != production, + ) + }); + (ties, learned_matches) + }); + let notes: u64 = rows.iter().map(|(t, _)| t.notes).sum(); + let rate = |x: f64| x / notes.max(1) as f64; + Ladder { + lines: rows.len(), + notes, + human_optimal_lines: rows.iter().filter(|(t, _)| t.human_optimal).count(), + unique_optimum_lines: rows.iter().filter(|(t, _)| t.unique).count(), + ln_count: quantiles(rows.iter().map(|(t, _)| t.ln_count_milli).collect()), + floor: rate(rows.iter().map(|(t, _)| t.min as f64).sum()), + uniform: rate(rows.iter().map(|(t, _)| t.expected).sum()), + production: rate(rows.iter().map(|(t, _)| t.production as f64).sum()), + learned: learned.map(|_| { + rate( + rows.iter() + .filter_map(|(_, l)| *l) + .map(|(x, _)| x as f64) + .sum(), + ) + }), + learned_changed_lines: learned.map(|_| { + rows.iter() + .filter(|(_, l)| l.is_some_and(|(_, changed)| changed)) + .count() + }), + ceiling: rate(rows.iter().map(|(t, _)| t.max as f64).sum()), + } +} + +fn examples_of(lines: &[&Line], weights: &FingeringWeights) -> Vec { + lines + .iter() + .map(|line| Example { + chain: chain_of(line, weights), + human: line.tab.human.clone(), + }) + .collect() +} + +#[derive(Serialize)] +struct MarginTrial { + margin: i64, + epochs: usize, + updates: u64, + validation_agreement: f64, +} + +#[derive(Serialize)] +struct TiebreakReport { + schema: &'static str, + version: u32, + primary: String, + epochs: usize, + trials: Vec, + chosen_margin: i64, + final_updates: u64, + final_epochs: usize, + weights: BTreeMap<&'static str, i64>, + train: Ladder, + test: Ladder, + corpus: CorpusFacts, +} + +const VALIDATION_BUCKET: u64 = 1; +const TIEBREAK_EPOCHS: usize = 10; +const MARGINS: [i64; 5] = [0, 1, 2, 4, 8]; + +#[allow(clippy::cast_precision_loss)] +fn tiebreak(corpus: Corpus, models: &[Model], out: &Path) -> std::io::Result<()> { + let Some(model) = models.iter().find(|m| v1_weights(m).is_some()) else { + return Err(std::io::Error::other("tiebreak needs a --v1 primary model")); + }; + let weights = v1_weights(model).expect("checked above"); + let train: Vec<&Line> = corpus.lines.iter().filter(|l| !l.test).collect(); + let fit: Vec<&Line> = train + .iter() + .copied() + .filter(|l| l.bucket != VALIDATION_BUCKET) + .collect(); + let validation: Vec<&Line> = train + .iter() + .copied() + .filter(|l| l.bucket == VALIDATION_BUCKET) + .collect(); + let test: Vec<&Line> = corpus.lines.iter().filter(|l| l.test).collect(); + eprintln!( + "primary {}: fit {} lines, validation {} lines, test {} lines", + model.name(), + fit.len(), + validation.len(), + test.len() + ); + + let fit_examples = examples_of(&fit, &weights); + let mut trials = Vec::new(); + for margin in MARGINS { + let started = Instant::now(); + let trained = train_secondary( + &fit_examples, + &PerceptronConfig { + epochs: TIEBREAK_EPOCHS, + margin, + }, + ); + let score = ladder(&validation, &weights, Some(&trained.weights)) + .learned + .unwrap_or(0.0); + eprintln!( + "margin {margin}: {} updates, {} epochs, validation agreement {:.2}% ({:.1}s)", + trained.updates, + trained.epochs, + 100.0 * score, + started.elapsed().as_secs_f64() + ); + trials.push(MarginTrial { + margin, + epochs: trained.epochs, + updates: trained.updates, + validation_agreement: score, + }); + } + let chosen_margin = trials + .iter() + .fold(None::<&MarginTrial>, |best, t| match best { + Some(b) if b.validation_agreement >= t.validation_agreement => Some(b), + _ => Some(t), + }) + .map_or(0, |t| t.margin); + + let final_trained = train_secondary( + &examples_of(&train, &weights), + &PerceptronConfig { + epochs: TIEBREAK_EPOCHS, + margin: chosen_margin, + }, + ); + let train_ladder = ladder(&train, &weights, Some(&final_trained.weights)); + let test_ladder = ladder(&test, &weights, Some(&final_trained.weights)); + + println!( + "\nprimary {} — margin {chosen_margin} (validation), {} updates over {} epochs on all train songs", + model.description_short(), + final_trained.updates, + final_trained.epochs + ); + println!("\n| split | lines | human optimal | unique optimum | ln #optima p50 / p90 | floor | uniform over optima | production tie-break | learned tie-break | ceiling |"); + println!("|---|---|---|---|---|---|---|---|---|---|"); + for (name, l) in [ + ("train", &train_ladder), + ("test (holdout songs)", &test_ladder), + ] { + println!( + "| {name} | {} | {:.1}% | {:.1}% | {:.2} / {:.2} | {:.1}% | {:.1}% | {:.1}% | {} | {:.1}% |", + l.lines, + 100.0 * l.human_optimal_lines as f64 / l.lines.max(1) as f64, + 100.0 * l.unique_optimum_lines as f64 / l.lines.max(1) as f64, + l.ln_count.p50 as f64 / 1000.0, + l.ln_count.p90 as f64 / 1000.0, + 100.0 * l.floor, + 100.0 * l.uniform, + 100.0 * l.production, + l.learned.map_or("—".into(), |x| format!("{:.1}%", 100.0 * x)), + 100.0 * l.ceiling + ); + } + let weights_by_name: BTreeMap<&'static str, i64> = FEATURE_NAMES + .iter() + .copied() + .zip(final_trained.weights) + .collect(); + println!("\nlearned secondary weights (averaged, unnormalized):"); + for (name, w) in FEATURE_NAMES.iter().zip(final_trained.weights) { + println!(" {name:>20} {w}"); + } + let report = TiebreakReport { + schema: "griff.constraint-lab-tiebreak", + version: 1, + primary: model.description_short(), + epochs: TIEBREAK_EPOCHS, + trials, + chosen_margin, + final_updates: final_trained.updates, + final_epochs: final_trained.epochs, + weights: weights_by_name, + train: train_ladder, + test: test_ladder, + corpus: corpus.facts, + }; + write_json(&out.join("tiebreak.json"), &report) +} + // ── repeat consistency ──────────────────────────────────────────────────────── /// Window of a repeated figure, in notes. @@ -1262,6 +1631,8 @@ fn run() -> Result<(), String> { "report" => report(corpus, &args.models, &args.out), "repeat-export" => repeat_export(&corpus, &args.models, &args.out), "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), other => return Err(format!("unknown command {other}")), }; result.map_err(|e| e.to_string()) From 55e4343fecb92ee6fcd70826bc441dead0594d59 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 07:32:22 +0500 Subject: [PATCH 04/10] =?UTF-8?q?test(lab):=20red=20=E2=80=94=20latent=20l?= =?UTF-8?q?earning=20target=20for=20the=20secondary=20tie-break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first tie-break run learned weights that changed 0 of 7,091 train lines. One cause is the fixed target: best_path breaks ties among the most-agreeing optimal paths by candidate index, so equally good targets disagree across similar lines and the perceptron chases conflicting updates. The standard remedy is a latent target — among the optimal paths that agree most with the tab author, the one cheapest under the current secondary weights. Pins latent_target against brute force: primary-optimal, most agreeing, then least secondary cost; equal to best_path under zero weights; refused for a reference of another length. Stub is todo!() (1 red). Co-Authored-By: Claude Opus 5 --- lab/src/ties.rs | 15 ++++++++++++++ lab/tests/ties.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/lab/src/ties.rs b/lab/src/ties.rs index fe3b1cc..c3e10eb 100644 --- a/lab/src/ties.rs +++ b/lab/src/ties.rs @@ -387,6 +387,21 @@ pub fn lexicographic_path( path } +/// The latent learning target for one line: among the optimal paths that +/// agree most with `reference`, the one cheapest under the secondary `weights` +/// (remaining ties: lowest candidate indices). With zero weights it is +/// [`AgreementRange::best_path`]. `None` when the reference length differs from +/// the chain's. +#[must_use] +pub fn latent_target( + chain: &Chain, + weights: &Features, + reference: &[FretboardPosition], +) -> Option> { + let _ = (chain, weights, reference); + todo!("latent target — green step") +} + /// One training line: its primary chain and the tab author's positions. #[derive(Debug, Clone)] pub struct Example { diff --git a/lab/tests/ties.rs b/lab/tests/ties.rs index 19ff8bb..67e43ed 100644 --- a/lab/tests/ties.rs +++ b/lab/tests/ties.rs @@ -26,8 +26,8 @@ use griff_constraint_lab::{ fingering::v1_cost, problems::LabError, ties::{ - lexicographic_path, optimum_set, path_features, path_matches, train_secondary, Chain, - Example, Features, PerceptronConfig, FEATURES, FEATURE_NAMES, + latent_target, lexicographic_path, optimum_set, path_features, path_matches, + train_secondary, Chain, Example, Features, PerceptronConfig, FEATURES, FEATURE_NAMES, }, }; use griff_core::{ @@ -367,6 +367,54 @@ fn loss_augmentation_finds_the_least_agreeing_optimal_path() { } } +#[test] +fn latent_target_is_the_cheapest_most_agreeing_optimal_path() { + let tuning = Tuning::standard_e(); + let mut secondary: Features = [0; FEATURES]; + for (i, w) in secondary.iter_mut().enumerate() { + *w = 2 - (i as i64 % 5); + } + for w in weight_sets() { + for raw in sequences(&ALPHABET, 3) { + let chain = Chain::v1(&pitches_of(&raw), &tuning, &w, STANDARD_MAX_FRET).unwrap(); + let paths = all_paths(&chain); + let optimum = paths.iter().map(|p| chain.cost(p).unwrap()).min().unwrap(); + for reference_path in paths.iter().step_by(3) { + let reference = chain.positions_of(reference_path).unwrap(); + let best = paths + .iter() + .filter(|p| chain.cost(p) == Some(optimum)) + .map(|p| { + ( + std::cmp::Reverse(path_matches(&chain, p, &reference).unwrap()), + dot(&secondary, &path_features(&chain, p).unwrap()), + ) + }) + .min() + .unwrap(); + let target = latent_target(&chain, &secondary, &reference).unwrap(); + assert_eq!(chain.cost(&target), Some(optimum)); + assert_eq!( + ( + std::cmp::Reverse(path_matches(&chain, &target, &reference).unwrap()), + dot(&secondary, &path_features(&chain, &target).unwrap()) + ), + best, + "{raw:?} {w:?}" + ); + assert_eq!( + latent_target(&chain, &[0; FEATURES], &reference), + optimum_set(&chain, Some(&reference)) + .agreement + .map(|a| a.best_path), + "zero weights give the best_path" + ); + } + assert_eq!(latent_target(&chain, &secondary, &[]), None); + } + } +} + // ── features ────────────────────────────────────────────────────────────────── fn feature(f: &Features, name: &str) -> i64 { From cdbe0c6f6725205662a54e38303705a7f9aad654 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 07:33:28 +0500 Subject: [PATCH 05/10] =?UTF-8?q?feat(lab):=20green=20=E2=80=94=20latent?= =?UTF-8?q?=20target=20via=20one=20lexicographic=20chain=20DP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lexicographic_path and latent_target share one DP minimizing (primary, middle, secondary) with strict comparisons: Plain ignores the reference, Augment adds the margin to the secondary per matching note, Latent puts −matches in the middle so the most-agreeing optimal paths win before the secondary cost. Zero weights still reproduce the production path, and the latent target under zero weights equals best_path. train_secondary now updates toward the latent target under the current weights instead of a fixed best_path, removing conflicting targets across lines with equally good choices. Suite green (14 ties + 78 existing); clippy clean. Co-Authored-By: Claude Opus 5 --- lab/src/ties.rs | 234 ++++++++++++++++++++++++++++-------------------- 1 file changed, 139 insertions(+), 95 deletions(-) diff --git a/lab/src/ties.rs b/lab/src/ties.rs index c3e10eb..8c86b79 100644 --- a/lab/src/ties.rs +++ b/lab/src/ties.rs @@ -307,84 +307,11 @@ pub fn lexicographic_path( weights: &Features, augment: Option<(&[FretboardPosition], i64)>, ) -> Vec { - let n = chain.len(); - if n == 0 { - return Vec::new(); - } - let reference = augment.filter(|(r, _)| r.len() == n); - // best[i][c] = lexicographically least (primary, secondary) of a prefix ending - // at candidate c of note i; parent[i][c] its predecessor. Strict `<` keeps the - // lowest index on ties, as the production DP does. - let mut best: Vec> = Vec::with_capacity(n); - let mut parent: Vec> = Vec::with_capacity(n); - for note in 0..n { - let candidates = chain.candidates(note); - let mut layer = Vec::with_capacity(candidates.len()); - let mut parents = Vec::with_capacity(candidates.len()); - for (c, position) in candidates.iter().enumerate() { - let unary = chain - .unary - .get(note) - .and_then(|u| u.get(c)) - .copied() - .unwrap_or(0); - let mut secondary = dot(weights, ¬e_features(*position)); - if let Some((r, margin)) = reference { - if r.get(note) == Some(position) { - secondary = secondary.saturating_add(i128::from(margin)); - } - } - let (from, parent_index) = match note.checked_sub(1) { - None => ((0_i64, 0_i128), 0), - Some(previous) => { - let mut chosen: Option<(Lexi, usize)> = None; - for (a, (prev_value, prev_position)) in best[previous] - .iter() - .zip(chain.candidates(previous)) - .enumerate() - { - let transition = chain - .pairwise - .get(note) - .and_then(|p| p.get(a)) - .and_then(|p| p.get(c)) - .copied() - .unwrap_or(0); - let value = ( - prev_value.0.saturating_add(transition), - prev_value.1.saturating_add(dot( - weights, - &transition_features(*prev_position, *position), - )), - ); - if chosen.is_none_or(|(v, _)| value < v) { - chosen = Some((value, a)); - } - } - chosen.unwrap_or(((0, 0), 0)) - } - }; - layer.push(( - from.0.saturating_add(unary), - from.1.saturating_add(secondary), - )); - parents.push(parent_index); - } - best.push(layer); - parent.push(parents); + let reference = augment.filter(|(r, _)| r.len() == chain.len()); + match reference { + Some((r, margin)) => lexicographic_dp(chain, weights, Some(r), Tiebreak::Augment(margin)), + None => lexicographic_dp(chain, weights, None, Tiebreak::Plain), } - let mut c = 0; - for (index, value) in best[n - 1].iter().enumerate() { - if *value < best[n - 1][c] { - c = index; - } - } - let mut path = vec![0; n]; - for note in (0..n).rev() { - path[note] = c; - c = parent[note][c]; - } - path } /// The latent learning target for one line: among the optimal paths that @@ -398,8 +325,15 @@ pub fn latent_target( weights: &Features, reference: &[FretboardPosition], ) -> Option> { - let _ = (chain, weights, reference); - todo!("latent target — green step") + if reference.len() != chain.len() { + return None; + } + Some(lexicographic_dp( + chain, + weights, + Some(reference), + Tiebreak::Latent, + )) } /// One training line: its primary chain and the tab author's positions. @@ -433,22 +367,22 @@ pub struct TrainedSecondary { pub epochs: usize, } -/// Learns secondary weights with an averaged, loss-augmented structured -/// perceptron **inside the primary optimum set**. The target per example is -/// the achievable one — [`AgreementRange::best_path`], not the human path, -/// which is often not primary-optimal. When the (augmented) prediction agrees -/// with the tab author less than the target does, the weights move by +/// Learns secondary weights with an averaged, loss-augmented, latent-target +/// structured perceptron **inside the primary optimum set**. The target per +/// step is the achievable one under the current weights — [`latent_target`]: +/// the cheapest of the most-agreeing optimal paths, not the human path, which +/// is often not primary-optimal. When the (augmented) prediction agrees with +/// the tab author less than the target does, the weights move by /// `features(prediction) − features(target)`. Deterministic: examples in the /// given order, integer arithmetic. #[must_use] pub fn train_secondary(examples: &[Example], config: &PerceptronConfig) -> TrainedSecondary { - // The achievable target per example: a most-agreeing optimal path. - let prepared: Vec<(&Example, usize, Features)> = examples + // The most agreement any optimal path reaches, per example. + let prepared: Vec<(&Example, usize)> = examples .iter() .filter_map(|example| { let range = optimum_set(&example.chain, Some(&example.human)).agreement?; - let target = path_features(&example.chain, &range.best_path)?; - Some((example, range.max, target)) + Some((example, range.max)) }) .collect(); let mut weights = [0_i64; FEATURES]; @@ -458,14 +392,18 @@ pub fn train_secondary(examples: &[Example], config: &PerceptronConfig) -> Train while epochs < config.epochs { epochs += 1; let mut changed = false; - for (example, target_matches, target_features) in &prepared { + for (example, most) in &prepared { let augment = (config.margin != 0).then_some((example.human.as_slice(), config.margin)); let predicted = lexicographic_path(&example.chain, &weights, augment); let matches = path_matches(&example.chain, &predicted, &example.human).unwrap_or(0); - if matches < *target_matches { - if let Some(features) = path_features(&example.chain, &predicted) { - for ((w, f), t) in weights.iter_mut().zip(features).zip(target_features) { - *w = w.saturating_add(f.saturating_sub(*t)); + if matches < *most { + let target = latent_target(&example.chain, &weights, &example.human) + .and_then(|t| path_features(&example.chain, &t)); + if let (Some(features), Some(target)) = + (path_features(&example.chain, &predicted), target) + { + for ((w, f), t) in weights.iter_mut().zip(features).zip(target) { + *w = w.saturating_add(f.saturating_sub(t)); } updates = updates.saturating_add(1); changed = true; @@ -488,6 +426,111 @@ pub fn train_secondary(examples: &[Example], config: &PerceptronConfig) -> Train // ── private machinery ───────────────────────────────────────────────────────── +/// What the lexicographic DP does with reference matches. +#[derive(Clone, Copy)] +enum Tiebreak { + /// Ignore the reference: `(primary, secondary)`. + Plain, + /// Add `margin` to the secondary cost per matching note. + Augment(i64), + /// `(primary, −matches, secondary)`: the most-agreeing optimal paths first. + Latent, +} + +/// The lexicographic chain DP behind [`lexicographic_path`] and +/// [`latent_target`]: minimizes `(primary, middle, secondary)` with strict +/// comparisons, so ties keep the lowest candidate indices as the production DP +/// does. `reference` must have the chain's length when given. +fn lexicographic_dp( + chain: &Chain, + weights: &Features, + reference: Option<&[FretboardPosition]>, + mode: Tiebreak, +) -> Vec { + let n = chain.len(); + if n == 0 { + return Vec::new(); + } + let mut best: Vec> = Vec::with_capacity(n); + let mut parent: Vec> = Vec::with_capacity(n); + for note in 0..n { + let candidates = chain.candidates(note); + let mut layer = Vec::with_capacity(candidates.len()); + let mut parents = Vec::with_capacity(candidates.len()); + for (c, position) in candidates.iter().enumerate() { + let unary = chain + .unary + .get(note) + .and_then(|u| u.get(c)) + .copied() + .unwrap_or(0); + let matched = reference.is_some_and(|r| r.get(note) == Some(position)); + let mut middle = 0_i64; + let mut secondary = dot(weights, ¬e_features(*position)); + match mode { + Tiebreak::Plain => {} + Tiebreak::Augment(margin) => { + if matched { + secondary = secondary.saturating_add(i128::from(margin)); + } + } + Tiebreak::Latent => middle = -i64::from(matched), + } + let (from, parent_index) = match note.checked_sub(1) { + None => ((0_i64, 0_i64, 0_i128), 0), + Some(previous) => { + let mut chosen: Option<(Lexi, usize)> = None; + for (a, (prev_value, prev_position)) in best[previous] + .iter() + .zip(chain.candidates(previous)) + .enumerate() + { + let transition = chain + .pairwise + .get(note) + .and_then(|p| p.get(a)) + .and_then(|p| p.get(c)) + .copied() + .unwrap_or(0); + let value = ( + prev_value.0.saturating_add(transition), + prev_value.1, + prev_value.2.saturating_add(dot( + weights, + &transition_features(*prev_position, *position), + )), + ); + if chosen.is_none_or(|(v, _)| value < v) { + chosen = Some((value, a)); + } + } + chosen.unwrap_or(((0, 0, 0), 0)) + } + }; + layer.push(( + from.0.saturating_add(unary), + from.1.saturating_add(middle), + from.2.saturating_add(secondary), + )); + parents.push(parent_index); + } + best.push(layer); + parent.push(parents); + } + let mut c = 0; + for (index, value) in best[n - 1].iter().enumerate() { + if *value < best[n - 1][c] { + c = index; + } + } + let mut path = vec![0; n]; + for note in (0..n).rev() { + path[note] = c; + c = parent[note][c]; + } + path +} + /// A path count carried through the DPs: exact (saturating) and in logs. #[derive(Debug, Clone, Copy)] struct Count { @@ -554,8 +597,9 @@ fn log_add(a: f64, b: f64) -> f64 { hi + (lo - hi).exp().ln_1p() } -/// A lexicographic `(primary, secondary)` cost. -type Lexi = (i64, i128); +/// A lexicographic `(primary, middle, secondary)` cost; the middle term is +/// `−matches` for [`latent_target`] and 0 otherwise. +type Lexi = (i64, i64, i128); /// Most reference matches reaching a candidate along optimal edges, with the /// predecessor attaining it; `None` off the optimum. From 0699d32ca4e61e29f13bb89c1ed230a5ec857c44 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 07:36:45 +0500 Subject: [PATCH 06/10] =?UTF-8?q?test(lab):=20red=20=E2=80=94=20hand=20anc?= =?UTF-8?q?hor=20before=20a=20line=20as=20tie-break=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the latent target and margins up to 10,000, the learned tie-break still changed only 7 of 7,091 train lines. Diagnosis on train songs: where a most-agreeing optimal path differs from production, the tab author plays the same pitch one or two strings lower and higher up the neck (+1 string/+5 frets 50%, +1/+4 40%, +2/+9 7%) — yet in ~75% of lines the author agrees with production, so no global local-geometry weight separates the cases; 42.5% of the differing notes sit in repeated-pitch runs, where travel inside the line is free. The deciding information lies outside the line: the author's choice is closer to the fret of the last fretted event before the line in 63% of differing lines (production's in 33%). Pins that context as a measured feature: - TabLine::anchor_fret — the fret of the latest positioned, fretted note of the voice before the line (lowest fret at a chord onset; open strings and unpositioned notes skipped; None at a voice's start); - Chain::with_anchor / anchor and the anchor_distance secondary feature (|fret − anchor| per fretted note), primary cost unchanged; - brute-force secondary optimality with anchored chains, and a perceptron that learns an anchor-nearest tie-break on held-out lines. TabLine carries anchor_fret: None and with_anchor is todo!() here (4 red). Co-Authored-By: Claude Opus 5 --- lab/src/fingering.rs | 7 +++ lab/src/ties.rs | 24 ++++++++- lab/tests/fingering.rs | 31 +++++++++++ lab/tests/ties.rs | 119 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 2 deletions(-) diff --git a/lab/src/fingering.rs b/lab/src/fingering.rs index cf64098..b9f30d0 100644 --- a/lab/src/fingering.rs +++ b/lab/src/fingering.rs @@ -131,6 +131,12 @@ pub struct TabLine { pub pitches: Vec, /// The tab author's positions — one per pitch, each sounding it. pub human: Vec, + /// Where the fretting hand was just before the line: the fret of the + /// latest positioned, fretted note of this voice with an earlier onset + /// (the lowest such fret when that onset is a chord). `None` when nothing + /// fretted precedes the line. Taken from the tab — context for tab + /// completion, not something MIDI-sourced material carries. + pub anchor_fret: Option, } /// Cuts one track into monophonic tablature lines, per voice. @@ -1038,6 +1044,7 @@ impl<'a> LineBuilder<'a> { tuning: self.tuning.clone(), pitches, human, + anchor_fret: None, }); } } diff --git a/lab/src/ties.rs b/lab/src/ties.rs index 8c86b79..b219a84 100644 --- a/lab/src/ties.rs +++ b/lab/src/ties.rs @@ -19,7 +19,7 @@ use crate::fingering::v1_unary; use crate::problems::LabError; /// Number of secondary features ([`FEATURE_NAMES`]). -pub const FEATURES: usize = 20; +pub const FEATURES: usize = 21; /// Secondary feature names, in [`Features`] order. Per note: `fret`, `open`, /// one-hot `string_1` … `string_7` (strings above 7 count as 7). Per @@ -28,7 +28,9 @@ pub const FEATURES: usize = 20; /// [Δfret = 0, both fretted], `span_over_3` / `span_over_5` [|Δfret| > 3 / 5, /// both fretted], `open_transition` [either open], `diagonal` [Δstring ≠ 0 and /// Δfret ≠ 0], `toward_high_string` [Δstring < 0], `fret_up` [Δfret > 0], -/// `box_move` [Δstring and Δfret nonzero with the same sign]. +/// `box_move` [Δstring and Δfret nonzero with the same sign]. With an anchor +/// ([`Chain::with_anchor`]), per fretted note: `anchor_distance` +/// |fret − anchor|. pub const FEATURE_NAMES: [&str; FEATURES] = [ "fret", "open", @@ -50,6 +52,7 @@ pub const FEATURE_NAMES: [&str; FEATURES] = [ "toward_high_string", "fret_up", "box_move", + "anchor_distance", ]; /// A feature vector (or a weight vector over it). @@ -65,6 +68,8 @@ pub struct Chain { /// `pairwise[i][a][b]`: candidate `a` of note `i − 1` to candidate `b` of /// note `i`; `pairwise[0]` is empty. pairwise: Vec>>, + /// Fret the hand was at before the line, when known. + anchor: Option, } impl Chain { @@ -128,9 +133,24 @@ impl Chain { positions, unary, pairwise, + anchor: None, }) } + /// The same chain with a hand anchor (e.g. `TabLine::anchor_fret`) for the + /// `anchor_distance` secondary feature. The primary objective is unchanged. + #[must_use] + pub fn with_anchor(self, anchor: Option) -> Self { + let _ = anchor; + todo!("chain anchor — green step") + } + + /// The hand anchor, when set. + #[must_use] + pub const fn anchor(&self) -> Option { + self.anchor + } + /// Notes in the line. #[must_use] pub fn len(&self) -> usize { diff --git a/lab/tests/fingering.rs b/lab/tests/fingering.rs index 5004bbb..a3ec919 100644 --- a/lab/tests/fingering.rs +++ b/lab/tests/fingering.rs @@ -288,6 +288,37 @@ fn tab_lines_keep_non_monotonic_tunings_as_they_are() { assert_eq!(lines[0].human[1], pos(4, 0)); } +#[test] +fn tab_lines_record_the_hand_anchor_before_each_line() { + let s = score(vec![vec![ + // A chord at 0 (frets 12 and 14): the next line starts from fret 12. + group(vec![note(0, 57, Some((5, 12))), note(0, 64, Some((4, 14)))]), + single(Q, 59, Some((4, 9))), + single(2 * Q, 60, Some((4, 10))), + single(3 * Q, 62, Some((4, 12))), + single(4 * Q, 64, Some((4, 14))), + // An open string does not place the hand; the fretted chord note does. + group(vec![ + note(5 * Q, 40, Some((6, 0))), + note(5 * Q, 47, Some((5, 2))), + ]), + single(6 * Q, 45, Some((5, 0))), + single(7 * Q, 47, Some((5, 2))), + single(8 * Q, 48, Some((5, 3))), + single(9 * Q, 50, Some((5, 5))), + ]]); + let (lines, _) = tab_lines(&s, 0, &LineCut::v1()).unwrap(); + let anchors: Vec> = lines.iter().map(|l| l.anchor_fret).collect(); + assert_eq!(anchors, vec![Some(12), Some(2)]); + + // Nothing fretted before a voice's first line; an unpositioned note and an + // open string are skipped on the way back. + let (lines, _) = tab_lines(&cut_fixture(), 0, &LineCut::v1()).unwrap(); + assert_eq!(lines[0].anchor_fret, None); + assert_eq!(lines[1].anchor_fret, Some(2)); + assert_eq!(lines[3].anchor_fret, None, "voice 1 starts fresh"); +} + #[test] fn tab_lines_refuse_a_missing_track() { assert_eq!( diff --git a/lab/tests/ties.rs b/lab/tests/ties.rs index 67e43ed..349ee3c 100644 --- a/lab/tests/ties.rs +++ b/lab/tests/ties.rs @@ -462,6 +462,76 @@ fn path_features_are_summed_over_notes_and_transitions() { assert_eq!(path_features(&chain, &path[..2]), None); } +#[test] +fn anchor_distance_sums_fretted_distance_to_the_anchor() { + let tuning = Tuning::standard_e(); + let pitches = pitches_of(&[52, 57, 64]); + let chain = Chain::v1( + &pitches, + &tuning, + &FingeringWeights::v1(), + STANDARD_MAX_FRET, + ) + .unwrap(); + let want = [pos(6, 12), pos(5, 12), pos(1, 0)]; + let path: Vec = want + .iter() + .enumerate() + .map(|(i, p)| chain.candidates(i).iter().position(|c| c == p).unwrap()) + .collect(); + assert_eq!(chain.anchor(), None); + assert_eq!( + feature(&path_features(&chain, &path).unwrap(), "anchor_distance"), + 0 + ); + let anchored = chain.clone().with_anchor(Some(10)); + assert_eq!(anchored.anchor(), Some(10)); + // |12 − 10| + |12 − 10|; the open string does not count. + assert_eq!( + feature(&path_features(&anchored, &path).unwrap(), "anchor_distance"), + 4 + ); + assert_eq!(anchored.cost(&path), chain.cost(&path), "primary unchanged"); +} + +#[test] +fn anchored_lexicographic_path_is_secondary_optimal() { + let tuning = Tuning::standard_e(); + let mut secondary: Features = [0; FEATURES]; + secondary[FEATURE_NAMES + .iter() + .position(|n| *n == "anchor_distance") + .unwrap()] = 3; + secondary[0] = -1; + for w in weight_sets() { + for raw in sequences(&ALPHABET, 3) { + for anchor in [1, 7, 15] { + let chain = Chain::v1(&pitches_of(&raw), &tuning, &w, STANDARD_MAX_FRET) + .unwrap() + .with_anchor(Some(anchor)); + let best = all_paths(&chain) + .iter() + .map(|p| { + ( + chain.cost(p).unwrap(), + dot(&secondary, &path_features(&chain, p).unwrap()), + ) + }) + .min() + .unwrap(); + let path = lexicographic_path(&chain, &secondary, None); + assert_eq!( + ( + chain.cost(&path).unwrap(), + dot(&secondary, &path_features(&chain, &path).unwrap()) + ), + best + ); + } + } + } +} + #[test] fn path_matches_counts_equal_positions() { let tuning = Tuning::standard_e(); @@ -524,6 +594,55 @@ fn perceptron_learns_a_separable_tie_break() { assert_eq!(train_secondary(&train, &config), trained, "deterministic"); } +/// Zero primary weights; each line has its own anchor, and the "author" plays +/// every note at the candidate nearest that anchor. +fn anchored_examples(lines: &[Vec], offset: usize) -> Vec { + let tuning = Tuning::standard_e(); + lines + .iter() + .enumerate() + .map(|(i, pitches)| { + let anchor = 3 + ((i + offset) % 13) as u8; + let chain = Chain::v1(pitches, &tuning, &weights(0, 0, 0, 0), STANDARD_MAX_FRET) + .unwrap() + .with_anchor(Some(anchor)); + let human = (0..chain.len()) + .map(|n| { + *chain + .candidates(n) + .iter() + .filter(|c| c.fret > 0) + .min_by_key(|c| (c.fret.abs_diff(anchor), c.string)) + .unwrap() + }) + .collect(); + Example { chain, human } + }) + .collect() +} + +#[test] +fn perceptron_learns_an_anchor_tie_break() { + let train = anchored_examples(&lcg_lines(60, 8, 45, 64), 0); + let trained = train_secondary( + &train, + &PerceptronConfig { + epochs: 30, + margin: 1, + }, + ); + let (mut agree, mut notes) = (0, 0); + for ex in anchored_examples(&lcg_lines(12, 10, 45, 64), 5) { + let path = lexicographic_path(&ex.chain, &trained.weights, None); + agree += path_matches(&ex.chain, &path, &ex.human).unwrap(); + notes += ex.chain.len(); + } + assert!( + agree * 10 >= notes * 9, + "held-out anchored agreement {agree}/{notes} below 90%" + ); +} + #[test] fn perceptron_makes_no_update_when_the_target_is_already_chosen() { // With the production tie-break as the "author", zero weights already agree. From 17aeaa6b40318325ca398625cac2aa92b1393fe6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 07:37:59 +0500 Subject: [PATCH 07/10] =?UTF-8?q?feat(lab):=20green=20=E2=80=94=20hand=20a?= =?UTF-8?q?nchor=20on=20tablature=20lines=20and=20as=20a=20secondary=20fea?= =?UTF-8?q?ture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tab_lines tracks, per voice, the lowest fretted position at the latest onset seen (chord notes included, open strings and unpositioned notes skipped) and stamps it on a line when its first note arrives: the fret the hand was at just before the line. Chain::with_anchor keeps the primary objective and feeds anchor_distance (|fret - anchor| per fretted note) to the secondary features. Suite green (30 fingering + 17 ties + 49 spike/optir); clippy clean. Co-Authored-By: Claude Opus 5 --- lab/src/fingering.rs | 21 ++++++++++++++++++--- lab/src/ties.rs | 12 +++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/lab/src/fingering.rs b/lab/src/fingering.rs index b9f30d0..4f6b38c 100644 --- a/lab/src/fingering.rs +++ b/lab/src/fingering.rs @@ -202,6 +202,8 @@ pub fn tab_lines( let mut line = LineBuilder::new(track_index, voice.id, &tuning); let mut sounding_until: Option = None; + // Lowest fretted position at the latest onset seen so far. + let mut last_fretted: Option = None; let mut rest = notes.as_slice(); while let Some(first) = rest.first() { let onset = first.absolute_start.0; @@ -210,6 +212,16 @@ pub fn tab_lines( .position(|n| n.absolute_start.0 != onset) .unwrap_or(rest.len()); let (group, tail) = rest.split_at(width); + let anchor_here = last_fretted; + if let Some(fret) = group + .iter() + .filter_map(|n| n.position) + .map(|p| p.position.fret) + .filter(|&fret| fret > 0) + .min() + { + last_fretted = Some(fret); + } rest = tail; stats.notes_seen = stats.notes_seen.saturating_add(count(group.len())); @@ -247,7 +259,7 @@ pub fn tab_lines( line.flush(cut, &mut lines, &mut stats); continue; } - line.push(onset, note.pitch, orient(position)); + line.push(onset, note.pitch, orient(position), anchor_here); } line.flush(cut, &mut lines, &mut stats); } @@ -993,6 +1005,7 @@ struct LineBuilder<'a> { voice: u8, tuning: &'a Tuning, start_tick: u32, + anchor: Option, pitches: Vec, human: Vec, } @@ -1004,6 +1017,7 @@ impl<'a> LineBuilder<'a> { voice, tuning, start_tick: 0, + anchor: None, pitches: Vec::new(), human: Vec::new(), } @@ -1013,9 +1027,10 @@ impl<'a> LineBuilder<'a> { self.pitches.is_empty() } - fn push(&mut self, onset: u32, pitch: Pitch, position: FretboardPosition) { + fn push(&mut self, onset: u32, pitch: Pitch, position: FretboardPosition, anchor: Option) { if self.pitches.is_empty() { self.start_tick = onset; + self.anchor = anchor; } self.pitches.push(pitch); self.human.push(position); @@ -1044,7 +1059,7 @@ impl<'a> LineBuilder<'a> { tuning: self.tuning.clone(), pitches, human, - anchor_fret: None, + anchor_fret: self.anchor, }); } } diff --git a/lab/src/ties.rs b/lab/src/ties.rs index b219a84..2fed893 100644 --- a/lab/src/ties.rs +++ b/lab/src/ties.rs @@ -141,8 +141,7 @@ impl Chain { /// `anchor_distance` secondary feature. The primary objective is unchanged. #[must_use] pub fn with_anchor(self, anchor: Option) -> Self { - let _ = anchor; - todo!("chain anchor — green step") + Self { anchor, ..self } } /// The hand anchor, when set. @@ -307,7 +306,7 @@ pub fn path_features(chain: &Chain, path: &[usize]) -> Option { let positions = chain.positions_of(path)?; let mut total = [0_i64; FEATURES]; for (note, position) in positions.iter().enumerate() { - add_features(&mut total, ¬e_features(*position)); + add_features(&mut total, ¬e_features(*position, chain.anchor)); if let Some(previous) = note.checked_sub(1).and_then(|i| positions.get(i)) { add_features(&mut total, &transition_features(*previous, *position)); } @@ -486,7 +485,7 @@ fn lexicographic_dp( .unwrap_or(0); let matched = reference.is_some_and(|r| r.get(note) == Some(position)); let mut middle = 0_i64; - let mut secondary = dot(weights, ¬e_features(*position)); + let mut secondary = dot(weights, ¬e_features(*position, chain.anchor)); match mode { Tiebreak::Plain => {} Tiebreak::Augment(margin) => { @@ -829,12 +828,15 @@ fn add_features(total: &mut Features, part: &Features) { } } -fn note_features(position: FretboardPosition) -> Features { +fn note_features(position: FretboardPosition, anchor: Option) -> Features { let mut f = [0_i64; FEATURES]; f[0] = i64::from(position.fret); f[1] = i64::from(position.fret == 0); let string = usize::from(position.string.clamp(1, 7)); f[1 + string] = 1; + if position.fret > 0 { + f[20] = anchor.map_or(0, |a| i64::from(position.fret.abs_diff(a))); + } f } From 3adf76a603d8d57c5a7ce188902f81f0c969f254 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 07:42:43 +0500 Subject: [PATCH 08/10] =?UTF-8?q?feat(lab):=20tie-break=20ablation=20?= =?UTF-8?q?=E2=80=94=20local=20features=20versus=20local=20+=20hand=20anch?= =?UTF-8?q?or?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tiebreak now trains and reports two secondary variants per primary: local fingering-geometry features only, and the same plus anchor_distance to the line's hand anchor. Margins span 0 to 10^7 and training runs 20 epochs; the margin is chosen on the song-level validation bucket per variant, and the ladder reports lines whose path the learned tie-break changes. Co-Authored-By: Claude Opus 5 --- lab/src/bin/fingering_gap.rs | 211 ++++++++++++++++++++--------------- 1 file changed, 121 insertions(+), 90 deletions(-) diff --git a/lab/src/bin/fingering_gap.rs b/lab/src/bin/fingering_gap.rs index 971db2c..67862c8 100644 --- a/lab/src/bin/fingering_gap.rs +++ b/lab/src/bin/fingering_gap.rs @@ -1006,6 +1006,16 @@ fn chain_of(line: &Line, weights: &FingeringWeights) -> Chain { .expect("tab lines only hold positionable pitches") } +/// A chain with or without the line's hand anchor (the tie-break ablation). +fn feature_chain(line: &Line, weights: &FingeringWeights, anchored: bool) -> Chain { + let chain = chain_of(line, weights); + if anchored { + chain.with_anchor(line.tab.anchor_fret) + } else { + chain + } +} + #[derive(Debug, Clone, Default, Serialize)] struct TiesCheck { records: usize, @@ -1122,11 +1132,16 @@ fn line_ties(line: &Line, weights: &FingeringWeights) -> LineTies { } #[allow(clippy::cast_precision_loss)] -fn ladder(lines: &[&Line], weights: &FingeringWeights, learned: Option<&Features>) -> Ladder { +fn ladder( + lines: &[&Line], + weights: &FingeringWeights, + learned: Option<&Features>, + anchored: bool, +) -> Ladder { let rows = par_map(lines, |line| { let ties = line_ties(line, weights); let learned_matches = learned.map(|w| { - let chain = chain_of(line, weights); + let chain = feature_chain(line, weights, anchored); let path = lexicographic_path(&chain, w, None); let production = lexicographic_path(&chain, &[0; FEATURES], None); ( @@ -1164,11 +1179,11 @@ fn ladder(lines: &[&Line], weights: &FingeringWeights, learned: Option<&Features } } -fn examples_of(lines: &[&Line], weights: &FingeringWeights) -> Vec { +fn examples_of(lines: &[&Line], weights: &FingeringWeights, anchored: bool) -> Vec { lines .iter() .map(|line| Example { - chain: chain_of(line, weights), + chain: feature_chain(line, weights, anchored), human: line.tab.human.clone(), }) .collect() @@ -1183,11 +1198,8 @@ struct MarginTrial { } #[derive(Serialize)] -struct TiebreakReport { - schema: &'static str, - version: u32, - primary: String, - epochs: usize, +struct TiebreakVariant { + features: &'static str, trials: Vec, chosen_margin: i64, final_updates: u64, @@ -1195,12 +1207,21 @@ struct TiebreakReport { weights: BTreeMap<&'static str, i64>, train: Ladder, test: Ladder, +} + +#[derive(Serialize)] +struct TiebreakReport { + schema: &'static str, + version: u32, + primary: String, + epochs: usize, + variants: Vec, corpus: CorpusFacts, } const VALIDATION_BUCKET: u64 = 1; -const TIEBREAK_EPOCHS: usize = 10; -const MARGINS: [i64; 5] = [0, 1, 2, 4, 8]; +const TIEBREAK_EPOCHS: usize = 20; +const MARGINS: [i64; 7] = [0, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000]; #[allow(clippy::cast_precision_loss)] fn tiebreak(corpus: Corpus, models: &[Model], out: &Path) -> std::io::Result<()> { @@ -1228,99 +1249,109 @@ fn tiebreak(corpus: Corpus, models: &[Model], out: &Path) -> std::io::Result<()> test.len() ); - let fit_examples = examples_of(&fit, &weights); - let mut trials = Vec::new(); - for margin in MARGINS { - let started = Instant::now(); - let trained = train_secondary( - &fit_examples, + let mut variants = Vec::new(); + for anchored in [false, true] { + let label = if anchored { + "local + anchor" + } else { + "local only" + }; + let fit_examples = examples_of(&fit, &weights, anchored); + let mut trials = Vec::new(); + for margin in MARGINS { + let started = Instant::now(); + let trained = train_secondary( + &fit_examples, + &PerceptronConfig { + epochs: TIEBREAK_EPOCHS, + margin, + }, + ); + let score = ladder(&validation, &weights, Some(&trained.weights), anchored) + .learned + .unwrap_or(0.0); + eprintln!( + "[{label}] margin {margin}: {} updates, {} epochs, validation agreement {:.2}% ({:.1}s)", + trained.updates, + trained.epochs, + 100.0 * score, + started.elapsed().as_secs_f64() + ); + trials.push(MarginTrial { + margin, + epochs: trained.epochs, + updates: trained.updates, + validation_agreement: score, + }); + } + let chosen_margin = trials + .iter() + .fold(None::<&MarginTrial>, |best, t| match best { + Some(b) if b.validation_agreement >= t.validation_agreement => Some(b), + _ => Some(t), + }) + .map_or(0, |t| t.margin); + let final_trained = train_secondary( + &examples_of(&train, &weights, anchored), &PerceptronConfig { epochs: TIEBREAK_EPOCHS, - margin, + margin: chosen_margin, }, ); - let score = ladder(&validation, &weights, Some(&trained.weights)) - .learned - .unwrap_or(0.0); - eprintln!( - "margin {margin}: {} updates, {} epochs, validation agreement {:.2}% ({:.1}s)", - trained.updates, - trained.epochs, - 100.0 * score, - started.elapsed().as_secs_f64() - ); - trials.push(MarginTrial { - margin, - epochs: trained.epochs, - updates: trained.updates, - validation_agreement: score, + variants.push(TiebreakVariant { + features: label, + trials, + chosen_margin, + final_updates: final_trained.updates, + final_epochs: final_trained.epochs, + weights: FEATURE_NAMES + .iter() + .copied() + .zip(final_trained.weights) + .collect(), + train: ladder(&train, &weights, Some(&final_trained.weights), anchored), + test: ladder(&test, &weights, Some(&final_trained.weights), anchored), }); } - let chosen_margin = trials - .iter() - .fold(None::<&MarginTrial>, |best, t| match best { - Some(b) if b.validation_agreement >= t.validation_agreement => Some(b), - _ => Some(t), - }) - .map_or(0, |t| t.margin); - - let final_trained = train_secondary( - &examples_of(&train, &weights), - &PerceptronConfig { - epochs: TIEBREAK_EPOCHS, - margin: chosen_margin, - }, - ); - let train_ladder = ladder(&train, &weights, Some(&final_trained.weights)); - let test_ladder = ladder(&test, &weights, Some(&final_trained.weights)); - println!( - "\nprimary {} — margin {chosen_margin} (validation), {} updates over {} epochs on all train songs", - model.description_short(), - final_trained.updates, - final_trained.epochs - ); - println!("\n| split | lines | human optimal | unique optimum | ln #optima p50 / p90 | floor | uniform over optima | production tie-break | learned tie-break | ceiling |"); - println!("|---|---|---|---|---|---|---|---|---|---|"); - for (name, l) in [ - ("train", &train_ladder), - ("test (holdout songs)", &test_ladder), - ] { + println!("\nprimary {}", model.description_short()); + println!("\n| features | split | lines | human optimal | unique optimum | ln #optima p50 / p90 | floor | uniform over optima | production tie-break | learned tie-break | lines changed | ceiling | margin |"); + println!("|---|---|---|---|---|---|---|---|---|---|---|---|---|"); + for v in &variants { + for (name, l) in [("train", &v.train), ("test (holdout)", &v.test)] { + println!( + "| {} | {name} | {} | {:.1}% | {:.1}% | {:.2} / {:.2} | {:.1}% | {:.1}% | {:.1}% | {} | {} | {:.1}% | {} |", + v.features, + l.lines, + 100.0 * l.human_optimal_lines as f64 / l.lines.max(1) as f64, + 100.0 * l.unique_optimum_lines as f64 / l.lines.max(1) as f64, + l.ln_count.p50 as f64 / 1000.0, + l.ln_count.p90 as f64 / 1000.0, + 100.0 * l.floor, + 100.0 * l.uniform, + 100.0 * l.production, + l.learned.map_or("—".into(), |x| format!("{:.1}%", 100.0 * x)), + l.learned_changed_lines.map_or("—".into(), |x| x.to_string()), + 100.0 * l.ceiling, + v.chosen_margin + ); + } + } + for v in &variants { println!( - "| {name} | {} | {:.1}% | {:.1}% | {:.2} / {:.2} | {:.1}% | {:.1}% | {:.1}% | {} | {:.1}% |", - l.lines, - 100.0 * l.human_optimal_lines as f64 / l.lines.max(1) as f64, - 100.0 * l.unique_optimum_lines as f64 / l.lines.max(1) as f64, - l.ln_count.p50 as f64 / 1000.0, - l.ln_count.p90 as f64 / 1000.0, - 100.0 * l.floor, - 100.0 * l.uniform, - 100.0 * l.production, - l.learned.map_or("—".into(), |x| format!("{:.1}%", 100.0 * x)), - 100.0 * l.ceiling + "\n[{}] learned secondary weights (averaged, unnormalized):", + v.features ); - } - let weights_by_name: BTreeMap<&'static str, i64> = FEATURE_NAMES - .iter() - .copied() - .zip(final_trained.weights) - .collect(); - println!("\nlearned secondary weights (averaged, unnormalized):"); - for (name, w) in FEATURE_NAMES.iter().zip(final_trained.weights) { - println!(" {name:>20} {w}"); + for (name, w) in &v.weights { + println!(" {name:>20} {w}"); + } } let report = TiebreakReport { schema: "griff.constraint-lab-tiebreak", - version: 1, + version: 2, primary: model.description_short(), epochs: TIEBREAK_EPOCHS, - trials, - chosen_margin, - final_updates: final_trained.updates, - final_epochs: final_trained.epochs, - weights: weights_by_name, - train: train_ladder, - test: test_ladder, + variants, corpus: corpus.facts, }; write_json(&out.join("tiebreak.json"), &report) From b5400a26d9f0baf84f7f3cd62ae5823b549001ad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 07:43:37 +0500 Subject: [PATCH 09/10] docs(lab): fingering tie-break audit and decision record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archives the optimum-set and learned tie-break results (aggregates only): exact DPs verified against CP-SAT optima and ceilings on 1,954 / 1,954 holdout lines; the tie-break ladder under v1-fit — floor 32.4%, uniform 42.9%, production 44.1%, learned local 44.0%, learned local + hand anchor 47.3%, ceiling 55.5%; tie structure and the v1 control; the diagnosis that tab authors' deviations are one-directional (lower string, higher fret) but context-dependent, with the preceding fretted event closer to the author's choice in 63% of differing lines; limitations (human-sourced anchor, linear non-convergent learner) and follow-ups. Co-Authored-By: Claude Opus 5 --- docs/audit/2026-09-fingering-tie-break.md | 158 ++++++++++++++++++++++ docs/decisions.log.md | 14 ++ lab/README.md | 18 +++ 3 files changed, 190 insertions(+) create mode 100644 docs/audit/2026-09-fingering-tie-break.md diff --git a/docs/audit/2026-09-fingering-tie-break.md b/docs/audit/2026-09-fingering-tie-break.md new file mode 100644 index 0000000..8bd4980 --- /dev/null +++ b/docs/audit/2026-09-fingering-tie-break.md @@ -0,0 +1,158 @@ +# 2026-09 — Fingering tie-break: optimum sets and a learned secondary objective + +Follow-up to the optimality-gap audit +([`2026-09-fingering-optimality-gap.md`](2026-09-fingering-optimality-gap.md), +PhysShell/griff#197), whose corrected numbers left one sharp question. Under +the fitted `v1` weights (fret 0, open-string penalty 3, position shift 1, +string change 0) the production DP keeps **44.1%** agreement with tab authors +on holdout songs, while the set of cost-optimal fingerings contains **55.5%**. +That 11.4-point gap needs no change to the primary objective, only a better +choice among its ties. This audit measures the tie sets exactly, asks how much +of the gap a **human-blind learned tie-break** recovers, and locates the +information the tab authors used. + +Research tooling only (`lab/`, excluded from the workspace); no production +code changes. Corpus, protocol and holdout are those of the corrected +optimality-gap audit: 410 Guitar Pro files, 1,149 guitar tracks, 9,045 +monophonic lines, song-level holdout (1,954 test lines, 70,933 notes). Only +aggregates are recorded; tab content stays out of git (ADR-0005). + +## What was built + +`lab/src/ties.rs`, red → green per commit: + +- **`Chain`** — the `v1` objective per line: candidates in production order, + unary and pairwise costs. +- **`optimum_set`** — exact chain DPs, no search. Forward and backward cost + tables carry path counts (saturating `u64` plus an exact natural log). A node + or edge lies on an optimal path iff `f + g = optimum`. From these come the + number of optimal paths; the **least and most agreement** with a reference + among them (min/max DPs over optimal edges); the **expected agreement under a + uniform draw** from the optimum set (paths through a candidate / total, in + log space); and a most-agreeing optimal path. +- **`lexicographic_path`** — minimizes `(primary, secondary)`; with zero + secondary weights it reproduces the production DP path exactly. It supports + loss augmentation (a margin per reference-matching note). +- **`latent_target`** — among the most-agreeing optimal paths, the cheapest + under the current secondary weights: the achievable learning target. The + human path itself is primary-optimal in only 31% of holdout lines. +- **`train_secondary`** — averaged, loss-augmented, latent-target structured + perceptron over integer weights, inside the primary optimum set. +- **Secondary features** — 20 local fingering-geometry features (fret, open + string, string one-hot; fret and string distance, string change, same fret, + spans over 3/5 frets, open transitions, diagonal and box moves, direction), + plus **`anchor_distance`** (|fret − anchor| per fretted note). + `TabLine::anchor_fret` gives the anchor: the fret of the latest fretted note + of the voice before the line, the lowest fret when that onset is a chord. +- **Runner** (`fingering_gap`) — `ties-check` compares the DPs with verified + CP-SAT records; `tiebreak` trains on train songs, picks the margin on a + song-level validation bucket (5,111 fit / 1,980 validation lines), and reports + on holdout songs. + +## Verification + +- Contract suite against brute force on exhaustive small families: + - optimum, exact count, min/max/expected agreement; + - secondary optimality, including anchored chains; + - the loss-augmented least-agreeing path; + - the latent target; + - zero-secondary equality with `infer_positions`; + - count saturation with an exact log; + - perceptron convergence on separable synthetic tie-breaks, local and anchored. +- **Exact DPs versus CP-SAT** (`ties-check`), on the verified agreement-pass + records from the corrected optimality-gap run: for **1,954 / 1,954** holdout + lines under both `v1` and `v1-fit`, the DP optimum equals the CP-SAT optimum + and the DP ceiling equals CP-SAT's recounted ceiling. The ceiling now costs + milliseconds instead of a solver pass. CP-SAT remains the spot-check oracle + for these DPs. + +## Results — the tie-break ladder (holdout songs) + +Primary `v1-fit` (the under-discriminative objective): + +| rung | agreement | lines changed vs production | +|---|---|---| +| floor — least-agreeing optimal path | 32.4% | | +| uniform draw from the optimum set | 42.9% | | +| production tie-break (lowest candidate index) | 44.1% | — | +| learned tie-break, local features | 44.0% | 89 | +| **learned tie-break, local features + hand anchor** | **47.3%** | 411 | +| ceiling — most-agreeing optimal path | 55.5% | | + +The anchored tie-break recovers **3.2 points, 28% of the gap** between the +production tie-break and the ceiling, without touching the primary objective. +The margin (10⁴) was chosen on validation. The validation curve is interior +(47.0 / 46.9 / 47.7 / **48.8** / 48.6 / 48.6 / 48.5% for margins 0 … 10⁷), +over 20 epochs of a non-separable problem (≈57k updates). + +Tie structure under `v1-fit` (holdout): + +- the optimum is unique in 45.3% of lines; +- ln(#optimal paths): median 0.69 (2 paths), p75 1.79 (6), p90 5.55 (~250), + p99 28.3; +- the human fingering is itself optimal in 30.9% of lines. + +**Control — production `v1` weights.** The optimum is unique in 87.1% of +lines, and the whole ladder spans floor 35.7% → ceiling 36.2%. The learned +tie-break reaches 36.1%, inside that band: the learner finds what little there +is and cannot exceed the ceiling by construction. + +## Results — where the tab authors' information lives + +**The local features carry none of it.** With local features only, the +learned tie-break converges on production's own choices: the latent target, +margins up to 10⁷ and 20 epochs changed no conclusion (44.0% vs 44.1%). A +diagnostic probe over train songs shows why: + +- where a most-agreeing optimal path differs from production, the author plays + the same pitch **one or two strings lower and higher up the neck** (+1 string + / +5 frets 50%, +1 / +4 40%, +2 / +9 7% of differing notes); +- yet in about three quarters of lines the author agrees with production's + "highest string, lowest fret" choice. No global weight on local geometry + separates the two; +- 42.5% of the differing notes sit in repeated-pitch runs, where hand travel + inside the line is free. + +**Part of it is just outside the line.** Lines are cut at chords and rests, +and the author's choice tracks where the hand was before the cut. On +differing notes the author's fret is closer to the anchor than production's +in 56.7% of notes against 37.8% (5.5% equal); per line, 63.4% against 32.8%. +Adding that one feature produced the only real gain. + +## Reading + +- **The v1-fit ceiling is not a local-geometry ceiling.** The optimum set does + contain better fingerings, but a tie-break that sees only the line cannot + tell them apart. The discriminating information is contextual. +- **Context works as expected.** One boundary feature recovers about a + quarter of the gap. The rest likely needs richer context than a single fret: + the anchor string, time since the anchor, the shape of the chord before and + after, and what follows the line. +- **Lexicographic primary + secondary is the right production shape** for this + kind of gain. It is exact, cheap, and leaves the primary objective's + guarantees intact. + +## Limitations (recorded, not hidden) + +- **The anchor is taken from the human tab.** That is the tab-completion + scenario. For MIDI-sourced material the preceding event is usually a chord, + which production leaves unpositioned today (ADR-0019 §7): an anchor there + needs chord voicing first. This ties the monophonic quality gain to the + next Lab subject. +- The secondary is linear and hand-featured, and the perceptron is not + convergent on real data (averaged weights, 20 epochs). A max-margin or + probabilistic learner may extract more from the same features. +- One primary (`v1-fit`) and its control; the hand-position model is parked + per the optimality-gap audit. +- Agreement treats one tab as ground truth. + +## Follow-ups proposed + +1. Richer boundary context for the secondary: anchor string, anchor-to-line + time, the following event's position, chord span. Measure each as an + ablation on this ladder. +2. Chord voicing (the next Constraint Lab subject), which also produces + anchors for MIDI-sourced lines. +3. Once the anchored tie-break is stable, an ADR for lexicographic + primary + secondary fingering in `core`, with the secondary weights as + versioned data (ADR-0017 §3). diff --git a/docs/decisions.log.md b/docs/decisions.log.md index bed3819..6a25a5a 100644 --- a/docs/decisions.log.md +++ b/docs/decisions.log.md @@ -2858,3 +2858,17 @@ Architectural decisions go to [`adr/`](adr/) instead. venv adapter (`lab/cpsat/`), never a dependency; idea-level prior art only (TablaZinc is MPL-2.0, `guitar-tab-generator` GPL-3.0 — no code copied). + +- 2026-09-17 — In the context of the fitted fingering objective being + under-discriminative (holdout: production tie-break 44.1% agreement, best + cost-optimal fingering 55.5%), we decided to **measure optimum sets with + exact chain DPs and to learn a lexicographic secondary tie-break instead of + refitting the primary objective**, to achieve an attribution of the gap + between search, local fingering geometry and context, accepting that the + first useful feature comes from the human tab (the hand anchor before a + line). Result (`docs/audit/2026-09-fingering-tie-break.md`): the DPs agree + with verified CP-SAT optima and ceilings on 1,954 / 1,954 holdout lines; a + tie-break over local geometry recovers nothing (44.0%); adding the anchor + 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. diff --git a/lab/README.md b/lab/README.md index f740e4f..681937e 100644 --- a/lab/README.md +++ b/lab/README.md @@ -89,6 +89,24 @@ Everything written to `--out` is corpus-derived and stays local; `report` archives aggregates only (`report.json`). Results: [`../docs/audit/2026-09-fingering-optimality-gap.md`](../docs/audit/2026-09-fingering-optimality-gap.md). +## Optimum sets and the learned tie-break + +`src/ties.rs` measures a fingering objective's optimum set exactly with chain +DPs — how many optimal paths, and the least, most and expected (uniform draw) +agreement with the tab author among them — and learns a human-blind secondary +objective that breaks ties lexicographically after the primary cost +(averaged, loss-augmented, latent-target perceptron). `ties-check` compares +the DPs with verified CP-SAT records; `tiebreak` reports the ladder +floor → uniform → production tie-break → learned → ceiling on holdout songs, +with and without the line's hand anchor. + +```sh +./target/release/fingering_gap ties-check --tabs $T --out $O --v1 v1-fit=0,-3,1,0 +./target/release/fingering_gap tiebreak --tabs $T --out $O --v1 v1-fit=0,-3,1,0 +``` + +Results: [`../docs/audit/2026-09-fingering-tie-break.md`](../docs/audit/2026-09-fingering-tie-break.md). + ## Known spike limits (deliberate) - The reference solver is leaf-checked backtracking with two sound band From d84b5b6b6d45475073e735ca13581865ecee7658 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 16:11:34 +0500 Subject: [PATCH 10/10] docs(lab): re-measure the tie-break ladder after the tuplet import fix (#202) This branch now contains #202, so the audit gains a "Re-measured after #202" section and a pointer from the introduction. The numbers were taken on this branch merged with main at e871a44, with the same commands, weights, epochs and validation split: - `ties-check`: the exact optimum-set DPs equal the re-measured CP-SAT optima and ceilings on 1,945 / 1,945 holdout lines, for `v1` and `v1-fit`. - `v1-fit` ladder: floor 32.6, uniform 43.0, production 44.2, learned local 44.1, learned + anchor 47.3 (+3.10 pt), ceiling 55.4. - The `v1` control is +0.32 pt. The validation-chosen margins are unchanged. The train-song diagnostic probe was not re-run and is marked as such. No conclusion changes. The ladder reproduces the impact sweep recorded before the legato census, figure for figure. Co-Authored-By: Claude Opus 5 --- docs/audit/2026-09-fingering-tie-break.md | 37 +++++++++++++++++++++++ docs/decisions.log.md | 3 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/audit/2026-09-fingering-tie-break.md b/docs/audit/2026-09-fingering-tie-break.md index 8bd4980..910a85d 100644 --- a/docs/audit/2026-09-fingering-tie-break.md +++ b/docs/audit/2026-09-fingering-tie-break.md @@ -17,6 +17,11 @@ optimality-gap audit: 410 Guitar Pro files, 1,149 guitar tracks, 9,045 monophonic lines, song-level holdout (1,954 test lines, 70,933 notes). Only aggregates are recorded; tab content stays out of git (ADR-0005). +> **Re-measured after the tuplet import fix (#202).** The ladder below was +> measured before #202. Rerun on the corrected import (1,945 holdout lines), +> it moves by at most 0.4 points, and the anchor still recovers +3.10 points. +> See [Re-measured after #202](#re-measured-after-202). + ## What was built `lab/src/ties.rs`, red → green per commit: @@ -119,6 +124,38 @@ differing notes the author's fret is closer to the anchor than production's in 56.7% of notes against 37.8% (5.5% equal); per line, 63.4% against 32.8%. Adding that one feature produced the only real gain. +## Re-measured after #202 + +PhysShell/griff#202 corrected the importer's tuplet durations. Before the +fix, bars with tuplets overflowed and tab lines interleaved neighbouring +bars. This section's numbers come from this branch merged with `main` at +`e871a44`. The commands, weights, epochs and validation split are unchanged. +The holdout songs now yield 1,945 lines and 72,245 notes. + +- **DPs against CP-SAT** (`ties-check`). Checked against the verified records + of the re-measured optimality-gap run. On **1,945 / 1,945** holdout lines, + under both `v1` and `v1-fit`, the DP optimum equals the CP-SAT optimum and + the DP ceiling equals CP-SAT's. +- **Ladder** (`tiebreak`, holdout songs). The validation-chosen margins are + unchanged (`v1-fit` 10⁴; `v1` 10³ local, 10⁴ anchored). + +| weights | features | floor | uniform | production | learned | lines changed | ceiling | learned − production | +|---|---|---|---|---|---|---|---|---| +| `v1-fit` | local | 32.4 → 32.6% | 42.9 → 43.0% | 44.1 → 44.2% | 44.0 → 44.1% | 89 → 81 | 55.5 → 55.4% | −0.18 → −0.15 pt | +| `v1-fit` | local + anchor | 32.4 → 32.6% | 42.9 → 43.0% | 44.1 → 44.2% | 47.3 → 47.3% | 411 → 409 | 55.5 → 55.4% | **+3.11 → +3.10 pt** | +| `v1` | local + anchor | 35.7 → 35.3% | 35.9 → 35.5% | 35.8 → 35.4% | 36.1 → 35.7% | 253 → 246 | 36.2 → 35.8% | +0.33 → +0.32 pt | + +- **Tie structure under `v1-fit`.** + - The optimum is unique in 44.9% of lines (was 45.3%). + - ln(#optimal paths): median and p75 are unchanged; p90 is 5.70 (was 5.55). + - The human fingering is optimal in 30.7% of lines (was 30.9%). +- **Not re-run.** The diagnostic probe over train songs (string and fret + offsets, repeated-pitch runs, anchor distances) was not re-run, so its + percentages are pre-#202. + +The anchor's gain survives the corrected timeline, even though the anchor is +the note just before the line. Nothing in the readings below changes. + ## Reading - **The v1-fit ceiling is not a local-geometry ceiling.** The optimum set does diff --git a/docs/decisions.log.md b/docs/decisions.log.md index f2912a8..65e0255 100644 --- a/docs/decisions.log.md +++ b/docs/decisions.log.md @@ -2950,6 +2950,7 @@ Architectural decisions go to [`adr/`](adr/) instead. line). Result (`docs/audit/2026-09-fingering-tie-break.md`): the DPs agree with verified CP-SAT optima and ceilings on 1,954 / 1,954 holdout lines; a tie-break over local geometry recovers nothing (44.0%); adding the anchor - recovers 3.2 points (47.3%, 28% of the gap). The discriminating + recovers 3.2 points (47.3%, 28% of the gap; +3.10 points after the + tuplet import fix #202). The discriminating information is contextual, so the next Lab subject (chord voicing) is also what makes such anchors available for MIDI-sourced material.