use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum LadderError {
#[error("ladder step must be >= 1 (0 would give every value the same entry zoom)")]
ZeroStep,
#[error("entry-zoom spec named no values: expected COLUMN:VALUE=ZOOM[,VALUE=ZOOM...]")]
Empty,
#[error("entry-zoom value {0} is not finite")]
NonFiniteValue(f64),
}
#[derive(Debug, Clone, PartialEq)]
pub struct EntryZoomLadder {
rungs: Vec<(f64, u8)>,
}
impl EntryZoomLadder {
pub fn dense_rank(
values: impl IntoIterator<Item = f64>,
base_zoom: u8,
step: u8,
max_zoom: u8,
) -> Result<Self, LadderError> {
if step == 0 {
return Err(LadderError::ZeroStep);
}
let mut distinct: Vec<f64> = values.into_iter().filter(|v| v.is_finite()).collect();
distinct.sort_by(|a, b| b.total_cmp(a)); distinct.dedup_by(|a, b| a.total_cmp(b).is_eq());
let total = distinct.len();
let rungs: Vec<(f64, u8)> = distinct
.into_iter()
.enumerate()
.map_while(|(rank, value)| {
let offset = (rank as u32).saturating_mul(step as u32);
let zoom = (base_zoom as u32).saturating_add(offset);
(zoom <= max_zoom as u32).then_some((value, zoom as u8))
})
.collect();
if rungs.len() < total {
log::warn!(
"[assign] entry-zoom ladder: {} of {total} distinct value(s) rank \
past zoom {max_zoom} at step {step} and get no entry zoom — they \
keep the ordinary visibility gate and thinning. Widen the zoom \
range, lower --ladder-step, or name the rungs with --entry-zoom.",
total - rungs.len()
);
}
Ok(Self::sorted(rungs))
}
pub fn explicit(pairs: impl IntoIterator<Item = (f64, u8)>) -> Result<Self, LadderError> {
let rungs: Vec<(f64, u8)> = pairs.into_iter().collect();
if rungs.is_empty() {
return Err(LadderError::Empty);
}
if let Some((v, _)) = rungs.iter().find(|(v, _)| !v.is_finite()) {
return Err(LadderError::NonFiniteValue(*v));
}
Ok(Self::sorted(rungs))
}
fn sorted(mut rungs: Vec<(f64, u8)>) -> Self {
rungs.sort_by(|a, b| a.0.total_cmp(&b.0));
rungs.dedup_by(|a, b| a.0.total_cmp(&b.0).is_eq());
Self { rungs }
}
pub fn entry_zoom(&self, value: Option<f64>) -> Option<u8> {
let v = value?;
if !v.is_finite() {
return None;
}
self.rungs
.binary_search_by(|(rv, _)| rv.total_cmp(&v))
.ok()
.map(|i| self.rungs[i].1)
}
pub fn to_map(&self) -> BTreeMap<String, u8> {
self.rungs
.iter()
.map(|(v, z)| (format_value(*v), *z))
.collect()
}
pub fn len(&self) -> usize {
self.rungs.len()
}
pub fn is_empty(&self) -> bool {
self.rungs.is_empty()
}
}
fn format_value(v: f64) -> String {
v.to_string()
}
#[derive(Debug, Clone, PartialEq)]
pub enum EntryZoomKind {
DenseRank {
step: u8,
},
Explicit(Vec<(f64, u8)>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct EntryZoomSpec {
pub column: String,
pub kind: EntryZoomKind,
}
pub fn build_ladder(
spec: &EntryZoomSpec,
values: &[Option<f64>],
level_zooms: &[Option<u8>],
) -> Result<EntryZoomLadder, LadderError> {
match &spec.kind {
EntryZoomKind::Explicit(pairs) => {
EntryZoomLadder::explicit(snap_to_observed(pairs, values, &spec.column))
}
EntryZoomKind::DenseRank { step } => {
let base = level_zooms.iter().flatten().copied().min().unwrap_or(0);
let max = level_zooms
.iter()
.flatten()
.copied()
.max()
.unwrap_or(u8::MAX);
EntryZoomLadder::dense_rank(values.iter().flatten().copied(), base, *step, max)
}
}
}
const RUNG_MATCH_REL_TOLERANCE: f64 = 1e-6;
fn snap_to_observed(pairs: &[(f64, u8)], values: &[Option<f64>], column: &str) -> Vec<(f64, u8)> {
let mut observed: Vec<f64> = values
.iter()
.flatten()
.copied()
.filter(|v| v.is_finite())
.collect();
observed.sort_by(f64::total_cmp);
observed.dedup_by(|a, b| a.total_cmp(b).is_eq());
if observed.is_empty() {
return pairs.to_vec();
}
let mut out = Vec::with_capacity(pairs.len());
for &(want, zoom) in pairs {
let nearest = observed
.iter()
.copied()
.min_by(|a, b| (a - want).abs().total_cmp(&(b - want).abs()));
match nearest {
Some(v) if (v - want).abs() <= RUNG_MATCH_REL_TOLERANCE * want.abs().max(1.0) => {
out.push((v, zoom));
}
_ => log::warn!(
"[assign] entry-zoom rung {want} matches no value in column \
{column:?}, so nothing enters at zoom {zoom}; check the spec \
against the column's actual values"
),
}
}
out
}
pub fn entry_levels(
ladder: &EntryZoomLadder,
values: &[Option<f64>],
level_zooms: &[Option<u8>],
) -> Vec<Option<u8>> {
if ladder.is_empty() || level_zooms.iter().all(|z| z.is_none()) {
if !ladder.is_empty() {
log::warn!(
"[assign] entry-zoom ladder ignored: the level plan has no zooms \
(a --gsd plan cannot anchor an entry zoom); use --min-zoom/--max-zoom"
);
}
return vec![None; values.len()];
}
let finest = (level_zooms.len().saturating_sub(1)) as u8;
values
.iter()
.map(|v| {
let zoom = ladder.entry_zoom(*v)?;
let level = level_zooms
.iter()
.position(|lz| lz.is_some_and(|z| z >= zoom))
.map(|i| i as u8)
.unwrap_or(finest);
Some(level)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_rungs_match_values_widened_from_f32() {
let column: Vec<Option<f64>> = [0.5f32, 0.3, 0.1, 0.3]
.iter()
.map(|v| Some(*v as f64))
.collect();
let spec = EntryZoomSpec {
column: "level".to_string(),
kind: EntryZoomKind::Explicit(vec![(0.5, 8), (0.3, 9), (0.1, 12)]),
};
let ladder = build_ladder(&spec, &column, &[Some(8), Some(10), Some(12)]).unwrap();
let got: Vec<Option<u8>> = column.iter().map(|v| ladder.entry_zoom(*v)).collect();
assert_eq!(
got,
vec![Some(8), Some(9), Some(12), Some(9)],
"every spec'd rung must match its column value despite f32 widening"
);
}
#[test]
fn values_past_the_finest_zoom_get_no_ladder_opinion() {
let values: Vec<f64> = (0..12).map(f64::from).collect();
let ladder = EntryZoomLadder::dense_rank(values.iter().copied(), 0, 1, 6).unwrap();
assert_eq!(ladder.entry_zoom(Some(11.0)), Some(0));
assert_eq!(ladder.entry_zoom(Some(5.0)), Some(6));
for surplus in [4.0, 3.0, 2.0, 1.0, 0.0] {
assert_eq!(
ladder.entry_zoom(Some(surplus)),
None,
"{surplus} has no rung, so it must keep the ordinary behaviour \
rather than being pinned to the finest level"
);
}
}
#[test]
fn dense_rank_places_the_strongest_value_earliest() {
let l =
EntryZoomLadder::dense_rank([100.0, 250.0, 600.0, 1500.0, 5000.0], 8, 1, 14).unwrap();
assert_eq!(l.entry_zoom(Some(5000.0)), Some(8));
assert_eq!(l.entry_zoom(Some(1500.0)), Some(9));
assert_eq!(l.entry_zoom(Some(600.0)), Some(10));
assert_eq!(l.entry_zoom(Some(250.0)), Some(11));
assert_eq!(l.entry_zoom(Some(100.0)), Some(12));
}
#[test]
fn ladder_is_scale_free_over_a_narrow_value_range() {
let narrow_vals = [0.5, 0.45, 0.4, 0.35, 0.3]; let wide_vals = [1e6, 90_000.0, 5000.0, 100.0, 1.0]; let narrow = EntryZoomLadder::dense_rank(narrow_vals, 0, 1, 14).unwrap();
let wide = EntryZoomLadder::dense_rank(wide_vals, 0, 1, 14).unwrap();
let zooms = |l: &EntryZoomLadder, vs: [f64; 5]| -> Vec<Option<u8>> {
vs.iter().map(|&v| l.entry_zoom(Some(v))).collect()
};
let expected: Vec<Option<u8>> = (0..5).map(|z| Some(z as u8)).collect();
assert_eq!(zooms(&narrow, narrow_vals), expected);
assert_eq!(zooms(&wide, wide_vals), expected);
}
#[test]
fn repeated_values_collapse_to_one_rung() {
let l =
EntryZoomLadder::dense_rank([0.2, 0.2, 0.2, 0.2, 0.5, 0.5, 0.35], 4, 1, 14).unwrap();
assert_eq!(l.len(), 3);
assert_eq!(l.entry_zoom(Some(0.5)), Some(4));
assert_eq!(l.entry_zoom(Some(0.35)), Some(5));
assert_eq!(l.entry_zoom(Some(0.2)), Some(6));
}
#[test]
fn step_widens_the_spacing_and_ranks_past_the_finest_zoom_are_dropped() {
let l = EntryZoomLadder::dense_rank([1.0, 2.0, 3.0, 4.0], 0, 3, 7).unwrap();
assert_eq!(l.entry_zoom(Some(4.0)), Some(0));
assert_eq!(l.entry_zoom(Some(3.0)), Some(3));
assert_eq!(l.entry_zoom(Some(2.0)), Some(6));
assert_eq!(l.entry_zoom(Some(1.0)), None);
assert_eq!(l.len(), 3, "only the rungs that fit are kept");
}
#[test]
fn a_rung_landing_exactly_on_the_finest_zoom_is_kept() {
let l = EntryZoomLadder::dense_rank([1.0, 2.0, 3.0], 0, 1, 2).unwrap();
assert_eq!(l.entry_zoom(Some(3.0)), Some(0));
assert_eq!(l.entry_zoom(Some(2.0)), Some(1));
assert_eq!(
l.entry_zoom(Some(1.0)),
Some(2),
"exactly max_zoom, not past it"
);
}
#[test]
fn explicit_rung_naming_no_observed_value_is_dropped() {
let column: Vec<Option<f64>> = vec![Some(0.5), Some(0.2)];
let spec = EntryZoomSpec {
column: "level".to_string(),
kind: EntryZoomKind::Explicit(vec![(0.5, 8), (0.9, 9), (0.2, 12)]),
};
let ladder = build_ladder(&spec, &column, &[Some(8), Some(10), Some(12)]).unwrap();
assert_eq!(ladder.len(), 2, "0.9 names no row");
assert_eq!(ladder.entry_zoom(Some(0.5)), Some(8));
assert_eq!(ladder.entry_zoom(Some(0.2)), Some(12));
}
#[test]
fn explicit_rung_does_not_snap_to_a_distant_value() {
let column: Vec<Option<f64>> = vec![Some(1.0), Some(2.0)];
let spec = EntryZoomSpec {
column: "level".to_string(),
kind: EntryZoomKind::Explicit(vec![(1.5, 8)]),
};
let err = build_ladder(&spec, &column, &[Some(8), Some(12)]).unwrap_err();
assert!(
matches!(err, LadderError::Empty),
"1.5 is nearest to both but matches neither: {err}"
);
}
#[test]
fn unknown_absent_and_nonfinite_values_yield_no_entry_zoom() {
let l = EntryZoomLadder::dense_rank([1.0, 2.0], 0, 1, 10).unwrap();
assert_eq!(l.entry_zoom(Some(1.5)), None, "not a rung");
assert_eq!(l.entry_zoom(None), None, "null column value");
assert_eq!(l.entry_zoom(Some(f64::NAN)), None);
assert_eq!(l.entry_zoom(Some(f64::INFINITY)), None);
}
#[test]
fn nonfinite_inputs_are_ignored_when_deriving() {
let l = EntryZoomLadder::dense_rank([1.0, f64::NAN, 2.0, f64::INFINITY], 0, 1, 10).unwrap();
assert_eq!(l.len(), 2, "only the finite values become rungs");
assert_eq!(l.entry_zoom(Some(2.0)), Some(0));
assert_eq!(l.entry_zoom(Some(1.0)), Some(1));
}
#[test]
fn explicit_rungs_are_honoured_verbatim() {
let l = EntryZoomLadder::explicit([(5000.0, 8), (1000.0, 9), (200.0, 12)]).unwrap();
assert_eq!(l.entry_zoom(Some(5000.0)), Some(8));
assert_eq!(l.entry_zoom(Some(200.0)), Some(12));
assert_eq!(l.entry_zoom(Some(600.0)), None, "unlisted values are free");
}
#[test]
fn degenerate_specs_are_rejected() {
assert_eq!(
EntryZoomLadder::dense_rank([1.0, 2.0], 0, 0, 10),
Err(LadderError::ZeroStep)
);
assert_eq!(
EntryZoomLadder::explicit(std::iter::empty()),
Err(LadderError::Empty)
);
assert!(matches!(
EntryZoomLadder::explicit([(f64::NAN, 3)]),
Err(LadderError::NonFiniteValue(v)) if v.is_nan()
));
}
#[test]
fn empty_input_gives_an_inert_ladder() {
let l = EntryZoomLadder::dense_rank(std::iter::empty(), 0, 1, 10).unwrap();
assert!(l.is_empty());
assert_eq!(l.entry_zoom(Some(1.0)), None);
}
#[test]
fn provenance_map_is_ordered_by_value() {
let l = EntryZoomLadder::dense_rank([0.2, 0.5, 0.35], 3, 1, 14).unwrap();
let m = l.to_map();
assert_eq!(m["0.5"], 3, "strongest enters at the base zoom");
assert_eq!(m["0.35"], 4);
assert_eq!(m["0.2"], 5);
assert_eq!(m.len(), 3);
}
#[test]
fn entry_zooms_resolve_to_level_indices() {
let zooms: Vec<Option<u8>> = (6..=14).map(|z| Some(z as u8)).collect();
let ladder = EntryZoomLadder::explicit([(0.5, 8), (0.35, 10), (0.2, 12)]).unwrap();
let values = [Some(0.5), Some(0.35), Some(0.2), Some(0.9), None];
let got = entry_levels(&ladder, &values, &zooms);
assert_eq!(
got,
vec![Some(2), Some(4), Some(6), None, None],
"z8/z10/z12 are levels 2/4/6 of a plan starting at z6"
);
}
#[test]
fn entry_zooms_outside_the_plan_clamp() {
let zooms: Vec<Option<u8>> = (6..=9).map(|z| Some(z as u8)).collect();
let ladder = EntryZoomLadder::explicit([(1.0, 0), (2.0, 30)]).unwrap();
let got = entry_levels(&ladder, &[Some(1.0), Some(2.0)], &zooms);
assert_eq!(got, vec![Some(0), Some(3)], "clamped to coarsest / finest");
}
#[test]
fn ladder_is_inert_without_zooms_in_the_plan() {
let ladder = EntryZoomLadder::explicit([(1.0, 3)]).unwrap();
let got = entry_levels(&ladder, &[Some(1.0), Some(1.0)], &[None, None]);
assert_eq!(got, vec![None, None]);
}
#[test]
fn derived_ladder_spans_the_level_plan() {
let zooms: Vec<Option<u8>> = (6..=14).map(|z| Some(z as u8)).collect();
let spec = EntryZoomSpec {
column: "level".to_string(),
kind: EntryZoomKind::DenseRank { step: 1 },
};
let values = [Some(0.2), Some(0.5), Some(0.35), None, Some(0.5)];
let ladder = build_ladder(&spec, &values, &zooms).unwrap();
assert_eq!(ladder.entry_zoom(Some(0.5)), Some(6), "strongest at z6");
assert_eq!(ladder.entry_zoom(Some(0.35)), Some(7));
assert_eq!(ladder.entry_zoom(Some(0.2)), Some(8));
}
#[test]
fn explicit_spec_is_passed_through() {
let spec = EntryZoomSpec {
column: "level".to_string(),
kind: EntryZoomKind::Explicit(vec![(0.5, 8), (0.2, 12)]),
};
let ladder = build_ladder(&spec, &[], &[Some(0), Some(14)]).unwrap();
assert_eq!(ladder.entry_zoom(Some(0.5)), Some(8));
assert_eq!(ladder.entry_zoom(Some(0.2)), Some(12));
}
}