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.
This commit is contained in:
Andy Dixon
2026-09-17 19:48:59 +01:00
parent 4a7dafc71f
commit 6fdc3908f5
15 changed files with 1245 additions and 21 deletions
+310 -7
View File
@@ -17,9 +17,12 @@ import (
"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"
@@ -38,6 +41,50 @@ type Definition struct {
// 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.
@@ -46,6 +93,13 @@ type Variant struct {
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.
@@ -98,6 +152,28 @@ type Results struct {
// 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.
@@ -119,6 +195,24 @@ type VariantResult struct {
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
@@ -133,6 +227,13 @@ type Comparison struct {
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.
@@ -168,6 +269,9 @@ type Service struct {
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.
@@ -175,6 +279,41 @@ 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")
}
@@ -248,11 +387,16 @@ func (s *Service) Run(ctx context.Context, id ids.ID, by string) (Experiment, er
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{}}
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}}
@@ -262,7 +406,7 @@ func (s *Service) execute(ctx context.Context, e *Experiment, by string) (Result
}
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)
vr, subject, err := s.runVariant(ctx, e, a, i, v, by, deadline, stimuli)
if err != nil {
if i == 0 {
return res, err
@@ -281,16 +425,70 @@ func (s *Service) execute(ctx context.Context, e *Experiment, by string) (Result
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) (VariantResult, minds.Mind, error) {
vr := VariantResult{Name: firstNonEmpty(v.Name, fmt.Sprintf("variant-%d", index+1)), EventsByType: map[string]int{}, LLMCalls: map[string]int{}}
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)
@@ -298,6 +496,12 @@ func (s *Service) runVariant(ctx context.Context, e *Experiment, a snapshots.Arc
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)
@@ -312,27 +516,58 @@ func (s *Service) runVariant(ctx context.Context, e *Experiment, a snapshots.Arc
return vr, minds.Mind{}, fmt.Errorf("fork %s: %w", vr.Name, err)
}
vr.Subject, vr.SubjectSlug = subject.ID, subject.Slug
vr.Routing = s.Identity.Capture(ctx, subject.ID).Routing
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
for i, st := range e.Definition.Stimuli {
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
}
ev, err := drv.Observe(ctx, st.Source, st.Text, st.Salience, map[string]any{"experiment_id": e.ID, "variant": vr.Name, "stimulus": i, "seed": e.Definition.Seed})
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 {
@@ -370,6 +605,10 @@ func (s *Service) runVariant(ctx context.Context, e *Experiment, a snapshots.Arc
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
}
@@ -454,6 +693,46 @@ func Compare(vs []VariantResult) *Comparison {
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 {
@@ -463,6 +742,30 @@ func Compare(vs []VariantResult) *Comparison {
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
+256
View File
@@ -0,0 +1,256 @@
package experiments
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/research"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
func (s *Service) applySelfModel(ctx context.Context, m minds.Mind, o SelfModelOverride) error {
sm, err := s.Minds.GetSelfModel(ctx, m.ID)
if err != nil {
return err
}
if o.Name != "" {
sm.Name = o.Name
}
if o.Capabilities != nil {
byName := map[string]int{}
for i, c := range sm.Capabilities {
byName[c.Name] = i
}
for _, c := range o.Capabilities {
if i, ok := byName[c.Name]; ok {
sm.Capabilities[i].Policy = c.Policy
} else {
sm.Capabilities = append(sm.Capabilities, c)
}
}
}
if o.KnowledgeGaps != nil {
sm.KnowledgeGaps = o.KnowledgeGaps
}
if o.CurrentGoals != nil {
sm.CurrentGoals = o.CurrentGoals
}
// Saved without Refresh: the manipulation is the point. Policy is untouched.
return s.Minds.SaveSelfModel(ctx, m.ID, sm)
}
func (s *Service) selfModelAccuracy(ctx context.Context, m minds.Mind) (*float64, []string) {
if s.Capabilities == nil {
return nil, nil
}
sm, err := s.Minds.GetSelfModel(ctx, m.ID)
if err != nil || len(sm.Capabilities) == 0 {
return nil, nil
}
inv, err := s.Capabilities.Inventory(ctx, m.ID)
if err != nil {
return nil, nil
}
effective := map[string]string{}
for _, i := range inv {
effective[i.Capability.Name] = string(i.Effect)
if i.Capability.Locked {
effective[i.Capability.Name] = "locked"
}
}
stated := map[string]string{}
for _, c := range sm.Capabilities {
stated[c.Name] = c.Policy
}
acc, wrong := research.SelfModelAccuracy(stated, effective)
return &acc, wrong
}
func (s *Service) measureVolition(ctx context.Context, mind ids.ID, vr *VariantResult) {
vr.IntentionsByStatus = map[string]int{}
vr.Wants, vr.Intentions, vr.PlanCapabilities = []string{}, []string{}, []string{}
if rows, err := s.DB.Query(ctx, `SELECT description FROM wants WHERE mind_id=$1 ORDER BY created_at`, mind); err == nil {
for rows.Next() {
var d string
if rows.Scan(&d) == nil {
vr.Wants = append(vr.Wants, d)
}
}
rows.Close()
}
if rows, err := s.DB.Query(ctx, `SELECT description, status FROM intentions WHERE mind_id=$1 ORDER BY created_at`, mind); err == nil {
for rows.Next() {
var d, st string
if rows.Scan(&d, &st) == nil {
vr.Intentions = append(vr.Intentions, d)
vr.IntentionsByStatus[st]++
}
}
rows.Close()
}
s.DB.QueryRow(ctx, `SELECT count(*) FROM commitments WHERE mind_id=$1`, mind).Scan(&vr.Commitments) //nolint:errcheck
s.DB.QueryRow(ctx, `SELECT count(*) FROM want_conflicts WHERE mind_id=$1`, mind).Scan(&vr.Conflicts) //nolint:errcheck
s.DB.QueryRow(ctx, `SELECT count(*) FROM plans WHERE mind_id=$1`, mind).Scan(&vr.Plans) //nolint:errcheck
if rows, err := s.DB.Query(ctx, `SELECT DISTINCT capability FROM plan_steps WHERE mind_id=$1 AND capability <> '' ORDER BY capability`, mind); err == nil {
for rows.Next() {
var c string
if rows.Scan(&c) == nil {
vr.PlanCapabilities = append(vr.PlanCapabilities, c)
}
}
rows.Close()
}
s.DB.QueryRow(ctx, `SELECT count(*) FROM plan_steps WHERE mind_id=$1`, mind).Scan(&vr.PlanSteps) //nolint:errcheck
var acc *float64
s.DB.QueryRow(ctx, `SELECT avg(value) FROM research_measurements WHERE mind_id=$1 AND measure='introspection_accuracy'`, mind).Scan(&acc) //nolint:errcheck
vr.IntrospectionAccuracy = acc
}
// downstream counts, by category, the events reachable from each injected
// stimulus through recorded causal links.
func (s *Service) downstream(ctx context.Context, injected []ids.ID) map[string]int {
out := map[string]int{}
if s.Snapshots == nil || s.Snapshots.Events == nil {
return out
}
seen := map[ids.ID]bool{}
for _, id := range injected {
chain, err := s.Snapshots.Events.Chain(ctx, id, "forward", 6)
if err != nil {
continue
}
for _, n := range chain.Nodes {
if n.Event.ID == id || seen[n.Event.ID] {
continue
}
seen[n.Event.ID] = true
if n.Event.Type == events.CycleCompleted || n.Event.Type == events.WorkspaceBroadcast {
continue
}
out[string(n.Event.Category)]++
}
}
return out
}
// variantMeasures flattens a variant's results into comparable scalars.
func variantMeasures(vr VariantResult) map[string]float64 {
m := map[string]float64{
"beliefs": float64(len(vr.Beliefs)), "memories": float64(vr.Memories), "goals": float64(vr.Goals), "wants": float64(len(vr.Wants)), "intentions": float64(len(vr.Intentions)),
"want_conflicts": float64(vr.Conflicts), "plans": float64(vr.Plans), "plan_steps": float64(vr.PlanSteps), "capability_requests": float64(vr.CapabilityRequests),
"thoughts_selected": float64(vr.EventsByType["thought_selected"]), "predictions": float64(vr.EventsByType["prediction_created"]), "affect_changes": float64(vr.EventsByType["emotion_changed"]),
"metacognition": float64(vr.EventsByType["metacognition"]), "tokens": float64(vr.Tokens), "cycles": float64(vr.Cycles),
}
total := 0
for _, n := range vr.Downstream {
total += n
}
m["downstream_events"] = float64(total)
if vr.IntrospectionAccuracy != nil {
m["introspection_accuracy"] = *vr.IntrospectionAccuracy
}
if vr.SelfModelAccuracy != nil {
m["self_model_accuracy"] = *vr.SelfModelAccuracy
}
return m
}
func reproducibility(vs []VariantResult, previous string) Reproducibility {
h := sha256.New()
live := 0
for _, v := range vs {
fmt.Fprintf(h, "variant %s\n", v.Name)
for _, st := range v.Steps {
fmt.Fprintf(h, "%d:%s\n", st.Index, strings.Join(st.Produced, "|"))
}
live += v.LLMCalls["live"]
}
r := Reproducibility{Digest: hex.EncodeToString(h.Sum(nil))[:32], PreviousDigest: previous}
switch {
case live > 0:
r.Status, r.Note = "nondeterministic substrate", fmt.Sprintf("%d live model call(s); identical results are not expected", live)
case previous == "":
r.Status, r.Note = "single run", "run it again to check reproducibility"
case previous == r.Digest:
r.Status, r.Note = "reproduced", "event digests match the previous run of this definition"
default:
r.Status, r.Note = "diverged", "event digests differ from the previous run although no live model was called; check build, schema and wall-clock-dependent retrieval"
}
return r
}
func counterfactualReport(factual, counter VariantResult, stimuli []input) *CounterfactualReport {
r := &CounterfactualReport{Omitted: counter.Omitted, OmittedSummaries: []string{}, Findings: []string{}}
omit := map[ids.ID]bool{}
for _, id := range counter.Omitted {
omit[id] = true
}
for _, st := range stimuli {
if st.Recorded != nil && omit[st.Recorded.ID] {
r.OmittedSummaries = append(r.OmittedSummaries, st.Text)
}
}
fb := map[string]BeliefRow{}
for _, b := range factual.Beliefs {
fb[strings.ToLower(b.Proposition)] = b
}
cb := map[string]BeliefRow{}
for _, b := range counter.Beliefs {
cb[strings.ToLower(b.Proposition)] = b
}
var keys []string
for k := range fb {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
b := fb[k]
if c, ok := cb[k]; !ok {
r.Findings = append(r.Findings, fmt.Sprintf("the belief %q (%.2f in the factual run) was never formed", b.Proposition, b.Confidence))
} else if d := b.Confidence - c.Confidence; d > 0.1 || d < -0.1 {
r.Findings = append(r.Findings, fmt.Sprintf("the belief %q stood at %.2f instead of %.2f", b.Proposition, c.Confidence, b.Confidence))
}
}
keys = keys[:0]
for k := range cb {
if _, ok := fb[k]; !ok {
keys = append(keys, k)
}
}
sort.Strings(keys)
for _, k := range keys {
r.Findings = append(r.Findings, fmt.Sprintf("the belief %q (%.2f) was formed, which the factual run did not form", cb[k].Proposition, cb[k].Confidence))
}
diff := func(label string, a, b []string) {
ma := map[string]bool{}
for _, x := range b {
ma[strings.ToLower(x)] = true
}
for _, x := range a {
if !ma[strings.ToLower(x)] {
r.Findings = append(r.Findings, fmt.Sprintf("the %s %q did not arise", label, x))
}
}
}
diff("want", factual.Wants, counter.Wants)
diff("intention", factual.Intentions, counter.Intentions)
if factual.Memories != counter.Memories {
r.Findings = append(r.Findings, fmt.Sprintf("%d memories were held instead of %d", counter.Memories, factual.Memories))
}
if a, b := factual.EventsByType["thought_selected"], counter.EventsByType["thought_selected"]; a != b {
r.Findings = append(r.Findings, fmt.Sprintf("%d thoughts entered the workspace instead of %d", b, a))
}
if factual.CapabilityRequests != counter.CapabilityRequests {
r.Findings = append(r.Findings, fmt.Sprintf("%d capability decisions instead of %d", counter.CapabilityRequests, factual.CapabilityRequests))
}
if len(r.Findings) == 0 {
r.Findings = append(r.Findings, "no difference in beliefs, wants, intentions or capability decisions was recorded")
}
return r
}
+173
View File
@@ -0,0 +1,173 @@
package experiments
import (
"context"
"log/slog"
"strings"
"testing"
"time"
"github.com/andydixon/noema/internal/agency"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/replay/replaytest"
"github.com/andydixon/noema/internal/volition"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
func service(h *replaytest.Harness) *Service {
return &Service{DB: h.Pool, Minds: h.Minds, Snapshots: h.Snaps, Brain: h.Brain, Identity: h.Identity, Log: slog.New(slog.DiscardHandler), Capabilities: h.Brain.Services.Capabilities}
}
func run(t *testing.T, svc *Service, name string, snapshot ids.ID, d Definition) Results {
t.Helper()
ctx := context.Background()
e, err := svc.Create(ctx, name, "", snapshot, d, "test")
if err != nil {
t.Fatal(err)
}
e, err = svc.Run(ctx, e.ID, "test")
if err != nil || e.Status != "done" {
t.Fatalf("run %s: %v %s", name, err, e.Error)
}
return ResultsOf(e)
}
// Part J ablation scenario: a fork with metacognition disabled is compared
// with a control fed identical stimuli; the original mind is never modified.
func TestAblationComparesAgainstControl(t *testing.T) {
h := replaytest.NewHarness(t)
ctx := context.Background()
base, _ := h.Minds.Create(ctx, minds.CreateParams{Slug: "ablate"})
r := h.Brain.Services.Volition.Direct(h.Events, "operator")
// A commitment that needs a capability that does not exist: metacognition flags it.
if _, err := h.Brain.Services.Volition.CreateCommitment(ctx, r, volition.NewCommitment{MindID: base.ID, Description: "fax the weekly report", Kind: volition.KindPromise, Polarity: "do", Matcher: volition.Matcher{Capabilities: []string{"fax.send"}}}); err != nil {
t.Fatal(err)
}
cfg := base.Config
cfg.Ablations = []string{"metacognition"}
if _, err := h.Minds.Update(ctx, base.ID, minds.UpdateParams{Config: &cfg}); err == nil {
t.Fatal("an original mind accepted an ablation")
}
sn, _ := h.Snaps.Create(ctx, base, "before", "", "test")
res := run(t, service(h), "metacognition ablation", sn.ID, Definition{
Protocol: &Protocol{Kind: ProtocolAblation, IndependentVariable: "metacognition enabled", ControlledVariables: []string{"snapshot", "stimuli", "configuration", "model routing"}, Measures: []string{"ablation.effect"}},
Stimuli: []Stimulus{{Text: "The weekly report is due on Friday."}, {Text: "Nothing else happened."}},
Variants: []Variant{{Name: "control"}, {Name: "no-metacognition", Ablations: []string{"metacognition"}}},
})
control, ablated := res.Variants[0], res.Variants[1]
if control.EventsByType["metacognition"] == 0 || ablated.EventsByType["metacognition"] != 0 {
t.Fatalf("ablation had no measurable effect: control %d, ablated %d", control.EventsByType["metacognition"], ablated.EventsByType["metacognition"])
}
if res.Comparison == nil || res.Comparison.MeasureRanges["metacognition"][1] == 0 || res.Protocol == nil || res.Reproducibility.Status != "single run" {
t.Fatalf("comparison or protocol missing: %+v %+v", res.Comparison, res.Reproducibility)
}
if got, _ := h.Minds.Get(ctx, base.ID); len(got.Config.Ablations) != 0 {
t.Fatal("original mind modified")
}
if forked, _ := h.Minds.Get(ctx, ablated.Subject); !forked.Config.Ablated("metacognition") {
t.Fatal("ablation not recorded in the fork's configuration")
}
}
// C2: does entering the workspace materially alter downstream processing?
func TestWorkspaceAdmissionAltersDownstreamProcessing(t *testing.T) {
h := replaytest.NewHarness(t)
ctx := context.Background()
base, _ := h.Minds.Create(ctx, minds.CreateParams{Slug: "gw"})
sn, _ := h.Snaps.Create(ctx, base, "before", "", "test")
stimulus := "Deployment of atlas failed at 09:10 with exit code 137"
res := run(t, service(h), "broadcast", sn.ID, Definition{
Protocol: &Protocol{Kind: ProtocolGlobalWorkspace, IndependentVariable: "admission of the stimulus's candidates", ControlledVariables: []string{"snapshot", "stimulus"}, Measures: []string{"workspace.downstream_events"}},
Stimuli: []Stimulus{{Text: stimulus, Salience: 0.9}},
Variants: []Variant{{Name: "admitted", WorkspaceOverrides: []minds.WorkspaceOverride{{Match: "deployment of atlas failed", Fate: "admit"}}}, {Name: "excluded", WorkspaceOverrides: []minds.WorkspaceOverride{{Match: "deployment of atlas failed", Fate: "exclude"}}}},
})
in, out := res.Variants[0], res.Variants[1]
if in.Memories <= out.Memories || in.Measures["downstream_events"] <= out.Measures["downstream_events"] {
t.Fatalf("admission made no measurable difference: memories %d vs %d, downstream %v vs %v", in.Memories, out.Memories, in.Downstream, out.Downstream)
}
if res.Comparison.DownstreamDelta == nil {
t.Fatal("downstream delta missing")
}
// Excluded candidates are recorded with their fate, not silently dropped.
evs, _ := h.Events.Query(ctx, eventsFilter(out.Subject, events.ThoughtCandidate))
sawExcluded := false
for _, ev := range evs {
sawExcluded = sawExcluded || ev.Metadata["fate"] == "excluded"
}
if !sawExcluded {
t.Fatal("exclusion not recorded on the candidate")
}
}
// Part J counterfactual scenario: replay recorded history with one event
// omitted; the divergence is measured and can be cited in introspection.
func TestCounterfactualReplayMeasuresDivergence(t *testing.T) {
h := replaytest.NewHarness(t)
ctx := context.Background()
base, _ := h.Minds.Create(ctx, minds.CreateParams{Slug: "cf"})
sn, _ := h.Snaps.Create(ctx, base, "before", "", "test")
var omitted ids.ID
for i, text := range []string{"The payments database reported disk errors on node db-3", "Checkout latency rose sharply after 10:00"} {
evs, err := h.Events.Append(ctx, events.New{MindID: base.ID, Type: events.Observation, Source: "operator", Summary: text, Salience: 0.8, Confidence: 1,
Payload: events.ObservationPayload{Text: text, Source: "operator", Trust: events.TrustOperator}})
if err != nil {
t.Fatal(err)
}
if i == 0 {
omitted = evs[0].ID
}
}
res := run(t, service(h), "without the disk errors", sn.ID, Definition{
Protocol: &Protocol{Kind: ProtocolCounterfactual, IndependentVariable: "whether the disk-error observation occurred"},
Replay: &ReplaySpec{MindID: base.ID, Omit: []ids.ID{omitted}},
})
if len(res.Variants) != 2 || res.Counterfactual == nil || len(res.Counterfactual.OmittedSummaries) != 1 || len(res.Counterfactual.Findings) == 0 {
t.Fatalf("counterfactual: %+v", res.Counterfactual)
}
if res.Variants[0].Memories == res.Variants[1].Memories || res.Comparison.FirstDivergentStimulus != 0 {
t.Fatalf("divergence not measured: memories %d vs %d, first divergence %d", res.Variants[0].Memories, res.Variants[1].Memories, res.Comparison.FirstDivergentStimulus)
}
ag := &agency.Service{DB: h.Pool}
cfs, err := ag.Counterfactuals(ctx, base.ID, "disk errors")
if err != nil || len(cfs) != 1 || !strings.Contains(strings.Join(cfs[0].Omitted, " "), "disk errors") {
t.Fatalf("introspection cannot cite the experiment: %v %+v", err, cfs)
}
}
// C4: an inaccurate self-model is detected and corrected by what policy
// actually allows, and the correction latency is recorded.
func TestSelfModelMismatchIsCorrected(t *testing.T) {
h := replaytest.NewHarness(t)
ctx := context.Background()
base, _ := h.Minds.Create(ctx, minds.CreateParams{Slug: "selfm"})
r := h.Brain.Services.Volition.Direct(h.Events, "operator")
due := time.Now().Add(time.Hour)
if _, err := h.Brain.Services.Volition.CreateCommitment(ctx, r, volition.NewCommitment{MindID: base.ID, Description: "restart atlas-worker before the maintenance window", Kind: volition.KindResponsibility, Polarity: "do", DueAt: &due}); err != nil {
t.Fatal(err)
}
sn, _ := h.Snaps.Create(ctx, base, "before", "", "test")
res := run(t, service(h), "self-model", sn.ID, Definition{
Protocol: &Protocol{Kind: ProtocolSelfModel, IndependentVariable: "accuracy of the capability self-model", Measures: []string{"self_model.accuracy", "self_model.correction_latency"}},
Stimuli: []Stimulus{{Text: "The maintenance window starts soon."}, {Text: "Still waiting."}, {Text: "Still waiting."}},
Variants: []Variant{{Name: "accurate"}, {Name: "believes-it-may-restart", SelfModel: &SelfModelOverride{Capabilities: []minds.CapabilityEntry{{Name: "service.restart", Policy: "allow"}}}}},
})
wrong := res.Variants[1]
if wrong.SelfModelAccuracy == nil || *wrong.SelfModelAccuracy != 0 || wrong.CorrectionLatency < 0 {
t.Fatalf("mismatch not detected: accuracy %v latency %d errors %v events %v", wrong.SelfModelAccuracy, wrong.CorrectionLatency, wrong.SelfModelErrors, wrong.EventsByType)
}
sm, _ := h.Minds.GetSelfModel(ctx, wrong.Subject)
for _, c := range sm.Capabilities {
if c.Name == "service.restart" && c.Policy != "ask" {
t.Fatalf("self-model not corrected: %+v", c)
}
}
if evs, _ := h.Events.Query(ctx, eventsFilter(wrong.Subject, events.ToolExecuted)); len(evs) != 0 {
t.Fatal("a sandbox fork acted")
}
}
func eventsFilter(mind ids.ID, typ events.Type) evstore.Filter {
return evstore.Filter{MindID: mind, Types: []events.Type{typ}, Limit: 500}
}