epiphany/crates/epiphany-core/src/graph.rs

1583 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The score-graph structure (Chapter 5): the canvas/region model, the
//! distinct [`Staff`] and [`StaffInstance`] concepts, voices, measures,
//! barline-alignment groups, the reference-bearing cross-cutting structures,
//! and the [`Score`] root.
//!
//! The graph is a *tree of containment overlaid with cross-cutting structures
//! that hold references* (Chapter 5 §"Design Principles": "Hybrid topology").
//! The tree determines existence; cross-cutting references may dangle and
//! require re-anchoring. The spatial root is the [`Canvas`], partitioned into
//! [`Region`]s; staves live inside regions ("Canvas before staff").
//!
//! Engraving-display detail (clef/key sequences, stem direction, line styles,
//! spanner/marker visual kinds) is *fully defined in Chapter 7* per the spec
//! and belongs to Agent E; this module carries minimal placeholders for it.
use core::num::NonZeroU16;
use std::collections::BTreeSet;
use epiphany_determinism::{CanonicalF64, SystemDomainTag};
use crate::event::EventArena;
use crate::ids::{
derive_system_id, BarlineAlignmentGroupId, BeamId, ChordSymbolId, IdentityContext,
InstrumentId, LyricLineId, MeasureId, OperationId, PartDefinitionId, PitchId, RegionId,
RepeatStructureId, SlurId, SpannerId, StaffGroupId, StaffId, StaffInstanceId, TimeSignatureId,
TupletId, ViewId, VoiceId,
};
use crate::pitch::{
ForeignFormatId, PitchSpaceId, ReferencePitch, SpellingAttachment, TuningSystemId,
};
use crate::time::{MeasurePosition, MusicalDuration, TimeAnchor, WallClockDuration};
// --- Engraving-display placeholders (Chapter 7 / Agent E). ------------------
/// Default stem direction for a voice. Placeholder (Chapter 7).
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub enum StemDirection {
Up,
Down,
}
/// Staff line configuration. Placeholder beyond the line count (Chapter 7).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct StaffLineConfiguration {
pub line_count: u8,
}
impl Default for StaffLineConfiguration {
fn default() -> Self {
StaffLineConfiguration { line_count: 5 }
}
}
/// The SMuFL clef family a [`Clef`] draws from. The reference pitch each family
/// fixes (G4 / F3 / middle C4) is what pins the staff-position mapping.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub enum ClefShape {
/// G clef (treble family) — reference pitch G4.
G,
/// F clef (bass family) — reference pitch F3.
F,
/// C clef (alto / tenor family) — reference pitch middle C (C4).
C,
/// Unpitched percussion clef — no diatonic reference.
Percussion,
}
/// A clef: the SMuFL [`ClefShape`], the staff line its reference pitch sits on
/// (`1` = the bottom line of the staff, counting up), and an octave
/// transposition (`-1` for treble-8vb, `+1` for treble-8va, …). The shape's
/// reference pitch on `line` fixes where every pitch under this clef lands on
/// the staff.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub struct Clef {
pub shape: ClefShape,
pub line: i8,
pub octave_shift: i8,
}
impl Clef {
/// Treble clef — G clef on line 2.
pub const fn treble() -> Self {
Clef {
shape: ClefShape::G,
line: 2,
octave_shift: 0,
}
}
/// Bass clef — F clef on line 4.
pub const fn bass() -> Self {
Clef {
shape: ClefShape::F,
line: 4,
octave_shift: 0,
}
}
/// Alto clef — C clef on line 3.
pub const fn alto() -> Self {
Clef {
shape: ClefShape::C,
line: 3,
octave_shift: 0,
}
}
/// Tenor clef — C clef on line 4.
pub const fn tenor() -> Self {
Clef {
shape: ClefShape::C,
line: 4,
octave_shift: 0,
}
}
}
impl Default for Clef {
fn default() -> Self {
Clef::treble()
}
}
/// A key signature as a position on the circle of fifths: `fifths` sharps when
/// positive (`1` = G major, one sharp), flats when negative (`-1` = F major,
/// one flat), and `0` for C major / A minor. The accidental set the renderer
/// draws is derived from this count.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)]
pub struct KeySignature {
fifths: i8,
}
impl KeySignature {
/// Lowest key-signature fifth count in conventional CMN notation: seven flats.
pub const MIN_FIFTHS: i8 = -7;
/// Highest key-signature fifth count in conventional CMN notation: seven sharps.
pub const MAX_FIFTHS: i8 = 7;
/// Builds a key signature, rejecting values outside the conventional
/// `-7..=7` circle-of-fifths range.
pub const fn new(fifths: i8) -> Option<Self> {
if fifths >= Self::MIN_FIFTHS && fifths <= Self::MAX_FIFTHS {
Some(KeySignature { fifths })
} else {
None
}
}
/// The circle-of-fifths position (`-7..=7`).
pub const fn fifths(self) -> i8 {
self.fifths
}
}
/// A clef placed at a point in a staff instance (Chapter 7).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ClefChange {
pub anchor: TimeAnchor,
pub clef: Clef,
}
/// A key-signature change at a point in a staff instance (Chapter 7).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct KeySignatureChange {
pub anchor: TimeAnchor,
pub key: KeySignature,
}
/// Whether and how a measure number is shown. Placeholder (Chapter 7).
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)]
pub enum MeasureNumberVisibility {
#[default]
Auto,
Always,
Never,
}
/// A graphic object stored in a region's graphic content (Chapter 5
/// §"Graphic Objects"). Carries its identifier so [`GraphicGesture::objects`]
/// and [`crate::GraphicEvent::graphics`] references can resolve against it; the
/// geometry/style detail is Chapter 7's.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct GraphicObject {
pub id: crate::ids::GraphicObjectId,
}
/// Free-graphic content of a region (Chapter 5 §"Graphic Content"): the graphic
/// objects placed in the region's coordinate space. The coordinate-system and
/// per-object geometry/style detail is Chapter 7's; this baseline carries the
/// object identities so references into the content can be resolved.
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct GraphicContent {
pub objects: Vec<GraphicObject>,
}
/// Reference to a tempo map (score-level or local). Placeholder (Chapter 3
/// §"Tempo and the Tempo Map").
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct TempoMapReference;
// --- Time models (Chapter 3 §"Region Time Models"). -------------------------
/// The anchoring discipline of an aleatoric region (Chapter 3 §"Aleatoric
/// Time"): which event coordinate kinds the region permits.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub enum AleatoricAnchoringDiscipline {
/// Musical-time coordinates only.
Musical,
/// Wall-clock coordinates only.
WallClock,
/// Either kind per event, but an event's position and duration kinds must
/// agree and duration bounds use a single concrete variant.
EitherPerEvent,
/// Either kind freely; duration bounds may mix kinds.
FreelyMixed,
}
/// A meter change within a metric grid (Chapter 5 §"Staff-Based Content").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct MeterChange {
/// Where this meter takes effect, within the enclosing staff or region.
pub anchor: TimeAnchor,
/// The active time signature until the next change.
pub time_signature: TimeSignatureId,
}
/// A strictly-positive power-of-two time-signature denominator (Chapter 3
/// §"Time Signatures and Meter"): the note value `1` (whole), `2` (half), `4`
/// (quarter), …. The spec types a `Standard`/`Compound` denominator as
/// `PowerOfTwo`, so a zero or non-power-of-two value is unrepresentable by
/// construction — an irrational meter (`4/6`, `5/12`) uses the dedicated
/// [`TimeSignatureDisplay::Irrational`] variant instead.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub struct PowerOfTwo(u16);
impl PowerOfTwo {
/// Builds a power-of-two denominator, returning `None` unless `value` is a
/// strictly-positive power of two.
#[inline]
pub const fn new(value: u16) -> Option<Self> {
if value != 0 && value.is_power_of_two() {
Some(PowerOfTwo(value))
} else {
None
}
}
/// The denominator value (always a positive power of two).
#[inline]
pub const fn get(self) -> u16 {
self.0
}
}
/// How a time signature is displayed (Chapter 3 §"Time Signatures and Meter").
/// The denominator *type* per variant carries the spec's display constraint: a
/// `Standard`/`Compound` denominator is a [`PowerOfTwo`]; an `Irrational` or
/// `MixedDenominators` denominator is any [`NonZeroU16`]. Zero denominators are
/// unrepresentable. None of this affects the rational `measure_duration`.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum TimeSignatureDisplay {
Standard {
numerator: u16,
denominator: PowerOfTwo,
},
Compound {
numerators: Vec<u16>,
denominator: PowerOfTwo,
},
/// Irrational meter: the denominator is *not* a power of two (`4/6`, `5/12`).
Irrational {
numerator: u16,
denominator: NonZeroU16,
},
MixedDenominators {
components: Vec<(u16, NonZeroU16)>,
},
None,
/// Custom symbol (cut time, common time, grammar-specific). Placeholder id.
Symbolic(u32),
}
/// A beat group within a measure (Chapter 3 §"Time Signatures and Meter").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct BeatGroup {
pub duration: MusicalDuration,
pub subdivision: Option<MusicalDuration>,
/// Accent strength relative to other beat groups; higher is stronger.
pub accent: u8,
}
/// A time signature object in the score graph (Chapter 3 §"Time Signatures and
/// Meter"): not merely a numerator/denominator pair. The beat-group durations
/// must sum to `measure_duration` — enforced by [`TimeSignature::new`], the
/// spec's "reject at construction" discipline ("Implementations MUST reject time
/// signatures whose beat groups do not sum to the measure duration").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct TimeSignature {
pub id: TimeSignatureId,
pub display: TimeSignatureDisplay,
measure_duration: MusicalDuration,
beat_groups: Vec<BeatGroup>,
}
impl TimeSignature {
/// Builds a time signature, returning `None` unless the beat-group durations
/// sum exactly to `measure_duration` (Chapter 3). Fields are private so the
/// invariant cannot be broken after construction.
pub fn new(
id: TimeSignatureId,
display: TimeSignatureDisplay,
measure_duration: MusicalDuration,
beat_groups: Vec<BeatGroup>,
) -> Option<Self> {
let sum = MusicalDuration::sum(beat_groups.iter().map(|b| &b.duration));
if sum == measure_duration {
Some(TimeSignature {
id,
display,
measure_duration,
beat_groups,
})
} else {
None
}
}
/// The total duration of one measure under this signature.
pub fn measure_duration(&self) -> &MusicalDuration {
&self.measure_duration
}
/// The beat groups (whose durations sum to [`Self::measure_duration`]).
pub fn beat_groups(&self) -> &[BeatGroup] {
&self.beat_groups
}
}
/// A per-staff (or per-region-default) metric organization (Chapter 5).
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct MetricGrid {
pub meter_sequence: Vec<MeterChange>,
}
/// The metric time model of a region (Chapter 3 §"Metric Time").
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct MetricTimeModel {
pub meters: Vec<MeterChange>,
pub tempo: TempoMapReference,
}
/// The proportional time model of a region (Chapter 3 §"Proportional Time").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ProportionalTimeModel {
/// Total wall-clock duration of the region.
pub duration: WallClockDuration,
}
/// An acyclic ordering of events in an aleatoric region (Chapter 3 §"Aleatoric
/// Time"): an edge `a -> b` means "a precedes b"; two events with no path
/// between them are unordered. The DAG **must be acyclic** (Chapter 3:
/// "Cycles MUST be rejected at construction"), so the only constructors —
/// [`EventOrderingDAG::default`] (empty) and [`EventOrderingDAG::try_new`]
/// (cycle-checked) — cannot produce a cyclic graph, and the adjacency is
/// private so it stays that way.
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct EventOrderingDAG {
/// `edges[a]` lists the events that directly follow `a`.
edges: std::collections::BTreeMap<crate::ids::EventId, Vec<crate::ids::EventId>>,
}
impl EventOrderingDAG {
/// Builds a DAG from adjacency, returning `None` if it contains a cycle
/// (Chapter 3: cycles are rejected at construction).
pub fn try_new(
edges: std::collections::BTreeMap<crate::ids::EventId, Vec<crate::ids::EventId>>,
) -> Option<Self> {
let dag = EventOrderingDAG { edges };
if dag.is_acyclic() {
Some(dag)
} else {
None
}
}
/// The events that directly follow `event`.
pub fn successors(&self, event: crate::ids::EventId) -> &[crate::ids::EventId] {
self.edges.get(&event).map(|v| v.as_slice()).unwrap_or(&[])
}
/// The raw adjacency map, for the canonical codec (which must serialize the
/// full ordering, not only the events reachable from a query).
pub(crate) fn edges_ref(
&self,
) -> &std::collections::BTreeMap<crate::ids::EventId, Vec<crate::ids::EventId>> {
&self.edges
}
/// Every event the ordering names — DAG nodes (sources) and edge targets.
/// Used by the invariant checker to confirm the ordering only references
/// events that exist in the region (invariant 10).
pub fn referenced_events(&self) -> BTreeSet<crate::ids::EventId> {
let mut set = BTreeSet::new();
for (source, targets) in &self.edges {
set.insert(*source);
set.extend(targets.iter().copied());
}
set
}
/// Whether the ordering is acyclic (always true for a value built through
/// the public constructors).
pub fn is_acyclic(&self) -> bool {
// Iterative DFS three-colour cycle detection over the adjacency.
#[derive(Clone, Copy, PartialEq)]
enum Mark {
Open,
Done,
}
let mut state: std::collections::BTreeMap<crate::ids::EventId, Mark> =
std::collections::BTreeMap::new();
// Stack entries: (node, expanded?) — expanded marks the post-visit.
let nodes: Vec<crate::ids::EventId> = self.edges.keys().copied().collect();
for root in nodes {
if state.contains_key(&root) {
continue;
}
let mut stack = vec![(root, false)];
while let Some((node, expanded)) = stack.pop() {
if expanded {
state.insert(node, Mark::Done);
continue;
}
if let Some(Mark::Done) = state.get(&node) {
continue;
}
state.insert(node, Mark::Open);
stack.push((node, true));
for &next in self.successors(node) {
match state.get(&next) {
Some(Mark::Open) => return false, // back-edge: cycle
Some(Mark::Done) => {}
None => stack.push((next, false)),
}
}
}
}
true
}
}
/// The aleatoric time model of a region (Chapter 3 §"Aleatoric Time").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct AleatoricTimeModel {
/// Ordering constraints among events (acyclic by construction).
pub ordering: EventOrderingDAG,
/// Which event coordinate kinds this region permits.
pub anchoring: AleatoricAnchoringDiscipline,
/// Optional per-event interval bounds.
pub bounds: std::collections::BTreeMap<crate::ids::EventId, crate::time::EventBounds>,
/// Approximate or maximum total duration, for layout.
pub duration_hint: WallClockDuration,
}
/// A region's time model (Chapter 3 §"Region Time Models").
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum RegionTimeModel {
Metric(MetricTimeModel),
Proportional(ProportionalTimeModel),
Aleatoric(AleatoricTimeModel),
}
/// How a region constrains event coordinate kinds, derived from its time model
/// (Chapter 5 invariant 4).
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub enum CoordinateDiscipline {
/// Musical coordinates only (metric regions).
Musical,
/// Wall-clock coordinates only (proportional regions).
WallClock,
/// Governed by the region's aleatoric anchoring discipline.
Aleatoric(AleatoricAnchoringDiscipline),
}
impl RegionTimeModel {
/// The coordinate discipline this time model imposes on its events.
pub fn coordinate_discipline(&self) -> CoordinateDiscipline {
match self {
RegionTimeModel::Metric(_) => CoordinateDiscipline::Musical,
RegionTimeModel::Proportional(_) => CoordinateDiscipline::WallClock,
RegionTimeModel::Aleatoric(a) => CoordinateDiscipline::Aleatoric(a.anchoring),
}
}
}
// --- Staves, instances, voices, measures (Chapter 5). -----------------------
/// The provenance of a voice (Chapter 5 §"Voices"). A voice's `origin` must be
/// consistent with how it was created (invariant 18).
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum VoiceOrigin {
/// Created by an explicit user action.
UserDeclared,
/// Imported from a foreign format.
Imported { format: ForeignFormatId },
/// System-promoted to resolve a concurrent-edit collision (Chapter 5
/// §"System-Promoted Voices").
SystemPromoted {
/// The lower-id concurrent operation that retained the original voice.
winning_operation: OperationId,
/// The greater-id concurrent operation moved into this voice.
losing_operation: OperationId,
original_voice: VoiceId,
},
}
/// Derives the deterministic [`VoiceId`] of a system-promoted voice from the
/// fixed function of (staff instance, original voice, winning op, losing op)
/// in Chapter 5 §"System-Promoted Voices", placed in the
/// [`crate::ReplicaId::SYSTEM_DERIVED`] namespace via the `MUSCSVCE` domain
/// tag.
///
/// The spec defers "the exact derivation function" to the semantic-operations
/// companion (Agent C); this is the prototype's concrete, deterministic
/// realization — the canonical inputs are the four identifiers' 16-byte forms
/// concatenated in the listed order. Recorded as a Pass 11 candidate in
/// `DECISIONS.md` so the companion can pin it.
pub fn derive_promoted_voice_id(
staff_instance: StaffInstanceId,
original_voice: VoiceId,
winning_op: OperationId,
losing_op: OperationId,
) -> VoiceId {
let mut inputs = Vec::with_capacity(64);
inputs.extend_from_slice(&staff_instance.canonical_bytes());
inputs.extend_from_slice(&original_voice.canonical_bytes());
inputs.extend_from_slice(&winning_op.canonical_bytes());
inputs.extend_from_slice(&losing_op.canonical_bytes());
derive_system_id::<VoiceId>(SystemDomainTag::VOICE, &inputs)
}
/// A polyphonic line within a staff instance (Chapter 5 §"Voices"). Holds
/// ordered references to events; the events live in the arena.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Voice {
pub id: VoiceId,
/// Ordered references to events in this voice, sorted by position. Each
/// referenced event has `voice == this voice's id`.
pub events: Vec<crate::ids::EventId>,
pub default_stem_direction: Option<StemDirection>,
pub is_primary: bool,
pub origin: VoiceOrigin,
}
impl Voice {
/// A new user-declared voice with no events.
pub fn user(id: VoiceId) -> Self {
Voice {
id,
events: Vec::new(),
default_stem_direction: None,
is_primary: false,
origin: VoiceOrigin::UserDeclared,
}
}
}
/// A measure belonging to exactly one staff instance (Chapter 5
/// §"Staff-Based Content").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Measure {
pub id: MeasureId,
pub start: TimeAnchor,
pub time_signature: Option<TimeSignatureId>,
pub explicit_number: Option<u32>,
pub number_visibility: MeasureNumberVisibility,
}
/// A region-local manifestation of a globally-identified [`Staff`]
/// (Chapter 5 §"Staves: Identity Versus Instance"). Distinct from `Staff`:
/// a `Staff` is the abstract identity persisting across the score; a
/// `StaffInstance` is its content for the duration of one region.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct StaffInstance {
pub id: StaffInstanceId,
/// The globally-identified staff this instance manifests.
pub staff: StaffId,
pub voices: Vec<Voice>,
pub clef_sequence: Vec<ClefChange>,
pub key_sequence: Vec<KeySignatureChange>,
pub local_metric_grid: Option<MetricGrid>,
/// Measures belonging to this instance, in order (per-staff; admits
/// polymeter).
pub measures: Vec<Measure>,
pub instrument_override: Option<InstrumentId>,
pub staff_lines_override: Option<StaffLineConfiguration>,
pub visible: bool,
}
impl StaffInstance {
/// A new empty instance of `staff`.
pub fn new(id: StaffInstanceId, staff: StaffId) -> Self {
StaffInstance {
id,
staff,
voices: Vec::new(),
clef_sequence: Vec::new(),
key_sequence: Vec::new(),
local_metric_grid: None,
measures: Vec::new(),
instrument_override: None,
staff_lines_override: None,
visible: true,
}
}
}
/// A declaration that the measure boundaries of two or more staff instances
/// coincide (Chapter 5 §"Staff-Based Content").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct BarlineAlignmentGroup {
pub id: BarlineAlignmentGroupId,
pub members: Vec<BarlineAlignmentMember>,
}
/// One member of a [`BarlineAlignmentGroup`].
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct BarlineAlignmentMember {
pub staff_instance: StaffInstanceId,
pub measure: MeasureId,
pub position: MeasurePosition,
}
/// The staff-based content of a region (Chapter 5 §"Staff-Based Content").
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct StaffBasedContent {
pub staff_instances: Vec<StaffInstance>,
pub default_metric_grid: Option<MetricGrid>,
pub barline_alignment_groups: Vec<BarlineAlignmentGroup>,
pub user_system_breaks: Vec<TimeAnchor>,
pub user_page_breaks: Vec<TimeAnchor>,
}
/// A region's content model (Chapter 5 §"Regions").
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum RegionContent {
/// Staff-based notation.
StaffBased(StaffBasedContent),
/// Free graphic content: no staves.
FreeGraphic(GraphicContent),
/// Staves with overlaid graphic content.
Hybrid {
staves: StaffBasedContent,
overlay: GraphicContent,
overlay_below_staves: bool,
},
}
impl RegionContent {
/// The staff instances of this content, or `&[]` for free-graphic content.
pub fn staff_instances(&self) -> &[StaffInstance] {
match self {
RegionContent::StaffBased(c) => &c.staff_instances,
RegionContent::Hybrid { staves, .. } => &staves.staff_instances,
RegionContent::FreeGraphic(_) => &[],
}
}
/// The graphic objects this content places, if any (free-graphic content,
/// or a hybrid region's overlay). Empty for purely staff-based content.
pub fn graphic_objects(&self) -> &[GraphicObject] {
match self {
RegionContent::FreeGraphic(g) => &g.objects,
RegionContent::Hybrid { overlay, .. } => &overlay.objects,
RegionContent::StaffBased(_) => &[],
}
}
/// The staff-based content, if this region is staff-based or hybrid.
pub fn staff_based(&self) -> Option<&StaffBasedContent> {
match self {
RegionContent::StaffBased(c) => Some(c),
RegionContent::Hybrid { staves, .. } => Some(staves),
RegionContent::FreeGraphic(_) => None,
}
}
/// Mutable staff-based content, if this region is staff-based or hybrid.
pub fn staff_based_mut(&mut self) -> Option<&mut StaffBasedContent> {
match self {
RegionContent::StaffBased(c) => Some(c),
RegionContent::Hybrid { staves, .. } => Some(staves),
RegionContent::FreeGraphic(_) => None,
}
}
/// Mutable access to the staff instances, if this content has any (used by
/// editing and by the invariant shrinker in [`crate::generators`]).
pub fn staff_instances_mut(&mut self) -> Option<&mut Vec<StaffInstance>> {
match self {
RegionContent::StaffBased(c) => Some(&mut c.staff_instances),
RegionContent::Hybrid { staves, .. } => Some(&mut staves.staff_instances),
RegionContent::FreeGraphic(_) => None,
}
}
/// The barline-alignment groups of this content, if staff-based.
pub fn barline_alignment_groups(&self) -> &[BarlineAlignmentGroup] {
match self {
RegionContent::StaffBased(c) => &c.barline_alignment_groups,
RegionContent::Hybrid { staves, .. } => &staves.barline_alignment_groups,
RegionContent::FreeGraphic(_) => &[],
}
}
}
/// The time range a region occupies (Chapter 5 §"Regions").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct TimeExtent {
pub start: TimeAnchor,
pub end: TimeAnchor,
}
impl TimeExtent {
/// The extent as an absolute wall-clock interval, when both endpoints are
/// [`TimeAnchor::WallClock`] anchors (the only extents this prototype can
/// resolve without the full tempo/measure machinery; see
/// [`Region::overlaps_in_time`]).
pub fn as_wallclock(&self) -> Option<(i64, i64)> {
match (&self.start, &self.end) {
(TimeAnchor::WallClock { time: a }, TimeAnchor::WallClock { time: b }) => {
Some((a.0, b.0))
}
_ => None,
}
}
}
/// The vertical staff range a region occupies (Chapter 5 §"Regions").
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct StaffExtent {
/// The globally-identified staves whose content this region carries.
pub staves: Vec<StaffId>,
}
/// A region of the canvas (Chapter 5 §"Regions").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Region {
pub id: RegionId,
pub time_model: RegionTimeModel,
pub content: RegionContent,
pub time_extent: TimeExtent,
pub staff_extent: StaffExtent,
pub local_tempo_map: Option<crate::tempo::TempoMap>,
}
impl Region {
/// The staff instances manifested in this region.
pub fn staff_instances(&self) -> &[StaffInstance] {
self.content.staff_instances()
}
/// Whether two regions demonstrably overlap in time. Resolvable only for
/// wall-clock extents in this prototype; symbolic (event/measure/region)
/// anchors return `false` (cannot prove overlap without the full tempo and
/// measure machinery — a sound but incomplete check). Half-open intervals:
/// touching at a boundary does not overlap.
pub fn overlaps_in_time(&self, other: &Region) -> bool {
match (
self.time_extent.as_wallclock(),
other.time_extent.as_wallclock(),
) {
(Some((a0, a1)), Some((b0, b1))) => a0 < b1 && b0 < a1,
_ => false,
}
}
/// Whether the staff extents of two regions intersect.
pub fn staff_extent_intersects(&self, other: &Region) -> bool {
let mine: BTreeSet<StaffId> = self.staff_extent.staves.iter().copied().collect();
other.staff_extent.staves.iter().any(|s| mine.contains(s))
}
}
/// A global, abstract staff identity persisting across the score (Chapter 5
/// §"Staves: Identity Versus Instance").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Staff {
pub id: StaffId,
pub name: String,
pub abbreviation: Option<String>,
pub instrument: InstrumentId,
pub default_staff_lines: StaffLineConfiguration,
pub group: Option<StaffGroupId>,
}
/// The spatial root of the score (Chapter 5 §"The Canvas").
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct Canvas {
pub regions: Vec<Region>,
/// Canvas-level layout defaults — page size and margins (Chapter 5;
/// schema major 1). A solver reads these as its default page geometry and
/// MAY override per solve.
pub layout_defaults: CanvasLayoutDefaults,
}
/// Canvas-level layout defaults (Chapter 5 §"The Canvas"; schema major 1). Page
/// size and margins are in staff spaces (1 staff space = staff height / 4). The
/// [`Default`] is A4 portrait at an 8 mm staff.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct CanvasLayoutDefaults {
pub page_size: CanvasSize,
pub margins: CanvasMargins,
}
/// A page size in staff spaces.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct CanvasSize {
pub width: CanonicalF64,
pub height: CanonicalF64,
}
/// Page margins in staff spaces.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct CanvasMargins {
pub top: CanonicalF64,
pub right: CanonicalF64,
pub bottom: CanonicalF64,
pub left: CanonicalF64,
}
impl Default for CanvasLayoutDefaults {
/// **A4 portrait at an 8 mm staff** (1 staff space = 2 mm): page
/// 105 × 148.5 staff spaces, 7.5-staff-space margins (a 90 × 133.5 content
/// area) — the reference engraver's documented default geometry.
fn default() -> Self {
let ss = |v: f64| CanonicalF64::new(v).expect("finite staff-space default");
CanvasLayoutDefaults {
page_size: CanvasSize {
width: ss(105.0),
height: ss(148.5),
},
margins: CanvasMargins {
top: ss(7.5),
right: ss(7.5),
bottom: ss(7.5),
left: ss(7.5),
},
}
}
}
// --- Cross-cutting structures bearing graph references (Chapter 5). ---------
//
// Modeled here are the structures whose references the invariants check
// (invariant 10) plus ties and tuplets (invariants 16, 17). The full
// cross-cutting registry (markers, repeats, analytical annotations, comments,
// graphic gestures, lyrics, chord symbols) extends this with the same
// reference-resolution discipline.
/// A slur / phrase mark over a span of events (Chapter 5 §"Slurs").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Slur {
pub id: SlurId,
pub start_event: crate::ids::EventId,
pub end_event: crate::ids::EventId,
}
/// A beam over a sequence of events (Chapter 5 §"Beams").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Beam {
pub id: BeamId,
pub events: Vec<crate::ids::EventId>,
pub level: u8,
}
/// A generic spanning mark anchored by time (Chapter 5 §"Spanners").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Spanner {
pub id: SpannerId,
pub start: TimeAnchor,
pub end: TimeAnchor,
/// Which staves this spanner attaches to.
pub staves: Vec<StaffId>,
}
/// The class of a tie, fixing its validation profile (Chapter 5 §"Ties").
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum TieClass {
/// Same voice, immediate adjacency in voice order, enharmonic pitches.
Standard,
/// Same voice, start precedes end, intervening events permitted.
Editorial,
/// Across voices on the same staff instance; start position <= end.
CrossVoice,
/// Trailing tie with no notated end (`end_event` may equal `start_event`).
LaissezVibrer,
/// Registered class with custom validation behaviour.
Registered(crate::pitch::TieClassRegistryId),
}
/// A tie between two events, pairing pitches by stable [`PitchId`] (Chapter 5
/// §"Ties").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Tie {
pub id: crate::ids::TieId,
pub start_event: crate::ids::EventId,
pub end_event: crate::ids::EventId,
/// Explicit `(start_pitch, end_pitch)` pairing; `None` means pair all
/// pitches by enharmonic matching in pitch-id-ascending order.
pub pitch_pairing: Option<Vec<(PitchId, PitchId)>>,
pub class: TieClass,
}
/// The actual:notated ratio of a tuplet (Chapter 3 §"Tuplets"). Built only
/// through [`TupletRatio::new`], which rejects degenerate ratios at
/// construction (Chapter 3 §"Tuplets", `req:time:tuplet-ratio-construction`):
/// both terms must be nonzero and `actual != notated`, so a degenerate ratio is
/// never a representable graph state. The fields are private to keep that
/// guarantee unbypassable.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct TupletRatio {
actual: u32,
notated: u32,
}
impl TupletRatio {
/// Builds an `actual:notated` ratio, returning `None` for a *degenerate*
/// ratio — either term zero, or `actual == notated` (a ratio that expresses
/// no augmentation or diminution). This is the construction-time MUST of
/// Chapter 3 §"Tuplets": degeneracy is rejected here, not by a runtime
/// invariant.
pub fn new(actual: u32, notated: u32) -> Option<Self> {
if actual == 0 || notated == 0 || actual == notated {
None
} else {
Some(TupletRatio { actual, notated })
}
}
/// The `actual` term: how many notes are played.
#[inline]
pub const fn actual(&self) -> u32 {
self.actual
}
/// The `notated` term: in the time of how many.
#[inline]
pub const fn notated(&self) -> u32 {
self.notated
}
}
/// A tuplet grouping object (Chapter 3 §"Tuplets as Grouping Objects").
/// Tuplets do not modify member sounding durations; the ratio is notational.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Tuplet {
pub id: TupletId,
pub ratio: TupletRatio,
pub members: Vec<crate::ids::EventId>,
pub parent: Option<TupletId>,
/// The structurally-required total sounding duration of the members
/// (Chapter 3 §"Tuplet Consistency"; invariant 16). For a 3:2 eighth-note
/// triplet of three members this is `1/4`.
pub required_total: MusicalDuration,
}
/// Where a point/range annotation attaches (Chapter 5 §"Analytical
/// Annotations"). Shared by annotations and comments.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum AnnotationAnchor {
Event(crate::ids::EventId),
Range { start: TimeAnchor, end: TimeAnchor },
Region(RegionId),
}
/// A point marker: rehearsal mark, segno, tempo text, … (Chapter 5 §"Markers").
/// The visual `kind` is Chapter 7's; the load-bearing field is the anchor.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Marker {
pub id: crate::ids::MarkerId,
pub anchor: TimeAnchor,
}
/// A repeat structure: simple repeat, da capo, dal segno, volta (Chapter 5
/// §"Repeat Structures"). Spanned by two time anchors.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct RepeatStructure {
pub id: RepeatStructureId,
pub start: TimeAnchor,
pub end: TimeAnchor,
}
/// An analytical annotation (Roman numeral, form label, …) (Chapter 5
/// §"Analytical Annotations").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct AnalyticalAnnotation {
pub id: crate::ids::AnalyticalAnnotationId,
pub anchor: AnnotationAnchor,
pub layer: Option<crate::ids::AnalysisLayerId>,
}
/// A review-mode comment thread anchored to a point or range (Chapter 5
/// §"Comments").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Comment {
pub id: crate::ids::CommentId,
pub anchor: AnnotationAnchor,
pub resolved: bool,
}
/// How a graphic gesture is positioned relative to score content (Chapter 5
/// §"Graphic Gestures").
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum GestureAnchoring {
/// Anchored to events: the gesture moves with them.
Events(Vec<crate::ids::EventId>),
/// Anchored to a time and staff range.
Range {
start: TimeAnchor,
end: TimeAnchor,
staves: Vec<StaffId>,
},
/// Free canvas coordinates: does not follow score edits.
Free,
}
/// A drawn gesture spanning events, staves, or regions (Chapter 5
/// §"Graphic Gestures").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct GraphicGesture {
pub id: crate::ids::GraphicGestureId,
pub objects: Vec<crate::ids::GraphicObjectId>,
pub anchoring: GestureAnchoring,
}
/// A lyric line attached to a sequence of events (Chapter 5
/// §"Cross-Cutting Structures"). Baseline: the event references it carries.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct LyricLine {
pub id: LyricLineId,
pub events: Vec<crate::ids::EventId>,
}
/// A chord symbol anchored to a point in time (Chapter 5
/// §"Cross-Cutting Structures").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ChordSymbol {
pub id: ChordSymbolId,
pub anchor: TimeAnchor,
}
/// The cross-cutting structures of a score (Chapter 5 §"Cross-Cutting
/// Structures"): musical phenomena that hold references spanning the
/// containment tree.
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct CrossCuttingRegistry {
pub slurs: Vec<Slur>,
pub ties: Vec<Tie>,
pub beams: Vec<Beam>,
pub tuplets: Vec<Tuplet>,
pub spanners: Vec<Spanner>,
pub markers: Vec<Marker>,
pub repeats: Vec<RepeatStructure>,
pub analytical: Vec<AnalyticalAnnotation>,
pub comments: Vec<Comment>,
pub graphic_gestures: Vec<GraphicGesture>,
pub lyrics: Vec<LyricLine>,
pub chord_symbols: Vec<ChordSymbol>,
}
// --- Notational decomposition attachments (Chapter 3; invariants 14, 15). ---
/// Provenance of a decomposition attachment (Chapter 3 §"Notational
/// Decomposition"), mirroring [`crate::SpellingSource`].
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum DecompositionSource {
UserChosen,
Inferred,
Imported { format: ForeignFormatId },
Propagated { from: crate::ids::EventId },
}
/// A base notated note value (Chapter 3 §"Sounding Duration and Notational
/// Decomposition"). Each is half the previous; the whole note is `1/1`.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub enum NoteValue {
Whole,
Half,
Quarter,
Eighth,
Sixteenth,
ThirtySecond,
SixtyFourth,
}
impl NoteValue {
/// The undotted value as a fraction of a whole note (`Quarter` -> `1/4`).
pub fn whole_note_fraction(self) -> MusicalDuration {
let denom: i64 = match self {
NoteValue::Whole => 1,
NoteValue::Half => 2,
NoteValue::Quarter => 4,
NoteValue::Eighth => 8,
NoteValue::Sixteenth => 16,
NoteValue::ThirtySecond => 32,
NoteValue::SixtyFourth => 64,
};
MusicalDuration(crate::time::RationalTime::new(1, denom).expect("nonzero denom"))
}
}
/// One notated component of an event's rhythm (Chapter 3 §"The Notational
/// Decomposition"): a base value with augmentation dots, optional tuplet
/// membership, and a tie flag.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct NotatedComponent {
pub base_value: NoteValue,
/// Augmentation dots (each adds half the previous increment).
pub dots: u8,
/// Tuplet membership, if any (scales the sounding duration).
pub tuplet: Option<TupletId>,
/// Whether this component is tied to the next.
pub tied_to_next: bool,
}
impl NotatedComponent {
/// The *notated* duration (base value with dots), ignoring any tuplet
/// scaling: the undotted value plus each augmentation dot's
/// successively-halved increment, i.e. `base * (2 - 2^(-dots))`.
///
/// Computed by exact halving rather than the closed `(2^(dots+1)-1)/2^dots`
/// form, so *every* dot count receives its defined augmentation semantics:
/// the count is never silently clamped, and [`crate::RationalTime`] promotes
/// to arbitrary precision rather than overflowing the shift (Chapter 3
/// §"Sounding Duration and Notational Decomposition").
pub fn notated_duration(&self) -> MusicalDuration {
let base = self.base_value.whole_note_fraction();
let half = crate::time::RationalTime::new(1, 2).expect("nonzero");
let mut increment = base.rational().clone();
let mut total = base.rational().clone();
for _ in 0..self.dots {
increment = increment.mul(&half);
total = total.add(&increment);
}
MusicalDuration(total)
}
/// The *sounding* duration: the notated duration scaled by a tuplet ratio
/// (`actual:notated` ⇒ ×`notated/actual`) when the component is in one. An
/// eighth in a 3:2 triplet sounds `1/8 × 2/3 = 1/12`.
pub fn sounding_duration(&self, tuplet_ratio: Option<TupletRatio>) -> MusicalDuration {
let notated = self.notated_duration();
match tuplet_ratio {
Some(r) if r.actual() != 0 && r.notated() != 0 => {
let scale = crate::time::RationalTime::new(r.notated() as i64, r.actual() as i64)
.expect("validated nonzero");
MusicalDuration(notated.rational().mul(&scale))
}
_ => notated,
}
}
}
/// A notational-decomposition attachment on an event (Chapter 3): the sequence
/// of notated components whose sounding durations sum to the event's sounding
/// duration (invariant 15). The base-value/dots → duration mapping is here; the
/// *choice* of decomposition (the pre-pass) is deferred (Appendix D §"Open
/// Algorithm Hooks").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct DecompositionAttachment {
pub target: crate::ids::EventId,
pub components: Vec<NotatedComponent>,
pub source: DecompositionSource,
}
// --- Top-level score structure (Chapter 5 §"Top-Level Score Structure"). ----
//
// These carry the Chapter 5 top-level shape. Their deep bodies (tuning
// resolution, tempo curves, part layout, view recipes) are Chapters 3/4/7 and
// later companions; this baseline models the identity- and reference-bearing
// skeleton and leaves the rest as documented placeholders.
/// Bibliographic and authorship metadata (Chapter 5 §"Score Metadata"). The
/// structure is deliberately small.
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct ScoreMetadata {
pub title: Option<String>,
pub composer: Option<String>,
pub copyright: Option<String>,
}
/// An abstract instrument definition (Chapter 5 §"Instruments"). Baseline: the
/// identity and name; sound configuration and ranges are the audio engine's.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Instrument {
pub id: InstrumentId,
pub name: String,
}
/// The kind of a staff grouping (Chapter 5 §"Top-Level Score Structure").
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum StaffGroupKind {
GrandStaff,
Bracket,
SubBracket,
Choral,
/// Custom group defined by a layout extension.
Registered(crate::pitch::StaffGroupKindRegistryId),
}
/// A staff grouping (grand staff, bracket, choral group) (Chapter 5).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct StaffGroup {
pub id: StaffGroupId,
pub name: Option<String>,
pub kind: StaffGroupKind,
pub members: Vec<StaffId>,
}
/// A per-part view definition for extraction (Chapter 5 §"Parts"). Parts are
/// projections, not storage: only references and overrides.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct PartDefinition {
pub id: PartDefinitionId,
pub name: String,
pub staves: Vec<StaffId>,
}
/// A first-class analysis layer (Chapter 5 §"Analysis Layers and Views").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct AnalysisLayer {
pub id: crate::ids::AnalysisLayerId,
pub name: String,
}
/// A view recipe (Chapter 5 §"Views"). Baseline: identity, name, and active
/// layers; the view-kind detail is Chapter 7's.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ViewDefinition {
pub id: ViewId,
pub name: String,
pub active_layers: Vec<crate::ids::AnalysisLayerId>,
}
/// The score's tuning environment (Chapter 4 §"Score Tuning Context"). Baseline:
/// the default pitch space, tuning system, and reference pitch every score must
/// declare; per-scope overrides and accidental extensions are deferred.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ScoreTuningContext {
pub default_pitch_space: PitchSpaceId,
pub default_tuning_system: TuningSystemId,
pub reference: ReferencePitch,
}
impl Default for ScoreTuningContext {
fn default() -> Self {
// The default score configuration (Chapter 4 §"Default Score
// Configuration"): cmn-12 / tet-12 / A4 = 440 Hz.
ScoreTuningContext {
default_pitch_space: PitchSpaceId::new("cmn-12"),
default_tuning_system: TuningSystemId::new("tet-12"),
reference: ReferencePitch::a440(),
}
}
}
/// The root object of a score (Chapter 5 §"Top-Level Score Structure").
///
/// This carries the full Chapter 5 top-level shape. The invariant-bearing
/// structure (canvas, staves, events, cross-cutting, attachments, identity) is
/// modeled in depth; the remaining top-level fields (metadata, instruments,
/// staff groups, parts, tuning context, tempo map, analysis layers, views)
/// carry their Chapter 5 identity/reference skeleton with deeper bodies left to
/// the consuming crates (Agents C/E) and later companions.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Score {
pub metadata: ScoreMetadata,
pub canvas: Canvas,
pub instruments: Vec<Instrument>,
/// Globally-identified staves; each region's instances reference these.
pub staves: Vec<Staff>,
pub staff_groups: Vec<StaffGroup>,
pub parts: Vec<PartDefinition>,
pub cross_cutting: CrossCuttingRegistry,
/// Time-signature objects referenced by measures and meter changes.
pub time_signatures: Vec<TimeSignature>,
pub tuning_context: ScoreTuningContext,
pub tempo_map: crate::tempo::TempoMap,
pub events: EventArena,
pub spelling_attachments: Vec<SpellingAttachment>,
pub decomposition_attachments: Vec<DecompositionAttachment>,
pub spelling_precedence: crate::pitch::SpellingPrecedence,
pub analysis_layers: Vec<AnalysisLayer>,
pub views: Vec<ViewDefinition>,
pub identity: IdentityContext,
/// Pitch identifiers retained as tombstones (Chapter 6 territory; referenced
/// by invariants 13/14). A tombstoned id is preserved, never re-matched.
pub tombstoned_pitches: BTreeSet<PitchId>,
/// Event identifiers retained as tombstones.
pub tombstoned_events: BTreeSet<crate::ids::EventId>,
}
impl Score {
/// An empty score for the given identity context, with the default tuning
/// context (cmn-12 / tet-12 / A4 = 440).
pub fn empty(identity: IdentityContext) -> Self {
Score {
metadata: ScoreMetadata::default(),
canvas: Canvas::default(),
instruments: Vec::new(),
staves: Vec::new(),
staff_groups: Vec::new(),
parts: Vec::new(),
cross_cutting: CrossCuttingRegistry::default(),
time_signatures: Vec::new(),
tuning_context: ScoreTuningContext::default(),
tempo_map: crate::tempo::TempoMap::default(),
events: EventArena::new(),
spelling_attachments: Vec::new(),
decomposition_attachments: Vec::new(),
spelling_precedence: crate::pitch::SpellingPrecedence::default(),
analysis_layers: Vec::new(),
views: Vec::new(),
identity,
tombstoned_pitches: BTreeSet::new(),
tombstoned_events: BTreeSet::new(),
}
}
/// Iterates every staff instance in the score, paired with its region id.
pub fn staff_instances(&self) -> impl Iterator<Item = (RegionId, &StaffInstance)> {
self.canvas
.regions
.iter()
.flat_map(|r| r.staff_instances().iter().map(move |si| (r.id, si)))
}
/// Iterates every voice in the score, paired with its region and staff
/// instance.
pub fn voices(&self) -> impl Iterator<Item = (RegionId, StaffInstanceId, &Voice)> {
self.canvas.regions.iter().flat_map(|r| {
r.staff_instances()
.iter()
.flat_map(move |si| si.voices.iter().map(move |v| (r.id, si.id, v)))
})
}
/// The set of live pitch identifiers across the arena (every embedded
/// [`crate::IdentifiedPitch`]).
pub fn live_pitch_ids(&self) -> BTreeSet<PitchId> {
let mut set = BTreeSet::new();
let mut buf = Vec::new();
for e in self.events.iter() {
buf.clear();
e.collect_identified_pitches(&mut buf);
for ip in &buf {
set.insert(ip.id);
}
}
set
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::ReplicaId;
use crate::time::WallClockTime;
fn wc_extent(a: i64, b: i64) -> TimeExtent {
TimeExtent {
start: TimeAnchor::WallClock {
time: WallClockTime(a),
},
end: TimeAnchor::WallClock {
time: WallClockTime(b),
},
}
}
fn region(id: RegionId, staves: Vec<StaffId>, ext: TimeExtent) -> Region {
Region {
id,
time_model: RegionTimeModel::Metric(MetricTimeModel::default()),
content: RegionContent::StaffBased(StaffBasedContent::default()),
time_extent: ext,
staff_extent: StaffExtent { staves },
local_tempo_map: None,
}
}
#[test]
fn promoted_voice_id_is_deterministic_and_system_namespaced() {
let r = ReplicaId(3);
let si = StaffInstanceId::new(r, 1);
let ov = VoiceId::new(r, 2);
let win = OperationId::new(r, 10);
let lose = OperationId::new(ReplicaId(4), 11);
let a = derive_promoted_voice_id(si, ov, win, lose);
let b = derive_promoted_voice_id(si, ov, win, lose);
assert_eq!(a, b);
assert_eq!(a.replica(), ReplicaId::SYSTEM_DERIVED);
// Swapping winner and loser changes the identity (order matters).
let c = derive_promoted_voice_id(si, ov, lose, win);
assert_ne!(a, c);
}
#[test]
fn promoted_voice_id_byte_form_is_locked() {
// Golden: locks the MUSCSVCE 64-byte preimage layout (staff instance ||
// original voice || winning op || losing op, each 16 big-endian bytes)
// and the hash output. RATIFIED by Pass 11 (item 1.2, P11-3 / ops C4):
// this is the spec's golden, normative in core_spec
// §"System-Promoted Voices" (`derive_promoted_voice_id`). A change to the
// input order, byte layout, or domain tag breaks this deliberately.
let id = derive_promoted_voice_id(
StaffInstanceId::new(ReplicaId(3), 1),
VoiceId::new(ReplicaId(3), 2),
OperationId::new(ReplicaId(3), 10),
OperationId::new(ReplicaId(4), 11),
);
assert_eq!(id.replica(), ReplicaId::SYSTEM_DERIVED);
const GOLDEN: [u8; 16] = [
255, 255, 255, 255, 255, 255, 255, 255, 76, 193, 12, 43, 57, 51, 131, 242,
];
assert_eq!(id.canonical_bytes(), GOLDEN);
}
#[test]
fn wallclock_time_overlap_is_half_open() {
let r = ReplicaId(1);
let s0 = StaffId::new(r, 0);
let a = region(RegionId::new(r, 0), vec![s0], wc_extent(0, 100));
let b = region(RegionId::new(r, 1), vec![s0], wc_extent(100, 200));
let c = region(RegionId::new(r, 2), vec![s0], wc_extent(50, 150));
assert!(
!a.overlaps_in_time(&b),
"touching at boundary is not overlap"
);
assert!(a.overlaps_in_time(&c));
assert!(a.staff_extent_intersects(&c));
}
#[test]
fn coordinate_discipline_follows_time_model() {
assert_eq!(
RegionTimeModel::Metric(MetricTimeModel::default()).coordinate_discipline(),
CoordinateDiscipline::Musical
);
assert_eq!(
RegionTimeModel::Proportional(ProportionalTimeModel {
duration: WallClockDuration(0)
})
.coordinate_discipline(),
CoordinateDiscipline::WallClock
);
}
#[test]
fn time_signature_rejects_mismatched_beat_groups() {
let r = ReplicaId(1);
let id = TimeSignatureId::new(r, 1);
let q = || MusicalDuration(crate::time::RationalTime::new(1, 4).unwrap());
let bg = |d| BeatGroup {
duration: d,
subdivision: None,
accent: 0,
};
// 4/4: four quarter beat groups sum to a whole-note measure.
assert!(TimeSignature::new(
id,
TimeSignatureDisplay::Standard {
numerator: 4,
denominator: PowerOfTwo::new(4).unwrap()
},
MusicalDuration::whole(),
vec![bg(q()), bg(q()), bg(q()), bg(q())],
)
.is_some());
// Three quarters do not sum to a whole note -> rejected.
assert!(TimeSignature::new(
id,
TimeSignatureDisplay::Standard {
numerator: 4,
denominator: PowerOfTwo::new(4).unwrap()
},
MusicalDuration::whole(),
vec![bg(q()), bg(q()), bg(q())],
)
.is_none());
}
#[test]
fn notated_component_durations() {
// Undotted quarter = 1/4.
let c = NotatedComponent {
base_value: NoteValue::Quarter,
dots: 0,
tuplet: None,
tied_to_next: false,
};
assert_eq!(
c.notated_duration(),
MusicalDuration(crate::time::RationalTime::new(1, 4).unwrap())
);
// Dotted quarter = 3/8.
let dotted = NotatedComponent {
dots: 1,
..c.clone()
};
assert_eq!(
dotted.notated_duration(),
MusicalDuration(crate::time::RationalTime::new(3, 8).unwrap())
);
// An eighth in a 3:2 triplet sounds 1/8 × 2/3 = 1/12.
let trip = NotatedComponent {
base_value: NoteValue::Eighth,
dots: 0,
tuplet: None,
tied_to_next: false,
};
assert_eq!(
trip.sounding_duration(Some(TupletRatio {
actual: 3,
notated: 2
})),
MusicalDuration(crate::time::RationalTime::new(1, 12).unwrap())
);
}
#[test]
fn power_of_two_denominator_rejects_zero_and_non_powers() {
assert!(PowerOfTwo::new(0).is_none());
assert!(PowerOfTwo::new(3).is_none());
assert!(PowerOfTwo::new(6).is_none());
assert_eq!(PowerOfTwo::new(1).unwrap().get(), 1);
assert_eq!(PowerOfTwo::new(8).unwrap().get(), 8);
}
#[test]
fn dot_counts_are_not_silently_clamped() {
let comp = |dots| NotatedComponent {
base_value: NoteValue::Quarter,
dots,
tuplet: None,
tied_to_next: false,
};
// Each dot count yields its defined augmentation, distinct from the next:
// dots=8 and dots=9 differ (the old code clamped both to 8).
assert_ne!(comp(8).notated_duration(), comp(9).notated_duration());
assert_ne!(comp(8).notated_duration(), comp(255).notated_duration());
// Triple-dotted quarter = 1/4 · (1 + 1/2 + 1/4 + 1/8) = 15/32.
assert_eq!(
comp(3).notated_duration(),
MusicalDuration(crate::time::RationalTime::new(15, 32).unwrap())
);
}
#[test]
fn event_ordering_dag_rejects_cycles_at_construction() {
use std::collections::BTreeMap;
let r = ReplicaId(1);
let a = crate::ids::EventId::new(r, 1);
let b = crate::ids::EventId::new(r, 2);
let c = crate::ids::EventId::new(r, 3);
// a -> b -> c is acyclic.
let mut acyclic = BTreeMap::new();
acyclic.insert(a, vec![b]);
acyclic.insert(b, vec![c]);
assert!(EventOrderingDAG::try_new(acyclic).is_some());
// a -> b -> a is a cycle, rejected.
let mut cyclic = BTreeMap::new();
cyclic.insert(a, vec![b]);
cyclic.insert(b, vec![a]);
assert!(EventOrderingDAG::try_new(cyclic).is_none());
// A self-loop is a cycle.
let mut selfloop = BTreeMap::new();
selfloop.insert(a, vec![a]);
assert!(EventOrderingDAG::try_new(selfloop).is_none());
// The empty DAG is acyclic.
assert!(EventOrderingDAG::default().is_acyclic());
}
}