Files
noema/internal/beliefs/confidence_test.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

171 lines
9.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
import (
"strings"
"testing"
"github.com/andydixon/noema/pkg/concepts"
)
func TestComputeIsMonotonicBoundedAndDiscountsSingleSource(t *testing.T) {
none := Compute(0.5, nil)
if none.Confidence != 0.5 {
t.Fatalf("no evidence should keep the prior, got %v", none.Confidence)
}
one := Compute(0.5, []Evidence{{Direction: "for", Kind: "event", RefID: "a", Source: "metrics", Weight: 0.8, Credibility: 0.9}})
two := Compute(0.5, []Evidence{{Direction: "for", Kind: "event", RefID: "a", Source: "metrics", Weight: 0.8, Credibility: 0.9}, {Direction: "for", Kind: "event", RefID: "b", Source: "metrics", Weight: 0.8, Credibility: 0.9}})
diverse := Compute(0.5, []Evidence{{Direction: "for", Kind: "event", RefID: "a", Source: "metrics", Weight: 0.8, Credibility: 0.9}, {Direction: "for", Kind: "event", RefID: "b", Source: "logs", Weight: 0.8, Credibility: 0.9}, {Direction: "for", Kind: "event", RefID: "c", Source: "operator", Weight: 0.8, Credibility: 0.9}})
if !(none.Confidence < one.Confidence && one.Confidence < two.Confidence && two.Confidence < diverse.Confidence) {
t.Fatalf("not monotonic: %v %v %v %v", none.Confidence, one.Confidence, two.Confidence, diverse.Confidence)
}
if one.DiversityScale != 0.6 || len(one.Notes) == 0 || diverse.DiversityScale != 1 {
t.Fatalf("diversity discount wrong: %+v / %+v", one, diverse)
}
huge := make([]Evidence, 50)
for i := range huge {
huge[i] = Evidence{Direction: "for", Kind: "event", RefID: string(rune('a' + i)), Source: "s" + string(rune('a'+i)), Weight: 1, Credibility: 1}
}
if Compute(0.5, huge).Confidence > MaxConfidence {
t.Fatal("confidence exceeded bound")
}
against := Compute(0.5, []Evidence{{Direction: "against", Kind: "event", RefID: "x", Weight: 1, Credibility: 1}})
if against.Confidence >= 0.5 {
t.Fatal("opposing evidence should lower confidence")
}
retracted := Compute(0.5, []Evidence{{Direction: "for", Kind: "event", RefID: "a", Weight: 1, Credibility: 1, Retracted: true}})
if retracted.Confidence != 0.5 {
t.Fatal("retracted evidence must not count")
}
}
func TestDeriveStatuses(t *testing.T) {
cases := []struct {
b Breakdown
e concepts.Epistemic
want Status
}{
{Compute(PriorFor(concepts.Hypothesis), nil), concepts.Hypothesis, StatusHypothesis},
{Compute(0.5, []Evidence{{Direction: "for", Source: "a", Weight: 0.5, Credibility: 0.9}}), concepts.Claim, StatusTentative},
{Compute(0.5, []Evidence{{Direction: "for", Source: "a", Weight: 1, Credibility: 1}, {Direction: "for", Source: "b", Weight: 1, Credibility: 1}, {Direction: "for", Source: "c", Weight: 1, Credibility: 1}}), concepts.Claim, StatusAccepted},
{Compute(0.5, []Evidence{{Direction: "for", Source: "a", Weight: 0.8, Credibility: 0.9}, {Direction: "for", Source: "b", Weight: 0.8, Credibility: 0.9}, {Direction: "against", Source: "c", Weight: 0.8, Credibility: 0.9}}), concepts.Claim, StatusContested},
{Compute(0.5, []Evidence{{Direction: "against", Source: "a", Weight: 1, Credibility: 1}, {Direction: "against", Source: "b", Weight: 1, Credibility: 1}}), concepts.Claim, StatusRejected},
}
for i, c := range cases {
if got := Derive(c.b, c.e); got != c.want {
t.Errorf("case %d: got %s want %s (conf %v for %v against %v)", i, got, c.want, c.b.Confidence, c.b.For, c.b.Against)
}
}
}
func TestContradicts(t *testing.T) {
if ok, _ := Contradicts("server-01 has an intermittent storage problem", "server-01 does not have an intermittent storage problem"); !ok {
t.Fatal("negated paraphrase not detected")
}
if ok, _ := Contradicts("server-01 has a storage problem", "server-02 has a network problem"); ok {
t.Fatal("unrelated propositions flagged")
}
if ok, _ := Contradicts("atlas is slow", "atlas is slow"); ok {
t.Fatal("identical propositions flagged")
}
}
// TestProvenanceGroupingPreventsInflation pins the family arithmetic:
// independent families add up; derived re-expressions of one source add
// nothing to it; repetition within a family is heavily discounted; legacy
// rows (family = source) behave as primary evidence.
func TestProvenanceGroupingPreventsInflation(t *testing.T) {
ev := func(fam, deriv string, w float64) Evidence {
return Evidence{Direction: "for", Kind: "event", RefID: fam + deriv, Source: fam, Family: fam, Derivation: deriv, Weight: w, Credibility: 1}
}
one := Compute(0.5, []Evidence{ev("actor:andy", DerivationAssertion, 0.6)})
threeIndependent := Compute(0.5, []Evidence{ev("actor:andy", DerivationAssertion, 0.6), ev("source:prometheus:prod", DerivationExternal, 0.6), ev("operator:admin", DerivationOperator, 0.6)})
threeDerived := Compute(0.5, []Evidence{
ev("actor:andy", DerivationAssertion, 0.6), // E1: what Andy said
ev("actor:andy", DerivationConsolidation, 0.6), // S1: a semantic memory consolidated from it
ev("actor:andy", DerivationInference, 0.6), // I1: an inference drawn from S1
})
repeated := Compute(0.5, []Evidence{ev("actor:andy", DerivationAssertion, 0.6), ev("actor:andy", DerivationAssertion, 0.6), ev("actor:andy", DerivationAssertion, 0.6)})
if threeDerived.Confidence != one.Confidence {
t.Fatalf("derived copies inflated confidence: one=%.4f derived=%.4f", one.Confidence, threeDerived.Confidence)
}
if threeDerived.SourcesFor != 1 || threeIndependent.SourcesFor != 3 {
t.Fatalf("family counting wrong: derived=%d independent=%d", threeDerived.SourcesFor, threeIndependent.SourcesFor)
}
if !(threeIndependent.Confidence > repeated.Confidence && repeated.Confidence > one.Confidence) {
t.Fatalf("ordering wrong: independent=%.4f repeated=%.4f one=%.4f", threeIndependent.Confidence, repeated.Confidence, one.Confidence)
}
// Repetition is capped at 1.5× the strongest item however many times it recurs.
many := make([]Evidence, 20)
for i := range many {
many[i] = ev("actor:andy", DerivationAssertion, 0.6)
}
if got := Compute(0.5, many).For; got > 0.6*1.5+1e-9 {
t.Fatalf("repetition not capped: for=%.4f", got)
}
// Derived evidence stands in only when nothing primary exists for the family.
onlyDerived := Compute(0.5, []Evidence{ev("actor:andy", DerivationConsolidation, 0.3), ev("actor:andy", DerivationInference, 0.2)})
if onlyDerived.For != 0.3 {
t.Fatalf("only-derived family should count its strongest item once: %.4f", onlyDerived.For)
}
// Legacy rows: family falls back to source and they count as primary.
legacy := Compute(0.5, []Evidence{{Direction: "for", Kind: "event", RefID: "x", Source: "metrics", Weight: 0.6, Credibility: 1, Derivation: DerivationLegacy}, {Direction: "for", Kind: "event", RefID: "y", Source: "logs", Weight: 0.6, Credibility: 1}})
if legacy.SourcesFor != 2 || len(legacy.Families) != 2 {
t.Fatalf("legacy grouping: %+v", legacy.Families)
}
// Counter-evidence is grouped the same way.
against := Compute(0.5, []Evidence{{Direction: "against", Kind: "belief", RefID: "b", Source: "contradiction", Family: "belief:b", Derivation: DerivationInference, Weight: 0.5, Credibility: 0.8},
{Direction: "against", Kind: "belief", RefID: "b", Source: "contradiction", Family: "belief:b", Derivation: DerivationInference, Weight: 0.5, Credibility: 0.8}})
if against.Against != 0.4 {
t.Fatalf("against grouping: %.4f", against.Against)
}
// Families derive from event payloads: an actor beats a channel beats the component.
if f := FamilyForActor(" Andy "); f != "actor:andy" {
t.Fatal(f)
}
if f := FamilyForSource("Prometheus", "prod"); f != "source:prometheus:prod" {
t.Fatal(f)
}
}
// TestCalibrationIsGatedAndDeterministic: a calibrated estimate appears only
// with enough decided predictions in the score's range, and the primary
// uncertainty names the right doubt.
func TestCalibrationIsGatedAndDeterministic(t *testing.T) {
buckets := []CalibrationBucket{{0, 0.5, 3, 0.33}, {0.5, 0.8, 25, 0.64}, {0.8, 1.01, 40, 0.9}}
if c := CalibrateWith(0.3, buckets); c.Available || c.SampleSize != 3 || c.Reason == "" {
t.Fatalf("small sample must not calibrate: %+v", c)
}
if c := CalibrateWith(0.59, buckets); !c.Available || c.Estimate != 0.64 || c.SampleSize != 25 {
t.Fatalf("calibrated: %+v", c)
}
if c := CalibrateWith(0.59, nil); c.Available || c.Reason == "" {
t.Fatalf("no buckets: %+v", c)
}
if b := Compute(0.5, nil); b.Meaning != ScoreMeaning {
t.Fatal("breakdown must carry its meaning")
}
rows := func(rs ...EvidenceRow) ([]Evidence, []EvidenceRow) {
var ev []Evidence
for _, r := range rs {
ev = append(ev, r.Evidence)
}
return ev, rs
}
one := EvidenceRow{Evidence: Evidence{Direction: "for", Kind: "assertion", Source: "asserted-by:andy", Family: "actor:andy", Derivation: DerivationAssertion, Weight: 0.5, Credibility: 0.9}, Note: "andy asserted it (read with 85% interpretation confidence)"}
ev, rs := rows(one)
if u := PrimaryUncertainty(Compute(0.5, ev), rs); u != "evidence independence: a single source family" {
t.Fatal(u)
}
ev, rs = rows(EvidenceRow{Evidence: Evidence{Direction: "for", Kind: "assertion", Source: "asserted-by:alice", Family: "actor:alice", Derivation: DerivationAssertion, Weight: 0.2, Credibility: 0.6}, Note: "andy reported that alice holds this (asserted, read with 75% interpretation confidence)"})
if u := PrimaryUncertainty(Compute(0.5, ev), rs); !strings.HasPrefix(u, "second-hand") {
t.Fatal(u)
}
ev, rs = rows(one, EvidenceRow{Evidence: Evidence{Direction: "against", Kind: "operator", Source: "operator:x", Family: "operator:x", Weight: 0.5, Credibility: 0.9}})
if u := PrimaryUncertainty(Compute(0.5, ev), rs); u != "conflicting evidence" {
t.Fatal(u)
}
if u := PrimaryUncertainty(Compute(0.5, nil), nil); u != "no live evidence" {
t.Fatal(u)
}
}