a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
249 lines
8.6 KiB
Go
249 lines
8.6 KiB
Go
package resolver
|
|
|
|
// LLM cleaning pass for low-confidence Temporal dispatch edges.
|
|
//
|
|
// PURPOSE: the AST layers deliberately over-produce — the generic "env"-name
|
|
// heuristic, the convention fallback, and the fuzzy matcher all mint edges at
|
|
// the speculative / inferred tier to maximise recall. This pass is the
|
|
// precision backstop: it asks an LLM, grounded in the real caller + candidate
|
|
// source, whether each such edge is a true dispatch, and then PROMOTES the
|
|
// confirmed ones (visible, high confidence), SUPPRESSES the rejected ones
|
|
// (hidden), and leaves the uncertain ones where they are.
|
|
//
|
|
// RATIONALE: per-edge LLM verification is expensive, but the verifiable set is
|
|
// tiny (only resolved temporal stubs at confidence ≤ 0.65 — dozens, not the
|
|
// whole graph), so the cost is bounded. The verifier and the source provider
|
|
// are injected interfaces, so the core is deterministic and unit-testable with
|
|
// a fake LLM; the real provider + caching adapter wraps this. Register-confirmed
|
|
// edges (0.9) are never touched — the blast radius is strictly the already
|
|
// uncertain band.
|
|
//
|
|
// KEYWORDS: temporal, llm, verify, false-positive, precision, clean
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// Confidence band the verifier operates in. Resolved-but-uncertain temporal
|
|
// stubs (heuristic env-default 0.4, fuzzy 0.5, convention / inferred env-default
|
|
// 0.6) fall in (0, 0.65]; register-confirmed 0.9 edges are above it and never
|
|
// verified.
|
|
const temporalVerifyMaxConfidence = 0.65
|
|
|
|
// Confidence stamped on an edge the LLM confirmed — above the inferred band so
|
|
// it surfaces by default, but below register-confirmed 0.9 (it is an LLM
|
|
// judgement over a heuristic, not a parsed registration).
|
|
const temporalVerifyConfirmedConfidence = 0.85
|
|
|
|
// Confidence stamped on an edge the LLM rejected — floored near zero and
|
|
// flagged speculative so it drops out of default queries without deleting the
|
|
// edge (the verdict + reason ride on its meta for audit).
|
|
const temporalVerifyRejectedConfidence = 0.1
|
|
|
|
// TemporalVerdict is the LLM's judgement on a single candidate dispatch edge.
|
|
type TemporalVerdict string
|
|
|
|
const (
|
|
TemporalVerdictConfirmed TemporalVerdict = "confirmed"
|
|
TemporalVerdictRejected TemporalVerdict = "rejected"
|
|
TemporalVerdictUncertain TemporalVerdict = "uncertain"
|
|
)
|
|
|
|
// TemporalVerifyRequest is the grounded context handed to the verifier for one
|
|
// candidate edge: the dispatch name + how it was recognised, plus the source of
|
|
// the calling workflow and the candidate activity / workflow it resolved to.
|
|
type TemporalVerifyRequest struct {
|
|
DispatchName string // the activity / workflow name being dispatched
|
|
Kind string // "activity" / "workflow"
|
|
Source string // how the AST resolved it: heuristic / convention / fuzzy / env_default / …
|
|
CallerName string
|
|
CallerSource string
|
|
TargetName string
|
|
TargetSource string
|
|
}
|
|
|
|
// TemporalVerifyResult is a single verdict plus a short human reason.
|
|
type TemporalVerifyResult struct {
|
|
Verdict TemporalVerdict
|
|
Reason string
|
|
}
|
|
|
|
// TemporalVerifier verifies one candidate dispatch edge. The real implementation
|
|
// calls an LLM with a grounded prompt and structured output; tests inject a fake.
|
|
type TemporalVerifier interface {
|
|
Verify(ctx context.Context, req TemporalVerifyRequest) (TemporalVerifyResult, error)
|
|
}
|
|
|
|
// TemporalSourceProvider returns the source text of a graph node (a workflow or
|
|
// activity function). The real implementation reads the node's file slice; tests
|
|
// inject an in-memory map.
|
|
type TemporalSourceProvider interface {
|
|
NodeSource(n *graph.Node) (string, bool)
|
|
}
|
|
|
|
// TemporalVerifyDetail records the outcome for one edge (for the report / audit).
|
|
type TemporalVerifyDetail struct {
|
|
From string
|
|
To string
|
|
Name string
|
|
Kind string
|
|
Source string
|
|
Verdict TemporalVerdict
|
|
Reason string
|
|
}
|
|
|
|
// TemporalVerifyReport summarises a verification run.
|
|
type TemporalVerifyReport struct {
|
|
Checked int
|
|
Confirmed int
|
|
Rejected int
|
|
Uncertain int
|
|
Errors int
|
|
Details []TemporalVerifyDetail
|
|
}
|
|
|
|
// temporalVerifiable reports whether an edge is an LLM-verification candidate: a
|
|
// resolved Temporal stub/link edge sitting in the uncertain confidence band.
|
|
func temporalVerifiable(e *graph.Edge) bool {
|
|
if e == nil || e.Meta == nil {
|
|
return false
|
|
}
|
|
via, _ := e.Meta["via"].(string)
|
|
if !strings.HasPrefix(via, "temporal.") {
|
|
return false
|
|
}
|
|
if e.To == "" || strings.HasPrefix(e.To, "unresolved::") {
|
|
return false // unresolved placeholder — nothing to verify
|
|
}
|
|
return e.Confidence > 0 && e.Confidence <= temporalVerifyMaxConfidence
|
|
}
|
|
|
|
// temporalEdgeSource recovers how an edge was resolved, for the prompt + report.
|
|
func temporalEdgeSource(e *graph.Edge) string {
|
|
if v, _ := e.Meta["temporal_env_source"].(string); v != "" {
|
|
return "env_default:" + v
|
|
}
|
|
if v, _ := e.Meta["temporal_resolution_via"].(string); v != "" {
|
|
return v
|
|
}
|
|
return "exact"
|
|
}
|
|
|
|
// VerifyTemporalEdges runs the LLM cleaning pass over every verifiable Temporal
|
|
// edge in g, mutating each edge's tier by the verdict and returning a report. It
|
|
// holds the graph's resolve mutex while mutating edge meta (mirroring
|
|
// ResolveTemporalCalls) so it is safe against concurrent meta readers. A
|
|
// verifier error leaves the edge untouched and is counted.
|
|
func VerifyTemporalEdges(ctx context.Context, g graph.Store, src TemporalSourceProvider, v TemporalVerifier) TemporalVerifyReport {
|
|
var report TemporalVerifyReport
|
|
if g == nil || src == nil || v == nil {
|
|
return report
|
|
}
|
|
|
|
// Phase 1 (locked): snapshot the candidate edges and their endpoint nodes,
|
|
// then release the resolve mutex. The LLM verification in phase 2 is slow
|
|
// and network-bound, so it must NOT run while holding the lock (that would
|
|
// stall every concurrent resolution / edit for the whole pass).
|
|
type candidate struct {
|
|
edge *graph.Edge
|
|
caller, target *graph.Node
|
|
}
|
|
mu := g.ResolveMutex()
|
|
mu.Lock()
|
|
var candidates []candidate
|
|
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
|
if !temporalVerifiable(e) {
|
|
continue
|
|
}
|
|
caller := g.GetNode(e.From)
|
|
target := g.GetNode(e.To)
|
|
if caller == nil || target == nil {
|
|
continue
|
|
}
|
|
candidates = append(candidates, candidate{edge: e, caller: caller, target: target})
|
|
}
|
|
mu.Unlock()
|
|
|
|
// Phase 2 (unlocked): read source and run the LLM verifier per candidate.
|
|
type verdictResult struct {
|
|
edge *graph.Edge
|
|
verdict TemporalVerdict
|
|
reason string
|
|
}
|
|
var results []verdictResult
|
|
for _, c := range candidates {
|
|
callerSrc, _ := src.NodeSource(c.caller)
|
|
targetSrc, _ := src.NodeSource(c.target)
|
|
name, _ := c.edge.Meta["temporal_name"].(string)
|
|
kind, _ := c.edge.Meta["temporal_kind"].(string)
|
|
source := temporalEdgeSource(c.edge)
|
|
|
|
report.Checked++
|
|
res, err := v.Verify(ctx, TemporalVerifyRequest{
|
|
DispatchName: name,
|
|
Kind: kind,
|
|
Source: source,
|
|
CallerName: c.caller.Name,
|
|
CallerSource: callerSrc,
|
|
TargetName: c.target.Name,
|
|
TargetSource: targetSrc,
|
|
})
|
|
if err != nil {
|
|
report.Errors++
|
|
continue
|
|
}
|
|
switch res.Verdict {
|
|
case TemporalVerdictConfirmed:
|
|
report.Confirmed++
|
|
case TemporalVerdictRejected:
|
|
report.Rejected++
|
|
default:
|
|
report.Uncertain++
|
|
}
|
|
report.Details = append(report.Details, TemporalVerifyDetail{
|
|
From: c.edge.From, To: c.edge.To, Name: name, Kind: kind,
|
|
Source: source, Verdict: res.Verdict, Reason: res.Reason,
|
|
})
|
|
results = append(results, verdictResult{edge: c.edge, verdict: res.Verdict, reason: res.Reason})
|
|
}
|
|
if len(results) == 0 {
|
|
return report
|
|
}
|
|
|
|
// Phase 3 (locked): apply the verdicts to the edges and PERSIST them via
|
|
// ReindexEdges, so the promotion / suppression survives on a disk-backed
|
|
// store (where EdgesByKind hands back decoded copies) — not just on the
|
|
// in-memory store.
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
batch := make([]graph.EdgeReindex, 0, len(results))
|
|
for _, r := range results {
|
|
e := r.edge
|
|
if e.Meta == nil {
|
|
e.Meta = map[string]any{}
|
|
}
|
|
e.Meta["temporal_llm_verdict"] = string(r.verdict)
|
|
if r.reason != "" {
|
|
e.Meta["temporal_llm_reason"] = r.reason
|
|
}
|
|
switch r.verdict {
|
|
case TemporalVerdictConfirmed:
|
|
e.Confidence = temporalVerifyConfirmedConfidence
|
|
e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, e.Confidence)
|
|
delete(e.Meta, graph.MetaSpeculative)
|
|
case TemporalVerdictRejected:
|
|
e.Confidence = temporalVerifyRejectedConfidence
|
|
e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, e.Confidence)
|
|
e.Meta[graph.MetaSpeculative] = true
|
|
}
|
|
// To is unchanged — OldTo == e.To — but ReindexEdges still persists the
|
|
// edge's mutated Confidence / Meta to the backend.
|
|
batch = append(batch, graph.EdgeReindex{Edge: e, OldTo: e.To})
|
|
}
|
|
g.ReindexEdges(batch)
|
|
return report
|
|
}
|