feat(volition): wants, intentions, commitments and their decisions
A model of volition that keeps wanting, intending and committing as separate states (PLAN §8.2 items 1–6, 14): - Wants (migration 0026) carry origin, strength with a six-hour half-life, urgency, valence, drives, values, goal, commitment and an optional proposed means. Derivation is deterministic and selective: failed predictions, contradictions, operator goals, stalled goals, questions and requests, and due commitments; drives and configured values modulate strength with recorded terms; candidates below 0.35 are not stored; identical subjects reinforce; faded and excess wants expire. - Conflicts are first-class and resolved by explicit scores with no fixed hierarchy: urgency scales strength, goals add priority, commitments add normative weight; an approach opposed by a value-derived avoid want wins outright only when clearly stronger, otherwise with a constraint or is deferred. Neither side is discarded. - Commitments (do/refrain with capability, target, access and risk matchers, optional "unless approval", due, expiry, revocability) are checked for due, overdue, impossible, conflicting and superseded states. They constrain means and never grant authority. - Formation weighs strength, urgency, goal, commitment, expected utility, risk scaled by caution, cost, privacy and autonomy; it returns formed or not formed with a reason (forbidden by commitment, denied, unavailable, no possible plan, below threshold, capacity, duplicate, deferred), and a commitment requiring approval turns an action into a request for approval. - Reconsideration maps triggers (denied or expired approval, capability or commitment change, failed step or expectation, weakened belief, budget, goal completion, world change, operator correction) to keep, block, suspend, resume, replan, require approval, abandon or fulfil, with bounded replanning. - The store records every transition as an event and every choice in the append-only volition_decisions ledger; fulfilling an intention satisfies its wants and fulfils its commitment. - Event vocabulary gains volition and plan categories.
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user