mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-25 15:05:56 +00:00
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:
co-authored by
Claude Opus 4.8
parent
5163a6243b
commit
dd0f8ec5b6
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user