epiphany/crates/epiphany-ops/tests/graph_reduction.rs

641 lines
20 KiB
Rust

//! M2 regression coverage for reducing operations onto Agent B's real score
//! graph rather than only the Chapter 6 bookkeeping projection.
use epiphany_core::{
check_invariants, derive_promoted_voice_id, AnchorOffset, EventId, MusicalDuration,
MusicalPosition, OperationId, PitchId, RationalTime, RegionEdge, RegionTimeModel, ReplicaId,
Score, SlurId, StaffInstanceId, TimeAnchor, TransactionId, TypedObjectId, VoiceId, VoiceOrigin,
WallClockTime,
};
use epiphany_ops::{
valuegen, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictKind, CreateCrossCuttingOp,
CrossCuttingValue, DeleteEventOp, HybridLogicalClock, InsertEventOp, NoOpReason,
OperationEffect, OperationEnvelope, OperationKind, OperationPayload, OperationSet,
OperationStamp, PositionRemapping, PreconditionFailureReason, SetUserSystemBreakOp,
TransactionCategory, TransactionDescriptor, TupletCompensation, UndoPolicy,
UndoTransactionPayload,
};
fn envelope(
replica: u64,
counter: u64,
physical: i64,
context: CausalContext,
transaction: Option<TransactionId>,
payload: OperationPayload,
) -> OperationEnvelope {
let id = OperationId::new(ReplicaId(replica), counter);
OperationEnvelope {
id,
author: AuthorId(0),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id),
causal_context: context,
transaction,
payload,
}
}
fn target(score: &Score) -> (StaffInstanceId, VoiceId) {
let instance = &score.canvas.regions[0].staff_instances()[0];
(instance.id, instance.voices[0].id)
}
fn insert(
staff_instance: StaffInstanceId,
voice: VoiceId,
event: EventId,
pitch: PitchId,
position: i32,
) -> OperationPayload {
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
staff_instance,
event: valuegen::insert_event_value(
event,
voice,
MusicalPosition(RationalTime::from_int(position)),
MusicalDuration::whole(),
&[pitch],
),
}))
}
fn voice(score: &Score, id: VoiceId) -> Option<&epiphany_core::Voice> {
score
.voices()
.find_map(|(_, _, voice)| (voice.id == id).then_some(voice))
}
#[test]
fn insert_materializes_in_the_real_arena_and_voice() {
let base = epiphany_core::generators::valid_score(100);
let (staff_instance, target_voice) = target(&base);
let event = EventId::new(ReplicaId(50), 0);
let pitch = PitchId::new(ReplicaId(50), 1);
let op = envelope(
50,
0,
10,
CausalContext::new(),
None,
insert(staff_instance, target_voice, event, pitch, 100),
);
let mut set = OperationSet::new();
set.accept(op);
let result = set.reduce_onto(&base);
assert!(result.score.events.contains(event));
assert!(voice(&result.score, target_voice)
.expect("target voice remains present")
.events
.contains(&event));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn graph_reduction_rejects_an_unknown_voice_without_creating_it() {
let base = epiphany_core::generators::valid_score(101);
let (staff_instance, _) = target(&base);
let missing_voice = VoiceId::new(ReplicaId(51), 99);
let event = EventId::new(ReplicaId(51), 0);
let op = envelope(
51,
0,
10,
CausalContext::new(),
None,
insert(
staff_instance,
missing_voice,
event,
PitchId::new(ReplicaId(51), 1),
100,
),
);
let mut set = OperationSet::new();
set.accept(op.clone());
let result = set.reduce_onto(&base);
assert!(!result.score.events.contains(event));
assert!(voice(&result.score, missing_voice).is_none());
assert_eq!(
result.state.effects,
vec![(
op.id,
OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::VoiceMissing,
},
},
)]
);
}
#[test]
fn concurrent_overlap_materializes_an_invariant_clean_promoted_voice() {
let base = epiphany_core::generators::valid_score(102);
let (staff_instance, target_voice) = target(&base);
let winner = envelope(
52,
0,
10,
CausalContext::new(),
None,
insert(
staff_instance,
target_voice,
EventId::new(ReplicaId(52), 10),
PitchId::new(ReplicaId(52), 11),
100,
),
);
let loser = envelope(
53,
0,
10,
CausalContext::new(),
None,
insert(
staff_instance,
target_voice,
EventId::new(ReplicaId(53), 10),
PitchId::new(ReplicaId(53), 11),
100,
),
);
let promoted = derive_promoted_voice_id(staff_instance, target_voice, winner.id, loser.id);
let mut set = OperationSet::new();
set.accept_all(vec![loser.clone(), winner.clone()]);
let mut reversed = OperationSet::new();
reversed.accept_all(vec![winner.clone(), loser.clone()]);
let result = set.reduce_onto(&base);
assert_eq!(result, reversed.reduce_onto(&base));
let promoted_voice = voice(&result.score, promoted).expect("promoted voice was materialized");
assert!(promoted_voice
.events
.contains(&EventId::new(ReplicaId(53), 10)));
assert_eq!(
promoted_voice.origin,
VoiceOrigin::SystemPromoted {
winning_operation: winner.id,
losing_operation: loser.id,
original_voice: target_voice,
}
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn delete_removes_the_event_and_records_graph_tombstones() {
let base = epiphany_core::generators::valid_score(103);
let (staff_instance, target_voice) = target(&base);
let event = EventId::new(ReplicaId(54), 10);
let pitch = PitchId::new(ReplicaId(54), 11);
let insertion = envelope(
54,
0,
10,
CausalContext::new(),
None,
insert(staff_instance, target_voice, event, pitch, 100),
);
let deletion = envelope(
54,
1,
11,
CausalContext::new().with_seen(ReplicaId(54), 0),
None,
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
event,
tuplet_compensation: TupletCompensation::NotInTuplet,
})),
);
let mut set = OperationSet::new();
set.accept_all(vec![deletion, insertion]);
let result = set.reduce_onto(&base);
assert!(!result.score.events.contains(event));
assert!(!voice(&result.score, target_voice)
.expect("target voice remains present")
.events
.contains(&event));
assert!(result.score.tombstoned_events.contains(&event));
assert!(result.score.tombstoned_pitches.contains(&pitch));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn failed_transaction_rolls_back_real_graph_mutations() {
let base = epiphany_core::generators::valid_score(104);
let (staff_instance, target_voice) = target(&base);
let tx = TransactionId::from_raw(77);
let descriptor = envelope(
55,
0,
10,
CausalContext::new(),
Some(tx),
OperationPayload::Primitive(OperationKind::DeclareTransaction(TransactionDescriptor {
id: tx,
label: String::from("graph rollback"),
category: Some(TransactionCategory::NoteEntry),
})),
);
let tx_context = CausalContext::new().with_seen(ReplicaId(55), 0);
let inserted_event = EventId::new(ReplicaId(55), 10);
let insertion = envelope(
55,
1,
11,
tx_context.clone(),
Some(tx),
insert(
staff_instance,
target_voice,
inserted_event,
PitchId::new(ReplicaId(55), 11),
100,
),
);
let failing = envelope(
55,
2,
12,
tx_context,
Some(tx),
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
event: EventId::new(ReplicaId(55), 999),
tuplet_compensation: TupletCompensation::NotInTuplet,
})),
);
let mut set = OperationSet::new();
set.accept_all(vec![failing, insertion, descriptor]);
let result = set.reduce_onto(&base);
assert!(!result.score.events.contains(inserted_event));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn forward_undo_removes_transaction_mints_from_the_graph() {
let base = epiphany_core::generators::valid_score(105);
let (staff_instance, target_voice) = target(&base);
let tx = TransactionId::from_raw(78);
let descriptor = envelope(
56,
0,
10,
CausalContext::new(),
Some(tx),
OperationPayload::Primitive(OperationKind::DeclareTransaction(TransactionDescriptor {
id: tx,
label: String::from("graph undo"),
category: None,
})),
);
let inserted_event = EventId::new(ReplicaId(56), 10);
let insertion = envelope(
56,
1,
11,
CausalContext::new().with_seen(ReplicaId(56), 0),
Some(tx),
insert(
staff_instance,
target_voice,
inserted_event,
PitchId::new(ReplicaId(56), 11),
100,
),
);
let undo = envelope(
56,
2,
12,
CausalContext::new().with_seen(ReplicaId(56), 1),
None,
OperationPayload::UndoTransaction(UndoTransactionPayload {
target: tx,
policy: UndoPolicy::StrictInverse,
}),
);
let mut set = OperationSet::new();
set.accept_all(vec![undo, insertion, descriptor]);
let result = set.reduce_onto(&base);
assert!(!result.score.events.contains(inserted_event));
assert!(result.score.tombstoned_events.contains(&inserted_event));
assert!(matches!(
result
.state
.objects
.get(&TypedObjectId::Event(inserted_event)),
Some(epiphany_ops::ObjectState::Tombstoned { .. })
));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn system_break_lww_state_is_materialized_in_the_region() {
let base = epiphany_core::generators::valid_score(106);
let region = base.canvas.regions[0].id;
let position = MusicalPosition(RationalTime::from_int(8));
let operation = envelope(
57,
0,
10,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
region,
anchor: valuegen::region_start_anchor(region, position.clone()),
present: true,
})),
);
let mut set = OperationSet::new();
set.accept(operation);
let result = set.reduce_onto(&base);
let breaks = &result.score.canvas.regions[0]
.content
.staff_based()
.expect("fixture is staff based")
.user_system_breaks;
assert_eq!(
breaks,
&[TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration(position.0)),
}]
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn migration_computes_incompatible_events_from_the_graph() {
let base = epiphany_core::generators::valid_score(107);
let region = base.canvas.regions[0].id;
let operation = envelope(
58,
0,
10,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel(
ChangeRegionTimeModelOp {
region,
new_time_model: valuegen::proportional_model(),
declared_incompatible: Vec::new(),
remapping: PositionRemapping::PreserveTime,
},
)),
);
let mut set = OperationSet::new();
set.accept(operation);
let result = set.reduce_onto(&base);
assert_eq!(result.score, base);
assert!(result
.state
.conflicts
.records()
.iter()
.any(|record| matches!(record.kind, ConflictKind::TimeModelMigrationFailure { .. })));
}
#[test]
fn create_cross_cutting_materializes_supported_graph_structures() {
let base = epiphany_core::generators::valid_score(108);
let endpoints = base.canvas.regions[0].staff_instances()[0].voices[0].events[..2].to_vec();
let slur = SlurId::new(ReplicaId(59), 10);
let operation = envelope(
59,
0,
10,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: CrossCuttingValue::Slur(valuegen::slur(slur, endpoints[0], endpoints[1])),
})),
);
let mut set = OperationSet::new();
set.accept(operation);
let result = set.reduce_onto(&base);
assert!(result
.score
.cross_cutting
.slurs
.iter()
.any(|value| value.id == slur));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn causally_ordered_time_migrations_do_not_conflict() {
let base = epiphany_core::generators::valid_score(109);
let region = base.canvas.regions[0].id;
let first = envelope(
60,
0,
10,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel(
ChangeRegionTimeModelOp {
region,
new_time_model: valuegen::aleatoric_model(),
declared_incompatible: Vec::new(),
remapping: PositionRemapping::PreserveTime,
},
)),
);
let second = envelope(
60,
1,
11,
CausalContext::new().with_seen(ReplicaId(60), 0),
None,
OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel(
ChangeRegionTimeModelOp {
region,
new_time_model: valuegen::metric_model(),
declared_incompatible: Vec::new(),
remapping: PositionRemapping::PreserveTime,
},
)),
);
let mut set = OperationSet::new();
set.accept_all(vec![second, first]);
let result = set.reduce_onto(&base);
assert!(result.state.conflicts.is_empty());
assert!(matches!(
result.score.canvas.regions[0].time_model,
RegionTimeModel::Metric(_)
));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn graph_materialization_is_deterministic_across_base_corpus_and_delivery_order() {
for seed in 0..64_u64 {
let base = epiphany_core::generators::valid_score(1_000 + seed);
let (staff_instance, target_voice) = target(&base);
let winner = envelope(
0xC001,
seed,
10,
CausalContext::new(),
None,
insert(
staff_instance,
target_voice,
EventId::new(ReplicaId(0xC001), seed),
PitchId::new(ReplicaId(0xC001), seed),
100,
),
);
let loser = envelope(
0xC002,
seed,
10,
CausalContext::new(),
None,
insert(
staff_instance,
target_voice,
EventId::new(ReplicaId(0xC002), seed),
PitchId::new(ReplicaId(0xC002), seed),
100,
),
);
let mut forward = OperationSet::new();
forward.accept_all(vec![winner.clone(), loser.clone()]);
let mut backward = OperationSet::new();
backward.accept_all(vec![loser, winner]);
let expected = forward.reduce_onto(&base);
let actual = backward.reduce_onto(&base);
assert_eq!(actual, expected, "base seed {seed}");
assert!(
check_invariants(&actual.score).is_empty(),
"base seed {seed}"
);
}
}
#[test]
fn delete_last_identified_pitch_degrades_the_note_to_a_rest() {
// A single-pitch note whose only pitch is deleted must NOT materialize as an
// empty (invalid) pitched event; Chapter 5 forbids that, so it degrades to a
// rest of the same placement (and `check_invariants` would reject otherwise).
let base = epiphany_core::generators::valid_score(100);
let (staff_instance, target_voice) = target(&base);
let event = EventId::new(ReplicaId(60), 0);
let pitch = PitchId::new(ReplicaId(60), 1);
let insert_note = envelope(
60,
0,
10,
CausalContext::new(),
None,
insert(staff_instance, target_voice, event, pitch, 100),
);
let delete_pitch = envelope(
60,
1,
20,
CausalContext::new().with_seen(ReplicaId(60), 0),
None,
OperationPayload::Primitive(OperationKind::DeleteIdentifiedPitch(
epiphany_ops::DeleteIdentifiedPitchOp { pitch },
)),
);
let mut set = OperationSet::new();
set.accept_all(vec![insert_note, delete_pitch]);
let result = set.reduce_onto(&base);
assert!(
matches!(
result.score.events.get(event),
Some(epiphany_core::Event::Rest(_))
),
"a note whose last pitch is deleted must become a rest, not an empty chord"
);
assert!(
matches!(
result.state.objects.get(&TypedObjectId::Pitch(pitch)),
Some(epiphany_ops::ObjectState::Tombstoned { .. })
),
"the deleted pitch is tombstoned in the bookkeeping projection"
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn insert_identified_pitch_into_a_rest_promotes_it_to_a_note() {
// Adding a pitch to a rest turns the rest into a note — the dual of the
// last-pitch delete — so the graph holds the pitch the bookkeeping minted
// (otherwise the live pitch object would have no graph counterpart).
let base = epiphany_core::generators::valid_score(100);
let (staff_instance, target_voice) = target(&base);
let rest = EventId::new(ReplicaId(61), 0);
let insert_rest = envelope(
61,
0,
10,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
staff_instance,
event: valuegen::insert_event_value(
rest,
target_voice,
MusicalPosition(RationalTime::from_int(100)),
MusicalDuration::whole(),
&[],
),
})),
);
let pitch = PitchId::new(ReplicaId(61), 1);
let add_pitch = envelope(
61,
1,
20,
CausalContext::new().with_seen(ReplicaId(61), 0),
None,
OperationPayload::Primitive(OperationKind::InsertIdentifiedPitch(
epiphany_ops::InsertIdentifiedPitchOp {
event: rest,
pitch: valuegen::identified_pitch(pitch),
},
)),
);
let mut set = OperationSet::new();
set.accept_all(vec![insert_rest, add_pitch]);
let result = set.reduce_onto(&base);
match result.score.events.get(rest) {
Some(epiphany_core::Event::Pitched(pe)) => assert!(
pe.pitches.iter().any(|ip| ip.id == pitch),
"the inserted pitch is present on the promoted note"
),
other => panic!("expected the rest to become a note, got {other:?}"),
}
assert!(check_invariants(&result.score).is_empty());
}