From a54a542b6c1d5b95e2691ecb3ffe4663033473a9 Mon Sep 17 00:00:00 2001 From: Aleksandr Tarutin Date: Wed, 27 May 2026 07:20:31 -0400 Subject: [PATCH] fix: make graph output deterministic (stop graphify-out churn) (#1010) graphify-out regenerates differently on every `graphify update` even when no source changed, so the committed graph is perpetually dirty and the post-commit/post-checkout hooks fight every commit. Two independent nondeterminism sources, each fixed here: 1. Edge direction flips. build.py builds an undirected graph and stores direction in _src/_tgt; collapsing two edges onto the same node pair is last-write-wins, and unstable edge iteration order flips them run-to-run. Fixed by sorting edges by (source, target, relation) before the add loop. 2. Clustering churn. The networkx Louvain fallback iterates string-keyed sets whose order is randomized per-process by PYTHONHASHSEED, so community assignments differ run-to-run even with seed=42. Fixed by exporting PYTHONHASHSEED=0 in the generated post-commit and post-checkout hook scripts. With both fixes, `graphify update` is idempotent: rebuilding an already-converged graphify-out reproduces graph.json and GRAPH_REPORT.md byte-for-byte. Co-authored-by: Claude Opus 4.7 --- graphify/build.py | 13 ++++++++++++- graphify/hooks.py | 10 ++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/graphify/build.py b/graphify/build.py index f180419d..a4d33c5e 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -160,7 +160,18 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat # slightly different casing or punctuation than the AST extractor. # e.g. "Session_ValidateToken" maps to "session_validatetoken". norm_to_id: dict[str, str] = {_normalize_id(nid): nid for nid in node_set} - for edge in extraction.get("edges", []): + # Iterate edges in a deterministic order. The graph is undirected and stores + # direction in _src/_tgt; when two edges collapse onto the same node pair the + # last write wins, so an unstable iteration order flips _src/_tgt run-to-run + # and makes the serialized graph churn. Sorting fixes the last-write outcome. + for edge in sorted( + extraction.get("edges", []), + key=lambda e: ( + str(e.get("source", e.get("from", ""))), + str(e.get("target", e.get("to", ""))), + str(e.get("relation", "")), + ), + ): if "source" not in edge and "from" in edge: edge["source"] = edge["from"] if "target" not in edge and "to" in edge: diff --git a/graphify/hooks.py b/graphify/hooks.py index 4792319d..9746737f 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -48,6 +48,11 @@ _HOOK_SCRIPT = """\ # Auto-rebuilds the knowledge graph after each commit (code files only, no LLM needed). # Installed by: graphify hook install +# Deterministic clustering: networkx louvain iterates string-keyed sets whose +# order is randomized per-process by PYTHONHASHSEED, so community assignments +# churn run-to-run. Pinning it makes graphify-out reproducible. +export PYTHONHASHSEED=0 + # Skip during rebase/merge/cherry-pick to avoid blocking --continue with unstaged changes GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) [ -d "$GIT_DIR/rebase-merge" ] && exit 0 @@ -106,6 +111,11 @@ _CHECKOUT_SCRIPT = """\ # Auto-rebuilds the knowledge graph (code only) when switching branches. # Installed by: graphify hook install +# Deterministic clustering: networkx louvain iterates string-keyed sets whose +# order is randomized per-process by PYTHONHASHSEED, so community assignments +# churn run-to-run. Pinning it makes graphify-out reproducible. +export PYTHONHASHSEED=0 + PREV_HEAD=$1 NEW_HEAD=$2 BRANCH_SWITCH=$3