#41: Landcover absorption drifts every class into the dominant one #56

Merged
art merged 8 commits from night/issue-41-absorption-drift into main 2026-08-05 07:20:28 +00:00
Owner

Closes #41


#41 Landcover absorption drifts every class into the dominant one — mechanism, fix, verification

Mechanism (measured on the live DB before any change)

Reproduced the issue's z9..z5 table on the current DB (SE/PL cover as rebuilt by #47):

z9 z8 z7 z6 z5
SE nature 1 895 646 1 945 355 1 978 134 2 005 668 2 025 571
SE water 570 056 562 947 555 793 547 089 541 446
SE agricultural 86 405 47 332 23 557 7 354 1 216
SE artificial 10 083 3 893 890 275 56
PL nature 289 894 278 115 269 592 259 067 254 820
PL water 93 632 92 483 91 409 91 276 91 654
PL agricultural 495 140 509 042 516 153 517 182 512 854
PL artificial 27 334 26 484 28 996 38 649 46 259

km². Sweden loses 98.6% of its agricultural area and 99.4% of its artificial area
between z9 and z5
, both into nature; Poland drifts the other way (agricultural +3.6%,
artificial +69%). The z9→z8 step alone eats 39 073 of the 86 405 km² of Swedish
agricultural.

Why the rollup does not save it: the z9 rollup already merges touching same-class
polygons into connected components — measured 684 residual adjacent pairs in Sweden's
42 642 z9 agricultural polygons — so 94% of the z9 agricultural polygons (40 037 of
42 642, 42 500 km²) sit individually below the z8 candidate threshold (5.98 km² at z8)
while the components they form are collectively the regions a reader can see. "Merge
adjacent same-class first and test the cluster" is therefore a no-op below z9 as
literally stated
: the touching-merge has already happened. The region is the components
that sit within a patch-size of each other (sqrt(min_area_pixels) px — 4 at the working
value 16), which the rollup (by construction) does
not merge — and which absorption then eats one component at a time.

Fix shape chosen and why

Cluster-before-absorbing, with the cluster defined by distance, applied below the
coarsest rollup
(importer/generalize.go, absorbSQL): below the layer's coarsest
enum_rollups zoom (z9 for cover), the area candidacy test runs on the same-class
cluster — every same-class polygon within sqrt(min_area_pixels) screen pixels of each
other, 4 at the working value 16, the side of the smallest visible patch
(ST_ClusterDBSCAN(geom, sqrt(min_area_pixels) * pixelSize, 1) OVER (PARTITION BY cover_class) — class only, deliberately not part_key) — instead of the polygon's own
area. A one-pixel eps was measured to fail: Stockholm's built-up fragments sit across
bays and parks with gaps wider than a pixel and were eaten fragment by fragment from z8
up, where the patch-side eps chain-connects them. The width test is switched off below
the rollup — a thin sliver inside a cluster that clears the threshold is no longer a
candidate at all, where before it was; the neighbour lookup, the direction rule and the
no-neighbour drop floor are untouched; like absorb_boundary, the floor only changes
which polygons are candidates — a recolour, never a filter — so the coverage stays
hole-free.

Why distance rather than the issue's literal "merge adjacent": measured above — the
touching-merge is already done by the z9 rollup; the distance is what re-forms regions at
coarse zoom (a pixel is 611 m at z8, 4.9 km at z5).

Why it stops at the coarsest rollup (measured cost, not convenience): ST_ClusterDBSCAN
is O(n²) in the partition size — on the z12 table (3.2 M rows) a single cell measured
4m55s (largest class partition 18 742 polygons), and the mid-class thresholds (0.09
km² at z11) are sub-feature so the per-polygon rule is already correct above z9. Below the
rollup the per-batch (part_key, class) partitions are small: 6 301 polygons through
coverage-simplify plus clustering measured 1.1 s, and the whole floor costs seconds
per step. The scoping is rollup-derived, not configured — move a rollup and the floor
follows — and z11/z10 generate byte-identical SQL, so z9 and above are unchanged by
construction
and the rebuild window is z3–z8 only.

The cluster is batch-scoped: the parallel cascade splits the work into partition
batches capped at -batch-rows polygons, each running its own absorbSQL with its own
temp table, so the DBSCAN only ever sees one batch — a region spanning a batch boundary
is still split, one level up from the per-cell failure. All measurements below assume
-batch-rows 10000.

