docs(timescale-architecture): 📝 Update Timescale system architecture documentation with new design details and future planning.

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-31 22:48:48 -07:00
parent a02b619ce8
commit e44b1b3ab1

View file

@ -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::<f32>() < base_probability { fire_event() }
```
dt-aware:
```rust
let p_tick = 1.0 - (1.0 - base_probability_per_year).powf(dt);
if rng.gen::<f32>() < 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