fix(build): coerce null/malformed edge weight to the 1.0 default (#1960)

An explicit "weight": null in the extraction JSON survived .get("weight", 1.0)
(the key is present, so the default never applied) and reached Louvain/Leiden as
None, crashing modularity with a TypeError (graspologic's Leiden even panics on
NaN). build_from_json now coerces weight and confidence_score to float at the
ingest choke point, falling back to 1.0 for null / non-numeric / NaN / inf /
negative values while preserving valid ones. Repairs (not drops) the key so
graph.json round-trips clean and a cluster-only/--update reload never re-ingests
the null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-17 11:21:21 +01:00
co-authored by Claude Opus 4.8
parent 5163a6243b
commit dd0f8ec5b6
2 changed files with 61 additions and 0 deletions
+19
View File
@@ -22,6 +22,7 @@
#
from __future__ import annotations
import json
import math
import os
import re
import sys
@@ -706,6 +707,24 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
if src not in node_set or tgt not in node_set:
continue # skip edges to external/stdlib nodes - expected, not an error
attrs = {k: v for k, v in edge.items() if k not in ("source", "target")}
# Sanitize numeric edge fields (#1960): an explicit ``"weight": null`` in
# the extraction JSON survives ``.get("weight", 1.0)`` (the key is present,
# so the default never applies) and reaches Louvain/Leiden as None,
# crashing modularity arithmetic with a TypeError (graspologic's Leiden
# even panics on NaN). Coerce to float and fall back to the schema default
# of 1.0 for anything the clustering backends reject — None, non-numeric
# strings, NaN/inf, negatives — while numeric strings coerce cleanly.
# Repair (not drop) the key so graph.json round-trips a clean value and a
# cluster-only/--update reload never re-ingests the null.
for _num_key in ("weight", "confidence_score"):
if _num_key in attrs:
try:
_num_val = float(attrs[_num_key])
except (TypeError, ValueError):
_num_val = 1.0
if not math.isfinite(_num_val) or _num_val < 0:
_num_val = 1.0
attrs[_num_key] = _num_val
# Backfill source_file from the endpoint nodes (every node carries one).
# Semantic/LLM edges occasionally omit it, which downstream validation
# flags and leaves query results with no file reference (#1279).
+42
View File
@@ -58,6 +58,48 @@ def test_build_from_json_edge_count():
G = build_from_json(load_extraction())
assert G.number_of_edges() == 4
def test_null_weight_edge_builds_and_clusters(tmp_path):
"""#1960: an explicit ``"weight": null`` (JSON null -> None) used to survive
``.get("weight", 1.0)`` and crash Louvain/Leiden modularity with a TypeError.
It must now coerce to the 1.0 default, build, and cluster without raising."""
from graphify.cluster import cluster
extraction = {
"nodes": [
{"id": "a", "label": "A", "file_type": "code", "source_file": "a.py"},
{"id": "b", "label": "B", "file_type": "code", "source_file": "b.py"},
{"id": "c", "label": "C", "file_type": "code", "source_file": "c.py"},
],
"edges": [
{"source": "a", "target": "b", "relation": "references", "weight": None,
"confidence_score": None},
{"source": "b", "target": "c", "relation": "references", "weight": 2.5},
],
}
G = build_from_json(extraction)
assert G["a"]["b"]["weight"] == 1.0 # null coerced to default
assert G["a"]["b"]["confidence_score"] == 1.0 # null confidence_score too
assert G["b"]["c"]["weight"] == 2.5 # a valid weight is preserved
cluster(G) # must not raise (Louvain/Leiden modularity)
def test_malformed_weights_normalize():
"""Non-numeric / NaN / inf / negative weights fall back to 1.0 (the backends
reject them); a missing weight key is left absent (backends default it)."""
extraction = {
"nodes": [{"id": f"n{i}", "label": str(i), "file_type": "code",
"source_file": f"{i}.py"} for i in range(4)],
"edges": [
{"source": "n0", "target": "n1", "relation": "references", "weight": "3.5"},
{"source": "n1", "target": "n2", "relation": "references", "weight": float("nan")},
{"source": "n2", "target": "n3", "relation": "references", "weight": -4},
],
}
G = build_from_json(extraction)
assert G["n0"]["n1"]["weight"] == 3.5 # numeric string coerces
assert G["n1"]["n2"]["weight"] == 1.0 # NaN -> default
assert G["n2"]["n3"]["weight"] == 1.0 # negative -> default
def test_nodes_have_label():
G = build_from_json(load_extraction())
assert G.nodes["n_transformer"]["label"] == "Transformer"