""" SwarmMetrics — reciprocity & echo measurement for agent communication graphs. Input: list of message dicts with keys: from_id (str), to_id (str), timestamp (float, epoch seconds), channel (str, optional), concepts (list[str], optional) Output: ScoredGraph with per-edge reciprocity, per-channel classification, per-node influence metrics, gravitational shadow estimates. Built from empirical data on 6345+ inter-agent bus messages. """ import math from collections import defaultdict from dataclasses import dataclass, field from typing import Optional @dataclass class EdgeScore: source: str target: str messages_ab: int = 0 messages_ba: int = 0 reciprocity_raw: float = 0.0 reciprocity_f3: float = 0.0 # log-transform, half-life weighted reciprocity_f32: float = 0.0 # F3.2: log(count) * log(1+reciprocity) reciprocity_f33: float = 0.0 # F3.3: log(unique_concepts) * log(1+reciprocity) unique_concepts: int = 0 # distinct concepts across both directions @dataclass class ChannelScore: channel: str speakers: list = field(default_factory=list) gini: float = 0.0 classification: str = "unknown" # dialogue, broadcast, echo, monologue dominant_speaker: Optional[str] = None dominant_share: float = 0.0 @dataclass class NodeScore: node_id: str total_sent: int = 0 total_received: int = 0 echo_coefficient: float = 0.0 shadow_strength: float = 0.0 is_shadow: bool = False last_active: float = 0.0 @dataclass class ScoredGraph: edges: dict = field(default_factory=dict) # (a,b) -> EdgeScore channels: dict = field(default_factory=dict) # ch -> ChannelScore nodes: dict = field(default_factory=dict) # id -> NodeScore summary: dict = field(default_factory=dict) def _gini(values: list[float]) -> float: """Gini coefficient. 0 = perfect equality, 1 = one speaker dominates.""" if len(values) <= 1: return 1.0 # single speaker = max inequality n = len(values) values = sorted(values) total = sum(values) if total == 0: return 0.0 cumsum = 0.0 weighted_sum = 0.0 for i, v in enumerate(values): cumsum += v weighted_sum += (2 * (i + 1) - n - 1) * v return weighted_sum / (n * total) def _half_life_weight(dt_seconds: float, tau_days: float = 7.0) -> float: """Exponential decay weight. tau_days = half-life in days.""" tau_seconds = tau_days * 86400 if tau_seconds == 0: return 0.0 return math.exp(-0.693 * dt_seconds / tau_seconds) def score_reciprocity(messages: list[dict], now: Optional[float] = None, tau_days: float = 7.0) -> dict: """ F3 reciprocity: log(1 + min(a→b, b→a) / max(a→b, b→a)) with half-life decay weighting. F3.2 (added 2026-07-27): log(count) * log(1 + reciprocity) Double-log compression prevents volume from buying back low reciprocity. Fix proposed by reticuli (Colony): count*log(1+r) lets 100-ping broadcast outrank 5-message dialogue. log(count)*log(1+r) closes that gap. F3.3 (added 2026-07-27): log(unique_concepts) * log(1 + reciprocity) Replaces raw message count with unique concept count per edge. Idea from 小风 (ClawdChat): "count is the problem, not log. Replace count with unique topic count or information entropy — 100 pings covering 2 topics collapse, 5 conversations covering 5 topics win." Requires concepts field in messages. Falls back to F3.2 when no concepts available. """ import time if now is None: now = time.time() # Count weighted messages per directed edge weighted = defaultdict(float) raw = defaultdict(int) edge_concepts = defaultdict(set) # (a,b) -> set of unique concepts for m in messages: a, b = m["from_id"], m["to_id"] t = m.get("timestamp", now) w = _half_life_weight(now - t, tau_days) weighted[(a, b)] += w raw[(a, b)] += 1 for c in m.get("concepts", []): edge_concepts[(a, b)].add(c) # Compute reciprocity per undirected pair edges = {} seen = set() for (a, b) in list(weighted.keys()) + list(raw.keys()): pair = tuple(sorted([a, b])) if pair in seen: continue seen.add(pair) ab_w = weighted.get((a, b), 0) + weighted.get((pair[0], pair[1]), 0) \ if (a, b) != (pair[0], pair[1]) else weighted.get((pair[0], pair[1]), 0) ba_w = weighted.get((b, a), 0) + weighted.get((pair[1], pair[0]), 0) \ if (b, a) != (pair[1], pair[0]) else weighted.get((pair[1], pair[0]), 0) # Simpler: just use pair order ab_w = weighted.get((pair[0], pair[1]), 0) ba_w = weighted.get((pair[1], pair[0]), 0) ab_r = raw.get((pair[0], pair[1]), 0) ba_r = raw.get((pair[1], pair[0]), 0) mx = max(ab_w, ba_w) if mx > 0: ratio = min(ab_w, ba_w) / mx f3 = math.log(1 + ratio) else: f3 = 0.0 mx_raw = max(ab_r, ba_r) raw_ratio = min(ab_r, ba_r) / mx_raw if mx_raw > 0 else 0.0 # F3.2: log(count) * log(1 + reciprocity) total_count = ab_r + ba_r f32 = math.log(max(total_count, 1)) * f3 # f3 is already log(1+ratio) # F3.3: log(unique_concepts) * log(1 + reciprocity) concepts_ab = edge_concepts.get((pair[0], pair[1]), set()) concepts_ba = edge_concepts.get((pair[1], pair[0]), set()) unique = concepts_ab | concepts_ba n_unique = len(unique) # Fall back to F3.2 when no concepts available f33 = math.log(max(n_unique, 1)) * f3 if n_unique > 0 else f32 es = EdgeScore( source=pair[0], target=pair[1], messages_ab=ab_r, messages_ba=ba_r, reciprocity_raw=raw_ratio, reciprocity_f3=f3, reciprocity_f32=f32, reciprocity_f33=f33, unique_concepts=n_unique ) edges[pair] = es return edges def score_channels(messages: list[dict], gini_broadcast_threshold: float = 0.6) -> dict: """ Per-channel Gini evenness. Classifies channels as: - monologue: 1 speaker - dialogue: 2 speakers, reciprocity exists - broadcast: N speakers but Gini > threshold - conversation: N speakers, Gini <= threshold """ ch_counts = defaultdict(lambda: defaultdict(int)) for m in messages: ch = m.get("channel", "default") speaker = m["from_id"] ch_counts[ch][speaker] += 1 channels = {} for ch, speakers in ch_counts.items(): counts = list(speakers.values()) n = len(counts) total = sum(counts) gini = _gini(counts) dominant = max(speakers, key=speakers.get) dominant_share = speakers[dominant] / total if total > 0 else 0 if n == 1: classification = "monologue" elif n == 2: classification = "dialogue" elif gini > gini_broadcast_threshold: classification = "broadcast" else: classification = "conversation" channels[ch] = ChannelScore( channel=ch, speakers=list(speakers.keys()), gini=round(gini, 4), classification=classification, dominant_speaker=dominant, dominant_share=round(dominant_share, 4) ) return channels def score_echo(messages: list[dict], concept_window_seconds: float = 604800, use_idf: bool = True, snapshot_idf: bool = False) -> 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. 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 introductions = {} # concept -> (first_node, first_time) echoes = defaultdict(float) # source_node -> weighted echo count 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) if use_idf and not snapshot_idf: # Legacy: pre-compute IDF on full corpus (endogenous, but backwards-compatible) concept_agents = defaultdict(set) for m in messages: for c in m.get("concepts", []): concept_agents[c].add(m["from_id"]) 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", []) node = m["from_id"] 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: if c not in introductions: introductions[c] = (node, t) else: orig_node, orig_t = introductions[c] if orig_node != node and (t - orig_t) <= concept_window_seconds: if use_idf: if snapshot_idf: # IDF at time of emission (pre-emission snapshot) 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 else: 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 concepts_per_node = defaultdict(set) for m in messages: for c in m.get("concepts", []): concepts_per_node[m["from_id"]].add(c) node_echo = {} 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.0) coeff = echo_count / introduced if introduced > 0 else 0.0 node_echo[node] = round(coeff, 4) return node_echo def shuffle_test(messages: list[dict], n_shuffles: int = 100, use_idf: bool = True) -> dict: """ Shuffle test for causal echo (Anagnostopoulos, Kumar & Mahdian 2008). Permutes timestamps while holding network fixed. If echo scores survive shuffling, they measured correlation (shared environment) not diffusion (causal influence). Returns dict with: - observed: {node: echo_coeff} from actual data - mean_shuffled: {node: mean echo across shuffles} - z_scores: {node: (observed - mean_shuffled) / std_shuffled} - significant: {node: bool} where |z| > 2.0 Cited: ColonistOne (Colony, 2026-07-28) pointed to this method. """ import random # Observed echo observed = score_echo(messages, use_idf=use_idf) # Run shuffles shuffled_scores = defaultdict(list) timestamps = [m.get("timestamp", 0) for m in messages] for _ in range(n_shuffles): # Permute timestamps, keep everything else perm = timestamps.copy() random.shuffle(perm) shuffled_msgs = [] for i, m in enumerate(messages): sm = m.copy() sm["timestamp"] = perm[i] shuffled_msgs.append(sm) shuffled_echo = score_echo(shuffled_msgs, use_idf=use_idf) all_nodes = set(observed) | set(shuffled_echo) for node in all_nodes: shuffled_scores[node].append(shuffled_echo.get(node, 0.0)) # Compute z-scores result = { "observed": observed, "mean_shuffled": {}, "z_scores": {}, "significant": {} } for node in set(observed) | set(shuffled_scores): scores = shuffled_scores.get(node, [0.0]) mean_s = sum(scores) / len(scores) std_s = (sum((s - mean_s)**2 for s in scores) / len(scores)) ** 0.5 obs = observed.get(node, 0.0) result["mean_shuffled"][node] = round(mean_s, 4) if std_s > 0: z = (obs - mean_s) / std_s else: z = 0.0 if obs == mean_s else float('inf') result["z_scores"][node] = round(z, 4) result["significant"][node] = abs(z) > 2.0 return result def detect_shadows(messages: list[dict], now: Optional[float] = None, silence_days: float = 7.0) -> dict: """ Gravitational shadow: nodes that were active but are now silent, with residual influence estimated from historical message weight. """ import time if now is None: now = time.time() silence_threshold = silence_days * 86400 node_activity = defaultdict(list) for m in messages: node_activity[m["from_id"]].append(m.get("timestamp", now)) shadows = {} for node, timestamps in node_activity.items(): last = max(timestamps) silence = now - last if silence > silence_threshold: # Historical weight: sum of half-life-weighted messages total_weight = sum(_half_life_weight(now - t, tau_days=30.0) for t in timestamps) shadows[node] = { "last_active": last, "silence_days": round(silence / 86400, 1), "historical_weight": round(total_weight, 4), "message_count": len(timestamps) } return shadows def analyze(messages: list[dict], now: Optional[float] = None, tau_days: float = 7.0, gini_threshold: float = 0.6, silence_days: float = 7.0) -> ScoredGraph: """ Full analysis: reciprocity + channels + echo + shadows. Returns ScoredGraph with all metrics. """ edges = score_reciprocity(messages, now=now, tau_days=tau_days) channels = score_channels(messages, gini_broadcast_threshold=gini_threshold) echo = score_echo(messages) shadows = detect_shadows(messages, now=now, silence_days=silence_days) # Build node scores nodes = {} node_sent = defaultdict(int) node_recv = defaultdict(int) node_last = defaultdict(float) for m in messages: node_sent[m["from_id"]] += 1 node_recv[m["to_id"]] += 1 t = m.get("timestamp", 0) node_last[m["from_id"]] = max(node_last[m["from_id"]], t) all_nodes = set(node_sent) | set(node_recv) for n in all_nodes: ns = NodeScore( node_id=n, total_sent=node_sent[n], total_received=node_recv[n], echo_coefficient=echo.get(n, 0.0), shadow_strength=shadows.get(n, {}).get("historical_weight", 0.0), is_shadow=n in shadows, last_active=node_last.get(n, 0.0) ) nodes[n] = ns # Summary dialogue_count = sum(1 for c in channels.values() if c.classification == "dialogue") broadcast_count = sum(1 for c in channels.values() if c.classification == "broadcast") shadow_count = len(shadows) graph = ScoredGraph( edges=edges, channels=channels, nodes=nodes, summary={ "total_messages": len(messages), "total_nodes": len(all_nodes), "total_edges": len(edges), "dialogue_channels": dialogue_count, "broadcast_channels": broadcast_count, "shadow_nodes": shadow_count, "mean_reciprocity_f3": round( sum(e.reciprocity_f3 for e in edges.values()) / max(len(edges), 1), 4 ) } ) return graph if __name__ == "__main__": # Quick demo with synthetic data import time now = time.time() day = 86400 demo_messages = [ # Alice-Bob dialogue (high reciprocity) {"from_id": "alice", "to_id": "bob", "timestamp": now - 1*day, "channel": "dev", "concepts": ["reciprocity", "graph"]}, {"from_id": "bob", "to_id": "alice", "timestamp": now - 1*day + 3600, "channel": "dev", "concepts": ["graph", "metrics"]}, {"from_id": "alice", "to_id": "bob", "timestamp": now - 0.5*day, "channel": "dev", "concepts": ["decay"]}, {"from_id": "bob", "to_id": "alice", "timestamp": now - 0.5*day + 1800, "channel": "dev"}, # Carol broadcasts (low reciprocity, high Gini) {"from_id": "carol", "to_id": "alice", "timestamp": now - 2*day, "channel": "announce", "concepts": ["launch"]}, {"from_id": "carol", "to_id": "bob", "timestamp": now - 2*day + 60, "channel": "announce"}, {"from_id": "carol", "to_id": "dave", "timestamp": now - 2*day + 120, "channel": "announce"}, {"from_id": "carol", "to_id": "eve", "timestamp": now - 2*day + 180, "channel": "announce"}, {"from_id": "alice", "to_id": "carol", "timestamp": now - 1.5*day, "channel": "announce"}, # Dave: silent but concept echoed {"from_id": "dave", "to_id": "alice", "timestamp": now - 10*day, "channel": "research", "concepts": ["echo", "shadow"]}, {"from_id": "alice", "to_id": "bob", "timestamp": now - 5*day, "channel": "dev", "concepts": ["echo"]}, {"from_id": "bob", "to_id": "carol", "timestamp": now - 3*day, "channel": "dev", "concepts": ["shadow"]}, # Eve: completely silent listener {"from_id": "eve", "to_id": "carol", "timestamp": now - 15*day, "channel": "announce"}, ] result = analyze(demo_messages, now=now) print("=== SwarmMetrics Demo ===\n") print(f"Summary: {result.summary}\n") print("Edge Reciprocity:") for pair, es in sorted(result.edges.items(), key=lambda x: -x[1].reciprocity_f3): print(f" {es.source} <-> {es.target}: F3={es.reciprocity_f3:.4f} " f"({es.messages_ab}↔{es.messages_ba})") print("\nChannel Classification:") for ch, cs in result.channels.items(): print(f" {ch}: {cs.classification} (Gini={cs.gini}, " f"dominant={cs.dominant_speaker} @ {cs.dominant_share:.0%})") print("\nNode Echo Coefficients:") for nid, ns in sorted(result.nodes.items(), key=lambda x: -x[1].echo_coefficient): shadow = " [SHADOW]" if ns.is_shadow else "" print(f" {nid}: echo={ns.echo_coefficient:.4f}, " f"sent={ns.total_sent}, recv={ns.total_received}{shadow}") # ── Semantic collapse (F3.4) ───────────────────────────── # Defense against synonym padding: adversary generates 50 unique strings # that all mean "hello", defeating unique_concepts count in F3.3. # Collapse semantically similar concepts before counting. # Atomic Raven (Colony, 2026-07-28) identified the attack vector. def _char_ngrams(s: str, n: int = 3) -> set: return set(s[i:i+n] for i in range(max(len(s) - n + 1, 1))) def _jaccard(a: str, b: str) -> float: sa, sb = _char_ngrams(a), _char_ngrams(b) if not sa or not sb: return 0.0 return len(sa & sb) / len(sa | sb) def semantic_collapse(concepts: list, threshold: float = 0.6) -> list: """Collapse similar concepts into canonical forms using n-gram Jaccard. A cheap proxy for semantic similarity. Catches "greeting-1"/"greeting-2" style padding but preserves genuinely diverse concepts. threshold: Jaccard similarity above which concepts merge. 0.6 collapses trivial suffix variations, keeps distinct concepts. Returns deduplicated concept list (canonical forms only). """ canonicals = [] # (canonical_form, ngrams) mapping = {} for c in concepts: matched = False for canon, canon_ngrams in canonicals: if _jaccard(c, canon) >= threshold: mapping[c] = canon matched = True break if not matched: canonicals.append((c, _char_ngrams(c))) mapping[c] = c return list(set(mapping[c] for c in concepts)) # ── Agent-alias merging ────────────────────────────────── # Agents that change names mid-corpus create phantom signals # in temporal analysis. merge_aliases() canonicalizes from_id/to_id # before scoring, collapsing identity evolution into a single node. def merge_aliases(messages: list, alias_map: dict) -> list: """Merge agent aliases in a message corpus. alias_map: {old_name: canonical_name, ...} Returns new list with from_id/to_id replaced by canonical names. Example: alias_map = { 'petrovich': 'petrovich-codex', 'hausmaster': 'φ-hausmaster', 'konstantин': 'кот-констант', 'кот-константин': 'кот-констант', } merged = merge_aliases(messages, alias_map) """ merged = [] for m in messages: m2 = m.copy() m2['from_id'] = alias_map.get(m2.get('from_id', ''), m2.get('from_id', '')) m2['to_id'] = alias_map.get(m2.get('to_id', ''), m2.get('to_id', '')) merged.append(m2) return merged # Default alias map for the OMPU bus corpus OMPU_BUS_ALIASES = { 'petrovich': 'petrovich-codex', 'hausmaster': 'φ-hausmaster', 'phi': 'φ-hausmaster', 'phi_hausmaster': 'φ-hausmaster', 'phi-hausmaster': 'φ-hausmaster', 'константин': 'кот-констант', 'кот-константин': 'кот-констант', }