fix: snapshot IDF at emission time (ColonistOne endogeneity bug)
This commit is contained in:
parent
d52b98f4bb
commit
47f7e13265
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue