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
+85 -8
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
@@ -451,9 +452,18 @@ func (r *Runtime) cycle(ctx context.Context, tx pgx.Tx, trigger string, start ti
if ms, err := r.services.Motivation.Load(ctx, tx, r.mind); err == nil {
motivation.Decay(&ms, motivation.Baselines(r.mind.Personality), start.Sub(ms.Affect.UpdatedAt))
st.Motiv = ms
st.Weights = motivation.Modulate(r.mind.Config.AttentionWeights, ms)
if !r.mind.Config.Ablated("affect") {
st.Weights = motivation.Modulate(r.mind.Config.AttentionWeights, ms)
}
}
}
if r.mind.Config.Ablated("attention") {
uniform := map[string]float64{}
for k := range r.mind.Config.AttentionWeights {
uniform[k] = 1
}
st.Weights = uniform
}
c := &Cycle{Runtime: r, State: st, Tx: tx}
// 1. Ask every process, concurrently and bounded.
@@ -481,8 +491,13 @@ func (r *Runtime) cycle(ctx context.Context, tx pgx.Tx, trigger string, start ti
byID[cand.ID] = cand
b := attention.Score(cand.Features, weights)
fp := fingerprint(*cand)
if r.loops.looping(fp) {
suppressed[cand.ID] = fp
if fate, match := r.override(ctx, *cand); fate == "exclude" {
suppressed[cand.ID] = "experimental workspace override (excluded, matched " + strconv.Quote(match) + ")"
b.Total = 0
} else if fate == "admit" {
b.Total = 1000 // forced to the top of admission; the breakdown terms stay visible
} else if r.loops.looping(fp) {
suppressed[cand.ID] = "loop detector: " + fp
b.Total = 0
if r.loops.shouldWarn(fp) {
c.Emit(events.New{Type: events.Metacognition, Source: "brain.loop-detector", Summary: "suppressed recurring thought without progress: " + cand.Summary,
@@ -526,8 +541,11 @@ func (r *Runtime) cycle(ctx context.Context, tx pgx.Tx, trigger string, start ti
o, has := outcomeByID[cand.ID]
fate := "rejected"
reason := o.Reason
if fp, s := suppressed[cand.ID]; s {
fate, reason = "suppressed", "loop detector: "+fp
if why, s := suppressed[cand.ID]; s {
fate, reason = "suppressed", why
if strings.HasPrefix(why, "experimental") {
fate = "excluded"
}
} else if has && o.Admitted {
fate = "admitted"
}
@@ -606,18 +624,26 @@ func (r *Runtime) cycle(ctx context.Context, tx pgx.Tx, trigger string, start ti
r.loops.record(fingerprint(Candidate{Process: t.Process, Kind: t.Kind, Subject: t.Subject}))
}
for _, h := range r.registry.hooks {
if r.ablatedHook(h.Name()) {
continue
}
if err := h.AfterCycle(ctx, c, c.emitted); err != nil {
r.log.Warn("hook failed", "hook", h.Name(), "err", err)
}
}
pruned := r.state.Prune(0.05)
isolated := r.mind.Config.Ablated("global_workspace")
if isolated {
// Ablation: nothing persists or is broadcast between cycles.
r.state.Workspace, r.state.WorkingMemory = nil, nil
}
// 7. Broadcast the resulting workspace and note attention shifts.
if newTop, ok := r.state.Top(); ok && (prevTop.ID == ids.Nil || prevTop.Subject != newTop.Subject || prevTop.Kind != newTop.Kind) {
if newTop, ok := r.state.Top(); ok && !isolated && (prevTop.ID == ids.Nil || prevTop.Subject != newTop.Subject || prevTop.Kind != newTop.Kind) {
c.Emit(events.New{Type: events.AttentionShift, Source: "brain.workspace", Summary: "attention now on: " + newTop.Summary, Salience: newTop.Score, Confidence: newTop.Confidence,
CausationID: newTop.EventID, Metadata: map[string]any{"from": prevTop.Subject, "to": newTop.Subject}, Payload: map[string]any{"from": prevTop.Summary, "to": newTop.Summary}})
}
if len(scored) > 0 || len(displaced) > 0 || pruned > 0 {
if (len(scored) > 0 || len(displaced) > 0 || pruned > 0) && !isolated {
c.Emit(events.New{Type: events.WorkspaceBroadcast, Source: "brain.workspace", Summary: fmt.Sprintf("workspace: %d thoughts, %d in working memory", len(r.state.Workspace), len(r.state.WorkingMemory)),
Salience: 0.2, Confidence: 1, Metadata: map[string]any{"thoughts": len(r.state.Workspace), "working_memory": len(r.state.WorkingMemory)},
Payload: map[string]any{"workspace": r.state.Workspace, "working_memory": r.state.WorkingMemory, "displaced": displaced, "pruned": pruned}})
@@ -661,7 +687,12 @@ func firstOr(xs []ids.ID, d ids.ID) ids.ID {
// observe runs all processes through the bounded pool with per-process
// timeouts and isolation. A failing process yields no candidates.
func (r *Runtime) observe(ctx context.Context, st *State) []Candidate {
procs := r.registry.Processes()
var procs []Process
for _, p := range r.registry.Processes() {
if !r.ablatedProcess(p.Name()) {
procs = append(procs, p)
}
}
results := make([][]Candidate, len(procs))
tasks := make([]func(context.Context) error, len(procs))
for i, p := range procs {
@@ -744,3 +775,49 @@ func (b *cycleBudget) allow(now time.Time) bool {
b.tokens--
return true
}
// ablationProcesses and ablationHooks map ablation names to the components
// they switch off (minds.Ablations documents each).
var (
ablationProcesses = map[string][]string{"memory_retrieval": {"retrieve"}, "metacognition": {"metacognition"}, "volition": {"intend", "act"}, "curiosity": {"curiosity"}, "association": {"associate"}, "goals": {"goal-monitor"}}
ablationHooks = map[string][]string{"affect": {"motivation"}, "volition": {"volition"}, "narrative": {"narrative"}, "notifications": {"notify"}}
)
func (r *Runtime) ablatedProcess(name string) bool { return r.ablated(ablationProcesses, name) }
func (r *Runtime) ablatedHook(name string) bool { return r.ablated(ablationHooks, name) }
func (r *Runtime) ablated(table map[string][]string, name string) bool {
for _, a := range r.mind.Config.Ablations {
for _, n := range table[a] {
if n == name {
return true
}
}
}
return false
}
// override applies an experimental workspace override to a candidate: it
// matches the candidate's summary or the summary of the event that
// triggered it. It returns the fate and the matching text.
func (r *Runtime) override(ctx context.Context, cand Candidate) (string, string) {
if len(r.mind.Config.WorkspaceOverrides) == 0 {
return "", ""
}
texts := []string{strings.ToLower(cand.Summary)}
for _, id := range cand.SourceEvents {
if ev, err := r.events.Get(ctx, id); err == nil {
texts = append(texts, strings.ToLower(ev.Summary))
}
}
for _, o := range r.mind.Config.WorkspaceOverrides {
m := strings.ToLower(o.Match)
for _, t := range texts {
if strings.Contains(t, m) {
return o.Fate, o.Match
}
}
}
return "", ""
}
+1
View File
@@ -107,6 +107,7 @@ type Services struct {
Predictions *predictions.Store
Capabilities *capabilities.Service
Volition *volition.Store
Minds *minds.Store // self-model corrections
Plans *planning.Store
// Executor plans formed intentions inside the cycle transaction. It never
// acts from inside a cycle: acting happens after commit, in its own loop.