//! 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, } 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 { 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, } /// 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 { 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, 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, /// 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, } 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, ) -> Option { 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, } /// The metric time model of a region (Chapter 3 §"Metric Time"). #[derive(Clone, PartialEq, Eq, Debug, Default)] pub struct MetricTimeModel { pub meters: Vec, 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>, } 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>, ) -> Option { 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> { &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 { 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 = std::collections::BTreeMap::new(); // Stack entries: (node, expanded?) — expanded marks the post-visit. let nodes: Vec = 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, /// 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::(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, pub default_stem_direction: Option, 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, pub explicit_number: Option, 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, pub clef_sequence: Vec, pub key_sequence: Vec, pub local_metric_grid: Option, /// 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, pub instrument_override: Option, pub staff_lines_override: Option, 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, } /// 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, pub default_metric_grid: Option, pub barline_alignment_groups: Vec, pub user_system_breaks: Vec, pub user_page_breaks: Vec, } /// 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> { 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, } /// 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, /// 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 = 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, 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, /// 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, /// 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, /// Arc height at the apex. pub height: Option, } /// 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, } /// 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, /// 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, } /// 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, /// Vertical displacement from the default placement (positive = up). pub offset: Option, } /// A beam over a sequence of events (Chapter 5 §"Beams"). #[derive(Clone, PartialEq, Eq, Debug)] pub struct Beam { pub id: BeamId, pub events: Vec, pub level: u8, /// Schema major 2 (appended; migration default empty). pub sub_beams: Vec, /// Schema major 2 (appended; migration default `None`). pub geometry_override: Option, } /// 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, /// 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>, 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 { 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, pub parent: Option, /// 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, 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, } 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, } /// 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), /// Anchored to a time and staff range. Range { start: TimeAnchor, end: TimeAnchor, staves: Vec, }, /// 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, 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, } /// 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, pub ties: Vec, pub beams: Vec, pub tuplets: Vec, pub spanners: Vec, pub markers: Vec, pub repeats: Vec, pub analytical: Vec, pub comments: Vec, pub graphic_gestures: Vec, pub lyrics: Vec, pub chord_symbols: Vec, } // --- 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, /// 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) -> 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, 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, pub composer: Option, pub copyright: Option, /// Schema major 2 (appended; migration default `None`). pub subtitle: Option, /// Schema major 2 (appended; migration default `None`). pub lyricist: Option, /// Schema major 2 (appended; migration default `None`). pub arranger: Option, /// 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, } /// 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); /// 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, /// Schema major 2 (appended after the major-1 order per the wire /// rule; migration default `None`). pub abbreviation: Option, /// 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, /// 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, } 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) -> 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, 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, } /// 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, } /// 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, } /// 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, /// 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, } 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, } /// 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, /// Globally-identified staves; each region's instances reference these. pub staves: Vec, pub staff_groups: Vec, pub parts: Vec, pub cross_cutting: CrossCuttingRegistry, /// Time-signature objects referenced by measures and meter changes. pub time_signatures: Vec, pub tuning_context: ScoreTuningContext, pub tempo_map: crate::tempo::TempoMap, pub events: EventArena, pub spelling_attachments: Vec, pub decomposition_attachments: Vec, pub spelling_precedence: crate::pitch::SpellingPrecedence, pub analysis_layers: Vec, pub views: Vec, 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, /// Event identifiers retained as tombstones. pub tombstoned_events: BTreeSet, } 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 { 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 { 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 { 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, 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,") .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,") .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}" ); } }