Files
exo/kg/kg_test.go
Kyle Isom 051a85d846 Add Phase 4 knowledge graph: nodes, cells, facts, edges, gRPC service
Build the knowledge graph pillar with the kg package:
- Node: hierarchical notes with parent/children, C2 wiki-style naming,
  shared tag/category pool with artifacts
- Cell: content units (markdown, code, plain) with ordinal ordering
- Fact: EAV tuples with transaction timestamps and retraction support
- Edge: directed graph links (child, parent, related, artifact_link)

Includes schema migration (002_knowledge_graph.sql), protobuf definitions
(kg.proto), full gRPC KnowledgeGraphService implementation, CLI commands
(node create/get), and comprehensive test coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:05:43 -07:00

418 lines
10 KiB
Go

package kg
import (
"context"
"database/sql"
"path/filepath"
"testing"
"time"
"git.wntrmute.dev/kyle/exo/artifacts"
"git.wntrmute.dev/kyle/exo/core"
"git.wntrmute.dev/kyle/exo/db"
)
func mustOpenAndMigrate(t *testing.T) *sql.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
database, err := db.Open(path)
if err != nil {
t.Fatalf("Open failed: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if err := db.Migrate(database); err != nil {
t.Fatalf("Migrate failed: %v", err)
}
return database
}
func mustTX(t *testing.T, database *sql.DB) (*sql.Tx, context.Context) {
t.Helper()
ctx := context.Background()
tx, err := db.StartTX(ctx, database)
if err != nil {
t.Fatalf("StartTX failed: %v", err)
}
return tx, ctx
}
// --- Node tests ---
func TestNodeStoreAndGet(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
node := NewNode("TestNote", NodeTypeNote)
if err := node.Store(ctx, tx); err != nil {
t.Fatalf("Node.Store failed: %v", err)
}
got := &Node{ID: node.ID}
if err := got.Get(ctx, tx); err != nil {
t.Fatalf("Node.Get failed: %v", err)
}
if got.Name != "TestNote" {
t.Fatalf("name mismatch: got %q", got.Name)
}
if got.Type != NodeTypeNote {
t.Fatalf("type mismatch: got %q", got.Type)
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
func TestNodeHierarchy(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
parent := NewNode("ParentNote", NodeTypeNote)
if err := parent.Store(ctx, tx); err != nil {
t.Fatalf("parent Store failed: %v", err)
}
child := NewNode("ChildNote", NodeTypeNote)
child.ParentID = parent.ID
if err := child.Store(ctx, tx); err != nil {
t.Fatalf("child Store failed: %v", err)
}
// Retrieve parent and check children.
gotParent := &Node{ID: parent.ID}
if err := gotParent.Get(ctx, tx); err != nil {
t.Fatalf("parent Get failed: %v", err)
}
if len(gotParent.Children) != 1 {
t.Fatalf("expected 1 child, got %d", len(gotParent.Children))
}
if gotParent.Children[0] != child.ID {
t.Fatalf("child ID mismatch: got %q", gotParent.Children[0])
}
// Retrieve child and check parent.
gotChild := &Node{ID: child.ID}
if err := gotChild.Get(ctx, tx); err != nil {
t.Fatalf("child Get failed: %v", err)
}
if gotChild.ParentID != parent.ID {
t.Fatalf("parent ID mismatch: got %q", gotChild.ParentID)
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
func TestNodeWithTags(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
// Create tags first (shared pool).
if err := artifacts.CreateTag(ctx, tx, "philosophy"); err != nil {
t.Fatalf("CreateTag failed: %v", err)
}
node := NewNode("PhilosophyNote", NodeTypeNote)
node.Tags["philosophy"] = true
if err := node.Store(ctx, tx); err != nil {
t.Fatalf("Node.Store failed: %v", err)
}
got := &Node{ID: node.ID}
if err := got.Get(ctx, tx); err != nil {
t.Fatalf("Node.Get failed: %v", err)
}
if !got.Tags["philosophy"] {
t.Fatalf("tag not found: %v", got.Tags)
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
func TestGetNodeByName(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
node := NewNode("FindMe", NodeTypeNote)
if err := node.Store(ctx, tx); err != nil {
t.Fatalf("Store failed: %v", err)
}
got, err := GetNodeByName(ctx, tx, "FindMe")
if err != nil {
t.Fatalf("GetNodeByName failed: %v", err)
}
if got == nil {
t.Fatal("expected to find node")
}
if got.ID != node.ID {
t.Fatalf("ID mismatch: got %q, want %q", got.ID, node.ID)
}
missing, err := GetNodeByName(ctx, tx, "NotHere")
if err != nil {
t.Fatalf("GetNodeByName failed: %v", err)
}
if missing != nil {
t.Fatal("expected nil for missing node")
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
// --- Cell tests ---
func TestCellStoreAndGet(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
node := NewNode("CellNote", NodeTypeNote)
if err := node.Store(ctx, tx); err != nil {
t.Fatalf("Node.Store failed: %v", err)
}
cell := NewCell(node.ID, CellTypeMarkdown, []byte("# Hello\nThis is a note."))
if err := cell.Store(ctx, tx); err != nil {
t.Fatalf("Cell.Store failed: %v", err)
}
got := &Cell{ID: cell.ID}
if err := got.Get(ctx, tx); err != nil {
t.Fatalf("Cell.Get failed: %v", err)
}
if got.NodeID != node.ID {
t.Fatalf("node ID mismatch: got %q", got.NodeID)
}
if got.Type != CellTypeMarkdown {
t.Fatalf("type mismatch: got %q", got.Type)
}
if string(got.Contents) != "# Hello\nThis is a note." {
t.Fatalf("contents mismatch: got %q", got.Contents)
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
func TestGetCellsForNode(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
node := NewNode("MultiCell", NodeTypeNote)
if err := node.Store(ctx, tx); err != nil {
t.Fatalf("Store failed: %v", err)
}
c1 := NewCell(node.ID, CellTypeMarkdown, []byte("First"))
c1.Ordinal = 0
c2 := NewCell(node.ID, CellTypeCode, []byte("fmt.Println()"))
c2.Ordinal = 1
c3 := NewCell(node.ID, CellTypeMarkdown, []byte("Last"))
c3.Ordinal = 2
for _, c := range []*Cell{c1, c2, c3} {
if err := c.Store(ctx, tx); err != nil {
t.Fatalf("Cell.Store failed: %v", err)
}
}
cells, err := GetCellsForNode(ctx, tx, node.ID)
if err != nil {
t.Fatalf("GetCellsForNode failed: %v", err)
}
if len(cells) != 3 {
t.Fatalf("expected 3 cells, got %d", len(cells))
}
if string(cells[0].Contents) != "First" {
t.Fatalf("first cell content mismatch: %q", cells[0].Contents)
}
if cells[1].Type != CellTypeCode {
t.Fatalf("second cell type mismatch: %q", cells[1].Type)
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
// --- Fact tests ---
func TestFactStoreAndGet(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
entityID := core.NewUUID()
attrID := core.NewUUID()
fact := NewFact(entityID, "TestEntity", attrID, "color", core.Vals("blue"))
if err := fact.Store(ctx, tx); err != nil {
t.Fatalf("Fact.Store failed: %v", err)
}
facts, err := GetFactsForEntity(ctx, tx, entityID)
if err != nil {
t.Fatalf("GetFactsForEntity failed: %v", err)
}
if len(facts) != 1 {
t.Fatalf("expected 1 fact, got %d", len(facts))
}
if facts[0].Value.Contents != "blue" {
t.Fatalf("value mismatch: %q", facts[0].Value.Contents)
}
if facts[0].Retraction {
t.Fatal("should not be a retraction")
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
func TestFactRetraction(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
entityID := core.NewUUID()
attrID := core.NewUUID()
// Assert a fact.
fact := NewFact(entityID, "Entity", attrID, "status", core.Vals("active"))
if err := fact.Store(ctx, tx); err != nil {
t.Fatalf("Store assertion failed: %v", err)
}
// Retract it.
time.Sleep(10 * time.Millisecond) // ensure different timestamp
retraction := fact.Retract()
if err := retraction.Store(ctx, tx); err != nil {
t.Fatalf("Store retraction failed: %v", err)
}
// All facts should include both.
allFacts, err := GetFactsForEntity(ctx, tx, entityID)
if err != nil {
t.Fatalf("GetFactsForEntity failed: %v", err)
}
if len(allFacts) != 2 {
t.Fatalf("expected 2 facts (assertion + retraction), got %d", len(allFacts))
}
// Current facts should be empty (retracted).
current, err := GetCurrentFactsForEntity(ctx, tx, entityID)
if err != nil {
t.Fatalf("GetCurrentFactsForEntity failed: %v", err)
}
if len(current) != 0 {
t.Fatalf("expected 0 current facts after retraction, got %d", len(current))
}
// Assert a new value.
fact2 := NewFact(entityID, "Entity", attrID, "status", core.Vals("archived"))
if err := fact2.Store(ctx, tx); err != nil {
t.Fatalf("Store second assertion failed: %v", err)
}
current2, err := GetCurrentFactsForEntity(ctx, tx, entityID)
if err != nil {
t.Fatalf("GetCurrentFactsForEntity failed: %v", err)
}
if len(current2) != 1 {
t.Fatalf("expected 1 current fact, got %d", len(current2))
}
if current2[0].Value.Contents != "archived" {
t.Fatalf("expected 'archived', got %q", current2[0].Value.Contents)
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
// --- Edge tests ---
func TestEdgeStoreAndGet(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
n1 := NewNode("Source", NodeTypeNote)
n2 := NewNode("Target", NodeTypeNote)
if err := n1.Store(ctx, tx); err != nil {
t.Fatalf("Store n1 failed: %v", err)
}
if err := n2.Store(ctx, tx); err != nil {
t.Fatalf("Store n2 failed: %v", err)
}
edge := NewEdge(n1.ID, n2.ID, EdgeRelationRelated)
if err := edge.Store(ctx, tx); err != nil {
t.Fatalf("Edge.Store failed: %v", err)
}
// Get edges from source.
fromEdges, err := GetEdgesFrom(ctx, tx, n1.ID)
if err != nil {
t.Fatalf("GetEdgesFrom failed: %v", err)
}
if len(fromEdges) != 1 {
t.Fatalf("expected 1 edge from source, got %d", len(fromEdges))
}
if fromEdges[0].TargetID != n2.ID {
t.Fatalf("target mismatch: got %q", fromEdges[0].TargetID)
}
if fromEdges[0].Relation != EdgeRelationRelated {
t.Fatalf("relation mismatch: got %q", fromEdges[0].Relation)
}
// Get edges to target.
toEdges, err := GetEdgesTo(ctx, tx, n2.ID)
if err != nil {
t.Fatalf("GetEdgesTo failed: %v", err)
}
if len(toEdges) != 1 {
t.Fatalf("expected 1 edge to target, got %d", len(toEdges))
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}
func TestNodeToArtifactLink(t *testing.T) {
database := mustOpenAndMigrate(t)
tx, ctx := mustTX(t, database)
node := NewNode("Research", NodeTypeNote)
if err := node.Store(ctx, tx); err != nil {
t.Fatalf("Store failed: %v", err)
}
// Link to an artifact ID (doesn't need to exist in edges table).
artifactID := core.NewUUID()
edge := NewEdge(node.ID, artifactID, EdgeRelationArtifactLink)
if err := edge.Store(ctx, tx); err != nil {
t.Fatalf("Edge.Store failed: %v", err)
}
edges, err := GetEdgesFrom(ctx, tx, node.ID)
if err != nil {
t.Fatalf("GetEdgesFrom failed: %v", err)
}
if len(edges) != 1 {
t.Fatalf("expected 1 edge, got %d", len(edges))
}
if edges[0].Relation != EdgeRelationArtifactLink {
t.Fatalf("relation should be artifact_link, got %q", edges[0].Relation)
}
if err := db.EndTX(tx, nil); err != nil {
t.Fatalf("EndTX failed: %v", err)
}
}