CODELOCKHF's picture
Release v0.2: multichannel affect engine and calibrated vectors
031e289
Raw History Blame Contribute Delete
3.73 kB
"""Bounded episodic record plus context-specific conditioning and extinction."""
from __future__ import annotations
import math
from dataclasses import asdict, dataclass, field
from .config import AffectConfig, finite
@dataclass
class CueEstimate:
expected_harm: float = 0.0
variance: float = 0.0
observations: int = 0
updated_at: float = 0.0
@dataclass
class EpisodicMemory:
capacity: int = 256
episodes: list[dict] = field(default_factory=list)
cues: dict[str, CueEstimate] = field(default_factory=dict)
sequence: int = 0
def __post_init__(self):
if not isinstance(self.capacity, int) or self.capacity < 1:
raise ValueError("capacity must be a positive integer")
def predict(self, cue: str, now: float, cfg: AffectConfig) -> float:
estimate = self.cues.get(cue)
if estimate is None:
return 0.0
age = max(0.0, now - estimate.updated_at)
return estimate.expected_harm * 0.5 ** (age / cfg.memory_half_life)
def observe(
self,
cue: str,
harm: float,
now: float,
cfg: AffectConfig,
event: str,
levels: dict,
) -> dict:
finite(harm, "harm", 0, 1)
expected = self.predict(cue, now, cfg)
item = self.cues.setdefault(cue, CueEstimate())
error = harm - expected
rate = cfg.conditioning_rate if error >= 0 else cfg.extinction_rate
item.expected_harm = min(1.0, max(0.0, expected + rate * error))
item.variance = (1.0 - rate) * (item.variance + rate * error * error)
item.observations += 1
item.updated_at = now
self.sequence += 1
episode = {
"id": self.sequence,
"t": now,
"cue": cue,
"event": event,
"harm": harm,
"prediction_before": expected,
"prediction_error": error,
"levels": dict(levels),
"salience": max(harm, abs(error), max(levels.values())),
}
self.episodes.append(episode)
self.episodes = self.episodes[-self.capacity :]
if len(self.cues) > self.capacity:
oldest = min(self.cues, key=lambda k: self.cues[k].updated_at)
del self.cues[oldest]
return episode
def retrieve(self, cue: str, now: float, limit: int = 3) -> list[dict]:
if limit < 0:
raise ValueError("limit must be nonnegative")
matches = [e for e in self.episodes if e["cue"] == cue]
score = lambda e: 0.7 * e["salience"] + 0.3 * math.exp(
-max(0.0, now - e["t"]) / 600.0
)
return sorted(matches, key=score, reverse=True)[:limit]
def to_dict(self):
return {
"capacity": self.capacity,
"episodes": self.episodes,
"sequence": self.sequence,
"cues": {key: asdict(value) for key, value in self.cues.items()},
}
@classmethod
def from_dict(cls, data):
data = dict(data)
data["cues"] = {
key: CueEstimate(**value) for key, value in data.get("cues", {}).items()
}
for value in data["cues"].values():
finite(value.expected_harm, "expected_harm", 0, 1)
finite(value.variance, "variance", 0, 1)
finite(value.updated_at, "updated_at")
if value.observations < 0:
raise ValueError("Invalid observation count")
memory = cls(**data)
if (
memory.capacity < 1
or len(memory.episodes) > memory.capacity
or len(memory.cues) > memory.capacity
):
raise ValueError("Invalid persisted memory bounds")
return memory