139 lines
5.3 KiB
Python
139 lines
5.3 KiB
Python
"""Tests for MnemosyneArchive.resonance() — latent connection discovery."""
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from nexus.mnemosyne.archive import MnemosyneArchive
|
|
from nexus.mnemosyne.ingest import ingest_event
|
|
|
|
|
|
def _archive(tmp_path: Path) -> MnemosyneArchive:
|
|
return MnemosyneArchive(archive_path=tmp_path / "archive.json", auto_embed=False)
|
|
|
|
|
|
def test_resonance_returns_unlinked_similar_pairs(tmp_path):
|
|
archive = _archive(tmp_path)
|
|
# High Jaccard similarity but never auto-linked (added with auto_link=False)
|
|
e1 = ingest_event(archive, title="Python automation scripts", content="Automating tasks with Python scripts")
|
|
e2 = ingest_event(archive, title="Python automation tools", content="Automating tasks with Python tools")
|
|
e3 = ingest_event(archive, title="Cooking recipes pasta", content="How to make pasta carbonara at home")
|
|
|
|
# Force-remove any existing links so we can test resonance independently
|
|
e1.links = []
|
|
e2.links = []
|
|
e3.links = []
|
|
archive._save()
|
|
|
|
pairs = archive.resonance(threshold=0.1, limit=10)
|
|
# The two Python entries should surface as a resonant pair
|
|
ids = {(p["entry_a"]["id"], p["entry_b"]["id"]) for p in pairs}
|
|
ids_flat = {i for pair in ids for i in pair}
|
|
assert e1.id in ids_flat and e2.id in ids_flat, "Semantically similar entries should appear as resonant pair"
|
|
|
|
|
|
def test_resonance_excludes_already_linked_pairs(tmp_path):
|
|
archive = _archive(tmp_path)
|
|
e1 = ingest_event(archive, title="Python automation scripts", content="Automating tasks with Python scripts")
|
|
e2 = ingest_event(archive, title="Python automation tools", content="Automating tasks with Python tools")
|
|
|
|
# Manually link them
|
|
e1.links = [e2.id]
|
|
e2.links = [e1.id]
|
|
archive._save()
|
|
|
|
pairs = archive.resonance(threshold=0.0, limit=100)
|
|
for p in pairs:
|
|
a_id = p["entry_a"]["id"]
|
|
b_id = p["entry_b"]["id"]
|
|
assert not (a_id == e1.id and b_id == e2.id), "Already-linked pair should be excluded"
|
|
assert not (a_id == e2.id and b_id == e1.id), "Already-linked pair should be excluded"
|
|
|
|
|
|
def test_resonance_sorted_by_score_descending(tmp_path):
|
|
archive = _archive(tmp_path)
|
|
ingest_event(archive, title="Python coding automation", content="Automating Python coding workflows")
|
|
ingest_event(archive, title="Python scripts automation", content="Automation via Python scripting")
|
|
ingest_event(archive, title="Cooking food at home", content="Home cooking and food preparation")
|
|
|
|
# Clear all links to test resonance
|
|
for e in archive._entries.values():
|
|
e.links = []
|
|
archive._save()
|
|
|
|
pairs = archive.resonance(threshold=0.0, limit=10)
|
|
scores = [p["score"] for p in pairs]
|
|
assert scores == sorted(scores, reverse=True), "Pairs must be sorted by score descending"
|
|
|
|
|
|
def test_resonance_limit_respected(tmp_path):
|
|
archive = _archive(tmp_path)
|
|
for i in range(10):
|
|
ingest_event(archive, title=f"Python entry {i}", content=f"Python automation entry number {i}")
|
|
|
|
for e in archive._entries.values():
|
|
e.links = []
|
|
archive._save()
|
|
|
|
pairs = archive.resonance(threshold=0.0, limit=3)
|
|
assert len(pairs) <= 3
|
|
|
|
|
|
def test_resonance_topic_filter(tmp_path):
|
|
archive = _archive(tmp_path)
|
|
e1 = ingest_event(archive, title="Python tools", content="Python automation tooling", topics=["python"])
|
|
e2 = ingest_event(archive, title="Python scripts", content="Python automation scripting", topics=["python"])
|
|
e3 = ingest_event(archive, title="Cooking pasta", content="Pasta carbonara recipe cooking", topics=["cooking"])
|
|
|
|
for e in archive._entries.values():
|
|
e.links = []
|
|
archive._save()
|
|
|
|
pairs = archive.resonance(threshold=0.0, limit=20, topic="python")
|
|
for p in pairs:
|
|
a_topics = [t.lower() for t in p["entry_a"]["topics"]]
|
|
b_topics = [t.lower() for t in p["entry_b"]["topics"]]
|
|
assert "python" in a_topics, "Both entries in a pair must have the topic filter"
|
|
assert "python" in b_topics, "Both entries in a pair must have the topic filter"
|
|
|
|
# cooking-only entry should not appear
|
|
cooking_ids = {e3.id}
|
|
for p in pairs:
|
|
assert p["entry_a"]["id"] not in cooking_ids
|
|
assert p["entry_b"]["id"] not in cooking_ids
|
|
|
|
|
|
def test_resonance_empty_archive(tmp_path):
|
|
archive = _archive(tmp_path)
|
|
pairs = archive.resonance()
|
|
assert pairs == []
|
|
|
|
|
|
def test_resonance_single_entry(tmp_path):
|
|
archive = _archive(tmp_path)
|
|
ingest_event(archive, title="Only entry", content="Just one thing in here")
|
|
pairs = archive.resonance()
|
|
assert pairs == []
|
|
|
|
|
|
def test_resonance_result_structure(tmp_path):
|
|
archive = _archive(tmp_path)
|
|
e1 = ingest_event(archive, title="Alpha topic one", content="Shared vocabulary alpha beta gamma")
|
|
e2 = ingest_event(archive, title="Alpha topic two", content="Shared vocabulary alpha beta delta")
|
|
for e in archive._entries.values():
|
|
e.links = []
|
|
archive._save()
|
|
|
|
pairs = archive.resonance(threshold=0.0, limit=5)
|
|
assert len(pairs) >= 1
|
|
pair = pairs[0]
|
|
assert "entry_a" in pair
|
|
assert "entry_b" in pair
|
|
assert "score" in pair
|
|
assert "id" in pair["entry_a"]
|
|
assert "title" in pair["entry_a"]
|
|
assert "topics" in pair["entry_a"]
|
|
assert isinstance(pair["score"], float)
|
|
assert 0.0 <= pair["score"] <= 1.0
|