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
+39
View File
@@ -1,6 +1,7 @@
package cognition
import (
"encoding/json"
"context"
"sort"
"strings"
@@ -8,6 +9,7 @@ import (
"github.com/andydixon/noema/internal/attention"
"github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/epistemics"
"github.com/andydixon/noema/internal/workspace"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
@@ -43,10 +45,29 @@ func (a Associate) Observe(_ context.Context, s *brain.State) ([]brain.Candidate
if laterU < 0.4 {
continue
}
// Speech is not a world event. A hypothetical or a question cannot be a
// cause or an effect, and two turns by the same speaker in one
// conversation are one person talking, not a sequence of happenings.
speaker := ""
if ev.Metadata["conversation_id"] != nil {
var p events.ObservationPayload
if json.Unmarshal(ev.Payload, &p) == nil {
speaker = p.Actor
}
if !worldly(speaker, ev.Summary) {
continue
}
}
for _, it := range s.Workspace.WorkingMemory {
if it.Kind != "observation" || it.SourceEvent.IsZero() || it.SourceEvent == ev.ID {
continue
}
if speaker != "" && strings.HasPrefix(it.Content, speaker+": ") {
continue
}
if !worldly("", it.Content) {
continue
}
if ev.OccurredAt.Sub(it.TouchedAt) > window || it.TouchedAt.After(ev.OccurredAt) {
continue
}
@@ -74,3 +95,21 @@ func toSet(ws []string) map[string]struct{} {
}
return m
}
// worldly reports whether an utterance describes something that happened,
// as opposed to a hypothetical, a question or a request, which cannot stand
// as a cause or an effect.
func worldly(speaker, summary string) bool {
text := summary
if speaker != "" {
text = strings.TrimPrefix(text, speaker+": ")
} else if i := strings.Index(text, ": "); i > 0 && i < 40 {
text = text[i+2:]
}
in := epistemics.Interpret(speaker, text)
switch in.Modality {
case epistemics.ModalityHypothetical, epistemics.ModalityQuestion, epistemics.ModalityImperative:
return false
}
return true
}
+2
View File
@@ -11,6 +11,7 @@ import (
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/epistemics"
"github.com/andydixon/noema/internal/graph"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/predictions"
@@ -27,6 +28,7 @@ type Handlers struct {
Beliefs *beliefs.Store
Conversations *conversation.Store
Graph *graph.Store
Assertions *epistemics.Store // nil disables assertion records (claims still become evidence)
}
// Register installs the handlers on a registry.
+70 -13
View File
@@ -10,6 +10,7 @@ import (
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/epistemics"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/internal/memory"
@@ -90,27 +91,57 @@ func (h Handlers) perceive(ctx context.Context, c *brain.Cycle, t workspace.Thou
Evidence: []events.EvidenceRef{{Kind: "memory", ID: string(mem.ID)}, {Kind: "event", ID: srcID, Note: "source"}}, Metadata: map[string]any{"memory_id": mem.ID, "kind": mem.Kind}})
}
}
// Claims become beliefs; the message is their evidence.
// Every claim is first an assertion: the certain fact that someone took a
// stance. Only factual assertions (direct or reported) then feed a
// proposition belief, as one piece of evidence attributed to whoever
// actually stands behind it; hypotheticals, questions and imperatives
// never do, and expressed doubt counts against.
var claimed []BeliefRef
var asserted []AssertionRef
if h.Beliefs != nil {
for _, cl := range a.Claims {
w := 0.6
if cl.Certainty == "hedged" {
w = 0.3
in := epistemics.Interpret(actor, cl.Proposition)
if cl.Certainty == "hedged" && in.Certainty == epistemics.CertaintyAsserted {
in.Certainty = epistemics.CertaintyHedged
}
if a.Intent == "correction" {
w += 0.2
var beliefID ids.ID
var res beliefs.Result
if in.CreatesBelief() {
w := in.EvidenceWeight()
if a.Intent == "correction" && !in.Reported {
w = min(1, w+0.2)
}
attributed := in.AttributedTo
cred := trust.Credibility()
if in.Reported {
cred = events.TrustLow.Credibility() + 0.2 // second-hand through a trusted speaker
}
ev := []beliefs.EvidenceInput{{Direction: "for", Kind: "assertion", RefID: "", Source: "asserted-by:" + attributed, Weight: w, Credibility: cred,
Note: describeAssertion(actor, in), Derivation: beliefs.DerivationAssertion, Family: beliefs.FamilyForActor(attributed), RootIDs: []string{srcID}}}
if dw := in.DoubtWeight(); dw > 0 {
ev = append(ev, beliefs.EvidenceInput{Direction: "against", Kind: "assertion", Source: "doubted-by:" + actor, Weight: dw, Credibility: trust.Credibility(),
Note: actor + " doubts it", Derivation: beliefs.DerivationAssertion, Family: beliefs.FamilyForActor(actor), RootIDs: []string{srcID}})
}
var err error
res, err = h.Beliefs.Create(ctx, c.Tx, emitter(c), beliefs.New{MindID: mind.ID, Proposition: in.Proposition, Epistemic: concepts.Claim, Source: "conversation", OriginEvent: perc.ID, Causation: perc.ID, Correlation: c.State.CycleID, Evidence: ev,
Detail: map[string]any{"attributed_to": attributed, "reported": in.Reported, "interpretation_confidence": in.Confidence}})
if err != nil {
c.Emit(events.New{Type: events.Metacognition, Source: "brain.perceive", Summary: "claim not recorded: " + err.Error(), Salience: 0.3, Confidence: 1, Metadata: map[string]any{"check": "claim_rejected"}})
} else {
beliefID = res.Belief.ID
claimed = append(claimed, BeliefRef{ID: res.Belief.ID, Proposition: res.Belief.Proposition, Confidence: res.Belief.Confidence, Status: string(res.Belief.Status)})
}
}
res, err := h.Beliefs.Create(ctx, c.Tx, emitter(c), beliefs.New{MindID: mind.ID, Proposition: cl.Proposition, Epistemic: concepts.Claim, Source: "conversation", OriginEvent: perc.ID, Causation: perc.ID, Correlation: c.State.CycleID,
Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "event", RefID: srcID, Source: "said-by:" + actor, Weight: w, Credibility: trust.Credibility(), Note: actor + " " + cl.Certainty + " it",
Derivation: beliefs.DerivationAssertion, Family: beliefs.FamilyForActor(actor), RootIDs: []string{srcID}}}})
if err != nil {
c.Emit(events.New{Type: events.Metacognition, Source: "brain.perceive", Summary: "claim not recorded: " + err.Error(), Salience: 0.3, Confidence: 1, Metadata: map[string]any{"check": "claim_rejected"}})
continue
if h.Assertions != nil {
rec, err := h.Assertions.Record(ctx, c.Tx, epistemics.New{MindID: mind.ID, Actor: actor, ActorKind: string(trust), Interpretation: in, Subject: subjectKey(in.Proposition), SourceEvent: ids.ID(srcID), ConversationID: ids.ID(convID), MessageID: ids.ID(msgID), Context: truncate(text, 300), Trust: string(trust), BeliefID: beliefID})
if err != nil {
return err
}
asserted = append(asserted, AssertionRef{ID: rec.ID, AttributedTo: rec.AttributedTo, Proposition: rec.Proposition, Stance: rec.Stance, Modality: rec.Modality, Reported: rec.Reported, Hypothetical: rec.Hypothetical, Interpretation: rec.Interpretation, BeliefID: beliefID})
}
claimed = append(claimed, BeliefRef{ID: res.Belief.ID, Proposition: res.Belief.Proposition, Confidence: res.Belief.Confidence, Status: string(res.Belief.Status)})
}
}
_ = asserted
refs := map[string]ids.ID{"memory": memRef}
for _, b := range claimed {
refs["belief"] = b.ID
@@ -449,3 +480,29 @@ func firstNonEmpty(a, b string) string {
}
return b
}
// AssertionRef is the response intent's view of a recorded assertion.
type AssertionRef struct {
ID ids.ID `json:"id"`
AttributedTo string `json:"attributed_to"`
Proposition string `json:"proposition"`
Stance string `json:"stance"`
Modality string `json:"modality"`
Reported bool `json:"reported"`
Hypothetical bool `json:"hypothetical"`
Interpretation float64 `json:"interpretation_confidence"`
BeliefID ids.ID `json:"belief_id,omitempty"`
}
// describeAssertion words the evidence note so the belief page says who
// stood behind the proposition and how the mind read them.
func describeAssertion(speaker string, in epistemics.Interpretation) string {
switch {
case in.Reported && in.Quoted:
return speaker + " quoted " + in.AttributedTo + " (" + in.Certainty + ")"
case in.Reported:
return speaker + " reported that " + in.AttributedTo + " holds this (" + in.Certainty + ", read with " + fmt.Sprintf("%.0f%%", in.Confidence*100) + " interpretation confidence)"
default:
return speaker + " " + in.Certainty + " it (read with " + fmt.Sprintf("%.0f%%", in.Confidence*100) + " interpretation confidence)"
}
}