feat(volition): wants, intentions, commitments and their decisions

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.
This commit is contained in:
Andy Dixon
2026-09-13 21:03:23 +01:00
parent e61122961b
commit 9db188ffe6
9 changed files with 2718 additions and 1 deletions
+903
View File
@@ -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 <url>" 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)
})
}
+293
View File
@@ -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)
}
+875
View File
@@ -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()
}
+137
View File
@@ -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)
}
}
+326
View File
@@ -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
}
+5
View File
@@ -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;
+137
View File
@@ -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();
+41 -1
View File
@@ -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"`
}
+1
View File
@@ -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); }