395 lines
17 KiB
Rust
395 lines
17 KiB
Rust
//! The CRDT convergence harness (QUICKSTART, Agent F): *"apply same envelope
|
|
//! set in N random orders, assert byte-identical materialized state."* This is
|
|
//! v0 acceptance criterion 1 (Convergence) and criterion 5 (Reduction
|
|
//! determinism) — the determinism heart of Chapter 6.
|
|
//!
|
|
//! It drives the **real** [`epiphany_ops`] crate (Agent C has shipped). There
|
|
//! are two levels of convergence, and this harness asserts both:
|
|
//!
|
|
//! * **Real-Score convergence** ([`assert_graph_convergence`],
|
|
//! [`run_graph_convergence`]) is the headline criterion 1: an edit session is
|
|
//! reduced onto a real base [`epiphany_core::Score`] via
|
|
//! [`OperationSet::reduce_onto`], and the entire materialized graph (the
|
|
//! `Score` *and* its bookkeeping state) must be identical across delivery
|
|
//! orders, pass `check_invariants`, and genuinely mutate the score.
|
|
//! * **Reducer-bookkeeping convergence** ([`assert_convergence`],
|
|
//! [`run_convergence`], [`run_two_staff_convergence`]) compares the canonical
|
|
//! *bookkeeping projection* — [`OperationSet::reduce`] →
|
|
//! `MaterializedState::canonical_bytes` — across delivery orders. This is the
|
|
//! Chapter 6 §6.3 ledger (effects, conflicts, anomalies, tombstones,
|
|
//! spellings, pending), not the full musical graph; it is retained as the
|
|
//! byte-canonical determinism gate and the basis of criterion 5.
|
|
//!
|
|
//! Because the canonical reduction order
|
|
//! ([`epiphany_ops::canonical_reduction_order`]) is a function of the operation
|
|
//! *set* and not of delivery order, every delivery permutation must materialize
|
|
//! identically at both levels. The negative control in the tests proves the
|
|
//! harness is not vacuous: a reducer that consumed *arrival* order instead would
|
|
//! diverge, and this harness would catch it.
|
|
|
|
use epiphany_core::{check_invariants, OperationId, Score, StaffInstanceId, VoiceId};
|
|
use epiphany_ops::{
|
|
canonical_reduction_order, GraphMaterialization, OperationEnvelope, OperationSet,
|
|
};
|
|
|
|
use crate::rng::Rng;
|
|
|
|
/// Agent C's authoritative reduction-determinism gate (10,000 randomized sets;
|
|
/// criteria 1 and 5 at scale), re-exported as the suite's single entry point.
|
|
pub use epiphany_ops::fuzz::run_reduction_determinism_fuzz as ops_reduction_determinism_fuzz;
|
|
|
|
/// Accepts `envelopes` in the given order into a fresh [`OperationSet`] and
|
|
/// returns the canonical materialized bytes.
|
|
fn materialize_in_order(envelopes: &[OperationEnvelope]) -> Vec<u8> {
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(envelopes.iter().cloned());
|
|
set.reduce().canonical_bytes()
|
|
}
|
|
|
|
/// The canonical reduction order (as `OperationId`s) of the accepted singles.
|
|
fn reduction_order_of(envelopes: &[OperationEnvelope]) -> Vec<OperationId> {
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(envelopes.iter().cloned());
|
|
let singles = set.single_envelopes();
|
|
canonical_reduction_order(&singles)
|
|
.into_iter()
|
|
.map(|e| e.id)
|
|
.collect()
|
|
}
|
|
|
|
/// Asserts that `envelopes` materialize to **byte-identical** state under
|
|
/// `orders` independent random delivery permutations (acceptance criterion 1).
|
|
/// Panics, with the diverging permutation index, on the first mismatch.
|
|
pub fn assert_convergence(envelopes: &[OperationEnvelope], orders: usize, rng: &mut Rng) {
|
|
let reference = materialize_in_order(envelopes);
|
|
for k in 0..orders {
|
|
let perm = rng.permutation(envelopes.len());
|
|
let shuffled: Vec<OperationEnvelope> = perm.iter().map(|&i| envelopes[i].clone()).collect();
|
|
let got = materialize_in_order(&shuffled);
|
|
assert_eq!(
|
|
reference, got,
|
|
"delivery permutation #{k} changed the materialized state \
|
|
(reduction is not order-independent)"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Asserts the stronger §6.7 property used by acceptance criterion 5: both the
|
|
/// **materialized state** and the **canonical reduction order itself** are
|
|
/// identical across `orders` delivery permutations. Returns that canonical order.
|
|
pub fn assert_reduction_determinism(
|
|
envelopes: &[OperationEnvelope],
|
|
orders: usize,
|
|
rng: &mut Rng,
|
|
) -> Vec<OperationId> {
|
|
let reference_order = reduction_order_of(envelopes);
|
|
let reference_state = materialize_in_order(envelopes);
|
|
for k in 0..orders {
|
|
let perm = rng.permutation(envelopes.len());
|
|
let shuffled: Vec<OperationEnvelope> = perm.iter().map(|&i| envelopes[i].clone()).collect();
|
|
assert_eq!(
|
|
reference_order,
|
|
reduction_order_of(&shuffled),
|
|
"delivery permutation #{k} changed the canonical reduction order"
|
|
);
|
|
assert_eq!(
|
|
reference_state,
|
|
materialize_in_order(&shuffled),
|
|
"delivery permutation #{k} changed the materialized state"
|
|
);
|
|
}
|
|
reference_order
|
|
}
|
|
|
|
/// Asserts the histories actually honor the **causal-first** order, not merely
|
|
/// permutation invariance (spec §"Identifiers": every causal predecessor's stamp
|
|
/// must be strictly less). For each operation `B` and each operation `A` in
|
|
/// `B`'s causal context that is present in the set, asserts `A`'s stamp is
|
|
/// strictly less than `B`'s *and* `A` precedes `B` in the canonical reduction
|
|
/// order. This proves the order is causal-first, which sorting-by-HLC delivers
|
|
/// only when histories are authored conformantly.
|
|
pub fn assert_causal_order_respected(envelopes: &[OperationEnvelope]) {
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(envelopes.iter().cloned());
|
|
let singles = set.single_envelopes();
|
|
let ordered = canonical_reduction_order(&singles);
|
|
let pos: std::collections::BTreeMap<OperationId, usize> =
|
|
ordered.iter().enumerate().map(|(i, e)| (e.id, i)).collect();
|
|
|
|
for b in &ordered {
|
|
for a in &ordered {
|
|
if a.id != b.id && b.causal_context.covers(a.id) {
|
|
assert!(
|
|
a.stamp.reduction_tuple() < b.stamp.reduction_tuple(),
|
|
"authoring rule violated: causal predecessor {:?} stamp is not strictly \
|
|
less than successor {:?}",
|
|
a.id,
|
|
b.id
|
|
);
|
|
assert!(
|
|
pos[&a.id] < pos[&b.id],
|
|
"causal predecessor {:?} does not precede successor {:?} in the reduction order",
|
|
a.id,
|
|
b.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A self-contained driver: generate `n_ops` envelopes from `seed`, assert the
|
|
/// histories honor causal order, and assert convergence across `orders`
|
|
/// permutations. Deterministic.
|
|
pub fn run_convergence(n_ops: usize, orders: usize, seed: u64) {
|
|
let mut rng = Rng::new(seed);
|
|
let envelopes = crate::generators::operation_envelopes(&mut rng, n_ops, 3, 6, 6);
|
|
assert_causal_order_respected(&envelopes);
|
|
assert_convergence(&envelopes, orders, &mut rng);
|
|
}
|
|
|
|
/// The v0 criterion-1 scenario: overlapping edits to a two-staff score by two
|
|
/// replicas. Asserts both staves are actually populated in the materialized
|
|
/// result, that the histories honor causal order, and that they converge to
|
|
/// byte-identical materialized state across `orders` delivery permutations.
|
|
pub fn run_two_staff_convergence(orders: usize, seed: u64) {
|
|
let mut rng = Rng::new(seed);
|
|
let envelopes = crate::generators::two_staff_edit_session(&mut rng);
|
|
assert_causal_order_respected(&envelopes);
|
|
crate::generators::assert_two_staff_populated(&envelopes);
|
|
assert_convergence(&envelopes, orders, &mut rng);
|
|
}
|
|
|
|
/// **The authoritative criterion-1/5 gate.** Generates `sets` randomized
|
|
/// *conformant* operation sets (the testkit's generator honors the HLC authoring
|
|
/// rule) and asserts, for each: (a) causal-first order is genuinely respected
|
|
/// ([`assert_causal_order_respected`]), and (b) reduction is byte-identical and
|
|
/// order-identical across `orders` delivery permutations
|
|
/// ([`assert_reduction_determinism`]).
|
|
///
|
|
/// This is the testkit's own gate and the one the suite treats as authoritative
|
|
/// for causal-order correctness. Agent C's re-exported
|
|
/// [`ops_reduction_determinism_fuzz`] is run in addition; its baseline histories
|
|
/// now use the same causal HLC authoring rule, while its explicit anomaly
|
|
/// injections continue to exercise quarantine behavior.
|
|
pub fn run_authoritative_reduction_gate(sets: usize, orders: usize, seed: u64) {
|
|
let mut rng = Rng::new(seed);
|
|
for _ in 0..sets {
|
|
let n = 1 + rng.below(30) as usize;
|
|
let envelopes = crate::generators::operation_envelopes(&mut rng, n, 3, 8, 8);
|
|
assert_causal_order_respected(&envelopes);
|
|
assert_reduction_determinism(&envelopes, orders, &mut rng);
|
|
}
|
|
}
|
|
|
|
// === Real-Score convergence (acceptance criterion 1, graph level). ==========
|
|
|
|
/// Reduces `envelopes` onto `base` in the given delivery order via the real
|
|
/// [`OperationSet::reduce_onto`], returning the full graph materialization (the
|
|
/// `epiphany_core::Score` together with its canonical bookkeeping state).
|
|
fn materialize_onto_in_order(
|
|
base: &Score,
|
|
envelopes: &[OperationEnvelope],
|
|
) -> GraphMaterialization {
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(envelopes.iter().cloned());
|
|
set.reduce_onto(base)
|
|
}
|
|
|
|
/// The number of events the given voice carries in `score`, or `None` if the
|
|
/// voice is absent.
|
|
fn voice_event_count(score: &Score, voice: VoiceId) -> Option<usize> {
|
|
score
|
|
.voices()
|
|
.find_map(|(_, _, v)| (v.id == voice).then_some(v.events.len()))
|
|
}
|
|
|
|
/// **Real-Score convergence (acceptance criterion 1).** Reduces the same
|
|
/// operation set onto `base` under `orders` independent delivery permutations
|
|
/// and asserts the entire [`GraphMaterialization`] — the real
|
|
/// [`epiphany_core::Score`] *and* its bookkeeping state — is identical every
|
|
/// time. Also asserts the materialized score satisfies every Chapter 5 graph
|
|
/// invariant ([`check_invariants`]) and that the session is non-vacuous: the
|
|
/// score actually changed and each targeted voice grew.
|
|
pub fn assert_graph_convergence(
|
|
base: &Score,
|
|
envelopes: &[OperationEnvelope],
|
|
targets: &[(StaffInstanceId, VoiceId)],
|
|
orders: usize,
|
|
rng: &mut Rng,
|
|
) {
|
|
let reference = materialize_onto_in_order(base, envelopes);
|
|
|
|
// The materialized real Score is structurally valid.
|
|
let violations = check_invariants(&reference.score);
|
|
assert!(
|
|
violations.is_empty(),
|
|
"materialized score violates graph invariants: {violations:?}"
|
|
);
|
|
|
|
// Non-vacuity: the session genuinely mutated the score, and each targeted
|
|
// voice grew (the generator inserts only at fresh positions, so no insert is
|
|
// lost to promotion).
|
|
assert!(
|
|
reference.score != *base,
|
|
"the edit session did not change the base score (vacuous convergence test)"
|
|
);
|
|
for &(_, voice) in targets {
|
|
let before = voice_event_count(base, voice).expect("target voice exists in base");
|
|
let after =
|
|
voice_event_count(&reference.score, voice).expect("target voice survives reduction");
|
|
assert!(
|
|
after > before,
|
|
"target voice {voice:?} did not grow under reduction ({before} -> {after})"
|
|
);
|
|
}
|
|
|
|
for k in 0..orders {
|
|
let perm = rng.permutation(envelopes.len());
|
|
let shuffled: Vec<OperationEnvelope> = perm.iter().map(|&i| envelopes[i].clone()).collect();
|
|
let got = materialize_onto_in_order(base, &shuffled);
|
|
assert_eq!(
|
|
reference, got,
|
|
"delivery permutation #{k} changed the materialized Score \
|
|
(graph reduction is not order-independent)"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Selects a `valid_score` base with at least two voices (scanning successive
|
|
/// seeds), so the graph convergence gate genuinely edits two staves.
|
|
fn two_voice_base(seed: u64) -> Score {
|
|
let mut s = seed;
|
|
for _ in 0..64 {
|
|
let score = epiphany_core::generators::valid_score(s);
|
|
if score.voices().count() >= 2 {
|
|
return score;
|
|
}
|
|
s = s.wrapping_mul(0x9E37_79B9).wrapping_add(1);
|
|
}
|
|
epiphany_core::generators::valid_score(seed)
|
|
}
|
|
|
|
/// **The self-contained real-Score convergence driver (criterion 1).** Builds a
|
|
/// two-voice base score, authors a real ~50-bar edit session against its actual
|
|
/// voices ([`crate::generators::graph_edit_session`]), and asserts graph-level
|
|
/// convergence across `orders` delivery permutations. This is the graph
|
|
/// counterpart of [`run_two_staff_convergence`] (its reducer-bookkeeping twin).
|
|
pub fn run_graph_convergence(orders: usize, seed: u64) {
|
|
let base = two_voice_base(seed);
|
|
let mut rng = Rng::new(seed ^ 0x67A0_6FAC_E0B0_B0B0);
|
|
let (targets, envelopes) = crate::generators::graph_edit_session(&base, &mut rng);
|
|
assert_graph_convergence(&base, &envelopes, &targets, orders, &mut rng);
|
|
}
|
|
|
|
/// Builds a two-voice base, authors a real ~50-bar edit session, reduces it onto
|
|
/// the base via [`OperationSet::reduce_onto`], and returns the materialized real
|
|
/// [`Score`] together with the causal frontier it covers. Used by the
|
|
/// full-Score serialization gate (criterion 4, whole-graph tier).
|
|
pub fn materialized_score(seed: u64) -> (Score, Vec<u8>) {
|
|
let base = two_voice_base(seed);
|
|
let mut rng = Rng::new(seed ^ 0x5C0E_5E51_A11A_B1E5);
|
|
let (_targets, envelopes) = crate::generators::graph_edit_session(&base, &mut rng);
|
|
let materialization = materialize_onto_in_order(&base, &envelopes);
|
|
let frontier = crate::generators::frontier_bytes(&envelopes);
|
|
(materialization.score, frontier)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use epiphany_core::{PitchId, PitchSpelling, ReplicaId, WallClockTime};
|
|
use epiphany_ops::{
|
|
valuegen, AuthorId, CausalContext, HybridLogicalClock, OperationKind, OperationPayload,
|
|
OperationStamp, RespellPitchOp,
|
|
};
|
|
use std::collections::BTreeMap;
|
|
|
|
#[test]
|
|
fn small_sets_converge() {
|
|
for seed in 0..200u64 {
|
|
run_convergence(16, 8, seed.wrapping_mul(0x9E37_79B9));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn two_staff_session_converges() {
|
|
for seed in 0..16u64 {
|
|
run_two_staff_convergence(8, seed.wrapping_mul(0x9E37_79B9).wrapping_add(1));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn graph_sessions_converge_on_the_real_score() {
|
|
for seed in 0..8u64 {
|
|
run_graph_convergence(4, seed.wrapping_mul(0x9E37_79B9).wrapping_add(7));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_and_singleton_sets_are_trivially_stable() {
|
|
let mut rng = Rng::new(1);
|
|
assert_convergence(&[], 4, &mut rng);
|
|
let one = crate::generators::operation_envelopes(&mut rng, 1, 2, 6, 6);
|
|
assert_convergence(&one, 4, &mut rng);
|
|
}
|
|
|
|
// --- Negative control: prove the harness is not vacuous. ---------------
|
|
//
|
|
// Two concurrent RespellPitch operations on the *same* pitch with different
|
|
// spellings. A reducer that consumed *arrival* order would land on whichever
|
|
// arrived last — so two delivery orders diverge. The real (canonical) reducer
|
|
// lands on the same spelling regardless. The convergence harness compares the
|
|
// canonical result, so it would FAIL for the arrival-order reducer; this test
|
|
// demonstrates the discriminating power directly.
|
|
|
|
fn respell(replica: u64, counter: u64, phys: i64, spelling: u8) -> OperationEnvelope {
|
|
let id = OperationId::new(ReplicaId(replica), counter);
|
|
OperationEnvelope {
|
|
id,
|
|
author: AuthorId(replica as u128),
|
|
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(phys), 0), id),
|
|
// No causal link → the two operations are concurrent.
|
|
causal_context: CausalContext::new(),
|
|
transaction: None,
|
|
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
|
pitch: PitchId::new(ReplicaId(0x0B7E_C700), 0),
|
|
spelling: valuegen::spelling(spelling),
|
|
})),
|
|
}
|
|
}
|
|
|
|
/// A deliberately broken, order-*dependent* reducer: last spelling wins by
|
|
/// arrival order. Stands in for the bug the harness must catch.
|
|
fn naive_arrival_order_spelling(envs: &[OperationEnvelope]) -> Option<PitchSpelling> {
|
|
let mut last: BTreeMap<PitchId, PitchSpelling> = BTreeMap::new();
|
|
for e in envs {
|
|
if let OperationPayload::Primitive(OperationKind::RespellPitch(op)) = &e.payload {
|
|
last.insert(op.pitch, op.spelling.clone());
|
|
}
|
|
}
|
|
last.values().next().cloned()
|
|
}
|
|
|
|
#[test]
|
|
fn negative_control_arrival_order_reducer_would_diverge() {
|
|
let a = respell(1, 0, 10, 0xAA);
|
|
let b = respell(2, 0, 20, 0xBB);
|
|
|
|
// Arrival order A,B vs B,A: the broken arrival-order reducer diverges...
|
|
let naive_ab = naive_arrival_order_spelling(&[a.clone(), b.clone()]);
|
|
let naive_ba = naive_arrival_order_spelling(&[b.clone(), a.clone()]);
|
|
assert_ne!(
|
|
naive_ab, naive_ba,
|
|
"negative control is mis-constructed: an arrival-order reducer must diverge here"
|
|
);
|
|
|
|
// ...while the real canonical reducer converges (the harness passes).
|
|
let canon_ab = materialize_in_order(&[a.clone(), b.clone()]);
|
|
let canon_ba = materialize_in_order(&[b, a]);
|
|
assert_eq!(
|
|
canon_ab, canon_ba,
|
|
"the canonical reducer must converge regardless of arrival order"
|
|
);
|
|
}
|
|
}
|