feat(extract): add --no-dedup so incremental merges can skip fuzzy dedup (#2881)

Adds --no-dedup (default off, so dedup stays on) to skip the fuzzy near-duplicate merge
pass on build and incremental merge, for operators who would rather keep distinct symbols
that fuzzy-matched than pay the merge. Exact-id uniqueness is unaffected (it is a graph
structural invariant, not a dedup responsibility), and the flag arms the #479 shrink
guard so a surprising node drop is refused loudly. Mutually exclusive with --dedup-llm.
This commit is contained in:
rajarshidattapy
2026-08-20 16:10:49 +01:00
committed by safishamsi
parent 05f90f1682
commit eccc6cf0bf
3 changed files with 167 additions and 10 deletions
+1
View File
@@ -749,6 +749,7 @@ graphify extract ./docs --no-cluster # raw extraction only, skip clust
graphify extract ./docs --timing # print per-stage wall-clock timings to stderr (also works on cluster-only)
graphify extract ./docs --force # overwrite graph.json even if new graph has fewer nodes (use after refactors or to clear ghost duplicates)
graphify extract ./docs --dedup-llm # LLM tiebreaker for ambiguous entity pairs (uses same API key)
graphify extract ./src --no-dedup # skip entity dedup; on an incremental merge this also arms the shrink guard that refuses to drop untouched files' nodes
graphify extract ./docs --global --as myrepo # extract and register into the cross-project global graph
GRAPHIFY_MAX_OUTPUT_TOKENS=32768 graphify extract ./docs --backend claude # raise output cap for dense corpora
+37 -10
View File
@@ -2889,7 +2889,7 @@ def dispatch_command(cmd: str) -> None:
print(
"Usage: graphify extract <path> [--backend gemini|kimi|claude|openai|deepseek|ollama] "
"[--model M] [--mode deep] [--out DIR|--output DIR] [--google-workspace] [--no-cluster] "
"[--no-gitignore] [--code-only] "
"[--no-gitignore] [--code-only] [--no-dedup] "
"[--max-workers N] [--token-budget N] [--max-concurrency N] "
"[--api-timeout S] [--postgres DSN] [--cargo] [--allow-partial] [--timing]",
file=sys.stderr,
@@ -2915,6 +2915,13 @@ def dispatch_command(cmd: str) -> None:
cli_allow_partial: bool = False
no_cluster = False
dedup_llm = False
# --no-dedup: skip entity deduplication entirely. On an incremental
# merge the fuzzy pass runs over the COMBINED node set (existing graph +
# new chunk), so a small diff merged into a large graph can collapse
# pre-existing nodes from files the diff never touched. Turning dedup
# off also arms build_merge's #479 shrink guard, which is disabled while
# dedup is on because fuzzy merging shrinks the graph legitimately (#2881).
no_dedup = False
google_workspace = False
global_merge = False
code_only = False
@@ -2983,6 +2990,8 @@ def dispatch_command(cmd: str) -> None:
no_cluster = True; i += 1
elif a == "--dedup-llm":
dedup_llm = True; i += 1
elif a == "--no-dedup":
no_dedup = True; i += 1
elif a == "--code-only":
code_only = True; i += 1
elif a == "--google-workspace":
@@ -3041,6 +3050,16 @@ def dispatch_command(cmd: str) -> None:
print("error: must specify a path to scan or a --postgres DSN", file=sys.stderr)
sys.exit(1)
if no_dedup and dedup_llm:
# --dedup-llm is pass 3 of the dedup pipeline, so with dedup off it
# would be a silent no-op that still demands an API key.
print(
"error: --no-dedup and --dedup-llm are mutually exclusive "
"(--dedup-llm is a tiebreaker inside the dedup pass)",
file=sys.stderr,
)
sys.exit(2)
_VALID_MODES = {"deep"}
if extract_mode is not None and extract_mode not in _VALID_MODES:
print(
@@ -3946,16 +3965,24 @@ def dispatch_command(cmd: str) -> None:
for _src in list(excluded_files) + graph_stale_sources:
if _src not in _prune_sources:
_prune_sources.append(_src)
G = _build_merge(
[merged],
graph_path=existing_graph_path,
prune_sources=_prune_sources or None,
dedup=True,
dedup_llm_backend=dedup_backend,
root=target,
)
try:
G = _build_merge(
[merged],
graph_path=existing_graph_path,
prune_sources=_prune_sources or None,
dedup=not no_dedup,
dedup_llm_backend=dedup_backend,
root=target,
)
except ValueError as exc:
# --no-dedup arms build_merge's #479 shrink guard, which refuses
# to drop nodes belonging to files this run neither re-extracted
# nor pruned. Report the refusal instead of a traceback (#2881):
# graph.json on disk is untouched, so the old graph is intact.
print(f"[graphify extract] {exc}", file=sys.stderr)
sys.exit(1)
else:
G = _build([merged], dedup=True, dedup_llm_backend=dedup_backend, root=target)
G = _build([merged], dedup=not no_dedup, dedup_llm_backend=dedup_backend, root=target)
stages.mark("build")
if G.number_of_nodes() == 0:
print(
+129
View File
@@ -0,0 +1,129 @@
"""`graphify extract --no-dedup` (#2881).
The incremental merge path hardcoded `dedup=True`, so fuzzy dedup always ran
over the COMBINED node set (existing graph + new chunk). On a large graph a
small diff could therefore collapse pre-existing nodes belonging to files the
diff never touched, and the #479 shrink guard — the one thing that would have
caught it — is deliberately skipped while dedup is on, because fuzzy merging
shrinks the graph legitimately. There was no way to opt out from the CLI.
"""
from __future__ import annotations
import graphify.__main__ as mainmod
def _corpus(tmp_path):
corpus = tmp_path / "corpus"
corpus.mkdir()
(corpus / "main.go").write_text("package main\nfunc main() {}\n")
return corpus
def _run(monkeypatch, argv):
"""Run the CLI and return its exit code (0 when main() simply returns)."""
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(mainmod.sys, "argv", argv)
try:
mainmod.main()
except SystemExit as exc:
return exc.code or 0
return 0
def _capture_dedup(monkeypatch):
"""Record the `dedup` kwarg both build entry points are called with.
Patching `graphify.build` does reach the CLI's `_build` / `_build_merge`
aliases, even though it refers to them by those names: the
from graphify.build import build as _build, build_merge as _build_merge
is function-local to `dispatch_command`, so the alias is bound when the
command runs, which is after this patch is installed. A module-level import
would bind at import time and make the patch inert — `_assert_spied` below
turns that into a loud failure rather than a test that quietly asserts
nothing.
"""
import graphify.build as buildmod
seen: dict[str, bool] = {}
real_build = buildmod.build
real_merge = buildmod.build_merge
def fake_build(chunks, *a, **kw):
seen["build"] = kw.get("dedup", True)
return real_build(chunks, *a, **kw)
def fake_merge(chunks, *a, **kw):
seen["build_merge"] = kw.get("dedup", True)
return real_merge(chunks, *a, **kw)
monkeypatch.setattr(buildmod, "build", fake_build)
monkeypatch.setattr(buildmod, "build_merge", fake_merge)
return seen
def _assert_spied(seen: dict, entry_point: str) -> None:
"""Fail loudly if the spy never fired, so no assertion is vacuous."""
assert entry_point in seen, (
f"{entry_point}() was never called through the patched "
f"graphify.build symbol — the spy is inert and every dedup assertion "
f"below it would be vacuous. Did the CLI's import of it move to module "
f"scope, or did this run take a path that skips the build stage?"
)
def test_no_dedup_flag_disables_dedup(monkeypatch, tmp_path):
corpus = _corpus(tmp_path)
seen = _capture_dedup(monkeypatch)
code = _run(monkeypatch, [
"graphify", "extract", str(corpus), "--code-only",
"--no-dedup", "--out", str(tmp_path / "out"),
])
assert code == 0
_assert_spied(seen, "build")
assert seen["build"] is False
def test_dedup_is_on_by_default(monkeypatch, tmp_path):
corpus = _corpus(tmp_path)
seen = _capture_dedup(monkeypatch)
code = _run(monkeypatch, [
"graphify", "extract", str(corpus), "--code-only",
"--out", str(tmp_path / "out"),
])
assert code == 0
_assert_spied(seen, "build")
assert seen["build"] is True
def test_no_dedup_reaches_the_incremental_merge(monkeypatch, tmp_path):
corpus = _corpus(tmp_path)
out = tmp_path / "out"
# First run establishes graph.json, so the second run takes the
# build_merge (incremental) path rather than build().
assert _run(monkeypatch, [
"graphify", "extract", str(corpus), "--code-only",
"--out", str(out),
]) == 0
(corpus / "other.go").write_text("package main\nfunc other() {}\n")
seen = _capture_dedup(monkeypatch)
assert _run(monkeypatch, [
"graphify", "extract", str(corpus), "--code-only",
"--no-dedup", "--out", str(out),
]) == 0
_assert_spied(seen, "build_merge")
assert seen["build_merge"] is False, (
"the incremental path hardcoded dedup=True, which is the bug"
)
def test_no_dedup_conflicts_with_dedup_llm(monkeypatch, tmp_path, capsys):
corpus = _corpus(tmp_path)
code = _run(monkeypatch, [
"graphify", "extract", str(corpus), "--code-only",
"--no-dedup", "--dedup-llm", "--out", str(tmp_path / "out"),
])
assert code == 2
assert "mutually exclusive" in capsys.readouterr().err