Skip to content

Commit 1492e6d

Browse files
committed
feat(retrieval): replay trace tokens stamped by every strategy
Every CostStrategy result now carries a deterministic trace token — sha256(doc_id | doc_version | model | system_prompt_version | sorted(selected_ids)) hex-encoded. Same inputs always produce the same 64-char hex string, regardless of reasoning path; permuted ID order is invariant. ComputeTraceToken is the canonical helper. SinglePass, ChunkedTree, and AgenticStrategy each call it before returning. The Cached wrapper re-derives the token on cache hits so the trace survives the cache layer (the token is a pure function of cached inputs). SystemPromptVersion ("v1") is bumped whenever a retrieval system prompt changes in a way that should invalidate replay; the constant is asserted in tests so the bump is a deliberate decision. A future phase will replace the placeholder doc_version "1" with real per-document versioning — the parameter is in the signature already so that's a one-line change.
1 parent 55dd9c1 commit 1492e6d

8 files changed

Lines changed: 322 additions & 4 deletions

File tree

pkg/retrieval/agentic.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ func (a *AgenticStrategy) SelectWithCost(ctx context.Context, t *tree.Tree, quer
184184
ModelUsed: model,
185185
Usage: totalUsage,
186186
HopsTaken: hopsTaken,
187+
TraceToken: ComputeTraceToken(t.DocumentID, traceDocVersionV1, model, finalIDs),
187188
}, nil
188189

189190
case actionOutline:
@@ -243,6 +244,7 @@ func (a *AgenticStrategy) SelectWithCost(ctx context.Context, t *tree.Tree, quer
243244
ModelUsed: model,
244245
Usage: totalUsage,
245246
HopsTaken: hopsTaken,
247+
TraceToken: ComputeTraceToken(t.DocumentID, traceDocVersionV1, model, finalIDs),
246248
}, nil
247249
}
248250

