From 73e5753a91288c700585dd683ce6b0ce27ce35ab Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sun, 21 Jun 2026 22:14:12 +0100 Subject: [PATCH] feat: add beliefs with evidence-derived confidence, contradictions and corrections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6. Beliefs are propositions with a prior set by epistemic kind and a confidence computed from typed evidence: sigmoid(logit(prior) + slope × (support × diversity − opposition)), where support from a single source is discounted so repetition cannot manufacture certainty, and bounds keep the mind away from absolute certainty. Every evaluation stores its breakdown and appends a version row protected by the append-only trigger; the belief page shows the arithmetic, evidence for and against, alternatives, contradictions and history. Contradiction detection is deterministic first (negation plus token overlap): a hit emits contradiction_detected, links both beliefs and makes each counter-evidence for the other so both trend to contested. Operators can assert beliefs (their statement is the evidence), add evidence, mark a belief wrong (pinned rejected, history kept) and forbid an inference; do-not-infer corrections block matching hypotheses. Retracting a memory retracts the evidence that cited it and re-evaluates the beliefs concerned. The associate process proposes low-confidence causal hypotheses from co-occurring notable observations, and the hypothesis handler turns admitted ones into beliefs with automatic alternatives, closing demo scenarios 1 and 2 without any model call. --- CHANGELOG.md | 1 + PLAN.md | 16 +- cmd/noemactl/beliefs.go | 78 +++ cmd/noemad/main.go | 12 +- docs/beliefs.md | 53 ++ internal/api/api_test.go | 3 +- internal/api/beliefs.go | 351 +++++++++++ internal/api/memory.go | 16 + internal/api/server.go | 1 + internal/app/app.go | 2 + internal/beliefs/confidence.go | 253 ++++++++ internal/beliefs/confidence_test.go | 69 +++ internal/beliefs/store.go | 789 +++++++++++++++++++++++++ internal/beliefs/store_test.go | 122 ++++ internal/brain/cycle.go | 26 + internal/brain/process.go | 6 +- internal/cognition/associate.go | 76 +++ internal/cognition/handlers.go | 55 +- internal/ui/beliefs.go | 159 +++++ internal/ui/memory.go | 5 + internal/ui/minds.go | 5 +- internal/ui/ui.go | 11 +- migrations/0006_beliefs.down.sql | 6 + migrations/0006_beliefs.up.sql | 104 ++++ web/static/app.css | 7 + web/templates/pages/belief_detail.html | 72 +++ web/templates/pages/beliefs.html | 57 ++ web/templates/pages/mind.html | 7 + web/templates/partials/mind_tabs.html | 2 +- 29 files changed, 2344 insertions(+), 20 deletions(-) create mode 100644 cmd/noemactl/beliefs.go create mode 100644 docs/beliefs.md create mode 100644 internal/api/beliefs.go create mode 100644 internal/beliefs/confidence.go create mode 100644 internal/beliefs/confidence_test.go create mode 100644 internal/beliefs/store.go create mode 100644 internal/beliefs/store_test.go create mode 100644 internal/cognition/associate.go create mode 100644 internal/ui/beliefs.go create mode 100644 migrations/0006_beliefs.down.sql create mode 100644 migrations/0006_beliefs.up.sql create mode 100644 web/templates/pages/belief_detail.html create mode 100644 web/templates/pages/beliefs.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e27d9f..8188df1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Noema are recorded here. Format follows Keep a Changelog; ## [Unreleased] ### Added +- Phase 6 beliefs: evidence-derived confidence with inspectable arithmetic, statuses, alternatives, append-only history, deterministic contradiction detection, temporal-association hypotheses, operator corrections (wrong, do-not-infer, inaccurate memory), belief explorer and CLI. - Phase 5 memory: episodic/semantic/procedural/autobiographical memory with epistemic status, fused retrieval with inspectable component scores, optional pgvector embeddings, decay without deletion, attention-gated encoding, retrieval process, memory explorer and CLI. - Phase 4 cognition core: deterministic attention scoring with inspectable breakdowns, global workspace and working memory with decay, per-mind cognitive runtime with bounded concurrent processes, loop detection, operator controls, live workspace page and CLI. - Phase 3 events: append-only cognitive event store with provenance links, filtered queries, durable cursors, live SSE streams with resume, forensic event explorer UI, thought stream page, operator observations and CLI. diff --git a/PLAN.md b/PLAN.md index 2272a83..a02b4e9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -91,14 +91,14 @@ An administrator can: start Noema → log in → create a mind → configure per - ☑ `noemactl memory search|list|inspect|add`; decay and embedding background jobs; docs/memory.md - ☑ Tests: lifecycle, fused retrieval, cross-mind isolation (retrieve, get, relate), reinforcement, decay, retraction, stats -### Phase 6 – Beliefs, evidence, confidence, contradictions -- ☐ `migrations/0006_beliefs`: beliefs, belief_versions (append-only), belief_evidence, corrections -- ☐ `internal/beliefs`: create/update/challenge/retract, deterministic confidence from evidence, statuses, alternatives, provenance -- ☐ Processes: contradiction detection (deterministic first), hypothesis generation (LLM-assisted proposal, code-computed confidence) -- ☐ Operator corrections (“belief wrong”, “memory inaccurate”, “do not infer again”) as events -- ☐ UI: belief explorer with evidence for/against, alternatives, history; correction actions -- ☐ `noemactl belief list|inspect` -- ☐ Tests: confidence arithmetic, provenance, contradiction, corrections preserve history +### Phase 6 – Beliefs, evidence, confidence, contradictions ☑ +- ☑ `migrations/0006_beliefs`: beliefs, belief_evidence, belief_alternatives, belief_links, append-only belief_versions and corrections +- ☑ `internal/beliefs`: deterministic confidence (prior by epistemic kind, weight×credibility, single-source diversity discount, bounded sigmoid) with stored breakdown; derived statuses; create/add evidence/re-evaluate/retract/alternatives/links; deterministic contradiction detection (negation + Jaccard) with mutual counter-evidence; operator corrections including do-not-infer blocking; retracted-evidence propagation +- ☑ Process `associate` (temporal co-occurrence hypotheses) and `hypothesis` handler creating beliefs with automatic alternatives; cycle `EmitNow` for atomic provenance +- ☑ API: list, assert, detail (breakdown, evidence, versions, alternatives, links, events), evidence, alternatives, retract, corrections; memory retraction re-evaluates beliefs +- ☑ UI: belief explorer with statuses and corrections, belief detail with arithmetic, evidence for/against, alternatives, contradictions, history, correction forms; overview shows important beliefs +- ☑ `noemactl belief list|inspect|assert|wrong`; docs/beliefs.md +- ☑ Tests: monotonic bounded confidence, diversity discount, status derivation, contradiction detection, lifecycle with provenance, append-only history, do-not-infer enforcement, isolation ### Phase 7 – LLM provider abstraction and configuration UI - ☐ `migrations/0007_llm`: llm_providers, llm_models, llm_roles, llm_calls, llm_usage, prompts, prompt_versions diff --git a/cmd/noemactl/beliefs.go b/cmd/noemactl/beliefs.go new file mode 100644 index 0000000..d0da8f4 --- /dev/null +++ b/cmd/noemactl/beliefs.go @@ -0,0 +1,78 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "strings" +) + +func init() { + extraCommands = append(extraCommands, command{"belief", "list --mind m [-status s]|inspect |assert |wrong – beliefs", cmdBelief}) +} + +func cmdBelief(args []string) int { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: noemactl belief list --mind [-status s] [-json] | inspect | assert | wrong ") + return exitUsage + } + switch args[0] { + case "list": + fs := flag.NewFlagSet("belief list", flag.ContinueOnError) + mind := fs.String("mind", "", "mind slug or id") + status := fs.String("status", "", "status filter") + asJSON := fs.Bool("json", false, "raw JSON") + if fs.Parse(args[1:]) != nil || *mind == "" { + fmt.Fprintln(os.Stderr, "usage: noemactl belief list --mind [-status s]") + return exitUsage + } + var out struct{ Items []map[string]any } + if code, err := apiRequest("GET", "/api/v1/minds/"+*mind+"/beliefs?status="+*status, nil, &out); err != nil { + return exitFor(code, err) + } + if *asJSON { + json.NewEncoder(os.Stdout).Encode(out.Items) //nolint:errcheck + return exitOK + } + for _, b := range out.Items { + fmt.Printf("%.2f %-10v %s (%v)\n", num(b["confidence"]), b["status"], b["proposition"], b["id"]) + } + return exitOK + case "inspect": + if len(args) < 3 { + return exitUsage + } + var d map[string]any + if code, err := apiRequest("GET", "/api/v1/minds/"+args[1]+"/beliefs/"+args[2], nil, &d); err != nil { + return exitFor(code, err) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.Encode(d) //nolint:errcheck + return exitOK + case "assert": + if len(args) < 3 { + return exitUsage + } + var res map[string]any + if code, err := apiRequest("POST", "/api/v1/minds/"+args[1]+"/beliefs", map[string]any{"proposition": strings.Join(args[2:], " ")}, &res); err != nil { + return exitFor(code, err) + } + b, _ := res["Belief"].(map[string]any) + fmt.Printf("recorded %v belief %v with confidence %.2f\n", b["status"], b["id"], num(b["confidence"])) + return exitOK + case "wrong": + if len(args) < 4 { + return exitUsage + } + var res map[string]any + if code, err := apiRequest("POST", "/api/v1/minds/"+args[1]+"/beliefs/"+args[2]+"/retract", map[string]any{"reason": strings.Join(args[3:], " ")}, &res); err != nil { + return exitFor(code, err) + } + fmt.Println("belief rejected; history preserved") + return exitOK + } + fmt.Fprintln(os.Stderr, "unknown belief subcommand", args[0]) + return exitUsage +} diff --git a/cmd/noemad/main.go b/cmd/noemad/main.go index d23473c..6bdfb20 100644 --- a/cmd/noemad/main.go +++ b/cmd/noemad/main.go @@ -17,6 +17,7 @@ import ( "github.com/andydixon/noema/internal/app" "github.com/andydixon/noema/internal/audit" "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/beliefs" "github.com/andydixon/noema/internal/brain" "github.com/andydixon/noema/internal/cognition" "github.com/andydixon/noema/internal/config" @@ -88,10 +89,11 @@ func run() error { group := scheduler.NewGroup(ctx, log) deps.Memory = memory.NewStore(ctx, db, nil) - registry := brain.NewRegistry().AddProcess(cognition.Attend{}).AddProcess(cognition.Retrieve{}) - cognition.Handlers{Memory: deps.Memory}.Register(registry) + deps.Beliefs = beliefs.NewStore(db) + registry := brain.NewRegistry().AddProcess(cognition.Attend{}).AddProcess(cognition.Retrieve{}).AddProcess(cognition.Associate{}) + cognition.Handlers{Memory: deps.Memory, Beliefs: deps.Beliefs}.Register(registry) deps.Brain = brain.NewManager(brain.Config{DB: db, Events: deps.Events, Minds: deps.Minds, Registry: registry, Pool: scheduler.NewPool(cfg.MaxWorkers), - Log: log, Metrics: metrics, Services: brain.Services{Events: deps.Events, Memory: deps.Memory}}, group) + Log: log, Metrics: metrics, Services: brain.Services{Events: deps.Events, Memory: deps.Memory, Beliefs: deps.Beliefs}}, group) group.Every("memory.decay", 6*time.Hour, func(ctx context.Context) error { return decayMemories(ctx, deps) }) group.Every("memory.embed", time.Minute, func(ctx context.Context) error { _, err := deps.Memory.EmbedMissing(ctx, 50); return err }) if err := deps.Brain.Start(ctx); err != nil { @@ -102,7 +104,9 @@ func run() error { uiHandler, err := ui.New(deps, func(r *http.Request) any { return srv.Status(r.Context()) }, func(r *http.Request, id ids.ID) (api.EventContext, error) { return srv.Context(r, id) }, func(r *http.Request, m minds.Mind) (api.WorkspaceView, error) { return srv.Workspace(r.Context(), m) }, - func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error) { return srv.MemoryDetail(r, mind, id) }) + func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error) { return srv.MemoryDetail(r, mind, id) }, + func(r *http.Request, mind, id ids.ID) (api.BeliefDetail, error) { return srv.BeliefDetail(r, mind, id) }, + func() beliefs.Emitter { return srv.Emitter() }) if err != nil { return err } diff --git a/docs/beliefs.md b/docs/beliefs.md new file mode 100644 index 0000000..bede129 --- /dev/null +++ b/docs/beliefs.md @@ -0,0 +1,53 @@ +# Belief system + +A **belief** is a proposition the mind holds with an explicit, evidence-derived confidence and a status. Beliefs are never edited in place: every change appends a `belief_versions` row (append-only trigger) and an event. + +## Structure + +| Table | Contents | +|---|---| +| `beliefs` | proposition, optional subject, epistemic kind, status, confidence, prior, version, source, origin event, operator-pinned status | +| `belief_evidence` | typed references (event, memory, belief, prediction, operator, note) with direction for/against, weight (how much it bears on the proposition), credibility (trust in the source), retracted flag | +| `belief_alternatives` | competing explanations kept alongside (open, dismissed, promoted) | +| `belief_links` | contradicts / supports / alternative_to / derived_from between beliefs | +| `belief_versions` | append-only history with the confidence breakdown and the event that caused each change | +| `corrections` | operator statements: belief wrong, memory inaccurate, do not infer, entity same/different | + +## Confidence arithmetic + +Confidence is never typed in by a person or produced by a model. It is: + +``` +for = Σ weight × credibility over supporting evidence +against = Σ weight × credibility over opposing evidence +diversity = 0.6 + 0.4 × min(1, (distinct supporting sources − 1) / 2) +confidence = sigmoid( logit(prior) + 1.5 × (for × diversity − against) ) clamped to [0.02, 0.98] +``` + +- The **prior** depends on the epistemic kind: hypothesis 0.25, assumption/opinion 0.4, claim 0.5, observation/memory 0.6, fact 0.8. +- **Diversity** discounts support that all comes from one source, so repetition from a single feed cannot manufacture certainty (threat T4). +- Absolute certainty is unreachable; the bounds are deliberate (spec §10). + +The breakdown (prior, for, against, sources, diversity, log-odds, formula, notes) is stored with every version and shown on the belief page. + +## Status + +Derived from the breakdown unless an operator pinned it: + +- `contested` when both sides carry real weight (against ≥ half of the discounted support) and confidence is mid-range; +- `rejected` below 0.15; `hypothesis` for hypotheses without support or below 0.5; `tentative` below 0.7; `likely` below 0.85; `accepted` above; +- `superseded` when replaced by a newer proposition. + +## Contradictions + +On creation and on gaining support, a belief is compared with the mind's live beliefs. The deterministic pass detects the same content with flipped polarity (negation words plus token Jaccard ≥ 0.6). A hit emits `contradiction_detected`, links both beliefs, and makes each counter-evidence for the other weighted by the other's confidence, so both move towards `contested` until more evidence arrives. Paraphrase-level judgement is added by an LLM-assisted process once providers exist; it can only propose, and the same arithmetic applies. + +## Hypothesis generation + +The `associate` process proposes causal hypotheses when two notable observations occur within a window ("deployment … may have caused: latency alert"). Each observation is weak evidence (weight 0.15) so the belief starts as a hypothesis around 0.3–0.4; independent support is needed to move it. Alternatives ("coincidence", "a third factor") are recorded automatically. + +## Corrections + +- **This belief is wrong** pins the status to rejected, adds operator counter-evidence, records a `belief_wrong` correction and a `belief_retracted` event. History is kept. +- **Do not infer this again** stores a `do_not_infer` correction; future creations of a matching proposition (token Jaccard ≥ 0.7) are refused and logged as metacognition. +- **This memory is inaccurate** retracts the memory and marks belief evidence citing it as retracted; affected beliefs are re-evaluated with a `belief_updated` event. diff --git a/internal/api/api_test.go b/internal/api/api_test.go index cc4cdd3..f7211db 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -13,6 +13,7 @@ import ( "github.com/andydixon/noema/internal/app" "github.com/andydixon/noema/internal/audit" "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/beliefs" "github.com/andydixon/noema/internal/brain" "github.com/andydixon/noema/internal/config" evstore "github.com/andydixon/noema/internal/events" @@ -31,7 +32,7 @@ func testServer(t *testing.T) (*httptest.Server, string) { cfg.DatabaseURL = "postgres://test" log := slog.New(slog.DiscardHandler) deps := app.Deps{Config: cfg, Log: log, DB: pool, Auth: auth.NewService(pool, time.Hour, 2*time.Hour), - Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Minds: minds.NewStore(pool), Memory: memory.NewStore(context.Background(), pool, nil), Events: evstore.NewStore(pool, evstore.NewBus(50), nil), Migrations: migrations.FS, Started: time.Now()} + Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Minds: minds.NewStore(pool), Beliefs: beliefs.NewStore(pool), Memory: memory.NewStore(context.Background(), pool, nil), Events: evstore.NewStore(pool, evstore.NewBus(50), nil), Migrations: migrations.FS, Started: time.Now()} ctx := context.Background() group := scheduler.NewGroup(ctx, log) t.Cleanup(func() { group.Stop() }) diff --git a/internal/api/beliefs.go b/internal/api/beliefs.go new file mode 100644 index 0000000..aa43f15 --- /dev/null +++ b/internal/api/beliefs.go @@ -0,0 +1,351 @@ +package api + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/beliefs" + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/pkg/concepts" + "github.com/andydixon/noema/pkg/events" + "github.com/andydixon/noema/pkg/ids" +) + +func (s *Server) beliefRoutes() { + m := s.mux + m.Handle("GET /api/v1/minds/{mind}/beliefs", require(auth.RoleObserver, s.beliefsList)) + m.Handle("POST /api/v1/minds/{mind}/beliefs", require(auth.RoleOperator, s.beliefsCreate)) + m.Handle("GET /api/v1/minds/{mind}/beliefs/stats", require(auth.RoleObserver, s.beliefsStats)) + m.Handle("GET /api/v1/minds/{mind}/beliefs/{id}", require(auth.RoleObserver, s.beliefGet)) + m.Handle("POST /api/v1/minds/{mind}/beliefs/{id}/evidence", require(auth.RoleOperator, s.beliefEvidence)) + m.Handle("POST /api/v1/minds/{mind}/beliefs/{id}/alternatives", require(auth.RoleOperator, s.beliefAlternative)) + m.Handle("POST /api/v1/minds/{mind}/beliefs/{id}/retract", require(auth.RoleOperator, s.beliefRetract)) + m.Handle("GET /api/v1/minds/{mind}/corrections", require(auth.RoleObserver, s.correctionsList)) + m.Handle("POST /api/v1/minds/{mind}/corrections", require(auth.RoleOperator, s.correctionsCreate)) +} + +// Emitter returns a beliefs.Emitter that appends through the event store and +// publishes after the surrounding transaction (or immediately on the pool). +func (s *Server) Emitter() beliefs.Emitter { + return func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) { + out, err := s.deps.Events.AppendIn(ctx, q, n) + if err != nil { + return events.Event{}, err + } + s.deps.Events.Publish(out...) + return out[0], nil + } +} + +func beliefErr(err error) error { + if errors.Is(err, beliefs.ErrInvalid) || errors.Is(err, beliefs.ErrBlocked) { + return ValidationError(err.Error()) + } + return err +} + +// ParseBeliefFilter reads the shared query vocabulary. +func ParseBeliefFilter(q map[string][]string) beliefs.Filter { + get := func(k string) string { + if v := q[k]; len(v) > 0 { + return v[0] + } + return "" + } + f := beliefs.Filter{Search: get("q"), Subject: get("subject")} + for _, st := range strings.Split(get("status"), ",") { + if st = strings.TrimSpace(st); st != "" { + f.Statuses = append(f.Statuses, beliefs.Status(st)) + } + } + f.MinConfidence, _ = strconv.ParseFloat(get("min_confidence"), 64) + f.MaxConfidence, _ = strconv.ParseFloat(get("max_confidence"), 64) + f.Limit, _ = strconv.Atoi(get("limit")) + return f +} + +func (s *Server) beliefsList(w http.ResponseWriter, r *http.Request) { + m, ok := s.mind(w, r) + if !ok { + return + } + list, err := s.deps.Beliefs.List(r.Context(), m.ID, ParseBeliefFilter(r.URL.Query())) + if err != nil { + fail(w, r, err) + return + } + if list == nil { + list = []beliefs.Belief{} + } + writeJSON(w, http.StatusOK, page[beliefs.Belief]{Items: list}) +} + +// beliefsCreate records an operator-asserted belief. The operator's statement +// is the evidence; confidence is still computed, not typed in. +func (s *Server) beliefsCreate(w http.ResponseWriter, r *http.Request) { + m, ok := s.mind(w, r) + if !ok { + return + } + var in struct { + Proposition string `json:"proposition"` + Subject string `json:"subject"` + Epistemic concepts.Epistemic `json:"epistemic"` + Weight float64 `json:"weight"` // how firmly the operator asserts it + Note string `json:"note"` + Alternatives []string `json:"alternatives"` + } + if err := decode(r, &in); err != nil { + fail(w, r, err) + return + } + if in.Epistemic == "" { + in.Epistemic = concepts.Claim + } + if in.Weight <= 0 { + in.Weight = 0.7 + } + res, err := s.deps.Beliefs.Create(r.Context(), s.deps.DB, s.Emitter(), beliefs.New{MindID: m.ID, Proposition: in.Proposition, Subject: in.Subject, Epistemic: in.Epistemic, Source: "operator", + Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "operator", Source: "operator:" + actor(r), Weight: in.Weight, Credibility: events.TrustOperator.Credibility(), Note: firstNonEmpty(in.Note, "asserted by operator "+actor(r))}}, + Alternatives: in.Alternatives}) + if err != nil { + fail(w, r, beliefErr(err)) + return + } + s.deps.Audit.Log(r.Context(), "belief.create", "belief", res.Belief.ID.String(), map[string]any{"mind": m.Slug, "proposition": res.Belief.Proposition}) + writeJSON(w, http.StatusCreated, res) +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} + +// 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"` +} + +// BeliefDetail assembles the detail; shared with the UI. +func (s *Server) BeliefDetail(r *http.Request, mind, id ids.ID) (BeliefDetail, error) { + ctx := r.Context() + b, err := s.deps.Beliefs.Get(ctx, s.deps.DB, mind, id) + if err != nil { + return BeliefDetail{}, err + } + d := BeliefDetail{Belief: b} + d.Evidence, _ = s.deps.Beliefs.EvidenceFor(ctx, s.deps.DB, mind, id) + var ev []beliefs.Evidence + for _, e := range d.Evidence { + ev = append(ev, e.Evidence) + } + d.Breakdown = beliefs.Compute(b.Prior, ev) + 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) + 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 { + d.Origin = &e + } + } + if d.Evidence == nil { + d.Evidence = []beliefs.EvidenceRow{} + } + if d.Versions == nil { + d.Versions = []beliefs.Version{} + } + if d.Alternatives == nil { + d.Alternatives = []beliefs.Alternative{} + } + if d.Links == nil { + d.Links = []beliefs.Link{} + } + if d.Events == nil { + d.Events = []events.Event{} + } + return d, nil +} + +func (s *Server) beliefGet(w http.ResponseWriter, r *http.Request) { + m, ok := s.mind(w, r) + if !ok { + return + } + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + fail(w, r, ValidationError("bad id")) + return + } + d, err := s.BeliefDetail(r, m.ID, id) + if err != nil { + fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, d) +} + +func (s *Server) beliefsStats(w http.ResponseWriter, r *http.Request) { + m, ok := s.mind(w, r) + if !ok { + return + } + st, err := s.deps.Beliefs.Stats(r.Context(), m.ID) + if err != nil { + fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, st) +} + +func (s *Server) beliefEvidence(w http.ResponseWriter, r *http.Request) { + m, ok := s.mind(w, r) + if !ok { + return + } + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + fail(w, r, ValidationError("bad id")) + return + } + var in struct { + Direction string `json:"direction"` + Kind string `json:"kind"` + RefID string `json:"ref_id"` + Weight float64 `json:"weight"` + Note string `json:"note"` + } + if err := decode(r, &in); err != nil { + fail(w, r, err) + return + } + if in.Kind == "" { + in.Kind = "operator" + } + res, err := s.deps.Beliefs.AddEvidence(r.Context(), s.deps.DB, s.Emitter(), m.ID, id, beliefs.EvidenceInput{Direction: in.Direction, Kind: in.Kind, RefID: in.RefID, Source: "operator:" + actor(r), Weight: in.Weight, Credibility: events.TrustOperator.Credibility(), Note: in.Note}, "operator evidence: "+in.Note, ids.Nil, ids.Nil) + if err != nil { + fail(w, r, beliefErr(err)) + return + } + s.deps.Audit.Log(r.Context(), "belief.evidence", "belief", id.String(), map[string]any{"direction": in.Direction, "note": in.Note}) + writeJSON(w, http.StatusOK, res) +} + +func (s *Server) beliefAlternative(w http.ResponseWriter, r *http.Request) { + m, ok := s.mind(w, r) + if !ok { + return + } + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + fail(w, r, ValidationError("bad id")) + return + } + var in struct { + Proposition string `json:"proposition"` + Note string `json:"note"` + } + if err := decode(r, &in); err != nil { + fail(w, r, err) + return + } + a, err := s.deps.Beliefs.AddAlternative(r.Context(), m.ID, id, in.Proposition, in.Note) + if err != nil { + fail(w, r, beliefErr(err)) + return + } + s.deps.Audit.Log(r.Context(), "belief.alternative", "belief", id.String(), map[string]any{"proposition": in.Proposition}) + writeJSON(w, http.StatusCreated, a) +} + +func (s *Server) beliefRetract(w http.ResponseWriter, r *http.Request) { + m, ok := s.mind(w, r) + if !ok { + return + } + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + fail(w, r, ValidationError("bad id")) + return + } + var in struct { + Reason string `json:"reason"` + DoNotInfer bool `json:"do_not_infer"` + } + if r.ContentLength != 0 { + if err := decode(r, &in); err != nil { + fail(w, r, err) + return + } + } + res, err := s.deps.Beliefs.Retract(r.Context(), s.Emitter(), m.ID, id, in.Reason, actor(r), in.DoNotInfer) + if err != nil { + fail(w, r, beliefErr(err)) + return + } + s.deps.Audit.Log(r.Context(), "belief.retract", "belief", id.String(), map[string]any{"reason": in.Reason, "do_not_infer": in.DoNotInfer}) + writeJSON(w, http.StatusOK, res) +} + +func (s *Server) correctionsList(w http.ResponseWriter, r *http.Request) { + m, ok := s.mind(w, r) + if !ok { + return + } + list, err := s.deps.Beliefs.Corrections(r.Context(), m.ID, r.URL.Query().Get("kind")) + if err != nil { + fail(w, r, err) + return + } + if list == nil { + list = []beliefs.Correction{} + } + writeJSON(w, http.StatusOK, page[beliefs.Correction]{Items: list}) +} + +// correctionsCreate records "do not infer this again" and similar statements. +func (s *Server) correctionsCreate(w http.ResponseWriter, r *http.Request) { + m, ok := s.mind(w, r) + if !ok { + return + } + var in struct { + Kind string `json:"kind"` + Statement string `json:"statement"` + Reason string `json:"reason"` + } + if err := decode(r, &in); err != nil { + fail(w, r, err) + return + } + if in.Kind != "do_not_infer" || strings.TrimSpace(in.Statement) == "" { + fail(w, r, ValidationError("kind must be do_not_infer with a statement (entity corrections arrive with the relationship graph)")) + return + } + evs, err := s.deps.Events.Append(r.Context(), events.New{MindID: m.ID, Type: events.OperatorAction, Source: "operator", Summary: "Operator " + actor(r) + " forbade inference: " + in.Statement, + Salience: 0.6, Confidence: 1, Metadata: map[string]any{"action": "correction.do_not_infer", "actor": actor(r)}, Payload: map[string]any{"statement": in.Statement, "reason": in.Reason}}) + if err != nil { + fail(w, r, err) + return + } + c, err := s.deps.Beliefs.AddCorrection(r.Context(), s.deps.DB, m.ID, beliefs.Correction{Kind: in.Kind, Statement: in.Statement, Reason: in.Reason, CreatedBy: actor(r), EventID: evs[0].ID}) + if err != nil { + fail(w, r, err) + return + } + s.deps.Audit.Log(r.Context(), "correction.create", "mind", m.ID.String(), map[string]any{"kind": in.Kind, "statement": in.Statement}) + writeJSON(w, http.StatusCreated, c) +} diff --git a/internal/api/memory.go b/internal/api/memory.go index 96caca6..57aedbd 100644 --- a/internal/api/memory.go +++ b/internal/api/memory.go @@ -232,6 +232,7 @@ func (s *Server) memoryRetract(w http.ResponseWriter, r *http.Request) { } s.deps.Events.OperatorAction(r.Context(), m.ID, actor(r), "memory.retract", "Operator "+actor(r)+" marked a memory inaccurate: "+in.Reason, map[string]any{"memory_id": id, "reason": in.Reason}) s.deps.Audit.Log(r.Context(), "memory.retract", "memory", id.String(), map[string]any{"reason": in.Reason}) + s.propagateMemoryRetraction(r, m.ID, id) w.WriteHeader(http.StatusNoContent) } @@ -272,3 +273,18 @@ func trunc(s string, n int) string { } return s } + +// propagateMemoryRetraction stops a retracted memory counting as belief +// evidence and re-evaluates the beliefs that cited it. +func (s *Server) propagateMemoryRetraction(r *http.Request, mind, memoryID ids.ID) { + affected, err := s.deps.Beliefs.RetractEvidence(r.Context(), s.deps.DB, mind, "memory", string(memoryID)) + if err != nil { + logger(r).Warn("retract evidence failed", "err", err) + return + } + for _, bid := range affected { + if _, err := s.deps.Beliefs.Reevaluate(r.Context(), s.deps.DB, s.Emitter(), mind, bid, "supporting memory retracted by operator"); err != nil { + logger(r).Warn("re-evaluate failed", "belief", bid, "err", err) + } + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 457d949..9609e58 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -55,6 +55,7 @@ func (s *Server) routes() { s.eventRoutes() s.workspaceRoutes() s.memoryRoutes() + s.beliefRoutes() m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint") diff --git a/internal/app/app.go b/internal/app/app.go index 0184562..5cad1be 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -12,6 +12,7 @@ import ( "github.com/andydixon/noema/internal/audit" "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/beliefs" "github.com/andydixon/noema/internal/brain" "github.com/andydixon/noema/internal/config" "github.com/andydixon/noema/internal/events" @@ -32,6 +33,7 @@ type Deps struct { Events *events.Store Brain *brain.Manager Memory *memory.Store + Beliefs *beliefs.Store Migrations fs.FS Started time.Time } diff --git a/internal/beliefs/confidence.go b/internal/beliefs/confidence.go new file mode 100644 index 0000000..5f10fda --- /dev/null +++ b/internal/beliefs/confidence.go @@ -0,0 +1,253 @@ +// Package beliefs manages propositions held with explicit, evidence-derived +// confidence. Confidence is arithmetic over evidence weights and source +// credibility; no model ever sets it directly (spec §10, §62). +package beliefs + +import ( + "math" + "sort" + + "github.com/andydixon/noema/pkg/concepts" +) + +// Status of a belief. +type Status string + +// Statuses in rough order of commitment. +const ( + StatusHypothesis Status = "hypothesis" + StatusTentative Status = "tentative" + StatusLikely Status = "likely" + StatusAccepted Status = "accepted" + StatusContested Status = "contested" + StatusRejected Status = "rejected" + StatusSuperseded Status = "superseded" +) + +// Valid reports whether s is known. +func (s Status) Valid() bool { + switch s { + case StatusHypothesis, StatusTentative, StatusLikely, StatusAccepted, StatusContested, StatusRejected, StatusSuperseded: + return true + } + return false +} + +// Slope converts net evidence into log-odds. 1.5 means one unit of fully +// credible evidence moves 0.5 to about 0.82. +const Slope = 1.5 + +// Bounds keep beliefs away from certainty unless an operator pins a fact. +const ( + MinConfidence = 0.02 + MaxConfidence = 0.98 +) + +// PriorFor returns the starting confidence for an epistemic kind. +func PriorFor(e concepts.Epistemic) float64 { + switch e { + case concepts.Hypothesis, concepts.Inference: + return 0.25 + case concepts.Assumption, concepts.Opinion: + return 0.4 + case concepts.Claim: + return 0.5 + case concepts.Observation, concepts.Memory: + return 0.6 + case concepts.Fact: + return 0.8 + default: + return 0.5 + } +} + +// Evidence is one piece of support or opposition. +type Evidence struct { + Direction string `json:"direction"` // for | against + Kind string `json:"kind"` + RefID string `json:"ref_id"` + Source string `json:"source"` + Weight float64 `json:"weight"` // how strongly it bears on the proposition, (0,1] + Credibility float64 `json:"credibility"` // trust in the source, [0,1] + Retracted bool `json:"retracted"` +} + +// Breakdown explains a confidence value. +type Breakdown struct { + Prior float64 `json:"prior"` + For float64 `json:"for"` // Σ weight×credibility, supporting + Against float64 `json:"against"` // Σ weight×credibility, opposing + SourcesFor int `json:"sources_for"` // distinct sources supporting + DiversityScale float64 `json:"diversity_scale"` // discount when support comes from one source + LogOdds float64 `json:"log_odds"` + Confidence float64 `json:"confidence"` + Formula string `json:"formula"` + Notes []string `json:"notes,omitempty"` +} + +// Compute derives confidence from prior and evidence. Support from a single +// source is discounted (T4: poisoned memory) so repetition alone cannot make +// a belief strong. +func Compute(prior float64, ev []Evidence) Breakdown { + b := Breakdown{Prior: clampP(prior), Formula: "confidence = sigmoid(logit(prior) + slope × (for × diversity − against))"} + sources := map[string]struct{}{} + for _, e := range ev { + if e.Retracted || e.Weight <= 0 { + continue + } + v := clamp01(e.Weight) * clamp01(e.Credibility) + if e.Direction == "against" { + b.Against += v + continue + } + b.For += v + key := e.Source + if key == "" { + key = e.Kind + ":" + e.RefID + } + sources[key] = struct{}{} + } + b.SourcesFor = len(sources) + // One source: 60% of face value; three or more independent sources: full. + b.DiversityScale = 1 + if b.SourcesFor > 0 { + b.DiversityScale = round4(0.6 + 0.4*math.Min(1, float64(b.SourcesFor-1)/2)) + } + if b.SourcesFor == 1 && b.For > 0 { + b.Notes = append(b.Notes, "all support comes from a single source") + } + logit := math.Log(b.Prior / (1 - b.Prior)) + b.LogOdds = logit + Slope*(b.For*b.DiversityScale-b.Against) + b.Confidence = clampP(1 / (1 + math.Exp(-b.LogOdds))) + b.For, b.Against, b.LogOdds, b.Confidence = round4(b.For), round4(b.Against), round4(b.LogOdds), round4(b.Confidence) + return b +} + +// Derive maps a breakdown to a status. Contested requires real evidence on +// both sides; rejected requires the evidence to have pushed confidence low. +func Derive(b Breakdown, epistemic concepts.Epistemic) Status { + if b.For > 0 && b.Against > 0 && b.Against >= 0.5*b.For*b.DiversityScale && b.Confidence >= 0.2 && b.Confidence <= 0.8 { + return StatusContested + } + switch { + case b.Confidence < 0.15: + return StatusRejected + case b.For == 0 && epistemic == concepts.Hypothesis: + return StatusHypothesis + case b.Confidence < 0.5: + if epistemic == concepts.Hypothesis { + return StatusHypothesis + } + return StatusTentative + case b.Confidence < 0.7: + return StatusTentative + case b.Confidence < 0.85: + return StatusLikely + default: + return StatusAccepted + } +} + +func clampP(v float64) float64 { + if math.IsNaN(v) || v < MinConfidence { + return MinConfidence + } + if v > MaxConfidence { + return MaxConfidence + } + return v +} + +func clamp01(v float64) float64 { + if math.IsNaN(v) || v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +func round4(v float64) float64 { return math.Round(v*10000) / 10000 } + +// negations are the words that flip a proposition's polarity for the +// deterministic contradiction check. +var negations = map[string]bool{"not": true, "no": true, "never": true, "isn't": true, "isnt": true, "doesn't": true, "doesnt": true, "don't": true, "dont": true, "cannot": true, "can't": true, "cant": true, "wasn't": true, "didn't": true, "won't": true, "unlikely": true, "false": true} + +// Polarity splits a proposition into its content tokens and whether it is negated. +func Polarity(text string) (tokens []string, negated bool) { + for _, w := range tokenise(text) { + if negations[w] { + negated = !negated + continue + } + tokens = append(tokens, w) + } + sort.Strings(tokens) + return tokens, negated +} + +// Jaccard similarity of two token lists. +func Jaccard(a, b []string) float64 { + if len(a) == 0 || len(b) == 0 { + return 0 + } + set := map[string]bool{} + for _, w := range a { + set[w] = true + } + inter := 0 + seenB := map[string]bool{} + for _, w := range b { + if seenB[w] { + continue + } + seenB[w] = true + if set[w] { + inter++ + } + } + union := len(set) + len(seenB) - inter + return float64(inter) / float64(union) +} + +// Contradicts reports whether two propositions are the same content with +// opposite polarity (deterministic first pass; paraphrase judgement can be +// added by an LLM-assisted process later). +func Contradicts(a, b string) (bool, float64) { + ta, na := Polarity(a) + tb, nb := Polarity(b) + sim := Jaccard(ta, tb) + if na != nb && sim >= 0.6 { + return true, round4(sim) + } + return false, round4(sim) +} + +var stop = map[string]bool{"the": true, "a": true, "an": true, "is": true, "are": true, "was": true, "were": true, "be": true, "been": true, "of": true, "to": true, "in": true, "on": true, "at": true, "for": true, "and": true, "or": true, "that": true, "this": true, "it": true, "has": true, "have": true, "had": true, "with": true, "by": true, "as": true, "may": true, "might": true, "probably": true, "likely": true} + +func tokenise(text string) []string { + var out []string + var cur []rune + flush := func() { + if len(cur) >= 2 { + w := string(cur) + if !stop[w] { + out = append(out, w) + } + } + cur = cur[:0] + } + for _, r := range text { + switch { + case r >= 'A' && r <= 'Z': + cur = append(cur, r+32) + case r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '\'' || r == '-' || r == '_' || r == '.': + cur = append(cur, r) + default: + flush() + } + } + flush() + return out +} diff --git a/internal/beliefs/confidence_test.go b/internal/beliefs/confidence_test.go new file mode 100644 index 0000000..ea206dc --- /dev/null +++ b/internal/beliefs/confidence_test.go @@ -0,0 +1,69 @@ +package beliefs + +import ( + "testing" + + "github.com/andydixon/noema/pkg/concepts" +) + +func TestComputeIsMonotonicBoundedAndDiscountsSingleSource(t *testing.T) { + none := Compute(0.5, nil) + if none.Confidence != 0.5 { + t.Fatalf("no evidence should keep the prior, got %v", none.Confidence) + } + one := Compute(0.5, []Evidence{{Direction: "for", Kind: "event", RefID: "a", Source: "metrics", Weight: 0.8, Credibility: 0.9}}) + two := Compute(0.5, []Evidence{{Direction: "for", Kind: "event", RefID: "a", Source: "metrics", Weight: 0.8, Credibility: 0.9}, {Direction: "for", Kind: "event", RefID: "b", Source: "metrics", Weight: 0.8, Credibility: 0.9}}) + diverse := Compute(0.5, []Evidence{{Direction: "for", Kind: "event", RefID: "a", Source: "metrics", Weight: 0.8, Credibility: 0.9}, {Direction: "for", Kind: "event", RefID: "b", Source: "logs", Weight: 0.8, Credibility: 0.9}, {Direction: "for", Kind: "event", RefID: "c", Source: "operator", Weight: 0.8, Credibility: 0.9}}) + if !(none.Confidence < one.Confidence && one.Confidence < two.Confidence && two.Confidence < diverse.Confidence) { + t.Fatalf("not monotonic: %v %v %v %v", none.Confidence, one.Confidence, two.Confidence, diverse.Confidence) + } + if one.DiversityScale != 0.6 || len(one.Notes) == 0 || diverse.DiversityScale != 1 { + t.Fatalf("diversity discount wrong: %+v / %+v", one, diverse) + } + huge := make([]Evidence, 50) + for i := range huge { + huge[i] = Evidence{Direction: "for", Kind: "event", RefID: string(rune('a' + i)), Source: "s" + string(rune('a'+i)), Weight: 1, Credibility: 1} + } + if Compute(0.5, huge).Confidence > MaxConfidence { + t.Fatal("confidence exceeded bound") + } + against := Compute(0.5, []Evidence{{Direction: "against", Kind: "event", RefID: "x", Weight: 1, Credibility: 1}}) + if against.Confidence >= 0.5 { + t.Fatal("opposing evidence should lower confidence") + } + retracted := Compute(0.5, []Evidence{{Direction: "for", Kind: "event", RefID: "a", Weight: 1, Credibility: 1, Retracted: true}}) + if retracted.Confidence != 0.5 { + t.Fatal("retracted evidence must not count") + } +} + +func TestDeriveStatuses(t *testing.T) { + cases := []struct { + b Breakdown + e concepts.Epistemic + want Status + }{ + {Compute(PriorFor(concepts.Hypothesis), nil), concepts.Hypothesis, StatusHypothesis}, + {Compute(0.5, []Evidence{{Direction: "for", Source: "a", Weight: 0.5, Credibility: 0.9}}), concepts.Claim, StatusTentative}, + {Compute(0.5, []Evidence{{Direction: "for", Source: "a", Weight: 1, Credibility: 1}, {Direction: "for", Source: "b", Weight: 1, Credibility: 1}, {Direction: "for", Source: "c", Weight: 1, Credibility: 1}}), concepts.Claim, StatusAccepted}, + {Compute(0.5, []Evidence{{Direction: "for", Source: "a", Weight: 0.8, Credibility: 0.9}, {Direction: "for", Source: "b", Weight: 0.8, Credibility: 0.9}, {Direction: "against", Source: "c", Weight: 0.8, Credibility: 0.9}}), concepts.Claim, StatusContested}, + {Compute(0.5, []Evidence{{Direction: "against", Source: "a", Weight: 1, Credibility: 1}, {Direction: "against", Source: "b", Weight: 1, Credibility: 1}}), concepts.Claim, StatusRejected}, + } + for i, c := range cases { + if got := Derive(c.b, c.e); got != c.want { + t.Errorf("case %d: got %s want %s (conf %v for %v against %v)", i, got, c.want, c.b.Confidence, c.b.For, c.b.Against) + } + } +} + +func TestContradicts(t *testing.T) { + if ok, _ := Contradicts("server-01 has an intermittent storage problem", "server-01 does not have an intermittent storage problem"); !ok { + t.Fatal("negated paraphrase not detected") + } + if ok, _ := Contradicts("server-01 has a storage problem", "server-02 has a network problem"); ok { + t.Fatal("unrelated propositions flagged") + } + if ok, _ := Contradicts("atlas is slow", "atlas is slow"); ok { + t.Fatal("identical propositions flagged") + } +} diff --git a/internal/beliefs/store.go b/internal/beliefs/store.go new file mode 100644 index 0000000..f80bbdd --- /dev/null +++ b/internal/beliefs/store.go @@ -0,0 +1,789 @@ +package beliefs + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/pkg/concepts" + "github.com/andydixon/noema/pkg/events" + "github.com/andydixon/noema/pkg/ids" +) + +// Belief is a proposition with derived confidence and status. +type Belief struct { + ID ids.ID `json:"id"` + MindID ids.ID `json:"mind_id"` + Proposition string `json:"proposition"` + Subject string `json:"subject"` + Epistemic concepts.Epistemic `json:"epistemic"` + Status Status `json:"status"` + Confidence float64 `json:"confidence"` + Prior float64 `json:"prior"` + Version int `json:"version"` + Source string `json:"source"` + Detail map[string]any `json:"detail"` + OriginEvent ids.ID `json:"origin_event,omitempty"` + SupersededBy ids.ID `json:"superseded_by,omitempty"` + PinnedStatus *Status `json:"pinned_status,omitempty"` + FirstCreated time.Time `json:"first_created"` + LastEvaluated time.Time `json:"last_evaluated"` + Evaluations int `json:"evaluations"` +} + +// EvidenceRow is stored evidence. +type EvidenceRow struct { + ID ids.ID `json:"id"` + Evidence `json:",inline"` + Note string `json:"note"` + EventID ids.ID `json:"event_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// Alternative is a competing explanation kept alongside a belief. +type Alternative struct { + ID ids.ID `json:"id"` + Proposition string `json:"proposition"` + Confidence float64 `json:"confidence"` + Note string `json:"note"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +// Version is one entry in the append-only history. +type Version struct { + Version int `json:"version"` + Status Status `json:"status"` + Confidence float64 `json:"confidence"` + Proposition string `json:"proposition"` + Reason string `json:"reason"` + Breakdown Breakdown `json:"breakdown"` + EventID ids.ID `json:"event_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// Link relates two beliefs. +type Link struct { + ID ids.ID `json:"id"` + FromID ids.ID `json:"from_id"` + ToID ids.ID `json:"to_id"` + Relation string `json:"relation"` + Confidence float64 `json:"confidence"` + Other *Belief `json:"other,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// Correction is an operator statement about the mind's content. +type Correction struct { + ID ids.ID `json:"id"` + Kind string `json:"kind"` + TargetKind string `json:"target_kind"` + TargetID string `json:"target_id"` + Statement string `json:"statement"` + Reason string `json:"reason"` + CreatedBy string `json:"created_by"` + EventID ids.ID `json:"event_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ErrInvalid marks bad input; ErrBlocked means a do-not-infer correction applies. +var ( + ErrInvalid = errors.New("invalid belief") + ErrBlocked = errors.New("blocked by operator correction") +) + +// Emitter appends events inside the caller's transaction and returns them. +// The brain cycle and the API each provide one so provenance events are +// written atomically with belief rows. +type Emitter func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) + +// Store persists beliefs. +type Store struct { + db *pgxpool.Pool +} + +// NewStore creates a store. +func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} } + +// New describes a belief to create. +type New struct { + MindID ids.ID + Proposition string + Subject string + Epistemic concepts.Epistemic + Source string + Evidence []EvidenceInput + Alternatives []string + Detail map[string]any + OriginEvent ids.ID + Correlation ids.ID + Causation ids.ID +} + +// EvidenceInput is evidence supplied by callers. +type EvidenceInput struct { + Direction string + Kind string + RefID string + Source string + Weight float64 + Credibility float64 + Note string +} + +// Result is a belief with its computed breakdown and the events written. +type Result struct { + Belief Belief + Breakdown Breakdown + Events []events.Event +} + +// Create stores a belief, its evidence, alternatives and first version, emits +// belief_created/hypothesis_created, and runs contradiction detection. +func (s *Store) Create(ctx context.Context, q storage.Querier, emit Emitter, n New) (Result, error) { + n.Proposition = strings.TrimSpace(n.Proposition) + if n.MindID.IsZero() || n.Proposition == "" || len(n.Proposition) > 2000 { + return Result{}, fmt.Errorf("%w: proposition required (max 2000 chars)", ErrInvalid) + } + if !n.Epistemic.Valid() { + n.Epistemic = concepts.Hypothesis + } + if blocked, c := s.blocked(ctx, q, n.MindID, n.Proposition); blocked { + return Result{}, fmt.Errorf("%w: %s", ErrBlocked, c.Statement) + } + var ev []Evidence + for _, e := range n.Evidence { + ev = append(ev, normalise(e)) + } + prior := PriorFor(n.Epistemic) + bd := Compute(prior, ev) + st := Derive(bd, n.Epistemic) + b := Belief{ID: ids.New(), MindID: n.MindID, Proposition: n.Proposition, Subject: strings.TrimSpace(n.Subject), Epistemic: n.Epistemic, Status: st, Confidence: bd.Confidence, + Prior: prior, Version: 1, Source: n.Source, Detail: n.Detail, OriginEvent: n.OriginEvent, FirstCreated: time.Now(), LastEvaluated: time.Now(), Evaluations: 1} + if b.Detail == nil { + b.Detail = map[string]any{} + } + detail, _ := json.Marshal(b.Detail) + if _, err := q.Exec(ctx, `INSERT INTO beliefs (id, mind_id, proposition, subject, epistemic, status, confidence, prior, version, source, detail, origin_event) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,1,$9,$10,$11)`, b.ID, b.MindID, b.Proposition, b.Subject, string(b.Epistemic), string(b.Status), b.Confidence, b.Prior, b.Source, detail, nullID(b.OriginEvent)); err != nil { + return Result{}, err + } + typ := events.BeliefCreated + if n.Epistemic == concepts.Hypothesis { + typ = events.HypothesisCreated + } + refs := []events.EvidenceRef{{Kind: "belief", ID: string(b.ID)}} + for _, e := range n.Evidence { + if e.RefID != "" { + refs = append(refs, events.EvidenceRef{Kind: e.Kind, ID: e.RefID, Note: e.Direction + ": " + e.Note}) + } + } + created, err := emit(ctx, q, events.New{MindID: b.MindID, Type: typ, Source: n.Source, Summary: fmt.Sprintf("%s (%.2f, %s): %s", typ, b.Confidence, b.Status, b.Proposition), + Salience: 0.5 + 0.3*b.Confidence, Confidence: b.Confidence, CausationID: n.Causation, CorrelationID: n.Correlation, Evidence: refs, + Metadata: map[string]any{"belief_id": string(b.ID), "status": b.Status, "subject": b.Subject}, Payload: map[string]any{"belief_id": b.ID, "proposition": b.Proposition, "status": b.Status, "confidence": b.Confidence, "breakdown": bd, "alternatives": n.Alternatives}}) + if err != nil { + return Result{}, err + } + res := Result{Belief: b, Breakdown: bd, Events: []events.Event{created}} + for _, e := range n.Evidence { + if err := s.insertEvidence(ctx, q, b, normalise(e), e.Note, created.ID); err != nil { + return Result{}, err + } + } + for _, alt := range n.Alternatives { + if alt = strings.TrimSpace(alt); alt != "" { + if _, err := q.Exec(ctx, `INSERT INTO belief_alternatives (id, belief_id, mind_id, proposition, confidence) VALUES ($1,$2,$3,$4,$5)`, ids.New(), b.ID, b.MindID, alt, 0.2); err != nil { + return Result{}, err + } + } + } + if err := s.insertVersion(ctx, q, b, bd, "created", created.ID); err != nil { + return Result{}, err + } + contra, err := s.detectContradictions(ctx, q, emit, b, created.ID) + if err != nil { + return Result{}, err + } + res.Events = append(res.Events, contra...) + return res, nil +} + +func normalise(e EvidenceInput) Evidence { + if e.Direction != "against" { + e.Direction = "for" + } + if e.Weight <= 0 || e.Weight > 1 { + e.Weight = 0.5 + } + if e.Credibility <= 0 || e.Credibility > 1 { + e.Credibility = 0.5 + } + if e.Kind == "" { + e.Kind = "note" + } + return Evidence{Direction: e.Direction, Kind: e.Kind, RefID: e.RefID, Source: e.Source, Weight: e.Weight, Credibility: e.Credibility} +} + +func (s *Store) insertEvidence(ctx context.Context, q storage.Querier, b Belief, e Evidence, note string, eventID ids.ID) error { + _, err := q.Exec(ctx, `INSERT INTO belief_evidence (id, belief_id, mind_id, direction, kind, ref_id, source, weight, credibility, note, event_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, + ids.New(), b.ID, b.MindID, e.Direction, e.Kind, e.RefID, e.Source, e.Weight, e.Credibility, note, nullID(eventID)) + return err +} + +func (s *Store) insertVersion(ctx context.Context, q storage.Querier, b Belief, bd Breakdown, reason string, eventID ids.ID) error { + raw, _ := json.Marshal(bd) + _, err := q.Exec(ctx, `INSERT INTO belief_versions (id, belief_id, mind_id, version, status, confidence, proposition, reason, breakdown, event_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + ids.New(), b.ID, b.MindID, b.Version, string(b.Status), b.Confidence, b.Proposition, reason, raw, nullID(eventID)) + return err +} + +func nullID(id ids.ID) *string { + if id.IsZero() { + return nil + } + s := string(id) + return &s +} + +const cols = `b.id, b.mind_id, b.proposition, b.subject, b.epistemic, b.status, b.confidence, b.prior, b.version, b.source, b.detail, b.origin_event, b.superseded_by, b.pinned_status, b.first_created, b.last_evaluated, b.evaluations` + +func scan(row pgx.Row) (Belief, error) { + var b Belief + var epi, status string + var detail []byte + var origin, superseded, pinned *string + if err := row.Scan(&b.ID, &b.MindID, &b.Proposition, &b.Subject, &epi, &status, &b.Confidence, &b.Prior, &b.Version, &b.Source, &detail, &origin, &superseded, &pinned, &b.FirstCreated, &b.LastEvaluated, &b.Evaluations); err != nil { + return b, storage.Translate(err) + } + b.Epistemic, b.Status = concepts.Epistemic(epi), Status(status) + json.Unmarshal(detail, &b.Detail) //nolint:errcheck + if b.Detail == nil { + b.Detail = map[string]any{} + } + if origin != nil { + b.OriginEvent = ids.ID(*origin) + } + if superseded != nil { + b.SupersededBy = ids.ID(*superseded) + } + if pinned != nil { + st := Status(*pinned) + b.PinnedStatus = &st + } + return b, nil +} + +// Get fetches a belief scoped to a mind. +func (s *Store) Get(ctx context.Context, q storage.Querier, mind, id ids.ID) (Belief, error) { + return scan(q.QueryRow(ctx, `SELECT `+cols+` FROM beliefs b WHERE b.id=$1 AND b.mind_id=$2`, id, mind)) +} + +// Filter selects beliefs. +type Filter struct { + Statuses []Status + MinConfidence float64 + MaxConfidence float64 + Subject string + Search string + Limit int +} + +// List returns beliefs by confidence descending. +func (s *Store) List(ctx context.Context, mind ids.ID, f Filter) ([]Belief, error) { + where := []string{"b.mind_id = $1"} + args := []any{mind} + arg := func(v any) string { args = append(args, v); return fmt.Sprintf("$%d", len(args)) } + if len(f.Statuses) > 0 { + ss := make([]string, len(f.Statuses)) + for i, st := range f.Statuses { + ss[i] = string(st) + } + where = append(where, "b.status = ANY("+arg(ss)+")") + } else { + where = append(where, "b.status <> 'superseded'") + } + if f.MinConfidence > 0 { + where = append(where, "b.confidence >= "+arg(f.MinConfidence)) + } + if f.MaxConfidence > 0 { + where = append(where, "b.confidence <= "+arg(f.MaxConfidence)) + } + if f.Subject != "" { + where = append(where, "b.subject = "+arg(f.Subject)) + } + if q := strings.TrimSpace(f.Search); q != "" { + where = append(where, "b.search @@ websearch_to_tsquery('english', "+arg(q)+")") + } + limit := f.Limit + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := s.db.Query(ctx, `SELECT `+cols+` FROM beliefs b WHERE `+strings.Join(where, " AND ")+` ORDER BY b.confidence DESC, b.last_evaluated DESC LIMIT `+arg(limit), args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Belief + for rows.Next() { + b, err := scan(rows) + if err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +// EvidenceFor returns all evidence rows of a belief. +func (s *Store) EvidenceFor(ctx context.Context, q storage.Querier, mind, id ids.ID) ([]EvidenceRow, error) { + rows, err := q.Query(ctx, `SELECT id, direction, kind, ref_id, source, weight, credibility, retracted, note, event_id, created_at FROM belief_evidence WHERE belief_id=$1 AND mind_id=$2 ORDER BY created_at`, id, mind) + if err != nil { + return nil, err + } + defer rows.Close() + var out []EvidenceRow + for rows.Next() { + var r EvidenceRow + var evID *string + if err := rows.Scan(&r.ID, &r.Direction, &r.Kind, &r.RefID, &r.Source, &r.Weight, &r.Credibility, &r.Retracted, &r.Note, &evID, &r.CreatedAt); err != nil { + return nil, err + } + if evID != nil { + r.EventID = ids.ID(*evID) + } + out = append(out, r) + } + return out, rows.Err() +} + +// AddEvidence attaches evidence, recomputes confidence and status, appends a +// version when anything changed and emits the matching event. +func (s *Store) AddEvidence(ctx context.Context, q storage.Querier, emit Emitter, mind, id ids.ID, in EvidenceInput, reason string, causation, correlation ids.ID) (Result, error) { + b, err := s.Get(ctx, q, mind, id) + if err != nil { + return Result{}, err + } + if b.Status == StatusSuperseded { + return Result{}, fmt.Errorf("%w: belief is superseded", ErrInvalid) + } + e := normalise(in) + if err := s.insertEvidence(ctx, q, b, e, in.Note, ids.Nil); err != nil { + return Result{}, err + } + return s.reevaluate(ctx, q, emit, b, reason, causation, correlation, &e, in) +} + +// Reevaluate recomputes a belief from its stored evidence (used after +// retractions and corrections). +func (s *Store) Reevaluate(ctx context.Context, q storage.Querier, emit Emitter, mind, id ids.ID, reason string) (Result, error) { + b, err := s.Get(ctx, q, mind, id) + if err != nil { + return Result{}, err + } + return s.reevaluate(ctx, q, emit, b, reason, ids.Nil, ids.Nil, nil, EvidenceInput{}) +} + +func (s *Store) reevaluate(ctx context.Context, q storage.Querier, emit Emitter, b Belief, reason string, causation, correlation ids.ID, added *Evidence, in EvidenceInput) (Result, error) { + rows, err := s.EvidenceFor(ctx, q, b.MindID, b.ID) + if err != nil { + return Result{}, err + } + ev := make([]Evidence, 0, len(rows)) + for _, r := range rows { + ev = append(ev, r.Evidence) + } + bd := Compute(b.Prior, ev) + st := Derive(bd, b.Epistemic) + if b.PinnedStatus != nil { + st = *b.PinnedStatus + } + old := b + b.Confidence, b.Status, b.LastEvaluated, b.Evaluations = bd.Confidence, st, time.Now(), b.Evaluations+1 + changed := old.Confidence != b.Confidence || old.Status != b.Status + res := Result{Belief: b, Breakdown: bd} + typ := events.BeliefUpdated + summary := fmt.Sprintf("belief re-evaluated (%.2f → %.2f, %s): %s", old.Confidence, b.Confidence, b.Status, b.Proposition) + if added != nil { + if added.Direction == "for" { + typ = events.HypothesisSupported + if b.Epistemic != concepts.Hypothesis { + typ = events.BeliefUpdated + } + summary = fmt.Sprintf("support added (%.2f → %.2f): %s", old.Confidence, b.Confidence, b.Proposition) + } else { + typ = events.HypothesisWeakened + if b.Epistemic != concepts.Hypothesis { + typ = events.BeliefChallenged + } + summary = fmt.Sprintf("counter-evidence added (%.2f → %.2f): %s", old.Confidence, b.Confidence, b.Proposition) + } + } + refs := []events.EvidenceRef{{Kind: "belief", ID: string(b.ID)}} + if added != nil && added.RefID != "" { + refs = append(refs, events.EvidenceRef{Kind: added.Kind, ID: added.RefID, Note: added.Direction + ": " + in.Note}) + } + ev1, err := emit(ctx, q, events.New{MindID: b.MindID, Type: typ, Source: firstNonEmpty(in.Source, "beliefs"), Summary: summary, Salience: 0.3 + 0.4*absf(b.Confidence-old.Confidence)*4, Confidence: b.Confidence, + CausationID: causation, CorrelationID: correlation, Evidence: refs, + Metadata: map[string]any{"belief_id": string(b.ID), "status": b.Status, "from": old.Confidence, "to": b.Confidence}, + Payload: map[string]any{"belief_id": b.ID, "proposition": b.Proposition, "from": map[string]any{"confidence": old.Confidence, "status": old.Status}, "to": map[string]any{"confidence": b.Confidence, "status": b.Status}, "breakdown": bd, "reason": reason}}) + if err != nil { + return Result{}, err + } + res.Events = append(res.Events, ev1) + if changed { + b.Version++ + res.Belief = b + if err := s.insertVersion(ctx, q, b, bd, reason, ev1.ID); err != nil { + return Result{}, err + } + } + if _, err := q.Exec(ctx, `UPDATE beliefs SET confidence=$3, status=$4, version=$5, last_evaluated=now(), evaluations=evaluations+1 WHERE id=$1 AND mind_id=$2`, b.ID, b.MindID, b.Confidence, string(b.Status), b.Version); err != nil { + return Result{}, err + } + if changed && added != nil && added.Direction == "for" { + contra, err := s.detectContradictions(ctx, q, emit, b, ev1.ID) + if err != nil { + return Result{}, err + } + res.Events = append(res.Events, contra...) + } + return res, nil +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} + +func absf(v float64) float64 { + if v < 0 { + return -v + } + return v +} + +// detectContradictions compares b against the mind's active beliefs. A +// contradiction links both, emits an event and adds mutual counter-evidence +// so both confidences reflect the conflict until it is resolved. +func (s *Store) detectContradictions(ctx context.Context, q storage.Querier, emit Emitter, b Belief, causation ids.ID) ([]events.Event, error) { + rows, err := q.Query(ctx, `SELECT `+cols+` FROM beliefs b WHERE b.mind_id=$1 AND b.id<>$2 AND b.status NOT IN ('rejected','superseded') AND ($3 = '' OR b.subject = $3 OR b.subject = '') LIMIT 500`, b.MindID, b.ID, b.Subject) + if err != nil { + return nil, err + } + var others []Belief + for rows.Next() { + o, err := scan(rows) + if err != nil { + rows.Close() + return nil, err + } + others = append(others, o) + } + rows.Close() + var out []events.Event + for _, o := range others { + ok, sim := Contradicts(b.Proposition, o.Proposition) + if !ok { + continue + } + var exists bool + q.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM belief_links WHERE relation='contradicts' AND ((from_id=$1 AND to_id=$2) OR (from_id=$2 AND to_id=$1)))`, b.ID, o.ID).Scan(&exists) //nolint:errcheck + if exists { + continue + } + ev, err := emit(ctx, q, events.New{MindID: b.MindID, Type: events.ContradictionDetected, Source: "beliefs.contradiction", Summary: fmt.Sprintf("contradiction: %q vs %q", trunc(b.Proposition, 60), trunc(o.Proposition, 60)), + Salience: 0.8, Confidence: sim, CausationID: causation, + Evidence: []events.EvidenceRef{{Kind: "belief", ID: string(b.ID)}, {Kind: "belief", ID: string(o.ID)}}, + Metadata: map[string]any{"belief_id": string(b.ID), "other_id": string(o.ID), "similarity": sim}, + Payload: map[string]any{"a": map[string]any{"id": b.ID, "proposition": b.Proposition, "confidence": b.Confidence}, "b": map[string]any{"id": o.ID, "proposition": o.Proposition, "confidence": o.Confidence}, "similarity": sim, "method": "negation+jaccard"}}) + if err != nil { + return nil, err + } + out = append(out, ev) + if _, err := q.Exec(ctx, `INSERT INTO belief_links (id, mind_id, from_id, to_id, relation, confidence, event_id) VALUES ($1,$2,$3,$4,'contradicts',$5,$6) ON CONFLICT DO NOTHING`, ids.New(), b.MindID, b.ID, o.ID, sim, ev.ID); err != nil { + return nil, err + } + // Each belief becomes counter-evidence for the other, weighted by the other's confidence. + if err := s.insertEvidence(ctx, q, b, Evidence{Direction: "against", Kind: "belief", RefID: string(o.ID), Source: "contradiction", Weight: 0.5, Credibility: o.Confidence}, "contradicts: "+trunc(o.Proposition, 120), ev.ID); err != nil { + return nil, err + } + if err := s.insertEvidence(ctx, q, o, Evidence{Direction: "against", Kind: "belief", RefID: string(b.ID), Source: "contradiction", Weight: 0.5, Credibility: b.Confidence}, "contradicts: "+trunc(b.Proposition, 120), ev.ID); err != nil { + return nil, err + } + for _, x := range []Belief{b, o} { + rows, err := s.EvidenceFor(ctx, q, x.MindID, x.ID) + if err != nil { + return nil, err + } + var evs []Evidence + for _, r := range rows { + evs = append(evs, r.Evidence) + } + bd := Compute(x.Prior, evs) + st := Derive(bd, x.Epistemic) + if x.PinnedStatus != nil { + st = *x.PinnedStatus + } + if bd.Confidence != x.Confidence || st != x.Status { + x.Confidence, x.Status, x.Version = bd.Confidence, st, x.Version+1 + if err := s.insertVersion(ctx, q, x, bd, "contradiction detected", ev.ID); err != nil { + return nil, err + } + if _, err := q.Exec(ctx, `UPDATE beliefs SET confidence=$3, status=$4, version=$5, last_evaluated=now() WHERE id=$1 AND mind_id=$2`, x.ID, x.MindID, x.Confidence, string(x.Status), x.Version); err != nil { + return nil, err + } + } + } + } + return out, nil +} + +// Retract is the operator saying "this belief is wrong". Status is pinned to +// rejected, a correction is recorded, and optionally a do-not-infer rule. +func (s *Store) Retract(ctx context.Context, emit Emitter, mind, id ids.ID, reason, by string, doNotInfer bool) (Result, error) { + var res Result + err := storage.Tx(ctx, s.db, func(tx pgx.Tx) error { + b, err := s.Get(ctx, tx, mind, id) + if err != nil { + return err + } + ev, err := emit(ctx, tx, events.New{MindID: mind, Type: events.BeliefRetracted, Source: "operator", Summary: fmt.Sprintf("operator %s rejected belief: %s (%s)", by, b.Proposition, reason), + Salience: 0.7, Confidence: 1, Evidence: []events.EvidenceRef{{Kind: "belief", ID: string(b.ID)}}, + Metadata: map[string]any{"belief_id": string(b.ID), "actor": by, "do_not_infer": doNotInfer}, Payload: map[string]any{"belief_id": b.ID, "proposition": b.Proposition, "reason": reason, "previous": map[string]any{"confidence": b.Confidence, "status": b.Status}}}) + if err != nil { + return err + } + if err := s.insertEvidence(ctx, tx, b, Evidence{Direction: "against", Kind: "operator", RefID: string(ev.ID), Source: "operator", Weight: 1, Credibility: 0.95}, reason, ev.ID); err != nil { + return err + } + rej := StatusRejected + b.PinnedStatus, b.Status, b.Version = &rej, rej, b.Version+1 + rows, _ := s.EvidenceFor(ctx, tx, mind, id) + var evs []Evidence + for _, r := range rows { + evs = append(evs, r.Evidence) + } + bd := Compute(b.Prior, evs) + b.Confidence = bd.Confidence + if err := s.insertVersion(ctx, tx, b, bd, "operator correction: "+reason, ev.ID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE beliefs SET status='rejected', pinned_status='rejected', confidence=$3, version=$4, last_evaluated=now() WHERE id=$1 AND mind_id=$2`, id, mind, b.Confidence, b.Version); err != nil { + return err + } + if _, err := tx.Exec(ctx, `INSERT INTO corrections (id, mind_id, kind, target_kind, target_id, statement, reason, created_by, event_id) VALUES ($1,$2,'belief_wrong','belief',$3,$4,$5,$6,$7)`, ids.New(), mind, id, b.Proposition, reason, by, ev.ID); err != nil { + return err + } + if doNotInfer { + if _, err := tx.Exec(ctx, `INSERT INTO corrections (id, mind_id, kind, target_kind, target_id, statement, reason, created_by, event_id) VALUES ($1,$2,'do_not_infer','belief',$3,$4,$5,$6,$7)`, ids.New(), mind, id, b.Proposition, reason, by, ev.ID); err != nil { + return err + } + } + res = Result{Belief: b, Breakdown: bd, Events: []events.Event{ev}} + return nil + }) + return res, err +} + +// AddCorrection records a free-standing correction such as "do not infer X". +func (s *Store) AddCorrection(ctx context.Context, q storage.Querier, mind ids.ID, c Correction) (Correction, error) { + c.ID, c.CreatedAt = ids.New(), time.Now() + _, err := q.Exec(ctx, `INSERT INTO corrections (id, mind_id, kind, target_kind, target_id, statement, reason, created_by, event_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + c.ID, mind, c.Kind, c.TargetKind, c.TargetID, c.Statement, c.Reason, c.CreatedBy, nullID(c.EventID)) + return c, err +} + +// Corrections lists corrections for a mind, newest first. +func (s *Store) Corrections(ctx context.Context, mind ids.ID, kind string) ([]Correction, error) { + rows, err := s.db.Query(ctx, `SELECT id, kind, target_kind, target_id, statement, reason, created_by, event_id, created_at FROM corrections WHERE mind_id=$1 AND ($2='' OR kind=$2) ORDER BY created_at DESC LIMIT 500`, mind, kind) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Correction + for rows.Next() { + var c Correction + var ev *string + if err := rows.Scan(&c.ID, &c.Kind, &c.TargetKind, &c.TargetID, &c.Statement, &c.Reason, &c.CreatedBy, &ev, &c.CreatedAt); err != nil { + return nil, err + } + if ev != nil { + c.EventID = ids.ID(*ev) + } + out = append(out, c) + } + return out, rows.Err() +} + +// blocked checks do-not-infer corrections against a proposition. +func (s *Store) blocked(ctx context.Context, q storage.Querier, mind ids.ID, proposition string) (bool, Correction) { + rows, err := q.Query(ctx, `SELECT id, statement, reason FROM corrections WHERE mind_id=$1 AND kind='do_not_infer'`, mind) + if err != nil { + return false, Correction{} + } + defer rows.Close() + pt, _ := Polarity(proposition) + for rows.Next() { + var c Correction + if err := rows.Scan(&c.ID, &c.Statement, &c.Reason); err != nil { + return false, Correction{} + } + ct, _ := Polarity(c.Statement) + if Jaccard(pt, ct) >= 0.7 { + return true, c + } + } + return false, Correction{} +} + +// Versions returns the append-only history, newest first. +func (s *Store) Versions(ctx context.Context, mind, id ids.ID) ([]Version, error) { + rows, err := s.db.Query(ctx, `SELECT version, status, confidence, proposition, reason, breakdown, event_id, created_at FROM belief_versions WHERE belief_id=$1 AND mind_id=$2 ORDER BY version DESC`, id, mind) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Version + for rows.Next() { + var v Version + var st string + var bd []byte + var ev *string + if err := rows.Scan(&v.Version, &st, &v.Confidence, &v.Proposition, &v.Reason, &bd, &ev, &v.CreatedAt); err != nil { + return nil, err + } + v.Status = Status(st) + json.Unmarshal(bd, &v.Breakdown) //nolint:errcheck + if ev != nil { + v.EventID = ids.ID(*ev) + } + out = append(out, v) + } + return out, rows.Err() +} + +// Alternatives lists competing explanations. +func (s *Store) Alternatives(ctx context.Context, mind, id ids.ID) ([]Alternative, error) { + rows, err := s.db.Query(ctx, `SELECT id, proposition, confidence, note, status, created_at FROM belief_alternatives WHERE belief_id=$1 AND mind_id=$2 ORDER BY created_at`, id, mind) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Alternative + for rows.Next() { + var a Alternative + if err := rows.Scan(&a.ID, &a.Proposition, &a.Confidence, &a.Note, &a.Status, &a.CreatedAt); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// AddAlternative records another candidate explanation. +func (s *Store) AddAlternative(ctx context.Context, mind, id ids.ID, proposition, note string) (Alternative, error) { + proposition = strings.TrimSpace(proposition) + if proposition == "" { + return Alternative{}, fmt.Errorf("%w: proposition required", ErrInvalid) + } + a := Alternative{ID: ids.New(), Proposition: proposition, Confidence: 0.2, Note: note, Status: "open", CreatedAt: time.Now()} + tag, err := s.db.Exec(ctx, `INSERT INTO belief_alternatives (id, belief_id, mind_id, proposition, confidence, note) SELECT $1,$2,$3,$4,$5,$6 WHERE EXISTS (SELECT 1 FROM beliefs WHERE id=$2 AND mind_id=$3)`, a.ID, id, mind, a.Proposition, a.Confidence, a.Note) + if err != nil { + return Alternative{}, err + } + if tag.RowsAffected() == 0 { + return Alternative{}, storage.ErrNotFound + } + return a, nil +} + +// Links returns contradiction and support links with the other belief loaded. +func (s *Store) Links(ctx context.Context, mind, id ids.ID) ([]Link, error) { + rows, err := s.db.Query(ctx, `SELECT l.id, l.from_id, l.to_id, l.relation, l.confidence, l.created_at, `+cols+` FROM belief_links l + JOIN beliefs b ON b.id = CASE WHEN l.from_id=$1 THEN l.to_id ELSE l.from_id END WHERE l.mind_id=$2 AND (l.from_id=$1 OR l.to_id=$1) ORDER BY l.created_at`, id, mind) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Link + for rows.Next() { + var l Link + var other Belief + var epi, status string + var detail []byte + var origin, superseded, pinned *string + if err := rows.Scan(&l.ID, &l.FromID, &l.ToID, &l.Relation, &l.Confidence, &l.CreatedAt, + &other.ID, &other.MindID, &other.Proposition, &other.Subject, &epi, &status, &other.Confidence, &other.Prior, &other.Version, &other.Source, &detail, &origin, &superseded, &pinned, &other.FirstCreated, &other.LastEvaluated, &other.Evaluations); err != nil { + return nil, err + } + other.Epistemic, other.Status = concepts.Epistemic(epi), Status(status) + l.Other = &other + out = append(out, l) + } + return out, rows.Err() +} + +// RetractEvidence marks a piece of evidence as not counting (memory retracted etc.). +func (s *Store) RetractEvidence(ctx context.Context, q storage.Querier, mind ids.ID, kind, refID string) ([]ids.ID, error) { + rows, err := q.Query(ctx, `UPDATE belief_evidence SET retracted=true WHERE mind_id=$1 AND kind=$2 AND ref_id=$3 AND NOT retracted RETURNING belief_id`, mind, kind, refID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ids.ID + for rows.Next() { + var id ids.ID + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// Stats summarises beliefs for the overview. +type Stats struct { + Total int64 `json:"total"` + ByStatus map[string]int64 `json:"by_status"` +} + +// Stats counts beliefs by status. +func (s *Store) Stats(ctx context.Context, mind ids.ID) (Stats, error) { + st := Stats{ByStatus: map[string]int64{}} + rows, err := s.db.Query(ctx, `SELECT status, count(*) FROM beliefs WHERE mind_id=$1 GROUP BY status`, mind) + if err != nil { + return st, err + } + defer rows.Close() + for rows.Next() { + var k string + var n int64 + if err := rows.Scan(&k, &n); err != nil { + return st, err + } + st.ByStatus[k] = n + st.Total += n + } + return st, rows.Err() +} + +// Contested returns beliefs currently in conflict, for attention and reflection. +func (s *Store) Contested(ctx context.Context, mind ids.ID, limit int) ([]Belief, error) { + return s.List(ctx, mind, Filter{Statuses: []Status{StatusContested}, Limit: limit}) +} + +func trunc(s string, n int) string { + if len(s) > n { + return s[:n-1] + "…" + } + return s +} diff --git a/internal/beliefs/store_test.go b/internal/beliefs/store_test.go new file mode 100644 index 0000000..6f2f811 --- /dev/null +++ b/internal/beliefs/store_test.go @@ -0,0 +1,122 @@ +package beliefs + +import ( + "context" + "errors" + "testing" + + evstore "github.com/andydixon/noema/internal/events" + "github.com/andydixon/noema/internal/minds" + "github.com/andydixon/noema/internal/storage" + "github.com/andydixon/noema/migrations" + "github.com/andydixon/noema/pkg/concepts" + "github.com/andydixon/noema/pkg/events" + "github.com/andydixon/noema/pkg/ids" +) + +func TestBeliefLifecycleProvenanceAndContradiction(t *testing.T) { + pool := storage.TestPool(t, migrations.FS) + ctx := context.Background() + m, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "b"}) + es := evstore.NewStore(pool, evstore.NewBus(8), nil) + emit := func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) { + out, err := es.AppendIn(ctx, q, n) + if err != nil { + return events.Event{}, err + } + return out[0], nil + } + s := NewStore(pool) + obs1s, _ := es.Append(ctx, events.New{MindID: m.ID, Type: events.Observation, Source: "smart", Summary: "SMART CRC errors on server-01", Salience: 0.8, Confidence: 1}) + obs2s, _ := es.Append(ctx, events.New{MindID: m.ID, Type: events.Observation, Source: "metrics", Summary: "I/O latency high on server-01", Salience: 0.8, Confidence: 1}) + obs1, obs2 := obs1s[0], obs2s[0] + + res, err := s.Create(ctx, pool, emit, New{MindID: m.ID, Proposition: "server-01 has an intermittent storage problem", Subject: "server-01", Epistemic: concepts.Hypothesis, Source: "reasoning", + Evidence: []EvidenceInput{{Direction: "for", Kind: "event", RefID: string(obs1.ID), Source: "smart", Weight: 0.6, Credibility: 0.9, Note: "CRC errors"}}, + Alternatives: []string{"SATA cable issue", "controller issue"}, OriginEvent: obs1.ID}) + if err != nil { + t.Fatal(err) + } + b := res.Belief + if b.Status != StatusHypothesis && b.Status != StatusTentative || b.Confidence <= PriorFor(concepts.Hypothesis) || res.Events[0].Type != events.HypothesisCreated { + t.Fatalf("create wrong: %+v %+v", b, res.Events) + } + if len(res.Events[0].Evidence) != 2 { + t.Fatalf("provenance refs missing: %+v", res.Events[0].Evidence) + } + // Support from a second, independent source raises confidence more than repetition. + r2, err := s.AddEvidence(ctx, pool, emit, m.ID, b.ID, EvidenceInput{Direction: "for", Kind: "event", RefID: string(obs2.ID), Source: "metrics", Weight: 0.6, Credibility: 0.9, Note: "latency"}, "observed latency", obs2.ID, ids.Nil) + if err != nil || r2.Belief.Confidence <= b.Confidence || r2.Belief.Version != 2 || r2.Events[0].Type != events.HypothesisSupported { + t.Fatalf("support wrong: %v %+v", err, r2) + } + // Counter-evidence lowers it and may contest. + r3, err := s.AddEvidence(ctx, pool, emit, m.ID, b.ID, EvidenceInput{Direction: "against", Kind: "note", Source: "smart", Weight: 0.7, Credibility: 0.9, Note: "extended SMART test passed"}, "test passed", ids.Nil, ids.Nil) + if err != nil || r3.Belief.Confidence >= r2.Belief.Confidence || r3.Events[0].Type != events.HypothesisWeakened { + t.Fatalf("weaken wrong: %v %+v", err, r3) + } + vs, _ := s.Versions(ctx, m.ID, b.ID) + if len(vs) != 3 || vs[0].Version != 3 || vs[0].Breakdown.Against == 0 { + t.Fatalf("versions wrong: %+v", vs) + } + if _, err := pool.Exec(ctx, `DELETE FROM belief_versions WHERE belief_id=$1`, b.ID); err == nil { + t.Fatal("belief history must be append-only") + } + alts, _ := s.Alternatives(ctx, m.ID, b.ID) + if len(alts) != 2 { + t.Fatalf("alternatives missing: %+v", alts) + } + + // A negated proposition triggers a contradiction and links both beliefs. + rc, err := s.Create(ctx, pool, emit, New{MindID: m.ID, Proposition: "server-01 does not have an intermittent storage problem", Subject: "server-01", Epistemic: concepts.Claim, Source: "user", + Evidence: []EvidenceInput{{Direction: "for", Kind: "note", Source: "user", Weight: 0.5, Credibility: 0.85}}}) + if err != nil { + t.Fatal(err) + } + var sawContra bool + for _, ev := range rc.Events { + if ev.Type == events.ContradictionDetected { + sawContra = true + } + } + if !sawContra { + t.Fatalf("no contradiction event: %+v", rc.Events) + } + links, _ := s.Links(ctx, m.ID, b.ID) + if len(links) != 1 || links[0].Relation != "contradicts" || links[0].Other.ID != rc.Belief.ID { + t.Fatalf("links wrong: %+v", links) + } + after, _ := s.Get(ctx, pool, m.ID, b.ID) + if after.Confidence >= r3.Belief.Confidence { + t.Fatalf("contradiction should lower confidence: %v -> %v", r3.Belief.Confidence, after.Confidence) + } + + // Operator correction: rejected, pinned, history kept, do-not-infer blocks recreation. + rr, err := s.Retract(ctx, emit, m.ID, b.ID, "it was the SATA cable", "andy", true) + if err != nil || rr.Belief.Status != StatusRejected || rr.Events[0].Type != events.BeliefRetracted { + t.Fatalf("retract wrong: %v %+v", err, rr) + } + got, _ := s.Get(ctx, pool, m.ID, b.ID) + if got.Status != StatusRejected || got.PinnedStatus == nil { + t.Fatalf("not pinned rejected: %+v", got) + } + vs, _ = s.Versions(ctx, m.ID, b.ID) + if len(vs) < 4 { + t.Fatalf("history lost on retraction: %d", len(vs)) + } + if _, err := s.Create(ctx, pool, emit, New{MindID: m.ID, Proposition: "server-01 has an intermittent storage problem", Epistemic: concepts.Hypothesis}); !errors.Is(err, ErrBlocked) { + t.Fatalf("do-not-infer not enforced: %v", err) + } + cs, _ := s.Corrections(ctx, m.ID, "") + if len(cs) != 2 { + t.Fatalf("corrections missing: %+v", cs) + } + // Isolation: another mind cannot see these. + other, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "other"}) + if _, err := s.Get(ctx, pool, other.ID, b.ID); err == nil { + t.Fatal("cross-mind belief read") + } + list, _ := s.List(ctx, other.ID, Filter{}) + if len(list) != 0 { + t.Fatal("cross-mind belief list") + } +} diff --git a/internal/brain/cycle.go b/internal/brain/cycle.go index 7e846e7..58a3ad1 100644 --- a/internal/brain/cycle.go +++ b/internal/brain/cycle.go @@ -60,6 +60,32 @@ func (c *Cycle) Emit(n events.New) { c.pending = append(c.pending, n) } +// EmitNow appends one event immediately inside the cycle transaction and +// returns it, for handlers that need the event id for provenance. It is still +// published only after commit. +func (c *Cycle) EmitNow(ctx context.Context, n events.New) (events.Event, error) { + if err := c.flush(ctx); err != nil { + return events.Event{}, err + } + n.MindID = c.State.Mind.ID + if n.CorrelationID.IsZero() { + n.CorrelationID = c.State.CycleID + } + if n.OccurredAt.IsZero() { + n.OccurredAt = c.State.Now + } + if n.Metadata == nil { + n.Metadata = map[string]any{} + } + n.Metadata["cycle"] = c.State.Cycle + evs, err := c.Runtime.events.AppendIn(ctx, c.Tx, n) + if err != nil { + return events.Event{}, err + } + c.emitted = append(c.emitted, evs...) + return evs[0], nil +} + // flush writes queued events inside the transaction and returns them. func (c *Cycle) flush(ctx context.Context) error { if len(c.pending) == 0 { diff --git a/internal/brain/process.go b/internal/brain/process.go index 547f67f..c66324b 100644 --- a/internal/brain/process.go +++ b/internal/brain/process.go @@ -9,6 +9,7 @@ import ( "time" "github.com/andydixon/noema/internal/attention" + "github.com/andydixon/noema/internal/beliefs" "github.com/andydixon/noema/internal/memory" "github.com/andydixon/noema/internal/minds" "github.com/andydixon/noema/internal/workspace" @@ -66,8 +67,9 @@ type State struct { // Services are read handles processes may use. Extended per phase. type Services struct { - Events EventReader - Memory *memory.Store + Events EventReader + Memory *memory.Store + Beliefs *beliefs.Store } // EventReader is the subset of the event store processes may call. diff --git a/internal/cognition/associate.go b/internal/cognition/associate.go new file mode 100644 index 0000000..63ad796 --- /dev/null +++ b/internal/cognition/associate.go @@ -0,0 +1,76 @@ +package cognition + +import ( + "context" + "sort" + "strings" + "time" + + "github.com/andydixon/noema/internal/attention" + "github.com/andydixon/noema/internal/brain" + "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 + } + for _, it := range s.Workspace.WorkingMemory { + if it.Kind != "observation" || it.SourceEvent.IsZero() || it.SourceEvent == ev.ID { + 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.5*ev.Salience + 0.2, Novelty: 0.6, Consequence: 0.6, Urgency: laterU * 0.5, Confidence: 0.3, ContextRelevance: 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 +} diff --git a/internal/cognition/handlers.go b/internal/cognition/handlers.go index 4d08ac5..05fe355 100644 --- a/internal/cognition/handlers.go +++ b/internal/cognition/handlers.go @@ -3,10 +3,14 @@ package cognition import ( "context" "encoding/json" + "errors" "fmt" + "strings" + "github.com/andydixon/noema/internal/beliefs" "github.com/andydixon/noema/internal/brain" "github.com/andydixon/noema/internal/memory" + "github.com/andydixon/noema/internal/storage" "github.com/andydixon/noema/internal/workspace" "github.com/andydixon/noema/pkg/concepts" "github.com/andydixon/noema/pkg/events" @@ -15,13 +19,62 @@ import ( // Handlers are the executive actions for admitted thought kinds. type Handlers struct { - Memory *memory.Store + Memory *memory.Store + Beliefs *beliefs.Store } // Register installs the handlers on a registry. func (h Handlers) Register(r *brain.Registry) { r.Handle("attend", h.attend) r.Handle("retrieve", h.retrieve) + r.Handle("hypothesis", h.hypothesis) +} + +// emitter adapts the cycle to the beliefs store's Emitter. +func emitter(c *brain.Cycle) beliefs.Emitter { + return func(ctx context.Context, _ storage.Querier, n events.New) (events.Event, error) { + return c.EmitNow(ctx, n) + } +} + +// hypothesis turns an admitted hypothesis candidate into a belief whose +// confidence is computed from the cited observations. Temporal co-occurrence +// is weak evidence, so weights are small and the result stays a hypothesis +// until independent support arrives. +func (h Handlers) hypothesis(ctx context.Context, c *brain.Cycle, t workspace.Thought) error { + if h.Beliefs == nil { + return nil + } + prop, _ := t.Content["proposition"].(string) + if prop == "" { + prop = strings.TrimPrefix(t.Summary, "hypothesis: ") + } + var ev []beliefs.EvidenceInput + for _, ref := range t.Evidence { + if ref.Kind != "event" { + continue + } + cred := 0.6 + if e, err := c.Runtime.Events().Get(ctx, ids.ID(ref.ID)); err == nil { + var p events.ObservationPayload + if json.Unmarshal(e.Payload, &p) == nil && p.Trust.Valid() { + cred = p.Trust.Credibility() + } + } + ev = append(ev, beliefs.EvidenceInput{Direction: "for", Kind: "event", RefID: ref.ID, Source: "co-occurrence:" + ref.ID, Weight: 0.15, Credibility: cred, Note: "temporal co-occurrence (" + ref.Note + ")"}) + } + res, err := h.Beliefs.Create(ctx, c.Tx, emitter(c), beliefs.New{MindID: c.State.Mind.ID, Proposition: prop, Epistemic: concepts.Hypothesis, Source: "brain.associate", + Evidence: ev, Alternatives: []string{"coincidence: the two events are unrelated", "a third factor caused both"}, OriginEvent: t.EventID, Causation: t.EventID, Correlation: c.State.CycleID, + Detail: map[string]any{"cause_event": t.Content["cause_event"], "effect_event": t.Content["effect_event"], "gap_seconds": t.Content["gap_seconds"]}}) + if err != nil { + if errors.Is(err, beliefs.ErrBlocked) { + c.Emit(events.New{Type: events.Metacognition, Source: "brain.executive", Summary: "hypothesis blocked by operator correction: " + prop, Salience: 0.3, Confidence: 1, Metadata: map[string]any{"check": "do_not_infer"}}) + return nil + } + return err + } + c.Runtime.Hold(workspace.Item{Key: "belief:" + string(res.Belief.ID), Content: "hypothesis (" + fmt.Sprintf("%.2f", res.Belief.Confidence) + "): " + prop, Kind: "thought", Ref: string(res.Belief.ID), SourceEvent: t.EventID}) + return nil } // attend encodes the attended event as a memory and holds it in working diff --git a/internal/ui/beliefs.go b/internal/ui/beliefs.go new file mode 100644 index 0000000..f48676a --- /dev/null +++ b/internal/ui/beliefs.go @@ -0,0 +1,159 @@ +package ui + +import ( + "net/http" + "strconv" + + "github.com/andydixon/noema/internal/api" + "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/beliefs" + "github.com/andydixon/noema/pkg/concepts" + "github.com/andydixon/noema/pkg/events" + "github.com/andydixon/noema/pkg/ids" +) + +func (u *UI) beliefRoutes(m *http.ServeMux) { + m.Handle("GET /minds/{mind}/beliefs", u.require(auth.RoleObserver, u.beliefsPage)) + m.Handle("POST /minds/{mind}/beliefs", u.require(auth.RoleOperator, u.beliefAssert)) + m.Handle("GET /minds/{mind}/beliefs/{id}", u.require(auth.RoleObserver, u.beliefDetail)) + m.Handle("POST /minds/{mind}/beliefs/{id}/evidence", u.require(auth.RoleOperator, u.beliefEvidence)) + m.Handle("POST /minds/{mind}/beliefs/{id}/alternatives", u.require(auth.RoleOperator, u.beliefAlternative)) + m.Handle("POST /minds/{mind}/beliefs/{id}/retract", u.require(auth.RoleOperator, u.beliefRetract)) + m.Handle("POST /minds/{mind}/corrections", u.require(auth.RoleOperator, u.correctionCreate)) +} + +func (u *UI) beliefsPage(w http.ResponseWriter, r *http.Request) { + m, ok := u.mind(w, r) + if !ok { + return + } + q := r.URL.Query() + f := api.ParseBeliefFilter(q) + f.Limit = 200 + list, err := u.deps.Beliefs.List(r.Context(), m.ID, f) + if err != nil { + u.errorPage(w, r, 500, "Could not list beliefs.") + return + } + flat := map[string]string{} + for k := range q { + flat[k] = q.Get(k) + } + stats, _ := u.deps.Beliefs.Stats(r.Context(), m.ID) + corr, _ := u.deps.Beliefs.Corrections(r.Context(), m.ID, "") + u.render(w, r, "beliefs", m.DisplayName+" · Beliefs", map[string]any{"Mind": m, "Beliefs": list, "Q": flat, "Stats": stats, "Corrections": corr}) +} + +func (u *UI) beliefAssert(w http.ResponseWriter, r *http.Request) { + m, ok := u.mind(w, r) + if !ok { + return + } + r.ParseForm() //nolint:errcheck + weight, _ := strconv.ParseFloat(r.PostFormValue("weight"), 64) + res, err := u.deps.Beliefs.Create(r.Context(), u.deps.DB, u.emitF(), beliefs.New{MindID: m.ID, Proposition: r.PostFormValue("proposition"), Subject: r.PostFormValue("subject"), + Epistemic: concepts.Epistemic(r.PostFormValue("epistemic")), Source: "operator", + Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "operator", Source: "operator:" + username(r), Weight: weight, Credibility: events.TrustOperator.Credibility(), Note: "asserted by operator " + username(r)}}}) + if err != nil { + u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/beliefs") + return + } + u.deps.Audit.Log(r.Context(), "belief.create", "belief", res.Belief.ID.String(), map[string]any{"mind": m.Slug, "proposition": res.Belief.Proposition}) + u.flash(w, r, "ok", "Belief recorded with confidence "+strconv.FormatFloat(res.Belief.Confidence, 'f', 2, 64)+".", "/minds/"+m.Slug+"/beliefs/"+res.Belief.ID.String()) +} + +func (u *UI) beliefDetail(w http.ResponseWriter, r *http.Request) { + m, ok := u.mind(w, r) + if !ok { + return + } + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + u.errorPage(w, r, 404, "No such belief.") + return + } + d, err := u.belF(r, m.ID, id) + if err != nil { + u.errorPage(w, r, 404, "No such belief.") + return + } + u.render(w, r, "belief_detail", "Belief", map[string]any{"Mind": m, "Detail": d}) +} + +func (u *UI) beliefEvidence(w http.ResponseWriter, r *http.Request) { + m, ok := u.mind(w, r) + if !ok { + return + } + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + u.errorPage(w, r, 404, "No such belief.") + return + } + r.ParseForm() //nolint:errcheck + weight, _ := strconv.ParseFloat(r.PostFormValue("weight"), 64) + note := r.PostFormValue("note") + if _, err := u.deps.Beliefs.AddEvidence(r.Context(), u.deps.DB, u.emitF(), m.ID, id, beliefs.EvidenceInput{Direction: r.PostFormValue("direction"), Kind: "operator", Source: "operator:" + username(r), Weight: weight, Credibility: events.TrustOperator.Credibility(), Note: note}, "operator evidence: "+note, ids.Nil, ids.Nil); err != nil { + u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/beliefs/"+id.String()) + return + } + u.deps.Audit.Log(r.Context(), "belief.evidence", "belief", id.String(), map[string]any{"direction": r.PostFormValue("direction"), "note": note}) + u.flash(w, r, "ok", "Evidence added and confidence re-evaluated.", "/minds/"+m.Slug+"/beliefs/"+id.String()) +} + +func (u *UI) beliefAlternative(w http.ResponseWriter, r *http.Request) { + m, ok := u.mind(w, r) + if !ok { + return + } + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + u.errorPage(w, r, 404, "No such belief.") + return + } + if _, err := u.deps.Beliefs.AddAlternative(r.Context(), m.ID, id, r.PostFormValue("proposition"), "operator"); err != nil { + u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/beliefs/"+id.String()) + return + } + u.flash(w, r, "ok", "Alternative recorded.", "/minds/"+m.Slug+"/beliefs/"+id.String()) +} + +func (u *UI) beliefRetract(w http.ResponseWriter, r *http.Request) { + m, ok := u.mind(w, r) + if !ok { + return + } + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + u.errorPage(w, r, 404, "No such belief.") + return + } + reason := r.PostFormValue("reason") + dni := r.PostFormValue("do_not_infer") == "1" + if _, err := u.deps.Beliefs.Retract(r.Context(), u.emitF(), m.ID, id, reason, username(r), dni); err != nil { + u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/beliefs/"+id.String()) + return + } + u.deps.Audit.Log(r.Context(), "belief.retract", "belief", id.String(), map[string]any{"reason": reason, "do_not_infer": dni}) + u.flash(w, r, "ok", "Belief marked wrong. Its history is preserved.", "/minds/"+m.Slug+"/beliefs/"+id.String()) +} + +func (u *UI) correctionCreate(w http.ResponseWriter, r *http.Request) { + m, ok := u.mind(w, r) + if !ok { + return + } + stmt, reason := r.PostFormValue("statement"), r.PostFormValue("reason") + evs, err := u.deps.Events.Append(r.Context(), events.New{MindID: m.ID, Type: events.OperatorAction, Source: "operator", Summary: "Operator " + username(r) + " forbade inference: " + stmt, + Salience: 0.6, Confidence: 1, Metadata: map[string]any{"action": "correction.do_not_infer", "actor": username(r)}, Payload: map[string]any{"statement": stmt, "reason": reason}}) + if err != nil { + u.flash(w, r, "error", "Could not record.", "/minds/"+m.Slug+"/beliefs") + return + } + if _, err := u.deps.Beliefs.AddCorrection(r.Context(), u.deps.DB, m.ID, beliefs.Correction{Kind: "do_not_infer", Statement: stmt, Reason: reason, CreatedBy: username(r), EventID: evs[0].ID}); err != nil { + u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/beliefs") + return + } + u.deps.Audit.Log(r.Context(), "correction.create", "mind", m.ID.String(), map[string]any{"kind": "do_not_infer", "statement": stmt}) + u.flash(w, r, "ok", "The mind will not infer that again.", "/minds/"+m.Slug+"/beliefs") +} diff --git a/internal/ui/memory.go b/internal/ui/memory.go index abd7312..987f690 100644 --- a/internal/ui/memory.go +++ b/internal/ui/memory.go @@ -120,6 +120,11 @@ func (u *UI) memoryRetract(w http.ResponseWriter, r *http.Request) { } u.deps.Events.OperatorAction(r.Context(), m.ID, username(r), "memory.retract", "Operator "+username(r)+" marked a memory inaccurate: "+reason, map[string]any{"memory_id": id, "reason": reason}) u.deps.Audit.Log(r.Context(), "memory.retract", "memory", id.String(), map[string]any{"reason": reason}) + if affected, err := u.deps.Beliefs.RetractEvidence(r.Context(), u.deps.DB, m.ID, "memory", string(id)); err == nil { + for _, bid := range affected { + u.deps.Beliefs.Reevaluate(r.Context(), u.deps.DB, u.emitF(), m.ID, bid, "supporting memory retracted by operator") //nolint:errcheck + } + } u.flash(w, r, "ok", "Memory marked inaccurate. It remains in history.", "/minds/"+m.Slug+"/memory/"+id.String()) } diff --git a/internal/ui/minds.go b/internal/ui/minds.go index 946e59d..69b3fc0 100644 --- a/internal/ui/minds.go +++ b/internal/ui/minds.go @@ -9,6 +9,7 @@ import ( "time" "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/beliefs" "github.com/andydixon/noema/internal/minds" "github.com/andydixon/noema/internal/storage" "github.com/andydixon/noema/pkg/ids" @@ -98,7 +99,9 @@ func (u *UI) mindOverview(w http.ResponseWriter, r *http.Request) { sm, _ := u.deps.Minds.GetSelfModel(r.Context(), m.ID) st, _ := u.deps.Events.Stats(r.Context(), m.ID, time.Now().Add(-24*time.Hour)) ms, _ := u.deps.Memory.Stats(r.Context(), m.ID) - u.render(w, r, "mind", m.DisplayName, map[string]any{"Mind": m, "Brief": m.Personality.Brief(), "SelfModel": sm, "Stats": st, "MemStats": ms}) + bs, _ := u.deps.Beliefs.Stats(r.Context(), m.ID) + top, _ := u.deps.Beliefs.List(r.Context(), m.ID, beliefs.Filter{Limit: 8}) + u.render(w, r, "mind", m.DisplayName, map[string]any{"Mind": m, "Brief": m.Personality.Brief(), "SelfModel": sm, "Stats": st, "MemStats": ms, "BeliefStats": bs, "Beliefs": top}) } func (u *UI) mindStatus(w http.ResponseWriter, r *http.Request) { diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 5291cb1..b1117d1 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -20,6 +20,7 @@ import ( "github.com/andydixon/noema/internal/app" "github.com/andydixon/noema/internal/attention" "github.com/andydixon/noema/internal/auth" + "github.com/andydixon/noema/internal/beliefs" "github.com/andydixon/noema/internal/minds" "github.com/andydixon/noema/internal/version" "github.com/andydixon/noema/pkg/concepts" @@ -36,6 +37,8 @@ type UI struct { ctxF func(r *http.Request, id ids.ID) (api.EventContext, error) // event forensic context, shared with api wsF func(r *http.Request, m minds.Mind) (api.WorkspaceView, error) memF func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error) + belF func(r *http.Request, mind, id ids.ID) (api.BeliefDetail, error) + emitF func() beliefs.Emitter } // View is the data passed to every template. @@ -56,7 +59,7 @@ const flashCookie = "noema_flash" // New parses templates. In development, when ./web exists on disk, templates // and static files are read from disk so edits show without a rebuild. -func New(deps app.Deps, statusF func(r *http.Request) any, ctxF func(r *http.Request, id ids.ID) (api.EventContext, error), wsF func(r *http.Request, m minds.Mind) (api.WorkspaceView, error), memF func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error)) (*UI, error) { +func New(deps app.Deps, statusF func(r *http.Request) any, ctxF func(r *http.Request, id ids.ID) (api.EventContext, error), wsF func(r *http.Request, m minds.Mind) (api.WorkspaceView, error), memF func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error), belF func(r *http.Request, mind, id ids.ID) (api.BeliefDetail, error), emitF func() beliefs.Emitter) (*UI, error) { tmplFS, staticFS := web.Templates(), web.Static() if !deps.Config.Production() { if st, err := os.Stat("web/templates"); err == nil && st.IsDir() { @@ -64,7 +67,7 @@ func New(deps app.Deps, statusF func(r *http.Request) any, ctxF func(r *http.Req deps.Log.Info("ui: serving templates and static files from ./web (development)") } } - u := &UI{deps: deps, static: staticFS, statusF: statusF, ctxF: ctxF, wsF: wsF, memF: memF} + u := &UI{deps: deps, static: staticFS, statusF: statusF, ctxF: ctxF, wsF: wsF, memF: memF, belF: belF, emitF: emitF} if err := u.parse(tmplFS); err != nil { return nil, err } @@ -94,6 +97,9 @@ func (u *UI) parse(fsys fs.FS) error { "memoryKinds": func() []concepts.MemoryKind { return concepts.AllMemoryKinds }, "epistemicKinds": func() []concepts.Epistemic { return concepts.AllEpistemic }, "relations": func() []concepts.Relation { return concepts.AllRelations }, + "beliefStatuses": func() []beliefs.Status { + return []beliefs.Status{beliefs.StatusHypothesis, beliefs.StatusTentative, beliefs.StatusLikely, beliefs.StatusAccepted, beliefs.StatusContested, beliefs.StatusRejected, beliefs.StatusSuperseded} + }, "trunc": func(s string, n int) string { if len(s) > n { return s[:n] + "…" @@ -246,6 +252,7 @@ func (u *UI) Register(m *http.ServeMux) { u.mindRoutes(m) u.eventRoutes(m) u.memoryRoutes(m) + u.beliefRoutes(m) for _, p := range []struct{ path, title, note string }{ {"/approvals", "Approvals", "The approval centre arrives with the capability system."}, diff --git a/migrations/0006_beliefs.down.sql b/migrations/0006_beliefs.down.sql new file mode 100644 index 0000000..07502f7 --- /dev/null +++ b/migrations/0006_beliefs.down.sql @@ -0,0 +1,6 @@ +DROP TABLE IF EXISTS corrections; +DROP TABLE IF EXISTS belief_links; +DROP TABLE IF EXISTS belief_alternatives; +DROP TABLE IF EXISTS belief_evidence; +DROP TABLE IF EXISTS belief_versions; +DROP TABLE IF EXISTS beliefs; diff --git a/migrations/0006_beliefs.up.sql b/migrations/0006_beliefs.up.sql new file mode 100644 index 0000000..519afeb --- /dev/null +++ b/migrations/0006_beliefs.up.sql @@ -0,0 +1,104 @@ +-- Beliefs with provenance: evidence for/against, alternatives, append-only +-- version history, contradiction links and operator corrections. + +CREATE TABLE beliefs ( + id uuid PRIMARY KEY, + mind_id uuid NOT NULL REFERENCES minds(id), + proposition text NOT NULL, + subject text NOT NULL DEFAULT '', + epistemic text NOT NULL, + status text NOT NULL CHECK (status IN ('hypothesis','tentative','likely','accepted','contested','rejected','superseded')), + confidence double precision NOT NULL CHECK (confidence >= 0 AND confidence <= 1), + prior double precision NOT NULL DEFAULT 0.5, + version integer NOT NULL DEFAULT 1, + source text NOT NULL DEFAULT '', + detail jsonb NOT NULL DEFAULT '{}'::jsonb, + origin_event uuid, + superseded_by uuid REFERENCES beliefs(id), + pinned_status text, -- operator override (e.g. rejected); NULL = derived + first_created timestamptz NOT NULL DEFAULT now(), + last_evaluated timestamptz NOT NULL DEFAULT now(), + evaluations integer NOT NULL DEFAULT 1, + search tsvector GENERATED ALWAYS AS (to_tsvector('english', proposition)) STORED +); +CREATE INDEX beliefs_mind_status_idx ON beliefs (mind_id, status, confidence DESC); +CREATE INDEX beliefs_mind_subject_idx ON beliefs (mind_id, subject); +CREATE INDEX beliefs_search_idx ON beliefs USING gin (search); + +CREATE TABLE belief_versions ( + id uuid PRIMARY KEY, + belief_id uuid NOT NULL REFERENCES beliefs(id), + mind_id uuid NOT NULL REFERENCES minds(id), + version integer NOT NULL, + status text NOT NULL, + confidence double precision NOT NULL, + proposition text NOT NULL, + reason text NOT NULL DEFAULT '', + breakdown jsonb NOT NULL DEFAULT '{}'::jsonb, + event_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (belief_id, version) +); +CREATE TRIGGER belief_versions_append_only BEFORE UPDATE OR DELETE ON belief_versions + FOR EACH ROW EXECUTE FUNCTION noema_forbid_change(); + +CREATE TABLE belief_evidence ( + id uuid PRIMARY KEY, + belief_id uuid NOT NULL REFERENCES beliefs(id), + mind_id uuid NOT NULL REFERENCES minds(id), + direction text NOT NULL CHECK (direction IN ('for','against')), + kind text NOT NULL, -- event | memory | belief | prediction | operator | note + ref_id text NOT NULL DEFAULT '', + source text NOT NULL DEFAULT '', + weight double precision NOT NULL CHECK (weight > 0 AND weight <= 1), + credibility double precision NOT NULL CHECK (credibility >= 0 AND credibility <= 1), + note text NOT NULL DEFAULT '', + event_id uuid, + retracted boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX belief_evidence_belief_idx ON belief_evidence (belief_id); +CREATE INDEX belief_evidence_ref_idx ON belief_evidence (kind, ref_id); + +CREATE TABLE belief_alternatives ( + id uuid PRIMARY KEY, + belief_id uuid NOT NULL REFERENCES beliefs(id), + mind_id uuid NOT NULL REFERENCES minds(id), + proposition text NOT NULL, + confidence double precision NOT NULL DEFAULT 0.2, + note text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','dismissed','promoted')), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX belief_alternatives_belief_idx ON belief_alternatives (belief_id); + +CREATE TABLE belief_links ( + id uuid PRIMARY KEY, + mind_id uuid NOT NULL REFERENCES minds(id), + from_id uuid NOT NULL REFERENCES beliefs(id), + to_id uuid NOT NULL REFERENCES beliefs(id), + relation text NOT NULL, -- contradicts | supports | alternative_to | derived_from + confidence double precision NOT NULL DEFAULT 0.5, + event_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (from_id, to_id, relation) +); +CREATE INDEX belief_links_to_idx ON belief_links (to_id); + +-- Operator corrections. Append-only: a correction can be superseded by another +-- correction but never erased (spec §40). +CREATE TABLE corrections ( + id uuid PRIMARY KEY, + mind_id uuid NOT NULL REFERENCES minds(id), + kind text NOT NULL CHECK (kind IN ('belief_wrong','memory_inaccurate','entities_same','entities_different','do_not_infer')), + target_kind text NOT NULL DEFAULT '', + target_id text NOT NULL DEFAULT '', + statement text NOT NULL DEFAULT '', + reason text NOT NULL DEFAULT '', + created_by text NOT NULL DEFAULT '', + event_id uuid, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX corrections_mind_kind_idx ON corrections (mind_id, kind); +CREATE TRIGGER corrections_append_only BEFORE UPDATE OR DELETE ON corrections + FOR EACH ROW EXECUTE FUNCTION noema_forbid_change(); diff --git a/web/static/app.css b/web/static/app.css index afebdb3..c301c09 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -168,3 +168,10 @@ tr.dayrow td { background: var(--surface-2); font-weight: 600; font-size: .78rem .badge.k-episodic { background: #e0f2fe; color: #0369a1; } .badge.k-semantic { background: #ede9fe; color: #6d28d9; } .badge.k-procedural { background: #dcfce7; color: #15803d; } .badge.k-autobiographical { background: #fef3c7; color: #b45309; } :root[data-theme="dark"] .badge.k-episodic, :root:not([data-theme="light"]) .badge.k-episodic { background: #0c2a3a; color: #7dd3fc; } :root[data-theme="dark"] .badge.k-semantic { background: #2a1f4d; color: #c4b5fd; } :root[data-theme="dark"] .badge.k-procedural { background: #12331d; color: #86efac; } :root[data-theme="dark"] .badge.k-autobiographical { background: #3a2a0a; color: #fcd34d; } + +/* Beliefs */ +.badge.st-hypothesis { background: var(--surface-2); color: var(--muted); } .badge.st-tentative { background: #fef3c7; color: #b45309; } .badge.st-likely { background: #dbeafe; color: #1d4ed8; } +.badge.st-accepted { background: var(--ok-soft); color: var(--ok); } .badge.st-contested { background: #fde68a; color: #92400e; } .badge.st-rejected { background: var(--danger-soft); color: var(--danger); } .badge.st-superseded { background: var(--surface-2); color: var(--muted); text-decoration: line-through; } +:root[data-theme="dark"] .badge.st-tentative { background: #3a2a0a; color: #fcd34d; } :root[data-theme="dark"] .badge.st-likely { background: #172554; color: #93c5fd; } :root[data-theme="dark"] .badge.st-contested { background: #3d2e05; color: #fbbf24; } +.conf.big .conf-bar.wide { width: 100%; height: 10px; margin: .3rem 0 .8rem; } +ul.ev li { padding: .4rem 0; border-bottom: 1px solid var(--border); } ul.ev li:last-child { border-bottom: 0; } ul.ev li.retracted { opacity: .55; } diff --git a/web/templates/pages/belief_detail.html b/web/templates/pages/belief_detail.html new file mode 100644 index 0000000..9824514 --- /dev/null +++ b/web/templates/pages/belief_detail.html @@ -0,0 +1,72 @@ +{{define "content"}} +{{$m := .Data.Mind}}{{$d := .Data.Detail}}{{$b := $d.Belief}}{{$bd := $d.Breakdown}} +
+

