From dd0f8ec5b6abf03603eaa6af62f275f0e0529012 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Fri, 17 Jul 2026 11:21:21 +0100 Subject: [PATCH] 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) --- graphify/build.py | 19 +++++++++++++++++++ tests/test_build.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/graphify/build.py b/graphify/build.py index caeaefdf9..9be58fb98 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -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). diff --git a/tests/test_build.py b/tests/test_build.py index ed87e6b11..9e470ad6d 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -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"