tile tool: R121 decoded-tile check, standardized benchmarks, size validation (#139) #142

Merged
art merged 1 commit from issue-139-tile-tool into main 2026-08-16 22:31:26 +00:00
Owner

#139: the tile tool — R121 decoded-tile check + standardized benchmarks + size validation

Closes #139


What this is

A read-only Go tool on the importer binary that serves tiles through the same
zxy_<layer> functions mapsrv calls (no HTTP) and adds the three mechanisms the
issue scoped in from #127. It is the importer tiles subcommand (the #129 CLI):
importer tiles --check / --bench / --size <config>, with make targets
tile-check / tile-bench / tile-size. The branch was rebased onto main after
#129 (importer subcommands) landed mid-work, which is why the interface is a
subcommand rather than flat flags.

  1. tiles --check — R121's decoded-tile half: per layer, serve one tile at the
    coarsest and one at the finest zoom it draws, decode the MVT in Go, and assert
    the tag keys equal the config contract's attr_names minus mvt_exclude
    (plus the machinery columns: the geometry, and country). A leak
    (part_key on cover, rank on places) or a dropped attribute fails the
    run.
  2. tiles --bench — the standardized benchmarks: the four capitals at
    z5/z8/z12/z14 plus a z5 whole-country view of each country (the z5 Stockholm
    probe included), fetched as 9×9 grids through zxy_<source> functions, JSON
    output with per-tile byte sizes and latencies.
  3. tiles --size — per-layer/per-zoom heaviest-tile report, the before/after
    instrument #127 will measure against.

Make targets: make tile-check, make tile-bench (saves
bench/results/<date>_<commit>_tiles.json), make tile-size. Docs in
docs/importer-cli.md ("The tile tool"); the R121 and R83 requirement entries in
docs/country-requirements.md now cite it.

Design decision: why the importer, not mapsrv

The R121 check asserts against the layer contracts — attr_names,
mvt_exclude, the shared_layers: contracts from conf/common.yaml, and the
country column applyCountry injects. The importer is the only component that
parses and validates those (mapsrv's config knows source names, not layers), and
it already has the read-only-against-the-DB shape (-acceptance-only) and the
pool machinery. mapsrv cannot import the importer's structs (both are
package main), so a mapsrv-side tool would have had to re-implement config
resolution — the exact duplication the tool is supposed to remove. The decoder
follows scripts/mvt-decode.py (the project's reference walker) and the
hand-rolled protobuf pattern mapsrv's glyph_check_test.go established.

Verification

R121 — all five configs pass (130/130 checks)

make tile-check (ismap, semap, plmap, fimap, world): every layer's decoded keys
match its contract exactly. The two classic leaks are confirmed absent —
cover serves [cover_class, country] (no part_key), places serves
[name, importance, country] (no rank); every shared layer carries country.
No layer had a missing function or a key mismatch on the live DB.

Sample (conf/ismap.yaml, 34/34 PASS):

config         layer                  func                      zoom  verdict  note
conf/ismap.yaml cover                  zxy_map_cover                3  PASS     features 30456, keys [cover_class country]
conf/ismap.yaml cover                  zxy_map_cover               13  PASS     features 473, keys [cover_class country]
conf/ismap.yaml places                 zxy_map_places               4  PASS     features 1, keys [name importance country]
conf/ismap.yaml places                 zxy_map_places              14  PASS     features 33, keys [name importance country]
conf/ismap.yaml roads                  zxy_map_roads                5  PASS     features 433, keys [road_class name ref ref_class surface country]
conf/ismap.yaml buildings              zxy_ismap_buildings         14  PASS     features 192, keys [mannvirki nafnfitju country]

The full 130-row table is one make tile-check away; the only per-config
difference is that ismap.yaml adds its five non-shared layers (contours, peaks,
buildings, airports, airport_points) on top of the twelve shared contracts.

Benchmarks — live DB, JSON saved

bench/results/2026-08-16_683e950_tiles.json (this branch's HEAD is main's
683e950; the numbers describe the live DB, as the issue says they should). 20
combos × 81 tiles per source function. zxy_map wall time 11 s for the whole
run; the z5 whole-country grids are dominated by the ~2 MB Stockholm tiles
(p50 0 ms — most of the 81 tiles are empty ocean — p95 ~350–400 ms).

Heaviest single tile in the run: zxy_map z5 (17,8) = 2 385 623 bytes
(852 ms).

Size validation — the offenders #127 will work on

make tile-size (full run, 48 s): 17 layers × every zoom they draw, top-5
heaviest tiles per layer/zoom. The heaviest tile per layer:

layer              heaviest tile            bytes
cover              z5 (17,8)         2 329 141   <- the z5 Stockholm area (was 8 MB, now 2.3 MB)
roads              z13 (4506,2408)     118 391
water_bodies       z7 (73,35)          103 722
railways           z12 (2253,1204)      75 533
waterways          z3 (4,2)             61 673
country_borders    z9 (224,131)         24 747
contours           z12 (1874,1079)      21 738
transit            z14 (9328,4740)      15 982

cover z5 is the one to watch: it is 27× the next heaviest tile. The z5
Stockholm 8 MB acceptance test still passes (zxy_map(5,17,9) = 1 841 744 bytes,
994 ms) — the historical runaway was fixed, and the tile tool is now what would
have caught it.

Honest limits

  • The size report's candidates come from a per-feature centroid+bbox pass (top 64
    per zoom are measured); a tile that clips the middle of one giant polygon
    with no feature centroid or bbox corner in it could be missed. The corner tile
    of that polygon carries the same bytes and is reported, so the top-5 stays
    approximately right — documented in the code and docs.
  • -tile-bench's 9×9 grids fetch a lot of empty-ocean tiles at z5 by design
    (that is what a real client sees panning); the per-tile JSON lets a graph
    filter them.
  • The tile check probes one tile per zoom. It proves the key set, not data
    coverage (coverage is R119's acceptance tests' job).

Files

  • importer/tiles.go — decoder, check, bench, size report (+ importer/testdata/ferries_z5_17_9.hex, a real 3129-byte ST_AsMVT tile as the golden decode fixture)
  • importer/tiles_test.go — decoder tests (hand-built + real tile), expected-keys logic, tile math; no DB needed
  • importer/main.go — the -tile-check / -tile-bench / -tile-size flags
  • Makefile — tile-check, tile-bench, tile-size
  • docs/importer-cli.md — "The tile tool" section; docs/country-requirements.md — R121/R83 now cite the tool
  • bench/results/2026-08-16_683e950_tiles.json — the committed benchmark run
# #139: the tile tool — R121 decoded-tile check + standardized benchmarks + size validation Closes #139 --- ## What this is A read-only Go tool on the **importer** binary that serves tiles through the same `zxy_<layer>` functions mapsrv calls (no HTTP) and adds the three mechanisms the issue scoped in from #127. It is the `importer tiles` subcommand (the #129 CLI): `importer tiles --check / --bench / --size <config>`, with make targets `tile-check` / `tile-bench` / `tile-size`. The branch was rebased onto main after #129 (importer subcommands) landed mid-work, which is why the interface is a subcommand rather than flat flags. 1. **`tiles --check`** — R121's decoded-tile half: per layer, serve one tile at the coarsest and one at the finest zoom it draws, decode the MVT in Go, and assert the tag keys equal the config contract's `attr_names` minus `mvt_exclude` (plus the machinery columns: the geometry, and `country`). A leak (`part_key` on `cover`, `rank` on `places`) or a dropped attribute fails the run. 2. **`tiles --bench`** — the standardized benchmarks: the four capitals at z5/z8/z12/z14 plus a z5 whole-country view of each country (the z5 Stockholm probe included), fetched as 9×9 grids through `zxy_<source>` functions, JSON output with per-tile byte sizes and latencies. 3. **`tiles --size`** — per-layer/per-zoom heaviest-tile report, the before/after instrument #127 will measure against. Make targets: `make tile-check`, `make tile-bench` (saves `bench/results/<date>_<commit>_tiles.json`), `make tile-size`. Docs in `docs/importer-cli.md` ("The tile tool"); the R121 and R83 requirement entries in `docs/country-requirements.md` now cite it. ## Design decision: why the importer, not mapsrv The R121 check asserts against the **layer contracts** — `attr_names`, `mvt_exclude`, the `shared_layers:` contracts from conf/common.yaml, and the `country` column `applyCountry` injects. The importer is the only component that parses and validates those (mapsrv's config knows source names, not layers), and it already has the read-only-against-the-DB shape (`-acceptance-only`) and the pool machinery. mapsrv cannot import the importer's structs (both are `package main`), so a mapsrv-side tool would have had to re-implement config resolution — the exact duplication the tool is supposed to remove. The decoder follows `scripts/mvt-decode.py` (the project's reference walker) and the hand-rolled protobuf pattern mapsrv's `glyph_check_test.go` established. ## Verification ### R121 — all five configs pass (130/130 checks) `make tile-check` (ismap, semap, plmap, fimap, world): every layer's decoded keys match its contract exactly. The two classic leaks are confirmed absent — `cover` serves `[cover_class, country]` (no `part_key`), `places` serves `[name, importance, country]` (no `rank`); every shared layer carries `country`. No layer had a missing function or a key mismatch on the live DB. Sample (conf/ismap.yaml, 34/34 PASS): ``` config layer func zoom verdict note conf/ismap.yaml cover zxy_map_cover 3 PASS features 30456, keys [cover_class country] conf/ismap.yaml cover zxy_map_cover 13 PASS features 473, keys [cover_class country] conf/ismap.yaml places zxy_map_places 4 PASS features 1, keys [name importance country] conf/ismap.yaml places zxy_map_places 14 PASS features 33, keys [name importance country] conf/ismap.yaml roads zxy_map_roads 5 PASS features 433, keys [road_class name ref ref_class surface country] conf/ismap.yaml buildings zxy_ismap_buildings 14 PASS features 192, keys [mannvirki nafnfitju country] ``` The full 130-row table is one `make tile-check` away; the only per-config difference is that ismap.yaml adds its five non-shared layers (contours, peaks, buildings, airports, airport_points) on top of the twelve shared contracts. ### Benchmarks — live DB, JSON saved `bench/results/2026-08-16_683e950_tiles.json` (this branch's HEAD is main's 683e950; the numbers describe the live DB, as the issue says they should). 20 combos × 81 tiles per source function. `zxy_map` wall time 11 s for the whole run; the z5 whole-country grids are dominated by the ~2 MB Stockholm tiles (p50 0 ms — most of the 81 tiles are empty ocean — p95 ~350–400 ms). Heaviest single tile in the run: `zxy_map` z5 (17,8) = **2 385 623 bytes** (852 ms). ### Size validation — the offenders #127 will work on `make tile-size` (full run, 48 s): 17 layers × every zoom they draw, top-5 heaviest tiles per layer/zoom. The heaviest tile per layer: ``` layer heaviest tile bytes cover z5 (17,8) 2 329 141 <- the z5 Stockholm area (was 8 MB, now 2.3 MB) roads z13 (4506,2408) 118 391 water_bodies z7 (73,35) 103 722 railways z12 (2253,1204) 75 533 waterways z3 (4,2) 61 673 country_borders z9 (224,131) 24 747 contours z12 (1874,1079) 21 738 transit z14 (9328,4740) 15 982 ``` `cover` z5 is the one to watch: it is 27× the next heaviest tile. The z5 Stockholm 8 MB acceptance test still passes (zxy_map(5,17,9) = 1 841 744 bytes, 994 ms) — the historical runaway was fixed, and the tile tool is now what would have caught it. ## Honest limits - The size report's candidates come from a per-feature centroid+bbox pass (top 64 per zoom are measured); a tile that clips the *middle* of one giant polygon with no feature centroid or bbox corner in it could be missed. The corner tile of that polygon carries the same bytes and is reported, so the top-5 stays approximately right — documented in the code and docs. - `-tile-bench`'s 9×9 grids fetch a lot of empty-ocean tiles at z5 by design (that is what a real client sees panning); the per-tile JSON lets a graph filter them. - The tile check probes one tile per zoom. It proves the key set, not data coverage (coverage is R119's acceptance tests' job). ## Files - `importer/tiles.go` — decoder, check, bench, size report (+ `importer/testdata/ferries_z5_17_9.hex`, a real 3129-byte ST_AsMVT tile as the golden decode fixture) - `importer/tiles_test.go` — decoder tests (hand-built + real tile), expected-keys logic, tile math; no DB needed - `importer/main.go` — the `-tile-check` / `-tile-bench` / `-tile-size` flags - `Makefile` — `tile-check`, `tile-bench`, `tile-size` - `docs/importer-cli.md` — "The tile tool" section; `docs/country-requirements.md` — R121/R83 now cite the tool - `bench/results/2026-08-16_683e950_tiles.json` — the committed benchmark run
The tile-serving/decoding/measuring mechanism the issue scoped in from #127,
as read-only flags on the importer binary (it already parses the layer
contracts -acceptance-only needs nothing new, and mapsrv cannot import its
structs; see docs/importer-cli.md "The tile tool" for the decision):

- -tile-check: R121's decoded-tile half. Per layer (every non-internal shared
  contract via zxy_map_<layer>, plus per-country layers), serve one tile at the
  coarsest and one at the finest zoom, decode the MVT with a Go walker
  (scripts/mvt-decode.py is the reference), and assert the tag keys equal
  attr_names minus mvt_exclude (the geometry and the injected country column
  are the machinery the check also expects). A leak (part_key on cover, rank on
  places) or a dropped attribute fails. SQL cannot run this — MVT is protobuf —
  so it is a separate mechanism, not an acceptance test.
