Skip to main content

acorde_analysis/
lib.rs

1//! Deterministic, explainable music analysis over [`acorde_core::Score`].
2
3use acorde_core::{ChordSymbol, KeySignature, NoteAddr, Score, detect_chord, roman_numeral};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, HashMap};
6use thiserror::Error;
7
8/// Version of the serialized analysis result contract.
9pub const ANALYSIS_SCHEMA_VERSION: u32 = 7;
10
11/// A chord label with source evidence and the rule that produced it.
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct ChordLabel {
14    pub address: NoteAddr,
15    pub chord: ChordSymbol,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub roman_numeral: Option<String>,
18    pub confidence: u8,
19    pub rule_id: String,
20    pub evidence: Vec<NoteAddr>,
21}
22
23/// Deterministic output of the chord-analysis pass.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct AnalysisResult {
26    pub schema_version: u32,
27    /// Deterministic fingerprint of the canonical input score content.
28    #[serde(default)]
29    pub score_fingerprint: String,
30    pub chords: Vec<ChordLabel>,
31    pub intervals: Vec<IntervalObservation>,
32    #[serde(default)]
33    pub key_estimates: Vec<KeyEstimate>,
34    #[serde(default)]
35    pub cadence_candidates: Vec<CadenceCandidate>,
36    #[serde(default)]
37    pub voice_leading: Vec<VoiceLeadingObservation>,
38    #[serde(default)]
39    pub satb_diagnostics: Vec<SatbDiagnostic>,
40    #[serde(default)]
41    pub motifs: Vec<MotifPattern>,
42    #[serde(default)]
43    pub phrase_boundaries: Vec<PhraseBoundary>,
44}
45
46impl AnalysisResult {
47    /// Return a cache key that invalidates when either the input or result schema changes.
48    pub fn cache_key(&self) -> String {
49        format!(
50            "analysis-v{}-{}",
51            self.schema_version, self.score_fingerprint
52        )
53    }
54
55    /// Check whether this result was produced from the supplied score.
56    pub fn matches_score(&self, score: &Score) -> bool {
57        self.score_fingerprint == score_fingerprint(score)
58    }
59}
60
61/// A host- or application-provided deterministic analysis extension.
62pub trait AnalysisPass {
63    /// Stable identifier used for ordering and persisted result lookup.
64    fn id(&self) -> &str;
65
66    /// Run the pass over a score and return its JSON payload.
67    fn run(&self, score: &Score) -> serde_json::Value;
68}
69
70/// Validation failures for a registered analysis pass set.
71#[derive(Debug, Error, Clone, PartialEq, Eq)]
72pub enum AnalysisPassError {
73    #[error("analysis pass ID must not be empty")]
74    EmptyId,
75    #[error("duplicate analysis pass ID: {0}")]
76    DuplicateId(String),
77}
78
79/// Result of one registered extension pass.
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct AnalysisPassResult {
82    pub pass_id: String,
83    pub output: serde_json::Value,
84}
85
86/// Run extension passes in stable ID order after validating their identifiers.
87pub fn run_analysis_passes(
88    score: &Score,
89    passes: &[&dyn AnalysisPass],
90) -> Result<Vec<AnalysisPassResult>, AnalysisPassError> {
91    let mut ordered: Vec<&dyn AnalysisPass> = passes.to_vec();
92    for pass in &ordered {
93        if pass.id().is_empty() {
94            return Err(AnalysisPassError::EmptyId);
95        }
96    }
97    ordered.sort_by(|left, right| left.id().cmp(right.id()));
98    for pair in ordered.windows(2) {
99        if pair[0].id() == pair[1].id() {
100            return Err(AnalysisPassError::DuplicateId(pair[0].id().to_string()));
101        }
102    }
103    Ok(ordered
104        .into_iter()
105        .map(|pass| AnalysisPassResult {
106            pass_id: pass.id().to_string(),
107            output: pass.run(score),
108        })
109        .collect())
110}
111
112/// A deterministic key candidate ranked by diatonic pitch coverage.
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct KeyEstimate {
115    pub key: KeySignature,
116    pub covered_pitches: usize,
117    pub total_pitches: usize,
118    pub confidence: u8,
119    pub rule_id: String,
120    pub evidence: Vec<NoteAddr>,
121}
122
123/// A cadence transition inferred only from adjacent, explicitly labeled chords.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct CadenceCandidate {
126    pub from: NoteAddr,
127    pub to: NoteAddr,
128    pub kind: CadenceKind,
129    pub confidence: u8,
130    pub rule_id: String,
131    pub evidence: Vec<NoteAddr>,
132}
133
134/// Supported cadence transition families.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub enum CadenceKind {
137    Authentic,
138    Plagal,
139    Deceptive,
140    Half,
141}
142
143/// Voice-leading observation for two adjacent voices at an aligned event.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct VoiceLeadingObservation {
146    pub upper: NoteAddr,
147    pub lower: NoteAddr,
148    pub upper_motion: i16,
149    pub lower_motion: i16,
150    pub parallel_perfect: bool,
151    pub confidence: u8,
152    pub rule_id: String,
153    pub evidence: Vec<NoteAddr>,
154}
155
156/// A typed SATB constraint finding with source addresses for UI selection.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct SatbDiagnostic {
159    pub upper: NoteAddr,
160    pub lower: NoteAddr,
161    pub kind: SatbDiagnosticKind,
162    pub severity: SatbSeverity,
163    pub confidence: u8,
164    pub rule_id: String,
165    pub evidence: Vec<NoteAddr>,
166}
167
168/// SATB constraint families reported by the deterministic pass.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub enum SatbDiagnosticKind {
171    VoiceCrossing,
172    WideSpacing,
173    ParallelPerfect,
174}
175
176/// User-facing seriousness of a SATB diagnostic.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub enum SatbSeverity {
179    Error,
180    Warning,
181}
182
183/// A repeated melodic interval pattern with all matching source occurrences.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct MotifPattern {
186    pub signature: Vec<i8>,
187    pub occurrences: Vec<MotifOccurrence>,
188    pub confidence: u8,
189    pub rule_id: String,
190}
191
192/// One source span matching a motif pattern.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub struct MotifOccurrence {
195    pub start: NoteAddr,
196    pub end: NoteAddr,
197    pub evidence: Vec<NoteAddr>,
198}
199
200/// A phrase boundary supported by an explicit rest at the end of a measure.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct PhraseBoundary {
203    pub address: NoteAddr,
204    pub reason: PhraseBoundaryReason,
205    pub confidence: u8,
206    pub rule_id: String,
207    pub evidence: Vec<NoteAddr>,
208}
209
210/// Evidence categories for phrase boundaries.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub enum PhraseBoundaryReason {
213    RestTermination,
214}
215
216/// Hand-verified expected counts for one analysis benchmark fixture.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
218pub struct BenchmarkExpectation {
219    pub chords: usize,
220    pub intervals: usize,
221    pub key_estimates: usize,
222    pub cadence_candidates: usize,
223    pub voice_leading: usize,
224    pub satb_diagnostics: usize,
225    pub motifs: usize,
226    pub phrase_boundaries: usize,
227}
228
229/// Predicted category counts used by the benchmark report.
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
231pub struct AnalysisCounts {
232    pub chords: usize,
233    pub intervals: usize,
234    pub key_estimates: usize,
235    pub cadence_candidates: usize,
236    pub voice_leading: usize,
237    pub satb_diagnostics: usize,
238    pub motifs: usize,
239    pub phrase_boundaries: usize,
240}
241
242/// An analysis category that can be compared in a benchmark report.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
244pub enum BenchmarkCategory {
245    Chords,
246    Intervals,
247    KeyEstimates,
248    CadenceCandidates,
249    VoiceLeading,
250    SatbDiagnostics,
251    Motifs,
252    PhraseBoundaries,
253}
254
255/// A category-level benchmark mismatch.
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
257pub struct BenchmarkFailure {
258    pub category: BenchmarkCategory,
259    pub expected: usize,
260    pub predicted: usize,
261    pub missing: usize,
262    pub excess: usize,
263}
264
265/// One benchmark fixture and its hand-verified expectation.
266#[derive(Debug, Clone)]
267pub struct BenchmarkCase<'a> {
268    pub name: &'a str,
269    pub score: &'a Score,
270    pub expected: BenchmarkExpectation,
271}
272
273/// Precision, recall, and explanation-completeness for one benchmark case.
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct BenchmarkCaseReport {
276    pub name: String,
277    pub predicted: AnalysisCounts,
278    pub expected: BenchmarkExpectation,
279    pub precision_percent: u8,
280    pub recall_percent: u8,
281    pub explanation_completeness_percent: u8,
282    pub failures: Vec<BenchmarkFailure>,
283}
284
285/// Aggregate results for a deterministic benchmark suite.
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287pub struct BenchmarkSuiteReport {
288    pub cases: Vec<BenchmarkCaseReport>,
289    pub case_count: usize,
290    pub passed_case_count: usize,
291    pub failed_case_count: usize,
292    pub precision_percent: u8,
293    pub recall_percent: u8,
294    pub explanation_completeness_percent: u8,
295}
296
297/// A consecutive melodic interval with addresses for both source notes.
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299pub struct IntervalObservation {
300    pub from: NoteAddr,
301    pub to: NoteAddr,
302    pub semitones: u8,
303    pub diatonic_steps: i8,
304    pub rule_id: String,
305    pub evidence: Vec<NoteAddr>,
306}
307
308/// Analyze every voice that contains at least two pitched notes in a measure.
309pub fn analyze_score(score: &Score) -> AnalysisResult {
310    let mut chords = Vec::new();
311    for (part_index, part) in score.parts.iter().enumerate() {
312        for (staff_index, staff) in part.staves.iter().enumerate() {
313            for (measure_index, measure) in staff.measures.iter().enumerate() {
314                let key = measure
315                    .key_sig
316                    .as_ref()
317                    .unwrap_or(&score.settings.key_signature);
318                for (voice_index, voice) in measure.voices.iter().enumerate() {
319                    let pitched: Vec<_> = voice
320                        .iter()
321                        .enumerate()
322                        .filter(|(_, note)| !note.is_rest && !note.pitches.is_empty())
323                        .collect();
324                    let pitches: Vec<_> = pitched
325                        .iter()
326                        .flat_map(|(_, note)| note.pitches.iter().cloned())
327                        .collect();
328                    let Some(chord) = detect_chord(&pitches) else {
329                        continue;
330                    };
331                    let evidence = pitched
332                        .iter()
333                        .map(|(note_index, _)| NoteAddr {
334                            part: part_index,
335                            staff: staff_index,
336                            measure: measure_index,
337                            voice: voice_index,
338                            note: *note_index,
339                        })
340                        .collect();
341                    chords.push(ChordLabel {
342                        address: NoteAddr {
343                            part: part_index,
344                            staff: staff_index,
345                            measure: measure_index,
346                            voice: voice_index,
347                            note: pitched[0].0,
348                        },
349                        roman_numeral: roman_numeral(&chord, key),
350                        chord,
351                        confidence: 100,
352                        rule_id: "pitch-class-template".to_string(),
353                        evidence,
354                    });
355                }
356            }
357        }
358    }
359    let intervals = analyze_intervals(score);
360    let key_estimates = estimate_keys(score);
361    let cadence_candidates = analyze_cadences(&chords);
362    let voice_leading = analyze_voice_leading(score);
363    let satb_diagnostics = analyze_satb_with_voice_leading(score, &voice_leading);
364    let motifs = analyze_motifs(score);
365    let phrase_boundaries = analyze_phrase_boundaries(score);
366    AnalysisResult {
367        schema_version: ANALYSIS_SCHEMA_VERSION,
368        score_fingerprint: score_fingerprint(score),
369        chords,
370        intervals,
371        key_estimates,
372        cadence_candidates,
373        voice_leading,
374        satb_diagnostics,
375        motifs,
376        phrase_boundaries,
377    }
378}
379
380/// Return a deterministic, non-cryptographic fingerprint for a canonical score.
381pub fn score_fingerprint(score: &Score) -> String {
382    let mut value = serde_json::to_value(score).unwrap_or_default();
383    remove_generated_ids(&mut value);
384    let bytes = serde_json::to_vec(&value).unwrap_or_default();
385    let hash = fnv1a64(&bytes);
386    format!("fnv1a64-{hash:016x}")
387}
388
389fn remove_generated_ids(value: &mut serde_json::Value) {
390    match value {
391        serde_json::Value::Object(object) => {
392            object.remove("id");
393            for child in object.values_mut() {
394                remove_generated_ids(child);
395            }
396        }
397        serde_json::Value::Array(values) => {
398            for child in values {
399                remove_generated_ids(child);
400            }
401        }
402        _ => {}
403    }
404}
405
406/// Return the current schema-versioned cache key without running the analysis passes.
407pub fn analysis_cache_key(score: &Score) -> String {
408    format!(
409        "analysis-v{}-{}",
410        ANALYSIS_SCHEMA_VERSION,
411        score_fingerprint(score)
412    )
413}
414
415fn fnv1a64(bytes: &[u8]) -> u64 {
416    bytes.iter().fold(0xcbf29ce484222325u64, |hash, byte| {
417        (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
418    })
419}
420
421impl AnalysisCounts {
422    fn from_result(result: &AnalysisResult) -> Self {
423        Self {
424            chords: result.chords.len(),
425            intervals: result.intervals.len(),
426            key_estimates: result.key_estimates.len(),
427            cadence_candidates: result.cadence_candidates.len(),
428            voice_leading: result.voice_leading.len(),
429            satb_diagnostics: result.satb_diagnostics.len(),
430            motifs: result.motifs.len(),
431            phrase_boundaries: result.phrase_boundaries.len(),
432        }
433    }
434
435    fn total(self) -> usize {
436        self.chords
437            + self.intervals
438            + self.key_estimates
439            + self.cadence_candidates
440            + self.voice_leading
441            + self.satb_diagnostics
442            + self.motifs
443            + self.phrase_boundaries
444    }
445
446    fn explained(self, result: &AnalysisResult) -> usize {
447        result
448            .chords
449            .iter()
450            .filter(|item| !item.evidence.is_empty())
451            .count()
452            + result
453                .intervals
454                .iter()
455                .filter(|item| !item.evidence.is_empty())
456                .count()
457            + result
458                .key_estimates
459                .iter()
460                .filter(|item| !item.evidence.is_empty())
461                .count()
462            + result
463                .cadence_candidates
464                .iter()
465                .filter(|item| !item.evidence.is_empty())
466                .count()
467            + result
468                .voice_leading
469                .iter()
470                .filter(|item| !item.evidence.is_empty())
471                .count()
472            + result
473                .satb_diagnostics
474                .iter()
475                .filter(|item| !item.evidence.is_empty())
476                .count()
477            + result
478                .motifs
479                .iter()
480                .map(|item| {
481                    item.occurrences
482                        .iter()
483                        .filter(|occurrence| !occurrence.evidence.is_empty())
484                        .count()
485                })
486                .sum::<usize>()
487            + result
488                .phrase_boundaries
489                .iter()
490                .filter(|item| !item.evidence.is_empty())
491                .count()
492    }
493}
494
495/// Run one offline benchmark case using count-based hand-verified annotations.
496pub fn benchmark_case(case: &BenchmarkCase<'_>) -> BenchmarkCaseReport {
497    let result = analyze_score(case.score);
498    let predicted = AnalysisCounts::from_result(&result);
499    let expected = case.expected;
500    let matched = predicted.chords.min(expected.chords)
501        + predicted.intervals.min(expected.intervals)
502        + predicted.key_estimates.min(expected.key_estimates)
503        + predicted
504            .cadence_candidates
505            .min(expected.cadence_candidates)
506        + predicted.voice_leading.min(expected.voice_leading)
507        + predicted.satb_diagnostics.min(expected.satb_diagnostics)
508        + predicted.motifs.min(expected.motifs)
509        + predicted.phrase_boundaries.min(expected.phrase_boundaries);
510    let expected_total = AnalysisCounts {
511        chords: expected.chords,
512        intervals: expected.intervals,
513        key_estimates: expected.key_estimates,
514        cadence_candidates: expected.cadence_candidates,
515        voice_leading: expected.voice_leading,
516        satb_diagnostics: expected.satb_diagnostics,
517        motifs: expected.motifs,
518        phrase_boundaries: expected.phrase_boundaries,
519    }
520    .total();
521    let predicted_total = predicted.total();
522    let failures = [
523        (BenchmarkCategory::Chords, expected.chords, predicted.chords),
524        (
525            BenchmarkCategory::Intervals,
526            expected.intervals,
527            predicted.intervals,
528        ),
529        (
530            BenchmarkCategory::KeyEstimates,
531            expected.key_estimates,
532            predicted.key_estimates,
533        ),
534        (
535            BenchmarkCategory::CadenceCandidates,
536            expected.cadence_candidates,
537            predicted.cadence_candidates,
538        ),
539        (
540            BenchmarkCategory::VoiceLeading,
541            expected.voice_leading,
542            predicted.voice_leading,
543        ),
544        (
545            BenchmarkCategory::SatbDiagnostics,
546            expected.satb_diagnostics,
547            predicted.satb_diagnostics,
548        ),
549        (BenchmarkCategory::Motifs, expected.motifs, predicted.motifs),
550        (
551            BenchmarkCategory::PhraseBoundaries,
552            expected.phrase_boundaries,
553            predicted.phrase_boundaries,
554        ),
555    ]
556    .into_iter()
557    .filter_map(|(category, expected, predicted)| {
558        if expected == predicted {
559            return None;
560        }
561        Some(BenchmarkFailure {
562            category,
563            expected,
564            predicted,
565            missing: expected.saturating_sub(predicted),
566            excess: predicted.saturating_sub(expected),
567        })
568    })
569    .collect();
570    BenchmarkCaseReport {
571        name: case.name.to_string(),
572        predicted,
573        expected,
574        precision_percent: percentage(matched, predicted_total),
575        recall_percent: percentage(matched, expected_total),
576        explanation_completeness_percent: percentage(predicted.explained(&result), predicted_total),
577        failures,
578    }
579}
580
581/// Run benchmark cases in input order; no filesystem or network access is used.
582pub fn run_benchmark(cases: &[BenchmarkCase<'_>]) -> Vec<BenchmarkCaseReport> {
583    cases.iter().map(benchmark_case).collect()
584}
585
586/// Run a benchmark suite and aggregate its case-level metrics.
587pub fn run_benchmark_suite(cases: &[BenchmarkCase<'_>]) -> BenchmarkSuiteReport {
588    let reports = run_benchmark(cases);
589    let case_count = reports.len();
590    let passed_case_count = reports
591        .iter()
592        .filter(|report| report.failures.is_empty())
593        .count();
594    let failed_case_count = case_count.saturating_sub(passed_case_count);
595    let precision_total: usize = reports
596        .iter()
597        .map(|report| usize::from(report.precision_percent))
598        .sum();
599    let recall_total: usize = reports
600        .iter()
601        .map(|report| usize::from(report.recall_percent))
602        .sum();
603    let explanation_total: usize = reports
604        .iter()
605        .map(|report| usize::from(report.explanation_completeness_percent))
606        .sum();
607    let metric_denominator = case_count.saturating_mul(100);
608    let aggregate_metric = |total: usize| {
609        if case_count == 0 {
610            0
611        } else {
612            percentage(total, metric_denominator)
613        }
614    };
615    BenchmarkSuiteReport {
616        cases: reports,
617        case_count,
618        passed_case_count,
619        failed_case_count,
620        precision_percent: aggregate_metric(precision_total),
621        recall_percent: aggregate_metric(recall_total),
622        explanation_completeness_percent: aggregate_metric(explanation_total),
623    }
624}
625
626fn percentage(numerator: usize, denominator: usize) -> u8 {
627    match numerator.saturating_mul(100).checked_div(denominator) {
628        Some(value) => value.min(100) as u8,
629        None => 100,
630    }
631}
632
633/// Analyze SATB constraints using the same aligned voice events as voice-leading analysis.
634pub fn analyze_satb(score: &Score) -> Vec<SatbDiagnostic> {
635    let voice_leading = analyze_voice_leading(score);
636    analyze_satb_with_voice_leading(score, &voice_leading)
637}
638
639fn analyze_satb_with_voice_leading(
640    score: &Score,
641    voice_leading: &[VoiceLeadingObservation],
642) -> Vec<SatbDiagnostic> {
643    let mut diagnostics = Vec::new();
644    for (part_index, part) in score.parts.iter().enumerate() {
645        for (staff_index, staff) in part.staves.iter().enumerate() {
646            for (measure_index, measure) in staff.measures.iter().enumerate() {
647                for (upper_index, upper_voice) in measure.voices.iter().enumerate() {
648                    let Some(lower_voice) = measure.voices.get(upper_index + 1) else {
649                        continue;
650                    };
651                    for note_index in 0..upper_voice.len().min(lower_voice.len()) {
652                        let Some(upper) = upper_voice[note_index].pitches.first() else {
653                            continue;
654                        };
655                        let Some(lower) = lower_voice[note_index].pitches.first() else {
656                            continue;
657                        };
658                        let upper_addr = NoteAddr {
659                            part: part_index,
660                            staff: staff_index,
661                            measure: measure_index,
662                            voice: upper_index,
663                            note: note_index,
664                        };
665                        let lower_addr = NoteAddr {
666                            part: part_index,
667                            staff: staff_index,
668                            measure: measure_index,
669                            voice: upper_index + 1,
670                            note: note_index,
671                        };
672                        let distance = upper.to_midi() - lower.to_midi();
673                        if distance < 0 {
674                            diagnostics.push(satb_diagnostic(
675                                upper_addr.clone(),
676                                lower_addr.clone(),
677                                SatbDiagnosticKind::VoiceCrossing,
678                                SatbSeverity::Error,
679                                "satb-voice-crossing",
680                            ));
681                        } else if distance > 24 {
682                            diagnostics.push(satb_diagnostic(
683                                upper_addr.clone(),
684                                lower_addr.clone(),
685                                SatbDiagnosticKind::WideSpacing,
686                                SatbSeverity::Warning,
687                                "satb-wide-spacing",
688                            ));
689                        }
690                    }
691                }
692            }
693        }
694    }
695    for observation in voice_leading {
696        if observation.parallel_perfect {
697            diagnostics.push(satb_diagnostic(
698                observation.upper.clone(),
699                observation.lower.clone(),
700                SatbDiagnosticKind::ParallelPerfect,
701                SatbSeverity::Warning,
702                "satb-parallel-perfect",
703            ));
704        }
705    }
706    diagnostics
707}
708
709fn satb_diagnostic(
710    upper: NoteAddr,
711    lower: NoteAddr,
712    kind: SatbDiagnosticKind,
713    severity: SatbSeverity,
714    rule_id: &str,
715) -> SatbDiagnostic {
716    SatbDiagnostic {
717        evidence: vec![upper.clone(), lower.clone()],
718        upper,
719        lower,
720        kind,
721        severity,
722        confidence: 100,
723        rule_id: rule_id.to_string(),
724    }
725}
726
727/// Find repeated three-note melodic interval patterns, resetting at rests.
728pub fn analyze_motifs(score: &Score) -> Vec<MotifPattern> {
729    let mut groups: BTreeMap<(usize, usize, usize, Vec<i8>), Vec<MotifOccurrence>> =
730        BTreeMap::new();
731    for (part_index, part) in score.parts.iter().enumerate() {
732        for (staff_index, staff) in part.staves.iter().enumerate() {
733            let Some(first_measure) = staff.measures.first() else {
734                continue;
735            };
736            for (voice_index, _) in first_measure.voices.iter().enumerate() {
737                let mut segment = Vec::new();
738                let mut segments = Vec::new();
739                for (measure_index, measure) in staff.measures.iter().enumerate() {
740                    for (note_index, note) in measure.voices[voice_index].iter().enumerate() {
741                        let Some(pitch) = note.pitches.first() else {
742                            if segment.len() >= 3 {
743                                segments.push(std::mem::take(&mut segment));
744                            } else {
745                                segment.clear();
746                            }
747                            continue;
748                        };
749                        if note.is_rest {
750                            if segment.len() >= 3 {
751                                segments.push(std::mem::take(&mut segment));
752                            } else {
753                                segment.clear();
754                            }
755                            continue;
756                        }
757                        segment.push((
758                            NoteAddr {
759                                part: part_index,
760                                staff: staff_index,
761                                measure: measure_index,
762                                voice: voice_index,
763                                note: note_index,
764                            },
765                            pitch.to_midi(),
766                        ));
767                    }
768                }
769                if segment.len() >= 3 {
770                    segments.push(segment);
771                }
772                for segment in segments {
773                    for window in segment.windows(3) {
774                        let signature = vec![
775                            (window[1].1 - window[0].1) as i8,
776                            (window[2].1 - window[1].1) as i8,
777                        ];
778                        let occurrence = MotifOccurrence {
779                            start: window[0].0.clone(),
780                            end: window[2].0.clone(),
781                            evidence: window.iter().map(|(address, _)| address.clone()).collect(),
782                        };
783                        groups
784                            .entry((part_index, staff_index, voice_index, signature))
785                            .or_default()
786                            .push(occurrence);
787                    }
788                }
789            }
790        }
791    }
792    groups
793        .into_iter()
794        .filter(|(_, occurrences)| occurrences.len() >= 2)
795        .map(|((_, _, _, signature), occurrences)| MotifPattern {
796            signature,
797            occurrences,
798            confidence: 100,
799            rule_id: "repeated-three-note-interval-pattern".to_string(),
800        })
801        .collect()
802}
803
804/// Report measure-ending rests as explicit, conservative phrase boundaries.
805pub fn analyze_phrase_boundaries(score: &Score) -> Vec<PhraseBoundary> {
806    let mut boundaries = Vec::new();
807    for (part_index, part) in score.parts.iter().enumerate() {
808        for (staff_index, staff) in part.staves.iter().enumerate() {
809            for (measure_index, measure) in staff.measures.iter().enumerate() {
810                for (voice_index, voice) in measure.voices.iter().enumerate() {
811                    let Some((note_index, note)) = voice.iter().enumerate().next_back() else {
812                        continue;
813                    };
814                    if !note.is_rest {
815                        continue;
816                    }
817                    let address = NoteAddr {
818                        part: part_index,
819                        staff: staff_index,
820                        measure: measure_index,
821                        voice: voice_index,
822                        note: note_index,
823                    };
824                    boundaries.push(PhraseBoundary {
825                        address: address.clone(),
826                        reason: PhraseBoundaryReason::RestTermination,
827                        confidence: 100,
828                        rule_id: "measure-ending-rest".to_string(),
829                        evidence: vec![address],
830                    });
831                }
832            }
833        }
834    }
835    boundaries
836}
837
838/// Find explicit cadence transitions in the order they occur within each voice.
839pub fn analyze_cadences(chords: &[ChordLabel]) -> Vec<CadenceCandidate> {
840    let mut candidates = Vec::new();
841    let mut previous: HashMap<(usize, usize, usize), &ChordLabel> = HashMap::new();
842    for chord in chords {
843        let key = (chord.address.part, chord.address.staff, chord.address.voice);
844        let Some(previous_chord) = previous.insert(key, chord) else {
845            continue;
846        };
847        let Some(from_roman) = previous_chord.roman_numeral.as_deref() else {
848            continue;
849        };
850        let Some(to_roman) = chord.roman_numeral.as_deref() else {
851            continue;
852        };
853        let from_figure = roman_figure(from_roman);
854        let to_figure = roman_figure(to_roman);
855        let kind = match (from_figure, to_figure) {
856            ("V" | "V7", "I") => CadenceKind::Authentic,
857            ("IV", "I") => CadenceKind::Plagal,
858            ("V" | "V7", "vi") => CadenceKind::Deceptive,
859            (_, "V" | "V7") => CadenceKind::Half,
860            _ => continue,
861        };
862        let evidence = vec![previous_chord.address.clone(), chord.address.clone()];
863        candidates.push(CadenceCandidate {
864            from: previous_chord.address.clone(),
865            to: chord.address.clone(),
866            kind,
867            confidence: 100,
868            rule_id: "roman-numeral-cadence-transition".to_string(),
869            evidence,
870        });
871    }
872    candidates
873}
874
875fn roman_figure(roman: &str) -> &str {
876    roman.find('/').map_or(roman, |index| &roman[..index])
877}
878
879/// Check aligned notes in adjacent voices for motion and parallel perfect intervals.
880pub fn analyze_voice_leading(score: &Score) -> Vec<VoiceLeadingObservation> {
881    let mut observations = Vec::new();
882    for (part_index, part) in score.parts.iter().enumerate() {
883        for (staff_index, staff) in part.staves.iter().enumerate() {
884            for (measure_index, measure) in staff.measures.iter().enumerate() {
885                for (upper_index, upper_voice) in measure.voices.iter().enumerate() {
886                    let Some(lower_voice) = measure.voices.get(upper_index + 1) else {
887                        continue;
888                    };
889                    let count = upper_voice.len().min(lower_voice.len());
890                    for note_index in 0..count {
891                        let Some(upper) = upper_voice[note_index].pitches.first() else {
892                            continue;
893                        };
894                        let Some(lower) = lower_voice[note_index].pitches.first() else {
895                            continue;
896                        };
897                        let next_upper = upper_voice[note_index + 1..]
898                            .iter()
899                            .find_map(|note| note.pitches.first());
900                        let next_lower = lower_voice[note_index + 1..]
901                            .iter()
902                            .find_map(|note| note.pitches.first());
903                        let (Some(next_upper), Some(next_lower)) = (next_upper, next_lower) else {
904                            continue;
905                        };
906                        let upper_addr = NoteAddr {
907                            part: part_index,
908                            staff: staff_index,
909                            measure: measure_index,
910                            voice: upper_index,
911                            note: note_index,
912                        };
913                        let lower_addr = NoteAddr {
914                            part: part_index,
915                            staff: staff_index,
916                            measure: measure_index,
917                            voice: upper_index + 1,
918                            note: note_index,
919                        };
920                        let upper_next_midi = next_upper.to_midi();
921                        let lower_next_midi = next_lower.to_midi();
922                        let upper_motion = upper_next_midi - upper.to_midi();
923                        let lower_motion = lower_next_midi - lower.to_midi();
924                        let initial = (upper.to_midi() - lower.to_midi()).unsigned_abs() % 12;
925                        let next = (upper_next_midi - lower_next_midi).unsigned_abs() % 12;
926                        observations.push(VoiceLeadingObservation {
927                            upper: upper_addr.clone(),
928                            lower: lower_addr.clone(),
929                            upper_motion,
930                            lower_motion,
931                            parallel_perfect: matches!(initial, 0 | 7)
932                                && initial == next
933                                && upper_motion != 0
934                                && upper_motion.signum() == lower_motion.signum(),
935                            confidence: 100,
936                            rule_id: "aligned-adjacent-voice-leading".to_string(),
937                            evidence: vec![upper_addr, lower_addr],
938                        });
939                    }
940                }
941            }
942        }
943    }
944    observations
945}
946
947/// Backwards-compatible name for the complete score analysis pass.
948pub fn analyze_chords(score: &Score) -> AnalysisResult {
949    analyze_score(score)
950}
951
952/// Analyze a finite batch in input order.
953pub fn analyze_batch(scores: &[Score]) -> Vec<AnalysisResult> {
954    scores.iter().map(analyze_score).collect()
955}
956
957/// Analyze scores lazily, one result per input score.
958pub fn analyze_stream<I>(scores: I) -> impl Iterator<Item = AnalysisResult>
959where
960    I: IntoIterator<Item = Score>,
961{
962    scores.into_iter().map(|score| analyze_score(&score))
963}
964
965/// Estimate major/minor keys from pitch coverage, preserving tied candidates.
966pub fn estimate_keys(score: &Score) -> Vec<KeyEstimate> {
967    let mut pitches = Vec::new();
968    let mut evidence = Vec::new();
969    for (part_index, part) in score.parts.iter().enumerate() {
970        for (staff_index, staff) in part.staves.iter().enumerate() {
971            for (measure_index, measure) in staff.measures.iter().enumerate() {
972                for (voice_index, voice) in measure.voices.iter().enumerate() {
973                    for (note_index, note) in voice.iter().enumerate() {
974                        if note.is_rest {
975                            continue;
976                        }
977                        pitches.extend(note.pitches.iter());
978                        if !note.pitches.is_empty() {
979                            evidence.push(NoteAddr {
980                                part: part_index,
981                                staff: staff_index,
982                                measure: measure_index,
983                                voice: voice_index,
984                                note: note_index,
985                            });
986                        }
987                    }
988                }
989            }
990        }
991    }
992    if pitches.is_empty() {
993        return Vec::new();
994    }
995    let total_pitches = pitches.len();
996    let mut candidates = Vec::with_capacity(30);
997    for fifths in -7..=7 {
998        for mode in ["major", "minor"] {
999            let key = KeySignature {
1000                fifths,
1001                mode: mode.to_string(),
1002            };
1003            let covered = pitches
1004                .iter()
1005                .filter(|pitch| key.contains_pitch(pitch))
1006                .count();
1007            candidates.push((key, covered));
1008        }
1009    }
1010    candidates.sort_by(|(left_key, left_score), (right_key, right_score)| {
1011        right_score
1012            .cmp(left_score)
1013            .then_with(|| left_key.fifths.abs().cmp(&right_key.fifths.abs()))
1014            .then_with(|| left_key.fifths.cmp(&right_key.fifths))
1015            .then_with(|| left_key.mode.cmp(&right_key.mode))
1016    });
1017    let best = candidates[0].1;
1018    candidates
1019        .into_iter()
1020        .take_while(|(_, covered)| *covered == best)
1021        .map(|(key, covered_pitches)| KeyEstimate {
1022            key,
1023            covered_pitches,
1024            total_pitches,
1025            confidence: ((covered_pitches * 100) / total_pitches) as u8,
1026            rule_id: "diatonic-pitch-coverage".to_string(),
1027            evidence: evidence.clone(),
1028        })
1029        .collect()
1030}
1031
1032/// Analyze adjacent pitched notes in every voice without inferring missing events.
1033pub fn analyze_intervals(score: &Score) -> Vec<IntervalObservation> {
1034    let mut observations = Vec::new();
1035    for (part_index, part) in score.parts.iter().enumerate() {
1036        for (staff_index, staff) in part.staves.iter().enumerate() {
1037            for (measure_index, measure) in staff.measures.iter().enumerate() {
1038                for (voice_index, voice) in measure.voices.iter().enumerate() {
1039                    let notes: Vec<_> = voice
1040                        .iter()
1041                        .enumerate()
1042                        .filter_map(|(note_index, note)| {
1043                            if note.is_rest {
1044                                None
1045                            } else {
1046                                note.pitches.first().map(|pitch| (note_index, pitch))
1047                            }
1048                        })
1049                        .collect();
1050                    for pair in notes.windows(2) {
1051                        let (from_index, from) = pair[0];
1052                        let (to_index, to) = pair[1];
1053                        let from_addr = NoteAddr {
1054                            part: part_index,
1055                            staff: staff_index,
1056                            measure: measure_index,
1057                            voice: voice_index,
1058                            note: from_index,
1059                        };
1060                        let to_addr = NoteAddr {
1061                            part: part_index,
1062                            staff: staff_index,
1063                            measure: measure_index,
1064                            voice: voice_index,
1065                            note: to_index,
1066                        };
1067                        observations.push(IntervalObservation {
1068                            from: from_addr.clone(),
1069                            to: to_addr.clone(),
1070                            semitones: (to.to_midi() - from.to_midi()).unsigned_abs() as u8,
1071                            diatonic_steps: diatonic_distance(from, to),
1072                            rule_id: "adjacent-melodic-interval".to_string(),
1073                            evidence: vec![from_addr, to_addr],
1074                        });
1075                    }
1076                }
1077            }
1078        }
1079    }
1080    observations
1081}
1082
1083fn diatonic_distance(from: &acorde_core::Pitch, to: &acorde_core::Pitch) -> i8 {
1084    let step_index = |step: &acorde_core::Step| match step {
1085        acorde_core::Step::C => 0i16,
1086        acorde_core::Step::D => 1,
1087        acorde_core::Step::E => 2,
1088        acorde_core::Step::F => 3,
1089        acorde_core::Step::G => 4,
1090        acorde_core::Step::A => 5,
1091        acorde_core::Step::B => 6,
1092    };
1093    (i16::from(to.octave) * 7 + step_index(&to.step)
1094        - (i16::from(from.octave) * 7 + step_index(&from.step))) as i8
1095}
1096
1097/// Return the stable chord spelling as a compact human-readable label.
1098pub fn chord_name(chord: &ChordSymbol) -> String {
1099    let suffix = match chord.kind.as_str() {
1100        "major" => "",
1101        "minor" => "m",
1102        "dominant" => "7",
1103        "major-seventh" => "maj7",
1104        "minor-seventh" => "m7",
1105        "diminished" => "dim",
1106        "diminished-seventh" => "dim7",
1107        "half-diminished" => "ΓΈ7",
1108        "augmented" => "+",
1109        _ => chord.kind.as_str(),
1110    };
1111    let bass = chord
1112        .bass
1113        .as_deref()
1114        .map_or(String::new(), |bass| format!("/{bass}"));
1115    format!("{}{suffix}{bass}", chord.root)
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120    use super::*;
1121    use acorde_core::{Duration, Note, Pitch, Score, Step};
1122
1123    #[test]
1124    fn labels_chord_with_note_addresses_and_roman_numeral() {
1125        let mut score = Score::default();
1126        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1127        voice.clear();
1128        for step in [Step::C, Step::E, Step::G] {
1129            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
1130        }
1131        let result = analyze_chords(&score);
1132        assert_eq!(result.schema_version, ANALYSIS_SCHEMA_VERSION);
1133        assert_eq!(result.chords.len(), 1);
1134        assert_eq!(result.intervals.len(), 2);
1135        assert!(!result.key_estimates.is_empty());
1136        assert_eq!(result.chords[0].address.note, 0);
1137        assert_eq!(result.chords[0].evidence.len(), 3);
1138        assert_eq!(result.chords[0].roman_numeral.as_deref(), Some("I"));
1139        assert_eq!(chord_name(&result.chords[0].chord), "C");
1140    }
1141
1142    #[test]
1143    fn analysis_has_stable_score_fingerprint() {
1144        let score = Score::default();
1145        let repeated = analyze_score(&score);
1146        assert_eq!(repeated.score_fingerprint, score_fingerprint(&score));
1147        assert_eq!(
1148            repeated.score_fingerprint,
1149            analyze_score(&score).score_fingerprint
1150        );
1151
1152        let mut changed = score.clone();
1153        changed.metadata.title = "Changed".to_string();
1154        assert_ne!(repeated.score_fingerprint, score_fingerprint(&changed));
1155    }
1156
1157    #[test]
1158    fn fingerprint_uses_fnv1a_byte_order() {
1159        assert_eq!(fnv1a64(b"hello"), 0xa430d84680aabd0b);
1160    }
1161
1162    #[test]
1163    fn cache_key_includes_schema_and_score_identity() {
1164        let result = analyze_score(&Score::default());
1165        assert!(result.cache_key().starts_with("analysis-v7-fnv1a64-"));
1166        assert_eq!(result.cache_key(), analysis_cache_key(&Score::default()));
1167        let mut changed = result.clone();
1168        changed.schema_version = 8;
1169        assert_ne!(result.cache_key(), changed.cache_key());
1170    }
1171
1172    #[test]
1173    fn analysis_result_rejects_a_different_score() {
1174        let score = Score::default();
1175        let result = analyze_score(&score);
1176        assert!(result.matches_score(&score));
1177        let mut changed = score.clone();
1178        changed.metadata.title = "Changed".to_string();
1179        assert!(!result.matches_score(&changed));
1180    }
1181
1182    #[test]
1183    fn interval_observation_preserves_direction_and_evidence() {
1184        let mut score = Score::default();
1185        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1186        voice.clear();
1187        voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
1188        voice.push(Note::new(Pitch::new(Step::G, 4), Duration::Quarter));
1189        let intervals = analyze_intervals(&score);
1190        assert_eq!(intervals.len(), 1);
1191        assert_eq!(intervals[0].semitones, 7);
1192        assert_eq!(intervals[0].diatonic_steps, 4);
1193        assert_eq!(intervals[0].evidence.len(), 2);
1194    }
1195
1196    #[test]
1197    fn does_not_invent_label_for_unknown_pitch_set() {
1198        let mut score = Score::default();
1199        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1200        voice.clear();
1201        for step in [Step::C, Step::C] {
1202            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
1203        }
1204        assert!(analyze_chords(&score).chords.is_empty());
1205    }
1206
1207    #[test]
1208    fn preserves_relative_major_minor_key_ambiguity() {
1209        let mut score = Score::default();
1210        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1211        voice.clear();
1212        for step in [
1213            Step::C,
1214            Step::D,
1215            Step::E,
1216            Step::F,
1217            Step::G,
1218            Step::A,
1219            Step::B,
1220        ] {
1221            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
1222        }
1223        let estimates = estimate_keys(&score);
1224        assert!(
1225            estimates
1226                .iter()
1227                .any(|estimate| estimate.key.display_name() == "C major")
1228        );
1229        assert!(
1230            estimates
1231                .iter()
1232                .any(|estimate| estimate.key.display_name() == "A minor")
1233        );
1234        assert!(estimates.iter().all(|estimate| estimate.confidence == 100));
1235    }
1236
1237    #[test]
1238    fn returns_no_key_for_empty_score() {
1239        assert!(estimate_keys(&Score::default()).is_empty());
1240    }
1241
1242    #[test]
1243    fn batch_and_stream_preserve_score_order() {
1244        let scores = vec![Score::default(), Score::default()];
1245        let batch = analyze_batch(&scores);
1246        let streamed: Vec<_> = analyze_stream(scores.clone()).collect();
1247        assert_eq!(batch, streamed);
1248        assert_eq!(batch.len(), scores.len());
1249    }
1250
1251    #[test]
1252    fn detects_authentic_cadence_from_adjacent_measures() {
1253        let mut score = Score::default();
1254        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1255        voice.clear();
1256        for (step, octave) in [(Step::G, 3), (Step::B, 3), (Step::D, 4)] {
1257            voice.push(Note::new(Pitch::new(step, octave), Duration::Quarter));
1258        }
1259        let voice = &mut score.parts[0].staves[0].measures[1].voices[0];
1260        voice.clear();
1261        for step in [Step::C, Step::E, Step::G] {
1262            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
1263        }
1264        let result = analyze_score(&score);
1265        assert_eq!(result.cadence_candidates.len(), 1);
1266        assert_eq!(result.cadence_candidates[0].kind, CadenceKind::Authentic);
1267        assert_eq!(result.cadence_candidates[0].evidence.len(), 2);
1268    }
1269
1270    #[test]
1271    fn flags_parallel_octaves_between_aligned_voices() {
1272        let mut score = Score::default();
1273        let measure = &mut score.parts[0].staves[0].measures[0];
1274        measure.voices[0].clear();
1275        measure.voices[1].clear();
1276        for (upper, lower) in [(Step::C, Step::C), (Step::D, Step::D)] {
1277            measure.voices[0].push(Note::new(Pitch::new(upper, 4), Duration::Quarter));
1278            measure.voices[1].push(Note::new(Pitch::new(lower, 3), Duration::Quarter));
1279        }
1280        let observations = analyze_voice_leading(&score);
1281        assert_eq!(observations.len(), 1);
1282        assert!(observations[0].parallel_perfect);
1283        assert_eq!(observations[0].evidence.len(), 2);
1284        let diagnostics = analyze_satb(&score);
1285        assert_eq!(diagnostics.len(), 1);
1286        assert_eq!(diagnostics[0].kind, SatbDiagnosticKind::ParallelPerfect);
1287        assert_eq!(diagnostics[0].severity, SatbSeverity::Warning);
1288    }
1289
1290    #[test]
1291    fn reports_voice_crossing_as_error() {
1292        let mut score = Score::default();
1293        let measure = &mut score.parts[0].staves[0].measures[0];
1294        measure.voices[0].clear();
1295        measure.voices[1].clear();
1296        measure.voices[0].push(Note::new(Pitch::new(Step::C, 3), Duration::Quarter));
1297        measure.voices[1].push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
1298        let diagnostics = analyze_satb(&score);
1299        assert_eq!(diagnostics.len(), 1);
1300        assert_eq!(diagnostics[0].kind, SatbDiagnosticKind::VoiceCrossing);
1301        assert_eq!(diagnostics[0].severity, SatbSeverity::Error);
1302    }
1303
1304    #[test]
1305    fn finds_repeated_melodic_motif_with_source_spans() {
1306        let mut score = Score::default();
1307        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1308        voice.clear();
1309        for step in [Step::C, Step::D, Step::E, Step::G, Step::A, Step::B] {
1310            voice.push(Note::new(Pitch::new(step, 4), Duration::Quarter));
1311        }
1312        let motifs = analyze_motifs(&score);
1313        assert_eq!(motifs.len(), 1);
1314        assert_eq!(motifs[0].signature, vec![2, 2]);
1315        assert_eq!(motifs[0].occurrences.len(), 2);
1316        assert_eq!(motifs[0].occurrences[0].evidence.len(), 3);
1317    }
1318
1319    #[test]
1320    fn reports_measure_ending_rest_as_phrase_boundary() {
1321        let mut score = Score::default();
1322        let voice = &mut score.parts[0].staves[0].measures[0].voices[0];
1323        voice.clear();
1324        voice.push(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
1325        voice.push(Note::rest(Duration::Quarter));
1326        let boundaries = analyze_phrase_boundaries(&score);
1327        assert!(boundaries.iter().any(|boundary| {
1328            boundary.reason == PhraseBoundaryReason::RestTermination
1329                && boundary.address.measure == 0
1330                && boundary.address.note == 1
1331        }));
1332    }
1333
1334    #[test]
1335    fn benchmark_reports_perfect_scores_for_hand_verified_empty_fixture() {
1336        let score = Score::default();
1337        let cases = [BenchmarkCase {
1338            name: "empty-score",
1339            score: &score,
1340            expected: BenchmarkExpectation {
1341                phrase_boundaries: 4,
1342                ..BenchmarkExpectation::default()
1343            },
1344        }];
1345        let reports = run_benchmark(&cases);
1346        assert_eq!(reports.len(), 1);
1347        assert_eq!(reports[0].precision_percent, 100);
1348        assert_eq!(reports[0].recall_percent, 100);
1349        assert_eq!(reports[0].explanation_completeness_percent, 100);
1350        assert!(reports[0].failures.is_empty());
1351    }
1352
1353    #[test]
1354    fn benchmark_reports_category_level_failure_details() {
1355        let score = Score::default();
1356        let cases = [BenchmarkCase {
1357            name: "under-annotated-score",
1358            score: &score,
1359            expected: BenchmarkExpectation {
1360                phrase_boundaries: 5,
1361                ..BenchmarkExpectation::default()
1362            },
1363        }];
1364        let reports = run_benchmark(&cases);
1365        assert_eq!(
1366            reports[0].failures,
1367            vec![BenchmarkFailure {
1368                category: BenchmarkCategory::PhraseBoundaries,
1369                expected: 5,
1370                predicted: 4,
1371                missing: 1,
1372                excess: 0,
1373            }]
1374        );
1375    }
1376
1377    #[test]
1378    fn benchmark_suite_aggregates_case_status_and_metrics() {
1379        let score = Score::default();
1380        let cases = [
1381            BenchmarkCase {
1382                name: "passing",
1383                score: &score,
1384                expected: BenchmarkExpectation {
1385                    phrase_boundaries: 4,
1386                    ..BenchmarkExpectation::default()
1387                },
1388            },
1389            BenchmarkCase {
1390                name: "failing",
1391                score: &score,
1392                expected: BenchmarkExpectation {
1393                    phrase_boundaries: 5,
1394                    ..BenchmarkExpectation::default()
1395                },
1396            },
1397        ];
1398        let suite = run_benchmark_suite(&cases);
1399        assert_eq!(suite.case_count, 2);
1400        assert_eq!(suite.passed_case_count, 1);
1401        assert_eq!(suite.failed_case_count, 1);
1402        assert_eq!(suite.cases.len(), 2);
1403        assert_eq!(suite.precision_percent, 100);
1404        assert_eq!(suite.recall_percent, 90);
1405        assert_eq!(suite.explanation_completeness_percent, 100);
1406    }
1407
1408    struct TestPass(&'static str);
1409
1410    impl AnalysisPass for TestPass {
1411        fn id(&self) -> &str {
1412            self.0
1413        }
1414
1415        fn run(&self, _score: &Score) -> serde_json::Value {
1416            serde_json::json!({ "pass": self.0 })
1417        }
1418    }
1419
1420    #[test]
1421    fn extension_passes_run_in_stable_id_order() {
1422        let score = Score::default();
1423        let beta = TestPass("beta");
1424        let alpha = TestPass("alpha");
1425        let results = run_analysis_passes(&score, &[&beta, &alpha]).unwrap();
1426        assert_eq!(
1427            results
1428                .iter()
1429                .map(|result| result.pass_id.as_str())
1430                .collect::<Vec<_>>(),
1431            ["alpha", "beta"]
1432        );
1433        assert_eq!(results[0].output["pass"], "alpha");
1434    }
1435
1436    #[test]
1437    fn extension_passes_reject_empty_and_duplicate_ids() {
1438        let score = Score::default();
1439        let empty = TestPass("");
1440        assert_eq!(
1441            run_analysis_passes(&score, &[&empty]),
1442            Err(AnalysisPassError::EmptyId)
1443        );
1444        let first = TestPass("same");
1445        let second = TestPass("same");
1446        assert_eq!(
1447            run_analysis_passes(&score, &[&first, &second]),
1448            Err(AnalysisPassError::DuplicateId("same".to_string()))
1449        );
1450    }
1451}