1use std::collections::{HashMap, HashSet};
2
3use super::*;
4use crate::stats::calculators::*;
5use crate::{PlayerId, SubtrActorResult};
6
7#[derive(Debug, Clone, Default)]
9pub struct StatsProjectionState {
10 pub core: CoreStatsAccumulator,
11 pub backboard: BackboardStatsAccumulator,
12 pub ceiling_shot: CeilingShotStatsAccumulator,
13 pub wall_aerial: WallAerialStatsAccumulator,
14 pub wall_aerial_shot: WallAerialShotStatsAccumulator,
15 pub double_tap: DoubleTapStatsAccumulator,
16 pub one_timer: OneTimerStatsAccumulator,
17 pub pass: PassStatsAccumulator,
18 pub fifty_fifty: FiftyFiftyStatsAccumulator,
19 pub kickoff: KickoffStatsAccumulator,
20 pub possession: PossessionStatsAccumulator,
21 pub ball_half: BallHalfStatsAccumulator,
22 pub ball_third: BallThirdStatsAccumulator,
23 pub territorial_pressure: TerritorialPressureStatsAccumulator,
24 pub rotation: RotationStatsAccumulator,
25 pub rush: RushStatsAccumulator,
26 pub touch: TouchStatsAccumulator,
27 pub whiff: WhiffStatsAccumulator,
28 pub wavedash: WavedashStatsAccumulator,
29 pub speed_flip: SpeedFlipStatsAccumulator,
30 pub half_flip: HalfFlipStatsAccumulator,
31 pub flick: FlickStatsAccumulator,
32 pub dodge_reset: DodgeResetStatsAccumulator,
33 pub flip_reset: FlipResetStatsAccumulator,
34 pub ball_carry: BallCarryStatsAccumulator,
35 pub boost: BoostStatsAccumulator,
36 pub bump: BumpStatsAccumulator,
37 pub half_volley: HalfVolleyStatsAccumulator,
38 pub movement: MovementStatsAccumulator,
39 pub positioning: PositioningStatsAccumulator,
40 pub powerslide: PowerslideStatsAccumulator,
41 pub demo: DemoStatsAccumulator,
42 pub center: CenterStatsAccumulator,
43 pub controlled_play: ControlledPlayStatsAccumulator,
44}
45
46#[derive(Debug, Clone, Default)]
47struct PowerslideProjectionState {
48 active_players: HashMap<PlayerId, bool>,
49 player_teams: HashMap<PlayerId, bool>,
50}
51
52impl PowerslideProjectionState {
53 fn apply_frame(
54 &mut self,
55 stats: &mut PowerslideStatsAccumulator,
56 frame: &FrameInfo,
57 events: &[PowerslideEvent],
58 counts_toward_motion: bool,
59 ) {
60 let mut started_this_frame = HashSet::new();
61 for event in events {
62 self.player_teams
63 .insert(event.player.clone(), event.is_team_0);
64 if event.active {
65 stats.apply_sample(
66 &event.player,
67 event.is_team_0,
68 true,
69 false,
70 frame.dt,
71 counts_toward_motion,
72 );
73 self.active_players.insert(event.player.clone(), true);
74 started_this_frame.insert(event.player.clone());
75 } else {
76 self.active_players.insert(event.player.clone(), false);
77 }
78 }
79
80 let active_players = self
81 .active_players
82 .iter()
83 .filter(|(player_id, active)| **active && !started_this_frame.contains(*player_id))
84 .map(|(player_id, _)| player_id.clone())
85 .collect::<Vec<_>>();
86 for player_id in active_players {
87 let Some(is_team_0) = self.player_teams.get(&player_id).copied() else {
88 continue;
89 };
90 stats.apply_sample(
91 &player_id,
92 is_team_0,
93 true,
94 true,
95 frame.dt,
96 counts_toward_motion,
97 );
98 }
99 }
100}
101
102#[derive(Debug, Clone, Default)]
118struct IncrementalMovementProjection {
119 base: MovementStatsAccumulator,
120 committed_cursor: usize,
121 #[cfg(test)]
126 committed_folds: usize,
127}
128
129impl IncrementalMovementProjection {
130 fn project(
133 &mut self,
134 committed: &[MovementEvent],
135 pending: &[MovementEvent],
136 ) -> MovementStatsAccumulator {
137 for event in committed.get(self.committed_cursor..).unwrap_or(&[]) {
138 self.base.apply_event(event);
139 #[cfg(test)]
140 {
141 self.committed_folds += 1;
142 }
143 }
144 self.committed_cursor = committed.len();
145
146 let mut snapshot = self.base.clone();
147 for event in pending {
148 snapshot.apply_event(event);
149 }
150 snapshot
151 }
152}
153
154#[derive(Debug, Clone, Default)]
156pub struct StatsProjectionNode {
157 state: StatsProjectionState,
158 cursors: StatsProjectionCursors,
159 movement_projection: IncrementalMovementProjection,
160 powerslide: PowerslideProjectionState,
161 boost_current_amount_consistency: BoostCurrentAmountConsistencyTracker,
162 last_powerslide_sample_frame: Option<usize>,
163 last_possession_sample_frame: Option<usize>,
164 possession_frame_buffer: Vec<PossessionFrameSample>,
169 territorial_pressure_tracked_time: f32,
170 previous_live_play: Option<bool>,
171}
172
173#[derive(Debug, Clone, Default)]
176struct PossessionFrameSample {
177 frame: usize,
178 dt: f32,
179 field_third: Option<String>,
180 field_half: Option<String>,
181}
182
183#[derive(Debug, Clone, Default)]
184struct StatsProjectionCursors {
185 core_player: usize,
186 core_player_goal_context: usize,
187 backboard: usize,
188 ceiling_shot: usize,
189 wall_aerial: usize,
190 wall_aerial_shot: usize,
191 double_tap: usize,
192 one_timer: usize,
193 pass: usize,
194 fifty_fifty: usize,
195 kickoff: usize,
196 ball_half: usize,
197 ball_third: usize,
198 rush: usize,
199 touch: usize,
200 whiff: usize,
201 wavedash: usize,
202 speed_flip: usize,
203 half_flip: usize,
204 flick: usize,
205 dodge_reset: usize,
206 flip_reset: usize,
207 dodge_reset_flip_reset_outcome: usize,
208 ball_carry: usize,
209 bump: usize,
210 half_volley: usize,
211 powerslide: usize,
212 demo_timeline: usize,
213 center: usize,
214 controlled_play: usize,
215}
216
217impl StatsProjectionNode {
218 pub fn new() -> Self {
219 Self::default()
220 }
221
222 fn drain_possession_buffer_through(&mut self, end_frame: usize, label: &str) {
225 let mut drained = 0;
226 while drained < self.possession_frame_buffer.len() {
227 if self.possession_frame_buffer[drained].frame > end_frame {
228 break;
229 }
230 let (dt, field_third, field_half) = {
231 let sample = &self.possession_frame_buffer[drained];
232 (
233 sample.dt,
234 sample.field_third.clone(),
235 sample.field_half.clone(),
236 )
237 };
238 self.state.possession.apply_frame(
239 label,
240 field_third.as_deref(),
241 field_half.as_deref(),
242 dt,
243 );
244 drained += 1;
245 }
246 self.possession_frame_buffer.drain(0..drained);
247 }
248
249 fn flush_possession_buffer_as_neutral(&mut self) {
252 if self.possession_frame_buffer.is_empty() {
253 return;
254 }
255 self.drain_possession_buffer_through(usize::MAX, "neutral");
256 }
257
258 fn begin_sample(&mut self, frame: &FrameInfo, live_play: bool) {
259 self.state.backboard.begin_sample(frame);
260 self.state.center.begin_sample(frame);
261 self.state.double_tap.begin_sample(frame);
262 self.state.half_volley.begin_sample(frame);
263 self.state.one_timer.begin_sample(frame);
264 self.state.pass.begin_sample(frame);
265 self.state.wall_aerial.begin_sample(frame);
266 self.state.wall_aerial_shot.begin_sample(frame);
267
268 if !live_play {
269 self.state.center.clear_current_last();
270 self.state.one_timer.clear_current_last();
271 self.state.pass.clear_current_last();
272 self.state.half_volley.reset_current_last_event_marker();
273 self.state.touch.set_current_last_touch_player(None);
274 self.state.wall_aerial.reset_current_last_event_marker();
275 self.state
276 .wall_aerial_shot
277 .reset_current_last_event_marker();
278 }
279
280 if live_play && self.previous_live_play == Some(false) {
281 self.state.ceiling_shot.reset_current_last_event_marker();
282 self.state.flick.reset_current_last_event_marker();
283 self.state.half_flip.reset_current_last_event_marker();
284 self.state.wavedash.reset_current_last_event_marker();
285 self.state.whiff.reset_current_last_event_marker();
286 }
287
288 if live_play {
289 self.state.ceiling_shot.begin_sample(frame);
290 self.state.flick.begin_sample(frame);
291 self.state.half_flip.begin_sample(frame);
292 self.state.touch.begin_sample(frame);
293 self.state.wavedash.begin_sample(frame);
294 self.state.whiff.begin_sample(frame);
295 }
296 }
297
298 fn finish_sample(&mut self) {
299 self.state.center.finish_sample();
300 self.state.double_tap.finish_sample();
301 self.state.one_timer.finish_sample();
302 self.state.pass.finish_sample();
303 self.state.half_volley.restore_current_last_event_marker();
304 self.state.touch.restore_current_last_touch_marker();
305 self.state.wall_aerial.restore_current_last_event_marker();
306 self.state
307 .wall_aerial_shot
308 .restore_current_last_event_marker();
309 self.state.whiff.restore_current_last_event_marker();
310 }
311
312 fn events_since<'a, E>(cursor: &mut usize, events: &'a [E]) -> &'a [E] {
313 let start = (*cursor).min(events.len());
314 *cursor = events.len();
315 &events[start..]
316 }
317
318 fn check_boost_current_amount_consistency(
319 &mut self,
320 frame: &FrameInfo,
321 players: &PlayerFrameState,
322 ) {
323 for player in &players.players {
324 if player.boost_active {
325 continue;
326 }
327 let Some(observed_byte) = player
328 .last_boost_amount
329 .map(|amount| amount.round().clamp(0.0, BOOST_MAX_AMOUNT) as u8)
330 else {
331 continue;
332 };
333 let stats = self.state.boost.player_stats_for(&player.player_id);
334 self.boost_current_amount_consistency.observe(
335 frame.frame_number,
336 frame.time,
337 &player.player_id,
338 &stats,
339 observed_byte,
340 );
341 }
342 }
343
344 fn warn_for_unresolved_boost_current_amount_drift(&self) {
345 for warning in self.boost_current_amount_consistency.unresolved_warnings() {
346 log::warn!(
347 "Boost invariant violation for player {:?} at frame {} (t={:.3}): {}",
348 warning.player_id,
349 warning.frame,
350 warning.time,
351 warning.message(),
352 );
353 }
354 }
355
356 fn project_frame(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> {
357 let frame = ctx.get::<FrameInfo>()?;
358 let players = ctx.get::<PlayerFrameState>()?;
359 let live_play_state = ctx.get::<LivePlayState>()?;
360 let live_play = live_play_state.is_live_play;
361 let counts_toward_powerslide_motion = matches!(
362 live_play_state.gameplay_phase,
363 GameplayPhase::ActivePlay | GameplayPhase::KickoffWaitingForTouch
364 );
365 let gameplay = ctx.get::<GameplayState>()?;
366 let speed_flip_stats_advance = live_play || gameplay.ball_has_been_hit == Some(false);
367 let should_sample_powerslide =
368 self.last_powerslide_sample_frame != Some(frame.frame_number);
369 self.begin_sample(frame, live_play);
370
371 let match_stats = ctx.get::<MatchStatsCalculator>()?;
372 for event in Self::events_since(
373 &mut self.cursors.core_player,
374 match_stats.core_player_events(),
375 ) {
376 self.state.core.apply_scoreboard_event(event);
377 }
378 for event in Self::events_since(
379 &mut self.cursors.core_player_goal_context,
380 match_stats.core_player_goal_context_events(),
381 ) {
382 self.state.core.apply_goal_context_event(event);
383 }
384
385 let backboard = ctx.get::<BackboardCalculator>()?;
386 self.state.backboard.apply_events(
387 frame,
388 Self::events_since(&mut self.cursors.backboard, backboard.events()),
389 );
390
391 let ceiling_shot = ctx.get::<CeilingShotCalculator>()?;
392 if live_play {
393 for event in Self::events_since(&mut self.cursors.ceiling_shot, ceiling_shot.events()) {
394 self.state.ceiling_shot.apply_event(event, frame);
395 }
396 }
397 let wall_aerial = ctx.get::<WallAerialCalculator>()?;
398 if live_play {
399 for event in Self::events_since(&mut self.cursors.wall_aerial, wall_aerial.events()) {
400 self.state.wall_aerial.apply_event(event, frame);
401 }
402 }
403 let wall_aerial_shot = ctx.get::<WallAerialShotCalculator>()?;
404 if live_play {
405 for event in Self::events_since(
406 &mut self.cursors.wall_aerial_shot,
407 wall_aerial_shot.events(),
408 ) {
409 self.state.wall_aerial_shot.apply_event(event, frame);
410 }
411 }
412 let double_tap = ctx.get::<DoubleTapCalculator>()?;
413 for event in Self::events_since(&mut self.cursors.double_tap, double_tap.events()) {
414 self.state.double_tap.apply_event(frame, event);
415 }
416 let one_timer = ctx.get::<OneTimerCalculator>()?;
417 if live_play {
418 for event in Self::events_since(&mut self.cursors.one_timer, one_timer.events()) {
419 self.state.one_timer.apply_event(frame, event);
420 }
421 }
422 let pass = ctx.get::<PassCalculator>()?;
423 if live_play {
424 for event in Self::events_since(&mut self.cursors.pass, pass.events()) {
425 self.state.pass.apply_event(frame, event);
426 }
427 }
428 let fifty_fifty = ctx.get::<FiftyFiftyCalculator>()?;
429 for event in Self::events_since(&mut self.cursors.fifty_fifty, fifty_fifty.events()) {
430 self.state.fifty_fifty.apply_event(event);
431 }
432 let kickoff = ctx.get::<KickoffCalculator>()?;
433 for event in Self::events_since(&mut self.cursors.kickoff, kickoff.events()) {
434 self.state.kickoff.apply_event(event);
435 }
436 let possession = ctx.get::<PossessionCalculator>()?;
448 let ball_third = ctx.get::<BallThirdCalculator>()?;
449 let ball_half_calculator = ctx.get::<BallHalfCalculator>()?;
450 if live_play && self.last_possession_sample_frame != Some(frame.frame_number) {
451 let field_third = ball_third
452 .current_event()
453 .filter(|event| event.active)
454 .map(|event| event.field_third.clone());
455 let field_half = ball_half_calculator
456 .current_event()
457 .filter(|event| event.active)
458 .map(|event| event.field_half.clone());
459 self.possession_frame_buffer.push(PossessionFrameSample {
460 frame: frame.frame_number,
461 dt: frame.dt,
462 field_third,
463 field_half,
464 });
465 }
466 self.last_possession_sample_frame = Some(frame.frame_number);
467 for segment in possession.new_resolved() {
468 self.drain_possession_buffer_through(segment.end_frame, segment.label.as_label_value());
469 }
470 let ball_half = ctx.get::<BallHalfCalculator>()?;
471 let projected_ball_half_events = ball_half.projected_events();
472 self.state.ball_half = BallHalfStatsAccumulator::default();
473 for event in projected_ball_half_events.iter() {
474 self.state.ball_half.apply_event(event);
475 }
476 self.cursors.ball_half = ball_half.events().len();
477 let ball_third = ctx.get::<BallThirdCalculator>()?;
478 let projected_ball_third_events = ball_third.projected_events();
479 self.state.ball_third = BallThirdStatsAccumulator::default();
480 for event in projected_ball_third_events.iter() {
481 self.state.ball_third.apply_event(event);
482 }
483 self.cursors.ball_third = ball_third.events().len();
484 let territorial_pressure = ctx.get::<TerritorialPressureCalculator>()?;
485 if live_play {
486 self.territorial_pressure_tracked_time += frame.dt;
487 }
488 let projected_territorial_pressure_events = territorial_pressure.projected_events();
489 self.state.territorial_pressure = TerritorialPressureStatsAccumulator::default();
490 self.state
491 .territorial_pressure
492 .set_tracked_time(self.territorial_pressure_tracked_time);
493 for event in projected_territorial_pressure_events.iter() {
494 self.state.territorial_pressure.apply_event(event);
495 }
496 let rotation = ctx.get::<RotationCalculator>()?;
497 self.state.rotation = RotationStatsAccumulator::with_first_man_stint_end_grace_seconds(
498 rotation.config().first_man_stint_end_grace_seconds,
499 );
500 for event in rotation.role_events().iter() {
501 self.state.rotation.apply_role_event(event);
502 }
503 for event in rotation.first_man_change_events() {
504 self.state.rotation.apply_first_man_change_event(event);
505 }
506 let rush = ctx.get::<RushCalculator>()?;
507 for event in Self::events_since(&mut self.cursors.rush, rush.events()) {
508 self.state.rush.apply_event(event);
509 }
510 let touch = ctx.get::<TouchCalculator>()?;
511 if live_play || self.cursors.touch != touch.events().len() {
512 self.state.touch = TouchStatsAccumulator::default();
513 for event in touch.events() {
514 self.state.touch.apply_touch_event(event, frame);
515 }
516 self.cursors.touch = touch.events().len();
517 }
518 let whiff = ctx.get::<WhiffCalculator>()?;
519 if live_play {
520 for event in Self::events_since(&mut self.cursors.whiff, whiff.events()) {
521 self.state.whiff.apply_event(event, frame);
522 }
523 }
524 let wavedash = ctx.get::<WavedashCalculator>()?;
525 if live_play {
526 for event in Self::events_since(&mut self.cursors.wavedash, wavedash.events()) {
527 self.state.wavedash.apply_event(event);
528 }
529 }
530 let speed_flip = ctx.get::<SpeedFlipCalculator>()?;
531 if speed_flip_stats_advance {
532 self.state.speed_flip.begin_sample(frame);
533 for event in Self::events_since(&mut self.cursors.speed_flip, speed_flip.events()) {
534 self.state.speed_flip.apply_event(event);
535 }
536 }
537 let half_flip = ctx.get::<HalfFlipCalculator>()?;
538 if live_play {
539 for event in Self::events_since(&mut self.cursors.half_flip, half_flip.events()) {
540 self.state.half_flip.apply_event(event);
541 }
542 }
543 let flick = ctx.get::<FlickCalculator>()?;
544 if live_play {
545 for event in Self::events_since(&mut self.cursors.flick, flick.events()) {
546 self.state.flick.apply_event(event, frame);
547 }
548 }
549 let dodge_reset = ctx.get::<DodgeResetCalculator>()?;
550 for event in Self::events_since(&mut self.cursors.dodge_reset, dodge_reset.events()) {
551 self.state.dodge_reset.apply_event(event);
552 }
553 for event in Self::events_since(
554 &mut self.cursors.flip_reset,
555 dodge_reset.confirmed_flip_reset_events(),
556 ) {
557 self.state.flip_reset.apply_event(event);
558 }
559 for event in Self::events_since(
560 &mut self.cursors.dodge_reset_flip_reset_outcome,
561 dodge_reset.flip_reset_outcome_events(),
562 ) {
563 self.state.dodge_reset.apply_flip_reset_outcome_event(event);
564 }
565 let ball_carry = ctx.get::<BallCarryCalculator>()?;
566 for event in Self::events_since(&mut self.cursors.ball_carry, ball_carry.carry_events()) {
567 self.state.ball_carry.apply_event(event);
568 }
569 let boost = ctx.get::<BoostCalculator>()?;
570 self.state.boost = boost.boost_stats().clone();
573 if live_play {
574 self.check_boost_current_amount_consistency(frame, players);
575 }
576 let bump = ctx.get::<BumpCalculator>()?;
577 for event in Self::events_since(&mut self.cursors.bump, bump.events()) {
578 self.state.bump.apply_event(event);
579 }
580 let half_volley = ctx.get::<HalfVolleyCalculator>()?;
581 if live_play {
582 for event in Self::events_since(&mut self.cursors.half_volley, half_volley.events()) {
583 self.state.half_volley.apply_event(event, frame);
584 }
585 }
586 let movement = ctx.get::<MovementCalculator>()?;
587 self.state.movement = self
588 .movement_projection
589 .project(movement.events(), &movement.pending_events());
590 let positioning = ctx.get::<PositioningCalculator>()?;
591 self.state.positioning = PositioningStatsAccumulator::default();
592 for event in positioning.activity_events().iter() {
593 self.state.positioning.apply_activity_event(event);
594 }
595 for event in positioning.field_third_events().iter() {
596 self.state.positioning.apply_field_third_event(event);
597 }
598 for event in positioning.field_half_events().iter() {
599 self.state.positioning.apply_field_half_event(event);
600 }
601 for event in positioning.ball_depth_events().iter() {
602 self.state.positioning.apply_ball_depth_event(event);
603 }
604 for event in positioning.depth_role_events().iter() {
605 self.state.positioning.apply_depth_role_event(event);
606 }
607 for event in positioning.ball_proximity_events().iter() {
608 self.state.positioning.apply_ball_proximity_event(event);
609 }
610 for event in positioning.shadow_defense_events().iter() {
611 self.state.positioning.apply_shadow_defense_event(event);
612 }
613 for (player, signal) in positioning.signals() {
614 self.state.positioning.apply_signal(player, signal);
615 }
616 let powerslide = ctx.get::<PowerslideCalculator>()?;
617 let powerslide_events =
618 Self::events_since(&mut self.cursors.powerslide, powerslide.events());
619 if should_sample_powerslide {
620 self.powerslide.apply_frame(
621 &mut self.state.powerslide,
622 frame,
623 powerslide_events,
624 counts_toward_powerslide_motion,
625 );
626 self.last_powerslide_sample_frame = Some(frame.frame_number);
627 }
628 let demo = ctx.get::<DemoCalculator>()?;
629 for event in Self::events_since(&mut self.cursors.demo_timeline, demo.events()) {
630 self.state.demo.apply_demolition_event(event);
631 }
632 let center = ctx.get::<CenterCalculator>()?;
633 for event in Self::events_since(&mut self.cursors.center, center.events()) {
634 self.state.center.apply_event(frame, event);
635 }
636 let controlled_play = ctx.get::<ControlledPlayCalculator>()?;
637 for event in Self::events_since(&mut self.cursors.controlled_play, controlled_play.events())
638 {
639 self.state.controlled_play.apply_event(event);
640 }
641
642 self.finish_sample();
643 self.previous_live_play = Some(live_play);
644 Ok(())
645 }
646}
647
648impl AnalysisNode for StatsProjectionNode {
649 type State = StatsProjectionState;
650
651 fn name(&self) -> &'static str {
652 "stats_projection"
653 }
654
655 fn dependencies(&self) -> NodeDependencies {
656 vec![
657 frame_info_dependency(),
658 gameplay_state_dependency(),
659 live_play_dependency(),
660 player_frame_state_dependency(),
661 match_stats_dependency(),
662 backboard_dependency(),
663 ceiling_shot_dependency(),
664 wall_aerial_dependency(),
665 wall_aerial_shot_dependency(),
666 double_tap_dependency(),
667 one_timer_dependency(),
668 pass_dependency(),
669 fifty_fifty_dependency(),
670 kickoff_dependency(),
671 possession_dependency(),
672 ball_half_dependency(),
673 ball_third_dependency(),
674 territorial_pressure_dependency(),
675 rotation_dependency(),
676 rush_dependency(),
677 touch_dependency(),
678 whiff_dependency(),
679 wavedash_dependency(),
680 speed_flip_dependency(),
681 half_flip_dependency(),
682 flick_dependency(),
683 dodge_reset_dependency(),
684 ball_carry_dependency(),
685 boost_dependency(),
686 bump_dependency(),
687 half_volley_dependency(),
688 movement_dependency(),
689 positioning_dependency(),
690 powerslide_dependency(),
691 demo_dependency(),
692 center_dependency(),
693 controlled_play_dependency(),
694 ]
695 }
696
697 fn evaluate(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> {
698 self.project_frame(ctx)
699 }
700
701 fn finish(&mut self, ctx: &AnalysisStateContext<'_>) -> SubtrActorResult<()> {
702 self.project_frame(ctx)?;
703 self.flush_possession_buffer_as_neutral();
704 self.warn_for_unresolved_boost_current_amount_drift();
705 Ok(())
706 }
707
708 fn state(&self) -> &Self::State {
709 &self.state
710 }
711}
712
713pub(crate) fn boxed_default() -> Box<dyn AnalysisNodeDyn> {
714 Box::new(StatsProjectionNode::new())
715}
716
717#[cfg(test)]
718#[path = "stats_projection_tests.rs"]
719mod tests;