{{$m.DisplayName}} › beliefs

+

{{$b.Status}} {{$b.Proposition}}

+

{{$b.Epistemic}} · version {{$b.Version}} · first {{date $b.FirstCreated}} · evaluated {{$b.Evaluations}}× · source {{$b.Source}}{{if $b.Subject}} · subject {{$b.Subject}}{{end}}{{if $b.PinnedStatus}} · status pinned by operator{{end}}

+
+
+
+
+

Confidence {{conf $b.Confidence}} how it was computed

+
+
+
Prior
{{conf $bd.Prior}} ({{$b.Epistemic}})
+
Support
{{conf $bd.For}} Σ weight × credibility over {{$bd.SourcesFor}} source(s); diversity scale {{conf $bd.DiversityScale}}
+
Opposition
{{conf $bd.Against}}
+
Log-odds
{{conf $bd.LogOdds}}
+
Formula
{{$bd.Formula}}
+ {{range $bd.Notes}}
Note
{{.}}
{{end}} +
+
+
+
+

Evidence for

+
    {{range $d.Evidence}}{{if eq .Direction "for"}}
  • {{conf .Weight}} × cred {{conf .Credibility}} {{.Kind}} {{if .Retracted}}retracted{{end}}
    {{.Note}}
    {{.Source}} · {{ago .CreatedAt}} {{if and (eq .Kind "event") .RefID}}· event{{else if and (eq .Kind "memory") .RefID}}· memory{{else if and (eq .Kind "belief") .RefID}}· belief{{end}}
  • {{end}}{{else}}
  • None.
  • {{end}}
