The elivagar CLI

August 7, 2026 ยท View on GitHub

The complete command-line surface. This document is the reference for what elivagar accepts; AGENTS.md summarises the commands a session reaches for most, and defers here for the full set.

Note that most work in this repo does not invoke elivagar directly - brokkr wraps the measured paths (brokkr tilegen, brokkr pmtiles-inspect) and records provenance that a raw invocation does not. Reach for the raw binary when there is no wrapper for what you need.

The rule that shapes this surface

Either it is explicit, or it is not set. Nothing is inferred from the filesystem. A run's behaviour is a function of its arguments and its input, and of nothing else.

This is not a style preference; it was bought with a specific failure. The ocean inputs used to be auto-detected from data/: the full shapefile, the simplified shapefile, and data/ocean-tiles.pmtiles. Two consequences followed, and both bit.

The run's meaning lived in the filesystem rather than the invocation. Two runs of the same binary on the same PBF produced different ocean geometry depending on whether a file happened to exist, with nothing in the recorded command line saying which. .brokkr/results.db stores the literal subprocess invocation in cli_args, so no bench row could be classified after the fact as artifact-active or computed - the comparison simply could not be made from the record.

And absence was silent. On 2026-07-14 a denmark archive was built, verified and blessed as the regress baseline while data/ocean-tiles.pmtiles was missing, so it took the computed path throughout while every gate passed. The baseline was wrong about the thing it was baselining, and nothing said so.

Hence: no auto-detection, --ocean names every ocean input, omitting it means no ocean, and a named input that cannot be honoured is an error rather than a fallback.

elivagar run <INPUT> -o <OUTPUT> [OPTIONS]

Generate a PMTiles archive from an OSM PBF.

Input and output

flagmeaning
<INPUT>input OSM PBF (positional, required)
-o, --output <PATH>output PMTiles path (required)
--tmp-dir <PATH>sort chunks and intermediates. Default data/tilegen_tmp

Ocean

--ocean <SPEC> is repeatable and is the only ocean input. Omit it entirely for no ocean. Each value is one of:

specmeaning
z0-z14:<file.shp>one shapefile serves every zoom
z0-z7:<file.shp>low-zoom shapefile (the pre-generalized one)
z8-z14:<file.shp>full-resolution shapefile
<file.pmtiles>precomputed world-ocean artifact

Shapefile entries must partition z0-z14 exactly, and the only accepted partitions are a single z0-z14 or the z0-z7 + z8-z14 pair. That is not arbitrary: ocean::selected_pass_grid implements one split, at z7/z8, and nowhere else. A z0-z5 request is rejected rather than accepted and quietly served at z7, because the alternative is a false statement in the recorded invocation - which is the failure this flag exists to prevent.

The two shapefiles are the OSM standard datasets from osmdata.openstreetmap.de - same coastlines, same projection (EPSG:3857), differing in vertex density. At z0-z7 the whole world is at most 256x128 pixels, so full-resolution coastline vertices are overwhelmingly sub-pixel: the split exists to avoid paying for detail that cannot be seen. It is an optimization, not a correctness requirement, and z0-z14:<full> alone is a legal and slower way to say the same thing - internally it still runs as two passes, with the full shapefile serving both, so the low-zoom behavior below is identical under either spelling.

The low-zoom (z0-z7) pass unions its source pieces before pyramid descent (OCEAN_POLICY_VERSION v3; the current version is v4, which adds the ring-cap partition below). The osmdata shapefiles arrive pre-split into grid cells comparable to low-zoom tile sizes; descending each cell independently simplified shared coastlines into disagreeing shapes (seam wedges on every cell edge) and applied the per-zoom min-area drop per cell fragment (half-peninsula spikes and dropped slivers), which is exactly what the corpus z2 tile showed on 2026-07-19. The union is measured per run: stderr prints one Union: line (pieces/vertices in and out, duration) and the sidecar carries ocean_union_ns, ocean_union_input_pieces, ocean_union_input_vertices, ocean_union_shapes, ocean_union_output_vertices. The z8-z14 pass keeps per-piece descent: its seam mismatch is sub-pixel at fixed tolerances, and a world-scale union of the full polygons would dominate ocean-build.

