1mod 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}