Skip to main content

sim_lib_music_serial/
canon.rs

1//! Canon construction with explicit symmetry and realization metadata.
2
3use std::collections::BTreeMap;
4
5use sim_lib_music_core::{Articulation, Channel, Time};
6use sim_lib_pitch_serial::RowForm;
7use thiserror::Error;
8
9use crate::{
10    EventPlacement, PlannedSerialEvent, RowInstanceId, SerialEventId, SerialOrigin, SerialPlan,
11    SerialRole, StrictEventSpec, StructuralLicense, VoiceId,
12};
13
14/// Symmetry policy required of one canon.
15#[derive(Copy, Clone, Debug, PartialEq, Eq)]
16pub enum CanonSymmetryRequirement {
17    /// No additional symmetry check.
18    None,
19    /// Later voices must present the retrograde of the first voice's row classes.
20    RetrogradeAnswer,
21    /// Voice onsets must mirror around the outer edges of the canon.
22    PalindromicVoiceOffsets,
23}
24
25/// Voice-level realization parameters that do not create new row events.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct CanonOrchestration {
28    /// MIDI channel for the realized voice.
29    pub channel: Channel,
30    /// Articulation for the realized voice.
31    pub articulation: Articulation,
32    /// Optional timbral label retained outside the row plan.
33    pub timbre: Option<String>,
34    /// Optional orchestration role retained outside the row plan.
35    pub orchestration: Option<String>,
36}
37
38/// One canonical voice specification.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct CanonVoiceSpec {
41    /// Row instance identity for this voice.
42    pub row_id: RowInstanceId,
43    /// Row form presented by this voice.
44    pub form: RowForm,
45    /// Stable voice identity.
46    pub voice: VoiceId,
47    /// Voice offset relative to the canon onset.
48    pub voice_offset: Time,
49    /// MIDI-style octave register.
50    pub register: i8,
51    /// Duration per row ordinal.
52    pub duration: Time,
53    /// Realization parameters retained outside the structural plan.
54    pub orchestration: CanonOrchestration,
55}
56
57/// One planned canon request.
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct CanonSpec {
60    /// Stable event-id prefix.
61    pub event_prefix: String,
62    /// Absolute canon onset.
63    pub onset: Time,
64    /// Structural rationale shared by all voices.
65    pub rationale: String,
66    /// Structural reading that licenses the canon.
67    pub license: StructuralLicense,
68    /// Required symmetry.
69    pub requirement: CanonSymmetryRequirement,
70    /// Voices participating in the canon.
71    pub voices: Vec<CanonVoiceSpec>,
72}
73
74/// One realized canon event specification.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct CanonRealizationEvent {
77    /// Planned event identity.
78    pub event_id: SerialEventId,
79    /// Exact onset assigned by the canon builder.
80    pub onset: Time,
81    /// Realization spec excluding onset.
82    pub spec: StrictEventSpec,
83}
84
85/// One canon voice profile kept outside the row-event graph.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct CanonVoiceProfile {
88    /// Stable voice identity.
89    pub voice: VoiceId,
90    /// Row form assigned to the voice.
91    pub form: RowForm,
92    /// Voice offset relative to the canon onset.
93    pub voice_offset: Time,
94    /// Realization-only orchestration parameters.
95    pub orchestration: CanonOrchestration,
96}
97
98/// Symmetry evidence attached to one built canon.
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct CanonSymmetryCertificate {
101    /// Requirement that was evaluated.
102    pub requirement: CanonSymmetryRequirement,
103    /// Whether the requirement held.
104    pub satisfied: bool,
105    /// Human-readable explanation of the result.
106    pub explanation: String,
107}
108
109/// Complete canon output with immutable row events plus realization metadata.
110#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct CanonDeployment {
112    /// Structural plan containing only row events.
113    pub plan: SerialPlan,
114    /// Event-level onset/register/duration realization parameters.
115    pub realization: Vec<CanonRealizationEvent>,
116    /// Voice-level timbre and orchestration metadata.
117    pub voices: Vec<CanonVoiceProfile>,
118    /// Symmetry evidence for the canon.
119    pub symmetry: CanonSymmetryCertificate,
120}
121
122/// Failure while constructing one canon.
123#[derive(Clone, Debug, PartialEq, Eq, Error)]
124pub enum CanonError {
125    /// The request omitted every voice.
126    #[error("canon requires at least one voice")]
127    EmptyVoices,
128    /// One voice named a non-positive duration.
129    #[error("canon voice {0} must use a strictly positive duration")]
130    NonPositiveDuration(VoiceId),
131    /// The requested symmetry requirement was not satisfied.
132    #[error("{0}")]
133    Symmetry(String),
134    /// Building the immutable serial plan failed.
135    #[error("canon plan failed: {0}")]
136    Plan(String),
137}
138
139/// Builds one canon with explicit symmetry and realization metadata.
140pub fn build_canon(spec: CanonSpec) -> Result<CanonDeployment, CanonError> {
141    if spec.voices.is_empty() {
142        return Err(CanonError::EmptyVoices);
143    }
144    for voice in &spec.voices {
145        if voice.duration <= Time::from_integer(0) {
146            return Err(CanonError::NonPositiveDuration(voice.voice.clone()));
147        }
148    }
149
150    let symmetry = validate_symmetry(&spec)?;
151    if !symmetry.satisfied {
152        return Err(CanonError::Symmetry(symmetry.explanation.clone()));
153    }
154
155    let mut rows = BTreeMap::new();
156    let mut events = BTreeMap::new();
157    let mut precedence = Vec::new();
158    let mut realization = Vec::new();
159    let mut voices = Vec::with_capacity(spec.voices.len());
160
161    for voice_spec in &spec.voices {
162        rows.insert(voice_spec.row_id.clone(), voice_spec.form.clone());
163        voices.push(CanonVoiceProfile {
164            voice: voice_spec.voice.clone(),
165            form: voice_spec.form.clone(),
166            voice_offset: voice_spec.voice_offset,
167            orchestration: voice_spec.orchestration.clone(),
168        });
169        let mut previous = None::<SerialEventId>;
170        for ordinal in 0..12usize {
171            let event_id = SerialEventId::new(format!("{}/{}", spec.event_prefix, events.len()))
172                .map_err(|error| CanonError::Plan(error.to_string()))?;
173            let event = PlannedSerialEvent {
174                id: event_id.clone(),
175                ordinals: vec![crate::OrdinalRef::new(voice_spec.row_id.clone(), ordinal)],
176                role: SerialRole::Structural,
177                origin: SerialOrigin::Structural {
178                    rationale: spec.rationale.clone(),
179                },
180                voice: voice_spec.voice.clone(),
181                placement: EventPlacement::independent(),
182                parents: Vec::new(),
183                licenses: vec![spec.license.clone()],
184            };
185            events.insert(event_id.clone(), event);
186            if let Some(previous_id) = previous.as_ref() {
187                precedence.push((previous_id.clone(), event_id.clone()));
188            }
189            previous = Some(event_id.clone());
190            realization.push(CanonRealizationEvent {
191                event_id,
192                onset: spec.onset
193                    + voice_spec.voice_offset
194                    + (voice_spec.duration * i64::try_from(ordinal).expect("ordinal fits i64")),
195                spec: StrictEventSpec::notes(
196                    voice_spec.register,
197                    voice_spec.duration,
198                    88,
199                    voice_spec.orchestration.channel,
200                    voice_spec.orchestration.articulation,
201                ),
202            });
203        }
204    }
205
206    let plan = SerialPlan::try_new(rows, events, precedence)
207        .map_err(|error| CanonError::Plan(error.to_string()))?;
208    Ok(CanonDeployment {
209        plan,
210        realization,
211        voices,
212        symmetry,
213    })
214}
215
216fn validate_symmetry(spec: &CanonSpec) -> Result<CanonSymmetryCertificate, CanonError> {
217    let certificate = match spec.requirement {
218        CanonSymmetryRequirement::None => CanonSymmetryCertificate {
219            requirement: CanonSymmetryRequirement::None,
220            satisfied: true,
221            explanation: "no symmetry requirement requested".to_owned(),
222        },
223        CanonSymmetryRequirement::RetrogradeAnswer => {
224            let Some(subject) = spec.voices.first() else {
225                return Err(CanonError::EmptyVoices);
226            };
227            let satisfied = spec.voices.iter().skip(1).all(|voice| {
228                voice.form.classes().iter().copied().eq(subject
229                    .form
230                    .classes()
231                    .iter()
232                    .rev()
233                    .copied())
234            });
235            CanonSymmetryCertificate {
236                requirement: CanonSymmetryRequirement::RetrogradeAnswer,
237                satisfied,
238                explanation: if satisfied {
239                    "every answer voice preserves the first voice as an exact retrograde".to_owned()
240                } else {
241                    "retrograde-answer requirement failed: at least one answer voice is not the subject retrograde".to_owned()
242                },
243            }
244        }
245        CanonSymmetryRequirement::PalindromicVoiceOffsets => {
246            let offsets = spec
247                .voices
248                .iter()
249                .map(|voice| voice.voice_offset)
250                .collect::<Vec<_>>();
251            let satisfied = offsets.iter().eq(offsets.iter().rev());
252            CanonSymmetryCertificate {
253                requirement: CanonSymmetryRequirement::PalindromicVoiceOffsets,
254                satisfied,
255                explanation: if satisfied {
256                    "voice offsets form a palindrome".to_owned()
257                } else {
258                    "palindromic-voice-offset requirement failed".to_owned()
259                },
260            }
261        }
262    };
263    Ok(certificate)
264}