pkg/retrieval/cached.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,23 @@ func (c *Cached) Select(ctx context.Context, t *tree.Tree, query string, budget
7777
// SelectWithCost checks the cache first. On a hit it returns zero usage
7878
// (no LLM call was made). On a miss it delegates to the inner strategy's
7979
// SelectWithCost if available, otherwise falls back to Select.
80+
//
81+
// The replay trace token is preserved across cache hits: because the
82+
// token is a pure function of (document_id, doc_version, model, sorted
83+
// selected_ids) and the cache key already varies with document + model,
84+
// re-deriving the token from the cached slice produces the same value
85+
// the original strategy would have stamped.
8086
func (c *Cached) SelectWithCost(ctx context.Context, t *tree.Tree, query string, budget ContextBudget) (*Result, error) {
8187
key := cache.Key(string(t.DocumentID), query, c.inner.Name(), budget.ModelName)
8288

8389
if v, ok := c.cache.Get(key); ok {
90+
ids := v.([]tree.SectionID)
8491
return &Result{
85-
SelectedIDs: v.([]tree.SectionID),
92+
SelectedIDs: ids,
8693
Reasoning: "cached",
8794
ModelUsed: budget.ModelName,
8895
Usage: Usage{}, // zero — no LLM call
96+
TraceToken: ComputeTraceToken(t.DocumentID, traceDocVersionV1, budget.ModelName, ids),
8997
}, nil
9098
}
9199

@@ -101,7 +109,10 @@ func (c *Cached) SelectWithCost(ctx context.Context, t *tree.Tree, query string,
101109
if err != nil {
102110
return nil, err
103111
}
104-
result = &Result{SelectedIDs: ids}
112+
result = &Result{
113+
SelectedIDs: ids,
114+
TraceToken: ComputeTraceToken(t.DocumentID, traceDocVersionV1, budget.ModelName, ids),
115+
}
105116
}
106117

107118
c.cache.Set(key, result.SelectedIDs, c.ttl)

pkg/retrieval/chunked_tree.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ func (c *ChunkedTree) Select(ctx context.Context, t *tree.Tree, query string, bu
5555

5656
// SelectWithCost implements CostStrategy.
5757
func (c *ChunkedTree) SelectWithCost(ctx context.Context, t *tree.Tree, query string, budget ContextBudget) (*Result, error) {
58+
if t == nil || t.Root == nil {
59+
return &Result{}, nil
60+
}
5861
tok := LLMTokenizer{C: c.LLM}
5962
slices, err := c.Splitter.Split(ctx, t, budget, tok)
6063
if err != nil {
@@ -109,10 +112,12 @@ func (c *ChunkedTree) SelectWithCost(ctx context.Context, t *tree.Tree, query st
109112
totalUsage.Add(r.usage)
110113
}
111114

115+
selected := c.Merge.Merge(allIDs)
112116
return &Result{
113-
SelectedIDs: c.Merge.Merge(allIDs),
117+
SelectedIDs: selected,
114118
Usage: totalUsage,
115119
HopsTaken: 1,
120+
TraceToken: ComputeTraceToken(t.DocumentID, traceDocVersionV1, budget.ModelName, selected),
116121
}, nil
117122
}
118123

pkg/retrieval/retrieval_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,3 +316,75 @@ func TestDefaultSplitterFastPath(t *testing.T) {
316316
t.Errorf("breadcrumb missing doc title: %q", slices[0].Breadcrumb)
317317
}
318318
}
319+
320+
// TestSinglePassStampsTraceToken verifies that SelectWithCost
321+
// populates a 64-char hex TraceToken on the returned Result.
322+
func TestSinglePassStampsTraceToken(t *testing.T) {
323+
tr := buildTree()
324+
m := &mockLLM{pickIfPresent: []tree.SectionID{"sec_b"}}
325+
s := retrieval.NewSinglePass(m)
326+
327+
res, err := s.SelectWithCost(context.Background(), tr, "q",
328+
retrieval.ContextBudget{ModelName: "claude-sonnet-4-5", MaxTokens: 1000})
329+
if err != nil {
330+
t.Fatal(err)
331+
}
332+
if len(res.TraceToken) != 64 {
333+
t.Fatalf("trace_token must be 64 chars, got %d (%q)", len(res.TraceToken), res.TraceToken)
334+
}
335+
for _, r := range res.TraceToken {
336+
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
337+
t.Fatalf("trace_token must be lowercase hex, got %q", res.TraceToken)
338+
}
339+
}
340+
341+
// Same inputs → same token.
342+
res2, err := s.SelectWithCost(context.Background(), tr, "q",
343+
retrieval.ContextBudget{ModelName: "claude-sonnet-4-5", MaxTokens: 1000})
344+
if err != nil {
345+
t.Fatal(err)
346+
}
347+
if res2.TraceToken != res.TraceToken {
348+
t.Errorf("same inputs must produce same trace_token: %q vs %q",
349+
res.TraceToken, res2.TraceToken)
350+
}
351+
}
352+
353+
// TestChunkedTreeStampsTraceToken verifies that the chunked-tree
354+
// strategy populates the trace token on its returned Result.
355+
func TestChunkedTreeStampsTraceToken(t *testing.T) {
356+
tr := buildTree()
357+
m := &mockLLM{pickIfPresent: []tree.SectionID{"sec_a", "sec_b"}}
358+
s := retrieval.NewChunkedTree(m)
359+
360+
res, err := s.SelectWithCost(context.Background(), tr, "q", retrieval.ContextBudget{
361+
ModelName: "claude-sonnet-4-5", MaxTokens: 100000, MaxParallelCalls: 4,
362+
})
363+
if err != nil {
364+
t.Fatal(err)
365+
}
366+
if len(res.TraceToken) != 64 {
367+
t.Fatalf("chunked-tree trace_token must be 64 chars, got %d", len(res.TraceToken))
368+
}
369+
}
370+
371+
// TestTraceTokenMatchesExternalComputation ties the strategy output to
372+
// the canonical ComputeTraceToken helper, so any drift between the
373+
// helper and the per-strategy plumbing is caught at test time.
374+
func TestTraceTokenMatchesExternalComputation(t *testing.T) {
375+
tr := buildTree()
376+
m := &mockLLM{pickIfPresent: []tree.SectionID{"sec_a"}}
377+
s := retrieval.NewSinglePass(m)
378+
model := "claude-sonnet-4-5"
379+
380+
res, err := s.SelectWithCost(context.Background(), tr, "q",
381+
retrieval.ContextBudget{ModelName: model, MaxTokens: 1000})
382+
if err != nil {
383+
t.Fatal(err)
384+
}
385+
want := retrieval.ComputeTraceToken(tr.DocumentID, "1", model, res.SelectedIDs)
386+
if res.TraceToken != want {
387+
t.Errorf("strategy trace_token %q does not match ComputeTraceToken %q",
388+
res.TraceToken, want)
389+
}
390+
}

pkg/retrieval/single_pass.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,21 @@ func (s *SinglePass) SelectWithCost(ctx context.Context, t *tree.Tree, query str
6565
return nil, fmt.Errorf("single-pass llm call: %w", err)
6666
}
6767

68+
selected := FilterKnownIDs(ids, view.Sections)
6869
return &Result{
69-
SelectedIDs: FilterKnownIDs(ids, view.Sections),
70+
SelectedIDs: selected,
7071
ModelUsed: model,
7172
Usage: usage,
7273
HopsTaken: 1,
74+
TraceToken: ComputeTraceToken(t.DocumentID, traceDocVersionV1, model, selected),
7375
}, nil
7476
}
7577

78+
// traceDocVersionV1 is the placeholder document version used by every
79+
// strategy until Phase 3.2 wires real per-document versioning. Defined
80+
// once so the bump is a one-line change.
81+
const traceDocVersionV1 = "1"
82+
7683
// defaultSelectionRetries is the number of EXTRA attempts (on top of the first)
7784
// the selection LLM call gets when its response fails to parse as JSON. Gemini's
7885
// JSON mode occasionally returns plain text ("The most relevant section is...");

pkg/retrieval/strategy.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ type Result struct {
7070
// strategies (e.g. agentic) set it to the number of tool-using turns
7171
// actually consumed, including the terminal "done" turn.
7272
HopsTaken int `json:"hops_taken,omitempty"`
73+
74+
// TraceToken is the replay token computed by ComputeTraceToken over
75+
// the inputs that determine selection (document ID + version,
76+
// retrieval model, system prompt version, sorted selected IDs).
77+
// Two retrieval runs with identical inputs produce the same token,
78+
// regardless of reasoning path. Empty when the strategy did not
79+
// populate it (e.g. tests, fallback paths).
80+
TraceToken string `json:"trace_token,omitempty"`
7381
}
7482

7583
// Usage is the aggregated token + cost accounting across all LLM calls

pkg/retrieval/trace.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package retrieval
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"sort"
7+
8+
"github.com/hallelx2/vectorless-engine/pkg/tree"
9+
)
10+
11+
// SystemPromptVersion is the build-time version stamp folded into every
12+
// trace token. Bump this whenever selectionSystemPrompt (or any other
13+
// retrieval system prompt whose change should invalidate replay) is
14+
// edited so that previously-cached replay entries are no longer
15+
// considered byte-equivalent.
16+
//
17+
// The version is a free-form string; "v1", "v2", … is the established
18+
// convention. Replay clients should treat it as opaque.
19+
const SystemPromptVersion = "v1"
20+
21+
// ComputeTraceToken returns the canonical replay trace token for a
22+
// retrieval result.
23+
//
24+
// The token is sha256(doc_id || \0 || doc_version || \0 ||
25+
// retrieval_model || \0 || system_prompt_version || \0 ||
26+
// sorted(selected_ids).joined("\0")), hex-encoded lowercase. The output
27+
// is 64 hex characters.
28+
//
29+
// Sorting the IDs lexicographically makes the token order-invariant:
30+
// two strategies that select the same set of sections — even via
31+
// different reasoning paths — produce the same token. The NUL separator
32+
// prevents pathological IDs containing the chosen delimiter from
33+
// colliding (e.g. "a,b" + "c" vs "a" + "b,c").
34+
//
35+
// The doc_version parameter is a caller-controlled string so the engine
36+
// can layer document versioning on top in a future phase without
37+
// changing the function signature; today callers pass "1".
38+
func ComputeTraceToken(docID tree.DocumentID, docVersion, model string, ids []tree.SectionID) string {
39+
// Defensive copy: callers reasonably expect ComputeTraceToken not to
40+
// mutate the slice they pass in. Sorting in place would be a subtle
41+
// foot-gun the next time someone reads selected_ids after computing
42+
// the token.
43+
sorted := make([]string, len(ids))
44+
for i, id := range ids {
45+
sorted[i] = string(id)
46+
}
47+
sort.Strings(sorted)
48+
49+
h := sha256.New()
50+
h.Write([]byte(string(docID)))
51+
h.Write([]byte{0})
52+
h.Write([]byte(docVersion))
53+
h.Write([]byte{0})
54+
h.Write([]byte(model))
55+
h.Write([]byte{0})
56+
h.Write([]byte(SystemPromptVersion))
57+
h.Write([]byte{0})
58+
for i, id := range sorted {
59+
if i > 0 {
60+
h.Write([]byte{0})
61+
}
62+
h.Write([]byte(id))
63+
}
64+
return hex.EncodeToString(h.Sum(nil))
65+
}

0 commit comments

Comments
 (0)