Agent K M2d (Group 4): score-settings operations

Fourth broad-K0 subsystem group — three value-typed LWW field-overwrite ops, one
per settings cluster (additive: OperationKind variants 21-23; framework frozen):

- SetMetadata { metadata: ScoreMetadata } — overwrite the score-singleton
  metadata (title/composer/copyright); concurrent differing ⇒
  StructuralFieldCollision.
- SetMetricGrid { region, grid: Option<MetricGrid> } — overwrite a region's
  default metric grid (LWW keyed by region; concurrent differing ⇒ collision).
- SetUserPageBreak { region, anchor, present } — the page-break sibling of
  SetUserSystemBreak: a canonical LWW advisory.

- core: expose ScoreMetadata + MetricGrid via CanonicalValue (no new byte layout
  — they already have whole-score Codec impls).
- SetMetadata / SetMetricGrid mirror the modify ops: the resolved value lives in
  the graph (reduce_onto), with new last_metadata / last_metric_grid LWW working
  state for concurrent-differing detection; MaterializedState records only the
  effect and conflict.
- SetUserPageBreak mirrors SetUserSystemBreak's canonical advisory: a new
  MaterializedState.page_breaks map (encode + decode added, parallel to breaks),
  plus the graph's region user_page_breaks.

Migration: v1-native (no lossy v0 predecessor) -> project/migrate by identity;
the round-trip identity test now covers all four M2 groups.

Coverage:
- testkit operation_payload + ops fuzz gen_payload emit the three kinds, so the
  convergence / determinism / migration-equivalence and MaterializedState
  decode-roundtrip gates exercise them (incl. page_breaks) at scale.
- reduce_onto graph tests: the three settings materialize in graph and ledger
  (metadata title, region default metric grid, region user page break +
  MaterializedState.page_breaks), invariant-clean; plus a concurrent-differing
  SetMetadata conflict test.

Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green (519);
conformance_suite scale 1 passes. Catalog sections + DECISIONS for the M2 groups
land in M2e per the staged plan. The unrelated Agent-I working tree is left
uncommitted; this commit stages only core/ops/testkit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-25 19:26:31 -04:00
parent 7a94814ba3
commit 1e4ab24779
11 changed files with 502 additions and 17 deletions

View File

@ -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)]

View File

@ -539,6 +539,19 @@ pub(crate) fn decode_materialized_state(bytes: &[u8]) -> Result<MaterializedStat
breaks.insert((region, anchor), present);
}
let page_break_count = reader.len()?;
let mut page_breaks = BTreeMap::new();
for _ in 0..page_break_count {
let region = fixed::<RegionId>(&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<MaterializedStat
objects,
spellings,
breaks,
page_breaks,
pending,
};
if state.canonical_bytes() != bytes {

View File

@ -34,7 +34,8 @@ use crate::payload::{
DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, DeleteRegionOp,
DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp,
ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload,
RespellPitchOp, SetUserSystemBreakOp, TransposeOp, TupletCompensation,
RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetUserPageBreakOp, SetUserSystemBreakOp,
TransposeOp, TupletCompensation,
};
use crate::stamp::{HybridLogicalClock, OperationStamp};
use crate::support::AuthorId;
@ -84,7 +85,7 @@ fn pitch(n: u64) -> 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)
}

View File

@ -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,

View File

@ -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
V0OperationKind::DeleteStaffInstance(op) => 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));

View File

@ -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<u8>) {
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<MetricGrid>,
}
impl CanonicalEncode for SetMetricGridOp {
fn encode_canonical(&self, out: &mut Vec<u8>) {
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<u8>) {
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::*;

View File

@ -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<PitchId, PitchSpelling>,
/// 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<TypedObjectId, ObjectState>,
spellings: BTreeMap<PitchId, PitchSpelling>,
breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
page_breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
conflicts: ConflictRegistry,
effects: Vec<(OperationId, OperationEffect)>,
anomalies: BTreeMap<epiphany_core::IntegrityAnomalyId, IntegrityAnomaly>,
@ -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<TypedObjectId, (OperationId, CrossCuttingValue)>,
// 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<RegionId, (OperationId, Option<MetricGrid>)>,
structures: BTreeMap<TypedObjectId, Vec<TypedObjectId>>,
// 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<TypedObjectId, ObjectState>,
spellings: BTreeMap<PitchId, PitchSpelling>,
breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
page_breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
conflicts: ConflictRegistry,
minted_by: BTreeMap<TypedObjectId, OperationId>,
event_pitches: BTreeMap<EventId, Vec<PitchId>>,
@ -358,6 +375,8 @@ struct WorkingSnapshot {
last_event_modify: BTreeMap<EventId, (OperationId, Event)>,
last_pitch_modify: BTreeMap<PitchId, (OperationId, Pitch)>,
last_cross_cutting_modify: BTreeMap<TypedObjectId, (OperationId, CrossCuttingValue)>,
last_metadata: Option<(OperationId, ScoreMetadata)>,
last_metric_grid: BTreeMap<RegionId, (OperationId, Option<MetricGrid>)>,
structures: BTreeMap<TypedObjectId, Vec<TypedObjectId>>,
region_instances: BTreeMap<RegionId, BTreeSet<StaffInstanceId>>,
instance_voices: BTreeMap<StaffInstanceId, BTreeSet<VoiceId>>,
@ -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<MetricGrid>) {
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;

View File

@ -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

View File

@ -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

View File

@ -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());
}

View File

@ -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),