Files
noema/internal/motivation/motivation.go
T
Andy Dixon 9eb873cbe6 feat: add goals, drives, affect state, predictions and curiosity
Phase 9. Goals form a mind-scoped tree with kind, priority, progress and
blockers; their deterministic relevance to a candidate feeds the
goal_relevance attention feature and a goal monitor raises stalled goals
and observations that touch one. Drives are a closed set of eight
scalars with operator-configured ranges and personality-derived
baselines; there is no self-preservation, replication or acquisition
drive and no way to add one at runtime. Affect is ten scalars moved by an
explicit rule table over events and decaying towards baseline; both
modulate attention weights each cycle and the effective weights are
recorded with every cycle and shown beside the configured ones.

Predictions carry a window and machine-checkable criteria (event terms
occurring or staying absent, a belief reaching a confidence, or operator
judgement). A resolver job judges them deterministically, keeps an
append-only outcome history for calibration (accuracy, Brier score,
confidence buckets, overconfidence flag), emits prediction_error events
scaled by how confident the miss was, and feeds the outcome back into the
linked belief as evidence. Causal hypotheses derive predictions
automatically. Curiosity turns misses, contradictions and unexplained
observations into questions that compete for attention and become
investigation goals when admitted.

Brain gains after-cycle hooks and goal/motivation state. API, UI (goals,
prediction dashboard, cognitive state panel, effective weights) and CLI
(goal, prediction, state). Demo scenario 4 verified end to end.
2026-06-27 20:41:03 +01:00

