Skip to content

Commit eac87c6

Browse files
authored
Merge pull request #21 from hallelx2/feat/confidence-and-abstention
feat: per-pick confidence scores + abstention (Phase 2.4)
2 parents cd42ae8 + 666a74b commit eac87c6

14 files changed

Lines changed: 1456 additions & 87 deletions

cmd/engine/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ func run() error {
209209
ReRanker: reRanker,
210210
ReRank: cfg.Retrieval.ReRank,
211211
Replay: replayStore,
212+
Abstain: cfg.Retrieval.Abstain,
212213
}
213214

214215
srv := &http.Server{

config.example.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,34 @@ retrieval:
182182
# re-rank pass to do the final selection.
183183
top_k: 0
184184

185+
# abstain: Phase 2.4 abstention. When the selection LLM returns
186+
# per-pick confidence scores (the new picks shape) and every
187+
# confidence falls below `below`, /v1/query and /v1/answer skip the
188+
# normal path and return an abstention response instead:
189+
# {abstained: true, abstention_reason: "...", sections: [],
190+
# min_confidence_threshold: 0.4, candidate_confidences: {...}}
191+
# For /v1/answer the synthesis call is skipped entirely; the answer
192+
# is the honest "I cannot answer this question from the supplied
193+
# document." This trades a likely hallucination for a clear refusal
194+
# when the engine's own confidence is weak.
195+
#
196+
# OPT-OUT. Default enabled. Per-request `enable_abstain` body field
197+
# overrides this block. When the selection LLM returns the legacy
198+
# shape (no confidence scores) the engine never abstains regardless
199+
# of this setting — abstention requires explicit confidence signal.
200+
#
201+
# The check is "all picks below threshold". If any pick scored
202+
# above, the engine surfaces that section as evidence — abstention
203+
# is reserved for the case where every candidate is weak.
204+
abstain:
205+
enabled: true
206+
# Confidence threshold in [0.0, 1.0]. Picks with confidence
207+
# strictly less than this are "not confident"; when ALL picks
208+
# fall below, the response is an abstention. 0.4 is the default
209+
# — high enough to filter weak matches, low enough not to
210+
# suppress legitimate partial answers.
211+
below: 0.4
212+
185213
# replay: Phase 3.1 reproducibility store. Every /v1/query and
186214
# /v1/answer response carries a deterministic `trace_token`; the
187215
# response body is stored in an in-memory LRU under that token so

internal/api/abstention_test.go

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"io"
8+
"log/slog"
9+
"net/http"
10+
"net/http/httptest"
11+
"strings"
12+
"sync/atomic"
13+
"testing"
14+
"time"
15+
16+
"github.com/go-chi/chi/v5"
17+
"github.com/hallelx2/llmgate"
18+
19+
"github.com/hallelx2/vectorless-engine/pkg/config"
20+
"github.com/hallelx2/vectorless-engine/pkg/retrieval"
21+
"github.com/hallelx2/vectorless-engine/pkg/tree"
22+
)
23+
24+
// TestShouldAbstainAllBelow: every confidence under threshold → abstain.
25+
func TestShouldAbstainAllBelow(t *testing.T) {
26+
t.Parallel()
27+
confidences := map[tree.SectionID]float64{"sec_a": 0.1, "sec_b": 0.2, "sec_c": 0.39}
28+
if !shouldAbstain(confidences, 0.4) {
29+
t.Error("all confidences below 0.4 must trigger abstention")
30+
}
31+
}
32+
33+
// TestShouldAbstainOneAbove: any confidence at-or-above threshold → no abstain.
34+
// The "all picks below" semantics is the spec's choice: if even one
35+
// section has signal, surface it as evidence.
36+
func TestShouldAbstainOneAbove(t *testing.T) {
37+
t.Parallel()
38+
confidences := map[tree.SectionID]float64{"sec_a": 0.1, "sec_b": 0.45}
39+
if shouldAbstain(confidences, 0.4) {
40+
t.Error("one pick at 0.45 should suppress abstention even when peers are low")
41+
}
42+
}
43+
44+
// TestShouldAbstainBoundary: confidence == threshold counts as "above" so
45+
// the engine is generous about evidence; the threshold is strict-below.
46+
func TestShouldAbstainBoundary(t *testing.T) {
47+
t.Parallel()
48+
confidences := map[tree.SectionID]float64{"sec_a": 0.4}
49+
if shouldAbstain(confidences, 0.4) {
50+
t.Error("confidence == threshold must NOT trigger abstention (strict-below)")
51+
}
52+
}
53+
54+
// TestShouldAbstainNilOrEmpty: missing confidence signal never abstains.
55+
// This is the contract that keeps legacy-shape LLM responses working
56+
// — the engine cannot abstain when it has no confidence to evaluate.
57+
func TestShouldAbstainNilOrEmpty(t *testing.T) {
58+
t.Parallel()
59+
if shouldAbstain(nil, 0.4) {
60+
t.Error("nil confidences must NOT trigger abstention")
61+
}
62+
if shouldAbstain(map[tree.SectionID]float64{}, 0.4) {
63+
t.Error("empty confidences must NOT trigger abstention")
64+
}
65+
}
66+
67+
// TestFilterConfidencesToIDsHappy verifies the helper restricts
68+
// surfaced confidences to the IDs the response actually carries (post
69+
// max_sections / re-rank truncation).
70+
func TestFilterConfidencesToIDs(t *testing.T) {
71+
t.Parallel()
72+
src := map[tree.SectionID]float64{"a": 0.1, "b": 0.5, "c": 0.9}
73+
got := filterConfidencesToIDs(src, []tree.SectionID{"a", "c"})
74+
if len(got) != 2 {
75+
t.Fatalf("filtered length = %d, want 2", len(got))
76+
}
77+
if got["a"] != 0.1 || got["c"] != 0.9 {
78+
t.Errorf("filtered = %v", got)
79+
}
80+
if _, present := got["b"]; present {
81+
t.Error("b should have been filtered out")
82+
}
83+
}
84+
85+
// TestFilterConfidencesNilStaysNil preserves the "no signal" sentinel
86+
// across the helper.
87+
func TestFilterConfidencesNilStaysNil(t *testing.T) {
88+
t.Parallel()
89+
if got := filterConfidencesToIDs(nil, []tree.SectionID{"a"}); got != nil {
90+
t.Errorf("nil input must produce nil output, got %v", got)
91+
}
92+
// All keys filtered out → nil too.
93+
if got := filterConfidencesToIDs(map[tree.SectionID]float64{"x": 0.5}, []tree.SectionID{"a"}); got != nil {
94+
t.Errorf("empty filtered result must produce nil, got %v", got)
95+
}
96+
}
97+
98+
// TestStringKeyedConfidencesShape: the helper converts the typed map
99+
// to JSON-friendly string keys for the wire response.
100+
func TestStringKeyedConfidences(t *testing.T) {
101+
t.Parallel()
102+
got := stringKeyedConfidences(map[tree.SectionID]float64{"sec_a": 0.7})
103+
if got["sec_a"] != 0.7 {
104+
t.Errorf("converted map should preserve the value, got %v", got)
105+
}
106+
if stringKeyedConfidences(nil) != nil {
107+
t.Error("nil input must produce nil")
108+
}
109+
}
110+
111+
// TestAbstentionEnabledOverride: per-request body field wins over server config.
112+
func TestAbstentionEnabledOverride(t *testing.T) {
113+
t.Parallel()
114+
d := Deps{Abstain: config.AbstainBlock{Enabled: false}}
115+
if !d.abstentionEnabled(boolPtr(true)) {
116+
t.Error("body=true should override server=false")
117+
}
118+
d2 := Deps{Abstain: config.AbstainBlock{Enabled: true}}
119+
if d2.abstentionEnabled(boolPtr(false)) {
120+
t.Error("body=false should override server=true")
121+
}
122+
}
123+
124+
// TestAbstentionEnabledFallsBackToConfig: when the body field is nil,
125+
// the server config decides.
126+
func TestAbstentionEnabledFallsBackToConfig(t *testing.T) {
127+
t.Parallel()
128+
d := Deps{Abstain: config.AbstainBlock{Enabled: true}}
129+
if !d.abstentionEnabled(nil) {
130+
t.Error("nil body should fall back to server=true")
131+
}
132+
d2 := Deps{Abstain: config.AbstainBlock{Enabled: false}}
133+
if d2.abstentionEnabled(nil) {
134+
t.Error("nil body should fall back to server=false")
135+
}
136+
}
137+
138+
// --- Integration-style tests against handleQuery / handleAnswer ---
139+
//
140+
// These exercise the response-shape contracts: that all-low
141+
// confidences yield an abstained response; that mixed
142+
// (some-above-threshold) confidences yield a normal response; and
143+
// that legacy responses (no confidences) never abstain.
144+
145+
// stubStrategy is a CostStrategy that returns canned IDs +
146+
// confidences without touching any LLM.
147+
type stubStrategy struct {
148+
ids []tree.SectionID
149+
confidences map[tree.SectionID]float64
150+
usage retrieval.Usage
151+
calls int32
152+
}
153+
154+
func (s *stubStrategy) Name() string { return "stub" }
155+
156+
func (s *stubStrategy) Select(ctx context.Context, t *tree.Tree, query string, budget retrieval.ContextBudget) ([]tree.SectionID, error) {
157+
atomic.AddInt32(&s.calls, 1)
158+
return s.ids, nil
159+
}
160+
161+
func (s *stubStrategy) SelectWithCost(ctx context.Context, t *tree.Tree, query string, budget retrieval.ContextBudget) (*retrieval.Result, error) {
162+
atomic.AddInt32(&s.calls, 1)
163+
return &retrieval.Result{
164+
SelectedIDs: s.ids,
165+
Confidences: s.confidences,
166+
Usage: s.usage,
167+
HopsTaken: 1,
168+
}, nil
169+
}
170+
171+
// abstentionRouter wires only handleQuery / handleAnswer. We mock the
172+
// strategy and bypass DB by passing a tiny in-memory tree-loader
173+
// stub. The simplest way is to give the handler a Strategy that
174+
// short-circuits before any storage read — done by also stubbing
175+
// the storage to return empty content.
176+
func abstentionRouter(d Deps) http.Handler {
177+
r := chi.NewRouter()
178+
r.Route("/v1", func(r chi.Router) {
179+
r.Post("/query", d.handleQuery)
180+
r.Post("/answer", d.handleAnswer)
181+
})
182+
return r
183+
}
184+
185+
// TestHandleQueryAbstainsOnAllLow: every confidence below threshold →
186+
// the response is the abstention shape with sections=[] and
187+
// abstained=true.
188+
//
189+
// We cannot run handleQuery without a DB-backed tree loader; instead,
190+
// this test calls the helper functions on a Deps struct as the
191+
// handler would, asserting the shape.
192+
func TestRespondAbstained(t *testing.T) {
193+
t.Parallel()
194+
d := Deps{
195+
Strategy: &stubStrategy{ids: []tree.SectionID{"sec_a"}},
196+
Abstain: config.AbstainBlock{Enabled: true, Below: 0.4},
197+
}
198+
confidences := map[tree.SectionID]float64{"sec_a": 0.12, "sec_b": 0.30}
199+
200+
rec := httptest.NewRecorder()
201+
d.respondAbstained(rec, tree.DocumentID("doc_x"), "what is x?", confidences, nil)
202+
203+
if rec.Code != http.StatusOK {
204+
t.Fatalf("status = %d, want 200", rec.Code)
205+
}
206+
var body map[string]any
207+
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
208+
t.Fatal(err)
209+
}
210+
if v, _ := body["abstained"].(bool); !v {
211+
t.Error("response must carry abstained=true")
212+
}
213+
if v, _ := body["abstention_reason"].(string); !strings.Contains(v, "confidence") {
214+
t.Errorf("abstention_reason missing 'confidence': %q", v)
215+
}
216+
if v, _ := body["min_confidence_threshold"].(float64); v != 0.4 {
217+
t.Errorf("min_confidence_threshold = %v, want 0.4", v)
218+
}
219+
if v, _ := body["sections"].([]any); len(v) != 0 {
220+
t.Errorf("sections must be empty, got %v", v)
221+
}
222+
cc, ok := body["candidate_confidences"].(map[string]any)
223+
if !ok {
224+
t.Fatal("candidate_confidences missing")
225+
}
226+
if cc["sec_a"] != 0.12 {
227+
t.Errorf("sec_a confidence = %v, want 0.12", cc["sec_a"])
228+
}
229+
}
230+
231+
// TestRespondAbstainedAnswer: same shape on /v1/answer. The synthesis
232+
// call is skipped — answer is the canonical refusal string, citations
233+
// is empty.
234+
func TestRespondAbstainedAnswer(t *testing.T) {
235+
t.Parallel()
236+
d := Deps{
237+
Strategy: &stubStrategy{ids: []tree.SectionID{"sec_a"}},
238+
Abstain: config.AbstainBlock{Enabled: true, Below: 0.4},
239+
Logger: slog.Default(),
240+
}
241+
confidences := map[tree.SectionID]float64{"sec_a": 0.1}
242+
usage := retrieval.Usage{InputTokens: 100, OutputTokens: 20, TotalTokens: 120, LLMCalls: 2}
243+
244+
rec := httptest.NewRecorder()
245+
d.respondAbstainedAnswer(rec, tree.DocumentID("doc_x"), "q", confidences, nil, usage, time.Now())
246+
247+
if rec.Code != http.StatusOK {
248+
t.Fatalf("status = %d, want 200", rec.Code)
249+
}
250+
var body map[string]any
251+
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
252+
t.Fatal(err)
253+
}
254+
if v, _ := body["abstained"].(bool); !v {
255+
t.Error("answer response must carry abstained=true")
256+
}
257+
if v, _ := body["answer"].(string); !strings.Contains(v, "cannot answer") {
258+
t.Errorf("answer must be the canonical refusal, got %q", v)
259+
}
260+
if v, _ := body["citations"].([]any); len(v) != 0 {
261+
t.Errorf("citations must be empty, got %v", v)
262+
}
263+
// Usage carried through (planning + retrieval — no synthesis).
264+
if u, ok := body["usage"].(map[string]any); !ok {
265+
t.Error("usage block missing")
266+
} else if u["llm_calls"].(float64) != 2 {
267+
t.Errorf("usage.llm_calls = %v, want 2", u["llm_calls"])
268+
}
269+
}
270+
271+
// TestRespondAbstainedTraceTokenAbsent: replay isn't meaningful for
272+
// an abstention (the engine produced no retrieval result); the
273+
// response must NOT carry a trace_token so callers don't try to
274+
// replay nothing.
275+
func TestRespondAbstainedTraceTokenAbsent(t *testing.T) {
276+
t.Parallel()
277+
d := Deps{
278+
Strategy: &stubStrategy{},
279+
Abstain: config.AbstainBlock{Enabled: true, Below: 0.4},
280+
}
281+
rec := httptest.NewRecorder()
282+
d.respondAbstained(rec, tree.DocumentID("doc_x"), "q", map[tree.SectionID]float64{"a": 0.1}, nil)
283+
284+
var body map[string]any
285+
_ = json.Unmarshal(rec.Body.Bytes(), &body)
286+
if _, has := body["trace_token"]; has {
287+
t.Error("abstention response must NOT carry trace_token")
288+
}
289+
}
290+
291+
// boolPtr is a tiny helper for the body-override tests.
292+
func boolPtr(b bool) *bool { return &b }
293+
294+
// --- end-to-end through ServeHTTP without DB ---
295+
//
296+
// To exercise handleQuery / handleAnswer end-to-end we'd need a
297+
// db.Pool. Instead we cover the in-handler logic by directly calling
298+
// the helpers above (which is what the handler itself does on the
299+
// abstention path) and by running the predicate tests through the
300+
// handler-facing entrypoint via shouldAbstain + abstentionEnabled.
301+
// A future test pass with a real test DB will exercise the full
302+
// stack — for now, the abstention contract is unit-tested at the
303+
// helper boundary, which is the only place the contract lives.
304+
305+
// mockLLMNeverCalled fails the test loudly if any LLM call lands.
306+
// Used as a tripwire in the abstention path: synthesis must NOT
307+
// run when /v1/answer abstains.
308+
type mockLLMNeverCalled struct{ t *testing.T }
309+
310+
func (m mockLLMNeverCalled) Complete(ctx context.Context, req llmgate.Request) (*llmgate.Response, error) {
311+
m.t.Error("LLM should not be called on the abstention path")
312+
return &llmgate.Response{Content: ""}, nil
313+
}
314+
315+
func (m mockLLMNeverCalled) CountTokens(ctx context.Context, s string) (int, error) {
316+
return len(s) / 4, nil
317+
}
318+
319+
// TestRespondAbstainedAnswerSkipsSynthesis: the /v1/answer abstention
320+
// helper must not invoke the LLM. We pass an LLM that explodes on
321+
// any call so we'd see the test fail if synthesis leaks through.
322+
func TestRespondAbstainedAnswerSkipsSynthesis(t *testing.T) {
323+
t.Parallel()
324+
d := Deps{
325+
Strategy: &stubStrategy{},
326+
Abstain: config.AbstainBlock{Enabled: true, Below: 0.4},
327+
LLM: mockLLMNeverCalled{t: t},
328+
}
329+
rec := httptest.NewRecorder()
330+
d.respondAbstainedAnswer(rec, tree.DocumentID("doc_x"), "q", map[tree.SectionID]float64{"a": 0.1}, nil, retrieval.Usage{}, time.Now())
331+
if rec.Code != http.StatusOK {
332+
t.Fatalf("status = %d, want 200", rec.Code)
333+
}
334+
}
335+
336+
// (Imports that won't otherwise be referenced by every test file go
337+
// through small uses below so go vet is happy.)
338+
var _ = bytes.NewReader
339+
var _ = io.EOF
340+
var _ = abstentionRouter

0 commit comments

Comments
 (0)