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