Gunnar

nordisk/znippy-zoomies

.nornir/geoparquet-spatial-layout-design.md

<!-- nornir:class: design -->
# GeoParquet spatial layout — one file, read in pieces (2026-07-22)

> Answers Rickard's question, verbatim:
>
> *"hmm 130mb I think I put on the wiki. Would it be multiple small files? Read when close to
> the point? OSM 2D and 3D needs to handle that, like a tile server? How does GeoParquet suppose
> to work?"* — and the follow-up: *"well make osm 3d 2d able to handle lfs git?"*
>
> **Short answer.** Not multiple small files. **One** file, read in pieces by HTTP byte range —
> a properly written GeoParquet *is its own spatial index*. osm-katana's output was not properly
> written: rows came out in PBF parse order, so every row group spanned the whole extract and
> nothing could ever be pruned. Fixed. For one city block in Stockholm the node table now reads
> **792011 bytes instead of 79189221** — **100× fewer** — in **4 row groups out of 575**.
>
> And yes: **Codeberg's git-LFS serves HTTP Range requests** (measured, §4). So a Hilbert-packed
> GeoParquet committed through LFS behaves like a tile server with no tile server. There is one
> trap that would silently defeat it, and the reader is built around it.
>
> Companion: [`workspace_nordisk/.nornir/demo-map-data-sizing.md`](../../workspace_nordisk/.nornir/demo-map-data-sizing.md)
> (the measured sizes this builds on — Sweden 2.28 GiB, Stockholm 130.0 MiB, POI layer 5.3 MiB).

**Host stamp.** All figures measured on **oden** (`NORNIR_MACHINE=oden`, Threadripper PRO 3975WX,
32 physical cores, SMT off, 499 GB RAM, NVIDIA RTX 4090), 2026-07-22. A **new host row** — never
merged with `loki · 12 cores`.

⚠ **Load caveat.** The box carried other agents throughout; `uptime` load averages during this
work ranged **44 → 228**. Every headline number below is a **byte count or a row-group count read
out of the Parquet footer** — deterministic, load-independent, and reproducible under any load.
No wall-clock number is used to justify any decision. The two timings that do appear are labelled
as taken under load and are not load-bearing.

---

## 1. How GeoParquet is *supposed* to work

Not "one big blob you download". A Parquet file is a **self-describing, seekable archive**:

```
┌──────────────────────────────────────────────────────── nodes.parquet ────────┐
│ row group 0   │ row group 1   │ … │ row group 574 │            FOOTER         │
│ ┌───────────┐ │ ┌───────────┐ │   │ ┌───────────┐ │  per row group, per column│
│ │id  chunk  │ │ │id  chunk  │ │   │ │id  chunk  │ │    · byte offset + length │
│ │geom chunk │ │ │geom chunk │ │   │ │geom chunk │ │    · MIN / MAX statistics │
│ │bbox chunks│ │ │bbox chunks│ │   │ │bbox chunks│ │    · row count            │
│ └───────────┘ │ └───────────┘ │   │ └───────────┘ │  + the `geo` metadata key │
└───────────────────────────────────────────────────────────────────────────────┘
```

A reader that wants "everything near this point" does exactly three things:

1. **Read the footer** (one range request at the end of the file).
2. **Compare** each row group's min/max against the viewport. Discard the ones that provably
   cannot match — without reading a byte of their data.
3. **Fetch the surviving column chunks** by their recorded byte offsets — a handful of
   `Range: bytes=A-B` requests.

That is the whole mechanism, and it is why the answer to *"multiple small files?"* is **no**.
Splitting into many small files is a strictly worse version of the same idea: it moves the index
out of the file and into the filesystem/URL layout, costs one request per tile, loses Parquet's
column pruning, and multiplies footers. The single file already has a spatial index — the footer.

### The `geo` metadata key

