From 1a14e94e53466a357c74bc35486d455f0d0afad7 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Wed, 24 Jun 2026 12:48:57 +0100 Subject: [PATCH] fix(reflect): dedupe dead-ends and corrections by question Saving the same Q&A more than once duplicated lines in the "known dead ends" and "corrections" sections: both lists were appended per memory doc with no key, while node scoring already dedups by node. They now collapse by question, keeping the most recent entry (docs are processed oldest-first, so a re-corrected question shows its latest correction). Output stays deterministic, ordered by (date, question). Applied to both the flat lists and the per-community buckets. Found by a user running it on a 104-file Go codebase. Added a regression test covering the dedupe and the recency-wins correction. Full suite 2389 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + graphify/reflect.py | 21 ++++++++++++++++++--- tests/test_reflect.py | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 480e0ab..f5cdde7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## Unreleased +- Fix: `graphify reflect` no longer duplicates lines in the "known dead ends" and "corrections" sections when the same Q&A is saved more than once. Those lists were appended per memory doc with no key (node scoring already dedups by node, but these two did not); they now collapse by question, keeping the most recent entry — so a re-corrected question shows its latest correction. Output stays deterministic (ordered by date then question). - Fix: the work-memory loop no longer depends on the git hook. The skill now tells the agent to run `graphify reflect --if-stale` itself at the start of graph work (cheap, deterministic, a no-op when no outcomes have been saved), then read `LESSONS.md`. Previously a skill-only install (without `graphify hook install`) would keep recording outcomes via `save-result` but never regenerate `LESSONS.md`, so the lessons never surfaced. The post-commit hook is now an optimization for between-session freshness rather than a requirement. The new `--if-stale` flag skips the run when `LESSONS.md` is already newer than every input (the memory docs and the graph), so when the hook just refreshed it the agent's session-start run costs almost nothing. ## 0.8.47 (2026-06-24) diff --git a/graphify/reflect.py b/graphify/reflect.py index b5498b4..87a7482 100644 --- a/graphify/reflect.py +++ b/graphify/reflect.py @@ -328,6 +328,20 @@ def _finalize_sources(bucket: dict[str, Any], return {"preferred": preferred, "tentative": tentative, "contested": contested} +def _dedupe_by_question(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Collapse repeated questions to one entry. Docs are processed oldest-first, so + the last write per question wins (recency — e.g. the most recent correction text). + Output is deterministically ordered by (date, question). Without this, saving the + same Q&A twice duplicated lines in the dead-ends / corrections lists, even though + node scoring already dedups by node. + """ + latest: dict[str, dict[str, Any]] = {} + for it in items: + latest[it.get("question", "")] = it + return sorted(latest.values(), + key=lambda it: (it.get("date", ""), it.get("question", ""))) + + def aggregate_lessons(docs: list[dict[str, Any]], node_community: dict[str, str] | None = None, *, @@ -384,7 +398,8 @@ def aggregate_lessons(docs: list[dict[str, Any]], if node_community: community_out = { label: {"counts": b["counts"], **_finalize_sources(b, min_corroboration), - "dead_ends": b["dead_ends"], "corrections": b["corrections"]} + "dead_ends": _dedupe_by_question(b["dead_ends"]), + "corrections": _dedupe_by_question(b["corrections"])} for label, b in by_community.items() } @@ -393,8 +408,8 @@ def aggregate_lessons(docs: list[dict[str, Any]], "counts": overall["counts"], "min_corroboration": min_corroboration, **_finalize_sources(overall, min_corroboration), - "dead_ends": overall["dead_ends"], - "corrections": overall["corrections"], + "dead_ends": _dedupe_by_question(overall["dead_ends"]), + "corrections": _dedupe_by_question(overall["corrections"]), "by_community": community_out, } diff --git a/tests/test_reflect.py b/tests/test_reflect.py index 8cd581d..8775535 100644 --- a/tests/test_reflect.py +++ b/tests/test_reflect.py @@ -631,3 +631,18 @@ def test_cli_reflect_if_stale_skips_when_fresh(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_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