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:
parent
efca5119dc
commit
f5cb39ba30
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue