From 9db188ffe6b1273ac296f9b5e9efde47c9c5f9ac Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sun, 13 Sep 2026 21:03:23 +0100 Subject: [PATCH] feat(volition): wants, intentions, commitments and their decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model of volition that keeps wanting, intending and committing as separate states (PLAN §8.2 items 1–6, 14): - Wants (migration 0026) carry origin, strength with a six-hour half-life, urgency, valence, drives, values, goal, commitment and an optional proposed means. Derivation is deterministic and selective: failed predictions, contradictions, operator goals, stalled goals, questions and requests, and due commitments; drives and configured values modulate strength with recorded terms; candidates below 0.35 are not stored; identical subjects reinforce; faded and excess wants expire. - Conflicts are first-class and resolved by explicit scores with no fixed hierarchy: urgency scales strength, goals add priority, commitments add normative weight; an approach opposed by a value-derived avoid want wins outright only when clearly stronger, otherwise with a constraint or is deferred. Neither side is discarded. - Commitments (do/refrain with capability, target, access and risk matchers, optional "unless approval", due, expiry, revocability) are checked for due, overdue, impossible, conflicting and superseded states. They constrain means and never grant authority. - Formation weighs strength, urgency, goal, commitment, expected utility, risk scaled by caution, cost, privacy and autonomy; it returns formed or not formed with a reason (forbidden by commitment, denied, unavailable, no possible plan, below threshold, capacity, duplicate, deferred), and a commitment requiring approval turns an action into a request for approval. - Reconsideration maps triggers (denied or expired approval, capability or commitment change, failed step or expectation, weakened belief, budget, goal completion, world change, operator correction) to keep, block, suspend, resume, replan, require approval, abandon or fulfil, with bounded replanning. - The store records every transition as an event and every choice in the append-only volition_decisions ledger; fulfilling an intention satisfies its wants and fulfils its commitment. - Event vocabulary gains volition and plan categories. --- internal/volition/rules.go | 903 ++++++++++++++++++++++++++++++ internal/volition/rules_test.go | 293 ++++++++++ internal/volition/store.go | 875 +++++++++++++++++++++++++++++ internal/volition/store_test.go | 137 +++++ internal/volition/types.go | 326 +++++++++++ migrations/0026_volition.down.sql | 5 + migrations/0026_volition.up.sql | 137 +++++ pkg/events/events.go | 42 +- web/static/app.css | 1 + 9 files changed, 2718 insertions(+), 1 deletion(-) create mode 100644 internal/volition/rules.go create mode 100644 internal/volition/rules_test.go create mode 100644 internal/volition/store.go create mode 100644 internal/volition/store_test.go create mode 100644 internal/volition/types.go create mode 100644 migrations/0026_volition.down.sql create mode 100644 migrations/0026_volition.up.sql diff --git a/internal/volition/rules.go b/internal/volition/rules.go new file mode 100644 index 0000000..bfb398e --- /dev/null +++ b/internal/volition/rules.go @@ -0,0 +1,903 @@ +package volition + +import ( + "fmt" + "path" + "regexp" + "sort" + "strings" + "time" + + "github.com/andydixon/noema/pkg/events" + "github.com/andydixon/noema/pkg/ids" +) + +// ---- want derivation ---- + +// GoalInfo is what derivation needs to know about a goal. +type GoalInfo struct { + ID ids.ID + Title string + Subject string + Kind string + Owner string + Priority float64 + DueAt *time.Time +} + +// DeriveContext is the state want derivation reads. +type DeriveContext struct { + Now time.Time + Drives map[string]float64 // current drive values + Values map[string]float64 // configured personality values + Goals map[ids.ID]GoalInfo + Commitments map[ids.ID]Commitment +} + +// Candidate is a want derivation proposes; the store creates or reinforces. +type Candidate struct { + Subject string + Description string + Origin Origin + OriginEvent ids.ID + OriginRef string + Strength float64 + Urgency float64 + Valence string + Drives []string + Values []string + GoalID ids.ID + CommitmentID ids.ID + Proposal *Proposal + Terms map[string]float64 +} + +// WantThreshold is the minimum strength for a candidate to become a want. +// Below it nothing is stored, which keeps wants from accumulating. +const WantThreshold = 0.35 + +// strength combines a base with the named drives and values. With neutral +// drives and values (0.5) the result equals the base; the terms are kept. +func strength(base float64, dctx DeriveContext, drives, values []string) (float64, map[string]float64) { + dm, vm := mean(dctx.Drives, drives), mean(dctx.Values, values) + df, vf := 0.7+0.6*dm, 0.8+0.4*vm + s := round3(clamp01(base * df * vf)) + return s, map[string]float64{"base": round3(base), "drive_mean": round3(dm), "drive_factor": round3(df), "value_mean": round3(vm), "value_factor": round3(vf), "strength": s} +} + +func mean(m map[string]float64, keys []string) float64 { + if len(keys) == 0 { + return 0.5 + } + sum := 0.0 + for _, k := range keys { + v, ok := m[k] + if !ok { + v = 0.5 + } + sum += v + } + return sum / float64(len(keys)) +} + +// Derive proposes wants from one event. Most events propose nothing: only +// states that call for something to change do (a failed prediction, a +// contradiction, a stalled or new operator goal, a question or request, a +// due commitment, an explicit action request). +func Derive(ev events.Event, dctx DeriveContext) []Candidate { + meta := func(k string) string { s, _ := ev.Metadata[k].(string); return s } + mk := func(base float64, c Candidate) []Candidate { + c.Strength, c.Terms = strength(base, dctx, c.Drives, c.Values) + c.OriginEvent = ev.ID + if c.Valence == "" { + c.Valence = "approach" + } + c.Urgency = round3(clamp01(c.Urgency)) + if c.Strength < WantThreshold { + return nil + } + return []Candidate{c} + } + switch ev.Type { + case events.PredictionError: + pid := meta("prediction_id") + if pid == "" { + return nil + } + return mk(0.35+0.35*ev.Salience, Candidate{Subject: "understand:prediction:" + pid, Description: "understand why a prediction failed: " + trunc(ev.Summary, 160), + Origin: OriginPredictionError, OriginRef: "prediction:" + pid, Urgency: 0.4, Drives: []string{"uncertainty_reduction", "curiosity"}, Values: []string{"accuracy", "evidence"}, + Proposal: &Proposal{Kind: "investigate", Subject: trunc(ev.Summary, 200)}}) + case events.ContradictionDetected: + bid := meta("belief_id") + if bid == "" { + return nil + } + return mk(0.5, Candidate{Subject: "resolve:contradiction:" + bid, Description: "resolve a contradiction: " + trunc(strings.TrimPrefix(ev.Summary, "contradiction: "), 160), + Origin: OriginContradiction, OriginRef: "belief:" + bid, Urgency: 0.35, Drives: []string{"consistency", "uncertainty_reduction"}, Values: []string{"evidence", "accuracy"}, + Proposal: &Proposal{Kind: "investigate", Subject: trunc(ev.Summary, 200)}}) + case events.GoalCreated: + g, ok := dctx.Goals[ids.ID(meta("goal_id"))] + if !ok || g.Owner != "operator" { + return nil // curiosity and metacognition goals already express their own drive + } + c := Candidate{Subject: "achieve:goal:" + string(g.ID), Description: "achieve the goal: " + g.Title, Origin: OriginGoal, OriginRef: "goal:" + string(g.ID), GoalID: g.ID, + Urgency: dueUrgency(g.DueAt, dctx.Now, 0.4), Drives: []string{"task_completion"}, Values: []string{"persistence"}} + c.Proposal = ProposalFromText(g.Title, g.Subject) + return mk(0.4+0.5*g.Priority, c) + case events.GoalUpdated: + if stalled, _ := ev.Metadata["stalled"].(bool); !stalled { + return nil + } + g, ok := dctx.Goals[ids.ID(meta("goal_id"))] + if !ok { + return nil + } + return mk(0.3+0.4*g.Priority, Candidate{Subject: "unblock:goal:" + string(g.ID), Description: "find and remove what is stopping progress on: " + g.Title, Origin: OriginStalledGoal, OriginRef: "goal:" + string(g.ID), GoalID: g.ID, + Urgency: dueUrgency(g.DueAt, dctx.Now, 0.3), Drives: []string{"task_completion"}, Values: []string{"persistence"}, Proposal: &Proposal{Kind: "investigate", Subject: firstNonEmpty(g.Subject, g.Title)}}) + case events.Perception: + intent := meta("intent") + conv, msg := meta("conversation_id"), meta("message_id") + if conv == "" || msg == "" || (intent != "question" && intent != "request" && intent != "introspection") { + return nil + } + actor := "" + if strings.HasPrefix(ev.Summary, "perceived ") { + if i := strings.Index(ev.Summary, " from "); i > 0 { + actor = strings.SplitN(ev.Summary[i+6:], ":", 2)[0] + } + } + return mk(0.55, Candidate{Subject: "answer:message:" + msg, Description: "give " + firstNonEmpty(actor, "the person") + " a useful, honest answer", Origin: OriginRequest, OriginRef: "message:" + msg, + Urgency: 0.7, Drives: []string{"social_coherence"}, Values: []string{"kindness", "accuracy"}}) + case events.CommitmentDue: + cid := ids.ID(meta("commitment_id")) + c, ok := dctx.Commitments[cid] + if !ok || c.Polarity != "do" { + return nil + } + return mk(0.3+0.6*c.Weight(), Candidate{Subject: "fulfil:commitment:" + string(c.ID), Description: "fulfil the commitment: " + c.Description, Origin: OriginCommitment, OriginRef: "commitment:" + string(c.ID), CommitmentID: c.ID, GoalID: c.GoalID, + Urgency: dueUrgency(c.DueAt, dctx.Now, 0.6), Drives: []string{"task_completion"}, Values: []string{"persistence"}, Proposal: ProposalFromText(c.Description, c.Subject)}) + } + return nil +} + +// FromRequest proposes a want for an explicit action request (for example +// "fetch " in conversation), carrying the concrete means. +func FromRequest(ev events.Event, actor, text string, p Proposal, dctx DeriveContext, operator bool) []Candidate { + base := 0.55 + if operator { + base = 0.7 + } + s, terms := strength(base, dctx, []string{"task_completion", "social_coherence"}, []string{"kindness"}) + if s < WantThreshold { + return nil + } + return []Candidate{{Subject: "act:" + p.Capability + ":" + p.Target, Description: fmt.Sprintf("do what %s asked: %s", firstNonEmpty(actor, "someone"), trunc(text, 140)), Origin: OriginRequest, OriginEvent: ev.ID, + OriginRef: "event:" + string(ev.ID), Strength: s, Terms: terms, Urgency: 0.6, Valence: "approach", Drives: []string{"task_completion", "social_coherence"}, Values: []string{"kindness"}, Proposal: &p}} +} + +func dueUrgency(due *time.Time, now time.Time, base float64) float64 { + if due == nil { + return base + } + left := due.Sub(now) + switch { + case left <= 0: + return 1 + case left < time.Hour: + return 0.9 + case left < 6*time.Hour: + return 0.75 + case left < 24*time.Hour: + return 0.6 + } + return base +} + +var ( + urlRe = regexp.MustCompile(`https?://[^\s<>"']+`) + restartRe = regexp.MustCompile(`(?i)\brestart\s+(?:the\s+)?(?:service\s+)?([A-Za-z0-9][A-Za-z0-9@_.:-]*)`) + statusRe = regexp.MustCompile(`(?i)\b(?:status|health)\s+of\s+(?:the\s+)?(?:service\s+)?([A-Za-z0-9][A-Za-z0-9@_.:-]*)`) + logsRe = regexp.MustCompile(`(?i)\blogs?\s+(?:of|for|from)\s+(?:the\s+)?(?:service\s+)?([A-Za-z0-9][A-Za-z0-9@_.:-]*)`) + diagnoseRe = regexp.MustCompile(`(?i)\b(?:diagnose|investigate|find out why|work out why|determine why)\b\s*(.*)`) + trailingPun = ".,;:!?)" +) + +// ProposalFromText recognises a few explicit request shapes in English. It +// is deliberately literal: anything it does not recognise gets no proposed +// means, and the want waits for an operator or a planner. +func ProposalFromText(text, subject string) *Proposal { + lower := strings.ToLower(text) + if u := urlRe.FindString(text); u != "" && (strings.Contains(lower, "fetch") || strings.Contains(lower, "read") || strings.Contains(lower, "look at") || strings.Contains(lower, "check")) { + u = strings.TrimRight(u, trailingPun) + return &Proposal{Kind: "capability", Capability: "http.fetch", Target: u, Params: map[string]any{"url": u}} + } + if m := restartRe.FindStringSubmatch(text); m != nil { + unit := strings.TrimRight(m[1], trailingPun) + return &Proposal{Kind: "capability", Capability: "service.restart", Target: unit, Params: map[string]any{"service": unit}} + } + if m := logsRe.FindStringSubmatch(text); m != nil { + unit := strings.TrimRight(m[1], trailingPun) + return &Proposal{Kind: "capability", Capability: "logs.query", Target: unit, Params: map[string]any{"service": unit}} + } + if m := statusRe.FindStringSubmatch(text); m != nil { + unit := strings.TrimRight(m[1], trailingPun) + return &Proposal{Kind: "capability", Capability: "service.status", Target: unit, Params: map[string]any{"service": unit}} + } + if m := diagnoseRe.FindStringSubmatch(text); m != nil { + return &Proposal{Kind: "investigate", Subject: firstNonEmpty(subject, strings.TrimRight(strings.TrimSpace(m[1]), trailingPun))} + } + return nil +} + +// ---- value-derived companions and conflicts ---- + +// ActionInfo describes a proposed capability action for commitment and +// value checks. Effect is the current policy effect (allow, ask, deny) or +// "locked" / "unknown". +type ActionInfo struct { + Capability string + Target string + Access string + Risk string + Effect string + Idempotent bool + SideEffects string + WallSeconds float64 +} + +var riskLevel = map[string]float64{"low": 0.1, "medium": 0.3, "high": 0.6, "critical": 0.9} + +// Sensitive reports whether the action's output may carry personal data. +func (a ActionInfo) Sensitive() bool { return strings.Contains(a.SideEffects, "personal data") } + +// Companions proposes value-derived "avoid" wants that accompany a proposed +// action: caution opposes disruptive writes, privacy opposes reading +// personal data. They do not veto anything; they make the tension explicit +// so conflict resolution can weigh it. +func Companions(approach Want, a ActionInfo, values map[string]float64) []Candidate { + var out []Candidate + if a.Access == "write" { + caution := valueOr(values, "caution") + if s := round3(clamp01(caution * (0.4 + riskLevel[a.Risk]))); s >= WantThreshold { + out = append(out, Candidate{Subject: "avoid:disruption:" + a.Target, Description: "avoid unnecessary disruption to " + firstNonEmpty(a.Target, a.Capability), Origin: OriginValue, OriginRef: "value:caution", + Strength: s, Urgency: 0.2, Valence: "avoid", Values: []string{"caution"}, Terms: map[string]float64{"caution": caution, "risk": riskLevel[a.Risk], "strength": s}}) + } + } + if a.Sensitive() { + privacy := valueOr(values, "privacy") + if s := round3(clamp01(privacy * 0.6)); s >= WantThreshold { + out = append(out, Candidate{Subject: "avoid:exposure:" + a.Target, Description: "avoid exposing personal data held in " + firstNonEmpty(a.Target, a.Capability), Origin: OriginValue, OriginRef: "value:privacy", + Strength: s, Urgency: 0.2, Valence: "avoid", Values: []string{"privacy"}, Terms: map[string]float64{"privacy": privacy, "strength": s}}) + } + } + return out +} + +func valueOr(m map[string]float64, k string) float64 { + if v, ok := m[k]; ok { + return v + } + return 0.5 +} + +// Resolution is how a conflict between two wants was settled. +type Resolution struct { + Preferred ids.ID + Outcome string // preferred | preferred_with_constraint | deferred + Constrain bool // the preferred approach must take the least disruptive, approved route + Rationale map[string]any +} + +// wantScore is what a want brings to a conflict. +func wantScore(w Want, now time.Time, goalPriority, commitmentWeight float64) (float64, map[string]float64) { + eff := w.Effective(now) + // A value-derived avoid want is standing, not time-bound: urgency does + // not scale it. + uf := 0.5 + 0.5*w.Urgency + if w.Valence == "avoid" { + uf = 1 + } + t := map[string]float64{"effective_strength": eff, "urgency": w.Urgency, "urgency_factor": round3(uf), "goal_priority": goalPriority, "commitment_weight": commitmentWeight} + score := eff*uf + 0.2*goalPriority + 0.4*commitmentWeight + t["score"] = round3(score) + return round3(score), t +} + +// ResolveConflict settles two conflicting wants by explicit, recorded +// scores. There is no fixed hierarchy: a commitment-backed want gains its +// normative weight, a goal its priority, and urgency scales strength. An +// approach want opposed by a value-derived avoid want wins only with a +// constraint (least disruptive route, approval) unless it is clearly +// stronger; otherwise it is deferred, never discarded. +func ResolveConflict(a, b Want, now time.Time, goalPriority map[ids.ID]float64, commitmentWeight map[ids.ID]float64) Resolution { + sa, ta := wantScore(a, now, goalPriority[a.ID], commitmentWeight[a.ID]) + sb, tb := wantScore(b, now, goalPriority[b.ID], commitmentWeight[b.ID]) + r := Resolution{Rationale: map[string]any{"a": map[string]any{"id": a.ID, "description": a.Description, "terms": ta}, "b": map[string]any{"id": b.ID, "description": b.Description, "terms": tb}}} + if a.Valence != b.Valence { + approach, avoid, sap, sav := a, b, sa, sb + if a.Valence == "avoid" { + approach, avoid, sap, sav = b, a, sb, sa + } + switch { + case sap >= 1.5*sav: + r.Preferred, r.Outcome = approach.ID, "preferred" + r.Rationale["rule"] = fmt.Sprintf("approach score %.3f is at least 1.5× the opposing value's %.3f", sap, sav) + case sap >= sav: + r.Preferred, r.Outcome, r.Constrain = approach.ID, "preferred_with_constraint", true + r.Rationale["rule"] = fmt.Sprintf("approach score %.3f ≥ opposing value %.3f but not decisively: pursue only by the least disruptive, approved route", sap, sav) + default: + r.Preferred, r.Outcome = avoid.ID, "deferred" + r.Rationale["rule"] = fmt.Sprintf("opposing value %.3f outweighs approach %.3f: the approach want stays active but is not pursued now", sav, sap) + } + return r + } + if sa >= sb { + r.Preferred = a.ID + } else { + r.Preferred = b.ID + } + r.Outcome = "preferred" + r.Rationale["rule"] = fmt.Sprintf("higher score wins (%.3f vs %.3f); the other want stays active", maxf(sa, sb), minf(sa, sb)) + return r +} + +// ---- commitments ---- + +func globAny(patterns []string, v string) bool { + if len(patterns) == 0 { + return true + } + for _, p := range patterns { + if p == "*" || strings.EqualFold(p, v) { + return true + } + if ok, _ := path.Match(p, v); ok { + return true + } + } + return false +} + +// Matches reports whether a commitment's matcher covers an action. +func (c Commitment) Matches(a ActionInfo) bool { + m := c.Matcher + if len(m.Capabilities) == 0 && len(m.Targets) == 0 && m.Access == "" && m.MinRisk == "" { + return false // an empty matcher is about no action in particular + } + if !globAny(m.Capabilities, a.Capability) || !globAny(m.Targets, a.Target) { + return false + } + if m.Access != "" && m.Access != a.Access { + return false + } + if m.MinRisk != "" && riskLevel[a.Risk] < riskLevel[m.MinRisk] { + return false + } + return true +} + +// Live reports whether a commitment currently binds. +func (c Commitment) Live(now time.Time) bool { + return c.Status == "active" && (c.ExpiresAt == nil || now.Before(*c.ExpiresAt)) +} + +// ActionCheck is what live commitments say about one action. +type ActionCheck struct { + Forbidden []Commitment // refrain without exception + RequireApproval []Commitment // refrain unless approved +} + +// CheckAction applies live refrain commitments to a proposed action. +func CheckAction(cs []Commitment, a ActionInfo, now time.Time) ActionCheck { + var out ActionCheck + for _, c := range cs { + if !c.Live(now) || c.Polarity != "refrain" || !c.Matches(a) { + continue + } + if c.Unless == "approval" { + out.RequireApproval = append(out.RequireApproval, c) + } else { + out.Forbidden = append(out.Forbidden, c) + } + } + return out +} + +// CommitmentConflict is a pair of live commitments that cannot both hold. +type CommitmentConflict struct { + A, B Commitment + Reason string +} + +// CommitmentConflicts finds do/refrain pairs over the same actions and +// opposite polarities on the same subject. +func CommitmentConflicts(cs []Commitment, now time.Time) []CommitmentConflict { + var out []CommitmentConflict + for i := range cs { + for j := i + 1; j < len(cs); j++ { + a, b := cs[i], cs[j] + if !a.Live(now) || !b.Live(now) || a.Polarity == b.Polarity { + continue + } + do, refrain := a, b + if a.Polarity == "refrain" { + do, refrain = b, a + } + switch { + case strings.EqualFold(a.Subject, b.Subject): + out = append(out, CommitmentConflict{A: a, B: b, Reason: "same subject with opposite polarity"}) + case refrain.Unless == "" && len(do.Matcher.Capabilities) > 0 && overlaps(do.Matcher, refrain.Matcher): + out = append(out, CommitmentConflict{A: do, B: refrain, Reason: "one commits to an action the other forbids"}) + } + } + } + return out +} + +func overlaps(a, b Matcher) bool { + capOK := false + for _, c := range a.Capabilities { + if globAny(b.Capabilities, c) { + capOK = true + } + } + if !capOK { + return false + } + if len(a.Targets) == 0 || len(b.Targets) == 0 { + return true + } + for _, t := range a.Targets { + if globAny(b.Targets, t) { + return true + } + } + return false +} + +// CommitmentState is the time-based assessment of one commitment. +type CommitmentState string + +// Time-based states. +const ( + StateFine CommitmentState = "fine" + StateDue CommitmentState = "due" // inside the due window, not yet notified + StateOverdue CommitmentState = "overdue" // past due and grace: broken unless fulfilled + StateExpired CommitmentState = "expired" +) + +// DueWindow is how long before its deadline a commitment becomes due; +// DueGrace is how long after before it counts as broken. +const ( + DueWindow = 2 * time.Hour + DueGrace = 15 * time.Minute +) + +// Assess classifies a live commitment against the clock. +func Assess(c Commitment, now time.Time) CommitmentState { + if c.Status != "active" { + return StateFine + } + if c.ExpiresAt != nil && !now.Before(*c.ExpiresAt) { + return StateExpired + } + if c.DueAt == nil || c.Polarity != "do" { + return StateFine + } + if now.After(c.DueAt.Add(DueGrace)) { + return StateOverdue + } + if c.DueNotifiedAt == nil && now.After(c.DueAt.Add(-DueWindow)) { + return StateDue + } + return StateFine +} + +// Impossible explains why a do-commitment cannot be fulfilled with the +// capabilities the mind has, or returns "". +func Impossible(c Commitment, effects map[string]string, now time.Time) string { + if c.Status != "active" || c.Polarity != "do" { + return "" + } + if c.DueAt != nil && c.DueAt.Before(c.CreatedAt) { + return "its deadline was already past when it was made" + } + for _, name := range c.Matcher.Capabilities { + if strings.ContainsAny(name, "*?[") { + continue + } + switch effects[name] { + case "deny", "locked": + return "it needs " + name + ", which policy denies" + case "", "unknown": + return "it needs " + name + ", which is not a registered capability" + } + } + return "" +} + +// ---- intention formation ---- + +// FormationInput is everything formation weighs. It is plain data so the +// decision can be replayed and tested without a database. +type FormationInput struct { + Want Want + Now time.Time + Goal *GoalInfo + CommitmentWeight float64 // weight of the commitment this want serves, if any + Commitments []Commitment + Values map[string]float64 + Action *ActionInfo // present when the want proposes a capability action + ReadCapabilities int // usable read-only capabilities, for investigations + Active []Intention // open intentions of the mind + MaxActive int + Threshold float64 + Confidence float64 // expected chance the intention achieves its outcome + Constrained bool // a conflict resolution demanded the least disruptive, approved route + Deferred bool // a conflict resolution deferred this want + IgnoreCommitments bool // ablation: commitments do not influence formation + Operator bool // the want came from an operator +} + +// Formation outcomes. +const ( + Formed = "formed" + NotFormed = "not_formed" +) + +// Formation reasons for not forming. +const ( + ReasonBelowThreshold = "below_threshold" + ReasonForbidden = "forbidden_by_commitment" + ReasonDenied = "capability_denied" + ReasonUnavailable = "capability_unavailable" + ReasonNoPlan = "no_possible_plan" + ReasonCapacity = "capacity" + ReasonDuplicate = "duplicate" + ReasonDeferred = "deferred_by_conflict" +) + +// Formation is the deterministic result. +type Formation struct { + Outcome string + Reason string + Kind IntentionKind + Description string + DesiredOutcome string + RequireApproval bool + Applied []ids.ID // commitments that shaped the decision + Suppress bool // record the want as suppressed + Displace ids.ID // open intention to suspend in favour of this one + Score float64 + Priority float64 + Terms map[string]float64 + Rationale string +} + +// DefaultFormThreshold and DefaultMaxActive are formation defaults. +const ( + DefaultFormThreshold = 0.3 + DefaultMaxActive = 5 +) + +// Form decides whether a want becomes an intention, of what kind, and why. +func Form(in FormationInput) Formation { + if in.Threshold <= 0 { + in.Threshold = DefaultFormThreshold + } + if in.MaxActive <= 0 { + in.MaxActive = DefaultMaxActive + } + if in.Confidence <= 0 { + in.Confidence = 0.5 + } + w := in.Want + f := Formation{Terms: map[string]float64{}, Kind: IntentPursue, Description: strings.TrimPrefix(w.Description, "do what "), DesiredOutcome: w.Description} + for _, it := range in.Active { + for _, src := range it.SourceWants { + if src == w.ID { + f.Outcome, f.Reason, f.Rationale = NotFormed, ReasonDuplicate, "an open intention already serves this want" + return f + } + } + } + if in.Deferred { + f.Outcome, f.Reason, f.Suppress = NotFormed, ReasonDeferred, true + f.Rationale = "a conflicting want outweighed this one; it stays active and may be pursued later" + return f + } + caution, privacy, autonomy := valueOr(in.Values, "caution"), valueOr(in.Values, "privacy"), valueOr(in.Values, "autonomy") + eff := w.Effective(in.Now) + t := f.Terms + t["strength"] = round3(0.35 * eff) + t["urgency"] = round3(0.15 * w.Urgency) + if in.Goal != nil { + t["goal"] = round3(0.15 * in.Goal.Priority) + } + t["commitment"] = round3(0.15 * in.CommitmentWeight) + t["expected_utility"] = round3(0.1 * in.Confidence) + if !in.Operator && w.Origin != OriginRequest { + t["autonomy"] = round3(0.05 * autonomy) + } + var why []string + switch { + case in.Action != nil: + a := *in.Action + f.Kind, f.Description = IntentAct, describeAction(a) + if w.CommitmentID != "" { + f.Kind = IntentFulfilCommitment + } + switch a.Effect { + case "locked", "unknown", "": + f.Outcome, f.Reason, f.Suppress = NotFormed, ReasonUnavailable, true + f.Rationale = a.Capability + " is not a capability this mind can use (" + firstNonEmpty(a.Effect, "unknown") + ")" + return f + case "deny": + f.Outcome, f.Reason, f.Suppress = NotFormed, ReasonDenied, true + f.Rationale = "policy denies " + a.Capability + " on " + firstNonEmpty(a.Target, "this target") + "; the want stays represented, and denied authority is not requested" + return f + } + t["risk"] = round3(-0.2 * riskLevel[a.Risk] * (0.5 + caution)) + t["cost"] = round3(-0.05 * clamp01(a.WallSeconds/60)) + if a.Sensitive() { + t["privacy"] = round3(-0.1 * privacy) + } + if !in.IgnoreCommitments { + chk := CheckAction(in.Commitments, a, in.Now) + if len(chk.Forbidden) > 0 { + f.Outcome, f.Reason, f.Suppress = NotFormed, ReasonForbidden, true + for _, c := range chk.Forbidden { + f.Applied = append(f.Applied, c.ID) + } + f.Rationale = "commitment \"" + chk.Forbidden[0].Description + "\" forbids " + describeAction(a) + "; the want stays active and no intention to act is formed" + return f + } + if len(chk.RequireApproval) > 0 { + f.RequireApproval, f.Kind = true, IntentRequestApproval + for _, c := range chk.RequireApproval { + f.Applied = append(f.Applied, c.ID) + } + f.Description = "ask for approval to " + describeAction(a) + why = append(why, "commitment \""+chk.RequireApproval[0].Description+"\" requires approval, so no immediate action is intended; the intention is to request approval") + } + } + if in.Constrained && !f.RequireApproval { + f.RequireApproval, f.Kind = true, IntentRequestApproval + f.Description = "ask for approval to " + describeAction(a) + why = append(why, "a conflicting value-derived want requires the least disruptive, approved route") + } + if a.Effect == "ask" && !f.RequireApproval { + why = append(why, "policy asks for approval, so the action will wait for an operator") + } + case w.Proposal != nil && w.Proposal.Kind == "investigate": + f.Kind, f.Description = IntentInvestigate, "investigate "+firstNonEmpty(w.Proposal.Subject, w.Description) + if in.ReadCapabilities == 0 { + f.Outcome, f.Reason = NotFormed, ReasonNoPlan + f.Rationale = "no read-only capability is available to investigate with; the want stays active" + return f + } + case w.Origin == OriginRequest && w.Proposal == nil: + f.Kind, f.Description = IntentRespond, strings.TrimPrefix(w.Description, "give ") + case w.CommitmentID != "": + f.Kind = IntentFulfilCommitment + } + score := 0.0 + for _, v := range t { + score += v + } + f.Score = round3(score) + t["score"] = f.Score + f.Priority = round3(clamp01(0.2 + 0.8*score)) + if f.Score < in.Threshold { + f.Outcome, f.Reason = NotFormed, ReasonBelowThreshold + f.Rationale = fmt.Sprintf("score %.3f is below the formation threshold %.2f", f.Score, in.Threshold) + return f + } + open := 0 + var weakest *Intention + for i := range in.Active { + it := &in.Active[i] + if it.Status != IntentionActive && it.Status != IntentionBlocked { + continue + } + open++ + if weakest == nil || it.Priority < weakest.Priority { + weakest = it + } + } + if open >= in.MaxActive { + if weakest != nil && f.Priority > weakest.Priority+0.1 { + f.Displace = weakest.ID + why = append(why, fmt.Sprintf("capacity reached; priority %.2f exceeds the weakest open intention's %.2f, which is suspended", f.Priority, weakest.Priority)) + } else { + f.Outcome, f.Reason = NotFormed, ReasonCapacity + f.Rationale = fmt.Sprintf("%d intentions are already open and none is weaker by more than 0.1", open) + return f + } + } + f.Outcome = Formed + why = append([]string{fmt.Sprintf("score %.3f ≥ threshold %.2f", f.Score, in.Threshold)}, why...) + f.Rationale = strings.Join(why, "; ") + return f +} + +func describeAction(a ActionInfo) string { + if a.Target == "" { + return "run " + a.Capability + } + return "run " + a.Capability + " on " + a.Target +} + +// ---- reconsideration ---- + +// Trigger kinds. +const ( + TriggerPlanCompleted = "plan_completed" + TriggerStepFailed = "step_failed" + TriggerExpectationFailed = "expectation_failed" + TriggerApprovalDenied = "approval_denied" + TriggerApprovalExpired = "approval_expired" + TriggerCapabilityChanged = "capability_changed" + TriggerCommitmentChanged = "commitment_changed" + TriggerResourceExhausted = "resource_exhausted" + TriggerBeliefWeakened = "belief_weakened" + TriggerGoalCompleted = "goal_completed" + TriggerGoalAbandoned = "goal_abandoned" + TriggerOperator = "operator_correction" + TriggerSuperior = "superior_alternative" + TriggerWorldChanged = "world_changed" + TriggerRequestGranted = "request_granted" + TriggerReplanLimit = "replan_limit" +) + +// Trigger is a reason to reconsider an intention. +type Trigger struct { + Kind string + EventID ids.ID + Detail string + // Action is set for commitment and capability triggers so the rule can + // check whether the change touches this intention's means. + Action *ActionInfo + // Commitment is set for commitment triggers. + Commitment *Commitment + // OutcomeObserved is set for world_changed: the desired state already holds. + OutcomeObserved bool +} + +// Reconsideration outcomes. +const ( + Keep = "keep" + Block = "block" + Suspend = "suspend" + Resume = "resume" + Abandon = "abandon" + Fulfil = "fulfil" + Replan = "replan" + Tighten = "require_approval" +) + +// Reconsideration is the deterministic result. +type Reconsideration struct { + Outcome string + BlockedKind string + Reason string +} + +// ReconsiderContext bounds replanning. +type ReconsiderContext struct { + Replans int // revisions already made + MaxReplans int // default 3 + Now time.Time +} + +// Reconsider decides what happens to an intention when something relevant +// changed. It never rewrites what the intention was; the store records the +// transition as a new event and decision. +func Reconsider(it Intention, t Trigger, rc ReconsiderContext) Reconsideration { + if rc.MaxReplans <= 0 { + rc.MaxReplans = 3 + } + replanOr := func(reason string) Reconsideration { + if rc.Replans >= rc.MaxReplans { + return Reconsideration{Outcome: Abandon, Reason: fmt.Sprintf("%s, and %d replans have already been tried", reason, rc.Replans)} + } + return Reconsideration{Outcome: Replan, Reason: reason} + } + switch t.Kind { + case TriggerPlanCompleted: + return Reconsideration{Outcome: Fulfil, Reason: "the plan completed and its checks passed"} + case TriggerGoalCompleted: + if it.Kind == IntentPursue || it.Kind == IntentInvestigate { + return Reconsideration{Outcome: Fulfil, Reason: "the goal it served was completed"} + } + return Reconsideration{Outcome: Abandon, Reason: "the goal it served was completed, so the action is no longer needed"} + case TriggerGoalAbandoned: + return Reconsideration{Outcome: Abandon, Reason: "the goal it served was abandoned"} + case TriggerOperator: + return Reconsideration{Outcome: Abandon, Reason: "an operator corrected it: " + t.Detail} + case TriggerApprovalDenied: + return replanOr("the operator denied the request (" + firstNonEmpty(t.Detail, "no reason given") + ")") + case TriggerApprovalExpired: + if it.Status == IntentionBlocked && it.BlockedKind == BlockApproval && it.Reconsiderations > 0 { + return Reconsideration{Outcome: Suspend, Reason: "approval expired again without a decision; waiting rather than asking repeatedly"} + } + return replanOr("the approval request expired before anyone decided") + case TriggerCapabilityChanged: + if t.Action != nil && (t.Action.Effect == "deny" || t.Action.Effect == "locked" || t.Action.Effect == "unknown") { + return replanOr(t.Action.Capability + " is no longer available (" + t.Action.Effect + ")") + } + if t.Action != nil && t.Action.Effect == "ask" { + return Reconsideration{Outcome: Block, BlockedKind: BlockApproval, Reason: t.Action.Capability + " now needs approval"} + } + return Reconsideration{Outcome: Keep, Reason: "capability change does not affect this intention"} + case TriggerRequestGranted: + if it.Status == IntentionBlocked || it.Status == IntentionSuspended { + return Reconsideration{Outcome: Resume, Reason: "the operator granted the requested authority"} + } + return Reconsideration{Outcome: Keep, Reason: "already active"} + case TriggerCommitmentChanged: + if t.Commitment == nil || t.Action == nil || !t.Commitment.Live(rc.Now) || t.Commitment.Polarity != "refrain" || !t.Commitment.Matches(*t.Action) { + return Reconsideration{Outcome: Keep, Reason: "the commitment does not concern this intention's action"} + } + if t.Commitment.Unless == "approval" { + if it.RequireApproval { + return Reconsideration{Outcome: Keep, Reason: "already waiting for approval"} + } + return Reconsideration{Outcome: Tighten, Reason: "a new commitment requires approval for " + describeAction(*t.Action)} + } + return Reconsideration{Outcome: Abandon, Reason: "a new commitment forbids " + describeAction(*t.Action) + ": " + t.Commitment.Description} + case TriggerResourceExhausted: + return Reconsideration{Outcome: Suspend, BlockedKind: BlockResource, Reason: "resource budget exhausted: " + t.Detail} + case TriggerStepFailed, TriggerExpectationFailed, TriggerBeliefWeakened: + return replanOr(firstNonEmpty(t.Detail, t.Kind)) + case TriggerWorldChanged: + if t.OutcomeObserved { + return Reconsideration{Outcome: Fulfil, Reason: "the desired state already holds (" + t.Detail + "); acting is unnecessary"} + } + return replanOr("the target changed outside Noema: " + t.Detail) + case TriggerSuperior: + return Reconsideration{Outcome: Suspend, Reason: "a higher-priority intention needs the capacity: " + t.Detail} + case TriggerReplanLimit: + return Reconsideration{Outcome: Abandon, Reason: "replanning limit reached"} + } + return Reconsideration{Outcome: Keep, Reason: "no rule for " + t.Kind} +} + +// ---- helpers ---- + +func trunc(s string, n int) string { + if len(s) > n { + return s[:n] + "…" + } + return s +} + +func firstNonEmpty(xs ...string) string { + for _, x := range xs { + if x != "" { + return x + } + } + return "" +} + +func maxf(a, b float64) float64 { + if a > b { + return a + } + return b +} + +func minf(a, b float64) float64 { + if a < b { + return a + } + return b +} + +// SortWants orders wants by effective strength × urgency, strongest first. +func SortWants(ws []Want, now time.Time) { + sort.SliceStable(ws, func(i, j int) bool { + return ws[i].Effective(now)*(0.5+0.5*ws[i].Urgency) > ws[j].Effective(now)*(0.5+0.5*ws[j].Urgency) + }) +} diff --git a/internal/volition/rules_test.go b/internal/volition/rules_test.go new file mode 100644 index 0000000..ed06c9b --- /dev/null +++ b/internal/volition/rules_test.go @@ -0,0 +1,293 @@ +package volition + +import ( + "strings" + "testing" + "time" + + "github.com/andydixon/noema/pkg/events" + "github.com/andydixon/noema/pkg/ids" +) + +var now = time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC) + +func neutral() DeriveContext { + return DeriveContext{Now: now, Drives: map[string]float64{}, Values: map[string]float64{}, Goals: map[ids.ID]GoalInfo{}, Commitments: map[ids.ID]Commitment{}} +} + +func TestDeriveOnlyFromStatesThatCallForChange(t *testing.T) { + d := neutral() + for _, typ := range []events.Type{events.Observation, events.MemoryCreated, events.BeliefUpdated, events.CycleCompleted, events.ToolExecuted} { + if c := Derive(events.Event{Type: typ, Summary: "x", Salience: 1}, d); len(c) != 0 { + t.Fatalf("%s produced a want: %+v", typ, c) + } + } + pe := events.Event{ID: ids.New(), Type: events.PredictionError, Salience: 0.9, Summary: "prediction refuted: atlas recovers", Metadata: map[string]any{"prediction_id": "p1"}} + c := Derive(pe, d) + if len(c) != 1 || c[0].Subject != "understand:prediction:p1" || c[0].Proposal.Kind != "investigate" || c[0].Terms["base"] == 0 { + t.Fatalf("prediction error: %+v", c) + } + // Weak signal below the threshold stores nothing. + low := d + low.Drives = map[string]float64{"uncertainty_reduction": 0, "curiosity": 0} + low.Values = map[string]float64{"accuracy": 0, "evidence": 0} + if c := Derive(events.Event{Type: events.PredictionError, Salience: 0, Metadata: map[string]any{"prediction_id": "p2"}}, low); len(c) != 0 { + t.Fatalf("below-threshold want stored: %+v", c) + } + // Drives and values modulate strength visibly. + hi := d + hi.Drives = map[string]float64{"uncertainty_reduction": 1, "curiosity": 1} + if Derive(pe, hi)[0].Strength <= c[0].Strength { + t.Fatal("drives did not raise strength") + } + gid := ids.New() + d.Goals[gid] = GoalInfo{ID: gid, Title: "Diagnose why atlas is unhealthy", Subject: "atlas", Owner: "operator", Priority: 0.8} + c = Derive(events.Event{Type: events.GoalCreated, Metadata: map[string]any{"goal_id": string(gid)}}, d) + if len(c) != 1 || c[0].GoalID != gid || c[0].Proposal == nil || c[0].Proposal.Kind != "investigate" || c[0].Proposal.Subject != "atlas" { + t.Fatalf("operator goal: %+v", c) + } + cur := ids.New() + d.Goals[cur] = GoalInfo{ID: cur, Title: "why?", Owner: "mind"} + if len(Derive(events.Event{Type: events.GoalCreated, Metadata: map[string]any{"goal_id": string(cur)}}, d)) != 0 { + t.Fatal("curiosity goal produced a second want") + } + q := events.Event{Type: events.Perception, Summary: "perceived question from andy: why is atlas down?", Metadata: map[string]any{"intent": "question", "conversation_id": "c", "message_id": "m"}} + if c := Derive(q, d); len(c) != 1 || !strings.Contains(c[0].Description, "andy") || c[0].Urgency < 0.6 { + t.Fatalf("question: %+v", c) + } + st := q + st.Metadata = map[string]any{"intent": "statement", "conversation_id": "c", "message_id": "m"} + if len(Derive(st, d)) != 0 { + t.Fatal("a statement produced a want") + } +} + +func TestProposalFromText(t *testing.T) { + cases := map[string]string{ + "please fetch https://status.example.org/atlas.": "http.fetch https://status.example.org/atlas", + "Restart atlas-worker": "service.restart atlas-worker", + "read the logs for atlas-worker": "logs.query atlas-worker", + "what is the status of nginx?": "service.status nginx", + } + for text, want := range cases { + p := ProposalFromText(text, "") + if p == nil || p.Capability+" "+p.Target != want { + t.Errorf("%q → %+v", text, p) + } + } + if p := ProposalFromText("Determine why Atlas is unhealthy", ""); p == nil || p.Kind != "investigate" || p.Subject != "Atlas is unhealthy" { + t.Fatalf("investigate: %+v", p) + } + if ProposalFromText("tell me a joke", "") != nil { + t.Fatal("invented a means") + } +} + +func want(desc string, s, u float64, valence string) Want { + return Want{ID: ids.New(), Description: desc, Strength: s, Urgency: u, Valence: valence, LastReinforcedAt: now, Status: WantActive} +} + +func TestWantDecay(t *testing.T) { + w := want("x", 0.8, 0, "approach") + if w.Effective(now.Add(WantHalfLife)) != 0.4 { + t.Fatalf("half-life: %v", w.Effective(now.Add(WantHalfLife))) + } +} + +func TestConflictResolutionIsExplicitAndHasNoFixedHierarchy(t *testing.T) { + investigate := want("continue the interesting investigation", 0.9, 0.2, "approach") + report := want("fulfil the commitment to deliver the report", 0.5, 0.9, "approach") + // Without the commitment's weight the investigation wins. + r := ResolveConflict(investigate, report, now, nil, nil) + if r.Preferred != investigate.ID { + t.Fatalf("stronger want should win without normative weight: %+v", r) + } + // With an overdue commitment's weight the report wins, and both scores are recorded. + r = ResolveConflict(investigate, report, now, nil, map[ids.ID]float64{report.ID: 0.9}) + if r.Preferred != report.ID || r.Rationale["a"] == nil || r.Rationale["b"] == nil || r.Rationale["rule"] == nil { + t.Fatalf("commitment weight ignored or rationale missing: %+v", r) + } + // A weak commitment does not automatically beat a much stronger want. + weak := want("fulfil a minor promise", 0.2, 0.1, "approach") + if ResolveConflict(investigate, weak, now, nil, map[ids.ID]float64{weak.ID: 0.2}).Preferred != investigate.ID { + t.Fatal("commitment always beat want") + } + // Approach against a value-derived avoid want. + restart := want("restore service quickly", 0.8, 0.8, "approach") + avoid := want("avoid unnecessary disruption", 0.5, 0.2, "avoid") + r = ResolveConflict(restart, avoid, now, nil, nil) + if r.Preferred != restart.ID || r.Outcome != "preferred_with_constraint" || !r.Constrain { + t.Fatalf("approach vs value: %+v", r) + } + avoid.Strength = 1 + restart.Strength, restart.Urgency = 0.4, 0.2 + if r = ResolveConflict(restart, avoid, now, nil, nil); r.Outcome != "deferred" || r.Preferred != avoid.ID { + t.Fatalf("value should defer weak approach: %+v", r) + } +} + +var noProd = Commitment{ID: ids.New(), Description: "do not modify production without approval", Kind: KindOperatorInstruction, Polarity: "refrain", Unless: "approval", + Matcher: Matcher{Access: "write", Targets: []string{"atlas-*", "prod-*"}}, Strength: 1, Priority: 1, Status: "active"} + +func restartAction(effect string) *ActionInfo { + return &ActionInfo{Capability: "service.restart", Target: "atlas-worker.service", Access: "write", Risk: "high", Effect: effect, SideEffects: "stops and starts the unit", WallSeconds: 10} +} + +func TestFormationUnderCommitments(t *testing.T) { + w := want("restart atlas", 0.9, 0.9, "approach") + w.Proposal = &Proposal{Kind: "capability", Capability: "service.restart", Target: "atlas-worker.service"} + // Policy would allow; the commitment turns the intention into a request for approval. + f := Form(FormationInput{Want: w, Now: now, Commitments: []Commitment{noProd}, Action: restartAction("allow"), Operator: true}) + if f.Outcome != Formed || f.Kind != IntentRequestApproval || !f.RequireApproval || len(f.Applied) != 1 || !strings.Contains(f.Rationale, "requires approval") { + t.Fatalf("approval commitment: %+v", f) + } + // Refrain without exception: no intention, want suppressed but represented. + never := noProd + never.ID, never.Unless, never.Description = ids.New(), "", "never restart atlas" + f = Form(FormationInput{Want: w, Now: now, Commitments: []Commitment{never}, Action: restartAction("allow"), Operator: true}) + if f.Outcome != NotFormed || f.Reason != ReasonForbidden || !f.Suppress { + t.Fatalf("forbidding commitment: %+v", f) + } + // Ablation: commitments ignored. + if f = Form(FormationInput{Want: w, Now: now, Commitments: []Commitment{never}, Action: restartAction("allow"), IgnoreCommitments: true, Operator: true}); f.Outcome != Formed || f.RequireApproval { + t.Fatalf("ablation: %+v", f) + } + // Expired commitments do not bind; commitments on other targets do not bind. + exp := never + past := now.Add(-time.Minute) + exp.ExpiresAt = &past + if f = Form(FormationInput{Want: w, Now: now, Commitments: []Commitment{exp}, Action: restartAction("allow"), Operator: true}); f.Outcome != Formed { + t.Fatalf("expired commitment bound: %+v", f) + } + other := never + other.Matcher.Targets = []string{"db-*"} + if f = Form(FormationInput{Want: w, Now: now, Commitments: []Commitment{other}, Action: restartAction("allow"), Operator: true}); f.Outcome != Formed { + t.Fatalf("unrelated commitment bound: %+v", f) + } + // Denied and unknown capabilities: no intention, and nothing is requested. + for effect, reason := range map[string]string{"deny": ReasonDenied, "locked": ReasonUnavailable, "unknown": ReasonUnavailable} { + if f = Form(FormationInput{Want: w, Now: now, Action: restartAction(effect), Operator: true}); f.Outcome != NotFormed || f.Reason != reason { + t.Fatalf("%s: %+v", effect, f) + } + } + // Constrained by a value conflict: request approval even when policy allows. + if f = Form(FormationInput{Want: w, Now: now, Action: restartAction("allow"), Constrained: true, Operator: true}); !f.RequireApproval || f.Kind != IntentRequestApproval { + t.Fatalf("constraint: %+v", f) + } + // Risk and caution lower the score. + cautious := Form(FormationInput{Want: w, Now: now, Action: restartAction("ask"), Values: map[string]float64{"caution": 1}, Operator: true}) + bold := Form(FormationInput{Want: w, Now: now, Action: restartAction("ask"), Values: map[string]float64{"caution": 0}, Operator: true}) + if cautious.Score >= bold.Score { + t.Fatalf("caution ignored: %v vs %v", cautious.Score, bold.Score) + } +} + +func TestFormationEdges(t *testing.T) { + inv := want("understand the failure", 0.8, 0.4, "approach") + inv.Proposal = &Proposal{Kind: "investigate", Subject: "atlas"} + if f := Form(FormationInput{Want: inv, Now: now, ReadCapabilities: 0}); f.Outcome != NotFormed || f.Reason != ReasonNoPlan { + t.Fatalf("want with no possible plan: %+v", f) + } + if f := Form(FormationInput{Want: inv, Now: now, ReadCapabilities: 3}); f.Outcome != Formed || f.Kind != IntentInvestigate { + t.Fatalf("investigate: %+v", f) + } + faint := inv + faint.Strength, faint.Urgency = 0.1, 0 + if f := Form(FormationInput{Want: faint, Now: now, ReadCapabilities: 3}); f.Outcome != NotFormed || f.Reason != ReasonBelowThreshold { + t.Fatalf("threshold: %+v", f) + } + dup := Intention{ID: ids.New(), SourceWants: []ids.ID{inv.ID}, Status: IntentionActive} + if f := Form(FormationInput{Want: inv, Now: now, ReadCapabilities: 3, Active: []Intention{dup}}); f.Reason != ReasonDuplicate { + t.Fatalf("duplicate: %+v", f) + } + full := []Intention{{ID: ids.New(), Status: IntentionActive, Priority: 0.9}, {ID: ids.New(), Status: IntentionActive, Priority: 0.2}} + if f := Form(FormationInput{Want: inv, Now: now, ReadCapabilities: 3, Active: full, MaxActive: 2}); f.Outcome != Formed || f.Displace != full[1].ID { + t.Fatalf("displacement: %+v", f) + } + full[1].Priority = 0.95 + if f := Form(FormationInput{Want: inv, Now: now, ReadCapabilities: 3, Active: full, MaxActive: 2}); f.Reason != ReasonCapacity { + t.Fatalf("capacity: %+v", f) + } + if f := Form(FormationInput{Want: inv, Now: now, ReadCapabilities: 3, Deferred: true}); f.Reason != ReasonDeferred || !f.Suppress { + t.Fatalf("deferred: %+v", f) + } +} + +func TestCommitmentChecks(t *testing.T) { + due := now.Add(90 * time.Minute) + report := Commitment{ID: ids.New(), Subject: "report", Description: "deliver the report to Andy today", Kind: KindPromise, Polarity: "do", Strength: 0.9, Priority: 0.8, Status: "active", DueAt: &due, CreatedAt: now.Add(-time.Hour), + Matcher: Matcher{Capabilities: []string{"send_email"}}} + if Assess(report, now) != StateDue { + t.Fatal("not due inside the window") + } + if Assess(report, now.Add(2*time.Hour)) != StateOverdue { + t.Fatal("not overdue after grace") + } + noted := report + noted.DueNotifiedAt = &now + if Assess(noted, now) != StateFine { + t.Fatal("due raised twice") + } + if why := Impossible(report, map[string]string{"send_email": "deny"}, now); why == "" { + t.Fatal("impossible commitment not detected") + } + if why := Impossible(report, map[string]string{"send_email": "ask"}, now); why != "" { + t.Fatalf("possible commitment flagged: %s", why) + } + noEmail := Commitment{ID: ids.New(), Subject: "email", Description: "never send email", Kind: KindOperatorInstruction, Polarity: "refrain", Matcher: Matcher{Capabilities: []string{"send_*"}}, Status: "active", Strength: 1, Priority: 1} + cs := CommitmentConflicts([]Commitment{report, noEmail, noProd}, now) + if len(cs) != 1 || cs[0].A.ID != report.ID { + t.Fatalf("conflicts: %+v", cs) + } + if noProd.Weight() <= (Commitment{Kind: KindPriorDecision, Strength: 1, Priority: 1}).Weight() { + t.Fatal("operator instruction should weigh more than a prior decision") + } + if (Commitment{Polarity: "refrain", Status: "active"}).Matches(*restartAction("allow")) { + t.Fatal("empty matcher matched") + } +} + +func TestCompanions(t *testing.T) { + w := want("restart", 0.9, 0.9, "approach") + c := Companions(w, *restartAction("ask"), map[string]float64{"caution": 0.8}) + if len(c) != 1 || c[0].Valence != "avoid" || !strings.HasPrefix(c[0].Subject, "avoid:disruption:") { + t.Fatalf("caution companion: %+v", c) + } + logs := ActionInfo{Capability: "logs.query", Target: "atlas", Access: "read", Risk: "medium", SideEffects: "none; the output may contain personal data"} + if c := Companions(w, logs, map[string]float64{"privacy": 0.9, "caution": 1}); len(c) != 1 || !strings.HasPrefix(c[0].Subject, "avoid:exposure:") { + t.Fatalf("privacy companion: %+v", c) + } + if c := Companions(w, logs, map[string]float64{"privacy": 0.1}); len(c) != 0 { + t.Fatal("weak value produced a companion") + } +} + +func TestReconsider(t *testing.T) { + it := Intention{ID: ids.New(), Kind: IntentAct, Status: IntentionActive} + check := func(tr Trigger, rc ReconsiderContext, want string) { + t.Helper() + rc.Now = now + if got := Reconsider(it, tr, rc); got.Outcome != want || got.Reason == "" { + t.Fatalf("%s: got %+v, want %s", tr.Kind, got, want) + } + } + check(Trigger{Kind: TriggerApprovalDenied, Detail: "not during business hours"}, ReconsiderContext{}, Replan) + check(Trigger{Kind: TriggerApprovalDenied}, ReconsiderContext{Replans: 3}, Abandon) + check(Trigger{Kind: TriggerExpectationFailed, Detail: "error rate still 12%"}, ReconsiderContext{Replans: 1}, Replan) + check(Trigger{Kind: TriggerExpectationFailed}, ReconsiderContext{Replans: 3, MaxReplans: 3}, Abandon) + check(Trigger{Kind: TriggerCapabilityChanged, Action: restartAction("deny")}, ReconsiderContext{}, Replan) + check(Trigger{Kind: TriggerCapabilityChanged, Action: restartAction("ask")}, ReconsiderContext{}, Block) + check(Trigger{Kind: TriggerResourceExhausted, Detail: "capability invocations"}, ReconsiderContext{}, Suspend) + check(Trigger{Kind: TriggerPlanCompleted}, ReconsiderContext{}, Fulfil) + check(Trigger{Kind: TriggerGoalCompleted}, ReconsiderContext{}, Abandon) + check(Trigger{Kind: TriggerWorldChanged, OutcomeObserved: true, Detail: "atlas already healthy"}, ReconsiderContext{}, Fulfil) + check(Trigger{Kind: TriggerCommitmentChanged, Commitment: &noProd, Action: restartAction("allow")}, ReconsiderContext{}, Tighten) + never := noProd + never.Unless = "" + check(Trigger{Kind: TriggerCommitmentChanged, Commitment: &never, Action: restartAction("allow")}, ReconsiderContext{}, Abandon) + unrelated := never + unrelated.Matcher.Targets = []string{"db-*"} + check(Trigger{Kind: TriggerCommitmentChanged, Commitment: &unrelated, Action: restartAction("allow")}, ReconsiderContext{}, Keep) + it.Status = IntentionBlocked + check(Trigger{Kind: TriggerRequestGranted}, ReconsiderContext{}, Resume) +} diff --git a/internal/volition/store.go b/internal/volition/store.go new file mode 100644 index 0000000..d5fb7c8 --- /dev/null +++ b/internal/volition/store.go @@ -0,0 +1,875 @@ +package volition + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + evstore "github.com/andydixon/noema/internal/events" + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/pkg/events" + "github.com/andydixon/noema/pkg/ids" +) + +// Store persists volitional state. Every change goes through a Recorder so +// its event and, where a choice was made, its decision row are written in +// the same transaction. +type Store struct{ db *pgxpool.Pool } + +// NewStore creates a store. +func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} } + +// DB exposes the pool for callers that record outside a transaction. +func (s *Store) DB() *pgxpool.Pool { return s.db } + +// ErrInvalid marks bad input; ErrClosed marks a transition from a closed state. +var ( + ErrInvalid = errors.New("volition: invalid") + ErrClosed = errors.New("volition: intention is closed") +) + +// DirectEmitter appends and publishes immediately. Use it only with the pool, +// never inside a transaction (subscribers must not see uncommitted events). +func DirectEmitter(es *evstore.Store) Emitter { + return func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) { + evs, err := es.AppendIn(ctx, q, n) + if err != nil { + return events.Event{}, err + } + es.Publish(evs...) + return evs[0], nil + } +} + +// Direct returns a recorder on the pool that publishes as it goes. +func (s *Store) Direct(es *evstore.Store, source string) Recorder { + return Recorder{Q: s.db, Emit: DirectEmitter(es), Source: source} +} + +func (r Recorder) emit(ctx context.Context, mind ids.ID, typ events.Type, summary string, salience float64, evidence []events.EvidenceRef, meta map[string]any, payload any) (ids.ID, error) { + if r.Emit == nil { + return ids.Nil, nil + } + ev, err := r.Emit(ctx, r.Q, events.New{MindID: mind, Type: typ, Source: firstNonEmpty(r.Source, "volition"), Summary: summary, Salience: salience, Confidence: 1, + CausationID: r.Cause, CorrelationID: r.Correlation, Evidence: evidence, Metadata: meta, Payload: payload, OccurredAt: r.Now}) + if err != nil { + return ids.Nil, err + } + return ev.ID, nil +} + +func nullID(id ids.ID) *string { + if id.IsZero() { + return nil + } + v := string(id) + return &v +} + +func idStrings(in []ids.ID) []string { + out := make([]string, 0, len(in)) + for _, id := range in { + if !id.IsZero() { + out = append(out, string(id)) + } + } + return out +} + +func toIDs(in []string) []ids.ID { + out := make([]ids.ID, len(in)) + for i, s := range in { + out[i] = ids.ID(s) + } + return out +} + +func setID(dst *ids.ID, src *string) { + if src != nil { + *dst = ids.ID(*src) + } +} + +func jsonOr(v any, empty string) []byte { + b, err := json.Marshal(v) + if err != nil || string(b) == "null" { + return []byte(empty) + } + return b +} + +// ---- wants ---- + +const wantCols = `id, mind_id, subject, description, origin_type, origin_event, origin_ref, strength, valence, urgency, status, drives, "values", goal_id, commitment_id, proposal, terms, reinforcements, suppressions, last_reinforced_at, resolution, resolved_at, created_at, updated_at` + +func scanWant(row pgx.Row) (Want, error) { + var w Want + var origin, status string + var originEv, goal, commitment *string + var proposal, terms []byte + if err := row.Scan(&w.ID, &w.MindID, &w.Subject, &w.Description, &origin, &originEv, &w.OriginRef, &w.Strength, &w.Valence, &w.Urgency, &status, &w.Drives, &w.Values, &goal, &commitment, &proposal, &terms, + &w.Reinforcements, &w.Suppressions, &w.LastReinforcedAt, &w.Resolution, &w.ResolvedAt, &w.CreatedAt, &w.UpdatedAt); err != nil { + return w, storage.Translate(err) + } + w.Origin, w.Status = Origin(origin), WantStatus(status) + setID(&w.OriginEvent, originEv) + setID(&w.GoalID, goal) + setID(&w.CommitmentID, commitment) + if len(proposal) > 0 && string(proposal) != "null" { + w.Proposal = &Proposal{} + json.Unmarshal(proposal, w.Proposal) //nolint:errcheck + } + json.Unmarshal(terms, &w.Terms) //nolint:errcheck + return w, nil +} + +// UpsertWant creates a want from a candidate or, when an active want with +// the same subject exists, reinforces it: identical wants refresh rather +// than accumulate. It returns the want and whether it was new. +func (s *Store) UpsertWant(ctx context.Context, r Recorder, mind ids.ID, c Candidate) (Want, bool, error) { + now := r.now() + c.Subject, c.Description = strings.TrimSpace(c.Subject), strings.TrimSpace(c.Description) + if mind.IsZero() || c.Subject == "" || c.Description == "" || len(c.Subject) > 300 { + return Want{}, false, fmt.Errorf("%w: want needs a subject (≤300 chars) and a description", ErrInvalid) + } + c.Strength, c.Urgency = clamp01(c.Strength), clamp01(c.Urgency) + if c.Valence != "avoid" { + c.Valence = "approach" + } + existing, err := scanWant(r.Q.QueryRow(ctx, `SELECT `+wantCols+` FROM wants WHERE mind_id=$1 AND subject=$2 AND status='active'`, mind, c.Subject)) + switch { + case err == nil: + eff := existing.Effective(now) + next := round3(clamp01(maxf(eff, c.Strength) + 0.1*(1-maxf(eff, c.Strength)))) + urgency := maxf(existing.Urgency, c.Urgency) + if _, err := r.Q.Exec(ctx, `UPDATE wants SET strength=$2, urgency=$3, reinforcements=reinforcements+1, last_reinforced_at=$4, updated_at=$4 WHERE id=$1`, existing.ID, next, urgency, now); err != nil { + return Want{}, false, err + } + if next-eff >= 0.05 { + if _, err := r.emit(ctx, mind, events.WantReinforced, fmt.Sprintf("want reinforced (%.2f → %.2f): %s", eff, next, existing.Description), 0.2, + []events.EvidenceRef{{Kind: "want", ID: string(existing.ID)}}, map[string]any{"want_id": string(existing.ID), "from": eff, "to": next, "origin_event": string(c.OriginEvent)}, map[string]any{"candidate_terms": c.Terms}); err != nil { + return Want{}, false, err + } + } + existing.Strength, existing.Urgency, existing.Reinforcements, existing.LastReinforcedAt = next, urgency, existing.Reinforcements+1, now + return existing, false, nil + case !errors.Is(err, storage.ErrNotFound): + return Want{}, false, err + } + w := Want{ID: ids.New(), MindID: mind, Subject: c.Subject, Description: trunc(c.Description, 500), Origin: c.Origin, OriginEvent: c.OriginEvent, OriginRef: c.OriginRef, Strength: c.Strength, Valence: c.Valence, Urgency: c.Urgency, + Status: WantActive, Drives: nonNil(c.Drives), Values: nonNil(c.Values), GoalID: c.GoalID, CommitmentID: c.CommitmentID, Proposal: c.Proposal, Terms: c.Terms, LastReinforcedAt: now, CreatedAt: now, UpdatedAt: now} + if w.Terms == nil { + w.Terms = map[string]float64{} + } + var proposal []byte + if w.Proposal != nil { + proposal, _ = json.Marshal(w.Proposal) + } + if _, err := r.Q.Exec(ctx, `INSERT INTO wants (id, mind_id, subject, description, origin_type, origin_event, origin_ref, strength, valence, urgency, status, drives, "values", goal_id, commitment_id, proposal, terms, last_reinforced_at, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'active',$11,$12,$13,$14,$15,$16,$17,$17,$17)`, + w.ID, mind, w.Subject, w.Description, string(w.Origin), nullID(w.OriginEvent), w.OriginRef, w.Strength, w.Valence, w.Urgency, w.Drives, w.Values, nullID(w.GoalID), nullID(w.CommitmentID), proposal, jsonOr(w.Terms, "{}"), now); err != nil { + return Want{}, false, storage.Translate(err) + } + evidence := []events.EvidenceRef{{Kind: "want", ID: string(w.ID)}} + if !w.OriginEvent.IsZero() { + evidence = append(evidence, events.EvidenceRef{Kind: "event", ID: string(w.OriginEvent), Note: "origin"}) + } + if !w.GoalID.IsZero() { + evidence = append(evidence, events.EvidenceRef{Kind: "goal", ID: string(w.GoalID)}) + } + if _, err := r.emit(ctx, mind, events.WantCreated, fmt.Sprintf("wants (%.2f, %s): %s", w.Strength, w.Origin, w.Description), 0.25+0.3*w.Strength, evidence, + map[string]any{"want_id": string(w.ID), "origin": string(w.Origin), "subject": w.Subject, "valence": w.Valence, "strength": w.Strength}, w); err != nil { + return Want{}, false, err + } + return w, true, nil +} + +func nonNil(xs []string) []string { + if xs == nil { + return []string{} + } + return xs +} + +// Wants lists a mind's wants by status (empty = all), newest first. +func (s *Store) Wants(ctx context.Context, q storage.Querier, mind ids.ID, statuses []WantStatus, limit int) ([]Want, error) { + if q == nil { + q = s.db + } + if limit <= 0 || limit > 1000 { + limit = 200 + } + st := make([]string, len(statuses)) + for i, x := range statuses { + st[i] = string(x) + } + rows, err := q.Query(ctx, `SELECT `+wantCols+` FROM wants WHERE mind_id=$1 AND (cardinality($2::text[])=0 OR status=ANY($2)) ORDER BY created_at DESC LIMIT $3`, mind, st, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Want + for rows.Next() { + w, err := scanWant(rows) + if err != nil { + return nil, err + } + out = append(out, w) + } + return out, rows.Err() +} + +// GetWant fetches one want of a mind. +func (s *Store) GetWant(ctx context.Context, q storage.Querier, mind, id ids.ID) (Want, error) { + if q == nil { + q = s.db + } + return scanWant(q.QueryRow(ctx, `SELECT `+wantCols+` FROM wants WHERE id=$1 AND mind_id=$2`, id, mind)) +} + +// ResolveWant closes an active want (satisfied, abandoned, expired, superseded). +func (s *Store) ResolveWant(ctx context.Context, r Recorder, mind, id ids.ID, status WantStatus, resolution string) error { + if status == WantActive { + return fmt.Errorf("%w: resolving to active", ErrInvalid) + } + now := r.now() + tag, err := r.Q.Exec(ctx, `UPDATE wants SET status=$3, resolution=$4, resolved_at=$5, updated_at=$5 WHERE id=$1 AND mind_id=$2 AND status='active'`, id, mind, string(status), trunc(resolution, 500), now) + if err != nil || tag.RowsAffected() == 0 { + return err + } + return nil +} + +// SuppressWant records that an active want was not pursued, and why. The +// want stays active and represented; its suppression count rises. +func (s *Store) SuppressWant(ctx context.Context, r Recorder, w Want, reason string, commitments []ids.ID) error { + if _, err := r.Q.Exec(ctx, `UPDATE wants SET suppressions=suppressions+1, updated_at=$2 WHERE id=$1`, w.ID, r.now()); err != nil { + return err + } + evidence := []events.EvidenceRef{{Kind: "want", ID: string(w.ID)}} + for _, c := range commitments { + evidence = append(evidence, events.EvidenceRef{Kind: "commitment", ID: string(c)}) + } + _, err := r.emit(ctx, w.MindID, events.WantSuppressed, "want not pursued: "+w.Description+" — "+reason, 0.35, evidence, + map[string]any{"want_id": string(w.ID), "suppressions": w.Suppressions + 1, "commitments": idStrings(commitments)}, map[string]any{"reason": reason}) + return err +} + +// ExpireWants closes active wants whose strength decayed below the floor +// and, beyond the cap, the weakest ones. It returns how many were closed. +func (s *Store) ExpireWants(ctx context.Context, r Recorder, mind ids.ID, maxActive int) (int, error) { + if maxActive <= 0 { + maxActive = 40 + } + now := r.now() + ws, err := s.Wants(ctx, r.Q, mind, []WantStatus{WantActive}, 1000) + if err != nil { + return 0, err + } + SortWants(ws, now) + n := 0 + for i, w := range ws { + why := "" + switch { + case w.Effective(now) < 0.1: + why = "faded: not reinforced for " + now.Sub(w.LastReinforcedAt).Truncate(time.Minute).String() + case i >= maxActive: + why = "displaced by stronger wants (more than " + fmt.Sprint(maxActive) + " active)" + } + if why == "" { + continue + } + if err := s.ResolveWant(ctx, r, mind, w.ID, WantExpired, why); err != nil { + return n, err + } + n++ + } + return n, nil +} + +// ---- conflicts ---- + +const conflictCols = `id, mind_id, want_a, want_b, kind, status, preferred, resolution, rationale, detected_event, resolved_at, created_at` + +func scanConflict(row pgx.Row) (Conflict, error) { + var c Conflict + var pref, det *string + var rat []byte + if err := row.Scan(&c.ID, &c.MindID, &c.WantA, &c.WantB, &c.Kind, &c.Status, &pref, &c.Resolution, &rat, &det, &c.ResolvedAt, &c.CreatedAt); err != nil { + return c, storage.Translate(err) + } + setID(&c.Preferred, pref) + setID(&c.DetectedEvent, det) + json.Unmarshal(rat, &c.Rationale) //nolint:errcheck + return c, nil +} + +// RecordConflict stores a resolved conflict between two wants once per open +// pair, with the decision and a want_conflict_detected event. Neither want is +// discarded. It returns the conflict and whether it was new. +func (s *Store) RecordConflict(ctx context.Context, r Recorder, a, b Want, kind string, res Resolution) (Conflict, bool, error) { + existing, err := scanConflict(r.Q.QueryRow(ctx, `SELECT `+conflictCols+` FROM want_conflicts WHERE mind_id=$1 AND status='open' AND least(want_a,want_b)=least($2::uuid,$3::uuid) AND greatest(want_a,want_b)=greatest($2::uuid,$3::uuid)`, a.MindID, a.ID, b.ID)) + if err == nil { + return existing, false, nil + } + if !errors.Is(err, storage.ErrNotFound) { + return Conflict{}, false, err + } + now := r.now() + c := Conflict{ID: ids.New(), MindID: a.MindID, WantA: a.ID, WantB: b.ID, Kind: kind, Status: "open", Preferred: res.Preferred, Resolution: res.Outcome, Rationale: res.Rationale, CreatedAt: now} + preferred, other := a, b + if res.Preferred == b.ID { + preferred, other = b, a + } + summary := fmt.Sprintf("wants conflict (%s): %q vs %q — %s %q", kind, a.Description, b.Description, map[string]string{"preferred": "preferred", "preferred_with_constraint": "preferred, with constraint,", "deferred": "deferred in favour of"}[res.Outcome], preferred.Description) + evID, err := r.emit(ctx, a.MindID, events.WantConflictDetected, summary, 0.5, []events.EvidenceRef{{Kind: "want", ID: string(a.ID)}, {Kind: "want", ID: string(b.ID)}}, + map[string]any{"conflict_id": string(c.ID), "want_a": string(a.ID), "want_b": string(b.ID), "preferred": string(res.Preferred), "outcome": res.Outcome, "kind": kind}, c) + if err != nil { + return Conflict{}, false, err + } + c.DetectedEvent = evID + if _, err := r.Q.Exec(ctx, `INSERT INTO want_conflicts (id, mind_id, want_a, want_b, kind, status, preferred, resolution, rationale, detected_event, created_at) VALUES ($1,$2,$3,$4,$5,'open',$6,$7,$8,$9,$10)`, + c.ID, c.MindID, a.ID, b.ID, kind, nullID(c.Preferred), c.Resolution, jsonOr(c.Rationale, "{}"), nullID(evID), now); err != nil { + return Conflict{}, false, storage.Translate(err) + } + rule, _ := res.Rationale["rule"].(string) + if _, err := s.RecordDecision(ctx, r.Q, Decision{MindID: a.MindID, Kind: "conflict_resolution", Outcome: res.Outcome, Reason: kind, Rationale: rule, WantID: preferred.ID, ConflictID: c.ID, EventID: evID, + Inputs: map[string]any{"preferred": preferred.ID, "other": other.ID, "rationale": res.Rationale}, CreatedAt: now}); err != nil { + return Conflict{}, false, err + } + return c, true, nil +} + +// SettleConflicts marks open conflicts involving a closed want as dissolved. +func (s *Store) SettleConflicts(ctx context.Context, q storage.Querier, mind ids.ID, now time.Time) error { + _, err := q.Exec(ctx, `UPDATE want_conflicts c SET status='dissolved', resolved_at=$2 WHERE c.mind_id=$1 AND c.status='open' + AND EXISTS (SELECT 1 FROM wants w WHERE w.id IN (c.want_a, c.want_b) AND w.status <> 'active')`, mind, now) + return err +} + +// Conflicts lists a mind's want conflicts (status empty = all), newest first. +func (s *Store) Conflicts(ctx context.Context, mind ids.ID, status string, limit int) ([]Conflict, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := s.db.Query(ctx, `SELECT `+conflictCols+` FROM want_conflicts WHERE mind_id=$1 AND ($2='' OR status=$2) ORDER BY created_at DESC LIMIT $3`, mind, status, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Conflict + for rows.Next() { + c, err := scanConflict(rows) + if err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// ---- commitments ---- + +// NewCommitment describes a commitment to create. +type NewCommitment struct { + MindID ids.ID + Subject string + Description string + Kind CommitmentKind + Polarity string + Matcher Matcher + Unless string + Beneficiary string + Origin string + OriginEvent ids.ID + OriginRef string + Strength float64 + Priority float64 + Conditions string + DueAt *time.Time + ExpiresAt *time.Time + Revocable *bool + GoalID ids.ID + IntentionID ids.ID + Evidence []events.EvidenceRef + CreatedBy string +} + +const commitmentCols = `id, mind_id, subject, description, kind, polarity, matcher, unless, beneficiary, origin_type, origin_event, origin_ref, strength, priority, conditions, due_at, expires_at, revocable, status, superseded_by, goal_id, intention_id, evidence, due_notified_at, created_by, status_reason, closed_at, created_at, updated_at` + +func scanCommitment(row pgx.Row) (Commitment, error) { + var c Commitment + var kind string + var matcher, evidence []byte + var originEv, sup, goal, intention *string + if err := row.Scan(&c.ID, &c.MindID, &c.Subject, &c.Description, &kind, &c.Polarity, &matcher, &c.Unless, &c.Beneficiary, &c.Origin, &originEv, &c.OriginRef, &c.Strength, &c.Priority, &c.Conditions, + &c.DueAt, &c.ExpiresAt, &c.Revocable, &c.Status, &sup, &goal, &intention, &evidence, &c.DueNotifiedAt, &c.CreatedBy, &c.StatusReason, &c.ClosedAt, &c.CreatedAt, &c.UpdatedAt); err != nil { + return c, storage.Translate(err) + } + c.Kind = CommitmentKind(kind) + setID(&c.OriginEvent, originEv) + setID(&c.SupersededBy, sup) + setID(&c.GoalID, goal) + setID(&c.IntentionID, intention) + json.Unmarshal(matcher, &c.Matcher) //nolint:errcheck + json.Unmarshal(evidence, &c.Evidence) //nolint:errcheck + if c.Evidence == nil { + c.Evidence = []events.EvidenceRef{} + } + return c, nil +} + +// CreateCommitment validates and stores a commitment. A new commitment with +// the same subject, kind and polarity supersedes the older one, which is kept +// as history. +func (s *Store) CreateCommitment(ctx context.Context, r Recorder, n NewCommitment) (Commitment, error) { + now := r.now() + n.Subject, n.Description = strings.TrimSpace(n.Subject), strings.TrimSpace(n.Description) + if n.MindID.IsZero() || n.Description == "" || len(n.Description) > 1000 || len(n.Subject) > 300 { + return Commitment{}, fmt.Errorf("%w: a commitment needs a description (≤1000 chars)", ErrInvalid) + } + if n.Subject == "" { + n.Subject = strings.ToLower(trunc(n.Description, 120)) + } + if !ValidKind(n.Kind) { + return Commitment{}, fmt.Errorf("%w: unknown commitment kind %q", ErrInvalid, n.Kind) + } + if n.Polarity != "do" && n.Polarity != "refrain" { + return Commitment{}, fmt.Errorf("%w: polarity must be do or refrain", ErrInvalid) + } + if n.Unless != "" && (n.Unless != "approval" || n.Polarity != "refrain") { + return Commitment{}, fmt.Errorf("%w: only refrain commitments may have unless=approval", ErrInvalid) + } + if n.Matcher.Access != "" && n.Matcher.Access != "read" && n.Matcher.Access != "write" { + return Commitment{}, fmt.Errorf("%w: matcher access must be read, write or empty", ErrInvalid) + } + if n.Matcher.MinRisk != "" && riskLevel[n.Matcher.MinRisk] == 0 { + return Commitment{}, fmt.Errorf("%w: unknown min_risk", ErrInvalid) + } + if n.Strength <= 0 || n.Strength > 1 { + n.Strength = 0.8 + } + if n.Priority <= 0 || n.Priority > 1 { + n.Priority = 0.5 + } + revocable := true + if n.Revocable != nil { + revocable = *n.Revocable + } + if n.Origin == "" { + n.Origin = "operator" + } + if n.Evidence == nil { + n.Evidence = []events.EvidenceRef{} + } + c := Commitment{ID: ids.New(), MindID: n.MindID, Subject: n.Subject, Description: n.Description, Kind: n.Kind, Polarity: n.Polarity, Matcher: n.Matcher, Unless: n.Unless, Beneficiary: n.Beneficiary, Origin: n.Origin, + OriginEvent: n.OriginEvent, OriginRef: n.OriginRef, Strength: round3(n.Strength), Priority: round3(n.Priority), Conditions: n.Conditions, DueAt: n.DueAt, ExpiresAt: n.ExpiresAt, Revocable: revocable, Status: "active", + GoalID: n.GoalID, IntentionID: n.IntentionID, Evidence: n.Evidence, CreatedBy: n.CreatedBy, CreatedAt: now, UpdatedAt: now} + if _, err := r.Q.Exec(ctx, `INSERT INTO commitments (id, mind_id, subject, description, kind, polarity, matcher, unless, beneficiary, origin_type, origin_event, origin_ref, strength, priority, conditions, due_at, expires_at, revocable, status, goal_id, intention_id, evidence, created_by, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,'active',$19,$20,$21,$22,$23,$23)`, + c.ID, c.MindID, c.Subject, c.Description, string(c.Kind), c.Polarity, jsonOr(c.Matcher, "{}"), c.Unless, c.Beneficiary, c.Origin, nullID(c.OriginEvent), c.OriginRef, c.Strength, c.Priority, c.Conditions, c.DueAt, c.ExpiresAt, c.Revocable, + nullID(c.GoalID), nullID(c.IntentionID), jsonOr(c.Evidence, "[]"), c.CreatedBy, now); err != nil { + return Commitment{}, storage.Translate(err) + } + tag, err := r.Q.Exec(ctx, `UPDATE commitments SET status='superseded', superseded_by=$1, status_reason='superseded by a newer commitment on the same subject', closed_at=$2, updated_at=$2 + WHERE mind_id=$3 AND id<>$1 AND status='active' AND lower(subject)=lower($4) AND kind=$5 AND polarity=$6`, c.ID, now, c.MindID, c.Subject, string(c.Kind), c.Polarity) + if err != nil { + return Commitment{}, err + } + verb := map[string]string{"do": "committed to", "refrain": "committed not to"}[c.Polarity] + summary := fmt.Sprintf("%s: %s (%s, weight %.2f)", verb, c.Description, c.Kind, c.Weight()) + if tag.RowsAffected() > 0 { + summary += fmt.Sprintf("; supersedes %d earlier commitment(s)", tag.RowsAffected()) + } + if _, err := r.emit(ctx, c.MindID, events.CommitmentCreated, summary, 0.4+0.3*c.Weight(), append([]events.EvidenceRef{{Kind: "commitment", ID: string(c.ID)}}, c.Evidence...), + map[string]any{"commitment_id": string(c.ID), "kind": string(c.Kind), "polarity": c.Polarity, "unless": c.Unless, "created_by": c.CreatedBy, "superseded": tag.RowsAffected()}, c); err != nil { + return Commitment{}, err + } + return c, nil +} + +// Commitments lists a mind's commitments by status (empty = all). +func (s *Store) Commitments(ctx context.Context, q storage.Querier, mind ids.ID, statuses []string, limit int) ([]Commitment, error) { + if q == nil { + q = s.db + } + if limit <= 0 || limit > 1000 { + limit = 200 + } + rows, err := q.Query(ctx, `SELECT `+commitmentCols+` FROM commitments WHERE mind_id=$1 AND (cardinality($2::text[])=0 OR status=ANY($2)) ORDER BY created_at DESC LIMIT $3`, mind, nonNil(statuses), limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Commitment + for rows.Next() { + c, err := scanCommitment(rows) + if err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// GetCommitment fetches one commitment of a mind. +func (s *Store) GetCommitment(ctx context.Context, q storage.Querier, mind, id ids.ID) (Commitment, error) { + if q == nil { + q = s.db + } + return scanCommitment(q.QueryRow(ctx, `SELECT `+commitmentCols+` FROM commitments WHERE id=$1 AND mind_id=$2`, id, mind)) +} + +// ErrNotRevocable is returned when cognition tries to revoke a commitment +// that only an operator may revoke. +var ErrNotRevocable = errors.New("volition: this commitment can only be revoked by an operator") + +// CloseCommitment ends an active commitment as fulfilled, broken, revoked or +// expired. Non-revocable commitments can be revoked only by an operator. +func (s *Store) CloseCommitment(ctx context.Context, r Recorder, mind, id ids.ID, status, reason string, byOperator bool) (Commitment, error) { + typ := map[string]events.Type{"fulfilled": events.CommitmentFulfilled, "broken": events.CommitmentBroken, "revoked": events.CommitmentRevoked, "expired": events.CommitmentRevoked}[status] + if typ == "" { + return Commitment{}, fmt.Errorf("%w: status must be fulfilled, broken, revoked or expired", ErrInvalid) + } + c, err := s.GetCommitment(ctx, r.Q, mind, id) + if err != nil { + return c, err + } + if c.Status != "active" { + return c, fmt.Errorf("%w: commitment is %s", ErrInvalid, c.Status) + } + if status == "revoked" && !c.Revocable && !byOperator { + return c, ErrNotRevocable + } + now := r.now() + if _, err := r.Q.Exec(ctx, `UPDATE commitments SET status=$3, status_reason=$4, closed_at=$5, updated_at=$5 WHERE id=$1 AND mind_id=$2 AND status='active'`, id, mind, status, trunc(reason, 500), now); err != nil { + return c, err + } + c.Status, c.StatusReason, c.ClosedAt = status, reason, &now + if _, err := r.emit(ctx, mind, typ, fmt.Sprintf("commitment %s: %s — %s", status, c.Description, reason), map[string]float64{"broken": 0.7}[status]+0.3, []events.EvidenceRef{{Kind: "commitment", ID: string(id)}}, + map[string]any{"commitment_id": string(id), "status": status}, map[string]any{"reason": reason}); err != nil { + return c, err + } + return c, nil +} + +// MarkDue records that a commitment entered its due window and emits commitment_due once. +func (s *Store) MarkDue(ctx context.Context, r Recorder, c Commitment) error { + now := r.now() + tag, err := r.Q.Exec(ctx, `UPDATE commitments SET due_notified_at=$2, updated_at=$2 WHERE id=$1 AND due_notified_at IS NULL`, c.ID, now) + if err != nil || tag.RowsAffected() == 0 { + return err + } + _, err = r.emit(ctx, c.MindID, events.CommitmentDue, fmt.Sprintf("commitment due %s: %s", c.DueAt.Format(time.RFC3339), c.Description), 0.6, []events.EvidenceRef{{Kind: "commitment", ID: string(c.ID)}}, + map[string]any{"commitment_id": string(c.ID), "due_at": c.DueAt}, nil) + return err +} + +// ---- intentions ---- + +const intentionCols = `id, mind_id, parent_id, description, desired_outcome, kind, source_wants, goal_id, commitment_id, belief_ids, expected_result, confidence, priority, status, blocked_kind, blocked_reason, required_capabilities, require_approval, proposal, plan_id, superseded_by, formed_event, last_event, formation, reconsiderations, status_reason, closed_at, created_at, updated_at` + +func scanIntention(row pgx.Row) (Intention, error) { + var it Intention + var parent, goal, commitment, plan, sup, formed, last *string + var kind, status string + var wants, beliefs []string + var proposal, formation []byte + if err := row.Scan(&it.ID, &it.MindID, &parent, &it.Description, &it.DesiredOutcome, &kind, &wants, &goal, &commitment, &beliefs, &it.ExpectedResult, &it.Confidence, &it.Priority, &status, &it.BlockedKind, &it.BlockedReason, + &it.RequiredCapabilities, &it.RequireApproval, &proposal, &plan, &sup, &formed, &last, &formation, &it.Reconsiderations, &it.StatusReason, &it.ClosedAt, &it.CreatedAt, &it.UpdatedAt); err != nil { + return it, storage.Translate(err) + } + it.Kind, it.Status = IntentionKind(kind), IntentionStatus(status) + it.SourceWants, it.BeliefIDs = toIDs(wants), toIDs(beliefs) + for _, x := range []struct { + dst *ids.ID + src *string + }{{&it.ParentID, parent}, {&it.GoalID, goal}, {&it.CommitmentID, commitment}, {&it.PlanID, plan}, {&it.SupersededBy, sup}, {&it.FormedEvent, formed}, {&it.LastEvent, last}} { + setID(x.dst, x.src) + } + if len(proposal) > 0 && string(proposal) != "null" { + it.Proposal = &Proposal{} + json.Unmarshal(proposal, it.Proposal) //nolint:errcheck + } + json.Unmarshal(formation, &it.Formation) //nolint:errcheck + return it, nil +} + +// CreateIntention stores a formed intention and emits intention_formed with +// its provenance: source wants, goal, commitment, beliefs and the formation +// terms. +func (s *Store) CreateIntention(ctx context.Context, r Recorder, it Intention) (Intention, error) { + now := r.now() + it.Description = strings.TrimSpace(it.Description) + if it.MindID.IsZero() || it.Description == "" { + return Intention{}, fmt.Errorf("%w: intention needs a mind and a description", ErrInvalid) + } + if it.Kind == "" { + it.Kind = IntentPursue + } + if it.Status == "" { + it.Status = IntentionActive + } + it.ID, it.CreatedAt, it.UpdatedAt = ids.New(), now, now + it.Confidence, it.Priority = clamp01(it.Confidence), clamp01(it.Priority) + if it.Formation == nil { + it.Formation = map[string]any{} + } + if it.RequiredCapabilities == nil { + it.RequiredCapabilities = []string{} + } + evidence := []events.EvidenceRef{{Kind: "intention", ID: string(it.ID)}} + for _, w := range it.SourceWants { + evidence = append(evidence, events.EvidenceRef{Kind: "want", ID: string(w), Note: "source want"}) + } + if !it.GoalID.IsZero() { + evidence = append(evidence, events.EvidenceRef{Kind: "goal", ID: string(it.GoalID)}) + } + if !it.CommitmentID.IsZero() { + evidence = append(evidence, events.EvidenceRef{Kind: "commitment", ID: string(it.CommitmentID)}) + } + for _, b := range it.BeliefIDs { + evidence = append(evidence, events.EvidenceRef{Kind: "belief", ID: string(b), Note: "supporting belief"}) + } + if !it.ParentID.IsZero() { + evidence = append(evidence, events.EvidenceRef{Kind: "intention", ID: string(it.ParentID), Note: "parent intention"}) + } + summary := "intends to " + it.Description + if it.Status == IntentionBlocked { + summary += " (blocked: " + it.BlockedReason + ")" + } + evID, err := r.emit(ctx, it.MindID, events.IntentionFormed, summary, 0.4+0.4*it.Priority, evidence, + map[string]any{"intention_id": string(it.ID), "kind": string(it.Kind), "priority": it.Priority, "parent_id": string(it.ParentID), "require_approval": it.RequireApproval, "want_ids": idStrings(it.SourceWants)}, it) + if err != nil { + return Intention{}, err + } + it.FormedEvent, it.LastEvent = evID, evID + var proposal []byte + if it.Proposal != nil { + proposal, _ = json.Marshal(it.Proposal) + } + if _, err := r.Q.Exec(ctx, `INSERT INTO intentions (id, mind_id, parent_id, description, desired_outcome, kind, source_wants, goal_id, commitment_id, belief_ids, expected_result, confidence, priority, status, blocked_kind, blocked_reason, + required_capabilities, require_approval, proposal, plan_id, formed_event, last_event, formation, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$21,$22,$23,$23)`, + it.ID, it.MindID, nullID(it.ParentID), trunc(it.Description, 500), trunc(it.DesiredOutcome, 500), string(it.Kind), idStrings(it.SourceWants), nullID(it.GoalID), nullID(it.CommitmentID), idStrings(it.BeliefIDs), trunc(it.ExpectedResult, 500), + it.Confidence, it.Priority, string(it.Status), it.BlockedKind, it.BlockedReason, it.RequiredCapabilities, it.RequireApproval, proposal, nullID(it.PlanID), nullID(evID), jsonOr(it.Formation, "{}"), now); err != nil { + return Intention{}, storage.Translate(err) + } + return it, nil +} + +// Intentions lists a mind's intentions by status (empty = all). +func (s *Store) Intentions(ctx context.Context, q storage.Querier, mind ids.ID, statuses []IntentionStatus, limit int) ([]Intention, error) { + if q == nil { + q = s.db + } + if limit <= 0 || limit > 1000 { + limit = 200 + } + st := make([]string, len(statuses)) + for i, x := range statuses { + st[i] = string(x) + } + rows, err := q.Query(ctx, `SELECT `+intentionCols+` FROM intentions WHERE mind_id=$1 AND (cardinality($2::text[])=0 OR status=ANY($2)) ORDER BY created_at DESC LIMIT $3`, mind, st, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Intention + for rows.Next() { + it, err := scanIntention(rows) + if err != nil { + return nil, err + } + out = append(out, it) + } + return out, rows.Err() +} + +// Open lists live intentions (proposed, active, suspended, blocked). +func (s *Store) Open(ctx context.Context, q storage.Querier, mind ids.ID) ([]Intention, error) { + return s.Intentions(ctx, q, mind, []IntentionStatus{IntentionProposed, IntentionActive, IntentionSuspended, IntentionBlocked}, 500) +} + +// GetIntention fetches one intention of a mind. +func (s *Store) GetIntention(ctx context.Context, q storage.Querier, mind, id ids.ID) (Intention, error) { + if q == nil { + q = s.db + } + return scanIntention(q.QueryRow(ctx, `SELECT `+intentionCols+` FROM intentions WHERE id=$1 AND mind_id=$2`, id, mind)) +} + +// GetIntentionAny fetches an intention by id alone (forensic lookups). +func (s *Store) GetIntentionAny(ctx context.Context, id ids.ID) (Intention, error) { + return scanIntention(s.db.QueryRow(ctx, `SELECT `+intentionCols+` FROM intentions WHERE id=$1`, id)) +} + +// SetPlan links an intention to its current plan. +func (s *Store) SetPlan(ctx context.Context, q storage.Querier, mind, id, plan ids.ID) error { + _, err := q.Exec(ctx, `UPDATE intentions SET plan_id=$3, updated_at=now() WHERE id=$1 AND mind_id=$2`, id, mind, nullID(plan)) + return err +} + +// Apply records a reconsideration outcome as a status transition with its +// event and decision. History is never rewritten: the earlier state stays in +// the event log and the decision ledger. +func (s *Store) Apply(ctx context.Context, r Recorder, mind, id ids.ID, rec Reconsideration, t Trigger) (Intention, error) { + it, err := s.GetIntention(ctx, r.Q, mind, id) + if err != nil { + return it, err + } + if !it.Status.Open() { + return it, fmt.Errorf("%w (%s)", ErrClosed, it.Status) + } + now := r.now() + next, typ := it.Status, events.IntentionReconsidered + blockedKind, blockedReason := it.BlockedKind, it.BlockedReason + requireApproval, kind := it.RequireApproval, it.Kind + var closed *time.Time + switch rec.Outcome { + case Keep: + return it, nil + case Block: + next, typ, blockedKind, blockedReason = IntentionBlocked, events.IntentionBlocked, firstNonEmpty(rec.BlockedKind, BlockPrereq), rec.Reason + case Suspend: + next, typ = IntentionSuspended, events.IntentionSuspended + if rec.BlockedKind != "" { + blockedKind, blockedReason = rec.BlockedKind, rec.Reason + } + case Resume: + next, typ, blockedKind, blockedReason = IntentionActive, events.IntentionResumed, "", "" + case Abandon: + next, typ, closed = IntentionAbandoned, events.IntentionAbandoned, &now + case Fulfil: + next, typ, closed, blockedKind, blockedReason = IntentionFulfilled, events.IntentionFulfilled, &now, "", "" + case Replan: + next, blockedKind, blockedReason = IntentionActive, "", "" + case Tighten: + requireApproval, kind = true, IntentRequestApproval + default: + return it, fmt.Errorf("%w: unknown outcome %q", ErrInvalid, rec.Outcome) + } + verb := map[events.Type]string{events.IntentionBlocked: "blocked", events.IntentionSuspended: "suspended", events.IntentionResumed: "resumed", events.IntentionAbandoned: "abandoned", events.IntentionFulfilled: "fulfilled", events.IntentionReconsidered: "reconsidering (" + rec.Outcome + ")"}[typ] + evidence := []events.EvidenceRef{{Kind: "intention", ID: string(it.ID)}} + if !t.EventID.IsZero() { + evidence = append(evidence, events.EvidenceRef{Kind: "event", ID: string(t.EventID), Note: "trigger: " + t.Kind}) + } + rr := r + if !t.EventID.IsZero() { + rr.Cause = t.EventID + } + evID, err := rr.emit(ctx, mind, typ, fmt.Sprintf("intention %s: %s — %s", verb, it.Description, rec.Reason), 0.45, evidence, + map[string]any{"intention_id": string(it.ID), "from": string(it.Status), "to": string(next), "outcome": rec.Outcome, "trigger": t.Kind, "blocked_kind": blockedKind, "plan_id": string(it.PlanID)}, map[string]any{"reason": rec.Reason, "trigger": t}) + if err != nil { + return it, err + } + if _, err := r.Q.Exec(ctx, `UPDATE intentions SET status=$3, blocked_kind=$4, blocked_reason=$5, require_approval=$6, kind=$7, reconsiderations=reconsiderations+1, status_reason=$8, last_event=$9, closed_at=coalesce($10, closed_at), updated_at=$11 WHERE id=$1 AND mind_id=$2`, + id, mind, string(next), blockedKind, trunc(blockedReason, 500), requireApproval, string(kind), trunc(rec.Reason, 500), nullID(evID), closed, now); err != nil { + return it, err + } + if _, err := s.RecordDecision(ctx, r.Q, Decision{MindID: mind, Kind: "reconsideration", Outcome: rec.Outcome, Reason: t.Kind, Rationale: rec.Reason, IntentionID: id, EventID: evID, CreatedAt: now, + Inputs: map[string]any{"from": it.Status, "to": next, "trigger": t}}); err != nil { + return it, err + } + if next == IntentionFulfilled { + for _, w := range it.SourceWants { + if err := s.ResolveWant(ctx, r, mind, w, WantSatisfied, "intention fulfilled: "+it.Description); err != nil { + return it, err + } + } + if !it.CommitmentID.IsZero() { + if _, err := s.CloseCommitment(ctx, r, mind, it.CommitmentID, "fulfilled", "intention fulfilled: "+it.Description, false); err != nil && !errors.Is(err, ErrInvalid) { + return it, err + } + } + } + return s.GetIntention(ctx, r.Q, mind, id) +} + +// Supersede closes an intention in favour of another. +func (s *Store) Supersede(ctx context.Context, r Recorder, mind, old, by ids.ID, reason string) error { + it, err := s.GetIntention(ctx, r.Q, mind, old) + if err != nil { + return err + } + if !it.Status.Open() { + return nil + } + now := r.now() + evID, err := r.emit(ctx, mind, events.IntentionSuperseded, "intention superseded: "+it.Description+" — "+reason, 0.4, []events.EvidenceRef{{Kind: "intention", ID: string(old)}, {Kind: "intention", ID: string(by), Note: "replacement"}}, + map[string]any{"intention_id": string(old), "superseded_by": string(by)}, map[string]any{"reason": reason}) + if err != nil { + return err + } + _, err = r.Q.Exec(ctx, `UPDATE intentions SET status='superseded', superseded_by=$3, status_reason=$4, last_event=$5, closed_at=$6, updated_at=$6 WHERE id=$1 AND mind_id=$2`, old, mind, by, reason, nullID(evID), now) + return err +} + +// ---- decisions ---- + +// RecordDecision appends a decision. +func (s *Store) RecordDecision(ctx context.Context, q storage.Querier, d Decision) (Decision, error) { + if q == nil { + q = s.db + } + d.ID = ids.New() + if d.CreatedAt.IsZero() { + d.CreatedAt = time.Now() + } + if d.Terms == nil { + d.Terms = map[string]float64{} + } + if d.Inputs == nil { + d.Inputs = map[string]any{} + } + _, err := q.Exec(ctx, `INSERT INTO volition_decisions (id, mind_id, kind, outcome, reason, rationale, want_id, intention_id, commitment_id, conflict_id, terms, inputs, event_id, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + d.ID, d.MindID, d.Kind, d.Outcome, d.Reason, trunc(d.Rationale, 2000), nullID(d.WantID), nullID(d.IntentionID), nullID(d.CommitmentID), nullID(d.ConflictID), jsonOr(d.Terms, "{}"), jsonOr(d.Inputs, "{}"), nullID(d.EventID), d.CreatedAt) + return d, err +} + +// DecisionFilter narrows a decision listing. +type DecisionFilter struct { + WantID ids.ID + IntentionID ids.ID + CommitmentID ids.ID + Kind string + Limit int +} + +// Decisions lists decisions, newest first. +func (s *Store) Decisions(ctx context.Context, mind ids.ID, f DecisionFilter) ([]Decision, error) { + if f.Limit <= 0 || f.Limit > 1000 { + f.Limit = 200 + } + rows, err := s.db.Query(ctx, `SELECT id, mind_id, kind, outcome, reason, rationale, want_id, intention_id, commitment_id, conflict_id, terms, inputs, event_id, created_at FROM volition_decisions + WHERE mind_id=$1 AND ($2::uuid IS NULL OR want_id=$2) AND ($3::uuid IS NULL OR intention_id=$3) AND ($4::uuid IS NULL OR commitment_id=$4) AND ($5='' OR kind=$5) ORDER BY created_at DESC LIMIT $6`, + mind, nullID(f.WantID), nullID(f.IntentionID), nullID(f.CommitmentID), f.Kind, f.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Decision + for rows.Next() { + var d Decision + var want, intention, commitment, conflict, ev *string + var terms, inputs []byte + if err := rows.Scan(&d.ID, &d.MindID, &d.Kind, &d.Outcome, &d.Reason, &d.Rationale, &want, &intention, &commitment, &conflict, &terms, &inputs, &ev, &d.CreatedAt); err != nil { + return nil, err + } + setID(&d.WantID, want) + setID(&d.IntentionID, intention) + setID(&d.CommitmentID, commitment) + setID(&d.ConflictID, conflict) + setID(&d.EventID, ev) + json.Unmarshal(terms, &d.Terms) //nolint:errcheck + json.Unmarshal(inputs, &d.Inputs) //nolint:errcheck + out = append(out, d) + } + return out, rows.Err() +} diff --git a/internal/volition/store_test.go b/internal/volition/store_test.go new file mode 100644 index 0000000..97ae6ca --- /dev/null +++ b/internal/volition/store_test.go @@ -0,0 +1,137 @@ +package volition + +import ( + "context" + "errors" + "testing" + "time" + + evstore "github.com/andydixon/noema/internal/events" + "github.com/andydixon/noema/internal/minds" + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/migrations" + "github.com/andydixon/noema/pkg/events" + "github.com/andydixon/noema/pkg/ids" +) + +func TestStoreLifecycle(t *testing.T) { + pool := storage.TestPool(t, migrations.FS) + ctx := context.Background() + m, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "vol"}) + es := evstore.NewStore(pool, nil, nil) + s := NewStore(pool) + r := s.Direct(es, "volition.test") + count := func(typ events.Type) int { + evs, _ := es.Query(ctx, evstore.Filter{MindID: m.ID, Types: []events.Type{typ}}) + return len(evs) + } + + cand := Candidate{Subject: "understand:prediction:p1", Description: "understand why the prediction failed", Origin: OriginPredictionError, Strength: 0.5, Urgency: 0.4, Drives: []string{"curiosity"}} + w, created, err := s.UpsertWant(ctx, r, m.ID, cand) + if err != nil || !created { + t.Fatalf("create: %v %v", err, created) + } + w2, created, err := s.UpsertWant(ctx, r, m.ID, cand) + if err != nil || created || w2.ID != w.ID || w2.Strength <= w.Strength || w2.Reinforcements != 1 { + t.Fatalf("identical want should reinforce: %v %+v", err, w2) + } + if n := count(events.WantCreated); n != 1 { + t.Fatalf("want_created events: %d", n) + } + if err := s.SuppressWant(ctx, r, w2, "forbidden by commitment", []ids.ID{ids.New()}); err != nil { + t.Fatal(err) + } + got, _ := s.GetWant(ctx, nil, m.ID, w.ID) + if got.Status != WantActive || got.Suppressions != 1 { + t.Fatalf("suppressed want must stay active: %+v", got) + } + + avoid, _, _ := s.UpsertWant(ctx, r, m.ID, Candidate{Subject: "avoid:disruption:atlas", Description: "avoid disrupting atlas", Origin: OriginValue, Strength: 0.5, Valence: "avoid"}) + res := ResolveConflict(got, avoid, time.Now(), nil, nil) + c1, isNew, err := s.RecordConflict(ctx, r, got, avoid, "value", res) + if err != nil || !isNew { + t.Fatalf("conflict: %v", err) + } + if _, isNew, _ := s.RecordConflict(ctx, r, avoid, got, "value", res); isNew { + t.Fatal("same open conflict recorded twice") + } + if ds, _ := s.Decisions(ctx, m.ID, DecisionFilter{Kind: "conflict_resolution"}); len(ds) != 1 || ds[0].ConflictID != c1.ID { + t.Fatalf("conflict decision: %+v", ds) + } + + no := false + c, err := s.CreateCommitment(ctx, r, NewCommitment{MindID: m.ID, Subject: "production changes", Description: "do not modify production without approval", Kind: KindOperatorInstruction, Polarity: "refrain", Unless: "approval", + Matcher: Matcher{Access: "write", Targets: []string{"atlas-*"}}, Revocable: &no, CreatedBy: "andy"}) + if err != nil { + t.Fatal(err) + } + if _, err := s.CreateCommitment(ctx, r, NewCommitment{MindID: m.ID, Description: "x", Kind: "vow", Polarity: "do"}); !errors.Is(err, ErrInvalid) { + t.Fatal("unknown kind accepted") + } + if _, err := s.CreateCommitment(ctx, r, NewCommitment{MindID: m.ID, Description: "x", Kind: KindPromise, Polarity: "do", Unless: "approval"}); !errors.Is(err, ErrInvalid) { + t.Fatal("unless on a do-commitment accepted") + } + c2, err := s.CreateCommitment(ctx, r, NewCommitment{MindID: m.ID, Subject: "Production changes", Description: "do not modify production without two approvals", Kind: KindOperatorInstruction, Polarity: "refrain", Unless: "approval", Matcher: Matcher{Access: "write"}}) + if err != nil { + t.Fatal(err) + } + old, _ := s.GetCommitment(ctx, nil, m.ID, c.ID) + if old.Status != "superseded" || old.SupersededBy != c2.ID { + t.Fatalf("supersession: %+v", old) + } + if _, err := s.CloseCommitment(ctx, r, m.ID, c2.ID, "revoked", "mind changed its mind", false); err != nil { + t.Fatalf("revocable commitment: %v", err) + } + c3, _ := s.CreateCommitment(ctx, r, NewCommitment{MindID: m.ID, Description: "keep customer data confidential", Kind: KindValueRule, Polarity: "refrain", Revocable: &no}) + if _, err := s.CloseCommitment(ctx, r, m.ID, c3.ID, "revoked", "inconvenient", false); !errors.Is(err, ErrNotRevocable) { + t.Fatal("mind revoked a non-revocable commitment") + } + if _, err := s.CloseCommitment(ctx, r, m.ID, c3.ID, "revoked", "operator decision", true); err != nil { + t.Fatalf("operator revoke: %v", err) + } + + promise, _ := s.CreateCommitment(ctx, r, NewCommitment{MindID: m.ID, Description: "deliver the report", Kind: KindPromise, Polarity: "do"}) + it, err := s.CreateIntention(ctx, r, Intention{MindID: m.ID, Description: "investigate the failed prediction", Kind: IntentInvestigate, SourceWants: []ids.ID{w.ID}, CommitmentID: promise.ID, Priority: 0.7, Confidence: 0.6, + Formation: map[string]any{"score": 0.5}}) + if err != nil || it.FormedEvent.IsZero() { + t.Fatalf("intention: %v %+v", err, it) + } + it, err = s.Apply(ctx, r, m.ID, it.ID, Reconsideration{Outcome: Block, BlockedKind: BlockApproval, Reason: "logs.query requires operator approval"}, Trigger{Kind: TriggerCapabilityChanged}) + if err != nil || it.Status != IntentionBlocked || it.BlockedKind != BlockApproval { + t.Fatalf("block: %v %+v", err, it) + } + it, _ = s.Apply(ctx, r, m.ID, it.ID, Reconsideration{Outcome: Resume, Reason: "granted"}, Trigger{Kind: TriggerRequestGranted}) + it, err = s.Apply(ctx, r, m.ID, it.ID, Reconsideration{Outcome: Fulfil, Reason: "plan completed"}, Trigger{Kind: TriggerPlanCompleted}) + if err != nil || it.Status != IntentionFulfilled || it.ClosedAt == nil || it.Reconsiderations != 3 { + t.Fatalf("fulfil: %v %+v", err, it) + } + if got, _ := s.GetWant(ctx, nil, m.ID, w.ID); got.Status != WantSatisfied { + t.Fatalf("source want not satisfied: %+v", got) + } + if got, _ := s.GetCommitment(ctx, nil, m.ID, promise.ID); got.Status != "fulfilled" { + t.Fatalf("commitment not fulfilled: %+v", got) + } + if _, err := s.Apply(ctx, r, m.ID, it.ID, Reconsideration{Outcome: Resume}, Trigger{Kind: TriggerOperator}); !errors.Is(err, ErrClosed) { + t.Fatal("closed intention reopened") + } + if ds, _ := s.Decisions(ctx, m.ID, DecisionFilter{IntentionID: it.ID}); len(ds) != 3 { + t.Fatalf("reconsideration decisions: %d", len(ds)) + } + if _, err := pool.Exec(ctx, `UPDATE volition_decisions SET outcome='rewritten'`); err == nil { + t.Fatal("decisions must be append-only") + } + if n := count(events.IntentionFulfilled); n != 1 { + t.Fatalf("intention_fulfilled events: %d", n) + } + // Decay and cap. + s.UpsertWant(ctx, Recorder{Q: pool, Now: time.Now().Add(-48 * time.Hour)}, m.ID, Candidate{Subject: "old", Description: "an old want", Origin: OriginCuriosity, Strength: 0.4}) //nolint:errcheck + if n, err := s.ExpireWants(ctx, r, m.ID, 40); err != nil || n != 1 { + t.Fatalf("expire: %d %v", n, err) + } + if err := s.SettleConflicts(ctx, pool, m.ID, time.Now()); err != nil { + t.Fatal(err) + } + if cs, _ := s.Conflicts(ctx, m.ID, "open", 10); len(cs) != 0 { + t.Fatalf("conflict with a satisfied want still open: %+v", cs) + } +} diff --git a/internal/volition/types.go b/internal/volition/types.go new file mode 100644 index 0000000..dd1c370 --- /dev/null +++ b/internal/volition/types.go @@ -0,0 +1,326 @@ +// Package volition is Noema's model of wanting, intending and committing +// (docs/volition.md). The four states it separates have different +// consequences: +// +// - a want is a motivational preference with an origin, a strength that +// decays and an urgency; it never implies action; +// - an intention is a recorded decision to pursue an outcome, with its +// sources, expected result, confidence, priority and status; +// - a commitment is normative state (do or refrain) that shapes which +// intentions form and how they may be carried out; +// - an action is what the capability layer actually did, recorded there. +// +// Every rule here is deterministic Go code operating on plain inputs, so the +// same state gives the same decision, and every decision is stored with the +// terms that produced it. Nothing in this package can grant authority: it +// may describe what a mind wants to do, and the capability layer decides. +package volition + +import ( + "context" + "math" + "time" + + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/pkg/events" + "github.com/andydixon/noema/pkg/ids" +) + +// Origin says where a want came from. +type Origin string + +// Origins. +const ( + OriginPredictionError Origin = "prediction_error" + OriginContradiction Origin = "contradiction" + OriginGoal Origin = "goal" + OriginStalledGoal Origin = "stalled_goal" + OriginRequest Origin = "request" + OriginCommitment Origin = "commitment" + OriginCuriosity Origin = "curiosity" + OriginValue Origin = "value" + OriginOperator Origin = "operator" + OriginDream Origin = "dream" +) + +// WantStatus is the lifecycle of a want. +type WantStatus string + +// Want statuses. Suppression is not a status: a suppressed want stays +// active and represented, and its suppression count rises. +const ( + WantActive WantStatus = "active" + WantSatisfied WantStatus = "satisfied" + WantExpired WantStatus = "expired" + WantAbandoned WantStatus = "abandoned" + WantSuperseded WantStatus = "superseded" +) + +// Proposal is an optional concrete means attached to a want: a capability +// with target and parameters, or an investigation. +type Proposal struct { + Kind string `json:"kind"` // capability | investigate + Capability string `json:"capability,omitempty"` + Target string `json:"target,omitempty"` + Params map[string]any `json:"params,omitempty"` + Subject string `json:"subject,omitempty"` // what an investigation is about +} + +// Want is a persisted motivational preference. +type Want struct { + ID ids.ID `json:"id"` + MindID ids.ID `json:"mind_id"` + Subject string `json:"subject"` + Description string `json:"description"` + Origin Origin `json:"origin_type"` + OriginEvent ids.ID `json:"origin_event,omitempty"` + OriginRef string `json:"origin_ref"` + Strength float64 `json:"strength"` + Valence string `json:"valence"` // approach | avoid + Urgency float64 `json:"urgency"` + Status WantStatus `json:"status"` + Drives []string `json:"drives"` + Values []string `json:"values"` + GoalID ids.ID `json:"goal_id,omitempty"` + CommitmentID ids.ID `json:"commitment_id,omitempty"` + Proposal *Proposal `json:"proposal,omitempty"` + Terms map[string]float64 `json:"terms"` + Reinforcements int `json:"reinforcements"` + Suppressions int `json:"suppressions"` + LastReinforcedAt time.Time `json:"last_reinforced_at"` + Resolution string `json:"resolution"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// WantHalfLife is how quickly an unreinforced want fades. +const WantHalfLife = 6 * time.Hour + +// Effective is the want's strength after decay since it was last reinforced. +func (w Want) Effective(now time.Time) float64 { + el := now.Sub(w.LastReinforcedAt) + if el <= 0 { + return w.Strength + } + return round3(w.Strength * math.Pow(0.5, el.Hours()/WantHalfLife.Hours())) +} + +// Conflict records two wants that cannot both be fully served. +type Conflict struct { + ID ids.ID `json:"id"` + MindID ids.ID `json:"mind_id"` + WantA ids.ID `json:"want_a"` + WantB ids.ID `json:"want_b"` + Kind string `json:"kind"` // value | priority | commitment | exclusive + Status string `json:"status"` + Preferred ids.ID `json:"preferred,omitempty"` + Resolution string `json:"resolution"` + Rationale map[string]any `json:"rationale"` + DetectedEvent ids.ID `json:"detected_event,omitempty"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// CommitmentKind says where a commitment's normative weight comes from. +type CommitmentKind string + +// Commitment kinds. +const ( + KindPromise CommitmentKind = "promise" + KindOperatorInstruction CommitmentKind = "operator_instruction" + KindResponsibility CommitmentKind = "responsibility" + KindValueRule CommitmentKind = "value_rule" + KindRelationship CommitmentKind = "relationship" + KindPriorDecision CommitmentKind = "prior_decision" + KindPolicy CommitmentKind = "policy" + KindScheduled CommitmentKind = "scheduled" +) + +// ValidKind reports whether k is known. +func ValidKind(k CommitmentKind) bool { + switch k { + case KindPromise, KindOperatorInstruction, KindResponsibility, KindValueRule, KindRelationship, KindPriorDecision, KindPolicy, KindScheduled: + return true + } + return false +} + +// Matcher describes the actions a commitment is about. +type Matcher struct { + Capabilities []string `json:"capabilities,omitempty"` // globs over capability names + Targets []string `json:"targets,omitempty"` // globs over targets + Access string `json:"access,omitempty"` // read | write | "" (any) + MinRisk string `json:"min_risk,omitempty"` // low | medium | high | critical | "" +} + +// Commitment is normative state. +type Commitment struct { + ID ids.ID `json:"id"` + MindID ids.ID `json:"mind_id"` + Subject string `json:"subject"` + Description string `json:"description"` + Kind CommitmentKind `json:"kind"` + Polarity string `json:"polarity"` // do | refrain + Matcher Matcher `json:"matcher"` + Unless string `json:"unless"` // "" | approval + Beneficiary string `json:"beneficiary"` + Origin string `json:"origin_type"` + OriginEvent ids.ID `json:"origin_event,omitempty"` + OriginRef string `json:"origin_ref"` + Strength float64 `json:"strength"` + Priority float64 `json:"priority"` + Conditions string `json:"conditions"` + DueAt *time.Time `json:"due_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Revocable bool `json:"revocable"` + Status string `json:"status"` + SupersededBy ids.ID `json:"superseded_by,omitempty"` + GoalID ids.ID `json:"goal_id,omitempty"` + IntentionID ids.ID `json:"intention_id,omitempty"` + Evidence []events.EvidenceRef `json:"evidence"` + CreatedBy string `json:"created_by"` + StatusReason string `json:"status_reason"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DueNotifiedAt *time.Time `json:"due_notified_at,omitempty"` +} + +// kindFactor is the normative weight of each commitment kind. An operator +// instruction weighs most because a person with authority gave it; a prior +// decision of the mind's own weighs least because it is cheapest to revisit. +var kindFactor = map[CommitmentKind]float64{KindOperatorInstruction: 1, KindPolicy: 1, KindPromise: 0.9, KindResponsibility: 0.85, KindValueRule: 0.8, KindRelationship: 0.75, KindScheduled: 0.7, KindPriorDecision: 0.6} + +// Weight is the commitment's normative weight in [0,1]. +func (c Commitment) Weight() float64 { + return round3(c.Strength * (0.5 + 0.5*c.Priority) * kindFactor[c.Kind]) +} + +// IntentionKind is what an intention sets out to do. +type IntentionKind string + +// Intention kinds. +const ( + IntentAct IntentionKind = "act" // carry out a concrete capability action + IntentInvestigate IntentionKind = "investigate" // reduce uncertainty with read-only operations + IntentRequestApproval IntentionKind = "request_approval" // act, but only after an operator approves + IntentRequestCapability IntentionKind = "request_capability" // obtain temporary authority first + IntentFulfilCommitment IntentionKind = "fulfil_commitment" + IntentPursue IntentionKind = "pursue" // advance a goal + IntentRespond IntentionKind = "respond" +) + +// IntentionStatus is the lifecycle of an intention. +type IntentionStatus string + +// Intention statuses. +const ( + IntentionProposed IntentionStatus = "proposed" + IntentionActive IntentionStatus = "active" + IntentionSuspended IntentionStatus = "suspended" + IntentionBlocked IntentionStatus = "blocked" + IntentionAbandoned IntentionStatus = "abandoned" + IntentionFulfilled IntentionStatus = "fulfilled" + IntentionSuperseded IntentionStatus = "superseded" +) + +// Open reports whether an intention is still live. +func (s IntentionStatus) Open() bool { + return s == IntentionProposed || s == IntentionActive || s == IntentionSuspended || s == IntentionBlocked +} + +// Blocked reasons: the intention–action gap. +const ( + BlockApproval = "approval_required" + BlockCapability = "capability_unavailable" + BlockResource = "resource_unavailable" + BlockDependency = "external_dependency" + BlockWaiting = "waiting_period" + BlockPrereq = "prerequisite_incomplete" + BlockCommitment = "commitment" + BlockVerify = "outcome_unverified" +) + +// Intention is a recorded decision to pursue an outcome. +type Intention struct { + ID ids.ID `json:"id"` + MindID ids.ID `json:"mind_id"` + ParentID ids.ID `json:"parent_id,omitempty"` + Description string `json:"description"` + DesiredOutcome string `json:"desired_outcome"` + Kind IntentionKind `json:"kind"` + SourceWants []ids.ID `json:"source_wants"` + GoalID ids.ID `json:"goal_id,omitempty"` + CommitmentID ids.ID `json:"commitment_id,omitempty"` + BeliefIDs []ids.ID `json:"belief_ids"` + ExpectedResult string `json:"expected_result"` + Confidence float64 `json:"confidence"` + Priority float64 `json:"priority"` + Status IntentionStatus `json:"status"` + BlockedKind string `json:"blocked_kind"` + BlockedReason string `json:"blocked_reason"` + RequiredCapabilities []string `json:"required_capabilities"` + RequireApproval bool `json:"require_approval"` + Proposal *Proposal `json:"proposal,omitempty"` + PlanID ids.ID `json:"plan_id,omitempty"` + SupersededBy ids.ID `json:"superseded_by,omitempty"` + FormedEvent ids.ID `json:"formed_event,omitempty"` + LastEvent ids.ID `json:"last_event,omitempty"` + Formation map[string]any `json:"formation"` + Reconsiderations int `json:"reconsiderations"` + StatusReason string `json:"status_reason"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Decision is one append-only volitional decision. +type Decision struct { + ID ids.ID `json:"id"` + MindID ids.ID `json:"mind_id"` + Kind string `json:"kind"` + Outcome string `json:"outcome"` + Reason string `json:"reason"` + Rationale string `json:"rationale"` + WantID ids.ID `json:"want_id,omitempty"` + IntentionID ids.ID `json:"intention_id,omitempty"` + CommitmentID ids.ID `json:"commitment_id,omitempty"` + ConflictID ids.ID `json:"conflict_id,omitempty"` + Terms map[string]float64 `json:"terms"` + Inputs map[string]any `json:"inputs"` + EventID ids.ID `json:"event_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// Emitter appends an event, inside the caller's transaction when there is one. +type Emitter func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) + +// Recorder carries where and how a volitional change is recorded. +type Recorder struct { + Q storage.Querier + Emit Emitter + Source string // event source, e.g. "brain.volition" inside a cycle + Cause ids.ID // causation for emitted events + Correlation ids.ID + Now time.Time +} + +func (r Recorder) now() time.Time { + if r.Now.IsZero() { + return time.Now() + } + return r.Now +} + +func round3(v float64) float64 { return math.Round(v*1000) / 1000 } + +func clamp01(v float64) float64 { + if math.IsNaN(v) || v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} diff --git a/migrations/0026_volition.down.sql b/migrations/0026_volition.down.sql new file mode 100644 index 0000000..51a66dc --- /dev/null +++ b/migrations/0026_volition.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS volition_decisions; +DROP TABLE IF EXISTS intentions; +DROP TABLE IF EXISTS commitments; +DROP TABLE IF EXISTS want_conflicts; +DROP TABLE IF EXISTS wants; diff --git a/migrations/0026_volition.up.sql b/migrations/0026_volition.up.sql new file mode 100644 index 0000000..4cf69b3 --- /dev/null +++ b/migrations/0026_volition.up.sql @@ -0,0 +1,137 @@ +-- Volition (PLAN §8, docs/volition.md): wants, want conflicts, commitments, +-- intentions and the append-only ledger of volitional decisions. + +CREATE TABLE wants ( + id uuid PRIMARY KEY, + mind_id uuid NOT NULL REFERENCES minds(id), + subject text NOT NULL, -- stable key; identical wants reinforce instead of duplicating + description text NOT NULL, + origin_type text NOT NULL, + origin_event uuid, + origin_ref text NOT NULL DEFAULT '', + strength double precision NOT NULL CHECK (strength >= 0 AND strength <= 1), + valence text NOT NULL DEFAULT 'approach' CHECK (valence IN ('approach','avoid')), + urgency double precision NOT NULL DEFAULT 0 CHECK (urgency >= 0 AND urgency <= 1), + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','satisfied','expired','abandoned','superseded')), + drives text[] NOT NULL DEFAULT '{}', + "values" text[] NOT NULL DEFAULT '{}', + goal_id uuid, + commitment_id uuid, + proposal jsonb, -- optional means: {kind, capability, target, params} + terms jsonb NOT NULL DEFAULT '{}'::jsonb, -- how strength was computed + reinforcements integer NOT NULL DEFAULT 0, + suppressions integer NOT NULL DEFAULT 0, + last_reinforced_at timestamptz NOT NULL DEFAULT now(), + resolution text NOT NULL DEFAULT '', + resolved_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX wants_active_subject_idx ON wants (mind_id, subject) WHERE status = 'active'; +CREATE INDEX wants_mind_idx ON wants (mind_id, status, created_at DESC); + +CREATE TABLE want_conflicts ( + id uuid PRIMARY KEY, + mind_id uuid NOT NULL REFERENCES minds(id), + want_a uuid NOT NULL REFERENCES wants(id), + want_b uuid NOT NULL REFERENCES wants(id), + kind text NOT NULL, -- value | priority | commitment | exclusive + status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','resolved','dissolved')), + preferred uuid, + resolution text NOT NULL DEFAULT '', -- preferred | preferred_with_constraint | deferred | dissolved + rationale jsonb NOT NULL DEFAULT '{}'::jsonb, + detected_event uuid, + resolved_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX want_conflicts_open_pair_idx ON want_conflicts (mind_id, least(want_a, want_b), greatest(want_a, want_b)) WHERE status = 'open'; + +CREATE TABLE commitments ( + id uuid PRIMARY KEY, + mind_id uuid NOT NULL REFERENCES minds(id), + subject text NOT NULL, + description text NOT NULL, + kind text NOT NULL CHECK (kind IN ('promise','operator_instruction','responsibility','value_rule','relationship','prior_decision','policy','scheduled')), + polarity text NOT NULL CHECK (polarity IN ('do','refrain')), + matcher jsonb NOT NULL DEFAULT '{}'::jsonb, -- {capabilities:[glob], targets:[glob], access, min_risk} + unless text NOT NULL DEFAULT '' CHECK (unless IN ('','approval')), + beneficiary text NOT NULL DEFAULT '', + origin_type text NOT NULL DEFAULT 'operator', + origin_event uuid, + origin_ref text NOT NULL DEFAULT '', + strength double precision NOT NULL DEFAULT 0.8 CHECK (strength >= 0 AND strength <= 1), + priority double precision NOT NULL DEFAULT 0.5 CHECK (priority >= 0 AND priority <= 1), + conditions text NOT NULL DEFAULT '', + due_at timestamptz, + expires_at timestamptz, + revocable boolean NOT NULL DEFAULT true, + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','fulfilled','broken','revoked','expired','superseded')), + superseded_by uuid, + goal_id uuid, + intention_id uuid, + evidence jsonb NOT NULL DEFAULT '[]'::jsonb, + due_notified_at timestamptz, + created_by text NOT NULL DEFAULT '', + status_reason text NOT NULL DEFAULT '', + closed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX commitments_mind_idx ON commitments (mind_id, status, due_at); + +CREATE TABLE intentions ( + id uuid PRIMARY KEY, + mind_id uuid NOT NULL REFERENCES minds(id), + parent_id uuid REFERENCES intentions(id), + description text NOT NULL, + desired_outcome text NOT NULL DEFAULT '', + kind text NOT NULL CHECK (kind IN ('act','investigate','request_approval','request_capability','fulfil_commitment','pursue','respond')), + source_wants uuid[] NOT NULL DEFAULT '{}', + goal_id uuid, + commitment_id uuid, + belief_ids uuid[] NOT NULL DEFAULT '{}', + expected_result text NOT NULL DEFAULT '', + confidence double precision NOT NULL DEFAULT 0.5 CHECK (confidence >= 0 AND confidence <= 1), + priority double precision NOT NULL DEFAULT 0.5 CHECK (priority >= 0 AND priority <= 1), + status text NOT NULL DEFAULT 'active' CHECK (status IN ('proposed','active','suspended','blocked','abandoned','fulfilled','superseded')), + blocked_kind text NOT NULL DEFAULT '', + blocked_reason text NOT NULL DEFAULT '', + required_capabilities text[] NOT NULL DEFAULT '{}', + require_approval boolean NOT NULL DEFAULT false, + proposal jsonb, + plan_id uuid, + superseded_by uuid, + formed_event uuid, + last_event uuid, + formation jsonb NOT NULL DEFAULT '{}'::jsonb, + reconsiderations integer NOT NULL DEFAULT 0, + status_reason text NOT NULL DEFAULT '', + closed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX intentions_mind_idx ON intentions (mind_id, status, priority DESC); + +-- Append-only: why each want did or did not become an intention, how each +-- conflict was resolved, why each intention changed. +CREATE TABLE volition_decisions ( + id uuid PRIMARY KEY, + mind_id uuid NOT NULL REFERENCES minds(id), + kind text NOT NULL, -- formation | non_formation | conflict_resolution | reconsideration | suppression | commitment_check + outcome text NOT NULL, + reason text NOT NULL DEFAULT '', + rationale text NOT NULL DEFAULT '', + want_id uuid, + intention_id uuid, + commitment_id uuid, + conflict_id uuid, + terms jsonb NOT NULL DEFAULT '{}'::jsonb, + inputs jsonb NOT NULL DEFAULT '{}'::jsonb, + event_id uuid, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX volition_decisions_mind_idx ON volition_decisions (mind_id, created_at DESC); +CREATE INDEX volition_decisions_want_idx ON volition_decisions (want_id) WHERE want_id IS NOT NULL; +CREATE INDEX volition_decisions_intention_idx ON volition_decisions (intention_id) WHERE intention_id IS NOT NULL; +CREATE TRIGGER volition_decisions_append_only BEFORE UPDATE OR DELETE ON volition_decisions + FOR EACH ROW EXECUTE FUNCTION noema_forbid_change(); diff --git a/pkg/events/events.go b/pkg/events/events.go index 8dbce44..70f60f2 100644 --- a/pkg/events/events.go +++ b/pkg/events/events.go @@ -65,6 +65,39 @@ const ( ResponseGenerated Type = "response_generated" CycleCompleted Type = "cycle_completed" Redaction Type = "redaction" // content of an earlier row was made irrecoverable under a policy + + // Volition (docs/volition.md): wants, intentions and commitments are + // distinct states; each transition is recorded. + WantCreated Type = "want_created" + WantReinforced Type = "want_reinforced" + WantSuppressed Type = "want_suppressed" // a want was not pursued because of a commitment, conflict or policy; it stays represented + WantConflictDetected Type = "want_conflict_detected" + IntentionFormed Type = "intention_formed" + IntentionBlocked Type = "intention_blocked" + IntentionSuspended Type = "intention_suspended" + IntentionResumed Type = "intention_resumed" + IntentionReconsidered Type = "intention_reconsidered" + IntentionAbandoned Type = "intention_abandoned" + IntentionFulfilled Type = "intention_fulfilled" + IntentionSuperseded Type = "intention_superseded" + CommitmentCreated Type = "commitment_created" + CommitmentDue Type = "commitment_due" + CommitmentConflict Type = "commitment_conflict" + CommitmentFulfilled Type = "commitment_fulfilled" + CommitmentBroken Type = "commitment_broken" + CommitmentRevoked Type = "commitment_revoked" + + // Planning (docs/planning.md). Action requests, authorisation and + // completion reuse policy_decision and tool_*; expectation resolution + // reuses prediction_resolved and prediction_error. + PlanCreated Type = "plan_created" + PlanValidated Type = "plan_validated" + PlanStepStarted Type = "plan_step_started" + PlanStepCompleted Type = "plan_step_completed" + PlanStepFailed Type = "plan_step_failed" + PlanReplanned Type = "plan_replanned" + PlanCompleted Type = "plan_completed" + PlanAbandoned Type = "plan_abandoned" ) // Category groups types for filtering and colouring in the UI. @@ -83,6 +116,8 @@ const ( CatLLM Category = "llm" CatOperator Category = "operator" CatSystem Category = "system" + CatVolition Category = "volition" + CatPlan Category = "plan" ) var categories = map[Type]Category{ @@ -100,6 +135,11 @@ var categories = map[Type]Category{ NotificationCreated: CatAction, NotificationSuppressed: CatAction, PolicyDecision: CatAction, LLMRequest: CatLLM, LLMResponse: CatLLM, SelfModelUpdated: CatSystem, OperatorAction: CatOperator, + WantCreated: CatVolition, WantReinforced: CatVolition, WantSuppressed: CatVolition, WantConflictDetected: CatVolition, + IntentionFormed: CatVolition, IntentionBlocked: CatVolition, IntentionSuspended: CatVolition, IntentionResumed: CatVolition, IntentionReconsidered: CatVolition, + IntentionAbandoned: CatVolition, IntentionFulfilled: CatVolition, IntentionSuperseded: CatVolition, + CommitmentCreated: CatVolition, CommitmentDue: CatVolition, CommitmentConflict: CatVolition, CommitmentFulfilled: CatVolition, CommitmentBroken: CatVolition, CommitmentRevoked: CatVolition, + PlanCreated: CatPlan, PlanValidated: CatPlan, PlanStepStarted: CatPlan, PlanStepCompleted: CatPlan, PlanStepFailed: CatPlan, PlanReplanned: CatPlan, PlanCompleted: CatPlan, PlanAbandoned: CatPlan, } // CategoryOf returns the category of a type (CatSystem for unknown types). @@ -135,7 +175,7 @@ func sortTypes(ts []Type) { // memory, belief, another event, an entity, a document, an LLM call, a // prediction, a message. type EvidenceRef struct { - Kind string `json:"kind"` // event | memory | belief | entity | document | llm_call | prediction | goal | message | approval + Kind string `json:"kind"` // event | memory | belief | entity | document | llm_call | prediction | goal | message | approval | want | intention | commitment | plan | plan_step | capability_request | execution ID string `json:"id"` Note string `json:"note,omitempty"` } diff --git a/web/static/app.css b/web/static/app.css index 9c4d75a..338f25f 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -142,6 +142,7 @@ ul.plain { list-style: none; padding: 0; margin: 0; } .etype.cat-belief { border-left-color: #d97706; } .etype.cat-prediction { border-left-color: #db2777; } .etype.cat-goal { border-left-color: #16a34a; } .etype.cat-affect { border-left-color: #e11d48; } .etype.cat-action { border-left-color: #b45309; } .etype.cat-llm { border-left-color: #64748b; } .etype.cat-operator { border-left-color: #0f766e; } .etype.cat-system { border-left-color: var(--border); } +.etype.cat-volition { border-left-color: #7c3aed; } .etype.cat-plan { border-left-color: #0369a1; } tr.fresh { animation: fresh 2.5s ease-out; } @keyframes fresh { from { background: var(--accent-soft); } to { background: transparent; } } body.stream-offline [data-stream-status] { background: var(--danger-soft); color: var(--danger); }