Files
Andy Dixon 0071658c93 feat(evidence): record provenance and stop derived evidence counting as corroboration
Every evidence row now records its derivation (direct, assertion, operator,
external, prediction, consolidation, inference, reflection, dream,
retrieval), its independent-source family (an actor, an integration, an
operator, a prediction) and its root event or memory ids. Confidence
groups evidence by family: the strongest primary item counts in full, each
further primary item adds a quarter, capped at 1.5x, and derived items
count only when the family has no primary member. Three derived
re-expressions of one utterance therefore weigh exactly what the utterance
weighs, while three independent families still add up.

Producers (conversation, association, consolidation, predictions,
metacognition, dreaming, operator and seed input, contradictions) supply
their ancestry. Existing rows are marked legacy_unknown with family =
source; nothing is invented. The belief page shows per-family
contributions and the replay test checks that provenance is reconstructed
identically in the sandbox. Migration 0017 is additive.
2026-07-31 21:14:52 +01:00

418 lines
16 KiB
Go

// Package consolidation turns clusters of related episodes into semantic
// memories and beliefs (spec §9). Clustering and confidence are
// deterministic; a model may only propose the wording of a generalisation,
// and every output links back to its episodes. Originals are never touched.
package consolidation
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/beliefs"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/internal/llm/prompts"
"github.com/andydixon/noema/internal/llm/router"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Job is one consolidation run.
type Job struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Status string `json:"status"`
Trigger string `json:"trigger"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Clusters int `json:"clusters"`
Produced int `json:"produced"`
Inputs json.RawMessage `json:"inputs"`
Outputs json.RawMessage `json:"outputs"`
Error string `json:"error"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
// Output is one generalisation produced by a job.
type Output struct {
MemoryID ids.ID `json:"memory_id"`
BeliefID ids.ID `json:"belief_id"`
Proposition string `json:"proposition"`
Confidence float64 `json:"confidence"`
Episodes []ids.ID `json:"episodes"`
Key string `json:"key"`
Wording string `json:"wording"` // model | deterministic
}
// Cluster is a group of episodes sharing distinctive terms.
type Cluster struct {
Key string
Terms []string
Episodes []memory.Memory
Sources map[string]bool
}
// Service runs consolidation.
type Service struct {
DB *pgxpool.Pool
Memory *memory.Store
Beliefs *beliefs.Store
Events *evstore.Store
LLM *router.Router
Prompts *prompts.Store
Minds *minds.Store
Log *slog.Logger
}
// Paused reports whether consolidation is globally paused (operator switch).
var paused = false
// SetPaused toggles the global pause.
func SetPaused(p bool) { paused = p }
// IsPaused reports the switch.
func IsPaused() bool { return paused }
// Clusterise groups episodes by shared distinctive terms. Deterministic:
// terms are stemmed crudely, sorted, and a cluster key is the sorted set of
// the two or three most frequent shared terms.
func Clusterise(eps []memory.Memory, minEpisodes int) []Cluster {
type ep struct {
m memory.Memory
terms map[string]bool
}
items := make([]ep, 0, len(eps))
df := map[string]int{}
for _, m := range eps {
terms := termSet(stripSpeaker(m.Content))
items = append(items, ep{m, terms})
for t := range terms {
df[t]++
}
}
// Candidate keys: pairs of terms appearing in >= minEpisodes episodes.
var freq []string
for t, n := range df {
if n >= minEpisodes && len(t) >= 2 {
freq = append(freq, t)
}
}
sort.Strings(freq)
used := map[ids.ID]bool{}
var out []Cluster
for i := 0; i < len(freq); i++ {
for j := i + 1; j < len(freq); j++ {
a, b := freq[i], freq[j]
var members []memory.Memory
sources := map[string]bool{}
for _, it := range items {
if used[it.m.ID] || !it.terms[a] || !it.terms[b] {
continue
}
members = append(members, it.m)
sources[it.m.Source+"/"+string(it.m.Trust)] = true
}
if len(members) < minEpisodes {
continue
}
// Extend the key with a third shared term when all members share it.
key := []string{a, b}
for _, t := range freq {
if t == a || t == b {
continue
}
all := true
for _, m := range members {
if !termSet(stripSpeaker(m.Content))[t] {
all = false
break
}
}
if all {
key = append(key, t)
break
}
}
sort.Strings(key)
for _, m := range members {
used[m.ID] = true
}
sort.Slice(members, func(x, y int) bool { return members[x].OccurredAt.Before(members[y].OccurredAt) })
out = append(out, Cluster{Key: strings.Join(key, "+"), Terms: key, Episodes: members, Sources: sources})
}
}
sort.Slice(out, func(i, j int) bool { return len(out[i].Episodes) > len(out[j].Episodes) })
return out
}
var stop = map[string]bool{"of": true, "to": true, "in": true, "on": true, "at": true, "an": true, "is": true, "it": true, "as": true, "by": true, "or": true, "if": true, "so": true, "no": true, "up": true, "do": true, "be": true, "we": true, "he": true, "me": true, "my": true, "us": true, "am": true, "the": true, "and": true, "for": true, "that": true, "this": true, "with": true, "from": true, "said": true, "was": true, "were": true, "has": true, "have": true, "had": true, "are": true, "its": true, "into": true, "about": true, "again": true, "after": true, "before": true, "when": true, "then": true, "than": true, "very": true, "just": true, "also": true, "been": true, "will": true, "would": true, "could": true, "should": true, "there": true, "their": true, "they": true, "them": true, "what": true, "which": true, "some": true, "more": true, "most": true, "over": true, "under": true, "your": true, "you": true, "not": true, "but": true, "all": true, "any": true, "can": true, "did": true, "does": true, "one": true, "two": true}
// stripSpeaker removes a leading "someone said: " so the speaker of a
// conversational episode does not become the pattern.
func stripSpeaker(text string) string {
if i := strings.Index(text, " said: "); i >= 0 && i < 40 {
return text[i+7:]
}
return text
}
// termSet extracts crude stems (lower-cased words with trailing s/ed/ing
// removed) so "deployments" and "deployment" cluster together.
func termSet(text string) map[string]bool {
out := map[string]bool{}
var cur strings.Builder
flush := func() {
w := strings.Trim(cur.String(), ".-_'")
cur.Reset()
if len(w) < 2 || stop[w] {
return
}
for _, suf := range []string{"ing", "ed", "es", "s"} {
if strings.HasSuffix(w, suf) && len(w)-len(suf) >= 3 {
w = strings.TrimSuffix(w, suf)
break
}
}
out[w] = true
}
for _, r := range strings.ToLower(text) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '.' || r == '_' || r == '\'' {
cur.WriteRune(r)
} else {
flush()
}
}
flush()
return out
}
// Proposition renders a deterministic generalisation for a cluster.
func Proposition(c Cluster) string {
first, last := c.Episodes[0].OccurredAt, c.Episodes[len(c.Episodes)-1].OccurredAt
span := "over " + humanSpan(last.Sub(first))
if last.Sub(first) < time.Hour {
span = "within an hour"
}
return fmt.Sprintf("Recurring pattern (%d episodes %s): %s", len(c.Episodes), span, strings.Join(c.Terms, ", "))
}
func humanSpan(d time.Duration) string {
switch {
case d < 24*time.Hour:
return fmt.Sprintf("%d hours", int(d.Hours()))
case d < 14*24*time.Hour:
return fmt.Sprintf("%d days", int(d.Hours()/24))
default:
return fmt.Sprintf("%d weeks", int(d.Hours()/24/7))
}
}
// Run consolidates a mind: cluster recent episodes, propose a generalisation
// per cluster, compute its confidence from the episodes, store a semantic
// memory and a belief linked to them, and record the job.
func (s *Service) Run(ctx context.Context, m minds.Mind, trigger, by string) (Job, error) {
job := Job{ID: ids.New(), MindID: m.ID, Status: "running", Trigger: trigger, CreatedBy: by, CreatedAt: time.Now()}
now := time.Now()
job.StartedAt = &now
if _, err := s.DB.Exec(ctx, `INSERT INTO consolidation_jobs (id, mind_id, status, trigger, started_at, created_by) VALUES ($1,$2,'running',$3,now(),$4)`, job.ID, m.ID, trigger, by); err != nil {
return job, err
}
finish := func(status, errText string, clusters, produced int, inputs, outputs any) (Job, error) {
in, _ := json.Marshal(inputs)
out, _ := json.Marshal(outputs)
if inputs == nil {
in = []byte("[]")
}
if outputs == nil {
out = []byte("[]")
}
_, err := s.DB.Exec(ctx, `UPDATE consolidation_jobs SET status=$2, error=$3, clusters=$4, produced=$5, inputs=$6, outputs=$7, finished_at=now() WHERE id=$1`, job.ID, status, errText, clusters, produced, in, out)
job.Status, job.Error, job.Clusters, job.Produced, job.Inputs, job.Outputs = status, errText, clusters, produced, in, out
return job, err
}
if paused && trigger == "schedule" {
return finish("rejected", "consolidation is paused", 0, 0, nil, nil)
}
since := now.Add(-30 * 24 * time.Hour)
eps, err := s.Memory.List(ctx, m.ID, memory.Filter{Kinds: []concepts.MemoryKind{concepts.Episodic}, Since: since, Limit: 500})
if err != nil {
return finish("failed", err.Error(), 0, 0, nil, nil)
}
// Skip episodes already consolidated (they have a derived_from edge into a semantic memory).
var fresh []memory.Memory
var inputs []ids.ID
for _, e := range eps {
var n int
s.DB.QueryRow(ctx, `SELECT count(*) FROM memory_relationships WHERE to_memory=$1 AND relation='derived_from'`, e.ID).Scan(&n) //nolint:errcheck
if n == 0 {
fresh = append(fresh, e)
inputs = append(inputs, e.ID)
}
}
clusters := Clusterise(fresh, m.Config.ConsolidationMinEpisodes)
llmBudget := m.Config.ConsolidationLLMCalls
var outputs []Output
for _, c := range clusters {
out, err := s.consolidate(ctx, m, c, &llmBudget)
if err != nil {
s.Log.Warn("consolidation cluster failed", "mind", m.Slug, "key", c.Key, "err", err)
continue
}
outputs = append(outputs, out)
}
return finish("done", "", len(clusters), len(outputs), inputs, outputs)
}
func (s *Service) consolidate(ctx context.Context, m minds.Mind, c Cluster, llmBudget *int) (Output, error) {
prop := Proposition(c)
wording := "deterministic"
var callID ids.ID
if s.LLM != nil && s.Prompts != nil && *llmBudget > 0 && s.LLM.Available(ctx, llm.RoleSummarise) {
if pr, err := s.Prompts.Get(ctx, "consolidate"); err == nil {
var lines []string
for _, e := range c.Episodes {
lines = append(lines, e.OccurredAt.Format("2006-01-02")+": "+e.Content)
}
if req, err := pr.Render(map[string]any{"Episodes": lines, "Terms": c.Terms}); err == nil {
req.MindID, req.Purpose = m.ID, "consolidate cluster "+c.Key
*llmBudget--
if resp, err := s.LLM.Complete(ctx, req); err == nil && len(resp.JSON) > 0 {
var out struct {
Proposition string `json:"proposition"`
Supported bool `json:"supported"`
}
if json.Unmarshal(resp.JSON, &out) == nil && out.Supported && len(strings.TrimSpace(out.Proposition)) > 8 {
prop, wording, callID = strings.TrimSpace(out.Proposition), "model", resp.CallID
}
}
}
}
}
var output Output
err := storage.Tx(ctx, s.DB, func(tx pgx.Tx) error {
var ev []beliefs.EvidenceInput
var refs []events.EvidenceRef
var epIDs []ids.ID
for _, e := range c.Episodes {
// Ancestry: the episode's origin event names the actor or channel it
// came from; five episodes from one person are one family.
family, roots := "memory:"+e.Source, []string{string(e.ID)}
if !e.OriginEvent.IsZero() {
roots = []string{string(e.OriginEvent)}
if oe, err := s.Events.Get(ctx, e.OriginEvent); err == nil {
if f := beliefs.FamilyFromEvent(oe); f != "" {
family = f
}
}
}
ev = append(ev, beliefs.EvidenceInput{Direction: "for", Kind: "memory", RefID: string(e.ID), Source: e.Source + "/" + string(e.Trust), Weight: 0.3, Credibility: e.Credibility, Note: "episode: " + truncate(e.Content, 80),
Derivation: beliefs.DerivationConsolidation, Family: family, RootIDs: roots})
refs = append(refs, events.EvidenceRef{Kind: "memory", ID: string(e.ID), Note: "episode"})
epIDs = append(epIDs, e.ID)
}
emit := func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) {
out, err := s.Events.AppendIn(ctx, q, n)
if err != nil {
return events.Event{}, err
}
return out[0], nil
}
res, err := s.Beliefs.Create(ctx, tx, emit, beliefs.New{MindID: m.ID, Proposition: prop, Subject: c.Key, Epistemic: concepts.Inference, Source: "consolidation", Evidence: ev, Detail: map[string]any{"cluster": c.Key, "episodes": len(c.Episodes), "wording": wording}})
if err != nil {
return err
}
mem, err := s.Memory.Create(ctx, tx, memory.New{MindID: m.ID, Kind: concepts.Semantic, Epistemic: concepts.Inference, Content: prop, Source: "consolidation", Trust: events.TrustSystem,
Confidence: res.Belief.Confidence, Importance: 0.5 + 0.1*float64(min(len(c.Episodes), 5)), Salience: 0.4, Detail: map[string]any{"belief_id": res.Belief.ID, "cluster": c.Key, "wording": wording}})
if err != nil {
return err
}
for _, e := range c.Episodes {
if _, err := s.Memory.Relate(ctx, tx, m.ID, mem.ID, e.ID, concepts.DerivedFrom, res.Belief.Confidence, "consolidation"); err != nil {
return err
}
}
if !callID.IsZero() {
refs = append(refs, events.EvidenceRef{Kind: "llm_call", ID: string(callID), Note: "wording"})
}
refs = append(refs, events.EvidenceRef{Kind: "memory", ID: string(mem.ID), Note: "generalisation"}, events.EvidenceRef{Kind: "belief", ID: string(res.Belief.ID)})
evs, err := s.Events.AppendIn(ctx, tx, events.New{MindID: m.ID, Type: events.MemoryConsolidated, Source: "consolidation", Summary: fmt.Sprintf("consolidated %d episodes (%.2f): %s", len(c.Episodes), res.Belief.Confidence, truncate(prop, 120)),
Salience: 0.5, Confidence: res.Belief.Confidence, Evidence: refs, Metadata: map[string]any{"memory_id": mem.ID, "belief_id": res.Belief.ID, "episodes": len(c.Episodes), "wording": wording},
Payload: map[string]any{"proposition": prop, "cluster": c.Key, "episodes": epIDs, "breakdown": res.Breakdown}})
if err != nil {
return err
}
s.Events.Publish(append(res.Events, evs...)...)
output = Output{MemoryID: mem.ID, BeliefID: res.Belief.ID, Proposition: prop, Confidence: res.Belief.Confidence, Episodes: epIDs, Key: c.Key, Wording: wording}
return nil
})
return output, err
}
// Jobs lists recent jobs for a mind.
func (s *Service) Jobs(ctx context.Context, mind ids.ID, limit int) ([]Job, error) {
if limit <= 0 || limit > 200 {
limit = 20
}
rows, err := s.DB.Query(ctx, `SELECT id, mind_id, status, trigger, started_at, finished_at, clusters, produced, inputs, outputs, error, created_by, created_at FROM consolidation_jobs WHERE mind_id=$1 ORDER BY created_at DESC LIMIT $2`, mind, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Job
for rows.Next() {
var j Job
if err := rows.Scan(&j.ID, &j.MindID, &j.Status, &j.Trigger, &j.StartedAt, &j.FinishedAt, &j.Clusters, &j.Produced, &j.Inputs, &j.Outputs, &j.Error, &j.CreatedBy, &j.CreatedAt); err != nil {
return nil, err
}
out = append(out, j)
}
return out, rows.Err()
}
// Reject undoes a generalisation: the memory is retracted, the belief pinned
// rejected, and an operator action recorded. Episodes are untouched.
func (s *Service) Reject(ctx context.Context, m minds.Mind, memoryID ids.ID, reason, by string) error {
mem, err := s.Memory.Get(ctx, m.ID, memoryID)
if err != nil {
return err
}
if err := s.Memory.SetStatus(ctx, m.ID, memoryID, memory.StatusRetracted); err != nil {
return err
}
if bid, ok := mem.Detail["belief_id"].(string); ok && bid != "" {
emit := func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) {
out, err := s.Events.AppendIn(ctx, q, n)
if err != nil {
return events.Event{}, err
}
s.Events.Publish(out...)
return out[0], nil
}
if _, err := s.Beliefs.Retract(ctx, emit, m.ID, ids.ID(bid), "consolidation rejected: "+reason, by, false); err != nil {
s.Log.Warn("reject consolidation belief failed", "err", err)
}
}
s.Events.OperatorAction(ctx, m.ID, by, "consolidation.reject", "Operator "+by+" rejected a consolidation: "+reason, map[string]any{"memory_id": memoryID, "reason": reason})
return nil
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n-1] + "…"
}
return s
}