epiphany/crates/epiphany-testkit/src/negative.rs

428 lines
14 KiB
Rust

//! Negative / regression guards for the defects found in the Agent C audit
//! (the M1 framework fixes). Item 4 of the v0 follow-up plan charters Agent F's
//! suite to independently guard *every* defect the audit surfaced, so a
//! regression in `epiphany-ops` trips this tripwire rather than slipping past a
//! generic convergence gate.
//!
//! Each guard drives the **real** [`epiphany_ops`] public API and asserts the
//! post-fix behavior. Where the pre-fix bug had a concrete, observable
//! signature (an inverted order, an under-cut quarantine, an un-promoted
//! collision), the guard also pins the *negative* outcome — the thing the buggy
//! reducer would have produced — so the test is provably non-vacuous.
//!
//! The six audited defects:
//!
//! 1. A causal predecessor authored with a larger HLC than its successor sorted
//! *after* it ([`assert_causal_order_dominates_inverted_hlc`]).
//! 2. A missing predecessor expressed through the DVV *vector* (not a dot) was
//! not detected ([`assert_missing_vector_predecessor_pends`]).
//! 3. An HLC sequence `100, 200, 50` quarantined from counter 1, not the
//! counter 0 that also forms a violating pair
//! ([`assert_hlc_100_200_50_quarantines_from_zero`]).
//! 4. A failed transaction left its members' generated conflicts behind
//! ([`assert_failed_transaction_rolls_back_member_conflicts`]).
//! 5. A *causally ordered* same-position insert was promoted as if concurrent
//! ([`assert_causally_ordered_same_position_not_promoted`]).
//! 6. Partial-duration interval overlaps were mishandled
//! ([`assert_partial_overlap_promotes_but_adjacent_does_not`]).
use epiphany_core::{
EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, ReplicaId,
StaffInstanceId, TransactionId, VoiceId, WallClockTime,
};
use epiphany_ops::{
canonical_reduction_order, valuegen, AuthorId, CausalContext, ConflictKind, DeleteEventOp,
HybridLogicalClock, InsertEventOp, IntegrityAnomalyKind, NoOpReason, OperationEffect,
OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp,
PendingReason, PreconditionFailureReason, RepairKind, RespellPitchOp, TransactionDescriptor,
TupletCompensation,
};
/// The replica that owns the synthetic object id space these scenarios edit.
const OBJ: ReplicaId = ReplicaId(9);
fn op(replica: u64, counter: u64) -> OperationId {
OperationId::new(ReplicaId(replica), counter)
}
fn envelope(
replica: u64,
counter: u64,
physical: i64,
ctx: CausalContext,
transaction: Option<TransactionId>,
payload: OperationPayload,
) -> OperationEnvelope {
let id = op(replica, counter);
OperationEnvelope {
id,
author: AuthorId(replica as u128),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id),
causal_context: ctx,
transaction,
payload,
}
}
fn insert_span(
voice: u64,
event: u64,
position: RationalTime,
duration: RationalTime,
) -> OperationPayload {
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
staff_instance: StaffInstanceId::new(OBJ, 0),
event: valuegen::insert_event_value(
EventId::new(OBJ, event),
VoiceId::new(OBJ, voice),
MusicalPosition(position),
MusicalDuration(duration),
&[PitchId::new(OBJ, event)],
),
}))
}
fn insert(voice: u64, event: u64, pos: i64) -> OperationPayload {
insert_span(
voice,
event,
RationalTime::from_int(pos as i32),
RationalTime::one(),
)
}
fn respell(pitch: u64, spelling: u8) -> OperationPayload {
OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: PitchId::new(OBJ, pitch),
spelling: valuegen::spelling(spelling),
}))
}
fn delete_event(event: u64) -> OperationPayload {
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
event: EventId::new(OBJ, event),
tuplet_compensation: TupletCompensation::NotInTuplet,
}))
}
fn declare_tx(replica: u64, counter: u64, physical: i64, tx: TransactionId) -> OperationEnvelope {
envelope(
replica,
counter,
physical,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::DeclareTransaction(TransactionDescriptor {
id: tx,
label: "edit".to_string(),
category: None,
})),
)
}
fn promotes(effect: Option<&OperationEffect>) -> bool {
matches!(
effect,
Some(OperationEffect::AppliedWithRepair { repairs })
if repairs.iter().any(|r| matches!(r.kind, RepairKind::VoicePromoted { .. }))
)
}
// --- Defect 1: causal order vs. an inverted HLC. ----------------------------
/// A predecessor carrying a *larger* physical stamp than its dot-linked
/// successor must still reduce first. A reducer that trusted the HLC tuple alone
/// (the pre-fix bug) would invert the pair; the canonical order must not.
pub fn assert_causal_order_dominates_inverted_hlc() {
let predecessor = envelope(1, 0, 100, CausalContext::new(), None, insert(0, 100, 0));
let successor = envelope(
2,
0,
1,
CausalContext::new().with_dot(predecessor.id),
None,
insert(0, 101, 1),
);
// Negative control: sorting by the HLC tuple alone *does* invert these.
let mut by_hlc = [&successor, &predecessor];
by_hlc.sort_by_key(|e| e.stamp.reduction_tuple());
assert_eq!(
by_hlc[0].id, successor.id,
"negative control mis-constructed: HLC-only order should place the successor first"
);
let ordered = canonical_reduction_order(&[&successor, &predecessor]);
assert_eq!(
ordered.iter().map(|e| e.id).collect::<Vec<_>>(),
vec![predecessor.id, successor.id],
"canonical reduction order must place the causal predecessor first despite its larger HLC"
);
}
// --- Defect 2: missing predecessor via the DVV vector. ----------------------
/// A dependent whose causal context covers a predecessor through the contiguous
/// *vector* (`with_seen`), not a dot, must be held pending when that predecessor
/// is absent. The pre-fix detector keyed only on dots and let this through.
pub fn assert_missing_vector_predecessor_pends() {
let present = envelope(1, 0, 1, CausalContext::new(), None, insert(0, 99, -1));
let dependent = envelope(
2,
0,
10,
CausalContext::new().with_seen(ReplicaId(1), 2),
None,
insert(0, 100, 0),
);
let mut set = OperationSet::new();
set.accept_all(vec![present.clone(), dependent.clone()]);
let state = set.reduce();
assert_eq!(state.effects.len(), 1, "only the present operation applies");
assert_eq!(state.effects[0].0, present.id);
assert_eq!(
state.pending,
vec![(
dependent.id,
PendingReason::MissingCausalPredecessor { missing: op(1, 1) }
)],
"the operation must pend on the vector-covered but absent predecessor (1,1)"
);
}
// --- Defect 3: HLC 100, 200, 50 quarantines from counter 0. -----------------
/// The cut must begin at the *earliest* counter participating in any violating
/// pair. With stamps `100, 200, 50` the violating pair is (counter 0 = 100,
/// counter 2 = 50), so even the monotone counter 0 is quarantined. The pre-fix
/// code cut from counter 1 (the first strict decrease) and left counter 0 live.
pub fn assert_hlc_100_200_50_quarantines_from_zero() {
let a = envelope(1, 0, 100, CausalContext::new(), None, insert(0, 100, 0));
let b = envelope(1, 1, 200, CausalContext::new(), None, insert(0, 101, 1));
let c = envelope(1, 2, 50, CausalContext::new(), None, insert(0, 102, 2));
let mut set = OperationSet::new();
set.accept_all(vec![a, b, c]);
let state = set.reduce();
assert!(
state.effects.is_empty(),
"the entire offending replica stream must be excluded, including counter 0"
);
assert!(
state.anomalies.iter().any(|an| matches!(
an.kind,
IntegrityAnomalyKind::ReplicaStreamQuarantined {
replica,
first_bad_counter,
..
} if replica == ReplicaId(1) && first_bad_counter == 0
)),
"the quarantine anomaly must report first_bad_counter == 0"
);
}
// --- Defect 4: failed transaction rolls back member-generated conflicts. -----
/// A transaction whose members would generate conflicts but whose block fails
/// must leave *no* member-generated conflict behind — only the wholesale
/// transaction conflict. The pre-fix snapshot did not restore the conflict
/// registry, so the member's structural-collision conflict survived.
pub fn assert_failed_transaction_rolls_back_member_conflicts() {
let seed = envelope(2, 0, 1, CausalContext::new(), None, insert(0, 100, 0));
let initial_spelling = envelope(
2,
1,
2,
CausalContext::new().with_seen(ReplicaId(2), 0),
None,
respell(100, 1),
);
let tx = TransactionId::from_raw(89);
let descriptor = declare_tx(1, 0, 10, tx);
let tx_ctx = CausalContext::new().with_seen(ReplicaId(1), 0);
let conflicting = envelope(1, 1, 11, tx_ctx.clone(), Some(tx), respell(100, 2));
let failing = envelope(1, 2, 12, tx_ctx, Some(tx), delete_event(999));
let mut set = OperationSet::new();
set.accept_all(vec![
seed,
initial_spelling,
descriptor,
conflicting,
failing,
]);
let state = set.reduce();
assert_eq!(
state.spellings.get(&PitchId::new(OBJ, 100)),
Some(&valuegen::spelling(1)),
"the pre-transaction spelling must survive the rollback"
);
assert_eq!(
state.conflicts.records().len(),
1,
"only the wholesale transaction conflict should remain"
);
assert!(matches!(
state.conflicts.records()[0].kind,
ConflictKind::TransactionConflict { .. }
));
}
// --- Defect 5: causally ordered same-position insert is not promoted. --------
/// A same-position insert that *causally follows* the first (so it is not
/// concurrent) must be a precondition failure, never a voice promotion.
/// Promotion is for concurrent collisions only.
pub fn assert_causally_ordered_same_position_not_promoted() {
let first = envelope(1, 0, 10, CausalContext::new(), None, insert(0, 100, 0));
let second = envelope(
1,
1,
11,
CausalContext::new().with_seen(ReplicaId(1), 0),
None,
insert(0, 101, 0),
);
let mut set = OperationSet::new();
set.accept_all(vec![first, second.clone()]);
let state = set.reduce();
let effect = state
.effects
.iter()
.find(|(id, _)| *id == second.id)
.map(|(_, e)| e);
assert_eq!(
effect,
Some(&OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::EventDurationInvalid,
},
}),
"a causally-ordered same-position insert must fail its precondition"
);
assert!(
!promotes(effect),
"a causally-ordered insert must not be promoted as if concurrent"
);
}
// --- Defect 6: partial-duration overlaps. -----------------------------------
/// Concurrent inserts whose intervals *partially* overlap collide (and promote
/// the greater id), while concurrent inserts at *adjacent* half-open intervals
/// do not. The pre-fix overlap test only compared start positions.
pub fn assert_partial_overlap_promotes_but_adjacent_does_not() {
// Partial overlap: [0, 1) and [1/2, 3/2).
let first = envelope(
1,
0,
10,
CausalContext::new(),
None,
insert_span(0, 100, RationalTime::zero(), RationalTime::one()),
);
let second = envelope(
2,
0,
10,
CausalContext::new(),
None,
insert_span(
0,
200,
RationalTime::new(1, 2).unwrap(),
RationalTime::one(),
),
);
let mut set = OperationSet::new();
set.accept_all(vec![first, second.clone()]);
let state = set.reduce();
assert!(
promotes(
state
.effects
.iter()
.find(|(id, _)| *id == second.id)
.map(|(_, e)| e)
),
"a concurrent partial-interval overlap must promote the greater id"
);
// Adjacent half-open intervals: [0, 1) and [1, 2) — no collision.
let a = envelope(
1,
0,
10,
CausalContext::new(),
None,
insert_span(0, 100, RationalTime::zero(), RationalTime::one()),
);
let b = envelope(
2,
0,
10,
CausalContext::new(),
None,
insert_span(0, 200, RationalTime::one(), RationalTime::one()),
);
let mut adjacent = OperationSet::new();
adjacent.accept_all(vec![a, b]);
let adjacent_state = adjacent.reduce();
assert!(
adjacent_state
.effects
.iter()
.all(|(_, e)| *e == OperationEffect::Applied),
"adjacent half-open intervals must not collide"
);
}
/// Runs every audited-defect regression guard. The acceptance suite calls this
/// as a single entry point (criterion-adjacent: the audit tripwire).
pub fn run_all() {
assert_causal_order_dominates_inverted_hlc();
assert_missing_vector_predecessor_pends();
assert_hlc_100_200_50_quarantines_from_zero();
assert_failed_transaction_rolls_back_member_conflicts();
assert_causally_ordered_same_position_not_promoted();
assert_partial_overlap_promotes_but_adjacent_does_not();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn causal_order_dominates_inverted_hlc() {
assert_causal_order_dominates_inverted_hlc();
}
#[test]
fn missing_vector_predecessor_pends() {
assert_missing_vector_predecessor_pends();
}
#[test]
fn hlc_100_200_50_quarantines_from_zero() {
assert_hlc_100_200_50_quarantines_from_zero();
}
#[test]
fn failed_transaction_rolls_back_member_conflicts() {
assert_failed_transaction_rolls_back_member_conflicts();
}
#[test]
fn causally_ordered_same_position_not_promoted() {
assert_causally_ordered_same_position_not_promoted();
}
#[test]
fn partial_overlap_promotes_but_adjacent_does_not() {
assert_partial_overlap_promotes_but_adjacent_does_not();
}
}