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:
parent
7a94814ba3
commit
1e4ab24779
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue