Skip to main content

sim_lib_music_serial/
extract.rs

1//! Ranked extraction of serial-row hypotheses from exact music attacks.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use sim_lib_discrete_search::{
6    NeverInterrupt, SearchControl, SearchInterrupt, SearchOrder, SearchProblem, SearchRun,
7    SearchStatus, SearchStep, solve,
8};
9use sim_lib_music_core::{
10    AmbiguousConversionPolicy, AtomRef, Music, MusicObject, Note, ObjectId, Score, ScoreForm,
11    ScoreFormKind, Staff, StaffNote, StaffVoice, Time, convert_score,
12};
13use sim_lib_pitch_serial::{RowClassAlias, RowLabelConvention, ToneRow, analyze_row_class};
14use thiserror::Error;
15
16use crate::{
17    ExtractionEvidence, ExtractionOutcome, RankedSerialHypothesis, SerialAliasEvidence,
18    SerialObservation, SerialObservationBlock, SerialReadingOrder, SerialStableRank,
19    SerialTimeSpan,
20};
21
22/// Request policy for serial-row extraction.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct SerialExtractionRequest {
25    /// Generic bounded-search control reused from the discrete owner.
26    pub search: SearchControl,
27    /// Convention used when rendering alias labels.
28    pub label_convention: RowLabelConvention,
29}
30
31impl Default for SerialExtractionRequest {
32    fn default() -> Self {
33        Self {
34            search: SearchControl::default()
35                .with_order(SearchOrder::DepthFirst)
36                .with_max_results(128),
37            label_convention: RowLabelConvention::FirstLastPitch,
38        }
39    }
40}
41
42/// Auxiliary services for extraction.
43#[derive(Default)]
44pub struct SerialExtractionServices<'a> {
45    /// Optional external interrupt source checked by the generic search loop.
46    pub interrupt: Option<&'a dyn SearchInterrupt>,
47}
48
49/// Failure while extracting serial-row hypotheses.
50#[derive(Debug, Error)]
51pub enum SerialExtractionError {
52    /// Existing score conversion rejected the source.
53    #[error("serial extraction score conversion failed: {0}")]
54    ScoreConversion(String),
55    /// The exact window owner rejected the source staff.
56    #[error("serial extraction window construction failed: {0}")]
57    WindowConstruction(String),
58    /// A score-form conversion or derived identity was invalid.
59    #[error("serial extraction identity failure: {0}")]
60    Identity(String),
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
64struct AttackCandidate {
65    voice_id: ObjectId,
66    note_id: ObjectId,
67    event_id: ObjectId,
68    midi: u8,
69    onset: Time,
70    release: Time,
71}
72
73#[derive(Clone, Debug, PartialEq, Eq)]
74struct AttackGroup {
75    span: SerialTimeSpan,
76    notes: Vec<AttackCandidate>,
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
80struct ChosenBlock {
81    span: SerialTimeSpan,
82    order: SerialReadingOrder,
83    notes: Vec<AttackCandidate>,
84}
85
86#[derive(Clone, Debug, PartialEq, Eq)]
87struct ExtractionState {
88    next_group: usize,
89    blocks: Vec<ChosenBlock>,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
93struct BlockChoice {
94    group_index: usize,
95    order: SerialReadingOrder,
96    event_key: String,
97}
98
99struct ExtractionProblem {
100    groups: Vec<AttackGroup>,
101}
102
103impl SearchProblem for ExtractionProblem {
104    type State = ExtractionState;
105    type Choice = BlockChoice;
106    type Output = RankedSerialHypothesis;
107
108    fn initial_state(&self) -> Self::State {
109        ExtractionState {
110            next_group: 0,
111            blocks: Vec::new(),
112        }
113    }
114
115    fn expand(&self, state: &Self::State, out: &mut Vec<Self::Choice>) {
116        if state.next_group >= self.groups.len() {
117            return;
118        }
119        let group = &self.groups[state.next_group];
120        for order in candidate_orders(group.notes.len()) {
121            let sorted = sorted_candidates(&group.notes, order);
122            out.push(BlockChoice {
123                group_index: state.next_group,
124                order,
125                event_key: stable_event_key(&sorted),
126            });
127        }
128    }
129
130    fn apply(&self, state: &Self::State, choice: &Self::Choice) -> SearchStep<Self::State> {
131        let group = &self.groups[choice.group_index];
132        let mut blocks = state.blocks.clone();
133        blocks.push(ChosenBlock {
134            span: group.span.clone(),
135            order: choice.order,
136            notes: sorted_candidates(&group.notes, choice.order),
137        });
138        SearchStep::Continue(ExtractionState {
139            next_group: state.next_group + 1,
140            blocks,
141        })
142    }
143
144    fn finish(&self, state: &Self::State) -> Option<Self::Output> {
145        (state.next_group == self.groups.len()).then(|| hypothesis_from_blocks(&state.blocks))
146    }
147
148    fn score_state(&self, state: &Self::State) -> i64 {
149        -(state.blocks.len() as i64)
150    }
151
152    fn output_score(&self, output: &Self::Output) -> Option<i64> {
153        Some(
154            output.stable_rank.omissions as i64 * 1_000_000
155                + output.stable_rank.duplicates_before_completion as i64 * 10_000
156                + output.stable_rank.order_errors as i64 * 100
157                + *output.stable_rank.occupied_span.numer(),
158        )
159    }
160}
161
162/// Extracts ranked serial-row hypotheses from a musical score.
163pub fn extract_serial_hypotheses(
164    score: &Score,
165    request: &SerialExtractionRequest,
166    services: &SerialExtractionServices<'_>,
167) -> Result<ExtractionOutcome, SerialExtractionError> {
168    let staff = canonical_staff(score)?;
169    let groups = attack_groups(&staff)?;
170    let source_summary = vec![
171        format!("voices={}", staff.voices.len()),
172        format!("attack-groups={}", groups.len()),
173        format!(
174            "attacks={}",
175            groups.iter().map(|group| group.notes.len()).sum::<usize>()
176        ),
177    ];
178    let interrupt = services
179        .interrupt
180        .map_or(&NeverInterrupt as &dyn SearchInterrupt, |interrupt| {
181            interrupt
182        });
183    let run = solve(
184        &ExtractionProblem { groups },
185        request.search.clone(),
186        interrupt,
187    );
188    let receipt = run.receipt.clone();
189    let ranked = dedupe_and_rank(run, request.label_convention);
190    let evidence = ExtractionEvidence {
191        search: receipt,
192        source_summary,
193    };
194    match evidence.search.status {
195        SearchStatus::Partial => Ok(ExtractionOutcome::BudgetExhausted { ranked, evidence }),
196        SearchStatus::Cancelled | SearchStatus::Infeasible | SearchStatus::Complete => {
197            if ranked.len() <= 1 {
198                let hypothesis = ranked.first().cloned().unwrap_or_else(empty_hypothesis);
199                Ok(ExtractionOutcome::Complete {
200                    hypothesis: Box::new(hypothesis),
201                    ranked,
202                    evidence,
203                })
204            } else {
205                Ok(ExtractionOutcome::Ambiguous { ranked, evidence })
206            }
207        }
208    }
209}
210
211fn canonical_staff(score: &Score) -> Result<Staff, SerialExtractionError> {
212    if let Some(form) = score_form(&score.body) {
213        let report = convert_score(
214            &form,
215            ScoreFormKind::Staff,
216            AmbiguousConversionPolicy::Reject,
217        )
218        .map_err(|error| SerialExtractionError::ScoreConversion(error.to_string()))?;
219        let ScoreForm::Staff(staff) = report.value else {
220            unreachable!("staff conversion must return a staff");
221        };
222        Ok(staff)
223    } else {
224        flattened_staff(score)
225    }
226}
227
228fn attack_groups(staff: &Staff) -> Result<Vec<AttackGroup>, SerialExtractionError> {
229    let duration = staff.duration();
230    if duration == Time::from_integer(0) {
231        return Ok(Vec::new());
232    }
233    let notes = staff
234        .notes()
235        .map(|note| {
236            let midi = note.note.pitch.to_midi().ok_or_else(|| {
237                SerialExtractionError::WindowConstruction(format!(
238                    "non-MIDI pitch in event {}",
239                    note.event_id
240                ))
241            })?;
242            Ok(AttackCandidate {
243                voice_id: note.voice_id.clone(),
244                note_id: note.note_id.clone(),
245                event_id: note.event_id.clone(),
246                midi,
247                onset: note.onset,
248                release: note.end(),
249            })
250        })
251        .collect::<Result<Vec<_>, SerialExtractionError>>()?;
252    let mut boundaries = vec![Time::from_integer(0), duration];
253    for note in &notes {
254        boundaries.push(note.onset);
255        boundaries.push(note.release);
256    }
257    boundaries.sort();
258    boundaries.dedup();
259    Ok(boundaries
260        .windows(2)
261        .filter_map(|pair| {
262            let span = SerialTimeSpan::new(pair[0], pair[1]);
263            let attacks = notes
264                .iter()
265                .filter(|note| note.onset == span.start)
266                .cloned()
267                .collect::<Vec<_>>();
268            (!attacks.is_empty()).then_some(AttackGroup {
269                span,
270                notes: attacks,
271            })
272        })
273        .collect())
274}
275
276fn hypothesis_from_blocks(blocks: &[ChosenBlock]) -> RankedSerialHypothesis {
277    let mut seen = BTreeMap::<u8, usize>::new();
278    let mut row_classes = Vec::new();
279    let mut duplicates_before_completion = 0usize;
280    let mut order_errors = 0usize;
281    let mut observations = Vec::new();
282    let mut all_event_ids = Vec::new();
283    for block in blocks {
284        let mut block_observations = Vec::new();
285        for note in &block.notes {
286            let class = note.midi % 12;
287            let ordinal = if let Some(&ordinal) = seen.get(&class) {
288                if row_classes.len() < 12 {
289                    duplicates_before_completion += 1;
290                } else {
291                    order_errors += 1;
292                }
293                ordinal
294            } else {
295                let ordinal = row_classes.len();
296                seen.insert(class, ordinal);
297                row_classes.push(class);
298                ordinal
299            };
300            block_observations.push(SerialObservation {
301                voice_id: note.voice_id.clone(),
302                note_id: note.note_id.clone(),
303                event_id: note.event_id.clone(),
304                ordinal,
305                span: block.span.clone(),
306            });
307            all_event_ids.push(note.event_id.to_string());
308        }
309        observations.push(SerialObservationBlock {
310            span: block.span.clone(),
311            order: block.order,
312            observations: block_observations,
313        });
314    }
315
316    let omissions = 12usize.saturating_sub(row_classes.len());
317    let row = tone_row_from_classes(&row_classes);
318    let row_report = analyze_row_class(&row);
319    let aliases = alias_evidence(
320        &row_report.aliases,
321        &row,
322        RowLabelConvention::FirstLastPitch,
323    );
324    let start = blocks
325        .first()
326        .map(|block| block.span.start)
327        .unwrap_or_else(|| Time::from_integer(0));
328    let end = blocks
329        .iter()
330        .flat_map(|block| block.notes.iter().map(|note| note.release))
331        .max()
332        .unwrap_or(start);
333    let span = SerialTimeSpan::new(start, end);
334    let stable_key = all_event_ids.join("|");
335    let stable_rank = SerialStableRank {
336        omissions,
337        duplicates_before_completion,
338        order_errors,
339        occupied_span: span.duration(),
340        stable_key,
341    };
342    RankedSerialHypothesis {
343        stable_rank,
344        row,
345        blocks: observations,
346        duplicates_before_completion,
347        order_errors,
348        omissions,
349        span,
350        aliases,
351    }
352}
353
354fn alias_evidence(
355    aliases: &[RowClassAlias],
356    row: &ToneRow,
357    convention: RowLabelConvention,
358) -> Vec<SerialAliasEvidence> {
359    aliases
360        .iter()
361        .copied()
362        .map(|alias| {
363            let label = row.apply(alias.operation).label(convention).to_string();
364            SerialAliasEvidence { alias, label }
365        })
366        .collect()
367}
368
369fn dedupe_and_rank(
370    run: SearchRun<RankedSerialHypothesis>,
371    convention: RowLabelConvention,
372) -> Vec<RankedSerialHypothesis> {
373    let mut by_key = BTreeMap::<String, RankedSerialHypothesis>::new();
374    for mut hypothesis in run.outputs {
375        hypothesis.aliases = alias_evidence(
376            &analyze_row_class(&hypothesis.row).aliases,
377            &hypothesis.row,
378            convention,
379        );
380        let key = format!(
381            "{:?}|{:?}|{:?}",
382            hypothesis.row.classes(),
383            hypothesis.stable_rank,
384            hypothesis
385                .blocks
386                .iter()
387                .map(|block| block.order.as_str())
388                .collect::<Vec<_>>()
389        );
390        by_key.entry(key).or_insert(hypothesis);
391    }
392    let mut ranked = by_key.into_values().collect::<Vec<_>>();
393    ranked.sort_by(|left, right| {
394        left.stable_rank
395            .cmp(&right.stable_rank)
396            .then_with(|| left.aliases.len().cmp(&right.aliases.len()))
397    });
398    ranked
399}
400
401fn candidate_orders(group_len: usize) -> Vec<SerialReadingOrder> {
402    let mut orders = BTreeSet::from([
403        SerialReadingOrder::WindowOrder,
404        SerialReadingOrder::PitchAscending,
405        SerialReadingOrder::PitchDescending,
406        SerialReadingOrder::VoiceAscending,
407        SerialReadingOrder::VoiceDescending,
408    ]);
409    if group_len <= 1 {
410        orders.retain(|order| *order == SerialReadingOrder::WindowOrder);
411    }
412    orders.into_iter().collect()
413}
414
415fn sorted_candidates(notes: &[AttackCandidate], order: SerialReadingOrder) -> Vec<AttackCandidate> {
416    let mut sorted = notes.to_vec();
417    match order {
418        SerialReadingOrder::WindowOrder => {}
419        SerialReadingOrder::PitchAscending => sorted.sort_by(|left, right| {
420            left.midi
421                .cmp(&right.midi)
422                .then_with(|| left.voice_id.cmp(&right.voice_id))
423                .then_with(|| left.event_id.cmp(&right.event_id))
424        }),
425        SerialReadingOrder::PitchDescending => sorted.sort_by(|left, right| {
426            right
427                .midi
428                .cmp(&left.midi)
429                .then_with(|| left.voice_id.cmp(&right.voice_id))
430                .then_with(|| left.event_id.cmp(&right.event_id))
431        }),
432        SerialReadingOrder::VoiceAscending => sorted.sort_by(|left, right| {
433            left.voice_id
434                .cmp(&right.voice_id)
435                .then_with(|| left.midi.cmp(&right.midi))
436                .then_with(|| left.event_id.cmp(&right.event_id))
437        }),
438        SerialReadingOrder::VoiceDescending => sorted.sort_by(|left, right| {
439            right
440                .voice_id
441                .cmp(&left.voice_id)
442                .then_with(|| left.midi.cmp(&right.midi))
443                .then_with(|| left.event_id.cmp(&right.event_id))
444        }),
445    }
446    sorted
447}
448
449fn stable_event_key(notes: &[AttackCandidate]) -> String {
450    notes
451        .iter()
452        .map(|note| note.event_id.to_string())
453        .collect::<Vec<_>>()
454        .join("|")
455}
456
457fn tone_row_from_classes(classes: &[u8]) -> ToneRow {
458    use sim_lib_music_core::PitchClass;
459
460    let mut ordered = classes
461        .iter()
462        .copied()
463        .map(|value| PitchClass::new(value).expect("pitch class"))
464        .collect::<Vec<_>>();
465    for value in 0..12u8 {
466        if !classes.contains(&value) {
467            ordered.push(PitchClass::new(value).expect("pitch class"));
468        }
469    }
470    let classes = std::array::from_fn(|index| ordered[index]);
471    ToneRow::try_from_classes(classes).expect("padded class order is exhaustive")
472}
473
474fn empty_hypothesis() -> RankedSerialHypothesis {
475    let row = tone_row_from_classes(&[]);
476    RankedSerialHypothesis {
477        stable_rank: SerialStableRank {
478            omissions: 12,
479            duplicates_before_completion: 0,
480            order_errors: 0,
481            occupied_span: Time::from_integer(0),
482            stable_key: "empty".to_owned(),
483        },
484        row,
485        blocks: Vec::new(),
486        duplicates_before_completion: 0,
487        order_errors: 0,
488        omissions: 12,
489        span: SerialTimeSpan::new(Time::from_integer(0), Time::from_integer(0)),
490        aliases: Vec::new(),
491    }
492}
493
494fn score_form(music: &Music) -> Option<ScoreForm> {
495    match music {
496        Music::Chord(value) => Some(ScoreForm::Chord(value.clone())),
497        Music::Melody(value) => Some(ScoreForm::Melody(value.clone())),
498        Music::Progression(value) => Some(ScoreForm::Progression(value.clone())),
499        Music::Counterpoint(value) => Some(ScoreForm::Counterpoint(value.clone())),
500        Music::PianoRoll(value) => Some(ScoreForm::PianoRoll(value.clone())),
501        Music::Note(_)
502        | Music::Rest(_)
503        | Music::Par(_)
504        | Music::Seq(_)
505        | Music::Arranger(_)
506        | Music::MidiTrack(_)
507        | Music::MidiFile(_) => None,
508    }
509}
510
511fn flattened_staff(score: &Score) -> Result<Staff, SerialExtractionError> {
512    let duration = score.body.duration();
513    let mut atoms = Vec::new();
514    score.body.voices(Time::from_integer(0), &mut atoms);
515    let mut voices = BTreeMap::<u8, StaffVoice>::new();
516    for (index, atom) in atoms.into_iter().enumerate() {
517        let AtomRef::Note(note) = atom.atom else {
518            continue;
519        };
520        push_derived_note(&mut voices, duration, index, atom.onset, note)?;
521    }
522    if voices.is_empty() {
523        let voice_id = object_id("score/voice/silence")?;
524        voices.insert(
525            0,
526            StaffVoice {
527                id: voice_id,
528                name: "Silence".to_owned(),
529                duration,
530                notes: Vec::new(),
531            },
532        );
533    }
534    Staff::new(voices.into_values().collect())
535        .map_err(|error| SerialExtractionError::ScoreConversion(error.to_string()))
536}
537
538fn push_derived_note(
539    voices: &mut BTreeMap<u8, StaffVoice>,
540    duration: Time,
541    index: usize,
542    onset: Time,
543    note: Note,
544) -> Result<(), SerialExtractionError> {
545    let channel = note.channel.0;
546    let voice_id = object_id(format!("score/voice/channel-{channel}"))?;
547    let entry = voices.entry(channel).or_insert_with(|| StaffVoice {
548        id: voice_id.clone(),
549        name: format!("Derived channel {channel}"),
550        duration,
551        notes: Vec::new(),
552    });
553    entry.notes.push(StaffNote {
554        voice_id,
555        note_id: object_id(format!("score/note/{index}"))?,
556        event_id: object_id(format!("score/event/{index}"))?,
557        onset,
558        note,
559    });
560    Ok(())
561}
562
563fn object_id(value: impl Into<String>) -> Result<ObjectId, SerialExtractionError> {
564    ObjectId::new(value).map_err(|error| SerialExtractionError::Identity(error.to_string()))
565}