Skip to content

Commit cd7ab3a

Browse files
committed
fix: final review minors — DSN escaping, parked-row reads, doc gaps
1 parent 6a0836a commit cd7ab3a

8 files changed

Lines changed: 146 additions & 27 deletions

File tree

docs/semantic-search-internals.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,12 @@ exists:
139139
`message_vectors*` table, including vec0 tables left behind by retired or
140140
abandoned generations — recreates the current schema, and restamps the
141141
version, so the next build takes the existing first-ever full-build path.
142-
`vectors.db` is disposable by design; `sessions.db` is never reset this way.
142+
`embeddings activate` and `retire` also open read-write on their direct
143+
(no-daemon) path (`directGenerationAction` in
144+
`cmd/agentsview/embeddings.go`), so against a mismatched `vectors.db` they
145+
trigger the same reset and then fail with "generation not found", the reset
146+
having removed every generation. `vectors.db` is disposable by design;
147+
`sessions.db` is never reset this way.
143148
- **Read path** (read-only `Open`: CLI reads, direct-install search): `Open`
144149
succeeds without touching any table, but every subsequent `Search`,
145150
`StaleActive`, `Generations`, or `ResolveMessageUnits` call fails closed
@@ -310,10 +315,13 @@ Generation activation always happens under the single writer. Search opens
310315
`internal/db` fuses rank-ordered legs with reciprocal rank fusion (rank
311316
constant 60) and shifts subordinate units' effective rank by +5 — a
312317
rank-based adjustment, not a hard tier or score multiplier, since RRF ranks
313-
are the only scale comparable across legs. Semantic-only search routes its
314-
single ranked list through the same merge as a one-leg fusion, so
315-
`--semantic` downranks subordinate hits identically to `--hybrid` (matches
316-
still carry the searcher's own cosine scores; only the order changes).
318+
are the only scale comparable across legs. The merge is a local
319+
implementation rather than kit's `Merge` because kit has no per-hit
320+
rank-offset hook for the subordinate penalty (upstreamable later).
321+
Semantic-only search routes its single ranked list through the same merge as
322+
a one-leg fusion, so `--semantic` downranks subordinate hits identically to
323+
`--hybrid` (matches still carry the searcher's own cosine scores; only the
324+
order changes).
317325
- **Hybrid fuses at unit granularity, with an FTS anchor override.** The FTS leg
318326
stays message-granularity (exact strings, commands, filenames) over the same
319327
embeddable-universe predicate `ScanEmbeddableUnits` uses. Each FTS message

docs/semantic-search.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ starts out empty and every document counts as pending. For a hosted embeddings
5757
API that is a real cost event, so run `agentsview embeddings build` directly at
5858
a time of your choosing if you want to control when that initial cost lands,
5959
rather than letting the debounced after-sync scheduler trigger it on its own.
60+
The same cost event can recur on upgrade: when a new agentsview version changes
61+
the index's internal mirror schema or document-identity scheme, the next
62+
writable open resets the mirror, and with `run_after_sync = true` the next sync
63+
automatically re-embeds the entire archive against the configured endpoint.
6064

6165
By default, `include_automated = false` keeps automated sessions (e.g. roborev)
6266
out of the embedding index entirely, mirroring session search's default

internal/db/search_content.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -872,7 +872,12 @@ type mergedUnit struct {
872872
// (rank+5 against a rank constant of 60). Semantic-only search routes its
873873
// single ranked list through this same merge as a one-leg fusion, so the
874874
// penalty applies identically in both modes. Ties break deterministically by
875-
// ascending key; limit > 0 truncates the fused list.
875+
// ascending key; limit > 0 truncates the fused list. Each leg's entries must
876+
// already be deduplicated by Key — both callers dedup via their display-map
877+
// seen-checks — since a repeated key within one leg would accumulate score
878+
// twice. This is a local merge rather than kitvec.Merge because kit's Merge
879+
// has no per-hit rank-offset hook for the subordinate penalty; upstreaming
880+
// such a hook would let this collapse onto kit's implementation later.
876881
func rrfMerge(legs [][]unitRanked, limit int) []mergedUnit {
877882
const rankConstant = 60
878883
const subordinatePenalty = 5

internal/server/search_scope_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,10 @@ func TestSearchContentScopeFiltersSemanticResults(t *testing.T) {
116116

117117
// TestSearchContentSemanticResponseCarriesUnitRangeAndLineage pins the HTTP
118118
// wire shape for run-grouped semantic hits: ordinal stays the anchor while
119-
// ordinal_start/ordinal_end, subordinate, and the lineage keys ride along;
120-
// a top-level single-message hit omits them all (omitempty), keeping its
121-
// JSON identical to before.
119+
// ordinal_start/ordinal_end, subordinate, and the lineage keys ride along.
120+
// The fixture's top-level single-message hit sits at ordinal 0, so all of
121+
// its zero-valued unit/lineage fields are omitted via omitempty; a nonzero
122+
// single-message hit would still emit ordinal_start/ordinal_end.
122123
func TestSearchContentSemanticResponseCarriesUnitRangeAndLineage(t *testing.T) {
123124
te := setup(t)
124125
te.seedSession(t, "top-sess", "proj", 2)

internal/vector/index.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,11 @@ type GenerationInfo struct {
139139
// pragma params either way, but it only honors mode=ro when the DSN carries
140140
// the file: scheme — a bare path silently opens read-write, so the ro
141141
// contract depends on the prefix.
142+
//
143+
// The path component is percent-encoded (slashes kept intact): SQLite
144+
// percent-decodes URI paths and splits params at `?`, so a raw path
145+
// containing `%`, `?`, or `#` would be misparsed — e.g. a literal "%41" in a
146+
// directory name would silently open a different file.
142147
func vectorDSN(path string, readOnly bool) string {
143148
params := url.Values{}
144149
if readOnly {
@@ -149,7 +154,8 @@ func vectorDSN(path string, readOnly bool) string {
149154
params.Set("_busy_timeout", "5000")
150155
params.Set("_synchronous", "NORMAL")
151156
}
152-
return "file:" + path + "?" + params.Encode()
157+
escaped := (&url.URL{Path: path}).EscapedPath()
158+
return "file:" + escaped + "?" + params.Encode()
153159
}
154160

155161
// ChunkOverlap derives the SplitOptions.Overlap rune count from

internal/vector/index_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package vector
33
import (
44
"context"
55
"database/sql"
6+
"os"
67
"path/filepath"
78
"testing"
89

@@ -61,6 +62,44 @@ func TestOpenReadOnlyRefusesWrites(t *testing.T) {
6162
"the refusal must be SQLite's readonly-database error, got: %v", err)
6263
}
6364

65+
// TestOpenPathWithSpecialCharacters pins vectorDSN's path escaping: SQLite
66+
// percent-decodes file: URI paths and splits params at `?`, so a directory
67+
// name containing a space and a literal %-hex sequence ("%41") would, raw,
68+
// be decoded to a different path ("weArd dir") and fail to open. Both the
69+
// writable and read-only branches must escape the path, and read-only must
70+
// still refuse writes.
71+
func TestOpenPathWithSpecialCharacters(t *testing.T) {
72+
ctx := context.Background()
73+
dir := filepath.Join(t.TempDir(), "we%41rd dir")
74+
require.NoError(t, os.MkdirAll(dir, 0o755))
75+
path := filepath.Join(dir, "vectors.db")
76+
77+
rw, err := Open(ctx, path, false, 4000)
78+
require.NoError(t, err, "writable Open must succeed on a path with %% and space")
79+
_, err = rw.db.ExecContext(ctx,
80+
`INSERT INTO vector_meta (key, value) VALUES ('probe', 'x')`)
81+
require.NoError(t, err)
82+
require.NoError(t, rw.Close())
83+
84+
_, err = os.Stat(path)
85+
require.NoError(t, err, "the database file must exist at the literal path, not a decoded one")
86+
87+
ro, err := Open(ctx, path, true, 4000)
88+
require.NoError(t, err, "read-only Open must succeed on a path with %% and space")
89+
defer ro.Close()
90+
91+
var value string
92+
require.NoError(t, ro.db.QueryRowContext(ctx,
93+
`SELECT value FROM vector_meta WHERE key = 'probe'`).Scan(&value))
94+
assert.Equal(t, "x", value)
95+
96+
_, err = ro.db.ExecContext(ctx,
97+
`INSERT INTO vector_meta (key, value) VALUES ('probe2', 'y')`)
98+
require.Error(t, err, "a read-only vectors.db handle must refuse writes")
99+
assert.Contains(t, err.Error(), "readonly",
100+
"the refusal must be SQLite's readonly-database error, got: %v", err)
101+
}
102+
64103
func TestOpenSplitOptionsUse15PercentOverlap(t *testing.T) {
65104
ctx := context.Background()
66105
path := filepath.Join(t.TempDir(), "vectors.db")

internal/vector/search.go

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const snippetMaxRunes = 200
1818
// Hit is one unit-level semantic search result, anchored to a specific
1919
// message. For a run document Ordinal is the anchor: the member message
2020
// whose rune span contains the matched chunk's center rune (see
21-
// anchorOrdinal), while OrdinalStart/OrdinalEnd span the whole run. For a
21+
// anchorMemberIndex), while OrdinalStart/OrdinalEnd span the whole run. For a
2222
// user document all three ordinals are the message's own ordinal.
2323
type Hit struct {
2424
SessionID string
@@ -231,7 +231,7 @@ const runMemberSeparatorRunes = 2
231231

232232
// resolveRunHit computes a run hit's anchor ordinal and anchor-local
233233
// snippet: the anchor is the member whose rune span contains the matched
234-
// chunk's center rune (see anchorOrdinal), and the snippet is the
234+
// chunk's center rune (see anchorMemberIndex), and the snippet is the
235235
// intersection of the chunk's rune window with that member's span — always
236236
// a substring of the anchor message's own text, so the db layer's snippet
237237
// centering (semanticSnippet) can locate it inside the anchor message's
@@ -277,17 +277,6 @@ func chunkWindow(contentRunes, chunkIndex int, o kitvec.SplitOptions) (start, en
277277
return start, end
278278
}
279279

280-
// anchorOrdinal maps a matched chunk back to the run member whose rune span
281-
// contains the chunk's center rune (chunk_start + actual_chunk_runes/2).
282-
// Separator runes between members belong to no member's span, so a center
283-
// falling there resolves to the preceding member: the earlier member wins a
284-
// boundary tie. offsets must be non-empty (run documents only); user
285-
// documents pass their ordinal through without calling this.
286-
func anchorOrdinal(offsets []db.UnitOffset, contentRunes, chunkIndex int, o kitvec.SplitOptions) int {
287-
start, end := chunkWindow(contentRunes, chunkIndex, o)
288-
return offsets[anchorMemberIndex(offsets, start, end)].Ordinal
289-
}
290-
291280
// anchorMemberIndex returns the offsets index of the run member whose rune
292281
// span contains the [start, end) chunk window's center rune, with the
293282
// earlier member winning when the center falls in the separator between two
@@ -310,15 +299,17 @@ func anchorMemberIndex(offsets []db.UnitOffset, start, end int) int {
310299
// keyed by doc_key, in maxSQLVars-sized chunks: a deep semantic overfetch
311300
// (large limit * over-fetch factor) can carry thousands of doc keys, well
312301
// past what a single IN (...) clause can bind. A key with no matching row is
313-
// simply absent from the result.
302+
// simply absent from the result. Rows parked at a negative sentinel ordinal
303+
// by a concurrent Refresh (see evictSlotOccupant) are excluded the same way:
304+
// mid-refresh state must never hydrate into a hit with a negative ordinal.
314305
func (ix *Index) lookupMirrorDocs(ctx context.Context, docKeys []string) (map[string]mirrorDoc, error) {
315306
docs := make(map[string]mirrorDoc, len(docKeys))
316307
err := chunkKeys(docKeys, func(chunk []string) error {
317308
placeholders, args := inPlaceholders(chunk)
318309
rows, err := ix.db.QueryContext(ctx, `
319310
SELECT doc_key, session_id, ordinal, ordinal_end, subordinate, offsets, content
320311
FROM vector_messages
321-
WHERE doc_key IN `+placeholders, args...)
312+
WHERE ordinal >= 0 AND doc_key IN `+placeholders, args...)
322313
if err != nil {
323314
return fmt.Errorf("look up search hit documents: %w", err)
324315
}
@@ -378,7 +369,10 @@ func truncateRunes(s string, maxRunes int) string {
378369
// yields a zero UnitRef. Each ref is a point lookup on the retained unique
379370
// (session_id, ordinal) index — greatest unit ordinal <= ref ordinal, then a
380371
// containment check against ordinal_end — via one prepared statement, so a
381-
// batch of any size never approaches SQLite's bind-variable limit.
372+
// batch of any size never approaches SQLite's bind-variable limit. Rows
373+
// parked at a negative sentinel ordinal by a concurrent Refresh (see
374+
// evictSlotOccupant) are skipped so a ref can never resolve into
375+
// mid-refresh state and surface a negative ordinal range.
382376
//
383377
// Like Search and StaleActive, it fails closed with ErrMirrorVersionMismatch
384378
// — before touching any table — when ix was opened read-only against a
@@ -397,7 +391,7 @@ func (ix *Index) ResolveMessageUnits(
397391
stmt, err := ix.db.PrepareContext(ctx, `
398392
SELECT doc_key, ordinal, ordinal_end, subordinate
399393
FROM vector_messages
400-
WHERE session_id = ? AND ordinal <= ?
394+
WHERE session_id = ? AND ordinal >= 0 AND ordinal <= ?
401395
ORDER BY ordinal DESC LIMIT 1`)
402396
if err != nil {
403397
return nil, fmt.Errorf("resolve message units: %w", err)

internal/vector/search_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,16 @@ func TestStaleActiveFalseWhenNoActiveGeneration(t *testing.T) {
171171
assert.False(t, stale, "no active generation means nothing to compare")
172172
}
173173

174+
// anchorOrdinal is a test-only convenience over the production anchor
175+
// pipeline: it maps a matched chunk back to the run member ordinal whose
176+
// rune span contains the chunk's center rune, composing chunkWindow and
177+
// anchorMemberIndex exactly the way resolveRunHit does. offsets must be
178+
// non-empty (run documents only).
179+
func anchorOrdinal(offsets []db.UnitOffset, contentRunes, chunkIndex int, o kitvec.SplitOptions) int {
180+
start, end := chunkWindow(contentRunes, chunkIndex, o)
181+
return offsets[anchorMemberIndex(offsets, start, end)].Ordinal
182+
}
183+
174184
// TestAnchorOrdinal pins the anchor policy: the anchor is the member message
175185
// whose rune span contains the matched chunk's center rune, computed from
176186
// the chunk's ACTUAL rune length (the final chunk is capped at the content's
@@ -579,6 +589,58 @@ func TestResolveMessageUnitsPointLookup(t *testing.T) {
579589
}
580590
}
581591

592+
// TestResolveMessageUnitsIgnoresParkedRows pins the mid-refresh read
593+
// contract: Refresh parks a displaced row at a negative sentinel ordinal
594+
// non-transactionally (evictSlotOccupant), so a concurrent resolver call can
595+
// see it. The point lookup's ordinal-DESC seek would otherwise land on the
596+
// parked row (its old ordinal_end still covers the ref) and emit a negative
597+
// OrdinalStart; parked rows must be invisible to readers.
598+
func TestResolveMessageUnitsIgnoresParkedRows(t *testing.T) {
599+
ix := openTestIndex(t)
600+
// Parked mid-refresh: ordinal moved to the sentinel, ordinal_end still
601+
// holds its old value, so containment (2 <= 3) would pass.
602+
seedUnitRow(t, ix, "r:s:parked", "s", -2, 3, false)
603+
seedUnitRow(t, ix, "r:s:valid", "s", 5, 6, false)
604+
605+
got, err := ix.ResolveMessageUnits(context.Background(), []db.MessageRef{
606+
{SessionID: "s", Ordinal: 2},
607+
{SessionID: "s", Ordinal: 5},
608+
})
609+
require.NoError(t, err)
610+
require.Len(t, got, 2)
611+
assert.Equal(t, db.UnitRef{}, got[0],
612+
"a ref covered only by a parked row must stay unresolved, not surface a negative ordinal")
613+
assert.Equal(t, db.UnitRef{
614+
DocKey: "r:s:valid", SessionID: "s", OrdinalStart: 5, OrdinalEnd: 6,
615+
}, got[1], "valid rows must keep resolving alongside a parked one")
616+
}
617+
618+
// TestHydrateHitsIgnoresParkedRows pins the same mid-refresh contract on the
619+
// hit-hydration path: a KNN hit whose doc_key points at a sentinel-parked
620+
// mirror row must be dropped (like a vanished doc), never hydrated into a
621+
// hit with a negative ordinal.
622+
func TestHydrateHitsIgnoresParkedRows(t *testing.T) {
623+
ix := openTestIndex(t)
624+
ctx := context.Background()
625+
seedMirrorRow(t, ix, "u-parked", db.EmbeddableUnit{
626+
SessionID: "s1", Kind: "user", Ordinal: -1, OrdinalEnd: 4,
627+
Content: "parked mid-refresh",
628+
})
629+
seedMirrorRow(t, ix, "u-valid", db.EmbeddableUnit{
630+
SessionID: "s1", Kind: "user", Ordinal: 6, OrdinalEnd: 6,
631+
Content: "still visible",
632+
})
633+
634+
hits, err := ix.hydrateHits(ctx, []kitvec.Hit[string]{
635+
{Doc: "u-parked", ChunkIndex: 0, Score: 0.9},
636+
{Doc: "u-valid", ChunkIndex: 0, Score: 0.8},
637+
})
638+
require.NoError(t, err)
639+
require.Len(t, hits, 1, "the parked row's hit must be dropped")
640+
assert.Equal(t, 6, hits[0].Ordinal)
641+
assert.Equal(t, "still visible", hits[0].Snippet)
642+
}
643+
582644
// TestResolveMessageUnitsResultParallelToRefs pins that one call over a
583645
// mixed batch keeps the result slice parallel to refs, with zero UnitRefs
584646
// holding the positions of unresolvable refs.

0 commit comments

Comments
 (0)