Files
noema/internal/brain/cycle.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

824 lines
28 KiB
Go

package brain
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/attention"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/motivation"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/scheduler"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/internal/workspace"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// cursorConsumer names the brain's durable read position.
const cursorConsumer = "brain"
// settle is how old an event must be before the brain reads it, so that a
// concurrently committing transaction with a lower sequence number is not
// 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
// Cycle is the working context of one cognitive cycle, handed to handlers.
type Cycle struct {
Runtime *Runtime
State *State
Tx pgx.Tx
pending []events.New
emitted []events.Event
}
// Emit queues an event for the cycle's transaction. Correlation is the cycle
// id unless the caller set one.
func (c *Cycle) Emit(n events.New) {
n.MindID = c.State.Mind.ID
if n.CorrelationID.IsZero() {
n.CorrelationID = c.State.CycleID
}
if n.OccurredAt.IsZero() {
n.OccurredAt = c.State.Now
}
if n.Metadata == nil {
n.Metadata = map[string]any{}
}
n.Metadata["cycle"] = c.State.Cycle
c.pending = append(c.pending, n)
}
// EmitNow appends one event immediately inside the cycle transaction and
// returns it, for handlers that need the event id for provenance. It is still
// published only after commit.
func (c *Cycle) EmitNow(ctx context.Context, n events.New) (events.Event, error) {
if err := c.flush(ctx); err != nil {
return events.Event{}, err
}
n.MindID = c.State.Mind.ID
if n.CorrelationID.IsZero() {
n.CorrelationID = c.State.CycleID
}
if n.OccurredAt.IsZero() {
n.OccurredAt = c.State.Now
}
if n.Metadata == nil {
n.Metadata = map[string]any{}
}
n.Metadata["cycle"] = c.State.Cycle
evs, err := c.Runtime.events.AppendIn(ctx, c.Tx, n)
if err != nil {
return events.Event{}, err
}
c.emitted = append(c.emitted, evs...)
return evs[0], nil
}
// flush writes queued events inside the transaction and returns them.
func (c *Cycle) flush(ctx context.Context) error {
if len(c.pending) == 0 {
return nil
}
evs, err := c.Runtime.events.AppendIn(ctx, c.Tx, c.pending...)
if err != nil {
return err
}
c.emitted = append(c.emitted, evs...)
c.pending = nil
return nil
}
// Runtime is the cognitive loop of one mind.
type Runtime struct {
mind minds.Mind
db *pgxpool.Pool
events *evstore.Store
minds *minds.Store
registry *Registry
pool *scheduler.Pool
log *slog.Logger
metrics *observability.Metrics
services Services
wake chan struct{}
commands chan command
snaps chan chan workspace.State
state workspace.State
loops *loopDetector
budget *cycleBudget
now func() time.Time
afterCommit func(mind ids.ID, emitted []events.Event)
}
type command struct {
kind string // cycle | pause | resume | reset
args map[string]bool
reply chan error
}
// Config groups runtime dependencies.
type Config struct {
DB *pgxpool.Pool
Events *evstore.Store
Minds *minds.Store
Registry *Registry
Pool *scheduler.Pool
Log *slog.Logger
Metrics *observability.Metrics
Services Services
// AfterCommit, when set, is called with the events a cycle committed
// (the daemon wakes the plan executor with it). Sandbox runtimes leave it nil.
AfterCommit func(mind ids.ID, emitted []events.Event)
}
// NewRuntime creates a runtime for a mind and loads its persisted state.
func NewRuntime(ctx context.Context, cfg Config, m minds.Mind) (*Runtime, error) {
r := &Runtime{mind: m, db: cfg.DB, events: cfg.Events, minds: cfg.Minds, registry: cfg.Registry, pool: cfg.Pool,
log: cfg.Log.With("mind", m.Slug), metrics: cfg.Metrics, services: cfg.Services,
wake: make(chan struct{}, 1), commands: make(chan command), snaps: make(chan chan workspace.State), now: time.Now, afterCommit: cfg.AfterCommit}
r.loops = newLoopDetector(m.Config.LoopDetectWindow, m.Config.LoopDetectThreshold)
r.budget = newCycleBudget(m.Config.MaxCyclesPerMinute)
if err := r.loadState(ctx); err != nil {
return nil, err
}
return r, nil
}
// persisted wraps the workspace state with runtime bookkeeping.
type persisted struct {
workspace.State
LoopRing []string `json:"loop_ring,omitempty"`
}
func (r *Runtime) loadState(ctx context.Context) error {
var raw []byte
err := r.db.QueryRow(ctx, `SELECT state FROM workspace_states WHERE mind_id=$1`, r.mind.ID).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
r.state = workspace.State{Mode: string(ModeAwake)}
return nil
}
if err != nil {
return err
}
var p persisted
if err := json.Unmarshal(raw, &p); err != nil {
return fmt.Errorf("corrupt workspace state: %w", err)
}
r.state = p.State
r.loops.restore(p.LoopRing)
if r.state.Mode == "" {
r.state.Mode = string(ModeAwake)
}
return nil
}
func (r *Runtime) saveState(ctx context.Context, q storage.Querier) error {
raw, _ := json.Marshal(persisted{State: r.state, LoopRing: r.loops.snapshot()})
_, err := q.Exec(ctx, `INSERT INTO workspace_states (mind_id, state) VALUES ($1,$2) ON CONFLICT (mind_id) DO UPDATE SET state=excluded.state, updated_at=now()`, r.mind.ID, raw)
return err
}
// Wake asks the runtime to run a cycle soon (debounced by CycleMinInterval).
func (r *Runtime) Wake() {
select {
case r.wake <- struct{}{}:
default:
}
}
// Command sends an operator command and waits for the result.
func (r *Runtime) Command(ctx context.Context, kind string, args map[string]bool) error {
reply := make(chan error, 1)
select {
case r.commands <- command{kind: kind, args: args, reply: reply}:
case <-ctx.Done():
return ctx.Err()
}
select {
case err := <-reply:
return err
case <-ctx.Done():
return ctx.Err()
}
}
// Snapshot returns a copy of the current workspace state, taken on the loop
// goroutine so no lock is needed.
func (r *Runtime) Snapshot(ctx context.Context) (workspace.State, error) {
reply := make(chan workspace.State, 1)
select {
case r.snaps <- reply:
case <-ctx.Done():
return workspace.State{}, ctx.Err()
}
select {
case st := <-reply:
return st, nil
case <-ctx.Done():
return workspace.State{}, ctx.Err()
}
}
// Run is the loop. It returns when ctx ends.
func (r *Runtime) Run(ctx context.Context) error {
r.log.Info("mind runtime started", "mode", r.state.Mode, "cycle", r.state.Cycle)
idle := time.NewTimer(r.mind.Config.IdleCycleInterval.D())
defer idle.Stop()
var debounce *time.Timer
var debounceC <-chan time.Time
for {
select {
case <-ctx.Done():
return nil
case <-r.wake:
if debounce == nil {
debounce = time.NewTimer(r.mind.Config.CycleMinInterval.D() + settle)
debounceC = debounce.C
}
case <-debounceC:
debounce, debounceC = nil, nil
r.runCycle(ctx, "stimulus")
idle.Reset(r.mind.Config.IdleCycleInterval.D())
case <-idle.C:
r.runCycle(ctx, "idle")
idle.Reset(r.mind.Config.IdleCycleInterval.D())
case cmd := <-r.commands:
cmd.reply <- r.handleCommand(ctx, cmd)
case reply := <-r.snaps:
reply <- r.copyState()
}
}
}
func (r *Runtime) handleCommand(ctx context.Context, cmd command) error {
switch cmd.kind {
case "cycle":
if Mode(r.state.Mode) == ModePaused {
return errors.New("mind is paused")
}
r.runCycle(ctx, "operator")
return nil
case "pause":
return r.setMode(ctx, ModePaused, "operator paused cognition")
case "resume":
if Mode(r.state.Mode) != ModePaused {
return nil
}
return r.setMode(ctx, ModeAwake, "operator resumed cognition")
case "reset":
return r.reset(ctx, cmd.args)
case "setmode":
for name, on := range cmd.args {
if on {
return r.setMode(ctx, Mode(name), "background job")
}
}
return nil
case "reload":
m, err := r.minds.Get(ctx, r.mind.ID)
if err != nil {
return err
}
r.mind = m
r.loops = newLoopDetector(m.Config.LoopDetectWindow, m.Config.LoopDetectThreshold)
r.budget = newCycleBudget(m.Config.MaxCyclesPerMinute)
return nil
}
return fmt.Errorf("unknown command %q", cmd.kind)
}
func (r *Runtime) setMode(ctx context.Context, to Mode, why string) error {
from := r.state.Mode
if from == string(to) {
return nil
}
r.state.Mode = string(to)
return storage.Tx(ctx, r.db, func(tx pgx.Tx) error {
if _, err := r.events.AppendIn(ctx, tx, events.New{MindID: r.mind.ID, Type: events.ModeChanged, Source: "brain", Summary: fmt.Sprintf("mode %s → %s: %s", from, to, why),
Salience: 0.3, Confidence: 1, Metadata: map[string]any{"from": from, "to": to}, Payload: map[string]any{"from": from, "to": to, "reason": why}}); err != nil {
return err
}
return r.saveState(ctx, tx)
})
}
// reset clears transient state on operator request (spec §3). It never touches
// events, memories or beliefs.
func (r *Runtime) reset(ctx context.Context, what map[string]bool) error {
var parts []string
if what["workspace"] {
r.state.Workspace = nil
parts = append(parts, "workspace")
}
if what["working_memory"] {
r.state.WorkingMemory = nil
parts = append(parts, "working memory")
}
if what["loops"] {
r.loops = newLoopDetector(r.mind.Config.LoopDetectWindow, r.mind.Config.LoopDetectThreshold)
parts = append(parts, "loop detector")
}
if len(parts) == 0 {
return nil
}
return storage.Tx(ctx, r.db, func(tx pgx.Tx) error {
if _, err := r.events.AppendIn(ctx, tx, events.New{MindID: r.mind.ID, Type: events.OperatorAction, Source: "operator", Summary: "Operator reset transient state: " + strings.Join(parts, ", "),
Salience: 0.5, Confidence: 1, Metadata: map[string]any{"action": "reset"}, Payload: map[string]any{"reset": parts}}); err != nil {
return err
}
return r.saveState(ctx, tx)
})
}
// runCycle executes one cycle. Errors are logged, never fatal: a broken
// process or malformed event must not stop the mind (spec §46).
func (r *Runtime) runCycle(ctx context.Context, trigger string) {
if Mode(r.state.Mode) == ModePaused {
return
}
if !r.budget.allow(r.now()) {
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()
var emitted []events.Event
before := r.copyState()
err := storage.Tx(cctx, r.db, func(tx pgx.Tx) error {
var err error
emitted, err = r.cycle(cctx, tx, trigger, start)
return err
})
if err != nil {
r.state = before // the transaction rolled back; forget in-memory changes too
return nil, err
}
r.events.Publish(emitted...) // only after commit, so subscribers never see phantom events
if r.afterCommit != nil && len(emitted) > 0 {
r.afterCommit(r.mind.ID, emitted)
}
return emitted, nil
}
func (r *Runtime) cycle(ctx context.Context, tx pgx.Tx, trigger string, start time.Time) ([]events.Event, error) {
cursor, err := r.events.Cursor(ctx, tx, r.mind.ID, cursorConsumer)
if err != nil {
return nil, err
}
// Read settled events after the cursor. The query is done on the pool (not
// the tx) so a long transaction does not hold a snapshot; ordering by seq.
newEvents, err := r.events.Query(ctx, evstore.Filter{MindID: r.mind.ID, AfterSeq: cursor, Ascending: true, Limit: 200})
if err != nil {
return nil, err
}
// Recorded-at settle: Query filters by occurred_at, so also drop events
// recorded too recently (occurred_at may be backdated by integrations).
filtered := newEvents[:0]
for _, ev := range newEvents {
if start.Sub(ev.RecordedAt) >= settle {
filtered = append(filtered, ev)
}
}
newEvents = filtered
stimulus := false
for _, ev := range newEvents {
if !strings.HasPrefix(ev.Source, "brain") {
stimulus = true
break
}
}
if trigger == "stimulus" && !stimulus {
// Woken by our own output or nothing has settled yet: consume and wait.
if len(newEvents) > 0 {
if err := r.events.SetCursor(ctx, tx, r.mind.ID, cursorConsumer, newEvents[len(newEvents)-1].Seq); err != nil {
return nil, err
}
}
return nil, nil
}
if trigger == "idle" && !stimulus && len(r.state.Workspace) == 0 {
// Nothing to think about: just move to idle mode.
if Mode(r.state.Mode) != ModeIdle && r.state.LastStimulus.Add(5*time.Minute).Before(start) {
r.state.Mode = string(ModeIdle)
}
return nil, r.saveState(ctx, tx)
}
cycleID := ids.New()
if stimulus {
r.state.LastStimulus = start
if Mode(r.state.Mode) == ModeIdle {
r.state.Mode = string(ModeAwake)
}
}
sm, _ := r.minds.GetSelfModel(ctx, r.mind.ID)
st := &State{Mind: r.mind, SelfModel: sm, Mode: Mode(r.state.Mode), Cycle: r.state.Cycle + 1, CycleID: cycleID, Now: start, NewEvents: newEvents, Workspace: r.state, Services: r.services}
if r.services.Goals != nil {
st.Goals, _ = r.services.Goals.Active(ctx, r.mind.ID)
}
st.Weights = r.mind.Config.AttentionWeights
if r.services.Motivation != nil {
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
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.
cands := r.observe(ctx, st)
if len(cands) == 0 && len(r.state.Workspace) == 0 {
// Nothing proposed and nothing held: consume the events without
// counting a cycle, so self-generated events cannot keep the loop busy.
if len(newEvents) > 0 {
if err := r.events.SetCursor(ctx, tx, r.mind.ID, cursorConsumer, newEvents[len(newEvents)-1].Seq); err != nil {
return nil, err
}
}
return nil, r.saveState(ctx, tx)
}
r.state.Cycle++
// 2. Score deterministically with the cycle's effective weights; suppress loops.
weights := st.Weights
var scored []attention.Scored
byID := map[ids.ID]*Candidate{}
suppressed := map[ids.ID]string{}
for i := range cands {
cand := &cands[i]
cand.ID = ids.New()
byID[cand.ID] = cand
b := attention.Score(cand.Features, weights)
fp := fingerprint(*cand)
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,
Salience: 0.6, Confidence: 0.9, Metadata: map[string]any{"fingerprint": fp, "check": "loop"}, Payload: map[string]any{"fingerprint": fp, "kind": cand.Kind, "subject": cand.Subject}})
}
}
scored = append(scored, attention.Scored{ID: string(cand.ID), Breakdown: b})
}
attention.Rank(scored)
if r.metrics != nil {
r.metrics.Candidates.Add(float64(len(scored)))
}
// 3. Decay, then admit.
prevTop, _ := r.state.Top()
r.state.Decay(r.mind.Config.WorkingMemoryDecay)
var thoughts []workspace.Thought
for _, sc := range scored {
cand := byID[ids.ID(sc.ID)]
if _, isSuppressed := suppressed[cand.ID]; isSuppressed {
continue
}
thoughts = append(thoughts, workspace.Thought{ID: cand.ID, Process: cand.Process, Kind: cand.Kind, Subject: cand.Subject, Summary: cand.Summary, Content: cand.Content,
Score: sc.Breakdown.Total, Breakdown: sc.Breakdown, Evidence: cand.Evidence, Confidence: cand.Confidence})
}
outcomes, displaced := r.state.Admit(thoughts, r.mind.Config.WorkspaceCapacity, r.state.Cycle, start)
outcomeByID := map[ids.ID]workspace.Outcome{}
for _, o := range outcomes {
outcomeByID[o.ID] = o
}
// 4. Record every candidate with its arithmetic and fate.
admittedThoughts := map[ids.ID]workspace.Thought{}
for _, t := range r.state.Workspace {
if t.AdmittedCycle == r.state.Cycle {
admittedThoughts[t.ID] = t
}
}
for _, sc := range scored {
cand := byID[ids.ID(sc.ID)]
o, has := outcomeByID[cand.ID]
fate := "rejected"
reason := o.Reason
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"
}
var causation ids.ID
if len(cand.SourceEvents) > 0 {
causation = cand.SourceEvents[0]
}
c.Emit(events.New{Type: events.ThoughtCandidate, Source: "brain." + cand.Process, Summary: fmt.Sprintf("[%s %.2f] %s", fate, sc.Breakdown.Total, cand.Summary),
Salience: sc.Breakdown.Total, Confidence: cand.Confidence, CausationID: causation, ParentIDs: cand.SourceEvents, Evidence: cand.Evidence,
Metadata: map[string]any{"candidate_id": string(cand.ID), "process": cand.Process, "kind": cand.Kind, "subject": cand.Subject, "fate": fate, "rank": o.Rank},
Payload: map[string]any{"candidate_id": cand.ID, "kind": cand.Kind, "subject": cand.Subject, "features": cand.Features, "breakdown": sc.Breakdown, "fate": fate, "reason": reason, "rank": o.Rank, "content": cand.Content}})
}
if err := c.flush(ctx); err != nil {
return nil, err
}
candidateEvent := map[ids.ID]ids.ID{}
for _, ev := range c.emitted {
if ev.Type == events.ThoughtCandidate {
if s, ok := ev.Metadata["candidate_id"].(string); ok {
candidateEvent[ids.ID(s)] = ev.ID
}
}
}
// 5. thought_selected for admissions, then broadcast.
for i := range r.state.Workspace {
t := &r.state.Workspace[i]
if t.AdmittedCycle != r.state.Cycle {
continue
}
cand := byID[t.ID]
var parents []ids.ID
if pe, ok := candidateEvent[t.ID]; ok {
parents = append(parents, pe)
}
if cand != nil {
parents = append(parents, cand.SourceEvents...)
}
c.Emit(events.New{Type: events.ThoughtSelected, Source: "brain.workspace", Summary: t.Summary, Salience: t.Score, Confidence: t.Confidence,
CausationID: firstOr(parents, ids.Nil), ParentIDs: parents, Evidence: t.Evidence,
Metadata: map[string]any{"candidate_id": string(t.ID), "process": t.Process, "kind": t.Kind, "subject": t.Subject, "rank": outcomeByID[t.ID].Rank},
Payload: map[string]any{"thought": t, "why": fmt.Sprintf("rank %d of %d with score %.2f; %s", outcomeByID[t.ID].Rank, len(scored), t.Score, outcomeByID[t.ID].Reason)}})
if r.metrics != nil {
r.metrics.Admissions.Inc()
}
}
if err := c.flush(ctx); err != nil {
return nil, err
}
for _, ev := range c.emitted {
if ev.Type == events.ThoughtSelected {
if s, ok := ev.Metadata["candidate_id"].(string); ok {
for i := range r.state.Workspace {
if string(r.state.Workspace[i].ID) == s {
r.state.Workspace[i].EventID = ev.ID
}
}
}
}
}
// 6. Executive: dispatch admitted thoughts to handlers (sequential, ordered by rank).
for _, t := range r.state.Workspace {
if t.AdmittedCycle != r.state.Cycle {
continue
}
h, ok := r.registry.handlers[t.Kind]
if !ok {
h = defaultHandler
}
if err := h(ctx, c, t); err != nil {
r.log.Warn("handler failed", "kind", t.Kind, "err", err)
c.Emit(events.New{Type: events.Metacognition, Source: "brain.executive", Summary: "handler for " + t.Kind + " failed: " + err.Error(), Salience: 0.4, Confidence: 1,
Metadata: map[string]any{"check": "handler_error", "kind": t.Kind}})
}
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 && !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) && !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}})
c.Emit(events.New{Type: events.CycleCompleted, Source: "brain", Summary: fmt.Sprintf("cycle %d (%s): %d events, %d candidates, %d admitted, %d suppressed in %dms", r.state.Cycle, trigger, len(newEvents), len(scored), len(admittedThoughts), len(suppressed), time.Since(start).Milliseconds()),
Salience: 0.1, Confidence: 1, Metadata: map[string]any{"trigger": trigger, "candidates": len(scored), "admitted": len(admittedThoughts), "suppressed": len(suppressed), "duration_ms": time.Since(start).Milliseconds()},
Payload: map[string]any{"effective_weights": st.Weights}})
}
if err := c.flush(ctx); err != nil {
return nil, err
}
// 8. Persist and advance the cursor to the last *read* event.
r.state.LastCycleID, r.state.LastCycleAt = cycleID, start
r.state.EffectiveWeights = st.Weights
if len(newEvents) > 0 {
if err := r.events.SetCursor(ctx, tx, r.mind.ID, cursorConsumer, newEvents[len(newEvents)-1].Seq); err != nil {
return nil, err
}
}
if err := r.saveState(ctx, tx); err != nil {
return nil, err
}
return c.emitted, nil
}
// copyState deep-copies slices so callers cannot alias loop-owned state.
func (r *Runtime) copyState() workspace.State {
st := r.state
st.Workspace = append([]workspace.Thought{}, r.state.Workspace...)
st.WorkingMemory = append([]workspace.Item{}, r.state.WorkingMemory...)
return st
}
func firstOr(xs []ids.ID, d ids.ID) ids.ID {
if len(xs) > 0 {
return xs[0]
}
return d
}
// 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 {
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 {
tasks[i] = func(ctx context.Context) error {
pctx, cancel := context.WithTimeout(ctx, processTimeout)
defer cancel()
out, err := p.Observe(pctx, st)
if err != nil {
r.log.Warn("process failed", "process", p.Name(), "err", err)
return nil
}
for j := range out {
out[j].Process = p.Name()
out[j].Features = out[j].Features.Clamp()
if out[j].Kind == "" {
out[j].Kind = "note"
}
}
results[i] = out
return nil
}
}
scheduler.Parallel(ctx, r.pool, tasks) //nolint:errcheck // per-task errors already logged
var all []Candidate
for _, rs := range results {
all = append(all, rs...)
}
return all
}
// Workspace returns a copy of the live workspace state for handlers.
func (r *Runtime) Workspace() workspace.State { return r.copyState() }
// Events exposes the event store to handlers.
func (r *Runtime) Events() *evstore.Store { return r.events }
// Hold places an item in working memory using the mind's configured capacity.
// It must only be called from handlers (on the loop goroutine).
func (r *Runtime) Hold(it workspace.Item) {
r.state.Hold(it, r.mind.Config.WorkingMemoryCapacity, r.state.Cycle, r.now())
}
// defaultHandler places an admitted thought into working memory.
func defaultHandler(_ context.Context, c *Cycle, t workspace.Thought) error {
key := t.Kind + ":" + t.Subject
if t.Subject == "" {
key = t.Kind + ":" + string(t.ID)
}
ref, _ := t.Content["ref"].(string)
var src ids.ID
if s, ok := t.Content["source_event"].(string); ok {
src = ids.ID(s)
}
c.Runtime.state.Hold(workspace.Item{Key: key, Content: t.Summary, Kind: t.Kind, SourceEvent: src, Ref: ref}, c.Runtime.mind.Config.WorkingMemoryCapacity, c.State.Cycle, c.State.Now)
return nil
}
// cycleBudget is a per-minute token bucket for cycles.
type cycleBudget struct {
perMinute int
tokens float64
last time.Time
}
func newCycleBudget(perMinute int) *cycleBudget {
return &cycleBudget{perMinute: perMinute, tokens: float64(perMinute)}
}
func (b *cycleBudget) allow(now time.Time) bool {
if !b.last.IsZero() {
b.tokens += now.Sub(b.last).Minutes() * float64(b.perMinute)
}
b.last = now
if b.tokens > float64(b.perMinute) {
b.tokens = float64(b.perMinute)
}
if b.tokens < 1 {
return false
}
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 "", ""
}