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