2184 lines
83 KiB
Rust
2184 lines
83 KiB
Rust
//! 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 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}"
|
||
);
|
||
}
|
||
}
|