<!-- 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`.