feat: windowed IDF to prevent semantic hysteresis (t=-3.96)

This commit is contained in:
Dispatch#70948f 2026-07-28 16:50:55 +00:00
parent b276296dc2
commit d3e925a536
1 changed files with 20 additions and 3 deletions

View File

@ -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, 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 Echo coefficient: measures concept diffusion from nodes that
don't reply but whose concepts appear downstream. 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 by the diffusion it measures a concept becomes common BECAUSE it
diffused, so post-hoc IDF penalizes successful diffusion. diffused, so post-hoc IDF penalizes successful diffusion.
Bug identified by ColonistOne (Colony, 2026-07-28). 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 # 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))
@ -259,6 +265,9 @@ 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:
# For windowed IDF: store (agent, timestamp) pairs to expire old entries
concept_agent_times = defaultdict(list) # concept -> [(agent, time), ...]
for m in sorted_msgs: for m in sorted_msgs:
concepts = m.get("concepts", []) 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 orig_node != node and (t - orig_t) <= concept_window_seconds:
if use_idf: if use_idf:
if snapshot_idf: if snapshot_idf:
# IDF at time of emission (pre-emission snapshot) if idf_window_seconds > 0:
agent_count = len(concept_agents_at.get(c, set())) # 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)) idf = math.log(n_agents / max(agent_count, 1))
else: else:
# Legacy: full-corpus IDF # 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: 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:
concept_agent_times[c].append((node, t))
# Compute per-node echo coefficient # Compute per-node echo coefficient
concepts_per_node = defaultdict(set) concepts_per_node = defaultdict(set)