496 lines
18 KiB
Python
496 lines
18 KiB
Python
"""
|
|
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) -> 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(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", [])
|
|
node = m["from_id"]
|
|
t = m.get("timestamp", 0)
|
|
|
|
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:
|
|
# 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)
|
|
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}")
|