feat: add goals, drives, affect state, predictions and curiosity

Phase 9. Goals form a mind-scoped tree with kind, priority, progress and
blockers; their deterministic relevance to a candidate feeds the
goal_relevance attention feature and a goal monitor raises stalled goals
and observations that touch one. Drives are a closed set of eight
scalars with operator-configured ranges and personality-derived
baselines; there is no self-preservation, replication or acquisition
drive and no way to add one at runtime. Affect is ten scalars moved by an
explicit rule table over events and decaying towards baseline; both
modulate attention weights each cycle and the effective weights are
recorded with every cycle and shown beside the configured ones.

Predictions carry a window and machine-checkable criteria (event terms
occurring or staying absent, a belief reaching a confidence, or operator
judgement). A resolver job judges them deterministically, keeps an
append-only outcome history for calibration (accuracy, Brier score,
confidence buckets, overconfidence flag), emits prediction_error events
scaled by how confident the miss was, and feeds the outcome back into the
linked belief as evidence. Causal hypotheses derive predictions
automatically. Curiosity turns misses, contradictions and unexplained
observations into questions that compete for attention and become
investigation goals when admitted.

Brain gains after-cycle hooks and goal/motivation state. API, UI (goals,
prediction dashboard, cognitive state panel, effective weights) and CLI
(goal, prediction, state). Demo scenario 4 verified end to end.
This commit is contained in:
Andy Dixon
2026-06-27 20:41:03 +01:00
parent 777e8f50b5
commit 9eb873cbe6
39 changed files with 3053 additions and 50 deletions
+456
View File
@@ -0,0 +1,456 @@
// Package predictions stores explicit expectations with resolution windows
// and machine-checkable criteria, resolves them deterministically, records
// prediction errors and keeps an append-only calibration history (spec §11).
package predictions
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Status of a prediction.
type Status string
// Statuses.
const (
StatusOpen Status = "open"
StatusConfirmed Status = "confirmed"
StatusRefuted Status = "refuted"
StatusExpired Status = "expired"
StatusWithdrawn Status = "withdrawn"
)
// Criteria says how a prediction is judged.
//
// event_match: terms must (expect=occurs) or must not (expect=absent) appear in an
// event summary of the given types within the window.
// belief: the referenced belief's confidence reaches min_confidence by the window end.
// operator: only an operator can resolve it.
type Criteria struct {
Kind string `json:"kind"`
Expect string `json:"expect,omitempty"` // occurs | absent
Terms []string `json:"terms,omitempty"`
MinTerms int `json:"min_terms,omitempty"` // how many terms must match (default: all, max 3)
Types []string `json:"types,omitempty"`
BeliefID ids.ID `json:"belief_id,omitempty"`
MinConfidence float64 `json:"min_confidence,omitempty"`
}
// Prediction is one expectation.
type Prediction struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Statement string `json:"statement"`
Subject string `json:"subject"`
Confidence float64 `json:"confidence"`
WindowStart time.Time `json:"window_start"`
WindowEnd time.Time `json:"window_end"`
Criteria Criteria `json:"criteria"`
Status Status `json:"status"`
Source string `json:"source"`
OriginEvent ids.ID `json:"origin_event,omitempty"`
BeliefID ids.ID `json:"belief_id,omitempty"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
Resolution json.RawMessage `json:"resolution,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Result is one append-only outcome row.
type Result struct {
ID ids.ID `json:"id"`
PredictionID ids.ID `json:"prediction_id"`
Outcome Status `json:"outcome"`
Confidence float64 `json:"confidence"`
Error float64 `json:"error"`
Evidence json.RawMessage `json:"evidence"`
EventID ids.ID `json:"event_id,omitempty"`
ResolvedAt time.Time `json:"resolved_at"`
}
// ErrInvalid marks bad input.
var ErrInvalid = errors.New("predictions: invalid")
// Store persists predictions.
type Store struct{ db *pgxpool.Pool }
// NewStore creates a store.
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
// New describes a prediction to create.
type New struct {
MindID ids.ID
Statement string
Subject string
Confidence float64
WindowStart time.Time
WindowEnd time.Time
Criteria Criteria
Source string
OriginEvent ids.ID
BeliefID ids.ID
}
func (n *New) validate() error {
n.Statement = strings.TrimSpace(n.Statement)
if n.MindID.IsZero() || n.Statement == "" || len(n.Statement) > 1000 {
return fmt.Errorf("%w: statement required (max 1000 chars)", ErrInvalid)
}
if n.Confidence <= 0 || n.Confidence >= 1 {
return fmt.Errorf("%w: confidence must be strictly between 0 and 1", ErrInvalid)
}
if n.WindowStart.IsZero() {
n.WindowStart = time.Now()
}
if !n.WindowEnd.After(n.WindowStart) {
return fmt.Errorf("%w: window_end must be after window_start", ErrInvalid)
}
switch n.Criteria.Kind {
case "event_match":
if n.Criteria.Expect != "occurs" && n.Criteria.Expect != "absent" {
n.Criteria.Expect = "occurs"
}
var terms []string
for _, t := range n.Criteria.Terms {
if t = strings.ToLower(strings.TrimSpace(t)); len(t) >= 2 {
terms = append(terms, t)
}
}
if len(terms) == 0 {
return fmt.Errorf("%w: event_match needs terms", ErrInvalid)
}
n.Criteria.Terms = terms
if n.Criteria.MinTerms <= 0 || n.Criteria.MinTerms > len(terms) {
n.Criteria.MinTerms = min(len(terms), 3)
}
case "belief":
if n.Criteria.BeliefID.IsZero() || n.Criteria.MinConfidence <= 0 {
return fmt.Errorf("%w: belief criteria need belief_id and min_confidence", ErrInvalid)
}
case "operator", "":
n.Criteria.Kind = "operator"
default:
return fmt.Errorf("%w: unknown criteria kind", ErrInvalid)
}
return nil
}
// Create inserts a prediction.
func (s *Store) Create(ctx context.Context, q storage.Querier, n New) (Prediction, error) {
if err := n.validate(); err != nil {
return Prediction{}, err
}
crit, _ := json.Marshal(n.Criteria)
p := Prediction{ID: ids.New(), MindID: n.MindID, Statement: n.Statement, Subject: n.Subject, Confidence: n.Confidence, WindowStart: n.WindowStart, WindowEnd: n.WindowEnd, Criteria: n.Criteria,
Status: StatusOpen, Source: n.Source, OriginEvent: n.OriginEvent, BeliefID: n.BeliefID, CreatedAt: time.Now()}
_, err := q.Exec(ctx, `INSERT INTO predictions (id, mind_id, statement, subject, confidence, window_start, window_end, criteria, status, source, origin_event, belief_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'open',$9,$10,$11)`, p.ID, p.MindID, p.Statement, p.Subject, p.Confidence, p.WindowStart, p.WindowEnd, crit, p.Source, nullID(p.OriginEvent), nullID(p.BeliefID))
if err != nil {
return Prediction{}, err
}
return p, nil
}
func nullID(id ids.ID) *string {
if id.IsZero() {
return nil
}
s := string(id)
return &s
}
const cols = `id, mind_id, statement, subject, confidence, window_start, window_end, criteria, status, source, origin_event, belief_id, resolved_at, resolution, created_at`
func scan(row pgx.Row) (Prediction, error) {
var p Prediction
var crit []byte
var status string
var origin, belief *string
var res *json.RawMessage
if err := row.Scan(&p.ID, &p.MindID, &p.Statement, &p.Subject, &p.Confidence, &p.WindowStart, &p.WindowEnd, &crit, &status, &p.Source, &origin, &belief, &p.ResolvedAt, &res, &p.CreatedAt); err != nil {
return p, storage.Translate(err)
}
p.Status = Status(status)
json.Unmarshal(crit, &p.Criteria) //nolint:errcheck
if origin != nil {
p.OriginEvent = ids.ID(*origin)
}
if belief != nil {
p.BeliefID = ids.ID(*belief)
}
if res != nil {
p.Resolution = *res
}
return p, nil
}
// Get fetches a prediction scoped to a mind.
func (s *Store) Get(ctx context.Context, mind, id ids.ID) (Prediction, error) {
return scan(s.db.QueryRow(ctx, `SELECT `+cols+` FROM predictions WHERE id=$1 AND mind_id=$2`, id, mind))
}
// List returns predictions, newest first, optionally by status.
func (s *Store) List(ctx context.Context, mind ids.ID, statuses []Status, limit int) ([]Prediction, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
ss := make([]string, len(statuses))
for i, st := range statuses {
ss[i] = string(st)
}
rows, err := s.db.Query(ctx, `SELECT `+cols+` FROM predictions WHERE mind_id=$1 AND (cardinality($2::text[])=0 OR status = ANY($2)) ORDER BY created_at DESC LIMIT $3`, mind, ss, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Prediction
for rows.Next() {
p, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// Open returns open predictions for a mind (all minds when mind is Nil).
func (s *Store) Open(ctx context.Context, mind ids.ID) ([]Prediction, error) {
rows, err := s.db.Query(ctx, `SELECT `+cols+` FROM predictions WHERE status='open' AND ($1::uuid IS NULL OR mind_id=$1) ORDER BY window_end LIMIT 1000`, nullID(mind))
if err != nil {
return nil, err
}
defer rows.Close()
var out []Prediction
for rows.Next() {
p, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// Matches reports whether an event satisfies event_match terms.
func (c Criteria) Matches(ev events.Event) bool {
if c.Kind != "event_match" {
return false
}
if len(c.Types) > 0 {
ok := false
for _, t := range c.Types {
if events.Type(t) == ev.Type {
ok = true
}
}
if !ok {
return false
}
} else if ev.Type != events.Observation && ev.Type != events.Perception && ev.Type != events.ToolExecuted && ev.Type != events.ToolFailed {
return false
}
text := strings.ToLower(ev.Summary)
if raw := string(ev.Payload); len(raw) < 4000 {
text += " " + strings.ToLower(raw)
}
n := 0
for _, t := range c.Terms {
if strings.Contains(text, t) {
n++
}
}
return n >= c.MinTerms
}
// Outcome is the deterministic verdict for a prediction.
type Outcome struct {
Status Status
Evidence []events.EvidenceRef
Reason string
}
// Judge decides a prediction against events in its window and the current
// time. It returns ok=false when the prediction cannot be judged yet.
func Judge(p Prediction, now time.Time, window []events.Event, beliefConfidence *float64) (Outcome, bool) {
switch p.Criteria.Kind {
case "event_match":
for _, ev := range window {
if p.Criteria.Matches(ev) {
refs := []events.EvidenceRef{{Kind: "event", ID: string(ev.ID), Note: "matched terms"}}
if p.Criteria.Expect == "absent" {
return Outcome{Status: StatusRefuted, Evidence: refs, Reason: "an event matching the terms occurred: " + ev.Summary}, true
}
return Outcome{Status: StatusConfirmed, Evidence: refs, Reason: "matching event observed: " + ev.Summary}, true
}
}
if now.After(p.WindowEnd) {
if p.Criteria.Expect == "absent" {
return Outcome{Status: StatusConfirmed, Reason: "window closed with no matching event"}, true
}
return Outcome{Status: StatusRefuted, Reason: "window closed without a matching event"}, true
}
return Outcome{}, false
case "belief":
if beliefConfidence != nil && *beliefConfidence >= p.Criteria.MinConfidence {
return Outcome{Status: StatusConfirmed, Evidence: []events.EvidenceRef{{Kind: "belief", ID: string(p.Criteria.BeliefID)}}, Reason: fmt.Sprintf("belief reached %.2f", *beliefConfidence)}, true
}
if now.After(p.WindowEnd) {
return Outcome{Status: StatusRefuted, Evidence: []events.EvidenceRef{{Kind: "belief", ID: string(p.Criteria.BeliefID)}}, Reason: "window closed below the confidence threshold"}, true
}
return Outcome{}, false
default:
if now.After(p.WindowEnd.Add(7 * 24 * time.Hour)) {
return Outcome{Status: StatusExpired, Reason: "operator did not resolve it within a week of the window"}, true
}
return Outcome{}, false
}
}
// Resolve records an outcome: status, resolution, append-only result row.
// Error is |truth − confidence| where truth is 1 for confirmed, 0 for refuted.
func (s *Store) Resolve(ctx context.Context, q storage.Querier, p Prediction, o Outcome, eventID ids.ID) (Result, error) {
if p.Status != StatusOpen {
return Result{}, fmt.Errorf("%w: prediction already %s", ErrInvalid, p.Status)
}
truth := -1.0
switch o.Status {
case StatusConfirmed:
truth = 1
case StatusRefuted:
truth = 0
}
errv := 0.0
if truth >= 0 {
errv = math.Abs(truth - p.Confidence)
}
evJSON, _ := json.Marshal(o.Evidence)
if o.Evidence == nil {
evJSON = []byte("[]")
}
resJSON, _ := json.Marshal(map[string]any{"reason": o.Reason, "evidence": o.Evidence, "error": errv})
tag, err := q.Exec(ctx, `UPDATE predictions SET status=$3, resolved_at=now(), resolution=$4 WHERE id=$1 AND mind_id=$2 AND status='open'`, p.ID, p.MindID, string(o.Status), resJSON)
if err != nil {
return Result{}, err
}
if tag.RowsAffected() == 0 {
return Result{}, fmt.Errorf("%w: already resolved", ErrInvalid)
}
r := Result{ID: ids.New(), PredictionID: p.ID, Outcome: o.Status, Confidence: p.Confidence, Error: errv, Evidence: evJSON, EventID: eventID, ResolvedAt: time.Now()}
_, err = q.Exec(ctx, `INSERT INTO prediction_results (id, prediction_id, mind_id, outcome, confidence, error, evidence, event_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
r.ID, r.PredictionID, p.MindID, string(r.Outcome), r.Confidence, r.Error, evJSON, nullID(eventID))
return r, err
}
// Bucket is one calibration bin.
type Bucket struct {
Low, High float64 `json:"-"`
Label string `json:"label"`
Count int `json:"count"`
Confirmed int `json:"confirmed"`
MeanConf float64 `json:"mean_confidence"`
HitRate float64 `json:"hit_rate"`
}
// Calibration summarises resolved predictions.
type Calibration struct {
Total int `json:"total"`
Confirmed int `json:"confirmed"`
Refuted int `json:"refuted"`
Expired int `json:"expired"`
Accuracy float64 `json:"accuracy"` // confirmed / (confirmed+refuted)
Brier float64 `json:"brier"` // mean squared error over decided predictions
Buckets []Bucket `json:"buckets"`
Overconfident bool `json:"overconfident"` // mean confidence exceeds hit rate by > 0.15
}
// Calibrate computes accuracy, Brier score and confidence buckets since a time.
func (s *Store) Calibrate(ctx context.Context, mind ids.ID, since time.Time) (Calibration, error) {
rows, err := s.db.Query(ctx, `SELECT outcome, confidence FROM prediction_results WHERE mind_id=$1 AND resolved_at >= $2`, mind, since)
if err != nil {
return Calibration{}, err
}
defer rows.Close()
c := Calibration{}
edges := []float64{0, 0.2, 0.4, 0.6, 0.8, 1.01}
for i := 0; i+1 < len(edges); i++ {
c.Buckets = append(c.Buckets, Bucket{Low: edges[i], High: edges[i+1], Label: fmt.Sprintf("%.0f–%.0f%%", edges[i]*100, math.Min(edges[i+1], 1)*100)})
}
var sumSq, sumConf float64
for rows.Next() {
var outcome string
var conf float64
if err := rows.Scan(&outcome, &conf); err != nil {
return c, err
}
c.Total++
switch Status(outcome) {
case StatusConfirmed:
c.Confirmed++
sumSq += (1 - conf) * (1 - conf)
case StatusRefuted:
c.Refuted++
sumSq += conf * conf
default:
c.Expired++
continue
}
sumConf += conf
for i := range c.Buckets {
b := &c.Buckets[i]
if conf >= b.Low && conf < b.High {
b.Count++
b.MeanConf += conf
if Status(outcome) == StatusConfirmed {
b.Confirmed++
}
}
}
}
decided := c.Confirmed + c.Refuted
if decided > 0 {
c.Accuracy = float64(c.Confirmed) / float64(decided)
c.Brier = sumSq / float64(decided)
c.Overconfident = sumConf/float64(decided)-c.Accuracy > 0.15 && decided >= 5
}
for i := range c.Buckets {
b := &c.Buckets[i]
if b.Count > 0 {
b.MeanConf /= float64(b.Count)
b.HitRate = float64(b.Confirmed) / float64(b.Count)
}
}
sort.SliceStable(c.Buckets, func(i, j int) bool { return c.Buckets[i].Low < c.Buckets[j].Low })
return c, rows.Err()
}
// Stats counts predictions by status.
func (s *Store) Stats(ctx context.Context, mind ids.ID) (map[string]int64, error) {
rows, err := s.db.Query(ctx, `SELECT status, count(*) FROM predictions WHERE mind_id=$1 GROUP BY status`, mind)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int64{}
for rows.Next() {
var k string
var n int64
if err := rows.Scan(&k, &n); err != nil {
return nil, err
}
out[k] = n
}
return out, rows.Err()
}
+89
View File
@@ -0,0 +1,89 @@
package predictions
import (
"context"
"testing"
"time"
"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 TestJudgeIsDeterministic(t *testing.T) {
now := time.Now()
p := Prediction{Criteria: Criteria{Kind: "event_match", Expect: "absent", Terms: []string{"server-01", "fail"}, MinTerms: 2}, WindowEnd: now.Add(time.Hour), Confidence: 0.8}
if _, ok := Judge(p, now, nil, nil); ok {
t.Fatal("should not be judgeable before the window ends")
}
fail := events.Event{ID: ids.New(), Type: events.Observation, Summary: "server-01 failed to boot"}
o, ok := Judge(p, now, []events.Event{fail}, nil)
if !ok || o.Status != StatusRefuted || len(o.Evidence) != 1 {
t.Fatalf("absent-expectation should be refuted by a match: %+v", o)
}
o, ok = Judge(p, now.Add(2*time.Hour), nil, nil)
if !ok || o.Status != StatusConfirmed {
t.Fatalf("absent-expectation should confirm at window end: %+v", o)
}
occurs := p
occurs.Criteria.Expect = "occurs"
if o, ok := Judge(occurs, now.Add(2*time.Hour), nil, nil); !ok || o.Status != StatusRefuted {
t.Fatalf("occurs-expectation should be refuted at window end: %+v", o)
}
unrelated := events.Event{Type: events.Observation, Summary: "weather is mild"}
if _, ok := Judge(occurs, now, []events.Event{unrelated}, nil); ok {
t.Fatal("unrelated event must not resolve")
}
conf := 0.9
b := Prediction{Criteria: Criteria{Kind: "belief", BeliefID: ids.New(), MinConfidence: 0.8}, WindowEnd: now.Add(time.Hour)}
if o, ok := Judge(b, now, nil, &conf); !ok || o.Status != StatusConfirmed {
t.Fatalf("belief criteria wrong: %+v", o)
}
}
func TestStoreResolveAndCalibration(t *testing.T) {
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
m, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "p"})
s := NewStore(pool)
if _, err := s.Create(ctx, pool, New{MindID: m.ID, Statement: "x", Confidence: 1, WindowEnd: time.Now().Add(time.Hour), Criteria: Criteria{Kind: "operator"}}); err == nil {
t.Fatal("certainty 1.0 accepted")
}
mk := func(conf float64) Prediction {
p, err := s.Create(ctx, pool, New{MindID: m.ID, Statement: "server stays healthy", Confidence: conf, WindowEnd: time.Now().Add(time.Hour), Criteria: Criteria{Kind: "event_match", Expect: "absent", Terms: []string{"server", "fail"}}, Source: "test"})
if err != nil {
t.Fatal(err)
}
return p
}
p1, p2, p3 := mk(0.9), mk(0.7), mk(0.3)
if _, err := s.Resolve(ctx, pool, p1, Outcome{Status: StatusRefuted, Reason: "server failed"}, ids.Nil); err != nil {
t.Fatal(err)
}
if _, err := s.Resolve(ctx, pool, p1, Outcome{Status: StatusConfirmed}, ids.Nil); err == nil {
t.Fatal("double resolution accepted")
}
s.Resolve(ctx, pool, p2, Outcome{Status: StatusConfirmed}, ids.Nil) //nolint:errcheck
s.Resolve(ctx, pool, p3, Outcome{Status: StatusConfirmed}, ids.Nil) //nolint:errcheck
c, err := s.Calibrate(ctx, m.ID, time.Now().Add(-time.Hour))
if err != nil || c.Total != 3 || c.Confirmed != 2 || c.Refuted != 1 {
t.Fatalf("calibration: %v %+v", err, c)
}
wantBrier := (0.9*0.9 + 0.3*0.3 + 0.7*0.7) / 3
if d := c.Brier - wantBrier; d > 1e-9 || d < -1e-9 {
t.Fatalf("brier %v want %v", c.Brier, wantBrier)
}
if _, err := pool.Exec(ctx, `DELETE FROM prediction_results`); err == nil {
t.Fatal("results must be append-only")
}
open, _ := s.Open(ctx, ids.Nil)
if len(open) != 0 {
t.Fatalf("open predictions remain: %d", len(open))
}
got, _ := s.Get(ctx, m.ID, p1.ID)
if got.Status != StatusRefuted || got.ResolvedAt == nil || len(got.Resolution) == 0 {
t.Fatalf("resolution not stored: %+v", got)
}
}
+143
View File
@@ -0,0 +1,143 @@
package predictions
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/andydixon/noema/internal/beliefs"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Resolver judges open predictions against recorded events, records
// outcomes, emits prediction_resolved / prediction_error events and feeds
// the result back into the linked belief as evidence (spec §11).
type Resolver struct {
Store *Store
Events *evstore.Store
Beliefs *beliefs.Store
DB interface {
Begin(ctx context.Context) (pgx.Tx, error)
}
Log *slog.Logger
Metrics *observability.Metrics
Now func() time.Time
}
// Run resolves every judgeable open prediction. Safe to call periodically.
func (r *Resolver) Run(ctx context.Context) error {
now := time.Now()
if r.Now != nil {
now = r.Now()
}
open, err := r.Store.Open(ctx, ids.Nil)
if err != nil {
return err
}
for _, p := range open {
if err := r.resolveOne(ctx, p, now); err != nil {
r.Log.Warn("prediction resolution failed", "prediction", p.ID, "err", err)
}
}
return nil
}
func (r *Resolver) resolveOne(ctx context.Context, p Prediction, now time.Time) error {
var window []events.Event
if p.Criteria.Kind == "event_match" {
var err error
window, err = r.Events.Query(ctx, evstore.Filter{MindID: p.MindID, Since: p.WindowStart, Until: minTime(now, p.WindowEnd), Ascending: true, Limit: 1000})
if err != nil {
return err
}
// Events that cite this prediction (its own creation) are not evidence about the world.
filtered := window[:0]
for _, ev := range window {
if ev.ID != p.OriginEvent && ev.Category != events.CatPrediction {
filtered = append(filtered, ev)
}
}
window = filtered
}
var beliefConf *float64
if p.Criteria.Kind == "belief" && r.Beliefs != nil {
if b, err := r.Beliefs.Get(ctx, r.Store.db, p.MindID, p.Criteria.BeliefID); err == nil {
beliefConf = &b.Confidence
}
}
o, ok := Judge(p, now, window, beliefConf)
if !ok {
return nil
}
return storage.Tx(ctx, r.Store.db, func(tx pgx.Tx) error {
res, emitted, err := r.ResolveIn(ctx, tx, p, o, "predictions.resolver")
if err != nil {
return err
}
_ = res
r.Events.Publish(emitted...)
return nil
})
}
// ResolveIn records an outcome inside q and emits the events; shared by the
// scheduler and operator resolution. Returned events must be published by
// the caller after commit.
func (r *Resolver) ResolveIn(ctx context.Context, q storage.Querier, p Prediction, o Outcome, source string) (Result, []events.Event, error) {
typ := events.PredictionResolved
salience := 0.4
if o.Status == StatusRefuted {
typ = events.PredictionError
salience = 0.5 + 0.4*p.Confidence // a confident miss deserves attention
}
refs := append([]events.EvidenceRef{{Kind: "prediction", ID: string(p.ID)}}, o.Evidence...)
evs, err := r.Events.AppendIn(ctx, q, events.New{MindID: p.MindID, Type: typ, Source: source, Summary: fmt.Sprintf("prediction %s (was %.2f): %s — %s", o.Status, p.Confidence, p.Statement, o.Reason),
Salience: salience, Confidence: 1, CausationID: p.OriginEvent, Evidence: refs,
Metadata: map[string]any{"prediction_id": string(p.ID), "outcome": string(o.Status), "confidence": p.Confidence, "belief_id": string(p.BeliefID)},
Payload: map[string]any{"prediction_id": p.ID, "statement": p.Statement, "outcome": o.Status, "confidence": p.Confidence, "reason": o.Reason, "evidence": o.Evidence}})
if err != nil {
return Result{}, nil, err
}
res, err := r.Store.Resolve(ctx, q, p, o, evs[0].ID)
if err != nil {
return Result{}, nil, err
}
if r.Metrics != nil {
if typ == events.PredictionError {
r.Metrics.PredictionErr.Inc()
}
}
// Feed back into the belief the prediction was derived from.
if !p.BeliefID.IsZero() && r.Beliefs != nil && (o.Status == StatusConfirmed || o.Status == StatusRefuted) {
dir, weight := "for", 0.3
if o.Status == StatusRefuted {
dir, weight = "against", 0.5
}
emit := func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) {
out, err := r.Events.AppendIn(ctx, q, n)
if err != nil {
return events.Event{}, err
}
evs = append(evs, out[0])
return out[0], nil
}
if _, err := r.Beliefs.AddEvidence(ctx, q, emit, p.MindID, p.BeliefID, beliefs.EvidenceInput{Direction: dir, Kind: "prediction", RefID: string(p.ID), Source: "prediction:" + string(p.ID), Weight: weight, Credibility: 0.9, Note: "prediction " + string(o.Status) + ": " + p.Statement}, "prediction "+string(o.Status), evs[0].ID, ids.Nil); err != nil {
r.Log.Warn("belief feedback failed", "belief", p.BeliefID, "err", err)
}
}
return res, evs, nil
}
func minTime(a, b time.Time) time.Time {
if a.Before(b) {
return a
}
return b
}