diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index f30e707..97bdeb3 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -2101,6 +2101,9 @@ canonical_value! { // fingerprint (a normative surface, vs. its Debug form). DecompositionAttachment, SpellingSourceKind, + // Score settings (M2d) — value-typed set-* payloads embed these. + ScoreMetadata, + MetricGrid, } #[cfg(test)] diff --git a/crates/epiphany-ops/src/decode.rs b/crates/epiphany-ops/src/decode.rs index 71f48fd..eeb0965 100644 --- a/crates/epiphany-ops/src/decode.rs +++ b/crates/epiphany-ops/src/decode.rs @@ -539,6 +539,19 @@ pub(crate) fn decode_materialized_state(bytes: &[u8]) -> Result(&mut reader, 16, "RegionId")?; + let anchor = musical_position(&mut reader)?; + let present = match reader.byte()? { + 0 => false, + 1 => true, + value => return Err(MaterializedDecodeError::InvalidBoolean(value)), + }; + page_breaks.insert((region, anchor), present); + } + let pending_count = reader.len()?; let mut pending = Vec::with_capacity(pending_count.min(1024)); for _ in 0..pending_count { @@ -556,6 +569,7 @@ pub(crate) fn decode_materialized_state(bytes: &[u8]) -> Result PitchId { /// Generates a random payload over the shared id space. fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { - let kind = match rng.below(18) { + let kind = match rng.below(21) { 0 => { let voice = VoiceId::new(ReplicaId(7), rng.below(3)); let position = MusicalPosition(RationalTime::from_int(rng.below(4) as i32)); @@ -184,9 +185,25 @@ fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { staff_instance: StaffInstanceId::new(ReplicaId(7), rng.below(3)), voice: valuegen::voice(VoiceId::new(ReplicaId(7), rng.below(3))), }), - _ => OperationKind::DeleteVoice(DeleteVoiceOp { + 17 => OperationKind::DeleteVoice(DeleteVoiceOp { voice: VoiceId::new(ReplicaId(7), rng.below(3)), }), + // Group 4 (M2d): score settings over the shared id space. + 18 => OperationKind::SetMetadata(SetMetadataOp { + metadata: valuegen::score_metadata(rng.below(3) as u8), + }), + 19 => OperationKind::SetMetricGrid(SetMetricGridOp { + region: RegionId::new(ReplicaId(7), rng.below(3)), + grid: rng.chance(2).then(valuegen::metric_grid), + }), + _ => OperationKind::SetUserPageBreak(SetUserPageBreakOp { + region: RegionId::new(ReplicaId(7), 0), + anchor: valuegen::region_start_anchor( + RegionId::new(ReplicaId(7), 0), + MusicalPosition(RationalTime::from_int(rng.below(4) as i32)), + ), + present: rng.chance(2), + }), }; OperationPayload::Primitive(kind) } diff --git a/crates/epiphany-ops/src/lib.rs b/crates/epiphany-ops/src/lib.rs index 083ba98..3c96e9e 100644 --- a/crates/epiphany-ops/src/lib.rs +++ b/crates/epiphany-ops/src/lib.rs @@ -117,9 +117,9 @@ pub use payload::{ CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationKindTag, - OperationPayload, PositionRemapping, ResolveConflictPayload, RespellPitchOp, - SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, TransposeOp, - TupletCompensation, + OperationPayload, PositionRemapping, ResolveConflictPayload, RespellPitchOp, SetMetadataOp, + SetMetricGridOp, SetUserPageBreakOp, SetUserSystemBreakOp, TransactionCategory, + TransactionDescriptor, TransposeOp, TupletCompensation, }; pub use reduce::{ canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason, diff --git a/crates/epiphany-ops/src/migrate.rs b/crates/epiphany-ops/src/migrate.rs index b87d6e7..9719c37 100644 --- a/crates/epiphany-ops/src/migrate.rs +++ b/crates/epiphany-ops/src/migrate.rs @@ -161,6 +161,10 @@ fn project_kind(kind: &OperationKind) -> V0OperationKind { OperationKind::DeleteStaffInstance(op) => V0OperationKind::DeleteStaffInstance(*op), OperationKind::CreateVoice(op) => V0OperationKind::CreateVoice(op.clone()), OperationKind::DeleteVoice(op) => V0OperationKind::DeleteVoice(*op), + // v1-native (Group 4): projected verbatim. + OperationKind::SetMetadata(op) => V0OperationKind::SetMetadata(op.clone()), + OperationKind::SetMetricGrid(op) => V0OperationKind::SetMetricGrid(op.clone()), + OperationKind::SetUserPageBreak(op) => V0OperationKind::SetUserPageBreak(op.clone()), } } @@ -296,6 +300,10 @@ fn migrate_kind(kind: &V0OperationKind, context: &Score) -> Result OperationKind::DeleteStaffInstance(*op), V0OperationKind::CreateVoice(op) => OperationKind::CreateVoice(op.clone()), V0OperationKind::DeleteVoice(op) => OperationKind::DeleteVoice(*op), + // v1-native (Group 4): identity round-trip. + V0OperationKind::SetMetadata(op) => OperationKind::SetMetadata(op.clone()), + V0OperationKind::SetMetricGrid(op) => OperationKind::SetMetricGrid(op.clone()), + V0OperationKind::SetUserPageBreak(op) => OperationKind::SetUserPageBreak(op.clone()), }) } @@ -615,6 +623,21 @@ mod tests { OperationKind::DeleteVoice(crate::payload::DeleteVoiceOp { voice: VoiceId::new(ReplicaId(3), 9), }), + OperationKind::SetMetadata(crate::payload::SetMetadataOp { + metadata: valuegen::score_metadata(2), + }), + OperationKind::SetMetricGrid(crate::payload::SetMetricGridOp { + region: epiphany_core::RegionId::new(ReplicaId(3), 7), + grid: Some(valuegen::metric_grid()), + }), + OperationKind::SetUserPageBreak(crate::payload::SetUserPageBreakOp { + region: epiphany_core::RegionId::new(ReplicaId(3), 7), + anchor: valuegen::region_start_anchor( + epiphany_core::RegionId::new(ReplicaId(3), 7), + epiphany_core::MusicalPosition::origin(), + ), + present: true, + }), ]; for kind in kinds { let e = env(primitive(kind)); diff --git a/crates/epiphany-ops/src/payload.rs b/crates/epiphany-ops/src/payload.rs index 3c8cc91..bf27ca0 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -33,9 +33,9 @@ use epiphany_core::{ Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, IdentifiedPitch, - MusicalDuration, MusicalPosition, Pitch, PitchId, PitchSpelling, Region, RegionId, - RegionTimeModel, Rest, Slur, Spanner, StaffInstance, StaffInstanceId, Tie, TimeAnchor, - TransactionId, TupletId, TypedObjectId, Voice, VoiceId, + MetricGrid, MusicalDuration, MusicalPosition, Pitch, PitchId, PitchSpelling, Region, RegionId, + RegionTimeModel, Rest, ScoreMetadata, Slur, Spanner, StaffInstance, StaffInstanceId, Tie, + TimeAnchor, TransactionId, TupletId, TypedObjectId, Voice, VoiceId, }; use epiphany_determinism::{sorted_canonical, CanonicalEncode}; @@ -138,6 +138,14 @@ pub enum OperationKind { CreateVoice(CreateVoiceOp), /// Tombstone an empty voice (precondition: no live events). DeleteVoice(DeleteVoiceOp), + // --- Group 4 (M2d): score settings. LWW field-overwrite. --- + /// Overwrite the score metadata (later-in-canonical-order wins). + SetMetadata(SetMetadataOp), + /// Overwrite a region's default metric grid (later-in-canonical-order wins). + SetMetricGrid(SetMetricGridOp), + /// Set a user page-break preference (LWW advisory; the page-break sibling of + /// `SetUserSystemBreak`). + SetUserPageBreak(SetUserPageBreakOp), } impl OperationKind { @@ -164,6 +172,9 @@ impl OperationKind { OperationKind::DeleteStaffInstance(_) => 18, OperationKind::CreateVoice(_) => 19, OperationKind::DeleteVoice(_) => 20, + OperationKind::SetMetadata(_) => 21, + OperationKind::SetMetricGrid(_) => 22, + OperationKind::SetUserPageBreak(_) => 23, } } @@ -193,6 +204,9 @@ impl OperationKind { OperationKind::DeleteStaffInstance(_) => OperationKindTag::DeleteStaffInstance, OperationKind::CreateVoice(_) => OperationKindTag::CreateVoice, OperationKind::DeleteVoice(_) => OperationKindTag::DeleteVoice, + OperationKind::SetMetadata(_) => OperationKindTag::SetMetadata, + OperationKind::SetMetricGrid(_) => OperationKindTag::SetMetricGrid, + OperationKind::SetUserPageBreak(_) => OperationKindTag::SetUserPageBreak, } } } @@ -225,6 +239,9 @@ impl CanonicalEncode for OperationKind { OperationKind::DeleteStaffInstance(op) => op.encode_canonical(out), OperationKind::CreateVoice(op) => op.encode_canonical(out), OperationKind::DeleteVoice(op) => op.encode_canonical(out), + OperationKind::SetMetadata(op) => op.encode_canonical(out), + OperationKind::SetMetricGrid(op) => op.encode_canonical(out), + OperationKind::SetUserPageBreak(op) => op.encode_canonical(out), } } } @@ -257,6 +274,8 @@ pub enum OperationKindTag { ModifyIdentifiedPitch, CreateVoice, DeleteVoice, + SetMetadata, + SetMetricGrid, } impl OperationKindTag { @@ -284,6 +303,8 @@ impl OperationKindTag { OperationKindTag::ModifyIdentifiedPitch => 19, OperationKindTag::CreateVoice => 20, OperationKindTag::DeleteVoice => 21, + OperationKindTag::SetMetadata => 22, + OperationKindTag::SetMetricGrid => 23, } } } @@ -952,6 +973,78 @@ impl CanonicalEncode for DeleteVoiceOp { } } +// --- Group 4 (M2d): score settings (Chapter 6 §6.10). LWW field-overwrite. --- + +/// Overwrite the score metadata (Chapter 6 §6.10 SetMetadata). Carries the full +/// [`ScoreMetadata`] (v1); the score-singleton field-overwrite is last-writer-wins +/// (concurrent differing ⇒ structural-field-collision). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct SetMetadataOp { + pub metadata: ScoreMetadata, +} + +impl CanonicalEncode for SetMetadataOp { + fn encode_canonical(&self, out: &mut Vec) { + push_lp_bytes(out, &self.metadata.canonical_bytes()); + } +} + +/// Overwrite a region's default metric grid (Chapter 6 §6.10 SetMetricGrid). +/// Carries the full target [`MetricGrid`] (or `None` to clear it); LWW +/// field-overwrite keyed by region (concurrent differing ⇒ +/// structural-field-collision). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct SetMetricGridOp { + pub region: RegionId, + pub grid: Option, +} + +impl CanonicalEncode for SetMetricGridOp { + fn encode_canonical(&self, out: &mut Vec) { + push_canon(out, &self.region); + match &self.grid { + None => push_tag(out, 0), + Some(grid) => { + push_tag(out, 1); + push_lp_bytes(out, &grid.canonical_bytes()); + } + } + } +} + +/// Set a user page-break preference (Chapter 6 §6.10 SetUserPageBreak) — the +/// page-break sibling of [`SetUserSystemBreakOp`]. Carries the full +/// [`TimeAnchor`] (v1); the LWW bucketing key is the anchor's resolved +/// [`MusicalPosition`]. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct SetUserPageBreakOp { + pub region: RegionId, + pub anchor: TimeAnchor, + pub present: bool, +} + +impl SetUserPageBreakOp { + /// The anchor's resolved musical position — the canonical LWW bucketing key + /// (see [`SetUserSystemBreakOp::resolved_position`]). + pub fn resolved_position(&self) -> MusicalPosition { + match &self.anchor { + TimeAnchor::Region { + offset: epiphany_core::AnchorOffset::Musical(d), + .. + } => MusicalPosition(d.0.clone()), + _ => MusicalPosition::origin(), + } + } +} + +impl CanonicalEncode for SetUserPageBreakOp { + fn encode_canonical(&self, out: &mut Vec) { + push_canon(out, &self.region); + push_lp_bytes(out, &self.anchor.canonical_bytes()); + push_u8_bool(out, self.present); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index a032b33..ade9560 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -32,10 +32,10 @@ use std::collections::{BTreeMap, BTreeSet}; use epiphany_core::{ derive_promoted_voice_id, AnchorOffset, CanonicalValue, Event, EventDuration, EventId, - EventPosition, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, PitchSpelling, - RegionEdge, RegionId, RegionTimeModel, Score, SpellingAttachment, SpellingDirective, - SpellingScope, SpellingSource, StaffInstance, StaffInstanceId, TimeAnchor, TransactionId, - TypedObjectId, Voice, VoiceId, VoiceOrigin, + EventPosition, MetricGrid, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, + PitchSpelling, RegionEdge, RegionId, RegionTimeModel, Score, ScoreMetadata, SpellingAttachment, + SpellingDirective, SpellingScope, SpellingSource, StaffInstance, StaffInstanceId, TimeAnchor, + TransactionId, TypedObjectId, Voice, VoiceId, VoiceOrigin, }; use epiphany_determinism::CanonicalEncode; @@ -55,7 +55,8 @@ use crate::payload::{ DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, - RespellPitchOp, TransposeOp, TupletCompensation, + RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetUserPageBreakOp, TransposeOp, + TupletCompensation, }; use crate::undo::{UndoPolicy, UndoTransactionPayload}; @@ -208,6 +209,9 @@ pub struct MaterializedState { pub spellings: BTreeMap, /// User system-break preferences (LWW advisory), keyed by region+anchor. pub breaks: BTreeMap<(RegionId, MusicalPosition), bool>, + /// User page-break preferences (LWW advisory), keyed by region+anchor (the + /// page-break sibling of [`MaterializedState::breaks`], M2d). + pub page_breaks: BTreeMap<(RegionId, MusicalPosition), bool>, /// Operations held pending, ordered by `OperationId`. pub pending: Vec<(OperationId, PendingReason)>, } @@ -267,6 +271,13 @@ impl MaterializedState { push_canon(&mut out, anchor); push_u8_bool(&mut out, *present); } + // Page breaks (region+anchor order) — sibling of breaks (M2d). + push_len(&mut out, self.page_breaks.len()); + for ((region, anchor), present) in &self.page_breaks { + push_canon(&mut out, region); + push_canon(&mut out, anchor); + push_u8_bool(&mut out, *present); + } // Pending (OperationId order). push_len(&mut out, self.pending.len()); for (id, reason) in &self.pending { @@ -313,6 +324,7 @@ struct Reducer<'a> { objects: BTreeMap, spellings: BTreeMap, breaks: BTreeMap<(RegionId, MusicalPosition), bool>, + page_breaks: BTreeMap<(RegionId, MusicalPosition), bool>, conflicts: ConflictRegistry, effects: Vec<(OperationId, OperationEffect)>, anomalies: BTreeMap, @@ -329,6 +341,10 @@ struct Reducer<'a> { // LWW working state for ModifyCrossCutting (Group 2), mirroring the leaf-field // modify maps above: last modifier + value it wrote, keyed by structure id. last_cross_cutting_modify: BTreeMap, + // LWW working state for the Group-4 (M2d) score-settings overwrites: the last + // writer and the value it wrote (the resolved value lives in the graph). + last_metadata: Option<(OperationId, ScoreMetadata)>, + last_metric_grid: BTreeMap)>, structures: BTreeMap>, // Live child sets for the structural-container empty-only delete (Group 3): // a region's live staff instances, and a staff instance's live voices. (A @@ -350,6 +366,7 @@ struct WorkingSnapshot { objects: BTreeMap, spellings: BTreeMap, breaks: BTreeMap<(RegionId, MusicalPosition), bool>, + page_breaks: BTreeMap<(RegionId, MusicalPosition), bool>, conflicts: ConflictRegistry, minted_by: BTreeMap, event_pitches: BTreeMap>, @@ -358,6 +375,8 @@ struct WorkingSnapshot { last_event_modify: BTreeMap, last_pitch_modify: BTreeMap, last_cross_cutting_modify: BTreeMap, + last_metadata: Option<(OperationId, ScoreMetadata)>, + last_metric_grid: BTreeMap)>, structures: BTreeMap>, region_instances: BTreeMap>, instance_voices: BTreeMap>, @@ -423,6 +442,7 @@ impl<'a> Reducer<'a> { objects: BTreeMap::new(), spellings: BTreeMap::new(), breaks: BTreeMap::new(), + page_breaks: BTreeMap::new(), conflicts: ConflictRegistry::new(), effects: Vec::new(), anomalies: BTreeMap::new(), @@ -433,6 +453,8 @@ impl<'a> Reducer<'a> { last_event_modify: BTreeMap::new(), last_pitch_modify: BTreeMap::new(), last_cross_cutting_modify: BTreeMap::new(), + last_metadata: None, + last_metric_grid: BTreeMap::new(), structures: BTreeMap::new(), region_instances: BTreeMap::new(), instance_voices: BTreeMap::new(), @@ -723,6 +745,7 @@ impl<'a> Reducer<'a> { objects: self.objects, spellings: self.spellings, breaks: self.breaks, + page_breaks: self.page_breaks, pending: pending_vec, }; (state, graph) @@ -1227,6 +1250,9 @@ impl<'a> Reducer<'a> { OperationKind::DeleteStaffInstance(op) => self.delete_staff_instance(env, op), OperationKind::CreateVoice(op) => self.create_voice(env, op), OperationKind::DeleteVoice(op) => self.delete_voice(env, op), + OperationKind::SetMetadata(op) => self.set_metadata(env, op), + OperationKind::SetMetricGrid(op) => self.set_metric_grid(env, op), + OperationKind::SetUserPageBreak(op) => self.set_user_page_break(op), }, OperationPayload::ResolveConflict(op) => self.resolve_conflict(env, op), OperationPayload::UndoTransaction(op) => self.undo_transaction(env, op), @@ -1285,6 +1311,148 @@ impl<'a> Reducer<'a> { OperationEffect::Applied } + // --- Group 4 (M2d): score settings (LWW field-overwrite). -------------- + // + // SetMetadata / SetMetricGrid mirror the modify ops: the resolved value lives + // in the graph (reduce_onto); MaterializedState records only the effect and, + // on a concurrent differing write, a StructuralFieldCollision. SetUserPageBreak + // mirrors SetUserSystemBreak: it is a canonical LWW advisory (page_breaks). + + fn set_metadata(&mut self, env: &OperationEnvelope, op: &SetMetadataOp) -> OperationEffect { + let effect = match &self.last_metadata { + Some((prev_op, prev_meta)) if self.concurrent(env.id, *prev_op) => { + if *prev_meta == op.metadata { + return OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + }; + } + let prev_op = *prev_op; + let conflict = ConflictRecord::new( + ConflictKind::StructuralFieldCollision { + winner: env.id, + loser: prev_op, + field: FieldPath("metadata".to_string()), + }, + vec![env.id, prev_op], + vec![], + ); + let cid = conflict.id; + self.conflicts.insert(conflict); + OperationEffect::Conflicted { conflict: cid } + } + _ => OperationEffect::Applied, + }; + self.last_metadata = Some((env.id, op.metadata.clone())); + if let Some(score) = self.graph.as_mut() { + score.metadata = op.metadata.clone(); + } + effect + } + + fn set_metric_grid( + &mut self, + env: &OperationEnvelope, + op: &SetMetricGridOp, + ) -> OperationEffect { + if !matches!( + self.objects.get(&TypedObjectId::Region(op.region)), + Some(ObjectState::Live) + ) { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + } + let prev = self + .last_metric_grid + .get(&op.region) + .map(|(o, g)| (*o, g.clone())); + let effect = match prev { + Some((prev_op, prev_grid)) if self.concurrent(env.id, prev_op) => { + if prev_grid == op.grid { + return OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + }; + } + let conflict = ConflictRecord::new( + ConflictKind::StructuralFieldCollision { + winner: env.id, + loser: prev_op, + field: FieldPath("metric_grid".to_string()), + }, + vec![env.id, prev_op], + vec![TypedObjectId::Region(op.region)], + ); + let cid = conflict.id; + self.conflicts.insert(conflict); + OperationEffect::Conflicted { conflict: cid } + } + _ => OperationEffect::Applied, + }; + self.last_metric_grid + .insert(op.region, (env.id, op.grid.clone())); + self.graph_set_metric_grid(op.region, &op.grid); + effect + } + + fn graph_set_metric_grid(&mut self, region: RegionId, grid: &Option) { + let Some(score) = self.graph.as_mut() else { + return; + }; + if let Some(region) = score.canvas.regions.iter_mut().find(|r| r.id == region) { + match &mut region.content { + epiphany_core::RegionContent::StaffBased(content) => { + content.default_metric_grid = grid.clone(); + } + epiphany_core::RegionContent::Hybrid { staves, .. } => { + staves.default_metric_grid = grid.clone(); + } + epiphany_core::RegionContent::FreeGraphic(_) => {} + } + } + } + + fn set_user_page_break(&mut self, op: &SetUserPageBreakOp) -> OperationEffect { + if let Some(score) = self.graph.as_mut() { + let Some(region) = score + .canvas + .regions + .iter_mut() + .find(|region| region.id == op.region) + else { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + }; + let breaks = match &mut region.content { + epiphany_core::RegionContent::StaffBased(content) => &mut content.user_page_breaks, + epiphany_core::RegionContent::Hybrid { staves, .. } => &mut staves.user_page_breaks, + epiphany_core::RegionContent::FreeGraphic(_) => { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + } + } + }; + let anchor = op.anchor.clone(); + if op.present { + if !breaks.contains(&anchor) { + breaks.push(anchor); + } + } else { + breaks.retain(|candidate| candidate != &anchor); + } + } + + self.page_breaks + .insert((op.region, op.resolved_position()), op.present); + OperationEffect::Applied + } + fn insert_event(&mut self, env: &OperationEnvelope, op: &InsertEventOp) -> OperationEffect { // The reduction keys are read from the carried event value (v1). let event_id = op.event_id(); @@ -3024,6 +3192,7 @@ impl<'a> Reducer<'a> { objects: self.objects.clone(), spellings: self.spellings.clone(), breaks: self.breaks.clone(), + page_breaks: self.page_breaks.clone(), conflicts: self.conflicts.clone(), minted_by: self.minted_by.clone(), event_pitches: self.event_pitches.clone(), @@ -3032,6 +3201,8 @@ impl<'a> Reducer<'a> { last_event_modify: self.last_event_modify.clone(), last_pitch_modify: self.last_pitch_modify.clone(), last_cross_cutting_modify: self.last_cross_cutting_modify.clone(), + last_metadata: self.last_metadata.clone(), + last_metric_grid: self.last_metric_grid.clone(), structures: self.structures.clone(), region_instances: self.region_instances.clone(), instance_voices: self.instance_voices.clone(), @@ -3047,6 +3218,7 @@ impl<'a> Reducer<'a> { self.objects = s.objects; self.spellings = s.spellings; self.breaks = s.breaks; + self.page_breaks = s.page_breaks; self.conflicts = s.conflicts; self.minted_by = s.minted_by; self.event_pitches = s.event_pitches; @@ -3055,6 +3227,8 @@ impl<'a> Reducer<'a> { self.last_event_modify = s.last_event_modify; self.last_pitch_modify = s.last_pitch_modify; self.last_cross_cutting_modify = s.last_cross_cutting_modify; + self.last_metadata = s.last_metadata; + self.last_metric_grid = s.last_metric_grid; self.structures = s.structures; self.region_instances = s.region_instances; self.instance_voices = s.instance_voices; diff --git a/crates/epiphany-ops/src/v0.rs b/crates/epiphany-ops/src/v0.rs index 7d226c1..5028840 100644 --- a/crates/epiphany-ops/src/v0.rs +++ b/crates/epiphany-ops/src/v0.rs @@ -84,6 +84,10 @@ pub enum V0OperationKind { DeleteStaffInstance(crate::payload::DeleteStaffInstanceOp), CreateVoice(crate::payload::CreateVoiceOp), DeleteVoice(crate::payload::DeleteVoiceOp), + // Group 4 (M2d) — also v1-native; round-trip by identity. + SetMetadata(crate::payload::SetMetadataOp), + SetMetricGrid(crate::payload::SetMetricGridOp), + SetUserPageBreak(crate::payload::SetUserPageBreakOp), } /// v0 `InsertEvent`: the event was a bare [`EventId`] plus the reduction-relevant diff --git a/crates/epiphany-ops/src/valuegen.rs b/crates/epiphany-ops/src/valuegen.rs index 25101dc..969a6c6 100644 --- a/crates/epiphany-ops/src/valuegen.rs +++ b/crates/epiphany-ops/src/valuegen.rs @@ -268,6 +268,25 @@ pub fn region(id: RegionId) -> Region { } } +/// Score metadata with a `nth`-distinct title (M2d) — distinct `nth` give +/// distinct [`ScoreMetadata`] values so a harness can make concurrent +/// `SetMetadata`s agree or conflict deterministically. +pub fn score_metadata(nth: u8) -> epiphany_core::ScoreMetadata { + epiphany_core::ScoreMetadata { + title: Some(format!("title-{nth}")), + composer: Some("composer".to_string()), + copyright: None, + } +} + +/// An empty metric grid (M2d) — no meter changes, hence anchor-free and +/// reference-clean (Chapter 5). The container a `SetMetricGrid` sets on a region. +pub fn metric_grid() -> epiphany_core::MetricGrid { + epiphany_core::MetricGrid { + meter_sequence: Vec::new(), + } +} + /// An explicit, user-chosen per-pitch [`SpellingAttachment`] — the engraved-layer /// spelling a materialized score carries after a `RespellPitch`. The v0 → v1 /// migration recovers a respell's spelling from exactly these attachments diff --git a/crates/epiphany-ops/tests/graph_reduction.rs b/crates/epiphany-ops/tests/graph_reduction.rs index 79318b0..5509e23 100644 --- a/crates/epiphany-ops/tests/graph_reduction.rs +++ b/crates/epiphany-ops/tests/graph_reduction.rs @@ -1213,3 +1213,124 @@ fn structural_containers_create_and_empty_only_delete_in_the_graph() { } assert!(check_invariants(&result.score).is_empty()); } + +#[test] +fn score_settings_materialize_in_the_graph_and_ledger() { + let base = epiphany_core::generators::valid_score(100); + let region = base.canvas.regions[0].id; + let r = 73; + let set_metadata = envelope( + r, + 0, + 10, + CausalContext::new(), + None, + OperationPayload::Primitive(OperationKind::SetMetadata(epiphany_ops::SetMetadataOp { + metadata: valuegen::score_metadata(5), + })), + ); + let set_grid = envelope( + r, + 1, + 11, + CausalContext::new().with_seen(ReplicaId(r), 0), + None, + OperationPayload::Primitive(OperationKind::SetMetricGrid( + epiphany_ops::SetMetricGridOp { + region, + grid: Some(valuegen::metric_grid()), + }, + )), + ); + let set_page = envelope( + r, + 2, + 12, + CausalContext::new().with_seen(ReplicaId(r), 1), + None, + OperationPayload::Primitive(OperationKind::SetUserPageBreak( + epiphany_ops::SetUserPageBreakOp { + region, + anchor: valuegen::region_start_anchor( + region, + MusicalPosition(RationalTime::from_int(0)), + ), + present: true, + }, + )), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![set_metadata, set_grid, set_page]); + let result = set.reduce_onto(&base); + + assert_eq!( + result.score.metadata.title.as_deref(), + Some("title-5"), + "SetMetadata overwrites the score metadata in the graph" + ); + let materialized_region = result + .score + .canvas + .regions + .iter() + .find(|rg| rg.id == region) + .expect("the region is present"); + assert!( + matches!( + &materialized_region.content, + epiphany_core::RegionContent::StaffBased(c) if c.default_metric_grid.is_some() + ), + "SetMetricGrid sets the region's default metric grid" + ); + assert!( + matches!( + &materialized_region.content, + epiphany_core::RegionContent::StaffBased(c) if !c.user_page_breaks.is_empty() + ), + "SetUserPageBreak adds the anchor to the region's user page breaks" + ); + assert!( + !result.state.page_breaks.is_empty(), + "the page break is recorded in the canonical MaterializedState.page_breaks" + ); + assert!(check_invariants(&result.score).is_empty()); +} + +#[test] +fn concurrent_differing_set_metadata_conflicts() { + let base = epiphany_core::generators::valid_score(100); + // Two concurrent SetMetadata (neither sees the other) with differing values. + let a = envelope( + 74, + 0, + 10, + CausalContext::new(), + None, + OperationPayload::Primitive(OperationKind::SetMetadata(epiphany_ops::SetMetadataOp { + metadata: valuegen::score_metadata(1), + })), + ); + let b = envelope( + 75, + 0, + 10, + CausalContext::new(), + None, + OperationPayload::Primitive(OperationKind::SetMetadata(epiphany_ops::SetMetadataOp { + metadata: valuegen::score_metadata(2), + })), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![a, b]); + let result = set.reduce_onto(&base); + assert_eq!( + result.state.conflicts.records().len(), + 1, + "concurrent differing SetMetadata records exactly one conflict" + ); + assert!(matches!( + result.state.conflicts.records()[0].kind, + ConflictKind::StructuralFieldCollision { .. } + )); + assert!(check_invariants(&result.score).is_empty()); +} diff --git a/crates/epiphany-testkit/src/generators.rs b/crates/epiphany-testkit/src/generators.rs index b939b79..6e42a87 100644 --- a/crates/epiphany-testkit/src/generators.rs +++ b/crates/epiphany-testkit/src/generators.rs @@ -43,8 +43,9 @@ use epiphany_ops::{ PreconditionFailureRegistryId, ReanchorReason, ReanchorReasonRegistryId, ReanchorResult, RepairKind, RepairKindRegistryId, RepairRecord, ReplicaAnomalyReason, ReplicaAnomalyRegistryId, ResolutionAction, ResolutionRegistryId, ResolveConflictPayload, RespellPitchOp, - SerializedCanonicalInputs, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, - TransposeOp, TupletCompensation, TupletCompensationKind, UndoPolicy, UndoTransactionPayload, + SerializedCanonicalInputs, SetMetadataOp, SetMetricGridOp, SetUserPageBreakOp, + SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, TransposeOp, + TupletCompensation, TupletCompensationKind, UndoPolicy, UndoTransactionPayload, }; use crate::rng::Rng; @@ -636,7 +637,7 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP } _ => {} } - let kind = match rng.below(21) { + let kind = match rng.below(24) { 0 => { let pitches = if rng.boolean() { vec![obj_pitch(rng.below(pitches))] @@ -756,6 +757,22 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP 19 => OperationKind::DeleteVoice(DeleteVoiceOp { voice: VoiceId::new(OBJ_REPLICA, rng.below(4)), }), + // Group 4 (M2d): score settings (LWW) over the shared id space. + 20 => OperationKind::SetMetadata(SetMetadataOp { + metadata: valuegen::score_metadata(rng.below(3) as u8), + }), + 21 => OperationKind::SetMetricGrid(SetMetricGridOp { + region: RegionId::new(OBJ_REPLICA, rng.below(2)), + grid: rng.boolean().then(valuegen::metric_grid), + }), + 22 => OperationKind::SetUserPageBreak(SetUserPageBreakOp { + region: RegionId::new(OBJ_REPLICA, 0), + anchor: valuegen::region_start_anchor( + RegionId::new(OBJ_REPLICA, 0), + MusicalPosition(RationalTime::from_int(rng.below(4) as i32)), + ), + present: rng.boolean(), + }), _ => OperationKind::Registered( OperationKindRegistryId(rng.next_u64() as u128), rng.byte_vec(0, 16),