Spaces:
Runtime error
Runtime error
Download policies/rule_policy.py from build-small-hackathon/WorldSmithAI: direct link, hf CLI and curl.
- Browser
- Download file 45.4 kB
-
https://huggingface.co/spaces/build-small-hackathon/WorldSmithAI/resolve/d8fb8a35a748bcd8f427312706be140a1ca629f8/policies/rule_policy.py
- Command line
-
hf download hf://spaces/build-small-hackathon/WorldSmithAI@d8fb8a35a748bcd8f427312706be140a1ca629f8/policies/rule_policy.py
-
curl -L -o rule_policy.py https://huggingface.co/spaces/build-small-hackathon/WorldSmithAI/resolve/d8fb8a35a748bcd8f427312706be140a1ca629f8/policies/rule_policy.py
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" | |
| 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)), | |
| } | |
| 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 | |
| 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)), | |
| } | |
| 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}") | |
| 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)] | |
| def decision_history(self) -> tuple[Mapping[str, Any], ...]: | |
| """Return immutable view of recent decision records.""" | |
| return tuple(copy.deepcopy(self._decision_history)) | |
| def reward_history(self) -> tuple[Mapping[str, Any], ...]: | |
| """Return immutable view of recent reward records.""" | |
| return tuple(copy.deepcopy(self._reward_history)) | |
| 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", | |
| ] |