Skip to main content

acorde_analysis/
lib.rs

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