feat: add beliefs with evidence-derived confidence, contradictions and corrections

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.
This commit is contained in:
Andy Dixon
2026-06-21 22:14:12 +01:00
parent 03631e5685
commit 73e5753a91
29 changed files with 2344 additions and 20 deletions
+1
View File
@@ -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.
+8 -8
View File
@@ -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
+78
View File
@@ -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 <mind> <id>|assert <mind> <text>|wrong <mind> <id> <reason> – beliefs", cmdBelief})
}
func cmdBelief(args []string) int {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: noemactl belief list --mind <mind> [-status s] [-json] | inspect <mind> <id> | assert <mind> <text...> | wrong <mind> <id> <reason...>")
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 <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
}
+8 -4
View File
@@ -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
}
+53
View File
@@ -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.
+2 -1
View File
@@ -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() })
+351
View File
@@ -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)
}
+16
View File
@@ -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)
}
}
}
+1
View File
@@ -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")
+2
View File
@@ -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
}
+253
View File
@@ -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
}
+69
View File
@@ -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")
}
}
+789
View File
@@ -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
}
+122
View File
@@ -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")
}
}
+26
View File
@@ -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 {
+4 -2
View File
@@ -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.
+76
View File
@@ -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
}
+54 -1
View File
@@ -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
+159
View File
@@ -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")
}
+5
View File
@@ -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())
}
+4 -1
View File
@@ -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) {
+9 -2
View File
@@ -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."},
+6
View File
@@ -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;
+104
View File
@@ -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();
+7
View File
@@ -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; }
+72
View File
@@ -0,0 +1,72 @@
{{define "content"}}
{{$m := .Data.Mind}}{{$d := .Data.Detail}}{{$b := $d.Belief}}{{$bd := $d.Breakdown}}
<div class="pagehead">
<p class="muted small"><a href="/minds/{{$m.Slug}}">{{$m.DisplayName}}</a> › <a href="/minds/{{$m.Slug}}/beliefs">beliefs</a></p>
<h1><span class="badge st-{{$b.Status}}">{{$b.Status}}</span> {{$b.Proposition}}</h1>
<p class="muted">{{$b.Epistemic}} · version {{$b.Version}} · first {{date $b.FirstCreated}} · evaluated {{$b.Evaluations}}× · source {{$b.Source}}{{if $b.Subject}} · subject <span class="mono">{{$b.Subject}}</span>{{end}}{{if $b.PinnedStatus}} · <span class="badge warn">status pinned by operator</span>{{end}}</p>
</div>
<section class="grid cols-3-1">
<div class="stack">
<div class="card">
<h2>Confidence {{conf $b.Confidence}} <span class="muted small">how it was computed</span></h2>
<div class="conf big"><span class="conf-bar wide"><i style="width:{{pct $b.Confidence}}"></i></span></div>
<dl class="kv small">
<dt>Prior</dt><dd>{{conf $bd.Prior}} <span class="muted">({{$b.Epistemic}})</span></dd>
<dt>Support</dt><dd>{{conf $bd.For}} <span class="muted">Σ weight × credibility over {{$bd.SourcesFor}} source(s); diversity scale {{conf $bd.DiversityScale}}</span></dd>
<dt>Opposition</dt><dd>{{conf $bd.Against}}</dd>
<dt>Log-odds</dt><dd>{{conf $bd.LogOdds}}</dd>
<dt>Formula</dt><dd class="mono">{{$bd.Formula}}</dd>
{{range $bd.Notes}}<dt>Note</dt><dd class="warn">{{.}}</dd>{{end}}
</dl>
</div>
<div class="grid cols-2">
<div class="card">
<h2>Evidence for</h2>
<ul class="plain ev">{{range $d.Evidence}}{{if eq .Direction "for"}}<li class="{{if .Retracted}}retracted{{end}}"><div><strong>{{conf .Weight}}</strong> × cred {{conf .Credibility}} <span class="badge">{{.Kind}}</span> {{if .Retracted}}<span class="badge danger">retracted</span>{{end}}</div><div>{{.Note}}</div><div class="muted small">{{.Source}} · {{ago .CreatedAt}} {{if and (eq .Kind "event") .RefID}}· <a href="/events/{{.RefID}}">event</a>{{else if and (eq .Kind "memory") .RefID}}· <a href="/minds/{{$m.Slug}}/memory/{{.RefID}}">memory</a>{{else if and (eq .Kind "belief") .RefID}}· <a href="/minds/{{$m.Slug}}/beliefs/{{.RefID}}">belief</a>{{end}}</div></li>{{end}}{{else}}<li class="muted">None.</li>{{end}}</ul>
</div>
<div class="card">
<h2>Evidence against</h2>
<ul class="plain ev">{{range $d.Evidence}}{{if eq .Direction "against"}}<li class="{{if .Retracted}}retracted{{end}}"><div><strong>{{conf .Weight}}</strong> × cred {{conf .Credibility}} <span class="badge">{{.Kind}}</span></div><div>{{.Note}}</div><div class="muted small">{{.Source}} · {{ago .CreatedAt}} {{if and (eq .Kind "belief") .RefID}}· <a href="/minds/{{$m.Slug}}/beliefs/{{.RefID}}">belief</a>{{else if and (eq .Kind "event") .RefID}}· <a href="/events/{{.RefID}}">event</a>{{end}}</div></li>{{end}}{{else}}<li class="muted">None.</li>{{end}}</ul>
</div>
</div>
<div class="card">
<h2>Alternative explanations</h2>
{{if $d.Alternatives}}<ul class="plain">{{range $d.Alternatives}}<li>{{.Proposition}} <span class="muted small">({{.Status}}{{if .Note}} · {{.Note}}{{end}})</span></li>{{end}}</ul>{{else}}<p class="muted small">None recorded.</p>{{end}}
{{if .Principal.User.Role.AtLeast "operator"}}<form method="post" action="/minds/{{$m.Slug}}/beliefs/{{$b.ID}}/alternatives" class="row"><input type="hidden" name="_csrf" value="{{.CSRF}}"><input name="proposition" placeholder="another explanation" size="40" required><button class="btn small" type="submit">Add</button></form>{{end}}
</div>
{{if $d.Links}}<div class="card"><h2>Contradictions and links</h2><ul class="plain">{{range $d.Links}}<li><span class="badge danger">{{.Relation}}</span> <a href="/minds/{{$m.Slug}}/beliefs/{{.Other.ID}}">{{.Other.Proposition}}</a> <span class="muted small">({{.Other.Status}}, {{conf .Other.Confidence}})</span></li>{{end}}</ul></div>{{end}}
<div class="card">
<h2>History <span class="muted small">append-only</span></h2>
<table class="table compact"><thead><tr><th>v</th><th>Status</th><th class="num">Conf</th><th>Reason</th><th>When</th><th></th></tr></thead>
<tbody>{{range $d.Versions}}<tr><td>{{.Version}}</td><td><span class="badge st-{{.Status}}">{{.Status}}</span></td><td class="num">{{conf .Confidence}}</td><td class="small">{{.Reason}}</td><td class="small muted nowrap">{{.CreatedAt.Local.Format "01-02 15:04:05"}}</td><td>{{if .EventID}}<a class="small" href="/events/{{.EventID}}">event</a>{{end}}</td></tr>{{end}}</tbody></table>
</div>
</div>
<div class="stack">
{{if .Principal.User.Role.AtLeast "operator"}}
<div class="card">
<h2>Add evidence</h2>
<form method="post" action="/minds/{{$m.Slug}}/beliefs/{{$b.ID}}/evidence" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Direction <select name="direction"><option value="for">for</option><option value="against">against</option></select></label>
<label>Note <input name="note" required maxlength="500" placeholder="what you observed"></label>
<label class="slider"><span>Weight</span><input type="range" name="weight" min="0.1" max="1" step="0.1" value="0.5" data-out><output>0.5</output></label>
<div><button class="btn small" type="submit">Add</button></div>
</form>
</div>
{{if ne $b.Status "rejected"}}
<div class="card">
<h2>Correct</h2>
<form method="post" action="/minds/{{$m.Slug}}/beliefs/{{$b.ID}}/retract" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Why it is wrong <input name="reason" required maxlength="500"></label>
<label class="check"><input type="checkbox" name="do_not_infer" value="1"> Do not infer this again</label>
<div><button class="btn btn-danger" type="submit" data-confirm="Mark this belief wrong? History is kept.">This belief is wrong</button></div>
</form>
</div>
{{end}}
{{end}}
<div class="card"><h2>Events</h2>{{if $d.Origin}}<p class="small">Origin: <a href="/events/{{$d.Origin.ID}}">{{$d.Origin.Summary}}</a></p>{{end}}{{if $d.Events}}<ul class="plain small">{{range $d.Events}}<li><span class="etype cat-{{.Category}}">{{.Type}}</span> <a href="/events/{{.ID}}">{{.Summary}}</a></li>{{end}}</ul>{{else}}<p class="muted small">None.</p>{{end}}</div>
<div class="card small"><span class="muted">ID</span> <span class="mono">{{$b.ID}}</span></div>
</div>
</section>
{{end}}
+57
View File
@@ -0,0 +1,57 @@
{{define "content"}}
{{$m := .Data.Mind}}
<div class="pagehead mindhead">
<div><h1>{{$m.DisplayName}} <span class="muted small">beliefs</span></h1><p class="muted">{{.Data.Stats.Total}} beliefs · {{range $k, $v := .Data.Stats.ByStatus}}<span class="badge st-{{$k}}">{{$k}} {{$v}}</span> {{end}}</p></div>
</div>
{{template "mind_tabs" (dict "M" $m "Active" "beliefs")}}
<form method="get" class="card filterbar">
<label>Search <input name="q" value="{{index .Data.Q "q"}}" size="24" placeholder="proposition contains"></label>
<label>Status <select name="status"><option value="">all live</option>{{range beliefStatuses}}<option value="{{.}}" {{if eq (index $.Data.Q "status") (print .)}}selected{{end}}>{{.}}</option>{{end}}</select></label>
<label>Min confidence <input type="number" name="min_confidence" min="0" max="1" step="0.1" value="{{index .Data.Q "min_confidence"}}" size="4"></label>
<label>Max confidence <input type="number" name="max_confidence" min="0" max="1" step="0.1" value="{{index .Data.Q "max_confidence"}}" size="4"></label>
<button class="btn" type="submit">Filter</button> <a class="btn btn-ghost" href="/minds/{{$m.Slug}}/beliefs">Reset</a>
</form>
<section class="grid cols-3-1">
<div class="card">
{{if .Data.Beliefs}}
<table class="table compact">
<thead><tr><th>Status</th><th>Proposition</th><th class="num">Confidence</th><th>Kind</th><th>Evaluated</th></tr></thead>
<tbody>{{range .Data.Beliefs}}<tr>
<td><span class="badge st-{{.Status}}">{{.Status}}</span></td>
<td><a href="/minds/{{$m.Slug}}/beliefs/{{.ID}}">{{.Proposition}}</a>{{if .Subject}} <span class="muted small mono">{{.Subject}}</span>{{end}}</td>
<td class="num"><span class="conf"><span class="conf-bar"><i style="width:{{pct .Confidence}}"></i></span> {{conf .Confidence}}</span></td>
<td class="small muted">{{.Epistemic}} · v{{.Version}}</td>
<td class="small muted nowrap">{{ago .LastEvaluated}}</td>
</tr>{{end}}</tbody>
</table>
{{else}}<p class="muted">No beliefs yet. Hypotheses form from co-occurring observations; operators can assert knowledge on the right.</p>{{end}}
</div>
<div class="stack">
{{if .Principal.User.Role.AtLeast "operator"}}
<div class="card">
<h2>Assert a belief <span class="muted small">your statement is the evidence</span></h2>
<form method="post" action="/minds/{{$m.Slug}}/beliefs" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Proposition <textarea name="proposition" rows="2" required maxlength="2000"></textarea></label>
<div class="grid cols-2 tight">
<label>Subject <input name="subject" placeholder="server-01" maxlength="80"></label>
<label>Epistemic <select name="epistemic">{{range epistemicKinds}}<option value="{{.}}" {{if eq . "claim"}}selected{{end}}>{{.}}</option>{{end}}</select></label>
</div>
<label class="slider"><span>How firmly</span><input type="range" name="weight" min="0.1" max="1" step="0.1" value="0.7" data-out><output>0.7</output></label>
<div><button class="btn" type="submit">Assert</button></div>
</form>
</div>
<div class="card">
<h2>Do not infer</h2>
<form method="post" action="/minds/{{$m.Slug}}/corrections" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Statement the mind must not conclude <textarea name="statement" rows="2" required maxlength="2000"></textarea></label>
<label>Reason <input name="reason" maxlength="500"></label>
<div><button class="btn" type="submit">Forbid inference</button></div>
</form>
</div>
{{end}}
{{if .Data.Corrections}}<div class="card"><h2>Corrections <span class="muted small">{{len .Data.Corrections}}</span></h2><ul class="plain small">{{range .Data.Corrections}}<li><span class="badge">{{.Kind}}</span> {{.Statement}}<div class="muted">{{.Reason}} · {{.CreatedBy}} · {{ago .CreatedAt}}</div></li>{{end}}</ul></div>{{end}}
</div>
</section>
{{end}}
+7
View File
@@ -42,6 +42,13 @@
</dl>
</div>
</section>
<section class="grid cols-2">
<div class="card">
<h2>Important beliefs <span class="muted small"><a href="/minds/{{$m.Slug}}/beliefs">{{.Data.BeliefStats.Total}} total</a></span></h2>
{{if .Data.Beliefs}}<table class="table compact"><tbody>{{range .Data.Beliefs}}<tr><td><span class="badge st-{{.Status}}">{{.Status}}</span></td><td><a href="/minds/{{$m.Slug}}/beliefs/{{.ID}}">{{.Proposition}}</a></td><td class="num"><span class="conf"><span class="conf-bar"><i style="width:{{pct .Confidence}}"></i></span> {{conf .Confidence}}</span></td></tr>{{end}}</tbody></table>
{{else}}<p class="muted small">No beliefs yet.</p>{{end}}
</div>
</section>
<section class="grid cols-2">
<div class="card">
<h2>Personality brief <span class="muted small">what cognitive prompts receive</span></h2>
+1 -1
View File
@@ -3,6 +3,6 @@
<a href="/minds/{{$m.Slug}}" {{if eq $a "overview"}}aria-current="page"{{end}}>Overview</a>
<a href="/minds/{{$m.Slug}}/thoughts" {{if eq $a "thoughts"}}aria-current="page"{{end}}>Thoughts</a>
<a href="/minds/{{$m.Slug}}/personality" {{if eq $a "personality"}}aria-current="page"{{end}}>Personality</a>
<a href="/minds/{{$m.Slug}}/workspace" {{if eq $a "workspace"}}aria-current="page"{{end}}>Workspace</a><a href="/minds/{{$m.Slug}}/memory" {{if eq $a "memory"}}aria-current="page"{{end}}>Memory</a><a class="disabled" title="Phase 6">Beliefs</a><a class="disabled" title="Phase 8">Conversation</a>
<a href="/minds/{{$m.Slug}}/workspace" {{if eq $a "workspace"}}aria-current="page"{{end}}>Workspace</a><a href="/minds/{{$m.Slug}}/memory" {{if eq $a "memory"}}aria-current="page"{{end}}>Memory</a><a href="/minds/{{$m.Slug}}/beliefs" {{if eq $a "beliefs"}}aria-current="page"{{end}}>Beliefs</a><a class="disabled" title="Phase 8">Conversation</a>
</nav>
{{end}}