diff --git a/docs/audit/2026-09-fingering-legato-continuity.md b/docs/audit/2026-09-fingering-legato-continuity.md index 51b0217..134e623 100644 --- a/docs/audit/2026-09-fingering-legato-continuity.md +++ b/docs/audit/2026-09-fingering-legato-continuity.md @@ -446,6 +446,69 @@ rests on few songs. 30 tapped lines, reported, not interpreted. `v1-fit` exactness: B 6.7%, C1 16.7%, D1 26.7%, C2(3) 13.3%, D2 13.3%. +### Projection forensic tail (post-fix) + +`legato-census` now writes three diagnostic-only artifacts under `--out`: +`legato-cross-line.jsonl` (all relations whose resolved target is outside the +origin line), `legato-longest-within-line.jsonl` (the deterministic top 50 by +exact quarter-note gap), and `legato-span-census.json` (nearest-rank +distributions overall and by kind, derived direction, origin/target tap state, +and open target). Ratios are reduced integers rather than floats. Licensed +corpus records are not committed. The census fails closed if the cross-line +manifest count differs from the importer's count. + +Full-corpus rerun (410 files) answers the boundary question more narrowly than +the old gap-only manifest could: + +- **46 / 46 cross-line relations** were emitted, from 44 origin lines and 15 + songs. The first boundary includes `chord_onset` in 45 cases and `rest_cut` + in 5 (causes can coincide): 41 are chord-only, 4 rest-plus-chord, and only 1 + is a pure rest cut. Across every crossed boundary location, the counts are + 333 chord and 7 rest; none is caused by unpositioned input, maximum fret, or + pitch mismatch. +- **38 targets are themselves excluded chord-onset atoms.** They do not belong + to any monophonic `TabLine`; this, rather than an ordinary handoff to the + next line, explains most of the 46. The other 8 targets are in kept lines: + 6 in the immediately next kept line and 2 much farther away. Those two span + 64.375 quarters / 16 boundary locations / 15 intervening kept fragments + (`Blue Dream`) and 182.496875 quarters / 27 locations / 3 kept plus 2 dropped + fragments (`Have A Great Life`). No target is in a dropped short fragment. +- Most cross-line gaps are nevertheless local in time: 30/46 are at most 1/2 + quarter and 31/46 at most one quarter. The exact quarter-gap distribution is + min 1/8, median 1/2, p90 27.5, p95 75, p99/max 182.496875. Thirty-six cases + cross one boundary; boundary-count median is 1, p90 16, p95 38, p99/max 155. +- The **29,758 retained within-line relations** are overwhelmingly ordinary: + note-distance min/median/p90/p95/p99 is 1 (max 169), intervening-onset and + note-atom min/median/p90/p95/p99 is 0 (max 168), and exact quarter-gap is + min 1/8, median 1/4, p90/p95/p99 1/2, max 212/3. Only 110 relations (0.37%) + are non-adjacent. +- The top 50 starts at 2.25 quarters and covers 10 songs. Fourteen relations + exceed 8 quarters and three exceed 32. The extremes are 70.667 and 57.5 + quarters in `Say Hi`, 39 in `Missed Injections`, 26.5 in `There's No Dust + in the City`, and a repeated 23.5-quarter pair in `Frozen One`. Sixteen + repeated 2.5-quarter figures in `Son of Robot` and six repeated + 10.167-quarter figures in `Say Hi` show that a long relation can be a stable + phrase pattern, while the isolated extreme spans are consistent with stale + or overextended source flags. That last classification is heuristic, not a + correction rule or ground truth. + +The evidence therefore separates two architectural questions. Some imported +technique relations do survive an optimization-line boundary, but only 8 of +the 46 have targets representable in another kept line, and only 6 reach the +next one. The dominant 38 are instead chord targets missing from the +monophonic representation. Together with #199's `anchor_fret` result this is +convergent, hypothesis-generating evidence that both hand position and some +technique context can outlive a line. It does **not** establish that production +must carry technique boundary state. + +A falsifiable next experiment should keep the strata separate: first expose +the 38 chord targets to evaluation without joining lines or changing the +objective, then replay only the 8 kept-target relations with an exact observed +origin-string boundary state and compare against independent-line inference. +If neither changes feasibility or the registered metrics on its predeclared +cases, the boundary-state hypothesis is rejected. No threshold or category +from this audit enters projection or optimization. + ## Reading 1. **Observed legato continuity explains a large, robust part of the @@ -484,20 +547,33 @@ C1 16.7%, D1 26.7%, C2(3) 13.3%, D2 13.3%. MIDI-sourced lines carry neither, so these gains assume the labels. - **Import limits.** Legato is imported as an origin only, and D's direction is derived from pitch. -- **Line slicing.** 46 resolved targets lie outside their origin's kept line. - They are counted explicitly but cannot enter a line-local objective. +- **Line slicing.** 46 resolved targets lie outside their origin's kept line: + 38 are excluded chord atoms and only 8 are in another kept line. They are + counted and emitted explicitly but cannot enter a line-local objective. - **Weights.** `v1-fit` was fitted on all lines and reused unchanged. `k` and the waiver were fixed before the results. - **Concentration.** The C1 → D1 gain rests on 3 songs. - **Holdout.** The holdout slice (30 lines) is too small to interpret. -## Follow-ups proposed (not done) - -1. **Hidden technique inference must predict legato edges, not only taps.** - Continuity carries half of the exactness effect, so a MIDI-side model - without legato labels would lose it. -2. **Inspect the 46 cross-line relations and the longest within-line spans.** - They are the remaining forensic set for distinguishing ordinary voice - interleaving from suspicious transcription or import cases. -3. **Hammer-on / pull-off direction.** D's derived direction argues for the - separate core decision on importing or deriving direction for all formats. +## Stage status and next targets + +Stage 2 is frozen after the semantic repair, independent remeasurement, and +the projection forensic tail: + +- **C1:** robust corpus evidence for hard continuity when an observed legato + relation is available. +- **D1:** promising conditional evidence, not a canonical objective rule; its + gain is concentrated and direction is derived from pitch. +- **No C3/C4 tuning:** the remaining tail does not justify another ladder of + penalties or exceptions. + +Before changing production, the next boundary experiment should test the two +predeclared strata above independently: chord-target visibility (38 cases) and +observed state replay between kept lines (8 cases). The next large Constraint +Lab target remains **chord voicing**, extending the guitar-specific structural +work from monophonic paths to hand shapes. **Hidden technique inference** +remains the next bridge to MIDI: predict taps and legato relations from pitches, +timing, and context, then score both label quality and downstream fingering +regret against this supervised oracle. +Importing or deriving hammer-on / pull-off direction remains a separate core +decision. diff --git a/lab/src/bin/fingering_gap.rs b/lab/src/bin/fingering_gap.rs index 42e6921..797ce8f 100644 --- a/lab/src/bin/fingering_gap.rs +++ b/lab/src/bin/fingering_gap.rs @@ -78,10 +78,12 @@ use std::time::Instant; use griff_constraint_lab::fingering::{ best_hands, decode_positions, hand_problem, holdout_bucket, repeat_pairs, solve_hand, song_key, - tab_lines, v1_cost, v1_problem, with_repeat_consistency, with_string_tiebreak, CutStats, - HandModel, HandWeights, LineCut, TabLine, TechniqueEdge, TechniqueKind, HAND_VARS_PER_NOTE, - V1_VARS_PER_NOTE, + tab_lines, v1_cost, v1_problem, with_repeat_consistency, with_string_tiebreak, + within_line_span, CrossLineBoundary, CutStats, HandModel, HandWeights, LineBoundary, + LineBoundaryCause, LineCut, TabLine, TargetDisposition, TechniqueEdge, TechniqueKind, + TechniqueSpanStats, HAND_VARS_PER_NOTE, V1_VARS_PER_NOTE, }; +use griff_constraint_lab::forensics::{distribution, top_n_longest, Distribution, ExactRatio}; use griff_constraint_lab::ir::VarId; use griff_constraint_lab::optir::{ verify_agreement, verify_record, OptProblem, ProblemRecord, SolveRecord, Verdict, @@ -1869,10 +1871,393 @@ struct CensusReport { dangling_legato: u64, /// Resolved same-string targets that fall outside their origin's kept line. cross_line_legato: u64, + projection_forensics: ProjectionForensicSummary, rows: Vec, corpus: CorpusFacts, } +#[derive(Debug, Clone, Serialize)] +struct ProjectionForensicSummary { + cross_line: u64, + within_line_edges: u64, + non_adjacent_within_line: u64, + top_longest_written: u64, + targets_in_next_kept_line: u64, + boundary_causes: BTreeMap<&'static str, u64>, + first_boundary_causes: BTreeMap<&'static str, u64>, + target_exclusion_causes: BTreeMap<&'static str, u64>, + target_dispositions: BTreeMap<&'static str, u64>, + boundaries_crossed: Distribution, + cross_line_delta_quarters: Distribution, +} + +#[derive(Debug, Clone, Serialize)] +struct ProjectionPoint { + line_index: Option, + voice_note_id: usize, + onset: u32, + duration: u32, + pitch: u8, + string: u8, + fret: u8, + tapped: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct CrossLineForensic { + schema: &'static str, + version: u32, + id: String, + file: String, + song: String, + family: &'static str, + test: bool, + track: usize, + voice: u8, + line_start_tick: u32, + ticks_per_quarter: u32, + kind: String, + origin: ProjectionPoint, + target: ProjectionPoint, + span: TechniqueSpanStats, + first_boundary: Option, + boundary: CrossLineBoundary, +} + +#[derive(Debug, Clone, Serialize)] +struct WithinLineForensic { + schema: &'static str, + version: u32, + id: String, + file: String, + song: String, + family: &'static str, + test: bool, + track: usize, + voice: u8, + line_start_tick: u32, + ticks_per_quarter: u32, + kind: String, + derived_direction: &'static str, + origin: ProjectionPoint, + target: ProjectionPoint, + span: TechniqueSpanStats, +} + +#[derive(Serialize)] +struct SpanMetrics { + note_distance: Distribution, + intervening_onsets: Distribution, + intervening_note_atoms: Distribution, + delta_ticks: Distribution, + delta_quarters: Distribution, +} + +#[derive(Serialize)] +struct SpanGroup { + dimension: &'static str, + value: String, + metrics: SpanMetrics, +} + +#[derive(Serialize)] +struct SpanCensus { + schema: &'static str, + version: u32, + quantile_method: &'static str, + top_n: usize, + overall: SpanMetrics, + groups: Vec, +} + +fn projection_point(tab: &TabLine, index: usize) -> ProjectionPoint { + let position = tab.original_positions[index]; + ProjectionPoint { + line_index: Some(index), + voice_note_id: tab.note_ids[index], + onset: tab.onsets[index], + duration: tab.durations[index], + pitch: tab.pitches[index].0, + string: position.string, + fret: position.fret, + tapped: tab.tapped[index], + } +} + +fn direction_name(direction: Option) -> &'static str { + match direction { + Some(LegatoDirection::Ascending) => "ascending", + Some(LegatoDirection::Descending) => "descending", + Some(LegatoDirection::Unison) => "unison", + None => "unknown", + } +} + +const fn boundary_cause_name(cause: LineBoundaryCause) -> &'static str { + match cause { + LineBoundaryCause::RestCut => "rest_cut", + LineBoundaryCause::ChordOnset => "chord_onset", + LineBoundaryCause::Unpositioned => "unpositioned", + LineBoundaryCause::BeyondMaxFret => "beyond_max_fret", + LineBoundaryCause::PitchMismatch => "pitch_mismatch", + } +} + +const fn disposition_name(disposition: TargetDisposition) -> &'static str { + match disposition { + TargetDisposition::KeptLine => "kept_line", + TargetDisposition::DroppedShortLine => "dropped_short_line", + TargetDisposition::Excluded => "excluded", + } +} + +fn span_metrics<'a>(spans: impl Iterator) -> SpanMetrics { + let spans: Vec<&TechniqueSpanStats> = spans.collect(); + let summarize = |select: fn(&TechniqueSpanStats) -> u64| { + distribution(&spans.iter().map(|span| select(span)).collect::>()) + .expect("span census group is non-empty") + }; + SpanMetrics { + note_distance: summarize(|span| span.note_distance as u64), + intervening_onsets: summarize(|span| span.intervening_onsets as u64), + intervening_note_atoms: summarize(|span| span.intervening_note_atoms as u64), + delta_ticks: summarize(|span| u64::from(span.delta_ticks)), + delta_quarters: distribution( + &spans + .iter() + .map(|span| span.delta_quarters) + .collect::>(), + ) + .expect("span census group is non-empty"), + } +} + +fn span_groups(records: &[WithinLineForensic]) -> Vec { + let mut grouped: BTreeMap<(&'static str, String), Vec<&TechniqueSpanStats>> = BTreeMap::new(); + for record in records { + for key in [ + ("technique_kind", record.kind.clone()), + ("derived_direction", record.derived_direction.to_owned()), + ("origin_tapped", record.origin.tapped.to_string()), + ("target_tapped", record.target.tapped.to_string()), + ("target_open", record.span.target_open.to_string()), + ] { + grouped.entry(key).or_default().push(&record.span); + } + } + grouped + .into_iter() + .map(|((dimension, value), spans)| SpanGroup { + dimension, + value, + metrics: span_metrics(spans.into_iter()), + }) + .collect() +} + +fn projection_forensics(corpus: &Corpus, out: &Path) -> std::io::Result { + const TOP_N: usize = 50; + let mut cross_line = Vec::new(); + let mut within_line = Vec::new(); + for line in &corpus.lines { + let tab = &line.tab; + let file = corpus.names[line.file].clone(); + for &edge in &tab.edges { + let Some(span) = within_line_span(tab, edge) else { + continue; + }; + within_line.push(WithinLineForensic { + schema: "griff.constraint-lab-legato-within-line", + version: 1, + id: line.id.clone(), + file: file.clone(), + song: song_key(&file), + family: format_family(&file), + test: line.test, + track: tab.track, + voice: tab.voice, + line_start_tick: tab.start_tick, + ticks_per_quarter: tab.ticks_per_quarter, + kind: format!("{:?}", edge.kind), + derived_direction: direction_name(direction_between( + &tab.pitches, + edge.from, + edge.to, + )), + origin: projection_point(tab, edge.from), + target: projection_point(tab, edge.to), + span, + }); + } + for edge in &tab.cross_line_edges { + let target = ProjectionPoint { + line_index: None, + voice_note_id: edge.target.note_id, + onset: edge.target.onset, + duration: edge.target.duration, + pitch: edge.target.pitch.0, + string: edge.target.original_position.string, + fret: edge.target.original_position.fret, + tapped: edge.target.tapped, + }; + cross_line.push(CrossLineForensic { + schema: "griff.constraint-lab-legato-cross-line", + version: 1, + id: line.id.clone(), + file: file.clone(), + song: song_key(&file), + family: format_family(&file), + test: line.test, + track: tab.track, + voice: tab.voice, + line_start_tick: tab.start_tick, + ticks_per_quarter: tab.ticks_per_quarter, + kind: format!("{:?}", edge.kind), + origin: projection_point(tab, edge.from), + target, + span: edge.span, + first_boundary: edge.boundary.boundaries.first().cloned(), + boundary: edge.boundary.clone(), + }); + } + } + cross_line.sort_by(|a, b| { + ( + &a.file, + a.track, + a.voice, + a.origin.onset, + a.origin.voice_note_id, + ) + .cmp(&( + &b.file, + b.track, + b.voice, + b.origin.onset, + b.origin.voice_note_id, + )) + }); + let top = top_n_longest( + within_line.clone(), + TOP_N, + |record| record.span.delta_quarters, + |record| { + ( + record.file.clone(), + record.track, + record.voice, + record.origin.onset, + record.origin.voice_note_id, + ) + }, + ); + let census = SpanCensus { + schema: "griff.constraint-lab-legato-span-census", + version: 1, + quantile_method: "nearest_rank", + top_n: TOP_N, + overall: span_metrics(within_line.iter().map(|record| &record.span)), + groups: span_groups(&within_line), + }; + + let cross_path = out.join("legato-cross-line.jsonl"); + let mut writer = BufWriter::new(fs::File::create(&cross_path)?); + for record in &cross_line { + serde_json::to_writer(&mut writer, record).map_err(std::io::Error::other)?; + writer.write_all(b"\n")?; + } + writer.flush()?; + eprintln!("wrote {}", cross_path.display()); + let longest_path = out.join("legato-longest-within-line.jsonl"); + let mut writer = BufWriter::new(fs::File::create(&longest_path)?); + for record in &top { + serde_json::to_writer(&mut writer, record).map_err(std::io::Error::other)?; + writer.write_all(b"\n")?; + } + writer.flush()?; + eprintln!("wrote {}", longest_path.display()); + write_json(&out.join("legato-span-census.json"), &census)?; + + let mut boundary_causes = BTreeMap::new(); + let mut first_boundary_causes = BTreeMap::new(); + let mut target_exclusion_causes = BTreeMap::new(); + let mut target_dispositions = BTreeMap::new(); + for record in &cross_line { + for boundary in &record.boundary.boundaries { + for &cause in &boundary.causes { + *boundary_causes + .entry(boundary_cause_name(cause)) + .or_default() += 1; + } + } + if let Some(first) = record.boundary.boundaries.first() { + for &cause in &first.causes { + *first_boundary_causes + .entry(boundary_cause_name(cause)) + .or_default() += 1; + } + } + if record.boundary.target_disposition == TargetDisposition::Excluded { + if let Some(target_boundary) = record.boundary.boundaries.iter().find(|boundary| { + boundary.before_note_id <= record.target.voice_note_id + && record.target.voice_note_id < boundary.excluded_note_ids_end + }) { + for &cause in target_boundary + .causes + .iter() + .filter(|cause| **cause != LineBoundaryCause::RestCut) + { + *target_exclusion_causes + .entry(boundary_cause_name(cause)) + .or_default() += 1; + } + } + } + *target_dispositions + .entry(disposition_name(record.boundary.target_disposition)) + .or_default() += 1; + } + let summary = ProjectionForensicSummary { + cross_line: cross_line.len() as u64, + within_line_edges: within_line.len() as u64, + non_adjacent_within_line: within_line + .iter() + .filter(|record| record.span.note_distance > 1) + .count() as u64, + top_longest_written: top.len() as u64, + targets_in_next_kept_line: cross_line + .iter() + .filter(|record| record.boundary.target_in_next_kept_line) + .count() as u64, + boundary_causes, + first_boundary_causes, + target_exclusion_causes, + target_dispositions, + boundaries_crossed: distribution( + &cross_line + .iter() + .map(|record| record.boundary.line_boundaries_crossed as u64) + .collect::>(), + ) + .expect("cross-line census is non-empty"), + cross_line_delta_quarters: distribution( + &cross_line + .iter() + .map(|record| record.span.delta_quarters) + .collect::>(), + ) + .expect("cross-line census is non-empty"), + }; + println!( + "projection forensics: {} cross-line relations; {} within-line edges; {} targets in the next kept line; top {} longest written", + summary.cross_line, + summary.within_line_edges, + summary.targets_in_next_kept_line, + summary.top_longest_written, + ); + Ok(summary) +} + #[allow(clippy::cast_precision_loss)] fn pct(part: u64, whole: u64) -> String { if whole == 0 { @@ -1997,11 +2382,19 @@ fn legato_census(corpus: Corpus, out: &Path) -> std::io::Result<()> { pct(c.into_tap_legato.same_string, c.into_tap_legato.edges) ); } + let projection_forensics = projection_forensics(&corpus, out)?; + if projection_forensics.cross_line != cross_line { + return Err(std::io::Error::other(format!( + "projection forensic manifest contains {} cross-line relations, but cut stats report {cross_line}", + projection_forensics.cross_line + ))); + } let report = CensusReport { schema: "griff.constraint-lab-legato-census", - version: 2, + version: 3, dangling_legato: dangling, cross_line_legato: cross_line, + projection_forensics, rows, corpus: corpus.facts, }; diff --git a/lab/src/fingering.rs b/lab/src/fingering.rs index 0b4b9d0..5c54085 100644 --- a/lab/src/fingering.rs +++ b/lab/src/fingering.rs @@ -23,6 +23,7 @@ use griff_core::score::{AtomEvent, AtomNote, EventGroup, Score}; use serde::{Deserialize, Serialize}; use thiserror::Error; +use crate::forensics::ExactRatio; use crate::ir::{fnv1a64, IntVar, VarId}; use crate::optir::{Hard, OptIrError, OptProblem, Term}; use crate::problems::LabError; @@ -181,6 +182,133 @@ impl TechniqueEdge { } } +/// Imported context for a resolved legato target outside its origin's kept +/// line. The position keeps the source file's string orientation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TechniqueTarget { + /// Stable index in the imported voice, assigned before line slicing. + pub note_id: usize, + /// Absolute onset tick in the imported score. + pub onset: u32, + /// Imported note duration in ticks. + pub duration: u32, + /// Imported pitch. + pub pitch: Pitch, + /// Imported, unoriented string and fret. + pub original_position: FretboardPosition, + /// Whether the target carries `NoteMark::Tap`. + pub tapped: bool, +} + +/// Exact descriptive measurements between a legato origin and its resolved +/// same-string target. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +pub struct TechniqueSpanStats { + /// Target atom id minus origin atom id. + pub note_distance: usize, + /// Distinct voice onsets strictly between origin and target onsets. + pub intervening_onsets: usize, + /// Imported note atoms strictly between the two stable note ids. + pub intervening_note_atoms: usize, + /// Positioned notes on the origin string at strictly intervening onsets. + pub intervening_origin_string_notes: usize, + /// Positioned notes on other strings at strictly intervening onsets. + pub intervening_other_string_notes: usize, + /// Unpositioned notes at strictly intervening onsets. + pub intervening_unpositioned_notes: usize, + /// Target onset minus origin onset, in ticks. + pub delta_ticks: u32, + /// Exact reduced `delta_ticks / ticks_per_quarter`. + pub delta_quarters: ExactRatio, + /// Signed target-minus-origin pitch interval in semitones. + pub pitch_interval_semitones: i16, + /// Absolute fret distance in the imported positions. + pub fret_distance: u8, + /// Whether the imported target is an open string. + pub target_open: bool, +} + +/// A real reason the slicing control flow ended or separated a fragment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LineBoundaryCause { + /// Silence reached the configured rest threshold. + RestCut, + /// More than one note atom shared the onset. + ChordOnset, + /// The single note had no imported position. + Unpositioned, + /// The imported fret exceeded the configured maximum. + BeyondMaxFret, + /// The imported position did not sound the imported pitch. + PitchMismatch, +} + +/// One boundary location; more than one real cause may apply at the same +/// onset (for example, a long rest followed by a chord). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +pub struct LineBoundary { + /// First stable voice-note id at or after the boundary. + pub before_note_id: usize, + /// End of the atom-id range excluded at this onset. Equal to + /// `before_note_id` for a pure rest cut, which excludes no note atom. + pub excluded_note_ids_end: usize, + /// Absolute onset at the boundary. + pub onset: u32, + /// Causes in slicing-control-flow order. + pub causes: Vec, +} + +/// Where the resolved target went under the unchanged line cut. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TargetDisposition { + /// The target belongs to a retained line. + KeptLine, + /// The target belonged to a valid fragment dropped for being too short. + DroppedShortLine, + /// The target onset itself was excluded by a typed boundary cause. + Excluded, +} + +/// Diagnostic boundary context for a cross-line relation. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +pub struct CrossLineBoundary { + /// Ordered unique boundary locations crossed by the relation. + pub boundaries: Vec, + /// `boundaries.len()`, stored explicitly in the artifact contract. + pub line_boundaries_crossed: usize, + /// Retained fragments strictly between origin and target fragments. + pub intervening_kept_fragments: usize, + /// Dropped short fragments strictly between origin and target, including a + /// dropped target fragment when applicable. + pub intervening_dropped_fragments: usize, + /// Target disposition under the unchanged cut. + pub target_disposition: TargetDisposition, + /// Start tick of the target's retained line, when it has one. + pub target_line_start_tick: Option, + /// Whether the target is in the next retained line of the same voice. + pub target_in_next_kept_line: bool, +} + +/// A resolved legato relation whose target does not survive in the same kept +/// line as its origin. It is forensic context, not an objective edge. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct CrossLineTechniqueEdge { + /// Origin index in the kept line. + pub from: usize, + /// Stable origin index in the imported voice. + pub origin_note_id: usize, + /// Resolved target in the imported voice. + pub target: TechniqueTarget, + /// Imported technique kind on the origin note. + pub kind: TechniqueKind, + /// Exact temporal/note context across the imported voice. + pub span: TechniqueSpanStats, + /// Why the target does not belong to the origin line. + pub boundary: CrossLineBoundary, +} + /// One monophonic tablature line with the tab author's positions. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TabLine { @@ -188,12 +316,22 @@ pub struct TabLine { pub track: usize, /// Voice id within the track. pub voice: u8, + /// Imported score resolution, used to normalize forensic onset gaps. + pub ticks_per_quarter: u32, /// Onset tick of the first note. pub start_tick: u32, /// The track tuning. pub tuning: Tuning, /// Pitches, in onset order. pub pitches: Vec, + /// Stable imported-voice note ids, one per pitch. + pub note_ids: Vec, + /// Absolute onset ticks, one per pitch. + pub onsets: Vec, + /// Imported duration ticks, one per pitch. + pub durations: Vec, + /// Imported positions before any string-orientation normalization. + pub original_positions: 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 @@ -209,6 +347,52 @@ pub struct TabLine { /// this line. Absence means a plain transition; edges may skip intervening /// notes on other strings. pub edges: Vec, + /// Resolved relations whose target lies outside this kept line. These are + /// retained only for projection auditing and never enter an objective. + pub cross_line_edges: Vec, +} + +/// Computes exact diagnostic span measurements for a retained relation. +/// Returns `None` when the edge or parallel line metadata is invalid. +#[must_use] +pub fn within_line_span(tab: &TabLine, edge: TechniqueEdge) -> Option { + if edge.from >= edge.to || edge.to >= tab.pitches.len() { + return None; + } + let origin_onset = *tab.onsets.get(edge.from)?; + let target_onset = *tab.onsets.get(edge.to)?; + let origin_id = *tab.note_ids.get(edge.from)?; + let target_id = *tab.note_ids.get(edge.to)?; + let origin = *tab.original_positions.get(edge.from)?; + let target = *tab.original_positions.get(edge.to)?; + let between = edge.from + 1..edge.to; + let intervening_onsets = distinct_count(&tab.onsets[between.clone()]); + let mut origin_string = 0; + let mut other_strings = 0; + for position in &tab.original_positions[between] { + if position.string == origin.string { + origin_string += 1; + } else { + other_strings += 1; + } + } + Some(TechniqueSpanStats { + note_distance: target_id.checked_sub(origin_id)?, + intervening_onsets, + intervening_note_atoms: target_id.checked_sub(origin_id)?.saturating_sub(1), + intervening_origin_string_notes: origin_string, + intervening_other_string_notes: other_strings, + intervening_unpositioned_notes: 0, + delta_ticks: target_onset.checked_sub(origin_onset)?, + delta_quarters: ExactRatio::new( + u64::from(target_onset.checked_sub(origin_onset)?), + u64::from(tab.ticks_per_quarter), + ), + pitch_interval_semitones: i16::from(tab.pitches[edge.to].0) + - i16::from(tab.pitches[edge.from].0), + fret_distance: origin.fret.abs_diff(target.fret), + target_open: target.fret == 0, + }) } /// Cuts one track into monophonic tablature lines, per voice. @@ -279,7 +463,7 @@ pub fn tab_lines( // first strictly later note in this imported voice on the same // original string. Notes at the same onset never target each other. let mut targets = vec![None; notes.len()]; - let mut next_on_string = [None; 256]; + let mut next_on_string: [Option; 256] = [None; 256]; let mut end = notes.len(); while end > 0 { let onset = notes[end - 1].0.absolute_start.0; @@ -292,7 +476,31 @@ pub fn tab_lines( targets[i] = notes[i] .0 .position - .and_then(|p| next_on_string[usize::from(p.position.string)]); + .and_then(|p| next_on_string[usize::from(p.position.string)]) + .and_then(|note_id| { + let target = notes[note_id].0; + target.position.and_then(|position| { + imported_span( + ¬es, + i, + note_id, + u32::from(score.ticks_per_quarter), + ) + .map(|span| { + ResolvedTechniqueTarget { + target: TechniqueTarget { + note_id, + onset: target.absolute_start.0, + duration: target.duration.0, + pitch: target.pitch, + original_position: position.position, + tapped: target.marks.contains(NoteMark::Tap), + }, + span, + } + }) + }) + }); } } for (i, (note, _)) in notes[start..end].iter().enumerate() { @@ -303,8 +511,16 @@ pub fn tab_lines( end = start; } - let mut line = LineBuilder::new(track_index, voice.id, &tuning); + let mut line = LineBuilder::new( + track_index, + voice.id, + u32::from(score.ticks_per_quarter), + &tuning, + ); let mut sounding_until: Option = None; + let voice_line_start = lines.len(); + let mut fragments = Vec::new(); + let mut boundaries = Vec::new(); // Lowest fretted position at the latest onset seen so far. let mut last_fretted: Option = None; let mut note_index = 0; @@ -342,33 +558,72 @@ pub fn tab_lines( sounding_until = Some(sounding_until.map_or(group_end, |end| end.max(group_end))); if rest_cut && !line.is_empty() { stats.rest_cuts = stats.rest_cuts.saturating_add(1); - line.flush(cut, &mut lines, &mut stats); + record_boundary( + &mut boundaries, + group_start, + group_start, + onset, + LineBoundaryCause::RestCut, + ); + line.flush(cut, &mut lines, &mut stats, &mut fragments); } let [(note, legato_out)] = group else { stats.chord_onsets = stats.chord_onsets.saturating_add(1); - line.flush(cut, &mut lines, &mut stats); + record_boundary( + &mut boundaries, + group_start, + group_start.saturating_add(width), + onset, + LineBoundaryCause::ChordOnset, + ); + line.flush(cut, &mut lines, &mut stats, &mut fragments); continue; }; let Some(position) = note.position.map(|p| p.position) else { stats.unpositioned = stats.unpositioned.saturating_add(1); - line.flush(cut, &mut lines, &mut stats); + record_boundary( + &mut boundaries, + group_start, + group_start.saturating_add(width), + onset, + LineBoundaryCause::Unpositioned, + ); + line.flush(cut, &mut lines, &mut stats, &mut fragments); continue; }; if position.fret > cut.max_fret { stats.beyond_max_fret = stats.beyond_max_fret.saturating_add(1); - line.flush(cut, &mut lines, &mut stats); + record_boundary( + &mut boundaries, + group_start, + group_start.saturating_add(width), + onset, + LineBoundaryCause::BeyondMaxFret, + ); + line.flush(cut, &mut lines, &mut stats, &mut fragments); continue; } if track.tuning.pitch_at(position) != Some(note.pitch) { stats.pitch_mismatch = stats.pitch_mismatch.saturating_add(1); - line.flush(cut, &mut lines, &mut stats); + record_boundary( + &mut boundaries, + group_start, + group_start.saturating_add(width), + onset, + LineBoundaryCause::PitchMismatch, + ); + line.flush(cut, &mut lines, &mut stats, &mut fragments); continue; } line.push( - onset, - note.pitch, - orient(position), + LineNote { + onset, + duration: note.duration.0, + pitch: note.pitch, + original_position: position, + position: orient(position), + }, NoteContext { note_id: group_start, anchor: anchor_here, @@ -378,7 +633,14 @@ pub fn tab_lines( }, ); } - line.flush(cut, &mut lines, &mut stats); + line.flush(cut, &mut lines, &mut stats, &mut fragments); + finalize_cross_line_boundaries( + &mut lines, + voice_line_start, + notes.len(), + &fragments, + &boundaries, + ); } Ok((lines, stats)) } @@ -1128,24 +1390,49 @@ struct NoteContext { /// The imported legato kind this note starts, if any. legato_out: Option, /// Stable imported-voice index of its same-string target, if resolved. - legato_target: Option, + legato_target: Option, +} + +#[derive(Clone, Copy)] +struct LineNote { + onset: u32, + duration: u32, + pitch: Pitch, + original_position: FretboardPosition, + position: FretboardPosition, +} + +#[derive(Clone, Copy)] +struct ResolvedTechniqueTarget { + target: TechniqueTarget, + span: TechniqueSpanStats, } #[derive(Clone, Copy)] struct PendingTechnique { from: usize, - target: Option, + target: Option, kind: TechniqueKind, } +struct LineFragment { + note_ids: Vec, + start_tick: u32, + kept_line_index: Option, +} + /// Accumulates one tablature line while a voice is scanned. struct LineBuilder<'a> { track: usize, voice: u8, + ticks_per_quarter: u32, tuning: &'a Tuning, start_tick: u32, anchor: Option, tapped: Vec, + onsets: Vec, + durations: Vec, + original_positions: Vec, note_ids: Vec, origins: Vec, pitches: Vec, @@ -1153,14 +1440,18 @@ struct LineBuilder<'a> { } impl<'a> LineBuilder<'a> { - const fn new(track: usize, voice: u8, tuning: &'a Tuning) -> Self { + const fn new(track: usize, voice: u8, ticks_per_quarter: u32, tuning: &'a Tuning) -> Self { Self { track, voice, + ticks_per_quarter, tuning, start_tick: 0, anchor: None, tapped: Vec::new(), + onsets: Vec::new(), + durations: Vec::new(), + original_positions: Vec::new(), note_ids: Vec::new(), origins: Vec::new(), pitches: Vec::new(), @@ -1172,19 +1463,16 @@ impl<'a> LineBuilder<'a> { self.pitches.is_empty() } - fn push( - &mut self, - onset: u32, - pitch: Pitch, - position: FretboardPosition, - context: NoteContext, - ) { + fn push(&mut self, note: LineNote, context: NoteContext) { if self.pitches.is_empty() { - self.start_tick = onset; + self.start_tick = note.onset; self.anchor = context.anchor; } - self.pitches.push(pitch); - self.human.push(position); + self.pitches.push(note.pitch); + self.onsets.push(note.onset); + self.durations.push(note.duration); + self.original_positions.push(note.original_position); + self.human.push(note.position); self.tapped.push(context.tapped); self.note_ids.push(context.note_id); if let Some(kind) = context.legato_out { @@ -1198,7 +1486,13 @@ impl<'a> LineBuilder<'a> { /// Ends the current line: kept when long enough, otherwise counted as /// dropped. An empty line is a no-op. - fn flush(&mut self, cut: &LineCut, lines: &mut Vec, stats: &mut CutStats) { + fn flush( + &mut self, + cut: &LineCut, + lines: &mut Vec, + stats: &mut CutStats, + fragments: &mut Vec, + ) { let len = self.pitches.len(); if len == 0 { return; @@ -1206,27 +1500,58 @@ 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); + let onsets = std::mem::take(&mut self.onsets); + let durations = std::mem::take(&mut self.durations); + let original_positions = std::mem::take(&mut self.original_positions); let note_ids = std::mem::take(&mut self.note_ids); let origins = std::mem::take(&mut self.origins); 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)); + fragments.push(LineFragment { + note_ids, + start_tick: self.start_tick, + kept_line_index: None, + }); return; } stats.kept_lines = stats.kept_lines.saturating_add(1); stats.kept_notes = stats.kept_notes.saturating_add(count(len)); + let kept_line_index = lines.len(); + fragments.push(LineFragment { + note_ids: note_ids.clone(), + start_tick: self.start_tick, + kept_line_index: Some(kept_line_index), + }); let mut edges = Vec::new(); + let mut cross_line_edges = Vec::new(); for origin in origins { let Some(target) = origin.target else { stats.dangling_legato = stats.dangling_legato.saturating_add(1); continue; }; let from = note_ids.iter().position(|&id| id == origin.from); - let to = note_ids.iter().position(|&id| id == target); + let to = note_ids.iter().position(|&id| id == target.target.note_id); match (from, to) { (Some(from), Some(to)) => edges.push(TechniqueEdge::new(from, to, origin.kind)), - (Some(_), None) => { + (Some(from), None) => { stats.cross_line_legato = stats.cross_line_legato.saturating_add(1); + cross_line_edges.push(CrossLineTechniqueEdge { + from, + origin_note_id: origin.from, + target: target.target, + kind: origin.kind, + span: target.span, + boundary: CrossLineBoundary { + boundaries: Vec::new(), + line_boundaries_crossed: 0, + intervening_kept_fragments: 0, + intervening_dropped_fragments: 0, + target_disposition: TargetDisposition::Excluded, + target_line_start_tick: None, + target_in_next_kept_line: false, + }, + }); } _ => {} } @@ -1234,17 +1559,193 @@ impl<'a> LineBuilder<'a> { lines.push(TabLine { track: self.track, voice: self.voice, + ticks_per_quarter: self.ticks_per_quarter, start_tick: self.start_tick, tuning: self.tuning.clone(), pitches, + note_ids, + onsets, + durations, + original_positions, human, anchor_fret: self.anchor, tapped, edges, + cross_line_edges, }); } } +fn imported_span( + notes: &[(&AtomNote, Option)], + origin_id: usize, + target_id: usize, + ticks_per_quarter: u32, +) -> Option { + let origin = notes.get(origin_id)?.0; + let target = notes.get(target_id)?.0; + let origin_position = origin.position?.position; + let target_position = target.position?.position; + let mut last_onset = None; + let mut intervening_onsets = 0; + let mut origin_string = 0; + let mut other_strings = 0; + let mut unpositioned = 0; + for (note, _) in notes.get(origin_id + 1..target_id)? { + let onset = note.absolute_start.0; + if onset <= origin.absolute_start.0 || onset >= target.absolute_start.0 { + continue; + } + if last_onset != Some(onset) { + intervening_onsets += 1; + last_onset = Some(onset); + } + match note.position.map(|position| position.position) { + Some(position) if position.string == origin_position.string => origin_string += 1, + Some(_) => other_strings += 1, + None => unpositioned += 1, + } + } + let delta_ticks = target + .absolute_start + .0 + .checked_sub(origin.absolute_start.0)?; + Some(TechniqueSpanStats { + note_distance: target_id.checked_sub(origin_id)?, + intervening_onsets, + intervening_note_atoms: target_id.checked_sub(origin_id)?.saturating_sub(1), + intervening_origin_string_notes: origin_string, + intervening_other_string_notes: other_strings, + intervening_unpositioned_notes: unpositioned, + delta_ticks, + delta_quarters: ExactRatio::new(u64::from(delta_ticks), u64::from(ticks_per_quarter)), + pitch_interval_semitones: i16::from(target.pitch.0) - i16::from(origin.pitch.0), + fret_distance: origin_position.fret.abs_diff(target_position.fret), + target_open: target_position.fret == 0, + }) +} + +fn record_boundary( + boundaries: &mut Vec, + before_note_id: usize, + excluded_note_ids_end: usize, + onset: u32, + cause: LineBoundaryCause, +) { + if let Some(boundary) = boundaries + .last_mut() + .filter(|boundary| boundary.before_note_id == before_note_id) + { + boundary.excluded_note_ids_end = boundary.excluded_note_ids_end.max(excluded_note_ids_end); + if !boundary.causes.contains(&cause) { + boundary.causes.push(cause); + } + } else { + boundaries.push(LineBoundary { + before_note_id, + excluded_note_ids_end, + onset, + causes: vec![cause], + }); + } +} + +fn finalize_cross_line_boundaries( + lines: &mut [TabLine], + voice_line_start: usize, + note_count: usize, + fragments: &[LineFragment], + boundaries: &[LineBoundary], +) { + let mut note_fragment = vec![None; note_count]; + for (fragment_index, fragment) in fragments.iter().enumerate() { + for ¬e_id in &fragment.note_ids { + if let Some(slot) = note_fragment.get_mut(note_id) { + *slot = Some(fragment_index); + } + } + } + for line in lines.iter_mut().skip(voice_line_start) { + for edge in &mut line.cross_line_edges { + let Some(origin_fragment) = note_fragment.get(edge.origin_note_id).copied().flatten() + else { + continue; + }; + let target_fragment = note_fragment.get(edge.target.note_id).copied().flatten(); + let target_disposition = target_fragment.map_or(TargetDisposition::Excluded, |index| { + if fragments[index].kept_line_index.is_some() { + TargetDisposition::KeptLine + } else { + TargetDisposition::DroppedShortLine + } + }); + let target_line_start_tick = target_fragment.and_then(|index| { + fragments[index] + .kept_line_index + .map(|_| fragments[index].start_tick) + }); + let next_kept_fragment = fragments + .iter() + .enumerate() + .skip(origin_fragment + 1) + .find_map(|(index, fragment)| fragment.kept_line_index.map(|_| index)); + let target_in_next_kept_line = target_fragment.is_some_and(|target_index| { + fragments[target_index].kept_line_index.is_some() + && next_kept_fragment == Some(target_index) + }); + + let mut intervening_kept = 0; + let mut intervening_dropped = 0; + for (index, fragment) in fragments.iter().enumerate().skip(origin_fragment + 1) { + let before_target = fragment + .note_ids + .first() + .is_some_and(|first| *first < edge.target.note_id); + let is_target = target_fragment == Some(index); + if !before_target && !is_target { + break; + } + if is_target { + if fragment.kept_line_index.is_none() { + intervening_dropped += 1; + } + break; + } + if fragment.kept_line_index.is_some() { + intervening_kept += 1; + } else { + intervening_dropped += 1; + } + } + let crossed: Vec = boundaries + .iter() + .filter(|boundary| { + edge.origin_note_id < boundary.before_note_id + && boundary.before_note_id <= edge.target.note_id + }) + .cloned() + .collect(); + edge.boundary = CrossLineBoundary { + line_boundaries_crossed: crossed.len(), + boundaries: crossed, + intervening_kept_fragments: intervening_kept, + intervening_dropped_fragments: intervening_dropped, + target_disposition, + target_line_start_tick, + target_in_next_kept_line, + }; + } + } +} + +fn distinct_count(values: &[T]) -> usize { + values + .windows(2) + .filter(|pair| pair[0] != pair[1]) + .count() + .saturating_add(usize::from(!values.is_empty())) +} + fn count(n: usize) -> u64 { u64::try_from(n).unwrap_or(u64::MAX) } diff --git a/lab/src/forensics.rs b/lab/src/forensics.rs new file mode 100644 index 0000000..f725110 --- /dev/null +++ b/lab/src/forensics.rs @@ -0,0 +1,122 @@ +//! Small deterministic helpers shared by diagnostic-only corpus audits. + +use std::cmp::Ordering; + +use serde::Serialize; + +/// A reduced, non-negative exact ratio. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +pub struct ExactRatio { + /// Reduced numerator. + pub numerator: u64, + /// Reduced, non-zero denominator. + pub denominator: u64, +} + +impl ExactRatio { + /// Reduces `numerator / denominator`; a zero denominator is represented as + /// `numerator / 1` so malformed diagnostic metadata stays sortable. + #[must_use] + pub fn new(numerator: u64, denominator: u64) -> Self { + let denominator = denominator.max(1); + let divisor = gcd(numerator, denominator); + Self { + numerator: numerator / divisor, + denominator: denominator / divisor, + } + } +} + +impl Ord for ExactRatio { + fn cmp(&self, other: &Self) -> Ordering { + (u128::from(self.numerator) * u128::from(other.denominator)) + .cmp(&(u128::from(other.numerator) * u128::from(self.denominator))) + .then_with(|| self.numerator.cmp(&other.numerator)) + .then_with(|| self.denominator.cmp(&other.denominator)) + } +} + +impl PartialOrd for ExactRatio { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Integer/exact-ratio distribution using the nearest-rank definition for +/// median and percentiles (`ceil(p · n)`, one-based). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Distribution { + /// Number of observations. + pub count: usize, + /// Smallest observation. + pub min: T, + /// Nearest-rank p50. + pub median: T, + /// Nearest-rank p90. + pub p90: T, + /// Nearest-rank p95. + pub p95: T, + /// Nearest-rank p99. + pub p99: T, + /// Largest observation. + pub max: T, +} + +/// Summarizes a non-empty collection without floating-point quantiles. +#[must_use] +pub fn distribution(values: &[T]) -> Option> { + let mut sorted = values.to_vec(); + sorted.sort_unstable(); + Some(Distribution { + count: sorted.len(), + min: sorted.first()?.clone(), + median: nearest_rank(&sorted, 50)?.clone(), + p90: nearest_rank(&sorted, 90)?.clone(), + p95: nearest_rank(&sorted, 95)?.clone(), + p99: nearest_rank(&sorted, 99)?.clone(), + max: sorted.last()?.clone(), + }) +} + +/// Returns at most `n` items ordered by descending measure, then ascending +/// stable identity. +#[must_use] +pub fn top_n_longest( + mut values: Vec, + n: usize, + measure: impl Fn(&T) -> M, + identity: impl Fn(&T) -> K, +) -> Vec +where + M: Ord, + K: Ord, +{ + values.sort_by(|a, b| { + measure(b) + .cmp(&measure(a)) + .then_with(|| identity(a).cmp(&identity(b))) + }); + values.truncate(n); + values +} + +fn nearest_rank(sorted: &[T], percentile: usize) -> Option<&T> { + if sorted.is_empty() || !(1..=100).contains(&percentile) { + return None; + } + let rank = percentile.saturating_mul(sorted.len()).div_ceil(100); + sorted.get(rank.saturating_sub(1)) +} + +fn gcd(mut a: u64, mut b: u64) -> u64 { + while b != 0 { + let remainder = a % b; + a = b; + b = remainder; + } + if a == 0 { + 1 + } else { + a + } +} diff --git a/lab/src/lib.rs b/lab/src/lib.rs index 7981fe7..c229f75 100644 --- a/lab/src/lib.rs +++ b/lab/src/lib.rs @@ -19,6 +19,7 @@ pub mod emit; pub mod fingering; +pub mod forensics; pub mod ir; pub mod manifest; pub mod optir; diff --git a/lab/tests/fingering.rs b/lab/tests/fingering.rs index d98857f..d1468ec 100644 --- a/lab/tests/fingering.rs +++ b/lab/tests/fingering.rs @@ -23,9 +23,9 @@ use griff_constraint_lab::{ fingering::{ best_hands, decode_positions, encode_hand_witness, encode_v1_witness, hand_cost, hand_problem, holdout_bucket, repeat_pairs, solve_hand, song_key, tab_lines, v1_cost, - v1_problem, with_repeat_consistency, with_string_tiebreak, CutStats, HandError, HandModel, - HandModelError, HandWeights, LineCut, Reach, TechniqueEdge, TechniqueKind, - HAND_VARS_PER_NOTE, V1_VARS_PER_NOTE, + v1_problem, with_repeat_consistency, with_string_tiebreak, within_line_span, CutStats, + HandError, HandModel, HandModelError, HandWeights, LineBoundaryCause, LineCut, Reach, + TargetDisposition, TechniqueEdge, TechniqueKind, HAND_VARS_PER_NOTE, V1_VARS_PER_NOTE, }, optir::{OptIrError, Term, WitnessError}, problems::LabError, @@ -54,9 +54,13 @@ fn pos(string: u8, fret: u8) -> FretboardPosition { } fn note(onset: u32, p: u8, position: Option<(u8, u8)>) -> AtomEvent { + note_for(onset, Q, p, position) +} + +fn note_for(onset: u32, duration: u32, p: u8, position: Option<(u8, u8)>) -> AtomEvent { AtomEvent::Note(AtomNote { absolute_start: Ticks(onset), - duration: Ticks(Q), + duration: Ticks(duration), pitch: pitch(p), velocity: Velocity::new(90).expect("velocity"), marks: NoteMarks::empty(), @@ -405,6 +409,8 @@ fn tab_lines_project_legato_past_intervening_other_strings() { ); assert_eq!(stats.dangling_legato, 0); assert_eq!(stats.cross_line_legato, 0); + assert_eq!(lines[0].onsets, vec![0, Q, 2 * Q, 3 * Q, 4 * Q]); + assert!(lines[0].cross_line_edges.is_empty()); } #[test] @@ -424,12 +430,118 @@ fn a_same_string_target_beyond_the_line_is_counted_not_retargeted() { assert!(lines.iter().all(|line| line.edges.is_empty())); assert_eq!(stats.dangling_legato, 0); assert_eq!(stats.cross_line_legato, 1); + let external = &lines[0].cross_line_edges; + assert_eq!(external.len(), 1); + assert_eq!(external[0].from, 3); + assert_eq!(external[0].origin_note_id, 3); + assert_eq!(external[0].kind, TechniqueKind::HammerOn); + assert_eq!(external[0].target.note_id, 4); + assert_eq!(external[0].target.onset, 10 * Q); + assert_eq!(external[0].target.duration, Q); + assert_eq!(external[0].target.pitch, Pitch(59)); + assert_eq!(external[0].target.original_position, pos(4, 9)); + assert!(!external[0].target.tapped); + assert_eq!(external[0].span.note_distance, 1); + assert_eq!(external[0].span.intervening_onsets, 0); + assert_eq!(external[0].span.intervening_note_atoms, 0); + assert_eq!(external[0].span.delta_ticks, 7 * Q); + assert_eq!(external[0].span.delta_quarters.numerator, 7); + assert_eq!(external[0].span.delta_quarters.denominator, 1); + assert_eq!(external[0].boundary.line_boundaries_crossed, 1); + assert_eq!(external[0].boundary.boundaries.len(), 1); + assert_eq!( + external[0].boundary.boundaries[0].causes, + vec![LineBoundaryCause::RestCut] + ); + assert_eq!(external[0].boundary.boundaries[0].before_note_id, 4); + assert_eq!( + external[0].boundary.target_disposition, + TargetDisposition::KeptLine + ); + assert_eq!(external[0].boundary.target_line_start_tick, Some(10 * Q)); + assert!(external[0].boundary.target_in_next_kept_line); + assert_eq!(external[0].boundary.intervening_dropped_fragments, 0); let mut total = CutStats::default(); total.absorb(&stats); total.absorb(&stats); assert_eq!(total.cross_line_legato, 2); } +#[test] +fn a_same_string_target_at_a_chord_boundary_is_classified_as_excluded() { + let s = score(vec![vec![ + single(0, 55, Some((4, 5))), + single(Q, 60, Some((3, 5))), + single(2 * Q, 64, Some((2, 5))), + with_span(single(3 * Q, 57, Some((4, 7))), SpanTechnique::HammerOn), + group(vec![ + note(4 * Q, 59, Some((4, 9))), + note(4 * Q, 67, Some((2, 8))), + ]), + single(5 * Q, 60, Some((3, 5))), + single(6 * Q, 62, Some((3, 7))), + single(7 * Q, 64, Some((3, 9))), + single(8 * Q, 65, Some((3, 10))), + ]]); + let (lines, stats) = tab_lines(&s, 0, &LineCut::v1()).unwrap(); + assert_eq!(lines.len(), 2); + assert_eq!(stats.cross_line_legato, 1); + let edge = &lines[0].cross_line_edges[0]; + assert_eq!(edge.target.note_id, 4); + assert_eq!(edge.boundary.line_boundaries_crossed, 1); + assert_eq!( + edge.boundary.boundaries[0].causes, + vec![LineBoundaryCause::ChordOnset] + ); + assert_eq!(edge.boundary.boundaries[0].before_note_id, 4); + assert_eq!(edge.boundary.boundaries[0].excluded_note_ids_end, 6); + assert_eq!( + edge.boundary.target_disposition, + TargetDisposition::Excluded + ); + assert_eq!(edge.boundary.target_line_start_tick, None); + assert!(!edge.boundary.target_in_next_kept_line); +} + +#[test] +fn a_long_sparse_within_line_edge_has_exact_span_counts() { + let s = score(vec![vec![ + with_span(single(0, 55, Some((4, 5))), SpanTechnique::HammerOn), + single(2 * Q, 60, Some((3, 5))), + single(4 * Q, 64, Some((2, 5))), + single(6 * Q, 69, Some((1, 5))), + single(8 * Q, 62, Some((3, 7))), + single(10 * Q, 66, Some((2, 7))), + single(12 * Q, 57, Some((4, 7))), + ]]); + let cut = LineCut { + min_notes: 4, + max_rest_quarters: 0, + max_fret: STANDARD_MAX_FRET, + }; + let (lines, stats) = tab_lines(&s, 0, &cut).unwrap(); + assert_eq!(stats.cross_line_legato, 0); + let edge = lines[0].edges[0]; + assert_eq!(edge, TechniqueEdge::new(0, 6, TechniqueKind::HammerOn)); + let span = within_line_span(&lines[0], edge).expect("valid retained edge"); + assert_eq!(span.note_distance, 6); + assert_eq!(span.intervening_onsets, 5); + assert_eq!(span.intervening_note_atoms, 5); + assert_eq!(span.intervening_origin_string_notes, 0); + assert_eq!(span.intervening_other_string_notes, 5); + assert_eq!(span.delta_ticks, 12 * Q); + assert_eq!( + ( + span.delta_quarters.numerator, + span.delta_quarters.denominator + ), + (12, 1) + ); + assert_eq!(span.pitch_interval_semitones, 2); + assert_eq!(span.fret_distance, 2); + assert!(!span.target_open); +} + #[test] fn a_legato_origin_without_a_later_same_string_note_is_unresolved() { let s = score(vec![vec![ @@ -441,6 +553,7 @@ fn a_legato_origin_without_a_later_same_string_note_is_unresolved() { let (lines, stats) = tab_lines(&s, 0, &LineCut::v1()).unwrap(); assert_eq!(lines.len(), 1); assert!(lines[0].edges.is_empty()); + assert!(lines[0].cross_line_edges.is_empty()); assert_eq!(stats.dangling_legato, 1); assert_eq!(stats.cross_line_legato, 0); } diff --git a/lab/tests/forensics.rs b/lab/tests/forensics.rs new file mode 100644 index 0000000..7897bcb --- /dev/null +++ b/lab/tests/forensics.rs @@ -0,0 +1,73 @@ +//! Contract tests for deterministic diagnostic-only corpus summaries. + +#![allow(clippy::unwrap_used, clippy::missing_assert_message)] + +use griff_constraint_lab::forensics::{distribution, top_n_longest, ExactRatio}; + +#[test] +fn nearest_rank_distribution_is_hand_computable_and_exact() { + let summary = distribution(&[10_u64, 1, 9, 2, 8, 3, 7, 4, 6, 5]).unwrap(); + assert_eq!(summary.count, 10); + assert_eq!(summary.min, 1); + assert_eq!(summary.median, 5); + assert_eq!(summary.p90, 9); + assert_eq!(summary.p95, 10); + assert_eq!(summary.p99, 10); + assert_eq!(summary.max, 10); + + let quarters = distribution(&[ + ExactRatio::new(3, 2), + ExactRatio::new(1, 4), + ExactRatio::new(1, 1), + ]) + .unwrap(); + assert_eq!(quarters.min, ExactRatio::new(1, 4)); + assert_eq!(quarters.median, ExactRatio::new(1, 1)); + assert_eq!(quarters.max, ExactRatio::new(3, 2)); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Case { + gap: u32, + identity: (&'static str, usize, u8, u32, usize), +} + +#[test] +fn longest_records_use_identity_order_to_break_ties() { + let cases = vec![ + Case { + gap: 7, + identity: ("b", 0, 0, 1, 1), + }, + Case { + gap: 9, + identity: ("z", 0, 0, 1, 1), + }, + Case { + gap: 7, + identity: ("a", 2, 0, 3, 4), + }, + Case { + gap: 7, + identity: ("a", 1, 0, 3, 4), + }, + ]; + let top = top_n_longest(cases, 3, |case| case.gap, |case| case.identity); + assert_eq!( + top, + vec![ + Case { + gap: 9, + identity: ("z", 0, 0, 1, 1) + }, + Case { + gap: 7, + identity: ("a", 1, 0, 3, 4) + }, + Case { + gap: 7, + identity: ("a", 2, 0, 3, 4) + }, + ] + ); +}