feat(beliefs): source reliability by domain, never global

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.
This commit is contained in:
Andy Dixon
2026-08-10 23:14:38 +01:00
parent 7821d89cf5
commit cff87af28d
10 changed files with 256 additions and 7 deletions
+7 -2
View File
@@ -141,8 +141,9 @@ type BeliefDetail struct {
Assertions []epistemics.Assertion `json:"assertions"` // who stated this proposition, with what stance
// Score semantics: the raw number, its empirical reading where prediction
// outcomes allow one, and the strongest deterministic reason to doubt it.
Calibration beliefs.Calibrated `json:"calibration"`
PrimaryUncertainty string `json:"primary_uncertainty"`
Calibration beliefs.Calibrated `json:"calibration"`
PrimaryUncertainty string `json:"primary_uncertainty"`
Reliability []beliefs.SourceReliability `json:"reliability"` // each cited family\'s record in this belief\'s domain
}
// BeliefDetail assembles the detail; shared with the UI.
@@ -174,6 +175,10 @@ func (s *Server) BeliefDetail(r *http.Request, mind, id ids.ID) (BeliefDetail, e
d.Calibration = beliefs.CalibrateWith(b.Confidence, nil)
}
d.PrimaryUncertainty = beliefs.PrimaryUncertainty(d.Breakdown, d.Evidence)
d.Reliability = s.deps.Beliefs.ReliabilityFor(ctx, mind, b, d.Evidence)
if d.Reliability == nil {
d.Reliability = []beliefs.SourceReliability{}
}
d.Events, _ = s.deps.Events.DerivedFrom(ctx, "belief", string(id), 100)
if !b.OriginEvent.IsZero() {
if e, err := s.deps.Events.Get(ctx, b.OriginEvent); err == nil {
+146
View File
@@ -0,0 +1,146 @@
package beliefs
import (
"context"
"errors"
"fmt"
"math"
"github.com/jackc/pgx/v5"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/ids"
)
// SourceReliability is a source's recorded track record in one domain.
// The score is a Laplace estimate (correct+1)/(correct+wrong+2), so a
// source with no history sits at 0.5 and is left alone.
type SourceReliability struct {
Source string `json:"source"`
Domain string `json:"domain"`
Correct int `json:"correct"`
Wrong int `json:"wrong"`
Score float64 `json:"score"`
Applied bool `json:"applied"` // enough history to adjust credibility
}
// MinReliabilityObservations is the fewest outcomes in a domain before a
// source's record adjusts the credibility of its evidence there.
const MinReliabilityObservations = 3
func (r SourceReliability) score() float64 {
return float64(r.Correct+1) / float64(r.Correct+r.Wrong+2)
}
// Adjust scales a trust-level credibility by the source's record in the
// domain: neutral (0.5) leaves it alone, a perfect record raises it by
// half, a hopeless one halves it. Applied only with enough history.
func (r SourceReliability) Adjust(credibility float64) float64 {
if !r.Applied {
return credibility
}
return math.Min(1, math.Max(0.05, credibility*(0.5+r.Score)))
}
// RecordOutcome adds one correct or wrong outcome for source in domain.
func (s *Store) RecordOutcome(ctx context.Context, q storage.Querier, mind ids.ID, source, domain string, correct bool) error {
if source == "" {
return nil
}
if domain == "" {
domain = "*"
}
col := "wrong"
if correct {
col = "correct"
}
_, err := q.Exec(ctx, fmt.Sprintf(`INSERT INTO source_reliability (mind_id, source, domain, %s) VALUES ($1,$2,$3,1)
ON CONFLICT (mind_id, source, domain) DO UPDATE SET %s = source_reliability.%s + 1, updated_at = now()`, col, col, col), mind, source, domain) // #nosec G202 -- col is one of two literals
return err
}
// Reliability returns the record for a source in a domain.
func (s *Store) Reliability(ctx context.Context, q storage.Querier, mind ids.ID, source, domain string) (SourceReliability, error) {
if q == nil {
q = s.db
}
if domain == "" {
domain = "*"
}
r := SourceReliability{Source: source, Domain: domain}
err := q.QueryRow(ctx, `SELECT correct, wrong FROM source_reliability WHERE mind_id=$1 AND source=$2 AND domain=$3`, mind, source, domain).Scan(&r.Correct, &r.Wrong)
if err != nil && !isNoRows(err) {
return r, err
}
r.Score = round4(r.score())
r.Applied = r.Correct+r.Wrong >= MinReliabilityObservations
return r, nil
}
// ReliabilityFor lists the records of every family cited by a belief in its
// domain, for display.
func (s *Store) ReliabilityFor(ctx context.Context, mind ids.ID, b Belief, rows []EvidenceRow) []SourceReliability {
seen := map[string]bool{}
var out []SourceReliability
for _, e := range rows {
fam := FamilyOf(e.Evidence)
if fam == "" || seen[fam] {
continue
}
seen[fam] = true
if r, err := s.Reliability(ctx, nil, mind, fam, b.Subject); err == nil {
out = append(out, r)
}
}
return out
}
// applyReliability adjusts the credibility of new evidence by the source's
// record in the belief's domain, noting the adjustment on the row so the
// arithmetic stays inspectable. Trust-level credibility is never changed.
func (s *Store) applyReliability(ctx context.Context, q storage.Querier, mind ids.ID, domain string, e Evidence, note string) (Evidence, string) {
fam := FamilyOf(e)
if fam == "" || e.Direction != "for" {
return e, note
}
r, err := s.Reliability(ctx, q, mind, fam, domain)
if err != nil || !r.Applied {
return e, note
}
before := e.Credibility
e.Credibility = round4(r.Adjust(e.Credibility))
if e.Credibility != before {
note += fmt.Sprintf(" [credibility %.2f→%.2f: %s has been right %d and wrong %d time(s) about %s]", before, e.Credibility, fam, r.Correct, r.Wrong, domain)
}
return e, note
}
// RecordBeliefOutcome records an outcome for every primary supporting family
// of a belief, in the belief's domain only.
func (s *Store) RecordBeliefOutcome(ctx context.Context, q storage.Querier, mind, beliefID ids.ID, correct bool) error {
b, err := s.Get(ctx, q, mind, beliefID)
if err != nil {
return err
}
rows, err := s.EvidenceFor(ctx, q, mind, beliefID)
if err != nil {
return err
}
seen := map[string]bool{}
for _, e := range rows {
if e.Retracted || e.Direction != "for" || Derived(e.Derivation) {
continue
}
fam := FamilyOf(e.Evidence)
if fam == "" || seen[fam] || fam == "prediction:"+"" {
continue
}
seen[fam] = true
if err := s.RecordOutcome(ctx, q, mind, fam, b.Subject, correct); err != nil {
return err
}
}
return nil
}
func isNoRows(err error) bool { return errors.Is(err, pgx.ErrNoRows) }
+14 -4
View File
@@ -175,8 +175,12 @@ func (s *Store) Create(ctx context.Context, q storage.Querier, emit Emitter, n N
return Result{}, fmt.Errorf("%w: %s", ErrBlocked, c.Statement)
}
var ev []Evidence
var notes []string
for _, e := range n.Evidence {
ev = append(ev, normalise(e))
ne := normalise(e)
ne, note := s.applyReliability(ctx, q, n.MindID, strings.TrimSpace(n.Subject), ne, e.Note)
ev = append(ev, ne)
notes = append(notes, note)
}
prior := PriorFor(n.Epistemic)
bd := Compute(prior, ev)
@@ -220,8 +224,8 @@ func (s *Store) Create(ctx context.Context, q storage.Querier, emit Emitter, n N
return Result{}, err
}
res := Result{Belief: b, Breakdown: bd, Events: []events.Event{created}}
for _, e := range n.Evidence {
if err := s.insertEvidence(ctx, q, b, normalise(e), e.Note, created.ID); err != nil {
for i, e := range ev {
if err := s.insertEvidence(ctx, q, b, e, notes[i], created.ID); err != nil {
return Result{}, err
}
}
@@ -425,7 +429,8 @@ func (s *Store) AddEvidence(ctx context.Context, q storage.Querier, emit Emitter
return Result{}, fmt.Errorf("%w: belief is superseded", ErrInvalid)
}
e := normalise(in)
if err := s.insertEvidence(ctx, q, b, e, in.Note, ids.Nil); err != nil {
e, note := s.applyReliability(ctx, q, mind, b.Subject, e, in.Note)
if err := s.insertEvidence(ctx, q, b, e, note, ids.Nil); err != nil {
return Result{}, err
}
return s.reevaluate(ctx, q, emit, b, reason, causation, correlation, &e, in)
@@ -617,6 +622,11 @@ func (s *Store) Retract(ctx context.Context, emit Emitter, mind, id ids.ID, reas
if err != nil {
return err
}
// An operator saying "wrong" is an outcome for the sources that backed
// it, in this belief's domain only: nobody's standing elsewhere moves.
if err := s.RecordBeliefOutcome(ctx, tx, mind, id, false); err != nil {
return err
}
ev, err := emit(ctx, tx, events.New{MindID: mind, Type: events.BeliefRetracted, Source: "operator", Summary: fmt.Sprintf("operator %s rejected belief: %s (%s)", by, b.Proposition, reason),
Salience: 0.7, Confidence: 1, Evidence: []events.EvidenceRef{{Kind: "belief", ID: string(b.ID)}},
Metadata: map[string]any{"belief_id": string(b.ID), "actor": by, "do_not_infer": doNotInfer}, Payload: map[string]any{"belief_id": b.ID, "proposition": b.Proposition, "reason": reason, "previous": map[string]any{"confidence": b.Confidence, "status": b.Status}}})
+65
View File
@@ -3,6 +3,7 @@ package beliefs
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -229,3 +230,67 @@ func TestTransientStatesSupersedeInsteadOfContesting(t *testing.T) {
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")
}
}
+3
View File
@@ -128,6 +128,9 @@ func (r *Resolver) ResolveIn(ctx context.Context, q storage.Querier, p Predictio
evs = append(evs, out[0])
return out[0], nil
}
if err := r.Beliefs.RecordBeliefOutcome(ctx, q, p.MindID, p.BeliefID, o.Status == StatusConfirmed); err != nil {
r.Log.Warn("reliability update failed", "belief", p.BeliefID, "err", err)
}
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,
Derivation: beliefs.DerivationPrediction, Family: "prediction:" + string(p.ID), RootIDs: refIDs(o.Evidence)}, "prediction "+string(o.Status), evs[0].ID, ids.Nil); err != nil {
r.Log.Warn("belief feedback failed", "belief", p.BeliefID, "err", err)