Files
noema/internal/beliefs/confidence.go
T
Andy Dixon 73e5753a91 feat: add beliefs with evidence-derived confidence, contradictions and corrections
Phase 6. Beliefs are propositions with a prior set by epistemic kind and a
confidence computed from typed evidence: sigmoid(logit(prior) + slope ×
(support × diversity − opposition)), where support from a single source
is discounted so repetition cannot manufacture certainty, and bounds keep
the mind away from absolute certainty. Every evaluation stores its
breakdown and appends a version row protected by the append-only
trigger; the belief page shows the arithmetic, evidence for and against,
alternatives, contradictions and history.

Contradiction detection is deterministic first (negation plus token
overlap): a hit emits contradiction_detected, links both beliefs and
makes each counter-evidence for the other so both trend to contested.
Operators can assert beliefs (their statement is the evidence), add
evidence, mark a belief wrong (pinned rejected, history kept) and forbid
an inference; do-not-infer corrections block matching hypotheses.
Retracting a memory retracts the evidence that cited it and re-evaluates
the beliefs concerned.

The associate process proposes low-confidence causal hypotheses from
co-occurring notable observations, and the hypothesis handler turns
admitted ones into beliefs with automatic alternatives, closing demo
scenarios 1 and 2 without any model call.
2026-06-21 22:14:12 +01:00

254 lines
7.4 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 (
"math"
"sort"
"github.com/andydixon/noema/pkg/concepts"
)
// Status of a belief.
type Status string
// Statuses in rough order of commitment.
const (
StatusHypothesis Status = "hypothesis"
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:
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"`
}
// Breakdown explains a confidence value.
type Breakdown struct {
Prior float64 `json:"prior"`
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. Support from a single
// source is discounted (T4: poisoned memory) so repetition alone cannot make
// a belief strong.
func Compute(prior float64, ev []Evidence) Breakdown {
b := Breakdown{Prior: clampP(prior), Formula: "confidence = sigmoid(logit(prior) + slope × (for × diversity − against))"}
sources := map[string]struct{}{}
for _, e := range ev {
if e.Retracted || e.Weight <= 0 {
continue
}
v := clamp01(e.Weight) * clamp01(e.Credibility)
if e.Direction == "against" {
b.Against += v
continue
}
b.For += v
key := e.Source
if key == "" {
key = e.Kind + ":" + e.RefID
}
sources[key] = struct{}{}
}
b.SourcesFor = len(sources)
// 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.
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
}
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
}