Files
noema/internal/replay/driver.go
T
Andy Dixon 072422d916 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
2026-07-03 22:12:31 +01:00

160 lines
4.9 KiB
Go

// 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), "<id>")
s = numRe.ReplaceAllString(s, "<n>")
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