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 =
263            find_column(&header, &["cancelled_in_flight_requests"])?;
264        let counter_frames_col = find_column(&header, &["counter_frames"])?;
265        let counter_requested_tiles_col = find_column(&header, &["counter_requested_tiles"])?;
266        let counter_exact_cache_hits_col = find_column(&header, &["counter_exact_cache_hits"])?;
267        let counter_fallback_hits_col = find_column(&header, &["counter_fallback_hits"])?;
268        let counter_cache_misses_col = find_column(&header, &["counter_cache_misses"])?;
269        let counter_cancelled_stale_pending_col =
270            find_column(&header, &["counter_cancelled_stale_pending"])?;
271        let counter_cancelled_evicted_pending_col =
272            find_column(&header, &["counter_cancelled_evicted_pending"])?;
273
274        let mut samples = Vec::new();
275        for (line_index, line) in lines.enumerate() {
276            let row_number = line_index + 2;
277            let row = split_csv_line(line).map_err(|err| err.with_row(row_number))?;
278            samples.push(Self {
279                sample_index: parse_usize(
280                    field(&row, sample_index_col, row_number)?,
281                    row_number,
282                    "sample_index",
283                )?,
284                sample_image: field(&row, sample_image_col, row_number)?.to_owned(),
285                fps: parse_f64(field(&row, fps_col, row_number)?, row_number, "fps")?,
286                zoom_level: parse_u8(
287                    field(&row, zoom_level_col, row_number)?,
288                    row_number,
289                    "zoom_level",
290                )?,
291                zoom_pct: parse_u8(
292                    field(&row, zoom_pct_col, row_number)?,
293                    row_number,
294                    "zoom_pct",
295                )?,
296                pitch_deg: parse_f64(
297                    field(&row, pitch_deg_col, row_number)?,
298                    row_number,
299                    "pitch_deg",
300                )?,
301                yaw_deg: parse_f64(field(&row, yaw_deg_col, row_number)?, row_number, "yaw_deg")?,
302                distance_m: parse_f64(
303                    field(&row, distance_m_col, row_number)?,
304                    row_number,
305                    "distance_m",
306                )?,
307                center_lat: parse_f64(
308                    field(&row, center_lat_col, row_number)?,
309                    row_number,
310                    "center_lat",
311                )?,
312                center_lon: parse_f64(
313                    field(&row, center_lon_col, row_number)?,
314                    row_number,
315                    "center_lon",
316                )?,
317                viewport_width_km: parse_f64(
318                    field(&row, viewport_width_km_col, row_number)?,
319                    row_number,
320                    "viewport_width_km",
321                )?,
322                mercator_world_width_km: parse_f64(
323                    field(&row, mercator_world_width_km_col, row_number)?,
324                    row_number,
325                    "mercator_world_width_km",
326                )?,
327                full_world_x: parse_bool(
328                    field(&row, full_world_x_col, row_number)?,
329                    row_number,
330                    "full_world_x",
331                )?,
332                layer_name: field(&row, layer_name_col, row_number)?.to_owned(),
333                desired_tiles: parse_usize(
334                    field(&row, desired_tiles_col, row_number)?,
335                    row_number,
336                    "desired_tiles",
337                )?,
338                raw_candidate_tiles: parse_usize(
339                    field(&row, raw_candidate_tiles_col, row_number)?,
340                    row_number,
341                    "raw_candidate_tiles",
342                )?,
343                loaded_tiles: parse_usize(
344                    field(&row, loaded_tiles_col, row_number)?,
345                    row_number,
346                    "loaded_tiles",
347                )?,
348                visible_tiles: parse_usize(
349                    field(&row, visible_tiles_col, row_number)?,
350                    row_number,
351                    "visible_tiles",
352                )?,
353                exact_visible_tiles: parse_usize(
354                    field(&row, exact_visible_tiles_col, row_number)?,
355                    row_number,
356                    "exact_visible_tiles",
357                )?,
358                fallback_visible_tiles: parse_usize(
359                    field(&row, fallback_visible_tiles_col, row_number)?,
360                    row_number,
361                    "fallback_visible_tiles",
362                )?,
363                missing_visible_tiles: parse_usize(
364                    field(&row, missing_visible_tiles_col, row_number)?,
365                    row_number,
366                    "missing_visible_tiles",
367                )?,
368                overzoomed_visible_tiles: parse_usize(
369                    field(&row, overzoomed_visible_tiles_col, row_number)?,
370                    row_number,
371                    "overzoomed_visible_tiles",
372                )?,
373                requested_tiles: parse_usize(
374                    field(&row, requested_tiles_col, row_number)?,
375                    row_number,
376                    "requested_tiles",
377                )?,
378                exact_cache_hits: parse_usize(
379                    field(&row, exact_cache_hits_col, row_number)?,
380                    row_number,
381                    "exact_cache_hits",
382                )?,
383                cache_misses: parse_usize(
384                    field(&row, cache_misses_col, row_number)?,
385                    row_number,
386                    "cache_misses",
387                )?,
388                cancelled_stale_pending: parse_usize(
389                    field(&row, cancelled_stale_pending_col, row_number)?,
390                    row_number,
391                    "cancelled_stale_pending",
392                )?,
393                budget_hit: parse_bool(
394                    field(&row, budget_hit_col, row_number)?,
395                    row_number,
396                    "budget_hit",
397                )?,
398                dropped_by_budget: parse_usize(
399                    field(&row, dropped_by_budget_col, row_number)?,
400                    row_number,
401                    "dropped_by_budget",
402                )?,
403                cache_total_entries: parse_usize(
404                    field(&row, cache_total_entries_col, row_number)?,
405                    row_number,
406                    "cache_total_entries",
407                )?,
408                cache_loaded_entries: parse_usize(
409                    field(&row, cache_loaded_entries_col, row_number)?,
410                    row_number,
411                    "cache_loaded_entries",
412                )?,
413                cache_expired_entries: parse_usize(
414                    field(&row, cache_expired_entries_col, row_number)?,
415                    row_number,
416                    "cache_expired_entries",
417                )?,
418                cache_reloading_entries: parse_usize(
419                    field(&row, cache_reloading_entries_col, row_number)?,
420                    row_number,
421                    "cache_reloading_entries",
422                )?,
423                cache_pending_entries: parse_usize(
424                    field(&row, cache_pending_entries_col, row_number)?,
425                    row_number,
426                    "cache_pending_entries",
427                )?,
428                cache_failed_entries: parse_usize(
429                    field(&row, cache_failed_entries_col, row_number)?,
430                    row_number,
431                    "cache_failed_entries",
432                )?,
433                cache_renderable_entries: parse_usize(
434                    field(&row, cache_renderable_entries_col, row_number)?,
435                    row_number,
436                    "cache_renderable_entries",
437                )?,
438                queued_requests: parse_usize(
439                    field(&row, queued_requests_col, row_number)?,
440                    row_number,
441                    "queued_requests",
442                )?,
443                in_flight_requests: parse_usize(
444                    field(&row, in_flight_requests_col, row_number)?,
445                    row_number,
446                    "in_flight_requests",
447                )?,
448                max_concurrent_requests: parse_usize(
449                    field(&row, max_concurrent_requests_col, row_number)?,
450                    row_number,
451                    "max_concurrent_requests",
452                )?,
453                known_requests: parse_usize(
454                    field(&row, known_requests_col, row_number)?,
455                    row_number,
456                    "known_requests",
457                )?,
458                cancelled_in_flight_requests: parse_usize(
459                    field(&row, cancelled_in_flight_requests_col, row_number)?,
460                    row_number,
461                    "cancelled_in_flight_requests",
462                )?,
463                counter_frames: parse_u64(
464                    field(&row, counter_frames_col, row_number)?,
465                    row_number,
466                    "counter_frames",
467                )?,
468                counter_requested_tiles: parse_u64(
469                    field(&row, counter_requested_tiles_col, row_number)?,
470                    row_number,
471                    "counter_requested_tiles",
472                )?,
473                counter_exact_cache_hits: parse_u64(
474                    field(&row, counter_exact_cache_hits_col, row_number)?,
475                    row_number,
476                    "counter_exact_cache_hits",
477                )?,
478                counter_fallback_hits: parse_u64(
479                    field(&row, counter_fallback_hits_col, row_number)?,
480                    row_number,
481                    "counter_fallback_hits",
482                )?,
483                counter_cache_misses: parse_u64(
484                    field(&row, counter_cache_misses_col, row_number)?,
485                    row_number,
486                    "counter_cache_misses",
487                )?,
488                counter_cancelled_stale_pending: parse_u64(
489                    field(&row, counter_cancelled_stale_pending_col, row_number)?,
490                    row_number,
491                    "counter_cancelled_stale_pending",
492                )?,
493                counter_cancelled_evicted_pending: parse_u64(
494                    field(&row, counter_cancelled_evicted_pending_col, row_number)?,
495                    row_number,
496                    "counter_cancelled_evicted_pending",
497                )?,
498            });
499        }
500
501        Ok(samples)
502    }
503
504    /// Serialize samples into machine-comparable CSV text.
505    pub fn to_csv(samples: &[Self]) -> String {
506        let mut out = String::new();
507        out.push_str(TILE_PIPELINE_REGRESSION_CSV_HEADER);
508        for sample in samples {
509            out.push('\n');
510            out.push_str(&sample.to_csv_row());
511        }
512        out
513    }
514
515    /// Serialize this sample as one CSV row.
516    pub fn to_csv_row(&self) -> String {
517        format!(
518            "{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}",
519            sample_index = self.sample_index,
520            sample_image = quote_csv(&self.sample_image),
521            fps = self.fps,
522            zoom_level = self.zoom_level,
523            zoom_pct = self.zoom_pct,
524            pitch_deg = self.pitch_deg,
525            yaw_deg = self.yaw_deg,
526            distance_m = self.distance_m,
527            center_lat = self.center_lat,
528            center_lon = self.center_lon,
529            viewport_width_km = self.viewport_width_km,
530            mercator_world_width_km = self.mercator_world_width_km,
531            full_world_x = self.full_world_x,
532            layer_name = quote_csv(&self.layer_name),
533            desired_tiles = self.desired_tiles,
534            raw_candidate_tiles = self.raw_candidate_tiles,
535            loaded_tiles = self.loaded_tiles,
536            visible_tiles = self.visible_tiles,
537            exact_visible_tiles = self.exact_visible_tiles,
538            fallback_visible_tiles = self.fallback_visible_tiles,
539            missing_visible_tiles = self.missing_visible_tiles,
540            overzoomed_visible_tiles = self.overzoomed_visible_tiles,
541            requested_tiles = self.requested_tiles,
542            exact_cache_hits = self.exact_cache_hits,
543            cache_misses = self.cache_misses,
544            cancelled_stale_pending = self.cancelled_stale_pending,
545            budget_hit = self.budget_hit,
546            dropped_by_budget = self.dropped_by_budget,
547            cache_total_entries = self.cache_total_entries,
548            cache_loaded_entries = self.cache_loaded_entries,
549            cache_expired_entries = self.cache_expired_entries,
550            cache_reloading_entries = self.cache_reloading_entries,
551            cache_pending_entries = self.cache_pending_entries,
552            cache_failed_entries = self.cache_failed_entries,
553            cache_renderable_entries = self.cache_renderable_entries,
554            queued_requests = self.queued_requests,
555            in_flight_requests = self.in_flight_requests,
556            max_concurrent_requests = self.max_concurrent_requests,
557            known_requests = self.known_requests,
558            cancelled_in_flight_requests = self.cancelled_in_flight_requests,
559            counter_frames = self.counter_frames,
560            counter_requested_tiles = self.counter_requested_tiles,
561            counter_exact_cache_hits = self.counter_exact_cache_hits,
562            counter_fallback_hits = self.counter_fallback_hits,
563            counter_cache_misses = self.counter_cache_misses,
564            counter_cancelled_stale_pending = self.counter_cancelled_stale_pending,
565            counter_cancelled_evicted_pending = self.counter_cancelled_evicted_pending,
566        )
567    }
568}
569
570/// Aggregate statistics computed across a sequence of regression samples.
571#[derive(Debug, Clone, Default, PartialEq, Eq)]
572pub struct TilePipelineRegressionSummary {
573    /// Total number of samples analyzed.
574    pub total_samples: usize,
575    /// Longest consecutive run with `exact_visible_tiles == 0`.
576    pub longest_exact_free_run: usize,
577    /// Longest consecutive run with `missing_visible_tiles > 0`.
578    pub longest_missing_run: usize,
579    /// Longest consecutive run with all visible coverage coming from fallback.
580    pub longest_fallback_only_run: usize,
581    /// Maximum missing visible tiles observed in any sample.
582    pub max_missing_visible_tiles: usize,
583    /// Maximum queued requests observed in any sample.
584    pub max_queued_requests: usize,
585    /// Maximum pending cache entries observed in any sample.
586    pub max_cache_pending_entries: usize,
587    /// Maximum failed cache entries observed in any sample.
588    pub max_cache_failed_entries: usize,
589    /// Maximum cumulative stale-cancellation counter observed in any sample.
590    pub max_counter_cancelled_stale_pending: u64,
591    /// Number of samples where the request pool was fully saturated.
592    pub saturated_request_pool_samples: usize,
593}
594
595impl TilePipelineRegressionSummary {
596    /// Compute aggregate statistics across `samples`.
597    pub fn from_samples(samples: &[TilePipelineRegressionSample]) -> Self {
598        let mut summary = Self {
599            total_samples: samples.len(),
600            ..Self::default()
601        };
602
603        let mut exact_free_run = 0usize;
604        let mut missing_run = 0usize;
605        let mut fallback_only_run = 0usize;
606
607        for sample in samples {
608            if sample.visible_tiles > 0 && sample.exact_visible_tiles == 0 {
609                exact_free_run += 1;
610                summary.longest_exact_free_run = summary.longest_exact_free_run.max(exact_free_run);
611            } else {
612                exact_free_run = 0;
613            }
614
615            if sample.missing_visible_tiles > 0 {
616                missing_run += 1;
617                summary.longest_missing_run = summary.longest_missing_run.max(missing_run);
618            } else {
619                missing_run = 0;
620            }
621
622            if sample.visible_tiles > 0
623                && sample.exact_visible_tiles == 0
624                && sample.fallback_visible_tiles > 0
625                && sample.missing_visible_tiles == 0
626            {
627                fallback_only_run += 1;
628                summary.longest_fallback_only_run =
629                    summary.longest_fallback_only_run.max(fallback_only_run);
630            } else {
631                fallback_only_run = 0;
632            }
633
634            summary.max_missing_visible_tiles = summary
635                .max_missing_visible_tiles
636                .max(sample.missing_visible_tiles);
637            summary.max_queued_requests = summary.max_queued_requests.max(sample.queued_requests);
638            summary.max_cache_pending_entries = summary
639                .max_cache_pending_entries
640                .max(sample.cache_pending_entries);
641            summary.max_cache_failed_entries = summary
642                .max_cache_failed_entries
643                .max(sample.cache_failed_entries);
644            summary.max_counter_cancelled_stale_pending = summary
645                .max_counter_cancelled_stale_pending
646                .max(sample.counter_cancelled_stale_pending);
647            if sample.max_concurrent_requests > 0
648                && sample.in_flight_requests >= sample.max_concurrent_requests
649            {
650                summary.saturated_request_pool_samples += 1;
651            }
652        }
653
654        summary
655    }
656}
657
658/// Thresholds used to fail a regression run when behavior degrades.
659#[derive(Debug, Clone, Default, PartialEq, Eq)]
660pub struct TilePipelineRegressionThresholds {
661    /// Maximum allowed consecutive exact-free samples.
662    pub max_exact_free_run: Option<usize>,
663    /// Maximum allowed consecutive missing-coverage samples.
664    pub max_missing_run: Option<usize>,
665    /// Maximum allowed consecutive fallback-only samples.
666    pub max_fallback_only_run: Option<usize>,
667    /// Maximum allowed missing visible tiles in any sample.
668    pub max_missing_visible_tiles: Option<usize>,
669    /// Maximum allowed queued requests.
670    pub max_queued_requests: Option<usize>,
671    /// Maximum allowed pending cache entries.
672    pub max_cache_pending_entries: Option<usize>,
673    /// Maximum allowed failed cache entries.
674    pub max_cache_failed_entries: Option<usize>,
675    /// Maximum allowed cumulative stale cancellations.
676    pub max_counter_cancelled_stale_pending: Option<u64>,
677}
678
679impl TilePipelineRegressionThresholds {
680    /// Evaluate `samples` against the configured thresholds.
681    pub fn evaluate(
682        &self,
683        samples: &[TilePipelineRegressionSample],
684    ) -> TilePipelineRegressionEvaluation {
685        let summary = TilePipelineRegressionSummary::from_samples(samples);
686        let mut violations = Vec::new();
687
688        push_violation_usize(
689            &mut violations,
690            "longest_exact_free_run",
691            summary.longest_exact_free_run,
692            self.max_exact_free_run,
693        );
694        push_violation_usize(
695            &mut violations,
696            "longest_missing_run",
697            summary.longest_missing_run,
698            self.max_missing_run,
699        );
700        push_violation_usize(
701            &mut violations,
702            "longest_fallback_only_run",
703            summary.longest_fallback_only_run,
704            self.max_fallback_only_run,
705        );
706        push_violation_usize(
707            &mut violations,
708            "max_missing_visible_tiles",
709            summary.max_missing_visible_tiles,
710            self.max_missing_visible_tiles,
711        );
712        push_violation_usize(
713            &mut violations,
714            "max_queued_requests",
715            summary.max_queued_requests,
716            self.max_queued_requests,
717        );
718        push_violation_usize(
719            &mut violations,
720            "max_cache_pending_entries",
721            summary.max_cache_pending_entries,
722            self.max_cache_pending_entries,
723        );
724        push_violation_usize(
725            &mut violations,
726            "max_cache_failed_entries",
727            summary.max_cache_failed_entries,
728            self.max_cache_failed_entries,
729        );
730        push_violation_u64(
731            &mut violations,
732            "max_counter_cancelled_stale_pending",
733            summary.max_counter_cancelled_stale_pending,
734            self.max_counter_cancelled_stale_pending,
735        );
736
737        TilePipelineRegressionEvaluation {
738            summary,
739            violations,
740        }
741    }
742}
743
744/// Result of evaluating a regression run against thresholds.
745#[derive(Debug, Clone, Default, PartialEq, Eq)]
746pub struct TilePipelineRegressionEvaluation {
747    /// Aggregate metrics computed from the run.
748    pub summary: TilePipelineRegressionSummary,
749    /// Threshold violations detected for the run.
750    pub violations: Vec<TilePipelineRegressionViolation>,
751}
752
753impl TilePipelineRegressionEvaluation {
754    /// Returns `true` when no thresholds were violated.
755    #[inline]
756    pub fn passed(&self) -> bool {
757        self.violations.is_empty()
758    }
759}
760
761/// One threshold violation reported by the regression harness.
762#[derive(Debug, Clone, PartialEq, Eq)]
763pub struct TilePipelineRegressionViolation {
764    /// Name of the metric that exceeded its threshold.
765    pub metric: &'static str,
766    /// Actual value observed in the run.
767    pub actual: u64,
768    /// Configured maximum allowed value.
769    pub allowed: u64,
770}
771
772/// Parse errors produced by [`TilePipelineRegressionSample::parse_csv`].
773#[derive(Debug, Clone, PartialEq, Eq, Error)]
774pub enum TilePipelineRegressionParseError {
775    /// A required CSV column is missing.
776    #[error("missing required CSV column '{column}'")]
777    MissingColumn {
778        /// Required column name.
779        column: &'static str,
780    },
781    /// A CSV row had fewer fields than expected.
782    #[error("row {row}: missing field '{field}'")]
783    MissingField {
784        /// One-based CSV row number.
785        row: usize,
786        /// Column or field name.
787        field: &'static str,
788    },
789    /// A field could not be parsed into the expected type.
790    #[error("row {row}: invalid value '{value}' for field '{field}'")]
791    InvalidField {
792        /// One-based CSV row number.
793        row: usize,
794        /// Column or field name.
795        field: &'static str,
796        /// Original field text.
797        value: String,
798    },
799    /// CSV syntax error such as an unterminated quote.
800    #[error("row {row}: {message}")]
801    CsvSyntax {
802        /// One-based CSV row number.
803        row: usize,
804        /// Human-readable error message.
805        message: &'static str,
806    },
807}
808
809impl TilePipelineRegressionParseError {
810    fn with_row(self, row: usize) -> Self {
811        match self {
812            Self::CsvSyntax { message, .. } => Self::CsvSyntax { row, message },
813            other => other,
814        }
815    }
816}
817
818fn first_visible_tile_layer(state: &MapState) -> Option<&TileLayer> {
819    state.layers().iter().find_map(|layer| {
820        if !layer.visible() {
821            return None;
822        }
823        layer.as_any().downcast_ref::<TileLayer>()
824    })
825}
826
827fn push_violation_usize(
828    out: &mut Vec<TilePipelineRegressionViolation>,
829    metric: &'static str,
830    actual: usize,
831    allowed: Option<usize>,
832) {
833    if let Some(allowed) = allowed.filter(|allowed| actual > *allowed) {
834        out.push(TilePipelineRegressionViolation {
835            metric,
836            actual: actual as u64,
837            allowed: allowed as u64,
838        });
839    }
840}
841
842fn push_violation_u64(
843    out: &mut Vec<TilePipelineRegressionViolation>,
844    metric: &'static str,
845    actual: u64,
846    allowed: Option<u64>,
847) {
848    if let Some(allowed) = allowed.filter(|allowed| actual > *allowed) {
849        out.push(TilePipelineRegressionViolation {
850            metric,
851            actual,
852            allowed,
853        });
854    }
855}
856
857fn find_column(
858    header: &[String],
859    candidates: &[&'static str],
860) -> Result<usize, TilePipelineRegressionParseError> {
861    header
862        .iter()
863        .position(|field| candidates.iter().any(|candidate| field == candidate))
864        .ok_or(TilePipelineRegressionParseError::MissingColumn {
865            column: candidates[0],
866        })
867}
868
869fn field(
870    row: &[String],
871    index: usize,
872    row_number: usize,
873) -> Result<&str, TilePipelineRegressionParseError> {
874    row.get(index)
875        .map(String::as_str)
876        .ok_or(TilePipelineRegressionParseError::MissingField {
877            row: row_number,
878            field: "csv field",
879        })
880}
881
882fn parse_bool(
883    value: &str,
884    row: usize,
885    field: &'static str,
886) -> Result<bool, TilePipelineRegressionParseError> {
887    match value {
888        "true" => Ok(true),
889        "false" => Ok(false),
890        _ => Err(TilePipelineRegressionParseError::InvalidField {
891            row,
892            field,
893            value: value.to_owned(),
894        }),
895    }
896}
897
898fn parse_u8(
899    value: &str,
900    row: usize,
901    field: &'static str,
902) -> Result<u8, TilePipelineRegressionParseError> {
903    value
904        .parse()
905        .map_err(|_| TilePipelineRegressionParseError::InvalidField {
906            row,
907            field,
908            value: value.to_owned(),
909        })
910}
911
912fn parse_usize(
913    value: &str,
914    row: usize,
915    field: &'static str,
916) -> Result<usize, TilePipelineRegressionParseError> {
917    value
918        .parse()
919        .map_err(|_| TilePipelineRegressionParseError::InvalidField {
920            row,
921            field,
922            value: value.to_owned(),
923        })
924}
925
926fn parse_u64(
927    value: &str,
928    row: usize,
929    field: &'static str,
930) -> Result<u64, TilePipelineRegressionParseError> {
931    value
932        .parse()
933        .map_err(|_| TilePipelineRegressionParseError::InvalidField {
934            row,
935            field,
936            value: value.to_owned(),
937        })
938}
939
940fn parse_f64(
941    value: &str,
942    row: usize,
943    field: &'static str,
944) -> Result<f64, TilePipelineRegressionParseError> {
945    value
946        .parse()
947        .map_err(|_| TilePipelineRegressionParseError::InvalidField {
948            row,
949            field,
950            value: value.to_owned(),
951        })
952}
953
954fn split_csv_line(line: &str) -> Result<Vec<String>, TilePipelineRegressionParseError> {
955    let mut fields = Vec::new();
956    let mut current = String::new();
957    let mut chars = line.chars().peekable();
958    let mut in_quotes = false;
959
960    while let Some(ch) = chars.next() {
961        match ch {
962            '"' => {
963                if in_quotes && chars.peek() == Some(&'"') {
964                    current.push('"');
965                    let _ = chars.next();
966                } else {
967                    in_quotes = !in_quotes;
968                }
969            }
970            ',' if !in_quotes => {
971                fields.push(current);
972                current = String::new();
973            }
974            _ => current.push(ch),
975        }
976    }
977
978    if in_quotes {
979        return Err(TilePipelineRegressionParseError::CsvSyntax {
980            row: 1,
981            message: "unterminated quoted field",
982        });
983    }
984
985    fields.push(current);
986    Ok(fields)
987}
988
989fn quote_csv(value: &str) -> String {
990    let escaped = value.replace('"', "\"\"");
991    format!("\"{escaped}\"")
992}
993
994#[cfg(test)]
995mod tests {
996    use super::*;
997    use crate::{
998        layers::TileLayer, DecodedImage, GeoCoord, MapState, TileData, TileResponse, TileSource,
999    };
1000    use rustial_math::TileId;
1001    use std::sync::Mutex;
1002
1003    const DEBUG_FIXTURE_CSV: &str = include_str!("../../../docs/debug/rustial_debug_values.csv");
1004
1005    struct ImmediateRasterSource {
1006        ready: Mutex<Vec<(TileId, Result<TileResponse, crate::TileError>)>>,
1007    }
1008
1009    impl ImmediateRasterSource {
1010        fn new() -> Self {
1011            Self {
1012                ready: Mutex::new(Vec::new()),
1013            }
1014        }
1015    }
1016
1017    impl TileSource for ImmediateRasterSource {
1018        fn request(&self, id: TileId) {
1019            let data = TileData::Raster(DecodedImage {
1020                width: 256,
1021                height: 256,
1022                data: vec![200u8; 256 * 256 * 4].into(),
1023            });
1024            self.ready
1025                .lock()
1026                .expect("ready queue lock")
1027                .push((id, Ok(TileResponse::from_data(data))));
1028        }
1029
1030        fn poll(&self) -> Vec<(TileId, Result<TileResponse, crate::TileError>)> {
1031            std::mem::take(&mut *self.ready.lock().expect("ready queue lock"))
1032        }
1033    }
1034
1035    #[test]
1036    fn parse_authoritative_debug_fixture() {
1037        let samples = TilePipelineRegressionSample::parse_csv(DEBUG_FIXTURE_CSV)
1038            .expect("fixture CSV should parse");
1039
1040        assert!(samples.len() >= 3);
1041        assert_eq!(samples.first().expect("first sample").sample_index, 1);
1042        assert_eq!(
1043            samples.last().expect("last sample").sample_index,
1044            samples.len()
1045        );
1046        assert!(samples
1047            .iter()
1048            .all(|sample| sample.layer_name == "__rustial_builtin_http_tiles"));
1049        assert!(samples.iter().all(|sample| sample.sample_index >= 1));
1050        assert!(samples.iter().any(|sample| sample.exact_visible_tiles > 0));
1051        assert!(
1052            samples
1053                .iter()
1054                .map(|sample| sample.missing_visible_tiles)
1055                .max()
1056                .unwrap_or(0)
1057                <= 40
1058        );
1059    }
1060
1061    #[test]
1062    fn csv_round_trip_preserves_reduced_schema() {
1063        let samples = TilePipelineRegressionSample::parse_csv(DEBUG_FIXTURE_CSV)
1064            .expect("fixture CSV should parse");
1065        let mid = samples.len() / 2;
1066        let trimmed = vec![
1067            samples.first().expect("first sample").clone(),
1068            samples[mid].clone(),
1069            samples.last().expect("last sample").clone(),
1070        ];
1071
1072        let csv = TilePipelineRegressionSample::to_csv(&trimmed);
1073        let reparsed = TilePipelineRegressionSample::parse_csv(&csv).expect("round-trip parse");
1074
1075        assert_eq!(reparsed, trimmed);
1076    }
1077
1078    #[test]
1079    fn threshold_evaluation_passes_healthy_fixture() {
1080        let samples = TilePipelineRegressionSample::parse_csv(DEBUG_FIXTURE_CSV)
1081            .expect("fixture CSV should parse");
1082        let thresholds = TilePipelineRegressionThresholds {
1083            max_exact_free_run: Some(4),
1084            max_fallback_only_run: Some(4),
1085            max_missing_visible_tiles: Some(40),
1086            ..TilePipelineRegressionThresholds::default()
1087        };
1088
1089        let evaluation = thresholds.evaluate(&samples);
1090
1091        assert!(
1092            evaluation.passed(),
1093            "healthy fixture should pass thresholds: {:?}",
1094            evaluation.violations
1095        );
1096        assert!(evaluation.summary.longest_exact_free_run <= 4);
1097        assert!(evaluation.summary.max_missing_visible_tiles <= 40);
1098    }
1099
1100    #[test]
1101    fn capture_sample_from_map_state() {
1102        let mut state = MapState::new();
1103        state.push_layer(Box::new(TileLayer::new(
1104            "regression-test",
1105            Box::new(ImmediateRasterSource::new()),
1106            64,
1107        )));
1108        state.set_viewport(1280, 720);
1109        state.set_camera_target(GeoCoord::from_lat_lon(48.8566, 2.3522));
1110        state.set_camera_distance(20_000.0);
1111
1112        state.update();
1113        state.update();
1114
1115        let sample =
1116            TilePipelineRegressionSample::capture_from_map_state(&state, 1, "frame_0001", 60.0)
1117                .expect("tile pipeline sample");
1118
1119        assert_eq!(sample.sample_index, 1);
1120        assert_eq!(sample.sample_image, "frame_0001");
1121        assert_eq!(sample.layer_name, "regression-test");
1122        assert!(sample.visible_tiles > 0);
1123        assert!(sample.loaded_tiles > 0);
1124        assert_eq!(sample.counter_frames, 2);
1125    }
1126}