Files
glpi-neural-brain/internal/graph/sqlite_backend_test.go
jbergner b4388d1ebc
All checks were successful
release-tag / release-image (push) Successful in 2m30s
Update 6 SQLite
2026-08-05 05:23:43 +02:00

204 lines
6.3 KiB
Go

package graph
import (
"context"
"math"
"os"
"path/filepath"
"testing"
"time"
"github.com/local/glpi-neural-brain/internal/model"
)
func TestSQLiteRoundTripUsesBinaryFloat32Vectors(t *testing.T) {
dir := t.TempDir()
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
s.UpsertNode(model.Node{ID: "a", Kind: "knowledge", Label: "A", Origin: "test", Categories: []string{"Netzwerk"}, Metadata: map[string]any{"rank": 2}})
s.UpsertNode(model.Node{ID: "b", Kind: "knowledge", Label: "B", Origin: "test"})
s.UpsertEdge(model.Edge{Source: "a", Target: "b", Type: "related_to", Origin: "test", Confidence: .8})
s.SetVector("a", []float64{.125, -.5, .875})
if _, err := s.PersistVersionContext(context.Background()); err != nil {
t.Fatal(err)
}
status := s.StorageStatus()
if status.Backend != "sqlite" || status.VectorBytes != 12 || status.PendingNodes != 0 || status.PendingVectors != 0 {
t.Fatalf("unexpected storage status: %+v", status)
}
if _, err := os.Stat(filepath.Join(dir, "graph-state.json")); !os.IsNotExist(err) {
t.Fatalf("legacy JSON snapshot must not be created: %v", err)
}
if err := s.Close(); err != nil {
t.Fatal(err)
}
reopened, err := Open(dir)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if got := reopened.Snapshot(); len(got.Nodes) != 2 || len(got.Edges) != 1 {
t.Fatalf("unexpected round-trip graph: nodes=%d edges=%d", len(got.Nodes), len(got.Edges))
}
vector, ok := reopened.Vector("a")
if !ok || len(vector) != 3 {
t.Fatalf("vector missing after round trip: %v %v", ok, vector)
}
want := []float64{.125, -.5, .875}
for i := range want {
if math.Abs(vector[i]-want[i]) > 1e-6 {
t.Fatalf("vector[%d]=%f want %f", i, vector[i], want[i])
}
}
}
func TestSQLitePersistsOnlyDirtyRows(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.UpsertNode(model.Node{ID: "a", Kind: "knowledge", Label: "A", Origin: "test"})
s.UpsertNode(model.Node{ID: "b", Kind: "knowledge", Label: "B", Origin: "test"})
s.SetVector("a", []float64{1, 0})
if _, err := s.PersistVersion(); err != nil {
t.Fatal(err)
}
if s.Dirty() {
t.Fatal("store remained dirty after successful flush")
}
n, _ := s.GetNode("a")
n.Label = "A2"
s.UpsertNode(n)
status := s.StorageStatus()
if status.PendingNodes != 1 || status.PendingEdges != 0 || status.PendingVectors != 0 {
t.Fatalf("expected one dirty node, got %+v", status)
}
if _, err := s.PersistVersion(); err != nil {
t.Fatal(err)
}
if status = s.StorageStatus(); status.PendingNodes != 0 || s.Dirty() {
t.Fatalf("dirty rows were not cleared: %+v", status)
}
}
func TestSQLiteExportCreatesPortableDatabase(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.UpsertNode(model.Node{ID: "portable", Kind: "knowledge", Label: "Portable", Origin: "test"})
s.SetVector("portable", []float64{1, 2, 3, 4})
exportDir := t.TempDir()
destination := filepath.Join(exportDir, "graph.db")
if err := s.Export(context.Background(), destination); err != nil {
t.Fatal(err)
}
copyStore, err := Open(exportDir)
if err != nil {
t.Fatal(err)
}
defer copyStore.Close()
if _, ok := copyStore.GetNode("portable"); !ok {
t.Fatal("exported database did not contain graph node")
}
if vector, ok := copyStore.Vector("portable"); !ok || len(vector) != 4 {
t.Fatalf("exported database did not contain vector: %v %v", ok, vector)
}
}
func TestReplaceOriginsLeavesUnchangedKnowledgeClean(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer s.Close()
updated := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
nodes := []model.Node{
{ID: "a", Kind: "knowledge", Label: "A", Summary: "gleich", Origin: "kb", UpdatedAt: updated},
{ID: "b", Kind: "knowledge", Label: "B", Summary: "gleich", Origin: "kb", UpdatedAt: updated},
}
edges := []model.Edge{{Source: "a", Target: "b", Type: "related_to", Origin: "kb", Weight: 1}}
s.ReplaceOrigins([]string{"kb"}, nodes, edges)
s.SetVector("a", []float64{1, 0, 0})
if _, err := s.PersistVersion(); err != nil {
t.Fatal(err)
}
version := s.Version()
s.ReplaceOrigins([]string{"kb"}, nodes, edges)
if s.Dirty() {
t.Fatalf("unchanged KB scan marked SQLite rows dirty: %+v", s.StorageStatus())
}
if s.Version() != version {
t.Fatalf("unchanged KB scan changed graph version: got %d want %d", s.Version(), version)
}
if _, ok := s.Vector("a"); !ok {
t.Fatal("unchanged KB scan discarded embedding")
}
}
func TestReplaceOriginsInvalidatesOnlyChangedEmbedding(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer s.Close()
updated := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
nodes := []model.Node{{ID: "a", Kind: "knowledge", Label: "A", Summary: "alt", Origin: "kb", UpdatedAt: updated}}
s.ReplaceOrigins([]string{"kb"}, nodes, nil)
s.SetVector("a", []float64{1, 0, 0})
if _, err := s.PersistVersion(); err != nil {
t.Fatal(err)
}
nodes[0].Summary = "neu"
nodes[0].UpdatedAt = updated.Add(time.Minute)
s.ReplaceOrigins([]string{"kb"}, nodes, nil)
if _, ok := s.Vector("a"); ok {
t.Fatal("changed embedding input retained stale vector")
}
status := s.StorageStatus()
if status.PendingNodes != 1 || status.PendingDeletions != 1 {
t.Fatalf("expected changed node and vector deletion, got %+v", status)
}
}
func TestEmbeddingModelIdentityInvalidatesImportedVectors(t *testing.T) {
dir := t.TempDir()
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
s.ConfigureEmbeddingIdentity("embeddinggemma", "sha256:d1")
s.UpsertNode(model.Node{ID: "a", Kind: "knowledge", Label: "A", Origin: "test"})
s.SetVector("a", []float64{1, 2, 3})
if _, err := s.PersistVersion(); err != nil {
t.Fatal(err)
}
if err := s.Close(); err != nil {
t.Fatal(err)
}
reopened, err := Open(dir)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if removed := reopened.ConfigureEmbeddingIdentity("embeddinggemma", "sha256:d2"); removed != 1 {
t.Fatalf("removed vectors=%d want 1", removed)
}
if _, ok := reopened.Vector("a"); ok {
t.Fatal("vector from a different embedding model remained available")
}
status := reopened.StorageStatus()
if status.EmbeddingModel != "embeddinggemma" || status.EmbeddingDigest != "sha256:d2" || !status.PendingMetadata || status.PendingDeletions != 1 {
t.Fatalf("unexpected model-change status: %+v", status)
}
}