- -tile-bench: fixed combos (capitals z5/z8/z12/z14, whole-country z5 views),
  9x9 grids through zxy_<source>, JSON with per-tile bytes and latencies.
- -tile-size: per-layer/per-zoom heaviest tiles by bytes, found via a
  per-feature centroid+bbox pass over the layer's own table (top 64 candidates
  measured per zoom) — the before/after instrument for #127.

make tile-check / tile-bench / tile-size; docs in docs/importer-cli.md; R121
and R83 in docs/country-requirements.md now cite the tool.

Verified against the live DB: 130/130 R121 checks PASS across the five configs
(part_key and rank confirmed excluded, country present everywhere). Benchmark
run saved as bench/results/2026-08-16_683e950_tiles.json: zxy_map z5 (17,8) is
the heaviest tile at 2 385 623 bytes. Size report: cover z5 (17,8) = 2 329 141
bytes dominates, 27x the next heaviest tile. make build, make test and make
acceptance all pass. Decoder and expected-keys logic are unit-tested with a
golden real ST_AsMVT tile (importer/testdata/), no DB needed.
art force-pushed issue-139-tile-tool from 68c6d464a3 to 5bb050d946 2026-08-16 07:11:55 +00:00 Compare
art changed title from # #139: the tile tool — R121 decoded-tile check + standardized benchmarks + size validation to tile tool: R121 decoded-tile check, standardized benchmarks, size validation (#139) 2026-08-16 07:12:09 +00:00
Collaborator

