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,
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