Skip to content

build(): dedup drops hyperedge members instead of remapping to the survivor — silent group shrink at 0.9.44, dangling members at 0.8.29 #2805

Description

@vihavald

Summary

build() rewires edges to dedup survivors but never rewires hyperedge members. At HEAD
(0.9.44) the orphaned member is silently dropped rather than remapped, so a hyperedge quietly
loses a participant — and can fall below the 3-member threshold that makes it a hyperedge — with no
warning on stderr and no trace in the graph. On 0.8.29 the same input leaves a dangling member
instead. Both are wrong in the same place; the newer behaviour is harder to detect, because the
resulting graph passes any referential-integrity check.

Mechanism

In graphify/build.py (line numbers from HEAD):

1325    combined["hyperedges"].extend(ext.get("hyperedges", []))
...
1340    combined["nodes"], combined["edges"] = deduplicate_entities(
1341        combined["nodes"], combined["edges"], communities={},
1342        dedup_llm_backend=dedup_llm_backend, root=root,
1343    )
1344    return build_from_json(combined, directed=directed, root=root)

deduplicate_entities takes and returns only (nodes, edges); graphify/dedup.py contains no
reference to hyperedges at all. combined["hyperedges"] is assembled at 1325 and is never passed
through the survivor mapping that rewrites edge endpoints. deduplicate_by_label in the same file
has the identical shape — its remap dict is applied to edges only.

_normalize_hyperedge_members / _coerce_hyperedge_member_refs normalise member shape (object
vs string, alias keys) but do not resolve members against surviving node ids, so they do not cover
this.

Reproduction

Self-contained, no corpus or API key needed. Two nodes normalise to the same label, so dedup merges
them; the hyperedge names only the id that loses.

import sys
from graphify.build import build
import importlib.metadata as md

extraction = {
    "nodes": [
        {"id": "alpha_a", "label": "Alpha Concept", "file_type": "concept", "source_file": "notes/a.md"},
        {"id": "alpha_concept_long_variant_id", "label": "alpha concept", "file_type": "concept", "source_file": "notes/a.md"},
        {"id": "beta_node", "label": "Beta", "file_type": "concept", "source_file": "notes/a.md"},
        {"id": "gamma_node", "label": "Gamma", "file_type": "concept", "source_file": "notes/a.md"},
    ],
    "edges": [],
    "hyperedges": [{"id": "the_group", "label": "The Group",
                    "nodes": ["alpha_concept_long_variant_id", "beta_node", "gamma_node"],
                    "relation": "participate_in", "confidence": "INFERRED",
                    "confidence_score": 0.75, "source_file": "notes/a.md"}],
    "input_tokens": 0, "output_tokens": 0,
}
G = build([extraction])
ids = set(G.nodes())
for h in G.graph.get("hyperedges", []):
    m = h.get("nodes", [])
    print("graphify", md.version("graphifyy"), "->", m)
    print("  survivor present:", "alpha_a" in m,
          "| dangling:", [x for x in m if x not in ids],
          "| members:", len(m))

0.9.44:

[graphify] Deduplicated 1 node(s) (1 exact).
graphify 0.9.44 -> ['beta_node', 'gamma_node']
  survivor present: False | dangling: [] | members: 2

0.8.29, same script:

[graphify] Deduplicated 1 node(s) (1 exact).
graphify 0.8.29 -> ['alpha_concept_long_variant_id', 'beta_node', 'gamma_node']
  survivor present: False | dangling: ['alpha_concept_long_variant_id'] | members: 3

Expected

The member should be remapped to the surviving id — ['alpha_a', 'beta_node', 'gamma_node'], three
members, no dangling reference. The concept still exists in the graph under alpha_a; only the
hyperedge's pointer to it is stale.

Observed impact

On a real corpus (docs + code, ~6.8k nodes) an incremental build_merge run deduplicated 151 nodes
(78 exact, 71 fuzzy) and produced 13 hyperedge members pointing at ids no longer in the graph
across 11 hyperedges. One hyperedge retained 2 of its 5 members, which is a pairwise edge wearing a
hyperedge's name. That run was on 0.8.29, so the failure was at least visible; on 0.9.44 the same
corpus would instead have lost those 13 participations silently.

Nothing in the pipeline surfaces it either way. The merge prints its dedup count and success, and
hyperedge counts are unchanged, so the damage is invisible unless something separately validates
member ids against node ids.

Suggested fix

Return the survivor map from deduplicate_entities (or expose it alongside), then apply it to
combined["hyperedges"] before build_from_json, deduping members in order afterwards since two
members can collapse onto the same survivor. The drop path should stay only for members that have
no survivor at all, and should warn on stderr naming the hyperedge — silent removal of a member is
what makes this hard to notice.

Two adjacent paths look like they need the same treatment, though I have not reproduced them
separately:

  • build_merge(prune_sources=...) removes nodes via G.remove_nodes_from() without touching
    G.graph["hyperedges"], so pruning a deleted file's nodes should orphan members the same way.
  • A hyperedge that falls below 3 resolvable members after any of this is arguably no longer a
    hyperedge; currently only the zero-member case is dropped with a warning.

Related: #2484 (closed) fixed the same class for merge-graphs/nx.compose; this is the
build() / deduplicate_entities path, which appears not to have been covered by it.

Environment

  • graphify 0.9.44 (reproduced) and 0.8.29 (different symptom, same cause)
  • Windows 11, Python 3.12, networkx via the package's own pins
  • No LLM backend involved — dedup_llm_backend unset, pass-1 exact normalisation only

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