test: F3.4 semantic collapse + snapshot_idf — 26/26 (23 invariants + 3 known-defeats)

This commit is contained in:
Dispatch#70948f 2026-07-28 14:07:10 +00:00
parent 593868aa8d
commit 0dc24e4da2
1 changed files with 52 additions and 1 deletions

View File

@ -4,7 +4,7 @@ import sys
sys.path.insert(0, ".") sys.path.insert(0, ".")
from swarmmetrics import ( from swarmmetrics import (
_gini, _half_life_weight, score_reciprocity, score_channels, _gini, _half_life_weight, score_reciprocity, score_channels,
score_echo, detect_shadows, analyze, shuffle_test score_echo, detect_shadows, analyze, shuffle_test, semantic_collapse
) )
now = time.time() now = time.time()
@ -408,6 +408,57 @@ def test_genuine_novel_concept_zero_echo():
# for concepts that have had time to propagate. # for concepts that have had time to propagate.
def test_semantic_collapse_padding():
"""F3.4: semantic_collapse catches synonym padding attack.
50 variants of 'greeting-N' should collapse to ~1 canonical form.
This is the defense layer that F3.3 alone lacks."""
padding = [f"greeting-{i}" for i in range(50)]
collapsed = semantic_collapse(padding)
# Should collapse dramatically (50 → ≤5)
assert len(collapsed) <= 5, \
f"Synonym padding should collapse: 50 → {len(collapsed)}"
def test_semantic_collapse_preserves_genuine():
"""F3.4: semantic_collapse must not collapse genuinely diverse concepts.
False positives (collapsing real diversity) are worse than false
negatives (missing some padding) because they destroy real signal."""
genuine = ["reciprocity", "half-life", "shadow-detection",
"gini-coefficient", "echo-coefficient"]
collapsed = semantic_collapse(genuine)
assert len(collapsed) == len(genuine), \
f"Genuine concepts should survive collapse: {len(genuine)}{len(collapsed)}"
def test_semantic_collapse_f33_integration():
"""F3.4 with F3.3: synonym padding that defeats F3.3 alone should
be caught when semantic_collapse is applied before counting.
Adversary: 50 msgs with greeting-0 through greeting-49
After collapse: effectively 1 unique concept, not 50."""
msgs = []
# Adversary pair with synonym padding
hello_synonyms = [f"greeting-{i}" for i in range(50)]
for i in range(25):
msgs.append({"from_id": "eve", "to_id": "mallory",
"timestamp": now - 2*day + i*60,
"concepts": [hello_synonyms[i], hello_synonyms[i+25]]})
for i in range(25):
msgs.append({"from_id": "mallory", "to_id": "eve",
"timestamp": now - 1.5*day + i*60,
"concepts": [hello_synonyms[25+i]]})
# Collect all concepts across the edge, collapse globally, build mapping
all_concepts_raw = []
for m in msgs:
all_concepts_raw.extend(m["concepts"])
collapsed_canonical = semantic_collapse(all_concepts_raw)
# After collapse, all greeting-N should map to ~1 canonical form
assert len(collapsed_canonical) <= 5, \
f"After collapse, adversary should have ≤5 unique concepts: {len(collapsed_canonical)}"
def test_snapshot_idf_vs_corpus_idf(): def test_snapshot_idf_vs_corpus_idf():
"""snapshot_idf=True should compute IDF at emission time, not on full corpus. """snapshot_idf=True should compute IDF at emission time, not on full corpus.