Files
noema/internal/graph/graph_test.go
T
Andy Dixon 99a89ba847 feat: add the relationship graph with extraction, corrections and explorer
Phase 11. Entities are mind-scoped nodes with canonical matching,
aliases and deterministic kind guessing (hostnames and hyphenated ids
are systems, capitalised names people, file suffixes documents).
Relationships carry a relation from the shared vocabulary, confidence,
source, reinforcement count and the event that produced them; links tie
entities to memories, beliefs, events, goals and predictions.

The perceive and attend handlers upsert the entities a message or
observation mentions and record co-mention edges cited to the event, so
the graph grows only from what was actually seen. Operators can merge
("these are the same": edges and links move, the old name becomes an
alias, the old id still resolves), record that two entities are
different, correct kinds and add typed edges; every correction is an
operator action and a corrections row.

The explorer renders a bounded subgraph on a canvas with a small
force-directed layout in plain JavaScript, with kind filters, focus on
a neighbourhood and a side panel per entity. API, entity page, CLI and
docs/graph.md.
2026-06-29 19:28:55 +01:00

86 lines
3.2 KiB
Go

package graph
import (
"context"
"testing"
"time"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/ids"
)
func TestGuessKindAndCanonical(t *testing.T) {
cases := map[string]Kind{"atlas.local": System, "server-01": System, "web-api-prod": System, "Andy": Person, "report.pdf": Document, "andydixon/noema": Repository, "latency": Concept}
for name, want := range cases {
if got := GuessKind(name); got != want {
t.Errorf("%s: got %s want %s", name, got, want)
}
}
if Canonical(" Atlas, ") != "atlas" {
t.Fatal("canonical wrong")
}
}
func TestUpsertRelateMergeAndIsolation(t *testing.T) {
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
ms := minds.NewStore(pool)
a, _ := ms.Create(ctx, minds.CreateParams{Slug: "a"})
b, _ := ms.Create(ctx, minds.CreateParams{Slug: "b"})
s := NewStore(pool)
now := time.Now()
andy, _ := s.Upsert(ctx, pool, a.ID, "", "Andy", now)
andy2, _ := s.Upsert(ctx, pool, a.ID, "", "andy", now)
if andy.ID != andy2.ID || andy2.MentionCount != 2 || andy.Kind != Person {
t.Fatalf("upsert dedupe wrong: %+v %+v", andy, andy2)
}
atlas, _ := s.Upsert(ctx, pool, a.ID, "", "atlas", now)
atlasHost, _ := s.Upsert(ctx, pool, a.ID, "", "atlas.local", now)
if _, err := s.Relate(ctx, pool, a.ID, andy.ID, atlas.ID, concepts.DiscussedWith, 0.3, "co-mention", ids.Nil); err != nil {
t.Fatal(err)
}
r2, _ := s.Relate(ctx, pool, a.ID, andy.ID, atlas.ID, concepts.DiscussedWith, 0.6, "co-mention", ids.Nil)
_ = r2
nb, _ := s.Neighbourhood(ctx, a.ID, andy.ID, 10)
if len(nb) != 1 || nb[0].Count != 2 || nb[0].Confidence != 0.6 {
t.Fatalf("relate reinforcement wrong: %+v", nb)
}
// Cross-mind endpoints are refused.
other, _ := s.Upsert(ctx, pool, b.ID, "", "atlas", now)
if _, err := s.Relate(ctx, pool, a.ID, andy.ID, other.ID, concepts.RelatedTo, 0.5, "x", ids.Nil); err == nil {
t.Fatal("cross-mind edge accepted")
}
if _, err := s.Get(ctx, b.ID, andy.ID); err == nil {
t.Fatal("cross-mind get")
}
// Merge atlas.local into atlas; old id still resolves; alias matches.
s.Relate(ctx, pool, a.ID, atlasHost.ID, andy.ID, concepts.BelongsTo, 0.5, "x", ids.Nil) //nolint:errcheck
s.LinkRef(ctx, pool, a.ID, atlasHost.ID, "memory", ids.New()) //nolint:errcheck
if err := s.Merge(ctx, a.ID, atlas.ID, atlasHost.ID); err != nil {
t.Fatal(err)
}
got, err := s.Get(ctx, a.ID, atlasHost.ID)
if err != nil || got.ID != atlas.ID || len(got.Aliases) == 0 {
t.Fatalf("merge resolution wrong: %v %+v", err, got)
}
again, _ := s.Upsert(ctx, pool, a.ID, "", "atlas.local", now)
if again.ID != atlas.ID {
t.Fatal("alias did not match after merge")
}
links, _ := s.Links(ctx, a.ID, atlas.ID, 10)
if len(links) != 1 {
t.Fatalf("links not moved: %d", len(links))
}
sg, _ := s.Subgraph(ctx, a.ID, ids.Nil, "", 50)
if len(sg.Nodes) != 2 || len(sg.Edges) < 1 || sg.Total != 2 {
t.Fatalf("subgraph wrong: %d nodes %d edges total %d", len(sg.Nodes), len(sg.Edges), sg.Total)
}
st, _ := s.Stats(ctx, a.ID)
if st.Entities != 2 || st.ByKind["person"] != 1 {
t.Fatalf("stats: %+v", st)
}
}