Files
growmos/tests/test_growmos.py

315 lines
15 KiB
Python

"""End-to-end and unit tests for growmos (stdlib unittest — run: python -m unittest -v)."""
from __future__ import annotations
import io
import json
import shutil
import sys
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from growmos import cli, schema as S # noqa: E402
from growmos.evaluate import Evaluator, doctor # noqa: E402
from growmos.graph import Graph # noqa: E402
from growmos.prompts import chunk_text, extraction_packet, query_packet, resolution_blocks # noqa: E402
from growmos.store import Store # noqa: E402
from growmos.util import read_json # noqa: E402
EXAMPLE = ROOT / "examples" / "apollo"
def run_cli(*argv: str, stdin: str = "") -> str:
buf = io.StringIO()
old = sys.stdin
sys.stdin = io.StringIO(stdin)
try:
with redirect_stdout(buf):
code = cli.main(list(argv))
finally:
sys.stdin = old
assert code == 0, f"exit {code}: {buf.getvalue()}"
return buf.getvalue()
class ApolloFixture(unittest.TestCase):
"""Builds the playbook's Apollo corpus graph in a temp repo, agent-natively."""
@classmethod
def setUpClass(cls) -> None:
cls.tmp = Path(tempfile.mkdtemp(prefix="growmos-"))
shutil.copytree(EXAMPLE / "docs", cls.tmp / "docs")
cls.st = Store.init(cls.tmp, preset_name="general")
cls.st.scan()
for f in sorted((EXAMPLE / "extractions").glob("*.json")):
data = read_json(f)
sid = next(s for s, r in cls.st.sources.items() if r["ref"] == data["source"])
cls.st.apply_extraction(sid, data)
res = read_json(EXAMPLE / "resolution.json")
for etype, payload in res.items():
names = [e["name"] for e in cls.st.entities.values() if e["type"] == etype]
clusters, _ = S.validate_resolution(payload, names)
cls.st.apply_resolution(etype, clusters)
gold_dir = cls.st.p("eval", "gold")
for f in (EXAMPLE / "gold").glob("*.json"):
shutil.copy(f, gold_dir / f.name)
shutil.copy(EXAMPLE / "gold-aliases.json", cls.st.p("eval", "aliases.json"))
cls.st.save()
@classmethod
def tearDownClass(cls) -> None:
shutil.rmtree(cls.tmp, ignore_errors=True)
def setUp(self) -> None:
self.st = Store(self.tmp).load()
self.g = Graph(self.st)
def test_graph_is_one_component_after_resolution(self):
d = self.g.diagnostics()
self.assertEqual(d["components"], 1, d)
self.assertGreaterEqual(d["nodes"], 15)
self.assertGreater(d["density"], 1.0)
self.assertEqual(d["provisional"], 0)
def test_hard_resolution_cases(self):
# Edwin Aldrin -> Buzz Aldrin (zero character overlap) and Neil Armstrong -> Neil Alden Armstrong
self.assertEqual(self.st.resolve_name("Edwin Aldrin"), self.st.resolve_name("Buzz Aldrin"))
self.assertEqual(self.st.resolve_name("Neil Armstrong"), self.st.resolve_name("Neil Alden Armstrong"))
# Gemini 12 was NOT folded into anything
self.assertIsNotNone(self.st.resolve_name("Gemini 12"))
self.assertNotEqual(self.st.resolve_name("Gemini 12"), self.st.resolve_name("Gemini 8"))
def test_corroboration_counts_documents(self):
armstrong = self.st.resolve_name("Neil Armstrong")
apollo11 = self.st.resolve_name("Apollo 11")
rel = next(r for r in self.st.relations.values()
if r["source"] == armstrong and r["target"] == apollo11 and r["predicate"] == "commanded")
self.assertEqual(rel["confidence"], 2) # neil-armstrong.md + apollo-11.md
def test_check_claim_gives_playbook_feedback(self):
res = self.g.check_claim("Neil Armstrong", "commanded", "Gemini 12")
self.assertEqual(res["verdict"], "contradicting_evidence")
ev = "\n".join(res["evidence"])
self.assertIn("Buzz Aldrin) --[flew on]--> (Gemini 12", ev)
self.assertIn("commanded]--> (Apollo 11", ev)
ok = self.g.check_claim("Armstrong", "walked on", "Moon") # partial name → not resolvable → absent
self.assertIn(ok["verdict"], ("absent", "contradicting_evidence"))
sup = self.g.check_claim("Neil Armstrong", "walked on", "Moon")
self.assertEqual(sup["verdict"], "supported")
def test_query_packet_grounds_and_cites(self):
text, meta = query_packet(self.st, "Which locations are connected to people who flew on Apollo 11?")
self.assertIn("(Apollo 11)", text)
self.assertIn("[r_", text)
self.assertGreaterEqual(meta["edges"], 10)
self.assertIn("Answer using only the knowledge graph", text)
def test_eval_precision_perfect_recall_partial(self):
rep = Evaluator(self.st).evaluate()
by = {r["doc"]: r for r in rep["docs"]}
self.assertEqual(by["docs/apollo-11.md"]["raw"]["p"], 1.0)
self.assertLess(by["docs/neil-armstrong.md"]["raw"]["r"], 1.0)
self.assertIn("purdue university", by["docs/neil-armstrong.md"]["missed_entities"])
def test_doctor_runs_and_reports_ten_items(self):
checks = doctor(self.st)
self.assertEqual(len(checks), 10)
names = {c["item"] for c in checks}
self.assertIn("Provenance tracking", names)
self.assertEqual(next(c for c in checks if c["item"] == "Provenance tracking")["status"], "ok")
def test_incremental_scan_flags_only_changed(self):
rep = self.st.scan()
self.assertEqual(rep["new"], [])
self.assertEqual(rep["changed"], [])
(self.tmp / "docs" / "apollo-11.md").write_text("# Apollo 11\n\nEdited.\n", encoding="utf-8")
rep = self.st.scan()
self.assertEqual(rep["changed"], ["docs/apollo-11.md"])
self.assertEqual(len(self.st.pending_sources()), 1)
# restore
shutil.copy(EXAMPLE / "docs" / "apollo-11.md", self.tmp / "docs" / "apollo-11.md")
self.st.scan()
pkt, meta = extraction_packet(self.st, self.st.pending_sources()[0]["id"])
self.assertIn("growmos apply extraction", pkt)
self.assertEqual(meta["chunks"], 1)
def test_next_hands_out_gold_then_review_then_done(self):
import copy
tmp2 = Path(tempfile.mkdtemp(prefix="growmos-next-"))
shutil.copytree(self.tmp, tmp2 / "r")
root = tmp2 / "r"
st = Store(root).load()
for f in st.p("eval", "gold").glob("*.json"):
f.unlink()
for eid, deg in Graph(st).hubs(50):
if deg >= 3:
st.set_profile(eid, {"summary": "s", "key_facts": ["k"], "time_range": {"start": "u", "end": "u"}})
st.state["last_sample"] = None
st.save()
out = run_cli("--root", str(root), "next")
self.assertIn("gold set", out)
for _ in range(2):
meta = json.loads(run_cli("--root", str(root), "next", "--json"))["meta"]
run_cli("--root", str(root), "apply", "gold", "-", "--source", meta["source"],
stdin=json.dumps({"entities": [{"name": "Apollo 11", "type": "EVENT"}], "relations": []}))
out = run_cli("--root", str(root), "next")
self.assertIn("review", out)
meta = json.loads(run_cli("--root", str(root), "next", "--json"))["meta"]
run_cli("--root", str(root), "apply", "review", "-", "--entity", meta["entity"],
stdin=json.dumps({"ok": True, "issues": [], "fixes": []}))
out = run_cli("--root", str(root), "next")
self.assertIn("up to date", out)
checks = {c["item"]: c["status"] for c in doctor(Store(root).load())}
self.assertEqual(checks["Gold set"], "ok")
self.assertEqual(checks["Human sample"], "ok")
shutil.rmtree(tmp2, ignore_errors=True)
def test_eval_auto_extends_alias_map(self):
tmp2 = Path(tempfile.mkdtemp(prefix="growmos-alias-"))
shutil.copytree(self.tmp, tmp2 / "r")
st = Store(tmp2 / "r").load()
(st.p("eval", "aliases.json")).write_text("{}", encoding="utf-8")
rep = Evaluator(st).evaluate()
amap = read_json(st.p("eval", "aliases.json"))
self.assertEqual(amap.get("Neil Alden Armstrong"), "Neil Armstrong")
self.assertEqual(rep["unrecognized_canonicals"], [])
shutil.rmtree(tmp2, ignore_errors=True)
def test_export_formats(self):
from growmos.export import EXPORTERS
for name, fn in EXPORTERS.items():
out = fn(self.st)
self.assertTrue(out.strip(), name)
self.assertIn("MERGE (n:PERSON", EXPORTERS["cypher"](self.st))
html = EXPORTERS["html"](self.st)
self.assertIn("Buzz Aldrin", html)
self.assertNotIn("__DATA__", html)
out = run_cli("--root", str(self.tmp), "view", "--no-open")
self.assertIn("graph.html", out)
self.assertIn("CREATE TABLE IF NOT EXISTS entities", EXPORTERS["sql"](self.st))
def test_cli_status_context_show(self):
out = run_cli("--root", str(self.tmp), "status")
self.assertIn("nodes", out)
out = run_cli("--root", str(self.tmp), "context", "--brief")
self.assertIn("Hubs:", out)
out = run_cli("--root", str(self.tmp), "show", "Buzz Aldrin")
self.assertIn("Edwin Aldrin", out) # alias listed
class UnitTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = Path(tempfile.mkdtemp(prefix="growmos-unit-"))
(self.tmp / "README.md").write_text("# Demo\n\nThe Scheduler depends on the Store.\n", encoding="utf-8")
self.st = Store.init(self.tmp, preset_name="software")
self.st.scan()
self.st.save()
def tearDown(self) -> None:
shutil.rmtree(self.tmp, ignore_errors=True)
def test_validate_extraction_drops_dangling_and_bad_types(self):
cleaned, problems = S.validate_extraction({
"entities": [{"name": "A", "type": "PERSON", "description": "x"}, {"name": "B", "type": "NOPE", "description": ""}],
"relations": [{"source": "A", "predicate": "knows", "target": "B"}, {"source": "A", "predicate": "knows", "target": "Z"}],
}, S.BASE_ENTITY_TYPES)
self.assertEqual(len(cleaned["entities"]), 2)
self.assertEqual(cleaned["entities"][1]["type"], "ARTIFACT")
self.assertEqual(len(cleaned["relations"]), 1)
self.assertTrue(any("dropped" in p for p in problems))
def test_validate_resolution_fallback_and_duplicates(self):
clusters, problems = S.validate_resolution(
{"clusters": [{"canonical": "A", "aliases": ["A", "a2"]}, {"canonical": "B", "aliases": ["a2", "B"]}]},
["A", "a2", "B", "C"])
names = sorted(n for c in clusters for n in c["aliases"])
self.assertEqual(names, ["A", "B", "C", "a2"])
self.assertTrue(any("missing from every cluster" in p for p in problems))
self.assertTrue(any("more than one cluster" in p for p in problems))
def test_remember_link_journal_and_merge(self):
eid, created = self.st.remember("Scheduler", "COMPONENT", "Schedules jobs.", "session:test")
self.assertTrue(created)
rid, _ = self.st.link("Scheduler", "depends on", "Store", "session:test")
self.assertIn(rid, self.st.relations)
self.assertEqual(self.st.relations[rid]["sources"], [self.st._source_id("session:test")])
# merge alias into canonical and ensure edges follow
other, _ = self.st.get_or_create_entity("The Store", "COMPONENT", "same thing")
store_id = self.st.resolve_name("Store")
self.st.link("Scheduler", "reads", "The Store", "session:test")
self.st.merge(other, store_id)
self.assertNotIn(other, self.st.entities)
self.assertEqual(self.st.resolve_name("The Store"), store_id)
self.assertTrue(all(r["source"] in self.st.entities and r["target"] in self.st.entities
for r in self.st.relations.values()))
self.st.journal("hello", author="test")
self.assertIn("hello", self.st.journal_tail(1)[0])
self.st.save()
st2 = Store(self.tmp).load()
self.assertEqual(len(st2.relations), len(self.st.relations))
def test_apply_extraction_creates_provisional_and_resolves_exact(self):
sid = next(iter(self.st.sources))
rep = self.st.apply_extraction(sid, {"entities": [
{"name": "Scheduler", "type": "COMPONENT", "description": "d"},
{"name": "scheduler", "type": "COMPONENT", "description": "dup by case"}],
"relations": []})
self.assertEqual(rep["new_entities"], 1) # case-insensitive exact match resolves
self.assertEqual(len(self.st.provisional_entities()), 1)
blocks = resolution_blocks(self.st, "COMPONENT")
self.assertEqual(len(blocks), 1)
self.assertEqual(self.st.sources[sid]["status"], "extracted")
def test_chunking_keeps_overlap(self):
text = "\n\n".join(f"## Section {i}\n\n" + ("word " * 400) for i in range(6))
chunks = chunk_text(text, max_chars=3000)
self.assertGreater(len(chunks), 1)
self.assertTrue(all(len(c) <= 4600 for c in chunks))
def test_schema_bump_and_type_added_by_remember(self):
v0 = self.st.schema_version
out = run_cli("--root", str(self.tmp), "remember", "Kafka", "--type", "QUEUE", "--desc", "message bus")
self.assertIn("created", out)
st = Store(self.tmp).load()
self.assertEqual(st.schema_version, v0 + 1)
self.assertIn("QUEUE", st.entity_types)
def test_integrate_is_idempotent(self):
from growmos.integrate import integrate
integrate(self.tmp, "codex")
first = (self.tmp / "AGENTS.md").read_text()
integrate(self.tmp, "codex")
self.assertEqual(first, (self.tmp / "AGENTS.md").read_text())
self.assertEqual(first.count("growmos:start"), 1)
integrate(self.tmp, "claude")
settings = read_json(self.tmp / ".claude" / "settings.json")
self.assertIn("SessionStart", settings["hooks"])
integrate(self.tmp, "claude")
settings2 = read_json(self.tmp / ".claude" / "settings.json")
self.assertEqual(len(settings2["hooks"]["SessionStart"]), 1)
self.assertIn("growmos", read_json(self.tmp / ".mcp.json")["mcpServers"])
def test_mcp_tools_call(self):
from growmos import mcp
import os
cwd = os.getcwd()
os.chdir(self.tmp)
try:
out = mcp.call_tool("growmos_remember", {"name": "Store", "type": "COMPONENT", "description": "persists"})
self.assertIn("component/store", out)
out = mcp.call_tool("growmos_context", {})
self.assertIn("growmos", out)
names = {t["name"] for t in mcp.TOOLS}
self.assertIn("growmos_query", names)
finally:
os.chdir(cwd)
if __name__ == "__main__":
unittest.main()