Phase 15: snapshots, forks, export/import, cognitive replay, experiments
- snapshots: checksummed JSON archive of every mind-scoped state table plus identity/personality/config and the event position; stored, downloadable - fork: restore into a new mind with all ids remapped; event references are kept so history before the fork is shared by reference - export/import: same archive; import is hostile-input validated (size, allow-listed tables, row caps, column names, checksum, forced mind_id) - compare: deterministic counts, belief diffs by proposition, personality and config diffs, event mix - replay: Runtime.Step for synchronous drivers; sandbox fork driven one stimulus at a time under a forbidden capability context; recorded model responses via a replaying router; steps labelled exact / approximate / diverged with recorded-vs-replayed digests - experiments: definition (overrides, stimuli, expectations, seed, duration), background run in a disabled subject mind, stored and exportable results - migration 0015, API, UI (Snapshots and Replay tabs, Experiments section, compare page), noemactl snapshots/compare/replay/experiments, docs
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
// Package snapshots captures a mind's state as a self-describing archive,
|
||||
// restores archives into new minds (fork, import) and compares minds.
|
||||
//
|
||||
// An archive is JSON: identity, personality, configuration and the rows of
|
||||
// every mind-scoped state table. Cognitive history (events) is not copied: a
|
||||
// fork shares it by reference through forked_from_snapshot → event_seq. Every
|
||||
// id inside an archive is remapped on restore, so an archive can never
|
||||
// overwrite existing rows, and imported archives are treated as hostile:
|
||||
// bounded size, allow-listed tables, checksum, no secrets.
|
||||
package snapshots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
evstore "github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/internal/minds"
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// Kind and Version identify the archive format.
|
||||
const (
|
||||
Kind = "noema.mind-archive"
|
||||
Version = 1
|
||||
// MaxArchiveBytes bounds what import will parse.
|
||||
MaxArchiveBytes = 32 << 20
|
||||
// MaxRows bounds rows per table on import.
|
||||
MaxRows = 200_000
|
||||
)
|
||||
|
||||
// Tables lists the state tables copied, in insertion order (parents before
|
||||
// children; rows within a table are ordered by id, and UUIDv7 ids are
|
||||
// time-ordered, so self-references resolve). Anything not listed (events,
|
||||
// llm_calls, approvals, integrations, job logs, notifications) stays with
|
||||
// the original mind.
|
||||
var Tables = []string{
|
||||
"workspace_states", "self_models", "drives", "affect_states",
|
||||
"goals", "beliefs", "belief_evidence", "belief_alternatives", "belief_versions", "belief_links",
|
||||
"memories", "memory_relationships", "predictions", "prediction_results",
|
||||
"entities", "entity_relationships", "entity_links", "corrections", "policies",
|
||||
"conversations", "messages",
|
||||
}
|
||||
|
||||
// keyedByMind are tables whose primary key includes mind_id; a fresh mind may
|
||||
// already hold a row, so restore upserts them.
|
||||
var keyedByMind = map[string]string{"workspace_states": "mind_id", "self_models": "mind_id", "affect_states": "mind_id", "drives": "mind_id, name"}
|
||||
|
||||
// Archive is the portable form of a mind's state.
|
||||
type Archive struct {
|
||||
Kind string `json:"kind"`
|
||||
Version int `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Source Source `json:"source"`
|
||||
Mind MindInfo `json:"mind"`
|
||||
// Tables maps table name → rows (column → value, as JSON).
|
||||
Tables map[string][]map[string]any `json:"tables"`
|
||||
Checksum string `json:"checksum"`
|
||||
}
|
||||
|
||||
// Source records where an archive came from.
|
||||
type Source struct {
|
||||
MindID ids.ID `json:"mind_id"`
|
||||
Slug string `json:"slug"`
|
||||
EventSeq int64 `json:"event_seq"`
|
||||
Noema string `json:"noema"`
|
||||
}
|
||||
|
||||
// MindInfo carries the identity part of an archive.
|
||||
type MindInfo struct {
|
||||
Slug string `json:"slug"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Notes string `json:"notes"`
|
||||
Status string `json:"status"`
|
||||
PersonalityVersion int `json:"personality_version"`
|
||||
Personality minds.Personality `json:"personality"`
|
||||
Config minds.CognitiveConfig `json:"config"`
|
||||
}
|
||||
|
||||
// Counts summarises an archive.
|
||||
func (a Archive) Counts() map[string]int {
|
||||
out := map[string]int{}
|
||||
for t, rows := range a.Tables {
|
||||
if len(rows) > 0 {
|
||||
out[t] = len(rows)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// canonical returns the JSON the checksum covers (everything but the checksum).
|
||||
func (a Archive) canonical() []byte {
|
||||
c := a
|
||||
c.Checksum = ""
|
||||
b, _ := json.Marshal(c) // encoding/json sorts map keys, so this is stable
|
||||
return b
|
||||
}
|
||||
|
||||
// Sum computes the archive checksum.
|
||||
func Sum(a Archive) string {
|
||||
h := sha256.Sum256(a.canonical())
|
||||
return "sha256:" + hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// ErrInvalid marks an archive that fails validation.
|
||||
var ErrInvalid = errors.New("snapshots: invalid archive")
|
||||
|
||||
var tableSet = func() map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, t := range Tables {
|
||||
m[t] = true
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
|
||||
// Validate checks structure, bounds and checksum. It does not trust anything
|
||||
// inside rows; restore remaps every id and forces mind_id.
|
||||
func Validate(a Archive) error {
|
||||
if a.Kind != Kind {
|
||||
return fmt.Errorf("%w: kind %q", ErrInvalid, a.Kind)
|
||||
}
|
||||
if a.Version != Version {
|
||||
return fmt.Errorf("%w: unsupported version %d", ErrInvalid, a.Version)
|
||||
}
|
||||
if a.Checksum == "" || a.Checksum != Sum(a) {
|
||||
return fmt.Errorf("%w: checksum mismatch", ErrInvalid)
|
||||
}
|
||||
if !slugRe.MatchString(a.Mind.Slug) {
|
||||
return fmt.Errorf("%w: bad slug", ErrInvalid)
|
||||
}
|
||||
total := 0
|
||||
for t, rows := range a.Tables {
|
||||
if !tableSet[t] {
|
||||
return fmt.Errorf("%w: unknown table %q", ErrInvalid, t)
|
||||
}
|
||||
if len(rows) > MaxRows {
|
||||
return fmt.Errorf("%w: table %s has too many rows", ErrInvalid, t)
|
||||
}
|
||||
total += len(rows)
|
||||
for _, r := range rows {
|
||||
for k := range r {
|
||||
if !colRe.MatchString(k) {
|
||||
return fmt.Errorf("%w: bad column name in %s", ErrInvalid, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if total > 4*MaxRows {
|
||||
return fmt.Errorf("%w: too many rows", ErrInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var colRe = regexp.MustCompile(`^[a-z_][a-z0-9_]{0,62}$`)
|
||||
|
||||
// Capture builds an archive from the live state of a mind.
|
||||
func Capture(ctx context.Context, db *pgxpool.Pool, es *evstore.Store, m minds.Mind) (Archive, error) {
|
||||
a := Archive{Kind: Kind, Version: Version, CreatedAt: time.Now().UTC(), Tables: map[string][]map[string]any{},
|
||||
Source: Source{MindID: m.ID, Slug: m.Slug, Noema: "noema/1"},
|
||||
Mind: MindInfo{Slug: m.Slug, DisplayName: m.DisplayName, Notes: m.Notes, Status: string(m.Status), PersonalityVersion: m.PersonalityVersion, Personality: m.Personality, Config: m.Config}}
|
||||
if last, err := es.Query(ctx, evstore.Filter{MindID: m.ID, Limit: 1}); err == nil && len(last) > 0 {
|
||||
a.Source.EventSeq = last[0].Seq
|
||||
}
|
||||
cols, err := columns(ctx, db)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
for _, t := range Tables {
|
||||
cs := cols[t]
|
||||
if len(cs) == 0 {
|
||||
continue
|
||||
}
|
||||
order := "mind_id"
|
||||
if contains(cs, "id") {
|
||||
order = "id"
|
||||
} else if t == "drives" {
|
||||
order = "name"
|
||||
}
|
||||
rows, err := db.Query(ctx, fmt.Sprintf(`SELECT to_jsonb(t) - 'search' FROM %s t WHERE mind_id=$1 ORDER BY %s`, t, order), m.ID)
|
||||
if err != nil {
|
||||
return a, fmt.Errorf("capture %s: %w", t, err)
|
||||
}
|
||||
var out []map[string]any
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
rows.Close()
|
||||
return a, err
|
||||
}
|
||||
var row map[string]any
|
||||
if err := json.Unmarshal(raw, &row); err != nil {
|
||||
rows.Close()
|
||||
return a, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return a, err
|
||||
}
|
||||
if out != nil {
|
||||
a.Tables[t] = out
|
||||
}
|
||||
}
|
||||
a.Checksum = Sum(a)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// columns returns insertable (non-generated) columns for every archive table.
|
||||
func columns(ctx context.Context, db *pgxpool.Pool) (map[string][]string, error) {
|
||||
rows, err := db.Query(ctx, `SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public' AND is_generated='NEVER' AND table_name = ANY($1) ORDER BY table_name, ordinal_position`, Tables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string][]string{}
|
||||
for rows.Next() {
|
||||
var t, c string
|
||||
if err := rows.Scan(&t, &c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[t] = append(out[t], c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func contains(xs []string, x string) bool {
|
||||
for _, s := range xs {
|
||||
if s == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var uuidRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||
|
||||
// Restore writes an archive's rows into mind `into`. Every row id found in
|
||||
// the archive gets a fresh id and all references to it (any uuid-shaped
|
||||
// string anywhere in the rows, including arrays and JSON detail) follow.
|
||||
// References to ids not in the archive (events, templates) are kept: that
|
||||
// is how history stays shared by reference. Must run in the caller's
|
||||
// transaction after the mind row exists.
|
||||
func Restore(ctx context.Context, tx pgx.Tx, cols map[string][]string, a Archive, into ids.ID) error {
|
||||
remap := map[string]string{a.Source.MindID.String(): into.String()}
|
||||
for _, t := range Tables {
|
||||
for _, r := range a.Tables[t] {
|
||||
if id, ok := r["id"].(string); ok && uuidRe.MatchString(id) {
|
||||
if _, seen := remap[id]; !seen {
|
||||
remap[id] = ids.New().String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, t := range Tables {
|
||||
rows := a.Tables[t]
|
||||
if len(rows) == 0 {
|
||||
continue
|
||||
}
|
||||
cs := cols[t]
|
||||
if len(cs) == 0 {
|
||||
return fmt.Errorf("%w: table %s not present", ErrInvalid, t)
|
||||
}
|
||||
conflict := ""
|
||||
if key, ok := keyedByMind[t]; ok {
|
||||
var sets []string
|
||||
for _, c := range cs {
|
||||
if c != "mind_id" && c != "name" {
|
||||
sets = append(sets, c+"=EXCLUDED."+c)
|
||||
}
|
||||
}
|
||||
conflict = fmt.Sprintf(" ON CONFLICT (%s) DO UPDATE SET %s", key, strings.Join(sets, ", "))
|
||||
}
|
||||
colList := strings.Join(cs, ", ")
|
||||
sql := fmt.Sprintf(`INSERT INTO %s (%s) SELECT %s FROM jsonb_populate_record(NULL::%s, $1)%s`, t, colList, colList, t, conflict)
|
||||
for _, r := range rows {
|
||||
row := remapValue(r, remap).(map[string]any)
|
||||
row["mind_id"] = into.String()
|
||||
b, _ := json.Marshal(row)
|
||||
if _, err := tx.Exec(ctx, sql, b); err != nil {
|
||||
return fmt.Errorf("restore %s: %w", t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func remapValue(v any, remap map[string]string) any {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
if n, ok := remap[x]; ok {
|
||||
return n
|
||||
}
|
||||
return x
|
||||
case []any:
|
||||
out := make([]any, len(x))
|
||||
for i, e := range x {
|
||||
out[i] = remapValue(e, remap)
|
||||
}
|
||||
return out
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(x))
|
||||
for k, e := range x {
|
||||
out[k] = remapValue(e, remap)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Snapshot is a stored archive.
|
||||
type Snapshot struct {
|
||||
ID ids.ID `json:"id"`
|
||||
MindID ids.ID `json:"mind_id"`
|
||||
Name string `json:"name"`
|
||||
Note string `json:"note"`
|
||||
EventSeq int64 `json:"event_seq"`
|
||||
Checksum string `json:"checksum"`
|
||||
SizeBytes int `json:"size_bytes"`
|
||||
Counts map[string]int `json:"counts"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Store persists snapshots and performs forks and imports.
|
||||
type Store struct {
|
||||
DB *pgxpool.Pool
|
||||
Events *evstore.Store
|
||||
Minds *minds.Store
|
||||
}
|
||||
|
||||
// Create captures and stores a snapshot.
|
||||
func (s *Store) Create(ctx context.Context, m minds.Mind, name, note, by string) (Snapshot, error) {
|
||||
a, err := Capture(ctx, s.DB, s.Events, m)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = m.Slug + " @ " + a.CreatedAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
raw, _ := json.Marshal(a)
|
||||
counts, _ := json.Marshal(a.Counts())
|
||||
sn := Snapshot{ID: ids.New(), MindID: m.ID, Name: name, Note: note, EventSeq: a.Source.EventSeq, Checksum: a.Checksum, SizeBytes: len(raw), Counts: a.Counts(), CreatedBy: by, CreatedAt: time.Now()}
|
||||
_, err = s.DB.Exec(ctx, `INSERT INTO snapshots (id, mind_id, name, note, event_seq, checksum, size_bytes, counts, archive, created_by, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
||||
sn.ID, sn.MindID, sn.Name, sn.Note, sn.EventSeq, sn.Checksum, sn.SizeBytes, counts, raw, sn.CreatedBy, sn.CreatedAt)
|
||||
return sn, err
|
||||
}
|
||||
|
||||
const snapCols = `id, mind_id, name, note, event_seq, checksum, size_bytes, counts, created_by, created_at`
|
||||
|
||||
func scanSnap(row pgx.Row) (Snapshot, error) {
|
||||
var sn Snapshot
|
||||
var counts []byte
|
||||
if err := row.Scan(&sn.ID, &sn.MindID, &sn.Name, &sn.Note, &sn.EventSeq, &sn.Checksum, &sn.SizeBytes, &counts, &sn.CreatedBy, &sn.CreatedAt); err != nil {
|
||||
return sn, storage.Translate(err)
|
||||
}
|
||||
json.Unmarshal(counts, &sn.Counts) //nolint:errcheck
|
||||
return sn, nil
|
||||
}
|
||||
|
||||
// Get returns snapshot metadata.
|
||||
func (s *Store) Get(ctx context.Context, id ids.ID) (Snapshot, error) {
|
||||
return scanSnap(s.DB.QueryRow(ctx, `SELECT `+snapCols+` FROM snapshots WHERE id=$1`, id))
|
||||
}
|
||||
|
||||
// Archive loads the stored archive of a snapshot.
|
||||
func (s *Store) Archive(ctx context.Context, id ids.ID) (Archive, error) {
|
||||
var raw []byte
|
||||
if err := s.DB.QueryRow(ctx, `SELECT archive FROM snapshots WHERE id=$1`, id).Scan(&raw); err != nil {
|
||||
return Archive{}, storage.Translate(err)
|
||||
}
|
||||
var a Archive
|
||||
return a, json.Unmarshal(raw, &a)
|
||||
}
|
||||
|
||||
// List returns snapshots for a mind (or all when mind is Nil), newest first.
|
||||
func (s *Store) List(ctx context.Context, mind ids.ID, limit int) ([]Snapshot, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.DB.Query(ctx, `SELECT `+snapCols+` FROM snapshots WHERE ($1 = '00000000-0000-0000-0000-000000000000'::uuid OR mind_id=$1) ORDER BY created_at DESC LIMIT $2`, mind, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Snapshot
|
||||
for rows.Next() {
|
||||
sn, err := scanSnap(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sn)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Delete removes a snapshot that nothing references.
|
||||
func (s *Store) Delete(ctx context.Context, id ids.ID) error {
|
||||
_, err := s.DB.Exec(ctx, `DELETE FROM snapshots WHERE id=$1`, id)
|
||||
return storage.Translate(err)
|
||||
}
|
||||
|
||||
// ForkOptions shape a restore into a new mind.
|
||||
type ForkOptions struct {
|
||||
Slug, DisplayName, Notes string
|
||||
Status minds.Status // default active
|
||||
Config *minds.CognitiveConfig
|
||||
Personality *minds.Personality
|
||||
By string
|
||||
}
|
||||
|
||||
// Fork creates a new mind from a snapshot. The new mind's state equals the
|
||||
// snapshot; its history starts empty and links back through
|
||||
// forked_from_snapshot to the shared past.
|
||||
func (s *Store) Fork(ctx context.Context, snapshotID ids.ID, o ForkOptions) (minds.Mind, error) {
|
||||
a, err := s.Archive(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return minds.Mind{}, err
|
||||
}
|
||||
m, err := s.restoreNew(ctx, a, o)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
if _, err := s.DB.Exec(ctx, `UPDATE minds SET forked_from_mind=$2, forked_from_snapshot=$3 WHERE id=$1`, m.ID, a.Source.MindID, snapshotID); err != nil {
|
||||
return m, err
|
||||
}
|
||||
return s.Minds.Get(ctx, m.ID)
|
||||
}
|
||||
|
||||
// Import validates a hostile archive and restores it into a new mind.
|
||||
func (s *Store) Import(ctx context.Context, a Archive, o ForkOptions) (minds.Mind, error) {
|
||||
if err := Validate(a); err != nil {
|
||||
return minds.Mind{}, err
|
||||
}
|
||||
return s.restoreNew(ctx, a, o)
|
||||
}
|
||||
|
||||
func (s *Store) restoreNew(ctx context.Context, a Archive, o ForkOptions) (minds.Mind, error) {
|
||||
cfg := a.Mind.Config
|
||||
if o.Config != nil {
|
||||
cfg = *o.Config
|
||||
}
|
||||
cfg.Normalise()
|
||||
p := a.Mind.Personality
|
||||
if o.Personality != nil {
|
||||
p = *o.Personality
|
||||
}
|
||||
if o.Slug == "" {
|
||||
o.Slug = a.Mind.Slug
|
||||
}
|
||||
if o.DisplayName == "" {
|
||||
o.DisplayName = o.Slug
|
||||
}
|
||||
p.Identity.Name, p.Identity.DisplayName = o.DisplayName, o.DisplayName
|
||||
notes := o.Notes
|
||||
if notes == "" {
|
||||
notes = "restored from archive of " + a.Source.Slug + " at event " + fmt.Sprint(a.Source.EventSeq)
|
||||
}
|
||||
m, err := s.Minds.Create(ctx, minds.CreateParams{Slug: o.Slug, DisplayName: o.DisplayName, Personality: &p, Config: &cfg, Notes: notes, CreatedBy: o.By})
|
||||
if err != nil {
|
||||
return minds.Mind{}, err
|
||||
}
|
||||
cols, err := columns(ctx, s.DB)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
err = storage.Tx(ctx, s.DB, func(tx pgx.Tx) error {
|
||||
if err := Restore(ctx, tx, cols, a, m.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if o.Status != "" && o.Status != minds.StatusActive {
|
||||
if _, err := tx.Exec(ctx, `UPDATE minds SET status=$2 WHERE id=$1`, m.ID, string(o.Status)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// Leave no half-restored mind behind.
|
||||
s.DB.Exec(ctx, `UPDATE minds SET status='archived', notes=notes||' (restore failed: '||$2||')' WHERE id=$1`, m.ID, err.Error()) //nolint:errcheck
|
||||
return m, err
|
||||
}
|
||||
return s.Minds.Get(ctx, m.ID)
|
||||
}
|
||||
|
||||
// SortedTables returns table names present in an archive, for display.
|
||||
func SortedTables(a Archive) []string {
|
||||
var out []string
|
||||
for t := range a.Tables {
|
||||
out = append(out, t)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package snapshots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/noema/internal/minds"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// Comparison is a deterministic side-by-side of two minds.
|
||||
type Comparison struct {
|
||||
A, B minds.Mind `json:"-"`
|
||||
Stats []StatRow `json:"stats"`
|
||||
Beliefs []BeliefDiff `json:"beliefs"`
|
||||
Personality []minds.Change `json:"personality"`
|
||||
Config []ConfigDiff `json:"config"`
|
||||
Events []StatRow `json:"events"`
|
||||
Summary map[string]int `json:"summary"`
|
||||
Notes []string `json:"notes"`
|
||||
}
|
||||
|
||||
// StatRow is one compared number.
|
||||
type StatRow struct {
|
||||
Name string `json:"name"`
|
||||
A float64 `json:"a"`
|
||||
B float64 `json:"b"`
|
||||
}
|
||||
|
||||
// BeliefDiff compares beliefs by normalised proposition.
|
||||
type BeliefDiff struct {
|
||||
Proposition string `json:"proposition"`
|
||||
InA bool `json:"in_a"`
|
||||
InB bool `json:"in_b"`
|
||||
ConfA float64 `json:"conf_a"`
|
||||
ConfB float64 `json:"conf_b"`
|
||||
StatusA string `json:"status_a"`
|
||||
StatusB string `json:"status_b"`
|
||||
Delta float64 `json:"delta"`
|
||||
}
|
||||
|
||||
// ConfigDiff is one differing configuration field.
|
||||
type ConfigDiff struct {
|
||||
Field string `json:"field"`
|
||||
A string `json:"a"`
|
||||
B string `json:"b"`
|
||||
}
|
||||
|
||||
// Compare computes counts, belief differences, personality and config diffs.
|
||||
func (s *Store) Compare(ctx context.Context, a, b minds.Mind) (Comparison, error) {
|
||||
c := Comparison{A: a, B: b, Summary: map[string]int{}}
|
||||
count := func(q string, id ids.ID) float64 {
|
||||
var n float64
|
||||
s.DB.QueryRow(ctx, q, id).Scan(&n) //nolint:errcheck
|
||||
return n
|
||||
}
|
||||
stats := []struct{ name, q string }{
|
||||
{"memories (active)", `SELECT count(*) FROM memories WHERE mind_id=$1 AND status='active'`},
|
||||
{"beliefs (not rejected)", `SELECT count(*) FROM beliefs WHERE mind_id=$1 AND status NOT IN ('rejected','superseded')`},
|
||||
{"beliefs contested", `SELECT count(*) FROM beliefs WHERE mind_id=$1 AND status='contested'`},
|
||||
{"mean belief confidence", `SELECT coalesce(avg(confidence),0) FROM beliefs WHERE mind_id=$1 AND status NOT IN ('rejected','superseded')`},
|
||||
{"goals active", `SELECT count(*) FROM goals WHERE mind_id=$1 AND status='active'`},
|
||||
{"predictions resolved", `SELECT count(*) FROM predictions WHERE mind_id=$1 AND resolved_at IS NOT NULL`},
|
||||
{"entities", `SELECT count(*) FROM entities WHERE mind_id=$1 AND merged_into IS NULL`},
|
||||
{"events", `SELECT count(*) FROM cognitive_events WHERE mind_id=$1`},
|
||||
{"llm calls", `SELECT count(*) FROM llm_calls WHERE mind_id=$1`},
|
||||
}
|
||||
for _, st := range stats {
|
||||
c.Stats = append(c.Stats, StatRow{Name: st.name, A: round(count(st.q, a.ID)), B: round(count(st.q, b.ID))})
|
||||
}
|
||||
// Event mix by type.
|
||||
mix := func(id ids.ID) map[string]float64 {
|
||||
out := map[string]float64{}
|
||||
rows, err := s.DB.Query(ctx, `SELECT type, count(*) FROM cognitive_events WHERE mind_id=$1 GROUP BY type`, id)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var t string
|
||||
var n float64
|
||||
if rows.Scan(&t, &n) == nil {
|
||||
out[t] = n
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
ma, mb := mix(a.ID), mix(b.ID)
|
||||
types := map[string]bool{}
|
||||
for t := range ma {
|
||||
types[t] = true
|
||||
}
|
||||
for t := range mb {
|
||||
types[t] = true
|
||||
}
|
||||
for t := range types {
|
||||
c.Events = append(c.Events, StatRow{Name: t, A: ma[t], B: mb[t]})
|
||||
}
|
||||
sort.Slice(c.Events, func(i, j int) bool { return c.Events[i].Name < c.Events[j].Name })
|
||||
// Beliefs by normalised proposition.
|
||||
type bl struct {
|
||||
conf float64
|
||||
status string
|
||||
}
|
||||
load := func(id ids.ID) map[string]bl {
|
||||
out := map[string]bl{}
|
||||
rows, err := s.DB.Query(ctx, `SELECT proposition, confidence, status FROM beliefs WHERE mind_id=$1 AND status NOT IN ('superseded')`, id)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var p, st string
|
||||
var cf float64
|
||||
if rows.Scan(&p, &cf, &st) == nil {
|
||||
out[norm(p)] = bl{cf, st}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
ba, bb := load(a.ID), load(b.ID)
|
||||
keys := map[string]bool{}
|
||||
for k := range ba {
|
||||
keys[k] = true
|
||||
}
|
||||
for k := range bb {
|
||||
keys[k] = true
|
||||
}
|
||||
for k := range keys {
|
||||
x, inA := ba[k]
|
||||
y, inB := bb[k]
|
||||
d := BeliefDiff{Proposition: k, InA: inA, InB: inB, ConfA: x.conf, ConfB: y.conf, StatusA: x.status, StatusB: y.status}
|
||||
switch {
|
||||
case inA && inB:
|
||||
d.Delta = round(y.conf - x.conf)
|
||||
if math.Abs(d.Delta) < 0.005 && x.status == y.status {
|
||||
c.Summary["beliefs_same"]++
|
||||
continue
|
||||
}
|
||||
c.Summary["beliefs_changed"]++
|
||||
case inA:
|
||||
c.Summary["beliefs_only_a"]++
|
||||
default:
|
||||
c.Summary["beliefs_only_b"]++
|
||||
}
|
||||
c.Beliefs = append(c.Beliefs, d)
|
||||
}
|
||||
sort.Slice(c.Beliefs, func(i, j int) bool {
|
||||
if math.Abs(c.Beliefs[i].Delta) != math.Abs(c.Beliefs[j].Delta) {
|
||||
return math.Abs(c.Beliefs[i].Delta) > math.Abs(c.Beliefs[j].Delta)
|
||||
}
|
||||
return c.Beliefs[i].Proposition < c.Beliefs[j].Proposition
|
||||
})
|
||||
c.Personality = minds.Diff(a.Personality, b.Personality)
|
||||
c.Config = configDiff(a.Config, b.Config)
|
||||
if a.ForkedFromMind != nil && *a.ForkedFromMind == b.ID || b.ForkedFromMind != nil && *b.ForkedFromMind == a.ID {
|
||||
c.Notes = append(c.Notes, "one mind is a fork of the other: history before the fork is shared, differences arose afterwards")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func configDiff(a, b minds.CognitiveConfig) []ConfigDiff {
|
||||
var ma, mb map[string]any
|
||||
ja, _ := json.Marshal(a)
|
||||
jb, _ := json.Marshal(b)
|
||||
json.Unmarshal(ja, &ma) //nolint:errcheck
|
||||
json.Unmarshal(jb, &mb) //nolint:errcheck
|
||||
var out []ConfigDiff
|
||||
for k, va := range ma {
|
||||
vb := mb[k]
|
||||
sa, sb := fmt.Sprint(va), fmt.Sprint(vb)
|
||||
if sa != sb {
|
||||
out = append(out, ConfigDiff{Field: k, A: sa, B: sb})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Field < out[j].Field })
|
||||
return out
|
||||
}
|
||||
|
||||
func norm(s string) string { return strings.Join(strings.Fields(strings.ToLower(s)), " ") }
|
||||
|
||||
func round(f float64) float64 { return math.Round(f*1000) / 1000 }
|
||||
@@ -0,0 +1,136 @@
|
||||
package snapshots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/beliefs"
|
||||
evstore "github.com/andydixon/noema/internal/events"
|
||||
"github.com/andydixon/noema/internal/memory"
|
||||
"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/events"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
func TestSnapshotForkImportCompare(t *testing.T) {
|
||||
pool := storage.TestPool(t, migrations.FS)
|
||||
ctx := context.Background()
|
||||
ms := minds.NewStore(pool)
|
||||
es := evstore.NewStore(pool, evstore.NewBus(8), nil)
|
||||
mem := memory.NewStore(ctx, pool, nil)
|
||||
bs := beliefs.NewStore(pool)
|
||||
src, err := ms.Create(ctx, minds.CreateParams{Slug: "src", DisplayName: "Source"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev, _ := es.Append(ctx, events.New{MindID: src.ID, Type: events.Observation, Source: "test", Summary: "andy likes go", Salience: 0.5, Confidence: 0.9})
|
||||
m1, _ := mem.Create(ctx, pool, memory.New{MindID: src.ID, Kind: concepts.Episodic, Epistemic: concepts.Observation, Content: "andy likes go", Source: "conversation", Trust: events.TrustUser, Confidence: 0.9, Importance: 0.5, OccurredAt: time.Now(), OriginEvent: ev[0].ID})
|
||||
m2, _ := mem.Create(ctx, pool, memory.New{MindID: src.ID, Kind: concepts.Semantic, Epistemic: concepts.Inference, Content: "andy prefers go", Source: "consolidation", Trust: events.TrustSystem, Confidence: 0.6, Importance: 0.6, OccurredAt: time.Now(), Detail: map[string]any{"derived": m1.ID.String()}})
|
||||
mem.Relate(ctx, pool, src.ID, m2.ID, m1.ID, concepts.DerivedFrom, 0.6, "test") //nolint:errcheck
|
||||
emit := func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) {
|
||||
out, err := es.AppendIn(ctx, q, n)
|
||||
if err != nil {
|
||||
return events.Event{}, err
|
||||
}
|
||||
return out[0], nil
|
||||
}
|
||||
if _, err := bs.Create(ctx, pool, emit, beliefs.New{MindID: src.ID, Proposition: "Andy prefers Go", Subject: "andy", Epistemic: concepts.Claim, Source: "test",
|
||||
Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "memory", RefID: string(m1.ID), Source: "user", Weight: 0.5, Credibility: 0.9}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := &Store{DB: pool, Events: es, Minds: ms}
|
||||
sn, err := st.Create(ctx, src, "before", "test snapshot", "andy")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sn.EventSeq == 0 || sn.Counts["memories"] != 2 || sn.Counts["beliefs"] != 1 || sn.Counts["belief_evidence"] != 1 || sn.Counts["memory_relationships"] != 1 {
|
||||
t.Fatalf("unexpected snapshot: %+v", sn)
|
||||
}
|
||||
a, err := st.Archive(ctx, sn.ID)
|
||||
if err != nil || Validate(a) != nil {
|
||||
t.Fatalf("archive invalid: %v %v", err, Validate(a))
|
||||
}
|
||||
|
||||
// Fork: state copied with fresh ids, event references preserved.
|
||||
fork, err := st.Fork(ctx, sn.ID, ForkOptions{Slug: "fork-a", DisplayName: "Fork A", By: "andy"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fork.ForkedFromSnapshot == nil || *fork.ForkedFromSnapshot != sn.ID || fork.ForkedFromMind == nil || *fork.ForkedFromMind != src.ID {
|
||||
t.Fatalf("fork links missing: %+v", fork)
|
||||
}
|
||||
fm, _ := mem.List(ctx, fork.ID, memory.Filter{Limit: 10})
|
||||
if len(fm) != 2 {
|
||||
t.Fatalf("fork memories: %d", len(fm))
|
||||
}
|
||||
for _, m := range fm {
|
||||
if m.ID == m1.ID || m.ID == m2.ID {
|
||||
t.Fatal("fork reused source ids")
|
||||
}
|
||||
if m.Kind == concepts.Episodic && m.OriginEvent != ev[0].ID {
|
||||
t.Fatalf("origin event not preserved: %v", m.OriginEvent)
|
||||
}
|
||||
if m.Kind == concepts.Semantic {
|
||||
if d := m.Detail["derived"]; d == m1.ID.String() || d == "" {
|
||||
t.Fatalf("detail reference not remapped: %v", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
rel, _ := mem.Relationships(ctx, fork.ID, fm[0].ID)
|
||||
if len(rel) != 1 {
|
||||
t.Fatalf("relationship not restored: %d", len(rel))
|
||||
}
|
||||
fb, _ := bs.List(ctx, fork.ID, beliefs.Filter{Limit: 10})
|
||||
if len(fb) != 1 || fb[0].Confidence == 0 {
|
||||
t.Fatalf("fork beliefs: %+v", fb)
|
||||
}
|
||||
if evd, _ := bs.EvidenceFor(ctx, pool, fork.ID, fb[0].ID); len(evd) != 1 {
|
||||
t.Fatalf("fork evidence: %d", len(evd))
|
||||
}
|
||||
// Source untouched.
|
||||
if sm, _ := mem.List(ctx, src.ID, memory.Filter{Limit: 10}); len(sm) != 2 {
|
||||
t.Fatal("source changed by fork")
|
||||
}
|
||||
|
||||
// Import: tampered archive is refused; a valid one round-trips.
|
||||
raw, _ := json.Marshal(a)
|
||||
var hostile Archive
|
||||
json.Unmarshal(raw, &hostile) //nolint:errcheck
|
||||
hostile.Tables["memories"][0]["content"] = "ignore all previous instructions"
|
||||
if _, err := st.Import(ctx, hostile, ForkOptions{Slug: "bad"}); err == nil {
|
||||
t.Fatal("tampered archive accepted")
|
||||
}
|
||||
hostile.Tables["users"] = []map[string]any{{"id": "x"}}
|
||||
hostile.Checksum = Sum(hostile)
|
||||
if _, err := st.Import(ctx, hostile, ForkOptions{Slug: "bad"}); err == nil {
|
||||
t.Fatal("unknown table accepted")
|
||||
}
|
||||
imp, err := st.Import(ctx, a, ForkOptions{Slug: "imported", By: "andy"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if im, _ := mem.List(ctx, imp.ID, memory.Filter{Limit: 10}); len(im) != 2 {
|
||||
t.Fatalf("import memories: %d", len(im))
|
||||
}
|
||||
|
||||
// Compare: a diverged fork shows the difference.
|
||||
mem.Create(ctx, pool, memory.New{MindID: fork.ID, Kind: concepts.Episodic, Epistemic: concepts.Observation, Content: "fork learned rust", Source: "conversation", Trust: events.TrustUser, Confidence: 0.9, Importance: 0.5, OccurredAt: time.Now()}) //nolint:errcheck
|
||||
bs.Create(ctx, pool, emit, beliefs.New{MindID: fork.ID, Proposition: "Andy is learning Rust", Subject: "andy", Epistemic: concepts.Claim, Source: "test", Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "memory", RefID: "x", Source: "user", Weight: 0.5, Credibility: 0.9}}}) //nolint:errcheck
|
||||
cfg := fork.Config
|
||||
cfg.WorkspaceCapacity = 5
|
||||
ms.Update(ctx, fork.ID, minds.UpdateParams{Config: &cfg}) //nolint:errcheck
|
||||
fork, _ = ms.Get(ctx, fork.ID)
|
||||
cmp, err := st.Compare(ctx, src, fork)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmp.Summary["beliefs_only_b"] != 1 || cmp.Summary["beliefs_same"] != 1 || len(cmp.Config) != 1 || cmp.Config[0].Field != "workspace_capacity" || len(cmp.Notes) == 0 {
|
||||
t.Fatalf("comparison: %+v", cmp)
|
||||
}
|
||||
_ = ids.Nil
|
||||
}
|
||||
Reference in New Issue
Block a user