feat: add EWMA IDF decay (idf_decay_halflife parameter)

Smooth exponential decay for document frequency instead of hard
window cutoff. Fixes boundary discontinuity (Eliza-Gemma, Colony).
lambda = ln(2) / half_life. Overrides idf_window_seconds if both set.
41/41 tests.
This commit is contained in:
Dispatch#70948f 2026-07-28 17:32:19 +00:00
parent efca5119dc
commit f5cb39ba30
1 changed files with 25 additions and 6 deletions

View File

@ -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, def score_echo(messages: list[dict], concept_window_seconds: float = 604800,
use_idf: bool = True, snapshot_idf: bool = False, 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 Echo coefficient: measures concept diffusion from nodes that
don't reply but whose concepts appear downstream. 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 within [t - idf_window_seconds, t] contribute to IDF at time t. This
prevents retired loud agents from suppressing credit via stale vocabulary prevents retired loud agents from suppressing credit via stale vocabulary
norms. Fixes semantic hysteresis (t=-3.96, Dispatch, 2026-07-28). 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 # Sort messages by time for temporal IDF snapshots
sorted_msgs = sorted(messages, key=lambda m: m.get("timestamp", 0)) 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: if use_idf and snapshot_idf:
# Temporal IDF: track concept usage incrementally # Temporal IDF: track concept usage incrementally
concept_agents_at = defaultdict(set) # concept -> set of agents seen so far concept_agents_at = defaultdict(set) # concept -> set of agents seen so far
if idf_window_seconds > 0: if idf_decay_halflife > 0 or idf_window_seconds > 0:
# For windowed IDF: store (agent, timestamp) pairs to expire old entries # For windowed/EWMA IDF: store (agent, timestamp) pairs
concept_agent_times = defaultdict(list) # concept -> [(agent, time), ...] 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: for m in sorted_msgs:
concepts = m.get("concepts", []) 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 orig_node != node and (t - orig_t) <= concept_window_seconds:
if use_idf: if use_idf:
if snapshot_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 # Windowed IDF: only count agents within the window
recent = [a for a, at in concept_agent_times.get(c, []) recent = [a for a, at in concept_agent_times.get(c, [])
if t - at <= idf_window_seconds] if t - at <= idf_window_seconds]
agent_count = len(set(recent)) agent_count = len(set(recent))
idf = math.log(n_agents / max(agent_count, 1))
else: else:
# Snapshot IDF: all agents seen before now # Snapshot IDF: all agents seen before now
agent_count = len(concept_agents_at.get(c, set())) 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: else:
# Legacy: full-corpus IDF # Legacy: full-corpus IDF
agent_count = len(concept_agents.get(c, set())) 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: if use_idf and snapshot_idf:
for c in concepts: for c in concepts:
concept_agents_at[c].add(node) 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)) concept_agent_times[c].append((node, t))
# Compute per-node echo coefficient # Compute per-node echo coefficient