Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| from dataclasses import dataclass, field | |
| from enum import Enum | |
| from typing import Mapping | |
| import numpy as np | |
| from engine.bocpd import run_bocpd | |
| from engine.brain import BrainSignals, coerce_signals | |
| class Action(str, Enum): | |
| SILENT = "SILENT" | |
| BACKCHANNEL = "BACKCHANNEL" | |
| TAKE_FLOOR = "TAKE_FLOOR" | |
| INTERRUPT = "INTERRUPT" | |
| class ControllerConfig: | |
| w_surprise: float = 0.70 | |
| w_change: float = 1.30 | |
| w_readiness: float = 0.80 | |
| w_end: float = 1.20 | |
| w_barge: float = 0.60 | |
| negative_surprise_weight: float = 0.25 | |
| tau: float = 1.60 | |
| backchannel_tau_fraction: float = 0.70 | |
| barge_tau_fraction: float = 0.50 | |
| take_floor_p_end: float = 0.70 | |
| interrupt_p_end_max: float = 0.35 | |
| backchannel_p_end_max: float = 0.35 | |
| min_readiness: float = 0.45 | |
| refractory_steps: int = 2 | |
| surprise_z_cap: float = 3.0 | |
| change_hazard: float = 0.35 | |
| change_prior_kappa: float = 0.20 | |
| change_prior_alpha: float = 0.75 | |
| change_prior_beta: float = 0.20 | |
| change_z_cap: float = 3.0 | |
| change_z_threshold: float = 1.15 | |
| turn_end_tau_discount: float = 0.35 | |
| def backchannel_tau(self) -> float: | |
| return self.backchannel_tau_fraction * self.tau | |
| def barge_tau(self) -> float: | |
| return self.barge_tau_fraction * self.tau | |
| class RunningStats: | |
| n: int = 0 | |
| mean: float = 0.0 | |
| m2: float = 0.0 | |
| def std(self) -> float: | |
| if self.n < 2: | |
| return 1.0 | |
| return max((self.m2 / (self.n - 1)) ** 0.5, 1.0e-6) | |
| def zscore(self, value: float, cap: float) -> float: | |
| if self.n < 2: | |
| return 0.0 | |
| z_value = (float(value) - self.mean) / self.std | |
| return float(np.clip(z_value, -cap, cap)) | |
| def update(self, value: float) -> None: | |
| self.n += 1 | |
| delta = float(value) - self.mean | |
| self.mean += delta / self.n | |
| self.m2 += delta * (float(value) - self.mean) | |
| class AgentState: | |
| surprise_stats: RunningStats = field(default_factory=RunningStats) | |
| previous_hidden: np.ndarray | None = None | |
| hidden_deltas: list[float] = field(default_factory=list) | |
| hidden_delta_z: list[float] = field(default_factory=list) | |
| hidden_delta_stats: RunningStats = field(default_factory=RunningStats) | |
| previous_map_run_length: int | None = None | |
| refractory_until: int = 0 | |
| class AgentDecision: | |
| agent_id: str | |
| action: Action | |
| urge: float | |
| z_surprise: float | |
| change_score: float | |
| readiness: float | |
| p_end: float | |
| hidden_delta: float | |
| map_run_length: int | |
| refractory: bool | |
| class ControllerTick: | |
| step: int | |
| floor_holder: str | |
| winner: str | None | |
| decisions: dict[str, AgentDecision] | |
| class WhenToSpeakController: | |
| """Training-free multi-agent timing controller.""" | |
| def __init__(self, agent_ids: list[str], config: ControllerConfig | None = None) -> None: | |
| if not agent_ids: | |
| raise ValueError("agent_ids must not be empty") | |
| self.agent_ids = list(agent_ids) | |
| self.config = config or ControllerConfig() | |
| self.states = {agent_id: AgentState() for agent_id in self.agent_ids} | |
| self.step = 0 | |
| self.floor_holder = "human" | |
| def reset(self) -> None: | |
| self.states = {agent_id: AgentState() for agent_id in self.agent_ids} | |
| self.step = 0 | |
| self.floor_holder = "human" | |
| def tick( | |
| self, | |
| signals_by_agent: Mapping[str, BrainSignals | dict[str, object]], | |
| *, | |
| floor_holder: str | None = None, | |
| ) -> ControllerTick: | |
| self.step += 1 | |
| if floor_holder is not None: | |
| self.floor_holder = floor_holder | |
| scored: dict[str, AgentDecision] = {} | |
| proposed: dict[str, Action] = {} | |
| for agent_id in self.agent_ids: | |
| if agent_id not in signals_by_agent: | |
| raise KeyError(f"missing signals for agent {agent_id!r}") | |
| signal = coerce_signals(signals_by_agent[agent_id]) | |
| decision = self._score_agent(agent_id, signal) | |
| scored[agent_id] = decision | |
| proposed[agent_id] = decision.action | |
| winner = self._floor_winner(scored) | |
| final_decisions: dict[str, AgentDecision] = {} | |
| for agent_id, decision in scored.items(): | |
| action = decision.action | |
| if action in {Action.TAKE_FLOOR, Action.INTERRUPT} and agent_id != winner: | |
| action = Action.BACKCHANNEL if self._may_backchannel(decision) else Action.SILENT | |
| final_decisions[agent_id] = AgentDecision( | |
| agent_id=decision.agent_id, | |
| action=action, | |
| urge=decision.urge, | |
| z_surprise=decision.z_surprise, | |
| change_score=decision.change_score, | |
| readiness=decision.readiness, | |
| p_end=decision.p_end, | |
| hidden_delta=decision.hidden_delta, | |
| map_run_length=decision.map_run_length, | |
| refractory=decision.refractory, | |
| ) | |
| for agent_id, decision in final_decisions.items(): | |
| if decision.action in {Action.TAKE_FLOOR, Action.INTERRUPT}: | |
| self.states[agent_id].refractory_until = self.step + self.config.refractory_steps | |
| if winner is not None: | |
| self.floor_holder = winner | |
| return ControllerTick( | |
| step=self.step, | |
| floor_holder=self.floor_holder, | |
| winner=winner, | |
| decisions=final_decisions, | |
| ) | |
| def _score_agent(self, agent_id: str, signal: BrainSignals) -> AgentDecision: | |
| state = self.states[agent_id] | |
| z_surprise = state.surprise_stats.zscore(signal.surprise, self.config.surprise_z_cap) | |
| hidden_delta, change_score, map_run_length = self._change_features(state, signal.hidden) | |
| barge = self.config.w_barge * max(z_surprise, 0.0) * signal.readiness * (1.0 - signal.p_end) | |
| surprise_term = z_surprise if z_surprise >= 0.0 else self.config.negative_surprise_weight * z_surprise | |
| urge = ( | |
| self.config.w_surprise * surprise_term | |
| + self.config.w_change * change_score | |
| + self.config.w_readiness * signal.readiness | |
| + self.config.w_end * signal.p_end | |
| + barge | |
| ) | |
| refractory = self.step <= state.refractory_until | |
| action = self._classify(urge, z_surprise, change_score, signal, refractory) | |
| state.surprise_stats.update(signal.surprise) | |
| state.previous_hidden = signal.hidden.astype(np.float32, copy=True) | |
| return AgentDecision( | |
| agent_id=agent_id, | |
| action=action, | |
| urge=float(urge), | |
| z_surprise=float(z_surprise), | |
| change_score=float(change_score), | |
| readiness=signal.readiness, | |
| p_end=signal.p_end, | |
| hidden_delta=float(hidden_delta), | |
| map_run_length=int(map_run_length), | |
| refractory=refractory, | |
| ) | |
| def _change_features(self, state: AgentState, hidden: np.ndarray) -> tuple[float, float, int]: | |
| if state.previous_hidden is None: | |
| return 0.0, 0.0, 0 | |
| hidden_delta = cosine_distance(state.previous_hidden, hidden) | |
| delta_z = state.hidden_delta_stats.zscore(hidden_delta, self.config.change_z_cap) | |
| state.hidden_delta_stats.update(hidden_delta) | |
| state.hidden_deltas.append(hidden_delta) | |
| state.hidden_delta_z.append(delta_z) | |
| results = run_bocpd( | |
| np.asarray(state.hidden_delta_z, dtype=np.float64), | |
| hazard=self.config.change_hazard, | |
| prior_kappa=self.config.change_prior_kappa, | |
| prior_alpha=self.config.change_prior_alpha, | |
| prior_beta=self.config.change_prior_beta, | |
| ) | |
| latest = results[-1] | |
| previous_map = state.previous_map_run_length | |
| state.previous_map_run_length = latest.map_run_length | |
| if previous_map is None: | |
| return hidden_delta, 0.0, latest.map_run_length | |
| collapsed = latest.map_run_length < previous_map | |
| collapse_ratio = (previous_map - latest.map_run_length) / max(previous_map, 1) | |
| collapse_score = max(1.0, collapse_ratio) if collapsed else 0.0 | |
| posterior_score = max(0.0, (latest.cp_prob - self.config.change_hazard) / max(1.0 - self.config.change_hazard, 1.0e-9)) | |
| z_score = max(0.0, abs(delta_z) - self.config.change_z_threshold) / max( | |
| self.config.change_z_cap - self.config.change_z_threshold, | |
| 1.0e-9, | |
| ) | |
| change_score = max(collapse_score, posterior_score, z_score) | |
| return hidden_delta, float(change_score), latest.map_run_length | |
| def _classify( | |
| self, | |
| urge: float, | |
| z_surprise: float, | |
| change_score: float, | |
| signal: BrainSignals, | |
| refractory: bool, | |
| ) -> Action: | |
| if refractory: | |
| return Action.SILENT | |
| ready = signal.readiness >= self.config.min_readiness | |
| human_has_floor = self.floor_holder == "human" | |
| barge_signal = max(z_surprise, 0.0) * signal.readiness * (1.0 - signal.p_end) | |
| floor_tau = self._effective_tau(signal.p_end) | |
| if human_has_floor and ready and signal.p_end >= self.config.take_floor_p_end and urge >= floor_tau: | |
| return Action.TAKE_FLOOR | |
| if ( | |
| human_has_floor | |
| and ready | |
| and signal.p_end <= self.config.interrupt_p_end_max | |
| and urge >= floor_tau | |
| and barge_signal >= self.config.barge_tau | |
| ): | |
| return Action.INTERRUPT | |
| if ( | |
| human_has_floor | |
| and ready | |
| and signal.p_end <= self.config.backchannel_p_end_max | |
| and change_score > 0.0 | |
| and urge >= self.config.backchannel_tau | |
| ): | |
| return Action.BACKCHANNEL | |
| if ( | |
| human_has_floor | |
| and ready | |
| and urge >= self.config.backchannel_tau | |
| and signal.p_end <= self.config.backchannel_p_end_max | |
| ): | |
| return Action.BACKCHANNEL | |
| return Action.SILENT | |
| def _floor_winner(self, decisions: Mapping[str, AgentDecision]) -> str | None: | |
| contenders = [ | |
| decision | |
| for decision in decisions.values() | |
| if decision.action in {Action.TAKE_FLOOR, Action.INTERRUPT} | |
| and decision.urge >= self._effective_tau(decision.p_end) | |
| ] | |
| if not contenders: | |
| return None | |
| return max(contenders, key=lambda decision: (decision.urge, -self.agent_ids.index(decision.agent_id))).agent_id | |
| def _may_backchannel(self, decision: AgentDecision) -> bool: | |
| return ( | |
| not decision.refractory | |
| and self.floor_holder == "human" | |
| and decision.urge >= self.config.backchannel_tau | |
| and decision.p_end <= self.config.backchannel_p_end_max | |
| ) | |
| def _effective_tau(self, p_end: float) -> float: | |
| discount = self.config.turn_end_tau_discount * float(np.clip(p_end, 0.0, 1.0)) | |
| return self.config.tau * max(0.20, 1.0 - discount) | |
| def cosine_distance(left: np.ndarray, right: np.ndarray) -> float: | |
| left_vec = np.asarray(left, dtype=np.float32) | |
| right_vec = np.asarray(right, dtype=np.float32) | |
| denom = float(np.linalg.norm(left_vec) * np.linalg.norm(right_vec)) | |
| if denom <= 1.0e-12: | |
| return 0.0 | |
| similarity = float(np.dot(left_vec, right_vec) / denom) | |
| return float(np.clip(1.0 - similarity, 0.0, 2.0)) | |