Skip to main content

oxigdal_pmtiles/
layout.rs

1//! PMTiles directory layout strategy analysis and auto-selection.
2//!
3//! The PMTiles v3 header carries a single `clustered` flag (byte offset 96)
4//! that signals whether tile payloads are stored in ascending `tile_id` order
5//! with monotonically non-decreasing data offsets.  A clustered archive can be
6//! streamed and supports delta-encoded offsets in directories.  Beyond that
7//! single flag, real-world archives benefit from picking *how* the directory
8//! is laid out: compact for small or deduplication-heavy data, leaf-split for
9//! very large tile counts, and plain clustered for the common case.
10//!
11//! This module provides a lightweight analysis of a tile-ordering manifest
12//! (`(tile_id, data_offset, data_length)` triples) and a deterministic
13//! strategy selector.  The writer ([`crate::writer::PmTilesBuilder`]) consumes
14//! these to resolve [`LayoutStrategy::Auto`] before serialising the header, and
15//! the reader ([`crate::pmtiles::PmTilesReader`]) re-derives the analysis from a
16//! decoded directory.
17//!
18//! Reference: <https://github.com/protomaps/PMTiles/blob/main/spec/v3/spec.md>
19
20use crate::writer::PmTilesBuilder;
21
22/// Tile-count threshold at and above which [`LayoutStrategy::Auto`] selects
23/// [`LayoutStrategy::LeafOptimized`].
24///
25/// At `16_384` tiles the root directory approaches the PMTiles-recommended
26/// ~16 kB ceiling, so splitting into leaf directories keeps the root small and
27/// range requests cheap.  A count of `16_383` resolves to
28/// [`LayoutStrategy::Clustered`]; `16_384` resolves to
29/// [`LayoutStrategy::LeafOptimized`].
30pub const LEAF_OPTIMIZED_TILE_THRESHOLD: usize = 16_384;
31
32/// Deduplication-ratio threshold above which [`LayoutStrategy::Auto`] selects
33/// [`LayoutStrategy::Compact`].
34///
35/// A ratio strictly greater than `0.5` means more than half of the addressed
36/// tiles share content with another tile, so a compact directory (favouring
37/// run-length sharing over streamability) minimises archive size.
38pub const COMPACT_DEDUP_RATIO_THRESHOLD: f64 = 0.5;
39
40/// Directory layout strategy for a PMTiles archive.
41///
42/// The variant selected drives the header `clustered` flag and (where the
43/// writer supports it) whether the root directory is split into leaf
44/// directories.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum LayoutStrategy {
47    /// Pick a concrete strategy automatically from a [`LayoutAnalysis`].
48    ///
49    /// Resolution rules (see [`choose_strategy`]): deduplication-heavy archives
50    /// become [`Compact`](LayoutStrategy::Compact); very large tile counts
51    /// become [`LeafOptimized`](LayoutStrategy::LeafOptimized); everything else
52    /// becomes [`Clustered`](LayoutStrategy::Clustered).
53    #[default]
54    Auto,
55    /// Tiles ordered by `tile_id` with monotonically non-decreasing data
56    /// offsets.  The archive is streamable and the header `clustered` flag is
57    /// set.  Best for the common, moderate-sized, low-deduplication case.
58    Clustered,
59    /// Split the directory early into leaf directories.  Keeps the root
60    /// directory small for large archives so initial range requests stay cheap.
61    /// The header `clustered` flag remains set (the data is still ordered).
62    LeafOptimized,
63    /// Minimise directory size, favouring run-length / content sharing over
64    /// strict streamable ordering.  Best for small or deduplication-heavy
65    /// archives.  The header `clustered` flag is cleared.
66    Compact,
67}
68
69/// Result of analysing a tile-ordering manifest.
70///
71/// Produced by [`analyze_tile_ordering`] and consumed by [`choose_strategy`].
72#[derive(Debug, Clone)]
73pub struct LayoutAnalysis {
74    /// Total number of tile entries analysed.
75    pub tile_count: usize,
76    /// Number of distinct `data_offset` values.  Identical offsets indicate
77    /// deduplicated tiles that share a single payload, so this is a proxy for
78    /// the count of unique payloads.
79    pub unique_data_count: usize,
80    /// Fraction of tiles that are deduplicated, in `[0.0, 1.0)`.
81    ///
82    /// Computed as `1.0 - unique_data_count / tile_count`.  `0.0` when every
83    /// tile is unique (or the manifest is empty).
84    pub dedup_ratio: f64,
85    /// `true` when, taken in ascending `tile_id` order, the data offsets are
86    /// monotonically non-decreasing (i.e. the archive is clustered).  Empty and
87    /// single-entry manifests are trivially clustered.
88    pub is_clustered: bool,
89    /// Largest gap, in bytes, between the end of one tile's payload and the
90    /// start of the next (in `tile_id` order).  `0` when there are no positive
91    /// gaps or fewer than two entries.
92    pub max_gap: u64,
93    /// Mean of all positive inter-tile gaps in bytes.  `0.0` when there are no
94    /// positive gaps or fewer than two entries.
95    pub mean_gap: f64,
96}
97
98/// Analyse a tile-ordering manifest of `(tile_id, data_offset, data_length)`
99/// triples.
100///
101/// The manifest does not need to be pre-sorted; a copy is sorted by `tile_id`
102/// internally before checking clustering and computing gaps.  Deduplication is
103/// inferred from repeated `data_offset` values (deduplicated tiles point at the
104/// same payload offset).
105///
106/// See [`LayoutAnalysis`] for the meaning of each computed field.
107pub fn analyze_tile_ordering(entries: &[(u64, u64, u64)]) -> LayoutAnalysis {
108    let tile_count = entries.len();
109
110    // Empty manifest: trivially clustered, zero everything.
111    if tile_count == 0 {
112        return LayoutAnalysis {
113            tile_count: 0,
114            unique_data_count: 0,
115            dedup_ratio: 0.0,
116            is_clustered: true,
117            max_gap: 0,
118            mean_gap: 0.0,
119        };
120    }
121
122    // Count distinct data offsets (proxy for unique, non-deduplicated payloads).
123    let mut distinct_offsets: Vec<u64> = entries.iter().map(|&(_, offset, _)| offset).collect();
124    distinct_offsets.sort_unstable();
125    distinct_offsets.dedup();
126    let unique_data_count = distinct_offsets.len();
127
128    // Deduplication ratio: fraction of tiles sharing a payload with another.
129    let dedup_ratio = 1.0 - (unique_data_count as f64 / tile_count as f64);
130
131    // Sort a copy by tile_id to evaluate clustering and gaps in tile order.
132    let mut sorted: Vec<(u64, u64, u64)> = entries.to_vec();
133    sorted.sort_by_key(|&(tile_id, _, _)| tile_id);
134
135    let mut is_clustered = true;
136    let mut max_gap: u64 = 0;
137    let mut gap_sum: u64 = 0;
138    let mut gap_count: u64 = 0;
139
140    for window in sorted.windows(2) {
141        let (_, prev_offset, prev_len) = window[0];
142        let (_, next_offset, _) = window[1];
143
144        // Monotonic non-decreasing offsets ⇒ clustered.
145        if next_offset < prev_offset {
146            is_clustered = false;
147        }
148
149        // Gap only when the next payload starts at or after the end of the
150        // previous payload.  Overlapping / deduplicated tiles (next_offset
151        // before prev end) contribute no positive gap.
152        let prev_end = prev_offset.saturating_add(prev_len);
153        if next_offset >= prev_end {
154            let gap = next_offset - prev_end;
155            if gap > max_gap {
156                max_gap = gap;
157            }
158            gap_sum = gap_sum.saturating_add(gap);
159            gap_count += 1;
160        }
161    }
162
163    let mean_gap = if gap_count == 0 {
164        0.0
165    } else {
166        gap_sum as f64 / gap_count as f64
167    };
168
169    LayoutAnalysis {
170        tile_count,
171        unique_data_count,
172        dedup_ratio,
173        is_clustered,
174        max_gap,
175        mean_gap,
176    }
177}
178
179/// Resolve a (possibly [`Auto`](LayoutStrategy::Auto)) strategy into a concrete
180/// one using a [`LayoutAnalysis`].
181///
182/// An explicit strategy is returned unchanged (pass-through).  For
183/// [`LayoutStrategy::Auto`] the rules are, in order:
184/// 1. `dedup_ratio` > [`COMPACT_DEDUP_RATIO_THRESHOLD`] ⇒
185///    [`LayoutStrategy::Compact`].
186/// 2. `tile_count` ≥ [`LEAF_OPTIMIZED_TILE_THRESHOLD`] ⇒
187///    [`LayoutStrategy::LeafOptimized`].
188/// 3. otherwise ⇒ [`LayoutStrategy::Clustered`].
189pub fn choose_strategy(analysis: &LayoutAnalysis, strategy: LayoutStrategy) -> LayoutStrategy {
190    match strategy {
191        LayoutStrategy::Auto => {
192            if analysis.dedup_ratio > COMPACT_DEDUP_RATIO_THRESHOLD {
193                LayoutStrategy::Compact
194            } else if analysis.tile_count >= LEAF_OPTIMIZED_TILE_THRESHOLD {
195                LayoutStrategy::LeafOptimized
196            } else {
197                LayoutStrategy::Clustered
198            }
199        }
200        explicit => explicit,
201    }
202}
203
204/// Apply a *concrete* strategy to a [`PmTilesBuilder`], setting its `clustered`
205/// flag accordingly.
206///
207/// [`Clustered`](LayoutStrategy::Clustered) and
208/// [`LeafOptimized`](LayoutStrategy::LeafOptimized) set the flag (`true`);
209/// [`Compact`](LayoutStrategy::Compact) clears it (`false`).
210///
211/// [`LayoutStrategy::Auto`] is expected to have been resolved via
212/// [`choose_strategy`] beforehand; if it reaches here it is treated defensively
213/// as [`Clustered`](LayoutStrategy::Clustered) (flag set).
214pub fn apply_strategy_to_writer(builder: &mut PmTilesBuilder, strategy: LayoutStrategy) {
215    let clustered = match strategy {
216        LayoutStrategy::Compact => false,
217        LayoutStrategy::Clustered | LayoutStrategy::LeafOptimized | LayoutStrategy::Auto => true,
218    };
219    builder.set_clustered_flag(clustered);
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_default_strategy_is_auto() {
228        assert_eq!(LayoutStrategy::default(), LayoutStrategy::Auto);
229    }
230
231    #[test]
232    fn test_analyze_empty() {
233        let analysis = analyze_tile_ordering(&[]);
234        assert_eq!(analysis.tile_count, 0);
235        assert_eq!(analysis.unique_data_count, 0);
236        assert_eq!(analysis.dedup_ratio, 0.0);
237        assert!(analysis.is_clustered);
238        assert_eq!(analysis.max_gap, 0);
239        assert_eq!(analysis.mean_gap, 0.0);
240    }
241
242    #[test]
243    fn test_analyze_monotonic_clustered() {
244        let entries = [(0, 0, 10), (1, 10, 10), (2, 20, 5)];
245        let analysis = analyze_tile_ordering(&entries);
246        assert!(analysis.is_clustered);
247        assert_eq!(analysis.unique_data_count, 3);
248        assert_eq!(analysis.dedup_ratio, 0.0);
249    }
250
251    #[test]
252    fn test_analyze_decreasing_not_clustered() {
253        // In tile_id order the offsets go 0, 100, 50 → not monotonic.
254        let entries = [(0, 0, 10), (1, 100, 10), (2, 50, 10)];
255        let analysis = analyze_tile_ordering(&entries);
256        assert!(!analysis.is_clustered);
257    }
258
259    #[test]
260    fn test_analyze_gaps() {
261        // Offsets: 0 (len 10) → end 10, next 30 → gap 20; 30 (len 5) → end 35,
262        // next 35 → gap 0.
263        let entries = [(0, 0, 10), (1, 30, 5), (2, 35, 5)];
264        let analysis = analyze_tile_ordering(&entries);
265        assert_eq!(analysis.max_gap, 20);
266        // gaps are 20 and 0 → mean 10.
267        assert_eq!(analysis.mean_gap, 10.0);
268    }
269
270    #[test]
271    fn test_choose_auto_small_clustered() {
272        let analysis = analyze_tile_ordering(&[(0, 0, 10), (1, 10, 10)]);
273        assert_eq!(
274            choose_strategy(&analysis, LayoutStrategy::Auto),
275            LayoutStrategy::Clustered
276        );
277    }
278
279    #[test]
280    fn test_choose_explicit_passthrough() {
281        let analysis = analyze_tile_ordering(&[(0, 0, 10)]);
282        for s in [
283            LayoutStrategy::Clustered,
284            LayoutStrategy::Compact,
285            LayoutStrategy::LeafOptimized,
286        ] {
287            assert_eq!(choose_strategy(&analysis, s), s);
288        }
289    }
290}