f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
241 lines
8.4 KiB
Go
241 lines
8.4 KiB
Go
package vector
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.kenn.io/agentsview/internal/db"
|
|
kitvec "go.kenn.io/kit/vector"
|
|
)
|
|
|
|
// fakeExportEncoder returns a deterministic 4-dimensional encoder for export
|
|
// tests, mirroring fakeBuildEncoder's shape at a different dimension so the
|
|
// round-trip test can assert Dimension and per-chunk vector length.
|
|
func fakeExportEncoder() kitvec.EncodeFunc {
|
|
return func(_ context.Context, texts []string) ([][]float32, error) {
|
|
out := make([][]float32, len(texts))
|
|
for i := range texts {
|
|
out[i] = []float32{1, 0, 0, 0}
|
|
}
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
// exportTestSource returns a fakeUnitSource with two sessions: session-1
|
|
// holds a user doc plus a multi-message run doc, session-2 holds a single
|
|
// user doc, the small corpus TestExportRoundTrip and
|
|
// TestSessionEmbeddedDocHashesChangesWithContent share.
|
|
func exportTestSource() *fakeUnitSource {
|
|
return &fakeUnitSource{rows: []fakeUnit{
|
|
{
|
|
unit: userDoc("session-1", "u1", 0, "hello"),
|
|
endedAt: "2024-01-01T00:00:00Z",
|
|
},
|
|
{
|
|
unit: runDoc("session-1", "a1", 1, 2, "first\n\nsecond", []db.UnitOffset{
|
|
{Ordinal: 1}, {Ordinal: 2, RuneStart: 7, ByteStart: 7},
|
|
}),
|
|
endedAt: "2024-01-01T00:00:01Z",
|
|
},
|
|
{
|
|
unit: userDoc("session-2", "u2", 0, "world"),
|
|
endedAt: "2024-01-01T00:00:02Z",
|
|
},
|
|
}}
|
|
}
|
|
|
|
// newBuiltTestIndex opens a temp index, refreshes exportTestSource's two
|
|
// sessions, and builds+activates a generation with a fake 4-dim encoder.
|
|
func newBuiltTestIndex(t *testing.T) (*Index, kitvec.Generation) {
|
|
t.Helper()
|
|
ix := openTestIndex(t)
|
|
ctx := context.Background()
|
|
src := exportTestSource()
|
|
gen := fakeGeneration4Dim()
|
|
|
|
result, err := ix.Build(ctx, src, fakeExportEncoder(), gen, BuildOptions{})
|
|
require.NoError(t, err)
|
|
require.True(t, result.Activated)
|
|
return ix, gen
|
|
}
|
|
|
|
// newEmptyTestIndex opens a temp index with no build, for the no-active-
|
|
// generation case.
|
|
func newEmptyTestIndex(t *testing.T) *Index {
|
|
t.Helper()
|
|
return openTestIndex(t)
|
|
}
|
|
|
|
func fakeGeneration4Dim() kitvec.Generation {
|
|
return kitvec.Generation{Model: "fake-export-model", Dimensions: 4}
|
|
}
|
|
|
|
func TestExportRoundTrip(t *testing.T) {
|
|
ctx := context.Background()
|
|
ix, gen := newBuiltTestIndex(t)
|
|
|
|
exp, ok, err := ix.ActiveExport(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, gen.Fingerprint(), exp.Fingerprint)
|
|
assert.Equal(t, 4, exp.Dimension)
|
|
assert.NotEmpty(t, exp.Model)
|
|
|
|
hashes, err := ix.SessionEmbeddedDocHashes(ctx, exp.Ordinal)
|
|
require.NoError(t, err)
|
|
require.Len(t, hashes, 2)
|
|
|
|
docs, aggHash, err := ix.ExportSessionDocs(ctx, exp.Ordinal, "session-1")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, docs)
|
|
assert.Equal(t, hashes["session-1"], aggHash,
|
|
"export hash must equal the session's SessionEmbeddedDocHashes value")
|
|
for _, d := range docs {
|
|
assert.Equal(t, "session-1", d.SessionID)
|
|
assert.NotEmpty(t, d.ContentHash)
|
|
require.NotEmpty(t, d.Chunks)
|
|
for _, c := range d.Chunks {
|
|
assert.Len(t, c.Embedding, 4)
|
|
}
|
|
}
|
|
|
|
noDocs, emptyHash, err := ix.ExportSessionDocs(ctx, exp.Ordinal, "absent")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, noDocs)
|
|
assert.Equal(t, "", emptyHash,
|
|
"an empty export hashes to \"\", matching absence from the hash map")
|
|
}
|
|
|
|
func TestExportNoActiveGeneration(t *testing.T) {
|
|
ctx := context.Background()
|
|
ix := newEmptyTestIndex(t)
|
|
_, ok, err := ix.ActiveExport(ctx)
|
|
require.NoError(t, err)
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
// TestSessionEmbeddedDocHashesChangesWithContent builds, captures each
|
|
// session's embedded-doc aggregate hash, mutates session-1's user doc
|
|
// content in the fake source and refreshes (without rebuilding), then
|
|
// captures again: session-1's stamp no longer matches its new content_hash,
|
|
// so it drops out of the aggregate (session-1's run doc, a1, is untouched
|
|
// and stays embedded, so the session key survives with a changed hash);
|
|
// session-2, untouched, keeps a stable hash.
|
|
func TestSessionEmbeddedDocHashesChangesWithContent(t *testing.T) {
|
|
ctx := context.Background()
|
|
ix := openTestIndex(t)
|
|
src := exportTestSource()
|
|
gen := fakeGeneration4Dim()
|
|
|
|
_, err := ix.Build(ctx, src, fakeExportEncoder(), gen, BuildOptions{})
|
|
require.NoError(t, err)
|
|
|
|
exp, ok, err := ix.ActiveExport(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
|
|
before, err := ix.SessionEmbeddedDocHashes(ctx, exp.Ordinal)
|
|
require.NoError(t, err)
|
|
require.Contains(t, before, "session-1")
|
|
require.Contains(t, before, "session-2")
|
|
|
|
// A later timestamp than the watermark Build's Refresh already advanced
|
|
// to, or the fake source's incremental scan would never resurface it.
|
|
src.rows[0].unit.Content = "changed"
|
|
src.rows[0].endedAt = "2024-01-02T00:00:00Z"
|
|
_, err = ix.Refresh(ctx, src, false, true)
|
|
require.NoError(t, err)
|
|
|
|
after, err := ix.SessionEmbeddedDocHashes(ctx, exp.Ordinal)
|
|
require.NoError(t, err)
|
|
require.Contains(t, after, "session-1",
|
|
"session-1's untouched run doc keeps it in the aggregate")
|
|
assert.NotEqual(t, before["session-1"], after["session-1"],
|
|
"the mutated session's aggregate must change")
|
|
assert.Equal(t, before["session-2"], after["session-2"],
|
|
"the untouched session's hash is stable")
|
|
|
|
// The export hash follows the shrunken embedded subset, so a pg push whose
|
|
// delta scan read `before` sees the divergence and defers the session
|
|
// instead of exporting the partial view.
|
|
_, exportHash, err := ix.ExportSessionDocs(ctx, exp.Ordinal, "session-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, after["session-1"], exportHash,
|
|
"export hash tracks the current embedded subset")
|
|
assert.NotEqual(t, before["session-1"], exportHash,
|
|
"a stale delta-scan hash no longer matches the export")
|
|
}
|
|
|
|
// TestSessionEmbeddedDocHashesChangesWithMetadata guards the compaction/resync
|
|
// case: a doc whose content (and content_hash) is unchanged but whose ordinal
|
|
// shifts keeps its UUID-derived doc_key and its embedding stamp, so it stays
|
|
// embedded — yet the aggregate must still change so pg push re-anchors it. A
|
|
// hash over only (doc_key, content_hash) would miss this and leave PG anchors
|
|
// stale. session-2, untouched, keeps a stable hash.
|
|
func TestSessionEmbeddedDocHashesChangesWithMetadata(t *testing.T) {
|
|
ctx := context.Background()
|
|
ix := openTestIndex(t)
|
|
src := exportTestSource()
|
|
gen := fakeGeneration4Dim()
|
|
|
|
_, err := ix.Build(ctx, src, fakeExportEncoder(), gen, BuildOptions{})
|
|
require.NoError(t, err)
|
|
|
|
exp, ok, err := ix.ActiveExport(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
|
|
before, err := ix.SessionEmbeddedDocHashes(ctx, exp.Ordinal)
|
|
require.NoError(t, err)
|
|
require.Contains(t, before, "session-1")
|
|
require.Contains(t, before, "session-2")
|
|
|
|
// Shift session-1's user doc to a free ordinal slot without touching its
|
|
// content. The UUID-keyed doc_key ("u:session-1:u1") and content_hash are
|
|
// unchanged, so its Build-time stamp still matches and the doc stays
|
|
// embedded. Bump endedAt past the watermark so the incremental scan
|
|
// resurfaces it.
|
|
src.rows[0].unit.Ordinal = 5
|
|
src.rows[0].unit.OrdinalEnd = 5
|
|
src.rows[0].endedAt = "2024-01-02T00:00:00Z"
|
|
_, err = ix.Refresh(ctx, src, false, true)
|
|
require.NoError(t, err)
|
|
|
|
after, err := ix.SessionEmbeddedDocHashes(ctx, exp.Ordinal)
|
|
require.NoError(t, err)
|
|
require.Contains(t, after, "session-1",
|
|
"a pure ordinal shift keeps the doc embedded (content_hash unchanged)")
|
|
assert.NotEqual(t, before["session-1"], after["session-1"],
|
|
"a metadata-only ordinal shift must still move the aggregate")
|
|
assert.Equal(t, before["session-2"], after["session-2"],
|
|
"the untouched session's hash is stable")
|
|
}
|
|
|
|
// TestExportVersionMismatchReturnsSentinel covers the read-path version gate on
|
|
// the export API: a read-only Index over a mirror stamped with a different
|
|
// MirrorSchemaVersion must refuse ActiveExport, SessionEmbeddedDocHashes, and
|
|
// ExportSessionDocs with ErrMirrorVersionMismatch, the same fail-closed
|
|
// behavior Search and StaleActive apply, instead of exporting rows shaped by a
|
|
// different schema.
|
|
func TestExportVersionMismatchReturnsSentinel(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "vectors.db")
|
|
seedV2Mirror(t, path)
|
|
|
|
ro, err := Open(ctx, path, true, 4000)
|
|
require.NoError(t, err, "read-only Open must succeed even against a v2-stamped mirror")
|
|
defer ro.Close()
|
|
|
|
_, _, err = ro.ActiveExport(ctx)
|
|
assert.ErrorIs(t, err, ErrMirrorVersionMismatch)
|
|
|
|
_, err = ro.SessionEmbeddedDocHashes(ctx, 1)
|
|
assert.ErrorIs(t, err, ErrMirrorVersionMismatch)
|
|
|
|
_, _, err = ro.ExportSessionDocs(ctx, 1, "s1")
|
|
assert.ErrorIs(t, err, ErrMirrorVersionMismatch)
|
|
}
|