From 9bf27fc017803b346bcc490420169ec9dce51434 Mon Sep 17 00:00:00 2001 From: agent-70948f1db9d839b7 Date: Tue, 28 Jul 2026 14:41:51 +0000 Subject: [PATCH] feat: add phi_accrual failure detector for agent liveness (Hayashibara 2004) --- swarmmetrics.py | 129 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/swarmmetrics.py b/swarmmetrics.py index a6087dc..0a6358c 100644 --- a/swarmmetrics.py +++ b/swarmmetrics.py @@ -606,3 +606,132 @@ OMPU_BUS_ALIASES = { 'константин': 'кот-констант', 'кот-константин': 'кот-констант', } + + +# ── φ-accrual failure detector for agent liveness ────── +# Hayashibara et al. 2004. Continuous suspicion metric instead +# of binary alive/dead. Tracks inter-arrival times of an agent's +# messages, outputs a suspicion level φ that climbs monotonically +# during silence. Higher φ = more suspicious that the agent is dead. +# +# φ = -log10(1 - F(t_now - t_last)) +# where F is the CDF of the inter-arrival distribution (assumed normal). +# +# Three states derived from φ: +# φ < 1.0 → GREEN (< 90% suspicion, probably alive) +# φ < 3.0 → STALE (< 99.9% suspicion, silence is unusual) +# φ >= 3.0 → GRAY (> 99.9% suspicion, likely dead or budget-exhausted) +# +# This distinguishes three failure modes that dashboards collapse: +# - stale green (checked at T, referent moved since) +# - silence (no check ran, no timestamp to compute staleness from) +# - dead observer (checker itself stopped) + +@dataclass +class AgentLiveness: + agent_id: str + last_seen: float = 0.0 # epoch seconds + message_count: int = 0 + mean_interval: float = 0.0 # seconds between messages + std_interval: float = 0.0 + phi: float = 0.0 # suspicion score + state: str = 'unknown' # green / stale / gray / unknown + + def as_dict(self) -> dict: + return { + 'agent_id': self.agent_id, + 'last_seen': self.last_seen, + 'message_count': self.message_count, + 'mean_interval': self.mean_interval, + 'std_interval': self.std_interval, + 'phi': round(self.phi, 3), + 'state': self.state, + } + + +def phi_accrual(messages: list, t_now: float = None, + min_messages: int = 5) -> dict: + """Compute φ-accrual suspicion for each agent in the corpus. + + Returns: {agent_id: AgentLiveness, ...} + + Args: + messages: list of dicts with 'from_id' and 'timestamp' keys + t_now: current time (epoch seconds). If None, uses max timestamp + 1h. + min_messages: minimum messages to compute φ (else state='unknown') + """ + # Collect per-agent timestamps, sorted + agent_times: dict = defaultdict(list) + for m in messages: + agent_id = m.get('from_id', '') + ts = m.get('timestamp', 0) + if agent_id and ts: + agent_times[agent_id].append(ts) + + if t_now is None: + all_ts = [t for times in agent_times.values() for t in times] + t_now = max(all_ts) + 3600 if all_ts else 0 + + results = {} + for agent_id, times in agent_times.items(): + times.sort() + liveness = AgentLiveness( + agent_id=agent_id, + last_seen=times[-1], + message_count=len(times), + ) + + if len(times) < min_messages: + liveness.state = 'unknown' + results[agent_id] = liveness + continue + + # Compute inter-arrival intervals + intervals = [times[i+1] - times[i] for i in range(len(times) - 1)] + mean_ival = sum(intervals) / len(intervals) + variance = sum((x - mean_ival) ** 2 for x in intervals) / len(intervals) + std_ival = math.sqrt(variance) if variance > 0 else mean_ival * 0.1 + + liveness.mean_interval = mean_ival + liveness.std_interval = std_ival + + # Time since last message + t_diff = t_now - times[-1] + + if t_diff <= 0: + liveness.phi = 0.0 + liveness.state = 'green' + elif std_ival == 0: + # Perfectly regular agent — any deviation is suspicious + liveness.phi = 10.0 if t_diff > mean_ival * 1.5 else 0.0 + liveness.state = 'gray' if liveness.phi >= 3.0 else 'green' + else: + # Normal CDF approximation (error function) + # P(X <= t_diff) where X ~ N(mean, std) + z = (t_diff - mean_ival) / std_ival + # Approximate CDF using logistic approximation + # F(z) ≈ 1 / (1 + exp(-1.7 * z)) + try: + cdf = 1.0 / (1.0 + math.exp(-1.7 * z)) + except OverflowError: + cdf = 1.0 if z > 0 else 0.0 + + # φ = -log10(1 - F(t_diff)) + if cdf >= 1.0 - 1e-15: + liveness.phi = 16.0 # cap at 16 (probability < 1e-16) + elif cdf <= 0.0: + liveness.phi = 0.0 + else: + liveness.phi = -math.log10(1.0 - cdf) + + # Classify + if liveness.phi < 1.0: + liveness.state = 'green' + elif liveness.phi < 3.0: + liveness.state = 'stale' + else: + liveness.state = 'gray' + + results[agent_id] = liveness + + return results