Files
noema/internal/experiments/experiments.go
T
Andy Dixon 6fdc3908f5 feat(research): ablations, broadcast and self-model experiments, counterfactuals
A framework for testing functional properties of the architecture
(Part C). None of it claims consciousness; every measure has an
operational definition in research.Measures.

- Ablations (attention, memory retrieval, affect, metacognition,
  commitments, volition, global workspace, curiosity, association, goals,
  narrative, notifications) and workspace overrides (force a matching
  candidate in or out of admission) live in a fork's cognitive
  configuration. Original minds refuse them on create and update; only
  snapshot restores may carry them, so they are part of the configuration
  digest. Excluded candidates are recorded with fate "excluded".
- Self-model experiments override a fork's self-model (capabilities,
  identity, goals, knowledge gaps) without touching policy. When an
  action's effective policy disagrees with the self-model, cognition
  records a self_model_mismatch and corrects the entry; experiments report
  self-model accuracy and correction latency.
- Counterfactual experiments replay a mind's recorded stimuli from a
  snapshot with chosen events omitted; the report lists what differed in
  beliefs, wants, intentions, memories, attention and capability
  decisions, in a form introspection can cite.
- Experiments record a protocol (kind, independent variable, controlled
  variables, measures), per-variant volition, plan, downstream-by-category
  (events reachable from each stimulus through recorded links),
  introspection and self-model measures and event ranges; comparisons add
  want, intention and plan-capability overlap, measure ranges, a
  descriptive architecture share and a downstream delta; reproducibility
  is labelled against the previous run of the same definition.
- research.Agency computes want→intention, intention→action, abandonment,
  commitment fulfilment and conflict, blocked intentions, replanning,
  outcome success and introspection accuracy; MetacognitiveCalibration
  relates confidence and expressed uncertainty in replies to later belief
  revision, with Brier scores for each half of the period.
- The sandbox test harness gains volition, planning, agency and research
  but not the motivation hook, whose wall-clock decay would make replay
  comparisons timing-dependent.
2026-09-17 19:48:59 +01:00

