diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index cccc50ebd..46c04a813 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -184,3 +184,77 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1030', 'status', 'backlog', 'in_progress', NULL, '2026-06-08 14:59:47', '2026-06-08 14:59:47', '2026-06-08 14:59:47', NULL, 'fb4b7f7f6a68990a7e917eb3078c7054', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1030', 'status', 'in_progress', 'review', NULL, '2026-06-08 15:16:48', '2026-06-08 15:16:48', '2026-06-08 15:16:48', NULL, 'c8cc30e7389c1a90e006fd35ccc435d5', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1030', 'status', 'review', 'done', NULL, '2026-06-08 15:34:04', '2026-06-08 15:34:04', '2026-06-08 15:34:04', NULL, '46641ba2f664fe87e24237814c56afff', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1031', 'status', 'backlog', 'in_progress', NULL, '2026-06-08 15:34:33', '2026-06-08 15:34:33', '2026-06-08 15:34:33', NULL, '61cceb69a98c51899481bc3fed3b0cb0', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1033', 'description', 'Rewrite derive_temperature_c (region_profile.rs) to the D-240 planet_class temperature-envelope model: drop ALL orbit/star physics (derive_distance_au, the luminosity() lookup + [star_luminosity] table, orbital_period_days, spectral_class, star_type as inputs). Temperature base = planet_class envelope (source-canonical in climate_constants.toml); latitude maps across it (pole=cold end, equator=warm end); atmosphere/hydrosphere/elevation modulate within bounds; the body seed adds a small bounded nudge for variety. Strip the now-dead orbit/star fields from BodyParams + BodyParamsReader + the bodies-table read (audit 2026-06-08 confirmed derive_temperature_c is the only consumer). Then regenerate the T-1031 golden so it pins correct temps. Blocks T-974 completion.', 'Rewrite derive_temperature_c (region_profile.rs) to the D-240 planet_class temperature-envelope model: drop ALL orbit/star physics (derive_distance_au, the luminosity() lookup + [star_luminosity] table, orbital_period_days, spectral_class, star_type as inputs). Temperature base = planet_class envelope (source-canonical in climate_constants.toml); latitude maps across it (pole=cold end, equator=warm end); atmosphere/hydrosphere/elevation modulate within bounds; the body seed adds a small bounded nudge for variety. Strip the now-dead orbit/star fields from BodyParams + BodyParamsReader + the bodies-table read (audit 2026-06-08 confirmed derive_temperature_c is the only consumer). Then regenerate the T-1031 golden so it pins correct temps. Blocks T-974 completion. + +Approved first-pass planet_class temperature envelope (°C, pole end → equator end). Tunable in server/data/climate_constants.toml. Parse cold_*/hot_*/warm_* prefixes to shift the band; sane default for unknown classes. + +| planet_class | band (pole → equator) | +|---|---| +| frozen / ice | -90 → -25 | +| boreal | -35 → 12 | +| cold_arid | -40 → 20 | +| temperate / oceanic | -12 → 28 | +| subtropical / warm_ocean | 2 → 34 | +| tropical | 16 → 40 | +| arid | -5 → 45 | +| hot_arid | 20 → 58 | +| volcanic / geothermal | 30 → 90 (special-cased) | +| barren (airless / atmosphere=none) | none (climate branch absent, per D-239 §2) | + +Latitude maps the region linearly across the band (|lat|/90). Atmosphere greenhouse shifts toward the warm end + sets diurnal swing; elevation lapse pulls highlands toward the cold end; clamp to the band. Seed nudge: a few °C of deterministic per-body variation, clamped within the band.', NULL, '2026-06-08 16:06:37', '2026-06-08 16:06:37', '2026-06-08 16:06:37', NULL, 'cf12b3fc266a7fce22de0fdbfdb5c27e', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1033', 'status', 'backlog', 'in_progress', NULL, '2026-06-08 16:09:59', '2026-06-08 16:09:59', '2026-06-08 16:09:59', NULL, '7abc69e2d2b79379657aa4e01088c56d', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1033', 'status', 'in_progress', 'review', NULL, '2026-06-08 16:30:14', '2026-06-08 16:30:14', '2026-06-08 16:30:14', NULL, 'c40e12cde396da8a36710c912551a7f6', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1033', 'status', 'review', 'done', NULL, '2026-06-08 17:17:58', '2026-06-08 17:17:58', '2026-06-08 17:17:58', NULL, '6f492a7bb9e83b817492420ada0fe5c6', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1031', 'description', 'D-239 sections 1,8,9. Golden-seed regression for the binding laws (drainage monotonicity, lithology->landform, glaciation->form with no glacial-U on GlaciationGrade 0, climate->vegetation no-skip + riparian band), per-family <5ms/chunk budget assertion, and the validation-case bodies: Kallast=alluvial plain/Soil, Velen=tidal-flat/dune coast (VERIFY it has a moon param for the tidal term first), Cygni B=volcanic immature drainage, Ross 128=ocean island terrain, Gruenfeld=marginal Gravel/Scrub. If a body reads wrong, fix its params — never patch the derivation (D-239 section 1). Blocked by the family generators. + +--- +Note from T-1028 review (Tyre, 2026-06-08): VoxelCache LRU eviction is an O(capacity) linear scan for the minimum access_gen (default cap 8192). Latent — at the D-239 §10 ~2.2-4.2 ms/chunk budget (4096 voxels) an 8k scan is measurable noise, not a blocker. If it shows up in this ticket''s profiling, the fix is a secondary BTreeMap<(access_gen, CacheKey), ()> eviction index (stays D-010-compliant). Scope before the budget test runs.', 'D-239 sections 1,8,9. Golden-seed regression for the binding laws (drainage monotonicity, lithology->landform, glaciation->form with no glacial-U on GlaciationGrade 0, climate->vegetation no-skip + riparian band), per-family <5ms/chunk budget assertion, and the validation-case bodies: Kallast=alluvial plain/Soil, Velen=tidal-flat/dune coast (VERIFY it has a moon param for the tidal term first), Cygni B=volcanic immature drainage, Ross 128=ocean island terrain, Gruenfeld=marginal Gravel/Scrub. If a body reads wrong, fix its params — never patch the derivation (D-239 section 1). Blocked by the family generators. + +--- +Note from T-1028 review (Tyre, 2026-06-08): VoxelCache LRU eviction is an O(capacity) linear scan for the minimum access_gen (default cap 8192). Latent — at the D-239 §10 ~2.2-4.2 ms/chunk budget (4096 voxels) an 8k scan is measurable noise, not a blocker. If it shows up in this ticket''s profiling, the fix is a secondary BTreeMap<(access_gen, CacheKey), ()> eviction index (stays D-010-compliant). Scope before the budget test runs. + +--- +RESUME NOTE (2026-06-08, context reset). Where T-974 stands and how to finish it. + +STATUS: Epic T-974 (Atlas-to-tile derivation) is ~done. Merged this session: +T-1023/1024/1025/1026/1027/1028/1029/1030/1032 + T-1033 (class-envelope temperature, +D-240). Only T-1031 (this ticket) + T-1034 remain to close T-974. + +D-240 (just landed): registry orbit/star data is non-canonical placeholder; temperature +derives from planet_class envelope + atmosphere + hydrosphere(maritime moderation) + +seed — NO orbit/star inputs. This FIXED the +356C "temperate" worlds. BodyParams was +stripped of orbital_period_days / spectral_class / star_type / axial_tilt_deg. + +T-1031 IS PARKED in a worktree at .worktrees/verification-harness (branch +verification-harness), created BEFORE T-1033. The harness (server/tests/derivation_harness.rs) +is built: golden-seed determinism + §8 law sweeps + per-family budget + validation bodies +(Kallast pass; Velen/Gruenfeld skipped-not-in-wiki). It also made derive_morphology_zone pub. + +TO FINISH T-1031 (3 steps): +1. Rebase/merge current main into the verification-harness worktree. It will then FAIL TO + COMPILE: the harness test fixtures construct BodyParams with the now-removed fields + (orbital_period_days/spectral_class/star_type/axial_tilt_deg) — strip those from the + fixtures. Also re-check any validation-body temperature assertions against the NEW + class-envelope temps (the harness previously noted Marevna -56C as a param issue — that + may now read differently). +2. Generate the golden against the CORRECTED temps: from the worktree, + `cd server && UPDATE_GOLDEN=1 cargo test --test derivation_harness -- golden_seed_determinism_regression` + then commit tests/golden/derivation_harness.json + the harness + the region_profile.rs + pub-visibility change together. +3. Lead verify (cargo test/clippy -D warnings/fmt — the push gate runs all), then /pr-review + (Hoshe+Tyre) -> merge -> mark T-1031 done -> T-974 CLOSES. + +ALSO PENDING (separate, fast-follow): +- T-1034 (high, under T-974): derive_moisture_q hydrosphere vocab mismatch — expects ''ocean'' + but DB uses ''liquid_water'' (175 bodies) -> wrong moisture/precip/glaciation/vegetation. + The T-1033 [hydrosphere_maritime] table in climate_constants.toml has the real vocab as a + reference. NOTE: this is now important (water worlds get right temp, wrong moisture). +- Flaky test (no ticket yet, offer to file): voice::lookup::cache_miss_returns_base_text + intermittently fails the full-suite push gate under parallelism (passes in isolation; + unrelated to climate work). CI-hygiene. + +WORKFLOW REMINDERS for the resumer: lead drives all git; agents run lean (don''t pre-run +fmt/clippy/test — the pre-push gate enforces them + cargo test now); review caught real +bugs on EVERY PR this session (hollow tests, a cache-coherence bug hidden by a tautological +test, the +356C climate bug) — keep reviewing + verifying agent output, don''t trust "all green".', NULL, '2026-06-08 17:22:10', '2026-06-08 17:22:10', '2026-06-08 17:22:10', NULL, 'e497455bce9ece94481dcc060d7db1c0', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 00e6e495f..3ed2ef98f 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -552,3 +552,130 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1030', 'task', 'T-974', 'Scattered transient freeze/snow model', 'D-239 section 3. Phase transition as a band with deterministic spatially-coherent scatter (clustered patches, never per-tile dice): freshwater (lakes/rivers) +5 to -10 C district mean (the +5 is night-frost reaching the coldest tiles); sea ice own lower/wider band (~-2 onset, pack-ice sheets+leads pattern); snow (land) moisture-gated. TRANSIENT in the marginal band — forms in the cold phase, melts in the warm phase (day/night, season) -> time-of-day passability shifts. The transient/clock-bound portion is a forward contract to Q-105 (region seasonal/clock state); the static scattered model is buildable now. Blocked by climate-derived fields.', 'in_progress', 'medium', NULL, 'server', 'D-239', '2026-06-07 10:34:11', '2026-06-08 14:59:47', NULL, 'eb3e8c2431e2500a0a9a135881a4596f', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1030', 'task', 'T-974', 'Scattered transient freeze/snow model', 'D-239 section 3. Phase transition as a band with deterministic spatially-coherent scatter (clustered patches, never per-tile dice): freshwater (lakes/rivers) +5 to -10 C district mean (the +5 is night-frost reaching the coldest tiles); sea ice own lower/wider band (~-2 onset, pack-ice sheets+leads pattern); snow (land) moisture-gated. TRANSIENT in the marginal band — forms in the cold phase, melts in the warm phase (day/night, season) -> time-of-day passability shifts. The transient/clock-bound portion is a forward contract to Q-105 (region seasonal/clock state); the static scattered model is buildable now. Blocked by climate-derived fields.', 'review', 'medium', NULL, 'server', 'D-239', '2026-06-07 10:34:11', '2026-06-08 15:16:48', NULL, 'bdb3b3f21bebba5abb885195d42a3c19', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1030', 'task', 'T-974', 'Scattered transient freeze/snow model', 'D-239 section 3. Phase transition as a band with deterministic spatially-coherent scatter (clustered patches, never per-tile dice): freshwater (lakes/rivers) +5 to -10 C district mean (the +5 is night-frost reaching the coldest tiles); sea ice own lower/wider band (~-2 onset, pack-ice sheets+leads pattern); snow (land) moisture-gated. TRANSIENT in the marginal band — forms in the cold phase, melts in the warm phase (day/night, season) -> time-of-day passability shifts. The transient/clock-bound portion is a forward contract to Q-105 (region seasonal/clock state); the static scattered model is buildable now. Blocked by climate-derived fields.', 'done', 'medium', NULL, 'server', 'D-239', '2026-06-07 10:34:11', '2026-06-08 15:34:04', NULL, '058f876ba9c10de81574529ab7adb748', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1031', 'task', 'T-974', 'Believability + determinism + <5ms budget verification harness', 'D-239 sections 1,8,9. Golden-seed regression for the binding laws (drainage monotonicity, lithology->landform, glaciation->form with no glacial-U on GlaciationGrade 0, climate->vegetation no-skip + riparian band), per-family <5ms/chunk budget assertion, and the validation-case bodies: Kallast=alluvial plain/Soil, Velen=tidal-flat/dune coast (VERIFY it has a moon param for the tidal term first), Cygni B=volcanic immature drainage, Ross 128=ocean island terrain, Gruenfeld=marginal Gravel/Scrub. If a body reads wrong, fix its params — never patch the derivation (D-239 section 1). Blocked by the family generators. + +--- +Note from T-1028 review (Tyre, 2026-06-08): VoxelCache LRU eviction is an O(capacity) linear scan for the minimum access_gen (default cap 8192). Latent — at the D-239 §10 ~2.2-4.2 ms/chunk budget (4096 voxels) an 8k scan is measurable noise, not a blocker. If it shows up in this ticket''s profiling, the fix is a secondary BTreeMap<(access_gen, CacheKey), ()> eviction index (stays D-010-compliant). Scope before the budget test runs.', 'in_progress', 'high', NULL, 'server', 'D-239', '2026-06-07 10:34:11', '2026-06-08 15:34:33', NULL, '721cea579cbb83d01fdd4184c6793933', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1033', 'story', 'T-974', 'Class-envelope temperature model — drop non-canonical orbit/star inputs (D-240)', 'Rewrite derive_temperature_c (region_profile.rs) to the D-240 planet_class temperature-envelope model: drop ALL orbit/star physics (derive_distance_au, the luminosity() lookup + [star_luminosity] table, orbital_period_days, spectral_class, star_type as inputs). Temperature base = planet_class envelope (source-canonical in climate_constants.toml); latitude maps across it (pole=cold end, equator=warm end); atmosphere/hydrosphere/elevation modulate within bounds; the body seed adds a small bounded nudge for variety. Strip the now-dead orbit/star fields from BodyParams + BodyParamsReader + the bodies-table read (audit 2026-06-08 confirmed derive_temperature_c is the only consumer). Then regenerate the T-1031 golden so it pins correct temps. Blocks T-974 completion.', 'backlog', 'high', NULL, 'server', 'D-240', '2026-06-08 16:06:13', '2026-06-08 16:06:13', NULL, 'f7e8f9ee6165f2ab5ecbbcddfa2cb871', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1033', 'story', 'T-974', 'Class-envelope temperature model — drop non-canonical orbit/star inputs (D-240)', 'Rewrite derive_temperature_c (region_profile.rs) to the D-240 planet_class temperature-envelope model: drop ALL orbit/star physics (derive_distance_au, the luminosity() lookup + [star_luminosity] table, orbital_period_days, spectral_class, star_type as inputs). Temperature base = planet_class envelope (source-canonical in climate_constants.toml); latitude maps across it (pole=cold end, equator=warm end); atmosphere/hydrosphere/elevation modulate within bounds; the body seed adds a small bounded nudge for variety. Strip the now-dead orbit/star fields from BodyParams + BodyParamsReader + the bodies-table read (audit 2026-06-08 confirmed derive_temperature_c is the only consumer). Then regenerate the T-1031 golden so it pins correct temps. Blocks T-974 completion. + +Approved first-pass planet_class temperature envelope (°C, pole end → equator end). Tunable in server/data/climate_constants.toml. Parse cold_*/hot_*/warm_* prefixes to shift the band; sane default for unknown classes. + +| planet_class | band (pole → equator) | +|---|---| +| frozen / ice | -90 → -25 | +| boreal | -35 → 12 | +| cold_arid | -40 → 20 | +| temperate / oceanic | -12 → 28 | +| subtropical / warm_ocean | 2 → 34 | +| tropical | 16 → 40 | +| arid | -5 → 45 | +| hot_arid | 20 → 58 | +| volcanic / geothermal | 30 → 90 (special-cased) | +| barren (airless / atmosphere=none) | none (climate branch absent, per D-239 §2) | + +Latitude maps the region linearly across the band (|lat|/90). Atmosphere greenhouse shifts toward the warm end + sets diurnal swing; elevation lapse pulls highlands toward the cold end; clamp to the band. Seed nudge: a few °C of deterministic per-body variation, clamped within the band.', 'backlog', 'high', NULL, 'server', 'D-240', '2026-06-08 16:06:13', '2026-06-08 16:06:37', NULL, '366cfc67a53bea69db80181326a488e3', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1033', 'story', 'T-974', 'Class-envelope temperature model — drop non-canonical orbit/star inputs (D-240)', 'Rewrite derive_temperature_c (region_profile.rs) to the D-240 planet_class temperature-envelope model: drop ALL orbit/star physics (derive_distance_au, the luminosity() lookup + [star_luminosity] table, orbital_period_days, spectral_class, star_type as inputs). Temperature base = planet_class envelope (source-canonical in climate_constants.toml); latitude maps across it (pole=cold end, equator=warm end); atmosphere/hydrosphere/elevation modulate within bounds; the body seed adds a small bounded nudge for variety. Strip the now-dead orbit/star fields from BodyParams + BodyParamsReader + the bodies-table read (audit 2026-06-08 confirmed derive_temperature_c is the only consumer). Then regenerate the T-1031 golden so it pins correct temps. Blocks T-974 completion. + +Approved first-pass planet_class temperature envelope (°C, pole end → equator end). Tunable in server/data/climate_constants.toml. Parse cold_*/hot_*/warm_* prefixes to shift the band; sane default for unknown classes. + +| planet_class | band (pole → equator) | +|---|---| +| frozen / ice | -90 → -25 | +| boreal | -35 → 12 | +| cold_arid | -40 → 20 | +| temperate / oceanic | -12 → 28 | +| subtropical / warm_ocean | 2 → 34 | +| tropical | 16 → 40 | +| arid | -5 → 45 | +| hot_arid | 20 → 58 | +| volcanic / geothermal | 30 → 90 (special-cased) | +| barren (airless / atmosphere=none) | none (climate branch absent, per D-239 §2) | + +Latitude maps the region linearly across the band (|lat|/90). Atmosphere greenhouse shifts toward the warm end + sets diurnal swing; elevation lapse pulls highlands toward the cold end; clamp to the band. Seed nudge: a few °C of deterministic per-body variation, clamped within the band.', 'in_progress', 'high', NULL, 'server', 'D-240', '2026-06-08 16:06:13', '2026-06-08 16:09:59', NULL, 'f0efd8f2a82265d627bcac1f074fce33', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1033', 'story', 'T-974', 'Class-envelope temperature model — drop non-canonical orbit/star inputs (D-240)', 'Rewrite derive_temperature_c (region_profile.rs) to the D-240 planet_class temperature-envelope model: drop ALL orbit/star physics (derive_distance_au, the luminosity() lookup + [star_luminosity] table, orbital_period_days, spectral_class, star_type as inputs). Temperature base = planet_class envelope (source-canonical in climate_constants.toml); latitude maps across it (pole=cold end, equator=warm end); atmosphere/hydrosphere/elevation modulate within bounds; the body seed adds a small bounded nudge for variety. Strip the now-dead orbit/star fields from BodyParams + BodyParamsReader + the bodies-table read (audit 2026-06-08 confirmed derive_temperature_c is the only consumer). Then regenerate the T-1031 golden so it pins correct temps. Blocks T-974 completion. + +Approved first-pass planet_class temperature envelope (°C, pole end → equator end). Tunable in server/data/climate_constants.toml. Parse cold_*/hot_*/warm_* prefixes to shift the band; sane default for unknown classes. + +| planet_class | band (pole → equator) | +|---|---| +| frozen / ice | -90 → -25 | +| boreal | -35 → 12 | +| cold_arid | -40 → 20 | +| temperate / oceanic | -12 → 28 | +| subtropical / warm_ocean | 2 → 34 | +| tropical | 16 → 40 | +| arid | -5 → 45 | +| hot_arid | 20 → 58 | +| volcanic / geothermal | 30 → 90 (special-cased) | +| barren (airless / atmosphere=none) | none (climate branch absent, per D-239 §2) | + +Latitude maps the region linearly across the band (|lat|/90). Atmosphere greenhouse shifts toward the warm end + sets diurnal swing; elevation lapse pulls highlands toward the cold end; clamp to the band. Seed nudge: a few °C of deterministic per-body variation, clamped within the band.', 'review', 'high', NULL, 'server', 'D-240', '2026-06-08 16:06:13', '2026-06-08 16:30:14', NULL, '904f95c1a63e317ac3ce93611ca2dfa2', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1034', 'bug', 'T-974', 'derive_moisture_q hydrosphere vocab mismatch — liquid_water etc. fall to default', 'derive_moisture_q (region_profile.rs) matches hydrosphere on ''ocean''/''rivers''/''ice''/''subsurface''/''none'', but the actual bodies.hydrosphere vocab in systems.db is: liquid_water (175 bodies), ice (962), rivers (31), moderate (21), ocean (only 19), minimal (10), trace (8), subsurface_liquid (7), rivers-lakes (4), extensive (4), subsurface_ice (3), ocean-coastal (1), plus NULL (1649). So ''liquid_water'' and most surface-water worlds fall to the ''_ => 30'' default instead of high moisture — propagating wrong precipitation_class / glaciation_grade / vegetation_class for ~175+ water worlds. Same root cause as D-240 (code keying on a vocab the data doesn''t use). Fix: align derive_moisture_q (and audit any other hydrosphere consumer) to the real vocab — surface liquid (liquid_water/ocean/ocean-coastal/extensive) -> high; rivers/rivers-lakes/moderate -> mid; ice/subsurface* -> low; minimal/trace/none/NULL -> arid. T-1033''s maritime_factor table already maps the real vocab and can be the reference. Found during T-1033 verification (2026-06-08).', 'backlog', 'high', NULL, 'server', 'D-240', '2026-06-08 17:00:07', '2026-06-08 17:00:07', NULL, '7466c8c813e48866029bd71da9d41834', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1033', 'story', 'T-974', 'Class-envelope temperature model — drop non-canonical orbit/star inputs (D-240)', 'Rewrite derive_temperature_c (region_profile.rs) to the D-240 planet_class temperature-envelope model: drop ALL orbit/star physics (derive_distance_au, the luminosity() lookup + [star_luminosity] table, orbital_period_days, spectral_class, star_type as inputs). Temperature base = planet_class envelope (source-canonical in climate_constants.toml); latitude maps across it (pole=cold end, equator=warm end); atmosphere/hydrosphere/elevation modulate within bounds; the body seed adds a small bounded nudge for variety. Strip the now-dead orbit/star fields from BodyParams + BodyParamsReader + the bodies-table read (audit 2026-06-08 confirmed derive_temperature_c is the only consumer). Then regenerate the T-1031 golden so it pins correct temps. Blocks T-974 completion. + +Approved first-pass planet_class temperature envelope (°C, pole end → equator end). Tunable in server/data/climate_constants.toml. Parse cold_*/hot_*/warm_* prefixes to shift the band; sane default for unknown classes. + +| planet_class | band (pole → equator) | +|---|---| +| frozen / ice | -90 → -25 | +| boreal | -35 → 12 | +| cold_arid | -40 → 20 | +| temperate / oceanic | -12 → 28 | +| subtropical / warm_ocean | 2 → 34 | +| tropical | 16 → 40 | +| arid | -5 → 45 | +| hot_arid | 20 → 58 | +| volcanic / geothermal | 30 → 90 (special-cased) | +| barren (airless / atmosphere=none) | none (climate branch absent, per D-239 §2) | + +Latitude maps the region linearly across the band (|lat|/90). Atmosphere greenhouse shifts toward the warm end + sets diurnal swing; elevation lapse pulls highlands toward the cold end; clamp to the band. Seed nudge: a few °C of deterministic per-body variation, clamped within the band.', 'done', 'high', NULL, 'server', 'D-240', '2026-06-08 16:06:13', '2026-06-08 17:17:58', NULL, 'c3f91480bb7baa86f85eeb7a950c2509', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-1031', 'task', 'T-974', 'Believability + determinism + <5ms budget verification harness', 'D-239 sections 1,8,9. Golden-seed regression for the binding laws (drainage monotonicity, lithology->landform, glaciation->form with no glacial-U on GlaciationGrade 0, climate->vegetation no-skip + riparian band), per-family <5ms/chunk budget assertion, and the validation-case bodies: Kallast=alluvial plain/Soil, Velen=tidal-flat/dune coast (VERIFY it has a moon param for the tidal term first), Cygni B=volcanic immature drainage, Ross 128=ocean island terrain, Gruenfeld=marginal Gravel/Scrub. If a body reads wrong, fix its params — never patch the derivation (D-239 section 1). Blocked by the family generators. + +--- +Note from T-1028 review (Tyre, 2026-06-08): VoxelCache LRU eviction is an O(capacity) linear scan for the minimum access_gen (default cap 8192). Latent — at the D-239 §10 ~2.2-4.2 ms/chunk budget (4096 voxels) an 8k scan is measurable noise, not a blocker. If it shows up in this ticket''s profiling, the fix is a secondary BTreeMap<(access_gen, CacheKey), ()> eviction index (stays D-010-compliant). Scope before the budget test runs. + +--- +RESUME NOTE (2026-06-08, context reset). Where T-974 stands and how to finish it. + +STATUS: Epic T-974 (Atlas-to-tile derivation) is ~done. Merged this session: +T-1023/1024/1025/1026/1027/1028/1029/1030/1032 + T-1033 (class-envelope temperature, +D-240). Only T-1031 (this ticket) + T-1034 remain to close T-974. + +D-240 (just landed): registry orbit/star data is non-canonical placeholder; temperature +derives from planet_class envelope + atmosphere + hydrosphere(maritime moderation) + +seed — NO orbit/star inputs. This FIXED the +356C "temperate" worlds. BodyParams was +stripped of orbital_period_days / spectral_class / star_type / axial_tilt_deg. + +T-1031 IS PARKED in a worktree at .worktrees/verification-harness (branch +verification-harness), created BEFORE T-1033. The harness (server/tests/derivation_harness.rs) +is built: golden-seed determinism + §8 law sweeps + per-family budget + validation bodies +(Kallast pass; Velen/Gruenfeld skipped-not-in-wiki). It also made derive_morphology_zone pub. + +TO FINISH T-1031 (3 steps): +1. Rebase/merge current main into the verification-harness worktree. It will then FAIL TO + COMPILE: the harness test fixtures construct BodyParams with the now-removed fields + (orbital_period_days/spectral_class/star_type/axial_tilt_deg) — strip those from the + fixtures. Also re-check any validation-body temperature assertions against the NEW + class-envelope temps (the harness previously noted Marevna -56C as a param issue — that + may now read differently). +2. Generate the golden against the CORRECTED temps: from the worktree, + `cd server && UPDATE_GOLDEN=1 cargo test --test derivation_harness -- golden_seed_determinism_regression` + then commit tests/golden/derivation_harness.json + the harness + the region_profile.rs + pub-visibility change together. +3. Lead verify (cargo test/clippy -D warnings/fmt — the push gate runs all), then /pr-review + (Hoshe+Tyre) -> merge -> mark T-1031 done -> T-974 CLOSES. + +ALSO PENDING (separate, fast-follow): +- T-1034 (high, under T-974): derive_moisture_q hydrosphere vocab mismatch — expects ''ocean'' + but DB uses ''liquid_water'' (175 bodies) -> wrong moisture/precip/glaciation/vegetation. + The T-1033 [hydrosphere_maritime] table in climate_constants.toml has the real vocab as a + reference. NOTE: this is now important (water worlds get right temp, wrong moisture). +- Flaky test (no ticket yet, offer to file): voice::lookup::cache_miss_returns_base_text + intermittently fails the full-suite push gate under parallelism (passes in isolation; + unrelated to climate work). CI-hygiene. + +WORKFLOW REMINDERS for the resumer: lead drives all git; agents run lean (don''t pre-run +fmt/clippy/test — the pre-push gate enforces them + cargo test now); review caught real +bugs on EVERY PR this session (hollow tests, a cache-coherence bug hidden by a tautological +test, the +356C climate bug) — keep reviewing + verifying agent output, don''t trust "all green".', 'in_progress', 'high', NULL, 'server', 'D-239', '2026-06-07 10:34:11', '2026-06-08 17:22:10', NULL, '32ac92fca534508f3387c87bc54d9fae', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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); diff --git a/governance/README.md b/governance/README.md index a28c22c7f..7eb2ca7e2 100644 --- a/governance/README.md +++ b/governance/README.md @@ -305,6 +305,7 @@ line in place — keep the Q-record for the audit trail rather than deleting it. - [D-237: Authored per-system specialisation layer — `economic_specialization` + `cultural_specialization` + `dominant_faction`](decisions/architecture.md#d-237-authored-per-system-specialisation-layer--economic-specialization--cultural-specialization--dominant-faction) — _architecture_ - [D-238: Symmetric shadowcasting (Albert Ford) selected for LOS computation](decisions/perception.md#d-238-symmetric-shadowcasting-albert-ford-selected-for-los-computation) — _perception_ - [D-239: Tile derivation contract — coarse→fine refinement chain (resolves Q-101)](decisions/architecture.md#d-239-tile-derivation-contract--coarsefine-refinement-chain-resolves-q-101) — _architecture_ +- [D-240: Registry orbit/star data is non-canonical — climate derives from a `planet_class` temperature envelope](decisions/architecture.md#d-240-registry-orbitstar-data-is-non-canonical--climate-derives-from-a-planet-class-temperature-envelope) — _architecture_ ## Open questions diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 96b9d8f09..eef696873 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1776,4 +1776,13 @@ Technical foundation decisions that constrain implementation: engine, client-ser --- -*97 decisions (D-001 through D-239, excluding gaps). Last updated: 2026-06-07 (tile-derivation-contract workshop — D-239 tile derivation contract resolving Q-101: three-carrier refinement chain, district temperature primitive + scattered transient freeze/snow model, 8 morphology families + frozen 17-zone vocabulary, prevent-at-source seams).* +### D-240: Registry orbit/star data is non-canonical — climate derives from a `planet_class` temperature envelope + +- **Decision:** A body's registry **orbital position and stellar assignment** (`orbital_period_days`, `spectral_class`, `star_type`, and any derived `distance_au`/luminosity) are **non-canonical placeholder data** — bodies were dropped into orbit and stars taken as they sat in the registry, with no authored physical intent. **No derivation layer may consume them as physical inputs.** Climate derives **only** from the authored, intent-bearing fields: `planet_class` (sets the temperature envelope), `atmosphere`, `hydrosphere`, plus the intrinsic latitude/elevation of the generated surface — with per-world variety from the deterministic body **seed** ([D-010](#d-010)). Temperature is a **`planet_class` → (min, max) °C envelope** (source-canonical in `server/data/climate_constants.toml`, tunable): latitude maps the region across the envelope (pole → cold end, equator → warm end); atmosphere / hydrosphere / elevation modulate **within** the bounds; the seed adds a small bounded nudge for inter-world character. A body can never derive outside its class band by construction. This **supersedes the sun-driven (Stefan-Boltzmann luminosity + distance) portion of [D-239](#d-239) §2**. +- **Rationale:** Deriving physics from arbitrary registry values dresses placeholder data as authoritative — *worse* than an obvious error, because it looks real. The sun-driven path produced physically impossible results (a `temperate`/liquid-water world deriving to **+356 °C**, because a NULL `spectral_class` defaulted to a G-type Sun on an M-dwarf-dominated Gliese catalog — a 25× luminosity error). The 2026-06-08 audit found the blast radius is exactly one function (`derive_temperature_c`); every other reference merely plumbs the columns, so once temperature is re-based the orbit/star columns can be dropped from the derivation inputs entirely. Anchoring to `planet_class` — which already encodes the authored intent (`frozen`, `tropical`, `hot_arid`, …) — keeps every world inside its band and makes the T-1031 believability harness's class-consistency check true by construction. +- **Raised by:** Jeroen (2026-06-08), reviewing T-1024 climate output across 25 non-Kallast bodies. +- **Cross-reference:** [D-239](#d-239) §2 (temperature primitive — the sun-driven portion is superseded here), [D-010](#d-010) (determinism — seed-based variety), [D-227](#d-227) (derive-don't-store), [D-228](#d-228) (tile axes). + +--- + +*98 decisions (D-001 through D-240, excluding gaps). Last updated: 2026-06-08 (climate-canonicity correction — D-240 declares registry orbit/star data non-canonical; temperature flips from sun-driven [D-239 §2] to a `planet_class` envelope, superseding the Stefan-Boltzmann path that produced +356 °C "temperate" worlds).* diff --git a/server/data/climate_constants.toml b/server/data/climate_constants.toml index d9edf393e..864eed01d 100644 --- a/server/data/climate_constants.toml +++ b/server/data/climate_constants.toml @@ -1,30 +1,56 @@ -# Climate constants for district temperature derivation (T-1024, D-239 §2). +# Climate constants for district temperature derivation (T-1024, D-239 §2, D-240). # # Source-canonical — loaded by the Rust simulation at runtime. -# These values mirror the tuned constants from tooling/planet-gen/planet_simulation.py. # Changing these constants does NOT require a DB migration (runtime file, not DB). # To tune: edit here, rerun the server, inspect Atlas temperature maps. # -# Two tables are required: -# [greenhouse_offset_c] — mean-annual base temperature offset by atmosphere class. -# [diurnal_amplitude_c] — day/night swing AMPLITUDE by atmosphere class. +# Three tables are required: +# [planet_class_temperature] — per-class [cold_end, warm_end] °C band (D-240). +# [greenhouse_offset_c] — mean-annual base temperature offset by atmosphere class. +# [diurnal_amplitude_c] — day/night swing AMPLITUDE by atmosphere class. # -# Temperature derivation formula (D-239 §2): -# T_base = T_stellar_equilibrium + greenhouse_offset_c[atmosphere] -# T_mean = T_base + latitude_term + elevation_lapse_term -# T_diurnal_amplitude = diurnal_amplitude_c[atmosphere] +# Temperature derivation formula (D-240): +# (cold, warm) = planet_class_temperature[planet_class] # envelope +# t_lat = warm - (warm - cold) * (|latitude_deg| / 90) # latitude lerp +# greenhouse modulates within the band +# elevation lapse pulls toward cold end +# seed nudge ±~3°C +# CLAMP to [cold, warm] — body can never derive outside its class band. # # No atmosphere → temperature_c = None (airless body; D-227). +# --------------------------------------------------------------------------- +# Planet class temperature envelopes (D-240). +# Each entry: [cold_end_c, warm_end_c] — the band a body of this class stays in. +# Prefix rules: "cold_*" shifts both ends ~10°C colder; "hot_*" ~10°C warmer; +# "warm_*" ~5°C warmer. Unknown class falls back to "temperate" band. +# --------------------------------------------------------------------------- +[planet_class_temperature] +frozen = [-90.0, -25.0] +ice = [-90.0, -25.0] +boreal = [-35.0, 12.0] +cold_arid = [-40.0, 20.0] +temperate = [-12.0, 28.0] +oceanic = [-12.0, 28.0] +subtropical = [ 2.0, 34.0] +warm_ocean = [ 2.0, 34.0] +tropical = [ 16.0, 40.0] +arid = [ -5.0, 45.0] +hot_arid = [ 20.0, 58.0] +volcanic = [ 30.0, 90.0] +geothermal = [ 30.0, 90.0] + [greenhouse_offset_c] -# Greenhouse warming contribution per atmosphere class (°C above bare rock). +# Greenhouse warming contribution per atmosphere class — used as a fractional +# nudge toward the warm end of the class band, not an absolute Kelvin offset. # "none" is not present — airless bodies skip the climate branch entirely (D-227). -# Values from planet_simulation.py STAR_LUMINOSITY + greenhouse table. -thin = 8 -standard = 33 -toxic = 33 # treated as standard greenhouse for thermal purposes -breathable = 33 # synonym for standard -dense = 80 +# Values represent a 0.0–1.0 fraction of the band width to add toward warm end. +# thin=0.1 (slightly warmer), breathable/standard=0.25, toxic=0.25, dense=0.55 +thin = 0.10 +standard = 0.25 +toxic = 0.25 +breathable = 0.25 +dense = 0.55 [diurnal_amplitude_c] # Day/night swing amplitude (°C). The actual swing is ±amplitude around the @@ -41,18 +67,20 @@ breathable = 15 # synonym for standard dense = 3 # ~3°C: Venus-like near-uniform temperature # --------------------------------------------------------------------------- -# Stellar luminosity lookup (relative to Sol = 1.0). -# Midpoint per spectral type, matching planet_simulation.py STAR_LUMINOSITY. -# Used to derive distance_au at runtime from orbital_period_days via Kepler's 3rd law. -# -# These values are also used to compute T_stellar_equilibrium: -# T_eq_K = 278.5 * (luminosity ^ 0.25) / sqrt(distance_au) -# --------------------------------------------------------------------------- -[star_luminosity] -O = 100000.0 -B = 1000.0 -A = 10.0 -F = 2.5 -G = 1.0 -K = 0.4 -M = 0.04 +[hydrosphere_maritime] +# Maritime moderation (D-240): scales the equator→pole temperature gradient by +# hydrosphere. 1.0 = full gradient (dry world swings the whole class band); +# < 1.0 compresses toward the band midpoint (water-rich worlds are milder at +# both poles and equator). Keys = actual bodies.hydrosphere vocabulary in +# systems.db. "minimal"/"trace"/"none"/absent default to 1.0 in code. +liquid_water = 0.6 # large surface liquid — strongly moderated +ocean = 0.6 +"ocean-coastal" = 0.6 +extensive = 0.6 +rivers = 0.8 # partial surface water — mild moderation +"rivers-lakes" = 0.8 +moderate = 0.8 +ice = 0.85 # frozen surface — slight moderation +subsurface_liquid = 0.9 +subsurface = 0.95 # buried water — minimal surface effect +subsurface_ice = 0.95 diff --git a/server/src/atlas/body_params_reader.rs b/server/src/atlas/body_params_reader.rs index 990c06805..99bcd59da 100644 --- a/server/src/atlas/body_params_reader.rs +++ b/server/src/atlas/body_params_reader.rs @@ -5,18 +5,20 @@ //! the Rayon work item DB-free while supplying the climate/tectonic inputs //! required by `derive_all_regions`. //! +//! **D-240:** orbit/star fields (`orbital_period_days`, `axial_tilt_deg`, +//! `spectral_class`, `star_type`) are non-canonical placeholder data — they are +//! NOT read into `BodyParams`. Temperature derives from `planet_class` envelope +//! only. The DB columns are left in place (no schema change) but are no longer +//! selected here. +//! //! **Columns queried** (all are nullable in the schema — `BodyParams` fields are //! `Option`): //! //! | Field | Source | //! |-------|--------| -//! | `hydrosphere` | `bodies.hydrosphere` | -//! | `atmosphere` | `bodies.atmosphere` | -//! | `planet_class` | `bodies.planet_class` | -//! | `orbital_period_days` | `bodies.orbital_period_days` | -//! | `axial_tilt_deg` | `bodies.axial_tilt_deg` | -//! | `spectral_class` | `star_systems.spectral_class` (via `bodies.system_id`) | -//! | `star_type` | `star_systems.star_type` (via `bodies.system_id`) | +//! | `hydrosphere` | `bodies.hydrosphere` | +//! | `atmosphere` | `bodies.atmosphere` | +//! | `planet_class` | `bodies.planet_class` | //! //! `tectonic_activity` is **not** in the current schema; `BodyParams.tectonic_activity` //! is left `None` so the derivation falls back to `planet_class` as documented @@ -75,8 +77,10 @@ impl BodyParamsReader { /// Read the physical parameters for `body_id`. /// - /// Joins `bodies` → `star_systems` (LEFT JOIN, so a body with no system row - /// still returns valid params with `spectral_class` and `star_type` = `None`). + /// D-240: only `hydrosphere`, `atmosphere`, and `planet_class` are selected. + /// The orbit/star columns (`orbital_period_days`, `axial_tilt_deg`, + /// `spectral_class`, `star_type`) remain in the DB schema but are not + /// consumed — they are non-canonical placeholder data per D-240. /// /// Returns `BodyParamsReadError::UnknownBody` if the body is not in the DB. /// A body that exists but has all-NULL columns still returns `Ok(BodyParams::default())` — @@ -87,69 +91,36 @@ impl BodyParamsReader { .lock() .map_err(|e| BodyParamsReadError::Db(format!("mutex poisoned: {e}")))?; - // All columns are nullable; query row presence is what signals UnknownBody. + // All three columns are nullable; row absence is what signals UnknownBody. // `tectonic_activity` is absent from the current schema — leave that - // BodyParams field None (derives from planet_class at query time). + // BodyParams field None (derives from planet_class at derivation time). let result: rusqlite::Result<( Option, // b.hydrosphere Option, // b.atmosphere Option, // b.planet_class - Option, // b.orbital_period_days - Option, // b.axial_tilt_deg - Option, // s.spectral_class - Option, // s.star_type )> = conn.query_row( "SELECT b.hydrosphere, b.atmosphere, - b.planet_class, - b.orbital_period_days, - b.axial_tilt_deg, - s.spectral_class, - s.star_type + b.planet_class FROM bodies AS b - LEFT JOIN star_systems AS s ON s.system_id = b.system_id WHERE b.body_id = ?1", [body_id], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - row.get(5)?, - row.get(6)?, - )) - }, + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ); match result { - Ok(( + Ok((hydrosphere, atmosphere, planet_class)) => Ok(BodyParams { hydrosphere, atmosphere, planet_class, - orbital_period_days, - axial_tilt_deg, - spectral_class, - star_type, - )) => { - Ok(BodyParams { - hydrosphere, - atmosphere, - planet_class, - orbital_period_days, - axial_tilt_deg, - spectral_class, - star_type, - // tectonic_activity not in schema — leave None. - tectonic_activity: None, - // Per-region fields are set by derive_all_regions / derive_region_profile, - // not at the body level. Leave at struct defaults (0.0). - region_latitude_deg: 0.0, - elevation_km: 0.0, - }) - } + // tectonic_activity not in schema — leave None. + tectonic_activity: None, + // Per-region fields are set by derive_all_regions / derive_region_profile, + // not at the body level. Leave at struct defaults (0.0). + region_latitude_deg: 0.0, + elevation_km: 0.0, + }), Err(rusqlite::Error::QueryReturnedNoRows) => { Err(BodyParamsReadError::UnknownBody(body_id.to_string())) } @@ -181,49 +152,34 @@ mod tests { static SEQ: AtomicU32 = AtomicU32::new(0); + /// Create a minimal test DB with the three canonical columns only. + /// The orbit/star columns (orbital_period_days, axial_tilt_deg, + /// spectral_class, star_type) are intentionally absent — the reader + /// must not select them (D-240). fn make_test_db( body_id: &str, - system_id: &str, hydrosphere: Option<&str>, atmosphere: Option<&str>, planet_class: Option<&str>, - orbital_period_days: Option, - axial_tilt_deg: Option, - spectral_class: Option<&str>, - star_type: Option<&str>, ) -> PathBuf { let n = SEQ.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("sr_bpr_{}_{n}.db", std::process::id())); let _ = std::fs::remove_file(&path); let conn = Connection::open(&path).expect("create db"); conn.execute_batch( - "CREATE TABLE star_systems ( - system_id TEXT PRIMARY KEY, - spectral_class TEXT, - star_type TEXT - ); - CREATE TABLE bodies ( + "CREATE TABLE bodies ( body_id TEXT PRIMARY KEY, - system_id TEXT, hydrosphere TEXT, atmosphere TEXT, - planet_class TEXT, - orbital_period_days REAL, - axial_tilt_deg REAL + planet_class TEXT );", ) .expect("create tables"); conn.execute( - "INSERT INTO star_systems (system_id, spectral_class, star_type) VALUES (?1, ?2, ?3)", - rusqlite::params![system_id, spectral_class, star_type], - ) - .expect("insert system"); - - conn.execute( - "INSERT INTO bodies (body_id, system_id, hydrosphere, atmosphere, planet_class, orbital_period_days, axial_tilt_deg) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - rusqlite::params![body_id, system_id, hydrosphere, atmosphere, planet_class, orbital_period_days, axial_tilt_deg], + "INSERT INTO bodies (body_id, hydrosphere, atmosphere, planet_class) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![body_id, hydrosphere, atmosphere, planet_class], ) .expect("insert body"); @@ -233,27 +189,13 @@ mod tests { #[test] fn reads_all_columns_present() { - let db = make_test_db( - "GJ1c", - "GJ-1", - Some("ocean"), - Some("breathable"), - Some("temperate"), - Some(365.25), - Some(23.5), - Some("G"), - Some("main_sequence"), - ); + let db = make_test_db("GJ1c", Some("ocean"), Some("breathable"), Some("temperate")); let reader = BodyParamsReader::open(&db).expect("open"); let params = reader.read_body_params("GJ1c").expect("read"); assert_eq!(params.hydrosphere.as_deref(), Some("ocean")); assert_eq!(params.atmosphere.as_deref(), Some("breathable")); assert_eq!(params.planet_class.as_deref(), Some("temperate")); - assert!((params.orbital_period_days.unwrap() - 365.25).abs() < 1e-9); - assert!((params.axial_tilt_deg.unwrap() - 23.5).abs() < 1e-9); - assert_eq!(params.spectral_class.as_deref(), Some("G")); - assert_eq!(params.star_type.as_deref(), Some("main_sequence")); // Per-region fields always start at 0.0 from the reader. assert_eq!(params.region_latitude_deg, 0.0); assert_eq!(params.elevation_km, 0.0); @@ -263,86 +205,26 @@ mod tests { #[test] fn handles_all_null_columns() { - let db = make_test_db("GJ2b", "GJ-2", None, None, None, None, None, None, None); + let db = make_test_db("GJ2b", None, None, None); let reader = BodyParamsReader::open(&db).expect("open"); let params = reader.read_body_params("GJ2b").expect("read"); - // All nullable columns → all None; struct defaults for per-region fields. assert!(params.hydrosphere.is_none()); assert!(params.atmosphere.is_none()); assert!(params.planet_class.is_none()); - assert!(params.orbital_period_days.is_none()); - assert!(params.axial_tilt_deg.is_none()); - assert!(params.spectral_class.is_none()); - assert!(params.star_type.is_none()); } #[test] fn unknown_body_returns_error() { - let db = make_test_db("GJ3c", "GJ-3", None, None, None, None, None, None, None); + let db = make_test_db("GJ3c", None, None, None); let reader = BodyParamsReader::open(&db).expect("open"); let err = reader.read_body_params("ghost").expect_err("should fail"); assert!(matches!(err, BodyParamsReadError::UnknownBody(_))); } - #[test] - fn body_with_orphan_system_id_returns_null_stellar_fields() { - // The LEFT JOIN guards against a body whose system_id has NO matching - // star_systems row (an incomplete DB — e.g. a body loaded before its - // system). It is NOT about a NULL system_id: production schema declares - // `bodies.system_id TEXT NOT NULL REFERENCES star_systems(system_id)`, - // so a NULL system_id can't exist. Mirror that NOT NULL here and test the - // real case — a non-NULL system_id pointing at an absent system row. - let n = SEQ.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!("sr_bpr_ns_{}_{n}.db", std::process::id())); - let _ = std::fs::remove_file(&path); - let conn = Connection::open(&path).expect("create db"); - conn.execute_batch( - "CREATE TABLE star_systems ( - system_id TEXT PRIMARY KEY, - spectral_class TEXT, - star_type TEXT - ); - CREATE TABLE bodies ( - body_id TEXT PRIMARY KEY, - system_id TEXT NOT NULL, - hydrosphere TEXT, - atmosphere TEXT, - planet_class TEXT, - orbital_period_days REAL, - axial_tilt_deg REAL - );", - ) - .expect("create tables"); - // Body points at a system that doesn't exist in star_systems (orphan FK). - conn.execute( - "INSERT INTO bodies (body_id, system_id, atmosphere) VALUES ('lonely', 'GJ-ORPHAN', 'thin')", - [], - ) - .expect("insert"); - drop(conn); - - let reader = BodyParamsReader::open(&path).expect("open"); - let params = reader.read_body_params("lonely").expect("read"); - assert_eq!(params.atmosphere.as_deref(), Some("thin")); - // No matching star_systems row → stellar fields NULL from the LEFT JOIN. - assert!(params.spectral_class.is_none()); - assert!(params.star_type.is_none()); - } - #[test] fn read_is_deterministic() { - let db = make_test_db( - "GJ4d", - "GJ-4", - Some("ice"), - Some("thin"), - Some("frozen"), - Some(200.0), - Some(15.0), - Some("K"), - Some("main_sequence"), - ); + let db = make_test_db("GJ4d", Some("ice"), Some("thin"), Some("frozen")); let reader = BodyParamsReader::open(&db).expect("open"); let p1 = reader.read_body_params("GJ4d").expect("first read"); let p2 = reader.read_body_params("GJ4d").expect("second read"); @@ -350,9 +232,35 @@ mod tests { assert_eq!(p1.hydrosphere, p2.hydrosphere); assert_eq!(p1.atmosphere, p2.atmosphere); assert_eq!(p1.planet_class, p2.planet_class); - assert_eq!(p1.orbital_period_days, p2.orbital_period_days); - assert_eq!(p1.axial_tilt_deg, p2.axial_tilt_deg); - assert_eq!(p1.spectral_class, p2.spectral_class); - assert_eq!(p1.star_type, p2.star_type); + } + + #[test] + fn reader_no_longer_queries_orbit_star_columns() { + // Verify the reader works against a DB that has NO orbit/star columns at all + // (the columns are left in production schema but the SELECT must not touch them). + // This test intentionally omits those columns from the schema to prove + // the query doesn't reference them. + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("sr_bpr_nostar_{}_{n}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + let conn = Connection::open(&path).expect("create"); + conn.execute_batch( + "CREATE TABLE bodies ( + body_id TEXT PRIMARY KEY, + hydrosphere TEXT, + atmosphere TEXT, + planet_class TEXT + -- orbital_period_days, axial_tilt_deg, spectral_class, star_type + -- deliberately absent to prove SELECT doesn't reference them + ); + INSERT INTO bodies VALUES ('X', 'ocean', 'breathable', 'temperate');", + ) + .expect("setup"); + drop(conn); + + let reader = BodyParamsReader::open(&path).expect("open"); + let params = reader.read_body_params("X").expect("read"); + assert_eq!(params.planet_class.as_deref(), Some("temperate")); } } diff --git a/server/src/atlas/region_profile.rs b/server/src/atlas/region_profile.rs index 588f6aa87..3434a7f8d 100644 --- a/server/src/atlas/region_profile.rs +++ b/server/src/atlas/region_profile.rs @@ -124,30 +124,29 @@ pub enum VegetationClass { /// Body-level physical parameters needed to derive `RegionProfile`. /// /// Modelled on the `BodyRow` reader at `bin/atlas/common.rs`. Source columns -/// live on the `bodies` table: `hydrosphere`, `atmosphere`, `planet_class`, -/// `orbital_period_days`. All are optional (may be NULL in the DB). +/// live on the `bodies` table: `hydrosphere`, `atmosphere`, `planet_class`. +/// All are optional (may be NULL in the DB). +/// +/// **D-240:** orbit/star fields (`orbital_period_days`, `axial_tilt_deg`, +/// `spectral_class`, `star_type`) are non-canonical placeholder data and are +/// NOT read into this struct. Temperature derives from `planet_class` envelope +/// only — see `derive_temperature_c`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct BodyParams { - /// `bodies.hydrosphere` — "ocean" | "ice" | "rivers" | "none" | "subsurface" | NULL + /// `bodies.hydrosphere` — the real vocabulary in systems.db: "ice", + /// "liquid_water", "none", "rivers", "moderate", "ocean", "minimal", "trace", + /// "subsurface_liquid", "rivers-lakes", "extensive", "subsurface_ice", + /// "subsurface", "ocean-coastal" | NULL. The `[hydrosphere_maritime]` table + /// keys on these for temperature moderation (D-240). pub hydrosphere: Option, /// `bodies.atmosphere` — "breathable" | "thin" | "toxic" | "none" | "dense" | NULL pub atmosphere: Option, /// `bodies.planet_class` — "temperate" | "arid" | "frozen" | "oceanic" | "volcanic" | … | NULL pub planet_class: Option, - /// `bodies.orbital_period_days` — orbital period in Earth days. - pub orbital_period_days: Option, /// `bodies.tectonic_activity` — optional authored tectonic override. /// "stable" | "active" | "volcanic" | "tidally_forced". If absent, derived /// from `planet_class`. pub tectonic_activity: Option, - /// `bodies.axial_tilt_deg` — axial tilt in degrees (T-1024, D-239 §2). - /// Sourced from planet-gen body-def frontmatter. - pub axial_tilt_deg: Option, - /// `star_systems.spectral_class` — stellar spectral class ("G", "K", "M", …). - /// Used to derive stellar luminosity for temperature calculation (T-1024). - pub spectral_class: Option, - /// `star_systems.star_type` — fallback stellar type if spectral_class is absent. - pub star_type: Option, /// Latitude of the region's centre in the body's reference frame, in degrees. /// 0.0 = equator, ±90.0 = poles. Used for latitude-band temperature gradient. pub region_latitude_deg: f64, @@ -624,29 +623,53 @@ const MAX_REGION_ELEVATION_KM: f64 = 8.0; /// `load()` method yet, so keep the TOML and the embedded `default()` in sync by /// hand until then. /// -/// Values mirror `tooling/planet-gen/planet_simulation.py`. +/// D-240: temperature derives from `planet_class` envelope, not orbit/star data. #[derive(Debug, Clone)] pub struct ClimateConstants { - /// Greenhouse warming offset per atmosphere class (°C). + /// Per-`planet_class` temperature envelope: `(cold_end_c, warm_end_c)` °C. + /// A body can never derive outside its class band (D-240). + /// Keys: "temperate", "arid", "frozen", "tropical", "hot_arid", "volcanic", … + /// Prefix rules applied in `envelope()`: "cold_*" → −10°C shift; "hot_*" → +10°C; + /// "warm_*" → +5°C. Unknown class falls back to "temperate" band. + pub planet_class_temperature: std::collections::BTreeMap, + /// Greenhouse warming fraction per atmosphere class (0.0–1.0 of band width). /// Keys: "thin", "standard", "breathable", "toxic", "dense". - /// "none" / absent = 0 °C (but airless bodies return `None` before reaching this). + /// "none" / absent = 0.0 (airless bodies return `None` before reaching this). pub greenhouse_offset_c: std::collections::BTreeMap, /// Day/night swing amplitude per atmosphere class (°C). pub diurnal_amplitude_c: std::collections::BTreeMap, - /// Stellar luminosity relative to Sol. Keys: spectral class letter. - pub star_luminosity: std::collections::BTreeMap, + /// Maritime-moderation factor per `hydrosphere` (D-240): scales the + /// equator→pole gradient. `1.0` = full gradient (dry world swings the whole + /// band); `< 1.0` compresses toward the band midpoint (water-rich worlds are + /// milder at both ends). Absent/unknown = `1.0`. + pub hydrosphere_maritime: std::collections::BTreeMap, } impl Default for ClimateConstants { /// Embedded fallback — matches `server/data/climate_constants.toml`. /// Used when the TOML file is not available (tests, embedded contexts). fn default() -> Self { + let mut pct = std::collections::BTreeMap::new(); + pct.insert("frozen".into(), (-90.0f32, -25.0f32)); + pct.insert("ice".into(), (-90.0f32, -25.0f32)); + pct.insert("boreal".into(), (-35.0f32, 12.0f32)); + pct.insert("cold_arid".into(), (-40.0f32, 20.0f32)); + pct.insert("temperate".into(), (-12.0f32, 28.0f32)); + pct.insert("oceanic".into(), (-12.0f32, 28.0f32)); + pct.insert("subtropical".into(), (2.0f32, 34.0f32)); + pct.insert("warm_ocean".into(), (2.0f32, 34.0f32)); + pct.insert("tropical".into(), (16.0f32, 40.0f32)); + pct.insert("arid".into(), (-5.0f32, 45.0f32)); + pct.insert("hot_arid".into(), (20.0f32, 58.0f32)); + pct.insert("volcanic".into(), (30.0f32, 90.0f32)); + pct.insert("geothermal".into(), (30.0f32, 90.0f32)); + let mut gh = std::collections::BTreeMap::new(); - gh.insert("thin".into(), 8.0f32); - gh.insert("standard".into(), 33.0f32); - gh.insert("breathable".into(), 33.0f32); - gh.insert("toxic".into(), 33.0f32); - gh.insert("dense".into(), 80.0f32); + gh.insert("thin".into(), 0.10f32); + gh.insert("standard".into(), 0.25f32); + gh.insert("breathable".into(), 0.25f32); + gh.insert("toxic".into(), 0.25f32); + gh.insert("dense".into(), 0.55f32); let mut da = std::collections::BTreeMap::new(); da.insert("thin".into(), 50.0f32); @@ -655,25 +678,63 @@ impl Default for ClimateConstants { da.insert("toxic".into(), 20.0f32); da.insert("dense".into(), 3.0f32); - let mut sl = std::collections::BTreeMap::new(); - sl.insert("O".into(), 100_000.0f64); - sl.insert("B".into(), 1_000.0f64); - sl.insert("A".into(), 10.0f64); - sl.insert("F".into(), 2.5f64); - sl.insert("G".into(), 1.0f64); - sl.insert("K".into(), 0.4f64); - sl.insert("M".into(), 0.04f64); + // Maritime moderation: water-rich worlds compress the equator–pole gradient. + // Keys are the actual `bodies.hydrosphere` vocabulary in systems.db. + let mut hm = std::collections::BTreeMap::new(); + // Large surface liquid — strong moderation. + hm.insert("liquid_water".into(), 0.6f32); + hm.insert("ocean".into(), 0.6f32); + hm.insert("ocean-coastal".into(), 0.6f32); + hm.insert("extensive".into(), 0.6f32); + // Partial surface water — mild moderation. + hm.insert("rivers".into(), 0.8f32); + hm.insert("rivers-lakes".into(), 0.8f32); + hm.insert("moderate".into(), 0.8f32); + // Frozen / subsurface — slight moderation. + hm.insert("ice".into(), 0.85f32); + hm.insert("subsurface_liquid".into(), 0.9f32); + hm.insert("subsurface".into(), 0.95f32); + hm.insert("subsurface_ice".into(), 0.95f32); + // "minimal" / "trace" / "none" / NULL → 1.0 (full gradient) via fallback. ClimateConstants { + planet_class_temperature: pct, greenhouse_offset_c: gh, diurnal_amplitude_c: da, - star_luminosity: sl, + hydrosphere_maritime: hm, } } } impl ClimateConstants { - /// Greenhouse offset for a given atmosphere class (°C), defaulting to 0. + /// Return the temperature envelope `(cold_c, warm_c)` for a planet class. + /// + /// Looks up the class directly first. On a miss, applies prefix rules against + /// the "temperate" band: `cold_*` shifts both ends −10 °C, `hot_*` +10 °C, + /// `warm_*` +5 °C. Completely unknown classes fall back to the "temperate" band. + pub fn envelope(&self, planet_class: &str) -> (f32, f32) { + // Direct lookup first. + if let Some(&band) = self.planet_class_temperature.get(planet_class) { + return band; + } + // Prefix rules: shift the temperate fallback band. + let default_band = self + .planet_class_temperature + .get("temperate") + .copied() + .unwrap_or((-12.0, 28.0)); + if planet_class.starts_with("cold_") { + (default_band.0 - 10.0, default_band.1 - 10.0) + } else if planet_class.starts_with("hot_") { + (default_band.0 + 10.0, default_band.1 + 10.0) + } else if planet_class.starts_with("warm_") { + (default_band.0 + 5.0, default_band.1 + 5.0) + } else { + default_band + } + } + + /// Greenhouse fraction for a given atmosphere class (0.0–1.0), defaulting to 0.0. pub fn greenhouse(&self, atmosphere: &str) -> f32 { self.greenhouse_offset_c .get(atmosphere) @@ -681,6 +742,16 @@ impl ClimateConstants { .unwrap_or(0.0) } + /// Maritime-moderation factor for a given `hydrosphere` (D-240): `1.0` = full + /// equator→pole gradient (dry/unknown); `< 1.0` compresses toward the band + /// midpoint (ocean ≈ 0.6 → milder at both ends). + pub fn maritime_factor(&self, hydrosphere: &str) -> f32 { + self.hydrosphere_maritime + .get(hydrosphere) + .copied() + .unwrap_or(1.0) + } + /// Diurnal amplitude for a given atmosphere class (°C), defaulting to 30. pub fn diurnal_amplitude(&self, atmosphere: &str) -> f32 { self.diurnal_amplitude_c @@ -688,46 +759,37 @@ impl ClimateConstants { .copied() .unwrap_or(30.0) } - - /// Stellar luminosity for a given spectral class (relative to Sol = 1.0). - pub fn luminosity(&self, spectral_class: &str) -> f64 { - // Spectral class is often multi-character like "G2V"; take the first letter. - let key = spectral_class - .chars() - .next() - .map(|c| c.to_uppercase().to_string()) - .unwrap_or_default(); - self.star_luminosity.get(&key).copied().unwrap_or(1.0) - } -} - -/// Derive orbital distance in AU from orbital period (days) and stellar luminosity. -/// -/// Kepler's 3rd law (approximation for main-sequence star with mass ≈ luminosity^0.25): -/// a_AU = (T_yr)^(2/3) × (star_mass)^(1/3) -/// star_mass_solar ≈ luminosity^0.25 (main-sequence mass-luminosity relation) -/// -/// This is the same derivation used in `planet_simulation.py`. -/// Uses f64 throughout; result is a positional input, not a gate comparison (D-010). -fn derive_distance_au(orbital_period_days: f64, luminosity_solar: f64) -> f64 { - let t_yr = orbital_period_days / 365.25; - // Mass-luminosity: M ≈ L^0.25 (rough but adequate for temperature estimation) - let star_mass = luminosity_solar.powf(0.25); - t_yr.powf(2.0 / 3.0) * star_mass.powf(1.0 / 3.0) } /// Derive mean-annual district temperature in °C (nullable). /// +/// Implements D-240: temperature derives from `planet_class` envelope only. +/// Orbit/star fields (`orbital_period_days`, `spectral_class`, `star_type`, +/// `axial_tilt_deg`) are non-canonical placeholder data and are NOT inputs. +/// /// Returns `None` if no atmosphere (airless; D-227). -/// Implements D-239 §2: mean-annual scalar, latitude + elevation lapse, greenhouse offset. -/// Orbital phase is fixed at equinox; diurnal swing is carried as `amplitude_c` only. /// -/// Uses f64 for intermediate calculations; the final result is cast to f32 -/// (adequate precision for a ~2 km district-mean temperature). +/// ## Algorithm (D-240) /// -/// D-010: all gating decisions downstream use the integer `temperature_c as i32`; -/// the f64/f32 here is positional physics, not a structural comparison. -pub fn derive_temperature_c(params: &BodyParams, constants: &ClimateConstants) -> Option { +/// 1. Look up the `(cold, warm)` envelope for `planet_class` via +/// `ClimateConstants::envelope()`. Unknown class falls back to "temperate". +/// 2. **Latitude lerp**: `t_lat = warm - (warm - cold) * (|lat| / 90)` — +/// equator → warm end, pole → cold end. +/// 3. **Atmosphere modulation**: greenhouse fraction nudges `t_lat` toward the +/// warm end by `greenhouse_frac × (warm - cold)`. +/// 4. **Elevation lapse**: subtract `lapse_rate × elevation_km` (thin atmo: +/// 3.5 °C/km, else 6.5 °C/km). +/// 5. **Seed nudge**: deterministic ±~3 °C per-body variation from `body_seed`. +/// 6. **Clamp to `[cold, warm]`** — class band is a hard invariant (D-240). +/// +/// Uses f32 throughout (adequate for ~2 km district-mean temperature). +/// D-010: all downstream gating uses `temperature_c as i32`; the float here +/// is positional physics, not a structural comparison. +pub fn derive_temperature_c( + params: &BodyParams, + constants: &ClimateConstants, + body_seed: u64, +) -> Option { let atmosphere = params.atmosphere.as_deref().unwrap_or("none"); // No atmosphere → airless body; temperature is None (D-227). @@ -735,48 +797,50 @@ pub fn derive_temperature_c(params: &BodyParams, constants: &ClimateConstants) - return None; } - // Stellar luminosity — from spectral_class or star_type. - let spectral = params - .spectral_class - .as_deref() - .or(params.star_type.as_deref()) - .unwrap_or("G"); - let luminosity = constants.luminosity(spectral); + // Step 1: planet_class → (cold, warm) envelope (D-240). + let planet_class = params.planet_class.as_deref().unwrap_or("temperate"); + let (cold, warm) = constants.envelope(planet_class); + let band_width = warm - cold; - // Orbital distance — derive from orbital_period_days if available, - // else fall back to 1 AU (Sol-equivalent distance). - let distance_au = params - .orbital_period_days - .filter(|&d| d > 0.0) - .map(|d| derive_distance_au(d, luminosity)) - .unwrap_or(1.0); + // Step 2: latitude lerp across the maritime-moderated band (D-240). Water-rich + // worlds compress the equator→pole gradient toward the band midpoint — an ocean + // world is milder at both ends; a dry world swings the full band. + let hydrosphere = params.hydrosphere.as_deref().unwrap_or("none"); + let maritime = constants.maritime_factor(hydrosphere); + let mid = (cold + warm) * 0.5; + let half = band_width * 0.5 * maritime; + let lat_frac = (params.region_latitude_deg.abs() as f32 / 90.0).clamp(0.0, 1.0); + // equator (frac 0) → mid + half; pole (frac 1) → mid − half. + let t_lat = (mid + half) - (2.0 * half) * lat_frac; - // Stellar equilibrium temperature (K) — Stefan-Boltzmann approximation. - let t_equilibrium = 278.5 * luminosity.powf(0.25) / distance_au.sqrt(); + // Step 3: atmosphere greenhouse nudge — fraction of band_width toward warm end. + let gh_frac = constants.greenhouse(atmosphere); + let t_atmo = t_lat + gh_frac * band_width; - // Greenhouse offset (°C from planet_simulation.py tuning). - let greenhouse = constants.greenhouse(atmosphere) as f64; - let t_base = t_equilibrium + greenhouse; - - // Latitude gradient. Axial tilt modulates the equator–pole delta. - let axial_tilt = params.axial_tilt_deg.unwrap_or(23.4); // Earth-like default - // tilt_factor: 1.0 = no tilt (full equator–pole gradient), 0.5 = 90° tilt (reduced gradient) - let tilt_factor = 1.0 - (axial_tilt / 90.0) * 0.5; - let lat_gradient_c = 60.0 * tilt_factor; - let lat_frac = params.region_latitude_deg / 90.0; // [-1.0, 1.0] - let t_lat = t_base - lat_gradient_c * lat_frac.abs(); - - // Elevation lapse rate (°C/km). Earth standard ~6.5 °C/km; airless = 2. + // Step 4: elevation lapse rate (°C/km). let lapse = if atmosphere == "thin" { - 3.5_f64 + 3.5_f32 } else { - 6.5_f64 + 6.5_f32 }; - let t_final = t_lat - lapse * params.elevation_km.max(0.0); + let t_lapse = t_atmo - lapse * (params.elevation_km as f32).max(0.0); - // Convert from K to °C (subtract 273.15) and clamp to a plausible range. - let t_celsius = (t_final - 273.15).clamp(-200.0, 600.0) as f32; - Some(t_celsius) + // Step 5: seed nudge — deterministic ±~3 °C per-body variety (D-010, D-240). + // Hash body_seed with a mixing constant to produce a per-body offset. + // The nudge is a fraction of the band width, bounded to ±3 °C maximum. + let nudge = { + // Deterministic hash: splitmix64-style single round. + let h = body_seed + .wrapping_add(0x9e37_79b9_7f4a_7c15) + .wrapping_mul(0x6c62_272e_07bb_0142); + // Map to [-1.0, 1.0] and scale to ±3°C. + let unit = (h as i64 as f64 / i64::MAX as f64) as f32; + unit * 3.0_f32 + }; + let t_nudged = t_lapse + nudge; + + // Step 6: clamp to [cold, warm] — class band is a hard invariant (D-240). + Some(t_nudged.clamp(cold, warm)) } /// Derive moisture primitive (0–100 integer). @@ -819,12 +883,11 @@ pub fn derive_moisture_q(params: &BodyParams) -> i32 { /// cell; default is 8 (at 128×64 working grid, that yields ~80×64 regions ≈ /// ~5 000 regions/body, within the D-203 ~6 000/body budget). /// -/// Temperature and moisture are derived inline via D-239 §2 climate functions -/// (T-1024). Pass a `&ClimateConstants` to control the tuning constants. +/// Temperature and moisture are derived inline via D-239 §2 / D-240 climate +/// functions (T-1024). Pass a `&ClimateConstants` to control the tuning constants. +/// The seed chain provides the body-scoped seed for the D-240 temperature nudge. pub fn derive_region_profile( - // Reserved: per-region stochastic derivation (T-1027/T-1028) will derive - // from this under a dedicated `SeedDomain::RegionProfile`. Unused today. - _seed: SeedChain, + seed: SeedChain, body_params: &BodyParams, ta: &TerrainAnalysis, pos: RegionPos, @@ -883,7 +946,9 @@ pub fn derive_region_profile( elevation_km: (elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM, ..body_params.clone() }; - let temperature_c = derive_temperature_c(®ion_climate_params, climate); + // D-240: body-scoped seed for the deterministic per-body temperature nudge. + let body_seed = seed.seed(); + let temperature_c = derive_temperature_c(®ion_climate_params, climate, body_seed); let moisture_q = derive_moisture_q(body_params); // Climate-derived fields: computed from temperature + moisture primitives @@ -1023,6 +1088,7 @@ mod tests { fn derive_region_profile_is_deterministic() { let hm = test_hm(); let ta = test_ta(&hm); + // D-240: no orbit/star fields in BodyParams — only class/atmo/hydro. let params = BodyParams { hydrosphere: Some("ocean".into()), atmosphere: Some("breathable".into()), @@ -1148,9 +1214,22 @@ mod tests { } // ----------------------------------------------------------------------- - // T-1024 climate derivation tests + // T-1024 / D-240 climate derivation tests — planet_class envelope model // ----------------------------------------------------------------------- + /// Convenience: derive temperature with a fixed seed (0) — deterministic, but + /// note seed 0 still applies its (constant, non-zero) nudge like any other seed. + fn temp(planet_class: &str, atmosphere: &str, lat: f64, elev_km: f64) -> Option { + let params = BodyParams { + planet_class: Some(planet_class.into()), + atmosphere: Some(atmosphere.into()), + region_latitude_deg: lat, + elevation_km: elev_km, + ..Default::default() + }; + derive_temperature_c(¶ms, &ClimateConstants::default(), 0) + } + #[test] fn airless_body_has_no_temperature() { let params = BodyParams { @@ -1159,55 +1238,17 @@ mod tests { }; let climate = ClimateConstants::default(); assert_eq!( - derive_temperature_c(¶ms, &climate), + derive_temperature_c(¶ms, &climate, 0), None, "airless body must return None temperature (D-227)" ); } - #[test] - fn earth_like_body_temperature_plausible() { - // Earth: G-star, 365-day orbit, 23.5° tilt, breathable atmosphere, equator. - let params = BodyParams { - atmosphere: Some("breathable".into()), - spectral_class: Some("G".into()), - orbital_period_days: Some(365.25), - axial_tilt_deg: Some(23.5), - region_latitude_deg: 0.0, - elevation_km: 0.0, - ..Default::default() - }; - let climate = ClimateConstants::default(); - let t = derive_temperature_c(¶ms, &climate).expect("breathable body must have temp"); - // Earth equator is roughly 20–30°C. With our formula T_eq ~278K + 33K greenhouse - // = ~38°C, plausible for equatorial region. - assert!( - (-10.0..=80.0).contains(&t), - "Earth-like equatorial temperature {t}°C out of plausible range [-10, 80]" - ); - } - #[test] fn polar_region_is_colder_than_equatorial() { - let base_params = BodyParams { - atmosphere: Some("breathable".into()), - spectral_class: Some("G".into()), - orbital_period_days: Some(365.25), - axial_tilt_deg: Some(23.5), - elevation_km: 0.0, - ..Default::default() - }; - let climate = ClimateConstants::default(); - let equatorial = BodyParams { - region_latitude_deg: 0.0, - ..base_params.clone() - }; - let polar = BodyParams { - region_latitude_deg: 90.0, - ..base_params - }; - let t_eq = derive_temperature_c(&equatorial, &climate).unwrap(); - let t_pol = derive_temperature_c(&polar, &climate).unwrap(); + // seed=0 has nudge; compare equatorial vs polar for same body → ordering holds. + let t_eq = temp("temperate", "breathable", 0.0, 0.0).unwrap(); + let t_pol = temp("temperate", "breathable", 90.0, 0.0).unwrap(); assert!( t_pol < t_eq, "polar temperature {t_pol}°C must be less than equatorial {t_eq}°C" @@ -1216,63 +1257,200 @@ mod tests { #[test] fn high_elevation_is_colder() { - let base_params = BodyParams { - atmosphere: Some("breathable".into()), - spectral_class: Some("G".into()), - orbital_period_days: Some(365.25), - axial_tilt_deg: Some(23.5), - region_latitude_deg: 0.0, - ..Default::default() - }; - let climate = ClimateConstants::default(); - let sea_level = BodyParams { - elevation_km: 0.0, - ..base_params.clone() - }; - let mountain = BodyParams { - elevation_km: 5.0, - ..base_params - }; - let t_low = derive_temperature_c(&sea_level, &climate).unwrap(); - let t_high = derive_temperature_c(&mountain, &climate).unwrap(); - // Lapse rate 6.5°C/km × 5 km = 32.5°C colder. + let t_low = temp("temperate", "breathable", 0.0, 0.0).unwrap(); + let t_high = temp("temperate", "breathable", 0.0, 5.0).unwrap(); + // Lapse rate 6.5°C/km × 5 km = 32.5°C; nudge same for same seed. assert!( t_high < t_low, "mountain temperature {t_high}°C must be less than sea level {t_low}°C" ); let delta = t_low - t_high; + // The clamping to [cold, warm] may reduce the apparent delta at the band edge, + // but 5 km lapse should still register at least 5°C. assert!( - (20.0..=45.0).contains(&delta), - "elevation delta {delta}°C unexpected for 5 km lapse" + delta >= 5.0, + "elevation delta {delta}°C too small for 5 km lapse" ); } #[test] - fn m_star_body_same_orbit_colder_than_g_star() { - let base_params = BodyParams { + fn tropical_body_temperature_plausible() { + // Tropical band is [16, 40]°C. Equatorial, sea-level, seed=0. + let t = temp("tropical", "breathable", 0.0, 0.0).unwrap(); + assert!( + (16.0..=40.0).contains(&t), + "tropical equatorial temperature {t}°C out of class band [16, 40]" + ); + } + + #[test] + fn frozen_body_temperature_plausible() { + // Frozen band is [-90, -25]°C. + let t = temp("frozen", "thin", 45.0, 0.0).unwrap(); + assert!( + (-90.0..=-25.0).contains(&t), + "frozen temperature {t}°C out of class band [-90, -25]" + ); + } + + #[test] + fn temperature_is_deterministic() { + // Same inputs → same output, always. + let params = BodyParams { + atmosphere: Some("thin".into()), + planet_class: Some("arid".into()), + region_latitude_deg: 45.0, + elevation_km: 1.5, + ..Default::default() + }; + let climate = ClimateConstants::default(); + let t1 = derive_temperature_c(¶ms, &climate, 12345); + let t2 = derive_temperature_c(¶ms, &climate, 12345); + assert_eq!(t1, t2, "temperature derivation must be deterministic"); + } + + #[test] + fn different_seeds_produce_different_nudges() { + // Two different body seeds should produce slightly different temperatures + // for the same class/lat/elev. + let params = BodyParams { atmosphere: Some("breathable".into()), - orbital_period_days: Some(365.25), - axial_tilt_deg: Some(23.5), - region_latitude_deg: 0.0, + planet_class: Some("temperate".into()), + region_latitude_deg: 30.0, elevation_km: 0.0, ..Default::default() }; let climate = ClimateConstants::default(); - let g_params = BodyParams { - spectral_class: Some("G".into()), - ..base_params.clone() + let t_a = derive_temperature_c(¶ms, &climate, 1); + let t_b = derive_temperature_c(¶ms, &climate, 999_999_999); + // Both still within the class band, but not identical. + assert_ne!(t_a, t_b, "different seeds should produce different nudges"); + let (cold, warm) = climate.envelope("temperate"); + assert!(t_a.unwrap() >= cold && t_a.unwrap() <= warm); + assert!(t_b.unwrap() >= cold && t_b.unwrap() <= warm); + } + + /// D-240 class-band consistency guard: every planet_class must derive within + /// its `[cold, warm]` band across a full latitude sweep, all atmospheres, + /// and sea level. This is the key acceptance test for T-1033. + #[test] + fn every_planet_class_derives_within_its_band() { + let climate = ClimateConstants::default(); + let planet_classes = [ + "frozen", + "ice", + "boreal", + "cold_arid", + "temperate", + "oceanic", + "subtropical", + "warm_ocean", + "tropical", + "arid", + "hot_arid", + "volcanic", + "geothermal", + ]; + let atmospheres = ["thin", "standard", "breathable", "toxic", "dense"]; + // Sweep latitudes 0°–90° in 5° steps; elevation = 0. + let latitudes: Vec = (0..=90).step_by(5).map(|d| d as f64).collect(); + let seeds: [u64; 4] = [0, 1, u64::MAX / 2, u64::MAX]; + + for class in &planet_classes { + let (cold, warm) = climate.envelope(class); + for atmo in &atmospheres { + for &lat in &latitudes { + // Sweep sea level AND high altitude so the lapse term + clamp + // are exercised jointly against the cold end, not just lat alone. + for elev in [0.0_f64, 6.0] { + // Sweep hydrosphere too, so the maritime-compressed gradient + // path is covered by the band invariant (not just None/1.0). + for hydro in ["none", "ocean", "liquid_water", "rivers", "ice"] { + for seed in seeds { + let params = BodyParams { + planet_class: Some((*class).into()), + atmosphere: Some((*atmo).into()), + hydrosphere: Some(hydro.into()), + region_latitude_deg: lat, + elevation_km: elev, + ..Default::default() + }; + let t = derive_temperature_c(¶ms, &climate, seed) + .expect("non-airless body must have temperature"); + assert!( + t >= cold && t <= warm, + "class={class} atmo={atmo} hydro={hydro} lat={lat} \ + elev={elev} seed={seed}: temperature {t}°C outside \ + band [{cold}, {warm}]" + ); + } + } + } + } + } + } + } + + #[test] + fn maritime_hydrosphere_compresses_the_gradient() { + // D-240: a water-rich world has a SMALLER equator→pole temperature delta + // than a dry world of the same class (maritime moderation). + let climate = ClimateConstants::default(); + let mk = |hydro: &str, lat: f64| BodyParams { + planet_class: Some("temperate".into()), + atmosphere: Some("standard".into()), + hydrosphere: Some(hydro.into()), + region_latitude_deg: lat, + elevation_km: 0.0, + ..Default::default() }; - let m_params = BodyParams { - spectral_class: Some("M".into()), - ..base_params + // Same seed → only hydrosphere differs. + let delta = |hydro: &str| { + (derive_temperature_c(&mk(hydro, 0.0), &climate, 7).unwrap() + - derive_temperature_c(&mk(hydro, 90.0), &climate, 7).unwrap()) + .abs() }; - let t_g = derive_temperature_c(&g_params, &climate).unwrap(); - let t_m = derive_temperature_c(&m_params, &climate).unwrap(); - // M star has luminosity 0.04 × Sol; same orbital period but star is - // much dimmer so equilibrium temperature is much lower. + let ocean_delta = delta("ocean"); + let dry_delta = delta("none"); // hydrosphere none ≠ airless (atmosphere is "standard") assert!( - t_m < t_g, - "M-star temperature {t_m}°C must be less than G-star {t_g}°C at same orbital period" + ocean_delta < dry_delta, + "ocean gradient {ocean_delta}°C must be milder than dry {dry_delta}°C" + ); + } + + #[test] + fn unknown_planet_class_falls_back_to_temperate_band() { + // An unrecognised class string must not panic; it falls back to temperate. + let climate = ClimateConstants::default(); + let (cold, warm) = climate.envelope("temperate"); + let params = BodyParams { + planet_class: Some("unknown_alien_class".into()), + atmosphere: Some("breathable".into()), + region_latitude_deg: 0.0, + elevation_km: 0.0, + ..Default::default() + }; + let t = derive_temperature_c(¶ms, &climate, 0) + .expect("breathable body must have temperature"); + assert!( + t >= cold && t <= warm, + "unknown class temperature {t}°C outside temperate band [{cold}, {warm}]" + ); + } + + #[test] + fn prefix_classes_derive_within_shifted_band() { + // "cold_weird" is not in the table; prefix rule should shift temperate. + let climate = ClimateConstants::default(); + let (base_cold, base_warm) = climate.envelope("temperate"); + let (cold, warm) = climate.envelope("cold_weird"); + assert!( + (cold - (base_cold - 10.0)).abs() < f32::EPSILON * 10.0, + "cold_ prefix should shift cold_end by -10" + ); + assert!( + (warm - (base_warm - 10.0)).abs() < f32::EPSILON * 10.0, + "cold_ prefix should shift warm_end by -10" ); } @@ -1328,30 +1506,18 @@ mod tests { } #[test] - fn temperature_is_deterministic() { - let params = BodyParams { - atmosphere: Some("thin".into()), - spectral_class: Some("K".into()), - orbital_period_days: Some(200.0), - axial_tilt_deg: Some(10.0), - region_latitude_deg: 45.0, - elevation_km: 1.5, - ..Default::default() - }; - let climate = ClimateConstants::default(); - let t1 = derive_temperature_c(¶ms, &climate); - let t2 = derive_temperature_c(¶ms, &climate); - assert_eq!(t1, t2, "temperature derivation must be deterministic"); - } - - #[test] - fn climate_constants_spectral_class_prefix_match() { - // "G2V" should resolve to the same luminosity as "G". + fn climate_constants_envelope_direct_lookup() { + // Direct lookup returns the exact registered band. let cc = ClimateConstants::default(); - assert_eq!(cc.luminosity("G2V"), cc.luminosity("G")); - assert_eq!(cc.luminosity("M5"), cc.luminosity("M")); - // Unknown class falls back to 1.0 (Sol). - assert_eq!(cc.luminosity(""), 1.0); + let (cold, warm) = cc.envelope("tropical"); + assert!( + (cold - 16.0).abs() < f32::EPSILON * 10.0, + "tropical cold_end should be 16°C" + ); + assert!( + (warm - 40.0).abs() < f32::EPSILON * 10.0, + "tropical warm_end should be 40°C" + ); } // ----------------------------------------------------------------------- diff --git a/server/src/main.rs b/server/src/main.rs index 783d374ea..d7d7857ea 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -215,10 +215,10 @@ fn main() { ), } - // Body physical params reader for RegionProfile carrier layer (T-1032, D-239 §1): - // reads hydrosphere / atmosphere / planet_class / orbital_period_days / - // axial_tilt_deg / spectral_class / star_type on a cache miss so the Rayon + // Body physical params reader for RegionProfile carrier layer (T-1032, D-239 §1, D-240): + // reads hydrosphere / atmosphere / planet_class on a cache miss so the Rayon // cascade work item stays DB-free (D-225 pattern). + // D-240: orbit/star fields are non-canonical and are no longer read. match settled_reach_server::atlas::body_params_reader::BodyParamsReader::open(&systems_db_path) { Ok(reader) => {