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
+222
View File
@@ -0,0 +1,222 @@
// Package epistemics keeps apart three things a careless system conflates:
// the observation that somebody said something, the assertion they made,
// and any belief about whether what they said is true.
//
// An Assertion is the durable, append-only record that an actor took a
// stance on a proposition. Its confidence is about interpretation ("how sure
// are we that Andy asserted X?"), never about X. Whether X is believed is a
// separate question answered by the belief layer from evidence, of which the
// assertion is one row with a weight that depends on stance, modality and
// how clearly it was understood. Reported speech attributes the stance to
// the person reported, hypotheticals create no belief at all, doubt counts
// against, and a possibly sarcastic remark is recorded with low
// interpretation confidence rather than guessed at.
package epistemics
import (
"regexp"
"strings"
)
// Stances an actor can take towards a proposition.
const (
StanceAsserts = "asserts"
StanceDenies = "denies"
StanceDoubts = "doubts"
StanceQuestions = "questions"
StanceSupposes = "supposes"
StanceReports = "reports" // the speaker relays somebody else's stance
)
// Certainty the speaker expressed.
const (
CertaintyAsserted = "asserted"
CertaintyHedged = "hedged"
CertaintyUncertain = "uncertain"
CertaintyEmphatic = "emphatic"
)
// Modality of the utterance.
const (
ModalityFactual = "factual"
ModalityHypothetical = "hypothetical"
ModalityReported = "reported"
ModalityQuoted = "quoted"
ModalityImperative = "imperative"
ModalityQuestion = "question"
)
// Interpretation is the deterministic reading of one claim sentence.
type Interpretation struct {
Proposition string `json:"proposition"` // the proposition as attributed (reporting frame removed)
AttributedTo string `json:"attributed_to"` // whose stance this is
Stance string `json:"stance"` // stance of AttributedTo towards Proposition
SpeakerStance string `json:"speaker_stance"` // stance of the speaker when they report somebody else ("" = same)
Certainty string `json:"certainty"`
Modality string `json:"modality"`
Negated bool `json:"negated"`
Reported bool `json:"reported"`
ReportedActor string `json:"reported_actor,omitempty"`
Quoted bool `json:"quoted"`
Hypothetical bool `json:"hypothetical"`
Confidence float64 `json:"interpretation_confidence"` // how sure the reading itself is
Notes []string `json:"notes,omitempty"`
}
var (
reportVerbs = `(?:says?|said|saying|thinks?|thought|thinking|believes?|believed|claims?|claimed|claiming|told (?:me|us)|telling (?:me|us)|reckons?|insists?|insisting|mentioned|reported|argues?|arguing|swears?|maintains?)`
reReported = regexp.MustCompile(`(?i)^(?:apparently[, ]+|well[, ]+)?([A-Z][\w'.-]*(?: [A-Z][\w'.-]*)?)\s+` + reportVerbs + `\s+(?:that\s+)?(.+)$`)
reQuoted = regexp.MustCompile(`(?i)^([A-Z][\w'.-]*(?: [A-Z][\w'.-]*)?)\s+` + reportVerbs + `[:,]?\s*["“](.+?)["”]\.?$`)
reDiffuse = regexp.MustCompile(`(?i)^(?:people|everyone|everybody|they|folks|some people|others|the team|the internet)\s+(?:keep |keeps |always |all )?` + reportVerbs + `\s+(?:that\s+)?(.+)$`)
reHypothet = regexp.MustCompile(`(?i)^(?:imagine|suppose|supposing|assume|assuming|let'?s say|what if|hypothetically|pretend|say)\b[,:]?\s*(?:that\s+)?(.+)$`)
reConditional = regexp.MustCompile(`(?i)^if\s+(.+?),\s*(?:then\s+)?(.+)$`)
reDisagree = regexp.MustCompile(`(?i)^(.+?)[,;]?\s+(?:but|though|although)\s+(?:i|we)\s+(?:don'?t|do not|didn'?t|can'?t|cannot|never)\s+(?:believe|buy|trust|think so|agree)(?:\s+(?:her|him|them|it|that|this))?\.?$`)
reDoubtSuffix = regexp.MustCompile(`(?i)^(.+?)[,;]?\s+(?:which|and that|but that)\s+(?:is|was|seems)\s+(?:nonsense|rubbish|wrong|false|unlikely|doubtful)\.?$`)
reNegation = regexp.MustCompile(`(?i)\b(?:not|no|never|isn't|isnt|aren't|wasn't|weren't|doesn't|don't|didn't|cannot|can't|won't|wouldn't|shouldn't|nobody|nothing|none)\b`)
reEmphatic = regexp.MustCompile(`(?i)\b(?:definitely|certainly|absolutely|without doubt|i'?m (?:sure|certain)|for sure|no doubt|clearly)\b`)
reHedge = regexp.MustCompile(`(?i)\b(?:i think|i believe|maybe|perhaps|possibly|might|probably|not sure|i guess|seems|i suspect|apparently|likely|sort of|kind of)\b`)
reSarcasm = regexp.MustCompile(`(?i)^(?:oh (?:yes|yeah|sure|right)|yeah,? right|sure,? and|of course|great,)\b|\b(?:absolute|total|utter|complete)\s+(?:genius|hero|legend|star|expert|joy|delight)\b|\b(?:as always|as usual|what a surprise|shock horror)\b`)
reImperative = regexp.MustCompile(`(?i)^(?:please\s+)?(?:do|make|run|go|check|tell|show|remember|note|delete|fetch|read|stop|start|send|install)\b`)
)
// Interpret reads one claim sentence spoken by speaker.
func Interpret(speaker, sentence string) Interpretation {
s := strings.TrimSpace(sentence)
s = strings.TrimRight(s, " .!")
in := Interpretation{Proposition: s, AttributedTo: speaker, Stance: StanceAsserts, Certainty: CertaintyAsserted, Modality: ModalityFactual, Confidence: 0.85}
lower := strings.ToLower(s)
if strings.HasSuffix(sentence, "?") {
in.Stance, in.Modality, in.Confidence = StanceQuestions, ModalityQuestion, 0.9
return in
}
if reImperative.MatchString(s) {
in.Modality, in.Stance, in.Confidence = ModalityImperative, StanceSupposes, 0.7
in.Notes = append(in.Notes, "imperative: a request, not a claim about the world")
return in
}
// Hypotheticals and conditionals create no commitment.
if m := reHypothet.FindStringSubmatch(s); m != nil {
in.Proposition, in.Hypothetical, in.Modality, in.Stance, in.Confidence = strings.TrimSpace(m[1]), true, ModalityHypothetical, StanceSupposes, 0.85
in.Notes = append(in.Notes, "hypothetical framing; no commitment to the proposition")
in.Negated = reNegation.MatchString(in.Proposition)
return in
}
if m := reConditional.FindStringSubmatch(s); m != nil {
in.Hypothetical, in.Modality, in.Stance, in.Confidence = true, ModalityHypothetical, StanceSupposes, 0.7
in.Notes = append(in.Notes, "conditional; the consequent is asserted only under the antecedent")
return in
}
// Disagreement with reported speech: "Sarah says X, but I don't believe her".
if m := reDisagree.FindStringSubmatch(s); m != nil {
inner := Interpret(speaker, m[1])
inner.SpeakerStance = StanceDoubts
inner.Notes = append(inner.Notes, "speaker distances themselves from the reported claim")
if !inner.Reported {
// "X, but I don't believe it": the speaker relays a claim without an author.
inner.Reported, inner.ReportedActor, inner.AttributedTo, inner.Modality = true, "unspecified", "unspecified", ModalityReported
inner.Confidence = minf(inner.Confidence, 0.6)
}
return inner
}
if m := reDoubtSuffix.FindStringSubmatch(s); m != nil {
inner := Interpret(speaker, m[1])
inner.SpeakerStance = StanceDoubts
inner.Notes = append(inner.Notes, "speaker rejects the claim")
return inner
}
// Quoted and reported speech attribute the stance to the person quoted.
if m := reQuoted.FindStringSubmatch(s); m != nil {
in.ReportedActor, in.Proposition = m[1], strings.TrimSpace(m[2])
in.Reported, in.Quoted, in.Modality, in.AttributedTo, in.Confidence = true, true, ModalityQuoted, m[1], 0.8
in.SpeakerStance = StanceReports
in.Negated = reNegation.MatchString(in.Proposition)
in.Certainty = certaintyOf(in.Proposition)
return in
}
if m := reDiffuse.FindStringSubmatch(s); m != nil {
in.ReportedActor, in.Proposition = "unspecified people", strings.TrimSpace(m[1])
in.Reported, in.Modality, in.AttributedTo, in.Confidence = true, ModalityReported, "unspecified people", 0.6
in.SpeakerStance, in.Certainty = StanceReports, CertaintyUncertain
in.Notes = append(in.Notes, "diffuse attribution; nobody in particular stands behind it")
in.Negated = reNegation.MatchString(in.Proposition)
return in
}
if m := reReported.FindStringSubmatch(s); m != nil && !strings.EqualFold(m[1], "I") {
in.ReportedActor, in.Proposition = m[1], strings.TrimSpace(m[2])
in.Reported, in.Modality, in.AttributedTo, in.Confidence = true, ModalityReported, m[1], 0.75
in.SpeakerStance = StanceReports
in.Negated = reNegation.MatchString(in.Proposition)
in.Certainty = certaintyOf(in.Proposition)
if strings.Contains(strings.ToLower(m[0]), "think") || strings.Contains(strings.ToLower(m[0]), "believe") {
in.Notes = append(in.Notes, "reports a belief, not an observation")
}
return in
}
// Plain statement by the speaker.
in.Negated = reNegation.MatchString(lower)
in.Certainty = certaintyOf(s)
if reSarcasm.MatchString(s) {
in.Confidence = 0.4
in.Notes = append(in.Notes, "possible sarcasm or irony; literal reading uncertain")
}
if in.Negated && in.Stance == StanceAsserts {
in.Notes = append(in.Notes, "negated proposition asserted as stated")
}
return in
}
func certaintyOf(s string) string {
switch {
case reEmphatic.MatchString(s):
return CertaintyEmphatic
case reHedge.MatchString(s):
return CertaintyHedged
}
return CertaintyAsserted
}
// CreatesBelief reports whether an interpretation should feed a proposition
// belief at all. Questions, imperatives and hypotheticals never do.
func (in Interpretation) CreatesBelief() bool {
switch in.Modality {
case ModalityQuestion, ModalityImperative, ModalityHypothetical:
return false
}
return in.Stance == StanceAsserts && strings.TrimSpace(in.Proposition) != ""
}
// EvidenceWeight is the weight the assertion contributes as evidence for
// its proposition, before source credibility: how firmly the attributed
// actor stood behind it, discounted by how surely the reading itself holds.
func (in Interpretation) EvidenceWeight() float64 {
w := 0.5
switch in.Certainty {
case CertaintyEmphatic:
w = 0.55
case CertaintyHedged:
w = 0.3
case CertaintyUncertain:
w = 0.2
}
if in.Reported {
w *= 0.5 // second-hand: the mind did not hear the attributed actor
}
return round(w * in.Confidence)
}
// DoubtWeight is the counter-weight the speaker's expressed doubt adds.
func (in Interpretation) DoubtWeight() float64 {
if in.SpeakerStance != StanceDoubts {
return 0
}
return round(0.3 * in.Confidence)
}
func minf(a, b float64) float64 {
if a < b {
return a
}
return b
}
func round(v float64) float64 { return float64(int(v*1000+0.5)) / 1000 }
+62
View File
@@ -0,0 +1,62 @@
package epistemics
import "testing"
func TestInterpretSpeechPatterns(t *testing.T) {
cases := []struct {
name, sentence string
wantAttributed, wantStance string
wantModality string
wantReported, wantHypothet bool
wantNegated, wantBelief bool
wantSpeakerStance string
maxConfidence, minConfidence float64
}{
{"direct factual", "Steve steals laptops.", "andy", StanceAsserts, ModalityFactual, false, false, false, true, "", 1, 0.8},
{"reported belief", "Alice thinks the server is compromised.", "Alice", StanceAsserts, ModalityReported, true, false, false, true, StanceReports, 1, 0.6},
{"diffuse report", "People keep saying PostgreSQL corrupts data.", "unspecified people", StanceAsserts, ModalityReported, true, false, false, true, StanceReports, 0.7, 0.4},
{"hypothetical", "Imagine Bob murdered somebody.", "andy", StanceSupposes, ModalityHypothetical, false, true, false, false, "", 1, 0.5},
{"conditional", "If the disk fills up, the database stops accepting writes.", "andy", StanceSupposes, ModalityHypothetical, false, true, false, false, "", 1, 0.5},
{"disagreement", "Sarah says the network is down, but I don't believe her.", "Sarah", StanceAsserts, ModalityReported, true, false, false, true, StanceDoubts, 1, 0.5},
{"quoted", `Dave said "the backups finished at 03:00".`, "Dave", StanceAsserts, ModalityQuoted, true, false, false, true, StanceReports, 1, 0.6},
{"negated", "Atlas is not the primary database host.", "andy", StanceAsserts, ModalityFactual, false, false, true, true, "", 1, 0.8},
{"sarcasm suspected", "Oh yes, Dave is an absolute genius.", "andy", StanceAsserts, ModalityFactual, false, false, false, true, "", 0.5, 0},
{"question", "Is atlas the primary host?", "andy", StanceQuestions, ModalityQuestion, false, false, false, false, "", 1, 0},
{"imperative", "Please check the disk on server-01.", "andy", StanceSupposes, ModalityImperative, false, false, false, false, "", 1, 0},
{"hedged", "I think the migration caused the outage.", "andy", StanceAsserts, ModalityFactual, false, false, false, true, "", 1, 0.8},
{"first person not reported", "I said the migration was fine.", "andy", StanceAsserts, ModalityFactual, false, false, false, true, "", 1, 0.8},
}
for _, c := range cases {
in := Interpret("andy", c.sentence)
if in.AttributedTo != c.wantAttributed || in.Stance != c.wantStance || in.Modality != c.wantModality || in.Reported != c.wantReported || in.Hypothetical != c.wantHypothet || in.Negated != c.wantNegated {
t.Errorf("%s: %+v", c.name, in)
}
if in.CreatesBelief() != c.wantBelief {
t.Errorf("%s: CreatesBelief=%v want %v", c.name, in.CreatesBelief(), c.wantBelief)
}
if in.SpeakerStance != c.wantSpeakerStance {
t.Errorf("%s: speaker stance %q want %q", c.name, in.SpeakerStance, c.wantSpeakerStance)
}
if in.Confidence > c.maxConfidence || in.Confidence < c.minConfidence {
t.Errorf("%s: interpretation confidence %.2f outside [%.2f, %.2f]", c.name, in.Confidence, c.minConfidence, c.maxConfidence)
}
}
// Weights: direct > hedged > reported; sarcasm discounts; doubt counts against.
direct, hedged, reported := Interpret("a", "The server is down."), Interpret("a", "I think the server is down."), Interpret("a", "Alice says the server is down.")
if !(direct.EvidenceWeight() > hedged.EvidenceWeight() && hedged.EvidenceWeight() > reported.EvidenceWeight()*1.2) {
t.Fatalf("weights: direct=%.3f hedged=%.3f reported=%.3f", direct.EvidenceWeight(), hedged.EvidenceWeight(), reported.EvidenceWeight())
}
if s := Interpret("a", "Oh yes, Dave is an absolute genius."); s.EvidenceWeight() >= direct.EvidenceWeight()/2 {
t.Fatalf("sarcasm not discounted: %.3f", s.EvidenceWeight())
}
if d := Interpret("a", "Sarah says the network is down, but I don't believe her."); d.DoubtWeight() == 0 || d.EvidenceWeight() == 0 {
t.Fatalf("disagreement should record both the reported claim and the doubt: %+v", d)
}
if h := Interpret("a", "Imagine Bob murdered somebody."); h.EvidenceWeight() != 0.5*0.85 && !h.Hypothetical {
t.Fatal("hypothetical weight irrelevant but flag must be set")
}
// Reported speech keeps the proposition, not the frame.
if r := Interpret("a", "Alice thinks the server is compromised."); r.Proposition != "the server is compromised" {
t.Fatalf("proposition should drop the reporting frame: %q", r.Proposition)
}
}
+172
View File
@@ -0,0 +1,172 @@
package epistemics
import (
"context"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/ids"
)
// Assertion is the append-only record that an actor took a stance.
type Assertion struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Actor string `json:"actor"`
ActorKind string `json:"actor_kind"`
AttributedTo string `json:"attributed_to"`
Proposition string `json:"proposition"`
Subject string `json:"subject"`
Stance string `json:"stance"`
Certainty string `json:"certainty"`
Modality string `json:"modality"`
Negated bool `json:"negated"`
Reported bool `json:"reported"`
ReportedActor string `json:"reported_actor,omitempty"`
Quoted bool `json:"quoted"`
Hypothetical bool `json:"hypothetical"`
Interpretation float64 `json:"interpretation_confidence"`
Notes []string `json:"notes"`
SourceEvent ids.ID `json:"source_event,omitempty"`
ConversationID ids.ID `json:"conversation_id,omitempty"`
MessageID ids.ID `json:"message_id,omitempty"`
Context string `json:"context,omitempty"`
Trust string `json:"trust"`
BeliefID ids.ID `json:"belief_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// New is the input to Record.
type New struct {
MindID ids.ID
Actor string
ActorKind string
Interpretation Interpretation
Subject string
SourceEvent ids.ID
ConversationID ids.ID
MessageID ids.ID
Context string
Trust string
BeliefID ids.ID
}
// Store persists assertions.
type Store struct{ db *pgxpool.Pool }
// NewStore creates a store.
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
const cols = `id, mind_id, actor, actor_kind, attributed_to, proposition, subject, stance, certainty, modality, negated, reported, reported_actor, quoted, hypothetical, interpretation_confidence, interpretation_notes, source_event, conversation_id, message_id, context, trust, belief_id, created_at`
// Record writes an assertion inside q.
func (s *Store) Record(ctx context.Context, q storage.Querier, n New) (Assertion, error) {
in := n.Interpretation
a := Assertion{ID: ids.New(), MindID: n.MindID, Actor: n.Actor, ActorKind: firstNonEmpty(n.ActorKind, "user"), AttributedTo: firstNonEmpty(in.AttributedTo, n.Actor), Proposition: in.Proposition, Subject: n.Subject,
Stance: in.Stance, Certainty: in.Certainty, Modality: in.Modality, Negated: in.Negated, Reported: in.Reported, ReportedActor: in.ReportedActor, Quoted: in.Quoted, Hypothetical: in.Hypothetical,
Interpretation: in.Confidence, Notes: in.Notes, SourceEvent: n.SourceEvent, ConversationID: n.ConversationID, MessageID: n.MessageID, Context: n.Context, Trust: firstNonEmpty(n.Trust, "user"), BeliefID: n.BeliefID, CreatedAt: time.Now()}
if a.Notes == nil {
a.Notes = []string{}
}
if in.SpeakerStance != "" && in.SpeakerStance != a.Stance && a.AttributedTo != n.Actor {
// The speaker's own stance is recorded separately so "Andy reports that
// Sarah asserts X" and "Andy doubts X" are both retrievable.
a.Notes = append(a.Notes, "speaker stance: "+in.SpeakerStance)
}
_, err := q.Exec(ctx, `INSERT INTO assertions (`+cols+`) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)`,
a.ID, a.MindID, a.Actor, a.ActorKind, a.AttributedTo, a.Proposition, a.Subject, a.Stance, a.Certainty, a.Modality, a.Negated, a.Reported, a.ReportedActor, a.Quoted, a.Hypothetical,
a.Interpretation, a.Notes, nullID(a.SourceEvent), nullID(a.ConversationID), nullID(a.MessageID), a.Context, a.Trust, nullID(a.BeliefID), a.CreatedAt)
return a, err
}
// Filter narrows a listing.
type Filter struct {
AttributedTo string
BeliefID ids.ID
Stance string
Limit int
}
// List returns assertions for a mind, newest first.
func (s *Store) List(ctx context.Context, mind ids.ID, f Filter) ([]Assertion, error) {
if f.Limit <= 0 || f.Limit > 500 {
f.Limit = 100
}
rows, err := s.db.Query(ctx, `SELECT `+cols+` FROM assertions WHERE mind_id=$1 AND ($2='' OR lower(attributed_to)=lower($2)) AND ($3::uuid IS NULL OR belief_id=$3) AND ($4='' OR stance=$4) ORDER BY created_at DESC LIMIT $5`,
mind, f.AttributedTo, nullID(f.BeliefID), f.Stance, f.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Assertion
for rows.Next() {
a, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// Actors summarises who has asserted things to a mind.
type ActorSummary struct {
AttributedTo string `json:"attributed_to"`
Assertions int `json:"assertions"`
Reported int `json:"reported"`
MeanClarity float64 `json:"mean_interpretation_confidence"`
}
// Actors lists asserting actors for a mind.
func (s *Store) Actors(ctx context.Context, mind ids.ID) ([]ActorSummary, error) {
rows, err := s.db.Query(ctx, `SELECT attributed_to, count(*), count(*) FILTER (WHERE reported), avg(interpretation_confidence) FROM assertions WHERE mind_id=$1 GROUP BY attributed_to ORDER BY count(*) DESC LIMIT 200`, mind)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ActorSummary
for rows.Next() {
var a ActorSummary
if err := rows.Scan(&a.AttributedTo, &a.Assertions, &a.Reported, &a.MeanClarity); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func scan(row pgx.Row) (Assertion, error) {
var a Assertion
var src, conv, msg, bel *string
if err := row.Scan(&a.ID, &a.MindID, &a.Actor, &a.ActorKind, &a.AttributedTo, &a.Proposition, &a.Subject, &a.Stance, &a.Certainty, &a.Modality, &a.Negated, &a.Reported, &a.ReportedActor, &a.Quoted, &a.Hypothetical,
&a.Interpretation, &a.Notes, &src, &conv, &msg, &a.Context, &a.Trust, &bel, &a.CreatedAt); err != nil {
return a, storage.Translate(err)
}
a.SourceEvent, a.ConversationID, a.MessageID, a.BeliefID = idOf(src), idOf(conv), idOf(msg), idOf(bel)
return a, nil
}
func nullID(id ids.ID) *string {
if id.IsZero() {
return nil
}
s := string(id)
return &s
}
func idOf(s *string) ids.ID {
if s == nil {
return ids.Nil
}
return ids.ID(*s)
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}