Skip to content

Commit 08cdc2d

Browse files
authored
Merge pull request #207 from PhysShell/claude/lab-legato-projection-fix
lab: project legato to same-string voice targets
2 parents f7feaee + 7175991 commit 08cdc2d

7 files changed

Lines changed: 605 additions & 314 deletions

File tree

‎docs/audit/2026-09-fingering-legato-continuity.md‎

Lines changed: 98 additions & 83 deletions
Large diffs are not rendered by default.

‎lab/src/bin/fingering_gap.rs‎

Lines changed: 81 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -79,15 +79,16 @@ use std::time::Instant;
7979
use griff_constraint_lab::fingering::{
8080
best_hands, decode_positions, hand_problem, holdout_bucket, repeat_pairs, solve_hand, song_key,
8181
tab_lines, v1_cost, v1_problem, with_repeat_consistency, with_string_tiebreak, CutStats,
82-
HandModel, HandWeights, LineCut, TabLine, TechniqueEdge, HAND_VARS_PER_NOTE, V1_VARS_PER_NOTE,
82+
HandModel, HandWeights, LineCut, TabLine, TechniqueEdge, TechniqueKind, HAND_VARS_PER_NOTE,
83+
V1_VARS_PER_NOTE,
8384
};
8485
use griff_constraint_lab::ir::VarId;
8586
use griff_constraint_lab::optir::{
8687
verify_agreement, verify_record, OptProblem, ProblemRecord, SolveRecord, Verdict,
8788
};
8889
use griff_constraint_lab::technique::{
89-
derived_direction, tap_aware_chain, tap_aware_cost, technique_chain, technique_cost,
90-
Continuity, LegatoDirection, TechniqueObjective,
90+
derived_direction, direction_between, tap_aware_chain, tap_aware_cost, technique_chain,
91+
technique_cost, Continuity, LegatoDirection, TechniqueObjective,
9192
};
9293
use griff_constraint_lab::ties::{
9394
lexicographic_path, optimum_set, path_matches, train_secondary, Chain, Example, Features,
@@ -1792,52 +1793,60 @@ impl LegatoCensus {
17921793
let (before, here) = (tab.human[i - 1], tab.human[i]);
17931794
let same = before.string == here.string;
17941795
let open = here.fret == 0;
1795-
let edge = tab.edges[i];
17961796
let direction = derived_direction(&tab.pitches, i);
17971797
self.edges += 1;
1798-
if edge.is_legato() {
1799-
self.legato.add(same);
1800-
match edge {
1801-
TechniqueEdge::HammerOn => self.hammer_on.add(same),
1802-
TechniqueEdge::PullOff => self.pull_off.add(same),
1803-
TechniqueEdge::Legato => self.legato_span.add(same),
1804-
TechniqueEdge::Plain => {}
1805-
}
1806-
match direction {
1807-
Some(LegatoDirection::Ascending) => self.ascending.add(same),
1808-
Some(LegatoDirection::Descending) => {
1809-
self.descending.add(same);
1810-
self.descending_legato_open.add(open);
1811-
}
1812-
Some(LegatoDirection::Unison) => self.unison.add(same),
1813-
None => {}
1814-
}
1815-
} else {
1798+
let observed = tab
1799+
.edges
1800+
.iter()
1801+
.any(|edge| edge.from == i - 1 && edge.to == i);
1802+
if !observed {
18161803
self.plain.add(same);
18171804
if direction == Some(LegatoDirection::Descending) {
18181805
self.descending_plain_open.add(open);
18191806
}
18201807
}
18211808
if tab.tapped[i - 1] {
18221809
self.out_of_tap += 1;
1823-
if edge.is_legato() {
1824-
self.out_of_tap_legato.add(same);
1825-
match direction {
1826-
Some(LegatoDirection::Ascending) => self.out_of_tap_legato_ascending += 1,
1827-
Some(LegatoDirection::Descending) => {
1828-
self.out_of_tap_legato_descending += 1;
1829-
}
1830-
Some(LegatoDirection::Unison) => self.out_of_tap_legato_unison += 1,
1831-
None => {}
1832-
}
1833-
self.out_of_tap_legato_open_target += u64::from(open);
1834-
}
18351810
}
18361811
if tab.tapped[i] {
18371812
self.into_tap += 1;
1838-
if edge.is_legato() {
1839-
self.into_tap_legato.add(same);
1813+
}
1814+
}
1815+
for edge in &tab.edges {
1816+
let before = tab.human[edge.from];
1817+
let here = tab.human[edge.to];
1818+
let same = before.string == here.string;
1819+
let open = here.fret == 0;
1820+
let direction = direction_between(&tab.pitches, edge.from, edge.to);
1821+
self.legato.add(same);
1822+
match edge.kind {
1823+
TechniqueKind::HammerOn => self.hammer_on.add(same),
1824+
TechniqueKind::PullOff => self.pull_off.add(same),
1825+
TechniqueKind::Legato => self.legato_span.add(same),
1826+
}
1827+
match direction {
1828+
Some(LegatoDirection::Ascending) => self.ascending.add(same),
1829+
Some(LegatoDirection::Descending) => {
1830+
self.descending.add(same);
1831+
self.descending_legato_open.add(open);
1832+
}
1833+
Some(LegatoDirection::Unison) => self.unison.add(same),
1834+
None => {}
1835+
}
1836+
if tab.tapped[edge.from] {
1837+
self.out_of_tap_legato.add(same);
1838+
match direction {
1839+
Some(LegatoDirection::Ascending) => self.out_of_tap_legato_ascending += 1,
1840+
Some(LegatoDirection::Descending) => {
1841+
self.out_of_tap_legato_descending += 1;
1842+
}
1843+
Some(LegatoDirection::Unison) => self.out_of_tap_legato_unison += 1,
1844+
None => {}
18401845
}
1846+
self.out_of_tap_legato_open_target += u64::from(open);
1847+
}
1848+
if tab.tapped[edge.to] {
1849+
self.into_tap_legato.add(same);
18411850
}
18421851
}
18431852
}
@@ -1855,9 +1864,11 @@ struct CensusRow {
18551864
struct CensusReport {
18561865
schema: &'static str,
18571866
version: u32,
1858-
/// Legato origins on the last note of a kept line (whole corpus, all
1859-
/// formats): they project onto no edge.
1867+
/// Legato origins in kept lines with no later same-string note in the
1868+
/// imported voice.
18601869
dangling_legato: u64,
1870+
/// Resolved same-string targets that fall outside their origin's kept line.
1871+
cross_line_legato: u64,
18611872
rows: Vec<CensusRow>,
18621873
corpus: CorpusFacts,
18631874
}
@@ -1918,7 +1929,9 @@ fn legato_census(corpus: Corpus, out: &Path) -> std::io::Result<()> {
19181929
.collect();
19191930

19201931
let dangling = corpus.facts.cut_stats.dangling_legato;
1921-
println!("\nlegato origins ending a kept line (no edge): {dangling}");
1932+
let cross_line = corpus.facts.cut_stats.cross_line_legato;
1933+
println!("\nunresolved legato origins (no later same-string voice note): {dangling}");
1934+
println!("resolved legato targets outside their origin's kept line: {cross_line}");
19221935
println!("\nL1/L2 — P(same string | edge): share (edges). Legato kinds are imported; directions are derived from pitch.\n");
19231936
println!("| split | family | population | legato edges | HammerOn | PullOff | Legato | plain edges (base rate) | ascending legato | descending legato | unison legato |");
19241937
println!("|---|---|---|---|---|---|---|---|---|---|---|");
@@ -1986,8 +1999,9 @@ fn legato_census(corpus: Corpus, out: &Path) -> std::io::Result<()> {
19861999
}
19872000
let report = CensusReport {
19882001
schema: "griff.constraint-lab-legato-census",
1989-
version: 1,
2002+
version: 2,
19902003
dangling_legato: dangling,
2004+
cross_line_legato: cross_line,
19912005
rows,
19922006
corpus: corpus.facts,
19932007
};
@@ -2096,66 +2110,50 @@ struct LegatoLine {
20962110
}
20972111

20982112
fn cross_string_legato(path: &[FretboardPosition], edges: &[TechniqueEdge]) -> u64 {
2099-
path.windows(2)
2100-
.zip(edges.iter().skip(1))
2101-
.filter(|(pair, edge)| {
2102-
edge.is_legato() && pair.first().map(|p| p.string) != pair.last().map(|p| p.string)
2103-
})
2113+
edges
2114+
.iter()
2115+
.filter(|edge| path[edge.from].string != path[edge.to].string)
21042116
.count() as u64
21052117
}
21062118

2107-
/// Human cross-string legato edges with enough local context to audit whether
2108-
/// the imported origin should bind to the immediate next onset or to a later
2109-
/// note on the origin string. This is diagnostic output only; it does not feed
2110-
/// any objective.
2119+
/// Any human cross-string legato edges left after same-string projection.
2120+
/// This is a fail-closed regression manifest only; it does not feed any
2121+
/// objective and is expected to be empty on the research corpus.
21112122
fn cross_string_legato_forensics(tab: &TabLine) -> Vec<serde_json::Value> {
2112-
(1..tab.human.len())
2113-
.filter_map(|i| {
2114-
let edge = tab.edges[i];
2115-
let from = tab.human[i - 1];
2116-
let immediate_to = tab.human[i];
2117-
if !edge.is_legato() || from.string == immediate_to.string {
2123+
tab.edges
2124+
.iter()
2125+
.filter_map(|edge| {
2126+
let from = tab.human[edge.from];
2127+
let target = tab.human[edge.to];
2128+
if from.string == target.string {
21182129
return None;
21192130
}
21202131

2121-
let direction = match derived_direction(&tab.pitches, i) {
2132+
let direction = match direction_between(&tab.pitches, edge.from, edge.to) {
21222133
Some(LegatoDirection::Ascending) => "ascending",
21232134
Some(LegatoDirection::Descending) => "descending",
21242135
Some(LegatoDirection::Unison) => "unison",
21252136
None => "unknown",
21262137
};
2127-
let next_on_origin_string = ((i + 1)..tab.human.len())
2128-
.find(|&j| tab.human[j].string == from.string)
2129-
.map(|j| {
2130-
serde_json::json!({
2131-
"index": j,
2132-
"notes_after_immediate": j - i,
2133-
"pitch": tab.pitches[j].0,
2134-
"string": tab.human[j].string,
2135-
"fret": tab.human[j].fret,
2136-
"tapped": tab.tapped[j],
2137-
})
2138-
});
21392138

21402139
Some(serde_json::json!({
2141-
"edge_into": i,
2142-
"kind": format!("{edge:?}"),
2140+
"kind": format!("{:?}", edge.kind),
21432141
"derived_direction": direction,
21442142
"from": {
2145-
"index": i - 1,
2146-
"pitch": tab.pitches[i - 1].0,
2143+
"index": edge.from,
2144+
"pitch": tab.pitches[edge.from].0,
21472145
"string": from.string,
21482146
"fret": from.fret,
2149-
"tapped": tab.tapped[i - 1],
2147+
"tapped": tab.tapped[edge.from],
21502148
},
2151-
"immediate_to": {
2152-
"index": i,
2153-
"pitch": tab.pitches[i].0,
2154-
"string": immediate_to.string,
2155-
"fret": immediate_to.fret,
2156-
"tapped": tab.tapped[i],
2149+
"target": {
2150+
"index": edge.to,
2151+
"notes_after_origin": edge.to - edge.from,
2152+
"pitch": tab.pitches[edge.to].0,
2153+
"string": target.string,
2154+
"fret": target.fret,
2155+
"tapped": tab.tapped[edge.to],
21572156
},
2158-
"next_on_origin_string": next_on_origin_string,
21592157
}))
21602158
})
21612159
.collect()
@@ -2217,7 +2215,7 @@ fn legato_line(line: &Line, weights: &FingeringWeights) -> LegatoLine {
22172215
paths.push(path);
22182216
}
22192217
let tapped = tab.tapped.iter().filter(|t| **t).count() as u64;
2220-
let has_legato = tab.edges.iter().any(|e| e.is_legato());
2218+
let has_legato = !tab.edges.is_empty();
22212219
LegatoLine {
22222220
notes: tab.human.len() as u64,
22232221
tapped,
@@ -2497,7 +2495,7 @@ fn legato(corpus: Corpus, out: &Path) -> std::io::Result<()> {
24972495
let file = corpus.names.get(line.file);
24982496
let forensic = serde_json::json!({
24992497
"schema": "griff.constraint-lab-legato-violator",
2500-
"version": 1,
2498+
"version": 2,
25012499
"id": line.id,
25022500
"file": file,
25032501
"song": file.map(|name| song_key(name)),
@@ -2580,7 +2578,7 @@ fn legato(corpus: Corpus, out: &Path) -> std::io::Result<()> {
25802578
let without_legato = corpus
25812579
.lines
25822580
.iter()
2583-
.filter(|l| l.tab.edges.iter().all(|e| !e.is_legato()))
2581+
.filter(|l| l.tab.edges.is_empty())
25842582
.count();
25852583
let untapped = corpus
25862584
.lines

0 commit comments

Comments
 (0)