Files
noema/internal/cognition/volition.go
T
Andy Dixon c3308106bb fix(cognition): decisions and cycle output no longer depend on map order
Formation summed its terms by ranging over a map, so the score could
differ in the last bit between runs of the same state; the volition hook
assessed commitments, metacognition raised repeated-failure candidates and
the forensic explanation listed commitments in map iteration order, so the
events a cycle emits and an explanation's order varied between runs. Each
now iterates in sorted order, as replay determinism (PLAN §1 R3) requires.
2026-10-06 20:24:54 +01:00

520 lines
21 KiB
Go

package cognition
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/andydixon/noema/internal/attention"
"github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/capabilities"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/planning"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/internal/volition"
"github.com/andydixon/noema/internal/workspace"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// volitionSource is the event source for volitional changes made inside a
// cycle. It deliberately does not start with "brain": a new want or
// intention is a change the next cycle should consider.
const volitionSource = "volition"
func recorder(c *brain.Cycle) volition.Recorder {
return volition.Recorder{Q: c.Tx, Source: volitionSource, Now: c.State.Now, Correlation: c.State.CycleID,
Emit: func(ctx context.Context, _ storage.Querier, n events.New) (events.Event, error) {
return c.EmitNow(ctx, n)
}}
}
func deriveContext(ctx context.Context, s *brain.State) volition.DeriveContext {
d := volition.DeriveContext{Now: s.Now, Drives: map[string]float64{}, Values: s.Mind.Personality.Values, Goals: map[ids.ID]volition.GoalInfo{}, Commitments: map[ids.ID]volition.Commitment{}}
for _, dr := range s.Motiv.Drives {
d.Drives[dr.Name] = dr.Value
}
if s.Services.Goals != nil {
if gs, err := s.Services.Goals.List(ctx, s.Mind.ID, nil, 300); err == nil {
for _, g := range gs {
d.Goals[g.ID] = volition.GoalInfo{ID: g.ID, Title: g.Title, Subject: g.Subject, Kind: string(g.Kind), Owner: g.Owner, Priority: g.Priority, DueAt: g.DueAt}
}
}
}
if s.Services.Volition != nil {
if cs, err := s.Services.Volition.Commitments(ctx, nil, s.Mind.ID, []string{"active"}, 500); err == nil {
for _, c := range cs {
d.Commitments[c.ID] = c
}
}
}
return d
}
// ActionInfo describes a proposed capability action for volition: its
// metadata from the registry and the effect policy would give it now.
func ActionInfo(ctx context.Context, caps *capabilities.Service, mind ids.ID, p *volition.Proposal) *volition.ActionInfo {
if p == nil || p.Kind != "capability" || caps == nil {
return nil
}
a := &volition.ActionInfo{Capability: p.Capability, Target: p.Target, Effect: "unknown"}
c, ok := caps.Registry.Get(p.Capability)
if !ok {
return a
}
a.Access, a.Risk, a.Idempotent, a.SideEffects, a.WallSeconds = string(c.Access), string(c.Risk), c.Idempotent, c.SideEffects, c.Estimate.WallSeconds
if c.Locked {
a.Effect = "locked"
return a
}
target := p.Target
if c.TargetFrom != nil {
target = ""
}
d, err := caps.Preview(ctx, capabilities.Invocation{MindID: mind, Capability: p.Capability, Target: target, Params: p.Params})
if err != nil {
a.Effect = "deny"
return a
}
if t, err := c.ResolveTarget("", p.Params); err == nil && t != "" {
a.Target = t
}
a.Effect = string(d.Effect)
return a
}
// ---- after-cycle hook: wants and commitments ----
// VolitionHook derives wants from what the cycle perceived and produced,
// satisfies answer wants when a reply was sent, raises due, overdue,
// expired and conflicting commitments, and lets faded wants expire.
type VolitionHook struct{}
// Name implements brain.Hook.
func (VolitionHook) Name() string { return "volition" }
// AfterCycle implements brain.Hook.
func (VolitionHook) AfterCycle(ctx context.Context, c *brain.Cycle, emitted []events.Event) error {
vs := c.State.Services.Volition
if vs == nil {
return nil
}
r := recorder(c)
mind := c.State.Mind.ID
dctx := deriveContext(ctx, c.State)
all := append(append([]events.Event{}, c.State.NewEvents...), emitted...)
for i, ev := range all {
if ev.Source == volitionSource && ev.Category == events.CatVolition {
continue // volition's own records never derive further wants
}
if i < len(c.State.NewEvents) && strings.HasPrefix(ev.Source, "brain") {
continue // produced by an earlier cycle, whose hook already saw it
}
for _, cand := range volition.Derive(ev, dctx) {
if _, _, err := vs.UpsertWant(ctx, r, mind, cand); err != nil {
return err
}
}
}
for _, ev := range emitted {
if ev.Type != events.ResponseGenerated {
continue
}
msg, _ := ev.Metadata["message_id"].(string)
if msg == "" {
continue
}
if ws, err := vs.Wants(ctx, c.Tx, mind, []volition.WantStatus{volition.WantActive}, 500); err == nil {
for _, w := range ws {
if w.Subject == "answer:message:"+msg {
if err := vs.ResolveWant(ctx, r, mind, w.ID, volition.WantSatisfied, "replied (event "+string(ev.ID)+")"); err != nil {
return err
}
}
}
}
}
// Commitments in id order, so the events a cycle emits do not depend on
// map iteration order.
live := make([]volition.Commitment, 0, len(dctx.Commitments))
for _, cm := range dctx.Commitments {
live = append(live, cm)
}
sort.Slice(live, func(i, j int) bool { return live[i].ID < live[j].ID })
for _, cm := range live {
switch volition.Assess(cm, c.State.Now) {
case volition.StateDue:
if err := vs.MarkDue(ctx, r, cm); err != nil {
return err
}
due := events.Event{Type: events.CommitmentDue, Metadata: map[string]any{"commitment_id": string(cm.ID)}}
for _, cand := range volition.Derive(due, dctx) {
if _, _, err := vs.UpsertWant(ctx, r, mind, cand); err != nil {
return err
}
}
case volition.StateOverdue:
if _, err := vs.CloseCommitment(ctx, r, mind, cm.ID, "broken", "the deadline "+cm.DueAt.Format(time.RFC3339)+" passed without fulfilment", false); err != nil && !errors.Is(err, volition.ErrInvalid) {
return err
}
case volition.StateExpired:
if _, err := vs.CloseCommitment(ctx, r, mind, cm.ID, "expired", "its expiry passed", false); err != nil && !errors.Is(err, volition.ErrInvalid) {
return err
}
}
}
for _, cf := range volition.CommitmentConflicts(live, c.State.Now) {
key := pairKey(cf.A.ID, cf.B.ID)
if recentlyRaised(ctx, c, events.CommitmentConflict, "pair", key) {
continue
}
if _, err := c.EmitNow(ctx, events.New{Type: events.CommitmentConflict, Source: volitionSource, Summary: fmt.Sprintf("commitments conflict: %q and %q — %s", cf.A.Description, cf.B.Description, cf.Reason), Salience: 0.7, Confidence: 1,
Evidence: []events.EvidenceRef{{Kind: "commitment", ID: string(cf.A.ID)}, {Kind: "commitment", ID: string(cf.B.ID)}}, Metadata: map[string]any{"pair": key, "a": string(cf.A.ID), "b": string(cf.B.ID)},
Payload: map[string]any{"reason": cf.Reason, "a_weight": cf.A.Weight(), "b_weight": cf.B.Weight()}}); err != nil {
return err
}
}
if _, err := vs.ExpireWants(ctx, r, mind, 40); err != nil {
return err
}
return vs.SettleConflicts(ctx, c.Tx, mind, c.State.Now)
}
func pairKey(a, b ids.ID) string {
if a > b {
a, b = b, a
}
return string(a) + "|" + string(b)
}
func recentlyRaised(ctx context.Context, c *brain.Cycle, typ events.Type, key, value string) bool {
es, ok := c.State.Services.Events.(*evstore.Store)
if !ok {
return false
}
evs, err := es.Query(ctx, evstore.Filter{MindID: c.State.Mind.ID, Types: []events.Type{typ}, Since: c.State.Now.Add(-24 * time.Hour), Limit: 200})
if err != nil {
return false
}
for _, ev := range evs {
if ev.Metadata[key] == value {
return true
}
}
return false
}
// ---- intention formation ----
// Intend proposes deciding about the strongest active wants that have not
// been decided since they were last reinforced. Whether the decision is
// made at all depends on attention: the candidate competes for the
// workspace like any other thought.
type Intend struct{}
// Name implements brain.Process.
func (Intend) Name() string { return "intend" }
// Observe implements brain.Process.
func (Intend) Observe(ctx context.Context, s *brain.State) ([]brain.Candidate, error) {
vs := s.Services.Volition
if vs == nil {
return nil, nil
}
ws, err := vs.Wants(ctx, nil, s.Mind.ID, []volition.WantStatus{volition.WantActive}, 200)
if err != nil {
return nil, err
}
decided := map[ids.ID]time.Time{}
if ds, err := vs.Decisions(ctx, s.Mind.ID, volition.DecisionFilter{Limit: 500}); err == nil {
for _, d := range ds {
if (d.Kind == "formation" || d.Kind == "non_formation") && !d.WantID.IsZero() && d.CreatedAt.After(decided[d.WantID]) {
decided[d.WantID] = d.CreatedAt
}
}
}
volition.SortWants(ws, s.Now)
var out []brain.Candidate
for _, w := range ws {
if len(out) >= 3 {
break
}
if w.Valence == "avoid" || (w.Origin == volition.OriginRequest && w.Proposal == nil) {
continue // values constrain other wants; replies are handled by perception
}
if at, ok := decided[w.ID]; ok && !w.LastReinforcedAt.After(at) {
continue // already decided; only new reinforcement reopens the question
}
eff := w.Effective(s.Now)
prio := 0.4
if w.Origin == volition.OriginGoal || w.Origin == volition.OriginOperator || w.Origin == volition.OriginCommitment {
prio = 0.9
} else if w.Origin == volition.OriginRequest {
prio = 0.7
}
var goalRel float64
for _, g := range s.Goals {
if g.ID == w.GoalID {
goalRel = g.Priority
}
}
out = append(out, brain.Candidate{Kind: "intend", Subject: "intend:" + string(w.ID), Summary: fmt.Sprintf("decide whether to pursue: %s (%.2f)", w.Description, eff),
Content: map[string]any{"want_id": string(w.ID)},
Features: attention.Features{Salience: eff, Urgency: w.Urgency, GoalRelevance: goalRel, Consequence: 0.5, Confidence: 0.8, OperatorPriority: prio, Novelty: 0.3},
Confidence: 0.8, Evidence: []events.EvidenceRef{{Kind: "want", ID: string(w.ID)}}, SourceEvents: nonZero(w.OriginEvent)})
}
return out, nil
}
func nonZero(id ids.ID) []ids.ID {
if id.IsZero() {
return nil
}
return []ids.ID{id}
}
// intend runs deterministic formation for an admitted want and records the
// decision, whatever it is. A formed intention is planned in the same
// transaction; nothing acts until the executor picks the plan up after commit.
func (h Handlers) intend(ctx context.Context, c *brain.Cycle, t workspace.Thought) error {
s := c.State
vs := s.Services.Volition
if vs == nil {
return nil
}
wid, _ := t.Content["want_id"].(string)
w, err := vs.GetWant(ctx, c.Tx, s.Mind.ID, ids.ID(wid))
if err != nil || w.Status != volition.WantActive {
return nil
}
r := recorder(c)
r.Cause = t.EventID
in := volition.FormationInput{Want: w, Now: s.Now, Values: s.Mind.Personality.Values, Confidence: 0.6, IgnoreCommitments: s.Mind.Config.Ablated("commitments"),
Operator: w.Origin == volition.OriginOperator}
if in.Commitments, err = vs.Commitments(ctx, c.Tx, s.Mind.ID, []string{"active"}, 500); err != nil {
return err
}
if in.Active, err = vs.Open(ctx, c.Tx, s.Mind.ID); err != nil {
return err
}
if !w.GoalID.IsZero() && s.Services.Goals != nil {
if g, err := s.Services.Goals.Get(ctx, s.Mind.ID, w.GoalID); err == nil {
in.Goal = &volition.GoalInfo{ID: g.ID, Title: g.Title, Priority: g.Priority, Owner: g.Owner}
in.Operator = in.Operator || g.Owner == "operator"
}
}
if !w.CommitmentID.IsZero() {
if cm, err := vs.GetCommitment(ctx, c.Tx, s.Mind.ID, w.CommitmentID); err == nil {
in.CommitmentWeight = cm.Weight()
}
}
in.Action = ActionInfo(ctx, s.Services.Capabilities, s.Mind.ID, w.Proposal)
if in.Action != nil {
if err := correctSelfModel(ctx, c, *in.Action, t.EventID); err != nil {
return err
}
}
if s.Services.Capabilities != nil {
if inv, err := s.Services.Capabilities.Inventory(ctx, s.Mind.ID); err == nil {
for _, i := range inv {
if i.Capability.Access == capabilities.AccessRead && !i.Capability.Locked && (i.Effect == capabilities.Allow || i.Effect == capabilities.Ask) && i.Capability.Name != "noema.note" {
in.ReadCapabilities++
}
}
}
}
// Value-derived tensions are made explicit before deciding.
if in.Action != nil {
for _, cand := range volition.Companions(w, *in.Action, in.Values) {
avoid, _, err := vs.UpsertWant(ctx, r, s.Mind.ID, cand)
if err != nil {
return err
}
res := volition.ResolveConflict(w, avoid, s.Now, nil, map[ids.ID]float64{w.ID: in.CommitmentWeight})
if _, _, err := vs.RecordConflict(ctx, r, w, avoid, "value", res); err != nil {
return err
}
in.Constrained = in.Constrained || (res.Preferred == w.ID && res.Constrain)
in.Deferred = in.Deferred || res.Preferred != w.ID
}
}
f := volition.Form(in)
inputs := map[string]any{"want": w.Description, "effective_strength": w.Effective(s.Now), "action": in.Action, "commitments_considered": len(in.Commitments), "open_intentions": len(in.Active), "read_capabilities": in.ReadCapabilities, "applied": f.Applied}
if f.Outcome != volition.Formed {
if f.Reason == volition.ReasonDuplicate {
return nil
}
evID, err := c.EmitNow(ctx, events.New{Type: events.Metacognition, Source: volitionSource, Summary: "did not form an intention for \"" + w.Description + "\": " + f.Rationale, Salience: 0.3 + 0.3*w.Effective(s.Now), Confidence: 1,
CausationID: t.EventID, Evidence: []events.EvidenceRef{{Kind: "want", ID: string(w.ID)}}, Metadata: map[string]any{"check": "intention_not_formed", "ref": string(w.ID), "reason": f.Reason, "want_id": string(w.ID)}, Payload: f})
if err != nil {
return err
}
if _, err := vs.RecordDecision(ctx, c.Tx, volition.Decision{MindID: s.Mind.ID, Kind: "non_formation", Outcome: f.Outcome, Reason: f.Reason, Rationale: f.Rationale, WantID: w.ID, Terms: f.Terms, Inputs: inputs, EventID: evID.ID, CreatedAt: s.Now}); err != nil {
return err
}
if f.Suppress {
if err := vs.SuppressWant(ctx, r, w, f.Rationale, f.Applied); err != nil {
return err
}
}
c.Runtime.Hold(workspace.Item{Key: "want:" + string(w.ID), Content: "not pursuing: " + w.Description + " (" + f.Reason + ")", Kind: "note", Ref: string(w.ID), SourceEvent: evID.ID})
return nil
}
if f.Displace != "" {
if _, err := vs.Apply(ctx, r, s.Mind.ID, f.Displace, volition.Reconsideration{Outcome: volition.Suspend, Reason: "capacity: a higher-priority intention formed (" + w.Description + ")"}, volition.Trigger{Kind: volition.TriggerSuperior, Detail: w.Description}); err != nil && !errors.Is(err, volition.ErrClosed) {
return err
}
if displaced, err := vs.GetIntention(ctx, c.Tx, s.Mind.ID, f.Displace); err == nil && len(displaced.SourceWants) > 0 {
if other, err := vs.GetWant(ctx, c.Tx, s.Mind.ID, displaced.SourceWants[0]); err == nil {
res := volition.ResolveConflict(w, other, s.Now, nil, map[ids.ID]float64{w.ID: in.CommitmentWeight})
vs.RecordConflict(ctx, r, w, other, "priority", res) //nolint:errcheck
}
}
}
var required []string
if w.Proposal != nil && w.Proposal.Capability != "" {
required = []string{w.Proposal.Capability}
}
beliefIDs := supportingBeliefs(ctx, s, w)
expected := w.Description
if in.Action != nil {
expected = "the effect of " + in.Action.Capability + " on " + in.Action.Target + " is observed"
}
terms := map[string]any{"score": f.Score, "terms": f.Terms, "rationale": f.Rationale, "applied_commitments": f.Applied, "constrained": in.Constrained}
it, err := vs.CreateIntention(ctx, r, volition.Intention{MindID: s.Mind.ID, Description: f.Description, DesiredOutcome: f.DesiredOutcome, Kind: f.Kind, SourceWants: []ids.ID{w.ID}, GoalID: w.GoalID, CommitmentID: w.CommitmentID,
ExpectedResult: expected, Confidence: in.Confidence, Priority: f.Priority, RequireApproval: f.RequireApproval, Proposal: w.Proposal, RequiredCapabilities: required, Formation: terms, BeliefIDs: beliefIDs})
if err != nil {
return err
}
if _, err := vs.RecordDecision(ctx, c.Tx, volition.Decision{MindID: s.Mind.ID, Kind: "formation", Outcome: string(f.Kind), Reason: f.Reason, Rationale: f.Rationale, WantID: w.ID, IntentionID: it.ID, Terms: f.Terms, Inputs: inputs, EventID: it.FormedEvent, CreatedAt: s.Now}); err != nil {
return err
}
c.Runtime.Hold(workspace.Item{Key: "intention:" + string(it.ID), Content: "intends to " + it.Description, Kind: "thought", Ref: string(it.ID), SourceEvent: it.FormedEvent})
if s.Services.Executor == nil || it.Kind == volition.IntentRespond {
return nil
}
if s.Mode == brain.ModeDreaming {
return nil // dreams may intend; plans are made awake
}
if _, err := s.Services.Executor.Start(ctx, r, it); err != nil {
reason := "no possible plan: " + err.Error()
outcome := volition.Abandon
if errors.Is(err, planning.ErrInvalidPlan) {
outcome, reason = volition.Block, "the proposed plan failed validation: "+err.Error()
}
if _, aerr := vs.Apply(ctx, r, s.Mind.ID, it.ID, volition.Reconsideration{Outcome: outcome, BlockedKind: volition.BlockPrereq, Reason: reason}, volition.Trigger{Kind: "planning", Detail: err.Error()}); aerr != nil {
return aerr
}
if outcome == volition.Abandon {
return vs.SuppressWant(ctx, r, w, reason, nil)
}
}
return nil
}
// want records an explicit action request as a want with its proposed means.
func (h Handlers) want(ctx context.Context, c *brain.Cycle, t workspace.Thought) error {
vs := c.State.Services.Volition
if vs == nil {
return nil
}
src, _ := t.Content["source_event"].(string)
actor, _ := t.Content["actor"].(string)
text, _ := t.Content["text"].(string)
operator, _ := t.Content["operator"].(bool)
p, ok := t.Content["proposal"].(volition.Proposal)
if !ok {
return nil
}
ev := events.Event{ID: ids.ID(src)}
r := recorder(c)
r.Cause = t.EventID
for _, cand := range volition.FromRequest(ev, actor, text, p, deriveContext(ctx, c.State), operator) {
w, _, err := vs.UpsertWant(ctx, r, c.State.Mind.ID, cand)
if err != nil {
return err
}
c.Runtime.Hold(workspace.Item{Key: "want:" + string(w.ID), Content: "wants: " + w.Description, Kind: "thought", Ref: string(w.ID), SourceEvent: ids.ID(src)})
}
return nil
}
// correctSelfModel compares what the self-model says about a capability
// with what policy actually gives now. A mismatch is recorded as a
// metacognitive finding and the self-model entry is corrected: reality, as
// the capability layer reports it, wins over the mind's belief about itself.
func correctSelfModel(ctx context.Context, c *brain.Cycle, a volition.ActionInfo, cause ids.ID) error {
s := c.State
if s.Services.Minds == nil {
return nil
}
actual := a.Effect
if actual == "locked" || actual == "unknown" {
actual = "unavailable"
}
for i, entry := range s.SelfModel.Capabilities {
if entry.Name != a.Capability || entry.Policy == actual {
continue
}
ev, err := c.EmitNow(ctx, events.New{Type: events.Metacognition, Source: "brain.selfmodel", Summary: fmt.Sprintf("self-model said %s is %s, but policy gives %s; corrected", a.Capability, entry.Policy, actual),
Salience: 0.6, Confidence: 1, CausationID: cause, Metadata: map[string]any{"check": "self_model_mismatch", "ref": a.Capability, "stated": entry.Policy, "actual": actual}})
if err != nil {
return err
}
sm, err := s.Services.Minds.GetSelfModel(ctx, s.Mind.ID)
if err != nil {
return nil
}
for j := range sm.Capabilities {
if sm.Capabilities[j].Name == a.Capability {
sm.Capabilities[j].Policy = actual
}
}
if err := s.Services.Minds.SaveSelfModel(ctx, s.Mind.ID, sm); err != nil {
return err
}
s.SelfModel.Capabilities[i].Policy = actual
c.Emit(events.New{Type: events.SelfModelUpdated, Source: "brain.selfmodel", Summary: "self-model corrected: " + a.Capability + " is " + actual, Salience: 0.4, Confidence: 1, CausationID: ev.ID,
Metadata: map[string]any{"correction": true, "capability": a.Capability, "from": entry.Policy, "to": actual}})
}
return nil
}
// supportingBeliefs links the beliefs an intention's action rests on: live
// beliefs that mention the proposal's target and are held with at least
// moderate confidence. A plan's expectation cites the first, so a failed
// expectation is recorded as evidence against it.
func supportingBeliefs(ctx context.Context, s *brain.State, w volition.Want) []ids.ID {
if s.Services.Beliefs == nil || w.Proposal == nil {
return nil
}
topic := strings.TrimSuffix(firstNonEmpty(w.Proposal.Target, w.Proposal.Subject), ".service")
if topic == "" {
return nil
}
bs, err := s.Services.Beliefs.Search(ctx, s.Mind.ID, topic, 5)
if err != nil {
return nil
}
// A belief about the proposed action itself ("restarting atlas-worker
// clears the queue") is what the action rests on, so it comes first; the
// first supporting belief is the one a verification outcome revises.
action := strings.ToLower(w.Proposal.Capability[strings.LastIndex(w.Proposal.Capability, ".")+1:])
sort.SliceStable(bs, func(i, j int) bool {
return action != "" && strings.Contains(strings.ToLower(bs[i].Proposition), action) && !strings.Contains(strings.ToLower(bs[j].Proposition), action)
})
var out []ids.ID
for _, b := range bs {
if len(out) == 3 {
break
}
if b.Confidence >= 0.4 && b.Status != "rejected" && b.Status != "superseded" && b.Status != "unknown" && strings.Contains(strings.ToLower(b.Proposition), strings.ToLower(topic)) {
out = append(out, b.ID)
}
}
return out
}