MHRN-Space / src /profiles /behavior.py
ThomasHeisig's picture
Sync GitHub main 4645f4137666463023fa501984a14336dd2fc666 (part 11)
31226fd verified
Raw History Blame Contribute Delete
6.45 kB
"""Operational, non-psychological behavior profile for controlled tasks."""
from __future__ import annotations
import hashlib
import json
import os
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from src.embodiment.models import ActionCommand
BEHAVIOR_SCHEMA_VERSION = 1
_DIMENSIONS = (
"exploration",
"novelty_response",
"persistence",
"strategy_switching",
"cost_weight",
)
def _digest(value: dict[str, Any]) -> str:
unsigned = json.loads(json.dumps(value, ensure_ascii=True))
unsigned.pop("integrity_digest", None)
return hashlib.sha256(
json.dumps(
unsigned, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode("utf-8")
).hexdigest()
def _bounded(value: float) -> float:
return max(0.0, min(1.0, float(value)))
@dataclass(slots=True)
class BehaviorProfile:
"""Three-level profile with deterministic, slowly changing disposition."""
profile_id: str
initial: dict[str, float] = field(default_factory=dict)
situational: dict[str, float] = field(default_factory=dict)
disposition: dict[str, float] = field(default_factory=dict)
update_rate: float = 0.05
update_log: list[dict[str, Any]] = field(default_factory=list[dict[str, Any]])
max_update_log: int = 256
schema_version: int = BEHAVIOR_SCHEMA_VERSION
def __post_init__(self) -> None:
defaults = {
"exploration": 0.2,
"novelty_response": 0.5,
"persistence": 0.5,
"strategy_switching": 0.3,
"cost_weight": 0.5,
}
for dimension in _DIMENSIONS:
self.initial[dimension] = _bounded(
self.initial.get(dimension, defaults[dimension])
)
self.situational[dimension] = _bounded(self.situational.get(dimension, 0.0))
self.disposition[dimension] = _bounded(
self.disposition.get(dimension, self.initial[dimension])
)
if not 0.0 < self.update_rate <= 1.0:
raise ValueError("update_rate must be between 0 and 1")
if self.max_update_log <= 0:
raise ValueError("max_update_log must be positive")
self.update_log = self.update_log[-self.max_update_log :]
def value(self, dimension: str) -> float:
if dimension not in _DIMENSIONS:
raise KeyError(dimension)
return _bounded(self.disposition[dimension] + self.situational[dimension])
def select_action(
self, candidates: tuple[ActionCommand, ...], *, tick: int
) -> ActionCommand | None:
"""Select a candidate using profile state and simulation tick only."""
if not candidates:
return None
if len(candidates) == 1:
return candidates[0]
exploration = self.value("exploration")
bucket = (tick * 1103515245 + 12345) % 1000
if bucket < int(exploration * 1000):
index = tick % len(candidates)
return candidates[index]
return candidates[0]
def update(self, *, success: bool, tick: int, source: str = "task_outcome") -> None:
"""Apply the disclosed bounded update rule using simulation time."""
target = 0.0 if success else 1.0
old = dict(self.disposition)
for dimension in _DIMENSIONS:
if dimension == "persistence":
target = 0.0 if success else 1.0
elif dimension == "strategy_switching":
target = 1.0 if not success else 0.0
else:
target = self.initial[dimension]
self.disposition[dimension] = _bounded(
self.disposition[dimension]
+ self.update_rate * (target - self.disposition[dimension])
)
self.situational["persistence"] = _bounded(0.2 if success else 0.6)
self.situational["strategy_switching"] = _bounded(0.2 if not success else 0.0)
self.update_log.append(
{
"tick": tick,
"source": source,
"rule": "bounded_ema_to_outcome_target_v1",
"success": success,
"before": old,
"after": dict(self.disposition),
}
)
self.update_log = self.update_log[-self.max_update_log :]
def state_dict(self) -> dict[str, Any]:
state: dict[str, Any] = {
"schema_version": self.schema_version,
"owner": "profiles.behavior",
"profile_id": self.profile_id,
"initial": dict(self.initial),
"situational": dict(self.situational),
"disposition": dict(self.disposition),
"update_rate": self.update_rate,
"update_log": list(self.update_log),
"max_update_log": self.max_update_log,
}
state["integrity_digest"] = _digest(state)
return state
def save(self, path: Path) -> Path:
payload = json.dumps(
self.state_dict(), sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode("utf-8")
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
try:
with os.fdopen(fd, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
return path
@classmethod
def load(cls, path: Path) -> "BehaviorProfile":
state = json.loads(path.read_text(encoding="utf-8"))
if (
not isinstance(state, dict)
or state.get("schema_version") != BEHAVIOR_SCHEMA_VERSION
):
raise ValueError("unsupported behavior profile schema")
if state.get("owner") != "profiles.behavior" or state.get(
"integrity_digest"
) != _digest(state):
raise ValueError("behavior profile integrity check failed")
return cls(
str(state["profile_id"]),
dict(state["initial"]),
dict(state["situational"]),
dict(state["disposition"]),
float(state["update_rate"]),
list(state["update_log"]),
int(state.get("max_update_log", 256)),
)