# Copyright (c) Meta Platforms, Inc. and affiliates. # All rights reserved. # # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. """ SEIR-style world simulator for CrisisWorld (design §6). Public API (re-exported via ``server/simulator/__init__.py``): - ``apply_tick(state, action, seed=None) -> WorldState`` — advance one tick. - ``make_observation(state, seed=None) -> CrisisworldcortexObservation`` — project latent state to wire-format observation with telemetry delay/noise. Internal types (``WorldState``, ``RegionLatentState``, ``TaskConfig``, ``SuperSpreaderEvent``, ``PendingEffect``, ``ChainBeta``) are defined here so latent fields cannot be reached from anything that imports the wire package — the wire/internal boundary is enforced structurally. Determinism: every random draw goes through ``random.Random(seed)`` with seed derived from ``(episode_seed, tick)``. ``apply_tick`` and ``make_observation`` use independent seed streams so observation noise is decorrelated from dynamics randomness. Modeling notes: - SEIR uses 4 fractions (S/E/I/R) per region, sum to 1.0 after each step. - ``base_R0`` per task is converted to per-tick transmission rate ``β = R_0 * γ`` inside ``_seir_step``. Cross-region β values are direct transmission rates (not R_0 conversions) per design §10. - ``_advance_terminal_state`` mutates state (advances ``consecutive_safe_ticks``); name reflects this. """ from __future__ import annotations import random from typing import Dict, List, Literal, Optional, Tuple from pydantic import BaseModel, Field # Wire-protocol imports use the absolute path ``CrisisWorldCortex.models`` # (canonicalized form per root CLAUDE.md). The dual-import fallback used by # server/CrisisWorldCortex_environment.py works there because ``..models`` from # ``CrisisWorldCortex.server.`` resolves to ``CrisisWorldCortex.models`` — # but ``..models`` from ``CrisisWorldCortex.server.simulator.seir_model`` (two # levels deep) resolves to a non-existent ``CrisisWorldCortex.server.models``, # so the fallback would fire and load bare ``models``. That creates a second # ``sys.modules`` entry with distinct class objects, and Pydantic's # discriminated-union validator rejects ``isinstance`` checks against inputs # constructed via the other path. Using the absolute path here avoids the trap. from CrisisWorldCortex.models import ( CrisisworldcortexObservation, DeployResource, Escalate, ExecutedAction, LegalConstraint, OuterActionPayload, ReallocateBudget, RegionId, RegionTelemetry, RequestData, ResourceInventory, ResourceType, Restriction, RestrictMovement, ) # ============================================================================ # SEIR rate constants (design §1a; locked) # ============================================================================ SIGMA = 0.4 # E -> I rate per tick (latent ~2.5 ticks) GAMMA = 0.2 # I -> R rate per tick (infectious ~5 ticks) HOSPITALIZATION_FRACTION_OF_I = 0.10 COMPLIANCE_DECAY_PER_STRICT_TICK = 0.03 COMPLIANCE_RECOVERY_PER_RELAXED_TICK = 0.02 # Hospital capacity threshold: I-fraction at which hospital_load saturates. # I * 0.10 / 0.05 = I/0.5 → I=0.5 → load=1.0. Tunable; not load-bearing. HOSPITAL_CAPACITY_FRACTION = 0.05 # Population per region (Q-Sim-1 option a; locked). POPULATION_PER_REGION = 1000 # Severity multipliers for restrict_movement action (design proposal §10). SEVERITY_MULTIPLIER: Dict[str, float] = { "none": 0.0, "light": 0.1, "moderate": 0.25, "strict": 0.5, } # Resource efficacy per unit deployed (per-tick I reduction; design §10 item 11). EFFICACY_PER_UNIT: Dict[str, float] = { "test_kits": 0.00002, # 1000 kits → -0.02 I/tick "hospital_beds": 0.0, # affects hospital_load downstream, not I "mobile_units": 0.0, # reserved for cross-region β reduction "vaccine_doses": 0.0001, # 1000 doses → -0.10 I/tick (and S → R) } # Reallocation efficiency loss (5%). REALLOCATION_EFFICIENCY = 0.95 # Action log buffer size (cap on `recent_action_log`). ACTION_LOG_BUFFER = 8 # Terminal-condition thresholds (design §5a; locked). CATASTROPHIC_INFECTION_THRESHOLD = 0.30 CATASTROPHIC_REGION_COUNT = 3 SAFE_INFECTION_THRESHOLD = 0.05 SAFE_CONSECUTIVE_TICKS = 3 # ============================================================================ # Internal state types (NOT on the wire) # ============================================================================ class PendingEffect(BaseModel): """A queued resource effect that decays over a few ticks.""" kind: ResourceType magnitude: float = Field(ge=0.0) ticks_remaining: int = Field(ge=0) class SuperSpreaderEvent(BaseModel): """A scheduled latent perturbation (hard task only).""" region: RegionId fires_at_tick: int = Field(ge=0) surfaces_at_tick: int = Field(ge=0) magnitude_I: float = Field(ge=0.0, le=1.0) class ChainBeta(BaseModel): """A directed cross-region transmission coefficient.""" from_region: RegionId to_region: RegionId beta: float = Field(ge=0.0) class RegionLatentState(BaseModel): """Per-region latent SEIR state. Never serialized over the wire.""" region: RegionId S: float = Field(ge=0.0, le=1.0) E: float = Field(ge=0.0, le=1.0) I: float = Field(ge=0.0, le=1.0) R: float = Field(ge=0.0, le=1.0) true_compliance: float = Field(ge=0.0, le=1.0) history_I: List[float] = Field(default_factory=list) pending_effects: List[PendingEffect] = Field(default_factory=list) noise_reduction_ticks: int = Field(default=0, ge=0) class TaskConfig(BaseModel): """Configuration for one CrisisWorld task (design §6.5).""" name: str region_count: int = Field(ge=1) max_ticks: int = Field(default=12, ge=1) base_R0: float = Field(ge=0.0) default_cross_beta: float = Field(default=0.0, ge=0.0) chain_betas: List[ChainBeta] = Field(default_factory=list) telemetry_delay_ticks: int = Field(ge=0) telemetry_noise_stddev_cases: float = Field(ge=0.0) telemetry_noise_stddev_compliance: float = Field(ge=0.0) cognition_budget_per_tick: int = Field(default=6000, ge=0) initial_resources: ResourceInventory initial_compliance: float = Field(ge=0.0, le=1.0) initial_seir_hot: Tuple[float, float, float, float] initial_seir_quiet: Optional[Tuple[float, float, float, float]] = None hot_regions: List[RegionId] = Field(default_factory=list) quiet_regions: List[RegionId] = Field(default_factory=list) superspreader_schedule: List[SuperSpreaderEvent] = Field(default_factory=list) legal_constraints: List[LegalConstraint] = Field(default_factory=list) class WorldState(BaseModel): """Live simulator state. Holds latent fields that never reach the wire.""" task_name: Literal["outbreak_easy", "outbreak_medium", "outbreak_hard"] task_config: TaskConfig episode_seed: int tick: int = Field(default=0, ge=0) max_ticks: int = Field(ge=1) regions: List[RegionLatentState] resources: ResourceInventory restrictions: Dict[RegionId, Restriction] = Field(default_factory=dict) legal_constraints: List[LegalConstraint] = Field(default_factory=list) escalation_level: int = Field(default=0, ge=0, le=2) escalation_unlocked_strict: bool = False superspreader_schedule: List[SuperSpreaderEvent] = Field(default_factory=list) recent_action_log: List[ExecutedAction] = Field(default_factory=list) consecutive_safe_ticks: int = Field(default=0, ge=0) terminal: Literal["none", "success", "failure", "timeout"] = "none" # ============================================================================ # Determinism helpers # ============================================================================ def _derive_tick_seed(episode_seed: int, tick: int) -> int: """RNG seed for ``apply_tick`` per (episode, tick).""" return (episode_seed * 1_000_003) ^ (tick * 31) def _derive_obs_seed(episode_seed: int, tick: int) -> int: """Independent RNG seed for ``make_observation`` so observation noise is decorrelated from dynamics randomness.""" return (episode_seed * 999_983) ^ (tick * 17) # ============================================================================ # Action handlers — return accepted: bool; mutate state in place # ============================================================================ def _find_region(state: WorldState, region_id: RegionId) -> Optional[RegionLatentState]: for r in state.regions: if r.region == region_id: return r return None def _resource_attr(resource_type: ResourceType) -> str: """Map ResourceType literal to ResourceInventory attribute name.""" if resource_type == "hospital_beds": return "hospital_beds_free" return resource_type # test_kits, mobile_units, vaccine_doses def _apply_deploy_resource(state: WorldState, a: DeployResource, rng: random.Random) -> bool: region = _find_region(state, a.region) if region is None: return False attr = _resource_attr(a.resource_type) available = getattr(state.resources, attr) if available < a.quantity: return False setattr(state.resources, attr, available - a.quantity) magnitude = EFFICACY_PER_UNIT[a.resource_type] * a.quantity if magnitude > 0: region.pending_effects.append( PendingEffect( kind=a.resource_type, magnitude=magnitude, ticks_remaining=2, ) ) return True def _apply_request_data(state: WorldState, a: RequestData, rng: random.Random) -> bool: region = _find_region(state, a.region) if region is None: return False region.noise_reduction_ticks = max(region.noise_reduction_ticks, 3) return True def _apply_restrict_movement(state: WorldState, a: RestrictMovement, rng: random.Random) -> bool: if a.severity == "strict" and not state.escalation_unlocked_strict: # Legal-constraint violation: action rejected, state unchanged. return False region = _find_region(state, a.region) if region is None: return False state.restrictions[a.region] = Restriction( region=a.region, severity=a.severity, ticks_remaining=4, ) return True def _apply_escalate(state: WorldState, a: Escalate, rng: random.Random) -> bool: if a.to_authority == "national": state.escalation_unlocked_strict = True state.escalation_level = min(state.escalation_level + 1, 2) return True def _apply_reallocate_budget(state: WorldState, a: ReallocateBudget, rng: random.Random) -> bool: from_attr = _resource_attr(a.from_resource) to_attr = _resource_attr(a.to_resource) available = getattr(state.resources, from_attr) if available < a.amount: return False setattr(state.resources, from_attr, available - a.amount) transferred = round(a.amount * REALLOCATION_EFFICIENCY) setattr(state.resources, to_attr, getattr(state.resources, to_attr) + transferred) return True def _dispatch_action(state: WorldState, action: OuterActionPayload, rng: random.Random) -> bool: """Dispatch action to its handler. Returns accepted: bool.""" if action.kind == "no_op": return True if action.kind == "public_communication": return False # V2-rejected per design §6.3 / §19 if action.kind == "deploy_resource": return _apply_deploy_resource(state, action, rng) if action.kind == "request_data": return _apply_request_data(state, action, rng) if action.kind == "restrict_movement": return _apply_restrict_movement(state, action, rng) if action.kind == "escalate": return _apply_escalate(state, action, rng) if action.kind == "reallocate_budget": return _apply_reallocate_budget(state, action, rng) return False # ============================================================================ # Dynamics helpers # ============================================================================ def _apply_pending_effects(state: WorldState) -> None: """Apply queued resource effects to I (and S→R for vaccines).""" for region in state.regions: for effect in region.pending_effects: if effect.kind == "test_kits": shift = min(region.I, effect.magnitude) region.I -= shift region.R = min(1.0, region.R + shift) elif effect.kind == "vaccine_doses": shift = min(region.S, effect.magnitude) region.S -= shift region.R += shift def _apply_scheduled_superspreaders(state: WorldState) -> None: """Inject scheduled +I perturbations at their fire tick.""" for event in state.superspreader_schedule: if event.fires_at_tick == state.tick: region = _find_region(state, event.region) if region is not None: region.I = min(1.0, region.I + event.magnitude_I) def _seir_step(state: WorldState, rng: random.Random) -> None: """Discrete SEIR update for all regions. Within-region: β_within = R_0_eff * γ. Cross-region: explicit β coefficients from task config (chain + default fallback). Effective R_0 reduced by restriction severity and scaled by compliance. """ I_snapshot = {r.region: r.I for r in state.regions} for region in state.regions: # Restriction severity for this region (defaults to "none"). restriction = state.restrictions.get(region.region) sev_mult = SEVERITY_MULTIPLIER[restriction.severity] if restriction else 0.0 R_0_eff = state.task_config.base_R0 * (1 - sev_mult) * region.true_compliance beta_within = R_0_eff * GAMMA within = beta_within * region.S * region.I # Cross-region transmission: chain edges override the default. cross = 0.0 for other_id, other_I in I_snapshot.items(): if other_id == region.region: continue beta = state.task_config.default_cross_beta for ce in state.task_config.chain_betas: if ce.from_region == other_id and ce.to_region == region.region: beta = ce.beta break cross += beta * region.S * other_I new_infections = within + cross new_S = region.S - new_infections new_E = region.E + new_infections - SIGMA * region.E new_I = region.I + SIGMA * region.E - GAMMA * region.I new_R = region.R + GAMMA * region.I # Clamp + renormalize so S+E+I+R == 1.0. new_S = max(0.0, min(1.0, new_S)) new_E = max(0.0, min(1.0, new_E)) new_I = max(0.0, min(1.0, new_I)) new_R = max(0.0, min(1.0, new_R)) total = new_S + new_E + new_I + new_R if total > 0: region.S = new_S / total region.E = new_E / total region.I = new_I / total region.R = new_R / total def _compliance_dynamics(state: WorldState) -> None: """Compliance decays under strict restrictions; recovers otherwise.""" for region in state.regions: restriction = state.restrictions.get(region.region) if restriction is not None and restriction.severity == "strict": region.true_compliance = max( 0.0, region.true_compliance - COMPLIANCE_DECAY_PER_STRICT_TICK, ) else: region.true_compliance = min( 1.0, region.true_compliance + COMPLIANCE_RECOVERY_PER_RELAXED_TICK, ) def _decrement_counters(state: WorldState) -> None: """Decrement ticks_remaining counters; remove expired entries.""" for region in state.regions: if region.noise_reduction_ticks > 0: region.noise_reduction_ticks -= 1 survivors: List[PendingEffect] = [] for e in region.pending_effects: if e.ticks_remaining > 1: survivors.append( PendingEffect( kind=e.kind, magnitude=e.magnitude, ticks_remaining=e.ticks_remaining - 1, ) ) region.pending_effects = survivors new_restrictions: Dict[RegionId, Restriction] = {} for region_id, restriction in state.restrictions.items(): if restriction.ticks_remaining > 1: new_restrictions[region_id] = Restriction( region=restriction.region, severity=restriction.severity, ticks_remaining=restriction.ticks_remaining - 1, ) state.restrictions = new_restrictions def _advance_terminal_state(state: WorldState) -> None: """Update ``state.consecutive_safe_ticks`` and set ``state.terminal``. Mutating: this is NOT a pure predicate. It increments / resets the consecutive-safe counter as a side effect, then sets ``terminal`` to one of {"none", "success", "failure", "timeout"} per design §6.4. Called at end of ``apply_tick`` after ``state.tick`` has advanced. """ if state.tick >= state.max_ticks: state.terminal = "timeout" return catastrophic = sum(1 for r in state.regions if r.I > CATASTROPHIC_INFECTION_THRESHOLD) if catastrophic >= CATASTROPHIC_REGION_COUNT: state.terminal = "failure" return all_safe_now = all(r.I < SAFE_INFECTION_THRESHOLD for r in state.regions) if all_safe_now: state.consecutive_safe_ticks += 1 else: state.consecutive_safe_ticks = 0 if state.consecutive_safe_ticks >= SAFE_CONSECUTIVE_TICKS: state.terminal = "success" return state.terminal = "none" # ============================================================================ # Public API: apply_tick + make_observation # ============================================================================ def apply_tick( state: WorldState, action: OuterActionPayload, seed: Optional[int] = None, ) -> WorldState: """Advance one tick. Deterministic given (state, action, seed). Steps (in order): 1. Dispatch action to handler; record acceptance flag. 2. Append ExecutedAction to recent_action_log (capped at 8). 3. Apply queued pending_effects (resource decay). 4. Fire scheduled superspreader events (if any fire this tick). 5. SEIR step (within-region + cross-region). 6. Compliance dynamics. 7. Decrement counters (noise_reduction, restrictions, pending_effects). 8. Append post-step I to per-region history_I buffer. 9. Advance ``state.tick``. 10. Update ``state.terminal`` (mutating). Rejected actions still advance the tick — the SEIR step runs whether or not the action was accepted. """ effective_seed = seed if seed is not None else _derive_tick_seed(state.episode_seed, state.tick) rng = random.Random(effective_seed) accepted = _dispatch_action(state, action, rng) state.recent_action_log.append( ExecutedAction( tick=state.tick, action=action, accepted=accepted, ) ) if len(state.recent_action_log) > ACTION_LOG_BUFFER: state.recent_action_log = state.recent_action_log[-ACTION_LOG_BUFFER:] _apply_pending_effects(state) _apply_scheduled_superspreaders(state) _seir_step(state, rng) _compliance_dynamics(state) _decrement_counters(state) for region in state.regions: region.history_I.append(region.I) state.tick += 1 _advance_terminal_state(state) return state def make_observation( state: WorldState, seed: Optional[int] = None, ) -> CrisisworldcortexObservation: """Project latent state to wire-format observation (pure function). Applies per-region telemetry delay (history-buffer indexed at ``tick - delay``) and Gaussian noise per task config. ``request_data`` halves the noise stddev for ``noise_reduction_ticks > 0`` regions. No latent SEIR fields appear in the output — only the declared ``CrisisworldcortexObservation`` fields. """ effective_seed = seed if seed is not None else _derive_obs_seed(state.episode_seed, state.tick) rng = random.Random(effective_seed) delay = state.task_config.telemetry_delay_ticks regions_obs: List[RegionTelemetry] = [] for region in state.regions: # history_I[k] is I after the k-th apply_tick. At current tick=T, # history has T+1 entries (initial at index 0 + one per applied tick). # We want I from `delay` ticks ago: index max(0, T - delay). delayed_idx = max(0, state.tick - delay) if delayed_idx < len(region.history_I): I_delayed = region.history_I[delayed_idx] else: I_delayed = region.history_I[-1] if region.history_I else region.I # reported_cases ≈ noisy estimate, scaled to absolute count. noise_stddev_cases = state.task_config.telemetry_noise_stddev_cases if region.noise_reduction_ticks > 0: noise_stddev_cases *= 0.5 true_cases = I_delayed * POPULATION_PER_REGION observed_cases = int( rng.gauss( true_cases, noise_stddev_cases * POPULATION_PER_REGION, ) ) observed_cases = max(0, observed_cases) # hospital_load: less delayed (operational signal); current I. hospital_load = max( 0.0, min( 1.0, region.I * HOSPITALIZATION_FRACTION_OF_I / HOSPITAL_CAPACITY_FRACTION, ), ) # compliance_proxy: noisy estimate of true_compliance. noise_stddev_comp = state.task_config.telemetry_noise_stddev_compliance if region.noise_reduction_ticks > 0: noise_stddev_comp *= 0.5 compliance_proxy = max( 0.0, min( 1.0, rng.gauss(region.true_compliance, noise_stddev_comp), ), ) regions_obs.append( RegionTelemetry( region=region.region, reported_cases_d_ago=observed_cases, hospital_load=hospital_load, compliance_proxy=compliance_proxy, ) ) obs = CrisisworldcortexObservation( regions=regions_obs, resources=state.resources, active_restrictions=list(state.restrictions.values()), legal_constraints=state.legal_constraints, tick=state.tick, ticks_remaining=max(0, state.max_ticks - state.tick), cognition_budget_remaining=state.task_config.cognition_budget_per_tick, recent_action_log=list(state.recent_action_log), ) obs.done = state.terminal != "none" return obs