Source code for modelly.behaviour.states.state

"""Continuous States Descriptor.

Provides piecewise-linear and piecewise-quadratic trajectory tracking, and
declarative threshold monitors natively integrated with signals and
the standard Modelly event queue.
"""

from __future__ import annotations

from collections.abc import Callable
from typing import TYPE_CHECKING, Any

import modelly.time.signals.core as core
from modelly.behaviour.states.threshold import Threshold
from modelly.time.signals import Observable, ObservableSignals
from modelly.time.signals.core import (
    BaseObservable,
    ComputedState,
    _hashable_signal,
)

if TYPE_CHECKING:
    from modelly.time.signals import HasEmitters


def _bind_thresholds(instance: HasEmitters) -> None:
    """Bind every declarative Threshold on the instance's class hierarchy."""
    cls = instance.__class__
    for klass in cls.__mro__:
        if "_continuous_thresholds" in klass.__dict__:
            for t_name in klass.__dict__["_continuous_thresholds"]:
                thresh = getattr(cls, t_name, None)
                if thresh is not None and hasattr(thresh, "bind"):
                    thresh.bind(instance)


[docs] class ContinuousState(BaseObservable): """A descriptor that tracks and extrapolates continuous states over time. Uses piecewise-linear interpolation by default: value(t) = base_value + current_rate * (t - last_time). When the rate changes, the current value is snapshotted as the new baseline so the trajectory remains mathematically continuous. Chained states (e.g. position' = speed, where speed is itself a ContinuousState) are extrapolated with the next order term instead: value(t) = base_value + current_rate*dt + 0.5*second_order_rate*dt**2. """ signal_types = ObservableSignals def __init__( self, fallback_value: float = 0.0, rate: float | Callable[[Any], float] = 0.0, ) -> None: """Initialize a ContinuousState. Args: fallback_value: Initial value for the state (default 0.0). rate: Either a constant float rate-of-change, or a callable f(agent) -> float that is evaluated reactively via the diffing engine. """ super().__init__(fallback_value=fallback_value) self.fallback_value = fallback_value self._rate_input = rate def __set_name__(self, owner: type[HasEmitters], name: str) -> None: """Bind the descriptor to the class and create shadow observables.""" super().__set_name__(owner, name) self.private_state_name = f"_{name}_continuous_state" self.rate_name = f"{name}_rate" # Guard against collisions in both the dynamic dict and memory-optimized slots collision = self.rate_name in owner.__dict__ if hasattr(owner, "__slots__"): collision = collision or (self.rate_name in owner.__slots__) if collision: raise AttributeError( f"Namespace collision: '{self.rate_name}' is a reserved shadow attribute " f"generated by the ContinuousState '{name}'. Please rename your attribute." ) # Use Observable (not BaseObservable) so that values are actually stored # on the instance, and old/new in CHANGED notifications are correct. rate_descriptor = Observable() rate_descriptor.__set_name__(owner, self.rate_name) setattr(owner, self.rate_name, rate_descriptor) def _get_state(self, instance: HasEmitters) -> dict[str, Any]: """Retrieve or initialize the internal state tracking dictionary.""" if not hasattr(instance, self.private_state_name): self._init_state(instance) return getattr(instance, self.private_state_name) def _init_state(self, instance: HasEmitters) -> None: """Create the tracking dictionary, binding Thresholds and the rate first.""" # Automatically bind any declarative Thresholds the first time # a continuous state initializes, entirely bypassing the need for a scheduler. if not getattr(instance, "_thresholds_bound", False): instance._thresholds_bound = True _bind_thresholds(instance) time = instance.model.peek("time", 0.0) state: dict[str, Any] = { "base_value": self.fallback_value, "last_time": time, "rate_computed": None, "current_rate": 0.0, # Rate-of-the-rate, captured when current_rate's source is # itself a ContinuousState. 0.0 when the rate is constant. "second_order_rate": 0.0, # Strong reference so Modelly's weakref GC doesn't silently drop the handler. "_rate_handler": None, } setattr(instance, self.private_state_name, state) if callable(self._rate_input): self._wire_rate(instance, state) else: state["current_rate"] = float(self._rate_input) def _wire_rate(self, instance: HasEmitters, state: dict[str, Any]) -> None: """Track a callable rate reactively and snapshot the trajectory on change.""" def compute_rate(agent): return self._rate_input(agent) rate_computed = ComputedState(instance, self.rate_name, compute_rate) state["rate_computed"] = rate_computed # Defer the first rate evaluation: the rate lambda may access other # Observable attributes whose private backing stores don't exist yet because # we are still inside Agent.__init__ -> register_agent -> bind(). def on_rate_change(message: Any) -> None: self._snapshot_trajectory(instance) state["_rate_handler"] = on_rate_change instance.observe( self.rate_name, ObservableSignals.CHANGED, state["_rate_handler"] ) def _find_chained_rate( self, comp_state: ComputedState, instance: HasEmitters ) -> float: """Return the rate-of-the-rate if this state's rate depends on another ContinuousState. Scans the dependencies the rate callable's ComputedState recorded for one that is a ContinuousState descriptor on the instance's class. """ for parent, attrs in comp_state.parents.items(): for attr_name in attrs: descriptor = getattr(type(parent), attr_name, None) if isinstance(descriptor, ContinuousState): return descriptor.get_rate(parent) return 0.0 def _extrapolate(self, state: dict[str, Any], dt: float) -> float: """Project the value forward by dt using the cached base and rates.""" return ( state["base_value"] + state["current_rate"] * dt + 0.5 * state["second_order_rate"] * dt * dt ) def _snapshot_trajectory(self, instance: HasEmitters) -> None: """Commit the current extrapolated value as the new baseline when the rate changes.""" # Call order matters: # 1. Extrapolate using the *old* cached rate(s) — prevents retroactive time-travel. # 2. Lock in the new baseline and timestamp. # 3. Evaluate and cache the *new* rates. # 4. Notify Threshold observers that the trajectory has shifted. state = self._get_state(instance) current_time = instance.model.peek("time", 0.0) dt = current_time - state["last_time"] current_value = self._extrapolate(state, dt) state["base_value"] = current_value state["last_time"] = current_time self._refresh_rate_if_dirty(state, instance) # Do NOT go through the batching aggregator path here, as old==new would be swallowed. signal = core.Message( name=self.public_name, owner=instance, signal_type=ObservableSignals.CHANGED, additional_kwargs={"old": current_value, "new": current_value}, ) instance._modelly_notify(signal) def _is_uninitialized_observable_race( self, instance: HasEmitters, exc: AttributeError ) -> bool: """Return True if the AttributeError looks like the known init-ordering race.""" missing_name = getattr(exc, "name", None) if missing_name is None or not missing_name.startswith("_"): return False public_name = missing_name[1:] observables = getattr(type(instance), "observables", None) if not observables or public_name not in observables: return False descriptor = getattr(type(instance), public_name, None) return ( isinstance(descriptor, BaseObservable) and descriptor.private_name == missing_name ) def _evaluate_rate(self, instance: HasEmitters) -> float: """Evaluate the rate callable using diffing engine to track dependencies.""" comp_state: ComputedState = self._get_state(instance)["rate_computed"] try: comp_state.evaluate() except AttributeError as exc: # The rate callable depends on an Observable that has not been assigned yet. # This happens when bind() runs eager recalculation during Agent.__init__. # Treat this as rate unknown, fall back to the "at rest" assumption (0.0). if not self._is_uninitialized_observable_race(instance, exc): raise return 0.0 return float(comp_state.value) def _refresh_rate_if_dirty( self, state: dict[str, Any], instance: HasEmitters ) -> None: """Re-evaluate the rate and second-order rate together so they never drift.""" comp_state = state.get("rate_computed") if comp_state is not None and comp_state.is_dirty: state["current_rate"] = self._evaluate_rate(instance) state["second_order_rate"] = self._find_chained_rate(comp_state, instance)
[docs] def get_rate(self, instance: HasEmitters) -> float: """Return the current rate for the instance, evaluating lazily if needed. Args: instance (HasEmitters): The instance being evaluated. Returns: float: The currently active rate. """ state = self._get_state(instance) self._refresh_rate_if_dirty(state, instance) return state["current_rate"]
def __get__(self, instance: HasEmitters, owner: type | None = None) -> Any: """Extrapolate and return the continuous state.""" if instance is None: return self state = self._get_state(instance) self._refresh_rate_if_dirty(state, instance) current_time = instance.model.peek("time", 0.0) dt = current_time - state["last_time"] current_value = self._extrapolate(state, dt) if core.CURRENT_COMPUTED is not None: core.CURRENT_COMPUTED._record_access( instance, self.public_name, current_value ) core.PROCESSING_SIGNALS.add(_hashable_signal(instance, self.public_name)) return current_value def __set__(self, instance: HasEmitters, value: float) -> None: """Assign a new mathematical baseline to the state.""" if ( core.CURRENT_COMPUTED is not None and _hashable_signal(instance, self.public_name) in core.PROCESSING_SIGNALS ): raise ValueError("Cyclical dependency detected in ContinuousState.") state = self._get_state(instance) current_time = instance.model.peek("time", 0.0) has_subscribers = instance._has_subscribers( self.public_name, ObservableSignals.CHANGED ) old_value = self.__get__(instance) if has_subscribers else None state["base_value"] = float(value) state["last_time"] = current_time if has_subscribers: instance.notify( self.public_name, ObservableSignals.CHANGED, old=old_value, new=float(value), ) core.PROCESSING_SIGNALS.discard( _hashable_signal(instance, self.public_name) )
__all__ = ["ContinuousState", "Threshold"]