diff --git a/test_swarmmetrics.py b/test_swarmmetrics.py index 982a564..bfdf618 100644 --- a/test_swarmmetrics.py +++ b/test_swarmmetrics.py @@ -123,6 +123,59 @@ def test_f32_zombie_broadcast(): f"F3.2 zombie fail: dialogue={dialogue.reciprocity_f32:.4f} <= broadcast={broadcast.reciprocity_f32:.4f}" +def test_f33_zombie_broadcast_concepts(): + """F3.3 unique-concept-count must crush spam even harder than F3.2. + 100 pings with 2 concepts vs 5 messages with 5 concepts. + Idea from 小風 (ClawdChat): replace count with unique topic count.""" + msgs = [] + # 5-message dialogue, high reciprocity (3+2), 5 unique concepts + concepts_rich = [["graph", "reciprocity"], ["decay", "metrics"], ["shadow"], + ["echo", "idf"], ["influence"]] + for i in range(3): + msgs.append({"from_id": "alice", "to_id": "bob", + "timestamp": now - 1*day + i*100, + "concepts": concepts_rich[i]}) + for i in range(2): + msgs.append({"from_id": "bob", "to_id": "alice", + "timestamp": now - 0.5*day + i*100, + "concepts": concepts_rich[3+i]}) + # 100 pings, low reciprocity (95+5), only 2 unique concepts (repeated) + for i in range(95): + msgs.append({"from_id": "carol", "to_id": "dave", + "timestamp": now - 2*day + i*60, + "concepts": ["hello", "ping"]}) + for i in range(5): + msgs.append({"from_id": "dave", "to_id": "carol", + "timestamp": now - 1.5*day + i*600, + "concepts": ["hello"]}) + + edges = score_reciprocity(msgs, now=now) + dialogue = [e for e in edges.values() if "alice" in (e.source, e.target)][0] + broadcast = [e for e in edges.values() if "carol" in (e.source, e.target)][0] + + # F3.3 must separate them + assert dialogue.reciprocity_f33 > broadcast.reciprocity_f33, \ + (f"F3.3 zombie fail: dialogue={dialogue.reciprocity_f33:.4f} " + f"<= broadcast={broadcast.reciprocity_f33:.4f}") + # F3.3 spread should be wider than F3.2 + f33_ratio = dialogue.reciprocity_f33 / max(broadcast.reciprocity_f33, 0.001) + f32_ratio = dialogue.reciprocity_f32 / max(broadcast.reciprocity_f32, 0.001) + assert f33_ratio > f32_ratio, \ + f"F3.3 should separate more than F3.2: f33_ratio={f33_ratio:.1f} <= f32_ratio={f32_ratio:.1f}" + + +def test_f33_fallback_no_concepts(): + """F3.3 falls back to F3.2 when messages have no concepts.""" + msgs = [ + {"from_id": "a", "to_id": "b", "timestamp": now - 1*day}, + {"from_id": "b", "to_id": "a", "timestamp": now - 0.5*day}, + ] + edges = score_reciprocity(msgs, now=now) + edge = list(edges.values())[0] + assert edge.reciprocity_f33 == edge.reciprocity_f32, \ + f"F3.3 should equal F3.2 with no concepts: f33={edge.reciprocity_f33}, f32={edge.reciprocity_f32}" + + def test_f32_idf_echo_combined(): """F3.2 and IDF echo work together in full analyze().""" msgs = [