1118 lines
46 KiB
Rust
1118 lines
46 KiB
Rust
//! 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,
|
||
RepeatStructureId, ReplicaId, SlurId, StaffId, StaffInstanceId, TranspositionInterval,
|
||
TypedObjectId, VoiceId,
|
||
};
|
||
use epiphany_determinism::fuzz::SplitMix64;
|
||
|
||
use crate::causal::CausalContext;
|
||
use crate::envelope::OperationEnvelope;
|
||
use crate::opset::OperationSet;
|
||
use crate::payload::OperationKindTag;
|
||
use crate::payload::{
|
||
CreateCrossCuttingOp, CreateRegionOp, CreateRepeatStructureOp, CreateStaffInstanceOp,
|
||
CreateStaffOp, CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp,
|
||
DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteRepeatStructureOp, DeleteStaffInstanceOp,
|
||
DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp,
|
||
ModifyIdentifiedPitchOp, OperationKind, OperationPayload, RespellPitchOp, SetMetadataOp,
|
||
SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, SetTimeSignatureOp, SetUserPageBreakOp,
|
||
SetUserSystemBreakOp, TransposeIntervalOp, TransposeOp, TupletCompensation,
|
||
};
|
||
use crate::stamp::{HybridLogicalClock, OperationStamp};
|
||
use crate::support::AuthorId;
|
||
use crate::valuegen;
|
||
use crate::{EnvelopeHash, IntegrityAnomalyKind, OperationEffect};
|
||
use epiphany_determinism::{CanonicalDecode, CanonicalEncode};
|
||
|
||
/// 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<T>(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(28) {
|
||
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),
|
||
}),
|
||
20 => 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),
|
||
}),
|
||
// Phase-3 first tranche: staff mint, meter/tempo overwrites, layout
|
||
// advisory — over the same shared id space so mints, re-carries,
|
||
// overwrites, and removals genuinely interact.
|
||
21 => OperationKind::CreateStaff(CreateStaffOp {
|
||
staff: valuegen::staff(
|
||
StaffId::new(ReplicaId(7), rng.below(3)),
|
||
epiphany_core::InstrumentId::new(ReplicaId(7), rng.below(2)),
|
||
),
|
||
}),
|
||
22 => {
|
||
let region = RegionId::new(ReplicaId(7), rng.below(3));
|
||
OperationKind::SetTimeSignature(SetTimeSignatureOp {
|
||
region,
|
||
anchor: valuegen::region_start_anchor(
|
||
region,
|
||
MusicalPosition(RationalTime::from_int(rng.below(3) as i32 * 4)),
|
||
),
|
||
time_signature: (!rng.chance(3)).then(|| {
|
||
valuegen::time_signature(
|
||
epiphany_core::TimeSignatureId::new(ReplicaId(7), rng.below(3)),
|
||
rng.below(3) as u16 + 2,
|
||
)
|
||
}),
|
||
})
|
||
}
|
||
23 => {
|
||
let region = RegionId::new(ReplicaId(7), rng.below(3));
|
||
let at = rng.below(3) as i32 * 4;
|
||
OperationKind::SetTempoSegment(SetTempoSegmentOp {
|
||
region: rng.chance(2).then_some(region),
|
||
start: valuegen::region_start_anchor(
|
||
region,
|
||
MusicalPosition(RationalTime::from_int(at)),
|
||
),
|
||
segment: (!rng.chance(3)).then(|| {
|
||
valuegen::tempo_segment(
|
||
region,
|
||
MusicalPosition(RationalTime::from_int(at)),
|
||
60.0 + rng.below(4) as f64 * 30.0,
|
||
)
|
||
}),
|
||
})
|
||
}
|
||
// Repeat authoring (schema-major-2 revision) over the shared
|
||
// event-id space.
|
||
25 => OperationKind::CreateRepeatStructure(CreateRepeatStructureOp {
|
||
repeat: if rng.chance(2) {
|
||
valuegen::repeat_structure(
|
||
RepeatStructureId::new(ReplicaId(7), rng.below(3)),
|
||
event(rng.below(ID_SPACE)),
|
||
event(rng.below(ID_SPACE)),
|
||
)
|
||
} else {
|
||
valuegen::volta_repeat(
|
||
RepeatStructureId::new(ReplicaId(7), rng.below(3)),
|
||
event(rng.below(ID_SPACE)),
|
||
event(rng.below(ID_SPACE)),
|
||
)
|
||
},
|
||
}),
|
||
26 => OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp {
|
||
repeat: RepeatStructureId::new(ReplicaId(7), rng.below(3)),
|
||
}),
|
||
// Push 4a. The frozen `Transpose` (arm 6) keeps its own coverage: it
|
||
// must reduce correctly forever, and only a generator will ever author
|
||
// one again.
|
||
27 => OperationKind::TransposeInterval(TransposeIntervalOp {
|
||
targets: (0..1 + rng.below(2))
|
||
.map(|_| pitch(rng.below(ID_SPACE)))
|
||
.collect(),
|
||
interval: TranspositionInterval {
|
||
diatonic_steps: rng.below(5) as i32 - 2,
|
||
chromatic_steps: rng.below(9) as i32 - 4,
|
||
},
|
||
}),
|
||
_ => OperationKind::SetStaffLayout(SetStaffLayoutOp {
|
||
staff_instance: StaffInstanceId::new(ReplicaId(7), rng.below(3)),
|
||
instrument_override: None,
|
||
staff_lines_override: rng
|
||
.chance(2)
|
||
.then(epiphany_core::StaffLineConfiguration::default),
|
||
visible: 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<OperationEnvelope> {
|
||
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<u8> {
|
||
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::<Vec<_>>();
|
||
|
||
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"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Runs `iters` equivocation-*resolution* iterations from `seed` (the
|
||
/// `ResolveEquivocation` sibling of [`run_equivocation_fuzz`]). Each iteration
|
||
/// builds two distinct canonical envelopes under one `OperationId` plus a
|
||
/// `ResolveEquivocation`, embeds them in random noise, and asserts across four
|
||
/// random acceptance orders:
|
||
///
|
||
/// * with a **valid** resolve (`chosen` names a real candidate): the resolved
|
||
/// slot contributes an effect at its own id, no `OperationSlotEquivocated`
|
||
/// anomaly is recorded for it, the resolve itself applies, and every
|
||
/// permutation reduces to byte-identical [`crate::MaterializedState`];
|
||
/// * with an **invalid** resolve (`chosen` names no candidate): behavior is
|
||
/// exactly today's unresolved equivocation — the slot contributes nothing,
|
||
/// the anomaly is recorded, the resolve is a precondition no-op — and the
|
||
/// permutation invariance still holds.
|
||
pub fn run_equivocation_resolution_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());
|
||
|
||
// Valid two-thirds of the time; otherwise a hash naming no candidate.
|
||
let valid = !rng.chance(3);
|
||
let chosen = if !valid {
|
||
EnvelopeHash([0xEE; 32])
|
||
} else if rng.chance(2) {
|
||
a.envelope_hash()
|
||
} else {
|
||
b.envelope_hash()
|
||
};
|
||
// The resolve lives on a replica the noise generator never draws
|
||
// (noise uses 1..=REPLICAS), so its own slot can neither equivocate
|
||
// nor land in a quarantined segment.
|
||
let resolve_id = OperationId::new(ReplicaId(REPLICAS + 2), 0);
|
||
let resolve = OperationEnvelope {
|
||
id: resolve_id,
|
||
author: AuthorId(0),
|
||
stamp: OperationStamp::new(
|
||
HybridLogicalClock::new(epiphany_core::WallClockTime(rng.below(100) as i64), 0),
|
||
resolve_id,
|
||
),
|
||
causal_context: CausalContext::new(),
|
||
transaction: None,
|
||
payload: OperationPayload::ResolveEquivocation(
|
||
crate::payload::ResolveEquivocationPayload { target: id, chosen },
|
||
),
|
||
};
|
||
|
||
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::<Vec<_>>();
|
||
|
||
let mut items = vec![a.clone(), b.clone(), resolve.clone()];
|
||
items.extend(noise);
|
||
|
||
let mut reference: Option<Vec<u8>> = None;
|
||
for _ in 0..4 {
|
||
shuffle(&mut items, &mut rng);
|
||
let mut set = OperationSet::new();
|
||
set.accept_all(items.iter().cloned());
|
||
let state = set.reduce();
|
||
|
||
let slot_contributes = state.effects.iter().any(|(e, _)| *e == id);
|
||
let anomalous = state.anomalies.iter().any(|an| matches!(
|
||
an.kind,
|
||
IntegrityAnomalyKind::OperationSlotEquivocated { operation_id } if operation_id == id
|
||
));
|
||
let resolve_effect = state
|
||
.effects
|
||
.iter()
|
||
.find(|(e, _)| *e == resolve_id)
|
||
.map(|(_, eff)| eff);
|
||
if valid {
|
||
assert!(
|
||
slot_contributes,
|
||
"a resolved slot must contribute its chosen candidate's effect"
|
||
);
|
||
assert!(
|
||
!anomalous,
|
||
"a resolved slot must record no OperationSlotEquivocated anomaly"
|
||
);
|
||
assert_eq!(
|
||
resolve_effect,
|
||
Some(&OperationEffect::Applied),
|
||
"the governing resolve must apply"
|
||
);
|
||
} else {
|
||
assert!(
|
||
!slot_contributes,
|
||
"an unresolved equivocated slot must contribute nothing"
|
||
);
|
||
assert!(
|
||
anomalous,
|
||
"an unresolved equivocated slot must record its anomaly"
|
||
);
|
||
assert!(
|
||
matches!(resolve_effect, Some(OperationEffect::NoOp { .. })),
|
||
"an invalid resolve must be a precondition no-op, got {resolve_effect:?}"
|
||
);
|
||
}
|
||
|
||
let bytes = state.canonical_bytes();
|
||
match &reference {
|
||
None => reference = Some(bytes),
|
||
Some(reference) => assert_eq!(
|
||
&bytes, reference,
|
||
"equivocation resolution is not permutation-invariant"
|
||
),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Decode fuzzing (P2 of the decode-hardening track; P1 covered `epiphany-core`).
|
||
//
|
||
// The operation layer exposes exactly two byte-decode surfaces:
|
||
// `MaterializedState::decode_canonical` and `OperationKindTag::decode_canonical`.
|
||
// Operation *payloads* have no decoder — `OperationKind` is encode-only — so
|
||
// there is nothing here that could accept a duplicate `TransposeInterval`
|
||
// target. When such a decoder lands it inherits the wire table's `seq^⇑` rule:
|
||
// reject a duplicate, never normalize it away.
|
||
//
|
||
// The properties, exactly as in P1: an adversarial byte string must never panic
|
||
// a decoder, and an *accepted* string must re-encode to itself. Canonical decode
|
||
// is injective, which is what content-addressing rests on.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// How much of the decode surface a fuzz run actually reached. A harness that
|
||
/// never gets a decoder to say `Ok` proves nothing about injectivity, so the
|
||
/// run asserts on these rather than merely on the absence of a panic.
|
||
#[derive(Default, Debug, PartialEq, Eq)]
|
||
pub struct DecodeFuzzCoverage {
|
||
pub states_accepted: u64,
|
||
pub states_rejected: u64,
|
||
pub tags_accepted: u64,
|
||
pub tags_rejected: u64,
|
||
}
|
||
|
||
struct DecodeCorpus {
|
||
states: Vec<Vec<u8>>,
|
||
tags: Vec<Vec<u8>>,
|
||
}
|
||
|
||
/// Builds the pool of valid canonical byte strings to mutate.
|
||
///
|
||
/// Corpus depth is load-bearing. A small envelope set reduces to a state with no
|
||
/// conflicts, anomalies, pending operations, or spellings — and those branches
|
||
/// carry every canonical-order check the decoder makes. A corpus without them
|
||
/// leaves the decoder's interesting half unreached, and a green run proves
|
||
/// nothing.
|
||
///
|
||
/// So the corpus is *drawn until covered* rather than drawn once and hoped over:
|
||
/// a fixed set of sizes is seed-dependent (measured: 6 of 12 seeds failed to
|
||
/// produce all four structures). Each round adds one state per size; the loop
|
||
/// stops as soon as every structure has appeared. The final assertion is then a
|
||
/// property of the harness, not of the seed.
|
||
fn build_decode_corpus(rng: &mut SplitMix64) -> DecodeCorpus {
|
||
const MAX_ROUNDS: usize = 12;
|
||
let mut states = Vec::new();
|
||
let (mut conflicts, mut anomalies, mut pending, mut spellings) = (false, false, false, false);
|
||
|
||
for round in 0..MAX_ROUNDS {
|
||
for n in [0usize, 1, 8, 200, 400] {
|
||
// Skip the trivial sizes after the first round: they only ever add
|
||
// shallow states, and one of each is enough to cover the empty and
|
||
// near-empty encodings.
|
||
if round > 0 && n < 200 {
|
||
continue;
|
||
}
|
||
let envelopes = gen_envelope_set(rng, n);
|
||
let mut set = OperationSet::new();
|
||
set.accept_all(envelopes);
|
||
let state = set.reduce();
|
||
conflicts |= !state.conflicts.records().is_empty();
|
||
anomalies |= !state.anomalies.is_empty();
|
||
pending |= !state.pending.is_empty();
|
||
spellings |= !state.spellings.is_empty();
|
||
states.push(state.canonical_bytes());
|
||
}
|
||
if conflicts && anomalies && pending && spellings {
|
||
break;
|
||
}
|
||
}
|
||
assert!(
|
||
conflicts && anomalies && pending && spellings,
|
||
"decode corpus never reached every structure in {MAX_ROUNDS} rounds \
|
||
(conflicts={conflicts}, anomalies={anomalies}, pending={pending}, \
|
||
spellings={spellings}); the decoder's canonical-order checks live in \
|
||
exactly those branches"
|
||
);
|
||
|
||
// EVERY tag, from the production vocabulary — not a hand-picked five. A tag
|
||
// named nowhere is a tag whose decoder is never exercised, which is how
|
||
// `TransposeInterval` encoded to a byte its own decoder rejected.
|
||
let tags = OperationKindTag::PAYLOAD_FREE
|
||
.iter()
|
||
.copied()
|
||
.chain(std::iter::once(OperationKindTag::Registered(
|
||
crate::OperationKindRegistryId(0x0123_4567_89AB_CDEF),
|
||
)))
|
||
.map(|t| t.to_canonical_bytes())
|
||
.collect();
|
||
DecodeCorpus { states, tags }
|
||
}
|
||
|
||
fn pick<'a>(rng: &mut SplitMix64, pool: &'a [Vec<u8>]) -> &'a [u8] {
|
||
&pool[(rng.next_u64() as usize) % pool.len()]
|
||
}
|
||
|
||
fn random_bytes(rng: &mut SplitMix64, n: usize) -> Vec<u8> {
|
||
(0..n).map(|_| rng.next_u64() as u8).collect()
|
||
}
|
||
|
||
fn substitute(rng: &mut SplitMix64, bytes: &mut [u8], k: usize) {
|
||
if bytes.is_empty() {
|
||
return;
|
||
}
|
||
for _ in 0..k {
|
||
let i = (rng.next_u64() as usize) % bytes.len();
|
||
bytes[i] = rng.next_u64() as u8;
|
||
}
|
||
}
|
||
|
||
/// Overwrites a random 4-byte window with a fresh, often-extreme `u32`: the
|
||
/// count/length-prefix attack a `Vec` decoder trusts for its element count.
|
||
fn corrupt_length_prefix(rng: &mut SplitMix64, bytes: &mut [u8]) {
|
||
if bytes.len() < 4 {
|
||
return;
|
||
}
|
||
let i = (rng.next_u64() as usize) % (bytes.len() - 3);
|
||
let v: u32 = match rng.next_u64() % 3 {
|
||
0 => u32::MAX,
|
||
1 => (rng.next_u64() as u32) | 0x8000_0000,
|
||
_ => rng.next_u64() as u32,
|
||
};
|
||
bytes[i..i + 4].copy_from_slice(&v.to_le_bytes());
|
||
}
|
||
|
||
/// One adversarial `MaterializedState` input. Strategy 1 is *unmutated* valid
|
||
/// bytes — a live check that the harness's own corpus round-trips.
|
||
fn gen_state_input(rng: &mut SplitMix64, corpus: &DecodeCorpus) -> Vec<u8> {
|
||
match rng.next_u64() % 7 {
|
||
0 => {
|
||
let n = (rng.next_u64() % 512) as usize;
|
||
random_bytes(rng, n)
|
||
}
|
||
1 => pick(rng, &corpus.states).to_vec(),
|
||
2 => {
|
||
let mut b = pick(rng, &corpus.states).to_vec();
|
||
let k = 1 + (rng.next_u64() % 4) as usize;
|
||
substitute(rng, &mut b, k);
|
||
b
|
||
}
|
||
3 => {
|
||
let mut b = pick(rng, &corpus.states).to_vec();
|
||
let t = (rng.next_u64() as usize) % (b.len() + 1);
|
||
b.truncate(t);
|
||
b
|
||
}
|
||
4 => {
|
||
let mut b = pick(rng, &corpus.states).to_vec();
|
||
let n = 1 + (rng.next_u64() % 16) as usize;
|
||
let tail = random_bytes(rng, n);
|
||
b.extend_from_slice(&tail);
|
||
b
|
||
}
|
||
5 => {
|
||
let mut b = pick(rng, &corpus.states).to_vec();
|
||
corrupt_length_prefix(rng, &mut b);
|
||
b
|
||
}
|
||
// A valid `OperationKindTag` standing where a state is expected: a
|
||
// structurally plausible, wrong-type payload.
|
||
_ => pick(rng, &corpus.tags).to_vec(),
|
||
}
|
||
}
|
||
|
||
/// One adversarial `OperationKindTag` input.
|
||
fn gen_tag_input(rng: &mut SplitMix64, corpus: &DecodeCorpus) -> Vec<u8> {
|
||
match rng.next_u64() % 5 {
|
||
0 => pick(rng, &corpus.tags).to_vec(),
|
||
1 => {
|
||
let mut b = pick(rng, &corpus.tags).to_vec();
|
||
substitute(rng, &mut b, 1);
|
||
b
|
||
}
|
||
2 => {
|
||
let mut b = pick(rng, &corpus.tags).to_vec();
|
||
let n = 1 + (rng.next_u64() % 4) as usize;
|
||
let tail = random_bytes(rng, n);
|
||
b.extend_from_slice(&tail);
|
||
b
|
||
}
|
||
3 => {
|
||
let mut b = pick(rng, &corpus.tags).to_vec();
|
||
let t = (rng.next_u64() as usize) % (b.len() + 1);
|
||
b.truncate(t);
|
||
b
|
||
}
|
||
_ => {
|
||
let n = (rng.next_u64() % 24) as usize;
|
||
random_bytes(rng, n)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Runs `iters` adversarial byte-decode iterations from `seed` against the
|
||
/// operation layer's two decoders, returning what it reached. A panic, or an
|
||
/// accepted byte string that does not re-encode to itself, fails the run;
|
||
/// `seed` reproduces it exactly.
|
||
pub fn run_decode_fuzz(iters: u64, seed: u64) -> DecodeFuzzCoverage {
|
||
let mut rng = SplitMix64::new(seed);
|
||
let corpus = build_decode_corpus(&mut rng);
|
||
let mut cov = DecodeFuzzCoverage::default();
|
||
|
||
// The corpus must decode. Obvious, and it was not checked: an *unmutated*
|
||
// valid byte string that a decoder rejects was silently tallied as a
|
||
// rejection, like any garbage input. `OperationKindTag::TransposeInterval`
|
||
// encoded to `[30]` and would not read back for two commits, right here in
|
||
// the corpus, and this fuzzer reported green (Push 5 / P4).
|
||
for bytes in &corpus.states {
|
||
let state = crate::MaterializedState::decode_canonical(bytes)
|
||
.expect("a valid materialized state must decode");
|
||
assert_eq!(state.canonical_bytes(), *bytes);
|
||
}
|
||
for bytes in &corpus.tags {
|
||
let tag = OperationKindTag::decode_canonical(bytes)
|
||
.expect("a valid operation-kind tag must decode");
|
||
assert_eq!(tag.to_canonical_bytes(), *bytes);
|
||
}
|
||
|
||
for _ in 0..iters {
|
||
let bytes = gen_state_input(&mut rng, &corpus);
|
||
match crate::MaterializedState::decode_canonical(&bytes) {
|
||
Ok(state) => {
|
||
cov.states_accepted += 1;
|
||
assert_eq!(
|
||
state.canonical_bytes(),
|
||
bytes,
|
||
"the materialized-state decoder accepted a non-canonical byte string"
|
||
);
|
||
}
|
||
Err(_) => cov.states_rejected += 1,
|
||
}
|
||
|
||
let tag_bytes = gen_tag_input(&mut rng, &corpus);
|
||
match OperationKindTag::decode_canonical(&tag_bytes) {
|
||
Ok(tag) => {
|
||
cov.tags_accepted += 1;
|
||
assert_eq!(
|
||
tag.to_canonical_bytes(),
|
||
tag_bytes,
|
||
"the OperationKindTag decoder accepted a non-canonical byte string"
|
||
);
|
||
}
|
||
Err(_) => cov.tags_rejected += 1,
|
||
}
|
||
}
|
||
cov
|
||
}
|
||
|
||
#[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 equivocation_resolution_smoke() {
|
||
run_equivocation_resolution_fuzz(500, 0x9E50_1AE5);
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
/// Two deterministic smoke seeds over the decode surface. A deeper sweep
|
||
/// runs the same entry point with a larger budget; a failure reproduces
|
||
/// exactly from its seed.
|
||
///
|
||
/// The coverage assertions are the point. A decode fuzzer that never gets a
|
||
/// decoder to say `Ok` proves nothing about injectivity — it only proves the
|
||
/// absence of a panic — so the run fails if it did not reach the accept path
|
||
/// on both surfaces.
|
||
#[test]
|
||
fn decode_fuzz_smoke_seed_a() {
|
||
let cov = run_decode_fuzz(20_000, 0x0DEC_0DE0_F022_1234);
|
||
assert!(cov.states_accepted > 1_000, "{cov:?}");
|
||
assert!(cov.states_rejected > 1_000, "{cov:?}");
|
||
assert!(cov.tags_accepted > 1_000, "{cov:?}");
|
||
assert!(cov.tags_rejected > 1_000, "{cov:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn decode_fuzz_smoke_seed_b() {
|
||
let cov = run_decode_fuzz(20_000, 0xF0FA_11BA_C0DE_5EED);
|
||
assert!(cov.states_accepted > 1_000, "{cov:?}");
|
||
assert!(cov.tags_accepted > 1_000, "{cov:?}");
|
||
}
|
||
|
||
/// `objects` is a `BTreeMap` with **no per-site order check**: the decoder
|
||
/// re-sorts it silently. Only the whole-state re-encode-and-compare guard
|
||
/// can reject an out-of-order encoding of it, so this is the deterministic
|
||
/// lock on that guard — the fuzzer's 2M-input sweep of the same property
|
||
/// finds it too, but a seeded sweep is not a proof.
|
||
///
|
||
/// Without the guard the decoder would be non-injective: two distinct byte
|
||
/// strings would decode to one value, which is what content-addressing
|
||
/// forbids.
|
||
#[test]
|
||
fn an_out_of_order_objects_map_is_rejected_by_the_whole_state_guard() {
|
||
use crate::ObjectState;
|
||
use epiphany_core::EventId;
|
||
|
||
let lo = TypedObjectId::Event(EventId::new(ReplicaId(1), 1));
|
||
let hi = TypedObjectId::Event(EventId::new(ReplicaId(1), 2));
|
||
assert!(lo < hi);
|
||
|
||
let empty = crate::MaterializedState::default().canonical_bytes();
|
||
let mut state = crate::MaterializedState::default();
|
||
state.objects.insert(lo, ObjectState::Live);
|
||
state.objects.insert(hi, ObjectState::Live);
|
||
let bytes = state.canonical_bytes();
|
||
assert!(crate::MaterializedState::decode_canonical(&bytes).is_ok());
|
||
|
||
// The two encodings agree until the objects count, and differ in length
|
||
// by exactly the two entries.
|
||
let count_at = empty
|
||
.iter()
|
||
.zip(bytes.iter())
|
||
.position(|(a, b)| a != b)
|
||
.expect("the objects count differs");
|
||
let entry = (bytes.len() - empty.len()) / 2;
|
||
let first = count_at + 4;
|
||
let second = first + entry;
|
||
|
||
// Swap the two entries: structurally valid, canonically wrong.
|
||
let mut swapped = bytes.clone();
|
||
swapped[first..second].copy_from_slice(&bytes[second..second + entry]);
|
||
swapped[second..second + entry].copy_from_slice(&bytes[first..second]);
|
||
assert_ne!(swapped, bytes, "the swap changed the bytes");
|
||
|
||
assert_eq!(
|
||
crate::MaterializedState::decode_canonical(&swapped),
|
||
Err(crate::MaterializedDecodeError::NonCanonical),
|
||
"an out-of-order objects map must be rejected, never silently re-sorted"
|
||
);
|
||
}
|
||
|
||
/// **The guard is not complete for order-preserving fields.**
|
||
///
|
||
/// `MaterializedState`'s decode has two layers. The whole-state
|
||
/// re-encode-and-compare guard catches every field the decoder *normalizes*
|
||
/// — the `BTreeMap`s re-sort and de-duplicate, so a non-canonical encoding
|
||
/// of them cannot survive a round trip. It is blind to `Vec` fields, whose
|
||
/// order the decoder preserves: a reordered `anomalies` or `pending` list
|
||
/// re-encodes to exactly the bytes it came from.
|
||
///
|
||
/// Those fields therefore need per-site canonical-order checks, and those
|
||
/// checks are invisible to an injectivity fuzzer — verified by removing them
|
||
/// and watching a 40K-input sweep stay green. The deterministic tests here
|
||
/// are the only thing that locks them.
|
||
///
|
||
/// (`effects` is a `Vec` with no order check by design: its canonical order
|
||
/// is *reduction* order, which a decoder cannot recompute. Two orderings are
|
||
/// two different states, so injectivity is not at stake.)
|
||
#[test]
|
||
fn an_out_of_order_anomaly_list_is_rejected() {
|
||
use crate::{IntegrityAnomaly, IntegrityAnomalyKind};
|
||
|
||
let a = IntegrityAnomaly::new(IntegrityAnomalyKind::OperationSlotEquivocated {
|
||
operation_id: OperationId::new(ReplicaId(1), 1),
|
||
});
|
||
let b = IntegrityAnomaly::new(IntegrityAnomalyKind::OperationSlotEquivocated {
|
||
operation_id: OperationId::new(ReplicaId(1), 2),
|
||
});
|
||
let (lo, hi) = if a.id < b.id { (a, b) } else { (b, a) };
|
||
|
||
let empty = crate::MaterializedState::default().canonical_bytes();
|
||
let state = crate::MaterializedState {
|
||
anomalies: vec![lo.clone(), hi.clone()],
|
||
..Default::default()
|
||
};
|
||
let bytes = state.canonical_bytes();
|
||
assert!(crate::MaterializedState::decode_canonical(&bytes).is_ok());
|
||
|
||
// The anomalies are equal-length, length-prefixed entries, so swapping
|
||
// them is a pure permutation of the byte string.
|
||
let count_at = empty
|
||
.iter()
|
||
.zip(bytes.iter())
|
||
.position(|(x, y)| x != y)
|
||
.expect("the anomaly count differs");
|
||
let entry = (bytes.len() - empty.len()) / 2;
|
||
let first = count_at + 4;
|
||
let second = first + entry;
|
||
|
||
let mut swapped = bytes.clone();
|
||
swapped[first..second].copy_from_slice(&bytes[second..second + entry]);
|
||
swapped[second..second + entry].copy_from_slice(&bytes[first..second]);
|
||
assert_ne!(swapped, bytes);
|
||
|
||
// The decoder preserves `Vec` order, so `swapped` re-encodes to itself:
|
||
// the whole-state guard cannot see this. Only the per-site check can.
|
||
assert_eq!(
|
||
crate::MaterializedState::decode_canonical(&swapped),
|
||
Err(crate::MaterializedDecodeError::NonCanonical),
|
||
"an out-of-order anomaly register must be rejected"
|
||
);
|
||
}
|
||
|
||
/// The corpus must reach the decoder's interesting half regardless of seed.
|
||
/// A fixed list of envelope-set sizes does not: measured, 6 of 12 seeds
|
||
/// failed to produce all four structures, so `build_decode_corpus` draws
|
||
/// until covered. This pins that.
|
||
#[test]
|
||
fn the_decode_corpus_is_covered_for_every_seed() {
|
||
for seed in 1u64..=12 {
|
||
let _ = build_decode_corpus(&mut SplitMix64::new(seed));
|
||
}
|
||
}
|
||
|
||
/// The `pending` list carries its own per-site canonical-order check, so a
|
||
/// reordered one is rejected even without the whole-state guard. This locks
|
||
/// that check specifically (verified: it still passes when the guard is
|
||
/// removed).
|
||
#[test]
|
||
fn a_reordered_pending_list_is_rejected() {
|
||
use crate::PendingReason;
|
||
let mut state = crate::MaterializedState::default();
|
||
let lo = OperationId::new(ReplicaId(1), 1);
|
||
let hi = OperationId::new(ReplicaId(1), 2);
|
||
state.pending = vec![(lo, PendingReason::MissingCausalPredecessor { missing: lo })];
|
||
let canonical = state.canonical_bytes();
|
||
assert!(crate::MaterializedState::decode_canonical(&canonical).is_ok());
|
||
|
||
// Two entries in canonical order decode; the same two reversed do not.
|
||
state.pending = vec![
|
||
(lo, PendingReason::MissingCausalPredecessor { missing: lo }),
|
||
(hi, PendingReason::MissingCausalPredecessor { missing: lo }),
|
||
];
|
||
let ok_bytes = state.canonical_bytes();
|
||
assert!(crate::MaterializedState::decode_canonical(&ok_bytes).is_ok());
|
||
|
||
state.pending = vec![
|
||
(hi, PendingReason::MissingCausalPredecessor { missing: lo }),
|
||
(lo, PendingReason::MissingCausalPredecessor { missing: lo }),
|
||
];
|
||
let bad_bytes = state.canonical_bytes();
|
||
assert_ne!(ok_bytes, bad_bytes, "the two orders differ on the wire");
|
||
assert_eq!(
|
||
crate::MaterializedState::decode_canonical(&bad_bytes),
|
||
Err(crate::MaterializedDecodeError::NonCanonical),
|
||
"a non-canonical pending order must be rejected, never re-sorted"
|
||
);
|
||
}
|
||
}
|