Skip to main content

sim_lib_music_serial/
spine.rs

1//! Inspectable serial-spine adaptation reports.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use sim_lib_music_core::{Pitch, Time};
6use sim_lib_pitch_dissonance::ContextualSonanceReport;
7use sim_lib_pitch_scale::PlayerScale;
8
9use crate::pitch_map::MapWitness;
10use crate::{OrdinalRef, RealizerId, SerialEventId};
11
12/// Stable adaptation family used by a modal-spine realizer.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub enum SerialSpineKind {
15    /// Degree labels derived from a landed modal scale.
16    DegreeCycle,
17    /// Direct landed modal pitch identity.
18    NearestScaleTone,
19    /// Degree labels plus explicit chromatic-inflection deltas.
20    MarkedChromaticInflection,
21    /// A non-pitch spine label carried alongside landed pitch adaptation.
22    NonPitchSpine,
23}
24
25/// One reported spine label, independent from the sounding pitch.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum SerialSpineLabel {
28    /// One-based modal scale degree.
29    Degree(usize),
30    /// Landed pitch identity.
31    LandedPitch(Pitch),
32    /// Degree plus chromatic displacement from the source pitch class.
33    ChromaticInflection {
34        /// Landed modal degree.
35        degree: usize,
36        /// Signed semitone shift between source and landed classes.
37        semitone_delta: i16,
38    },
39    /// Non-pitch serial token that still follows the landed pitch map.
40    OrdinalToken {
41        /// Stable ordinal source.
42        ordinal: OrdinalRef,
43        /// Zero-based ordinal occurrence inside the event.
44        note_index: usize,
45    },
46}
47
48/// One mapped sounding note in a serial-spine adaptation.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct SerialSpineEntry {
51    /// Stable source event identity.
52    pub event_id: SerialEventId,
53    /// Stable ordinal source for this sounding note.
54    pub ordinal: OrdinalRef,
55    /// Stable ordinal occurrence inside the event's note list.
56    pub note_index: usize,
57    /// Exact onset retained from the source realization.
58    pub onset: Time,
59    /// Source pitch before adaptation.
60    pub source_pitch: Pitch,
61    /// Landed pitch after adaptation.
62    pub landed_pitch: Pitch,
63    /// One-based modal degree, when the landed pitch belongs to the scale.
64    pub modal_degree: Option<usize>,
65    /// Whether the landed pitch is a scale member.
66    pub modal_member: bool,
67    /// Explicit pitch-map witness for the landing step.
68    pub witness: MapWitness,
69    /// Independent spine label for this note.
70    pub label: SerialSpineLabel,
71}
72
73/// Collision where multiple source classes land on the same target class.
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct SerialSpineCollision {
76    /// Landed pitch class value.
77    pub landed_class: u8,
78    /// Distinct source pitch classes that collided.
79    pub source_classes: Vec<u8>,
80}
81
82/// Repeated modal degree observed in canonical note order.
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct SerialRepeatedDegree {
85    /// Degree that repeated.
86    pub degree: usize,
87    /// Events that carried the repetition.
88    pub events: Vec<SerialEventId>,
89}
90
91/// Aggregate comparison between source and landed pitch-class collections.
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct ChromaticAggregateIdentity {
94    /// Source pitch classes heard before adaptation.
95    pub source_classes: Vec<u8>,
96    /// Landed pitch classes heard after adaptation.
97    pub landed_classes: Vec<u8>,
98    /// Source classes missing after landing.
99    pub lost_source_classes: Vec<u8>,
100    /// Whether the landed result preserved the chromatic aggregate exactly.
101    pub preserved: bool,
102}
103
104/// Sonance report for one adjacent adapted window.
105#[derive(Clone, Debug, PartialEq)]
106pub struct SerialSonanceContext {
107    /// Stable prior event.
108    pub from_event: SerialEventId,
109    /// Stable next event.
110    pub to_event: SerialEventId,
111    /// Contextual sonance comparison over the landed pitches.
112    pub report: ContextualSonanceReport,
113}
114
115/// Complete inspectable serial-spine report attached to one realization.
116#[derive(Clone, Debug, PartialEq)]
117pub struct SerialSpineReport {
118    /// Realizer that produced the report.
119    pub realizer_id: RealizerId,
120    /// Adaptation family used by the realizer.
121    pub kind: SerialSpineKind,
122    /// Effective modal or custom performance scale.
123    pub scale: PlayerScale,
124    /// One note-level entry in canonical note order.
125    pub entries: Vec<SerialSpineEntry>,
126    /// Collisions recorded while landing the chromatic source.
127    pub collisions: Vec<SerialSpineCollision>,
128    /// Repeated modal degrees in canonical note order.
129    pub repeated_degrees: Vec<SerialRepeatedDegree>,
130    /// Notes that remained outside the scale after landing.
131    pub out_of_mode: Vec<SerialEventId>,
132    /// Notes whose pitch changed under landing.
133    pub pitch_changes: Vec<SerialEventId>,
134    /// Aggregate identity report for source versus landed pitch classes.
135    pub aggregate_identity: ChromaticAggregateIdentity,
136    /// Canonical ordinal order retained by the landed adaptation.
137    pub ordinal_order: Vec<OrdinalRef>,
138    /// Adjacent contextual sonance comparisons over landed windows.
139    pub sonance_context: Vec<SerialSonanceContext>,
140}
141
142impl SerialSpineReport {
143    /// Returns the modal-membership view independently of other report facets.
144    pub fn modal_membership(&self) -> Vec<(SerialEventId, bool, Option<usize>)> {
145        self.entries
146            .iter()
147            .map(|entry| {
148                (
149                    entry.event_id.clone(),
150                    entry.modal_member,
151                    entry.modal_degree,
152                )
153            })
154            .collect()
155    }
156
157    /// Returns the pitch-identity view independently of other report facets.
158    pub fn pitch_identity(&self) -> Vec<(SerialEventId, Pitch, Pitch, MapWitness)> {
159        self.entries
160            .iter()
161            .map(|entry| {
162                (
163                    entry.event_id.clone(),
164                    entry.source_pitch,
165                    entry.landed_pitch,
166                    entry.witness.clone(),
167                )
168            })
169            .collect()
170    }
171
172    /// Returns the aggregate identity report independently of other report facets.
173    pub fn chromatic_aggregate_identity(&self) -> &ChromaticAggregateIdentity {
174        &self.aggregate_identity
175    }
176
177    /// Returns the retained ordinal order independently of other report facets.
178    pub fn ordinal_order(&self) -> &[OrdinalRef] {
179        &self.ordinal_order
180    }
181
182    /// Returns the contextual sonance view independently of other report facets.
183    pub fn sonance_context(&self) -> &[SerialSonanceContext] {
184        &self.sonance_context
185    }
186}
187
188pub(crate) fn collect_collisions(entries: &[SerialSpineEntry]) -> Vec<SerialSpineCollision> {
189    let mut by_target = BTreeMap::<u8, BTreeSet<u8>>::new();
190    for entry in entries {
191        by_target
192            .entry(entry.landed_pitch.class.value())
193            .or_default()
194            .insert(entry.source_pitch.class.value());
195    }
196    by_target
197        .into_iter()
198        .filter_map(|(landed_class, source_classes)| {
199            (source_classes.len() > 1).then(|| SerialSpineCollision {
200                landed_class,
201                source_classes: source_classes.into_iter().collect(),
202            })
203        })
204        .collect()
205}
206
207pub(crate) fn collect_repeated_degrees(entries: &[SerialSpineEntry]) -> Vec<SerialRepeatedDegree> {
208    let mut out = Vec::new();
209    let mut current_degree = None;
210    let mut current_events = Vec::<SerialEventId>::new();
211    for entry in entries {
212        if let Some(degree) = entry.modal_degree {
213            if current_degree == Some(degree) {
214                current_events.push(entry.event_id.clone());
215            } else {
216                if let Some(previous_degree) = current_degree.take()
217                    && current_events.len() > 1
218                {
219                    out.push(SerialRepeatedDegree {
220                        degree: previous_degree,
221                        events: current_events.clone(),
222                    });
223                }
224                current_degree = Some(degree);
225                current_events = vec![entry.event_id.clone()];
226            }
227        }
228    }
229    if let Some(degree) = current_degree
230        && current_events.len() > 1
231    {
232        out.push(SerialRepeatedDegree {
233            degree,
234            events: current_events,
235        });
236    }
237    out
238}
239
240pub(crate) fn aggregate_identity(entries: &[SerialSpineEntry]) -> ChromaticAggregateIdentity {
241    let source = entries
242        .iter()
243        .map(|entry| entry.source_pitch.class.value())
244        .collect::<BTreeSet<_>>();
245    let landed = entries
246        .iter()
247        .map(|entry| entry.landed_pitch.class.value())
248        .collect::<BTreeSet<_>>();
249    let lost_source_classes = source.difference(&landed).copied().collect::<Vec<_>>();
250    ChromaticAggregateIdentity {
251        source_classes: source.iter().copied().collect(),
252        landed_classes: landed.iter().copied().collect(),
253        lost_source_classes: lost_source_classes.clone(),
254        preserved: lost_source_classes.is_empty() && source == landed,
255    }
256}