No emitted polygon exceeds MapLibre's 500-ring clamp (OCEAN_POLICY_VERSION v4). classifyRings applies EARCUT_MAX_RINGS = 500 to each classified polygon

  • an outer plus its following opposite-wound holes - and silently drops all but the 500 largest rings of any polygon past it, so three many-holed ocean polygons were losing their smallest islands in MapLibre and nowhere else. Emission now bisects such a shape's clip rect until every piece fits under the cap; the halves share their integer cut coordinate, so the pieces abut exactly under nonzero fill. The sidecar carries ring_cap_partitions and ring_cap_pieces; the gate is scripts/validate/ring-cap-census.mjs at zero over the cap. Note that an artifact-active extract reads its interior tiles from the artifact, so those two counters read zero there even when the archive carries partitioned geometry - the partition ran during ocean-build.

The .pmtiles artifact is a cache over the shapefiles, not a substitute for them, and is rejected on its own. Two things force this. An extract still computes its boundary band near the bbox edge from the shapefiles and takes only the interior from the artifact (the band is empty only at world bounds). And the artifact's key is validated by re-hashing the shapefiles it claims to have been built from, so both sides must be present for the check to mean anything. Build one with ocean-build.

A named artifact that cannot serve the run is an error. It applies only to the exact MVT + gzip + z0-14 contract at a matching compression level, and a mismatch on any of those four fails the run rather than quietly computing the ocean instead. This used to print Ocean artifact inactive to stderr and carry on - and brokkr reads nothing from tilegen's stderr under --bench, so a measured run could switch ocean paths on four separate conditions leaving no trace in the results row, the sidecar, or the archive. If you want the computed path, say so by dropping the artifact from --ocean.

Example, the production shape:

--ocean z0-z7:data/simplified-water-polygons-split-3857/simplified_water_polygons.shp
--ocean z8-z14:data/water-polygons-split-3857/water_polygons.shp
--ocean data/ocean-tiles.pmtiles

Pipeline

flagmeaning
--skip-to <ocean|sort|assemble>resume from a checkpoint
--in-memorykeep the tile blob in RAM. Faster for small extracts
--compression-level <0-10>gzip level. Default 6
--tile-format <mvt|mlt>payload format. Default mvt
--tile-compression <gzip|brotli>MVT only. Default gzip
--compress-sort-chunks <lz4|snappy>compress sort chunks: less disk I/O, more CPU. Wanted at planet scale, where scratch exceeds RAM
-j, --threads <N>rayon pool and pbfhogg decode pool. Default: logical CPUs

A resume validates the checkpoint's input identity, producer config, and ocean source against the current run and refuses a mismatch, so --skip-to cannot silently blend two contracts into one archive.

The ocean half of that is checkpoint v6 (2026-07-14). v5 recorded computed ocean as the bare string computed, naming no source, so chunks built from one shapefile could be reused by a resume naming another while the archive's metadata described the second - the artifact arm had carried its key since it was introduced and the computed arm simply had not. Both arms now carry their producer's identity: shapefile content hashes plus the zoom span, and deliberately not the compression level, which is assemble-side and may legitimately differ across a resume. v5 and earlier checkpoints are refused; re-run a full tilegen.

Node and way handling

flagmeaning
--locations-on-waysPBF has node coordinates embedded in ways. Auto-detected from the PBF header; this forces it when the header does not declare it
--force-sorteduse the compact node store without the PBF header flag. Errors if nodes are not monotonic
--allow-unsafe-flat-indexbypass flat-index guardrails. Expert debugging only; may cause severe IO/RSS degradation

--locations-on-ways reads a PBF header, not the filesystem, and is the one piece of detection that survives: it is a property of the input file, which is itself named explicitly and hashed into provenance.

Memory budgets

flagdefaultmeaning
--sort-budget <SIZE>1Gper-chunk sort buffer. Min 64M. Lower cuts phase12 RSS, costs merge chunks
--way-budget <SIZE>128M, or 4G under --locations-on-waysin-flight way processing, in raw bytes (decompressed block size plus measured plan bytes). Min 1M. The count ceiling (threads blocks in flight) is the primary admission control; this budget is a safety net against individually huge blocks and never binds on normal input, whatever the blob packing. See the constant's comment in src/pipeline/phase12.rs
--dedup-cap <N>1000000entry cap for the PMTiles payload dedup map. Tile content is identical under any cap; past it duplicate payloads are stored instead of referenced, costing archive bytes, and the map costs ~56 bytes/entry of RAM. Deliberately outside the provenance contract, like the budgets: it cannot change what a consumer decodes
--assemble-budget <SIZE>32Mtile assembly batch. Min 1M

