Formation summed its terms by ranging over a map, so the score could differ in the last bit between runs of the same state; the volition hook assessed commitments, metacognition raised repeated-failure candidates and the forensic explanation listed commitments in map iteration order, so the events a cycle emits and an explanation's order varied between runs. Each now iterates in sorted order, as replay determinism (PLAN §1 R3) requires.
923 lines
34 KiB
Go
923 lines
34 KiB
Go
package volition
|
||
|
||
import (
|
||
"fmt"
|
||
"path"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/andydixon/noema/pkg/events"
|
||
"github.com/andydixon/noema/pkg/ids"
|
||
)
|
||
|
||
// ---- want derivation ----
|
||
|
||
// GoalInfo is what derivation needs to know about a goal.
|
||
type GoalInfo struct {
|
||
ID ids.ID
|
||
Title string
|
||
Subject string
|
||
Kind string
|
||
Owner string
|
||
Priority float64
|
||
DueAt *time.Time
|
||
}
|
||
|
||
// DeriveContext is the state want derivation reads.
|
||
type DeriveContext struct {
|
||
Now time.Time
|
||
Drives map[string]float64 // current drive values
|
||
Values map[string]float64 // configured personality values
|
||
Goals map[ids.ID]GoalInfo
|
||
Commitments map[ids.ID]Commitment
|
||
}
|
||
|
||
// Candidate is a want derivation proposes; the store creates or reinforces.
|
||
type Candidate struct {
|
||
Subject string
|
||
Description string
|
||
Origin Origin
|
||
OriginEvent ids.ID
|
||
OriginRef string
|
||
Strength float64
|
||
Urgency float64
|
||
Valence string
|
||
Drives []string
|
||
Values []string
|
||
GoalID ids.ID
|
||
CommitmentID ids.ID
|
||
Proposal *Proposal
|
||
Terms map[string]float64
|
||
}
|
||
|
||
// WantThreshold is the minimum strength for a candidate to become a want.
|
||
// Below it nothing is stored, which keeps wants from accumulating.
|
||
const WantThreshold = 0.35
|
||
|
||
// strength combines a base with the named drives and values. With neutral
|
||
// drives and values (0.5) the result equals the base; the terms are kept.
|
||
func strength(base float64, dctx DeriveContext, drives, values []string) (float64, map[string]float64) {
|
||
dm, vm := mean(dctx.Drives, drives), mean(dctx.Values, values)
|
||
df, vf := 0.7+0.6*dm, 0.8+0.4*vm
|
||
s := round3(clamp01(base * df * vf))
|
||
return s, map[string]float64{"base": round3(base), "drive_mean": round3(dm), "drive_factor": round3(df), "value_mean": round3(vm), "value_factor": round3(vf), "strength": s}
|
||
}
|
||
|
||
func mean(m map[string]float64, keys []string) float64 {
|
||
if len(keys) == 0 {
|
||
return 0.5
|
||
}
|
||
sum := 0.0
|
||
for _, k := range keys {
|
||
v, ok := m[k]
|
||
if !ok {
|
||
v = 0.5
|
||
}
|
||
sum += v
|
||
}
|
||
return sum / float64(len(keys))
|
||
}
|
||
|
||
// Derive proposes wants from one event. Most events propose nothing: only
|
||
// states that call for something to change do (a failed prediction, a
|
||
// contradiction, a stalled or new operator goal, a question or request, a
|
||
// due commitment, an explicit action request).
|
||
func Derive(ev events.Event, dctx DeriveContext) []Candidate {
|
||
meta := func(k string) string { s, _ := ev.Metadata[k].(string); return s }
|
||
mk := func(base float64, c Candidate) []Candidate {
|
||
c.Strength, c.Terms = strength(base, dctx, c.Drives, c.Values)
|
||
c.OriginEvent = ev.ID
|
||
if c.Valence == "" {
|
||
c.Valence = "approach"
|
||
}
|
||
c.Urgency = round3(clamp01(c.Urgency))
|
||
if c.Strength < WantThreshold {
|
||
return nil
|
||
}
|
||
return []Candidate{c}
|
||
}
|
||
switch ev.Type {
|
||
case events.PredictionError:
|
||
pid := meta("prediction_id")
|
||
if pid == "" {
|
||
return nil
|
||
}
|
||
return mk(0.35+0.35*ev.Salience, Candidate{Subject: "understand:prediction:" + pid, Description: "understand why a prediction failed: " + trunc(ev.Summary, 160),
|
||
Origin: OriginPredictionError, OriginRef: "prediction:" + pid, Urgency: 0.4, Drives: []string{"uncertainty_reduction", "curiosity"}, Values: []string{"accuracy", "evidence"},
|
||
Proposal: &Proposal{Kind: "investigate", Subject: trunc(ev.Summary, 200)}})
|
||
case events.ContradictionDetected:
|
||
bid := meta("belief_id")
|
||
if bid == "" {
|
||
return nil
|
||
}
|
||
return mk(0.5, Candidate{Subject: "resolve:contradiction:" + bid, Description: "resolve a contradiction: " + trunc(strings.TrimPrefix(ev.Summary, "contradiction: "), 160),
|
||
Origin: OriginContradiction, OriginRef: "belief:" + bid, Urgency: 0.35, Drives: []string{"consistency", "uncertainty_reduction"}, Values: []string{"evidence", "accuracy"},
|
||
Proposal: &Proposal{Kind: "investigate", Subject: trunc(ev.Summary, 200)}})
|
||
case events.GoalCreated:
|
||
g, ok := dctx.Goals[ids.ID(meta("goal_id"))]
|
||
if !ok || g.Owner != "operator" {
|
||
return nil // curiosity and metacognition goals already express their own drive
|
||
}
|
||
c := Candidate{Subject: "achieve:goal:" + string(g.ID), Description: "achieve the goal: " + g.Title, Origin: OriginGoal, OriginRef: "goal:" + string(g.ID), GoalID: g.ID,
|
||
Urgency: dueUrgency(g.DueAt, dctx.Now, 0.4), Drives: []string{"task_completion"}, Values: []string{"persistence"}}
|
||
c.Proposal = ProposalFromText(g.Title, g.Subject)
|
||
return mk(0.4+0.5*g.Priority, c)
|
||
case events.GoalUpdated:
|
||
if stalled, _ := ev.Metadata["stalled"].(bool); !stalled {
|
||
return nil
|
||
}
|
||
g, ok := dctx.Goals[ids.ID(meta("goal_id"))]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return mk(0.3+0.4*g.Priority, Candidate{Subject: "unblock:goal:" + string(g.ID), Description: "find and remove what is stopping progress on: " + g.Title, Origin: OriginStalledGoal, OriginRef: "goal:" + string(g.ID), GoalID: g.ID,
|
||
Urgency: dueUrgency(g.DueAt, dctx.Now, 0.3), Drives: []string{"task_completion"}, Values: []string{"persistence"}, Proposal: &Proposal{Kind: "investigate", Subject: firstNonEmpty(g.Subject, g.Title)}})
|
||
case events.Perception:
|
||
intent := meta("intent")
|
||
conv, msg := meta("conversation_id"), meta("message_id")
|
||
if conv == "" || msg == "" || (intent != "question" && intent != "request" && intent != "introspection") {
|
||
return nil
|
||
}
|
||
actor := ""
|
||
if strings.HasPrefix(ev.Summary, "perceived ") {
|
||
if i := strings.Index(ev.Summary, " from "); i > 0 {
|
||
actor = strings.SplitN(ev.Summary[i+6:], ":", 2)[0]
|
||
}
|
||
}
|
||
return mk(0.55, Candidate{Subject: "answer:message:" + msg, Description: "give " + firstNonEmpty(actor, "the person") + " a useful, honest answer", Origin: OriginRequest, OriginRef: "message:" + msg,
|
||
Urgency: 0.7, Drives: []string{"social_coherence"}, Values: []string{"kindness", "accuracy"}})
|
||
case events.CommitmentDue:
|
||
cid := ids.ID(meta("commitment_id"))
|
||
c, ok := dctx.Commitments[cid]
|
||
if !ok || c.Polarity != "do" {
|
||
return nil
|
||
}
|
||
return mk(0.3+0.6*c.Weight(), Candidate{Subject: "fulfil:commitment:" + string(c.ID), Description: "fulfil the commitment: " + c.Description, Origin: OriginCommitment, OriginRef: "commitment:" + string(c.ID), CommitmentID: c.ID, GoalID: c.GoalID,
|
||
Urgency: dueUrgency(c.DueAt, dctx.Now, 0.6), Drives: []string{"task_completion"}, Values: []string{"persistence"}, Proposal: ProposalFromText(c.Description, c.Subject)})
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// FromRequest proposes a want for an explicit action request (for example
|
||
// "fetch <url>" in conversation), carrying the concrete means.
|
||
func FromRequest(ev events.Event, actor, text string, p Proposal, dctx DeriveContext, operator bool) []Candidate {
|
||
base := 0.55
|
||
if operator {
|
||
base = 0.7
|
||
}
|
||
s, terms := strength(base, dctx, []string{"task_completion", "social_coherence"}, []string{"kindness"})
|
||
if s < WantThreshold {
|
||
return nil
|
||
}
|
||
origin := OriginRequest
|
||
if operator {
|
||
origin = OriginOperator
|
||
}
|
||
return []Candidate{{Subject: "act:" + p.Capability + ":" + p.Target, Description: fmt.Sprintf("do what %s asked: %s", firstNonEmpty(actor, "someone"), trunc(text, 140)), Origin: origin, 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)
|
||
switch {
|
||
case in.Operator:
|
||
t["operator_request"] = 0.1 // a person with authority asked for it
|
||
case 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
|
||
}
|
||
// Summed in key order: floating-point addition is not associative, and a
|
||
// decision must not depend on map iteration order.
|
||
keys := make([]string, 0, len(t))
|
||
for k := range t {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
score := 0.0
|
||
for _, k := range keys {
|
||
score += t[k]
|
||
}
|
||
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 when the desired state already
|
||
// holds, and for plan_completed when the plan's completion means the
|
||
// desired outcome was reached.
|
||
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:
|
||
if !t.OutcomeObserved {
|
||
return Reconsideration{Outcome: Abandon, Reason: "the plan finished without achieving the desired outcome (" + firstNonEmpty(t.Detail, "fallback strategy") + "); it has been handed to the operator"}
|
||
}
|
||
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 rc.Replans > 0 {
|
||
return Reconsideration{Outcome: Suspend, Reason: "approval expired again without a decision; waiting rather than asking the operator 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)
|
||
})
|
||
}
|