subtr_actor/stats/calculators/in_flight.rs
1//! Uniform lifecycle for *in-flight events* — analysis results that are
2//! recognized on one frame but only finalized later, once enough subsequent
3//! frames have been observed.
4//!
5//! Many calculators share the same shape: arm a pending/"active" candidate when
6//! something is first recognized, fold in metadata over subsequent frames, then
7//! emit a finalized event once a completion condition is met. Historically each
8//! calculator hand-rolled this, including the easy-to-forget part: making sure a
9//! candidate that is still in flight when a *boundary* occurs (a goal, play
10//! leaving the live phase, or the end of the replay) is finalized or discarded
11//! rather than silently leaking or absorbing data across the boundary.
12//!
13//! Two containers are offered, sharing the same vocabulary ([`Boundary`],
14//! [`FinalizeReason`], [`Recognition`], [`Disposition`], [`InFlightItem`]) and
15//! the same latent-query semantics:
16//! * [`InFlightLedger`] holds an unordered set of in-flight items;
17//! * [`KeyedInFlightLedger`] holds at most one in-flight item per key (e.g.
18//! per player), for calculators that look candidates up by subject.
19//!
20//! Both centralize boundary handling ([`apply_boundary`](InFlightLedger::apply_boundary),
21//! `finish`) so a calculator cannot forget a boundary, and both log every
22//! recognition so other nodes can ask "did this happen recently?" *latently* —
23//! counting events that are recognized but not yet finalized, with a
24//! `committed_only` gate so speculative candidates don't give false positives.
25
26use std::collections::hash_map::Entry;
27use std::collections::{HashMap, VecDeque};
28use std::hash::Hash;
29
30/// Default span, in seconds, over which finalized recognitions are retained for
31/// latent queries. Generous enough for "did X happen recently" questions while
32/// keeping the history bounded.
33pub const DEFAULT_HISTORY_WINDOW_SECONDS: f32 = 10.0;
34
35/// A coarse game-flow boundary at which pending analysis work must resolve.
36///
37/// The set is deliberately limited to boundaries the replay model can actually
38/// express. (There is no period/half/overtime concept in the data, so none is
39/// offered here.)
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41pub enum Boundary {
42 /// Play left the live ("active") phase: a goal, a whistle, or a kickoff
43 /// reset. Derived from `LivePlayState::is_live_play` going false.
44 LivePlayEnded,
45 /// A goal was scored. Derived from `FrameEventsState::goal_events`.
46 GoalScored,
47 /// The replay stream ended; nothing more will ever be observed. Delivered
48 /// from each node's `finish`.
49 ReplayEnded,
50}
51
52/// Why an in-flight item was finalized into an event.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub enum FinalizeReason {
55 /// Reached its natural completion condition (window elapsed, follow-up
56 /// observed, possession resolved, ...).
57 Completed,
58 /// Replaced by a newer candidate for the same subject before completing.
59 Superseded,
60 /// Cut short by a game-flow [`Boundary`] before natural completion. The
61 /// resulting event's measured window is truncated at the boundary.
62 Boundary(Boundary),
63}
64
65/// A cheap, early record that an event has been recognized.
66///
67/// A recognition is available from the moment a candidate is armed — before the
68/// full event payload exists. This is what lets other analysis nodes ask
69/// whether an event happened *recently* even when it is still in flight.
70#[derive(Debug, Clone, Copy, PartialEq)]
71pub struct Recognition {
72 pub time: f32,
73 pub frame: usize,
74 /// `false` while the candidate is still speculative (it may yet be
75 /// discarded rather than finalized); `true` once it is certain to emit.
76 pub committed: bool,
77}
78
79impl Recognition {
80 pub fn new(time: f32, frame: usize, committed: bool) -> Self {
81 Self {
82 time,
83 frame,
84 committed,
85 }
86 }
87
88 /// A committed recognition (certain to produce an event).
89 pub fn committed(time: f32, frame: usize) -> Self {
90 Self::new(time, frame, true)
91 }
92
93 /// A speculative recognition (may still be discarded).
94 pub fn speculative(time: f32, frame: usize) -> Self {
95 Self::new(time, frame, false)
96 }
97}
98
99/// What should happen to an in-flight item, as decided either by a per-frame
100/// step or by a [`Boundary`].
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Disposition {
103 /// Keep accumulating; the item stays in flight.
104 Keep,
105 /// Finalize now, for the given reason. The item is removed and handed back
106 /// to the caller to convert into an event.
107 Finalize(FinalizeReason),
108 /// Abandon the item without emitting anything.
109 Discard,
110}
111
112/// An item that can be held in flight by a ledger.
113///
114/// The per-frame accumulation logic stays in the owning calculator (its inputs
115/// are calculator-specific); the trait only covers what a ledger must do
116/// uniformly: expose a [`Recognition`] for latent queries, and decide how to
117/// respond when a [`Boundary`] forces resolution.
118pub trait InFlightItem {
119 fn recognition(&self) -> Recognition;
120
121 /// How this item responds to `boundary`. Committed items that have earned
122 /// an event typically return `Finalize(FinalizeReason::Boundary(boundary))`;
123 /// speculative candidates that have not yet earned one typically `Discard`.
124 fn on_boundary(&mut self, boundary: Boundary) -> Disposition;
125}
126
127/// Bounded, time-indexed record of finalized recognitions, shared by both
128/// ledgers to answer latent "did X happen recently?" queries.
129#[derive(Debug, Clone, PartialEq)]
130struct RecognitionLog {
131 finalized: VecDeque<Recognition>,
132 history_window: f32,
133 last_time: f32,
134}
135
136impl RecognitionLog {
137 fn with_history_window(history_window: f32) -> Self {
138 Self {
139 finalized: VecDeque::new(),
140 history_window,
141 last_time: 0.0,
142 }
143 }
144
145 fn observe_time(&mut self, now: f32) {
146 self.last_time = self.last_time.max(now);
147 }
148
149 fn log(&mut self, item: &impl InFlightItem) {
150 let mut recognition = item.recognition();
151 recognition.committed = true;
152 // Keep `last_time` roughly current even for imperative flows that don't
153 // observe a frame time, so the history still prunes (recognition time is
154 // a lower bound on the current time).
155 self.last_time = self.last_time.max(recognition.time);
156 self.finalized.push_back(recognition);
157 }
158
159 fn prune(&mut self) {
160 let cutoff = self.last_time - self.history_window;
161 while self.finalized.front().is_some_and(|rec| rec.time < cutoff) {
162 self.finalized.pop_front();
163 }
164 }
165
166 fn finalized_within(
167 &self,
168 now: f32,
169 window: f32,
170 committed_only: bool,
171 ) -> impl Iterator<Item = Recognition> + '_ {
172 self.finalized
173 .iter()
174 .copied()
175 .filter(move |rec| in_window(rec, now, window, committed_only))
176 }
177}
178
179fn in_window(rec: &Recognition, now: f32, window: f32, committed_only: bool) -> bool {
180 rec.time <= now && now - rec.time <= window && (!committed_only || rec.committed)
181}
182
183/// Holds an unordered set of in-flight items, drives their lifecycle uniformly,
184/// and records recognitions so finalized and in-flight events alike can be
185/// queried latently.
186#[derive(Debug, Clone, PartialEq)]
187pub struct InFlightLedger<C> {
188 active: Vec<C>,
189 log: RecognitionLog,
190}
191
192impl<C> Default for InFlightLedger<C> {
193 fn default() -> Self {
194 Self::with_history_window(DEFAULT_HISTORY_WINDOW_SECONDS)
195 }
196}
197
198impl<C> InFlightLedger<C> {
199 pub fn new() -> Self {
200 Self::default()
201 }
202
203 pub fn with_history_window(history_window: f32) -> Self {
204 Self {
205 active: Vec::new(),
206 log: RecognitionLog::with_history_window(history_window),
207 }
208 }
209
210 /// Arm a new in-flight item.
211 pub fn arm(&mut self, item: C) {
212 self.active.push(item);
213 }
214
215 /// Discard every in-flight item without finalizing any (e.g. abandoning a
216 /// candidate that never earned an event).
217 pub fn clear(&mut self) {
218 self.active.clear();
219 }
220
221 /// The items currently in flight.
222 pub fn in_flight(&self) -> &[C] {
223 &self.active
224 }
225
226 /// Mutable access to the items currently in flight, for per-frame
227 /// accumulation that does not change the in-flight set.
228 pub fn in_flight_mut(&mut self) -> &mut [C] {
229 &mut self.active
230 }
231
232 pub fn any_in_flight(&self) -> bool {
233 !self.active.is_empty()
234 }
235
236 pub fn is_empty(&self) -> bool {
237 self.active.is_empty()
238 }
239
240 pub fn len(&self) -> usize {
241 self.active.len()
242 }
243}
244
245impl<C: InFlightItem> InFlightLedger<C> {
246 /// Advance every in-flight item with `step`, which folds in this frame's
247 /// data and returns a [`Disposition`]. Finalized and discarded items are
248 /// removed; finalized items are logged for latent queries and returned with
249 /// their reason for the caller to turn into events. `now` is the current
250 /// frame time, used to bound the latent-query history.
251 pub fn advance(
252 &mut self,
253 now: f32,
254 mut step: impl FnMut(&mut C) -> Disposition,
255 ) -> Vec<(C, FinalizeReason)> {
256 self.log.observe_time(now);
257 let finalized = self.resolve_each(|item| step(item));
258 self.log.prune();
259 finalized
260 }
261
262 /// Apply `boundary` to every in-flight item via [`InFlightItem::on_boundary`].
263 /// This is the uniform replacement for hand-placed flush calls: a single
264 /// call resolves *every* pending item against the boundary, so none can be
265 /// forgotten.
266 pub fn apply_boundary(&mut self, boundary: Boundary) -> Vec<(C, FinalizeReason)> {
267 let finalized = self.resolve_each(|item| item.on_boundary(boundary));
268 self.log.prune();
269 finalized
270 }
271
272 /// Finalize everything still in flight at end of stream. Calling this from a
273 /// node's `finish` guarantees no in-flight item is ever silently dropped.
274 pub fn finish(&mut self) -> Vec<(C, FinalizeReason)> {
275 self.apply_boundary(Boundary::ReplayEnded)
276 }
277
278 /// Imperatively finalize every in-flight item with `reason`, returning them
279 /// for the caller to convert into events. For calculators that drive
280 /// finalization from specific code paths (e.g. emit-early, patch-in-place)
281 /// rather than a per-frame step or a [`Boundary`].
282 pub fn finalize_all(&mut self, reason: FinalizeReason) -> Vec<(C, FinalizeReason)> {
283 let mut finalized = Vec::with_capacity(self.active.len());
284 for item in std::mem::take(&mut self.active) {
285 self.log.log(&item);
286 finalized.push((item, reason));
287 }
288 self.log.prune();
289 finalized
290 }
291
292 fn resolve_each(
293 &mut self,
294 mut decide: impl FnMut(&mut C) -> Disposition,
295 ) -> Vec<(C, FinalizeReason)> {
296 let mut finalized = Vec::new();
297 let mut i = 0;
298 while i < self.active.len() {
299 match decide(&mut self.active[i]) {
300 Disposition::Keep => i += 1,
301 Disposition::Discard => {
302 self.active.remove(i);
303 }
304 Disposition::Finalize(reason) => {
305 let item = self.active.remove(i);
306 self.log.log(&item);
307 finalized.push((item, reason));
308 }
309 }
310 }
311 finalized
312 }
313
314 /// Whether an event was recognized within `window` seconds before `now`,
315 /// counting both finalized events and items still in flight.
316 ///
317 /// With `committed_only`, speculative (not-yet-committed) in-flight items
318 /// are ignored — use it for "did X *happen*?" questions that must not be
319 /// fooled by a candidate that may still be discarded. Without it, the query
320 /// is fully latent: a just-recognized, not-yet-finalized candidate counts.
321 pub fn happened_within(&self, now: f32, window: f32, committed_only: bool) -> bool {
322 self.recognitions_within(now, window, committed_only)
323 .next()
324 .is_some()
325 }
326
327 /// All recognitions within `window` seconds before `now`, across in-flight
328 /// and finalized items. See [`happened_within`](Self::happened_within) for
329 /// the meaning of `committed_only`.
330 pub fn recognitions_within(
331 &self,
332 now: f32,
333 window: f32,
334 committed_only: bool,
335 ) -> impl Iterator<Item = Recognition> + '_ {
336 let active = self
337 .active
338 .iter()
339 .map(C::recognition)
340 .filter(move |rec| in_window(rec, now, window, committed_only));
341 active.chain(self.log.finalized_within(now, window, committed_only))
342 }
343}
344
345/// Holds at most one in-flight item per key, drives their lifecycle uniformly,
346/// and records recognitions for latent queries. The keyed analogue of
347/// [`InFlightLedger`], for calculators that track a candidate per subject (e.g.
348/// per player).
349#[derive(Debug, Clone)]
350pub struct KeyedInFlightLedger<K, C> {
351 active: HashMap<K, C>,
352 log: RecognitionLog,
353}
354
355impl<K: Eq + Hash, C: PartialEq> PartialEq for KeyedInFlightLedger<K, C> {
356 fn eq(&self, other: &Self) -> bool {
357 self.active == other.active && self.log == other.log
358 }
359}
360
361impl<K, C> Default for KeyedInFlightLedger<K, C> {
362 fn default() -> Self {
363 Self::with_history_window(DEFAULT_HISTORY_WINDOW_SECONDS)
364 }
365}
366
367impl<K, C> KeyedInFlightLedger<K, C> {
368 pub fn new() -> Self {
369 Self::default()
370 }
371
372 pub fn with_history_window(history_window: f32) -> Self {
373 Self {
374 active: HashMap::new(),
375 log: RecognitionLog::with_history_window(history_window),
376 }
377 }
378
379 pub fn is_empty(&self) -> bool {
380 self.active.is_empty()
381 }
382
383 pub fn len(&self) -> usize {
384 self.active.len()
385 }
386}
387
388impl<K: Eq + Hash + Clone, C> KeyedInFlightLedger<K, C> {
389 /// Arm (or replace) the in-flight item for `key`.
390 pub fn arm(&mut self, key: K, item: C) {
391 self.active.insert(key, item);
392 }
393
394 pub fn contains(&self, key: &K) -> bool {
395 self.active.contains_key(key)
396 }
397
398 pub fn get(&self, key: &K) -> Option<&C> {
399 self.active.get(key)
400 }
401
402 pub fn get_mut(&mut self, key: &K) -> Option<&mut C> {
403 self.active.get_mut(key)
404 }
405
406 /// Access the in-flight item for `key`, inserting one produced by `default`
407 /// if absent. Mirrors `HashMap::entry(..).or_insert_with(..)`.
408 pub fn entry_or_insert_with(&mut self, key: K, default: impl FnOnce() -> C) -> &mut C {
409 match self.active.entry(key) {
410 Entry::Occupied(occupied) => occupied.into_mut(),
411 Entry::Vacant(vacant) => vacant.insert(default()),
412 }
413 }
414
415 pub fn keys(&self) -> impl Iterator<Item = &K> + '_ {
416 self.active.keys()
417 }
418
419 pub fn values(&self) -> impl Iterator<Item = &C> + '_ {
420 self.active.values()
421 }
422
423 pub fn values_mut(&mut self) -> impl Iterator<Item = &mut C> + '_ {
424 self.active.values_mut()
425 }
426
427 pub fn iter(&self) -> impl Iterator<Item = (&K, &C)> + '_ {
428 self.active.iter()
429 }
430
431 pub fn iter_mut(&mut self) -> impl Iterator<Item = (&K, &mut C)> + '_ {
432 self.active.iter_mut()
433 }
434
435 /// Keep only the items for which `keep` returns true, discarding the rest
436 /// without finalizing them (e.g. pruning stale candidates).
437 pub fn retain(&mut self, mut keep: impl FnMut(&K, &C) -> bool) {
438 self.active.retain(|key, item| keep(key, item));
439 }
440
441 /// Remove the item for `key` without recording it as having happened (it is
442 /// abandoned, not finalized).
443 pub fn discard(&mut self, key: &K) -> Option<C> {
444 self.active.remove(key)
445 }
446
447 /// Discard every in-flight item without finalizing any (e.g. abandoning all
448 /// candidates when their precondition no longer holds).
449 pub fn clear(&mut self) {
450 self.active.clear();
451 }
452}
453
454impl<K: Eq + Hash + Clone, C: InFlightItem> KeyedInFlightLedger<K, C> {
455 /// Finalize the item for `key`, logging it for latent queries and returning
456 /// it for the caller to convert into an event.
457 pub fn finalize(&mut self, key: &K, _reason: FinalizeReason) -> Option<C> {
458 let item = self.active.remove(key)?;
459 self.log.log(&item);
460 Some(item)
461 }
462
463 /// Advance every in-flight item with `step`, which receives the key and a
464 /// mutable item and returns a [`Disposition`]. `now` bounds the latent-query
465 /// history. Finalized items are returned with their key and reason.
466 pub fn advance(
467 &mut self,
468 now: f32,
469 mut step: impl FnMut(&K, &mut C) -> Disposition,
470 ) -> Vec<(K, C, FinalizeReason)> {
471 self.log.observe_time(now);
472 let finalized = self.resolve_each(|key, item| step(key, item));
473 self.log.prune();
474 finalized
475 }
476
477 /// Apply `boundary` to every in-flight item via [`InFlightItem::on_boundary`].
478 pub fn apply_boundary(&mut self, boundary: Boundary) -> Vec<(K, C, FinalizeReason)> {
479 let finalized = self.resolve_each(|_key, item| item.on_boundary(boundary));
480 self.log.prune();
481 finalized
482 }
483
484 /// Finalize everything still in flight at end of stream.
485 pub fn finish(&mut self) -> Vec<(K, C, FinalizeReason)> {
486 self.apply_boundary(Boundary::ReplayEnded)
487 }
488
489 fn resolve_each(
490 &mut self,
491 mut decide: impl FnMut(&K, &mut C) -> Disposition,
492 ) -> Vec<(K, C, FinalizeReason)> {
493 let mut finalized = Vec::new();
494 let mut remove_finalize: Vec<(K, FinalizeReason)> = Vec::new();
495 let mut remove_discard: Vec<K> = Vec::new();
496 for (key, item) in self.active.iter_mut() {
497 match decide(key, item) {
498 Disposition::Keep => {}
499 Disposition::Discard => remove_discard.push(key.clone()),
500 Disposition::Finalize(reason) => remove_finalize.push((key.clone(), reason)),
501 }
502 }
503 for key in remove_discard {
504 self.active.remove(&key);
505 }
506 for (key, reason) in remove_finalize {
507 let removed = self.active.remove(&key);
508 if let Some(item) = removed {
509 self.log.log(&item);
510 finalized.push((key, item, reason));
511 }
512 }
513 finalized
514 }
515
516 /// See [`InFlightLedger::happened_within`].
517 pub fn happened_within(&self, now: f32, window: f32, committed_only: bool) -> bool {
518 self.recognitions_within(now, window, committed_only)
519 .next()
520 .is_some()
521 }
522
523 /// All recognitions within `window` seconds before `now`, across in-flight
524 /// and finalized items.
525 pub fn recognitions_within(
526 &self,
527 now: f32,
528 window: f32,
529 committed_only: bool,
530 ) -> impl Iterator<Item = Recognition> + '_ {
531 let active = self
532 .active
533 .values()
534 .map(C::recognition)
535 .filter(move |rec| in_window(rec, now, window, committed_only));
536 active.chain(self.log.finalized_within(now, window, committed_only))
537 }
538}
539
540#[cfg(test)]
541#[path = "in_flight_tests.rs"]
542mod tests;