Skip to main content

reifydb_sub_flow/operator/window/
accumulator.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::ops::{Add, Rem, Sub};
5
6use reifydb_core::window::{
7	accumulator::{
8		WindowAccumulator,
9		invertible::Multiset,
10		sealing::{SealingEndpoint, SealingMax, SealingMin},
11	},
12	span::Slot,
13};
14use reifydb_engine::flow::aggregate::SlotKind;
15use reifydb_value::{
16	reifydb_assertions,
17	value::{
18		Value,
19		datetime::DateTime,
20		duration::Duration,
21		number::safe::{add::SafeAdd, div::SafeDiv, sub::SafeSub},
22	},
23};
24use serde::{Deserialize, Serialize};
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
27pub struct WindowSlotKey {
28	pub timestamp: DateTime,
29	pub seq: u64,
30}
31
32impl WindowSlotKey {
33	pub fn new(timestamp: DateTime, seq: u64) -> Self {
34		Self {
35			timestamp,
36			seq,
37		}
38	}
39}
40
41impl Add<Duration> for WindowSlotKey {
42	type Output = WindowSlotKey;
43	fn add(self, duration: Duration) -> WindowSlotKey {
44		WindowSlotKey {
45			timestamp: self.timestamp + duration,
46			seq: self.seq,
47		}
48	}
49}
50
51impl Sub<WindowSlotKey> for WindowSlotKey {
52	type Output = Duration;
53	fn sub(self, other: WindowSlotKey) -> Duration {
54		self.timestamp - other.timestamp
55	}
56}
57
58impl Sub<Duration> for WindowSlotKey {
59	type Output = WindowSlotKey;
60	fn sub(self, duration: Duration) -> WindowSlotKey {
61		WindowSlotKey {
62			timestamp: self.timestamp - duration,
63			seq: self.seq,
64		}
65	}
66}
67
68impl Rem<Duration> for WindowSlotKey {
69	type Output = Duration;
70	fn rem(self, duration: Duration) -> Duration {
71		self.timestamp % duration
72	}
73}
74
75impl Slot for WindowSlotKey {
76	type Duration = Duration;
77
78	fn order_key(&self) -> u64 {
79		self.timestamp.to_nanos()
80	}
81}
82
83#[derive(Clone, Debug, Serialize, Deserialize)]
84pub enum AggregateSlot {
85	Count {
86		n: i64,
87		count_star: bool,
88	},
89	Sum {
90		accumulator: Value,
91		n: u64,
92	},
93	Avg {
94		sum: Value,
95		n: i64,
96	},
97	Min(Multiset<Value>),
98	Max(Multiset<Value>),
99	MinSealed(SealingMin<WindowSlotKey, Value>),
100	MaxSealed(SealingMax<WindowSlotKey, Value>),
101	First(SealingEndpoint<WindowSlotKey, Value>),
102	Last(SealingEndpoint<WindowSlotKey, Value>),
103}
104
105fn endpoint(lateness: Option<Duration>) -> SealingEndpoint<WindowSlotKey, Value> {
106	match lateness {
107		Some(l) => SealingEndpoint::with_lateness(l),
108		None => SealingEndpoint::default(),
109	}
110}
111
112impl AggregateSlot {
113	fn empty(kind: SlotKind, lateness: Option<Duration>) -> Self {
114		match kind {
115			SlotKind::Count {
116				count_star,
117			} => AggregateSlot::Count {
118				n: 0,
119				count_star,
120			},
121			SlotKind::Sum => AggregateSlot::Sum {
122				accumulator: Value::none(),
123				n: 0,
124			},
125			SlotKind::Avg => AggregateSlot::Avg {
126				sum: Value::none(),
127				n: 0,
128			},
129			SlotKind::Min => match lateness {
130				Some(l) => AggregateSlot::MinSealed(SealingMin::with_lateness(l)),
131				None => AggregateSlot::Min(Multiset::default()),
132			},
133			SlotKind::Max => match lateness {
134				Some(l) => AggregateSlot::MaxSealed(SealingMax::with_lateness(l)),
135				None => AggregateSlot::Max(Multiset::default()),
136			},
137			SlotKind::First => AggregateSlot::First(endpoint(lateness)),
138			SlotKind::Last => AggregateSlot::Last(endpoint(lateness)),
139		}
140	}
141
142	fn add(&mut self, coord: WindowSlotKey, input: &Option<Value>) {
143		match self {
144			AggregateSlot::Count {
145				n,
146				count_star,
147			} => {
148				if *count_star || present(input).is_some() {
149					*n += 1;
150				}
151			}
152			AggregateSlot::Sum {
153				accumulator,
154				n,
155			} => {
156				if let Some(v) = present(input) {
157					*accumulator = if *n == 0 {
158						widen(v)
159					} else {
160						accumulator.checked_add(v).unwrap_or_else(Value::none)
161					};
162					*n += 1;
163				}
164			}
165			AggregateSlot::Avg {
166				sum,
167				n,
168			} => {
169				if let Some(v) = present(input) {
170					*sum = if *n == 0 {
171						widen(v)
172					} else {
173						sum.checked_add(v).unwrap_or_else(Value::none)
174					};
175					*n += 1;
176				}
177			}
178			AggregateSlot::Min(set) | AggregateSlot::Max(set) => {
179				if let Some(v) = present(input) {
180					set.add(v.clone());
181				}
182			}
183			AggregateSlot::MinSealed(s) => {
184				if let Some(v) = present(input) {
185					s.add(&(coord, v.clone()));
186				}
187			}
188			AggregateSlot::MaxSealed(s) => {
189				if let Some(v) = present(input) {
190					s.add(&(coord, v.clone()));
191				}
192			}
193			AggregateSlot::First(e) | AggregateSlot::Last(e) => {
194				if let Some(v) = present(input) {
195					e.add(&(coord, v.clone()));
196				}
197			}
198		}
199	}
200
201	fn remove(&mut self, coord: WindowSlotKey, input: &Option<Value>) {
202		match self {
203			AggregateSlot::Count {
204				n,
205				count_star,
206			} => {
207				if *count_star || present(input).is_some() {
208					*n -= 1;
209				}
210			}
211			AggregateSlot::Sum {
212				accumulator,
213				n,
214			} => {
215				if let Some(v) = present(input) {
216					*n -= 1;
217					*accumulator = if *n == 0 {
218						Value::none()
219					} else {
220						accumulator.checked_sub(v).unwrap_or_else(Value::none)
221					};
222				}
223			}
224			AggregateSlot::Avg {
225				sum,
226				n,
227			} => {
228				if let Some(v) = present(input) {
229					*n -= 1;
230					*sum = if *n == 0 {
231						Value::none()
232					} else {
233						sum.checked_sub(v).unwrap_or_else(Value::none)
234					};
235				}
236			}
237			AggregateSlot::Min(set) | AggregateSlot::Max(set) => {
238				if let Some(v) = present(input) {
239					set.remove(v);
240				}
241			}
242			AggregateSlot::MinSealed(s) => {
243				if let Some(v) = present(input) {
244					s.remove(&(coord, v.clone()));
245				}
246			}
247			AggregateSlot::MaxSealed(s) => {
248				if let Some(v) = present(input) {
249					s.remove(&(coord, v.clone()));
250				}
251			}
252			AggregateSlot::First(e) | AggregateSlot::Last(e) => {
253				if let Some(v) = present(input) {
254					e.remove(&(coord, v.clone()));
255				}
256			}
257		}
258	}
259
260	fn merge(&mut self, other: &AggregateSlot) {
261		match (self, other) {
262			(
263				AggregateSlot::Count {
264					n,
265					..
266				},
267				AggregateSlot::Count {
268					n: on,
269					..
270				},
271			) => *n += *on,
272			(
273				AggregateSlot::Sum {
274					accumulator,
275					n,
276				},
277				AggregateSlot::Sum {
278					accumulator: other_accumulator,
279					n: on,
280				},
281			) => {
282				if *on > 0 {
283					*accumulator = if *n == 0 {
284						other_accumulator.clone()
285					} else {
286						accumulator.checked_add(other_accumulator).unwrap_or_else(Value::none)
287					};
288					*n += *on;
289				}
290			}
291			(
292				AggregateSlot::Avg {
293					sum,
294					n,
295				},
296				AggregateSlot::Avg {
297					sum: osum,
298					n: on,
299				},
300			) => {
301				if *on > 0 {
302					*sum = if *n == 0 {
303						osum.clone()
304					} else {
305						sum.checked_add(osum).unwrap_or_else(Value::none)
306					};
307					*n += *on;
308				}
309			}
310			(
311				AggregateSlot::Min(set) | AggregateSlot::Max(set),
312				AggregateSlot::Min(oset) | AggregateSlot::Max(oset),
313			) => set.merge(oset),
314			(AggregateSlot::MinSealed(a), AggregateSlot::MinSealed(b)) => a.absorb(b),
315			(AggregateSlot::MaxSealed(a), AggregateSlot::MaxSealed(b)) => a.absorb(b),
316			(
317				AggregateSlot::First(a) | AggregateSlot::Last(a),
318				AggregateSlot::First(b) | AggregateSlot::Last(b),
319			) => a.absorb(b),
320			_ => {}
321		}
322	}
323
324	fn finalize(&self) -> Value {
325		match self {
326			AggregateSlot::Count {
327				n,
328				..
329			} => Value::Int8(*n),
330			AggregateSlot::Sum {
331				accumulator,
332				..
333			} => accumulator.clone(),
334			AggregateSlot::Avg {
335				sum,
336				n,
337			} => sum.checked_div(&Value::Int8(*n)).unwrap_or_else(Value::none),
338			AggregateSlot::Min(set) => set.min().cloned().unwrap_or_else(Value::none),
339			AggregateSlot::Max(set) => set.max().cloned().unwrap_or_else(Value::none),
340			AggregateSlot::MinSealed(s) => s.min().unwrap_or_else(Value::none),
341			AggregateSlot::MaxSealed(s) => s.max().unwrap_or_else(Value::none),
342			AggregateSlot::First(e) => e.open().cloned().unwrap_or_else(Value::none),
343			AggregateSlot::Last(e) => e.close().cloned().unwrap_or_else(Value::none),
344		}
345	}
346
347	fn is_empty(&self) -> bool {
348		match self {
349			AggregateSlot::Count {
350				n,
351				..
352			} => *n == 0,
353			AggregateSlot::Sum {
354				n,
355				..
356			} => *n == 0,
357			AggregateSlot::Avg {
358				n,
359				..
360			} => *n == 0,
361			AggregateSlot::Min(set) | AggregateSlot::Max(set) => set.is_empty(),
362			AggregateSlot::MinSealed(s) => s.is_empty(),
363			AggregateSlot::MaxSealed(s) => s.is_empty(),
364			AggregateSlot::First(e) | AggregateSlot::Last(e) => e.is_empty(),
365		}
366	}
367}
368
369#[derive(Clone, Debug, Default, Serialize, Deserialize)]
370pub struct RowAccumulator {
371	slots: Vec<AggregateSlot>,
372}
373
374impl RowAccumulator {
375	pub fn new(kinds: &[SlotKind], lateness: Option<Duration>) -> Self {
376		Self {
377			slots: kinds.iter().map(|k| AggregateSlot::empty(*k, lateness)).collect(),
378		}
379	}
380
381	pub fn merge(&mut self, other: &RowAccumulator) {
382		for (slot, other_slot) in self.slots.iter_mut().zip(other.slots.iter()) {
383			slot.merge(other_slot);
384		}
385	}
386}
387
388impl WindowAccumulator for RowAccumulator {
389	type Contribution = (WindowSlotKey, Vec<Option<Value>>);
390	type Output = Vec<Value>;
391
392	fn add(&mut self, contribution: &Self::Contribution) {
393		let (coord, values) = contribution;
394		reifydb_assertions! {
395			assert!(
396				values.len() == self.slots.len(),
397				"RowAccumulator contribution length {} != slot count {}; the zip below truncates to the \
398				 shorter side, so a default-constructed zero-slot accumulator (e.g. routed through an engine \
399				 that builds empties via Default instead of new(kinds)) would silently swallow every \
400				 contribution",
401				values.len(),
402				self.slots.len()
403			);
404		}
405		for (slot, input) in self.slots.iter_mut().zip(values.iter()) {
406			slot.add(*coord, input);
407		}
408	}
409
410	fn remove(&mut self, contribution: &Self::Contribution) {
411		let (coord, values) = contribution;
412		reifydb_assertions! {
413			assert!(
414				values.len() == self.slots.len(),
415				"RowAccumulator contribution length {} != slot count {}; the zip below truncates to the \
416				 shorter side, so a default-constructed zero-slot accumulator (e.g. routed through an engine \
417				 that builds empties via Default instead of new(kinds)) would silently swallow every \
418				 retraction",
419				values.len(),
420				self.slots.len()
421			);
422		}
423		for (slot, input) in self.slots.iter_mut().zip(values.iter()) {
424			slot.remove(*coord, input);
425		}
426	}
427
428	fn finalize(&self) -> Option<Self::Output> {
429		if self.is_empty() {
430			return None;
431		}
432		Some(self.slots.iter().map(AggregateSlot::finalize).collect())
433	}
434
435	fn is_empty(&self) -> bool {
436		self.slots.iter().all(AggregateSlot::is_empty)
437	}
438}
439
440#[derive(Clone, Debug, Default, Serialize, Deserialize)]
441pub struct StampedAccumulator {
442	inner: RowAccumulator,
443	ts: u64,
444}
445
446impl StampedAccumulator {
447	pub fn new(kinds: &[SlotKind], lateness: Option<Duration>) -> Self {
448		Self {
449			inner: RowAccumulator::new(kinds, lateness),
450			ts: 0,
451		}
452	}
453
454	pub fn inner(&self) -> &RowAccumulator {
455		&self.inner
456	}
457}
458
459impl WindowAccumulator for StampedAccumulator {
460	type Contribution = ((WindowSlotKey, Vec<Option<Value>>), u64);
461	type Output = Vec<Value>;
462
463	fn add(&mut self, contribution: &Self::Contribution) {
464		self.inner.add(&contribution.0);
465		self.ts = self.ts.max(contribution.1);
466	}
467
468	fn remove(&mut self, contribution: &Self::Contribution) {
469		self.inner.remove(&contribution.0);
470	}
471
472	fn finalize(&self) -> Option<Self::Output> {
473		self.inner.finalize()
474	}
475
476	fn is_empty(&self) -> bool {
477		self.inner.is_empty()
478	}
479
480	fn stamp(&self) -> Option<u64> {
481		if self.inner.is_empty() {
482			None
483		} else {
484			Some(self.ts)
485		}
486	}
487}
488
489fn present(input: &Option<Value>) -> Option<&Value> {
490	match input {
491		Some(v) if !matches!(v, Value::None { .. }) => Some(v),
492		_ => None,
493	}
494}
495
496fn widen(v: &Value) -> Value {
497	v.checked_add(v).and_then(|two| two.checked_sub(v)).unwrap_or_else(|| v.clone())
498}
499
500#[cfg(test)]
501mod tests {
502	use super::*;
503
504	fn i4(v: i32) -> Option<Value> {
505		Some(Value::Int4(v))
506	}
507
508	fn accumulator(kinds: &[SlotKind]) -> RowAccumulator {
509		RowAccumulator::new(kinds, None)
510	}
511
512	fn at(seq: u64) -> WindowSlotKey {
513		WindowSlotKey::new(DateTime::default(), seq)
514	}
515
516	fn coord(secs: u64) -> WindowSlotKey {
517		WindowSlotKey::new(DateTime::from_timestamp(secs as i64).unwrap(), secs)
518	}
519
520	fn add(a: &mut RowAccumulator, seq: u64, values: Vec<Option<Value>>) {
521		a.add(&(at(seq), values));
522	}
523
524	fn remove(a: &mut RowAccumulator, seq: u64, values: Vec<Option<Value>>) {
525		a.remove(&(at(seq), values));
526	}
527
528	#[test]
529	fn count_counts_rows_and_resets_on_empty() {
530		let mut a = accumulator(&[SlotKind::Count {
531			count_star: true,
532		}]);
533		assert!(a.is_empty());
534		add(&mut a, 0, vec![None]);
535		add(&mut a, 1, vec![None]);
536		assert_eq!(a.finalize(), Some(vec![Value::Int8(2)]));
537		remove(&mut a, 0, vec![None]);
538		remove(&mut a, 1, vec![None]);
539		assert!(a.is_empty());
540		assert_eq!(a.finalize(), None);
541	}
542
543	#[test]
544	fn count_col_ignores_none() {
545		let mut a = accumulator(&[SlotKind::Count {
546			count_star: false,
547		}]);
548		add(&mut a, 0, vec![i4(5)]);
549		add(&mut a, 1, vec![Some(Value::none())]); // none -> not counted
550		add(&mut a, 2, vec![i4(7)]);
551		assert_eq!(a.finalize(), Some(vec![Value::Int8(2)]));
552	}
553
554	#[test]
555	fn sum_has_stable_widened_type_and_inverts() {
556		let mut a = accumulator(&[SlotKind::Sum]);
557		add(&mut a, 0, vec![i4(5)]);
558		// single contribution is already widened to Int16
559		assert_eq!(a.finalize(), Some(vec![Value::Int16(5)]));
560		add(&mut a, 1, vec![i4(3)]);
561		assert_eq!(a.finalize(), Some(vec![Value::Int16(8)]));
562		// retraction inverts exactly
563		remove(&mut a, 1, vec![i4(3)]);
564		assert_eq!(a.finalize(), Some(vec![Value::Int16(5)]));
565	}
566
567	#[test]
568	fn sum_skips_none() {
569		let mut a = accumulator(&[SlotKind::Sum]);
570		add(&mut a, 0, vec![i4(10)]);
571		add(&mut a, 1, vec![Some(Value::none())]);
572		assert_eq!(a.finalize(), Some(vec![Value::Int16(10)]));
573	}
574
575	#[test]
576	fn avg_is_decimal_and_inverts() {
577		let mut a = accumulator(&[SlotKind::Avg]);
578		add(&mut a, 0, vec![i4(2)]);
579		add(&mut a, 1, vec![i4(3)]);
580		// (2 + 3) / 2 = 2.5 as Decimal
581		let got = a.finalize().unwrap();
582		assert!(matches!(got[0], Value::Decimal(_)), "avg is Decimal, got {:?}", got[0]);
583		let expected = Value::Int16(5).checked_div(&Value::Int8(2)).unwrap();
584		assert_eq!(got[0], expected);
585		remove(&mut a, 1, vec![i4(3)]);
586		assert_eq!(a.finalize().unwrap()[0], Value::Int16(2).checked_div(&Value::Int8(1)).unwrap());
587	}
588
589	#[test]
590	fn min_max_via_multiset_invert() {
591		let mut a = accumulator(&[SlotKind::Min, SlotKind::Max]);
592		for (seq, v) in [5, 8, 6].into_iter().enumerate() {
593			add(&mut a, seq as u64, vec![i4(v), i4(v)]);
594		}
595		assert_eq!(a.finalize(), Some(vec![Value::Int4(5), Value::Int4(8)]));
596		// remove the current min (5) -> min becomes 6, max stays 8
597		remove(&mut a, 0, vec![i4(5), i4(5)]);
598		assert_eq!(a.finalize(), Some(vec![Value::Int4(6), Value::Int4(8)]));
599	}
600
601	#[test]
602	fn multi_slot_row_add_remove_inverse() {
603		let kinds = [
604			SlotKind::Count {
605				count_star: true,
606			},
607			SlotKind::Sum,
608			SlotKind::Min,
609		];
610		let mut a = accumulator(&kinds);
611		add(&mut a, 0, vec![None, i4(100), i4(100)]);
612		let snap = a.finalize();
613		add(&mut a, 1, vec![None, i4(40), i4(40)]);
614		remove(&mut a, 1, vec![None, i4(40), i4(40)]);
615		assert_eq!(a.finalize(), snap, "add then remove restores all slots");
616	}
617
618	#[test]
619	fn merge_equals_accumulating_all_into_one() {
620		let kinds = [
621			SlotKind::Count {
622				count_star: true,
623			},
624			SlotKind::Sum,
625			SlotKind::Avg,
626			SlotKind::Min,
627			SlotKind::Max,
628		];
629		// One accumulator holding every contribution directly.
630		let mut whole = accumulator(&kinds);
631		let rows = [(10, 10, 10), (40, 40, 40), (7, 7, 7), (99, 99, 99)];
632		for (seq, (s, mn, mx)) in rows.into_iter().enumerate() {
633			add(&mut whole, seq as u64, vec![None, i4(s), i4(s), i4(mn), i4(mx)]);
634		}
635		// Two partial accumulators (disjoint slots, as a rolling buffer would hold) merged.
636		let mut left = accumulator(&kinds);
637		for (seq, (s, mn, mx)) in rows[..2].iter().enumerate() {
638			add(&mut left, seq as u64, vec![None, i4(*s), i4(*s), i4(*mn), i4(*mx)]);
639		}
640		let mut right = accumulator(&kinds);
641		for (seq, (s, mn, mx)) in rows[2..].iter().enumerate() {
642			add(&mut right, (seq + 2) as u64, vec![None, i4(*s), i4(*s), i4(*mn), i4(*mx)]);
643		}
644		left.merge(&right);
645		assert_eq!(
646			left.finalize(),
647			whole.finalize(),
648			"merge of two partials must equal one combined accumulator"
649		);
650	}
651
652	#[test]
653	fn merge_into_empty_takes_other_widened_sum() {
654		let kinds = [SlotKind::Sum];
655		let mut empty = accumulator(&kinds);
656		let mut other = accumulator(&kinds);
657		add(&mut other, 0, vec![i4(5)]);
658		empty.merge(&other);
659		// Empty-self merge must adopt the other's already-widened Int16, not stay none.
660		assert_eq!(empty.finalize(), Some(vec![Value::Int16(5)]));
661	}
662
663	#[test]
664	fn empty_when_all_removed() {
665		let mut a = accumulator(&[SlotKind::Sum, SlotKind::Min]);
666		add(&mut a, 0, vec![i4(1), i4(1)]);
667		remove(&mut a, 0, vec![i4(1), i4(1)]);
668		assert!(a.is_empty());
669		assert_eq!(a.finalize(), None);
670	}
671
672	#[test]
673	fn first_last_track_endpoints_by_coordinate() {
674		// first/last order by the event coordinate; out-of-order arrival must still
675		// yield the earliest/latest by coordinate, not by arrival.
676		let mut a = RowAccumulator::new(&[SlotKind::First, SlotKind::Last], None);
677		a.add(&(coord(20), vec![i4(20), i4(20)]));
678		a.add(&(coord(10), vec![i4(10), i4(10)]));
679		a.add(&(coord(30), vec![i4(30), i4(30)]));
680		assert_eq!(a.finalize(), Some(vec![Value::Int4(10), Value::Int4(30)]));
681	}
682
683	#[test]
684	fn lateness_seals_aged_min_max_and_drops_late_retraction() {
685		// With lateness = 5s, an entry whose coordinate is more than 5s behind the
686		// high-water mark is folded into the sealed scalar. The max stays correct, but a
687		// retraction of that aged entry is a no-op (it is no longer in the live tail) -
688		// this is the documented memory-vs-exactness trade, identical to chaindex.
689		let lateness = Duration::from_seconds(5).unwrap();
690		let mut a = RowAccumulator::new(&[SlotKind::Max], Some(lateness));
691		a.add(&(coord(0), vec![i4(100)])); // becomes sealed once high-water passes 5s
692		a.add(&(coord(10), vec![i4(50)]));
693		assert_eq!(a.finalize(), Some(vec![Value::Int4(100)]), "sealed max still dominates");
694		// Retracting the sealed entry cannot lower the max: it was already folded away.
695		a.remove(&(coord(0), vec![i4(100)]));
696		assert_eq!(
697			a.finalize(),
698			Some(vec![Value::Int4(100)]),
699			"retraction older than lateness is a no-op, so the sealed max survives"
700		);
701		// A retraction still inside the lateness window does take effect.
702		a.add(&(coord(12), vec![i4(70)]));
703		a.remove(&(coord(12), vec![i4(70)]));
704		assert_eq!(a.finalize(), Some(vec![Value::Int4(100)]));
705	}
706
707	#[test]
708	fn lateness_none_min_max_is_exact_under_retraction() {
709		// Without lateness, Min/Max use the exact Multiset and a retraction of any prior
710		// value is honored regardless of age.
711		let mut a = accumulator(&[SlotKind::Max]);
712		add(&mut a, 0, vec![i4(100)]);
713		add(&mut a, 1, vec![i4(50)]);
714		remove(&mut a, 0, vec![i4(100)]);
715		assert_eq!(a.finalize(), Some(vec![Value::Int4(50)]), "exact path retracts the old max");
716	}
717
718	#[test]
719	fn sealed_merge_matches_one_combined_accumulator() {
720		// Rolling merges sub-accumulators; a sealed Min/Max/endpoint merge must equal one
721		// accumulator that saw all contributions.
722		let lateness = Duration::from_seconds(60).unwrap();
723		let kinds = [SlotKind::Min, SlotKind::Max, SlotKind::First, SlotKind::Last];
724		let rows = [(5, 30), (8, 10), (3, 50), (12, 20)];
725		let mut whole = RowAccumulator::new(&kinds, Some(lateness));
726		for (i, (v, _)) in rows.iter().enumerate() {
727			whole.add(&(coord((i as u64) * 10), vec![i4(*v), i4(*v), i4(*v), i4(*v)]));
728		}
729		let mut left = RowAccumulator::new(&kinds, Some(lateness));
730		for (i, (v, _)) in rows[..2].iter().enumerate() {
731			left.add(&(coord((i as u64) * 10), vec![i4(*v), i4(*v), i4(*v), i4(*v)]));
732		}
733		let mut right = RowAccumulator::new(&kinds, Some(lateness));
734		for (i, (v, _)) in rows[2..].iter().enumerate() {
735			right.add(&(coord(((i + 2) as u64) * 10), vec![i4(*v), i4(*v), i4(*v), i4(*v)]));
736		}
737		left.merge(&right);
738		assert_eq!(left.finalize(), whole.finalize(), "sealed merge must equal one combined accumulator");
739	}
740}