A source's track record is kept per (evidence family, belief subject) and updated only by outcomes: predictions derived from a belief resolving, and operators marking a belief wrong. With three or more outcomes in a domain the record adjusts the credibility of that source's new evidence in that domain, and the adjustment is written on the evidence row. Standing in one domain never moves standing in another, and the trust-level credibility constants are never rewritten. Migration 0021 is additive; belief pages show each cited family's record in the belief's domain.
297 lines
14 KiB
Go
297 lines
14 KiB
Go
package beliefs
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
evstore "github.com/andydixon/noema/internal/events"
|
|
"github.com/andydixon/noema/internal/minds"
|
|
"github.com/andydixon/noema/internal/storage"
|
|
"github.com/andydixon/noema/migrations"
|
|
"github.com/andydixon/noema/pkg/concepts"
|
|
"github.com/andydixon/noema/pkg/events"
|
|
"github.com/andydixon/noema/pkg/ids"
|
|
)
|
|
|
|
func TestBeliefLifecycleProvenanceAndContradiction(t *testing.T) {
|
|
pool := storage.TestPool(t, migrations.FS)
|
|
ctx := context.Background()
|
|
m, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "b"})
|
|
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)
|
|
obs1s, _ := es.Append(ctx, events.New{MindID: m.ID, Type: events.Observation, Source: "smart", Summary: "SMART CRC errors on server-01", Salience: 0.8, Confidence: 1})
|
|
obs2s, _ := es.Append(ctx, events.New{MindID: m.ID, Type: events.Observation, Source: "metrics", Summary: "I/O latency high on server-01", Salience: 0.8, Confidence: 1})
|
|
obs1, obs2 := obs1s[0], obs2s[0]
|
|
|
|
res, err := s.Create(ctx, pool, emit, New{MindID: m.ID, Proposition: "server-01 has an intermittent storage problem", Subject: "server-01", Epistemic: concepts.Hypothesis, Source: "reasoning",
|
|
Evidence: []EvidenceInput{{Direction: "for", Kind: "event", RefID: string(obs1.ID), Source: "smart", Weight: 0.6, Credibility: 0.9, Note: "CRC errors"}},
|
|
Alternatives: []string{"SATA cable issue", "controller issue"}, OriginEvent: obs1.ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := res.Belief
|
|
if b.Status != StatusHypothesis && b.Status != StatusTentative || b.Confidence <= PriorFor(concepts.Hypothesis) || res.Events[0].Type != events.HypothesisCreated {
|
|
t.Fatalf("create wrong: %+v %+v", b, res.Events)
|
|
}
|
|
if len(res.Events[0].Evidence) != 2 {
|
|
t.Fatalf("provenance refs missing: %+v", res.Events[0].Evidence)
|
|
}
|
|
// Support from a second, independent source raises confidence more than repetition.
|
|
r2, err := s.AddEvidence(ctx, pool, emit, m.ID, b.ID, EvidenceInput{Direction: "for", Kind: "event", RefID: string(obs2.ID), Source: "metrics", Weight: 0.6, Credibility: 0.9, Note: "latency"}, "observed latency", obs2.ID, ids.Nil)
|
|
if err != nil || r2.Belief.Confidence <= b.Confidence || r2.Belief.Version != 2 || r2.Events[0].Type != events.HypothesisSupported {
|
|
t.Fatalf("support wrong: %v %+v", err, r2)
|
|
}
|
|
// Counter-evidence lowers it and may contest.
|
|
r3, err := s.AddEvidence(ctx, pool, emit, m.ID, b.ID, EvidenceInput{Direction: "against", Kind: "note", Source: "smart", Weight: 0.7, Credibility: 0.9, Note: "extended SMART test passed"}, "test passed", ids.Nil, ids.Nil)
|
|
if err != nil || r3.Belief.Confidence >= r2.Belief.Confidence || r3.Events[0].Type != events.HypothesisWeakened {
|
|
t.Fatalf("weaken wrong: %v %+v", err, r3)
|
|
}
|
|
vs, _ := s.Versions(ctx, m.ID, b.ID)
|
|
if len(vs) != 3 || vs[0].Version != 3 || vs[0].Breakdown.Against == 0 {
|
|
t.Fatalf("versions wrong: %+v", vs)
|
|
}
|
|
if _, err := pool.Exec(ctx, `DELETE FROM belief_versions WHERE belief_id=$1`, b.ID); err == nil {
|
|
t.Fatal("belief history must be append-only")
|
|
}
|
|
alts, _ := s.Alternatives(ctx, m.ID, b.ID)
|
|
if len(alts) != 2 {
|
|
t.Fatalf("alternatives missing: %+v", alts)
|
|
}
|
|
|
|
// A negated proposition triggers a contradiction and links both beliefs.
|
|
rc, err := s.Create(ctx, pool, emit, New{MindID: m.ID, Proposition: "server-01 does not have an intermittent storage problem", Subject: "server-01", Epistemic: concepts.Claim, Source: "user",
|
|
Evidence: []EvidenceInput{{Direction: "for", Kind: "note", Source: "user", Weight: 0.5, Credibility: 0.85}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var sawContra bool
|
|
for _, ev := range rc.Events {
|
|
if ev.Type == events.ContradictionDetected {
|
|
sawContra = true
|
|
}
|
|
}
|
|
if !sawContra {
|
|
t.Fatalf("no contradiction event: %+v", rc.Events)
|
|
}
|
|
links, _ := s.Links(ctx, m.ID, b.ID)
|
|
if len(links) != 1 || links[0].Relation != "contradicts" || links[0].Other.ID != rc.Belief.ID {
|
|
t.Fatalf("links wrong: %+v", links)
|
|
}
|
|
after, _ := s.Get(ctx, pool, m.ID, b.ID)
|
|
if after.Confidence >= r3.Belief.Confidence {
|
|
t.Fatalf("contradiction should lower confidence: %v -> %v", r3.Belief.Confidence, after.Confidence)
|
|
}
|
|
|
|
// Operator correction: rejected, pinned, history kept, do-not-infer blocks recreation.
|
|
rr, err := s.Retract(ctx, emit, m.ID, b.ID, "it was the SATA cable", "andy", true)
|
|
if err != nil || rr.Belief.Status != StatusRejected || rr.Events[0].Type != events.BeliefRetracted {
|
|
t.Fatalf("retract wrong: %v %+v", err, rr)
|
|
}
|
|
got, _ := s.Get(ctx, pool, m.ID, b.ID)
|
|
if got.Status != StatusRejected || got.PinnedStatus == nil {
|
|
t.Fatalf("not pinned rejected: %+v", got)
|
|
}
|
|
vs, _ = s.Versions(ctx, m.ID, b.ID)
|
|
if len(vs) < 4 {
|
|
t.Fatalf("history lost on retraction: %d", len(vs))
|
|
}
|
|
if _, err := s.Create(ctx, pool, emit, New{MindID: m.ID, Proposition: "server-01 has an intermittent storage problem", Epistemic: concepts.Hypothesis}); !errors.Is(err, ErrBlocked) {
|
|
t.Fatalf("do-not-infer not enforced: %v", err)
|
|
}
|
|
cs, _ := s.Corrections(ctx, m.ID, "")
|
|
if len(cs) != 2 {
|
|
t.Fatalf("corrections missing: %+v", cs)
|
|
}
|
|
// Isolation: another mind cannot see these.
|
|
other, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "other"})
|
|
if _, err := s.Get(ctx, pool, other.ID, b.ID); err == nil {
|
|
t.Fatal("cross-mind belief read")
|
|
}
|
|
list, _ := s.List(ctx, other.ID, Filter{})
|
|
if len(list) != 0 {
|
|
t.Fatal("cross-mind belief list")
|
|
}
|
|
}
|
|
|
|
// TestUnknownIsNotFalse: withdrawing every piece of evidence leaves a belief
|
|
// unknown, not tentatively held at its prior and not rejected.
|
|
func TestUnknownIsNotFalse(t *testing.T) {
|
|
if Derive(Breakdown{Prior: 0.5, Confidence: 0.5}, concepts.Claim) != StatusUnknown {
|
|
t.Fatal("no evidence should derive unknown")
|
|
}
|
|
if Derive(Breakdown{Prior: 0.25, Confidence: 0.25}, concepts.Hypothesis) != StatusHypothesis {
|
|
t.Fatal("hypotheses keep their own status")
|
|
}
|
|
pool := storage.TestPool(t, migrations.FS)
|
|
ctx := context.Background()
|
|
m, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "unk"})
|
|
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)
|
|
res, err := s.Create(ctx, pool, emit, New{MindID: m.ID, Proposition: "the cache is warm", Epistemic: concepts.Claim, Source: "test",
|
|
Evidence: []EvidenceInput{{Direction: "for", Kind: "memory", RefID: "mem-1", Source: "user", Weight: 0.6, Credibility: 0.9}}})
|
|
if err != nil || res.Belief.Status == StatusUnknown {
|
|
t.Fatalf("create: %+v %v", res.Belief, err)
|
|
}
|
|
if _, err := s.RetractEvidence(ctx, pool, m.ID, "memory", "mem-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.Reevaluate(ctx, pool, emit, m.ID, res.Belief.ID, "memory retracted"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := s.Get(ctx, pool, m.ID, res.Belief.ID)
|
|
if b.Status != StatusUnknown {
|
|
t.Fatalf("after retraction status=%s (%.2f); want unknown", b.Status, b.Confidence)
|
|
}
|
|
if b.Status == StatusRejected {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestReliabilityIsPerDomain: being wrong about databases three times lowers a
|
|
// source's credibility for database claims and nothing else; the trust-level
|
|
// credibility itself never changes.
|
|
func TestReliabilityIsPerDomain(t *testing.T) {
|
|
pool := storage.TestPool(t, migrations.FS)
|
|
ctx := context.Background()
|
|
m, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "rel"})
|
|
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)
|
|
andy := func(subject, prop string) Result {
|
|
r, err := s.Create(ctx, pool, emit, New{MindID: m.ID, Proposition: prop, Subject: subject, Epistemic: concepts.Claim, Source: "conversation",
|
|
Evidence: []EvidenceInput{{Direction: "for", Kind: "assertion", Source: "asserted-by:andy", Family: "actor:andy", Derivation: DerivationAssertion, Weight: 0.5, Credibility: 0.85}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
baseline := andy("postgres", "postgres corrupts data under load")
|
|
// Three operator corrections in the database domain.
|
|
for i, prop := range []string{"postgres loses commits", "postgres ignores fsync", "postgres drops indexes"} {
|
|
r := andy("postgres", prop)
|
|
if _, err := s.Retract(ctx, emit, m.ID, r.Belief.ID, "wrong", "operator", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec, _ := s.Reliability(ctx, nil, m.ID, "actor:andy", "postgres")
|
|
if rec.Wrong != i+1 {
|
|
t.Fatalf("wrong count after %d corrections: %+v", i+1, rec)
|
|
}
|
|
}
|
|
rec, _ := s.Reliability(ctx, nil, m.ID, "actor:andy", "postgres")
|
|
if !rec.Applied || rec.Score >= 0.5 {
|
|
t.Fatalf("reliability should now apply and be low: %+v", rec)
|
|
}
|
|
// New database claims from Andy are weighed down, with the reason on the row.
|
|
after := andy("postgres", "postgres cannot handle json")
|
|
if after.Belief.Confidence >= baseline.Belief.Confidence {
|
|
t.Fatalf("credibility not lowered in domain: %.3f vs %.3f", after.Belief.Confidence, baseline.Belief.Confidence)
|
|
}
|
|
rows, _ := s.EvidenceFor(ctx, pool, m.ID, after.Belief.ID)
|
|
if len(rows) != 1 || rows[0].Credibility >= 0.85 || !strings.Contains(rows[0].Note, "credibility 0.85→") {
|
|
t.Fatalf("adjustment not recorded: %+v", rows)
|
|
}
|
|
// A different domain is untouched: same credibility, same score as the baseline.
|
|
diary := andy("andy", "andy was born in march")
|
|
if diary.Belief.Confidence != baseline.Belief.Confidence {
|
|
t.Fatalf("unrelated domain affected: %.3f vs %.3f", diary.Belief.Confidence, baseline.Belief.Confidence)
|
|
}
|
|
other, _ := s.Reliability(ctx, nil, m.ID, "actor:andy", "andy")
|
|
if other.Applied || other.Wrong != 0 {
|
|
t.Fatalf("global standing leaked: %+v", other)
|
|
}
|
|
// Trust-level credibility is a constant; it was never rewritten.
|
|
if events.TrustUser.Credibility() != 0.85 {
|
|
t.Fatal("trust credibility changed")
|
|
}
|
|
}
|