assign
assign-many and assign-one are two internal crosswalk strategies in
core/assign/; neither is a standalone CLI/API tool. Both build a
(input_fid, overlay_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 input feature in one input file is forced onto one shared overlay feature, chosen
by majority vote of that file's input features, once that file has any winner at
all. This is the safest choice for a large group of polygons resolving
against a single true overlay feature, since a per-feature 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 (--per-feature): each input feature
decides independently which overlay feature it overlaps most, so one input file's
input features MAY scatter across many different overlay features. Use it only when
that's actually true of the input, e.g. a poorly-digitized admin4 layer
whose input features genuinely belong to many different admin3 overlay features. It is
never used for edge-mosaic/edge-clip, whose already-extended (overshoot)
geometry makes per-feature 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 overlay layer raw via
core.io.read_and_reproject, and the (possibly multi-file) input 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
input feature 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/pages/3-edge/explanation/edge_clip.md) both call its load_input()/
load_overlay() directly, since ADR-0023 split those apart; api.edge_match
loads its own input features 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 --per-feature 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 input file,
reusing a cached overlay-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 input features,
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 overlay feature (a country with offshore
islands) would otherwise get one bbox spanning everything and defeat the
prefilter. Shared area per (input, overlay) fid pair is summed across
every part-pair (a multi-part input feature can overlap a multi-part overlay feature 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 overlay features. Only the intersection geometry
is transformed, not the whole layer, to bound the cost.
picks the plurality overlay feature per input feature; ties break on the lowest overlay fid. Input features with zero overlap with any overlay feature 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 overlay 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
overlay 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_overlay_tiles(), which
takes an optional input_bbox (xmin, ymin, xmax, ymax) that skips any
overlay part whose bbox can't overlap it, so a handful of input features matched
against one much larger shared overlay feature (e.g. a global admin0 file) doesn't
tile parts nothing will ever be assigned to (see docs/adr/0085). A caller
processing one input 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 input feature's own bbox. edge-mosaic's
multi-file loop instead pre-scans every input file's bbox, unions them,
and calls prepare_overlay_tiles() once before iterating with the combined
bbox, passing use_cached_tiles=True on every file's assign_one() call, so
the same overlay feature'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 input features
intersect each candidate overlay feature (a count of intersecting input features, not
summed overlap area) and assigns every input feature in the file to whichever
overlay feature wins that count, since a single file's input features are always one
group. This guards against cross-country border overshoot misassigning a
file by per-feature area alone; see docs/adr/0019-mosaic-per-file-majority-vote.md
for why a per-feature rule isn't enough and what it was measured to break.
Once a file has a winner, every input feature in that file rides it unconditionally,
including an input feature with literally zero overlap with the winning overlay feature (e.g.
a tiny offshore island whose true overlay feature it can only reach after
edge-match's own post-assign Voronoi extension). Only a file whose
input features have no overlap with any overlay feature at all lands in
_02_unassigned. A forced-in input feature that still doesn't physically reach its
overlay feature 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/pages/3-edge/explanation/edge_clip.md).
Code-column join (optional)¶
Both assign_many and assign_one accept optional overlay_match_column/
input_match_column kwargs. None (the default) keeps _02_assign's
schema and values exactly as before, input_fid/overlay_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 (input, overlay) pairs that actually overlap (a
code match against a non-overlapping overlay feature 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 input feature (its usual per-feature plurality feeds
the cross-check); assign_one computes it per source file (its usual
per-file majority vote feeds the cross-check, so every input feature 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/adr/0045 for why code wins on
disagreement instead of spatial, and why an unmatched code falls back
instead of dropping the input feature/file.
Carry-forward columns (optional)¶
Both assign_many and assign_one accept an optional carry_columns list
of overlay column names and an optional input_columns list of input column
names. None (the default, for both) leaves _02_assign's schema
unchanged. When carry_columns is given, each name is projected from
{name}_overlay_01 onto every matched input feature row, joined on the
already-resolved overlay_fid (after any code-column join above has picked
a winner), so it costs one extra join regardless of which path assigned
the overlay feature. 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/pages/1-schema/explanation/schema_map.md). A name colliding with _02_assign's own
reserved columns (input_fid, overlay_fid, assignment_method,
spatial_agrees) raises ValueError; a name already present on the
input feature's own resolved column list (input_columns when given, else the raw
{name}_input_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 input_columns rather than the raw schema
means a column narrowed away by --input-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: overlay_include/
overlay_exclude narrow carry_columns (always dropping fid/geom),
input_include/input_exclude narrow input_columns (always keeping
fid/geom/source_file), and prefer ("overlay"/"input") drops the
losing side's column wherever both lists name the same column, resolving a
collision automatically instead of raising.
Input features with no overlay feature match (_02_unassigned) never gain the carried
columns. A caller that keeps such rows in its own output regardless (input
passthrough, or an unmatched overlay feature kept via core.assign.fill_unmatched_overlays();
see docs/pages/3-edge/explanation/edge_mosaic.md/docs/pages/3-edge/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 --per-feature only |
edge-mosaic, edge-clip, edge-match (default) |
| Decision granularity | Per input feature | Per source file |
| Zero-overlap input feature 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 input feature stands alone) | Count of intersecting input features per file |
| Misassignment risk | None from overshoot (there is none) | A file too small to form a real majority (single-input file, tied vote) |
Caveats¶
assign-one runs against overshoot geometry (for edge-mosaic/edge-clip).
It never sees an input feature'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-feature overlap signal underlying the vote could be
skewed by an input feature whose overshoot bulges further into a neighboring overlay feature
than into its true one. The per-file majority vote mitigates this because a
file's other, unaffected input features still outvote a single misbehaving one by
count, but a single-input file has no other vote to correct it, and a tied
vote (e.g. a two-input file split one-and-one between two overlay features) falls
back to the lower overlay feature id rather than any geometric signal.
assign-one forces every input feature 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 input feature
must also check the kind='clip-empty' issue rows produced downstream.