Fix: validate_extraction crashes on non-hashable node id / edge endpoint

validate_extraction() is documented to return a list of error strings ("empty
list means valid"), but raised TypeError: unhashable type: 'list' when a node
id -- or an edge source/target -- was a non-hashable value such as a list. This
occurs in practice when an LLM extraction subagent emits malformed JSON like
{"id": ["foo", "bar"], ...}. Crash sites: the node_ids set comprehension and
the `edge[...] not in node_ids` membership tests.

Because build_from_json() validates at its start, a single malformed node
aborted the entire build, losing an otherwise-complete extraction of a large
corpus. build_from_json() itself would also raise (G.add_node(<list>) and the
`not in node_set` test) if the validator were bypassed.

- validate.py: build node_ids during the node pass, adding only hashable ids;
  report a non-hashable id/endpoint as an error string instead of crashing.
  All existing messages and the dangling-edge checks are preserved.
- build.py: skip dict nodes with a missing/non-hashable id and edges with
  non-hashable endpoints (stderr warning). Non-dict nodes are deliberately
  left to raise so the multigraph diagnostic still observes shape errors.
- tests: 3 cases in test_validate.py and 2 in test_build.py.
This commit is contained in:
dschwartzi
2026-06-24 08:54:11 +01:00
committed by safishamsi
parent ad6cb753c0
commit 2c7cbb6530
4 changed files with 147 additions and 7 deletions
+32 -2
View File
@@ -182,8 +182,25 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
print(f"[graphify] Extraction warning ({len(real_errors)} issues): {real_errors[0]}", file=sys.stderr)
G: nx.Graph = nx.DiGraph() if directed else nx.Graph()
for node in extraction.get("nodes", []):
if "source_file" in node:
node["source_file"] = _norm_source_file(node["source_file"], _root)
# Skip dict nodes with a missing or non-hashable id (e.g. a list emitted
# by a buggy LLM extraction) so NetworkX add_node never raises
# TypeError: unhashable type. Non-dict nodes are deliberately left to
# raise as before, so callers that probe build for shape errors (e.g.
# the multigraph diagnostic) still observe the malformed shape.
if isinstance(node, dict):
if "id" not in node:
continue
try:
hash(node["id"])
except TypeError:
print(
f"[graphify] WARNING: skipping node with non-hashable id "
f"{node['id']!r} (must be a string).",
file=sys.stderr,
)
continue
if "source_file" in node:
node["source_file"] = _norm_source_file(node["source_file"], _root)
G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
node_set = set(G.nodes())
@@ -276,6 +293,19 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
if "source" not in edge or "target" not in edge:
continue
src, tgt = edge["source"], edge["target"]
# Skip edges with non-hashable endpoints (e.g. a list emitted by a buggy
# LLM extraction) so the `not in node_set` membership test below never
# raises TypeError: unhashable type. The validator already reported these.
try:
hash(src)
hash(tgt)
except TypeError:
print(
f"[graphify] WARNING: skipping edge with non-hashable endpoint "
f"(source={src!r}, target={tgt!r}).",
file=sys.stderr,
)
continue
# Remap mismatched IDs via normalization before dropping the edge.
if src not in node_set:
src = norm_to_id.get(_normalize_id(src), src)
+28 -5
View File
@@ -17,6 +17,12 @@ def validate_extraction(data: dict) -> list[str]:
errors: list[str] = []
# Collected during the node pass so the edge pass can reuse it. Only
# hashable ids land here; a non-hashable id (e.g. a list emitted by a
# malformed LLM extraction) is reported as an error rather than crashing
# the validator on set construction.
node_ids: set = set()
# Nodes
if "nodes" not in data:
errors.append("Missing required key 'nodes'")
@@ -30,6 +36,15 @@ def validate_extraction(data: dict) -> list[str]:
for field in REQUIRED_NODE_FIELDS:
if field not in node:
errors.append(f"Node {i} (id={node.get('id', '?')!r}) missing required field '{field}'")
if "id" in node:
try:
hash(node["id"])
except TypeError:
errors.append(
f"Node {i} has non-hashable id {node['id']!r} - id must be a string"
)
else:
node_ids.add(node["id"])
if "file_type" in node and node["file_type"] not in VALID_FILE_TYPES:
errors.append(
f"Node {i} (id={node.get('id', '?')!r}) has invalid file_type "
@@ -43,7 +58,6 @@ def validate_extraction(data: dict) -> list[str]:
elif not isinstance(edge_list, list):
errors.append("'edges' must be a list")
else:
node_ids = {n["id"] for n in data.get("nodes", []) if isinstance(n, dict) and "id" in n}
for i, edge in enumerate(edge_list):
if not isinstance(edge, dict):
errors.append(f"Edge {i} must be an object")
@@ -56,10 +70,19 @@ def validate_extraction(data: dict) -> list[str]:
f"Edge {i} has invalid confidence '{edge['confidence']}' "
f"- must be one of {sorted(VALID_CONFIDENCES)}"
)
if "source" in edge and node_ids and edge["source"] not in node_ids:
errors.append(f"Edge {i} source '{edge['source']}' does not match any node id")
if "target" in edge and node_ids and edge["target"] not in node_ids:
errors.append(f"Edge {i} target '{edge['target']}' does not match any node id")
for endpoint in ("source", "target"):
if endpoint not in edge:
continue
val = edge[endpoint]
try:
unmatched = bool(node_ids) and val not in node_ids
except TypeError:
errors.append(
f"Edge {i} {endpoint} {val!r} is non-hashable - must be a string"
)
continue
if unmatched:
errors.append(f"Edge {i} {endpoint} '{val}' does not match any node id")
return errors
+37
View File
@@ -677,3 +677,40 @@ def test_build_merge_rejects_oversized_existing_graph(monkeypatch, tmp_path):
monkeypatch.setattr("graphify.security._MAX_GRAPH_FILE_BYTES", 8)
with pytest.raises(ValueError, match="exceeds"):
build_merge([], graph_path, dedup=False)
def test_build_from_json_skips_non_hashable_node_id():
# A malformed LLM extraction can emit a list-valued id; build_from_json must
# skip it (NetworkX add_node would otherwise raise unhashable type) and still
# build the graph from the well-formed nodes.
extraction = {
"nodes": [
{"id": "a", "label": "A", "file_type": "code", "source_file": "a.py"},
{"id": ["x", "y"], "label": "B", "file_type": "code", "source_file": "b.py"},
{"label": "C", "file_type": "code", "source_file": "c.py"}, # missing id
],
"edges": [],
}
G = build_from_json(extraction)
assert set(G.nodes()) == {"a"}
def test_build_from_json_skips_edge_with_non_hashable_endpoint():
# A list-valued edge endpoint must be skipped rather than crash the
# `not in node_set` membership test. The well-formed edge survives.
extraction = {
"nodes": [
{"id": "a", "label": "A", "file_type": "code", "source_file": "a.py"},
{"id": "b", "label": "B", "file_type": "code", "source_file": "b.py"},
],
"edges": [
{"source": "a", "target": ["b", "c"], "relation": "calls",
"confidence": "INFERRED", "source_file": "a.py"},
{"source": "a", "target": "b", "relation": "imports",
"confidence": "EXTRACTED", "source_file": "a.py"},
],
}
G = build_from_json(extraction)
assert G.number_of_nodes() == 2
assert G.number_of_edges() == 1
assert G.has_edge("a", "b")
+50
View File
@@ -85,3 +85,53 @@ def test_assert_valid_raises_on_errors():
def test_assert_valid_passes_silently():
assert_valid(VALID) # should not raise
def test_non_hashable_node_id_reported_not_raised():
# A malformed LLM extraction can emit a list-valued id. The validator must
# report it as an error string (its documented contract) rather than crash
# with TypeError: unhashable type on set construction.
data = {
"nodes": [
{"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"},
{"id": ["x", "y"], "label": "B", "file_type": "code", "source_file": "b.py"},
],
"edges": [],
}
errors = validate_extraction(data)
assert any("non-hashable id" in e for e in errors)
def test_non_hashable_edge_endpoint_reported_not_raised():
# A list-valued edge source/target must be reported, not crash the
# membership test against the node-id set.
data = {
"nodes": [
{"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"},
{"id": "n2", "label": "B", "file_type": "code", "source_file": "b.py"},
],
"edges": [
{"source": "n1", "target": ["n2", "n3"], "relation": "calls",
"confidence": "INFERRED", "source_file": "a.py"},
],
}
errors = validate_extraction(data)
assert any("target" in e and "non-hashable" in e for e in errors)
def test_non_hashable_node_id_does_not_mask_valid_ids():
# The valid node id must still be collected so a legitimately-dangling edge
# is still flagged even when a sibling node has a bad id.
data = {
"nodes": [
{"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"},
{"id": {"oops": 1}, "label": "B", "file_type": "code", "source_file": "b.py"},
],
"edges": [
{"source": "n1", "target": "ghost", "relation": "calls",
"confidence": "EXTRACTED", "source_file": "a.py"},
],
}
errors = validate_extraction(data)
assert any("non-hashable id" in e for e in errors)
assert any("target" in e and "ghost" in e for e in errors)