Review — the tile tool (#139)

Good tool, and the design decision is right: the R121 check asserts against the layer
contracts, and the importer is the only component that resolves them. Reviewed by
reading the diff and by using it — tiles --check and tiles --size both ran against
the live DB for every config while working #127 on top of this branch.

Fixed on the #127 branch (they were in files that branch already touches)

The docs and the code name flags the #129 rewrite deleted. docs/importer-cli.md
documents -tile-size-top, -tile-size-zooms and -tile-bench-funcs; tiles.go's
doc comments say "implements -tile-check / -tile-bench / -tile-size"; and
parseTileSizeZooms prints -tile-size-zooms %q in two error strings. Copying the
documented command gives "flag provided but not defined".

The probe assigns features by bbox north-west corner, not centroid. probeTile and
candidateTiles both use ST_XMin(geom) / ST_YMax(geom), while both code comments
and the doc say "the tile holding the most feature centroids". The corner is a fine
heuristic for "which tile has data" — the documentation is what's wrong.

tiles --check could not see a wrong tile. It compares key sets and nothing else.
ST_AsMVT's extent argument is separate from ST_AsMVTGeom's and both default to
4096, so a tile carrying 1024-unit coordinates while declaring 4096 decodes perfectly,
has exactly the right keys, passes — and draws at quarter scale in its top-left corner.
That is not hypothetical: it is what #127's first version did, and this check is what
should have caught it before a browser did. --check now also asserts that the declared
extent equals mvtExtent, and that no feature arrives without a geometry (tags with
nothing to draw). Both are FAILs; both were silent before.

Worth your attention, not fixed

--size has no layer filter. --size-zooms exists but there is no --size-layer,
so measuring one layer's optimization means running all twelve, each doing a full-table
pass. That is the #127 workflow this tool exists for; I ended up writing raw SQL against
zxy_map_cover instead of using it.

candidateTiles has no LIMIT while probeTile has 200 000. probeTile samples;
candidateTiles scans the whole table, for every layer, at every zoom in the window.
make tile-size only runs ismap so it is never felt, but tiles --size conf/semap.yaml
walks Sweden's 5.4M-row z13 cover partition.

A failed fetch is recorded as a 0-byte tile. sizeOneZoom appends
measured{cx, cy, 0, 0} on error and the top-N sort then discards it silently. A
measurement tool should not turn an error into a plausible-looking zero — at minimum it
should be counted and reported the way --check's MISSING is.

The committed bench JSON is 475 904 B / 26 654 lines. The other records in
bench/results/ are 1–2 kB, and make tile-bench adds one of these per run. The 3 240
per-tile records are 97% of the file; the same report without the per-tile arrays is
14.5 kB. Either gitignore *_tiles.json, drop the SetIndent, or commit the combo
summaries and keep the per-tile detail local.

parseIntStrict is not strict. fmt.Sscanf(s, "%d", &n) stops at the first
non-digit and reports success, so --size-zooms 5x silently means 5, and
--size-zooms 9-3x is rejected for the wrong reason.

checkTileAtZoom re-implements tileTarget.tableAtZoom inline — the same six lines
of prefix derivation, ten lines below the method that already does it.

A static: true layer would be silently skipped. tileTargets only skips
l.Shared != nil; for a static layer tableName(maxz) has no _<z> suffix, so the
prefix derivation builds a table name that does not exist and the layer reports EMPTY
("no rows in … to probe") rather than being checked or deliberately skipped. Latent —
no config declares one today — but EMPTY is not fatal, so it would never be noticed.

Smaller things. decodeMVTTile never returns a non-nil error, and three call sites
check it. probeTile computes n := math.Pow(2, z) and passes it to Sprintf as an
argument the format string never references. benchCombos calls Sweden's whole-country
probe stockholm-whole-country (62.4/15.6, the middle of the country) alongside
iceland-, poland- and finland-whole-country — and that label is in the committed
JSON.

## Review — the tile tool (#139) Good tool, and the design decision is right: the R121 check asserts against the layer *contracts*, and the importer is the only component that resolves them. Reviewed by reading the diff and by using it — `tiles --check` and `tiles --size` both ran against the live DB for every config while working #127 on top of this branch. ### Fixed on the #127 branch (they were in files that branch already touches) **The docs and the code name flags the #129 rewrite deleted.** `docs/importer-cli.md` documents `-tile-size-top`, `-tile-size-zooms` and `-tile-bench-funcs`; `tiles.go`'s doc comments say "implements -tile-check / -tile-bench / -tile-size"; and `parseTileSizeZooms` prints `-tile-size-zooms %q` in two error strings. Copying the documented command gives "flag provided but not defined". **The probe assigns features by bbox north-west corner, not centroid.** `probeTile` and `candidateTiles` both use `ST_XMin(geom)` / `ST_YMax(geom)`, while both code comments and the doc say "the tile holding the most feature centroids". The corner is a fine heuristic for "which tile has data" — the documentation is what's wrong. **`tiles --check` could not see a wrong tile.** It compares key sets and nothing else. `ST_AsMVT`'s `extent` argument is separate from `ST_AsMVTGeom`'s and both default to 4096, so a tile carrying 1024-unit coordinates while declaring 4096 decodes perfectly, has exactly the right keys, passes — and draws at quarter scale in its top-left corner. That is not hypothetical: it is what #127's first version did, and this check is what should have caught it before a browser did. `--check` now also asserts that the declared extent equals `mvtExtent`, and that no feature arrives without a geometry (tags with nothing to draw). Both are FAILs; both were silent before. ### Worth your attention, not fixed **`--size` has no layer filter.** `--size-zooms` exists but there is no `--size-layer`, so measuring one layer's optimization means running all twelve, each doing a full-table pass. That is the #127 workflow this tool exists for; I ended up writing raw SQL against `zxy_map_cover` instead of using it. **`candidateTiles` has no LIMIT while `probeTile` has 200 000.** `probeTile` samples; `candidateTiles` scans the whole table, for every layer, at every zoom in the window. `make tile-size` only runs ismap so it is never felt, but `tiles --size conf/semap.yaml` walks Sweden's 5.4M-row z13 cover partition. **A failed fetch is recorded as a 0-byte tile.** `sizeOneZoom` appends `measured{cx, cy, 0, 0}` on error and the top-N sort then discards it silently. A measurement tool should not turn an error into a plausible-looking zero — at minimum it should be counted and reported the way `--check`'s MISSING is. **The committed bench JSON is 475 904 B / 26 654 lines.** The other records in `bench/results/` are 1–2 kB, and `make tile-bench` adds one of these per run. The 3 240 per-tile records are 97% of the file; the same report without the per-tile arrays is 14.5 kB. Either gitignore `*_tiles.json`, drop the `SetIndent`, or commit the combo summaries and keep the per-tile detail local. **`parseIntStrict` is not strict.** `fmt.Sscanf(s, "%d", &n)` stops at the first non-digit and reports success, so `--size-zooms 5x` silently means `5`, and `--size-zooms 9-3x` is rejected for the wrong reason. **`checkTileAtZoom` re-implements `tileTarget.tableAtZoom` inline** — the same six lines of prefix derivation, ten lines below the method that already does it. **A `static: true` layer would be silently skipped.** `tileTargets` only skips `l.Shared != nil`; for a static layer `tableName(maxz)` has no `_<z>` suffix, so the prefix derivation builds a table name that does not exist and the layer reports EMPTY ("no rows in … to probe") rather than being checked or deliberately skipped. Latent — no config declares one today — but EMPTY is not fatal, so it would never be noticed. **Smaller things.** `decodeMVTTile` never returns a non-nil error, and three call sites check it. `probeTile` computes `n := math.Pow(2, z)` and passes it to `Sprintf` as an argument the format string never references. `benchCombos` calls Sweden's whole-country probe `stockholm-whole-country` (62.4/15.6, the middle of the country) alongside `iceland-`, `poland-` and `finland-whole-country` — and that label is in the committed JSON.
art merged commit 5bb050d946 into main 2026-08-16 22:31:26 +00:00
art deleted branch issue-139-tile-tool 2026-08-16 22:31:26 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
art/ismap!142
No description provided.