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.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user