Version: 0.9.29
Affects: watch / update / the post-commit + post-checkout hooks, on any repo where graphify label has been run.
Summary
After graphify label, every rebuild revalidates each reused label against a membership fingerprint:
# cluster.py:113 — sha256 over the exact sorted member id list
sigs[cid] = h.hexdigest()[:16]
# watch.py:1327
stale = {cid for cid in labels if saved_sigs.get(cid) != cur_sigs.get(cid)}
The intent is right and the comment says so plainly — reusing a label on a community that has become a different community is the stale-label bug this guards against, and hub-naming is correct-by-construction as a fallback.
But the test is exact set equality. A community that gains or loses a single node fails it. Since remap_communities_to_previous has already re-aligned cids by node overlap, the comparison is between the same conceptual community's old and new membership — so a 200-node community that absorbs one new symbol is judged "different" and its LLM name is replaced by its hub filename.
Any code edit reshuffles some memberships, so labels decay on ordinary work.
Measurement
Two independent observations on a 5.8k-node repo, right after a full graphify label --backend claude-cli run that produced plain-language names for every community:
| trigger |
communities named in plain language |
lost to hub filenames |
git checkout -b <new-branch> |
491 → 410 |
81 (16%) |
git checkout main |
576 → 488 |
87 (15%) |
No code was edited between labelling and the checkout in either case — switching branches was enough. The names lost are the useful ones (Alert Engine & Jobs, Location Hours Engine); what replaces them is Summary.tsx, payment/repo.ts, wire.ts.
The practical consequence is that label cannot be treated as a durable step. On this repo it costs ~15 LLM calls to restore, and the next branch switch undoes ~15% of it again, so GRAPH_REPORT.md drifts toward filenames between runs.
Why "just re-run label" is not the answer
Re-running is not idempotent-cheap: it re-clusters, and on a rate-limited backend a partial run is actively harmful — batches that fail fall back to hub names and are written, so an interrupted refresh can leave the graph worse than before it started. (Observed here: a retry that hit a provider daily cap at batch 12 of 20 turned 63 filename-names into 263. Recovered from the <date>/ backup graphify writes.) That is arguably its own issue; it matters here because it removes the obvious workaround.
Suggested fix
Invalidate on substantial change rather than any change. A community whose membership is 99% the same is the same community and its name still describes it; one that shares 20% is not.
_minhash.py already ships MinHash/MinHashLSH in this codebase, so the machinery exists:
- Persist a MinHash (or the sorted member list) per community in the
.sig sidecar instead of a truncated sha256, and invalidate when Jaccard similarity to the saved set falls below a threshold (0.7 seems a reasonable default — happy to be told otherwise).
- Keep the current exact-hash path as the fast accept: identical signature → reuse, no similarity computation.
- Sidecar format change needs a version marker; a sidecar in the old format can fall back to today's exact-equality behaviour, so existing installs degrade to current behaviour rather than breaking.
A cheaper variant if changing the sidecar is unwelcome: store the member count alongside the hash and treat a delta under N% as non-stale. Weaker (it cannot tell a swap from a no-op) but a one-line sidecar change.
I have a checkout with tests set up from #2294 and can implement whichever direction you prefer — I would rather not guess at the threshold policy in a PR, since it is a judgement call about how much drift makes a name wrong.
Related: #2073 (placeholder perpetuation), #1027 (cid misalignment), #822 (overlap remap).
Version: 0.9.29
Affects:
watch/update/ thepost-commit+post-checkouthooks, on any repo wheregraphify labelhas been run.Summary
After
graphify label, every rebuild revalidates each reused label against a membership fingerprint:The intent is right and the comment says so plainly — reusing a label on a community that has become a different community is the stale-label bug this guards against, and hub-naming is
correct-by-constructionas a fallback.But the test is exact set equality. A community that gains or loses a single node fails it. Since
remap_communities_to_previoushas already re-aligned cids by node overlap, the comparison is between the same conceptual community's old and new membership — so a 200-node community that absorbs one new symbol is judged "different" and its LLM name is replaced by its hub filename.Any code edit reshuffles some memberships, so labels decay on ordinary work.
Measurement
Two independent observations on a 5.8k-node repo, right after a full
graphify label --backend claude-clirun that produced plain-language names for every community:git checkout -b <new-branch>git checkout mainNo code was edited between labelling and the checkout in either case — switching branches was enough. The names lost are the useful ones (
Alert Engine & Jobs,Location Hours Engine); what replaces them isSummary.tsx,payment/repo.ts,wire.ts.The practical consequence is that
labelcannot be treated as a durable step. On this repo it costs ~15 LLM calls to restore, and the next branch switch undoes ~15% of it again, soGRAPH_REPORT.mddrifts toward filenames between runs.Why "just re-run label" is not the answer
Re-running is not idempotent-cheap: it re-clusters, and on a rate-limited backend a partial run is actively harmful — batches that fail fall back to hub names and are written, so an interrupted refresh can leave the graph worse than before it started. (Observed here: a retry that hit a provider daily cap at batch 12 of 20 turned 63 filename-names into 263. Recovered from the
<date>/backup graphify writes.) That is arguably its own issue; it matters here because it removes the obvious workaround.Suggested fix
Invalidate on substantial change rather than any change. A community whose membership is 99% the same is the same community and its name still describes it; one that shares 20% is not.
_minhash.pyalready shipsMinHash/MinHashLSHin this codebase, so the machinery exists:.sigsidecar instead of a truncated sha256, and invalidate when Jaccard similarity to the saved set falls below a threshold (0.7 seems a reasonable default — happy to be told otherwise).A cheaper variant if changing the sidecar is unwelcome: store the member count alongside the hash and treat a delta under N% as non-stale. Weaker (it cannot tell a swap from a no-op) but a one-line sidecar change.
I have a checkout with tests set up from #2294 and can implement whichever direction you prefer — I would rather not guess at the threshold policy in a PR, since it is a judgement call about how much drift makes a name wrong.
Related: #2073 (placeholder perpetuation), #1027 (cid misalignment), #822 (overlap remap).