Sizes accept 256M, 1G, or raw bytes.

Geometry

flagmeaning
--fanout-cap-default <N>default fanout cap for all polygon layers. 0 or omitted = uncapped
--fanout-cap <layer=N,...>per-layer fanout caps; takes precedence over the default. Features whose bbox tile count exceeds the cap are skipped at that zoom
--polygon-simplify-factor <F>simplification multiplier for polygon layers. Default 1.0 (same as lines). Higher simplifies harder, cutting sort volume; fills tolerate this better than stroked lines do

elivagar ocean-build --ocean <SPEC> -o <OUTPUT>

Build the durable world-ocean PMTiles artifact. One shot per shapefile release, not per run.

flagmeaning
--ocean <SPEC>repeatable, same spelling as run --ocean, shapefiles only. Required
-o, --output <PATH>output artifact path (required)
--tmp-dir <PATH>default data/ocean-build_tmp
--compression-level <0-10>default 6
-j, --threads <N>default: logical CPUs

The --ocean spelling is shared with run deliberately: the shapefiles and the zoom split are part of the artifact's identity, so the key it records and the key a run recomputes are derived from the same statement. An artifact is valid only for a run naming the same shapefiles at the same compression level over z0-14.

Rotating the artifact is an output-changing event. The corpus contract records the artifact key, so the next brokkr pmtiles-corpus check refuses with the key mismatch named until the corpus is re-blessed (brokkr pmtiles-corpus bless --rotate) from a build carrying the new artifact - and that rotation commit is the review.

OCEAN_POLICY_VERSION (src/ocean.rs) is the code half of the artifact key: the shapefile hashes catch input rotations, the version stands in for "the geometry pipeline changed". Any change that alters emitted ocean geometry or encoding must bump it, which makes every existing artifact fail the key check loudly instead of silently serving pre-change geometry - the 2026-07-15 stale-artifact incident (the VW landing shipped without a bump; DP-era coastline spikes served world-wide for three days with all gates green) is the motivating case.

