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) } }