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

2184 lines
83 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, PitchRange, PitchSpaceId, ReferencePitch, SpellingAttachment,
TranspositionInterval, 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,
/// Schema major 2 (appended; migration default 1.0).
pub line_spacing: SpaceUnit,
/// Schema major 2 (appended; migration default `Solid`).
pub line_style: LineStyle,
/// Schema major 2 (appended; migration default `None`). A per-staff
/// bracket adornment, distinct from StaffGroup-level bracketing.
pub bracket: Option<StaffBracketKind>,
}
impl Default for StaffLineConfiguration {
fn default() -> Self {
StaffLineConfiguration {
line_count: 5,
line_spacing: SpaceUnit::normal(),
line_style: LineStyle::Solid,
bracket: None,
}
}
}
/// A per-staff bracket adornment (Chapter 5; schema major 2).
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum StaffBracketKind {
Brace,
Bracket,
}
/// The SMuFL clef family a [`Clef`] draws from. The reference pitch each family
/// fixes (G4 / F3 / middle&nbsp;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").
///
/// Genesis tranche G3b (`spec/CONTRACT_GENESIS_G3B_MEASURE.md`): `CreateMeasure`
/// is the sole authoring path, and it is append-only — a measure is always
/// pushed at the end of the owning [`StaffInstance::measures`], never
/// inserted. `time_signature: None` means *inherit* the effective grid's
/// active signature at this measure's start; that inherited meter still
/// governs boundary consistency (invariant 20's second clause) even though
/// `None` exempts a measure from the first clause (agreement). Invariant 20's
/// agreement and boundary checks ABSTAIN — emit no violation — where the
/// comparison or delta they need is not computable (contract pin 7): this is
/// deliberate, not a safety property, because base-ingested data may predate
/// the rule. Pickup/anacrusis (a partial first measure) is deferred
/// (P13-S19): a first measure skips only the predecessor-dependent checks
/// — invariant 20's boundary clause and `create_measure`'s clauses 1 and 3
/// — plus the agreement check when it declares `None` or a matching
/// signature; it can still be refused for other reasons (a dead parent, an
/// unresolving referent). Its own successor is unaffected by any of this:
/// the successor is measured against the governing signature's full
/// `measure_duration()` regardless, and is refused (`MeasureMeterMismatch`)
/// and flagged by invariant 20 when the pickup's actual length is shorter
/// than a full bar.
#[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). Append-only: `CreateMeasure` (genesis tranche G3b) always
/// pushes at the end, gated on the effective grid's agreement and
/// boundary-distance preconditions (contract pin 9).
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>,
/// Whether slurs may cross this region's boundary (schema major 1). `false`
/// is the default and today's behavior — the CreateCrossCutting(Slur)
/// advisory precondition reports a slur whose endpoints lie in different
/// regions unless **both** endpoint regions set this flag (a boundary is
/// permeable only when neither side forbids it; core spec §6.10
/// CreateCrossCutting, Slur bucket). Advisory: it never alters reduction.
pub permits_spanning_slurs: bool,
}
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,
/// Which staff group (if any) this staff belongs to. **The sole authority
/// for group membership** (genesis tranche G3a,
/// `spec/CONTRACT_GENESIS_G3A_ENTITIES.md` §1.1, disposition B, filed as
/// P13-S16): every consumer MUST read membership from this field, not
/// from [`StaffGroup::members`], which is a non-authoritative denormalized
/// projection that may disagree with this field in either direction.
pub group: Option<StaffGroupId>,
/// Default clef for new instances of this staff (schema major 2,
/// appended last per the wire rule; migration default treble).
pub default_clef: Clef,
}
/// 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 dimension in staff spaces (Chapter 5; schema major 2). Staff line
/// spacing is relative to the global staff space: 1.0 is a normal-size
/// staff, smaller values yield cue/ossia staves. The wire form is the
/// wrapped [`CanonicalF64`]'s (the newtype adds no bytes).
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct SpaceUnit(pub CanonicalF64);
impl SpaceUnit {
/// The normal-size staff spacing, 1.0 — the v1→v2 migration default
/// for `StaffLineConfiguration.line_spacing`.
pub fn normal() -> Self {
SpaceUnit(CanonicalF64::new(1.0).expect("1.0 is finite"))
}
}
/// A line drawing style (Chapter 5; schema major 2). Shared by staff
/// lines and [`SpanStyle`].
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
pub enum LineStyle {
#[default]
Solid,
Dashed,
Dotted,
}
/// The class of a slur (Chapter 5 §"Slurs"; schema major 2). The
/// v1→v2 migration default is `Legato`.
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
pub enum SlurKind {
/// An ordinary legato slur.
#[default]
Legato,
/// A phrase mark (typically longer, over sub-phrases).
Phrase,
/// An articulation slur (e.g., over a two-note sigh figure).
Articulation,
/// An editorial slur (rendered distinctly, e.g., dashed).
Editorial,
}
/// Which side of the notes a curve arcs toward (Chapter 5; schema
/// major 2).
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum CurveDirection {
Above,
Below,
}
/// An authored curvature override (Chapter 5 §"Slurs"; schema major 2).
/// The engraver computes default curvature; each present field
/// overrides that component. Consumed by the Standard engraving tier;
/// stored and preserved at every tier.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct CurvatureOverride {
pub direction: Option<CurveDirection>,
/// Arc height at the apex.
pub height: Option<SpaceUnit>,
}
/// The visual style of a spanning mark (Chapter 5; schema major 2):
/// one shared record for `Slur.style`, `Tie.style`, and
/// `Spanner.style`. Defaults: solid, engraver-chosen thickness.
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct SpanStyle {
pub line: LineStyle,
/// Line thickness; `None` = the engraver's default.
pub thickness: Option<SpaceUnit>,
}
/// 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,
/// Schema major 2 (appended; migration default `Legato`).
pub kind: SlurKind,
/// Schema major 2 (appended; migration default `None`).
pub curvature_override: Option<CurvatureOverride>,
/// Schema major 2 (appended; migration default `SpanStyle::default()`).
pub style: SpanStyle,
}
/// A beam segment at a deeper subdivision level (Chapter 5 §"Beams";
/// schema major 2): a contiguous subset of the owning beam's events
/// beamed together at `level` (strictly deeper than the owner's
/// primary level).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct SubBeam {
pub level: u8,
pub events: Vec<crate::ids::EventId>,
}
/// An authored beam-geometry override (Chapter 5 §"Beams"; schema
/// major 2). Each present field overrides the engraver's computed
/// geometry. Consumed by the Standard engraving tier; stored and
/// preserved at every tier.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct BeamGeometryOverride {
/// Beam slope: staff spaces of rise per staff space of run
/// (dimensionless, hence not a [`SpaceUnit`]).
pub slope: Option<CanonicalF64>,
/// Vertical displacement from the default placement (positive = up).
pub offset: Option<SpaceUnit>,
}
/// 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,
/// Schema major 2 (appended; migration default empty).
pub sub_beams: Vec<SubBeam>,
/// Schema major 2 (appended; migration default `None`).
pub geometry_override: Option<BeamGeometryOverride>,
}
/// Hairpin orientation (Chapter 5 §"Spanners"; schema major 2).
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum HairpinDirection {
Crescendo,
Diminuendo,
}
/// Octave-line displacement in signed octaves (Chapter 5 §"Spanners";
/// schema major 2): +1 = 8va, -1 = 8vb, +2 = 15ma, -2 = 15mb. Zero is
/// representable but degenerate; the authoring advisory layer flags
/// it, reduction does not.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct OctaveOffset(pub i8);
/// Pedal-line kind (Chapter 5 §"Spanners"; schema major 2).
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum PedalKind {
Sustain,
Sostenuto,
UnaCorda,
}
/// A text line's content (Chapter 5 §"Spanners"; schema major 2); the
/// dash pattern comes from the spanner's style.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct TextLineDefinition {
pub text: String,
}
/// A bracket spanner's shape (Chapter 5 §"Spanners"; schema major 2).
/// Growth is by appended variant.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum BracketKind {
Square,
}
/// The kind of a spanner (Chapter 5 §"Spanners"; schema major 2).
/// `Generic` is deliberately first: it is the v1→v2 migration default
/// — a v1 spanner carried no kind, and `Generic` (a plain line or
/// bracket) is the honest translation of that absence.
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub enum SpannerKind {
/// An unclassified spanning mark: renders as a plain line/bracket.
#[default]
Generic,
Hairpin(HairpinDirection),
OctaveLine(OctaveOffset),
PedalLine(PedalKind),
TrillExtension,
Glissando,
Portamento,
TextLine(TextLineDefinition),
Bracket(BracketKind),
}
/// 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>,
/// Schema major 2 (appended after `staves` per the wire rule;
/// migration default `Generic`).
pub kind: SpannerKind,
/// Schema major 2 (appended; migration default `SpanStyle::default()`).
pub style: SpanStyle,
}
/// 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,
/// Schema major 2 (appended; migration default `SpanStyle::default()`).
pub style: SpanStyle,
}
/// 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,
}
/// The kind of a repeat structure (Chapter 5 §"Repeat Structures";
/// schema major 2). The v1→v2 migration default is
/// `SimpleRepeat { count: 2 }`: a v1 repeat *meant* a repeat, and
/// playing the span twice is the conventional semantics of an
/// unadorned repeat sign.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum RepeatKind {
SimpleRepeat {
count: u32,
},
DaCapo {
end_target: TimeAnchor,
},
DalSegno {
segno: TimeAnchor,
end_target: TimeAnchor,
},
Volta,
}
impl RepeatKind {
/// The v1→v2 migration default (Binary Format §Schema Major 2).
pub const fn migration_default() -> Self {
RepeatKind::SimpleRepeat { count: 2 }
}
}
/// One volta bracket (Chapter 5 §"Repeat Structures"; schema major 2):
/// the passes it applies on and the time span it covers. The `endings`
/// constraints (non-empty, 1-based, strictly ascending) are advisory —
/// decoders and reduction accept violations, the authoring validation
/// layer flags them.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Volta {
/// The pass numbers this ending plays on (1-based), ascending.
pub endings: Vec<u32>,
pub start: TimeAnchor,
pub end: 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,
/// Schema major 2 (appended; migration default
/// [`RepeatKind::migration_default`]).
pub kind: RepeatKind,
/// Schema major 2 (appended; migration default empty).
pub voltas: Vec<Volta>,
}
impl RepeatStructure {
/// Every [`TimeAnchor`] site this structure carries: `start`/`end`, the
/// kind's jump targets (`DaCapo.end_target`,
/// `DalSegno.segno`/`end_target`), and each volta's span.
///
/// THE single site-set walk: reduction's re-anchoring (rule table row
/// "Repeat structure / Anchor"), the editor's barrier seam, the
/// invariant anchor walk, and the cross-reference index all consume
/// this, so the site set cannot drift between them when a future
/// revision adds an anchor-bearing field (an exhaustive `RepeatKind`
/// match protects only against new *variants*).
pub fn anchor_sites(&self) -> Vec<&TimeAnchor> {
let mut sites = vec![&self.start, &self.end];
match &self.kind {
RepeatKind::SimpleRepeat { .. } | RepeatKind::Volta => {}
RepeatKind::DaCapo { end_target } => sites.push(end_target),
RepeatKind::DalSegno { segno, end_target } => {
sites.push(segno);
sites.push(end_target);
}
}
for volta in &self.voltas {
sites.push(&volta.start);
sites.push(&volta.end);
}
sites
}
/// The mutable sibling of [`RepeatStructure::anchor_sites`], for
/// re-anchoring rewrites.
pub fn anchor_sites_mut(&mut self) -> Vec<&mut TimeAnchor> {
let mut sites: Vec<&mut TimeAnchor> = vec![&mut self.start, &mut self.end];
match &mut self.kind {
RepeatKind::SimpleRepeat { .. } | RepeatKind::Volta => {}
RepeatKind::DaCapo { end_target } => sites.push(end_target),
RepeatKind::DalSegno { segno, end_target } => {
sites.push(segno);
sites.push(end_target);
}
}
for volta in &mut self.voltas {
sites.push(&mut volta.start);
sites.push(&mut volta.end);
}
sites
}
}
/// 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.
/// A calendar timestamp (Chapter 5 §"Score Metadata"; schema major 2):
/// nanoseconds since the Unix epoch, UTC, no zone. Distinct from
/// [`crate::WallClockTime`], which is *performance* time within a
/// score. Zero is the "unset" convention. Strictly authored
/// (`req:graph:metadata-timestamps`): nothing writes these implicitly.
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
pub struct Timestamp(pub i64);
/// The value of an additional metadata entry (Chapter 5 §"Score
/// Metadata"; schema major 2). Closed small union; growth by appended
/// variant.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum MetadataValue {
Text(String),
Integer(i64),
Flag(bool),
}
/// One additional metadata entry (Chapter 5 §"Score Metadata"; schema
/// major 2). `ScoreMetadata.additional` is an ordered authored *list*,
/// not a map: order is preserved verbatim and duplicate keys are
/// permitted (foreign formats carry repeated keys); map-seeking
/// consumers take the first entry per key.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct MetadataEntry {
pub key: String,
pub value: MetadataValue,
}
/// 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>,
/// Schema major 2 (appended; migration default `None`).
pub subtitle: Option<String>,
/// Schema major 2 (appended; migration default `None`).
pub lyricist: Option<String>,
/// Schema major 2 (appended; migration default `None`).
pub arranger: Option<String>,
/// Schema major 2 (appended; migration default unset/zero).
pub creation_timestamp: Timestamp,
/// Schema major 2 (appended; migration default unset/zero).
pub modification_timestamp: Timestamp,
/// Schema major 2 (appended; migration default empty).
pub additional: Vec<MetadataEntry>,
}
/// Opaque sound configuration (Chapter 5 §"Instruments"; schema
/// major 2). The audio engine specification owns the structure; the
/// core stores the bytes verbatim and never interprets them.
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct SoundConfiguration(pub Vec<u8>);
/// One playable member of an unpitched instrument (Chapter 5
/// §"Instruments"; schema major 2). `member` is an instrument-scoped
/// small value (not a 128-bit object id); resolution from
/// `UnpitchedEvent.instrument_member` is by first match in list order,
/// and a no-match is tolerated (every pre-major-2 instrument has an
/// empty member list while its events carry member values) — the
/// event's own `staff_position` governs placement either way; the
/// member's is the authoring default copied onto new events.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct UnpitchedMember {
pub member: crate::event::UnpitchedMemberId,
pub name: String,
pub staff_position: crate::event::StaffPosition,
}
/// An abstract instrument definition (Chapter 5 §"Instruments"). Baseline: the
/// identity and name; sound configuration is the audio engine's.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Instrument {
pub id: InstrumentId,
pub name: String,
/// The instrument's declared playable pitch range, if any (schema major 1).
/// `None` is "no declared range" — the InsertEvent pitch-in-range advisory
/// precondition is then a vacuous pass (core spec §6.10, "if any"). A
/// spanning-frame candidate outside the range trips the advisory check in
/// authoring mode only (see [`PitchRange::contains`]).
pub range: Option<PitchRange>,
/// Schema major 2 (appended after the major-1 order per the wire
/// rule; migration default `None`).
pub abbreviation: Option<String>,
/// Schema major 2 (appended; migration default empty).
pub sound_config: SoundConfiguration,
/// The written-versus-sounding interval of a transposing instrument (a
/// B-flat clarinet is `-1` diatonic, `-2` chromatic). Schema major 2
/// (appended; migration default `None`).
///
/// Its *action* on a pitch is pinned by [`crate::Pitch::transposed`]
/// (`req:pitch:transposition`). What is still unimplemented is its
/// *automatic application* at the written/sounding boundary: nothing here
/// respells a written part into a sounding one, or resolves either to a
/// frequency. So the field remains advisory — for that reason, and not,
/// as this doc once claimed, for want of interval algebra.
pub transposition: Option<TranspositionInterval>,
/// Schema major 2 (appended; migration default treble).
pub default_clef: Clef,
/// Schema major 2 (appended; migration default the 5-line default).
pub default_staff_lines: StaffLineConfiguration,
/// Schema major 2 (appended; migration default empty).
pub unpitched_members: Vec<UnpitchedMember>,
}
impl Instrument {
/// A minimal instrument: identity and name, every other field at its
/// canonical default (the schema-major-2 migration defaults).
pub fn new(id: InstrumentId, name: impl Into<String>) -> Self {
Instrument {
id,
name: name.into(),
range: None,
abbreviation: None,
sound_config: SoundConfiguration::default(),
transposition: None,
default_clef: Clef::treble(),
default_staff_lines: StaffLineConfiguration::default(),
unpitched_members: Vec::new(),
}
}
}
/// 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,
/// A **non-authoritative denormalized projection** of group membership
/// (genesis tranche G3a, `spec/CONTRACT_GENESIS_G3A_ENTITIES.md` §1.1,
/// disposition B, filed as P13-S16). [`Staff::group`] is the sole
/// authority: this field MUST NOT be read to decide whether a staff is in
/// a group, and MAY be stale in **both** directions — a member missing
/// here while `Staff.group` names this group, or a staff listed here
/// while its own `Staff.group` is `None` or names a different group.
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 land here (Push 4b tranche 2); accidental
/// registry extensions and the SMuFL version requirement land here too (Push
/// 4b tranche 3a). `smufl` and `overrides` reach the wire as of tranche 3b-i;
/// `accidental_extensions` stays in memory only.
///
/// **Wire note (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`; tranche
/// 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`; tranche 3b-i,
/// `spec/CONTRACT_PUSH4B_3BI_WIRE.md`, schema major 3).** The canonical
/// encoding is `default_pitch_space` ⌢ `default_tuning_system` ⌢ `reference`
/// (the frozen major-0..2 prefix) ⌢ `smufl` ⌢ `overrides` — append-after-existing,
/// per the frozen-layout rule. `accidental_extensions` is **not** on the wire:
/// it is staged to a later major, which will append it *after* `overrides`
/// when it lands (its own consumer, the engraver, does not exist yet). See
/// the hand-written `impl Codec` in `codec.rs` and `impl TextValue` in
/// `textvalue_graph.rs` (the manual codec predates this bump and stays manual
/// now for the one remaining in-memory field, `accidental_extensions`, since
/// `struct_codec!`'s generated decoder cannot build a value from fewer fields
/// than it declares). Where `accidental_extensions` sits in *this* Rust
/// struct is free — the manual codec fixes the wire order independently of
/// field declaration order — but it is declared here in the specification's
/// eventual field order (`accidental_extensions`, `smufl`, `overrides`) for
/// readability.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ScoreTuningContext {
pub default_pitch_space: PitchSpaceId,
pub default_tuning_system: TuningSystemId,
pub reference: ReferencePitch,
/// Score-local accidental-registry extensions (Chapter 4 §"Score-Local
/// Extensions", `:3228`). **In memory only**: staged out of schema major 3
/// and appended after `overrides` whenever a later major lands it — see
/// the wire note above. Neither the binary codec nor the text projection
/// carries it.
pub accidental_extensions: Vec<crate::accidental::ScoreAccidentalExtensions>,
/// The SMuFL version this score requires (Chapter 4 §"SMuFL Versioning",
/// `req:tuning:smufl-version-fallback`). **On the wire** as of schema
/// major 3 (tranche 3b-i), and projected to text alongside it — see the
/// wire note above.
pub smufl: crate::accidental::SmuflVersionRequirement,
/// Per-scope overrides consulted by the tuning resolver
/// (`crate::tuning::resolve_pitch_frequency`), scopes 2-4 of
/// `req:tuning:tuning-resolution-order`. **On the wire** as of schema
/// major 3 (tranche 3b-i), and projected to text alongside it — see the
/// wire note above. Note that reaching the wire is not the same as being
/// canonically persistable: no operation authors this field and the
/// canonical base does not carry it, so today it survives only in the
/// (regenerable) acceleration snapshot — P13-S13.
pub overrides: Vec<crate::tuning::TuningOverride>,
}
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(),
accidental_extensions: Vec::new(),
smufl: crate::accidental::SmuflVersionRequirement::default(),
overrides: Vec::new(),
}
}
}
/// The **authored subset** of [`ScoreTuningContext`] that `SetTuningContext`
/// (genesis tranche G2b, `spec/CONTRACT_GENESIS_G2B_TUNING.md` §1) carries:
/// exactly the five fields `ScoreTuningContext`'s `Codec` actually walks onto
/// the wire (`codec.rs`'s hand-written `impl Codec for ScoreTuningContext`),
/// in that same order. `accidental_extensions` is **not** a field of this
/// type — it is deliberately absent, not cleared or normalized.
///
/// **Why a new type rather than reusing `ScoreTuningContext` directly.**
/// `ScoreTuningContext`'s `Codec` drops `accidental_extensions` on encode and
/// default-fills it to `Vec::new()` on decode: the field is staged out of
/// schema major 3 and stays in-memory-only. If an operation carried the full
/// `ScoreTuningContext`, `OperationSet::accept` would store the authored
/// envelope as a **value** (with `accidental_extensions` intact), while a
/// document reloaded from bytes would decode the same envelope with that
/// field reconstructed as empty — two divergent graph states from one
/// document, observable only by whether you just authored it or reloaded it.
/// A field that never reaches the wire cannot be caught by
/// `canonical_value!`'s decode → `finish()` → re-encode byte comparison,
/// because that comparison never touches the originating value.
///
/// This type makes the divergence **unrepresentable**: it has no
/// `accidental_extensions` field to diverge on. `SetTuningContext`'s
/// reduction writes exactly these five fields onto `score.tuning_context` and
/// leaves `accidental_extensions` untouched, preserving whatever the graph
/// already held. This is a **type-level narrowing, not a new wire form** —
/// `TuningContextSettings`'s canonical encoding is byte-for-byte identical to
/// `ScoreTuningContext`'s existing five-field walk (asserted in
/// `codec.rs`), so the packet designs no new layout.
///
/// When a later schema major lands `accidental_extensions` on the wire, this
/// type gains the field like any other major payload change — the same cost
/// normalization would have paid, but without making the never-authored /
/// authored-to-default distinction depend on a clearing discipline enforced
/// by nothing the compiler can see.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct TuningContextSettings {
pub default_pitch_space: PitchSpaceId,
pub default_tuning_system: TuningSystemId,
pub reference: ReferencePitch,
pub smufl: crate::accidental::SmuflVersionRequirement,
pub overrides: Vec<crate::tuning::TuningOverride>,
}
/// 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,
permits_spanning_slurs: false,
}
}
#[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());
}
}
/// Genesis tranche G3a (`spec/CONTRACT_GENESIS_G3A_ENTITIES.md` pin 4b): the
/// authority-rule doc-comment guards for `Staff.group` and
/// `StaffGroup.members`.
#[cfg(test)]
mod g3a_tests {
const SOURCE: &str = include_str!("graph.rs");
/// The production portion of this file only, ending right before the
/// first `#[cfg(test)]` module. Every needle this module searches for is
/// itself written, as a string literal, inside *this* test module — so
/// searching the whole file (including this module) risks the exact
/// self-matching trap `spec/CONTRACT_GENESIS_G3A_ENTITIES.md` §4 warns
/// about: a needle that matches the guard's own source cannot fail.
/// Restricting the haystack to the code *above* any test module removes
/// that risk structurally, rather than relying on needle length alone.
fn production_source() -> &'static str {
SOURCE
.split_once("#[cfg(test)]")
.map(|(before, _)| before)
.expect("this file contains at least one #[cfg(test)] module")
}
/// (t14) `Staff.group`'s doc comment states it is authoritative for
/// group membership. Grep-assert, **sliced to that field's doc block
/// only** — `graph.rs` mentions `group` throughout, so a file-wide
/// search cannot fail.
///
/// **Mutation:** delete the doc comment (revert to no doc comment on
/// this field, its pre-G3a state); must fail.
#[test]
fn t14_staff_group_field_doc_comment_states_sole_authority() {
let source = production_source();
let start = source
.find(" /// Which staff group (if any) this staff belongs to.")
.expect("Staff.group's doc comment is present");
let end = source[start..]
.find("pub group: Option<StaffGroupId>,")
.map(|offset| start + offset)
.expect("the `group` field declaration follows its doc comment");
let doc_block = &source[start..end];
assert!(
doc_block.contains("sole authority"),
"Staff.group's doc comment must state it is the sole authority; block was:\n{doc_block}"
);
}
/// (t14) `StaffGroup.members`'s doc comment states it is a
/// non-authoritative projection that may be stale in **both**
/// directions. Grep-assert, **sliced to that field's doc block only** —
/// same discipline as the `Staff.group` guard above.
///
/// **Mutation:** delete the doc comment (revert to no doc comment on
/// this field, its pre-G3a state); must fail.
#[test]
fn t14_staff_group_members_field_doc_comment_states_non_authoritative_projection() {
let source = production_source();
let start = source
.find(" /// A **non-authoritative denormalized projection** of group")
.expect("StaffGroup.members's doc comment is present");
let end = source[start..]
.find("pub members: Vec<StaffId>,")
.map(|offset| start + offset)
.expect("the `members` field declaration follows its doc comment");
let doc_block = &source[start..end];
assert!(
doc_block.contains("non-authoritative"),
"StaffGroup.members's doc comment must state it is non-authoritative; block was:\n{doc_block}"
);
assert!(
doc_block.contains("MUST NOT be read"),
"StaffGroup.members's doc comment must forbid reading it to decide membership; block was:\n{doc_block}"
);
assert!(
doc_block.contains("both** directions"),
"StaffGroup.members's doc comment must permit staleness in both directions; block was:\n{doc_block}"
);
}
}