mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-25 08:11:54 +00:00
_fold_confidence normalized the confidence/confidence_score pair on edges and hyperedges, but nodes passed through the build verbatim, so a model emitting a lowercase enum value, a bare float, or a drifted word for a node's confidence landed in graph.json as-is. Fold it in _fold_node_aliases, which both build_from_json and build_merge already call, so nodes share one normalization contract with edges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
225 lines
8.1 KiB
Python
225 lines
8.1 KiB
Python
"""LLM field aliases: node file/path/filepath -> source_file, edge relationship -> relation."""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from contextlib import redirect_stderr
|
|
|
|
from graphify.build import (
|
|
_fold_confidence,
|
|
_fold_edge_aliases,
|
|
_fold_node_aliases,
|
|
build_from_json,
|
|
)
|
|
|
|
|
|
def test_edge_relationship_folds_to_relation():
|
|
edge = {"source": "a", "target": "b", "relationship": "calls"}
|
|
_fold_edge_aliases(edge)
|
|
assert edge["relation"] == "calls"
|
|
assert "relationship" not in edge
|
|
|
|
|
|
def test_edge_relation_wins_over_relationship():
|
|
edge = {"source": "a", "target": "b", "relation": "imports", "relationship": "calls"}
|
|
_fold_edge_aliases(edge)
|
|
assert edge["relation"] == "imports"
|
|
|
|
|
|
def test_node_file_folds_to_source_file():
|
|
node = {"id": "a", "label": "A", "file": "foo.ts"}
|
|
_fold_node_aliases(node)
|
|
assert node["source_file"] == "foo.ts"
|
|
assert "file" not in node
|
|
|
|
|
|
def test_node_path_folds_to_source_file():
|
|
node = {"id": "a", "label": "A", "path": "bar.py"}
|
|
_fold_node_aliases(node)
|
|
assert node["source_file"] == "bar.py"
|
|
assert "path" not in node
|
|
|
|
|
|
def test_node_filepath_folds_to_source_file():
|
|
node = {"id": "a", "label": "A", "filepath": "baz.go"}
|
|
_fold_node_aliases(node)
|
|
assert node["source_file"] == "baz.go"
|
|
|
|
|
|
def test_node_source_file_wins_over_alias():
|
|
node = {"id": "a", "label": "A", "source_file": "real.py", "file": "other.py"}
|
|
_fold_node_aliases(node)
|
|
assert node["source_file"] == "real.py"
|
|
|
|
|
|
def test_numeric_confidence_folds_to_inferred_and_backfills_score():
|
|
edge = {"source": "a", "target": "b", "confidence": 0.85}
|
|
_fold_edge_aliases(edge)
|
|
assert edge["confidence"] == "INFERRED"
|
|
assert edge["confidence_score"] == 0.85
|
|
|
|
|
|
def test_numeric_confidence_does_not_override_an_existing_score():
|
|
edge = {"source": "a", "target": "b", "confidence": 0.85, "confidence_score": 0.5}
|
|
_fold_edge_aliases(edge)
|
|
assert edge["confidence"] == "INFERRED"
|
|
assert edge["confidence_score"] == 0.5
|
|
|
|
|
|
def test_model_drifted_string_confidence_folds_to_inferred():
|
|
# "high"/"confirmed"/"medium-high"/"medium-low" are model drift from the
|
|
# LLM semantic pass, not the documented EXTRACTED/INFERRED/AMBIGUOUS enum
|
|
# (#4495): none of them parse as a float, so confidence_score is left
|
|
# alone and only the enum field itself is repaired.
|
|
for drifted in ("high", "confirmed", "medium-high", "medium-low"):
|
|
edge = {"source": "a", "target": "b", "confidence": drifted}
|
|
_fold_edge_aliases(edge)
|
|
assert edge["confidence"] == "INFERRED", drifted
|
|
assert "confidence_score" not in edge, drifted
|
|
|
|
|
|
def test_numeric_looking_string_confidence_backfills_score():
|
|
"""A string that DOES parse as a float still backfills confidence_score,
|
|
the same as a genuinely numeric confidence (#4495)."""
|
|
edge = {"source": "a", "target": "b", "confidence": "0.85"}
|
|
_fold_edge_aliases(edge)
|
|
assert edge["confidence"] == "INFERRED"
|
|
assert edge["confidence_score"] == 0.85
|
|
|
|
|
|
def test_lowercase_confidence_is_case_corrected_not_treated_as_drift():
|
|
"""A case variant of a real enum value (#4495) IS the enum value, just
|
|
mis-cased -- it must be corrected to its exact spelling, not treated as
|
|
an unknown word that backfills confidence_score from a failed float
|
|
parse of a string that was never meant to be a number."""
|
|
edge = {"source": "a", "target": "b", "confidence": "inferred"}
|
|
_fold_edge_aliases(edge)
|
|
assert edge["confidence"] == "INFERRED"
|
|
assert "confidence_score" not in edge
|
|
|
|
|
|
def test_valid_enum_confidences_are_left_untouched():
|
|
for valid in ("EXTRACTED", "INFERRED", "AMBIGUOUS"):
|
|
edge = {"source": "a", "target": "b", "confidence": valid}
|
|
_fold_edge_aliases(edge)
|
|
assert edge["confidence"] == valid
|
|
assert "confidence_score" not in edge
|
|
|
|
|
|
def test_hyperedge_confidence_folds_the_same_way():
|
|
"""#4495: a hyperedge carries the same confidence/confidence_score pair
|
|
an edge does, and had no fold of its own."""
|
|
he = {"id": "h1", "nodes": ["a", "b"], "confidence": "high"}
|
|
_fold_confidence(he)
|
|
assert he["confidence"] == "INFERRED"
|
|
|
|
he_numeric = {"id": "h2", "nodes": ["a", "b"], "confidence": 0.75}
|
|
_fold_confidence(he_numeric)
|
|
assert he_numeric["confidence"] == "INFERRED"
|
|
assert he_numeric["confidence_score"] == 0.75
|
|
|
|
|
|
def test_build_from_json_normalizes_drifted_confidence_without_warning():
|
|
"""#4495 end to end: a graph carrying the exact warning text from the
|
|
issue must build silently, with the edge and hyperedge confidence both
|
|
repaired to the enum."""
|
|
extraction = {
|
|
"nodes": [
|
|
{"id": "a", "label": "A", "file_type": "document", "source_file": "a.md"},
|
|
{"id": "b", "label": "B", "file_type": "document", "source_file": "b.md"},
|
|
],
|
|
"edges": [
|
|
{
|
|
"source": "a", "target": "b", "relation": "references",
|
|
"confidence": "confirmed", "source_file": "a.md",
|
|
}
|
|
],
|
|
"hyperedges": [
|
|
{"id": "h1", "nodes": ["a", "b"], "relation": "form", "confidence": "high"}
|
|
],
|
|
}
|
|
err = io.StringIO()
|
|
with redirect_stderr(err):
|
|
G = build_from_json(extraction)
|
|
assert "invalid confidence" not in err.getvalue()
|
|
_, _, data = next(iter(G.edges(data=True)))
|
|
assert data["confidence"] == "INFERRED"
|
|
assert G.graph["hyperedges"][0]["confidence"] == "INFERRED"
|
|
|
|
|
|
def test_build_from_json_normalizes_without_validation_warnings():
|
|
extraction = {
|
|
"nodes": [
|
|
{"id": "a", "label": "A", "file_type": "code", "file": "foo.ts"},
|
|
{"id": "b", "label": "B", "file_type": "code", "path": "bar.py"},
|
|
],
|
|
"edges": [
|
|
{
|
|
"source": "a",
|
|
"target": "b",
|
|
"relationship": "calls",
|
|
"confidence": "EXTRACTED",
|
|
"source_file": "foo.ts",
|
|
}
|
|
],
|
|
}
|
|
err = io.StringIO()
|
|
with redirect_stderr(err):
|
|
G = build_from_json(extraction)
|
|
out = err.getvalue()
|
|
assert "missing required field" not in out
|
|
assert G.nodes["a"]["source_file"] == "foo.ts"
|
|
assert G.nodes["b"]["source_file"] == "bar.py"
|
|
assert G.number_of_edges() == 1
|
|
_, _, data = next(iter(G.edges(data=True)))
|
|
assert data["relation"] == "calls"
|
|
|
|
|
|
def test_node_confidence_folds_the_same_way():
|
|
"""#4529: a node carrying a confidence/confidence_score pair gets the same
|
|
fold edges and hyperedges already have, instead of passing through the
|
|
build verbatim."""
|
|
drifted = {"id": "a", "label": "A", "confidence": "high"}
|
|
_fold_node_aliases(drifted)
|
|
assert drifted["confidence"] == "INFERRED"
|
|
assert "confidence_score" not in drifted
|
|
|
|
mis_cased = {"id": "b", "label": "B", "confidence": "extracted"}
|
|
_fold_node_aliases(mis_cased)
|
|
assert mis_cased["confidence"] == "EXTRACTED"
|
|
assert "confidence_score" not in mis_cased
|
|
|
|
numeric = {"id": "c", "label": "C", "confidence": 0.9}
|
|
_fold_node_aliases(numeric)
|
|
assert numeric["confidence"] == "INFERRED"
|
|
assert numeric["confidence_score"] == 0.9
|
|
|
|
|
|
def test_node_without_confidence_is_left_untouched():
|
|
"""No behavior change for graphs whose nodes carry no confidence (#4529)."""
|
|
node = {"id": "a", "label": "A", "source_file": "a.py"}
|
|
_fold_node_aliases(node)
|
|
assert "confidence" not in node
|
|
assert "confidence_score" not in node
|
|
|
|
|
|
def test_build_from_json_normalizes_node_confidence():
|
|
"""#4529 end to end: a drifted node confidence reaches the built graph
|
|
folded onto the enum, the same as an edge's."""
|
|
extraction = {
|
|
"nodes": [
|
|
{
|
|
"id": "a", "label": "A", "file_type": "document",
|
|
"source_file": "a.md", "confidence": "extracted",
|
|
},
|
|
{
|
|
"id": "b", "label": "B", "file_type": "document",
|
|
"source_file": "b.md", "confidence": 0.7,
|
|
},
|
|
],
|
|
"edges": [],
|
|
}
|
|
G = build_from_json(extraction)
|
|
assert G.nodes["a"]["confidence"] == "EXTRACTED"
|
|
assert G.nodes["b"]["confidence"] == "INFERRED"
|
|
assert G.nodes["b"]["confidence_score"] == 0.7
|