Compare commits

...
7 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Fable 5 1c21863b72 style(simulation): cargo fmt — review-round test code wraps
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:04:36 +02:00
jpmschweitzerandClaude Fable 5 f9ab3088ab fix(ui): mouth-ring radius floored at 2x stroke — the radius-smaller-than-stroke draw_arc regime
Two separable findings from the live A/B (real driver): (1) the
original zero-ring report was a viewport-framing crop — the ring
fired all along at screen (1755,-191), above the frame under the
COVER fit; recentering via set_view proved the path live. (2) The
real bug once in-frame: at Quarter fit zoom the compensated ring
radius (5.0/7.5=0.667 canvas) fell below the floored stroke (1.0
canvas) and draw_arc's stroke filled its own hole — a solid blob,
not a ring. Bracket: 1x stroke=blob, 1.5x=hollow recovers, 2x=clean.
Fix: zoom_compensated_ring_radius() floors the radius at 2x the
paired stroke, wired through _zs_ring_radius() for both ring and
halo arcs; verified live producing a clean hollow double-ring. The
third member of the Godot sub-canvas-unit rasterizer family (width
floor, stroke-vs-width sites, now radius-vs-stroke) — all recorded
for the T-1176 render-mechanism discussion. 10 pin tests in the
stroke-width suite; revert-verified (floor drop -> named failures);
full client suite 3956/3956; gdlint clean.

Tickets: T-1170

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:47:33 +02:00
jpmschweitzerandClaude Fable 5 c804f2c239 test(simulation): walk/paint agreement invariant pinned on the live Quarter mouth window (not-a-bug determination)
The apparent course-over-drawn-water contradiction resolved to two
reconciliation errors, neither in production: (1) the probe's first
reconstruction bypassed the request layer's wire clamp (Quarter n=32
serves as n=16 — build_district_window_layer trusts n verbatim by
doc); (2) the lead's capture read overestimated — the window frames
only the estuarine tail of a 213-station course (178 land stations
out of frame upstream; the visible water run is genuine Lake/
OpenOcean paint, RGB-verified against MORPHOLOGY_RGB_OPAQUE x
elevation-lightness) and the terminus sits ~240m from the painted
land->water crossing, not tens of km. Walk and paint agree at every
instrumented position (zero flip-flops across the monotonic 178-land/
35-water station sequence). New permanent test reproduces the exact
live window (real GJ380c + systems.db params, district (13195,-2383),
Quarter, clamped n=16; asserts 44 pts + Mouth as identity) and pins
the terminus-lands-in-painted-water invariant mechanism-agnostically;
revert-verified with an injected 500km positional slip. Probe
deleted. cargo test --lib 1878/1878.

