Files
noema/internal/minds/config.go
T
Andy Dixon 03e2d7890a feat(cognition): wanting, intending and acting inside the cognitive cycle
Volition and planning now run as part of cognition (PLAN §8.2 item 3):

- A volition hook derives wants after each cycle from what it perceived
  and produced, satisfies answer wants when a reply is sent, raises due,
  overdue, expired and conflicting commitments, and expires faded wants.
  Events produced by earlier cycles are not re-derived.
- An intend process proposes deciding about the strongest undecided
  wants; the candidate competes for the workspace, so attention gates
  what the mind decides to pursue. The handler makes value tensions
  explicit (caution against disruptive writes, privacy against reading
  personal data), resolves them with recorded scores, runs deterministic
  formation with commitments, policy preview, goals and values, records
  the decision whatever it is, and plans a formed intention in the same
  transaction. Operator requests carry their own weight in formation.
- Explicit requests in conversation ("restart <unit>", "logs for <unit>",
  "fetch <url>") become wants with proposed means instead of direct
  invocations; the reply says the request was recorded and that acting
  depends on commitments and policy.
- The executor is woken after commits, runs in a scheduler-owned loop and
  reacts to the event bus: a new commitment tightens or abandons affected
  intentions (withdrawing their pending approvals), a weakened assumption
  or a finished goal triggers reconsideration. A tightened plan may reuse
  its strategy; a failed one may not.
- Metacognition gains volition checks: strong wants repeatedly set aside,
  intentions with no recorded reason, intentions a commitment forbids,
  commitments that cannot be kept, churn, repeated failed execution,
  cost outweighing value, and intentions that outlived their goal.
- A plan model role and prompt let a model propose decompositions that
  the planner validates. Ablations are a fork-only configuration field.
- Approvals required by an intention are described as coming from a
  commitment or value constraint. The expectation after an approved
  action is linked to the execution event, not the request.
- The wants.values column of the unreleased migration 0026 is renamed
  value_keys: VALUES is reserved and the generic snapshot restore does
  not quote identifiers.
- The hostile-output security test now uses a message that asks for no
  action, because an operator's "status of <unit>" is legitimately a
  read-only status check.
2026-09-15 21:04:07 +01:00

242 lines
8.7 KiB
Go

