Skip to main content

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}