feat(epistemics): record assertions between observations and beliefs

A statement made to a mind is now three things kept apart: the observation
that it arrived, an append-only assertion (attributed actor, stance,
certainty, modality, negation, reported or quoted speech, hypothetical
status, interpretation confidence and notes), and, only for factual
assertions, one piece of evidence for a proposition belief attributed to
whoever actually stands behind it. Reported speech is attributed to the
person reported and weighed as second-hand; hypotheticals, questions and
imperatives create no belief; expressed doubt counts against; a possibly
sarcastic remark is recorded with low interpretation confidence rather
than guessed at. Interpretation is deterministic code; a model may enrich
the claim list but never sets stance or confidence.

Association no longer treats conversational turns as world events:
hypotheticals, questions, requests and consecutive turns by one speaker
cannot seed causal hypotheses.

Adds internal/epistemics, migration 0018, the assertions API, assertions
on belief pages and in belief detail, a shared end-to-end test harness
(tests/harness), end-to-end tests for eight speech patterns, and updates
the milestone test to the new provenance.
This commit is contained in:
Andy Dixon
2026-08-05 19:22:48 +01:00
parent 0071658c93
commit 9b2521bffd
28 changed files with 1447 additions and 325 deletions
+158
View File
@@ -0,0 +1,158 @@
// Package epistemics_test drives the conversation pipeline end to end and
// checks that statements become assertions first and beliefs only when
// warranted: the fact that somebody said something is certain; the truth
// of what they said is a separate, weaker matter.
package epistemics_test
import (
"context"
"strings"
"testing"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/epistemics"
"github.com/andydixon/noema/pkg/ids"
"github.com/andydixon/noema/tests/harness"
)
func TestAssertionsAreNotBeliefs(t *testing.T) {
h := harness.Start(t)
ctx := context.Background()
m := h.Mind("epi")
say := func(text string) {
t.Helper()
if code, body := h.Do(h.Admin, "POST", "/api/v1/minds/epi/ask", map[string]any{"text": text}); code >= 300 {
t.Fatalf("say %q → %d %s", text, code, body)
}
h.Settle(m.ID)
}
say("Steve steals laptops.")
say("Alice thinks the server is compromised.")
say("People keep saying PostgreSQL corrupts data.")
say("Imagine Bob murdered somebody.")
say("Sarah says the network is down, but I don't believe her.")
say(`Dave said "the backups finished at 03:00".`)
say("Atlas is not the primary database host.")
say("Oh yes, Dave is an absolute genius.")
all, err := h.Deps.Assertions.List(ctx, m.ID, epistemics.Filter{Limit: 100})
if err != nil {
t.Fatal(err)
}
byProp := map[string]epistemics.Assertion{}
for _, a := range all {
byProp[strings.ToLower(a.Proposition)] = a
}
find := func(sub string) epistemics.Assertion {
t.Helper()
for k, a := range byProp {
if strings.Contains(k, sub) {
return a
}
}
t.Fatalf("no assertion containing %q among %d: %v", sub, len(all), keys(byProp))
return epistemics.Assertion{}
}
beliefFor := func(id ids.ID) (beliefs.Belief, bool) {
if id.IsZero() {
return beliefs.Belief{}, false
}
b, err := h.Deps.Beliefs.Get(ctx, nil, m.ID, id)
return b, err == nil
}
// Direct assertion: attributed to the speaker, a claim belief with modest confidence.
steve := find("steve steals laptops")
if !strings.EqualFold(steve.AttributedTo, "admin") || steve.Stance != epistemics.StanceAsserts || steve.Reported {
t.Fatalf("direct: %+v", steve)
}
if b, ok := beliefFor(steve.BeliefID); !ok || b.Epistemic != "claim" || b.Confidence > 0.75 || b.Confidence < 0.5 {
t.Fatalf("direct claim belief: %+v ok=%v", b, ok)
}
// Reported belief: attributed to Alice, second-hand, weaker than the direct one.
alice := find("server is compromised")
if !strings.EqualFold(alice.AttributedTo, "Alice") || !alice.Reported || alice.Actor != "admin" {
t.Fatalf("reported: %+v", alice)
}
ab, _ := beliefFor(alice.BeliefID)
sb, _ := beliefFor(steve.BeliefID)
if !(ab.Confidence < sb.Confidence) {
t.Fatalf("reported claim (%.2f) should be weaker than direct (%.2f)", ab.Confidence, sb.Confidence)
}
rows, _ := h.Deps.Beliefs.EvidenceFor(ctx, nil, m.ID, ab.ID)
if len(rows) != 1 || rows[0].Family != "actor:alice" || rows[0].Derivation != beliefs.DerivationAssertion {
t.Fatalf("reported evidence family: %+v", rows)
}
// Diffuse: attributed to nobody in particular, low interpretation confidence.
pg := find("postgresql corrupts data")
if pg.AttributedTo != "unspecified people" || pg.Interpretation > 0.7 {
t.Fatalf("diffuse: %+v", pg)
}
// Hypothetical: recorded, no belief at all.
bob := find("bob murdered somebody")
if !bob.Hypothetical || !bob.BeliefID.IsZero() {
t.Fatalf("hypothetical created a belief: %+v", bob)
}
list, _ := h.Deps.Beliefs.List(ctx, m.ID, beliefs.Filter{Limit: 100})
for _, b := range list {
if strings.Contains(strings.ToLower(b.Proposition), "murdered") {
t.Fatalf("hypothetical leaked into beliefs: %+v", b)
}
}
// Disagreement: Sarah's claim recorded, speaker's doubt counts against it.
sarah := find("network is down")
if !strings.EqualFold(sarah.AttributedTo, "Sarah") || !strings.Contains(strings.Join(sarah.Notes, " "), "doubts") {
t.Fatalf("disagreement: %+v", sarah)
}
srows, _ := h.Deps.Beliefs.EvidenceFor(ctx, nil, m.ID, sarah.BeliefID)
var against int
for _, r := range srows {
if r.Direction == "against" && r.Family == "actor:admin" {
against++
}
}
if against != 1 {
t.Fatalf("doubt not recorded as counter-evidence: %+v", srows)
}
sarahB, _ := beliefFor(sarah.BeliefID)
if sarahB.Confidence >= ab.Confidence {
t.Fatalf("doubted claim (%.2f) should be weaker than an undisputed reported one (%.2f)", sarahB.Confidence, ab.Confidence)
}
// Quoted speech is attributed to the quoted person.
dave := find("backups finished at 03:00")
if !dave.Quoted || !strings.EqualFold(dave.AttributedTo, "Dave") {
t.Fatalf("quoted: %+v", dave)
}
// Negation is recorded on the assertion and the proposition kept as stated.
atlas := find("atlas is not the primary database host")
if !atlas.Negated {
t.Fatalf("negation: %+v", atlas)
}
// Possible sarcasm: recorded with low interpretation confidence; any belief is weak.
genius := find("dave is an absolute genius")
if genius.Interpretation > 0.5 {
t.Fatalf("sarcasm should lower interpretation confidence: %+v", genius)
}
if gb, ok := beliefFor(genius.BeliefID); ok && gb.Confidence > sb.Confidence {
t.Fatalf("sarcastic remark believed more than a plain one: %.2f vs %.2f", gb.Confidence, sb.Confidence)
}
// Assertions are append-only.
if _, err := h.Deps.DB.Exec(ctx, `UPDATE assertions SET proposition='x' WHERE id=$1`, steve.ID); err == nil {
t.Fatal("assertion row was mutable")
}
// API surface.
if code, body := h.Do(h.Obs, "GET", "/api/v1/minds/epi/assertions?actor=alice", nil); code != 200 || !strings.Contains(string(body), "server is compromised") {
t.Fatalf("assertions API: %d %s", code, body)
}
if code, body := h.Do(h.Obs, "GET", "/api/v1/minds/epi/beliefs/"+ab.ID.String(), nil); code != 200 || !strings.Contains(string(body), `"assertions"`) {
t.Fatalf("belief detail lacks assertions: %d", code)
}
}
func keys(m map[string]epistemics.Assertion) []string {
var out []string
for k := range m {
out = append(out, k)
}
return out
}