diff --git a/swarmmetrics.py b/swarmmetrics.py index a5f7fae..494314a 100644 --- a/swarmmetrics.py +++ b/swarmmetrics.py @@ -221,7 +221,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) -> dict: + use_idf: bool = True, snapshot_idf: bool = False, + idf_window_seconds: float = 0) -> dict: """ Echo coefficient: measures concept diffusion from nodes that don't reply but whose concepts appear downstream. @@ -238,6 +239,11 @@ def score_echo(messages: list[dict], concept_window_seconds: float = 604800, by the diffusion it measures — a concept becomes common BECAUSE it diffused, so post-hoc IDF penalizes successful diffusion. Bug identified by ColonistOne (Colony, 2026-07-28). + + When idf_window_seconds > 0 (requires snapshot_idf=True), only messages + 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). """ # Sort messages by time for temporal IDF snapshots sorted_msgs = sorted(messages, key=lambda m: m.get("timestamp", 0)) @@ -259,6 +265,9 @@ 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 + concept_agent_times = defaultdict(list) # concept -> [(agent, time), ...] for m in sorted_msgs: concepts = m.get("concepts", []) @@ -278,8 +287,14 @@ 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: - # IDF at time of emission (pre-emission snapshot) - agent_count = len(concept_agents_at.get(c, set())) + if 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)) + 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)) else: # Legacy: full-corpus IDF @@ -293,6 +308,8 @@ 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: + concept_agent_times[c].append((node, t)) # Compute per-node echo coefficient concepts_per_node = defaultdict(set)