+
+
+

Evidence against

+
    {{range $d.Evidence}}{{if eq .Direction "against"}}
  • {{conf .Weight}} × cred {{conf .Credibility}} {{.Kind}}
    {{.Note}}
    {{.Source}} · {{ago .CreatedAt}} {{if and (eq .Kind "belief") .RefID}}· belief{{else if and (eq .Kind "event") .RefID}}· event{{end}}
  • {{end}}{{else}}
  • None.
  • {{end}}
+
+
+
+

Alternative explanations

+ {{if $d.Alternatives}}
    {{range $d.Alternatives}}
  • {{.Proposition}} ({{.Status}}{{if .Note}} · {{.Note}}{{end}})
  • {{end}}
{{else}}

None recorded.

{{end}} + {{if .Principal.User.Role.AtLeast "operator"}}
{{end}} +
+ {{if $d.Links}}

Contradictions and links

{{end}} +
+

History append-only

+ + {{range $d.Versions}}{{end}}
vStatusConfReasonWhen
{{.Version}}{{.Status}}{{conf .Confidence}}{{.Reason}}{{.CreatedAt.Local.Format "01-02 15:04:05"}}{{if .EventID}}event{{end}}
+
+
+
+ {{if .Principal.User.Role.AtLeast "operator"}} +
+

