Files
noema/internal/cognition/associate.go
T
Andy Dixon 9b2521bffd 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.
2026-08-05 19:22:48 +01:00

116 lines
3.9 KiB
Go

package cognition
import (
"encoding/json"
"context"
"sort"
"strings"
"time"
"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"
)
// Associate proposes low-confidence causal hypotheses when two notable
// observations arrive close together ("deployment at 09:10, latency at
// 09:12"). It is deliberately weak: confidence comes from the belief layer's
// arithmetic over the two observations, never from the wording.
type Associate struct {
Window time.Duration // how far back an earlier observation may lie
}
// Name implements brain.Process.
func (Associate) Name() string { return "associate" }
// Observe implements brain.Process.
func (a Associate) Observe(_ context.Context, s *brain.State) ([]brain.Candidate, error) {
if s.Services.Beliefs == nil {
return nil, nil
}
window := a.Window
if window <= 0 {
window = 15 * time.Minute
}
var out []brain.Candidate
for _, ev := range s.NewEvents {
if strings.HasPrefix(ev.Source, "brain") || ev.Type != events.Observation {
continue
}
laterToks := workspace.Tokenise(ev.Summary)
laterU := urgency(laterToks)
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
}
earlierToks := workspace.Tokenise(it.Content)
if urgency(earlierToks) < 0.3 && workspace.Overlap(earlierToks, toSet(laterToks)) < 0.2 {
continue
}
pair := []string{string(it.SourceEvent), string(ev.ID)}
sort.Strings(pair)
prop := truncate(it.Content, 100) + " may have caused: " + truncate(ev.Summary, 100)
out = append(out, brain.Candidate{Kind: "hypothesis", Subject: strings.Join(pair, "+"), Summary: "hypothesis: " + prop,
Content: map[string]any{"proposition": prop, "cause_event": string(it.SourceEvent), "effect_event": string(ev.ID), "gap_seconds": int(ev.OccurredAt.Sub(it.TouchedAt).Seconds()), "source_event": string(ev.ID)},
Features: attention.Features{Salience: 0.4 + 0.5*ev.Salience, Novelty: 0.85, Consequence: 0.8, Urgency: laterU * 0.6, Confidence: 0.3, ContextRelevance: 0.6, OperatorPriority: 0.5},
Confidence: 0.3, SourceEvents: []ids.ID{ev.ID, it.SourceEvent},
Evidence: []events.EvidenceRef{{Kind: "event", ID: string(it.SourceEvent), Note: "earlier"}, {Kind: "event", ID: string(ev.ID), Note: "later"}}})
}
}
return out, nil
}
func toSet(ws []string) map[string]struct{} {
m := make(map[string]struct{}, len(ws))
for _, w := range ws {
m[w] = 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
}