//! Reconciliation engine for deterministic merge after network partitions. //! //! When two `TidalDB` nodes diverge during a partition, each accumulates //! independent signal events and hard-negative decisions. After the partition //! heals, the `ReconciliationEngine` produces a deterministic `MergePlan` //! from their diverged `StateSnapshot`s and applies it to the local state. //! //! # Merge semantics //! //! - **Signal states:** CRDT-merged per `(entity, signal_type)`. Each node's //! contribution is summed (disjoint events); timestamps are max'd. //! - **Hard negatives:** LWW-resolved per `(user, item)` by HLC timestamp. //! The most recent hide or unhide wins deterministically. //! //! # Idempotency //! //! Applying a `MergePlan` is idempotent: applying the same plan twice //! produces identical state. This is critical for at-least-once delivery //! guarantees during reconnection. use std::{ collections::{HashMap, HashSet}, sync::Arc, }; use crate::{ entities::HardNegIndex, replication::crdt::{CrdtSignalState, LWWRegister}, schema::EntityId, signals::{SignalLedger, SignalTypeId}, }; // --------------------------------------------------------------------------- // HardNegAction // --------------------------------------------------------------------------- /// An action applied to a hard-negative register. /// /// Stored inside an `LWWRegister` and resolved by HLC /// timestamp during reconciliation. // `Ord`/`PartialOrd` are required because `LWWRegister` folds the value into // its tie-break so `merge` is commutative on an exact-HLC-timestamp collision. // Declaration order (`Hide` < `Unhide`) makes such a tie resolve to `Unhide`; // the value tie-break only matters for the otherwise-unreachable collision case. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] pub enum HardNegAction { /// The user explicitly hid, muted, or blocked this item. Hide, /// The user reversed a previous hide (explicit un-hide). Unhide, } // --------------------------------------------------------------------------- // StateSnapshot // --------------------------------------------------------------------------- /// A snapshot of CRDT state for reconciliation. /// /// Produced by `TidalDb::take_crdt_snapshot()` or constructed manually in /// tests. Contains the per-key CRDT state for all entities and hard negatives /// that participated in diverged writes. /// /// # Wire serde /// /// The in-memory maps are tuple-keyed (`(EntityId, SignalTypeId)` / /// `(EntityId, EntityId)`), which JSON cannot use as object keys. Serialization /// therefore routes through [`StateSnapshotWire`] via `#[serde(from/into)]`: the /// maps become `Vec` entries carrying the newtypes' inner values /// (`EntityId::as_u64`, `SignalTypeId::as_u16`), reconstructed via their /// constructors on the way back in. This lets two processes exchange CRDT /// snapshots over HTTP for cross-process reconciliation. The roundtrip is /// lossless and `ReconciliationEngine::plan` is order-independent, so a plan /// computed against a roundtripped remote equals one against the original. #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] #[serde(from = "StateSnapshotWire", into = "StateSnapshotWire")] pub struct StateSnapshot { /// Per-(entity, `signal_type`) CRDT signal state. signal_states: HashMap<(EntityId, SignalTypeId), CrdtSignalState>, /// Per-(user, item) LWW hard-negative register. hardneg_registers: HashMap<(EntityId, EntityId), LWWRegister>, } /// JSON-friendly wire form of [`StateSnapshot`]. /// /// The tuple keys are flattened into `Vec` entries of the newtypes' inner /// values so the snapshot serializes to plain JSON arrays (no map-key /// restriction). This is an implementation detail of `StateSnapshot`'s serde — /// callers never construct it directly. /// /// # Float precision on the wire /// /// `CrdtSignalState` carries `f64` decay scores and a per-signal `lambda`. /// `serde_json` encodes `f64` with a shortest-roundtrip codec that is *not* /// guaranteed bitwise-exact for every magnitude — a tiny value like `lambda` /// (≈1.1e-6) can decode 1 ULP off. This is semantically harmless: a 1-ULP /// `lambda` shift moves a decay score by ~1e-16 relative (far below any decision /// boundary), and the receiver reconstructs `lambda` from its own schema when it /// applies the merged state. Reconcile determinism therefore holds at the level /// that matters (merged decay scores and hard-negative actions), which the /// reconcile-roundtrip tests assert against the observable outcome rather than /// raw bytes. #[derive(serde::Serialize, serde::Deserialize)] struct StateSnapshotWire { /// `(entity_id, signal_type_id, state)` per signal entry. signal_states: Vec<(u64, u16, CrdtSignalState)>, /// `(user_id, item_id, register)` per hard-negative entry. hardneg_registers: Vec<(u64, u64, LWWRegister)>, } impl From for StateSnapshotWire { fn from(snap: StateSnapshot) -> Self { Self { signal_states: snap .signal_states .into_iter() .map(|((entity, sig), state)| (entity.as_u64(), sig.as_u16(), state)) .collect(), hardneg_registers: snap .hardneg_registers .into_iter() .map(|((user, item), reg)| (user.as_u64(), item.as_u64(), reg)) .collect(), } } } impl From for StateSnapshot { fn from(wire: StateSnapshotWire) -> Self { Self { signal_states: wire .signal_states .into_iter() .map(|(entity, sig, state)| { ((EntityId::new(entity), SignalTypeId::new(sig)), state) }) .collect(), hardneg_registers: wire .hardneg_registers .into_iter() .map(|(user, item, reg)| ((EntityId::new(user), EntityId::new(item)), reg)) .collect(), } } } impl StateSnapshot { /// Create an empty snapshot. #[must_use] pub fn new() -> Self { Self::default() } /// Insert a signal state entry. pub fn add_signal_state( &mut self, entity_id: EntityId, signal_type_id: SignalTypeId, state: CrdtSignalState, ) { self.signal_states .insert((entity_id, signal_type_id), state); } /// Insert a hard-negative register entry. pub fn add_hardneg_register( &mut self, user_id: EntityId, item_id: EntityId, register: LWWRegister, ) { self.hardneg_registers.insert((user_id, item_id), register); } /// Number of signal state entries. #[must_use] pub fn signal_count(&self) -> usize { self.signal_states.len() } /// Number of hard-negative register entries. #[must_use] pub fn hardneg_count(&self) -> usize { self.hardneg_registers.len() } /// Iterate over all signal state keys. pub fn signal_keys(&self) -> impl Iterator + '_ { self.signal_states.keys().copied() } /// Get signal state for a key. #[must_use] pub fn signal_state(&self, key: (EntityId, SignalTypeId)) -> Option<&CrdtSignalState> { self.signal_states.get(&key) } /// Iterate over all hard-negative keys. pub fn hardneg_keys(&self) -> impl Iterator + '_ { self.hardneg_registers.keys().copied() } /// Get hard-negative register for a key. #[must_use] pub fn hardneg_register( &self, key: (EntityId, EntityId), ) -> Option<&LWWRegister> { self.hardneg_registers.get(&key) } } // --------------------------------------------------------------------------- // MergePlan operations // --------------------------------------------------------------------------- /// A merge operation for a single signal counter. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct SignalMergeOp { /// The entity whose signal state is being merged. pub entity_id: EntityId, /// The signal type being merged. pub signal_type_id: SignalTypeId, /// The CRDT-merged state (union of both nodes' contributions). pub merged_state: CrdtSignalState, } /// A resolution for a single hard-negative register. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct HardNegResolutionOp { /// The user whose hard-negative is being resolved. pub user_id: EntityId, /// The item targeted by the hard-negative. pub item_id: EntityId, /// Winning action after LWW resolution. `None` means no hard negative /// was ever written (both sides were empty). pub action: Option, } // --------------------------------------------------------------------------- // MergePlan // --------------------------------------------------------------------------- /// The reconciliation plan: a list of operations to apply. /// /// Produced by `ReconciliationEngine::plan()`. Applying the plan is /// idempotent -- applying it twice produces identical state. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct MergePlan { /// Signal merge operations (one per diverged entity-signal pair). pub signal_merges: Vec, /// Hard-negative resolution operations (one per diverged user-item pair). pub hardneg_resolutions: Vec, } impl MergePlan { /// Total number of operations in this plan. #[must_use] pub const fn operation_count(&self) -> usize { self.signal_merges.len() + self.hardneg_resolutions.len() } /// Whether this plan has no operations (snapshots were identical). #[must_use] pub const fn is_empty(&self) -> bool { self.signal_merges.is_empty() && self.hardneg_resolutions.is_empty() } } // --------------------------------------------------------------------------- // ReconciliationEngine // --------------------------------------------------------------------------- /// Produces and applies reconciliation plans for partitioned shards. /// /// The engine is bound to a local `SignalLedger` and `HardNegIndex`. /// It does not own or modify the remote state -- the caller provides /// snapshots and the engine computes a deterministic merge. /// /// # Usage /// /// ```ignore /// let engine = ReconciliationEngine::new( /// Arc::clone(&signal_ledger), /// Arc::clone(&hard_neg_index), /// ); /// let plan = engine.plan(&local_snapshot, &remote_snapshot); /// engine.apply(&plan)?; /// ``` pub struct ReconciliationEngine { signal_ledger: Arc, hard_neg_index: Arc, } impl ReconciliationEngine { /// Create a new engine bound to the given ledger and hard-neg index. #[must_use] pub const fn new(signal_ledger: Arc, hard_neg_index: Arc) -> Self { Self { signal_ledger, hard_neg_index, } } /// Produce a deterministic merge plan from two diverged state snapshots. /// /// - Signal states: union of both snapshots, CRDT-merged per /// `(entity, signal_type)`. /// - Hard negatives: LWW-resolved per `(user, item)` by HLC timestamp. /// /// Entities/signals present on only one side are included unchanged /// (no data loss -- single-sided state is still valid state). #[must_use] pub fn plan(&self, local: &StateSnapshot, remote: &StateSnapshot) -> MergePlan { // -- Signal merges -- let signal_keys: HashSet<(EntityId, SignalTypeId)> = local.signal_keys().chain(remote.signal_keys()).collect(); let mut signal_merges = Vec::with_capacity(signal_keys.len()); for key in signal_keys { let local_state = local.signal_state(key); let remote_state = remote.signal_state(key); let merged = match (local_state, remote_state) { (Some(l), Some(r)) => { let mut m = l.clone(); m.merge(r); m } (Some(l), None) => l.clone(), (None, Some(r)) => r.clone(), (None, None) => continue, // unreachable: key came from one of the iterators }; signal_merges.push(SignalMergeOp { entity_id: key.0, signal_type_id: key.1, merged_state: merged, }); } // -- Hard-negative resolutions -- let neg_keys: HashSet<(EntityId, EntityId)> = local.hardneg_keys().chain(remote.hardneg_keys()).collect(); let mut hardneg_resolutions = Vec::with_capacity(neg_keys.len()); for key in neg_keys { let local_reg = local.hardneg_register(key); let remote_reg = remote.hardneg_register(key); let resolved = match (local_reg, remote_reg) { (Some(l), Some(r)) => { let mut m = l.clone(); m.merge(r); m } (Some(l), None) => l.clone(), (None, Some(r)) => r.clone(), (None, None) => continue, // unreachable }; hardneg_resolutions.push(HardNegResolutionOp { user_id: key.0, item_id: key.1, action: resolved.get().cloned(), }); } MergePlan { signal_merges, hardneg_resolutions, } } /// Apply a merge plan to the local state. /// /// Idempotent: applying the same plan twice produces identical state. /// /// # Errors /// /// Returns an error if any signal type in the plan is unknown to the /// ledger's schema, or if a hard-negative item ID exceeds `u32::MAX`. pub fn apply(&self, plan: &MergePlan) -> crate::Result<()> { // Apply signal merges. for op in &plan.signal_merges { self.signal_ledger.apply_crdt_state( op.entity_id, op.signal_type_id, &op.merged_state, )?; } // Apply hard-negative resolutions. for op in &plan.hardneg_resolutions { // RoaringBitmap uses u32; EntityId wraps u64. let item_id = u32::try_from(op.item_id.as_u64()).map_err(|_| { crate::TidalError::Internal(crate::ErrorContext::new( "reconcile_apply", format!( "hard-negative item_id {} exceeds RoaringBitmap u32 range", op.item_id.as_u64() ), )) })?; let user_id = op.user_id.as_u64(); match &op.action { Some(HardNegAction::Hide) => { self.hard_neg_index.add(user_id, item_id); } Some(HardNegAction::Unhide) | None => { // Unhide or empty register: ensure the item is NOT in the // hard-negative set. self.hard_neg_index.remove(user_id, item_id); } } } Ok(()) } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] #[allow(clippy::unwrap_used, clippy::float_cmp, clippy::cast_precision_loss)] #[path = "reconcile_tests.rs"] mod tests;