osm-katana/src/bin/main.rs
use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
/// Which recorded defect budget `verify --geometry-quality` judges a cut against.
/// The budgets themselves live in `osm_katana::verify` with their provenance — this
/// is only the CLI's name for them.
#[derive(Clone, Copy, Debug, ValueEnum)]
enum BaselineArg {
/// `GeometryBaseline::ZERO` — nothing tolerated.
Zero,
/// `GeometryBaseline::RECORDED` — the live Sweden census of 2026-09-02.
Recorded,
}
#[derive(Parser)]
// `--version` / `-V` prints `osm-katana v<semver> (<git-hash>)` — the build-version
// law, one source (`osm_katana::VERSION_LINE`, stamped by `build.rs`).
#[command(name = "osm-katana", about = "Fast OSM format converter", version = osm_katana::VERSION_LINE)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Convert OSM (XML / BZ2 / GZ / PBF) → GeoParquet
Convert {
input: PathBuf,
#[arg(short, long, default_value = "out")]
output: PathBuf,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
nodes: bool,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
ways: bool,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
relations: bool,
/// Write the `node_refs` column of `ways.parquet`.
///
/// MEASURED on korp's 2026-08-03 `sweden` cut: 237.1 MB of an 893.3 MB
/// `ways.parquet` — **26.5 % of the file**, 1 009.3 MB of 3 648.3 MB
/// uncompressed. Only two consumers read it: the relation ring assembly,
/// which matches member-way endpoints by node id (so a cut that will be
/// handed to `osm-katana relations` needs it), and `osm-katana verify`.
/// A pure map-delivery cut needs neither.
///
/// `--node-refs false` OMITS the column rather than writing empty lists —
/// an absent column is honest, a present empty one would claim every way
/// has no nodes. Defaults to `true`, so no published pack changes shape.
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
node_refs: bool,
#[arg(long, default_value = "zstd")]
compression: String,
#[arg(long, default_value_t = 0)]
vtd_workers: usize,
/// resolved (2-pass, ways → WKB) | raw (1-pass, keep node-ID list)
#[arg(long, default_value = "resolved")]
geometry: String,
#[arg(long, default_value_t = false)]
skip_changesets: bool,
#[arg(long)]
log: Option<PathBuf>,
/// Region clip: a preset (europe|nordics) or a raw bbox
/// min_lon,min_lat,max_lon,max_lat. Nodes outside are dropped at decode
/// (during the parallel convert — zero extra pass). Mutually exclusive
/// with --poly. Requires the default 'resolved' geometry.
#[arg(long, conflicts_with = "poly")]
clip: Option<String>,
/// Region clip from a polygon file: Osmosis `.poly` or GeoJSON
/// (Polygon/MultiPolygon). Clips to the region's REAL shape via a
/// per-node ray-cast inside the workers (bbox pre-filtered). Mutually
/// exclusive with --clip.
#[arg(long)]
poly: Option<PathBuf>,
/// Keep the `node_coords.arrow` intermediate after the convert finishes.
///
/// It is the mmap-backed node store pass 2 resolves way geometry through,
/// so it is always WRITTEN; this only controls whether it is kept. It is
/// large — 75 MB for Stockholm, **1.68 GB for Sweden** — and buys nothing
/// for a shipped GeoParquet pack. `--keep-node-coords false` deletes it
/// (and any `_nc_chunk_*.bin` spill files) once the parquet is closed.
/// Defaults to `true`, so existing behaviour is unchanged.
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
keep_node_coords: bool,
/// Skip pass 1 when a complete `node_coords.arrow` from THIS input and
/// THIS clip is already in the output dir.
///
/// Pass 1 exists only to build that file (plus `nodes.parquet`); a run
/// that already has them can open the store straight off disk and go
/// directly to pass 2. On the Sweden extract that is 1 365 s of wall not
/// paid twice, which is what makes an interrupted convert resumable
/// instead of merely restartable.
///
/// Reuse is gated on a provenance record written beside the artefact and
/// is refused — out loud, naming the reason — on any mismatch in input
/// path, length, mtime or clip, and on any artefact missing its magic at
/// both ends. `--reuse-node-coords false` forces a full pass 1.
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
reuse_node_coords: bool,
/// Hilbert-pack the base tables before returning — the same layout
/// `osm-katana spatial-pack` and `osm-katana networks` produce.
///
/// Convert writes in PBF order, so every row group spans the whole
/// extract and a viewport query has nothing to prune on. MEASURED on the
/// Sweden cut: central Stockholm out of the unpacked 902.13 MB
/// `ways.parquet` is a full scan — **902.13 MB, 3 538.2 ms**. Packed, the
/// same answer reads 71 of 1 139 row groups: **27.08 MB, 144.0 ms**.
///
/// The price, also measured: 78.3 s for the three Sweden tables and up to
/// 15.6 GB resident while the largest one is sorted.
/// `--pack false` leaves the tables in convert order.
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
pack: bool,
},
/// **Spatially pack** a GeoParquet dir/file: sort rows on a Hilbert curve
/// over their geometry and add a GeoParquet 1.1 `covering.bbox` column, so a
/// reader can prune whole row groups by min/max statistics and fetch only the
/// byte ranges its viewport touches. Content-preserving — same rows, same
/// values, new order (`verify --digest` `set` is unchanged).
SpatialPack {
/// Input GeoParquet directory (nodes/ways/relations.parquet) or a single file.
input: PathBuf,
/// Output directory (or file, if `input` is a file).
#[arg(short, long, default_value = "packed")]
output: PathBuf,
/// Rows per row group. Smaller ⇒ finer pruning, more footer bytes.
/// Default measured — see `spatial::PACK_ROW_GROUP_ROWS`.
#[arg(long, default_value_t = osm_katana::spatial::PACK_ROW_GROUP_ROWS)]
row_group_rows: usize,
/// Emit the `covering.bbox` column. `false` = Hilbert ordering only
/// (schema unchanged) — useful to isolate ordering from covering.
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
covering: bool,
/// Emit the generalisation columns `geometry_z6`/`z9`/`z12` (DELTA 2).
/// `false` is the measurement's RED arm — the same viewport read then has only
/// full-precision `geometry` to project, and the decode cost comes back.
/// Ignored for point-only tables, which have nothing to simplify.
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
generalise: bool,
/// Move `node_refs` out of the packed table into `<name>.refs.parquet` beside
/// it (row 22). MEASURED on the live Sweden cut: `node_refs` is **437.3 MB
/// compressed, 28.4 % of `ways.parquet`** — more than every rung column
/// together — and nothing korp serves reads it. The two consumers that do,
/// `osm-katana relations` and `osm-katana verify`, read the sidecar.
#[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
node_refs_sidecar: bool,
#[arg(long, default_value = "zstd")]
compression: String,
},
/// **Cut the road / rail / trail networks** out of a region extract as their own
/// GeoParquet tables — stored ONCE, Hilbert-sorted with a `covering.bbox` column, and
/// never duplicated into a tile. LOD for a network is a `WHERE class IN (…)`, not a
/// pyramid, so one cut serves every zoom. See `osm_katana::network`.
Networks {
/// Region extract directory (the one holding `ways.parquet`).
input: PathBuf,
/// Output directory for `roads.parquet` / `rails.parquet` / `trails.parquet`
/// plus `networks.json`. Write it BESIDE a served tile pyramid, never into it.
#[arg(short, long, default_value = "networks")]
output: PathBuf,
/// Only these networks (default: all three).
#[arg(long, value_delimiter = ',')]
only: Option<Vec<String>>,
/// Rows per row group — the bbox-pruning granularity. Default measured —
/// see `spatial::PACK_ROW_GROUP_ROWS`.
#[arg(long, default_value_t = osm_katana::spatial::PACK_ROW_GROUP_ROWS)]
row_group_rows: usize,
#[arg(long, default_value = "zstd")]
compression: String,
/// Hard ceiling on selected rows held in memory. A stop, not a warning.
#[arg(long, default_value_t = 40_000_000)]
max_rows: usize,
/// Skip the adjacency (`graph_edges` / `graph_nodes`). Off by default — a network
/// table without a graph can only answer "which lines cross this box".
#[arg(long)]
no_graph: bool,
},
/// **Cut the `buildings` table** out of a region extract — one row per
/// `building=*` / `building:part=*` feature, with `height_m`, `min_height_m`,
/// `levels` and a `height_source` stamp saying where each number came from.
///
/// The point is the stamp. MEASURED on the Sweden cut 2026-08-31: 95.68 % of
/// buildings carry no height information at all, 4.15 % have it derived from
/// `building:levels`, 0.24 % carry a tagged `height`. `buildings.json` reports that
/// histogram, so an operator learns the coverage without opening the parquet. See
/// `osm_katana::side_outputs::buildings` and `osm_katana::shared::height`.
Buildings {
/// Region extract directory (the one holding `ways.parquet`).
input: PathBuf,
/// Output directory for `buildings.parquet` + `buildings.json`. Write it BESIDE
/// a served tile pyramid, never into it.
#[arg(short, long, default_value = "buildings")]
output: PathBuf,
/// Rows per row group — the bbox-pruning granularity. Default measured —
/// see `spatial::PACK_ROW_GROUP_ROWS`.
#[arg(long, default_value_t = osm_katana::spatial::PACK_ROW_GROUP_ROWS)]
row_group_rows: usize,
#[arg(long, default_value = "zstd")]
compression: String,
/// Hard ceiling on selected rows held in memory. A stop, not a warning.
#[arg(long, default_value_t = 40_000_000)]
max_rows: usize,
/// Leave `building:part` rows out. They are the rows carrying `min_height`, so
/// dropping them makes a tower's parts vanish — off by default.
#[arg(long)]
no_parts: bool,
/// Carry the raw `roof:shape` value as its own column. **Off by default and
/// measured**: it MOVES the recipe token, so every buildings table on every box
/// reads Stale and is re-cut. The 3D lane's switch; see the module docs on
/// `roof_shape_field` for why the raw string and not a bucket code.
#[arg(long)]
roof_shape: bool,
/// Skip DELTA 2's generalisation columns (`geometry_z6` / `z9` / `z12`). Off by
/// default — a z6 read of 3.8 M footprints without them pays the full WKB decode.
#[arg(long)]
no_generalise: bool,
},
/// **Ask the road/rail GRAPH a question a table of geometries cannot answer.**
///
/// Reads `graph_nodes.parquet` + `graph_edges.parquet` (cut by `networks`) into a CSR
/// that respects `oneway`, and runs one of:
///
/// * `unguarded-crossings` — every `railway=level_crossing` that the ADJACENCY says is
/// a real joint (a road edge and a rail edge both end there) and that carries no
/// barrier, light, bell or supervision, with the road classes that meet there.
/// * `nearest-crossing` — Dijkstra from `--lon`/`--lat` to the closest unguarded
/// crossing **along the network**, reported beside the crow-fly distance to the same
/// node. The gap between the two numbers is the whole argument for the graph.
Graph {
/// The networks directory holding `graph_nodes.parquet` and `graph_edges.parquet`.
dir: PathBuf,
/// `unguarded-crossings` | `nearest-crossing`.
#[arg(long, default_value = "unguarded-crossings")]
question: String,
#[arg(long, allow_hyphen_values = true)]
lon: Option<f64>,
#[arg(long, allow_hyphen_values = true)]
lat: Option<f64>,
/// How many rows of detail to print.
#[arg(long, default_value_t = 15)]
limit: usize,
},
/// **Cut a slippy-tile GeoParquet pyramid** out of a converted extract, and
/// write the `tiles/manifest.json` beside it.
///
/// This is the same [`osm_katana::tiles::tile_region`] korp's `--tile-maps`
/// calls — one writer of a pyramid, so a cut done by hand and a cut done by the
/// server produce the same objects and the same [`osm_katana::tiles::TileRecipe`]
/// (LAW 5). It exists because measuring a pyramid, or cutting one BESIDE a live
/// one, previously meant either driving korp (which ships to S3 and restarts a
/// server) or a bench (which deletes what it produced).
///
/// It writes files and nothing else: no S3, no manifest upload, no service.
Tile {
/// Converted extract directory holding `ways.parquet` / `nodes.parquet`.
input: PathBuf,
/// Output directory. The pyramid lands under `<output>/tiles/z{Z}/{X}/{Y}.parquet`.
#[arg(short, long, default_value = "tiled")]
output: PathBuf,
/// Zoom levels to cut, comma-separated. Omitted ⇒
/// [`osm_katana::tiles::DEFAULT_TILE_ZOOMS`].
///
/// **`Option` rather than a `default_value` literal on purpose (LAW 5).** A
/// clap `default_value` must be a literal, so spelling the ladder here made
/// this CLI a SECOND writer of the default zoom set: when `DEFAULT_TILE_ZOOMS`
/// gained `15` on 2026-08-03 this string still said `6,9,12`, so a hand cut and
/// the server's cut would have produced pyramids with different recipes — the
/// exact disagreement this subcommand exists to make impossible.
#[arg(long)]
zooms: Option<String>,
/// Region name written into the manifest.
#[arg(long, default_value = "region")]
region_name: String,
/// Region bbox `min_lon,min_lat,max_lon,max_lat`. Tiles outside it are refused
/// at the one point a tile comes into existence. Without it a leaked foreign
/// object IS tiled where it lies.
#[arg(long)]
region: Option<String>,
/// Resident tile-payload budget in GiB; over it the biggest tiles spill to a
/// per-shard scratch file. `0` ⇒ unbounded. Omitted ⇒ the library's
/// `default_tile_mem_budget()`, which scales with this box's MemAvailable —
/// NOT a 4.0 spelled here, which is how this binary and korp's
/// `run_tile_maps` came to cut with two different budgets (LAW 5).
#[arg(long)]
budget_gib: Option<f64>,
/// Scan workers. `0` ⇒ all cores.
#[arg(long, default_value_t = 0)]
workers: usize,
/// Simplification basis in pixels per tile — part of the `TileRecipe`, so
/// changing it makes every existing pyramid read as STALE. Omitted ⇒
/// [`osm_katana::tiles::DEFAULT_TILE_RASTER_PX`].
///
/// **`Option` rather than `default_value_t = 2048`, for the same reason
/// `--zooms` is (LAW 5).** A clap `default_value_t` is a literal, so `2048`
/// spelled here made this CLI a SECOND writer of a `TileRecipe` axis: the day
/// the library's simplification basis moves, a hand cut and korp's cut stamp
/// different tokens and each judges the other's pyramid STALE. The literal
/// cannot be checked against the constant by anything — but "not given" can be,
/// because "not given" is read at run time.
#[arg(long)]
raster_px: Option<u32>,
#[arg(long, default_value = "zstd")]
compression: String,
/// Hand `highway=*` and `railway=*` to a network table instead of duplicating
/// them into every tile they cross. Part of the `TileRecipe`. Omitted ⇒ the
/// library's default (`TileOptions::default().exclude_networks`).
///
/// `Option` for the same reason as `--raster-px`: `default_value_t = true` is a
/// literal restatement of a library default, and it also erased the difference
/// between "the operator did not say" and "the operator asked for the default"
/// — which are not the same request when the default is what moves.
#[arg(long, action = clap::ArgAction::Set)]
exclude_networks: Option<bool>,
},
/// Convert OSM XML (or .osm.bz2) → zstd-compressed PBF
Xml2Pbf {
input: PathBuf,
output: Option<PathBuf>,
#[arg(long, default_value_t = false)]
skip_changesets: bool,
},
/// Convert OSM PBF → GeoParquet
Pbf2Geo {
input: PathBuf,
#[arg(short, long, default_value = "out")]
output: PathBuf,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
nodes: bool,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
ways: bool,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
relations: bool,
#[arg(long, default_value = "zstd")]
compression: String,
},
/// Convert GeoParquet → Arrow IPC
Geo2Arrow { input: PathBuf, output: PathBuf },
/// Shrink a GeoParquet dir into a small, embed-ready GeoParquet (for a wasm
/// demo's `include_bytes!`). Trims to geometry + a slimmed tag set, keeps
/// named POIs first, caps the feature count, and zstd-compresses — the
/// gatling (1 reader → N workers → 1 writer) way.
Optimize {
/// Input GeoParquet directory (with nodes.parquet / ways.parquet).
input: PathBuf,
/// Output GeoParquet FILE.
#[arg(short, long, default_value = "optimized.parquet")]
output: PathBuf,
/// Keep only node points (read nodes.parquet, skip ways/relations).
#[arg(long, default_value_t = false)]
points_only: bool,
/// Fold ways.parquet in too (ignored with --points-only). Default on.
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
ways: bool,
/// Rank named (name/place/POI-tagged) features first under --max-features.
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
named_first: bool,
/// Keep ONLY named features (drops everything without a name/place/POI tag).
#[arg(long, default_value_t = false)]
named_only: bool,
/// **Buildings mode** — keep every `building=*` way (regardless of name) +
/// the roads/water ways, preserving `height` / `building:levels` so the
/// 3D map viewer can extrude real city footprints. Named POIs are kept too;
/// unnamed clutter is dropped. Buildings are ranked first under
/// `--max-features`. Overrides `--named-only`.
#[arg(long, default_value_t = false)]
buildings: bool,
/// Cap total output features (0 = unlimited), named-first if --named-first.
#[arg(long, default_value_t = 0)]
max_features: usize,
/// Parquet compression: zstd (default) | snappy | none.
#[arg(long, default_value = "zstd")]
compression: String,
/// Optional phase-log JSONL path (cores-busy is always echoed to stderr).
#[arg(long)]
log: Option<PathBuf>,
},
/// **Cut a region's administrative boundary out of a planet** as an Osmosis
/// `.poly` — from a `.osm.pbf` OR, block-parallel, straight from a
/// `.osm.bz2`.
///
/// This is where a clip polygon is supposed to come from: the same OSM data
/// the map is cut from, not a download. `--admin-level 2` is the sovereign
/// national border, whose outer ring is by definition the official one —
/// islands and all.
AdminPoly {
/// Planet or extract: `.osm.bz2` (block-parallel bz2 sweep) or `.osm.pbf`.
input: PathBuf,
/// Region name, matched case-insensitively against `name` and `name:en`
/// (so `sweden` finds `name:en=Sweden` / `name=Sverige`).
#[arg(short, long)]
name: String,
/// Required `admin_level` (2 = country). Omit to accept any level.
#[arg(long)]
admin_level: Option<String>,
/// Where to write the `.poly` (stdout summary either way).
#[arg(short, long)]
output: Option<PathBuf>,
/// Polygon name written into the `.poly` header (default: `--name`).
#[arg(long)]
poly_name: Option<String>,
/// bz2 worker fan-out (0 = every core but one, for the reader).
#[arg(long, default_value_t = 0)]
workers: usize,
/// Phase-log JSONL (per-pass wall time + cores busy), same shape as
/// `convert --log` so the two are comparable.
#[arg(long)]
log: Option<PathBuf>,
},
/// Inspect / verify GeoParquet output directory
/// **What is actually IN these tables — a census whose books close.**
///
/// Every population reported sums to the file's own footer `num_rows`, every line
/// carries an explicit `UNACCOUNTED`, and a partition that does not close prints NO
/// percentage and exits non-zero. See `osm_katana::census` for the wrong 57.0 %
/// figure this verb exists to make impossible.
///
/// Rows, bytes, row groups, column bytes and covering extents are FOOTER ONLY — no
/// data page is read, and that is the whole speed of the tool. `--tags`,
/// `--geometry` and `--classes` each read a real column and cost accordingly.
Census {
/// Directory to census. In `--pyramid` mode this is the pyramid ROOT (the
/// directory holding `z6/`, `z9/`, …) — never a flattened copy of it.
#[arg(default_value = "out")]
dir: PathBuf,
/// Census a tile pyramid (`z<Z>/<X>/<Y>.parquet`) instead of an extract's
/// tables. Files that are not at a pyramid address are REFUSED rather than
/// sampled, and every tile's covering box is checked against the address its
/// path names.
#[arg(long)]
pyramid: bool,
/// Count tags — all three spellings of "no tags" (NULL cell, empty string,
/// `{}`), and a source with no `tags` column at all fails OPEN into a bucket of
/// its own rather than being called untagged. Reads the `tags` column.
#[arg(long)]
tags: bool,
/// Geometry-type histogram from each row's WKB header (Point / LineString /
/// Polygon / Multi*). Reads the `geometry` column, which is the fat one.
#[arg(long)]
geometry: bool,
/// Per-class breakdown via the ONE classifier (`shared::class`); implies
/// `--tags`.
#[arg(long)]
classes: bool,
/// Also write the machine-readable census here, so a future agent quotes a file
/// instead of re-deriving a figure with `cp`. Includes the input identities —
/// paths, sizes, mtimes and the recipe token where one exists.
#[arg(long)]
json: Option<PathBuf>,
/// Scan workers. `0` ⇒ one per core (gatling's default).
#[arg(long, default_value_t = 0)]
workers: usize,
},
Verify {
#[arg(default_value = "out")]
dir: PathBuf,
/// Also census every geometry column's SHAPE QUALITY (`geometry` + every
/// `geometry_z*` rung column): decode failures, the point/line/polygon
/// histogram, unclosed rings, sub-4-vertex rings, collapsed (zero-area)
/// outers and self-intersecting rings — with books that close per column.
///
/// The VERDICT comes from `verify::check_cut_geometry`, the same call the cut
/// chain's post-condition step makes, judged against `--geometry-baseline`:
/// decode failures, unclosed rings, self-intersections ABOVE the record and
/// unbalanced books are FAULTS (exit non-zero). Sub-4-vertex rings and
/// collapsed areas are reported and gate nothing.
#[arg(long)]
geometry_quality: bool,
/// Which recorded defect budget `--geometry-quality` judges against.
///
/// `recorded` (default) is the live Sweden census of 2026-09-02 — it tolerates
/// exactly the defects that were counted then, prints each one as a DEBT on
/// every run, and fails on one more. `zero` tolerates nothing: what a fresh cut
/// off the fixed encoder must pass, and what proves a cut clean.
#[arg(long, value_enum, default_value_t = BaselineArg::Recorded)]
geometry_baseline: BaselineArg,
/// Also print a layout-invariant CONTENT digest per table (`table rows
/// digest`). Two converts of the same input agree here iff they produced
/// the same rows in the same order — the equivalence gate a raw file
/// checksum cannot give, because row-GROUP boundaries move run-to-run
/// (the writer encodes groups on the gatling workers). Use it to prove a
/// perf change did not alter output.
#[arg(long)]
digest: bool,
},
/// **Clip and assemble the relation table.** Reads `<dir>/relations.parquet`
/// + `<dir>/ways.parquet`, keeps only the relations a member of this extract
/// can reach, and fills the `geometry` column of `type=multipolygon` /
/// `type=boundary` relations with an assembled WKB MultiPolygon (holes and
/// all). Writes `relations.resolved.parquet` beside the input unless `-o`
/// says otherwise — it never overwrites its own source.
///
/// MEASURED, korp's 2026-08-03 `sweden` cut: 14 300 803 relations in,
/// 268 857 out (1.880 %). The rest is the planet's relation space carried
/// through a regional extract.
Relations {
/// GeoParquet directory holding relations.parquet + ways.parquet.
dir: PathBuf,
/// Output file. Default `<dir>/relations.resolved.parquet`.
#[arg(short, long)]
output: Option<PathBuf>,
/// Drop everything that is not an assembled area (no routes, no
/// restrictions). Smaller output; deletes the bus network.
#[arg(long)]
areas_only: bool,
/// Do NOT load nodes.parquet's id column as a second clip oracle. Saves
/// ~820 MB on Sweden; drops the ~3.7 % of relations (stop areas) whose
/// only in-cut members are nodes.
#[arg(long)]
no_node_oracle: bool,
/// Ceiling on the member-way coordinate table, in vertices (24 B each).
#[arg(long, default_value_t = 400_000_000)]
max_member_vertices: usize,
/// Also write the way-grain BRIDGE table to this path: one row per way
/// that is a member of a kept route relation, carrying the way's own
/// geometry byte for byte plus the relation ids it belongs to. Joins to
/// the way-side network tables on `id`; `network` = "route_members" so the
/// id namespaces cannot be confused. Default
/// `<dir>/networks/route_members.parquet`.
#[arg(long, num_args = 0..=1, default_missing_value = "")]
route_members: Option<String>,
/// Log every ring that could not be closed (Sweden emits ~10^5).
#[arg(long)]
verbose_rings: bool,
#[arg(long, default_value = "zstd")]
compression: String,
/// Hilbert-pack `relations.resolved.parquet` before returning — the same
/// layout the base tables and the network tables carry. MEASURED on the
/// Sweden cut: 274 206 rows in 3.96 s, +1.1 % on disk, and the
/// central-Stockholm viewport then reads 10 of 34 row groups.
/// `--pack false` leaves it in `relations.parquet` order.
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
pack: bool,
},
/// Build an inverted text/tag search index over a GeoParquet dir (or a single
/// optimized `.parquet`) and save it as JSON. With `--query`, also run the
/// query and print the top hits (id, name, lon/lat). The gatling way
/// (one LPT-scheduled unit per row group, results in row-group order, so the
/// index is reproducible). Feature-gated: `--features search-index`.
#[cfg(feature = "search-index")]
Search {
/// Input GeoParquet dir (nodes.parquet / ways.parquet) or a single .parquet.
input: PathBuf,
/// Output index FILE (JSON).
#[arg(short, long, default_value = "search.idx.json")]
output: PathBuf,
/// Optional query to run after building; prints the top matching hits.
#[arg(short, long)]
query: Option<String>,
/// Max hits to print for `--query`.
#[arg(long, default_value_t = 20)]
limit: usize,
},
}
/// Resolve `--clip`/`--poly` into an optional shared [`osm_katana::Clip`].
/// `--clip` is a bbox/preset; `--poly` loads a `.poly` / GeoJSON polygon. The two
/// are mutually exclusive (enforced by clap); at most one is `Some`.
fn parse_clip(
clip: Option<&str>,
poly: Option<&std::path::Path>,
) -> anyhow::Result<Option<std::sync::Arc<osm_katana::Clip>>> {
use osm_katana::{Bounds, Clip, Polygon};
if let Some(path) = poly {
let p = Polygon::from_file(path)?;
eprintln!(
"clip: polygon {path:?} — {} ring(s), {} vertices, bbox {:?}",
p.ring_count(),
p.vertex_count(),
p.bbox(),
);
return Ok(Some(std::sync::Arc::new(Clip::Poly(p))));
}
if let Some(s) = clip {
let b = Bounds::parse(s)?;
eprintln!("clip: bbox {b:?}");
return Ok(Some(std::sync::Arc::new(Clip::Bbox(b))));
}
Ok(None)
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
// Introspection: name the CLI verb before it's consumed by the dispatch match.
let verb = match &cli.cmd {
Cmd::Convert { .. } => "convert",
Cmd::SpatialPack { .. } => "spatial-pack",
Cmd::Networks { .. } => "networks",
Cmd::Buildings { .. } => "buildings",
Cmd::Graph { .. } => "graph",
Cmd::Tile { .. } => "tile",
Cmd::Xml2Pbf { .. } => "xml2pbf",
Cmd::Pbf2Geo { .. } => "pbf2geo",
Cmd::Geo2Arrow { .. } => "geo2arrow",
Cmd::Optimize { .. } => "optimize",
Cmd::AdminPoly { .. } => "admin-poly",
Cmd::Census { .. } => "census",
Cmd::Verify { .. } => "verify",
Cmd::Relations { .. } => "relations",
#[cfg(feature = "search-index")]
Cmd::Search { .. } => "search",
};
let result: anyhow::Result<()> = match cli.cmd {
Cmd::Convert {
input,
output,
nodes,
ways,
relations,
node_refs,
compression,
vtd_workers,
geometry,
skip_changesets,
log,
clip,
poly,
keep_node_coords,
reuse_node_coords,
pack,
} => {
let clip = parse_clip(clip.as_deref(), poly.as_deref())?;
let opts = osm_katana::ConvertOptions {
output_dir: output,
include_nodes: nodes,
include_ways: ways,
include_rels: relations,
include_node_refs: node_refs,
compression,
vtd_workers,
geometry,
skip_changesets,
log_path: log,
clip,
keep_node_coords,
reuse_node_coords,
cancel: None,
pack,
};
osm_katana::convert(&input, &opts)
}
Cmd::SpatialPack {
input,
output,
row_group_rows,
covering,
generalise,
node_refs_sidecar,
compression,
} => {
use osm_katana::spatial::{PackOptions, spatial_pack, spatial_pack_dir};
let opts = PackOptions {
compression,
row_group_rows,
covering,
generalise,
node_refs_sidecar,
};
let report = if input.is_dir() {
spatial_pack_dir(&input, &output, &opts)?
} else {
let st = spatial_pack(&input, &output, &opts)?;
vec![(input.display().to_string(), st)]
};
for (name, s) in &report {
println!(
"{name:>20}: {:>9} rows {:>5} row groups {:>12} bytes{}",
s.rows,
s.row_groups,
s.bytes,
if s.rows_no_geometry > 0 {
format!(" ({} without geometry, sorted last)", s.rows_no_geometry)
} else {
String::new()
},
);
}
Ok(())
}
Cmd::Networks {
input,
output,
only,
row_group_rows,
compression,
max_rows,
no_graph,
} => {
use osm_katana::network::{ALL_NETWORKS, NetworkOptions, extract_networks};
let networks = match &only {
None => ALL_NETWORKS.to_vec(),
Some(names) => {
let picked: Vec<_> = ALL_NETWORKS
.iter()
.filter(|s| names.iter().any(|n| n == s.name))
.copied()
.collect();
// An unknown name must be an ERROR, not an empty cut: a typo that
// silently produces nothing looks exactly like a clean run.
for n in names {
anyhow::ensure!(
ALL_NETWORKS.iter().any(|s| s.name == n),
"unknown network `{n}` — known: {}",
ALL_NETWORKS
.iter()
.map(|s| s.name)
.collect::<Vec<_>>()
.join(", ")
);
}
picked
}
};
let opts = NetworkOptions {
networks,
compression,
row_group_rows,
max_rows,
graph: !no_graph,
};
let t0 = std::time::Instant::now();
let stats = extract_networks(&input, &output, &opts)?;
for s in &stats {
println!(
"{:>7}: {:>9} rows {:>4} row groups {:>12} bytes ({} refused, {} \
without geometry)",
s.name, s.rows, s.row_groups, s.bytes, s.refused, s.rows_no_geometry
);
println!(" recipe {}", s.recipe);
let top: Vec<String> = s
.classes
.iter()
.take(12)
.map(|(c, n)| format!("{c}={n}"))
.collect();
println!(" classes[{}] {}", s.classes.len(), top.join(" "));
if let Some(g) = &s.graph {
println!(
" graph: {} ways / {} node refs → {} distinct nodes, of \
which {} shared and {} endpoints",
g.ways, g.node_refs, g.distinct_nodes, g.shared_nodes, g.endpoint_nodes
);
println!(
" {} GRAPH NODES ({:.1} % of the nodes touched, {} \
forced joints), {} edges, {} cross-network joints",
g.graph_nodes,
100.0 * g.graph_nodes as f64 / g.distinct_nodes.max(1) as f64,
g.forced_joints,
g.graph_edges,
g.joints
);
println!(
" oneway: {} forward, {} backward; {} ways could \
not be sliced (geometry != node_refs) and became one edge each",
g.oneway_forward, g.oneway_backward, g.whole_way_fallback
);
println!(
" speed: {} of {} edges from a tagged maxspeed; \
road {:.0} km, of which {:.0} km tagged ({:.1} %) — the rest \
drive class defaults (SPEED_TABLE)",
g.speed_tagged_edges,
g.graph_edges,
g.road_m / 1000.0,
g.speed_tagged_road_m / 1000.0,
100.0 * g.speed_tagged_road_m / g.road_m.max(1.0)
);
}
}
println!(
"wall {:.1}s → {}",
t0.elapsed().as_secs_f64(),
output.display()
);
Ok(())
}
Cmd::Buildings {
input,
output,
row_group_rows,
compression,
max_rows,
no_parts,
roof_shape,
no_generalise,
} => {
use osm_katana::side_outputs::buildings::{BuildingOptions, extract_buildings};
let opts = BuildingOptions {
compression,
row_group_rows,
max_rows,
include_parts: !no_parts,
generalise: !no_generalise,
roof_shape,
};
let t0 = std::time::Instant::now();
let s = extract_buildings(&input, &output, &opts)?;
println!(
"buildings: {} rows {} row groups {} bytes ({} without geometry, {} \
parts, {} with min_height)",
s.rows, s.row_groups, s.bytes, s.rows_no_geometry, s.parts, s.with_min_height
);
println!(" recipe {}", s.recipe);
// THE number. Printed as a ratio and not as a count, because "9 095 buildings
// have a height" reads like a lot and 0.24 % does not.
for (k, n) in &s.height_sources {
println!(
" {k:<20} {n:>9} {:>7.4} %",
100.0 * *n as f64 / s.rows.max(1) as f64
);
}
let top: Vec<String> = s
.kinds
.iter()
.take(12)
.map(|(c, n)| format!("{c}={n}"))
.collect();
println!(" kinds[{}] {}", s.kinds.len(), top.join(" "));
println!(
"wall {:.1}s → {}",
t0.elapsed().as_secs_f64(),
output.display()
);
Ok(())
}
Cmd::Graph {
dir,
question,
lon,
lat,
limit,
} => {
use osm_katana::graph::{
Graph, NET_ROAD, edge_classes, haversine_m, unguarded_crossings,
};
let t0 = std::time::Instant::now();
let g = Graph::load(&dir)?;
let load = t0.elapsed().as_secs_f64();
println!(
"graph: {} nodes, {} edges → {} directed arcs, loaded in {load:.2}s",
g.node_id.len(),
g.edges,
g.csr.len()
);
match question.as_str() {
"unguarded-crossings" => {
let t = std::time::Instant::now();
let cls = edge_classes(&dir)?;
let mut u = unguarded_crossings(&g, &cls);
let all_lc = (0..g.node_id.len())
.filter(|&i| g.rail_class[i].as_deref() == Some("level_crossing"))
.count();
let joint_lc = (0..g.node_id.len())
.filter(|&i| {
g.rail_class[i].as_deref() == Some("level_crossing")
&& g.node_kind[i] == 3
})
.count();
println!(
"\n{all_lc} level crossings are graph nodes; {joint_lc} of them are \
real joints (a road edge AND a rail edge end there); \
{} of those carry no barrier, light, bell or supervision \
({:.1} %) [{:.2}s]",
u.len(),
100.0 * u.len() as f64 / joint_lc.max(1) as f64,
t.elapsed().as_secs_f64()
);
let mut by_class: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
for c in &u {
for k in &c.road_classes {
*by_class.entry(k.clone()).or_insert(0) += 1;
}
}
println!("\nby road class meeting the track (the adjacency's answer):");
let mut v: Vec<_> = by_class.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1));
for (k, n) in v {
println!(" {k:24} {n:>6}");
}
// The ones that matter most: a through road, not a farm track.
u.sort_by(|a, b| b.road_edges.cmp(&a.road_edges).then(a.node.cmp(&b.node)));
println!("\ntop {limit} by incident road edges:");
for c in u.iter().take(limit) {
println!(
" node {:>12} {:>9.5},{:>8.5} road_edges={} rail_edges={} \
classes={}",
c.node,
c.lon,
c.lat,
c.road_edges,
c.rail_edges,
c.road_classes.join("+")
);
}
}
"nearest-crossing" => {
let (lon, lat) = match (lon, lat) {
(Some(a), Some(b)) => (a, b),
_ => anyhow::bail!("nearest-crossing needs --lon and --lat"),
};
let t = std::time::Instant::now();
let start = g
.nearest_node(lon, lat, NET_ROAD)
.ok_or_else(|| anyhow::anyhow!("no road node in the graph"))?;
let snap = haversine_m((lon, lat), g.node_xy[start]);
println!(
"\nstart {lon},{lat} snaps to the nearest ROAD node {} ({snap:.0} m \
away) in {:.2}s",
g.node_id[start],
t.elapsed().as_secs_f64()
);
let t = std::time::Instant::now();
let target = |i: usize| {
g.rail_class[i].as_deref() == Some("level_crossing")
&& g.node_kind[i] == 3
&& !osm_katana::graph::is_guarded(
g.node_tags[i].as_deref().unwrap_or(""),
)
};
match g.nearest_matching(start, NET_ROAD, target) {
None => println!("no unguarded crossing is reachable from here"),
Some((i, d, settled)) => {
let crow = haversine_m(g.node_xy[start], g.node_xy[i]);
println!(
"nearest UNGUARDED level crossing: node {} at {:.5},{:.5}\n \
along the ROAD network {:>10.0} m\n as the crow flies \
{:>10.0} m ({:.2}x further by road)\n {settled} nodes \
settled in {:.2}s",
g.node_id[i],
g.node_xy[i].0,
g.node_xy[i].1,
d,
crow,
d / crow.max(1.0),
t.elapsed().as_secs_f64()
);
println!(" tags {}", g.node_tags[i].as_deref().unwrap_or("-"));
}
}
}
other => anyhow::bail!(
"unknown question `{other}` — known: unguarded-crossings, \
nearest-crossing"
),
}
Ok(())
}
Cmd::Tile {
input,
output,
zooms,
region_name,
region,
budget_gib,
workers,
raster_px,
compression,
exclude_networks,
} => {
use osm_katana::tiles::{self, TileOptions, TileSource};
// No `--zooms` ⇒ the library's ladder, read at run time, so this binary and
// korp's `run_tile_maps` can never cut at different levels (LAW 5).
let zooms: Vec<u8> = match zooms.as_deref() {
None => tiles::DEFAULT_TILE_ZOOMS.to_vec(),
Some(s) => {
let v: Vec<u8> = s.split(',').filter_map(|t| t.trim().parse().ok()).collect();
anyhow::ensure!(!v.is_empty(), "--zooms parsed to nothing");
v
}
};
let region = match region.as_deref() {
None => None,
Some(s) => {
let v: Vec<f64> = s.split(',').filter_map(|t| t.trim().parse().ok()).collect();
anyhow::ensure!(
v.len() == 4,
"--region wants min_lon,min_lat,max_lon,max_lat, got {s:?}"
);
Some(osm_katana::Bounds::new(v[0], v[1], v[2], v[3]))
}
};
#[allow(clippy::float_arithmetic)]
let budget = match budget_gib {
Some(g) => (g * (1u64 << 30) as f64) as u64,
None => tiles::default_tile_mem_budget(),
};
// Neither `--raster-px` nor `--exclude-networks` given ⇒ the LIBRARY's value,
// read at run time from the one `TileOptions::default()` korp also inherits
// from, so a hand cut and the server's cut stamp the same `TileRecipe`
// (LAW 5). `..Default::default()` alone cannot express that for a field the
// struct literal names, so the fallback is spelled per field.
let defaults = TileOptions::default();
let opts = TileOptions {
zooms,
compression,
region,
workers,
mem_budget_bytes: budget,
tile_raster_px: raster_px.unwrap_or(defaults.tile_raster_px),
exclude_networks: exclude_networks.unwrap_or(defaults.exclude_networks),
..defaults
};
let t0 = std::time::Instant::now();
let st = tiles::tile_region(TileSource::Extract(&input), &output, &opts)?;
let wall = t0.elapsed().as_secs_f64();
// The manifest is written from the STATS, never from the options: it may
// only ever claim the zooms and the recipe the objects beside it were
// actually written with.
let manifest = output.join(tiles::manifest_key());
if let Some(d) = manifest.parent() {
std::fs::create_dir_all(d)?;
}
std::fs::write(
&manifest,
tiles::manifest_json(
®ion_name,
&st.produced_zooms,
&st.recipe,
st.deepest_tile.as_deref(),
),
)?;
let tiles_total: usize = st.zooms.iter().map(|z| z.tiles).sum();
let bytes_total: u64 = st.zooms.iter().map(|z| z.total_bytes).sum();
#[allow(clippy::float_arithmetic)]
{
println!(
"osm-katana tile: {tiles_total} tiles · {:.2} GB on disk · {wall:.2} s wall \
(scan {:.2} s at {:.1} cores, write {:.2} s at {:.1} cores)\n\
\trecipe {} · manifest {}",
bytes_total as f64 / 1e9,
st.scan_wall_s,
st.scan_cpu_cores,
st.write_wall_s,
st.write_cpu_cores,
st.recipe,
manifest.display(),
);
for z in &st.zooms {
println!(
"\tz{:<3} {:>9} tiles {:>12} bytes mean {:>8} B",
z.zoom,
z.tiles,
z.total_bytes,
if z.tiles > 0 {
z.total_bytes / z.tiles as u64
} else {
0
},
);
}
println!("{}", st.memory_attribution());
}
Ok(())
}
Cmd::Xml2Pbf {
input,
output,
skip_changesets,
} => {
use std::time::Instant;
let is_bz2 = input
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("bz2"));
let n = std::thread::available_parallelism()
.map(|n| n.get().saturating_sub(1).max(1))
.unwrap_or(1);
let file_mb = std::fs::metadata(&input)
.map(|m| m.len() / 1_048_576)
.unwrap_or(0);
if is_bz2 {
let pbf = output.unwrap_or_else(|| {
let stem = PathBuf::from(input.file_stem().unwrap_or_default());
let inner = stem.file_stem().unwrap_or_default();
input.with_file_name(format!("{}.osm.pbf", inner.to_string_lossy()))
});
println!(
"input: {} ({file_mb} MB bz2) output: {} workers: {n}",
input.display(),
pbf.display()
);
let t = Instant::now();
let (total, _) =
osm_katana::xml_to_pbf::xml_to_pbf_bz2_path(&input, &pbf, n, skip_changesets)?;
let pbf_mb = std::fs::metadata(&pbf)
.map(|m| m.len() / 1_048_576)
.unwrap_or(0);
println!(
"done: {:.1}s {total} elements {pbf_mb} MB PBF",
t.elapsed().as_secs_f64()
);
} else {
let pbf = output.unwrap_or_else(|| input.with_extension("osm.pbf"));
println!(
"input: {} ({file_mb} MB xml) output: {} workers: {n}",
input.display(),
pbf.display()
);
let t = Instant::now();
let (total, _) = osm_katana::xml_to_pbf::xml_to_pbf_raw_path(&input, &pbf, n)?;
let pbf_mb = std::fs::metadata(&pbf)
.map(|m| m.len() / 1_048_576)
.unwrap_or(0);
println!(
"done: {:.1}s {total} elements {pbf_mb} MB PBF",
t.elapsed().as_secs_f64()
);
}
Ok(())
}
Cmd::Pbf2Geo {
input,
output,
nodes,
ways,
relations,
compression,
} => {
let opts = osm_katana::ConvertOptions {
output_dir: output,
include_nodes: nodes,
include_ways: ways,
include_rels: relations,
include_node_refs: true,
compression,
vtd_workers: 0,
geometry: "resolved".into(),
skip_changesets: false,
log_path: None,
clip: None,
keep_node_coords: true,
reuse_node_coords: true,
cancel: None,
pack: true,
};
osm_katana::convert(&input, &opts)
}
Cmd::Geo2Arrow { input, output } => osm_katana::geo2arrow(&input, &output),
Cmd::Optimize {
input,
output,
points_only,
ways,
named_first,
named_only,
buildings,
max_features,
compression,
log,
} => {
let opts = osm_katana::OptimizeOptions {
output,
points_only,
include_ways: ways,
named_first,
named_only,
buildings,
max_features,
compression,
log_path: log,
};
osm_katana::optimize(&input, &opts)
}
Cmd::AdminPoly {
input,
name,
admin_level,
output,
poly_name,
workers,
log,
} => {
let path = input
.to_str()
.ok_or_else(|| anyhow::anyhow!("non-UTF8 input path"))?;
let opts = osm_katana::AdminPolyOptions {
admin_level: admin_level.as_deref(),
n_workers: workers,
log_path: log.as_deref(),
};
let t0 = std::time::Instant::now();
let derived = osm_katana::stream_admin_region_polygon_detailed(path, &name, &opts)?;
let wall = t0.elapsed().as_secs_f64();
match derived {
None => {
// A FACT, not a silent success: nothing matched, so nothing is written.
anyhow::bail!(
"no boundary=administrative relation named {name:?}{} in {path} \
(scanned in {wall:.1}s) — nothing written",
admin_level
.as_deref()
.map(|l| format!(" at admin_level={l}"))
.unwrap_or_default(),
);
}
Some(d) => {
let b = d.polygon.bbox();
eprintln!(
"admin-poly: {name} — {} relation(s), {} ring(s), {} vertices, \
{} member ways, {} boundary nodes, {} unclosed chain(s), {wall:.1}s",
d.relations.len(),
d.polygon.ring_count(),
d.polygon.vertex_count(),
d.member_ways,
d.boundary_nodes,
d.open_rings,
);
for r in &d.relations {
eprintln!(
" relation {} admin_level={} — {} outer / {} inner member ways",
r.id, r.admin_level, r.outer_ways, r.inner_ways,
);
}
eprintln!(" bbox {:?}", b);
let text = d.polygon.to_poly_str(poly_name.as_deref().unwrap_or(&name));
match output {
Some(p) => {
std::fs::write(&p, &text)?;
eprintln!(" wrote {} ({} bytes)", p.display(), text.len());
}
None => print!("{text}"),
}
Ok(())
}
}
}
Cmd::Census {
dir,
pyramid,
tags,
geometry,
classes,
json,
workers,
} => {
use osm_katana::census;
let opts = census::CensusOptions {
tags,
geometry,
classes,
workers,
// The red arm, reachable from a shell: `OSM_KATANA_CENSUS_SABOTAGE=
// drop-tagged` makes the real counting loop lose a whole population, and
// the verb must then refuse. A closure check that cannot be made to fail
// is decoration (LAW 2).
sabotage: census::Sabotage::from_env(),
};
let mode = if pyramid {
census::Mode::Pyramid
} else {
census::Mode::Tables
};
let c = census::census(&dir, mode, &opts)?;
print!("{}", c.report());
if let Some(p) = &json {
std::fs::write(p, c.to_json()?)?;
eprintln!("census json → {}", p.display());
}
// The judgement and the printed table come from ONE value, so they can never
// say different things — the same shape `verify` was fixed into.
let faults = c.faults();
if !faults.is_empty() {
anyhow::bail!("census REFUSED — {}", faults.join("; "));
}
Ok(())
}
Cmd::Verify { dir, digest, geometry_quality, geometry_baseline } => {
// The judgement lives in the library (`verify::verify_dir`), so the line
// printed here and the exit code returned below come from ONE call and
// cannot disagree. Before that existed this loop printed `0 set, N null`
// and exited 0 — a verb named `verify` that could not report red.
let verdicts =
osm_katana::verify::verify_dir(&dir, &osm_katana::verify::EXTRACT_TABLES)?;
for tv in &verdicts {
let name = &tv.name;
match &tv.counts {
None => println!("{name:>20}: (absent) <-- FAIL"),
Some(v) => {
print!("{name:>20}: {:>9} rows", v.rows);
if v.has_geom {
print!(" | geometry: {} set, {} null", v.geom_set, v.geom_null);
}
if v.has_refs {
print!(
" | node_refs: {} non-empty, {} ids",
v.refs_nonempty, v.refs_total
);
}
if let Some(f) = tv.fault {
print!(" <-- FAIL: {f}");
}
println!();
}
}
}
let mut failed: Vec<String> = verdicts
.iter()
.filter_map(|tv| tv.fault.map(|f| format!("{}: {f}", tv.name)))
.collect();
if geometry_quality {
// The verb and the CHAIN judge geometry through the SAME call
// (`verify::check_cut_geometry`), so a cut the daemon refuses can never
// be one this verb calls clean. Before row 39 the verb had its own rule
// (`GeomQuality::fault`) and the chain had none at all.
let base = match geometry_baseline {
BaselineArg::Zero => &osm_katana::verify::GeometryBaseline::ZERO,
BaselineArg::Recorded => &osm_katana::verify::GeometryBaseline::RECORDED,
};
let g = osm_katana::verify::check_cut_geometry(
&dir,
&osm_katana::verify::EXTRACT_TABLES,
base,
)?;
println!();
println!(
"geometry quality — baseline `{}` ({}); `<4v`/`area0` are reported, \
not gated:",
g.baseline, base.provenance,
);
for (name, q) in &g.census {
let name = name.replace(".parquet::", "::");
print!(
"{name:>40}: {:>9} rows | {} null · {} pt · {} ln · {} poly",
q.rows, q.null, q.points, q.lines, q.polys,
);
print!(
" | decode_fail {} · unclosed {} · <4v {} · area0 {} · selfx {} · big? {}",
q.decode_fail,
q.unclosed_ring,
q.ring_under_4,
q.zero_area,
q.self_intersecting,
q.big_ring_unchecked,
);
if q.unaccounted() != 0 {
print!(" <-- BOOKS DO NOT CLOSE ({} unaccounted)", q.unaccounted());
}
println!();
}
// A tolerated defect is a DEBT with a number on it, printed on every
// run — never a silent pass. This is the whole difference between a
// baseline and switching the gate off.
for a in base.allow {
let owed: Vec<String> = [
("unclosed", a.unclosed_ring.recorded().unwrap_or(0)),
("selfx", a.self_intersecting.recorded().unwrap_or(0)),
]
.into_iter()
.filter(|(_, n)| *n > 0)
.map(|(k, n)| format!("{k} {n}"))
.collect();
if !owed.is_empty() {
println!(
" DEBT tolerated by this baseline: {}::{} — {}",
a.table.trim_end_matches(".parquet"),
a.column,
owed.join(" · "),
);
}
}
for n in &g.notes {
println!(" {n}");
}
for f in &g.findings {
println!(" <-- FAIL: {f}");
failed.push(format!("{}::{} {}", f.table, f.column, f.defect));
}
}
if !failed.is_empty() {
anyhow::bail!(
"verify: {} of {} tables unfit — {}",
failed.len(),
verdicts.len(),
failed.join("; ")
);
}
if digest {
println!(
"content digest (layout-invariant). `set` = order-independent multiset \
digest — THE equivalence gate; `ord` also pins row order:"
);
for (name, d) in osm_katana::digest::digest_dir(&dir)? {
match d {
Some(d) => println!(
"{name:>20}: {:>9} rows set {} ord {}",
d.rows,
d.set_hex(),
d.hex()
),
None => println!("{name:>20}: (absent)"),
}
}
}
Ok(())
}
Cmd::Relations {
dir,
output,
areas_only,
no_node_oracle,
max_member_vertices,
route_members,
verbose_rings,
compression,
pack,
} => {
let out = output.unwrap_or_else(|| dir.join("relations.resolved.parquet"));
anyhow::ensure!(
out != dir.join("relations.parquet"),
"refusing to write over the source relations.parquet — the stage reads it \
TWICE (clip, then emit) and overwriting it mid-run would race its own input"
);
let opts = osm_katana::RelationResolveOptions {
compression,
max_member_vertices,
keep_non_areas: !areas_only,
members_via_nodes: !no_node_oracle,
route_members: route_members.map(|p| {
if p.is_empty() {
dir.join("networks").join("route_members.parquet")
} else {
PathBuf::from(p)
}
}),
verbose_ring_errors: verbose_rings,
pack,
};
let stats = osm_katana::resolve_relations(&dir, &out, &opts)?;
println!("{}", stats.summary());
println!("wrote {out:?}");
println!("\nkept by kind (top 25):");
let mut kinds: Vec<_> = stats.by_kind.iter().collect();
kinds.sort_by(|a, b| b.1.cmp(a.1));
for (k, v) in kinds.into_iter().take(25) {
println!(" {k:24} {v:>9}");
}
if let Some(rm) = &stats.route_members {
println!("\nroute_members bridge — rows per class (the way side's namespace):");
let mut c: Vec<_> = rm.by_class.iter().collect();
c.sort_by(|a, b| b.1.cmp(a.1));
for (k, v) in c {
println!(" {k:24} {v:>9}");
}
}
if !stats.by_route.is_empty() {
println!(
"\nkept route relations by mode (top 25) — the join key with the \
way-side network layer:"
);
let mut r: Vec<_> = stats.by_route.iter().collect();
r.sort_by(|a, b| b.1.cmp(a.1));
for (k, v) in r.into_iter().take(25) {
println!(" route={k:20} {v:>9}");
}
}
Ok(())
}
#[cfg(feature = "search-index")]
Cmd::Search {
input,
output,
query,
limit,
} => {
use osm_katana::side_outputs::search::SearchIndex;
let idx = SearchIndex::build(&input)?;
idx.save(&output)?;
println!(
"search: indexed {} features, {} tokens → {}",
idx.len(),
idx.token_count(),
output.display(),
);
if let Some(q) = query {
let hits = idx.search_limit(&q, limit);
println!("query {q:?}: {} hit(s)", hits.len());
for h in hits {
println!(
" #{:<8} {:<40} {:.5},{:.5}",
h.id,
h.name.as_deref().unwrap_or("(unnamed)"),
h.lon,
h.lat,
);
}
}
Ok(())
}
};
// Introspection marker: report the CLI verb's terminal health to the matrix.
osm_katana::functional_status(
"osm-katana",
verb,
result.is_ok(),
if result.is_ok() {
"cli command completed"
} else {
"cli command failed"
},
);
result
}
#[cfg(test)]
mod cli_tests {
use super::*;
fn tile_flags(argv: &[&str]) -> (Option<u32>, Option<bool>) {
let cli = Cli::try_parse_from(argv).expect("the tile subcommand must parse");
match cli.cmd {
Cmd::Tile {
raster_px,
exclude_networks,
..
} => (raster_px, exclude_networks),
_ => panic!("expected `tile`"),
}
}
/// ★ **"not given" and "given the library's current value" must not be the same
/// parse** — for the two `TileRecipe` axes this CLI exposes.
///
/// Both flags used to be `default_value_t` literals (`2048`, `true`). Two things
/// followed, and the second is the one that bites:
///
/// 1. the literals were a SECOND writer of a recipe axis whose first writer is
/// `TileOptions::default()` — the same defect `--zooms` was fixed for when
/// `DEFAULT_TILE_ZOOMS` gained `15` and this file still said `6,9,12`;
/// 2. an omitted flag parsed to exactly the value an explicit flag parsed to, so
/// nothing downstream could tell "the operator did not say" from "the operator
/// asked for today's default" — and those stop being the same request the moment
/// the default moves, which is the only moment that matters.
///
/// **This is the arm that went red before the change and could not have been faked
/// green:** with `u32`/`bool` fields both sides were `2048`/`true`, so `assert_ne!`
/// failed on equal values. It compiles either way, so the red is reproducible by
/// reverting the two `#[arg]`s alone.
#[test]
fn an_omitted_recipe_flag_is_distinguishable_from_an_explicit_one() {
let defaults = osm_katana::tiles::TileOptions::default();
let omitted = tile_flags(&["osm-katana", "tile", "extract"]);
let explicit = tile_flags(&[
"osm-katana",
"tile",
"extract",
"--raster-px",
&defaults.tile_raster_px.to_string(),
"--exclude-networks",
&defaults.exclude_networks.to_string(),
]);
assert_ne!(
omitted.0, explicit.0,
"`--raster-px` omitted parses identically to `--raster-px {}` — the CLI \
cannot tell silence from a request, and the literal it substitutes is a \
second writer of a TileRecipe axis",
defaults.tile_raster_px
);
assert_ne!(
omitted.1, explicit.1,
"`--exclude-networks` omitted parses identically to `--exclude-networks {}`",
defaults.exclude_networks
);
// …and silence must resolve to the LIBRARY's value, not to a fresh literal
// hiding one layer down.
assert_eq!(
omitted.0.unwrap_or(defaults.tile_raster_px),
defaults.tile_raster_px
);
assert_eq!(
omitted.1.unwrap_or(defaults.exclude_networks),
defaults.exclude_networks
);
// Non-vacuity: an explicit flag still has to be HONOURED, or the arms above
// would pass on a CLI that ignores its own options.
let odd = tile_flags(&[
"osm-katana",
"tile",
"extract",
"--raster-px",
"512",
"--exclude-networks",
"false",
]);
assert_eq!(odd.0, Some(512));
assert_eq!(odd.1, Some(false));
}
}