Retention: when the output path already holds an artifact, ocean-build keeps the outgoing generation as <stem>-v<policy_version>-<build_date>.pmtiles next to it (version from the artifact's own metadata, unknown if unreadable; date from its mtime), one generation deep - the previous retained copy is removed, but only names matching that exact scheme are ever deleted, so manually kept copies survive. The keep is a hard link made before the build starts: the active path is never empty, a failed rebuild changes nothing, and the extra ~1 GB materializes only when the finalize rename swaps the inode out. The retained generation is the comparand for the archive-bracketing idiom (brokkr svg -o the same tile from each generation, diff one ROI with scripts/validate/svg-roi.mjs) - without it, every policy bump destroys the only artifact of the version it replaces.

elivagar inspect <FILE>

Header, tile statistics, section layout, provenance, and metadata (layer list with zoom ranges) for a PMTiles archive. Wrapped by brokkr pmtiles-inspect - the brokkr name avoids colliding with brokkr inspect, which is pbfhogg's PBF inspector.

Provenance

The elivagar metadata member (reference/metadata.md, schema at src/provenance.rs), contract first:

  Provenance:  schema 1
    Input:      north-america-seq4710-locations.osm.pbf
                xxh3 8122bcc83873ef95349e6a3522827fd9  17.8 GB
                features: sorted, locations-on-ways
    Config:     shortbread, z0-z14, polygon simplify x1
                tile: mvt gzip, base level 6, policy zoom-v1
                fanout: none
    Ocean:      artifact, low zoom simplified, simplifier on
                key: shp c10be1c7843c simplified b3417e31c287 level 6 policy 1
    Build:      elivagar b833fc8730cd, pbfhogg 0f1eb01a1c1e
    Effective:  coords inline, way members relation_scan, pins block_local

Input and Config together are the comparability contract, and the whole of both is printed - not a selection. That matters: two archives differing only in polygon_simplify_factor or one fanout cap would otherwise display identically, and a reader told these lines are the contract would conclude a geometry diff between them says something about the code. It is cheap to print in full because the fanout map omits defaults, so it is a line at most.

Build, Effective and Resumed (only on a --skip-to run) are diagnostic. They explain a diff once the contract matches and must never be equality-gated - given identical input and config they are a function of the code, and a regression gate exists to compare revisions.

For the standing gate, brokkr pmtiles-corpus check enforces the contract before reading content, decoding this block through the linked crate. brokkr regress does not read the block, so for an ad-hoc comparison these lines let a human refuse; they do not refuse it. See reference/metadata.md.

Ocean answers "was this artifact-active or computed", which before this existed could only be recovered by dumping the raw metadata - and on 2026-07-14 was not recovered at all, which is how a computed-path archive was blessed as the baseline for an artifact-active pipeline. Its key line carries the shapefile identities, which appear nowhere else in the block: an artifact-active archive's contract is incomplete without them.

features reports the PBF header bits, not the filename. A name ending -locations-prepass is a label and can lie; the bits decide which coordinate, membership and pin paths the run took, and Effective says which it then used. Unset features are omitted, so features: none means a plain PBF - but a missing flag reports unknown rather than being read as false, since none is a positive claim and must not be derived from absent data.

Commits are abbreviated to 12 characters for reading; the block stores them in full and that remains the identity. (dirty) means the commit names the nearest ancestor of the code that ran, not the code that ran.

Every way of having no contract names itself, because "no block", "unreadable metadata" and "not JSON" are different facts and reporting any of them as silence would read as nothing to report:

  Provenance:  absent - archive predates the elivagar metadata block
  Provenance:  unavailable - archive stores no metadata
  Provenance:  unavailable - metadata could not be read or decompressed
  Provenance:  invalid - metadata is not JSON
  Provenance:  schema 99 - this build understands 1; not interpreted
  Contract:    INCOMPLETE - block is missing input and config

A schema this build does not know is refused rather than summarised: a bump means an existing field changed meaning, so interpreting it with these meanings would report confident nonsense. Adding members does not bump, so unknown members at a known schema are ignored silently.

For the raw block use scripts/dump-pmtiles-metadata.py.

elivagar verify <FILE> [--geometry-stats] [--unique-payloads]

End-to-end archive validation: container integrity, metadata schema, tile decompression, MVT payload structure, geometry commands, layer coverage, and ocean ring self-intersection. Exit 0 on pass, 1 on failure; stops after 100 tile-level errors.

flagmeaning
--geometry-statsper-zoom ocean statistics: ring counts, max/p99 ring vertices, consecutive duplicates, full-tile fills
--unique-payloadsvalidate each distinct compressed payload once, keeping addressed-tile accounting. For very large run-heavy archives such as the ocean artifact

verify is a standing gate, but it is a structural one: it cannot see a tessellation fault that decodes cleanly. That is what the earcut oracle (scripts/validate/earcut-oracle.mjs) is for.

The corpus gate and regress - moved to brokkr (2026-07-24)

The corpus namespace (check, bless, render, render-manifest, rings, mutate) and regress are no longer elivagar subcommands. The corpus redesign moved the whole adjudication layer - the canonical semantic tile hash, the digest fold, the contract gating policy, the canonical SVG render core, the calibration instruments, and every baseline verdict - into brokkr, which links this crate and decodes archives in-process. The spellings are brokkr pmtiles-corpus check|bless|render|render-manifest| rings|mutate and brokkr regress; there is no raw elivagar spelling. reference/corpus.md remains the methodology. The baseline state stays committed in this repo at corpus/<dataset>/ + corpus/style.toml, read and written only by brokkr, so rotations stay atomic with their landing commits.

What elivagar exposes instead is a library surface: the pmtiles reader (including run-level raw payload access), the tile_detail wire-order MVT decoder with selectable unknown-field strictness (strict is the gate's mode - foreign wire fields error; tolerant skips them, for cross-producer comparison), typed provenance in full, and the writer's add_run plus the mutate-only set_metadata_verbatim. The comparability policy went with the gate: the provenance schema lives here, the decision of which fields gate lives in brokkr.

brokkr compare-tiles, the lenient cross-producer sampling comparison, is likewise brokkr-native (historically an elivagar cargo example, never a subcommand).

elivagar svg <FILE> -z <Z> -x <X> -y <Y> [OPTIONS]

Render tiles as SVG. Single tile or an NxM grid.

flagdefaultmeaning
-z, -x, -ytile coordinates (top-left of the grid)
-W, --width <N>1grid width in tiles
-H, --height <N>1grid height in tiles
-l, --layers <LIST>allcomma-separated layer filter, e.g. ocean,boundaries
-o, --output <PATH>stdoutoutput path

Polygons fill with nonzero, matching MapLibre; points are circles, lines are stroked paths, background is land-coloured. Grid lines are drawn when width or height exceeds 1.

elivagar diag <FILE> -z <Z> -x <X> -y <Y>

Ring winding for one tile: per-ring vertex count, signed area, and direction (CW outer, CCW hole) for every polygon feature across all layers. Prints first/last 3 vertices for large rings, all vertices for rings of 6 or fewer.

Environment variables

None of these duplicates a flag or outranks one. A knob is a flag or an env var, never both, because two ways to say one thing means the quieter one wins silently.

They are recordable: brokkr's capture_env in brokkr.toml matches ELIVAGAR* and MALLOC* and stores what it finds with the run, so an env-configured run can be reconstructed from the results row. That is what makes them legitimate rather than ambient - before it, the knob below that moves NA assemble by 26s was tunable from any shell and recorded nowhere.

vareffect
ELIVAGAR_ASSEMBLE_WORKERSassemble worker cap, default 8. Measured on NA locations: 4 workers left the writer idle 68% of assemble; 8 cut assemble 186.4 to 160.7s; 12 was WORSE at 176.1s and +3 GB RSS - encode CPU saturates near 8
ELIVAGAR_ASSEMBLE_PARK_BUDGETbytes of encoded-but-unwritten tiles workers may run ahead of the writer, default 2 GiB. The RAM ceiling on assemble, and the term the planet ledger's 6-9 GB assemble estimate is derived from
ELIVAGAR_ASSEMBLE_SPLIT_TARGETpiece size in record bytes for hot-partition splitting, default 64 MiB; a partition over twice this splits into contiguous tile-range pieces read by independent workers (H8b - one dense z14-block partition otherwise puts hundreds of encoded MB behind one ordered slot). Output is byte-identical split or unsplit; set a tiny value to force splits on a small extract for that gate
ELIVAGAR_REL_BLOCKS_CAPrelation-block buffer cap in bytes. Past it the tail re-reads relation blobs instead of holding them; set it to 1 to force the spill path on a small extract
ELIVAGAR_LAYER_STATS=1per-layer per-zoom sort-stats firehose (~800 counters). Emitted at end of run, so it never affects timing. The per-layer totals are always emitted
BROKKR_MARKER_FIFOset by brokkr. Phase markers and counters go here; unset means every counter in src/debug.rs is a silent no-op

BROKKR_MARKER_FIFO is why a bare elivagar run emits no metrics at all: the sidecar numbers exist only for runs brokkr started with a measurement flag.

The harness cannot express "allow brokkr with any env assignments", so VAR=x brokkr ... is blocked in-session; scripts/envrun.sh exists only to carry that, and gates nothing.

What is not on this surface, and why

The corpus and regress subcommands were removed on 2026-07-24 by the corpus redesign - see the moved-to-brokkr section above. The gate machinery was development tooling in an end-user binary; it now lives in brokkr, which links this crate.

--no-ocean, --no-ocean-simplify, --ocean-simplified and --ocean-tiles were removed on 2026-07-14 along with the auto-detection.

--no-ocean existed only to defeat the auto-detection, so it died with it: omitting --ocean now means what --no-ocean used to.

--ocean-simplified and --ocean-tiles folded into --ocean's zoom-range spelling. Both named an ocean input; there is now one flag for that, and its value states the coverage it claims rather than leaving it to the flag's name.

--no-ocean-simplify skipped the ocean VW simplifier to emit verbatim polygons for an ocean-coverage baseline. It was a production-binary flag whose only purpose was producing deliberately non-production tiles for a diagnostic that had already been demoted from gate to triage. The ocean-coverage subcommand followed it out on 2026-07-24: with no way left to produce a verbatim baseline it was unfeedable, and its triage role is covered baseline-free by scripts/validate/zoom-overlay.mjs, svg-roi.mjs, and regress --overlay (a confirmed false negative on the 2026-07-12 VW landing is why it could never be a gate; the authoritative ocean gates remain the earcut oracle and the human visual check). provenance.rs keeps the runtime_simplification field, invariably true on archives built after the removal and possibly false on older ones - dropping the field would make those archives incomparable to these, which is the opposite of what the block is for.