Skip to main content

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;