379 lines
15 KiB
Rust
379 lines
15 KiB
Rust
//! Replica-stream anomalies and the integrity-anomaly register (Chapter 6
|
|
//! §"Per-Replica HLC Monotonicity"; Chapter 5 §"System-Derived Counter
|
|
//! Collisions"; Pass 10).
|
|
//!
|
|
//! Two kinds of structural failure are modeled here, both kept rigorously
|
|
//! *separate* from ordinary [`ConflictRecord`](crate::ConflictRecord)s: a
|
|
//! conflict is a canonical-state fact (two valid edits collide and the user
|
|
//! resolves it); an [`IntegrityAnomaly`] means the structural assumptions
|
|
//! underlying canonical state have failed and the document needs external
|
|
//! recovery (Pass 10 moved these out of `ConflictKind`).
|
|
//!
|
|
//! * **HLC monotonicity** (Chapter 6 §6.6) is a property of the *set* of
|
|
//! accepted envelopes, not of arrival order. For two envelopes of one replica
|
|
//! with counters `c1 < c2`, the monotonicity tuple `(physical, logical,
|
|
//! counter)` of `c1` MUST be ≤ that of `c2`. A violation forms an
|
|
//! [`AnomalousReplicaSegment`]: every envelope from that replica with counter
|
|
//! `≥ first_bad_counter` is *excluded from canonical reduction* (retained, but
|
|
//! not reduced), and the document carries a `ReplicaStreamQuarantined`
|
|
//! anomaly. Excluding the segment — rather than reducing-but-marking-tainted —
|
|
//! keeps canonical state derived only from monotone envelopes.
|
|
//! * **System-derived counter collisions** (Chapter 5) and **slot
|
|
//! equivocation** (Chapter 6 §6.5) are the other two anomaly kinds.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use epiphany_core::{derive_system_id, IntegrityAnomalyId, OperationId, ReplicaId};
|
|
use epiphany_determinism::{CanonicalEncode, SystemDomainTag};
|
|
|
|
use crate::encode::{push_canon, push_seq, push_tag, push_u64};
|
|
use crate::envelope::OperationEnvelope;
|
|
use crate::support::{
|
|
IntegrityAnomalyRegistryId, ObjectKind, ReplicaAnomalyRegistryId, SerializedCanonicalInputs,
|
|
};
|
|
|
|
/// The reserved built-in system domain tag (`MUSCSANM`) under which
|
|
/// integrity-anomaly identifiers are content-derived, so two replicas reducing
|
|
/// the same operation set mint byte-identical anomaly ids. Ratified by Pass 11
|
|
/// (item 1.4): `MUSCSANM` is a built-in reserved tag alongside `MUSCSVCE` /
|
|
/// `MUSCSPCH` (Chapter 5 §"System-Derived Counter Collisions",
|
|
/// Requirement `req:graph:integrity-anomaly-id`), not an extension tag —
|
|
/// anomalies are core.
|
|
fn anomaly_domain_tag() -> SystemDomainTag {
|
|
SystemDomainTag::ANOMALY
|
|
}
|
|
|
|
/// A range of envelopes from a single replica identified as
|
|
/// monotonicity-violating and excluded from ordinary canonical reduction
|
|
/// (Chapter 6 §6.6). Retained for diagnostic and recovery purposes.
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub struct AnomalousReplicaSegment {
|
|
/// The offending replica.
|
|
pub replica: ReplicaId,
|
|
/// The smallest counter at or after which the replica's stream is
|
|
/// anomalous. Every envelope from this replica with counter
|
|
/// `>= first_bad_counter` is excluded from canonical reduction.
|
|
pub first_bad_counter: u64,
|
|
/// The detected anomaly reason.
|
|
pub reason: ReplicaAnomalyReason,
|
|
/// Operation ids in the excluded segment, sorted by counter.
|
|
pub excluded: Vec<OperationId>,
|
|
}
|
|
|
|
impl CanonicalEncode for AnomalousReplicaSegment {
|
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
|
out.extend_from_slice(&self.replica.to_be_bytes());
|
|
push_u64(out, self.first_bad_counter);
|
|
self.reason.encode_canonical(out);
|
|
push_seq(out, &self.excluded);
|
|
}
|
|
}
|
|
|
|
/// Why a replica's stream was flagged anomalous (Chapter 6 §6.6).
|
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
|
pub enum ReplicaAnomalyReason {
|
|
/// The replica produced an envelope at counter `c2` with a stamp tuple
|
|
/// strictly less than a previously-known envelope at counter `c1 < c2`.
|
|
HlcMonotonicityViolation {
|
|
violating_pair: (OperationId, OperationId),
|
|
},
|
|
/// A registered anomaly reason defined by an extension or transport.
|
|
Registered(ReplicaAnomalyRegistryId),
|
|
}
|
|
|
|
impl ReplicaAnomalyReason {
|
|
fn discriminant(&self) -> u8 {
|
|
match self {
|
|
ReplicaAnomalyReason::HlcMonotonicityViolation { .. } => 0,
|
|
ReplicaAnomalyReason::Registered(_) => 1,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CanonicalEncode for ReplicaAnomalyReason {
|
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
|
push_tag(out, self.discriminant());
|
|
match self {
|
|
ReplicaAnomalyReason::HlcMonotonicityViolation {
|
|
violating_pair: (a, b),
|
|
} => {
|
|
push_canon(out, a);
|
|
push_canon(out, b);
|
|
}
|
|
ReplicaAnomalyReason::Registered(id) => push_canon(out, id),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An integrity anomaly that takes the document out of ordinary canonical
|
|
/// operation (Chapter 5 §"System-Derived Counter Collisions"; Chapter 6 §6.6).
|
|
/// Distinct from [`ConflictRecord`](crate::ConflictRecord).
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub struct IntegrityAnomaly {
|
|
/// Content-derived identifier (see [`IntegrityAnomaly::new`]).
|
|
pub id: IntegrityAnomalyId,
|
|
/// The anomaly kind.
|
|
pub kind: IntegrityAnomalyKind,
|
|
}
|
|
|
|
impl IntegrityAnomaly {
|
|
/// Builds an anomaly, content-deriving its [`IntegrityAnomalyId`] from the
|
|
/// kind's canonical bytes in the `SYSTEM_DERIVED` namespace, so the
|
|
/// register is itself a deterministic materialized fact.
|
|
pub fn new(kind: IntegrityAnomalyKind) -> Self {
|
|
let id: IntegrityAnomalyId =
|
|
derive_system_id(anomaly_domain_tag(), &kind.to_canonical_bytes());
|
|
IntegrityAnomaly { id, kind }
|
|
}
|
|
}
|
|
|
|
impl CanonicalEncode for IntegrityAnomaly {
|
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
|
push_canon(out, &self.id);
|
|
self.kind.encode_canonical(out);
|
|
}
|
|
}
|
|
|
|
/// The kind of integrity anomaly (Chapter 5 §"System-Derived Counter
|
|
/// Collisions"; Pass 10).
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub enum IntegrityAnomalyKind {
|
|
/// A system-derived identifier counter collision: two different canonical
|
|
/// input sets derived the same 64-bit counter within one object kind.
|
|
SystemIdentifierCollision {
|
|
kind: ObjectKind,
|
|
colliding_counter: u64,
|
|
input_set_a: SerializedCanonicalInputs,
|
|
input_set_b: SerializedCanonicalInputs,
|
|
},
|
|
/// An `OperationSlot` is equivocated; reduction has excluded the operation.
|
|
OperationSlotEquivocated { operation_id: OperationId },
|
|
/// A replica's stream has been quarantined per the HLC monotonicity rule.
|
|
ReplicaStreamQuarantined {
|
|
replica: ReplicaId,
|
|
first_bad_counter: u64,
|
|
},
|
|
/// A registered anomaly defined by an extension or transport.
|
|
Registered(IntegrityAnomalyRegistryId),
|
|
}
|
|
|
|
impl IntegrityAnomalyKind {
|
|
fn discriminant(&self) -> u8 {
|
|
match self {
|
|
IntegrityAnomalyKind::SystemIdentifierCollision { .. } => 0,
|
|
IntegrityAnomalyKind::OperationSlotEquivocated { .. } => 1,
|
|
IntegrityAnomalyKind::ReplicaStreamQuarantined { .. } => 2,
|
|
IntegrityAnomalyKind::Registered(_) => 3,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CanonicalEncode for IntegrityAnomalyKind {
|
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
|
push_tag(out, self.discriminant());
|
|
match self {
|
|
IntegrityAnomalyKind::SystemIdentifierCollision {
|
|
kind,
|
|
colliding_counter,
|
|
input_set_a,
|
|
input_set_b,
|
|
} => {
|
|
kind.encode_canonical(out);
|
|
push_u64(out, *colliding_counter);
|
|
// Order the two input sets canonically so the anomaly's identity
|
|
// does not depend on which collided input was discovered first.
|
|
let (lo, hi) = if input_set_a.0 <= input_set_b.0 {
|
|
(input_set_a, input_set_b)
|
|
} else {
|
|
(input_set_b, input_set_a)
|
|
};
|
|
lo.encode_canonical(out);
|
|
hi.encode_canonical(out);
|
|
}
|
|
IntegrityAnomalyKind::OperationSlotEquivocated { operation_id } => {
|
|
push_canon(out, operation_id)
|
|
}
|
|
IntegrityAnomalyKind::ReplicaStreamQuarantined {
|
|
replica,
|
|
first_bad_counter,
|
|
} => {
|
|
out.extend_from_slice(&replica.to_be_bytes());
|
|
push_u64(out, *first_bad_counter);
|
|
}
|
|
IntegrityAnomalyKind::Registered(id) => push_canon(out, id),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Detects per-replica HLC monotonicity anomalies over a set of reducible
|
|
/// envelopes (Chapter 6 §6.6).
|
|
///
|
|
/// The check is order-independent: it groups the envelopes by authoring
|
|
/// replica, walks each replica's envelopes in ascending counter order, and
|
|
/// finds every pair `c1 < c2` whose monotonicity tuples decrease. The resulting
|
|
/// segment starts at the smallest `c1` participating in any violating pair, and
|
|
/// every envelope of that replica with counter `>= c1` is excluded.
|
|
///
|
|
/// Returns one [`AnomalousReplicaSegment`] per offending replica, in ascending
|
|
/// replica order.
|
|
pub fn detect_replica_anomalies(envelopes: &[&OperationEnvelope]) -> Vec<AnomalousReplicaSegment> {
|
|
// (counter, op id, monotonicity tuple) for one of a replica's envelopes.
|
|
type ReplicaEntry = (u64, OperationId, (i64, u32, u64));
|
|
// Group by replica, collecting each envelope's entry.
|
|
let mut by_replica: BTreeMap<ReplicaId, Vec<ReplicaEntry>> = BTreeMap::new();
|
|
for env in envelopes {
|
|
let t = env.stamp.monotonicity_tuple();
|
|
by_replica.entry(env.id.replica).or_default().push((
|
|
env.id.counter,
|
|
env.id,
|
|
(t.0 .0, t.1, t.2),
|
|
));
|
|
}
|
|
|
|
let mut segments = Vec::new();
|
|
for (replica, mut ops) in by_replica {
|
|
ops.sort_by_key(|(counter, _, _)| *counter);
|
|
// Find the earliest counter participating as the left side of any
|
|
// violating pair. Comparing only against the preceding maximum is not
|
|
// enough: [100, 200, 50] also makes (counter 0, counter 2) a violating
|
|
// pair, so quarantine must begin at counter 0 rather than counter 1.
|
|
// Suffix minima make this linear after the counter sort.
|
|
let mut suffix_min = vec![(i64::MAX, u32::MAX, u64::MAX); ops.len()];
|
|
if let Some(last) = ops.last() {
|
|
suffix_min[ops.len() - 1] = last.2;
|
|
for index in (0..ops.len() - 1).rev() {
|
|
suffix_min[index] = ops[index].2.min(suffix_min[index + 1]);
|
|
}
|
|
}
|
|
let first_bad = (0..ops.len().saturating_sub(1)).find_map(|earlier| {
|
|
if ops[earlier].2 <= suffix_min[earlier + 1] {
|
|
return None;
|
|
}
|
|
let later = (earlier + 1..ops.len())
|
|
.find(|&later| ops[earlier].2 > ops[later].2)
|
|
.expect("suffix minimum proves that a violating successor exists");
|
|
Some((ops[earlier].0, ops[earlier].1, ops[later].1))
|
|
});
|
|
|
|
if let Some((first_bad_counter, c1_op, c2_op)) = first_bad {
|
|
let excluded: Vec<OperationId> = ops
|
|
.iter()
|
|
.filter(|(counter, _, _)| *counter >= first_bad_counter)
|
|
.map(|(_, op, _)| *op)
|
|
.collect();
|
|
segments.push(AnomalousReplicaSegment {
|
|
replica,
|
|
first_bad_counter,
|
|
reason: ReplicaAnomalyReason::HlcMonotonicityViolation {
|
|
violating_pair: (c1_op, c2_op),
|
|
},
|
|
excluded,
|
|
});
|
|
}
|
|
}
|
|
segments
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::causal::CausalContext;
|
|
use crate::payload::{OperationKind, RespellPitchOp};
|
|
use crate::stamp::{HybridLogicalClock, OperationStamp};
|
|
use crate::support::AuthorId;
|
|
use crate::OperationPayload;
|
|
use epiphany_core::{PitchId, WallClockTime};
|
|
|
|
fn env(replica: u64, counter: u64, physical: i64, logical: u32) -> OperationEnvelope {
|
|
let id = OperationId::new(ReplicaId(replica), counter);
|
|
OperationEnvelope {
|
|
id,
|
|
author: AuthorId(0),
|
|
stamp: OperationStamp::new(
|
|
HybridLogicalClock::new(WallClockTime(physical), logical),
|
|
id,
|
|
),
|
|
causal_context: CausalContext::new(),
|
|
transaction: None,
|
|
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
|
pitch: PitchId::new(ReplicaId(replica), counter),
|
|
spelling: crate::valuegen::spelling(1),
|
|
})),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn integrity_anomaly_id_byte_form_is_locked() {
|
|
// Golden: locks the MUSCSANM-derived anomaly id for a fixed kind.
|
|
// RATIFIED by Pass 11 (item 1.4, req:graph:integrity-anomaly-id): the
|
|
// identity is content-derived so two replicas observing the same failure
|
|
// agree on it, which is a conformance property. A change to the domain
|
|
// tag, the kind's canonical encoding, or the derivation breaks this
|
|
// deliberately.
|
|
let kind = IntegrityAnomalyKind::OperationSlotEquivocated {
|
|
operation_id: OperationId::new(ReplicaId(7), 42),
|
|
};
|
|
let id = IntegrityAnomaly::new(kind).id;
|
|
// First 8 bytes are the SYSTEM_DERIVED replica; the id is in that namespace.
|
|
assert_eq!(
|
|
&id.canonical_bytes()[0..8],
|
|
&ReplicaId::SYSTEM_DERIVED.to_be_bytes()
|
|
);
|
|
const GOLDEN: [u8; 16] = [
|
|
255, 255, 255, 255, 255, 255, 255, 255, 81, 178, 18, 20, 252, 222, 201, 215,
|
|
];
|
|
assert_eq!(id.canonical_bytes(), GOLDEN);
|
|
}
|
|
|
|
#[test]
|
|
fn monotone_stream_has_no_anomaly() {
|
|
let a = env(1, 0, 10, 0);
|
|
let b = env(1, 1, 10, 1);
|
|
let c = env(1, 2, 20, 0);
|
|
let refs = [&a, &b, &c];
|
|
assert!(detect_replica_anomalies(&refs).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn non_monotone_stream_quarantines_from_first_bad_counter() {
|
|
// counter 0: physical 50; counter 1: physical 10 (< 50) -> violation.
|
|
let a = env(1, 0, 50, 0);
|
|
let b = env(1, 1, 10, 0);
|
|
let refs = [&a, &b];
|
|
let seg = detect_replica_anomalies(&refs);
|
|
assert_eq!(seg.len(), 1);
|
|
assert_eq!(seg[0].replica, ReplicaId(1));
|
|
// first_bad is the earlier counter of the pair (0).
|
|
assert_eq!(seg[0].first_bad_counter, 0);
|
|
assert_eq!(seg[0].excluded.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn detection_is_arrival_order_independent() {
|
|
let a = env(1, 0, 50, 0);
|
|
let b = env(1, 1, 10, 0);
|
|
let forward = detect_replica_anomalies(&[&a, &b]);
|
|
let backward = detect_replica_anomalies(&[&b, &a]);
|
|
assert_eq!(forward, backward);
|
|
}
|
|
|
|
#[test]
|
|
fn quarantine_starts_at_earliest_counter_in_any_violating_pair() {
|
|
// Counter 2 is below both earlier stamps. The first violating pair by
|
|
// counter is (0, 2), even though counter 1 carries the maximum stamp.
|
|
let a = env(1, 0, 100, 0);
|
|
let b = env(1, 1, 200, 0);
|
|
let c = env(1, 2, 50, 0);
|
|
let seg = detect_replica_anomalies(&[&a, &b, &c]);
|
|
assert_eq!(seg.len(), 1);
|
|
assert_eq!(seg[0].first_bad_counter, 0);
|
|
assert_eq!(seg[0].excluded, vec![a.id, b.id, c.id]);
|
|
assert_eq!(
|
|
seg[0].reason,
|
|
ReplicaAnomalyReason::HlcMonotonicityViolation {
|
|
violating_pair: (a.id, c.id),
|
|
}
|
|
);
|
|
}
|
|
}
|