Add evidence

+
+ + + + +
+
+
+ {{if ne $b.Status "rejected"}} +
+

Correct

+
+ + + +
+
+
+ {{end}} + {{end}} +

Events

{{if $d.Origin}}

Origin: {{$d.Origin.Summary}}

{{end}}{{if $d.Events}}{{else}}

None.

{{end}}
+
ID {{$b.ID}}
+
+
+{{end}} diff --git a/web/templates/pages/beliefs.html b/web/templates/pages/beliefs.html new file mode 100644 index 0000000..7f750e1 --- /dev/null +++ b/web/templates/pages/beliefs.html @@ -0,0 +1,57 @@ +{{define "content"}} +{{$m := .Data.Mind}} +
+

{{$m.DisplayName}} beliefs

{{.Data.Stats.Total}} beliefs · {{range $k, $v := .Data.Stats.ByStatus}}{{$k}} {{$v}} {{end}}

+
+{{template "mind_tabs" (dict "M" $m "Active" "beliefs")}} +
+ + + + + Reset +
+
+
+ {{if .Data.Beliefs}} + + + {{range .Data.Beliefs}} + + + + + + {{end}} +
StatusPropositionConfidenceKindEvaluated
{{.Status}}{{.Proposition}}{{if .Subject}} {{.Subject}}{{end}} {{conf .Confidence}}{{.Epistemic}} · v{{.Version}}{{ago .LastEvaluated}}
+ {{else}}

