Files
Andy Dixon fb34f08f40 feat: add mind model with versioned personality, templates and self-model
Phase 2. Minds are rows with a slug, status (active/disabled/archived),
cognitive configuration and a pointer to the current personality version.
Personalities are structured (identity, values, traits, communication,
behaviour, free text) with a deterministic Brief() used by prompts and the
editor preview. Every change appends a personality_versions row protected
by the append-only trigger; restore copies an old version forward.
Templates carry reusable personalities; minds record their overrides so a
template can be re-applied without losing mind-specific edits. The
self-model starts with structural limitations the mind cannot drop
(no policy changes, no influence over shutdown, no cross-mind access).

API, UI (list, overview, editor with sliders/diff/history/import/export,
template editor) and noemactl mind/template commands. All mutations are
audited with field-level diffs.
2026-06-15 22:17:01 +01:00

165 lines
5.5 KiB
Go

package minds
import (
"context"
"errors"
"strings"
"testing"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
)
func TestBriefIsDeterministicAndReflectsStructure(t *testing.T) {
p := DefaultPersonality("Dani")
p.Traits["humour"] = 0.9
p.Values["caution"] = 0.1
p.Communication.Formality = 0.2
b1, b2 := p.Brief(), p.Brief()
if b1 != b2 {
t.Fatal("brief not deterministic")
}
for _, want := range []string{"You are Dani", "Pronounced traits: humour", "little weight on: caution", "casually", "I do not know"} {
if !strings.Contains(b1, want) {
t.Errorf("brief missing %q:\n%s", want, b1)
}
}
}
func TestDiffAndOverridesRoundTrip(t *testing.T) {
base := DefaultPersonality("t")
mod := base
mod.Traits = map[string]float64{}
for k, v := range base.Traits {
mod.Traits[k] = v
}
mod.Traits["humour"] = 0.9
mod.Identity.Role = "investigator"
mod.Behaviour.NotifyProactively = true
d := Diff(base, mod)
if len(d) != 3 {
t.Fatalf("expected 3 changes, got %+v", d)
}
ov := Overrides(base, mod)
back := ApplyOverrides(base, ov)
if len(Diff(mod, back)) != 0 {
t.Fatalf("overrides did not round-trip: %+v", Diff(mod, back))
}
// Applying the same overrides to a changed template keeps the overrides and takes the rest from the template.
base2 := base
base2.Values = map[string]float64{}
for k, v := range base.Values {
base2.Values[k] = v
}
base2.Values["kindness"] = 0.95
res := ApplyOverrides(base2, ov)
if res.Values["kindness"] != 0.95 || res.Traits["humour"] != 0.9 || res.Identity.Role != "investigator" {
t.Fatalf("template change + overrides wrong: %+v", res)
}
}
func TestNormaliseClampsAndFills(t *testing.T) {
p := Personality{Identity: Identity{Name: "x"}, Values: map[string]float64{"privacy": 7, "bogus": 1}, Traits: nil}
p.Normalise()
if p.Values["privacy"] != 1 || len(p.Values) != len(ValueKeys) || len(p.Traits) != len(TraitKeys) || p.Communication.Language != "en" {
t.Fatalf("normalise wrong: %+v", p)
}
if _, ok := p.Values["bogus"]; ok {
t.Fatal("unknown key kept")
}
}
func TestStoreLifecycle(t *testing.T) {
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
s := NewStore(pool)
if _, err := s.Create(ctx, CreateParams{Slug: "Bad Slug!"}); !errors.Is(err, ErrInvalid) {
t.Fatalf("expected invalid slug, got %v", err)
}
m, err := s.Create(ctx, CreateParams{Slug: "dani", DisplayName: "Dani", CreatedBy: "test"})
if err != nil {
t.Fatal(err)
}
if _, err := s.Create(ctx, CreateParams{Slug: "DANI"}); !errors.Is(err, storage.ErrConflict) {
t.Fatalf("expected conflict, got %v", err)
}
got, err := s.Resolve(ctx, "dani")
if err != nil || got.ID != m.ID || got.PersonalityVersion != 1 || got.Config.WorkspaceCapacity != 7 {
t.Fatalf("resolve: %v %+v", err, got)
}
sm, err := s.GetSelfModel(ctx, m.ID)
if err != nil || len(sm.Limitations) < 3 || sm.Name != "Dani" {
t.Fatalf("self model: %v %+v", err, sm)
}
// New personality version, then restore.
p := got.Personality
p.Traits["curiosity"] = 0.95
p.FreeText = "Loves old hardware."
v2, err := s.SavePersonality(ctx, m.ID, p, "more curious", "test")
if err != nil || v2.Version != 2 {
t.Fatalf("save: %v %+v", err, v2)
}
v3, err := s.Restore(ctx, m.ID, 1, "test")
if err != nil || v3.Version != 3 || v3.Personality.Traits["curiosity"] != 0.5 {
t.Fatalf("restore: %v %+v", err, v3)
}
vs, _ := s.ListVersions(ctx, m.ID)
if len(vs) != 3 {
t.Fatalf("expected 3 versions, got %d", len(vs))
}
// History is immutable.
if _, err := pool.Exec(ctx, `DELETE FROM personality_versions WHERE mind_id=$1`, m.ID); err == nil {
t.Fatal("personality history should be append-only")
}
// Templates and overrides survive re-application.
tp := DefaultPersonality("Investigator")
tp.Traits["scepticism"] = 0.9
tmpl, err := s.CreateTemplate(ctx, "investigator", "sceptical analyst", tp)
if err != nil {
t.Fatal(err)
}
v4, err := s.ApplyTemplate(ctx, m.ID, &tmpl.ID, "test")
if err != nil || v4.Personality.Traits["scepticism"] != 0.9 || v4.Personality.Identity.DisplayName != "Dani" {
t.Fatalf("apply template: %v %+v", err, v4.Personality)
}
p4 := v4.Personality
p4.Traits["humour"] = 0.85
v5, err := s.SavePersonality(ctx, m.ID, p4, "funnier", "test")
if err != nil {
t.Fatal(err)
}
if _, ok := v5.Overrides["traits"].(map[string]any)["humour"]; !ok {
t.Fatalf("override not recorded: %+v", v5.Overrides)
}
tp.Values["kindness"] = 0.99
if _, err := s.UpdateTemplate(ctx, tmpl.ID, "investigator", "v2", tp); err != nil {
t.Fatal(err)
}
v6, err := s.ApplyTemplate(ctx, m.ID, &tmpl.ID, "test")
if err != nil || v6.Personality.Values["kindness"] != 0.99 || v6.Personality.Traits["humour"] != 0.85 {
t.Fatalf("re-apply lost overrides or template change: %+v", v6.Personality)
}
if err := s.DeleteTemplate(ctx, tmpl.ID); !errors.Is(err, ErrInvalid) {
t.Fatalf("in-use template deletion should be refused, got %v", err)
}
// Clone copies personality only.
c, err := s.Clone(ctx, m.ID, "dani-2", "Dani Two", "test")
if err != nil || c.Personality.Traits["humour"] != 0.85 || c.ClonedFromMind == nil || *c.ClonedFromMind != m.ID || c.TemplateID == nil {
t.Fatalf("clone: %v %+v", err, c)
}
// Status transitions and list filtering.
st := StatusArchived
if _, err := s.Update(ctx, c.ID, UpdateParams{Status: &st}); err != nil {
t.Fatal(err)
}
live, _ := s.List(ctx, false)
all, _ := s.List(ctx, true)
if len(live) != 1 || len(all) != 2 {
t.Fatalf("list filtering wrong: %d live %d all", len(live), len(all))
}
}