WorldSmithAI / policies /rule_policy.py
Srishti280992's picture
Upload 39 files
caad8d0 verified
Raw History Blame
45.4 kB
"""
Rule-based policy implementation for WorldSmithAI.
This module provides a deterministic, domain-agnostic policy that selects
behaviors using configurable rules. It deliberately avoids hardcoded species,
world types, behavior meanings, or domain-specific assumptions.
A rule can:
- target one behavior or many behaviors by name.
- match generic conditions over agent, world, or behavior paths.
- add score and priority contributions.
- block a behavior when conditions are met.
- apply cooldowns per agent and rule.
- score numeric context paths.
Example:
policy = RulePolicy(
rules=(
BehaviorRule(
id="low_energy_prefers_recovery",
behavior_name="consume",
conditions={"state.energy": {"max": 2.0}},
score_delta=10.0,
priority_delta=1.0,
),
BehaviorRule(
id="avoid_unavailable_budget",
behavior_name="trade",
action=RuleAction.BLOCK,
conditions={"state.credits": {"max": 0.0}},
),
)
)
selected_behavior = policy.choose_action(agent, world, agent.behaviors)
Future extensibility:
- Load rule sets directly from DSL schemas.
- Add explainable policy traces to Gradio.
- Add rule groups and hierarchical rule policies.
- Add soft constraints and utility functions.
- Add policy distillation into contextual bandit priors.
"""
from __future__ import annotations
import copy
import logging
from collections.abc import Iterable, Mapping, MutableMapping, MutableSequence, Sequence
from dataclasses import dataclass, field
from enum import Enum
from numbers import Real
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, ClassVar
from core.behavior import Behavior
from policies.base_policy import BasePolicy, BehaviorCollection
if TYPE_CHECKING:
from core.agent import Agent
from core.world import World
logger = logging.getLogger(__name__)
_MISSING = object()
_EPSILON = 1.0e-12
class RuleAction(str, Enum):
"""Supported actions a matching rule may take."""
SCORE = "score"
BLOCK = "block"
class RuleSelectionStrategy(str, Enum):
"""Supported deterministic selection strategies."""
HIGHEST_SCORE = "highest_score"
FIRST_VALID = "first_valid"
FIRST_MATCH = "first_match"
@dataclass(frozen=True)
class RuleMatchResult:
"""Result of evaluating one rule against one behavior candidate."""
rule_id: str
matched: bool
action: RuleAction
score_contribution: float = 0.0
priority_contribution: float = 0.0
reason: str | None = None
components: Mapping[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""Return a JSON-friendly representation of this rule match."""
return {
"rule_id": self.rule_id,
"matched": self.matched,
"action": self.action.value,
"score_contribution": self.score_contribution,
"priority_contribution": self.priority_contribution,
"reason": self.reason,
"components": copy.deepcopy(dict(self.components)),
}
@dataclass(frozen=True)
class RuleEvaluation:
"""Policy evaluation for a single behavior candidate."""
behavior: Behavior = field(repr=False, compare=False)
behavior_name: str
behavior_index: int
available: bool
score: float
priority: float
matched_rule_ids: tuple[str, ...] = ()
blocked_rule_ids: tuple[str, ...] = ()
reason: str | None = None
rule_results: tuple[RuleMatchResult, ...] = ()
metadata: Mapping[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""Return a JSON-friendly representation of this evaluation."""
return {
"behavior_name": self.behavior_name,
"behavior_index": self.behavior_index,
"available": self.available,
"score": self.score,
"priority": self.priority,
"matched_rule_ids": list(self.matched_rule_ids),
"blocked_rule_ids": list(self.blocked_rule_ids),
"reason": self.reason,
"rule_results": [result.to_dict() for result in self.rule_results],
"metadata": copy.deepcopy(dict(self.metadata)),
}
def _agent_id(agent: Agent) -> str:
"""Return a stable string identifier for an agent."""
return str(getattr(agent, "id", "unknown_agent"))
def _world_step(world: World) -> int | None:
"""Return the current world step if available."""
value = getattr(world, "step_count", None)
if isinstance(value, Real) and not isinstance(value, bool):
return int(value)
return None
def _is_number(value: Any) -> bool:
"""Return whether a value is a real numeric scalar, excluding booleans."""
return isinstance(value, Real) and not isinstance(value, bool)
def _as_float(value: Any, default: float = 0.0) -> float:
"""Safely convert a value to float when possible."""
if _is_number(value):
return float(value)
return default
def _value_as_score(value: Any, default: float = 0.0) -> float:
"""Convert arbitrary context values into deterministic numeric scores."""
if isinstance(value, bool):
return 1.0 if value else 0.0
if _is_number(value):
return float(value)
if value is None or value is _MISSING:
return default
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
return float(len(value))
if isinstance(value, Mapping):
return float(len(value))
return default
def _append_bounded(items: MutableSequence[Any], value: Any, max_items: int) -> None:
"""Append an item while enforcing an optional maximum history length."""
items.append(value)
if max_items > 0 and len(items) > max_items:
del items[: len(items) - max_items]
def _split_path(path: str) -> tuple[str, ...]:
"""Split a dot-separated path into components."""
return tuple(part for part in str(path).split(".") if part)
def _get_mapping_path(container: Mapping[str, Any], path: str, default: Any = _MISSING) -> Any:
"""Read a nested mapping value using dot notation."""
parts = _split_path(path)
if not parts:
return default
current: Any = container
for part in parts:
if not isinstance(current, Mapping) or part not in current:
return default
current = current[part]
return current
def _get_object_path(root: Any, path: str, default: Any = _MISSING) -> Any:
"""Read nested values from mappings, sequences, or object attributes."""
parts = _split_path(path)
if not parts:
return root
current: Any = root
for part in parts:
if isinstance(current, Mapping):
if part not in current:
return default
current = current[part]
continue
if (
isinstance(current, Sequence)
and not isinstance(current, (str, bytes))
and part.isdigit()
):
index = int(part)
if index >= len(current):
return default
current = current[index]
continue
if not hasattr(current, part):
return default
current = getattr(current, part)
return current
def _agent_state(agent: Agent) -> Mapping[str, Any]:
"""Return an agent state mapping without requiring a concrete state class."""
state = getattr(agent, "state", {})
return state if isinstance(state, Mapping) else {}
def _agent_memory(agent: Agent) -> Mapping[str, Any]:
"""Return an agent memory mapping without requiring a concrete memory class."""
memory = getattr(agent, "memory", {})
return memory if isinstance(memory, Mapping) else {}
def _mutable_agent_memory(agent: Agent) -> MutableMapping[str, Any]:
"""Return mutable agent memory, creating one when necessary."""
memory = getattr(agent, "memory", None)
if isinstance(memory, MutableMapping):
return memory
replacement: dict[str, Any] = {}
setattr(agent, "memory", replacement)
return replacement
def _read_context_value(
agent: Agent,
world: World,
behavior: Behavior | None,
path: str,
default: Any = _MISSING,
) -> Any:
"""Read a generic path from agent, world, behavior, state, or memory.
Supported prefixes:
- ``state.foo``
- ``state:foo``
- ``memory.foo``
- ``memory:foo``
- ``agent.foo``
- ``world.foo``
- ``behavior.foo``
If no prefix is supplied, state is checked first, then memory, then agent
attributes.
"""
normalized_path = str(path)
if normalized_path.startswith("state."):
return _get_mapping_path(
_agent_state(agent),
normalized_path.removeprefix("state."),
default,
)
if normalized_path.startswith("state:"):
return _get_mapping_path(
_agent_state(agent),
normalized_path.removeprefix("state:"),
default,
)
if normalized_path.startswith("memory."):
return _get_mapping_path(
_agent_memory(agent),
normalized_path.removeprefix("memory."),
default,
)
if normalized_path.startswith("memory:"):
return _get_mapping_path(
_agent_memory(agent),
normalized_path.removeprefix("memory:"),
default,
)
if normalized_path.startswith("agent."):
return _get_object_path(
agent,
normalized_path.removeprefix("agent."),
default,
)
if normalized_path.startswith("world."):
return _get_object_path(
world,
normalized_path.removeprefix("world."),
default,
)
if normalized_path.startswith("behavior."):
if behavior is None:
return default
return _get_object_path(
behavior,
normalized_path.removeprefix("behavior."),
default,
)
state_value = _get_mapping_path(_agent_state(agent), normalized_path, _MISSING)
if state_value is not _MISSING:
return state_value
memory_value = _get_mapping_path(_agent_memory(agent), normalized_path, _MISSING)
if memory_value is not _MISSING:
return memory_value
return _get_object_path(agent, normalized_path, default)
def _safe_equal(left: Any, right: Any) -> bool:
"""Return safe equality for arbitrary DSL values."""
try:
return bool(left == right)
except (TypeError, ValueError):
return False
def _constraint_matches(actual: Any, expected: Any) -> bool:
"""Return whether a value satisfies a generic constraint specification."""
if isinstance(expected, Mapping):
exists = expected.get("exists")
if exists is not None:
has_value = actual is not _MISSING and actual is not None
if bool(exists) != has_value:
return False
if actual is _MISSING:
return False
if "equals" in expected and not _safe_equal(actual, expected["equals"]):
return False
if "not_equals" in expected and _safe_equal(actual, expected["not_equals"]):
return False
if "min" in expected:
if not _is_number(actual) or float(actual) < float(expected["min"]):
return False
if "max" in expected:
if not _is_number(actual) or float(actual) > float(expected["max"]):
return False
if "between" in expected:
bounds = expected["between"]
if (
not isinstance(bounds, Sequence)
or isinstance(bounds, (str, bytes))
or len(bounds) != 2
or not _is_number(actual)
):
return False
lower, upper = float(bounds[0]), float(bounds[1])
if float(actual) < lower or float(actual) > upper:
return False
if "in" in expected:
valid_values = expected["in"]
if not isinstance(valid_values, Iterable) or isinstance(valid_values, (str, bytes)):
return False
if actual not in valid_values:
return False
if "not_in" in expected:
invalid_values = expected["not_in"]
if isinstance(invalid_values, Iterable) and not isinstance(invalid_values, (str, bytes)):
if actual in invalid_values:
return False
if "contains" in expected:
contained_value = expected["contains"]
if isinstance(actual, Mapping):
if contained_value not in actual:
return False
elif isinstance(actual, str):
if str(contained_value) not in actual:
return False
elif isinstance(actual, Iterable) and not isinstance(actual, (str, bytes)):
if contained_value not in actual:
return False
else:
return False
if "truthy" in expected and bool(actual) != bool(expected["truthy"]):
return False
if "falsy" in expected and bool(actual) == bool(expected["falsy"]):
return False
return True
if actual is _MISSING:
return False
return _safe_equal(actual, expected)
def _conditions_match(
agent: Agent,
world: World,
behavior: Behavior | None,
conditions: Mapping[str, Any],
) -> tuple[bool, str | None]:
"""Return whether all configured conditions match."""
for path, expected in conditions.items():
actual = _read_context_value(agent, world, behavior, str(path), _MISSING)
if not _constraint_matches(actual, expected):
return False, f"condition_failed:{path}"
return True, None
def _normalize_action(action: RuleAction | str) -> RuleAction:
"""Normalize a rule action."""
if isinstance(action, RuleAction):
return action
return RuleAction(str(action))
def _normalize_strategy(strategy: RuleSelectionStrategy | str) -> RuleSelectionStrategy:
"""Normalize a selection strategy."""
if isinstance(strategy, RuleSelectionStrategy):
return strategy
return RuleSelectionStrategy(str(strategy))
def _name_matches(candidate_name: str, expected_names: Iterable[str]) -> bool:
"""Return whether a behavior name matches any expected name."""
normalized_candidate = str(candidate_name)
normalized_candidate_lower = normalized_candidate.lower()
for expected in expected_names:
expected_text = str(expected)
if normalized_candidate == expected_text:
return True
if normalized_candidate_lower == expected_text.lower():
return True
return False
@dataclass
class BehaviorRule:
"""Configurable rule used by ``RulePolicy``.
A rule is domain-agnostic. It targets behavior names and generic path
constraints rather than concrete agent classes or species.
Attributes:
id: Optional stable rule identifier.
behavior_name: Optional single target behavior name.
behavior_names: Optional additional target behavior names.
action: ``score`` to add score, or ``block`` to disallow behavior.
conditions: Generic path constraints required for the rule to match.
score_delta: Score added when the rule matches.
priority_delta: Priority added when the rule matches.
score_paths: Mapping of context paths to numeric weights.
min_step: Earliest world step at which the rule may match.
max_step: Latest world step at which the rule may match.
cooldown_steps: Per-agent rule cooldown after the selected behavior uses
this rule.
enabled: Whether this rule is active.
metadata: Optional serializable metadata.
"""
id: str | None = None
behavior_name: str | None = None
behavior_names: tuple[str, ...] = ()
action: RuleAction | str = RuleAction.SCORE
conditions: Mapping[str, Any] = field(default_factory=dict)
score_delta: float = 1.0
priority_delta: float = 0.0
score_paths: Mapping[str, float] = field(default_factory=dict)
min_step: int | None = None
max_step: int | None = None
cooldown_steps: int = 0
enabled: bool = True
metadata: Mapping[str, Any] = field(default_factory=dict)
def identifier(self, index: int) -> str:
"""Return a stable rule identifier."""
if self.id:
return str(self.id)
target = self.behavior_name or "any"
return f"rule_{index}:{target}"
def normalized_action(self) -> RuleAction:
"""Return the normalized rule action."""
return _normalize_action(self.action)
def target_names(self) -> tuple[str, ...]:
"""Return all configured behavior target names."""
names: list[str] = []
if self.behavior_name is not None:
names.append(str(self.behavior_name))
names.extend(str(name) for name in self.behavior_names)
return tuple(names)
def applies_to(self, behavior: Behavior, behavior_name: str) -> bool:
"""Return whether this rule applies to a behavior candidate."""
expected_names = self.target_names()
if not expected_names:
return True
class_name = behavior.__class__.__name__
return _name_matches(behavior_name, expected_names) or _name_matches(class_name, expected_names)
def evaluate(
self,
*,
rule_id: str,
agent: Agent,
world: World,
behavior: Behavior,
behavior_name: str,
) -> RuleMatchResult:
"""Evaluate this rule against a behavior candidate."""
if not self.enabled:
return RuleMatchResult(
rule_id=rule_id,
matched=False,
action=self.normalized_action(),
reason="rule_disabled",
)
if not self.applies_to(behavior, behavior_name):
return RuleMatchResult(
rule_id=rule_id,
matched=False,
action=self.normalized_action(),
reason="behavior_name_not_targeted",
)
step = _world_step(world)
if self.min_step is not None and step is not None and step < int(self.min_step):
return RuleMatchResult(
rule_id=rule_id,
matched=False,
action=self.normalized_action(),
reason="before_min_step",
)
if self.max_step is not None and step is not None and step > int(self.max_step):
return RuleMatchResult(
rule_id=rule_id,
matched=False,
action=self.normalized_action(),
reason="after_max_step",
)
matched, reason = _conditions_match(agent, world, behavior, self.conditions)
if not matched:
return RuleMatchResult(
rule_id=rule_id,
matched=False,
action=self.normalized_action(),
reason=reason,
)
score_path_components: dict[str, float] = {}
score_path_total = 0.0
for path, weight in self.score_paths.items():
raw_value = _read_context_value(agent, world, behavior, str(path), _MISSING)
numeric_value = _value_as_score(raw_value)
contribution = numeric_value * float(weight)
score_path_components[str(path)] = contribution
score_path_total += contribution
score_contribution = float(self.score_delta) + score_path_total
priority_contribution = float(self.priority_delta)
return RuleMatchResult(
rule_id=rule_id,
matched=True,
action=self.normalized_action(),
score_contribution=score_contribution,
priority_contribution=priority_contribution,
reason="matched",
components={
"score_delta": float(self.score_delta),
"score_paths": score_path_components,
"priority_delta": priority_contribution,
"metadata": copy.deepcopy(dict(self.metadata)),
},
)
def to_dict(self) -> dict[str, Any]:
"""Return a serializable rule representation."""
return {
"id": self.id,
"behavior_name": self.behavior_name,
"behavior_names": list(self.behavior_names),
"action": self.normalized_action().value,
"conditions": copy.deepcopy(dict(self.conditions)),
"score_delta": float(self.score_delta),
"priority_delta": float(self.priority_delta),
"score_paths": copy.deepcopy(dict(self.score_paths)),
"min_step": self.min_step,
"max_step": self.max_step,
"cooldown_steps": int(self.cooldown_steps),
"enabled": bool(self.enabled),
"metadata": copy.deepcopy(dict(self.metadata)),
}
@classmethod
def from_mapping(cls, data: Mapping[str, Any]) -> BehaviorRule:
"""Create a behavior rule from a mapping."""
behavior_names_raw = data.get("behavior_names", ())
if isinstance(behavior_names_raw, str):
behavior_names = (behavior_names_raw,)
elif isinstance(behavior_names_raw, Iterable):
behavior_names = tuple(str(name) for name in behavior_names_raw)
else:
behavior_names = ()
conditions = data.get("conditions", {})
score_paths = data.get("score_paths", {})
metadata = data.get("metadata", {})
return cls(
id=None if data.get("id") is None else str(data.get("id")),
behavior_name=None
if data.get("behavior_name") is None
else str(data.get("behavior_name")),
behavior_names=behavior_names,
action=str(data.get("action", RuleAction.SCORE.value)),
conditions=dict(conditions) if isinstance(conditions, Mapping) else {},
score_delta=_as_float(data.get("score_delta"), 1.0),
priority_delta=_as_float(data.get("priority_delta"), 0.0),
score_paths=dict(score_paths) if isinstance(score_paths, Mapping) else {},
min_step=None if data.get("min_step") is None else int(data["min_step"]),
max_step=None if data.get("max_step") is None else int(data["max_step"]),
cooldown_steps=int(data.get("cooldown_steps", 0)),
enabled=bool(data.get("enabled", True)),
metadata=dict(metadata) if isinstance(metadata, Mapping) else {},
)
def _coerce_rule(rule: BehaviorRule | Mapping[str, Any]) -> BehaviorRule:
"""Convert rule-like input into a ``BehaviorRule``."""
if isinstance(rule, BehaviorRule):
return rule
if isinstance(rule, Mapping):
return BehaviorRule.from_mapping(rule)
raise TypeError(f"Unsupported rule specification: {rule!r}")
@dataclass
class RulePolicy(BasePolicy):
"""Deterministic rule-based policy.
The policy evaluates currently available behaviors, applies matching rules,
and chooses one behavior according to a deterministic selection strategy.
Rules do not execute behavior logic. They only score, prioritize, or block
behavior candidates.
"""
name: ClassVar[str] = "rule_policy"
rules: tuple[BehaviorRule | Mapping[str, Any], ...] = ()
selection_strategy: RuleSelectionStrategy | str = RuleSelectionStrategy.HIGHEST_SCORE
default_score: float = 0.0
default_priority: float = 0.0
minimum_score: float | None = None
allow_unmatched_behaviors: bool = True
disabled_behavior_names: tuple[str, ...] = ()
behavior_cooldowns: Mapping[str, int] = field(default_factory=dict)
default_behavior_cooldown_steps: int = 0
write_decisions_to_agent_memory: bool = False
decision_memory_key: str = "policy_decisions"
latest_decision_memory_key: str = "latest_policy_decision"
max_decision_history: int = 500
max_reward_history: int = 500
_decision_count: int = field(default=0, init=False, repr=False)
_last_behavior_steps: dict[str, int] = field(default_factory=dict, init=False, repr=False)
_last_rule_steps: dict[str, int] = field(default_factory=dict, init=False, repr=False)
_last_evaluations: tuple[RuleEvaluation, ...] = field(default_factory=tuple, init=False, repr=False)
_decision_history: list[dict[str, Any]] = field(default_factory=list, init=False, repr=False)
_reward_history: list[dict[str, Any]] = field(default_factory=list, init=False, repr=False)
def __post_init__(self) -> None:
"""Normalize rule specifications after initialization."""
self.rules = tuple(_coerce_rule(rule) for rule in self.rules)
def choose_action(
self,
agent: Agent,
world: World,
behaviors: BehaviorCollection | None = None,
) -> Behavior | None:
"""Choose one behavior according to configured rules.
Args:
agent: Agent requesting a behavior.
world: Current world.
behaviors: Optional behavior collection. If omitted, reads
``agent.behaviors``.
Returns:
Selected behavior, or ``None`` when no behavior is available.
"""
if not self.enabled:
self._record_decision(agent, world, None, (), "policy_disabled")
return None
candidates = self.candidate_behaviors(agent, world, behaviors)
evaluations = tuple(
self.evaluate_behavior(
agent=agent,
world=world,
behavior=candidate.behavior,
behavior_index=candidate.index,
precondition_available=candidate.available,
precondition_reason=candidate.reason,
)
for candidate in candidates
)
self._last_evaluations = evaluations
selectable = tuple(evaluation for evaluation in evaluations if evaluation.available)
if not selectable:
self._record_decision(agent, world, None, evaluations, "no_selectable_behaviors")
return None
selected_evaluation = self._select_evaluation(selectable)
self._record_selection_state(agent, world, selected_evaluation)
self._record_decision(agent, world, selected_evaluation, evaluations, "selected")
logger.debug(
"RulePolicy selected behavior %s for agent %s with score %.3f",
selected_evaluation.behavior_name,
_agent_id(agent),
selected_evaluation.score,
)
return selected_evaluation.behavior
def evaluate_behavior(
self,
*,
agent: Agent,
world: World,
behavior: Behavior,
behavior_index: int = 0,
precondition_available: bool = True,
precondition_reason: str | None = None,
) -> RuleEvaluation:
"""Evaluate one behavior candidate against this policy's rules."""
behavior_name = self.behavior_name(behavior)
if not precondition_available:
return RuleEvaluation(
behavior=behavior,
behavior_name=behavior_name,
behavior_index=behavior_index,
available=False,
score=float("-inf"),
priority=float("-inf"),
reason=precondition_reason or "preconditions_not_met",
)
if _name_matches(behavior_name, self.disabled_behavior_names):
return RuleEvaluation(
behavior=behavior,
behavior_name=behavior_name,
behavior_index=behavior_index,
available=False,
score=float("-inf"),
priority=float("-inf"),
reason="behavior_disabled_by_policy",
)
cooldown_active, cooldown_reason = self._behavior_cooldown_status(agent, world, behavior_name)
if cooldown_active:
return RuleEvaluation(
behavior=behavior,
behavior_name=behavior_name,
behavior_index=behavior_index,
available=False,
score=float("-inf"),
priority=float("-inf"),
reason=cooldown_reason,
)
score = float(self.default_score)
priority = float(self.default_priority)
matched_rule_ids: list[str] = []
blocked_rule_ids: list[str] = []
rule_results: list[RuleMatchResult] = []
for index, rule in enumerate(self.rules):
rule_id = rule.identifier(index)
rule_action = rule.normalized_action()
if self._rule_cooldown_active(agent, world, rule_id, rule.cooldown_steps):
rule_results.append(
RuleMatchResult(
rule_id=rule_id,
matched=False,
action=rule_action,
reason="rule_cooldown_active",
)
)
continue
result = rule.evaluate(
rule_id=rule_id,
agent=agent,
world=world,
behavior=behavior,
behavior_name=behavior_name,
)
rule_results.append(result)
if not result.matched:
continue
if result.action is RuleAction.BLOCK:
blocked_rule_ids.append(rule_id)
return RuleEvaluation(
behavior=behavior,
behavior_name=behavior_name,
behavior_index=behavior_index,
available=False,
score=float("-inf"),
priority=float("-inf"),
matched_rule_ids=tuple(matched_rule_ids),
blocked_rule_ids=tuple(blocked_rule_ids),
reason=f"blocked_by_rule:{rule_id}",
rule_results=tuple(rule_results),
)
matched_rule_ids.append(rule_id)
score += result.score_contribution
priority += result.priority_contribution
if not matched_rule_ids and not self.allow_unmatched_behaviors:
return RuleEvaluation(
behavior=behavior,
behavior_name=behavior_name,
behavior_index=behavior_index,
available=False,
score=score,
priority=priority,
reason="no_matching_rule",
rule_results=tuple(rule_results),
)
if self.minimum_score is not None and score < float(self.minimum_score):
return RuleEvaluation(
behavior=behavior,
behavior_name=behavior_name,
behavior_index=behavior_index,
available=False,
score=score,
priority=priority,
matched_rule_ids=tuple(matched_rule_ids),
reason="below_minimum_score",
rule_results=tuple(rule_results),
)
return RuleEvaluation(
behavior=behavior,
behavior_name=behavior_name,
behavior_index=behavior_index,
available=True,
score=score,
priority=priority,
matched_rule_ids=tuple(matched_rule_ids),
rule_results=tuple(rule_results),
metadata={
"matched": bool(matched_rule_ids),
"selection_strategy": _normalize_strategy(self.selection_strategy).value,
},
)
def score_behavior(
self,
agent: Agent,
world: World,
behavior: Behavior,
) -> float | None:
"""Return the most recent or freshly computed score for a behavior."""
for evaluation in self._last_evaluations:
if evaluation.behavior is behavior:
return None if not evaluation.available else evaluation.score
evaluation = self.evaluate_behavior(agent=agent, world=world, behavior=behavior)
return None if not evaluation.available else evaluation.score
def update(
self,
agent: Agent,
world: World,
behavior: Behavior | str | None,
reward: float,
context: Sequence[float] | Mapping[str, Any] | None = None,
metadata: Mapping[str, Any] | None = None,
) -> None:
"""Record reward feedback without changing rule weights.
RulePolicy is intentionally deterministic and non-learning. This method
stores reward traces for diagnostics. Adaptive policies should implement
learning in their own subclasses.
"""
if isinstance(behavior, Behavior):
behavior_name = self.behavior_name(behavior)
elif behavior is None:
behavior_name = None
else:
behavior_name = str(behavior)
record = {
"agent_id": _agent_id(agent),
"world_step": _world_step(world),
"behavior_name": behavior_name,
"reward": float(reward),
"context": copy.deepcopy(context),
"metadata": copy.deepcopy(dict(metadata or {})),
}
_append_bounded(self._reward_history, record, self.max_reward_history)
def receive_reward(
self,
reward: float,
*,
behavior: Behavior | str | None = None,
context: Sequence[float] | Mapping[str, Any] | None = None,
metadata: Mapping[str, Any] | None = None,
) -> None:
"""Record reward feedback when agent and world are not available."""
if isinstance(behavior, Behavior):
behavior_name = self.behavior_name(behavior)
elif behavior is None:
behavior_name = None
else:
behavior_name = str(behavior)
record = {
"agent_id": None,
"world_step": None,
"behavior_name": behavior_name,
"reward": float(reward),
"context": copy.deepcopy(context),
"metadata": copy.deepcopy(dict(metadata or {})),
}
_append_bounded(self._reward_history, record, self.max_reward_history)
def reset(self) -> None:
"""Reset policy-local runtime state."""
self._decision_count = 0
self._last_behavior_steps.clear()
self._last_rule_steps.clear()
self._last_evaluations = ()
self._decision_history.clear()
self._reward_history.clear()
def state_dict(self) -> dict[str, Any]:
"""Return a serializable policy state snapshot."""
state = super().state_dict()
state.update(
{
"selection_strategy": _normalize_strategy(self.selection_strategy).value,
"default_score": float(self.default_score),
"default_priority": float(self.default_priority),
"minimum_score": self.minimum_score,
"allow_unmatched_behaviors": bool(self.allow_unmatched_behaviors),
"disabled_behavior_names": list(self.disabled_behavior_names),
"behavior_cooldowns": copy.deepcopy(dict(self.behavior_cooldowns)),
"default_behavior_cooldown_steps": int(self.default_behavior_cooldown_steps),
"rules": [rule.to_dict() for rule in self.rules],
"decision_count": int(self._decision_count),
"last_behavior_steps": copy.deepcopy(self._last_behavior_steps),
"last_rule_steps": copy.deepcopy(self._last_rule_steps),
"decision_history": copy.deepcopy(self._decision_history),
"reward_history": copy.deepcopy(self._reward_history),
}
)
return state
def load_state_dict(self, state: Mapping[str, Any]) -> None:
"""Load policy configuration and runtime state from a mapping."""
super().load_state_dict(state)
if "selection_strategy" in state:
self.selection_strategy = str(state["selection_strategy"])
if "default_score" in state:
self.default_score = _as_float(state["default_score"], self.default_score)
if "default_priority" in state:
self.default_priority = _as_float(state["default_priority"], self.default_priority)
self.minimum_score = (
None
if state.get("minimum_score") is None
else _as_float(state.get("minimum_score"), 0.0)
)
if "allow_unmatched_behaviors" in state:
self.allow_unmatched_behaviors = bool(state["allow_unmatched_behaviors"])
disabled = state.get("disabled_behavior_names")
if isinstance(disabled, Iterable) and not isinstance(disabled, (str, bytes)):
self.disabled_behavior_names = tuple(str(name) for name in disabled)
cooldowns = state.get("behavior_cooldowns")
if isinstance(cooldowns, Mapping):
self.behavior_cooldowns = {
str(name): int(value)
for name, value in cooldowns.items()
if _is_number(value)
}
if "default_behavior_cooldown_steps" in state:
self.default_behavior_cooldown_steps = int(state["default_behavior_cooldown_steps"])
rules = state.get("rules")
if isinstance(rules, Iterable) and not isinstance(rules, (str, bytes, Mapping)):
self.rules = tuple(
BehaviorRule.from_mapping(rule)
for rule in rules
if isinstance(rule, Mapping)
)
self._decision_count = int(state.get("decision_count", self._decision_count))
last_behavior_steps = state.get("last_behavior_steps")
if isinstance(last_behavior_steps, Mapping):
self._last_behavior_steps = {
str(key): int(value)
for key, value in last_behavior_steps.items()
if _is_number(value)
}
last_rule_steps = state.get("last_rule_steps")
if isinstance(last_rule_steps, Mapping):
self._last_rule_steps = {
str(key): int(value)
for key, value in last_rule_steps.items()
if _is_number(value)
}
decision_history = state.get("decision_history")
if isinstance(decision_history, Sequence) and not isinstance(decision_history, (str, bytes)):
self._decision_history = [copy.deepcopy(item) for item in decision_history if isinstance(item, Mapping)]
reward_history = state.get("reward_history")
if isinstance(reward_history, Sequence) and not isinstance(reward_history, (str, bytes)):
self._reward_history = [copy.deepcopy(item) for item in reward_history if isinstance(item, Mapping)]
@property
def decision_history(self) -> tuple[Mapping[str, Any], ...]:
"""Return immutable view of recent decision records."""
return tuple(copy.deepcopy(self._decision_history))
@property
def reward_history(self) -> tuple[Mapping[str, Any], ...]:
"""Return immutable view of recent reward records."""
return tuple(copy.deepcopy(self._reward_history))
@property
def last_evaluations(self) -> tuple[RuleEvaluation, ...]:
"""Return the latest behavior evaluations."""
return self._last_evaluations
def _select_evaluation(self, evaluations: Sequence[RuleEvaluation]) -> RuleEvaluation:
"""Select one evaluation according to the configured strategy."""
strategy = _normalize_strategy(self.selection_strategy)
if strategy is RuleSelectionStrategy.FIRST_VALID:
return sorted(evaluations, key=lambda item: item.behavior_index)[0]
if strategy is RuleSelectionStrategy.FIRST_MATCH:
matched = tuple(evaluation for evaluation in evaluations if evaluation.matched_rule_ids)
if matched:
return sorted(matched, key=lambda item: item.behavior_index)[0]
return sorted(evaluations, key=lambda item: item.behavior_index)[0]
return sorted(
evaluations,
key=lambda item: (
-float(item.priority),
-float(item.score),
item.behavior_index,
item.behavior_name,
),
)[0]
def _record_selection_state(
self,
agent: Agent,
world: World,
selected: RuleEvaluation,
) -> None:
"""Record cooldown state after a behavior has been selected."""
step = self._step_token(world)
behavior_key = self._scoped_key(agent, selected.behavior_name)
self._last_behavior_steps[behavior_key] = step
for rule_id in selected.matched_rule_ids:
self._last_rule_steps[self._scoped_key(agent, rule_id)] = step
def _record_decision(
self,
agent: Agent,
world: World,
selected: RuleEvaluation | None,
evaluations: Sequence[RuleEvaluation],
reason: str,
) -> None:
"""Record a decision trace locally and optionally in agent memory."""
step = _world_step(world)
record = {
"policy_name": self.policy_name,
"agent_id": _agent_id(agent),
"world_step": step,
"decision_index": self._decision_count,
"reason": reason,
"selected_behavior_name": None if selected is None else selected.behavior_name,
"selected_score": None if selected is None else selected.score,
"selected_priority": None if selected is None else selected.priority,
"evaluations": [evaluation.to_dict() for evaluation in evaluations],
}
_append_bounded(self._decision_history, copy.deepcopy(record), self.max_decision_history)
if self.write_decisions_to_agent_memory:
memory = _mutable_agent_memory(agent)
decision_history = memory.get(self.decision_memory_key)
if not isinstance(decision_history, MutableSequence):
decision_history = []
memory[self.decision_memory_key] = decision_history
_append_bounded(decision_history, copy.deepcopy(record), self.max_decision_history)
memory[self.latest_decision_memory_key] = copy.deepcopy(record)
self._decision_count += 1
def _behavior_cooldown_status(
self,
agent: Agent,
world: World,
behavior_name: str,
) -> tuple[bool, str | None]:
"""Return whether a behavior is on cooldown for this agent."""
configured_cooldown = self._behavior_cooldown_steps(behavior_name)
if configured_cooldown <= 0:
return False, None
step = self._step_token(world)
key = self._scoped_key(agent, behavior_name)
last_step = self._last_behavior_steps.get(key)
if last_step is None:
return False, None
elapsed = step - last_step
if elapsed <= configured_cooldown:
return True, f"behavior_cooldown_active:{behavior_name}"
return False, None
def _rule_cooldown_active(
self,
agent: Agent,
world: World,
rule_id: str,
cooldown_steps: int,
) -> bool:
"""Return whether a rule is on cooldown for this agent."""
if cooldown_steps <= 0:
return False
step = self._step_token(world)
key = self._scoped_key(agent, rule_id)
last_step = self._last_rule_steps.get(key)
if last_step is None:
return False
return step - last_step <= cooldown_steps
def _behavior_cooldown_steps(self, behavior_name: str) -> int:
"""Return configured cooldown steps for a behavior name."""
for name, cooldown in self.behavior_cooldowns.items():
if _name_matches(behavior_name, (name,)):
return max(0, int(cooldown))
return max(0, int(self.default_behavior_cooldown_steps))
def _step_token(self, world: World) -> int:
"""Return a deterministic step token for cooldown accounting."""
step = _world_step(world)
return self._decision_count if step is None else int(step)
def _scoped_key(self, agent: Agent, identifier: str) -> str:
"""Return a per-agent key for policy runtime state."""
return f"{_agent_id(agent)}::{identifier}"
Policy = RulePolicy
RULE_POLICY_REGISTRY: Mapping[str, type[BasePolicy]] = MappingProxyType(
{
RulePolicy.name: RulePolicy,
}
)
__all__ = [
"BehaviorRule",
"Policy",
"RULE_POLICY_REGISTRY",
"RuleAction",
"RuleEvaluation",
"RuleMatchResult",
"RulePolicy",
"RuleSelectionStrategy",
]