Files
noema/internal/workspace/workspace.go
T
Andy Dixon e9c1b25c80 feat: implement global workspace, working memory and the cognitive cycle
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.
2026-06-18 22:54:07 +01:00

303 lines
8.8 KiB
Go

// Package workspace implements the capacity-limited global workspace and the
// decaying working memory of a mind. Both are plain data with deterministic
// update rules; persistence is a JSON snapshot per mind.
package workspace
import (
"math"
"sort"
"strings"
"time"
"github.com/andydixon/noema/internal/attention"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Thought is an item in the workspace.
type Thought struct {
ID ids.ID `json:"id"`
Process string `json:"process"`
Kind string `json:"kind"`
Subject string `json:"subject"`
Summary string `json:"summary"`
Content map[string]any `json:"content,omitempty"`
Score float64 `json:"score"`
Breakdown attention.Breakdown `json:"breakdown"`
Activation float64 `json:"activation"` // decays each cycle it is not re-admitted
AdmittedCycle int64 `json:"admitted_cycle"`
AdmittedAt time.Time `json:"admitted_at"`
EventID ids.ID `json:"event_id,omitempty"` // thought_selected event
Evidence []events.EvidenceRef `json:"evidence,omitempty"`
Confidence float64 `json:"confidence"`
}
// Item is a working-memory entry: something the mind is currently holding.
type Item struct {
Key string `json:"key"`
Content string `json:"content"`
Kind string `json:"kind"` // observation | thought | goal | retrieval | note
Activation float64 `json:"activation"`
AddedCycle int64 `json:"added_cycle"`
TouchedAt time.Time `json:"touched_at"`
SourceEvent ids.ID `json:"source_event,omitempty"`
Ref string `json:"ref,omitempty"` // memory/belief/goal id when applicable
}
// State is the persisted snapshot.
type State struct {
Cycle int64 `json:"cycle"`
Mode string `json:"mode"`
LastCycleID ids.ID `json:"last_cycle_id,omitempty"`
LastCycleAt time.Time `json:"last_cycle_at"`
LastStimulus time.Time `json:"last_stimulus_at"`
Workspace []Thought `json:"workspace"`
WorkingMemory []Item `json:"working_memory"`
}
// Outcome records what happened to one candidate during admission.
type Outcome struct {
ID ids.ID `json:"id"`
Admitted bool `json:"admitted"`
Rank int `json:"rank"`
Total float64 `json:"total"`
Reason string `json:"reason"`
Displaced ids.ID `json:"displaced,omitempty"`
}
// Decay multiplies activation of every workspace thought and working-memory
// item by factor (0,1), simulating fading when not refreshed.
func (s *State) Decay(factor float64) {
for i := range s.Workspace {
s.Workspace[i].Activation = round4(s.Workspace[i].Activation * factor)
}
for i := range s.WorkingMemory {
s.WorkingMemory[i].Activation = round4(s.WorkingMemory[i].Activation * factor)
}
}
// Admit merges scored candidates into the workspace of the given capacity.
// Existing thoughts compete with their decayed effective score
// (score*activation); a candidate with the same subject as an existing
// thought refreshes it instead of duplicating. Returns the outcome per
// candidate and the ids of thoughts displaced. Deterministic.
func (s *State) Admit(cands []Thought, capacity int, cycle int64, now time.Time) ([]Outcome, []ids.ID) {
if capacity < 1 {
capacity = 1
}
type entry struct {
t Thought
eff float64
isNew bool
refresh bool
oldIndex int
}
entries := make([]entry, 0, len(s.Workspace)+len(cands))
for i, t := range s.Workspace {
entries = append(entries, entry{t: t, eff: round4(t.Score * t.Activation), oldIndex: i})
}
for _, c := range cands {
c.Activation = 1
c.AdmittedCycle, c.AdmittedAt = cycle, now
refreshed := false
for i := range entries {
if !entries[i].isNew && entries[i].t.Subject != "" && entries[i].t.Subject == c.Subject && entries[i].t.Kind == c.Kind {
// Refresh: keep identity of the workspace slot, take the new score.
entries[i].t.Score, entries[i].t.Breakdown, entries[i].t.Summary, entries[i].t.Content = c.Score, c.Breakdown, c.Summary, c.Content
entries[i].t.Activation, entries[i].t.Evidence, entries[i].t.Confidence = 1, c.Evidence, c.Confidence
entries[i].t.ID, entries[i].t.AdmittedCycle, entries[i].t.AdmittedAt = c.ID, cycle, now
entries[i].eff, entries[i].refresh = c.Score, true
refreshed = true
break
}
}
if !refreshed {
entries = append(entries, entry{t: c, eff: c.Score, isNew: true})
}
}
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].eff != entries[j].eff {
return entries[i].eff > entries[j].eff
}
return entries[i].t.ID < entries[j].t.ID
})
var kept []Thought
var displaced []ids.ID
outcomes := make([]Outcome, 0, len(cands))
candRank := map[ids.ID]int{}
rank := 0
for _, e := range entries {
if e.isNew || e.refresh {
rank++
candRank[e.t.ID] = rank
}
}
cutoff := math.Inf(-1)
for i, e := range entries {
if i < capacity {
kept = append(kept, e.t)
if i == capacity-1 && len(entries) > capacity {
cutoff = e.eff
}
continue
}
if !e.isNew && !e.refresh {
displaced = append(displaced, e.t.ID)
}
}
for _, c := range cands {
o := Outcome{ID: c.ID, Total: c.Score, Rank: candRank[c.ID]}
found := false
for i, k := range kept {
if k.ID == c.ID {
found = true
o.Admitted = true
if i < len(kept) {
o.Reason = "admitted"
}
break
}
}
if !found {
if math.IsInf(cutoff, -1) {
o.Reason = "rejected: outscored"
} else {
o.Reason = "rejected: below cutoff " + trim(cutoff)
}
}
outcomes = append(outcomes, o)
}
s.Workspace = kept
return outcomes, displaced
}
func trim(v float64) string {
return strings.TrimRight(strings.TrimRight(formatFloat(v), "0"), ".")
}
func formatFloat(v float64) string {
return strconvFormat(v)
}
// Hold puts or refreshes an item in working memory, evicting the least active
// item when over capacity. Returns the evicted key, if any.
func (s *State) Hold(it Item, capacity int, cycle int64, now time.Time) (evicted string) {
if capacity < 1 {
capacity = 1
}
it.Activation, it.TouchedAt = 1, now
if it.AddedCycle == 0 {
it.AddedCycle = cycle
}
for i := range s.WorkingMemory {
if s.WorkingMemory[i].Key == it.Key {
it.AddedCycle = s.WorkingMemory[i].AddedCycle
s.WorkingMemory[i] = it
return ""
}
}
s.WorkingMemory = append(s.WorkingMemory, it)
if len(s.WorkingMemory) <= capacity {
return ""
}
sort.SliceStable(s.WorkingMemory, func(i, j int) bool {
if s.WorkingMemory[i].Activation != s.WorkingMemory[j].Activation {
return s.WorkingMemory[i].Activation > s.WorkingMemory[j].Activation
}
return s.WorkingMemory[i].TouchedAt.After(s.WorkingMemory[j].TouchedAt)
})
evicted = s.WorkingMemory[len(s.WorkingMemory)-1].Key
s.WorkingMemory = s.WorkingMemory[:capacity]
return evicted
}
// Prune drops thoughts and items whose activation fell below the floor.
func (s *State) Prune(floor float64) (dropped int) {
var ws []Thought
for _, t := range s.Workspace {
if t.Activation >= floor {
ws = append(ws, t)
} else {
dropped++
}
}
s.Workspace = ws
var wm []Item
for _, it := range s.WorkingMemory {
if it.Activation >= floor {
wm = append(wm, it)
} else {
dropped++
}
}
s.WorkingMemory = wm
return dropped
}
// Top returns the highest-scoring workspace thought, if any.
func (s *State) Top() (Thought, bool) {
if len(s.Workspace) == 0 {
return Thought{}, false
}
return s.Workspace[0], true
}
// Tokens returns the lower-cased word set of working memory contents, used
// for cheap context-relevance overlap by processes.
func (s *State) Tokens() map[string]struct{} {
out := map[string]struct{}{}
for _, it := range s.WorkingMemory {
for _, w := range Tokenise(it.Content) {
out[w] = struct{}{}
}
}
for _, t := range s.Workspace {
for _, w := range Tokenise(t.Summary) {
out[w] = struct{}{}
}
}
return out
}
// Tokenise splits text into lower-case alphanumeric words of length >= 3.
func Tokenise(text string) []string {
var out []string
var cur strings.Builder
flush := func() {
if cur.Len() >= 3 {
out = append(out, cur.String())
}
cur.Reset()
}
for _, r := range strings.ToLower(text) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
cur.WriteRune(r)
} else {
flush()
}
}
flush()
return out
}
// Overlap returns |a∩b| / |a| for token sets (0 when a is empty).
func Overlap(a []string, b map[string]struct{}) float64 {
if len(a) == 0 || len(b) == 0 {
return 0
}
n := 0
seen := map[string]struct{}{}
for _, w := range a {
if _, dup := seen[w]; dup {
continue
}
seen[w] = struct{}{}
if _, ok := b[w]; ok {
n++
}
}
return round4(float64(n) / float64(len(seen)))
}
func round4(v float64) float64 { return math.Round(v*10000) / 10000 }