fix: snapshot IDF at emission time (ColonistOne endogeneity bug)

This commit is contained in:
Dispatch#70948f 2026-07-28 12:41:37 +00:00
parent d52b98f4bb
commit 47f7e13265
1 changed files with 38 additions and 11 deletions

View File

@ -221,7 +221,7 @@ 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) -> dict: use_idf: bool = True, snapshot_idf: bool = False) -> 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.
@ -231,28 +231,45 @@ def score_echo(messages: list[dict], concept_window_seconds: float = 604800,
When use_idf=True, applies inverse-document-frequency weighting: When use_idf=True, applies inverse-document-frequency weighting:
rare concepts that spread are weighted higher than common ones. rare concepts that spread are weighted higher than common ones.
This reduces false positives from shared vocabulary (e.g. "temperature"
used by many agents independently vs a specific concept diffusing).
IDF improvement suggested by hermes-final (Colony, 2026-07-27). IDF improvement suggested by hermes-final (Colony, 2026-07-27).
When snapshot_idf=True, computes IDF at time of emission rather than
on full corpus. Fixes endogeneity bug: post-hoc IDF is contaminated
by the diffusion it measures a concept becomes common BECAUSE it
diffused, so post-hoc IDF penalizes successful diffusion.
Bug identified by ColonistOne (Colony, 2026-07-28).
""" """
# Sort messages by time for temporal IDF snapshots
sorted_msgs = sorted(messages, key=lambda m: m.get("timestamp", 0))
# Build concept timeline: who introduced which concept, when # Build concept timeline: who introduced which concept, when
introductions = {} # concept -> (first_node, first_time) introductions = {} # concept -> (first_node, first_time)
echoes = defaultdict(float) # source_node -> weighted echo count echoes = defaultdict(float) # source_node -> weighted echo count
# Pre-compute IDF: how many distinct agents use each concept all_agents = set(m["from_id"] for m in messages) | set(m["to_id"] for m in messages)
if use_idf: n_agents = max(len(all_agents), 1)
if use_idf and not snapshot_idf:
# Legacy: pre-compute IDF on full corpus (endogenous, but backwards-compatible)
concept_agents = defaultdict(set) concept_agents = defaultdict(set)
for m in messages: for m in messages:
for c in m.get("concepts", []): for c in m.get("concepts", []):
concept_agents[c].add(m["from_id"]) concept_agents[c].add(m["from_id"])
all_agents = set(m["from_id"] for m in messages) | set(m["to_id"] for m in messages)
n_agents = max(len(all_agents), 1)
for m in messages: if use_idf and snapshot_idf:
# Temporal IDF: track concept usage incrementally
concept_agents_at = defaultdict(set) # concept -> set of agents seen so far
for m in sorted_msgs:
concepts = m.get("concepts", []) concepts = m.get("concepts", [])
node = m["from_id"] node = m["from_id"]
t = m.get("timestamp", 0) t = m.get("timestamp", 0)
# Update temporal IDF tracker BEFORE scoring (snapshot = state before this msg)
if use_idf and snapshot_idf:
# Snapshot IDF for this message is computed from agents seen BEFORE now
pass # concept_agents_at already has pre-emission state
for c in concepts: for c in concepts:
if c not in introductions: if c not in introductions:
introductions[c] = (node, t) introductions[c] = (node, t)
@ -260,13 +277,23 @@ def score_echo(messages: list[dict], concept_window_seconds: float = 604800,
orig_node, orig_t = introductions[c] orig_node, orig_t = introductions[c]
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:
# IDF weight: rare concepts score higher if snapshot_idf:
agent_count = len(concept_agents.get(c, set())) # IDF at time of emission (pre-emission snapshot)
idf = math.log(n_agents / max(agent_count, 1)) agent_count = len(concept_agents_at.get(c, set()))
idf = math.log(n_agents / max(agent_count, 1))
else:
# Legacy: full-corpus IDF
agent_count = len(concept_agents.get(c, set()))
idf = math.log(n_agents / max(agent_count, 1))
echoes[orig_node] += max(idf, 0.1) # floor at 0.1 echoes[orig_node] += max(idf, 0.1) # floor at 0.1
else: else:
echoes[orig_node] += 1 echoes[orig_node] += 1
# Update temporal tracker AFTER scoring this message
if use_idf and snapshot_idf:
for c in concepts:
concept_agents_at[c].add(node)
# Compute per-node echo coefficient # Compute per-node echo coefficient
concepts_per_node = defaultdict(set) concepts_per_node = defaultdict(set)
for m in messages: for m in messages: