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