Files
Andy Dixon 08a265f0fa feat(beliefs): represent unknown explicitly; absence of evidence is not falsity
A belief with no live evidence on either side now derives the status
'unknown' instead of sitting tentatively at its prior. Withdrawing every
piece of evidence leaves the proposition unknown, never rejected, and
introspection answers 'I do not know whether X' rather than quoting the
prior. Hypotheses keep their own status because they are proposals by
construction. Migration 0019 widens the status constraint; the docs define
every epistemic number and how absence of evidence is handled.
2026-08-05 23:04:15 +01:00

116 lines
3.9 KiB
Go

package cognition
import (
"context"
"encoding/json"
"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
}