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