package minds
import (
"fmt"
"time"
)
// CognitiveConfig holds per-mind tunables consumed by the brain. Defaults are
// conservative; every field is exposed in the UI in later phases.
type CognitiveConfig struct {
WorkspaceCapacity int `json:"workspace_capacity"`
WorkingMemoryCapacity int `json:"working_memory_capacity"`
AttentionWeights map[string]float64 `json:"attention_weights"`
CycleMinInterval Duration `json:"cycle_min_interval"`
IdleCycleInterval Duration `json:"idle_cycle_interval"`
MaxCyclesPerMinute int `json:"max_cycles_per_minute"`
LLMCallsPerCycle int `json:"llm_calls_per_cycle"`
DailyTokenBudget int64 `json:"daily_token_budget"`
WorkingMemoryDecay float64 `json:"working_memory_decay"` // activation multiplier per cycle
LoopDetectWindow int `json:"loop_detect_window"`
LoopDetectThreshold int `json:"loop_detect_threshold"`
// Background cognition (spec §9, §18, §39). Each subsystem has a switch,
// a schedule and a model-call budget so idle minds cannot spend freely.
ConsolidationEnabled bool `json:"consolidation_enabled"`
ConsolidationInterval Duration `json:"consolidation_interval"`
ConsolidationMinEpisodes int `json:"consolidation_min_episodes"`
ConsolidationLLMCalls int `json:"consolidation_llm_calls"`
ReflectionEnabled bool `json:"reflection_enabled"`
ReflectionInterval Duration `json:"reflection_interval"`
ReflectionLLMCalls int `json:"reflection_llm_calls"`
NarrativeLLM bool `json:"narrative_llm"`
NotifyMinConfidence float64 `json:"notify_min_confidence"`
NotifyMinSalience float64 `json:"notify_min_salience"`
NotifyCooldown Duration `json:"notify_cooldown"`
// Dreaming (spec §19): off by default, confined to allowed hours, with its
// own model-call and memory budgets. Dreams can never act.
DreamingEnabled bool `json:"dreaming_enabled"`
DreamHours string `json:"dream_hours"` // "HH-HH" local, e.g. "01-06"; empty = any hour
DreamInterval Duration `json:"dream_interval"`
DreamLLMCalls int `json:"dream_llm_calls"`
DreamMaxMemories int `json:"dream_max_memories"`
DreamMaxHypotheses int `json:"dream_max_hypotheses"`
// LLMRoles overrides the daemon-wide model routing for this mind:
// role → model id. It is what lets two forks of one snapshot run on
// different providers while sharing everything else.
LLMRoles map[string]string `json:"llm_roles,omitempty"`
// Ablations disable named cognitive components in a research fork
// (docs/cognitive-research.md). Only forks may carry them.
Ablations []string `json:"ablations,omitempty"`
}
// Ablated reports whether a component is disabled in this configuration.
func (c CognitiveConfig) Ablated(component string) bool {
for _, a := range c.Ablations {
if a == component {
return true
}
}
return false
}
// AttentionFeatures is the fixed set of scoring features (spec §6). Weights are
// per mind; features are computed deterministically by the attention package.
var AttentionFeatures = []string{"salience", "urgency", "novelty", "affect", "goal_relevance", "consequence", "confidence", "contradiction", "operator_priority", "context_relevance"}
// DefaultConfig returns the baseline cognitive configuration.
func DefaultConfig() CognitiveConfig {
w := map[string]float64{"salience": 1.0, "urgency": 1.2, "novelty": 0.8, "affect": 0.6, "goal_relevance": 1.1,
"consequence": 0.9, "confidence": 0.4, "contradiction": 1.0, "operator_priority": 1.5, "context_relevance": 0.9}
return CognitiveConfig{WorkspaceCapacity: 7, WorkingMemoryCapacity: 9, AttentionWeights: w,
CycleMinInterval: Duration(500 * time.Millisecond), IdleCycleInterval: Duration(60 * time.Second), MaxCyclesPerMinute: 60,
LLMCallsPerCycle: 3, DailyTokenBudget: 500_000, WorkingMemoryDecay: 0.85, LoopDetectWindow: 20, LoopDetectThreshold: 5,
ConsolidationEnabled: true, ConsolidationInterval: Duration(6 * time.Hour), ConsolidationMinEpisodes: 3, ConsolidationLLMCalls: 5,
ReflectionEnabled: true, ReflectionInterval: Duration(12 * time.Hour), ReflectionLLMCalls: 2, NarrativeLLM: false,
NotifyMinConfidence: 0.85, NotifyMinSalience: 0.7, NotifyCooldown: Duration(time.Hour),
DreamingEnabled: false, DreamHours: "01-06", DreamInterval: Duration(24 * time.Hour), DreamLLMCalls: 2, DreamMaxMemories: 40, DreamMaxHypotheses: 5}
}
// Normalise fills gaps from defaults and clamps ranges.
func (c *CognitiveConfig) Normalise() {
d := DefaultConfig()
if c.WorkspaceCapacity < 1 || c.WorkspaceCapacity > 32 {
c.WorkspaceCapacity = d.WorkspaceCapacity
}
if c.WorkingMemoryCapacity < 1 || c.WorkingMemoryCapacity > 64 {
c.WorkingMemoryCapacity = d.WorkingMemoryCapacity
}
if c.AttentionWeights == nil {
c.AttentionWeights = map[string]float64{}
}
for _, f := range AttentionFeatures {
v, ok := c.AttentionWeights[f]
if !ok || v < 0 || v > 5 {
c.AttentionWeights[f] = d.AttentionWeights[f]
}
}
for k := range c.AttentionWeights {
if !contains(AttentionFeatures, k) {
delete(c.AttentionWeights, k)
}
}
if c.CycleMinInterval <= 0 {
c.CycleMinInterval = d.CycleMinInterval
}
if c.IdleCycleInterval <= 0 {
c.IdleCycleInterval = d.IdleCycleInterval
}
if c.MaxCyclesPerMinute < 1 || c.MaxCyclesPerMinute > 600 {
c.MaxCyclesPerMinute = d.MaxCyclesPerMinute
}
if c.LLMCallsPerCycle < 0 || c.LLMCallsPerCycle > 20 {
c.LLMCallsPerCycle = d.LLMCallsPerCycle
}
if c.DailyTokenBudget <= 0 {
c.DailyTokenBudget = d.DailyTokenBudget
}
if c.WorkingMemoryDecay <= 0 || c.WorkingMemoryDecay >= 1 {
c.WorkingMemoryDecay = d.WorkingMemoryDecay
}
if c.LoopDetectWindow < 5 {
c.LoopDetectWindow = d.LoopDetectWindow
}
if c.LoopDetectThreshold < 2 {
c.LoopDetectThreshold = d.LoopDetectThreshold
}
if c.ConsolidationInterval < Duration(10*time.Minute) {
c.ConsolidationInterval = d.ConsolidationInterval
}
if c.ConsolidationMinEpisodes < 2 {
c.ConsolidationMinEpisodes = d.ConsolidationMinEpisodes
}
if c.ConsolidationLLMCalls < 0 || c.ConsolidationLLMCalls > 50 {
c.ConsolidationLLMCalls = d.ConsolidationLLMCalls
}
if c.DreamInterval < Duration(30*time.Minute) {
c.DreamInterval = d.DreamInterval
}
if c.DreamLLMCalls < 0 || c.DreamLLMCalls > 20 {
c.DreamLLMCalls = d.DreamLLMCalls
}
if c.DreamMaxMemories < 4 || c.DreamMaxMemories > 200 {
c.DreamMaxMemories = d.DreamMaxMemories
}
if c.DreamMaxHypotheses < 1 || c.DreamMaxHypotheses > 20 {
c.DreamMaxHypotheses = d.DreamMaxHypotheses
}
if len(c.LLMRoles) > 0 {
clean := map[string]string{}
for role, model := range c.LLMRoles {
if validRole(role) && looksLikeID(model) {
clean[role] = model
}
}
c.LLMRoles = clean
}
if c.ReflectionInterval < Duration(10*time.Minute) {
c.ReflectionInterval = d.ReflectionInterval
}
if c.ReflectionLLMCalls < 0 || c.ReflectionLLMCalls > 20 {
c.ReflectionLLMCalls = d.ReflectionLLMCalls
}
if c.NotifyMinConfidence <= 0 || c.NotifyMinConfidence > 1 {
c.NotifyMinConfidence = d.NotifyMinConfidence
}
if c.NotifyMinSalience <= 0 || c.NotifyMinSalience > 1 {
c.NotifyMinSalience = d.NotifyMinSalience
}
if c.NotifyCooldown < Duration(time.Minute) {
c.NotifyCooldown = d.NotifyCooldown
}
}
func contains(xs []string, x string) bool {
for _, y := range xs {
if y == x {
return true
}
}
return false
}
// Duration marshals as a Go duration string in JSON ("500ms") for readability.
type Duration time.Duration
// MarshalJSON implements json.Marshaler.
func (d Duration) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf("%q", time.Duration(d).String())), nil
}
// UnmarshalJSON accepts a duration string or a number of nanoseconds.
func (d *Duration) UnmarshalJSON(b []byte) error {
if len(b) > 0 && b[0] == '"' {
v, err := time.ParseDuration(string(b[1 : len(b)-1]))
if err != nil {
return err
}
*d = Duration(v)
return nil
}
var n int64
if _, err := fmt.Sscan(string(b), &n); err != nil {
return err
}
*d = Duration(n)
return nil
}
// D returns the time.Duration.
func (d Duration) D() time.Duration { return time.Duration(d) }
// validRole accepts the model roles a mind may override.
func validRole(r string) bool {
switch r {
case "perceive", "respond", "hypothesise", "summarise", "judge", "narrate", "embed":
return true
}
return false
}
// looksLikeID accepts a UUID-shaped model id; anything else is dropped.
func looksLikeID(s string) bool {
if len(s) != 36 {
return false
}
for i, ch := range s {
switch {
case i == 8 || i == 13 || i == 18 || i == 23:
if ch != '-' {
return false
}
case (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'):
default:
return false
}
}
return true
}