edge-match
edge-match fits a child polygon layer into a coarser parent/clip layer
(e.g. admin4 into admin0), reusing edge-extend's Voronoi gap-filling pipeline
under the hood, then the same assign/edge-clip/edge-stitch primitives edge-mosaic
uses. This doc covers the parts that are specific to edge-match: the
overlap/assignment algorithm, the per-group subprocess design, and the
constraints it inherits from edge-extend. See docs/explanation/topology.md for the
coverage-clean/SPATIAL_JOIN background both tools share.
Usage¶
from topo_tools import edge_match
edge_match("admin4.geojson", "admin0.geojson", "admin4_matched.geojson")
OUTPUT_FILE (positional, optional) defaults to INPUT_FILE with a
_matched suffix; it is required when INPUT_FILE is a glob matching more
than one file, or when --input is given.
| Option | Description |
|---|---|
--input |
Extra children file(s), repeatable and comma-separable, combined with INPUT_FILE. |
--overwrite |
Overwrite an existing output file. |
--threads |
DuckDB thread count. |
--debug |
Keep intermediate tables, export to Parquet, log timing/memory per query. |
--tmp-dir |
Intermediate DuckDB + Parquet location. |
--step |
Run only one named stage: inputs, assign, groups, edge-clip, edge-stitch, outputs. Unavailable when multiple children files are given. |
# Fit an admin4 layer into a single country boundary
topo-tools edge-match adm4.geojson adm0.geojson
# Fit admin3 into admin2 groups, each cleaned against its own parent
topo-tools edge-match adm3.gpkg adm2.gpkg adm3_matched.gpkg
# Combine several raw countries' admin1 layers, matched and extended
# together against one shared parent
topo-tools edge-match sen_adm1.parquet world_adm0.geojson out.parquet \
--input gmb_adm1.parquet,gnb_adm1.parquet
Each parent's group of children runs in its own isolated subprocess, so a run with many parents (e.g. matching a nationwide admin4 layer against dozens of admin2 units) scales without one large group's memory use affecting another's.
Run topo-tools edge-match --help for the full, always-current option list.
Pipeline¶
_01_inputs: coverage-cleans the child layer via the sharedcore.io.read_reproject_and_clean()helper ({name}_child_01), and loads the parent/clip layer raw viacore.assign.load_parent()({name}_parent_01), the same loaderedge-mosaicuses (seedocs/adr/0086).- assign: calls
core.assign.assign_many()directly: assigns each child to the parent it shares the largest area with (plurality, not majority); drops and logs children with zero overlap with any parent, keeping their geometry for the issues report (or, whenmergeis set, for the orphan group below instead; see "Parent gap-fill and child passthrough"). Seedocs/explanation/assign.mdfor the algorithm. _02_groups: groups children by their assigned parent (always, even a group of exactly one parent), runsedge-extend's pipeline within each group in an isolated subprocess, then reassembles the survivors, tagging each row with its group's ownparent_fid. No clipping happens here anymore (see "Two subprocess generations" below). A failed group's children are recorded, with the parent id and failure reason, for the issues report. Whenmergeis set and any children were unassigned, one more orphan group runs afterward (see "Parent gap-fill and child passthrough")._03_clip: one batched call intocore.edge_clip.main()over every real group's rows at once, clipping each to its ownparent_fid's geometry. Seedocs/explanation/edge_clip.mdfor the algorithm. The orphan group's rows (when present) are split out first and unioned back in afterward, unclipped. Whenmergeis set, the api layer then calls the sharedcore.assign.fill_unmatched_parents()to append every zero-children parent's own geometry, before stitch ever runs._04_stitch: callscore.edge_stitch._02_clean.main()directly: a single whole-tableST_CoverageCleanpass over the clipped output to close cross-group seams. Seedocs/explanation/edge_stitch.md._05_outputs: validates topology (any overlap, or a gap at or belowSNAP_TOLERANCE, raises), builds the issues report from the dropped children collected in stages ⅔ (or the passthrough children and gap-filled parents, whenmergeis set) plus any leftover gap wider thanSNAP_TOLERANCE, logs a warning if any such gap remains, and exports both the final layer and the issues report (only when it has rows).
Multi-file children¶
The child role MAY span multiple files in one call (e.g. one raw admin
boundary file per country); the parent/clip layer stays single-file.
output_path MUST be given explicitly whenever multiple paths are passed,
and step/multi_parent MUST both be None/False (see docs/adr/0084).
With more than one input path, _match_multi_file() (api/edge_match.py)
runs only inputs+assign in a per-file loop, mirroring edge-mosaic's own
_mosaic_multi_file() memory-bounded pattern (docs/explanation/edge_mosaic.md)
but stopping one stage earlier: the parent is loaded once into a pristine
snapshot with its heavy-part tile decomposition cached
(core.assign.prepare_parent_tiles()), then each children file is loaded and
assigned alone against a fresh copy of that snapshot, one at a time, folding
{name}_child_01, {name}_02_assign, and {name}_02_unassigned into running
accumulators (UNION ALL BY NAME) rather than holding every file until a
final combine. fid is kept globally unique via a running offset applied
right after each file's assign step. groups, clip, stitch, and
outputs all run exactly once afterward, over the fully accumulated result,
not per file: grouping is keyed purely by parent_fid
(_02_groups.py::list_groups), so children from different files sharing a
parent_fid extend together as one Voronoi group only if groups runs after
every file's children have landed in {name}_02_assign, which is the whole
point of combining files here (unlike edge-mosaic, whose clip step is
embarrassingly per-file and folds directly into its loop instead).
assign_one narrows {name}_parent_01 to only that iteration's matched
fids at the end of every call, so the loop resets it from the full snapshot
at the start of every iteration, and restores it once more from the
snapshot after the loop ends, before groups runs; otherwise groups/clip
would only see the last file's matched parents, not the union across all
files. Every row on the internal {name}_05 table still carries a source_file
column tagging its origin file; it's an assign-one working column,
stripped before the exported output (see docs/adr/0087).
Two subprocess generations: edge-extend, then batched edge-clip¶
Before the assign/edge-clip/edge-stitch extraction, each group's subprocess ran
edge-extend's pipeline and clipped to that group's parent in the same
process. core.edge_clip now always isolates per distinct parent_fid in its
own spawned subprocess, uniformly for every caller, so edge-match moved to two
subprocess generations per run instead: a per-group edge-extend-only
subprocess (unchanged in count/shape from before), followed by a second,
later generation of per-parent_fid clip subprocesses (edge-clip's own
mechanism, batched over the whole reassembled table). See
docs/adr/0020-match-clip-two-subprocess-generations.md for the empirical
re-verification this required and its results.
One consequence: a single bad parent_fid in the edge-clip step now aborts
the whole run, rather than edge-match's old per-group continue-past-failure
behavior for clip failures specifically. Per-group edge-extend failures (OOM,
or exhausting attempt.py's 10 retries) still continue, dropping just that
group, as before; only clip's own hard-fail-on-first-bad-parent_fid
semantics are new, and apply uniformly to every edge-clip caller including
edge-match (see docs/explanation/edge_clip.md).
Parent gap-fill and child passthrough¶
Both opt-in via the single boolean merge flag (CLI: --merge), off by
default, the same flag edge-mosaic uses (see
docs/explanation/edge_mosaic.md); --parent-include/--parent-exclude/
--child-include/--child-exclude/--prefer further narrow which
columns survive (see docs/explanation/assign.md).
Child passthrough. edge-match uses assign-many, a per-child (not
per-file) assignment strategy, so its passthrough granularity is
per-child: a child with zero overlap with any parent is dropped by
default, the same as always; with merge set, that child (along with
every other zero-overlap child, if any) is instead grouped into one
orphan group of its own, tagged with the reserved sentinel
PASSTHROUGH_PARENT_FID (-1, guaranteed absent from real parent fids)
instead of a real parent_fid, and run through the identical per-group
edge-extend subprocess as every other group. Extension only needs a
group's own children, never a parent, so a group made of nothing but
orphans extends exactly like any other group; it just never gets clipped
afterward (_03_clip splits sentinel rows out before calling
core.edge_clip.main(), unions them back into {name}_04 afterward,
unclipped), since there is no parent to clip against. A
successfully-extended orphan is reported as a kind='passthrough' issues
row rather than unassigned; an orphan group whose extension itself
fails still becomes a kind='dropped_group' row, same as any other
failed group. Any merged parent columns (see docs/explanation/assign.md)
are NULL on passthrough rows, since there's no parent to join against;
_02_groups.py's INSERT INTO ... BY NAME fills them in automatically
once the orphan group is appended after every real group (ordering
matters: appending it first would create {name}_03a} without the
carried columns, and a later real group's INSERT ... BY NAME would then
fail with extra, unmatched columns).
Parent gap-fill. A parent matched by zero children is dropped by
default; with merge set, that parent's own geometry and carried columns
are kept in the output unclipped instead, reported as a kind='gap-fill'
row. This is the shared core.assign.fill_unmatched_parents() helper
(the same one edge-mosaic calls), called from the api layer right after
_03_clip's core.edge_clip.main() call returns, against a
{name}_parent_full snapshot taken before assign narrows
{name}_parent_01 to only-matched fids. Both mechanisms are identical in
outcome to edge-mosaic's own merge, given an equivalent
raw/already-extended child set against the same parent (see
docs/adr/0088).
Materially weaker safety profile than edge-mosaic's child
passthrough. edge-mosaic's passthrough geometry was already a
finished, validated edge_extend() output before the run even started.
edge-match's orphan group is extended fresh, alone, with zero
neighboring-parent context of any kind, and its own per-group extension
has no majority/plurality vote to fall back on if the extension
misbehaves (there was nothing to vote on, unlike a normal multi-child
group where other children can outvote one bad one). Treat the two
child-passthrough modes as different risk profiles, not interchangeable;
a passthrough-heavy edge-match run is worth a visual spot-check (e.g.
via the geo-preview skill) before trusting it the way a normal matched
group would be trusted. Parent gap-fill carries no such asymmetry: both
tools keep the same unclipped parent geometry the same way.
Per-group subprocess isolation (edge-extend)¶
Each group's edge-extend pipeline (_02_lines → attempt → _05_merge) runs in
its own fresh multiprocessing (spawn context) subprocess, not the parent
edge_match() call's shared connection. This is a deliberate design choice, not
an afterthought: CLAUDE.md documents a real, previously-confirmed finding
that GEOS's native heap isn't fully released between files even after
closing the DuckDB connection, which is exactly why edge_extend() processes one
file per OS process today. A many-parent-group edge_match() run (e.g.
admin4-into-admin2 for a country with dozens or hundreds of admin2 units)
would hit the identical failure mode in-process, just with groups
substituting for files, if groups shared one process. Building the same
per-file-per-process isolation guarantee down to group granularity avoids
that outright rather than hoping in-process cleanup is sufficient.
Data crosses the process boundary as small Parquet files (child.parquet,
parent.parquet in, output.parquet out), never a shared connection: a
DuckDB file is single-writer, and the group's own DuckDB file
(group.duckdb) lives entirely inside that group's private temp directory,
discarded when the group finishes (unless --debug). Verified empirically
that a GEOMETRY column round-trips correctly through
COPY ... TO (FORMAT PARQUET) + read_parquet(...) in the installed DuckDB
version; no GEOPARQUET_VERSION option is needed for this internal,
DuckDB-to-DuckDB round trip.
edge_match() raises only if no group produced any output at all (see
"Two subprocess generations" above for the separate, stricter clip-stage
failure behavior).
A freshly-spawned process has no logging configuration of its own
(basicConfig only ever runs in cli/main.py, in the parent process); the
worker puts a success/error signal on a multiprocessing.Queue instead of
relying solely on its own log output, and only configures logging locally
(mirroring cli/main.py's own call, teed to a per-group log file) when
--debug is set, so ProfiledConnection's per-query timing/RSS output isn't
silently dropped during a debug run.
Real-world smoke test: bdi_admin4.gpkg (3,067 features) matched against
bdi_admin2.parquet (119 parents) completed successfully end-to-end: 119
subprocess spawns, zero dropped children, zero failed groups, valid output
coverage (see verification steps in the project's implementation history).
Colombia-scale profiling (portolan col/latest/adm3 → col/latest/adm2,
--debug, Apple Silicon/10 logical cores): 31,880 children against 1,122
parents, 1,120 of them with at least one assigned child (the other 2 parents
had zero overlapping children (not a failure, no adm3 unit fell inside
them). All 1,120 groups and all 1,120 clip subprocesses succeeded: zero
dropped children, zero failed groups. Wall time 38m23s, peak RSS 7.23 GB
(see docs/adr/0020 for the full before/after comparison against the
pre-extraction fused-subprocess design). Stage breakdown:
| Stage | Wall time | Share |
|---|---|---|
| inputs | 59s | 3% |
| assign | 10s | 0% |
| groups | 29m24s | 77% |
| clip | 5m13s | 14% |
| stitch | 46s | 2% |
| outputs | 1m51s | 4% |
groups still dominates as expected (1,120 sequential subprocess spawns,
each running the per-group edge-extend pipeline) but no longer accounts for
the clip work it used to do inline; that now shows up as its own edge-clip
stage, batched over all 1,120 parent fids after every group has finished.
Rejected: ST_Snap around the clip step's ST_Intersection¶
_05_merge.py snapping the Voronoi cell onto its neighbor union before
ST_Difference (see docs/explanation/topology.md) measurably reduces how many
untouched fids the final whole-table ST_CoverageClean pass has to touch,
because many independent per-fid ST_Difference calls against
independently computed neighbor unions invent slightly different
floating-point crossing points for what should be the same vertex. Two
variants of the same idea were tried in what was then edge-match's own inline
clip step (now core.edge_clip), on the theory that a shared, exact parent
boundary (the parent layer was coverage-cleaned in _01_inputs.py at the
time, so two adjacent parents' shared edge was vertex-identical) should let
two independently-clipped groups tile seamlessly if their output vertices
land on that same exact reference:
- Snap each group's pre-clip geometry onto the parent's vertices, before
ST_Intersection(t.geom, p.geom). - Snap the clipped result onto the parent's vertices, after the
intersection: the mirror image, covering the case where
ST_Intersectionitself perturbs the parent's inherited edge vertices during overlay processing rather thant.geom's own vertices being the problem.
Tested both on Burundi, Sri Lanka, Malawi, Senegal, Haiti, Guatemala, and Chile (admin2-into-admin1 for the first six, admin3-into-admin2 for Chile): zero measurable difference, either direction, on every metric checked (pre-clean invalid-edge flag, count of fids the clean pass actually touches, whole-table clean peak RSS, wall time): identical or noise-level (<10%, no consistent direction) on all seven files, including Chile's 56-group stress test (213/213 fids touched both ways, ~2550 MB peak all three variants).
Root cause, found by extracting the actual invalid-edge geometries on
Burundi (ST_CoverageInvalidEdges_Agg, unnested, joined back to nearby
fids): all 171 invalid edges border exactly the fid pairs the assign step
places in two different parent groups, confirming these are genuinely
cross-group seams, but their lengths run from slivers up to 0.0058°
(~645 m), averaging ~12 m. SNAP_TOLERANCE is 1e-8° (~1.1 mm),
five to six orders of magnitude smaller. This isn't the same failure mode
as _05_merge.py at all: it's not two computations of the same crossing
point disagreeing by float noise, it's two different groups' Voronoi
extensions, built independently, with no knowledge of each other,
genuinely disagreeing about how far to reach near their shared parent
border. No vertex-snapping tolerance in a sane range closes a
meters-to-hundreds-of-meters gap; that's real gap-filling work, which is
exactly what edge-stitch's whole-table ST_CoverageClean pass is for (see
docs/explanation/edge_stitch.md). Reverted both variants; the clip step stays
a plain ST_Intersection. This null result is also why _01_inputs.py
later dropped coverage-cleaning the parent layer entirely: seam quality
never came from parent vertex identity in the first place (see
docs/adr/0086).
check_valid_topology and parent-layer gaps¶
_05_outputs.py calls check_valid_topology(conn, f"{name}_05"), relying
on its default gap_maximum_width=SNAP_TOLERANCE (see docs/adr/0039): it
still raises on any overlap or mismatched edge, but only raises on a gap
at or below SNAP_TOLERANCE, not any gap.
A zero-tolerance gate here breaks on a real case: matching South Africa's
admin4 into South Africa's own admin0 boundary correctly reproduces the
interior hole for Lesotho, a country fully enclosed by South Africa's
territory. That hole isn't a defect edge-match introduced, it's the parent
layer's own legitimate shape, and the old strict gate raised
RuntimeError over it. edge-match has no way to distinguish that case from
an actual coverage defect by size alone (both can be wide), so instead of
guessing, it stops treating "wide gap" as fatal and reports it: any gap
wider than SNAP_TOLERANCE gets a kind='gap' row in the issues report
(width, area, thinness ratio) and a warning log, for a human to review.
Only a leftover gap at or below SNAP_TOLERANCE still raises, since
nothing that small should ever survive the pipeline's own noise-floor
cleaning passes; a leftover one there is unambiguously a bug, not a real
absence. See docs/adr/0035.
Debug tables¶
--step=groups --debug exports everything currently in the connection
({name}_child_01, {name}_parent_01, {name}_02_pairs, {name}_02_assign,
{name}_02_unassigned, {name}_03a, {name}_03b), the same as a full run:
group ids
aren't known ahead of time, so there's no static table list to filter to for
that step. Per-group internal detail (the group's own group.duckdb,
group.log, child.parquet, parent.parquet, output.parquet) is
preserved under {tmp_dir}/{name}_g{parent_fid}/ when --debug is set,
inspectable independently of the main connection's exports. --step=clip
--debug similarly preserves each parent_fid's own
{tmp_dir}/{name}_04_p{parent_fid}/ directory.
Opt-in schema-fill composition (fill_schema)¶
edge-match MAY invoke schema-fill's own fill logic itself, right after
stitching and before export, via fill_schema=True (CLI:
--fill-schema). This lives entirely in api/edge_match.py, at both
insertion points (the single-file step loop's outputs branch and
_match_multi_file()'s own final stage), calling
core.schema_fill._02_fill.main() directly through the private
api._schema_fill_compose helper; core.edge_match itself is unchanged
and still MUST NOT depend on core.schema_fill/core.schema_map (see
docs/reference/shared.md, docs/adr/0095).
fill_schema and merge are conceptually complementary but
independently gated flags, not aliases: merge's own
fill_unmatched_parents() (docs/adr/0083) fills a geometry-coverage
gap, a parent with zero matched children, by keeping its own unclipped
geometry in the output; fill_schema fills a schema-depth gap, a row
whose admin-hierarchy columns don't reach as deep as some other row's,
by cascading each column family down to the row's own real depth. Both
can be set together freely; neither implies the other.