mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-27 00:36:39 +00:00
fix(extract): give AST INFERRED edges a rubric confidence_score (#2813)
AST-emitted INFERRED edges landed at the rubric-forbidden 0.5 (or a hardcoded 0.8). Each AST INFERRED emission site now sets a discrete rubric score keyed to the relation (uses -> 0.95 direct structural evidence, indirect_call and unresolved cross-file calls -> 0.85), and the INFERRED write-time default moves 0.5 -> 0.55 so any score-less INFERRED edge is on the rubric set. EXTRACTED / AMBIGUOUS tiers are unchanged; uses stays INFERRED (not promoted to EXTRACTED) to keep audit percentages honest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
74abbfa30c
commit
3bbf420bc3
+9
-1
@@ -157,7 +157,15 @@ from graphify.exporters.base import COMMUNITY_COLORS # noqa: E402,F401
|
||||
from graphify.exporters.html import to_html # noqa: E402,F401
|
||||
|
||||
|
||||
_CONFIDENCE_SCORE_DEFAULTS = {"EXTRACTED": 1.0, "INFERRED": 0.5, "AMBIGUOUS": 0.2}
|
||||
# Fallback scores for an edge that carries a confidence tier but no
|
||||
# confidence_score. The INFERRED default was 0.5, which references/extraction-spec.md
|
||||
# rules out in as many words — "never omit it, never use 0.5 as a default" — and
|
||||
# which is not in the discrete INFERRED set {0.55, 0.65, 0.75, 0.85, 0.95} either.
|
||||
# It is now the bottom of that set: a missing score is an absence of evidence
|
||||
# about strength, so the honest fallback is the weakest value the rubric allows,
|
||||
# not a midpoint that reads as a coin flip (#2813). Every AST emission site now
|
||||
# supplies its own score, so this is a backstop rather than a routine path.
|
||||
_CONFIDENCE_SCORE_DEFAULTS = {"EXTRACTED": 1.0, "INFERRED": 0.55, "AMBIGUOUS": 0.2}
|
||||
|
||||
|
||||
def attach_hyperedges(G: nx.Graph, hyperedges: list) -> None:
|
||||
|
||||
+8
-2
@@ -6494,7 +6494,11 @@ def extract(
|
||||
"relation": "indirect_call",
|
||||
"context": rc.get("context", "argument"),
|
||||
"confidence": "INFERRED",
|
||||
"confidence_score": 0.8,
|
||||
# 0.85, not 0.8: the rubric in references/extraction-spec.md
|
||||
# is a discrete set {0.55, 0.65, 0.75, 0.85, 0.95} and 0.8 is
|
||||
# not in it. Same tier, same meaning ("strong inference"),
|
||||
# now a value the documented scale actually contains (#2813).
|
||||
"confidence_score": 0.85,
|
||||
"source_file": rc.get("source_file", ""),
|
||||
"source_location": rc.get("source_location"),
|
||||
"weight": 1.0,
|
||||
@@ -6523,7 +6527,9 @@ def extract(
|
||||
confidence_score = 1.0
|
||||
else:
|
||||
confidence = "INFERRED"
|
||||
confidence_score = 0.8
|
||||
# 0.85 rather than 0.8 — the rubric's INFERRED set is discrete
|
||||
# and does not contain 0.8 (#2813).
|
||||
confidence_score = 0.85
|
||||
all_edges.append({
|
||||
"source": caller,
|
||||
"target": tgt,
|
||||
|
||||
@@ -4686,6 +4686,12 @@ def _extract_generic(
|
||||
"relation": "indirect_call",
|
||||
"context": context,
|
||||
"confidence": "INFERRED",
|
||||
# 0.85 = "strong inference" on the extraction-spec rubric. The symbol
|
||||
# link is direct — the function is named right here — but that it is
|
||||
# ever INVOKED is the inference, which is why this is not the 0.95
|
||||
# tier. Previously no score was emitted at all and the edge inherited
|
||||
# the 0.5 default the rubric forbids (#2813).
|
||||
"confidence_score": 0.85,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{loc_node.start_point[0] + 1}",
|
||||
"weight": 1.0,
|
||||
|
||||
@@ -2062,6 +2062,12 @@ def _resolve_cross_file_imports(
|
||||
"target": tgt_nid,
|
||||
"relation": "uses",
|
||||
"confidence": "INFERRED",
|
||||
# 0.95 = "direct structural evidence (named cross-file
|
||||
# reference)" from the extraction-spec rubric, which is
|
||||
# exactly what this edge is: a name this file imports, then
|
||||
# references. Omitting the score entirely fell through to the
|
||||
# 0.5 default the same rubric forbids outright (#2813).
|
||||
"confidence_score": 0.95,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
"weight": 0.8,
|
||||
|
||||
@@ -367,7 +367,9 @@ def resolve_cross_file_raw_calls(
|
||||
"relation": "calls",
|
||||
"context": "call",
|
||||
"confidence": "INFERRED",
|
||||
"confidence_score": 0.8,
|
||||
# 0.85, not 0.8 — the extraction-spec rubric's INFERRED values
|
||||
# are a discrete set and 0.8 is not one of them (#2813).
|
||||
"confidence_score": 0.85,
|
||||
"source_file": raw_call.get("source_file", ""),
|
||||
"source_location": raw_call.get("source_location"),
|
||||
"weight": 1.0,
|
||||
|
||||
@@ -130,7 +130,11 @@ def test_to_json_defaults_missing_confidence_score():
|
||||
links_by_conf[conf] = link.get("confidence_score")
|
||||
|
||||
assert links_by_conf.get("EXTRACTED") == 1.0, "EXTRACTED default should be 1.0"
|
||||
assert links_by_conf.get("INFERRED") == 0.5, "INFERRED default should be 0.5"
|
||||
# references/extraction-spec.md forbids 0.5 outright ("never use 0.5 as a
|
||||
# default") and its INFERRED set is {0.55, 0.65, 0.75, 0.85, 0.95}. A missing
|
||||
# score is an absence of evidence about strength, so the fallback is the
|
||||
# weakest value the rubric allows rather than a midpoint (#2813).
|
||||
assert links_by_conf.get("INFERRED") == 0.55, "INFERRED default should be 0.55"
|
||||
|
||||
|
||||
def test_report_shows_avg_confidence_for_inferred():
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Every INFERRED edge the AST extractor emits must carry a rubric score.
|
||||
|
||||
`references/extraction-spec.md` is explicit:
|
||||
|
||||
confidence_score is REQUIRED on every edge - never omit it, never use 0.5
|
||||
as a default
|
||||
- INFERRED edges: pick exactly ONE value from this set — never 0.5:
|
||||
0.95 / 0.85 / 0.75 / 0.65 / 0.55
|
||||
|
||||
The AST extractor honoured neither half. Some sites emitted no
|
||||
`confidence_score` at all, which fell through to `_CONFIDENCE_SCORE_DEFAULTS`
|
||||
and landed on exactly the 0.5 the rubric rules out; others hardcoded 0.8, which
|
||||
is not in the discrete set. On graphify's own package that was 128 of 128
|
||||
INFERRED edges — 54 missing a score and 74 at 0.8 (#2813).
|
||||
|
||||
The tiers are deliberately NOT changed here. The reporter's other option was to
|
||||
promote AST `uses` edges to EXTRACTED/1.0, which is a semantic judgement about
|
||||
what the extractor knows; snapping the scores onto the documented scale fixes
|
||||
the stated violation without making that call.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from graphify.export import _CONFIDENCE_SCORE_DEFAULTS
|
||||
|
||||
# The discrete INFERRED set from references/extraction-spec.md.
|
||||
RUBRIC = {0.55, 0.65, 0.75, 0.85, 0.95}
|
||||
|
||||
SRC = Path(__file__).resolve().parent.parent / "graphify"
|
||||
|
||||
|
||||
def _extract(tmp_path, name, body):
|
||||
from graphify.extract import extract
|
||||
f = tmp_path / name
|
||||
f.write_text(body, encoding="utf-8")
|
||||
return extract([f], root=tmp_path)
|
||||
|
||||
|
||||
def _inferred(res):
|
||||
return [e for e in res.get("edges", []) if e.get("confidence") == "INFERRED"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The default
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_inferred_default_is_not_the_forbidden_value():
|
||||
assert _CONFIDENCE_SCORE_DEFAULTS["INFERRED"] != 0.5
|
||||
|
||||
|
||||
def test_every_default_that_can_apply_to_an_inferred_edge_is_on_the_rubric():
|
||||
assert _CONFIDENCE_SCORE_DEFAULTS["INFERRED"] in RUBRIC
|
||||
|
||||
|
||||
def test_extracted_and_ambiguous_defaults_are_unchanged():
|
||||
"""The fix is scoped to INFERRED; the other two tiers keep their values."""
|
||||
assert _CONFIDENCE_SCORE_DEFAULTS["EXTRACTED"] == 1.0
|
||||
assert _CONFIDENCE_SCORE_DEFAULTS["AMBIGUOUS"] == 0.2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No emission site ships an off-rubric literal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("rel_path", [
|
||||
"extract.py",
|
||||
"symbol_resolution.py",
|
||||
"extractors/engine.py",
|
||||
"extractors/resolution.py",
|
||||
])
|
||||
def test_no_module_hardcodes_an_off_rubric_inferred_score(rel_path):
|
||||
"""0.8 was the value in the tree and is not on the scale. Catch it and the
|
||||
forbidden 0.5 as literals, so a future edit cannot reintroduce either."""
|
||||
text = (SRC / rel_path).read_text(encoding="utf-8")
|
||||
for forbidden in ('"confidence_score": 0.8,', '"confidence_score": 0.5,',
|
||||
"confidence_score = 0.8\n", "confidence_score = 0.5\n"):
|
||||
assert forbidden not in text, f"{rel_path} still emits {forbidden.strip()}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End to end
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_python_indirect_call_edges_carry_a_rubric_score(tmp_path):
|
||||
"""A function passed by name as an argument — the indirect_call path."""
|
||||
res = _extract(tmp_path, "m.py", (
|
||||
"def handler():\n return 1\n\n"
|
||||
"def register(cb):\n return cb\n\n"
|
||||
"def wire():\n return register(handler)\n"
|
||||
))
|
||||
inferred = _inferred(res)
|
||||
if not inferred:
|
||||
pytest.skip("this build produced no INFERRED edges for the fixture")
|
||||
for e in inferred:
|
||||
assert e.get("confidence_score") in RUBRIC, e
|
||||
|
||||
|
||||
def test_no_inferred_edge_is_left_without_a_score(tmp_path):
|
||||
res = _extract(tmp_path, "m.py", (
|
||||
"def handler():\n return 1\n\n"
|
||||
"def register(cb):\n return cb\n\n"
|
||||
"def wire():\n return register(handler)\n"
|
||||
))
|
||||
for e in _inferred(res):
|
||||
assert e.get("confidence_score") is not None, (
|
||||
f"edge would inherit the default instead of stating a score: {e}")
|
||||
|
||||
|
||||
def test_extracted_edges_still_score_one(tmp_path):
|
||||
"""The other half of the rubric must not drift while fixing this one."""
|
||||
res = _extract(tmp_path, "m.py", "def a():\n return 1\n\ndef b():\n return a()\n")
|
||||
for e in res.get("edges", []):
|
||||
if e.get("confidence") == "EXTRACTED" and "confidence_score" in e:
|
||||
assert e["confidence_score"] == 1.0, e
|
||||
@@ -70,7 +70,7 @@ def test_resolve_cross_file_raw_calls_emits_unique_unqualified_call() -> None:
|
||||
"relation": "calls",
|
||||
"context": "call",
|
||||
"confidence": "INFERRED",
|
||||
"confidence_score": 0.8,
|
||||
"confidence_score": 0.85, # rubric value; 0.8 is not on the scale (#2813)
|
||||
"source_file": "caller.py",
|
||||
"source_location": "L2",
|
||||
"weight": 1.0,
|
||||
|
||||
Reference in New Issue
Block a user