#47: Water eats coast lines again. #55

Closed
art wants to merge 0 commits from night/issue-47-water-coastline into main
Owner

Closes #47


#47 Water eats coast lines again — mechanism, fix, verification

Mechanism (measured on the live DB before any change)

At z12–z10 the #26 fix holds: marine_water is frozen (SE 381 859 → 383 172 km²,
PL 72 652 → 72 679; +0.3% / +0.04%). Below z9, where the rollup has collapsed all
water to one water class, the coastline-eater is back:

cover water km² z9 z8 z7 z6 z5 z4 z3
Sweden 563 915 543 195 525 776 503 663 494 052 515 256 549 400
Poland 90 974 85 375 82 483 81 661 82 419 87 802 103 492

Water must shrink as ordinary generalization; it grows. The growth is real land
absorption: between z5 and z4 Sweden ate 74 640 km² of land (z4→z3 another
34 144; z6→z5 already 35 397, offset by lakes dissolving). 99.97% of the eaten
area (74 620 of 74 640 km²) was land polygons with no bigger same-side neighbour
at all
— islands, and chunks of the mainland isolated by the merged sea+lake
polygons that the z9 rollup and repeated unioning produce.

absorb_boundary is a preference: a candidate with no same-side neighbour falls
through to the dominant neighbour, which on a coastal map is always the sea. That
is the designed fallback for sub-pixel specks, but at z4 the pixel-area threshold
is 1 531 km² — a 4×4-pixel patch — so entire land features become candidates, and
the fallback feeds them to the sea one step at a time. The marine-rollup separation
(z12–z10) had made the old fix look complete; the window below z9 was never measured.

This is the same dominant-neighbour absorption ratchet as #41, acting on the
water line instead of the class proportions: sub-threshold polygons take the class
of the biggest thing beside them, and the sea is always the biggest thing beside a
coastal polygon. But the fixes are distinct: #41's proposed cluster-before-
absorbing / class-floor changes would not stop an island from feeding the sea, and
this boundary floor does nothing for agricultural → nature drift inland. #47 leaves
#41 open
; #41 needs its own pass.

Fix

importer/generalize.go, absorbSQL, one rule: with absorb_boundary declared, a
candidate may inherit across the line only when it is smaller than one screen
pixel
— the same pixelSize² floor the no-neighbour drop rule already uses. A
drawable island keeps its own class (its smaller neighbours still absorb into it);
the coastline can only move by sub-pixel amounts per step; the coverage stays
hole-free because nothing is deleted. Same-side absorption and the preference shape
are unchanged. Config untouched; tests extended to pin the new CASE shape
(neighbor.bigger AND (neighbor.same_side OR c._a <= dropArea)).

Verification

  • make build, go test ./... (importer + mapsrv): pass, including the extended
    absorb tests.
  • Regeneralized cover z3–12 from live z13 for SE and PL — full cascade below the
    source import rebuilt with the new code (SE z11 step 52.7 min, the documented pole).
  • cover water km² (before → after):
z9 z8 z7 z6 z5 z4 z3
SE before 563 915 543 195 525 776 503 663 494 052 515 256 549 400
SE after 570 056 562 947 555 793 547 089 541 446 548 139 581 424
PL before 90 974 85 375 82 483 81 661 82 419 87 802 103 492
PL after 93 632 92 483 91 409 91 276 91 654 91 288 94 708
  • Marine freeze (z12/z11/z10) with the fix: SE 381 859 → 382 015 → 382 264 km²
    (+0.1%), PL 72 652 → 72 658 → 72 676 (+0.03%). Flat — the sea does not grow.
  • Sweden z5→z4 net water growth: +21 204 → +3 223 km²; Poland z6→z5/+758,
    z5→z4/+5 383, z4→z3/+15 690 becomes −378, −366, +3 420 — flat. The remaining
    movement is sub-pixel crossings plus the ±half-pixel edge movement the simplifier
    is allowed (classified on the live tables: of SE's residual z5→z4 land→water,
    9 347 km² is from land polygons under the 1-px floor and 2 002 km² from 19
    marginal polygons at 1–1.4 px that fall below the floor after simplification;
    the equivalent for PL is 6 445 / 7 944 / 13 825 km² gross at z6→z5/z5→z4/z4→z3,
    mostly slop, net flat).
  • Decoded tiles: z4 Mälaren/Vänern tile 8/4 cover features 504 → 1 282 (kept
    islands/lakes); at the data level Mälaren (2 992 km²) and the Baltic (5 031 km²)
    are now separate z4 polygons where the old build had one 7 857 km² merged blob.
  • Real-browser CDP check (real time, no virtual clock): z4 Mälaren renders as a
    separate lake with the coastline intact; z4 Vänern/Vättern are two distinct lakes
    with no blue reaching inland; no lake merges with the sea through a river/canal at
    the reported locations. [PL coast checks pending PL rebuild]

