Skip to main content

stt_core/
budget.rs

1//! Tile size budget enforcement and intelligent feature dropping
2//!
3//! This module provides strategies for keeping tiles within size limits
4//! by intelligently dropping less important features.
5
6use crate::tile::Feature;
7#[cfg(test)]
8use crate::types::GeometryType;
9
10/// Feature importance scorer
11#[derive(Debug, Clone, Copy)]
12pub enum ImportanceScorer {
13    /// Score by geometry size (larger = more important)
14    GeometrySize,
15    /// Score by property count (more properties = more important)
16    PropertyCount,
17    /// Score by feature ID (useful for debugging)
18    FeatureId,
19    /// Random scoring (for testing)
20    Random,
21    /// Combined scoring (geometry + properties)
22    Combined,
23}
24
25impl ImportanceScorer {
26    /// Calculate importance score for a feature (higher = more important)
27    pub fn score(&self, feature: &Feature) -> f64 {
28        match self {
29            Self::GeometrySize => feature.positions.len() as f64,
30            Self::PropertyCount => {
31                // Features with more properties might be more important
32                feature.properties.len() as f64
33            }
34            Self::FeatureId => {
35                // Lower IDs = higher importance (arbitrary but deterministic)
36                1.0 / (feature.id as f64 + 1.0)
37            }
38            Self::Random => {
39                // Random for testing
40                use std::collections::hash_map::RandomState;
41                use std::hash::{BuildHasher, Hash, Hasher};
42                let mut hasher = RandomState::new().build_hasher();
43                feature.id.hash(&mut hasher);
44                (hasher.finish() as f64) / (u64::MAX as f64)
45            }
46            Self::Combined => {
47                let geom_score = feature.positions.len() as f64;
48                let prop_score = feature.properties.len() as f64 * 10.0;
49                geom_score + prop_score
50            }
51        }
52    }
53}
54
55/// Tile size budget configuration
56#[derive(Debug, Clone)]
57pub struct TileBudget {
58    /// Maximum uncompressed size in bytes
59    pub max_uncompressed_size: usize,
60    /// Maximum compressed size in bytes (after compression)
61    pub max_compressed_size: usize,
62    /// Maximum number of features
63    pub max_feature_count: usize,
64    /// Importance scorer to use for feature dropping
65    pub scorer: ImportanceScorer,
66}
67
68impl Default for TileBudget {
69    fn default() -> Self {
70        Self {
71            max_uncompressed_size: 500 * 1024, // 500KB uncompressed
72            max_compressed_size: 128 * 1024,   // 128KB compressed
73            max_feature_count: 10_000,
74            scorer: ImportanceScorer::Combined,
75        }
76    }
77}
78
79impl TileBudget {
80    /// Create a new tile budget with custom limits
81    pub fn new(
82        max_uncompressed_size: usize,
83        max_compressed_size: usize,
84        max_feature_count: usize,
85    ) -> Self {
86        Self {
87            max_uncompressed_size,
88            max_compressed_size,
89            max_feature_count,
90            scorer: ImportanceScorer::Combined,
91        }
92    }
93
94    /// Set the importance scorer
95    pub fn with_scorer(mut self, scorer: ImportanceScorer) -> Self {
96        self.scorer = scorer;
97        self
98    }
99
100    /// Estimate the uncompressed size of features
101    pub fn estimate_size(features: &[Feature]) -> usize {
102        let mut size = 0;
103
104        for feature in features {
105            // Rough geometry size (16 bytes per lon/lat pair)
106            size += feature.positions.len() * 16;
107
108            // Properties size (rough estimate)
109            for (key, value) in &feature.properties {
110                size += key.len();
111                size += Self::estimate_value_size(value);
112            }
113
114            // Feature metadata overhead
115            size += 32; // ID, type, time range, etc.
116        }
117
118        size
119    }
120
121    /// Estimate the size of a property value
122    fn estimate_value_size(value: &crate::tile::Value) -> usize {
123        match value {
124            crate::tile::Value::String(s) => s.len(),
125            crate::tile::Value::Double(_) => 8,
126            crate::tile::Value::Float(_) => 4,
127            crate::tile::Value::Int(_) => 8,
128            crate::tile::Value::UInt(_) => 8,
129            crate::tile::Value::Bool(_) => 1,
130        }
131    }
132
133    /// Enforce the budget over an *opaque, externally-owned* collection of
134    /// `count` items, identified only by index.
135    ///
136    /// This is the type-impedance bridge used by stt-build: the tiler's
137    /// per-tile feature representation (`TileFeature` — an enum over a borrowed
138    /// `ParsedFeature` or an owned clipped segment) is not a [`Feature`], and
139    /// converting it into one (and back) would be both lossy and wasteful.
140    /// Instead the caller supplies two closures over its own collection:
141    ///
142    /// * `score(i)` — importance of item `i` (higher = keep). Mirror the
143    ///   [`ImportanceScorer`] semantics for the chosen strategy.
144    /// * `size(i)`  — estimated uncompressed bytes of item `i`.
145    ///
146    /// The drop policy is identical to [`Self::enforce`]:
147    /// 1. **Count cap** (`max_feature_count`): keep the highest-scored items.
148    /// 2. **Size cap** (`max_uncompressed_size`): greedily keep items by
149    ///    descending importance-per-byte until the target (90% of the cap) is
150    ///    reached.
151    ///
152    /// Returns the indices to KEEP, **sorted ascending** so the caller can
153    /// preserve its original feature order. Nothing is dropped (every index is
154    /// returned) when the collection already fits — guaranteeing the
155    /// default-off / under-budget path is a no-op.
156    pub fn enforce_indexed<S, Z>(&self, count: usize, score: S, size: Z) -> Vec<usize>
157    where
158        S: Fn(usize) -> f64,
159        Z: Fn(usize) -> usize,
160    {
161        // Fast path: a collection within BOTH caps is returned untouched. This
162        // is what makes a tile under the budget byte-for-byte identical to a
163        // build with no budget at all.
164        let total_size: usize = (0..count).map(&size).sum();
165        if count <= self.max_feature_count && total_size <= self.max_uncompressed_size {
166            return (0..count).collect();
167        }
168
169        // Pre-score every item once.
170        let mut scored: Vec<(usize, f64, usize)> = (0..count)
171            .map(|i| (i, score(i), size(i)))
172            .collect();
173
174        // Step 1: count cap — keep the highest-scored items.
175        if scored.len() > self.max_feature_count {
176            scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
177            scored.truncate(self.max_feature_count);
178        }
179
180        // Step 2: size cap — greedy keep by importance-per-byte. Identical
181        // policy to `drop_by_size` (target = 90% of the cap, with the same
182        // slight-overshoot tolerance) but operating on indices.
183        let kept_size: usize = scored.iter().map(|(_, _, s)| *s).sum();
184        let mut keep: Vec<usize> = if kept_size > self.max_uncompressed_size {
185            let target = (self.max_uncompressed_size as f64 * 0.9) as usize;
186            scored.sort_by(|a, b| {
187                let ratio_a = a.1 / (a.2.max(1) as f64);
188                let ratio_b = b.1 / (b.2.max(1) as f64);
189                ratio_b
190                    .partial_cmp(&ratio_a)
191                    .unwrap_or(std::cmp::Ordering::Equal)
192            });
193            let mut kept = Vec::new();
194            let mut total = 0usize;
195            for (i, _, s) in &scored {
196                if total + s <= target {
197                    total += s;
198                    kept.push(*i);
199                } else if total < target * 95 / 100 && total + s <= target * 105 / 100 {
200                    // Within 5% of budget — allow a slight overshoot to fill it.
201                    total += s;
202                    kept.push(*i);
203                }
204            }
205            kept
206        } else {
207            scored.into_iter().map(|(i, _, _)| i).collect()
208        };
209
210        // Restore the caller's original order so feature emission is stable.
211        keep.sort_unstable();
212        keep
213    }
214
215    /// Score one *opaque* item for the budget's configured scorer, given the
216    /// raw signals the tiler can cheaply produce: geometry vertex count and
217    /// property count. Mirrors [`ImportanceScorer::score`] but without needing
218    /// a [`Feature`]. `Random`/`FeatureId` are not reachable via this path
219    /// (the budget always uses a deterministic geometry/combined strategy from
220    /// stt-build), so they fall back to the combined formula.
221    pub fn score_signals(&self, vertex_count: usize, property_count: usize) -> f64 {
222        match self.scorer {
223            ImportanceScorer::GeometrySize => vertex_count as f64,
224            ImportanceScorer::PropertyCount => property_count as f64,
225            ImportanceScorer::Combined
226            | ImportanceScorer::FeatureId
227            | ImportanceScorer::Random => {
228                vertex_count as f64 + property_count as f64 * 10.0
229            }
230        }
231    }
232
233    /// Drop features to fit within budget constraints
234    /// Returns (kept_features, dropped_count)
235    pub fn enforce(&self, mut features: Vec<Feature>) -> (Vec<Feature>, usize) {
236        let original_count = features.len();
237
238        // Check feature count limit
239        if features.len() > self.max_feature_count {
240            features = self.drop_by_count(features, self.max_feature_count);
241        }
242
243        // Check size limit
244        let size = Self::estimate_size(&features);
245        if size > self.max_uncompressed_size {
246            let target_size = (self.max_uncompressed_size as f64 * 0.9) as usize; // 90% of budget
247            features = self.drop_by_size(features, target_size);
248        }
249
250        let dropped_count = original_count - features.len();
251        (features, dropped_count)
252    }
253
254    /// Drop features to meet a count limit
255    fn drop_by_count(&self, features: Vec<Feature>, max_count: usize) -> Vec<Feature> {
256        if features.len() <= max_count {
257            return features;
258        }
259
260        // Score all features
261        let mut scored: Vec<(Feature, f64)> = features
262            .into_iter()
263            .map(|f| {
264                let score = self.scorer.score(&f);
265                (f, score)
266            })
267            .collect();
268
269        // Sort by score (descending - highest score first)
270        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
271
272        // Keep only the most important features
273        scored.truncate(max_count);
274        scored.into_iter().map(|(f, _)| f).collect()
275    }
276
277    /// Drop features to meet a size limit
278    fn drop_by_size(&self, features: Vec<Feature>, target_size: usize) -> Vec<Feature> {
279        let current_size = Self::estimate_size(&features);
280        if current_size <= target_size {
281            return features;
282        }
283
284        // Score all features
285        let mut scored: Vec<(Feature, f64, usize)> = features
286            .into_iter()
287            .map(|f| {
288                let score = self.scorer.score(&f);
289                let size = self.estimate_feature_size(&f);
290                (f, score, size)
291            })
292            .collect();
293
294        // Sort by score/size ratio (importance per byte - higher is better)
295        scored.sort_by(|a, b| {
296            let ratio_a = a.1 / (a.2 as f64);
297            let ratio_b = b.1 / (b.2 as f64);
298            ratio_b
299                .partial_cmp(&ratio_a)
300                .unwrap_or(std::cmp::Ordering::Equal)
301        });
302
303        // Greedily keep features until we hit the budget
304        let mut kept = Vec::new();
305        let mut total_size = 0;
306
307        for (feature, _, size) in scored {
308            if total_size + size <= target_size {
309                total_size += size;
310                kept.push(feature);
311            } else if total_size < target_size * 95 / 100 {
312                // If we're more than 5% under budget, try to fit this feature
313                // even if it goes slightly over
314                if total_size + size <= target_size * 105 / 100 {
315                    total_size += size;
316                    kept.push(feature);
317                }
318            }
319        }
320
321        kept
322    }
323
324    /// Estimate the size of a single feature
325    fn estimate_feature_size(&self, feature: &Feature) -> usize {
326        let mut size = feature.positions.len() * 16;
327
328        for (key, value) in &feature.properties {
329            size += key.len();
330            size += Self::estimate_value_size(value);
331        }
332
333        size + 32 // metadata overhead
334    }
335}
336
337/// Statistics about budget enforcement
338#[derive(Debug, Clone, Default)]
339pub struct BudgetStats {
340    pub tiles_processed: usize,
341    pub tiles_reduced: usize,
342    pub features_dropped: usize,
343    pub original_size: usize,
344    pub reduced_size: usize,
345}
346
347impl BudgetStats {
348    /// Add stats from processing a tile
349    pub fn add_tile(
350        &mut self,
351        original_count: usize,
352        final_count: usize,
353        original_size: usize,
354        final_size: usize,
355    ) {
356        self.tiles_processed += 1;
357        if final_count < original_count {
358            self.tiles_reduced += 1;
359        }
360        self.features_dropped += original_count - final_count;
361        self.original_size += original_size;
362        self.reduced_size += final_size;
363    }
364
365    /// Get the average reduction ratio
366    pub fn reduction_ratio(&self) -> f64 {
367        if self.original_size == 0 {
368            return 1.0;
369        }
370        self.reduced_size as f64 / self.original_size as f64
371    }
372
373    /// Get the percentage of features dropped
374    pub fn features_dropped_pct(&self) -> f64 {
375        if self.tiles_processed == 0 {
376            return 0.0;
377        }
378        (self.features_dropped as f64 / self.tiles_processed as f64) * 100.0
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use std::collections::HashMap;
386
387    fn create_test_feature(id: u64, geometry_size: usize, property_count: usize) -> Feature {
388        let positions = vec![crate::tile::Position { lon: 0.0, lat: 0.0 }; geometry_size];
389        let mut properties = HashMap::new();
390
391        for i in 0..property_count {
392            properties.insert(format!("key_{}", i), crate::tile::Value::Int(i as i64));
393        }
394
395        Feature {
396            id,
397            geometry_type: GeometryType::Point,
398            positions,
399            properties,
400            time_range: None,
401        }
402    }
403
404    #[test]
405    fn test_importance_scoring() {
406        let scorer = ImportanceScorer::GeometrySize;
407
408        let feature1 = create_test_feature(1, 100, 0);
409        let feature2 = create_test_feature(2, 50, 0);
410
411        assert!(scorer.score(&feature1) > scorer.score(&feature2));
412    }
413
414    #[test]
415    fn test_budget_enforcement_by_count() {
416        let budget = TileBudget::default().with_scorer(ImportanceScorer::GeometrySize);
417
418        let features = vec![
419            create_test_feature(1, 100, 0),
420            create_test_feature(2, 50, 0),
421            create_test_feature(3, 75, 0),
422            create_test_feature(4, 200, 0), // Largest, should be kept
423        ];
424
425        let original_count = features.len();
426        let kept = budget.drop_by_count(features, 2);
427        let dropped = original_count - kept.len();
428
429        assert_eq!(kept.len(), 2);
430        assert_eq!(dropped, 2);
431
432        // Should keep the largest geometries
433        assert_eq!(kept[0].id, 4); // 200
434        assert_eq!(kept[1].id, 1); // 100
435    }
436
437    #[test]
438    fn test_size_estimation() {
439        let feature = create_test_feature(1, 100, 5);
440        let size = TileBudget::estimate_size(&[feature]);
441
442        assert!(size > 1000);
443        assert!(size < 3000);
444    }
445
446    #[test]
447    fn test_budget_enforcement_no_drop() {
448        let budget = TileBudget::default();
449
450        let features = vec![create_test_feature(1, 10, 1), create_test_feature(2, 10, 1)];
451
452        let original_count = features.len();
453        let (kept, dropped) = budget.enforce(features);
454
455        assert_eq!(kept.len(), original_count);
456        assert_eq!(dropped, 0);
457    }
458
459    #[test]
460    fn test_enforce_indexed_under_budget_is_noop() {
461        // Well within both caps -> every index returned, in order.
462        let budget = TileBudget::new(1_000_000, 256 * 1024, 1000);
463        let sizes = [100usize, 50, 200, 75];
464        let keep = budget.enforce_indexed(
465            sizes.len(),
466            |i| sizes[i] as f64,
467            |i| sizes[i],
468        );
469        assert_eq!(keep, vec![0, 1, 2, 3]);
470    }
471
472    #[test]
473    fn test_enforce_indexed_count_cap_keeps_highest_scored() {
474        // Cap at 2 features; scorer = geometry size (here, size doubles as both
475        // score and bytes). The two largest (idx 2 and 3) survive, returned in
476        // ascending index order.
477        let budget = TileBudget::new(1_000_000, 256 * 1024, 2);
478        let sizes = [10usize, 50, 200, 100];
479        let mut keep = budget.enforce_indexed(
480            sizes.len(),
481            |i| sizes[i] as f64,
482            |i| sizes[i],
483        );
484        keep.sort_unstable();
485        assert_eq!(keep, vec![2, 3]); // 200 and 100 are largest
486    }
487
488    #[test]
489    fn test_enforce_indexed_size_cap_drops_to_fit() {
490        // Tiny byte cap forces a size-based drop; result must fit under cap.
491        let budget = TileBudget::new(150, 256 * 1024, 10_000);
492        let sizes = [100usize, 100, 100, 100];
493        let keep = budget.enforce_indexed(
494            sizes.len(),
495            |i| sizes[i] as f64,
496            |i| sizes[i],
497        );
498        let kept_bytes: usize = keep.iter().map(|&i| sizes[i]).sum();
499        assert!(keep.len() < sizes.len(), "expected some features dropped");
500        // 90%-of-cap target with slight-overshoot tolerance (<=105%).
501        assert!(kept_bytes <= 150 * 105 / 100);
502    }
503
504    #[test]
505    fn test_score_signals_matches_scorer() {
506        let geo = TileBudget::default().with_scorer(ImportanceScorer::GeometrySize);
507        assert_eq!(geo.score_signals(10, 5), 10.0);
508        let combined = TileBudget::default().with_scorer(ImportanceScorer::Combined);
509        assert_eq!(combined.score_signals(10, 5), 10.0 + 50.0);
510    }
511
512    #[test]
513    fn test_budget_stats() {
514        let mut stats = BudgetStats::default();
515
516        stats.add_tile(1000, 800, 100_000, 80_000);
517        stats.add_tile(500, 500, 50_000, 50_000);
518
519        assert_eq!(stats.tiles_processed, 2);
520        assert_eq!(stats.tiles_reduced, 1);
521        assert_eq!(stats.features_dropped, 200);
522        assert!((stats.reduction_ratio() - 0.8666).abs() < 0.01);
523    }
524}