mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 10:27:11 +00:00
c865a3c9b0
Refines the staleness file resolution (00e00a0) by folding in the two
genuine merits of @TPAteeq's parallel fix (#1558), which independently
and correctly diagnosed the same root-mismatch bug:
- Layout-ordered candidates: try the layout-appropriate root FIRST (the
graphify-out parent for the standard layout, graph.json's own dir for a
flat layout) before the other. The prior order tried the grandparent
first unconditionally, which in a flat layout (graph.json at the project
root) could fingerprint a same-named file one directory up. Existence
checking is kept on top, so a defeated name heuristic or a stale
.graphify_root marker still falls through to the real file.
- Adds @TPAteeq's .graphify_root-marker-driven regression test, plus a
flat-layout test that pins the ordering (editing the real file flips
stale; editing the same-named decoy one dir up does not).
Co-Authored-By: tpateeq <mohammedateequddin399@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
959 lines
42 KiB
Python
959 lines
42 KiB
Python
"""Tests for `graphify reflect` and the work-memory reflection layer.
|
|
|
|
`graphify reflect` reads the outcome-tagged Q&A docs that `graphify save-result`
|
|
files into graphify-out/memory/ and writes a deterministic lessons artifact
|
|
(graphify-out/reflections/LESSONS.md) an agent can load next session: preferred
|
|
sources, known dead ends, and corrections — optionally grouped by community.
|
|
|
|
Covers the pure aggregation/rendering helpers (deterministic, no LLM, no graph
|
|
required) and the end-to-end CLI, including the "second session benefits from the
|
|
first" worked example from the issue.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from graphify.ingest import save_query_result
|
|
from graphify.reflect import (
|
|
aggregate_lessons,
|
|
lessons_fresh,
|
|
load_memory_docs,
|
|
parse_memory_doc,
|
|
reflect,
|
|
render_lessons_md,
|
|
)
|
|
|
|
PYTHON = sys.executable
|
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
|
|
# Fixed clock so time-decay scoring is byte-stable in tests (reflect/aggregate take `now`).
|
|
_NOW = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
|
|
|
|
|
def _days_before(n: int) -> str:
|
|
return (_NOW - timedelta(days=n)).isoformat()
|
|
|
|
|
|
def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
|
|
return subprocess.run(
|
|
[PYTHON, "-m", "graphify"] + args,
|
|
cwd=cwd, capture_output=True, text=True,
|
|
)
|
|
|
|
|
|
# --- frontmatter parsing -------------------------------------------------------
|
|
|
|
|
|
def test_parse_round_trips_a_saved_doc(tmp_path):
|
|
"""parse_memory_doc reads back exactly what save_query_result wrote, including
|
|
an escaped question and the source_nodes flow list."""
|
|
out = save_query_result(
|
|
'what is "attention"?', "softmax", tmp_path / "memory",
|
|
query_type="explain", source_nodes=["AttentionLayer", "SoftmaxFunc"],
|
|
outcome="useful",
|
|
)
|
|
parsed = parse_memory_doc(out.read_text(encoding="utf-8"))
|
|
assert parsed is not None
|
|
assert parsed["type"] == "explain"
|
|
assert parsed["question"] == 'what is "attention"?'
|
|
assert parsed["outcome"] == "useful"
|
|
assert parsed["source_nodes"] == ["AttentionLayer", "SoftmaxFunc"]
|
|
|
|
|
|
def test_parse_returns_none_for_foreign_doc():
|
|
"""A plain markdown file with no frontmatter is skipped, not crashed on."""
|
|
assert parse_memory_doc("# just a note\n\nno frontmatter here\n") is None
|
|
assert parse_memory_doc("") is None
|
|
|
|
|
|
def test_round_trip_survives_backslash_newline_and_quoted_node(tmp_path):
|
|
"""save -> parse preserves tricky characters in the question, the correction,
|
|
and (the previously-unescaped) source-node names exactly."""
|
|
out = save_query_result(
|
|
r'path is C:\Users and a "quote"', "a", tmp_path / "memory",
|
|
source_nodes=[r'Node"With\Quote'],
|
|
outcome="corrected", correction="line1\nline2",
|
|
)
|
|
parsed = parse_memory_doc(out.read_text(encoding="utf-8"))
|
|
assert parsed is not None
|
|
assert parsed["question"] == r'path is C:\Users and a "quote"'
|
|
assert parsed["correction"] == "line1\nline2"
|
|
assert parsed["source_nodes"] == [r'Node"With\Quote']
|
|
|
|
|
|
def test_parse_handles_crlf():
|
|
doc = "---\r\ntype: \"query\"\r\noutcome: \"useful\"\r\nsource_nodes: [\"A\"]\r\n---\r\n# body\r\n"
|
|
parsed = parse_memory_doc(doc)
|
|
assert parsed is not None
|
|
assert parsed["outcome"] == "useful"
|
|
assert parsed["source_nodes"] == ["A"]
|
|
|
|
|
|
def test_load_memory_docs_skips_foreign_and_sorts(tmp_path):
|
|
mem = tmp_path / "memory"
|
|
mem.mkdir()
|
|
(mem / "foreign.md").write_text("# not a memory doc\n", encoding="utf-8")
|
|
save_query_result("first", "a", mem, outcome="useful")
|
|
save_query_result("second", "b", mem, outcome="dead_end")
|
|
docs = load_memory_docs(mem)
|
|
# Foreign doc dropped; the two real docs survive.
|
|
assert len(docs) == 2
|
|
assert {d["outcome"] for d in docs} == {"useful", "dead_end"}
|
|
|
|
|
|
def test_load_memory_docs_missing_dir_is_empty(tmp_path):
|
|
assert load_memory_docs(tmp_path / "nope") == []
|
|
|
|
|
|
def _write_raw_doc(mem: Path, filename: str, date: str, *, outcome="dead_end",
|
|
question="q", nodes=None):
|
|
"""Write a memory doc with a controlled date so ordering is deterministic to assert."""
|
|
mem.mkdir(parents=True, exist_ok=True)
|
|
nodes = nodes or []
|
|
lines = ["---", 'type: "query"', f'date: "{date}"', f'question: "{question}"',
|
|
'contributor: "graphify"', f'outcome: "{outcome}"']
|
|
if nodes:
|
|
lines.append("source_nodes: [" + ", ".join(f'"{n}"' for n in nodes) + "]")
|
|
lines += ["---", "", f"# Q: {question}", ""]
|
|
(mem / filename).write_text("\n".join(lines), encoding="utf-8")
|
|
|
|
|
|
def test_load_memory_docs_orders_by_date_then_filename(tmp_path):
|
|
"""Determinism hinges on this sort: docs come back oldest-first, filename as tiebreak."""
|
|
mem = tmp_path / "memory"
|
|
_write_raw_doc(mem, "z.md", "2026-03-01", question="march")
|
|
_write_raw_doc(mem, "a.md", "2026-01-01", question="january")
|
|
_write_raw_doc(mem, "b.md", "2026-02-01", question="february")
|
|
# Same date, two filenames -> filename tiebreak.
|
|
_write_raw_doc(mem, "c.md", "2026-01-01", question="january-2")
|
|
dates = [d["date"] for d in load_memory_docs(mem)]
|
|
assert dates == ["2026-01-01", "2026-01-01", "2026-02-01", "2026-03-01"]
|
|
# Within the tied date, "a.md" precedes "c.md".
|
|
tied = [d["_path"] for d in load_memory_docs(mem) if d["date"] == "2026-01-01"]
|
|
assert tied == ["a.md", "c.md"]
|
|
|
|
|
|
# --- aggregation ---------------------------------------------------------------
|
|
|
|
|
|
def _doc(outcome=None, nodes=None, question="q", correction="", date="2026-01-01"):
|
|
return {
|
|
"outcome": outcome, "source_nodes": nodes or [],
|
|
"question": question, "correction": correction, "date": date,
|
|
}
|
|
|
|
|
|
def test_aggregate_counts_each_outcome():
|
|
docs = [
|
|
_doc("useful", ["A"]), _doc("useful", ["A", "B"]),
|
|
_doc("dead_end", ["C"]), _doc("corrected", correction="use D"),
|
|
_doc(None),
|
|
]
|
|
agg = aggregate_lessons(docs)
|
|
assert agg["total"] == 5
|
|
assert agg["counts"] == {"useful": 2, "dead_end": 1, "corrected": 1, "unmarked": 1}
|
|
|
|
|
|
def test_sources_split_into_preferred_tentative_contested():
|
|
"""Corroboration (k>=2) + sign decide the bucket, not raw frequency:
|
|
A is useful twice but also a dead end -> contested; B twice-useful -> preferred;
|
|
C once-useful -> tentative."""
|
|
docs = [
|
|
_doc("useful", ["A", "B"]), _doc("useful", ["A", "B"]),
|
|
_doc("useful", ["C"]),
|
|
_doc("dead_end", ["A"]), # gives A a negative signal
|
|
]
|
|
agg = aggregate_lessons(docs, now=_NOW, min_corroboration=2)
|
|
preferred = [e["node"] for e in agg["preferred"]]
|
|
tentative = [e["node"] for e in agg["tentative"]]
|
|
contested = [e["node"] for e in agg["contested"]]
|
|
assert preferred == ["B"] # 2 useful, no negatives
|
|
assert tentative == ["C"] # 1 useful only
|
|
assert contested == ["A"] # 2 useful + 1 dead end
|
|
# A never silently appears as a plain preferred/tentative source.
|
|
assert "A" not in preferred and "A" not in tentative
|
|
|
|
|
|
def test_corroboration_threshold_promotes_only_repeated_nodes():
|
|
"""One save can't mint a 'preferred' lesson; a second distinct result promotes it."""
|
|
one = aggregate_lessons([_doc("useful", ["A"])], now=_NOW, min_corroboration=2)
|
|
assert [e["node"] for e in one["tentative"]] == ["A"]
|
|
assert one["preferred"] == []
|
|
|
|
two = aggregate_lessons(
|
|
[_doc("useful", ["A"]), _doc("useful", ["A"])], now=_NOW, min_corroboration=2)
|
|
assert [e["node"] for e in two["preferred"]] == ["A"]
|
|
assert two["tentative"] == []
|
|
|
|
|
|
def test_recency_decides_contested_verdict():
|
|
"""A fresh dead_end outweighs a stale useful (30d half-life), so the contested
|
|
node leans 'dead end'; flip the dates and it leans 'useful'."""
|
|
stale_useful = _doc("useful", ["N"], date=_days_before(120))
|
|
fresh_deadend = _doc("dead_end", ["N"], date=_days_before(1))
|
|
agg = aggregate_lessons([stale_useful, fresh_deadend], now=_NOW)
|
|
contested = agg["contested"]
|
|
assert len(contested) == 1 and contested[0]["node"] == "N"
|
|
assert contested[0]["verdict"] == "dead end"
|
|
|
|
flipped = aggregate_lessons(
|
|
[_doc("useful", ["N"], date=_days_before(1)),
|
|
_doc("dead_end", ["N"], date=_days_before(120))], now=_NOW)
|
|
assert flipped["contested"][0]["verdict"] == "useful"
|
|
|
|
|
|
def test_node_existence_gate_drops_stale_nodes():
|
|
"""A cited node no longer in the graph is dropped from lessons entirely."""
|
|
docs = [_doc("useful", ["Alive", "Deleted"]), _doc("useful", ["Alive", "Deleted"])]
|
|
agg = aggregate_lessons(docs, now=_NOW, known_nodes={"Alive"})
|
|
names = [e["node"] for e in agg["preferred"] + agg["tentative"] + agg["contested"]]
|
|
assert "Deleted" not in names
|
|
assert "Alive" in names
|
|
|
|
|
|
def test_corroboration_counts_distinct_docs_not_citations():
|
|
"""A node cited twice *within one doc* counts as ONE corroborating result, so it
|
|
stays tentative under k=2 — guards the dict.fromkeys per-doc dedup."""
|
|
agg = aggregate_lessons([_doc("useful", ["A", "A"])], now=_NOW, min_corroboration=2)
|
|
assert agg["preferred"] == []
|
|
assert [e["node"] for e in agg["tentative"]] == ["A"]
|
|
assert agg["tentative"][0]["n"] == 1
|
|
|
|
|
|
def test_min_corroboration_is_honored_not_hardcoded():
|
|
"""Two distinct useful results -> preferred at k=2, but only tentative at k=3."""
|
|
docs = [_doc("useful", ["A"]), _doc("useful", ["A"])]
|
|
assert [e["node"] for e in aggregate_lessons(docs, now=_NOW, min_corroboration=2)["preferred"]] == ["A"]
|
|
at_k3 = aggregate_lessons(docs, now=_NOW, min_corroboration=3)
|
|
assert at_k3["preferred"] == []
|
|
assert [e["node"] for e in at_k3["tentative"]] == ["A"]
|
|
|
|
|
|
def test_half_life_actually_feeds_decay():
|
|
"""Two stale useful + one fresh dead_end: a long half-life (≈no decay) lets the 2
|
|
useful win; a short half-life lets the fresh dead end win. Proves the flag feeds
|
|
the decay, not just the default."""
|
|
docs = [
|
|
_doc("useful", ["N"], date=_days_before(90)),
|
|
_doc("useful", ["N"], date=_days_before(90)),
|
|
_doc("dead_end", ["N"], date=_days_before(1)),
|
|
]
|
|
long_hl = aggregate_lessons(docs, now=_NOW, half_life_days=100000)
|
|
short_hl = aggregate_lessons(docs, now=_NOW, half_life_days=10)
|
|
assert long_hl["contested"][0]["verdict"] == "useful"
|
|
assert short_hl["contested"][0]["verdict"] == "dead end"
|
|
|
|
|
|
def test_evenly_split_verdict_when_signals_cancel():
|
|
"""A same-date useful + dead_end on one node cancel to score 0 -> 'evenly split'."""
|
|
day = _days_before(5)
|
|
agg = aggregate_lessons(
|
|
[_doc("useful", ["N"], date=day), _doc("dead_end", ["N"], date=day)], now=_NOW)
|
|
assert agg["contested"][0]["verdict"] == "even"
|
|
assert "evenly split" in render_lessons_md(agg)
|
|
|
|
|
|
def test_nonpositive_half_life_disables_decay():
|
|
"""half_life<=0 turns decay off (full weight), so a stale useful and a fresh
|
|
dead_end weigh equally and cancel."""
|
|
docs = [_doc("useful", ["N"], date=_days_before(365)),
|
|
_doc("dead_end", ["N"], date=_days_before(1))]
|
|
agg = aggregate_lessons(docs, now=_NOW, half_life_days=0)
|
|
assert agg["contested"][0]["verdict"] == "even"
|
|
|
|
|
|
def test_negative_only_node_absent_from_sources():
|
|
"""A node seen only in dead_end docs never appears as a source bucket entry, but
|
|
its dead-end question still renders."""
|
|
agg = aggregate_lessons([_doc("dead_end", ["Bad"], question="why?")], now=_NOW)
|
|
names = [e["node"] for e in agg["preferred"] + agg["tentative"] + agg["contested"]]
|
|
assert "Bad" not in names
|
|
assert agg["dead_ends"][0]["nodes"] == ["Bad"]
|
|
|
|
|
|
def test_dead_ends_and_corrections_collected():
|
|
docs = [
|
|
_doc("dead_end", ["RedisClient"], question="where is the cache?"),
|
|
_doc("corrected", question="what hashes pw?", correction="bcrypt"),
|
|
]
|
|
agg = aggregate_lessons(docs)
|
|
assert agg["dead_ends"][0]["question"] == "where is the cache?"
|
|
assert agg["dead_ends"][0]["nodes"] == ["RedisClient"]
|
|
assert agg["corrections"][0]["correction"] == "bcrypt"
|
|
|
|
|
|
def test_dead_ends_and_corrections_follow_doc_order(tmp_path):
|
|
"""dead_ends/corrections are appended in doc order, so their determinism rides on
|
|
load_memory_docs' (date, filename) sort — assert that, not just their presence."""
|
|
mem = tmp_path / "memory"
|
|
_write_raw_doc(mem, "later.md", "2026-02-01", outcome="dead_end", question="second")
|
|
_write_raw_doc(mem, "earlier.md", "2026-01-01", outcome="dead_end", question="first")
|
|
agg = aggregate_lessons(load_memory_docs(mem))
|
|
assert [d["question"] for d in agg["dead_ends"]] == ["first", "second"]
|
|
|
|
|
|
def test_no_community_grouping_without_graph():
|
|
agg = aggregate_lessons([_doc("useful", ["A"])])
|
|
assert agg["by_community"] == {}
|
|
|
|
|
|
def test_doc_community_tie_breaks_to_smallest_label():
|
|
"""A doc whose source nodes split evenly across communities lands in the
|
|
lexicographically-smallest one — deterministically, regardless of node order."""
|
|
nc = {"x": "Zeta", "y": "Alpha"}
|
|
agg1 = aggregate_lessons([_doc("useful", ["x", "y"])], nc)
|
|
agg2 = aggregate_lessons([_doc("useful", ["y", "x"])], nc)
|
|
assert "Alpha" in agg1["by_community"] and "Zeta" not in agg1["by_community"]
|
|
assert agg1["by_community"].keys() == agg2["by_community"].keys()
|
|
|
|
|
|
def test_community_grouping_uses_plurality_community():
|
|
node_community = {"A": "Auth", "B": "Auth", "C": "Cache"}
|
|
docs = [
|
|
_doc("useful", ["A", "B", "C"]), # plurality Auth (2 vs 1)
|
|
_doc("dead_end", ["C"]), # Cache
|
|
_doc("useful", ["Z"]), # unknown node -> Uncategorized
|
|
]
|
|
agg = aggregate_lessons(docs, node_community)
|
|
assert set(agg["by_community"]) == {"Auth", "Cache", "Uncategorized"}
|
|
assert agg["by_community"]["Auth"]["counts"]["useful"] == 1
|
|
assert agg["by_community"]["Cache"]["counts"]["dead_end"] == 1
|
|
assert agg["by_community"]["Uncategorized"]["counts"]["useful"] == 1
|
|
|
|
|
|
# --- rendering -----------------------------------------------------------------
|
|
|
|
|
|
def test_render_is_deterministic():
|
|
docs = [_doc("useful", ["A", "B"]), _doc("dead_end", ["C"], question="dead?")]
|
|
agg = aggregate_lessons(docs)
|
|
assert render_lessons_md(agg) == render_lessons_md(agg)
|
|
|
|
|
|
def test_render_has_summary_and_sections():
|
|
docs = [
|
|
_doc("useful", ["AuthMiddleware"]),
|
|
_doc("dead_end", ["RedisClient"], question="where is the cache?"),
|
|
_doc("corrected", question="pw?", correction="bcrypt"),
|
|
]
|
|
md = render_lessons_md(aggregate_lessons(docs))
|
|
assert "# Lessons" in md
|
|
assert "1 useful · 1 dead ends · 1 corrected" in md
|
|
assert "`AuthMiddleware`" in md
|
|
assert "where is the cache?" in md
|
|
assert "bcrypt" in md
|
|
# No graph -> no per-topic section.
|
|
assert "## By topic" not in md
|
|
|
|
|
|
def test_render_includes_by_topic_when_graph_present():
|
|
node_community = {"A": "Auth"}
|
|
md = render_lessons_md(aggregate_lessons([_doc("useful", ["A"])], node_community))
|
|
assert "## By topic" in md
|
|
assert "### Auth" in md
|
|
|
|
|
|
def test_topic_sections_alpha_with_uncategorized_last():
|
|
"""Topic headers render alphabetically, with Uncategorized always last."""
|
|
nc = {"a": "Zeta", "b": "Alpha"}
|
|
docs = [_doc("useful", ["a"]), _doc("useful", ["b"]), _doc("useful", ["unknown"])]
|
|
md = render_lessons_md(aggregate_lessons(docs, nc))
|
|
headers = [line[4:] for line in md.splitlines() if line.startswith("### ")]
|
|
assert headers == ["Alpha", "Zeta", "Uncategorized"]
|
|
|
|
|
|
def test_render_byte_stable_across_independent_aggregations(tmp_path):
|
|
"""The headline guarantee: identical memory/ contents + same `now` -> byte-identical
|
|
output, built from scratch twice (not just render(agg)==render(agg))."""
|
|
mem = tmp_path / "memory"
|
|
_write_raw_doc(mem, "a.md", "2026-01-01", outcome="useful", nodes=["A", "B"])
|
|
_write_raw_doc(mem, "b.md", "2026-01-02", outcome="dead_end", question="dead?")
|
|
first = render_lessons_md(aggregate_lessons(load_memory_docs(mem), now=_NOW))
|
|
second = render_lessons_md(aggregate_lessons(load_memory_docs(mem), now=_NOW))
|
|
assert first == second
|
|
|
|
|
|
def test_contested_node_renders_once_under_contested():
|
|
"""A mixed-signal node appears in a single Contested line, not silently in both
|
|
a positive bucket and elsewhere."""
|
|
docs = [_doc("useful", ["N"]), _doc("dead_end", ["N"], question="bad?")]
|
|
md = render_lessons_md(aggregate_lessons(docs, now=_NOW))
|
|
assert "**Contested**" in md
|
|
# Exactly one rendered line carries the node as a contested source.
|
|
contested_lines = [l for l in md.splitlines()
|
|
if l.startswith("- `N` —") and "useful" in l and "dead end" in l]
|
|
assert len(contested_lines) == 1
|
|
|
|
|
|
def test_header_is_cautious():
|
|
"""The header nudges verification, not blind reuse."""
|
|
md = render_lessons_md(aggregate_lessons([_doc("useful", ["A"])], now=_NOW))
|
|
assert "verify before relying" in md
|
|
assert "reuse what worked" not in md
|
|
|
|
|
|
def test_lessons_artifact_cannot_be_globbed_back_into_memory(tmp_path):
|
|
"""Regression guard: the LESSONS.md output must never be re-ingested as a memory
|
|
doc. It has no frontmatter, so parse_memory_doc rejects it and load_memory_docs
|
|
skips it even if it lands inside memory/."""
|
|
md = render_lessons_md(aggregate_lessons([_doc("useful", ["A"])], now=_NOW))
|
|
assert parse_memory_doc(md) is None
|
|
mem = tmp_path / "memory"
|
|
mem.mkdir()
|
|
(mem / "LESSONS.md").write_text(md, encoding="utf-8")
|
|
save_query_result("real", "a", mem, outcome="useful")
|
|
docs = load_memory_docs(mem)
|
|
assert len(docs) == 1 and docs[0]["question"] == "real"
|
|
|
|
|
|
def test_render_empty_memory_is_graceful():
|
|
md = render_lessons_md(aggregate_lessons([], now=_NOW))
|
|
assert "from 0 session memories" in md
|
|
assert "_No marked outcomes yet._" in md
|
|
|
|
|
|
# --- orchestrator + CLI --------------------------------------------------------
|
|
|
|
|
|
def test_reflect_writes_lessons_file(tmp_path):
|
|
mem = tmp_path / "memory"
|
|
save_query_result("q1", "a1", mem, source_nodes=["A"], outcome="useful")
|
|
out_path, agg = reflect(mem, tmp_path / "reflections" / "LESSONS.md")
|
|
assert out_path.exists()
|
|
assert agg["total"] == 1
|
|
assert "`A`" in out_path.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_second_session_benefits_from_the_first(tmp_path):
|
|
"""The issue's worked example: session 1 records a win and a dead end; session 2
|
|
loads LESSONS.md and sees both."""
|
|
out = tmp_path / "graphify-out"
|
|
mem = out / "memory"
|
|
|
|
# Session 1: one useful answer, one dead end.
|
|
save_query_result(
|
|
"how does auth work?", "JWT in middleware", mem,
|
|
source_nodes=["AuthMiddleware"], outcome="useful",
|
|
)
|
|
save_query_result(
|
|
"where is the cache?", "looked at RedisClient, not it", mem,
|
|
source_nodes=["RedisClient"], outcome="dead_end",
|
|
)
|
|
|
|
# End of session 1 -> reflect.
|
|
lessons = out / "reflections" / "LESSONS.md"
|
|
reflect(mem, lessons)
|
|
|
|
# Session 2 loads the lessons doc.
|
|
body = lessons.read_text(encoding="utf-8")
|
|
assert "`AuthMiddleware`" in body # start here next time
|
|
assert "where is the cache?" in body # don't re-derive this dead end
|
|
|
|
|
|
def test_cli_reflect_end_to_end(tmp_path):
|
|
cwd = tmp_path
|
|
r1 = _run(["save-result", "--question", "how does auth work?",
|
|
"--answer", "JWT", "--nodes", "AuthMiddleware",
|
|
"--outcome", "useful"], cwd)
|
|
assert r1.returncode == 0, r1.stderr
|
|
r2 = _run(["reflect"], cwd)
|
|
assert r2.returncode == 0, r2.stderr
|
|
assert "Reflected 1 memories" in r2.stdout
|
|
lessons = cwd / "graphify-out" / "reflections" / "LESSONS.md"
|
|
assert lessons.exists()
|
|
assert "`AuthMiddleware`" in lessons.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_cli_save_result_rejects_bad_outcome(tmp_path):
|
|
"""argparse `choices` rejects an unknown outcome before save_query_result runs."""
|
|
r = _run(["save-result", "--question", "q", "--answer", "a",
|
|
"--outcome", "great"], tmp_path)
|
|
assert r.returncode != 0
|
|
assert "great" in (r.stderr + r.stdout)
|
|
|
|
|
|
def test_cli_save_result_reads_answer_from_file(tmp_path):
|
|
"""--answer-file lets callers pass a long/multiline answer via a file instead
|
|
of a fragile inline arg (Windows/PowerShell quoting), #1502."""
|
|
ans = tmp_path / "answer.txt"
|
|
ans.write_text("line one\nline two with a \"quote\"\n", encoding="utf-8")
|
|
r = _run(["save-result", "--question", "how does auth work?",
|
|
"--answer-file", str(ans), "--outcome", "useful"], tmp_path)
|
|
assert r.returncode == 0, r.stderr
|
|
docs = list((tmp_path / "graphify-out" / "memory").glob("*.md"))
|
|
assert docs, "save-result wrote no memory doc"
|
|
body = docs[0].read_text(encoding="utf-8")
|
|
assert "line one" in body and "line two" in body
|
|
|
|
|
|
def test_cli_save_result_requires_answer_or_answer_file(tmp_path):
|
|
"""Neither --answer nor --answer-file -> clean argparse error, not a crash."""
|
|
r = _run(["save-result", "--question", "q", "--outcome", "useful"], tmp_path)
|
|
assert r.returncode != 0
|
|
assert "--answer" in (r.stderr + r.stdout)
|
|
|
|
|
|
def test_cli_reflect_cold_start_writes_empty_lessons(tmp_path):
|
|
"""First run with no graphify-out/memory/ still succeeds and writes a valid doc."""
|
|
r = _run(["reflect"], tmp_path)
|
|
assert r.returncode == 0, r.stderr
|
|
assert "Reflected 0 memories" in r.stdout
|
|
lessons = tmp_path / "graphify-out" / "reflections" / "LESSONS.md"
|
|
assert lessons.exists()
|
|
assert "from 0 session memories" in lessons.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_cli_reflect_respects_out_flag(tmp_path):
|
|
cwd = tmp_path
|
|
_run(["save-result", "--question", "q", "--answer", "a",
|
|
"--outcome", "useful", "--nodes", "X"], cwd)
|
|
dest = cwd / "custom" / "lessons.md"
|
|
r = _run(["reflect", "--out", str(dest)], cwd)
|
|
assert r.returncode == 0, r.stderr
|
|
assert dest.exists()
|
|
|
|
|
|
def test_cli_reflect_groups_by_community_when_graph_present(tmp_path):
|
|
"""With a real graph.json present, reflect auto-detects it and groups lessons
|
|
under the community of the cited node — including when the node is cited by its
|
|
LABEL (what save-result records), not its id (regression guard: keying community
|
|
lookup on ids alone collapsed every lesson into Uncategorized)."""
|
|
out = _make_graph(tmp_path)
|
|
graph = json.loads((out / "graph.json").read_text())
|
|
node_label = graph["nodes"][0]["label"]
|
|
|
|
_run(["save-result", "--question", "q", "--answer", "a",
|
|
"--nodes", node_label, "--outcome", "useful"], tmp_path)
|
|
r = _run(["reflect"], tmp_path)
|
|
assert r.returncode == 0, r.stderr
|
|
body = (out / "reflections" / "LESSONS.md").read_text(encoding="utf-8")
|
|
assert "## By topic" in body
|
|
# The label-cited node must land in a real community, not Uncategorized.
|
|
assert "### Uncategorized" not in body
|
|
|
|
|
|
def test_cli_node_existence_gate_drops_stale_node_end_to_end(tmp_path):
|
|
"""Through reflect()/CLI with a real graph.json: a cited node that isn't in the
|
|
graph is dropped from LESSONS.md; a real one stays. Exercises _load_known_nodes
|
|
+ the wiring, not just the known_nodes param."""
|
|
out = _make_graph(tmp_path)
|
|
# Cite the node by its LABEL — what an agent/`save-result` actually records —
|
|
# not its id. The gate must match labels too, else every real citation is
|
|
# silently dropped whenever a graph is present (regression guard).
|
|
real = json.loads((out / "graph.json").read_text())["nodes"][0]["label"]
|
|
|
|
_run(["save-result", "--question", "q", "--answer", "a",
|
|
"--nodes", real, "GhostNode", "--outcome", "useful"], tmp_path)
|
|
r = _run(["reflect"], tmp_path)
|
|
assert r.returncode == 0, r.stderr
|
|
body = (out / "reflections" / "LESSONS.md").read_text(encoding="utf-8")
|
|
assert "GhostNode" not in body
|
|
assert f"`{real}`" in body
|
|
|
|
|
|
def _make_graph(tmp_path: Path) -> Path:
|
|
"""Build a minimal graph.json + analysis/labels in tmp_path/graphify-out/.
|
|
|
|
Mirrors tests/test_cli_export.py::_make_graph so reflect can be exercised with a
|
|
real community structure.
|
|
"""
|
|
out = tmp_path / "graphify-out"
|
|
out.mkdir()
|
|
extraction = json.loads((FIXTURES / "extraction.json").read_text())
|
|
from graphify.build import build_from_json
|
|
from graphify.cluster import cluster, score_all
|
|
from graphify.analyze import god_nodes, surprising_connections
|
|
from graphify.export import to_json
|
|
|
|
G = build_from_json(extraction)
|
|
communities = cluster(G)
|
|
cohesion = score_all(G, communities)
|
|
gods = god_nodes(G)
|
|
surprises = surprising_connections(G, communities)
|
|
to_json(G, communities, str(out / "graph.json"))
|
|
(out / ".graphify_analysis.json").write_text(json.dumps({
|
|
"communities": {str(k): v for k, v in communities.items()},
|
|
"cohesion": {str(k): v for k, v in cohesion.items()},
|
|
"gods": gods, "surprises": surprises,
|
|
}))
|
|
(out / ".graphify_labels.json").write_text(
|
|
json.dumps({str(cid): f"Community {cid}" for cid in communities})
|
|
)
|
|
return out
|
|
|
|
|
|
# --- lessons_fresh / `reflect --if-stale` -------------------------------------
|
|
|
|
def test_lessons_fresh_missing_output_is_not_fresh(tmp_path):
|
|
mem = tmp_path / "memory"; mem.mkdir()
|
|
(mem / "q.md").write_text("x", encoding="utf-8")
|
|
assert lessons_fresh(tmp_path / "LESSONS.md", mem) is False # must build
|
|
|
|
|
|
def test_lessons_fresh_true_when_output_newer_than_inputs(tmp_path):
|
|
import os
|
|
mem = tmp_path / "memory"; mem.mkdir()
|
|
doc = mem / "q.md"; doc.write_text("x", encoding="utf-8")
|
|
out = tmp_path / "LESSONS.md"; out.write_text("y", encoding="utf-8")
|
|
os.utime(doc, (1000, 1000))
|
|
os.utime(out, (2000, 2000))
|
|
assert lessons_fresh(out, mem) is True
|
|
|
|
|
|
def test_lessons_fresh_false_when_memory_newer(tmp_path):
|
|
import os
|
|
mem = tmp_path / "memory"; mem.mkdir()
|
|
doc = mem / "q.md"; doc.write_text("x", encoding="utf-8")
|
|
out = tmp_path / "LESSONS.md"; out.write_text("y", encoding="utf-8")
|
|
os.utime(out, (1000, 1000))
|
|
os.utime(doc, (2000, 2000)) # a new outcome was saved after the last reflect
|
|
assert lessons_fresh(out, mem) is False
|
|
|
|
|
|
def test_lessons_fresh_false_when_graph_newer(tmp_path):
|
|
import os
|
|
mem = tmp_path / "memory"; mem.mkdir()
|
|
(mem / "q.md").write_text("x", encoding="utf-8")
|
|
out = tmp_path / "LESSONS.md"; out.write_text("y", encoding="utf-8")
|
|
graph = tmp_path / "graph.json"; graph.write_text("{}", encoding="utf-8")
|
|
os.utime(mem / "q.md", (1000, 1000))
|
|
os.utime(out, (1500, 1500))
|
|
os.utime(graph, (2000, 2000)) # graph rebuilt since last reflect -> stale
|
|
assert lessons_fresh(out, mem, graph) is False
|
|
|
|
|
|
@pytest.mark.parametrize("sidecar_name", [".graphify_analysis.json", ".graphify_labels.json"])
|
|
def test_lessons_fresh_false_when_graph_sidecar_newer(tmp_path, sidecar_name):
|
|
import os
|
|
mem = tmp_path / "memory"; mem.mkdir()
|
|
(mem / "q.md").write_text("x", encoding="utf-8")
|
|
out = tmp_path / "LESSONS.md"; out.write_text("y", encoding="utf-8")
|
|
graph = tmp_path / "graph.json"; graph.write_text("{}", encoding="utf-8")
|
|
analysis = tmp_path / ".graphify_analysis.json"; analysis.write_text("{}", encoding="utf-8")
|
|
labels = tmp_path / ".graphify_labels.json"; labels.write_text("{}", encoding="utf-8")
|
|
for p in [mem / "q.md", graph, analysis, labels]:
|
|
os.utime(p, (1000, 1000))
|
|
os.utime(out, (1500, 1500))
|
|
os.utime(tmp_path / sidecar_name, (2000, 2000))
|
|
assert lessons_fresh(out, mem, graph, analysis, labels) is False
|
|
|
|
|
|
def test_cli_reflect_if_stale_skips_when_fresh(tmp_path):
|
|
"""`reflect --if-stale` skips the rebuild when LESSONS.md is already current,
|
|
and still runs when a new outcome arrives."""
|
|
out = _make_graph(tmp_path)
|
|
real = json.loads((out / "graph.json").read_text())["nodes"][0]["label"]
|
|
_run(["save-result", "--question", "q", "--answer", "a",
|
|
"--nodes", real, "--outcome", "useful"], tmp_path)
|
|
first = _run(["reflect"], tmp_path)
|
|
assert first.returncode == 0
|
|
lessons = out / "reflections" / "LESSONS.md"
|
|
body_before = lessons.read_text(encoding="utf-8")
|
|
|
|
# Second call with --if-stale: nothing changed -> skipped, file untouched.
|
|
skipped = _run(["reflect", "--if-stale"], tmp_path)
|
|
assert skipped.returncode == 0
|
|
assert "up to date" in (skipped.stdout + skipped.stderr).lower()
|
|
assert lessons.read_text(encoding="utf-8") == body_before
|
|
|
|
# A new outcome makes it stale -> --if-stale runs again.
|
|
_run(["save-result", "--question", "q2", "--answer", "a",
|
|
"--nodes", real, "--outcome", "useful"], tmp_path)
|
|
ran = _run(["reflect", "--if-stale"], tmp_path)
|
|
assert ran.returncode == 0
|
|
assert "up to date" not in (ran.stdout + ran.stderr).lower()
|
|
|
|
|
|
def test_cli_reflect_if_stale_reruns_when_labels_newer(tmp_path):
|
|
"""A label refresh changes LESSONS.md topic headings, so --if-stale must rebuild."""
|
|
out = _make_graph(tmp_path)
|
|
graph_data = json.loads((out / "graph.json").read_text())
|
|
node = graph_data["nodes"][0]
|
|
real = node["label"]
|
|
community = str(node["community"])
|
|
_run(["save-result", "--question", "q", "--answer", "a",
|
|
"--nodes", real, "--outcome", "useful"], tmp_path)
|
|
first = _run(["reflect"], tmp_path)
|
|
assert first.returncode == 0, first.stderr
|
|
|
|
lessons = out / "reflections" / "LESSONS.md"
|
|
labels_path = out / ".graphify_labels.json"
|
|
labels = json.loads(labels_path.read_text(encoding="utf-8"))
|
|
labels[community] = "Renamed Topic"
|
|
labels_path.write_text(json.dumps(labels), encoding="utf-8")
|
|
|
|
import os
|
|
os.utime(lessons, (1500, 1500))
|
|
os.utime(labels_path, (2000, 2000))
|
|
ran = _run(["reflect", "--if-stale"], tmp_path)
|
|
assert ran.returncode == 0, ran.stderr
|
|
assert "up to date" not in (ran.stdout + ran.stderr).lower()
|
|
assert "### Renamed Topic" in lessons.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_dead_ends_and_corrections_dedupe_by_question():
|
|
"""Saving the same Q&A more than once must not duplicate lines in the dead-ends
|
|
/ corrections lists; for a re-corrected question the most recent text wins."""
|
|
docs = [
|
|
_doc("dead_end", question="ws server?", date="2026-01-01"),
|
|
_doc("dead_end", question="ws server?", date="2026-01-02"), # duplicate
|
|
_doc("corrected", question="hash?", correction="SHA-1", date="2026-01-01"),
|
|
_doc("corrected", question="hash?", correction="SHA-256", date="2026-01-03"), # newer
|
|
]
|
|
agg = aggregate_lessons(docs, now=_NOW)
|
|
assert [d["question"] for d in agg["dead_ends"]] == ["ws server?"]
|
|
assert len(agg["corrections"]) == 1
|
|
assert agg["corrections"][0]["correction"] == "SHA-256" # recency wins
|
|
|
|
|
|
# --- work-memory overlay sidecar (.graphify_learning.json) --------------------
|
|
#
|
|
# The sidecar is a DERIVED experiential layer written next to graph.json; the
|
|
# durable structural truth in graph.json is never stamped with learning_* fields.
|
|
# It projects the reflect aggregate (preferred/tentative/contested) into a
|
|
# per-node-id map with a code fingerprint for staleness and a provenance trail.
|
|
|
|
from graphify.reflect import ( # noqa: E402
|
|
LEARNING_SIDECAR_NAME,
|
|
build_learning_overlay,
|
|
load_learning_overlay,
|
|
write_learning_sidecar,
|
|
)
|
|
|
|
|
|
def _overlay_graph(out: Path, nodes: list[dict]) -> None:
|
|
"""Write a minimal graph.json under ``out`` with the given node dicts."""
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
graph = {"directed": True, "multigraph": False, "graph": {},
|
|
"nodes": nodes, "links": []}
|
|
(out / "graph.json").write_text(json.dumps(graph), encoding="utf-8")
|
|
|
|
|
|
def _overlay_corpus(mem: Path) -> None:
|
|
"""A corpus with: a PREFERRED node (2 useful), a TENTATIVE node (1 useful),
|
|
a CONTESTED node (useful + dead_end), and a DEAD-END-ONLY node."""
|
|
_write_raw_doc(mem, "p1.md", "2026-05-01", outcome="useful",
|
|
question="how do I auth?", nodes=["login()"])
|
|
_write_raw_doc(mem, "p2.md", "2026-05-10", outcome="useful",
|
|
question="auth again", nodes=["login()"])
|
|
_write_raw_doc(mem, "t1.md", "2026-05-02", outcome="useful",
|
|
question="cache?", nodes=["RedisClient"])
|
|
_write_raw_doc(mem, "c1.md", "2026-05-03", outcome="useful",
|
|
question="contested useful", nodes=["Contested"])
|
|
_write_raw_doc(mem, "c2.md", "2026-05-04", outcome="dead_end",
|
|
question="contested dead", nodes=["Contested"])
|
|
_write_raw_doc(mem, "d1.md", "2026-05-05", outcome="dead_end",
|
|
question="led nowhere", nodes=["DeadEnd"])
|
|
|
|
|
|
def test_sidecar_write_classifies_and_keys_by_canonical_id(tmp_path):
|
|
"""reflect with a graph writes .graphify_learning.json next to graph.json with
|
|
the preferred/tentative/contested nodes keyed by canonical node id; the
|
|
dead-end-only node is NOT present; score/uses/provenance are carried."""
|
|
out = tmp_path / "graphify-out"
|
|
src = tmp_path / "auth.py"
|
|
src.write_text("def login(): pass\n", encoding="utf-8")
|
|
_overlay_graph(out, [
|
|
{"id": "auth_login", "label": "login()", "source_file": str(src), "community": 0},
|
|
{"id": "redis_client", "label": "RedisClient", "source_file": "", "community": 0},
|
|
{"id": "contested_node", "label": "Contested", "source_file": "", "community": 0},
|
|
{"id": "deadend_node", "label": "DeadEnd", "source_file": "", "community": 0},
|
|
])
|
|
mem = out / "memory"
|
|
_overlay_corpus(mem)
|
|
|
|
reflect(mem, out / "reflections" / "LESSONS.md",
|
|
graph_path=out / "graph.json", now=_NOW)
|
|
sidecar = json.loads((out / LEARNING_SIDECAR_NAME).read_text(encoding="utf-8"))
|
|
|
|
assert sidecar["version"] == 1
|
|
assert sidecar["generated_at"] == _NOW.isoformat()
|
|
nodes = sidecar["nodes"]
|
|
# Keyed by canonical node id, not label.
|
|
assert nodes["auth_login"]["status"] == "preferred"
|
|
assert nodes["auth_login"]["uses"] == 2
|
|
assert nodes["auth_login"]["label"] == "login()"
|
|
assert isinstance(nodes["auth_login"]["score"], float)
|
|
assert nodes["auth_login"]["provenance"] # captured during aggregation
|
|
assert nodes["redis_client"]["status"] == "tentative"
|
|
assert nodes["contested_node"]["status"] == "contested"
|
|
assert nodes["contested_node"]["verdict"] in ("useful", "dead end", "even")
|
|
# Dead-end-only node stays query-scoped — never in the overlay.
|
|
assert "deadend_node" not in nodes
|
|
# And learning_* is NOT stamped into graph.json (durable truth untouched).
|
|
graph = json.loads((out / "graph.json").read_text(encoding="utf-8"))
|
|
for n in graph["nodes"]:
|
|
assert not any(k.startswith("learning") for k in n)
|
|
|
|
|
|
def test_sidecar_is_byte_identical_across_runs(tmp_path):
|
|
"""Two reflect runs on identical input + fixed `now` produce a byte-identical
|
|
sidecar (sorted keys, stable indent)."""
|
|
out = tmp_path / "graphify-out"
|
|
src = tmp_path / "auth.py"
|
|
src.write_text("def login(): pass\n", encoding="utf-8")
|
|
_overlay_graph(out, [
|
|
{"id": "auth_login", "label": "login()", "source_file": str(src), "community": 0},
|
|
])
|
|
mem = out / "memory"
|
|
_write_raw_doc(mem, "a.md", "2026-05-01", outcome="useful", nodes=["login()"])
|
|
_write_raw_doc(mem, "b.md", "2026-05-10", outcome="useful", nodes=["login()"])
|
|
|
|
reflect(mem, out / "reflections" / "LESSONS.md",
|
|
graph_path=out / "graph.json", now=_NOW)
|
|
first = (out / LEARNING_SIDECAR_NAME).read_bytes()
|
|
reflect(mem, out / "reflections" / "LESSONS.md",
|
|
graph_path=out / "graph.json", now=_NOW)
|
|
second = (out / LEARNING_SIDECAR_NAME).read_bytes()
|
|
assert first == second
|
|
|
|
|
|
def test_loader_marks_entry_stale_when_source_file_changes(tmp_path):
|
|
"""load_learning_overlay recomputes the file fingerprint: unchanged source =>
|
|
stale=False; an edit to that source => stale=True."""
|
|
out = tmp_path / "graphify-out"
|
|
src = tmp_path / "auth.py"
|
|
src.write_text("def login(): pass\n", encoding="utf-8")
|
|
_overlay_graph(out, [
|
|
{"id": "auth_login", "label": "login()", "source_file": str(src), "community": 0},
|
|
])
|
|
mem = out / "memory"
|
|
_write_raw_doc(mem, "a.md", "2026-05-01", outcome="useful", nodes=["login()"])
|
|
_write_raw_doc(mem, "b.md", "2026-05-10", outcome="useful", nodes=["login()"])
|
|
reflect(mem, out / "reflections" / "LESSONS.md",
|
|
graph_path=out / "graph.json", now=_NOW)
|
|
|
|
fresh = load_learning_overlay(out / "graph.json")
|
|
assert fresh["auth_login"]["stale"] is False
|
|
|
|
src.write_text("def login(): return 1 # changed\n", encoding="utf-8")
|
|
after = load_learning_overlay(out / "graph.json")
|
|
assert after["auth_login"]["stale"] is True
|
|
|
|
|
|
def test_relative_source_file_not_spuriously_stale_in_graphify_out_layout(tmp_path):
|
|
"""Regression: with a RELATIVE source_file and graph.json under graphify-out/,
|
|
a freshly-written verdict must NOT be flagged stale. The fingerprint resolves
|
|
the file relative to the PROJECT root (tmp_path), not graph.json's own dir
|
|
(graphify-out/) — otherwise every node looked unfindable and was marked stale.
|
|
The edit case must still flip stale=True."""
|
|
out = tmp_path / "graphify-out" # graph.json lives here
|
|
(tmp_path / "auth.py").write_text("def login(): pass\n", encoding="utf-8")
|
|
_overlay_graph(out, [
|
|
# source_file is RELATIVE to the project root (tmp_path), as `extract` writes it
|
|
{"id": "auth_login", "label": "login()", "source_file": "auth.py", "community": 0},
|
|
])
|
|
mem = out / "memory"
|
|
_write_raw_doc(mem, "a.md", "2026-05-01", outcome="useful", nodes=["login()"])
|
|
_write_raw_doc(mem, "b.md", "2026-05-10", outcome="useful", nodes=["login()"])
|
|
reflect(mem, out / "reflections" / "LESSONS.md",
|
|
graph_path=out / "graph.json", now=_NOW)
|
|
|
|
fresh = load_learning_overlay(out / "graph.json")
|
|
assert fresh["auth_login"]["status"] == "preferred"
|
|
assert fresh["auth_login"]["stale"] is False # the bug: was spuriously True
|
|
|
|
(tmp_path / "auth.py").write_text("def login(): return 1 # changed\n", encoding="utf-8")
|
|
assert load_learning_overlay(out / "graph.json")["auth_login"]["stale"] is True
|
|
|
|
|
|
def test_relative_source_file_resolved_via_graphify_root_marker(tmp_path):
|
|
"""When a committed .graphify_root marker records the project root (e.g. a
|
|
GRAPHIFY_OUT override pointing the output dir elsewhere), the fingerprint
|
|
resolves source_file against that root, not graph.json's own dir."""
|
|
proj = tmp_path / "project"
|
|
proj.mkdir()
|
|
(proj / "auth.py").write_text("def login(): pass\n", encoding="utf-8")
|
|
out = tmp_path / "elsewhere-out" # output dir NOT under the project
|
|
_overlay_graph(out, [
|
|
{"id": "auth_login", "label": "login()", "source_file": "auth.py", "community": 0},
|
|
])
|
|
(out / ".graphify_root").write_text(str(proj), encoding="utf-8") # the marker
|
|
mem = out / "memory"
|
|
_write_raw_doc(mem, "a.md", "2026-05-01", outcome="useful", nodes=["login()"])
|
|
_write_raw_doc(mem, "b.md", "2026-05-10", outcome="useful", nodes=["login()"])
|
|
reflect(mem, out / "reflections" / "LESSONS.md",
|
|
graph_path=out / "graph.json", now=_NOW)
|
|
assert load_learning_overlay(out / "graph.json")["auth_login"]["stale"] is False
|
|
|
|
|
|
def test_flat_layout_does_not_match_same_named_file_one_dir_up(tmp_path):
|
|
"""In a flat layout (graph.json at the project root), the resolver must use the
|
|
graph's own dir, not its parent — otherwise a same-named file one level up
|
|
would be fingerprinted instead, producing a wrong staleness verdict."""
|
|
proj = tmp_path / "proj"
|
|
proj.mkdir()
|
|
(proj / "util.py").write_text("REAL = 1\n", encoding="utf-8")
|
|
# A decoy same-named file in the parent dir (tmp_path / util.py).
|
|
(tmp_path / "util.py").write_text("DECOY = 2\n", encoding="utf-8")
|
|
# Flat layout: graph.json sits directly in proj/ (not a graphify-out subdir).
|
|
proj.joinpath("graph.json").write_text(json.dumps({
|
|
"nodes": [{"id": "util", "label": "util.py", "source_file": "util.py",
|
|
"source_location": "L1", "community": 0}],
|
|
"links": [],
|
|
}), encoding="utf-8")
|
|
mem = proj / "memory"
|
|
_write_raw_doc(mem, "a.md", "2026-05-01", outcome="useful", nodes=["util.py"])
|
|
_write_raw_doc(mem, "b.md", "2026-05-10", outcome="useful", nodes=["util.py"])
|
|
reflect(mem, proj / "reflections" / "LESSONS.md",
|
|
graph_path=proj / "graph.json", now=_NOW)
|
|
# Not stale on a clean build...
|
|
assert load_learning_overlay(proj / "graph.json")["util"]["stale"] is False
|
|
# ...and editing the REAL file (proj/util.py) flips it, while editing the
|
|
# decoy (parent) does not — proving the resolver bound to the right file.
|
|
(tmp_path / "util.py").write_text("DECOY = 999\n", encoding="utf-8")
|
|
assert load_learning_overlay(proj / "graph.json")["util"]["stale"] is False
|
|
(proj / "util.py").write_text("REAL = 999\n", encoding="utf-8")
|
|
assert load_learning_overlay(proj / "graph.json")["util"]["stale"] is True
|
|
|
|
|
|
def test_provenance_capped_to_five_most_recent(tmp_path):
|
|
"""A node cited by >5 useful results keeps exactly the 5 most-recent in
|
|
provenance (recent-first)."""
|
|
out = tmp_path / "graphify-out"
|
|
src = tmp_path / "auth.py"
|
|
src.write_text("x\n", encoding="utf-8")
|
|
_overlay_graph(out, [
|
|
{"id": "auth_login", "label": "login()", "source_file": str(src), "community": 0},
|
|
])
|
|
mem = out / "memory"
|
|
for i in range(7):
|
|
_write_raw_doc(mem, f"u{i}.md", f"2026-05-{10 + i:02d}",
|
|
outcome="useful", question=f"q{i}", nodes=["login()"])
|
|
reflect(mem, out / "reflections" / "LESSONS.md",
|
|
graph_path=out / "graph.json", now=_NOW)
|
|
sidecar = json.loads((out / LEARNING_SIDECAR_NAME).read_text(encoding="utf-8"))
|
|
prov = sidecar["nodes"]["auth_login"]["provenance"]
|
|
assert len(prov) == 5
|
|
# Most-recent first.
|
|
assert prov[0]["date"] == "2026-05-16"
|
|
assert prov[-1]["date"] == "2026-05-12"
|
|
|
|
|
|
def test_ambiguous_or_unresolved_citation_is_skipped(tmp_path):
|
|
"""A label shared by >1 node id (ambiguous) or absent from the graph
|
|
(unresolved) is skipped — it can't be displayed against a single node."""
|
|
out = tmp_path / "graphify-out"
|
|
_overlay_graph(out, [
|
|
{"id": "dup_a", "label": "Dup", "source_file": "", "community": 0},
|
|
{"id": "dup_b", "label": "Dup", "source_file": "", "community": 0},
|
|
{"id": "solo", "label": "Solo", "source_file": "", "community": 0},
|
|
])
|
|
mem = out / "memory"
|
|
_write_raw_doc(mem, "a.md", "2026-05-01", outcome="useful", nodes=["Dup"])
|
|
_write_raw_doc(mem, "b.md", "2026-05-02", outcome="useful", nodes=["Dup"])
|
|
_write_raw_doc(mem, "c.md", "2026-05-03", outcome="useful", nodes=["Solo"])
|
|
_write_raw_doc(mem, "d.md", "2026-05-04", outcome="useful", nodes=["Solo"])
|
|
reflect(mem, out / "reflections" / "LESSONS.md",
|
|
graph_path=out / "graph.json", now=_NOW)
|
|
nodes = json.loads((out / LEARNING_SIDECAR_NAME).read_text(encoding="utf-8"))["nodes"]
|
|
# Ambiguous "Dup" skipped; only the unambiguous "Solo" survives.
|
|
assert "dup_a" not in nodes and "dup_b" not in nodes
|
|
assert "solo" in nodes
|