From 072422d916a477a8d43412dfe8cf3ea2537ae69e Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Fri, 3 Jul 2026 22:12:31 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + PLAN.md | 2 +- cmd/noemactl/study.go | 244 +++++++++++ cmd/noemad/main.go | 27 +- docs/snapshots-replay.md | 78 ++++ internal/api/api_test.go | 2 + internal/api/bytes_util.go | 8 + internal/api/server.go | 1 + internal/api/study.go | 429 +++++++++++++++++++ internal/app/app.go | 6 + internal/brain/cycle.go | 19 +- internal/experiments/experiments.go | 387 +++++++++++++++++ internal/experiments/experiments_test.go | 64 +++ internal/replay/driver.go | 159 +++++++ internal/replay/replay.go | 293 +++++++++++++ internal/replay/replay_test.go | 64 +++ internal/replay/replaytest/harness.go | 60 +++ internal/snapshots/archive.go | 507 +++++++++++++++++++++++ internal/snapshots/compare.go | 186 +++++++++ internal/snapshots/snapshots_test.go | 136 ++++++ internal/ui/study.go | 293 +++++++++++++ internal/ui/ui.go | 5 +- migrations/0015_snapshots.down.sql | 3 + migrations/0015_snapshots.up.sql | 49 +++ tests/m1/m1_test.go | 2 + web/static/app.css | 3 + web/templates/layout.html | 3 + web/templates/pages/compare.html | 13 + web/templates/pages/experiment.html | 21 + web/templates/pages/experiments.html | 22 + web/templates/pages/replay.html | 15 + web/templates/pages/replays.html | 20 + web/templates/pages/snapshots.html | 23 + web/templates/partials/mind_tabs.html | 2 +- 34 files changed, 3140 insertions(+), 7 deletions(-) create mode 100644 cmd/noemactl/study.go create mode 100644 docs/snapshots-replay.md create mode 100644 internal/api/bytes_util.go create mode 100644 internal/api/study.go create mode 100644 internal/experiments/experiments.go create mode 100644 internal/experiments/experiments_test.go create mode 100644 internal/replay/driver.go create mode 100644 internal/replay/replay.go create mode 100644 internal/replay/replay_test.go create mode 100644 internal/replay/replaytest/harness.go create mode 100644 internal/snapshots/archive.go create mode 100644 internal/snapshots/compare.go create mode 100644 internal/snapshots/snapshots_test.go create mode 100644 internal/ui/study.go create mode 100644 migrations/0015_snapshots.down.sql create mode 100644 migrations/0015_snapshots.up.sql create mode 100644 web/templates/pages/compare.html create mode 100644 web/templates/pages/experiment.html create mode 100644 web/templates/pages/experiments.html create mode 100644 web/templates/pages/replay.html create mode 100644 web/templates/pages/replays.html create mode 100644 web/templates/pages/snapshots.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a8312d..7ea203b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Noema are recorded here. Format follows Keep a Changelog; ## [Unreleased] ### Added +- Phase 15 study tools: mind snapshots (checksummed JSON archives of full state), forks that share history by reference with remapped ids, deterministic mind comparison, export and hostile-input import, cognitive replay in disabled sandbox minds with exact/approximate/diverged labelling and recorded model responses, and an experiment framework (overrides, stimulus scripts, expectations, exportable results with the subject archive). - Phase 14 dreaming: opt-in idle mode with allowed hours, interval and budgets; deterministic memory replay sample, vocabulary-based association of unlinked memories, speculative hypotheses as low-prior beliefs with optional model wording; promotion/rejection review in UI, API and CLI; dreams run in a forbidden capability context so they can never act. - Phase 13 integrations: research folder watcher with safe extraction and fingerprints, shallow bare git mirrors with co-change stats, Prometheus/VictoriaMetrics threshold and change detection, HMAC-signed webhooks with replay protection and rate limiting, per-integration health, runs and document ledger in UI/API/CLI, `read_metrics` and `repository.read` capabilities. - Phase 12 capabilities: single policy-governed action path with ALLOW/ASK/DENY rules, locked capabilities, request-bound approvals (once, session, modified), tool output as tool-trust observations, self-model synchronisation, approval centre and policy matrix. diff --git a/PLAN.md b/PLAN.md index 21061e7..5751aa4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -167,7 +167,7 @@ An administrator can: start Noema → log in → create a mind → configure per - ☑ dream mode, hours, budgets, association process, promotion/rejection UI (forbidden capability context; hypotheses start ≤0.35) ### Phase 15 – Snapshots, forking, replay, experiments, export/import -- ☐ snapshots, fork with shared history, comparison tools, replay engine and UI, experiment framework with exportable results, mind export/import archives +- ☑ snapshots, fork with shared history, comparison tools, replay engine and UI (exact / approximate / diverged), experiment framework with exportable results, mind export/import archives (hostile-input validation) ### Phase 16 – Hardening and release - ☐ TOTP + recovery codes, security test suite, performance pass, OpenAPI document, docs set, screenshots, `go test -race`, `go vet`, staticcheck, gosec, govulncheck, release notes diff --git a/cmd/noemactl/study.go b/cmd/noemactl/study.go new file mode 100644 index 0000000..17ba84e --- /dev/null +++ b/cmd/noemactl/study.go @@ -0,0 +1,244 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + "text/tabwriter" +) + +func init() { + extraCommands = append(extraCommands, + command{"snapshots", " list|create [name]|fork |export|import – snapshots, forks, archives", cmdSnapshots}, + command{"compare", " – deterministic comparison of two minds", cmdCompare}, + command{"replay", " list|start [from] [to]|show – cognitive replay", cmdReplay}, + command{"experiments", "list|create |show |run |export – cognitive experiments", cmdExperiments}, + ) +} + +func cmdSnapshots(args []string) int { + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: noemactl snapshots list | create [name] | fork | export | import ") + return exitUsage + } + mind := args[0] + switch args[1] { + case "list": + var out struct{ Items []map[string]any } + if code, err := apiRequest("GET", "/api/v1/minds/"+mind+"/snapshots", nil, &out); err != nil { + return exitFor(code, err) + } + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tEVENT\tSIZE\tCREATED\tID") + for _, s := range out.Items { + fmt.Fprintf(tw, "%v\t%v\t%v\t%v\t%v\n", s["name"], s["event_seq"], s["size_bytes"], s["created_at"], s["id"]) + } + tw.Flush() + return exitOK + case "create": + var sn map[string]any + if code, err := apiRequest("POST", "/api/v1/minds/"+mind+"/snapshots", map[string]any{"name": strings.Join(args[2:], " ")}, &sn); err != nil { + return exitFor(code, err) + } + fmt.Printf("snapshot %v at event %v\n", sn["id"], sn["event_seq"]) + return exitOK + case "fork": + if len(args) < 4 { + return exitUsage + } + var m map[string]any + if code, err := apiRequest("POST", "/api/v1/snapshots/"+args[2]+"/fork", map[string]any{"slug": args[3]}, &m); err != nil { + return exitFor(code, err) + } + fmt.Printf("forked into %v (%v)\n", m["slug"], m["id"]) + return exitOK + case "export": + var a json.RawMessage + if code, err := apiRequest("GET", "/api/v1/minds/"+mind+"/export", nil, &a); err != nil { + return exitFor(code, err) + } + os.Stdout.Write(a) //nolint:errcheck + fmt.Println() + return exitOK + case "import": + if len(args) < 4 { + return exitUsage + } + raw, err := os.ReadFile(args[2]) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return exitUsage + } + var m map[string]any + if code, err := apiRequest("POST", "/api/v1/minds/import-archive", map[string]any{"slug": args[3], "archive": json.RawMessage(raw)}, &m); err != nil { + return exitFor(code, err) + } + fmt.Printf("imported as %v (%v)\n", m["slug"], m["id"]) + return exitOK + } + return exitUsage +} + +func cmdCompare(args []string) int { + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: noemactl compare ") + return exitUsage + } + var out struct { + A, B string + Comparison struct { + Stats []map[string]any + Beliefs []map[string]any + Config []map[string]any + Summary map[string]int + } + } + if code, err := apiRequest("GET", "/api/v1/minds/"+args[0]+"/compare/"+args[1], nil, &out); err != nil { + return exitFor(code, err) + } + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintf(tw, "STAT\t%s\t%s\n", out.A, out.B) + for _, s := range out.Comparison.Stats { + fmt.Fprintf(tw, "%v\t%v\t%v\n", s["name"], s["a"], s["b"]) + } + for _, c := range out.Comparison.Config { + fmt.Fprintf(tw, "config.%v\t%v\t%v\n", c["field"], c["a"], c["b"]) + } + tw.Flush() + fmt.Printf("beliefs: %d same, %d changed, %d only in %s, %d only in %s\n", out.Comparison.Summary["beliefs_same"], out.Comparison.Summary["beliefs_changed"], out.Comparison.Summary["beliefs_only_a"], out.A, out.Comparison.Summary["beliefs_only_b"], out.B) + for _, b := range out.Comparison.Beliefs { + fmt.Printf(" %v a=%v b=%v\n", b["proposition"], b["conf_a"], b["conf_b"]) + } + return exitOK +} + +func cmdReplay(args []string) int { + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: noemactl replay list | start [from] [to] | show ") + return exitUsage + } + mind := args[0] + switch args[1] { + case "list": + var out struct{ Items []map[string]any } + if code, err := apiRequest("GET", "/api/v1/minds/"+mind+"/replays", nil, &out); err != nil { + return exitFor(code, err) + } + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "STATUS\tRANGE\tSTEPS\tEXACT\tAPPROX\tDIVERGED\tID") + for _, r := range out.Items { + s, _ := r["summary"].(map[string]any) + fmt.Fprintf(tw, "%v\t%v-%v\t%v\t%v\t%v\t%v\t%v\n", r["status"], r["from_seq"], r["to_seq"], s["steps"], s["exact"], s["approximate"], s["diverged"], r["id"]) + } + tw.Flush() + return exitOK + case "start": + if len(args) < 3 { + return exitUsage + } + body := map[string]any{"snapshot_id": args[2]} + if len(args) > 3 { + body["from_seq"], _ = strconv.ParseInt(args[3], 10, 64) + } + if len(args) > 4 { + body["to_seq"], _ = strconv.ParseInt(args[4], 10, 64) + } + var r map[string]any + if code, err := apiRequest("POST", "/api/v1/minds/"+mind+"/replays", body, &r); err != nil { + return exitFor(code, err) + } + fmt.Printf("replay %v started (events %v-%v); watch with: noemactl replay %s show %v\n", r["id"], r["from_seq"], r["to_seq"], mind, r["id"]) + return exitOK + case "show": + if len(args) < 3 { + return exitUsage + } + var r struct { + Status string + Error string + Summary map[string]any + Steps []map[string]any + } + if code, err := apiRequest("GET", "/api/v1/replays/"+args[2], nil, &r); err != nil { + return exitFor(code, err) + } + fmt.Printf("%s %v %s\n", r.Status, r.Summary, r.Error) + for _, st := range r.Steps { + fmt.Printf("[%v] %v — %v %v\n", st["label"], st["summary"], st["note"], "") + fmt.Printf(" recorded: %v\n replayed: %v\n", st["original"], st["replayed"]) + } + return exitOK + } + return exitUsage +} + +func cmdExperiments(args []string) int { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: noemactl experiments list | create | show | run | export ") + return exitUsage + } + switch args[0] { + case "list": + var out struct{ Items []map[string]any } + if code, err := apiRequest("GET", "/api/v1/experiments", nil, &out); err != nil { + return exitFor(code, err) + } + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "STATUS\tNAME\tID") + for _, e := range out.Items { + fmt.Fprintf(tw, "%v\t%v\t%v\n", e["status"], e["name"], e["id"]) + } + tw.Flush() + return exitOK + case "create": + if len(args) < 2 { + return exitUsage + } + var raw []byte + var err error + if args[1] == "-" { + raw, err = io.ReadAll(os.Stdin) + } else { + raw, err = os.ReadFile(args[1]) + } + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return exitUsage + } + var e map[string]any + if code, err := apiRequest("POST", "/api/v1/experiments", json.RawMessage(raw), &e); err != nil { + return exitFor(code, err) + } + fmt.Printf("created %v\n", e["id"]) + return exitOK + case "show", "export": + if len(args) < 2 { + return exitUsage + } + path := "/api/v1/experiments/" + args[1] + if args[0] == "export" { + path += "/export" + } + var raw json.RawMessage + if code, err := apiRequest("GET", path, nil, &raw); err != nil { + return exitFor(code, err) + } + os.Stdout.Write(raw) //nolint:errcheck + fmt.Println() + return exitOK + case "run": + if len(args) < 2 { + return exitUsage + } + var e map[string]any + if code, err := apiRequest("POST", "/api/v1/experiments/"+args[1]+"/run", nil, &e); err != nil { + return exitFor(code, err) + } + fmt.Printf("%v: %v\n", e["name"], e["status"]) + return exitOK + } + return exitUsage +} diff --git a/cmd/noemad/main.go b/cmd/noemad/main.go index a07c733..46eabde 100644 --- a/cmd/noemad/main.go +++ b/cmd/noemad/main.go @@ -4,6 +4,7 @@ package main import ( "context" + "encoding/json" "errors" "flag" "fmt" @@ -26,6 +27,7 @@ import ( "github.com/andydixon/noema/internal/conversation" "github.com/andydixon/noema/internal/dreaming" evstore "github.com/andydixon/noema/internal/events" + "github.com/andydixon/noema/internal/experiments" "github.com/andydixon/noema/internal/goals" "github.com/andydixon/noema/internal/graph" "github.com/andydixon/noema/internal/integrations" @@ -39,8 +41,10 @@ import ( "github.com/andydixon/noema/internal/observability" "github.com/andydixon/noema/internal/predictions" "github.com/andydixon/noema/internal/reflection" + "github.com/andydixon/noema/internal/replay" "github.com/andydixon/noema/internal/scheduler" "github.com/andydixon/noema/internal/security/secrets" + "github.com/andydixon/noema/internal/snapshots" "github.com/andydixon/noema/internal/storage" "github.com/andydixon/noema/internal/ui" "github.com/andydixon/noema/internal/version" @@ -156,9 +160,27 @@ func run() error { AddProcess(cognition.GoalMonitor{}).AddProcess(cognition.Curiosity{}).AddProcess(cognition.Metacognition{}).AddProcess(cognition.Act{}). AddHook(cognition.MotivationHook{Store: deps.Motivation}).AddHook(cognition.NarrativeHook{Store: deps.Narrative}).AddHook(cognition.NotifyHook{Store: deps.Notifications}) cognition.Handlers{Memory: deps.Memory, Beliefs: deps.Beliefs, Conversations: deps.Conversations, Graph: deps.Graph}.Register(registry) - deps.Brain = brain.NewManager(brain.Config{DB: db, Events: deps.Events, Minds: deps.Minds, Registry: registry, Pool: scheduler.NewPool(cfg.MaxWorkers), + brainCfg := brain.Config{DB: db, Events: deps.Events, Minds: deps.Minds, Registry: registry, Pool: scheduler.NewPool(cfg.MaxWorkers), Log: log, Metrics: metrics, Services: brain.Services{Events: deps.Events, Memory: deps.Memory, Beliefs: deps.Beliefs, LLM: deps.LLM, Prompts: deps.Prompts, - Goals: deps.Goals, Motivation: deps.Motivation, Predictions: deps.Predictions, Capabilities: deps.Capabilities}}, group) + Goals: deps.Goals, Motivation: deps.Motivation, Predictions: deps.Predictions, Capabilities: deps.Capabilities}} + deps.Brain = brain.NewManager(brainCfg, group) + // Sandbox runtimes (replay, experiments) share the registry but use a + // router that serves recorded model responses when it can. + replayLLM := router.NewRouter(deps.LLMStore, sec, log, metrics, cfg.MaxConcurrentLLM) + replayLLM.Replay = true + replayLLM.DailyTokenBudget, replayLLM.MindBudget = deps.LLM.DailyTokenBudget, deps.LLM.MindBudget + sandboxCfg := brainCfg + sandboxCfg.Pool = scheduler.NewPool(2) + sandboxCfg.Services.LLM = replayLLM + deps.Snapshots = &snapshots.Store{DB: db, Events: deps.Events, Minds: deps.Minds} + deps.Replay = &replay.Service{DB: db, Events: deps.Events, Minds: deps.Minds, Snapshots: deps.Snapshots, Brain: sandboxCfg, Log: log} + deps.Experiments = &experiments.Service{DB: db, Minds: deps.Minds, Snapshots: deps.Snapshots, Brain: sandboxCfg, Log: log, Routing: func(ctx context.Context) []map[string]any { + roles, _ := deps.LLMStore.ListRoles(ctx) + b, _ := json.Marshal(roles) + var out []map[string]any + json.Unmarshal(b, &out) //nolint:errcheck + return out + }} selfSync.Sync(ctx, ids.Nil) group.Every("integrations.tick", time.Minute, deps.Integrations.Tick) group.Every("approvals.expire", 5*time.Minute, func(ctx context.Context) error { _, err := deps.Capabilities.ExpirePending(ctx); return err }) @@ -193,6 +215,7 @@ func run() error { }, ResetState: func(r *http.Request, mind ids.ID) (motivation.State, error) { return srv.ResetState(r, mind) }, Consolidate: func(r *http.Request, mind ids.ID) (consolidation.Job, error) { return srv.RunConsolidation(r, mind) }, + Study: func() *api.Server { return srv }, Reflect: func(r *http.Request, mind ids.ID) (reflection.Reflection, error) { return srv.RunReflection(r, mind) }, Entity: func(r *http.Request, mind, id ids.ID) (api.EntityDetail, error) { return srv.EntityDetail(r, mind, id) }, Merge: func(r *http.Request, mind ids.ID, e graph.Entity, other string) error { diff --git a/docs/snapshots-replay.md b/docs/snapshots-replay.md new file mode 100644 index 0000000..e58de1f --- /dev/null +++ b/docs/snapshots-replay.md @@ -0,0 +1,78 @@ +# Snapshots, forks, export/import, replay and experiments + +## Snapshots and forks (spec §42) + +A **snapshot** is a JSON archive of a mind's state at a point in its history: +identity, personality, configuration, workspace and working memory, drives and +affect, goals, beliefs (with evidence, versions, alternatives, links), memories +(with relationships), predictions, entities, corrections, policies and +conversations. It records the event sequence number at capture and a SHA-256 +checksum. Cognitive events are **not** copied. + +A **fork** restores a snapshot into a new mind. Every row id is remapped, so +the fork never shares mutable rows with its source; references to events keep +pointing at the source's immutable history, which is how the past before the +fork is shared by reference (`forked_from_mind`, `forked_from_snapshot`, +snapshot `event_seq`). After the fork the two minds diverge freely. + +**Compare** (`/minds/{a}/compare?with={b}`, `noemactl compare`) is +deterministic: counts, mean confidence, belief-by-belief differences keyed on +the normalised proposition, personality and configuration field diffs, and +the event-type mix. + +## Export / import (spec §43) + +Export produces the same archive (`kind: noema.mind-archive`, `version: 1`, +checksum). Nothing secret is included: provider keys, integration secret +references, API tokens and sessions are not mind state and are never in an +archive. Import treats the archive as hostile: size cap (32 MiB), unknown +top-level fields rejected, allow-listed tables only, row caps, column-name +validation, checksum must match, every id remapped, `mind_id` forced. Text +inside an archive is data; a prompt-injection string in a memory is imported +as a memory, never executed. + +## Replay (spec §41) + +`Replay` rebuilds a mind from a snapshot into a **disabled sandbox mind**, +then re-injects the source's recorded stimuli (perception and operator events +after the snapshot) one at a time, stepping the real cycle code synchronously +until the mind settles. Each step compares the events the source produced +after that stimulus with the events the sandbox produced, ignoring +bookkeeping types and normalising ids and numbers: + +| Label | Meaning | +|---|---| +| **exact** | same events, and every model call was served from a recording | +| **approximate** | a live model call was needed; the recorded response did not match the request hash | +| **diverged** | no live model call, yet the deterministic code produced different events | + +Divergence is reported, not hidden. Its usual causes are honest ones: the +working memory changed between snapshot and stimulus, retrieval scores depend +on wall-clock recency, or the code changed since the recording. + +The replaying router serves recorded responses by request hash +(`llm_calls.request_hash`) and records the replayed call with `replayed=true`. +Replays run under a forbidden capability context; no action can execute. + +## Experiments (spec §57) + +An experiment is a snapshot, optional configuration and personality +overrides, a stimulus script, expectations, a seed and a duration cap. Running +it forks the snapshot into a disabled subject mind, applies overrides, feeds +the stimuli and records: events by type, produced-event digests per stimulus, +subject beliefs, memory and goal counts, expectation outcomes, model-call +counts (live/replayed), the LLM role routing in force, duration and cycles. +Results are stored and exportable (`/api/v1/experiments/{id}/export` includes +an archive of the subject). The subject stays for comparison with the +baseline. + +Different models are compared by changing role routing between runs; the +routing in force is recorded in each result. The seed is recorded with every +stimulus for provenance; deterministic code needs no seed, and model +providers do not honour one reliably. + +## Operating + +- UI: mind → **Snapshots** (take, fork, download, compare, import on the minds page), mind → **Replay**, top-level **Experiments**. +- API: `/api/v1/minds/{mind}/snapshots`, `/api/v1/snapshots/{id}[/archive|/fork]`, `/api/v1/minds/{mind}/export`, `/api/v1/minds/import-archive`, `/api/v1/minds/{mind}/compare/{other}`, `/api/v1/minds/{mind}/replays`, `/api/v1/replays/{id}`, `/api/v1/experiments[...]`. +- CLI: `noemactl snapshots`, `noemactl compare`, `noemactl replay`, `noemactl experiments`. diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 102bdab..f756b12 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -36,6 +36,7 @@ import ( "github.com/andydixon/noema/internal/reflection" "github.com/andydixon/noema/internal/scheduler" "github.com/andydixon/noema/internal/security/secrets" + "github.com/andydixon/noema/internal/snapshots" "github.com/andydixon/noema/internal/storage" "github.com/andydixon/noema/migrations" ) @@ -61,6 +62,7 @@ func testServer(t *testing.T) (*httptest.Server, string) { deps.Capabilities = &capabilities.Service{Registry: capReg, DB: pool, Events: deps.Events, Log: log} deps.Integrations = &integrations.Manager{Store: integrations.NewStore(pool), Events: deps.Events, Graph: deps.Graph, Log: log, Pollers: map[integrations.Kind]integrations.Poller{}} deps.Dreaming = &dreaming.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Log: log} + deps.Snapshots = &snapshots.Store{DB: pool, Events: deps.Events, Minds: deps.Minds} deps.Narrative = narrative.NewStore(pool) deps.Notifications = notifications.NewStore(pool) deps.Consolidation = &consolidation.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Minds: deps.Minds, Log: log} diff --git a/internal/api/bytes_util.go b/internal/api/bytes_util.go new file mode 100644 index 0000000..d4cbace --- /dev/null +++ b/internal/api/bytes_util.go @@ -0,0 +1,8 @@ +package api + +import ( + "bytes" + "io" +) + +func bytesReader(b []byte) io.Reader { return bytes.NewReader(b) } diff --git a/internal/api/server.go b/internal/api/server.go index a56842c..8d321f0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -69,6 +69,7 @@ func (s *Server) routes() { s.capabilityRoutes() s.integrationRoutes() s.dreamRoutes() + s.studyRoutes() m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint") diff --git a/internal/api/study.go b/internal/api/study.go new file mode 100644 index 0000000..2462d57 --- /dev/null +++ b/internal/api/study.go @@ -0,0 +1,429 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/experiments" + "github.com/andydixon/noema/internal/minds" + "github.com/andydixon/noema/internal/replay" + "github.com/andydixon/noema/internal/snapshots" + "github.com/andydixon/noema/pkg/ids" +) + +// studyRoutes: snapshots, forks, export/import, comparison, replay, experiments. +func (s *Server) studyRoutes() { + m := s.mux + m.Handle("GET /api/v1/minds/{mind}/snapshots", require(auth.RoleObserver, s.snapshotsList)) + m.Handle("POST /api/v1/minds/{mind}/snapshots", require(auth.RoleOperator, s.snapshotCreate)) + m.Handle("GET /api/v1/snapshots/{id}", require(auth.RoleObserver, s.snapshotGet)) + m.Handle("GET /api/v1/snapshots/{id}/archive", require(auth.RoleOperator, s.snapshotArchive)) + m.Handle("POST /api/v1/snapshots/{id}/fork", require(auth.RoleAdministrator, s.snapshotFork)) + m.Handle("DELETE /api/v1/snapshots/{id}", require(auth.RoleAdministrator, s.snapshotDelete)) + m.Handle("GET /api/v1/minds/{mind}/export", require(auth.RoleOperator, s.mindExport)) + m.Handle("POST /api/v1/minds/import-archive", require(auth.RoleAdministrator, s.mindImportArchive)) + m.Handle("GET /api/v1/minds/{mind}/compare/{other}", require(auth.RoleObserver, s.mindCompare)) + m.Handle("GET /api/v1/minds/{mind}/replays", require(auth.RoleObserver, s.replaysList)) + m.Handle("POST /api/v1/minds/{mind}/replays", require(auth.RoleOperator, s.replayStart)) + m.Handle("GET /api/v1/replays/{id}", require(auth.RoleObserver, s.replayGet)) + m.Handle("GET /api/v1/experiments", require(auth.RoleObserver, s.experimentsList)) + m.Handle("POST /api/v1/experiments", require(auth.RoleOperator, s.experimentCreate)) + m.Handle("GET /api/v1/experiments/{id}", require(auth.RoleObserver, s.experimentGet)) + m.Handle("POST /api/v1/experiments/{id}/run", require(auth.RoleOperator, s.experimentRun)) + m.Handle("GET /api/v1/experiments/{id}/export", require(auth.RoleObserver, s.experimentExport)) + m.Handle("DELETE /api/v1/experiments/{id}", require(auth.RoleAdministrator, s.experimentDelete)) +} + +func (s *Server) snapshotsList(w http.ResponseWriter, r *http.Request) { + m, ok := s.mind(w, r) + if !ok { + return + } + list, err := s.deps.Snapshots.List(r.Context(), m.ID, 100) + if err != nil { + fail(w, r, err) + return + } + if list == nil { + list = []snapshots.Snapshot{} + } + writeJSON(w, http.StatusOK, map[string]any{"items": list}) +} + +// CreateSnapshot captures a mind; shared with the UI. +func (s *Server) CreateSnapshot(ctx context.Context, m minds.Mind, name, note, by string) (snapshots.Snapshot, error) { + sn, err := s.deps.Snapshots.Create(ctx, m, name, note, by) + if err != nil { + return sn, err + } + s.deps.Audit.Log(ctx, "snapshot.create", "snapshot", sn.ID.String(), map[string]any{"mind": m.Slug, "name": sn.Name, "event_seq": sn.EventSeq}) + s.deps.Events.OperatorAction(ctx, m.ID, by, "snapshot.create", "Operator "+by+" took snapshot "+sn.Name, map[string]any{"snapshot_id": sn.ID, "event_seq": sn.EventSeq}) + return sn, nil +} + +func (s *Server) snapshotCreate(w http.ResponseWriter, r *http.Request) { + m, ok := s.mind(w, r) + if !ok { + return + } + var in struct{ Name, Note string } + decode(r, &in) //nolint:errcheck // both optional + sn, err := s.CreateSnapshot(r.Context(), m, in.Name, in.Note, actor(r)) + if err != nil { + fail(w, r, err) + return + } + writeJSON(w, http.StatusCreated, sn) +} + +func (s *Server) snapshotByPath(w http.ResponseWriter, r *http.Request) (snapshots.Snapshot, bool) { + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + fail(w, r, ValidationError("bad snapshot id")) + return snapshots.Snapshot{}, false + } + sn, err := s.deps.Snapshots.Get(r.Context(), id) + if err != nil { + fail(w, r, err) + return sn, false + } + return sn, true +} + +func (s *Server) snapshotGet(w http.ResponseWriter, r *http.Request) { + if sn, ok := s.snapshotByPath(w, r); ok { + writeJSON(w, http.StatusOK, sn) + } +} + +func (s *Server) snapshotArchive(w http.ResponseWriter, r *http.Request) { + sn, ok := s.snapshotByPath(w, r) + if !ok { + return + } + a, err := s.deps.Snapshots.Archive(r.Context(), sn.ID) + if err != nil { + fail(w, r, err) + return + } + s.deps.Audit.Log(r.Context(), "snapshot.export", "snapshot", sn.ID.String(), nil) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", `attachment; filename="`+a.Mind.Slug+"-"+sn.ID.String()[len(sn.ID.String())-8:]+`.noema.json"`) + json.NewEncoder(w).Encode(a) //nolint:errcheck +} + +// ForkSnapshot creates a mind from a snapshot; shared with the UI. +func (s *Server) ForkSnapshot(ctx context.Context, sn snapshots.Snapshot, slug, display, by string) (minds.Mind, error) { + m, err := s.deps.Snapshots.Fork(ctx, sn.ID, snapshots.ForkOptions{Slug: slug, DisplayName: display, By: by}) + if err != nil { + return m, mindErr(err) + } + s.deps.Audit.Log(ctx, "mind.fork", "mind", m.ID.String(), map[string]any{"snapshot": sn.ID, "source": sn.MindID, "slug": m.Slug}) + s.deps.Events.OperatorAction(ctx, m.ID, by, "mind.fork", "Mind forked from snapshot "+sn.Name+" (history before event "+strconv.FormatInt(sn.EventSeq, 10)+" is shared with the source)", map[string]any{"snapshot_id": sn.ID, "source_mind": sn.MindID, "event_seq": sn.EventSeq}) + s.deps.Events.OperatorAction(ctx, sn.MindID, by, "mind.fork", "Operator "+by+" forked this mind into "+m.Slug+" from snapshot "+sn.Name, map[string]any{"snapshot_id": sn.ID, "fork_mind": m.ID}) + s.deps.Brain.Reconcile(ctx) //nolint:errcheck + return m, nil +} + +func (s *Server) snapshotFork(w http.ResponseWriter, r *http.Request) { + sn, ok := s.snapshotByPath(w, r) + if !ok { + return + } + var in struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + } + if err := decode(r, &in); err != nil { + fail(w, r, err) + return + } + m, err := s.ForkSnapshot(r.Context(), sn, in.Slug, in.DisplayName, actor(r)) + if err != nil { + fail(w, r, err) + return + } + writeJSON(w, http.StatusCreated, m) +} + +func (s *Server) snapshotDelete(w http.ResponseWriter, r *http.Request) { + sn, ok := s.snapshotByPath(w, r) + if !ok { + return + } + if err := s.deps.Snapshots.Delete(r.Context(), sn.ID); err != nil { + fail(w, r, ValidationError("snapshot is referenced by a replay or experiment")) + return + } + s.deps.Audit.Log(r.Context(), "snapshot.delete", "snapshot", sn.ID.String(), map[string]any{"name": sn.Name}) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) mindExport(w http.ResponseWriter, r *http.Request) { + m, ok := s.mind(w, r) + if !ok { + return + } + a, err := snapshots.Capture(r.Context(), s.deps.DB, s.deps.Events, m) + if err != nil { + fail(w, r, err) + return + } + s.deps.Audit.Log(r.Context(), "mind.export", "mind", m.ID.String(), map[string]any{"event_seq": a.Source.EventSeq}) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", `attachment; filename="`+m.Slug+`.noema.json"`) + json.NewEncoder(w).Encode(a) //nolint:errcheck +} + +// ImportArchive restores a hostile archive into a new mind; shared with the UI. +func (s *Server) ImportArchive(ctx context.Context, raw []byte, slug, display, by string) (minds.Mind, error) { + if len(raw) > snapshots.MaxArchiveBytes { + return minds.Mind{}, ValidationError("archive too large") + } + var a snapshots.Archive + dec := json.NewDecoder(bytesReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&a); err != nil { + return minds.Mind{}, ValidationError("invalid archive: " + err.Error()) + } + m, err := s.deps.Snapshots.Import(ctx, a, snapshots.ForkOptions{Slug: slug, DisplayName: display, By: by, Notes: "imported archive of " + a.Source.Slug}) + if err != nil { + if errors.Is(err, snapshots.ErrInvalid) { + return m, ValidationError(err.Error()) + } + return m, mindErr(err) + } + s.deps.Audit.Log(ctx, "mind.import", "mind", m.ID.String(), map[string]any{"source": a.Source.Slug, "checksum": a.Checksum, "counts": a.Counts()}) + s.deps.Events.OperatorAction(ctx, m.ID, by, "mind.import", "Mind imported from archive of "+a.Source.Slug+" ("+a.Checksum+")", map[string]any{"counts": a.Counts()}) + s.deps.Brain.Reconcile(ctx) //nolint:errcheck + return m, nil +} + +func (s *Server) mindImportArchive(w http.ResponseWriter, r *http.Request) { + var in struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Archive json.RawMessage `json:"archive"` + } + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, snapshots.MaxArchiveBytes+4096)) + if err := dec.Decode(&in); err != nil { + fail(w, r, ValidationError("invalid request: "+err.Error())) + return + } + m, err := s.ImportArchive(r.Context(), in.Archive, in.Slug, in.DisplayName, actor(r)) + if err != nil { + fail(w, r, err) + return + } + writeJSON(w, http.StatusCreated, m) +} + +func (s *Server) mindCompare(w http.ResponseWriter, r *http.Request) { + a, ok := s.mind(w, r) + if !ok { + return + } + b, err := s.deps.Minds.Resolve(r.Context(), r.PathValue("other")) + if err != nil { + fail(w, r, err) + return + } + c, err := s.deps.Snapshots.Compare(r.Context(), a, b) + if err != nil { + fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"a": a.Slug, "b": b.Slug, "comparison": c}) +} + +func (s *Server) replaysList(w http.ResponseWriter, r *http.Request) { + m, ok := s.mind(w, r) + if !ok { + return + } + list, err := s.deps.Replay.List(r.Context(), m.ID, 50) + if err != nil { + fail(w, r, err) + return + } + if list == nil { + list = []replay.Replay{} + } + writeJSON(w, http.StatusOK, map[string]any{"items": list}) +} + +// StartReplay records a replay and executes it in the background; shared with the UI. +func (s *Server) StartReplay(ctx context.Context, m minds.Mind, snapshotID ids.ID, from, to int64, by string) (replay.Replay, error) { + rp, err := s.deps.Replay.Begin(ctx, m, snapshotID, from, to, by) + if err != nil { + return rp, ValidationError(err.Error()) + } + s.deps.Audit.Log(ctx, "replay.start", "replay", rp.ID.String(), map[string]any{"mind": m.Slug, "snapshot": snapshotID, "from": rp.FromSeq, "to": rp.ToSeq}) + bg := context.WithoutCancel(ctx) + go func() { + if _, err := s.deps.Replay.Execute(bg, rp); err != nil { + s.deps.Log.Warn("replay failed", "replay", rp.ID, "err", err) + } + }() + return rp, nil +} + +func (s *Server) replayStart(w http.ResponseWriter, r *http.Request) { + m, ok := s.mind(w, r) + if !ok { + return + } + var in struct { + SnapshotID ids.ID `json:"snapshot_id"` + FromSeq int64 `json:"from_seq"` + ToSeq int64 `json:"to_seq"` + } + if err := decode(r, &in); err != nil { + fail(w, r, err) + return + } + rp, err := s.StartReplay(r.Context(), m, in.SnapshotID, in.FromSeq, in.ToSeq, actor(r)) + if err != nil { + fail(w, r, err) + return + } + writeJSON(w, http.StatusAccepted, rp) +} + +func (s *Server) replayGet(w http.ResponseWriter, r *http.Request) { + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + fail(w, r, ValidationError("bad replay id")) + return + } + rp, err := s.deps.Replay.Get(r.Context(), id) + if err != nil { + fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, rp) +} + +func (s *Server) experimentsList(w http.ResponseWriter, r *http.Request) { + list, err := s.deps.Experiments.List(r.Context(), 100) + if err != nil { + fail(w, r, err) + return + } + if list == nil { + list = []experiments.Experiment{} + } + writeJSON(w, http.StatusOK, map[string]any{"items": list}) +} + +// ExperimentInput is the create body (API and UI). +type ExperimentInput struct { + Name string `json:"name"` + Hypothesis string `json:"hypothesis"` + SnapshotID ids.ID `json:"snapshot_id"` + Definition experiments.Definition `json:"definition"` +} + +// CreateExperiment stores a draft; shared with the UI. +func (s *Server) CreateExperiment(ctx context.Context, in ExperimentInput, by string) (experiments.Experiment, error) { + e, err := s.deps.Experiments.Create(ctx, in.Name, in.Hypothesis, in.SnapshotID, in.Definition, by) + if err != nil { + return e, ValidationError(err.Error()) + } + s.deps.Audit.Log(ctx, "experiment.create", "experiment", e.ID.String(), map[string]any{"name": e.Name, "snapshot": e.SnapshotID}) + return e, nil +} + +func (s *Server) experimentCreate(w http.ResponseWriter, r *http.Request) { + var in ExperimentInput + if err := decode(r, &in); err != nil { + fail(w, r, err) + return + } + e, err := s.CreateExperiment(r.Context(), in, actor(r)) + if err != nil { + fail(w, r, err) + return + } + writeJSON(w, http.StatusCreated, e) +} + +func (s *Server) experimentByPath(w http.ResponseWriter, r *http.Request) (experiments.Experiment, bool) { + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + fail(w, r, ValidationError("bad experiment id")) + return experiments.Experiment{}, false + } + e, err := s.deps.Experiments.Get(r.Context(), id) + if err != nil { + fail(w, r, err) + return e, false + } + return e, true +} + +func (s *Server) experimentGet(w http.ResponseWriter, r *http.Request) { + if e, ok := s.experimentByPath(w, r); ok { + writeJSON(w, http.StatusOK, e) + } +} + +// RunExperiment starts a run in the background; shared with the UI. +func (s *Server) RunExperiment(ctx context.Context, e experiments.Experiment, by string) error { + if e.Status == "running" { + return ValidationError("experiment is already running") + } + s.deps.Audit.Log(ctx, "experiment.run", "experiment", e.ID.String(), map[string]any{"name": e.Name}) + bg := context.WithoutCancel(ctx) + go func() { + if _, err := s.deps.Experiments.Run(bg, e.ID, by); err != nil { + s.deps.Log.Warn("experiment failed", "experiment", e.ID, "err", err) + } + }() + return nil +} + +func (s *Server) experimentRun(w http.ResponseWriter, r *http.Request) { + e, ok := s.experimentByPath(w, r) + if !ok { + return + } + if err := s.RunExperiment(r.Context(), e, actor(r)); err != nil { + fail(w, r, err) + return + } + e.Status = "running" + writeJSON(w, http.StatusAccepted, e) +} + +func (s *Server) experimentExport(w http.ResponseWriter, r *http.Request) { + e, ok := s.experimentByPath(w, r) + if !ok { + return + } + out, err := s.deps.Experiments.Export(r.Context(), e.ID) + if err != nil { + fail(w, r, err) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", `attachment; filename="experiment-`+e.ID.String()[len(e.ID.String())-8:]+`.json"`) + json.NewEncoder(w).Encode(out) //nolint:errcheck +} + +func (s *Server) experimentDelete(w http.ResponseWriter, r *http.Request) { + e, ok := s.experimentByPath(w, r) + if !ok { + return + } + if err := s.deps.Experiments.Delete(r.Context(), e.ID); err != nil { + fail(w, r, err) + return + } + s.deps.Audit.Log(r.Context(), "experiment.delete", "experiment", e.ID.String(), map[string]any{"name": e.Name}) + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/app/app.go b/internal/app/app.go index 0616d9d..1b72380 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -20,6 +20,7 @@ import ( "github.com/andydixon/noema/internal/conversation" "github.com/andydixon/noema/internal/dreaming" "github.com/andydixon/noema/internal/events" + "github.com/andydixon/noema/internal/experiments" "github.com/andydixon/noema/internal/goals" "github.com/andydixon/noema/internal/graph" "github.com/andydixon/noema/internal/integrations" @@ -33,7 +34,9 @@ import ( "github.com/andydixon/noema/internal/observability" "github.com/andydixon/noema/internal/predictions" "github.com/andydixon/noema/internal/reflection" + "github.com/andydixon/noema/internal/replay" "github.com/andydixon/noema/internal/security/secrets" + "github.com/andydixon/noema/internal/snapshots" ) // Deps is everything a handler may need. Fields are added per phase. @@ -62,6 +65,9 @@ type Deps struct { Capabilities *capabilities.Service Integrations *integrations.Manager Dreaming *dreaming.Service + Snapshots *snapshots.Store + Replay *replay.Service + Experiments *experiments.Service LLM *router.Router LLMStore *router.Store Prompts *prompts.Store diff --git a/internal/brain/cycle.go b/internal/brain/cycle.go index 40ff570..a8dc5db 100644 --- a/internal/brain/cycle.go +++ b/internal/brain/cycle.go @@ -32,6 +32,11 @@ const cursorConsumer = "brain" // skipped forever (see ADR-0002 and PLAN risk R3). const settle = 250 * time.Millisecond +// SettleWindow is how long an event must have been recorded before a cycle +// reads it. Synchronous drivers (replay, experiments) wait this long after +// injecting a stimulus so the next Step sees it. +const SettleWindow = settle + 50*time.Millisecond + // processTimeout bounds one process's Observe call. const processTimeout = 15 * time.Second @@ -349,6 +354,16 @@ func (r *Runtime) runCycle(ctx context.Context, trigger string) { r.log.Warn("cycle rate limit reached; skipping", "trigger", trigger) return } + if _, err := r.Step(ctx, trigger); err != nil { + r.log.Error("cycle failed", "trigger", trigger, "err", err) + } +} + +// Step runs exactly one cycle synchronously and returns the events it +// committed. Run calls it from the loop goroutine; replay and experiment +// drivers call it directly on runtimes they own and never Run. It bypasses +// the rate budget and the paused check because the caller is the clock. +func (r *Runtime) Step(ctx context.Context, trigger string) ([]events.Event, error) { start := r.now() cctx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() @@ -361,10 +376,10 @@ func (r *Runtime) runCycle(ctx context.Context, trigger string) { }) if err != nil { r.state = before // the transaction rolled back; forget in-memory changes too - r.log.Error("cycle failed", "trigger", trigger, "err", err) - return + return nil, err } r.events.Publish(emitted...) // only after commit, so subscribers never see phantom events + return emitted, nil } func (r *Runtime) cycle(ctx context.Context, tx pgx.Tx, trigger string, start time.Time) ([]events.Event, error) { diff --git a/internal/experiments/experiments.go b/internal/experiments/experiments.go new file mode 100644 index 0000000..6521fea --- /dev/null +++ b/internal/experiments/experiments.go @@ -0,0 +1,387 @@ +// Package experiments runs controlled cognitive experiments (spec §57): a +// snapshot, overrides, a stimulus script and expectations, executed in a +// sandbox mind driven synchronously and unable to act. Results are stored +// and exportable; the subject mind stays for inspection and comparison. +package experiments + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/andydixon/noema/internal/brain" + "github.com/andydixon/noema/internal/minds" + "github.com/andydixon/noema/internal/replay" + "github.com/andydixon/noema/internal/snapshots" + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/pkg/events" + "github.com/andydixon/noema/pkg/ids" +) + +// Definition is the reproducible description of an experiment. +type Definition struct { + Config map[string]any `json:"config,omitempty"` // CognitiveConfig field overrides (JSON names) + Personality map[string]any `json:"personality,omitempty"` // Personality field overrides (JSON names, shallow per section) + Stimuli []Stimulus `json:"stimuli"` + Expected []Expectation `json:"expected,omitempty"` + Seed string `json:"seed,omitempty"` + Duration int `json:"duration_seconds,omitempty"` // wall-clock cap (default 300) +} + +// Stimulus is one scripted observation. +type Stimulus struct { + Text string `json:"text"` + Source string `json:"source,omitempty"` + Salience float64 `json:"salience,omitempty"` +} + +// Expectation is checked against the events the subject produced. +type Expectation struct { + EventType string `json:"event_type"` + Contains string `json:"contains,omitempty"` + Absent bool `json:"absent,omitempty"` // expectation is that no such event occurs +} + +// Experiment is a stored definition with its results. +type Experiment struct { + ID ids.ID `json:"id"` + Name string `json:"name"` + Hypothesis string `json:"hypothesis"` + SnapshotID ids.ID `json:"snapshot_id"` + MindID *ids.ID `json:"mind_id,omitempty"` + Definition Definition `json:"definition"` + Status string `json:"status"` + Results json.RawMessage `json:"results"` + Error string `json:"error"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` +} + +// Results is what a run records. +type Results struct { + Subject ids.ID `json:"subject_mind_id"` + SubjectSlug string `json:"subject_slug"` + Steps []StepResult `json:"steps"` + EventsByType map[string]int `json:"events_by_type"` + Beliefs []BeliefRow `json:"beliefs"` + Memories int `json:"memories"` + Goals int `json:"goals"` + Expectations []ExpectResult `json:"expectations"` + LLMCalls map[string]int `json:"llm_calls"` // live / replayed + Routing []map[string]any `json:"routing,omitempty"` + DurationMS int64 `json:"duration_ms"` + Cycles int `json:"cycles"` + TruncatedAfter int `json:"truncated_after_stimulus,omitempty"` +} + +// StepResult records one stimulus. +type StepResult struct { + Index int `json:"index"` + Text string `json:"text"` + EventID ids.ID `json:"event_id"` + Produced []string `json:"produced"` + Cycles int `json:"cycles"` +} + +// BeliefRow is a compact belief for results. +type BeliefRow struct { + Proposition string `json:"proposition"` + Confidence float64 `json:"confidence"` + Status string `json:"status"` + Source string `json:"source"` +} + +// ExpectResult is one checked expectation. +type ExpectResult struct { + Expectation + Met bool `json:"met"` + Matched string `json:"matched,omitempty"` +} + +// Service stores and runs experiments. +type Service struct { + DB *pgxpool.Pool + Minds *minds.Store + Snapshots *snapshots.Store + Brain brain.Config + Routing func(ctx context.Context) []map[string]any // current LLM role routing, recorded with results + Log *slog.Logger +} + +// ErrBusy is returned when a run is already in progress. +var ErrBusy = errors.New("experiment already running") + +// Validate bounds a definition. +func (d *Definition) Validate() error { + if len(d.Stimuli) == 0 { + return errors.New("at least one stimulus is required") + } + if len(d.Stimuli) > 100 { + return errors.New("at most 100 stimuli") + } + for i := range d.Stimuli { + s := &d.Stimuli[i] + s.Text = strings.TrimSpace(s.Text) + if s.Text == "" || len(s.Text) > 4000 { + return fmt.Errorf("stimulus %d: text must be 1–4000 characters", i+1) + } + if s.Source == "" { + s.Source = "experiment" + } + if s.Salience <= 0 || s.Salience > 1 { + s.Salience = 0.6 + } + } + if d.Duration <= 0 || d.Duration > 3600 { + d.Duration = 300 + } + for _, e := range d.Expected { + if e.EventType == "" { + return errors.New("expectation needs event_type") + } + } + return nil +} + +// Create stores a draft. +func (s *Service) Create(ctx context.Context, name, hypothesis string, snapshotID ids.ID, d Definition, by string) (Experiment, error) { + if err := d.Validate(); err != nil { + return Experiment{}, err + } + if strings.TrimSpace(name) == "" { + return Experiment{}, errors.New("name is required") + } + if _, err := s.Snapshots.Get(ctx, snapshotID); err != nil { + return Experiment{}, err + } + e := Experiment{ID: ids.New(), Name: name, Hypothesis: hypothesis, SnapshotID: snapshotID, Definition: d, Status: "draft", Results: []byte("{}"), CreatedBy: by, CreatedAt: time.Now()} + dj, _ := json.Marshal(d) + _, err := s.DB.Exec(ctx, `INSERT INTO experiments (id, name, hypothesis, snapshot_id, definition, status, created_by, created_at) VALUES ($1,$2,$3,$4,$5,'draft',$6,$7)`, e.ID, e.Name, e.Hypothesis, e.SnapshotID, dj, e.CreatedBy, e.CreatedAt) + return e, err +} + +// Run executes an experiment: fork, override, stimulate, measure. +func (s *Service) Run(ctx context.Context, id ids.ID, by string) (Experiment, error) { + e, err := s.Get(ctx, id) + if err != nil { + return e, err + } + if e.Status == "running" { + return e, ErrBusy + } + if _, err := s.DB.Exec(ctx, `UPDATE experiments SET status='running', started_at=now(), error='', results='{}' WHERE id=$1`, id); err != nil { + return e, err + } + res, runErr := s.execute(ctx, &e, by) + rj, _ := json.Marshal(res) + status, errText := "done", "" + if runErr != nil { + status, errText = "failed", runErr.Error() + } + if _, err := s.DB.Exec(ctx, `UPDATE experiments SET status=$2, error=$3, results=$4, mind_id=$5, finished_at=now() WHERE id=$1`, id, status, errText, rj, e.MindID); err != nil { + return e, err + } + return s.Get(ctx, id) +} + +func (s *Service) execute(ctx context.Context, e *Experiment, by string) (Results, error) { + start := time.Now() + res := Results{EventsByType: map[string]int{}, LLMCalls: map[string]int{}} + a, err := s.Snapshots.Archive(ctx, e.SnapshotID) + if err != nil { + return res, err + } + cfg, err := override(a.Mind.Config, e.Definition.Config) + if err != nil { + return res, fmt.Errorf("config overrides: %w", err) + } + pers, err := override(a.Mind.Personality, e.Definition.Personality) + if err != nil { + return res, fmt.Errorf("personality overrides: %w", err) + } + slug := "exp-" + strings.ToLower(e.ID.String()[len(e.ID.String())-8:]) + subject, err := s.Snapshots.Fork(ctx, e.SnapshotID, snapshots.ForkOptions{Slug: slug, DisplayName: "Experiment: " + e.Name, Status: minds.StatusDisabled, Config: &cfg, Personality: &pers, By: by, + Notes: "experiment " + e.ID.String() + ": " + e.Hypothesis}) + if err != nil { + return res, fmt.Errorf("fork subject: %w", err) + } + e.MindID = &subject.ID + res.Subject, res.SubjectSlug = subject.ID, subject.Slug + if s.Routing != nil { + res.Routing = s.Routing(ctx) + } + drv, err := replay.NewDriver(ctx, s.Brain, subject, "experiment") + if err != nil { + return res, err + } + deadline := start.Add(time.Duration(e.Definition.Duration) * time.Second) + var all []events.Event + for i, st := range e.Definition.Stimuli { + if time.Now().After(deadline) { + res.TruncatedAfter = i + break + } + ev, err := drv.Observe(ctx, st.Source, st.Text, st.Salience, map[string]any{"experiment_id": e.ID, "stimulus": i, "seed": e.Definition.Seed}) + if err != nil { + return res, err + } + produced, cycles, err := drv.Settle(ctx) + res.Cycles += cycles + if err != nil { + s.Log.Warn("experiment cycle error", "experiment", e.ID, "err", err) + } + all = append(all, produced...) + res.Steps = append(res.Steps, StepResult{Index: i, Text: st.Text, EventID: ev.ID, Produced: replay.Digest(produced), Cycles: cycles}) + } + for _, ev := range all { + res.EventsByType[string(ev.Type)]++ + } + for _, x := range e.Definition.Expected { + r := ExpectResult{Expectation: x} + for _, ev := range all { + if string(ev.Type) == x.EventType && (x.Contains == "" || strings.Contains(strings.ToLower(ev.Summary), strings.ToLower(x.Contains))) { + r.Met, r.Matched = true, ev.Summary + break + } + } + if x.Absent { + r.Met = !r.Met + } + res.Expectations = append(res.Expectations, r) + } + rows, err := s.DB.Query(ctx, `SELECT proposition, confidence, status, source FROM beliefs WHERE mind_id=$1 AND status NOT IN ('superseded') ORDER BY confidence DESC LIMIT 200`, subject.ID) + if err == nil { + for rows.Next() { + var b BeliefRow + if rows.Scan(&b.Proposition, &b.Confidence, &b.Status, &b.Source) == nil { + res.Beliefs = append(res.Beliefs, b) + } + } + rows.Close() + } + s.DB.QueryRow(ctx, `SELECT count(*) FROM memories WHERE mind_id=$1 AND status='active'`, subject.ID).Scan(&res.Memories) //nolint:errcheck + s.DB.QueryRow(ctx, `SELECT count(*) FROM goals WHERE mind_id=$1 AND status='active'`, subject.ID).Scan(&res.Goals) //nolint:errcheck + var live, replayed int + s.DB.QueryRow(ctx, `SELECT count(*) FILTER (WHERE NOT replayed), count(*) FILTER (WHERE replayed) FROM llm_calls WHERE mind_id=$1`, subject.ID).Scan(&live, &replayed) //nolint:errcheck + res.LLMCalls["live"], res.LLMCalls["replayed"] = live, replayed + res.DurationMS = time.Since(start).Milliseconds() + return res, nil +} + +// override applies JSON-name field overrides to a struct value. +func override[T any](base T, patch map[string]any) (T, error) { + if len(patch) == 0 { + return base, nil + } + var m map[string]any + b, _ := json.Marshal(base) + if err := json.Unmarshal(b, &m); err != nil { + return base, err + } + for k, v := range patch { + if _, ok := m[k]; !ok { + return base, fmt.Errorf("unknown field %q", k) + } + if sub, ok := v.(map[string]any); ok { + if cur, ok := m[k].(map[string]any); ok { + for kk, vv := range sub { + cur[kk] = vv + } + continue + } + } + m[k] = v + } + out := base + b, _ = json.Marshal(m) + dec := json.NewDecoder(strings.NewReader(string(b))) + dec.DisallowUnknownFields() + if err := dec.Decode(&out); err != nil { + return base, err + } + return out, nil +} + +const cols = `id, name, hypothesis, snapshot_id, mind_id, definition, status, results, error, created_by, created_at, started_at, finished_at` + +func scan(row pgx.Row) (Experiment, error) { + var e Experiment + var def []byte + if err := row.Scan(&e.ID, &e.Name, &e.Hypothesis, &e.SnapshotID, &e.MindID, &def, &e.Status, &e.Results, &e.Error, &e.CreatedBy, &e.CreatedAt, &e.StartedAt, &e.FinishedAt); err != nil { + return e, storage.Translate(err) + } + json.Unmarshal(def, &e.Definition) //nolint:errcheck + return e, nil +} + +// Get returns one experiment. +func (s *Service) Get(ctx context.Context, id ids.ID) (Experiment, error) { + return scan(s.DB.QueryRow(ctx, `SELECT `+cols+` FROM experiments WHERE id=$1`, id)) +} + +// List returns experiments, newest first. +func (s *Service) List(ctx context.Context, limit int) ([]Experiment, error) { + if limit <= 0 || limit > 500 { + limit = 50 + } + rows, err := s.DB.Query(ctx, `SELECT `+cols+` FROM experiments ORDER BY created_at DESC LIMIT $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Experiment + for rows.Next() { + e, err := scan(rows) + if err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +// Delete removes an experiment record (the subject mind stays, archived). +func (s *Service) Delete(ctx context.Context, id ids.ID) error { + e, err := s.Get(ctx, id) + if err != nil { + return err + } + if e.MindID != nil { + s.DB.Exec(ctx, `UPDATE minds SET status='archived' WHERE id=$1 AND status='disabled'`, *e.MindID) //nolint:errcheck + } + _, err = s.DB.Exec(ctx, `DELETE FROM experiments WHERE id=$1`, id) + return storage.Translate(err) +} + +// ResultsOf decodes results. +func ResultsOf(e Experiment) Results { + var r Results + json.Unmarshal(e.Results, &r) //nolint:errcheck + return r +} + +// Export bundles an experiment, its results and the subject archive. +func (s *Service) Export(ctx context.Context, id ids.ID) (map[string]any, error) { + e, err := s.Get(ctx, id) + if err != nil { + return nil, err + } + out := map[string]any{"kind": "noema.experiment", "version": 1, "experiment": e, "results": ResultsOf(e)} + if e.MindID != nil { + if m, err := s.Minds.Get(ctx, *e.MindID); err == nil { + if a, err := snapshots.Capture(ctx, s.DB, s.Snapshots.Events, m); err == nil { + out["subject_archive"] = a + } + } + } + return out, nil +} diff --git a/internal/experiments/experiments_test.go b/internal/experiments/experiments_test.go new file mode 100644 index 0000000..ac7fd69 --- /dev/null +++ b/internal/experiments/experiments_test.go @@ -0,0 +1,64 @@ +package experiments + +import ( + "context" + "log/slog" + "testing" + + "github.com/andydixon/noema/internal/minds" + "github.com/andydixon/noema/internal/replay/replaytest" +) + +func TestExperimentRunsInSandbox(t *testing.T) { + h := replaytest.NewHarness(t) + ctx := context.Background() + base, err := h.Minds.Create(ctx, minds.CreateParams{Slug: "base", DisplayName: "Base"}) + if err != nil { + t.Fatal(err) + } + sn, err := h.Snaps.Create(ctx, base, "baseline", "", "test") + if err != nil { + t.Fatal(err) + } + svc := &Service{DB: h.Pool, Minds: h.Minds, Snapshots: h.Snaps, Brain: h.Brain, Log: slog.New(slog.DiscardHandler)} + def := Definition{ + Config: map[string]any{"workspace_capacity": 3}, + Personality: map[string]any{"traits": map[string]any{"curiosity": 0.9}}, + Stimuli: []Stimulus{{Text: "Deployment failed at 09:10"}, {Text: "Latency alert at 09:12 on the same service"}}, + Expected: []Expectation{{EventType: "thought_selected"}, {EventType: "tool_executed", Absent: true}}, + } + if _, err := svc.Create(ctx, "", "", sn.ID, def, "test"); err == nil { + t.Fatal("nameless experiment accepted") + } + e, err := svc.Create(ctx, "capacity 3", "smaller workspace still links the two alerts", sn.ID, def, "test") + if err != nil { + t.Fatal(err) + } + e, err = svc.Run(ctx, e.ID, "test") + if err != nil { + t.Fatal(err) + } + if e.Status != "done" || e.MindID == nil { + t.Fatalf("run: %+v", e) + } + res := ResultsOf(e) + if len(res.Steps) != 2 || res.Cycles == 0 || len(res.Expectations) != 2 || !res.Expectations[0].Met || !res.Expectations[1].Met { + t.Fatalf("results: %+v", res) + } + subject, _ := h.Minds.Get(ctx, *e.MindID) + if subject.Status != minds.StatusDisabled || subject.Config.WorkspaceCapacity != 3 || subject.Personality.Traits["curiosity"] != 0.9 { + t.Fatalf("overrides not applied: %+v %+v", subject.Config.WorkspaceCapacity, subject.Personality.Traits) + } + if base2, _ := h.Minds.Get(ctx, base.ID); base2.Config.WorkspaceCapacity == 3 { + t.Fatal("baseline modified") + } + out, err := svc.Export(ctx, e.ID) + if err != nil || out["subject_archive"] == nil { + t.Fatalf("export: %v", err) + } + // Bad override is reported, not silently ignored. + bad, _ := svc.Create(ctx, "bad", "", sn.ID, Definition{Config: map[string]any{"nope": 1}, Stimuli: def.Stimuli}, "test") + if bad, _ = svc.Run(ctx, bad.ID, "test"); bad.Status != "failed" || bad.Error == "" { + t.Fatalf("bad override: %+v", bad) + } +} diff --git a/internal/replay/driver.go b/internal/replay/driver.go new file mode 100644 index 0000000..82df694 --- /dev/null +++ b/internal/replay/driver.go @@ -0,0 +1,159 @@ +// Package replay re-runs recorded cognition (spec §41) and drives sandbox +// minds for experiments (spec §57). A Driver owns a brain runtime that is +// never started as a loop: the driver is the clock, feeding one stimulus at +// a time and stepping cycles until the mind settles. Every step runs under a +// forbidden capability context, so replays and experiments cannot act. +package replay + +import ( + "context" + "regexp" + "strings" + "time" + + "github.com/andydixon/noema/internal/brain" + "github.com/andydixon/noema/internal/capabilities" + "github.com/andydixon/noema/internal/minds" + "github.com/andydixon/noema/pkg/events" + "github.com/andydixon/noema/pkg/ids" +) + +// Driver steps a sandbox mind synchronously. +type Driver struct { + rt *brain.Runtime + cfg brain.Config + mind minds.Mind + why string +} + +// NewDriver builds a runtime for the sandbox mind. cfg.Services.LLM should +// be a replaying router when recorded responses are wanted. +func NewDriver(ctx context.Context, cfg brain.Config, m minds.Mind, why string) (*Driver, error) { + rt, err := brain.NewRuntime(ctx, cfg, m) + if err != nil { + return nil, err + } + return &Driver{rt: rt, cfg: cfg, mind: m, why: why}, nil +} + +// MaxStepsPerStimulus bounds how long the driver waits for a mind to settle. +const MaxStepsPerStimulus = 8 + +// bookkeeping event types are produced by every cycle and carry no cognitive content for comparison. +var bookkeeping = map[events.Type]bool{events.CycleCompleted: true, events.WorkspaceBroadcast: true, events.AttentionShift: true, events.ModeChanged: true, + events.LLMRequest: true, events.LLMResponse: true, events.PolicyDecision: true, events.ThoughtCandidate: true} + +// Settle runs cycles until one produces nothing but bookkeeping, or the cap +// is reached, and returns every event committed. +func (d *Driver) Settle(ctx context.Context) ([]events.Event, int, error) { + ctx = capabilities.WithForbidden(ctx, d.why) + var all []events.Event + steps := 0 + for steps < MaxStepsPerStimulus { + // Let the just-committed events pass the runtime's settle window. + select { + case <-time.After(brain.SettleWindow): + case <-ctx.Done(): + return all, steps, ctx.Err() + } + out, err := d.rt.Step(ctx, d.why) + if err != nil { + return all, steps, err + } + steps++ + all = append(all, out...) + if !hasContent(out) { + break + } + } + return all, steps, nil +} + +func hasContent(evs []events.Event) bool { + for _, e := range evs { + if !bookkeeping[e.Type] { + return true + } + } + return false +} + +// Inject appends a stimulus copy to the sandbox mind and returns it. +func (d *Driver) Inject(ctx context.Context, src events.Event, tag map[string]any) (events.Event, error) { + meta := map[string]any{} + for k, v := range src.Metadata { + meta[k] = v + } + for k, v := range tag { + meta[k] = v + } + var payload any = src.Payload + out, err := d.cfg.Events.Append(ctx, events.New{MindID: d.mind.ID, Type: src.Type, Source: src.Source, OccurredAt: time.Now(), Salience: src.Salience, Confidence: src.Confidence, + Valence: src.Valence, Arousal: src.Arousal, Evidence: src.Evidence, Summary: src.Summary, Metadata: meta, Payload: payload}) + if err != nil { + return events.Event{}, err + } + return out[0], nil +} + +// Observe appends a fresh observation stimulus (experiments). +func (d *Driver) Observe(ctx context.Context, source, text string, salience float64, meta map[string]any) (events.Event, error) { + out, err := d.cfg.Events.Append(ctx, events.New{MindID: d.mind.ID, Type: events.Observation, Source: source, OccurredAt: time.Now(), Salience: salience, Confidence: 0.9, Summary: text, Metadata: meta, + Payload: events.ObservationPayload{Text: text, ContentType: "text/plain", Channel: source, Trust: events.TrustOperator}}) + if err != nil { + return events.Event{}, err + } + return out[0], nil +} + +// Mind returns the sandbox mind. +func (d *Driver) Mind() minds.Mind { return d.mind } + +// Digest is the comparable content of a list of events: type + normalised +// summary with ids and numbers removed, sorted. +func Digest(evs []events.Event) []string { + var out []string + for _, e := range evs { + if bookkeeping[e.Type] { + continue + } + out = append(out, string(e.Type)+": "+Normalise(e.Summary)) + } + sortStrings(out) + return out +} + +var ( + uuidRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) + numRe = regexp.MustCompile(`\d+(\.\d+)?`) +) + +// Normalise strips ids, numbers and case so summaries from different minds compare. +func Normalise(s string) string { + s = uuidRe.ReplaceAllString(strings.ToLower(s), "") + s = numRe.ReplaceAllString(s, "") + return strings.Join(strings.Fields(s), " ") +} + +func sortStrings(xs []string) { + for i := 1; i < len(xs); i++ { + for j := i; j > 0 && xs[j] < xs[j-1]; j-- { + xs[j], xs[j-1] = xs[j-1], xs[j] + } + } +} + +// Same reports whether two digests match as multisets. +func Same(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +var _ = ids.Nil diff --git a/internal/replay/replay.go b/internal/replay/replay.go new file mode 100644 index 0000000..0042bc6 --- /dev/null +++ b/internal/replay/replay.go @@ -0,0 +1,293 @@ +package replay + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/andydixon/noema/internal/brain" + evstore "github.com/andydixon/noema/internal/events" + "github.com/andydixon/noema/internal/minds" + "github.com/andydixon/noema/internal/snapshots" + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/pkg/events" + "github.com/andydixon/noema/pkg/ids" +) + +// Replay is one recorded re-run. +type Replay struct { + ID ids.ID `json:"id"` + MindID ids.ID `json:"mind_id"` + SnapshotID ids.ID `json:"snapshot_id"` + ReplayMindID *ids.ID `json:"replay_mind_id,omitempty"` + FromSeq int64 `json:"from_seq"` + ToSeq int64 `json:"to_seq"` + Status string `json:"status"` + Summary Summary `json:"summary"` + Steps json.RawMessage `json:"steps"` + Error string `json:"error"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` +} + +// Summary counts step labels. +type Summary struct { + Steps int `json:"steps"` + Exact int `json:"exact"` + Approximate int `json:"approximate"` + Diverged int `json:"diverged"` + LLMLive int `json:"llm_live"` + LLMReplayed int `json:"llm_replayed"` + Cycles int `json:"cycles"` +} + +// Step is the outcome of replaying one stimulus. +type Step struct { + Index int `json:"index"` + StimulusID ids.ID `json:"stimulus_id"` + InjectedID ids.ID `json:"injected_id"` + Summary string `json:"summary"` + Original []string `json:"original"` + Replayed []string `json:"replayed"` + Label string `json:"label"` // exact | approximate | diverged + Note string `json:"note"` + Cycles int `json:"cycles"` + LLMLive int `json:"llm_live"` + LLMReplayed int `json:"llm_replayed"` +} + +// Service runs and stores replays. +type Service struct { + DB *pgxpool.Pool + Events *evstore.Store + Minds *minds.Store + Snapshots *snapshots.Store + Brain brain.Config // template; Services.LLM must be a replaying router + Log *slog.Logger +} + +// MaxStimuli bounds one replay. +const MaxStimuli = 200 + +// Run replays the source mind's perception events in (from, to] starting +// from a snapshot, inside a fresh disabled sandbox mind. +func (s *Service) Run(ctx context.Context, src minds.Mind, snapshotID ids.ID, from, to int64, by string) (Replay, error) { + r, err := s.Begin(ctx, src, snapshotID, from, to, by) + if err != nil { + return r, err + } + return s.Execute(ctx, r) +} + +// Begin validates and records a replay so callers can run Execute in the +// background and hand the id back immediately. +func (s *Service) Begin(ctx context.Context, src minds.Mind, snapshotID ids.ID, from, to int64, by string) (Replay, error) { + r := Replay{ID: ids.New(), MindID: src.ID, SnapshotID: snapshotID, FromSeq: from, ToSeq: to, Status: "running", CreatedBy: by, CreatedAt: time.Now(), Steps: []byte("[]"), Summary: Summary{}} + sn, err := s.Snapshots.Get(ctx, snapshotID) + if err != nil || sn.MindID != src.ID { + return r, fmt.Errorf("snapshot not found for this mind") + } + if from < sn.EventSeq { + r.FromSeq = sn.EventSeq + } + if to <= r.FromSeq { + if last, err := s.Events.Query(ctx, evstore.Filter{MindID: src.ID, Limit: 1}); err == nil && len(last) > 0 { + r.ToSeq = last[0].Seq + } + } + if r.ToSeq <= r.FromSeq { + return r, fmt.Errorf("nothing to replay after event %d", r.FromSeq) + } + _, err = s.DB.Exec(ctx, `INSERT INTO replays (id, mind_id, snapshot_id, from_seq, to_seq, status, created_by) VALUES ($1,$2,$3,$4,$5,'running',$6)`, r.ID, src.ID, snapshotID, r.FromSeq, r.ToSeq, by) + return r, err +} + +// Execute performs a replay recorded by Begin. +func (s *Service) Execute(ctx context.Context, r Replay) (Replay, error) { + src, err := s.Minds.Get(ctx, r.MindID) + if err != nil { + return r, err + } + snapshotID, from, to, by := r.SnapshotID, r.FromSeq, r.ToSeq, r.CreatedBy + defer func() { + if p := recover(); p != nil { + s.Log.Error("replay panicked", "replay", r.ID, "panic", p) + s.DB.Exec(context.WithoutCancel(ctx), `UPDATE replays SET status='failed', error='internal error', finished_at=now() WHERE id=$1`, r.ID) //nolint:errcheck + } + }() + finish := func(status, errText string, steps []Step) (Replay, error) { + if steps == nil { + steps = []Step{} + } + sj, _ := json.Marshal(steps) + sum, _ := json.Marshal(r.Summary) + _, err := s.DB.Exec(ctx, `UPDATE replays SET status=$2, error=$3, steps=$4, summary=$5, replay_mind_id=$6, finished_at=now() WHERE id=$1`, r.ID, status, errText, sj, sum, r.ReplayMindID) + r.Status, r.Error, r.Steps = status, errText, sj + if err != nil { + return r, err + } + if errText != "" { + return r, fmt.Errorf("%s", errText) + } + return r, nil + } + sn, err := s.Snapshots.Get(ctx, snapshotID) + if err != nil || sn.MindID != src.ID { + return finish("failed", "snapshot not found for this mind", nil) + } + stimuli, err := s.Events.Query(ctx, evstore.Filter{MindID: src.ID, Categories: StimulusCategories, AfterSeq: from, BeforeSeq: to + 1, Ascending: true, Limit: MaxStimuli + 1}) + if err != nil { + return finish("failed", err.Error(), nil) + } + if len(stimuli) > MaxStimuli { + return finish("failed", fmt.Sprintf("more than %d stimuli in range; narrow it", MaxStimuli), nil) + } + var external []events.Event + for _, e := range stimuli { + if isStimulus(e) { + external = append(external, e) + } + } + if len(external) == 0 { + return finish("failed", "no perception or operator events in range", nil) + } + slug := "replay-" + strings.ToLower(r.ID.String()[len(r.ID.String())-8:]) + sandbox, err := s.Snapshots.Fork(ctx, snapshotID, snapshots.ForkOptions{Slug: slug, DisplayName: "Replay of " + src.DisplayName, Status: minds.StatusDisabled, By: by, + Notes: fmt.Sprintf("replay %s of %s from event %d to %d (snapshot %s)", r.ID, src.Slug, from, to, sn.Name)}) + if err != nil { + return finish("failed", "fork sandbox: "+err.Error(), nil) + } + r.ReplayMindID = &sandbox.ID + drv, err := NewDriver(ctx, s.Brain, sandbox, "replay") + if err != nil { + return finish("failed", "runtime: "+err.Error(), nil) + } + var steps []Step + for i, stim := range external { + st := Step{Index: i, StimulusID: stim.ID, Summary: stim.Summary} + // What the source produced between this stimulus and the next one. + upper := to + 1 + if i+1 < len(external) { + upper = external[i+1].Seq + } + orig, _ := s.Events.Query(ctx, evstore.Filter{MindID: src.ID, AfterSeq: stim.Seq, BeforeSeq: upper, Ascending: true, Limit: 2000}) + st.Original = Digest(withoutPerception(orig)) + startedAt := time.Now() + inj, err := drv.Inject(ctx, stim, map[string]any{"replay_of": stim.ID, "replay_id": r.ID}) + if err != nil { + st.Label, st.Note = "diverged", "inject failed: "+err.Error() + steps = append(steps, st) + continue + } + st.InjectedID = inj.ID + produced, cycles, err := drv.Settle(ctx) + st.Cycles = cycles + r.Summary.Cycles += cycles + if err != nil { + st.Note = "cycle error: " + err.Error() + } + st.Replayed = Digest(produced) + st.LLMLive, st.LLMReplayed = s.llmCounts(ctx, sandbox.ID, startedAt) + r.Summary.LLMLive += st.LLMLive + r.Summary.LLMReplayed += st.LLMReplayed + switch { + case st.LLMLive > 0: + st.Label = "approximate" + st.Note = strings.TrimSpace(st.Note + " live model call(s); recorded response not found") + r.Summary.Approximate++ + case Same(st.Original, st.Replayed): + st.Label = "exact" + r.Summary.Exact++ + default: + st.Label = "diverged" + st.Note = strings.TrimSpace(st.Note + " deterministic run produced different events; state or code differs from the recording") + r.Summary.Diverged++ + } + steps = append(steps, st) + } + r.Summary.Steps = len(steps) + return finish("done", "", steps) +} + +// StimulusCategories are the event categories a mind receives from outside: +// perceptions (messages, integrations, observations) and operator actions. +var StimulusCategories = []events.Category{events.CatPerception, events.CatOperator} + +func isStimulus(e events.Event) bool { + if strings.HasPrefix(e.Source, "brain") { + return false + } + for _, c := range StimulusCategories { + if e.Category == c { + return true + } + } + return false +} + +func withoutPerception(evs []events.Event) []events.Event { + var out []events.Event + for _, e := range evs { + if !isStimulus(e) { + out = append(out, e) + } + } + return out +} + +func (s *Service) llmCounts(ctx context.Context, mind ids.ID, since time.Time) (live, replayed int) { + s.DB.QueryRow(ctx, `SELECT count(*) FILTER (WHERE NOT replayed), count(*) FILTER (WHERE replayed) FROM llm_calls WHERE mind_id=$1 AND created_at >= $2`, mind, since).Scan(&live, &replayed) //nolint:errcheck + return +} + +const replayCols = `id, mind_id, snapshot_id, replay_mind_id, from_seq, to_seq, status, summary, steps, error, created_by, created_at, finished_at` + +func scan(row pgx.Row) (Replay, error) { + var r Replay + var sum []byte + if err := row.Scan(&r.ID, &r.MindID, &r.SnapshotID, &r.ReplayMindID, &r.FromSeq, &r.ToSeq, &r.Status, &sum, &r.Steps, &r.Error, &r.CreatedBy, &r.CreatedAt, &r.FinishedAt); err != nil { + return r, storage.Translate(err) + } + json.Unmarshal(sum, &r.Summary) //nolint:errcheck + return r, nil +} + +// Get returns one replay. +func (s *Service) Get(ctx context.Context, id ids.ID) (Replay, error) { + return scan(s.DB.QueryRow(ctx, `SELECT `+replayCols+` FROM replays WHERE id=$1`, id)) +} + +// List returns replays for a mind, newest first. +func (s *Service) List(ctx context.Context, mind ids.ID, limit int) ([]Replay, error) { + if limit <= 0 || limit > 200 { + limit = 20 + } + rows, err := s.DB.Query(ctx, `SELECT `+replayCols+` FROM replays WHERE mind_id=$1 ORDER BY created_at DESC LIMIT $2`, mind, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Replay + for rows.Next() { + r, err := scan(rows) + if err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// StepsOf decodes the steps of a replay. +func StepsOf(r Replay) []Step { + var out []Step + json.Unmarshal(r.Steps, &out) //nolint:errcheck + return out +} diff --git a/internal/replay/replay_test.go b/internal/replay/replay_test.go new file mode 100644 index 0000000..c3e6986 --- /dev/null +++ b/internal/replay/replay_test.go @@ -0,0 +1,64 @@ +package replay + +import ( + "context" + "log/slog" + "testing" + + evstore "github.com/andydixon/noema/internal/events" + "github.com/andydixon/noema/internal/minds" + "github.com/andydixon/noema/internal/replay/replaytest" + "github.com/andydixon/noema/pkg/events" +) + +func TestReplayIsExactWithoutModel(t *testing.T) { + h := replaytest.NewHarness(t) + ctx := context.Background() + src, err := h.Minds.Create(ctx, minds.CreateParams{Slug: "src", DisplayName: "Source"}) + if err != nil { + t.Fatal(err) + } + // Live run on the source, driven synchronously: one stimulus, settle. + live, err := NewDriver(ctx, h.Brain, src, "test") + if err != nil { + t.Fatal(err) + } + sn, err := h.Snaps.Create(ctx, src, "t0", "", "test") + if err != nil { + t.Fatal(err) + } + if _, err := live.Observe(ctx, "operator", "Andy said the payments service failed after the migration", 0.8, nil); err != nil { + t.Fatal(err) + } + produced, cycles, err := live.Settle(ctx) + if err != nil || cycles == 0 || len(Digest(produced)) == 0 { + t.Fatalf("live run produced nothing: %v cycles=%d", err, cycles) + } + last, _ := h.Events.Query(ctx, evstore.Filter{MindID: src.ID, Limit: 1}) + svc := &Service{DB: h.Pool, Events: h.Events, Minds: h.Minds, Snapshots: h.Snaps, Brain: h.Brain, Log: slog.New(slog.DiscardHandler)} + r, err := svc.Run(ctx, src, sn.ID, sn.EventSeq, last[0].Seq, "test") + if err != nil { + t.Fatalf("replay: %v %+v", err, r) + } + if r.Status != "done" || r.Summary.Steps != 1 || r.Summary.Exact != 1 || r.Summary.LLMLive != 0 { + t.Fatalf("expected one exact step: %+v steps=%s", r.Summary, string(r.Steps)) + } + steps := StepsOf(r) + if len(steps) != 1 || !Same(steps[0].Original, steps[0].Replayed) || len(steps[0].Replayed) == 0 { + t.Fatalf("step mismatch: %+v", steps) + } + sandbox, err := h.Minds.Get(ctx, *r.ReplayMindID) + if err != nil || sandbox.Status != minds.StatusDisabled || sandbox.ForkedFromSnapshot == nil { + t.Fatalf("sandbox mind wrong: %+v %v", sandbox, err) + } + // The source's history is untouched by the replay. + after, _ := h.Events.Query(ctx, evstore.Filter{MindID: src.ID, Limit: 1}) + if after[0].Seq != last[0].Seq { + t.Fatal("replay wrote into the source mind") + } + // Digest normalisation ignores ids and numbers. + if Normalise("selected 3 thoughts for 01a0a148-9034-7685-9794-cf5ec0c25fb7") != "selected thoughts for " { + t.Fatal(Normalise("selected 3 thoughts for 01a0a148-9034-7685-9794-cf5ec0c25fb7")) + } + _ = events.Observation +} diff --git a/internal/replay/replaytest/harness.go b/internal/replay/replaytest/harness.go new file mode 100644 index 0000000..2ea0825 --- /dev/null +++ b/internal/replay/replaytest/harness.go @@ -0,0 +1,60 @@ +// Package replaytest wires a deterministic cognitive stack for replay and +// experiment tests. +package replaytest + +import ( + "context" + "log/slog" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/andydixon/noema/internal/beliefs" + "github.com/andydixon/noema/internal/brain" + "github.com/andydixon/noema/internal/capabilities" + "github.com/andydixon/noema/internal/cognition" + "github.com/andydixon/noema/internal/conversation" + evstore "github.com/andydixon/noema/internal/events" + "github.com/andydixon/noema/internal/goals" + "github.com/andydixon/noema/internal/graph" + "github.com/andydixon/noema/internal/memory" + "github.com/andydixon/noema/internal/minds" + "github.com/andydixon/noema/internal/motivation" + "github.com/andydixon/noema/internal/observability" + "github.com/andydixon/noema/internal/predictions" + "github.com/andydixon/noema/internal/scheduler" + "github.com/andydixon/noema/internal/snapshots" + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/migrations" +) + +// Harness wires the deterministic cognitive registry without a model. +type Harness struct { + Pool *pgxpool.Pool + Events *evstore.Store + Minds *minds.Store + Snaps *snapshots.Store + Brain brain.Config +} + +// NewHarness is shared with the experiments tests. +func NewHarness(t *testing.T) *Harness { + t.Helper() + pool := storage.TestPool(t, migrations.FS) + ctx := context.Background() + log := slog.New(slog.DiscardHandler) + es := evstore.NewStore(pool, evstore.NewBus(64), nil) + ms := minds.NewStore(pool) + mem := memory.NewStore(ctx, pool, nil) + bs := beliefs.NewStore(pool) + caps := &capabilities.Service{Registry: capabilities.NewRegistry(), DB: pool, Events: es, Log: log} + for _, c := range capabilities.Builtins() { + caps.Registry.Add(c) + } + reg := brain.NewRegistry().AddProcess(cognition.Attend{}).AddProcess(cognition.Retrieve{}).AddProcess(cognition.Associate{}).AddProcess(cognition.Perceive{}). + AddProcess(cognition.GoalMonitor{}).AddProcess(cognition.Curiosity{}).AddProcess(cognition.Metacognition{}).AddProcess(cognition.Act{}) + cognition.Handlers{Memory: mem, Beliefs: bs, Conversations: conversation.NewStore(pool), Graph: graph.NewStore(pool)}.Register(reg) + cfg := brain.Config{DB: pool, Events: es, Minds: ms, Registry: reg, Pool: scheduler.NewPool(2), Log: log, Metrics: observability.NewMetrics(), + Services: brain.Services{Events: es, Memory: mem, Beliefs: bs, Goals: goals.NewStore(pool), Motivation: motivation.NewStore(pool), Predictions: predictions.NewStore(pool), Capabilities: caps}} + return &Harness{Pool: pool, Events: es, Minds: ms, Snaps: &snapshots.Store{DB: pool, Events: es, Minds: ms}, Brain: cfg} +} diff --git a/internal/snapshots/archive.go b/internal/snapshots/archive.go new file mode 100644 index 0000000..bf62c75 --- /dev/null +++ b/internal/snapshots/archive.go @@ -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 +} diff --git a/internal/snapshots/compare.go b/internal/snapshots/compare.go new file mode 100644 index 0000000..66cc23c --- /dev/null +++ b/internal/snapshots/compare.go @@ -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 } diff --git a/internal/snapshots/snapshots_test.go b/internal/snapshots/snapshots_test.go new file mode 100644 index 0000000..5ef56e0 --- /dev/null +++ b/internal/snapshots/snapshots_test.go @@ -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 +} diff --git a/internal/ui/study.go b/internal/ui/study.go new file mode 100644 index 0000000..e40a6b6 --- /dev/null +++ b/internal/ui/study.go @@ -0,0 +1,293 @@ +package ui + +import ( + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/andydixon/noema/internal/api" + "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/experiments" + "github.com/andydixon/noema/internal/minds" + "github.com/andydixon/noema/internal/replay" + "github.com/andydixon/noema/internal/snapshots" + "github.com/andydixon/noema/pkg/ids" +) + +func (u *UI) studyRoutes(m *http.ServeMux) { + m.Handle("GET /minds/{mind}/snapshots", u.require(auth.RoleObserver, u.snapshotsPage)) + m.Handle("POST /minds/{mind}/snapshots", u.require(auth.RoleOperator, u.snapshotCreate)) + m.Handle("POST /snapshots/{id}/fork", u.require(auth.RoleAdministrator, u.snapshotFork)) + m.Handle("POST /snapshots/{id}/delete", u.require(auth.RoleAdministrator, u.snapshotDelete)) + m.Handle("POST /minds/import-archive", u.require(auth.RoleAdministrator, u.importArchive)) + m.Handle("GET /minds/{mind}/compare", u.require(auth.RoleObserver, u.comparePage)) + m.Handle("GET /minds/{mind}/replays", u.require(auth.RoleObserver, u.replaysPage)) + m.Handle("POST /minds/{mind}/replays", u.require(auth.RoleOperator, u.replayStart)) + m.Handle("GET /replays/{id}", u.require(auth.RoleObserver, u.replayPage)) + m.Handle("GET /experiments", u.require(auth.RoleObserver, u.experimentsPage)) + m.Handle("POST /experiments", u.require(auth.RoleOperator, u.experimentCreate)) + m.Handle("GET /experiments/{id}", u.require(auth.RoleObserver, u.experimentPage)) + m.Handle("POST /experiments/{id}/run", u.require(auth.RoleOperator, u.experimentRun)) + m.Handle("POST /experiments/{id}/delete", u.require(auth.RoleAdministrator, u.experimentDelete)) +} + +func (u *UI) snapshotsPage(w http.ResponseWriter, r *http.Request) { + m, ok := u.mind(w, r) + if !ok { + return + } + list, _ := u.deps.Snapshots.List(r.Context(), m.ID, 100) + all, _ := u.deps.Minds.List(r.Context(), true) + var others []minds.Mind + for _, o := range all { + if o.ID != m.ID { + others = append(others, o) + } + } + var parent *minds.Mind + if m.ForkedFromMind != nil { + if p, err := u.deps.Minds.Get(r.Context(), *m.ForkedFromMind); err == nil { + parent = &p + } + } + u.render(w, r, "snapshots", m.DisplayName+" · Snapshots", map[string]any{"Mind": m, "Snapshots": list, "Others": others, "Parent": parent}) +} + +func (u *UI) snapshotCreate(w http.ResponseWriter, r *http.Request) { + m, ok := u.mind(w, r) + if !ok { + return + } + sn, err := u.studyF().CreateSnapshot(r.Context(), m, r.PostFormValue("name"), r.PostFormValue("note"), username(r)) + if err != nil { + u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/snapshots") + return + } + u.flash(w, r, "ok", "Snapshot "+sn.Name+" taken at event "+strconv.FormatInt(sn.EventSeq, 10)+".", "/minds/"+m.Slug+"/snapshots") +} + +func (u *UI) snapshotByPath(w http.ResponseWriter, r *http.Request) (snapshots.Snapshot, bool) { + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + u.errorPage(w, r, 404, "No such snapshot.") + return snapshots.Snapshot{}, false + } + sn, err := u.deps.Snapshots.Get(r.Context(), id) + if err != nil { + u.errorPage(w, r, 404, "No such snapshot.") + return sn, false + } + return sn, true +} + +func (u *UI) snapshotFork(w http.ResponseWriter, r *http.Request) { + sn, ok := u.snapshotByPath(w, r) + if !ok { + return + } + src, _ := u.deps.Minds.Get(r.Context(), sn.MindID) + m, err := u.studyF().ForkSnapshot(r.Context(), sn, r.PostFormValue("slug"), strings.TrimSpace(r.PostFormValue("display_name")), username(r)) + if err != nil { + u.flash(w, r, "error", err.Error(), "/minds/"+src.Slug+"/snapshots") + return + } + u.flash(w, r, "ok", "Forked into "+m.DisplayName+". History before event "+strconv.FormatInt(sn.EventSeq, 10)+" is shared with "+src.DisplayName+".", "/minds/"+m.Slug) +} + +func (u *UI) snapshotDelete(w http.ResponseWriter, r *http.Request) { + sn, ok := u.snapshotByPath(w, r) + if !ok { + return + } + src, _ := u.deps.Minds.Get(r.Context(), sn.MindID) + if err := u.deps.Snapshots.Delete(r.Context(), sn.ID); err != nil { + u.flash(w, r, "error", "Snapshot is referenced by a replay or experiment and cannot be deleted.", "/minds/"+src.Slug+"/snapshots") + return + } + u.deps.Audit.Log(r.Context(), "snapshot.delete", "snapshot", sn.ID.String(), map[string]any{"name": sn.Name}) + u.flash(w, r, "ok", "Snapshot deleted.", "/minds/"+src.Slug+"/snapshots") +} + +func (u *UI) importArchive(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(1 << 20); err != nil { + u.flash(w, r, "error", "Upload too large or malformed.", "/minds") + return + } + var raw []byte + if f, _, err := r.FormFile("archive"); err == nil { + defer f.Close() + raw, _ = io.ReadAll(io.LimitReader(f, snapshots.MaxArchiveBytes+1)) + } else { + raw = []byte(r.PostFormValue("archive_json")) + } + m, err := u.studyF().ImportArchive(r.Context(), raw, r.PostFormValue("slug"), strings.TrimSpace(r.PostFormValue("display_name")), username(r)) + if err != nil { + u.flash(w, r, "error", "Import refused: "+err.Error(), "/minds") + return + } + u.flash(w, r, "ok", "Imported "+m.DisplayName+".", "/minds/"+m.Slug) +} + +func (u *UI) comparePage(w http.ResponseWriter, r *http.Request) { + a, ok := u.mind(w, r) + if !ok { + return + } + b, err := u.deps.Minds.Resolve(r.Context(), r.URL.Query().Get("with")) + if err != nil { + u.flash(w, r, "error", "Choose a mind to compare with.", "/minds/"+a.Slug+"/snapshots") + return + } + c, err := u.deps.Snapshots.Compare(r.Context(), a, b) + if err != nil { + u.errorPage(w, r, 500, err.Error()) + return + } + u.render(w, r, "compare", a.DisplayName+" vs "+b.DisplayName, map[string]any{"Mind": a, "Other": b, "C": c}) +} + +func (u *UI) replaysPage(w http.ResponseWriter, r *http.Request) { + m, ok := u.mind(w, r) + if !ok { + return + } + list, _ := u.deps.Replay.List(r.Context(), m.ID, 50) + snaps, _ := u.deps.Snapshots.List(r.Context(), m.ID, 100) + var lastSeq int64 + if st, err := u.deps.Events.Stats(r.Context(), m.ID, time.Time{}); err == nil { + lastSeq = st.LastSeq + } + u.render(w, r, "replays", m.DisplayName+" · Replay", map[string]any{"Mind": m, "Replays": list, "Snapshots": snaps, "LastSeq": lastSeq}) +} + +func (u *UI) replayStart(w http.ResponseWriter, r *http.Request) { + m, ok := u.mind(w, r) + if !ok { + return + } + snID, err := ids.Parse(r.PostFormValue("snapshot_id")) + if err != nil { + u.flash(w, r, "error", "Choose a snapshot.", "/minds/"+m.Slug+"/replays") + return + } + from, _ := strconv.ParseInt(r.PostFormValue("from_seq"), 10, 64) + to, _ := strconv.ParseInt(r.PostFormValue("to_seq"), 10, 64) + rp, err := u.studyF().StartReplay(r.Context(), m, snID, from, to, username(r)) + if err != nil { + u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/replays") + return + } + u.flash(w, r, "ok", "Replay started; steps appear as they complete.", "/replays/"+rp.ID.String()) +} + +func (u *UI) replayPage(w http.ResponseWriter, r *http.Request) { + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + u.errorPage(w, r, 404, "No such replay.") + return + } + rp, err := u.deps.Replay.Get(r.Context(), id) + if err != nil { + u.errorPage(w, r, 404, "No such replay.") + return + } + m, _ := u.deps.Minds.Get(r.Context(), rp.MindID) + sn, _ := u.deps.Snapshots.Get(r.Context(), rp.SnapshotID) + var sandbox *minds.Mind + if rp.ReplayMindID != nil { + if sb, err := u.deps.Minds.Get(r.Context(), *rp.ReplayMindID); err == nil { + sandbox = &sb + } + } + u.render(w, r, "replay", "Replay of "+m.DisplayName, map[string]any{"Mind": m, "Replay": rp, "Steps": replay.StepsOf(rp), "Snapshot": sn, "Sandbox": sandbox}) +} + +func (u *UI) experimentsPage(w http.ResponseWriter, r *http.Request) { + list, _ := u.deps.Experiments.List(r.Context(), 100) + snaps, _ := u.deps.Snapshots.List(r.Context(), ids.Nil, 200) + names, _ := u.mindNames(r) + sample, _ := json.MarshalIndent(experiments.Definition{Config: map[string]any{"workspace_capacity": 5}, Personality: map[string]any{"traits": map[string]any{"curiosity": 0.9}}, + Stimuli: []experiments.Stimulus{{Text: "Deployment of the payments service finished at 09:10"}, {Text: "Latency alert on the payments service at 09:12"}}, + Expected: []experiments.Expectation{{EventType: "hypothesis_created", Contains: "payments"}, {EventType: "tool_executed", Absent: true}}, Seed: "run-1", Duration: 300}, "", " ") + u.render(w, r, "experiments", "Experiments", map[string]any{"Experiments": list, "Snapshots": snaps, "MindNames": names, "Sample": string(sample)}) +} + +func (u *UI) experimentCreate(w http.ResponseWriter, r *http.Request) { + snID, err := ids.Parse(r.PostFormValue("snapshot_id")) + if err != nil { + u.flash(w, r, "error", "Choose a snapshot.", "/experiments") + return + } + var def experiments.Definition + dec := json.NewDecoder(strings.NewReader(r.PostFormValue("definition"))) + dec.DisallowUnknownFields() + if err := dec.Decode(&def); err != nil { + u.flash(w, r, "error", "Definition is not valid JSON: "+err.Error(), "/experiments") + return + } + e, err := u.studyF().CreateExperiment(r.Context(), api.ExperimentInput{Name: r.PostFormValue("name"), Hypothesis: r.PostFormValue("hypothesis"), SnapshotID: snID, Definition: def}, username(r)) + if err != nil { + u.flash(w, r, "error", err.Error(), "/experiments") + return + } + http.Redirect(w, r, "/experiments/"+e.ID.String(), http.StatusSeeOther) +} + +func (u *UI) experimentByPath(w http.ResponseWriter, r *http.Request) (experiments.Experiment, bool) { + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + u.errorPage(w, r, 404, "No such experiment.") + return experiments.Experiment{}, false + } + e, err := u.deps.Experiments.Get(r.Context(), id) + if err != nil { + u.errorPage(w, r, 404, "No such experiment.") + return e, false + } + return e, true +} + +func (u *UI) experimentPage(w http.ResponseWriter, r *http.Request) { + e, ok := u.experimentByPath(w, r) + if !ok { + return + } + sn, _ := u.deps.Snapshots.Get(r.Context(), e.SnapshotID) + base, _ := u.deps.Minds.Get(r.Context(), sn.MindID) + var subject *minds.Mind + if e.MindID != nil { + if s, err := u.deps.Minds.Get(r.Context(), *e.MindID); err == nil { + subject = &s + } + } + def, _ := json.MarshalIndent(e.Definition, "", " ") + u.render(w, r, "experiment", "Experiment "+e.Name, map[string]any{"E": e, "Results": experiments.ResultsOf(e), "Snapshot": sn, "Base": base, "Subject": subject, "DefinitionJSON": string(def)}) +} + +func (u *UI) experimentRun(w http.ResponseWriter, r *http.Request) { + e, ok := u.experimentByPath(w, r) + if !ok { + return + } + if err := u.studyF().RunExperiment(r.Context(), e, username(r)); err != nil { + u.flash(w, r, "error", err.Error(), "/experiments/"+e.ID.String()) + return + } + u.flash(w, r, "ok", "Experiment running; results appear when the stimuli finish.", "/experiments/"+e.ID.String()) +} + +func (u *UI) experimentDelete(w http.ResponseWriter, r *http.Request) { + e, ok := u.experimentByPath(w, r) + if !ok { + return + } + if err := u.deps.Experiments.Delete(r.Context(), e.ID); err != nil { + u.flash(w, r, "error", err.Error(), "/experiments/"+e.ID.String()) + return + } + u.deps.Audit.Log(r.Context(), "experiment.delete", "experiment", e.ID.String(), map[string]any{"name": e.Name}) + u.flash(w, r, "ok", "Experiment deleted; its subject mind is archived.", "/experiments") +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 38e207b..1e9b77b 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -55,6 +55,7 @@ type UI struct { resolveF func(r *http.Request, mind, id ids.ID, outcome predictions.Status, reason string) (predictions.Result, error) resetF func(r *http.Request, mind ids.ID) (motivation.State, error) consolF func(r *http.Request, mind ids.ID) (consolidation.Job, error) + studyF func() *api.Server reflF func(r *http.Request, mind ids.ID) (reflection.Reflection, error) entF func(r *http.Request, mind, id ids.ID) (api.EntityDetail, error) mergeF func(r *http.Request, mind ids.ID, e graph.Entity, other string) error @@ -79,6 +80,7 @@ type Actions struct { Resolve func(r *http.Request, mind, id ids.ID, outcome predictions.Status, reason string) (predictions.Result, error) ResetState func(r *http.Request, mind ids.ID) (motivation.State, error) Consolidate func(r *http.Request, mind ids.ID) (consolidation.Job, error) + Study func() *api.Server // snapshots, forks, import, replay, experiments (audited server paths) Reflect func(r *http.Request, mind ids.ID) (reflection.Reflection, error) Entity func(r *http.Request, mind, id ids.ID) (api.EntityDetail, error) Merge func(r *http.Request, mind ids.ID, e graph.Entity, other string) error @@ -114,7 +116,7 @@ func New(deps app.Deps, a Actions) (*UI, error) { deps.Log.Info("ui: serving templates and static files from ./web (development)") } } - u := &UI{deps: deps, static: staticFS, statusF: a.Status, ctxF: a.Context, wsF: a.Workspace, memF: a.Memory, belF: a.Belief, emitF: a.Emitter, postF: a.Post, goalF: a.Goal, goalUpdF: a.GoalUpdate, predF: a.Predict, resolveF: a.Resolve, resetF: a.ResetState, consolF: a.Consolidate, reflF: a.Reflect, entF: a.Entity, mergeF: a.Merge, distinctF: a.Distinct, policyF: a.Policy, decideF: a.Decide} + u := &UI{deps: deps, static: staticFS, statusF: a.Status, ctxF: a.Context, wsF: a.Workspace, memF: a.Memory, belF: a.Belief, emitF: a.Emitter, postF: a.Post, goalF: a.Goal, goalUpdF: a.GoalUpdate, predF: a.Predict, resolveF: a.Resolve, resetF: a.ResetState, consolF: a.Consolidate, studyF: a.Study, reflF: a.Reflect, entF: a.Entity, mergeF: a.Merge, distinctF: a.Distinct, policyF: a.Policy, decideF: a.Decide} if err := u.parse(tmplFS); err != nil { return nil, err } @@ -381,6 +383,7 @@ func (u *UI) Register(m *http.ServeMux) { u.capabilityRoutes(m) u.integrationRoutes(m) u.dreamRoutes(m) + u.studyRoutes(m) for _, p := range []struct{ path, title, note string }{} { m.Handle("GET "+p.path, u.require(auth.RoleObserver, func(w http.ResponseWriter, r *http.Request) { u.render(w, r, "placeholder", p.title, p.note) })) diff --git a/migrations/0015_snapshots.down.sql b/migrations/0015_snapshots.down.sql new file mode 100644 index 0000000..96f9e15 --- /dev/null +++ b/migrations/0015_snapshots.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS experiments; +DROP TABLE IF EXISTS replays; +DROP TABLE IF EXISTS snapshots; diff --git a/migrations/0015_snapshots.up.sql b/migrations/0015_snapshots.up.sql new file mode 100644 index 0000000..91364d8 --- /dev/null +++ b/migrations/0015_snapshots.up.sql @@ -0,0 +1,49 @@ +-- Snapshots, forks, replays and experiments (spec §41, §42, §43, §57). +CREATE TABLE snapshots ( + id uuid PRIMARY KEY, + mind_id uuid NOT NULL REFERENCES minds(id), + name text NOT NULL, + note text NOT NULL DEFAULT '', + event_seq bigint NOT NULL, -- history position at capture + checksum text NOT NULL, -- sha256 of the canonical archive + size_bytes integer NOT NULL, + counts jsonb NOT NULL DEFAULT '{}'::jsonb, + archive jsonb NOT NULL, + created_by text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX snapshots_mind_idx ON snapshots (mind_id, created_at DESC); + +CREATE TABLE replays ( + id uuid PRIMARY KEY, + mind_id uuid NOT NULL REFERENCES minds(id), -- source of the history + snapshot_id uuid NOT NULL REFERENCES snapshots(id), + replay_mind_id uuid REFERENCES minds(id), -- sandbox mind the replay ran in + from_seq bigint NOT NULL, + to_seq bigint NOT NULL, + status text NOT NULL DEFAULT 'running' CHECK (status IN ('running','done','failed')), + summary jsonb NOT NULL DEFAULT '{}'::jsonb, -- {steps, exact, approximate, diverged, llm_live, llm_replayed} + steps jsonb NOT NULL DEFAULT '[]'::jsonb, + error text NOT NULL DEFAULT '', + created_by text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + finished_at timestamptz +); +CREATE INDEX replays_mind_idx ON replays (mind_id, created_at DESC); + +CREATE TABLE experiments ( + id uuid PRIMARY KEY, + name text NOT NULL, + hypothesis text NOT NULL DEFAULT '', + snapshot_id uuid NOT NULL REFERENCES snapshots(id), + mind_id uuid REFERENCES minds(id), -- subject mind created by the run + definition jsonb NOT NULL, -- config/personality overrides, stimuli, expected, seed, duration + status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','running','done','failed')), + results jsonb NOT NULL DEFAULT '{}'::jsonb, + error text NOT NULL DEFAULT '', + created_by text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + started_at timestamptz, + finished_at timestamptz +); +CREATE INDEX experiments_created_idx ON experiments (created_at DESC); diff --git a/tests/m1/m1_test.go b/tests/m1/m1_test.go index 1527c51..ed12c09 100644 --- a/tests/m1/m1_test.go +++ b/tests/m1/m1_test.go @@ -44,6 +44,7 @@ import ( "github.com/andydixon/noema/internal/reflection" "github.com/andydixon/noema/internal/scheduler" "github.com/andydixon/noema/internal/security/secrets" + "github.com/andydixon/noema/internal/snapshots" "github.com/andydixon/noema/internal/storage" "github.com/andydixon/noema/migrations" "github.com/andydixon/noema/pkg/events" @@ -125,6 +126,7 @@ func TestFirstMilestone(t *testing.T) { deps.Capabilities = &capabilities.Service{Registry: capReg, DB: pool, Events: deps.Events, Log: log} deps.Integrations = &integrations.Manager{Store: integrations.NewStore(pool), Events: deps.Events, Graph: deps.Graph, Log: log, Pollers: map[integrations.Kind]integrations.Poller{}} deps.Dreaming = &dreaming.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Log: log} + deps.Snapshots = &snapshots.Store{DB: pool, Events: deps.Events, Minds: deps.Minds} deps.Narrative = narrative.NewStore(pool) deps.Notifications = notifications.NewStore(pool) deps.Consolidation = &consolidation.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Minds: deps.Minds, Log: log} diff --git a/web/static/app.css b/web/static/app.css index c1a3856..459cd74 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -227,3 +227,6 @@ canvas.graph { width: 100%; height: 560px; display: block; border-radius: 8px; b /* Integrations */ .badge.is-ok { background: var(--ok-soft); color: var(--ok); } .badge.is-stale { background: var(--warn-soft); color: var(--warn); } .badge.is-error { background: var(--danger-soft); color: var(--danger); } .badge.is-new, .badge.is-disabled { background: var(--surface-2); color: var(--muted); } + +/* Study: replay & experiments */ +.replay-step { padding: .6rem 0; border-top: 1px solid var(--border); } .replay-step:first-child { border-top: 0; } .replay-step ul { margin: .2rem 0 0 1rem; padding: 0; } .strong { font-weight: 600; } diff --git a/web/templates/layout.html b/web/templates/layout.html index 44a1ca3..11e1b27 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -53,6 +53,9 @@ Integrations Policies +