Files
noema/internal/beliefs/confidence.go
T
Andy Dixon b661496128 feat(beliefs): state what a score means; gated calibration; primary uncertainty
Every breakdown now carries its meaning: an uncalibrated epistemic score
derived from recorded evidence, not a probability of truth. Belief detail
adds a calibrated estimate read from the mind's resolved predictions when
at least twenty were decided in the score's range, and otherwise says why
none is shown; and names the primary uncertainty deterministically (no live
evidence, conflicting evidence, second-hand only, unclear reading, a single
source family, little evidence). The belief list heading becomes Score.
2026-08-09 19:14:53 +01:00

497 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package beliefs manages propositions held with explicit, evidence-derived
// confidence. Confidence is arithmetic over evidence weights and source
// credibility; no model ever sets it directly (spec §10, §62).
package beliefs
import (
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/events"
)
// Status of a belief.
type Status string
// Statuses in rough order of commitment.
const (
StatusHypothesis Status = "hypothesis"
StatusUnknown Status = "unknown" // recorded, but no live evidence for or against: not false, not known
StatusTentative Status = "tentative"
StatusLikely Status = "likely"
StatusAccepted Status = "accepted"
StatusContested Status = "contested"
StatusRejected Status = "rejected"
StatusSuperseded Status = "superseded"
)
// Valid reports whether s is known.
func (s Status) Valid() bool {
switch s {
case StatusHypothesis, StatusTentative, StatusLikely, StatusAccepted, StatusContested, StatusRejected, StatusSuperseded, StatusUnknown:
return true
}
return false
}
// Slope converts net evidence into log-odds. 1.5 means one unit of fully
// credible evidence moves 0.5 to about 0.82.
const Slope = 1.5
// Bounds keep beliefs away from certainty unless an operator pins a fact.
const (
MinConfidence = 0.02
MaxConfidence = 0.98
)
// PriorFor returns the starting confidence for an epistemic kind.
func PriorFor(e concepts.Epistemic) float64 {
switch e {
case concepts.Hypothesis, concepts.Inference:
return 0.25
case concepts.Assumption, concepts.Opinion:
return 0.4
case concepts.Claim:
return 0.5
case concepts.Observation, concepts.Memory:
return 0.6
case concepts.Fact:
return 0.8
default:
return 0.5
}
}
// Evidence is one piece of support or opposition.
type Evidence struct {
Direction string `json:"direction"` // for | against
Kind string `json:"kind"`
RefID string `json:"ref_id"`
Source string `json:"source"`
Weight float64 `json:"weight"` // how strongly it bears on the proposition, (0,1]
Credibility float64 `json:"credibility"` // trust in the source, [0,1]
Retracted bool `json:"retracted"`
// Provenance. Family is the independent-source key (an actor, an
// integration, an operator); Derivation says how this row relates to its
// roots; RootIDs are the originating event or memory ids. Derived rows
// (consolidation, inference, reflection, dream, retrieval) never add
// weight to a family that already has a primary member: they carry no
// information their roots did not.
Derivation string `json:"derivation,omitempty"`
Family string `json:"family,omitempty"`
RootIDs []string `json:"root_ids,omitempty"`
ProvenanceStatus string `json:"provenance_status,omitempty"` // recorded | legacy_unknown
}
// ScoreMeaning states, on every breakdown, what the confidence number is.
// It is an uncalibrated epistemic score: the deterministic output of the
// evidence arithmetic, comparable between one mind's beliefs, and not a
// probability that the proposition is true. Only resolved predictions can
// say how a score has tracked reality; see Calibrated.
const ScoreMeaning = "uncalibrated epistemic score derived from recorded evidence; not a probability of truth"
// Calibrated is the empirical reading of a score, when there is one: the
// hit rate of this mind's resolved predictions whose stated confidence fell
// in the same range. It is offered only above a minimum sample so that no
// false precision is shown.
type Calibrated struct {
Raw float64 `json:"raw"`
Estimate float64 `json:"estimate,omitempty"`
SampleSize int `json:"sample_size"`
Available bool `json:"available"`
Basis string `json:"basis"`
Reason string `json:"reason,omitempty"`
}
// CalibrationBucket is one range of stated confidence with its outcomes.
type CalibrationBucket struct {
Low, High float64
Decided int
HitRate float64
}
// MinCalibrationSample is the fewest decided predictions in a bucket before
// a calibrated estimate is shown.
const MinCalibrationSample = 20
// CalibrateWith maps a score onto the bucket containing it. Deterministic;
// the buckets come from prediction outcomes.
func CalibrateWith(score float64, buckets []CalibrationBucket) Calibrated {
c := Calibrated{Raw: round4(score), Basis: "resolved predictions of this mind with stated confidence in the same range"}
for _, b := range buckets {
if score >= b.Low && score < b.High {
c.SampleSize = b.Decided
if b.Decided >= MinCalibrationSample {
c.Available, c.Estimate = true, round4(b.HitRate)
} else {
c.Reason = fmt.Sprintf("insufficient data: %d decided prediction(s) in this range; %d needed", b.Decided, MinCalibrationSample)
}
return c
}
}
c.Reason = "no prediction outcomes recorded in this range"
return c
}
// PrimaryUncertainty names, deterministically, the strongest reason to doubt
// a score: what an operator should look at first.
func PrimaryUncertainty(b Breakdown, ev []EvidenceRow) string {
var live, reported int
lowReading := false
for _, e := range ev {
if e.Retracted || e.Weight <= 0 || e.Direction == "against" {
continue
}
live++
if e.Derivation == DerivationAssertion && strings.Contains(e.Note, "reported") {
reported++
}
if strings.Contains(e.Note, "interpretation confidence") {
for _, low := range []string{"40%", "50%", "60%"} {
if strings.Contains(e.Note, low) {
lowReading = true
}
}
}
}
switch {
case live == 0 && b.Against == 0:
return "no live evidence"
case b.For > 0 && b.Against > 0 && b.Against >= 0.5*b.For:
return "conflicting evidence"
case live > 0 && reported == live:
return "second-hand only: nobody who saw it has told the mind"
case lowReading:
return "unclear reading of what was said"
case b.SourcesFor == 1:
return "evidence independence: a single source family"
case b.For < 0.3:
return "little evidence"
}
return "none dominant"
}
// Derivations. Primary derivations bring information from outside the
// belief system; derived ones re-express information already present.
const (
DerivationDirect = "direct" // an observation or event from the source itself
DerivationAssertion = "assertion" // somebody stated the proposition
DerivationOperator = "operator" // an operator asserted or endorsed it
DerivationExternal = "external" // external corroboration (integration, tool result)
DerivationPrediction = "prediction" // a prediction resolved against events
DerivationConsolidation = "consolidation" // generalised from episodes
DerivationInference = "inference" // reasoned from other items
DerivationReflection = "reflection" // produced by reflection or metacognition
DerivationDream = "dream" // proposed while dreaming
DerivationRetrieval = "retrieval" // a recalled memory offered as support
DerivationLegacy = "legacy" // written before provenance existed; treated as primary
)
// Derived reports whether a derivation re-expresses existing information.
func Derived(d string) bool {
switch d {
case DerivationConsolidation, DerivationInference, DerivationReflection, DerivationDream, DerivationRetrieval:
return true
}
return false
}
// FamilyOf returns the grouping key for a piece of evidence: its recorded
// family, else its source, else its reference.
func FamilyOf(e Evidence) string {
if e.Family != "" {
return e.Family
}
if e.Source != "" {
return e.Source
}
return e.Kind + ":" + e.RefID
}
// FamilyForActor is the family key for statements made by a person or agent.
func FamilyForActor(actor string) string {
actor = strings.ToLower(strings.TrimSpace(actor))
if actor == "" {
return ""
}
return "actor:" + actor
}
// FamilyForSource is the family key for an integration or channel that has
// no individual actor (metrics, webhooks, repositories).
func FamilyForSource(source, channel string) string {
source = strings.ToLower(strings.TrimSpace(source))
if source == "" {
return ""
}
if channel = strings.ToLower(strings.TrimSpace(channel)); channel != "" {
return "source:" + source + ":" + channel
}
return "source:" + source
}
// FamilyFromEvent derives the family of an observation from its payload:
// the actor who spoke, else the integration or channel it came from, else
// the event's source component.
func FamilyFromEvent(ev events.Event) string {
var p events.ObservationPayload
if len(ev.Payload) > 0 {
json.Unmarshal(ev.Payload, &p) //nolint:errcheck // absent payload fields simply fall through
}
if f := FamilyForActor(p.Actor); f != "" {
return f
}
if f := FamilyForSource(p.Source, p.Channel); f != "" {
return f
}
return FamilyForSource(ev.Source, "")
}
// FamilyShare explains one family's contribution to a confidence.
type FamilyShare struct {
Family string `json:"family"`
Direction string `json:"direction"`
Items int `json:"items"`
Primary int `json:"primary"`
Contribution float64 `json:"contribution"`
}
// Breakdown explains a confidence value.
type Breakdown struct {
Prior float64 `json:"prior"`
Meaning string `json:"meaning"` // what the number is and is not
Families []FamilyShare `json:"families,omitempty"` // per independent-source group
For float64 `json:"for"` // Σ weight×credibility, supporting
Against float64 `json:"against"` // Σ weight×credibility, opposing
SourcesFor int `json:"sources_for"` // distinct sources supporting
DiversityScale float64 `json:"diversity_scale"` // discount when support comes from one source
LogOdds float64 `json:"log_odds"`
Confidence float64 `json:"confidence"`
Formula string `json:"formula"`
Notes []string `json:"notes,omitempty"`
}
// Compute derives confidence from prior and evidence.
//
// Evidence is grouped by family (independent source). Within a family the
// strongest primary item counts in full and each further primary item adds
// a quarter of its value, capped at one and a half times the strongest, so
// repetition from one source is worth little. Derived items (consolidation,
// inference, reflection, dream, retrieval) contribute only when their family
// has no primary item, and then only the strongest of them: a semantic
// memory consolidated from five episodes, and an inference drawn from it,
// re-express the episodes rather than corroborate them. Diversity counts
// families, not rows (T4: poisoned memory; review pass item 2).
func Compute(prior float64, ev []Evidence) Breakdown {
b := Breakdown{Prior: clampP(prior), Meaning: ScoreMeaning, Formula: "confidence = sigmoid(logit(prior) + slope × (for × diversity − against)); for/against sum per-family contributions (strongest primary + ¼ of the rest, capped at 1.5×; derived items only when no primary exists)"}
type group struct {
primary, derived []float64
items int
}
groups := map[string]map[string]*group{"for": {}, "against": {}}
var order []string
for _, e := range ev {
if e.Retracted || e.Weight <= 0 {
continue
}
v := clamp01(e.Weight) * clamp01(e.Credibility)
dir := "for"
if e.Direction == "against" {
dir = "against"
}
fam := FamilyOf(e)
g := groups[dir][fam]
if g == nil {
g = &group{}
groups[dir][fam] = g
order = append(order, dir+"\x00"+fam)
}
g.items++
if Derived(e.Derivation) {
g.derived = append(g.derived, v)
} else {
g.primary = append(g.primary, v)
}
}
contribution := func(g *group) float64 {
if len(g.primary) > 0 {
sort.Sort(sort.Reverse(sort.Float64Slice(g.primary)))
c := g.primary[0]
for _, v := range g.primary[1:] {
c += 0.25 * v
}
return math.Min(c, 1.5*g.primary[0])
}
best := 0.0
for _, v := range g.derived {
best = math.Max(best, v)
}
return best
}
for _, key := range order {
dir, fam, _ := strings.Cut(key, "\x00")
g := groups[dir][fam]
c := contribution(g)
b.Families = append(b.Families, FamilyShare{Family: fam, Direction: dir, Items: g.items, Primary: len(g.primary), Contribution: round4(c)})
if dir == "against" {
b.Against += c
continue
}
b.For += c
}
b.SourcesFor = len(groups["for"])
// One source: 60% of face value; three or more independent sources: full.
b.DiversityScale = 1
if b.SourcesFor > 0 {
b.DiversityScale = round4(0.6 + 0.4*math.Min(1, float64(b.SourcesFor-1)/2))
}
if b.SourcesFor == 1 && b.For > 0 {
b.Notes = append(b.Notes, "all support comes from a single source")
}
logit := math.Log(b.Prior / (1 - b.Prior))
b.LogOdds = logit + Slope*(b.For*b.DiversityScale-b.Against)
b.Confidence = clampP(1 / (1 + math.Exp(-b.LogOdds)))
b.For, b.Against, b.LogOdds, b.Confidence = round4(b.For), round4(b.Against), round4(b.LogOdds), round4(b.Confidence)
return b
}
// Derive maps a breakdown to a status. Contested requires real evidence on
// both sides; rejected requires the evidence to have pushed confidence low;
// unknown means nothing live bears on the proposition either way, which is
// deliberately distinct from tentative belief at the prior (absence of
// evidence is not evidence of falsity). Hypotheses keep their own status
// because they are proposals awaiting evidence by construction.
func Derive(b Breakdown, epistemic concepts.Epistemic) Status {
if b.For > 0 && b.Against > 0 && b.Against >= 0.5*b.For*b.DiversityScale && b.Confidence >= 0.2 && b.Confidence <= 0.8 {
return StatusContested
}
if b.For == 0 && b.Against == 0 && epistemic != concepts.Hypothesis {
return StatusUnknown
}
switch {
case b.Confidence < 0.15:
return StatusRejected
case b.For == 0 && epistemic == concepts.Hypothesis:
return StatusHypothesis
case b.Confidence < 0.5:
if epistemic == concepts.Hypothesis {
return StatusHypothesis
}
return StatusTentative
case b.Confidence < 0.7:
return StatusTentative
case b.Confidence < 0.85:
return StatusLikely
default:
return StatusAccepted
}
}
func clampP(v float64) float64 {
if math.IsNaN(v) || v < MinConfidence {
return MinConfidence
}
if v > MaxConfidence {
return MaxConfidence
}
return v
}
func clamp01(v float64) float64 {
if math.IsNaN(v) || v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func round4(v float64) float64 { return math.Round(v*10000) / 10000 }
// negations are the words that flip a proposition's polarity for the
// deterministic contradiction check.
var negations = map[string]bool{"not": true, "no": true, "never": true, "isn't": true, "isnt": true, "doesn't": true, "doesnt": true, "don't": true, "dont": true, "cannot": true, "can't": true, "cant": true, "wasn't": true, "didn't": true, "won't": true, "unlikely": true, "false": true}
// Polarity splits a proposition into its content tokens and whether it is negated.
func Polarity(text string) (tokens []string, negated bool) {
for _, w := range tokenise(text) {
if negations[w] {
negated = !negated
continue
}
tokens = append(tokens, w)
}
sort.Strings(tokens)
return tokens, negated
}
// Jaccard similarity of two token lists.
func Jaccard(a, b []string) float64 {
if len(a) == 0 || len(b) == 0 {
return 0
}
set := map[string]bool{}
for _, w := range a {
set[w] = true
}
inter := 0
seenB := map[string]bool{}
for _, w := range b {
if seenB[w] {
continue
}
seenB[w] = true
if set[w] {
inter++
}
}
union := len(set) + len(seenB) - inter
return float64(inter) / float64(union)
}
// Contradicts reports whether two propositions are the same content with
// opposite polarity (deterministic first pass; paraphrase judgement can be
// added by an LLM-assisted process later).
func Contradicts(a, b string) (bool, float64) {
ta, na := Polarity(a)
tb, nb := Polarity(b)
sim := Jaccard(ta, tb)
if na != nb && sim >= 0.6 {
return true, round4(sim)
}
return false, round4(sim)
}
var stop = map[string]bool{"the": true, "a": true, "an": true, "is": true, "are": true, "was": true, "were": true, "be": true, "been": true, "of": true, "to": true, "in": true, "on": true, "at": true, "for": true, "and": true, "or": true, "that": true, "this": true, "it": true, "has": true, "have": true, "had": true, "with": true, "by": true, "as": true, "may": true, "might": true, "probably": true, "likely": true}
func tokenise(text string) []string {
var out []string
var cur []rune
flush := func() {
if len(cur) >= 2 {
w := string(cur)
if !stop[w] {
out = append(out, w)
}
}
cur = cur[:0]
}
for _, r := range text {
switch {
case r >= 'A' && r <= 'Z':
cur = append(cur, r+32)
case r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '\'' || r == '-' || r == '_' || r == '.':
cur = append(cur, r)
default:
flush()
}
}
flush()
return out
}