safemesh_crdt/crdt/pncounter.rs
1// SafeMesh — delta-state CRDT convergence, built on crdt-lean.
2// Copyright (C) 2026 Ben Cassie
3// SPDX-License-Identifier: Apache-2.0
4
5use crate::{
6 ownership, CoordinateError, Crdt, GCounter, MergeError, Mergeable, RecordId, WireError,
7};
8
9/// An increment/decrement counter: a pair of G-Counters (increments `P`,
10/// decrements `N`), join = componentwise.
11///
12/// Model: `Crdt.PNCounter ι = (ι → ℕ) × (ι → ℕ)` with the Prod lattice.
13/// Deltas bump one side, one coordinate (`SafeMesh.deltaBumpP` /
14/// `deltaBumpN`); each side accumulates exactly as a delta G-Counter
15/// (`SafeMesh.deltaPNCounter_correct_P` / `_N`), and equal state gives an
16/// equal read (`SafeMesh.deltaPNCounter_value_matches`).
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct PnCounter {
19 p: GCounter,
20 n: GCounter,
21}
22
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub enum PnCounterDelta {
25 Inc { replica: usize, tally: u64 },
26 Dec { replica: usize, tally: u64 },
27}
28
29impl PnCounter {
30 /// Fresh counter for `n` replicas — the lattice bottom `(⊥, ⊥)`.
31 ///
32 /// # Panics
33 /// Panics above [`GCounter::MAX_REPLICAS`] or if either side cannot be
34 /// allocated. Use [`Self::try_new`] for a fallible width check.
35 pub fn new(n: usize) -> Self {
36 Self::try_new(n).expect("counter width cannot be allocated")
37 }
38
39 /// Reject counts above [`GCounter::MAX_REPLICAS`] (4,096) before allocation.
40 /// This matches the wire author limit and bounds both sides to 64 KiB.
41 /// Allocate both sides through the zeroed allocator path, as on main.
42 /// Admitted allocation exhaustion follows the allocator's out-of-memory policy.
43 pub fn try_new(n: usize) -> Result<Self, CoordinateError> {
44 Ok(PnCounter {
45 p: GCounter::try_new(n)?,
46 n: GCounter::try_new(n)?,
47 })
48 }
49
50 /// Apply `SafeMesh.deltaBumpP replica tally` — increment side, one
51 /// coordinate on the wire, `⊥` on the other side.
52 /// Out-of-range indices are ignored; use [`Self::try_apply_inc`] for an error.
53 pub fn apply_inc(&mut self, replica: usize, tally: u64) {
54 self.p.apply_bump(replica, tally);
55 }
56
57 /// Apply an increment-side delta with a checked replica coordinate.
58 ///
59 /// Returns [`CoordinateError::ReplicaOutOfRange`] before any mutation if the
60 /// index is invalid. Both sides remain unchanged on error. Valid input has
61 /// the same effect as [`Self::apply_inc`].
62 pub fn try_apply_inc(&mut self, replica: usize, tally: u64) -> Result<(), CoordinateError> {
63 self.p.try_apply_bump(replica, tally)
64 }
65
66 /// Apply `SafeMesh.deltaBumpN replica tally` — decrement side.
67 /// Out-of-range indices are ignored; use [`Self::try_apply_dec`] for an error.
68 pub fn apply_dec(&mut self, replica: usize, tally: u64) {
69 self.n.apply_bump(replica, tally);
70 }
71
72 /// Apply a decrement-side delta with a checked replica coordinate.
73 ///
74 /// Returns [`CoordinateError::ReplicaOutOfRange`] before any mutation if the
75 /// index is invalid. Both sides remain unchanged on error. Valid input has
76 /// the same effect as [`Self::apply_dec`].
77 pub fn try_apply_dec(&mut self, replica: usize, tally: u64) -> Result<(), CoordinateError> {
78 self.n.try_apply_bump(replica, tally)
79 }
80
81 /// Checked full-state merge: componentwise checked G-Counter merge,
82 /// erroring on a replica-count mismatch on either side instead of silently
83 /// truncating (WS1). Both sides are length-checked before any mutation, so
84 /// the operation is all-or-nothing: on error `self` is left unchanged.
85 pub fn try_merge(&mut self, other: &PnCounter) -> Result<(), MergeError> {
86 if self.p.state().len() != other.p.state().len() {
87 return Err(MergeError::ReplicaCountMismatch {
88 own: self.p.state().len(),
89 other: other.p.state().len(),
90 });
91 }
92 if self.n.state().len() != other.n.state().len() {
93 return Err(MergeError::ReplicaCountMismatch {
94 own: self.n.state().len(),
95 other: other.n.state().len(),
96 });
97 }
98 self.p.try_merge(&other.p)?;
99 self.n.try_merge(&other.n)?;
100 Ok(())
101 }
102
103 /// Full-state merge: componentwise G-Counter merge —
104 /// `Crdt.pncounter_merge_apply`.
105 ///
106 /// Returns [`MergeError::ReplicaCountMismatch`] without changing either
107 /// side if the replica counts differ, just like [`PnCounter::try_merge`].
108 pub fn merge(&mut self, other: &PnCounter) -> Result<(), MergeError> {
109 self.try_merge(other)
110 }
111
112 /// Increment-side state.
113 pub fn p_state(&self) -> &[u64] {
114 self.p.state()
115 }
116
117 /// Decrement-side state.
118 pub fn n_state(&self) -> &[u64] {
119 self.n.state()
120 }
121
122 /// The counter's read: (sum of increments) − (sum of decrements) —
123 /// `Crdt.pncounterValue`, an integer with no bound in the model.
124 ///
125 /// The read is exact for every representable state: each side's total is
126 /// below `2^127` (see [`GCounter::value`]), so both fit in `i128` and their
127 /// difference lies strictly inside `i128`'s range. No conversion here can
128 /// change the sign or wrap; a positive total always reads positive.
129 pub fn value(&self) -> i128 {
130 let increments = i128::try_from(self.p.value()).expect("a G-Counter total is below 2^127");
131 let decrements = i128::try_from(self.n.value()).expect("a G-Counter total is below 2^127");
132 increments - decrements
133 }
134}
135
136impl Mergeable for PnCounter {
137 fn merge(&mut self, other: &Self) -> Result<(), MergeError> {
138 PnCounter::merge(self, other)
139 }
140}
141
142impl Crdt for PnCounter {
143 type Delta = PnCounterDelta;
144
145 fn validate_record(&self, id: RecordId, delta: &Self::Delta) -> Result<(), WireError> {
146 ownership::check_counter_record(self.p.len(), id, delta)
147 .map_err(|_| WireError::OwnershipViolation)
148 }
149
150 fn replica_count(&self) -> Option<usize> {
151 Some(self.p.len())
152 }
153
154 fn apply_delta(&mut self, delta: Self::Delta) {
155 match delta {
156 PnCounterDelta::Inc { replica, tally } => self.apply_inc(replica, tally),
157 PnCounterDelta::Dec { replica, tally } => self.apply_dec(replica, tally),
158 }
159 }
160}