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
+2
View File
@@ -20,6 +20,7 @@ import (
"github.com/andydixon/noema/internal/consolidation"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/dreaming"
"github.com/andydixon/noema/internal/epistemics"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/goals"
"github.com/andydixon/noema/internal/graph"
@@ -63,6 +64,7 @@ func testServer(t *testing.T) (*httptest.Server, string) {
deps.Integrations = &integrations.Manager{Store: integrations.NewStore(pool), Events: deps.Events, Graph: deps.Graph, Log: log, Pollers: map[integrations.Kind]integrations.Poller{}}
deps.Dreaming = &dreaming.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Log: log}
deps.Snapshots = &snapshots.Store{DB: pool, Events: deps.Events, Minds: deps.Minds}
deps.Assertions = epistemics.NewStore(pool)
deps.Narrative = narrative.NewStore(pool)
deps.Notifications = notifications.NewStore(pool)
deps.Consolidation = &consolidation.Service{DB: pool, Memory: deps.Memory, Beliefs: deps.Beliefs, Events: deps.Events, Minds: deps.Minds, Log: log}
+69
View File
@@ -0,0 +1,69 @@
package api
import (
"net/http"
"strconv"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/epistemics"
"github.com/andydixon/noema/pkg/ids"
)
// assertionRoutes expose who asserted what to a mind, kept apart from what
// the mind believes.
func (s *Server) assertionRoutes() {
m := s.mux
m.Handle("GET /api/v1/minds/{mind}/assertions", require(auth.RoleObserver, s.assertionsList))
m.Handle("GET /api/v1/minds/{mind}/assertions/actors", require(auth.RoleObserver, s.assertionActors))
}
func (s *Server) assertionsList(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
q := r.URL.Query()
f := epistemics.Filter{AttributedTo: q.Get("actor"), Stance: q.Get("stance"), Limit: intParam(q.Get("limit"), 100)}
if b := q.Get("belief"); b != "" {
id, err := ids.Parse(b)
if err != nil {
fail(w, r, ValidationError("bad belief id"))
return
}
f.BeliefID = id
}
list, err := s.deps.Assertions.List(r.Context(), m.ID, f)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []epistemics.Assertion{}
}
writeJSON(w, http.StatusOK, map[string]any{"items": list})
}
func (s *Server) assertionActors(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
list, err := s.deps.Assertions.Actors(r.Context(), m.ID)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []epistemics.ActorSummary{}
}
writeJSON(w, http.StatusOK, map[string]any{"items": list})
}
// intParam parses a bounded positive integer query value.
func intParam(v string, def int) int {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return def
}
return n
}
+16 -8
View File
@@ -9,6 +9,7 @@ import (
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/epistemics"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/events"
@@ -129,14 +130,15 @@ func firstNonEmpty(a, b string) string {
// BeliefDetail is the full picture of one belief.
type BeliefDetail struct {
Belief beliefs.Belief `json:"belief"`
Breakdown beliefs.Breakdown `json:"breakdown"`
Evidence []beliefs.EvidenceRow `json:"evidence"`
Versions []beliefs.Version `json:"versions"`
Alternatives []beliefs.Alternative `json:"alternatives"`
Links []beliefs.Link `json:"links"`
Events []events.Event `json:"events"`
Origin *events.Event `json:"origin,omitempty"`
Belief beliefs.Belief `json:"belief"`
Breakdown beliefs.Breakdown `json:"breakdown"`
Evidence []beliefs.EvidenceRow `json:"evidence"`
Versions []beliefs.Version `json:"versions"`
Alternatives []beliefs.Alternative `json:"alternatives"`
Links []beliefs.Link `json:"links"`
Events []events.Event `json:"events"`
Origin *events.Event `json:"origin,omitempty"`
Assertions []epistemics.Assertion `json:"assertions"` // who stated this proposition, with what stance
}
// BeliefDetail assembles the detail; shared with the UI.
@@ -156,6 +158,12 @@ func (s *Server) BeliefDetail(r *http.Request, mind, id ids.ID) (BeliefDetail, e
d.Versions, _ = s.deps.Beliefs.Versions(ctx, mind, id)
d.Alternatives, _ = s.deps.Beliefs.Alternatives(ctx, mind, id)
d.Links, _ = s.deps.Beliefs.Links(ctx, mind, id)
if s.deps.Assertions != nil {
d.Assertions, _ = s.deps.Assertions.List(ctx, mind, epistemics.Filter{BeliefID: id, Limit: 50})
}
if d.Assertions == nil {
d.Assertions = []epistemics.Assertion{}
}
d.Events, _ = s.deps.Events.DerivedFrom(ctx, "belief", string(id), 100)
if !b.OriginEvent.IsZero() {
if e, err := s.deps.Events.Get(ctx, b.OriginEvent); err == nil {
+112
View File
@@ -2626,6 +2626,118 @@
}
}
},
"/api/v1/minds/{mind}/assertions": {
"get": {
"operationId": "assertionsList",
"summary": "list assertions: who took what stance on which proposition",
"x-required-role": "observer",
"tags": [
"minds"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
}
},
"parameters": [
{
"name": "mind",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "mind slug or id"
},
{
"name": "actor",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "belief",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "stance",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "limit",
"in": "query",
"schema": {
"type": "integer"
}
}
]
}
},
"/api/v1/minds/{mind}/assertions/actors": {
"get": {
"operationId": "assertionActors",
"summary": "summarise asserting actors",
"x-required-role": "observer",
"tags": [
"minds"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"$ref": "#/components/responses/Error"
},
"403": {
"$ref": "#/components/responses/Error"
},
"404": {
"$ref": "#/components/responses/Error"
}
},
"parameters": [
{
"name": "mind",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "mind slug or id"
}
]
}
},
"/api/v1/minds/{mind}/beliefs": {
"get": {
"operationId": "beliefsList",
+1
View File
@@ -79,6 +79,7 @@ func (s *Server) routes() {
s.integrationRoutes()
s.dreamRoutes()
s.studyRoutes()
s.assertionRoutes()
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")