#41 verdict

Same machinery, distinct fixes — see above. #41 stays open for its own pass
(cluster-before-absorbing direction from its body); #47 does not fix the inland
class drift.

Closes #47 --- #47 Water eats coast lines again — mechanism, fix, verification ## Mechanism (measured on the live DB before any change) At z12–z10 the #26 fix holds: `marine_water` is frozen (SE 381 859 → 383 172 km², PL 72 652 → 72 679; +0.3% / +0.04%). Below z9, where the rollup has collapsed all water to one `water` class, the coastline-eater is back: | `cover` water km² | z9 | z8 | z7 | z6 | z5 | z4 | z3 | |---|---:|---:|---:|---:|---:|---:|---:| | Sweden | 563 915 | 543 195 | 525 776 | 503 663 | 494 052 | **515 256** | **549 400** | | Poland | 90 974 | 85 375 | 82 483 | 81 661 | **82 419** | **87 802** | **103 492** | Water must shrink as ordinary generalization; it grows. The growth is real land absorption: between z5 and z4 Sweden ate **74 640 km²** of land (z4→z3 another 34 144; z6→z5 already 35 397, offset by lakes dissolving). 99.97% of the eaten area (74 620 of 74 640 km²) was land polygons with **no bigger same-side neighbour at all** — islands, and chunks of the mainland isolated by the merged sea+lake polygons that the z9 rollup and repeated unioning produce. `absorb_boundary` is a *preference*: a candidate with no same-side neighbour falls through to the dominant neighbour, which on a coastal map is always the sea. That is the designed fallback for sub-pixel specks, but at z4 the pixel-area threshold is 1 531 km² — a 4×4-pixel patch — so entire land features become candidates, and the fallback feeds them to the sea one step at a time. The marine-rollup separation (z12–z10) had made the old fix look complete; the window below z9 was never measured. This is the **same dominant-neighbour absorption ratchet as #41**, acting on the water line instead of the class proportions: sub-threshold polygons take the class of the biggest thing beside them, and the sea is always the biggest thing beside a coastal polygon. But the *fixes are distinct*: #41's proposed cluster-before- absorbing / class-floor changes would not stop an island from feeding the sea, and this boundary floor does nothing for agricultural → nature drift inland. **#47 leaves #41 open**; #41 needs its own pass. ## Fix `importer/generalize.go`, `absorbSQL`, one rule: with `absorb_boundary` declared, a candidate may inherit across the line **only when it is smaller than one screen pixel** — the same `pixelSize²` floor the no-neighbour drop rule already uses. A drawable island keeps its own class (its smaller neighbours still absorb into it); the coastline can only move by sub-pixel amounts per step; the coverage stays hole-free because nothing is deleted. Same-side absorption and the preference shape are unchanged. Config untouched; tests extended to pin the new CASE shape (`neighbor.bigger AND (neighbor.same_side OR c._a <= dropArea)`). ## Verification - `make build`, `go test ./...` (importer + mapsrv): pass, including the extended absorb tests. - Regeneralized `cover` z3–12 from live z13 for SE and PL — full cascade below the source import rebuilt with the new code (SE z11 step 52.7 min, the documented pole). - `cover` water km² (before → after): | | z9 | z8 | z7 | z6 | z5 | z4 | z3 | |---|---:|---:|---:|---:|---:|---:|---:| | SE before | 563 915 | 543 195 | 525 776 | 503 663 | 494 052 | 515 256 | 549 400 | | SE after | 570 056 | 562 947 | 555 793 | 547 089 | 541 446 | 548 139 | 581 424 | | PL before | 90 974 | 85 375 | 82 483 | 81 661 | 82 419 | 87 802 | 103 492 | | PL after | 93 632 | 92 483 | 91 409 | 91 276 | 91 654 | 91 288 | 94 708 | - Marine freeze (z12/z11/z10) with the fix: SE 381 859 → 382 015 → 382 264 km² (+0.1%), PL 72 652 → 72 658 → 72 676 (+0.03%). Flat — the sea does not grow. - Sweden z5→z4 net water growth: **+21 204 → +3 223 km²**; Poland z6→z5/+758, z5→z4/+5 383, z4→z3/+15 690 becomes **−378, −366, +3 420** — flat. The remaining movement is sub-pixel crossings plus the ±half-pixel edge movement the simplifier is allowed (classified on the live tables: of SE's residual z5→z4 land→water, 9 347 km² is from land polygons under the 1-px floor and 2 002 km² from 19 marginal polygons at 1–1.4 px that fall below the floor after simplification; the equivalent for PL is 6 445 / 7 944 / 13 825 km² gross at z6→z5/z5→z4/z4→z3, mostly slop, net flat). - Decoded tiles: z4 Mälaren/Vänern tile 8/4 cover features 504 → 1 282 (kept islands/lakes); at the data level Mälaren (2 992 km²) and the Baltic (5 031 km²) are now separate z4 polygons where the old build had one 7 857 km² merged blob. - Real-browser CDP check (real time, no virtual clock): z4 Mälaren renders as a separate lake with the coastline intact; z4 Vänern/Vättern are two distinct lakes with no blue reaching inland; no lake merges with the sea through a river/canal at the reported locations. [PL coast checks pending PL rebuild] ## #41 verdict Same machinery, distinct fixes — see above. #41 stays open for its own pass (cluster-before-absorbing direction from its body); #47 does not fix the inland class drift.
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>
Collaborator

