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

214 lines
8.6 KiB
Rust

//! The equivocation harness (QUICKSTART, Agent F; v0 acceptance criterion 3):
//!
//! > An injected duplicate `OperationId` with different canonical bytes produces
//! > an `OperationSlot::Equivocated` at both replicas, regardless of which
//! > envelope arrived first. (Tests Pass 10's order-independence fix.)
//!
//! Driven against the **real** [`epiphany_ops`] crate. The harness asserts the
//! full §6.5 contract:
//!
//! 1. A byte-identical duplicate is dropped: the slot stays [`OperationSlot::Single`].
//! 2. A same-id / different-bytes twin equivocates the slot, and the resulting
//! [`OperationSlot::Equivocated`] is **identical regardless of arrival order**.
//! 3. An equivocated operation contributes **no canonical effect**: it appears in
//! no effect entry, and the canonical content (spellings/objects/breaks) is
//! byte-for-byte what it would be with the operation absent. A positive
//! control confirms the same operation *would* change that content if it were
//! `Single`, so the no-effect assertion is not vacuous.
use epiphany_core::{
EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, ReplicaId,
StaffInstanceId, VoiceId, WallClockTime,
};
use epiphany_ops::{
valuegen, AuthorId, CausalContext, HybridLogicalClock, InsertEventOp, MaterializedState,
OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationSlot,
OperationStamp, RespellPitchOp,
};
use crate::rng::Rng;
/// Agent C's authoritative equivocation gate, re-exported as the suite's entry
/// point.
pub use epiphany_ops::fuzz::run_equivocation_fuzz as ops_equivocation_fuzz;
/// The shared object-id namespace (matching [`crate::generators`]).
const OBJ_REPLICA: ReplicaId = ReplicaId(0x0B7E_C700);
fn reduce(envelopes: &[OperationEnvelope]) -> MaterializedState {
let mut set = OperationSet::new();
set.accept_all(envelopes.iter().cloned());
set.reduce()
}
/// Asserts the order-independence of equivocation for one `(base, twin)` pair
/// embedded in a surrounding `context`. `base` and `twin` share an `OperationId`
/// but differ in canonical bytes.
pub fn assert_equivocation_order_independent(
context: &[OperationEnvelope],
base: &OperationEnvelope,
twin: &OperationEnvelope,
) {
assert_eq!(base.id, twin.id, "base and twin must share an OperationId");
assert_ne!(
base.envelope_hash(),
twin.envelope_hash(),
"base and twin must differ in canonical bytes to equivocate"
);
// Arrival order A: context, then base, then twin.
let mut a = OperationSet::new();
a.accept_all(context.iter().cloned());
a.accept(base.clone());
a.accept(twin.clone());
// Arrival order B: twin first, then base, then context.
let mut b = OperationSet::new();
b.accept(twin.clone());
b.accept(base.clone());
b.accept_all(context.iter().cloned());
let slot_a = a.slot(base.id).expect("slot must exist");
let slot_b = b.slot(base.id).expect("slot must exist");
assert!(
matches!(slot_a, OperationSlot::Equivocated { .. }),
"order A did not equivocate: {slot_a:?}"
);
assert_eq!(
slot_a, slot_b,
"equivocated slot differs by arrival order (Pass 10 violation)"
);
}
/// Asserts a byte-identical duplicate of `env` leaves the slot [`OperationSlot::Single`].
pub fn assert_duplicate_is_idempotent(env: &OperationEnvelope) {
let mut s = OperationSet::new();
s.accept(env.clone());
s.accept(env.clone());
s.accept(env.clone());
assert!(
matches!(s.slot(env.id), Some(OperationSlot::Single(_))),
"byte-identical duplicate must not equivocate the slot"
);
}
/// Asserts the equivocated operation contributes **no canonical effect**, in a
/// self-contained controlled scenario: an `InsertEvent` makes a pitch live, then
/// a same-id / different-bytes pair of `RespellPitch` operations target it.
///
/// * with the pair equivocated, the operation appears in no effect entry and the
/// canonical content (spellings/objects/breaks) is exactly the
/// operation-absent baseline (the recorded anomaly differs, legitimately, so
/// content fields are compared, not the whole materialized state);
/// * a **positive control** confirms the same respelling, accepted alone as
/// `Single`, *does* change the canonical spellings — so the no-effect
/// assertion is not vacuous (the reducer would otherwise no-op a respelling of
/// a pitch that was never inserted).
pub fn assert_equivocated_has_no_effect() {
// An InsertEvent that mints event 0 carrying pitch 0 (so the pitch is live).
let insert = insert_pitch_env(OperationId::new(ReplicaId(1), 0), 0, 0, 0);
// The equivocating pair: same id, different spelling bytes, targeting pitch 0.
let id = OperationId::new(ReplicaId(9), 0);
let base = respell_env(id, 10, 0, 0xAA);
let twin = respell_env(id, 10, 0, 0xBB);
debug_assert_ne!(base.envelope_hash(), twin.envelope_hash());
let absent = reduce(std::slice::from_ref(&insert));
let with_equiv = reduce(&[insert.clone(), base.clone(), twin.clone()]);
let single = reduce(&[insert.clone(), base.clone()]);
assert!(
with_equiv.effects.iter().all(|(e, _)| *e != base.id),
"an equivocated operation must produce no canonical effect"
);
assert_eq!(
with_equiv.spellings, absent.spellings,
"equivocated operation must not change canonical spellings"
);
assert_eq!(
with_equiv.objects, absent.objects,
"equivocated operation must not change canonical object state"
);
assert_eq!(
with_equiv.breaks, absent.breaks,
"equivocated operation must not change canonical breaks"
);
assert_ne!(
single.spellings, absent.spellings,
"control: a Single respelling of a live pitch must change canonical spellings"
);
}
fn respell_env(id: OperationId, phys: i64, pitch: u64, spelling: u8) -> OperationEnvelope {
OperationEnvelope {
id,
author: AuthorId(0),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(phys), 0), id),
causal_context: CausalContext::new(),
transaction: None,
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: PitchId::new(OBJ_REPLICA, pitch),
spelling: valuegen::spelling(spelling),
})),
}
}
/// An `InsertEvent` minting `event` (carrying `pitch`) into voice 0 / instance 0,
/// so the pitch becomes live and a later `RespellPitch` can take effect.
fn insert_pitch_env(id: OperationId, phys: i64, event: u64, pitch: u64) -> OperationEnvelope {
OperationEnvelope {
id,
author: AuthorId(id.replica.0 as u128),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(phys), 0), id),
causal_context: CausalContext::new(),
transaction: None,
payload: OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
staff_instance: StaffInstanceId::new(OBJ_REPLICA, 0),
event: valuegen::insert_event_value(
EventId::new(OBJ_REPLICA, event),
VoiceId::new(OBJ_REPLICA, 0),
MusicalPosition(RationalTime::from_int(0)),
MusicalDuration::whole(),
&[PitchId::new(OBJ_REPLICA, pitch)],
),
})),
}
}
/// A self-contained driver: from `seed`, generate a context, mint a fresh target
/// operation (on a replica the context does not use), build its equivocating
/// twin, and assert the order-independence and idempotent-duplicate properties.
/// The no-effect property is asserted on its own controlled scenario.
pub fn run_equivocation(n_context: usize, seed: u64) {
let mut rng = Rng::new(seed);
// Context authored by replicas 1..=3.
let context = crate::generators::operation_envelopes(&mut rng, n_context, 3, 6, 6);
// The target lives on replica 9 (unused by the context) so it is a genuinely
// fresh operation id nothing causally depends on.
let id = OperationId::new(ReplicaId(9), rng.range(0, 5));
let base = respell_env(id, rng.range(0, 100) as i64, 0, 0xAA);
let twin = respell_env(id, rng.range(0, 100) as i64, 0, 0xBB);
assert_duplicate_is_idempotent(&base);
assert_equivocation_order_independent(&context, &base, &twin);
assert_equivocated_has_no_effect();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn equivocation_holds_across_many_seeds() {
for seed in 0..300u64 {
run_equivocation(12, seed.wrapping_mul(0x9E37_79B9).wrapping_add(1));
}
}
#[test]
fn agent_c_equivocation_gate_smoke() {
ops_equivocation_fuzz(500, 0x1234_5678);
}
}