Per the [GeoParquet 1.1.0 specification](https://geoparquet.org/releases/v1.1.0/):

> "A GeoParquet file MUST include a `geo` key in the Parquet metadata"

with three required fields — `version`, `primary_column`, `columns` — and each geometry column
carrying `encoding` and `geometry_types`. Two details the spec is precise about and osm-katana
was wrong about (§3.1): `crs` "should be provided in **PROJJSON** format", and if omitted the
default is **OGC:CRS84** (longitude/latitude on WGS84) — which is exactly what OSM coordinates
are; and `bbox`, "if specified, MUST be encoded with an array".

### `covering.bbox` — the 1.1 field that makes pruning possible

GeoParquet 1.1 adds `covering`, a "simplified representation of each geometry". For a bounding
box the required shape is exactly:

```json
"covering": { "bbox": {
  "xmin": ["bbox","xmin"], "ymin": ["bbox","ymin"],
  "xmax": ["bbox","xmax"], "ymax": ["bbox","ymax"] } }
```

pointing at a struct column whose children "MUST be named `xmin`, `ymin`, `xmax`, `ymax` and MUST
be ordered in this same way" and "MUST be of Parquet type FLOAT or DOUBLE".

**Why it is not optional.** Parquet already records min/max for the WKB `geometry` column — but
those statistics are *spatially meaningless*. WKB compares lexicographically, and a WKB point is

```
[0x01][01 00 00 00][8 little-endian bytes of x][8 little-endian bytes of y]
 order   type=Point         ↑ the FIRST differing byte is x's LOW mantissa byte
```

so the ordering is dominated by the least-significant bits of the longitude mantissa. Sorting or
comparing on it tells you nothing about geography. The `covering` column exists precisely to give
the engine four plain numbers it can compare. Dewey Dunnington's
[survey of lazy GeoParquet reading](https://dewey.dunnington.ca/post/2025/lazy-geoparquet-reading-in-sedonadb-duckdb-geopandas-and-gdal/)
puts the payoff plainly for the Overture buildings dataset — 237 files pruned to 2 by 1.0
file-level bbox metadata, then to **6 row groups** by 1.1 bbox column statistics:

> "Of all those independently fetchable chunks, we fulfill our query result by downloading only
> six if the producer and consumer both speak GeoParquet 1.1!"

### …and why the covering alone is still not enough

A covering prunes nothing if the rows are in **parse order**. Every row group would then contain
a random scatter across the whole extract, so every group's min/max *is* the file's min/max and
every group survives every query. This is the state osm-katana's output was in.

**Both halves are required, and each is inert without the other:**

| | no covering | covering |
|---|---|---|
| **parse order** | nothing prunable — osm-katana before | groups all span the file — still nothing |
| **Hilbert order** | groups are compact but unreadable — still nothing | **prunes** |

[geoparquet.io's best practices](https://geoparquet.io/concepts/best-practices/) name the same
pair: apply Hilbert ordering so "spatial queries read fewer row groups", and include a bbox column
because "without bbox: must decode WKB geometry and compute intersection (slow); with bbox:
compare 4 numbers (fast)".

---

## 2. Hilbert ordering

A Hilbert curve maps 2-D space to a 1-D index while preserving locality: points close on the
ground get close indices. Sorting rows by that index makes each row group a compact patch of
map. It is what Overture Maps does, and what GDAL's Parquet driver offers as `SORT_BY_BBOX`.

```
        parse order (before)                    Hilbert order (after)
   ┌───────────────────────────┐          ┌───────────────────────────┐
   │ ·  ▲  ·  ■  ·  ▲  ■  ·  ▲ │          │ ▲▲▲▲  ■■■■  ●●●●  ◆◆◆◆   │
   │ ■  ·  ●  ·  ▲  ·  ·  ◆  · │          │ ▲▲▲▲  ■■■■  ●●●●  ◆◆◆◆   │
   │ ·  ◆  ·  ▲  ·  ■  ●  ·  · │          │ ▲▲▲▲  ■■■■  ●●●●  ◆◆◆◆   │
   └───────────────────────────┘          └───────────────────────────┘
    every row group spans the map          each row group is one patch
    → min/max = file bounds                → min/max = a small box
    → 0 row groups can ever be pruned      → 4 of 575 survive a city block
```

`osm-katana spatial-pack` implements it: 31 bits per axis (a 62-bit index, so it stays a
non-negative `i64`; 360°/2³¹ ≈ **1.9 cm** of longitude, finer than OSM's own 1e-7° grid), keyed on
each feature's bounding-box centre. Rows with no usable geometry keep their input order and sort
**last**, so they occupy their own trailing row groups and never widen a real group's bounds.

**It is a separate command, not a change to `convert`, on purpose.** `convert` is a streaming
pipeline whose throughput numbers are the point of the crate; a global sort needs every row in
hand and would wreck it. Packing is a post-process, exactly as Overture and GDAL do it.

---

### Since 2026-09-01: band first, Hilbert inside (DELTA 3)

The sort key is no longer the Hilbert index alone. `spatial::pack_batches` orders rows
`(min_zoom, hilbert)` — the rung a feature first draws at, then the curve — so a reader
asking `min_zoom <= camera` prunes whole row groups off the footer before it looks at a
single covering box, and inside a band every row group is as compact as before. A table
with `tags` and no `min_zoom` gets the column derived by the pack; `is_packed` refuses a
Hilbert-only table so it is upgraded. Numbers and the red arms are in
`osm-katana-v2-design.md`, DELTA 3.

## 3. The three defects fixed

### 3.1 The `geo` key was in the wrong place — output was Parquet-with-WKB, not GeoParquet

The sizing doc reported "no `geo` metadata key". The truth is sharper and more interesting: the
key **was** being written, into the *Arrow schema's* metadata — which arrow-rs serialises into the
base64 `ARROW:schema` blob. Arrow-native readers decode that blob and therefore "see" `geo`; the
spec's location, and the only place GDAL/OGR, DuckDB-spatial and geopandas look, is the
**file-level Parquet key-value metadata**, where it was absent. Measured on the shipped pack:

```
FILE-LEVEL parquet key_value_metadata keys: [b'ARROW:schema']       ← no 'geo'
arrow schema metadata: {b'geo': b'{"version":"1.0.0",…}'}           ← hidden in here
```

That is why pyarrow appeared to find it while the sizing agent's spec check said it was missing —
both were right about different things. Now written in **both** places (the Arrow copy retained,
so nothing that worked stops working). After:

```
nodes file-level keys: ['ARROW:schema', 'geo']
   geo = {"version":"1.1.0","primary_column":"geometry",
          "columns":{"geometry":{"encoding":"WKB","geometry_types":["Point"]}}}
```

Two spec violations fixed at the same time:

| field | before | after | why |
|---|---|---|---|
| `crs` | `"EPSG:4326"` (a bare string) | **omitted** | spec wants PROJJSON; omitted ⇒ OGC:CRS84, which is exactly what OSM data is |
| `bbox` | `null` | **omitted** | "MUST be encoded with an array" — `null` is not legal |
| `version` | `1.0.0` | `1.1.0` | needed for `covering` |

### 3.2 `node_coords.arrow` written unconditionally

75 MB for Stockholm, **1.68 GB for Sweden**, left on disk after every convert — even a run that
only wanted parquet. It is not removable outright: it is the mmap-backed node store pass 2
resolves way geometry through. So it is still always *written*, and now optionally *deleted*:
`--keep-node-coords false` removes it and the `_nc_chunk_*.bin` spills once the parquet is closed.
Default stays `true`, so no existing behaviour changes.

### 3.3 THE HEADLINE — spatial ordering + covering

`osm-katana spatial-pack <in-dir> -o <out-dir>`. Hilbert-sorts and adds `covering.bbox`.

---

## 4. Delivery: does git-LFS actually let you range-read?

This is the crux of *"make osm 3d 2d able to handle lfs git?"*. If LFS object storage serves
byte ranges, a Hilbert-packed GeoParquet on LFS is a tile server. If it does not, LFS is a
whole-file download channel and 95.6 MiB must land on disk before the first pixel.

**Measured live against Codeberg, 2026-07-22 — not assumed.**

| channel | absolute `Range` | suffix `bytes=-N` | `HEAD` | `Content-Range` total |
|---|---|---|---|---|
| `/{owner}/{repo}/media/branch/…` (LFS-aware raw) | **206** ✔ | **206** ✔ | 200 + `accept-ranges: bytes` | correct |
| `/{owner}/{repo}/releases/download/…` | **206** ✔ | **206** ✔ | 200 + `accept-ranges: bytes` | correct |
| `/{owner}/{repo}.git/info/lfs/objects/{oid}` | **206** ✔ | **200 — WHOLE FILE** ✘ | **404** ✘ | wrong unless start = 0 |
| `/{owner}/{repo}/raw/branch/…` (plain git blob) | **206** ✔ | 416 | 200, no `accept-ranges` | correct |

So: **yes, git-LFS on Codeberg serves byte ranges.** The full LFS fetch path works unauthenticated
on a public repo — `POST …/{repo}.git/info/lfs/objects/batch` with
`{"operation":"download","objects":[{"oid":…,"size":…}]}` returns a `download.href`, and that href
answers `Range` with `206 Partial Content`. Range bytes verified byte-identical to the same span
of the full file.

**Three traps, all real, all measured:**

1. **Never send a suffix range.** The bare LFS object endpoint answers `Range: bytes=-8` with
   **`200` and the entire object**. The textbook Parquet footer read is *exactly* a suffix range,
   so a naïve reader would silently download 95 MiB to find 8 bytes. Use absolute
   `bytes=start-end` only.
2. **Never trust `Content-Range`'s total** unless the request started at 0 — Forgejo reports
   `size - start` there (`bytes 100-199/2813744` on a 2813844-byte object). Probe the size once
   with `Range: bytes=0-0`, which does report it correctly.
3. **Never use `HEAD`** — 404 on the LFS object endpoint.

**Prefer `/media/`.** It is fully RFC-7233 compliant, resolves LFS pointers transparently, and
needs no batch call, no `git-lfs` client and no clone. Release attachments are equally compliant
and do not consume the git quota.

*(Already established in the sizing doc and not re-derived: Codeberg **wikis** have no LFS server
at all — `.wiki.git/info/lfs/objects/batch` 404s while normal repos 200 — and the quotas are
750 MiB git + 1.5 GiB LFS/releases per org, 100 MiB per release attachment.)*

---

## 5. THE NUMBER

Same content in both files; the only difference is row layout. Query: **one city block in Gamla
Stan, Stockholm** — `18.0700,59.3235 .. 18.0740,59.3255`, about **227 m × 221 m**.

Reproduce with:
`cargo run -p skade --release --example geo_bbox_prune_bench -- <convert-out> <packed-out>`

### nodes.parquet — 4705949 rows

| projection | layout | row groups touched | bytes read | of file |
|---|---|---:|---:|---:|
| all columns | `convert` (parse order) | **31 / 31** | **79189221** | 100.00% |
| all columns | `spatial-pack` (Hilbert) | **4 / 575** | **792011** | **0.69%** |
| | | | **100× fewer bytes** | |
| id + geometry | `convert` | 31 / 31 | 75382915 | 100.00% |
| id + geometry | `spatial-pack` | **4 / 575** | **497928** | **0.61%** |
| | | | **151× fewer bytes** | |

### ways.parquet — 735420 rows

| projection | layout | row groups touched | bytes read | of file |
|---|---|---:|---:|---:|
| all columns | `convert` | **24 / 24** | **55722525** | 100.00% |
| all columns | `spatial-pack` | **8 / 90** | **5562065** | **8.83%** |
| | | | **10× fewer bytes** | |
| id + geometry | `convert` | 24 / 24 | 30950322 | 100.00% |
| id + geometry | `spatial-pack` | **8 / 90** | **2664908** | **8.54%** |

**A whole viewport (nodes + ways, id + geometry): 106333237 → 3162836 bytes. 33.6× fewer —
3.0 MB instead of 101 MB.**

Ways prune less well than nodes for a real geometric reason, not a defect: a way's covering is its
whole extent, and a motorway that crosses the city has a city-sized bounding box, so it is a
candidate for every query. That is inherent to bbox covering of long features and is why nodes —
the layer that actually dominates the bytes — is where the win lands.

### As byte ranges: what a viewer actually asks the server for

For the same query on the packed `nodes.parquet`, coalescing chunks within 64 KiB:

```
6 contiguous ranges, 555390 bytes of data:
   bytes=14291-41723            (27433 B)
   bytes=130139-245994         (115856 B)
   bytes=57700460-57729533      (29074 B)
   bytes=57815269-57972202     (156934 B)
   bytes=58059110-58175885     (116776 B)
   bytes=114271287-114380603   (109317 B)
plus a 659093-byte footer, read once and cached for the session.
```

**Six range requests.** That is the tile server, and there is no tile server.

### Content equivalence — the gate

`osm-katana verify --digest`. The `set` fold is order-independent; `ord` also pins row order.

```
                        BEFORE (convert)                      AFTER (spatial-pack)
nodes.parquet     set 17d0dd3f25565c93   ord 08b998890a9eebff  →  set 17d0dd3f25565c93  ord 163fb936f4ee86bd
ways.parquet      set 01c4f5b4774a29ee   ord 12a2c9beb169a909  →  set 01c4f5b4774a29ee  ord 1147fec8c8973ca5
relations.parquet set 1360ec5d868f3197   ord 14295ce611696a1b  →  set 1360ec5d868f3197  ord 14295ce611696a1b
```

**Every `set` digest is identical** — same rows, same values, none lost, none duplicated. `ord`
moved for nodes and ways, which is the entire point; it did **not** move for relations, because
every relation is geometry-less and those sort last in input order. Exactly the expected signature.

The covering column is *derived* (a pure function of `geometry`), so `digest` now excludes it —
otherwise a packed table could never compare equal to the convert output it came from, and the
gate would be useless precisely when it matters.

---

## 6. The two design decisions, both measured

### 6.1 Covering as FLOAT + `BYTE_STREAM_SPLIT`, not DOUBLE

The first implementation used `f64` and made `nodes.parquet` **3× larger** (79 MB → 241 MB) —
because for a *point*, `xmin == xmax` and `ymin == ymax`, so the covering stores each coordinate
four times. Priced, on Stockholm's 4705949 nodes, compressed bytes for the four columns:

| encoding | bytes |
|---|---:|
| `f64`, plain zstd | 132611232 |
| `f64`, `BYTE_STREAM_SPLIT` | 97321255 |
| `f32`, plain zstd | 46296340 |
| **`f32`, `BYTE_STREAM_SPLIT`** | **28774887** |

**4.6× smaller than the naïve choice**, and the spec explicitly permits FLOAT. `BYTE_STREAM_SPLIT`
transposes the four bytes of each `f32` into four planes; after Hilbert sorting neighbouring rows
share sign, exponent and high mantissa almost exactly, so three of the four planes become long
runs that zstd flattens.

A covering is a **conservative approximation by definition** — the reader re-checks the real
geometry — so spending 8 bytes to re-state a coordinate the WKB column already holds exactly is
pure waste. `f32` costs about **2 m** of slop at Stockholm's latitude, invisible at row-group
granularity (a group spans hundreds of metres at minimum).

Correctness is held by rounding **outwards**: minima round down, maxima round up, so the stored
box always *contains* the true box and a row group is never pruned away when it might have
matched. Round-to-nearest would shrink roughly half of all boxes; the `covering_is_conservative`
test asserts the direction against 20000 real 1e-7° OSM coordinates and checks that the fixture
actually exercises the case a naïve cast gets wrong.

### 6.2 8192 rows per row group

The pruning/compression dial. Swept on Stockholm — bytes read for the city-block query, and
packed file size:

| rows/group | row groups | nodes read | ways read | footer bytes | packed total |
|---:|---:|---:|---:|---:|---:|
| **8192** | **575** | **0.69%** | **8.83%** | 659093 | 178486633 |
| 16384 | 288 | 0.86% | 15.59% | 330813 | 179619338 |
| 32768 | 144 | 2.73% | 29.90% | 167149 | 177283892 |
| 65536 | 72 | 4.15% | 48.24% | 85849 | 171899868 |
| 131072 | 36 | 8.40% | **100.00%** | 44201 | 167987505 |

8192 reads **6× fewer bytes** than 65536 for **3.8% more file**, and at ~200 KB of compressed node
data per group it is a sensible HTTP range-request size — which is the delivery this layout exists
to serve. (At 131072 the ways table stops pruning entirely: 6 groups, all candidates.)

**Honest caveat:** the footer grows with the group count, and at 8192 it is 659 KB. Cold-start cost
for one query is footer + data: 8192 → ~1.45 MB, 16384 → ~1.33 MB — a wash. Warm (footer cached,
which is what panning does) 8192 wins by 6×. 8192 is the default; 16384 is the right choice if
cold first-paint dominates and only one query is ever made.

### What it costs

| | convert | spatial-pack | Δ |
|---|---:|---:|---:|
| nodes.parquet | 79282235 | 115323568 | +45% |
| ways.parquet | 55769366 | 63163065 | +13% |
| relations.parquet | 1272672 | 1280025 | +0.6% |
| **total** | **136324273** (130.0 MiB) | **179766658** (171.4 MiB) | **+31.9%** |

Where the +36 MB on nodes goes: **+29 MB** covering (the thing being bought), **+3.5 MB** because
Hilbert order destroys the `id` column's ascending delta locality, **+2.6 MB** from smaller row
groups. Paid **once, on disk**, to read **100× less per query**. For a viewer that pans, that
trade is not close.

---

## 7. The reader — does skade's pruning already engage?

**Partly, and the missing part was the important one.**

| capability | state before | verdict |
|---|---|---|
| row-group min/max pruning (cycle-12) | present, `row_group_survives` | ✔ sound, reusable |
| page-level skipping (cycles 13–14) | present | ✔ |
| `ParquetWindows` (footer parsed once, bounded memory) | present | ✔ but windows over **every** row group — no predicate |
| `spatial::GeoIndex` (geohash) | present | in-memory, **requires a full scan first** |
| a **spatial predicate** | **absent** | ✘ `ScanFilter` constrains exactly **one** column; a bbox is a conjunction over four |
| `Statistics::Float` | **absent** | ✘ would have silently disabled pruning on a FLOAT covering |

So the seam was there and the predicate was not. A bbox intersection is

```
xmin ≤ q.xmax   AND   ymin ≤ q.ymax   AND   xmax ≥ q.xmin   AND   ymax ≥ q.ymin
```

— four one-sided ranges on four different columns. `ScanFilter::{Eq,In,Range}` each name a single
column, and the whole filtered read path is Iceberg-table-oriented, so it could not be expressed
at all. Added, in `skade/src/geo_parquet.rs`:

- `covering_of` — resolves the `covering.bbox` declaration from the file-level `geo` key (falling
  back to the Arrow copy), to four leaf column indices.
- `prune_row_groups` / `plan_bbox_query` — decomposes the intersection into the four ranges and
  feeds each to the **existing** `row_group_survives`. One pruning rule in the codebase, not two
  (L5). Returns `PruneStats`: row groups touched, rows, bytes read vs total — all counted from the
  footer, hence exact and load-independent.
- `read_bbox` — plan, then decode only the survivors.
- `Statistics::Float` / `ColumnIndexMetaData::FLOAT` arms in `row_group_survives` / `page_survives`
  (`f32 → f64` widening is exact, so no slack is introduced).

Pruning is **conservative**: a group is dropped only when its own statistics prove no row can
match. `prune_never_drops_a_matching_row` checks that against a brute-force scan. A file with no
covering keeps every group and reports `covering_used: false` rather than quietly claiming a win.

**No second reader was written** (L5): this is a planner over the existing metadata plumbing.

### Consuming it remotely — `skade/src/geo_source.rs` (feature `geo-http`)

`HttpRangeReader` implements parquet's `ChunkReader`, so `plan_bbox_query` / `read_bbox` work
unchanged against a URL. Built around the three measured LFS traps of §4: absolute ranges only,
size probed via `bytes=0-0`, never `HEAD`. An in-process memo coalesces repeats — without it every
pan re-pays the 659 KB footer. `GeoSource::forge_media` / `forge_release` build the two
range-compliant Codeberg URLs.

Tests run against a local `TcpListener` range server (pure Rust, no network, no external service,
L9). One of them reproduces Codeberg's LFS-object behaviour exactly — suffix range answered with
the whole file — and asserts the reader never emits one.

---

## 8. PMTiles vs GeoParquet — honestly, they are different jobs

They are not competitors, and pretending one answer covers both would be wrong.

| | **PMTiles** | **GeoParquet** |
|---|---|---|
| holds | a pre-rendered **tile pyramid** (vector or raster), z/x/y | **rows** with attributes, columnar |
| answers | "draw the map here at this zoom" | "which features match this predicate" |
| index | Hilbert-ordered tile directory, in-file | row-group statistics + `covering.bbox`, in-footer |
| access | HTTP range per tile, no server | HTTP range per row group, no server |
| built-in LOD / generalisation | **yes** — that is the format's point | **no** — you get every vertex at every zoom |
| attribute query / joins / analytics | no | **yes** — DuckDB, Sedona, geopandas, GDAL |
| update cost | re-upload the archive | re-upload the file |

Both are cloud-native single-file formats read by byte range. The real difference is **level of
detail**. PMTiles has already decided, per zoom level, which features and which vertices matter;
a GeoParquet hands you the full-resolution geometry no matter how far out the camera is. That is
decisive for a basemap and irrelevant for a query.

Protomaps' own [*You Might Not Want PMTiles*](https://protomaps.com/blog/you-might-not-want-pmtiles/)
is candid about the boundary — PMTiles fits when "the experience is built on the web platform",
"the total information to be explored is over a few megabytes", and "the dataset changes at most
daily, or never"; and for large static data read locally, "QGIS is an excellent, open-source
solution" over GeoParquet.

**Verdict for this demo:**

- **Queryable feature data → GeoParquet.** POIs, search, "what is here", anything with tags, and
  anything skade/korp should be able to run a predicate over. This is where §5's 100× lives.
- **A rendered basemap at world/country zoom → PMTiles would genuinely be better.** Zooming out
  over Sweden in GeoParquet means reading every vertex of every road; PMTiles has already
  generalised. If the 2D map ever wants a smooth world-to-street zoom over more than a city,
  PMTiles is the right tool and this design does not replace it.
- **At city scale, which is what the demo is, GeoParquet alone is sufficient** — Stockholm's whole
  packed pack is 171 MiB and any viewport reads a few MB of it. Adding a tile pipeline for one
  city would be a second format and a second build step for no measured gain.

The two compose: PMTiles under the pins, GeoParquet for the pins and everything clickable.

---

## 9. What the demo should ship

**Recommendation: the POI-only GeoParquet committed straight to git, plus the full Stockholm pack
as a release attachment for anyone who wants the basemap.**

| option | size | delivery | verdict |
|---|---:|---|---|
| **POI layer, named only** | **1658708** (1.58 MiB) | plain git file, no LFS | **ship this** — 81726 features |
| POI layer, all tagged nodes | 5564300 (5.3 MiB) | plain git file, no LFS | fine too, if all 246191 tagged nodes are wanted |
| **Stockholm packed, zstd-19** | **148982556** (142.1 MiB) | **release attachments** | **the optional basemap** — every file under the cap |
| Stockholm packed, default zstd | 179766658 (171.4 MiB) | — | `nodes.parquet` **over** the 100 MiB attachment cap |
| Stockholm unpacked, zstd-19 | 100202418 (95.6 MiB) | release attachments | the sizing doc's pack; **no spatial pruning at all** |
| Sweden | 2449332105 (2.28 GiB) | — | no. Exceeds the whole 1.5 GiB org bucket. |

**Why POI-only first.** 1.58 MiB is small enough to commit as a plain git blob: no LFS, no
release, no network at demo time, no range reads, no argument. It is exactly the pin dataset
facett-geomap's clustering path wants, and facett-geomap culls 1 M points in 355 ns — 81726 pins
is nowhere near the rendering budget. Nothing about this option can go wrong at demo time.

**And the full packed basemap does fit — confirmed.** The packed `nodes.parquet` at default zstd
is 115323568 bytes, which is **over** Codeberg's 100 MiB (104857600 byte) per-attachment cap: the
covering column pushes it over where the unpacked table (79282235) was under. The zstd-19 repack
resolves it, and was measured end to end:

| table | packed (default zstd) | packed + zstd-19 | under the 100 MiB cap? |
|---|---:|---:|---|
| `nodes.parquet` | 115323568 | **101989362** | **yes**, by 2868238 bytes |
| `ways.parquet` | 63163065 | **46088567** | yes |
| `relations.parquet` | 1280025 | **904627** | yes |
| **total** | 179766658 | **148982556** (142.1 MiB) | **9.5% of the 1.5 GiB org bucket** |

The repack is **not** a downgrade — it keeps the layout, the covering and the `geo` key, and
prunes at least as well:

```
nodes.parquet, same city-block query, zstd-19 repack:
    all columns    4/575 row groups     696904 / 101367812 bytes   0.69%   113.6x fewer
    id+geometry    4/575 row groups     443468 /  72065727 bytes   0.62%   170.0x fewer
```

and `verify --digest` gives byte-identical `set` **and** `ord` digests to the packed input
(`17d0dd3f25565c93` / `163fb936f4ee86bd` for nodes), so it is a pure recompression. It costs
~190 s of one-off CPU at pack time (measured under load average 119 — indicative only).

If for any reason the repack is not wanted, two fallbacks: ship with `--covering false` (Hilbert
ordering only, schema unchanged, size within noise of the original — prunes nothing today, but the
ordering is already right so adding the covering later is a repack, not a re-convert); or split
`nodes.parquet` by Hilbert range into two attachments, which works but is the "multiple small
files" answer this whole document argues against.

**Not the wiki, for the reason the sizing doc proved:** Codeberg wikis have no LFS server at all.
Anything put there is a permanent plain-git blob against the smaller 750 MiB git quota.

**If you do want it on LFS in a normal repo, that now works properly** — §4 established that
`/media/branch/…` serves ranges correctly, so a packed pack on LFS is genuinely range-readable and
the viewer never downloads the whole thing. That is the configuration that makes "read when close
to the point" literally true over the network.

---

## 10. Progressive loading — what still needs facett

Honest answer: **the reader half is done; the render half is not, and it is not mine to write.**

*(facett is owned by other agents. Nothing in facett was touched. The following is the requirement
to route to them.)*

**What already works with zero facett change.** `facett-demo/src/osm_fly.rs` checks its clip cache
*before* it needs a planet: `clip_cached` is "`nodes.parquet` and `ways.parquet` both exist" in
`<cache_root>/<region>-<src_hash>-geo`, and `source_hash(None)` returns the literal `"no-planet"`.
So a pack materialised into `$FACETT_OSM_CACHE/stockholm-no-planet-geo/` is already a cache hit and
the demo loads straight to flight. `skade::geo_source::{PackCache, ensure_pack}` does exactly that
materialisation from a URL — turning the cache from "a human placed files here" into
"self-populating from a remote source". Also verified: `facett-osm`'s `read_points` /
`read_ways` select columns **by name** (`column_by_name("geometry")`), so the appended `bbox`
column is a no-op for them — a packed pack is a drop-in for the existing loader.

**What does NOT work today.** `osm_tabs::load_from` does a **single eager full read** at startup
(`read_points(path, MAX_POINTS)`, `read_ways(path, MAX_WAYS_2D)`) and hands the scene a finished
`OsmGeometry`. There is no path for geometry to arrive later. "Read when close to the point"
requires both:

> **Requirement for the facett agents — incremental map data.**
>
> 1. **A viewport-driven data request.** The 2D/3D map panes need to emit their current view
>    bounds (and ideally a zoom hint) on camera change, debounced, instead of loading once at
>    startup. skade side is ready: `skade::geo_parquet::read_bbox(path_or_url, &Bbox, projection,
>    batch_rows, threads)` returns exactly the matching rows and a `PruneStats` saying what it
>    cost.
> 2. **An append-capable scene.** `OsmGeometry` must accept rows arriving after first paint —
>    `extend(points, ways)` plus a dedup key (the OSM `id` column is already in every table and is
>    unique per table) so an overlapping viewport does not double-insert. Today `load_from`
>    returns an immutable snapshot and the scene is built once.
> 3. **A "loading" state that is not a stall.** Range fetches take a network round trip; the pane
>    should render what it has and fill in, not block.
>
> Nothing else is required — no tile pipeline, no second format, no server. Item 2 is the real
> work; item 1 is small; item 3 is a UI affordance.

Until those exist, the honest shipping story is §9's option: a small POI file loaded eagerly,
which the current code path already handles perfectly.

---

## 11. Reproduce

```bash
# convert (unchanged), then pack
osm-katana convert Stockholm.osm.pbf -o out --keep-node-coords false
osm-katana spatial-pack out -o packed              # default 8192 rows/group, covering on

# prove content is unchanged (the `set` fold must match; `ord` will not)
osm-katana verify out    --digest
osm-katana verify packed --digest

# the headline: bytes read + row groups touched, before vs after
cargo run -p skade --release --example geo_bbox_prune_bench -- out packed
```

---

## 12. Follow-ups

1. **`covering.bbox` for `ways` is weak on long features** (8.83% vs nodes' 0.69%) — a
   city-crossing motorway is a candidate for every query. A finer covering (per-segment rows, or
   splitting long ways) would help; not worth it for a city-scale demo.
2. **`id` costs +3.5 MB after packing** because Hilbert order destroys its ascending delta.
   `DELTA_BINARY_PACKED` will not help a random sequence; a per-row-group sort of `id` within the
   group would break the spatial ordering. Accept, or drop `id` from shipped packs.
3. **Sweden needs an external sort.** `spatial_pack` is in-memory; 105 M nodes will not fit
   comfortably. `node_store.rs` already has a chunked external merge sort keyed on `i64` —
   re-pointing it at the Hilbert index instead of the node id is the natural path.
4. **Parquet 2.11 / GeoParquet 2.0 native `GEOMETRY` logical type** would make the covering column
   unnecessary (statistics become spatial natively). Worth revisiting when arrow-rs ships it — it
   would delete the +29 MB.
5. **Add `--compression-level` to `osm-katana spatial-pack`** so the zstd-19 pack is one command
   rather than a pyarrow post-step (§9 measured it via pyarrow). The sizing doc raised the same
   gap for `convert`.