Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
- Feat: `get_neighbors` and `get_community` (MCP) now honor a `token_budget` (default 2000) instead of rendering unbounded, so one call on a god node or large community can't flood the client's context (#2069, thanks @ojmucianski). Truncation is announced at the top of the output (matching `query`), with a line count and a narrowing hint.
- Docs: `--code-only` is now surfaced in the `extract` usage text and README (#2071, thanks @HerenderKumar); documented as an `extract` flag rather than a `/graphify` skill flag.
- Docs: README troubleshooting note for an older `graphifyy` in system site-packages shadowing `uv run --with graphifyy`, which silently runs the old version (#1540, thanks @HerenderKumar).
- Fix: same-ID node dedup now gap-fills the survivor from the loser instead of discarding the loser's dict wholesale (#2091). On the default `dedup=True` path the pre-dedup collapse kept one whole node and dropped the other, so in the AST↔semantic reconciliation case (where `_semantic_id_remap` forces the AST node and the LLM node onto one ID) the LLM's `summary`/`confidence_score`/`references` were silently lost even though the docstring promised a key-wise union. The survivor still wins every genuine key conflict (label, source_file), so `_collision_rank` ordering and the collision reporting are unchanged; only attributes the survivor lacked are now carried over.

## 0.9.23 (2026-07-21)

Expand Down
17 changes: 14 additions & 3 deletions graphify/dedup.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,14 +326,25 @@ def deduplicate_entities(
continue
incumbent = seen_ids.get(nid)
if incumbent is None:
seen_ids[nid] = node
seen_ids[nid] = dict(node)
elif _collision_rank(node) < _collision_rank(incumbent):
# Smallest-ranked node wins; the min over a total order is independent
# of the order nodes arrive in, so the survivor no longer depends on
# chunk ordering (#1851).
seen_ids[nid] = node
# chunk ordering (#1851). Gap-fill from the loser so a same-ID merge
# keeps attributes the survivor lacks (summary, confidence_score,
# references) rather than discarding the loser's dict wholesale — the
# AST↔semantic reconciliation case, where the LLM's contribution would
# otherwise be silently lost (#2091). Conflicting keys still resolve to
# the survivor's value, so _collision_rank ordering and _report_id_collision
# bookkeeping are unchanged.
merged = dict(node)
for k, v in incumbent.items():
merged.setdefault(k, v)
seen_ids[nid] = merged
dropped[nid].append(incumbent)
else:
for k, v in node.items():
incumbent.setdefault(k, v)
dropped[nid].append(node)

for nid, losers in dropped.items():
Expand Down
47 changes: 47 additions & 0 deletions tests/test_dedup.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,3 +551,50 @@ def test_defines_id_helper():
# A path that is merely a string-prefix of the ID's path does not define it.
assert not _defines_id({"id": "agents_foo", "source_file": "agent/foo.md"})
assert not _defines_id({"id": "docs_intro_foo", "source_file": ""})


# ── #2091: same-ID merge gap-fills attributes instead of discarding them ───────

def test_same_id_merge_gap_fills_survivor_from_loser():
"""AST↔semantic reconciliation: the AST node and the LLM node share an ID so
they collapse. The survivor must keep the loser's disjoint attributes
(summary, confidence_score) rather than dropping them wholesale (#2091)."""
ast = {"id": "src_auth_login", "label": "login", "file_type": "code",
"source_file": "src/auth.py", "_origin": "ast", "source_location": "L42"}
sem = {"id": "src_auth_login", "label": "User login handler", "file_type": "code",
"source_file": "src/auth.py", "summary": "Authenticates a user.",
"confidence_score": 0.9}
for order in ([ast, sem], [sem, ast]):
out, _ = deduplicate_entities([dict(n) for n in order], [], communities={})
assert len(out) == 1
node = out[0]
# Survivor wins on conflicting keys (shorter, more canonical label).
assert node["label"] == "login"
assert node["source_location"] == "L42"
# ...but the loser's disjoint attributes are preserved, not discarded.
assert node["summary"] == "Authenticates a user."
assert node["confidence_score"] == 0.9
assert node["_origin"] == "ast"


def test_same_id_merge_does_not_overwrite_survivor_on_conflict():
"""Gap-fill only fills keys the survivor lacks; a key present on both keeps the
survivor's value, so _collision_rank ordering semantics are unchanged (#2091)."""
winner = {"id": "src_mod_thing", "label": "thing", "file_type": "code",
"source_file": "src/mod.py", "summary": "canonical summary"}
loser = {"id": "src_mod_thing", "label": "thing helper", "file_type": "code",
"source_file": "src/mod.py", "summary": "stale summary"}
out, _ = deduplicate_entities([winner, loser], [], communities={})
assert len(out) == 1
assert out[0]["summary"] == "canonical summary"


def test_same_id_merge_does_not_mutate_input_nodes():
"""The gap-fill copies before merging, so caller-owned node dicts are untouched
(a mutated survivor would be reported into the LLM cache under the wrong keys)."""
ast = {"id": "src_auth_login", "label": "login", "source_file": "src/auth.py"}
sem = {"id": "src_auth_login", "label": "User login handler",
"source_file": "src/auth.py", "summary": "Authenticates a user."}
deduplicate_entities([ast, sem], [], communities={})
assert "summary" not in ast
assert set(sem) == {"id", "label", "source_file", "summary"}
Loading