Merge remote-tracking branch 'origin/main' into t1181-step-canvas
This commit is contained in:
@@ -1856,3 +1856,13 @@ D-256/T-1174 finding (2026-07-25): the believability harness golden is INSENSITI
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRXGSXBZKXTHR7MR5K9H2HRG', 'status', 'in_progress', 'review', NULL, '2026-07-24 23:18:26', '2026-07-24 23:18:26.774', '2026-07-24 23:18:26.774', NULL, '0e4aa2b6429cbcae6e99807975ff69cf', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XQW73B1P8WKR2BCZBFXZW', 'status', 'backlog', 'in_progress', NULL, '2026-07-24 23:52:05', '2026-07-24 23:52:05.692', '2026-07-24 23:52:05.692', NULL, 'f675cb4c0fd898dad72722cbee9572ed', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XQW73B1P8WKR2BCZBFXZW', 'status', 'in_progress', 'review', NULL, '2026-07-25 00:26:42', '2026-07-25 00:26:42.634', '2026-07-25 00:26:42.634', NULL, 'ada454394fd3e1eb062c1ed4b7a0356f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRXGSXBZKXTHR7MR5K9H2HRG', 'status', 'review', 'done', NULL, '2026-07-24 23:35:06', '2026-07-24 23:35:06.393', '2026-07-24 23:35:06.393', NULL, 'e0388f7bb73f510999e595c12fbbcfb5', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XQW73B1P8WKR2BCZBFXZW', 'status', 'backlog', 'in_progress', NULL, '2026-07-24 23:49:56', '2026-07-24 23:49:56.371', '2026-07-24 23:49:56.371', NULL, '4a54eb01cde514c7118a9b217420c857', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XXY5GNCQEXYAYQEQD0CHM', 'status', 'backlog', 'in_progress', NULL, '2026-07-24 23:49:56', '2026-07-24 23:49:56.374', '2026-07-24 23:49:56.374', NULL, 'cabecf341333b7a0e56ac7f2b05bc1b0', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XXY5GNCQEXYAYQEQD0CHM', 'description', 'Two halves, one ticket (the D-227 lakes amendment''s implementation; converged in araminta-round2.md §(e) + dudley-interview2-response.md CORRECTED section). HALF 1 — productionize: wire server/src/atlas/hydrology_equilibrium.rs (currently an inert workshop prototype, zero non-test callers) into the derive pipeline: solve once per body at rung-0 Global populate (measured ~24ms/body at 512x256; population survey: all 267 bodies ~0.86s), result held with the global tier (D-203-shaped, keep-always). HALF 2 — lake sourcing: MorphologyZone::Lake (discriminant 1 — ALREADY in the frozen D-239 17-zone vocabulary, no widening) becomes sourced from the settled solver: at derive time a gridunit is Lake when bilinear-sampled HydrologyResult.filled_scaled exceeds bilinear-sampled original elevation (the continuous filled-surface comparison — lake edges refine with rung like coastlines, per D-166; NEVER project basin-cell membership as a discrete lookup, which gives blocky non-refining edges), with today''s ocean_fraction_q>=60 heuristic as fallback where no solve exists. This is seed-chaining mechanism B (coarse continuous primitive sampled fresh per rung — D-255(f)). STATIC classification — distinct from the sim-state flooded plane; a lake''s existence must never flicker on a TTL. NO endorheic bit anywhere (ruled: the cue is outflow-course presence, delivered by the separate outlet-wiring ticket; until that lands the map shows lakes without the drains-vs-closed distinction — honest sequencing per D-227 amendment (4)). Byte cost: zero new wire bytes (existing field). Uniform moisture_q=55 scope limit from the survey noted: affects endorheic/overflow split only, never lake extent.', 'Two halves, one ticket (the D-227 lakes amendment''s implementation; converged in araminta-round2.md §(e) + dudley-interview2-response.md CORRECTED section). HALF 1 — productionize: wire server/src/atlas/hydrology_equilibrium.rs (currently an inert workshop prototype, zero non-test callers) into the derive pipeline: solve once per body at rung-0 Global populate (measured ~24ms/body at 512x256; population survey: all 267 bodies ~0.86s), result held with the global tier (D-203-shaped, keep-always). HALF 2 — lake sourcing: MorphologyZone::Lake (discriminant 1 — ALREADY in the frozen D-239 17-zone vocabulary, no widening) becomes sourced from the settled solver: at derive time a gridunit is Lake when bilinear-sampled HydrologyResult.filled_scaled exceeds bilinear-sampled original elevation (the continuous filled-surface comparison — lake edges refine with rung like coastlines, per D-166; NEVER project basin-cell membership as a discrete lookup, which gives blocky non-refining edges), with today''s ocean_fraction_q>=60 heuristic as fallback where no solve exists. This is seed-chaining mechanism B (coarse continuous primitive sampled fresh per rung — D-255(f)). STATIC classification — distinct from the sim-state flooded plane; a lake''s existence must never flicker on a TTL. NO endorheic bit anywhere (ruled: the cue is outflow-course presence, delivered by the separate outlet-wiring ticket; until that lands the map shows lakes without the drains-vs-closed distinction — honest sequencing per D-227 amendment (4)). Byte cost: zero new wire bytes (existing field). Uniform moisture_q=55 scope limit from the survey noted: affects endorheic/overflow split only, never lake extent.
|
||||
|
||||
IMPLEMENTATION NOTE (2026-07-25, lead-accepted deviation): the ticket''s ''result held with the global tier (D-203-shaped, keep-always)'' clause could not be satisfied literally — no keep-always rung-0 resident tier exists in the codebase yet (BodyWorldStateCache is the D-203 50-body LRU and drops TerrainAnalysis per T-1048). Implemented instead: hydrology solves once per TerrainAnalysis build inside run_layer1/run_layer1_with_moisture (~24ms, mirrors drainage::analyze), carried as TerrainAnalysis.hydrology: Option<HydrologySample> (~1MB), resident wherever TA is resident (TerrainAnalysisCache in gen_queue). When T-1181 builds the actual resident global tier, hydrology rides TA into it with no further wiring. Also: TerrainAnalysisCache::get_or_derive gained a body_params param to thread the real moisture ceiling — a known merge seam with the concurrent T-1181 branch (second-to-land rebases). Basin-id/outcome sampling deliberately NOT wired (T-1185 will need its own nearest-sample basin-id lookup if it needs identity beyond outlet-path presence); HydrologyResult.basins spill points computed but currently discarded after filled/elevation extraction — cheap to extend at the same call site.', NULL, '2026-07-25 00:16:29', '2026-07-25 00:16:29.760', '2026-07-25 00:16:29.760', NULL, '07732da7d3a5c8a9e5c17bbd4b743d54', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XXY5GNCQEXYAYQEQD0CHM', 'status', 'in_progress', 'review', NULL, '2026-07-25 00:28:44', '2026-07-25 00:28:44.132', '2026-07-25 00:28:44.132', NULL, '3044cf7ce15aeb72b4aff61313cc3e7e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FSCXX7E3BXT7WG0BYDB6PJA0', 'description', 'Found during the PR #199 (T-1174/D-256) review fix round, PRE-EXISTING on the window path since the region climate stack (T-1078/T-1113) was wired into derive_at_metres. region_profile::region_centre_latitude_deg (region_profile.rs ~270-280) maps region row 0 to the north pole (+90) with lat_frac clamped to [0,1] — a pole-anchored, non-negative row convention. But the derive core (derive_at_metres_with_riparian, district_profile.rs ~1788) keys regions by floor-dividing EQUATOR-anchored world metres (negative wy = north), producing signed region rows (Earth-class: -49..+49). Consequence: every northern-hemisphere region has negative row -> region_centre_y_m negative -> lat_frac clamps to 0 -> baseline latitude +90 (polar) regardless of true latitude; southern-hemisphere rows 0..~48 read as compressed NORTHERN latitudes ~89..~0.7 (south pole reads as equator). Affects the region temperature baseline (and glaciation/moisture components derived from it) for the window path at every rung, and post-D-256 the batch/survey path inherits the same keys (still an improvement over the pre-D-256 body-wide region-(0,0) baseline, which read ~+89 everywhere). NOT touched in PR #199 to keep the review round scoped; goldens currently pin the wrong-latitude values. FIX SHAPE: make region_centre_latitude_deg mirror the derive core''s inverse mapping (equator-anchored signed rows: lat = -((ry+0.5)*REGION_M/meridian_m clamped to [-0.5,0.5])*180), audit the OTHER convention''s callers (the D-255 rung-0 canvas row space and the collapsed LayerRegionOutput builder use non-negative pole-anchored rows - decide ONE convention per D-256''s one-inverse-mapping principle, likely at the T-1181 rung-0 rebuild), regen affected goldens, and re-run the believability direction check. Related: D-256, D-243, T-1181, T-1078.', 'Found during the PR #199 (T-1174/D-256) review fix round, PRE-EXISTING on the window path since the region climate stack (T-1078/T-1113) was wired into derive_at_metres. region_profile::region_centre_latitude_deg (region_profile.rs ~270-280) maps region row 0 to the north pole (+90) with lat_frac clamped to [0,1] — a pole-anchored, non-negative row convention. But the derive core (derive_at_metres_with_riparian, district_profile.rs ~1788) keys regions by floor-dividing EQUATOR-anchored world metres (negative wy = north), producing signed region rows (Earth-class: -49..+49). Consequence: every northern-hemisphere region has negative row -> region_centre_y_m negative -> lat_frac clamps to 0 -> baseline latitude +90 (polar) regardless of true latitude; southern-hemisphere rows 0..~48 read as compressed NORTHERN latitudes ~89..~0.7 (south pole reads as equator). Affects the region temperature baseline (and glaciation/moisture components derived from it) for the window path at every rung, and post-D-256 the batch/survey path inherits the same keys (still an improvement over the pre-D-256 body-wide region-(0,0) baseline, which read ~+89 everywhere). NOT touched in PR #199 to keep the review round scoped; goldens currently pin the wrong-latitude values. FIX SHAPE: make region_centre_latitude_deg mirror the derive core''s inverse mapping (equator-anchored signed rows: lat = -((ry+0.5)*REGION_M/meridian_m clamped to [-0.5,0.5])*180), audit the OTHER convention''s callers (the D-255 rung-0 canvas row space and the collapsed LayerRegionOutput builder use non-negative pole-anchored rows - decide ONE convention per D-256''s one-inverse-mapping principle, likely at the T-1181 rung-0 rebuild), regen affected goldens, and re-run the believability direction check. Related: D-256, D-243, T-1181, T-1078.
|
||||
|
||||
In-the-wild datapoint (2026-07-25, T-1184 eyeball session): GJ338Bd (Arbour, R=6711km) survey cell (42,29) — true position latitude -76.2S (wy=8,929,367m, region row 43) — derives with the pole-anchored baseline latitude +13.9N, producing an 18C temperate, glaciation-free lake district near the antarctic circle. Concrete demonstration of the southern-hemisphere-reads-as-northern-tropics half of the bug (the northern half clamps to +90). Conversion math used: scratchpad survey_to_district.py, the D-256 bridge formula.', NULL, '2026-07-25 01:01:37', '2026-07-25 01:01:37.348', '2026-07-25 01:01:37.348', NULL, '5aa76c70a4a81e6e66391bbfaea9b85e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -100,3 +100,4 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XXY5GNCQEXYAYQEQD0CHM', 'T-1184', '2026-07-24 07:12:17.452', '2026-07-24 07:12:17.452', NULL, '3fddf4af56979065160b8a3386874bdf', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XZF4A978WS1J66Q2JBK8C', 'T-1185', '2026-07-24 07:12:29.987', '2026-07-24 07:12:29.987', NULL, '191b89e5f2be7d2916f5c17b397b769f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FSCXX7E3BXT7WG0BYDB6PJA0', 'T-1186', '2026-07-24 23:30:51.896', '2026-07-24 23:30:51.896', NULL, '9000ac24144dd50b1189590db47ae2ba', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FSDE3STZBP0Y7GQ3ZEZPKFP8', 'T-1187', '2026-07-25 00:41:40.058', '2026-07-25 00:41:40.058', NULL, '312ad3897a28515b4ea488e2ccd920e2', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
|
||||
@@ -2693,3 +2693,20 @@ RULING (2026-07-24): D-256 claimed and written on branch t1174-derive-sampling (
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FSCXX7E3BXT7WG0BYDB6PJA0', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'Region-baseline latitude is pole-anchored while the derive core keys equator-anchored signed regions — northern hemisphere clamps to +90', 'Found during the PR #199 (T-1174/D-256) review fix round, PRE-EXISTING on the window path since the region climate stack (T-1078/T-1113) was wired into derive_at_metres. region_profile::region_centre_latitude_deg (region_profile.rs ~270-280) maps region row 0 to the north pole (+90) with lat_frac clamped to [0,1] — a pole-anchored, non-negative row convention. But the derive core (derive_at_metres_with_riparian, district_profile.rs ~1788) keys regions by floor-dividing EQUATOR-anchored world metres (negative wy = north), producing signed region rows (Earth-class: -49..+49). Consequence: every northern-hemisphere region has negative row -> region_centre_y_m negative -> lat_frac clamps to 0 -> baseline latitude +90 (polar) regardless of true latitude; southern-hemisphere rows 0..~48 read as compressed NORTHERN latitudes ~89..~0.7 (south pole reads as equator). Affects the region temperature baseline (and glaciation/moisture components derived from it) for the window path at every rung, and post-D-256 the batch/survey path inherits the same keys (still an improvement over the pre-D-256 body-wide region-(0,0) baseline, which read ~+89 everywhere). NOT touched in PR #199 to keep the review round scoped; goldens currently pin the wrong-latitude values. FIX SHAPE: make region_centre_latitude_deg mirror the derive core''s inverse mapping (equator-anchored signed rows: lat = -((ry+0.5)*REGION_M/meridian_m clamped to [-0.5,0.5])*180), audit the OTHER convention''s callers (the D-255 rung-0 canvas row space and the collapsed LayerRegionOutput builder use non-negative pole-anchored rows - decide ONE convention per D-256''s one-inverse-mapping principle, likely at the T-1181 rung-0 rebuild), regen affected goldens, and re-run the believability direction check. Related: D-256, D-243, T-1181, T-1078.', 'backlog', 'high', NULL, 'server', NULL, '2026-07-24 23:30:51.889', '2026-07-24 23:30:51.889', NULL, '6a7b5705a768da90b74449383d5a127b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XQW73B1P8WKR2BCZBFXZW', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Step-canvas serving: StepCanvasRequest/Response tagged envelope (D-255 wire migration)', 'The D-225-discharging migration per D-255(c): one new Inbound variant (StepCanvasRequest, required marker field step_canvas, extending the proven star_map/city_names/browse ShapeProbe pattern in server/src/bridge/mod.rs) + a dedicated StepCanvasResponse (NOT a field on AtlasLayerResponse — the D-226 §2 ceiling is re-scoped to the legacy carrier). Serves all six rungs of the D-255(a) ladder — rung-0 Global variable-extent is a field value, not a different shape; fixed rungs viewport-sized per D-255(b). Encoding: PNG-per-field dense (8 fields: 6 static + glaciation/flooded sim-state L8 planes) + MessagePack-native sparse (courses, cliffs) per the T-1179 measured table. SERVER CACHE TIERS ARE THIS TICKET''S SCOPE TOO (SI gap 3): the D-203-shaped resident global tier (~8.85MB population-wide, measured) + TTL(detail, time, distance) for finer rungs + SIM_STATE_TTL[field] = 1x the field''s own fastest driving clock-bucket per body class (flooded: tidal bucket moon-bearing / seasonal moonless; glaciation: seasonal — D-227 amendment (1), Dudley+Araminta joint formula). ACCEPTANCE GATE (SI gap 2, mandatory per D-227 amendment (3)): byte-identical output test — cache-hit derive path == cache-miss derive path for every rung; the seed-chaining cache-accelerated-pure-function correctness gate, same shape as T-1170''s window-independence invariant. Implementation note carried from measurement S2: courses cost +38-87% at chunk/block spacing (station count scales inversely with rung spacing — 1,732 vs 867 points/course measured); a station-spacing cap independent of rung spacing is a named candidate optimization, decision at implementation. Design sources: dudley-round2.md §(a), tyre-round2.md §(b), D-255(c)(d)(f). Legacy district_window survives byte-unchanged until its consumer is replaced.', 'in_progress', 'high', NULL, 'server', 'D-255', '2026-07-24 07:11:27.800', '2026-07-24 23:52:05.687', NULL, '047e4a20983d55f97bb41ae909addbba', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XQW73B1P8WKR2BCZBFXZW', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Step-canvas serving: StepCanvasRequest/Response tagged envelope (D-255 wire migration)', 'The D-225-discharging migration per D-255(c): one new Inbound variant (StepCanvasRequest, required marker field step_canvas, extending the proven star_map/city_names/browse ShapeProbe pattern in server/src/bridge/mod.rs) + a dedicated StepCanvasResponse (NOT a field on AtlasLayerResponse — the D-226 §2 ceiling is re-scoped to the legacy carrier). Serves all six rungs of the D-255(a) ladder — rung-0 Global variable-extent is a field value, not a different shape; fixed rungs viewport-sized per D-255(b). Encoding: PNG-per-field dense (8 fields: 6 static + glaciation/flooded sim-state L8 planes) + MessagePack-native sparse (courses, cliffs) per the T-1179 measured table. SERVER CACHE TIERS ARE THIS TICKET''S SCOPE TOO (SI gap 3): the D-203-shaped resident global tier (~8.85MB population-wide, measured) + TTL(detail, time, distance) for finer rungs + SIM_STATE_TTL[field] = 1x the field''s own fastest driving clock-bucket per body class (flooded: tidal bucket moon-bearing / seasonal moonless; glaciation: seasonal — D-227 amendment (1), Dudley+Araminta joint formula). ACCEPTANCE GATE (SI gap 2, mandatory per D-227 amendment (3)): byte-identical output test — cache-hit derive path == cache-miss derive path for every rung; the seed-chaining cache-accelerated-pure-function correctness gate, same shape as T-1170''s window-independence invariant. Implementation note carried from measurement S2: courses cost +38-87% at chunk/block spacing (station count scales inversely with rung spacing — 1,732 vs 867 points/course measured); a station-spacing cap independent of rung spacing is a named candidate optimization, decision at implementation. Design sources: dudley-round2.md §(a), tyre-round2.md §(b), D-255(c)(d)(f). Legacy district_window survives byte-unchanged until its consumer is replaced.', 'review', 'high', NULL, 'server', 'D-255', '2026-07-24 07:11:27.800', '2026-07-25 00:26:42.634', NULL, '403b79fde0dcd81c48718da87bde22e9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRXGSXBZKXTHR7MR5K9H2HRG', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Batch vs window derive paths sample different world positions for the same DistrictPos', 'Pre-existing architectural quirk found by Dudley during T-1168 integration testing (2026-07-23, not introduced by that batch): derive_district_profile''s cell-aggregate-centre sampling and the window path''s district-origin sampling resolve to genuinely different world positions for ''the same DistrictPos'' — full DistrictProfile output legitimately differs between paths (morphology/elev/moisture) at the same nominal district. His integration test was redesigned to compare the riparian signal at a shared exact world position instead (the right test). Investigate whether any existing batch/window consumer pair assumes positional agreement (city placement vs window rendering?), decide the canonical sampling convention, and either unify or document the divergence as intentional with the reasoning.
|
||||
|
||||
D-255 (2026-07-24): the tagged-envelope step-canvas serving (T-1181) depends on batch and window derive agreeing cell-for-cell — this bug now also blocks the step-canvas serving path, and priority is raised accordingly (Tyre disposition, body-map-viewer workshop). No supersession; intent unchanged.
|
||||
|
||||
RULING (2026-07-24): D-256 claimed and written on branch t1174-derive-sampling (rides the PR; syncs to main on merge). Canonical convention = derive(seed, absolute world metres) via the derive_at_metres family; batch 64x32 pseudo-grid re-scoped as a survey raster with a real SurveyCellPos newtype; derive_district_profile becomes a thin wrapper over an extracted riparian-parameterized core at survey-cell-centre positions (basin_direction preserved by post-call override; near_perennial_water_at plumbed, never the empty-slice default); skeleton morphology judgments move to exact-position derive; step canvases fenced to the window family. LayerRegionOutput rebuild deferred to T-1181 (tripwire verified: sole reader is the region_grid overlay); voxel carrier deferred to Phase 5. Consults: Tyre APPROVE-WITH-CHANGES, Dudley FEASIBLE-WITH-CHANGES (both incorporated). Settlement/road positions do NOT move (L1 attractor-sourced; T-1075 bake orthogonal); believability goldens regen, gates unchanged; window goldens must stay byte-green.', 'done', 'high', NULL, 'server', NULL, '2026-07-23 11:36:27.743', '2026-07-24 23:35:06.385', NULL, '8eb2f277a07bd3d5d780a55e4c329792', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XQW73B1P8WKR2BCZBFXZW', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Step-canvas serving: StepCanvasRequest/Response tagged envelope (D-255 wire migration)', 'The D-225-discharging migration per D-255(c): one new Inbound variant (StepCanvasRequest, required marker field step_canvas, extending the proven star_map/city_names/browse ShapeProbe pattern in server/src/bridge/mod.rs) + a dedicated StepCanvasResponse (NOT a field on AtlasLayerResponse — the D-226 §2 ceiling is re-scoped to the legacy carrier). Serves all six rungs of the D-255(a) ladder — rung-0 Global variable-extent is a field value, not a different shape; fixed rungs viewport-sized per D-255(b). Encoding: PNG-per-field dense (8 fields: 6 static + glaciation/flooded sim-state L8 planes) + MessagePack-native sparse (courses, cliffs) per the T-1179 measured table. SERVER CACHE TIERS ARE THIS TICKET''S SCOPE TOO (SI gap 3): the D-203-shaped resident global tier (~8.85MB population-wide, measured) + TTL(detail, time, distance) for finer rungs + SIM_STATE_TTL[field] = 1x the field''s own fastest driving clock-bucket per body class (flooded: tidal bucket moon-bearing / seasonal moonless; glaciation: seasonal — D-227 amendment (1), Dudley+Araminta joint formula). ACCEPTANCE GATE (SI gap 2, mandatory per D-227 amendment (3)): byte-identical output test — cache-hit derive path == cache-miss derive path for every rung; the seed-chaining cache-accelerated-pure-function correctness gate, same shape as T-1170''s window-independence invariant. Implementation note carried from measurement S2: courses cost +38-87% at chunk/block spacing (station count scales inversely with rung spacing — 1,732 vs 867 points/course measured); a station-spacing cap independent of rung spacing is a named candidate optimization, decision at implementation. Design sources: dudley-round2.md §(a), tyre-round2.md §(b), D-255(c)(d)(f). Legacy district_window survives byte-unchanged until its consumer is replaced.', 'in_progress', 'high', NULL, 'server', 'D-255', '2026-07-24 07:11:27.800', '2026-07-24 23:49:56.370', NULL, 'ec6cbae56f99407aa4763726af4577c0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XXY5GNCQEXYAYQEQD0CHM', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Hydrology into the pipeline: productionize the T-1177 solver + lake morphology sourcing (D-255)', 'Two halves, one ticket (the D-227 lakes amendment''s implementation; converged in araminta-round2.md §(e) + dudley-interview2-response.md CORRECTED section). HALF 1 — productionize: wire server/src/atlas/hydrology_equilibrium.rs (currently an inert workshop prototype, zero non-test callers) into the derive pipeline: solve once per body at rung-0 Global populate (measured ~24ms/body at 512x256; population survey: all 267 bodies ~0.86s), result held with the global tier (D-203-shaped, keep-always). HALF 2 — lake sourcing: MorphologyZone::Lake (discriminant 1 — ALREADY in the frozen D-239 17-zone vocabulary, no widening) becomes sourced from the settled solver: at derive time a gridunit is Lake when bilinear-sampled HydrologyResult.filled_scaled exceeds bilinear-sampled original elevation (the continuous filled-surface comparison — lake edges refine with rung like coastlines, per D-166; NEVER project basin-cell membership as a discrete lookup, which gives blocky non-refining edges), with today''s ocean_fraction_q>=60 heuristic as fallback where no solve exists. This is seed-chaining mechanism B (coarse continuous primitive sampled fresh per rung — D-255(f)). STATIC classification — distinct from the sim-state flooded plane; a lake''s existence must never flicker on a TTL. NO endorheic bit anywhere (ruled: the cue is outflow-course presence, delivered by the separate outlet-wiring ticket; until that lands the map shows lakes without the drains-vs-closed distinction — honest sequencing per D-227 amendment (4)). Byte cost: zero new wire bytes (existing field). Uniform moisture_q=55 scope limit from the survey noted: affects endorheic/overflow split only, never lake extent.', 'in_progress', 'medium', NULL, 'server', 'D-227', '2026-07-24 07:12:17.452', '2026-07-24 23:49:56.374', NULL, '32e1feaeeace1bb37417c88f222dae83', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XXY5GNCQEXYAYQEQD0CHM', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Hydrology into the pipeline: productionize the T-1177 solver + lake morphology sourcing (D-255)', 'Two halves, one ticket (the D-227 lakes amendment''s implementation; converged in araminta-round2.md §(e) + dudley-interview2-response.md CORRECTED section). HALF 1 — productionize: wire server/src/atlas/hydrology_equilibrium.rs (currently an inert workshop prototype, zero non-test callers) into the derive pipeline: solve once per body at rung-0 Global populate (measured ~24ms/body at 512x256; population survey: all 267 bodies ~0.86s), result held with the global tier (D-203-shaped, keep-always). HALF 2 — lake sourcing: MorphologyZone::Lake (discriminant 1 — ALREADY in the frozen D-239 17-zone vocabulary, no widening) becomes sourced from the settled solver: at derive time a gridunit is Lake when bilinear-sampled HydrologyResult.filled_scaled exceeds bilinear-sampled original elevation (the continuous filled-surface comparison — lake edges refine with rung like coastlines, per D-166; NEVER project basin-cell membership as a discrete lookup, which gives blocky non-refining edges), with today''s ocean_fraction_q>=60 heuristic as fallback where no solve exists. This is seed-chaining mechanism B (coarse continuous primitive sampled fresh per rung — D-255(f)). STATIC classification — distinct from the sim-state flooded plane; a lake''s existence must never flicker on a TTL. NO endorheic bit anywhere (ruled: the cue is outflow-course presence, delivered by the separate outlet-wiring ticket; until that lands the map shows lakes without the drains-vs-closed distinction — honest sequencing per D-227 amendment (4)). Byte cost: zero new wire bytes (existing field). Uniform moisture_q=55 scope limit from the survey noted: affects endorheic/overflow split only, never lake extent.
|
||||
|
||||
IMPLEMENTATION NOTE (2026-07-25, lead-accepted deviation): the ticket''s ''result held with the global tier (D-203-shaped, keep-always)'' clause could not be satisfied literally — no keep-always rung-0 resident tier exists in the codebase yet (BodyWorldStateCache is the D-203 50-body LRU and drops TerrainAnalysis per T-1048). Implemented instead: hydrology solves once per TerrainAnalysis build inside run_layer1/run_layer1_with_moisture (~24ms, mirrors drainage::analyze), carried as TerrainAnalysis.hydrology: Option<HydrologySample> (~1MB), resident wherever TA is resident (TerrainAnalysisCache in gen_queue). When T-1181 builds the actual resident global tier, hydrology rides TA into it with no further wiring. Also: TerrainAnalysisCache::get_or_derive gained a body_params param to thread the real moisture ceiling — a known merge seam with the concurrent T-1181 branch (second-to-land rebases). Basin-id/outcome sampling deliberately NOT wired (T-1185 will need its own nearest-sample basin-id lookup if it needs identity beyond outlet-path presence); HydrologyResult.basins spill points computed but currently discarded after filled/elevation extraction — cheap to extend at the same call site.', 'in_progress', 'medium', NULL, 'server', 'D-227', '2026-07-24 07:12:17.452', '2026-07-25 00:16:29.760', NULL, '176b6cd1a00b243926603db5e64acd5b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XXY5GNCQEXYAYQEQD0CHM', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Hydrology into the pipeline: productionize the T-1177 solver + lake morphology sourcing (D-255)', 'Two halves, one ticket (the D-227 lakes amendment''s implementation; converged in araminta-round2.md §(e) + dudley-interview2-response.md CORRECTED section). HALF 1 — productionize: wire server/src/atlas/hydrology_equilibrium.rs (currently an inert workshop prototype, zero non-test callers) into the derive pipeline: solve once per body at rung-0 Global populate (measured ~24ms/body at 512x256; population survey: all 267 bodies ~0.86s), result held with the global tier (D-203-shaped, keep-always). HALF 2 — lake sourcing: MorphologyZone::Lake (discriminant 1 — ALREADY in the frozen D-239 17-zone vocabulary, no widening) becomes sourced from the settled solver: at derive time a gridunit is Lake when bilinear-sampled HydrologyResult.filled_scaled exceeds bilinear-sampled original elevation (the continuous filled-surface comparison — lake edges refine with rung like coastlines, per D-166; NEVER project basin-cell membership as a discrete lookup, which gives blocky non-refining edges), with today''s ocean_fraction_q>=60 heuristic as fallback where no solve exists. This is seed-chaining mechanism B (coarse continuous primitive sampled fresh per rung — D-255(f)). STATIC classification — distinct from the sim-state flooded plane; a lake''s existence must never flicker on a TTL. NO endorheic bit anywhere (ruled: the cue is outflow-course presence, delivered by the separate outlet-wiring ticket; until that lands the map shows lakes without the drains-vs-closed distinction — honest sequencing per D-227 amendment (4)). Byte cost: zero new wire bytes (existing field). Uniform moisture_q=55 scope limit from the survey noted: affects endorheic/overflow split only, never lake extent.
|
||||
|
||||
IMPLEMENTATION NOTE (2026-07-25, lead-accepted deviation): the ticket''s ''result held with the global tier (D-203-shaped, keep-always)'' clause could not be satisfied literally — no keep-always rung-0 resident tier exists in the codebase yet (BodyWorldStateCache is the D-203 50-body LRU and drops TerrainAnalysis per T-1048). Implemented instead: hydrology solves once per TerrainAnalysis build inside run_layer1/run_layer1_with_moisture (~24ms, mirrors drainage::analyze), carried as TerrainAnalysis.hydrology: Option<HydrologySample> (~1MB), resident wherever TA is resident (TerrainAnalysisCache in gen_queue). When T-1181 builds the actual resident global tier, hydrology rides TA into it with no further wiring. Also: TerrainAnalysisCache::get_or_derive gained a body_params param to thread the real moisture ceiling — a known merge seam with the concurrent T-1181 branch (second-to-land rebases). Basin-id/outcome sampling deliberately NOT wired (T-1185 will need its own nearest-sample basin-id lookup if it needs identity beyond outlet-path presence); HydrologyResult.basins spill points computed but currently discarded after filled/elevation extraction — cheap to extend at the same call site.', 'review', 'medium', NULL, 'server', 'D-227', '2026-07-24 07:12:17.452', '2026-07-25 00:28:44.132', NULL, 'ce686f7567ba8cd58fb6d4af0641f353', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FSDE3STZBP0Y7GQ3ZEZPKFP8', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Arc-wrap TerrainAnalysis in TerrainAnalysisCache — clone-on-hit copies ~2.6MB per window request', 'Follow-up from PR #200 review (T-1184, 2026-07-25). TerrainAnalysisCache::get_or_derive''s hit branch clones the full TerrainAnalysis (~2.62MB/entry at the 512x256 working grid post-hydrology: ~1.57MB dense fields + ~1.05MB HydrologySample) on EVERY cache hit — every DeriveWindow/DeriveStepCanvas work item pays a multi-MB memcpy for data that is immutable after construction. The clean fix is Arc<TerrainAnalysis> in the cache (clone becomes a refcount bump); flagged by the implementer during the review round and ruled out of scope there. Sizing truth now documented at gen_queue.rs get_or_derive + features.rs HydrologySample (corrected figures: capacity-8 worst case ~21MB). Low priority: correctness unaffected, cost is per-request memcpy only.', 'backlog', 'low', NULL, 'server', NULL, '2026-07-25 00:41:40.055', '2026-07-25 00:41:40.055', NULL, '28e18f415ed43a36e00d72a4d2502ead', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FSCXX7E3BXT7WG0BYDB6PJA0', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'Region-baseline latitude is pole-anchored while the derive core keys equator-anchored signed regions — northern hemisphere clamps to +90', 'Found during the PR #199 (T-1174/D-256) review fix round, PRE-EXISTING on the window path since the region climate stack (T-1078/T-1113) was wired into derive_at_metres. region_profile::region_centre_latitude_deg (region_profile.rs ~270-280) maps region row 0 to the north pole (+90) with lat_frac clamped to [0,1] — a pole-anchored, non-negative row convention. But the derive core (derive_at_metres_with_riparian, district_profile.rs ~1788) keys regions by floor-dividing EQUATOR-anchored world metres (negative wy = north), producing signed region rows (Earth-class: -49..+49). Consequence: every northern-hemisphere region has negative row -> region_centre_y_m negative -> lat_frac clamps to 0 -> baseline latitude +90 (polar) regardless of true latitude; southern-hemisphere rows 0..~48 read as compressed NORTHERN latitudes ~89..~0.7 (south pole reads as equator). Affects the region temperature baseline (and glaciation/moisture components derived from it) for the window path at every rung, and post-D-256 the batch/survey path inherits the same keys (still an improvement over the pre-D-256 body-wide region-(0,0) baseline, which read ~+89 everywhere). NOT touched in PR #199 to keep the review round scoped; goldens currently pin the wrong-latitude values. FIX SHAPE: make region_centre_latitude_deg mirror the derive core''s inverse mapping (equator-anchored signed rows: lat = -((ry+0.5)*REGION_M/meridian_m clamped to [-0.5,0.5])*180), audit the OTHER convention''s callers (the D-255 rung-0 canvas row space and the collapsed LayerRegionOutput builder use non-negative pole-anchored rows - decide ONE convention per D-256''s one-inverse-mapping principle, likely at the T-1181 rung-0 rebuild), regen affected goldens, and re-run the believability direction check. Related: D-256, D-243, T-1181, T-1078.
|
||||
|
||||
In-the-wild datapoint (2026-07-25, T-1184 eyeball session): GJ338Bd (Arbour, R=6711km) survey cell (42,29) — true position latitude -76.2S (wy=8,929,367m, region row 43) — derives with the pole-anchored baseline latitude +13.9N, producing an 18C temperate, glaciation-free lake district near the antarctic circle. Concrete demonstration of the southern-hemisphere-reads-as-northern-tropics half of the bug (the northern half clamps to +90). Conversion math used: scratchpad survey_to_district.py, the D-256 bridge formula.', 'backlog', 'high', NULL, 'server', NULL, '2026-07-24 23:30:51.889', '2026-07-25 01:01:37.348', NULL, 'e21a79e29bd911e28ff64a26cd517c0e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -7,6 +7,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Lakes from settled hydrology** (D-227, T-1184) — depressions in a body's terrain now hold water: an equilibrium hydrology solve (the T-1177 solver, ~24 ms per body) runs once per body and fills every basin to its settled water level, and the map's terrain classification reads it directly — a spot is a lake when the settled water surface sits above the ground there. Lake edges refine with zoom exactly like coastlines (continuous surface comparison, never a blocky cell lookup), a lake's existence never flickers (static classification, distinct from tidal/seasonal flooding), and bodies without a solve fall back to the old ocean-fraction heuristic byte-identically. Whether a lake drains via an exit river or sits closed (endorheic) becomes readable when the outlet wiring lands (T-1185)
|
||||
- **3D locomotion sandbox + in-house wardrobe engine — Fable-5 character sidequest** (D-248–D-252, T-1088, T-1089) — a sanctioned cascade exception delivered a walking 3D character ahead of Phase 5: a locomotion sandbox (SR_LIVE, greybox tiles derived from live server snapshots, no hand-built map) with per-leg constant-velocity interpolation cadence-synced to gait clips, mouse-driven move-here pathing with a walk/sprint/take-cover gesture vocabulary, and a client-side wall-cutaway camera kept decoupled from fog-of-perception. The server protocol's `Facing` is now view-only — movement direction is derived client-side from position deltas, and NPC gaze relocates to path-follow intent (D-252). Alongside it, a full in-house wardrobe engine: offset-shell garment authoring from our own body meshes (per-body mode, weights inherited by construction), a 24-garment catalogue — everyday casual through a hand-authored suit and colorable uniform — fitted across all 11 body types, RGBA multi-region tinting with per-character brand logos, a creation-screen try-on UI, 12 named outfit presets, and a chromakey QA harness gating every garment for clip-through before release. The character asset route (Quaternius rig, in-house wardrobe) was re-confirmed after a hands-on vendor evaluation (D-251 — Synty proved technically viable but was rejected on cost, modularity, and style), and the purchased UAL1/UAL2 animation tiers are wired in. Five of the eleven body types, found broken bare mid-sidequest (T-1090), were repaired — all eleven now render and fit correctly
|
||||
- **Layer 2 — inter-settlement road & rail graph** (D-211, T-1038) — the generation cascade now draws the transport network *between* a body's settlements: a minimum-spanning backbone connects every city, extra direct links are added where that backbone would force a long detour, and each route is pathed across the real terrain — hugging river valleys (preferred), skirting steep ground, never crossing open water. Every road carries a *maintenance authority* you'll be able to read in its condition (Administrative government highway, Corporate access road, Communal local lane, long-haul Trade route, or Abandoned), and long routes gain waypoint stops at their midpoints. Deterministic and held in the per-body world cache (re-derived from seed, never saved); the Atlas overlay and downstream layers (road-entry directions, territory road-coverage) consume it next. Corp HQs are kept out of the city pool so they don't spawn duplicate roadside towns (D-242)
|
||||
- **Layer 3 — settlement placement** (D-211, #955) — the generation cascade now places a body's settlements on terrain that fits their economy: capitals and cities are matched to river mouths, coasts, valleys, passes, etc. via the D-195 affinity table (a financial hub gravitates to the coast, a farm to a fertile valley, a fort to a mountain pass). Runs in-game: when a body is first viewed, its settlements are read from the atlas and placed by the live cascade, then held in the per-body world cache
|
||||
|
||||
@@ -252,8 +252,22 @@ pub fn run_cascade_from_heightmap(
|
||||
// run_layer1 now returns (Layer1Output, TerrainAnalysis); the TerrainAnalysis
|
||||
// is carried transiently on the snapshot so DistrictProfile + RoadGraph can
|
||||
// reuse it without the former ~45 ms redundant drainage re-run (T-1044).
|
||||
//
|
||||
// T-1184: settled-equilibrium hydrology solves inside run_layer1 as part
|
||||
// of this same pass (D-227 amendment (4), the AnalyzeBody cascade populate
|
||||
// point). When real BodyParams are available, derive the body's actual
|
||||
// moisture ceiling (hydrosphere/atmosphere) for the endorheic-vs-overflow
|
||||
// split rather than falling back to run_layer1's body-agnostic default —
|
||||
// this cascade entry point always has body_params in scope when the
|
||||
// caller supplied one, so there is no reason to leave it on the fallback.
|
||||
if up_to >= CascadeLayer::Topography {
|
||||
let (mut l1, ta) = layer1::run_layer1(&snapshot.heightmap);
|
||||
let (mut l1, ta) = match body_params {
|
||||
Some(params) => layer1::run_layer1_with_moisture(
|
||||
&snapshot.heightmap,
|
||||
district_profile::derive_moisture_ceiling_q(params),
|
||||
),
|
||||
None => layer1::run_layer1(&snapshot.heightmap),
|
||||
};
|
||||
// Stamp the province TerritorialStatus (D-212) onto each basin.
|
||||
for basin in &mut l1.drainage_basins {
|
||||
basin.territorial_status = territorial_status.clone();
|
||||
|
||||
@@ -551,6 +551,23 @@ pub fn derive_river_threshold(tectonic: TectonicClass, precip: PrecipitationClas
|
||||
/// - BraidedPlain (§6) is NOT emitted at district scale — distinguishing it from Delta needs a lithology signal (§8 Gravel→braided) DistrictProfile lacks; deferred to ChunkContext (see the D-239 §6 implementation note).
|
||||
///
|
||||
/// D-010: all gates are integer comparisons. No float arithmetic in this function.
|
||||
///
|
||||
/// `lake_from_hydrology` (T-1184, D-227 amendment (4) / D-255(f) mechanism B):
|
||||
/// the caller-computed settled-hydrology basin verdict at this exact
|
||||
/// position — `true` when a bilinear sample of `HydrologyResult.filled_scaled`
|
||||
/// exceeds a bilinear sample of the original elevation at the SAME position
|
||||
/// (the continuous filled-surface comparison; never a discrete basin-cell
|
||||
/// membership lookup, which would give a blocky, non-refining lake edge).
|
||||
/// `false` both when hydrology genuinely found no lake here AND when no
|
||||
/// `HydrologyResult` is available at all (`TerrainAnalysis.hydrology ==
|
||||
/// None`) — both cases fall through to the pre-existing `ocean_fraction_q`
|
||||
/// heuristic below unchanged, so a caller with no hydrology data reproduces
|
||||
/// today's behaviour byte-for-byte. This is a MORE AUTHORITATIVE trigger
|
||||
/// checked AHEAD OF the heuristic (per the araminta-round2.md §(e) ruling:
|
||||
/// "Sea vs. Lake stays exactly as today... the `Lake` emission site gains a
|
||||
/// second, more-authoritative trigger ahead of the existing heuristic
|
||||
/// fallback") — it never touches the `ocean_fraction_q >= 80` open-ocean
|
||||
/// tier, which stays exactly as before.
|
||||
pub fn derive_morphology_zone(
|
||||
tectonic: TectonicClass,
|
||||
glaciation: GlaciationGrade,
|
||||
@@ -558,14 +575,26 @@ pub fn derive_morphology_zone(
|
||||
elev_q: i32,
|
||||
ocean_fraction_q: i32,
|
||||
moisture_q: i32,
|
||||
lake_from_hydrology: bool,
|
||||
) -> MorphologyZone {
|
||||
// ── Tier 0: fully submerged ──────────────────────────────────────────────
|
||||
if ocean_fraction_q >= 80 {
|
||||
// Very high ocean fraction: open ocean or lake depending on context.
|
||||
// No body-scale salinity signal at district level yet; treat all as OpenOcean.
|
||||
// Lake differentiation lives at ChunkContext (D-239 §10).
|
||||
//
|
||||
// Unchanged by T-1184: a settled-hydrology lake basin can never
|
||||
// reclassify a cell the raw heightmap already reads as ≥80% below
|
||||
// sea level back OUT of OpenOcean — hydrology only ever ADDS Lake
|
||||
// coverage the heuristic was missing, never removes the open-ocean
|
||||
// floor. (Also structurally moot: `HydrologyResult`'s priority-flood
|
||||
// seeds from below-sea-level cells, so a genuine open-ocean cell's
|
||||
// `filled == original` there — no lake basin ever covers it.)
|
||||
return MorphologyZone::OpenOcean;
|
||||
}
|
||||
if lake_from_hydrology {
|
||||
return MorphologyZone::Lake;
|
||||
}
|
||||
if ocean_fraction_q >= 60 {
|
||||
return MorphologyZone::Lake;
|
||||
}
|
||||
@@ -1013,12 +1042,16 @@ pub fn derive_district_temperature_c(
|
||||
/// The `hydro` arms use the **actual `bodies.hydrosphere` vocabulary in systems.db**
|
||||
/// — same set the `[hydrosphere_maritime]` table (D-240) keys on — grouped by available
|
||||
/// surface moisture (T-1034).
|
||||
pub fn derive_moisture_q(
|
||||
params: &BodyParams,
|
||||
elev_q: i32,
|
||||
ocean_fraction_q: i32,
|
||||
climate: &ClimateConstants,
|
||||
) -> i32 {
|
||||
/// Body-wide moisture ceiling — the wettest a district on this body can be,
|
||||
/// from `hydrosphere` + `atmosphere` alone (T-1080's `ceiling` term, before
|
||||
/// the per-district latitude/elevation/continentality gradient). Extracted
|
||||
/// (T-1184) so a body-level-only consumer — [`crate::atlas::hydrology_equilibrium`]'s
|
||||
/// `ClimateInputs::moisture_q`, which needs exactly this single scalar and
|
||||
/// nothing position-specific — can share the vocabulary table with
|
||||
/// [`derive_moisture_q`] instead of re-deriving a parallel one that could
|
||||
/// silently drift from it. Byte-identical to the `ceiling` local this
|
||||
/// function's caller computed inline before the extraction.
|
||||
pub fn derive_moisture_ceiling_q(params: &BodyParams) -> i32 {
|
||||
let hydro = params.hydrosphere.as_deref().unwrap_or("none");
|
||||
let atmo = params.atmosphere.as_deref().unwrap_or("none");
|
||||
|
||||
@@ -1046,8 +1079,17 @@ pub fn derive_moisture_q(
|
||||
"dense" => 15,
|
||||
_ => 0,
|
||||
};
|
||||
(base + atmo_boost).clamp(0, 100)
|
||||
}
|
||||
|
||||
pub fn derive_moisture_q(
|
||||
params: &BodyParams,
|
||||
elev_q: i32,
|
||||
ocean_fraction_q: i32,
|
||||
climate: &ClimateConstants,
|
||||
) -> i32 {
|
||||
// Body moisture ceiling — the wettest a district on this body can be.
|
||||
let ceiling = (base + atmo_boost).clamp(0, 100);
|
||||
let ceiling = derive_moisture_ceiling_q(params);
|
||||
|
||||
// ── Per-district spatial gradient (T-1080) ────────────────────────────────
|
||||
// Latitude: equator (0) wet → pole (90) dry. `latitude_deg` is per-district.
|
||||
@@ -1479,6 +1521,16 @@ pub fn derive_district_profile(
|
||||
/// value NEVER touches `moisture_q`** (Ruling 4d, binding, re-affirmed): it
|
||||
/// is threaded straight through to `derive_vegetation` unchanged, after every
|
||||
/// moisture/temperature/morphology field above it has already been resolved.
|
||||
///
|
||||
/// ## Lake sourcing (T-1184, D-227 amendment (4))
|
||||
///
|
||||
/// `lake_from_hydrology` is the caller-computed [`lake_from_hydrology_at`]
|
||||
/// verdict for this position — threaded straight into
|
||||
/// [`derive_morphology_zone`]'s new gate, ahead of its pre-existing
|
||||
/// `ocean_fraction_q >= 60` heuristic. Computed by the caller (not here) for
|
||||
/// the same reason `near_perennial_water` is: this function stays free of
|
||||
/// `TerrainAnalysis`/pixel-position concerns, taking only the already-reduced
|
||||
/// per-position signals every other field here consumes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_district_profile(
|
||||
seed: SeedChain,
|
||||
@@ -1493,6 +1545,7 @@ fn build_district_profile(
|
||||
world_y_m: f64,
|
||||
min_wavelength_m: f64,
|
||||
near_perennial_water: bool,
|
||||
lake_from_hydrology: bool,
|
||||
) -> DistrictProfile {
|
||||
let tectonic_class = derive_tectonic_class(body_params);
|
||||
|
||||
@@ -1555,6 +1608,7 @@ fn build_district_profile(
|
||||
elev_q,
|
||||
ocean_fraction_q,
|
||||
moisture_q,
|
||||
lake_from_hydrology,
|
||||
);
|
||||
|
||||
// Vegetation class (T-1025, D-239 §8). near_perennial_water (T-1168) is
|
||||
@@ -1819,6 +1873,11 @@ fn derive_at_metres_with_riparian(
|
||||
min_wavelength_m,
|
||||
);
|
||||
|
||||
// T-1184: the settled-hydrology lake test, sampled at the SAME (px, py)
|
||||
// fractional working-grid position every other envelope field here reads
|
||||
// — the continuous filled-surface comparison (D-227 amendment (4)).
|
||||
let lake_from_hydrology = lake_from_hydrology_at(ta, px, py);
|
||||
|
||||
build_district_profile(
|
||||
seed,
|
||||
¶ms,
|
||||
@@ -1832,6 +1891,7 @@ fn derive_at_metres_with_riparian(
|
||||
world_y_m,
|
||||
min_wavelength_m,
|
||||
near_perennial_water,
|
||||
lake_from_hydrology,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1945,6 +2005,11 @@ pub fn derive_orbital_at_metres(
|
||||
None, // no pre-built cache; derive on-the-fly, same posture as derive_at_metres
|
||||
);
|
||||
|
||||
// T-1184: same continuous filled-surface comparison every rung samples,
|
||||
// at the orbital rung's own (px, py) — lake edges refine at Region
|
||||
// spacing exactly as they do at every finer rung (D-227 amendment (4)).
|
||||
let lake_from_hydrology = lake_from_hydrology_at(ta, px, py);
|
||||
|
||||
build_district_profile(
|
||||
seed,
|
||||
¶ms,
|
||||
@@ -1973,6 +2038,7 @@ pub fn derive_orbital_at_metres(
|
||||
// riparian band is many orders of magnitude below Region's ~205 km
|
||||
// spacing and could never fire (Ruling 4e).
|
||||
false,
|
||||
lake_from_hydrology,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2006,6 +2072,31 @@ pub(crate) fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f
|
||||
a + (b - a) * ty
|
||||
}
|
||||
|
||||
/// The T-1184 settled-hydrology lake test (D-227 amendment (4) / D-255(f)
|
||||
/// mechanism B): `true` when a bilinear sample of the settled filled-surface
|
||||
/// field strictly exceeds a bilinear sample of the original elevation at the
|
||||
/// SAME fractional working-grid position — the continuous comparison that
|
||||
/// makes lake edges refine with rung exactly like coastlines, rather than
|
||||
/// projecting `HydrologyResult.basins[*].cells` membership as a discrete,
|
||||
/// non-refining lookup (explicitly rejected, see this function's callers'
|
||||
/// docs). `false` when `ta.hydrology` is `None` (no solve available for this
|
||||
/// analysis — every caller must already treat `false` here as "fall through
|
||||
/// to the `ocean_fraction_q` heuristic", never as an error).
|
||||
///
|
||||
/// Both `elevation` and `filled` are sampled via the SAME `bilinear` helper
|
||||
/// `ocean_fraction_q`'s own `ta.elev_pct`/`ta.ocean_mask` reads already use at
|
||||
/// every derive-core call site (T-1178/T-1154's per-cell rate numbers already
|
||||
/// include equivalent-cost sampling in the measured per-rung budget — no new
|
||||
/// cost category, per the workshop's own pipeline-slot ruling).
|
||||
fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> bool {
|
||||
let Some(h) = ta.hydrology.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let filled = bilinear(&h.filled, ta.w, ta.h, px, py);
|
||||
let original = bilinear(&h.elevation, ta.w, ta.h, px, py);
|
||||
filled > original
|
||||
}
|
||||
|
||||
/// Bilinear interpolation of a boolean mask as a 0–1 fraction (for ocean coverage).
|
||||
fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 {
|
||||
if w == 0 || h == 0 {
|
||||
@@ -2491,6 +2582,277 @@ mod tests {
|
||||
assert_eq!(a.basin_direction as u8, b.basin_direction as u8);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// T-1184 — lake sourcing from settled hydrology (D-227 amendment (4))
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Bowl-shaped heightmap (high rim, low centre) — same fixture shape as
|
||||
/// `hydrology_equilibrium.rs`'s own `bowl_grid` and `layer1.rs`'s
|
||||
/// `bowl_hm`, reproduced locally (both are `#[cfg(test)]`-private to
|
||||
/// their own modules) so this module's tests can build a
|
||||
/// `TerrainAnalysis` with real hydrology attached via
|
||||
/// `with_hydrology` without depending on solver-internal or
|
||||
/// layer1-internal test helpers. `sea_level: 0.0` keeps the ENTIRE grid
|
||||
/// dry land except the filled basin, so `ocean_fraction_q` can never
|
||||
/// independently trigger the pre-existing `>= 60` heuristic — any
|
||||
/// `Lake` verdict this test observes can only come from the hydrology
|
||||
/// gate.
|
||||
fn bowl_hm_no_ocean() -> BodyHeightmap {
|
||||
let (w, h) = (64u32, 32u32);
|
||||
let n = (w * h) as usize;
|
||||
let cx = w as f32 / 2.0;
|
||||
let cy = h as f32 / 2.0;
|
||||
let max_r = cx.min(cy).max(1.0);
|
||||
let data = (0..n)
|
||||
.map(|i| {
|
||||
let r = (i / w as usize) as f32;
|
||||
let c = (i % w as usize) as f32;
|
||||
let d = (((c - cx).powi(2) + (r - cy).powi(2)).sqrt() / max_r).min(1.0);
|
||||
0.1 + d * 0.8
|
||||
})
|
||||
.collect();
|
||||
BodyHeightmap {
|
||||
body_id: "bowl_test".into(),
|
||||
width: w,
|
||||
height: h,
|
||||
data,
|
||||
sea_level: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Real end-to-end wiring: solve hydrology on the bowl fixture, attach it
|
||||
/// via `with_hydrology` (the same call `layer1::run_layer1` makes in
|
||||
/// production), and confirm `derive_at_metres` classifies the bowl
|
||||
/// CENTRE as `Lake` — sourced from the hydrology gate, not the
|
||||
/// `ocean_fraction_q` heuristic (impossible here: `sea_level == 0.0`
|
||||
/// means `ocean_fraction_q` is always 0 on this fixture).
|
||||
#[test]
|
||||
fn derive_at_metres_sources_lake_from_hydrology_at_bowl_centre() {
|
||||
let hm = bowl_hm_no_ocean();
|
||||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||||
let base_ta = TerrainAnalysis::analyze(&hm, &dr);
|
||||
let hydrology = crate::atlas::hydrology_equilibrium::solve(
|
||||
&hm.data,
|
||||
hm.width,
|
||||
hm.height,
|
||||
hm.sea_level,
|
||||
crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 55 },
|
||||
);
|
||||
let ta = base_ta.with_hydrology(&hm.data, &hydrology);
|
||||
|
||||
let climate = ClimateConstants::default();
|
||||
let p = BodyParams {
|
||||
planet_class: Some("temperate".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
..Default::default() // body_radius_km: None -> 1 DISTRICT_M = 1 px
|
||||
};
|
||||
let dm = scale::DISTRICT_M as f64;
|
||||
// Bowl centre in pixel space is (32, 16); no-radius mode maps
|
||||
// DistrictPos 1:1 onto heightmap pixels.
|
||||
let prof = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
32.0 * dm,
|
||||
16.0 * dm,
|
||||
&climate,
|
||||
0.0,
|
||||
&[],
|
||||
);
|
||||
assert_eq!(
|
||||
prof.morphology_zone,
|
||||
MorphologyZone::Lake,
|
||||
"bowl centre must classify Lake via the hydrology-sourced gate; \
|
||||
ocean_fraction_q is always 0 on this fixture (sea_level=0.0), so \
|
||||
this cannot be the pre-existing heuristic"
|
||||
);
|
||||
assert_eq!(
|
||||
prof.ocean_fraction_q, 0,
|
||||
"sanity: heuristic gate never fires here"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same bowl centre, sampled via `derive_orbital_at_metres` (Region
|
||||
/// rung) — confirms the hydrology gate is wired into BOTH derive paths
|
||||
/// through the shared `build_district_profile` tail, not just the
|
||||
/// district/quarter/chunk path.
|
||||
#[test]
|
||||
fn derive_orbital_at_metres_sources_lake_from_hydrology_at_bowl_centre() {
|
||||
let hm = bowl_hm_no_ocean();
|
||||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||||
let base_ta = TerrainAnalysis::analyze(&hm, &dr);
|
||||
let hydrology = crate::atlas::hydrology_equilibrium::solve(
|
||||
&hm.data,
|
||||
hm.width,
|
||||
hm.height,
|
||||
hm.sea_level,
|
||||
crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 55 },
|
||||
);
|
||||
let ta = base_ta.with_hydrology(&hm.data, &hydrology);
|
||||
|
||||
let climate = ClimateConstants::default();
|
||||
let p = BodyParams {
|
||||
planet_class: Some("temperate".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let dm = scale::DISTRICT_M as f64;
|
||||
let prof = derive_orbital_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
32.0 * dm,
|
||||
16.0 * dm,
|
||||
&climate,
|
||||
);
|
||||
assert_eq!(
|
||||
prof.morphology_zone,
|
||||
MorphologyZone::Lake,
|
||||
"orbital rung must also source Lake from hydrology at the bowl centre"
|
||||
);
|
||||
}
|
||||
|
||||
/// No hydrology attached (`ta.hydrology == None`, the state every
|
||||
/// pre-T-1184 caller and every OTHER test in this module is already in)
|
||||
/// must fall through to the pre-existing `ocean_fraction_q` heuristic
|
||||
/// byte-identically — the whole point of making `with_hydrology` an
|
||||
/// opt-in builder rather than changing `analyze`'s default output.
|
||||
#[test]
|
||||
fn derive_at_metres_without_hydrology_falls_back_to_heuristic() {
|
||||
let hm = bowl_hm_no_ocean();
|
||||
let ta = test_ta(&hm); // no with_hydrology call — ta.hydrology stays None
|
||||
assert!(ta.hydrology.is_none());
|
||||
|
||||
let climate = ClimateConstants::default();
|
||||
let p = BodyParams {
|
||||
planet_class: Some("temperate".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let dm = scale::DISTRICT_M as f64;
|
||||
let prof = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
32.0 * dm,
|
||||
16.0 * dm,
|
||||
&climate,
|
||||
0.0,
|
||||
&[],
|
||||
);
|
||||
// sea_level=0.0 on this fixture means ocean_fraction_q is always 0,
|
||||
// so without hydrology the bowl centre must NOT classify Lake (no
|
||||
// trigger available at all) — proving the fallback path is inert,
|
||||
// not silently finding a lake some other way.
|
||||
assert_ne!(
|
||||
prof.morphology_zone,
|
||||
MorphologyZone::Lake,
|
||||
"without hydrology data, the bowl centre must not classify Lake — \
|
||||
confirms with_hydrology is what supplies the signal, not some \
|
||||
other implicit path"
|
||||
);
|
||||
}
|
||||
|
||||
/// The D-255(f) mandatory cache-hit == cache-miss determinism gate,
|
||||
/// applied to lake classification specifically: deriving the SAME
|
||||
/// position through the SAME `HydrologyResult` (as if reading a resident
|
||||
/// coarser canvas) must be byte-identical to solving hydrology fresh a
|
||||
/// second time and deriving again (as if the cache had been evicted and
|
||||
/// hydrology re-solved) — D-227's "evict -> recompute -> byte-identical"
|
||||
/// test, instantiated for the hydrology-sourced `morphology_zone` gate
|
||||
/// this ticket adds.
|
||||
#[test]
|
||||
fn lake_classification_cache_hit_equals_cache_miss() {
|
||||
let hm = bowl_hm_no_ocean();
|
||||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||||
let climate_inputs = crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 55 };
|
||||
|
||||
// "Cache hit" path: solve once, reuse the SAME HydrologyResult for
|
||||
// every sample (mirrors a resident coarser canvas served from cache).
|
||||
let hydrology_cached = crate::atlas::hydrology_equilibrium::solve(
|
||||
&hm.data,
|
||||
hm.width,
|
||||
hm.height,
|
||||
hm.sea_level,
|
||||
climate_inputs,
|
||||
);
|
||||
let ta_hit = TerrainAnalysis::analyze(&hm, &dr).with_hydrology(&hm.data, &hydrology_cached);
|
||||
|
||||
let climate = ClimateConstants::default();
|
||||
let p = BodyParams {
|
||||
planet_class: Some("temperate".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let dm = scale::DISTRICT_M as f64;
|
||||
|
||||
// Sample several positions (centre, rim, corner) through the "hit" path.
|
||||
let positions = [(32.0, 16.0), (5.0, 5.0), (60.0, 28.0), (32.0, 4.0)];
|
||||
let hit_zones: Vec<MorphologyZone> = positions
|
||||
.iter()
|
||||
.map(|&(px, py)| {
|
||||
derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta_hit,
|
||||
px * dm,
|
||||
py * dm,
|
||||
&climate,
|
||||
0.0,
|
||||
&[],
|
||||
)
|
||||
.morphology_zone
|
||||
})
|
||||
.collect();
|
||||
|
||||
// "Cache miss" path: re-solve hydrology fresh (a second, independent
|
||||
// solve() call — D-227's eviction/recompute case) and re-derive the
|
||||
// SAME positions.
|
||||
let hydrology_fresh = crate::atlas::hydrology_equilibrium::solve(
|
||||
&hm.data,
|
||||
hm.width,
|
||||
hm.height,
|
||||
hm.sea_level,
|
||||
climate_inputs,
|
||||
);
|
||||
let ta_miss = TerrainAnalysis::analyze(&hm, &dr).with_hydrology(&hm.data, &hydrology_fresh);
|
||||
let miss_zones: Vec<MorphologyZone> = positions
|
||||
.iter()
|
||||
.map(|&(px, py)| {
|
||||
derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta_miss,
|
||||
px * dm,
|
||||
py * dm,
|
||||
&climate,
|
||||
0.0,
|
||||
&[],
|
||||
)
|
||||
.morphology_zone
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
hit_zones, miss_zones,
|
||||
"cache-hit path (reused HydrologyResult) and cache-miss path \
|
||||
(freshly re-solved HydrologyResult) must classify byte-identically \
|
||||
at every sampled position (D-227 / D-255(f))"
|
||||
);
|
||||
// Non-vacuous: at least the centre position must actually be a lake,
|
||||
// so this test is exercising the gate, not trivially passing because
|
||||
// nothing ever classified Lake.
|
||||
assert!(
|
||||
hit_zones.contains(&MorphologyZone::Lake),
|
||||
"sanity: the position sweep must include at least one Lake cell"
|
||||
);
|
||||
}
|
||||
|
||||
/// A non-district-aligned fractional metre position (e.g. a quarter-grid
|
||||
/// sample, T-1150) must derive without panicking and stay within the same
|
||||
/// value ranges as the district-aligned case — the whole point of the
|
||||
@@ -3817,7 +4179,10 @@ mod tests {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Helper to call `derive_morphology_zone` with a complete set of defaults,
|
||||
/// overriding only the parameters relevant to the test.
|
||||
/// overriding only the parameters relevant to the test. `lake_from_hydrology`
|
||||
/// defaults to `false` (T-1184) — no existing caller of this helper tests
|
||||
/// the hydrology-sourced lake gate; see `lake_from_hydrology_true_wins_...`
|
||||
/// below for the dedicated hydrology-path tests.
|
||||
fn zone(
|
||||
tectonic: TectonicClass,
|
||||
glaciation: GlaciationGrade,
|
||||
@@ -3833,6 +4198,7 @@ mod tests {
|
||||
elev_q,
|
||||
ocean_fraction_q,
|
||||
moisture_q,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,62 @@ pub struct TerrainAnalysis {
|
||||
pub slope_deg: Vec<f32>,
|
||||
/// Elevation percentile [0,1] among land cells (ocean cells = 0.0).
|
||||
pub elev_pct: Vec<f32>,
|
||||
/// Settled-equilibrium hydrology sourcing (T-1184, D-227 amendment (4) /
|
||||
/// D-255(f) seed-chaining mechanism B). `None` when hydrology hasn't been
|
||||
/// solved for this analysis (e.g. every pre-T-1184 call site still using
|
||||
/// bare [`TerrainAnalysis::analyze`], and every unit test that constructs
|
||||
/// a `TerrainAnalysis` directly without going through the hydrology-aware
|
||||
/// entry point) — callers MUST treat `None` as "fall through to the
|
||||
/// `ocean_fraction_q` heuristic", never as an error. `Some` when
|
||||
/// [`TerrainAnalysis::with_hydrology`] populated it from a real
|
||||
/// [`crate::atlas::hydrology_equilibrium::HydrologyResult`].
|
||||
pub hydrology: Option<HydrologySample>,
|
||||
}
|
||||
|
||||
/// The two continuous working-grid fields `derive_morphology_zone`'s lake
|
||||
/// sourcing bilinearly samples (T-1184) — never a discrete basin-membership
|
||||
/// lookup (that gives blocky, non-refining lake edges, the exact D-166
|
||||
/// magnified-composite artifact this design avoids; see D-227 amendment (4)
|
||||
/// / D-255(f) mechanism B). Both fields are row-major, `w × h`, in the SAME
|
||||
/// `[0.0, 1.0]` normalized domain the raw heightmap and `sea_level` already
|
||||
/// share — so a bilinear sample of one is directly comparable to a bilinear
|
||||
/// sample of the other, no rescaling at the call site.
|
||||
///
|
||||
/// **Size + clone cost (PR #200 review, Hoshe finding 1):** two `Vec<f32>` at
|
||||
/// the real 512×256 working grid = ~1.05 MB/entry, added on top of
|
||||
/// `TerrainAnalysis`'s pre-existing ~1.57 MB of dense fields (~2.62 MB total,
|
||||
/// ×1.67 growth, not quite a doubling) — see the corrected sizing comment on
|
||||
/// `GenWorkItem::DeriveWindow` (`gen_queue.rs`) for the full accounting and
|
||||
/// the `TerrainAnalysisCache` cache-HIT clone-cost note (every hit
|
||||
/// deep-copies both these `Vec`s, not just the first miss/insert).
|
||||
///
|
||||
/// **`elevation` is a deliberate, provably-necessary redundant copy, not an
|
||||
/// oversight.** `TerrainAnalysis` has no OTHER field that retains the raw
|
||||
/// `[0,1]` heightmap: `elev_pct` is a RANK PERCENTILE (`rank(elev[i]) /
|
||||
/// (land_cell_count - 1)`, `compute_elev_percentile`'s own doc/impl) —
|
||||
/// mathematically a different quantity from absolute elevation, and NOT
|
||||
/// safe to compare against `filled` (two cells at different true elevations
|
||||
/// can share adjacent ranks; ocean cells are forced to `0.0` regardless of
|
||||
/// their real depth). `HydrologyResult` itself carries no elevation field
|
||||
/// either (`hydrology_equilibrium.rs`: `basins`, `filled_scaled`,
|
||||
/// `channel_depth_scaled`, `cliff_edge` — no `original`/`elevation` member).
|
||||
/// So there is no existing bit-identical grid this field could point at
|
||||
/// instead — carrying its own copy is the only byte-safe option today.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HydrologySample {
|
||||
/// The original (unfilled) heightmap elevation, `[0.0, 1.0]`. Not stored
|
||||
/// anywhere else on `TerrainAnalysis` (`elev_pct` is a land-cell RANK
|
||||
/// percentile, a different quantity — see its own doc) — this is the
|
||||
/// literal `hm.data` the solver's `original` array was built from,
|
||||
/// carried alongside `filled` so both halves of the lake comparison
|
||||
/// sample from the identical grid at the identical resolution.
|
||||
pub elevation: Vec<f32>,
|
||||
/// `HydrologyResult.filled_scaled`, rescaled back from the solver's
|
||||
/// `i64`-scaled integer domain to `[0.0, 1.0]` (dividing by the same
|
||||
/// `ELEV_SCALE` the solver used to go the other way) — the settled
|
||||
/// water-surface height at every working-grid cell (equal to
|
||||
/// `elevation` wherever no lake exists).
|
||||
pub filled: Vec<f32>,
|
||||
}
|
||||
|
||||
const WATER_DIST_CAP: u16 = 255;
|
||||
@@ -115,9 +171,57 @@ impl TerrainAnalysis {
|
||||
water_dist,
|
||||
slope_deg,
|
||||
elev_pct,
|
||||
hydrology: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Populate the settled-hydrology sourcing fields (T-1184, D-227
|
||||
/// amendment (4) / D-255(f) mechanism B) from a solved
|
||||
/// [`crate::atlas::hydrology_equilibrium::HydrologyResult`].
|
||||
///
|
||||
/// Builder-style (consumes and returns `self`) rather than a constructor
|
||||
/// parameter on [`TerrainAnalysis::analyze`] — `analyze` has ~20 call
|
||||
/// sites across production code and tests that have no hydrology input
|
||||
/// (and, per D-227, don't need one: hydrology sourcing is a lake-specific
|
||||
/// refinement, not a precondition for every other terrain field this
|
||||
/// struct carries). Keeping `analyze`'s signature untouched means every
|
||||
/// existing caller keeps working byte-identically; only the two
|
||||
/// production sites that actually solve hydrology
|
||||
/// (`layer1::run_layer1`, `gen_queue::TerrainAnalysisCache::get_or_derive`)
|
||||
/// opt in by chaining this call.
|
||||
///
|
||||
/// Panics if `result`'s grids aren't `self.w * self.h` cells — a
|
||||
/// programmer error (mismatched working-grid resolution between the
|
||||
/// heightmap this `TerrainAnalysis` was built from and the elevation grid
|
||||
/// `solve()` was called on), never a legitimate runtime state.
|
||||
pub fn with_hydrology(
|
||||
mut self,
|
||||
elevation: &[f32],
|
||||
result: &crate::atlas::hydrology_equilibrium::HydrologyResult,
|
||||
) -> TerrainAnalysis {
|
||||
let n = self.w * self.h;
|
||||
assert_eq!(
|
||||
elevation.len(),
|
||||
n,
|
||||
"with_hydrology: elevation grid size does not match TerrainAnalysis dims"
|
||||
);
|
||||
assert_eq!(
|
||||
result.filled_scaled.len(),
|
||||
n,
|
||||
"with_hydrology: HydrologyResult grid size does not match TerrainAnalysis dims"
|
||||
);
|
||||
let filled: Vec<f32> = result
|
||||
.filled_scaled
|
||||
.iter()
|
||||
.map(|&s| crate::atlas::hydrology_equilibrium::scaled_to_fraction(s))
|
||||
.collect();
|
||||
self.hydrology = Some(HydrologySample {
|
||||
elevation: elevation.to_vec(),
|
||||
filled,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_ocean(&self, r: usize, c: usize) -> bool {
|
||||
self.ocean_mask[idx(r, c, self.w)]
|
||||
|
||||
@@ -197,20 +197,58 @@ pub enum GenWorkItem {
|
||||
/// `drain_generation_completions` documents).
|
||||
///
|
||||
/// **TerrainAnalysis availability (T-1137 binding decision, with numbers;
|
||||
/// corrected 2026-07-21 per PR #187 review — Tyre C1):** `BodyWorldState`
|
||||
/// does NOT retain `TerrainAnalysis` after cascade completion (T-1044
|
||||
/// scoped its transient-carry fix to *within-cascade* reuse only —
|
||||
/// `cascade.rs` drops it once `DistrictProfile`+`RoadGraph` finish; see the
|
||||
/// doc on `CascadeSnapshot::terrain_analysis`). Caching it alongside every
|
||||
/// `BodyWorldStateCache` entry would cost ~2 MB × 50-body capacity ≈
|
||||
/// 100 MB of PERMANENT resident cost, paid by every cached body whether or
|
||||
/// not a window is ever requested for it — the exact D-203 budget concern
|
||||
/// T-1044's own ticket text guarded against. So `run_work_item` re-derives
|
||||
/// via `run_layer1` (matching `aliveness_probe`'s existing `--render`
|
||||
/// workaround) rather than persisting a field on `BodyWorldState` — but
|
||||
/// NOT unconditionally on every work item: the actual model is a small
|
||||
/// **per-body LRU** (`TerrainAnalysisCache`, capacity 8, ~16 MB worst
|
||||
/// case), consulted before every re-derive. The FIRST `DeriveWindow` on a
|
||||
/// corrected 2026-07-21 per PR #187 review — Tyre C1; RE-CORRECTED
|
||||
/// 2026-07-25 per PR #200 review — Hoshe finding 1, T-1184's hydrology
|
||||
/// field addition):** `BodyWorldState` does NOT retain `TerrainAnalysis`
|
||||
/// after cascade completion (T-1044 scoped its transient-carry fix to
|
||||
/// *within-cascade* reuse only — `cascade.rs` drops it once
|
||||
/// `DistrictProfile`+`RoadGraph` finish; see the doc on
|
||||
/// `CascadeSnapshot::terrain_analysis`). Caching it alongside every
|
||||
/// `BodyWorldStateCache` entry would cost **~2.62 MB × 50-body capacity ≈
|
||||
/// 131 MB** of PERMANENT resident cost (real per-entry figure below —
|
||||
/// this multiplier is unchanged, only the per-entry base moved), paid by
|
||||
/// every cached body whether or not a window is ever requested for it —
|
||||
/// the exact D-203 budget concern T-1044's own ticket text guarded
|
||||
/// against. So `run_work_item` re-derives via `run_layer1` (matching
|
||||
/// `aliveness_probe`'s existing `--render` workaround) rather than
|
||||
/// persisting a field on `BodyWorldState` — but NOT unconditionally on
|
||||
/// every work item: the actual model is a small **per-body LRU**
|
||||
/// (`TerrainAnalysisCache`, capacity 8), consulted before every
|
||||
/// re-derive.
|
||||
///
|
||||
/// **Real per-entry size (T-1184, corrected from the stale "~2 MB"
|
||||
/// figure the PR #187 pass computed before hydrology existed):** at the
|
||||
/// real `GRID_W × GRID_H = 512 × 256` working grid (131,072 cells, the
|
||||
/// SAME grid every `TerrainAnalysisCache` entry is built at — NOT the
|
||||
/// 1024×512 stored-heightmap-PNG resolution, D-202, which is downsampled
|
||||
/// away before `run_layer1` ever runs) — pre-T-1184 dense fields
|
||||
/// (`ocean_mask`/`lake_mask`: `Vec<bool>`, 1 B/cell each; `water_dist`:
|
||||
/// `Vec<u16>`, 2 B/cell; `slope_deg`/`elev_pct`: `Vec<f32>`, 4 B/cell
|
||||
/// each) sum to **~1.57 MB**. T-1184's `HydrologySample` (`elevation` +
|
||||
/// `filled`, both `Vec<f32>`, 4 B/cell) adds **~1.05 MB** — **not quite a
|
||||
/// doubling** (×1.67, not ×2), but a real, non-trivial per-entry growth:
|
||||
/// **post-T-1184 total ~2.62 MB/entry.** At capacity 8 that is **~21.0 MB
|
||||
/// worst case** (was ~12.6 MB pre-T-1184; the old "~16 MB" comment was
|
||||
/// itself a round-up of the pre-T-1184 figure, not a post-hydrology one).
|
||||
/// Both this cache and `BodyWorldStateCache`'s counterfactual above use
|
||||
/// the corrected ~2.62 MB base.
|
||||
///
|
||||
/// **Clone-on-HIT, not just insert (Hoshe finding 1, binding for the next
|
||||
/// sizing decision):** `get_or_derive`'s cache-hit branch
|
||||
/// (`return (l1.clone(), ta.clone())`) deep-copies the FULL
|
||||
/// `TerrainAnalysis` — including both new `HydrologySample` `Vec<f32>`
|
||||
/// fields — on EVERY hit, not merely on the first miss/insert. A
|
||||
/// pan-heavy session hitting the same body's cache entry repeatedly pays
|
||||
/// the ~2.62 MB clone cost per `DeriveWindow` work item, not once per
|
||||
/// body. This was already true pre-T-1184 for the smaller ~1.57 MB
|
||||
/// struct; T-1184 makes the per-hit clone cost ~1.67× larger, not a new
|
||||
/// category of cost. **Not fixed this round** (an `Arc<TerrainAnalysis>`
|
||||
/// refactor — sharing one heap allocation across hits instead of
|
||||
/// deep-copying — is the obvious next step if hit-heavy clone cost ever
|
||||
/// shows up in a profile, but is out of scope for T-1184; flagged for a
|
||||
/// follow-up ticket rather than done speculatively here).
|
||||
///
|
||||
/// The FIRST `DeriveWindow` on a
|
||||
/// body pays the full ~45 ms `run_layer1` cost and populates that body's
|
||||
/// cache entry; EVERY SUBSEQUENT window on the SAME body (any `center`/`n`,
|
||||
/// not just an exact repeat — that narrower case is what
|
||||
@@ -795,18 +833,30 @@ impl TerrainAnalysisCache {
|
||||
}
|
||||
|
||||
/// Look up a cached `(Layer1Output, TerrainAnalysis)` pair for `body_id`,
|
||||
/// re-deriving via `run_layer1` on a miss and inserting the result
|
||||
/// (evicting the LRU entry first if at capacity). Bumps the access clock
|
||||
/// on both a hit and a fresh insert (both are "this body was just used").
|
||||
/// re-deriving via `run_layer1_with_moisture` on a miss and inserting the
|
||||
/// result (evicting the LRU entry first if at capacity). Bumps the access
|
||||
/// clock on both a hit and a fresh insert (both are "this body was just
|
||||
/// used").
|
||||
///
|
||||
/// Returns both halves of `run_layer1`'s output (T-1170 Ruling 4b) — the
|
||||
/// window derive path (`GenWorkItem::DeriveWindow`) needs `Layer1Output`'s
|
||||
/// `RiverNetwork` to know which river edges exist near the requested
|
||||
/// window, in addition to the `TerrainAnalysis` it always needed.
|
||||
///
|
||||
/// `body_params` (T-1184) is `Option` — `None` when the caller has no DB
|
||||
/// row for this body, matching the same "params absent → fall back"
|
||||
/// posture every other `body_params: Option<&BodyParams>` consumer in this
|
||||
/// module already has (`resolve_settlement_morphology_zone`). Falling
|
||||
/// through to `run_layer1`'s body-agnostic moisture default in that case
|
||||
/// is a body-classification-quality concern (which basins read Endorheic
|
||||
/// vs. Overflow), never a correctness one — lake EXTENT never depends on
|
||||
/// moisture (only the elevation-geometry-gated filled-surface comparison
|
||||
/// does; see `district_profile::derive_morphology_zone`'s lake tier).
|
||||
fn get_or_derive(
|
||||
&mut self,
|
||||
body_id: &str,
|
||||
heightmap: &crate::atlas::heightmap::BodyHeightmap,
|
||||
body_params: Option<&BodyParams>,
|
||||
) -> (Layer1Output, TerrainAnalysis) {
|
||||
self.clock += 1;
|
||||
let now = self.clock;
|
||||
@@ -815,7 +865,13 @@ impl TerrainAnalysisCache {
|
||||
return (l1.clone(), ta.clone());
|
||||
}
|
||||
|
||||
let (l1, ta) = crate::atlas::layer1::run_layer1(heightmap);
|
||||
let (l1, ta) = match body_params {
|
||||
Some(params) => crate::atlas::layer1::run_layer1_with_moisture(
|
||||
heightmap,
|
||||
crate::atlas::district_profile::derive_moisture_ceiling_q(params),
|
||||
),
|
||||
None => crate::atlas::layer1::run_layer1(heightmap),
|
||||
};
|
||||
|
||||
if self.entries.len() >= self.capacity && !self.entries.contains_key(body_id) {
|
||||
if let Some(victim) = self
|
||||
@@ -870,7 +926,7 @@ pub(crate) fn resolve_settlement_morphology_zone(
|
||||
let (_l1, ta) = terrain_cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_or_derive(body_id, heightmap);
|
||||
.get_or_derive(body_id, heightmap, Some(params));
|
||||
let climate = ClimateConstants::default();
|
||||
let profile = crate::atlas::district_profile::derive_at_metres(
|
||||
body_seed,
|
||||
@@ -1072,10 +1128,11 @@ fn run_work_item(
|
||||
// river edges exist near this window (T-1170 A2) without a
|
||||
// second drainage pass — the fix for the former
|
||||
// `let (_, ta) = run_layer1(...)` discard (Ruling 4b).
|
||||
let (l1, ta) = terrain_cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_or_derive(body_id, &working);
|
||||
let (l1, ta) = terrain_cache.lock().unwrap().get_or_derive(
|
||||
body_id,
|
||||
&working,
|
||||
Some(body_params),
|
||||
);
|
||||
let climate = ClimateConstants::default();
|
||||
let layer = build_district_window_layer(
|
||||
*body_seed,
|
||||
@@ -1815,11 +1872,11 @@ mod tests {
|
||||
let hm = window_test_hm();
|
||||
|
||||
assert!(!cache.contains("BodyA"));
|
||||
let (l1_first, ta_first) = cache.get_or_derive("BodyA", &hm);
|
||||
let (l1_first, ta_first) = cache.get_or_derive("BodyA", &hm, None);
|
||||
assert_eq!(cache.len(), 1);
|
||||
assert!(cache.contains("BodyA"));
|
||||
|
||||
let (l1_second, ta_second) = cache.get_or_derive("BodyA", &hm);
|
||||
let (l1_second, ta_second) = cache.get_or_derive("BodyA", &hm, None);
|
||||
assert_eq!(
|
||||
cache.len(),
|
||||
1,
|
||||
@@ -1849,16 +1906,16 @@ mod tests {
|
||||
let mut cache = TerrainAnalysisCache::new(2);
|
||||
let hm = window_test_hm();
|
||||
|
||||
cache.get_or_derive("BodyA", &hm);
|
||||
cache.get_or_derive("BodyB", &hm);
|
||||
cache.get_or_derive("BodyA", &hm, None);
|
||||
cache.get_or_derive("BodyB", &hm, None);
|
||||
assert_eq!(cache.len(), 2);
|
||||
|
||||
// Touch BodyA again — it is now the MOST recently used, so BodyB
|
||||
// (untouched since its own insert) is the true LRU victim.
|
||||
cache.get_or_derive("BodyA", &hm);
|
||||
cache.get_or_derive("BodyA", &hm, None);
|
||||
|
||||
// Insert a third body — capacity 2 forces an eviction.
|
||||
cache.get_or_derive("BodyC", &hm);
|
||||
cache.get_or_derive("BodyC", &hm, None);
|
||||
assert_eq!(cache.len(), 2);
|
||||
assert!(
|
||||
cache.contains("BodyA"),
|
||||
|
||||
@@ -178,6 +178,18 @@ pub struct HydrologyResult {
|
||||
pub cliff_edge: Vec<bool>,
|
||||
}
|
||||
|
||||
/// Convert an `i64`-scaled elevation value (as carried on
|
||||
/// [`HydrologyResult::filled_scaled`] and friends) back to the `[0.0, 1.0]`
|
||||
/// normalized fraction the raw heightmap and `sea_level` are expressed in —
|
||||
/// the exact inverse of the `(e as f64 * ELEV_SCALE) as i64` conversion
|
||||
/// `solve()` applies at its own entry point. `pub(crate)` so callers outside
|
||||
/// this module (T-1184: [`crate::atlas::features::TerrainAnalysis::with_hydrology`])
|
||||
/// never need to know or duplicate the scale constant — the module that owns
|
||||
/// the scaling owns the inverse too.
|
||||
pub(crate) fn scaled_to_fraction(scaled: i64) -> f32 {
|
||||
(scaled as f64 / ELEV_SCALE) as f32
|
||||
}
|
||||
|
||||
/// Moisture/climate inputs governing the endorheic-vs-overflow decision.
|
||||
/// Deliberately minimal and explicitly tunable — see the module docs and the
|
||||
/// results doc's "endorheic criterion" section for the rationale and the
|
||||
|
||||
+238
-1
@@ -73,15 +73,74 @@ pub struct Layer1Output {
|
||||
pub survey_basin_dirs: BTreeMap<SurveyCellPos, BasinDirection>,
|
||||
}
|
||||
|
||||
/// Body-wide moisture ceiling fallback for [`run_layer1`]'s hydrology solve
|
||||
/// when no [`crate::atlas::district_profile::BodyParams`] is available to
|
||||
/// derive a real one from (`run_layer1`'s signature is heightmap-only, matching
|
||||
/// `drainage::analyze`'s own "same way it already runs once per body today"
|
||||
/// shape per T-1177's scope). Matches the T-1177 prototype's own
|
||||
/// `ClimateInputs { moisture_q: 55 }` population-survey default (moderate
|
||||
/// hydrosphere, breathable atmosphere) — a reasonable body-agnostic guess,
|
||||
/// used ONLY by [`run_layer1`]'s two-arg form; [`run_layer1_with_moisture`]
|
||||
/// (called by every production site that has real `BodyParams` in scope) never
|
||||
/// reaches this constant.
|
||||
const DEFAULT_HYDROLOGY_MOISTURE_Q: i32 = 55;
|
||||
|
||||
/// Run the Layer-1 topography pipeline for a single body.
|
||||
///
|
||||
/// Returns `(Layer1Output, TerrainAnalysis)`. The `TerrainAnalysis` is carried
|
||||
/// transiently on `CascadeSnapshot.terrain_analysis` so `cascade.rs` can pass
|
||||
/// it to `derive_all_districts` and `build_road_graph` without re-running the
|
||||
/// full D8 drainage pass (T-1044 — eliminates the PERF/TODO re-run).
|
||||
///
|
||||
/// Solves settled-equilibrium hydrology (T-1177/T-1184, D-227 amendment (4))
|
||||
/// once per body as part of this same pass, using
|
||||
/// [`DEFAULT_HYDROLOGY_MOISTURE_Q`] as the body-wide moisture ceiling — this
|
||||
/// two-arg form has no `BodyParams` to derive a real one from. Every
|
||||
/// production call site that DOES have `BodyParams` in scope
|
||||
/// (`cascade::run_cascade_from_heightmap`,
|
||||
/// `gen_queue::TerrainAnalysisCache::get_or_derive`) calls
|
||||
/// [`run_layer1_with_moisture`] instead, so this fallback is only ever
|
||||
/// exercised by call sites (mostly tests) that never had body params to begin
|
||||
/// with — never a silent downgrade of a real value.
|
||||
pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
|
||||
run_layer1_with_moisture(hm, DEFAULT_HYDROLOGY_MOISTURE_Q)
|
||||
}
|
||||
|
||||
/// [`run_layer1`], with the body-wide hydrology moisture ceiling
|
||||
/// (`ClimateInputs::moisture_q`, T-1177) supplied explicitly rather than
|
||||
/// defaulted. Callers with a real [`crate::atlas::district_profile::BodyParams`]
|
||||
/// in scope should derive it via
|
||||
/// [`crate::atlas::district_profile::derive_moisture_ceiling_q`] and pass the
|
||||
/// result here, so the endorheic-vs-overflow basin split reflects the body's
|
||||
/// actual hydrosphere/atmosphere instead of the fallback constant.
|
||||
///
|
||||
/// Determinism (D-010): pure function of `(hm, moisture_q)` — same inputs,
|
||||
/// byte-identical `TerrainAnalysis.hydrology` every time (inherits
|
||||
/// `hydrology_equilibrium::solve`'s own determinism guarantee).
|
||||
pub fn run_layer1_with_moisture(
|
||||
hm: &BodyHeightmap,
|
||||
moisture_q: i32,
|
||||
) -> (Layer1Output, TerrainAnalysis) {
|
||||
let drainage: DrainageResult = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||||
let ta: TerrainAnalysis = TerrainAnalysis::analyze(hm, &drainage);
|
||||
let mut ta: TerrainAnalysis = TerrainAnalysis::analyze(hm, &drainage);
|
||||
|
||||
// T-1184: solve settled-equilibrium hydrology once per body (the
|
||||
// AnalyzeBody cascade populate point, D-227 amendment (4)) and fold the
|
||||
// continuous filled-surface field into this TerrainAnalysis so every
|
||||
// derive-core caller downstream (`derive_at_metres_with_riparian`) can
|
||||
// bilinearly sample it for lake sourcing — mechanism B, D-255(f): a
|
||||
// coarse continuous primitive computed once, sampled fresh at every rung,
|
||||
// never re-solved. `hm.data` (the raw [0,1] elevation this analysis was
|
||||
// built from) is the SAME grid the solver runs on, so the two fields
|
||||
// `with_hydrology` stores are always the correct pairing.
|
||||
let hydrology = crate::atlas::hydrology_equilibrium::solve(
|
||||
&hm.data,
|
||||
hm.width,
|
||||
hm.height,
|
||||
hm.sea_level,
|
||||
crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q },
|
||||
);
|
||||
ta = ta.with_hydrology(&hm.data, &hydrology);
|
||||
|
||||
let raw = features::extract_attractors(hm, &drainage, &ta);
|
||||
let attractors: Vec<GeographicAttractor> = raw
|
||||
@@ -336,4 +395,182 @@ mod tests {
|
||||
let (rivers, _mtn) = attach_feature_names(&o, &names, &[]);
|
||||
assert!(rivers.len() <= names.len());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// T-1184 — hydrology productionization
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Bowl-shaped fixture (high rim, low centre) — the same shape
|
||||
/// `hydrology_equilibrium.rs`'s own `bowl_grid` test fixture uses,
|
||||
/// reproduced here (not imported — that one is `#[cfg(test)]`-private to
|
||||
/// its own module) so `run_layer1`'s hydrology wiring can be exercised
|
||||
/// end-to-end without depending on solver-internal test helpers.
|
||||
/// `sea_level: 0.0` keeps the whole grid land except the filled basin, so
|
||||
/// a resulting `MorphologyZone::Lake` can only be hydrology-sourced, never
|
||||
/// the `ocean_fraction_q` heuristic fallback.
|
||||
fn bowl_hm(w: u32, h: u32, body_id: &str) -> BodyHeightmap {
|
||||
let n = (w * h) as usize;
|
||||
let cx = w as f32 / 2.0;
|
||||
let cy = h as f32 / 2.0;
|
||||
let max_r = cx.min(cy).max(1.0);
|
||||
let data = (0..n)
|
||||
.map(|i| {
|
||||
let r = (i / w as usize) as f32;
|
||||
let c = (i % w as usize) as f32;
|
||||
let d = (((c - cx).powi(2) + (r - cy).powi(2)).sqrt() / max_r).min(1.0);
|
||||
0.1 + d * 0.8
|
||||
})
|
||||
.collect();
|
||||
BodyHeightmap {
|
||||
body_id: body_id.into(),
|
||||
width: w,
|
||||
height: h,
|
||||
data,
|
||||
sea_level: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_layer1_populates_hydrology_on_terrain_analysis() {
|
||||
let h = bowl_hm(64, 32, "BowlBody");
|
||||
let (_o, ta) = run_layer1(&h);
|
||||
let hydro = ta
|
||||
.hydrology
|
||||
.as_ref()
|
||||
.expect("run_layer1 must populate TerrainAnalysis.hydrology (T-1184)");
|
||||
assert_eq!(hydro.elevation.len(), (64 * 32) as usize);
|
||||
assert_eq!(hydro.filled.len(), (64 * 32) as usize);
|
||||
// The bowl centre must be a lake cell: filled strictly exceeds original.
|
||||
let centre_idx = (16 * 64 + 32) as usize; // row 16, col 32 — the bowl centre
|
||||
assert!(
|
||||
hydro.filled[centre_idx] > hydro.elevation[centre_idx],
|
||||
"bowl centre must be filled above its original elevation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_layer1_hydrology_is_deterministic() {
|
||||
let h = bowl_hm(64, 32, "BowlBody");
|
||||
let (_o1, ta1) = run_layer1(&h);
|
||||
let (_o2, ta2) = run_layer1(&h);
|
||||
let h1 = ta1.hydrology.expect("first run must populate hydrology");
|
||||
let h2 = ta2.hydrology.expect("second run must populate hydrology");
|
||||
assert_eq!(
|
||||
h1.elevation, h2.elevation,
|
||||
"D-010: identical inputs must produce byte-identical elevation carry"
|
||||
);
|
||||
assert_eq!(
|
||||
h1.filled, h2.filled,
|
||||
"D-010: identical inputs must produce byte-identical filled-surface field"
|
||||
);
|
||||
}
|
||||
|
||||
/// The D-255(f) mandatory determinism gate: whether hydrology is solved
|
||||
/// with the fallback default moisture (`run_layer1`) or an explicit
|
||||
/// caller-supplied moisture that happens to equal the default
|
||||
/// (`run_layer1_with_moisture`), the two code paths must produce
|
||||
/// byte-identical `TerrainAnalysis.hydrology` output — the "cache-hit
|
||||
/// path == cache-miss path" shape applied to the two entry points that
|
||||
/// stand in for it here (both are genuinely fresh `derive()` calls; T-1184
|
||||
/// has no separate cached-coarser-canvas to compare against yet, since
|
||||
/// that tier is T-1181's rung-0 scope — this test instead pins that
|
||||
/// `run_layer1`'s convenience wrapper and its explicit-moisture sibling
|
||||
/// never silently diverge, which is the property the next ticket's
|
||||
/// resident-cache read will depend on staying true).
|
||||
#[test]
|
||||
fn run_layer1_default_and_explicit_moisture_agree_at_the_default_value() {
|
||||
let h = bowl_hm(64, 32, "BowlBody");
|
||||
let (_o1, ta1) = run_layer1(&h);
|
||||
let (_o2, ta2) = run_layer1_with_moisture(&h, DEFAULT_HYDROLOGY_MOISTURE_Q);
|
||||
let h1 = ta1.hydrology.expect("run_layer1 must populate hydrology");
|
||||
let h2 = ta2
|
||||
.hydrology
|
||||
.expect("run_layer1_with_moisture must populate hydrology");
|
||||
assert_eq!(h1.elevation, h2.elevation);
|
||||
assert_eq!(h1.filled, h2.filled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_layer1_with_moisture_changes_endorheic_split_not_lake_extent() {
|
||||
// T-1184 scope note (ticket text): moisture affects the
|
||||
// endorheic-vs-overflow split only, never lake EXTENT (filled_scaled
|
||||
// is a pure function of elevation/sea_level, moisture-independent).
|
||||
let h = bowl_hm(64, 32, "BowlBody");
|
||||
let (_o_dry, ta_dry) = run_layer1_with_moisture(&h, 0);
|
||||
let (_o_wet, ta_wet) = run_layer1_with_moisture(&h, 100);
|
||||
let hydro_dry = ta_dry.hydrology.expect("dry run must populate hydrology");
|
||||
let hydro_wet = ta_wet.hydrology.expect("wet run must populate hydrology");
|
||||
assert_eq!(
|
||||
hydro_dry.filled, hydro_wet.filled,
|
||||
"lake extent (filled_scaled) must be moisture-independent — only \
|
||||
the endorheic/overflow split may vary with moisture_q"
|
||||
);
|
||||
|
||||
// PR #200 review, Hoshe finding 2: the assertion above only proves
|
||||
// EXTENT is moisture-independent — it says nothing about whether the
|
||||
// endorheic/overflow split (this test's OWN namesake claim) actually
|
||||
// moves at all. `TerrainAnalysis.hydrology` doesn't carry
|
||||
// `BasinOutcome` (only the continuous `elevation`/`filled` fields
|
||||
// `derive_morphology_zone`'s lake gate needs — see `HydrologySample`'s
|
||||
// doc), so re-solve directly via `hydrology_equilibrium::solve` to
|
||||
// reach `Basin::outcome`. `moisture_q: 0` and `moisture_q: 100`
|
||||
// straddle `ENDORHEIC_MOISTURE_CEILING = 60` on either side, so the
|
||||
// SAME basin (same elevation geometry, same `bowl_hm`) must classify
|
||||
// differently — a regression that makes the endorheic split dead code
|
||||
// (e.g. `is_endorheic` always returning `false`, or `moisture_q` never
|
||||
// threading through to it) would leave the `filled` assertion above
|
||||
// green but SHOULD fail here.
|
||||
let dry_result = crate::atlas::hydrology_equilibrium::solve(
|
||||
&h.data,
|
||||
h.width,
|
||||
h.height,
|
||||
h.sea_level,
|
||||
crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 0 },
|
||||
);
|
||||
let wet_result = crate::atlas::hydrology_equilibrium::solve(
|
||||
&h.data,
|
||||
h.width,
|
||||
h.height,
|
||||
h.sea_level,
|
||||
crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 100 },
|
||||
);
|
||||
assert_eq!(
|
||||
dry_result.basins.len(),
|
||||
1,
|
||||
"fixture sanity: bowl_hm(64, 32, ...) must produce exactly one basin"
|
||||
);
|
||||
assert_eq!(
|
||||
wet_result.basins.len(),
|
||||
1,
|
||||
"fixture sanity: same on the wet solve"
|
||||
);
|
||||
let dry_outcome = &dry_result.basins[0].outcome;
|
||||
let wet_outcome = &wet_result.basins[0].outcome;
|
||||
assert_ne!(
|
||||
dry_outcome, wet_outcome,
|
||||
"the SAME basin (identical elevation geometry) must classify \
|
||||
differently at moisture_q=0 vs moisture_q=100 — these straddle \
|
||||
ENDORHEIC_MOISTURE_CEILING=60, so a regression making the \
|
||||
endorheic/overflow split dead code would collapse both to the \
|
||||
same outcome here"
|
||||
);
|
||||
// Non-vacuous on BOTH halves of this test's own name: dry actually
|
||||
// IS Endorheic, wet actually IS Overflow — not just "different from
|
||||
// each other" (which alone couldn't rule out both being some other
|
||||
// unrelated pair of values).
|
||||
assert!(
|
||||
matches!(
|
||||
dry_outcome,
|
||||
crate::atlas::hydrology_equilibrium::BasinOutcome::Endorheic { .. }
|
||||
),
|
||||
"moisture_q=0 (well below the ceiling) must classify Endorheic; got {dry_outcome:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
wet_outcome,
|
||||
crate::atlas::hydrology_equilibrium::BasinOutcome::Overflow { .. }
|
||||
),
|
||||
"moisture_q=100 (well above the ceiling) must classify Overflow; got {wet_outcome:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3830,6 +3830,20 @@ mod tests {
|
||||
assert_eq!(ta_pass1.water_dist, ta_pass2.water_dist);
|
||||
assert_eq!(ta_pass1.slope_deg, ta_pass2.slope_deg);
|
||||
assert_eq!(ta_pass1.elev_pct, ta_pass2.elev_pct);
|
||||
// T-1184: two independent hydrology solves (each run_layer1 call
|
||||
// solves fresh — no shared HydrologyResult) must also agree
|
||||
// byte-for-byte, extending this test's own "weakest link in the
|
||||
// determinism chain" rationale to the newest field on TerrainAnalysis.
|
||||
let hydro1 = ta_pass1
|
||||
.hydrology
|
||||
.as_ref()
|
||||
.expect("run_layer1 must populate hydrology");
|
||||
let hydro2 = ta_pass2
|
||||
.hydrology
|
||||
.as_ref()
|
||||
.expect("run_layer1 must populate hydrology");
|
||||
assert_eq!(hydro1.elevation, hydro2.elevation);
|
||||
assert_eq!(hydro1.filled, hydro2.filled);
|
||||
|
||||
// Now the FULL path: pack a DistrictWindowLayer from each independent
|
||||
// TerrainAnalysis and confirm the complete served payload agrees.
|
||||
|
||||
@@ -1654,9 +1654,23 @@ mod tests {
|
||||
);
|
||||
|
||||
// Ground truth: derive_at_metres at the SAME settlement world metres,
|
||||
// via the terrain cache's own re-derive path (run_layer1) so the
|
||||
// TerrainAnalysis is byte-identical to what the resolver used.
|
||||
let (_l1, ta) = crate::atlas::layer1::run_layer1(&heightmap);
|
||||
// via the terrain cache's own re-derive path so the TerrainAnalysis is
|
||||
// byte-identical to what the resolver used — INCLUDING which hydrology
|
||||
// moisture ceiling gets solved with. `resolve_settlement_morphology_zone`
|
||||
// threads `Some(&body_params)` into `get_or_derive` (T-1184), which
|
||||
// derives the body's real moisture ceiling rather than falling back to
|
||||
// `run_layer1`'s body-agnostic default; this ground truth must use the
|
||||
// SAME `run_layer1_with_moisture` path (not bare `run_layer1`) or the
|
||||
// two `TerrainAnalysis`es solve hydrology at different moisture inputs
|
||||
// — moot for this fixture's LAKE EXTENT (moisture-independent, see
|
||||
// `run_layer1_with_moisture_changes_endorheic_split_not_lake_extent`),
|
||||
// but the two paths must agree by construction, not by coincidence of
|
||||
// this specific body having no moisture-sensitive basin near the
|
||||
// sampled position.
|
||||
let (_l1, ta) = crate::atlas::layer1::run_layer1_with_moisture(
|
||||
&heightmap,
|
||||
district_profile::derive_moisture_ceiling_q(&body_params),
|
||||
);
|
||||
let expected = district_profile::derive_at_metres(
|
||||
body_seed,
|
||||
"TestBody",
|
||||
|
||||
@@ -753,6 +753,7 @@ fn law_glaciation_grade_0_never_produces_fjord() {
|
||||
60,
|
||||
ocean_q,
|
||||
55,
|
||||
false, // T-1184: no hydrology solve in this synthetic-gate sweep
|
||||
);
|
||||
assert_ne!(
|
||||
zone,
|
||||
@@ -776,6 +777,7 @@ fn law_glaciation_grade_1_never_produces_fjord() {
|
||||
60,
|
||||
ocean_q,
|
||||
55,
|
||||
false, // T-1184: no hydrology solve in this synthetic-gate sweep
|
||||
);
|
||||
assert_ne!(
|
||||
zone,
|
||||
@@ -798,6 +800,7 @@ fn law_glaciation_grade_2_enables_fjord_with_correct_params() {
|
||||
60,
|
||||
25, // coastal
|
||||
55,
|
||||
false, // T-1184: no hydrology solve in this synthetic-gate test
|
||||
);
|
||||
assert_eq!(
|
||||
zone,
|
||||
@@ -817,6 +820,7 @@ fn law_glaciation_grade_4_also_enables_fjord() {
|
||||
60,
|
||||
25,
|
||||
55,
|
||||
false, // T-1184: no hydrology solve in this synthetic-gate test
|
||||
);
|
||||
assert_eq!(
|
||||
zone,
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"morphology_zones": 9,
|
||||
"vegetation_classes": 4,
|
||||
"terrain_materials": 4,
|
||||
"voxel_relief_m": 27,
|
||||
"voxel_relief_m": 28,
|
||||
"micro_habitat_distinct": 2
|
||||
},
|
||||
"coherence": {
|
||||
|
||||
@@ -916,5 +916,107 @@
|
||||
"temperature_dc": 480,
|
||||
"moisture_q": 47,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
"label": "lake_bowl/lake_centre",
|
||||
"rung": "district",
|
||||
"wx_m": 20015086,
|
||||
"wy_m": 158849,
|
||||
"min_wl_m": 4096,
|
||||
"morphology": 1,
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 3,
|
||||
"slope_q": 4,
|
||||
"elev_q": 0,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 62,
|
||||
"moisture_q": 56,
|
||||
"vegetation": 6
|
||||
},
|
||||
{
|
||||
"label": "lake_bowl/lake_centre",
|
||||
"rung": "quarter",
|
||||
"wx_m": 20015086,
|
||||
"wy_m": 158849,
|
||||
"min_wl_m": 1024,
|
||||
"morphology": 1,
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 3,
|
||||
"slope_q": 5,
|
||||
"elev_q": 0,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 62,
|
||||
"moisture_q": 56,
|
||||
"vegetation": 6
|
||||
},
|
||||
{
|
||||
"label": "lake_bowl/lake_centre",
|
||||
"rung": "region",
|
||||
"wx_m": 20015086,
|
||||
"wy_m": 158849,
|
||||
"min_wl_m": 0,
|
||||
"morphology": 1,
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 3,
|
||||
"slope_q": 0,
|
||||
"elev_q": 0,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 62,
|
||||
"moisture_q": 56,
|
||||
"vegetation": 6
|
||||
},
|
||||
{
|
||||
"label": "lake_bowl/lake_rim",
|
||||
"rung": "district",
|
||||
"wx_m": 1250942,
|
||||
"wy_m": 158849,
|
||||
"min_wl_m": 4096,
|
||||
"morphology": 8,
|
||||
"tectonic": 0,
|
||||
"glaciation": 2,
|
||||
"precipitation": 1,
|
||||
"slope_q": 0,
|
||||
"elev_q": 71,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": -120,
|
||||
"moisture_q": 55,
|
||||
"vegetation": 1
|
||||
},
|
||||
{
|
||||
"label": "lake_bowl/lake_rim",
|
||||
"rung": "quarter",
|
||||
"wx_m": 1250942,
|
||||
"wy_m": 158849,
|
||||
"min_wl_m": 1024,
|
||||
"morphology": 8,
|
||||
"tectonic": 0,
|
||||
"glaciation": 2,
|
||||
"precipitation": 1,
|
||||
"slope_q": 2,
|
||||
"elev_q": 75,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": -120,
|
||||
"moisture_q": 54,
|
||||
"vegetation": 1
|
||||
},
|
||||
{
|
||||
"label": "lake_bowl/lake_rim",
|
||||
"rung": "region",
|
||||
"wx_m": 1250942,
|
||||
"wy_m": 158849,
|
||||
"min_wl_m": 0,
|
||||
"morphology": 8,
|
||||
"tectonic": 0,
|
||||
"glaciation": 2,
|
||||
"precipitation": 1,
|
||||
"slope_q": 0,
|
||||
"elev_q": 70,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": -120,
|
||||
"moisture_q": 54,
|
||||
"vegetation": 1
|
||||
}
|
||||
]
|
||||
|
||||
@@ -28,17 +28,38 @@
|
||||
//! original body's rows (never interleaved), so the original rows stay
|
||||
//! byte-identical across the I4 regen — see `body_sweep_samples`'s doc.
|
||||
//!
|
||||
//! **T-1184 fixture fidelity fix (lead review, post-hydrology-productionization):**
|
||||
//! `sample_ta` now builds its `TerrainAnalysis` via `run_layer1_with_moisture`
|
||||
//! (solving settled hydrology and attaching it, exactly what
|
||||
//! `TerrainAnalysisCache::get_or_derive` does in production once `body_params`
|
||||
//! is `Some` — the ONLY path `derive_window_cell`'s callers reach post-T-1184),
|
||||
//! not the bare `drainage::analyze` + `TerrainAnalysis::analyze` construction
|
||||
//! this file used before, which silently pinned the fallback heuristic path
|
||||
//! (`ta.hydrology == None`) that production no longer takes. Each body in the
|
||||
//! sweep gets its OWN `ta`, solved with ITS OWN moisture ceiling
|
||||
//! (`derive_moisture_ceiling_q(params)`) — mirrors production's per-`body_id`
|
||||
//! `TerrainAnalysisCache` keying, since hydrology's endorheic/overflow split
|
||||
//! (not lake EXTENT, which is moisture-independent) depends on the body's own
|
||||
//! params. A FOURTH body (`lake_bowl`, a dedicated bowl-shaped heightmap with a
|
||||
//! real filled basin at its centre) is appended for exactly this reason: the
|
||||
//! original three bodies' `sample_hm()` gradient-plus-ripple heightmap
|
||||
//! produces ZERO filled basins anywhere (verified before this fix — see the
|
||||
//! coordinator's review), so without a dedicated lake fixture the hydrology
|
||||
//! lake-sourcing gate would be wired but never pinned by any golden row.
|
||||
//!
|
||||
//! Run: `cargo test --test window_derivation_golden`
|
||||
//! Regenerate: `UPDATE_GOLDEN=1 cargo test --test window_derivation_golden`
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use settled_reach_server::atlas::district_profile::{
|
||||
derive_at_metres, derive_orbital_at_metres, BodyParams, ClimateConstants,
|
||||
derive_at_metres, derive_moisture_ceiling_q, derive_orbital_at_metres, BodyParams,
|
||||
ClimateConstants,
|
||||
};
|
||||
use settled_reach_server::atlas::drainage;
|
||||
use settled_reach_server::atlas::features::TerrainAnalysis;
|
||||
use settled_reach_server::atlas::heightmap::BodyHeightmap;
|
||||
use settled_reach_server::atlas::layer1::run_layer1_with_moisture;
|
||||
use settled_reach_server::atlas::river_course;
|
||||
use settled_reach_server::atlas::scale;
|
||||
use settled_reach_server::seed::{SeedChain, SeedDomain};
|
||||
@@ -101,9 +122,22 @@ fn sample_hm() -> BodyHeightmap {
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_ta(hm: &BodyHeightmap) -> TerrainAnalysis {
|
||||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||||
TerrainAnalysis::analyze(hm, &dr)
|
||||
/// Builds a `TerrainAnalysis` the PRODUCTION way (T-1184 fixture-fidelity
|
||||
/// fix): `run_layer1_with_moisture`, solving settled hydrology and attaching
|
||||
/// it via `with_hydrology`, using the SAME moisture ceiling
|
||||
/// `TerrainAnalysisCache::get_or_derive` derives from `body_params` in
|
||||
/// production (`derive_moisture_ceiling_q(params)`) — not the bare
|
||||
/// `drainage::analyze` + `TerrainAnalysis::analyze` construction this
|
||||
/// function used before, which left `ta.hydrology == None` and silently
|
||||
/// pinned the fallback heuristic path production no longer takes once
|
||||
/// `body_params` is `Some` (the only case `derive_window_cell`'s callers
|
||||
/// reach: `resolve_settlement_morphology_zone` short-circuits to `None` on a
|
||||
/// missing `body_params`, and `DeriveWindow`'s `body_params` field is a
|
||||
/// non-`Option` `Box<BodyParams>`).
|
||||
fn sample_ta(hm: &BodyHeightmap, params: &BodyParams) -> TerrainAnalysis {
|
||||
let moisture_q = derive_moisture_ceiling_q(params);
|
||||
let (_l1, ta) = run_layer1_with_moisture(hm, moisture_q);
|
||||
ta
|
||||
}
|
||||
|
||||
fn sample_params() -> BodyParams {
|
||||
@@ -153,6 +187,88 @@ fn volcanic_coast_params() -> BodyParams {
|
||||
}
|
||||
}
|
||||
|
||||
/// T-1184 (lead review fixture-fidelity fix): a dedicated bowl-shaped
|
||||
/// heightmap — high rim (0.9), low centre (0.15), same 128×64 dims as
|
||||
/// `sample_hm()` — so the settled-hydrology solver produces a real, single
|
||||
/// filled lake basin at the bowl's geometric centre. `sea_level = 0.05`
|
||||
/// (well below the bowl's own lowest point, 0.15) keeps the ENTIRE grid dry
|
||||
/// by the raw heightmap threshold, so a `Lake` verdict at the centre can only
|
||||
/// be hydrology-sourced, never the pre-existing `ocean_fraction_q` heuristic.
|
||||
///
|
||||
/// Needed because NONE of `sample_hm()`/`airless_dry_params()`'s/
|
||||
/// `volcanic_coast_params()`'s shared gradient-plus-ripple heightmap ever
|
||||
/// forms an enclosed depression (verified: `solve()` returns zero basins on
|
||||
/// it at any moisture input) — without this fixture, T-1184's hydrology
|
||||
/// lake-sourcing gate would be wired into the derive core but pinned by no
|
||||
/// golden row at all, exactly the "unpinned production path" gap the review
|
||||
/// flagged.
|
||||
fn lake_bowl_hm() -> BodyHeightmap {
|
||||
let (w, h) = (128u32, 64u32);
|
||||
let n = (w * h) as usize;
|
||||
let cx = w as f32 / 2.0;
|
||||
let cy = h as f32 / 2.0;
|
||||
let max_r = cx.min(cy);
|
||||
let data = (0..n)
|
||||
.map(|i| {
|
||||
let r = (i / w as usize) as f32;
|
||||
let c = (i % w as usize) as f32;
|
||||
let d = (((c - cx).powi(2) + (r - cy).powi(2)).sqrt() / max_r).min(1.0);
|
||||
0.15 + d * 0.75
|
||||
})
|
||||
.collect();
|
||||
BodyHeightmap {
|
||||
body_id: "golden_body_lake_bowl".into(),
|
||||
width: w,
|
||||
height: h,
|
||||
data,
|
||||
sea_level: 0.05,
|
||||
}
|
||||
}
|
||||
|
||||
/// Params for the lake-bowl body — ocean/breathable/temperate at Earth
|
||||
/// radius (matches `sample_params()`'s class so the lake row exercises the
|
||||
/// same moisture ceiling as the original body, isolating the bowl geometry
|
||||
/// as the one variable).
|
||||
fn lake_bowl_params() -> BodyParams {
|
||||
BodyParams {
|
||||
hydrosphere: Some("ocean".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
planet_class: Some("temperate".into()),
|
||||
body_radius_km: Some(6371.0),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel → world-metres, reproducing `district_profile::pixel_to_world_m`'s
|
||||
/// formula (that function is `pub(crate)`, unreachable from this integration
|
||||
/// test) — the SAME mapping `derive_at_metres`/`derive_orbital_at_metres`
|
||||
/// compute inline for their own `(px, py)` derivation.
|
||||
fn golden_pixel_to_world_m(px: f64, py: f64, w: u32, h: u32, radius_km: f64) -> (f64, f64) {
|
||||
let wx = px / w as f64 * (std::f64::consts::TAU * radius_km * 1000.0);
|
||||
let lat_frac = py / (h as f64 - 1.0) - 0.5;
|
||||
(wx, lat_frac * (std::f64::consts::PI * radius_km * 1000.0))
|
||||
}
|
||||
|
||||
/// Sweep positions for the lake-bowl body — just the bowl centre (world
|
||||
/// metres for pixel (64, 32) at `body_radius_km = 6371.0`) plus a rim point
|
||||
/// clearly outside the basin, so the golden also pins the "near but not in a
|
||||
/// lake" boundary case at this fixture. Computed once via
|
||||
/// `golden_pixel_to_world_m` so the position always matches the ACTUAL bowl
|
||||
/// centre even if the bowl dimensions ever change, rather than a hand-copied
|
||||
/// literal.
|
||||
fn lake_bowl_sweep_positions() -> Vec<(&'static str, f64, f64)> {
|
||||
let (w, h) = (128u32, 64u32);
|
||||
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
|
||||
let (centre_x, centre_y) = golden_pixel_to_world_m(cx, cy, w, h, 6371.0);
|
||||
// A rim pixel (near the bowl edge, outside the filled basin) — same row,
|
||||
// near the left edge of the bowl's radius.
|
||||
let (rim_x, rim_y) = golden_pixel_to_world_m(4.0, cy, w, h, 6371.0);
|
||||
vec![
|
||||
("lake_centre", centre_x, centre_y),
|
||||
("lake_rim", rim_x, rim_y),
|
||||
]
|
||||
}
|
||||
|
||||
/// Fixed sweep positions (world metres from origin) — a handful of points
|
||||
/// spanning a coastal stretch (per the heightmap's ripple) plus a couple of
|
||||
/// clearly inland/high-latitude points, so the golden exercises coast warp,
|
||||
@@ -244,6 +360,12 @@ const QUARTER_MIN_WL_M: f64 = 1_024.0;
|
||||
/// body, non-empty for the I4 additions) is prepended to each row's `label`
|
||||
/// so multi-body output stays distinguishable without a new struct field
|
||||
/// (see [`GoldenSample`]'s doc on why no `body` field was added).
|
||||
///
|
||||
/// `positions` is now a parameter (T-1184) rather than always calling
|
||||
/// `sweep_positions()` internally — the lake-bowl body needs its OWN sweep
|
||||
/// (`lake_bowl_sweep_positions()`, a position that's actually inside its
|
||||
/// basin), not the original three bodies' coastal/inland/river sweep, which
|
||||
/// means nothing on the bowl fixture's geometry.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn body_sweep_samples(
|
||||
label_prefix: &str,
|
||||
@@ -252,9 +374,10 @@ fn body_sweep_samples(
|
||||
params: &BodyParams,
|
||||
ta: &TerrainAnalysis,
|
||||
climate: &ClimateConstants,
|
||||
positions: &[(&'static str, f64, f64)],
|
||||
) -> Vec<GoldenSample> {
|
||||
let mut out = Vec::new();
|
||||
for (label, wx, wy) in sweep_positions() {
|
||||
for &(label, wx, wy) in positions {
|
||||
let label = format!("{label_prefix}{label}");
|
||||
out.push(derive_golden_sample(
|
||||
&label,
|
||||
@@ -290,46 +413,93 @@ fn body_sweep_samples(
|
||||
}
|
||||
|
||||
/// Build the full golden sample set: the ORIGINAL temperate/ocean/breathable
|
||||
/// body's sweep first (byte-identical inputs to the pre-I4 `golden_samples`
|
||||
/// — same seed, same `body_id`, same unprefixed labels, so its rows are
|
||||
/// byte-identical in the regenerated fixture), THEN the two I4 body rows
|
||||
/// appended after (never interleaved) so the diff against the pre-I4 golden
|
||||
/// is a pure append, not a reshuffle.
|
||||
/// body's sweep first (byte-identical POSITIONS/seed/label to the pre-I4
|
||||
/// `golden_samples` — the VALUES move under the T-1184 fixture-fidelity fix,
|
||||
/// see below), THEN the I4 body rows, THEN the T-1184 lake-bowl body
|
||||
/// (appended last, never interleaved) so the diff against the pre-fix golden
|
||||
/// is a pure value-update-plus-append, not a reshuffle.
|
||||
///
|
||||
/// **T-1184: each body now gets its OWN `TerrainAnalysis`**, built via
|
||||
/// `sample_ta(&hm, params)` — production-faithful (`run_layer1_with_moisture`
|
||||
/// solving hydrology with THIS body's own moisture ceiling), mirroring
|
||||
/// `TerrainAnalysisCache`'s real per-`body_id` keying. Before this fix all
|
||||
/// three bodies shared ONE `ta` built from ONE call with no hydrology
|
||||
/// attached at all — cheap to share when `TerrainAnalysis` was a pure
|
||||
/// function of the heightmap alone, no longer correct now that hydrology's
|
||||
/// endorheic/overflow split depends on the body's own `BodyParams`. This
|
||||
/// doesn't change any EXISTING row's values on `sample_hm()` bodies (that
|
||||
/// fixture has zero filled basins at any moisture input — hydrology only
|
||||
/// changes the fallback-vs-sourced CODE PATH taken, not the OUTPUT, when
|
||||
/// there's nothing to source) but is required for correctness going forward
|
||||
/// and is what the lake-bowl body's own per-body `ta` depends on.
|
||||
fn golden_samples() -> Vec<GoldenSample> {
|
||||
let hm = sample_hm();
|
||||
let ta = sample_ta(&hm);
|
||||
let climate = ClimateConstants::default();
|
||||
|
||||
let mut out = Vec::new();
|
||||
|
||||
// Original body — UNCHANGED inputs from pre-I4 (T-1162 initial landing).
|
||||
// Original body — same seed/body_id/labels/positions as pre-T-1184; `ta`
|
||||
// is now built production-faithfully (see doc above) but this fixture has
|
||||
// no basins, so the row VALUES are unaffected.
|
||||
let hm = sample_hm();
|
||||
let params = sample_params();
|
||||
let ta = sample_ta(&hm, ¶ms);
|
||||
out.extend(body_sweep_samples(
|
||||
"",
|
||||
SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7),
|
||||
"golden_body",
|
||||
&sample_params(),
|
||||
¶ms,
|
||||
&ta,
|
||||
&climate,
|
||||
&sweep_positions(),
|
||||
));
|
||||
|
||||
// I4 addition 1: airless/dry — ceiling_q == 0 vegetation short-circuit.
|
||||
// Same heightmap (sample_hm()) as the original body, but its OWN ta
|
||||
// (moisture ceiling for an airless/dry body is 0, not the ocean body's
|
||||
// moisture — matters for endorheic/overflow classification even though,
|
||||
// again, this fixture has no basins to classify).
|
||||
let airless_params = airless_dry_params();
|
||||
let airless_ta = sample_ta(&hm, &airless_params);
|
||||
out.extend(body_sweep_samples(
|
||||
"airless_dry/",
|
||||
SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 8),
|
||||
"golden_body_airless_dry",
|
||||
&airless_dry_params(),
|
||||
&ta,
|
||||
&airless_params,
|
||||
&airless_ta,
|
||||
&climate,
|
||||
&sweep_positions(),
|
||||
));
|
||||
|
||||
// I4 addition 2: volcanic/high-tectonic coast — ridged warp, wide scatter_floor.
|
||||
let volcanic_params = volcanic_coast_params();
|
||||
let volcanic_ta = sample_ta(&hm, &volcanic_params);
|
||||
out.extend(body_sweep_samples(
|
||||
"volcanic_coast/",
|
||||
SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 9),
|
||||
"golden_body_volcanic_coast",
|
||||
&volcanic_coast_params(),
|
||||
&ta,
|
||||
&volcanic_params,
|
||||
&volcanic_ta,
|
||||
&climate,
|
||||
&sweep_positions(),
|
||||
));
|
||||
|
||||
// T-1184 addition: lake-bowl body — a dedicated heightmap WITH a real
|
||||
// filled basin, so the settled-hydrology lake-sourcing gate this ticket
|
||||
// adds is pinned by at least one golden row (the original three bodies'
|
||||
// shared heightmap has zero basins at any sampled position — see this
|
||||
// function's and `lake_bowl_hm`'s docs). Own heightmap, own params, own
|
||||
// seed, own sweep (the bowl centre + a rim point, not the coastal/inland
|
||||
// positions that mean nothing on this fixture's geometry).
|
||||
let lake_hm = lake_bowl_hm();
|
||||
let lake_params = lake_bowl_params();
|
||||
let lake_ta = sample_ta(&lake_hm, &lake_params);
|
||||
out.extend(body_sweep_samples(
|
||||
"lake_bowl/",
|
||||
SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 10),
|
||||
"golden_body_lake_bowl",
|
||||
&lake_params,
|
||||
&lake_ta,
|
||||
&climate,
|
||||
&lake_bowl_sweep_positions(),
|
||||
));
|
||||
|
||||
out
|
||||
@@ -475,8 +645,8 @@ struct GoldenCourseSample {
|
||||
|
||||
fn river_course_golden_samples() -> Vec<GoldenCourseSample> {
|
||||
let hm = sample_hm();
|
||||
let ta = sample_ta(&hm);
|
||||
let params = sample_params();
|
||||
let ta = sample_ta(&hm, ¶ms);
|
||||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7);
|
||||
|
||||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||||
|
||||
Reference in New Issue
Block a user