Skip to content

Commit eb0af05

Browse files
committed
fix(vector): version-gate search path, anchor-local snippets, identity version bump
1 parent 0e629e1 commit eb0af05

8 files changed

Lines changed: 404 additions & 30 deletions

File tree

cmd/agentsview/embed_scheduler.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,12 @@ func (a searcherAdapter) SemanticSearch(
255255
) ([]db.VectorHit, error) {
256256
stale, err := a.ix.StaleActive(ctx, a.fingerprint)
257257
if err != nil {
258-
return nil, fmt.Errorf("checking embedding index staleness: %w", err)
258+
// StaleActive shares Search's error taxonomy (notably
259+
// vector.ErrMirrorVersionMismatch from a version-mismatched
260+
// read-only vectors.db), so it is translated the same way;
261+
// errors outside the taxonomy pass through with this context.
262+
return nil, translateSearchError(
263+
fmt.Errorf("checking embedding index staleness: %w", err))
259264
}
260265
if stale {
261266
return nil, fmt.Errorf(
@@ -288,19 +293,24 @@ func (a searcherAdapter) SemanticSearch(
288293
// server-facing sentinels. ErrNoActiveGeneration and BuildingError both
289294
// mean nothing is queryable yet, so they map to db.ErrSemanticUnavailable
290295
// (ErrNoActiveGeneration needs no extra cause text: db.ErrSemanticUnavailable's
291-
// own message already is the "run the build" remediation). A
292-
// QueryEncodeError means the index itself is ready but this particular
293-
// query-time embed call failed (the embeddings endpoint is down, slow, or
294-
// erroring); that maps to the distinct db.ErrSemanticTransient so a caller
295-
// can tell "not configured" apart from "configured, but this request
296-
// failed and can be retried".
296+
// own message already is the "run the build" remediation).
297+
// ErrMirrorVersionMismatch (a read-only vectors.db written by an
298+
// incompatible mirror schema version) also maps to
299+
// db.ErrSemanticUnavailable, carrying the sentinel's rebuild-required
300+
// message as the cause. A QueryEncodeError means the index itself is ready
301+
// but this particular query-time embed call failed (the embeddings endpoint
302+
// is down, slow, or erroring); that maps to the distinct
303+
// db.ErrSemanticTransient so a caller can tell "not configured" apart from
304+
// "configured, but this request failed and can be retried".
297305
func translateSearchError(err error) error {
298306
var buildingErr *vector.BuildingError
299307
var queryEncErr *vector.QueryEncodeError
300308
switch {
301309
case errors.As(err, &buildingErr):
302310
return fmt.Errorf("%w: index is building: %d%% complete",
303311
db.ErrSemanticUnavailable, buildingErr.Percent)
312+
case errors.Is(err, vector.ErrMirrorVersionMismatch):
313+
return fmt.Errorf("%w: %v", db.ErrSemanticUnavailable, err)
304314
case errors.Is(err, vector.ErrNoActiveGeneration):
305315
return db.ErrSemanticUnavailable
306316
case errors.As(err, &queryEncErr):

cmd/agentsview/embed_scheduler_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"database/sql"
56
"errors"
67
"fmt"
78
"mime"
@@ -459,6 +460,52 @@ func TestTranslateSearchErrorMapsVectorErrorsToSemanticUnavailable(t *testing.T)
459460
assert.ErrorIs(t, got, context.Canceled,
460461
"context errors must stay matchable so cancellation handling still fires")
461462
})
463+
t.Run("mirror version mismatch maps to semantic unavailable with rebuild message", func(t *testing.T) {
464+
got := translateSearchError(
465+
fmt.Errorf("checking embedding index staleness: %w", vector.ErrMirrorVersionMismatch))
466+
assert.ErrorIs(t, got, db.ErrSemanticUnavailable)
467+
assert.Contains(t, got.Error(), "embeddings build",
468+
"the rebuild remediation must survive translation")
469+
})
470+
}
471+
472+
// TestSearcherAdapterVersionMismatchedIndexReturnsSemanticUnavailable is the
473+
// adapter-level regression test for the mirror version gate: a
474+
// searcherAdapter over a read-only vectors.db written by a different mirror
475+
// schema version must return an error matching db.ErrSemanticUnavailable and
476+
// mentioning the rebuild remediation — not a raw SQL error or a wrong
477+
// staleness verdict from StaleActive querying an incompatible mirror.
478+
func TestSearcherAdapterVersionMismatchedIndexReturnsSemanticUnavailable(t *testing.T) {
479+
dataDir := t.TempDir()
480+
cfg := vectorTestConfig(dataDir)
481+
path := cfg.Vector.ResolvedDBPath(dataDir)
482+
483+
// Create a current vectors.db, then restamp it as written by the
484+
// previous mirror schema version, simulating a file left behind by an
485+
// older agentsview build.
486+
seed, err := vector.Open(context.Background(), path, false, cfg.Vector.Embeddings.MaxInputChars)
487+
require.NoError(t, err)
488+
require.NoError(t, seed.Close())
489+
raw, err := sql.Open("sqlite3", path)
490+
require.NoError(t, err)
491+
_, err = raw.Exec(`UPDATE vector_meta SET value = '2' WHERE key = 'mirror_schema_version'`)
492+
require.NoError(t, err)
493+
require.NoError(t, raw.Close())
494+
495+
ix, err := vector.Open(context.Background(), path, true, cfg.Vector.Embeddings.MaxInputChars)
496+
require.NoError(t, err, "read-only Open must succeed against a mismatched vectors.db")
497+
defer ix.Close()
498+
499+
enc, err := newVectorEncoder(cfg.Vector.Embeddings)
500+
require.NoError(t, err)
501+
adapter := newSearcherAdapter(ix, enc, vectorGeneration(cfg.Vector.Embeddings))
502+
503+
_, err = adapter.SemanticSearch(context.Background(), "any query", 5)
504+
require.Error(t, err)
505+
assert.ErrorIs(t, err, db.ErrSemanticUnavailable,
506+
"a version-mismatched index must surface the semantic-unavailable taxonomy")
507+
assert.Contains(t, err.Error(), "embeddings build",
508+
"the error must tell the user to rebuild the index")
462509
}
463510

464511
// --- integration: real serve/server construction path ---
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// ABOUTME: end-to-end semantic search tests wiring a real internal/vector
2+
// ABOUTME: index into db.SearchContent, pinning anchor-local snippet centering.
3+
package db_test
4+
5+
import (
6+
"context"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"go.kenn.io/agentsview/internal/db"
14+
"go.kenn.io/agentsview/internal/dbtest"
15+
"go.kenn.io/agentsview/internal/vector"
16+
kitvec "go.kenn.io/kit/vector"
17+
)
18+
19+
// vectorIndexSearcher adapts a real *vector.Index to db.VectorSearcher for
20+
// tests, mirroring the production searcherAdapter in cmd/agentsview without
21+
// its staleness gate.
22+
type vectorIndexSearcher struct {
23+
ix *vector.Index
24+
enc kitvec.EncodeFunc
25+
}
26+
27+
func (s vectorIndexSearcher) SemanticSearch(
28+
ctx context.Context, query string, limit int,
29+
) ([]db.VectorHit, error) {
30+
hits, err := s.ix.Search(ctx, s.enc, query, limit)
31+
if err != nil {
32+
return nil, err
33+
}
34+
out := make([]db.VectorHit, len(hits))
35+
for i, h := range hits {
36+
out[i] = db.VectorHit{
37+
SessionID: h.SessionID,
38+
Ordinal: h.Ordinal,
39+
OrdinalStart: h.OrdinalStart,
40+
OrdinalEnd: h.OrdinalEnd,
41+
Subordinate: h.Subordinate,
42+
Score: h.Score,
43+
Snippet: h.Snippet,
44+
}
45+
}
46+
return out, nil
47+
}
48+
49+
// TestSearchContentSemanticCrossMemberChunkCentersOnAnchorMessage is the
50+
// end-to-end regression test for run-chunk snippet mislocation: a run whose
51+
// matched chunk spans two assistant messages must produce a ContentMatch
52+
// whose snippet centers on the ANCHOR message's content. Before the fix, the
53+
// vector layer returned the whole cross-member chunk as the snippet; the db
54+
// layer could not locate that text inside the anchor message's content and
55+
// fell back to centering on the query pattern (absent here), i.e. the start
56+
// of the message — losing the matched region entirely.
57+
func TestSearchContentSemanticCrossMemberChunkCentersOnAnchorMessage(t *testing.T) {
58+
ctx := context.Background()
59+
d := dbtest.OpenTestDB(t)
60+
61+
memberA := "a short first assistant step"
62+
// The distinctive matched text sits past the snippet window's 60-byte
63+
// radius from the start of the anchor message, so a start-of-content
64+
// fallback cannot accidentally include it.
65+
memberB := strings.Repeat("background context sentence. ", 4) +
66+
"the particles remain entangled across any distance"
67+
msgs := []db.Message{
68+
dbtest.UserMsg("s1", 0, "please explain the experiment results"),
69+
dbtest.AsstMsg("s1", 1, memberA),
70+
dbtest.AsstMsg("s1", 2, memberB),
71+
}
72+
dbtest.SeedSessionWithMessages(t, d, "s1", "proj", msgs,
73+
dbtest.WithMessageCounts(3, 2))
74+
75+
enc := func(_ context.Context, texts []string) ([][]float32, error) {
76+
out := make([][]float32, len(texts))
77+
for i, text := range texts {
78+
if strings.Contains(text, "entangled") || strings.Contains(text, "quantum") {
79+
out[i] = []float32{1, 0, 0}
80+
} else {
81+
out[i] = []float32{0, 1, 0}
82+
}
83+
}
84+
return out, nil
85+
}
86+
87+
ix, err := vector.Open(ctx, filepath.Join(t.TempDir(), "vectors.db"), false, 4000)
88+
require.NoError(t, err)
89+
defer func() { require.NoError(t, ix.Close()) }()
90+
gen := kitvec.Generation{Model: "fake-model", Dimensions: 3}
91+
_, err = ix.Build(ctx, d, enc, gen, vector.BuildOptions{})
92+
require.NoError(t, err)
93+
94+
d.SetVectorSearcher(vectorIndexSearcher{ix: ix, enc: enc})
95+
96+
// The query shares no literal token with the anchor message, so a
97+
// pattern-based fallback cannot rescue a mislocated snippet.
98+
page, err := d.SearchContent(ctx, db.ContentSearchFilter{
99+
Pattern: "quantum superposition", Mode: "semantic", Limit: 10,
100+
})
101+
require.NoError(t, err)
102+
require.NotEmpty(t, page.Matches)
103+
104+
m := page.Matches[0]
105+
assert.Equal(t, "s1", m.SessionID)
106+
assert.Equal(t, 2, m.Ordinal,
107+
"anchor: the member containing the matched chunk's center")
108+
assert.Contains(t, m.Snippet, "entangled",
109+
"snippet must center on the anchor message's matched content")
110+
assert.NotContains(t, m.Snippet, memberA,
111+
"snippet must not carry text from a different run member")
112+
}

internal/vector/build.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ type BuildResult struct {
7070
}
7171

7272
// Build runs one embedding pass against gen (the desired vector space, from
73-
// config: Model, Dimensions, Params{"max_input_chars": itoa(n)}). It
73+
// config: Model, Dimensions, and the fingerprinted Params — max_input_chars,
74+
// doc_unit_scheme, and chunk_overlap_chars; see vectorGeneration in
75+
// cmd/agentsview/embeddings.go). It
7476
// refreshes the vector_messages mirror, resolves which generation to fill
7577
// (top-up the active one, start a new building generation, or reset and
7678
// refill the active one for FullRebuild), fills pending documents, and

internal/vector/index.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,19 @@ CREATE TABLE IF NOT EXISTS vector_meta (
7373
`
7474

7575
// mirrorSchemaVersion is the current vectors.db mirror schema generation,
76-
// stamped into vector_meta under mirrorSchemaVersionKey. Bump it whenever
77-
// mirrorDDL's column set changes in a way old rows cannot simply be read
78-
// as-is: Open resets vectors.db on the write path, and flags
79-
// ErrMirrorVersionMismatch on the read path, whenever the stamped value
80-
// differs (or is absent while any mirror state already exists).
81-
const mirrorSchemaVersion = "2"
76+
// stamped into vector_meta under mirrorSchemaVersionKey. It covers both the
77+
// mirror's DDL shape (mirrorDDL's column set) AND its document-identity
78+
// scheme (what one vector_messages row means); bump it whenever either
79+
// changes in a way old rows cannot simply be read as-is. Open resets
80+
// vectors.db on the write path, and flags ErrMirrorVersionMismatch on the
81+
// read path, whenever the stamped value differs (or is absent while any
82+
// mirror state already exists).
83+
//
84+
// History: "2" added the ordinal_end/subordinate/offsets columns but still
85+
// held one row per message; "3" switched document identity to run-grouped
86+
// units (one row per user message or per run of contiguous assistant
87+
// messages) with no DDL change.
88+
const mirrorSchemaVersion = "3"
8289

8390
// mirrorSchemaVersionKey is the vector_meta key holding mirrorSchemaVersion.
8491
const mirrorSchemaVersionKey = "mirror_schema_version"

internal/vector/index_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,31 @@ INSERT INTO vector_meta (key, value) VALUES (?, ?), (?, ?)`,
254254
require.NoError(t, err)
255255
}
256256

257+
// seedV2Mirror writes path as a vectors.db with the current mirror DDL but
258+
// stamped mirror_schema_version "2": the window between the v2 columns
259+
// landing and the run-grouped document-identity change, when rows were still
260+
// one-per-message. The DDL shape matches the current schema exactly, so only
261+
// the version stamp can tell the two apart — the case the "3" bump exists
262+
// for.
263+
func seedV2Mirror(t *testing.T, path string) {
264+
t.Helper()
265+
ctx := context.Background()
266+
raw, err := sql.Open("sqlite3", path)
267+
require.NoError(t, err)
268+
defer raw.Close()
269+
270+
_, err = raw.ExecContext(ctx, mirrorDDL)
271+
require.NoError(t, err)
272+
_, err = raw.ExecContext(ctx, `
273+
INSERT INTO vector_messages (doc_key, session_id, ordinal, ordinal_end, content, content_hash)
274+
VALUES (?, ?, ?, ?, ?, ?)`,
275+
"s1:0", "s1", 0, 0, "a per-message row", "h1")
276+
require.NoError(t, err)
277+
_, err = raw.ExecContext(ctx, `
278+
INSERT INTO vector_meta (key, value) VALUES (?, ?)`, mirrorSchemaVersionKey, "2")
279+
require.NoError(t, err)
280+
}
281+
257282
func TestMirrorSchemaVersionFreshDBStampsVersionNothingDropped(t *testing.T) {
258283
ctx := context.Background()
259284
path := filepath.Join(t.TempDir(), "vectors.db")
@@ -357,6 +382,56 @@ VALUES (?, ?, ?, ?, ?, ?)`,
357382
assert.Zero(t, genCount, "kit must have recreated its generations table fresh, not kept the fake row")
358383
}
359384

385+
// TestMirrorSchemaVersionV2StampResetsWritePath covers the document-identity
386+
// half of the version gate: a vectors.db whose DDL already matches the
387+
// current shape but whose rows predate run grouping (stamped "2", one row
388+
// per message) must still be reset on writable open — the stamp, not the
389+
// column set, is what marks the rows incompatible.
390+
func TestMirrorSchemaVersionV2StampResetsWritePath(t *testing.T) {
391+
ctx := context.Background()
392+
path := filepath.Join(t.TempDir(), "vectors.db")
393+
seedV2Mirror(t, path)
394+
395+
ix, err := Open(ctx, path, false, 4000)
396+
require.NoError(t, err)
397+
defer ix.Close()
398+
399+
var rowCount int
400+
require.NoError(t, ix.db.QueryRowContext(ctx,
401+
`SELECT COUNT(*) FROM vector_messages`).Scan(&rowCount))
402+
assert.Zero(t, rowCount, "per-message v2 rows must not survive a version reset")
403+
404+
var version string
405+
require.NoError(t, ix.db.QueryRowContext(ctx,
406+
`SELECT value FROM vector_meta WHERE key = ?`, mirrorSchemaVersionKey,
407+
).Scan(&version))
408+
assert.Equal(t, mirrorSchemaVersion, version)
409+
}
410+
411+
// TestMirrorSchemaVersionV2StampReadOnlyReturnsSentinel covers the read path
412+
// for the same document-identity mismatch: a read-only Open against a
413+
// "2"-stamped vectors.db must succeed, but both Search and StaleActive must
414+
// fail closed with ErrMirrorVersionMismatch — StaleActive runs before Search
415+
// in the real serving path, so without its gate a caller would query the
416+
// generation tables of a mirror shaped by a different identity scheme and
417+
// never reach the sentinel.
418+
func TestMirrorSchemaVersionV2StampReadOnlyReturnsSentinel(t *testing.T) {
419+
ctx := context.Background()
420+
path := filepath.Join(t.TempDir(), "vectors.db")
421+
seedV2Mirror(t, path)
422+
423+
ro, err := Open(ctx, path, true, 4000)
424+
require.NoError(t, err, "read-only Open must succeed even against a v2-stamped mirror")
425+
defer ro.Close()
426+
427+
_, err = ro.Search(ctx, fakeSearchEncoder(), "alpha", 10)
428+
assert.ErrorIs(t, err, ErrMirrorVersionMismatch)
429+
430+
_, err = ro.StaleActive(ctx, "any-fingerprint")
431+
assert.ErrorIs(t, err, ErrMirrorVersionMismatch,
432+
"StaleActive must apply the same version gate Search does")
433+
}
434+
360435
// TestMirrorSchemaVersionReadOnlyMismatchSearchReturnsSentinel covers the
361436
// read path: Open against a version-mismatched vectors.db must still
362437
// succeed (a read-only CLI process cannot reset the file), but Search must

0 commit comments

Comments
 (0)