214 lines
8.6 KiB
Rust
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);
|
|
}
|
|
}
|