Phase 4 (ADR-0001). internal/attention scores thought candidates as a normalised weighted sum over ten named features with a per-term breakdown, ranked by (score, id) so ordering is total and replayable. internal/workspace holds the capacity-limited workspace (refresh by subject, decayed competition, explained admit/reject outcomes) and the decaying working memory. internal/brain runs one runtime per active mind: debounced wake-ups from the event bus (own output never re-triggers), a settle window so concurrently committing events are not skipped, bounded concurrent Observe calls with per-process isolation and timeouts, deterministic scoring, a loop detector that suppresses recurring thoughts and raises a metacognition warning, executive dispatch by thought kind, and one transaction per cycle whose events are published only after commit. Every candidate, selection, attention shift and broadcast is an event with provenance back to the triggering observation. Operators can run a cycle, pause, resume, reset transient state and change attention weights live; the manager reconciles runtimes with mind status. cognition/attend is the first process: deterministic candidates from external events using an urgency lexicon, novelty against context and trust-based operator priority.
110 lines
3.7 KiB
Go
110 lines
3.7 KiB
Go
// Package attention scores thought candidates deterministically so that the
|
|
// operator can always see why one thought won access to the workspace over
|
|
// another (ADR-0001, spec §6). There is no model call in this package.
|
|
package attention
|
|
|
|
import (
|
|
"math"
|
|
"sort"
|
|
)
|
|
|
|
// Features are the inputs to scoring, each in [0,1]. Processes fill what they
|
|
// know; unknown features stay 0 and therefore neither help nor hurt.
|
|
type Features struct {
|
|
Salience float64 `json:"salience"`
|
|
Urgency float64 `json:"urgency"`
|
|
Novelty float64 `json:"novelty"`
|
|
Affect float64 `json:"affect"` // emotional significance
|
|
GoalRelevance float64 `json:"goal_relevance"`
|
|
Consequence float64 `json:"consequence"` // predicted importance of acting
|
|
Confidence float64 `json:"confidence"`
|
|
Contradiction float64 `json:"contradiction"` // unresolved conflict
|
|
OperatorPriority float64 `json:"operator_priority"`
|
|
ContextRelevance float64 `json:"context_relevance"`
|
|
}
|
|
|
|
// Names lists feature keys in a stable order; must match minds.AttentionFeatures.
|
|
var Names = []string{"salience", "urgency", "novelty", "affect", "goal_relevance", "consequence", "confidence", "contradiction", "operator_priority", "context_relevance"}
|
|
|
|
// Map returns the features keyed by name.
|
|
func (f Features) Map() map[string]float64 {
|
|
return map[string]float64{"salience": f.Salience, "urgency": f.Urgency, "novelty": f.Novelty, "affect": f.Affect, "goal_relevance": f.GoalRelevance,
|
|
"consequence": f.Consequence, "confidence": f.Confidence, "contradiction": f.Contradiction, "operator_priority": f.OperatorPriority, "context_relevance": f.ContextRelevance}
|
|
}
|
|
|
|
// Clamp forces every feature into [0,1].
|
|
func (f Features) Clamp() Features {
|
|
c := func(v float64) float64 {
|
|
if math.IsNaN(v) || v < 0 {
|
|
return 0
|
|
}
|
|
if v > 1 {
|
|
return 1
|
|
}
|
|
return v
|
|
}
|
|
return Features{c(f.Salience), c(f.Urgency), c(f.Novelty), c(f.Affect), c(f.GoalRelevance), c(f.Consequence), c(f.Confidence), c(f.Contradiction), c(f.OperatorPriority), c(f.ContextRelevance)}
|
|
}
|
|
|
|
// Term is one weighted contribution, kept so the UI can show the arithmetic.
|
|
type Term struct {
|
|
Feature string `json:"feature"`
|
|
Value float64 `json:"value"`
|
|
Weight float64 `json:"weight"`
|
|
Contribution float64 `json:"contribution"` // value*weight / sum(weights)
|
|
}
|
|
|
|
// Breakdown is a scored candidate's explanation.
|
|
type Breakdown struct {
|
|
Total float64 `json:"total"` // in [0,1]
|
|
Terms []Term `json:"terms"`
|
|
}
|
|
|
|
// Score computes the normalised weighted sum. Missing weights count as 0, so
|
|
// a mind can switch a feature off entirely. The result is rounded to 4 places
|
|
// to keep replays byte-identical across platforms.
|
|
func Score(f Features, weights map[string]float64) Breakdown {
|
|
f = f.Clamp()
|
|
vals := f.Map()
|
|
var sum float64
|
|
for _, n := range Names {
|
|
if w := weights[n]; w > 0 {
|
|
sum += w
|
|
}
|
|
}
|
|
b := Breakdown{Terms: make([]Term, 0, len(Names))}
|
|
if sum == 0 {
|
|
return b
|
|
}
|
|
for _, n := range Names {
|
|
w := weights[n]
|
|
if w < 0 {
|
|
w = 0
|
|
}
|
|
c := round4(vals[n] * w / sum)
|
|
b.Terms = append(b.Terms, Term{Feature: n, Value: round4(vals[n]), Weight: w, Contribution: c})
|
|
b.Total += c
|
|
}
|
|
b.Total = round4(b.Total)
|
|
return b
|
|
}
|
|
|
|
func round4(v float64) float64 { return math.Round(v*10000) / 10000 }
|
|
|
|
// Scored pairs an identifier with its breakdown for ranking.
|
|
type Scored struct {
|
|
ID string
|
|
Breakdown Breakdown
|
|
}
|
|
|
|
// Rank sorts by total descending, breaking ties by ID so ordering is total and
|
|
// reproducible regardless of the order processes returned candidates.
|
|
func Rank(items []Scored) {
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
if items[i].Breakdown.Total != items[j].Breakdown.Total {
|
|
return items[i].Breakdown.Total > items[j].Breakdown.Total
|
|
}
|
|
return items[i].ID < items[j].ID
|
|
})
|
|
}
|