assign
assign-many and assign-one are two internal crosswalk strategies in
core/assign/; neither is a standalone CLI/API tool. Both build a
(child_fid, parent_fid) pairing from a bbox-prefiltered, part-exploded
overlap-area join; they differ only in how that pairing gets finalized
once per-pair shared area is known.
assign-one is the default for both edge-mosaic and edge-match:
every child in one input file is forced onto one shared parent, chosen
by majority vote of that file's children, once that file has any winner at
all. This is the safest choice for a large group of polygons resolving
against a single true parent, since a per-child rule could otherwise let a
handful of stragglers (a border-crossing overshoot on extended geometry, or
a genuinely non-overlapping outlying island on raw geometry) get pulled
onto the wrong neighbor or dropped outright, even though the rest of the
file clearly belongs together.
assign-many is edge-match's opt-in (--multi-parent): each child
decides independently which parent it overlaps most, so one input file's
children MAY scatter across many different parents. Use it only when
that's actually true of the input, e.g. a poorly-digitized admin4 layer
whose children genuinely belong to many different admin3 parents. It is
never used for edge-mosaic/edge-clip, whose already-extended (overshoot)
geometry makes per-child voting unsafe (see docs/adr/0019).
Modules¶
core/assign/ has no api.*()/CLI pipeline of its own; every function
below is called directly by another tool's own api.*() orchestrator
(api.edge_mosaic, api.edge_clip, api.edge_match), never from inside
core.edge_mosaic/core.edge_match themselves.
core/assign/_inputs.py loads the single parent/clip layer raw via
core.io.read_and_reproject, and the (possibly multi-file) child layer via
one combined query built from core.io.reproject_select_sql() per file
(UNION ALL BY NAME, no table-per-file materialization, see
docs/adr/0044); neither is coverage-checked nor -cleaned (consistent
with edge-clip/edge-stitch; these are all purely mechanical primitives). Every
child row is tagged with a source_file column recording the exact path
it came from (basename alone can't distinguish same-named files across
directories).
api.edge_mosaic and standalone api.edge_clip (its multi-file loop, see
docs/explanation/edge_clip.md) both call its load_children()/
load_parent() directly, since ADR-0023 split those apart; api.edge_match
loads its own children via core.edge_match._01_inputs instead.
_many.py / _one.py hold the actual assignment logic (see below):
api.edge_mosaic, standalone api.edge_clip, and api.edge_match (by
default) all call core.assign.assign_one(); api.edge_match calls
core.assign.assign_many() only when --multi-parent is given. All call
directly on their own already-loaded tables. edge-mosaic calls it once
per run; edge-clip's multi-file loop calls it once per children file,
reusing a cached parent-tile decomposition across every call (see below and
docs/adr/0024). Neither function runs a coverage hard gate: an unclipped
crosswalk is expected to still overlap/gap between neighboring children,
that's edge-clip's and edge-stitch's job downstream.
assign_many: largest-overlap assignment¶
Both layers are exploded into parts (UNNEST(ST_Dump(geom))) before
computing bbox candidates, a multi-part parent (a country with offshore
islands) would otherwise get one bbox spanning everything and defeat the
prefilter. Shared area per (child, parent) fid pair is summed across
every part-pair (a multi-part child can overlap a multi-part parent in
more than one place), ranked in an equal-area CRS (EQUAL_AREA_CRS)
rather than raw EPSG:4326 degree-area, which would bias plurality
assignment toward higher-latitude parents. Only the intersection geometry
is transformed, not the whole layer, to bound the cost.
picks the plurality parent per child; ties break on the lowest parent fid. Children with zero overlap with any parent are dropped with a logged warning, not an error.
assign_one: per-file majority-vote assignment¶
assign_one cannot use assign_many's pairs join unmodified: it runs
against already-extended (often massively overshot) geometry, and a plain
ST_Intersection-based area-sum join against a huge single parent part
(e.g. a country-scale admin0 polygon) is exactly the failure mode edge-clip
itself grid-tiles around. So assign_one builds its own pairs table
(_build_pairs), reusing core.edge_clip.subdivide_boundary to tile any
parent part at or above CLIP_TILE_MIN_VERTICES before intersecting, the
same threshold and tiling logic edge-clip uses.
That tiling is split into its own function, prepare_parent_tiles(), which
takes an optional child_bbox (xmin, ymin, xmax, ymax) that skips any
parent part whose bbox can't overlap it, so a handful of children matched
against one much larger shared parent (e.g. a global admin0 file) doesn't
tile parts nothing will ever be assigned to (see docs/adr/0085). A caller
processing one children file per run (edge-mosaic, single-file
edge-clip) never notices: assign_one()'s default use_cached_tiles=False
calls it internally with the already-loaded child's own bbox. edge-mosaic's
multi-file loop instead pre-scans every children file's bbox, unions them,
and calls prepare_parent_tiles() once before iterating with the combined
bbox, passing use_cached_tiles=True on every file's assign_one() call, so
the same parent's tiles aren't grid-subdivided from scratch on every one of
possibly hundreds of files. See docs/adr/0024 for the profiling
discrepancy that motivated the caching split, and docs/adr/0085 for the
bbox prefilter.
For each source_file, assign_one counts how many of that file's children
intersect each candidate parent (a count of intersecting children, not
summed overlap area) and assigns every child in the file to whichever
parent wins that count, since a single file's children are always one
group. This guards against cross-country border overshoot misassigning a
file by per-child area alone; see docs/adr/0019-mosaic-per-file-majority-vote.md
for why a per-child rule isn't enough and what it was measured to break.
Once a file has a winner, every child in that file rides it unconditionally,
including a child with literally zero overlap with the winning parent (e.g.
a tiny offshore island whose true parent it can only reach after
edge-match's own post-assign Voronoi extension). Only a file whose
children have no overlap with any parent at all lands in
_02_unassigned. A forced-in child that still doesn't physically reach its
parent by clip time produces an empty ST_Intersection and is dropped
there instead, reported as a kind='clip-empty' issue row rather than an
assign-time one (see docs/explanation/edge_clip.md).
Code-column join (optional)¶
Both assign_many and assign_one accept optional parent_match_column/
child_match_column kwargs. None (the default) keeps _02_assign's
schema and values exactly as before, child_fid/parent_fid only, no
behavior change. When both are given, the existing spatial result above is
still always computed (it's needed as the cross-check), alongside an exact
code join restricted to (child, parent) pairs that actually overlap (a
code match against a non-overlapping parent doesn't count). The code result
wins whenever one exists; _02_assign gains two more columns recording
which path won:
assignment_method:'code'when a code match existed,'spatial_fallback'when it fell back to the spatial result.spatial_agrees: formethod='code'rows, whether the spatial result agreed (True)/disagreed (False) with the code match;NULLforspatial_fallbackrows.
assign_many computes this per child (its usual per-child plurality feeds
the cross-check); assign_one computes it per source file (its usual
per-file majority vote feeds the cross-check, so every child in a
code-mismatched or code-fallback file shares the same assignment_method).
Neither function derives issues rows itself; that's each calling
api.*()'s job (kind='code-mismatch'/'code-fallback', see
docs/reference/shared.md). See docs/adr/0045 for why code wins on
disagreement instead of spatial, and why an unmatched code falls back
instead of dropping the child/file.
Carry-forward columns (optional)¶
Both assign_many and assign_one accept an optional carry_columns list
of parent column names and an optional child_columns list of child column
names. None (the default, for both) leaves _02_assign's schema
unchanged. When carry_columns is given, each name is projected from
{name}_parent_01 onto every matched child row, joined on the
already-resolved parent_fid (after any code-column join above has picked
a winner), so it costs one extra join regardless of which path assigned
the parent. Column names are always caller-specified, never inferred from
either layer's schema, matching this project's structural (not
name/value-based) matching philosophy elsewhere (see
docs/explanation/schema_map.md). A name colliding with _02_assign's own
reserved columns (child_fid, parent_fid, assignment_method,
spatial_agrees) raises ValueError; a name already present on the
child's own resolved column list (child_columns when given, else the raw
{name}_child_01 schema via DESCRIBE) also raises ValueError, not left
to the SQL layer to reject on its own (DuckDB silently renames/dedups a
duplicate SELECT column instead of erroring, see docs/adr/0077).
Checking against the resolved child_columns rather than the raw schema
means a column narrowed away by --child-exclude no longer false-positives
this collision check.
Both lists are resolved once per call by core.assign.resolve_merge_columns()
(itself built on resolve_column_selection(), and gated by
validate_merge_flags()'s mutual-exclusion/require-merge checks), shared
by edge-mosaic's and edge-match's api layers: parent_include/
parent_exclude narrow carry_columns (always dropping fid/geom),
child_include/child_exclude narrow child_columns (always keeping
fid/geom/source_file), and prefer ("parent"/"child") drops the
losing side's column wherever both lists name the same column, resolving a
collision automatically instead of raising.
Children with no parent match (_02_unassigned) never gain the carried
columns. A caller that keeps such rows in its own output regardless (child
passthrough, or a zero-children parent kept via core.assign.fill_unmatched_parents();
see docs/explanation/edge_mosaic.md/docs/explanation/edge_match.md)
gets NULL for all of them automatically via that caller's own UNION ALL
BY NAME.
Comparison¶
assign-many |
assign-one |
|
|---|---|---|
| Used by | edge-match --multi-parent only |
edge-mosaic, edge-clip, edge-match (default) |
| Decision granularity | Per child | Per source file |
| Zero-overlap child in an otherwise-matched file | Dropped, logged, _02_unassigned |
Forced onto the file's winner; dropped later at clip time if it still doesn't reach it |
| Vote signal | N/A (each child stands alone) | Count of intersecting children per file |
| Misassignment risk | None from overshoot (there is none) | A file too small to form a real majority (single-child file, tied vote) |
Caveats¶
assign-one runs against overshoot geometry (for edge-mosaic/edge-clip).
It never sees a child's pre-extension footprint, only the already-extended
geometry, so the bbox prefilter is less selective than assign-many's, and
in principle the per-child overlap signal underlying the vote could be
skewed by a child whose overshoot bulges further into a neighboring parent
than into its true one. The per-file majority vote mitigates this because a
file's other, unaffected children still outvote a single misbehaving one by
count, but a single-child file has no other vote to correct it, and a tied
vote (e.g. a two-child file split one-and-one between two parents) falls
back to the lower parent id rather than any geometric signal.
assign-one forces every child onto its file's winner, even a
non-overlapping one. This is intentional (see above), but it means
edge-match's default no longer drops a straggler at assign time; a
caller relying on _02_unassigned to catch every non-overlapping child
must also check the kind='clip-empty' issue rows produced downstream.