Skip to main content

acorde_core/model/
structural_plan.rs

1//! Serializable, non-mutating previews for structural score transformations.
2
3use super::commands::{Command, apply_command, command_key};
4use super::duration::Duration;
5use super::score::{NoteAddr, Score};
6use super::validate::validate;
7use crate::Error;
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, BTreeSet};
10
11/// Version of the structural change-plan JSON contract.
12pub const STRUCTURAL_CHANGE_PLAN_CONTRACT_VERSION: u32 = 1;
13
14/// The reason a structural command cannot safely be applied.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum StructuralChangeDiagnosticKind {
18    Conflict,
19    Unsupported,
20    Invalid,
21    ValidationFailed,
22}
23
24/// A machine-readable pre-mutation diagnostic.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct StructuralChangeDiagnostic {
27    pub kind: StructuralChangeDiagnosticKind,
28    pub message: String,
29}
30
31/// The resulting editable voice structure for one physical measure.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct StructuralMeasureStructure {
34    pub part: usize,
35    pub staff: usize,
36    pub measure: usize,
37    pub exists: bool,
38    pub voice_note_counts: [usize; 4],
39    pub source_voice_numbers: [Option<u32>; 4],
40}
41
42/// A side-effect-free preview of a structural command.
43///
44/// When `can_apply` is true, `affected_addresses` includes both old and new
45/// note addresses for moved notes and `resulting_measures` records every lane
46/// whose voice structure changes.  A false plan never exposes a partial score:
47/// it contains diagnostics only.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct StructuralChangePlan {
50    pub contract_version: u32,
51    pub command_key: String,
52    pub can_apply: bool,
53    pub affected_addresses: Vec<NoteAddr>,
54    pub resulting_measures: Vec<StructuralMeasureStructure>,
55    pub diagnostics: Vec<StructuralChangeDiagnostic>,
56}
57
58fn is_structural_command(command: &Command) -> bool {
59    matches!(
60        command,
61        Command::ExchangeVoices(_)
62            | Command::MoveOrCopyVoiceRange(_)
63            | Command::SplitMeasure(_)
64            | Command::JoinMeasures(_)
65            | Command::ImplodeStaves(_)
66            | Command::ExplodeVoices(_)
67            | Command::ExplodeChordPitches(_)
68            | Command::ScaleVoiceRange(_)
69    )
70}
71
72fn diagnostic_kind(message: &str) -> StructuralChangeDiagnosticKind {
73    if message.contains("not yet supported") || message.contains("does not support") {
74        StructuralChangeDiagnosticKind::Unsupported
75    } else if message.contains("discard")
76        || message.contains("destination")
77        || message.contains("onto itself")
78        || message.contains("conflict")
79    {
80        StructuralChangeDiagnosticKind::Conflict
81    } else {
82        StructuralChangeDiagnosticKind::Invalid
83    }
84}
85
86#[derive(Clone, PartialEq, Eq)]
87struct NoteRecord {
88    address: NoteAddr,
89    duration: Duration,
90    dot_count: u8,
91}
92
93fn note_records(score: &Score) -> BTreeMap<String, Vec<NoteRecord>> {
94    let mut addresses = BTreeMap::new();
95    for (part, part_data) in score.parts.iter().enumerate() {
96        for (staff, staff_data) in part_data.staves.iter().enumerate() {
97            for (measure, measure_data) in staff_data.measures.iter().enumerate() {
98                for (voice, notes) in measure_data.voices.iter().enumerate() {
99                    for (note, note_data) in notes.iter().enumerate() {
100                        addresses
101                            .entry(note_data.id.clone())
102                            .or_insert_with(Vec::new)
103                            .push(NoteRecord {
104                                address: NoteAddr {
105                                    part,
106                                    staff,
107                                    measure,
108                                    voice,
109                                    note,
110                                },
111                                duration: note_data.duration.clone(),
112                                dot_count: note_data.dot_count,
113                            });
114                    }
115                }
116            }
117        }
118    }
119    addresses
120}
121
122fn sorted_unique_addresses(mut addresses: Vec<NoteAddr>) -> Vec<NoteAddr> {
123    addresses.sort_by_key(|address| {
124        (
125            address.part,
126            address.staff,
127            address.measure,
128            address.voice,
129            address.note,
130        )
131    });
132    addresses.dedup();
133    addresses
134}
135
136fn affected_addresses(before: &Score, after: &Score) -> Vec<NoteAddr> {
137    let before = note_records(before);
138    let after = note_records(after);
139    let ids: BTreeSet<_> = before.keys().chain(after.keys()).cloned().collect();
140    let mut affected = Vec::new();
141    for id in ids {
142        let old = before.get(&id);
143        let new = after.get(&id);
144        if old != new {
145            affected.extend(
146                old.into_iter()
147                    .flatten()
148                    .map(|record| record.address.clone()),
149            );
150            affected.extend(
151                new.into_iter()
152                    .flatten()
153                    .map(|record| record.address.clone()),
154            );
155        }
156    }
157    sorted_unique_addresses(affected)
158}
159
160fn measure_structure(
161    score: &Score,
162    part: usize,
163    staff: usize,
164    measure: usize,
165) -> StructuralMeasureStructure {
166    let current = score
167        .parts
168        .get(part)
169        .and_then(|part_data| part_data.staves.get(staff))
170        .and_then(|staff_data| staff_data.measures.get(measure));
171    match current {
172        Some(current) => StructuralMeasureStructure {
173            part,
174            staff,
175            measure,
176            exists: true,
177            voice_note_counts: std::array::from_fn(|voice| current.voices[voice].len()),
178            source_voice_numbers: current.source_voice_numbers,
179        },
180        None => StructuralMeasureStructure {
181            part,
182            staff,
183            measure,
184            exists: false,
185            voice_note_counts: [0; 4],
186            source_voice_numbers: [None; 4],
187        },
188    }
189}
190
191fn resulting_measures(before: &Score, after: &Score) -> Vec<StructuralMeasureStructure> {
192    let mut result = Vec::new();
193    let part_count = before.parts.len().max(after.parts.len());
194    for part in 0..part_count {
195        let staff_count = before
196            .parts
197            .get(part)
198            .map(|part_data| part_data.staves.len())
199            .unwrap_or(0)
200            .max(
201                after
202                    .parts
203                    .get(part)
204                    .map(|part_data| part_data.staves.len())
205                    .unwrap_or(0),
206            );
207        for staff in 0..staff_count {
208            let measure_count = before
209                .parts
210                .get(part)
211                .and_then(|part_data| part_data.staves.get(staff))
212                .map(|staff_data| staff_data.measures.len())
213                .unwrap_or(0)
214                .max(
215                    after
216                        .parts
217                        .get(part)
218                        .and_then(|part_data| part_data.staves.get(staff))
219                        .map(|staff_data| staff_data.measures.len())
220                        .unwrap_or(0),
221                );
222            for measure in 0..measure_count {
223                let old = measure_structure(before, part, staff, measure);
224                let new = measure_structure(after, part, staff, measure);
225                if old != new {
226                    result.push(new);
227                }
228            }
229        }
230    }
231    result
232}
233
234/// Preview a structural command without changing `score` or any command history.
235///
236/// Only commands that rearrange voices, ranges, or measures are accepted.  A
237/// malformed structural command yields a serializable unsuccessful plan rather
238/// than a partially-applied candidate score.
239pub fn plan_structural_change(
240    score: &Score,
241    command: &Command,
242) -> Result<StructuralChangePlan, Error> {
243    if !is_structural_command(command) {
244        return Err(Error::InvalidCommand(
245            "structural change plans require a structural command".into(),
246        ));
247    }
248    let command_key = command_key(command);
249    let initial_validation = validate(score);
250    if !initial_validation.is_valid() {
251        return Ok(StructuralChangePlan {
252            contract_version: STRUCTURAL_CHANGE_PLAN_CONTRACT_VERSION,
253            command_key,
254            can_apply: false,
255            affected_addresses: Vec::new(),
256            resulting_measures: Vec::new(),
257            diagnostics: vec![StructuralChangeDiagnostic {
258                kind: StructuralChangeDiagnosticKind::ValidationFailed,
259                message: format!(
260                    "source score failed structural validation with {} error(s)",
261                    initial_validation.errors.len()
262                ),
263            }],
264        });
265    }
266    let mut candidate = score.clone();
267    if let Err(error) = apply_command(command, &mut candidate) {
268        let message = error.to_string();
269        return Ok(StructuralChangePlan {
270            contract_version: STRUCTURAL_CHANGE_PLAN_CONTRACT_VERSION,
271            command_key,
272            can_apply: false,
273            affected_addresses: Vec::new(),
274            resulting_measures: Vec::new(),
275            diagnostics: vec![StructuralChangeDiagnostic {
276                kind: diagnostic_kind(&message),
277                message,
278            }],
279        });
280    }
281    let validation = validate(&candidate);
282    if !validation.is_valid() {
283        return Ok(StructuralChangePlan {
284            contract_version: STRUCTURAL_CHANGE_PLAN_CONTRACT_VERSION,
285            command_key,
286            can_apply: false,
287            affected_addresses: Vec::new(),
288            resulting_measures: Vec::new(),
289            diagnostics: vec![StructuralChangeDiagnostic {
290                kind: StructuralChangeDiagnosticKind::ValidationFailed,
291                message: format!(
292                    "result would fail structural validation with {} error(s)",
293                    validation.errors.len()
294                ),
295            }],
296        });
297    }
298    Ok(StructuralChangePlan {
299        contract_version: STRUCTURAL_CHANGE_PLAN_CONTRACT_VERSION,
300        command_key,
301        can_apply: true,
302        affected_addresses: affected_addresses(score, &candidate),
303        resulting_measures: resulting_measures(score, &candidate),
304        diagnostics: Vec::new(),
305    })
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::{
312        Duration, DurationScale, ExplodeChordPitchesCmd, ExplodeVoicesCmd, ImplodeStavesCmd, Note,
313        Pitch, ScaleVoiceRangeCmd, ScoreTemplate, Step, TupletInfo, TupletScalePolicy,
314    };
315
316    fn piano_score() -> Score {
317        let mut score = Score::template(ScoreTemplate::Piano);
318        score.parts[0].staves[0].measures[0].voices[0] =
319            vec![Note::new(Pitch::new(Step::C, 5), Duration::Whole)];
320        score.parts[0].staves[1].measures[0].voices[0] =
321            vec![Note::new(Pitch::new(Step::E, 3), Duration::Whole)];
322        score
323    }
324
325    #[test]
326    fn implode_plan_is_non_mutating_and_describes_resulting_voices() {
327        let score = piano_score();
328        let before = serde_json::to_value(&score).unwrap();
329        let plan = plan_structural_change(
330            &score,
331            &Command::ImplodeStaves(ImplodeStavesCmd {
332                part_index: 0,
333                source_staves: vec![0, 1],
334                target_staff: 0,
335                start_measure: 0,
336                end_measure: 0,
337            }),
338        )
339        .unwrap();
340        assert!(plan.can_apply);
341        assert_eq!(plan.command_key, "ImplodeStaves");
342        assert!(plan.affected_addresses.iter().any(|address| {
343            address.part == 0
344                && address.staff == 1
345                && address.measure == 0
346                && address.voice == 0
347                && address.note == 0
348        }));
349        assert!(plan.affected_addresses.iter().any(|address| {
350            address.part == 0
351                && address.staff == 0
352                && address.measure == 0
353                && address.voice == 1
354                && address.note == 0
355        }));
356        assert!(plan.resulting_measures.iter().any(|measure| {
357            measure.part == 0
358                && measure.staff == 0
359                && measure.measure == 0
360                && measure.voice_note_counts == [1, 1, 0, 0]
361        }));
362        assert_eq!(serde_json::to_value(&score).unwrap(), before);
363    }
364
365    #[test]
366    fn explode_plan_reports_destination_conflict_without_mutation() {
367        let mut score = piano_score();
368        score.parts[0].staves[0].measures[0].voices[1] =
369            vec![Note::new(Pitch::new(Step::G, 4), Duration::Whole)];
370        let before = serde_json::to_value(&score).unwrap();
371        let plan = plan_structural_change(
372            &score,
373            &Command::ExplodeVoices(ExplodeVoicesCmd {
374                part_index: 0,
375                source_staff: 0,
376                target_staves: vec![0, 1],
377                start_measure: 0,
378                end_measure: 0,
379            }),
380        )
381        .unwrap();
382        assert!(!plan.can_apply);
383        assert_eq!(
384            plan.diagnostics[0].kind,
385            StructuralChangeDiagnosticKind::Conflict
386        );
387        assert_eq!(serde_json::to_value(&score).unwrap(), before);
388    }
389
390    #[test]
391    fn scale_plan_supports_tuplets_with_preserved_ratio() {
392        let mut score = piano_score();
393        score.parts[0].staves[0].measures[0].voices[0][0].tuplet = Some(TupletInfo {
394            actual_notes: 3,
395            normal_notes: 2,
396        });
397        let before = serde_json::to_value(&score).unwrap();
398        let plan = plan_structural_change(
399            &score,
400            &Command::ScaleVoiceRange(ScaleVoiceRangeCmd {
401                part_index: 0,
402                staff_index: 0,
403                voice: 0,
404                start_measure: 0,
405                end_measure: 0,
406                scale: DurationScale::Half,
407                tuplet_policy: TupletScalePolicy::PreserveRatio,
408            }),
409        )
410        .unwrap();
411        assert!(plan.can_apply);
412        assert!(plan.diagnostics.is_empty());
413        assert_eq!(serde_json::to_value(&score).unwrap(), before);
414    }
415
416    #[test]
417    fn scale_plan_marks_duration_changes_at_stable_addresses() {
418        let score = piano_score();
419        let plan = plan_structural_change(
420            &score,
421            &Command::ScaleVoiceRange(ScaleVoiceRangeCmd {
422                part_index: 0,
423                staff_index: 0,
424                voice: 0,
425                start_measure: 0,
426                end_measure: 0,
427                scale: DurationScale::Half,
428                tuplet_policy: TupletScalePolicy::PreserveRatio,
429            }),
430        )
431        .unwrap();
432        assert!(plan.can_apply);
433        assert!(plan.affected_addresses.iter().any(|address| {
434            address.part == 0
435                && address.staff == 0
436                && address.measure == 0
437                && address.voice == 0
438                && address.note == 0
439        }));
440        assert!(plan.resulting_measures.iter().any(|measure| {
441            measure.part == 0
442                && measure.staff == 0
443                && measure.measure == 0
444                && measure.voice_note_counts == [2, 0, 0, 0]
445        }));
446    }
447
448    #[test]
449    fn chord_explode_plan_is_available_before_mutation() {
450        let mut score = piano_score();
451        score.parts[0].staves[0].measures[0].voices[0][0]
452            .pitches
453            .push(Pitch::new(Step::E, 4));
454        score.parts[0].staves[1].measures[0].voices[0] = vec![Note::rest(Duration::Whole)];
455        let plan = plan_structural_change(
456            &score,
457            &Command::ExplodeChordPitches(ExplodeChordPitchesCmd {
458                part_index: 0,
459                source_staff: 0,
460                target_staves: vec![0, 1],
461                start_measure: 0,
462                end_measure: 0,
463            }),
464        )
465        .unwrap();
466        assert!(plan.can_apply);
467        assert!(plan.affected_addresses.iter().any(|address| {
468            address.part == 0
469                && address.staff == 1
470                && address.measure == 0
471                && address.voice == 0
472                && address.note == 0
473        }));
474    }
475}