Skip to main content

reifydb_core/window/engine/
multi_rolling.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{
5	collections::{BTreeMap, BTreeSet, HashMap},
6	fmt::Debug,
7	hash::Hash,
8	marker::PhantomData,
9};
10
11use reifydb_codec::key::encoded::{EncodedKey, IntoEncodedKey};
12use reifydb_value::{Result, reifydb_assertions, value::row_number::RowNumber};
13use serde::{Serialize, de::DeserializeOwned};
14
15use crate::window::{
16	accumulator::WindowAccumulator,
17	engine::{
18		AccumulatorEvent, EmitKey, GroupMeta, MetaKey, config::WindowEngineConfig, load_buffer, meta_key_for,
19		persist_buffer, rolling::RollingBuckets, sweep_stale_meta,
20	},
21	span::Slot,
22	state::StateCache,
23	store::WindowStore,
24};
25
26pub type MultiRollingBuffer<C, Accumulator> = BTreeMap<C, Accumulator>;
27
28pub type MultiRollingEmit<SK, Output> = BTreeMap<SK, Output>;
29
30pub enum MultiEmit<Output> {
31	Insert {
32		row_number: RowNumber,
33		value: Output,
34	},
35	Update {
36		row_number: RowNumber,
37		prior: Output,
38		value: Output,
39	},
40	Remove {
41		row_number: RowNumber,
42		value: Output,
43	},
44}
45
46type MetaLoaded<G, C> = HashMap<G, GroupMeta<C>>;
47type StateRows<G> = HashMap<G, RowNumber>;
48
49struct GroupSlot<C, Accumulator, SK, Output> {
50	state_row_number: RowNumber,
51	buffer: MultiRollingBuffer<C, Accumulator>,
52	loaded_coords: Vec<u64>,
53	dirty: BTreeSet<u64>,
54	prior_emit: MultiRollingEmit<SK, Output>,
55	buffer_changed: bool,
56}
57
58pub struct MultiRollingEngine<G, C, Accumulator, SK, Output> {
59	last_emit: StateCache<EmitKey, MultiRollingEmit<SK, Output>>,
60	meta: StateCache<MetaKey, GroupMeta<C>>,
61	meta_low_water: Option<u64>,
62	_pd: PhantomData<(G, C, Accumulator)>,
63}
64
65impl<G, C, Accumulator, SK, Output> MultiRollingEngine<G, C, Accumulator, SK, Output>
66where
67	G: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
68	C: Slot + Hash + Serialize + DeserializeOwned,
69	Accumulator: WindowAccumulator,
70	SK: Clone + Eq + Ord + Hash + Debug + Serialize + DeserializeOwned,
71	Output: Clone + Debug + PartialEq + Serialize + DeserializeOwned,
72	for<'a> &'a G: IntoEncodedKey,
73{
74	pub fn new(config: WindowEngineConfig) -> Self {
75		Self {
76			last_emit: StateCache::<EmitKey, MultiRollingEmit<SK, Output>>::new_internal(
77				config.state_cache_capacity(),
78			),
79			meta: StateCache::<MetaKey, GroupMeta<C>>::new_internal(config.internal_state_cache_capacity()),
80			meta_low_water: None,
81			_pd: PhantomData,
82		}
83	}
84
85	pub fn expire_meta<S: WindowStore>(&mut self, store: &mut S, threshold: u64) -> Result<usize> {
86		sweep_stale_meta(store, &mut self.meta, threshold, &mut self.meta_low_water)
87	}
88
89	pub fn apply<S, SKF, RKF, CB>(
90		&mut self,
91		store: &mut S,
92		buckets: RollingBuckets<G, C, Accumulator::Contribution>,
93		capacity: usize,
94		state_key: SKF,
95		row_key: RKF,
96		combine: CB,
97	) -> Result<Vec<MultiEmit<Output>>>
98	where
99		S: WindowStore,
100		SKF: Fn(&G) -> EncodedKey,
101		RKF: Fn(&G, &SK) -> EncodedKey,
102		CB: Fn(&G, &MultiRollingBuffer<C, Accumulator>) -> MultiRollingEmit<SK, Output>,
103	{
104		if buckets.is_empty() {
105			return Ok(Vec::new());
106		}
107		let mut meta_loaded = self.warm_and_load_meta(store, &buckets)?;
108		let state_rows = self.resolve_state_rows(store, &buckets, &meta_loaded, &state_key)?;
109		let group_slots = self.apply_events_into_buffers(
110			store,
111			buckets,
112			&mut meta_loaded,
113			&state_rows,
114			&state_key,
115			capacity,
116		)?;
117		let emits = self.diff_emits(store, group_slots, &row_key, &combine)?;
118		self.persist_meta(store, meta_loaded)?;
119		Ok(emits)
120	}
121
122	pub fn flush<S: WindowStore>(&mut self, store: &mut S) -> Result<()> {
123		self.last_emit.flush(store)?;
124		self.meta.flush(store)?;
125		Ok(())
126	}
127
128	fn warm_and_load_meta<S: WindowStore>(
129		&mut self,
130		store: &mut S,
131		buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
132	) -> Result<MetaLoaded<G, C>> {
133		let meta_keys: Vec<MetaKey> = buckets
134			.keys()
135			.map(|(group, _)| group)
136			.collect::<BTreeSet<_>>()
137			.into_iter()
138			.map(meta_key_for)
139			.collect();
140		self.meta.warm(store, &meta_keys)?;
141
142		let mut meta_loaded: MetaLoaded<G, C> = HashMap::new();
143		for (group, _) in buckets.keys() {
144			if !meta_loaded.contains_key(group) {
145				let m = self.meta.get(store, &meta_key_for(group))?.unwrap_or_default();
146				meta_loaded.insert(group.clone(), m);
147			}
148		}
149		Ok(meta_loaded)
150	}
151
152	fn resolve_state_rows<S, SKF>(
153		&mut self,
154		store: &mut S,
155		buckets: &RollingBuckets<G, C, Accumulator::Contribution>,
156		meta_loaded: &MetaLoaded<G, C>,
157		state_key: &SKF,
158	) -> Result<StateRows<G>>
159	where
160		S: WindowStore,
161		SKF: Fn(&G) -> EncodedKey,
162	{
163		let mut state_rows: StateRows<G> = HashMap::new();
164		let mut resolve_order: Vec<G> = Vec::new();
165		let mut state_lookup_keys: Vec<EncodedKey> = Vec::new();
166		let mut seen: BTreeSet<G> = BTreeSet::new();
167		for (group, coord) in buckets.keys() {
168			let initial_high_water = meta_loaded.get(group).and_then(|m| m.high_water);
169			if initial_high_water.is_none_or(|hw| *coord >= hw) && seen.insert(group.clone()) {
170				resolve_order.push(group.clone());
171				state_lookup_keys.push(state_key(group));
172			}
173		}
174		let resolved_rows = store.get_or_create_row_numbers(&state_lookup_keys)?;
175		reifydb_assertions! {
176			let resolved = resolved_rows.len();
177			let requested = state_lookup_keys.len();
178			assert!(
179				resolved == requested,
180				"get_or_create_row_numbers returned {resolved} rows for {requested} group keys; \
181				 the zip below pairs resolve_order with resolved_rows by position, so a length \
182				 mismatch would silently leave some groups without a state_rows entry and route \
183				 them through the per-bucket get_or_create_row_number fallback, diverging behaviour"
184			);
185		}
186		let emit_keys: Vec<EmitKey> = resolved_rows.iter().map(|(rn, _)| EmitKey(*rn)).collect();
187		for (group, (state_row_number, _)) in resolve_order.into_iter().zip(resolved_rows) {
188			state_rows.insert(group, state_row_number);
189		}
190		self.last_emit.warm(store, &emit_keys)?;
191		Ok(state_rows)
192	}
193
194	fn apply_events_into_buffers<S, SKF>(
195		&mut self,
196		store: &mut S,
197		buckets: RollingBuckets<G, C, Accumulator::Contribution>,
198		meta_loaded: &mut MetaLoaded<G, C>,
199		state_rows: &StateRows<G>,
200		state_key: &SKF,
201		capacity: usize,
202	) -> Result<BTreeMap<G, GroupSlot<C, Accumulator, SK, Output>>>
203	where
204		S: WindowStore,
205		SKF: Fn(&G) -> EncodedKey,
206	{
207		let mut group_slots: BTreeMap<G, GroupSlot<C, Accumulator, SK, Output>> = BTreeMap::new();
208
209		for ((group, coord), events) in buckets {
210			let meta = meta_loaded.entry(group.clone()).or_default();
211
212			let slot = match group_slots.get_mut(&group) {
213				Some(s) => s,
214				None => {
215					let state_row_number = match state_rows.get(&group) {
216						Some(&rn) => rn,
217						None => {
218							let key = state_key(&group);
219							let (rn, _is_new) = store.get_or_create_row_number(&key)?;
220							rn
221						}
222					};
223					let (buffer, loaded_coords): (MultiRollingBuffer<C, Accumulator>, Vec<u64>) =
224						load_buffer(store, state_row_number)?;
225					let prior_emit = self
226						.last_emit
227						.get(store, &EmitKey(state_row_number))?
228						.unwrap_or_default();
229					group_slots.insert(
230						group.clone(),
231						GroupSlot {
232							state_row_number,
233							buffer,
234							loaded_coords,
235							dirty: BTreeSet::new(),
236							prior_emit,
237							buffer_changed: false,
238						},
239					);
240					group_slots.get_mut(&group).expect("just inserted")
241				}
242			};
243
244			let mut accumulator = slot.buffer.remove(&coord).unwrap_or_default();
245			let mut touched = false;
246			for event in events {
247				match event {
248					AccumulatorEvent::Add(c) => {
249						accumulator.add(&c);
250						touched = true;
251					}
252					AccumulatorEvent::Remove(c) => {
253						if accumulator.is_empty() {
254							continue;
255						}
256						accumulator.remove(&c);
257						touched = true;
258					}
259				}
260			}
261			if !accumulator.is_empty() {
262				slot.buffer.insert(coord, accumulator);
263			}
264			if !touched {
265				continue;
266			}
267			while slot.buffer.len() > capacity {
268				slot.buffer.pop_first();
269			}
270			slot.buffer_changed = true;
271			slot.dirty.insert(coord.order_key());
272
273			let next_high_water = match meta.high_water {
274				Some(hw) if hw > coord => hw,
275				_ => coord,
276			};
277			reifydb_assertions! {
278				assert!(
279					next_high_water >= coord,
280					"high_water regressed below the window coord it just admitted, so the next batch would \
281					 treat an already-processed window as late and silently drop its events (coord={coord:?}, \
282					 prev_high_water={prev:?}, next_high_water={next_high_water:?})",
283					prev = meta.high_water
284				);
285				if let Some(prev) = meta.high_water {
286					assert!(
287						next_high_water >= prev,
288						"high_water moved backwards across an admit, breaking the monotonic late-event \
289						 cutoff that buried-window dropping relies on (coord={coord:?}, prev_high_water={prev:?}, \
290						 next_high_water={next_high_water:?})"
291					);
292				}
293			}
294			meta.high_water = Some(next_high_water);
295		}
296
297		Ok(group_slots)
298	}
299
300	fn diff_emits<S, RKF, CB>(
301		&mut self,
302		store: &mut S,
303		group_slots: BTreeMap<G, GroupSlot<C, Accumulator, SK, Output>>,
304		row_key: &RKF,
305		combine: &CB,
306	) -> Result<Vec<MultiEmit<Output>>>
307	where
308		S: WindowStore,
309		RKF: Fn(&G, &SK) -> EncodedKey,
310		CB: Fn(&G, &MultiRollingBuffer<C, Accumulator>) -> MultiRollingEmit<SK, Output>,
311	{
312		let mut emits: Vec<MultiEmit<Output>> = Vec::new();
313
314		for (group, slot) in group_slots {
315			if !slot.buffer_changed {
316				continue;
317			}
318			let new_emit = combine(&group, &slot.buffer);
319
320			for (sk, new_out) in &new_emit {
321				let key = row_key(&group, sk);
322				let (rn, _is_new_alloc) = store.get_or_create_row_number(&key)?;
323				match slot.prior_emit.get(sk) {
324					Some(prior_out) => {
325						if prior_out != new_out {
326							emits.push(MultiEmit::Update {
327								row_number: rn,
328								prior: prior_out.clone(),
329								value: new_out.clone(),
330							});
331						}
332					}
333					None => {
334						emits.push(MultiEmit::Insert {
335							row_number: rn,
336							value: new_out.clone(),
337						});
338					}
339				}
340			}
341			for (sk, prior_out) in &slot.prior_emit {
342				if !new_emit.contains_key(sk) {
343					let key = row_key(&group, sk);
344					let (rn, _is_new_alloc) = store.get_or_create_row_number(&key)?;
345					emits.push(MultiEmit::Remove {
346						row_number: rn,
347						value: prior_out.clone(),
348					});
349					store.drop_row_number(&key)?;
350				}
351			}
352
353			persist_buffer(store, slot.state_row_number, &slot.buffer, &slot.loaded_coords, &slot.dirty)?;
354			if new_emit.is_empty() {
355				self.last_emit.remove(store, &EmitKey(slot.state_row_number))?;
356			} else {
357				self.last_emit.put(store, &EmitKey(slot.state_row_number), new_emit)?;
358			}
359		}
360
361		Ok(emits)
362	}
363
364	fn persist_meta<S: WindowStore>(&mut self, store: &mut S, meta_loaded: MetaLoaded<G, C>) -> Result<()> {
365		for (group, meta) in meta_loaded {
366			self.meta.set(store, &meta_key_for(&group), &meta)?;
367		}
368		Ok(())
369	}
370}
371
372#[cfg(test)]
373mod tests {
374	use std::collections::BTreeMap;
375
376	use reifydb_codec::key::encoded::EncodedKey;
377
378	use super::{MultiEmit, MultiRollingBuffer, MultiRollingEngine};
379	use crate::window::engine::{
380		AccumulatorEvent,
381		config::WindowEngineConfig,
382		rolling::RollingBuckets,
383		test_support::{MockStore, SumAccumulator},
384	};
385
386	fn test_config() -> WindowEngineConfig {
387		WindowEngineConfig::builder().state_cache_capacity(8).internal_state_cache_capacity(64).build()
388	}
389
390	fn state_key(group: &u32) -> EncodedKey {
391		EncodedKey::builder().u32(*group).build()
392	}
393
394	fn row_key(group: &u32, sk: &u32) -> EncodedKey {
395		EncodedKey::builder().u32(*group).u32(*sk).build()
396	}
397
398	fn combine(_group: &u32, buffer: &MultiRollingBuffer<u64, SumAccumulator>) -> BTreeMap<u32, i64> {
399		let mut out = BTreeMap::new();
400		if !buffer.is_empty() {
401			out.insert(0u32, buffer.values().map(|a| a.sum).sum());
402		}
403		out
404	}
405
406	#[test]
407	fn group_state_survives_restart() {
408		// multi_rolling bundles a group's rolling buffer and its last emitted ranking into one
409		// persisted GroupState. When the group empties under retraction, the vanishing ranked key is
410		// withdrawn using the persisted `last_emit`. Dropping the engine between the publish and the
411		// retraction (a restart) forces the GroupState to be reloaded from the store. It would fail if
412		// the GroupState (buffer + last_emit) failed to round-trip - a serialization break, or
413		// last_emit not being persisted.
414		let mut store = MockStore::default();
415
416		let mut engine = MultiRollingEngine::<u32, u64, SumAccumulator, u32, i64>::new(test_config());
417		let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
418		buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Add(5)]);
419		let published = engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
420		engine.flush(&mut store).unwrap();
421		assert_eq!(published.len(), 1);
422		let published_row = match &published[0] {
423			MultiEmit::Insert {
424				row_number,
425				value,
426			} => {
427				assert_eq!(*value, 5);
428				*row_number
429			}
430			_ => panic!("expected an Insert for the newly published group"),
431		};
432
433		// Restart: a brand new engine with empty caches, forced to reload the persisted GroupState.
434		let mut engine = MultiRollingEngine::<u32, u64, SumAccumulator, u32, i64>::new(test_config());
435		let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
436		buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Remove(5)]);
437		let withdrawn = engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
438		engine.flush(&mut store).unwrap();
439
440		assert_eq!(withdrawn.len(), 1, "emptying the group emits exactly one terminal diff");
441		match &withdrawn[0] {
442			MultiEmit::Remove {
443				row_number,
444				value,
445			} => {
446				assert_eq!(
447					*value, 5,
448					"the withdrawn value is the reloaded last_emit, not a stale or zeroed value"
449				);
450				assert_eq!(
451					*row_number, published_row,
452					"the withdrawal targets the same row that was published"
453				);
454			}
455			_ => panic!("the group emptied under retraction, so it must emit a terminal Remove"),
456		}
457	}
458
459	#[test]
460	fn withdrawn_ranking_reclaims_its_row_number_mapping() {
461		// Every ranked (group, secondary) mints a row-number mapping ('M') via get_or_create_row_number.
462		// When the ranking is withdrawn (its secondary drops out of the emit) that mapping must be
463		// reclaimed, or 'M' grows per distinct ranked key ever seen - a leak the emitted Remove alone
464		// does not close, since Remove only withdraws the view row, not the internal mapping.
465		let mut store = MockStore::default();
466		// `combine` publishes the group's ranking under secondary key 0 (see the helper below), so the
467		// ranked row's mapping is row_key(group=1, sk=0) - distinct from the rolling coord (10).
468		let ranked_key = row_key(&1, &0);
469
470		let mut engine = MultiRollingEngine::<u32, u64, SumAccumulator, u32, i64>::new(test_config());
471		let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
472		buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Add(5)]);
473		engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
474		engine.flush(&mut store).unwrap();
475		assert!(store.contains_row_mapping(&ranked_key), "publishing the ranking mints its mapping");
476
477		let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
478		buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Remove(5)]);
479		engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
480		engine.flush(&mut store).unwrap();
481		assert!(
482			!store.contains_row_mapping(&ranked_key),
483			"withdrawing the ranking must reclaim its row-number mapping, not leak it"
484		);
485	}
486
487	#[test]
488	fn group_state_survives_lru_eviction() {
489		// The other way the GroupState is read back is LRU eviction, no restart needed: the group cache
490		// holds only 8 groups, so tracking more evicts the oldest and the next access re-reads it from
491		// the store. We publish 11 groups so group 1 is evicted, flush, then retract group 1 and assert
492		// its GroupState reloads and the vanishing ranked key is withdrawn with the persisted value.
493		let mut store = MockStore::default();
494		let mut engine = MultiRollingEngine::<u32, u64, SumAccumulator, u32, i64>::new(test_config());
495
496		let mut published_row_1 = None;
497		for group in 1u32..=11u32 {
498			let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
499			buckets.insert((group, 10u64), vec![AccumulatorEvent::Add(i64::from(group))]);
500			let out = engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
501			if group == 1 {
502				assert_eq!(out.len(), 1);
503				published_row_1 = match &out[0] {
504					MultiEmit::Insert {
505						row_number,
506						value,
507					} => {
508						assert_eq!(*value, 1);
509						Some(*row_number)
510					}
511					_ => panic!("expected an Insert for group 1"),
512				};
513			}
514		}
515		engine.flush(&mut store).unwrap();
516		let published_row_1 = published_row_1.expect("group 1 published an Insert");
517
518		// Group 1 was published first and pushed out of the 8-slot group cache by the later groups, so
519		// the same engine must re-read its GroupState from the store to apply this retraction.
520		let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
521		buckets.insert((1u32, 10u64), vec![AccumulatorEvent::Remove(1)]);
522		let withdrawn = engine.apply(&mut store, buckets, 4, state_key, row_key, combine).unwrap();
523		engine.flush(&mut store).unwrap();
524
525		assert_eq!(withdrawn.len(), 1, "emptying the evicted group emits exactly one terminal diff");
526		match &withdrawn[0] {
527			MultiEmit::Remove {
528				row_number,
529				value,
530			} => {
531				assert_eq!(*value, 1, "the withdrawn value is the reloaded last_emit for group 1");
532				assert_eq!(
533					*row_number, published_row_1,
534					"the withdrawal targets the same row that was published for group 1"
535				);
536			}
537			_ => panic!("the evicted group emptied under retraction, so it must emit a terminal Remove"),
538		}
539	}
540	#[test]
541	fn per_coord_churn_matches_a_recomputed_ranking_oracle() {
542		// After the storage split the buffer lives as per-coord entries and the
543		// ranking as a separate last_emit entry. The engine must still emit exactly
544		// what a from-scratch recombine would across a seeded workload of adds,
545		// retractions, and capacity eviction. A single ranked key (SK 0 = sum over
546		// the live buffer) makes the visible state one value we compare against an
547		// independent live-buffer oracle after every batch. Storing blobs, dropping
548		// or keeping the wrong coords on eviction, or mis-persisting last_emit would
549		// surface as a divergence at the exact round.
550		const CAP: usize = 4;
551		let mut store = MockStore::default();
552		let mut engine = MultiRollingEngine::<u32, u64, SumAccumulator, u32, i64>::new(test_config());
553
554		let mut state = 0x1234_5678_9abc_def0u64;
555		let mut roll = |bound: u64| {
556			state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
557			(state >> 33) % bound
558		};
559
560		let mut live: BTreeMap<u64, (i64, u64)> = BTreeMap::new();
561		let mut added: Vec<(u64, i64)> = Vec::new();
562		let mut visible: Option<i64> = None;
563		let mut coord_base = 100u64;
564
565		for round in 0..200u64 {
566			let mut plan: Vec<(u64, i64, bool)> = Vec::new();
567			for _ in 0..=roll(3) {
568				let coord = coord_base + roll(20);
569				let value = roll(1_000) as i64 + 1;
570				plan.push((coord, value, true));
571				added.push((coord, value));
572			}
573			if round % 3 == 2 && !added.is_empty() {
574				let (coord, value) = added.remove((roll(added.len() as u64)) as usize);
575				plan.push((coord, value, false));
576			}
577
578			for &(coord, value, is_add) in &plan {
579				let e = live.entry(coord).or_insert((0, 0));
580				if is_add {
581					e.0 += value;
582					e.1 += 1;
583				} else if e.1 > 0 {
584					e.0 -= value;
585					e.1 -= 1;
586					if e.1 == 0 {
587						live.remove(&coord);
588					}
589				} else {
590					live.remove(&coord);
591				}
592			}
593			while live.len() > CAP {
594				let &lowest = live.keys().next().unwrap();
595				live.remove(&lowest);
596			}
597
598			let mut buckets: RollingBuckets<u32, u64, i64> = BTreeMap::new();
599			for &(coord, value, is_add) in &plan {
600				let ev = if is_add {
601					AccumulatorEvent::Add(value)
602				} else {
603					AccumulatorEvent::Remove(value)
604				};
605				buckets.entry((1u32, coord)).or_default().push(ev);
606			}
607			let emits = engine.apply(&mut store, buckets, CAP, state_key, row_key, combine).unwrap();
608			engine.flush(&mut store).unwrap();
609			for e in &emits {
610				match e {
611					MultiEmit::Insert {
612						value,
613						..
614					}
615					| MultiEmit::Update {
616						value,
617						..
618					} => visible = Some(*value),
619					MultiEmit::Remove {
620						..
621					} => visible = None,
622				}
623			}
624
625			let oracle = if live.is_empty() {
626				None
627			} else {
628				Some(live.values().map(|(s, _)| *s).sum::<i64>())
629			};
630			assert_eq!(visible, oracle, "visible ranking diverged from the oracle after round {round}");
631			coord_base += roll(10);
632		}
633	}
634}