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:
@@ -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() })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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."},
|
||||
|
||||
Reference in New Issue
Block a user