Skip to content

Commit 9d5f60b

Browse files
cdeustclaude
andcommitted
feat(graph): Gap 6 — confidence + reason on WorkflowEdge
Every edge in the workflow graph now carries two optional fields: * ``confidence: float | None`` (range-checked 0.0–1.0 via pydantic) * ``reason: str | None`` — short provenance tag The detail-panel renders a percent chip + reason tag for heuristic edges (calls / imports / unresolved) so a viewer can tell a resolved import from a same-name guess at a glance. Structural defaults ("100% direct-ast") are suppressed — they'd just be noise on every defined_in edge. Producer plumbing * ``workflow_graph_source_ast._run_edge`` fetches AP's ``r.confidence`` + ``r.resolution_method`` via Cypher, gated by a new ``has_provenance: bool`` arg so HasMethod_* tables (no confidence column) don't trigger Kuzu's Binder exception. * AP quotes ``resolution_method`` as a literal string in resolver.rs:183 (``format!("'{method}'")``), so we strip the quote chars at the infrastructure boundary. Convention-based defaults (one source of truth) ``core.workflow_graph_schema.edge_provenance_defaults(kind, ap_confidence=None, ap_reason=None)`` is the single place that maps an edge kind to its default (confidence, reason) pair: * ``defined_in`` / ``member_of`` → (1.0, "direct-ast") * ``about_entity`` → (1.0, "memory-entities-link") * ``calls`` / ``imports`` → AP-supplied values or (None, None) Four ingest sites (``ingest_symbol``, ``ingest_ast_edge``, ``ingest_about_entity``, and the parallel inline L6 path in ``http_standalone_graph``) all call the helper, eliminating the DRY violation the audit flagged. Tests 15 new unit tests in ``tests_py/core/test_workflow_edge_confidence.py``: * Schema round-trip and pydantic range check (raises on 1.5 / -0.3). * ``edge_provenance_defaults`` — structural defaults, heuristic pass-through, empty-string reason normalises to None (cross-path parity), AP confidence of 0.0 is preserved (not treated as missing). * Ingestor tagging — defined_in / member_of / calls paths. * Validate-graph tolerance of mixed-confidence edges. Existing 1902 tests unchanged → 1910 passing total. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5a4278e commit 9d5f60b

9 files changed

Lines changed: 452 additions & 11 deletions

mcp_server/core/workflow_graph_builder_relational.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
ToolKind,
3030
WorkflowEdge,
3131
WorkflowNode,
32+
edge_provenance_defaults,
3233
)
3334
from mcp_server.core.workflow_graph_schema_enums import PrimaryToolCluster
3435

@@ -330,11 +331,15 @@ def ingest_symbol(b, sym: dict) -> None:
330331
line=sym.get("line"),
331332
path=str(file_path),
332333
)
334+
# Gap 6: defaults from the central provenance table.
335+
conf, reason = edge_provenance_defaults(EdgeKind.DEFINED_IN.value)
333336
b._edges.append(
334337
WorkflowEdge(
335338
source=sid,
336339
target=fid,
337340
kind=EdgeKind.DEFINED_IN,
341+
confidence=conf,
342+
reason=reason,
338343
)
339344
)
340345

@@ -370,11 +375,19 @@ def ingest_ast_edge(b, edge: dict) -> None:
370375
return
371376
edge_kind = EdgeKind.CALLS if kind == "calls" else EdgeKind.MEMBER_OF
372377

378+
# Gap 6: central provenance defaults — single source of truth.
379+
conf, reason = edge_provenance_defaults(
380+
edge_kind.value,
381+
ap_confidence=edge.get("confidence"),
382+
ap_reason=edge.get("reason"),
383+
)
373384
b._edges.append(
374385
WorkflowEdge(
375386
source=src_id,
376387
target=dst_id,
377388
kind=edge_kind,
389+
confidence=conf,
390+
reason=reason,
378391
)
379392
)
380393

mcp_server/core/workflow_graph_entity.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
NodeIdFactory,
2727
NodeKind,
2828
WorkflowEdge,
29+
edge_provenance_defaults,
2930
)
3031

3132

@@ -82,11 +83,15 @@ def ingest_about_entity(b, link: dict) -> None:
8283
ent_id = NodeIdFactory.entity_id(ent_pg)
8384
if mem_id not in b._nodes or ent_id not in b._nodes:
8485
return
86+
# Gap 6: shared provenance defaults.
87+
conf, reason = edge_provenance_defaults(EdgeKind.ABOUT_ENTITY.value)
8588
b._edges.append(
8689
WorkflowEdge(
8790
source=mem_id,
8891
target=ent_id,
8992
kind=EdgeKind.ABOUT_ENTITY,
93+
confidence=conf,
94+
reason=reason,
9095
)
9196
)
9297

mcp_server/core/workflow_graph_schema.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,20 @@ class WorkflowNode(BaseModel):
8383

