diff --git a/swarmmetrics.py b/swarmmetrics.py index 494314a..7a5173f 100644 --- a/swarmmetrics.py +++ b/swarmmetrics.py @@ -222,7 +222,8 @@ def score_channels(messages: list[dict], gini_broadcast_threshold: float = 0.6) def score_echo(messages: list[dict], concept_window_seconds: float = 604800, use_idf: bool = True, snapshot_idf: bool = False, - idf_window_seconds: float = 0) -> dict: + idf_window_seconds: float = 0, + idf_decay_halflife: float = 0) -> dict: """ Echo coefficient: measures concept diffusion from nodes that don't reply but whose concepts appear downstream. @@ -244,6 +245,13 @@ def score_echo(messages: list[dict], concept_window_seconds: float = 604800, within [t - idf_window_seconds, t] contribute to IDF at time t. This prevents retired loud agents from suppressing credit via stale vocabulary norms. Fixes semantic hysteresis (t=-3.96, Dispatch, 2026-07-28). + + When idf_decay_halflife > 0 (requires snapshot_idf=True), uses + exponentially weighted moving average (EWMA) for document frequency + instead of a hard window cutoff. Each past agent-concept association + decays as exp(-λ·Δt) where λ = ln(2)/halflife. Fixes the boundary + discontinuity of hard windows. Suggested by Eliza-Gemma (Colony, + 2026-07-28). Overrides idf_window_seconds if both are set. """ # Sort messages by time for temporal IDF snapshots sorted_msgs = sorted(messages, key=lambda m: m.get("timestamp", 0)) @@ -265,9 +273,11 @@ def score_echo(messages: list[dict], concept_window_seconds: float = 604800, if use_idf and snapshot_idf: # Temporal IDF: track concept usage incrementally concept_agents_at = defaultdict(set) # concept -> set of agents seen so far - if idf_window_seconds > 0: - # For windowed IDF: store (agent, timestamp) pairs to expire old entries + if idf_decay_halflife > 0 or idf_window_seconds > 0: + # For windowed/EWMA IDF: store (agent, timestamp) pairs concept_agent_times = defaultdict(list) # concept -> [(agent, time), ...] + if idf_decay_halflife > 0: + _ewma_lambda = math.log(2) / idf_decay_halflife for m in sorted_msgs: concepts = m.get("concepts", []) @@ -287,15 +297,24 @@ def score_echo(messages: list[dict], concept_window_seconds: float = 604800, if orig_node != node and (t - orig_t) <= concept_window_seconds: if use_idf: if snapshot_idf: - if idf_window_seconds > 0: + if idf_decay_halflife > 0: + # EWMA IDF: smooth exponential decay + agent_weights = defaultdict(float) + for a, at in concept_agent_times.get(c, []): + w = math.exp(-_ewma_lambda * (t - at)) + agent_weights[a] += w + effective = sum(min(w, 1.0) for w in agent_weights.values()) + idf = math.log(n_agents / max(effective, 1)) + elif idf_window_seconds > 0: # Windowed IDF: only count agents within the window recent = [a for a, at in concept_agent_times.get(c, []) if t - at <= idf_window_seconds] agent_count = len(set(recent)) + idf = math.log(n_agents / max(agent_count, 1)) else: # Snapshot IDF: all agents seen before now agent_count = len(concept_agents_at.get(c, set())) - idf = math.log(n_agents / max(agent_count, 1)) + idf = math.log(n_agents / max(agent_count, 1)) else: # Legacy: full-corpus IDF agent_count = len(concept_agents.get(c, set())) @@ -308,7 +327,7 @@ def score_echo(messages: list[dict], concept_window_seconds: float = 604800, if use_idf and snapshot_idf: for c in concepts: concept_agents_at[c].add(node) - if idf_window_seconds > 0: + if idf_decay_halflife > 0 or idf_window_seconds > 0: concept_agent_times[c].append((node, t)) # Compute per-node echo coefficient