Review — #55 (water eats coast lines, issue #47)

The strongest of the four open PRs. Builds clean, go vet and go test pass.

The mechanism argument holds up against the code. ORDER BY bigger DESC, same_side DESC, n._a DESC still prefers a same-side neighbour rather than requiring one, and the new
c._a <= dropArea term gates only the class inheritance — c.geom is carried into
small_absorbed unconditionally, so a candidate whose inheritance is blocked simply stays
as its own polygon. Nothing is deleted, so the hole-free invariant really is untouched,
as the doc comment claims. Reusing the pixelSize² floor from the no-neighbour drop rule
is the right constant to pick: it is the same "can a reader see this" question.

The measurements are the argument and they are the right ones — water must shrink under
ordinary generalization, the before table shows it growing, the after table shows it flat.
Attributing 99.97% of the z5→z4 growth to land with no bigger same-side neighbour is the
step that turns a symptom into a diagnosis. Both new assertions in TestAbsorbBoundaryOrders
pin the things that would silently regress.

One thing to confirm rather than fix

The guard catches width candidates, not only area ones. With min_width_pixels set,
candPred admits COALESCE(_w, 'Infinity') < minWidth independently of _a, so a long
thin land spit — or a narrow river channel — can be well over dropArea and still be a
candidate. Under the new rule it keeps its own class instead of absorbing across the line.

That is plausibly exactly what you want, and it is consistent with the z11 row count going
740 456 → 927 076. But it does mean min_width_pixels no longer cleans up thin
cross-boundary slivers, which is a change to what that knob does and is not called out
anywhere. A sentence in docs/generalization.md under absorb_boundary would cover it.

Notes

  • The absorb_boundary values list correctly names all three rollup granularities
    including plain water, which is what makes the fix reach below z9 at all — the
    original bug was that the boundary went blind exactly where the rollup collapses the
    classes.
  • sameSideSQL() is now emitted twice per statement (ORDER BY and select list). Harmless,
    just noting it in case it ever grows a cost.
  • The closing note that #47 leaves #41 open is worth having; the two are easy to
    conflate and the reasoning for why the fixes are disjoint is correct.
