Skip to content

Cross-file dedup picks survivor by shortest node ID, discarding the richer node's content #3372

Description

@tourko

deduplicate_entities's cross-file exact-match pass (in build.py, gated to concept-type nodes sharing a normalized label) merges two nodes for the same real-world entity into one survivor. The survivor is chosen by dedup.py's _pick_winner():

def _pick_winner(nodes: list[dict]) -> dict:
    """Pick the canonical survivor: prefer no chunk suffix, then shorter ID."""
    def _score(n): return (1 if _CHUNK_SUFFIX.search(n["id"]) else 0, len(n["id"]))
    return min(nodes, key=_score)

This scores purely on ID shape/length — it has no regard for which node actually carries content (attributes, confidence, _merged_from history, etc.). In practice, a dedicated page for an entity tends to sit in a nested category folder, giving it a longer node ID (e.g. topics/networking/widget-x.md), while a passing mention of that same entity on an unrelated, flat summary page mints a short, shallow node (e.g. sources/notes.md) purely because it has one fewer path segment — regardless of how the two filenames themselves compare in length. Since the shorter ID always wins, the established, enriched node is discarded and replaced by the shallow one — every time this pattern occurs.

Repro:

  1. Create topics/networking/widget-x.md, a dedicated page for an entity "Widget X" that produces a rich node (multiple attributes, high confidence, etc.).
  2. Extract it — Widget X's node now has real content.
  3. Create sources/notes.md, an unrelated, top-level page that only mentions "Widget X" in passing (one line, no real detail).
  4. Extract it. The exact-match cross-file dedup pass fires (same normalized label, both concept type), and — because sources/notes.md sits one directory level shallower, making its node ID shorter regardless of the actual filename — the shallow node from step 3 wins. Diff graph.json before/after: Widget X's attributes (and any _merged_from history) from step 1 are gone, replaced by the shallow stub.

Note this is pure content loss, not structural corruption — edges pointing at the old survivor are correctly rewired first (confirmed via graphify diagnose / the health check: zero dangling edges).

Suggested fix: score candidates by content richness first (e.g. number of populated fields, presence of _merged_from/attributes/confidence) and fall back to ID length only as a tiebreaker among equally-rich candidates, rather than using ID length as the primary signal.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions