diff --git a/test_swarmmetrics.py b/test_swarmmetrics.py index e9ef090..4959305 100644 --- a/test_swarmmetrics.py +++ b/test_swarmmetrics.py @@ -1177,3 +1177,73 @@ def test_jitter_defense_recovers_from_injection(): mean_recovery = sum(recoveries) / len(recoveries) assert mean_recovery > 0.5, \ f"Jitter defense should recover >50% of baseline: got {mean_recovery:.0%}" + + +def test_independent_adoption_filters_conversation(): + """Independent-only mode should exclude echo from conversation partners. + + Setup: A introduces concept X. B (who messages A directly) uses X. + C (no direct contact with A) uses X. + + Without filter: both B and C count as echo for A. + With filter: only C counts. + """ + messages = [ + # A introduces concept "novel" + {"from_id": "A", "to_id": "channel", "timestamp": 100, "concepts": ["novel"]}, + # B messages A directly (conversation partner) + {"from_id": "B", "to_id": "A", "timestamp": 150, "concepts": ["hello"]}, + # B uses A's concept (conversation echo) + {"from_id": "B", "to_id": "channel", "timestamp": 200, "concepts": ["novel"]}, + # C uses A's concept (independent adoption, no direct contact with A) + {"from_id": "C", "to_id": "channel", "timestamp": 300, "concepts": ["novel"]}, + ] + + # Without filter: A gets echo from both B and C + echo_all = score_echo(messages, use_idf=False) + assert echo_all.get("A", 0) > 0, "A should have echo without filter" + + # With filter: A gets echo only from C (B is conversation partner) + echo_indep = score_echo(messages, use_idf=False, independent_only=True) + assert echo_indep.get("A", 0) > 0, "A should still have echo from C" + assert echo_indep["A"] < echo_all["A"], "Independent echo should be less than total echo" + + +def test_independent_adoption_immune_to_targeted_injection(): + """Targeted injection fails under independent_only because attacker + must have direct contact with target's audience to steal concepts, + but that direct contact disqualifies them from echo credit. + """ + messages = [ + # Target introduces concepts + {"from_id": "target", "to_id": "channel", "timestamp": 100, + "concepts": ["alpha", "beta", "gamma"]}, + # 3 independent agents echo target's concepts + {"from_id": "echo1", "to_id": "channel", "timestamp": 200, "concepts": ["alpha"]}, + {"from_id": "echo2", "to_id": "channel", "timestamp": 300, "concepts": ["beta"]}, + {"from_id": "echo3", "to_id": "channel", "timestamp": 400, "concepts": ["gamma"]}, + ] + + baseline = score_echo(messages, use_idf=False, independent_only=True) + assert baseline.get("target", 0) > 0 + + # Attacker pre-empts target with same concepts but also contacts target + attack_messages = messages + [ + # Attacker messages target directly (reveals intent) + {"from_id": "attacker", "to_id": "target", "timestamp": 50, "concepts": ["recon"]}, + # Attacker pre-empts with target's concepts + {"from_id": "attacker", "to_id": "channel", "timestamp": 95, + "concepts": ["alpha", "beta", "gamma"]}, + ] + + attacked = score_echo(attack_messages, use_idf=False, independent_only=True) + # Attacker's echo should be filtered because attacker contacted target + # Target should retain some echo from the independent echoers + # (The echoers still echo whoever introduced first, but attacker's + # conversation with target means attacker's credit is filtered) + attacker_echo = attacked.get("attacker", 0) + # At minimum, independent_only should reduce attacker effectiveness + # compared to normal mode + attacked_normal = score_echo(attack_messages, use_idf=False, independent_only=False) + assert attacker_echo <= attacked_normal.get("attacker", 0), \ + "Independent mode should not increase attacker echo"