346 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package motivation holds a mind's drives and affect-like state: named
// scalars updated by deterministic rules from events, decaying towards
// personality-derived baselines. They bias attention and style and are fully
// inspectable and resettable. Nothing here claims the software feels
// anything; see docs/interpretation.md.
package motivation
import (
"context"
"encoding/json"
"errors"
"math"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// DriveNames is the closed set of drives. There is deliberately no
// self-preservation, replication or acquisition drive and no way to add one
// at runtime (spec §13, threat T25).
var DriveNames = []string{"curiosity", "uncertainty_reduction", "task_completion", "consistency", "novelty", "social_coherence", "exploration", "rest"}
// Drive is one motivational scalar with operator-configured range.
type Drive struct {
Name string `json:"name"`
Value float64 `json:"value"`
Min float64 `json:"min"`
Max float64 `json:"max"`
Baseline float64 `json:"baseline"`
UpdatedAt time.Time `json:"updated_at"`
}
// Affect is the emotion-like state vector (spec §14). Valence is in [-1,1];
// everything else in [0,1].
type Affect struct {
Valence float64 `json:"valence"`
Arousal float64 `json:"arousal"`
Curiosity float64 `json:"curiosity"`
Concern float64 `json:"concern"`
Frustration float64 `json:"frustration"`
Confidence float64 `json:"confidence"`
Uncertainty float64 `json:"uncertainty"`
Engagement float64 `json:"engagement"`
TaskPressure float64 `json:"task_pressure"`
Load float64 `json:"load"`
UpdatedAt time.Time `json:"updated_at"`
}
// Baselines derives the resting affect from personality traits.
func Baselines(p minds.Personality) Affect {
t := p.Traits
return Affect{Valence: 0.1, Arousal: 0.2, Curiosity: t["curiosity"], Concern: 0.1 + 0.2*(1-t["risk_tolerance"]), Frustration: 0.05,
Confidence: t["confidence"], Uncertainty: 0.3 + 0.3*t["scepticism"], Engagement: 0.3 + 0.3*t["sociability"], TaskPressure: 0.1, Load: 0.1}
}
// DefaultDrives derives drive baselines from personality.
func DefaultDrives(p minds.Personality) []Drive {
t, v := p.Traits, p.Values
base := map[string]float64{"curiosity": t["curiosity"], "uncertainty_reduction": 0.3 + 0.5*v["accuracy"], "task_completion": 0.3 + 0.5*v["persistence"], "consistency": 0.3 + 0.5*v["evidence"],
"novelty": t["novelty_preference"], "social_coherence": 0.2 + 0.6*t["sociability"], "exploration": 0.2 + 0.6*t["curiosity"]*t["risk_tolerance"], "rest": 0.3}
out := make([]Drive, 0, len(DriveNames))
for _, n := range DriveNames {
out = append(out, Drive{Name: n, Value: clamp01(base[n]), Min: 0, Max: 1, Baseline: clamp01(base[n]), UpdatedAt: time.Now()})
}
return out
}
// State bundles drives and affect for a mind.
type State struct {
Drives []Drive `json:"drives"`
Affect Affect `json:"affect"`
}
// Store persists motivation state.
type Store struct{ db *pgxpool.Pool }
// NewStore creates a store.
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
// Load returns the state, initialising it from personality on first use.
func (s *Store) Load(ctx context.Context, q storage.Querier, m minds.Mind) (State, error) {
var st State
rows, err := q.Query(ctx, `SELECT name, value, min, max, baseline, updated_at FROM drives WHERE mind_id=$1`, m.ID)
if err != nil {
return st, err
}
byName := map[string]Drive{}
for rows.Next() {
var d Drive
if err := rows.Scan(&d.Name, &d.Value, &d.Min, &d.Max, &d.Baseline, &d.UpdatedAt); err != nil {
rows.Close()
return st, err
}
byName[d.Name] = d
}
rows.Close()
defaults := DefaultDrives(m.Personality)
for _, d := range defaults {
if have, ok := byName[d.Name]; ok {
st.Drives = append(st.Drives, have)
continue
}
if _, err := q.Exec(ctx, `INSERT INTO drives (mind_id, name, value, min, max, baseline) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`, m.ID, d.Name, d.Value, d.Min, d.Max, d.Baseline); err != nil {
return st, err
}
st.Drives = append(st.Drives, d)
}
var raw []byte
err = q.QueryRow(ctx, `SELECT state FROM affect_states WHERE mind_id=$1`, m.ID).Scan(&raw)
switch {
case errors.Is(err, pgx.ErrNoRows):
st.Affect = Baselines(m.Personality)
st.Affect.UpdatedAt = time.Now()
if err := s.SaveAffect(ctx, q, m.ID, st.Affect); err != nil {
return st, err
}
case err != nil:
return st, err
default:
json.Unmarshal(raw, &st.Affect) //nolint:errcheck
}
return st, nil
}
// SaveAffect stores the affect vector.
func (s *Store) SaveAffect(ctx context.Context, q storage.Querier, mind ids.ID, a Affect) error {
raw, _ := json.Marshal(a)
_, err := q.Exec(ctx, `INSERT INTO affect_states (mind_id, state) VALUES ($1,$2) ON CONFLICT (mind_id) DO UPDATE SET state=excluded.state, updated_at=now()`, mind, raw)
return err
}
// SaveDrive stores one drive value (and optionally its range/baseline).
func (s *Store) SaveDrive(ctx context.Context, q storage.Querier, mind ids.ID, d Drive) error {
_, err := q.Exec(ctx, `INSERT INTO drives (mind_id, name, value, min, max, baseline, updated_at) VALUES ($1,$2,$3,$4,$5,$6,now())
ON CONFLICT (mind_id, name) DO UPDATE SET value=excluded.value, min=excluded.min, max=excluded.max, baseline=excluded.baseline, updated_at=now()`, mind, d.Name, clampRange(d.Value, d.Min, d.Max), d.Min, d.Max, d.Baseline)
return err
}
// Reset returns affect to baseline and drives to their baselines.
func (s *Store) Reset(ctx context.Context, q storage.Querier, m minds.Mind) (State, error) {
if _, err := q.Exec(ctx, `UPDATE drives SET value=baseline, updated_at=now() WHERE mind_id=$1`, m.ID); err != nil {
return State{}, err
}
a := Baselines(m.Personality)
a.UpdatedAt = time.Now()
if err := s.SaveAffect(ctx, q, m.ID, a); err != nil {
return State{}, err
}
return s.Load(ctx, q, m)
}
// Delta is a change to apply to the state, produced by rules.
type Delta struct {
Affect map[string]float64 // field → additive change
Drives map[string]float64 // drive → additive change
Reason string
}
// Rules maps an event to state changes. The table is the whole model: small,
// explicit, testable.
func Rules(ev events.Event) (Delta, bool) {
d := Delta{Affect: map[string]float64{}, Drives: map[string]float64{}}
switch ev.Type {
case events.ContradictionDetected:
d.Affect["concern"], d.Affect["uncertainty"], d.Affect["arousal"] = 0.15, 0.15, 0.1
d.Drives["uncertainty_reduction"], d.Drives["consistency"] = 0.1, 0.1
d.Reason = "contradiction"
case events.PredictionError:
d.Affect["arousal"], d.Affect["confidence"], d.Affect["uncertainty"], d.Affect["curiosity"] = 0.2, -0.15, 0.15, 0.1
d.Drives["uncertainty_reduction"], d.Drives["curiosity"] = 0.15, 0.1
d.Reason = "prediction error"
case events.PredictionResolved:
if ev.Metadata["outcome"] == "confirmed" {
d.Affect["confidence"], d.Affect["valence"] = 0.1, 0.1
d.Reason = "prediction confirmed"
} else {
return d, false
}
case events.GoalCompleted:
d.Affect["valence"], d.Affect["task_pressure"], d.Affect["frustration"] = 0.2, -0.2, -0.1
d.Drives["task_completion"], d.Drives["rest"] = -0.1, 0.1
d.Reason = "goal completed"
case events.GoalCreated:
d.Affect["task_pressure"], d.Affect["engagement"] = 0.1, 0.1
d.Drives["task_completion"] = 0.05
d.Reason = "new goal"
case events.GoalAbandoned:
d.Affect["frustration"], d.Affect["valence"] = 0.1, -0.1
d.Reason = "goal abandoned"
case events.BeliefRetracted:
d.Affect["uncertainty"], d.Affect["confidence"], d.Affect["frustration"] = 0.1, -0.1, 0.05
d.Drives["consistency"] = 0.1
d.Reason = "belief corrected by operator"
case events.Observation:
d.Affect["engagement"], d.Affect["arousal"] = 0.05, 0.05*ev.Salience
if ev.Valence != nil {
d.Affect["valence"] = 0.2 * *ev.Valence
}
d.Drives["novelty"] = 0.02
d.Reason = "observation"
case events.HypothesisCreated:
d.Affect["curiosity"] = 0.1
d.Drives["curiosity"], d.Drives["exploration"] = 0.05, 0.05
d.Reason = "hypothesis"
case events.Metacognition:
d.Affect["uncertainty"], d.Affect["load"] = 0.05, 0.05
d.Reason = "metacognitive warning"
case events.CycleCompleted:
if n, ok := ev.Metadata["candidates"].(int); ok && n > 8 {
d.Affect["load"] = 0.1
d.Reason = "busy cycle"
} else {
return d, false
}
default:
return d, false
}
return d, true
}
// Apply adds a delta to the state and clamps.
func Apply(st *State, d Delta) {
a := &st.Affect
for k, v := range d.Affect {
switch k {
case "valence":
a.Valence = clamp(a.Valence+v, -1, 1)
case "arousal":
a.Arousal = clamp01(a.Arousal + v)
case "curiosity":
a.Curiosity = clamp01(a.Curiosity + v)
case "concern":
a.Concern = clamp01(a.Concern + v)
case "frustration":
a.Frustration = clamp01(a.Frustration + v)
case "confidence":
a.Confidence = clamp01(a.Confidence + v)
case "uncertainty":
a.Uncertainty = clamp01(a.Uncertainty + v)
case "engagement":
a.Engagement = clamp01(a.Engagement + v)
case "task_pressure":
a.TaskPressure = clamp01(a.TaskPressure + v)
case "load":
a.Load = clamp01(a.Load + v)
}
}
for i := range st.Drives {
if v, ok := d.Drives[st.Drives[i].Name]; ok {
st.Drives[i].Value = clampRange(st.Drives[i].Value+v, st.Drives[i].Min, st.Drives[i].Max)
}
}
}
// Decay moves state towards baselines by a fraction that depends on elapsed
// time (half-life one hour for affect, six hours for drives).
func Decay(st *State, base Affect, elapsed time.Duration) {
if elapsed <= 0 {
return
}
fa := 1 - math.Pow(0.5, elapsed.Hours())
fd := 1 - math.Pow(0.5, elapsed.Hours()/6)
a := &st.Affect
a.Valence += (base.Valence - a.Valence) * fa
a.Arousal += (base.Arousal - a.Arousal) * fa
a.Curiosity += (base.Curiosity - a.Curiosity) * fa
a.Concern += (base.Concern - a.Concern) * fa
a.Frustration += (base.Frustration - a.Frustration) * fa
a.Confidence += (base.Confidence - a.Confidence) * fa
a.Uncertainty += (base.Uncertainty - a.Uncertainty) * fa
a.Engagement += (base.Engagement - a.Engagement) * fa
a.TaskPressure += (base.TaskPressure - a.TaskPressure) * fa
a.Load += (base.Load - a.Load) * fa
for i := range st.Drives {
d := &st.Drives[i]
d.Value += (d.Baseline - d.Value) * fd
}
}
// Modulate scales attention weights by drives and affect. The mapping is
// explicit so the workspace page can show effective weights next to the
// configured ones.
func Modulate(weights map[string]float64, st State) map[string]float64 {
out := make(map[string]float64, len(weights))
for k, v := range weights {
out[k] = v
}
drive := map[string]float64{}
for _, d := range st.Drives {
drive[d.Name] = d.Value
}
scale := func(key string, factor float64) {
if v, ok := out[key]; ok {
out[key] = round3(v * factor)
}
}
scale("novelty", 0.6+0.8*drive["curiosity"]) // 0.6× .. 1.4×
scale("goal_relevance", 0.6+0.8*drive["task_completion"])
scale("contradiction", 0.6+0.8*drive["uncertainty_reduction"])
scale("affect", 0.6+0.8*st.Affect.Arousal)
scale("urgency", 0.8+0.4*st.Affect.Concern)
scale("context_relevance", 0.7+0.6*(1-drive["exploration"]))
return out
}
// Significant reports whether two affect vectors differ enough to emit an event.
func Significant(a, b Affect) (string, bool) {
fields := map[string][2]float64{"valence": {a.Valence, b.Valence}, "arousal": {a.Arousal, b.Arousal}, "curiosity": {a.Curiosity, b.Curiosity}, "concern": {a.Concern, b.Concern},
"frustration": {a.Frustration, b.Frustration}, "confidence": {a.Confidence, b.Confidence}, "uncertainty": {a.Uncertainty, b.Uncertainty}, "engagement": {a.Engagement, b.Engagement},
"task_pressure": {a.TaskPressure, b.TaskPressure}, "load": {a.Load, b.Load}}
best, bestKey := 0.0, ""
for k, v := range fields {
if d := math.Abs(v[0] - v[1]); d > best {
best, bestKey = d, k
}
}
return bestKey, best >= 0.1
}
func clamp01(v float64) float64 { return clamp(v, 0, 1) }
func clamp(v, lo, hi float64) float64 {
if math.IsNaN(v) || v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func clampRange(v, lo, hi float64) float64 {
if hi < lo {
hi = lo
}
return clamp(v, lo, hi)
}
func round3(v float64) float64 { return math.Round(v*1000) / 1000 }