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