Merge remote-tracking branch 'origin/main' into verification-harness

This commit is contained in:
2026-06-08 19:25:27 +02:00
8 changed files with 720 additions and 407 deletions
+74
View File
@@ -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;
+127
View File
@@ -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);
+1
View File
@@ -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
+10 -1
View File
@@ -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).*
+59 -31
View File
@@ -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.01.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
+68 -160
View File
@@ -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<String>, // b.hydrosphere
Option<String>, // b.atmosphere
Option<String>, // b.planet_class
Option<f64>, // b.orbital_period_days
Option<f64>, // b.axial_tilt_deg
Option<String>, // s.spectral_class
Option<String>, // 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<f64>,
axial_tilt_deg: Option<f64>,
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"));
}
}
+378 -212
View File
@@ -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<String>,
/// `bodies.atmosphere` — "breathable" | "thin" | "toxic" | "none" | "dense" | NULL
pub atmosphere: Option<String>,
/// `bodies.planet_class` — "temperate" | "arid" | "frozen" | "oceanic" | "volcanic" | … | NULL
pub planet_class: Option<String>,
/// `bodies.orbital_period_days` — orbital period in Earth days.
pub orbital_period_days: Option<f64>,
/// `bodies.tectonic_activity` — optional authored tectonic override.
/// "stable" | "active" | "volcanic" | "tidally_forced". If absent, derived
/// from `planet_class`.
pub tectonic_activity: Option<String>,
/// `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<f64>,
/// `star_systems.spectral_class` — stellar spectral class ("G", "K", "M", …).
/// Used to derive stellar luminosity for temperature calculation (T-1024).
pub spectral_class: Option<String>,
/// `star_systems.star_type` — fallback stellar type if spectral_class is absent.
pub star_type: Option<String>,
/// 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<String, (f32, f32)>,
/// Greenhouse warming fraction per atmosphere class (0.01.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<String, f32>,
/// Day/night swing amplitude per atmosphere class (°C).
pub diurnal_amplitude_c: std::collections::BTreeMap<String, f32>,
/// Stellar luminosity relative to Sol. Keys: spectral class letter.
pub star_luminosity: std::collections::BTreeMap<String, f64>,
/// 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<String, f32>,
}
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 equatorpole 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.01.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<f32> {
/// 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<f32> {
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 equatorpole delta.
let axial_tilt = params.axial_tilt_deg.unwrap_or(23.4); // Earth-like default
// tilt_factor: 1.0 = no tilt (full equatorpole 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 (0100 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(&region_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(&region_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<f32> {
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(&params, &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(&params, &climate),
derive_temperature_c(&params, &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(&params, &climate).expect("breathable body must have temp");
// Earth equator is roughly 2030°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(&params, &climate, 12345);
let t2 = derive_temperature_c(&params, &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(&params, &climate, 1);
let t_b = derive_temperature_c(&params, &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<f64> = (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(&params, &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(&params, &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(&params, &climate);
let t2 = derive_temperature_c(&params, &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"
);
}
// -----------------------------------------------------------------------
+3 -3
View File
@@ -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) => {