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.
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
// 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 }
|
||||
@@ -0,0 +1,83 @@
|
||||
package motivation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/minds"
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/migrations"
|
||||
"github.com/andydixon/noema/pkg/events"
|
||||
)
|
||||
|
||||
func TestRulesApplyDecayAndModulate(t *testing.T) {
|
||||
p := minds.DefaultPersonality("x")
|
||||
st := State{Drives: DefaultDrives(p), Affect: Baselines(p)}
|
||||
base := st.Affect
|
||||
d, ok := Rules(events.Event{Type: events.ContradictionDetected})
|
||||
if !ok {
|
||||
t.Fatal("contradiction should have a rule")
|
||||
}
|
||||
Apply(&st, d)
|
||||
if st.Affect.Concern <= base.Concern || st.Affect.Uncertainty <= base.Uncertainty {
|
||||
t.Fatalf("contradiction did not raise concern/uncertainty: %+v", st.Affect)
|
||||
}
|
||||
for i := 0; i < 50; i++ {
|
||||
Apply(&st, d)
|
||||
}
|
||||
if st.Affect.Concern > 1 || st.Affect.Uncertainty > 1 {
|
||||
t.Fatal("clamp failed")
|
||||
}
|
||||
if _, ok := Rules(events.Event{Type: events.ThoughtCandidate}); ok {
|
||||
t.Fatal("candidates must not move affect")
|
||||
}
|
||||
before := st.Affect.Concern
|
||||
Decay(&st, base, 3*time.Hour)
|
||||
if st.Affect.Concern >= before || st.Affect.Concern < base.Concern-0.01 {
|
||||
t.Fatalf("decay wrong: %v -> %v (base %v)", before, st.Affect.Concern, base.Concern)
|
||||
}
|
||||
w := map[string]float64{"novelty": 1, "goal_relevance": 1, "contradiction": 1, "salience": 1}
|
||||
for i := range st.Drives {
|
||||
if st.Drives[i].Name == "curiosity" {
|
||||
st.Drives[i].Value = 1
|
||||
}
|
||||
}
|
||||
m := Modulate(w, st)
|
||||
if m["novelty"] <= 1 || m["salience"] != 1 {
|
||||
t.Fatalf("modulation wrong: %v", m)
|
||||
}
|
||||
if k, sig := Significant(base, st.Affect); sig && k == "" {
|
||||
t.Fatal("significant without key")
|
||||
}
|
||||
// No forbidden drives can exist.
|
||||
for _, n := range DriveNames {
|
||||
if n == "self_preservation" || n == "replication" || n == "acquisition" {
|
||||
t.Fatal("forbidden drive present")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreInitialisesResetsAndPersists(t *testing.T) {
|
||||
pool := storage.TestPool(t, migrations.FS)
|
||||
ctx := context.Background()
|
||||
m, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "mot"})
|
||||
s := NewStore(pool)
|
||||
st, err := s.Load(ctx, pool, m)
|
||||
if err != nil || len(st.Drives) != len(DriveNames) {
|
||||
t.Fatalf("load: %v %+v", err, st)
|
||||
}
|
||||
st.Affect.Concern = 0.9
|
||||
s.SaveAffect(ctx, pool, m.ID, st.Affect) //nolint:errcheck
|
||||
d := st.Drives[0]
|
||||
d.Value, d.Max = 0.95, 0.8
|
||||
s.SaveDrive(ctx, pool, m.ID, d) //nolint:errcheck
|
||||
st2, _ := s.Load(ctx, pool, m)
|
||||
if st2.Affect.Concern != 0.9 || st2.Drives[0].Value != 0.8 {
|
||||
t.Fatalf("persist/clamp wrong: %+v %+v", st2.Affect, st2.Drives[0])
|
||||
}
|
||||
st3, err := s.Reset(ctx, pool, m)
|
||||
if err != nil || st3.Affect.Concern == 0.9 || st3.Drives[0].Value != st3.Drives[0].Baseline {
|
||||
t.Fatalf("reset wrong: %v %+v", err, st3)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user