8484

8585
class WorkflowEdge(BaseModel):
86-
"""A directed edge in the workflow graph."""
86+
"""A directed edge in the workflow graph.
87+
88+
``confidence`` (0.0–1.0) signals how trustworthy the edge is: 1.0 for
89+
edges derived from direct AST facts (``defined_in``, ``member_of``),
90+
≤0.9 for heuristic resolution (unqualified call target), and lower
91+
for inferred or cross-file edges. Callers that don't compute a
92+
confidence leave the field ``None``.
93+
94+
``reason`` is a short free-form tag describing WHY the edge was
95+
emitted — e.g. ``"direct-ast"``, ``"import-scope-lookup"``,
96+
``"same-file-fallback"``, ``"heat-link"``. The renderer surfaces it
97+
in the detail panel so a reader can tell a structural fact from a
98+
statistical hint without opening the source.
99+
"""
87100

88101
model_config = ConfigDict(extra="ignore", use_enum_values=True)
89102

@@ -92,6 +105,11 @@ class WorkflowEdge(BaseModel):
92105
kind: EdgeKind
93106
weight: float = 1.0
94107
label: str | None = None
108+
# ``ge=0.0, le=1.0`` is a contract the renderer relies on — any
109+
# producer emitting a value outside [0, 1] is a bug and pydantic
110+
# raises at construction so the drift is caught immediately.
111+
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
112+
reason: str | None = None
95113

96114

97115
# ── Deterministic ID factory ───────────────────────────────────────────
@@ -162,6 +180,50 @@ def entity_id(pg_id: str | int) -> str:
162180
return f"entity:{pg_id}"
163181

164182

183+
# ── Edge provenance defaults (Gap 6) ──────────────────────────────────
184+
185+
186+
# Convention — NOT measured constants. Structural AST facts (symbol →
187+
# file definition, method-of-class containment) are ground-truth by
188+
# definition: the parser either sees them or it doesn't. ``about_entity``
189+
# links are materialised from the persisted ``memory_entities`` join
190+
# table so they are equally definitive. Heuristic edges (``calls`` /
191+
# ``imports``) carry the resolver's actual confidence score or ``None``
192+
# when AP didn't emit one.
193+
_STRUCTURAL_DEFAULTS: dict[str, tuple[float, str]] = {
194+
"defined_in": (1.0, "direct-ast"),
195+
"member_of": (1.0, "direct-ast"),
196+
"about_entity": (1.0, "memory-entities-link"),
197+
}
198+
199+
200+
def edge_provenance_defaults(
201+
edge_kind: str,
202+
ap_confidence: float | None = None,
203+
ap_reason: str | None = None,
204+
) -> tuple[float | None, str | None]:
205+
"""Return the (confidence, reason) pair for an edge of ``edge_kind``.
206+
207+
Producer-supplied AP values win: if ``ap_confidence`` or
208+
``ap_reason`` is given, they are preserved verbatim. Otherwise the
209+
structural defaults in ``_STRUCTURAL_DEFAULTS`` apply. Edges whose
210+
kind isn't in that table (currently ``calls`` / ``imports``) keep
211+
``None`` when AP didn't annotate — they are heuristic and their
212+
absence of confidence is itself information.
213+
214+
An empty-string reason is normalised to ``None`` so the builder
215+
path and the parallel inline path in ``http_standalone_graph``
216+
never disagree on its shape.
217+
"""
218+
kind_str = str(edge_kind)
219+
default_conf, default_reason = _STRUCTURAL_DEFAULTS.get(kind_str, (None, None))
220+
confidence = ap_confidence if ap_confidence is not None else default_conf
221+
reason = ap_reason if ap_reason else default_reason
222+
if reason == "":
223+
reason = None
224+
return confidence, reason
225+
226+
165227
# ── Validation (meta-rules that decide well-formedness) ────────────────
166228

167229

mcp_server/infrastructure/workflow_graph_source_ast.py

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -537,15 +537,38 @@ def _match(file_part: str) -> bool:
537537
for p in path_tails
538538
)
539539

