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, Score, detect_chord, roman_numeral,
5};
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, HashMap, VecDeque};
8use thiserror::Error;
9
10/// Version of the serialized analysis result contract.
11pub const ANALYSIS_SCHEMA_VERSION: u32 = 13;
12
13/// A chord label with source evidence and the rule that produced it.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct ChordLabel {
16    pub address: NoteAddr,
17    pub chord: ChordSymbol,
18    /// Canonical human-readable spelling derived from `chord`.
19    #[serde(default)]
20    pub name: String,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub roman_numeral: Option<String>,
23    pub confidence: u8,
24    pub rule_id: String,
25    pub evidence: Vec<NoteAddr>,
26}
27
28/// Deterministic output of the chord-analysis pass.
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30pub struct AnalysisResult {
31    pub schema_version: u32,
32    /// Deterministic fingerprint of the canonical input score content.
33    #[serde(default)]
34    pub score_fingerprint: String,
35    pub chords: Vec<ChordLabel>,
36    pub intervals: Vec<IntervalObservation>,
37    #[serde(default)]
38    pub key_estimates: Vec<KeyEstimate>,
39    #[serde(default)]
40    pub cadence_candidates: Vec<CadenceCandidate>,
41    #[serde(default)]
42    pub voice_leading: Vec<VoiceLeadingObservation>,
43    #[serde(default)]
44    pub satb_diagnostics: Vec<SatbDiagnostic>,
45    #[serde(default)]
46    pub motifs: Vec<MotifPattern>,
47    #[serde(default)]
48    pub phrase_boundaries: Vec<PhraseBoundary>,
49}
50
51/// One complete-analysis category that can change between two results.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53pub enum AnalysisCategory {
54    Chords,
55    Intervals,
56    KeyEstimates,
57    CadenceCandidates,
58    VoiceLeading,
59    SatbDiagnostics,
60    Motifs,
61    PhraseBoundaries,
62}
63
64/// A changed part/staff measure interval used by incremental editor planning.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct AnalysisRegion {
67    pub part: usize,
68    pub staff: usize,
69    pub start_measure: usize,
70    pub end_measure: usize,
71}
72
73/// Dependency-aware refresh plan for a score edit.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct AnalysisRefreshPlan {
76    pub local_categories: Vec<AnalysisCategory>,
77    pub global_categories: Vec<AnalysisCategory>,
78    pub region: Option<AnalysisRegion>,
79    pub context_before: usize,
80    pub context_after: usize,
81}
82
83/// Build a conservative refresh plan from an engine change hint.
84pub fn analysis_refresh_plan(hint: &ChangeHint) -> AnalysisRefreshPlan {
85    let all = all_analysis_categories();
86    if !hint.layout_dirty && !hint.playback_dirty {
87        return AnalysisRefreshPlan {
88            local_categories: Vec::new(),
89            global_categories: Vec::new(),
90            region: None,
91            context_before: 0,
92            context_after: 0,
93        };
94    }
95    match &hint.scope {
96        acorde_core::ChangeScope::Measures {
97            part,
98            staff,
99            start,
100            end,
101        } => AnalysisRefreshPlan {
102            local_categories: vec![
103                AnalysisCategory::Chords,
104                AnalysisCategory::Intervals,
105                AnalysisCategory::CadenceCandidates,
106                AnalysisCategory::VoiceLeading,
107                AnalysisCategory::SatbDiagnostics,
108                AnalysisCategory::PhraseBoundaries,
109            ],
110            global_categories: vec![AnalysisCategory::KeyEstimates, AnalysisCategory::Motifs],
111            region: Some(AnalysisRegion {
112                part: *part,
113                staff: *staff,
114                start_measure: *start,
115                end_measure: *end,
116            }),
117            context_before: 1,
118            context_after: 1,
119        },
120        acorde_core::ChangeScope::Part(_) | acorde_core::ChangeScope::Global => {
121            AnalysisRefreshPlan {
122                local_categories: Vec::new(),
123                global_categories: all,
124                region: None,
125                context_before: 0,
126                context_after: 0,
127            }
128        }
129    }
130}
131
132fn all_analysis_categories() -> Vec<AnalysisCategory> {
133    vec![
134        AnalysisCategory::Chords,
135        AnalysisCategory::Intervals,
136        AnalysisCategory::KeyEstimates,
137        AnalysisCategory::CadenceCandidates,
138        AnalysisCategory::VoiceLeading,
139        AnalysisCategory::SatbDiagnostics,
140        AnalysisCategory::Motifs,
141        AnalysisCategory::PhraseBoundaries,
142    ]
143}
144
145/// Return the complete-analysis categories that a dirty engine hint may affect.
146///
147/// The mapping is intentionally conservative: any layout or playback dirty hint may alter
148/// score content or temporal context, so all categories are returned. A clean hint is known to
149/// affect neither analysis input nor context and returns an empty list.
150pub fn affected_categories_for_change_hint(hint: &ChangeHint) -> Vec<AnalysisCategory> {
151    let plan = analysis_refresh_plan(hint);
152    let selected: Vec<_> = plan
153        .local_categories
154        .into_iter()
155        .chain(plan.global_categories)
156        .collect();
157    all_analysis_categories()
158        .into_iter()
159        .filter(|category| selected.contains(category))
160        .collect()
161}
162
163/// Deterministic category-level diff between two analysis results.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct AnalysisDiff {
166    pub previous_score_fingerprint: String,
167    pub current_score_fingerprint: String,
168    pub schema_changed: bool,
169    pub changed_categories: Vec<AnalysisCategory>,
170}
171
172/// Analysis result and its deterministic diff from a previous result.
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
174pub struct AnalysisEditResult {
175    pub analysis: AnalysisResult,
176    pub diff: AnalysisDiff,
177}
178
179/// One deterministic explanation attached to a source note address.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct AnalysisProvenance {
182    pub category: AnalysisCategory,
183    pub rule_id: String,
184    pub confidence: u8,
185    pub evidence: Vec<NoteAddr>,
186    /// Canonical display label when the finding is a chord explanation.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub label: Option<String>,
189}
190
191/// Return every analysis finding whose evidence contains the supplied source address.
192pub fn analysis_provenance(
193    analysis: &AnalysisResult,
194    address: &NoteAddr,
195) -> Vec<AnalysisProvenance> {
196    let mut findings = Vec::new();
197    let mut add = |category, rule_id: &str, confidence, evidence: &[NoteAddr]| {
198        if evidence.iter().any(|item| item == address) {
199            findings.push(AnalysisProvenance {
200                category,
201                rule_id: rule_id.to_string(),
202                confidence,
203                evidence: evidence.to_vec(),
204                label: None,
205            });
206        }
207    };
208    for item in &analysis.chords {
209        add(
210            AnalysisCategory::Chords,
211            &item.rule_id,
212            item.confidence,
213            &item.evidence,
214        );
215    }
216    for item in &analysis.intervals {
217        add(
218            AnalysisCategory::Intervals,
219            &item.rule_id,
220            100,
221            &item.evidence,
222        );
223    }
224    for item in &analysis.key_estimates {
225        add(
226            AnalysisCategory::KeyEstimates,
227            &item.rule_id,
228            item.confidence,
229            &item.evidence,
230        );
231    }
232    for item in &analysis.cadence_candidates {
233        add(
234            AnalysisCategory::CadenceCandidates,
235            &item.rule_id,
236            item.confidence,
237            &item.evidence,
238        );
239    }
240    for item in &analysis.voice_leading {
241        add(
242            AnalysisCategory::VoiceLeading,
243            &item.rule_id,
244            item.confidence,
245            &item.evidence,
246        );
247    }
248    for item in &analysis.satb_diagnostics {
249        add(
250            AnalysisCategory::SatbDiagnostics,
251            &item.rule_id,
252            item.confidence,
253            &item.evidence,
254        );
255    }
256    for item in &analysis.motifs {
257        for occurrence in &item.occurrences {
258            add(
259                AnalysisCategory::Motifs,
260                &item.rule_id,
261                item.confidence,
262                &occurrence.evidence,
263            );
264        }
265    }
266    for item in &analysis.phrase_boundaries {
267        add(
268            AnalysisCategory::PhraseBoundaries,
269            &item.rule_id,
270            item.confidence,
271            &item.evidence,
272        );
273    }
274    for finding in &mut findings {
275        if finding.category == AnalysisCategory::Chords {
276            finding.label = analysis
277                .chords
278                .iter()
279                .find(|item| item.rule_id == finding.rule_id && item.evidence == finding.evidence)
280                .map(|item| item.name.clone());
281        }
282    }
283    findings.sort_by_key(|item| {
284        (
285            analysis_category_rank(item.category),
286            item.rule_id.clone(),
287            item.evidence
288                .iter()
289                .map(|address| {
290                    (
291                        address.part,
292                        address.staff,
293                        address.measure,
294                        address.voice,
295                        address.note,
296                    )
297                })
298                .collect::<Vec<_>>(),
299        )
300    });
301    findings
302}
303
304/// A score diff together with the explanations affected at one source address.
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub struct AnalysisChangeExplanation {
307    pub diff: AnalysisDiff,
308    pub previous: Vec<AnalysisProvenance>,
309    pub current: Vec<AnalysisProvenance>,
310}
311
312/// Compare two analysis results and explain the findings attached to one source address.
313pub fn explain_analysis_change(
314    previous: &AnalysisResult,
315    current: &AnalysisResult,
316    address: &NoteAddr,
317) -> AnalysisChangeExplanation {
318    AnalysisChangeExplanation {
319        diff: diff_analysis(previous, current),
320        previous: analysis_provenance(previous, address),
321        current: analysis_provenance(current, address),
322    }
323}
324
325fn analysis_category_rank(category: AnalysisCategory) -> u8 {
326    match category {
327        AnalysisCategory::Chords => 0,
328        AnalysisCategory::Intervals => 1,
329        AnalysisCategory::KeyEstimates => 2,
330        AnalysisCategory::CadenceCandidates => 3,
331        AnalysisCategory::VoiceLeading => 4,
332        AnalysisCategory::SatbDiagnostics => 5,
333        AnalysisCategory::Motifs => 6,
334        AnalysisCategory::PhraseBoundaries => 7,
335    }
336}
337
338impl AnalysisDiff {
339    /// Return whether any schema or analysis category changed.
340    pub fn is_empty(&self) -> bool {
341        !self.schema_changed && self.changed_categories.is_empty()
342    }
343}
344
345/// Compare two complete results in stable category order.
346pub fn diff_analysis(previous: &AnalysisResult, current: &AnalysisResult) -> AnalysisDiff {
347    let mut changed_categories = Vec::new();
348    if previous.chords != current.chords {
349        changed_categories.push(AnalysisCategory::Chords);
350    }
351    if previous.intervals != current.intervals {
352        changed_categories.push(AnalysisCategory::Intervals);
353    }
354    if previous.key_estimates != current.key_estimates {
355        changed_categories.push(AnalysisCategory::KeyEstimates);
356    }
357    if previous.cadence_candidates != current.cadence_candidates {
358        changed_categories.push(AnalysisCategory::CadenceCandidates);
359    }
360    if previous.voice_leading != current.voice_leading {
361        changed_categories.push(AnalysisCategory::VoiceLeading);
362    }
363    if previous.satb_diagnostics != current.satb_diagnostics {
364        changed_categories.push(AnalysisCategory::SatbDiagnostics);
365    }
366    if previous.motifs != current.motifs {
367        changed_categories.push(AnalysisCategory::Motifs);
368    }
369    if previous.phrase_boundaries != current.phrase_boundaries {
370        changed_categories.push(AnalysisCategory::PhraseBoundaries);
371    }
372    AnalysisDiff {
373        previous_score_fingerprint: previous.score_fingerprint.clone(),
374        current_score_fingerprint: current.score_fingerprint.clone(),
375        schema_changed: previous.schema_version != current.schema_version,
376        changed_categories,
377    }
378}
379
380impl AnalysisResult {
381    /// Return a cache key that invalidates when either the input or result schema changes.
382    pub fn cache_key(&self) -> String {
383        format!(
384            "analysis-v{}-{}",
385            self.schema_version, self.score_fingerprint
386        )
387    }
388
389    /// Check whether this result was produced from the supplied score.
390    pub fn matches_score(&self, score: &Score) -> bool {
391        self.score_fingerprint == score_fingerprint(score)
392    }
393}
394
395/// A host- or application-provided deterministic analysis extension.
396pub trait AnalysisPass {
397    /// Stable identifier used for ordering and persisted result lookup.
398    fn id(&self) -> &str;
399
400    /// Run the pass over a score and return its JSON payload.
401    fn run(&self, score: &Score) -> serde_json::Value;
402}
403
404/// Validation failures for a registered analysis pass set.
405#[derive(Debug, Error, Clone, PartialEq, Eq)]
406pub enum AnalysisPassError {
407    #[error("analysis pass ID must not be empty")]
408    EmptyId,
409    #[error("duplicate analysis pass ID: {0}")]
410    DuplicateId(String),
411}
412
413/// Result of one registered extension pass.
414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
415pub struct AnalysisPassResult {
416    pub pass_id: String,
417    pub output: serde_json::Value,
418}
419
420/// Run extension passes in stable ID order after validating their identifiers.
421pub fn run_analysis_passes(
422    score: &Score,
423    passes: &[&dyn AnalysisPass],
424) -> Result<Vec<AnalysisPassResult>, AnalysisPassError> {
425    let mut ordered: Vec<&dyn AnalysisPass> = passes.to_vec();
426    for pass in &ordered {
427        if pass.id().is_empty() {
428            return Err(AnalysisPassError::EmptyId);
429        }
430    }
431    ordered.sort_by(|left, right| left.id().cmp(right.id()));
432    for pair in ordered.windows(2) {
433        if pair[0].id() == pair[1].id() {
434            return Err(AnalysisPassError::DuplicateId(pair[0].id().to_string()));
435        }
436    }
437    Ok(ordered
438        .into_iter()
439        .map(|pass| AnalysisPassResult {
440            pass_id: pass.id().to_string(),
441            output: pass.run(score),
442        })
443        .collect())
444}
445
446/// A deterministic key candidate ranked by duration-weighted diatonic pitch coverage.
447#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
448pub struct KeyEstimate {
449    pub key: KeySignature,
450    pub covered_pitches: usize,
451    pub total_pitches: usize,
452    /// Sum of note durations whose pitches belong to this key candidate.
453    #[serde(default)]
454    pub weighted_covered_beats: f64,
455    /// Sum of pitched-note durations used as the weighting denominator.
456    #[serde(default)]
457    pub total_duration_beats: f64,
458    pub confidence: u8,
459    pub rule_id: String,
460    pub evidence: Vec<NoteAddr>,
461}
462
463/// A cadence transition inferred only from adjacent, explicitly labeled chords.
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465pub struct CadenceCandidate {
466    pub from: NoteAddr,
467    pub to: NoteAddr,
468    pub kind: CadenceKind,
469    pub confidence: u8,
470    pub rule_id: String,
471    pub evidence: Vec<NoteAddr>,
472}
473
474/// Supported cadence transition families.
475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
476pub enum CadenceKind {
477    Authentic,
478    Plagal,
479    Deceptive,
480    Half,
481}
482
483/// Voice-leading observation for two adjacent voices at an aligned event.
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
485pub struct VoiceLeadingObservation {
486    pub upper: NoteAddr,
487    pub lower: NoteAddr,
488    pub upper_motion: i16,
489    pub lower_motion: i16,
490    /// Exact signed upper-voice motion in cents.
491    #[serde(default)]
492    pub upper_motion_cents: i32,
493    /// Exact signed lower-voice motion in cents.
494    #[serde(default)]
495    pub lower_motion_cents: i32,
496    pub parallel_perfect: bool,
497    pub confidence: u8,
498    pub rule_id: String,
499    pub evidence: Vec<NoteAddr>,
500}
501
502/// A typed SATB constraint finding with source addresses for UI selection.
503#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
504pub struct SatbDiagnostic {
505    pub upper: NoteAddr,
506    pub lower: NoteAddr,
507    pub kind: SatbDiagnosticKind,
508    pub severity: SatbSeverity,
509    pub confidence: u8,
510    pub rule_id: String,
511    pub evidence: Vec<NoteAddr>,
512}
513
514/// SATB constraint families reported by the deterministic pass.
515#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
516pub enum SatbDiagnosticKind {
517    VoiceCrossing,
518    WideSpacing,
519    ParallelPerfect,
520}
521
522/// User-facing seriousness of a SATB diagnostic.
523#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
524pub enum SatbSeverity {
525    Error,
526    Warning,
527}
528
529/// A repeated melodic interval pattern with all matching source occurrences.
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
531pub struct MotifPattern {
532    pub signature: Vec<i8>,
533    pub occurrences: Vec<MotifOccurrence>,
534    pub confidence: u8,
535    pub rule_id: String,
536}
537
538/// One source span matching a motif pattern.
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540pub struct MotifOccurrence {
541    pub start: NoteAddr,
542    pub end: NoteAddr,
543    pub evidence: Vec<NoteAddr>,
544}
545
546/// A phrase boundary supported by an explicit rest at the end of a measure.
547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
548pub struct PhraseBoundary {
549    pub address: NoteAddr,
550    pub reason: PhraseBoundaryReason,
551    pub confidence: u8,
552    pub rule_id: String,
553    pub evidence: Vec<NoteAddr>,
554}
555
556/// Evidence categories for phrase boundaries.
557#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
558pub enum PhraseBoundaryReason {
559    RestTermination,
560}
561
562/// Hand-verified expected counts for one analysis benchmark fixture.
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
564pub struct BenchmarkExpectation {
565    pub chords: usize,
566    pub intervals: usize,
567    pub key_estimates: usize,
568    pub cadence_candidates: usize,
569    pub voice_leading: usize,
570    pub satb_diagnostics: usize,
571    pub motifs: usize,
572    pub phrase_boundaries: usize,
573}
574
575/// Predicted category counts used by the benchmark report.
576#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
577pub struct AnalysisCounts {
578    pub chords: usize,
579    pub intervals: usize,
580    pub key_estimates: usize,
581    pub cadence_candidates: usize,
582    pub voice_leading: usize,
583    pub satb_diagnostics: usize,
584    pub motifs: usize,
585    pub phrase_boundaries: usize,
586}
587
588/// An analysis category that can be compared in a benchmark report.
589#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
590pub enum BenchmarkCategory {
591    Chords,
592    Intervals,
593    KeyEstimates,
594    CadenceCandidates,
595    VoiceLeading,
596    SatbDiagnostics,
597    Motifs,
598    PhraseBoundaries,
599}
600
601/// A category-level benchmark mismatch.
602#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
603pub struct BenchmarkFailure {
604    pub category: BenchmarkCategory,
605    pub expected: usize,
606    pub predicted: usize,
607    pub missing: usize,
608    pub excess: usize,
609}
610
611/// One benchmark fixture and its hand-verified expectation.
612#[derive(Debug, Clone)]
613pub struct BenchmarkCase<'a> {
614    pub name: &'a str,
615    pub score: &'a Score,
616    pub expected: BenchmarkExpectation,
617}
618
619/// Precision, recall, and explanation-completeness for one benchmark case.
620#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
621pub struct BenchmarkCaseReport {
622    pub name: String,
623    pub predicted: AnalysisCounts,
624    pub expected: BenchmarkExpectation,
625    pub precision_percent: u8,
626    pub recall_percent: u8,
627    pub explanation_completeness_percent: u8,
628    pub failures: Vec<BenchmarkFailure>,
629}
630
631/// Aggregate results for a deterministic benchmark suite.
632#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
633pub struct BenchmarkSuiteReport {
634    pub cases: Vec<BenchmarkCaseReport>,
635    pub case_count: usize,
636    pub passed_case_count: usize,
637    pub failed_case_count: usize,
638    pub precision_percent: u8,
639    pub recall_percent: u8,
640    pub explanation_completeness_percent: u8,
641}
642
643/// A consecutive melodic interval with addresses for both source notes.
644#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
645pub struct IntervalObservation {
646    pub from: NoteAddr,
647    pub to: NoteAddr,
648    pub semitones: u8,
649    /// Signed exact pitch distance in cents; unlike `semitones`, this preserves microtones.
650    #[serde(default)]
651    pub cents: i32,
652    pub diatonic_steps: i8,
653    pub rule_id: String,
654    pub evidence: Vec<NoteAddr>,
655}
656
657/// Analyze every voice that contains at least two pitched notes in a measure.
658pub fn analyze_score(score: &Score) -> AnalysisResult {
659    let chords = analyze_chords_only(score);
660    let intervals = analyze_intervals(score);
661    let key_estimates = estimate_keys(score);
662    let cadence_candidates = analyze_cadences(&chords);
663    let voice_leading = analyze_voice_leading(score);
664    let satb_diagnostics = analyze_satb_in_region(score, &voice_leading, None);
665    let motifs = analyze_motifs(score);
666    let phrase_boundaries = analyze_phrase_boundaries(score);
667    AnalysisResult {
668        schema_version: ANALYSIS_SCHEMA_VERSION,
669        score_fingerprint: score_fingerprint(score),
670        chords,
671        intervals,
672        key_estimates,
673        cadence_candidates,
674        voice_leading,
675        satb_diagnostics,
676        motifs,
677        phrase_boundaries,
678    }
679}
680
681fn analyze_chords_only(score: &Score) -> Vec<ChordLabel> {
682    analyze_chords_in_region(score, None)
683}
684
685/// Analyze chord labels only inside an optional part/staff measure region.
686pub fn analyze_chords_in_region(score: &Score, region: Option<&AnalysisRegion>) -> Vec<ChordLabel> {
687    let mut chords = Vec::new();
688    for (part_index, part) in score.parts.iter().enumerate() {
689        for (staff_index, staff) in part.staves.iter().enumerate() {
690            for (measure_index, measure) in staff.measures.iter().enumerate() {
691                if let Some(region) = region
692                    && (part_index != region.part
693                        || staff_index != region.staff
694                        || measure_index < region.start_measure
695                        || measure_index >= region.end_measure)
696                {
697                    continue;
698                }
699                let key = measure
700                    .key_sig
701                    .as_ref()
702                    .unwrap_or(&score.settings.key_signature);
703                for (voice_index, voice) in measure.voices.iter().enumerate() {
704                    let authored: Vec<_> = voice
705                        .iter()
706                        .enumerate()
707                        .filter_map(|(note_index, note)| {
708                            note.chord_symbol.as_ref().map(|chord| (note_index, chord))
709                        })
710                        .collect();
711                    if !authored.is_empty() {
712                        for (note_index, chord) in authored {
713                            let address = NoteAddr {
714                                part: part_index,
715                                staff: staff_index,
716                                measure: measure_index,
717                                voice: voice_index,
718                                note: note_index,
719                            };
720                            chords.push(ChordLabel {
721                                address: address.clone(),
722                                name: chord_name(chord),
723                                roman_numeral: roman_numeral(chord, key),
724                                chord: chord.clone(),
725                                confidence: 100,
726                                rule_id: "authored-chord-symbol".to_string(),
727                                evidence: vec![address],
728                            });
729                        }
730                        continue;
731                    }
732                    let pitched: Vec<_> = voice
733                        .iter()
734                        .enumerate()
735                        .filter(|(_, note)| !note.is_rest && !note.pitches.is_empty())
736                        .collect();
737                    let pitches: Vec<_> = pitched
738                        .iter()
739                        .flat_map(|(_, note)| note.pitches.iter().cloned())
740                        .collect();
741                    let Some(chord) = detect_chord(&pitches) else {
742                        continue;
743                    };
744                    let evidence = pitched
745                        .iter()
746                        .map(|(note_index, _)| NoteAddr {
747                            part: part_index,
748                            staff: staff_index,
749                            measure: measure_index,
750                            voice: voice_index,
751                            note: *note_index,
752                        })
753                        .collect();
754                    chords.push(ChordLabel {
755                        address: NoteAddr {
756                            part: part_index,
757                            staff: staff_index,
758                            measure: measure_index,
759                            voice: voice_index,
760                            note: pitched[0].0,
761                        },
762                        name: chord_name(&chord),
763                        roman_numeral: roman_numeral(&chord, key),
764                        chord,
765                        confidence: 100,
766                        rule_id: "pitch-class-template".to_string(),
767                        evidence,
768                    });
769                }
770            }
771        }
772    }
773    chords
774}
775
776/// Recompute only the requested categories and carry forward the remaining categories.
777///
778/// The caller must provide a result from the immediately preceding score state. Categories with
779/// dependencies are expanded conservatively: cadence analysis refreshes chords, and SATB
780/// diagnostics refresh voice-leading first. This is intended for editor-local incremental work.
781pub fn analyze_selected_categories(
782    score: &Score,
783    previous: &AnalysisResult,
784    categories: &[AnalysisCategory],
785) -> AnalysisResult {
786    analyze_selected_categories_in_region(score, previous, categories, None)
787}
788
789/// Recompute selected categories with an optional bounded region for local passes.
790pub fn analyze_selected_categories_in_region(
791    score: &Score,
792    previous: &AnalysisResult,
793    categories: &[AnalysisCategory],
794    region: Option<&AnalysisRegion>,
795) -> AnalysisResult {
796    let selected = |category| categories.contains(&category);
797    let chords =
798        if selected(AnalysisCategory::Chords) || selected(AnalysisCategory::CadenceCandidates) {
799            let refreshed = analyze_chords_in_region(score, region);
800            merge_chord_region(&previous.chords, refreshed, region)
801        } else {
802            previous.chords.clone()
803        };
804    let intervals = if selected(AnalysisCategory::Intervals) {
805        merge_interval_region(
806            &previous.intervals,
807            analyze_intervals_in_region(score, region),
808            region,
809        )
810    } else {
811        previous.intervals.clone()
812    };
813    let key_estimates = if selected(AnalysisCategory::KeyEstimates) {
814        estimate_keys(score)
815    } else {
816        previous.key_estimates.clone()
817    };
818    let cadence_candidates = if selected(AnalysisCategory::CadenceCandidates) {
819        analyze_cadences(&chords)
820    } else {
821        previous.cadence_candidates.clone()
822    };
823    let voice_leading = if selected(AnalysisCategory::VoiceLeading)
824        || selected(AnalysisCategory::SatbDiagnostics)
825    {
826        merge_voice_leading_region(
827            &previous.voice_leading,
828            analyze_voice_leading_in_region(score, region),
829            region,
830        )
831    } else {
832        previous.voice_leading.clone()
833    };
834    let satb_diagnostics = if selected(AnalysisCategory::SatbDiagnostics) {
835        merge_satb_region(
836            &previous.satb_diagnostics,
837            analyze_satb_in_region(score, &voice_leading, region),
838            region,
839        )
840    } else {
841        previous.satb_diagnostics.clone()
842    };
843    let motifs = if selected(AnalysisCategory::Motifs) {
844        analyze_motifs(score)
845    } else {
846        previous.motifs.clone()
847    };
848    let phrase_boundaries = if selected(AnalysisCategory::PhraseBoundaries) {
849        analyze_phrase_boundaries(score)
850    } else {
851        previous.phrase_boundaries.clone()
852    };
853    AnalysisResult {
854        schema_version: ANALYSIS_SCHEMA_VERSION,
855        score_fingerprint: score_fingerprint(score),
856        chords,
857        intervals,
858        key_estimates,
859        cadence_candidates,
860        voice_leading,
861        satb_diagnostics,
862        motifs,
863        phrase_boundaries,
864    }
865}
866
867fn merge_interval_region(
868    previous: &[IntervalObservation],
869    refreshed: Vec<IntervalObservation>,
870    region: Option<&AnalysisRegion>,
871) -> Vec<IntervalObservation> {
872    let Some(region) = region else {
873        return refreshed;
874    };
875    let mut merged: Vec<_> = previous
876        .iter()
877        .filter(|item| {
878            !analysis_region_contains(region, &item.from)
879                && !analysis_region_contains(region, &item.to)
880        })
881        .cloned()
882        .collect();
883    merged.extend(refreshed);
884    merged.sort_by_key(|item| {
885        (
886            item.from.part,
887            item.from.staff,
888            item.from.measure,
889            item.from.voice,
890            item.from.note,
891            item.to.measure,
892            item.to.note,
893        )
894    });
895    merged
896}
897
898fn merge_voice_leading_region(
899    previous: &[VoiceLeadingObservation],
900    refreshed: Vec<VoiceLeadingObservation>,
901    region: Option<&AnalysisRegion>,
902) -> Vec<VoiceLeadingObservation> {
903    let Some(region) = region else {
904        return refreshed;
905    };
906    let mut merged: Vec<_> = previous
907        .iter()
908        .filter(|item| {
909            !analysis_region_contains(region, &item.upper)
910                && !analysis_region_contains(region, &item.lower)
911        })
912        .cloned()
913        .collect();
914    merged.extend(refreshed);
915    merged.sort_by_key(|item| {
916        (
917            item.upper.part,
918            item.upper.staff,
919            item.upper.measure,
920            item.upper.voice,
921            item.upper.note,
922            item.lower.voice,
923        )
924    });
925    merged
926}
927
928fn merge_chord_region(
929    previous: &[ChordLabel],
930    refreshed: Vec<ChordLabel>,
931    region: Option<&AnalysisRegion>,
932) -> Vec<ChordLabel> {
933    let Some(region) = region else {
934        return refreshed;
935    };
936    let mut merged: Vec<_> = previous
937        .iter()
938        .filter(|label| !analysis_region_contains(region, &label.address))
939        .cloned()
940        .collect();
941    merged.extend(refreshed);
942    merged.sort_by_key(|label| {
943        (
944            label.address.part,
945            label.address.staff,
946            label.address.measure,
947            label.address.voice,
948            label.address.note,
949        )
950    });
951    merged
952}
953
954fn merge_satb_region(
955    previous: &[SatbDiagnostic],
956    refreshed: Vec<SatbDiagnostic>,
957    region: Option<&AnalysisRegion>,
958) -> Vec<SatbDiagnostic> {
959    let Some(region) = region else {
960        return refreshed;
961    };
962    let mut merged: Vec<_> = previous
963        .iter()
964        .filter(|diagnostic| {
965            !diagnostic
966                .evidence
967                .iter()
968                .any(|address| analysis_region_contains(region, address))
969        })
970        .cloned()
971        .collect();
972    merged.extend(refreshed);
973    merged.sort_by_key(|diagnostic| {
974        (
975            diagnostic.upper.part,
976            diagnostic.upper.staff,
977            diagnostic.upper.measure,
978            diagnostic.upper.voice,
979            diagnostic.upper.note,
980            diagnostic.lower.voice,
981            diagnostic.rule_id.clone(),
982        )
983    });
984    merged
985}
986
987fn analysis_region_contains(region: &AnalysisRegion, address: &NoteAddr) -> bool {
988    address.part == region.part
989        && address.staff == region.staff
990        && (region.start_measure..region.end_measure).contains(&address.measure)
991}
992
993/// Return a deterministic, non-cryptographic fingerprint for a canonical score.
994pub fn score_fingerprint(score: &Score) -> String {
995    let mut value = serde_json::to_value(score).unwrap_or_default();
996    remove_generated_ids(&mut value);
997    let bytes = serde_json::to_vec(&value).unwrap_or_default();
998    let hash = fnv1a64(&bytes);
999    format!("fnv1a64-{hash:016x}")
1000}
1001
1002fn remove_generated_ids(value: &mut serde_json::Value) {
1003    match value {
1004        serde_json::Value::Object(object) => {
1005            object.remove("id");
1006            for child in object.values_mut() {
1007                remove_generated_ids(child);
1008            }
1009        }
1010        serde_json::Value::Array(values) => {
1011            for child in values {
1012                remove_generated_ids(child);
1013            }
1014        }
1015        _ => {}
1016    }
1017}
1018
1019/// Return the current schema-versioned cache key without running the analysis passes.
1020pub fn analysis_cache_key(score: &Score) -> String {
1021    format!(
1022        "analysis-v{}-{}",
1023        ANALYSIS_SCHEMA_VERSION,
1024        score_fingerprint(score)
1025    )
1026}
1027
1028/// Errors returned when configuring the bounded analysis cache.
1029#[derive(Debug, Error, Clone, PartialEq, Eq)]
1030pub enum AnalysisCacheError {
1031    #[error("analysis cache capacity must be greater than zero")]
1032    ZeroCapacity,
1033}
1034
1035/// Counters for measuring analysis-cache reuse.
1036#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1037pub struct AnalysisCacheStats {
1038    pub hits: usize,
1039    pub misses: usize,
1040}
1041
1042/// A deterministic, bounded cache for complete analysis results.
1043///
1044/// Entries are keyed by the schema-versioned canonical score fingerprint. A score edit therefore
1045/// naturally misses the old entry without requiring callers to diff JSON or manually invalidate
1046/// every analysis category. Eviction is insertion-order based rather than hash-map iteration
1047/// based, keeping behavior reproducible across hosts.
1048#[derive(Debug, Clone)]
1049pub struct AnalysisCache {
1050    capacity: usize,
1051    entries: BTreeMap<String, AnalysisResult>,
1052    insertion_order: VecDeque<String>,
1053    stats: AnalysisCacheStats,
1054}
1055
1056impl Default for AnalysisCache {
1057    fn default() -> Self {
1058        Self {
1059            capacity: 16,
1060            entries: BTreeMap::new(),
1061            insertion_order: VecDeque::new(),
1062            stats: AnalysisCacheStats::default(),
1063        }
1064    }
1065}
1066
1067impl AnalysisCache {
1068    /// Create a cache with a fixed maximum number of score results.
1069    pub fn with_capacity(capacity: usize) -> Result<Self, AnalysisCacheError> {
1070        if capacity == 0 {
1071            return Err(AnalysisCacheError::ZeroCapacity);
1072        }
1073        Ok(Self {
1074            capacity,
1075            entries: BTreeMap::new(),
1076            insertion_order: VecDeque::new(),
1077            stats: AnalysisCacheStats::default(),
1078        })
1079    }
1080
1081    /// Return the configured maximum number of cached results.
1082    pub fn capacity(&self) -> usize {
1083        self.capacity
1084    }
1085
1086    /// Return hit/miss counters since construction or the last reset.
1087    pub fn stats(&self) -> AnalysisCacheStats {
1088        self.stats
1089    }
1090
1091    /// Reset hit/miss counters without removing cached results.
1092    pub fn reset_stats(&mut self) {
1093        self.stats = AnalysisCacheStats::default();
1094    }
1095
1096    /// Return a cached result for the supplied score, if its canonical content is present.
1097    pub fn get(&self, score: &Score) -> Option<&AnalysisResult> {
1098        self.entries.get(&analysis_cache_key(score))
1099    }
1100
1101    /// Analyze a score once per cache key, returning a cloned result for ergonomic host use.
1102    pub fn analyze(&mut self, score: &Score) -> AnalysisResult {
1103        let key = analysis_cache_key(score);
1104        if let Some(result) = self.entries.get(&key) {
1105            self.stats.hits = self.stats.hits.saturating_add(1);
1106            return result.clone();
1107        }
1108
1109        self.stats.misses = self.stats.misses.saturating_add(1);
1110        let result = analyze_score(score);
1111        self.insert(key, result.clone());
1112        result
1113    }
1114
1115    /// Analyze scores in input order while reusing results already present in the cache.
1116    pub fn analyze_batch(&mut self, scores: &[Score]) -> Vec<AnalysisResult> {
1117        scores.iter().map(|score| self.analyze(score)).collect()
1118    }
1119
1120    /// Reclaim a previous editor snapshot and analyze its replacement in one operation.
1121    pub fn analyze_after_edit(&mut self, previous: &Score, current: &Score) -> AnalysisResult {
1122        if analysis_cache_key(previous) != analysis_cache_key(current) {
1123            self.invalidate(previous);
1124        }
1125        self.analyze(current)
1126    }
1127
1128    /// Re-analyze an edited score and return the category-level diff from the previous result.
1129    pub fn analyze_after_edit_with_diff(
1130        &mut self,
1131        previous_score: &Score,
1132        previous_result: &AnalysisResult,
1133        current: &Score,
1134    ) -> AnalysisEditResult {
1135        let analysis = self.analyze_after_edit(previous_score, current);
1136        let diff = diff_analysis(previous_result, &analysis);
1137        AnalysisEditResult { analysis, diff }
1138    }
1139
1140    /// Recompute selected categories after an edit and cache the merged complete result.
1141    pub fn analyze_selected_after_edit(
1142        &mut self,
1143        previous_score: &Score,
1144        previous_result: &AnalysisResult,
1145        current: &Score,
1146        categories: &[AnalysisCategory],
1147    ) -> AnalysisResult {
1148        let key = analysis_cache_key(current);
1149        if let Some(result) = self.entries.get(&key) {
1150            self.stats.hits = self.stats.hits.saturating_add(1);
1151            return result.clone();
1152        }
1153        self.stats.misses = self.stats.misses.saturating_add(1);
1154        if analysis_cache_key(previous_score) != key {
1155            self.invalidate(previous_score);
1156        }
1157        let result = analyze_selected_categories(current, previous_result, categories);
1158        self.insert(key, result.clone());
1159        result
1160    }
1161
1162    /// Recompute a refresh plan, using its region for local passes, and cache the merged result.
1163    pub fn analyze_after_edit_with_plan(
1164        &mut self,
1165        previous_score: &Score,
1166        previous_result: &AnalysisResult,
1167        current: &Score,
1168        plan: &AnalysisRefreshPlan,
1169    ) -> AnalysisEditResult {
1170        let categories: Vec<_> = plan
1171            .local_categories
1172            .iter()
1173            .chain(&plan.global_categories)
1174            .copied()
1175            .collect();
1176        let key = analysis_cache_key(current);
1177        let analysis = if let Some(result) = self.entries.get(&key) {
1178            self.stats.hits = self.stats.hits.saturating_add(1);
1179            result.clone()
1180        } else {
1181            self.stats.misses = self.stats.misses.saturating_add(1);
1182            if analysis_cache_key(previous_score) != key {
1183                self.invalidate(previous_score);
1184            }
1185            let result = analyze_selected_categories_in_region(
1186                current,
1187                previous_result,
1188                &categories,
1189                plan.region.as_ref(),
1190            );
1191            self.insert(key, result.clone());
1192            result
1193        };
1194        AnalysisEditResult {
1195            diff: diff_analysis(previous_result, &analysis),
1196            analysis,
1197        }
1198    }
1199
1200    /// Apply a change hint, recompute its affected categories, and return the result diff.
1201    pub fn analyze_after_edit_with_hint(
1202        &mut self,
1203        previous_score: &Score,
1204        previous_result: &AnalysisResult,
1205        current: &Score,
1206        hint: &ChangeHint,
1207    ) -> AnalysisEditResult {
1208        let plan = analysis_refresh_plan(hint);
1209        self.analyze_after_edit_with_plan(previous_score, previous_result, current, &plan)
1210    }
1211
1212    /// Insert a previously computed result and evict the oldest entry when the cache is full.
1213    pub fn insert(&mut self, key: String, result: AnalysisResult) {
1214        if self.entries.contains_key(&key) {
1215            self.entries.insert(key.clone(), result);
1216            self.insertion_order.retain(|existing| existing != &key);
1217        } else {
1218            self.entries.insert(key.clone(), result);
1219        }
1220        self.insertion_order.push_back(key);
1221        while self.entries.len() > self.capacity {
1222            if let Some(oldest) = self.insertion_order.pop_front() {
1223                self.entries.remove(&oldest);
1224            }
1225        }
1226    }
1227
1228    /// Remove the cached result for a score and return whether an entry was removed.
1229    pub fn invalidate(&mut self, score: &Score) -> bool {
1230        let key = analysis_cache_key(score);
1231        let removed = self.entries.remove(&key).is_some();
1232        if removed {
1233            self.insertion_order.retain(|existing| existing != &key);
1234        }
1235        removed
1236    }
1237
1238    /// Remove all cached results.
1239    pub fn clear(&mut self) {
1240        self.entries.clear();
1241        self.insertion_order.clear();
1242    }
1243
1244    /// Return the number of cached results.
1245    pub fn len(&self) -> usize {
1246        self.entries.len()
1247    }
1248
1249    /// Return whether the cache contains no results.
1250    pub fn is_empty(&self) -> bool {
1251        self.entries.is_empty()
1252    }
1253}
1254
1255fn fnv1a64(bytes: &[u8]) -> u64 {
1256    bytes.iter().fold(0xcbf29ce484222325u64, |hash, byte| {
1257        (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
1258    })
1259}
1260
1261impl AnalysisCounts {
1262    fn from_result(result: &AnalysisResult) -> Self {
1263        Self {
1264            chords: result.chords.len(),
1265            intervals: result.intervals.len(),
1266            key_estimates: result.key_estimates.len(),
1267            cadence_candidates: result.cadence_candidates.len(),
1268            voice_leading: result.voice_leading.len(),
1269            satb_diagnostics: result.satb_diagnostics.len(),
1270            motifs: result.motifs.len(),
1271            phrase_boundaries: result.phrase_boundaries.len(),
1272        }
1273    }
1274
1275    fn total(self) -> usize {
1276        self.chords
1277            + self.intervals
1278            + self.key_estimates
1279            + self.cadence_candidates
1280            + self.voice_leading
1281            + self.satb_diagnostics
1282            + self.motifs
1283            + self.phrase_boundaries
1284    }
1285
1286    fn explained(self, result: &AnalysisResult) -> usize {
1287        result
1288            .chords
1289            .iter()
1290            .filter(|item| !item.evidence.is_empty())
1291            .count()
1292            + result
1293                .intervals
1294                .iter()
1295                .filter(|item| !item.evidence.is_empty())
1296                .count()
1297            + result
1298                .key_estimates
1299                .iter()
1300                .filter(|item| !item.evidence.is_empty())
1301                .count()
1302            + result
1303                .cadence_candidates
1304                .iter()
1305                .filter(|item| !item.evidence.is_empty())
1306                .count()
1307            + result
1308                .voice_leading
1309                .iter()
1310                .filter(|item| !item.evidence.is_empty())
1311                .count()
1312            + result
1313                .satb_diagnostics
1314                .iter()
1315                .filter(|item| !item.evidence.is_empty())
1316                .count()
1317            + result
1318                .motifs
1319                .iter()
1320                .map(|item| {
1321                    item.occurrences
1322                        .iter()
1323                        .filter(|occurrence| !occurrence.evidence.is_empty())
1324                        .count()
1325                })
1326                .sum::<usize>()
1327            + result
1328                .phrase_boundaries
1329                .iter()
1330                .filter(|item| !item.evidence.is_empty())
1331                .count()
1332    }
1333}
1334
1335/// Run one offline benchmark case using count-based hand-verified annotations.
1336pub fn benchmark_case(case: &BenchmarkCase<'_>) -> BenchmarkCaseReport {
1337    let result = analyze_score(case.score);
1338    let predicted = AnalysisCounts::from_result(&result);
1339    let expected = case.expected;
1340    let matched = predicted.chords.min(expected.chords)
1341        + predicted.intervals.min(expected.intervals)
1342        + predicted.key_estimates.min(expected.key_estimates)
1343        + predicted
1344            .cadence_candidates
1345            .min(expected.cadence_candidates)
1346        + predicted.voice_leading.min(expected.voice_leading)
1347        + predicted.satb_diagnostics.min(expected.satb_diagnostics)
1348        + predicted.motifs.min(expected.motifs)
1349        + predicted.phrase_boundaries.min(expected.phrase_boundaries);
1350    let expected_total = AnalysisCounts {
1351        chords: expected.chords,
1352        intervals: expected.intervals,
1353        key_estimates: expected.key_estimates,
1354        cadence_candidates: expected.cadence_candidates,
1355        voice_leading: expected.voice_leading,
1356        satb_diagnostics: expected.satb_diagnostics,
1357        motifs: expected.motifs,
1358        phrase_boundaries: expected.phrase_boundaries,
1359    }
1360    .total();
1361    let predicted_total = predicted.total();
1362    let failures = [
1363        (BenchmarkCategory::Chords, expected.chords, predicted.chords),
1364        (
1365            BenchmarkCategory::Intervals,
1366            expected.intervals,
1367            predicted.intervals,
1368        ),
1369        (
1370            BenchmarkCategory::KeyEstimates,
1371            expected.key_estimates,
1372            predicted.key_estimates,
1373        ),
1374        (
1375            BenchmarkCategory::CadenceCandidates,
1376            expected.cadence_candidates,
1377            predicted.cadence_candidates,
1378        ),
1379        (
1380            BenchmarkCategory::VoiceLeading,
1381            expected.voice_leading,
1382            predicted.voice_leading,
1383        ),
1384        (
1385            BenchmarkCategory::SatbDiagnostics,
1386            expected.satb_diagnostics,
1387            predicted.satb_diagnostics,
1388        ),
1389        (BenchmarkCategory::Motifs, expected.motifs, predicted.motifs),
1390        (
1391            BenchmarkCategory::PhraseBoundaries,
1392            expected.phrase_boundaries,
1393            predicted.phrase_boundaries,
1394        ),
1395    ]
1396    .into_iter()
1397    .filter_map(|(category, expected, predicted)| {
1398        if expected == predicted {
1399            return None;
1400        }
1401        Some(BenchmarkFailure {
1402            category,
1403            expected,
1404            predicted,
1405            missing: expected.saturating_sub(predicted),
1406            excess: predicted.saturating_sub(expected),
1407        })
1408    })
1409    .collect();
1410    BenchmarkCaseReport {
1411        name: case.name.to_string(),
1412        predicted,
1413        expected,
1414        precision_percent: percentage(matched, predicted_total),
1415        recall_percent: percentage(matched, expected_total),
1416        explanation_completeness_percent: percentage(predicted.explained(&result), predicted_total),
1417        failures,
1418    }
1419}
1420
1421/// Run benchmark cases in input order; no filesystem or network access is used.
1422pub fn run_benchmark(cases: &[BenchmarkCase<'_>]) -> Vec<BenchmarkCaseReport> {
1423    cases.iter().map(benchmark_case).collect()
1424}
1425
1426/// Run a benchmark suite and aggregate its case-level metrics.
1427pub fn run_benchmark_suite(cases: &[BenchmarkCase<'_>]) -> BenchmarkSuiteReport {
1428    let reports = run_benchmark(cases);
1429    let case_count = reports.len();
1430    let passed_case_count = reports
1431        .iter()
1432        .filter(|report| report.failures.is_empty())
1433        .count();
1434    let failed_case_count = case_count.saturating_sub(passed_case_count);
1435    let precision_total: usize = reports
1436        .iter()
1437        .map(|report| usize::from(report.precision_percent))
1438        .sum();
1439    let recall_total: usize = reports
1440        .iter()
1441        .map(|report| usize::from(report.recall_percent))
1442        .sum();
1443    let explanation_total: usize = reports
1444        .iter()
1445        .map(|report| usize::from(report.explanation_completeness_percent))
1446        .sum();
1447    let metric_denominator = case_count.saturating_mul(100);
1448    let aggregate_metric = |total: usize| {
1449        if case_count == 0 {
1450            0
1451        } else {
1452            percentage(total, metric_denominator)
1453        }
1454    };
1455    BenchmarkSuiteReport {
1456        cases: reports,
1457        case_count,
1458        passed_case_count,
1459        failed_case_count,
1460        precision_percent: aggregate_metric(precision_total),
1461        recall_percent: aggregate_metric(recall_total),
1462        explanation_completeness_percent: aggregate_metric(explanation_total),
1463    }
1464}
1465
1466fn percentage(numerator: usize, denominator: usize) -> u8 {
1467    match numerator.saturating_mul(100).checked_div(denominator) {
1468        Some(value) => value.min(100) as u8,
1469        None => 100,
1470    }
1471}
1472
1473/// Analyze SATB constraints using the same aligned voice events as voice-leading analysis.
1474pub fn analyze_satb(score: &Score) -> Vec<SatbDiagnostic> {
1475    let voice_leading = analyze_voice_leading(score);
1476    analyze_satb_in_region(score, &voice_leading, None)
1477}
1478
1479/// Analyze SATB diagnostics inside an optional part/staff measure region.
1480pub fn analyze_satb_in_region(
1481    score: &Score,
1482    voice_leading: &[VoiceLeadingObservation],
1483    region: Option<&AnalysisRegion>,
1484) -> Vec<SatbDiagnostic> {
1485    let mut diagnostics = Vec::new();
1486    for (part_index, part) in score.parts.iter().enumerate() {
1487        for (staff_index, staff) in part.staves.iter().enumerate() {
1488            for (measure_index, measure) in staff.measures.iter().enumerate() {
1489                if let Some(region) = region
1490                    && (part_index != region.part
1491                        || staff_index != region.staff
1492                        || measure_index < region.start_measure
1493                        || measure_index >= region.end_measure)
1494                {
1495                    continue;
1496                }
1497                for (upper_index, upper_voice) in measure.voices.iter().enumerate() {
1498                    let Some(lower_voice) = measure.voices.get(upper_index + 1) else {
1499                        continue;
1500                    };
1501                    for note_index in 0..upper_voice.len().min(lower_voice.len()) {
1502                        let Some(upper) = upper_voice[note_index].pitches.first() else {
1503                            continue;
1504                        };
1505                        let Some(lower) = lower_voice[note_index].pitches.first() else {
1506                            continue;
1507                        };
1508                        let upper_addr = NoteAddr {
1509                            part: part_index,
1510                            staff: staff_index,
1511                            measure: measure_index,
1512                            voice: upper_index,
1513                            note: note_index,
1514                        };
1515                        let lower_addr = NoteAddr {
1516                            part: part_index,
1517                            staff: staff_index,
1518                            measure: measure_index,
1519                            voice: upper_index + 1,
1520                            note: note_index,
1521                        };
1522                        let distance = upper.to_midi() - lower.to_midi();
1523                        if distance < 0 {
1524                            diagnostics.push(satb_diagnostic(
1525                                upper_addr.clone(),
1526                                lower_addr.clone(),
1527                                SatbDiagnosticKind::VoiceCrossing,
1528                                SatbSeverity::Error,
1529                                "satb-voice-crossing",
1530                            ));
1531                        } else if distance > 24 {
1532                            diagnostics.push(satb_diagnostic(
1533                                upper_addr.clone(),
1534                                lower_addr.clone(),
1535                                SatbDiagnosticKind::WideSpacing,
1536                                SatbSeverity::Warning,
1537                                "satb-wide-spacing",
1538                            ));
1539                        }
1540                    }
1541                }
1542            }
1543        }
1544    }
1545    for observation in voice_leading {
1546        if let Some(region) = region
1547            && !observation
1548                .evidence
1549                .iter()
1550                .any(|address| analysis_region_contains(region, address))
1551        {
1552            continue;
1553        }
1554        if observation.parallel_perfect {
1555            diagnostics.push(satb_diagnostic(
1556                observation.upper.clone(),
1557                observation.lower.clone(),
1558                SatbDiagnosticKind::ParallelPerfect,
1559                SatbSeverity::Warning,
1560                "satb-parallel-perfect",
1561            ));
1562        }
1563    }
1564    diagnostics
1565}
1566
1567fn satb_diagnostic(
1568    upper: NoteAddr,
1569    lower: NoteAddr,
1570    kind: SatbDiagnosticKind,
1571    severity: SatbSeverity,
1572    rule_id: &str,
1573) -> SatbDiagnostic {
1574    SatbDiagnostic {
1575        evidence: vec![upper.clone(), lower.clone()],
1576        upper,
1577        lower,
1578        kind,
1579        severity,
1580        confidence: 100,
1581        rule_id: rule_id.to_string(),
1582    }
1583}
1584
1585/// Find repeated three-note melodic interval patterns, resetting at rests.
1586pub fn analyze_motifs(score: &Score) -> Vec<MotifPattern> {
1587    let mut groups: BTreeMap<(usize, usize, usize, Vec<i8>), Vec<MotifOccurrence>> =
1588        BTreeMap::new();
1589    for (part_index, part) in score.parts.iter().enumerate() {
1590        for (staff_index, staff) in part.staves.iter().enumerate() {
1591            let Some(first_measure) = staff.measures.first() else {
1592                continue;
1593            };
1594            for (voice_index, _) in first_measure.voices.iter().enumerate() {
1595                let mut segment = Vec::new();
1596                let mut segments = Vec::new();
1597                for (measure_index, measure) in staff.measures.iter().enumerate() {
1598                    for (note_index, note) in measure.voices[voice_index].iter().enumerate() {
1599                        let Some(pitch) = note.pitches.first() else {
1600                            if segment.len() >= 3 {
1601                                segments.push(std::mem::take(&mut segment));
1602                            } else {
1603                                segment.clear();
1604                            }
1605                            continue;
1606                        };
1607                        if note.is_rest {
1608                            if segment.len() >= 3 {
1609                                segments.push(std::mem::take(&mut segment));
1610                            } else {
1611                                segment.clear();
1612                            }
1613                            continue;
1614                        }
1615                        segment.push((
1616                            NoteAddr {
1617                                part: part_index,
1618                                staff: staff_index,
1619                                measure: measure_index,
1620                                voice: voice_index,
1621                                note: note_index,
1622                            },
1623                            pitch.to_midi(),
1624                        ));
1625                    }
1626                }
1627                if segment.len() >= 3 {
1628                    segments.push(segment);
1629                }
1630                for segment in segments {
1631                    for window in segment.windows(3) {
1632                        let signature = vec![
1633                            (window[1].1 - window[0].1) as i8,
1634                            (window[2].1 - window[1].1) as i8,
1635                        ];
1636                        let occurrence = MotifOccurrence {
1637                            start: window[0].0.clone(),
1638                            end: window[2].0.clone(),
1639                            evidence: window.iter().map(|(address, _)| address.clone()).collect(),
1640                        };
1641                        groups
1642                            .entry((part_index, staff_index, voice_index, signature))
1643                            .or_default()
1644                            .push(occurrence);
1645                    }
1646                }
1647            }
1648        }
1649    }
1650    groups
1651        .into_iter()
1652        .filter(|(_, occurrences)| occurrences.len() >= 2)
1653        .map(|((_, _, _, signature), occurrences)| MotifPattern {
1654            signature,
1655            occurrences,
1656            confidence: 100,
1657            rule_id: "repeated-three-note-interval-pattern".to_string(),
1658        })
1659        .collect()
1660}
1661
1662/// Report measure-ending rests as explicit, conservative phrase boundaries.
1663pub fn analyze_phrase_boundaries(score: &Score) -> Vec<PhraseBoundary> {
1664    let mut boundaries = Vec::new();
1665    for (part_index, part) in score.parts.iter().enumerate() {
1666        for (staff_index, staff) in part.staves.iter().enumerate() {
1667            for (measure_index, measure) in staff.measures.iter().enumerate() {
1668                for (voice_index, voice) in measure.voices.iter().enumerate() {
1669                    let Some((note_index, note)) = voice.iter().enumerate().next_back() else {
1670                        continue;
1671                    };
1672                    if !note.is_rest {
1673                        continue;
1674                    }
1675                    let address = NoteAddr {
1676                        part: part_index,
1677                        staff: staff_index,
1678                        measure: measure_index,
1679                        voice: voice_index,
1680                        note: note_index,
1681                    };
1682                    boundaries.push(PhraseBoundary {
1683                        address: address.clone(),
1684                        reason: PhraseBoundaryReason::RestTermination,
1685                        confidence: 100,
1686                        rule_id: "measure-ending-rest".to_string(),
1687                        evidence: vec![address],
1688                    });
1689                }
1690            }
1691        }
1692    }
1693    boundaries
1694}
1695
1696/// Find explicit cadence transitions in the order they occur within each voice.
1697pub fn analyze_cadences(chords: &[ChordLabel]) -> Vec<CadenceCandidate> {
1698    let mut candidates = Vec::new();
1699    let mut previous: HashMap<(usize, usize, usize), &ChordLabel> = HashMap::new();
1700    for chord in chords {
1701        let key = (chord.address.part, chord.address.staff, chord.address.voice);
1702        let Some(previous_chord) = previous.insert(key, chord) else {
1703            continue;
1704        };
1705        let Some(from_roman) = previous_chord.roman_numeral.as_deref() else {
1706            continue;
1707        };
1708        let Some(to_roman) = chord.roman_numeral.as_deref() else {
1709            continue;
1710        };
1711        let from_figure = roman_figure(from_roman);
1712        let to_figure = roman_figure(to_roman);
1713        let kind = match (from_figure, to_figure) {
1714            ("V" | "V7", "I") => CadenceKind::Authentic,
1715            ("IV", "I") => CadenceKind::Plagal,
1716            ("V" | "V7", "vi") => CadenceKind::Deceptive,
1717            (_, "V" | "V7") => CadenceKind::Half,
1718            _ => continue,
1719        };
1720        let evidence = vec![previous_chord.address.clone(), chord.address.clone()];
1721        candidates.push(CadenceCandidate {
1722            from: previous_chord.address.clone(),
1723            to: chord.address.clone(),
1724            kind,
1725            confidence: 100,
1726            rule_id: "roman-numeral-cadence-transition".to_string(),
1727            evidence,
1728        });
1729    }
1730    candidates
1731}
1732
1733fn roman_figure(roman: &str) -> &str {
1734    roman.find('/').map_or(roman, |index| &roman[..index])
1735}
1736
1737/// Check aligned notes in adjacent voices for motion and parallel perfect intervals.
1738pub fn analyze_voice_leading(score: &Score) -> Vec<VoiceLeadingObservation> {
1739    analyze_voice_leading_in_region(score, None)
1740}
1741
1742/// Analyze voice-leading observations inside an optional part/staff measure region.
1743pub fn analyze_voice_leading_in_region(
1744    score: &Score,
1745    region: Option<&AnalysisRegion>,
1746) -> Vec<VoiceLeadingObservation> {
1747    let mut observations = Vec::new();
1748    for (part_index, part) in score.parts.iter().enumerate() {
1749        for (staff_index, staff) in part.staves.iter().enumerate() {
1750            for (measure_index, measure) in staff.measures.iter().enumerate() {
1751                if let Some(region) = region
1752                    && (part_index != region.part
1753                        || staff_index != region.staff
1754                        || measure_index < region.start_measure
1755                        || measure_index >= region.end_measure)
1756                {
1757                    continue;
1758                }
1759                for (upper_index, upper_voice) in measure.voices.iter().enumerate() {
1760                    let Some(lower_voice) = measure.voices.get(upper_index + 1) else {
1761                        continue;
1762                    };
1763                    let count = upper_voice.len().min(lower_voice.len());
1764                    for note_index in 0..count {
1765                        let Some(upper) = upper_voice[note_index].pitches.first() else {
1766                            continue;
1767                        };
1768                        let Some(lower) = lower_voice[note_index].pitches.first() else {
1769                            continue;
1770                        };
1771                        let Some(next_upper_note) = upper_voice.get(note_index + 1) else {
1772                            continue;
1773                        };
1774                        let Some(next_lower_note) = lower_voice.get(note_index + 1) else {
1775                            continue;
1776                        };
1777                        if next_upper_note.is_rest || next_lower_note.is_rest {
1778                            continue;
1779                        }
1780                        let next_upper = next_upper_note.pitches.first();
1781                        let next_lower = next_lower_note.pitches.first();
1782                        let (Some(next_upper), Some(next_lower)) = (next_upper, next_lower) else {
1783                            continue;
1784                        };
1785                        let upper_addr = NoteAddr {
1786                            part: part_index,
1787                            staff: staff_index,
1788                            measure: measure_index,
1789                            voice: upper_index,
1790                            note: note_index,
1791                        };
1792                        let lower_addr = NoteAddr {
1793                            part: part_index,
1794                            staff: staff_index,
1795                            measure: measure_index,
1796                            voice: upper_index + 1,
1797                            note: note_index,
1798                        };
1799                        let upper_next_midi = next_upper.to_midi();
1800                        let lower_next_midi = next_lower.to_midi();
1801                        let upper_motion = upper_next_midi - upper.to_midi();
1802                        let lower_motion = lower_next_midi - lower.to_midi();
1803                        let upper_motion_cents = next_upper.to_midi_cents() - upper.to_midi_cents();
1804                        let lower_motion_cents = next_lower.to_midi_cents() - lower.to_midi_cents();
1805                        let initial =
1806                            (upper.to_midi_cents() - lower.to_midi_cents()).unsigned_abs() % 1200;
1807                        let next = (next_upper.to_midi_cents() - next_lower.to_midi_cents())
1808                            .unsigned_abs()
1809                            % 1200;
1810                        observations.push(VoiceLeadingObservation {
1811                            upper: upper_addr.clone(),
1812                            lower: lower_addr.clone(),
1813                            upper_motion,
1814                            lower_motion,
1815                            upper_motion_cents,
1816                            lower_motion_cents,
1817                            parallel_perfect: matches!(initial, 0 | 700)
1818                                && initial == next
1819                                && upper_motion_cents != 0
1820                                && upper_motion_cents.signum() == lower_motion_cents.signum(),
1821                            confidence: 100,
1822                            rule_id: "aligned-adjacent-voice-leading".to_string(),
1823                            evidence: vec![upper_addr, lower_addr],
1824                        });
1825                    }
1826                }
1827            }
1828        }
1829    }
1830    observations
1831}
1832
1833/// Backwards-compatible name for the complete score analysis pass.
1834pub fn analyze_chords(score: &Score) -> AnalysisResult {
1835    analyze_score(score)
1836}
1837
1838/// Analyze a finite batch in input order.
1839pub fn analyze_batch(scores: &[Score]) -> Vec<AnalysisResult> {
1840    scores.iter().map(analyze_score).collect()
1841}
1842
1843/// Analyze scores lazily, one result per input score.
1844pub fn analyze_stream<I>(scores: I) -> impl Iterator<Item = AnalysisResult>
1845where
1846    I: IntoIterator<Item = Score>,
1847{
1848    scores.into_iter().map(|score| analyze_score(&score))
1849}
1850
1851/// Estimate major/minor keys from pitch coverage, preserving tied candidates.
1852pub fn estimate_keys(score: &Score) -> Vec<KeyEstimate> {
1853    let mut pitches = Vec::new();
1854    let mut evidence = Vec::new();
1855    for (part_index, part) in score.parts.iter().enumerate() {
1856        for (staff_index, staff) in part.staves.iter().enumerate() {
1857            for (measure_index, measure) in staff.measures.iter().enumerate() {
1858                for (voice_index, voice) in measure.voices.iter().enumerate() {
1859                    for (note_index, note) in voice.iter().enumerate() {
1860                        if note.is_rest {
1861                            continue;
1862                        }
1863                        let duration_beats = note.beats().max(0.0);
1864                        pitches.extend(note.pitches.iter().map(|pitch| (pitch, duration_beats)));
1865                        if !note.pitches.is_empty() {
1866                            evidence.push(NoteAddr {
1867                                part: part_index,
1868                                staff: staff_index,
1869                                measure: measure_index,
1870                                voice: voice_index,
1871                                note: note_index,
1872                            });
1873                        }
1874                    }
1875                }
1876            }
1877        }
1878    }
1879    if pitches.is_empty() {
1880        return Vec::new();
1881    }
1882    let total_pitches = pitches.len();
1883    let total_duration_beats: f64 = pitches.iter().map(|(_, beats)| *beats).sum();
1884    let mut candidates = Vec::with_capacity(30);
1885    for fifths in -7..=7 {
1886        for mode in ["major", "minor"] {
1887            let key = KeySignature {
1888                fifths,
1889                mode: mode.to_string(),
1890            };
1891            let covered = pitches
1892                .iter()
1893                .filter(|(pitch, _)| key.contains_pitch(pitch))
1894                .count();
1895            let weighted_covered_beats = pitches
1896                .iter()
1897                .filter(|(pitch, _)| key.contains_pitch(pitch))
1898                .map(|(_, beats)| *beats)
1899                .sum::<f64>();
1900            candidates.push((key, covered, weighted_covered_beats));
1901        }
1902    }
1903    candidates.sort_by(
1904        |(left_key, left_score, left_weight), (right_key, right_score, right_weight)| {
1905            right_weight
1906                .total_cmp(left_weight)
1907                .then_with(|| right_score.cmp(left_score))
1908                .then_with(|| left_key.fifths.abs().cmp(&right_key.fifths.abs()))
1909                .then_with(|| left_key.fifths.cmp(&right_key.fifths))
1910                .then_with(|| left_key.mode.cmp(&right_key.mode))
1911        },
1912    );
1913    let best_weight = candidates[0].2;
1914    candidates
1915        .into_iter()
1916        .take_while(|(_, _, weighted)| weighted.total_cmp(&best_weight).is_eq())
1917        .map(
1918            |(key, covered_pitches, weighted_covered_beats)| KeyEstimate {
1919                key,
1920                covered_pitches,
1921                total_pitches,
1922                weighted_covered_beats,
1923                total_duration_beats,
1924                confidence: if total_duration_beats > 0.0 {
1925                    ((weighted_covered_beats / total_duration_beats * 100.0).round() as u8).min(100)
1926                } else {
1927                    0
1928                },
1929                rule_id: "duration-weighted-diatonic-pitch-coverage".to_string(),
1930                evidence: evidence.clone(),
1931            },
1932        )
1933        .collect()
1934}
1935
1936/// Analyze adjacent pitched notes in every voice without inferring missing events.
1937pub fn analyze_intervals(score: &Score) -> Vec<IntervalObservation> {
1938    analyze_intervals_in_region(score, None)
1939}
1940
1941/// Analyze intervals whose endpoints touch an optional part/staff measure region.
1942pub fn analyze_intervals_in_region(
1943    score: &Score,
1944    region: Option<&AnalysisRegion>,
1945) -> Vec<IntervalObservation> {
1946    let mut observations = Vec::new();
1947    for (part_index, part) in score.parts.iter().enumerate() {
1948        for (staff_index, staff) in part.staves.iter().enumerate() {
1949            for (measure_index, measure) in staff.measures.iter().enumerate() {
1950                for (voice_index, voice) in measure.voices.iter().enumerate() {
1951                    for pair in voice.iter().enumerate().collect::<Vec<_>>().windows(2) {
1952                        let (from_index, from_note) = pair[0];
1953                        let (to_index, to_note) = pair[1];
1954                        if from_note.is_rest || to_note.is_rest {
1955                            continue;
1956                        }
1957                        let Some(from) = from_note.pitches.first() else {
1958                            continue;
1959                        };
1960                        let Some(to) = to_note.pitches.first() else {
1961                            continue;
1962                        };
1963                        let from_addr = NoteAddr {
1964                            part: part_index,
1965                            staff: staff_index,
1966                            measure: measure_index,
1967                            voice: voice_index,
1968                            note: from_index,
1969                        };
1970                        let to_addr = NoteAddr {
1971                            part: part_index,
1972                            staff: staff_index,
1973                            measure: measure_index,
1974                            voice: voice_index,
1975                            note: to_index,
1976                        };
1977                        if let Some(region) = region
1978                            && !analysis_region_contains(region, &from_addr)
1979                            && !analysis_region_contains(region, &to_addr)
1980                        {
1981                            continue;
1982                        }
1983                        observations.push(IntervalObservation {
1984                            from: from_addr.clone(),
1985                            to: to_addr.clone(),
1986                            semitones: (to.to_midi() - from.to_midi()).unsigned_abs() as u8,
1987                            cents: to.to_midi_cents() - from.to_midi_cents(),
1988                            diatonic_steps: diatonic_distance(from, to),
1989                            rule_id: "adjacent-melodic-interval".to_string(),
1990                            evidence: vec![from_addr, to_addr],
1991                        });
1992                    }
1993                }
1994            }
1995        }
1996    }
1997    observations
1998}
1999
2000fn diatonic_distance(from: &acorde_core::Pitch, to: &acorde_core::Pitch) -> i8 {
2001    let step_index = |step: &acorde_core::Step| match step {
2002        acorde_core::Step::C => 0i16,
2003        acorde_core::Step::D => 1,
2004        acorde_core::Step::E => 2,
2005        acorde_core::Step::F => 3,
2006        acorde_core::Step::G => 4,
2007        acorde_core::Step::A => 5,
2008        acorde_core::Step::B => 6,
2009    };
2010    (i16::from(to.octave) * 7 + step_index(&to.step)
2011        - (i16::from(from.octave) * 7 + step_index(&from.step))) as i8
2012}
2013
2014/// Return the stable chord spelling as a compact human-readable label.
2015pub fn chord_name(chord: &ChordSymbol) -> String {
2016    chord.display_text()
2017}
2018
2019#[cfg(test)]
2020mod tests {
2021    use super::*;
2022    use acorde_core::{Duration, Note, Pitch, Score, Step};
2023
2024    #[test]
2025    fn labels_chord_with_note_addresses_and_roman_numeral() {
2026        let mut score = Score::default();
2027        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2028        voice.clear();
2029        for step in [Step::C, Step::E, Step::G] {
2030            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2031        }
2032        let result = analyze_chords(&score);
2033        assert_eq!(result.schema_version, ANALYSIS_SCHEMA_VERSION);
2034        assert_eq!(result.chords.len(), 1);
2035        assert_eq!(result.intervals.len(), 2);
2036        assert!(!result.key_estimates.is_empty());
2037        assert_eq!(result.chords[0].address.note, 0);
2038        assert_eq!(result.chords[0].evidence.len(), 3);
2039        assert_eq!(result.chords[0].roman_numeral.as_deref(), Some("I"));
2040        assert_eq!(chord_name(&result.chords[0].chord), "C");
2041        assert_eq!(result.chords[0].name, "C");
2042    }
2043
2044    #[test]
2045    fn authored_chord_symbol_wins_with_source_address_provenance() {
2046        use acorde_core::ChordSymbol;
2047
2048        let mut score = Score::default();
2049        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2050        voice.clear();
2051        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2052        note.chord_symbol = Some(ChordSymbol {
2053            root: "F".to_owned(),
2054            kind: "dominant".to_owned(),
2055            bass: Some("A".to_owned()),
2056            placement: None,
2057            extender: false,
2058            harmonic_degree: Some("4".to_owned()),
2059            harmony_function: Some("D".to_owned()),
2060            harmony_type: None,
2061            chord_ref: None,
2062            range_end: None,
2063            degrees: Vec::new(),
2064        });
2065        voice.push(note);
2066
2067        let result = analyze_score(&score);
2068        assert_eq!(result.chords.len(), 1);
2069        assert_eq!(result.chords[0].rule_id, "authored-chord-symbol");
2070        assert_eq!(result.chords[0].address.note, 0);
2071        assert_eq!(
2072            result.chords[0].evidence,
2073            vec![result.chords[0].address.clone()]
2074        );
2075        assert_eq!(chord_name(&result.chords[0].chord), "F7/A");
2076        assert_eq!(result.chords[0].name, "F7/A");
2077        let finding = analysis_provenance(&result, &result.chords[0].address)
2078            .into_iter()
2079            .find(|finding| finding.category == AnalysisCategory::Chords)
2080            .expect("authored chord provenance");
2081        assert_eq!(finding.label.as_deref(), Some("F7/A"));
2082    }
2083
2084    #[test]
2085    fn chord_name_preserves_authored_degree_extensions() {
2086        use acorde_core::{ChordDegree, ChordSymbol};
2087
2088        let chord = ChordSymbol {
2089            root: "C".to_owned(),
2090            kind: "dominant".to_owned(),
2091            bass: None,
2092            placement: None,
2093            extender: false,
2094            harmonic_degree: None,
2095            harmony_function: None,
2096            harmony_type: None,
2097            chord_ref: None,
2098            range_end: None,
2099            degrees: vec![
2100                ChordDegree {
2101                    value: 9,
2102                    alter: 1,
2103                    kind: "add".to_owned(),
2104                },
2105                ChordDegree {
2106                    value: 5,
2107                    alter: -1,
2108                    kind: "alter".to_owned(),
2109                },
2110                ChordDegree {
2111                    value: 3,
2112                    alter: 0,
2113                    kind: "subtract".to_owned(),
2114                },
2115            ],
2116        };
2117
2118        assert_eq!(chord_name(&chord), "C7add#9b5no3");
2119    }
2120
2121    #[test]
2122    fn provenance_returns_stable_explanations_for_a_note() {
2123        let mut score = Score::default();
2124        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2125        voice.clear();
2126        voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2127        voice.push(Note::new(Pitch::new(Step::E, 4), Duration::Quarter));
2128        voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
2129        let address = NoteAddr {
2130            part: 0,
2131            staff: 0,
2132            measure: 0,
2133            voice: 0,
2134            note: 0,
2135        };
2136        let findings = analysis_provenance(&analyze_score(&score), &address);
2137        let chord = findings
2138            .iter()
2139            .find(|finding| finding.category == AnalysisCategory::Chords)
2140            .expect("chord provenance");
2141        assert_eq!(chord.rule_id, "pitch-class-template");
2142        assert_eq!(chord.label.as_deref(), Some("C"));
2143        assert_eq!(chord.evidence[0], address);
2144        assert!(findings.windows(2).all(|pair| {
2145            (analysis_category_rank(pair[0].category), &pair[0].rule_id)
2146                <= (analysis_category_rank(pair[1].category), &pair[1].rule_id)
2147        }));
2148    }
2149
2150    #[test]
2151    fn change_explanation_combines_diff_and_before_after_provenance() {
2152        let mut previous_score = Score::default();
2153        let voice = &mut previous_score.parts[0].staves[0].measures[0].voices[0];
2154        voice.clear();
2155        for step in [Step::C, Step::E, Step::G] {
2156            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2157        }
2158        let previous = analyze_score(&previous_score);
2159        let mut current_score = previous_score.clone();
2160        current_score.parts[0].staves[0].measures[0].voices[0][1] =
2161            Note::new(Pitch::new(Step::F, 4), Duration::Quarter);
2162        let current = analyze_score(&current_score);
2163        let address = NoteAddr {
2164            part: 0,
2165            staff: 0,
2166            measure: 0,
2167            voice: 0,
2168            note: 1,
2169        };
2170
2171        let explanation = explain_analysis_change(&previous, &current, &address);
2172        assert!(!explanation.diff.is_empty());
2173        assert!(!explanation.previous.is_empty());
2174        assert!(!explanation.current.is_empty());
2175    }
2176
2177    #[test]
2178    fn analysis_has_stable_score_fingerprint() {
2179        let score = Score::default();
2180        let repeated = analyze_score(&score);
2181        assert_eq!(repeated.score_fingerprint, score_fingerprint(&score));
2182        assert_eq!(
2183            repeated.score_fingerprint,
2184            analyze_score(&score).score_fingerprint
2185        );
2186
2187        let mut changed = score.clone();
2188        changed.metadata.title = "Changed".to_string();
2189        assert_ne!(repeated.score_fingerprint, score_fingerprint(&changed));
2190    }
2191
2192    #[test]
2193    fn fingerprint_uses_fnv1a_byte_order() {
2194        assert_eq!(fnv1a64(b"hello"), 0xa430d84680aabd0b);
2195    }
2196
2197    #[test]
2198    fn cache_key_includes_schema_and_score_identity() {
2199        let result = analyze_score(&Score::default());
2200        assert!(result.cache_key().starts_with("analysis-v13-fnv1a64-"));
2201        assert_eq!(result.cache_key(), analysis_cache_key(&Score::default()));
2202        let mut changed = result.clone();
2203        changed.schema_version = ANALYSIS_SCHEMA_VERSION + 1;
2204        assert_ne!(result.cache_key(), changed.cache_key());
2205    }
2206
2207    #[test]
2208    fn analysis_cache_reuses_identical_score_and_misses_after_edit() {
2209        let score = Score::default();
2210        let mut cache = AnalysisCache::with_capacity(2).unwrap();
2211        let first = cache.analyze(&score);
2212        assert_eq!(cache.len(), 1);
2213        assert!(cache.get(&score).is_some());
2214        let second = cache.analyze(&score);
2215        assert_eq!(first, second);
2216
2217        let mut changed = score.clone();
2218        changed.metadata.title = "changed".to_owned();
2219        assert!(cache.get(&changed).is_none());
2220        let changed_result = cache.analyze(&changed);
2221        assert_ne!(first.score_fingerprint, changed_result.score_fingerprint);
2222        assert_eq!(cache.len(), 2);
2223    }
2224
2225    #[test]
2226    fn analysis_cache_eviction_is_bounded_and_deterministic() {
2227        let mut cache = AnalysisCache::with_capacity(1).unwrap();
2228        let first = Score::default();
2229        let mut second = first.clone();
2230        second.metadata.title = "second".to_owned();
2231        cache.analyze(&first);
2232        cache.analyze(&second);
2233        assert_eq!(cache.len(), 1);
2234        assert!(cache.get(&first).is_none());
2235        assert!(cache.get(&second).is_some());
2236    }
2237
2238    #[test]
2239    fn analysis_cache_batch_preserves_order_and_reuses_duplicate_scores() {
2240        let first = Score::default();
2241        let mut second = first.clone();
2242        second.metadata.title = "second".to_owned();
2243        let mut cache = AnalysisCache::with_capacity(2).unwrap();
2244        let results = cache.analyze_batch(&[second.clone(), first.clone(), second.clone()]);
2245
2246        assert_eq!(results.len(), 3);
2247        assert_eq!(results[0].score_fingerprint, results[2].score_fingerprint);
2248        assert_ne!(results[0].score_fingerprint, results[1].score_fingerprint);
2249        assert_eq!(cache.len(), 2);
2250        assert_eq!(cache.stats(), AnalysisCacheStats { hits: 1, misses: 2 });
2251        cache.reset_stats();
2252        assert_eq!(cache.stats(), AnalysisCacheStats::default());
2253        assert!(cache.get(&first).is_some());
2254    }
2255
2256    #[test]
2257    fn analysis_cache_invalidates_one_score_without_affecting_others() {
2258        let first = Score::default();
2259        let mut second = first.clone();
2260        second.metadata.title = "second".to_owned();
2261        let mut cache = AnalysisCache::with_capacity(2).unwrap();
2262        cache.analyze_batch(&[first.clone(), second.clone()]);
2263
2264        assert!(cache.invalidate(&first));
2265        assert!(!cache.invalidate(&first));
2266        assert!(cache.get(&first).is_none());
2267        assert!(cache.get(&second).is_some());
2268        assert_eq!(cache.len(), 1);
2269    }
2270
2271    #[test]
2272    fn analysis_cache_analyze_after_edit_reclaims_previous_snapshot() {
2273        let previous = Score::default();
2274        let mut current = previous.clone();
2275        current.metadata.title = "edited".to_owned();
2276        let mut cache = AnalysisCache::with_capacity(2).unwrap();
2277        cache.analyze(&previous);
2278
2279        let result = cache.analyze_after_edit(&previous, &current);
2280        assert!(result.matches_score(&current));
2281        assert!(cache.get(&previous).is_none());
2282        assert!(cache.get(&current).is_some());
2283        assert_eq!(cache.len(), 1);
2284    }
2285
2286    #[test]
2287    fn analysis_cache_analyze_after_noop_edit_reuses_existing_result() {
2288        let score = Score::default();
2289        let mut cache = AnalysisCache::with_capacity(1).unwrap();
2290        cache.analyze(&score);
2291        cache.reset_stats();
2292
2293        let result = cache.analyze_after_edit(&score, &score);
2294        assert!(result.matches_score(&score));
2295        assert_eq!(cache.stats(), AnalysisCacheStats { hits: 1, misses: 0 });
2296        assert_eq!(cache.len(), 1);
2297    }
2298
2299    #[test]
2300    fn analysis_cache_edit_with_diff_returns_result_and_changed_categories() {
2301        let previous = Score::default();
2302        let mut current = previous.clone();
2303        let voice = &mut current.parts[0].staves[0].measures[0].voices[0];
2304        voice.clear();
2305        for step in [Step::C, Step::E, Step::G] {
2306            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2307        }
2308        let previous_result = analyze_score(&previous);
2309        let mut cache = AnalysisCache::with_capacity(2).unwrap();
2310        cache.analyze(&previous);
2311
2312        let edited = cache.analyze_after_edit_with_diff(&previous, &previous_result, &current);
2313        assert!(edited.analysis.matches_score(&current));
2314        assert_eq!(edited.diff.changed_categories[0], AnalysisCategory::Chords);
2315        assert!(
2316            edited
2317                .diff
2318                .changed_categories
2319                .contains(&AnalysisCategory::Intervals)
2320        );
2321        assert!(cache.get(&previous).is_none());
2322        assert!(cache.get(&current).is_some());
2323
2324        cache.reset_stats();
2325        let no_op = cache.analyze_after_edit_with_diff(&current, &edited.analysis, &current);
2326        assert!(no_op.diff.is_empty());
2327        assert_eq!(cache.stats(), AnalysisCacheStats { hits: 1, misses: 0 });
2328    }
2329
2330    #[test]
2331    fn selected_analysis_reuses_unchanged_categories_and_rebinds_identity() {
2332        let previous = Score::default();
2333        let previous_result = analyze_score(&previous);
2334        let mut current = previous.clone();
2335        current.metadata.title = "edited".to_owned();
2336        let mut cache = AnalysisCache::with_capacity(2).unwrap();
2337        let result = cache.analyze_selected_after_edit(&previous, &previous_result, &current, &[]);
2338
2339        assert!(result.matches_score(&current));
2340        assert_eq!(result.chords, previous_result.chords);
2341        assert_ne!(result.score_fingerprint, previous_result.score_fingerprint);
2342        assert_eq!(cache.stats(), AnalysisCacheStats { hits: 0, misses: 1 });
2343    }
2344
2345    #[test]
2346    fn hinted_analysis_uses_clean_hint_without_recomputing_categories() {
2347        let previous = Score::default();
2348        let previous_result = analyze_score(&previous);
2349        let mut current = previous.clone();
2350        current.metadata.title = "edited".to_owned();
2351        let hint = ChangeHint {
2352            scope: acorde_core::ChangeScope::Global,
2353            layout_dirty: false,
2354            playback_dirty: false,
2355        };
2356        let mut cache = AnalysisCache::with_capacity(2).unwrap();
2357        let edited =
2358            cache.analyze_after_edit_with_hint(&previous, &previous_result, &current, &hint);
2359
2360        assert!(edited.diff.is_empty());
2361        assert!(edited.analysis.matches_score(&current));
2362        assert_eq!(edited.analysis.chords, previous_result.chords);
2363    }
2364
2365    #[test]
2366    fn analysis_cache_rejects_zero_capacity() {
2367        assert!(matches!(
2368            AnalysisCache::with_capacity(0),
2369            Err(AnalysisCacheError::ZeroCapacity)
2370        ));
2371    }
2372
2373    #[test]
2374    fn analysis_result_rejects_a_different_score() {
2375        let score = Score::default();
2376        let result = analyze_score(&score);
2377        assert!(result.matches_score(&score));
2378        let mut changed = score.clone();
2379        changed.metadata.title = "Changed".to_string();
2380        assert!(!result.matches_score(&changed));
2381    }
2382
2383    #[test]
2384    fn analysis_diff_is_empty_for_identical_results() {
2385        let result = analyze_score(&Score::default());
2386        let diff = diff_analysis(&result, &result);
2387        assert!(diff.is_empty());
2388        assert_eq!(diff.previous_score_fingerprint, result.score_fingerprint);
2389        assert_eq!(diff.current_score_fingerprint, result.score_fingerprint);
2390    }
2391
2392    #[test]
2393    fn analysis_diff_keeps_score_identity_separate_from_category_changes() {
2394        let mut first_score = Score::default();
2395        first_score.metadata.title = "first".to_owned();
2396        let mut second_score = first_score.clone();
2397        second_score.metadata.title = "second".to_owned();
2398        let diff = diff_analysis(&analyze_score(&first_score), &analyze_score(&second_score));
2399
2400        assert!(diff.is_empty());
2401        assert!(diff.changed_categories.is_empty());
2402        assert_ne!(
2403            diff.previous_score_fingerprint,
2404            diff.current_score_fingerprint
2405        );
2406    }
2407
2408    #[test]
2409    fn analysis_diff_reports_changed_categories_in_stable_order() {
2410        let previous = analyze_score(&Score::default());
2411        let mut changed_score = Score::default();
2412        let voice = &mut changed_score.parts[0].staves[0].measures[0].voices[0];
2413        voice.clear();
2414        for step in [Step::C, Step::E, Step::G] {
2415            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2416        }
2417        let current = analyze_score(&changed_score);
2418        let diff = diff_analysis(&previous, &current);
2419
2420        assert!(!diff.is_empty());
2421        assert_eq!(diff.changed_categories[0], AnalysisCategory::Chords);
2422        assert!(
2423            diff.changed_categories
2424                .contains(&AnalysisCategory::Intervals)
2425        );
2426    }
2427
2428    #[test]
2429    fn affected_categories_are_conservative_and_stable() {
2430        let clean = ChangeHint {
2431            scope: acorde_core::ChangeScope::Global,
2432            layout_dirty: false,
2433            playback_dirty: false,
2434        };
2435        assert!(affected_categories_for_change_hint(&clean).is_empty());
2436
2437        let dirty = ChangeHint {
2438            scope: acorde_core::ChangeScope::Measures {
2439                part: 0,
2440                staff: 0,
2441                start: 1,
2442                end: 2,
2443            },
2444            layout_dirty: false,
2445            playback_dirty: true,
2446        };
2447        assert_eq!(
2448            affected_categories_for_change_hint(&dirty),
2449            vec![
2450                AnalysisCategory::Chords,
2451                AnalysisCategory::Intervals,
2452                AnalysisCategory::KeyEstimates,
2453                AnalysisCategory::CadenceCandidates,
2454                AnalysisCategory::VoiceLeading,
2455                AnalysisCategory::SatbDiagnostics,
2456                AnalysisCategory::Motifs,
2457                AnalysisCategory::PhraseBoundaries,
2458            ]
2459        );
2460    }
2461
2462    #[test]
2463    fn refresh_plan_separates_local_and_global_dependencies() {
2464        let hint = ChangeHint {
2465            scope: acorde_core::ChangeScope::Measures {
2466                part: 1,
2467                staff: 2,
2468                start: 3,
2469                end: 4,
2470            },
2471            layout_dirty: true,
2472            playback_dirty: true,
2473        };
2474        let plan = analysis_refresh_plan(&hint);
2475        assert_eq!(
2476            plan.region,
2477            Some(AnalysisRegion {
2478                part: 1,
2479                staff: 2,
2480                start_measure: 3,
2481                end_measure: 4,
2482            })
2483        );
2484        assert!(plan.local_categories.contains(&AnalysisCategory::Chords));
2485        assert!(
2486            plan.local_categories
2487                .contains(&AnalysisCategory::CadenceCandidates)
2488        );
2489        assert_eq!(
2490            plan.global_categories,
2491            vec![AnalysisCategory::KeyEstimates, AnalysisCategory::Motifs]
2492        );
2493        assert_eq!(plan.context_before, 1);
2494        assert_eq!(plan.context_after, 1);
2495    }
2496
2497    #[test]
2498    fn chord_region_keeps_outside_results_and_refreshes_inside() {
2499        let mut score = Score::default();
2500        for measure_index in 0..2 {
2501            let voice = &mut score.parts[0].staves[0].measures[measure_index].voices[0];
2502            voice.clear();
2503            for step in [Step::C, Step::E, Step::G] {
2504                voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2505            }
2506        }
2507        let region = AnalysisRegion {
2508            part: 0,
2509            staff: 0,
2510            start_measure: 0,
2511            end_measure: 1,
2512        };
2513        let chords = analyze_chords_in_region(&score, Some(&region));
2514        assert_eq!(chords.len(), 1);
2515        assert_eq!(chords[0].address.measure, 0);
2516    }
2517
2518    #[test]
2519    fn interval_region_includes_boundary_observations() {
2520        let mut score = Score::default();
2521        for measure_index in 0..2 {
2522            let voice = &mut score.parts[0].staves[0].measures[measure_index].voices[0];
2523            voice.clear();
2524            voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2525            voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
2526        }
2527        let region = AnalysisRegion {
2528            part: 0,
2529            staff: 0,
2530            start_measure: 0,
2531            end_measure: 1,
2532        };
2533        let intervals = analyze_intervals_in_region(&score, Some(&region));
2534        assert_eq!(intervals.len(), 1);
2535        assert_eq!(intervals[0].from.measure, 0);
2536    }
2537
2538    #[test]
2539    fn voice_leading_region_limits_measure_traversal() {
2540        let mut score = Score::default();
2541        for measure_index in 0..2 {
2542            let measure = &mut score.parts[0].staves[0].measures[measure_index];
2543            measure.voices[0].clear();
2544            measure.voices[1].clear();
2545            measure.voices[0].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2546            measure.voices[1].push(Note::new(Pitch::new(Step::G, 3), Duration::Quarter));
2547            measure.voices[0].push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
2548            measure.voices[1].push(Note::new(Pitch::new(Step::A, 3), Duration::Quarter));
2549        }
2550        let region = AnalysisRegion {
2551            part: 0,
2552            staff: 0,
2553            start_measure: 1,
2554            end_measure: 2,
2555        };
2556        let observations = analyze_voice_leading_in_region(&score, Some(&region));
2557        assert_eq!(observations.len(), 1);
2558        assert_eq!(observations[0].upper.measure, 1);
2559    }
2560
2561    #[test]
2562    fn interval_observation_preserves_direction_and_evidence() {
2563        let mut score = Score::default();
2564        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2565        voice.clear();
2566        voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2567        voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
2568        let intervals = analyze_intervals(&score);
2569        assert_eq!(intervals.len(), 1);
2570        assert_eq!(intervals[0].semitones, 7);
2571        assert_eq!(intervals[0].diatonic_steps, 4);
2572        assert_eq!(intervals[0].evidence.len(), 2);
2573    }
2574
2575    #[test]
2576    fn interval_analysis_treats_rests_as_melodic_boundaries() {
2577        let mut score = Score::default();
2578        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2579        voice.clear();
2580        voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2581        voice.push(Note::rest(Duration::Quarter));
2582        voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
2583
2584        assert!(analyze_intervals(&score).is_empty());
2585    }
2586
2587    #[test]
2588    fn interval_observation_preserves_exact_microtonal_distance() {
2589        let mut score = Score::default();
2590        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2591        voice.clear();
2592        voice.push(Note::new(
2593            Pitch::try_with_microtone(Step::C, 4, 0, 25).expect("valid microtone"),
2594            Duration::Quarter,
2595        ));
2596        voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
2597
2598        let intervals = analyze_intervals(&score);
2599        assert_eq!(intervals.len(), 1);
2600        assert_eq!(intervals[0].semitones, 2);
2601        assert_eq!(intervals[0].cents, 175);
2602    }
2603
2604    #[test]
2605    fn key_estimates_report_duration_weighted_coverage() {
2606        let mut score = Score::default();
2607        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2608        voice.clear();
2609        voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Whole));
2610        voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Eighth));
2611
2612        let estimates = estimate_keys(&score);
2613        assert!(!estimates.is_empty());
2614        assert!(estimates.iter().all(|estimate| {
2615            (estimate.total_duration_beats - 4.5).abs() < f64::EPSILON
2616                && estimate.weighted_covered_beats <= estimate.total_duration_beats
2617                && estimate.rule_id == "duration-weighted-diatonic-pitch-coverage"
2618        }));
2619    }
2620
2621    #[test]
2622    fn does_not_invent_label_for_unknown_pitch_set() {
2623        let mut score = Score::default();
2624        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2625        voice.clear();
2626        for step in [Step::C, Step::C] {
2627            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2628        }
2629        assert!(analyze_chords(&score).chords.is_empty());
2630    }
2631
2632    #[test]
2633    fn preserves_relative_major_minor_key_ambiguity() {
2634        let mut score = Score::default();
2635        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2636        voice.clear();
2637        for step in [
2638            Step::C,
2639            Step::D,
2640            Step::E,
2641            Step::F,
2642            Step::G,
2643            Step::A,
2644            Step::B,
2645        ] {
2646            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2647        }
2648        let estimates = estimate_keys(&score);
2649        assert!(
2650            estimates
2651                .iter()
2652                .any(|estimate| estimate.key.display_name() == "C major")
2653        );
2654        assert!(
2655            estimates
2656                .iter()
2657                .any(|estimate| estimate.key.display_name() == "A minor")
2658        );
2659        assert!(estimates.iter().all(|estimate| estimate.confidence == 100));
2660    }
2661
2662    #[test]
2663    fn returns_no_key_for_empty_score() {
2664        assert!(estimate_keys(&Score::default()).is_empty());
2665    }
2666
2667    #[test]
2668    fn batch_and_stream_preserve_score_order() {
2669        let scores = vec![Score::default(), Score::default()];
2670        let batch = analyze_batch(&scores);
2671        let streamed: Vec<_> = analyze_stream(scores.clone()).collect();
2672        assert_eq!(batch, streamed);
2673        assert_eq!(batch.len(), scores.len());
2674    }
2675
2676    #[test]
2677    fn detects_authentic_cadence_from_adjacent_measures() {
2678        let mut score = Score::default();
2679        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2680        voice.clear();
2681        for (step, octave) in [(Step::G, 3), (Step::B, 3), (Step::D, 4)] {
2682            voice.push(Note::new(Pitch::new(step, octave), Duration::Quarter));
2683        }
2684        let voice = &mut score.parts[0].staves[0].measures[1].voices[0];
2685        voice.clear();
2686        for step in [Step::C, Step::E, Step::G] {
2687            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2688        }
2689        let result = analyze_score(&score);
2690        assert_eq!(result.cadence_candidates.len(), 1);
2691        assert_eq!(result.cadence_candidates[0].kind, CadenceKind::Authentic);
2692        assert_eq!(result.cadence_candidates[0].evidence.len(), 2);
2693    }
2694
2695    #[test]
2696    fn flags_parallel_octaves_between_aligned_voices() {
2697        let mut score = Score::default();
2698        let measure = &mut score.parts[0].staves[0].measures[0];
2699        measure.voices[0].clear();
2700        measure.voices[1].clear();
2701        for (upper, lower) in [(Step::C, Step::C), (Step::D, Step::D)] {
2702            measure.voices[0].push(Note::new(Pitch::new(upper, 4), Duration::Quarter));
2703            measure.voices[1].push(Note::new(Pitch::new(lower, 3), Duration::Quarter));
2704        }
2705        let observations = analyze_voice_leading(&score);
2706        assert_eq!(observations.len(), 1);
2707        assert!(observations[0].parallel_perfect);
2708        assert_eq!(observations[0].upper_motion_cents, 200);
2709        assert_eq!(observations[0].lower_motion_cents, 200);
2710        assert_eq!(observations[0].evidence.len(), 2);
2711        let diagnostics = analyze_satb(&score);
2712        assert_eq!(diagnostics.len(), 1);
2713        assert_eq!(diagnostics[0].kind, SatbDiagnosticKind::ParallelPerfect);
2714        assert_eq!(diagnostics[0].severity, SatbSeverity::Warning);
2715    }
2716
2717    #[test]
2718    fn voice_leading_does_not_bridge_rests() {
2719        let mut score = Score::default();
2720        let measure = &mut score.parts[0].staves[0].measures[0];
2721        measure.voices[0].clear();
2722        measure.voices[1].clear();
2723        let (upper_voices, lower_voices) = measure.voices.split_at_mut(1);
2724        for voice in [&mut upper_voices[0], &mut lower_voices[0]] {
2725            voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2726            voice.push(Note::rest(Duration::Quarter));
2727            voice.push(Note::new(Pitch::new(Step::D, 4), Duration::Quarter));
2728        }
2729        assert!(analyze_voice_leading(&score).is_empty());
2730    }
2731
2732    #[test]
2733    fn reports_voice_crossing_as_error() {
2734        let mut score = Score::default();
2735        let measure = &mut score.parts[0].staves[0].measures[0];
2736        measure.voices[0].clear();
2737        measure.voices[1].clear();
2738        measure.voices[0].push(Note::new(Pitch::new(Step::C, 3), Duration::Quarter));
2739        measure.voices[1].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2740        let diagnostics = analyze_satb(&score);
2741        assert_eq!(diagnostics.len(), 1);
2742        assert_eq!(diagnostics[0].kind, SatbDiagnosticKind::VoiceCrossing);
2743        assert_eq!(diagnostics[0].severity, SatbSeverity::Error);
2744    }
2745
2746    #[test]
2747    fn satb_region_replaces_local_findings_and_preserves_outside_findings() {
2748        let mut previous_score = Score::default();
2749        for measure in &mut previous_score.parts[0].staves[0].measures {
2750            measure.voices[0].clear();
2751            measure.voices[1].clear();
2752            measure.voices[0].push(Note::new(Pitch::new(Step::C, 3), Duration::Quarter));
2753            measure.voices[1].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2754        }
2755        let previous = analyze_score(&previous_score);
2756
2757        let mut edited_score = previous_score.clone();
2758        let measure = &mut edited_score.parts[0].staves[0].measures[0];
2759        measure.voices[0][0] = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2760        let region = AnalysisRegion {
2761            part: 0,
2762            staff: 0,
2763            start_measure: 0,
2764            end_measure: 1,
2765        };
2766        let refreshed = analyze_selected_categories_in_region(
2767            &edited_score,
2768            &previous,
2769            &[AnalysisCategory::SatbDiagnostics],
2770            Some(&region),
2771        );
2772
2773        assert_eq!(refreshed.satb_diagnostics.len(), 3);
2774        assert!(
2775            refreshed
2776                .satb_diagnostics
2777                .iter()
2778                .all(|diagnostic| diagnostic.upper.measure >= 1)
2779        );
2780    }
2781
2782    #[test]
2783    fn finds_repeated_melodic_motif_with_source_spans() {
2784        let mut score = Score::default();
2785        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2786        voice.clear();
2787        for step in [Step::C, Step::D, Step::E, Step::G, Step::A, Step::B] {
2788            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
2789        }
2790        let motifs = analyze_motifs(&score);
2791        assert_eq!(motifs.len(), 1);
2792        assert_eq!(motifs[0].signature, vec![2, 2]);
2793        assert_eq!(motifs[0].occurrences.len(), 2);
2794        assert_eq!(motifs[0].occurrences[0].evidence.len(), 3);
2795    }
2796
2797    #[test]
2798    fn reports_measure_ending_rest_as_phrase_boundary() {
2799        let mut score = Score::default();
2800        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
2801        voice.clear();
2802        voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2803        voice.push(Note::rest(Duration::Quarter));
2804        let boundaries = analyze_phrase_boundaries(&score);
2805        assert!(boundaries.iter().any(|boundary| {
2806            boundary.reason == PhraseBoundaryReason::RestTermination
2807                && boundary.address.measure == 0
2808                && boundary.address.note == 1
2809        }));
2810    }
2811
2812    #[test]
2813    fn benchmark_reports_perfect_scores_for_hand_verified_empty_fixture() {
2814        let score = Score::default();
2815        let cases = [BenchmarkCase {
2816            name: "empty-score",
2817            score: &score,
2818            expected: BenchmarkExpectation {
2819                phrase_boundaries: 4,
2820                ..BenchmarkExpectation::default()
2821            },
2822        }];
2823        let reports = run_benchmark(&cases);
2824        assert_eq!(reports.len(), 1);
2825        assert_eq!(reports[0].precision_percent, 100);
2826        assert_eq!(reports[0].recall_percent, 100);
2827        assert_eq!(reports[0].explanation_completeness_percent, 100);
2828        assert!(reports[0].failures.is_empty());
2829    }
2830
2831    #[test]
2832    fn benchmark_reports_category_level_failure_details() {
2833        let score = Score::default();
2834        let cases = [BenchmarkCase {
2835            name: "under-annotated-score",
2836            score: &score,
2837            expected: BenchmarkExpectation {
2838                phrase_boundaries: 5,
2839                ..BenchmarkExpectation::default()
2840            },
2841        }];
2842        let reports = run_benchmark(&cases);
2843        assert_eq!(
2844            reports[0].failures,
2845            vec![BenchmarkFailure {
2846                category: BenchmarkCategory::PhraseBoundaries,
2847                expected: 5,
2848                predicted: 4,
2849                missing: 1,
2850                excess: 0,
2851            }]
2852        );
2853    }
2854
2855    #[test]
2856    fn benchmark_suite_aggregates_case_status_and_metrics() {
2857        let score = Score::default();
2858        let cases = [
2859            BenchmarkCase {
2860                name: "passing",
2861                score: &score,
2862                expected: BenchmarkExpectation {
2863                    phrase_boundaries: 4,
2864                    ..BenchmarkExpectation::default()
2865                },
2866            },
2867            BenchmarkCase {
2868                name: "failing",
2869                score: &score,
2870                expected: BenchmarkExpectation {
2871                    phrase_boundaries: 5,
2872                    ..BenchmarkExpectation::default()
2873                },
2874            },
2875        ];
2876        let suite = run_benchmark_suite(&cases);
2877        assert_eq!(suite.case_count, 2);
2878        assert_eq!(suite.passed_case_count, 1);
2879        assert_eq!(suite.failed_case_count, 1);
2880        assert_eq!(suite.cases.len(), 2);
2881        assert_eq!(suite.precision_percent, 100);
2882        assert_eq!(suite.recall_percent, 90);
2883        assert_eq!(suite.explanation_completeness_percent, 100);
2884    }
2885
2886    struct TestPass(&'static str);
2887
2888    impl AnalysisPass for TestPass {
2889        fn id(&self) -> &str {
2890            self.0
2891        }
2892
2893        fn run(&self, _score: &Score) -> serde_json::Value {
2894            serde_json::json!({ "pass": self.0 })
2895        }
2896    }
2897
2898    #[test]
2899    fn extension_passes_run_in_stable_id_order() {
2900        let score = Score::default();
2901        let beta = TestPass("beta");
2902        let alpha = TestPass("alpha");
2903        let results = run_analysis_passes(&score, &[&beta, &alpha]).unwrap();
2904        assert_eq!(
2905            results
2906                .iter()
2907                .map(|result| result.pass_id.as_str())
2908                .collect::<Vec<_>>(),
2909            ["alpha", "beta"]
2910        );
2911        assert_eq!(results[0].output["pass"], "alpha");
2912    }
2913
2914    #[test]
2915    fn extension_passes_reject_empty_and_duplicate_ids() {
2916        let score = Score::default();
2917        let empty = TestPass("");
2918        assert_eq!(
2919            run_analysis_passes(&score, &[&empty]),
2920            Err(AnalysisPassError::EmptyId)
2921        );
2922        let first = TestPass("same");
2923        let second = TestPass("same");
2924        assert_eq!(
2925            run_analysis_passes(&score, &[&first, &second]),
2926            Err(AnalysisPassError::DuplicateId("same".to_string()))
2927        );
2928    }
2929}