valhalla / backend /src /core /checkpoint_manager.py
Dontcryx_07
final
d660fa9
Raw
History Blame Contribute Delete
12.1 kB
"""checkpoint_manager — per-tick world state persistence.
Saves WorldState, AgentRegistry (including the serialized action manager),
engine-owned state, and Python RNG state to gzip JSON after every tick,
and loads them for resume/rewind; prunes to a one-day window.
Architecture: called by WorldEngine each tick and at day handoff; enables
the server's resume/rewind endpoints and the --resume-checkpoint flag.
Design: atomic writes (.tmp + rename); saved RNG state makes collision
resolution replay identically.
"""
from __future__ import annotations
import json
import gzip
import random
import time as _time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from src.core.log import get_logger
from src.core.world_state import WorldState, Position
from src.core.agent_registry import AgentRegistry, AgentRuntimeState
from src.agents.Actions import AgentActionManager, ActionState, ActionType, LocationResolver
from src.config import DATA_DIR
logger = get_logger(__name__)
CHECKPOINT_DIR = DATA_DIR / "checkpoints"
HISTORY_DIR = DATA_DIR / "history"
# One full simulated day of per-tick snapshots gives observers a complete
# rewind window without retaining prior days indefinitely.
KEEP_LAST = 1440
SCHEMA_VERSION = 2
CHECKPOINT_SUFFIX = ".json.gz"
LEGACY_CHECKPOINT_SUFFIX = ".json"
def _tick_filename(tick: int) -> str:
return f"tick_{tick:05d}{CHECKPOINT_SUFFIX}"
def _legacy_tick_filename(tick: int) -> str:
return f"tick_{tick:05d}{LEGACY_CHECKPOINT_SUFFIX}"
def _parse_tick_from_name(name: str) -> int:
if name.endswith(CHECKPOINT_SUFFIX):
stem = name[: -len(CHECKPOINT_SUFFIX)]
elif name.endswith(LEGACY_CHECKPOINT_SUFFIX):
stem = name[: -len(LEGACY_CHECKPOINT_SUFFIX)]
else:
raise ValueError(f"not a checkpoint filename: {name}")
return int(stem.removeprefix("tick_"))
def _checkpoint_path(tick: int) -> Path:
"""Prefer compressed checkpoints while retaining read compatibility."""
compressed = CHECKPOINT_DIR / _tick_filename(tick)
if compressed.exists():
return compressed
return CHECKPOINT_DIR / _legacy_tick_filename(tick)
def _checkpoint_paths(tick: int) -> tuple[Path, Path]:
return (
CHECKPOINT_DIR / _tick_filename(tick),
CHECKPOINT_DIR / _legacy_tick_filename(tick),
)
# ------------------------------------------------------------------ #
# Save
# ------------------------------------------------------------------ #
def _manager_to_dict(manager: AgentActionManager) -> Dict[str, Any]:
"""Serialize an AgentActionManager's internal state."""
def _action_dict(a: Optional[ActionState]) -> Optional[Dict[str, Any]]:
if a is None:
return None
# ActionState contains more than the display fields. In particular,
# its resolved destination and energy/emotion deltas affect subsequent
# movement and behaviour, so preserve the entire serializable model.
return a.model_dump(mode="json")
return {
"day_plan": manager.day_plan,
"last_action": _action_dict(manager.last_action),
"current_action": _action_dict(manager.current_action),
"next_action": _action_dict(manager.next_action),
"_conversation_mode": manager._conversation_mode,
"_pending_plan_action": _action_dict(manager._pending_plan_action),
"_entered_last_action": manager._entered_last_action,
}
def _agent_to_dict(state: AgentRuntimeState) -> Dict[str, Any]:
"""Serialize one agent's runtime state."""
return {
"agent_id": state.agent_id,
"persona": state.persona,
"persona_name": state.persona_name,
"position": state.position.model_dump(),
"paused": state.paused,
"day_plan": state.day_plan,
"day_archived": state.day_archived,
"conversation_start_tick": state.conversation_start_tick,
"conversation_count": state.conversation_count,
"replan_count": state.replan_count,
"last_conversation_partner": state.last_conversation_partner,
"active_conversation": state.active_conversation,
"emotion_state": state.emotion_state,
"emotion_baseline": state.emotion_baseline,
"energy_level": state.energy_level,
"color": state.color,
"manager": _manager_to_dict(state.manager) if state.manager else None,
}
def save_checkpoint(
world: WorldState,
registry: AgentRegistry,
tick: int,
engine_state: Optional[Dict[str, Any]] = None,
) -> Optional[Path]:
"""Save current simulation state as an atomically replaced gzip JSON file."""
CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
path = CHECKPOINT_DIR / _tick_filename(tick)
agents_data = [_agent_to_dict(s) for s in registry.all_states()]
# History is not needed to advance an action, but it is part of the
# observable world and is required for a faithful replay after restore.
world_dict = json.loads(world.model_dump_json())
payload = {
"schema_version": SCHEMA_VERSION,
"tick": tick,
"world": world_dict,
"agents": agents_data,
"engine": engine_state or {},
# The simulation uses Python's process-wide RNG for collision nudges
# and interior placement. JSON converts tuples to lists, which are
# converted back on load before passing the state to random.setstate.
"random_state": random.getstate(),
"saved_at": _time.strftime("%Y-%m-%d %H:%M:%S"),
}
tmp = path.with_suffix(path.suffix + ".tmp")
with gzip.open(tmp, "wt", encoding="utf-8") as handle:
handle.write(json.dumps(payload, separators=(",", ":"), default=str))
tmp.replace(path)
return path
# ------------------------------------------------------------------ #
# History
# ------------------------------------------------------------------ #
def save_history(world: WorldState, date: str) -> Optional[Path]:
"""Save per-day action history to ``data/history/<date>_history.json``."""
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
path = HISTORY_DIR / f"{date}_history.json"
history_data = [entry.model_dump() for entry in world.history]
path.write_text(json.dumps(history_data, indent=2, default=str))
logger.info("[Checkpoint] saved history for %s (%d entries)", date, len(history_data))
return path
# ------------------------------------------------------------------ #
# Load
# ------------------------------------------------------------------ #
def _action_from_dict(d: Optional[Dict[str, Any]]) -> Optional[ActionState]:
"""Reconstruct an ActionState from a saved dict."""
if d is None:
return None
# model_validate also remains compatible with version-1 checkpoints,
# whose omitted ActionState fields have model defaults.
return ActionState.model_validate(d)
def _lists_to_tuples(value: Any) -> Any:
"""Restore the tuple structure required by random.setstate()."""
if isinstance(value, list):
return tuple(_lists_to_tuples(item) for item in value)
return value
def _restore_manager(
data: Dict[str, Any],
resolver: LocationResolver,
) -> AgentActionManager:
"""Reconstruct an AgentActionManager from saved state dict."""
position = Position(**data["position"])
manager = AgentActionManager(
agent_id=data["agent_id"],
day_plan=data.get("day_plan", data["manager"]["day_plan"]) if data.get("manager") else [],
initial_position=position,
resolver=resolver,
)
if data.get("manager"):
m = data["manager"]
manager.last_action = _action_from_dict(m.get("last_action"))
manager.current_action = _action_from_dict(m.get("current_action"))
manager.next_action = _action_from_dict(m.get("next_action"))
manager._conversation_mode = m.get("_conversation_mode", False)
manager._pending_plan_action = _action_from_dict(m.get("_pending_plan_action"))
manager._entered_last_action = m.get("_entered_last_action", False)
return manager
def load_checkpoint(
tick: int,
resolver: LocationResolver,
return_metadata: bool = False,
) -> Tuple[WorldState, AgentRegistry] | Tuple[WorldState, AgentRegistry, Dict[str, Any]]:
"""Load simulation state from a checkpoint file."""
path = _checkpoint_path(tick)
if not path.exists():
raise FileNotFoundError(f"No checkpoint for tick {tick} at {path}")
if path.suffix == ".gz":
with gzip.open(path, "rt", encoding="utf-8") as handle:
raw = json.load(handle)
else:
raw = json.loads(path.read_text(encoding="utf-8"))
if raw.get("random_state") is not None:
random.setstate(_lists_to_tuples(raw["random_state"]))
world = WorldState.model_validate(raw["world"])
registry = AgentRegistry()
for agent_data in raw["agents"]:
position = Position(**agent_data["position"])
manager = _restore_manager(agent_data, resolver)
state = AgentRuntimeState(
agent_id=agent_data["agent_id"],
persona=agent_data["persona"],
persona_name=agent_data["persona_name"],
manager=manager,
position=position,
paused=agent_data["paused"],
day_plan=agent_data["day_plan"],
day_archived=agent_data["day_archived"],
conversation_start_tick=agent_data.get("conversation_start_tick", 0),
conversation_count=agent_data.get("conversation_count", 0),
replan_count=agent_data.get("replan_count", 0),
last_conversation_partner=agent_data.get("last_conversation_partner"),
active_conversation=agent_data.get("active_conversation"),
emotion_state=agent_data.get("emotion_state", 0.5),
emotion_baseline=agent_data.get("emotion_baseline", 0.5),
energy_level=agent_data.get("energy_level", 1.0),
color=agent_data.get("color", "#888888"),
)
state.current_action = (
manager.current_action.model_dump()
if manager.current_action else None
)
registry.register(state)
logger.info(
"[Checkpoint] loaded tick %d: %d agents, world.tick=%d",
tick, len(registry), world.tick,
)
if return_metadata:
return world, registry, raw.get("engine", {})
return world, registry
# ------------------------------------------------------------------ #
# List & Prune
# ------------------------------------------------------------------ #
def list_checkpoints() -> List[int]:
"""Return sorted list of available checkpoint tick numbers."""
if not CHECKPOINT_DIR.exists():
return []
ticks = []
for f in sorted(CHECKPOINT_DIR.iterdir()):
if f.is_file() and f.name.startswith("tick_") and (
f.name.endswith(CHECKPOINT_SUFFIX) or f.name.endswith(LEGACY_CHECKPOINT_SUFFIX)
):
try:
ticks.append(_parse_tick_from_name(f.name))
except (ValueError, IndexError):
continue
return sorted(set(ticks))
def prune_checkpoints(keep_last: int = KEEP_LAST) -> int:
"""Remove all checkpoints except the ``keep_last`` most recent ones.
Returns the number of deleted files."""
ticks = list_checkpoints()
if len(ticks) <= keep_last:
return 0
to_delete = ticks[:-keep_last]
count = 0
for t in to_delete:
for path in _checkpoint_paths(t):
if not path.exists():
continue
try:
path.unlink()
count += 1
except OSError as e:
logger.warning("[Checkpoint] failed to delete %s: %s", path, e)
if count:
logger.info("[Checkpoint] pruned %d old checkpoints, kept last %d", count, keep_last)
return count
def latest_tick() -> Optional[int]:
"""Return the highest available tick number, or None."""
ticks = list_checkpoints()
return ticks[-1] if ticks else None