Skip to main content

acorde_analysis/
lib.rs

1//! Deterministic, explainable music analysis over [`acorde_core::Score`].
2
3use acorde_core::{
4    ChangeHint, ChordSymbol, KeySignature, NoteAddr, Pitch, Score, Step, detect_chord,
5    roman_numeral, roman_numeral_figured, transpose_fifths,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, HashMap, VecDeque};
9use thiserror::Error;
10
11/// Version of the serialized analysis result contract.
12pub const ANALYSIS_SCHEMA_VERSION: u32 = 14;
13
14/// A chord label with source evidence and the rule that produced it.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct ChordLabel {
17    pub address: NoteAddr,
18    pub chord: ChordSymbol,
19    /// Canonical human-readable spelling derived from `chord`.
20    #[serde(default)]
21    pub name: String,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub roman_numeral: Option<String>,
24    pub confidence: u8,
25    pub rule_id: String,
26    pub evidence: Vec<NoteAddr>,
27}
28
29/// Deterministic output of the chord-analysis pass.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct AnalysisResult {
32    pub schema_version: u32,
33    /// Deterministic fingerprint of the canonical input score content.
34    #[serde(default)]
35    pub score_fingerprint: String,
36    pub chords: Vec<ChordLabel>,
37    pub intervals: Vec<IntervalObservation>,
38    #[serde(default)]
39    pub key_estimates: Vec<KeyEstimate>,
40    #[serde(default)]
41    pub cadence_candidates: Vec<CadenceCandidate>,
42    #[serde(default)]
43    pub voice_leading: Vec<VoiceLeadingObservation>,
44    #[serde(default)]
45    pub satb_diagnostics: Vec<SatbDiagnostic>,
46    #[serde(default)]
47    pub motifs: Vec<MotifPattern>,
48    #[serde(default)]
49    pub phrase_boundaries: Vec<PhraseBoundary>,
50}
51
52/// One complete-analysis category that can change between two results.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54pub enum AnalysisCategory {
55    Chords,
56    Intervals,
57    KeyEstimates,
58    CadenceCandidates,
59    VoiceLeading,
60    SatbDiagnostics,
61    Motifs,
62    PhraseBoundaries,
63}
64
65/// A changed part/staff measure interval used by incremental editor planning.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct AnalysisRegion {
68    pub part: usize,
69    pub staff: usize,
70    pub start_measure: usize,
71    pub end_measure: usize,
72}
73
74/// Dependency-aware refresh plan for a score edit.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct AnalysisRefreshPlan {
77    pub local_categories: Vec<AnalysisCategory>,
78    pub global_categories: Vec<AnalysisCategory>,
79    pub region: Option<AnalysisRegion>,
80    pub context_before: usize,
81    pub context_after: usize,
82}
83
84/// Build a conservative refresh plan from an engine change hint.
85pub fn analysis_refresh_plan(hint: &ChangeHint) -> AnalysisRefreshPlan {
86    let all = all_analysis_categories();
87    if !hint.layout_dirty && !hint.playback_dirty {
88        return AnalysisRefreshPlan {
89            local_categories: Vec::new(),
90            global_categories: Vec::new(),
91            region: None,
92            context_before: 0,
93            context_after: 0,
94        };
95    }
96    match &hint.scope {
97        acorde_core::ChangeScope::Measures {
98            part,
99            staff,
100            start,
101            end,
102        } => AnalysisRefreshPlan {
103            local_categories: vec![
104                AnalysisCategory::Chords,
105                AnalysisCategory::Intervals,
106                AnalysisCategory::CadenceCandidates,
107                AnalysisCategory::VoiceLeading,
108                AnalysisCategory::SatbDiagnostics,
109                AnalysisCategory::PhraseBoundaries,
110            ],
111            global_categories: vec![AnalysisCategory::KeyEstimates, AnalysisCategory::Motifs],
112            region: Some(AnalysisRegion {
113                part: *part,
114                staff: *staff,
115                start_measure: *start,
116                end_measure: *end,
117            }),
118            context_before: 1,
119            context_after: 1,
120        },
121        acorde_core::ChangeScope::Part(_) | acorde_core::ChangeScope::Global => {
122            AnalysisRefreshPlan {
123                local_categories: Vec::new(),
124                global_categories: all,
125                region: None,
126                context_before: 0,
127                context_after: 0,
128            }
129        }
130    }
131}
132
133fn all_analysis_categories() -> Vec<AnalysisCategory> {
134    vec![
135        AnalysisCategory::Chords,
136        AnalysisCategory::Intervals,
137        AnalysisCategory::KeyEstimates,
138        AnalysisCategory::CadenceCandidates,
139        AnalysisCategory::VoiceLeading,
140        AnalysisCategory::SatbDiagnostics,
141        AnalysisCategory::Motifs,
142        AnalysisCategory::PhraseBoundaries,
143    ]
144}
145
146/// Return the complete-analysis categories that a dirty engine hint may affect.
147///
148/// The mapping is intentionally conservative: any layout or playback dirty hint may alter
149/// score content or temporal context, so all categories are returned. A clean hint is known to
150/// affect neither analysis input nor context and returns an empty list.
151pub fn affected_categories_for_change_hint(hint: &ChangeHint) -> Vec<AnalysisCategory> {
152    let plan = analysis_refresh_plan(hint);
153    let selected: Vec<_> = plan
154        .local_categories
155        .into_iter()
156        .chain(plan.global_categories)
157        .collect();
158    all_analysis_categories()
159        .into_iter()
160        .filter(|category| selected.contains(category))
161        .collect()
162}
163
164/// Deterministic category-level diff between two analysis results.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct AnalysisDiff {
167    pub previous_score_fingerprint: String,
168    pub current_score_fingerprint: String,
169    pub schema_changed: bool,
170    pub changed_categories: Vec<AnalysisCategory>,
171}
172
173/// Analysis result and its deterministic diff from a previous result.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct AnalysisEditResult {
176    pub analysis: AnalysisResult,
177    pub diff: AnalysisDiff,
178}
179
180/// One deterministic explanation attached to a source note address.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct AnalysisProvenance {
183    pub category: AnalysisCategory,
184    pub rule_id: String,
185    pub confidence: u8,
186    pub evidence: Vec<NoteAddr>,
187    /// Canonical display label when the finding is a chord explanation.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub label: Option<String>,
190}
191
192/// Return every analysis finding whose evidence contains the supplied source address.
193pub fn analysis_provenance(
194    analysis: &AnalysisResult,
195    address: &NoteAddr,
196) -> Vec<AnalysisProvenance> {
197    let mut findings = Vec::new();
198    let mut add = |category, rule_id: &str, confidence, evidence: &[NoteAddr]| {
199        if evidence.iter().any(|item| item == address) {
200            findings.push(AnalysisProvenance {
201                category,
202                rule_id: rule_id.to_string(),
203                confidence,
204                evidence: evidence.to_vec(),
205                label: None,
206            });
207        }
208    };
209    for item in &analysis.chords {
210        add(
211            AnalysisCategory::Chords,
212            &item.rule_id,
213            item.confidence,
214            &item.evidence,
215        );
216    }
217    for item in &analysis.intervals {
218        add(
219            AnalysisCategory::Intervals,
220            &item.rule_id,
221            100,
222            &item.evidence,
223        );
224    }
225    for item in &analysis.key_estimates {
226        add(
227            AnalysisCategory::KeyEstimates,
228            &item.rule_id,
229            item.confidence,
230            &item.evidence,
231        );
232    }
233    for item in &analysis.cadence_candidates {
234        add(
235            AnalysisCategory::CadenceCandidates,
236            &item.rule_id,
237            item.confidence,
238            &item.evidence,
239        );
240    }
241    for item in &analysis.voice_leading {
242        add(
243            AnalysisCategory::VoiceLeading,
244            &item.rule_id,
245            item.confidence,
246            &item.evidence,
247        );
248    }
249    for item in &analysis.satb_diagnostics {
250        add(
251            AnalysisCategory::SatbDiagnostics,
252            &item.rule_id,
253            item.confidence,
254            &item.evidence,
255        );
256    }
257    for item in &analysis.motifs {
258        for occurrence in &item.occurrences {
259            add(
260                AnalysisCategory::Motifs,
261                &item.rule_id,
262                item.confidence,
263                &occurrence.evidence,
264            );
265        }
266    }
267    for item in &analysis.phrase_boundaries {
268        add(
269            AnalysisCategory::PhraseBoundaries,
270            &item.rule_id,
271            item.confidence,
272            &item.evidence,
273        );
274    }
275    for finding in &mut findings {
276        if finding.category == AnalysisCategory::Chords {
277            finding.label = analysis
278                .chords
279                .iter()
280                .find(|item| item.rule_id == finding.rule_id && item.evidence == finding.evidence)
281                .map(|item| item.name.clone());
282        }
283    }
284    findings.sort_by_key(|item| {
285        (
286            analysis_category_rank(item.category),
287            item.rule_id.clone(),
288            item.evidence
289                .iter()
290                .map(|address| {
291                    (
292                        address.part,
293                        address.staff,
294                        address.measure,
295                        address.voice,
296                        address.note,
297                    )
298                })
299                .collect::<Vec<_>>(),
300        )
301    });
302    findings
303}
304
305/// A score diff together with the explanations affected at one source address.
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307pub struct AnalysisChangeExplanation {
308    pub diff: AnalysisDiff,
309    pub previous: Vec<AnalysisProvenance>,
310    pub current: Vec<AnalysisProvenance>,
311}
312
313/// Compare two analysis results and explain the findings attached to one source address.
314pub fn explain_analysis_change(
315    previous: &AnalysisResult,
316    current: &AnalysisResult,
317    address: &NoteAddr,
318) -> AnalysisChangeExplanation {
319    AnalysisChangeExplanation {
320        diff: diff_analysis(previous, current),
321        previous: analysis_provenance(previous, address),
322        current: analysis_provenance(current, address),
323    }
324}
325
326fn analysis_category_rank(category: AnalysisCategory) -> u8 {
327    match category {
328        AnalysisCategory::Chords => 0,
329        AnalysisCategory::Intervals => 1,
330        AnalysisCategory::KeyEstimates => 2,
331        AnalysisCategory::CadenceCandidates => 3,
332        AnalysisCategory::VoiceLeading => 4,
333        AnalysisCategory::SatbDiagnostics => 5,
334        AnalysisCategory::Motifs => 6,
335        AnalysisCategory::PhraseBoundaries => 7,
336    }
337}
338
339impl AnalysisDiff {
340    /// Return whether any schema or analysis category changed.
341    pub fn is_empty(&self) -> bool {
342        !self.schema_changed && self.changed_categories.is_empty()
343    }
344}
345
346/// Compare two complete results in stable category order.
347pub fn diff_analysis(previous: &AnalysisResult, current: &AnalysisResult) -> AnalysisDiff {
348    let mut changed_categories = Vec::new();
349    if previous.chords != current.chords {
350        changed_categories.push(AnalysisCategory::Chords);
351    }
352    if previous.intervals != current.intervals {
353        changed_categories.push(AnalysisCategory::Intervals);
354    }
355    if previous.key_estimates != current.key_estimates {
356        changed_categories.push(AnalysisCategory::KeyEstimates);
357    }
358    if previous.cadence_candidates != current.cadence_candidates {
359        changed_categories.push(AnalysisCategory::CadenceCandidates);
360    }
361    if previous.voice_leading != current.voice_leading {
362        changed_categories.push(AnalysisCategory::VoiceLeading);
363    }
364    if previous.satb_diagnostics != current.satb_diagnostics {
365        changed_categories.push(AnalysisCategory::SatbDiagnostics);
366    }
367    if previous.motifs != current.motifs {
368        changed_categories.push(AnalysisCategory::Motifs);
369    }
370    if previous.phrase_boundaries != current.phrase_boundaries {
371        changed_categories.push(AnalysisCategory::PhraseBoundaries);
372    }
373    AnalysisDiff {
374        previous_score_fingerprint: previous.score_fingerprint.clone(),
375        current_score_fingerprint: current.score_fingerprint.clone(),
376        schema_changed: previous.schema_version != current.schema_version,
377        changed_categories,
378    }
379}
380
381impl AnalysisResult {
382    /// Return a cache key that invalidates when either the input or result schema changes.
383    pub fn cache_key(&self) -> String {
384        format!(
385            "analysis-v{}-{}",
386            self.schema_version, self.score_fingerprint
387        )
388    }
389
390    /// Check whether this result was produced from the supplied score.
391    pub fn matches_score(&self, score: &Score) -> bool {
392        self.score_fingerprint == score_fingerprint(score)
393    }
394}
395
396/// A host- or application-provided deterministic analysis extension.
397pub trait AnalysisPass {
398    /// Stable identifier used for ordering and persisted result lookup.
399    fn id(&self) -> &str;
400
401    /// Run the pass over a score and return its JSON payload.
402    fn run(&self, score: &Score) -> serde_json::Value;
403}
404
405/// Validation failures for a registered analysis pass set.
406#[derive(Debug, Error, Clone, PartialEq, Eq)]
407pub enum AnalysisPassError {
408    #[error("analysis pass ID must not be empty")]
409    EmptyId,
410    #[error("duplicate analysis pass ID: {0}")]
411    DuplicateId(String),
412}
413
414/// Result of one registered extension pass.
415#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
416pub struct AnalysisPassResult {
417    pub pass_id: String,
418    pub output: serde_json::Value,
419}
420
421/// Run extension passes in stable ID order after validating their identifiers.
422pub fn run_analysis_passes(
423    score: &Score,
424    passes: &[&dyn AnalysisPass],
425) -> Result<Vec<AnalysisPassResult>, AnalysisPassError> {
426    let mut ordered: Vec<&dyn AnalysisPass> = passes.to_vec();
427    for pass in &ordered {
428        if pass.id().is_empty() {
429            return Err(AnalysisPassError::EmptyId);
430        }
431    }
432    ordered.sort_by(|left, right| left.id().cmp(right.id()));
433    for pair in ordered.windows(2) {
434        if pair[0].id() == pair[1].id() {
435            return Err(AnalysisPassError::DuplicateId(pair[0].id().to_string()));
436        }
437    }
438    Ok(ordered
439        .into_iter()
440        .map(|pass| AnalysisPassResult {
441            pass_id: pass.id().to_string(),
442            output: pass.run(score),
443        })
444        .collect())
445}
446
447/// A deterministic key candidate ranked by duration-weighted diatonic pitch coverage.
448#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
449pub struct KeyEstimate {
450    pub key: KeySignature,
451    pub covered_pitches: usize,
452    pub total_pitches: usize,
453    /// Sum of note durations whose pitches belong to this key candidate.
454    #[serde(default)]
455    pub weighted_covered_beats: f64,
456    /// Sum of pitched-note durations used as the weighting denominator.
457    #[serde(default)]
458    pub total_duration_beats: f64,
459    pub confidence: u8,
460    pub rule_id: String,
461    pub evidence: Vec<NoteAddr>,
462}
463
464/// Opt-in local key estimate for one physical measure and a centered measure window.
465#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
466pub struct LocalKeyEstimate {
467    pub measure: usize,
468    pub window_start: usize,
469    pub window_end_exclusive: usize,
470    pub key: KeySignature,
471    /// Pearson correlation with the duration-weighted pitch-class profile, not a probability.
472    pub profile_correlation: f64,
473    pub rule_id: String,
474}
475
476/// A cadence transition inferred only from adjacent, explicitly labeled chords.
477#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
478pub struct CadenceCandidate {
479    pub from: NoteAddr,
480    pub to: NoteAddr,
481    pub kind: CadenceKind,
482    pub confidence: u8,
483    pub rule_id: String,
484    pub evidence: Vec<NoteAddr>,
485}
486
487/// Supported cadence transition families.
488#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
489pub enum CadenceKind {
490    Authentic,
491    Plagal,
492    Deceptive,
493    Half,
494}
495
496/// Voice-leading observation for two adjacent voices at an aligned event.
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
498pub struct VoiceLeadingObservation {
499    pub upper: NoteAddr,
500    pub lower: NoteAddr,
501    pub upper_motion: i16,
502    pub lower_motion: i16,
503    /// Exact signed upper-voice motion in cents.
504    #[serde(default)]
505    pub upper_motion_cents: i32,
506    /// Exact signed lower-voice motion in cents.
507    #[serde(default)]
508    pub lower_motion_cents: i32,
509    pub parallel_perfect: bool,
510    pub confidence: u8,
511    pub rule_id: String,
512    pub evidence: Vec<NoteAddr>,
513}
514
515/// One monophonic written note in a located two-voice comparison.
516#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
517pub struct QuartetNote {
518    pub address: NoteAddr,
519    pub pitch: Pitch,
520    /// Absolute written onset in quarter-note beats from the score start.
521    pub onset_beats: f64,
522    pub duration_beats: f64,
523}
524
525/// Four sounding notes at two successive aligned events in distinct voices.
526///
527/// Voice order is source order (part, staff, voice), not pitch height. Chords,
528/// rests, unpitched notes, and malformed voice bars do not form quartet notes.
529#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
530pub struct VoiceLeadingQuartet {
531    pub first_before: QuartetNote,
532    pub first_after: QuartetNote,
533    pub second_before: QuartetNote,
534    pub second_after: QuartetNote,
535    pub contrary_motion: bool,
536    pub similar_motion: bool,
537    /// A perfect fifth to a perfect fifth, including octave displacement and
538    /// antiparallel motion, using written pitch spelling.
539    pub parallel_fifth: bool,
540    /// A perfect octave to a perfect octave, excluding unisons.
541    pub parallel_octave: bool,
542    /// music21's keyless P4/A4/d5/m7 resolution rule on the four written notes.
543    pub proper_resolution_without_key: bool,
544    pub rule_id: String,
545    pub evidence: Vec<NoteAddr>,
546}
547
548/// A typed SATB constraint finding with source addresses for UI selection.
549#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
550pub struct SatbDiagnostic {
551    pub upper: NoteAddr,
552    pub lower: NoteAddr,
553    pub kind: SatbDiagnosticKind,
554    pub severity: SatbSeverity,
555    pub confidence: u8,
556    pub rule_id: String,
557    pub evidence: Vec<NoteAddr>,
558}
559
560/// SATB constraint families reported by the deterministic pass.
561#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
562pub enum SatbDiagnosticKind {
563    VoiceCrossing,
564    WideSpacing,
565    ParallelPerfect,
566}
567
568/// User-facing seriousness of a SATB diagnostic.
569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
570pub enum SatbSeverity {
571    Error,
572    Warning,
573}
574
575/// A repeated melodic interval pattern with all matching source occurrences.
576#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
577pub struct MotifPattern {
578    pub signature: Vec<i8>,
579    pub occurrences: Vec<MotifOccurrence>,
580    pub confidence: u8,
581    pub rule_id: String,
582}
583
584/// One source span matching a motif pattern.
585#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
586pub struct MotifOccurrence {
587    pub start: NoteAddr,
588    pub end: NoteAddr,
589    pub evidence: Vec<NoteAddr>,
590}
591
592/// A phrase boundary supported by an explicit rest at the end of a measure.
593#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
594pub struct PhraseBoundary {
595    pub address: NoteAddr,
596    pub reason: PhraseBoundaryReason,
597    pub confidence: u8,
598    pub rule_id: String,
599    pub evidence: Vec<NoteAddr>,
600}
601
602/// Evidence categories for phrase boundaries.
603#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
604pub enum PhraseBoundaryReason {
605    RestTermination,
606}
607
608/// Hand-verified expected counts for one analysis benchmark fixture.
609#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
610pub struct BenchmarkExpectation {
611    pub chords: usize,
612    pub intervals: usize,
613    pub key_estimates: usize,
614    pub cadence_candidates: usize,
615    pub voice_leading: usize,
616    pub satb_diagnostics: usize,
617    pub motifs: usize,
618    pub phrase_boundaries: usize,
619}
620
621/// Predicted category counts used by the benchmark report.
622#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
623pub struct AnalysisCounts {
624    pub chords: usize,
625    pub intervals: usize,
626    pub key_estimates: usize,
627    pub cadence_candidates: usize,
628    pub voice_leading: usize,
629    pub satb_diagnostics: usize,
630    pub motifs: usize,
631    pub phrase_boundaries: usize,
632}
633
634/// An analysis category that can be compared in a benchmark report.
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
636pub enum BenchmarkCategory {
637    Chords,
638    Intervals,
639    KeyEstimates,
640    CadenceCandidates,
641    VoiceLeading,
642    SatbDiagnostics,
643    Motifs,
644    PhraseBoundaries,
645}
646
647/// A category-level benchmark mismatch.
648#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
649pub struct BenchmarkFailure {
650    pub category: BenchmarkCategory,
651    pub expected: usize,
652    pub predicted: usize,
653    pub missing: usize,
654    pub excess: usize,
655}
656
657/// One benchmark fixture and its hand-verified expectation.
658#[derive(Debug, Clone)]
659pub struct BenchmarkCase<'a> {
660    pub name: &'a str,
661    pub score: &'a Score,
662    pub expected: BenchmarkExpectation,
663}
664
665/// Precision, recall, and explanation-completeness for one benchmark case.
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
667pub struct BenchmarkCaseReport {
668    pub name: String,
669    pub predicted: AnalysisCounts,
670    pub expected: BenchmarkExpectation,
671    pub precision_percent: u8,
672    pub recall_percent: u8,
673    pub explanation_completeness_percent: u8,
674    pub failures: Vec<BenchmarkFailure>,
675}
676
677/// Aggregate results for a deterministic benchmark suite.
678#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
679pub struct BenchmarkSuiteReport {
680    pub cases: Vec<BenchmarkCaseReport>,
681    pub case_count: usize,
682    pub passed_case_count: usize,
683    pub failed_case_count: usize,
684    pub precision_percent: u8,
685    pub recall_percent: u8,
686    pub explanation_completeness_percent: u8,
687}
688
689/// A consecutive melodic interval with addresses for both source notes.
690#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
691pub struct IntervalObservation {
692    pub from: NoteAddr,
693    pub to: NoteAddr,
694    pub semitones: u8,
695    /// Signed exact pitch distance in cents; unlike `semitones`, this preserves microtones.
696    #[serde(default)]
697    pub cents: i32,
698    pub diatonic_steps: i8,
699    pub rule_id: String,
700    pub evidence: Vec<NoteAddr>,
701}
702
703/// Analyze every voice that contains at least two pitched notes in a measure.
704pub fn analyze_score(score: &Score) -> AnalysisResult {
705    let chords = analyze_chords_only(score);
706    let intervals = analyze_intervals(score);
707    let key_estimates = estimate_keys(score);
708    let cadence_candidates = analyze_cadences(&chords);
709    let voice_leading = analyze_voice_leading(score);
710    let satb_diagnostics = analyze_satb_in_region(score, &voice_leading, None);
711    let motifs = analyze_motifs(score);
712    let phrase_boundaries = analyze_phrase_boundaries(score);
713    AnalysisResult {
714        schema_version: ANALYSIS_SCHEMA_VERSION,
715        score_fingerprint: score_fingerprint(score),
716        chords,
717        intervals,
718        key_estimates,
719        cadence_candidates,
720        voice_leading,
721        satb_diagnostics,
722        motifs,
723        phrase_boundaries,
724    }
725}
726
727fn analyze_chords_only(score: &Score) -> Vec<ChordLabel> {
728    analyze_chords_in_region(score, None)
729}
730
731/// One chord heard at a written beat, using notes active across all parts and voices.
732#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
733pub struct VerticalSonority {
734    /// Physical measure in the first available staff. For unequal but
735    /// time-aligned bar partitions, this is the reference coordinate rather
736    /// than the measure containing `label.address`.
737    pub measure: usize,
738    /// Beat from the start of that reference measure.
739    pub beat: f64,
740    pub label: ChordLabel,
741}
742
743/// A part/staff whose physical bar cannot safely be aligned with the others.
744#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
745pub struct VerticalTimelineIssue {
746    pub measure: usize,
747    pub part: usize,
748    pub staff: usize,
749    pub kind: VerticalTimelineIssueKind,
750}
751
752#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
753#[serde(rename_all = "snake_case")]
754pub enum VerticalTimelineIssueKind {
755    MissingMeasure,
756    DifferentStart,
757    DifferentLength,
758    InvalidLength,
759    InvalidNoteDuration,
760    OverfullVoice,
761    UnrepresentableConcertPitch,
762}
763
764/// Sonorities and physical-bar alignment diagnostics in source order.
765#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
766pub struct VerticalAnalysisReport {
767    pub sonorities: Vec<VerticalSonority>,
768    pub timeline_issues: Vec<VerticalTimelineIssue>,
769}
770
771/// Opt-in cadence candidates inferred from located vertical sonorities.
772#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
773pub struct VerticalCadenceReport {
774    pub cadence_candidates: Vec<CadenceCandidate>,
775    pub timeline_issues: Vec<VerticalTimelineIssue>,
776}
777
778fn vertical_measure_starts(score: &Score) -> Vec<Vec<Vec<f64>>> {
779    score
780        .parts
781        .iter()
782        .map(|part| {
783            part.staves
784                .iter()
785                .map(|staff| {
786                    let mut start = 0.0;
787                    (0..staff.measures.len())
788                        .map(|index| {
789                            let current = start;
790                            start += staff.measure_beats(index, &score.settings.time_signature);
791                            current
792                        })
793                        .collect()
794                })
795                .collect()
796        })
797        .collect()
798}
799
800fn vertical_measure_alignment_issues(
801    score: &Score,
802    starts: &[Vec<Vec<f64>>],
803    measure_index: usize,
804) -> Vec<VerticalTimelineIssue> {
805    const EPSILON: f64 = 1e-7;
806    let mut reference: Option<(f64, f64)> = None;
807    let mut issues = Vec::new();
808    for (part_index, part) in score.parts.iter().enumerate() {
809        for (staff_index, staff) in part.staves.iter().enumerate() {
810            let Some(measure) = staff.measures.get(measure_index) else {
811                issues.push(VerticalTimelineIssue {
812                    measure: measure_index,
813                    part: part_index,
814                    staff: staff_index,
815                    kind: VerticalTimelineIssueKind::MissingMeasure,
816                });
817                continue;
818            };
819            let start = starts[part_index][staff_index][measure_index];
820            let length = staff.measure_beats(measure_index, &score.settings.time_signature);
821            if !start.is_finite()
822                || !length.is_finite()
823                || length <= 0.0
824                || measure
825                    .actual_length
826                    .is_some_and(|value| value.beats().is_none())
827            {
828                issues.push(VerticalTimelineIssue {
829                    measure: measure_index,
830                    part: part_index,
831                    staff: staff_index,
832                    kind: VerticalTimelineIssueKind::InvalidLength,
833                });
834                continue;
835            }
836            for voice in &measure.voices {
837                let mut elapsed = 0.0;
838                let mut invalid = false;
839                for note in voice {
840                    let duration = if voice.len() == 1 && note.is_plain_whole_rest() {
841                        length
842                    } else {
843                        note.beats()
844                    };
845                    if !duration.is_finite() || duration < 0.0 {
846                        issues.push(VerticalTimelineIssue {
847                            measure: measure_index,
848                            part: part_index,
849                            staff: staff_index,
850                            kind: VerticalTimelineIssueKind::InvalidNoteDuration,
851                        });
852                        invalid = true;
853                        break;
854                    }
855                    elapsed += duration;
856                }
857                if !invalid && elapsed > length + EPSILON {
858                    issues.push(VerticalTimelineIssue {
859                        measure: measure_index,
860                        part: part_index,
861                        staff: staff_index,
862                        kind: VerticalTimelineIssueKind::OverfullVoice,
863                    });
864                }
865            }
866            if let Some((reference_start, reference_length)) = reference {
867                if (start - reference_start).abs() > EPSILON {
868                    issues.push(VerticalTimelineIssue {
869                        measure: measure_index,
870                        part: part_index,
871                        staff: staff_index,
872                        kind: VerticalTimelineIssueKind::DifferentStart,
873                    });
874                }
875                if (length - reference_length).abs() > EPSILON {
876                    issues.push(VerticalTimelineIssue {
877                        measure: measure_index,
878                        part: part_index,
879                        staff: staff_index,
880                        kind: VerticalTimelineIssueKind::DifferentLength,
881                    });
882                }
883            } else {
884                reference = Some((start, length));
885            }
886        }
887    }
888    issues
889}
890
891struct WrittenEvent<'a> {
892    start: f64,
893    end: f64,
894    address: NoteAddr,
895    note: &'a acorde_core::Note,
896}
897
898/// Shared written-position traversal for analysis passes within one physical bar.
899/// A lone plain whole rest occupies the actual bar length, including a pickup.
900fn written_measure_events(score: &Score, measure_index: usize) -> Vec<WrittenEvent<'_>> {
901    let mut events = Vec::new();
902    for (part_index, part) in score.parts.iter().enumerate() {
903        for staff_index in 0..part.staves.len() {
904            events.extend(written_staff_measure_events(
905                score,
906                part_index,
907                staff_index,
908                measure_index,
909            ));
910        }
911    }
912    events
913}
914
915fn written_staff_measure_events(
916    score: &Score,
917    part_index: usize,
918    staff_index: usize,
919    measure_index: usize,
920) -> Vec<WrittenEvent<'_>> {
921    let Some(staff) = score
922        .parts
923        .get(part_index)
924        .and_then(|part| part.staves.get(staff_index))
925    else {
926        return Vec::new();
927    };
928    let Some(measure) = staff.measures.get(measure_index) else {
929        return Vec::new();
930    };
931    let bar_beats = staff.measure_beats(measure_index, &score.settings.time_signature);
932    let mut events = Vec::new();
933    for (voice_index, voice) in measure.voices.iter().enumerate() {
934        let mut beat = 0.0;
935        for (note_index, note) in voice.iter().enumerate() {
936            let duration = if voice.len() == 1 && note.is_plain_whole_rest() {
937                bar_beats
938            } else {
939                note.beats()
940            };
941            if !duration.is_finite() || duration < 0.0 {
942                continue;
943            }
944            events.push(WrittenEvent {
945                start: beat,
946                end: beat + duration,
947                address: NoteAddr {
948                    part: part_index,
949                    staff: staff_index,
950                    measure: measure_index,
951                    voice: voice_index,
952                    note: note_index,
953                },
954                note,
955            });
956            beat += duration;
957        }
958    }
959    events
960}
961
962fn sounding_transpose(score: &Score, address: &NoteAddr) -> Option<i8> {
963    let part = score.parts.get(address.part)?;
964    let staff = part.staves.get(address.staff)?;
965    let measure = staff.measures.get(address.measure)?;
966    let instrument = measure
967        .instrument_change
968        .as_ref()
969        .or(part.instrument.as_ref());
970    let channel = instrument.map_or(part.midi_channel, |value| value.midi_channel);
971    Some(if channel == 9 {
972        0
973    } else {
974        staff
975            .transpose_semitones
976            .saturating_add(instrument.map_or(0, |value| value.transpose_semitones))
977    })
978}
979
980fn concert_key_at_measure(
981    score: &Score,
982    part_index: usize,
983    staff_index: usize,
984    measure_index: usize,
985) -> Option<KeySignature> {
986    let staff = score.parts.get(part_index)?.staves.get(staff_index)?;
987    let measure = staff.measures.get(measure_index)?;
988    let address = NoteAddr {
989        part: part_index,
990        staff: staff_index,
991        measure: measure_index,
992        voice: 0,
993        note: 0,
994    };
995    let semitones = sounding_transpose(score, &address)?;
996    if measure.key_sig.is_none()
997        && let Some(previous_index) = measure_index.checked_sub(1)
998        && sounding_transpose(
999            score,
1000            &NoteAddr {
1001                measure: previous_index,
1002                ..address.clone()
1003            },
1004        ) != Some(semitones)
1005    {
1006        return None;
1007    }
1008    let explicit = staff
1009        .measures
1010        .iter()
1011        .take(measure_index + 1)
1012        .rev()
1013        .find_map(|measure| measure.key_sig.as_ref());
1014    // A global score key is not enough to tell whether a transposing staff's
1015    // written key has already been converted to concert pitch.
1016    let written = if semitones == 0 {
1017        explicit.unwrap_or(&score.settings.key_signature)
1018    } else {
1019        explicit?
1020    };
1021    Some(KeySignature {
1022        fifths: transpose_fifths(written.fifths, &written.mode, semitones),
1023        mode: written.mode.clone(),
1024    })
1025}
1026
1027fn agreed_concert_key_at_measure(score: &Score, measure_index: usize) -> Option<KeySignature> {
1028    let mut agreed: Option<KeySignature> = None;
1029    for (part_index, part) in score.parts.iter().enumerate() {
1030        for (staff_index, staff) in part.staves.iter().enumerate() {
1031            let measure = staff.measures.get(measure_index)?;
1032            let contributes = measure.voices.iter().flatten().any(|note| {
1033                (!note.is_rest && !note.is_unpitched && !note.pitches.is_empty())
1034                    || note.chord_symbol.is_some()
1035            });
1036            if !contributes {
1037                continue;
1038            }
1039            let candidate = concert_key_at_measure(score, part_index, staff_index, measure_index)?;
1040            if let Some(previous) = &agreed
1041                && previous != &candidate
1042            {
1043                return None;
1044            }
1045            agreed = Some(candidate);
1046        }
1047    }
1048    agreed
1049}
1050
1051fn agreed_concert_key_for_addresses<'a>(
1052    score: &Score,
1053    addresses: impl IntoIterator<Item = &'a NoteAddr>,
1054) -> Option<KeySignature> {
1055    let mut agreed: Option<KeySignature> = None;
1056    for address in addresses {
1057        let candidate =
1058            concert_key_at_measure(score, address.part, address.staff, address.measure)?;
1059        if agreed
1060            .as_ref()
1061            .is_some_and(|previous| previous != &candidate)
1062        {
1063            return None;
1064        }
1065        agreed = Some(candidate);
1066    }
1067    agreed
1068}
1069
1070struct TieStartCandidate {
1071    address: NoteAddr,
1072    within_measure_start: f64,
1073    absolute_end: f64,
1074}
1075
1076type TieStartIndex = HashMap<(usize, i32), Vec<TieStartCandidate>>;
1077
1078fn tie_start_index(score: &Score, starts: &[Vec<Vec<f64>>]) -> TieStartIndex {
1079    const EPSILON: f64 = 1e-7;
1080    let mut index: TieStartIndex = HashMap::new();
1081    for (part_index, part) in score.parts.iter().enumerate() {
1082        for (staff_index, staff) in part.staves.iter().enumerate() {
1083            for (measure_index, &measure_start) in
1084                starts[part_index][staff_index].iter().enumerate()
1085            {
1086                let bar_length = staff.measure_beats(measure_index, &score.settings.time_signature);
1087                for event in
1088                    written_staff_measure_events(score, part_index, staff_index, measure_index)
1089                {
1090                    if event.note.is_rest
1091                        || event.note.is_unpitched
1092                        || event.end - event.start <= EPSILON
1093                        || event.end > bar_length + EPSILON
1094                        || !measure_start.is_finite()
1095                    {
1096                        continue;
1097                    }
1098                    let Some(transpose) = sounding_transpose(score, &event.address) else {
1099                        continue;
1100                    };
1101                    for (pitch_index, pitch) in event.note.pitches.iter().enumerate() {
1102                        if event.note.pitch_tie_start(pitch_index) {
1103                            let cents = pitch.to_midi_cents() + i32::from(transpose) * 100;
1104                            index
1105                                .entry((part_index, cents))
1106                                .or_default()
1107                                .push(TieStartCandidate {
1108                                    address: event.address.clone(),
1109                                    within_measure_start: event.start,
1110                                    absolute_end: measure_start + event.end,
1111                                });
1112                        }
1113                    }
1114                }
1115            }
1116        }
1117    }
1118    index
1119}
1120
1121/// A continuation needs a matching tie start in the same part at the same
1122/// absolute beat and sounding pitch. It may cross voices or staves. If the
1123/// source explicitly identifies its predecessor, that location must match.
1124fn is_tied_continuation(
1125    score: &Score,
1126    event: &WrittenEvent<'_>,
1127    pitch_index: usize,
1128    starts: &[Vec<Vec<f64>>],
1129    tie_starts: &TieStartIndex,
1130) -> bool {
1131    const EPSILON: f64 = 1e-7;
1132    if !event.note.pitch_tie_end(pitch_index) {
1133        return false;
1134    }
1135    let Some(pitch) = event.note.pitches.get(pitch_index) else {
1136        return false;
1137    };
1138    let Some(transpose) = sounding_transpose(score, &event.address) else {
1139        return false;
1140    };
1141    let Some(&measure_start) = starts
1142        .get(event.address.part)
1143        .and_then(|part| part.get(event.address.staff))
1144        .and_then(|staff| staff.get(event.address.measure))
1145    else {
1146        return false;
1147    };
1148    let absolute_start = measure_start + event.start;
1149    if !absolute_start.is_finite() {
1150        return false;
1151    }
1152    let cents = pitch.to_midi_cents() + i32::from(transpose) * 100;
1153    let explicit_source = event
1154        .note
1155        .pitch_tie_sources
1156        .get(pitch_index)
1157        .and_then(Option::as_ref);
1158    tie_starts
1159        .get(&(event.address.part, cents))
1160        .is_some_and(|candidates| {
1161            candidates.iter().any(|candidate| {
1162                (candidate.absolute_end - absolute_start).abs() < EPSILON
1163                    && explicit_source.is_none_or(|source| {
1164                        source.matches(
1165                            &event.address,
1166                            &candidate.address,
1167                            candidate.within_measure_start - event.start,
1168                        )
1169                    })
1170            })
1171        })
1172}
1173
1174fn has_new_pitched_attack(
1175    score: &Score,
1176    event: &WrittenEvent<'_>,
1177    starts: &[Vec<Vec<f64>>],
1178    tie_starts: &TieStartIndex,
1179) -> bool {
1180    event
1181        .note
1182        .pitches
1183        .iter()
1184        .enumerate()
1185        .any(|(index, _)| !is_tied_continuation(score, event, index, starts, tie_starts))
1186}
1187
1188/// Use the same written-to-sounding semitone offsets as playback. The
1189/// original spelling is retained when there is no transposition; otherwise
1190/// enharmonic spelling follows the concert key used for this analysis pass.
1191fn concert_pitch(
1192    score: &Score,
1193    event: &WrittenEvent<'_>,
1194    pitch: &Pitch,
1195    key: &KeySignature,
1196) -> Option<Pitch> {
1197    let semitones = sounding_transpose(score, &event.address)?;
1198    if semitones == 0 {
1199        return Some(pitch.clone());
1200    }
1201    let base =
1202        (pitch.to_midi_cents() - i32::from(pitch.microtone_cents)) / 100 + i32::from(semitones);
1203    let step_index = match pitch.step {
1204        Step::C => 0,
1205        Step::D => 1,
1206        Step::E => 2,
1207        Step::F => 3,
1208        Step::G => 4,
1209        Step::A => 5,
1210        Step::B => 6,
1211    };
1212    // Diatonic interval for the chromatic shift. For example, -2 is a major
1213    // second down and -9 is a major sixth down, preserving C -> Eb for an
1214    // E-flat instrument even when the global key has no flats.
1215    const DIATONIC_STEPS: [i32; 12] = [0, 0, 1, 2, 2, 3, 3, 4, 5, 5, 6, 6];
1216    let shift =
1217        semitones.div_euclid(12) as i32 * 7 + DIATONIC_STEPS[semitones.rem_euclid(12) as usize];
1218    let diatonic = i32::from(pitch.octave) * 7 + step_index + shift;
1219    let step = match diatonic.rem_euclid(7) {
1220        0 => Step::C,
1221        1 => Step::D,
1222        2 => Step::E,
1223        3 => Step::F,
1224        4 => Step::G,
1225        5 => Step::A,
1226        _ => Step::B,
1227    };
1228    let octave = diatonic.div_euclid(7);
1229    let alter = base - (octave + 1) * 12 - i32::from(step.to_semitone());
1230    if let (Ok(octave), Ok(alter)) = (i8::try_from(octave), i8::try_from(alter))
1231        && (-2..=2).contains(&alter)
1232    {
1233        return Pitch::try_with_microtone(step, octave, alter, pitch.microtone_cents);
1234    }
1235    let mut result = Pitch::from_midi((60 + base.rem_euclid(12)) as u8, key.fifths < 0);
1236    let octave =
1237        (base - i32::from(result.step.to_semitone()) - i32::from(result.alter)).div_euclid(12) - 1;
1238    result.octave = i8::try_from(octave).ok()?;
1239    result.microtone_cents = pitch.microtone_cents;
1240    Some(result)
1241}
1242
1243/// Infer chords from simultaneous sounding notes, retaining authored chord symbols.
1244///
1245/// This opt-in pass groups all parts, staves and voices at each written pitched
1246/// onset. It avoids treating consecutive notes of a melody as one chord. It does
1247/// not expand repeats or infer a harmonic rhythm where no note starts. Unequal
1248/// bar partitions are aligned by absolute written beats when every staff has
1249/// the same validated total length. Roman numerals use the concert key only
1250/// when all active contributing staves agree, or when the caller supplies one.
1251/// Output positions use the first available staff's bars.
1252pub fn analyze_vertical_sonorities(score: &Score) -> Vec<VerticalSonority> {
1253    analyze_vertical_sonorities_report(score).sonorities
1254}
1255
1256pub fn analyze_vertical_sonorities_report(score: &Score) -> VerticalAnalysisReport {
1257    analyze_vertical_sonorities_with_key(score, None)
1258}
1259
1260/// Infer vertical chords in an independently chosen global key.
1261///
1262/// Use this when a corpus has a known or separately estimated global key;
1263/// local key-signature changes in the score are ignored for this pass.
1264pub fn analyze_vertical_sonorities_in_key(
1265    score: &Score,
1266    key: &KeySignature,
1267) -> Vec<VerticalSonority> {
1268    analyze_vertical_sonorities_in_key_report(score, key).sonorities
1269}
1270
1271pub fn analyze_vertical_sonorities_in_key_report(
1272    score: &Score,
1273    key: &KeySignature,
1274) -> VerticalAnalysisReport {
1275    analyze_vertical_sonorities_with_key(score, Some(key))
1276}
1277
1278/// Add conservative opt-in chords when notes within a bar arpeggiate a complete
1279/// tertian chord. A bar start uses its first half, and a later onset may use
1280/// notes up to the next detected sonority within half a beat. A three-note
1281/// melody is never promoted by the bar-start rule. Evidence includes the later
1282/// notes used for each inference.
1283pub fn analyze_arpeggiated_bars_report(score: &Score) -> VerticalAnalysisReport {
1284    analyze_arpeggiated_bars_with_key(score, None)
1285}
1286
1287/// As [`analyze_arpeggiated_bars_report`], using a caller-supplied concert key.
1288pub fn analyze_arpeggiated_bars_in_key_report(
1289    score: &Score,
1290    key: &KeySignature,
1291) -> VerticalAnalysisReport {
1292    analyze_arpeggiated_bars_with_key(score, Some(key))
1293}
1294
1295/// Find conservative dominant-to-tonic resolutions from opt-in vertical chords.
1296///
1297/// A candidate requires a preceding un-applied dominant, a tonic chord, a
1298/// sounding bass pitch class to rise by five semitones modulo 12, and the
1299/// outer voices to finish in the same pitch class. This identifies a supported
1300/// authentic-cadence *candidate*; it does not distinguish perfect from
1301/// imperfect authentic cadence or assert a phrase ending.
1302pub fn analyze_vertical_cadences_report(score: &Score) -> VerticalCadenceReport {
1303    analyze_vertical_cadences_with_key(score, None)
1304}
1305
1306/// As [`analyze_vertical_cadences_report`], with a caller-supplied concert key.
1307pub fn analyze_vertical_cadences_in_key_report(
1308    score: &Score,
1309    key: &KeySignature,
1310) -> VerticalCadenceReport {
1311    analyze_vertical_cadences_with_key(score, Some(key))
1312}
1313
1314fn is_plain_roman(roman: &str, numeral: &str) -> bool {
1315    if roman.contains('/') {
1316        return false;
1317    }
1318    roman.strip_prefix(numeral).is_some_and(|suffix| {
1319        suffix.is_empty() || suffix.starts_with(|character: char| character.is_ascii_digit())
1320    })
1321}
1322
1323fn active_outer_sounding_semitones(score: &Score, measure: usize, beat: f64) -> Option<(i32, i32)> {
1324    const EPSILON: f64 = 1e-7;
1325    let mut bass = None;
1326    let mut top = None;
1327    for event in written_measure_events(score, measure) {
1328        if event.start > beat + EPSILON
1329            || event.end <= beat + EPSILON
1330            || event.note.is_rest
1331            || event.note.is_unpitched
1332        {
1333            continue;
1334        }
1335        let transpose = i32::from(sounding_transpose(score, &event.address)?);
1336        for pitch in &event.note.pitches {
1337            let cents = pitch.to_midi_cents() + transpose * 100;
1338            if cents.rem_euclid(100) != 0 {
1339                return None;
1340            }
1341            let semitone = cents / 100;
1342            bass = Some(bass.map_or(semitone, |low: i32| low.min(semitone)));
1343            top = Some(top.map_or(semitone, |high: i32| high.max(semitone)));
1344        }
1345    }
1346    Some((bass?, top?))
1347}
1348
1349fn analyze_vertical_cadences_with_key(
1350    score: &Score,
1351    key_override: Option<&KeySignature>,
1352) -> VerticalCadenceReport {
1353    let report = analyze_arpeggiated_bars_with_key(score, key_override);
1354    let mut result = VerticalCadenceReport {
1355        cadence_candidates: Vec::new(),
1356        timeline_issues: report.timeline_issues,
1357    };
1358    // The outer-pitch helper uses each staff's physical measure index.
1359    if valid_alternate_bar_partition(score, &vertical_measure_starts(score)).is_some() {
1360        return result;
1361    }
1362    let mut sonorities: Vec<_> = report.sonorities.iter().collect();
1363    sonorities.sort_by(|left, right| {
1364        left.measure
1365            .cmp(&right.measure)
1366            .then_with(|| left.beat.total_cmp(&right.beat))
1367    });
1368    for (index, current) in sonorities.iter().enumerate() {
1369        if result
1370            .timeline_issues
1371            .iter()
1372            .any(|issue| issue.measure == current.measure)
1373        {
1374            continue;
1375        }
1376        let Some(roman) = current.label.roman_numeral.as_deref() else {
1377            continue;
1378        };
1379        if !is_plain_roman(roman, "I") {
1380            continue;
1381        }
1382        let previous = sonorities[index.saturating_sub(8)..index]
1383            .iter()
1384            .rev()
1385            .find(|previous| {
1386                current.measure.saturating_sub(previous.measure) <= 1
1387                    && previous
1388                        .label
1389                        .roman_numeral
1390                        .as_deref()
1391                        .is_some_and(|label| label != roman)
1392            });
1393        let Some(previous) = previous else {
1394            continue;
1395        };
1396        if result
1397            .timeline_issues
1398            .iter()
1399            .any(|issue| issue.measure == previous.measure)
1400            || !previous
1401                .label
1402                .roman_numeral
1403                .as_deref()
1404                .is_some_and(|label| is_plain_roman(label, "V"))
1405        {
1406            continue;
1407        }
1408        let Some((previous_bass, _)) =
1409            active_outer_sounding_semitones(score, previous.measure, previous.beat)
1410        else {
1411            continue;
1412        };
1413        let Some((current_bass, current_top)) =
1414            active_outer_sounding_semitones(score, current.measure, current.beat)
1415        else {
1416            continue;
1417        };
1418        if (current_bass - previous_bass).rem_euclid(12) != 5
1419            || (current_top - current_bass).rem_euclid(12) != 0
1420        {
1421            continue;
1422        }
1423        let mut evidence = previous.label.evidence.clone();
1424        for address in &current.label.evidence {
1425            if !evidence.contains(address) {
1426                evidence.push(address.clone());
1427            }
1428        }
1429        result.cadence_candidates.push(CadenceCandidate {
1430            from: previous.label.address.clone(),
1431            to: current.label.address.clone(),
1432            kind: CadenceKind::Authentic,
1433            confidence: if current.beat.abs() < 1e-7 { 70 } else { 55 },
1434            rule_id: "vertical-dominant-tonic-outer-voices".to_string(),
1435            evidence,
1436        });
1437    }
1438    result
1439}
1440
1441fn analyze_arpeggiated_bars_with_key(
1442    score: &Score,
1443    key_override: Option<&KeySignature>,
1444) -> VerticalAnalysisReport {
1445    const EPSILON: f64 = 1e-7;
1446    let mut report = analyze_vertical_sonorities_with_key(score, key_override);
1447    let starts = vertical_measure_starts(score);
1448    // A physical bar in one staff must not be mistaken for the same time span
1449    // in another staff when their bar partitions differ.
1450    if valid_alternate_bar_partition(score, &starts).is_some() {
1451        return report;
1452    }
1453    let tie_starts = tie_start_index(score, &starts);
1454    let Some(reference) = score.parts.first().and_then(|part| part.staves.first()) else {
1455        return report;
1456    };
1457    for measure_index in 0..reference.measures.len() {
1458        if report
1459            .timeline_issues
1460            .iter()
1461            .any(|issue| issue.measure == measure_index)
1462            || report
1463                .sonorities
1464                .iter()
1465                .any(|item| item.measure == measure_index && item.beat.abs() < EPSILON)
1466        {
1467            continue;
1468        }
1469        let length = reference.measure_beats(measure_index, &score.settings.time_signature);
1470        if !length.is_finite() || length <= 0.0 {
1471            continue;
1472        }
1473        let window = (length / 2.0).min(2.0);
1474        let written = written_measure_events(score, measure_index);
1475        let opening: Vec<_> = written
1476            .iter()
1477            .filter(|event| {
1478                !event.note.is_rest
1479                    && !event.note.is_unpitched
1480                    && !event.note.pitches.is_empty()
1481                    && event.end > event.start + EPSILON
1482                    && event.start < window - EPSILON
1483            })
1484            .collect();
1485        if opening.len() < 4
1486            || !opening.iter().any(|event| {
1487                event.start.abs() < EPSILON
1488                    && has_new_pitched_attack(score, event, &starts, &tie_starts)
1489            })
1490            || opening
1491                .iter()
1492                .any(|event| event.start > EPSILON && event.note.chord_symbol.is_some())
1493        {
1494            continue;
1495        }
1496        let resolved_key = key_override
1497            .cloned()
1498            .or_else(|| agreed_concert_key_at_measure(score, measure_index));
1499        let spelling_key = resolved_key
1500            .as_ref()
1501            .unwrap_or(&score.settings.key_signature);
1502        let Some(pitches): Option<Vec<_>> = opening
1503            .iter()
1504            .flat_map(|event| {
1505                event
1506                    .note
1507                    .pitches
1508                    .iter()
1509                    .map(|pitch| concert_pitch(score, event, pitch, spelling_key))
1510            })
1511            .collect()
1512        else {
1513            continue;
1514        };
1515        let Some(chord) = detect_chord(&pitches) else {
1516            continue;
1517        };
1518        if !matches!(
1519            chord.kind.as_str(),
1520            "major"
1521                | "minor"
1522                | "diminished"
1523                | "augmented"
1524                | "dominant"
1525                | "major-seventh"
1526                | "minor-seventh"
1527                | "diminished-seventh"
1528                | "half-diminished"
1529        ) {
1530            continue;
1531        }
1532        let Some(anchor) = opening.iter().find(|event| event.start.abs() < EPSILON) else {
1533            continue;
1534        };
1535        report.sonorities.push(VerticalSonority {
1536            measure: measure_index,
1537            beat: 0.0,
1538            label: ChordLabel {
1539                address: anchor.address.clone(),
1540                name: chord_name(&chord),
1541                roman_numeral: resolved_key
1542                    .as_ref()
1543                    .and_then(|key| roman_numeral_figured(&chord, key)),
1544                chord,
1545                confidence: 55,
1546                rule_id: "bar-arpeggio-template".to_string(),
1547                evidence: opening.iter().map(|event| event.address.clone()).collect(),
1548            },
1549        });
1550    }
1551    // A note that begins shortly before an identified vertical chord can be
1552    // its arpeggiated opening. Complete only when the intervening pitches form
1553    // an exact tertian template; a chromatic approach note rejects it.
1554    for measure_index in 0..reference.measures.len() {
1555        if report
1556            .timeline_issues
1557            .iter()
1558            .any(|issue| issue.measure == measure_index)
1559        {
1560            continue;
1561        }
1562        let written = written_measure_events(score, measure_index);
1563        let known: Vec<_> = report
1564            .sonorities
1565            .iter()
1566            .filter(|item| item.measure == measure_index)
1567            .cloned()
1568            .collect();
1569        let mut onsets: Vec<_> = written
1570            .iter()
1571            .filter(|event| {
1572                !event.note.is_rest
1573                    && !event.note.is_unpitched
1574                    && !event.note.pitches.is_empty()
1575                    && event.end > event.start + EPSILON
1576                    && has_new_pitched_attack(score, event, &starts, &tie_starts)
1577            })
1578            .map(|event| event.start)
1579            .collect();
1580        onsets.sort_by(f64::total_cmp);
1581        onsets.dedup_by(|left, right| (*left - *right).abs() < EPSILON);
1582        for onset in onsets {
1583            if known.iter().any(|item| (item.beat - onset).abs() < EPSILON) {
1584                continue;
1585            }
1586            let Some(next) = known
1587                .iter()
1588                .find(|item| item.beat > onset + EPSILON && item.beat <= onset + 0.5 + EPSILON)
1589            else {
1590                continue;
1591            };
1592            let opening: Vec<_> = written
1593                .iter()
1594                .filter(|event| {
1595                    !event.note.is_rest
1596                        && !event.note.is_unpitched
1597                        && !event.note.pitches.is_empty()
1598                        && event.end > onset + EPSILON
1599                        && event.start <= next.beat + EPSILON
1600                })
1601                .collect();
1602            let resolved_key = key_override
1603                .cloned()
1604                .or_else(|| agreed_concert_key_at_measure(score, measure_index));
1605            let spelling_key = resolved_key
1606                .as_ref()
1607                .unwrap_or(&score.settings.key_signature);
1608            let Some(pitches): Option<Vec<_>> = opening
1609                .iter()
1610                .flat_map(|event| {
1611                    event
1612                        .note
1613                        .pitches
1614                        .iter()
1615                        .map(|pitch| concert_pitch(score, event, pitch, spelling_key))
1616                })
1617                .collect()
1618            else {
1619                continue;
1620            };
1621            let Some(chord) = detect_chord(&pitches) else {
1622                continue;
1623            };
1624            if !matches!(
1625                chord.kind.as_str(),
1626                "major"
1627                    | "minor"
1628                    | "diminished"
1629                    | "augmented"
1630                    | "dominant"
1631                    | "major-seventh"
1632                    | "minor-seventh"
1633                    | "diminished-seventh"
1634                    | "half-diminished"
1635            ) {
1636                continue;
1637            }
1638            let Some(anchor) = opening.iter().find(|event| {
1639                (event.start - onset).abs() < EPSILON
1640                    && has_new_pitched_attack(score, event, &starts, &tie_starts)
1641            }) else {
1642                continue;
1643            };
1644            report.sonorities.push(VerticalSonority {
1645                measure: measure_index,
1646                beat: onset,
1647                label: ChordLabel {
1648                    address: anchor.address.clone(),
1649                    name: chord_name(&chord),
1650                    roman_numeral: resolved_key
1651                        .as_ref()
1652                        .and_then(|key| roman_numeral_figured(&chord, key)),
1653                    chord,
1654                    confidence: 45,
1655                    rule_id: "forward-completed-sonority".to_string(),
1656                    evidence: opening.iter().map(|event| event.address.clone()).collect(),
1657                },
1658            });
1659        }
1660    }
1661    report.sonorities.sort_by(|left, right| {
1662        left.measure
1663            .cmp(&right.measure)
1664            .then_with(|| left.beat.total_cmp(&right.beat))
1665    });
1666    report
1667}
1668
1669struct AbsoluteWrittenEvent<'a> {
1670    start: f64,
1671    end: f64,
1672    written: WrittenEvent<'a>,
1673}
1674
1675/// A different physical bar partition is usable only when every staff spans
1676/// the same finite score length and every local note fits its own bar.
1677fn valid_alternate_bar_partition(
1678    score: &Score,
1679    starts: &[Vec<Vec<f64>>],
1680) -> Option<(usize, usize)> {
1681    const EPSILON: f64 = 1e-7;
1682    let (reference_part, reference_staff, reference) = score
1683        .parts
1684        .iter()
1685        .enumerate()
1686        .flat_map(|(part_index, part)| {
1687            part.staves
1688                .iter()
1689                .enumerate()
1690                .map(move |(staff_index, staff)| (part_index, staff_index, staff))
1691        })
1692        .next()?;
1693    if reference.measures.is_empty() {
1694        return None;
1695    }
1696    let reference_starts = starts.get(reference_part)?.get(reference_staff)?;
1697    let reference_last = reference.measures.len() - 1;
1698    let reference_total = reference_starts.get(reference_last)?
1699        + reference.measure_beats(reference_last, &score.settings.time_signature);
1700    if !reference_total.is_finite() || reference_total <= 0.0 {
1701        return None;
1702    }
1703    let mut differs = false;
1704    for (part_index, part) in score.parts.iter().enumerate() {
1705        for (staff_index, staff) in part.staves.iter().enumerate() {
1706            let staff_starts = starts.get(part_index)?.get(staff_index)?;
1707            let last = staff.measures.len().checked_sub(1)?;
1708            let total =
1709                staff_starts.get(last)? + staff.measure_beats(last, &score.settings.time_signature);
1710            if !total.is_finite() || (total - reference_total).abs() > EPSILON {
1711                return None;
1712            }
1713            differs |= staff.measures.len() != reference.measures.len();
1714            for (measure_index, measure) in staff.measures.iter().enumerate() {
1715                let start = *staff_starts.get(measure_index)?;
1716                let length = staff.measure_beats(measure_index, &score.settings.time_signature);
1717                if !start.is_finite()
1718                    || !length.is_finite()
1719                    || length <= 0.0
1720                    || measure
1721                        .actual_length
1722                        .is_some_and(|actual| actual.beats().is_none())
1723                {
1724                    return None;
1725                }
1726                if let Some(reference_start) = reference_starts.get(measure_index) {
1727                    differs |= (start - reference_start).abs() > EPSILON;
1728                    differs |= (length
1729                        - reference.measure_beats(measure_index, &score.settings.time_signature))
1730                    .abs()
1731                        > EPSILON;
1732                }
1733                for voice in &measure.voices {
1734                    let mut elapsed = 0.0;
1735                    for note in voice {
1736                        let duration = if voice.len() == 1 && note.is_plain_whole_rest() {
1737                            length
1738                        } else {
1739                            note.beats()
1740                        };
1741                        if !duration.is_finite() || duration < 0.0 {
1742                            return None;
1743                        }
1744                        elapsed += duration;
1745                    }
1746                    if elapsed > length + EPSILON {
1747                        return None;
1748                    }
1749                }
1750            }
1751        }
1752    }
1753    differs.then_some((reference_part, reference_staff))
1754}
1755
1756fn analyze_vertical_sonorities_on_absolute_timeline(
1757    score: &Score,
1758    starts: &[Vec<Vec<f64>>],
1759    tie_starts: &TieStartIndex,
1760    reference_part: usize,
1761    reference_staff: usize,
1762    key_override: Option<&KeySignature>,
1763) -> VerticalAnalysisReport {
1764    const EPSILON: f64 = 1e-7;
1765    let reference = &score.parts[reference_part].staves[reference_staff];
1766    let reference_starts = &starts[reference_part][reference_staff];
1767    let mut events = Vec::new();
1768    for (part_index, part) in score.parts.iter().enumerate() {
1769        for (staff_index, staff) in part.staves.iter().enumerate() {
1770            for (measure_index, &measure_start) in starts[part_index][staff_index]
1771                .iter()
1772                .enumerate()
1773                .take(staff.measures.len())
1774            {
1775                for written in
1776                    written_staff_measure_events(score, part_index, staff_index, measure_index)
1777                {
1778                    events.push(AbsoluteWrittenEvent {
1779                        start: measure_start + written.start,
1780                        end: measure_start + written.end,
1781                        written,
1782                    });
1783                }
1784            }
1785        }
1786    }
1787    let mut key = key_override
1788        .cloned()
1789        .unwrap_or_else(|| score.settings.key_signature.clone());
1790    let mut result = Vec::new();
1791    let mut timeline_issues = Vec::new();
1792    for (measure_index, measure) in reference.measures.iter().enumerate() {
1793        let bar_start = reference_starts[measure_index];
1794        let bar_end =
1795            bar_start + reference.measure_beats(measure_index, &score.settings.time_signature);
1796        if key_override.is_none()
1797            && let Some(next_key) = &measure.key_sig
1798        {
1799            key = next_key.clone();
1800        }
1801        let window: Vec<_> = events
1802            .iter()
1803            .filter(|event| {
1804                (event.start < bar_end - EPSILON && event.end > bar_start + EPSILON)
1805                    || (event.start >= bar_start - EPSILON
1806                        && event.start < bar_end - EPSILON
1807                        && event.written.note.chord_symbol.is_some())
1808            })
1809            .collect();
1810        let mut invalid = false;
1811        for event in &window {
1812            if event.written.note.is_rest || event.written.note.is_unpitched {
1813                continue;
1814            }
1815            if event
1816                .written
1817                .note
1818                .pitches
1819                .iter()
1820                .any(|pitch| concert_pitch(score, &event.written, pitch, &key).is_none())
1821            {
1822                timeline_issues.push(VerticalTimelineIssue {
1823                    measure: event.written.address.measure,
1824                    part: event.written.address.part,
1825                    staff: event.written.address.staff,
1826                    kind: VerticalTimelineIssueKind::UnrepresentableConcertPitch,
1827                });
1828                invalid = true;
1829            }
1830        }
1831        if invalid {
1832            continue;
1833        }
1834        let mut authored = Vec::new();
1835        let mut sounding = Vec::new();
1836        let mut onsets = Vec::new();
1837        for event in &window {
1838            if event.start >= bar_start - EPSILON
1839                && let Some(chord) = &event.written.note.chord_symbol
1840            {
1841                authored.push((event.start, event.written.address.clone(), chord));
1842                onsets.push(event.start);
1843            }
1844            if !event.written.note.is_rest
1845                && !event.written.note.is_unpitched
1846                && !event.written.note.pitches.is_empty()
1847                && event.end - event.start > EPSILON
1848            {
1849                sounding.push(*event);
1850                if event.start >= bar_start - EPSILON
1851                    && has_new_pitched_attack(score, &event.written, starts, tie_starts)
1852                {
1853                    onsets.push(event.start);
1854                }
1855            }
1856        }
1857        onsets.sort_by(f64::total_cmp);
1858        onsets.dedup_by(|left, right| (*left - *right).abs() < EPSILON);
1859        for onset in onsets {
1860            let authored_here: Vec<_> = authored
1861                .iter()
1862                .filter(|(start, _, _)| (*start - onset).abs() < EPSILON)
1863                .collect();
1864            let active: Vec<_> = sounding
1865                .iter()
1866                .filter(|event| event.start <= onset + EPSILON && event.end > onset + EPSILON)
1867                .collect();
1868            let resolved_key = key_override.cloned().or_else(|| {
1869                agreed_concert_key_for_addresses(
1870                    score,
1871                    active
1872                        .iter()
1873                        .map(|event| &event.written.address)
1874                        .chain(authored_here.iter().map(|entry| &entry.1)),
1875                )
1876            });
1877            if !authored_here.is_empty() {
1878                result.extend(authored_here.into_iter().map(|(_, address, chord)| {
1879                    VerticalSonority {
1880                        measure: measure_index,
1881                        beat: onset - bar_start,
1882                        label: ChordLabel {
1883                            address: address.clone(),
1884                            chord: (*chord).clone(),
1885                            name: chord_name(chord),
1886                            roman_numeral: resolved_key
1887                                .as_ref()
1888                                .filter(|_| sounding_transpose(score, address) == Some(0))
1889                                .and_then(|key| roman_numeral_figured(chord, key)),
1890                            confidence: 100,
1891                            rule_id: "authored-chord-symbol".to_string(),
1892                            evidence: vec![address.clone()],
1893                        },
1894                    }
1895                }));
1896                continue;
1897            }
1898            let Some(pitches): Option<Vec<_>> = active
1899                .iter()
1900                .flat_map(|event| {
1901                    event
1902                        .written
1903                        .note
1904                        .pitches
1905                        .iter()
1906                        .map(|pitch| concert_pitch(score, &event.written, pitch, &key))
1907                })
1908                .collect()
1909            else {
1910                continue;
1911            };
1912            let Some(chord) = detect_chord(&pitches) else {
1913                continue;
1914            };
1915            let Some(anchor) = active.iter().find(|event| {
1916                (event.start - onset).abs() < EPSILON
1917                    && has_new_pitched_attack(score, &event.written, starts, tie_starts)
1918            }) else {
1919                continue;
1920            };
1921            result.push(VerticalSonority {
1922                measure: measure_index,
1923                beat: onset - bar_start,
1924                label: ChordLabel {
1925                    address: anchor.written.address.clone(),
1926                    name: chord_name(&chord),
1927                    roman_numeral: resolved_key
1928                        .as_ref()
1929                        .and_then(|key| roman_numeral_figured(&chord, key)),
1930                    chord,
1931                    confidence: 70,
1932                    rule_id: "vertical-sonority-template".to_string(),
1933                    evidence: active
1934                        .iter()
1935                        .map(|event| event.written.address.clone())
1936                        .collect(),
1937                },
1938            });
1939        }
1940    }
1941    VerticalAnalysisReport {
1942        sonorities: result,
1943        timeline_issues,
1944    }
1945}
1946
1947fn analyze_vertical_sonorities_with_key(
1948    score: &Score,
1949    key_override: Option<&KeySignature>,
1950) -> VerticalAnalysisReport {
1951    const EPSILON: f64 = 1e-7;
1952    let measure_count = score
1953        .parts
1954        .iter()
1955        .flat_map(|part| part.staves.iter())
1956        .map(|staff| staff.measures.len())
1957        .max()
1958        .unwrap_or(0);
1959    let mut key = key_override
1960        .cloned()
1961        .unwrap_or_else(|| score.settings.key_signature.clone());
1962    let mut result = Vec::new();
1963    let mut timeline_issues = Vec::new();
1964    let starts = vertical_measure_starts(score);
1965    let tie_starts = tie_start_index(score, &starts);
1966    if let Some((reference_part, reference_staff)) = valid_alternate_bar_partition(score, &starts) {
1967        return analyze_vertical_sonorities_on_absolute_timeline(
1968            score,
1969            &starts,
1970            &tie_starts,
1971            reference_part,
1972            reference_staff,
1973            key_override,
1974        );
1975    }
1976
1977    for measure_index in 0..measure_count {
1978        let issues = vertical_measure_alignment_issues(score, &starts, measure_index);
1979        if !issues.is_empty() {
1980            timeline_issues.extend(issues);
1981            continue;
1982        }
1983        if key_override.is_none()
1984            && let Some(next_key) = score
1985                .parts
1986                .iter()
1987                .flat_map(|part| part.staves.iter())
1988                .filter_map(|staff| staff.measures.get(measure_index))
1989                .find_map(|measure| measure.key_sig.as_ref())
1990        {
1991            key = next_key.clone();
1992        }
1993        let resolved_key = key_override
1994            .cloned()
1995            .or_else(|| agreed_concert_key_at_measure(score, measure_index));
1996        let spelling_key = resolved_key.as_ref().unwrap_or(&key);
1997        let written = written_measure_events(score, measure_index);
1998        let mut unrepresentable = Vec::new();
1999        for event in &written {
2000            if event.note.is_rest || event.note.is_unpitched {
2001                continue;
2002            }
2003            for pitch in &event.note.pitches {
2004                if concert_pitch(score, event, pitch, spelling_key).is_none() {
2005                    unrepresentable.push(VerticalTimelineIssue {
2006                        measure: measure_index,
2007                        part: event.address.part,
2008                        staff: event.address.staff,
2009                        kind: VerticalTimelineIssueKind::UnrepresentableConcertPitch,
2010                    });
2011                }
2012            }
2013        }
2014        if !unrepresentable.is_empty() {
2015            timeline_issues.extend(unrepresentable);
2016            continue;
2017        }
2018        let mut sounding = Vec::new();
2019        let mut authored = Vec::new();
2020        let mut onsets = Vec::new();
2021        for event in &written {
2022            if let Some(chord) = &event.note.chord_symbol {
2023                let authored_key = resolved_key
2024                    .as_ref()
2025                    .filter(|_| sounding_transpose(score, &event.address) == Some(0));
2026                authored.push((
2027                    event.start,
2028                    ChordLabel {
2029                        address: event.address.clone(),
2030                        chord: chord.clone(),
2031                        name: chord_name(chord),
2032                        roman_numeral: authored_key
2033                            .and_then(|key| roman_numeral_figured(chord, key)),
2034                        confidence: 100,
2035                        rule_id: "authored-chord-symbol".to_string(),
2036                        evidence: vec![event.address.clone()],
2037                    },
2038                ));
2039                onsets.push(event.start);
2040            }
2041            if !event.note.is_rest
2042                && !event.note.is_unpitched
2043                && !event.note.pitches.is_empty()
2044                && event.end - event.start > EPSILON
2045            {
2046                sounding.push(event);
2047                if has_new_pitched_attack(score, event, &starts, &tie_starts) {
2048                    onsets.push(event.start);
2049                }
2050            }
2051        }
2052        onsets.sort_by(f64::total_cmp);
2053        onsets.dedup_by(|left, right| (*left - *right).abs() < EPSILON);
2054        for beat in onsets {
2055            let authored_here: Vec<_> = authored
2056                .iter()
2057                .filter(|(onset, _)| (*onset - beat).abs() < EPSILON)
2058                .collect();
2059            if !authored_here.is_empty() {
2060                result.extend(
2061                    authored_here
2062                        .into_iter()
2063                        .map(|(_, label)| VerticalSonority {
2064                            measure: measure_index,
2065                            beat,
2066                            label: label.clone(),
2067                        }),
2068                );
2069                continue;
2070            }
2071            let active: Vec<_> = sounding
2072                .iter()
2073                .filter(|event| event.start <= beat + EPSILON && event.end > beat + EPSILON)
2074                .collect();
2075            let Some(pitches): Option<Vec<_>> = active
2076                .iter()
2077                .flat_map(|event| {
2078                    event
2079                        .note
2080                        .pitches
2081                        .iter()
2082                        .map(|pitch| concert_pitch(score, event, pitch, spelling_key))
2083                })
2084                .collect()
2085            else {
2086                continue;
2087            };
2088            let Some(chord) = detect_chord(&pitches) else {
2089                continue;
2090            };
2091            let Some(anchor) = active.iter().find(|event| {
2092                (event.start - beat).abs() < EPSILON
2093                    && has_new_pitched_attack(score, event, &starts, &tie_starts)
2094            }) else {
2095                continue;
2096            };
2097            result.push(VerticalSonority {
2098                measure: measure_index,
2099                beat,
2100                label: ChordLabel {
2101                    address: anchor.address.clone(),
2102                    name: chord_name(&chord),
2103                    roman_numeral: resolved_key
2104                        .as_ref()
2105                        .and_then(|key| roman_numeral_figured(&chord, key)),
2106                    chord,
2107                    confidence: 70,
2108                    rule_id: "vertical-sonority-template".to_string(),
2109                    evidence: active.iter().map(|event| event.address.clone()).collect(),
2110                },
2111            });
2112        }
2113    }
2114    VerticalAnalysisReport {
2115        sonorities: result,
2116        timeline_issues,
2117    }
2118}
2119
2120/// Analyze chord labels only inside an optional part/staff measure region.
2121pub fn analyze_chords_in_region(score: &Score, region: Option<&AnalysisRegion>) -> Vec<ChordLabel> {
2122    let mut chords = Vec::new();
2123    for (part_index, part) in score.parts.iter().enumerate() {
2124        for (staff_index, staff) in part.staves.iter().enumerate() {
2125            for (measure_index, measure) in staff.measures.iter().enumerate() {
2126                if let Some(region) = region
2127                    && (part_index != region.part
2128                        || staff_index != region.staff
2129                        || measure_index < region.start_measure
2130                        || measure_index >= region.end_measure)
2131                {
2132                    continue;
2133                }
2134                let key = measure
2135                    .key_sig
2136                    .as_ref()
2137                    .unwrap_or(&score.settings.key_signature);
2138                for (voice_index, voice) in measure.voices.iter().enumerate() {
2139                    let authored: Vec<_> = voice
2140                        .iter()
2141                        .enumerate()
2142                        .filter_map(|(note_index, note)| {
2143                            note.chord_symbol.as_ref().map(|chord| (note_index, chord))
2144                        })
2145                        .collect();
2146                    if !authored.is_empty() {
2147                        for (note_index, chord) in authored {
2148                            let address = NoteAddr {
2149                                part: part_index,
2150                                staff: staff_index,
2151                                measure: measure_index,
2152                                voice: voice_index,
2153                                note: note_index,
2154                            };
2155                            chords.push(ChordLabel {
2156                                address: address.clone(),
2157                                name: chord_name(chord),
2158                                roman_numeral: roman_numeral(chord, key),
2159                                chord: chord.clone(),
2160                                confidence: 100,
2161                                rule_id: "authored-chord-symbol".to_string(),
2162                                evidence: vec![address],
2163                            });
2164                        }
2165                        continue;
2166                    }
2167                    let pitched: Vec<_> = voice
2168                        .iter()
2169                        .enumerate()
2170                        .filter(|(_, note)| !note.is_rest && !note.pitches.is_empty())
2171                        .collect();
2172                    let pitches: Vec<_> = pitched
2173                        .iter()
2174                        .flat_map(|(_, note)| note.pitches.iter().cloned())
2175                        .collect();
2176                    let Some(chord) = detect_chord(&pitches) else {
2177                        continue;
2178                    };
2179                    let evidence = pitched
2180                        .iter()
2181                        .map(|(note_index, _)| NoteAddr {
2182                            part: part_index,
2183                            staff: staff_index,
2184                            measure: measure_index,
2185                            voice: voice_index,
2186                            note: *note_index,
2187                        })
2188                        .collect();
2189                    chords.push(ChordLabel {
2190                        address: NoteAddr {
2191                            part: part_index,
2192                            staff: staff_index,
2193                            measure: measure_index,
2194                            voice: voice_index,
2195                            note: pitched[0].0,
2196                        },
2197                        name: chord_name(&chord),
2198                        roman_numeral: roman_numeral(&chord, key),
2199                        chord,
2200                        confidence: 100,
2201                        rule_id: "pitch-class-template".to_string(),
2202                        evidence,
2203                    });
2204                }
2205            }
2206        }
2207    }
2208    chords
2209}
2210
2211/// Recompute only the requested categories and carry forward the remaining categories.
2212///
2213/// The caller must provide a result from the immediately preceding score state. Categories with
2214/// dependencies are expanded conservatively: cadence analysis refreshes chords, and SATB
2215/// diagnostics refresh voice-leading first. This is intended for editor-local incremental work.
2216pub fn analyze_selected_categories(
2217    score: &Score,
2218    previous: &AnalysisResult,
2219    categories: &[AnalysisCategory],
2220) -> AnalysisResult {
2221    analyze_selected_categories_in_region(score, previous, categories, None)
2222}
2223
2224/// Recompute selected categories with an optional bounded region for local passes.
2225pub fn analyze_selected_categories_in_region(
2226    score: &Score,
2227    previous: &AnalysisResult,
2228    categories: &[AnalysisCategory],
2229    region: Option<&AnalysisRegion>,
2230) -> AnalysisResult {
2231    let selected = |category| categories.contains(&category);
2232    let chords =
2233        if selected(AnalysisCategory::Chords) || selected(AnalysisCategory::CadenceCandidates) {
2234            let refreshed = analyze_chords_in_region(score, region);
2235            merge_chord_region(&previous.chords, refreshed, region)
2236        } else {
2237            previous.chords.clone()
2238        };
2239    let intervals = if selected(AnalysisCategory::Intervals) {
2240        merge_interval_region(
2241            &previous.intervals,
2242            analyze_intervals_in_region(score, region),
2243            region,
2244        )
2245    } else {
2246        previous.intervals.clone()
2247    };
2248    let key_estimates = if selected(AnalysisCategory::KeyEstimates) {
2249        estimate_keys(score)
2250    } else {
2251        previous.key_estimates.clone()
2252    };
2253    let cadence_candidates = if selected(AnalysisCategory::CadenceCandidates) {
2254        analyze_cadences(&chords)
2255    } else {
2256        previous.cadence_candidates.clone()
2257    };
2258    let voice_leading = if selected(AnalysisCategory::VoiceLeading)
2259        || selected(AnalysisCategory::SatbDiagnostics)
2260    {
2261        merge_voice_leading_region(
2262            &previous.voice_leading,
2263            analyze_voice_leading_in_region(score, region),
2264            region,
2265        )
2266    } else {
2267        previous.voice_leading.clone()
2268    };
2269    let satb_diagnostics = if selected(AnalysisCategory::SatbDiagnostics) {
2270        merge_satb_region(
2271            &previous.satb_diagnostics,
2272            analyze_satb_in_region(score, &voice_leading, region),
2273            region,
2274        )
2275    } else {
2276        previous.satb_diagnostics.clone()
2277    };
2278    let motifs = if selected(AnalysisCategory::Motifs) {
2279        analyze_motifs(score)
2280    } else {
2281        previous.motifs.clone()
2282    };
2283    let phrase_boundaries = if selected(AnalysisCategory::PhraseBoundaries) {
2284        analyze_phrase_boundaries(score)
2285    } else {
2286        previous.phrase_boundaries.clone()
2287    };
2288    AnalysisResult {
2289        schema_version: ANALYSIS_SCHEMA_VERSION,
2290        score_fingerprint: score_fingerprint(score),
2291        chords,
2292        intervals,
2293        key_estimates,
2294        cadence_candidates,
2295        voice_leading,
2296        satb_diagnostics,
2297        motifs,
2298        phrase_boundaries,
2299    }
2300}
2301
2302fn merge_interval_region(
2303    previous: &[IntervalObservation],
2304    refreshed: Vec<IntervalObservation>,
2305    region: Option<&AnalysisRegion>,
2306) -> Vec<IntervalObservation> {
2307    let Some(region) = region else {
2308        return refreshed;
2309    };
2310    let mut merged: Vec<_> = previous
2311        .iter()
2312        .filter(|item| {
2313            !analysis_region_contains(region, &item.from)
2314                && !analysis_region_contains(region, &item.to)
2315        })
2316        .cloned()
2317        .collect();
2318    merged.extend(refreshed);
2319    merged.sort_by_key(|item| {
2320        (
2321            item.from.part,
2322            item.from.staff,
2323            item.from.measure,
2324            item.from.voice,
2325            item.from.note,
2326            item.to.measure,
2327            item.to.note,
2328        )
2329    });
2330    merged
2331}
2332
2333fn merge_voice_leading_region(
2334    previous: &[VoiceLeadingObservation],
2335    refreshed: Vec<VoiceLeadingObservation>,
2336    region: Option<&AnalysisRegion>,
2337) -> Vec<VoiceLeadingObservation> {
2338    let Some(region) = region else {
2339        return refreshed;
2340    };
2341    let mut merged: Vec<_> = previous
2342        .iter()
2343        .filter(|item| {
2344            !analysis_region_contains(region, &item.upper)
2345                && !analysis_region_contains(region, &item.lower)
2346        })
2347        .cloned()
2348        .collect();
2349    merged.extend(refreshed);
2350    merged.sort_by_key(|item| {
2351        (
2352            item.upper.part,
2353            item.upper.staff,
2354            item.upper.measure,
2355            item.upper.voice,
2356            item.upper.note,
2357            item.lower.voice,
2358        )
2359    });
2360    merged
2361}
2362
2363fn merge_chord_region(
2364    previous: &[ChordLabel],
2365    refreshed: Vec<ChordLabel>,
2366    region: Option<&AnalysisRegion>,
2367) -> Vec<ChordLabel> {
2368    let Some(region) = region else {
2369        return refreshed;
2370    };
2371    let mut merged: Vec<_> = previous
2372        .iter()
2373        .filter(|label| !analysis_region_contains(region, &label.address))
2374        .cloned()
2375        .collect();
2376    merged.extend(refreshed);
2377    merged.sort_by_key(|label| {
2378        (
2379            label.address.part,
2380            label.address.staff,
2381            label.address.measure,
2382            label.address.voice,
2383            label.address.note,
2384        )
2385    });
2386    merged
2387}
2388
2389fn merge_satb_region(
2390    previous: &[SatbDiagnostic],
2391    refreshed: Vec<SatbDiagnostic>,
2392    region: Option<&AnalysisRegion>,
2393) -> Vec<SatbDiagnostic> {
2394    let Some(region) = region else {
2395        return refreshed;
2396    };
2397    let mut merged: Vec<_> = previous
2398        .iter()
2399        .filter(|diagnostic| {
2400            !diagnostic
2401                .evidence
2402                .iter()
2403                .any(|address| analysis_region_contains(region, address))
2404        })
2405        .cloned()
2406        .collect();
2407    merged.extend(refreshed);
2408    merged.sort_by_key(|diagnostic| {
2409        (
2410            diagnostic.upper.part,
2411            diagnostic.upper.staff,
2412            diagnostic.upper.measure,
2413            diagnostic.upper.voice,
2414            diagnostic.upper.note,
2415            diagnostic.lower.voice,
2416            diagnostic.rule_id.clone(),
2417        )
2418    });
2419    merged
2420}
2421
2422fn analysis_region_contains(region: &AnalysisRegion, address: &NoteAddr) -> bool {
2423    address.part == region.part
2424        && address.staff == region.staff
2425        && (region.start_measure..region.end_measure).contains(&address.measure)
2426}
2427
2428/// Return a deterministic, non-cryptographic fingerprint for a canonical score.
2429pub fn score_fingerprint(score: &Score) -> String {
2430    let mut value = serde_json::to_value(score).unwrap_or_default();
2431    remove_generated_ids(&mut value);
2432    let bytes = serde_json::to_vec(&value).unwrap_or_default();
2433    let hash = fnv1a64(&bytes);
2434    format!("fnv1a64-{hash:016x}")
2435}
2436
2437fn remove_generated_ids(value: &mut serde_json::Value) {
2438    match value {
2439        serde_json::Value::Object(object) => {
2440            object.remove("id");
2441            for child in object.values_mut() {
2442                remove_generated_ids(child);
2443            }
2444        }
2445        serde_json::Value::Array(values) => {
2446            for child in values {
2447                remove_generated_ids(child);
2448            }
2449        }
2450        _ => {}
2451    }
2452}
2453
2454/// Return the current schema-versioned cache key without running the analysis passes.
2455pub fn analysis_cache_key(score: &Score) -> String {
2456    format!(
2457        "analysis-v{}-{}",
2458        ANALYSIS_SCHEMA_VERSION,
2459        score_fingerprint(score)
2460    )
2461}
2462
2463/// Errors returned when configuring the bounded analysis cache.
2464#[derive(Debug, Error, Clone, PartialEq, Eq)]
2465pub enum AnalysisCacheError {
2466    #[error("analysis cache capacity must be greater than zero")]
2467    ZeroCapacity,
2468}
2469
2470/// Counters for measuring analysis-cache reuse.
2471#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2472pub struct AnalysisCacheStats {
2473    pub hits: usize,
2474    pub misses: usize,
2475}
2476
2477/// A deterministic, bounded cache for complete analysis results.
2478///
2479/// Entries are keyed by the schema-versioned canonical score fingerprint. A score edit therefore
2480/// naturally misses the old entry without requiring callers to diff JSON or manually invalidate
2481/// every analysis category. Eviction is insertion-order based rather than hash-map iteration
2482/// based, keeping behavior reproducible across hosts.
2483#[derive(Debug, Clone)]
2484pub struct AnalysisCache {
2485    capacity: usize,
2486    entries: BTreeMap<String, AnalysisResult>,
2487    insertion_order: VecDeque<String>,
2488    stats: AnalysisCacheStats,
2489}
2490
2491impl Default for AnalysisCache {
2492    fn default() -> Self {
2493        Self {
2494            capacity: 16,
2495            entries: BTreeMap::new(),
2496            insertion_order: VecDeque::new(),
2497            stats: AnalysisCacheStats::default(),
2498        }
2499    }
2500}
2501
2502impl AnalysisCache {
2503    /// Create a cache with a fixed maximum number of score results.
2504    pub fn with_capacity(capacity: usize) -> Result<Self, AnalysisCacheError> {
2505        if capacity == 0 {
2506            return Err(AnalysisCacheError::ZeroCapacity);
2507        }
2508        Ok(Self {
2509            capacity,
2510            entries: BTreeMap::new(),
2511            insertion_order: VecDeque::new(),
2512            stats: AnalysisCacheStats::default(),
2513        })
2514    }
2515
2516    /// Return the configured maximum number of cached results.
2517    pub fn capacity(&self) -> usize {
2518        self.capacity
2519    }
2520
2521    /// Return hit/miss counters since construction or the last reset.
2522    pub fn stats(&self) -> AnalysisCacheStats {
2523        self.stats
2524    }
2525
2526    /// Reset hit/miss counters without removing cached results.
2527    pub fn reset_stats(&mut self) {
2528        self.stats = AnalysisCacheStats::default();
2529    }
2530
2531    /// Return a cached result for the supplied score, if its canonical content is present.
2532    pub fn get(&self, score: &Score) -> Option<&AnalysisResult> {
2533        self.entries.get(&analysis_cache_key(score))
2534    }
2535
2536    /// Analyze a score once per cache key, returning a cloned result for ergonomic host use.
2537    pub fn analyze(&mut self, score: &Score) -> AnalysisResult {
2538        let key = analysis_cache_key(score);
2539        if let Some(result) = self.entries.get(&key) {
2540            self.stats.hits = self.stats.hits.saturating_add(1);
2541            return result.clone();
2542        }
2543
2544        self.stats.misses = self.stats.misses.saturating_add(1);
2545        let result = analyze_score(score);
2546        self.insert(key, result.clone());
2547        result
2548    }
2549
2550    /// Analyze scores in input order while reusing results already present in the cache.
2551    pub fn analyze_batch(&mut self, scores: &[Score]) -> Vec<AnalysisResult> {
2552        scores.iter().map(|score| self.analyze(score)).collect()
2553    }
2554
2555    /// Reclaim a previous editor snapshot and analyze its replacement in one operation.
2556    pub fn analyze_after_edit(&mut self, previous: &Score, current: &Score) -> AnalysisResult {
2557        if analysis_cache_key(previous) != analysis_cache_key(current) {
2558            self.invalidate(previous);
2559        }
2560        self.analyze(current)
2561    }
2562
2563    /// Re-analyze an edited score and return the category-level diff from the previous result.
2564    pub fn analyze_after_edit_with_diff(
2565        &mut self,
2566        previous_score: &Score,
2567        previous_result: &AnalysisResult,
2568        current: &Score,
2569    ) -> AnalysisEditResult {
2570        let analysis = self.analyze_after_edit(previous_score, current);
2571        let diff = diff_analysis(previous_result, &analysis);
2572        AnalysisEditResult { analysis, diff }
2573    }
2574
2575    /// Recompute selected categories after an edit and cache the merged complete result.
2576    pub fn analyze_selected_after_edit(
2577        &mut self,
2578        previous_score: &Score,
2579        previous_result: &AnalysisResult,
2580        current: &Score,
2581        categories: &[AnalysisCategory],
2582    ) -> AnalysisResult {
2583        let key = analysis_cache_key(current);
2584        if let Some(result) = self.entries.get(&key) {
2585            self.stats.hits = self.stats.hits.saturating_add(1);
2586            return result.clone();
2587        }
2588        self.stats.misses = self.stats.misses.saturating_add(1);
2589        if analysis_cache_key(previous_score) != key {
2590            self.invalidate(previous_score);
2591        }
2592        let result = analyze_selected_categories(current, previous_result, categories);
2593        self.insert(key, result.clone());
2594        result
2595    }
2596
2597    /// Recompute a refresh plan, using its region for local passes, and cache the merged result.
2598    pub fn analyze_after_edit_with_plan(
2599        &mut self,
2600        previous_score: &Score,
2601        previous_result: &AnalysisResult,
2602        current: &Score,
2603        plan: &AnalysisRefreshPlan,
2604    ) -> AnalysisEditResult {
2605        let categories: Vec<_> = plan
2606            .local_categories
2607            .iter()
2608            .chain(&plan.global_categories)
2609            .copied()
2610            .collect();
2611        let key = analysis_cache_key(current);
2612        let analysis = if let Some(result) = self.entries.get(&key) {
2613            self.stats.hits = self.stats.hits.saturating_add(1);
2614            result.clone()
2615        } else {
2616            self.stats.misses = self.stats.misses.saturating_add(1);
2617            if analysis_cache_key(previous_score) != key {
2618                self.invalidate(previous_score);
2619            }
2620            let result = analyze_selected_categories_in_region(
2621                current,
2622                previous_result,
2623                &categories,
2624                plan.region.as_ref(),
2625            );
2626            self.insert(key, result.clone());
2627            result
2628        };
2629        AnalysisEditResult {
2630            diff: diff_analysis(previous_result, &analysis),
2631            analysis,
2632        }
2633    }
2634
2635    /// Apply a change hint, recompute its affected categories, and return the result diff.
2636    pub fn analyze_after_edit_with_hint(
2637        &mut self,
2638        previous_score: &Score,
2639        previous_result: &AnalysisResult,
2640        current: &Score,
2641        hint: &ChangeHint,
2642    ) -> AnalysisEditResult {
2643        let plan = analysis_refresh_plan(hint);
2644        self.analyze_after_edit_with_plan(previous_score, previous_result, current, &plan)
2645    }
2646
2647    /// Insert a previously computed result and evict the oldest entry when the cache is full.
2648    pub fn insert(&mut self, key: String, result: AnalysisResult) {
2649        if self.entries.contains_key(&key) {
2650            self.entries.insert(key.clone(), result);
2651            self.insertion_order.retain(|existing| existing != &key);
2652        } else {
2653            self.entries.insert(key.clone(), result);
2654        }
2655        self.insertion_order.push_back(key);
2656        while self.entries.len() > self.capacity {
2657            if let Some(oldest) = self.insertion_order.pop_front() {
2658                self.entries.remove(&oldest);
2659            }
2660        }
2661    }
2662
2663    /// Remove the cached result for a score and return whether an entry was removed.
2664    pub fn invalidate(&mut self, score: &Score) -> bool {
2665        let key = analysis_cache_key(score);
2666        let removed = self.entries.remove(&key).is_some();
2667        if removed {
2668            self.insertion_order.retain(|existing| existing != &key);
2669        }
2670        removed
2671    }
2672
2673    /// Remove all cached results.
2674    pub fn clear(&mut self) {
2675        self.entries.clear();
2676        self.insertion_order.clear();
2677    }
2678
2679    /// Return the number of cached results.
2680    pub fn len(&self) -> usize {
2681        self.entries.len()
2682    }
2683
2684    /// Return whether the cache contains no results.
2685    pub fn is_empty(&self) -> bool {
2686        self.entries.is_empty()
2687    }
2688}
2689
2690fn fnv1a64(bytes: &[u8]) -> u64 {
2691    bytes.iter().fold(0xcbf29ce484222325u64, |hash, byte| {
2692        (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
2693    })
2694}
2695
2696impl AnalysisCounts {
2697    fn from_result(result: &AnalysisResult) -> Self {
2698        Self {
2699            chords: result.chords.len(),
2700            intervals: result.intervals.len(),
2701            key_estimates: result.key_estimates.len(),
2702            cadence_candidates: result.cadence_candidates.len(),
2703            voice_leading: result.voice_leading.len(),
2704            satb_diagnostics: result.satb_diagnostics.len(),
2705            motifs: result.motifs.len(),
2706            phrase_boundaries: result.phrase_boundaries.len(),
2707        }
2708    }
2709
2710    fn total(self) -> usize {
2711        self.chords
2712            + self.intervals
2713            + self.key_estimates
2714            + self.cadence_candidates
2715            + self.voice_leading
2716            + self.satb_diagnostics
2717            + self.motifs
2718            + self.phrase_boundaries
2719    }
2720
2721    fn explained(self, result: &AnalysisResult) -> usize {
2722        result
2723            .chords
2724            .iter()
2725            .filter(|item| !item.evidence.is_empty())
2726            .count()
2727            + result
2728                .intervals
2729                .iter()
2730                .filter(|item| !item.evidence.is_empty())
2731                .count()
2732            + result
2733                .key_estimates
2734                .iter()
2735                .filter(|item| !item.evidence.is_empty())
2736                .count()
2737            + result
2738                .cadence_candidates
2739                .iter()
2740                .filter(|item| !item.evidence.is_empty())
2741                .count()
2742            + result
2743                .voice_leading
2744                .iter()
2745                .filter(|item| !item.evidence.is_empty())
2746                .count()
2747            + result
2748                .satb_diagnostics
2749                .iter()
2750                .filter(|item| !item.evidence.is_empty())
2751                .count()
2752            + result
2753                .motifs
2754                .iter()
2755                .map(|item| {
2756                    item.occurrences
2757                        .iter()
2758                        .filter(|occurrence| !occurrence.evidence.is_empty())
2759                        .count()
2760                })
2761                .sum::<usize>()
2762            + result
2763                .phrase_boundaries
2764                .iter()
2765                .filter(|item| !item.evidence.is_empty())
2766                .count()
2767    }
2768}
2769
2770/// Run one offline benchmark case using count-based hand-verified annotations.
2771pub fn benchmark_case(case: &BenchmarkCase<'_>) -> BenchmarkCaseReport {
2772    let result = analyze_score(case.score);
2773    let predicted = AnalysisCounts::from_result(&result);
2774    let expected = case.expected;
2775    let matched = predicted.chords.min(expected.chords)
2776        + predicted.intervals.min(expected.intervals)
2777        + predicted.key_estimates.min(expected.key_estimates)
2778        + predicted
2779            .cadence_candidates
2780            .min(expected.cadence_candidates)
2781        + predicted.voice_leading.min(expected.voice_leading)
2782        + predicted.satb_diagnostics.min(expected.satb_diagnostics)
2783        + predicted.motifs.min(expected.motifs)
2784        + predicted.phrase_boundaries.min(expected.phrase_boundaries);
2785    let expected_total = AnalysisCounts {
2786        chords: expected.chords,
2787        intervals: expected.intervals,
2788        key_estimates: expected.key_estimates,
2789        cadence_candidates: expected.cadence_candidates,
2790        voice_leading: expected.voice_leading,
2791        satb_diagnostics: expected.satb_diagnostics,
2792        motifs: expected.motifs,
2793        phrase_boundaries: expected.phrase_boundaries,
2794    }
2795    .total();
2796    let predicted_total = predicted.total();
2797    let failures = [
2798        (BenchmarkCategory::Chords, expected.chords, predicted.chords),
2799        (
2800            BenchmarkCategory::Intervals,
2801            expected.intervals,
2802            predicted.intervals,
2803        ),
2804        (
2805            BenchmarkCategory::KeyEstimates,
2806            expected.key_estimates,
2807            predicted.key_estimates,
2808        ),
2809        (
2810            BenchmarkCategory::CadenceCandidates,
2811            expected.cadence_candidates,
2812            predicted.cadence_candidates,
2813        ),
2814        (
2815            BenchmarkCategory::VoiceLeading,
2816            expected.voice_leading,
2817            predicted.voice_leading,
2818        ),
2819        (
2820            BenchmarkCategory::SatbDiagnostics,
2821            expected.satb_diagnostics,
2822            predicted.satb_diagnostics,
2823        ),
2824        (BenchmarkCategory::Motifs, expected.motifs, predicted.motifs),
2825        (
2826            BenchmarkCategory::PhraseBoundaries,
2827            expected.phrase_boundaries,
2828            predicted.phrase_boundaries,
2829        ),
2830    ]
2831    .into_iter()
2832    .filter_map(|(category, expected, predicted)| {
2833        if expected == predicted {
2834            return None;
2835        }
2836        Some(BenchmarkFailure {
2837            category,
2838            expected,
2839            predicted,
2840            missing: expected.saturating_sub(predicted),
2841            excess: predicted.saturating_sub(expected),
2842        })
2843    })
2844    .collect();
2845    BenchmarkCaseReport {
2846        name: case.name.to_string(),
2847        predicted,
2848        expected,
2849        precision_percent: percentage(matched, predicted_total),
2850        recall_percent: percentage(matched, expected_total),
2851        explanation_completeness_percent: percentage(predicted.explained(&result), predicted_total),
2852        failures,
2853    }
2854}
2855
2856/// Run benchmark cases in input order; no filesystem or network access is used.
2857pub fn run_benchmark(cases: &[BenchmarkCase<'_>]) -> Vec<BenchmarkCaseReport> {
2858    cases.iter().map(benchmark_case).collect()
2859}
2860
2861/// Run a benchmark suite and aggregate its case-level metrics.
2862pub fn run_benchmark_suite(cases: &[BenchmarkCase<'_>]) -> BenchmarkSuiteReport {
2863    let reports = run_benchmark(cases);
2864    let case_count = reports.len();
2865    let passed_case_count = reports
2866        .iter()
2867        .filter(|report| report.failures.is_empty())
2868        .count();
2869    let failed_case_count = case_count.saturating_sub(passed_case_count);
2870    let precision_total: usize = reports
2871        .iter()
2872        .map(|report| usize::from(report.precision_percent))
2873        .sum();
2874    let recall_total: usize = reports
2875        .iter()
2876        .map(|report| usize::from(report.recall_percent))
2877        .sum();
2878    let explanation_total: usize = reports
2879        .iter()
2880        .map(|report| usize::from(report.explanation_completeness_percent))
2881        .sum();
2882    let metric_denominator = case_count.saturating_mul(100);
2883    let aggregate_metric = |total: usize| {
2884        if case_count == 0 {
2885            0
2886        } else {
2887            percentage(total, metric_denominator)
2888        }
2889    };
2890    BenchmarkSuiteReport {
2891        cases: reports,
2892        case_count,
2893        passed_case_count,
2894        failed_case_count,
2895        precision_percent: aggregate_metric(precision_total),
2896        recall_percent: aggregate_metric(recall_total),
2897        explanation_completeness_percent: aggregate_metric(explanation_total),
2898    }
2899}
2900
2901fn percentage(numerator: usize, denominator: usize) -> u8 {
2902    match numerator.saturating_mul(100).checked_div(denominator) {
2903        Some(value) => value.min(100) as u8,
2904        None => 100,
2905    }
2906}
2907
2908/// Analyze SATB constraints using the same aligned voice events as voice-leading analysis.
2909pub fn analyze_satb(score: &Score) -> Vec<SatbDiagnostic> {
2910    let voice_leading = analyze_voice_leading(score);
2911    analyze_satb_in_region(score, &voice_leading, None)
2912}
2913
2914/// Analyze SATB diagnostics inside an optional part/staff measure region.
2915pub fn analyze_satb_in_region(
2916    score: &Score,
2917    voice_leading: &[VoiceLeadingObservation],
2918    region: Option<&AnalysisRegion>,
2919) -> Vec<SatbDiagnostic> {
2920    const EPSILON: f64 = 1e-7;
2921    let mut diagnostics = Vec::new();
2922    for (part_index, part) in score.parts.iter().enumerate() {
2923        for (staff_index, staff) in part.staves.iter().enumerate() {
2924            for (measure_index, measure) in staff.measures.iter().enumerate() {
2925                if let Some(region) = region
2926                    && (part_index != region.part
2927                        || staff_index != region.staff
2928                        || measure_index < region.start_measure
2929                        || measure_index >= region.end_measure)
2930                {
2931                    continue;
2932                }
2933                let written =
2934                    written_staff_measure_events(score, part_index, staff_index, measure_index);
2935                for upper_index in 0..measure.voices.len().saturating_sub(1) {
2936                    let pair: Vec<_> = written
2937                        .iter()
2938                        .filter(|event| {
2939                            event.address.part == part_index
2940                                && event.address.staff == staff_index
2941                                && (event.address.voice == upper_index
2942                                    || event.address.voice == upper_index + 1)
2943                        })
2944                        .collect();
2945                    let mut onsets: Vec<_> = pair.iter().map(|event| event.start).collect();
2946                    onsets.sort_by(f64::total_cmp);
2947                    onsets.dedup_by(|left, right| (*left - *right).abs() < EPSILON);
2948                    for beat in onsets {
2949                        let active = |voice_index: usize| {
2950                            pair.iter()
2951                                .find(|event| {
2952                                    event.address.voice == voice_index
2953                                        && event.start <= beat + EPSILON
2954                                        && event.end > beat + EPSILON
2955                                        && !event.note.is_rest
2956                                        && !event.note.is_unpitched
2957                                })
2958                                .copied()
2959                        };
2960                        let (Some(upper_event), Some(lower_event)) =
2961                            (active(upper_index), active(upper_index + 1))
2962                        else {
2963                            continue;
2964                        };
2965                        let (Some(upper), Some(lower)) = (
2966                            upper_event.note.pitches.first(),
2967                            lower_event.note.pitches.first(),
2968                        ) else {
2969                            continue;
2970                        };
2971                        let upper_addr = upper_event.address.clone();
2972                        let lower_addr = lower_event.address.clone();
2973                        let distance = upper.to_midi() - lower.to_midi();
2974                        if distance < 0 {
2975                            diagnostics.push(satb_diagnostic(
2976                                upper_addr.clone(),
2977                                lower_addr.clone(),
2978                                SatbDiagnosticKind::VoiceCrossing,
2979                                SatbSeverity::Error,
2980                                "satb-voice-crossing",
2981                            ));
2982                        } else if distance > 24 {
2983                            diagnostics.push(satb_diagnostic(
2984                                upper_addr.clone(),
2985                                lower_addr.clone(),
2986                                SatbDiagnosticKind::WideSpacing,
2987                                SatbSeverity::Warning,
2988                                "satb-wide-spacing",
2989                            ));
2990                        }
2991                    }
2992                }
2993            }
2994        }
2995    }
2996    for observation in voice_leading {
2997        if let Some(region) = region
2998            && !observation
2999                .evidence
3000                .iter()
3001                .any(|address| analysis_region_contains(region, address))
3002        {
3003            continue;
3004        }
3005        if observation.parallel_perfect {
3006            diagnostics.push(satb_diagnostic(
3007                observation.upper.clone(),
3008                observation.lower.clone(),
3009                SatbDiagnosticKind::ParallelPerfect,
3010                SatbSeverity::Warning,
3011                "satb-parallel-perfect",
3012            ));
3013        }
3014    }
3015    diagnostics
3016}
3017
3018fn satb_diagnostic(
3019    upper: NoteAddr,
3020    lower: NoteAddr,
3021    kind: SatbDiagnosticKind,
3022    severity: SatbSeverity,
3023    rule_id: &str,
3024) -> SatbDiagnostic {
3025    SatbDiagnostic {
3026        evidence: vec![upper.clone(), lower.clone()],
3027        upper,
3028        lower,
3029        kind,
3030        severity,
3031        confidence: 100,
3032        rule_id: rule_id.to_string(),
3033    }
3034}
3035
3036/// Find repeated three-note melodic interval patterns, resetting at rests.
3037pub fn analyze_motifs(score: &Score) -> Vec<MotifPattern> {
3038    let mut groups: BTreeMap<(usize, usize, usize, Vec<i8>), Vec<MotifOccurrence>> =
3039        BTreeMap::new();
3040    for (part_index, part) in score.parts.iter().enumerate() {
3041        for (staff_index, staff) in part.staves.iter().enumerate() {
3042            let Some(first_measure) = staff.measures.first() else {
3043                continue;
3044            };
3045            for (voice_index, _) in first_measure.voices.iter().enumerate() {
3046                let mut segment = Vec::new();
3047                let mut segments = Vec::new();
3048                for (measure_index, measure) in staff.measures.iter().enumerate() {
3049                    for (note_index, note) in measure.voices[voice_index].iter().enumerate() {
3050                        let Some(pitch) = note.pitches.first() else {
3051                            if segment.len() >= 3 {
3052                                segments.push(std::mem::take(&mut segment));
3053                            } else {
3054                                segment.clear();
3055                            }
3056                            continue;
3057                        };
3058                        if note.is_rest {
3059                            if segment.len() >= 3 {
3060                                segments.push(std::mem::take(&mut segment));
3061                            } else {
3062                                segment.clear();
3063                            }
3064                            continue;
3065                        }
3066                        segment.push((
3067                            NoteAddr {
3068                                part: part_index,
3069                                staff: staff_index,
3070                                measure: measure_index,
3071                                voice: voice_index,
3072                                note: note_index,
3073                            },
3074                            pitch.to_midi(),
3075                        ));
3076                    }
3077                }
3078                if segment.len() >= 3 {
3079                    segments.push(segment);
3080                }
3081                for segment in segments {
3082                    for window in segment.windows(3) {
3083                        let signature = vec![
3084                            (window[1].1 - window[0].1) as i8,
3085                            (window[2].1 - window[1].1) as i8,
3086                        ];
3087                        let occurrence = MotifOccurrence {
3088                            start: window[0].0.clone(),
3089                            end: window[2].0.clone(),
3090                            evidence: window.iter().map(|(address, _)| address.clone()).collect(),
3091                        };
3092                        groups
3093                            .entry((part_index, staff_index, voice_index, signature))
3094                            .or_default()
3095                            .push(occurrence);
3096                    }
3097                }
3098            }
3099        }
3100    }
3101    groups
3102        .into_iter()
3103        .filter(|(_, occurrences)| occurrences.len() >= 2)
3104        .map(|((_, _, _, signature), occurrences)| MotifPattern {
3105            signature,
3106            occurrences,
3107            confidence: 100,
3108            rule_id: "repeated-three-note-interval-pattern".to_string(),
3109        })
3110        .collect()
3111}
3112
3113/// Report measure-ending rests as explicit, conservative phrase boundaries.
3114pub fn analyze_phrase_boundaries(score: &Score) -> Vec<PhraseBoundary> {
3115    let mut boundaries = Vec::new();
3116    for (part_index, part) in score.parts.iter().enumerate() {
3117        for (staff_index, staff) in part.staves.iter().enumerate() {
3118            for (measure_index, measure) in staff.measures.iter().enumerate() {
3119                for (voice_index, voice) in measure.voices.iter().enumerate() {
3120                    let Some((note_index, note)) = voice.iter().enumerate().next_back() else {
3121                        continue;
3122                    };
3123                    if !note.is_rest {
3124                        continue;
3125                    }
3126                    let address = NoteAddr {
3127                        part: part_index,
3128                        staff: staff_index,
3129                        measure: measure_index,
3130                        voice: voice_index,
3131                        note: note_index,
3132                    };
3133                    boundaries.push(PhraseBoundary {
3134                        address: address.clone(),
3135                        reason: PhraseBoundaryReason::RestTermination,
3136                        confidence: 100,
3137                        rule_id: "measure-ending-rest".to_string(),
3138                        evidence: vec![address],
3139                    });
3140                }
3141            }
3142        }
3143    }
3144    boundaries
3145}
3146
3147/// Find explicit cadence transitions in the order they occur within each voice.
3148pub fn analyze_cadences(chords: &[ChordLabel]) -> Vec<CadenceCandidate> {
3149    let mut candidates = Vec::new();
3150    let mut previous: HashMap<(usize, usize, usize), &ChordLabel> = HashMap::new();
3151    for chord in chords {
3152        let key = (chord.address.part, chord.address.staff, chord.address.voice);
3153        let Some(previous_chord) = previous.insert(key, chord) else {
3154            continue;
3155        };
3156        let Some(from_roman) = previous_chord.roman_numeral.as_deref() else {
3157            continue;
3158        };
3159        let Some(to_roman) = chord.roman_numeral.as_deref() else {
3160            continue;
3161        };
3162        let from_figure = roman_figure(from_roman);
3163        let to_figure = roman_figure(to_roman);
3164        let kind = match (from_figure, to_figure) {
3165            ("V" | "V7", "I") => CadenceKind::Authentic,
3166            ("IV", "I") => CadenceKind::Plagal,
3167            ("V" | "V7", "vi") => CadenceKind::Deceptive,
3168            (_, "V" | "V7") => CadenceKind::Half,
3169            _ => continue,
3170        };
3171        let evidence = vec![previous_chord.address.clone(), chord.address.clone()];
3172        candidates.push(CadenceCandidate {
3173            from: previous_chord.address.clone(),
3174            to: chord.address.clone(),
3175            kind,
3176            confidence: 100,
3177            rule_id: "roman-numeral-cadence-transition".to_string(),
3178            evidence,
3179        });
3180    }
3181    candidates
3182}
3183
3184fn roman_figure(roman: &str) -> &str {
3185    roman.find('/').map_or(roman, |index| &roman[..index])
3186}
3187
3188/// Locate two-voice quartets across parts, staves, and physical bar lines.
3189///
3190/// A voice is identified by its part/staff/voice indices. Only successive
3191/// simultaneous states are compared, and neither voice may have a gap between
3192/// them. Each state needs exactly one written pitch per voice. Source order is
3193/// retained so a caller can pass the same four pitches to another analyzer.
3194pub fn analyze_voice_leading_quartets(score: &Score) -> Vec<VoiceLeadingQuartet> {
3195    const EPSILON: f64 = 1e-7;
3196    let starts = vertical_measure_starts(score);
3197    let mut voices: BTreeMap<(usize, usize, usize), Vec<QuartetNote>> = BTreeMap::new();
3198    for (part_index, part) in score.parts.iter().enumerate() {
3199        for (staff_index, staff) in part.staves.iter().enumerate() {
3200            for (measure_index, measure) in staff.measures.iter().enumerate() {
3201                let bar_start = starts[part_index][staff_index][measure_index];
3202                let bar_length = staff.measure_beats(measure_index, &score.settings.time_signature);
3203                if !bar_start.is_finite() || !bar_length.is_finite() || bar_length <= 0.0 {
3204                    continue;
3205                }
3206                let written =
3207                    written_staff_measure_events(score, part_index, staff_index, measure_index);
3208                for (voice_index, voice) in measure.voices.iter().enumerate() {
3209                    let events: Vec<_> = written
3210                        .iter()
3211                        .filter(|event| event.address.voice == voice_index)
3212                        .collect();
3213                    // The shared traversal omits invalid-duration events. Reject the
3214                    // whole voice bar so an omission cannot shift later onsets.
3215                    if events.len() != voice.len()
3216                        || events.iter().any(|event| {
3217                            event.end > bar_length + EPSILON
3218                                || event.start < -EPSILON
3219                                || event.end < event.start - EPSILON
3220                        })
3221                    {
3222                        continue;
3223                    }
3224                    let stream = voices
3225                        .entry((part_index, staff_index, voice_index))
3226                        .or_default();
3227                    for event in events {
3228                        if event.note.is_rest
3229                            || event.note.is_unpitched
3230                            || event.note.pitches.len() != 1
3231                            || event.end <= event.start + EPSILON
3232                        {
3233                            continue;
3234                        }
3235                        stream.push(QuartetNote {
3236                            address: event.address.clone(),
3237                            pitch: event.note.pitches[0].clone(),
3238                            onset_beats: bar_start + event.start,
3239                            duration_beats: event.end - event.start,
3240                        });
3241                    }
3242                }
3243            }
3244        }
3245    }
3246
3247    let streams: Vec<_> = voices
3248        .into_values()
3249        .filter(|voice| !voice.is_empty())
3250        .collect();
3251    let mut quartets = Vec::new();
3252    for (first_index, first) in streams.iter().enumerate() {
3253        for second in streams.iter().skip(first_index + 1) {
3254            let mut onsets: Vec<_> = first
3255                .iter()
3256                .chain(second.iter())
3257                .map(|note| note.onset_beats)
3258                .collect();
3259            onsets.sort_by(f64::total_cmp);
3260            onsets.dedup_by(|left, right| (*left - *right).abs() < EPSILON);
3261            let mut first_index = 0;
3262            let mut second_index = 0;
3263            let mut previous: Option<(&QuartetNote, &QuartetNote)> = None;
3264            for beat in onsets {
3265                while first_index + 1 < first.len()
3266                    && first[first_index + 1].onset_beats <= beat + EPSILON
3267                {
3268                    first_index += 1;
3269                }
3270                while second_index + 1 < second.len()
3271                    && second[second_index + 1].onset_beats <= beat + EPSILON
3272                {
3273                    second_index += 1;
3274                }
3275                let current =
3276                    first
3277                        .get(first_index)
3278                        .zip(second.get(second_index))
3279                        .filter(|(a, b)| {
3280                            a.onset_beats <= beat + EPSILON
3281                                && b.onset_beats <= beat + EPSILON
3282                                && a.onset_beats + a.duration_beats > beat + EPSILON
3283                                && b.onset_beats + b.duration_beats > beat + EPSILON
3284                        });
3285                let Some((first_after, second_after)) = current else {
3286                    previous = None;
3287                    continue;
3288                };
3289                if let Some((first_before, second_before)) = previous
3290                    && (first_before.address != first_after.address
3291                        || second_before.address != second_after.address)
3292                    && first_before.onset_beats + first_before.duration_beats + EPSILON >= beat
3293                    && second_before.onset_beats + second_before.duration_beats + EPSILON >= beat
3294                {
3295                    let first_motion =
3296                        first_after.pitch.to_midi_cents() - first_before.pitch.to_midi_cents();
3297                    let second_motion =
3298                        second_after.pitch.to_midi_cents() - second_before.pitch.to_midi_cents();
3299                    let similar_motion = first_motion != 0
3300                        && second_motion != 0
3301                        && first_motion.signum() == second_motion.signum();
3302                    let contrary_motion = first_motion != 0
3303                        && second_motion != 0
3304                        && first_motion.signum() != second_motion.signum();
3305                    let parallel_fifth = (similar_motion || contrary_motion)
3306                        && spelled_perfect_interval(&first_before.pitch, &second_before.pitch, 5)
3307                        && spelled_perfect_interval(&first_after.pitch, &second_after.pitch, 5);
3308                    let parallel_octave = (similar_motion || contrary_motion)
3309                        && spelled_perfect_interval(&first_before.pitch, &second_before.pitch, 8)
3310                        && spelled_perfect_interval(&first_after.pitch, &second_after.pitch, 8);
3311                    let proper_resolution_without_key = keyless_quartet_resolution(
3312                        &first_before.pitch,
3313                        &first_after.pitch,
3314                        &second_before.pitch,
3315                        &second_after.pitch,
3316                        first_motion,
3317                        second_motion,
3318                    );
3319                    quartets.push(VoiceLeadingQuartet {
3320                        first_before: first_before.clone(),
3321                        first_after: first_after.clone(),
3322                        second_before: second_before.clone(),
3323                        second_after: second_after.clone(),
3324                        contrary_motion,
3325                        similar_motion,
3326                        parallel_fifth,
3327                        parallel_octave,
3328                        proper_resolution_without_key,
3329                        rule_id: "located-written-voice-quartet".to_string(),
3330                        evidence: vec![
3331                            first_before.address.clone(),
3332                            first_after.address.clone(),
3333                            second_before.address.clone(),
3334                            second_after.address.clone(),
3335                        ],
3336                    });
3337                }
3338                previous = Some((first_after, second_after));
3339            }
3340        }
3341    }
3342    quartets
3343}
3344
3345fn spelled_perfect_interval(a: &Pitch, b: &Pitch, number: u8) -> bool {
3346    let diatonic = quartet_diatonic_distance(a, b);
3347    let cents = (a.to_midi_cents() - b.to_midi_cents()).abs();
3348    match number {
3349        5 => diatonic % 7 == 4 && cents % 1200 == 700,
3350        8 => diatonic >= 7 && diatonic % 7 == 0 && cents >= 1200 && cents % 1200 == 0,
3351        _ => false,
3352    }
3353}
3354
3355fn keyless_quartet_resolution(
3356    first_before: &Pitch,
3357    first_after: &Pitch,
3358    second_before: &Pitch,
3359    second_after: &Pitch,
3360    first_motion: i32,
3361    second_motion: i32,
3362) -> bool {
3363    if first_motion == 0 && second_motion == 0 {
3364        return true;
3365    }
3366    let initial_diatonic = quartet_diatonic_distance(first_before, second_before) % 7;
3367    let initial_cents = (first_before.to_midi_cents() - second_before.to_midi_cents()).abs() % 1200;
3368    let final_generic = quartet_diatonic_distance(first_after, second_after) % 7 + 1;
3369    match (initial_diatonic, initial_cents) {
3370        (3, 500) => first_motion <= 0, // P4: first voice stays or descends.
3371        (3, 600) => first_motion > 0 && second_motion < 0 && final_generic == 6, // A4.
3372        (4, 600) => first_motion < 0 && second_motion > 0 && final_generic == 3, // d5.
3373        (6, 1000) => final_generic == 3, // m7.
3374        _ => true,
3375    }
3376}
3377
3378fn quartet_diatonic_distance(a: &Pitch, b: &Pitch) -> i32 {
3379    fn index(step: &Step) -> i32 {
3380        match step {
3381            Step::C => 0,
3382            Step::D => 1,
3383            Step::E => 2,
3384            Step::F => 3,
3385            Step::G => 4,
3386            Step::A => 5,
3387            Step::B => 6,
3388        }
3389    }
3390    ((i32::from(a.octave) - i32::from(b.octave)) * 7 + index(&a.step) - index(&b.step)).abs()
3391}
3392
3393/// Check simultaneously sounding notes in adjacent voices for motion and parallel intervals.
3394pub fn analyze_voice_leading(score: &Score) -> Vec<VoiceLeadingObservation> {
3395    analyze_voice_leading_in_region(score, None)
3396}
3397
3398/// Analyze voice-leading observations inside an optional part/staff measure region.
3399pub fn analyze_voice_leading_in_region(
3400    score: &Score,
3401    region: Option<&AnalysisRegion>,
3402) -> Vec<VoiceLeadingObservation> {
3403    const EPSILON: f64 = 1e-7;
3404    let mut observations = Vec::new();
3405    for (part_index, part) in score.parts.iter().enumerate() {
3406        for (staff_index, staff) in part.staves.iter().enumerate() {
3407            for (measure_index, measure) in staff.measures.iter().enumerate() {
3408                if let Some(region) = region
3409                    && (part_index != region.part
3410                        || staff_index != region.staff
3411                        || measure_index < region.start_measure
3412                        || measure_index >= region.end_measure)
3413                {
3414                    continue;
3415                }
3416                let written =
3417                    written_staff_measure_events(score, part_index, staff_index, measure_index);
3418                for upper_index in 0..measure.voices.len().saturating_sub(1) {
3419                    let pair: Vec<_> = written
3420                        .iter()
3421                        .filter(|event| {
3422                            event.address.part == part_index
3423                                && event.address.staff == staff_index
3424                                && (event.address.voice == upper_index
3425                                    || event.address.voice == upper_index + 1)
3426                        })
3427                        .collect();
3428                    let mut onsets: Vec<_> = pair.iter().map(|event| event.start).collect();
3429                    onsets.sort_by(f64::total_cmp);
3430                    onsets.dedup_by(|left, right| (*left - *right).abs() < EPSILON);
3431                    let active = |voice_index: usize, beat: f64| {
3432                        pair.iter()
3433                            .find(|event| {
3434                                event.address.voice == voice_index
3435                                    && event.start <= beat + EPSILON
3436                                    && event.end > beat + EPSILON
3437                                    && !event.note.is_rest
3438                                    && !event.note.is_unpitched
3439                                    && !event.note.pitches.is_empty()
3440                            })
3441                            .copied()
3442                    };
3443                    let mut previous = None;
3444                    for beat in onsets {
3445                        let (Some(upper), Some(lower)) =
3446                            (active(upper_index, beat), active(upper_index + 1, beat))
3447                        else {
3448                            previous = None;
3449                            continue;
3450                        };
3451                        if let Some((prev_upper, prev_lower)) = previous {
3452                            let prev_upper: &WrittenEvent<'_> = prev_upper;
3453                            let prev_lower: &WrittenEvent<'_> = prev_lower;
3454                            if prev_upper.end + EPSILON >= beat
3455                                && prev_lower.end + EPSILON >= beat
3456                                && (prev_upper.address != upper.address
3457                                    || prev_lower.address != lower.address)
3458                            {
3459                                let (Some(u0), Some(l0), Some(u1), Some(l1)) = (
3460                                    prev_upper.note.pitches.first(),
3461                                    prev_lower.note.pitches.first(),
3462                                    upper.note.pitches.first(),
3463                                    lower.note.pitches.first(),
3464                                ) else {
3465                                    previous = Some((upper, lower));
3466                                    continue;
3467                                };
3468                                let upper_motion_cents = u1.to_midi_cents() - u0.to_midi_cents();
3469                                let lower_motion_cents = l1.to_midi_cents() - l0.to_midi_cents();
3470                                let initial =
3471                                    (u0.to_midi_cents() - l0.to_midi_cents()).unsigned_abs() % 1200;
3472                                let next =
3473                                    (u1.to_midi_cents() - l1.to_midi_cents()).unsigned_abs() % 1200;
3474                                observations.push(VoiceLeadingObservation {
3475                                    upper: prev_upper.address.clone(),
3476                                    lower: prev_lower.address.clone(),
3477                                    upper_motion: u1.to_midi() - u0.to_midi(),
3478                                    lower_motion: l1.to_midi() - l0.to_midi(),
3479                                    upper_motion_cents,
3480                                    lower_motion_cents,
3481                                    parallel_perfect: matches!(initial, 0 | 700)
3482                                        && initial == next
3483                                        && upper_motion_cents != 0
3484                                        && upper_motion_cents.signum()
3485                                            == lower_motion_cents.signum(),
3486                                    confidence: 100,
3487                                    rule_id: "time-aligned-adjacent-voice-leading".to_string(),
3488                                    evidence: vec![
3489                                        prev_upper.address.clone(),
3490                                        prev_lower.address.clone(),
3491                                    ],
3492                                });
3493                            }
3494                        }
3495                        previous = Some((upper, lower));
3496                    }
3497                }
3498            }
3499        }
3500    }
3501    observations
3502}
3503
3504/// Backwards-compatible name for the complete score analysis pass.
3505pub fn analyze_chords(score: &Score) -> AnalysisResult {
3506    analyze_score(score)
3507}
3508
3509/// Analyze a finite batch in input order.
3510pub fn analyze_batch(scores: &[Score]) -> Vec<AnalysisResult> {
3511    scores.iter().map(analyze_score).collect()
3512}
3513
3514/// Analyze scores lazily, one result per input score.
3515pub fn analyze_stream<I>(scores: I) -> impl Iterator<Item = AnalysisResult>
3516where
3517    I: IntoIterator<Item = Score>,
3518{
3519    scores.into_iter().map(|score| analyze_score(&score))
3520}
3521
3522/// Estimate major/minor keys from pitch coverage, preserving tied candidates.
3523pub fn estimate_keys(score: &Score) -> Vec<KeyEstimate> {
3524    estimate_key_candidates(score, true)
3525}
3526
3527fn estimate_key_candidates(score: &Score, only_best_coverage: bool) -> Vec<KeyEstimate> {
3528    let mut pitches = Vec::new();
3529    let mut evidence = Vec::new();
3530    for (part_index, part) in score.parts.iter().enumerate() {
3531        for (staff_index, staff) in part.staves.iter().enumerate() {
3532            for (measure_index, measure) in staff.measures.iter().enumerate() {
3533                for (voice_index, voice) in measure.voices.iter().enumerate() {
3534                    for (note_index, note) in voice.iter().enumerate() {
3535                        if note.is_rest {
3536                            continue;
3537                        }
3538                        let duration_beats = note.beats().max(0.0);
3539                        pitches.extend(note.pitches.iter().map(|pitch| (pitch, duration_beats)));
3540                        if !note.pitches.is_empty() {
3541                            evidence.push(NoteAddr {
3542                                part: part_index,
3543                                staff: staff_index,
3544                                measure: measure_index,
3545                                voice: voice_index,
3546                                note: note_index,
3547                            });
3548                        }
3549                    }
3550                }
3551            }
3552        }
3553    }
3554    if pitches.is_empty() {
3555        return Vec::new();
3556    }
3557    let total_pitches = pitches.len();
3558    let total_duration_beats: f64 = pitches.iter().map(|(_, beats)| *beats).sum();
3559    let mut candidates = Vec::with_capacity(30);
3560    for fifths in -7..=7 {
3561        for mode in ["major", "minor"] {
3562            let key = KeySignature {
3563                fifths,
3564                mode: mode.to_string(),
3565            };
3566            let covered = pitches
3567                .iter()
3568                .filter(|(pitch, _)| key.contains_pitch(pitch))
3569                .count();
3570            let weighted_covered_beats = pitches
3571                .iter()
3572                .filter(|(pitch, _)| key.contains_pitch(pitch))
3573                .map(|(_, beats)| *beats)
3574                .sum::<f64>();
3575            candidates.push((key, covered, weighted_covered_beats));
3576        }
3577    }
3578    candidates.sort_by(
3579        |(left_key, left_score, left_weight), (right_key, right_score, right_weight)| {
3580            right_weight
3581                .total_cmp(left_weight)
3582                .then_with(|| right_score.cmp(left_score))
3583                .then_with(|| left_key.fifths.abs().cmp(&right_key.fifths.abs()))
3584                .then_with(|| left_key.fifths.cmp(&right_key.fifths))
3585                .then_with(|| left_key.mode.cmp(&right_key.mode))
3586        },
3587    );
3588    let best_weight = candidates[0].2;
3589    candidates
3590        .into_iter()
3591        .take_while(|(_, _, weighted)| {
3592            !only_best_coverage || weighted.total_cmp(&best_weight).is_eq()
3593        })
3594        .map(
3595            |(key, covered_pitches, weighted_covered_beats)| KeyEstimate {
3596                key,
3597                covered_pitches,
3598                total_pitches,
3599                weighted_covered_beats,
3600                total_duration_beats,
3601                confidence: if total_duration_beats > 0.0 {
3602                    ((weighted_covered_beats / total_duration_beats * 100.0).round() as u8).min(100)
3603                } else {
3604                    0
3605                },
3606                rule_id: "duration-weighted-diatonic-pitch-coverage".to_string(),
3607                evidence: evidence.clone(),
3608            },
3609        )
3610        .collect()
3611}
3612
3613/// Rank the equal-coverage candidates from [`estimate_keys`] by tonal profile.
3614///
3615/// This opt-in ranking preserves the candidate set and the legacy coverage
3616/// `confidence` value. The first candidate is a profile preference, not a
3617/// probability or a claim that a score has one unambiguous tonal center.
3618/// Microtonal input leaves the original order unchanged.
3619pub fn estimate_keys_with_tonal_profile(score: &Score) -> Vec<KeyEstimate> {
3620    let mut estimates = estimate_keys(score);
3621    rank_key_estimates_by_tonal_profile(
3622        score,
3623        &mut estimates,
3624        &KRUMHANSL_MAJOR,
3625        &KRUMHANSL_MINOR,
3626        "duration-weighted-diatonic-pitch-coverage+krumhansl-profile-ranking",
3627    );
3628    estimates
3629}
3630
3631/// Rank all major/minor key spellings by the Aarden-Essen tonal profile.
3632///
3633/// This uses the profile behind music21 10.5.2's `analyze("key")`, while
3634/// preserving Acorde's own input model and key spellings. Unlike
3635/// [`estimate_keys_with_tonal_profile`], chromatic notes do not discard a key
3636/// before the profile is evaluated. `confidence` still means diatonic coverage,
3637/// not probability. Microtonal or uniform input falls back to the legacy set.
3638pub fn estimate_keys_with_aarden_essen_profile(score: &Score) -> Vec<KeyEstimate> {
3639    let mut estimates = estimate_key_candidates(score, false);
3640    if !rank_key_estimates_by_tonal_profile(
3641        score,
3642        &mut estimates,
3643        &AARDEN_ESSEN_MAJOR,
3644        &AARDEN_ESSEN_MINOR,
3645        "duration-weighted-aarden-essen-profile-ranking",
3646    ) {
3647        return estimate_keys(score);
3648    }
3649    estimates
3650}
3651
3652// Major/minor weights are indexed from the tonic. The Aarden-Essen constants
3653// match music21 10.5.2's AardenEssen.getWeights implementation.
3654const KRUMHANSL_MAJOR: [f64; 12] = [
3655    6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88,
3656];
3657const KRUMHANSL_MINOR: [f64; 12] = [
3658    6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17,
3659];
3660const AARDEN_ESSEN_MAJOR: [f64; 12] = [
3661    17.7661, 0.145624, 14.9265, 0.160186, 19.8049, 11.3587, 0.291248, 22.062, 0.145624, 8.15494,
3662    0.232998, 4.95122,
3663];
3664const AARDEN_ESSEN_MINOR: [f64; 12] = [
3665    18.2648, 0.737619, 14.0499, 16.8599, 0.702494, 14.4362, 0.702494, 18.6161, 4.56621, 1.93186,
3666    7.37619, 1.75623,
3667];
3668
3669fn rank_key_estimates_by_tonal_profile(
3670    score: &Score,
3671    estimates: &mut [KeyEstimate],
3672    major: &[f64; 12],
3673    minor: &[f64; 12],
3674    rule_id: &str,
3675) -> bool {
3676    if estimates.len() < 2 {
3677        return false;
3678    }
3679
3680    let mut pitch_classes = [0.0_f64; 12];
3681    for part in &score.parts {
3682        for staff in &part.staves {
3683            for measure in &staff.measures {
3684                for voice in &measure.voices {
3685                    for note in voice {
3686                        if note.is_rest {
3687                            continue;
3688                        }
3689                        let beats = note.beats();
3690                        if !beats.is_finite() || beats <= 0.0 {
3691                            continue;
3692                        }
3693                        for pitch in &note.pitches {
3694                            if pitch.microtone_cents != 0 {
3695                                return false;
3696                            }
3697                            pitch_classes[pitch.to_midi().rem_euclid(12) as usize] += beats;
3698                        }
3699                    }
3700                }
3701            }
3702        }
3703    }
3704    let observed_mean = pitch_classes.iter().sum::<f64>() / 12.0;
3705    let observed_variance = pitch_classes
3706        .iter()
3707        .map(|value| (value - observed_mean).powi(2))
3708        .sum::<f64>();
3709    if observed_variance <= 0.0 {
3710        return false;
3711    }
3712    let correlation = |estimate: &KeyEstimate| {
3713        let weights = if estimate.key.mode == "minor" {
3714            minor
3715        } else {
3716            major
3717        };
3718        let tonic = (estimate.key.fifths * 7 + if estimate.key.mode == "minor" { 9 } else { 0 })
3719            .rem_euclid(12) as usize;
3720        let profile_mean = weights.iter().sum::<f64>() / 12.0;
3721        let profile_variance = weights
3722            .iter()
3723            .map(|value| (value - profile_mean).powi(2))
3724            .sum::<f64>();
3725        let covariance = pitch_classes
3726            .iter()
3727            .enumerate()
3728            .map(|(class, value)| {
3729                (value - observed_mean) * (weights[(class + 12 - tonic) % 12] - profile_mean)
3730            })
3731            .sum::<f64>();
3732        covariance / (observed_variance * profile_variance).sqrt()
3733    };
3734    estimates.sort_by(|left, right| correlation(right).total_cmp(&correlation(left)));
3735    for estimate in estimates {
3736        estimate.rule_id = rule_id.to_string();
3737    }
3738    true
3739}
3740
3741/// Estimate a key for each physical measure from a centered pitch-class window.
3742///
3743/// `radius` counts measures on either side. Durations in all parts and staves
3744/// contribute equally; the first staff's written key signature breaks ties
3745/// between enharmonic spellings. The result has one position per measure, so
3746/// it cannot resolve a modulation within a measure. Empty or uniform windows
3747/// yield no estimate. The correlation is not a confidence probability.
3748pub fn estimate_local_keys_with_aarden_essen_profile(
3749    score: &Score,
3750    radius: usize,
3751) -> Vec<LocalKeyEstimate> {
3752    let measure_count = score
3753        .parts
3754        .iter()
3755        .flat_map(|part| &part.staves)
3756        .map(|staff| staff.measures.len())
3757        .max()
3758        .unwrap_or(0);
3759    let mut histograms = vec![[0.0_f64; 12]; measure_count];
3760    for part in &score.parts {
3761        for staff in &part.staves {
3762            for (index, measure) in staff.measures.iter().enumerate() {
3763                for voice in &measure.voices {
3764                    for note in voice {
3765                        if note.is_rest || note.is_unpitched {
3766                            continue;
3767                        }
3768                        let beats = note.beats();
3769                        if !beats.is_finite() || beats <= 0.0 {
3770                            continue;
3771                        }
3772                        for pitch in &note.pitches {
3773                            if pitch.microtone_cents == 0 {
3774                                histograms[index][pitch.to_midi().rem_euclid(12) as usize] += beats;
3775                            }
3776                        }
3777                    }
3778                }
3779            }
3780        }
3781    }
3782    let written_measures = score
3783        .parts
3784        .first()
3785        .and_then(|part| part.staves.first())
3786        .map(|staff| staff.measures.as_slice())
3787        .unwrap_or(&[]);
3788    let mut reference_fifths = 0_i8;
3789    let mut estimates = Vec::with_capacity(measure_count);
3790    for measure in 0..measure_count {
3791        if let Some(key) = written_measures
3792            .get(measure)
3793            .and_then(|written| written.key_sig.as_ref())
3794        {
3795            reference_fifths = key.fifths;
3796        }
3797        let start = measure.saturating_sub(radius);
3798        let end = measure
3799            .saturating_add(radius)
3800            .saturating_add(1)
3801            .min(measure_count);
3802        let mut observed = [0.0_f64; 12];
3803        for histogram in &histograms[start..end] {
3804            for (class, weight) in histogram.iter().enumerate() {
3805                observed[class] += weight;
3806            }
3807        }
3808        let observed_mean = observed.iter().sum::<f64>() / 12.0;
3809        let observed_variance = observed
3810            .iter()
3811            .map(|weight| (weight - observed_mean).powi(2))
3812            .sum::<f64>();
3813        if observed_variance <= 0.0 {
3814            continue;
3815        }
3816        let mut best: Option<(f64, i8, &str)> = None;
3817        for fifths in -7_i8..=7 {
3818            for mode in ["major", "minor"] {
3819                let weights = if mode == "minor" {
3820                    &AARDEN_ESSEN_MINOR
3821                } else {
3822                    &AARDEN_ESSEN_MAJOR
3823                };
3824                let tonic = (i32::from(fifths) * 7 + if mode == "minor" { 9 } else { 0 })
3825                    .rem_euclid(12) as usize;
3826                let profile_mean = weights.iter().sum::<f64>() / 12.0;
3827                let profile_variance = weights
3828                    .iter()
3829                    .map(|weight| (weight - profile_mean).powi(2))
3830                    .sum::<f64>();
3831                let covariance = observed
3832                    .iter()
3833                    .enumerate()
3834                    .map(|(class, weight)| {
3835                        (weight - observed_mean)
3836                            * (weights[(class + 12 - tonic) % 12] - profile_mean)
3837                    })
3838                    .sum::<f64>();
3839                let correlation = covariance / (observed_variance * profile_variance).sqrt();
3840                let better = best.is_none_or(|(best_correlation, best_fifths, best_mode)| {
3841                    correlation.total_cmp(&best_correlation).is_gt()
3842                        || (correlation.total_cmp(&best_correlation).is_eq()
3843                            && (
3844                                (i16::from(fifths) - i16::from(reference_fifths)).abs(),
3845                                i16::from(fifths).abs(),
3846                                fifths,
3847                                std::cmp::Reverse(mode),
3848                            ) < (
3849                                (i16::from(best_fifths) - i16::from(reference_fifths)).abs(),
3850                                i16::from(best_fifths).abs(),
3851                                best_fifths,
3852                                std::cmp::Reverse(best_mode),
3853                            ))
3854                });
3855                if better {
3856                    best = Some((correlation, fifths, mode));
3857                }
3858            }
3859        }
3860        if let Some((profile_correlation, fifths, mode)) = best {
3861            estimates.push(LocalKeyEstimate {
3862                measure,
3863                window_start: start,
3864                window_end_exclusive: end,
3865                key: KeySignature {
3866                    fifths,
3867                    mode: mode.to_string(),
3868                },
3869                profile_correlation,
3870                rule_id: "centered-aarden-essen-local-window".to_string(),
3871            });
3872        }
3873    }
3874    estimates
3875}
3876
3877/// Analyze adjacent pitched notes in every voice without inferring missing events.
3878pub fn analyze_intervals(score: &Score) -> Vec<IntervalObservation> {
3879    analyze_intervals_in_region(score, None)
3880}
3881
3882/// Analyze intervals whose endpoints touch an optional part/staff measure region.
3883pub fn analyze_intervals_in_region(
3884    score: &Score,
3885    region: Option<&AnalysisRegion>,
3886) -> Vec<IntervalObservation> {
3887    let mut observations = Vec::new();
3888    for (part_index, part) in score.parts.iter().enumerate() {
3889        for (staff_index, staff) in part.staves.iter().enumerate() {
3890            for (measure_index, measure) in staff.measures.iter().enumerate() {
3891                for (voice_index, voice) in measure.voices.iter().enumerate() {
3892                    for pair in voice.iter().enumerate().collect::<Vec<_>>().windows(2) {
3893                        let (from_index, from_note) = pair[0];
3894                        let (to_index, to_note) = pair[1];
3895                        if from_note.is_rest || to_note.is_rest {
3896                            continue;
3897                        }
3898                        let Some(from) = from_note.pitches.first() else {
3899                            continue;
3900                        };
3901                        let Some(to) = to_note.pitches.first() else {
3902                            continue;
3903                        };
3904                        let from_addr = NoteAddr {
3905                            part: part_index,
3906                            staff: staff_index,
3907                            measure: measure_index,
3908                            voice: voice_index,
3909                            note: from_index,
3910                        };
3911                        let to_addr = NoteAddr {
3912                            part: part_index,
3913                            staff: staff_index,
3914                            measure: measure_index,
3915                            voice: voice_index,
3916                            note: to_index,
3917                        };
3918                        if let Some(region) = region
3919                            && !analysis_region_contains(region, &from_addr)
3920                            && !analysis_region_contains(region, &to_addr)
3921                        {
3922                            continue;
3923                        }
3924                        observations.push(IntervalObservation {
3925                            from: from_addr.clone(),
3926                            to: to_addr.clone(),
3927                            semitones: (to.to_midi() - from.to_midi()).unsigned_abs() as u8,
3928                            cents: to.to_midi_cents() - from.to_midi_cents(),
3929                            diatonic_steps: diatonic_distance(from, to),
3930                            rule_id: "adjacent-melodic-interval".to_string(),
3931                            evidence: vec![from_addr, to_addr],
3932                        });
3933                    }
3934                }
3935            }
3936        }
3937    }
3938    observations
3939}
3940
3941fn diatonic_distance(from: &acorde_core::Pitch, to: &acorde_core::Pitch) -> i8 {
3942    let step_index = |step: &acorde_core::Step| match step {
3943        acorde_core::Step::C => 0i16,
3944        acorde_core::Step::D => 1,
3945        acorde_core::Step::E => 2,
3946        acorde_core::Step::F => 3,
3947        acorde_core::Step::G => 4,
3948        acorde_core::Step::A => 5,
3949        acorde_core::Step::B => 6,
3950    };
3951    (i16::from(to.octave) * 7 + step_index(&to.step)
3952        - (i16::from(from.octave) * 7 + step_index(&from.step))) as i8
3953}
3954
3955/// Return the stable chord spelling as a compact human-readable label.
3956pub fn chord_name(chord: &ChordSymbol) -> String {
3957    chord.display_text()
3958}
3959
3960#[cfg(test)]
3961mod tests {
3962    use super::*;
3963    use acorde_core::{Duration, MeasureLength, Note, Pitch, Score, Step, TupletInfo};
3964
3965    #[test]
3966    fn labels_chord_with_note_addresses_and_roman_numeral() {
3967        let mut score = Score::default();
3968        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
3969        voice.clear();
3970        for step in [Step::C, Step::E, Step::G] {
3971            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
3972        }
3973        let result = analyze_chords(&score);
3974        assert_eq!(result.schema_version, ANALYSIS_SCHEMA_VERSION);
3975        assert_eq!(result.chords.len(), 1);
3976        assert_eq!(result.intervals.len(), 2);
3977        assert!(!result.key_estimates.is_empty());
3978        assert_eq!(result.chords[0].address.note, 0);
3979        assert_eq!(result.chords[0].evidence.len(), 3);
3980        assert_eq!(result.chords[0].roman_numeral.as_deref(), Some("I"));
3981        assert_eq!(chord_name(&result.chords[0].chord), "C");
3982        assert_eq!(result.chords[0].name, "C");
3983    }
3984
3985    #[test]
3986    fn vertical_sonorities_do_not_turn_a_sequential_melody_into_a_chord() {
3987        let mut score = Score::new("melody", 120, 4, 4, 0, 1);
3988        score.parts[0].staves[0].measures[0].voices[0] = [Step::C, Step::E, Step::G]
3989            .into_iter()
3990            .map(|step| Note::new(Pitch::new(step, 4), Duration::Quarter))
3991            .collect();
3992
3993        assert!(analyze_vertical_sonorities(&score).is_empty());
3994        // The legacy pass still has its original behavior for existing callers.
3995        assert_eq!(analyze_chords(&score).chords.len(), 1);
3996    }
3997
3998    #[test]
3999    fn opt_in_arpeggiated_bar_labels_its_downbeat_with_later_note_evidence() {
4000        let mut score = Score::new("arpeggio", 120, 4, 4, 0, 1);
4001        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
4002        *voice = [(Step::C, 4), (Step::E, 4), (Step::G, 4), (Step::C, 5)]
4003            .into_iter()
4004            .map(|(step, octave)| Note::new(Pitch::new(step, octave), Duration::Eighth))
4005            .collect();
4006        voice.push(Note::rest(Duration::Half));
4007
4008        assert!(analyze_vertical_sonorities(&score).is_empty());
4009        let report = analyze_arpeggiated_bars_report(&score);
4010        assert!(report.timeline_issues.is_empty());
4011        assert_eq!(report.sonorities.len(), 1);
4012        let inferred = &report.sonorities[0];
4013        assert_eq!((inferred.measure, inferred.beat), (0, 0.0));
4014        assert_eq!(inferred.label.roman_numeral.as_deref(), Some("I"));
4015        assert_eq!(inferred.label.rule_id, "bar-arpeggio-template");
4016        assert_eq!(inferred.label.evidence.len(), 4);
4017
4018        // The exact-template rule does not silently turn a changed pitch into
4019        // a C-major harmony merely because three of the notes still fit.
4020        score.parts[0].staves[0].measures[0].voices[0][3].pitches[0] = Pitch::new(Step::F, 4);
4021        assert!(
4022            analyze_arpeggiated_bars_report(&score)
4023                .sonorities
4024                .is_empty()
4025        );
4026    }
4027
4028    #[test]
4029    fn opt_in_forward_completion_requires_an_exact_tertian_window() {
4030        let mut score = Score::new("staggered chord", 120, 4, 4, 0, 1);
4031        let measure = &mut score.parts[0].staves[0].measures[0];
4032        measure.voices[0] = vec![
4033            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
4034            Note::rest(Duration::Half),
4035            Note::rest(Duration::Quarter),
4036        ];
4037        let mut upper = Note::new(Pitch::new(Step::E, 4), Duration::Eighth);
4038        upper.pitches.push(Pitch::new(Step::G, 4));
4039        measure.voices[1] = vec![
4040            Note::rest(Duration::Eighth),
4041            upper,
4042            Note::rest(Duration::Half),
4043            Note::rest(Duration::Quarter),
4044        ];
4045
4046        let baseline = analyze_vertical_sonorities(&score);
4047        assert_eq!(baseline.len(), 1);
4048        assert_eq!(baseline[0].beat, 0.5);
4049        let inferred = analyze_arpeggiated_bars_report(&score);
4050        assert_eq!(inferred.sonorities.len(), 2);
4051        assert_eq!(inferred.sonorities[0].beat, 0.0);
4052        assert_eq!(
4053            inferred.sonorities[0].label.roman_numeral.as_deref(),
4054            Some("I")
4055        );
4056        assert_eq!(
4057            inferred.sonorities[0].label.rule_id,
4058            "forward-completed-sonority"
4059        );
4060        assert_eq!(inferred.sonorities[0].label.evidence.len(), 2);
4061
4062        let mut shifted = score.clone();
4063        let upper = shifted.parts[0].staves[0].measures[0].voices[1][1].clone();
4064        shifted.parts[0].staves[0].measures[0].voices[0] = vec![
4065            Note::rest(Duration::Eighth),
4066            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
4067            Note::rest(Duration::Half),
4068            Note::rest(Duration::Eighth),
4069        ];
4070        shifted.parts[0].staves[0].measures[0].voices[1] = vec![
4071            Note::rest(Duration::Quarter),
4072            upper,
4073            Note::rest(Duration::Half),
4074            Note::rest(Duration::Eighth),
4075        ];
4076        let offbeat = analyze_arpeggiated_bars_report(&shifted);
4077        assert_eq!(offbeat.sonorities[0].beat, 0.5);
4078        assert_eq!(
4079            offbeat.sonorities[0].label.rule_id,
4080            "forward-completed-sonority"
4081        );
4082
4083        // F-sharp approaches an otherwise complete C-major chord at beat 0.5.
4084        score.parts[0].staves[0].measures[0].voices[0] = vec![
4085            Note::new(Pitch::with_alter(Step::F, 4, 1), Duration::Eighth),
4086            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
4087            Note::rest(Duration::Half),
4088            Note::rest(Duration::Quarter),
4089        ];
4090        let changed = analyze_arpeggiated_bars_report(&score);
4091        assert!(
4092            changed
4093                .sonorities
4094                .iter()
4095                .all(|item| item.label.rule_id != "forward-completed-sonority")
4096        );
4097    }
4098
4099    #[test]
4100    fn opt_in_vertical_cadence_uses_bass_and_outer_voice_resolution() {
4101        let mut score = Score::new("dominant tonic", 120, 4, 4, 0, 2);
4102        for (measure_index, bass_step, bass_octave, upper_pitches) in [
4103            (
4104                0,
4105                Step::G,
4106                3,
4107                vec![
4108                    Pitch::new(Step::B, 4),
4109                    Pitch::new(Step::D, 5),
4110                    Pitch::new(Step::G, 5),
4111                ],
4112            ),
4113            (
4114                1,
4115                Step::C,
4116                3,
4117                vec![
4118                    Pitch::new(Step::E, 4),
4119                    Pitch::new(Step::G, 4),
4120                    Pitch::new(Step::C, 5),
4121                ],
4122            ),
4123        ] {
4124            let measure = &mut score.parts[0].staves[0].measures[measure_index];
4125            measure.voices[0] = vec![Note::new(
4126                Pitch::new(bass_step, bass_octave),
4127                Duration::Whole,
4128            )];
4129            let mut upper = Note::new(upper_pitches[0].clone(), Duration::Whole);
4130            upper.pitches.extend(upper_pitches.into_iter().skip(1));
4131            measure.voices[1] = vec![upper];
4132        }
4133
4134        let report = analyze_vertical_cadences_report(&score);
4135        assert!(report.timeline_issues.is_empty());
4136        assert_eq!(report.cadence_candidates.len(), 1);
4137        let candidate = &report.cadence_candidates[0];
4138        assert_eq!(candidate.kind, CadenceKind::Authentic);
4139        assert_eq!(candidate.from.measure, 0);
4140        assert_eq!(candidate.to.measure, 1);
4141        assert_eq!(candidate.rule_id, "vertical-dominant-tonic-outer-voices");
4142
4143        let upper = &mut score.parts[0].staves[0].measures[1].voices[1][0];
4144        upper.pitches.retain(|pitch| pitch.step != Step::C);
4145        assert!(
4146            analyze_vertical_cadences_report(&score)
4147                .cadence_candidates
4148                .is_empty()
4149        );
4150    }
4151
4152    #[test]
4153    fn local_key_windows_keep_physical_measure_addresses() {
4154        assert!(estimate_local_keys_with_aarden_essen_profile(&Score::default(), 4).is_empty());
4155        let mut score = Score::new("local keys", 120, 4, 4, 0, 3);
4156        for measure in &mut score.parts[0].staves[0].measures {
4157            let mut chord = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4158            chord.pitches.push(Pitch::new(Step::E, 4));
4159            chord.pitches.push(Pitch::new(Step::G, 4));
4160            measure.voices[0] = vec![chord];
4161        }
4162        let keys = estimate_local_keys_with_aarden_essen_profile(&score, 1);
4163        assert_eq!(keys.len(), 3);
4164        assert_eq!(keys[0].measure, 0);
4165        assert_eq!((keys[0].window_start, keys[0].window_end_exclusive), (0, 2));
4166        assert_eq!((keys[1].window_start, keys[1].window_end_exclusive), (0, 3));
4167        assert_eq!((keys[2].window_start, keys[2].window_end_exclusive), (1, 3));
4168        assert!(keys.iter().all(|key| key.profile_correlation.is_finite()));
4169    }
4170
4171    #[test]
4172    fn vertical_sonorities_include_held_notes_across_parts() {
4173        let mut score = Score::new("vertical", 120, 4, 4, 0, 1);
4174        let part = score.parts[0].clone();
4175        score.parts.push(part.clone());
4176        score.parts.push(part);
4177        score.parts[0].staves[0].measures[0].voices[0] =
4178            vec![Note::new(Pitch::new(Step::C, 3), Duration::Whole)];
4179        score.parts[1].staves[0].measures[0].voices[0] = vec![
4180            Note::new(Pitch::new(Step::E, 4), Duration::Half),
4181            Note::new(Pitch::new(Step::F, 4), Duration::Half),
4182        ];
4183        score.parts[2].staves[0].measures[0].voices[0] =
4184            vec![Note::new(Pitch::new(Step::G, 4), Duration::Whole)];
4185
4186        let sonorities = analyze_vertical_sonorities(&score);
4187        assert_eq!(sonorities.len(), 2);
4188        assert_eq!((sonorities[0].measure, sonorities[0].beat), (0, 0.0));
4189        assert_eq!((sonorities[1].measure, sonorities[1].beat), (0, 2.0));
4190        assert_eq!(sonorities[0].label.chord.root, "C");
4191        assert_eq!(sonorities[0].label.chord.kind, "major");
4192        assert_eq!(sonorities[1].label.chord.kind, "suspended-fourth");
4193        assert!(sonorities.iter().all(|item| item.label.evidence.len() == 3));
4194    }
4195
4196    #[test]
4197    fn vertical_sonorities_preserve_authored_chord_at_its_beat() {
4198        let mut score = Score::new("authored", 120, 4, 4, 0, 1);
4199        let part = score.parts[0].clone();
4200        score.parts.push(part.clone());
4201        score.parts.push(part);
4202        let mut bass = Note::new(Pitch::new(Step::C, 3), Duration::Quarter);
4203        bass.chord_symbol = Some(ChordSymbol {
4204            root: "D".to_string(),
4205            kind: "minor".to_string(),
4206            bass: None,
4207            placement: None,
4208            extender: false,
4209            harmonic_degree: None,
4210            harmony_function: None,
4211            harmony_type: None,
4212            chord_ref: None,
4213            range_end: None,
4214            degrees: Vec::new(),
4215        });
4216        score.parts[0].staves[0].measures[0].voices[0] = vec![bass];
4217        score.parts[1].staves[0].measures[0].voices[0] =
4218            vec![Note::new(Pitch::new(Step::E, 4), Duration::Quarter)];
4219        score.parts[2].staves[0].measures[0].voices[0] =
4220            vec![Note::new(Pitch::new(Step::G, 4), Duration::Quarter)];
4221
4222        let sonorities = analyze_vertical_sonorities(&score);
4223        assert_eq!(sonorities.len(), 1);
4224        assert_eq!(sonorities[0].label.rule_id, "authored-chord-symbol");
4225        assert_eq!(sonorities[0].label.chord.root, "D");
4226        assert_eq!(sonorities[0].label.confidence, 100);
4227
4228        score.parts[0].staves[0].transpose_semitones = -2;
4229        let key = KeySignature {
4230            fifths: 0,
4231            mode: "major".to_string(),
4232        };
4233        let transposing = analyze_vertical_sonorities_in_key(&score, &key);
4234        assert_eq!(transposing[0].label.chord.root, "D");
4235        assert_eq!(transposing[0].label.roman_numeral, None);
4236    }
4237
4238    #[test]
4239    fn vertical_sonorities_can_use_an_independently_estimated_minor_key() {
4240        let mut score = Score::new("minor", 120, 4, 4, -3, 1);
4241        let part = score.parts[0].clone();
4242        score.parts.push(part.clone());
4243        score.parts.push(part);
4244        for (part_index, (step, alter)) in [(Step::C, 0), (Step::E, -1), (Step::G, 0)]
4245            .into_iter()
4246            .enumerate()
4247        {
4248            score.parts[part_index].staves[0].measures[0].voices[0] = vec![Note::new(
4249                Pitch::with_alter(step, 4, alter),
4250                Duration::Quarter,
4251            )];
4252        }
4253        let c_minor = KeySignature {
4254            fifths: -3,
4255            mode: "minor".to_string(),
4256        };
4257        assert_eq!(
4258            analyze_vertical_sonorities(&score)[0]
4259                .label
4260                .roman_numeral
4261                .as_deref(),
4262            Some("vi")
4263        );
4264        assert_eq!(
4265            analyze_vertical_sonorities_in_key(&score, &c_minor)[0]
4266                .label
4267                .roman_numeral
4268                .as_deref(),
4269            Some("i")
4270        );
4271    }
4272
4273    #[test]
4274    fn vertical_sonorities_report_unequal_part_bar_lengths() {
4275        let mut score = Score::new("unequal", 120, 4, 4, 0, 2);
4276        let part = score.parts[0].clone();
4277        score.parts.push(part.clone());
4278        score.parts.push(part);
4279        for (part_index, step) in [Step::C, Step::E, Step::G].into_iter().enumerate() {
4280            score.parts[part_index].staves[0].measures[0].voices[0] =
4281                vec![Note::new(Pitch::new(step, 4), Duration::Quarter)];
4282        }
4283        score.parts[2].staves[0].measures[0].actual_length = Some(MeasureLength {
4284            numerator: 3,
4285            denominator: 4,
4286        });
4287        let report = analyze_vertical_sonorities_report(&score);
4288        assert!(report.sonorities.iter().all(|item| item.measure != 0));
4289        assert_eq!(report.timeline_issues[0].part, 2);
4290        assert_eq!(
4291            report.timeline_issues[0].kind,
4292            VerticalTimelineIssueKind::DifferentLength
4293        );
4294        assert!(report.timeline_issues.iter().any(|issue| {
4295            issue.measure == 1 && issue.kind == VerticalTimelineIssueKind::DifferentStart
4296        }));
4297        assert_eq!(analyze_vertical_sonorities(&score), report.sonorities);
4298    }
4299
4300    #[test]
4301    fn vertical_harmony_aligns_two_short_bars_with_one_long_bar() {
4302        let mut score = Score::new("mixed bar partitions", 120, 4, 4, 0, 2);
4303        let part = score.parts[0].clone();
4304        score.parts.push(part.clone());
4305        score.parts.push(part);
4306
4307        let mut first_c = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4308        first_c.tie_start = true;
4309        let mut second_c = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4310        second_c.tie_end = true;
4311        score.parts[0].staves[0].measures[0].voices[0] = vec![first_c];
4312        score.parts[0].staves[0].measures[1].voices[0] = vec![second_c];
4313
4314        score.parts[1].staves[0].measures.truncate(1);
4315        score.parts[1].staves[0].measures[0].actual_length = Some(MeasureLength {
4316            numerator: 2,
4317            denominator: 1,
4318        });
4319        score.parts[1].staves[0].measures[0].voices[0] =
4320            vec![Note::new(Pitch::new(Step::E, 4), Duration::Breve)];
4321
4322        for measure in &mut score.parts[2].staves[0].measures {
4323            measure.voices[0] = vec![Note::new(Pitch::new(Step::G, 4), Duration::Whole)];
4324        }
4325
4326        let key = KeySignature {
4327            fifths: 0,
4328            mode: "major".to_string(),
4329        };
4330        let report = analyze_vertical_sonorities_in_key_report(&score, &key);
4331        assert!(report.timeline_issues.is_empty());
4332        assert_eq!(report.sonorities.len(), 2);
4333        assert_eq!(
4334            (report.sonorities[1].measure, report.sonorities[1].beat),
4335            (1, 0.0)
4336        );
4337        assert_eq!(report.sonorities[1].label.chord.root, "C");
4338        assert_eq!(
4339            report.sonorities[1].label.roman_numeral.as_deref(),
4340            Some("I")
4341        );
4342        assert!(
4343            report.sonorities[1]
4344                .label
4345                .evidence
4346                .iter()
4347                .any(|address| address.part == 1 && address.measure == 0)
4348        );
4349
4350        let without_key = analyze_vertical_sonorities_report(&score);
4351        assert_eq!(without_key.sonorities.len(), 2);
4352        assert_eq!(
4353            without_key.sonorities[1].label.roman_numeral.as_deref(),
4354            Some("I")
4355        );
4356
4357        let mut transposing = score.clone();
4358        transposing.parts[2].staves[0].transpose_semitones = -2;
4359        for measure in &mut transposing.parts[2].staves[0].measures {
4360            measure.key_sig = Some(KeySignature {
4361                fifths: 2,
4362                mode: "major".to_string(),
4363            });
4364            measure.voices[0][0].pitches[0] = Pitch::new(Step::A, 4);
4365        }
4366        let converted = analyze_vertical_sonorities_report(&transposing);
4367        assert_eq!(converted.sonorities.len(), 2);
4368        assert_eq!(
4369            converted.sonorities[1].label.roman_numeral.as_deref(),
4370            Some("I")
4371        );
4372
4373        score.parts[2].staves[0].measures[1].key_sig = Some(KeySignature {
4374            fifths: 1,
4375            mode: "major".to_string(),
4376        });
4377        let conflicting = analyze_vertical_sonorities_report(&score);
4378        assert_eq!(conflicting.sonorities.len(), 2);
4379        assert_eq!(
4380            conflicting.sonorities[0].label.roman_numeral.as_deref(),
4381            Some("I")
4382        );
4383        assert_eq!(conflicting.sonorities[1].label.roman_numeral, None);
4384    }
4385
4386    #[test]
4387    fn vertical_harmony_uses_long_reference_bar_coordinates() {
4388        let mut score = Score::new("long reference bar", 120, 4, 4, 0, 2);
4389        let part = score.parts[0].clone();
4390        score.parts.push(part.clone());
4391        score.parts.push(part);
4392        score.parts[0].staves[0].measures.truncate(1);
4393        score.parts[0].staves[0].measures[0].actual_length = Some(MeasureLength {
4394            numerator: 2,
4395            denominator: 1,
4396        });
4397        score.parts[0].staves[0].measures[0].voices[0] =
4398            vec![Note::new(Pitch::new(Step::C, 4), Duration::Breve)];
4399        for measure_index in 0..2 {
4400            score.parts[1].staves[0].measures[measure_index].voices[0] =
4401                vec![Note::new(Pitch::new(Step::E, 4), Duration::Whole)];
4402            score.parts[2].staves[0].measures[measure_index].voices[0] =
4403                vec![Note::new(Pitch::new(Step::G, 4), Duration::Whole)];
4404        }
4405
4406        let report = analyze_vertical_sonorities_report(&score);
4407        assert!(report.timeline_issues.is_empty());
4408        assert_eq!(report.sonorities.len(), 2);
4409        assert_eq!(
4410            (report.sonorities[1].measure, report.sonorities[1].beat),
4411            (0, 4.0)
4412        );
4413        assert_eq!(report.sonorities[1].label.chord.root, "C");
4414        assert_eq!(report.sonorities[1].label.address.measure, 1);
4415    }
4416
4417    #[test]
4418    fn vertical_sonorities_do_not_relabel_a_fully_tied_barline() {
4419        let mut score = Score::new("ties", 120, 4, 4, 0, 2);
4420        let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4421        first
4422            .pitches
4423            .extend([Pitch::new(Step::E, 4), Pitch::new(Step::G, 4)]);
4424        first.set_pitch_ties(&[true, true, true], &[false, false, false]);
4425        let mut second = first.clone();
4426        second.set_pitch_ties(&[false, false, false], &[true, true, true]);
4427        score.parts[0].staves[0].measures[0].voices[0] = vec![first];
4428        score.parts[0].staves[0].measures[1].voices[0] = vec![second];
4429        let report = analyze_vertical_sonorities_report(&score);
4430        assert!(report.timeline_issues.is_empty());
4431        assert_eq!(report.sonorities.len(), 1);
4432        assert_eq!(report.sonorities[0].measure, 0);
4433        let opt_in = analyze_arpeggiated_bars_report(&score);
4434        assert_eq!(opt_in.sonorities.len(), 1);
4435        assert_eq!(opt_in.sonorities[0].measure, 0);
4436    }
4437
4438    #[test]
4439    fn vertical_harmony_recognizes_tie_across_voices() {
4440        let mut score = Score::new("voice crossing tie", 120, 4, 4, 0, 2);
4441        let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4442        first
4443            .pitches
4444            .extend([Pitch::new(Step::E, 4), Pitch::new(Step::G, 4)]);
4445        first.set_pitch_ties(&[true, true, true], &[false, false, false]);
4446        let mut second = first.clone();
4447        second.set_pitch_ties(&[false, false, false], &[true, true, true]);
4448        score.parts[0].staves[0].measures[0].voices[0] = vec![first];
4449        score.parts[0].staves[0].measures[1].voices[1] = vec![second];
4450
4451        let report = analyze_vertical_sonorities_report(&score);
4452        assert!(report.timeline_issues.is_empty());
4453        assert_eq!(report.sonorities.len(), 1);
4454        assert_eq!(report.sonorities[0].measure, 0);
4455    }
4456
4457    #[test]
4458    fn cross_staff_tie_uses_explicit_source_location() {
4459        let mut score = Score::new("staff crossing tie", 120, 4, 4, 0, 2);
4460        let other_staff = score.parts[0].staves[0].clone();
4461        score.parts[0].staves.push(other_staff);
4462        let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4463        first
4464            .pitches
4465            .extend([Pitch::new(Step::E, 4), Pitch::new(Step::G, 4)]);
4466        first.set_pitch_ties(&[true, true, true], &[false, false, false]);
4467        let mut second = first.clone();
4468        second.set_pitch_ties(&[false, false, false], &[true, true, true]);
4469        let source = acorde_core::TieSourceLocation {
4470            measures: -1,
4471            staves: -1,
4472            fractions: Some("3/4".to_string()),
4473            ..Default::default()
4474        };
4475        second.pitch_tie_sources = vec![Some(source); 3];
4476        score.parts[0].staves[0].measures[0].voices[0] = vec![
4477            Note::rest(Duration::Quarter),
4478            Note::rest(Duration::Quarter),
4479            Note::rest(Duration::Quarter),
4480            first,
4481        ];
4482        score.parts[0].staves[1].measures[1].voices[0] = vec![second];
4483
4484        let report = analyze_vertical_sonorities_report(&score);
4485        assert!(report.timeline_issues.is_empty());
4486        assert_eq!(report.sonorities.len(), 1);
4487        assert_eq!(report.sonorities[0].beat, 3.0);
4488
4489        for source in &mut score.parts[0].staves[1].measures[1].voices[0][0].pitch_tie_sources {
4490            source.as_mut().expect("source exists").staves = 0;
4491        }
4492        let invalid_source = analyze_vertical_sonorities_report(&score);
4493        assert_eq!(invalid_source.sonorities.len(), 2);
4494        assert_eq!(invalid_source.sonorities[1].measure, 1);
4495    }
4496
4497    #[test]
4498    fn partial_or_dangling_ties_keep_new_harmonic_attacks() {
4499        let mut score = Score::new("partial ties", 120, 4, 4, 0, 2);
4500        let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4501        first
4502            .pitches
4503            .extend([Pitch::new(Step::E, 4), Pitch::new(Step::G, 4)]);
4504        first.set_pitch_ties(&[true, false, false], &[false, false, false]);
4505        let mut second = first.clone();
4506        second.set_pitch_ties(&[false, false, false], &[true, false, false]);
4507        score.parts[0].staves[0].measures[0].voices[0] = vec![first];
4508        score.parts[0].staves[0].measures[1].voices[0] = vec![second.clone()];
4509        let report = analyze_vertical_sonorities_report(&score);
4510        assert_eq!(report.sonorities.len(), 2);
4511        assert_eq!(report.sonorities[1].measure, 1);
4512
4513        score.parts[0].staves[0].measures[0].voices[0][0]
4514            .set_pitch_ties(&[false, false, false], &[false, false, false]);
4515        second.set_pitch_ties(&[false, false, false], &[true, true, true]);
4516        score.parts[0].staves[0].measures[1].voices[0] = vec![second];
4517        let report = analyze_vertical_sonorities_report(&score);
4518        assert_eq!(report.sonorities.len(), 2);
4519    }
4520
4521    #[test]
4522    fn tie_end_in_newly_used_voice_is_an_attack_without_a_predecessor() {
4523        let mut score = Score::new("new voice", 120, 4, 4, 0, 2);
4524        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4525        note.pitches
4526            .extend([Pitch::new(Step::E, 4), Pitch::new(Step::G, 4)]);
4527        note.set_pitch_ties(&[false, false, false], &[true, true, true]);
4528        score.parts[0].staves[0].measures[1].voices[1] = vec![note];
4529
4530        let report = analyze_vertical_sonorities_report(&score);
4531        assert!(report.timeline_issues.is_empty());
4532        assert_eq!(report.sonorities.len(), 1);
4533        assert_eq!(report.sonorities[0].measure, 1);
4534        assert_eq!(report.sonorities[0].label.address.voice, 1);
4535    }
4536
4537    #[test]
4538    fn instrument_change_breaks_a_written_pitch_tie_for_harmony() {
4539        let mut score = Score::new("changed sounding pitch", 120, 4, 4, 0, 2);
4540        let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4541        first
4542            .pitches
4543            .extend([Pitch::new(Step::E, 4), Pitch::new(Step::G, 4)]);
4544        first.set_pitch_ties(&[true, true, true], &[false, false, false]);
4545        let mut second = first.clone();
4546        second.set_pitch_ties(&[false, false, false], &[true, true, true]);
4547        score.parts[0].staves[0].measures[0].voices[0] = vec![first];
4548        score.parts[0].staves[0].measures[1].voices[0] = vec![second];
4549        let mut clarinet = acorde_core::InstrumentDefinition::new("clarinet", "Clarinet");
4550        clarinet.transpose_semitones = -2;
4551        score.parts[0].staves[0].measures[1].instrument_change = Some(clarinet);
4552
4553        let report = analyze_vertical_sonorities_report(&score);
4554        assert!(report.timeline_issues.is_empty());
4555        assert_eq!(report.sonorities.len(), 2);
4556        assert_eq!(report.sonorities[0].label.chord.root, "C");
4557        assert_eq!(report.sonorities[1].label.chord.root, "Bb");
4558    }
4559
4560    #[test]
4561    fn new_attack_combines_with_tied_pitches_across_parts() {
4562        let mut score = Score::new("held harmony", 120, 4, 4, 0, 2);
4563        let part = score.parts[0].clone();
4564        score.parts.push(part.clone());
4565        score.parts.push(part);
4566        for (part_index, step) in [Step::C, Step::E, Step::G].into_iter().enumerate() {
4567            let mut first = Note::new(Pitch::new(step, 4), Duration::Whole);
4568            let mut second = first.clone();
4569            if part_index < 2 {
4570                first.tie_start = true;
4571                second.tie_end = true;
4572            }
4573            score.parts[part_index].staves[0].measures[0].voices[0] = vec![first];
4574            score.parts[part_index].staves[0].measures[1].voices[0] = vec![second];
4575        }
4576        let report = analyze_vertical_sonorities_report(&score);
4577        assert!(report.timeline_issues.is_empty());
4578        let second = report
4579            .sonorities
4580            .iter()
4581            .find(|sonority| sonority.measure == 1)
4582            .expect("the new G attack triggers a sonority");
4583        assert_eq!(second.label.evidence.len(), 3);
4584        assert_eq!(second.label.address.part, 2);
4585    }
4586
4587    #[test]
4588    fn vertical_harmony_uses_concert_pitches_for_transposing_parts() {
4589        let mut score = Score::new("clarinet and piano", 120, 4, 4, 0, 1);
4590        let part = score.parts[0].clone();
4591        score.parts.push(part.clone());
4592        score.parts.push(part);
4593        score.parts[0].staves[0].transpose_semitones = -2;
4594        for (part_index, step) in [Step::D, Step::E, Step::G].into_iter().enumerate() {
4595            score.parts[part_index].staves[0].measures[0].voices[0] =
4596                vec![Note::new(Pitch::new(step, 4), Duration::Quarter)];
4597        }
4598        let key = KeySignature {
4599            fifths: 0,
4600            mode: "major".to_string(),
4601        };
4602        let in_key = analyze_vertical_sonorities_in_key_report(&score, &key);
4603        assert!(in_key.timeline_issues.is_empty());
4604        assert_eq!(in_key.sonorities.len(), 1);
4605        assert_eq!(in_key.sonorities[0].label.chord.root, "C");
4606        assert_eq!(
4607            in_key.sonorities[0].label.roman_numeral.as_deref(),
4608            Some("I")
4609        );
4610
4611        let without_concert_key = analyze_vertical_sonorities_report(&score);
4612        assert_eq!(without_concert_key.sonorities[0].label.chord.root, "C");
4613        assert_eq!(without_concert_key.sonorities[0].label.roman_numeral, None);
4614
4615        score.parts[0].staves[0].measures[0].key_sig = Some(KeySignature {
4616            fifths: 2,
4617            mode: "major".to_string(),
4618        });
4619        let reconciled = analyze_vertical_sonorities_report(&score);
4620        assert_eq!(
4621            reconciled.sonorities[0].label.roman_numeral.as_deref(),
4622            Some("I")
4623        );
4624
4625        let mut silent = score.parts[1].clone();
4626        silent.staves[0].measures[0].voices[0] = vec![Note::rest(Duration::Whole)];
4627        silent.staves[0].measures[0].key_sig = Some(KeySignature {
4628            fifths: 1,
4629            mode: "major".to_string(),
4630        });
4631        score.parts.push(silent);
4632        let silent_conflict = analyze_vertical_sonorities_report(&score);
4633        assert_eq!(
4634            silent_conflict.sonorities[0].label.roman_numeral.as_deref(),
4635            Some("I")
4636        );
4637
4638        score.parts[1].staves[0].measures[0].key_sig = Some(KeySignature {
4639            fifths: 1,
4640            mode: "major".to_string(),
4641        });
4642        let conflicting = analyze_vertical_sonorities_report(&score);
4643        assert_eq!(conflicting.sonorities[0].label.roman_numeral, None);
4644    }
4645
4646    #[test]
4647    fn vertical_harmony_uses_measure_instrument_change() {
4648        let mut score = Score::new("changing instrument", 120, 4, 4, 0, 2);
4649        let part = score.parts[0].clone();
4650        score.parts.push(part.clone());
4651        score.parts.push(part);
4652        let mut clarinet = acorde_core::InstrumentDefinition::new("clarinet", "Clarinet");
4653        clarinet.transpose_semitones = -2;
4654        score.parts[0].instrument = Some(clarinet);
4655        score.parts[0].staves[0].measures[1].instrument_change =
4656            Some(acorde_core::InstrumentDefinition::new("piano", "Piano"));
4657        for (part_index, (part, step)) in score
4658            .parts
4659            .iter_mut()
4660            .zip([Step::D, Step::E, Step::G])
4661            .enumerate()
4662        {
4663            for (measure_index, measure) in part.staves[0].measures.iter_mut().enumerate() {
4664                let written = if measure_index == 1 && part_index == 0 {
4665                    Step::C
4666                } else {
4667                    step.clone()
4668                };
4669                measure.voices[0] = vec![Note::new(Pitch::new(written, 4), Duration::Quarter)];
4670            }
4671        }
4672        let key = KeySignature {
4673            fifths: 0,
4674            mode: "major".to_string(),
4675        };
4676        let report = analyze_vertical_sonorities_in_key_report(&score, &key);
4677        assert!(report.timeline_issues.is_empty());
4678        assert_eq!(report.sonorities.len(), 2);
4679        assert!(
4680            report
4681                .sonorities
4682                .iter()
4683                .all(|item| item.label.chord.root == "C")
4684        );
4685
4686        score.parts[0].staves[0].measures[0].key_sig = Some(KeySignature {
4687            fifths: 2,
4688            mode: "major".to_string(),
4689        });
4690        let ambiguous = analyze_vertical_sonorities_report(&score);
4691        assert_eq!(
4692            ambiguous.sonorities[0].label.roman_numeral.as_deref(),
4693            Some("I")
4694        );
4695        assert_eq!(ambiguous.sonorities[1].label.roman_numeral, None);
4696
4697        score.parts[0].staves[0].measures[1].key_sig = Some(key);
4698        let reconciled = analyze_vertical_sonorities_report(&score);
4699        assert_eq!(
4700            reconciled.sonorities[1].label.roman_numeral.as_deref(),
4701            Some("I")
4702        );
4703    }
4704
4705    #[test]
4706    fn e_flat_instrument_keeps_concert_chord_spelling() {
4707        let mut score = Score::new("alto sax and piano", 120, 4, 4, 0, 1);
4708        let part = score.parts[0].clone();
4709        score.parts.push(part.clone());
4710        score.parts.push(part);
4711        score.parts[0].staves[0].transpose_semitones = -9;
4712        for (part_index, pitch) in [
4713            Pitch::new(Step::C, 5),
4714            Pitch::new(Step::G, 4),
4715            Pitch::with_alter(Step::B, 4, -1),
4716        ]
4717        .into_iter()
4718        .enumerate()
4719        {
4720            score.parts[part_index].staves[0].measures[0].voices[0] =
4721                vec![Note::new(pitch, Duration::Quarter)];
4722        }
4723        let report = analyze_vertical_sonorities_report(&score);
4724        assert!(report.timeline_issues.is_empty());
4725        assert_eq!(report.sonorities[0].label.chord.root, "Eb");
4726    }
4727
4728    #[test]
4729    fn unrepresentable_concert_pitch_omits_the_measure() {
4730        let mut score = Score::new("out of range", 120, 4, 4, 0, 1);
4731        score.parts[0].staves[0].transpose_semitones = 12;
4732        score.parts[0].staves[0].measures[0].voices[0] =
4733            vec![Note::new(Pitch::new(Step::B, 127), Duration::Quarter)];
4734
4735        let report = analyze_vertical_sonorities_report(&score);
4736        assert!(report.sonorities.is_empty());
4737        assert_eq!(
4738            report.timeline_issues[0].kind,
4739            VerticalTimelineIssueKind::UnrepresentableConcertPitch
4740        );
4741    }
4742
4743    #[test]
4744    fn vertical_sonorities_report_invalid_and_overfull_voice_timing() {
4745        let mut score = Score::default();
4746        score.parts[0].staves[0].measures[0].voices[0] = (0..5)
4747            .map(|_| Note::new(Pitch::new(Step::C, 4), Duration::Quarter))
4748            .collect();
4749        let report = analyze_vertical_sonorities_report(&score);
4750        assert!(report.sonorities.is_empty());
4751        assert_eq!(
4752            report.timeline_issues[0].kind,
4753            VerticalTimelineIssueKind::OverfullVoice
4754        );
4755
4756        let mut malformed = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4757        malformed.tuplet = Some(TupletInfo {
4758            actual_notes: 0,
4759            normal_notes: 2,
4760        });
4761        score.parts[0].staves[0].measures[0].voices[0] = vec![malformed];
4762        let report = analyze_vertical_sonorities_report(&score);
4763        assert!(report.sonorities.is_empty());
4764        assert_eq!(
4765            report.timeline_issues[0].kind,
4766            VerticalTimelineIssueKind::InvalidNoteDuration
4767        );
4768
4769        score.parts[0].staves[0].measures[0].voices[0].clear();
4770        score.parts[0].staves[0].measures[0].actual_length = Some(MeasureLength {
4771            numerator: 1,
4772            denominator: 0,
4773        });
4774        let report = analyze_vertical_sonorities_report(&score);
4775        assert_eq!(
4776            report.timeline_issues[0].kind,
4777            VerticalTimelineIssueKind::InvalidLength
4778        );
4779    }
4780
4781    #[test]
4782    fn authored_chord_symbol_wins_with_source_address_provenance() {
4783        use acorde_core::ChordSymbol;
4784
4785        let mut score = Score::default();
4786        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
4787        voice.clear();
4788        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4789        note.chord_symbol = Some(ChordSymbol {
4790            root: "F".to_owned(),
4791            kind: "dominant".to_owned(),
4792            bass: Some("A".to_owned()),
4793            placement: None,
4794            extender: false,
4795            harmonic_degree: Some("4".to_owned()),
4796            harmony_function: Some("D".to_owned()),
4797            harmony_type: None,
4798            chord_ref: None,
4799            range_end: None,
4800            degrees: Vec::new(),
4801        });
4802        voice.push(note);
4803
4804        let result = analyze_score(&score);
4805        assert_eq!(result.chords.len(), 1);
4806        assert_eq!(result.chords[0].rule_id, "authored-chord-symbol");
4807        assert_eq!(result.chords[0].address.note, 0);
4808        assert_eq!(
4809            result.chords[0].evidence,
4810            vec![result.chords[0].address.clone()]
4811        );
4812        assert_eq!(chord_name(&result.chords[0].chord), "F7/A");
4813        assert_eq!(result.chords[0].name, "F7/A");
4814        let finding = analysis_provenance(&result, &result.chords[0].address)
4815            .into_iter()
4816            .find(|finding| finding.category == AnalysisCategory::Chords)
4817            .expect("authored chord provenance");
4818        assert_eq!(finding.label.as_deref(), Some("F7/A"));
4819    }
4820
4821    #[test]
4822    fn chord_name_preserves_authored_degree_extensions() {
4823        use acorde_core::{ChordDegree, ChordSymbol};
4824
4825        let chord = ChordSymbol {
4826            root: "C".to_owned(),
4827            kind: "dominant".to_owned(),
4828            bass: None,
4829            placement: None,
4830            extender: false,
4831            harmonic_degree: None,
4832            harmony_function: None,
4833            harmony_type: None,
4834            chord_ref: None,
4835            range_end: None,
4836            degrees: vec![
4837                ChordDegree {
4838                    value: 9,
4839                    alter: 1,
4840                    kind: "add".to_owned(),
4841                },
4842                ChordDegree {
4843                    value: 5,
4844                    alter: -1,
4845                    kind: "alter".to_owned(),
4846                },
4847                ChordDegree {
4848                    value: 3,
4849                    alter: 0,
4850                    kind: "subtract".to_owned(),
4851                },
4852            ],
4853        };
4854
4855        assert_eq!(chord_name(&chord), "C7add#9b5no3");
4856    }
4857
4858    #[test]
4859    fn provenance_returns_stable_explanations_for_a_note() {
4860        let mut score = Score::default();
4861        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
4862        voice.clear();
4863        voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
4864        voice.push(Note::new(Pitch::new(Step::E, 4), Duration::Quarter));
4865        voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
4866        let address = NoteAddr {
4867            part: 0,
4868            staff: 0,
4869            measure: 0,
4870            voice: 0,
4871            note: 0,
4872        };
4873        let findings = analysis_provenance(&analyze_score(&score), &address);
4874        let chord = findings
4875            .iter()
4876            .find(|finding| finding.category == AnalysisCategory::Chords)
4877            .expect("chord provenance");
4878        assert_eq!(chord.rule_id, "pitch-class-template");
4879        assert_eq!(chord.label.as_deref(), Some("C"));
4880        assert_eq!(chord.evidence[0], address);
4881        assert!(findings.windows(2).all(|pair| {
4882            (analysis_category_rank(pair[0].category), &pair[0].rule_id)
4883                <= (analysis_category_rank(pair[1].category), &pair[1].rule_id)
4884        }));
4885    }
4886
4887    #[test]
4888    fn change_explanation_combines_diff_and_before_after_provenance() {
4889        let mut previous_score = Score::default();
4890        let voice = &mut previous_score.parts[0].staves[0].measures[0].voices[0];
4891        voice.clear();
4892        for step in [Step::C, Step::E, Step::G] {
4893            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
4894        }
4895        let previous = analyze_score(&previous_score);
4896        let mut current_score = previous_score.clone();
4897        current_score.parts[0].staves[0].measures[0].voices[0][1] =
4898            Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
4899        let current = analyze_score(&current_score);
4900        let address = NoteAddr {
4901            part: 0,
4902            staff: 0,
4903            measure: 0,
4904            voice: 0,
4905            note: 1,
4906        };
4907
4908        let explanation = explain_analysis_change(&previous, &current, &address);
4909        assert!(!explanation.diff.is_empty());
4910        assert!(!explanation.previous.is_empty());
4911        assert!(!explanation.current.is_empty());
4912    }
4913
4914    #[test]
4915    fn analysis_has_stable_score_fingerprint() {
4916        let score = Score::default();
4917        let repeated = analyze_score(&score);
4918        assert_eq!(repeated.score_fingerprint, score_fingerprint(&score));
4919        assert_eq!(
4920            repeated.score_fingerprint,
4921            analyze_score(&score).score_fingerprint
4922        );
4923
4924        let mut changed = score.clone();
4925        changed.metadata.title = "Changed".to_string();
4926        assert_ne!(repeated.score_fingerprint, score_fingerprint(&changed));
4927    }
4928
4929    #[test]
4930    fn fingerprint_uses_fnv1a_byte_order() {
4931        assert_eq!(fnv1a64(b"hello"), 0xa430d84680aabd0b);
4932    }
4933
4934    #[test]
4935    fn cache_key_includes_schema_and_score_identity() {
4936        let result = analyze_score(&Score::default());
4937        assert!(result.cache_key().starts_with("analysis-v14-fnv1a64-"));
4938        assert_eq!(result.cache_key(), analysis_cache_key(&Score::default()));
4939        let mut changed = result.clone();
4940        changed.schema_version = ANALYSIS_SCHEMA_VERSION + 1;
4941        assert_ne!(result.cache_key(), changed.cache_key());
4942    }
4943
4944    #[test]
4945    fn analysis_cache_reuses_identical_score_and_misses_after_edit() {
4946        let score = Score::default();
4947        let mut cache = AnalysisCache::with_capacity(2).unwrap();
4948        let first = cache.analyze(&score);
4949        assert_eq!(cache.len(), 1);
4950        assert!(cache.get(&score).is_some());
4951        let second = cache.analyze(&score);
4952        assert_eq!(first, second);
4953
4954        let mut changed = score.clone();
4955        changed.metadata.title = "changed".to_owned();
4956        assert!(cache.get(&changed).is_none());
4957        let changed_result = cache.analyze(&changed);
4958        assert_ne!(first.score_fingerprint, changed_result.score_fingerprint);
4959        assert_eq!(cache.len(), 2);
4960    }
4961
4962    #[test]
4963    fn analysis_cache_eviction_is_bounded_and_deterministic() {
4964        let mut cache = AnalysisCache::with_capacity(1).unwrap();
4965        let first = Score::default();
4966        let mut second = first.clone();
4967        second.metadata.title = "second".to_owned();
4968        cache.analyze(&first);
4969        cache.analyze(&second);
4970        assert_eq!(cache.len(), 1);
4971        assert!(cache.get(&first).is_none());
4972        assert!(cache.get(&second).is_some());
4973    }
4974
4975    #[test]
4976    fn analysis_cache_batch_preserves_order_and_reuses_duplicate_scores() {
4977        let first = Score::default();
4978        let mut second = first.clone();
4979        second.metadata.title = "second".to_owned();
4980        let mut cache = AnalysisCache::with_capacity(2).unwrap();
4981        let results = cache.analyze_batch(&[second.clone(), first.clone(), second.clone()]);
4982
4983        assert_eq!(results.len(), 3);
4984        assert_eq!(results[0].score_fingerprint, results[2].score_fingerprint);
4985        assert_ne!(results[0].score_fingerprint, results[1].score_fingerprint);
4986        assert_eq!(cache.len(), 2);
4987        assert_eq!(cache.stats(), AnalysisCacheStats { hits: 1, misses: 2 });
4988        cache.reset_stats();
4989        assert_eq!(cache.stats(), AnalysisCacheStats::default());
4990        assert!(cache.get(&first).is_some());
4991    }
4992
4993    #[test]
4994    fn analysis_cache_invalidates_one_score_without_affecting_others() {
4995        let first = Score::default();
4996        let mut second = first.clone();
4997        second.metadata.title = "second".to_owned();
4998        let mut cache = AnalysisCache::with_capacity(2).unwrap();
4999        cache.analyze_batch(&[first.clone(), second.clone()]);
5000
5001        assert!(cache.invalidate(&first));
5002        assert!(!cache.invalidate(&first));
5003        assert!(cache.get(&first).is_none());
5004        assert!(cache.get(&second).is_some());
5005        assert_eq!(cache.len(), 1);
5006    }
5007
5008    #[test]
5009    fn analysis_cache_analyze_after_edit_reclaims_previous_snapshot() {
5010        let previous = Score::default();
5011        let mut current = previous.clone();
5012        current.metadata.title = "edited".to_owned();
5013        let mut cache = AnalysisCache::with_capacity(2).unwrap();
5014        cache.analyze(&previous);
5015
5016        let result = cache.analyze_after_edit(&previous, &current);
5017        assert!(result.matches_score(&current));
5018        assert!(cache.get(&previous).is_none());
5019        assert!(cache.get(&current).is_some());
5020        assert_eq!(cache.len(), 1);
5021    }
5022
5023    #[test]
5024    fn analysis_cache_analyze_after_noop_edit_reuses_existing_result() {
5025        let score = Score::default();
5026        let mut cache = AnalysisCache::with_capacity(1).unwrap();
5027        cache.analyze(&score);
5028        cache.reset_stats();
5029
5030        let result = cache.analyze_after_edit(&score, &score);
5031        assert!(result.matches_score(&score));
5032        assert_eq!(cache.stats(), AnalysisCacheStats { hits: 1, misses: 0 });
5033        assert_eq!(cache.len(), 1);
5034    }
5035
5036    #[test]
5037    fn analysis_cache_edit_with_diff_returns_result_and_changed_categories() {
5038        let previous = Score::default();
5039        let mut current = previous.clone();
5040        let voice = &mut current.parts[0].staves[0].measures[0].voices[0];
5041        voice.clear();
5042        for step in [Step::C, Step::E, Step::G] {
5043            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5044        }
5045        let previous_result = analyze_score(&previous);
5046        let mut cache = AnalysisCache::with_capacity(2).unwrap();
5047        cache.analyze(&previous);
5048
5049        let edited = cache.analyze_after_edit_with_diff(&previous, &previous_result, &current);
5050        assert!(edited.analysis.matches_score(&current));
5051        assert_eq!(edited.diff.changed_categories[0], AnalysisCategory::Chords);
5052        assert!(
5053            edited
5054                .diff
5055                .changed_categories
5056                .contains(&AnalysisCategory::Intervals)
5057        );
5058        assert!(cache.get(&previous).is_none());
5059        assert!(cache.get(&current).is_some());
5060
5061        cache.reset_stats();
5062        let no_op = cache.analyze_after_edit_with_diff(&current, &edited.analysis, &current);
5063        assert!(no_op.diff.is_empty());
5064        assert_eq!(cache.stats(), AnalysisCacheStats { hits: 1, misses: 0 });
5065    }
5066
5067    #[test]
5068    fn selected_analysis_reuses_unchanged_categories_and_rebinds_identity() {
5069        let previous = Score::default();
5070        let previous_result = analyze_score(&previous);
5071        let mut current = previous.clone();
5072        current.metadata.title = "edited".to_owned();
5073        let mut cache = AnalysisCache::with_capacity(2).unwrap();
5074        let result = cache.analyze_selected_after_edit(&previous, &previous_result, &current, &[]);
5075
5076        assert!(result.matches_score(&current));
5077        assert_eq!(result.chords, previous_result.chords);
5078        assert_ne!(result.score_fingerprint, previous_result.score_fingerprint);
5079        assert_eq!(cache.stats(), AnalysisCacheStats { hits: 0, misses: 1 });
5080    }
5081
5082    #[test]
5083    fn hinted_analysis_uses_clean_hint_without_recomputing_categories() {
5084        let previous = Score::default();
5085        let previous_result = analyze_score(&previous);
5086        let mut current = previous.clone();
5087        current.metadata.title = "edited".to_owned();
5088        let hint = ChangeHint {
5089            scope: acorde_core::ChangeScope::Global,
5090            layout_dirty: false,
5091            playback_dirty: false,
5092        };
5093        let mut cache = AnalysisCache::with_capacity(2).unwrap();
5094        let edited =
5095            cache.analyze_after_edit_with_hint(&previous, &previous_result, &current, &hint);
5096
5097        assert!(edited.diff.is_empty());
5098        assert!(edited.analysis.matches_score(&current));
5099        assert_eq!(edited.analysis.chords, previous_result.chords);
5100    }
5101
5102    #[test]
5103    fn analysis_cache_rejects_zero_capacity() {
5104        assert!(matches!(
5105            AnalysisCache::with_capacity(0),
5106            Err(AnalysisCacheError::ZeroCapacity)
5107        ));
5108    }
5109
5110    #[test]
5111    fn analysis_result_rejects_a_different_score() {
5112        let score = Score::default();
5113        let result = analyze_score(&score);
5114        assert!(result.matches_score(&score));
5115        let mut changed = score.clone();
5116        changed.metadata.title = "Changed".to_string();
5117        assert!(!result.matches_score(&changed));
5118    }
5119
5120    #[test]
5121    fn analysis_diff_is_empty_for_identical_results() {
5122        let result = analyze_score(&Score::default());
5123        let diff = diff_analysis(&result, &result);
5124        assert!(diff.is_empty());
5125        assert_eq!(diff.previous_score_fingerprint, result.score_fingerprint);
5126        assert_eq!(diff.current_score_fingerprint, result.score_fingerprint);
5127    }
5128
5129    #[test]
5130    fn analysis_diff_keeps_score_identity_separate_from_category_changes() {
5131        let mut first_score = Score::default();
5132        first_score.metadata.title = "first".to_owned();
5133        let mut second_score = first_score.clone();
5134        second_score.metadata.title = "second".to_owned();
5135        let diff = diff_analysis(&analyze_score(&first_score), &analyze_score(&second_score));
5136
5137        assert!(diff.is_empty());
5138        assert!(diff.changed_categories.is_empty());
5139        assert_ne!(
5140            diff.previous_score_fingerprint,
5141            diff.current_score_fingerprint
5142        );
5143    }
5144
5145    #[test]
5146    fn analysis_diff_reports_changed_categories_in_stable_order() {
5147        let previous = analyze_score(&Score::default());
5148        let mut changed_score = Score::default();
5149        let voice = &mut changed_score.parts[0].staves[0].measures[0].voices[0];
5150        voice.clear();
5151        for step in [Step::C, Step::E, Step::G] {
5152            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5153        }
5154        let current = analyze_score(&changed_score);
5155        let diff = diff_analysis(&previous, &current);
5156
5157        assert!(!diff.is_empty());
5158        assert_eq!(diff.changed_categories[0], AnalysisCategory::Chords);
5159        assert!(
5160            diff.changed_categories
5161                .contains(&AnalysisCategory::Intervals)
5162        );
5163    }
5164
5165    #[test]
5166    fn affected_categories_are_conservative_and_stable() {
5167        let clean = ChangeHint {
5168            scope: acorde_core::ChangeScope::Global,
5169            layout_dirty: false,
5170            playback_dirty: false,
5171        };
5172        assert!(affected_categories_for_change_hint(&clean).is_empty());
5173
5174        let dirty = ChangeHint {
5175            scope: acorde_core::ChangeScope::Measures {
5176                part: 0,
5177                staff: 0,
5178                start: 1,
5179                end: 2,
5180            },
5181            layout_dirty: false,
5182            playback_dirty: true,
5183        };
5184        assert_eq!(
5185            affected_categories_for_change_hint(&dirty),
5186            vec![
5187                AnalysisCategory::Chords,
5188                AnalysisCategory::Intervals,
5189                AnalysisCategory::KeyEstimates,
5190                AnalysisCategory::CadenceCandidates,
5191                AnalysisCategory::VoiceLeading,
5192                AnalysisCategory::SatbDiagnostics,
5193                AnalysisCategory::Motifs,
5194                AnalysisCategory::PhraseBoundaries,
5195            ]
5196        );
5197    }
5198
5199    #[test]
5200    fn refresh_plan_separates_local_and_global_dependencies() {
5201        let hint = ChangeHint {
5202            scope: acorde_core::ChangeScope::Measures {
5203                part: 1,
5204                staff: 2,
5205                start: 3,
5206                end: 4,
5207            },
5208            layout_dirty: true,
5209            playback_dirty: true,
5210        };
5211        let plan = analysis_refresh_plan(&hint);
5212        assert_eq!(
5213            plan.region,
5214            Some(AnalysisRegion {
5215                part: 1,
5216                staff: 2,
5217                start_measure: 3,
5218                end_measure: 4,
5219            })
5220        );
5221        assert!(plan.local_categories.contains(&AnalysisCategory::Chords));
5222        assert!(
5223            plan.local_categories
5224                .contains(&AnalysisCategory::CadenceCandidates)
5225        );
5226        assert_eq!(
5227            plan.global_categories,
5228            vec![AnalysisCategory::KeyEstimates, AnalysisCategory::Motifs]
5229        );
5230        assert_eq!(plan.context_before, 1);
5231        assert_eq!(plan.context_after, 1);
5232    }
5233
5234    #[test]
5235    fn chord_region_keeps_outside_results_and_refreshes_inside() {
5236        let mut score = Score::default();
5237        for measure in score.parts[0].staves[0].measures.iter_mut().take(2) {
5238            let voice = &mut measure.voices[0];
5239            voice.clear();
5240            for step in [Step::C, Step::E, Step::G] {
5241                voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5242            }
5243        }
5244        let region = AnalysisRegion {
5245            part: 0,
5246            staff: 0,
5247            start_measure: 0,
5248            end_measure: 1,
5249        };
5250        let chords = analyze_chords_in_region(&score, Some(&region));
5251        assert_eq!(chords.len(), 1);
5252        assert_eq!(chords[0].address.measure, 0);
5253    }
5254
5255    #[test]
5256    fn local_analysis_refresh_matches_full_pass_after_a_middle_measure_edit() {
5257        let mut score = Score::default();
5258        for measure in &mut score.parts[0].staves[0].measures {
5259            measure.voices[0] = [Step::C, Step::E, Step::G]
5260                .into_iter()
5261                .map(|step| Note::new(Pitch::new(step, 4), Duration::Quarter))
5262                .collect();
5263            measure.voices[1] = [Step::G, Step::A, Step::B]
5264                .into_iter()
5265                .map(|step| Note::new(Pitch::new(step, 3), Duration::Quarter))
5266                .collect();
5267        }
5268        let before = analyze_score(&score);
5269        score.parts[0].staves[0].measures[1].voices[0][1] =
5270            Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
5271        score.parts[0].staves[0].measures[1].voices[1][1] =
5272            Note::new(Pitch::new(Step::F, 3), Duration::Quarter);
5273        let region = AnalysisRegion {
5274            part: 0,
5275            staff: 0,
5276            start_measure: 1,
5277            end_measure: 2,
5278        };
5279        let refreshed = analyze_selected_categories_in_region(
5280            &score,
5281            &before,
5282            &[
5283                AnalysisCategory::Chords,
5284                AnalysisCategory::Intervals,
5285                AnalysisCategory::VoiceLeading,
5286            ],
5287            Some(&region),
5288        );
5289        let full = analyze_score(&score);
5290        assert_eq!(refreshed.chords, full.chords);
5291        assert_eq!(refreshed.intervals, full.intervals);
5292        assert_eq!(refreshed.voice_leading, full.voice_leading);
5293        assert_eq!(refreshed.key_estimates, before.key_estimates);
5294    }
5295
5296    #[test]
5297    fn interval_region_includes_boundary_observations() {
5298        let mut score = Score::default();
5299        for measure in score.parts[0].staves[0].measures.iter_mut().take(2) {
5300            let voice = &mut measure.voices[0];
5301            voice.clear();
5302            voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5303            voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
5304        }
5305        let region = AnalysisRegion {
5306            part: 0,
5307            staff: 0,
5308            start_measure: 0,
5309            end_measure: 1,
5310        };
5311        let intervals = analyze_intervals_in_region(&score, Some(&region));
5312        assert_eq!(intervals.len(), 1);
5313        assert_eq!(intervals[0].from.measure, 0);
5314    }
5315
5316    #[test]
5317    fn voice_leading_region_limits_measure_traversal() {
5318        let mut score = Score::default();
5319        for measure in score.parts[0].staves[0].measures.iter_mut().take(2) {
5320            measure.voices[0].clear();
5321            measure.voices[1].clear();
5322            measure.voices[0].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5323            measure.voices[1].push(Note::new(Pitch::new(Step::G, 3), Duration::Quarter));
5324            measure.voices[0].push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
5325            measure.voices[1].push(Note::new(Pitch::new(Step::A, 3), Duration::Quarter));
5326        }
5327        let region = AnalysisRegion {
5328            part: 0,
5329            staff: 0,
5330            start_measure: 1,
5331            end_measure: 2,
5332        };
5333        let observations = analyze_voice_leading_in_region(&score, Some(&region));
5334        assert_eq!(observations.len(), 1);
5335        assert_eq!(observations[0].upper.measure, 1);
5336    }
5337
5338    #[test]
5339    fn interval_observation_preserves_direction_and_evidence() {
5340        let mut score = Score::default();
5341        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5342        voice.clear();
5343        voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5344        voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
5345        let intervals = analyze_intervals(&score);
5346        assert_eq!(intervals.len(), 1);
5347        assert_eq!(intervals[0].semitones, 7);
5348        assert_eq!(intervals[0].diatonic_steps, 4);
5349        assert_eq!(intervals[0].evidence.len(), 2);
5350    }
5351
5352    #[test]
5353    fn interval_analysis_treats_rests_as_melodic_boundaries() {
5354        let mut score = Score::default();
5355        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5356        voice.clear();
5357        voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5358        voice.push(Note::rest(Duration::Quarter));
5359        voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
5360
5361        assert!(analyze_intervals(&score).is_empty());
5362    }
5363
5364    #[test]
5365    fn interval_observation_preserves_exact_microtonal_distance() {
5366        let mut score = Score::default();
5367        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5368        voice.clear();
5369        voice.push(Note::new(
5370            Pitch::try_with_microtone(Step::C, 4, 0, 25).expect("valid microtone"),
5371            Duration::Quarter,
5372        ));
5373        voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
5374
5375        let intervals = analyze_intervals(&score);
5376        assert_eq!(intervals.len(), 1);
5377        assert_eq!(intervals[0].semitones, 2);
5378        assert_eq!(intervals[0].cents, 175);
5379    }
5380
5381    #[test]
5382    fn key_estimates_report_duration_weighted_coverage() {
5383        let mut score = Score::default();
5384        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5385        voice.clear();
5386        voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Whole));
5387        voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Eighth));
5388
5389        let estimates = estimate_keys(&score);
5390        assert!(!estimates.is_empty());
5391        assert!(estimates.iter().all(|estimate| {
5392            (estimate.total_duration_beats - 4.5).abs() < f64::EPSILON
5393                && estimate.weighted_covered_beats <= estimate.total_duration_beats
5394                && estimate.rule_id == "duration-weighted-diatonic-pitch-coverage"
5395        }));
5396    }
5397
5398    #[test]
5399    fn does_not_invent_label_for_unknown_pitch_set() {
5400        let mut score = Score::default();
5401        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5402        voice.clear();
5403        for step in [Step::C, Step::C] {
5404            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5405        }
5406        assert!(analyze_chords(&score).chords.is_empty());
5407    }
5408
5409    #[test]
5410    fn preserves_relative_major_minor_key_ambiguity() {
5411        let mut score = Score::default();
5412        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5413        voice.clear();
5414        for step in [
5415            Step::C,
5416            Step::D,
5417            Step::E,
5418            Step::F,
5419            Step::G,
5420            Step::A,
5421            Step::B,
5422        ] {
5423            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5424        }
5425        let estimates = estimate_keys(&score);
5426        assert!(
5427            estimates
5428                .iter()
5429                .any(|estimate| estimate.key.display_name() == "C major")
5430        );
5431        assert!(
5432            estimates
5433                .iter()
5434                .any(|estimate| estimate.key.display_name() == "A minor")
5435        );
5436        assert!(estimates.iter().all(|estimate| estimate.confidence == 100));
5437    }
5438
5439    #[test]
5440    fn tonal_profile_ranks_minor_center_without_discarding_legacy_candidates() {
5441        let mut score = Score::default();
5442        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5443        voice.clear();
5444        for (step, duration) in [
5445            (Step::A, Duration::Whole),
5446            (Step::C, Duration::Quarter),
5447            (Step::E, Duration::Quarter),
5448            (Step::A, Duration::Whole),
5449        ] {
5450            voice.push(Note::new(Pitch::new(step, 4), duration));
5451        }
5452
5453        let estimates = estimate_keys(&score);
5454        let original_keys: Vec<_> = estimates
5455            .iter()
5456            .map(|estimate| estimate.key.display_name())
5457            .collect();
5458        assert!(original_keys.contains(&"C major".to_string()));
5459        assert!(original_keys.contains(&"A minor".to_string()));
5460        let ranked = estimate_keys_with_tonal_profile(&score);
5461        assert_eq!(ranked[0].key.display_name(), "A minor");
5462        assert_eq!(ranked.len(), original_keys.len());
5463        assert!(
5464            ranked
5465                .iter()
5466                .all(|estimate| estimate.rule_id.ends_with("krumhansl-profile-ranking"))
5467        );
5468    }
5469
5470    #[test]
5471    fn tonal_profile_does_not_round_microtones_into_key_evidence() {
5472        let mut score = Score::default();
5473        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5474        voice.clear();
5475        voice.push(Note::new(Pitch::new(Step::A, 4), Duration::Quarter));
5476        voice.push(Note::new(
5477            Pitch::with_microtone(Step::C, 5, 0, 25),
5478            Duration::Quarter,
5479        ));
5480        let original = estimate_keys(&score);
5481        assert_eq!(estimate_keys_with_tonal_profile(&score), original);
5482    }
5483
5484    #[test]
5485    fn aarden_essen_profile_keeps_candidates_discarded_by_coverage() {
5486        let mut score = Score::default();
5487        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5488        voice.clear();
5489        for step in [
5490            Step::A,
5491            Step::B,
5492            Step::C,
5493            Step::D,
5494            Step::E,
5495            Step::F,
5496            Step::G,
5497        ] {
5498            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5499        }
5500        let coverage = estimate_keys(&score);
5501        let full = estimate_keys_with_aarden_essen_profile(&score);
5502        assert!(coverage.len() < full.len());
5503        assert_eq!(full.len(), 30);
5504        assert!(
5505            full.iter()
5506                .any(|estimate| estimate.key.display_name() == "A major")
5507        );
5508        assert!(full.iter().all(|estimate| {
5509            estimate.rule_id == "duration-weighted-aarden-essen-profile-ranking"
5510        }));
5511    }
5512
5513    #[test]
5514    fn aarden_essen_profile_falls_back_on_microtones() {
5515        let mut score = Score::default();
5516        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5517        voice.clear();
5518        voice.push(Note::new(Pitch::new(Step::A, 4), Duration::Quarter));
5519        voice.push(Note::new(
5520            Pitch::with_microtone(Step::C, 5, 0, 25),
5521            Duration::Quarter,
5522        ));
5523        assert_eq!(
5524            estimate_keys_with_aarden_essen_profile(&score),
5525            estimate_keys(&score)
5526        );
5527    }
5528
5529    #[test]
5530    fn returns_no_key_for_empty_score() {
5531        assert!(estimate_keys(&Score::default()).is_empty());
5532    }
5533
5534    #[test]
5535    fn batch_and_stream_preserve_score_order() {
5536        let scores = vec![Score::default(), Score::default()];
5537        let batch = analyze_batch(&scores);
5538        let streamed: Vec<_> = analyze_stream(scores.clone()).collect();
5539        assert_eq!(batch, streamed);
5540        assert_eq!(batch.len(), scores.len());
5541    }
5542
5543    #[test]
5544    fn detects_authentic_cadence_from_adjacent_measures() {
5545        let mut score = Score::default();
5546        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5547        voice.clear();
5548        for (step, octave) in [(Step::G, 3), (Step::B, 3), (Step::D, 4)] {
5549            voice.push(Note::new(Pitch::new(step, octave), Duration::Quarter));
5550        }
5551        let voice = &mut score.parts[0].staves[0].measures[1].voices[0];
5552        voice.clear();
5553        for step in [Step::C, Step::E, Step::G] {
5554            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5555        }
5556        let result = analyze_score(&score);
5557        assert_eq!(result.cadence_candidates.len(), 1);
5558        assert_eq!(result.cadence_candidates[0].kind, CadenceKind::Authentic);
5559        assert_eq!(result.cadence_candidates[0].evidence.len(), 2);
5560    }
5561
5562    #[test]
5563    fn flags_parallel_octaves_between_aligned_voices() {
5564        let mut score = Score::default();
5565        let measure = &mut score.parts[0].staves[0].measures[0];
5566        measure.voices[0].clear();
5567        measure.voices[1].clear();
5568        for (upper, lower) in [(Step::C, Step::C), (Step::D, Step::D)] {
5569            measure.voices[0].push(Note::new(Pitch::new(upper, 4), Duration::Quarter));
5570            measure.voices[1].push(Note::new(Pitch::new(lower, 3), Duration::Quarter));
5571        }
5572        let observations = analyze_voice_leading(&score);
5573        assert_eq!(observations.len(), 1);
5574        assert!(observations[0].parallel_perfect);
5575        assert_eq!(observations[0].upper_motion_cents, 200);
5576        assert_eq!(observations[0].lower_motion_cents, 200);
5577        assert_eq!(observations[0].evidence.len(), 2);
5578        let diagnostics = analyze_satb(&score);
5579        assert_eq!(diagnostics.len(), 1);
5580        assert_eq!(diagnostics[0].kind, SatbDiagnosticKind::ParallelPerfect);
5581        assert_eq!(diagnostics[0].severity, SatbSeverity::Warning);
5582    }
5583
5584    #[test]
5585    fn located_quartets_include_cross_bar_nonadjacent_voices() {
5586        let mut score = Score::default();
5587        for (measure_index, pitches) in [
5588            [(Step::G, 4), (Step::E, 4), (Step::C, 4)],
5589            [(Step::A, 4), (Step::F, 4), (Step::D, 4)],
5590        ]
5591        .into_iter()
5592        .enumerate()
5593        {
5594            let measure = &mut score.parts[0].staves[0].measures[measure_index];
5595            for (voice_index, (step, octave)) in pitches.into_iter().enumerate() {
5596                measure.voices[voice_index] =
5597                    vec![Note::new(Pitch::new(step, octave), Duration::Whole)];
5598            }
5599        }
5600        let quartets = analyze_voice_leading_quartets(&score);
5601        assert_eq!(quartets.len(), 3);
5602        let outer = quartets
5603            .iter()
5604            .find(|quartet| quartet.second_before.address.voice == 2)
5605            .unwrap();
5606        assert_eq!(outer.first_before.address.measure, 0);
5607        assert_eq!(outer.first_after.address.measure, 1);
5608        assert_eq!(outer.first_after.onset_beats, 4.0);
5609        assert_eq!(outer.second_before.duration_beats, 4.0);
5610        assert!(outer.similar_motion);
5611        assert!(outer.parallel_fifth);
5612        assert_eq!(outer.evidence.len(), 4);
5613    }
5614
5615    #[test]
5616    fn located_quartets_separate_perfect_spelling_and_motion() {
5617        let mut score = Score::default();
5618        let measure = &mut score.parts[0].staves[0].measures[0];
5619        measure.voices[0] = vec![
5620            Note::new(Pitch::new(Step::G, 4), Duration::Quarter),
5621            Note::new(Pitch::new(Step::A, 4), Duration::Quarter),
5622        ];
5623        measure.voices[1] = vec![
5624            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
5625            Note::new(Pitch::new(Step::D, 3), Duration::Quarter),
5626        ];
5627        let quartet = &analyze_voice_leading_quartets(&score)[0];
5628        assert!(quartet.contrary_motion);
5629        assert!(!quartet.similar_motion);
5630        assert!(quartet.parallel_fifth);
5631        assert!(!quartet.parallel_octave);
5632
5633        score.parts[0].staves[0].measures[0].voices[1][0].pitches[0] =
5634            Pitch::with_alter(Step::C, 4, 1);
5635        assert!(!analyze_voice_leading_quartets(&score)[0].parallel_fifth);
5636    }
5637
5638    #[test]
5639    fn located_quartets_align_distinct_parts_and_reject_overfull_bars() {
5640        let mut score = Score::default();
5641        score.parts.push(score.parts[0].clone());
5642        score.parts[0].staves[0].measures[0].voices[0] = vec![
5643            Note::new(Pitch::new(Step::C, 5), Duration::Half),
5644            Note::new(Pitch::new(Step::D, 5), Duration::Half),
5645        ];
5646        score.parts[1].staves[0].measures[0].voices[0] = vec![
5647            Note::new(Pitch::new(Step::C, 4), Duration::Half),
5648            Note::new(Pitch::new(Step::D, 4), Duration::Half),
5649        ];
5650        let quartets = analyze_voice_leading_quartets(&score);
5651        assert_eq!(quartets.len(), 1);
5652        assert_eq!(quartets[0].first_before.address.part, 0);
5653        assert_eq!(quartets[0].second_before.address.part, 1);
5654        assert!(quartets[0].parallel_octave);
5655
5656        score.parts[1].staves[0].measures[0].voices[0]
5657            .push(Note::new(Pitch::new(Step::E, 4), Duration::Quarter));
5658        assert!(analyze_voice_leading_quartets(&score).is_empty());
5659    }
5660
5661    #[test]
5662    fn located_quartets_do_not_bridge_rests_or_call_unisons_octaves() {
5663        let mut score = Score::default();
5664        {
5665            let measure = &mut score.parts[0].staves[0].measures[0];
5666            for voice in measure.voices.iter_mut().take(2) {
5667                *voice = vec![
5668                    Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
5669                    Note::rest(Duration::Quarter),
5670                    Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
5671                ];
5672            }
5673        }
5674        assert!(analyze_voice_leading_quartets(&score).is_empty());
5675        for voice in score.parts[0].staves[0].measures[0]
5676            .voices
5677            .iter_mut()
5678            .take(2)
5679        {
5680            voice.remove(1);
5681        }
5682        let quartet = &analyze_voice_leading_quartets(&score)[0];
5683        assert!(quartet.similar_motion);
5684        assert!(!quartet.parallel_octave);
5685    }
5686
5687    #[test]
5688    fn keyless_quartet_resolution_checks_each_dissonance_family() {
5689        let pitch = |step, octave| Pitch::new(step, octave);
5690        let flat = |step, octave| Pitch::with_alter(step, octave, -1);
5691        let resolves = |a: Pitch, b: Pitch, c: Pitch, d: Pitch| {
5692            let first_motion = b.to_midi_cents() - a.to_midi_cents();
5693            let second_motion = d.to_midi_cents() - c.to_midi_cents();
5694            keyless_quartet_resolution(&a, &b, &c, &d, first_motion, second_motion)
5695        };
5696        // Perfect fourth: the first voice may stay or descend.
5697        assert!(!resolves(
5698            pitch(Step::F, 5),
5699            pitch(Step::G, 5),
5700            pitch(Step::C, 4),
5701            pitch(Step::C, 4)
5702        ));
5703        assert!(resolves(
5704            pitch(Step::F, 5),
5705            pitch(Step::E, 5),
5706            pitch(Step::C, 4),
5707            pitch(Step::C, 4)
5708        ));
5709        // Augmented fourth opens to a sixth; diminished fifth closes to a third.
5710        assert!(resolves(
5711            pitch(Step::E, 5),
5712            pitch(Step::F, 5),
5713            flat(Step::B, 4),
5714            pitch(Step::A, 4)
5715        ));
5716        assert!(resolves(
5717            flat(Step::B, 4),
5718            pitch(Step::A, 4),
5719            pitch(Step::E, 4),
5720            pitch(Step::F, 4)
5721        ));
5722        // Minor seventh must arrive at a generic third.
5723        assert!(resolves(
5724            flat(Step::B, 4),
5725            pitch(Step::A, 4),
5726            pitch(Step::C, 4),
5727            pitch(Step::F, 4)
5728        ));
5729        assert!(!resolves(
5730            flat(Step::B, 4),
5731            pitch(Step::A, 4),
5732            pitch(Step::C, 4),
5733            pitch(Step::G, 4)
5734        ));
5735    }
5736
5737    #[test]
5738    fn voice_leading_does_not_bridge_rests() {
5739        let mut score = Score::default();
5740        let measure = &mut score.parts[0].staves[0].measures[0];
5741        measure.voices[0].clear();
5742        measure.voices[1].clear();
5743        let (upper_voices, lower_voices) = measure.voices.split_at_mut(1);
5744        for voice in [&mut upper_voices[0], &mut lower_voices[0]] {
5745            voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5746            voice.push(Note::rest(Duration::Quarter));
5747            voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
5748        }
5749        assert!(analyze_voice_leading(&score).is_empty());
5750    }
5751
5752    #[test]
5753    fn voice_leading_uses_written_onsets_instead_of_matching_note_indices() {
5754        let mut score = Score::default();
5755        let measure = &mut score.parts[0].staves[0].measures[0];
5756        measure.voices[0] = vec![
5757            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
5758            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
5759        ];
5760        measure.voices[1] = vec![
5761            Note::new(Pitch::new(Step::C, 3), Duration::Half),
5762            Note::new(Pitch::new(Step::D, 3), Duration::Quarter),
5763        ];
5764        let observations = analyze_voice_leading(&score);
5765        assert_eq!(observations.len(), 1);
5766        assert_eq!(observations[0].upper_motion_cents, 200);
5767        assert_eq!(observations[0].lower_motion_cents, 0);
5768        assert!(!observations[0].parallel_perfect);
5769        assert_eq!(observations[0].lower.note, 0);
5770    }
5771
5772    #[test]
5773    fn satb_does_not_compare_notes_at_different_written_beats() {
5774        let mut score = Score::default();
5775        let measure = &mut score.parts[0].staves[0].measures[0];
5776        measure.voices[0] = vec![
5777            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
5778            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
5779        ];
5780        measure.voices[1] = vec![
5781            Note::new(Pitch::new(Step::C, 3), Duration::Half),
5782            Note::new(Pitch::new(Step::D, 5), Duration::Quarter),
5783        ];
5784        assert!(analyze_satb(&score).is_empty());
5785    }
5786
5787    #[test]
5788    fn written_timeline_uses_actual_length_for_a_lone_whole_rest() {
5789        let mut score = Score::new("pickup", 120, 3, 4, 0, 1);
5790        let measure = &mut score.parts[0].staves[0].measures[0];
5791        measure.voices[0] = vec![Note::rest(Duration::Whole)];
5792        let events = written_measure_events(&score, 0);
5793        assert_eq!(events.len(), 1);
5794        assert_eq!(events[0].start, 0.0);
5795        assert_eq!(events[0].end, 3.0);
5796    }
5797
5798    #[test]
5799    fn reports_voice_crossing_as_error() {
5800        let mut score = Score::default();
5801        let measure = &mut score.parts[0].staves[0].measures[0];
5802        measure.voices[0].clear();
5803        measure.voices[1].clear();
5804        measure.voices[0].push(Note::new(Pitch::new(Step::C, 3), Duration::Quarter));
5805        measure.voices[1].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5806        let diagnostics = analyze_satb(&score);
5807        assert_eq!(diagnostics.len(), 1);
5808        assert_eq!(diagnostics[0].kind, SatbDiagnosticKind::VoiceCrossing);
5809        assert_eq!(diagnostics[0].severity, SatbSeverity::Error);
5810    }
5811
5812    #[test]
5813    fn satb_region_replaces_local_findings_and_preserves_outside_findings() {
5814        let mut previous_score = Score::default();
5815        for measure in &mut previous_score.parts[0].staves[0].measures {
5816            measure.voices[0].clear();
5817            measure.voices[1].clear();
5818            measure.voices[0].push(Note::new(Pitch::new(Step::C, 3), Duration::Quarter));
5819            measure.voices[1].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5820        }
5821        let previous = analyze_score(&previous_score);
5822
5823        let mut edited_score = previous_score.clone();
5824        let measure = &mut edited_score.parts[0].staves[0].measures[0];
5825        measure.voices[0][0] = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
5826        let region = AnalysisRegion {
5827            part: 0,
5828            staff: 0,
5829            start_measure: 0,
5830            end_measure: 1,
5831        };
5832        let refreshed = analyze_selected_categories_in_region(
5833            &edited_score,
5834            &previous,
5835            &[AnalysisCategory::SatbDiagnostics],
5836            Some(&region),
5837        );
5838
5839        assert_eq!(refreshed.satb_diagnostics.len(), 3);
5840        assert!(
5841            refreshed
5842                .satb_diagnostics
5843                .iter()
5844                .all(|diagnostic| diagnostic.upper.measure >= 1)
5845        );
5846    }
5847
5848    #[test]
5849    fn finds_repeated_melodic_motif_with_source_spans() {
5850        let mut score = Score::default();
5851        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5852        voice.clear();
5853        for step in [Step::C, Step::D, Step::E, Step::G, Step::A, Step::B] {
5854            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
5855        }
5856        let motifs = analyze_motifs(&score);
5857        assert_eq!(motifs.len(), 1);
5858        assert_eq!(motifs[0].signature, vec![2, 2]);
5859        assert_eq!(motifs[0].occurrences.len(), 2);
5860        assert_eq!(motifs[0].occurrences[0].evidence.len(), 3);
5861    }
5862
5863    #[test]
5864    fn reports_measure_ending_rest_as_phrase_boundary() {
5865        let mut score = Score::default();
5866        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
5867        voice.clear();
5868        voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
5869        voice.push(Note::rest(Duration::Quarter));
5870        let boundaries = analyze_phrase_boundaries(&score);
5871        assert!(boundaries.iter().any(|boundary| {
5872            boundary.reason == PhraseBoundaryReason::RestTermination
5873                && boundary.address.measure == 0
5874                && boundary.address.note == 1
5875        }));
5876    }
5877
5878    #[test]
5879    fn benchmark_reports_perfect_scores_for_hand_verified_empty_fixture() {
5880        let score = Score::default();
5881        let cases = [BenchmarkCase {
5882            name: "empty-score",
5883            score: &score,
5884            expected: BenchmarkExpectation {
5885                phrase_boundaries: 4,
5886                ..BenchmarkExpectation::default()
5887            },
5888        }];
5889        let reports = run_benchmark(&cases);
5890        assert_eq!(reports.len(), 1);
5891        assert_eq!(reports[0].precision_percent, 100);
5892        assert_eq!(reports[0].recall_percent, 100);
5893        assert_eq!(reports[0].explanation_completeness_percent, 100);
5894        assert!(reports[0].failures.is_empty());
5895    }
5896
5897    #[test]
5898    fn benchmark_reports_category_level_failure_details() {
5899        let score = Score::default();
5900        let cases = [BenchmarkCase {
5901            name: "under-annotated-score",
5902            score: &score,
5903            expected: BenchmarkExpectation {
5904                phrase_boundaries: 5,
5905                ..BenchmarkExpectation::default()
5906            },
5907        }];
5908        let reports = run_benchmark(&cases);
5909        assert_eq!(
5910            reports[0].failures,
5911            vec![BenchmarkFailure {
5912                category: BenchmarkCategory::PhraseBoundaries,
5913                expected: 5,
5914                predicted: 4,
5915                missing: 1,
5916                excess: 0,
5917            }]
5918        );
5919    }
5920
5921    #[test]
5922    fn benchmark_suite_aggregates_case_status_and_metrics() {
5923        let score = Score::default();
5924        let cases = [
5925            BenchmarkCase {
5926                name: "passing",
5927                score: &score,
5928                expected: BenchmarkExpectation {
5929                    phrase_boundaries: 4,
5930                    ..BenchmarkExpectation::default()
5931                },
5932            },
5933            BenchmarkCase {
5934                name: "failing",
5935                score: &score,
5936                expected: BenchmarkExpectation {
5937                    phrase_boundaries: 5,
5938                    ..BenchmarkExpectation::default()
5939                },
5940            },
5941        ];
5942        let suite = run_benchmark_suite(&cases);
5943        assert_eq!(suite.case_count, 2);
5944        assert_eq!(suite.passed_case_count, 1);
5945        assert_eq!(suite.failed_case_count, 1);
5946        assert_eq!(suite.cases.len(), 2);
5947        assert_eq!(suite.precision_percent, 100);
5948        assert_eq!(suite.recall_percent, 90);
5949        assert_eq!(suite.explanation_completeness_percent, 100);
5950    }
5951
5952    struct TestPass(&'static str);
5953
5954    impl AnalysisPass for TestPass {
5955        fn id(&self) -> &str {
5956            self.0
5957        }
5958
5959        fn run(&self, _score: &Score) -> serde_json::Value {
5960            serde_json::json!({ "pass": self.0 })
5961        }
5962    }
5963
5964    #[test]
5965    fn extension_passes_run_in_stable_id_order() {
5966        let score = Score::default();
5967        let beta = TestPass("beta");
5968        let alpha = TestPass("alpha");
5969        let results = run_analysis_passes(&score, &[&beta, &alpha]).unwrap();
5970        assert_eq!(
5971            results
5972                .iter()
5973                .map(|result| result.pass_id.as_str())
5974                .collect::<Vec<_>>(),
5975            ["alpha", "beta"]
5976        );
5977        assert_eq!(results[0].output["pass"], "alpha");
5978    }
5979
5980    #[test]
5981    fn extension_passes_reject_empty_and_duplicate_ids() {
5982        let score = Score::default();
5983        let empty = TestPass("");
5984        assert_eq!(
5985            run_analysis_passes(&score, &[&empty]),
5986            Err(AnalysisPassError::EmptyId)
5987        );
5988        let first = TestPass("same");
5989        let second = TestPass("same");
5990        assert_eq!(
5991            run_analysis_passes(&score, &[&first, &second]),
5992            Err(AnalysisPassError::DuplicateId("same".to_string()))
5993        );
5994    }
5995}