1use std::collections::BTreeMap;
42use std::collections::HashMap;
43
44use serde::{Deserialize, Serialize};
45
46use crate::traits::{CrdtMerge, DeltaCrdt};
47
48#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
54pub struct SignedSum {
55 pos: HashMap<String, i128>,
57 neg: HashMap<String, i128>,
59 #[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 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 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 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 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#[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#[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 pub fn add(&mut self, key: K, node: &str, delta: i128) {
171 self.entries.entry(key).or_default().add(node, delta);
172 }
173
174 pub fn get(&self, key: &K) -> i128 {
176 self.entries.get(key).map(SignedSum::value).unwrap_or(0)
177 }
178
179 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 let mut a = SignedSum::new();
219 let mut b = SignedSum::new();
220 a.add("n1", 5);
221 a.add("n1", 3); b.add("n1", 5); 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 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 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); (r1, r2, r3)
258 };
259
260 let (mut a, b, c) = build();
262 a.merge(&b);
263 a.merge(&c);
264
265 let (r1, r2, mut x) = build();
267 x.merge(&r1);
268 x.merge(&r2);
269
270 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; 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); assert_eq!(s.value(), i128::MAX);
291 }
292
293 #[test]
294 fn saturates_at_i128_min_value() {
295 let mut s = SignedSum::new();
296 s.add("n1", i128::MIN);
299 assert_eq!(s.value(), i128::MIN + 1);
301
302 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 let mut s = SignedSum::new();
316 s.add("a", i128::MIN);
317 s.add("b", i128::MIN);
318 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.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 use proptest::prelude::*;
387
388 proptest! {
389 #[test]
404 fn any_interleaving_converges(
405 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 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 let mut reference: SignedSumMap<String> = SignedSumMap::new();
427 for r in &replicas {
428 reference.merge(r);
429 }
430
431 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 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}