All checks were successful
release-tag / release-image (push) Successful in 2m30s
204 lines
6.3 KiB
Go
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)
|
|
}
|
|
}
|