//! The spelling and notational-decomposition **pre-passes** (Agent H; Chapter 2 //! §"The Spelling Pre-Pass", Chapter 3 §"Sounding Duration and Notational //! Decomposition"). //! //! ## Canonical model: derived annotations, not stored objects //! //! Pre-pass outputs are **canonical derived annotations**: deterministic //! functions of `(materialized score graph, profile, [`SpellingAlgorithmId`], //! [`DecompositionAlgorithmId`])`, recomputed on materialization. They are *not* //! author-minted graph objects with operation envelopes, and they do **not** //! enter the canonical `Score` bytes (there is deliberately no codec for //! [`DerivedAnnotations`]). Three consequences fall out of this choice: //! //! * **Manual overrides layer above generated output via ordinary operations.** //! An authored spelling (the canonical product of a `RespellPitch` operation, //! carried as a [`SpellingAttachment`] whose [`crate::SpellingSource`] outranks //! [`SpellingSourceKind::Inferred`] in the score's [`SpellingPrecedence`]) //! takes precedence over the algorithm's default. The default is *derived*; //! the override is *authored*. H formalizes the precedence rule, not the model //! (see [`resolve_spelling`]). //! * **Algorithm version is part of the derivation key.** The //! [`SpellingAlgorithmId`] / [`DecompositionAlgorithmId`] in [`PrePassProfile`] //! key the derivation; a profile-declared change deterministically //! re-derives. No stored state migrates, because annotations are not stored. //! * **Caching is permitted; canonical identity is not.** Two replicas at the //! same `(graph, profile, algorithm version)` produce byte-identical //! annotations whether or not either cached the result. //! //! The reducer (Chapter 6) does **not** run these algorithms; materialization //! does, after reduction completes, as a pure function over a fully-reduced //! `Score`. [`derive_annotations`] is the entry point a materializer (or F's //! integration harness) calls. use std::cmp::Reverse; use std::collections::{BTreeMap, BTreeSet, VecDeque}; use crate::event::Event; use crate::graph::{ DecompositionAttachment, DecompositionSource, NotatedComponent, NoteValue, RegionTimeModel, Score, TupletRatio, }; use crate::ids::{EventId, PitchId, RegionId, TupletId}; use crate::pitch::{ AccidentalId, CmnNominal, DecompositionAlgorithmId, IdentifiedPitch, Pitch, PitchSpacePosition, PitchSpelling, SpellingAlgorithmId, SpellingAttachment, SpellingDirective, SpellingNominal, SpellingPrecedence, SpellingScope, SpellingSourceKind, }; use crate::time::{EventDuration, EventPosition, MusicalDuration, MusicalPosition, RationalTime}; // =========================================================================== // Profile and result types // =========================================================================== /// The algorithm-version key for a pre-pass run (Chapter 2 §"The Spelling /// Pre-Pass"; Chapter 3). The literal algorithm ids are part of the derivation /// key: two derivations agree byte-for-byte iff their profiles agree. #[derive(Clone, PartialEq, Eq, Debug)] pub struct PrePassProfile { pub spelling_algorithm: SpellingAlgorithmId, pub decomposition_algorithm: DecompositionAlgorithmId, } impl Default for PrePassProfile { fn default() -> Self { PrePassProfile { spelling_algorithm: SpellingAlgorithmId::default_id(), decomposition_algorithm: DecompositionAlgorithmId::default_id(), } } } /// Where a resolved spelling came from: the algorithm's inference, or an /// authored override that outranked it (Chapter 2 §"Configurable Precedence"). #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum SpellingProvenance { /// Produced by the spelling pre-pass algorithm (lowest default precedence). Inferred, /// Selected from an authored [`SpellingAttachment`] of the given source kind, /// which outranks [`SpellingSourceKind::Inferred`] in the score's precedence. Authored(SpellingSourceKind), } /// One pitch's resolved spelling: the effective [`PitchSpelling`] plus its /// provenance (authored override vs. inferred default). #[derive(Clone, PartialEq, Eq, Debug)] pub struct ResolvedSpelling { pub spelling: PitchSpelling, pub provenance: SpellingProvenance, } /// Per-event-kind classification of what the pre-passes produced or deliberately /// skipped, so "ineligible" is **explicit and counted**, never silently absent /// (the acceptance taxonomy of Chapter 2 / PHASE2_QUICKSTART §H). Every embedded /// pitch and every event is accounted for in exactly the buckets that apply. #[derive(Clone, PartialEq, Eq, Debug, Default)] pub struct TaxonomyReport { // --- Event counts by kind. --- pub pitched_events: usize, pub unpitched_events: usize, pub rest_events: usize, pub trajectory_events: usize, pub graphic_events: usize, pub indeterminate_events: usize, pub cue_events: usize, // --- Spelling outcomes (over embedded `IdentifiedPitch`es). --- /// Eligible pitches that received a non-trivial inferred spelling. pub spellings_inferred: usize, /// Eligible pitches whose effective spelling came from an authored override. pub spellings_authored: usize, /// Pitches whose pitch space declares spelling unavailable (non-`cmn-12`- /// determinable positions: JI vectors, non-12 EDOs, registered grammars). pub spelling_unavailable: usize, // --- Decomposition outcomes (over events). --- /// Events that received a decomposition. pub decompositions_inferred: usize, /// Metric-region events whose duration is wall-clock or indeterminate (no /// determinate musical duration to decompose). pub decomposition_skipped_nonmusical: usize, /// Events in proportional or aleatoric regions: decomposition deferred /// (Chapter 3; PHASE2_QUICKSTART §H "proportional and aleatoric regions: /// explicitly deferred"). pub decomposition_deferred_nonmetric: usize, /// Event kinds that never decompose (trajectory / graphic / indeterminate / /// cue), counted so their absence from the decomposition set is explicit. pub decomposition_inapplicable: usize, /// Events whose musical duration is not representable on the notation grid /// (finer than a sixty-fourth, or non-dyadic and not a tuplet member); /// skipped rather than mis-rendered. Expected to be zero for well-formed /// metric input. pub decomposition_ungriddable: usize, } /// The canonical derived annotations for a score under a profile: the effective /// spelling per eligible pitch, the decomposition per eligible event, and the /// eligibility taxonomy with per-kind counts. Recomputed on materialization; /// never serialized into canonical `Score` state. #[derive(Clone, PartialEq, Eq, Debug)] pub struct DerivedAnnotations { pub spellings: BTreeMap, pub decompositions: BTreeMap, pub taxonomy: TaxonomyReport, pub profile: PrePassProfile, } impl DerivedAnnotations { /// A canonical byte fingerprint of the derivation. Deterministic and /// order-independent — the maps are `BTreeMap`s in canonical key order, the /// embedded graph values (`PitchSpelling`, `DecompositionAttachment`, /// `SpellingSourceKind`) use their ratified [`CanonicalValue`] bytes, and /// counts/ids are little-endian, length-framed where variable. Two /// byte-equal fingerprints imply byte-identical derivations. This is the /// normative serialization surface the determinism gate compares, rather /// than the (non-normative) `Debug` form. /// /// [`CanonicalValue`]: crate::CanonicalValue pub fn canonical_fingerprint(&self) -> Vec { use crate::CanonicalValue; let mut out = Vec::new(); let lp = |out: &mut Vec, bytes: &[u8]| { out.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); out.extend_from_slice(bytes); }; out.extend_from_slice(&(self.spellings.len() as u64).to_le_bytes()); for (pid, rs) in &self.spellings { out.extend_from_slice(&pid.canonical_bytes()); lp(&mut out, &rs.spelling.canonical_bytes()); match &rs.provenance { SpellingProvenance::Inferred => out.push(0), SpellingProvenance::Authored(kind) => { out.push(1); lp(&mut out, &kind.canonical_bytes()); } } } out.extend_from_slice(&(self.decompositions.len() as u64).to_le_bytes()); for (eid, dec) in &self.decompositions { out.extend_from_slice(&eid.canonical_bytes()); lp(&mut out, &dec.canonical_bytes()); } let t = &self.taxonomy; for count in [ t.pitched_events, t.unpitched_events, t.rest_events, t.trajectory_events, t.graphic_events, t.indeterminate_events, t.cue_events, t.spellings_inferred, t.spellings_authored, t.spelling_unavailable, t.decompositions_inferred, t.decomposition_skipped_nonmusical, t.decomposition_deferred_nonmetric, t.decomposition_inapplicable, t.decomposition_ungriddable, ] { out.extend_from_slice(&(count as u64).to_le_bytes()); } lp( &mut out, self.profile.spelling_algorithm.as_str().as_bytes(), ); lp( &mut out, self.profile.decomposition_algorithm.as_str().as_bytes(), ); out } } // =========================================================================== // Entry point // =========================================================================== /// Computes the [`DerivedAnnotations`] for `score` under `profile`. Pure and /// deterministic: byte-identical `(score, profile)` yields byte-identical /// output, independent of iteration incidentals (the derivation walks the graph /// in canonical id/position order). pub fn derive_annotations(score: &Score, profile: &PrePassProfile) -> DerivedAnnotations { let mut taxonomy = TaxonomyReport::default(); // Index regions: event -> (is the owning region metric?), and the event's // musical position when it has one. The graph keeps each voice's events // position-sorted (invariant 3), so the per-voice order is canonical. let layout = ScoreLayout::build(score); // The pre-passes implement exactly one algorithm each (the registered // `"default"` id). A profile requesting any other id is **not honored**: // rather than return default output labeled as the requested algorithm — // which would let an unimplemented/future algorithm silently alias the // default in a derivation cache — that pre-pass derives nothing. The // requested id stays in the result's `profile`, so the cache key is honest. let spelling_supported = profile.spelling_algorithm == SpellingAlgorithmId::default_id(); let decomposition_supported = profile.decomposition_algorithm == DecompositionAlgorithmId::default_id(); // --- Spelling pre-pass. --- let mut spellings = BTreeMap::new(); if spelling_supported { let inferred = infer_spellings(score, &layout, &mut taxonomy); let precedence = &score.spelling_precedence; for (pid, inferred_spelling) in inferred { let resolved = resolve_spelling(score, pid, inferred_spelling, precedence); match resolved.provenance { SpellingProvenance::Inferred => taxonomy.spellings_inferred += 1, SpellingProvenance::Authored(_) => taxonomy.spellings_authored += 1, } spellings.insert(pid, resolved); } } // --- Decomposition pre-pass. --- let decompositions = if decomposition_supported { infer_decompositions(score, &layout, &mut taxonomy) } else { BTreeMap::new() }; DerivedAnnotations { spellings, decompositions, taxonomy, profile: profile.clone(), } } // =========================================================================== // Score layout index (event -> region/voice/position membership) // =========================================================================== /// The containment facts the pre-passes need, gathered once: which region owns /// each event (and whether that region is metric), each event's musical /// position, and the per-(region, voice) ordered pitch sequences spelling runs /// over. Tuplet membership is folded in for decomposition's notated domain. struct ScoreLayout { /// Event -> (owning region, region is metric). event_region: BTreeMap, metric_regions: BTreeSet, /// Event -> its musical position, when it has one. event_position: BTreeMap, /// Ordered melodic runs for spelling: `(region, voice) -> events in position /// order`. Keyed in a `BTreeMap` so iteration is canonical. voice_runs: BTreeMap<(RegionId, crate::ids::VoiceId), Vec>, /// Event -> innermost tuplet membership (id + ratio), if any. event_tuplet: BTreeMap, /// Measure duration (whole-note units on the notation grid) governing each /// region, resolved from the region's first determinable time signature. region_measure_units: BTreeMap, } impl ScoreLayout { fn build(score: &Score) -> Self { let mut event_region = BTreeMap::new(); let mut metric_regions = BTreeSet::new(); let mut event_position = BTreeMap::new(); let mut voice_runs: BTreeMap<(RegionId, crate::ids::VoiceId), Vec> = BTreeMap::new(); let mut region_measure_units = BTreeMap::new(); for region in &score.canvas.regions { let is_metric = matches!(region.time_model, RegionTimeModel::Metric(_)); if is_metric { metric_regions.insert(region.id); } region_measure_units.insert(region.id, resolve_measure_units(score, region)); for si in region.staff_instances() { for v in &si.voices { let run = voice_runs.entry((region.id, v.id)).or_default(); for &eid in &v.events { event_region.insert(eid, region.id); run.push(eid); if let Some(ev) = score.events.get(eid) { if let EventPosition::Musical(p) = ev.position() { event_position.insert(eid, p.clone()); } } } } } } // Tuplet membership: innermost wins (a member listed by a child tuplet // shadows its parent). We resolve "innermost" as the tuplet with the // smallest `required_total`; ties keep the lowest id for determinism. An // id→tuplet index makes the "is the incumbent still the smallest?" check an // O(log n) lookup rather than a linear scan of every tuplet per member (a // quadratic blow-up on densely-tupletted input otherwise). let tuplet_by_id: BTreeMap = score .cross_cutting .tuplets .iter() .map(|t| (t.id, t)) .collect(); let mut event_tuplet: BTreeMap = BTreeMap::new(); for t in &score.cross_cutting.tuplets { for &m in &t.members { let replace = match event_tuplet.get(&m) { Some((cur_id, _)) => match tuplet_by_id.get(cur_id).copied() { Some(cur) => { (t.required_total.rational(), t.id) < (cur.required_total.rational(), cur.id) } None => true, }, None => true, }; if replace { event_tuplet.insert(m, (t.id, t.ratio)); } } } ScoreLayout { event_region, metric_regions, event_position, voice_runs, event_tuplet, region_measure_units, } } fn is_metric(&self, event: EventId) -> bool { self.event_region .get(&event) .is_some_and(|r| self.metric_regions.contains(r)) } } // =========================================================================== // Line-of-fifths spelling model (Chapter 2) // =========================================================================== // // A "tonal pitch class" is a point on the *line of fifths*: an integer `lof` // where each +1 step is an ascending perfect fifth. `C = 0`, the naturals run // `F C G D A E B = -1..=5`, sharps continue upward, flats downward. The // chromatic (12-TET) pitch class of `lof` is `(7 * lof) mod 12`. Candidate // spellings of a pitch class are the `lof` values realizing it; the Temperley // preference rule chooses the candidate keeping the music *compact* on the line // of fifths (close to a running centre of gravity), which yields tonally // plausible spellings (sharps in sharp keys, flats in flat keys). /// Lowest / highest line-of-fifths index the algorithm spells with — `Cb` /// (`-7`) up to `B#` (`12`): naturals and single accidentals only. Double /// accidentals are out of the default candidate set (Phase-2 scope; a /// musicologically-perfect double-accidental refinement is a Pass-12 candidate). const LOF_MIN: i32 = -7; const LOF_MAX: i32 = 12; /// Melodic memory (in spelled pitches) of the centre-of-gravity window. ~one /// octave of context; large enough to stabilise a key, small enough to follow a /// modulation. const SPELLING_WINDOW: usize = 9; /// The 12-TET pitch class of a line-of-fifths index. #[inline] fn lof_pitch_class(lof: i32) -> i32 { (7 * lof).rem_euclid(12) } /// The diatonic nominal and chromatic alteration (semitones) a line-of-fifths /// index resolves to. Naturals sit at `lof -1..=5`; each whole step of 7 is one /// alteration. fn lof_to_nominal_alteration(lof: i32) -> (CmnNominal, i32) { let mut nat = lof; let mut alteration = 0; while nat > 5 { nat -= 7; alteration += 1; } while nat < -1 { nat += 7; alteration -= 1; } let nominal = match nat { -1 => CmnNominal::F, 0 => CmnNominal::C, 1 => CmnNominal::G, 2 => CmnNominal::D, 3 => CmnNominal::A, 4 => CmnNominal::E, 5 => CmnNominal::B, _ => unreachable!("nat reduced into -1..=5"), }; (nominal, alteration) } /// The line-of-fifths index of a CMN nominal + alteration (the inverse of /// [`lof_to_nominal_alteration`] on the nominal band). fn cmn_to_lof(nominal: CmnNominal, alteration: i32) -> i32 { let base = match nominal { CmnNominal::F => -1, CmnNominal::C => 0, CmnNominal::G => 1, CmnNominal::D => 2, CmnNominal::A => 3, CmnNominal::E => 4, CmnNominal::B => 5, }; base + 7 * alteration } /// Accidental "complexity" of a spelling: `|alteration|`. Naturals (0) are /// simplest, single accidentals (1) next; used as a tiebreak so a white-key /// pitch class defaults to its natural spelling absent contrary context. #[inline] fn lof_complexity(lof: i32) -> i32 { lof_to_nominal_alteration(lof).1.abs() } /// The candidate line-of-fifths spellings of a pitch class, within the /// single-accidental band `[LOF_MIN, LOF_MAX]`, in ascending order. fn lof_candidates(pitch_class: i32) -> Vec { (LOF_MIN..=LOF_MAX) .filter(|&lof| lof_pitch_class(lof) == pitch_class) .collect() } /// The accidental glyph stack for a chromatic alteration. An empty stack means /// *no glyph is drawn* (a natural with no key signature to cancel), distinct /// from a natural sign (Chapter 2 §"Absent Accidentals"). fn accidental_ids(alteration: i32) -> Vec { // The inferred candidate band ([`LOF_MIN`, `LOF_MAX`]) only ever yields single // accidentals, but the *authored* path preserves any `alteration` an author // wrote (`alteration` is an `i8`), so a triple-sharp can reach here. Emit a // glyph stack whose magnitudes sum to `alteration` — `⌊|a|/2⌋` double-glyphs // plus a single-glyph for the odd remainder — so the spelling reconstructs the // authored pitch exactly rather than being clamped to a double accidental // (which would silently drop a semitone). An alteration of 0 draws no glyph. let (double, single, magnitude) = if alteration >= 0 { ("double-sharp", "sharp", alteration) } else { ("double-flat", "flat", -alteration) }; let mut out = Vec::with_capacity((magnitude as usize).div_ceil(2)); for _ in 0..(magnitude / 2) { out.push(AccidentalId::new(double)); } if magnitude % 2 == 1 { out.push(AccidentalId::new(single)); } out } /// Builds a [`PitchSpelling`] for a chosen line-of-fifths index realizing the /// absolute 12-TET `semitone` (octave included). The spelling octave is solved /// so `nominal.chromatic() + alteration + 12*octave == semitone`, which places /// e.g. `B#` an octave below the `C` it sounds as. fn spelling_from_lof(lof: i32, semitone: i32) -> PitchSpelling { let (nominal, alteration) = lof_to_nominal_alteration(lof); // Exact: (semitone - chromatic - alteration) is divisible by 12 because the // candidate realizes the same pitch class as `semitone`. let octave = (semitone - nominal.chromatic() as i32 - alteration).div_euclid(12); PitchSpelling { nominal: SpellingNominal::Cmn(nominal), accidentals: accidental_ids(alteration), octave: octave as i8, render_hints: Default::default(), } } /// The context-free simplest spelling of a single pitch: its authored CMN /// letter if it has one, else the fewest-accidental enharmonic spelling of its /// 12-TET pitch class. Returns `None` if the pitch space declares spelling /// unavailable. The score-level [`derive_annotations`] is the context-aware /// entry; this is the isolated fallback behind [`crate::spell`]. pub fn simplest_spelling(pitch: &Pitch) -> Option { let (semitone, authored) = spell_entry_for(pitch)?; if let Some((nominal, alteration, octave)) = authored { return Some(PitchSpelling { nominal: SpellingNominal::Cmn(nominal), accidentals: accidental_ids(alteration), octave, render_hints: Default::default(), }); } let pc = semitone.rem_euclid(12); let lof = choose_lof(pc, 0, 0, std::cmp::Ordering::Equal); Some(spelling_from_lof(lof, semitone)) } /// An authored CMN letter the algorithm must preserve: `(nominal, alteration, /// octave)`. type AuthoredCmn = (CmnNominal, i32, i8); /// A pitch's role in a spelling run: its id, its absolute 12-TET semitone, and — /// when its scale position is already CMN — the authored letter the algorithm /// must preserve rather than re-spell. struct SpellEntry { pid: PitchId, semitone: i32, authored_cmn: Option, } /// The absolute 12-TET semitone of a pitch, and its authored CMN letter if it /// has one. Returns `None` when the pitch space declares spelling unavailable /// (the 12-TET class is not determinable from the position alone). fn spell_entry_for(pitch: &Pitch) -> Option<(i32, Option)> { let semitone = pitch.twelve_tet_semitone()?; let authored = match &pitch.scale_position.position { PitchSpacePosition::Cmn { nominal, alteration, octave, } => Some((*nominal, *alteration as i32, *octave)), _ => None, }; Some((semitone, authored)) } /// Chooses the line-of-fifths spelling of a pitch class given the running centre /// of gravity (`sum`/`count` of recent spelled `lof`s) and the melodic step /// direction into this note. Lower key wins: /// 1. distance to the centre of gravity (`|lof*count - sum|`, exact integer); /// 2. accidental simplicity; /// 3. melodic direction (ascending prefers sharps, descending flats) on ties; /// 4. the lof value itself, for a total deterministic order. fn choose_lof(pitch_class: i32, sum: i64, count: i64, direction: std::cmp::Ordering) -> i32 { use std::cmp::Ordering; let candidates = lof_candidates(pitch_class); candidates .into_iter() .min_by_key(|&lof| { let distance = (lof as i64 * count - sum).abs(); let complexity = lof_complexity(lof); let directional = match direction { Ordering::Greater => -lof, // ascending: prefer higher lof (sharps) Ordering::Less => lof, // descending: prefer lower lof (flats) Ordering::Equal => lof.abs(), }; (distance, complexity, directional, lof) }) .expect("every pitch class has at least one candidate in the single-accidental band") } /// Assigns inferred spellings to one ordered melodic run (a voice), folding /// chords (multiple pitches at one position) into a single centre-of-gravity /// step. fn spell_run(slots: &[Vec], out: &mut BTreeMap) { let mut window: VecDeque = VecDeque::with_capacity(SPELLING_WINDOW); let mut prev_ref: Option = None; for slot in slots { if slot.is_empty() { continue; } // The slot's representative semitone (mean), for melodic direction. let ref_sem: i32 = (slot.iter().map(|e| e.semitone as i64).sum::() / slot.len() as i64) as i32; let direction = match prev_ref { Some(p) => ref_sem.cmp(&p), None => std::cmp::Ordering::Equal, }; let count = window.len() as i64; let sum: i64 = window.iter().map(|&l| l as i64).sum(); let mut chosen: Vec = Vec::with_capacity(slot.len()); for entry in slot { let (lof, spelling) = match entry.authored_cmn { Some((nominal, alteration, octave)) => { let lof = cmn_to_lof(nominal, alteration); let spelling = PitchSpelling { nominal: SpellingNominal::Cmn(nominal), accidentals: accidental_ids(alteration), octave, render_hints: Default::default(), }; (lof, spelling) } None => { let pc = entry.semitone.rem_euclid(12); let lof = choose_lof(pc, sum, count, direction); (lof, spelling_from_lof(lof, entry.semitone)) } }; out.insert(entry.pid, spelling); chosen.push(lof); } for l in chosen { window.push_back(l); while window.len() > SPELLING_WINDOW { window.pop_front(); } } prev_ref = Some(ref_sem); } } /// Runs the spelling pre-pass over every eligible embedded pitch, returning the /// inferred (pre-precedence) spelling per pitch. Counts unavailable pitches into /// the taxonomy. fn infer_spellings( score: &Score, layout: &ScoreLayout, taxonomy: &mut TaxonomyReport, ) -> BTreeMap { let mut out = BTreeMap::new(); // Count events by kind once (independent of spelling eligibility). for ev in score.events.iter_canonical() { match ev { Event::Pitched(_) => taxonomy.pitched_events += 1, Event::Unpitched(_) => taxonomy.unpitched_events += 1, Event::Rest(_) => taxonomy.rest_events += 1, Event::Trajectory(_) => taxonomy.trajectory_events += 1, Event::Graphic(_) => taxonomy.graphic_events += 1, Event::Indeterminate(_) => taxonomy.indeterminate_events += 1, Event::Cue(_) => taxonomy.cue_events += 1, } } // Spell each voice run in melodic order. Pitches that decline a 12-TET class // (spelling-unavailable spaces) are counted, not spelled. for events in layout.voice_runs.values() { let mut slots: Vec> = Vec::new(); for &eid in events { let Some(ev) = score.events.get(eid) else { continue; }; let mut pitches: Vec<&IdentifiedPitch> = Vec::new(); ev.collect_identified_pitches(&mut pitches); if pitches.is_empty() { continue; } // Deterministic chord order: ascending semitone, then pitch id. let mut entries: Vec = Vec::new(); for ip in pitches { match spell_entry_for(&ip.pitch) { Some((semitone, authored_cmn)) => entries.push(SpellEntry { pid: ip.id, semitone, authored_cmn, }), None => taxonomy.spelling_unavailable += 1, } } entries.sort_by_key(|e| (e.semitone, e.pid)); if !entries.is_empty() { slots.push(entries); } } spell_run(&slots, &mut out); } out } /// Resolves the *effective* spelling for a pitch: an authored /// [`SpellingAttachment`] on the engraved layer whose [`SpellingSourceKind`] /// outranks [`SpellingSourceKind::Inferred`] in `precedence` wins; otherwise the /// algorithm's inferred spelling stands. This is the precedence rule a /// `RespellPitch` override rides on (Chapter 2 §"Configurable Precedence"). pub fn resolve_spelling( score: &Score, pitch: PitchId, inferred: PitchSpelling, precedence: &SpellingPrecedence, ) -> ResolvedSpelling { let inferred_rank = precedence.rank(SpellingSourceKind::Inferred); // The best authored override: engraved layer, pitch-scoped, explicit, source // outranking Inferred. Among candidates, lowest precedence rank wins, then // highest priority; a remaining tie keeps the first candidate in the score's // `spelling_attachments` order, which is canonical (codec-fixed), so the // resolution is deterministic across replicas. let mut best: Option<(usize, i32, &SpellingAttachment)> = None; for att in &score.spelling_attachments { if att.layer.is_some() { continue; // engraved layer only } if !matches!(&att.scope, SpellingScope::Pitch(p) if *p == pitch) { continue; } let SpellingDirective::Explicit(_) = &att.directive else { continue; }; let rank = precedence.rank(att.source.kind()); if rank >= inferred_rank { continue; // does not outrank the inferred default } let candidate = (rank, att.priority, att); best = match best { None => Some(candidate), Some(cur) => { // Lower rank wins; then higher priority; a full tie keeps `cur` // (the earlier attachment in canonical order). `Reverse` orders by // descending priority without negating, which would overflow for a // `priority` of `i32::MIN`. if (rank, Reverse(att.priority)) < (cur.0, Reverse(cur.2.priority)) { Some(candidate) } else { Some(cur) } } }; } match best { Some((_, _, att)) => { if let SpellingDirective::Explicit(spelling) = &att.directive { ResolvedSpelling { spelling: spelling.clone(), provenance: SpellingProvenance::Authored(att.source.kind()), } } else { unreachable!("filtered to Explicit directives") } } None => ResolvedSpelling { spelling: inferred, provenance: SpellingProvenance::Inferred, }, } } // =========================================================================== // Notational decomposition (Chapter 3) // =========================================================================== // // The notation grid is whole-note units over `GRID_DEN` (1/4096), so every note // value down to a (dotted) sixty-fourth is an exact integer. All metric grid // logic — measure offset, dyadic levels, barline and beat splitting — is then // integer arithmetic, which keeps the derivation exact and deterministic. /// Subdivisions of a whole note on the notation grid: `2^12`, so a sixty-fourth /// is `64` units and a (single-)dotted sixty-fourth is `96`. const GRID_DEN: i64 = 1 << 12; /// Phase-2 default decomposition uses single dots only (Chapter 3 / §H "simple /// augmentation dots; double-dotted and beyond may defer"). const MAX_DOTS: u8 = 1; /// Grid units of a base note value (undotted). This is the integer-grid mirror of /// [`NoteValue::whole_note_fraction`] (the rational source of truth); the two are /// pinned together by the `grid_units_agree_with_canonical_note_value_math` test /// so they cannot silently diverge. fn note_value_units(v: NoteValue) -> i64 { match v { NoteValue::Whole => GRID_DEN, NoteValue::Half => GRID_DEN / 2, NoteValue::Quarter => GRID_DEN / 4, NoteValue::Eighth => GRID_DEN / 8, NoteValue::Sixteenth => GRID_DEN / 16, NoteValue::ThirtySecond => GRID_DEN / 32, NoteValue::SixtyFourth => GRID_DEN / 64, } } const ALL_NOTE_VALUES: [NoteValue; 7] = [ NoteValue::Whole, NoteValue::Half, NoteValue::Quarter, NoteValue::Eighth, NoteValue::Sixteenth, NoteValue::ThirtySecond, NoteValue::SixtyFourth, ]; /// Total grid units of `value` with `dots` augmentation dots /// (`base * (2 - 2^-dots)`). The integer-grid mirror of /// [`crate::graph::NotatedComponent::notated_duration`]; pinned to it by the /// `grid_units_agree_with_canonical_note_value_math` test so a change to dot /// semantics in either domain cannot silently diverge from the other. fn dotted_units(value: NoteValue, dots: u8) -> i64 { let base = note_value_units(value); let mut total = base; let mut increment = base; for _ in 0..dots { increment /= 2; total += increment; } total } /// The `(value, dots)` whose dotted length is exactly `units`, if any (dots /// capped at [`MAX_DOTS`]). Larger base values are tried first so a length is /// expressed by the fewest noteheads. fn note_for_units(units: i64) -> Option<(NoteValue, u8)> { for &v in &ALL_NOTE_VALUES { for dots in 0..=MAX_DOTS { if dotted_units(v, dots) == units { return Some((v, dots)); } } } None } /// Total grid units of a component-length list. Used to verify a decomposition /// reconstructs its input exactly: every component's `dotted_units` equals the /// span it represents, so the sum equals the input length unless a residue finer /// than the grid's smallest value was dropped. fn sum_component_units(lengths: &[(NoteValue, u8)]) -> i64 { lengths.iter().map(|&(v, d)| dotted_units(v, d)).sum() } /// The dyadic *level* (metric strength) of a within-measure grid position: /// `0` is a barline-strength multiple of a whole note, larger is weaker. A /// boundary at level `L` is at least as strong as one at level `> L`. fn dyadic_level(units: i64) -> i32 { if units == 0 { return 0; } // GRID_DEN = 2^12; level = max(0, 12 - v2(units)). let v2 = units.trailing_zeros() as i32; (12 - v2).max(0) } /// Whether `[start, start+len)` may be written as the single notated value of /// `len`, starting at `start`: the length is a notatable value, and it crosses /// no interior grid boundary at least as strong as `start` (so it never obscures /// a beat stronger than the one it began on — the metric well-formedness rule of /// Chapter 3). Positions are within-measure grid units. fn single_value_acceptable(start: i64, len: i64) -> Option<(NoteValue, u8)> { let (value, dots) = note_for_units(len)?; let start_level = dyadic_level(start); // The strongest interior dyadic boundary in (start, start+len). The interior // boundary nearest `start` at each level governs; it suffices to check the // single strongest interior boundary. if let Some(b) = strongest_interior_boundary(start, start + len) { if dyadic_level(b) <= start_level { return None; } } Some((value, dots)) } /// The strongest (lowest-level, i.e. metrically heaviest) dyadic grid boundary /// strictly inside `(start, end)`, or `None` if the open interval contains none. /// Ties at the same level resolve to the leftmost. fn strongest_interior_boundary(start: i64, end: i64) -> Option { // Walk levels from strongest (0) to finest; the first level with an interior // multiple gives the answer. for level in 0..=12i32 { let step = GRID_DEN >> level; // spacing of level-`level` boundaries if step == 0 { break; } // First multiple of `step` strictly greater than `start`. let first = (start / step + 1) * step; if first < end { return Some(first); } } None } /// Decomposes one within-measure span `[start, end)` (grid units) into notated /// component lengths, splitting at the strongest interior boundary it crosses so /// syncopations tie across stronger beats. Appends `(value, dots)` in order. fn decompose_segment(start: i64, end: i64, out: &mut Vec<(NoteValue, u8)>) { if start >= end { return; } if let Some(component) = single_value_acceptable(start, end - start) { out.push(component); return; } match strongest_interior_boundary(start, end) { Some(b) => { decompose_segment(start, b, out); decompose_segment(b, end, out); } None => { // No interior boundary and not a clean note value: the span is finer // than the grid's smallest representable value (a sixty-fourth), so it // cannot be notated. Leave it unrepresented — `decompose_metric` / // `decompose_tuplet_member` verify the components reconstruct the input // and report such a residue as ungriddable rather than emitting a short // decomposition that would violate invariant 15. } } } /// Decomposes a determinate musical duration starting at `position` (region- /// relative) under a measure of `measure_units`, splitting at barlines with /// ties. Returns the notated component lengths in order, or `None` if the /// duration is not representable on the grid. fn decompose_metric( position_units: i64, duration_units: i64, measure_units: i64, ) -> Option> { if duration_units <= 0 { return Some(Vec::new()); } // A degenerate (zero or negative) measure length has no barline grid to align // against; report it ungriddable rather than dividing by zero in the offset // reduction below. if measure_units <= 0 { return None; } let mut out = Vec::new(); // Position within the current measure; barlines fall every `measure_units`. let mut offset = position_units.rem_euclid(measure_units); let mut remaining = duration_units; let mut guard = 0; while remaining > 0 { guard += 1; if guard > 4096 { return None; // runaway guard; should be unreachable for griddable input } let room = measure_units - offset; // until the next barline let seg = remaining.min(room); decompose_segment(offset, offset + seg, &mut out); remaining -= seg; offset += seg; if offset >= measure_units { offset = 0; } } // The components must reconstruct the input exactly. They will not when the // duration is a grid multiple but not a note-value multiple — i.e. finer than // the grid's smallest representable value (a sixty-fourth) — because // `decompose_segment` drops that residue. Report it as ungriddable rather than // emit a decomposition whose components fall short of the duration (invariant // 15). For well-formed metric input the sum always matches. if sum_component_units(&out) != duration_units { return None; } Some(out) } /// Decomposes a tuplet member: its *sounding* duration (which is non-dyadic, /// e.g. `1/12` for a triplet eighth) is converted to the *notated* domain in the /// exact rational domain (`sounding * actual / notated`, which a well-formed /// tuplet makes dyadic), gridded, decomposed there, and tagged with the tuplet. /// The notated value is decomposed as a standalone span (Phase-2 simplification: /// tuplet-internal metric alignment beyond a clean member, and tuplet nesting, /// are Pass-12 refinements). fn decompose_tuplet_member( sounding: &RationalTime, ratio: TupletRatio, ) -> Option> { // notated = sounding * actual / notated, computed exactly before gridding. let scale = RationalTime::new(ratio.actual() as i64, ratio.notated() as i64)?; let notated = sounding.mul(&scale); let notated_units = to_grid_units(¬ated)?; if notated_units <= 0 { return Some(Vec::new()); } let mut out = Vec::new(); decompose_segment(0, notated_units, &mut out); // Same reconstruction guarantee as `decompose_metric`: reject a notated value // finer than the grid's smallest representable value rather than emit // components that fall short of it (invariant 15). if sum_component_units(&out) != notated_units { return None; } Some(out) } /// Converts a musical duration/position to grid units, or `None` if it is not an /// exact multiple of `1/GRID_DEN` (finer than a sixty-fourth, or non-dyadic). fn to_grid_units(r: &RationalTime) -> Option { match r { RationalTime::Small(s) => { let n = s.numerator() as i64; let d = s.denominator() as i64; if d == 0 || GRID_DEN % d != 0 { return None; } Some(n * (GRID_DEN / d)) } // Real notation durations never promote; treat a promoted value as // ungriddable rather than guessing. RationalTime::Large(_) => None, } } /// Resolves the measure length (grid units) governing a region: the first /// determinable time signature referenced by one of the region's measures, else /// the score's first time signature, else a whole note (a 4/4-equivalent /// default). Multi-meter and mid-region meter changes are a Phase-2 /// simplification (a single governing meter per region). fn resolve_measure_units(score: &Score, region: &crate::graph::Region) -> i64 { let lookup = |tsid: &crate::ids::TimeSignatureId| -> Option { score .time_signatures .iter() .find(|ts| &ts.id == tsid) .and_then(|ts| to_grid_units(ts.measure_duration().rational())) }; for si in region.staff_instances() { for m in &si.measures { if let Some(tsid) = &m.time_signature { if let Some(units) = lookup(tsid) { return units; } } } } if let Some(ts) = score.time_signatures.first() { if let Some(units) = to_grid_units(ts.measure_duration().rational()) { return units; } } GRID_DEN } /// Builds the decomposition attachments for every eligible event, counting /// ineligible / deferred / skipped cases into the taxonomy. fn infer_decompositions( score: &Score, layout: &ScoreLayout, taxonomy: &mut TaxonomyReport, ) -> BTreeMap { let mut out = BTreeMap::new(); for ev in score.events.iter_canonical() { let eid = ev.id(); // Kinds that never decompose: explicit, counted absence. match ev { Event::Trajectory(_) | Event::Graphic(_) | Event::Indeterminate(_) | Event::Cue(_) => { taxonomy.decomposition_inapplicable += 1; continue; } Event::Pitched(_) | Event::Unpitched(_) | Event::Rest(_) => {} } // Only metric regions decompose; proportional/aleatoric deferred. if !layout.is_metric(eid) { taxonomy.decomposition_deferred_nonmetric += 1; continue; } // Need a determinate musical duration. let EventDuration::Musical(MusicalDuration(dur)) = ev.duration() else { taxonomy.decomposition_skipped_nonmusical += 1; continue; }; // Tuplet members convert sounding -> notated *before* gridding (the // sounding duration is non-dyadic); plain metric events grid directly. let components_units = if let Some((tuplet_id, ratio)) = layout.event_tuplet.get(&eid) { decompose_tuplet_member(dur, *ratio).map(|l| (l, Some(*tuplet_id))) } else { match to_grid_units(dur) { Some(duration_units) => { let measure_units = layout .region_measure_units .get(layout.event_region.get(&eid).unwrap()) .copied() .unwrap_or(GRID_DEN); // The position must land on the grid too. A recorded but // off-grid position (e.g. an event sitting mid-tuplet) is // reported as ungriddable rather than silently decomposed as // if it began on the barline; an absent position — a metric // event must have one — defaults to the barline. let position_units = match layout.event_position.get(&eid) { Some(p) => to_grid_units(p.rational()), None => Some(0), }; position_units.and_then(|pos| { decompose_metric(pos, duration_units, measure_units).map(|l| (l, None)) }) } None => None, } }; let Some((lengths, tuplet)) = components_units else { taxonomy.decomposition_ungriddable += 1; continue; }; if lengths.is_empty() { // Zero-duration determinate event: nothing to decompose, but it is a // determinate musical duration, so do not mis-count it. taxonomy.decomposition_skipped_nonmusical += 1; continue; } let n = lengths.len(); let components: Vec = lengths .into_iter() .enumerate() .map(|(i, (base_value, dots))| NotatedComponent { base_value, dots, tuplet, tied_to_next: i + 1 < n, }) .collect(); out.insert( eid, DecompositionAttachment { target: eid, components, source: DecompositionSource::Inferred, }, ); taxonomy.decompositions_inferred += 1; } out } #[cfg(test)] mod tests;