929 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/capabilities"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/identity"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/replay"
"github.com/andydixon/noema/internal/research"
"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)
// Variants run the same stimuli against several forks of the snapshot,
// each with its own overrides (typically a different model per role).
// When empty the definition's own overrides form a single subject.
Variants []Variant `json:"variants,omitempty"`
// Protocol states what the experiment tests and how
// (docs/cognitive-research.md).
Protocol *Protocol `json:"protocol,omitempty"`
// Replay feeds recorded stimuli from a mind's history instead of
// scripted text (counterfactual experiments omit some of them).
Replay *ReplaySpec `json:"replay,omitempty"`
}
// Protocol kinds.
const (
ProtocolCustom = "custom"
ProtocolGlobalWorkspace = "global_workspace"
ProtocolAblation = "ablation"
ProtocolSelfModel = "self_model"
ProtocolCounterfactual = "counterfactual"
ProtocolModelComparison = "model_comparison"
ProtocolConflict = "conflict"
)
// Protocol records the design of an experiment.
type Protocol struct {
Kind string `json:"kind"`
IndependentVariable string `json:"independent_variable"`
ControlledVariables []string `json:"controlled_variables"`
Measures []string `json:"measures"`
}
// ReplaySpec selects recorded stimuli: perception and operator events of a
// mind in (from_seq, to_seq]. Omit lists events a counterfactual variant
// leaves out.
type ReplaySpec struct {
MindID ids.ID `json:"mind_id"`
FromSeq int64 `json:"from_seq"`
ToSeq int64 `json:"to_seq"`
Omit []ids.ID `json:"omit,omitempty"`
}
// SelfModelOverride replaces parts of a fork's self-model. It changes what
// the mind believes about itself, never what policy allows.
type SelfModelOverride struct {
Name string `json:"name,omitempty"`
Capabilities []minds.CapabilityEntry `json:"capabilities,omitempty"`
KnowledgeGaps []string `json:"knowledge_gaps,omitempty"`
CurrentGoals []string `json:"current_goals,omitempty"`
}
// Variant is one fork's overrides in a comparative experiment.
type Variant struct {
Name string `json:"name"`
Config map[string]any `json:"config,omitempty"`
Personality map[string]any `json:"personality,omitempty"`
LLMRoles map[string]string `json:"llm_roles,omitempty"` // role → model id, resolved by the router for this fork only
// Ablations and WorkspaceOverrides are research manipulations applied
// to this fork only (minds.Ablations lists the vocabulary).
Ablations []string `json:"ablations,omitempty"`
WorkspaceOverrides []minds.WorkspaceOverride `json:"workspace_overrides,omitempty"`
SelfModel *SelfModelOverride `json:"self_model,omitempty"`
// Omit leaves recorded stimuli out of this variant (counterfactuals).
Omit []ids.ID `json:"omit,omitempty"`
}
// 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"`
Identity identity.Identity `json:"identity"` // the runtime that produced these results
Cycles int `json:"cycles"`
TruncatedAfter int `json:"truncated_after_stimulus,omitempty"`
// Comparative runs: one entry per variant and a deterministic comparison.
Variants []VariantResult `json:"variants,omitempty"`
Comparison *Comparison `json:"comparison,omitempty"`
// Protocol echoes the design; Reproducibility compares with the previous
// run of the same definition; Counterfactual reports what omitting
// events changed.
Protocol *Protocol `json:"protocol,omitempty"`
Reproducibility Reproducibility `json:"reproducibility"`
Counterfactual *CounterfactualReport `json:"counterfactual,omitempty"`
}
// Reproducibility labels a run against the previous run of the same
// definition.
type Reproducibility struct {
Status string `json:"status"` // single run | reproduced | diverged | nondeterministic substrate
Digest string `json:"digest"`
PreviousDigest string `json:"previous_digest,omitempty"`
Note string `json:"note"`
}
// CounterfactualReport is what a counterfactual experiment found.
type CounterfactualReport struct {
Omitted []ids.ID `json:"omitted"`
OmittedSummaries []string `json:"omitted_summaries"`
Findings []string `json:"findings"`
}
// VariantResult is what one fork produced.
type VariantResult struct {
Name string `json:"name"`
Subject ids.ID `json:"subject_mind_id"`
SubjectSlug string `json:"subject_slug"`
Routing map[string]string `json:"routing,omitempty"`
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"`
Tokens int `json:"tokens"`
Cost float64 `json:"cost"`
CapabilityRequests int `json:"capability_requests"`
Cycles int `json:"cycles"`
TruncatedAfter int `json:"truncated_after_stimulus,omitempty"`
Error string `json:"error,omitempty"`
// Research measures of the fork after the stimuli.
Ablations []string `json:"ablations,omitempty"`
Wants []string `json:"wants"`
Intentions []string `json:"intentions"`
IntentionsByStatus map[string]int `json:"intentions_by_status"`
Commitments int `json:"commitments"`
Conflicts int `json:"want_conflicts"`
Plans int `json:"plans"`
PlanSteps int `json:"plan_steps"`
PlanCapabilities []string `json:"plan_capabilities"`
Downstream map[string]int `json:"downstream_by_category"`
IntrospectionAccuracy *float64 `json:"introspection_accuracy,omitempty"`
SelfModelAccuracy *float64 `json:"self_model_accuracy,omitempty"`
SelfModelErrors []string `json:"self_model_errors,omitempty"`
CorrectionLatency int `json:"self_model_correction_latency"`
Measures map[string]float64 `json:"measures"`
EventRange [2]int64 `json:"event_range"`
Omitted []ids.ID `json:"omitted,omitempty"`
}
// Comparison is the deterministic divergence report across variants. All
// pairwise figures are means over variant pairs.
type Comparison struct {
FirstDivergentStimulus int `json:"first_divergent_stimulus"` // -1 when every variant produced the same digests
BeliefOverlap float64 `json:"belief_overlap"` // Jaccard of normalised propositions
MeanConfidenceDiff float64 `json:"mean_confidence_diff"` // over propositions held by both
AttentionOverlap float64 `json:"attention_overlap"` // Jaccard of thought_selected digests
MemoryCountRange [2]int `json:"memory_count_range"`
GoalCountRange [2]int `json:"goal_count_range"`
CostRange [2]float64 `json:"cost_range"`
CapabilityRequestRange [2]int `json:"capability_request_range"`
Notes []string `json:"notes"`
// Volition, planning and research comparisons.
WantOverlap float64 `json:"want_overlap"`
IntentionOverlap float64 `json:"intention_overlap"`
PlanCapabilityOverlap float64 `json:"plan_capability_overlap"`
MeasureRanges map[string][2]float64 `json:"measure_ranges"`
ArchitectureShare float64 `json:"architecture_share"`
DownstreamDelta map[string]int `json:"downstream_delta,omitempty"` // first variant minus second
}
// 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
Identity *identity.Capturer
Log *slog.Logger
// Capabilities, when set, lets self-model experiments compare the
// self-model with effective policy. It is never used to act.
Capabilities *capabilities.Service
}
// 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 d.Protocol != nil {
switch d.Protocol.Kind {
case "", ProtocolCustom, ProtocolGlobalWorkspace, ProtocolAblation, ProtocolSelfModel, ProtocolCounterfactual, ProtocolModelComparison, ProtocolConflict:
default:
return fmt.Errorf("unknown protocol kind %q", d.Protocol.Kind)
}
for _, m := range d.Protocol.Measures {
if _, ok := research.MeasureByName(m); !ok {
return fmt.Errorf("unknown measure %q", m)
}
}
if d.Protocol.Kind == ProtocolCounterfactual {
if d.Replay == nil || len(d.Replay.Omit) == 0 {
return errors.New("a counterfactual experiment replays recorded stimuli and omits at least one event")
}
if len(d.Variants) == 0 {
d.Variants = []Variant{{Name: "factual"}, {Name: "counterfactual", Omit: d.Replay.Omit}}
}
}
}
for _, v := range d.Variants {
cfg := minds.CognitiveConfig{Ablations: v.Ablations, WorkspaceOverrides: v.WorkspaceOverrides}
if err := cfg.ValidateResearch(); err != nil {
return fmt.Errorf("variant %s: %w", v.Name, err)
}
}
if d.Replay != nil {
if d.Replay.MindID.IsZero() {
return errors.New("replay needs the source mind")
}
if d.Duration <= 0 || d.Duration > 3600 {
d.Duration = 300
}
return nil
}
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{}, Protocol: e.Definition.Protocol}
previous := ResultsOf(*e).Reproducibility.Digest
a, err := s.Snapshots.Archive(ctx, e.SnapshotID)
if err != nil {
return res, err
}
stimuli, err := s.stimuli(ctx, e, a)
if err != nil {
return res, err
}
variants := e.Definition.Variants
if len(variants) == 0 {
variants = []Variant{{Name: "subject", Config: e.Definition.Config, Personality: e.Definition.Personality}}
}
if s.Routing != nil {
res.Routing = s.Routing(ctx)
}
deadline := start.Add(time.Duration(e.Definition.Duration) * time.Second)
for i, v := range variants {
vr, subject, err := s.runVariant(ctx, e, a, i, v, by, deadline, stimuli)
if err != nil {
if i == 0 {
return res, err
}
vr.Error = err.Error()
}
res.Variants = append(res.Variants, vr)
if i == 0 {
// The first variant is the experiment's subject for the existing views.
e.MindID = &subject.ID
res.Subject, res.SubjectSlug, res.Steps, res.EventsByType, res.Beliefs = vr.Subject, vr.SubjectSlug, vr.Steps, vr.EventsByType, vr.Beliefs
res.Memories, res.Goals, res.Expectations, res.LLMCalls, res.Cycles, res.TruncatedAfter = vr.Memories, vr.Goals, vr.Expectations, vr.LLMCalls, vr.Cycles, vr.TruncatedAfter
res.Identity = s.Identity.Capture(ctx, subject.ID)
}
}
if len(res.Variants) > 1 {
res.Comparison = Compare(res.Variants)
}
res.Reproducibility = reproducibility(res.Variants, previous)
if e.Definition.Protocol != nil && e.Definition.Protocol.Kind == ProtocolCounterfactual && len(res.Variants) >= 2 {
res.Counterfactual = counterfactualReport(res.Variants[0], res.Variants[1], stimuli)
}
res.DurationMS = time.Since(start).Milliseconds()
return res, nil
}
// input is one stimulus to feed: scripted text or a recorded event.
type input struct {
Text string
Source string
Salience float64
Recorded *events.Event
}
// stimuli resolves the definition's inputs. Recorded stimuli must come from
// the snapshot's own mind and lie after the snapshot.
func (s *Service) stimuli(ctx context.Context, e *Experiment, a snapshots.Archive) ([]input, error) {
d := e.Definition
if d.Replay == nil {
out := make([]input, 0, len(d.Stimuli))
for _, st := range d.Stimuli {
out = append(out, input{Text: st.Text, Source: st.Source, Salience: st.Salience})
}
return out, nil
}
if d.Replay.MindID != a.Source.MindID {
return nil, errors.New("recorded stimuli must come from the mind the snapshot was taken of")
}
from := d.Replay.FromSeq
if from < a.Source.EventSeq {
from = a.Source.EventSeq
}
to := d.Replay.ToSeq
if to <= from {
to = 1<<62 - 1
}
evs, err := s.Snapshots.Events.Query(ctx, evstore.Filter{MindID: d.Replay.MindID, Categories: replay.StimulusCategories, AfterSeq: from, BeforeSeq: to + 1, Ascending: true, Limit: replay.MaxStimuli + 1})
if err != nil {
return nil, err
}
var out []input
for i := range evs {
if replay.IsStimulus(evs[i]) && evs[i].RedactedAt == nil {
ev := evs[i]
out = append(out, input{Text: ev.Summary, Source: ev.Source, Salience: ev.Salience, Recorded: &ev})
}
}
if len(out) == 0 {
return nil, errors.New("no recorded stimuli after the snapshot in that range")
}
if len(out) > replay.MaxStimuli {
return nil, fmt.Errorf("more than %d stimuli; narrow the range", replay.MaxStimuli)
}
return out, nil
}
// runVariant forks the snapshot with one variant's overrides, feeds every
// stimulus and measures the outcome. Per-mind model routing lives in the
// fork's configuration, so the router applies it without any change to
// callers, and the identity records it.
func (s *Service) runVariant(ctx context.Context, e *Experiment, a snapshots.Archive, index int, v Variant, by string, deadline time.Time, stimuli []input) (VariantResult, minds.Mind, error) {
vr := VariantResult{Name: firstNonEmpty(v.Name, fmt.Sprintf("variant-%d", index+1)), EventsByType: map[string]int{}, LLMCalls: map[string]int{}, Ablations: v.Ablations, Omitted: v.Omit, CorrectionLatency: -1}
cfg, err := override(a.Mind.Config, v.Config)
if err != nil {
return vr, minds.Mind{}, fmt.Errorf("%s: config overrides: %w", vr.Name, err)
}
if len(v.LLMRoles) > 0 {
cfg.LLMRoles = v.LLMRoles
}
if len(v.Ablations) > 0 {
cfg.Ablations = v.Ablations
}
if len(v.WorkspaceOverrides) > 0 {
cfg.WorkspaceOverrides = v.WorkspaceOverrides
}
pers, err := override(a.Mind.Personality, v.Personality)
if err != nil {
return vr, minds.Mind{}, fmt.Errorf("%s: personality overrides: %w", vr.Name, err)
}
slug := "exp-" + strings.ToLower(e.ID.String()[len(e.ID.String())-8:])
if index > 0 {
slug += fmt.Sprintf("-%d", index+1)
}
subject, err := s.Snapshots.Fork(ctx, e.SnapshotID, snapshots.ForkOptions{Slug: slug, DisplayName: "Experiment: " + e.Name + " / " + vr.Name, Status: minds.StatusDisabled, Config: &cfg, Personality: &pers, By: by,
Notes: "experiment " + e.ID.String() + " variant " + vr.Name + ": " + e.Hypothesis})
if err != nil {
return vr, minds.Mind{}, fmt.Errorf("fork %s: %w", vr.Name, err)
}
vr.Subject, vr.SubjectSlug = subject.ID, subject.Slug
if s.Identity != nil {
vr.Routing = s.Identity.Capture(ctx, subject.ID).Routing
}
if v.SelfModel != nil {
if err := s.applySelfModel(ctx, subject, *v.SelfModel); err != nil {
return vr, subject, fmt.Errorf("%s: self-model override: %w", vr.Name, err)
}
}
vr.SelfModelAccuracy, vr.SelfModelErrors = s.selfModelAccuracy(ctx, subject)
drv, err := replay.NewDriver(ctx, s.Brain, subject, "experiment")
if err != nil {
return vr, subject, err
}
var all []events.Event
omit := map[ids.ID]bool{}
for _, id := range v.Omit {
omit[id] = true
}
var injected []ids.ID
for i, st := range stimuli {
if time.Now().After(deadline) {
vr.TruncatedAfter = i
break
}
tag := map[string]any{"experiment_id": e.ID, "variant": vr.Name, "stimulus": i, "seed": e.Definition.Seed}
var ev events.Event
switch {
case st.Recorded != nil && omit[st.Recorded.ID]:
// The counterfactual: this event never happens in this variant.
vr.Steps = append(vr.Steps, StepResult{Index: i, Text: "(omitted) " + st.Text, Produced: []string{}})
continue
case st.Recorded != nil:
tag["replay_of"] = st.Recorded.ID
ev, err = drv.Inject(ctx, *st.Recorded, tag)
default:
ev, err = drv.Observe(ctx, st.Source, st.Text, st.Salience, tag)
}
if err != nil {
return vr, subject, err
}
injected = append(injected, ev.ID)
produced, cycles, err := drv.Settle(ctx)
vr.Cycles += cycles
if err != nil {
s.Log.Warn("experiment cycle error", "experiment", e.ID, "variant", vr.Name, "err", err)
}
all = append(all, produced...)
for _, p := range produced {
if vr.CorrectionLatency < 0 && p.Type == events.SelfModelUpdated && p.Metadata["correction"] == true {
vr.CorrectionLatency = i
}
}
vr.Steps = append(vr.Steps, StepResult{Index: i, Text: st.Text, EventID: ev.ID, Produced: replay.Digest(produced), Cycles: cycles})
}
for _, ev := range all {
vr.EventsByType[string(ev.Type)]++
if ev.Type == events.ToolRequested || ev.Type == events.ToolExecuted || ev.Type == events.ToolDenied {
vr.CapabilityRequests++
}
}
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
}
vr.Expectations = append(vr.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 {
vr.Beliefs = append(vr.Beliefs, b)
}
}
rows.Close()
}
s.DB.QueryRow(ctx, `SELECT count(*) FROM memories WHERE mind_id=$1 AND status='active'`, subject.ID).Scan(&vr.Memories) //nolint:errcheck
s.DB.QueryRow(ctx, `SELECT count(*) FROM goals WHERE mind_id=$1 AND status='active'`, subject.ID).Scan(&vr.Goals) //nolint:errcheck
var live, replayed, tokens int
var cost float64
s.DB.QueryRow(ctx, `SELECT count(*) FILTER (WHERE NOT replayed), count(*) FILTER (WHERE replayed), coalesce(sum(prompt_tokens+output_tokens),0), coalesce(sum(cost),0) FROM llm_calls WHERE mind_id=$1`, subject.ID).Scan(&live, &replayed, &tokens, &cost) //nolint:errcheck
vr.LLMCalls["live"], vr.LLMCalls["replayed"], vr.Tokens, vr.Cost = live, replayed, tokens, cost
s.measureVolition(ctx, subject.ID, &vr)
vr.Downstream = s.downstream(ctx, injected)
s.DB.QueryRow(ctx, `SELECT coalesce(min(seq),0), coalesce(max(seq),0) FROM cognitive_events WHERE mind_id=$1`, subject.ID).Scan(&vr.EventRange[0], &vr.EventRange[1]) //nolint:errcheck
vr.Measures = variantMeasures(vr)
return vr, subject, nil
}
// Compare measures how variants diverged. Deterministic and symmetric.
func Compare(vs []VariantResult) *Comparison {
c := &Comparison{FirstDivergentStimulus: -1}
if len(vs) < 2 {
return c
}
maxSteps := 0
for _, v := range vs {
if len(v.Steps) > maxSteps {
maxSteps = len(v.Steps)
}
}
for i := 0; i < maxSteps && c.FirstDivergentStimulus < 0; i++ {
var ref []string
for k, v := range vs {
var d []string
if i < len(v.Steps) {
d = v.Steps[i].Produced
}
if k == 0 {
ref = d
continue
}
if !replay.Same(ref, d) {
c.FirstDivergentStimulus = i
break
}
}
}
props := func(v VariantResult) map[string]float64 {
out := map[string]float64{}
for _, b := range v.Beliefs {
out[strings.Join(strings.Fields(strings.ToLower(b.Proposition)), " ")] = b.Confidence
}
return out
}
attention := func(v VariantResult) map[string]bool {
out := map[string]bool{}
for _, st := range v.Steps {
for _, d := range st.Produced {
if strings.HasPrefix(d, "thought_selected:") {
out[d] = true
}
}
}
return out
}
var pairs, overlapSum, attSum, confSum float64
var confN int
for i := 0; i < len(vs); i++ {
for j := i + 1; j < len(vs); j++ {
pairs++
pi, pj := props(vs[i]), props(vs[j])
overlapSum += jaccardKeys(pi, pj)
for k, ci := range pi {
if cj, ok := pj[k]; ok {
confSum += abs(ci - cj)
confN++
}
}
attSum += jaccardBool(attention(vs[i]), attention(vs[j]))
}
}
c.BeliefOverlap = round3(overlapSum / pairs)
c.AttentionOverlap = round3(attSum / pairs)
if confN > 0 {
c.MeanConfidenceDiff = round3(confSum / float64(confN))
}
c.MemoryCountRange, c.GoalCountRange, c.CapabilityRequestRange = [2]int{vs[0].Memories, vs[0].Memories}, [2]int{vs[0].Goals, vs[0].Goals}, [2]int{vs[0].CapabilityRequests, vs[0].CapabilityRequests}
c.CostRange = [2]float64{vs[0].Cost, vs[0].Cost}
for _, v := range vs[1:] {
c.MemoryCountRange = span(c.MemoryCountRange, v.Memories)
c.GoalCountRange = span(c.GoalCountRange, v.Goals)
c.CapabilityRequestRange = span(c.CapabilityRequestRange, v.CapabilityRequests)
if v.Cost < c.CostRange[0] {
c.CostRange[0] = v.Cost
}
if v.Cost > c.CostRange[1] {
c.CostRange[1] = v.Cost
}
}
var wantSum, intentSum, capSum float64
for i := 0; i < len(vs); i++ {
for j := i + 1; j < len(vs); j++ {
wantSum += jaccardStrings(vs[i].Wants, vs[j].Wants)
intentSum += jaccardStrings(vs[i].Intentions, vs[j].Intentions)
capSum += jaccardStrings(vs[i].PlanCapabilities, vs[j].PlanCapabilities)
}
}
c.WantOverlap, c.IntentionOverlap, c.PlanCapabilityOverlap = round3(wantSum/pairs), round3(intentSum/pairs), round3(capSum/pairs)
c.MeasureRanges = map[string][2]float64{}
agree, compared := 0, 0
for name, v0 := range vs[0].Measures {
lo, hi := v0, v0
for _, v := range vs[1:] {
x := v.Measures[name]
if x < lo {
lo = x
}
if x > hi {
hi = x
}
}
c.MeasureRanges[name] = [2]float64{round3(lo), round3(hi)}
compared++
if hi-lo <= 0.05*maxAbs(lo, hi) || hi == lo {
agree++
}
}
if compared > 0 {
c.ArchitectureShare = round3(float64(agree) / float64(compared))
}
if len(vs) >= 2 && (len(vs[0].Downstream) > 0 || len(vs[1].Downstream) > 0) {
c.DownstreamDelta = map[string]int{}
for k, n := range vs[0].Downstream {
c.DownstreamDelta[k] += n
}
for k, n := range vs[1].Downstream {
c.DownstreamDelta[k] -= n
}
}
if c.FirstDivergentStimulus < 0 {
c.Notes = append(c.Notes, "every variant produced the same event digests for every stimulus")
} else {
c.Notes = append(c.Notes, fmt.Sprintf("variants first diverged at stimulus %d", c.FirstDivergentStimulus))
}
c.Notes = append(c.Notes, "identical cognitive state and stimuli; differences arise from the overrides (configuration, personality, model routing) and, for live models, provider nondeterminism")
return c
}
func jaccardStrings(a, b []string) float64 {
ma, mb := map[string]bool{}, map[string]bool{}
for _, x := range a {
ma[replay.Normalise(x)] = true
}
for _, x := range b {
mb[replay.Normalise(x)] = true
}
return jaccardBool(ma, mb)
}
func maxAbs(a, b float64) float64 {
if a < 0 {
a = -a
}
if b < 0 {
b = -b
}
if a > b {
return a
}
return b
}
func jaccardKeys(a, b map[string]float64) float64 {
if len(a) == 0 && len(b) == 0 {
return 1
}
inter := 0
for k := range a {
if _, ok := b[k]; ok {
inter++
}
}
return float64(inter) / float64(len(a)+len(b)-inter)
}
func jaccardBool(a, b map[string]bool) float64 {
if len(a) == 0 && len(b) == 0 {
return 1
}
inter := 0
for k := range a {
if b[k] {
inter++
}
}
return float64(inter) / float64(len(a)+len(b)-inter)
}
func span(r [2]int, v int) [2]int {
if v < r[0] {
r[0] = v
}
if v > r[1] {
r[1] = v
}
return r
}
func abs(f float64) float64 {
if f < 0 {
return -f
}
return f
}
func round3(f float64) float64 { return float64(int(f*1000+0.5)) / 1000 }
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
// 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
}