feat(beliefs): temporal scope and validity; state changes supersede rather than contradict

Beliefs gain a temporal scope (persistent, transient or unknown, classified
deterministically from the proposition), an observation time and, for
transient states, a validity interval. When a newer belief conflicts with
an older transient one observed at least a minute earlier, the older belief
is closed (valid_until set, status superseded, a supersedes link, a version
and an event saying 'state changed') with its score untouched: 'the door
was open at 10:00' stays true of 10:00 when it is closed at 10:05.
Simultaneous conflicting reports and persistent propositions contradict as
before. Introspection lists superseded states with their validity.
Migration 0020 is additive.
This commit is contained in:
Andy Dixon
2026-08-09 21:42:19 +01:00
parent b661496128
commit 7821d89cf5
9 changed files with 256 additions and 8 deletions
+95 -7
View File
@@ -36,6 +36,13 @@ type Belief struct {
FirstCreated time.Time `json:"first_created"`
LastEvaluated time.Time `json:"last_evaluated"`
Evaluations int `json:"evaluations"`
// Temporal scope and validity (see temporal.go). ValidUntil is set when a
// later observation superseded a transient state; the belief then says
// what was true, and when.
TemporalScope string `json:"temporal_scope"`
ObservedAt *time.Time `json:"observed_at,omitempty"`
ValidFrom *time.Time `json:"valid_from,omitempty"`
ValidUntil *time.Time `json:"valid_until,omitempty"`
}
// EvidenceRow is stored evidence.
@@ -125,6 +132,10 @@ type New struct {
OriginEvent ids.ID
Correlation ids.ID
Causation ids.ID
// ObservedAt is when the state described was observed (defaults to now);
// TemporalScope overrides the deterministic classifier when the producer knows.
ObservedAt *time.Time
TemporalScope string
}
// EvidenceInput is evidence supplied by callers.
@@ -170,14 +181,26 @@ func (s *Store) Create(ctx context.Context, q storage.Querier, emit Emitter, n N
prior := PriorFor(n.Epistemic)
bd := Compute(prior, ev)
st := Derive(bd, n.Epistemic)
observed := time.Now()
if n.ObservedAt != nil {
observed = *n.ObservedAt
}
scope := n.TemporalScope
if scope == "" {
scope = ClassifyScope(n.Proposition)
}
b := Belief{ID: ids.New(), MindID: n.MindID, Proposition: n.Proposition, Subject: strings.TrimSpace(n.Subject), Epistemic: n.Epistemic, Status: st, Confidence: bd.Confidence,
Prior: prior, Version: 1, Source: n.Source, Detail: n.Detail, OriginEvent: n.OriginEvent, FirstCreated: time.Now(), LastEvaluated: time.Now(), Evaluations: 1}
Prior: prior, Version: 1, Source: n.Source, Detail: n.Detail, OriginEvent: n.OriginEvent, FirstCreated: time.Now(), LastEvaluated: time.Now(), Evaluations: 1,
TemporalScope: scope, ObservedAt: &observed}
if scope == ScopeTransient {
b.ValidFrom = &observed
}
if b.Detail == nil {
b.Detail = map[string]any{}
}
detail, _ := json.Marshal(b.Detail)
if _, err := q.Exec(ctx, `INSERT INTO beliefs (id, mind_id, proposition, subject, epistemic, status, confidence, prior, version, source, detail, origin_event)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,1,$9,$10,$11)`, b.ID, b.MindID, b.Proposition, b.Subject, string(b.Epistemic), string(b.Status), b.Confidence, b.Prior, b.Source, detail, nullID(b.OriginEvent)); err != nil {
if _, err := q.Exec(ctx, `INSERT INTO beliefs (id, mind_id, proposition, subject, epistemic, status, confidence, prior, version, source, detail, origin_event, temporal_scope, observed_at, valid_from)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,1,$9,$10,$11,$12,$13,$14)`, b.ID, b.MindID, b.Proposition, b.Subject, string(b.Epistemic), string(b.Status), b.Confidence, b.Prior, b.Source, detail, nullID(b.OriginEvent), b.TemporalScope, b.ObservedAt, b.ValidFrom); err != nil {
return Result{}, err
}
typ := events.BeliefCreated
@@ -274,14 +297,14 @@ func nullID(id ids.ID) *string {
return &s
}
const cols = `b.id, b.mind_id, b.proposition, b.subject, b.epistemic, b.status, b.confidence, b.prior, b.version, b.source, b.detail, b.origin_event, b.superseded_by, b.pinned_status, b.first_created, b.last_evaluated, b.evaluations`
const cols = `b.id, b.mind_id, b.proposition, b.subject, b.epistemic, b.status, b.confidence, b.prior, b.version, b.source, b.detail, b.origin_event, b.superseded_by, b.pinned_status, b.first_created, b.last_evaluated, b.evaluations, b.temporal_scope, b.observed_at, b.valid_from, b.valid_until`
func scan(row pgx.Row) (Belief, error) {
var b Belief
var epi, status string
var detail []byte
var origin, superseded, pinned *string
if err := row.Scan(&b.ID, &b.MindID, &b.Proposition, &b.Subject, &epi, &status, &b.Confidence, &b.Prior, &b.Version, &b.Source, &detail, &origin, &superseded, &pinned, &b.FirstCreated, &b.LastEvaluated, &b.Evaluations); err != nil {
if err := row.Scan(&b.ID, &b.MindID, &b.Proposition, &b.Subject, &epi, &status, &b.Confidence, &b.Prior, &b.Version, &b.Source, &detail, &origin, &superseded, &pinned, &b.FirstCreated, &b.LastEvaluated, &b.Evaluations, &b.TemporalScope, &b.ObservedAt, &b.ValidFrom, &b.ValidUntil); err != nil {
return b, storage.Translate(err)
}
b.Epistemic, b.Status = concepts.Epistemic(epi), Status(status)
@@ -528,6 +551,16 @@ func (s *Store) detectContradictions(ctx context.Context, q storage.Querier, emi
if exists {
continue
}
if StateChange(o, b) {
// A later observation of a transient state: the older belief was
// true of its time and is closed, not contested and not weakened.
ev, err := s.closeState(ctx, q, emit, o, b, sim, causation)
if err != nil {
return nil, err
}
out = append(out, ev)
continue
}
ev, err := emit(ctx, q, events.New{MindID: b.MindID, Type: events.ContradictionDetected, Source: "beliefs.contradiction", Summary: fmt.Sprintf("contradiction: %q vs %q", trunc(b.Proposition, 60), trunc(o.Proposition, 60)),
Salience: 0.8, Confidence: sim, CausationID: causation,
Evidence: []events.EvidenceRef{{Kind: "belief", ID: string(b.ID)}, {Kind: "belief", ID: string(o.ID)}},
@@ -750,7 +783,7 @@ func (s *Store) Links(ctx context.Context, mind, id ids.ID) ([]Link, error) {
var detail []byte
var origin, superseded, pinned *string
if err := rows.Scan(&l.ID, &l.FromID, &l.ToID, &l.Relation, &l.Confidence, &l.CreatedAt,
&other.ID, &other.MindID, &other.Proposition, &other.Subject, &epi, &status, &other.Confidence, &other.Prior, &other.Version, &other.Source, &detail, &origin, &superseded, &pinned, &other.FirstCreated, &other.LastEvaluated, &other.Evaluations); err != nil {
&other.ID, &other.MindID, &other.Proposition, &other.Subject, &epi, &status, &other.Confidence, &other.Prior, &other.Version, &other.Source, &detail, &origin, &superseded, &pinned, &other.FirstCreated, &other.LastEvaluated, &other.Evaluations, &other.TemporalScope, &other.ObservedAt, &other.ValidFrom, &other.ValidUntil); err != nil {
return nil, err
}
other.Epistemic, other.Status = concepts.Epistemic(epi), Status(status)
@@ -876,7 +909,7 @@ func (s *Store) RecentChanges(ctx context.Context, mind ids.ID, since time.Time,
var detail []byte
var origin, superseded, pinned *string
b := &c.Belief
if err := rows.Scan(&b.ID, &b.MindID, &b.Proposition, &b.Subject, &epi, &status, &b.Confidence, &b.Prior, &b.Version, &b.Source, &detail, &origin, &superseded, &pinned, &b.FirstCreated, &b.LastEvaluated, &b.Evaluations, &fromStatus, &c.FromConf, &c.Reason, &c.At); err != nil {
if err := rows.Scan(&b.ID, &b.MindID, &b.Proposition, &b.Subject, &epi, &status, &b.Confidence, &b.Prior, &b.Version, &b.Source, &detail, &origin, &superseded, &pinned, &b.FirstCreated, &b.LastEvaluated, &b.Evaluations, &b.TemporalScope, &b.ObservedAt, &b.ValidFrom, &b.ValidUntil, &fromStatus, &c.FromConf, &c.Reason, &c.At); err != nil {
return nil, err
}
b.Epistemic, b.Status, c.FromStatus = concepts.Epistemic(epi), Status(status), Status(fromStatus)
@@ -906,3 +939,58 @@ func (s *Store) Uncertain(ctx context.Context, mind ids.ID, limit int) ([]Belief
}
return out, rows.Err()
}
// closeState ends the validity of an older transient belief when a newer
// observation shows the state changed. Its score is untouched; its status
// becomes superseded with valid_until set, and a version and an event
// record why. "The door was open at 10:00" remains true.
func (s *Store) closeState(ctx context.Context, q storage.Querier, emit Emitter, older, newer Belief, sim float64, causation ids.ID) (events.Event, error) {
until := *newer.ObservedAt
ev, err := emit(ctx, q, events.New{MindID: older.MindID, Type: events.BeliefUpdated, Source: "beliefs.temporal", Summary: fmt.Sprintf("state changed: %q held until %s; now %q", trunc(older.Proposition, 60), until.Format("15:04:05"), trunc(newer.Proposition, 60)),
Salience: 0.5, Confidence: sim, CausationID: causation,
Evidence: []events.EvidenceRef{{Kind: "belief", ID: string(older.ID), Note: "closed"}, {Kind: "belief", ID: string(newer.ID), Note: "current"}},
Metadata: map[string]any{"belief_id": string(older.ID), "superseded_by": string(newer.ID), "valid_until": until, "reason": "state changed"},
Payload: map[string]any{"older": map[string]any{"id": older.ID, "proposition": older.Proposition, "valid_from": older.ValidFrom, "valid_until": until}, "newer": map[string]any{"id": newer.ID, "proposition": newer.Proposition, "observed_at": newer.ObservedAt}, "method": "temporal supersession"}})
if err != nil {
return events.Event{}, err
}
if _, err := q.Exec(ctx, `UPDATE beliefs SET status='superseded', superseded_by=$3, valid_until=$4, version=version+1, last_evaluated=now(), evaluations=evaluations+1 WHERE id=$1 AND mind_id=$2`, older.ID, older.MindID, newer.ID, until); err != nil {
return events.Event{}, err
}
older.Version++
older.Status = StatusSuperseded
if err := s.insertVersion(ctx, q, older, Breakdown{Prior: older.Prior, Confidence: older.Confidence, Meaning: ScoreMeaning, Notes: []string{"state changed; score unchanged, validity closed"}}, "state changed: superseded by a later observation", ev.ID); err != nil {
return events.Event{}, err
}
if _, err := q.Exec(ctx, `INSERT INTO belief_links (id, mind_id, from_id, to_id, relation, confidence, event_id) VALUES ($1,$2,$3,$4,'supersedes',$5,$6) ON CONFLICT DO NOTHING`, ids.New(), older.MindID, newer.ID, older.ID, sim, ev.ID); err != nil {
return events.Event{}, err
}
return ev, nil
}
// History lists superseded beliefs about a topic with their validity, so a
// mind can say what was true and when.
func (s *Store) History(ctx context.Context, mind ids.ID, text string, limit int) ([]Belief, error) {
if limit <= 0 || limit > 50 {
limit = 10
}
toks, _ := Polarity(text)
if len(toks) == 0 {
return nil, nil
}
q := strings.Join(toks, " | ")
rows, err := s.db.Query(ctx, `SELECT `+cols+` FROM beliefs b WHERE b.mind_id=$1 AND b.status='superseded' AND b.valid_until IS NOT NULL AND b.search @@ to_tsquery('english', $2) ORDER BY b.valid_until DESC LIMIT $3`, mind, q, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Belief
for rows.Next() {
b, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
+68
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"testing"
"time"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/minds"
@@ -161,3 +162,70 @@ func TestUnknownIsNotFalse(t *testing.T) {
t.Fatal("absence of evidence must not read as falsity")
}
}
// TestTransientStatesSupersedeInsteadOfContesting: a later observation of a
// changed state closes the earlier belief's validity and leaves it true of
// its time; persistent propositions still contradict.
func TestTransientStatesSupersedeInsteadOfContesting(t *testing.T) {
if ClassifyScope("the door is open") != ScopeTransient || ClassifyScope("server-a is down") != ScopeTransient || ClassifyScope("atlas is the primary database host") != ScopePersistent || ClassifyScope("andy prefers go") != ScopePersistent {
t.Fatal("scope classifier")
}
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
m, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "tmp"})
es := evstore.NewStore(pool, evstore.NewBus(8), nil)
emit := func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) {
out, err := es.AppendIn(ctx, q, n)
if err != nil {
return events.Event{}, err
}
return out[0], nil
}
s := NewStore(pool)
ten := time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC)
tenFive := ten.Add(5 * time.Minute)
ev := []EvidenceInput{{Direction: "for", Kind: "note", Source: "sensor", Weight: 0.7, Credibility: 0.9}}
open, err := s.Create(ctx, pool, emit, New{MindID: m.ID, Proposition: "the door is open", Subject: "door", Epistemic: concepts.Observation, Source: "sensor", Evidence: ev, ObservedAt: &ten})
if err != nil || open.Belief.TemporalScope != ScopeTransient || open.Belief.ValidFrom == nil {
t.Fatalf("open: %+v %v", open.Belief, err)
}
closed, err := s.Create(ctx, pool, emit, New{MindID: m.ID, Proposition: "the door is not open", Subject: "door", Epistemic: concepts.Observation, Source: "sensor", Evidence: ev, ObservedAt: &tenFive})
if err != nil {
t.Fatal(err)
}
before, _ := s.Get(ctx, pool, m.ID, open.Belief.ID)
if before.Status != StatusSuperseded || before.ValidUntil == nil || !before.ValidUntil.Equal(tenFive) || before.SupersededBy != closed.Belief.ID {
t.Fatalf("earlier state should be closed, not contested: %+v", before)
}
if before.Confidence != open.Belief.Confidence {
t.Fatalf("closing a state must not change its score: %.3f -> %.3f", open.Belief.Confidence, before.Confidence)
}
now, _ := s.Get(ctx, pool, m.ID, closed.Belief.ID)
if now.Status == StatusContested || now.Confidence < closed.Belief.Confidence {
t.Fatalf("current state should not be contested: %+v", now)
}
if rows, _ := s.EvidenceFor(ctx, pool, m.ID, now.ID); len(rows) != 1 {
t.Fatalf("no counter-evidence should be written for a state change: %+v", rows)
}
hist, _ := s.History(ctx, m.ID, "door open", 5)
if len(hist) != 1 || hist[0].ID != open.Belief.ID {
t.Fatalf("history: %+v", hist)
}
// Same-moment conflicting reports remain a contradiction.
a, _ := s.Create(ctx, pool, emit, New{MindID: m.ID, Proposition: "the pump is running", Subject: "pump", Epistemic: concepts.Observation, Source: "a", Evidence: ev, ObservedAt: &ten})
b, _ := s.Create(ctx, pool, emit, New{MindID: m.ID, Proposition: "the pump is not running", Subject: "pump", Epistemic: concepts.Observation, Source: "b", Evidence: ev, ObservedAt: &ten})
ab, _ := s.Get(ctx, pool, m.ID, a.Belief.ID)
if ab.Status == StatusSuperseded || ab.ValidUntil != nil {
t.Fatalf("simultaneous conflict must contest, not supersede: %+v", ab)
}
if links, _ := s.Links(ctx, m.ID, b.Belief.ID); len(links) != 1 || links[0].Relation != "contradicts" {
t.Fatalf("contradiction link expected: %+v", links)
}
// Persistent propositions contradict regardless of time.
p1, _ := s.Create(ctx, pool, emit, New{MindID: m.ID, Proposition: "atlas is the primary database host", Subject: "atlas", Epistemic: concepts.Claim, Source: "x", Evidence: ev, ObservedAt: &ten})
s.Create(ctx, pool, emit, New{MindID: m.ID, Proposition: "atlas is not the primary database host", Subject: "atlas", Epistemic: concepts.Claim, Source: "y", Evidence: ev, ObservedAt: &tenFive}) //nolint:errcheck
p1b, _ := s.Get(ctx, pool, m.ID, p1.Belief.ID)
if p1b.Status == StatusSuperseded {
t.Fatalf("persistent fact wrongly treated as a state change: %+v", p1b)
}
}
+55
View File
@@ -0,0 +1,55 @@
package beliefs
import (
"regexp"
"strings"
"time"
)
// Temporal scopes. Persistent propositions are true or false without an
// interval ("atlas is the primary host", "Andy prefers Go"). Transient ones
// describe a state that can change ("the door is open", "server A is down")
// and carry a validity interval. Unknown is the honest default when the
// classifier cannot tell.
const (
ScopePersistent = "persistent"
ScopeTransient = "transient"
ScopeUnknown = "unknown"
)
// MinStateChangeGap is the shortest interval between two observations for a
// later, conflicting observation of a transient state to count as a state
// change rather than a contradiction. Two reports of the same moment that
// disagree are a contradiction; reports minutes apart are a change.
const MinStateChangeGap = time.Minute
var (
reTransient = regexp.MustCompile(`(?i)\b(?:is|are|was|were|remains|seems|looks|appears|has been|have been|is currently|is now)\s+(?:not\s+|still\s+|no longer\s+)?(?:down|up|open|closed|offline|online|running|stopped|stuck|failing|degraded|full|empty|busy|idle|locked|unlocked|available|unavailable|slow|broken|healthy|unhealthy|overloaded|unreachable|reachable|responding|paused|active|inactive|pending|in progress|at capacity|on call|away|present|absent|late|early|hot|cold|warm|crossed)\b|\b(?:right now|currently|at the moment|at \d{1,2}:\d{2}|between \d|since \d|until \d|today|tonight|this morning|this afternoon|for the last|in the last)\b`)
rePersistent = regexp.MustCompile(`(?i)\b(?:is (?:a|an|the)\b|are (?:a|an|the)\b|was born|prefers?|likes?|dislikes?|always|never|is named|is called|is the name|belongs to|owns|is made of|is located in|is based in|works at|works for|is part of|means|stands for|is written in|uses|runs on|depends on)\b`)
)
// ClassifyScope decides, deterministically, whether a proposition names a
// transient state, a persistent fact, or neither recognisably.
func ClassifyScope(proposition string) string {
p := strings.TrimSpace(proposition)
switch {
case reTransient.MatchString(p):
return ScopeTransient
case rePersistent.MatchString(p):
return ScopePersistent
}
return ScopeUnknown
}
// StateChange reports whether a newer belief should close an older,
// conflicting one as a change of state rather than contest it: both must be
// transient, and the newer observation must be later by at least the gap.
func StateChange(older, newer Belief) bool {
if older.TemporalScope != ScopeTransient || newer.TemporalScope != ScopeTransient {
return false
}
if older.ObservedAt == nil || newer.ObservedAt == nil {
return false
}
return newer.ObservedAt.Sub(*older.ObservedAt) >= MinStateChangeGap
}
+13 -1
View File
@@ -123,7 +123,8 @@ func (h Handlers) perceive(ctx context.Context, c *brain.Cycle, t workspace.Thou
Note: actor + " doubts it", Derivation: beliefs.DerivationAssertion, Family: beliefs.FamilyForActor(actor), RootIDs: []string{srcID}})
}
var err error
res, err = h.Beliefs.Create(ctx, c.Tx, emitter(c), beliefs.New{MindID: mind.ID, Proposition: in.Proposition, Epistemic: concepts.Claim, Source: "conversation", OriginEvent: perc.ID, Causation: perc.ID, Correlation: c.State.CycleID, Evidence: ev,
observed := c.State.Now
res, err = h.Beliefs.Create(ctx, c.Tx, emitter(c), beliefs.New{MindID: mind.ID, Proposition: in.Proposition, Epistemic: concepts.Claim, Source: "conversation", OriginEvent: perc.ID, Causation: perc.ID, Correlation: c.State.CycleID, Evidence: ev, ObservedAt: &observed,
Detail: map[string]any{"attributed_to": attributed, "reported": in.Reported, "interpretation_confidence": in.Confidence}})
if err != nil {
c.Emit(events.New{Type: events.Metacognition, Source: "brain.perceive", Summary: "claim not recorded: " + err.Error(), Salience: 0.3, Confidence: 1, Metadata: map[string]any{"check": "claim_rejected"}})
@@ -304,6 +305,17 @@ func (h Handlers) addKnowledge(ctx context.Context, c *brain.Cycle, in *Intent,
in.Uncertainty = fmt.Sprintf("The strongest relevant belief is only at %.2f.", maxConf)
}
}
// Historical truth is not overwritten by the present: say what was
// true and when, alongside what is believed now.
if past, _ := h.Beliefs.History(ctx, mind, topic, 3); len(past) > 0 {
for _, b := range past {
from := "earlier"
if b.ValidFrom != nil {
from = b.ValidFrom.Format("15:04 on 2 Jan")
}
in.Points = append(in.Points, fmt.Sprintf("Earlier: %s (true from %s until %s, then superseded).", b.Proposition, from, b.ValidUntil.Format("15:04 on 2 Jan")))
}
}
}
if h.Memory != nil {
if hits, _ := h.Memory.Retrieve(ctx, mind, memory.Query{Text: topic, Limit: 4, Now: c.State.Now, Semantic: true}); len(hits) > 0 {