540-
async def _run_edge(kind: str, table: str, src_lbl: str, dst_lbl: str):
540+
async def _run_edge(
541+
kind: str,
542+
table: str,
543+
src_lbl: str,
544+
dst_lbl: str,
545+
has_provenance: bool,
546+
):
547+
"""Query AP for edges of ``kind`` in ``table``.
548+
549+
``has_provenance`` gates whether to fetch ``r.confidence`` +
550+
``r.resolution_method``: Kuzu raises a Binder exception on
551+
missing-property access, so we only request those columns
552+
for rel tables the AP resolver actually annotates (Calls_*
553+
/ Imports_* / Implements_* / Extends_* / Uses_*). Structural
554+
tables (HasMethod_* / Defines_*) have no such columns —
555+
callers default confidence to 1.0 for those kinds instead.
556+
"""
541557
if src_lbl == "File":
542558
select_src = "src.id AS src_name"
543559
else:
544560
select_src = "src.qualified_name AS src_name"
561+
if has_provenance:
562+
return_tail = (
563+
" dst.qualified_name AS dst_name, "
564+
" r.confidence AS confidence, "
565+
" r.resolution_method AS reason"
566+
)
567+
else:
568+
return_tail = " dst.qualified_name AS dst_name"
545569
query = (
546-
f"MATCH (src:{src_lbl})-[:{table}]->(dst:{dst_lbl}) "
547-
f"RETURN {select_src}, "
548-
" dst.qualified_name AS dst_name"
570+
f"MATCH (src:{src_lbl})-[r:{table}]->(dst:{dst_lbl}) "
571+
f"RETURN {select_src}, {return_tail}"
549572
)
550573
rows = await self._bridge.call(
551574
"query_graph",
@@ -570,22 +593,41 @@ async def _run_edge(kind: str, table: str, src_lbl: str, dst_lbl: str):
570593
else:
571594
if not (_match(src_file) and _match(dst_file)):
572595
continue
596+
# AP stores ``resolution_method`` wrapped in literal
597+
# single quotes (see ``automatised-pipeline``
598+
# resolver.rs:183 — ``format!("'{method}'")``), so the
599+
# value comes back INCLUDING quotes. Strip them here at
600+
# the infrastructure boundary. Remove this strip once
601+
# AP fixes the upstream quoting.
602+
conf_raw = r.get("confidence") if has_provenance else None
603+
try:
604+
confidence = float(conf_raw) if conf_raw is not None else None
605+
except (TypeError, ValueError):
606+
confidence = None
607+
reason_raw = r.get("reason") if has_provenance else None
608+
reason_str = (
609+
str(reason_raw).strip("'\"") or None if reason_raw else None
610+
)
573611
out.append(
574612
{
575613
"kind": kind,
576614
"src_file": src_file,
577615
"src_name": src_qn,
578616
"dst_file": dst_file,
579617
"dst_name": dst,
618+
"confidence": confidence,
619+
"reason": reason_str,
580620
}
581621
)
582622

583623
for s, d in calls_rels:
584-
await _run_edge("calls", f"Calls_{s}_{d}", s, d)
624+
await _run_edge("calls", f"Calls_{s}_{d}", s, d, has_provenance=True)
585625
for s, d in imports_rels:
586-
await _run_edge("imports", f"Imports_{s}_{d}", s, d)
626+
await _run_edge("imports", f"Imports_{s}_{d}", s, d, has_provenance=True)
587627
for s, d in member_rels:
588-
await _run_edge("member_of", f"HasMethod_{s}_{d}", s, d)
628+
await _run_edge(
629+
"member_of", f"HasMethod_{s}_{d}", s, d, has_provenance=False
630+
)
589631
return out
590632

591633

mcp_server/server/http_standalone_graph.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,10 @@ def _edges_for(node_ids: set[str]):
543543
SYMBOL_COLOR_DEFAULT,
544544
SYMBOL_COLORS,
545545
)
546-
from mcp_server.core.workflow_graph_schema import NodeIdFactory
546+
from mcp_server.core.workflow_graph_schema import (
547+
NodeIdFactory,
548+
edge_provenance_defaults,
549+
)
547550
from mcp_server.infrastructure.ap_bridge import (
548551
is_enabled as _ap_enabled,
549552
resolve_graph_paths,
@@ -807,13 +810,17 @@ async def _load_with_timeout(gp_):
807810
)
808811
parent = file_id_by_path.get(fp)
809812
if parent:
813+
# Gap 6: shared provenance defaults.
814+
di_conf, di_reason = edge_provenance_defaults("defined_in")
810815
proj_edges.append(
811816
{
812817
"source": sid,
813818
"target": parent,
814819
"kind": "defined_in",
815820
"type": "defined_in",
816821
"weight": 1.0,
822+
"confidence": di_conf,
823+
"reason": di_reason,
817824
}
818825
)
819826
for e in edgs:
@@ -833,12 +840,20 @@ async def _load_with_timeout(gp_):
833840
if not sf or not sn:
834841
continue
835842
sid = NodeIdFactory.symbol_id(sf, sn)
843+
# Gap 6: single source-of-truth defaults.
844+
conf, reason_v = edge_provenance_defaults(
845+
kind,
846+
ap_confidence=e.get("confidence"),
847+
ap_reason=e.get("reason"),
848+
)
836849
edge = {
837850
"source": sid,
838851
"target": did,
839852
"kind": kind,
840853
"type": kind,
841854
"weight": 1.0,
855+
"confidence": conf,
856+
"reason": reason_v,
842857
}
843858
# Intra-project iff both endpoints (where they are
844859
# symbols) belong to THIS project. For `imports`

0 commit comments

Comments
 (0)