Source code for langgoap.state

"""Immutable planning state for GOAP world representation."""

from __future__ import annotations

import logging
from collections.abc import Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from langgoap.actions import ActionSpec

logger = logging.getLogger(__name__)


[docs] @dataclass(frozen=True, slots=True) class PlanningState: """Immutable, hashable representation of a GOAP world state. Internally stores state as a frozenset of (key, value) tuples to ensure immutability and hashability. This enables use as dictionary keys and in sets — critical for A* closed-set tracking. The public attribute :attr:`conditions` exposes the underlying frozenset so callers can inspect it directly without an allocation. In practice, a GOAP world state dict may contain both **planning flags** (hashable booleans/scalars used by the A* planner) and **execution context** (rich data like document lists or LLM responses). Use the ``keys`` parameter of :meth:`from_dict` to extract only the planning-relevant subset. """ conditions: frozenset[tuple[str, Any]] @classmethod def from_dict( cls, d: dict[str, Any], *, keys: set[str] | None = None ) -> PlanningState: """Create a PlanningState from a dictionary. Args: d: Source dictionary (typically the full ``world_state``). keys: If provided, only include entries whose key is in this set. Useful for filtering a rich world-state dict down to planning-relevant boolean flags. Non-hashable values are silently dropped (with a warning log) because ``PlanningState`` requires all values to be hashable for use as A* closed-set keys. """ if keys is not None: d = {k: v for k, v in d.items() if k in keys} items: list[tuple[str, Any]] = [] for k, v in d.items(): try: hash(v) items.append((k, v)) except TypeError: logger.warning( "Skipping unhashable value for key %r (type=%s)", k, type(v).__name__, ) return cls(conditions=frozenset(items)) def to_dict(self) -> dict[str, Any]: """Convert to a mutable dictionary snapshot.""" return dict(self.conditions) def satisfies(self, requirements: Mapping[str, Any]) -> bool: """Check if this state satisfies all given conditions. Returns True if every key-value pair in ``requirements`` is present in this state. Missing keys cause failure. Uses direct frozenset membership to avoid creating an intermediate dict — important for A* hot paths. """ for k, v in requirements.items(): if (k, v) not in self.conditions: return False return True def apply(self, effects: Mapping[str, Any]) -> PlanningState: """Return a new PlanningState with effects applied. Existing keys are overwritten; new keys are added. The original state is not modified. """ updated = self.to_dict() updated.update(effects) return PlanningState.from_dict(updated) def get(self, key: str, default: Any = None) -> Any: """Get a value by key, returning default if missing. Scans the frozenset directly to avoid a full dict allocation. """ for k, v in self.conditions: if k == key: return v return default def __len__(self) -> int: return len(self.conditions) def __contains__(self, key: str) -> bool: return any(k == key for k, _ in self.conditions) def __repr__(self) -> str: return f"PlanningState({self.to_dict()!r})"
def infer_start_state(actions: list[ActionSpec]) -> dict[str, bool]: """Build a clean-slate world state from action preconditions and effects. Collects every condition key referenced in preconditions and boolean effects, then sets each to ``False``. Non-boolean effect values are skipped. This eliminates the hand-written start-state factories common in notebooks and tutorial modules:: start = infer_start_state(actions) # → {"has_data": False, "report_complete": False, ...} Args: actions: List of actions whose preconditions/effects define the planning state space. Returns: A dict mapping every discovered boolean condition key to ``False``. """ keys: set[str] = set() for a in actions: keys.update(a.preconditions.keys()) if a.has_dynamic_effects: # Dynamic effects don't expose concrete values; skip — callers # should pass a real starting state when using them. continue for k, v in a.effects.items(): # type: ignore[union-attr] if isinstance(v, bool): keys.add(k) return {k: False for k in sorted(keys)}