Files
graphify/tests/test_evidence_binding.py
T
tpateeq 741876b7f2 fix(llm): downgrade unverifiable code-typed semantic nodes to UNVERIFIED
The semantic (LLM) extraction runs on documents/papers/images; code files are
handled by the deterministic AST engine and never reach the model. A node the
model tags file_type="code" is therefore a symbol it surfaced from within a
document (a name in a fenced code block, an API referenced in a paper), and it
enters graph.json today with no check that the symbol actually appears in the
source the model read. `_out_of_scope` (#1895) only rejects nodes attributed to a
file that was NOT dispatched, so a fabricated symbol on a dispatched file passes.

`extract_files_direct` now verifies every file_type=="code" node whose
source_file was dispatched in the call: if no identifier from its label occurs
(case-insensitive substring) in that file's source bytes, the node's confidence
is downgraded to "UNVERIFIED" (never dropped) and the count is reported to
stderr. Concept/document nodes, nodes without a source_file, nodes attributed to
undispatched files, and labels with no checkable identifier are left untouched.
Best-effort; never aborts extraction; one unreadable file is skipped, not fatal.
2026-07-17 00:02:15 +01:00

196 lines
7.7 KiB
Python

"""Tests for semantic evidence-binding in graphify.llm.
A code node the model returns whose symbol name has no evidence in the dispatched
source is downgraded to ``confidence = "UNVERIFIED"`` (never dropped). This closes
the intra-file hallucination gap that ``_out_of_scope`` (#1895) — which only
rejects nodes attributed to a file that was NOT dispatched — cannot see.
"""
from pathlib import Path
from unittest.mock import patch
from graphify import llm
_SOURCE = (
"def real_function():\n"
" return PaymentProcessor().charge_card()\n"
"\n"
"class PaymentProcessor:\n"
" def charge_card(self):\n"
" pass\n"
)
def _run(files, nodes, tmp_path):
"""Drive extract_files_direct with a faked backend returning ``nodes``."""
result = {
"nodes": nodes,
"edges": [],
"hyperedges": [],
"input_tokens": 1,
"output_tokens": 1,
"finish_reason": "stop",
}
with patch("graphify.llm._call_openai_compat", return_value=result):
return llm.extract_files_direct(files, backend="kimi", api_key="k", root=tmp_path)
def _by_label(out):
return {n["label"]: n for n in out["nodes"]}
def test_fabricated_code_symbol_is_downgraded(tmp_path):
src = tmp_path / "mod.py"
src.write_text(_SOURCE, encoding="utf-8")
nodes = [
{"id": "a", "label": "real_function()", "file_type": "code", "source_file": "mod.py"},
{"id": "b", "label": "totally_fabricated_symbol()", "file_type": "code", "source_file": "mod.py"},
{"id": "c", "label": "Payments Overview", "file_type": "concept", "source_file": "mod.py"},
]
out = _by_label(_run([src], nodes, tmp_path))
# The fabricated symbol has no evidence in the source -> flagged.
assert out["totally_fabricated_symbol()"]["confidence"] == "UNVERIFIED"
# A symbol that IS in the source is verified -> untouched (no confidence key).
assert "confidence" not in out["real_function()"]
# A concept node is prose, never checked.
assert "confidence" not in out["Payments Overview"]
def test_qualified_and_prettified_labels_do_not_false_positive(tmp_path):
src = tmp_path / "mod.py"
src.write_text(_SOURCE, encoding="utf-8")
nodes = [
{"id": "a", "label": "PaymentProcessor.charge_card()", "file_type": "code", "source_file": "mod.py"},
{"id": "b", "label": "charge_card(amount, token)", "file_type": "code", "source_file": "mod.py"},
]
out = _by_label(_run([src], nodes, tmp_path))
# Any label identifier present in the source verifies the whole label.
assert "confidence" not in out["PaymentProcessor.charge_card()"]
assert "confidence" not in out["charge_card(amount, token)"]
def test_document_and_sourceless_nodes_are_never_flagged(tmp_path):
src = tmp_path / "mod.py"
src.write_text(_SOURCE, encoding="utf-8")
nodes = [
{"id": "a", "label": "Nonexistent Heading", "file_type": "document", "source_file": "mod.py"},
{"id": "b", "label": "orphan_symbol()", "file_type": "code"}, # no source_file
]
out = _by_label(_run([src], nodes, tmp_path))
assert "confidence" not in out["Nonexistent Heading"]
assert "confidence" not in out["orphan_symbol()"]
def test_node_attributed_to_undispatched_file_is_left_to_out_of_scope(tmp_path):
src = tmp_path / "mod.py"
src.write_text(_SOURCE, encoding="utf-8")
# other.py exists on disk but is NOT dispatched in this call.
(tmp_path / "other.py").write_text("def elsewhere():\n pass\n", encoding="utf-8")
nodes = [
{"id": "a", "label": "ghost_func()", "file_type": "code", "source_file": "other.py"},
]
out = _by_label(_run([src], nodes, tmp_path))
# Not in the dispatched set -> #1895's domain, not evidence-binding's.
assert "confidence" not in out["ghost_func()"]
def test_uncheckable_short_label_is_not_flagged(tmp_path):
src = tmp_path / "mod.py"
src.write_text(_SOURCE, encoding="utf-8")
nodes = [
{"id": "a", "label": "id()", "file_type": "code", "source_file": "mod.py"},
]
out = _by_label(_run([src], nodes, tmp_path))
# "id" is < 3 chars, so there is no checkable identifier -> leave as-is.
assert "confidence" not in out["id()"]
def test_existing_lower_confidence_is_not_overwritten(tmp_path):
src = tmp_path / "mod.py"
src.write_text(_SOURCE, encoding="utf-8")
nodes = [
{"id": "a", "label": "made_up()", "file_type": "code", "source_file": "mod.py", "confidence": "INFERRED"},
]
out = _by_label(_run([src], nodes, tmp_path))
# The model already flagged it lower; the downgrade never clobbers that.
assert out["made_up()"]["confidence"] == "INFERRED"
def test_label_identifiers_helper():
assert llm._label_identifiers("foo()") == ["foo"]
assert llm._label_identifiers("Cls.method(x)") == ["Cls", "method"]
assert llm._label_identifiers("id()") == [] # all tokens < 3 chars
assert llm._label_identifiers("") == []
def test_bind_node_evidence_returns_downgrade_count(tmp_path):
src = tmp_path / "mod.py"
src.write_text(_SOURCE, encoding="utf-8")
result = {
"nodes": [
{"id": "a", "label": "real_function()", "file_type": "code", "source_file": "mod.py"},
{"id": "b", "label": "fake_one()", "file_type": "code", "source_file": "mod.py"},
{"id": "c", "label": "fake_two()", "file_type": "code", "source_file": "mod.py"},
],
}
downgraded = llm._bind_node_evidence(result, [src], tmp_path)
assert downgraded == 2
def test_evidence_binding_handles_file_slice(tmp_path):
# A slice reports its PARENT file as source_file; verification runs against
# the slice bytes the model actually saw.
from graphify.file_slice import FileSlice
src = tmp_path / "big.md"
src.write_text("intro\n" + _SOURCE + "\ntail\n", encoding="utf-8")
text = src.read_text()
fs = FileSlice(path=src, start=0, end=len(text), index=0, total=1)
result = {
"nodes": [
{"id": "a", "label": "real_function()", "file_type": "code", "source_file": "big.md"},
{"id": "b", "label": "ghost_symbol()", "file_type": "code", "source_file": "big.md"},
],
}
n = llm._bind_node_evidence(result, [fs], tmp_path)
by = {x["label"]: x for x in result["nodes"]}
assert n == 1
assert "confidence" not in by["real_function()"]
assert by["ghost_symbol()"]["confidence"] == "UNVERIFIED"
def test_evidence_binding_handles_absolute_source_file(tmp_path):
src = tmp_path / "mod.py"
src.write_text(_SOURCE, encoding="utf-8")
# Model returns an absolute source_file rather than a root-relative one.
result = {"nodes": [
{"id": "a", "label": "ghost_symbol()", "file_type": "code", "source_file": str(src)},
]}
n = llm._bind_node_evidence(result, [src], tmp_path)
assert n == 1
assert result["nodes"][0]["confidence"] == "UNVERIFIED"
def test_downgrade_emits_stderr_summary(tmp_path, capsys):
src = tmp_path / "mod.py"
src.write_text(_SOURCE, encoding="utf-8")
nodes = [{"id": "b", "label": "totally_made_up_symbol()", "file_type": "code", "source_file": "mod.py"}]
_run([src], nodes, tmp_path)
err = capsys.readouterr().err
assert "UNVERIFIED" in err
def test_unverified_confidence_does_not_fail_validation():
# The downgrade must never make an otherwise-valid node fail validation
# (node-level confidence is not part of the validated schema).
from graphify.validate import validate_extraction
extraction = {
"nodes": [{"id": "n1", "label": "foo", "file_type": "code",
"source_file": "a.md", "confidence": "UNVERIFIED"}],
"edges": [],
}
errors = validate_extraction(extraction)
assert not any("confidence" in str(e).lower() for e in errors)