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 =
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 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 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
572pub struct TilePipelineRegressionSummary {
573 pub total_samples: usize,
575 pub longest_exact_free_run: usize,
577 pub longest_missing_run: usize,
579 pub longest_fallback_only_run: usize,
581 pub max_missing_visible_tiles: usize,
583 pub max_queued_requests: usize,
585 pub max_cache_pending_entries: usize,
587 pub max_cache_failed_entries: usize,
589 pub max_counter_cancelled_stale_pending: u64,
591 pub saturated_request_pool_samples: usize,
593}
594
595impl TilePipelineRegressionSummary {
596 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
660pub struct TilePipelineRegressionThresholds {
661 pub max_exact_free_run: Option<usize>,
663 pub max_missing_run: Option<usize>,
665 pub max_fallback_only_run: Option<usize>,
667 pub max_missing_visible_tiles: Option<usize>,
669 pub max_queued_requests: Option<usize>,
671 pub max_cache_pending_entries: Option<usize>,
673 pub max_cache_failed_entries: Option<usize>,
675 pub max_counter_cancelled_stale_pending: Option<u64>,
677}
678
679impl TilePipelineRegressionThresholds {
680 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
746pub struct TilePipelineRegressionEvaluation {
747 pub summary: TilePipelineRegressionSummary,
749 pub violations: Vec<TilePipelineRegressionViolation>,
751}
752
753impl TilePipelineRegressionEvaluation {
754 #[inline]
756 pub fn passed(&self) -> bool {
757 self.violations.is_empty()
758 }
759}
760
761#[derive(Debug, Clone, PartialEq, Eq)]
763pub struct TilePipelineRegressionViolation {
764 pub metric: &'static str,
766 pub actual: u64,
768 pub allowed: u64,
770}
771
772#[derive(Debug, Clone, PartialEq, Eq, Error)]
774pub enum TilePipelineRegressionParseError {
775 #[error("missing required CSV column '{column}'")]
777 MissingColumn {
778 column: &'static str,
780 },
781 #[error("row {row}: missing field '{field}'")]
783 MissingField {
784 row: usize,
786 field: &'static str,
788 },
789 #[error("row {row}: invalid value '{value}' for field '{field}'")]
791 InvalidField {
792 row: usize,
794 field: &'static str,
796 value: String,
798 },
799 #[error("row {row}: {message}")]
801 CsvSyntax {
802 row: usize,
804 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}