Skip to main content

atomr_distributed_data/
signed_sum.rs

1//! Signed, `i128`-payload exposure CRDT (FR-4).
2//!
3//! [`SignedSum`] tracks a firm-wide *net signed exposure* that converges
4//! across replicas, and [`SignedSumMap`] keys those exposures (e.g. per
5//! instrument / desk / counterparty).
6//!
7//! # Why a PN-counter shape over `i128`
8//!
9//! [`SignedSum`] mirrors [`crate::counters::PNCounter`]: it keeps a
10//! per-node *positive* accumulator and a per-node *negative* accumulator.
11//! Each accumulator is itself a grow-only register — a node's own value
12//! only ever increases — so the join-semilattice merge is simply a
13//! per-node `max` of `pos` and a per-node `max` of `neg`. That makes
14//! [`CrdtMerge::merge`] commutative, associative and idempotent, which is
15//! exactly the convergence guarantee callers rely on.
16//!
17//! `value() = sum(pos) - sum(neg)`.
18//!
19//! # Negative deltas
20//!
21//! Negative deltas are first-class: [`SignedSum::add`] routes a `delta`
22//! into the `neg` accumulator (by its absolute magnitude) when `delta < 0`
23//! and into `pos` otherwise. There is no separate `decrement` entry point.
24//!
25//! # Overflow
26//!
27//! Exposure is financial and must never silently wrap. All accumulation
28//! and the final `value()` reduction use **saturating** `i128` arithmetic:
29//! a per-node accumulator saturates at [`i128::MAX`], and `value()`
30//! saturates at [`i128::MAX`] / [`i128::MIN`]. Saturation is monotone, so
31//! it preserves the join-semilattice merge (a saturated `pos` is still the
32//! `max` of the two inputs).
33//!
34//! # Decimal feature (deferred)
35//!
36//! A future `decimal` feature could swap the `i128` payload for a
37//! fixed-point decimal type to carry fractional exposure. It is
38//! intentionally **out of scope here** to avoid a cross-crate dependency;
39//! `i128` (minor units / basis points) is the deliverable.
40
41use std::collections::BTreeMap;
42use std::collections::HashMap;
43
44use serde::{Deserialize, Serialize};
45
46use crate::traits::{CrdtMerge, DeltaCrdt};
47
48/// Per-node positive / negative accumulators for one signed exposure.
49///
50/// Both accumulators are grow-only per node; the net exposure is
51/// `sum(pos) - sum(neg)`. See the module-level docs for convergence and
52/// overflow semantics.
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
54pub struct SignedSum {
55    /// Per-node positive accumulator (monotonically increasing per node).
56    pos: HashMap<String, i128>,
57    /// Per-node negative accumulator (stores magnitudes; monotone per node).
58    neg: HashMap<String, i128>,
59    /// Accumulated since the last `take_delta`. Skipped on serialization so
60    /// peers never observe another node's pending delta — they receive
61    /// deltas through the explicit delta-gossip path. Mirrors `GCounter`.
62    #[serde(skip)]
63    pending_pos: HashMap<String, i128>,
64    #[serde(skip)]
65    pending_neg: HashMap<String, i128>,
66}
67
68impl SignedSum {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Apply a signed `delta` attributed to `node`.
74    ///
75    /// `delta >= 0` adds to the node's positive accumulator; `delta < 0`
76    /// adds its magnitude to the node's negative accumulator. Accumulation
77    /// is saturating — a node's accumulator never wraps and never decreases.
78    pub fn add(&mut self, node: &str, delta: i128) {
79        let key = node.to_string();
80        if delta >= 0 {
81            Self::add_into(&mut self.pos, key.clone(), delta);
82            Self::add_into(&mut self.pending_pos, key, delta);
83        } else {
84            // Magnitude of a negative delta. `i128::MIN.unsigned_abs()` does
85            // not fit in i128, so saturate the magnitude at `i128::MAX`.
86            let mag = delta.checked_neg().unwrap_or(i128::MAX);
87            Self::add_into(&mut self.neg, key.clone(), mag);
88            Self::add_into(&mut self.pending_neg, key, mag);
89        }
90    }
91
92    /// Saturating per-node accumulation helper.
93    fn add_into(map: &mut HashMap<String, i128>, key: String, amount: i128) {
94        let slot = map.entry(key).or_default();
95        *slot = slot.saturating_add(amount);
96    }
97
98    /// Net signed exposure: `sum(pos) - sum(neg)`, computed with
99    /// saturating arithmetic (saturates at `i128::MAX` / `i128::MIN`).
100    pub fn value(&self) -> i128 {
101        let pos = Self::saturating_sum(&self.pos);
102        let neg = Self::saturating_sum(&self.neg);
103        pos.saturating_sub(neg)
104    }
105
106    fn saturating_sum(map: &HashMap<String, i128>) -> i128 {
107        map.values().fold(0i128, |acc, &v| acc.saturating_add(v))
108    }
109}
110
111impl CrdtMerge for SignedSum {
112    fn merge(&mut self, other: &Self) {
113        for (k, v) in &other.pos {
114            let slot = self.pos.entry(k.clone()).or_default();
115            *slot = (*slot).max(*v);
116        }
117        for (k, v) in &other.neg {
118            let slot = self.neg.entry(k.clone()).or_default();
119            *slot = (*slot).max(*v);
120        }
121    }
122}
123
124/// Delta for [`SignedSum`]: the per-node `pos` / `neg` increments
125/// accumulated since the last [`DeltaCrdt::take_delta`].
126#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
127pub struct SignedSumDelta {
128    pos: HashMap<String, i128>,
129    neg: HashMap<String, i128>,
130}
131
132impl DeltaCrdt for SignedSum {
133    type Delta = SignedSumDelta;
134
135    fn take_delta(&mut self) -> Option<Self::Delta> {
136        if self.pending_pos.is_empty() && self.pending_neg.is_empty() {
137            return None;
138        }
139        Some(SignedSumDelta {
140            pos: std::mem::take(&mut self.pending_pos),
141            neg: std::mem::take(&mut self.pending_neg),
142        })
143    }
144
145    fn merge_delta(&mut self, delta: &Self::Delta) {
146        for (k, v) in &delta.pos {
147            Self::add_into(&mut self.pos, k.clone(), *v);
148        }
149        for (k, v) in &delta.neg {
150            Self::add_into(&mut self.neg, k.clone(), *v);
151        }
152    }
153}
154
155/// Map of `K → SignedSum` — convergent per-key net signed exposure.
156///
157/// Merge is the per-key [`SignedSum`] merge over the union of keys, so the
158/// map inherits the same commutative / associative / idempotent guarantee.
159#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
160pub struct SignedSumMap<K: Ord + Clone> {
161    entries: BTreeMap<K, SignedSum>,
162}
163
164impl<K: Ord + Clone> SignedSumMap<K> {
165    pub fn new() -> Self {
166        Self { entries: BTreeMap::new() }
167    }
168
169    /// Apply a signed `delta` attributed to `node` for `key`.
170    pub fn add(&mut self, key: K, node: &str, delta: i128) {
171        self.entries.entry(key).or_default().add(node, delta);
172    }
173
174    /// Net signed exposure for `key` (`0` if the key is absent).
175    pub fn get(&self, key: &K) -> i128 {
176        self.entries.get(key).map(SignedSum::value).unwrap_or(0)
177    }
178
179    /// Iterate `(&key, net_value)` over all keys, in key order.
180    pub fn entries(&self) -> impl Iterator<Item = (&K, i128)> {
181        self.entries.iter().map(|(k, s)| (k, s.value()))
182    }
183}
184
185impl<K: Ord + Clone> CrdtMerge for SignedSumMap<K> {
186    fn merge(&mut self, other: &Self) {
187        for (k, v) in &other.entries {
188            self.entries.entry(k.clone()).or_default().merge(v);
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn add_routes_sign_correctly() {
199        let mut s = SignedSum::new();
200        s.add("n1", 100);
201        s.add("n1", -30);
202        assert_eq!(s.value(), 70);
203    }
204
205    #[test]
206    fn negative_deltas_are_first_class() {
207        let mut s = SignedSum::new();
208        s.add("n1", -50);
209        s.add("n2", -25);
210        s.add("n1", 10);
211        assert_eq!(s.value(), -65);
212    }
213
214    #[test]
215    fn merge_takes_per_node_max() {
216        // Same node observed at two replicas: merge takes the max, it does
217        // NOT sum (that is what keeps re-merge idempotent).
218        let mut a = SignedSum::new();
219        let mut b = SignedSum::new();
220        a.add("n1", 5);
221        a.add("n1", 3); // n1.pos = 8 at a
222        b.add("n1", 5); // n1.pos = 5 at b (stale view)
223        b.add("n2", 7);
224        a.merge(&b);
225        assert_eq!(a.value(), 8 + 7);
226    }
227
228    #[test]
229    fn merge_is_idempotent() {
230        let mut a = SignedSum::new();
231        a.add("n1", 10);
232        a.add("n2", -4);
233        let before = a.clone();
234        // Merging a replica's own state (or any state already absorbed)
235        // any number of times must leave the converged state untouched.
236        a.merge(&before);
237        a.merge(&before);
238        assert_eq!(a.pos, before.pos);
239        assert_eq!(a.neg, before.neg);
240        assert_eq!(a.value(), 6);
241    }
242
243    #[test]
244    fn merge_commutative_associative_across_three_replicas() {
245        // Three replicas each see a disjoint-ish stream of ops. Every merge
246        // order / interleaving must converge to the identical value.
247        let build = || {
248            let mut r1 = SignedSum::new();
249            r1.add("n1", 100);
250            r1.add("n1", -10);
251            let mut r2 = SignedSum::new();
252            r2.add("n2", -40);
253            r2.add("n2", 5);
254            let mut r3 = SignedSum::new();
255            r3.add("n3", 7);
256            r3.add("n1", 100); // stale view of n1 (<= r1's 90)
257            (r1, r2, r3)
258        };
259
260        // Order A: ((r1 <- r2) <- r3)
261        let (mut a, b, c) = build();
262        a.merge(&b);
263        a.merge(&c);
264
265        // Order B: ((r3 <- r1) <- r2)
266        let (r1, r2, mut x) = build();
267        x.merge(&r1);
268        x.merge(&r2);
269
270        // Order C: r2 <- (r3 merged into r1) — different associativity.
271        let (mut p, q, r) = build();
272        let mut pr = r;
273        pr.merge(&p);
274        p = q;
275        p.merge(&pr);
276
277        let expected = 90 - 35 + 7; // n1=100 (max), n1.neg=10, n2=-35, n3=7
278        assert_eq!(a.value(), expected);
279        assert_eq!(x.value(), expected);
280        assert_eq!(p.value(), expected);
281        assert_eq!(a.value(), x.value());
282        assert_eq!(x.value(), p.value());
283    }
284
285    #[test]
286    fn saturates_at_i128_max_on_accumulate() {
287        let mut s = SignedSum::new();
288        s.add("n1", i128::MAX);
289        s.add("n1", i128::MAX); // would overflow -> saturates
290        assert_eq!(s.value(), i128::MAX);
291    }
292
293    #[test]
294    fn saturates_at_i128_min_value() {
295        let mut s = SignedSum::new();
296        // `i128::MIN` has no positive counterpart, so its magnitude
297        // saturates to `i128::MAX` in the neg accumulator.
298        s.add("n1", i128::MIN);
299        // pos=0, neg=i128::MAX => value = 0 - MAX = i128::MIN + 1, no wrap.
300        assert_eq!(s.value(), i128::MIN + 1);
301
302        // Pile on more negative magnitude across nodes: the neg sum
303        // saturates at i128::MAX, so value() floors at i128::MIN + 1 and
304        // never wraps positive.
305        s.add("n2", i128::MIN);
306        s.add("n3", -1_000_000);
307        assert_eq!(s.value(), i128::MIN + 1);
308    }
309
310    #[test]
311    fn value_subtraction_saturates_at_min() {
312        // Directly exercise the saturating_sub floor in value(): a neg sum
313        // at i128::MAX with zero pos yields i128::MIN + 1 (the true value),
314        // and the reduction never panics or wraps regardless of ordering.
315        let mut s = SignedSum::new();
316        s.add("a", i128::MIN);
317        s.add("b", i128::MIN);
318        // neg sum saturates at i128::MAX; 0 - MAX = i128::MIN + 1, no wrap.
319        assert_eq!(s.value(), i128::MIN + 1);
320    }
321
322    #[test]
323    fn value_reduction_saturates_positive() {
324        let mut s = SignedSum::new();
325        s.add("n1", i128::MAX);
326        s.add("n2", i128::MAX);
327        assert_eq!(s.value(), i128::MAX);
328    }
329
330    #[test]
331    fn map_per_key_isolation_and_merge() {
332        let mut m: SignedSumMap<&'static str> = SignedSumMap::new();
333        m.add("aapl", "n1", 500);
334        m.add("aapl", "n1", -100);
335        m.add("msft", "n1", 200);
336        assert_eq!(m.get(&"aapl"), 400);
337        assert_eq!(m.get(&"msft"), 200);
338        assert_eq!(m.get(&"absent"), 0);
339
340        let mut m2: SignedSumMap<&'static str> = SignedSumMap::new();
341        m2.add("aapl", "n2", -50);
342        m2.add("googl", "n2", 90);
343        m.merge(&m2);
344        assert_eq!(m.get(&"aapl"), 350);
345        assert_eq!(m.get(&"msft"), 200);
346        assert_eq!(m.get(&"googl"), 90);
347
348        let collected: Vec<_> = m.entries().collect();
349        assert_eq!(collected, vec![(&"aapl", 350), (&"googl", 90), (&"msft", 200)]);
350    }
351
352    #[test]
353    fn delta_take_and_clear() {
354        let mut s = SignedSum::new();
355        s.add("a", 30);
356        s.add("a", -5);
357        let d = s.take_delta().expect("non-empty");
358        assert_eq!(d.pos.get("a"), Some(&30));
359        assert_eq!(d.neg.get("a"), Some(&5));
360        assert!(s.take_delta().is_none());
361    }
362
363    #[test]
364    fn delta_merge_matches_full_merge() {
365        let mut local = SignedSum::new();
366        local.add("a", 50);
367        local.add("a", -10);
368        let _ = local.take_delta();
369
370        let mut remote = SignedSum::new();
371        remote.add("a", 50);
372        remote.add("a", -10);
373        let _ = remote.take_delta();
374
375        // Local applies more ops, ships the delta, remote applies it.
376        local.add("a", 25);
377        local.add("a", -5);
378        let delta = local.take_delta().unwrap();
379        remote.merge_delta(&delta);
380        assert_eq!(remote.value(), local.value());
381        assert_eq!(remote.value(), 50 - 10 + 25 - 5);
382    }
383
384    // ---- proptest: any permutation / replica split / merge order converges.
385
386    use proptest::prelude::*;
387
388    proptest! {
389        /// Convergence under arbitrary op interleaving + arbitrary merge
390        /// order across N replicas.
391        ///
392        /// Each op carries the replica that applied it, and **that replica's
393        /// id is used as the CRDT node id**. This reflects the actual usage
394        /// contract of a per-node grow-only accumulator: a single logical
395        /// node owns its own counter. (Letting one node id be mutated
396        /// concurrently on two replicas and merged via `max` would lose
397        /// writes — that is the documented G/PN-counter model, not a bug.)
398        ///
399        /// Given that contract, a key's converged net value is deterministic:
400        /// the per-node grow-only accumulators commute, so summing every
401        /// op (grouped by node) gives the reference, and merging the replicas
402        /// in *any* order — with arbitrary repeats — must reproduce it.
403        #[test]
404        fn any_interleaving_converges(
405            // (key 0..4, replica 0..4, delta). The replica is also the node.
406            ops in proptest::collection::vec(
407                (0u8..4, 0u8..4, -1_000_000i128..1_000_000i128),
408                0..80,
409            ),
410            merge_perm in proptest::collection::vec(any::<u8>(), 0..32),
411        ) {
412            let n_replicas = 4usize;
413            let node_name = |n: u8| format!("node-{n}");
414            let key_name = |k: u8| format!("key-{k}");
415
416            // Each replica applies its own ops under its own node id.
417            let mut replicas: Vec<SignedSumMap<String>> =
418                (0..n_replicas).map(|_| SignedSumMap::new()).collect();
419            for &(k, r, d) in &ops {
420                let r = r as usize % n_replicas;
421                replicas[r].add(key_name(k), &node_name(r as u8), d);
422            }
423
424            // Reference: merge every replica once into a fresh map (a fixed,
425            // canonical merge order). Convergence means any other order ties.
426            let mut reference: SignedSumMap<String> = SignedSumMap::new();
427            for r in &replicas {
428                reference.merge(r);
429            }
430
431            // acc: start from replica 0, full-merge all, then re-merge in a
432            // seed-driven order with repeats (commutativity + idempotence).
433            let mut acc = replicas[0].clone();
434            for r in &replicas {
435                acc.merge(r);
436            }
437            for &seed in &merge_perm {
438                acc.merge(&replicas[seed as usize % n_replicas]);
439            }
440
441            // acc2: start from the last replica, merge in reverse order.
442            let mut acc2 = replicas[n_replicas - 1].clone();
443            for r in replicas.iter().rev() {
444                acc2.merge(r);
445            }
446
447            for k in 0u8..4 {
448                let key = key_name(k);
449                prop_assert_eq!(acc.get(&key), reference.get(&key));
450                prop_assert_eq!(acc2.get(&key), reference.get(&key));
451            }
452        }
453    }
454}