diff --git a/swarmmetrics.py b/swarmmetrics.py index a9b21f6..97f9c6d 100644 --- a/swarmmetrics.py +++ b/swarmmetrics.py @@ -187,17 +187,33 @@ def score_channels(messages: list[dict], gini_broadcast_threshold: float = 0.6) return channels -def score_echo(messages: list[dict], concept_window_seconds: float = 604800) -> dict: +def score_echo(messages: list[dict], concept_window_seconds: float = 604800, + use_idf: bool = True) -> dict: """ Echo coefficient: measures concept diffusion from nodes that don't reply but whose concepts appear downstream. For each node, echo_coeff = concepts_echoed / concepts_introduced. High echo + low message count = gravitational shadow. + + When use_idf=True, applies inverse-document-frequency weighting: + 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). """ # Build concept timeline: who introduced which concept, when introductions = {} # concept -> (first_node, first_time) - echoes = defaultdict(int) # source_node -> count of echoes + echoes = defaultdict(float) # source_node -> weighted echo count + + # Pre-compute IDF: how many distinct agents use each concept + if use_idf: + concept_agents = defaultdict(set) + for m in messages: + for c in m.get("concepts", []): + 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: concepts = m.get("concepts", []) @@ -210,7 +226,13 @@ def score_echo(messages: list[dict], concept_window_seconds: float = 604800) -> else: orig_node, orig_t = introductions[c] if orig_node != node and (t - orig_t) <= concept_window_seconds: - echoes[orig_node] += 1 + if use_idf: + # IDF weight: rare concepts score higher + 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 + else: + echoes[orig_node] += 1 # Compute per-node echo coefficient concepts_per_node = defaultdict(set) @@ -222,7 +244,7 @@ def score_echo(messages: list[dict], concept_window_seconds: float = 604800) -> for node, concepts in concepts_per_node.items(): introduced = sum(1 for c in concepts if introductions.get(c, (None,))[0] == node) - echo_count = echoes.get(node, 0) + echo_count = echoes.get(node, 0.0) coeff = echo_count / introduced if introduced > 0 else 0.0 node_echo[node] = round(coeff, 4)