Rejected alternatives, with numbers:

  • Per-cell class floor (the issue's shape b, per-cell): a window sum over
    (part_key, cover_class) instead of clustering. Zero cost, and it recovers more area at
    z8 (86 268 km² of SE agricultural vs 74 145 for the cluster) — but it protects every
    member of a class in a cell whose total clears the threshold, including isolated
    sub-pixel specks, and it splits cross-cell regions at cell boundaries that the cluster
    re-joins. The cluster keeps the de-speckling (an isolated speck's cluster is itself and
    still absorbs) and costs seconds below the rollup, so the only reason to take the floor
    would be the z12 cost — which the rollup scoping already removes.
  • Class-share neighbour ranking (shape c): changes where a speck goes, not whether
    it is a candidate; the ratchet is about candidacy (94% of z9 agricultural is
    sub-threshold at z8), so this does not fix the measured drift.
  • Per-polygon area unchanged: the drift, by definition.

Verification

  • make build; go test ./... (importer + mapsrv) — pass, including the new
    TestAbsorbClusterFloor (pins the _cl_a candidate test and the DBSCAN partition
    below the rollup, the per-polygon rule above it and for no-rollup layers, and the
    unchanged drop floor).

  • Regeneralized cover z3–z8 from the live z9 for SE and PL (z9+ unchanged by
    construction — see above). Final combined-build numbers (-regeneralize 3-8 -jobs 6 -batch-rows 10000; the branch's cluster floor AND the #47 cross-boundary floor, whose
    commits this branch carries — see the supersede note below — the build the DB had not
    had yet):

    SE cover km² z9 z8 z7 z6 z5
    nature 1 895 646 1 908 925 1 925 024 1 945 387 1 960 781
    water 570 056 565 377 562 471 562 045 568 385
    agricultural 86 405 79 286 69 652 55 626 40 469
    artificial 10 083 8 355 5 115 1 919 695
    PL cover km² z9 z8 z7 z6 z5
    nature 289 894 287 624 285 219 282 362 279 018
    water 93 632 93 056 92 591 92 434 91 510
    agricultural 495 140 501 331 504 444 505 464 508 304
    artificial 27 334 24 115 23 879 25 802 25 988

    The z9→z8 step now eats 7 119 km² of Swedish agricultural (86 405 → 79 286) instead
    of 39 073, and the z9→z5 picture is: SE agricultural −98.6% → −53.2% (86 405 →
    40 469 km²), SE artificial −99.4% → −93.1% (10 083 → 695 km²), PL agricultural
    +3.6% → +2.7% (508 304 km² at z5), PL artificial +69% → −4.9% (25 988 km² at
    z5). The #47 floor shows in the water line: SE z5 water holds at 568 385 km², within
    0.3% of the z9 seed (570 056), where the #47-only build had retreated 5%.

  • Coverage hole-free: the check that actually constrains this failure mode is the
    per-zoom totals, and they hold — SE 2 562 190 km² (z9) → 2 561 943 (z8) → 2 570 330
    (z5), PL 906 000 (z9) → 904 820 (z5), all within 0.3%. (The earlier "0 NULL cover_class
    rows" check tests classification, not hole-freeness: a polygon falling out of both
    halves of the small/large split disappears entirely rather than appearing with a NULL
    class.)

  • Decoded tile (mvt-decode.py + attribute walk): zxy_map(6,34,20) (southern Skåne)
    and zxy_map(5,17,10) carry 4 222 and 17 950 cover features classified
    agricultural — farmland is the dominant class in the served tiles.

  • Real-browser CDP check (mapsrv :18080, headless Chrome, #5/55.6/13.0): 35 205
    rendered cover_agricultural features vs 32 145 nature — Skåne reads as farmland,
    not uniform forest. No console errors.

#47 cross-reference and supersede

#47 (water eats coastlines, PR #55) floored cross-boundary inheritance at one screen
pixel — the coastline can only move by sub-pixel amounts per step. This fix floors the
inland ratchet: sub-threshold polygons are tested as same-class regions. They are
distinct mechanisms (boundary floor vs region cluster), neither retires the other; #47
stays open on its own merits
and this PR's target is #41.

This branch carries the #47 fix in full — the absorb_boundary cross-boundary floor
(78dad2f), its mechanism documentation (4c3bcd4), and the combined
cluster-floor-does-not-disable-#47 test (8ad8517). Merging this PR alone would ship the
#47 fix while #47 stays open and PR #55 becomes an empty PR. Since #55 is not merged and
this branch contains its content, this PR supersedes #55: the maintainer may retire
PR #55 in this one's favour at merge time. The incremental review surface for the
#41-specific work is git diff origin/night/issue-47-water-coastline..HEAD — the 153
lines of absorbSQL actually about #41. Note that the #47 branch has since been rebased
and extended (its current tip carries a guard-covers-width doc note, 50ccd57); that
note is incorporated into this branch's docs/generalization.md, so retiring #55 loses
nothing from its current tip. The verification numbers below combine both fixes and hold
only if the combined code (this branch) is what lands.

Left undone

  • Finland's cover was not rebuilt (FI is still on the pre-#47 logic; rebuild on
    merge — it has neither the cluster floor nor the cross-boundary floor).
Closes #41 --- #41 Landcover absorption drifts every class into the dominant one — mechanism, fix, verification ## Mechanism (measured on the live DB before any change) Reproduced the issue's z9..z5 table on the current DB (SE/PL cover as rebuilt by #47): | | z9 | z8 | z7 | z6 | z5 | |---|---:|---:|---:|---:|---:| | SE nature | 1 895 646 | 1 945 355 | 1 978 134 | 2 005 668 | 2 025 571 | | SE water | 570 056 | 562 947 | 555 793 | 547 089 | 541 446 | | SE agricultural | 86 405 | 47 332 | 23 557 | 7 354 | **1 216** | | SE artificial | 10 083 | 3 893 | 890 | 275 | **56** | | PL nature | 289 894 | 278 115 | 269 592 | 259 067 | 254 820 | | PL water | 93 632 | 92 483 | 91 409 | 91 276 | 91 654 | | PL agricultural | 495 140 | 509 042 | 516 153 | 517 182 | 512 854 | | PL artificial | 27 334 | 26 484 | 28 996 | 38 649 | 46 259 | km². **Sweden loses 98.6% of its agricultural area and 99.4% of its artificial area between z9 and z5**, both into nature; Poland drifts the other way (agricultural +3.6%, artificial +69%). The z9→z8 step alone eats 39 073 of the 86 405 km² of Swedish agricultural. Why the rollup does not save it: the z9 rollup already merges *touching* same-class polygons into connected components — measured **684 residual adjacent pairs** in Sweden's 42 642 z9 agricultural polygons — so 94% of the z9 agricultural polygons (40 037 of 42 642, 42 500 km²) sit individually below the z8 candidate threshold (5.98 km² at z8) while the components they form are collectively the regions a reader can see. "Merge adjacent same-class first and test the cluster" is therefore a **no-op below z9 as literally stated**: the touching-merge has already happened. The region is the components that sit within a patch-size of each other (sqrt(min_area_pixels) px — 4 at the working value 16), which the rollup (by construction) does not merge — and which absorption then eats one component at a time. ## Fix shape chosen and why **Cluster-before-absorbing, with the cluster defined by distance, applied below the coarsest rollup** (`importer/generalize.go`, `absorbSQL`): below the layer's coarsest `enum_rollups` zoom (z9 for `cover`), the area candidacy test runs on the same-class cluster — every same-class polygon within sqrt(min_area_pixels) screen pixels of each other, 4 at the working value 16, the side of the smallest visible patch (`ST_ClusterDBSCAN(geom, sqrt(min_area_pixels) * pixelSize, 1) OVER (PARTITION BY cover_class)` — class only, deliberately not part_key) — instead of the polygon's own area. A one-pixel eps was measured to fail: Stockholm's built-up fragments sit across bays and parks with gaps wider than a pixel and were eaten fragment by fragment from z8 up, where the patch-side eps chain-connects them. The width test is switched off below the rollup — a thin sliver inside a cluster that clears the threshold is no longer a candidate at all, where before it was; the neighbour lookup, the direction rule and the no-neighbour drop floor are untouched; like `absorb_boundary`, the floor only changes *which* polygons are candidates — a recolour, never a filter — so the coverage stays hole-free. Why distance rather than the issue's literal "merge adjacent": measured above — the touching-merge is already done by the z9 rollup; the distance is what re-forms regions at coarse zoom (a pixel is 611 m at z8, 4.9 km at z5). Why it stops at the coarsest rollup (measured cost, not convenience): `ST_ClusterDBSCAN` is O(n²) in the partition size — on the z12 table (3.2 M rows) a single cell measured **4m55s** (largest class partition 18 742 polygons), and the mid-class thresholds (0.09 km² at z11) are sub-feature so the per-polygon rule is already correct above z9. Below the rollup the per-batch (part_key, class) partitions are small: 6 301 polygons through coverage-simplify plus clustering measured **1.1 s**, and the whole floor costs seconds per step. The scoping is rollup-derived, not configured — move a rollup and the floor follows — and z11/z10 generate byte-identical SQL, so **z9 and above are unchanged by construction** and the rebuild window is z3–z8 only. The cluster is **batch-scoped**: the parallel cascade splits the work into partition batches capped at `-batch-rows` polygons, each running its own `absorbSQL` with its own temp table, so the DBSCAN only ever sees one batch — a region spanning a batch boundary is still split, one level up from the per-cell failure. All measurements below assume `-batch-rows 10000`. Rejected alternatives, with numbers: - *Per-cell class floor* (the issue's shape b, per-cell): a window sum over (part_key, cover_class) instead of clustering. Zero cost, and it recovers *more* area at z8 (86 268 km² of SE agricultural vs 74 145 for the cluster) — but it protects every member of a class in a cell whose total clears the threshold, including isolated sub-pixel specks, and it splits cross-cell regions at cell boundaries that the cluster re-joins. The cluster keeps the de-speckling (an isolated speck's cluster is itself and still absorbs) and costs seconds below the rollup, so the only reason to take the floor would be the z12 cost — which the rollup scoping already removes. - *Class-share neighbour ranking* (shape c): changes *where* a speck goes, not *whether* it is a candidate; the ratchet is about candidacy (94% of z9 agricultural is sub-threshold at z8), so this does not fix the measured drift. - *Per-polygon area unchanged*: the drift, by definition. ## Verification - `make build`; `go test ./...` (importer + mapsrv) — pass, including the new `TestAbsorbClusterFloor` (pins the `_cl_a` candidate test and the DBSCAN partition below the rollup, the per-polygon rule above it and for no-rollup layers, and the unchanged drop floor). - Regeneralized `cover` z3–z8 from the live z9 for SE and PL (z9+ unchanged by construction — see above). Final combined-build numbers (`-regeneralize 3-8 -jobs 6 -batch-rows 10000`; the branch's cluster floor AND the #47 cross-boundary floor, whose commits this branch carries — see the supersede note below — the build the DB had not had yet): | SE `cover` km² | z9 | z8 | z7 | z6 | z5 | |---|---|---:|---:|---:|---:| | nature | 1 895 646 | 1 908 925 | 1 925 024 | 1 945 387 | 1 960 781 | | water | 570 056 | 565 377 | 562 471 | 562 045 | 568 385 | | agricultural | 86 405 | 79 286 | 69 652 | 55 626 | **40 469** | | artificial | 10 083 | 8 355 | 5 115 | 1 919 | **695** | | PL `cover` km² | z9 | z8 | z7 | z6 | z5 | |---|---|---:|---:|---:|---:| | nature | 289 894 | 287 624 | 285 219 | 282 362 | 279 018 | | water | 93 632 | 93 056 | 92 591 | 92 434 | 91 510 | | agricultural | 495 140 | 501 331 | 504 444 | 505 464 | **508 304** | | artificial | 27 334 | 24 115 | 23 879 | 25 802 | **25 988** | The z9→z8 step now eats 7 119 km² of Swedish agricultural (86 405 → 79 286) instead of 39 073, and the z9→z5 picture is: SE agricultural **−98.6% → −53.2%** (86 405 → 40 469 km²), SE artificial **−99.4% → −93.1%** (10 083 → 695 km²), PL agricultural **+3.6% → +2.7%** (508 304 km² at z5), PL artificial **+69% → −4.9%** (25 988 km² at z5). The #47 floor shows in the water line: SE z5 water holds at 568 385 km², within 0.3% of the z9 seed (570 056), where the #47-only build had retreated 5%. - Coverage hole-free: the check that actually constrains this failure mode is the per-zoom totals, and they hold — SE 2 562 190 km² (z9) → 2 561 943 (z8) → 2 570 330 (z5), PL 906 000 (z9) → 904 820 (z5), all within 0.3%. (The earlier "0 NULL cover_class rows" check tests classification, not hole-freeness: a polygon falling out of both halves of the small/large split disappears entirely rather than appearing with a NULL class.) - Decoded tile (mvt-decode.py + attribute walk): `zxy_map(6,34,20)` (southern Skåne) and `zxy_map(5,17,10)` carry 4 222 and 17 950 `cover` features classified `agricultural` — farmland is the dominant class in the served tiles. - Real-browser CDP check (mapsrv :18080, headless Chrome, `#5/55.6/13.0`): 35 205 rendered `cover_agricultural` features vs 32 145 nature — Skåne reads as farmland, not uniform forest. No console errors. ## #47 cross-reference and supersede #47 (water eats coastlines, PR #55) floored *cross-boundary* inheritance at one screen pixel — the coastline can only move by sub-pixel amounts per step. This fix floors the *inland* ratchet: sub-threshold polygons are tested as same-class regions. They are distinct mechanisms (boundary floor vs region cluster), neither retires the other; **#47 stays open on its own merits** and this PR's target is #41. **This branch carries the #47 fix in full** — the absorb_boundary cross-boundary floor (`78dad2f`), its mechanism documentation (`4c3bcd4`), and the combined cluster-floor-does-not-disable-#47 test (`8ad8517`). Merging this PR alone would ship the #47 fix while #47 stays open and PR #55 becomes an empty PR. Since #55 is not merged and this branch contains its content, **this PR supersedes #55**: the maintainer may retire PR #55 in this one's favour at merge time. The incremental review surface for the #41-specific work is `git diff origin/night/issue-47-water-coastline..HEAD` — the 153 lines of `absorbSQL` actually about #41. Note that the #47 branch has since been rebased and extended (its current tip carries a guard-covers-width doc note, `50ccd57`); that note is incorporated into this branch's `docs/generalization.md`, so retiring #55 loses nothing from its current tip. The verification numbers below combine both fixes and hold only if the combined code (this branch) is what lands. ## Left undone - Finland's `cover` was not rebuilt (FI is still on the pre-#47 logic; rebuild on merge — it has neither the cluster floor nor the cross-boundary floor).
Dominant-neighbour absorption reclassifies a sub-threshold polygon to the
largest thing beside it. Repeated once per zoom, that is a ratchet toward the
country's dominant class: measured on the live SE/PL tables, Sweden's cover
loses 98.6% of its agricultural area (86 405 -> 1 216 km2) and 99.4% of its
artificial area (10 083 -> 56 km2) between z9 and z5, both into nature;
Poland drifts the other way (agricultural +3.6%, artificial +69%).

The z9 rollup already merges *touching* same-class polygons into connected
components (measured: 684 residual adjacent pairs in Sweden's 42 642 z9
agricultural polygons), so "merge adjacent same-class first" alone is a
no-op; the region a reader sees is the components that sit within one screen
pixel of each other. Below the layer's coarsest rollup — where the class
vocabulary is final — the area candidacy test now runs on that same-class
cluster (ST_ClusterDBSCAN over (partition, class), eps = one pixel) instead
of the polygon's own area: a thousand fields within a pixel of each other
count as one region and survive as one, while an isolated speck's cluster is
itself and it still absorbs. The floor is a recolour rule like the rest —
it only changes which polygons are candidates, never deletes, so the
coverage stays hole-free (the no-neighbour drop floor is untouched).

The floor stops at the coarsest rollup for cost: ST_ClusterDBSCAN is O(n^2)
in the partition size, and on the mid-class tables (z12->z11, 3.2M rows) a
single cell measured 5 min — while below the rollup the per-batch
(part_key, class) partitions are small and the floor costs seconds per step
(measured: 6 301 polygons through simplify + clustering in 1.1 s). The
cluster id is materialised in two UPDATEs rather than one CTE because the
planner re-evaluates a window function referenced by a second window
(measured O(n^2) again).

Validation on the live z9 tables: the cluster floor keeps 74 145 km2 of
Sweden's z9 agricultural at z8 (vs 47 332 with the old per-polygon rule),
and the per-cell class totals it implies hold 73 741 km2 through z5 (vs
1 216 today). Full cascade verification pending (regeneralize runs in
progress); the z11/z10 steps are byte-identical to before, so z9 and above
are unchanged by construction.

Co-Authored-By: Hermes Agent <noreply@nousresearch.com>
The first cluster-floor cut (per-cell DBSCAN at one screen pixel) measured
right on the mechanism but wrong on the issue's own test cases:

- Per-cell clustering split Stockholm: the city's urban fragments spread over
  several grid cells, no single cell held enough of it, and each fragment's
  cluster died under the z5 threshold. The cluster now partitions by class
  only, so a region crossing a cell boundary is one cluster (measured:
  Stockholm's z8 fragments chain-connect to 1 110 km² at the z7 eps).
- A one-pixel eps was still too fine: the fragments of a city sit across bays
  and parks with gaps wider than a pixel, so at 1 px Stockholm was eaten one
  fragment at a time from z8 up. The eps is now the side of the smallest
  visible patch — sqrt(min_area_pixels) pixels, 4 for the working value 16 —
  the distance within which two fragments are the same visible region.
- The per-polygon width test ate the rest: Stockholm's surviving fragments
  are peninsulas narrower than one z5 pixel, so min_width_pixels 1 absorbed
  them all even though their cluster was 685 km². Below the rollup the width
  test is subsumed by the cluster — a fragment of a visible region is kept
  even when individually thin; the width test still runs above the rollup and
  for no-rollup layers, where its sliver-removal remains the whole point.

Measured after these changes (SE cover, z9→z5): agricultural 86 405 →
40 798 km² (was 1 216), artificial 10 083 → 702 (was 56, Stockholm 656 km² in
373 fragments). Cost is unchanged: ~15 s per 50k-row batch at z8, <1 s at z6.

Co-Authored-By: Hermes Agent <noreply@nousresearch.com>
Issue #47: at z4-z6 the coastline is eaten again — Sweden's cover water
grew +21 204 km² on the z5→z4 step and another +34 144 on z4→z3, Poland
starts growing at z5, and the Baltic chews into the coast. The #26 fix
(absorb_boundary) holds at z12-z10 (marine_water frozen) but fails below
z9: once the rollup has made everything `water`, the boundary is only a
preference, and at coarse zoom the pixel-based threshold makes whole
land features candidates — measured on the live z5 table, 99.97% of the
74 640 km² eaten on the z5→z4 step was land with no same-side neighbour
at all (islands and chunks isolated by the merged sea/lake polygons).

Fix: with absorb_boundary declared, a candidate may inherit across the
line only when it is smaller than one screen pixel — the same floor the
no-neighbour drop rule already uses. A multi-pixel island keeps its own
class (and its smaller neighbours absorb into it) instead of feeding the
sea, so the coastline can only move by sub-pixel amounts per step. The
preference/fallback shape is unchanged for sub-pixel specks, so the
coverage stays hole-free and same-side absorption still merges freely.

Importer-only change (importer/generalize.go absorbSQL): the LATERAL now
exposes the winning neighbour's `same_side` flag and the class CASE
requires `neighbor.bigger AND (neighbor.same_side OR c._a <= dropArea)`.
Config untouched. Tests extended to pin the new CASE shape.

Co-Authored-By: Hermes Agent <noreply@nousresearch.com>
The conf comment, docs/generalization.md (the absorb_boundary floor semantics)
and docs/performance.md (the marine-freeze measurement and the z5->z3 land-loss
numbers) that belong with commit 805ebb8.

Co-Authored-By: Hermes Agent <noreply@nousresearch.com>
Co-Authored-By: Hermes Agent <noreply@nousresearch.com>
SE z5 agricultural 1 216 -> 40 469 km2 (-98.6% -> -53.2%), artificial 56 -> 695
(-99.4% -> -93.1%); PL agricultural +3.6% -> +2.7%, artificial +69% -> -4.9%.
Measured on the -regeneralize 3-8 combined build (cluster floor + cherry-picked
#47 cross-boundary floor) from the live z9 seed; hole-free (0 NULL cover_class
at z3-z8, both countries); Skane tile decode + real-browser CDP check confirm
farmland survives in the served tiles.

Co-Authored-By: Hermes Agent <noreply@nousresearch.com>
Collaborator

Review — #56 (landcover absorption drift, issue #41)

Builds clean, go vet and go test pass (the only failure is TestDumpGlyphPNG, which
cannot write /tmp/glyph_g.png in this sandbox and fails identically on every branch
including the docs-only one).

The mechanism work is the best part of this PR. Measuring 684 residual adjacent pairs in
42 642 z9 agricultural polygons
to show that the z9 rollup has already done the
touching-merge — and therefore that the issue's literal "merge adjacent same-class first"
is a no-op below z9 — is the finding that turns the whole thing from a guess into a design.
Reaching for distance instead, and deriving the scope from coarsestRollupZoom rather than
a new config knob, is the right shape. The two-UPDATE materialisation with the note that
the planner re-evaluates a window function referenced by a second window is a good catch and
exactly the kind of thing that belongs in a comment.

Four things before merge.

1. This branch contains PR #55 in full

The body describes the #47 cross-boundary floor as "cherry-picked" into the verification
build, which reads as a local convenience. It is not — the branch carries those commits:

69490e7 docs: final combined-build verification numbers for the #41 cluster floor
08d94f6 test: cluster floor must not disable the #47 cross-boundary floor
79155c0 docs: absorb_boundary cross-boundary floor, mechanism and measurement (issue #47)
65d57a9 absorb_boundary: never cross the water line with a drawable polygon
633882a absorb: cluster floor refinement — cross-cell, patch-side eps, width subsumed
b72fa80 absorb: cluster-before-absorbing floor below the coarsest rollup (issue #41)

git diff main... for this branch therefore includes all of #55. So merging #56 on its own
would silently ship the #47 fix while issue #47 stays open and #55 becomes an empty PR —
awkward given the body explicitly says "this change leaves #47 open". Either rebase onto
main after #55 lands, or say plainly that #56 supersedes #55 and retire #55 in its favour.
The incremental review surface here is git diff origin/night/issue-47-water-coastline..,
which is the 153 lines of absorbSQL that are actually about #41.

It also means the verification numbers cannot separate the two fixes, which the body is
honest about but which is worth restating: if #55 does not merge, these tables do not hold.

2. Width-only config generates SQL referencing a column that is never created

cluster is lowered to false at generalize.go:889 when the layer has no class columns or
min_area_pixels == 0 — but areaTest, candPred and notCandPred were already built
from the original cluster at lines 835–850. So a width-only cover layer below the
coarsest rollup emits _cl_a predicates while clusterSQL stays empty and the column is
never added. Reproduced with a throwaway test on this branch:

references _cl_a = true ; defines _cl_a = false
  offending SQL: WHERE _cl_a <= 0.00
  offending SQL: WHERE _cl_a > 0.00

That is column "_cl_a" does not exist at runtime. No current config reaches it — every
cover layer sets min_area_pixels — but width-only is an explicitly supported shape, and
absorbSQL's own comment documents it ("a width-only config leaves min_area_pixels unset…
absorption is on because min_width_pixels turned it on"). Moving the
if len(classCols) == 0 || l.MinAreaPixels == 0 { cluster = false } block above the
areaTest computation fixes it. Worth a test case, since nothing currently exercises the
path.

3. Three places still document the eps that was measured not to work

633882a changed the cluster distance from one pixel to sqrt(min_area_pixels) * px
(4 px at the working value) and changed the partition from part_key, class to class only.
absorbSQL's doc comment and TestAbsorbClusterFloor's assertions were updated. These were
not:

  • conf/common.yaml — "every same-class polygon within one screen pixel"
  • docs/generalization.md:142 — "within one screen pixel
    (ST_ClusterDBSCAN(geom, pixelSize, 1) OVER (PARTITION BY cover_class))"
  • the PR body's "Fix shape chosen and why" — same wording and same call signature

This matters more than ordinary doc drift: the eps is the single most consequential constant
in the change, and the commit that changed it says 1 px "ate Stockholm fragment by fragment
from z8 up". All three currently describe the version that was measured to fail.

Minor, same family: TestAbsorbClusterFloor's opening docstring says "_cl_a,
ST_ClusterDBSCAN over (partition, class) at one screen pixel" while the assertions below it
correctly check class-only and the sqrt eps.

4. The cluster is per-batch, not global — so output depends on -batch-rows

generalizeZParallel splits the work with partitionBatches, capped at batchRows
polygons, and each batch runs its own absorbSQL with its own _cover_absorb. The DBSCAN
therefore only sees one batch. "The cluster deliberately crosses the processing partition
(it does not partition by part_key)" is true within a batch and not across batches, so a
region spanning a batch boundary is still split — the same failure the comment says
per-cell clustering caused, one level up.

Stockholm survived at the -batch-rows 10000 used for verification, but that makes a
rendering outcome depend on a performance flag, which is the kind of thing that will be very
confusing the first time someone reruns with the 50 000 default and gets different tiles.
At minimum the comment and docs/generalization.md should say the cluster is
batch-scoped; better would be a note on what batch size the measured behaviour assumes.

On the hole-free check

"0 NULL cover_class rows at z3–z8" tests classification, not hole-freeness — a polygon
falling out of both halves of the small/large split disappears entirely rather than
appearing with a NULL class, so that check would not see it. The published km² tables do
cover it, and they pass: SE totals 2 562 190 (z9) → 2 561 943 (z8) → 2 570 330 (z5), PL
906 000 (z9) → 904 820 (z5), all within 0.3%. Worth citing that as the hole check
instead, since it is the one that actually constrains the failure mode.

Related: _cl_a has no COALESCE guard, where _w got one precisely because a NULL there
would drop a row out of both halves. With minpoints=1 every row should get a cluster id so
it should not be reachable, and the area conservation above says it is not happening — but
the asymmetry with _w is worth a line of comment either way.

Smaller notes

  • min_width_pixels is switched off entirely below the rollup (if minWidth > 0 && !cluster).
    "Subsumed by the cluster test" is not quite the right word: a thin sliver inside a cluster
    that clears the threshold is no longer a candidate at all, where before it was. Combined
    with #55 — which already lets width candidates keep their class across the boundary — that
    knob now does substantially less than its name suggests, and does different things above
    and below z9. Both changes are defensible; the combination deserves one sentence somewhere
    so the next person to tune it is not surprised.
  • clusterEps² == minArea exactly, so "the side of a square of the threshold area" is
    self-consistent and scales with the config as claimed. Nice.
  • SE agricultural still loses 53% between z9 and z5. The PR is upfront about this and it is
    a large improvement on 98.6%, but it is not obviously the end of #41 — worth deciding
    whether closing the issue here is right, or whether the residual deserves its own follow-up.
## Review — #56 (landcover absorption drift, issue #41) Builds clean, `go vet` and `go test` pass (the only failure is `TestDumpGlyphPNG`, which cannot write `/tmp/glyph_g.png` in this sandbox and fails identically on every branch including the docs-only one). The mechanism work is the best part of this PR. Measuring **684 residual adjacent pairs in 42 642 z9 agricultural polygons** to show that the z9 rollup has already done the touching-merge — and therefore that the issue's literal "merge adjacent same-class first" is a no-op below z9 — is the finding that turns the whole thing from a guess into a design. Reaching for distance instead, and deriving the scope from `coarsestRollupZoom` rather than a new config knob, is the right shape. The two-UPDATE materialisation with the note that the planner re-evaluates a window function referenced by a second window is a good catch and exactly the kind of thing that belongs in a comment. Four things before merge. ### 1. This branch contains PR #55 in full The body describes the #47 cross-boundary floor as "cherry-picked" into the verification build, which reads as a local convenience. It is not — the branch carries those commits: ``` 69490e7 docs: final combined-build verification numbers for the #41 cluster floor 08d94f6 test: cluster floor must not disable the #47 cross-boundary floor 79155c0 docs: absorb_boundary cross-boundary floor, mechanism and measurement (issue #47) 65d57a9 absorb_boundary: never cross the water line with a drawable polygon 633882a absorb: cluster floor refinement — cross-cell, patch-side eps, width subsumed b72fa80 absorb: cluster-before-absorbing floor below the coarsest rollup (issue #41) ``` `git diff main...` for this branch therefore includes all of #55. So merging #56 on its own would silently ship the #47 fix while issue #47 stays open and #55 becomes an empty PR — awkward given the body explicitly says "this change leaves #47 open". Either rebase onto `main` after #55 lands, or say plainly that #56 supersedes #55 and retire #55 in its favour. The incremental review surface here is `git diff origin/night/issue-47-water-coastline..`, which is the 153 lines of `absorbSQL` that are actually about #41. It also means the verification numbers cannot separate the two fixes, which the body is honest about but which is worth restating: if #55 does not merge, these tables do not hold. ### 2. Width-only config generates SQL referencing a column that is never created `cluster` is lowered to `false` at generalize.go:889 when the layer has no class columns or `min_area_pixels == 0` — but `areaTest`, `candPred` and `notCandPred` were already built from the *original* `cluster` at lines 835–850. So a width-only cover layer below the coarsest rollup emits `_cl_a` predicates while `clusterSQL` stays empty and the column is never added. Reproduced with a throwaway test on this branch: ``` references _cl_a = true ; defines _cl_a = false offending SQL: WHERE _cl_a <= 0.00 offending SQL: WHERE _cl_a > 0.00 ``` That is `column "_cl_a" does not exist` at runtime. No current config reaches it — every `cover` layer sets `min_area_pixels` — but width-only is an explicitly supported shape, and `absorbSQL`'s own comment documents it ("a width-only config leaves min_area_pixels unset… absorption is on because min_width_pixels turned it on"). Moving the `if len(classCols) == 0 || l.MinAreaPixels == 0 { cluster = false }` block above the `areaTest` computation fixes it. Worth a test case, since nothing currently exercises the path. ### 3. Three places still document the eps that was measured not to work `633882a` changed the cluster distance from one pixel to `sqrt(min_area_pixels) * px` (4 px at the working value) and changed the partition from `part_key, class` to class only. `absorbSQL`'s doc comment and `TestAbsorbClusterFloor`'s assertions were updated. These were not: - `conf/common.yaml` — "every same-class polygon within one screen pixel" - `docs/generalization.md:142` — "within **one screen pixel** (`ST_ClusterDBSCAN(geom, pixelSize, 1) OVER (PARTITION BY cover_class)`)" - the PR body's "Fix shape chosen and why" — same wording and same call signature This matters more than ordinary doc drift: the eps is the single most consequential constant in the change, and the commit that changed it says 1 px "ate Stockholm fragment by fragment from z8 up". All three currently describe the version that was measured to fail. Minor, same family: `TestAbsorbClusterFloor`'s opening docstring says "`_cl_a`, ST_ClusterDBSCAN over (partition, class) at one screen pixel" while the assertions below it correctly check class-only and the sqrt eps. ### 4. The cluster is per-batch, not global — so output depends on `-batch-rows` `generalizeZParallel` splits the work with `partitionBatches`, capped at `batchRows` polygons, and each batch runs its own `absorbSQL` with its own `_cover_absorb`. The DBSCAN therefore only sees one batch. "The cluster deliberately crosses the processing partition (it does not partition by part_key)" is true *within* a batch and not across batches, so a region spanning a batch boundary is still split — the same failure the comment says per-cell clustering caused, one level up. Stockholm survived at the `-batch-rows 10000` used for verification, but that makes a rendering outcome depend on a performance flag, which is the kind of thing that will be very confusing the first time someone reruns with the 50 000 default and gets different tiles. At minimum the comment and `docs/generalization.md` should say the cluster is batch-scoped; better would be a note on what batch size the measured behaviour assumes. ### On the hole-free check "0 NULL cover_class rows at z3–z8" tests classification, not hole-freeness — a polygon falling out of both halves of the small/large split disappears entirely rather than appearing with a NULL class, so that check would not see it. The published km² tables do cover it, and they pass: SE totals 2 562 190 (z9) → 2 561 943 (z8) → 2 570 330 (z5), PL 906 000 (z9) → 904 820 (z5), all within 0.3%. Worth citing *that* as the hole check instead, since it is the one that actually constrains the failure mode. Related: `_cl_a` has no `COALESCE` guard, where `_w` got one precisely because a NULL there would drop a row out of both halves. With `minpoints=1` every row should get a cluster id so it should not be reachable, and the area conservation above says it is not happening — but the asymmetry with `_w` is worth a line of comment either way. ### Smaller notes - `min_width_pixels` is switched off entirely below the rollup (`if minWidth > 0 && !cluster`). "Subsumed by the cluster test" is not quite the right word: a thin sliver inside a cluster that clears the threshold is no longer a candidate at all, where before it was. Combined with #55 — which already lets width candidates keep their class across the boundary — that knob now does substantially less than its name suggests, and does different things above and below z9. Both changes are defensible; the combination deserves one sentence somewhere so the next person to tune it is not surprised. - `clusterEps² == minArea` exactly, so "the side of a square of the threshold area" is self-consistent and scales with the config as claimed. Nice. - SE agricultural still loses 53% between z9 and z5. The PR is upfront about this and it is a large improvement on 98.6%, but it is not obviously the end of #41 — worth deciding whether closing the issue here is right, or whether the residual deserves its own follow-up.
art force-pushed night/issue-41-absorption-drift from 69490e7533 to 6d6c931975 2026-08-05 01:58:45 +00:00 Compare
Author
Owner

Review addressed on night/issue-41-absorption-drift — rebased onto current main (was 5 behind) and pushed as 69490e7…6d6c931 with two new commits. Point by point:

1. Supersede / branch contains PR #55 — decided: #56 supersedes #55, and the body now says so. We never merge or retire PRs ourselves, so rather than rebase onto a #55 that has not landed, the body states it plainly: this branch carries the #47 fix in full — 78dad2f (absorb_boundary cross-boundary floor), 4c3bcd4 (its mechanism docs), 8ad8517 (the combined cluster-floor-does-not-disable-#47 test); those are the post-rebase SHAs of the review's 65d57a9 / 79155c0 / 08d94f6. The maintainer may retire PR #55 in this one's favour at merge time; the #41-only review surface is git diff origin/night/issue-47-water-coastline..HEAD. One thing changed since the review: the #47 branch was itself rebased and extended — its current tip carries 50ccd57, the guard-covers-width doc note from the #55 review round. That note is now incorporated into this branch's docs/generalization.md, so retiring #55 loses nothing from its current tip.

2. The width-only _cl_a bug — fixed, with a test. The cluster = false lowering (and the classCols computation it depends on) now runs before the candidate test is built, so a width-only layer below the coarsest rollup can no longer emit _cl_a predicates while clusterSQL stays empty. Reproduced first with a new TestAbsorbClusterFloorWidthOnly on the pre-fix code: WHERE _cl_a <= 0.00 and WHERE _cl_a > 0.00 with no _cl_a defined — your repro, verbatim. It passes after the fix, and additionally asserts the width path itself is intact (ADD COLUMN _w float8, COALESCE(_w, 'Infinity'::float8) still in the candidate test). make build, go test ./... (importer + mapsrv) and go vet all green. Code-only change: the live DB is untouched — it takes effect on the next import.

3. The three stale eps sites — corrected. conf/common.yaml, docs/generalization.md:142 and the PR body's "Fix shape chosen and why" now all say the cluster is every same-class polygon within sqrt(min_area_pixels) * pixelSize (4 px at the working value 16) over the class column only — the version that was measured to work, not the 1 px / (part_key, class) version that lost Stockholm. TestAbsorbClusterFloor's opening docstring is fixed the same way; its assertions already checked class-only and the sqrt eps.

4. Cluster is batch-scoped — documented, not papered over. Agreed: generalizeZParallel caps batches at batchRows polygons and each batch runs its own absorbSQL with its own _cover_absorb, so the DBSCAN only ever sees one batch. The doc comment, docs/generalization.md and the PR body now say so explicitly, and note the measured behaviour assumes -batch-rows 10000. "Crosses the processing partition" is qualified to within a batch; a region spanning a batch boundary is still split — a known limitation, not a silent one. A rendering outcome depending on a performance flag is exactly the thing to flag; it is now flagged in all three places.

5. Smaller notes.

  • Hole-free check: agreed, corrected in the body — the constraining check is the per-zoom totals (SE 2 562 190 → 2 561 943 → 2 570 330, PL 906 000 → 904 820, all within 0.3%). The "0 NULL cover_class rows" claim is gone, with the reason stated: a polygon falling out of both halves of the small/large split disappears entirely, so the NULL count tests classification, not hole-freeness.
  • _cl_a has no COALESCE guard where _w got one: noted in a comment next to clusterSQL. With minpoints=1 every row gets a cluster id, so the sum is never NULL — and were it reachable, a NULL would drop the row out of both halves exactly like a NULL _w did. No behavioural change.
  • min_width_pixels below the rollup: reworded everywhere from "subsumed" to "switched off" — a thin sliver inside a cluster that clears the threshold is no longer a candidate at all, where before it was. Combined with the #47 floor (which also lets width candidates keep their class across the line), the knob does less below the rollup than its name suggests; it still runs above the rollup and for no-rollup layers. One sentence each in the code comment, the docs and the PR body.
  • SE agricultural −53% z9→z5: I think the residual deserves its own follow-up — 53% of a class's km² is still a large drift for an issue whose headline is "drifts every class into the dominant one", even at a quarter of the pre-fix loss, and the steps accelerate as zoom coarsens (z9→z8 −8%, z6→z5 −27%), which is not obviously the floor converging. I have not filed it — that is your call — but I would not consider the residual designed-in, and if #41 closes here I'd want the follow-up tracked. The PR's own close intent is unchanged; merge-time decision is yours.

FI cover remains un-rebuilt (noted in "Left undone" — it gets both floors on merge).

Review addressed on `night/issue-41-absorption-drift` — rebased onto current main (was 5 behind) and pushed as `69490e7…6d6c931` with two new commits. Point by point: **1. Supersede / branch contains PR #55 — decided: #56 supersedes #55, and the body now says so.** We never merge or retire PRs ourselves, so rather than rebase onto a #55 that has not landed, the body states it plainly: this branch carries the #47 fix in full — `78dad2f` (absorb_boundary cross-boundary floor), `4c3bcd4` (its mechanism docs), `8ad8517` (the combined cluster-floor-does-not-disable-#47 test); those are the post-rebase SHAs of the review's 65d57a9 / 79155c0 / 08d94f6. The maintainer may retire PR #55 in this one's favour at merge time; the #41-only review surface is `git diff origin/night/issue-47-water-coastline..HEAD`. One thing changed since the review: the #47 branch was itself rebased and extended — its current tip carries `50ccd57`, the guard-covers-width doc note from the #55 review round. That note is now incorporated into this branch's `docs/generalization.md`, so retiring #55 loses nothing from its current tip. **2. The width-only `_cl_a` bug — fixed, with a test.** The `cluster = false` lowering (and the `classCols` computation it depends on) now runs *before* the candidate test is built, so a width-only layer below the coarsest rollup can no longer emit `_cl_a` predicates while `clusterSQL` stays empty. Reproduced first with a new `TestAbsorbClusterFloorWidthOnly` on the pre-fix code: `WHERE _cl_a <= 0.00` and `WHERE _cl_a > 0.00` with no `_cl_a` defined — your repro, verbatim. It passes after the fix, and additionally asserts the width path itself is intact (`ADD COLUMN _w float8`, `COALESCE(_w, 'Infinity'::float8)` still in the candidate test). `make build`, `go test ./...` (importer + mapsrv) and `go vet` all green. Code-only change: the live DB is untouched — it takes effect on the next import. **3. The three stale eps sites — corrected.** `conf/common.yaml`, `docs/generalization.md:142` and the PR body's "Fix shape chosen and why" now all say the cluster is every same-class polygon within `sqrt(min_area_pixels) * pixelSize` (4 px at the working value 16) over the class column only — the version that was measured to work, not the 1 px / (part_key, class) version that lost Stockholm. `TestAbsorbClusterFloor`'s opening docstring is fixed the same way; its assertions already checked class-only and the sqrt eps. **4. Cluster is batch-scoped — documented, not papered over.** Agreed: `generalizeZParallel` caps batches at `batchRows` polygons and each batch runs its own `absorbSQL` with its own `_cover_absorb`, so the DBSCAN only ever sees one batch. The doc comment, `docs/generalization.md` and the PR body now say so explicitly, and note the measured behaviour assumes `-batch-rows 10000`. "Crosses the processing partition" is qualified to *within a batch*; a region spanning a batch boundary is still split — a known limitation, not a silent one. A rendering outcome depending on a performance flag is exactly the thing to flag; it is now flagged in all three places. **5. Smaller notes.** - Hole-free check: agreed, corrected in the body — the constraining check is the per-zoom totals (SE 2 562 190 → 2 561 943 → 2 570 330, PL 906 000 → 904 820, all within 0.3%). The "0 NULL cover_class rows" claim is gone, with the reason stated: a polygon falling out of both halves of the small/large split disappears entirely, so the NULL count tests classification, not hole-freeness. - `_cl_a` has no COALESCE guard where `_w` got one: noted in a comment next to `clusterSQL`. With `minpoints=1` every row gets a cluster id, so the sum is never NULL — and were it reachable, a NULL would drop the row out of both halves exactly like a NULL `_w` did. No behavioural change. - `min_width_pixels` below the rollup: reworded everywhere from "subsumed" to "switched off" — a thin sliver inside a cluster that clears the threshold is no longer a candidate at all, where before it was. Combined with the #47 floor (which also lets width candidates keep their class across the line), the knob does less below the rollup than its name suggests; it still runs above the rollup and for no-rollup layers. One sentence each in the code comment, the docs and the PR body. - SE agricultural −53% z9→z5: I think the residual deserves its own follow-up — 53% of a class's km² is still a large drift for an issue whose headline is "drifts every class into the dominant one", even at a quarter of the pre-fix loss, and the steps accelerate as zoom coarsens (z9→z8 −8%, z6→z5 −27%), which is not obviously the floor converging. I have not filed it — that is your call — but I would not consider the residual designed-in, and if #41 closes here I'd want the follow-up tracked. The PR's own close intent is unchanged; merge-time decision is yours. FI cover remains un-rebuilt (noted in "Left undone" — it gets both floors on merge).
art merged commit f2b41a48bb into main 2026-08-05 07:20:28 +00:00
art deleted branch night/issue-41-absorption-drift 2026-08-05 07:20:28 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
art/ismap!56
No description provided.