Skip to main content

rustial_engine/
regression.rs

1//! Tile-pipeline regression harness utilities.
2//!
3//! This module provides a small, dependency-free regression harness for the
4//! HTTP raster tile pipeline.  It serves two purposes:
5//!
6//! 1. Parse the authoritative route fixture in `docs/debug/rustial_debug_values.csv`.
7//! 2. Capture comparable per-frame telemetry from [`MapState`](crate::MapState)
8//!    and evaluate it against configurable thresholds.
9//!
10//! The CSV schema is intentionally machine-comparable and stable so tests,
11//! scripted runs, and benchmarks can exchange the same sample format.
12
13use crate::{layers::TileLayer, MapState, TilePipelineDiagnostics, WebMercator};
14use thiserror::Error;
15
16/// CSV header emitted by [`TilePipelineRegressionSample::to_csv`].
17pub const TILE_PIPELINE_REGRESSION_CSV_HEADER: &str = "sample_index,sample_image,fps,zoom_level,zoom_pct,pitch_deg,yaw_deg,distance_m,center_lat,center_lon,viewport_width_km,mercator_world_width_km,full_world_x,layer_name,desired_tiles,raw_candidate_tiles,loaded_tiles,visible_tiles,exact_visible_tiles,fallback_visible_tiles,missing_visible_tiles,overzoomed_visible_tiles,requested_tiles,exact_cache_hits,cache_misses,cancelled_stale_pending,budget_hit,dropped_by_budget,cache_total_entries,cache_loaded_entries,cache_expired_entries,cache_reloading_entries,cache_pending_entries,cache_failed_entries,cache_renderable_entries,queued_requests,in_flight_requests,max_concurrent_requests,known_requests,cancelled_in_flight_requests,counter_frames,counter_requested_tiles,counter_exact_cache_hits,counter_fallback_hits,counter_cache_misses,counter_cancelled_stale_pending,counter_cancelled_evicted_pending";
18
19/// One machine-comparable tile-pipeline telemetry sample.
20#[derive(Debug, Clone, PartialEq)]
21pub struct TilePipelineRegressionSample {
22    /// Sequential sample identifier.
23    pub sample_index: usize,
24    /// Optional image or frame label associated with the sample.
25    pub sample_image: String,
26    /// Frames-per-second recorded for the sample.
27    pub fps: f64,
28    /// Integer zoom level used for tile selection.
29    pub zoom_level: u8,
30    /// Fractional zoom remainder expressed as a percentage in `[0, 100]`.
31    pub zoom_pct: u8,
32    /// Camera pitch in degrees.
33    pub pitch_deg: f64,
34    /// Camera yaw / bearing in degrees.
35    pub yaw_deg: f64,
36    /// Camera distance from the target in meters.
37    pub distance_m: f64,
38    /// Camera target latitude in degrees.
39    pub center_lat: f64,
40    /// Camera target longitude in degrees.
41    pub center_lon: f64,
42    /// Width of the current viewport footprint in Mercator kilometers.
43    pub viewport_width_km: f64,
44    /// Total Web Mercator world width in kilometers.
45    pub mercator_world_width_km: f64,
46    /// Whether the viewport spans the full wrapped world width.
47    pub full_world_x: bool,
48    /// Name of the tile layer that produced the sample.
49    pub layer_name: String,
50    /// Number of desired source tiles considered for the current view.
51    pub desired_tiles: usize,
52    /// Number of raw selection candidates before budget capping.
53    pub raw_candidate_tiles: usize,
54    /// Number of visible tiles with imagery currently loaded.
55    pub loaded_tiles: usize,
56    /// Number of visible tiles emitted for the frame.
57    pub visible_tiles: usize,
58    /// Number of exact visible tiles.
59    pub exact_visible_tiles: usize,
60    /// Number of visible tiles rendered via fallback imagery.
61    pub fallback_visible_tiles: usize,
62    /// Number of visible tiles still missing imagery.
63    pub missing_visible_tiles: usize,
64    /// Number of visible overzoomed tiles.
65    pub overzoomed_visible_tiles: usize,
66    /// Number of requests issued in the current frame.
67    pub requested_tiles: usize,
68    /// Number of exact cache hits in the current frame.
69    pub exact_cache_hits: usize,
70    /// Number of cache misses in the current frame.
71    pub cache_misses: usize,
72    /// Number of stale pending requests cancelled in the current frame.
73    pub cancelled_stale_pending: usize,
74    /// Whether the visible-tile budget was hit this frame.
75    pub budget_hit: bool,
76    /// Number of candidate tiles dropped by the visible budget.
77    pub dropped_by_budget: usize,
78    /// Total number of cache entries.
79    pub cache_total_entries: usize,
80    /// Number of loaded cache entries.
81    pub cache_loaded_entries: usize,
82    /// Number of expired cache entries.
83    pub cache_expired_entries: usize,
84    /// Number of reloading cache entries.
85    pub cache_reloading_entries: usize,
86    /// Number of pending cache entries.
87    pub cache_pending_entries: usize,
88    /// Number of failed cache entries.
89    pub cache_failed_entries: usize,
90    /// Number of renderable cache entries.
91    pub cache_renderable_entries: usize,
92    /// Number of queued source requests.
93    pub queued_requests: usize,
94    /// Number of in-flight source requests.
95    pub in_flight_requests: usize,
96    /// Maximum allowed concurrent source requests.
97    pub max_concurrent_requests: usize,
98    /// Number of transport-known requests.
99    pub known_requests: usize,
100    /// Number of forced cancellations for already in-flight requests.
101    pub cancelled_in_flight_requests: usize,
102    /// Cumulative frame count.
103    pub counter_frames: u64,
104    /// Cumulative requested tile count.
105    pub counter_requested_tiles: u64,
106    /// Cumulative exact cache hits.
107    pub counter_exact_cache_hits: u64,
108    /// Cumulative fallback hits.
109    pub counter_fallback_hits: u64,
110    /// Cumulative cache misses.
111    pub counter_cache_misses: u64,
112    /// Cumulative stale cancellations.
113    pub counter_cancelled_stale_pending: u64,
114    /// Cumulative evicted-pending cancellations.
115    pub counter_cancelled_evicted_pending: u64,
116}
117
118impl TilePipelineRegressionSample {
119    /// Capture a regression sample from the first visible tile layer in `state`.
120    ///
121    /// Returns `None` when no visible tile layer is active or the map has not
122    /// yet produced tile-pipeline diagnostics.
123    pub fn capture_from_map_state(
124        state: &MapState,
125        sample_index: usize,
126        sample_image: impl Into<String>,
127        fps: f64,
128    ) -> Option<Self> {
129        let diagnostics = state.tile_pipeline_diagnostics()?;
130        let desired_tiles = first_visible_tile_layer(state)?.desired_tiles().len();
131        Some(Self::from_state_parts(
132            state,
133            diagnostics,
134            desired_tiles,
135            sample_index,
136            sample_image.into(),
137            fps,
138        ))
139    }
140
141    fn from_state_parts(
142        state: &MapState,
143        diagnostics: TilePipelineDiagnostics,
144        desired_tiles: usize,
145        sample_index: usize,
146        sample_image: String,
147        fps: f64,
148    ) -> Self {
149        let camera = state.camera();
150        let viewport_bounds = state.viewport_bounds();
151        let viewport_width_km =
152            (viewport_bounds.max.position.x - viewport_bounds.min.position.x).abs() / 1_000.0;
153        let mercator_world_width_km = (2.0 * WebMercator::max_extent()) / 1_000.0;
154        let full_world_x = viewport_width_km >= mercator_world_width_km;
155        let zoom_level = state.zoom_level();
156        let zoom_fraction = (state.fractional_zoom() - zoom_level as f64).clamp(0.0, 0.999_999);
157        let zoom_pct = (zoom_fraction * 100.0).round().clamp(0.0, 99.0) as u8;
158        let source = diagnostics.source_diagnostics.unwrap_or_default();
159
160        Self {
161            sample_index,
162            sample_image,
163            fps,
164            zoom_level,
165            zoom_pct,
166            pitch_deg: camera.pitch().to_degrees(),
167            yaw_deg: camera.yaw().to_degrees(),
168            distance_m: camera.distance(),
169            center_lat: camera.target().lat,
170            center_lon: camera.target().lon,
171            viewport_width_km,
172            mercator_world_width_km,
173            full_world_x,
174            layer_name: diagnostics.layer_name,
175            desired_tiles,
176            raw_candidate_tiles: diagnostics.selection_stats.raw_candidate_tiles,
177            loaded_tiles: diagnostics.visible_loaded_tiles,
178            visible_tiles: diagnostics.visible_tiles,
179            exact_visible_tiles: diagnostics.selection_stats.exact_visible_tiles,
180            fallback_visible_tiles: diagnostics.visible_fallback_tiles,
181            missing_visible_tiles: diagnostics.visible_missing_tiles,
182            overzoomed_visible_tiles: diagnostics.visible_overzoomed_tiles,
183            requested_tiles: diagnostics.selection_stats.requested_tiles,
184            exact_cache_hits: diagnostics.selection_stats.exact_cache_hits,
185            cache_misses: diagnostics.selection_stats.cache_misses,
186            cancelled_stale_pending: diagnostics.selection_stats.cancelled_stale_pending,
187            budget_hit: diagnostics.selection_stats.budget_hit,
188            dropped_by_budget: diagnostics.selection_stats.dropped_by_budget,
189            cache_total_entries: diagnostics.cache_stats.total_entries,
190            cache_loaded_entries: diagnostics.cache_stats.loaded_entries,
191            cache_expired_entries: diagnostics.cache_stats.expired_entries,
192            cache_reloading_entries: diagnostics.cache_stats.reloading_entries,
193            cache_pending_entries: diagnostics.cache_stats.pending_entries,
194            cache_failed_entries: diagnostics.cache_stats.failed_entries,
195            cache_renderable_entries: diagnostics.cache_stats.renderable_entries,
196            queued_requests: source.queued_requests,
197            in_flight_requests: source.in_flight_requests,
198            max_concurrent_requests: source.max_concurrent_requests,
199            known_requests: source.known_requests,
200            cancelled_in_flight_requests: source.cancelled_in_flight_requests,
201            counter_frames: diagnostics.counters.frames,
202            counter_requested_tiles: diagnostics.counters.requested_tiles,
203            counter_exact_cache_hits: diagnostics.counters.exact_cache_hits,
204            counter_fallback_hits: diagnostics.counters.fallback_hits,
205            counter_cache_misses: diagnostics.counters.cache_misses,
206            counter_cancelled_stale_pending: diagnostics.counters.cancelled_stale_pending,
207            counter_cancelled_evicted_pending: diagnostics.counters.cancelled_evicted_pending,
208        }
209    }
210
211    /// Parse a sequence of samples from CSV text.
212    ///
213    /// This accepts both the reduced harness schema emitted by [`to_csv`](Self::to_csv)
214    /// and the checked-in `docs/debug/rustial_debug_values.csv` fixture. When the
215    /// fixture is parsed, `selected_tiles` is mapped into [`desired_tiles`](Self::desired_tiles).
216    pub fn parse_csv(input: &str) -> Result<Vec<Self>, TilePipelineRegressionParseError> {
217        let mut lines = input.lines().filter(|line| !line.trim().is_empty());
218        let Some(header_line) = lines.next() else {
219            return Ok(Vec::new());
220        };
221        let header = split_csv_line(header_line)?;
222
223        let sample_index_col = find_column(&header, &["sample_index"])?;
224        let sample_image_col = find_column(&header, &["sample_image"])?;
225        let fps_col = find_column(&header, &["fps"])?;
226        let zoom_level_col = find_column(&header, &["zoom_level"])?;
227        let zoom_pct_col = find_column(&header, &["zoom_pct"])?;
228        let pitch_deg_col = find_column(&header, &["pitch_deg"])?;
229        let yaw_deg_col = find_column(&header, &["yaw_deg"])?;
230        let distance_m_col = find_column(&header, &["distance_m"])?;
231        let center_lat_col = find_column(&header, &["center_lat"])?;
232        let center_lon_col = find_column(&header, &["center_lon"])?;
233        let viewport_width_km_col = find_column(&header, &["viewport_width_km"])?;
234        let mercator_world_width_km_col = find_column(&header, &["mercator_world_width_km"])?;
235        let full_world_x_col = find_column(&header, &["full_world_x"])?;
236        let layer_name_col = find_column(&header, &["layer_name"])?;
237        let desired_tiles_col = find_column(&header, &["desired_tiles", "selected_tiles"])?;
238        let raw_candidate_tiles_col = find_column(&header, &["raw_candidate_tiles"])?;
239        let loaded_tiles_col = find_column(&header, &["loaded_tiles"])?;
240        let visible_tiles_col = find_column(&header, &["visible_tiles"])?;
241        let exact_visible_tiles_col = find_column(&header, &["exact_visible_tiles"])?;
242        let fallback_visible_tiles_col = find_column(&header, &["fallback_visible_tiles"])?;
243        let missing_visible_tiles_col = find_column(&header, &["missing_visible_tiles"])?;
244        let overzoomed_visible_tiles_col = find_column(&header, &["overzoomed_visible_tiles"])?;
245        let requested_tiles_col = find_column(&header, &["requested_tiles"])?;
246        let exact_cache_hits_col = find_column(&header, &["exact_cache_hits"])?;
247        let cache_misses_col = find_column(&header, &["cache_misses"])?;
248        let cancelled_stale_pending_col = find_column(&header, &["cancelled_stale_pending"])?;
249        let budget_hit_col = find_column(&header, &["budget_hit"])?;
250        let dropped_by_budget_col = find_column(&header, &["dropped_by_budget"])?;
251        let cache_total_entries_col = find_column(&header, &["cache_total_entries"])?;
252        let cache_loaded_entries_col = find_column(&header, &["cache_loaded_entries"])?;
253        let cache_expired_entries_col = find_column(&header, &["cache_expired_entries"])?;
254        let cache_reloading_entries_col = find_column(&header, &["cache_reloading_entries"])?;
255        let cache_pending_entries_col = find_column(&header, &["cache_pending_entries"])?;
256        let cache_failed_entries_col = find_column(&header, &["cache_failed_entries"])?;
257        let cache_renderable_entries_col = find_column(&header, &["cache_renderable_entries"])?;
258        let queued_requests_col = find_column(&header, &["queued_requests"])?;
259        let in_flight_requests_col = find_column(&header, &["in_flight_requests"])?;
260        let max_concurrent_requests_col = find_column(&header, &["max_concurrent_requests"])?;
261        let known_requests_col = find_column(&header, &["known_requests"])?;
262        let cancelled_in_flight_requests_col = find_column(&header, &["cancelled_in_flight_requests"])?;
263        let counter_frames_col = find_column(&header, &["counter_frames"])?;
264        let counter_requested_tiles_col = find_column(&header, &["counter_requested_tiles"])?;
265        let counter_exact_cache_hits_col = find_column(&header, &["counter_exact_cache_hits"])?;
266        let counter_fallback_hits_col = find_column(&header, &["counter_fallback_hits"])?;
267        let counter_cache_misses_col = find_column(&header, &["counter_cache_misses"])?;
268        let counter_cancelled_stale_pending_col = find_column(&header, &["counter_cancelled_stale_pending"])?;
269        let counter_cancelled_evicted_pending_col = find_column(&header, &["counter_cancelled_evicted_pending"])?;
270
271        let mut samples = Vec::new();
272        for (line_index, line) in lines.enumerate() {
273            let row_number = line_index + 2;
274            let row = split_csv_line(line).map_err(|err| err.with_row(row_number))?;
275            samples.push(Self {
276                sample_index: parse_usize(field(&row, sample_index_col, row_number)?, row_number, "sample_index")?,
277                sample_image: field(&row, sample_image_col, row_number)?.to_owned(),
278                fps: parse_f64(field(&row, fps_col, row_number)?, row_number, "fps")?,
279                zoom_level: parse_u8(field(&row, zoom_level_col, row_number)?, row_number, "zoom_level")?,
280                zoom_pct: parse_u8(field(&row, zoom_pct_col, row_number)?, row_number, "zoom_pct")?,
281                pitch_deg: parse_f64(field(&row, pitch_deg_col, row_number)?, row_number, "pitch_deg")?,
282                yaw_deg: parse_f64(field(&row, yaw_deg_col, row_number)?, row_number, "yaw_deg")?,
283                distance_m: parse_f64(field(&row, distance_m_col, row_number)?, row_number, "distance_m")?,
284                center_lat: parse_f64(field(&row, center_lat_col, row_number)?, row_number, "center_lat")?,
285                center_lon: parse_f64(field(&row, center_lon_col, row_number)?, row_number, "center_lon")?,
286                viewport_width_km: parse_f64(field(&row, viewport_width_km_col, row_number)?, row_number, "viewport_width_km")?,
287                mercator_world_width_km: parse_f64(field(&row, mercator_world_width_km_col, row_number)?, row_number, "mercator_world_width_km")?,
288                full_world_x: parse_bool(field(&row, full_world_x_col, row_number)?, row_number, "full_world_x")?,
289                layer_name: field(&row, layer_name_col, row_number)?.to_owned(),
290                desired_tiles: parse_usize(field(&row, desired_tiles_col, row_number)?, row_number, "desired_tiles")?,
291                raw_candidate_tiles: parse_usize(field(&row, raw_candidate_tiles_col, row_number)?, row_number, "raw_candidate_tiles")?,
292                loaded_tiles: parse_usize(field(&row, loaded_tiles_col, row_number)?, row_number, "loaded_tiles")?,
293                visible_tiles: parse_usize(field(&row, visible_tiles_col, row_number)?, row_number, "visible_tiles")?,
294                exact_visible_tiles: parse_usize(field(&row, exact_visible_tiles_col, row_number)?, row_number, "exact_visible_tiles")?,
295                fallback_visible_tiles: parse_usize(field(&row, fallback_visible_tiles_col, row_number)?, row_number, "fallback_visible_tiles")?,
296                missing_visible_tiles: parse_usize(field(&row, missing_visible_tiles_col, row_number)?, row_number, "missing_visible_tiles")?,
297                overzoomed_visible_tiles: parse_usize(field(&row, overzoomed_visible_tiles_col, row_number)?, row_number, "overzoomed_visible_tiles")?,
298                requested_tiles: parse_usize(field(&row, requested_tiles_col, row_number)?, row_number, "requested_tiles")?,
299                exact_cache_hits: parse_usize(field(&row, exact_cache_hits_col, row_number)?, row_number, "exact_cache_hits")?,
300                cache_misses: parse_usize(field(&row, cache_misses_col, row_number)?, row_number, "cache_misses")?,
301                cancelled_stale_pending: parse_usize(field(&row, cancelled_stale_pending_col, row_number)?, row_number, "cancelled_stale_pending")?,
302                budget_hit: parse_bool(field(&row, budget_hit_col, row_number)?, row_number, "budget_hit")?,
303                dropped_by_budget: parse_usize(field(&row, dropped_by_budget_col, row_number)?, row_number, "dropped_by_budget")?,
304                cache_total_entries: parse_usize(field(&row, cache_total_entries_col, row_number)?, row_number, "cache_total_entries")?,
305                cache_loaded_entries: parse_usize(field(&row, cache_loaded_entries_col, row_number)?, row_number, "cache_loaded_entries")?,
306                cache_expired_entries: parse_usize(field(&row, cache_expired_entries_col, row_number)?, row_number, "cache_expired_entries")?,
307                cache_reloading_entries: parse_usize(field(&row, cache_reloading_entries_col, row_number)?, row_number, "cache_reloading_entries")?,
308                cache_pending_entries: parse_usize(field(&row, cache_pending_entries_col, row_number)?, row_number, "cache_pending_entries")?,
309                cache_failed_entries: parse_usize(field(&row, cache_failed_entries_col, row_number)?, row_number, "cache_failed_entries")?,
310                cache_renderable_entries: parse_usize(field(&row, cache_renderable_entries_col, row_number)?, row_number, "cache_renderable_entries")?,
311                queued_requests: parse_usize(field(&row, queued_requests_col, row_number)?, row_number, "queued_requests")?,
312                in_flight_requests: parse_usize(field(&row, in_flight_requests_col, row_number)?, row_number, "in_flight_requests")?,
313                max_concurrent_requests: parse_usize(field(&row, max_concurrent_requests_col, row_number)?, row_number, "max_concurrent_requests")?,
314                known_requests: parse_usize(field(&row, known_requests_col, row_number)?, row_number, "known_requests")?,
315                cancelled_in_flight_requests: parse_usize(field(&row, cancelled_in_flight_requests_col, row_number)?, row_number, "cancelled_in_flight_requests")?,
316                counter_frames: parse_u64(field(&row, counter_frames_col, row_number)?, row_number, "counter_frames")?,
317                counter_requested_tiles: parse_u64(field(&row, counter_requested_tiles_col, row_number)?, row_number, "counter_requested_tiles")?,
318                counter_exact_cache_hits: parse_u64(field(&row, counter_exact_cache_hits_col, row_number)?, row_number, "counter_exact_cache_hits")?,
319                counter_fallback_hits: parse_u64(field(&row, counter_fallback_hits_col, row_number)?, row_number, "counter_fallback_hits")?,
320                counter_cache_misses: parse_u64(field(&row, counter_cache_misses_col, row_number)?, row_number, "counter_cache_misses")?,
321                counter_cancelled_stale_pending: parse_u64(field(&row, counter_cancelled_stale_pending_col, row_number)?, row_number, "counter_cancelled_stale_pending")?,
322                counter_cancelled_evicted_pending: parse_u64(field(&row, counter_cancelled_evicted_pending_col, row_number)?, row_number, "counter_cancelled_evicted_pending")?,
323            });
324        }
325
326        Ok(samples)
327    }
328
329    /// Serialize samples into machine-comparable CSV text.
330    pub fn to_csv(samples: &[Self]) -> String {
331        let mut out = String::new();
332        out.push_str(TILE_PIPELINE_REGRESSION_CSV_HEADER);
333        for sample in samples {
334            out.push('\n');
335            out.push_str(&sample.to_csv_row());
336        }
337        out
338    }
339
340    /// Serialize this sample as one CSV row.
341    pub fn to_csv_row(&self) -> String {
342        format!(
343            "{sample_index},{sample_image},{fps:.3},{zoom_level},{zoom_pct},{pitch_deg:.3},{yaw_deg:.3},{distance_m:.3},{center_lat:.6},{center_lon:.6},{viewport_width_km:.3},{mercator_world_width_km:.3},{full_world_x},{layer_name},{desired_tiles},{raw_candidate_tiles},{loaded_tiles},{visible_tiles},{exact_visible_tiles},{fallback_visible_tiles},{missing_visible_tiles},{overzoomed_visible_tiles},{requested_tiles},{exact_cache_hits},{cache_misses},{cancelled_stale_pending},{budget_hit},{dropped_by_budget},{cache_total_entries},{cache_loaded_entries},{cache_expired_entries},{cache_reloading_entries},{cache_pending_entries},{cache_failed_entries},{cache_renderable_entries},{queued_requests},{in_flight_requests},{max_concurrent_requests},{known_requests},{cancelled_in_flight_requests},{counter_frames},{counter_requested_tiles},{counter_exact_cache_hits},{counter_fallback_hits},{counter_cache_misses},{counter_cancelled_stale_pending},{counter_cancelled_evicted_pending}",
344            sample_index = self.sample_index,
345            sample_image = quote_csv(&self.sample_image),
346            fps = self.fps,
347            zoom_level = self.zoom_level,
348            zoom_pct = self.zoom_pct,
349            pitch_deg = self.pitch_deg,
350            yaw_deg = self.yaw_deg,
351            distance_m = self.distance_m,
352            center_lat = self.center_lat,
353            center_lon = self.center_lon,
354            viewport_width_km = self.viewport_width_km,
355            mercator_world_width_km = self.mercator_world_width_km,
356            full_world_x = self.full_world_x,
357            layer_name = quote_csv(&self.layer_name),
358            desired_tiles = self.desired_tiles,
359            raw_candidate_tiles = self.raw_candidate_tiles,
360            loaded_tiles = self.loaded_tiles,
361            visible_tiles = self.visible_tiles,
362            exact_visible_tiles = self.exact_visible_tiles,
363            fallback_visible_tiles = self.fallback_visible_tiles,
364            missing_visible_tiles = self.missing_visible_tiles,
365            overzoomed_visible_tiles = self.overzoomed_visible_tiles,
366            requested_tiles = self.requested_tiles,
367            exact_cache_hits = self.exact_cache_hits,
368            cache_misses = self.cache_misses,
369            cancelled_stale_pending = self.cancelled_stale_pending,
370            budget_hit = self.budget_hit,
371            dropped_by_budget = self.dropped_by_budget,
372            cache_total_entries = self.cache_total_entries,
373            cache_loaded_entries = self.cache_loaded_entries,
374            cache_expired_entries = self.cache_expired_entries,
375            cache_reloading_entries = self.cache_reloading_entries,
376            cache_pending_entries = self.cache_pending_entries,
377            cache_failed_entries = self.cache_failed_entries,
378            cache_renderable_entries = self.cache_renderable_entries,
379            queued_requests = self.queued_requests,
380            in_flight_requests = self.in_flight_requests,
381            max_concurrent_requests = self.max_concurrent_requests,
382            known_requests = self.known_requests,
383            cancelled_in_flight_requests = self.cancelled_in_flight_requests,
384            counter_frames = self.counter_frames,
385            counter_requested_tiles = self.counter_requested_tiles,
386            counter_exact_cache_hits = self.counter_exact_cache_hits,
387            counter_fallback_hits = self.counter_fallback_hits,
388            counter_cache_misses = self.counter_cache_misses,
389            counter_cancelled_stale_pending = self.counter_cancelled_stale_pending,
390            counter_cancelled_evicted_pending = self.counter_cancelled_evicted_pending,
391        )
392    }
393}
394
395/// Aggregate statistics computed across a sequence of regression samples.
396#[derive(Debug, Clone, Default, PartialEq, Eq)]
397pub struct TilePipelineRegressionSummary {
398    /// Total number of samples analyzed.
399    pub total_samples: usize,
400    /// Longest consecutive run with `exact_visible_tiles == 0`.
401    pub longest_exact_free_run: usize,
402    /// Longest consecutive run with `missing_visible_tiles > 0`.
403    pub longest_missing_run: usize,
404    /// Longest consecutive run with all visible coverage coming from fallback.
405    pub longest_fallback_only_run: usize,
406    /// Maximum missing visible tiles observed in any sample.
407    pub max_missing_visible_tiles: usize,
408    /// Maximum queued requests observed in any sample.
409    pub max_queued_requests: usize,
410    /// Maximum pending cache entries observed in any sample.
411    pub max_cache_pending_entries: usize,
412    /// Maximum failed cache entries observed in any sample.
413    pub max_cache_failed_entries: usize,
414    /// Maximum cumulative stale-cancellation counter observed in any sample.
415    pub max_counter_cancelled_stale_pending: u64,
416    /// Number of samples where the request pool was fully saturated.
417    pub saturated_request_pool_samples: usize,
418}
419
420impl TilePipelineRegressionSummary {
421    /// Compute aggregate statistics across `samples`.
422    pub fn from_samples(samples: &[TilePipelineRegressionSample]) -> Self {
423        let mut summary = Self {
424            total_samples: samples.len(),
425            ..Self::default()
426        };
427
428        let mut exact_free_run = 0usize;
429        let mut missing_run = 0usize;
430        let mut fallback_only_run = 0usize;
431
432        for sample in samples {
433            if sample.visible_tiles > 0 && sample.exact_visible_tiles == 0 {
434                exact_free_run += 1;
435                summary.longest_exact_free_run = summary.longest_exact_free_run.max(exact_free_run);
436            } else {
437                exact_free_run = 0;
438            }
439
440            if sample.missing_visible_tiles > 0 {
441                missing_run += 1;
442                summary.longest_missing_run = summary.longest_missing_run.max(missing_run);
443            } else {
444                missing_run = 0;
445            }
446
447            if sample.visible_tiles > 0
448                && sample.exact_visible_tiles == 0
449                && sample.fallback_visible_tiles > 0
450                && sample.missing_visible_tiles == 0
451            {
452                fallback_only_run += 1;
453                summary.longest_fallback_only_run =
454                    summary.longest_fallback_only_run.max(fallback_only_run);
455            } else {
456                fallback_only_run = 0;
457            }
458
459            summary.max_missing_visible_tiles = summary
460                .max_missing_visible_tiles
461                .max(sample.missing_visible_tiles);
462            summary.max_queued_requests = summary.max_queued_requests.max(sample.queued_requests);
463            summary.max_cache_pending_entries = summary
464                .max_cache_pending_entries
465                .max(sample.cache_pending_entries);
466            summary.max_cache_failed_entries = summary
467                .max_cache_failed_entries
468                .max(sample.cache_failed_entries);
469            summary.max_counter_cancelled_stale_pending = summary
470                .max_counter_cancelled_stale_pending
471                .max(sample.counter_cancelled_stale_pending);
472            if sample.max_concurrent_requests > 0
473                && sample.in_flight_requests >= sample.max_concurrent_requests
474            {
475                summary.saturated_request_pool_samples += 1;
476            }
477        }
478
479        summary
480    }
481}
482
483/// Thresholds used to fail a regression run when behavior degrades.
484#[derive(Debug, Clone, Default, PartialEq, Eq)]
485pub struct TilePipelineRegressionThresholds {
486    /// Maximum allowed consecutive exact-free samples.
487    pub max_exact_free_run: Option<usize>,
488    /// Maximum allowed consecutive missing-coverage samples.
489    pub max_missing_run: Option<usize>,
490    /// Maximum allowed consecutive fallback-only samples.
491    pub max_fallback_only_run: Option<usize>,
492    /// Maximum allowed missing visible tiles in any sample.
493    pub max_missing_visible_tiles: Option<usize>,
494    /// Maximum allowed queued requests.
495    pub max_queued_requests: Option<usize>,
496    /// Maximum allowed pending cache entries.
497    pub max_cache_pending_entries: Option<usize>,
498    /// Maximum allowed failed cache entries.
499    pub max_cache_failed_entries: Option<usize>,
500    /// Maximum allowed cumulative stale cancellations.
501    pub max_counter_cancelled_stale_pending: Option<u64>,
502}
503
504impl TilePipelineRegressionThresholds {
505    /// Evaluate `samples` against the configured thresholds.
506    pub fn evaluate(
507        &self,
508        samples: &[TilePipelineRegressionSample],
509    ) -> TilePipelineRegressionEvaluation {
510        let summary = TilePipelineRegressionSummary::from_samples(samples);
511        let mut violations = Vec::new();
512
513        push_violation_usize(
514            &mut violations,
515            "longest_exact_free_run",
516            summary.longest_exact_free_run,
517            self.max_exact_free_run,
518        );
519        push_violation_usize(
520            &mut violations,
521            "longest_missing_run",
522            summary.longest_missing_run,
523            self.max_missing_run,
524        );
525        push_violation_usize(
526            &mut violations,
527            "longest_fallback_only_run",
528            summary.longest_fallback_only_run,
529            self.max_fallback_only_run,
530        );
531        push_violation_usize(
532            &mut violations,
533            "max_missing_visible_tiles",
534            summary.max_missing_visible_tiles,
535            self.max_missing_visible_tiles,
536        );
537        push_violation_usize(
538            &mut violations,
539            "max_queued_requests",
540            summary.max_queued_requests,
541            self.max_queued_requests,
542        );
543        push_violation_usize(
544            &mut violations,
545            "max_cache_pending_entries",
546            summary.max_cache_pending_entries,
547            self.max_cache_pending_entries,
548        );
549        push_violation_usize(
550            &mut violations,
551            "max_cache_failed_entries",
552            summary.max_cache_failed_entries,
553            self.max_cache_failed_entries,
554        );
555        push_violation_u64(
556            &mut violations,
557            "max_counter_cancelled_stale_pending",
558            summary.max_counter_cancelled_stale_pending,
559            self.max_counter_cancelled_stale_pending,
560        );
561
562        TilePipelineRegressionEvaluation { summary, violations }
563    }
564}
565
566/// Result of evaluating a regression run against thresholds.
567#[derive(Debug, Clone, Default, PartialEq, Eq)]
568pub struct TilePipelineRegressionEvaluation {
569    /// Aggregate metrics computed from the run.
570    pub summary: TilePipelineRegressionSummary,
571    /// Threshold violations detected for the run.
572    pub violations: Vec<TilePipelineRegressionViolation>,
573}
574
575impl TilePipelineRegressionEvaluation {
576    /// Returns `true` when no thresholds were violated.
577    #[inline]
578    pub fn passed(&self) -> bool {
579        self.violations.is_empty()
580    }
581}
582
583/// One threshold violation reported by the regression harness.
584#[derive(Debug, Clone, PartialEq, Eq)]
585pub struct TilePipelineRegressionViolation {
586    /// Name of the metric that exceeded its threshold.
587    pub metric: &'static str,
588    /// Actual value observed in the run.
589    pub actual: u64,
590    /// Configured maximum allowed value.
591    pub allowed: u64,
592}
593
594/// Parse errors produced by [`TilePipelineRegressionSample::parse_csv`].
595#[derive(Debug, Clone, PartialEq, Eq, Error)]
596pub enum TilePipelineRegressionParseError {
597    /// A required CSV column is missing.
598    #[error("missing required CSV column '{column}'")]
599    MissingColumn {
600        /// Required column name.
601        column: &'static str,
602    },
603    /// A CSV row had fewer fields than expected.
604    #[error("row {row}: missing field '{field}'")]
605    MissingField {
606        /// One-based CSV row number.
607        row: usize,
608        /// Column or field name.
609        field: &'static str,
610    },
611    /// A field could not be parsed into the expected type.
612    #[error("row {row}: invalid value '{value}' for field '{field}'")]
613    InvalidField {
614        /// One-based CSV row number.
615        row: usize,
616        /// Column or field name.
617        field: &'static str,
618        /// Original field text.
619        value: String,
620    },
621    /// CSV syntax error such as an unterminated quote.
622    #[error("row {row}: {message}")]
623    CsvSyntax {
624        /// One-based CSV row number.
625        row: usize,
626        /// Human-readable error message.
627        message: &'static str,
628    },
629}
630
631impl TilePipelineRegressionParseError {
632    fn with_row(self, row: usize) -> Self {
633        match self {
634            Self::CsvSyntax { message, .. } => Self::CsvSyntax { row, message },
635            other => other,
636        }
637    }
638}
639
640fn first_visible_tile_layer(state: &MapState) -> Option<&TileLayer> {
641    state.layers().iter().find_map(|layer| {
642        if !layer.visible() {
643            return None;
644        }
645        layer.as_any().downcast_ref::<TileLayer>()
646    })
647}
648
649fn push_violation_usize(
650    out: &mut Vec<TilePipelineRegressionViolation>,
651    metric: &'static str,
652    actual: usize,
653    allowed: Option<usize>,
654) {
655    if let Some(allowed) = allowed.filter(|allowed| actual > *allowed) {
656        out.push(TilePipelineRegressionViolation {
657            metric,
658            actual: actual as u64,
659            allowed: allowed as u64,
660        });
661    }
662}
663
664fn push_violation_u64(
665    out: &mut Vec<TilePipelineRegressionViolation>,
666    metric: &'static str,
667    actual: u64,
668    allowed: Option<u64>,
669) {
670    if let Some(allowed) = allowed.filter(|allowed| actual > *allowed) {
671        out.push(TilePipelineRegressionViolation {
672            metric,
673            actual,
674            allowed,
675        });
676    }
677}
678
679fn find_column(
680    header: &[String],
681    candidates: &[&'static str],
682) -> Result<usize, TilePipelineRegressionParseError> {
683    header
684        .iter()
685        .position(|field| candidates.iter().any(|candidate| field == candidate))
686        .ok_or(TilePipelineRegressionParseError::MissingColumn {
687            column: candidates[0],
688        })
689}
690
691fn field<'a>(
692    row: &'a [String],
693    index: usize,
694    row_number: usize,
695) -> Result<&'a str, TilePipelineRegressionParseError> {
696    row.get(index)
697        .map(String::as_str)
698        .ok_or(TilePipelineRegressionParseError::MissingField {
699            row: row_number,
700            field: "csv field",
701        })
702}
703
704fn parse_bool(
705    value: &str,
706    row: usize,
707    field: &'static str,
708) -> Result<bool, TilePipelineRegressionParseError> {
709    match value {
710        "true" => Ok(true),
711        "false" => Ok(false),
712        _ => Err(TilePipelineRegressionParseError::InvalidField {
713            row,
714            field,
715            value: value.to_owned(),
716        }),
717    }
718}
719
720fn parse_u8(
721    value: &str,
722    row: usize,
723    field: &'static str,
724) -> Result<u8, TilePipelineRegressionParseError> {
725    value
726        .parse()
727        .map_err(|_| TilePipelineRegressionParseError::InvalidField {
728            row,
729            field,
730            value: value.to_owned(),
731        })
732}
733
734fn parse_usize(
735    value: &str,
736    row: usize,
737    field: &'static str,
738) -> Result<usize, TilePipelineRegressionParseError> {
739    value
740        .parse()
741        .map_err(|_| TilePipelineRegressionParseError::InvalidField {
742            row,
743            field,
744            value: value.to_owned(),
745        })
746}
747
748fn parse_u64(
749    value: &str,
750    row: usize,
751    field: &'static str,
752) -> Result<u64, TilePipelineRegressionParseError> {
753    value
754        .parse()
755        .map_err(|_| TilePipelineRegressionParseError::InvalidField {
756            row,
757            field,
758            value: value.to_owned(),
759        })
760}
761
762fn parse_f64(
763    value: &str,
764    row: usize,
765    field: &'static str,
766) -> Result<f64, TilePipelineRegressionParseError> {
767    value
768        .parse()
769        .map_err(|_| TilePipelineRegressionParseError::InvalidField {
770            row,
771            field,
772            value: value.to_owned(),
773        })
774}
775
776fn split_csv_line(line: &str) -> Result<Vec<String>, TilePipelineRegressionParseError> {
777    let mut fields = Vec::new();
778    let mut current = String::new();
779    let mut chars = line.chars().peekable();
780    let mut in_quotes = false;
781
782    while let Some(ch) = chars.next() {
783        match ch {
784            '"' => {
785                if in_quotes && chars.peek() == Some(&'"') {
786                    current.push('"');
787                    let _ = chars.next();
788                } else {
789                    in_quotes = !in_quotes;
790                }
791            }
792            ',' if !in_quotes => {
793                fields.push(current);
794                current = String::new();
795            }
796            _ => current.push(ch),
797        }
798    }
799
800    if in_quotes {
801        return Err(TilePipelineRegressionParseError::CsvSyntax {
802            row: 1,
803            message: "unterminated quoted field",
804        });
805    }
806
807    fields.push(current);
808    Ok(fields)
809}
810
811fn quote_csv(value: &str) -> String {
812    let escaped = value.replace('"', "\"\"");
813    format!("\"{escaped}\"")
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819    use crate::{
820        layers::TileLayer, DecodedImage, GeoCoord, MapState, TileData, TileResponse, TileSource,
821    };
822    use rustial_math::TileId;
823    use std::sync::Mutex;
824
825    const DEBUG_FIXTURE_CSV: &str = include_str!("../../../docs/debug/rustial_debug_values.csv");
826
827    struct ImmediateRasterSource {
828        ready: Mutex<Vec<(TileId, Result<TileResponse, crate::TileError>)>>,
829    }
830
831    impl ImmediateRasterSource {
832        fn new() -> Self {
833            Self {
834                ready: Mutex::new(Vec::new()),
835            }
836        }
837    }
838
839    impl TileSource for ImmediateRasterSource {
840        fn request(&self, id: TileId) {
841            let data = TileData::Raster(DecodedImage {
842                width: 256,
843                height: 256,
844                data: vec![200u8; 256 * 256 * 4].into(),
845            });
846            self.ready
847                .lock()
848                .expect("ready queue lock")
849                .push((id, Ok(TileResponse::from_data(data))));
850        }
851
852        fn poll(&self) -> Vec<(TileId, Result<TileResponse, crate::TileError>)> {
853            std::mem::take(&mut *self.ready.lock().expect("ready queue lock"))
854        }
855    }
856
857    #[test]
858    fn parse_authoritative_debug_fixture() {
859        let samples = TilePipelineRegressionSample::parse_csv(DEBUG_FIXTURE_CSV)
860            .expect("fixture CSV should parse");
861
862        assert!(samples.len() >= 3);
863        assert_eq!(samples.first().expect("first sample").sample_index, 1);
864        assert_eq!(samples.last().expect("last sample").sample_index, samples.len());
865        assert!(samples.iter().all(|sample| sample.layer_name == "__rustial_builtin_http_tiles"));
866        assert!(samples.iter().all(|sample| sample.sample_index >= 1));
867        assert!(samples.iter().any(|sample| sample.exact_visible_tiles > 0));
868        assert!(samples.iter().map(|sample| sample.missing_visible_tiles).max().unwrap_or(0) <= 40);
869    }
870
871    #[test]
872    fn csv_round_trip_preserves_reduced_schema() {
873        let samples = TilePipelineRegressionSample::parse_csv(DEBUG_FIXTURE_CSV)
874            .expect("fixture CSV should parse");
875        let mid = samples.len() / 2;
876        let trimmed = vec![
877            samples.first().expect("first sample").clone(),
878            samples[mid].clone(),
879            samples.last().expect("last sample").clone(),
880        ];
881
882        let csv = TilePipelineRegressionSample::to_csv(&trimmed);
883        let reparsed = TilePipelineRegressionSample::parse_csv(&csv).expect("round-trip parse");
884
885        assert_eq!(reparsed, trimmed);
886    }
887
888    #[test]
889    fn threshold_evaluation_passes_healthy_fixture() {
890        let samples = TilePipelineRegressionSample::parse_csv(DEBUG_FIXTURE_CSV)
891            .expect("fixture CSV should parse");
892        let thresholds = TilePipelineRegressionThresholds {
893            max_exact_free_run: Some(4),
894            max_fallback_only_run: Some(4),
895            max_missing_visible_tiles: Some(40),
896            ..TilePipelineRegressionThresholds::default()
897        };
898
899        let evaluation = thresholds.evaluate(&samples);
900
901        assert!(evaluation.passed(), "healthy fixture should pass thresholds: {:?}", evaluation.violations);
902        assert!(evaluation.summary.longest_exact_free_run <= 4);
903        assert!(evaluation.summary.max_missing_visible_tiles <= 40);
904    }
905
906    #[test]
907    fn capture_sample_from_map_state() {
908        let mut state = MapState::new();
909        state.push_layer(Box::new(TileLayer::new(
910            "regression-test",
911            Box::new(ImmediateRasterSource::new()),
912            64,
913        )));
914        state.set_viewport(1280, 720);
915        state.set_camera_target(GeoCoord::from_lat_lon(48.8566, 2.3522));
916        state.set_camera_distance(20_000.0);
917
918        state.update();
919        state.update();
920
921        let sample = TilePipelineRegressionSample::capture_from_map_state(
922            &state,
923            1,
924            "frame_0001",
925            60.0,
926        )
927        .expect("tile pipeline sample");
928
929        assert_eq!(sample.sample_index, 1);
930        assert_eq!(sample.sample_image, "frame_0001");
931        assert_eq!(sample.layer_name, "regression-test");
932        assert!(sample.visible_tiles > 0);
933        assert!(sample.loaded_tiles > 0);
934        assert_eq!(sample.counter_frames, 2);
935    }
936}