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,
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue