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}