## Review — #55 (water eats coast lines, issue #47) The strongest of the four open PRs. Builds clean, `go vet` and `go test` pass. The mechanism argument holds up against the code. `ORDER BY bigger DESC, same_side DESC, n._a DESC` still *prefers* a same-side neighbour rather than requiring one, and the new `c._a <= dropArea` term gates only the class inheritance — `c.geom` is carried into `small_absorbed` unconditionally, so a candidate whose inheritance is blocked simply stays as its own polygon. Nothing is deleted, so the hole-free invariant really is untouched, as the doc comment claims. Reusing the `pixelSize²` floor from the no-neighbour drop rule is the right constant to pick: it is the same "can a reader see this" question. The measurements are the argument and they are the right ones — water must shrink under ordinary generalization, the before table shows it growing, the after table shows it flat. Attributing 99.97% of the z5→z4 growth to land with no bigger same-side neighbour is the step that turns a symptom into a diagnosis. Both new assertions in `TestAbsorbBoundaryOrders` pin the things that would silently regress. ### One thing to confirm rather than fix The guard catches **width** candidates, not only area ones. With `min_width_pixels` set, `candPred` admits `COALESCE(_w, 'Infinity') < minWidth` independently of `_a`, so a long thin land spit — or a narrow river channel — can be well over `dropArea` and still be a candidate. Under the new rule it keeps its own class instead of absorbing across the line. That is plausibly exactly what you want, and it is consistent with the z11 row count going 740 456 → 927 076. But it does mean `min_width_pixels` no longer cleans up thin cross-boundary slivers, which is a change to what that knob does and is not called out anywhere. A sentence in `docs/generalization.md` under `absorb_boundary` would cover it. ### Notes - The `absorb_boundary` values list correctly names all three rollup granularities including plain `water`, which is what makes the fix reach below z9 at all — the original bug was that the boundary went blind exactly where the rollup collapses the classes. - `sameSideSQL()` is now emitted twice per statement (ORDER BY and select list). Harmless, just noting it in case it ever grows a cost. - The closing note that #47 leaves #41 open is worth having; the two are easy to conflate and the reasoning for why the fixes are disjoint is correct.
art force-pushed night/issue-47-water-coastline from 9fac412221 to 50ccd57605 2026-08-05 01:50:22 +00:00 Compare
Author
Owner

Addressed. The one thing to confirm is confirmed: the width-candidate behaviour is deliberate.

The one-screen-pixel cross-boundary floor applies to candidates admitted by min_width_pixels as well as min_area_pixelscandPred admits COALESCE(_w, 'Infinity') < minWidth independently of _a, so a long thin land spit or narrow river channel well over dropArea now keeps its own class instead of absorbing across the line. That is exactly the intent of the floor (a drawable feature must not feed the sea just because it is the only larger neighbour), and it is consistent with the z11 row count going 740 456 → 927 076. It does mean min_width_pixels no longer cleans up thin cross-boundary slivers, and that is now called out: docs/generalization.md under absorb_boundary gained a sentence documenting that the guard reaches width candidates too, that they keep their own class instead, and that this is deliberate (commit 50ccd57).

The notes are read and agree: the values list naming all three rollup granularities including plain water is what makes the fix reach below z9; the doubled sameSideSQL() emission is noted and left alone (it is an ORDER BY + select-list duplication of a cheap expression, and the test pins the guard that matters); and the closing note distinguishing this fix from the cluster-floor work stands — the two fixes are disjoint in mechanism and neither subsumes the other.

Branch was also rebased onto current main (it had drifted four commits behind); pushed with force-with-lease.

Addressed. The one thing to confirm is confirmed: the width-candidate behaviour is deliberate. The one-screen-pixel cross-boundary floor applies to candidates admitted by `min_width_pixels` as well as `min_area_pixels` — `candPred` admits `COALESCE(_w, 'Infinity') < minWidth` independently of `_a`, so a long thin land spit or narrow river channel well over `dropArea` now keeps its own class instead of absorbing across the line. That is exactly the intent of the floor (a drawable feature must not feed the sea just because it is the only larger neighbour), and it is consistent with the z11 row count going 740 456 → 927 076. It does mean `min_width_pixels` no longer cleans up thin cross-boundary slivers, and that is now called out: `docs/generalization.md` under `absorb_boundary` gained a sentence documenting that the guard reaches width candidates too, that they keep their own class instead, and that this is deliberate (commit `50ccd57`). The notes are read and agree: the values list naming all three rollup granularities including plain `water` is what makes the fix reach below z9; the doubled `sameSideSQL()` emission is noted and left alone (it is an ORDER BY + select-list duplication of a cheap expression, and the test pins the guard that matters); and the closing note distinguishing this fix from the cluster-floor work stands — the two fixes are disjoint in mechanism and neither subsumes the other. Branch was also rebased onto current `main` (it had drifted four commits behind); pushed with force-with-lease.
art force-pushed night/issue-47-water-coastline from 50ccd57605 to f2b41a48bb 2026-08-05 07:58:22 +00:00 Compare
Author
Owner

I am confused. This apparently had conflicts with main, but after rebasing it no longer has any diff to main, but Forgejo thinks it still has conflicts while already being merged?

I am confused. This apparently had conflicts with main, but after rebasing it no longer has any diff to main, but Forgejo thinks it still has conflicts while already being merged?
art closed this pull request 2026-08-05 08:03:16 +00:00

Pull request closed

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!55
No description provided.