epiphany/crates/epiphany-ops/src/conflict.rs

484 lines
17 KiB
Rust

//! Conflict records and the content-derived [`ConflictId`] (Chapter 6
//! §"Conflict Records").
//!
//! When an operation cannot apply cleanly, the reduction produces a
//! `Conflicted` effect referencing a [`ConflictRecord`] in the score's conflict
//! registry. Conflict records are first-class, *canonical* graph objects:
//! stable, addressable, visible to users, and resolvable by a later
//! `ResolveConflict` operation.
//!
//! Because conflict records are produced during deterministic reduction rather
//! than authored by replicas, their identifiers MUST be **content-derived**:
//! two replicas reducing the same operation set produce the same conflicts with
//! the same ids ([`derive_conflict_id`]). An ordinal or local counter in the
//! preimage is forbidden — conflicts that share kind, causing operations, and
//! affected objects are by definition the same conflict (Chapter 6 §6.4.3).
//!
//! Integrity *anomalies* ([`crate::IntegrityAnomaly`]) are deliberately a
//! separate type, not a `ConflictKind`: a conflict is an ordinary
//! canonical-state fact (two valid edits collide); an anomaly is a structural
//! failure that takes the document out of ordinary canonical operation
//! (Chapter 6 / Chapter 5 §"System-Derived Counter Collisions"; Pass 10).
use epiphany_core::{OperationId, RegionId, TransactionId, TypedObjectId};
use epiphany_determinism::{
sorted_canonical, CanonicalByteOrder, CanonicalEncode, DomainTag, Preimage,
};
use crate::encode::{push_canon, push_seq, push_str, push_tag};
use crate::support::{ConflictKindRegistryId, ResolutionRegistryId};
/// A content-derived conflict identifier: `trunc128(BLAKE3("MUSCCONF" || …))`
/// (Chapter 6 §6.4.3). 128-bit, canonical 16-byte big-endian form, ordered
/// byte-lexicographically — the order the conflict registry iterates in
/// (Appendix D §"Ordered Iteration": "Conflict records: ascending by
/// ConflictId").
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Debug)]
pub struct ConflictId(pub u128);
impl ConflictId {
/// The canonical 16-byte big-endian form.
#[inline]
pub const fn canonical_bytes(self) -> [u8; 16] {
self.0.to_be_bytes()
}
}
impl CanonicalEncode for ConflictId {
#[inline]
fn encode_canonical(&self, out: &mut Vec<u8>) {
out.extend_from_slice(&self.canonical_bytes());
}
}
impl CanonicalByteOrder for ConflictId {}
/// A path to a specific field, naming what two concurrent operations collided
/// on in a [`ConflictKind::StructuralFieldCollision`]. Canonical text
/// (Appendix D §"Text and Unicode").
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
pub struct FieldPath(pub String);
impl CanonicalEncode for FieldPath {
#[inline]
fn encode_canonical(&self, out: &mut Vec<u8>) {
push_str(out, &self.0);
}
}
/// The kind of conflict, governing the resolution options (Chapter 6 §6.4.1).
///
/// The canonical byte form (via [`CanonicalEncode`]) includes the kind
/// discriminant and **all** payload, so distinct conflicts have distinct
/// preimages by construction (Chapter 6 §6.4.3 requirement).
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum ConflictKind {
/// Two concurrent operations wrote the same non-LWW field. The winner's
/// effect is materialized; the loser's is preserved here for inspection.
StructuralFieldCollision {
winner: OperationId,
loser: OperationId,
field: FieldPath,
},
/// A transaction's members were partially applicable; the whole
/// transaction was rejected.
TransactionConflict {
transaction: TransactionId,
failed_members: Vec<OperationId>,
},
/// An operation targeted a tombstoned object the kind's rules could not
/// repair.
TombstonedTarget {
target: TypedObjectId,
operation: OperationId,
},
/// Re-anchoring failed: no deterministic target could be found.
ReanchorFailure {
original_referent: TypedObjectId,
referencing_object: TypedObjectId,
},
/// A region time-model migration produced contained events whose
/// coordinate kinds are incompatible with the new model.
TimeModelMigrationFailure {
region: RegionId,
incompatible_events: Vec<TypedObjectId>,
},
/// A registered extension operation's reduction failed; opaque to the core.
ExtensionConflict {
kind_id: ConflictKindRegistryId,
details: Vec<u8>,
},
}
impl ConflictKind {
/// The discriminant byte; part of the canonical preimage.
fn discriminant(&self) -> u8 {
match self {
ConflictKind::StructuralFieldCollision { .. } => 0,
ConflictKind::TransactionConflict { .. } => 1,
ConflictKind::TombstonedTarget { .. } => 2,
ConflictKind::ReanchorFailure { .. } => 3,
ConflictKind::TimeModelMigrationFailure { .. } => 4,
ConflictKind::ExtensionConflict { .. } => 5,
}
}
/// The canonical byte form of the kind, as required by
/// [`derive_conflict_id`] (Chapter 6 §6.4.3).
pub fn canonical_bytes(&self) -> Vec<u8> {
self.to_canonical_bytes()
}
}
impl CanonicalEncode for ConflictKind {
fn encode_canonical(&self, out: &mut Vec<u8>) {
push_tag(out, self.discriminant());
match self {
ConflictKind::StructuralFieldCollision {
winner,
loser,
field,
} => {
push_canon(out, winner);
push_canon(out, loser);
push_canon(out, field);
}
ConflictKind::TransactionConflict {
transaction,
failed_members,
} => {
push_canon(out, transaction);
// Failed members in canonical order so the preimage is stable.
push_seq(out, &sorted_canonical(failed_members.clone()));
}
ConflictKind::TombstonedTarget { target, operation } => {
push_canon(out, target);
push_canon(out, operation);
}
ConflictKind::ReanchorFailure {
original_referent,
referencing_object,
} => {
push_canon(out, original_referent);
push_canon(out, referencing_object);
}
ConflictKind::TimeModelMigrationFailure {
region,
incompatible_events,
} => {
push_canon(out, region);
push_seq(out, &sorted_canonical(incompatible_events.clone()));
}
ConflictKind::ExtensionConflict { kind_id, details } => {
push_canon(out, kind_id);
crate::encode::push_lp_bytes(out, details);
}
}
}
}
/// How a conflict was resolved (Chapter 6 §6.4.1).
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum ResolutionAction {
/// Accept the losing operation's effect (replacing the winner).
AcceptLoser,
/// Reapply the winner explicitly (clears the conflict, no semantic change).
KeepWinner,
/// A user-authored replacement that supersedes both.
Override { override_operation: OperationId },
/// Re-anchor to a user-chosen target.
Reanchor { new_target: TypedObjectId },
/// Dismiss the conflict without changing the materialized graph: the user
/// acknowledges it and accepts the current (winner) state. Selects the
/// `Dismissed` resolution state (Pass 11, item 2.5; Chapter 6).
Dismiss,
/// Custom resolution for a registered conflict kind.
Registered(ResolutionRegistryId),
}
impl ResolutionAction {
fn discriminant(&self) -> u8 {
match self {
ResolutionAction::AcceptLoser => 0,
ResolutionAction::KeepWinner => 1,
ResolutionAction::Override { .. } => 2,
ResolutionAction::Reanchor { .. } => 3,
ResolutionAction::Dismiss => 4,
ResolutionAction::Registered(_) => 5,
}
}
}
impl CanonicalEncode for ResolutionAction {
fn encode_canonical(&self, out: &mut Vec<u8>) {
push_tag(out, self.discriminant());
match self {
ResolutionAction::AcceptLoser
| ResolutionAction::KeepWinner
| ResolutionAction::Dismiss => {}
ResolutionAction::Override { override_operation } => {
push_canon(out, override_operation)
}
ResolutionAction::Reanchor { new_target } => push_canon(out, new_target),
ResolutionAction::Registered(id) => push_canon(out, id),
}
}
}
/// The current resolution state of a conflict (Chapter 6 §6.4.1). Conflicts
/// begin `Unresolved`; a later `ResolveConflict` operation transitions them.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum ConflictResolutionState {
Unresolved,
Resolved {
by: OperationId,
action: ResolutionAction,
},
Dismissed {
by: OperationId,
},
}
impl ConflictResolutionState {
fn discriminant(&self) -> u8 {
match self {
ConflictResolutionState::Unresolved => 0,
ConflictResolutionState::Resolved { .. } => 1,
ConflictResolutionState::Dismissed { .. } => 2,
}
}
}
impl CanonicalEncode for ConflictResolutionState {
fn encode_canonical(&self, out: &mut Vec<u8>) {
push_tag(out, self.discriminant());
match self {
ConflictResolutionState::Unresolved => {}
ConflictResolutionState::Resolved { by, action } => {
push_canon(out, by);
push_canon(out, action);
}
ConflictResolutionState::Dismissed { by } => push_canon(out, by),
}
}
}
/// A first-class conflict record (Chapter 6 §6.4.1).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ConflictRecord {
/// Content-derived identifier ([`derive_conflict_id`]).
pub id: ConflictId,
/// The operations that participated. At least two for a true conflict; one
/// for an operation that failed precondition checking under reduction.
pub caused_by: Vec<OperationId>,
/// The kind of conflict.
pub kind: ConflictKind,
/// Objects affected, for diagnostic and UI navigation.
pub affected_objects: Vec<TypedObjectId>,
/// Current resolution state.
pub resolution_state: ConflictResolutionState,
}
impl ConflictRecord {
/// Builds an unresolved conflict record, deriving its content id from the
/// kind, causing operations, and affected objects (Chapter 6 §6.4.3). The
/// `caused_by` and `affected_objects` vectors are stored in canonical order
/// so the record's own canonical bytes are stable regardless of how the
/// reduction assembled them.
pub fn new(
kind: ConflictKind,
caused_by: Vec<OperationId>,
affected_objects: Vec<TypedObjectId>,
) -> Self {
let id = derive_conflict_id(&kind, &caused_by, &affected_objects);
ConflictRecord {
id,
caused_by: sorted_canonical(caused_by),
kind,
affected_objects: sorted_canonical(affected_objects),
resolution_state: ConflictResolutionState::Unresolved,
}
}
}
impl CanonicalEncode for ConflictRecord {
fn encode_canonical(&self, out: &mut Vec<u8>) {
push_canon(out, &self.id);
push_seq(out, &self.caused_by);
push_canon(out, &self.kind);
push_seq(out, &self.affected_objects);
push_canon(out, &self.resolution_state);
}
}
/// Derives a [`ConflictId`] from a conflict's content (Chapter 6 §6.4.3).
///
/// The preimage is `"MUSCCONF" || kind.canonical_bytes() || sorted_ops ||
/// sorted_objs`, where causing operations and affected objects are each sorted
/// lexicographically by canonical bytes. No ordinal or local counter enters the
/// preimage: conflicts sharing kind, causing operations, and affected objects
/// are by definition the same conflict.
pub fn derive_conflict_id(
kind: &ConflictKind,
causing_operations: &[OperationId],
affected_objects: &[TypedObjectId],
) -> ConflictId {
let mut p = Preimage::new(DomainTag::CONFLICT);
p.push_bytes(&kind.canonical_bytes());
for op in sorted_canonical(causing_operations.to_vec()) {
p.push_bytes(&op.canonical_bytes());
}
for obj in sorted_canonical(affected_objects.to_vec()) {
p.push_bytes(&obj.canonical_bytes());
}
ConflictId(p.finish_trunc128())
}
/// The score's conflict registry (Chapter 6 §6.4.2): part of canonical
/// materialized state. Records are kept in the normative order — ascending by
/// [`ConflictId`] — so the registry's canonical bytes never depend on the order
/// in which the reduction discovered the conflicts.
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct ConflictRegistry {
records: Vec<ConflictRecord>,
}
impl ConflictRegistry {
/// An empty registry.
#[inline]
pub fn new() -> Self {
ConflictRegistry::default()
}
/// Inserts a record, keeping the registry ordered by `ConflictId`. If a
/// record with the same content id already exists (the same conflict
/// re-derived), the insert is idempotent — content-derived identity means
/// re-discovering a conflict is not a second conflict.
pub fn insert(&mut self, record: ConflictRecord) {
match self.records.binary_search_by(|r| r.id.cmp(&record.id)) {
Ok(_) => {} // already present; content-derived identity ⇒ idempotent
Err(pos) => self.records.insert(pos, record),
}
}
/// The records, in canonical (ascending-`ConflictId`) order.
#[inline]
pub fn records(&self) -> &[ConflictRecord] {
&self.records
}
/// A mutable handle to the record with `id`, if present.
pub fn get_mut(&mut self, id: ConflictId) -> Option<&mut ConflictRecord> {
self.records
.binary_search_by(|r| r.id.cmp(&id))
.ok()
.map(|pos| &mut self.records[pos])
}
/// Whether the registry holds any records.
#[inline]
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
}
impl CanonicalEncode for ConflictRegistry {
fn encode_canonical(&self, out: &mut Vec<u8>) {
// Already in ConflictId order by construction.
push_seq(out, &self.records);
}
}
#[cfg(test)]
mod tests {
use super::*;
use epiphany_core::{EventId, ReplicaId};
fn op(r: u64, c: u64) -> OperationId {
OperationId::new(ReplicaId(r), c)
}
fn obj(n: u128) -> TypedObjectId {
TypedObjectId::Event(EventId::from_raw(n))
}
#[test]
fn resolution_action_discriminants_are_golden() {
// RATIFIED by Pass 11 (item 2.5; core_spec
// `req:semops:resolution-action-discriminants`): ResolutionAction is
// canonically encoded into operation content, so its discriminants are
// normative. Dismiss = 4 was inserted ahead of Registered (now 5); lock
// the literal values so any future reorder breaks deliberately rather
// than silently shifting the wire form.
assert_eq!(ResolutionAction::AcceptLoser.discriminant(), 0);
assert_eq!(ResolutionAction::KeepWinner.discriminant(), 1);
assert_eq!(
ResolutionAction::Override {
override_operation: op(1, 1)
}
.discriminant(),
2
);
assert_eq!(
ResolutionAction::Reanchor { new_target: obj(7) }.discriminant(),
3
);
assert_eq!(ResolutionAction::Dismiss.discriminant(), 4);
assert_eq!(
ResolutionAction::Registered(ResolutionRegistryId(0)).discriminant(),
5
);
}
#[test]
fn conflict_id_is_order_independent_in_its_inputs() {
let kind = ConflictKind::TombstonedTarget {
target: obj(7),
operation: op(1, 1),
};
let a = derive_conflict_id(&kind, &[op(1, 1), op(2, 2)], &[obj(7), obj(3)]);
let b = derive_conflict_id(&kind, &[op(2, 2), op(1, 1)], &[obj(3), obj(7)]);
assert_eq!(a, b, "causing-op / affected-object order must not matter");
}
#[test]
fn distinct_kinds_yield_distinct_ids() {
let k1 = ConflictKind::TombstonedTarget {
target: obj(7),
operation: op(1, 1),
};
let k2 = ConflictKind::ReanchorFailure {
original_referent: obj(7),
referencing_object: obj(1),
};
assert_ne!(
derive_conflict_id(&k1, &[op(1, 1)], &[obj(7)]),
derive_conflict_id(&k2, &[op(1, 1)], &[obj(7)]),
);
}
#[test]
fn registry_is_sorted_and_idempotent_on_same_content() {
let mut reg = ConflictRegistry::new();
let mk = |t: u128| {
ConflictRecord::new(
ConflictKind::TombstonedTarget {
target: obj(t),
operation: op(1, 1),
},
vec![op(1, 1)],
vec![obj(t)],
)
};
let a = mk(5);
reg.insert(mk(9));
reg.insert(a.clone());
reg.insert(mk(1));
reg.insert(a.clone()); // re-discovering the same conflict
let ids: Vec<_> = reg.records().iter().map(|r| r.id).collect();
let mut sorted = ids.clone();
sorted.sort();
assert_eq!(ids, sorted, "registry must be ConflictId-ordered");
// a inserted twice but present once.
assert_eq!(reg.records().iter().filter(|r| r.id == a.id).count(), 1);
}
}