1use crate::{layers::TileLayer, MapState, TilePipelineDiagnostics, WebMercator};
14use thiserror::Error;
15
16pub 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#[derive(Debug, Clone, PartialEq)]
21pub struct TilePipelineRegressionSample {
22 pub sample_index: usize,
24 pub sample_image: String,
26 pub fps: f64,
28 pub zoom_level: u8,
30 pub zoom_pct: u8,
32 pub pitch_deg: f64,
34 pub yaw_deg: f64,
36 pub distance_m: f64,
38 pub center_lat: f64,
40 pub center_lon: f64,
42 pub viewport_width_km: f64,
44 pub mercator_world_width_km: f64,
46 pub full_world_x: bool,
48 pub layer_name: String,
50 pub desired_tiles: usize,
52 pub raw_candidate_tiles: usize,
54 pub loaded_tiles: usize,
56 pub visible_tiles: usize,
58 pub exact_visible_tiles: usize,
60 pub fallback_visible_tiles: usize,
62 pub missing_visible_tiles: usize,
64 pub overzoomed_visible_tiles: usize,
66 pub requested_tiles: usize,
68 pub exact_cache_hits: usize,
70 pub cache_misses: usize,
72 pub cancelled_stale_pending: usize,
74 pub budget_hit: bool,
76 pub dropped_by_budget: usize,
78 pub cache_total_entries: usize,
80 pub cache_loaded_entries: usize,
82 pub cache_expired_entries: usize,
84 pub cache_reloading_entries: usize,
86 pub cache_pending_entries: usize,
88 pub cache_failed_entries: usize,
90 pub cache_renderable_entries: usize,
92 pub queued_requests: usize,
94 pub in_flight_requests: usize,
96 pub max_concurrent_requests: usize,
98 pub known_requests: usize,
100 pub cancelled_in_flight_requests: usize,
102 pub counter_frames: u64,
104 pub counter_requested_tiles: u64,
106 pub counter_exact_cache_hits: u64,
108 pub counter_fallback_hits: u64,
110 pub counter_cache_misses: u64,
112 pub counter_cancelled_stale_pending: u64,
114 pub counter_cancelled_evicted_pending: u64,
116}
117
118impl TilePipelineRegressionSample {
119 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 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 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 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
397pub struct TilePipelineRegressionSummary {
398 pub total_samples: usize,
400 pub longest_exact_free_run: usize,
402 pub longest_missing_run: usize,
404 pub longest_fallback_only_run: usize,
406 pub max_missing_visible_tiles: usize,
408 pub max_queued_requests: usize,
410 pub max_cache_pending_entries: usize,
412 pub max_cache_failed_entries: usize,
414 pub max_counter_cancelled_stale_pending: u64,
416 pub saturated_request_pool_samples: usize,
418}
419
420impl TilePipelineRegressionSummary {
421 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
485pub struct TilePipelineRegressionThresholds {
486 pub max_exact_free_run: Option<usize>,
488 pub max_missing_run: Option<usize>,
490 pub max_fallback_only_run: Option<usize>,
492 pub max_missing_visible_tiles: Option<usize>,
494 pub max_queued_requests: Option<usize>,
496 pub max_cache_pending_entries: Option<usize>,
498 pub max_cache_failed_entries: Option<usize>,
500 pub max_counter_cancelled_stale_pending: Option<u64>,
502}
503
504impl TilePipelineRegressionThresholds {
505 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
568pub struct TilePipelineRegressionEvaluation {
569 pub summary: TilePipelineRegressionSummary,
571 pub violations: Vec<TilePipelineRegressionViolation>,
573}
574
575impl TilePipelineRegressionEvaluation {
576 #[inline]
578 pub fn passed(&self) -> bool {
579 self.violations.is_empty()
580 }
581}
582
583#[derive(Debug, Clone, PartialEq, Eq)]
585pub struct TilePipelineRegressionViolation {
586 pub metric: &'static str,
588 pub actual: u64,
590 pub allowed: u64,
592}
593
594#[derive(Debug, Clone, PartialEq, Eq, Error)]
596pub enum TilePipelineRegressionParseError {
597 #[error("missing required CSV column '{column}'")]
599 MissingColumn {
600 column: &'static str,
602 },
603 #[error("row {row}: missing field '{field}'")]
605 MissingField {
606 row: usize,
608 field: &'static str,
610 },
611 #[error("row {row}: invalid value '{value}' for field '{field}'")]
613 InvalidField {
614 row: usize,
616 field: &'static str,
618 value: String,
620 },
621 #[error("row {row}: {message}")]
623 CsvSyntax {
624 row: usize,
626 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}