No beliefs yet. Hypotheses form from co-occurring observations; operators can assert knowledge on the right.

{{end}} +
+
+ {{if .Principal.User.Role.AtLeast "operator"}} +
+

Assert a belief your statement is the evidence

+
+ + +
+ + +
+ +
+
+
+
+

Do not infer

+
+ + + +
+
+
+ {{end}} + {{if .Data.Corrections}}

Corrections {{len .Data.Corrections}}

    {{range .Data.Corrections}}
  • {{.Kind}} {{.Statement}}
    {{.Reason}} · {{.CreatedBy}} · {{ago .CreatedAt}}
  • {{end}}
{{end}} +
+
+{{end}} diff --git a/web/templates/pages/mind.html b/web/templates/pages/mind.html index 0aac799..b8b6a0d 100644 --- a/web/templates/pages/mind.html +++ b/web/templates/pages/mind.html @@ -42,6 +42,13 @@ +
+
+

Important beliefs {{.Data.BeliefStats.Total}} total

+ {{if .Data.Beliefs}}{{range .Data.Beliefs}}{{end}}
{{.Status}}{{.Proposition}} {{conf .Confidence}}
+ {{else}}

No beliefs yet.

{{end}} +
+

Personality brief what cognitive prompts receive

diff --git a/web/templates/partials/mind_tabs.html b/web/templates/partials/mind_tabs.html index 476f173..5858fb4 100644 --- a/web/templates/partials/mind_tabs.html +++ b/web/templates/partials/mind_tabs.html @@ -3,6 +3,6 @@ Overview Thoughts Personality - WorkspaceMemoryBeliefsConversation + WorkspaceMemoryBeliefsConversation {{end}}