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:
parent
a02b619ce8
commit
e44b1b3ab1
1 changed files with 189 additions and 0 deletions
189
.project/handoffs/20260331_timescale-architecture.md
Normal file
189
.project/handoffs/20260331_timescale-architecture.md
Normal 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
|
||||
Loading…
Add table
Reference in a new issue