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..910a85d --- /dev/null +++ b/docs/audit/2026-09-fingering-tie-break.md @@ -0,0 +1,195 @@ +# 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). + +> **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: + +- **`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. + +## 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 + 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 3093053..65e0255 100644 --- a/docs/decisions.log.md +++ b/docs/decisions.log.md @@ -2939,3 +2939,18 @@ Architectural decisions go to [`adr/`](adr/) instead. Identities are consistency, not authentication: the only sealing path is crate-private, so the public API cannot re-verify an edited run. + +- 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; +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. 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 diff --git a/lab/src/bin/fingering_gap.rs b/lab/src/bin/fingering_gap.rs index 60b5180..67862c8 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,376 @@ 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") +} + +/// 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, + 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>, + anchored: bool, +) -> Ladder { + let rows = par_map(lines, |line| { + let ties = line_ties(line, weights); + let learned_matches = learned.map(|w| { + let chain = feature_chain(line, weights, anchored); + 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, anchored: bool) -> Vec { + lines + .iter() + .map(|line| Example { + chain: feature_chain(line, weights, anchored), + human: line.tab.human.clone(), + }) + .collect() +} + +#[derive(Serialize)] +struct MarginTrial { + margin: i64, + epochs: usize, + updates: u64, + validation_agreement: f64, +} + +#[derive(Serialize)] +struct TiebreakVariant { + features: &'static str, + trials: Vec, + chosen_margin: i64, + final_updates: u64, + final_epochs: usize, + 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 = 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<()> { + 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 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: chosen_margin, + }, + ); + 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), + }); + } + + 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!( + "\n[{}] learned secondary weights (averaged, unnormalized):", + v.features + ); + for (name, w) in &v.weights { + println!(" {name:>20} {w}"); + } + } + let report = TiebreakReport { + schema: "griff.constraint-lab-tiebreak", + version: 2, + primary: model.description_short(), + epochs: TIEBREAK_EPOCHS, + variants, + corpus: corpus.facts, + }; + write_json(&out.join("tiebreak.json"), &report) +} + // ── repeat consistency ──────────────────────────────────────────────────────── /// Window of a repeated figure, in notes. @@ -1262,6 +1662,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()) diff --git a/lab/src/fingering.rs b/lab/src/fingering.rs index 8177ec7..4f6b38c 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. @@ -196,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; @@ -204,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())); @@ -241,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); } @@ -987,6 +1005,7 @@ struct LineBuilder<'a> { voice: u8, tuning: &'a Tuning, start_tick: u32, + anchor: Option, pitches: Vec, human: Vec, } @@ -998,6 +1017,7 @@ impl<'a> LineBuilder<'a> { voice, tuning, start_tick: 0, + anchor: None, pitches: Vec::new(), human: Vec::new(), } @@ -1007,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); @@ -1038,6 +1059,7 @@ impl<'a> LineBuilder<'a> { tuning: self.tuning.clone(), pitches, human, + anchor_fret: self.anchor, }); } } @@ -1047,7 +1069,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/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..2fed893 --- /dev/null +++ b/lab/src/ties.rs @@ -0,0 +1,860 @@ +//! 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::fingering::v1_unary; +use crate::problems::LabError; + +/// Number of secondary features ([`FEATURE_NAMES`]). +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 +/// 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]. With an anchor +/// ([`Chain::with_anchor`]), per fretted note: `anchor_distance` +/// |fret − anchor|. +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", + "anchor_distance", +]; + +/// 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>>, + /// Fret the hand was at before the line, when known. + anchor: Option, +} + +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 { + 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, + 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 { + Self { anchor, ..self } + } + + /// 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 { + 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 { + 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> { + if path.len() != self.len() { + return None; + } + path.iter() + .enumerate() + .map(|(note, &c)| self.positions.get(note)?.get(c).copied()) + .collect() + } +} + +/// 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 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. +#[must_use] +pub fn path_matches( + chain: &Chain, + path: &[usize], + reference: &[FretboardPosition], +) -> Option { + 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 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, chain.anchor)); + 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, +/// 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 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), + } +} + +/// 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> { + 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. +#[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, 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 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?; + Some((example, range.max)) + }) + .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, 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 < *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; + } + } + for (s, w) in sum.iter_mut().zip(weights) { + *s = s.saturating_add(w); + } + } + if !changed { + break; + } + } + TrainedSecondary { + weights: sum, + updates, + epochs, + } +} + +// ── 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, chain.anchor)); + 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 { + 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, 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. +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, 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 +} + +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/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 new file mode 100644 index 0000000..349ee3c --- /dev/null +++ b/lab/tests/ties.rs @@ -0,0 +1,669 @@ +//! 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, + clippy::cast_possible_wrap +)] + +use griff_constraint_lab::{ + fingering::v1_cost, + problems::LabError, + ties::{ + latent_target, 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(&[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(&[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) + ); + } + } +} + +#[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 { + 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 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(); + 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"); +} + +/// 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. + 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]); +}