# Task 02: PNCounter ## Delivers `PNCounter` in `tidal/src/replication/crdt/pn_counter.rs`. Per-node P and N vectors (backed by `HashMap`). Supports `increment`, `decrement`, `merge`, `value`. Property tests verify commutativity, monotonicity, and associativity (CMA) across 100K random operations over 5 nodes. ## Complexity: M ## Dependencies - Phase 8.1 (ShardId) ## Technical Design ```rust // tidal/src/replication/crdt/pn_counter.rs /// Positive-Negative Counter CRDT. /// /// Each node (ShardId) maintains its own P (increment) and N (decrement) /// totals. The global value = sum(P) - sum(N). Merge takes the per-node /// max of each component -- safe because values only ever increase within /// a node. /// /// Properties: /// - Commutative: merge(A, B) == merge(B, A) /// - Associative: merge(A, merge(B, C)) == merge(merge(A, B), C) /// - Idempotent: merge(A, A) == A #[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] pub struct PNCounter { positive: HashMap, negative: HashMap, } impl PNCounter { pub fn new() -> Self { Self::default() } /// Increment by `amount` for this node. pub fn increment(&mut self, node: ShardId, amount: u64) { *self.positive.entry(node).or_default() += amount; } /// Decrement by `amount` for this node. pub fn decrement(&mut self, node: ShardId, amount: u64) { *self.negative.entry(node).or_default() += amount; } /// Merge another counter into this one. /// /// Takes the per-node maximum of both P and N components. /// Safe because each node's contribution only grows. pub fn merge(&mut self, other: &PNCounter) { for (&node, &val) in &other.positive { let entry = self.positive.entry(node).or_default(); *entry = (*entry).max(val); } for (&node, &val) in &other.negative { let entry = self.negative.entry(node).or_default(); *entry = (*entry).max(val); } } /// Returns the current value: sum(P) - sum(N). /// /// Saturates at 0 (never negative). pub fn value(&self) -> u64 { let p: u64 = self.positive.values().sum(); let n: u64 = self.negative.values().sum(); p.saturating_sub(n) } /// Total positive contributions across all nodes. pub fn total_positive(&self) -> u64 { self.positive.values().sum() } } ``` ## Acceptance Criteria - [ ] `PNCounter::increment(node, amount)` increases the P component for `node` - [ ] `PNCounter::decrement(node, amount)` increases the N component for `node` - [ ] `PNCounter::value()` returns `sum(P) - sum(N)`, saturating at 0 - [ ] `PNCounter::merge` is commutative: `merge(A, B) == merge(B, A)` (property test: 100K random sequences, 5 nodes) - [ ] `PNCounter::merge` is associative: `merge(A, merge(B, C)) == merge(merge(A, B), C)` (property test) - [ ] `PNCounter::merge` is idempotent: `merge(A, A) == A` (property test) - [ ] No double-counting: after merging two counters that each received N independent increments (no overlap), `value() == N * 2` (property test) - [ ] `cargo clippy -D warnings` and `cargo fmt` pass