Skip to main content

rust_robotics_planning/experiments/moving_ai_runtime/
mod.rs

1//! Runtime-aggregation experiments for MovingAI planner comparisons.
2//!
3//! This module treats the *evaluation strategy itself* as the experiment target.
4//! The concrete variants all answer the same question:
5//! "On the same MovingAI family/bucket windows, how should we aggregate A* vs
6//! JPS runtime observations?"
7//!
8//! The stable core here is intentionally small:
9//! - `RuntimeAggregationVariant`
10//! - `RuntimeSamplingPlan`
11//! - `RuntimeExperimentCase`
12//! - `RuntimeObservation`
13//! - `RuntimeVariantReport`
14//! - `run_variant_suite`
15//!
16//! Everything else is an experiment that can be discarded.
17
18mod first_scenario;
19mod full_bucket;
20mod percentile_bucket;
21mod sampled_bucket;
22mod variance_triggered;
23
24use std::sync::OnceLock;
25use std::time::Instant;
26
27use rust_robotics_core::{
28    annotate_against_reference as annotate_reference_reports, average_coverage_ratio,
29    read_source_metrics, ExperimentObservation, ExperimentSamplingPlan, ExperimentVariantReport,
30    ExtensibilityMetrics, Path2D, Point2D, VariantDescriptor,
31};
32
33use crate::moving_ai::{MovingAiMap, MovingAiScenario};
34use crate::{AStarConfig, AStarPlanner, JPSConfig, JPSPlanner};
35
36pub use first_scenario::FirstScenarioRuntimeAggregation;
37pub use full_bucket::FullBucketRuntimeAggregation;
38pub use percentile_bucket::PercentileBucketRuntimeAggregation;
39pub use sampled_bucket::SampledBucketRuntimeAggregation;
40pub use variance_triggered::VarianceTriggeredRuntimeAggregation;
41
42pub type RuntimeSamplingPlan = ExperimentSamplingPlan;
43
44pub trait RuntimeAggregationVariant {
45    fn descriptor(&self) -> VariantDescriptor;
46    fn selected_slots(&self, total_scenarios: usize) -> Vec<usize>;
47
48    fn sampling_plan(&self, total_scenarios: usize) -> RuntimeSamplingPlan {
49        RuntimeSamplingPlan::static_slots(self.selected_slots(total_scenarios))
50    }
51}
52
53#[derive(Debug, Clone, Copy)]
54pub struct RuntimeExperimentCase {
55    pub family_name: &'static str,
56    pub map_str: &'static str,
57    pub scen_str: &'static str,
58    pub buckets: &'static [u32],
59}
60
61#[derive(Debug, Clone)]
62pub struct RuntimeObservation {
63    pub family_name: &'static str,
64    pub bucket: u32,
65    pub total_scenarios: usize,
66    pub initial_slots: Vec<usize>,
67    pub selected_slots: Vec<usize>,
68    pub escalated: bool,
69    pub a_star_bucket_median_us: f64,
70    pub jps_bucket_median_us: f64,
71    pub a_star_min_us: f64,
72    pub a_star_max_us: f64,
73    pub jps_min_us: f64,
74    pub jps_max_us: f64,
75    pub jps_wins: usize,
76}
77
78impl RuntimeObservation {
79    pub fn a_over_j(&self) -> f64 {
80        self.a_star_bucket_median_us / self.jps_bucket_median_us
81    }
82
83    pub fn winner(&self) -> &'static str {
84        if self.a_over_j() > 1.0 {
85            "JPS"
86        } else {
87            "A*"
88        }
89    }
90
91    pub fn coverage_ratio(&self) -> f64 {
92        self.selected_slots.len() as f64 / self.total_scenarios as f64
93    }
94}
95
96impl ExperimentObservation for RuntimeObservation {
97    type Key = (&'static str, u32);
98
99    fn comparison_key(&self) -> Self::Key {
100        (self.family_name, self.bucket)
101    }
102
103    fn winner_label(&self) -> &'static str {
104        RuntimeObservation::winner(self)
105    }
106
107    fn ratio_value(&self) -> f64 {
108        RuntimeObservation::a_over_j(self)
109    }
110
111    fn coverage_ratio(&self) -> f64 {
112        RuntimeObservation::coverage_ratio(self)
113    }
114}
115
116pub type RuntimeVariantReport = ExperimentVariantReport<RuntimeObservation>;
117
118#[derive(Debug, Clone, Copy)]
119pub struct RuntimeEvaluationConfig {
120    pub iterations_per_scenario: usize,
121}
122
123const SYNTHETIC_BUCKETS: &[u32] = &[20, 60, 100];
124const SYNTHETIC_SCENARIOS_PER_BUCKET: usize = 10;
125const SYNTHETIC_PROBE_COORDS: &[usize] = &[1, 8, 15, 22, 29, 36, 43];
126
127impl Default for RuntimeEvaluationConfig {
128    fn default() -> Self {
129        Self {
130            iterations_per_scenario: 3,
131        }
132    }
133}
134
135struct PreparedExperimentCase {
136    family_name: &'static str,
137    map: MovingAiMap,
138    scenarios: Vec<MovingAiScenario>,
139    a_star: AStarPlanner,
140    jps: JPSPlanner,
141    buckets: &'static [u32],
142}
143
144pub fn current_runtime_process_problem() -> Vec<RuntimeExperimentCase> {
145    vec![
146        RuntimeExperimentCase {
147            family_name: "arena2",
148            map_str: include_str!("../../../benchdata/moving_ai/dao/arena2.map"),
149            scen_str: include_str!("../../../benchdata/moving_ai/dao/arena2.map.scen"),
150            buckets: &[86, 87, 88, 89, 90],
151        },
152        RuntimeExperimentCase {
153            family_name: "8room_000",
154            map_str: include_str!("../../../benchdata/moving_ai/room/8room_000.map"),
155            scen_str: include_str!("../../../benchdata/moving_ai/room/8room_000.map.scen"),
156            buckets: &[80, 85, 90],
157        },
158        RuntimeExperimentCase {
159            family_name: "random512-10-0",
160            map_str: include_str!("../../../benchdata/moving_ai/random/random512-10-0.map"),
161            scen_str: include_str!("../../../benchdata/moving_ai/random/random512-10-0.map.scen"),
162            buckets: &[130, 132, 135],
163        },
164        RuntimeExperimentCase {
165            family_name: "Berlin_0_512",
166            map_str: include_str!("../../../benchdata/moving_ai/street/Berlin_0_512.map"),
167            scen_str: include_str!("../../../benchdata/moving_ai/street/Berlin_0_512.map.scen"),
168            buckets: &[120, 140, 160, 186],
169        },
170    ]
171}
172
173pub fn long_tail_runtime_process_problem() -> Vec<RuntimeExperimentCase> {
174    vec![
175        RuntimeExperimentCase {
176            family_name: "arena2",
177            map_str: include_str!("../../../benchdata/moving_ai/dao/arena2.map"),
178            scen_str: include_str!("../../../benchdata/moving_ai/dao/arena2.map.scen"),
179            buckets: &[90],
180        },
181        RuntimeExperimentCase {
182            family_name: "8room_000",
183            map_str: include_str!("../../../benchdata/moving_ai/room/8room_000.map"),
184            scen_str: include_str!("../../../benchdata/moving_ai/room/8room_000.map.scen"),
185            buckets: &[120, 160, 213],
186        },
187        RuntimeExperimentCase {
188            family_name: "random512-10-0",
189            map_str: include_str!("../../../benchdata/moving_ai/random/random512-10-0.map"),
190            scen_str: include_str!("../../../benchdata/moving_ai/random/random512-10-0.map.scen"),
191            buckets: &[140, 177],
192        },
193        RuntimeExperimentCase {
194            family_name: "maze512-1-0",
195            map_str: include_str!("../../../benchdata/moving_ai/maze/maze512-1-0.map"),
196            scen_str: include_str!("../../../benchdata/moving_ai/maze/maze512-1-0.map.scen"),
197            buckets: &[200, 800, 1211],
198        },
199        RuntimeExperimentCase {
200            family_name: "Berlin_0_512",
201            map_str: include_str!("../../../benchdata/moving_ai/street/Berlin_0_512.map"),
202            scen_str: include_str!("../../../benchdata/moving_ai/street/Berlin_0_512.map.scen"),
203            buckets: &[160, 186],
204        },
205    ]
206}
207
208pub fn synthetic_runtime_process_problem() -> Vec<RuntimeExperimentCase> {
209    static CASES: OnceLock<Vec<RuntimeExperimentCase>> = OnceLock::new();
210    CASES
211        .get_or_init(build_synthetic_runtime_process_problem)
212        .clone()
213}
214
215fn build_synthetic_runtime_process_problem() -> Vec<RuntimeExperimentCase> {
216    vec![
217        build_synthetic_runtime_case(
218            "synthetic_open_50x50",
219            "synthetic_open_50x50.map",
220            synthetic_open_tiles(),
221        ),
222        build_synthetic_runtime_case(
223            "synthetic_maze_50x50",
224            "synthetic_maze_50x50.map",
225            synthetic_maze_tiles(),
226        ),
227        build_synthetic_runtime_case(
228            "synthetic_dense_50x50",
229            "synthetic_dense_50x50.map",
230            synthetic_dense_tiles(),
231        ),
232    ]
233}
234
235fn build_synthetic_runtime_case(
236    family_name: &'static str,
237    map_name: &'static str,
238    tiles: Vec<Vec<char>>,
239) -> RuntimeExperimentCase {
240    let map_str = leak_string(serialize_synthetic_moving_ai_map(&tiles));
241    let scen_str = leak_string(build_synthetic_scenario_payload(map_name, map_str));
242    RuntimeExperimentCase {
243        family_name,
244        map_str,
245        scen_str,
246        buckets: SYNTHETIC_BUCKETS,
247    }
248}
249
250fn leak_string(content: String) -> &'static str {
251    Box::leak(content.into_boxed_str())
252}
253
254fn synthetic_open_tiles() -> Vec<Vec<char>> {
255    vec![vec!['.'; 50]; 50]
256}
257
258fn synthetic_maze_tiles() -> Vec<Vec<char>> {
259    let mut tiles = synthetic_open_tiles();
260
261    let vertical_x = [10, 10, 10, 15, 20, 20, 30, 30, 35, 30, 40, 45];
262    let vertical_y = [10, 30, 45, 20, 5, 40, 10, 40, 5, 40, 10, 25];
263    let vertical_len = [10, 10, 5, 10, 10, 5, 20, 10, 25, 10, 35, 15];
264    for ((x, y), len) in vertical_x
265        .iter()
266        .zip(vertical_y.iter())
267        .zip(vertical_len.iter())
268    {
269        draw_vertical_bar(&mut tiles, *x as usize, *y as usize, *len as usize);
270    }
271
272    let horizontal_x = [35, 40, 15, 10, 45, 20, 10, 15, 25, 45, 10, 30, 10, 40];
273    let horizontal_y = [5, 10, 15, 20, 20, 25, 30, 35, 35, 35, 40, 40, 45, 45];
274    let horizontal_len = [10, 5, 10, 10, 5, 5, 10, 5, 10, 5, 10, 5, 5, 5];
275    for ((x, y), len) in horizontal_x
276        .iter()
277        .zip(horizontal_y.iter())
278        .zip(horizontal_len.iter())
279    {
280        draw_horizontal_bar(&mut tiles, *x as usize, *y as usize, *len as usize);
281    }
282
283    tiles
284}
285
286fn synthetic_dense_tiles() -> Vec<Vec<char>> {
287    let mut tiles = synthetic_open_tiles();
288    for (y, row) in tiles.iter_mut().enumerate() {
289        for (x, tile) in row.iter_mut().enumerate() {
290            let hash =
291                ((x as u64).wrapping_mul(2654435761) ^ (y as u64).wrapping_mul(2246822519)) % 100;
292            if hash < 18 && !(x <= 1 || y <= 1 || x >= 48 || y >= 48) {
293                *tile = '@';
294            }
295        }
296    }
297    tiles
298}
299
300fn draw_vertical_bar(tiles: &mut [Vec<char>], x: usize, start_y: usize, len: usize) {
301    for y in start_y..start_y.saturating_add(len) {
302        if let Some(tile) = tiles.get_mut(y).and_then(|row| row.get_mut(x)) {
303            *tile = '@';
304        }
305    }
306}
307
308fn draw_horizontal_bar(tiles: &mut [Vec<char>], start_x: usize, y: usize, len: usize) {
309    if let Some(row) = tiles.get_mut(y) {
310        for x in start_x..start_x.saturating_add(len) {
311            if let Some(tile) = row.get_mut(x) {
312                *tile = '@';
313            }
314        }
315    }
316}
317
318fn serialize_synthetic_moving_ai_map(tiles: &[Vec<char>]) -> String {
319    let height = tiles.len();
320    let width = tiles.first().map_or(0, Vec::len);
321    let mut rows = Vec::with_capacity(height + 4);
322    rows.push("type octile".to_string());
323    rows.push(format!("height {height}"));
324    rows.push(format!("width {width}"));
325    rows.push("map".to_string());
326    for row in tiles {
327        rows.push(row.iter().collect());
328    }
329    rows.join("\n")
330}
331
332#[derive(Debug, Clone)]
333struct SyntheticScenarioSeed {
334    start_x: usize,
335    start_y: usize,
336    goal_x: usize,
337    goal_y: usize,
338    optimal_length: f64,
339}
340
341fn build_synthetic_scenario_payload(map_name: &str, map_payload: &str) -> String {
342    let map = MovingAiMap::parse_str(map_payload)
343        .unwrap_or_else(|_| panic!("{map_name} synthetic map should parse"));
344    let planner = AStarPlanner::from_obstacle_points(
345        &map.to_obstacles(),
346        AStarConfig {
347            resolution: 1.0,
348            robot_radius: 0.5,
349            heuristic_weight: 1.0,
350        },
351    )
352    .unwrap_or_else(|_| panic!("A* planner should build for synthetic map {map_name}"));
353
354    let candidates = synthetic_candidate_cells(&map);
355    let mut seeds = Vec::new();
356    for (index, &(start_x, start_y)) in candidates.iter().enumerate() {
357        for &(goal_x, goal_y) in candidates.iter().skip(index + 1) {
358            let Ok(path) = planner.plan(
359                map.planning_point(start_x, start_y)
360                    .expect("synthetic start should be valid"),
361                map.planning_point(goal_x, goal_y)
362                    .expect("synthetic goal should be valid"),
363            ) else {
364                continue;
365            };
366            seeds.push(SyntheticScenarioSeed {
367                start_x,
368                start_y,
369                goal_x,
370                goal_y,
371                optimal_length: path.total_length(),
372            });
373        }
374    }
375
376    assert!(
377        seeds.len() >= SYNTHETIC_BUCKETS.len() * SYNTHETIC_SCENARIOS_PER_BUCKET,
378        "synthetic scenario generation for {map_name} needs at least {} solved pairs, got {}",
379        SYNTHETIC_BUCKETS.len() * SYNTHETIC_SCENARIOS_PER_BUCKET,
380        seeds.len()
381    );
382
383    seeds.sort_by(|left, right| {
384        left.optimal_length
385            .partial_cmp(&right.optimal_length)
386            .unwrap()
387            .then_with(|| left.start_x.cmp(&right.start_x))
388            .then_with(|| left.start_y.cmp(&right.start_y))
389            .then_with(|| left.goal_x.cmp(&right.goal_x))
390            .then_with(|| left.goal_y.cmp(&right.goal_y))
391    });
392
393    let mut lines = vec!["version 1".to_string()];
394    for (bucket_index, &bucket_id) in SYNTHETIC_BUCKETS.iter().enumerate() {
395        let start = bucket_index * seeds.len() / SYNTHETIC_BUCKETS.len();
396        let end = (bucket_index + 1) * seeds.len() / SYNTHETIC_BUCKETS.len();
397        let selected =
398            evenly_spaced_scenario_seeds(&seeds[start..end], SYNTHETIC_SCENARIOS_PER_BUCKET);
399        for seed in selected {
400            lines.push(format!(
401                "{} {} {} {} {} {} {} {} {:.8}",
402                bucket_id,
403                map_name,
404                map.width,
405                map.height,
406                seed.start_x,
407                seed.start_y,
408                seed.goal_x,
409                seed.goal_y,
410                seed.optimal_length
411            ));
412        }
413    }
414
415    lines.join("\n")
416}
417
418fn synthetic_candidate_cells(map: &MovingAiMap) -> Vec<(usize, usize)> {
419    let mut cells = Vec::new();
420    for &y in SYNTHETIC_PROBE_COORDS {
421        for &x in SYNTHETIC_PROBE_COORDS {
422            if x >= map.width || y >= map.height {
423                continue;
424            }
425            if map
426                .is_passable(x, y)
427                .expect("synthetic probe cell should be in-bounds")
428            {
429                cells.push((x, y));
430            }
431        }
432    }
433    cells
434}
435
436fn evenly_spaced_scenario_seeds(
437    seeds: &[SyntheticScenarioSeed],
438    count: usize,
439) -> Vec<&SyntheticScenarioSeed> {
440    assert!(
441        seeds.len() >= count,
442        "synthetic bucket needs at least {count} seeds, got {}",
443        seeds.len()
444    );
445    if seeds.len() == count {
446        return seeds.iter().collect();
447    }
448    if count == 1 {
449        return vec![&seeds[seeds.len() / 2]];
450    }
451
452    let mut selected = Vec::with_capacity(count);
453    for index in 0..count {
454        let seed_index = index * (seeds.len() - 1) / (count - 1);
455        selected.push(&seeds[seed_index]);
456    }
457    selected
458}
459
460pub fn default_runtime_variants() -> Vec<Box<dyn RuntimeAggregationVariant>> {
461    vec![
462        Box::new(FirstScenarioRuntimeAggregation::new()),
463        Box::new(SampledBucketRuntimeAggregation::new(vec![0, 4, 9])),
464        Box::new(PercentileBucketRuntimeAggregation::new(vec![
465            0.0, 0.25, 0.5, 0.75, 1.0,
466        ])),
467        Box::new(VarianceTriggeredRuntimeAggregation::new(
468            vec![0, 4, 9],
469            0.10,
470        )),
471        Box::new(FullBucketRuntimeAggregation::new()),
472    ]
473}
474
475pub fn run_variant_suite(
476    variants: &[Box<dyn RuntimeAggregationVariant>],
477    cases: &[RuntimeExperimentCase],
478    config: RuntimeEvaluationConfig,
479) -> Vec<RuntimeVariantReport> {
480    let prepared_cases: Vec<PreparedExperimentCase> = cases.iter().map(prepare_case).collect();
481    let mut reports = Vec::with_capacity(variants.len());
482
483    for variant in variants {
484        let started = Instant::now();
485        let mut observations = Vec::new();
486        for case in &prepared_cases {
487            for &bucket in case.buckets {
488                observations.push(measure_bucket_observation(
489                    &**variant,
490                    case,
491                    bucket,
492                    config.iterations_per_scenario,
493                ));
494            }
495        }
496        let descriptor = variant.descriptor();
497        let source_metrics = read_source_metrics(std::path::Path::new(descriptor.source_path))
498            .expect("experiment source metrics should be readable");
499        let average_coverage_ratio = average_coverage_ratio(&observations);
500        let extensibility_metrics = ExtensibilityMetrics {
501            average_coverage_ratio,
502            knob_count: descriptor.knob_count,
503            reports_dispersion: descriptor.reports_dispersion,
504        };
505        reports.push(RuntimeVariantReport {
506            descriptor,
507            evaluation_runtime_ms: started.elapsed().as_secs_f64() * 1000.0,
508            observations,
509            source_metrics,
510            extensibility_metrics,
511            agreement_vs_reference: None,
512            mean_ratio_error_vs_reference: None,
513        });
514    }
515
516    annotate_reference_reports(&mut reports, "full-bucket");
517    reports
518}
519
520fn prepare_case(case: &RuntimeExperimentCase) -> PreparedExperimentCase {
521    let map = MovingAiMap::parse_str(case.map_str)
522        .unwrap_or_else(|_| panic!("{} map should parse", case.family_name));
523    let scenarios = MovingAiScenario::parse_str(case.scen_str)
524        .unwrap_or_else(|_| panic!("{} scenarios should parse", case.family_name));
525    let obstacles = map.to_obstacles();
526    let a_star = AStarPlanner::from_obstacle_points(
527        &obstacles,
528        AStarConfig {
529            resolution: 1.0,
530            robot_radius: 0.5,
531            heuristic_weight: 1.0,
532        },
533    )
534    .unwrap_or_else(|_| panic!("A* planner should build for {}", case.family_name));
535    let jps = JPSPlanner::from_obstacle_points(
536        &obstacles,
537        JPSConfig {
538            resolution: 1.0,
539            robot_radius: 0.5,
540            heuristic_weight: 1.0,
541        },
542    )
543    .unwrap_or_else(|_| panic!("JPS planner should build for {}", case.family_name));
544
545    PreparedExperimentCase {
546        family_name: case.family_name,
547        map,
548        scenarios,
549        a_star,
550        jps,
551        buckets: case.buckets,
552    }
553}
554
555fn measure_bucket_observation(
556    variant: &dyn RuntimeAggregationVariant,
557    case: &PreparedExperimentCase,
558    bucket: u32,
559    iterations: usize,
560) -> RuntimeObservation {
561    let bucket_scenarios: Vec<&MovingAiScenario> = case
562        .scenarios
563        .iter()
564        .filter(|scenario| scenario.bucket == bucket)
565        .collect();
566    assert!(
567        !bucket_scenarios.is_empty(),
568        "{} bucket {} should exist",
569        case.family_name,
570        bucket
571    );
572    let plan = variant.sampling_plan(bucket_scenarios.len());
573    let initial_slots = normalize_slots(bucket_scenarios.len(), &plan.initial_slots);
574    assert!(
575        !initial_slots.is_empty(),
576        "{} bucket {} should select at least one scenario",
577        case.family_name,
578        bucket
579    );
580
581    let mut slot_samples =
582        measure_slot_samples(&bucket_scenarios, &initial_slots, case, bucket, iterations);
583    let mut selected_slots = initial_slots.clone();
584    let mut escalated = false;
585    let escalation_slots = normalize_slots(bucket_scenarios.len(), &plan.escalation_slots);
586
587    if should_escalate(&slot_samples, &plan) {
588        let additional_slots: Vec<usize> = escalation_slots
589            .into_iter()
590            .filter(|slot| !selected_slots.contains(slot))
591            .collect();
592        if !additional_slots.is_empty() {
593            slot_samples.extend(measure_slot_samples(
594                &bucket_scenarios,
595                &additional_slots,
596                case,
597                bucket,
598                iterations,
599            ));
600            selected_slots.extend(additional_slots);
601            selected_slots.sort_unstable();
602            escalated = true;
603        }
604    }
605
606    let a_star_samples: Vec<f64> = slot_samples.iter().map(|sample| sample.a_star_us).collect();
607    let jps_samples: Vec<f64> = slot_samples.iter().map(|sample| sample.jps_us).collect();
608    let jps_wins = slot_samples
609        .iter()
610        .filter(|sample| sample.jps_us < sample.a_star_us)
611        .count();
612
613    RuntimeObservation {
614        family_name: case.family_name,
615        bucket,
616        total_scenarios: bucket_scenarios.len(),
617        initial_slots,
618        selected_slots,
619        escalated,
620        a_star_bucket_median_us: median_value(&a_star_samples),
621        jps_bucket_median_us: median_value(&jps_samples),
622        a_star_min_us: min_value(&a_star_samples),
623        a_star_max_us: max_value(&a_star_samples),
624        jps_min_us: min_value(&jps_samples),
625        jps_max_us: max_value(&jps_samples),
626        jps_wins,
627    }
628}
629
630#[derive(Debug, Clone, Copy)]
631struct SlotRuntimeSample {
632    a_star_us: f64,
633    jps_us: f64,
634}
635
636fn measure_slot_samples(
637    bucket_scenarios: &[&MovingAiScenario],
638    slots: &[usize],
639    case: &PreparedExperimentCase,
640    bucket: u32,
641    iterations: usize,
642) -> Vec<SlotRuntimeSample> {
643    let mut samples = Vec::with_capacity(slots.len());
644    for slot in slots {
645        let scenario = bucket_scenarios[*slot];
646        let start = case
647            .map
648            .planning_point(scenario.start_x, scenario.start_y)
649            .unwrap_or_else(|_| panic!("{} start should be valid", case.family_name));
650        let goal = case
651            .map
652            .planning_point(scenario.goal_x, scenario.goal_y)
653            .unwrap_or_else(|_| panic!("{} goal should be valid", case.family_name));
654        warmup_and_assert(
655            &case.a_star,
656            start,
657            goal,
658            scenario.optimal_length,
659            case.family_name,
660            bucket,
661            *slot,
662            "A*",
663        );
664        warmup_and_assert(
665            &case.jps,
666            start,
667            goal,
668            scenario.optimal_length,
669            case.family_name,
670            bucket,
671            *slot,
672            "JPS",
673        );
674        let a_star_us = measure_planner_median_us(iterations, || {
675            let path = case.a_star.plan(start, goal).unwrap_or_else(|_| {
676                panic!("A* should solve {} bucket {}", case.family_name, bucket)
677            });
678            assert!(
679                (path.total_length() - scenario.optimal_length).abs() <= 1e-6,
680                "A* path length should match MovingAI optimal on {} bucket {} slot {}",
681                case.family_name,
682                bucket,
683                slot
684            );
685            path
686        });
687        let jps_us = measure_planner_median_us(iterations, || {
688            let path = case.jps.plan(start, goal).unwrap_or_else(|_| {
689                panic!("JPS should solve {} bucket {}", case.family_name, bucket)
690            });
691            assert!(
692                (path.total_length() - scenario.optimal_length).abs() <= 1e-6,
693                "JPS path length should match MovingAI optimal on {} bucket {} slot {}",
694                case.family_name,
695                bucket,
696                slot
697            );
698            path
699        });
700        samples.push(SlotRuntimeSample { a_star_us, jps_us });
701    }
702    samples
703}
704
705fn should_escalate(slot_samples: &[SlotRuntimeSample], plan: &RuntimeSamplingPlan) -> bool {
706    if slot_samples.is_empty() || plan.escalation_slots.is_empty() {
707        return false;
708    }
709
710    let vote_split = {
711        let jps_wins = slot_samples
712            .iter()
713            .filter(|sample| sample.jps_us < sample.a_star_us)
714            .count();
715        jps_wins > 0 && jps_wins < slot_samples.len()
716    };
717    let ratio_close = plan
718        .escalate_if_ratio_margin_below
719        .map(|threshold| {
720            let a_star_samples: Vec<f64> =
721                slot_samples.iter().map(|sample| sample.a_star_us).collect();
722            let jps_samples: Vec<f64> = slot_samples.iter().map(|sample| sample.jps_us).collect();
723            (median_value(&a_star_samples) / median_value(&jps_samples) - 1.0).abs() < threshold
724        })
725        .unwrap_or(false);
726
727    (plan.escalate_if_vote_split && vote_split) || ratio_close
728}
729
730fn normalize_slots(total_scenarios: usize, slots: &[usize]) -> Vec<usize> {
731    let mut normalized = slots
732        .iter()
733        .copied()
734        .filter(|slot| *slot < total_scenarios)
735        .collect::<Vec<_>>();
736    normalized.sort_unstable();
737    normalized.dedup();
738    normalized
739}
740
741#[allow(clippy::too_many_arguments)]
742fn warmup_and_assert(
743    planner: &impl PlannerRuntime,
744    start: Point2D,
745    goal: Point2D,
746    optimal_length: f64,
747    family_name: &str,
748    bucket: u32,
749    slot: usize,
750    label: &str,
751) {
752    let path = planner.plan_path(start, goal).unwrap_or_else(|_| {
753        panic!("{label} should solve {family_name} bucket {bucket} slot {slot}")
754    });
755    assert!(
756        (path.total_length() - optimal_length).abs() <= 1e-6,
757        "{label} warmup path should match MovingAI optimal on {family_name} bucket {bucket} slot {slot}"
758    );
759}
760
761trait PlannerRuntime {
762    fn plan_path(&self, start: Point2D, goal: Point2D) -> Result<Path2D, ()>;
763}
764
765impl PlannerRuntime for AStarPlanner {
766    fn plan_path(&self, start: Point2D, goal: Point2D) -> Result<Path2D, ()> {
767        self.plan(start, goal).map_err(|_| ())
768    }
769}
770
771impl PlannerRuntime for JPSPlanner {
772    fn plan_path(&self, start: Point2D, goal: Point2D) -> Result<Path2D, ()> {
773        self.plan(start, goal).map_err(|_| ())
774    }
775}
776
777fn measure_planner_median_us<F>(iterations: usize, mut plan: F) -> f64
778where
779    F: FnMut() -> Path2D,
780{
781    let mut samples = Vec::with_capacity(iterations);
782    for _ in 0..iterations {
783        let started = Instant::now();
784        let path = plan();
785        std::hint::black_box(path.points.len());
786        std::hint::black_box(path.total_length());
787        samples.push(started.elapsed().as_secs_f64() * 1_000_000.0);
788    }
789    median_value(&samples)
790}
791
792fn median_value(samples: &[f64]) -> f64 {
793    assert!(
794        !samples.is_empty(),
795        "median_value requires at least one sample"
796    );
797    let mut sorted = samples.to_vec();
798    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
799    sorted[sorted.len() / 2]
800}
801
802fn min_value(samples: &[f64]) -> f64 {
803    samples.iter().copied().fold(f64::INFINITY, f64::min)
804}
805
806fn max_value(samples: &[f64]) -> f64 {
807    samples.iter().copied().fold(f64::NEG_INFINITY, f64::max)
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813
814    #[test]
815    fn first_variant_selects_one_slot() {
816        let variant = FirstScenarioRuntimeAggregation::new();
817        assert_eq!(variant.selected_slots(10), vec![0]);
818    }
819
820    #[test]
821    fn sampled_variant_clamps_to_valid_slots() {
822        let variant = SampledBucketRuntimeAggregation::new(vec![0, 4, 9]);
823        assert_eq!(variant.selected_slots(3), vec![0]);
824        assert_eq!(variant.selected_slots(10), vec![0, 4, 9]);
825    }
826
827    #[test]
828    fn full_variant_selects_everything() {
829        let variant = FullBucketRuntimeAggregation::new();
830        assert_eq!(variant.selected_slots(4), vec![0, 1, 2, 3]);
831    }
832
833    #[test]
834    fn percentile_variant_maps_percentiles_to_unique_slots() {
835        let variant = PercentileBucketRuntimeAggregation::new(vec![0.0, 0.25, 0.5, 0.75, 1.0]);
836        assert_eq!(variant.selected_slots(10), vec![0, 2, 5, 7, 9]);
837        assert_eq!(variant.selected_slots(1), vec![0]);
838    }
839
840    #[test]
841    fn variance_variant_builds_adaptive_sampling_plan() {
842        let variant = VarianceTriggeredRuntimeAggregation::new(vec![0, 4, 9], 0.15);
843        let plan = variant.sampling_plan(10);
844        assert_eq!(plan.initial_slots, vec![0, 4, 9]);
845        assert_eq!(plan.escalation_slots, (0..10).collect::<Vec<_>>());
846        assert!(plan.escalate_if_vote_split);
847        assert_eq!(plan.escalate_if_ratio_margin_below, Some(0.15));
848    }
849
850    #[test]
851    fn adaptive_plan_escalates_on_vote_split() {
852        let plan = RuntimeSamplingPlan {
853            initial_slots: vec![0, 4, 9],
854            escalation_slots: (0..10).collect(),
855            escalate_if_vote_split: true,
856            escalate_if_ratio_margin_below: None,
857        };
858        let slot_samples = vec![
859            SlotRuntimeSample {
860                a_star_us: 10.0,
861                jps_us: 9.0,
862            },
863            SlotRuntimeSample {
864                a_star_us: 10.0,
865                jps_us: 11.0,
866            },
867        ];
868        assert!(should_escalate(&slot_samples, &plan));
869    }
870
871    #[test]
872    fn adaptive_plan_escalates_on_small_ratio_margin() {
873        let plan = RuntimeSamplingPlan {
874            initial_slots: vec![0, 4, 9],
875            escalation_slots: (0..10).collect(),
876            escalate_if_vote_split: false,
877            escalate_if_ratio_margin_below: Some(0.15),
878        };
879        let slot_samples = vec![
880            SlotRuntimeSample {
881                a_star_us: 10.0,
882                jps_us: 9.8,
883            },
884            SlotRuntimeSample {
885                a_star_us: 10.2,
886                jps_us: 10.1,
887            },
888            SlotRuntimeSample {
889                a_star_us: 9.9,
890                jps_us: 10.0,
891            },
892        ];
893        assert!(should_escalate(&slot_samples, &plan));
894    }
895}