//! Deterministic fuzz harnesses for the reduction (the Agent C hand-off gates). //! //! The QUICKSTART gates this crate on two properties: //! //! 1. **Reduction determinism** — "the determinism property holds across 10,000 //! randomized envelope sets": any permutation of an operation set reduces to //! *byte-identical* materialized state ([`run_reduction_determinism_fuzz`]). //! This is v0 acceptance criteria 1 (convergence) and 5 (reduction //! determinism), exercised at scale. //! 2. **Equivocation order-independence** — "the equivocation harness produces //! `OperationSlot::Equivocated` for any duplicate-id-with-different-bytes //! scenario regardless of arrival order" ([`run_equivocation_fuzz`]). This is //! v0 acceptance criterion 3 (Pass 10's order-independence fix). //! //! Both harnesses are themselves deterministic: they draw from a seeded //! SplitMix64 (the determinism crate's, reused — no `rand`, no platform //! entropy), so a failing iteration reproduces exactly from its seed. The //! generated sets deliberately reuse a small object-id space so deletes, //! respellings, and inserts interact (tombstones, already-applied, conflicts), //! and they occasionally inject equivocation and HLC-monotonicity anomalies so //! those paths are exercised for permutation-invariance too. use epiphany_core::{ EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, RegionId, ReplicaId, SlurId, StaffId, StaffInstanceId, TypedObjectId, VoiceId, }; use epiphany_determinism::fuzz::SplitMix64; use crate::causal::CausalContext; use crate::envelope::OperationEnvelope; use crate::opset::OperationSet; use crate::payload::{ CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetUserPageBreakOp, SetUserSystemBreakOp, TransposeOp, TupletCompensation, }; use crate::stamp::{HybridLogicalClock, OperationStamp}; use crate::support::AuthorId; use crate::valuegen; use crate::IntegrityAnomalyKind; /// Number of replicas the generator draws authors from. const REPLICAS: u64 = 3; /// Size of the shared object-id space (events, pitches), so operations /// genuinely interact during reduction. const ID_SPACE: u64 = 6; /// A tiny extension trait for readable bounded draws. trait Draw { fn below(&mut self, n: u64) -> u64; fn chance(&mut self, one_in: u64) -> bool; } impl Draw for SplitMix64 { #[inline] fn below(&mut self, n: u64) -> u64 { if n == 0 { 0 } else { self.next_u64() % n } } #[inline] fn chance(&mut self, one_in: u64) -> bool { self.below(one_in.max(1)) == 0 } } /// In-place Fisher–Yates shuffle driven by the seeded generator. fn shuffle(items: &mut [T], rng: &mut SplitMix64) { for i in (1..items.len()).rev() { let j = (rng.next_u64() % (i as u64 + 1)) as usize; items.swap(i, j); } } fn event(n: u64) -> EventId { EventId::new(ReplicaId(7), n) } fn pitch(n: u64) -> PitchId { PitchId::new(ReplicaId(7), n) } /// Generates a random payload over the shared id space. fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { 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)); let pitches = if rng.chance(2) { vec![pitch(rng.below(ID_SPACE))] } else { vec![] }; OperationKind::InsertEvent(InsertEventOp { staff_instance: StaffInstanceId::new(ReplicaId(7), 0), event: valuegen::insert_event_value( event(rng.below(ID_SPACE)), voice, position, MusicalDuration::whole(), &pitches, ), }) } 1 => OperationKind::DeleteEvent(DeleteEventOp { event: event(rng.below(ID_SPACE)), tuplet_compensation: TupletCompensation::NotInTuplet, }), 2 => OperationKind::RespellPitch(RespellPitchOp { pitch: pitch(rng.below(ID_SPACE)), spelling: valuegen::spelling(rng.below(4) as u8 + 1), }), 3 => OperationKind::CreateCrossCutting(CreateCrossCuttingOp { structure: CrossCuttingValue::Slur(valuegen::slur( SlurId::new(ReplicaId(7), rng.below(ID_SPACE)), event(rng.below(ID_SPACE)), event(rng.below(ID_SPACE)), )), }), 4 => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp { 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), }), // Group 1 (M2): event & pitch leaf-field ops over the shared id space. 5 => OperationKind::ModifyEvent(ModifyEventOp { event: valuegen::insert_event_value( event(rng.below(ID_SPACE)), VoiceId::new(ReplicaId(7), rng.below(3)), MusicalPosition(RationalTime::from_int(rng.below(4) as i32)), MusicalDuration::whole(), &[pitch(rng.below(ID_SPACE))], ), }), 6 => OperationKind::Transpose(TransposeOp { targets: vec![pitch(rng.below(ID_SPACE))], chromatic_steps: rng.below(5) as i32 - 2, }), 7 => OperationKind::InsertIdentifiedPitch(InsertIdentifiedPitchOp { event: event(rng.below(ID_SPACE)), pitch: valuegen::identified_pitch(pitch(rng.below(ID_SPACE))), }), 8 => OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp { pitch: pitch(rng.below(ID_SPACE)), }), 9 => OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp { pitch: pitch(rng.below(ID_SPACE)), value: valuegen::pitch_value_nth(rng.below(4) as u8 + 1), }), // Group 2 (M2): cross-cutting CRUD over the shared id space. 10 => OperationKind::DeleteCrossCutting(DeleteCrossCuttingOp { structure: TypedObjectId::Slur(SlurId::new(ReplicaId(7), rng.below(ID_SPACE))), }), 11 => OperationKind::ModifyCrossCutting(ModifyCrossCuttingOp { structure: CrossCuttingValue::Slur(valuegen::slur( SlurId::new(ReplicaId(7), rng.below(ID_SPACE)), event(rng.below(ID_SPACE)), event(rng.below(ID_SPACE)), )), }), // Group 3 (M2c): structural container CRUD over the shared id space. 12 => OperationKind::CreateRegion(CreateRegionOp { region: valuegen::region(RegionId::new(ReplicaId(7), rng.below(3))), }), 13 => OperationKind::DeleteRegion(DeleteRegionOp { region: RegionId::new(ReplicaId(7), rng.below(3)), }), 14 => OperationKind::CreateStaffInstance(CreateStaffInstanceOp { region: RegionId::new(ReplicaId(7), rng.below(3)), instance: valuegen::staff_instance( StaffInstanceId::new(ReplicaId(7), rng.below(3)), StaffId::new(ReplicaId(7), 0), ), }), 15 => OperationKind::DeleteStaffInstance(DeleteStaffInstanceOp { staff_instance: StaffInstanceId::new(ReplicaId(7), rng.below(3)), }), 16 => OperationKind::CreateVoice(CreateVoiceOp { staff_instance: StaffInstanceId::new(ReplicaId(7), rng.below(3)), voice: valuegen::voice(VoiceId::new(ReplicaId(7), rng.below(3))), }), 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) } /// Generates a random, mostly-well-formed operation set. Per-replica stamps are /// monotonic by construction (so most sets are anomaly-free), but the generator /// occasionally injects equivocation (a duplicate id with mutated bytes) and an /// HLC-monotonicity anomaly, so those paths are exercised for permutation /// invariance too. pub fn gen_envelope_set(rng: &mut SplitMix64, n: usize) -> Vec { let mut counters = [0u64; (REPLICAS + 1) as usize]; let mut clocks = [0i64; (REPLICAS + 1) as usize]; let mut stamps = vec![Vec::<(i64, u32)>::new(); (REPLICAS + 1) as usize]; let mut envs = Vec::with_capacity(n); for _ in 0..n { let replica = 1 + rng.below(REPLICAS); let r = replica as usize; let counter = counters[r]; let id = OperationId::new(ReplicaId(replica), counter); // Causal context contains prior history only. Track the maximum stamp // among selected predecessors so the new HLC can strictly outrank it. let mut ctx = CausalContext::new(); let mut pred_max = (0i64, 0u32); if counter > 0 { ctx = ctx.with_seen(ReplicaId(replica), counter - 1); pred_max = pred_max.max(stamps[r][(counter - 1) as usize]); } for rr in 1..counters.len() { if rr == r { continue; } let seen = counters[rr]; if seen > 0 && rng.chance(2) { let high = rng.below(seen); ctx = ctx.with_seen(ReplicaId(rr as u64), high); pred_max = pred_max.max(stamps[rr][high as usize]); } } clocks[r] += rng.below(3) as i64; let previous = stamps[r].last().copied().unwrap_or((0, 0)); let physical = clocks[r].max(previous.0).max(pred_max.0); let logical = if physical == previous.0 && physical == pred_max.0 { previous.1.max(pred_max.1) + 1 } else if physical == previous.0 { previous.1 + 1 } else if physical == pred_max.0 { pred_max.1 + 1 } else { 0 }; stamps[r].push((physical, logical)); counters[r] += 1; envs.push(OperationEnvelope { id, author: AuthorId(replica as u128), stamp: OperationStamp::new( HybridLogicalClock::new(epiphany_core::WallClockTime(physical), logical), id, ), causal_context: ctx, transaction: None, payload: gen_payload(rng), }); } // Occasionally inject an HLC-monotonicity anomaly: a fresh op on some // replica whose physical time is below an earlier one. if !envs.is_empty() && rng.chance(4) { let replica = 1 + rng.below(REPLICAS); let r = replica as usize; let counter = counters[r]; let id = OperationId::new(ReplicaId(replica), counter); envs.push(OperationEnvelope { id, author: AuthorId(replica as u128), // physical -1 is below every generated (non-negative) clock value. stamp: OperationStamp::new( HybridLogicalClock::new(epiphany_core::WallClockTime(0), 0), id, ), causal_context: CausalContext::new(), payload: gen_payload(rng), transaction: None, }); // The earlier op must outrank it; bump an existing op's clock high. if let Some(first) = envs .iter_mut() .find(|e| e.id.replica == ReplicaId(replica) && e.id.counter < counter) { first.stamp.hlc.physical_time = epiphany_core::WallClockTime(1_000_000); } } // Occasionally inject equivocation: a duplicate id with mutated payload. if !envs.is_empty() && rng.chance(4) { let victim = &envs[rng.below(envs.len() as u64) as usize]; let mut twin = victim.clone(); twin.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { pitch: pitch(rng.below(ID_SPACE)), spelling: valuegen::spelling(6), })); if twin.envelope_hash() != victim.envelope_hash() { envs.push(twin); } } envs } /// Reduces an envelope set accepted in the given order, returning the canonical /// materialized bytes. fn reduce_in_order(envs: &[OperationEnvelope]) -> Vec { let mut set = OperationSet::new(); set.accept_all(envs.iter().cloned()); set.reduce().canonical_bytes() } /// Runs `iters` reduction-determinism iterations from `seed`. Each iteration /// generates a random operation set and asserts that several random /// *acceptance orders* reduce to byte-identical materialized state. Panics on /// the first violation (the hand-off gate's failure condition). pub fn run_reduction_determinism_fuzz(iters: u64, seed: u64) { let mut rng = SplitMix64::new(seed); for _ in 0..iters { let n = 1 + rng.below(14) as usize; let base = gen_envelope_set(&mut rng, n); let reference = reduce_in_order(&base); for _ in 0..3 { let mut perm = base.clone(); shuffle(&mut perm, &mut rng); let got = reduce_in_order(&perm); assert_eq!( got, reference, "reduction is not permutation-invariant (n = {n})" ); } } } /// Runs `iters` equivocation iterations from `seed`. Each iteration builds two /// distinct canonical envelopes under one `OperationId`, accepts them (with a /// few unrelated envelopes) in a random order, and asserts the slot is /// `Equivocated`, the operation contributes no effect, and an /// `OperationSlotEquivocated` anomaly is recorded — regardless of arrival order. pub fn run_equivocation_fuzz(iters: u64, seed: u64) { let mut rng = SplitMix64::new(seed); for _ in 0..iters { let id = OperationId::new(ReplicaId(1 + rng.below(REPLICAS)), rng.below(5)); let mk = |rng: &mut SplitMix64, spelling: u8| OperationEnvelope { id, author: AuthorId(0), stamp: OperationStamp::new( HybridLogicalClock::new(epiphany_core::WallClockTime(rng.below(100) as i64), 0), id, ), causal_context: CausalContext::new(), transaction: None, payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { pitch: pitch(0), spelling: valuegen::spelling(spelling), })), }; let a = mk(&mut rng, 1); let b = mk(&mut rng, 2); // distinct canonical bytes (different spelling) debug_assert_ne!(a.envelope_hash(), b.envelope_hash()); // A few unrelated, well-formed envelopes to vary the surrounding set. let noise_count = rng.below(4) as usize; let noise = gen_envelope_set(&mut rng, noise_count) .into_iter() .filter(|e| e.id != id) .collect::>(); let mut items = vec![a.clone(), b.clone()]; items.extend(noise); shuffle(&mut items, &mut rng); let mut set = OperationSet::new(); set.accept_all(items); let slot = set.slot(id).expect("slot exists for the equivocating id"); assert!( slot.is_equivocated(), "duplicate id with different bytes must equivocate regardless of order" ); let state = set.reduce(); assert!( state.effects.iter().all(|(e, _)| *e != id), "an equivocated operation must produce no canonical effect" ); assert!( state.anomalies.iter().any(|an| matches!( an.kind, IntegrityAnomalyKind::OperationSlotEquivocated { operation_id } if operation_id == id )), "an equivocated slot must record an OperationSlotEquivocated anomaly" ); } } #[cfg(test)] mod tests { use super::*; #[test] fn reduction_determinism_smoke() { run_reduction_determinism_fuzz(500, 0xC0FFEE); } #[test] fn equivocation_smoke() { run_equivocation_fuzz(500, 0x1234_5678); } #[test] fn generator_is_deterministic() { let mut a = SplitMix64::new(99); let mut b = SplitMix64::new(99); let sa = gen_envelope_set(&mut a, 10); let sb = gen_envelope_set(&mut b, 10); assert_eq!(sa, sb); } #[test] fn clean_generated_histories_respect_causal_stamps() { let mut rng = SplitMix64::new(0xCA05_A117); let mut checked = 0; for _ in 0..2_000 { let set = gen_envelope_set(&mut rng, 24); let mut accepted = OperationSet::new(); accepted.accept_all(set); let singles = accepted.single_envelopes(); if !crate::anomaly::detect_replica_anomalies(&singles).is_empty() { continue; } for successor in &singles { for predecessor in &singles { if predecessor.id != successor.id && successor.causal_context.covers(predecessor.id) { assert!( predecessor.stamp.reduction_tuple() < successor.stamp.reduction_tuple(), "causal predecessor did not have a lower stamp" ); } } } checked += 1; } assert!(checked > 1_000, "too few clean generated histories checked"); } }