Skip to main content

hyperactor/
accum.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Defines the accumulator trait and some common accumulators.
10
11use std::collections::HashMap;
12use std::marker::PhantomData;
13use std::sync::OnceLock;
14use std::time::Duration;
15
16use algebra::JoinSemilattice;
17use enum_as_inner::EnumAsInner;
18use serde::Deserialize;
19use serde::Serialize;
20use serde::de::DeserializeOwned;
21use typeuri::Named;
22
23// for macros
24use crate::config;
25
26/// An accumulator is a object that accumulates updates into a state.
27pub trait Accumulator {
28    /// The type of the accumulated state.
29    type State;
30    /// The type of the updates sent to the accumulator. Updates will be
31    /// accumulated into type [Self::State].
32    type Update;
33
34    /// Accumulate an update into the current state.
35    fn accumulate(&self, state: &mut Self::State, update: Self::Update) -> anyhow::Result<()>;
36
37    /// The specification used to build the reducer.
38    fn reducer_spec(&self) -> Option<ReducerSpec>;
39}
40
41/// Serializable information needed to build a comm reducer.
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, typeuri::Named)]
43pub struct ReducerSpec {
44    /// The typehash of the underlying [Self::Reducer] type.
45    pub typehash: u64,
46    /// The parameters used to build the reducer.
47    pub builder_params: Option<wirevalue::Any>,
48}
49wirevalue::register_type!(ReducerSpec);
50
51/// Options for streaming reducer mode.
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named, Default)]
53pub struct StreamingReducerOpts {
54    /// The maximum interval between updates. When unspecified, a default
55    /// interval is used.
56    pub max_update_interval: Option<Duration>,
57    /// The initial interval for the first update. When unspecified, defaults to 1ms.
58    /// This allows quick flushing of single messages while using exponential backoff
59    /// to reach max_update_interval for batched messages.
60    pub initial_update_interval: Option<Duration>,
61}
62
63/// The mode in which a reducer operates.
64#[derive(
65    Debug,
66    Clone,
67    PartialEq,
68    Serialize,
69    Deserialize,
70    EnumAsInner,
71    typeuri::Named
72)]
73pub enum ReducerMode {
74    /// Streaming mode: continuously reduce and emit updates based on buffer size/timeout.
75    Streaming(StreamingReducerOpts),
76    /// Once mode: accumulate exactly `n` values, emit a single reduced update, then tear down.
77    Once(usize),
78}
79
80impl Default for ReducerMode {
81    fn default() -> Self {
82        ReducerMode::Streaming(StreamingReducerOpts::default())
83    }
84}
85
86impl ReducerMode {
87    pub(crate) fn max_update_interval(&self) -> Duration {
88        match self {
89            ReducerMode::Streaming(opts) => opts
90                .max_update_interval
91                .unwrap_or(hyperactor_config::global::get(config::SPLIT_MAX_BUFFER_AGE)),
92            ReducerMode::Once(_) => Duration::MAX,
93        }
94    }
95
96    pub(crate) fn initial_update_interval(&self) -> Duration {
97        match self {
98            ReducerMode::Streaming(opts) => opts
99                .initial_update_interval
100                .unwrap_or(Duration::from_millis(1)),
101            ReducerMode::Once(_) => Duration::MAX,
102        }
103    }
104}
105
106/// Commutative reducer for an accumulator. This is used to coallesce updates.
107/// For example, if the accumulator is a sum, its reducer calculates and returns
108/// the sum of 2 updates. This is helpful in split ports, where a large number
109/// of updates can be reduced into a smaller number of updates before being sent
110/// to the parent port.
111pub trait CommReducer {
112    /// The type of updates to be reduced.
113    type Update;
114
115    /// Reduce 2 updates into a single update.
116    fn reduce(&self, left: Self::Update, right: Self::Update) -> anyhow::Result<Self::Update>;
117}
118
119/// Type erased version of [CommReducer].
120pub trait ErasedCommReducer {
121    /// Reduce 2 updates into a single update.
122    fn reduce_erased(
123        &self,
124        left: &wirevalue::Any,
125        right: &wirevalue::Any,
126    ) -> anyhow::Result<wirevalue::Any>;
127
128    /// Reducer an non-empty vector of updates. Return Error if the vector is
129    /// empty.
130    fn reduce_updates(
131        &self,
132        updates: Vec<wirevalue::Any>,
133    ) -> Result<wirevalue::Any, (anyhow::Error, Vec<wirevalue::Any>)> {
134        if updates.is_empty() {
135            return Err((anyhow::anyhow!("empty updates"), updates));
136        }
137        if updates.len() == 1 {
138            return Ok(updates.into_iter().next().expect("checked above"));
139        }
140
141        let mut iter = updates.iter();
142        let first = iter.next().unwrap();
143        let second = iter.next().unwrap();
144        let init = match self.reduce_erased(first, second) {
145            Ok(v) => v,
146            Err(e) => return Err((e, updates)),
147        };
148        let reduced = match iter.try_fold(init, |acc, e| self.reduce_erased(&acc, e)) {
149            Ok(v) => v,
150            Err(e) => return Err((e, updates)),
151        };
152        Ok(reduced)
153    }
154
155    /// Typehash of the underlying [`CommReducer`] type.
156    fn typehash(&self) -> u64;
157}
158
159impl<R, T> ErasedCommReducer for R
160where
161    R: CommReducer<Update = T> + Named,
162    T: Serialize + DeserializeOwned + Named,
163{
164    fn reduce_erased(
165        &self,
166        left: &wirevalue::Any,
167        right: &wirevalue::Any,
168    ) -> anyhow::Result<wirevalue::Any> {
169        let left = left.deserialized::<T>()?;
170        let right = right.deserialized::<T>()?;
171        let result = self.reduce(left, right)?;
172        Ok(wirevalue::Any::serialize(&result)?)
173    }
174
175    fn typehash(&self) -> u64 {
176        R::typehash()
177    }
178}
179
180/// A factory for [`ErasedCommReducer`]s. This is used to register a
181/// [`ErasedCommReducer`] type. We cannot register [`ErasedCommReducer`] trait
182/// object directly because the object could have internal state, and cannot be
183/// shared.
184pub struct ReducerFactory {
185    /// Return the typehash of the [`ErasedCommReducer`] type built by this
186    /// factory.
187    pub typehash_f: fn() -> u64,
188    /// The builder function to build the [`ErasedCommReducer`] type.
189    pub builder_f: fn(
190        Option<wirevalue::Any>,
191    ) -> anyhow::Result<Box<dyn ErasedCommReducer + Sync + Send + 'static>>,
192}
193
194inventory::collect!(ReducerFactory);
195
196inventory::submit! {
197    ReducerFactory {
198        typehash_f: <SumReducer<i64> as Named>::typehash,
199        builder_f: |_| Ok(Box::new(SumReducer::<i64>(PhantomData))),
200    }
201}
202inventory::submit! {
203    ReducerFactory {
204        typehash_f: <SumReducer<u64> as Named>::typehash,
205        builder_f: |_| Ok(Box::new(SumReducer::<u64>(PhantomData))),
206    }
207}
208inventory::submit! {
209    ReducerFactory {
210        typehash_f: <SemilatticeReducer<Max<i64>> as Named>::typehash,
211        builder_f: |_| Ok(Box::new(SemilatticeReducer::<Max<i64>>(PhantomData))),
212    }
213}
214inventory::submit! {
215    ReducerFactory {
216        typehash_f: <SemilatticeReducer<Max<u64>> as Named>::typehash,
217        builder_f: |_| Ok(Box::new(SemilatticeReducer::<Max<u64>>(PhantomData))),
218    }
219}
220inventory::submit! {
221    ReducerFactory {
222        typehash_f: <SemilatticeReducer<Min<i64>> as Named>::typehash,
223        builder_f: |_| Ok(Box::new(SemilatticeReducer::<Min<i64>>(PhantomData))),
224    }
225}
226inventory::submit! {
227    ReducerFactory {
228        typehash_f: <SemilatticeReducer<Min<u64>> as Named>::typehash,
229        builder_f: |_| Ok(Box::new(SemilatticeReducer::<Min<u64>>(PhantomData))),
230    }
231}
232inventory::submit! {
233    ReducerFactory {
234        typehash_f: <SemilatticeReducer<WatermarkUpdate<i64>> as Named>::typehash,
235        builder_f: |_| Ok(Box::new(SemilatticeReducer::<WatermarkUpdate<i64>>(PhantomData))),
236    }
237}
238inventory::submit! {
239    ReducerFactory {
240        typehash_f: <SemilatticeReducer<WatermarkUpdate<u64>> as Named>::typehash,
241        builder_f: |_| Ok(Box::new(SemilatticeReducer::<WatermarkUpdate<u64>>(PhantomData))),
242    }
243}
244inventory::submit! {
245    ReducerFactory {
246        typehash_f: <SemilatticeReducer<GCounterUpdate> as Named>::typehash,
247        builder_f: |_| Ok(Box::new(SemilatticeReducer::<GCounterUpdate>(PhantomData))),
248    }
249}
250inventory::submit! {
251    ReducerFactory {
252        typehash_f: <SemilatticeReducer<PNCounterUpdate> as Named>::typehash,
253        builder_f: |_| Ok(Box::new(SemilatticeReducer::<PNCounterUpdate>(PhantomData))),
254    }
255}
256inventory::submit! {
257    ReducerFactory {
258        typehash_f: <UnitReducer as Named>::typehash,
259        builder_f: |_| Ok(Box::new(UnitReducer)),
260    }
261}
262
263/// Build a reducer object with the given typehash's [CommReducer] type, and
264/// return the type-erased version of it.
265pub(crate) fn resolve_reducer(
266    typehash: u64,
267    builder_params: Option<wirevalue::Any>,
268) -> anyhow::Result<Option<Box<dyn ErasedCommReducer + Sync + Send + 'static>>> {
269    static FACTORY_MAP: OnceLock<HashMap<u64, &'static ReducerFactory>> = OnceLock::new();
270    let factories = FACTORY_MAP.get_or_init(|| {
271        let mut map = HashMap::new();
272        for factory in inventory::iter::<ReducerFactory> {
273            map.insert((factory.typehash_f)(), factory);
274        }
275        map
276    });
277
278    factories
279        .get(&typehash)
280        .map(|f| (f.builder_f)(builder_params))
281        .transpose()
282}
283
284#[derive(typeuri::Named)]
285struct SumReducer<T>(PhantomData<T>);
286
287impl<T: std::ops::Add<Output = T> + Copy + 'static> CommReducer for SumReducer<T> {
288    type Update = T;
289
290    fn reduce(&self, left: T, right: T) -> anyhow::Result<T> {
291        Ok(left + right)
292    }
293}
294
295/// Accumulate the sum of received updates. The inner function performs the
296/// summation between an update and the current state.
297struct SumAccumulator<T>(PhantomData<T>);
298
299impl<T: std::ops::Add<Output = T> + Copy + Named + 'static> Accumulator for SumAccumulator<T> {
300    type State = T;
301    type Update = T;
302
303    fn accumulate(&self, state: &mut T, update: T) -> anyhow::Result<()> {
304        *state = *state + update;
305        Ok(())
306    }
307
308    fn reducer_spec(&self) -> Option<ReducerSpec> {
309        Some(ReducerSpec {
310            typehash: <SumReducer<T> as Named>::typehash(),
311            builder_params: None,
312        })
313    }
314}
315
316/// Accumulate the sum of received updates.
317///
318/// # Note: Not a CRDT
319///
320/// This accumulator is *not idempotent* and is therefore *not
321/// suitable* for distributed scatter/gather patterns with
322/// at-least-once delivery semantics. Duplicate updates will be
323/// counted multiple times:
324///
325/// ```text
326/// sum(1, 2, 2, 3) = 8  (expected 6 if second 2 is duplicate)
327/// ```
328///
329/// ## When to use:
330/// - Single-source accumulation with exactly-once delivery
331/// - Local (non-distributed) aggregation
332/// - When upstream deduplication is guaranteed
333///
334/// ## CRDT Alternative:
335/// For distributed use cases, consider using a GCounter CRDT instead,
336/// which tracks per-replica increments and uses pointwise-max for
337/// merging (commutative, associative, and idempotent).
338///
339/// *See also*: [`Max`], [`Min`] (proper lattice-based CRDTs)
340pub fn sum<T: std::ops::Add<Output = T> + Copy + Named + 'static>()
341-> impl Accumulator<State = T, Update = T> {
342    SumAccumulator(PhantomData)
343}
344
345/// Reducer for the unit type `()`.
346#[derive(typeuri::Named)]
347struct UnitReducer;
348
349impl CommReducer for UnitReducer {
350    type Update = ();
351
352    fn reduce(&self, _left: (), _right: ()) -> anyhow::Result<()> {
353        Ok(())
354    }
355}
356
357/// Accumulate unit updates into a unit state.
358struct UnitAccumulator;
359
360impl Accumulator for UnitAccumulator {
361    type State = ();
362    type Update = ();
363
364    fn accumulate(&self, _state: &mut (), _update: ()) -> anyhow::Result<()> {
365        Ok(())
366    }
367
368    fn reducer_spec(&self) -> Option<ReducerSpec> {
369        Some(ReducerSpec {
370            typehash: <UnitReducer as Named>::typehash(),
371            builder_params: None,
372        })
373    }
374}
375
376/// Trivial `()` payload for scatter/gather patterns where the caller only
377/// cares that all expected replies arrived, not about any per-reply payload.
378///
379/// Note: this does not itself provide a barrier — the all-arrived guarantee
380/// comes from the reduce port's expected peer count. `unit()` just supplies
381/// the trivial `()` payload.
382pub fn unit() -> impl Accumulator<State = (), Update = ()> {
383    UnitAccumulator
384}
385
386/// Generic reducer for any JoinSemilattice type.
387#[derive(typeuri::Named)]
388struct SemilatticeReducer<L>(PhantomData<L>);
389
390impl<L: JoinSemilattice + Clone> CommReducer for SemilatticeReducer<L> {
391    type Update = L;
392
393    fn reduce(&self, left: L, right: L) -> anyhow::Result<L> {
394        Ok(left.join(&right))
395    }
396}
397
398/// Generic accumulator for any JoinSemilattice type.
399struct SemilatticeAccumulator<L>(PhantomData<L>);
400
401impl<L: JoinSemilattice + Clone + Named + 'static> Accumulator for SemilatticeAccumulator<L> {
402    type State = L;
403    type Update = L;
404
405    fn accumulate(&self, state: &mut L, update: L) -> anyhow::Result<()> {
406        *state = state.join(&update);
407        Ok(())
408    }
409
410    fn reducer_spec(&self) -> Option<ReducerSpec> {
411        Some(ReducerSpec {
412            typehash: <SemilatticeReducer<L> as Named>::typehash(),
413            builder_params: None,
414        })
415    }
416}
417
418/// Create an accumulator for any JoinSemilattice type.
419///
420/// This is the primary way to create accumulators for lattice-based
421/// types like `Max<T>`, `Min<T>`, `GCounterUpdate`, `PNCounterUpdate`,
422/// and `WatermarkUpdate<T>`.
423///
424/// # Example
425///
426/// ```ignore
427/// use hyperactor::accum::{join_semilattice, Max};
428///
429/// let max_acc = join_semilattice::<Max<u64>>();
430/// ```
431pub fn join_semilattice<L: JoinSemilattice + Clone + Named + 'static>()
432-> impl Accumulator<State = L, Update = L> {
433    SemilatticeAccumulator::<L>(PhantomData)
434}
435
436/// Re-export Max from algebra.
437pub use algebra::Max;
438/// Re-export Min from algebra.
439pub use algebra::Min;
440
441/// Update from ranks for watermark accumulator using Last-Writer-Wins
442/// CRDT.
443///
444/// This is a proper CRDT that tracks the latest value from each rank
445/// using logical timestamps. When updates from the same rank are
446/// merged, the one with the higher timestamp wins. This allows ranks
447/// to report values that may decrease (e.g., during failure recovery)
448/// while maintaining proper commutativity and idempotence.
449///
450/// # CRDT Properties
451///
452/// - *Commutative*: Merge order doesn't matter (timestamps resolve
453///   conflicts)
454/// - *Idempotent*: Merging duplicate updates has no effect
455/// - *Convergent*: All replicas converge to the same state
456///
457/// # Watermark Semantics
458///
459/// The watermark is the minimum value across all ranks' *latest*
460/// reports. "Latest" is determined by logical timestamp, not arrival
461/// order.
462#[derive(Default, Debug, Clone, Serialize, Deserialize, typeuri::Named)]
463pub struct WatermarkUpdate<T>(algebra::LatticeMap<usize, algebra::LWW<T>>);
464
465impl<T: Ord + Clone> WatermarkUpdate<T> {
466    /// Get the watermark value (minimum of all ranks' current values).
467    ///
468    /// WatermarkUpdate is guaranteed to be initialized by the accumulator
469    /// before it is sent to the user.
470    pub fn get(&self) -> &T {
471        self.0
472            .iter()
473            .map(|(_, lww)| &lww.value)
474            .min()
475            .expect("watermark should have been initialized")
476    }
477
478    /// Get the current value for a specific rank, if present.
479    pub fn get_rank(&self, rank: usize) -> Option<&T> {
480        self.0.get(&rank).map(|lww| &lww.value)
481    }
482
483    /// Get the number of ranks currently tracked.
484    pub fn num_ranks(&self) -> usize {
485        self.0.len()
486    }
487}
488
489impl<T> From<(usize, T, u64)> for WatermarkUpdate<T> {
490    /// Create a watermark update from (rank, value, timestamp).
491    ///
492    /// The timestamp should be a logical clock value (Lamport clock, sequence
493    /// number, or monotonic counter) that increases with each update from
494    /// the same rank.
495    fn from((rank, value, timestamp): (usize, T, u64)) -> Self {
496        let mut map = algebra::LatticeMap::new();
497        // Use rank as replica ID - each rank is a unique writer
498        map.insert(rank, algebra::LWW::new(value, timestamp, rank as u64));
499        Self(map)
500    }
501}
502
503impl<T: Clone + PartialEq> JoinSemilattice for WatermarkUpdate<T> {
504    fn join(&self, other: &Self) -> Self {
505        WatermarkUpdate(self.0.join(&other.0))
506    }
507}
508
509/// State for a grow-only distributed counter (GCounter CRDT).
510///
511/// Each rank maintains its own count. The total value is the sum of
512/// all ranks' counts. Merge takes pointwise max.
513///
514/// # CRDT Properties
515///
516/// - *Commutative*: Merge order doesn't matter
517/// - *Associative*: Grouping doesn't matter
518/// - *Idempotent*: Merging duplicate updates has no effect
519/// - *Convergent*: All replicas converge to the same state
520#[derive(Default, Debug, Clone, Serialize, Deserialize, typeuri::Named)]
521pub struct GCounterUpdate(algebra::LatticeMap<usize, Max<u64>>);
522wirevalue::register_type!(GCounterUpdate);
523
524impl GCounterUpdate {
525    /// Total counter value (sum of all ranks' counts).
526    pub fn get(&self) -> u64 {
527        self.0.iter().map(|(_, max)| max.0).sum()
528    }
529
530    /// Get count for a specific rank.
531    pub fn get_rank(&self, rank: usize) -> Option<u64> {
532        self.0.get(&rank).map(|max| max.0)
533    }
534
535    /// Number of ranks that have contributed.
536    pub fn num_ranks(&self) -> usize {
537        self.0.len()
538    }
539}
540
541impl From<(usize, u64)> for GCounterUpdate {
542    /// Create a GCounter update from (rank, count).
543    fn from((rank, count): (usize, u64)) -> Self {
544        let mut map = algebra::LatticeMap::new();
545        map.insert(rank, Max(count));
546        Self(map)
547    }
548}
549
550impl JoinSemilattice for GCounterUpdate {
551    fn join(&self, other: &Self) -> Self {
552        GCounterUpdate(self.0.join(&other.0))
553    }
554}
555
556/// State for an increment/decrement distributed counter (PNCounter
557/// CRDT).
558///
559/// Internally uses two GCounters: one for increments (P), one for
560/// decrements (N). The value is P - N. Each is merged independently
561/// via pointwise max.
562#[derive(Default, Debug, Clone, Serialize, Deserialize, typeuri::Named)]
563pub struct PNCounterUpdate {
564    p: algebra::LatticeMap<usize, Max<u64>>,
565    n: algebra::LatticeMap<usize, Max<u64>>,
566}
567wirevalue::register_type!(PNCounterUpdate);
568
569impl PNCounterUpdate {
570    /// Counter value (sum of increments minus sum of decrements).
571    pub fn get(&self) -> i64 {
572        let p: u64 = self.p.iter().map(|(_, m)| m.0).sum();
573        let n: u64 = self.n.iter().map(|(_, m)| m.0).sum();
574        p as i64 - n as i64
575    }
576
577    /// Create an increment update for a rank.
578    pub fn inc(rank: usize, delta: u64) -> Self {
579        let mut p = algebra::LatticeMap::new();
580        p.insert(rank, Max(delta));
581        Self {
582            p,
583            n: algebra::LatticeMap::new(),
584        }
585    }
586
587    /// Create a decrement update for a rank.
588    pub fn dec(rank: usize, delta: u64) -> Self {
589        let mut n = algebra::LatticeMap::new();
590        n.insert(rank, Max(delta));
591        Self {
592            p: algebra::LatticeMap::new(),
593            n,
594        }
595    }
596
597    /// Number of ranks that have contributed increments.
598    pub fn num_inc_ranks(&self) -> usize {
599        self.p.len()
600    }
601
602    /// Number of ranks that have contributed decrements.
603    pub fn num_dec_ranks(&self) -> usize {
604        self.n.len()
605    }
606}
607
608impl JoinSemilattice for PNCounterUpdate {
609    fn join(&self, other: &Self) -> Self {
610        PNCounterUpdate {
611            p: self.p.join(&other.p),
612            n: self.n.join(&other.n),
613        }
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use std::fmt::Debug;
620
621    use maplit::hashmap;
622    use typeuri::Named;
623
624    use super::*;
625
626    fn serialize<T: Serialize + Named>(values: Vec<T>) -> Vec<wirevalue::Any> {
627        values
628            .into_iter()
629            .map(|n| wirevalue::Any::serialize(&n).unwrap())
630            .collect()
631    }
632
633    #[test]
634    fn test_comm_reducer_numeric() {
635        let u64_numbers_sum: Vec<_> = serialize(vec![1u64, 3u64, 1100u64]);
636        let i64_numbers_sum: Vec<_> = serialize(vec![-123i64, 33i64, 110i64]);
637        let u64_numbers_max: Vec<_> = serialize(vec![Max(1u64), Max(3u64), Max(1100u64)]);
638        let i64_numbers_max: Vec<_> = serialize(vec![Max(-123i64), Max(33i64), Max(110i64)]);
639        let u64_numbers_min: Vec<_> = serialize(vec![Min(1u64), Min(3u64), Min(1100u64)]);
640        let i64_numbers_min: Vec<_> = serialize(vec![Min(-123i64), Min(33i64), Min(110i64)]);
641        {
642            let typehash = <SemilatticeReducer<Max<u64>> as Named>::typehash();
643            assert_eq!(
644                resolve_reducer(typehash, None)
645                    .unwrap()
646                    .unwrap()
647                    .reduce_updates(u64_numbers_max.clone())
648                    .unwrap()
649                    .deserialized::<Max<u64>>()
650                    .unwrap(),
651                Max(1100u64),
652            );
653
654            let typehash = <SemilatticeReducer<Min<u64>> as Named>::typehash();
655            assert_eq!(
656                resolve_reducer(typehash, None)
657                    .unwrap()
658                    .unwrap()
659                    .reduce_updates(u64_numbers_min.clone())
660                    .unwrap()
661                    .deserialized::<Min<u64>>()
662                    .unwrap(),
663                Min(1u64),
664            );
665
666            let typehash = <SumReducer<u64> as Named>::typehash();
667            assert_eq!(
668                resolve_reducer(typehash, None)
669                    .unwrap()
670                    .unwrap()
671                    .reduce_updates(u64_numbers_sum)
672                    .unwrap()
673                    .deserialized::<u64>()
674                    .unwrap(),
675                1104u64,
676            );
677        }
678
679        {
680            let typehash = <SemilatticeReducer<Max<i64>> as Named>::typehash();
681            assert_eq!(
682                resolve_reducer(typehash, None)
683                    .unwrap()
684                    .unwrap()
685                    .reduce_updates(i64_numbers_max.clone())
686                    .unwrap()
687                    .deserialized::<Max<i64>>()
688                    .unwrap(),
689                Max(110i64),
690            );
691
692            let typehash = <SemilatticeReducer<Min<i64>> as Named>::typehash();
693            assert_eq!(
694                resolve_reducer(typehash, None)
695                    .unwrap()
696                    .unwrap()
697                    .reduce_updates(i64_numbers_min.clone())
698                    .unwrap()
699                    .deserialized::<Min<i64>>()
700                    .unwrap(),
701                Min(-123i64),
702            );
703
704            let typehash = <SumReducer<i64> as Named>::typehash();
705            assert_eq!(
706                resolve_reducer(typehash, None)
707                    .unwrap()
708                    .unwrap()
709                    .reduce_updates(i64_numbers_sum)
710                    .unwrap()
711                    .deserialized::<i64>()
712                    .unwrap(),
713                20i64,
714            );
715        }
716    }
717
718    #[test]
719    fn test_comm_reducer_watermark() {
720        // With LWW, we need timestamps. Assign in order of appearance.
721        let u64_updates = serialize::<WatermarkUpdate<u64>>(
722            vec![
723                (1, 1, 0),   // rank 1: value 1, ts 0
724                (0, 2, 1),   // rank 0: value 2, ts 1
725                (0, 1, 2),   // rank 0: value 1, ts 2 (later ts, wins over value 2)
726                (3, 35, 3),  // rank 3: value 35, ts 3
727                (0, 9, 4),   // rank 0: value 9, ts 4 (latest for rank 0)
728                (1, 10, 5),  // rank 1: value 10, ts 5 (latest for rank 1)
729                (3, 32, 6),  // rank 3: value 32, ts 6
730                (3, 0, 7),   // rank 3: value 0, ts 7
731                (3, 321, 8), // rank 3: value 321, ts 8 (latest for rank 3)
732            ]
733            .into_iter()
734            .map(|(k, v, ts)| WatermarkUpdate::from((k, v, ts)))
735            .collect(),
736        );
737        let i64_updates: Vec<_> = serialize::<WatermarkUpdate<i64>>(
738            vec![
739                (0, 2, 0),   // rank 0: value 2, ts 0
740                (1, 1, 1),   // rank 1: value 1, ts 1
741                (3, 35, 2),  // rank 3: value 35, ts 2
742                (0, 1, 3),   // rank 0: value 1, ts 3
743                (1, -10, 4), // rank 1: value -10, ts 4
744                (3, 32, 5),  // rank 3: value 32, ts 5
745                (3, 0, 6),   // rank 3: value 0, ts 6
746                (3, -99, 7), // rank 3: value -99, ts 7 (latest for rank 3)
747                (0, -9, 8),  // rank 0: value -9, ts 8 (latest for rank 0)
748            ]
749            .into_iter()
750            .map(WatermarkUpdate::from)
751            .collect(),
752        );
753
754        fn verify<T: Ord + Clone + PartialEq + DeserializeOwned + Debug + Named>(
755            updates: Vec<wirevalue::Any>,
756            expected: HashMap<usize, T>,
757        ) {
758            let typehash = <SemilatticeReducer<WatermarkUpdate<T>> as Named>::typehash();
759            let result = resolve_reducer(typehash, None)
760                .unwrap()
761                .unwrap()
762                .reduce_updates(updates)
763                .unwrap()
764                .deserialized::<WatermarkUpdate<T>>()
765                .unwrap();
766
767            // Check each expected rank value
768            for (rank, expected_value) in &expected {
769                assert_eq!(
770                    result.get_rank(*rank).unwrap(),
771                    expected_value,
772                    "Mismatch for rank {rank}"
773                );
774            }
775            // Also verify no extra ranks
776            assert_eq!(result.num_ranks(), expected.len());
777        }
778
779        verify::<i64>(
780            i64_updates,
781            hashmap! {
782                0 => -9,   // latest ts for rank 0
783                1 => -10,  // latest ts for rank 1
784                3 => -99,  // latest ts for rank 3
785            },
786        );
787
788        verify::<u64>(
789            u64_updates,
790            hashmap! {
791                0 => 9,    // latest ts for rank 0
792                1 => 10,   // latest ts for rank 1
793                3 => 321,  // latest ts for rank 3
794            },
795        );
796    }
797
798    #[test]
799    fn test_accum_reducer_numeric() {
800        assert_eq!(
801            sum::<u64>().reducer_spec().unwrap().typehash,
802            <SumReducer::<u64> as Named>::typehash(),
803        );
804        assert_eq!(
805            sum::<i64>().reducer_spec().unwrap().typehash,
806            <SumReducer::<i64> as Named>::typehash(),
807        );
808
809        assert_eq!(
810            join_semilattice::<Min<u64>>()
811                .reducer_spec()
812                .unwrap()
813                .typehash,
814            <SemilatticeReducer<Min<u64>> as Named>::typehash(),
815        );
816        assert_eq!(
817            join_semilattice::<Min<i64>>()
818                .reducer_spec()
819                .unwrap()
820                .typehash,
821            <SemilatticeReducer<Min<i64>> as Named>::typehash(),
822        );
823
824        assert_eq!(
825            join_semilattice::<Max<u64>>()
826                .reducer_spec()
827                .unwrap()
828                .typehash,
829            <SemilatticeReducer<Max<u64>> as Named>::typehash(),
830        );
831        assert_eq!(
832            join_semilattice::<Max<i64>>()
833                .reducer_spec()
834                .unwrap()
835                .typehash,
836            <SemilatticeReducer<Max<i64>> as Named>::typehash(),
837        );
838        assert_eq!(
839            unit().reducer_spec().unwrap().typehash,
840            <UnitReducer as Named>::typehash(),
841        );
842    }
843
844    #[test]
845    fn test_comm_reducer_unit() {
846        let unit_updates = serialize(vec![(), (), ()]);
847        let typehash = <UnitReducer as Named>::typehash();
848        assert_eq!(
849            resolve_reducer(typehash, None)
850                .unwrap()
851                .unwrap()
852                .reduce_updates(unit_updates)
853                .unwrap()
854                .deserialized::<()>()
855                .unwrap(),
856            (),
857        );
858    }
859
860    #[test]
861    fn test_accum_reducer_watermark() {
862        fn verify<T: Clone + PartialEq + Named + 'static>() {
863            assert_eq!(
864                join_semilattice::<WatermarkUpdate<T>>()
865                    .reducer_spec()
866                    .unwrap()
867                    .typehash,
868                <SemilatticeReducer<WatermarkUpdate<T>> as Named>::typehash(),
869            );
870        }
871        verify::<u64>();
872        verify::<i64>();
873    }
874
875    #[test]
876    fn test_watermark_accumulator() {
877        let accumulator = join_semilattice::<WatermarkUpdate<u64>>();
878        let ranks_values_expectations = [
879            // send in descending order (with timestamps 0, 1, 2)
880            (0, 1003, 0, 1003),
881            (1, 1002, 1, 1002),
882            (2, 1001, 2, 1001),
883            // send in ascending order (timestamps 3, 4, 5)
884            (0, 100, 3, 100),
885            (1, 101, 4, 100),
886            (2, 102, 5, 100),
887            // send same values (timestamps 6, 7, 8)
888            (0, 100, 6, 100),
889            (1, 101, 7, 100),
890            (2, 102, 8, 100),
891            // shuffle rank 0 to be largest, and make rank 1 smallest (timestamps 9, 10, 11)
892            (0, 1000, 9, 101),
893            // shuffle rank 1 to be largest, and make rank 2 smallest
894            (1, 1100, 10, 102),
895            // shuffle rank 2 to be largest, and make rank 0 smallest
896            (2, 1200, 11, 1000),
897            // Increase their value, but do not change their order (timestamps 12, 13, 14)
898            (0, 1001, 12, 1001),
899            (1, 1101, 13, 1001),
900            (2, 1201, 14, 1001),
901            // decrease their values (timestamps 15, 16, 17)
902            (2, 102, 15, 102),
903            (1, 101, 16, 101),
904            (0, 100, 17, 100),
905        ];
906        let mut state = WatermarkUpdate::default();
907        for (rank, value, ts, expected) in ranks_values_expectations {
908            accumulator
909                .accumulate(&mut state, WatermarkUpdate::from((rank, value, ts)))
910                .unwrap();
911            assert_eq!(
912                state.get(),
913                &expected,
914                "rank is {rank}; value is {value}; ts is {ts}"
915            );
916        }
917    }
918
919    #[test]
920    fn test_comm_reducer_gcounter() {
921        // Updates from different ranks
922        let updates = serialize::<GCounterUpdate>(vec![
923            GCounterUpdate::from((0, 10)),
924            GCounterUpdate::from((1, 20)),
925            GCounterUpdate::from((0, 15)), // rank 0 increases to 15
926            GCounterUpdate::from((2, 5)),
927            GCounterUpdate::from((1, 25)), // rank 1 increases to 25
928        ]);
929
930        let typehash = <SemilatticeReducer<GCounterUpdate> as Named>::typehash();
931        let result = resolve_reducer(typehash, None)
932            .unwrap()
933            .unwrap()
934            .reduce_updates(updates)
935            .unwrap()
936            .deserialized::<GCounterUpdate>()
937            .unwrap();
938
939        // Each rank should have its max value
940        assert_eq!(result.get_rank(0), Some(15));
941        assert_eq!(result.get_rank(1), Some(25));
942        assert_eq!(result.get_rank(2), Some(5));
943        assert_eq!(result.num_ranks(), 3);
944        // Total is sum of max values: 15 + 25 + 5 = 45
945        assert_eq!(result.get(), 45);
946    }
947
948    #[test]
949    fn test_accum_reducer_gcounter() {
950        assert_eq!(
951            join_semilattice::<GCounterUpdate>()
952                .reducer_spec()
953                .unwrap()
954                .typehash,
955            <SemilatticeReducer<GCounterUpdate> as Named>::typehash(),
956        );
957    }
958
959    #[test]
960    fn test_gcounter_accumulator() {
961        let accumulator = join_semilattice::<GCounterUpdate>();
962        // (rank, count, expected_total)
963        let ranks_counts_expectations: [(usize, u64, u64); 17] = [
964            // initialize all 3 ranks in descending order
965            (0, 1000, 1000),
966            (1, 100, 1100),
967            (2, 10, 1110),
968            // increase in ascending order
969            (2, 20, 1120),
970            (1, 200, 1220),
971            (0, 2000, 2220),
972            // same values (idempotent - no change)
973            (0, 2000, 2220),
974            (1, 200, 2220),
975            (2, 20, 2220),
976            // lower values (ignored - max wins)
977            (0, 1, 2220),
978            (1, 1, 2220),
979            (2, 1, 2220),
980            // shuffle which rank has max: make rank 2 largest
981            (2, 5000, 7200), // 2000 + 200 + 5000
982            // make rank 1 largest
983            (1, 6000, 13000), // 2000 + 6000 + 5000
984            // make rank 0 largest again
985            (0, 10000, 21000), // 10000 + 6000 + 5000
986            // all ranks increase together
987            (0, 10001, 21001),
988            (1, 6001, 21002),
989        ];
990        let mut state = GCounterUpdate::default();
991        for (rank, count, expected) in ranks_counts_expectations {
992            accumulator
993                .accumulate(&mut state, GCounterUpdate::from((rank, count)))
994                .unwrap();
995            assert_eq!(state.get(), expected, "rank is {rank}; count is {count}");
996        }
997        // Verify final per-rank values
998        assert_eq!(state.get_rank(0), Some(10001));
999        assert_eq!(state.get_rank(1), Some(6001));
1000        assert_eq!(state.get_rank(2), Some(5000));
1001        assert_eq!(state.get_rank(3), None);
1002        assert_eq!(state.num_ranks(), 3);
1003    }
1004
1005    #[test]
1006    fn test_gcounter_commutativity() {
1007        // Verify that order of accumulation doesn't matter
1008        let updates = [
1009            GCounterUpdate::from((0, 10)),
1010            GCounterUpdate::from((1, 20)),
1011            GCounterUpdate::from((0, 15)),
1012            GCounterUpdate::from((2, 5)),
1013            GCounterUpdate::from((1, 25)),
1014        ];
1015
1016        // Forward order
1017        let accumulator = join_semilattice::<GCounterUpdate>();
1018        let mut forward = GCounterUpdate::default();
1019        for update in updates.iter().cloned() {
1020            accumulator.accumulate(&mut forward, update).unwrap();
1021        }
1022
1023        // Reverse order
1024        let mut reverse = GCounterUpdate::default();
1025        for update in updates.iter().rev().cloned() {
1026            accumulator.accumulate(&mut reverse, update).unwrap();
1027        }
1028
1029        assert_eq!(forward.get(), reverse.get());
1030        assert_eq!(forward.get(), 45); // 15 + 25 + 5
1031        assert_eq!(forward.get_rank(0), reverse.get_rank(0));
1032        assert_eq!(forward.get_rank(1), reverse.get_rank(1));
1033        assert_eq!(forward.get_rank(2), reverse.get_rank(2));
1034    }
1035
1036    #[test]
1037    fn test_comm_reducer_pncounter() {
1038        // Updates from different ranks with increments and decrements
1039        let updates = serialize::<PNCounterUpdate>(vec![
1040            PNCounterUpdate::inc(0, 10),
1041            PNCounterUpdate::inc(1, 20),
1042            PNCounterUpdate::dec(0, 5),
1043            PNCounterUpdate::inc(0, 15), // rank 0 inc increases to 15
1044            PNCounterUpdate::dec(1, 8),
1045            PNCounterUpdate::dec(0, 7), // rank 0 dec increases to 7
1046        ]);
1047
1048        let typehash = <SemilatticeReducer<PNCounterUpdate> as Named>::typehash();
1049        let result = resolve_reducer(typehash, None)
1050            .unwrap()
1051            .unwrap()
1052            .reduce_updates(updates)
1053            .unwrap()
1054            .deserialized::<PNCounterUpdate>()
1055            .unwrap();
1056
1057        // Each rank should have its max values for both inc and dec
1058        // rank 0: inc=15, dec=7 -> contribution = 15-7 = 8
1059        // rank 1: inc=20, dec=8 -> contribution = 20-8 = 12
1060        // Total: 8 + 12 = 20
1061        assert_eq!(result.get(), 20);
1062        assert_eq!(result.num_inc_ranks(), 2);
1063        assert_eq!(result.num_dec_ranks(), 2);
1064    }
1065
1066    #[test]
1067    fn test_accum_reducer_pncounter() {
1068        assert_eq!(
1069            join_semilattice::<PNCounterUpdate>()
1070                .reducer_spec()
1071                .unwrap()
1072                .typehash,
1073            <SemilatticeReducer<PNCounterUpdate> as Named>::typehash(),
1074        );
1075    }
1076
1077    #[test]
1078    fn test_pncounter_accumulator() {
1079        let accumulator = join_semilattice::<PNCounterUpdate>();
1080        // Helper to make updates clearer
1081        #[derive(Clone, Copy, Debug)]
1082        enum Op {
1083            Inc(usize, u64),
1084            Dec(usize, u64),
1085        }
1086        use Op::*;
1087
1088        // (operation, expected_total)
1089        // State tracked: p0, p1, p2 (increments), n0, n1, n2 (decrements)
1090        // Total = (p0 + p1 + p2) - (n0 + n1 + n2)
1091        let ops_expectations = [
1092            // initialize all 3 ranks with increments
1093            (Inc(0, 100), 100), // p: 100,0,0 n: 0,0,0 = 100
1094            (Inc(1, 50), 150),  // p: 100,50,0 n: 0,0,0 = 150
1095            (Inc(2, 25), 175),  // p: 100,50,25 n: 0,0,0 = 175
1096            // add decrements
1097            (Dec(0, 10), 165), // p: 100,50,25 n: 10,0,0 = 175-10 = 165
1098            (Dec(1, 5), 160),  // p: 100,50,25 n: 10,5,0 = 175-15 = 160
1099            (Dec(2, 2), 158),  // p: 100,50,25 n: 10,5,2 = 175-17 = 158
1100            // increase increments
1101            (Inc(0, 200), 258), // p: 200,50,25 n: 10,5,2 = 275-17 = 258
1102            (Inc(1, 100), 308), // p: 200,100,25 n: 10,5,2 = 325-17 = 308
1103            (Inc(2, 50), 333),  // p: 200,100,50 n: 10,5,2 = 350-17 = 333
1104            // increase decrements
1105            (Dec(0, 20), 323), // p: 200,100,50 n: 20,5,2 = 350-27 = 323
1106            (Dec(1, 15), 313), // p: 200,100,50 n: 20,15,2 = 350-37 = 313
1107            (Dec(2, 5), 310),  // p: 200,100,50 n: 20,15,5 = 350-40 = 310
1108            // duplicate updates (idempotent - no change)
1109            (Inc(0, 200), 310),
1110            (Dec(1, 15), 310),
1111            // lower values (ignored - max wins)
1112            (Inc(0, 1), 310),
1113            (Dec(0, 1), 310),
1114            // make decrements larger than increments for some ranks
1115            (Dec(2, 60), 255),  // p: 200,100,50 n: 20,15,60 = 350-95 = 255
1116            (Dec(1, 120), 150), // p: 200,100,50 n: 20,120,60 = 350-200 = 150
1117            // rank 1 now contributes negatively: 100 - 120 = -20
1118            (Inc(2, 60), 160), // p: 200,100,60 n: 20,120,60 = 360-200 = 160
1119            // shuffle: make rank 0 contribute most
1120            (Inc(0, 1000), 960), // p: 1000,100,60 n: 20,120,60 = 1160-200 = 960
1121            (Dec(2, 100), 920),  // p: 1000,100,60 n: 20,120,100 = 1160-240 = 920
1122        ];
1123
1124        let mut state = PNCounterUpdate::default();
1125        for (i, (op, expected)) in ops_expectations.iter().enumerate() {
1126            let update = match op {
1127                Inc(rank, delta) => PNCounterUpdate::inc(*rank, *delta),
1128                Dec(rank, delta) => PNCounterUpdate::dec(*rank, *delta),
1129            };
1130            accumulator.accumulate(&mut state, update).unwrap();
1131            assert_eq!(state.get(), *expected, "step {i}: {op:?}");
1132        }
1133
1134        // Verify final state
1135        assert_eq!(state.num_inc_ranks(), 3);
1136        assert_eq!(state.num_dec_ranks(), 3);
1137    }
1138
1139    #[test]
1140    fn test_pncounter_commutativity() {
1141        // Verify that order of accumulation doesn't matter
1142        let updates = [
1143            PNCounterUpdate::inc(0, 10),
1144            PNCounterUpdate::inc(1, 20),
1145            PNCounterUpdate::dec(0, 5),
1146            PNCounterUpdate::inc(0, 15),
1147            PNCounterUpdate::dec(1, 8),
1148            PNCounterUpdate::dec(2, 3),
1149            PNCounterUpdate::inc(2, 12),
1150        ];
1151
1152        // Forward order
1153        let accumulator = join_semilattice::<PNCounterUpdate>();
1154        let mut forward = PNCounterUpdate::default();
1155        for update in updates.iter().cloned() {
1156            accumulator.accumulate(&mut forward, update).unwrap();
1157        }
1158
1159        // Reverse order
1160        let mut reverse = PNCounterUpdate::default();
1161        for update in updates.iter().rev().cloned() {
1162            accumulator.accumulate(&mut reverse, update).unwrap();
1163        }
1164
1165        assert_eq!(forward.get(), reverse.get());
1166        assert_eq!(forward.get(), 31); // (15 + 20 + 12) - (5 + 8 + 3) = 47 - 16 = 31
1167        assert_eq!(forward.num_inc_ranks(), reverse.num_inc_ranks());
1168        assert_eq!(forward.num_dec_ranks(), reverse.num_dec_ranks());
1169    }
1170}