Skip to main content

acorde_analysis/
lib.rs

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