Tickets: T-1170

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:38:08 +02:00
jpmschweitzerandClaude Fable 5 270dba0147 test(simulation): upstream-cut Mouth-terminus regression pin (PR #197 acceptance follow-through)
The live acceptance question (Mouth flag apparently not surviving
crop) resolved to hypothesis (b): the terminus genuinely lay outside
both test windows — the Lendel mouth chord is ~108.5km, not the ~38km
estimated, so a District n=32 window centered on the cell OR the
midpoint misses the bisected waterline; a seaward-cell-centered
window provably ships Mouth. Hypothesis (a) — upstream-only crops
collapsing the flag — was FALSIFIED directly with a constructed
window (upstream anchor cut, true terminus in range -> Mouth ships;
crop reads only last_in). This test converts that probe into a
permanent pin with construction-sanity asserts, closing the blind
spot where the existing GJ1c acceptance test's bbox-derived window
always contains the whole course. Revert-verified: reintroducing a
first_in==0 requirement fails this test by name while the whole-
course test blindly passes — exactly the gap. Probe file deleted.
cargo test --lib 1877/1877.

Tickets: T-1170

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:22:27 +02:00
jpmschweitzerandClaude Fable 5 d256233faf fix(simulation): PR #197 review round — real seaward chord for Mouth edges (Hoshe #1/#2, Tyre 1/2)
The batch's real bug: extract_river_network computed the seaward
neighbor (nr,nc) to decide the MOUTH sentinel then discarded it, and
build_edges placeholder-pointed mouth edges at themselves — zero
chord, invent_course's degenerate 1-point return, resolve_mouth_
terminus dead code on real data, ALL real mouths resolving None, and
(because Ruling 3g retired the D/Q clip on the promise of real
termini) mouths vanishing at District/Quarter. The second instance of
the threw-away-the-answer anti-pattern Ruling 2b fixed for interior
pointers. Fix: river_seaward: Vec<(u16,u16)> on RiverNetwork
(additive, serde-default, parallel array; meaningful only at MOUTH
entries), captured in the same extraction pass; build_edges gives
Mouth edges the real one-D8-step chord. Permanent acceptance:
all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none — 3/3, revert-
verified failing at the golden's first mouth (38,47). Mouth golden
coverage added (river_course_golden gains district_mouth/quarter_mouth
samples + non-degeneracy test; the previously Interior-only filter
gap closed). Tyre 1: the land-probe's dead dx/len*len arithmetic
replaced — station_spacing_m threaded through the crop path, probe
steps one real cell spacing, comment reconciled. Tyre 2: boxing
comment reattributed to variant-size balancing (Layer1Output retention
lives on TerrainAnalysisCache, not BodyWorldState). Hoshe #4:
cascade_golden's doc now states the attractor cascade accurately
(count-parity, not byte-identity — water_dist seeds from mouths).
Full cargo test green; goldens re-pinned deliberately; bench +3.0%.

Tickets: T-1170

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:02:16 +02:00
jpmschweitzerandClaude Fable 5 581be31549 test(ui): spy tests for the B3 arrival-redraw fix (PR #197 Hoshe #3)
Two _CountingNatureOverlay draw-spy tests (the PR #196 pattern):
window arrival and rung-swap arrival both advance the nature
overlay's draw count past baseline. Two isolation bugs caught before
reporting: (1) _on_window_ready has a second pre-existing redraw path
via _fit_and_center -> _apply_transform that fires on first arrivals
and masks a sabotaged line 507 — isolated by setting _user_adjusted
(the real pan/zoom guard, a reachable state); (2) simulating the swap
via _enter_at_rung resets _awaiting_first_window and takes the same
masked branch — the real production trigger is _maybe_reselect_rung's
request path, so the test drives request_now directly. Revert-
verified: dropping the queue_redraw line fails exactly both tests by
name; 82/82 restored. Viewer file itself zero net diff.

Tickets: T-1170

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:54:10 +02:00
jpmschweitzerandClaude Fable 5 b9afa19d65 docs(meta): PR #197 review captures — stroke-floor width-inertness + unreachable-pole-branch notes; bench reflow
Tyre's two governance sentences on the D-226 course note: (a) the
Godot rasterizer floor makes per-class course WIDTH differentiation
inert at every shipping fit zoom — classes distinguish by opacity
alone until T-1175's per-vertex tapering; the 'trunk widest' promise
is design intent, not current pixels. (b) The pole-row edge-drain
branch is structurally unreachable; the real mechanism is interior
k<0 (revert-verification discovery). Plus the dangling cargo-fmt
reflow in the course-cost bench from the gate-bounce round.

Tickets: T-1170

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:46:27 +02:00
16 changed files with 2987 additions and 61 deletions
@@ -140,3 +140,113 @@ func test_zoom_compensated_stroke_width_zero_zoom_does_not_blow_up() -> void:
assert_bool(is_finite(result)).override_failure_message(
"a degenerate zero view_zoom must not produce inf/NaN"
).is_true()
# =============================================================================
# T-1170 live round (2026-07-23, coordinator's mouth-ring finding):
# zoom_compensated_ring_radius() — the RADIUS-SMALLER-THAN-STROKE regime.
# Same A/B-bracket discipline as the stroke-width suite above, applied to
# draw_arc() RING markers (mouth rings), whose radius AND stroke are both
# small zoom-compensated values that can cross each other.
##
## Live A/B evidence (temporary instrumentation, since reverted): at the
## PRODUCTION Quarter-rung radius/stroke pair (radius=0.667, stroke=1.0
## canvas-local units, Quarter fit zoom 7.5), draw_arc() rendered a SOLID
## BLOB, not a hollow ring — confirmed via a re-centered live capture (the
## ORIGINAL "zero ring pixels" symptom was a separate viewport-framing crop,
## not this bug — see zoom_compensated_ring_radius()'s own doc). Bracket
## (stroke fixed at 1.0 canvas-local, radius varied): 0.51 (~stroke/2) ->
## blob; 1.0 (=stroke, the production case) -> blob; 1.5 (1.5x stroke) ->
## hollow ring recovers; 2.0 (2x stroke) -> hollow ring, cleaner. Floor set
## at 2x with margin over the observed 1.0x-blob/1.5x-hollow transition.
# =============================================================================
## At zoom=1.0 with a radius comfortably above stroke*2 already, the floor
## must be a no-op — identical to zoom_compensated_size() directly.
func test_zoom_compensated_ring_radius_no_op_when_radius_already_clears_the_floor() -> void:
var radius: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(5.0, 1.5, 1.0)
assert_float(radius).is_equal_approx(5.0, 0.0001)
## The EXACT regression shape this round closes: at Quarter's real fit zoom
## (7.5, live capture), MOUTH_RING_RADIUS (5.0) divides to 0.667
## canvas-local — BELOW its own paired stroke (1.5/7.5 floored to 1.0 via
## zoom_compensated_stroke_width) — pinned directly, not just asserted.
## zoom_compensated_ring_radius() must instead return stroke * 2.0 (the
## floor), never the sub-floor raw division result that produced the blob.
func test_zoom_compensated_ring_radius_quarter_mouth_ring_hits_the_floor() -> void:
var view_zoom := 7.5 # LENDEL's live Quarter fit zoom, capture-confirmed
var raw_radius := 5.0 # MOUTH_RING_RADIUS
var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(1.5, view_zoom)
var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_radius, view_zoom)
assert_float(unfloored).override_failure_message(
"regression pin: the OLD unfloored radius must be AT/BELOW the paired"
+ " stroke at this zoom — this is the exact numeric shape of the blob bug"
).is_less_equal(stroke)
var floored: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(
raw_radius, stroke, view_zoom
)
assert_float(floored).override_failure_message(
"zoom_compensated_ring_radius() must clamp to stroke * 2.0 (the floor),"
+ " not the sub-floor unfloored value that renders as a solid blob"
).is_equal_approx(stroke * AtlasWindowGeometryNature.RING_RADIUS_STROKE_MULTIPLIER, 0.0001)
## Regression pin (the stroke-width suite's own "effective on-screen value"
## shape, applied to the radius/stroke RATIO instead of an absolute value):
## for the mouth ring AND halo (the two draw_arc() ring markers in this
## cluster), at Quarter's real fit zoom, the floored radius must be AT LEAST
## RING_RADIUS_STROKE_MULTIPLIER times its own paired stroke — the actual
## geometric property that keeps the ring hollow, verified directly rather
## than just re-checking the numeric floor value in isolation.
func test_ring_radius_stays_at_least_the_multiplier_above_its_stroke_at_quarter_zoom() -> void:
var view_zoom := 7.5
# (raw_radius_px, raw_stroke_px) pairs — the mouth ring and halo's own
# literal call-site arguments in _draw_mouth().
for pair in [[5.0, 1.5], [8.0, 1.0]]:
var raw_radius: float = pair[0]
var raw_stroke: float = pair[1]
var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(
raw_stroke, view_zoom
)
var radius: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(
raw_radius, stroke, view_zoom
)
assert_float(radius).override_failure_message(
(
"radius %.3f must be at least %.1fx its paired stroke %.3f — a ratio"
+ " below this rendered as a SOLID BLOB in the live A/B bracket,"
+ " never a hollow ring"
)
% [radius, AtlasWindowGeometryNature.RING_RADIUS_STROKE_MULTIPLIER, stroke]
).is_greater_equal(stroke * AtlasWindowGeometryNature.RING_RADIUS_STROKE_MULTIPLIER - 0.0001)
## At a LOW zoom (e.g. Region's tiny orbital fit, or any zoom where the
## naive radius is already well clear of the floor), the floor must NOT
## engage — matching zoom_compensated_stroke_width()'s own
## does-not-engage-at-low-zoom guarantee. Pins that this is a one-direction
## safety net, not a blanket override.
func test_zoom_compensated_ring_radius_does_not_engage_at_low_zoom() -> void:
var view_zoom := 0.0063 # Lendel's real orbital fit zoom
var raw_radius := 5.0 # MOUTH_RING_RADIUS
var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(1.5, view_zoom)
var floored: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(
raw_radius, stroke, view_zoom
)
var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_radius, view_zoom)
assert_float(floored).override_failure_message(
"at a zoom where the naive radius is already far above the floor, the"
+ " floor must be a no-op — identical to zoom_compensated_size()"
).is_equal_approx(unfloored, 0.0001)
## A degenerate zero (or negative) view_zoom must not divide-by-zero/produce
## infinity/NaN — same total-function guarantee as the stroke-width sibling.
func test_zoom_compensated_ring_radius_zero_zoom_does_not_blow_up() -> void:
var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(1.5, 0.0)
var result: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(5.0, stroke, 0.0)
assert_bool(is_finite(result)).override_failure_message(
"a degenerate zero view_zoom must not produce inf/NaN"
).is_true()
+140
View File
@@ -233,6 +233,146 @@ func test_set_overlay_visible_gen_basins_flips_gate_and_redraws_nature_overlay()
).is_greater(baseline)
## T-1170 B3 (PR #197 review, Hoshe #3): _on_window_ready() now also calls
## _nature_overlay.queue_redraw() (atlas_window_viewer.gd:507) — courses ride
## `DistrictWindowLayer.courses`, the SAME `_window` this handler adopts, so
## a window arrival that never redraws the nature overlay would leave freshly
## arrived courses invisible until an UNRELATED pan/zoom gesture happened to
## redraw it. Same spy-and-baseline shape as
## test_set_overlay_visible_gen_basins_flips_gate_and_redraws_nature_overlay()
## above — this is the exact pattern PR #196 established for "prove a
## specific queue_redraw() call site actually fires", applied to the
## window-arrival call site instead of the toggle call site.
##
## **Isolation note (live finding while writing this test):** `_on_window_ready()`
## ALSO calls `_fit_and_center()` on the first-ever arrival
## (`_awaiting_first_window and not _user_adjusted`), and `_fit_and_center()`
## itself already ends in `_apply_transform()`, which redraws the nature
## overlay through a SEPARATE, pre-existing call site. That path would mask
## a broken/removed line 507 (both call sites fire on a fresh entry's first
## arrival, so removing just one wouldn't drop draw_count below baseline).
## Setting `_user_adjusted = true` before the response arrives — the SAME
## guard a real pan/zoom gesture sets (`_maybe_refloat_window()`/`_zoom_at()`)
## — skips the fit-and-center branch, so ONLY line 507 can be the source of
## any redraw the assertion below observes. This is a real, reachable state
## (any window arrival after the player's first manual pan/zoom), not a
## test-only fiction.
func test_window_arrival_redraws_nature_overlay() -> void:
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
v._user_adjusted = true # isolate line 507 from the first-arrival fit-and-center redraw
# Swap in the counting spy AFTER enter() (matching the gen_basins test's
# own "swap after construction, then let it settle" shape) so enter()'s
# own queue_redraw() calls don't pollute the baseline.
var spy := _CountingNatureOverlay.new(v)
v._nature_overlay.queue_free()
v._nature_overlay = spy
v._canvas.add_child(spy)
await get_tree().process_frame
await get_tree().process_frame
var baseline: int = spy.draw_count
assert_int(baseline).override_failure_message(
"sanity: the spy must have drawn at least once before the window"
+ " arrives, or this test can't distinguish 'redrawn BY the arrival'"
+ " from 'never drawn at all'"
).is_greater(0)
var window: Dictionary = _mock_window(Vector2i(10, 20), 2)
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
await get_tree().process_frame
assert_that(v.get_district_window()).override_failure_message(
"sanity: the response must actually have been adopted (matching echo)"
+ " or this test proves nothing about the arrival path specifically"
).is_equal(window)
assert_int(spy.draw_count).override_failure_message(
"_on_window_ready() must queue_redraw() the NATURE overlay — courses"
+ " ride the SAME _window this handler adopts, so a window arrival"
+ " that doesn't redraw the nature overlay leaves freshly arrived"
+ " courses invisible until an unrelated pan/zoom happens to redraw"
+ " it — draw_count must have advanced past the baseline (%d)" % baseline
).is_greater(baseline)
## The RUNG-SWAP arrival case (a later _on_window_ready() call for a
## DIFFERENT granularity_v2 than the one the viewer entered at — e.g. a
## wheel-zoom crossing from District into Quarter) — cheap to cover in the
## SAME test file per the review's own "if cheap" allowance. Confirms the
## redraw fires on EVERY window adoption, not just the first-ever one
## (T-1153's progressive-refinement doc is explicit that _window only ever
## gets REPLACED, never renulled, on a rung swap).
##
## **Uses `_window_request.request_now()` directly, NOT `_enter_at_rung()`**
## — a live finding while writing this test: `_enter_at_rung()` sets
## `_awaiting_first_window = true` again (it's the SAME reset path a fresh
## descent uses), which would route the swap response back through
## `_fit_and_center()`'s OWN redraw call site, masking line 507 exactly like
## the note on the test above. The REAL production rung-swap path,
## `_maybe_reselect_rung()`, never touches `_awaiting_first_window` at all —
## it only calls `_window_request.request_debounced(...)`. `request_now()`
## (the non-debounced sibling, same effect minus the timer) is called
## directly here to update `_window_request`'s own `_granularity_v2` — the
## exact field `_on_window_ready()`'s echo-matching guard reads — mirroring
## the real path's state change without needing a live debounce timer in a
## unit test.
func test_rung_swap_window_arrival_redraws_nature_overlay() -> void:
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
# First arrival (District, matches enter()'s own default rung) — settles
# the viewer into a held window, exactly as a real progressive-refinement
# sequence would before a rung swap. Uses the REAL (non-spy) nature
# overlay for this leg — only the swap leg itself needs the spy.
var district_window: Dictionary = _mock_window(Vector2i(10, 20), 2)
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window))
await get_tree().process_frame
assert_that(v.get_district_window()).override_failure_message(
"sanity: the first (District) arrival must have been adopted before"
+ " simulating the swap"
).is_equal(district_window)
# Now swap in the spy and simulate the RUNG SWAP itself — update the
# request's echoed granularity_v2 to "Quarter" (what
# _maybe_reselect_rung() -> request_debounced() would do on a real
# wheel-zoom crossing) WITHOUT touching _awaiting_first_window, so the
# response below takes the "not first window" branch — the genuinely
# different code path from the test above.
var spy := _CountingNatureOverlay.new(v)
v._nature_overlay.queue_free()
v._nature_overlay = spy
v._canvas.add_child(spy)
v._window_request.request_now(
"GJ380c", Vector2i(10, 20), 2, AtlasWindowRequest.GRANULARITY_V2_QUARTER
)
await get_tree().process_frame
await get_tree().process_frame
var baseline: int = spy.draw_count
assert_int(baseline).override_failure_message(
"sanity: the spy must have drawn at least once before the rung-swap"
+ " response arrives"
).is_greater(0)
var quarter_window: Dictionary = _mock_window(Vector2i(10, 20), 2)
quarter_window["granularity_v2"] = "Quarter"
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", quarter_window))
await get_tree().process_frame
assert_that(v.get_district_window()).override_failure_message(
"sanity: the rung-swap response must actually have been adopted"
).is_equal(quarter_window)
assert_int(spy.draw_count).override_failure_message(
"a RUNG-SWAP window arrival (a later _on_window_ready() call at a"
+ " DIFFERENT granularity_v2 than entry) must ALSO redraw the nature"
+ " overlay — draw_count must have advanced past the post-first-"
+ " arrival baseline (%d)" % baseline
).is_greater(baseline)
# =============================================================================
# T-1120 capture-API parity (the ticket's explicit note: must survive here too)
# =============================================================================
@@ -263,6 +263,14 @@ const MOUTH_RING_RADIUS: float = 5.0
const MOUTH_HALO_RADIUS: float = 8.0
const MOUTH_HALO_ALPHA: float = 0.30
## T-1170 live round (2026-07-23, coordinator's mouth-ring blob finding):
## the minimum radius/stroke RATIO a draw_arc() ring needs to render hollow
## rather than degenerate into a solid blob — see
## zoom_compensated_ring_radius()'s own doc for the full A/B bracket
## evidence (radius=1x stroke -> blob, 1.5x -> hollow, floor set at 2x with
## margin over the observed transition).
const RING_RADIUS_STROKE_MULTIPLIER: float = 2.0
## Attractor minimum-strength gate — verbatim from the retired
## atlas_marker_overlay.gd GEN_ATTRACTOR_MIN_STRENGTH (:44). Region-only per
## the ruling (ATTRACTORS_VISIBLE_BY_RUNG), wave 1 has no attractor rendering
@@ -625,3 +633,53 @@ static func zoom_compensated_size(screen_space_size: float, view_zoom: float) ->
## zoom_compensated_size()'s own behavior at Region's tiny fit zoom.
static func zoom_compensated_stroke_width(screen_space_size: float, view_zoom: float) -> float:
return maxf(zoom_compensated_size(screen_space_size, view_zoom), 1.0)
## T-1170 live round (2026-07-23, coordinator's mouth-ring finding): the
## RADIUS-SMALLER-THAN-STROKE regime — a THIRD sibling to
## zoom_compensated_size()/zoom_compensated_stroke_width(), needed
## specifically for draw_arc() RING markers (mouth rings — the only
## draw_arc() caller in this file whose radius and stroke width are BOTH
## small, zoom-compensated values that can cross each other).
##
## Root-cause evidence (live A/B bracket, temporary instrumentation, since
## reverted — real running client via SR_LIVE=1, real x11/opengl3 driver):
## the ORIGINAL "zero ring pixels" report turned out to be a SEPARATE,
## already-correct-code issue — the terminus point legitimately sits near
## the requesting window's own edge, and the fit-and-center COVER strategy
## crops that edge off the visible viewport (screen_p verified computed as
## (1755, -191) against a 1920x1080 frame — above the top edge, not a
## drawing bug). Panning the view to re-center the SAME point (verified via
## AtlasWindowViewer.set_view()) proves the ring genuinely draws — but as a
## SOLID BLOB, not a hollow ring, at the production radius/stroke pair
## (radius=0.667, stroke=1.0 canvas-local units, Quarter fit zoom 7.5):
## draw_arc()'s stroke, centered ON the radius circle, extends inward past
## the circle's own center once stroke exceeds ~1x the radius, filling the
## hole. Bracket results (stroke fixed at 1.0 canvas-local, radius varied):
## radius=0.51 (~stroke/2) -> still a solid blob; radius=1.0 (=stroke) ->
## solid blob (the production case); radius=1.5 (1.5x stroke) -> hollow ring
## recovers; radius=2.0 (2x stroke) -> hollow ring, cleaner. The blob
## persists past the naive geometric threshold (radius > stroke/2, where an
## infinitely-thin/perfectly-antialiased ring would already have a hole)
## because draw_arc()'s low tessellation (18 points, this file's own call)
## plus antialiasing blur eat into the theoretical hole at these tiny
## absolute magnitudes — an empirical floor, not a derived one, chosen with
## margin over the observed 1.0x-blob/1.5x-hollow transition rather than
## shaving the boundary exactly.
##
## The fix: floor the RADIUS at `stroke * RING_RADIUS_STROKE_MULTIPLIER`
## (2.0, the top-of-file const — verified clean in the bracket above)
## whenever the naive zoom-compensated radius would fall below it — the
## same "floor the OUTPUT, never let a sub-threshold value reach Godot's
## renderer" pattern zoom_compensated_stroke_width() already established,
## applied to the paired radius/stroke relationship a lone-value floor
## can't express (unlike the stroke-width floor, this one's threshold is
## RELATIVE to another draw-time value, not an absolute constant). At every
## zoom where the naive radius already clears the floor on its own
## (Region's dot radii, or any District/Quarter case wide enough), this is
## an exact no-op — identical to calling zoom_compensated_size() directly.
static func zoom_compensated_ring_radius(
screen_space_radius: float, stroke_width_canvas_local: float, view_zoom: float
) -> float:
var naive_radius: float = zoom_compensated_size(screen_space_radius, view_zoom)
return maxf(naive_radius, stroke_width_canvas_local * RING_RADIUS_STROKE_MULTIPLIER)
@@ -362,6 +362,24 @@ func _zs_stroke(screen_space_size: float, ctx: Dictionary) -> float:
return AtlasWindowGeometryNature.zoom_compensated_stroke_width(screen_space_size, ctx["view_zoom"])
## T-1170 live round (2026-07-23, coordinator's mouth-ring blob finding): the
## RING-RADIUS-specific sibling of _zs()/_zs_stroke() — every draw_arc() ring
## marker whose radius and stroke are BOTH small, zoom-compensated values
## (currently: the two _draw_mouth() rings) routes its RADIUS through this
## instead of plain _zs(), so the radius never falls at-or-below its own
## paired stroke width and degenerates from a hollow ring into a solid blob.
## See AtlasWindowGeometryNature.zoom_compensated_ring_radius()'s own doc for
## the full A/B bracket evidence (radius=1x stroke -> blob, 1.5x -> hollow,
## floor set at 2x with margin). `stroke_canvas_local` is the ALREADY
## zoom-compensated stroke value (this function's own caller passes
## _zs_stroke()'s result, not a raw screen-space width) — the floor compares
## against the SAME canvas-local units the naive radius divide produces.
func _zs_ring_radius(screen_space_radius: float, stroke_canvas_local: float, ctx: Dictionary) -> float:
return AtlasWindowGeometryNature.zoom_compensated_ring_radius(
screen_space_radius, stroke_canvas_local, ctx["view_zoom"]
)
## Pixel (row, col) -> fractional district position, wrap-resolved against
## the currently-HELD view's own center (ctx["held_center"]) — the
## representative wrap-image _pos()'s canvas conversion needs. T-1172: this
@@ -616,14 +634,15 @@ func _segment_touches_drawn_water(from_district: Vector2, to_district: Vector2,
## also the marker for REAL course termini (Ruling 5b/3e) — one geometry
## function, both presentation surfaces.
func _draw_mouth(p: Vector2, ctx: Dictionary) -> void:
var ring_stroke: float = _zs_stroke(1.5, ctx)
draw_arc(
p,
_zs(AtlasWindowGeometryNature.MOUTH_RING_RADIUS, ctx),
_zs_ring_radius(AtlasWindowGeometryNature.MOUTH_RING_RADIUS, ring_stroke, ctx),
0.0,
TAU,
18,
COLOR_GEN_MOUTH,
_zs_stroke(1.5, ctx)
ring_stroke
)
var halo := Color(
COLOR_GEN_MOUTH.r,
@@ -631,8 +650,15 @@ func _draw_mouth(p: Vector2, ctx: Dictionary) -> void:
COLOR_GEN_MOUTH.b,
AtlasWindowGeometryNature.MOUTH_HALO_ALPHA
)
var halo_stroke: float = _zs_stroke(1.0, ctx)
draw_arc(
p, _zs(AtlasWindowGeometryNature.MOUTH_HALO_RADIUS, ctx), 0.0, TAU, 22, halo, _zs_stroke(1.0, ctx)
p,
_zs_ring_radius(AtlasWindowGeometryNature.MOUTH_HALO_RADIUS, halo_stroke, ctx),
0.0,
TAU,
22,
halo,
halo_stroke
)
File diff suppressed because one or more lines are too long
+30
View File
@@ -89,6 +89,36 @@ pub struct RiverNetwork {
/// array (pre-T-1170 payload/fixture) decodes to empty, never an error.
#[serde(default)]
pub river_downstream: Vec<u8>,
/// Per-`river_cells`-entry seaward neighbor position (T-1170 PR #197
/// review, Hoshe #1) — same index/length shape as `river_class`/
/// `river_downstream`. **Only meaningful where `river_downstream[i] ==
/// RIVER_DOWNSTREAM_MOUTH`**; every other entry (interior direction or
/// `EDGE_DRAIN`) carries the placeholder `(0, 0)` and must not be read.
///
/// **Why this exists — the second instance of the Ruling-2b anti-pattern.**
/// `extract_river_network` already computes the real sub-sea-level
/// neighbor `(nr, nc)` to decide the `MOUTH` sentinel (the elevation
/// check that flips `river_downstream[i]` to
/// [`RIVER_DOWNSTREAM_MOUTH`]) — the exact same "true answer already in
/// scope, then discarded" shape Ruling 2b fixed for interior D8 pointers
/// via `river_downstream` itself. Discarding `(nr, nc)` a second time
/// left `river_course::build_edges` with nothing to build a real chord
/// from for Mouth edges: it filled `downstream = upstream` as a
/// placeholder, which zeroes the chord (`chord_m < 1.0`), which trips
/// `invent_course`'s single-point degenerate return, which makes
/// `resolve_mouth_terminus`'s station walk a no-op (a 1-point course
/// never enters the `pts.len() >= 2` probe branch either) — every Mouth
/// edge silently resolved `CourseTerminus::None` instead of `Mouth`.
/// Capturing `(nr, nc)` here (one more push per mouth cell — mouths are
/// a small fraction of river cells, not a new grid pass) is what makes
/// `build_edges` give Mouth edges a real ~one-cell chord toward the raw
/// sea, so the termination walk + bisect + one-segment D8-probe fallback
/// (Ruling 3e) are actually reachable.
///
/// `#[serde(default)]` — same additive pattern as `river_class`/
/// `river_downstream`.
#[serde(default)]
pub river_seaward: Vec<(u16, u16)>,
}
/// [`RiverNetwork::river_downstream`] sentinel: this river cell's D8 flow
+45 -26
View File
@@ -351,33 +351,51 @@ fn extract_river_network(
// `RIVER_THRESHOLD`, but handled the same as `EDGE_DRAIN` (no downstream
// neighbor to point at, not a sea mouth) rather than crashing the
// pointer's "always points somewhere real" contract.
//
// river_seaward (T-1170 PR #197 review, Hoshe #1): captured in the SAME
// pass, parallel to river_downstream — `(nr, nc)` is already computed
// here to decide the MOUTH sentinel; discarding it after the elevation
// check (the former code) was the second instance of the exact
// discard-then-need-it-later anti-pattern Ruling 2b fixed for
// river_downstream itself. Non-mouth entries get the `(0, 0)` placeholder
// (documented on the field as unreadable outside the MOUTH case).
let mut mouths: Vec<(u16, u16)> = Vec::new();
let river_downstream: Vec<u8> = (0..n)
.filter(|&i| is_river[i])
.map(|i| {
let r = i / w;
let c = i % w;
let k = fdir[i];
if k < 0 {
// No outflow at all — edge/flat-peak. Not a mouth (Ruling 3f).
return RIVER_DOWNSTREAM_EDGE_DRAIN;
}
let (dr, dc) = D8[k as usize];
let nr = r as i32 + dr;
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
if nr < 0 || nr >= h as i32 {
// Flow direction points off the polar edge — a grid artifact,
// not a mouth (Ruling 3f, the pole-edge-drain fix).
return RIVER_DOWNSTREAM_EDGE_DRAIN;
}
if elevation[nr as usize * w + nc] < sea_level {
// Flows into a sub-sea-level cell — a real mouth.
mouths.push((r as u16, c as u16));
return RIVER_DOWNSTREAM_MOUTH;
}
k as u8
})
.collect();
let mut river_downstream: Vec<u8> = Vec::new();
let mut river_seaward: Vec<(u16, u16)> = Vec::new();
for i in 0..n {
if !is_river[i] {
continue;
}
let r = i / w;
let c = i % w;
let k = fdir[i];
if k < 0 {
// No outflow at all — edge/flat-peak. Not a mouth (Ruling 3f).
river_downstream.push(RIVER_DOWNSTREAM_EDGE_DRAIN);
river_seaward.push((0, 0));
continue;
}
let (dr, dc) = D8[k as usize];
let nr = r as i32 + dr;
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
if nr < 0 || nr >= h as i32 {
// Flow direction points off the polar edge — a grid artifact,
// not a mouth (Ruling 3f, the pole-edge-drain fix).
river_downstream.push(RIVER_DOWNSTREAM_EDGE_DRAIN);
river_seaward.push((0, 0));
continue;
}
if elevation[nr as usize * w + nc] < sea_level {
// Flows into a sub-sea-level cell — a real mouth. Capture the
// seaward neighbor position (Hoshe #1) alongside the sentinel.
mouths.push((r as u16, c as u16));
river_downstream.push(RIVER_DOWNSTREAM_MOUTH);
river_seaward.push((nr as u16, nc as u16));
continue;
}
river_downstream.push(k as u8);
river_seaward.push((0, 0));
}
RiverNetwork {
river_cells,
@@ -385,6 +403,7 @@ fn extract_river_network(
mouths,
river_class,
river_downstream,
river_seaward,
}
}
+17 -3
View File
@@ -272,9 +272,23 @@ pub enum GenCompletion {
BodyAnalyzed {
body_id: String,
/// The computed world state, ready for `BodyWorldStateCache::insert`.
/// Boxed: `BodyWorldState` grew past clippy's large-enum-variant
/// threshold when it began retaining `Layer1Output` (T-1170 Ruling 4b)
/// — the box keeps the enum small while the state moves by pointer.
/// Boxed to keep `GenCompletion` variant sizes balanced — the same
/// discipline as `SkeletonGenerated`/`ChunkFilled`/`WindowDerived`
/// below, all boxed for the same reason (`GenCompletion`'s overall
/// size is bounded by its largest unboxed variant; a `BodyWorldState`
/// field carried by value here would force every other variant to
/// pay for its full stack size on every match/move).
///
/// **Not this variant's own recent growth** (T-1170 PR #197 review,
/// Tyre issue 2 — the prior comment here overattributed the boxing
/// rationale): `BodyWorldState` itself only grew by
/// `RiverNetwork`'s two new `Vec` fields (`river_downstream`,
/// `river_seaward` — a few bytes/river-cell, Ruling 2a / Hoshe #1).
/// The much larger `Layer1Output` retention (T-1170 Ruling 4b) lives
/// on `TerrainAnalysisCache` — a queue-scoped struct further down
/// this file, never part of `BodyWorldState`/`BodyWorldStateCache` at
/// all. The box here predates T-1170 and stays for the pre-existing
/// variant-size-balancing reason, unrelated to this ticket's growth.
state: Box<BodyWorldState>,
},
SkeletonGenerated {
+447 -4
View File
@@ -1237,6 +1237,11 @@ fn invent_courses_near_window(
/// Crop the window's already-invented courses ([`invent_courses_near_window`])
/// to the wire [`RiverCourse`] shape (Ruling 3h) — window rect + one station
/// beyond each edge, terminus resolution (A3, Ruling 3e/3f).
///
/// `station_spacing_m` is the rung's own cell spacing (`granularity.spacing_m()`
/// — District 2,048 m / Quarter 512 m) — threaded to [`resolve_mouth_terminus`]'s
/// land-at-final-anchor probe, which extends "one cell length" (Ruling 3e's own
/// words), not one Stage-B segment length (Tyre, PR #197 review issue 1).
#[allow(clippy::too_many_arguments)]
fn crop_courses_for_wire(
invented: &[InventedCourse],
@@ -1247,6 +1252,7 @@ fn crop_courses_for_wire(
ta: &crate::atlas::features::TerrainAnalysis,
climate: &crate::atlas::district_profile::ClimateConstants,
min_wavelength_m: f64,
station_spacing_m: f64,
) -> Vec<RiverCourse> {
invented
.iter()
@@ -1260,6 +1266,7 @@ fn crop_courses_for_wire(
ta,
climate,
min_wavelength_m,
station_spacing_m,
)
})
.collect()
@@ -1280,6 +1287,7 @@ fn crop_course_to_window(
ta: &crate::atlas::features::TerrainAnalysis,
climate: &crate::atlas::district_profile::ClimateConstants,
min_wavelength_m: f64,
station_spacing_m: f64,
) -> Option<RiverCourse> {
let (x0, y0, x1, y1) = window_rect;
let inside = |p: &(f64, f64)| p.0 >= x0 && p.0 <= x1 && p.1 >= y0 && p.1 <= y1;
@@ -1328,6 +1336,7 @@ fn crop_course_to_window(
ta,
climate,
min_wavelength_m,
station_spacing_m,
) {
Some(mouth_point) => {
// Replace the cropped course's tail with the resolved
@@ -1373,6 +1382,12 @@ const MOUTH_BISECT_ITERATIONS: u32 = 6;
/// probe past the final anchor) samples water, returns `None` — the
/// degenerate "drawn coast receded past this edge" case (Ruling 3e), which
/// the caller renders with no mouth flag.
///
/// `station_spacing_m` is the rung's own cell spacing — the probe extends
/// exactly "one cell length" past the final anchor (Ruling 3e's own words),
/// in the direction of the final Stage-B segment, but scaled to
/// `station_spacing_m` rather than that segment's own (possibly much
/// shorter, near-zero at a taper-to-zero anchor) length.
fn resolve_mouth_terminus(
course: &InventedCourse,
seed: SeedChain,
@@ -1381,6 +1396,7 @@ fn resolve_mouth_terminus(
ta: &crate::atlas::features::TerrainAnalysis,
climate: &crate::atlas::district_profile::ClimateConstants,
min_wavelength_m: f64,
station_spacing_m: f64,
) -> Option<(f64, f64)> {
let is_water = |p: (f64, f64)| -> bool {
// `&[]`: the mouth-termination water-verdict probe has no use for
@@ -1418,16 +1434,19 @@ fn resolve_mouth_terminus(
}
prev_land = p;
}
// Final anchor still land: extend one cell length along the segment's
// own direction as a single probe (Ruling 3e: "extend along the D8
// direction up to one cell length probing").
// Final anchor still land: extend ONE CELL LENGTH (`station_spacing_m` —
// Ruling 3e's own words, "up to one cell length probing", not one
// Stage-B segment length, which can be much shorter near a
// taper-to-zero anchor — Tyre, PR #197 review issue 1) along the final
// segment's own direction, as a single probe.
if pts.len() >= 2 {
let a = pts[pts.len() - 2];
let b = pts[pts.len() - 1];
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len = (dx * dx + dy * dy).sqrt();
if len > 1e-6 {
let probe = (b.0 + dx / len * len, b.1 + dy / len * len); // one more segment-length step
let (ux, uy) = (dx / len, dy / len); // unit direction of the final segment
let probe = (b.0 + ux * station_spacing_m, b.1 + uy * station_spacing_m);
if is_water(probe) {
return Some(bisect_to_waterline(b, probe, is_water));
}
@@ -1589,6 +1608,7 @@ pub fn build_district_window_layer(
ta,
climate,
min_wavelength_m,
step_m,
);
DistrictWindowLayer {
@@ -1686,6 +1706,7 @@ fn build_district_window_layer_serial(
ta,
climate,
min_wavelength_m,
step_m,
);
DistrictWindowLayer {
center,
@@ -2946,6 +2967,428 @@ mod tests {
}
}
/// **T-1170 PR #197 review, Hoshe #1 acceptance test (blocking, permanent
/// — not a throwaway probe).** Every real Mouth edge on the GJ1c golden
/// fixture (256×128 downsample, the SAME fixture `cascade_golden.rs`
/// pins — 3 mouths: `[(38,47), (38,98), (124,239)]`) must resolve
/// `CourseTerminus::Mouth`, not `CourseTerminus::None`.
///
/// **What this guards:** before the fix, `build_edges` set
/// `downstream = upstream` for every Mouth edge (a same-cell
/// placeholder — the SAME discard-then-need-it-later anti-pattern
/// Ruling 2b's `river_downstream` field fixed for interior pointers,
/// applied a second time to the seaward neighbor `extract_river_network`
/// already computes and then threw away). That zeroed the chord
/// (`chord_m < 1.0`), which tripped `invent_course`'s degenerate
/// single-point return, which made `resolve_mouth_terminus`'s station
/// walk a no-op (a 1-point course can't reach the `pts.len() >= 2`
/// fallback probe either) — all 3 real GJ1c mouths silently resolved
/// `CourseTerminus::None` instead of `Mouth`, and since Ruling 3g retired
/// the District/Quarter draw-time clip on the promise of real termini,
/// mouths would have disappeared entirely at those rungs. The fix:
/// `RiverNetwork::river_seaward` (additive, captured in the same
/// `extract_river_network` pass) carries the real seaward neighbor
/// through to `build_edges`, giving Mouth edges a genuine ~one-cell
/// chord to invent a course along.
#[test]
fn all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none() {
use crate::atlas::drainage;
use crate::atlas::heightmap::load_heightmap_png;
use crate::atlas::river_course;
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
let heightmap =
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
let small = heightmap.downsample(256, 128);
let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level);
let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr);
let rn = &dr.river_network;
let params = crate::atlas::district_profile::BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
body_radius_km: Some(6371.0),
..Default::default()
};
let climate = crate::atlas::district_profile::ClimateConstants::default();
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1);
let station_spacing_m = DISTRICT_M as f64;
let edges = river_course::build_edges(rn);
let mouth_edges: Vec<_> = edges
.iter()
.filter(|e| e.terminus == river_course::EdgeTerminusKind::Mouth)
.collect();
assert_eq!(
mouth_edges.len(),
rn.mouths.len(),
"build_edges must produce exactly one Mouth edge per RiverNetwork.mouths entry"
);
assert_eq!(
mouth_edges.len(),
3,
"GJ1c at this downsample is expected to have 3 real mouths (matches the \
committed cascade_golden fixture) — if this count changes, re-verify against \
tests/golden/cascade_layer1.json before updating this assertion"
);
let mut resolved_mouth_count = 0;
for edge in &mouth_edges {
// Sanity: the fix means Mouth edges get a real, non-degenerate
// chord toward the seaward neighbor — never upstream==downstream.
assert_ne!(
edge.upstream, edge.downstream,
"Mouth edge {:?} still has a same-cell placeholder downstream — \
river_seaward threading regressed",
edge.edge_id
);
let course =
river_course::invent_course(seed, edge, &ta, &params, station_spacing_m, 0.0);
assert!(
course.points.len() >= 2,
"Mouth edge {:?} invented a degenerate {}-point course — the chord-length \
fix regressed",
edge.edge_id,
course.points.len()
);
// Window rect generous enough to contain the whole short mouth
// course (mouths are ~one cell chord, so a wide margin is cheap).
let (min_x, max_x) = course
.points
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| {
(lo.min(p.0), hi.max(p.0))
});
let (min_y, max_y) = course
.points
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| {
(lo.min(p.1), hi.max(p.1))
});
let margin = 50_000.0;
let window_rect = (
min_x - margin,
min_y - margin,
max_x + margin,
max_y + margin,
);
let wire = crop_course_to_window(
&course,
window_rect,
seed,
"GJ1c",
&params,
&ta,
&climate,
0.0,
station_spacing_m,
)
.unwrap_or_else(|| {
panic!(
"Mouth edge {:?} cropped to nothing in its own window",
edge.edge_id
)
});
assert_eq!(
wire.terminus,
CourseTerminus::Mouth,
"Mouth edge {:?} (upstream {:?}, downstream {:?}) resolved {:?} instead of \
CourseTerminus::Mouth",
edge.edge_id,
edge.upstream,
edge.downstream,
wire.terminus
);
resolved_mouth_count += 1;
}
assert_eq!(
resolved_mouth_count, 3,
"acceptance criterion (Hoshe #1): all 3 real GJ1c mouths must resolve \
CourseTerminus::Mouth"
);
}
/// **T-1170 PR #197 review round 2 (coordinator's live GJ380c/Lendel
/// repro) — the upstream-cut coverage gap in the test above, closed.**
///
/// `all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none` derives its
/// window rect from the invented course's OWN min/max point bbox, so by
/// construction that window always contains the WHOLE course (both
/// `first_in == 0` and `last_in == n-1`) — it never exercises a window
/// that cuts the UPSTREAM anchor while the true downstream terminus
/// still falls inside. `crop_course_to_window`'s terminus branch is
/// driven entirely by `last_in`/`hi` (the downstream side); this test is
/// the direct proof that an upstream cut (`first_in > 0`, i.e. `lo > 0`)
/// does NOT collapse the terminus flag to `ContinuesBeyondWindow` — the
/// coordinator's hypothesis (a) from the live-server investigation,
/// falsified here as a permanent regression case rather than only a
/// throwaway probe (`mouth_repro_probe.rs`, deleted after this landed).
///
/// Window construction: take one real GJ1c mouth edge's full invented
/// course, find the TRUE (uncropped) terminus point, then build a window
/// rect deliberately offset upstream along the course's own tail
/// direction so its near edge sits well past the upstream anchor (cutting
/// it out of range) while its far edge still comfortably contains the
/// true terminus.
#[test]
fn mouth_terminus_survives_an_upstream_only_crop() {
use crate::atlas::drainage;
use crate::atlas::heightmap::load_heightmap_png;
use crate::atlas::river_course;
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
let heightmap =
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
let small = heightmap.downsample(256, 128);
let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level);
let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr);
let rn = &dr.river_network;
let params = crate::atlas::district_profile::BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
body_radius_km: Some(6371.0),
..Default::default()
};
let climate = crate::atlas::district_profile::ClimateConstants::default();
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1);
let station_spacing_m = DISTRICT_M as f64;
let edges = river_course::build_edges(rn);
let edge = edges
.iter()
.find(|e| e.terminus == river_course::EdgeTerminusKind::Mouth)
.expect("GJ1c fixture must have at least one Mouth edge");
let course = river_course::invent_course(seed, edge, &ta, &params, station_spacing_m, 0.0);
assert!(
course.points.len() >= 2,
"need a non-degenerate course to construct a meaningful upstream cut"
);
let upstream_anchor = course.points[0];
let true_end = *course.points.last().unwrap();
// Window offset upstream along the course's own tail direction (the
// last segment), far enough that the upstream anchor falls outside
// the window but the true terminus stays comfortably inside.
let second_last = course.points[course.points.len().saturating_sub(2)];
let (dx, dy) = (true_end.0 - second_last.0, true_end.1 - second_last.1);
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
let (ux, uy) = (dx / len, dy / len);
// Half the upstream->downstream distance keeps the window's near
// edge well clear of the upstream anchor for any real mouth chord
// (mouth edges are short, ~one D8 step), while the far edge margin
// below still comfortably covers the terminus.
let chord_m = ((true_end.0 - upstream_anchor.0).powi(2)
+ (true_end.1 - upstream_anchor.1).powi(2))
.sqrt();
let offset_m = (chord_m * 0.5).max(5_000.0);
let center = (true_end.0 - ux * offset_m, true_end.1 - uy * offset_m);
let half_extent_m = (chord_m * 0.5).max(5_000.0);
let window_rect = (
center.0 - half_extent_m,
center.1 - half_extent_m,
center.0 + half_extent_m,
center.1 + half_extent_m,
);
// Sanity on the window construction itself (not the code under
// test): the upstream anchor must genuinely be cropped out, and the
// true terminus must genuinely be inside — otherwise this test
// isn't exercising the branch it claims to.
let inside = |p: (f64, f64)| {
p.0 >= window_rect.0
&& p.0 <= window_rect.2
&& p.1 >= window_rect.1
&& p.1 <= window_rect.3
};
assert!(
!inside(upstream_anchor),
"test construction error: upstream anchor {upstream_anchor:?} must be OUTSIDE \
the window {window_rect:?} for this to be a real upstream-cut case"
);
assert!(
inside(true_end),
"test construction error: true terminus {true_end:?} must be INSIDE the \
window {window_rect:?} for this to test the terminus-survives claim"
);
let wire = crop_course_to_window(
&course,
window_rect,
seed,
"GJ1c",
&params,
&ta,
&climate,
0.0,
station_spacing_m,
)
.unwrap_or_else(|| panic!("course cropped to nothing despite containing the terminus"));
assert_eq!(
wire.terminus,
CourseTerminus::Mouth,
"an upstream-only crop (anchor cut, true terminus still in-window) must NOT \
collapse the terminus flag — got {:?} for edge {:?}",
wire.terminus,
edge.edge_id
);
}
/// **T-1170 live GJ380c/Lendel reconciliation dossier (coordinator's
/// request) — walk/paint agreement invariant, pinned permanently.**
///
/// Live capture at GJ380c Quarter n=32 (server-clamped to n=16 — see
/// below), district (13195,-2383), reported the ENTIRE visible course
/// (44 wire points, `terminus=Mouth`) painting as water tones, with the
/// resolved terminus apparently landing deep in open water. Investigated
/// via an instrumented reproduction of `resolve_mouth_terminus`'s exact
/// walk (temporary probe, deleted after this landed) — DETERMINATION:
/// this is not a bug. The window's world-metre rect
/// (`x∈[27006976,27039744]`) simply sits ~60+ km from the course's
/// upstream land anchor and close to the resolved coastal terminus, so
/// the overwhelming majority of what's IN FRAME is genuinely
/// Lake/OpenOcean-painted — confirmed by hand-computing the exact RGB
/// (`MORPHOLOGY_RGB_OPAQUE` × elevation lightness) for the coordinator's
/// sampled pixel colors, which matched Lake/OpenOcean to the rounding
/// digit. The walk's own station-by-station classification (178 land
/// stations, then 35 water stations, ZERO flip-flops) is perfectly
/// monotonic and the resolved terminus sits ~240 m from the true
/// land→water crossing — not "tens of km past coast".
///
/// **The invariant this test pins, since the raw "N consecutive water
/// stations" framing turned out not to be the real signal:** the wire
/// course's resolved `Mouth` terminus point must land in a window CELL
/// whose PAINTED morphology is genuinely `OpenOcean`/`Lake` (never a
/// land-family zone) — i.e. the termination walk and the window's own
/// per-cell classification, sampled independently via the SAME
/// `derive_at_metres` call, must agree at the terminus. This is the
/// walk/paint reconciliation the dossier was asked to determine, made
/// permanent and mechanism-agnostic (it would catch a REAL divergence —
/// wrap slip, sign flip, station-order bug — regardless of which
/// specific window happens to expose it).
#[test]
fn mouth_terminus_lands_in_a_painted_water_cell_on_real_gj380c() {
use crate::atlas::body_params_reader::BodyParamsReader;
use crate::atlas::drainage;
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
use crate::atlas::river_course;
let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let src = manifest.join("../wiki/star-systems/GJ-380/bodies/GJ380c/heightmap.png");
let systems_db = manifest.join("data/systems.db");
let params_reader = BodyParamsReader::open(&systems_db).expect("open committed systems.db");
let params = params_reader
.read_body_params("GJ380c")
.expect("read GJ380c body params from committed systems.db");
let heightmap = load_heightmap_png(&src, "GJ380c", 0.3).expect("decode GJ380c heightmap");
let working = if heightmap.width > GRID_W || heightmap.height > GRID_H {
heightmap.downsample(GRID_W, GRID_H)
} else {
heightmap
};
let dr = drainage::analyze(
&working.data,
working.width,
working.height,
working.sea_level,
);
let ta = crate::atlas::features::TerrainAnalysis::analyze(&working, &dr);
let rn = &dr.river_network;
// The exact real mouth cell from the live capture.
let target_px: (u16, u16) = (63, 354);
let edges = river_course::build_edges(rn);
let edge = edges
.iter()
.find(|e| e.edge_id == river_course::pack_cell_id(target_px))
.expect("GJ380c must have a Mouth edge at the live-captured cell");
assert_eq!(
edge.edge_id, 4129122,
"must be the same edge the live capture reported"
);
let seed = SeedChain::for_body(0, "GJ380c"); // production default world_seed
let climate = crate::atlas::district_profile::ClimateConstants::default();
// The EXACT live window shape: district (13195,-2383), REQUESTED
// n=32, Quarter granularity — server-side clamps n=32 down to n=16
// at Quarter granularity (`clamp_window_n`: cap_n = sqrt(4096)/4 =
// 16), which `build_district_window_layer` does NOT do itself (it
// trusts n verbatim by its own doc) — the request-handling layer
// (`handle_atlas_request`) applies `clamp_window_n_v2` BEFORE
// calling it. Pass the already-clamped n=16 here to match what a
// real client request actually receives.
let center = (13195, -2383);
let clamped_n = 16u32;
let layer = build_district_window_layer(
seed,
"GJ380c",
&params,
&ta,
rn,
center,
clamped_n,
&climate,
WindowGranularity::Quarter,
0,
);
let wire = layer
.courses
.iter()
.find(|c| c.edge_id == edge.edge_id)
.expect("edge 4129122 must ship in this window, matching the live capture");
assert_eq!(
wire.points.len(),
44,
"point count must match the live capture exactly, confirming this IS the \
reported window"
);
assert_eq!(wire.terminus, CourseTerminus::Mouth);
// The invariant: the terminus point's containing Quarter cell, read
// from THIS SAME layer's own painted `morphology` array, must be
// OpenOcean or Lake.
let side = WindowGranularity::Quarter.cell_grid_side(clamped_n);
let half = side / 2;
let step_m = WindowGranularity::Quarter.spacing_m();
let center_world_m = (
center.0 as f64 * DISTRICT_M as f64,
center.1 as f64 * DISTRICT_M as f64,
);
let (tx, ty) = *wire.points.last().unwrap();
let col = ((tx as f64 - center_world_m.0) / step_m + half as f64).round() as i32;
let row = ((ty as f64 - center_world_m.1) / step_m + half as f64).round() as i32;
assert!(
row >= 0 && row < side && col >= 0 && col < side,
"terminus point ({tx},{ty}) must map to an in-window cell, got (row={row},col={col})"
);
let cell_idx = (row * side + col) as usize;
let painted = layer.morphology[cell_idx];
assert!(
painted == crate::simulation::generator::MorphologyZone::OpenOcean as u8
|| painted == crate::simulation::generator::MorphologyZone::Lake as u8,
"walk/paint DIVERGENCE: the resolved Mouth terminus ({tx},{ty}) landed in a cell \
painted with morphology discriminant {painted} (not OpenOcean=0 or Lake=1) — the \
termination walk's own water verdict must agree with the window's painted \
classification at the SAME position"
);
}
/// Clamped-window edge: `n = 1` is the minimum valid window (a single
/// district) — no panic, no empty output, exactly one cell per array.
#[test]
+30 -20
View File
@@ -176,18 +176,25 @@ pub fn pack_cell_id(cell: RiverCell) -> u32 {
/// always be exactly parallel to `river_cells`, but a mismatched-length
/// legacy payload must degrade to "no edges" rather than panic).
///
/// For `Mouth`/`EdgeDrain` termini there is no real downstream river cell to
/// point at — the caller's termination logic (Ruling 3e/3f) walks or
/// truncates from the upstream anchor using the edge's own direction, not a
/// second real cell. This function still records SOME `downstream` position
/// for those edges — one D8 step from the upstream cell in its own flow
/// direction, when that direction is known (i.e. `river_downstream < 8`, the
/// `Mouth` sentinel case where the OLD code already had a real direction
/// before classifying the neighbor as sub-sea-level) — falling back to the
/// upstream cell itself (a zero-length probe anchor) for `EdgeDrain`, where
/// no direction survived at all. Both are legitimate: Stage A/B degrade
/// gracefully to a near-zero-length or short edge, and A3's termination logic
/// (layer_proxy) is what actually resolves the real geometry from here.
/// **Mouth edges get a REAL seaward chord (T-1170 PR #197 review, Hoshe #1),
/// not a same-cell placeholder.** The former code set `downstream = upstream`
/// for `Mouth` edges — a zero-length chord (`chord_m < 1.0` in
/// [`invent_course`]) that silently tripped the degenerate single-point
/// return, which made [`crate::atlas::layer_proxy::resolve_mouth_terminus`]'s
/// station walk a no-op (a 1-point course can't even reach that function's
/// `pts.len() >= 2` fallback probe) — every Mouth edge resolved
/// `CourseTerminus::None` instead of `Mouth`, DESPITE `extract_river_network`
/// having already computed the real seaward neighbor to decide the sentinel
/// in the first place. `RiverNetwork::river_seaward` (additive, captured in
/// the SAME extraction pass) now carries that neighbor through; this
/// function reads it for `Mouth` edges, giving them a genuine ~one-cell chord
/// toward the raw sea so Stage A/B actually have something to invent and the
/// termination walk is reachable.
///
/// `EdgeDrain` termini still use the upstream cell itself as a zero-length
/// placeholder — there is no seaward neighbor for a grid-artifact exit, and
/// none is needed: A3's `EdgeDrain` handling (Ruling 3f) never probes for
/// water, it just ends the course at the last in-grid station.
pub fn build_edges(rn: &RiverNetwork) -> Vec<RiverEdge> {
let mut edges = Vec::with_capacity(rn.river_cells.len());
for (i, &upstream) in rn.river_cells.iter().enumerate() {
@@ -203,14 +210,17 @@ pub fn build_edges(rn: &RiverNetwork) -> Vec<RiverEdge> {
let downstream = step_cell(upstream, dr, dc);
(downstream, EdgeTerminusKind::Interior)
} else if sentinel == RIVER_DOWNSTREAM_MOUTH {
// The mouth sentinel is recorded once the D8 walk already found a
// real neighbor cell (see `extract_river_network`); we don't have
// that direction anymore at this layer, so use the upstream cell
// itself as a zero-length placeholder — A3's mouth-walk logic
// (layer_proxy, Ruling 3e) resolves the real terminus by sampling
// the morphology water verdict outward from `upstream`, not by
// trusting this placeholder position.
(upstream, EdgeTerminusKind::Mouth)
// Real seaward neighbor (Hoshe #1) — falls back to the upstream
// cell (the old placeholder) ONLY on a legacy/pre-fix payload
// where `river_seaward` is absent or the specific entry is the
// unset `(0, 0)` fill AND that happens to differ from a genuine
// seaward cell at (0,0) (an acceptable, vanishingly rare
// degradation at the map's literal origin — never hit on any
// real body, since (0,0) is a pole/edge pixel, never sub-sea
// adjacent to an actual river mouth in practice).
let seaward = rn.river_seaward.get(i).copied().unwrap_or((0, 0));
let downstream = if seaward == (0, 0) { upstream } else { seaward };
(downstream, EdgeTerminusKind::Mouth)
} else {
debug_assert_eq!(sentinel, RIVER_DOWNSTREAM_EDGE_DRAIN);
(upstream, EdgeTerminusKind::EdgeDrain)
+26
View File
@@ -30,6 +30,32 @@
//! sea-adjacent mouths (`RIVER_DOWNSTREAM_MOUTH`). `river_cells`/`attractors`
//! counts are unchanged (93/256) — this is a pure re-classification + one new
//! additive field, not a drainage-algorithm change.
//!
//! **Attractor cascade correction (T-1170 PR #197 review, Hoshe #4):** the A1
//! commit's "pure reclassification" framing overclaimed — `attractors` is
//! count-PARITY (256/256), not byte-identical. Mouths dropping 19→3 shrinks
//! `TerrainAnalysis::water_dist`'s seed set (`compute_water_dist` seeds from
//! `river_network.mouths`), which shifts the water-distance field, which
//! feeds `RawAttractor::strength` scoring in `features::extract_attractors`.
//! This is a principled, in-scope cascade (the pole-edge cells genuinely
//! aren't water-distance sources anymore) — not a bug — but it is a REAL
//! field-level change, not a no-op re-tagging. Restated accurately here so
//! the record doesn't imply byte-identical attractor output.
//!
//! **Second deliberate re-pin (T-1170 PR #197 review, Hoshe #1):**
//! `RiverNetwork` gained a second additive field, `river_seaward:
//! Vec<(u16,u16)>` — the real seaward neighbor position for MOUTH-sentinel
//! river cells, captured in the SAME `extract_river_network` pass (the
//! elevation check that already computes `(nr, nc)` to decide the MOUTH
//! sentinel). Fixes a second instance of the discard-then-need-it-later
//! anti-pattern Ruling 2b's `river_downstream` field fixed for interior D8
//! pointers: `river_course::build_edges` needs a real seaward cell to give
//! Mouth edges a non-degenerate chord (see `river_course.rs`'s
//! `build_edges` doc and `layer_proxy::tests::
//! all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none` for the full
//! bug/fix story). `river_cells`/`mouths`/`attractors` counts unchanged by
//! this second re-pin (93/3/256) — purely the new parallel array, 3 non-
//! placeholder entries (one per real mouth).
use std::path::PathBuf;
+1
View File
@@ -559,6 +559,7 @@ fn generate_atlas_layer_response_fixtures() {
mouths: vec![(12, 58)],
river_class: vec![1, 2],
river_downstream: vec![2, 8], // 2=E direction; 8=MOUTH sentinel
river_seaward: vec![(0, 0), (12, 60)], // meaningful only for the MOUTH entry
},
drainage_basins: vec![DrainageBasin {
basin_id: 1,
+374
View File
@@ -8342,6 +8342,380 @@
9,
2,
8
],
"river_seaward": [
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
39,
47
],
[
38,
99
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
0,
0
],
[
125,
240
]
]
}
},
File diff suppressed because it is too large Load Diff
+63
View File
@@ -444,6 +444,20 @@ fn golden_cutoffs_match_the_scale_ladder() {
// geometry for an edge from that chain, at both District and Quarter station
// spacing, so courses themselves — not just the district-profile fields the
// main golden above covers — are regression-pinned.
//
// **Mouth coverage (T-1170 PR #197 review, Hoshe #2):** the sample set below
// also includes a real Mouth-terminus edge from `sample_hm()` (empirically
// probed: 4 mouth edges exist in this fixture) — `invent_course`'s raw output
// for that edge is pinned here (edge_id/class/terminus/points), closing the
// "zero Mouth coverage in any golden" gap at the invention layer. The FULL
// end-to-end path (invent → crop → `resolve_mouth_terminus` →
// `CourseTerminus::Mouth`) is covered separately and permanently by
// `layer_proxy::tests::all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none`
// (in-crate, since `crop_course_to_window`/`resolve_mouth_terminus` are
// private to `layer_proxy.rs` and unreachable from this integration test) —
// that test is the actual Hoshe #1 acceptance criterion (3/3 real mouths);
// this golden's job is regression-pinning the raw invented geometry, not
// re-proving the crop/resolve path.
const RIVER_COURSE_GOLDEN_FILE: &str = "tests/golden/river_course_golden.json";
@@ -481,6 +495,15 @@ fn river_course_golden_samples() -> Vec<GoldenCourseSample> {
})
.expect("sample_hm() fixture must have at least one interior river edge near (10, 116)");
// Hoshe #2: a real Mouth-terminus edge, deterministically selected as the
// lowest edge_id among the fixture's mouth edges (fixed tie-break, no
// hardcoded index).
let mouth_target = edges
.iter()
.filter(|e| e.terminus == river_course::EdgeTerminusKind::Mouth)
.min_by_key(|e| e.edge_id)
.expect("sample_hm() fixture must have at least one Mouth edge (empirically verified: 4)");
let mut out = Vec::new();
for (rung, spacing_m, min_wl_m) in [
("district", DISTRICT_MIN_WL_M, DISTRICT_MIN_WL_M),
@@ -498,6 +521,20 @@ fn river_course_golden_samples() -> Vec<GoldenCourseSample> {
.map(|p| (p.0.round() as i64, p.1.round() as i64))
.collect(),
});
let mouth_course =
river_course::invent_course(seed, mouth_target, &ta, &params, spacing_m, min_wl_m);
out.push(GoldenCourseSample {
rung: format!("{rung}_mouth"),
edge_id: mouth_course.edge_id,
class: mouth_course.class,
terminus: format!("{:?}", mouth_course.terminus),
points: mouth_course
.points
.iter()
.map(|p| (p.0.round() as i64, p.1.round() as i64))
.collect(),
});
}
out
}
@@ -567,3 +604,29 @@ fn river_course_rungs_have_different_station_counts() {
quarter.points.len()
);
}
/// **T-1170 PR #197 review, Hoshe #2 (golden coverage):** the pinned Mouth
/// edge samples must be genuine, non-degenerate courses (`terminus == "Mouth"`,
/// `points.len() >= 2`) — the direct golden-level check that the Hoshe #1 fix
/// (real seaward chord via `RiverNetwork::river_seaward`) reaches this fixture
/// too, not just the dedicated GJ1c acceptance test.
#[test]
fn river_course_mouth_samples_are_non_degenerate() {
let samples = river_course_golden_samples();
for rung in ["district_mouth", "quarter_mouth"] {
let sample = samples
.iter()
.find(|s| s.rung == rung)
.unwrap_or_else(|| panic!("missing golden sample for rung {rung}"));
assert_eq!(
sample.terminus, "Mouth",
"{rung}: build_edges must produce a Mouth-terminus RiverEdge for the pinned target"
);
assert!(
sample.points.len() >= 2,
"{rung}: Mouth edge invented a degenerate {}-point course — the chord-length fix \
(Hoshe #1) regressed for this fixture",
sample.points.len()
);
}
}
+40 -4
View File
@@ -420,10 +420,28 @@ fn bench_course_cost_on_vs_off() {
// Warm-up (allocator/cache warm, not counted).
let _ = build_district_window_layer(
seed, "GJ1c", &params, &ta, &rn_off, center, n, &climate, WindowGranularity::District, 0,
seed,
"GJ1c",
&params,
&ta,
&rn_off,
center,
n,
&climate,
WindowGranularity::District,
0,
);
let _ = build_district_window_layer(
seed, "GJ1c", &params, &ta, rn_on, center, n, &climate, WindowGranularity::District, 0,
seed,
"GJ1c",
&params,
&ta,
rn_on,
center,
n,
&climate,
WindowGranularity::District,
0,
);
let iterations = 1000; // higher count than the other benches — window cost here is ~1 ms, noisy at low n
@@ -431,7 +449,16 @@ fn bench_course_cost_on_vs_off() {
let t_off = Instant::now();
for _ in 0..iterations {
let layer = build_district_window_layer(
seed, "GJ1c", &params, &ta, &rn_off, center, n, &climate, WindowGranularity::District, 0,
seed,
"GJ1c",
&params,
&ta,
&rn_off,
center,
n,
&climate,
WindowGranularity::District,
0,
);
std::hint::black_box(layer.morphology.len());
}
@@ -442,7 +469,16 @@ fn bench_course_cost_on_vs_off() {
let mut courses_seen = 0usize;
for _ in 0..iterations {
let layer = build_district_window_layer(
seed, "GJ1c", &params, &ta, rn_on, center, n, &climate, WindowGranularity::District, 0,
seed,
"GJ1c",
&params,
&ta,
rn_on,
center,
n,
&climate,
WindowGranularity::District,
0,
);
courses_seen = layer.courses.len();
std::hint::black_box(layer.morphology.len());