Skip to main content

stt_core/
arrow_tile.rs

1//! Arrow-based tile payload format.
2//!
3//! A tile's payload is one or more *layers*. Each layer is a single Arrow
4//! [`RecordBatch`] serialised as an Arrow IPC **stream**. Geometry is encoded
5//! using the GeoArrow interleaved-coordinate convention so the payload can be
6//! consumed directly by GeoArrow-aware renderers (e.g. `@geoarrow/deck.gl`).
7//!
8//! ## Per-layer schema
9//!
10//! | column        | type                                    | notes                         |
11//! |---------------|-----------------------------------------|-------------------------------|
12//! | `id`          | `UInt64`                                | feature id                    |
13//! | `start_time`  | `Int64`                                 | Unix ms, absolute             |
14//! | `end_time`    | `Int64`                                 | Unix ms, absolute             |
15//! | `geometry`    | GeoArrow point / linestring / polygon   | interleaved f64 lon/lat       |
16//! | `vertex_time` | `List<UInt16>` deltas or `List<Int64>` (nullable) | per-vertex Unix ms (optional; see [`build_vertex_time_array`]) |
17//! | `vertex_value`| `List<Float32>` (nullable)              | per-vertex scalar, e.g. SST (optional) |
18//! | `triangles`   | `List<UInt16>` or `List<UInt32>`        | pre-baked earcut indices, feature-local (optional; polygon only) |
19//! | `<property>`  | `Float64` or `Dictionary<UInt16,Utf8>`   | one column per property       |
20//!
21//! All layers in one tile are concatenated with a tiny frame so a tile can
22//! carry, say, a linestring layer and a point layer side by side. Two frame
23//! shapes exist, selected by [`EncoderConfig::format_version`] (the payload
24//! side of the packed format's `manifest.formatVersion` — the two are bumped
25//! in lockstep, see `docs/spec/stt-packed-format.md` §9):
26//!
27//! **v1** (`format_version: 1`, the 0.3.x wire shape — frozen, byte-identical):
28//!
29//! ```text
30//! [u16 layer_count | ALIGNED_FRAME_FLAG]
31//!   repeated: [u16 name_len][name utf8][u32 ipc_len][pad to 8][ipc stream bytes]
32//! ```
33//!
34//! The leading u16's top bit ([`ALIGNED_FRAME_FLAG`]) marks the *aligned*
35//! frame: zero padding after each `ipc_len` places every Arrow IPC stream at
36//! an 8-byte boundary relative to the payload start, so readers can hand the
37//! stream to an Arrow implementation zero-copy (Arrow buffers are 8-byte
38//! aligned *within* a stream; the stream itself must start aligned for that
39//! to survive). The pad length is not stored — readers derive it as
40//! `(8 - pos % 8) % 8` from the position after `ipc_len`. Frames without the
41//! flag (every archive written before the flag existed) carry no padding and
42//! decode exactly as before.
43//!
44//! **v2** (`format_version: 2`, default for new `stt-build` output): a
45//! sectioned frame that hoists each layer's Arrow IPC *schema message* into a
46//! per-dataset **template** (referenced by blake3-128 hash, resolved through
47//! the manifest's `schemas` table) so the per-tile schema tax disappears, and
48//! carries the per-tile-varying metadata in a compact [`TILE_META`
49//! section](TileMeta) instead of the schema:
50//!
51//! ```text
52//! u16  0xFFFF                    # v2 escape (see FRAME_V2_ESCAPE)
53//! u8   frame_version = 2
54//! u8   flags = 0                 # reserved, MUST be 0
55//! u16  layer_count
56//! per layer:
57//!   u16  name_len, [name utf8]
58//!   u8   ref_kind_core           # 0 = INLINE_SCHEMA_CORE section present;
59//!                                # 1 = the next 16 bytes are the template hash
60//!   [16] core template hash     # iff ref_kind_core == 1
61//!   u8   ref_kind_props          # 0/1 as above; 2 = NO props sections at all
62//!   [16] props template hash    # iff ref_kind_props == 1
63//!   u8   section_count
64//!   per section (TOC): u8 tag, u32 length     # at-rest bytes, pad excluded
65//!   [pad to 8, derived]
66//!   per section: [section bytes][pad to 8, derived]
67//! ```
68//!
69//! Reserved columns (`id`/times/geometry/vertex_*/`triangles`) form the CORE
70//! batch; property columns form the PROPS batch with its own schema/template
71//! (emitted only when properties exist). Each `*_BATCH` section is the IPC
72//! stream's **tail** — dictionary batch(es) + record batch + end-of-stream —
73//! and a reader materialises `concat(template, tail)` for a stock Arrow
74//! reader. Unknown section tags are skippable via the TOC. Rows are
75//! stable-sorted by `start_time` at encode (after id assignment), declared by
76//! `TILE_META.sorted`. The v2 escape is unreachable from the v1 writer: the
77//! v1 path caps `layer_count` below `0x7fff`, so an aligned v1 frame can
78//! never start with `0xFFFF`.
79
80use crate::error::{Error, Result};
81use crate::types::GeometryType;
82use arrow::array::{
83    Array, ArrayRef, DictionaryArray, FixedSizeListArray, Float32Array, Float32Builder,
84    Float64Array, Int32Array, Int32Builder, Int64Array, Int64Builder, ListArray, ListBuilder,
85    RecordBatch, StringArray, UInt16Array, UInt16Builder, UInt32Builder, UInt64Array, UInt8Array,
86};
87use arrow::buffer::OffsetBuffer;
88use arrow::datatypes::{DataType, Field, Schema, UInt16Type};
89use arrow::ipc::reader::StreamReader;
90use arrow::ipc::writer::StreamWriter;
91use arrow::ipc::{root_as_message, MessageHeader};
92use serde::{Deserialize, Serialize};
93use std::borrow::Cow;
94use std::collections::{BTreeMap, HashMap, HashSet};
95use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
96use std::sync::{Arc, Mutex, OnceLock, RwLock};
97
98/// Top bit of the layer frame's leading u16: marks an *aligned* frame whose
99/// IPC streams are padded to 8-byte boundaries (see the module docs). The
100/// remaining 15 bits carry the layer count, so a tile may have at most
101/// `0x7fff` layers. Old frames never set this bit (layer counts are tiny).
102pub const ALIGNED_FRAME_FLAG: u16 = 0x8000;
103
104/// Alignment (bytes) of each layer's Arrow IPC stream within an aligned frame.
105const FRAME_ALIGN: usize = 8;
106
107// ----------------------------------------------------------------------------
108// Layer frame v2 (packed formatVersion 2) — see the module docs.
109// ----------------------------------------------------------------------------
110
111/// Leading u16 of a **v2** layer frame. Deliberately unreachable from the v1
112/// writer: the v1 encoder rejects tiles whose `count | ALIGNED_FRAME_FLAG`
113/// would collide with this escape, so the two frame shapes are disjoint on
114/// their first two bytes. Manifest `formatVersion` remains the authoritative
115/// discriminator (design ★F6); this escape is defense-in-depth.
116pub const FRAME_V2_ESCAPE: u16 = 0xFFFF;
117
118/// `frame_version` byte of the v2 frame.
119const FRAME_V2_VERSION: u8 = 2;
120
121/// v2 section tag: full IPC schema prefix for the CORE batch (self-contained
122/// mode, `ref_kind == REF_KIND_INLINE`).
123pub const SECTION_INLINE_SCHEMA_CORE: u8 = 0x01;
124/// v2 section tag: the canonical [`TileMeta`] JSON.
125pub const SECTION_TILE_META: u8 = 0x02;
126/// v2 section tag: CORE batch IPC tail (dict batches + record batch + EOS).
127pub const SECTION_CORE_BATCH: u8 = 0x03;
128/// v2 section tag: full IPC schema prefix for the PROPS batch (as 0x01).
129pub const SECTION_INLINE_SCHEMA_PROPS: u8 = 0x04;
130/// v2 section tag: PROPS batch IPC tail (as 0x03, props schema).
131pub const SECTION_PROPS_BATCH: u8 = 0x05;
132
133/// v2 `ref_kind`: the schema rides inline in an `INLINE_SCHEMA_*` section
134/// (self-contained blob — no template registry needed to decode).
135const REF_KIND_INLINE: u8 = 0;
136/// v2 `ref_kind`: the next 16 bytes are the blake3-128 template hash,
137/// resolved against the dataset's [`TemplateRegistry`].
138const REF_KIND_TEMPLATE_HASH: u8 = 1;
139/// v2 `ref_kind_props`: the layer has no property columns — no props
140/// schema/template and no `PROPS_BATCH` section.
141const REF_KIND_NO_PROPS: u8 = 2;
142
143/// The frame format this encoder emits when nothing opts in explicitly —
144/// v1, the frozen 0.3.x wire shape. `stt-build` opts into v2 explicitly
145/// (its `--format-version` default is 2); `stt-serve` and every other
146/// existing caller stays v1 without changes (design §7).
147pub const FORMAT_VERSION_V1: u32 = 1;
148/// The sectioned template-referencing frame (packed formatVersion 2).
149pub const FORMAT_VERSION_V2: u32 = 2;
150
151/// blake3 content hash truncated to 128 bits — the v2 template reference
152/// (16 raw bytes in the frame; lowercase hex in `manifest.schemas`).
153pub(crate) fn blake3_128(bytes: &[u8]) -> [u8; 16] {
154    let hash = blake3::hash(bytes);
155    let mut out = [0u8; 16];
156    out.copy_from_slice(&hash.as_bytes()[..16]);
157    out
158}
159
160/// Thread-safe encode-side sink for the schema templates a v2 build produces.
161///
162/// The encoder [`record`](Self::record)s each layer's stripped schema prefix
163/// and embeds the returned hash in the frame; `PackWriter::finalize` snapshots
164/// the collector into the manifest's `schemas` table. Content-addressed keys
165/// make the result independent of encode parallelism/order (design ★F1/E1):
166/// two tiles sharing a schema record the same entry, and the snapshot is
167/// sorted by hash.
168#[derive(Debug, Default)]
169pub struct TemplateCollector {
170    templates: Mutex<BTreeMap<[u8; 16], Vec<u8>>>,
171}
172
173impl TemplateCollector {
174    /// New, empty collector.
175    pub fn new() -> Self {
176        Self::default()
177    }
178
179    /// Record a template's bytes (idempotent), returning its blake3-128 hash —
180    /// the 16-byte reference the v2 frame carries.
181    pub fn record(&self, template: &[u8]) -> [u8; 16] {
182        let hash = blake3_128(template);
183        self.templates
184            .lock()
185            .unwrap()
186            .entry(hash)
187            .or_insert_with(|| template.to_vec());
188        hash
189    }
190
191    /// Snapshot of every recorded `(hash, template bytes)`, sorted by hash
192    /// (deduped by construction) — the byte-reproducible manifest order.
193    pub fn snapshot(&self) -> Vec<([u8; 16], Vec<u8>)> {
194        self.templates
195            .lock()
196            .unwrap()
197            .iter()
198            .map(|(h, b)| (*h, b.clone()))
199            .collect()
200    }
201
202    /// Number of distinct templates recorded so far.
203    pub fn len(&self) -> usize {
204        self.templates.lock().unwrap().len()
205    }
206
207    /// Whether no template has been recorded.
208    pub fn is_empty(&self) -> bool {
209        self.len() == 0
210    }
211}
212
213/// Decode-side template lookup: blake3-128 hash → raw template bytes.
214///
215/// Built once per dataset from `manifest.schemas` (each entry hash-validated
216/// at open) and threaded into [`decode_tile_with_templates`].
217#[derive(Debug, Default, Clone)]
218pub struct TemplateRegistry {
219    templates: HashMap<[u8; 16], Arc<Vec<u8>>>,
220}
221
222impl TemplateRegistry {
223    /// New, empty registry.
224    pub fn new() -> Self {
225        Self::default()
226    }
227
228    /// Insert a template, returning its blake3-128 hash.
229    pub fn insert(&mut self, template: Vec<u8>) -> [u8; 16] {
230        let hash = blake3_128(&template);
231        self.templates.insert(hash, Arc::new(template));
232        hash
233    }
234
235    /// Look a template up by its 16-byte hash.
236    pub fn get(&self, hash: &[u8; 16]) -> Option<&[u8]> {
237        self.templates.get(hash).map(|t| t.as_slice())
238    }
239
240    /// Iterate every `(hash, template bytes)` pair (arbitrary order). Lets a
241    /// verbatim-repack tool seed a [`TemplateCollector`] from a source
242    /// dataset's registry ([`crate::pack::PackWriter::with_seeded_templates`]).
243    pub fn iter(&self) -> impl Iterator<Item = (&[u8; 16], &[u8])> {
244        self.templates.iter().map(|(h, t)| (h, t.as_slice()))
245    }
246
247    /// Number of templates registered.
248    pub fn len(&self) -> usize {
249        self.templates.len()
250    }
251
252    /// Whether the registry is empty.
253    pub fn is_empty(&self) -> bool {
254        self.templates.is_empty()
255    }
256}
257
258/// The per-tile-varying metadata a v2 frame carries in its `TILE_META`
259/// section instead of the (now dataset-constant, template-resident) Arrow
260/// schema metadata. Canonical serialization (design §4.3): JSON, keys sorted
261/// — field order below IS alphabetical and the `qa` map is a `BTreeMap` — no
262/// whitespace. Readers MUST ignore unknown keys (serde's default here), so
263/// the section can evolve additively. Presence rules: a key is present iff
264/// the corresponding feature is (`qa` omits non-quantized columns entirely;
265/// `t0` iff a start-time column exists; `vt` iff delta-encoded vertex_time;
266/// `vb` iff a value matrix).
267#[derive(Debug, Default, Clone, Serialize, Deserialize)]
268pub struct TileMeta {
269    /// Per-property attribute-quantization affines, `column → [o, s]`
270    /// (`value = o + q*s`) — the v1 `stt:qa` field metadata, hoisted.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub qa: Option<BTreeMap<String, (f64, f64)>>,
273    /// Rows are stable-sorted by `start_time` (always `true` from this
274    /// writer; a flag so Stage III can demote the sort without a frame bump).
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub sorted: Option<bool>,
277    /// The v1 `stt:time_offset_ms` schema key: minimum feature start-time.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub t0: Option<i64>,
280    /// The v1 `stt:vertex_value_buckets` schema key.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub vb: Option<u32>,
283    /// The v1 `stt:vertex_time_origin_ms` / `stt:vertex_time_step_ms` pair,
284    /// as `[origin_ms, step_ms]`.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub vt: Option<(i64, u32)>,
287}
288
289/// GeoArrow extension-name metadata key.
290const GEOARROW_EXT_KEY: &str = "ARROW:extension:name";
291
292/// GeoArrow extension-*metadata* key — the sibling of [`GEOARROW_EXT_KEY`] that
293/// carries the per-geometry-type JSON metadata (CRS, edge interpretation, ...).
294const GEOARROW_EXT_META_KEY: &str = "ARROW:extension:metadata";
295
296/// CRS advertised on every geometry field.
297///
298/// STT stores interleaved `[lon, lat]` in WGS84, i.e. **OGC:CRS84** (the GeoJSON
299/// longitude-first axis order) — *not* `EPSG:4326`, which strict readers treat as
300/// lat/lon. Emitting it as a GeoArrow `authority_code` makes every tile
301/// self-describing to GDAL / GeoPandas / lonboard / QGIS; without it those
302/// readers fall back to "unknown CRS" even though the geometry is plain lon/lat.
303const GEOARROW_CRS_METADATA: &str = r#"{"crs":"OGC:CRS84","crs_type":"authority_code"}"#;
304
305/// A single coordinate pair (lon, lat) in WGS84 degrees.
306pub type Coord = [f64; 2];
307
308/// Geometry for one layer, grouped by kind. Every feature in a layer shares
309/// one kind — the tiler emits a separate layer per geometry type.
310#[derive(Debug, Clone)]
311pub enum GeometryColumn {
312    /// One coordinate per feature.
313    Point(Vec<Coord>),
314    /// A vertex list per feature.
315    LineString(Vec<Vec<Coord>>),
316    /// A list of rings per feature (ring 0 is the exterior).
317    Polygon(Vec<Vec<Vec<Coord>>>),
318}
319
320impl GeometryColumn {
321    /// Number of features represented.
322    pub fn len(&self) -> usize {
323        match self {
324            GeometryColumn::Point(v) => v.len(),
325            GeometryColumn::LineString(v) => v.len(),
326            GeometryColumn::Polygon(v) => v.len(),
327        }
328    }
329
330    /// Whether the column is empty.
331    pub fn is_empty(&self) -> bool {
332        self.len() == 0
333    }
334
335    /// The matching [`GeometryType`].
336    pub fn kind(&self) -> GeometryType {
337        match self {
338            GeometryColumn::Point(_) => GeometryType::Point,
339            GeometryColumn::LineString(_) => GeometryType::LineString,
340            GeometryColumn::Polygon(_) => GeometryType::Polygon,
341        }
342    }
343
344    /// The GeoArrow extension name for this geometry kind.
345    fn geoarrow_name(&self) -> &'static str {
346        match self {
347            GeometryColumn::Point(_) => "geoarrow.point",
348            GeometryColumn::LineString(_) => "geoarrow.linestring",
349            GeometryColumn::Polygon(_) => "geoarrow.polygon",
350        }
351    }
352}
353
354/// Leaf element type of a [`PropertyColumn::Vector`] — the GPU upload type the
355/// renderer binds the decoded child buffer as.
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub enum VectorElem {
358    /// `Float32` leaf — quaternion / scale / generic vec attributes.
359    F32,
360    /// `UInt8` leaf — packed RGBA colour (0–255 per channel).
361    U8,
362}
363
364/// A property column. Values are per-feature and may be missing.
365#[derive(Debug, Clone)]
366pub enum PropertyColumn {
367    /// Numeric values (f64).
368    Numeric(Vec<Option<f64>>),
369    /// Categorical / string values.
370    Categorical(Vec<Option<String>>),
371    /// A fixed-width interleaved vector per feature — a GPU-ready instance
372    /// attribute baked at build time (e.g. a `[qx,qy,qz,qw]` surfel quaternion,
373    /// a `[s_major,s_minor]` scale, an `[r,g,b,a]` colour). Encoded as
374    /// `FixedSizeList<Float32|UInt8, width>` so the TS decoder hands the
375    /// contiguous child buffer straight to deck.gl with **zero per-point work**
376    /// (no main-thread re-interleave). `values` is row-major and flattened:
377    /// feature `i` occupies `[i*width, (i+1)*width)`. No per-element nulls (a
378    /// missing feature is encoded as a zero/identity vector by the producer).
379    Vector {
380        /// Components per feature (the FixedSizeList list size).
381        width: usize,
382        /// Leaf upload type (`F32` or `U8`).
383        elem: VectorElem,
384        /// Flattened row-major values; length must be `width * feature_count`.
385        /// `U8` values are rounded+clamped to `[0,255]` at encode.
386        values: Vec<f32>,
387    },
388}
389
390/// One decoded/encodable tile layer.
391#[derive(Debug, Clone)]
392pub struct ColumnarLayer {
393    /// Layer name (e.g. `"default"`, `"default_originals"`).
394    pub name: String,
395    /// Per-feature id.
396    pub feature_ids: Vec<u64>,
397    /// Per-feature start time (Unix ms, absolute).
398    pub start_times: Vec<i64>,
399    /// Per-feature end time (Unix ms, absolute).
400    pub end_times: Vec<i64>,
401    /// Geometry, one entry per feature.
402    pub geometry: GeometryColumn,
403    /// Optional per-vertex timestamps (Unix ms). When present, length equals
404    /// the feature count and each inner vec matches that feature's vertex count.
405    pub vertex_times: Option<Vec<Vec<i64>>>,
406    /// Optional per-vertex scalar values (producer-defined; e.g. sea-surface
407    /// temperature for the ocean-drifter dataset). When present, length equals
408    /// the feature count and each inner vec matches that feature's vertex count.
409    /// `NaN` marks a vertex with no value; renderers map it to a fallback color.
410    pub vertex_values: Option<Vec<Vec<f32>>>,
411    /// Optional per-vertex × per-time-bucket value matrix, flattened
412    /// **vertex-major** per feature: `matrix[v * num_buckets + b]`. When
413    /// present, length equals the feature count and each inner vec is
414    /// `feature_vertex_count * num_buckets` long. Lets a static-geometry
415    /// overview carry a per-vertex time series (e.g. flow-corridor counts per
416    /// bin) so the renderer animates resident data instead of re-fetching
417    /// geometry per bucket. Encoded as the tile's `vertex_value_matrix` column;
418    /// `num_buckets` is recorded in schema metadata under
419    /// `stt:vertex_value_buckets`.
420    pub vertex_value_matrix: Option<Vec<Vec<f32>>>,
421    /// Optional pre-baked triangle indices for polygon features (MLT-style).
422    ///
423    /// When present, `triangles.len() == feature_count` and each inner vec is
424    /// the flat triangle-index list (groups of 3 vertex indices) produced by
425    /// earcut at build time. Indices are LOCAL to the feature: they reference
426    /// positions within that feature's own ring coordinates, so the renderer
427    /// only needs to add the feature's `startIndex` to each one.
428    ///
429    /// Only meaningful for `GeometryColumn::Polygon`. Encoders that set this
430    /// on a non-polygon layer will have it dropped at encode time.
431    pub triangles: Option<Vec<Vec<u32>>>,
432    /// Property columns, keyed by name. Each column has one value per feature.
433    pub properties: Vec<(String, PropertyColumn)>,
434}
435
436impl ColumnarLayer {
437    /// Feature count for this layer.
438    pub fn feature_count(&self) -> usize {
439        self.feature_ids.len()
440    }
441
442    /// Validate that every column has a consistent length.
443    fn validate(&self) -> Result<()> {
444        let n = self.feature_ids.len();
445        let check = |label: &str, len: usize| -> Result<()> {
446            if len != n {
447                return Err(Error::Other(format!(
448                    "tile layer '{}': {} has {} entries, expected {}",
449                    self.name, label, len, n
450                )));
451            }
452            Ok(())
453        };
454        check("start_times", self.start_times.len())?;
455        check("end_times", self.end_times.len())?;
456        check("geometry", self.geometry.len())?;
457        if let Some(vt) = &self.vertex_times {
458            check("vertex_times", vt.len())?;
459        }
460        if let Some(vv) = &self.vertex_values {
461            check("vertex_values", vv.len())?;
462        }
463        if let Some(vm) = &self.vertex_value_matrix {
464            check("vertex_value_matrix", vm.len())?;
465        }
466        if let Some(tri) = &self.triangles {
467            check("triangles", tri.len())?;
468        }
469        for (name, col) in &self.properties {
470            match col {
471                PropertyColumn::Numeric(v) => check(&format!("property '{}'", name), v.len())?,
472                PropertyColumn::Categorical(v) => check(&format!("property '{}'", name), v.len())?,
473                PropertyColumn::Vector { width, values, .. } => {
474                    if *width == 0 {
475                        return Err(Error::Other(format!(
476                            "tile layer '{}': vector property '{}' has width 0",
477                            self.name, name
478                        )));
479                    }
480                    if values.len() != width * n {
481                        return Err(Error::Other(format!(
482                            "tile layer '{}': vector property '{}' has {} values, expected {} ({} × {})",
483                            self.name,
484                            name,
485                            values.len(),
486                            width * n,
487                            width,
488                            n
489                        )));
490                    }
491                }
492            }
493        }
494        Ok(())
495    }
496}
497
498/// Schema-metadata key set on layers that carry pre-baked triangle indices.
499pub const TRIANGLES_METADATA_KEY: &str = "stt:has_triangles";
500
501/// Tessellate one polygon feature (a list of rings) using earcut. Returns the
502/// flat triangle index list — each triple of indices is one triangle, indices
503/// are LOCAL (relative to the start of the feature's coordinate run, where
504/// the exterior ring sits first followed by every hole).
505///
506/// Returns an empty vec for degenerate inputs (no exterior ring, <3 vertices).
507pub fn tessellate_polygon(rings: &[Vec<Coord>]) -> Vec<u32> {
508    if rings.is_empty() {
509        return Vec::new();
510    }
511    // Flatten coords into the [x0, y0, x1, y1, ...] format earcutr expects.
512    let mut flat: Vec<f64> = Vec::with_capacity(rings.iter().map(|r| r.len()).sum::<usize>() * 2);
513    // Hole offsets are vertex indices (not coord-pair indices) where each
514    // hole begins. The first hole starts after the exterior ring.
515    let mut hole_indices: Vec<usize> = Vec::with_capacity(rings.len().saturating_sub(1));
516    let mut running = 0usize;
517    for (i, ring) in rings.iter().enumerate() {
518        if i > 0 {
519            hole_indices.push(running);
520        }
521        for [x, y] in ring {
522            flat.push(*x);
523            flat.push(*y);
524        }
525        running += ring.len();
526    }
527    if running < 3 {
528        return Vec::new();
529    }
530    match earcutr::earcut(&flat, &hole_indices, 2) {
531        Ok(tris) => tris.into_iter().map(|i| i as u32).collect(),
532        Err(_) => Vec::new(),
533    }
534}
535
536// ----------------------------------------------------------------------------
537// Geometry array construction (GeoArrow interleaved)
538// ----------------------------------------------------------------------------
539
540/// Build an `i32` offset buffer from per-element counts.
541///
542/// Errors when the accumulated count exceeds `i32::MAX`: Arrow list offsets
543/// are 32-bit, so a larger layer would wrap into corrupt offsets in release
544/// builds. No budget cap guarantees safety here (budgets are optional), so the
545/// overflow must be a hard, descriptive failure.
546fn offsets_from_counts(counts: impl Iterator<Item = usize>) -> Result<OffsetBuffer<i32>> {
547    let mut acc = 0i32;
548    let mut offsets = vec![0i32];
549    for c in counts {
550        acc = i32::try_from(c)
551            .ok()
552            .and_then(|c| acc.checked_add(c))
553            .ok_or_else(|| {
554                Error::Other(format!(
555                    "layer geometry exceeds {} total vertices/rings, which Arrow's \
556                     32-bit list offsets cannot address; split the layer",
557                    i32::MAX
558                ))
559            })?;
560        offsets.push(acc);
561    }
562    Ok(OffsetBuffer::new(offsets.into()))
563}
564
565/// Meters per degree of latitude (WGS84 mean) — the constant the coordinate
566/// quantizer sizes its grid from. Longitude scales by `cos(lat)`.
567const M_PER_DEG_LAT: f64 = 111_320.0;
568
569/// Schema-metadata key (on the `geometry` field) that flags a tile whose
570/// coordinates are fixed-point `i32` grid indices rather than GeoArrow Float64
571/// lon/lat. Its value is the [`QuantAffine`] JSON; absent ⇒ standard Float64.
572pub const STT_QUANT_META_KEY: &str = "stt:quant";
573
574/// Per-layer coordinate-quantization affine. Coordinates ship as `i32` grid
575/// indices; the decoder reconstructs `lon = x0 + qx*sx`, `lat = y0 + qy*sy`.
576/// `sx`/`sy` (degrees per quantum) are sized from a target ground precision in
577/// meters at the layer's mid-latitude, so the worst-case error is ≤ half a
578/// quantum (~`meters/2`). This trades GeoArrow self-describing Float64 for size
579/// (coords are the dominant, near-incompressible column) and is opt-in.
580#[derive(Debug, Clone, Copy, PartialEq)]
581pub struct QuantAffine {
582    pub x0: f64,
583    pub y0: f64,
584    pub sx: f64,
585    pub sy: f64,
586    /// Z-axis origin/step (metres), present ONLY for 3D point geometry
587    /// (`FixedSizeList<i32,3>`). `None` ⇒ plain 2D coords, byte-identical to the
588    /// historical affine (the `z0`/`sz` keys are simply omitted from the JSON).
589    pub z0: Option<f64>,
590    pub sz: Option<f64>,
591}
592
593impl QuantAffine {
594    fn to_json(&self) -> String {
595        // Full f64 round-trip precision (17 sig digits) so decode is exact. The
596        // z keys are emitted only for 3D affines, so a 2D affine is byte-identical.
597        match (self.z0, self.sz) {
598            (Some(z0), Some(sz)) => format!(
599                r#"{{"x0":{:.17e},"y0":{:.17e},"sx":{:.17e},"sy":{:.17e},"z0":{:.17e},"sz":{:.17e}}}"#,
600                self.x0, self.y0, self.sx, self.sy, z0, sz
601            ),
602            _ => format!(
603                r#"{{"x0":{:.17e},"y0":{:.17e},"sx":{:.17e},"sy":{:.17e}}}"#,
604                self.x0, self.y0, self.sx, self.sy
605            ),
606        }
607    }
608
609    /// Parse the affine from its [`STT_QUANT_META_KEY`] JSON value. The TS
610    /// reader applies the identical reconstruction (`tile.ts`).
611    pub fn from_json(s: &str) -> Option<QuantAffine> {
612        let v: serde_json::Value = serde_json::from_str(s).ok()?;
613        let f = |k: &str| v.get(k).and_then(|x| x.as_f64());
614        Some(QuantAffine {
615            x0: f("x0")?,
616            y0: f("y0")?,
617            sx: f("sx")?,
618            sy: f("sy")?,
619            z0: f("z0"),
620            sz: f("sz"),
621        })
622    }
623
624    /// Reconstruct longitude from a quantized x grid index.
625    #[inline]
626    pub fn lon(&self, qx: i32) -> f64 {
627        self.x0 + qx as f64 * self.sx
628    }
629    /// Reconstruct latitude from a quantized y grid index.
630    #[inline]
631    pub fn lat(&self, qy: i32) -> f64 {
632        self.y0 + qy as f64 * self.sy
633    }
634
635    #[inline]
636    fn qx(&self, lon: f64) -> i32 {
637        (((lon - self.x0) / self.sx).round() as i64).clamp(i32::MIN as i64, i32::MAX as i64) as i32
638    }
639    #[inline]
640    fn qy(&self, lat: f64) -> i32 {
641        (((lat - self.y0) / self.sy).round() as i64).clamp(i32::MIN as i64, i32::MAX as i64) as i32
642    }
643    /// Quantize a metre altitude to a grid index (3D affines only; `z0`/`sz` set).
644    ///
645    /// Altitude is unbounded input (unlike lon/lat), so an index outside i32 is
646    /// a hard error identifying the offending value — the old silent clamp
647    /// snapped such points to ±i32::MAX quanta without a trace.
648    #[inline]
649    fn qz(&self, z: f64) -> Result<i32> {
650        let z0 = self.z0.unwrap_or(0.0);
651        let sz = self.sz.unwrap_or(1.0);
652        let q = ((z - z0) / sz).round();
653        if !(q >= i32::MIN as f64 && q <= i32::MAX as f64) {
654            return Err(Error::Other(format!(
655                "altitude {z} does not fit the quantization grid (origin {z0}, step {sz}): \
656                 index {q} is outside i32; use a coarser --quantize-coords precision or drop \
657                 the point-elevation fold for this dataset"
658            )));
659        }
660        Ok(q as i32)
661    }
662}
663
664/// Finest coordinate-quantization precision (meters) the world-anchored grid
665/// supports: the grid is anchored at `(-180, -90)` with a uniform step of
666/// `meters / M_PER_DEG_LAT` degrees, so the largest longitude index is
667/// `360 * M_PER_DEG_LAT / meters`. Below this floor (≈ 0.0187 m, ~19 mm) that
668/// index exceeds `i32::MAX` and quantization would silently snap far-east
669/// coordinates to wrong locations — so finer precisions are rejected.
670pub const MIN_QUANTIZE_COORDS_M: f64 = 360.0 * M_PER_DEG_LAT / (i32::MAX as f64);
671
672/// Validate a coordinate-quantization precision against the world grid's
673/// [`MIN_QUANTIZE_COORDS_M`] floor. `meters <= 0` (quantization off) passes.
674/// Public so config-building paths ([`EncoderConfig`] consumers like
675/// `stt-serve`) can fail fast at startup with the same error the global
676/// setter and the encode-time guard produce.
677pub fn validate_quantize_coords_m(meters: f64) -> Result<()> {
678    if meters > 0.0 && meters < MIN_QUANTIZE_COORDS_M {
679        return Err(Error::Other(format!(
680            "coordinate quantization precision {meters} m is finer than the minimum \
681             {MIN_QUANTIZE_COORDS_M} m (~19 mm): the world-anchored grid's ±180° longitude \
682             index would overflow i32 and snap points to wrong locations"
683        )));
684    }
685    Ok(())
686}
687
688/// The fixed, dataset-independent quantization grid for a target ground
689/// precision in meters, or `None` when off (`meters <= 0`).
690///
691/// The origin is the world corner `(-180, -90)` and the step is uniform in
692/// degrees (`meters / M_PER_DEG_LAT`) — deliberately **not** a per-tile
693/// bbox-relative grid. A per-tile grid gives the same coordinate different
694/// indices in different tiles, which destroys the packed format's
695/// content-addressed blob dedup (measured: +61% on a dedup-heavy dataset). A
696/// single world grid keeps identical geometry byte-identical across tiles.
697///
698/// Latitude precision is exactly `meters`; longitude is `meters * cos(lat)` —
699/// i.e. always ≤ `meters` (finer toward the poles), never coarser than asked.
700/// At 1 m the largest index is `360 * M_PER_DEG_LAT ≈ 4.0e7`, well within i32.
701fn world_grid_affine(meters: f64) -> Option<QuantAffine> {
702    if !(meters > 0.0) {
703        return None;
704    }
705    let step = meters / M_PER_DEG_LAT;
706    Some(QuantAffine {
707        x0: -180.0,
708        y0: -90.0,
709        sx: step,
710        sy: step,
711        z0: None,
712        sz: None,
713    })
714}
715
716/// The 3D variant of [`world_grid_affine`]: same world-grid xy plus a Z axis
717/// quantized to the SAME ground precision in metres, origin pinned to a fixed
718/// global datum (`z0 = 0`) so identical surfels stay byte-identical across tiles
719/// (dedup-preserving, like the xy world grid). For point clouds whose altitude
720/// rides the geometry's 3rd coordinate (`--point-elevation-column`).
721fn world_grid_affine_3d(meters: f64) -> Option<QuantAffine> {
722    let a = world_grid_affine(meters)?;
723    Some(QuantAffine {
724        z0: Some(0.0),
725        sz: Some(meters),
726        ..a
727    })
728}
729
730/// Optionally quantizing coordinates to an `i32` grid
731/// (when `quant` is `Some`). The List/FixedSizeList nesting and offset buffers
732/// are identical either way — only the leaf changes from `Float64` to `Int32`,
733/// so `quant = None` is byte-identical to the historical encoder.
734///
735/// When `point_elev` is `Some` (POINT geometry only), each point's altitude is
736/// folded in as a 3rd coordinate: the leaf becomes `FixedSizeList<_,3>`
737/// (`[x,y,z]`), quantized with the affine's `z0`/`sz` when quantizing. The
738/// renderer then binds 3D positions zero-copy (no pad). `None` ⇒ 2D, unchanged.
739fn build_geometry_array_q(
740    geom: &GeometryColumn,
741    quant: Option<&QuantAffine>,
742    point_elev: Option<&[f64]>,
743) -> Result<ArrayRef> {
744    // 3D POINT path: interleave [x,y,z] into a FixedSizeList<_,3> leaf.
745    if let (GeometryColumn::Point(points), Some(elev)) = (geom, point_elev) {
746        let list_size = 3;
747        let dt = if quant.is_some() { DataType::Int32 } else { DataType::Float64 };
748        let field = Arc::new(Field::new("xyz", dt, false));
749        let child: ArrayRef = match quant {
750            Some(q) => {
751                let mut iv = Vec::with_capacity(points.len() * 3);
752                for (i, [x, y]) in points.iter().enumerate() {
753                    iv.push(q.qx(*x));
754                    iv.push(q.qy(*y));
755                    iv.push(q.qz(elev.get(i).copied().unwrap_or(0.0))?);
756                }
757                Arc::new(Int32Array::from(iv))
758            }
759            None => {
760                let mut flat = Vec::with_capacity(points.len() * 3);
761                for (i, [x, y]) in points.iter().enumerate() {
762                    flat.push(*x);
763                    flat.push(*y);
764                    flat.push(elev.get(i).copied().unwrap_or(0.0));
765                }
766                Arc::new(Float64Array::from(flat))
767            }
768        };
769        return Ok(Arc::new(FixedSizeListArray::new(field, list_size, child, None)));
770    }
771
772    let coord_field = || {
773        let dt = if quant.is_some() {
774            DataType::Int32
775        } else {
776            DataType::Float64
777        };
778        Arc::new(Field::new("xy", dt, false))
779    };
780    // Turn a flat `[x,y,x,y,...]` f64 run into the FixedSizeList<_,2> leaf,
781    // quantizing to i32 grid indices when an affine is supplied.
782    let make_leaf = |flat: Vec<f64>| -> ArrayRef {
783        match quant {
784            Some(q) => {
785                let mut iv = Vec::with_capacity(flat.len());
786                let mut i = 0;
787                while i + 1 < flat.len() {
788                    iv.push(q.qx(flat[i]));
789                    iv.push(q.qy(flat[i + 1]));
790                    i += 2;
791                }
792                Arc::new(FixedSizeListArray::new(
793                    coord_field(),
794                    2,
795                    Arc::new(Int32Array::from(iv)),
796                    None,
797                ))
798            }
799            None => Arc::new(FixedSizeListArray::new(
800                coord_field(),
801                2,
802                Arc::new(Float64Array::from(flat)),
803                None,
804            )),
805        }
806    };
807
808    Ok(match geom {
809        GeometryColumn::Point(points) => {
810            let mut flat = Vec::with_capacity(points.len() * 2);
811            for [x, y] in points {
812                flat.push(*x);
813                flat.push(*y);
814            }
815            make_leaf(flat)
816        }
817        GeometryColumn::LineString(lines) => {
818            let mut flat: Vec<f64> = Vec::new();
819            for line in lines {
820                for [x, y] in line {
821                    flat.push(*x);
822                    flat.push(*y);
823                }
824            }
825            let coords = make_leaf(flat);
826            let offsets = offsets_from_counts(lines.iter().map(|l| l.len()))?;
827            let vertex_field = Arc::new(Field::new("vertices", coords.data_type().clone(), false));
828            Arc::new(ListArray::new(vertex_field, offsets, coords, None))
829        }
830        GeometryColumn::Polygon(polys) => {
831            // Flatten all coordinates, ring sizes, and ring counts per feature.
832            let mut flat: Vec<f64> = Vec::new();
833            let mut ring_sizes: Vec<usize> = Vec::new();
834            let mut rings_per_feature: Vec<usize> = Vec::new();
835            for feature in polys {
836                rings_per_feature.push(feature.len());
837                for ring in feature {
838                    ring_sizes.push(ring.len());
839                    for [x, y] in ring {
840                        flat.push(*x);
841                        flat.push(*y);
842                    }
843                }
844            }
845            let coords = make_leaf(flat);
846            // Ring level: List<FixedSizeList>.
847            let ring_offsets = offsets_from_counts(ring_sizes.into_iter())?;
848            let vertex_field = Arc::new(Field::new("vertices", coords.data_type().clone(), false));
849            let rings: ArrayRef = Arc::new(ListArray::new(
850                vertex_field,
851                ring_offsets,
852                coords,
853                None,
854            ));
855            // Feature level: List<List<FixedSizeList>>.
856            let feature_offsets = offsets_from_counts(rings_per_feature.into_iter())?;
857            let ring_field = Arc::new(Field::new("rings", rings.data_type().clone(), false));
858            Arc::new(ListArray::new(ring_field, feature_offsets, rings, None))
859        }
860    })
861}
862
863/// Schema metadata keys for the v3 per-vertex time encoding.
864const VERTEX_TIME_ORIGIN_KEY: &str = "stt:vertex_time_origin_ms";
865const VERTEX_TIME_STEP_KEY: &str = "stt:vertex_time_step_ms";
866/// Number of time buckets packed into each row of the `vertex_value_matrix`
867/// column. The renderer reshapes the flat vertex-major list back into a
868/// `[vertex][bucket]` grid using this count.
869const VERTEX_VALUE_BUCKETS_KEY: &str = "stt:vertex_value_buckets";
870/// Baked minimum feature start-time (integer Unix ms) for the layer. The TS
871/// decoder relativizes every start/end time against this value so the times
872/// fit an f32; baking it here lets the decoder skip its client-side min-scan
873/// over the whole start-time column. Mirrors exactly what the decoder computes
874/// (the min of the `start_time` column) — see `packages/core/src/tile.ts`.
875const TIME_OFFSET_MS_KEY: &str = "stt:time_offset_ms";
876
877/// Default ceiling (ms) on the u16-delta `vertex_time` quantization step.
878///
879/// The u16 encoding trades precision for a 4x payload shrink, but the step is
880/// derived from the layer's temporal span — without a ceiling, a wide layer
881/// (e.g. a 30-day temporal-LOD bucket) silently quantizes vertex times to
882/// tens of seconds. A 1 s ceiling keeps the worst-case error below anything
883/// playback can show; layers needing a coarser step fall back to the exact
884/// `List<Int64>` shape instead (no thinning).
885pub const DEFAULT_VERTEX_TIME_MAX_STEP_MS: u32 = 1000;
886
887/// Process-wide step ceiling, settable once at startup (e.g. from
888/// `stt-build --vertex-time-precision`). Reads/writes are monotonic-free
889/// config, so `Relaxed` is sufficient.
890static VERTEX_TIME_MAX_STEP_MS: AtomicU32 = AtomicU32::new(DEFAULT_VERTEX_TIME_MAX_STEP_MS);
891
892/// Latch so the "u16-delta ceiling exceeded" warning fires at most once per
893/// process instead of once per tile.
894static VERTEX_TIME_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false);
895
896/// Override the u16-delta `vertex_time` step ceiling for every subsequent
897/// [`encode_layer`] call. Values below 1 ms clamp to 1 (every layer with a
898/// span beyond u16 milliseconds then takes the exact `List<Int64>` path).
899pub fn set_vertex_time_max_step_ms(ms: u32) {
900    VERTEX_TIME_MAX_STEP_MS.store(ms.max(1), Ordering::Relaxed);
901}
902
903/// The currently configured u16-delta `vertex_time` step ceiling (ms).
904pub fn vertex_time_max_step_ms() -> u32 {
905    VERTEX_TIME_MAX_STEP_MS.load(Ordering::Relaxed)
906}
907
908/// Build-global coordinate quantization precision, in **micrometers** (lets the
909/// `AtomicU32` carry sub-mm..km without a float). `0` = off (Float64 coords).
910/// Set once per build (e.g. `stt-build --quantize-coords`); read by the default
911/// [`encode_tile`] / [`encode_layer`] path so it covers both the streaming and
912/// in-memory builders without threading through every tile.
913static QUANTIZE_COORDS_UM: AtomicU32 = AtomicU32::new(0);
914
915/// Set the build-global coordinate quantization precision in meters for every
916/// subsequent default [`encode_tile`] call. `<= 0` (the default) turns it off —
917/// coordinates stay Float64 GeoArrow. See [`encode_layer_quantized`] for the
918/// size/precision trade-off. Errors (storing nothing) for a positive precision
919/// below [`MIN_QUANTIZE_COORDS_M`], which would overflow the world grid's
920/// longitude index.
921pub fn set_quantize_coords_m(meters: f64) -> Result<()> {
922    validate_quantize_coords_m(meters)?;
923    let um = if meters > 0.0 {
924        (meters * 1.0e6).round().clamp(1.0, u32::MAX as f64) as u32
925    } else {
926        0
927    };
928    QUANTIZE_COORDS_UM.store(um, Ordering::Relaxed);
929    Ok(())
930}
931
932/// The build-global quantization precision in meters, or `None` when off.
933pub fn quantize_coords_m() -> Option<f64> {
934    let um = QUANTIZE_COORDS_UM.load(Ordering::Relaxed);
935    (um > 0).then(|| um as f64 / 1.0e6)
936}
937
938/// Field-metadata key flagging a *numeric property* column that ships as
939/// fixed-point integer indices (smallest of `UInt16`/`Int32`) instead of
940/// `Float64`. Its value is the [`AttrQuant`] JSON (`value = o + q*s`). Sibling
941/// of [`STT_QUANT_META_KEY`] for geometry; lives on the property field, so a
942/// reader reconstructs Float64 the same way it does coordinates.
943pub const STT_QUANT_ATTR_META_KEY: &str = "stt:qa";
944
945/// Per-numeric-property quantization affine: `value = o + q * s`, where `o` is
946/// the column minimum (the dequantization offset) and `s` the requested ground
947/// precision (the step). Reconstruction is lossy to ≤ `s/2`; the reader applies
948/// the identical math (`tile.ts`).
949#[derive(Debug, Clone, Copy, PartialEq)]
950pub struct AttrQuant {
951    pub o: f64,
952    pub s: f64,
953}
954
955impl AttrQuant {
956    fn to_json(&self) -> String {
957        // Full f64 round-trip precision so the offset/step decode exactly.
958        format!(r#"{{"o":{:.17e},"s":{:.17e}}}"#, self.o, self.s)
959    }
960
961    /// Parse the affine from its [`STT_QUANT_ATTR_META_KEY`] JSON value.
962    pub fn from_json(s: &str) -> Option<AttrQuant> {
963        let v: serde_json::Value = serde_json::from_str(s).ok()?;
964        Some(AttrQuant {
965            o: v.get("o")?.as_f64()?,
966            s: v.get("s")?.as_f64()?,
967        })
968    }
969
970    /// Reconstruct the original value from a quantized index.
971    #[inline]
972    pub fn value(&self, q: i64) -> f64 {
973        self.o + q as f64 * self.s
974    }
975}
976
977/// Build-global map of `property-name → ground precision (units)`. A numeric
978/// property named here is stored quantized (see [`build_quantized_numeric`]);
979/// every other numeric property stays `Float64`. Set once per build from
980/// `stt-build --quantize-attr name=prec`. Empty (the default) ⇒ all numeric
981/// properties stay `Float64`, byte-identical to the historical encoder.
982fn quant_attrs_cell() -> &'static RwLock<HashMap<String, f64>> {
983    static A: OnceLock<RwLock<HashMap<String, f64>>> = OnceLock::new();
984    A.get_or_init(|| RwLock::new(HashMap::new()))
985}
986
987/// Replace the build-global numeric-property quantization map.
988pub fn set_quantize_attrs(map: HashMap<String, f64>) {
989    *quant_attrs_cell().write().unwrap() = map;
990}
991
992/// The current numeric-property quantization map (a clone).
993pub fn quantize_attrs() -> HashMap<String, f64> {
994    quant_attrs_cell().read().unwrap().clone()
995}
996
997
998/// When set, EVERY `Float64` numeric property not given an explicit precision in
999/// the [`set_quantize_attrs`] map is quantized automatically: its step is sized
1000/// so the column's full `[min, max]` range spans the 16-bit index space
1001/// (`step = (max-min)/65535`), i.e. a range-adaptive `UInt16` with ~65k levels.
1002/// This is the "born-optimized" generation default — no per-column precision to
1003/// pick, and >=16 bits of dynamic range is visually lossless for the scalar
1004/// fields STT carries (magnitude, depth, altitude, speed, SST, dBZ, ...). The
1005/// default-off keeps `stt-build` byte-identical unless a caller opts in.
1006static QUANTIZE_ATTRS_AUTO: AtomicBool = AtomicBool::new(false);
1007
1008/// Enable/disable automatic range-adaptive quantization of every otherwise-raw
1009/// `Float64` numeric property (see [`QUANTIZE_ATTRS_AUTO`]). Explicit precisions
1010/// in [`set_quantize_attrs`] always win.
1011pub fn set_quantize_attrs_auto(on: bool) {
1012    QUANTIZE_ATTRS_AUTO.store(on, Ordering::Relaxed);
1013}
1014
1015/// Whether automatic numeric-property quantization is enabled.
1016pub fn quantize_attrs_auto() -> bool {
1017    QUANTIZE_ATTRS_AUTO.load(Ordering::Relaxed)
1018}
1019
1020/// A build-time directive to fuse several scalar numeric properties into one
1021/// GPU-ready interleaved [`PropertyColumn::Vector`]. The component order is the
1022/// vector's component order (e.g. `["qx","qy","qz","qw"]` → `instanceQuaternions`).
1023/// Applied at encode time (like the quantization maps), so the producer keeps
1024/// emitting plain scalar columns and the renderer still binds the result
1025/// zero-copy. See [`set_vector_groups`].
1026#[derive(Debug, Clone)]
1027pub struct VectorGroup {
1028    /// Output column name (the FixedSizeList field name the decoder keys on).
1029    pub name: String,
1030    /// Source scalar-property names, in component order.
1031    pub components: Vec<String>,
1032    /// Leaf upload type (`F32` for quat/scale, `U8` for 0–255 RGBA).
1033    pub elem: VectorElem,
1034}
1035
1036/// Build-global list of vector groups (see [`VectorGroup`]). Set once per build
1037/// from `stt-build --vector-group`. Empty (the default) ⇒ every property stays a
1038/// scalar column, byte-identical to the historical encoder.
1039fn vector_groups_cell() -> &'static RwLock<Vec<VectorGroup>> {
1040    static A: OnceLock<RwLock<Vec<VectorGroup>>> = OnceLock::new();
1041    A.get_or_init(|| RwLock::new(Vec::new()))
1042}
1043
1044/// Replace the build-global vector-group list.
1045pub fn set_vector_groups(groups: Vec<VectorGroup>) {
1046    *vector_groups_cell().write().unwrap() = groups;
1047}
1048
1049/// The current vector-group list (a clone).
1050pub fn vector_groups() -> Vec<VectorGroup> {
1051    vector_groups_cell().read().unwrap().clone()
1052}
1053
1054/// Build-global name of the numeric property to fold into POINT geometry as the
1055/// 3rd (altitude) coordinate, so the tile ships true 3D points
1056/// (`FixedSizeList<_,3>`) the renderer binds zero-copy — no per-point pad to 3D
1057/// on the main thread. The named column is REMOVED from the property set (it
1058/// lives in the geometry instead). Empty (default) ⇒ plain 2D points.
1059fn point_elevation_column_cell() -> &'static RwLock<String> {
1060    static A: OnceLock<RwLock<String>> = OnceLock::new();
1061    A.get_or_init(|| RwLock::new(String::new()))
1062}
1063
1064/// Set the build-global point-elevation column name (see above). Empty disables.
1065pub fn set_point_elevation_column(name: &str) {
1066    *point_elevation_column_cell().write().unwrap() = name.to_string();
1067}
1068
1069/// The current point-elevation column name (empty ⇒ disabled).
1070pub fn point_elevation_column() -> String {
1071    point_elevation_column_cell().read().unwrap().clone()
1072}
1073
1074/// Build-global layer-frame format version ([`FORMAT_VERSION_V1`] |
1075/// [`FORMAT_VERSION_V2`]). Defaults to v1 so every existing globals-path
1076/// caller (including `stt-serve`, which must stay v1 per the serve protocol)
1077/// is byte-identical to the 0.3.x writer; `stt-build` opts the offline build
1078/// into v2 explicitly.
1079static FORMAT_VERSION: AtomicU32 = AtomicU32::new(FORMAT_VERSION_V1);
1080
1081/// Set the build-global frame format version for every subsequent default
1082/// [`encode_tile`] call. Errors (storing nothing) on anything but 1 or 2.
1083pub fn set_format_version(v: u32) -> Result<()> {
1084    if v != FORMAT_VERSION_V1 && v != FORMAT_VERSION_V2 {
1085        return Err(Error::Other(format!(
1086            "unsupported format version {v} (this writer emits 1 or 2)"
1087        )));
1088    }
1089    FORMAT_VERSION.store(v, Ordering::Relaxed);
1090    Ok(())
1091}
1092
1093/// The build-global frame format version.
1094pub fn format_version() -> u32 {
1095    FORMAT_VERSION.load(Ordering::Relaxed)
1096}
1097
1098/// Build-global template collector for the v2 hash-referencing mode. `None`
1099/// (the default) makes a v2 encode self-contained (inline schema sections);
1100/// `stt-build` installs the `PackWriter`'s collector here so the offline
1101/// globals-path encodes reference templates the manifest will carry.
1102fn template_collector_cell() -> &'static RwLock<Option<Arc<TemplateCollector>>> {
1103    static A: OnceLock<RwLock<Option<Arc<TemplateCollector>>>> = OnceLock::new();
1104    A.get_or_init(|| RwLock::new(None))
1105}
1106
1107/// Install (or clear, with `None`) the build-global [`TemplateCollector`].
1108pub fn set_template_collector(collector: Option<Arc<TemplateCollector>>) {
1109    *template_collector_cell().write().unwrap() = collector;
1110}
1111
1112/// The current build-global template collector, if any.
1113pub fn template_collector() -> Option<Arc<TemplateCollector>> {
1114    template_collector_cell().read().unwrap().clone()
1115}
1116
1117/// Resolved, explicit encoder settings — the values the tile encoder reads at
1118/// encode time (coordinate + attribute quantization, vector grouping, the
1119/// point-elevation fold, vertex-time precision).
1120///
1121/// Historically these lived only in process-wide mutable statics set via the
1122/// `set_*` functions above; that made a new encode caller silently inherit
1123/// whatever the last `set_*` left behind (the origin of the `stt-serve` parity
1124/// bug) and made it impossible to encode two different configurations in one
1125/// process (blocking multi-dataset serve + parallel per-config tests). Passing an
1126/// `EncoderConfig` explicitly to [`encode_tile_with`] / [`encode_layer_with`]
1127/// removes both problems. The globals + the no-arg [`encode_tile`] /
1128/// [`encode_layer`] wrappers remain for the one-shot CLI and existing callers;
1129/// [`EncoderConfig::from_globals`] snapshots them.
1130#[derive(Debug, Clone)]
1131pub struct EncoderConfig {
1132    /// Fixed-point coordinate quantization ground precision in meters
1133    /// (`None` = Float64 GeoArrow coordinates, the default).
1134    pub quantize_coords_m: Option<f64>,
1135    /// Per-property explicit fixed-point precisions (`name → precision`).
1136    pub quantize_attrs: HashMap<String, f64>,
1137    /// Range-adaptive `UInt16` quantization for every un-listed Float64 property.
1138    pub quantize_attrs_auto: bool,
1139    /// Scalar columns to fuse into interleaved `FixedSizeList` vector columns.
1140    pub vector_groups: Vec<VectorGroup>,
1141    /// Property folded into POINT geometry as the z coordinate (empty = none).
1142    pub point_elevation_column: String,
1143    /// Ceiling (ms) on the per-vertex time u16-delta quantization step.
1144    pub vertex_time_max_step_ms: u32,
1145    /// Layer-frame format version ([`FORMAT_VERSION_V1`] |
1146    /// [`FORMAT_VERSION_V2`]). The SINGLE branch point between the frozen
1147    /// 0.3.x v1 frame and the sectioned v2 frame (design §1 ★F3): everything
1148    /// upstream of frame assembly + metadata placement is shared. Defaults to
1149    /// v1 so every existing caller stays byte-identical; `stt-build` passes 2.
1150    pub format_version: u32,
1151    /// v2 only: when set, layer schemas are hoisted into this collector and
1152    /// frames carry 16-byte template-hash references (the packed-dataset
1153    /// mode — `PackWriter::finalize` publishes the collected templates in
1154    /// `manifest.schemas`). `None` under v2 emits self-contained frames with
1155    /// inline schema sections. Ignored under v1.
1156    pub template_collector: Option<Arc<TemplateCollector>>,
1157}
1158
1159impl Default for EncoderConfig {
1160    fn default() -> Self {
1161        Self {
1162            quantize_coords_m: None,
1163            quantize_attrs: HashMap::new(),
1164            quantize_attrs_auto: false,
1165            vector_groups: Vec::new(),
1166            point_elevation_column: String::new(),
1167            vertex_time_max_step_ms: DEFAULT_VERTEX_TIME_MAX_STEP_MS,
1168            format_version: FORMAT_VERSION_V1,
1169            template_collector: None,
1170        }
1171    }
1172}
1173
1174impl EncoderConfig {
1175    /// Snapshot the current process-wide encoder globals into an explicit config.
1176    /// Used by the no-arg [`encode_tile`] / [`encode_layer`] back-compat wrappers.
1177    pub fn from_globals() -> Self {
1178        Self {
1179            quantize_coords_m: quantize_coords_m(),
1180            quantize_attrs: quantize_attrs(),
1181            quantize_attrs_auto: quantize_attrs_auto(),
1182            vector_groups: vector_groups(),
1183            point_elevation_column: point_elevation_column(),
1184            vertex_time_max_step_ms: vertex_time_max_step_ms(),
1185            format_version: format_version(),
1186            template_collector: template_collector(),
1187        }
1188    }
1189}
1190
1191/// Fuse the scalar columns named by each configured [`VectorGroup`] into a single
1192/// [`PropertyColumn::Vector`], leaving every other column untouched and in order.
1193///
1194/// A group whose components are not ALL present as `Numeric` columns is skipped
1195/// (its scalars stay as-is) — so a tile that happens not to carry one of the
1196/// inputs degrades to scalars rather than dropping data. Missing per-feature
1197/// values (`None`) encode as `0.0`. Returns `None` when no group applied, letting
1198/// the caller iterate `layer.properties` directly with no clone.
1199fn group_vector_properties(
1200    props: &[(String, PropertyColumn)],
1201    n: usize,
1202    groups: &[VectorGroup],
1203) -> Option<Vec<(String, PropertyColumn)>> {
1204    if groups.is_empty() {
1205        return None;
1206    }
1207    // name → numeric values slice, for component lookup.
1208    let numeric: HashMap<&str, &[Option<f64>]> = props
1209        .iter()
1210        .filter_map(|(k, c)| match c {
1211            PropertyColumn::Numeric(v) => Some((k.as_str(), v.as_slice())),
1212            _ => None,
1213        })
1214        .collect();
1215
1216    let mut out: Vec<(String, PropertyColumn)> = Vec::new();
1217    let mut consumed: HashSet<String> = HashSet::new();
1218    let mut any = false;
1219    for g in groups {
1220        if g.components.is_empty()
1221            || !g.components.iter().all(|c| numeric.contains_key(c.as_str()))
1222        {
1223            continue;
1224        }
1225        let width = g.components.len();
1226        let mut values = vec![0f32; width * n];
1227        for (ci, cname) in g.components.iter().enumerate() {
1228            let col = numeric[cname.as_str()];
1229            for i in 0..n {
1230                values[i * width + ci] = col[i].map(|x| x as f32).unwrap_or(0.0);
1231            }
1232        }
1233        for c in &g.components {
1234            consumed.insert(c.clone());
1235        }
1236        out.push((
1237            g.name.clone(),
1238            PropertyColumn::Vector {
1239                width,
1240                elem: g.elem,
1241                values,
1242            },
1243        ));
1244        any = true;
1245    }
1246    if !any {
1247        return None;
1248    }
1249    // Keep every column not pulled into a group, in original order.
1250    for (k, c) in props {
1251        if !consumed.contains(k) {
1252            out.push((k.clone(), c.clone()));
1253        }
1254    }
1255    Some(out)
1256}
1257
1258/// Range-adaptive auto quantization: size the step from the column's own span so
1259/// `[min, max]` maps onto `[0, 65535]`. ALWAYS returns a `UInt16` column (never
1260/// `None`, never `Int32`) so a column is quantized to the *same type in every
1261/// tile* — a per-tile range-adaptive choice would otherwise leave constant /
1262/// all-null tiles as `Float64` and drift the layer schema across tiles. A
1263/// constant column quantizes to all-zeros and an all-null column to all-nulls;
1264/// both compress to nothing, so the uniform `UInt16` costs nothing and keeps the
1265/// schema consistent.
1266fn build_quantized_numeric_auto(values: &[Option<f64>]) -> Option<(ArrayRef, String)> {
1267    let mut min = f64::INFINITY;
1268    let mut max = f64::NEG_INFINITY;
1269    for v in values.iter().flatten() {
1270        if v.is_finite() {
1271            min = min.min(*v);
1272            max = max.max(*v);
1273        }
1274    }
1275    // o = offset (column min, or 0 when no finite value); s = step. A zero span
1276    // (constant / no-data) uses step 1 → every present value maps to index 0,
1277    // which reconstructs to `o` exactly.
1278    let (o, s) = if min.is_finite() {
1279        if max > min {
1280            (min, (max - min) / u16::MAX as f64)
1281        } else {
1282            (min, 1.0)
1283        }
1284    } else {
1285        (0.0, 1.0)
1286    };
1287    let affine = AttrQuant { o, s };
1288    let mut b = UInt16Builder::with_capacity(values.len());
1289    for v in values {
1290        match v {
1291            Some(x) if x.is_finite() => {
1292                let q = (((*x - o) / s).round()).clamp(0.0, u16::MAX as f64) as u16;
1293                b.append_value(q);
1294            }
1295            _ => b.append_null(),
1296        }
1297    }
1298    Some((Arc::new(b.finish()), affine.to_json()))
1299}
1300
1301/// Quantize a numeric property column to the smallest integer leaf at `prec`
1302/// units, with the offset pinned to the column minimum. Returns
1303/// `(array, affine_json)` — a `UInt16` leaf when the quantized range fits 16
1304/// bits, else `Int32` — or `None` when no finite value exists (caller keeps the
1305/// `Float64` column). Nulls and non-finite values become Arrow nulls. The
1306/// offset is the per-column minimum: identical columns quantize identically, so
1307/// the packed format's content-addressed dedup is preserved.
1308///
1309/// Errors when a value's quantized index exceeds `i32::MAX` — the widest leaf
1310/// this encoding ships. Erroring is deliberate (mirrors the dictionary-overflow
1311/// error in [`build_dictionary_indices`]): the previous behaviour silently
1312/// clamped every overflowing value to `i32::MAX`, mislabeling features without
1313/// a trace. A producer whose column genuinely spans more than `i32::MAX`
1314/// quanta should pick a coarser precision or leave the column `Float64`.
1315fn build_quantized_numeric(
1316    values: &[Option<f64>],
1317    prec: f64,
1318) -> Result<Option<(ArrayRef, String)>> {
1319    if !(prec > 0.0) {
1320        return Ok(None);
1321    }
1322    let mut min = f64::INFINITY;
1323    for v in values.iter().flatten() {
1324        if v.is_finite() && *v < min {
1325            min = *v;
1326        }
1327    }
1328    if !min.is_finite() {
1329        return Ok(None); // no finite values — keep Float64
1330    }
1331    let affine = AttrQuant { o: min, s: prec };
1332    let mut q: Vec<Option<i64>> = Vec::with_capacity(values.len());
1333    let mut max_q: i64 = 0;
1334    for v in values {
1335        match v {
1336            Some(x) if x.is_finite() => {
1337                let qi = (((*x - affine.o) / affine.s).round() as i64).max(0);
1338                if qi > i32::MAX as i64 {
1339                    return Err(Error::Other(format!(
1340                        "numeric property quantization overflows: value {x} at precision \
1341                         {prec} quantizes to index {qi} (offset {min}), beyond the Int32 \
1342                         leaf's {} ceiling; use a coarser --quantize-attr precision or \
1343                         leave the column Float64",
1344                        i32::MAX
1345                    )));
1346                }
1347                if qi > max_q {
1348                    max_q = qi;
1349                }
1350                q.push(Some(qi));
1351            }
1352            _ => q.push(None),
1353        }
1354    }
1355    let array: ArrayRef = if max_q <= u16::MAX as i64 {
1356        let mut b = UInt16Builder::with_capacity(q.len());
1357        for qi in &q {
1358            match qi {
1359                Some(v) => b.append_value(*v as u16),
1360                None => b.append_null(),
1361            }
1362        }
1363        Arc::new(b.finish())
1364    } else {
1365        let mut b = Int32Builder::with_capacity(q.len());
1366        for qi in &q {
1367            match qi {
1368                Some(v) => b.append_value(*v as i32),
1369                None => b.append_null(),
1370            }
1371        }
1372        Arc::new(b.finish())
1373    };
1374    Ok(Some((array, affine.to_json())))
1375}
1376
1377/// Build a (key_array, value_array) pair for a Dictionary<UInt16, Utf8>
1378/// column. Null inputs become null keys (the corresponding string is not
1379/// inserted into the dictionary); strings are deduplicated in first-seen
1380/// order so the on-disk Arrow dictionary is stable across runs.
1381///
1382/// Errors if a column has more than `u16::MAX` distinct values, which the
1383/// `UInt16` key space cannot address. In practice STT categorical columns top
1384/// out in the low hundreds, so this only fires on pathological input (e.g. a
1385/// per-feature unique id mistaken for a category). Erroring is deliberate: the
1386/// previous behaviour silently collapsed every overflowing value to a single
1387/// index, mislabeling features without a trace. A producer that genuinely needs
1388/// >65k distinct strings should split the column or widen the key type.
1389fn build_dictionary_indices(
1390    values: &[Option<String>],
1391) -> Result<(Vec<Option<u16>>, Vec<String>)> {
1392    let mut categories: Vec<String> = Vec::new();
1393    let mut lookup: HashMap<String, u16> = HashMap::new();
1394    let mut indices: Vec<Option<u16>> = Vec::with_capacity(values.len());
1395    for v in values {
1396        match v {
1397            Some(s) => {
1398                if let Some(&idx) = lookup.get(s) {
1399                    indices.push(Some(idx));
1400                } else if categories.len() < u16::MAX as usize {
1401                    let idx = categories.len() as u16;
1402                    categories.push(s.clone());
1403                    lookup.insert(s.clone(), idx);
1404                    indices.push(Some(idx));
1405                } else {
1406                    return Err(Error::Other(format!(
1407                        "categorical column has more than {} distinct values, which a \
1408                         Dictionary<UInt16, Utf8> key cannot address; split the column \
1409                         into multiple categorical fields or widen the key type",
1410                        u16::MAX
1411                    )));
1412                }
1413            }
1414            None => indices.push(None),
1415        }
1416    }
1417    Ok((indices, categories))
1418}
1419
1420/// Built per-vertex time column, alongside the per-layer schema metadata
1421/// that lets the reader reconstruct absolute timestamps.
1422struct VertexTimeColumn {
1423    array: ArrayRef,
1424    /// `(origin_ms, step_ms)` when the column is u16-delta-encoded. `None`
1425    /// when the column kept its absolute `List<Int64>` shape (the exact
1426    /// fallback path, used for layers whose temporal span would need a step
1427    /// beyond the configured ceiling — see [`DEFAULT_VERTEX_TIME_MAX_STEP_MS`]).
1428    encoding: Option<(i64, u32)>,
1429}
1430
1431/// Build the optional per-vertex time column.
1432///
1433/// v3 attempts to encode timestamps as `List<UInt16>` deltas relative to
1434/// a per-layer origin and step (`absolute = origin + delta * step`), with
1435/// the step bounded by `max_step_ms` (see
1436/// [`DEFAULT_VERTEX_TIME_MAX_STEP_MS`]). When the layer's temporal span
1437/// would need a coarser step than that, the column keeps the exact v2
1438/// `List<Int64>` shape instead — bounded quantization or no quantization,
1439/// never a silent precision cliff.
1440fn build_vertex_time_array(
1441    vertex_times: &Option<Vec<Vec<i64>>>,
1442    feature_count: usize,
1443    max_step_ms: u32,
1444) -> Option<VertexTimeColumn> {
1445    let vt = vertex_times.as_ref()?;
1446
1447    // Discover the layer's temporal span across every (feature, vertex) pair.
1448    // Empty / null lists are skipped — a list-of-nulls is fine, it just won't
1449    // shrink the span.
1450    let mut min = i64::MAX;
1451    let mut max = i64::MIN;
1452    let mut any = false;
1453    for times in vt.iter().take(feature_count) {
1454        for &t in times {
1455            if t < min {
1456                min = t;
1457            }
1458            if t > max {
1459                max = t;
1460            }
1461            any = true;
1462        }
1463    }
1464
1465    if any && max >= min {
1466        // Pick the smallest step (in ms) that keeps every (t - min) inside
1467        // u16::MAX. step=1 means "exact ms granularity"; larger steps trade
1468        // precision (bounded by step ms) for a 4x payload shrink vs i64.
1469        let span = (max - min) as u64;
1470        // Computed in u64 and compared BEFORE narrowing: a pathological span
1471        // whose step overflows u32 must hit the i64 path, not wrap small.
1472        let step = if span <= u16::MAX as u64 {
1473            1u64
1474        } else {
1475            ((span + u16::MAX as u64 - 1) / u16::MAX as u64).max(1)
1476        };
1477        // Step ceiling: beyond it the quantization error would exceed the
1478        // configured precision, so take the exact i64 path below instead.
1479        if step <= max_step_ms as u64 {
1480            let step = step as u32;
1481            let mut builder = ListBuilder::new(UInt16Builder::new());
1482            for i in 0..feature_count {
1483                match vt.get(i) {
1484                    Some(times) if !times.is_empty() => {
1485                        for &t in times {
1486                            // Saturate at u16::MAX — for a sensibly chosen
1487                            // step this branch can only fire on inputs that
1488                            // disagree with the (min,max) scan above (e.g.
1489                            // a `vertex_times` longer than feature_count).
1490                            let delta = ((t - min) as u64 / step as u64).min(u16::MAX as u64) as u16;
1491                            builder.values().append_value(delta);
1492                        }
1493                        builder.append(true);
1494                    }
1495                    _ => builder.append(false),
1496                }
1497            }
1498            return Some(VertexTimeColumn {
1499                array: Arc::new(builder.finish()),
1500                encoding: Some((min, step)),
1501            });
1502        }
1503        // Reached the exact-Int64 fallback because the span needs a coarser
1504        // step than the ceiling. Warn once per process (not per tile).
1505        if !VERTEX_TIME_FALLBACK_WARNED.swap(true, Ordering::Relaxed) {
1506            tracing::warn!(
1507                "vertex-time span {}ms exceeds u16-delta ceiling (step {}ms > max {}ms); \
1508                 falling back to exact Int64 — payload keeps full precision but is ~4x larger",
1509                span,
1510                step,
1511                max_step_ms
1512            );
1513        }
1514    }
1515
1516    // Fallback: legacy absolute List<Int64> for empty-ish columns or
1517    // pathological steps. Identical wire shape to v2.
1518    let mut builder = ListBuilder::new(Int64Builder::new());
1519    for i in 0..feature_count {
1520        match vt.get(i) {
1521            Some(times) if !times.is_empty() => {
1522                for &t in times {
1523                    builder.values().append_value(t);
1524                }
1525                builder.append(true);
1526            }
1527            _ => builder.append(false),
1528        }
1529    }
1530    Some(VertexTimeColumn {
1531        array: Arc::new(builder.finish()),
1532        encoding: None,
1533    })
1534}
1535
1536/// Build the optional per-vertex scalar column as a nullable `List<Float32>`.
1537///
1538/// Unlike `vertex_time`, there's no delta/origin/step encoding — the values are
1539/// producer-defined scalars (e.g. sea-surface temperature) with a small range
1540/// where f32 precision is ample. A feature with no per-vertex values appends a
1541/// null list; `NaN` entries within a list mark individual vertices with no value.
1542/// Returns `None` when no feature carries any value, so the column is omitted.
1543fn build_vertex_value_array(
1544    vertex_values: &Option<Vec<Vec<f32>>>,
1545    feature_count: usize,
1546) -> Option<ArrayRef> {
1547    let vv = vertex_values.as_ref()?;
1548    let any = vv.iter().take(feature_count).any(|v| !v.is_empty());
1549    if !any {
1550        return None;
1551    }
1552    let mut builder = ListBuilder::new(Float32Builder::new());
1553    for i in 0..feature_count {
1554        match vv.get(i) {
1555            Some(values) if !values.is_empty() => {
1556                for &v in values {
1557                    builder.values().append_value(v);
1558                }
1559                builder.append(true);
1560            }
1561            _ => builder.append(false),
1562        }
1563    }
1564    Some(Arc::new(builder.finish()))
1565}
1566
1567/// Recover the per-vertex value-matrix bucket count: for the first LineString
1568/// feature carrying both vertices and matrix data, `num_buckets = matrix_len /
1569/// vertex_count` (the matrix is vertex-major, `num_vertices * num_buckets`
1570/// long). Returns `None` for non-line geometry or when no feature carries a
1571/// clean multiple — the matrix column is then present but non-animatable.
1572fn infer_vertex_value_buckets(matrix: &[Vec<f32>], geometry: &GeometryColumn) -> Option<u32> {
1573    let lines = match geometry {
1574        GeometryColumn::LineString(lines) => lines,
1575        _ => return None,
1576    };
1577    for (i, m) in matrix.iter().enumerate() {
1578        let nv = lines.get(i)?.len();
1579        if !m.is_empty() && nv > 0 && m.len() % nv == 0 {
1580            return Some((m.len() / nv) as u32);
1581        }
1582    }
1583    None
1584}
1585
1586// ----------------------------------------------------------------------------
1587// Encoding
1588// ----------------------------------------------------------------------------
1589
1590/// Encode a single layer to an Arrow IPC stream.
1591pub fn encode_layer(layer: &ColumnarLayer) -> Result<Vec<u8>> {
1592    encode_layer_cfg(layer, &EncoderConfig::from_globals())
1593}
1594
1595/// [`encode_layer`] with optional fixed-point coordinate quantization.
1596///
1597/// `quantize_m = Some(meters)` stores coordinates as `i32` grid indices at that
1598/// ground precision (default-off `None` is byte-identical to [`encode_layer`]).
1599/// Coordinates are the dominant, near-incompressible tile column, so quantizing
1600/// them is the single largest size lever — at the cost of GeoArrow Float64
1601/// self-description, hence opt-in. The per-layer affine rides in the geometry
1602/// field metadata under [`STT_QUANT_META_KEY`]; the reader reconstructs Float64.
1603///
1604/// The non-coordinate settings (attribute quantization, vector grouping,
1605/// point-elevation fold, vertex-time precision) come from the process-wide
1606/// globals; use [`encode_layer_with`] to pass every setting explicitly.
1607pub fn encode_layer_quantized(layer: &ColumnarLayer, quantize_m: Option<f64>) -> Result<Vec<u8>> {
1608    encode_layer_cfg(
1609        layer,
1610        &EncoderConfig {
1611            quantize_coords_m: quantize_m,
1612            ..EncoderConfig::from_globals()
1613        },
1614    )
1615}
1616
1617/// [`encode_layer`] with a fully-explicit [`EncoderConfig`] — no process-wide
1618/// globals are read. This is the concurrency- and multi-config-safe entry point
1619/// (e.g. a dynamic server fronting several datasets with different settings).
1620pub fn encode_layer_with(layer: &ColumnarLayer, cfg: &EncoderConfig) -> Result<Vec<u8>> {
1621    encode_layer_cfg(layer, cfg)
1622}
1623
1624/// The single-layer encode implementation, driven entirely by an explicit
1625/// [`EncoderConfig`]. Every public `encode_layer*` entry point funnels here.
1626///
1627/// Always emits the self-describing v1 shape — one Arrow IPC stream with ALL
1628/// metadata inline. The v2 tile frame reuses the identical column/field build
1629/// ([`build_layer_parts`]) and changes only metadata PLACEMENT at assembly
1630/// ([`encode_layer_v2_parts`]) — the design's single v1/v2 branch point.
1631fn encode_layer_cfg(layer: &ColumnarLayer, cfg: &EncoderConfig) -> Result<Vec<u8>> {
1632    let parts = build_layer_parts(layer, cfg)?;
1633    assemble_layer_ipc_v1(parts)
1634}
1635
1636/// Everything a frame assembler needs about one built layer: the Arrow fields
1637/// + columns in canonical order (reserved columns first, then properties) and
1638/// the per-tile schema-metadata values. Both frame versions build this
1639/// identically; only where the metadata LANDS differs (v1: schema metadata;
1640/// v2: `TILE_META` section + stripped, dataset-constant templates).
1641struct LayerParts {
1642    fields: Vec<Arc<Field>>,
1643    columns: Vec<ArrayRef>,
1644    /// `fields[..reserved_len]` are the reserved (CORE) columns; the rest are
1645    /// property (PROPS) columns.
1646    reserved_len: usize,
1647    layer_name: String,
1648    geometry_name: &'static str,
1649    /// Minimum feature start-time (v1 `stt:time_offset_ms` / v2 `t0`).
1650    min_start_time: Option<i64>,
1651    /// `(origin_ms, step_ms)` of a u16-delta `vertex_time` column.
1652    vertex_time_encoding: Option<(i64, u32)>,
1653    /// v1 `stt:vertex_value_buckets` / v2 `vb`.
1654    vertex_value_buckets: Option<u32>,
1655    has_triangles: bool,
1656}
1657
1658/// Build the Arrow fields + columns for one layer — the version-independent
1659/// front of the encoder (validation, quantization, vector grouping, the
1660/// point-elevation fold, vertex-time encoding).
1661fn build_layer_parts(layer: &ColumnarLayer, cfg: &EncoderConfig) -> Result<LayerParts> {
1662    layer.validate()?;
1663    let n = layer.feature_count();
1664
1665    let mut fields: Vec<Arc<Field>> = Vec::new();
1666    let mut columns: Vec<ArrayRef> = Vec::new();
1667
1668    fields.push(Arc::new(Field::new("id", DataType::UInt64, false)));
1669    columns.push(Arc::new(UInt64Array::from(layer.feature_ids.clone())));
1670
1671    fields.push(Arc::new(Field::new("start_time", DataType::Int64, false)));
1672    columns.push(Arc::new(Int64Array::from(layer.start_times.clone())));
1673
1674    fields.push(Arc::new(Field::new("end_time", DataType::Int64, false)));
1675    columns.push(Arc::new(Int64Array::from(layer.end_times.clone())));
1676
1677    // 3D POINT geometry: fold the configured numeric column into the geometry's
1678    // 3rd coordinate, so the tile ships true 3D points the renderer binds
1679    // zero-copy (no per-point pad). The column is then dropped from properties.
1680    let elev_col = cfg.point_elevation_column.clone();
1681    let point_elev: Option<Vec<f64>> = if !elev_col.is_empty()
1682        && matches!(layer.geometry, GeometryColumn::Point(_))
1683    {
1684        layer.properties.iter().find_map(|(name, col)| match col {
1685            PropertyColumn::Numeric(v) if name == &elev_col => {
1686                let n = layer.feature_count();
1687                let mut out = vec![0.0f64; n];
1688                for (i, x) in v.iter().enumerate() {
1689                    if let Some(val) = x {
1690                        out[i] = *val;
1691                    }
1692                }
1693                Some(out)
1694            }
1695            _ => None,
1696        })
1697    } else {
1698        None
1699    };
1700    let elev_consumed = point_elev.is_some();
1701
1702    // Geometry column carries the GeoArrow extension name in field metadata.
1703    // The precision floor is enforced HERE (where the meters value is
1704    // consumed), so every entry point — globals, explicit EncoderConfig, a
1705    // server's per-request config — hits the same guard.
1706    if let Some(m) = cfg.quantize_coords_m {
1707        validate_quantize_coords_m(m)?;
1708    }
1709    let quant = cfg.quantize_coords_m.and_then(|m| {
1710        if point_elev.is_some() {
1711            world_grid_affine_3d(m)
1712        } else {
1713            world_grid_affine(m)
1714        }
1715    });
1716    let geom_array =
1717        build_geometry_array_q(&layer.geometry, quant.as_ref(), point_elev.as_deref())?;
1718    // Assemble field metadata in a BTreeMap so the key set is emitted in a
1719    // deterministic (lexicographic) order regardless of insertion order. Arrow
1720    // ≥59 serializes IPC schema metadata in sorted order, so building from a
1721    // sorted source makes the raw metadata-region bytes byte-reproducible across
1722    // runs (guarded by `same_tile_encodes_byte_identically` in
1723    // reproducible_build.rs) — closing the old arrow-54 HashMap-iteration gap.
1724    let mut geom_meta = BTreeMap::new();
1725    geom_meta.insert(
1726        GEOARROW_EXT_KEY.to_string(),
1727        layer.geometry.geoarrow_name().to_string(),
1728    );
1729    match &quant {
1730        // A quantized tile's `xy` leaf is i32 grid indices, not Float64 lon/lat,
1731        // so the GeoArrow CRS doesn't apply — swap it for the reconstruction
1732        // affine (whose presence is the reader's quantization signal). Field
1733        // metadata is assembled in a BTreeMap (deterministic key order); Arrow
1734        // ≥59 serializes IPC schema metadata in sorted order, so the raw
1735        // metadata-region bytes are byte-reproducible across runs (guarded by
1736        // the `same_tile_encodes_byte_identically` test in reproducible_build.rs).
1737        Some(q) => {
1738            geom_meta.insert(STT_QUANT_META_KEY.to_string(), q.to_json());
1739        }
1740        // Advertise the CRS so the tile is self-describing to GeoArrow consumers.
1741        None => {
1742            geom_meta.insert(
1743                GEOARROW_EXT_META_KEY.to_string(),
1744                GEOARROW_CRS_METADATA.to_string(),
1745            );
1746        }
1747    }
1748    fields.push(Arc::new(
1749        Field::new("geometry", geom_array.data_type().clone(), false)
1750            .with_metadata(geom_meta.into_iter().collect()),
1751    ));
1752    columns.push(geom_array);
1753
1754    // Track per-layer vertex-time encoding so the schema metadata (set
1755    // below) records the origin/step needed for the u16-delta reader path.
1756    let mut vertex_time_encoding: Option<(i64, u32)> = None;
1757    if let Some(vt_col) = build_vertex_time_array(&layer.vertex_times, n, cfg.vertex_time_max_step_ms) {
1758        fields.push(Arc::new(Field::new(
1759            "vertex_time",
1760            vt_col.array.data_type().clone(),
1761            true,
1762        )));
1763        columns.push(vt_col.array);
1764        vertex_time_encoding = vt_col.encoding;
1765    }
1766
1767    // Optional per-vertex scalar column (e.g. sea-surface temperature),
1768    // aligned 1:1 with the geometry vertices like `vertex_time`.
1769    if let Some(vv_array) = build_vertex_value_array(&layer.vertex_values, n) {
1770        fields.push(Arc::new(Field::new(
1771            "vertex_value",
1772            vv_array.data_type().clone(),
1773            true,
1774        )));
1775        columns.push(vv_array);
1776    }
1777
1778    // Optional per-vertex × per-bucket value matrix (static-geometry overview
1779    // animation). Reuses the `vertex_value` List<Float32> encoding — each row
1780    // is just longer (vertex_count * num_buckets, vertex-major). num_buckets is
1781    // recovered from the per-feature vertex count and recorded in schema meta.
1782    let mut vertex_value_buckets: Option<u32> = None;
1783    if let Some(vm_array) = build_vertex_value_array(&layer.vertex_value_matrix, n) {
1784        fields.push(Arc::new(Field::new(
1785            "vertex_value_matrix",
1786            vm_array.data_type().clone(),
1787            true,
1788        )));
1789        columns.push(vm_array);
1790        vertex_value_buckets = layer
1791            .vertex_value_matrix
1792            .as_ref()
1793            .and_then(|vm| infer_vertex_value_buckets(vm, &layer.geometry));
1794    }
1795
1796    // Pre-baked triangle indices (MLT-style). Only emitted for polygon
1797    // layers; for any other geometry kind the column is silently dropped so
1798    // an over-eager builder can't poison a point/line layer with stale data.
1799    let has_triangles = matches!(layer.geometry, GeometryColumn::Polygon(_))
1800        && layer
1801            .triangles
1802            .as_ref()
1803            .map(|t| t.iter().any(|f| !f.is_empty()))
1804            .unwrap_or(false);
1805    if has_triangles {
1806        let tri = layer.triangles.as_ref().unwrap();
1807        // Indices are feature-LOCAL (see the field doc above), so they're
1808        // almost always well under 65,536 even for large layers. Mirrors
1809        // build_vertex_time_array's width-selection: scan once, use the
1810        // narrower UInt16 (half the bytes) when every index fits, UInt32
1811        // otherwise. The Arrow field type is derived from the array below,
1812        // so this is fully self-describing — the TS decoder branches on the
1813        // runtime child-array type exactly like it already does for
1814        // vertex_time's UInt16-delta vs Int64-absolute split.
1815        let max_index = tri.iter().flatten().copied().max().unwrap_or(0);
1816        let array: ArrayRef = if max_index <= u16::MAX as u32 {
1817            let mut builder = ListBuilder::new(UInt16Builder::new());
1818            for feature in tri {
1819                for &idx in feature {
1820                    builder.values().append_value(idx as u16);
1821                }
1822                // Always append a (possibly empty) list — readers expect one
1823                // entry per feature.
1824                builder.append(true);
1825            }
1826            Arc::new(builder.finish())
1827        } else {
1828            let mut builder = ListBuilder::new(UInt32Builder::new());
1829            for feature in tri {
1830                for &idx in feature {
1831                    builder.values().append_value(idx);
1832                }
1833                builder.append(true);
1834            }
1835            Arc::new(builder.finish())
1836        };
1837        fields.push(Arc::new(Field::new(
1838            "triangles",
1839            array.data_type().clone(),
1840            false,
1841        )));
1842        columns.push(array);
1843    }
1844
1845    // Every field pushed so far is a reserved column — the v2 CORE batch.
1846    // Property columns (the v2 PROPS batch) follow.
1847    let reserved_len = fields.len();
1848
1849    // Fuse configured scalar columns into GPU-ready interleaved Vector columns
1850    // (e.g. qx/qy/qz/qw → one FixedSizeList<f32,4>). Runs BEFORE the quantize
1851    // loop so grouped components are written as the raw vector, not individually
1852    // quantized. No groups configured ⇒ iterate `layer.properties` with no clone.
1853    let grouped =
1854        group_vector_properties(&layer.properties, layer.feature_count(), &cfg.vector_groups);
1855    let props_iter: &[(String, PropertyColumn)] =
1856        grouped.as_deref().unwrap_or(&layer.properties);
1857    for (name, col) in props_iter {
1858        // The point-elevation column now lives in the geometry's 3rd coordinate;
1859        // don't also emit it as a scalar property.
1860        if elev_consumed && name == &elev_col {
1861            continue;
1862        }
1863        match col {
1864            PropertyColumn::Numeric(values) => {
1865                // Opt-in: a numeric property named in the build-global
1866                // quantization map ships as fixed-point ints + a per-column
1867                // affine in field metadata (the reader reconstructs Float64).
1868                // For a LiDAR `z` column this is the single largest size lever
1869                // after `id` — a raw Float64 elevation barely compresses, while
1870                // the i16 grid is both smaller and far more compressible.
1871                let quantized = match cfg
1872                    .quantize_attrs
1873                    .get(name)
1874                    .copied()
1875                    .filter(|p| *p > 0.0)
1876                {
1877                    Some(p) => build_quantized_numeric(values, p)?,
1878                    None => None,
1879                }
1880                .or_else(|| {
1881                    // No explicit precision — fall back to the configured
1882                    // automatic range-adaptive quantization when enabled.
1883                    cfg.quantize_attrs_auto
1884                        .then(|| build_quantized_numeric_auto(values))
1885                        .flatten()
1886                });
1887                match quantized {
1888                    Some((array, affine_json)) => {
1889                        let mut m = HashMap::new();
1890                        m.insert(STT_QUANT_ATTR_META_KEY.to_string(), affine_json);
1891                        fields.push(Arc::new(
1892                            Field::new(name, array.data_type().clone(), true).with_metadata(m),
1893                        ));
1894                        columns.push(array);
1895                    }
1896                    None => {
1897                        fields.push(Arc::new(Field::new(name, DataType::Float64, true)));
1898                        columns.push(Arc::new(Float64Array::from(values.clone())));
1899                    }
1900                }
1901            }
1902            PropertyColumn::Categorical(values) => {
1903                // Build a Dictionary<UInt16, Utf8>: deduplicate strings once
1904                // here so the TS reader can lift the dictionary table out of
1905                // the Arrow batch directly instead of rebuilding it per tile.
1906                let (indices, categories) = build_dictionary_indices(values)?;
1907                let key_type = DataType::UInt16;
1908                let value_type = DataType::Utf8;
1909                let dict_type = DataType::Dictionary(Box::new(key_type), Box::new(value_type));
1910                fields.push(Arc::new(Field::new(name, dict_type, true)));
1911
1912                let value_array: ArrayRef = Arc::new(StringArray::from(
1913                    categories.iter().map(|s| Some(s.as_str())).collect::<Vec<_>>(),
1914                ));
1915                let key_array = UInt16Array::from(indices);
1916                let dict = DictionaryArray::<UInt16Type>::try_new(key_array, value_array)
1917                    .map_err(|e| Error::Other(format!("dictionary build failed: {e}")))?;
1918                columns.push(Arc::new(dict));
1919            }
1920            PropertyColumn::Vector { width, elem, values } => {
1921                // Interleaved GPU-ready vector → FixedSizeList<leaf, width>. The
1922                // child buffer is the flattened row-major run, so the TS decoder
1923                // hands `child.values.subarray(...)` straight to deck.gl with no
1924                // per-point re-interleave. Non-null leaf (producer encodes a
1925                // missing feature as a zero/identity vector).
1926                let (child, child_dt): (ArrayRef, DataType) = match elem {
1927                    VectorElem::F32 => (
1928                        Arc::new(Float32Array::from(values.clone())),
1929                        DataType::Float32,
1930                    ),
1931                    VectorElem::U8 => {
1932                        let bytes: Vec<u8> = values
1933                            .iter()
1934                            .map(|v| v.round().clamp(0.0, 255.0) as u8)
1935                            .collect();
1936                        (Arc::new(UInt8Array::from(bytes)), DataType::UInt8)
1937                    }
1938                };
1939                let item_field = Arc::new(Field::new("item", child_dt, false));
1940                let width_i32 = i32::try_from(*width).map_err(|_| {
1941                    Error::Other(format!(
1942                        "vector property '{name}' has width {width}, which exceeds the \
1943                         FixedSizeList i32 size limit"
1944                    ))
1945                })?;
1946                let fsl = FixedSizeListArray::new(item_field, width_i32, child, None);
1947                fields.push(Arc::new(Field::new(
1948                    name,
1949                    fsl.data_type().clone(),
1950                    true,
1951                )));
1952                columns.push(Arc::new(fsl));
1953            }
1954        }
1955    }
1956
1957    Ok(LayerParts {
1958        fields,
1959        columns,
1960        reserved_len,
1961        layer_name: layer.name.clone(),
1962        geometry_name: layer.geometry.geoarrow_name(),
1963        // The layer's minimum feature start-time (integer Unix ms), so the TS
1964        // decoder can skip its client-side min-scan over the whole start-time
1965        // column and relativize times against this value directly. Mirrors
1966        // exactly what the decoder computes (the min of the `start_time`
1967        // column); only emitted when a start-time column is present. See
1968        // packages/core/src/tile.ts.
1969        min_start_time: layer.start_times.iter().copied().min(),
1970        vertex_time_encoding,
1971        vertex_value_buckets,
1972        has_triangles,
1973    })
1974}
1975
1976/// Serialize one `(schema, columns)` batch as an Arrow IPC stream.
1977fn write_ipc_stream(schema: Arc<Schema>, columns: Vec<ArrayRef>) -> Result<Vec<u8>> {
1978    let batch = RecordBatch::try_new(schema.clone(), columns)
1979        .map_err(|e| Error::Other(format!("failed to build tile RecordBatch: {e}")))?;
1980    let mut buf = Vec::new();
1981    {
1982        let mut writer = StreamWriter::try_new(&mut buf, &schema)
1983            .map_err(|e| Error::Other(format!("Arrow IPC writer init failed: {e}")))?;
1984        writer
1985            .write(&batch)
1986            .map_err(|e| Error::Other(format!("Arrow IPC write failed: {e}")))?;
1987        writer
1988            .finish()
1989            .map_err(|e| Error::Other(format!("Arrow IPC finish failed: {e}")))?;
1990    }
1991    Ok(buf)
1992}
1993
1994/// v1 assembly: one schema carrying EVERY metadata key (dataset-constant AND
1995/// per-tile-varying), serialized as a single IPC stream — the frozen 0.3.x
1996/// bytes, guarded by the `tests/v1_golden.rs` fixture.
1997fn assemble_layer_ipc_v1(parts: LayerParts) -> Result<Vec<u8>> {
1998    // Schema-level metadata records the layer name and geometry kind so a
1999    // reader does not have to inspect the geometry column. When the
2000    // vertex_time column is u16-delta encoded we add `origin_ms` and
2001    // `step_ms` so the reader can reconstruct absolute timestamps as
2002    // `origin + delta * step`.
2003    //
2004    // Built in a BTreeMap so the key set is assembled in deterministic
2005    // (lexicographic) order — this encoder contributes no ordering
2006    // non-determinism. Arrow ≥59 serializes IPC schema metadata in sorted order,
2007    // so the raw metadata-region bytes of two identical tiles are now identical
2008    // across runs (unblocking content-addressed pack dedup) — closing the old
2009    // arrow-54 HashMap-iteration gap.
2010    let mut schema_meta: BTreeMap<String, String> = BTreeMap::new();
2011    schema_meta.insert("stt:layer".to_string(), parts.layer_name.clone());
2012    schema_meta.insert("stt:geometry".to_string(), parts.geometry_name.to_string());
2013    if let Some(min_start) = parts.min_start_time {
2014        schema_meta.insert(TIME_OFFSET_MS_KEY.to_string(), min_start.to_string());
2015    }
2016    if let Some((origin, step)) = parts.vertex_time_encoding {
2017        schema_meta.insert(VERTEX_TIME_ORIGIN_KEY.to_string(), origin.to_string());
2018        schema_meta.insert(VERTEX_TIME_STEP_KEY.to_string(), step.to_string());
2019    }
2020    if let Some(buckets) = parts.vertex_value_buckets {
2021        schema_meta.insert(VERTEX_VALUE_BUCKETS_KEY.to_string(), buckets.to_string());
2022    }
2023    if parts.has_triangles {
2024        schema_meta.insert(TRIANGLES_METADATA_KEY.to_string(), "true".to_string());
2025    }
2026    let schema = Arc::new(
2027        Schema::new(parts.fields).with_metadata(schema_meta.into_iter().collect()),
2028    );
2029    write_ipc_stream(schema, parts.columns)
2030}
2031
2032// ----------------------------------------------------------------------------
2033// v2 layer encoding (template extraction + TILE_META + core/props split)
2034// ----------------------------------------------------------------------------
2035
2036/// One layer, encoded for the v2 frame: templates split off, tails verbatim,
2037/// per-tile metadata canonicalized into the TILE_META JSON.
2038struct EncodedLayerV2 {
2039    core_template: Vec<u8>,
2040    core_tail: Vec<u8>,
2041    /// `(template, tail)` of the PROPS batch; `None` when the layer has no
2042    /// property columns (`ref_kind_props = 2`).
2043    props: Option<(Vec<u8>, Vec<u8>)>,
2044    tile_meta_json: String,
2045}
2046
2047/// Locate the end of the leading schema message by walking the Arrow IPC
2048/// encapsulated framing: `[0xFFFFFFFF][i32 metadata_len][flatbuffer (padded)]`
2049/// with the schema's `bodyLength == 0` (spike-proven boundary — deterministic,
2050/// no re-serialization; `metadata_len` already includes the flatbuffer's
2051/// padding). Everything before the boundary is the template, everything after
2052/// is the tail (dictionary batches + record batch + EOS).
2053fn split_ipc_at_schema(ipc: &[u8]) -> Result<usize> {
2054    if ipc.len() < 8 || ipc[0..4] != [0xFF, 0xFF, 0xFF, 0xFF] {
2055        return Err(Error::Other(
2056            "layer IPC stream does not start with an encapsulated message".into(),
2057        ));
2058    }
2059    let meta_len = i32::from_le_bytes(ipc[4..8].try_into().expect("4 bytes"));
2060    if meta_len <= 0 {
2061        return Err(Error::Other(
2062            "layer IPC stream starts with an end-of-stream marker".into(),
2063        ));
2064    }
2065    let boundary = 8usize
2066        .checked_add(meta_len as usize)
2067        .filter(|b| *b <= ipc.len())
2068        .ok_or_else(|| Error::Other("layer IPC schema message overruns the stream".into()))?;
2069    let msg = root_as_message(&ipc[8..boundary])
2070        .map_err(|e| Error::Other(format!("layer IPC schema flatbuffer parse failed: {e}")))?;
2071    if msg.header_type() != MessageHeader::Schema {
2072        return Err(Error::Other(format!(
2073            "layer IPC stream must start with a Schema message, got {:?}",
2074            msg.header_type()
2075        )));
2076    }
2077    if msg.bodyLength() != 0 {
2078        return Err(Error::Other(
2079            "layer IPC schema message unexpectedly carries a body".into(),
2080        ));
2081    }
2082    Ok(boundary)
2083}
2084
2085/// v2 row order (design §4.2, ★F10): stable-sort rows by `start_time` at
2086/// ENCODE time — after the tiler assigned feature ids — so ids stay
2087/// order-independent. Returns the layer unchanged (borrowed) when its rows
2088/// are already non-decreasing, which also makes the sort a no-op for
2089/// pre-sorted producers.
2090fn sort_rows_by_start_time(layer: &ColumnarLayer) -> Cow<'_, ColumnarLayer> {
2091    if layer.start_times.windows(2).all(|w| w[0] <= w[1]) {
2092        return Cow::Borrowed(layer);
2093    }
2094    let mut idx: Vec<usize> = (0..layer.feature_count()).collect();
2095    idx.sort_by_key(|&i| layer.start_times[i]); // stable
2096
2097    // Per-feature Option<Vec<Vec<_>>> columns tolerate inner vecs shorter
2098    // than the feature count at encode (`vt.get(i)` ⇒ null list); the
2099    // permutation preserves that semantic via `.get(..).unwrap_or_default()`.
2100    fn permute_nested<T: Clone>(v: &Option<Vec<Vec<T>>>, idx: &[usize]) -> Option<Vec<Vec<T>>> {
2101        v.as_ref()
2102            .map(|v| idx.iter().map(|&i| v.get(i).cloned().unwrap_or_default()).collect())
2103    }
2104
2105    let geometry = match &layer.geometry {
2106        GeometryColumn::Point(v) => {
2107            GeometryColumn::Point(idx.iter().map(|&i| v[i]).collect())
2108        }
2109        GeometryColumn::LineString(v) => {
2110            GeometryColumn::LineString(idx.iter().map(|&i| v[i].clone()).collect())
2111        }
2112        GeometryColumn::Polygon(v) => {
2113            GeometryColumn::Polygon(idx.iter().map(|&i| v[i].clone()).collect())
2114        }
2115    };
2116    let properties = layer
2117        .properties
2118        .iter()
2119        .map(|(name, col)| {
2120            let col = match col {
2121                PropertyColumn::Numeric(v) => {
2122                    PropertyColumn::Numeric(idx.iter().map(|&i| v[i]).collect())
2123                }
2124                PropertyColumn::Categorical(v) => {
2125                    PropertyColumn::Categorical(idx.iter().map(|&i| v[i].clone()).collect())
2126                }
2127                PropertyColumn::Vector { width, elem, values } => PropertyColumn::Vector {
2128                    width: *width,
2129                    elem: *elem,
2130                    values: idx
2131                        .iter()
2132                        .flat_map(|&i| values[i * width..(i + 1) * width].iter().copied())
2133                        .collect(),
2134                },
2135            };
2136            (name.clone(), col)
2137        })
2138        .collect();
2139
2140    Cow::Owned(ColumnarLayer {
2141        name: layer.name.clone(),
2142        feature_ids: idx.iter().map(|&i| layer.feature_ids[i]).collect(),
2143        start_times: idx.iter().map(|&i| layer.start_times[i]).collect(),
2144        end_times: idx.iter().map(|&i| layer.end_times[i]).collect(),
2145        geometry,
2146        vertex_times: permute_nested(&layer.vertex_times, &idx),
2147        vertex_values: permute_nested(&layer.vertex_values, &idx),
2148        vertex_value_matrix: permute_nested(&layer.vertex_value_matrix, &idx),
2149        triangles: permute_nested(&layer.triangles, &idx),
2150        properties,
2151    })
2152}
2153
2154/// Encode one layer for the v2 frame: sort rows, build the shared parts, move
2155/// per-tile-varying metadata into TILE_META, split the CORE and PROPS IPC
2156/// streams at their schema boundaries.
2157///
2158/// Metadata placement (design §3.1 audit): per-tile-varying and hoisted into
2159/// TILE_META are exactly `stt:qa` (property fields) and the schema-level
2160/// `stt:time_offset_ms` / `stt:vertex_time_origin_ms` / `stt:vertex_time_step_ms`
2161/// / `stt:vertex_value_buckets`. Dataset-constant and template-resident:
2162/// `ARROW:extension:name` / `ARROW:extension:metadata` (CRS), `stt:quant`
2163/// (world-anchored), `stt:layer`, `stt:geometry`, `stt:has_triangles`.
2164fn encode_layer_v2_parts(layer: &ColumnarLayer, cfg: &EncoderConfig) -> Result<EncodedLayerV2> {
2165    // Validate BEFORE the row sort: `sort_rows_by_start_time` indexes every
2166    // column by the feature count, so a length-inconsistent layer would panic
2167    // there instead of returning the v1 path's descriptive error.
2168    // (`build_layer_parts` re-validates the sorted layer; the check is pure.)
2169    layer.validate()?;
2170    let sorted = sort_rows_by_start_time(layer);
2171    let parts = build_layer_parts(&sorted, cfg)?;
2172
2173    // Strip `stt:qa` off the property fields into the TILE_META `qa` map —
2174    // spike-proven to leave the batch tail bytes byte-identical (only the
2175    // schema message changes). The affine JSON round-trips exactly
2176    // (`AttrQuant::to_json` is a pure function of `(o, s)`), so decode
2177    // re-injects byte-identical field metadata.
2178    let mut qa: BTreeMap<String, (f64, f64)> = BTreeMap::new();
2179    let mut props_fields: Vec<Arc<Field>> = Vec::with_capacity(
2180        parts.fields.len() - parts.reserved_len,
2181    );
2182    for field in &parts.fields[parts.reserved_len..] {
2183        let mut meta = field.metadata().clone();
2184        if let Some(json) = meta.remove(STT_QUANT_ATTR_META_KEY) {
2185            let affine = AttrQuant::from_json(&json).ok_or_else(|| {
2186                Error::Other(format!(
2187                    "property '{}' carries an unparseable {STT_QUANT_ATTR_META_KEY} affine",
2188                    field.name()
2189                ))
2190            })?;
2191            qa.insert(field.name().clone(), (affine.o, affine.s));
2192            props_fields.push(Arc::new(field.as_ref().clone().with_metadata(meta)));
2193        } else {
2194            props_fields.push(field.clone());
2195        }
2196    }
2197
2198    let tile_meta = TileMeta {
2199        qa: (!qa.is_empty()).then_some(qa),
2200        sorted: Some(true),
2201        t0: parts.min_start_time,
2202        vb: parts.vertex_value_buckets,
2203        vt: parts.vertex_time_encoding,
2204    };
2205    let tile_meta_json = serde_json::to_string(&tile_meta)
2206        .map_err(|e| Error::Other(format!("TILE_META encode failed: {e}")))?;
2207
2208    // CORE schema: dataset-constant metadata ONLY (the per-tile keys live in
2209    // TILE_META), so the template bytes are identical across every tile of
2210    // the layer's shape — the whole point of the hoist.
2211    let mut core_meta: BTreeMap<String, String> = BTreeMap::new();
2212    core_meta.insert("stt:layer".to_string(), parts.layer_name.clone());
2213    core_meta.insert("stt:geometry".to_string(), parts.geometry_name.to_string());
2214    if parts.has_triangles {
2215        core_meta.insert(TRIANGLES_METADATA_KEY.to_string(), "true".to_string());
2216    }
2217    let core_fields: Vec<Arc<Field>> = parts.fields[..parts.reserved_len].to_vec();
2218    let core_columns: Vec<ArrayRef> = parts.columns[..parts.reserved_len].to_vec();
2219    let core_schema = Arc::new(
2220        Schema::new(core_fields).with_metadata(core_meta.into_iter().collect()),
2221    );
2222    let core_ipc = write_ipc_stream(core_schema, core_columns)?;
2223    let core_boundary = split_ipc_at_schema(&core_ipc)?;
2224    let core_tail = core_ipc[core_boundary..].to_vec();
2225    let mut core_template = core_ipc;
2226    core_template.truncate(core_boundary);
2227
2228    // PROPS schema: property fields only, no schema-level metadata (every
2229    // dataset-constant key lives on the CORE template).
2230    let props = if props_fields.is_empty() {
2231        None
2232    } else {
2233        let props_columns: Vec<ArrayRef> = parts.columns[parts.reserved_len..].to_vec();
2234        let props_schema = Arc::new(Schema::new(props_fields));
2235        let props_ipc = write_ipc_stream(props_schema, props_columns)?;
2236        let boundary = split_ipc_at_schema(&props_ipc)?;
2237        let tail = props_ipc[boundary..].to_vec();
2238        let mut template = props_ipc;
2239        template.truncate(boundary);
2240        Some((template, tail))
2241    };
2242
2243    Ok(EncodedLayerV2 {
2244        core_template,
2245        core_tail,
2246        props,
2247        tile_meta_json,
2248    })
2249}
2250
2251/// Encode a full tile payload (one or more layers) with the layer frame.
2252///
2253/// Always emits the *aligned* frame ([`ALIGNED_FRAME_FLAG`] set): each
2254/// layer's IPC stream is preceded by zero padding to an 8-byte boundary
2255/// relative to the payload start, so readers can wrap the stream zero-copy.
2256/// `ipc_len` records the exact IPC byte length (padding excluded); readers
2257/// derive the pad from alignment math alone.
2258pub fn encode_tile(layers: &[ColumnarLayer]) -> Result<Vec<u8>> {
2259    encode_tile_cfg(layers, &EncoderConfig::from_globals())
2260}
2261
2262/// [`encode_tile`] with optional fixed-point coordinate quantization applied to
2263/// every layer (see [`encode_layer_quantized`]). `quantize_m = None` is
2264/// byte-identical to [`encode_tile`]; the other encoder settings come from the
2265/// process-wide globals.
2266pub fn encode_tile_quantized(layers: &[ColumnarLayer], quantize_m: Option<f64>) -> Result<Vec<u8>> {
2267    encode_tile_cfg(
2268        layers,
2269        &EncoderConfig {
2270            quantize_coords_m: quantize_m,
2271            ..EncoderConfig::from_globals()
2272        },
2273    )
2274}
2275
2276/// [`encode_tile`] with a fully-explicit [`EncoderConfig`] — no process-wide
2277/// globals are read. The concurrency- and multi-config-safe entry point a
2278/// dynamic per-request tile server uses so each dataset/request encodes with its
2279/// own settings without touching shared state.
2280pub fn encode_tile_with(layers: &[ColumnarLayer], cfg: &EncoderConfig) -> Result<Vec<u8>> {
2281    encode_tile_cfg(layers, cfg)
2282}
2283
2284/// The single tile-encode implementation, driven entirely by an explicit
2285/// [`EncoderConfig`]. Every public `encode_tile*` entry point funnels here,
2286/// then branches ONCE on [`EncoderConfig::format_version`] (design §1 ★F3):
2287/// everything upstream (column/field build) is shared.
2288fn encode_tile_cfg(layers: &[ColumnarLayer], cfg: &EncoderConfig) -> Result<Vec<u8>> {
2289    match cfg.format_version {
2290        FORMAT_VERSION_V1 => encode_tile_frame_v1(layers, cfg),
2291        FORMAT_VERSION_V2 => encode_tile_frame_v2(layers, cfg),
2292        other => Err(Error::Other(format!(
2293            "unsupported format version {other} (this writer emits 1 or 2)"
2294        ))),
2295    }
2296}
2297
2298/// v1 frame layer-count guard: the aligned frame's leading u16 spends bit 15
2299/// on [`ALIGNED_FRAME_FLAG`], leaving 15 bits for the count — so 0x7FFE is the
2300/// maximum representable count. 0x7FFF is additionally reserved (its OR with
2301/// the flag collides with [`FRAME_V2_ESCAPE`]), and any count with bit 15 set
2302/// (0x8000..) would OR into the flag as a silently-mangled smaller count.
2303/// `>= 0x7FFF` rejects the collision AND the whole unrepresentable range.
2304fn v1_layer_count_ok(count: usize) -> bool {
2305    count < 0x7FFF
2306}
2307
2308/// v1 frame assembly — the frozen 0.3.x bytes (`tests/v1_golden.rs`).
2309fn encode_tile_frame_v1(layers: &[ColumnarLayer], cfg: &EncoderConfig) -> Result<Vec<u8>> {
2310    // Cap the count one below the historical 0x7fff limit (see
2311    // [`v1_layer_count_ok`]). No real tile comes within orders of magnitude
2312    // of the limit, so only the error bound moves.
2313    if !v1_layer_count_ok(layers.len()) {
2314        return Err(Error::Other(format!(
2315            "tile has {} layers, exceeds the {} frame limit",
2316            layers.len(),
2317            ALIGNED_FRAME_FLAG - 2
2318        )));
2319    }
2320    let mut out = Vec::new();
2321    out.extend_from_slice(&(layers.len() as u16 | ALIGNED_FRAME_FLAG).to_le_bytes());
2322    for layer in layers {
2323        let name = layer.name.as_bytes();
2324        if name.len() > u16::MAX as usize {
2325            return Err(Error::Other("layer name too long".into()));
2326        }
2327        let ipc = encode_layer_cfg(layer, cfg)?;
2328        let ipc_len = u32::try_from(ipc.len()).map_err(|_| {
2329            Error::Other(format!(
2330                "layer '{}' IPC stream is {} bytes, exceeding the frame's u32 length field",
2331                layer.name,
2332                ipc.len()
2333            ))
2334        })?;
2335        out.extend_from_slice(&(name.len() as u16).to_le_bytes());
2336        out.extend_from_slice(name);
2337        out.extend_from_slice(&ipc_len.to_le_bytes());
2338        let pad = (FRAME_ALIGN - out.len() % FRAME_ALIGN) % FRAME_ALIGN;
2339        out.extend_from_slice(&[0u8; FRAME_ALIGN][..pad]);
2340        out.extend_from_slice(&ipc);
2341    }
2342    Ok(out)
2343}
2344
2345/// Zero-pad `out` to the next [`FRAME_ALIGN`] boundary (v2 derived padding —
2346/// like v1, the pad length is never stored).
2347fn pad_to_frame_align(out: &mut Vec<u8>) {
2348    let pad = (FRAME_ALIGN - out.len() % FRAME_ALIGN) % FRAME_ALIGN;
2349    out.extend_from_slice(&[0u8; FRAME_ALIGN][..pad]);
2350}
2351
2352/// v2 frame assembly (module docs / design §4). With a
2353/// [`TemplateCollector`] configured, schemas are recorded there and frames
2354/// carry 16-byte hash references (the packed-dataset mode); without one the
2355/// frame is self-contained via `INLINE_SCHEMA_*` sections.
2356fn encode_tile_frame_v2(layers: &[ColumnarLayer], cfg: &EncoderConfig) -> Result<Vec<u8>> {
2357    if layers.len() > u16::MAX as usize {
2358        return Err(Error::Other(format!(
2359            "tile has {} layers, exceeds the {} frame limit",
2360            layers.len(),
2361            u16::MAX
2362        )));
2363    }
2364    let collector = cfg.template_collector.as_deref();
2365    let mut out = Vec::new();
2366    out.extend_from_slice(&FRAME_V2_ESCAPE.to_le_bytes());
2367    out.push(FRAME_V2_VERSION);
2368    out.push(0); // flags — reserved, MUST be 0
2369    out.extend_from_slice(&(layers.len() as u16).to_le_bytes());
2370    for layer in layers {
2371        let name = layer.name.as_bytes();
2372        if name.len() > u16::MAX as usize {
2373            return Err(Error::Other("layer name too long".into()));
2374        }
2375        let enc = encode_layer_v2_parts(layer, cfg)?;
2376        out.extend_from_slice(&(name.len() as u16).to_le_bytes());
2377        out.extend_from_slice(name);
2378
2379        // Schema references. Hash mode records the template with the
2380        // collector (content-addressed, so parallel encode order is
2381        // irrelevant); inline mode ships it as a section instead.
2382        match collector {
2383            Some(c) => {
2384                out.push(REF_KIND_TEMPLATE_HASH);
2385                out.extend_from_slice(&c.record(&enc.core_template));
2386            }
2387            None => out.push(REF_KIND_INLINE),
2388        }
2389        match (&enc.props, collector) {
2390            (None, _) => out.push(REF_KIND_NO_PROPS),
2391            (Some((template, _)), Some(c)) => {
2392                out.push(REF_KIND_TEMPLATE_HASH);
2393                out.extend_from_slice(&c.record(template));
2394            }
2395            (Some(_), None) => out.push(REF_KIND_INLINE),
2396        }
2397
2398        // Sections in ascending tag order; TOC lengths are the exact at-rest
2399        // byte counts (padding derived, never stored).
2400        let mut sections: Vec<(u8, &[u8])> = Vec::new();
2401        if collector.is_none() {
2402            sections.push((SECTION_INLINE_SCHEMA_CORE, &enc.core_template));
2403        }
2404        sections.push((SECTION_TILE_META, enc.tile_meta_json.as_bytes()));
2405        sections.push((SECTION_CORE_BATCH, &enc.core_tail));
2406        if let Some((template, tail)) = &enc.props {
2407            if collector.is_none() {
2408                sections.push((SECTION_INLINE_SCHEMA_PROPS, template));
2409            }
2410            sections.push((SECTION_PROPS_BATCH, tail));
2411        }
2412        out.push(sections.len() as u8);
2413        for (tag, bytes) in &sections {
2414            out.push(*tag);
2415            let len = u32::try_from(bytes.len()).map_err(|_| {
2416                Error::Other(format!(
2417                    "layer '{}' section 0x{tag:02x} is {} bytes, exceeding the TOC's u32 \
2418                     length field",
2419                    layer.name,
2420                    bytes.len()
2421                ))
2422            })?;
2423            out.extend_from_slice(&len.to_le_bytes());
2424        }
2425        pad_to_frame_align(&mut out);
2426        for (_, bytes) in &sections {
2427            out.extend_from_slice(bytes);
2428            pad_to_frame_align(&mut out);
2429        }
2430    }
2431    Ok(out)
2432}
2433
2434// ----------------------------------------------------------------------------
2435// Decoding
2436// ----------------------------------------------------------------------------
2437
2438/// A decoded tile layer: its name and the raw Arrow [`RecordBatch`].
2439#[derive(Debug, Clone)]
2440pub struct DecodedLayer {
2441    /// Layer name from the layer frame.
2442    pub name: String,
2443    /// The decoded Arrow record batch.
2444    pub batch: RecordBatch,
2445}
2446
2447/// Decode a single-layer Arrow IPC stream into a [`RecordBatch`].
2448pub fn decode_layer(ipc: &[u8]) -> Result<RecordBatch> {
2449    let reader = StreamReader::try_new(ipc, None)
2450        .map_err(|e| Error::Other(format!("Arrow IPC reader init failed: {e}")))?;
2451    let mut batches: Vec<RecordBatch> = Vec::new();
2452    for batch in reader {
2453        batches.push(batch.map_err(|e| Error::Other(format!("Arrow IPC read failed: {e}")))?);
2454    }
2455    match batches.len() {
2456        0 => Err(Error::Other("tile layer IPC contained no record batch".into())),
2457        1 => Ok(batches.into_iter().next().unwrap()),
2458        // A layer is written as exactly one batch; concatenating is the safe
2459        // fallback if a producer ever splits it.
2460        _ => arrow::compute::concat_batches(&batches[0].schema(), &batches)
2461            .map_err(|e| Error::Other(format!("failed to concat tile batches: {e}"))),
2462    }
2463}
2464
2465/// Whether a tile payload carries the v2 sectioned frame (leading u16 is the
2466/// [`FRAME_V2_ESCAPE`]). The manifest's `formatVersion` remains authoritative
2467/// (★F6) — this sniff is defense-in-depth for the payload level.
2468pub fn is_frame_v2(payload: &[u8]) -> bool {
2469    payload.len() >= 2 && u16::from_le_bytes([payload[0], payload[1]]) == FRAME_V2_ESCAPE
2470}
2471
2472/// Decode a full tile payload (the layer frame) into its layers.
2473///
2474/// Accepts the v1 frame in both shapes — aligned ([`ALIGNED_FRAME_FLAG`] set,
2475/// derived padding before each IPC stream) and the legacy unpadded frame
2476/// written before the flag existed — plus **self-contained** v2 frames
2477/// (inline schema sections). A v2 frame that references templates by hash
2478/// needs the dataset's registry: use [`decode_tile_with_templates`] (a hash
2479/// reference here is a descriptive error, not a panic).
2480pub fn decode_tile(payload: &[u8]) -> Result<Vec<DecodedLayer>> {
2481    if is_frame_v2(payload) {
2482        return decode_tile_v2(payload, None);
2483    }
2484    decode_tile_v1(payload)
2485}
2486
2487/// [`decode_tile`] with the dataset's [`TemplateRegistry`], so v2 frames can
2488/// resolve their 16-byte template-hash references. v1 frames decode
2489/// unchanged (the registry is simply unused).
2490pub fn decode_tile_with_templates(
2491    payload: &[u8],
2492    templates: &TemplateRegistry,
2493) -> Result<Vec<DecodedLayer>> {
2494    if is_frame_v2(payload) {
2495        return decode_tile_v2(payload, Some(templates));
2496    }
2497    decode_tile_v1(payload)
2498}
2499
2500/// The v1 frame walk — UNCHANGED from the 0.3.x reader.
2501fn decode_tile_v1(payload: &[u8]) -> Result<Vec<DecodedLayer>> {
2502    if payload.len() < 2 {
2503        return Err(Error::Other("tile payload too short for layer frame".into()));
2504    }
2505    let raw_count = u16::from_le_bytes([payload[0], payload[1]]);
2506    let aligned = raw_count & ALIGNED_FRAME_FLAG != 0;
2507    let count = (raw_count & !ALIGNED_FRAME_FLAG) as usize;
2508    let mut pos = 2usize;
2509    let mut layers = Vec::with_capacity(count);
2510    for _ in 0..count {
2511        let name_len = read_u16(payload, &mut pos)? as usize;
2512        let name = read_slice(payload, &mut pos, name_len)?;
2513        let name = String::from_utf8(name.to_vec())
2514            .map_err(|e| Error::Other(format!("layer name not utf8: {e}")))?;
2515        let ipc_len = read_u32(payload, &mut pos)? as usize;
2516        if aligned {
2517            let pad = (FRAME_ALIGN - pos % FRAME_ALIGN) % FRAME_ALIGN;
2518            read_slice(payload, &mut pos, pad)?;
2519        }
2520        let ipc = read_slice(payload, &mut pos, ipc_len)?;
2521        let batch = decode_layer(ipc)?;
2522        layers.push(DecodedLayer { name, batch });
2523    }
2524    Ok(layers)
2525}
2526
2527/// Splice a template onto a section tail and decode the resulting stream.
2528///
2529/// Normative guards (design §3.4): the tail is EXACTLY the TOC-declared bytes
2530/// (the caller sliced it that way) and MUST begin with the `0xFFFFFFFF`
2531/// continuation marker — stray zero bytes make arrow-rs silently return an
2532/// EMPTY tile (they parse as a legacy 4-byte end-of-stream), so a malformed
2533/// section must error loudly instead. The template gets the same check
2534/// (a corrupt manifest entry hashes consistently but still must not splice).
2535fn splice_decode(template: &[u8], tail: &[u8], what: &str) -> Result<RecordBatch> {
2536    if template.len() < 4 || template[0..4] != [0xFF, 0xFF, 0xFF, 0xFF] {
2537        return Err(Error::Other(format!(
2538            "{what}: schema template does not start with an encapsulated Arrow message"
2539        )));
2540    }
2541    if tail.len() < 4 || tail[0..4] != [0xFF, 0xFF, 0xFF, 0xFF] {
2542        return Err(Error::Other(format!(
2543            "{what}: batch section does not start with the 0xFFFFFFFF continuation marker \
2544             (corrupt or misaligned section)"
2545        )));
2546    }
2547    let mut buf = Vec::with_capacity(template.len() + tail.len());
2548    buf.extend_from_slice(template);
2549    buf.extend_from_slice(tail);
2550    decode_layer(&buf)
2551}
2552
2553/// Resolve a v2 layer's schema template: inline section or registry lookup.
2554fn resolve_template<'a>(
2555    ref_kind: u8,
2556    hash: Option<[u8; 16]>,
2557    inline: Option<&'a [u8]>,
2558    registry: Option<&'a TemplateRegistry>,
2559    what: &str,
2560) -> Result<&'a [u8]> {
2561    match ref_kind {
2562        REF_KIND_INLINE => inline.ok_or_else(|| {
2563            Error::Other(format!("{what}: inline schema section missing from the frame"))
2564        }),
2565        REF_KIND_TEMPLATE_HASH => {
2566            let hash = hash.expect("hash read for ref_kind 1");
2567            let registry = registry.ok_or_else(|| {
2568                Error::Other(format!(
2569                    "{what}: frame references schema template {} but no template registry \
2570                     was provided — open the dataset through its manifest (or use \
2571                     decode_tile_with_templates)",
2572                    hex_16(&hash)
2573                ))
2574            })?;
2575            registry.get(&hash).ok_or_else(|| {
2576                Error::Other(format!(
2577                    "{what}: schema template {} is not in the dataset's registry \
2578                     (manifest.schemas is incomplete or the frame is corrupt)",
2579                    hex_16(&hash)
2580                ))
2581            })
2582        }
2583        other => Err(Error::Other(format!(
2584            "{what}: unknown schema ref_kind {other} (this reader knows 0..=2)"
2585        ))),
2586    }
2587}
2588
2589fn hex_16(hash: &[u8; 16]) -> String {
2590    hash.iter().map(|b| format!("{b:02x}")).collect()
2591}
2592
2593/// Merge a v2 layer's decoded CORE + PROPS batches back into the v1-shaped
2594/// single batch, RE-INJECTING the TILE_META values into schema/field metadata
2595/// with the exact v1 formatting — so every downstream consumer (validator,
2596/// stt-optimize, tests, renderers) sees v1-equivalent decoded layers with
2597/// zero changes.
2598fn merge_v2_layer(
2599    core: RecordBatch,
2600    props: Option<RecordBatch>,
2601    meta: &TileMeta,
2602) -> Result<RecordBatch> {
2603    let mut fields: Vec<Arc<Field>> = core.schema().fields().iter().cloned().collect();
2604    let mut columns: Vec<ArrayRef> = core.columns().to_vec();
2605    let mut schema_meta: HashMap<String, String> = core.schema().metadata().clone();
2606
2607    if let Some(props) = props {
2608        if props.num_rows() != core.num_rows() {
2609            return Err(Error::Other(format!(
2610                "tile layer CORE/PROPS row counts disagree: {} vs {}",
2611                core.num_rows(),
2612                props.num_rows()
2613            )));
2614        }
2615        for (field, column) in props.schema().fields().iter().zip(props.columns()) {
2616            // Re-inject the hoisted attribute-quantization affine
2617            // (byte-identical to the v1 field metadata: `to_json` is a pure
2618            // function of the `[o, s]` pair TILE_META carries).
2619            let field = match meta.qa.as_ref().and_then(|qa| qa.get(field.name())) {
2620                Some(&(o, s)) => {
2621                    let mut m = field.metadata().clone();
2622                    m.insert(
2623                        STT_QUANT_ATTR_META_KEY.to_string(),
2624                        AttrQuant { o, s }.to_json(),
2625                    );
2626                    Arc::new(field.as_ref().clone().with_metadata(m))
2627                }
2628                None => field.clone(),
2629            };
2630            fields.push(field);
2631            columns.push(column.clone());
2632        }
2633    }
2634
2635    // Schema-level re-injection, mirroring the v1 assembler's formatting.
2636    if let Some(t0) = meta.t0 {
2637        schema_meta.insert(TIME_OFFSET_MS_KEY.to_string(), t0.to_string());
2638    }
2639    if let Some((origin, step)) = meta.vt {
2640        schema_meta.insert(VERTEX_TIME_ORIGIN_KEY.to_string(), origin.to_string());
2641        schema_meta.insert(VERTEX_TIME_STEP_KEY.to_string(), step.to_string());
2642    }
2643    if let Some(buckets) = meta.vb {
2644        schema_meta.insert(VERTEX_VALUE_BUCKETS_KEY.to_string(), buckets.to_string());
2645    }
2646
2647    let schema = Arc::new(Schema::new_with_metadata(fields, schema_meta));
2648    RecordBatch::try_new(schema, columns)
2649        .map_err(|e| Error::Other(format!("failed to merge v2 CORE/PROPS batches: {e}")))
2650}
2651
2652/// The v2 frame walk. Bounds-checked byte reads throughout (`read_slice`), so
2653/// arbitrary/truncated input errors instead of panicking; unknown section
2654/// tags are skipped via their TOC length (additive evolution).
2655fn decode_tile_v2(
2656    payload: &[u8],
2657    registry: Option<&TemplateRegistry>,
2658) -> Result<Vec<DecodedLayer>> {
2659    let mut pos = 0usize;
2660    let escape = read_u16(payload, &mut pos)?;
2661    debug_assert_eq!(escape, FRAME_V2_ESCAPE, "caller dispatched on the escape");
2662    let frame_version = read_slice(payload, &mut pos, 1)?[0];
2663    if frame_version != FRAME_V2_VERSION {
2664        return Err(Error::Other(format!(
2665            "unsupported layer-frame version {frame_version} (this reader knows v2)"
2666        )));
2667    }
2668    let flags = read_slice(payload, &mut pos, 1)?[0];
2669    if flags != 0 {
2670        return Err(Error::Other(format!(
2671            "reserved v2 layer-frame flags must be 0, got {flags:#04x}"
2672        )));
2673    }
2674    let count = read_u16(payload, &mut pos)? as usize;
2675    let mut layers = Vec::with_capacity(count.min(64));
2676    for _ in 0..count {
2677        let name_len = read_u16(payload, &mut pos)? as usize;
2678        let name = read_slice(payload, &mut pos, name_len)?;
2679        let name = String::from_utf8(name.to_vec())
2680            .map_err(|e| Error::Other(format!("layer name not utf8: {e}")))?;
2681
2682        let mut read_ref = |what: &str| -> Result<(u8, Option<[u8; 16]>)> {
2683            let kind = read_slice(payload, &mut pos, 1)?[0];
2684            let hash = if kind == REF_KIND_TEMPLATE_HASH {
2685                let mut h = [0u8; 16];
2686                h.copy_from_slice(read_slice(payload, &mut pos, 16)?);
2687                Some(h)
2688            } else if kind == REF_KIND_INLINE || kind == REF_KIND_NO_PROPS {
2689                None
2690            } else {
2691                return Err(Error::Other(format!(
2692                    "layer '{name}' {what}: unknown schema ref_kind {kind} \
2693                     (this reader knows 0..=2)"
2694                )));
2695            };
2696            Ok((kind, hash))
2697        };
2698        let (ref_core, core_hash) = read_ref("core")?;
2699        if ref_core == REF_KIND_NO_PROPS {
2700            return Err(Error::Other(format!(
2701                "layer '{name}': ref_kind_core 2 is invalid (every layer has a CORE batch)"
2702            )));
2703        }
2704        let (ref_props, props_hash) = read_ref("props")?;
2705
2706        let section_count = read_slice(payload, &mut pos, 1)?[0] as usize;
2707        let mut toc: Vec<(u8, usize)> = Vec::with_capacity(section_count);
2708        for _ in 0..section_count {
2709            let tag = read_slice(payload, &mut pos, 1)?[0];
2710            let len = read_u32(payload, &mut pos)? as usize;
2711            toc.push((tag, len));
2712        }
2713        let pad = (FRAME_ALIGN - pos % FRAME_ALIGN) % FRAME_ALIGN;
2714        read_slice(payload, &mut pos, pad)?;
2715
2716        let mut sections: BTreeMap<u8, &[u8]> = BTreeMap::new();
2717        for (tag, len) in toc {
2718            let bytes = read_slice(payload, &mut pos, len)?;
2719            let pad = (FRAME_ALIGN - pos % FRAME_ALIGN) % FRAME_ALIGN;
2720            read_slice(payload, &mut pos, pad)?;
2721            if sections.insert(tag, bytes).is_some() {
2722                return Err(Error::Other(format!(
2723                    "layer '{name}': duplicate section tag 0x{tag:02x} in the TOC"
2724                )));
2725            }
2726        }
2727
2728        // TILE_META: canonical JSON, unknown keys ignored (additive contract).
2729        let tile_meta: TileMeta = match sections.get(&SECTION_TILE_META) {
2730            Some(bytes) => serde_json::from_slice(bytes).map_err(|e| {
2731                Error::Other(format!("layer '{name}': TILE_META JSON decode failed: {e}"))
2732            })?,
2733            None => TileMeta::default(),
2734        };
2735
2736        let core_template = resolve_template(
2737            ref_core,
2738            core_hash,
2739            sections.get(&SECTION_INLINE_SCHEMA_CORE).copied(),
2740            registry,
2741            &format!("layer '{name}' core"),
2742        )?;
2743        let core_tail = sections.get(&SECTION_CORE_BATCH).ok_or_else(|| {
2744            Error::Other(format!("layer '{name}': CORE_BATCH section missing"))
2745        })?;
2746        let core = splice_decode(core_template, core_tail, &format!("layer '{name}' core"))?;
2747
2748        let props = if ref_props == REF_KIND_NO_PROPS {
2749            if sections.contains_key(&SECTION_PROPS_BATCH) {
2750                return Err(Error::Other(format!(
2751                    "layer '{name}': PROPS_BATCH section present but ref_kind_props \
2752                     declares no props"
2753                )));
2754            }
2755            None
2756        } else {
2757            let template = resolve_template(
2758                ref_props,
2759                props_hash,
2760                sections.get(&SECTION_INLINE_SCHEMA_PROPS).copied(),
2761                registry,
2762                &format!("layer '{name}' props"),
2763            )?;
2764            let tail = sections.get(&SECTION_PROPS_BATCH).ok_or_else(|| {
2765                Error::Other(format!("layer '{name}': PROPS_BATCH section missing"))
2766            })?;
2767            Some(splice_decode(template, tail, &format!("layer '{name}' props"))?)
2768        };
2769
2770        let batch = merge_v2_layer(core, props, &tile_meta)?;
2771        layers.push(DecodedLayer { name, batch });
2772    }
2773    Ok(layers)
2774}
2775
2776/// Walk ONLY a v2 frame's header structure — escape/version/flags/count,
2777/// per-layer name + schema ref_kinds (+ their 16-byte hashes), TOC-driven
2778/// section skips; **no Arrow decode, no section parse** — and return every
2779/// template hash the frame references. The packed-format validators use this
2780/// to prove each referenced hash resolves in `manifest.schemas` without
2781/// decoding tiles. Errors (never panics) on truncated/malformed headers.
2782pub fn frame_v2_template_refs(payload: &[u8]) -> Result<Vec<[u8; 16]>> {
2783    if !is_frame_v2(payload) {
2784        return Err(Error::Other("not a v2 layer frame (missing escape)".into()));
2785    }
2786    let mut pos = 2usize; // past the escape
2787    let frame_version = read_slice(payload, &mut pos, 1)?[0];
2788    if frame_version != FRAME_V2_VERSION {
2789        return Err(Error::Other(format!(
2790            "unsupported layer-frame version {frame_version} (this reader knows v2)"
2791        )));
2792    }
2793    let flags = read_slice(payload, &mut pos, 1)?[0];
2794    if flags != 0 {
2795        return Err(Error::Other(format!(
2796            "reserved v2 layer-frame flags must be 0, got {flags:#04x}"
2797        )));
2798    }
2799    let count = read_u16(payload, &mut pos)? as usize;
2800    let mut refs: Vec<[u8; 16]> = Vec::new();
2801    for _ in 0..count {
2802        let name_len = read_u16(payload, &mut pos)? as usize;
2803        read_slice(payload, &mut pos, name_len)?;
2804        for what in ["core", "props"] {
2805            let kind = read_slice(payload, &mut pos, 1)?[0];
2806            if kind == REF_KIND_TEMPLATE_HASH {
2807                let mut h = [0u8; 16];
2808                h.copy_from_slice(read_slice(payload, &mut pos, 16)?);
2809                refs.push(h);
2810            } else if kind != REF_KIND_INLINE && kind != REF_KIND_NO_PROPS {
2811                return Err(Error::Other(format!(
2812                    "{what}: unknown schema ref_kind {kind} (this reader knows 0..=2)"
2813                )));
2814            }
2815        }
2816        let section_count = read_slice(payload, &mut pos, 1)?[0] as usize;
2817        let mut toc: Vec<usize> = Vec::with_capacity(section_count);
2818        for _ in 0..section_count {
2819            read_slice(payload, &mut pos, 1)?; // tag
2820            toc.push(read_u32(payload, &mut pos)? as usize);
2821        }
2822        let pad = (FRAME_ALIGN - pos % FRAME_ALIGN) % FRAME_ALIGN;
2823        read_slice(payload, &mut pos, pad)?;
2824        for len in toc {
2825            read_slice(payload, &mut pos, len)?;
2826            let pad = (FRAME_ALIGN - pos % FRAME_ALIGN) % FRAME_ALIGN;
2827            read_slice(payload, &mut pos, pad)?;
2828        }
2829    }
2830    Ok(refs)
2831}
2832
2833fn read_u16(buf: &[u8], pos: &mut usize) -> Result<u16> {
2834    let s = read_slice(buf, pos, 2)?;
2835    Ok(u16::from_le_bytes([s[0], s[1]]))
2836}
2837
2838fn read_u32(buf: &[u8], pos: &mut usize) -> Result<u32> {
2839    let s = read_slice(buf, pos, 4)?;
2840    Ok(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
2841}
2842
2843fn read_slice<'a>(buf: &'a [u8], pos: &mut usize, len: usize) -> Result<&'a [u8]> {
2844    let end = pos
2845        .checked_add(len)
2846        .ok_or_else(|| Error::Other("tile frame length overflow".into()))?;
2847    if end > buf.len() {
2848        return Err(Error::Other("tile frame truncated".into()));
2849    }
2850    let s = &buf[*pos..end];
2851    *pos = end;
2852    Ok(s)
2853}
2854
2855#[cfg(test)]
2856mod tests {
2857    use super::*;
2858
2859    fn sample_point_layer() -> ColumnarLayer {
2860        ColumnarLayer {
2861            name: "points".to_string(),
2862            feature_ids: vec![1, 2, 3],
2863            start_times: vec![1000, 2000, 3000],
2864            end_times: vec![1500, 2500, 3500],
2865            geometry: GeometryColumn::Point(vec![
2866                [-122.4, 37.7],
2867                [-122.5, 37.8],
2868                [-122.6, 37.9],
2869            ]),
2870            vertex_times: None,
2871            vertex_values: None,
2872            triangles: None,
2873            vertex_value_matrix: None,
2874            properties: vec![
2875                (
2876                    "speed".to_string(),
2877                    PropertyColumn::Numeric(vec![Some(10.0), None, Some(30.0)]),
2878                ),
2879                (
2880                    "kind".to_string(),
2881                    PropertyColumn::Categorical(vec![
2882                        Some("car".to_string()),
2883                        Some("bus".to_string()),
2884                        None,
2885                    ]),
2886                ),
2887            ],
2888        }
2889    }
2890
2891    /// Two DIFFERENT [`EncoderConfig`]s encode the SAME layer to DIFFERENT tiles
2892    /// in ONE process, and the output is driven purely by the passed config — not
2893    /// by the process-wide globals. This is the property that unblocks a dynamic
2894    /// server hosting several datasets/configs concurrently: if `encode_tile_with`
2895    /// read the (unset) globals instead of the config, the quantized and plain
2896    /// encodes would be identical and this would fail.
2897    #[test]
2898    fn encode_tile_with_is_config_driven_not_global() {
2899        let layer = sample_point_layer();
2900        let layers = std::slice::from_ref(&layer);
2901
2902        let plain_cfg = EncoderConfig::default();
2903        let quant_cfg = EncoderConfig {
2904            quantize_coords_m: Some(1.0),
2905            ..EncoderConfig::default()
2906        };
2907        let attr_cfg = EncoderConfig {
2908            quantize_attrs_auto: true,
2909            ..EncoderConfig::default()
2910        };
2911
2912        let plain = encode_tile_with(layers, &plain_cfg).unwrap();
2913        let quant = encode_tile_with(layers, &quant_cfg).unwrap();
2914        let attr = encode_tile_with(layers, &attr_cfg).unwrap();
2915
2916        // Each explicit config yields a distinct tile — the config, not shared
2917        // state, decides the encoding. (If `encode_tile_with` read the unset
2918        // globals instead of the config, all three would be identical.) These
2919        // differences are config-driven at the WIRE COLUMN level — coord
2920        // quantization changes the geometry column (i32 grid vs Float64) and
2921        // attribute quantization changes the `speed` column (u16 vs Float64) — so
2922        // the inequality is not attributable to the encoder's (separately
2923        // tracked) non-deterministic Arrow-metadata ordering, which we therefore
2924        // deliberately do NOT byte-assert here.
2925        assert_ne!(plain, quant, "coord quantization must change the tile");
2926        assert_ne!(plain, attr, "attribute quantization must change the tile");
2927        assert_ne!(quant, attr, "the two quantizations differ from each other");
2928
2929        // All three still decode to the SAME feature set — encoding differs, data
2930        // does not.
2931        for tile in [&plain, &quant, &attr] {
2932            let rows: usize = decode_tile(tile).unwrap().iter().map(|l| l.batch.num_rows()).sum();
2933            assert_eq!(rows, 3);
2934        }
2935    }
2936
2937    fn sample_line_layer() -> ColumnarLayer {
2938        ColumnarLayer {
2939            name: "tracks".to_string(),
2940            feature_ids: vec![10, 11],
2941            start_times: vec![0, 100],
2942            end_times: vec![50, 200],
2943            geometry: GeometryColumn::LineString(vec![
2944                vec![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]],
2945                vec![[5.0, 5.0], [6.0, 6.0]],
2946            ]),
2947            vertex_times: Some(vec![vec![0, 25, 50], vec![100, 200]]),
2948            vertex_values: None,
2949            triangles: None,
2950            vertex_value_matrix: None,
2951            properties: vec![],
2952        }
2953    }
2954
2955    fn sample_polygon_layer() -> ColumnarLayer {
2956        ColumnarLayer {
2957            name: "zones".to_string(),
2958            feature_ids: vec![42],
2959            start_times: vec![0],
2960            end_times: vec![1000],
2961            geometry: GeometryColumn::Polygon(vec![vec![
2962                // exterior ring
2963                vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0], [0.0, 0.0]],
2964                // hole
2965                vec![[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 2.0], [1.0, 1.0]],
2966            ]]),
2967            vertex_times: None,
2968            vertex_values: None,
2969            triangles: None,
2970            vertex_value_matrix: None,
2971            properties: vec![],
2972        }
2973    }
2974
2975    #[test]
2976    fn categorical_columns_use_dictionary_encoding() {
2977        let layer = ColumnarLayer {
2978            name: "cars".into(),
2979            feature_ids: vec![1, 2, 3, 4, 5],
2980            start_times: vec![0; 5],
2981            end_times: vec![1; 5],
2982            geometry: GeometryColumn::Point(vec![[0.0, 0.0]; 5]),
2983            vertex_times: None,
2984            vertex_values: None,
2985            triangles: None,
2986            vertex_value_matrix: None,
2987            properties: vec![(
2988                "kind".into(),
2989                PropertyColumn::Categorical(vec![
2990                    Some("car".into()),
2991                    Some("bus".into()),
2992                    Some("car".into()),
2993                    None,
2994                    Some("car".into()),
2995                ]),
2996            )],
2997        };
2998        let ipc = encode_layer(&layer).unwrap();
2999        let batch = decode_layer(&ipc).unwrap();
3000        let field = batch.schema().field_with_name("kind").unwrap().clone();
3001        match field.data_type() {
3002            DataType::Dictionary(k, v) => {
3003                assert_eq!(k.as_ref(), &DataType::UInt16);
3004                assert_eq!(v.as_ref(), &DataType::Utf8);
3005            }
3006            other => panic!("expected Dictionary<UInt16, Utf8>, got {other:?}"),
3007        }
3008
3009        let col = batch
3010            .column_by_name("kind")
3011            .unwrap()
3012            .as_any()
3013            .downcast_ref::<DictionaryArray<UInt16Type>>()
3014            .unwrap();
3015        let values = col
3016            .values()
3017            .as_any()
3018            .downcast_ref::<StringArray>()
3019            .unwrap();
3020        // First-seen order: "car" then "bus".
3021        let mut categories: Vec<&str> = (0..values.len()).map(|i| values.value(i)).collect();
3022        categories.sort();
3023        assert_eq!(categories, vec!["bus", "car"]);
3024
3025        // The 4th row is null; others reference one of the two slots.
3026        assert!(col.is_null(3));
3027        let keys = col.keys();
3028        for i in [0usize, 1, 2, 4] {
3029            assert!(keys.value(i) < values.len() as u16);
3030        }
3031    }
3032
3033    #[test]
3034    fn categorical_overflow_errors_instead_of_corrupting() {
3035        // A column whose distinct-value count exceeds the UInt16 dictionary
3036        // key space must be rejected, not silently collapsed onto one index.
3037        let n = u16::MAX as usize + 1; // 65_536 distinct strings
3038        let kinds: Vec<Option<String>> = (0..n).map(|i| Some(format!("c{i}"))).collect();
3039        let layer = ColumnarLayer {
3040            name: "huge".into(),
3041            feature_ids: (0..n as u64).collect(),
3042            start_times: vec![0; n],
3043            end_times: vec![1; n],
3044            geometry: GeometryColumn::Point(vec![[0.0, 0.0]; n]),
3045            vertex_times: None,
3046            vertex_values: None,
3047            triangles: None,
3048            vertex_value_matrix: None,
3049            properties: vec![("kind".into(), PropertyColumn::Categorical(kinds))],
3050        };
3051        let err = encode_layer(&layer).expect_err("overflowing dictionary must error");
3052        assert!(
3053            err.to_string().contains("distinct values"),
3054            "unexpected error: {err}"
3055        );
3056    }
3057
3058    #[test]
3059    fn geometry_field_advertises_crs_metadata() {
3060        // Every geometry field carries the GeoArrow extension *name* and the
3061        // CRS in extension *metadata*, so external GeoArrow readers see WGS84
3062        // lon/lat (OGC:CRS84) rather than an unknown CRS.
3063        for layer in [sample_point_layer(), sample_line_layer(), sample_polygon_layer()] {
3064            let ipc = encode_layer(&layer).unwrap();
3065            let batch = decode_layer(&ipc).unwrap();
3066            let field = batch.schema().field_with_name("geometry").unwrap().clone();
3067            let meta = field.metadata();
3068            assert_eq!(
3069                meta.get(GEOARROW_EXT_KEY).map(String::as_str),
3070                Some(layer.geometry.geoarrow_name())
3071            );
3072            let crs = meta
3073                .get(GEOARROW_EXT_META_KEY)
3074                .expect("geometry field must carry ARROW:extension:metadata");
3075            assert!(crs.contains("OGC:CRS84"), "crs metadata was: {crs}");
3076            assert!(crs.contains("crs_type"), "crs metadata was: {crs}");
3077        }
3078    }
3079
3080    #[test]
3081    fn point_layer_roundtrips() {
3082        let layer = sample_point_layer();
3083        let ipc = encode_layer(&layer).unwrap();
3084        let batch = decode_layer(&ipc).unwrap();
3085
3086        assert_eq!(batch.num_rows(), 3);
3087        // id / start / end / geometry / speed / kind
3088        assert_eq!(batch.num_columns(), 6);
3089
3090        let ids = batch
3091            .column_by_name("id")
3092            .unwrap()
3093            .as_any()
3094            .downcast_ref::<UInt64Array>()
3095            .unwrap();
3096        assert_eq!(ids.values(), &[1, 2, 3]);
3097
3098        let geom = batch
3099            .column_by_name("geometry")
3100            .unwrap()
3101            .as_any()
3102            .downcast_ref::<FixedSizeListArray>()
3103            .unwrap();
3104        assert_eq!(geom.len(), 3);
3105        assert_eq!(geom.value_length(), 2);
3106
3107        // Geometry field carries the GeoArrow extension name.
3108        let geom_field = batch.schema().field_with_name("geometry").unwrap().clone();
3109        assert_eq!(
3110            geom_field.metadata().get(GEOARROW_EXT_KEY).map(String::as_str),
3111            Some("geoarrow.point")
3112        );
3113
3114        // Nullable numeric property preserves the null.
3115        let speed = batch
3116            .column_by_name("speed")
3117            .unwrap()
3118            .as_any()
3119            .downcast_ref::<Float64Array>()
3120            .unwrap();
3121        assert!(speed.is_null(1));
3122        assert_eq!(speed.value(0), 10.0);
3123    }
3124
3125    #[test]
3126    fn vector_property_roundtrips_as_fixed_size_list() {
3127        // A Vector property encodes as FixedSizeList<leaf, width>: the f32 quat
3128        // as <Float32,4>, the u8 colour as <UInt8,4>, with the child buffer the
3129        // flattened row-major run the TS decoder hands to the GPU zero-copy.
3130        use arrow::array::{Float32Array, UInt8Array};
3131        let layer = ColumnarLayer {
3132            name: "surfels".to_string(),
3133            feature_ids: vec![1, 2],
3134            start_times: vec![0, 10],
3135            end_times: vec![0, 10],
3136            geometry: GeometryColumn::Point(vec![[-122.4, 37.7], [-122.5, 37.8]]),
3137            vertex_times: None,
3138            vertex_values: None,
3139            triangles: None,
3140            vertex_value_matrix: None,
3141            properties: vec![
3142                (
3143                    "surfel_quat".to_string(),
3144                    PropertyColumn::Vector {
3145                        width: 4,
3146                        elem: VectorElem::F32,
3147                        values: vec![0.0, 0.0, 0.0, 1.0, 0.5, 0.5, 0.5, 0.5],
3148                    },
3149                ),
3150                (
3151                    "surfel_rgba".to_string(),
3152                    PropertyColumn::Vector {
3153                        width: 4,
3154                        elem: VectorElem::U8,
3155                        values: vec![255.0, 0.0, 0.0, 128.0, 0.0, 255.0, 0.0, 255.0],
3156                    },
3157                ),
3158            ],
3159        };
3160        let ipc = encode_layer(&layer).unwrap();
3161        let batch = decode_layer(&ipc).unwrap();
3162
3163        let quat = batch
3164            .column_by_name("surfel_quat")
3165            .unwrap()
3166            .as_any()
3167            .downcast_ref::<FixedSizeListArray>()
3168            .unwrap();
3169        assert_eq!(quat.len(), 2);
3170        assert_eq!(quat.value_length(), 4);
3171        let qchild = quat
3172            .values()
3173            .as_any()
3174            .downcast_ref::<Float32Array>()
3175            .unwrap();
3176        assert_eq!(
3177            qchild.values(),
3178            &[0.0, 0.0, 0.0, 1.0, 0.5, 0.5, 0.5, 0.5]
3179        );
3180
3181        let rgba = batch
3182            .column_by_name("surfel_rgba")
3183            .unwrap()
3184            .as_any()
3185            .downcast_ref::<FixedSizeListArray>()
3186            .unwrap();
3187        assert_eq!(rgba.value_length(), 4);
3188        let cchild = rgba
3189            .values()
3190            .as_any()
3191            .downcast_ref::<UInt8Array>()
3192            .unwrap();
3193        assert_eq!(cchild.values(), &[255, 0, 0, 128, 0, 255, 0, 255]);
3194    }
3195
3196    #[test]
3197    fn vector_groups_fuse_scalar_columns_at_encode() {
3198        // `--vector-group` fuses named scalar columns into one interleaved
3199        // FixedSizeList and drops the scalars; ungrouped columns are untouched.
3200        use arrow::array::Float32Array;
3201        let layer = ColumnarLayer {
3202            name: "surfels".to_string(),
3203            feature_ids: vec![1, 2],
3204            start_times: vec![0, 10],
3205            end_times: vec![0, 10],
3206            geometry: GeometryColumn::Point(vec![[-122.4, 37.7], [-122.5, 37.8]]),
3207            vertex_times: None,
3208            vertex_values: None,
3209            triangles: None,
3210            vertex_value_matrix: None,
3211            properties: vec![
3212                ("qx".into(), PropertyColumn::Numeric(vec![Some(0.0), Some(0.5)])),
3213                ("qy".into(), PropertyColumn::Numeric(vec![Some(0.0), Some(0.5)])),
3214                ("qz".into(), PropertyColumn::Numeric(vec![Some(0.0), Some(0.5)])),
3215                ("qw".into(), PropertyColumn::Numeric(vec![Some(1.0), Some(0.5)])),
3216                ("z".into(), PropertyColumn::Numeric(vec![Some(3.0), Some(4.0)])),
3217            ],
3218        };
3219        // Explicit config (not the process-global setter) so this test can't
3220        // leak a non-default vector-group into a concurrently-running test that
3221        // reads the encoder globals via bare `encode_layer`.
3222        let cfg = EncoderConfig {
3223            vector_groups: vec![VectorGroup {
3224                name: "surfel_quat".to_string(),
3225                components: vec!["qx".into(), "qy".into(), "qz".into(), "qw".into()],
3226                elem: VectorElem::F32,
3227            }],
3228            ..EncoderConfig::default()
3229        };
3230        let ipc = encode_layer_with(&layer, &cfg).unwrap();
3231        let batch = decode_layer(&ipc).unwrap();
3232
3233        // Scalars fused away; the grouped vector + the ungrouped `z` remain.
3234        assert!(batch.column_by_name("qx").is_none());
3235        assert!(batch.column_by_name("z").is_some());
3236        let quat = batch
3237            .column_by_name("surfel_quat")
3238            .unwrap()
3239            .as_any()
3240            .downcast_ref::<FixedSizeListArray>()
3241            .unwrap();
3242        assert_eq!(quat.value_length(), 4);
3243        let qchild = quat
3244            .values()
3245            .as_any()
3246            .downcast_ref::<Float32Array>()
3247            .unwrap();
3248        assert_eq!(qchild.values(), &[0.0, 0.0, 0.0, 1.0, 0.5, 0.5, 0.5, 0.5]);
3249    }
3250
3251    #[test]
3252    fn point_elevation_folds_into_3d_geometry_unquantized() {
3253        use arrow::array::Float64Array;
3254        let layer = ColumnarLayer {
3255            name: "cloud".into(),
3256            feature_ids: vec![1, 2],
3257            start_times: vec![0, 0],
3258            end_times: vec![0, 0],
3259            geometry: GeometryColumn::Point(vec![[-122.4, 37.7], [-122.5, 37.8]]),
3260            vertex_times: None,
3261            vertex_values: None,
3262            triangles: None,
3263            vertex_value_matrix: None,
3264            properties: vec![
3265                ("z".into(), PropertyColumn::Numeric(vec![Some(3.5), Some(9.0)])),
3266                ("speed".into(), PropertyColumn::Numeric(vec![Some(1.0), Some(2.0)])),
3267            ],
3268        };
3269        let cfg = EncoderConfig {
3270            point_elevation_column: "z".to_string(),
3271            ..EncoderConfig::default()
3272        };
3273        let ipc = encode_layer_with(&layer, &cfg).unwrap();
3274        let batch = decode_layer(&ipc).unwrap();
3275
3276        // Geometry is now a 3-wide list with z folded in; `z` is gone as a property.
3277        let geom = batch
3278            .column_by_name("geometry")
3279            .unwrap()
3280            .as_any()
3281            .downcast_ref::<FixedSizeListArray>()
3282            .unwrap();
3283        assert_eq!(geom.value_length(), 3);
3284        let coords = geom.values().as_any().downcast_ref::<Float64Array>().unwrap();
3285        assert_eq!(coords.value(2), 3.5); // feature 0 z
3286        assert_eq!(coords.value(5), 9.0); // feature 1 z
3287        assert!(batch.column_by_name("z").is_none(), "z folded into geometry");
3288        assert!(batch.column_by_name("speed").is_some(), "other props untouched");
3289    }
3290
3291    #[test]
3292    fn point_elevation_3d_geometry_quantizes_with_z_affine() {
3293        use arrow::array::Int32Array;
3294        let layer = ColumnarLayer {
3295            name: "cloud".into(),
3296            feature_ids: vec![1],
3297            start_times: vec![0],
3298            end_times: vec![0],
3299            geometry: GeometryColumn::Point(vec![[-122.4, 37.7]]),
3300            vertex_times: None,
3301            vertex_values: None,
3302            triangles: None,
3303            vertex_value_matrix: None,
3304            properties: vec![("z".into(), PropertyColumn::Numeric(vec![Some(5.0)]))],
3305        };
3306        let cfg = EncoderConfig {
3307            quantize_coords_m: Some(0.05),
3308            point_elevation_column: "z".to_string(),
3309            ..EncoderConfig::default()
3310        };
3311        let ipc = encode_layer_with(&layer, &cfg).unwrap();
3312        let batch = decode_layer(&ipc).unwrap();
3313
3314        let field = batch.schema().field_with_name("geometry").unwrap().clone();
3315        let affine = QuantAffine::from_json(field.metadata().get(STT_QUANT_META_KEY).unwrap()).unwrap();
3316        assert_eq!(affine.z0, Some(0.0));
3317        assert_eq!(affine.sz, Some(0.05));
3318        let geom = batch
3319            .column_by_name("geometry")
3320            .unwrap()
3321            .as_any()
3322            .downcast_ref::<FixedSizeListArray>()
3323            .unwrap();
3324        assert_eq!(geom.value_length(), 3);
3325        let coords = geom.values().as_any().downcast_ref::<Int32Array>().unwrap();
3326        // z = 5.0 / 0.05 = 100; reconstructs to z0 + 100*sz = 5.0.
3327        assert_eq!(coords.value(2), 100);
3328        assert_eq!(affine.z0.unwrap() + coords.value(2) as f64 * affine.sz.unwrap(), 5.0);
3329    }
3330
3331    #[test]
3332    fn quantized_point_layer_roundtrips_within_precision() {
3333        let layer = sample_point_layer();
3334        let ipc = encode_layer_quantized(&layer, Some(1.0)).unwrap();
3335        let batch = decode_layer(&ipc).unwrap();
3336
3337        // Geometry leaf is now i32 grid indices, and the affine rides in metadata.
3338        let geom_field = batch.schema().field_with_name("geometry").unwrap().clone();
3339        let affine = QuantAffine::from_json(
3340            geom_field
3341                .metadata()
3342                .get(STT_QUANT_META_KEY)
3343                .expect("quantized tile must carry the affine"),
3344        )
3345        .unwrap();
3346
3347        let geom = batch
3348            .column_by_name("geometry")
3349            .unwrap()
3350            .as_any()
3351            .downcast_ref::<FixedSizeListArray>()
3352            .unwrap();
3353        assert_eq!(geom.value_type(), DataType::Int32);
3354        let coords = geom
3355            .values()
3356            .as_any()
3357            .downcast_ref::<Int32Array>()
3358            .unwrap();
3359
3360        let original = [[-122.4, 37.7], [-122.5, 37.8], [-122.6, 37.9]];
3361        for (i, [lon, lat]) in original.iter().enumerate() {
3362            let rlon = affine.lon(coords.value(i * 2));
3363            let rlat = affine.lat(coords.value(i * 2 + 1));
3364            // Worst-case reconstruction error ≤ ~half a quantum (~0.5 m).
3365            let dlon_m = (rlon - lon).abs() * M_PER_DEG_LAT * lat.to_radians().cos();
3366            let dlat_m = (rlat - lat).abs() * M_PER_DEG_LAT;
3367            assert!(dlon_m < 1.0, "lon err {dlon_m} m at point {i}");
3368            assert!(dlat_m < 1.0, "lat err {dlat_m} m at point {i}");
3369        }
3370    }
3371
3372    #[test]
3373    fn quantized_numeric_attr_roundtrips_within_precision_and_is_opt_in() {
3374        // A LiDAR-style `z` elevation column: high-entropy Float64 by default,
3375        // but fixed-point UInt16 when the build opts the column in. The reader
3376        // reconstructs `value = o + q*s`, lossy to <= s/2.
3377        let zvals: Vec<Option<f64>> =
3378            vec![Some(1.07), Some(-2.4), Some(15.9), None, Some(40.02)];
3379        let make = || ColumnarLayer {
3380            name: "lidar".into(),
3381            feature_ids: vec![1, 2, 3, 4, 5],
3382            start_times: vec![0; 5],
3383            end_times: vec![1; 5],
3384            geometry: GeometryColumn::Point(vec![[-122.4, 37.7]; 5]),
3385            vertex_times: None,
3386            vertex_values: None,
3387            triangles: None,
3388            vertex_value_matrix: None,
3389            properties: vec![("z".into(), PropertyColumn::Numeric(zvals.clone()))],
3390        };
3391
3392        // Default (no attr-quant configured): `z` stays Float64, byte-identical
3393        // to the historical encoder. Explicit config (not the global setter) so
3394        // this test stays hermetic under the parallel test runner.
3395        let plain =
3396            decode_layer(&encode_layer_with(&make(), &EncoderConfig::default()).unwrap()).unwrap();
3397        let zf = plain.schema().field_with_name("z").unwrap().clone();
3398        assert_eq!(zf.data_type(), &DataType::Float64);
3399        assert!(zf.metadata().get(STT_QUANT_ATTR_META_KEY).is_none());
3400
3401        // Opt the `z` column in at 0.05-unit precision.
3402        let q = encode_layer_with(
3403            &make(),
3404            &EncoderConfig {
3405                quantize_attrs: HashMap::from([("z".to_string(), 0.05f64)]),
3406                ..EncoderConfig::default()
3407            },
3408        )
3409        .unwrap();
3410
3411        let batch = decode_layer(&q).unwrap();
3412        let field = batch.schema().field_with_name("z").unwrap().clone();
3413        // Range (-2.4..40.02)/0.05 ~= 848 fits 16 bits → UInt16 leaf.
3414        assert_eq!(field.data_type(), &DataType::UInt16);
3415        let affine = AttrQuant::from_json(
3416            field
3417                .metadata()
3418                .get(STT_QUANT_ATTR_META_KEY)
3419                .expect("quantized attr must carry the affine"),
3420        )
3421        .unwrap();
3422
3423        let col = batch
3424            .column_by_name("z")
3425            .unwrap()
3426            .as_any()
3427            .downcast_ref::<UInt16Array>()
3428            .unwrap();
3429        for (i, want) in zvals.iter().enumerate() {
3430            match want {
3431                Some(v) => {
3432                    assert!(!col.is_null(i), "row {i} should be present");
3433                    let got = affine.value(col.value(i) as i64);
3434                    assert!((got - v).abs() <= 0.05 / 2.0 + 1e-9, "z[{i}] {got} vs {v}");
3435                }
3436                None => assert!(col.is_null(i), "row {i} should be null"),
3437            }
3438        }
3439    }
3440
3441    #[test]
3442    fn auto_numeric_quantization_is_range_adaptive_and_opt_in() {
3443        // With auto-quant enabled, a raw Float64 property is quantized to a
3444        // UInt16 sized from its own [min,max] span (no precision configured),
3445        // and reconstructs to <= span/65535. Default-off keeps it Float64.
3446        let depth: Vec<Option<f64>> = vec![Some(0.0), Some(10.0), Some(123.4), Some(700.0)];
3447        let make = || ColumnarLayer {
3448            name: "q".into(),
3449            feature_ids: vec![1, 2, 3, 4],
3450            start_times: vec![0; 4],
3451            end_times: vec![1; 4],
3452            geometry: GeometryColumn::Point(vec![[0.0, 0.0]; 4]),
3453            vertex_times: None,
3454            vertex_values: None,
3455            triangles: None,
3456            vertex_value_matrix: None,
3457            properties: vec![("depth".into(), PropertyColumn::Numeric(depth.clone()))],
3458        };
3459
3460        // Default: auto off → Float64. Explicit config (not the global setter)
3461        // so this test can't flip `quantize_attrs_auto` under a concurrently
3462        // running test that reads the encoder globals via bare `encode_layer`.
3463        let plain =
3464            decode_layer(&encode_layer_with(&make(), &EncoderConfig::default()).unwrap()).unwrap();
3465        assert_eq!(
3466            plain.schema().field_with_name("depth").unwrap().data_type(),
3467            &DataType::Float64
3468        );
3469
3470        // Auto on → range-adaptive UInt16 + affine.
3471        let batch = decode_layer(
3472            &encode_layer_with(
3473                &make(),
3474                &EncoderConfig {
3475                    quantize_attrs_auto: true,
3476                    ..EncoderConfig::default()
3477                },
3478            )
3479            .unwrap(),
3480        )
3481        .unwrap();
3482
3483        let field = batch.schema().field_with_name("depth").unwrap().clone();
3484        assert_eq!(field.data_type(), &DataType::UInt16);
3485        let aff = AttrQuant::from_json(field.metadata().get(STT_QUANT_ATTR_META_KEY).unwrap()).unwrap();
3486        let col = batch.column_by_name("depth").unwrap().as_any().downcast_ref::<UInt16Array>().unwrap();
3487        let tol = (700.0 - 0.0) / u16::MAX as f64 / 2.0 + 1e-9;
3488        for (i, want) in depth.iter().enumerate() {
3489            let got = aff.value(col.value(i) as i64);
3490            assert!((got - want.unwrap()).abs() <= tol, "depth[{i}] {got} vs {want:?}");
3491        }
3492        // Min and max land on the index endpoints (full 16-bit span used).
3493        assert_eq!(col.value(0), 0);
3494        assert_eq!(col.value(3), u16::MAX);
3495    }
3496
3497    #[test]
3498    fn quantization_shrinks_geometry_and_is_opt_in() {
3499        // A many-vertex line is coordinate-dominated; quantization should shrink
3500        // the IPC, and the default (None) path must stay byte-identical.
3501        let line: Vec<[f64; 2]> = (0..400)
3502            .map(|k| [-73.95 + k as f64 * 1e-4, 40.75 + k as f64 * 7e-5])
3503            .collect();
3504        let layer = ColumnarLayer {
3505            name: "q".into(),
3506            feature_ids: vec![1],
3507            start_times: vec![0],
3508            end_times: vec![1],
3509            geometry: GeometryColumn::LineString(vec![line]),
3510            vertex_times: None,
3511            vertex_values: None,
3512            triangles: None,
3513            vertex_value_matrix: None,
3514            properties: vec![],
3515        };
3516        let plain = encode_layer_quantized(&layer, None).unwrap();
3517        let quant = encode_layer_quantized(&layer, Some(1.0)).unwrap();
3518
3519        // None keeps the Float64 GeoArrow leaf and carries no affine.
3520        let pb = decode_layer(&plain).unwrap();
3521        let pf = pb.schema().field_with_name("geometry").unwrap().clone();
3522        assert!(pf.metadata().get(STT_QUANT_META_KEY).is_none());
3523
3524        // Some(_) switches the leaf to Int32 and emits the affine.
3525        let qb = decode_layer(&quant).unwrap();
3526        let qf = qb.schema().field_with_name("geometry").unwrap().clone();
3527        assert!(qf.metadata().get(STT_QUANT_META_KEY).is_some());
3528
3529        // i32 coords (4 B) replace f64 (8 B) for a coordinate-dominated layer.
3530        assert!(
3531            quant.len() < plain.len(),
3532            "quantized {} should be smaller than f64 {}",
3533            quant.len(),
3534            plain.len()
3535        );
3536    }
3537
3538    #[test]
3539    fn line_layer_roundtrips_with_vertex_times() {
3540        let layer = sample_line_layer();
3541        let ipc = encode_layer(&layer).unwrap();
3542        let batch = decode_layer(&ipc).unwrap();
3543
3544        assert_eq!(batch.num_rows(), 2);
3545        let geom = batch
3546            .column_by_name("geometry")
3547            .unwrap()
3548            .as_any()
3549            .downcast_ref::<ListArray>()
3550            .unwrap();
3551        // Feature 0 has 3 vertices, feature 1 has 2.
3552        assert_eq!(geom.value(0).len(), 3);
3553        assert_eq!(geom.value(1).len(), 2);
3554
3555        // v3 layers with a tight temporal span carry u16-delta vertex times
3556        // and the origin/step metadata needed to reconstruct absolutes.
3557        let meta = batch.schema().metadata().clone();
3558        let origin: i64 = meta
3559            .get("stt:vertex_time_origin_ms")
3560            .expect("u16 vertex-time layers carry an origin")
3561            .parse()
3562            .unwrap();
3563        let step: u32 = meta
3564            .get("stt:vertex_time_step_ms")
3565            .expect("u16 vertex-time layers carry a step")
3566            .parse()
3567            .unwrap();
3568        assert_eq!(origin, 0);
3569        assert_eq!(step, 1);
3570
3571        let vt = batch
3572            .column_by_name("vertex_time")
3573            .unwrap()
3574            .as_any()
3575            .downcast_ref::<ListArray>()
3576            .unwrap();
3577        assert_eq!(vt.len(), 2);
3578        let first = vt.value(0);
3579        let deltas = first.as_any().downcast_ref::<arrow::array::UInt16Array>().unwrap();
3580        let absolutes: Vec<i64> = deltas
3581            .values()
3582            .iter()
3583            .map(|d| origin + (*d as i64) * step as i64)
3584            .collect();
3585        assert_eq!(absolutes, vec![0, 25, 50]);
3586    }
3587
3588    #[test]
3589    fn line_layer_roundtrips_with_vertex_values() {
3590        // Per-vertex scalars (e.g. SST) ride a nullable List<Float32> aligned
3591        // with the geometry vertices. A NaN entry marks a vertex with no value.
3592        let layer = ColumnarLayer {
3593            name: "drift".into(),
3594            feature_ids: vec![1, 2],
3595            start_times: vec![0, 0],
3596            end_times: vec![100, 100],
3597            geometry: GeometryColumn::LineString(vec![
3598                vec![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]],
3599                vec![[3.0, 3.0], [4.0, 4.0]],
3600            ]),
3601            vertex_times: None,
3602            vertex_values: Some(vec![vec![5.0, f32::NAN, 27.5], vec![12.0, 13.0]]),
3603            triangles: None,
3604            vertex_value_matrix: None,
3605            properties: vec![],
3606        };
3607        let ipc = encode_layer(&layer).unwrap();
3608        let batch = decode_layer(&ipc).unwrap();
3609
3610        let vv = batch
3611            .column_by_name("vertex_value")
3612            .expect("layers with per-vertex values carry a vertex_value column")
3613            .as_any()
3614            .downcast_ref::<ListArray>()
3615            .unwrap();
3616        assert_eq!(vv.len(), 2);
3617        let first = vv.value(0);
3618        let vals = first.as_any().downcast_ref::<arrow::array::Float32Array>().unwrap();
3619        assert_eq!(vals.value(0), 5.0);
3620        assert!(vals.value(1).is_nan());
3621        assert_eq!(vals.value(2), 27.5);
3622        let second = vv.value(1);
3623        let vals2 = second.as_any().downcast_ref::<arrow::array::Float32Array>().unwrap();
3624        assert_eq!(vals2.values(), &[12.0, 13.0]);
3625    }
3626
3627    #[test]
3628    fn line_layer_roundtrips_with_vertex_value_matrix() {
3629        // Static-geometry overview: per-vertex × per-bucket value matrix rides a
3630        // nullable List<Float32>, flattened vertex-major (vertex 0's buckets,
3631        // then vertex 1's, ...). num_buckets is recorded in schema metadata.
3632        // Feature 0: 3 vertices × 2 buckets; feature 1: 2 vertices × 2 buckets.
3633        let layer = ColumnarLayer {
3634            name: "flows".into(),
3635            feature_ids: vec![1, 2],
3636            start_times: vec![0, 0],
3637            end_times: vec![1800, 1800],
3638            geometry: GeometryColumn::LineString(vec![
3639                vec![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]],
3640                vec![[3.0, 3.0], [4.0, 4.0]],
3641            ]),
3642            vertex_times: None,
3643            vertex_values: None,
3644            triangles: None,
3645            // vertex-major: [v0b0, v0b1, v1b0, v1b1, v2b0, v2b1]
3646            vertex_value_matrix: Some(vec![
3647                vec![10.0, 11.0, 20.0, 21.0, 30.0, 31.0],
3648                vec![40.0, 41.0, 50.0, 51.0],
3649            ]),
3650            properties: vec![],
3651        };
3652        let ipc = encode_layer(&layer).unwrap();
3653        let batch = decode_layer(&ipc).unwrap();
3654
3655        let vm = batch
3656            .column_by_name("vertex_value_matrix")
3657            .expect("matrix layers carry a vertex_value_matrix column")
3658            .as_any()
3659            .downcast_ref::<ListArray>()
3660            .unwrap();
3661        assert_eq!(vm.len(), 2);
3662        let f0 = vm.value(0);
3663        let f0v = f0.as_any().downcast_ref::<arrow::array::Float32Array>().unwrap();
3664        assert_eq!(f0v.values(), &[10.0, 11.0, 20.0, 21.0, 30.0, 31.0]);
3665        let f1 = vm.value(1);
3666        let f1v = f1.as_any().downcast_ref::<arrow::array::Float32Array>().unwrap();
3667        assert_eq!(f1v.values(), &[40.0, 41.0, 50.0, 51.0]);
3668
3669        // num_buckets = matrix_len / vertex_count = 6 / 3 = 2, in schema meta.
3670        assert_eq!(
3671            batch.schema().metadata().get("stt:vertex_value_buckets"),
3672            Some(&"2".to_string())
3673        );
3674    }
3675
3676    #[test]
3677    fn vertex_time_falls_back_to_int64_for_wide_spans() {
3678        // span = 100 billion ms; the u16 encoding would need step ≈ 1.5e6 ms,
3679        // far beyond the DEFAULT_VERTEX_TIME_MAX_STEP_MS ceiling — so the
3680        // encoder must take the exact List<Int64> path, byte-for-byte
3681        // absolute timestamps, with no origin/step metadata.
3682        let layer = ColumnarLayer {
3683            name: "edge".into(),
3684            feature_ids: vec![1],
3685            start_times: vec![0],
3686            end_times: vec![100],
3687            geometry: GeometryColumn::LineString(vec![vec![[0.0, 0.0], [1.0, 1.0]]]),
3688            vertex_times: Some(vec![vec![0, 100_000_000_000]]),
3689            vertex_values: None,
3690            triangles: None,
3691            vertex_value_matrix: None,
3692            properties: vec![],
3693        };
3694        let ipc = encode_layer(&layer).unwrap();
3695        let batch = decode_layer(&ipc).unwrap();
3696        let schema = batch.schema();
3697        let meta = schema.metadata();
3698        assert!(meta.get("stt:vertex_time_origin_ms").is_none());
3699        assert!(meta.get("stt:vertex_time_step_ms").is_none());
3700        let vt = batch
3701            .column_by_name("vertex_time")
3702            .unwrap()
3703            .as_any()
3704            .downcast_ref::<ListArray>()
3705            .unwrap();
3706        let first = vt.value(0);
3707        let absolutes = first
3708            .as_any()
3709            .downcast_ref::<Int64Array>()
3710            .expect("wide spans must keep the exact Int64 shape");
3711        assert_eq!(absolutes.values(), &[0, 100_000_000_000]);
3712    }
3713
3714    #[test]
3715    fn vertex_time_step_ceiling_is_the_u16_vs_int64_threshold() {
3716        // span = 65_535_000 ms quantizes at exactly the 1000 ms default
3717        // ceiling → u16 deltas; one ms more pushes the step to 1001 → i64.
3718        let make = |span: i64| ColumnarLayer {
3719            name: "edge".into(),
3720            feature_ids: vec![1],
3721            start_times: vec![0],
3722            end_times: vec![100],
3723            geometry: GeometryColumn::LineString(vec![vec![[0.0, 0.0], [1.0, 1.0]]]),
3724            vertex_times: Some(vec![vec![0, span]]),
3725            vertex_values: None,
3726            triangles: None,
3727            vertex_value_matrix: None,
3728            properties: vec![],
3729        };
3730
3731        let at_ceiling = decode_layer(&encode_layer(&make(65_535_000)).unwrap()).unwrap();
3732        let schema = at_ceiling.schema();
3733        let step: u32 = schema
3734            .metadata()
3735            .get("stt:vertex_time_step_ms")
3736            .expect("span at the ceiling stays u16-delta encoded")
3737            .parse()
3738            .unwrap();
3739        assert_eq!(step, DEFAULT_VERTEX_TIME_MAX_STEP_MS);
3740
3741        let past_ceiling = decode_layer(&encode_layer(&make(65_536_000)).unwrap()).unwrap();
3742        assert!(past_ceiling
3743            .schema()
3744            .metadata()
3745            .get("stt:vertex_time_step_ms")
3746            .is_none());
3747        let vt = past_ceiling
3748            .column_by_name("vertex_time")
3749            .unwrap()
3750            .as_any()
3751            .downcast_ref::<ListArray>()
3752            .unwrap();
3753        let first = vt.value(0);
3754        let absolutes = first.as_any().downcast_ref::<Int64Array>().unwrap();
3755        assert_eq!(absolutes.values(), &[0, 65_536_000]);
3756    }
3757
3758    #[test]
3759    fn polygon_layer_roundtrips_with_rings() {
3760        let layer = sample_polygon_layer();
3761        let ipc = encode_layer(&layer).unwrap();
3762        let batch = decode_layer(&ipc).unwrap();
3763
3764        let geom = batch
3765            .column_by_name("geometry")
3766            .unwrap()
3767            .as_any()
3768            .downcast_ref::<ListArray>()
3769            .unwrap();
3770        assert_eq!(geom.len(), 1);
3771        // One feature with two rings (exterior + hole).
3772        let rings = geom.value(0);
3773        let rings = rings.as_any().downcast_ref::<ListArray>().unwrap();
3774        assert_eq!(rings.len(), 2);
3775        assert_eq!(rings.value(0).len(), 5); // exterior ring vertices
3776        assert_eq!(rings.value(1).len(), 5); // hole vertices
3777    }
3778
3779    #[test]
3780    fn multi_layer_tile_frame_roundtrips() {
3781        let layers = vec![sample_line_layer(), sample_point_layer()];
3782        let payload = encode_tile(&layers).unwrap();
3783        let decoded = decode_tile(&payload).unwrap();
3784
3785        assert_eq!(decoded.len(), 2);
3786        assert_eq!(decoded[0].name, "tracks");
3787        assert_eq!(decoded[1].name, "points");
3788        assert_eq!(decoded[0].batch.num_rows(), 2);
3789        assert_eq!(decoded[1].batch.num_rows(), 3);
3790        // Schema metadata records the layer name on the batch too.
3791        assert_eq!(
3792            decoded[1]
3793                .batch
3794                .schema()
3795                .metadata()
3796                .get("stt:layer")
3797                .map(String::as_str),
3798            Some("points")
3799        );
3800    }
3801
3802    #[test]
3803    fn tessellate_polygon_emits_two_triangles_for_a_square() {
3804        // A simple closed square (5 verts, last duplicates first) earcuts into
3805        // exactly 2 triangles, 6 indices in [0, 3].
3806        let ring: Vec<Coord> = vec![
3807            [0.0, 0.0],
3808            [1.0, 0.0],
3809            [1.0, 1.0],
3810            [0.0, 1.0],
3811            [0.0, 0.0],
3812        ];
3813        let tris = tessellate_polygon(&[ring]);
3814        assert_eq!(tris.len(), 6);
3815        for &i in &tris {
3816            assert!(i < 5);
3817        }
3818    }
3819
3820    #[test]
3821    fn tessellate_polygon_handles_a_hole() {
3822        // 4x4 square with a 1x1 hole — earcut should still produce a valid
3823        // tessellation. Index count is implementation-dependent but must be a
3824        // multiple of 3 and reference valid vertex indices.
3825        let exterior: Vec<Coord> =
3826            vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0], [0.0, 0.0]];
3827        let hole: Vec<Coord> =
3828            vec![[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 2.0], [1.0, 1.0]];
3829        let tris = tessellate_polygon(&[exterior, hole]);
3830        assert!(tris.len() >= 6);
3831        assert_eq!(tris.len() % 3, 0);
3832        for &i in &tris {
3833            assert!(i < 10);
3834        }
3835    }
3836
3837    #[test]
3838    fn tessellate_polygon_handles_degenerate_input() {
3839        // No rings → empty result, not a panic.
3840        assert!(tessellate_polygon(&[]).is_empty());
3841        // Single 2-vert ring is below the 3-vertex minimum.
3842        let degenerate: Vec<Coord> = vec![[0.0, 0.0], [1.0, 1.0]];
3843        assert!(tessellate_polygon(&[degenerate]).is_empty());
3844    }
3845
3846    #[test]
3847    fn polygon_layer_with_triangles_roundtrips() {
3848        let exterior: Vec<Coord> =
3849            vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]];
3850        let tris = tessellate_polygon(&[exterior.clone()]);
3851        assert_eq!(tris.len(), 6);
3852        let layer = ColumnarLayer {
3853            name: "zones".into(),
3854            feature_ids: vec![42],
3855            start_times: vec![0],
3856            end_times: vec![1000],
3857            geometry: GeometryColumn::Polygon(vec![vec![exterior]]),
3858            vertex_times: None,
3859            vertex_values: None,
3860            triangles: Some(vec![tris.clone()]),
3861            vertex_value_matrix: None,
3862            properties: vec![],
3863        };
3864        let ipc = encode_layer(&layer).unwrap();
3865        let batch = decode_layer(&ipc).unwrap();
3866
3867        // Schema metadata advertises the sidecar.
3868        assert_eq!(
3869            batch
3870                .schema()
3871                .metadata()
3872                .get(TRIANGLES_METADATA_KEY)
3873                .map(String::as_str),
3874            Some("true")
3875        );
3876        // Column exists with the expected shape. Indices here are tiny
3877        // (well under u16::MAX), so the narrower UInt16 encoding applies.
3878        let col = batch
3879            .column_by_name("triangles")
3880            .expect("triangles column present")
3881            .as_any()
3882            .downcast_ref::<ListArray>()
3883            .expect("triangles is a List");
3884        assert_eq!(col.len(), 1);
3885        let first = col.value(0);
3886        let values: &arrow::array::UInt16Array = first
3887            .as_any()
3888            .downcast_ref::<arrow::array::UInt16Array>()
3889            .expect("triangle values are UInt16 for small feature-local indices");
3890        assert_eq!(
3891            values.values().iter().map(|&v| v as u32).collect::<Vec<_>>(),
3892            tris
3893        );
3894    }
3895
3896    #[test]
3897    fn polygon_layer_with_oversized_triangle_index_falls_back_to_uint32() {
3898        // A feature-local triangle index beyond u16::MAX (pathological, but
3899        // possible for a single giant polygon) must fall back to UInt32
3900        // rather than silently truncating.
3901        let exterior: Vec<Coord> =
3902            vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]];
3903        let big_tris = vec![0u32, 1, 70_000];
3904        let layer = ColumnarLayer {
3905            name: "zones".into(),
3906            feature_ids: vec![42],
3907            start_times: vec![0],
3908            end_times: vec![1000],
3909            geometry: GeometryColumn::Polygon(vec![vec![exterior]]),
3910            vertex_times: None,
3911            vertex_values: None,
3912            triangles: Some(vec![big_tris.clone()]),
3913            vertex_value_matrix: None,
3914            properties: vec![],
3915        };
3916        let ipc = encode_layer(&layer).unwrap();
3917        let batch = decode_layer(&ipc).unwrap();
3918
3919        let col = batch
3920            .column_by_name("triangles")
3921            .expect("triangles column present")
3922            .as_any()
3923            .downcast_ref::<ListArray>()
3924            .expect("triangles is a List");
3925        let first = col.value(0);
3926        let values: &arrow::array::UInt32Array = first
3927            .as_any()
3928            .downcast_ref::<arrow::array::UInt32Array>()
3929            .expect("triangle values fall back to UInt32 when an index exceeds u16::MAX");
3930        assert_eq!(values.values().to_vec(), big_tris);
3931    }
3932
3933    #[test]
3934    fn polygon_layer_without_triangles_skips_the_metadata_key() {
3935        // Backwards-compat guarantee: a v3 polygon layer that was NOT built
3936        // with pre-tessellation must not carry the metadata flag — otherwise
3937        // a reader would expect a column that isn't there.
3938        let layer = sample_polygon_layer();
3939        let ipc = encode_layer(&layer).unwrap();
3940        let batch = decode_layer(&ipc).unwrap();
3941        assert!(!batch.schema().metadata().contains_key(TRIANGLES_METADATA_KEY));
3942        assert!(batch.column_by_name("triangles").is_none());
3943    }
3944
3945    #[test]
3946    fn non_polygon_layer_drops_stray_triangles() {
3947        // A producer that mistakenly attaches `triangles` to a point or line
3948        // layer must not poison the wire format. The encoder silently drops
3949        // the column so the metadata key never appears.
3950        let mut layer = sample_point_layer();
3951        // Add a bogus per-feature triangle list. The encoder must ignore it.
3952        layer.triangles = Some(vec![vec![0, 1, 2]; layer.feature_ids.len()]);
3953        let ipc = encode_layer(&layer).unwrap();
3954        let batch = decode_layer(&ipc).unwrap();
3955        assert!(!batch.schema().metadata().contains_key(TRIANGLES_METADATA_KEY));
3956        assert!(batch.column_by_name("triangles").is_none());
3957    }
3958
3959    /// Walk a frame and return each layer's IPC start offset + length,
3960    /// honouring the aligned-frame padding rule.
3961    fn ipc_offsets(payload: &[u8]) -> Vec<(usize, usize)> {
3962        let raw = u16::from_le_bytes([payload[0], payload[1]]);
3963        let aligned = raw & ALIGNED_FRAME_FLAG != 0;
3964        let count = (raw & !ALIGNED_FRAME_FLAG) as usize;
3965        let mut pos = 2usize;
3966        let mut out = Vec::new();
3967        for _ in 0..count {
3968            let name_len =
3969                u16::from_le_bytes([payload[pos], payload[pos + 1]]) as usize;
3970            pos += 2 + name_len;
3971            let ipc_len = u32::from_le_bytes([
3972                payload[pos],
3973                payload[pos + 1],
3974                payload[pos + 2],
3975                payload[pos + 3],
3976            ]) as usize;
3977            pos += 4;
3978            if aligned {
3979                pos += (FRAME_ALIGN - pos % FRAME_ALIGN) % FRAME_ALIGN;
3980            }
3981            out.push((pos, ipc_len));
3982            pos += ipc_len;
3983        }
3984        out
3985    }
3986
3987    #[test]
3988    fn encoded_frames_align_every_ipc_stream_to_8_bytes() {
3989        // Layer names of varying lengths so the unpadded offsets would land
3990        // all over the place; the aligned frame must place every IPC stream
3991        // at an 8-byte boundary regardless.
3992        let mut a = sample_line_layer();
3993        a.name = "x".into();
3994        let mut b = sample_point_layer();
3995        b.name = "a-longer-layer-name".into();
3996        let payload = encode_tile(&[a, b]).unwrap();
3997
3998        let raw = u16::from_le_bytes([payload[0], payload[1]]);
3999        assert_ne!(raw & ALIGNED_FRAME_FLAG, 0, "writer must set the aligned flag");
4000
4001        let offsets = ipc_offsets(&payload);
4002        assert_eq!(offsets.len(), 2);
4003        for (off, _) in &offsets {
4004            assert_eq!(off % 8, 0, "IPC stream at offset {off} is misaligned");
4005        }
4006
4007        // And the padded frame still round-trips.
4008        let decoded = decode_tile(&payload).unwrap();
4009        assert_eq!(decoded[0].name, "x");
4010        assert_eq!(decoded[1].name, "a-longer-layer-name");
4011        assert_eq!(decoded[0].batch.num_rows(), 2);
4012        assert_eq!(decoded[1].batch.num_rows(), 3);
4013    }
4014
4015    #[test]
4016    fn legacy_unpadded_frames_still_decode() {
4017        // Rebuild an old-style frame (no flag, no padding) from the layers'
4018        // IPC bytes — the shape every pre-alignment archive carries — and
4019        // assert the decoder reproduces the aligned frame's batches.
4020        let layers = vec![sample_line_layer(), sample_point_layer()];
4021        let aligned_payload = encode_tile(&layers).unwrap();
4022        let aligned = decode_tile(&aligned_payload).unwrap();
4023
4024        let mut legacy: Vec<u8> = Vec::new();
4025        legacy.extend_from_slice(&(layers.len() as u16).to_le_bytes());
4026        for ((off, len), layer) in ipc_offsets(&aligned_payload).iter().zip(&layers) {
4027            let name = layer.name.as_bytes();
4028            legacy.extend_from_slice(&(name.len() as u16).to_le_bytes());
4029            legacy.extend_from_slice(name);
4030            legacy.extend_from_slice(&(*len as u32).to_le_bytes());
4031            legacy.extend_from_slice(&aligned_payload[*off..*off + *len]);
4032        }
4033
4034        let decoded = decode_tile(&legacy).unwrap();
4035        assert_eq!(decoded.len(), aligned.len());
4036        for (l, a) in decoded.iter().zip(&aligned) {
4037            assert_eq!(l.name, a.name);
4038            assert_eq!(l.batch, a.batch);
4039        }
4040    }
4041
4042    #[test]
4043    fn truncated_tile_frame_errors_cleanly() {
4044        let payload = encode_tile(&[sample_point_layer()]).unwrap();
4045        // Chop the payload mid-stream; decode must error, not panic.
4046        let truncated = &payload[..payload.len() / 2];
4047        assert!(decode_tile(truncated).is_err());
4048    }
4049
4050    #[test]
4051    fn length_mismatch_is_rejected() {
4052        let mut layer = sample_point_layer();
4053        layer.start_times.pop(); // now 2 entries vs 3 features
4054        assert!(encode_layer(&layer).is_err());
4055    }
4056
4057    /// The v1 test's scenario through the V2 path: a length-inconsistent
4058    /// layer must be the same descriptive Err, not an index-out-of-bounds
4059    /// panic inside `sort_rows_by_start_time`'s column permutation. The
4060    /// start times are UNSORTED so the pre-sort actually permutes (a sorted
4061    /// layer short-circuits before touching the truncated column).
4062    #[test]
4063    fn length_mismatch_is_rejected_by_v2_frame_too() {
4064        let mut layer = sample_point_layer();
4065        layer.start_times = vec![3000, 1000, 2000];
4066        layer.end_times.pop(); // now 2 entries vs 3 features
4067        let err = encode_tile_with(
4068            &[layer],
4069            &EncoderConfig {
4070                format_version: FORMAT_VERSION_V2,
4071                ..EncoderConfig::default()
4072            },
4073        )
4074        .expect_err("length-inconsistent layer must Err through the v2 path");
4075        assert!(err.to_string().contains("end_times"), "got: {err}");
4076    }
4077
4078    /// The v1 frame's leading u16 has only 15 bits for the layer count (bit
4079    /// 15 is the aligned flag), so counts in [0x8000, 0xFFFE] would OR into
4080    /// the flag as silently-mangled smaller counts, and 0x7FFF collides with
4081    /// the v2 escape. The guard must reject the ENTIRE range, not just the
4082    /// escape collision.
4083    #[test]
4084    fn v1_frame_rejects_unrepresentable_layer_counts() {
4085        // Boundary on the extracted predicate (cheap — no encoding).
4086        assert!(v1_layer_count_ok(0));
4087        assert!(v1_layer_count_ok(0x7FFE), "max representable count");
4088        assert!(!v1_layer_count_ok(0x7FFF), "v2-escape collision");
4089        assert!(!v1_layer_count_ok(0x8000), "bit-15 OR is a no-op → count 0");
4090        assert!(!v1_layer_count_ok(0xFFFE), "top of the mangled range");
4091        assert!(!v1_layer_count_ok(0x10000));
4092
4093        // End-to-end: a 0x8000-layer tile previously ENCODED with a mangled
4094        // count of 0; now a descriptive error. The guard runs before any
4095        // per-layer encode, so 32Ki empty layers are cheap.
4096        let empty = ColumnarLayer {
4097            name: "l".to_string(),
4098            feature_ids: vec![],
4099            start_times: vec![],
4100            end_times: vec![],
4101            geometry: GeometryColumn::Point(vec![]),
4102            vertex_times: None,
4103            vertex_values: None,
4104            triangles: None,
4105            vertex_value_matrix: None,
4106            properties: vec![],
4107        };
4108        let layers = vec![empty; 0x8000];
4109        let err = encode_tile_with(&layers, &EncoderConfig::default())
4110            .expect_err("0x8000 layers must be rejected");
4111        assert!(err.to_string().contains("frame limit"), "got: {err}");
4112    }
4113
4114    #[test]
4115    fn offsets_from_counts_errors_on_i32_overflow() {
4116        // Accumulating past i32::MAX must be a hard error, not a silent wrap
4117        // (release builds would otherwise emit corrupt Arrow list offsets).
4118        let ok = offsets_from_counts([3usize, 2, 0].into_iter()).unwrap();
4119        assert_eq!(ok.len(), 4); // N+1 offsets
4120
4121        let at_limit = offsets_from_counts([i32::MAX as usize].into_iter());
4122        assert!(at_limit.is_ok(), "exactly i32::MAX vertices still fits");
4123
4124        let over = offsets_from_counts([i32::MAX as usize, 1].into_iter())
4125            .expect_err("i32::MAX + 1 total vertices must error");
4126        assert!(over.to_string().contains("32-bit list offsets"), "got: {over}");
4127
4128        // A single count beyond i32::MAX errors too (the per-count try_from).
4129        assert!(offsets_from_counts([usize::MAX].into_iter()).is_err());
4130    }
4131
4132    #[test]
4133    fn quantize_precision_below_floor_is_rejected() {
4134        // 1 mm would put the ±180° longitude index past i32::MAX. Both the
4135        // explicit-config path and the global setter must reject it with the
4136        // minimum in the message; a precision at/above the floor encodes fine.
4137        let layer = sample_point_layer();
4138        let err = encode_layer_with(
4139            &layer,
4140            &EncoderConfig {
4141                quantize_coords_m: Some(0.001),
4142                ..EncoderConfig::default()
4143            },
4144        )
4145        .expect_err("1 mm precision must be rejected");
4146        assert!(
4147            err.to_string().contains("minimum") && err.to_string().contains("overflow"),
4148            "error must state the minimum: {err}"
4149        );
4150
4151        assert!(0.0187 > MIN_QUANTIZE_COORDS_M);
4152        assert!(encode_layer_with(
4153            &layer,
4154            &EncoderConfig {
4155                quantize_coords_m: Some(0.0187),
4156                ..EncoderConfig::default()
4157            },
4158        )
4159        .is_ok());
4160
4161        // Global setter: rejects the same value WITHOUT storing it; <= 0
4162        // (off) still passes. (No positive value is stored here so the test
4163        // can't leak quantization into concurrently running global-path tests.)
4164        assert!(set_quantize_coords_m(0.001).is_err());
4165        assert!(set_quantize_coords_m(0.0).is_ok());
4166    }
4167
4168    #[test]
4169    fn quantized_altitude_outside_i32_errors_instead_of_clamping() {
4170        // The z axis is unbounded input: a value whose grid index leaves i32
4171        // must error (identifying the value), not clamp to ±i32::MAX quanta.
4172        let make = |z: f64| ColumnarLayer {
4173            name: "cloud".into(),
4174            feature_ids: vec![1],
4175            start_times: vec![0],
4176            end_times: vec![0],
4177            geometry: GeometryColumn::Point(vec![[-122.4, 37.7]]),
4178            vertex_times: None,
4179            vertex_values: None,
4180            triangles: None,
4181            vertex_value_matrix: None,
4182            properties: vec![("z".into(), PropertyColumn::Numeric(vec![Some(z)]))],
4183        };
4184        let cfg = EncoderConfig {
4185            quantize_coords_m: Some(0.05),
4186            point_elevation_column: "z".to_string(),
4187            ..EncoderConfig::default()
4188        };
4189        // Sane altitude still encodes.
4190        assert!(encode_layer_with(&make(5.0), &cfg).is_ok());
4191        // 1e18 m at a 0.05 m step → index 2e19, far outside i32.
4192        let err = encode_layer_with(&make(1.0e18), &cfg)
4193            .expect_err("overflowing altitude must error");
4194        let msg = err.to_string();
4195        // f64 Display renders 1.0e18 as its full integer form.
4196        assert!(
4197            msg.contains("altitude") && msg.contains("1000000000000000000"),
4198            "got: {msg}"
4199        );
4200    }
4201
4202    #[test]
4203    fn quantized_attr_index_beyond_i32_errors_instead_of_clamping() {
4204        // An attribute whose quantized index exceeds i32::MAX must error
4205        // (mirroring the dictionary-overflow error), not clamp to i32::MAX.
4206        let layer = ColumnarLayer {
4207            name: "wide".into(),
4208            feature_ids: vec![1, 2],
4209            start_times: vec![0; 2],
4210            end_times: vec![1; 2],
4211            geometry: GeometryColumn::Point(vec![[0.0, 0.0]; 2]),
4212            vertex_times: None,
4213            vertex_values: None,
4214            triangles: None,
4215            vertex_value_matrix: None,
4216            properties: vec![(
4217                "v".into(),
4218                PropertyColumn::Numeric(vec![Some(0.0), Some(3.0e9)]),
4219            )],
4220        };
4221        let err = encode_layer_with(
4222            &layer,
4223            &EncoderConfig {
4224                quantize_attrs: HashMap::from([("v".to_string(), 1.0f64)]),
4225                ..EncoderConfig::default()
4226            },
4227        )
4228        .expect_err("index 3e9 > i32::MAX must error");
4229        let msg = err.to_string();
4230        assert!(msg.contains("overflows") && msg.contains("3000000000"), "got: {msg}");
4231    }
4232
4233    // ------------------------------------------------------------------
4234    // Layer frame v2 (packed formatVersion 2)
4235    // ------------------------------------------------------------------
4236
4237    /// v2 config in SELF-CONTAINED mode (inline schema sections, no registry
4238    /// needed to decode), layered over `base`.
4239    fn v2_inline(base: &EncoderConfig) -> EncoderConfig {
4240        EncoderConfig {
4241            format_version: FORMAT_VERSION_V2,
4242            template_collector: None,
4243            ..base.clone()
4244        }
4245    }
4246
4247    /// v2 config in HASH-REFERENCING mode (templates recorded with
4248    /// `collector`; frames carry 16-byte hashes), layered over `base`.
4249    fn v2_hashed(base: &EncoderConfig, collector: &Arc<TemplateCollector>) -> EncoderConfig {
4250        EncoderConfig {
4251            format_version: FORMAT_VERSION_V2,
4252            template_collector: Some(Arc::clone(collector)),
4253            ..base.clone()
4254        }
4255    }
4256
4257    /// Decode-side registry over everything `collector` recorded.
4258    fn registry_from(collector: &TemplateCollector) -> TemplateRegistry {
4259        let mut registry = TemplateRegistry::new();
4260        for (_, template) in collector.snapshot() {
4261            registry.insert(template);
4262        }
4263        registry
4264    }
4265
4266    /// The v1-equivalence contract (design §4.3 / merge_v2_layer): the SAME
4267    /// layers encoded v1 and v2 (both v2 modes) decode to EQUAL
4268    /// `DecodedLayer`s — batch equality covers columns AND schema/field
4269    /// metadata, i.e. the TILE_META re-injection must reproduce the v1
4270    /// metadata byte-for-byte.
4271    fn assert_v2_decodes_like_v1(layers: &[ColumnarLayer], base: &EncoderConfig, what: &str) {
4272        let v1 = decode_tile(&encode_tile_with(layers, base).unwrap()).unwrap();
4273
4274        let inline = decode_tile(&encode_tile_with(layers, &v2_inline(base)).unwrap()).unwrap();
4275        assert_eq!(inline.len(), v1.len(), "{what}: inline layer count");
4276        for (a, b) in inline.iter().zip(&v1) {
4277            assert_eq!(a.name, b.name, "{what}: inline layer name");
4278            assert_eq!(a.batch, b.batch, "{what}: inline v2 decode != v1 decode");
4279        }
4280
4281        let collector = Arc::new(TemplateCollector::new());
4282        let payload = encode_tile_with(layers, &v2_hashed(base, &collector)).unwrap();
4283        let registry = registry_from(&collector);
4284        let hashed = decode_tile_with_templates(&payload, &registry).unwrap();
4285        assert_eq!(hashed.len(), v1.len(), "{what}: hashed layer count");
4286        for (a, b) in hashed.iter().zip(&v1) {
4287            assert_eq!(a.name, b.name, "{what}: hashed layer name");
4288            assert_eq!(a.batch, b.batch, "{what}: hashed v2 decode != v1 decode");
4289        }
4290    }
4291
4292    /// Every payload shape the v2 break touches round-trips to EXACTLY the v1
4293    /// decode: plain + quantized points, dictionary props (incl. TWO
4294    /// dictionary columns), u16-delta AND exact-Int64 vertex_time, the
4295    /// vertex-value matrix, pre-tessellated triangles, an empty-bucket tile
4296    /// (zero rows, dictionary column intact), and a multi-layer tile.
4297    #[test]
4298    fn v2_roundtrip_equals_v1_decode_across_payload_shapes() {
4299        let plain = EncoderConfig::default();
4300        let quant = EncoderConfig {
4301            quantize_coords_m: Some(1.0),
4302            quantize_attrs_auto: true,
4303            ..EncoderConfig::default()
4304        };
4305
4306        let two_dicts = ColumnarLayer {
4307            properties: vec![
4308                (
4309                    "kind".to_string(),
4310                    PropertyColumn::Categorical(vec![
4311                        Some("car".into()),
4312                        Some("bus".into()),
4313                        None,
4314                    ]),
4315                ),
4316                (
4317                    "color".to_string(),
4318                    PropertyColumn::Categorical(vec![
4319                        Some("red".into()),
4320                        None,
4321                        Some("blue".into()),
4322                    ]),
4323                ),
4324            ],
4325            ..sample_point_layer()
4326        };
4327
4328        let wide_span_vt = ColumnarLayer {
4329            vertex_times: Some(vec![vec![0, 100_000_000_000], vec![0, 1]]),
4330            ..sample_line_layer()
4331        };
4332
4333        let matrix = ColumnarLayer {
4334            name: "flows".into(),
4335            feature_ids: vec![1, 2],
4336            start_times: vec![0, 0],
4337            end_times: vec![1800, 1800],
4338            geometry: GeometryColumn::LineString(vec![
4339                vec![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]],
4340                vec![[3.0, 3.0], [4.0, 4.0]],
4341            ]),
4342            vertex_times: None,
4343            vertex_values: None,
4344            triangles: None,
4345            vertex_value_matrix: Some(vec![
4346                vec![10.0, 11.0, 20.0, 21.0, 30.0, 31.0],
4347                vec![40.0, 41.0, 50.0, 51.0],
4348            ]),
4349            properties: vec![],
4350        };
4351
4352        let exterior: Vec<Coord> =
4353            vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]];
4354        let triangles = ColumnarLayer {
4355            triangles: Some(vec![tessellate_polygon(&[exterior.clone()])]),
4356            geometry: GeometryColumn::Polygon(vec![vec![exterior]]),
4357            ..sample_polygon_layer()
4358        };
4359
4360        let empty = ColumnarLayer {
4361            name: "points".into(),
4362            feature_ids: vec![],
4363            start_times: vec![],
4364            end_times: vec![],
4365            geometry: GeometryColumn::Point(vec![]),
4366            vertex_times: None,
4367            vertex_values: None,
4368            triangles: None,
4369            vertex_value_matrix: None,
4370            properties: vec![
4371                ("speed".into(), PropertyColumn::Numeric(vec![])),
4372                ("kind".into(), PropertyColumn::Categorical(vec![])),
4373            ],
4374        };
4375
4376        assert_v2_decodes_like_v1(&[sample_point_layer()], &plain, "points");
4377        assert_v2_decodes_like_v1(&[sample_point_layer()], &quant, "quantized points");
4378        assert_v2_decodes_like_v1(&[two_dicts], &plain, "two dictionary columns");
4379        assert_v2_decodes_like_v1(&[sample_line_layer()], &plain, "u16-delta vertex_time");
4380        assert_v2_decodes_like_v1(&[wide_span_vt], &plain, "exact Int64 vertex_time");
4381        assert_v2_decodes_like_v1(&[matrix], &plain, "vertex-value matrix");
4382        assert_v2_decodes_like_v1(&[triangles], &plain, "pre-tessellated triangles");
4383        assert_v2_decodes_like_v1(&[empty], &quant, "empty-bucket tile");
4384        assert_v2_decodes_like_v1(
4385            &[sample_line_layer(), sample_point_layer()],
4386            &plain,
4387            "multi-layer tile",
4388        );
4389    }
4390
4391    /// v2 row order (design §4.2 ★F10): rows come out stable-sorted by
4392    /// `start_time`, ids travel WITH their rows (the sort runs after id
4393    /// assignment), and the result equals the v1 decode of the pre-sorted
4394    /// layer. v1 encoding of the same input stays in input order.
4395    #[test]
4396    fn v2_rows_stable_sorted_by_start_time_after_id_assignment() {
4397        let unsorted = ColumnarLayer {
4398            name: "points".into(),
4399            feature_ids: vec![1, 2, 3, 4],
4400            start_times: vec![3000, 1000, 2000, 1000],
4401            end_times: vec![3500, 1500, 2500, 1600],
4402            geometry: GeometryColumn::Point(vec![
4403                [-122.4, 37.7],
4404                [-122.5, 37.8],
4405                [-122.6, 37.9],
4406                [-122.7, 38.0],
4407            ]),
4408            vertex_times: None,
4409            vertex_values: None,
4410            triangles: None,
4411            vertex_value_matrix: None,
4412            properties: vec![(
4413                "kind".into(),
4414                PropertyColumn::Categorical(vec![
4415                    Some("a".into()),
4416                    Some("b".into()),
4417                    Some("c".into()),
4418                    Some("d".into()),
4419                ]),
4420            )],
4421        };
4422
4423        let decoded =
4424            decode_tile(&encode_tile_with(&[unsorted.clone()], &v2_inline(&EncoderConfig::default())).unwrap())
4425                .unwrap();
4426        let batch = &decoded[0].batch;
4427        let starts = batch
4428            .column_by_name("start_time")
4429            .unwrap()
4430            .as_any()
4431            .downcast_ref::<Int64Array>()
4432            .unwrap()
4433            .values()
4434            .to_vec();
4435        // Stable: the two 1000s keep input order (id 2 before id 4).
4436        assert_eq!(starts, vec![1000, 1000, 2000, 3000]);
4437        let ids = batch
4438            .column_by_name("id")
4439            .unwrap()
4440            .as_any()
4441            .downcast_ref::<UInt64Array>()
4442            .unwrap()
4443            .values()
4444            .to_vec();
4445        assert_eq!(ids, vec![2, 4, 3, 1], "ids must travel with their rows");
4446
4447        // Equivalent to v1-encoding the manually pre-sorted layer.
4448        let presorted = sort_rows_by_start_time(&unsorted).into_owned();
4449        let v1 = decode_tile(&encode_tile_with(&[presorted], &EncoderConfig::default()).unwrap())
4450            .unwrap();
4451        assert_eq!(batch, &v1[0].batch);
4452    }
4453
4454    /// Template constancy (design §3.1d): tiles differing ONLY per-tile —
4455    /// qa affines, t0, dictionary categories, row counts — share ONE
4456    /// CORE + ONE PROPS template. Type variants (u16-delta vs exact-Int64
4457    /// vertex_time) legitimately mint DISTINCT templates (§2.2 cardinality),
4458    /// and every recorded template resolves through the registry.
4459    #[test]
4460    fn v2_template_constancy_and_type_variant_cardinality() {
4461        let quant = EncoderConfig {
4462            quantize_coords_m: Some(1.0),
4463            quantize_attrs_auto: true,
4464            ..EncoderConfig::default()
4465        };
4466        let collector = Arc::new(TemplateCollector::new());
4467        let cfg = v2_hashed(&quant, &collector);
4468
4469        let tile = |seed: i64, cats: [&str; 2], n: usize| ColumnarLayer {
4470            name: "points".into(),
4471            feature_ids: (0..n as u64).collect(),
4472            start_times: (0..n as i64).map(|i| seed + i * 250).collect(),
4473            end_times: (0..n as i64).map(|i| seed + i * 250 + 100).collect(),
4474            geometry: GeometryColumn::Point(
4475                (0..n).map(|i| [-122.0 + i as f64 * 1e-3, 37.0]).collect(),
4476            ),
4477            vertex_times: None,
4478            vertex_values: None,
4479            triangles: None,
4480            vertex_value_matrix: None,
4481            properties: vec![
4482                (
4483                    "speed".into(),
4484                    PropertyColumn::Numeric(
4485                        (0..n).map(|i| Some(seed as f64 * 0.01 + i as f64)).collect(),
4486                    ),
4487                ),
4488                (
4489                    "kind".into(),
4490                    PropertyColumn::Categorical(
4491                        (0..n).map(|i| Some(cats[i % 2].to_string())).collect(),
4492                    ),
4493                ),
4494            ],
4495        };
4496
4497        let a = encode_tile_with(&[tile(1_000_000, ["car", "bus"], 3)], &cfg).unwrap();
4498        let b = encode_tile_with(&[tile(9_000_000, ["tram", "ferry"], 5)], &cfg).unwrap();
4499        assert_ne!(a, b, "per-tile content must still differ");
4500        assert_eq!(
4501            collector.len(),
4502            2,
4503            "qa/t0/category/row-count variance must NOT mint new templates (core+props)"
4504        );
4505
4506        // Type variants: narrow-span (u16-delta) vs wide-span (Int64)
4507        // vertex_time change the CORE schema → one new template each.
4508        let narrow = sample_line_layer();
4509        let wide = ColumnarLayer {
4510            vertex_times: Some(vec![vec![0, 100_000_000_000], vec![0, 1]]),
4511            ..sample_line_layer()
4512        };
4513        let c = encode_tile_with(&[narrow], &cfg).unwrap();
4514        let d = encode_tile_with(&[wide], &cfg).unwrap();
4515        assert_eq!(collector.len(), 4, "u16 vs Int64 vertex_time are distinct templates");
4516
4517        // Every frame resolves through a registry built from the collector.
4518        let registry = registry_from(&collector);
4519        for payload in [&a, &b, &c, &d] {
4520            decode_tile_with_templates(payload, &registry).unwrap();
4521        }
4522    }
4523
4524    /// TILE_META canonical serialization (design §4.3): alphabetical keys, no
4525    /// whitespace, and unknown keys are ignored on decode (additive contract).
4526    #[test]
4527    fn v2_tile_meta_is_canonical_json_and_ignores_unknown_keys() {
4528        let meta = TileMeta {
4529            qa: Some(BTreeMap::from([("speed".to_string(), (0.0, 0.15))])),
4530            sorted: Some(true),
4531            t0: Some(1_577_836_800_000),
4532            vb: Some(24),
4533            vt: Some((1_577_836_800_000, 1000)),
4534        };
4535        assert_eq!(
4536            serde_json::to_string(&meta).unwrap(),
4537            r#"{"qa":{"speed":[0.0,0.15]},"sorted":true,"t0":1577836800000,"vb":24,"vt":[1577836800000,1000]}"#
4538        );
4539        // Presence rules: absent features serialize NO key at all.
4540        assert_eq!(serde_json::to_string(&TileMeta::default()).unwrap(), "{}");
4541        // Unknown keys from a future writer must be ignored, not rejected.
4542        let parsed: TileMeta =
4543            serde_json::from_str(r#"{"sorted":true,"zz_future":{"x":1}}"#).unwrap();
4544        assert_eq!(parsed.sorted, Some(true));
4545    }
4546
4547    /// Walk a SINGLE-layer v2 frame's header and return each section's
4548    /// `(tag, payload_offset, len)` — test-side mirror of the decoder's TOC
4549    /// walk, so guard tests can doctor exact section bytes.
4550    fn v2_section_spans(payload: &[u8]) -> Vec<(u8, usize, usize)> {
4551        assert!(is_frame_v2(payload));
4552        let mut pos = 6usize; // escape + frame_version + flags + layer_count
4553        let name_len = u16::from_le_bytes([payload[pos], payload[pos + 1]]) as usize;
4554        pos += 2 + name_len;
4555        for _ in 0..2 {
4556            let kind = payload[pos];
4557            pos += 1;
4558            if kind == REF_KIND_TEMPLATE_HASH {
4559                pos += 16;
4560            }
4561        }
4562        let section_count = payload[pos] as usize;
4563        pos += 1;
4564        let mut toc = Vec::with_capacity(section_count);
4565        for _ in 0..section_count {
4566            let tag = payload[pos];
4567            let len = u32::from_le_bytes(payload[pos + 1..pos + 5].try_into().unwrap()) as usize;
4568            toc.push((tag, len));
4569            pos += 5;
4570        }
4571        pos += (FRAME_ALIGN - pos % FRAME_ALIGN) % FRAME_ALIGN;
4572        let mut spans = Vec::with_capacity(section_count);
4573        for (tag, len) in toc {
4574            spans.push((tag, pos, len));
4575            pos += len;
4576            pos += (FRAME_ALIGN - pos % FRAME_ALIGN) % FRAME_ALIGN;
4577        }
4578        spans
4579    }
4580
4581    /// Splice guards (design §3.4): stray zeros at a batch section's head
4582    /// must ERROR LOUDLY — under arrow-rs they'd otherwise parse as a legacy
4583    /// 4-byte end-of-stream and silently EMPTY the tile.
4584    #[test]
4585    fn v2_stray_zeros_in_batch_section_error_instead_of_empty_tile() {
4586        let payload =
4587            encode_tile_with(&[sample_point_layer()], &v2_inline(&EncoderConfig::default()))
4588                .unwrap();
4589        let (_, off, len) = *v2_section_spans(&payload)
4590            .iter()
4591            .find(|(tag, _, _)| *tag == SECTION_CORE_BATCH)
4592            .expect("CORE_BATCH present");
4593        assert!(len > 4);
4594
4595        let mut doctored = payload.clone();
4596        doctored[off..off + 4].fill(0);
4597        let err = decode_tile(&doctored).expect_err("zeroed continuation must error");
4598        assert!(
4599            err.to_string().contains("0xFFFFFFFF"),
4600            "error must name the continuation guard: {err}"
4601        );
4602
4603        // Direct splice guard: both halves are checked.
4604        let template = &payload[..4]; // any bytes NOT starting with FFFFFFFF
4605        assert!(splice_decode(&[0u8; 8], template, "guard").is_err());
4606    }
4607
4608    /// Frame guards: truncations anywhere in the header error cleanly, and a
4609    /// TOC length overrunning the payload is rejected before Arrow sees it.
4610    #[test]
4611    fn v2_truncated_header_and_lying_toc_length_error() {
4612        let payload =
4613            encode_tile_with(&[sample_point_layer()], &v2_inline(&EncoderConfig::default()))
4614                .unwrap();
4615        let first_section_off = v2_section_spans(&payload)[0].1;
4616        for cut in 0..first_section_off {
4617            assert!(decode_tile(&payload[..cut]).is_err(), "cut at {cut} must error");
4618        }
4619
4620        // Inflate the first TOC length (u32 after the 1-byte tag): the
4621        // decoder's bounds-checked read must reject it, not over-read.
4622        let mut doctored = payload.clone();
4623        let toc0 = first_toc_offset(&payload);
4624        doctored[toc0 + 1..toc0 + 5].copy_from_slice(&u32::MAX.to_le_bytes());
4625        let err = decode_tile(&doctored).expect_err("overrunning TOC length must error");
4626        assert!(err.to_string().contains("truncated"), "got: {err}");
4627    }
4628
4629    /// Byte offset of a single-layer v2 frame's FIRST TOC entry.
4630    fn first_toc_offset(payload: &[u8]) -> usize {
4631        let name_len = u16::from_le_bytes([payload[6], payload[7]]) as usize;
4632        let mut pos = 8 + name_len;
4633        for _ in 0..2 {
4634            let kind = payload[pos];
4635            pos += 1;
4636            if kind == REF_KIND_TEMPLATE_HASH {
4637                pos += 16;
4638            }
4639        }
4640        pos + 1 // past section_count
4641    }
4642
4643    /// Unknown section tags are SKIPPED via their TOC length (additive
4644    /// evolution): re-tagging TILE_META to an unknown tag still decodes the
4645    /// batch — only the re-injected per-tile metadata disappears.
4646    #[test]
4647    fn v2_unknown_section_tag_is_skipped() {
4648        let payload =
4649            encode_tile_with(&[sample_point_layer()], &v2_inline(&EncoderConfig::default()))
4650                .unwrap();
4651        let toc0 = first_toc_offset(&payload);
4652        let mut doctored = payload.clone();
4653        // TOC entries are (u8 tag, u32 len); find TILE_META's entry.
4654        let section_count = doctored[toc0 - 1] as usize;
4655        let mut retagged = false;
4656        for i in 0..section_count {
4657            let at = toc0 + i * 5;
4658            if doctored[at] == SECTION_TILE_META {
4659                doctored[at] = 0x6f; // unknown tag
4660                retagged = true;
4661            }
4662        }
4663        assert!(retagged);
4664        let decoded = decode_tile(&doctored).unwrap();
4665        assert_eq!(decoded[0].batch.num_rows(), 3);
4666        assert!(
4667            decoded[0].batch.schema().metadata().get(TIME_OFFSET_MS_KEY).is_none(),
4668            "skipped TILE_META means no t0 re-injection"
4669        );
4670    }
4671
4672    /// A hash-referencing v2 frame decoded WITHOUT (or with an incomplete)
4673    /// registry is a descriptive error naming the fix — never a panic.
4674    #[test]
4675    fn v2_hash_frame_without_registry_errors_descriptively() {
4676        let collector = Arc::new(TemplateCollector::new());
4677        let payload = encode_tile_with(
4678            &[sample_point_layer()],
4679            &v2_hashed(&EncoderConfig::default(), &collector),
4680        )
4681        .unwrap();
4682
4683        let err = decode_tile(&payload).expect_err("no registry must error");
4684        assert!(
4685            err.to_string().contains("decode_tile_with_templates"),
4686            "error must point at the registry entry point: {err}"
4687        );
4688
4689        let empty = TemplateRegistry::new();
4690        let err = decode_tile_with_templates(&payload, &empty)
4691            .expect_err("incomplete registry must error");
4692        assert!(
4693            err.to_string().contains("not in the dataset's registry"),
4694            "got: {err}"
4695        );
4696    }
4697
4698    /// The strip is tail-invariant (design §3.6, spike-proven): hoisting the
4699    /// per-tile metadata out of the schema changes ONLY the schema message —
4700    /// two tiles differing in per-tile metadata alone share byte-identical
4701    /// templates while their tails differ, which is exactly what makes
4702    /// cross-tile template dedup sound.
4703    #[test]
4704    fn v2_metadata_strip_leaves_template_constant_and_tails_differing() {
4705        let quant = EncoderConfig {
4706            quantize_attrs_auto: true,
4707            ..EncoderConfig::default()
4708        };
4709        let mut early = sample_point_layer();
4710        // Different t0 + different qa affine (value range) per tile.
4711        for t in early.start_times.iter_mut() {
4712            *t += 7_000;
4713        }
4714        let late = sample_point_layer();
4715
4716        let a = encode_layer_v2_parts(&early, &quant).unwrap();
4717        let b = encode_layer_v2_parts(&late, &quant).unwrap();
4718        assert_eq!(a.core_template, b.core_template, "CORE template must be constant");
4719        let (a_props_template, a_props_tail) = a.props.as_ref().unwrap();
4720        let (b_props_template, b_props_tail) = b.props.as_ref().unwrap();
4721        assert_eq!(a_props_template, b_props_template, "PROPS template must be constant");
4722        assert_ne!(a.core_tail, b.core_tail, "t0 shift must land in the tail");
4723        assert_ne!(a.tile_meta_json, b.tile_meta_json, "TILE_META varies per tile");
4724        // Same qa affine + same categories here → identical props tails is
4725        // fine; what matters is templates never absorb per-tile variance.
4726        let _ = (a_props_tail, b_props_tail);
4727    }
4728}