//! Tile-pipeline regression harness utilities.
//!
//! This module provides a small, dependency-free regression harness for the
//! HTTP raster tile pipeline. It serves two purposes:
//!
//! 1. Parse the authoritative route fixture in `docs/debug/rustial_debug_values.csv`.
//! 2. Capture comparable per-frame telemetry from [`MapState`](crate::MapState)
//! and evaluate it against configurable thresholds.
//!
//! The CSV schema is intentionally machine-comparable and stable so tests,
//! scripted runs, and benchmarks can exchange the same sample format.
use crate::{layers::TileLayer, MapState, TilePipelineDiagnostics, WebMercator};
use thiserror::Error;
/// CSV header emitted by [`TilePipelineRegressionSample::to_csv`].
pub 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";
/// One machine-comparable tile-pipeline telemetry sample.
#[derive(Debug, Clone, PartialEq)]
pub struct TilePipelineRegressionSample {
/// Sequential sample identifier.
pub sample_index: usize,
/// Optional image or frame label associated with the sample.
pub sample_image: String,
/// Frames-per-second recorded for the sample.
pub fps: f64,
/// Integer zoom level used for tile selection.
pub zoom_level: u8,
/// Fractional zoom remainder expressed as a percentage in `[0, 100]`.
pub zoom_pct: u8,
/// Camera pitch in degrees.
pub pitch_deg: f64,
/// Camera yaw / bearing in degrees.
pub yaw_deg: f64,
/// Camera distance from the target in meters.
pub distance_m: f64,
/// Camera target latitude in degrees.
pub center_lat: f64,
/// Camera target longitude in degrees.
pub center_lon: f64,
/// Width of the current viewport footprint in Mercator kilometers.
pub viewport_width_km: f64,
/// Total Web Mercator world width in kilometers.
pub mercator_world_width_km: f64,
/// Whether the viewport spans the full wrapped world width.
pub full_world_x: bool,
/// Name of the tile layer that produced the sample.
pub layer_name: String,
/// Number of desired source tiles considered for the current view.
pub desired_tiles: usize,
/// Number of raw selection candidates before budget capping.
pub raw_candidate_tiles: usize,
/// Number of visible tiles with imagery currently loaded.
pub loaded_tiles: usize,
/// Number of visible tiles emitted for the frame.
pub visible_tiles: usize,
/// Number of exact visible tiles.
pub exact_visible_tiles: usize,
/// Number of visible tiles rendered via fallback imagery.
pub fallback_visible_tiles: usize,
/// Number of visible tiles still missing imagery.
pub missing_visible_tiles: usize,
/// Number of visible overzoomed tiles.
pub overzoomed_visible_tiles: usize,
/// Number of requests issued in the current frame.
pub requested_tiles: usize,
/// Number of exact cache hits in the current frame.
pub exact_cache_hits: usize,
/// Number of cache misses in the current frame.
pub cache_misses: usize,
/// Number of stale pending requests cancelled in the current frame.
pub cancelled_stale_pending: usize,
/// Whether the visible-tile budget was hit this frame.
pub budget_hit: bool,
/// Number of candidate tiles dropped by the visible budget.
pub dropped_by_budget: usize,
/// Total number of cache entries.
pub cache_total_entries: usize,
/// Number of loaded cache entries.
pub cache_loaded_entries: usize,
/// Number of expired cache entries.
pub cache_expired_entries: usize,
/// Number of reloading cache entries.
pub cache_reloading_entries: usize,
/// Number of pending cache entries.
pub cache_pending_entries: usize,
/// Number of failed cache entries.
pub cache_failed_entries: usize,
/// Number of renderable cache entries.
pub cache_renderable_entries: usize,
/// Number of queued source requests.
pub queued_requests: usize,
/// Number of in-flight source requests.
pub in_flight_requests: usize,
/// Maximum allowed concurrent source requests.
pub max_concurrent_requests: usize,
/// Number of transport-known requests.
pub known_requests: usize,
/// Number of forced cancellations for already in-flight requests.
pub cancelled_in_flight_requests: usize,
/// Cumulative frame count.
pub counter_frames: u64,
/// Cumulative requested tile count.
pub counter_requested_tiles: u64,
/// Cumulative exact cache hits.
pub counter_exact_cache_hits: u64,
/// Cumulative fallback hits.
pub counter_fallback_hits: u64,
/// Cumulative cache misses.
pub counter_cache_misses: u64,
/// Cumulative stale cancellations.
pub counter_cancelled_stale_pending: u64,
/// Cumulative evicted-pending cancellations.
pub counter_cancelled_evicted_pending: u64,
}
impl TilePipelineRegressionSample {
/// Capture a regression sample from the first visible tile layer in `state`.
///
/// Returns `None` when no visible tile layer is active or the map has not
/// yet produced tile-pipeline diagnostics.
pub fn capture_from_map_state(
state: &MapState,
sample_index: usize,
sample_image: impl Into<String>,
fps: f64,
) -> Option<Self> {
let diagnostics = state.tile_pipeline_diagnostics()?;
let desired_tiles = first_visible_tile_layer(state)?.desired_tiles().len();
Some(Self::from_state_parts(
state,
diagnostics,
desired_tiles,
sample_index,
sample_image.into(),
fps,
))
}
fn from_state_parts(
state: &MapState,
diagnostics: TilePipelineDiagnostics,
desired_tiles: usize,
sample_index: usize,
sample_image: String,
fps: f64,
) -> Self {
let camera = state.camera();
let viewport_bounds = state.viewport_bounds();
let viewport_width_km =
(viewport_bounds.max.position.x - viewport_bounds.min.position.x).abs() / 1_000.0;
let mercator_world_width_km = (2.0 * WebMercator::max_extent()) / 1_000.0;
let full_world_x = viewport_width_km >= mercator_world_width_km;
let zoom_level = state.zoom_level();
let zoom_fraction = (state.fractional_zoom() - zoom_level as f64).clamp(0.0, 0.999_999);
let zoom_pct = (zoom_fraction * 100.0).round().clamp(0.0, 99.0) as u8;
let source = diagnostics.source_diagnostics.unwrap_or_default();
Self {
sample_index,
sample_image,
fps,
zoom_level,
zoom_pct,
pitch_deg: camera.pitch().to_degrees(),
yaw_deg: camera.yaw().to_degrees(),
distance_m: camera.distance(),
center_lat: camera.target().lat,
center_lon: camera.target().lon,
viewport_width_km,
mercator_world_width_km,
full_world_x,
layer_name: diagnostics.layer_name,
desired_tiles,
raw_candidate_tiles: diagnostics.selection_stats.raw_candidate_tiles,
loaded_tiles: diagnostics.visible_loaded_tiles,
visible_tiles: diagnostics.visible_tiles,
exact_visible_tiles: diagnostics.selection_stats.exact_visible_tiles,
fallback_visible_tiles: diagnostics.visible_fallback_tiles,
missing_visible_tiles: diagnostics.visible_missing_tiles,
overzoomed_visible_tiles: diagnostics.visible_overzoomed_tiles,
requested_tiles: diagnostics.selection_stats.requested_tiles,
exact_cache_hits: diagnostics.selection_stats.exact_cache_hits,
cache_misses: diagnostics.selection_stats.cache_misses,
cancelled_stale_pending: diagnostics.selection_stats.cancelled_stale_pending,
budget_hit: diagnostics.selection_stats.budget_hit,
dropped_by_budget: diagnostics.selection_stats.dropped_by_budget,
cache_total_entries: diagnostics.cache_stats.total_entries,
cache_loaded_entries: diagnostics.cache_stats.loaded_entries,
cache_expired_entries: diagnostics.cache_stats.expired_entries,
cache_reloading_entries: diagnostics.cache_stats.reloading_entries,
cache_pending_entries: diagnostics.cache_stats.pending_entries,
cache_failed_entries: diagnostics.cache_stats.failed_entries,
cache_renderable_entries: diagnostics.cache_stats.renderable_entries,
queued_requests: source.queued_requests,
in_flight_requests: source.in_flight_requests,
max_concurrent_requests: source.max_concurrent_requests,
known_requests: source.known_requests,
cancelled_in_flight_requests: source.cancelled_in_flight_requests,
counter_frames: diagnostics.counters.frames,
counter_requested_tiles: diagnostics.counters.requested_tiles,
counter_exact_cache_hits: diagnostics.counters.exact_cache_hits,
counter_fallback_hits: diagnostics.counters.fallback_hits,
counter_cache_misses: diagnostics.counters.cache_misses,
counter_cancelled_stale_pending: diagnostics.counters.cancelled_stale_pending,
counter_cancelled_evicted_pending: diagnostics.counters.cancelled_evicted_pending,
}
}
/// Parse a sequence of samples from CSV text.
///
/// This accepts both the reduced harness schema emitted by [`to_csv`](Self::to_csv)
/// and the checked-in `docs/debug/rustial_debug_values.csv` fixture. When the
/// fixture is parsed, `selected_tiles` is mapped into [`desired_tiles`](Self::desired_tiles).
pub fn parse_csv(input: &str) -> Result<Vec<Self>, TilePipelineRegressionParseError> {
let mut lines = input.lines().filter(|line| !line.trim().is_empty());
let Some(header_line) = lines.next() else {
return Ok(Vec::new());
};
let header = split_csv_line(header_line)?;
let sample_index_col = find_column(&header, &["sample_index"])?;
let sample_image_col = find_column(&header, &["sample_image"])?;
let fps_col = find_column(&header, &["fps"])?;
let zoom_level_col = find_column(&header, &["zoom_level"])?;
let zoom_pct_col = find_column(&header, &["zoom_pct"])?;
let pitch_deg_col = find_column(&header, &["pitch_deg"])?;
let yaw_deg_col = find_column(&header, &["yaw_deg"])?;
let distance_m_col = find_column(&header, &["distance_m"])?;
let center_lat_col = find_column(&header, &["center_lat"])?;
let center_lon_col = find_column(&header, &["center_lon"])?;
let viewport_width_km_col = find_column(&header, &["viewport_width_km"])?;
let mercator_world_width_km_col = find_column(&header, &["mercator_world_width_km"])?;
let full_world_x_col = find_column(&header, &["full_world_x"])?;
let layer_name_col = find_column(&header, &["layer_name"])?;
let desired_tiles_col = find_column(&header, &["desired_tiles", "selected_tiles"])?;
let raw_candidate_tiles_col = find_column(&header, &["raw_candidate_tiles"])?;
let loaded_tiles_col = find_column(&header, &["loaded_tiles"])?;
let visible_tiles_col = find_column(&header, &["visible_tiles"])?;
let exact_visible_tiles_col = find_column(&header, &["exact_visible_tiles"])?;
let fallback_visible_tiles_col = find_column(&header, &["fallback_visible_tiles"])?;
let missing_visible_tiles_col = find_column(&header, &["missing_visible_tiles"])?;
let overzoomed_visible_tiles_col = find_column(&header, &["overzoomed_visible_tiles"])?;
let requested_tiles_col = find_column(&header, &["requested_tiles"])?;
let exact_cache_hits_col = find_column(&header, &["exact_cache_hits"])?;
let cache_misses_col = find_column(&header, &["cache_misses"])?;
let cancelled_stale_pending_col = find_column(&header, &["cancelled_stale_pending"])?;
let budget_hit_col = find_column(&header, &["budget_hit"])?;
let dropped_by_budget_col = find_column(&header, &["dropped_by_budget"])?;
let cache_total_entries_col = find_column(&header, &["cache_total_entries"])?;
let cache_loaded_entries_col = find_column(&header, &["cache_loaded_entries"])?;
let cache_expired_entries_col = find_column(&header, &["cache_expired_entries"])?;
let cache_reloading_entries_col = find_column(&header, &["cache_reloading_entries"])?;
let cache_pending_entries_col = find_column(&header, &["cache_pending_entries"])?;
let cache_failed_entries_col = find_column(&header, &["cache_failed_entries"])?;
let cache_renderable_entries_col = find_column(&header, &["cache_renderable_entries"])?;
let queued_requests_col = find_column(&header, &["queued_requests"])?;
let in_flight_requests_col = find_column(&header, &["in_flight_requests"])?;
let max_concurrent_requests_col = find_column(&header, &["max_concurrent_requests"])?;
let known_requests_col = find_column(&header, &["known_requests"])?;
let cancelled_in_flight_requests_col =
find_column(&header, &["cancelled_in_flight_requests"])?;
let counter_frames_col = find_column(&header, &["counter_frames"])?;
let counter_requested_tiles_col = find_column(&header, &["counter_requested_tiles"])?;
let counter_exact_cache_hits_col = find_column(&header, &["counter_exact_cache_hits"])?;
let counter_fallback_hits_col = find_column(&header, &["counter_fallback_hits"])?;
let counter_cache_misses_col = find_column(&header, &["counter_cache_misses"])?;
let counter_cancelled_stale_pending_col =
find_column(&header, &["counter_cancelled_stale_pending"])?;
let counter_cancelled_evicted_pending_col =
find_column(&header, &["counter_cancelled_evicted_pending"])?;
let mut samples = Vec::new();
for (line_index, line) in lines.enumerate() {
let row_number = line_index + 2;
let row = split_csv_line(line).map_err(|err| err.with_row(row_number))?;
samples.push(Self {
sample_index: parse_usize(
field(&row, sample_index_col, row_number)?,
row_number,
"sample_index",
)?,
sample_image: field(&row, sample_image_col, row_number)?.to_owned(),
fps: parse_f64(field(&row, fps_col, row_number)?, row_number, "fps")?,
zoom_level: parse_u8(
field(&row, zoom_level_col, row_number)?,
row_number,
"zoom_level",
)?,
zoom_pct: parse_u8(
field(&row, zoom_pct_col, row_number)?,
row_number,
"zoom_pct",
)?,
pitch_deg: parse_f64(
field(&row, pitch_deg_col, row_number)?,
row_number,
"pitch_deg",
)?,
yaw_deg: parse_f64(field(&row, yaw_deg_col, row_number)?, row_number, "yaw_deg")?,
distance_m: parse_f64(
field(&row, distance_m_col, row_number)?,
row_number,
"distance_m",
)?,
center_lat: parse_f64(
field(&row, center_lat_col, row_number)?,
row_number,
"center_lat",
)?,
center_lon: parse_f64(
field(&row, center_lon_col, row_number)?,
row_number,
"center_lon",
)?,
viewport_width_km: parse_f64(
field(&row, viewport_width_km_col, row_number)?,
row_number,
"viewport_width_km",
)?,
mercator_world_width_km: parse_f64(
field(&row, mercator_world_width_km_col, row_number)?,
row_number,
"mercator_world_width_km",
)?,
full_world_x: parse_bool(
field(&row, full_world_x_col, row_number)?,
row_number,
"full_world_x",
)?,
layer_name: field(&row, layer_name_col, row_number)?.to_owned(),
desired_tiles: parse_usize(
field(&row, desired_tiles_col, row_number)?,
row_number,
"desired_tiles",
)?,
raw_candidate_tiles: parse_usize(
field(&row, raw_candidate_tiles_col, row_number)?,
row_number,
"raw_candidate_tiles",
)?,
loaded_tiles: parse_usize(
field(&row, loaded_tiles_col, row_number)?,
row_number,
"loaded_tiles",
)?,
visible_tiles: parse_usize(
field(&row, visible_tiles_col, row_number)?,
row_number,
"visible_tiles",
)?,
exact_visible_tiles: parse_usize(
field(&row, exact_visible_tiles_col, row_number)?,
row_number,
"exact_visible_tiles",
)?,
fallback_visible_tiles: parse_usize(
field(&row, fallback_visible_tiles_col, row_number)?,
row_number,
"fallback_visible_tiles",
)?,
missing_visible_tiles: parse_usize(
field(&row, missing_visible_tiles_col, row_number)?,
row_number,
"missing_visible_tiles",
)?,
overzoomed_visible_tiles: parse_usize(
field(&row, overzoomed_visible_tiles_col, row_number)?,
row_number,
"overzoomed_visible_tiles",
)?,
requested_tiles: parse_usize(
field(&row, requested_tiles_col, row_number)?,
row_number,
"requested_tiles",
)?,
exact_cache_hits: parse_usize(
field(&row, exact_cache_hits_col, row_number)?,
row_number,
"exact_cache_hits",
)?,
cache_misses: parse_usize(
field(&row, cache_misses_col, row_number)?,
row_number,
"cache_misses",
)?,
cancelled_stale_pending: parse_usize(
field(&row, cancelled_stale_pending_col, row_number)?,
row_number,
"cancelled_stale_pending",
)?,
budget_hit: parse_bool(
field(&row, budget_hit_col, row_number)?,
row_number,
"budget_hit",
)?,
dropped_by_budget: parse_usize(
field(&row, dropped_by_budget_col, row_number)?,
row_number,
"dropped_by_budget",
)?,
cache_total_entries: parse_usize(
field(&row, cache_total_entries_col, row_number)?,
row_number,
"cache_total_entries",
)?,
cache_loaded_entries: parse_usize(
field(&row, cache_loaded_entries_col, row_number)?,
row_number,
"cache_loaded_entries",
)?,
cache_expired_entries: parse_usize(
field(&row, cache_expired_entries_col, row_number)?,
row_number,
"cache_expired_entries",
)?,
cache_reloading_entries: parse_usize(
field(&row, cache_reloading_entries_col, row_number)?,
row_number,
"cache_reloading_entries",
)?,
cache_pending_entries: parse_usize(
field(&row, cache_pending_entries_col, row_number)?,
row_number,
"cache_pending_entries",
)?,
cache_failed_entries: parse_usize(
field(&row, cache_failed_entries_col, row_number)?,
row_number,
"cache_failed_entries",
)?,
cache_renderable_entries: parse_usize(
field(&row, cache_renderable_entries_col, row_number)?,
row_number,
"cache_renderable_entries",
)?,
queued_requests: parse_usize(
field(&row, queued_requests_col, row_number)?,
row_number,
"queued_requests",
)?,
in_flight_requests: parse_usize(
field(&row, in_flight_requests_col, row_number)?,
row_number,
"in_flight_requests",
)?,
max_concurrent_requests: parse_usize(
field(&row, max_concurrent_requests_col, row_number)?,
row_number,
"max_concurrent_requests",
)?,
known_requests: parse_usize(
field(&row, known_requests_col, row_number)?,
row_number,
"known_requests",
)?,
cancelled_in_flight_requests: parse_usize(
field(&row, cancelled_in_flight_requests_col, row_number)?,
row_number,
"cancelled_in_flight_requests",
)?,
counter_frames: parse_u64(
field(&row, counter_frames_col, row_number)?,
row_number,
"counter_frames",
)?,
counter_requested_tiles: parse_u64(
field(&row, counter_requested_tiles_col, row_number)?,
row_number,
"counter_requested_tiles",
)?,
counter_exact_cache_hits: parse_u64(
field(&row, counter_exact_cache_hits_col, row_number)?,
row_number,
"counter_exact_cache_hits",
)?,
counter_fallback_hits: parse_u64(
field(&row, counter_fallback_hits_col, row_number)?,
row_number,
"counter_fallback_hits",
)?,
counter_cache_misses: parse_u64(
field(&row, counter_cache_misses_col, row_number)?,
row_number,
"counter_cache_misses",
)?,
counter_cancelled_stale_pending: parse_u64(
field(&row, counter_cancelled_stale_pending_col, row_number)?,
row_number,
"counter_cancelled_stale_pending",
)?,
counter_cancelled_evicted_pending: parse_u64(
field(&row, counter_cancelled_evicted_pending_col, row_number)?,
row_number,
"counter_cancelled_evicted_pending",
)?,
});
}
Ok(samples)
}
/// Serialize samples into machine-comparable CSV text.
pub fn to_csv(samples: &[Self]) -> String {
let mut out = String::new();
out.push_str(TILE_PIPELINE_REGRESSION_CSV_HEADER);
for sample in samples {
out.push('\n');
out.push_str(&sample.to_csv_row());
}
out
}
/// Serialize this sample as one CSV row.
pub fn to_csv_row(&self) -> String {
format!(
"{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}",
sample_index = self.sample_index,
sample_image = quote_csv(&self.sample_image),
fps = self.fps,
zoom_level = self.zoom_level,
zoom_pct = self.zoom_pct,
pitch_deg = self.pitch_deg,
yaw_deg = self.yaw_deg,
distance_m = self.distance_m,
center_lat = self.center_lat,
center_lon = self.center_lon,
viewport_width_km = self.viewport_width_km,
mercator_world_width_km = self.mercator_world_width_km,
full_world_x = self.full_world_x,
layer_name = quote_csv(&self.layer_name),
desired_tiles = self.desired_tiles,
raw_candidate_tiles = self.raw_candidate_tiles,
loaded_tiles = self.loaded_tiles,
visible_tiles = self.visible_tiles,
exact_visible_tiles = self.exact_visible_tiles,
fallback_visible_tiles = self.fallback_visible_tiles,
missing_visible_tiles = self.missing_visible_tiles,
overzoomed_visible_tiles = self.overzoomed_visible_tiles,
requested_tiles = self.requested_tiles,
exact_cache_hits = self.exact_cache_hits,
cache_misses = self.cache_misses,
cancelled_stale_pending = self.cancelled_stale_pending,
budget_hit = self.budget_hit,
dropped_by_budget = self.dropped_by_budget,
cache_total_entries = self.cache_total_entries,
cache_loaded_entries = self.cache_loaded_entries,
cache_expired_entries = self.cache_expired_entries,
cache_reloading_entries = self.cache_reloading_entries,
cache_pending_entries = self.cache_pending_entries,
cache_failed_entries = self.cache_failed_entries,
cache_renderable_entries = self.cache_renderable_entries,
queued_requests = self.queued_requests,
in_flight_requests = self.in_flight_requests,
max_concurrent_requests = self.max_concurrent_requests,
known_requests = self.known_requests,
cancelled_in_flight_requests = self.cancelled_in_flight_requests,
counter_frames = self.counter_frames,
counter_requested_tiles = self.counter_requested_tiles,
counter_exact_cache_hits = self.counter_exact_cache_hits,
counter_fallback_hits = self.counter_fallback_hits,
counter_cache_misses = self.counter_cache_misses,
counter_cancelled_stale_pending = self.counter_cancelled_stale_pending,
counter_cancelled_evicted_pending = self.counter_cancelled_evicted_pending,
)
}
}
/// Aggregate statistics computed across a sequence of regression samples.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TilePipelineRegressionSummary {
/// Total number of samples analyzed.
pub total_samples: usize,
/// Longest consecutive run with `exact_visible_tiles == 0`.
pub longest_exact_free_run: usize,
/// Longest consecutive run with `missing_visible_tiles > 0`.
pub longest_missing_run: usize,
/// Longest consecutive run with all visible coverage coming from fallback.
pub longest_fallback_only_run: usize,
/// Maximum missing visible tiles observed in any sample.
pub max_missing_visible_tiles: usize,
/// Maximum queued requests observed in any sample.
pub max_queued_requests: usize,
/// Maximum pending cache entries observed in any sample.
pub max_cache_pending_entries: usize,
/// Maximum failed cache entries observed in any sample.
pub max_cache_failed_entries: usize,
/// Maximum cumulative stale-cancellation counter observed in any sample.
pub max_counter_cancelled_stale_pending: u64,
/// Number of samples where the request pool was fully saturated.
pub saturated_request_pool_samples: usize,
}
impl TilePipelineRegressionSummary {
/// Compute aggregate statistics across `samples`.
pub fn from_samples(samples: &[TilePipelineRegressionSample]) -> Self {
let mut summary = Self {
total_samples: samples.len(),
..Self::default()
};
let mut exact_free_run = 0usize;
let mut missing_run = 0usize;
let mut fallback_only_run = 0usize;
for sample in samples {
if sample.visible_tiles > 0 && sample.exact_visible_tiles == 0 {
exact_free_run += 1;
summary.longest_exact_free_run = summary.longest_exact_free_run.max(exact_free_run);
} else {
exact_free_run = 0;
}
if sample.missing_visible_tiles > 0 {
missing_run += 1;
summary.longest_missing_run = summary.longest_missing_run.max(missing_run);
} else {
missing_run = 0;
}
if sample.visible_tiles > 0
&& sample.exact_visible_tiles == 0
&& sample.fallback_visible_tiles > 0
&& sample.missing_visible_tiles == 0
{
fallback_only_run += 1;
summary.longest_fallback_only_run =
summary.longest_fallback_only_run.max(fallback_only_run);
} else {
fallback_only_run = 0;
}
summary.max_missing_visible_tiles = summary
.max_missing_visible_tiles
.max(sample.missing_visible_tiles);
summary.max_queued_requests = summary.max_queued_requests.max(sample.queued_requests);
summary.max_cache_pending_entries = summary
.max_cache_pending_entries
.max(sample.cache_pending_entries);
summary.max_cache_failed_entries = summary
.max_cache_failed_entries
.max(sample.cache_failed_entries);
summary.max_counter_cancelled_stale_pending = summary
.max_counter_cancelled_stale_pending
.max(sample.counter_cancelled_stale_pending);
if sample.max_concurrent_requests > 0
&& sample.in_flight_requests >= sample.max_concurrent_requests
{
summary.saturated_request_pool_samples += 1;
}
}
summary
}
}
/// Thresholds used to fail a regression run when behavior degrades.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TilePipelineRegressionThresholds {
/// Maximum allowed consecutive exact-free samples.
pub max_exact_free_run: Option<usize>,
/// Maximum allowed consecutive missing-coverage samples.
pub max_missing_run: Option<usize>,
/// Maximum allowed consecutive fallback-only samples.
pub max_fallback_only_run: Option<usize>,
/// Maximum allowed missing visible tiles in any sample.
pub max_missing_visible_tiles: Option<usize>,
/// Maximum allowed queued requests.
pub max_queued_requests: Option<usize>,
/// Maximum allowed pending cache entries.
pub max_cache_pending_entries: Option<usize>,
/// Maximum allowed failed cache entries.
pub max_cache_failed_entries: Option<usize>,
/// Maximum allowed cumulative stale cancellations.
pub max_counter_cancelled_stale_pending: Option<u64>,
}
impl TilePipelineRegressionThresholds {
/// Evaluate `samples` against the configured thresholds.
pub fn evaluate(
&self,
samples: &[TilePipelineRegressionSample],
) -> TilePipelineRegressionEvaluation {
let summary = TilePipelineRegressionSummary::from_samples(samples);
let mut violations = Vec::new();
push_violation_usize(
&mut violations,
"longest_exact_free_run",
summary.longest_exact_free_run,
self.max_exact_free_run,
);
push_violation_usize(
&mut violations,
"longest_missing_run",
summary.longest_missing_run,
self.max_missing_run,
);
push_violation_usize(
&mut violations,
"longest_fallback_only_run",
summary.longest_fallback_only_run,
self.max_fallback_only_run,
);
push_violation_usize(
&mut violations,
"max_missing_visible_tiles",
summary.max_missing_visible_tiles,
self.max_missing_visible_tiles,
);
push_violation_usize(
&mut violations,
"max_queued_requests",
summary.max_queued_requests,
self.max_queued_requests,
);
push_violation_usize(
&mut violations,
"max_cache_pending_entries",
summary.max_cache_pending_entries,
self.max_cache_pending_entries,
);
push_violation_usize(
&mut violations,
"max_cache_failed_entries",
summary.max_cache_failed_entries,
self.max_cache_failed_entries,
);
push_violation_u64(
&mut violations,
"max_counter_cancelled_stale_pending",
summary.max_counter_cancelled_stale_pending,
self.max_counter_cancelled_stale_pending,
);
TilePipelineRegressionEvaluation {
summary,
violations,
}
}
}
/// Result of evaluating a regression run against thresholds.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TilePipelineRegressionEvaluation {
/// Aggregate metrics computed from the run.
pub summary: TilePipelineRegressionSummary,
/// Threshold violations detected for the run.
pub violations: Vec<TilePipelineRegressionViolation>,
}
impl TilePipelineRegressionEvaluation {
/// Returns `true` when no thresholds were violated.
#[inline]
pub fn passed(&self) -> bool {
self.violations.is_empty()
}
}
/// One threshold violation reported by the regression harness.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TilePipelineRegressionViolation {
/// Name of the metric that exceeded its threshold.
pub metric: &'static str,
/// Actual value observed in the run.
pub actual: u64,
/// Configured maximum allowed value.
pub allowed: u64,
}
/// Parse errors produced by [`TilePipelineRegressionSample::parse_csv`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum TilePipelineRegressionParseError {
/// A required CSV column is missing.
#[error("missing required CSV column '{column}'")]
MissingColumn {
/// Required column name.
column: &'static str,
},
/// A CSV row had fewer fields than expected.
#[error("row {row}: missing field '{field}'")]
MissingField {
/// One-based CSV row number.
row: usize,
/// Column or field name.
field: &'static str,
},
/// A field could not be parsed into the expected type.
#[error("row {row}: invalid value '{value}' for field '{field}'")]
InvalidField {
/// One-based CSV row number.
row: usize,
/// Column or field name.
field: &'static str,
/// Original field text.
value: String,
},
/// CSV syntax error such as an unterminated quote.
#[error("row {row}: {message}")]
CsvSyntax {
/// One-based CSV row number.
row: usize,
/// Human-readable error message.
message: &'static str,
},
}
impl TilePipelineRegressionParseError {
fn with_row(self, row: usize) -> Self {
match self {
Self::CsvSyntax { message, .. } => Self::CsvSyntax { row, message },
other => other,
}
}
}
fn first_visible_tile_layer(state: &MapState) -> Option<&TileLayer> {
state.layers().iter().find_map(|layer| {
if !layer.visible() {
return None;
}
layer.as_any().downcast_ref::<TileLayer>()
})
}
fn push_violation_usize(
out: &mut Vec<TilePipelineRegressionViolation>,
metric: &'static str,
actual: usize,
allowed: Option<usize>,
) {
if let Some(allowed) = allowed.filter(|allowed| actual > *allowed) {
out.push(TilePipelineRegressionViolation {
metric,
actual: actual as u64,
allowed: allowed as u64,
});
}
}
fn push_violation_u64(
out: &mut Vec<TilePipelineRegressionViolation>,
metric: &'static str,
actual: u64,
allowed: Option<u64>,
) {
if let Some(allowed) = allowed.filter(|allowed| actual > *allowed) {
out.push(TilePipelineRegressionViolation {
metric,
actual,
allowed,
});
}
}
fn find_column(
header: &[String],
candidates: &[&'static str],
) -> Result<usize, TilePipelineRegressionParseError> {
header
.iter()
.position(|field| candidates.iter().any(|candidate| field == candidate))
.ok_or(TilePipelineRegressionParseError::MissingColumn {
column: candidates[0],
})
}
fn field(
row: &[String],
index: usize,
row_number: usize,
) -> Result<&str, TilePipelineRegressionParseError> {
row.get(index)
.map(String::as_str)
.ok_or(TilePipelineRegressionParseError::MissingField {
row: row_number,
field: "csv field",
})
}
fn parse_bool(
value: &str,
row: usize,
field: &'static str,
) -> Result<bool, TilePipelineRegressionParseError> {
match value {
"true" => Ok(true),
"false" => Ok(false),
_ => Err(TilePipelineRegressionParseError::InvalidField {
row,
field,
value: value.to_owned(),
}),
}
}
fn parse_u8(
value: &str,
row: usize,
field: &'static str,
) -> Result<u8, TilePipelineRegressionParseError> {
value
.parse()
.map_err(|_| TilePipelineRegressionParseError::InvalidField {
row,
field,
value: value.to_owned(),
})
}
fn parse_usize(
value: &str,
row: usize,
field: &'static str,
) -> Result<usize, TilePipelineRegressionParseError> {
value
.parse()
.map_err(|_| TilePipelineRegressionParseError::InvalidField {
row,
field,
value: value.to_owned(),
})
}
fn parse_u64(
value: &str,
row: usize,
field: &'static str,
) -> Result<u64, TilePipelineRegressionParseError> {
value
.parse()
.map_err(|_| TilePipelineRegressionParseError::InvalidField {
row,
field,
value: value.to_owned(),
})
}
fn parse_f64(
value: &str,
row: usize,
field: &'static str,
) -> Result<f64, TilePipelineRegressionParseError> {
value
.parse()
.map_err(|_| TilePipelineRegressionParseError::InvalidField {
row,
field,
value: value.to_owned(),
})
}
fn split_csv_line(line: &str) -> Result<Vec<String>, TilePipelineRegressionParseError> {
let mut fields = Vec::new();
let mut current = String::new();
let mut chars = line.chars().peekable();
let mut in_quotes = false;
while let Some(ch) = chars.next() {
match ch {
'"' => {
if in_quotes && chars.peek() == Some(&'"') {
current.push('"');
let _ = chars.next();
} else {
in_quotes = !in_quotes;
}
}
',' if !in_quotes => {
fields.push(current);
current = String::new();
}
_ => current.push(ch),
}
}
if in_quotes {
return Err(TilePipelineRegressionParseError::CsvSyntax {
row: 1,
message: "unterminated quoted field",
});
}
fields.push(current);
Ok(fields)
}
fn quote_csv(value: &str) -> String {
let escaped = value.replace('"', "\"\"");
format!("\"{escaped}\"")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
layers::TileLayer, DecodedImage, GeoCoord, MapState, TileData, TileResponse, TileSource,
};
use rustial_math::TileId;
use std::sync::Mutex;
const DEBUG_FIXTURE_CSV: &str = include_str!("../../../docs/debug/rustial_debug_values.csv");
struct ImmediateRasterSource {
ready: Mutex<Vec<(TileId, Result<TileResponse, crate::TileError>)>>,
}
impl ImmediateRasterSource {
fn new() -> Self {
Self {
ready: Mutex::new(Vec::new()),
}
}
}
impl TileSource for ImmediateRasterSource {
fn request(&self, id: TileId) {
let data = TileData::Raster(DecodedImage {
width: 256,
height: 256,
data: vec![200u8; 256 * 256 * 4].into(),
});
self.ready
.lock()
.expect("ready queue lock")
.push((id, Ok(TileResponse::from_data(data))));
}
fn poll(&self) -> Vec<(TileId, Result<TileResponse, crate::TileError>)> {
std::mem::take(&mut *self.ready.lock().expect("ready queue lock"))
}
}
#[test]
fn parse_authoritative_debug_fixture() {
let samples = TilePipelineRegressionSample::parse_csv(DEBUG_FIXTURE_CSV)
.expect("fixture CSV should parse");
assert!(samples.len() >= 3);
assert_eq!(samples.first().expect("first sample").sample_index, 1);
assert_eq!(
samples.last().expect("last sample").sample_index,
samples.len()
);
assert!(samples
.iter()
.all(|sample| sample.layer_name == "__rustial_builtin_http_tiles"));
assert!(samples.iter().all(|sample| sample.sample_index >= 1));
assert!(samples.iter().any(|sample| sample.exact_visible_tiles > 0));
assert!(
samples
.iter()
.map(|sample| sample.missing_visible_tiles)
.max()
.unwrap_or(0)
<= 40
);
}
#[test]
fn csv_round_trip_preserves_reduced_schema() {
let samples = TilePipelineRegressionSample::parse_csv(DEBUG_FIXTURE_CSV)
.expect("fixture CSV should parse");
let mid = samples.len() / 2;
let trimmed = vec![
samples.first().expect("first sample").clone(),
samples[mid].clone(),
samples.last().expect("last sample").clone(),
];
let csv = TilePipelineRegressionSample::to_csv(&trimmed);
let reparsed = TilePipelineRegressionSample::parse_csv(&csv).expect("round-trip parse");
assert_eq!(reparsed, trimmed);
}
#[test]
fn threshold_evaluation_passes_healthy_fixture() {
let samples = TilePipelineRegressionSample::parse_csv(DEBUG_FIXTURE_CSV)
.expect("fixture CSV should parse");
let thresholds = TilePipelineRegressionThresholds {
max_exact_free_run: Some(4),
max_fallback_only_run: Some(4),
max_missing_visible_tiles: Some(40),
..TilePipelineRegressionThresholds::default()
};
let evaluation = thresholds.evaluate(&samples);
assert!(
evaluation.passed(),
"healthy fixture should pass thresholds: {:?}",
evaluation.violations
);
assert!(evaluation.summary.longest_exact_free_run <= 4);
assert!(evaluation.summary.max_missing_visible_tiles <= 40);
}
#[test]
fn capture_sample_from_map_state() {
let mut state = MapState::new();
state.push_layer(Box::new(TileLayer::new(
"regression-test",
Box::new(ImmediateRasterSource::new()),
64,
)));
state.set_viewport(1280, 720);
state.set_camera_target(GeoCoord::from_lat_lon(48.8566, 2.3522));
state.set_camera_distance(20_000.0);
state.update();
state.update();
let sample =
TilePipelineRegressionSample::capture_from_map_state(&state, 1, "frame_0001", 60.0)
.expect("tile pipeline sample");
assert_eq!(sample.sample_index, 1);
assert_eq!(sample.sample_image, "frame_0001");
assert_eq!(sample.layer_name, "regression-test");
assert!(sample.visible_tiles > 0);
assert!(sample.loaded_tiles > 0);
assert_eq!(sample.counter_frames, 2);
}
}