Skip to main content

navcore_enc_check/
bench.rs

1//! Reference routes for the `bench` subcommand: a fixed set of
2//! positions in Slovenian and Italian waters, checked against a real
3//! chart on every run, for measuring the effect of a change to
4//! `route-find`'s search or its caches.
5//!
6//! Every position is verified against a real chart as open, safe
7//! water -- a reference case sitting on a rock or in charted shallows
8//! would only prove itself broken, not detect a regression. A
9//! zero-length `enc-check leg --from P --to P` probe cannot verify a
10//! candidate point reliably: a degenerate leg has no corridor band for
11//! `check_leg` to test, so it reports "nothing found" for any
12//! position, land included. Instead, each candidate point is checked
13//! with a short real leg (a tenth of a nautical mile) in a few
14//! directions; `route-find`'s own `Hazards::blocked` (private to that
15//! crate) tests a small square around the point directly, without
16//! this gap.
17
18use nav_math::Position;
19
20/// One route `bench` measures on every run: a name, and the two
21/// positions [`route_find::find_route`] searches between.
22pub struct ReferenceRoute {
23    /// What this case is meant to exercise, for the table `bench`
24    /// prints.
25    pub name: &'static str,
26    /// Where the route starts.
27    pub from: Position,
28    /// Where the route ends.
29    pub to: Position,
30}
31
32/// A spread of distances and difficulty; each entry tests something
33/// specific:
34///
35/// - the two short routes are the baseline case: lattice search only,
36///   at the low end of wall-clock time;
37/// - the medium route sits between the short and long cases, still
38///   lattice-only, at real cross-gulf scale;
39/// - "around Istria (moderate)" is a long case the lattice's own
40///   margin escalation resolves without the visibility-graph
41///   fallback -- worth checking if a future change makes it need that
42///   fallback;
43/// - "around Istria (DEPARE-heavy)" is the hardest lattice-only case,
44///   exercising `fetch_preferring_detail`'s cost against dense
45///   `DEPARE` coverage; the most regression-sensitive figure in this
46///   list;
47/// - Naples-Palermo, Bari-Brindisi, and Chioggia-Koper extend the
48///   geographic spread beyond the northern Adriatic, each a real,
49///   direct or near-direct route;
50/// - Palermo-Olbia, Salerno-Napoli, and Marina di Campo (Elba)-La
51///   Spezia extend it further: open-sea crossings and short coastal
52///   hops;
53/// - "La Maddalena to Porto Cervo" is short (about 10 NM) but tests
54///   the archipelago it crosses: dense small `DEPARE` tiles there
55///   make `route_find::visibility::significant_shallow_areas`'s own
56///   `geo::unary_union` slow if fetched over too wide an area, rather
57///   than the tight area `find_route`'s own margin schedule actually
58///   uses;
59/// - "Licata to Catania" is the hardest included case: rounding
60///   Sicily's Cape Passero requires the visibility-graph fallback's
61///   basemap-derived corners, adaptive edge subdivision, and per-edge
62///   bisection together. Tens of seconds on a real chart, slower than
63///   every other case here; a future change that makes this
64///   meaningfully slower, or shifts the route's shape away from
65///   hugging the cape, is worth noticing.
66pub 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/// One [`ReferenceRoute`]'s own previously measured result, against which
200/// `bench` compares a later run's own result to decide whether it
201/// regressed -- see [`distance_regressed`] and [`time_regressed`].
202#[derive(Clone, Copy)]
203pub struct BaselineEntry {
204    /// The route length measured by `run_bench`, in NM.
205    pub route_nm: f64,
206    /// How long [`route_find::find_route`] took, in milliseconds.
207    pub elapsed_ms: u128,
208}
209
210/// Every [`ReferenceRoute`]'s own [`BaselineEntry`], keyed by
211/// [`ReferenceRoute::name`].
212pub type Baseline = std::collections::HashMap<String, BaselineEntry>;
213
214/// How much a route may grow, relative to its own baseline length,
215/// before [`distance_regressed`] reports a regression -- whichever is
216/// larger, this or [`DISTANCE_REGRESSION_FLOOR_NM`].
217///
218/// Route length is deterministic for a given chart and code version,
219/// so any growth is technically a real change. One percent allows for
220/// a legitimate tweak (a smoothing tolerance, a lattice cell size)
221/// shifting a route slightly, while remaining far below what an
222/// actual regression in this crate produces -- a route lengthening
223/// from roughly 118 NM to 695 NM, not from 118 to 119.
224const DISTANCE_REGRESSION_RELATIVE: f64 = 0.01;
225
226/// The absolute floor [`distance_regressed`] applies alongside
227/// [`DISTANCE_REGRESSION_RELATIVE`], in NM, so a short hop's own tiny
228/// baseline (the two short Gulf of Trieste cases measure a bare 3-4 NM)
229/// does not make one percent tighter than floating-point noise can
230/// actually promise.
231const DISTANCE_REGRESSION_FLOOR_NM: f64 = 0.05;
232
233/// How much slower a route may run, relative to its own baseline
234/// time, before [`time_regressed`] reports a regression -- whichever
235/// is larger, this or [`TIME_REGRESSION_FLOOR_MS`].
236///
237/// Measured rather than assumed: running this suite twice in a row on
238/// the same machine varies each case's time by at most a few percent,
239/// even the slowest case (Licata to Catania, tens of seconds). Fifty
240/// percent stays comfortably above that margin, with room for a
241/// shared CI runner's noisier hardware, while still catching an
242/// actual regression in this crate -- a sevenfold slowdown, not a
243/// fifty-percent one.
244const TIME_REGRESSION_RELATIVE: f64 = 0.50;
245
246/// The absolute floor [`time_regressed`] applies alongside
247/// [`TIME_REGRESSION_RELATIVE`], in milliseconds, so the two fastest
248/// cases here (the short Gulf of Trieste hops, under 100 ms each) are
249/// not held to a tighter margin in absolute terms than scheduler jitter
250/// alone can already account for.
251const TIME_REGRESSION_FLOOR_MS: u128 = 100;
252
253/// Whether `current_nm` counts as a regression against `baseline_nm` --
254/// see [`DISTANCE_REGRESSION_RELATIVE`] and [`DISTANCE_REGRESSION_FLOOR_NM`]
255/// for the margin. A route that got *shorter* never regresses here,
256/// whatever the reason.
257#[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/// Whether `current_ms` counts as a regression against `baseline_ms` --
264/// see [`TIME_REGRESSION_RELATIVE`] and [`TIME_REGRESSION_FLOOR_MS`] for
265/// the margin. A run that got *faster* never regresses here, whatever
266/// the reason.
267#[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
274/// The one-line, tab-separated format [`load_baseline`] reads and
275/// [`write_baseline`] writes: `name\troute_nm\telapsed_ms`, one
276/// [`ReferenceRoute`] per line, blank lines and `#`-comments ignored.
277///
278/// Hand-rolled rather than reached for a serialisation crate for the
279/// same reason `hazards::restricts_entry`'s own doc gives for reading
280/// `RESTRN` codes as digits instead of JSON: three plain fields split on
281/// a tab is not a format worth a dependency to read.
282fn 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
290/// Loads a [`Baseline`] from `path`. A missing file reads as an empty
291/// baseline, not an error -- the ordinary state before `bench` has ever
292/// been run with `--write-baseline`, or for a [`ReferenceRoute`] just
293/// added and not yet measured.
294///
295/// # Errors
296///
297/// If `path` exists but cannot be read.
298pub 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
312/// Writes `entries` to `path` as [`parse_baseline_line`]'s own format, in
313/// [`REFERENCE_ROUTES`]'s own order rather than whatever order a
314/// [`Baseline`]'s own hash map would iterate in -- so the file reads the
315/// same top-to-bottom as `bench`'s own printed table, and so a diff
316/// between two baselines is a diff between two runs, not shuffled noise.
317///
318/// # Errors
319///
320/// If `path` cannot be written.
321pub 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}