diff --git a/.project/handoffs/20260331_timescale-architecture.md b/.project/handoffs/20260331_timescale-architecture.md new file mode 100644 index 00000000..72e527f5 --- /dev/null +++ b/.project/handoffs/20260331_timescale-architecture.md @@ -0,0 +1,189 @@ +# Timescale Architecture — Adaptive dt for Simulation Engine + +**Date**: 2026-03-31 +**Status**: Design complete, implementation not started +**Design doc**: `games/age-of-dwarves/docs/TIMESCALES.md` + +## Context + +The simulation engine currently runs with an implicit `dt=1` (one tick = one undifferentiated time unit). The geological history phase runs 0-3000 ticks, and the design docs claim "1 turn = 10 years" but this is not reflected in the code — all rates and constants are simply per-tick with no time awareness. + +This creates fundamental problems: + +1. **Geological history is too short.** 3000 ticks × 10 years = 30,000 years. An Earth-like planet needs ~4.5 billion years of history to produce realistic terrain, climate, and ecology. The current system produces immature planets. + +2. **Gameplay timescales are contradictory.** Flora/fauna need annual resolution (seasonal migration, breeding cycles), but early-era gameplay should represent centuries per turn for narrative pacing (Civ-style "1500 years to discover pottery"). Without dt-aware simulation, either ecology is wrong or narrative is wrong. + +3. **Event frequencies are uncalibrated.** EVENT_FREQUENCY_SPEC.md defines frequencies for a 10yr/tick regime, but the sim doesn't actually use time-aware probability scaling. + +## Design Decision + +**Make `dt` (years per tick) an explicit parameter to `process_step()`.** All systems use `dt` to scale their behavior: + +- **Analytical systems** (logistic growth, exponential decay): closed-form jump, O(1) per tick regardless of dt +- **Transport systems** (temperature diffusion, moisture advection): coefficient scaling with stability clamping +- **Stochastic systems** (events): probability scaling via `1 - (1 - p)^dt` + +### Two applications of dt + +**1. Geological resolution ladder** — Pre-game history uses adaptive dt across 5 phases: + +| Phase | dt (years/tick) | Ticks | Covers | +|-------|----------------|-------|--------| +| Primordial | 10,000,000 | ~100 | 0-1 Gy — crust, oceans, atmosphere | +| Geological | 1,000,000 | ~2,000 | 1-3 Gy — continents, mountains | +| Ecological dawn | 100,000 | ~10,000 | 3-4 Gy — first biomes, soil | +| Ecological maturity | 10,000 | ~50,000 | 4-4.5 Gy — complex ecosystems | +| Pre-civilization | 1,000 | ~100 | Last 100 ky — final tuning | + +Total: ~62,000 ticks for a full Earth-like planet. + +**2. Era-scaled gameplay** — Each era in `eras.json` defines `years_per_turn`: + +Era 1 = 100, Era 2 = 50, Era 3 = 25, Era 4 = 20, Era 5 = 10, Era 6 = 5, Era 7 = 2, Eras 8-10 = 1. + +A 300-turn game spans ~4,700 calendar years. Same tick count per turn, different temporal resolution. + +## Files That Need Changes + +### Rust simulation (core changes) + +| File | Change | +|------|--------| +| `src/simulator/crates/mc-climate/src/physics.rs` | Add `dt: f32` param to `process_step()`. Scale transport coefficients (`conductivity`, `transport_rate`, `equilibrium_relaxation`, `atmo_loss`) by dt. Add stability clamping when `dt * rate > 1.0`. | +| `src/simulator/crates/mc-climate/src/ecology.rs` | Add `dt: f32` param to `process_step()`. Replace linear growth increments with analytical logistic/exponential integration when `dt > 1.0`. Pioneer rate, canopy growth, undergrowth, fungi — all become `dt`-aware. | +| `src/simulator/crates/mc-compute/src/lib.rs` | Thread `dt` through `ComputeEngine::process_step()` to physics and ecology. | +| `src/simulator/crates/mc-compute/src/cpu.rs` | Thread `dt` through `process_step_parallel()`. | +| `src/simulator/crates/mc-compute/src/gpu/mod.rs` | Thread `dt` through GPU `process_step()`. | + +### API surfaces + +| File | Change | +|------|--------| +| `src/simulator/api-wasm/src/lib.rs` | Add `dt: f32` param to `WasmClimatePhysics::processStep()` and `WasmEcologyPhysics::processStep()`. | +| `src/simulator/api-gdext/src/lib.rs` | Add `dt: f64` param to `GdClimatePhysics::process_step()` and `GdEcologyPhysics::process_step()`. | + +### TypeScript runner (orchestration) + +| File | Change | +|------|--------| +| `src/packages/engine-ts/src/runner.ts` | Replace fixed tick loop with resolution-ladder scheduler for geological phase. Pass era-appropriate `dt` during scenario/gameplay phase. `worldAge` becomes target years, not tick count. | +| `src/packages/engine-ts/src/types.ts` | `WorldAge` becomes a year-denominated type. Add `ResolutionTier` type. `ScenarioConfig.worldAge` changes from tick count to target age. | +| `src/packages/engine-ts/src/scenarios.ts` | Update `worldAge` values from tick counts to year targets. | +| `src/packages/engine-ts/src/configs/simulation.ts` | Add resolution ladder constants (tier boundaries, dt values). | + +### Game data + +| File | Change | +|------|--------| +| `games/age-of-dwarves/data/eras.json` | Add `years_per_turn` field to each era object. | +| `games/age-of-dwarves/docs/EVENT_FREQUENCY_SPEC.md` | Update to document dt-scaled probability formulas. Reframe "1 turn = 10 years" as "dt = 10" for the ecological maturity phase. | + +### Web guide + +| File | Change | +|------|--------| +| `games/age-of-dwarves/guide/src/simulation/simulation.worker.ts` | Pass dt to WASM processStep calls. Support resolution ladder for geological phase. | +| `games/age-of-dwarves/guide/src/pages/SurvivalGuidePage.tsx` | Update "2 turns (20 years)" references to use era-appropriate dt. | + +## Implementation Sequence + +### Phase 1: dt parameter threading (non-breaking) + +Add `dt: f32` to all `process_step` signatures with default `1.0`. No behavioral change — all existing callers pass `1.0` (or the parameter is defaulted). This is pure plumbing. + +**Touches**: physics.rs, ecology.rs, lib.rs (mc-compute), cpu.rs, gpu/mod.rs, api-wasm, api-gdext +**Tests**: All existing tests pass unchanged (dt=1.0 = current behavior). + +### Phase 2: dt-aware physics + +Scale transport coefficients by dt in `ClimatePhysics`. Add stability analysis: if `dt * transport_rate > STABILITY_THRESHOLD`, either clamp or sub-step internally. Temperature, moisture, and aerosol transport become dt-correct. + +**Touches**: physics.rs (core changes), existing test golden values may shift +**Tests**: New tests comparing `process_step(dt=10)` vs 10× `process_step(dt=1)` — results should be close (not identical due to nonlinearity, but bounded divergence). + +### Phase 3: dt-aware ecology + +Replace linear growth increments with analytical integration in `EcologyPhysics`. Logistic growth for canopy/undergrowth/fungi, exponential decay for dead matter. When `dt <= 1.0`, behavior is identical to current (linear ≈ analytical for small dt). + +**Touches**: ecology.rs +**Tests**: Resolution-independence tests. Verify canopy growth over 100 ticks at dt=1 matches 1 tick at dt=100 within tolerance. This is the core correctness guarantee — the simulation should produce equivalent results regardless of how time is subdivided. + +### Phase 4: dt-aware events + +Scale event probability rolls by dt. Update EventEvaluator to accept dt parameter. + +**Touches**: engine-ts eventSystem, Rust event system (when ported) +**Tests**: Statistical tests verifying event frequency is calendar-consistent across dt values. + +### Phase 5: Resolution ladder + +Implement the adaptive timestep scheduler in the runner. `worldAge` becomes year-denominated. Runner computes tick schedule across resolution tiers and passes appropriate dt to each phase. + +**Touches**: runner.ts, types.ts, scenarios.ts, simulation configs +**Tests**: Full simulation at different world ages produces geologically plausible results. Compare "young" vs "earth-like" vs "ancient" planet outputs. + +### Phase 6: Era-scaled gameplay + +Add `years_per_turn` to `eras.json`. Runner reads era dt and passes to processStep during gameplay. Calendar display uses cumulative era-scaled years. + +**Touches**: eras.json, runner.ts, GDScript turn processing, UI calendar display +**Tests**: 300-turn game simulation produces ~4,700 calendar years. Event density feels consistent across eras. + +## Key Mathematical Details + +### Logistic growth (analytical) + +Current (dt=1, linear approximation): +```rust +canopy += growth_rate * (1.0 - canopy / carrying_capacity) +``` + +dt-aware (exact): +```rust +let k = carrying_capacity; +let r = growth_rate; +let p0 = canopy; +canopy = k / (1.0 + ((k - p0) / p0) * (-r * dt).exp()); +``` + +### Exponential decay (analytical) + +Current: +```rust +aerosol *= 0.95 // 5% decay per tick +``` + +dt-aware: +```rust +aerosol *= (0.95_f32).powf(dt) // or equivalently: aerosol *= (-0.05129 * dt).exp() +``` + +### Event probability (dt-scaled) + +Current: +```rust +if rng.gen::() < base_probability { fire_event() } +``` + +dt-aware: +```rust +let p_tick = 1.0 - (1.0 - base_probability_per_year).powf(dt); +if rng.gen::() < p_tick { fire_event() } +``` + +### Transport stability + +For spatially-coupled systems, `effective_rate = base_rate * dt` can exceed 1.0 at large dt, causing numerical instability (oscillation, overshoot). Two strategies: + +1. **Clamping**: `effective_rate = min(base_rate * dt, MAX_SAFE_RATE)` — loses physical accuracy at very large dt but stays stable +2. **Internal sub-stepping**: if `dt * rate > threshold`, run `ceil(dt * rate / threshold)` internal sub-steps at reduced dt — exact but costs more compute + +For the geological phases (dt=10M), clamping is appropriate — we're after broad geological structure, not precise diffusion. For gameplay (dt=1-100), sub-stepping is unlikely to trigger since rates are calibrated for dt=1. + +## Verification + +- **Unit tests**: Each phase's changes include comparison tests (dt-scaled vs iterated) +- **Golden vectors**: Update existing WASM golden test vectors for dt=1.0 (should be unchanged) +- **Visual verification**: Run web guide with resolution ladder, compare planet maturity visually against current 2000-tick geological phase +- **Gameplay feel**: 300-turn simulated game, verify event density and ecological pacing across eras