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.
165 lines
5.5 KiB
Go
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))
|
|
}
|
|
}
|