1use nav_math::Position;
19
20pub struct ReferenceRoute {
23 pub name: &'static str,
26 pub from: Position,
28 pub to: Position,
30}
31
32pub const REFERENCE_ROUTES: &[ReferenceRoute] = &[
67 ReferenceRoute {
68 name: "short hop, open Gulf of Trieste",
69 from: Position::new(45.60, 13.55),
70 to: Position::new(45.65, 13.55),
71 },
72 ReferenceRoute {
73 name: "short hop, near-shore Gulf of Trieste",
74 from: Position::new(45.55, 13.62),
75 to: Position::new(45.60, 13.55),
76 },
77 ReferenceRoute {
78 name: "medium, across the gulf toward Istria",
79 from: Position::new(45.65, 13.55),
80 to: Position::new(45.45, 13.20),
81 },
82 ReferenceRoute {
83 name: "around Istria (moderate)",
84 from: Position::new(45.60, 13.65),
85 to: Position::new(43.58, 13.60),
86 },
87 ReferenceRoute {
88 name: "around Istria (DEPARE-heavy)",
89 from: Position::new(45.70, 13.65),
90 to: Position::new(42.50, 14.50),
91 },
92 ReferenceRoute {
93 name: "Naples to Palermo",
94 from: Position::new(40.75, 14.20),
95 to: Position::new(38.25, 13.20),
96 },
97 ReferenceRoute {
98 name: "Bari to Brindisi",
99 from: Position::new(41.10, 17.10),
100 to: Position::new(40.65, 18.15),
101 },
102 ReferenceRoute {
103 name: "Chioggia to Koper",
104 from: Position::new(45.15, 12.35),
105 to: Position::new(45.55, 13.65),
106 },
107 ReferenceRoute {
108 name: "Palermo to Olbia",
109 from: Position::new(38.19, 13.40),
110 to: Position::new(40.90, 9.65),
111 },
112 ReferenceRoute {
113 name: "Salerno to Napoli",
114 from: Position::new(40.62, 14.78),
115 to: Position::new(40.78, 14.27),
116 },
117 ReferenceRoute {
118 name: "Marina di Campo (Elba) to La Spezia",
119 from: Position::new(42.70, 10.24),
120 to: Position::new(44.02, 9.80),
121 },
122 ReferenceRoute {
123 name: "La Maddalena to Porto Cervo",
124 from: Position::new(41.19, 9.43),
125 to: Position::new(41.10, 9.60),
126 },
127 ReferenceRoute {
128 name: "Licata to Catania",
129 from: Position::new(37.05, 13.85),
130 to: Position::new(37.45, 15.20),
131 },
132 ReferenceRoute {
133 name: "Piran to Izola",
134 from: Position::new(45.5450, 13.5550),
135 to: Position::new(45.5650, 13.6550),
136 },
137 ReferenceRoute {
138 name: "Izola to Koper",
139 from: Position::new(45.5650, 13.6550),
140 to: Position::new(45.5520, 13.7150),
141 },
142 ReferenceRoute {
143 name: "Koper to Trieste",
144 from: Position::new(45.5520, 13.7150),
145 to: Position::new(45.6300, 13.7300),
146 },
147 ReferenceRoute {
148 name: "Trieste to Muggia",
149 from: Position::new(45.6300, 13.7300),
150 to: Position::new(45.6150, 13.7500),
151 },
152 ReferenceRoute {
153 name: "Trieste to Grado",
154 from: Position::new(45.6300, 13.7300),
155 to: Position::new(45.6000, 13.5200),
156 },
157 ReferenceRoute {
158 name: "Grado to Lignano",
159 from: Position::new(45.6000, 13.5200),
160 to: Position::new(45.6500, 13.2000),
161 },
162 ReferenceRoute {
163 name: "Lignano to Caorle",
164 from: Position::new(45.6500, 13.2000),
165 to: Position::new(45.5700, 12.9500),
166 },
167 ReferenceRoute {
168 name: "Caorle to Venezia",
169 from: Position::new(45.5700, 12.9500),
170 to: Position::new(45.4000, 12.5500),
171 },
172 ReferenceRoute {
173 name: "Venezia to Chioggia",
174 from: Position::new(45.4000, 12.5500),
175 to: Position::new(45.1500, 12.3500),
176 },
177 ReferenceRoute {
178 name: "Chioggia to Ravenna",
179 from: Position::new(45.1500, 12.3500),
180 to: Position::new(44.4600, 12.4000),
181 },
182 ReferenceRoute {
183 name: "Ravenna to Rimini",
184 from: Position::new(44.4600, 12.4000),
185 to: Position::new(44.0600, 12.6100),
186 },
187 ReferenceRoute {
188 name: "Rimini to Ancona",
189 from: Position::new(44.0600, 12.6100),
190 to: Position::new(43.6500, 13.6500),
191 },
192 ReferenceRoute {
193 name: "Koper to Venezia",
194 from: Position::new(45.5520, 13.7150),
195 to: Position::new(45.4000, 12.5500),
196 },
197];
198
199#[derive(Clone, Copy)]
203pub struct BaselineEntry {
204 pub route_nm: f64,
206 pub elapsed_ms: u128,
208}
209
210pub type Baseline = std::collections::HashMap<String, BaselineEntry>;
213
214const DISTANCE_REGRESSION_RELATIVE: f64 = 0.01;
225
226const DISTANCE_REGRESSION_FLOOR_NM: f64 = 0.05;
232
233const TIME_REGRESSION_RELATIVE: f64 = 0.50;
245
246const TIME_REGRESSION_FLOOR_MS: u128 = 100;
252
253#[must_use]
258pub fn distance_regressed(baseline_nm: f64, current_nm: f64) -> bool {
259 let allowance = (baseline_nm * DISTANCE_REGRESSION_RELATIVE).max(DISTANCE_REGRESSION_FLOOR_NM);
260 current_nm > baseline_nm + allowance
261}
262
263#[must_use]
268pub fn time_regressed(baseline_ms: u128, current_ms: u128) -> bool {
269 #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
270 let allowance_ms = ((baseline_ms as f64) * TIME_REGRESSION_RELATIVE).max(TIME_REGRESSION_FLOOR_MS as f64) as u128;
271 current_ms > baseline_ms + allowance_ms
272}
273
274fn parse_baseline_line(line: &str) -> Option<(String, BaselineEntry)> {
283 let mut fields = line.split('\t');
284 let name = fields.next()?;
285 let route_nm: f64 = fields.next()?.parse().ok()?;
286 let elapsed_ms: u128 = fields.next()?.parse().ok()?;
287 Some((name.to_string(), BaselineEntry { route_nm, elapsed_ms }))
288}
289
290pub fn load_baseline(path: &std::path::Path) -> std::io::Result<Baseline> {
299 let text = match std::fs::read_to_string(path) {
300 Ok(text) => text,
301 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Baseline::new()),
302 Err(error) => return Err(error),
303 };
304 Ok(text
305 .lines()
306 .map(str::trim)
307 .filter(|line| !line.is_empty() && !line.starts_with('#'))
308 .filter_map(parse_baseline_line)
309 .collect())
310}
311
312pub fn write_baseline(path: &std::path::Path, entries: &[(String, BaselineEntry)]) -> std::io::Result<()> {
322 let mut text = String::from(
323 "# enc-check bench baseline -- generated by `enc-check bench --write-baseline`; do not hand-edit\n",
324 );
325 for (name, entry) in entries {
326 text.push_str(&format!("{name}\t{:.2}\t{}\n", entry.route_nm, entry.elapsed_ms));
327 }
328 std::fs::write(path, text)
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn distance_regressed_ignores_noise_within_the_margin() {
337 assert!(!distance_regressed(100.0, 100.9), "well within the 1% relative margin");
338 assert!(!distance_regressed(2.0, 2.04), "within the absolute floor for a short route");
339 }
340
341 #[test]
342 fn distance_regressed_catches_real_growth() {
343 assert!(distance_regressed(100.0, 105.0), "5% growth on a long route is a real regression");
344 assert!(distance_regressed(2.0, 2.10), "growth past the absolute floor on a short route");
345 }
346
347 #[test]
348 fn distance_regressed_never_flags_a_shorter_route() {
349 assert!(!distance_regressed(100.0, 50.0));
350 }
351
352 #[test]
353 fn time_regressed_ignores_noise_within_the_margin() {
354 assert!(!time_regressed(20_000, 21_000), "5% is well within the 50% relative margin");
355 assert!(!time_regressed(90, 130), "within the absolute floor for a fast route");
356 }
357
358 #[test]
359 fn time_regressed_catches_real_slowdown() {
360 assert!(time_regressed(13_500, 90_000), "a real regression this crate has seen before");
361 }
362
363 #[test]
364 fn time_regressed_never_flags_a_faster_run() {
365 assert!(!time_regressed(20_000, 5_000));
366 }
367
368 #[test]
369 fn parse_baseline_line_reads_a_well_formed_line() {
370 let (name, entry) = parse_baseline_line("Licata to Catania\t118.32\t23491").unwrap();
371 assert_eq!(name, "Licata to Catania");
372 assert!((entry.route_nm - 118.32).abs() < 1e-9);
373 assert_eq!(entry.elapsed_ms, 23491);
374 }
375
376 #[test]
377 fn parse_baseline_line_rejects_a_malformed_line() {
378 assert!(parse_baseline_line("only one field").is_none());
379 assert!(parse_baseline_line("name\tnot-a-number\t123").is_none());
380 }
381
382 #[test]
383 fn load_baseline_of_a_missing_file_is_empty_not_an_error() {
384 let path = std::path::Path::new("/nonexistent/enc-check-bench-baseline-test.tsv");
385 let baseline = load_baseline(path).expect("a missing file is not an error");
386 assert!(baseline.is_empty());
387 }
388
389 #[test]
390 fn write_then_load_baseline_round_trips() {
391 let path = std::env::temp_dir().join(format!("enc-check-bench-baseline-test-{}.tsv", std::process::id()));
392 let entries = vec![
393 ("first route".to_string(), BaselineEntry { route_nm: 12.34, elapsed_ms: 567 }),
394 ("second route".to_string(), BaselineEntry { route_nm: 89.0, elapsed_ms: 1234 }),
395 ];
396
397 write_baseline(&path, &entries).unwrap();
398 let loaded = load_baseline(&path).unwrap();
399
400 assert_eq!(loaded.len(), 2);
401 for (name, entry) in &entries {
402 let round_tripped = loaded.get(name).unwrap();
403 assert!((round_tripped.route_nm - entry.route_nm).abs() < 1e-9);
404 assert_eq!(round_tripped.elapsed_ms, entry.elapsed_ms);
405 }
406
407 let _ = std::fs::remove_file(&path);
408 }
409
410 #[test]
411 fn load_baseline_skips_comments_and_blank_lines() {
412 let path = std::env::temp_dir().join(format!("enc-check-bench-baseline-comment-test-{}.tsv", std::process::id()));
413 std::fs::write(&path, "# a comment\n\nreal route\t1.00\t100\n").unwrap();
414
415 let baseline = load_baseline(&path).unwrap();
416 assert_eq!(baseline.len(), 1);
417 assert!(baseline.contains_key("real route"));
418
419 let _ = std::fs::remove_file(&path);
420 }
421}