Skip to main content

pe_core/
cognitive.rs

1//! Cognitive architecture — optional inner subgraph for agent reasoning.
2//!
3//! When present on an [`Agent`](crate::agent::Agent), the cognitive architecture
4//! decomposes the agent's reasoning into parallel cognitive streams that process
5//! the same input through different lenses (emotional, logical, devil's advocate, etc.)
6//! and synthesize the results before the main LLM call.
7//!
8//! ## How it works
9//!
10//! - `None` → normal execution. Single LLM call, zero overhead.
11//! - `Some(CognitiveArchitecture)` → the agent carries an inner subgraph.
12//!   When a node calls the LLM, the cognitive architecture runs first:
13//!   parallel streams process the agent's self-context, a hippocampus node
14//!   synthesizes them, and the main LLM receives an enriched prompt.
15//!
16//! ## Multi-model
17//!
18//! Each stream can use a different (typically smaller/cheaper) model.
19//! The main model is always the agent's `model_preference.primary`.
20//! Streams do pre-processing — the main model gets richer input.
21//!
22//! ## Runtime wiring
23//!
24//! The types here define the cognitive architecture configuration and state.
25//! The actual inner graph execution is wired by pe-runtime (future plan).
26//! The inner graph uses the same `CompiledGraph<CognitiveState>` primitives
27//! from pe-graph — no new engine needed.
28
29use crate::cognitive_budget::CognitiveBudget;
30use crate::cognitive_memory::{MemoryConfig, WorkingNote};
31use crate::cognitive_signal::CognitiveSignal;
32use crate::lobe::LobeOutput;
33use crate::self_model::{FailureRecord, NegativeKnowledge, SelfModel};
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36
37/// Defines how an agent's reasoning is decomposed into parallel streams.
38///
39/// This is a property of the agent, not the graph. The graph is oblivious —
40/// a node calls `llm.complete()`, and the cognitive architecture intercepts
41/// to run the inner subgraph transparently.
42///
43/// # Example
44///
45/// ```ignore
46/// let cognitive = CognitiveArchitecture::new()
47///     .add_stream(CognitiveStream::new("logical", "Analyze purely logically."))
48///     .add_stream(CognitiveStream::new("antithink", "Challenge every assumption."))
49///     .with_synthesis(SynthesisStrategy::Integrate);
50///
51/// let agent = Agent::new("analyst", "You analyze data.")
52///     .with_cognitive_architecture(cognitive);
53/// ```
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct CognitiveArchitecture {
56    /// Parallel cognitive streams — each processes input through a different lens.
57    pub streams: Vec<CognitiveStream>,
58
59    /// How stream outputs are combined into the final enriched prompt.
60    pub synthesis: SynthesisStrategy,
61
62    /// Resource limits for cognitive processing.
63    #[serde(default)]
64    pub budget: CognitiveBudget,
65
66    /// Memory tier configuration.
67    #[serde(default)]
68    pub memory_config: MemoryConfig,
69
70    /// The agent's self-model (self/user/collective context).
71    #[serde(default)]
72    pub self_model: SelfModel,
73}
74
75impl CognitiveArchitecture {
76    /// Create a new cognitive architecture with no streams.
77    pub fn new() -> Self {
78        Self {
79            streams: Vec::new(),
80            synthesis: SynthesisStrategy::Integrate,
81            budget: CognitiveBudget::default(),
82            memory_config: MemoryConfig::default(),
83            self_model: SelfModel::default(),
84        }
85    }
86
87    /// Add a cognitive stream.
88    #[must_use]
89    pub fn add_stream(mut self, stream: CognitiveStream) -> Self {
90        self.streams.push(stream);
91        self
92    }
93
94    /// Set the synthesis strategy.
95    #[must_use]
96    pub fn with_synthesis(mut self, strategy: SynthesisStrategy) -> Self {
97        self.synthesis = strategy;
98        self
99    }
100
101    /// Set the cognitive budget.
102    #[must_use]
103    pub fn with_budget(mut self, budget: CognitiveBudget) -> Self {
104        self.budget = budget;
105        self
106    }
107
108    /// Set the memory configuration.
109    #[must_use]
110    pub fn with_memory_config(mut self, config: MemoryConfig) -> Self {
111        self.memory_config = config;
112        self
113    }
114
115    /// Set the self-model.
116    #[must_use]
117    pub fn with_self_model(mut self, model: SelfModel) -> Self {
118        self.self_model = model;
119        self
120    }
121}
122
123impl Default for CognitiveArchitecture {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129/// A single cognitive processing stream — one lens on the agent's identity.
130///
131/// All streams share the same agent identity, memory, and boundaries.
132/// Each stream modifies the system prompt to focus on a specific cognitive
133/// function, and can optionally use a different (typically smaller) model.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct CognitiveStream {
136    /// What cognitive function this stream handles.
137    /// Examples: "emotional", "logical", "antithink", "past-errors", "creative"
138    pub lens: String,
139
140    /// How the agent's system prompt is modified for this stream.
141    /// Appended to the agent's base system prompt.
142    pub prompt_modifier: String,
143
144    /// Optional model override — use a smaller/cheaper model for this stream.
145    /// When `None`, uses the agent's primary model.
146    /// Fast streams (haiku, local models) cost ~1% of the main model.
147    #[serde(default)]
148    pub model_override: Option<String>,
149
150    /// Stream-specific metadata.
151    #[serde(default)]
152    pub metadata: HashMap<String, serde_json::Value>,
153}
154
155impl CognitiveStream {
156    /// Create a new cognitive stream with a lens name and prompt modifier.
157    pub fn new(lens: impl Into<String>, prompt_modifier: impl Into<String>) -> Self {
158        Self {
159            lens: lens.into(),
160            prompt_modifier: prompt_modifier.into(),
161            model_override: None,
162            metadata: HashMap::new(),
163        }
164    }
165
166    /// Use a different model for this stream (e.g., a fast/cheap model).
167    #[must_use]
168    pub fn with_model(mut self, model: impl Into<String>) -> Self {
169        self.model_override = Some(model.into());
170        self
171    }
172}
173
174/// How cognitive stream outputs are combined into the final result.
175#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
176#[non_exhaustive]
177pub enum SynthesisStrategy {
178    /// LLM synthesizes all stream outputs into a unified perspective.
179    /// The hippocampus node receives all outputs and produces one result.
180    #[default]
181    Integrate,
182
183    /// Majority vote — streams produce discrete choices, most common wins.
184    Vote,
185
186    /// Weighted combination — streams have confidence scores, higher weight wins.
187    Weighted,
188}
189
190/// State for the inner cognitive subgraph.
191///
192/// Defines the fields the cognitive architecture operates on.
193/// Separate from the outer task state. Implements [`State`](crate::State)
194/// so it can be used with `CompiledGraph<CognitiveState>` directly.
195///
196/// ## Merge semantics
197///
198/// **Last-value-wins (replace):**
199/// `input`, `synthesis_result`, `budget_tokens`, `current_plan`, `confidence`
200///
201/// **Merge (HashMap extend):**
202/// `stream_outputs` — new keys overwrite, old keys preserved.
203/// Key is `CognitiveStream::lens` or `Lobe::name()`.
204///
205/// **Append (Vec extend):**
206/// `loaded_memories`, `working_notes`, `quality_trend`, `error_history`,
207/// `signals`, `negative_knowledge`, `failure_records`
208#[derive(Debug, Clone, Default, Serialize, Deserialize)]
209pub struct CognitiveState {
210    /// The input prompt/task the agent is processing.
211    pub input: String,
212
213    /// Full outputs from each cognitive lobe, keyed by lobe name.
214    ///
215    /// Stores the complete [`LobeOutput`] (content + confidence + signals + metadata)
216    /// so the synthesizer receives lossless data. The key is `Lobe::name()`.
217    #[serde(default)]
218    pub stream_outputs: HashMap<String, LobeOutput>,
219
220    /// The synthesized result from synthesis node.
221    #[serde(default)]
222    pub synthesis_result: Option<String>,
223
224    /// Token budget for cognitive processing (fraction of agent's budget).
225    #[serde(default)]
226    pub budget_tokens: Option<u32>,
227
228    /// Relevant memories loaded for this cognitive cycle.
229    #[serde(default)]
230    pub loaded_memories: Vec<String>,
231
232    // --- Working Memory ---
233    /// Agent's scratchpad — structured notes with categories.
234    #[serde(default)]
235    pub working_notes: Vec<WorkingNote>,
236
237    /// What the agent thinks it should do next.
238    #[serde(default)]
239    pub current_plan: Option<String>,
240
241    // --- Self-Awareness ---
242    /// Confidence level (0.0-1.0), fed from matrix C value when available.
243    #[serde(default)]
244    pub confidence: f64,
245
246    /// Rolling quality scores — is the agent getting better or worse?
247    #[serde(default)]
248    pub quality_trend: Vec<f64>,
249
250    /// Record of errors/failures during this session.
251    #[serde(default)]
252    pub error_history: Vec<String>,
253
254    // --- Signals ---
255    /// Signals emitted by lobes for the outer graph to read.
256    #[serde(default)]
257    pub signals: Vec<CognitiveSignal>,
258
259    // --- Structured Stores ---
260    /// Negative knowledge — things the agent learned NOT to do.
261    #[serde(default)]
262    pub negative_knowledge: Vec<NegativeKnowledge>,
263
264    /// Structured failure records for pattern recognition.
265    #[serde(default)]
266    pub failure_records: Vec<FailureRecord>,
267}
268
269/// Partial update for [`CognitiveState`].
270///
271/// Each field is `Option<T>` — `None` means "no change", `Some(v)` means "apply v".
272/// - `input`, `synthesis_result`, `budget_tokens`: last-value-wins (replace).
273/// - `stream_outputs`: merge — new entries overwrite existing keys, old keys preserved.
274/// - `loaded_memories`: append — new memories are added to the existing list.
275#[derive(Debug, Clone, Default, Serialize, Deserialize)]
276pub struct CognitiveStateUpdate {
277    /// Replace the input prompt.
278    #[serde(default)]
279    pub input: Option<String>,
280
281    /// Merge lobe outputs — new keys overwrite, existing keys preserved.
282    #[serde(default)]
283    pub stream_outputs: Option<HashMap<String, LobeOutput>>,
284
285    /// Replace the synthesis result.
286    #[serde(default)]
287    pub synthesis_result: Option<Option<String>>,
288
289    /// Replace the token budget.
290    #[serde(default)]
291    pub budget_tokens: Option<Option<u32>>,
292
293    /// Append to loaded memories.
294    #[serde(default)]
295    pub loaded_memories: Option<Vec<String>>,
296
297    /// Append working notes.
298    #[serde(default)]
299    pub working_notes: Option<Vec<WorkingNote>>,
300
301    /// Replace current plan.
302    #[serde(default)]
303    pub current_plan: Option<Option<String>>,
304
305    /// Replace confidence.
306    #[serde(default)]
307    pub confidence: Option<f64>,
308
309    /// Append quality scores.
310    #[serde(default)]
311    pub quality_trend: Option<Vec<f64>>,
312
313    /// Append error history entries.
314    #[serde(default)]
315    pub error_history: Option<Vec<String>>,
316
317    /// Append cognitive signals.
318    #[serde(default)]
319    pub signals: Option<Vec<CognitiveSignal>>,
320
321    /// Append negative knowledge entries.
322    #[serde(default)]
323    pub negative_knowledge: Option<Vec<NegativeKnowledge>>,
324
325    /// Append failure records.
326    #[serde(default)]
327    pub failure_records: Option<Vec<FailureRecord>>,
328
329    /// Replace working notes entirely (used by meditate/consolidation).
330    ///
331    /// When `Some`, the entire `working_notes` vector is replaced (not appended).
332    /// Takes precedence over `working_notes` if both are set.
333    #[serde(default)]
334    pub replace_working_notes: Option<Vec<WorkingNote>>,
335
336    /// Replace failure records entirely (used by meditate/consolidation).
337    ///
338    /// When `Some`, the entire `failure_records` vector is replaced.
339    /// Takes precedence over `failure_records` if both are set.
340    #[serde(default)]
341    pub replace_failure_records: Option<Vec<FailureRecord>>,
342
343    /// Replace negative knowledge entirely (used by meditate/consolidation).
344    ///
345    /// When `Some`, the entire `negative_knowledge` vector is replaced.
346    /// Takes precedence over `negative_knowledge` if both are set.
347    #[serde(default)]
348    pub replace_negative_knowledge: Option<Vec<NegativeKnowledge>>,
349}
350
351impl crate::state::StateUpdate for CognitiveStateUpdate {}
352
353impl crate::state::State for CognitiveState {
354    type Update = CognitiveStateUpdate;
355
356    fn apply(&mut self, update: CognitiveStateUpdate) {
357        // Last-value-wins fields
358        if let Some(input) = update.input {
359            self.input = input;
360        }
361        if let Some(synthesis) = update.synthesis_result {
362            self.synthesis_result = synthesis;
363        }
364        if let Some(budget) = update.budget_tokens {
365            self.budget_tokens = budget;
366        }
367        if let Some(plan) = update.current_plan {
368            self.current_plan = plan;
369        }
370        if let Some(confidence) = update.confidence {
371            self.confidence = confidence;
372        }
373
374        // Merge fields (HashMap extend)
375        if let Some(outputs) = update.stream_outputs {
376            self.stream_outputs.extend(outputs);
377        }
378
379        // Replace fields (meditate/consolidation) — takes precedence over append
380        if let Some(notes) = update.replace_working_notes {
381            self.working_notes = notes;
382        } else if let Some(notes) = update.working_notes {
383            self.working_notes.extend(notes);
384        }
385        if let Some(failures) = update.replace_failure_records {
386            self.failure_records = failures;
387        } else if let Some(failures) = update.failure_records {
388            self.failure_records.extend(failures);
389        }
390        if let Some(nk) = update.replace_negative_knowledge {
391            self.negative_knowledge = nk;
392        } else if let Some(nk) = update.negative_knowledge {
393            self.negative_knowledge.extend(nk);
394        }
395
396        // Append fields (Vec extend)
397        if let Some(memories) = update.loaded_memories {
398            self.loaded_memories.extend(memories);
399        }
400        if let Some(scores) = update.quality_trend {
401            self.quality_trend.extend(scores);
402        }
403        if let Some(errors) = update.error_history {
404            self.error_history.extend(errors);
405        }
406        if let Some(signals) = update.signals {
407            self.signals.extend(signals);
408        }
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::cognitive_memory::NoteCategory;
416    use crate::self_model::{FailureRecord, NegativeKnowledge};
417    use crate::state::State;
418
419    fn base_state() -> CognitiveState {
420        CognitiveState {
421            input: "analyze this".to_string(),
422            stream_outputs: HashMap::from([(
423                "logical".to_string(),
424                LobeOutput::new("logic output", 0.9).with_lobe_name("logical"),
425            )]),
426            synthesis_result: None,
427            budget_tokens: Some(1000),
428            loaded_memories: vec!["mem-1".to_string()],
429            working_notes: Vec::new(),
430            current_plan: None,
431            confidence: 0.5,
432            quality_trend: Vec::new(),
433            error_history: Vec::new(),
434            signals: Vec::new(),
435            negative_knowledge: Vec::new(),
436            failure_records: Vec::new(),
437        }
438    }
439
440    #[test]
441    fn test_apply_input_replaces() {
442        let mut state = base_state();
443        state.apply(CognitiveStateUpdate {
444            input: Some("new input".to_string()),
445            ..Default::default()
446        });
447        assert_eq!(state.input, "new input");
448        // Other fields unchanged
449        assert_eq!(state.stream_outputs.len(), 1);
450        assert_eq!(state.budget_tokens, Some(1000));
451    }
452
453    #[test]
454    fn test_apply_stream_outputs_merges() {
455        let mut state = base_state();
456        state.apply(CognitiveStateUpdate {
457            stream_outputs: Some(HashMap::from([
458                (
459                    "emotional".to_string(),
460                    LobeOutput::new("emotion output", 0.7).with_lobe_name("emotional"),
461                ),
462                (
463                    "logical".to_string(),
464                    LobeOutput::new("updated logic", 0.95).with_lobe_name("logical"),
465                ),
466            ])),
467            ..Default::default()
468        });
469        // New key added
470        assert_eq!(
471            state.stream_outputs.get("emotional").unwrap().content,
472            "emotion output"
473        );
474        // Existing key overwritten
475        assert_eq!(
476            state.stream_outputs.get("logical").unwrap().content,
477            "updated logic"
478        );
479        // Confidence preserved
480        assert!(
481            (state.stream_outputs.get("logical").unwrap().confidence - 0.95).abs() < f64::EPSILON
482        );
483        assert_eq!(state.stream_outputs.len(), 2);
484    }
485
486    #[test]
487    fn test_apply_synthesis_result_replaces() {
488        let mut state = base_state();
489        assert!(state.synthesis_result.is_none());
490
491        state.apply(CognitiveStateUpdate {
492            synthesis_result: Some(Some("synthesized answer".to_string())),
493            ..Default::default()
494        });
495        assert_eq!(
496            state.synthesis_result.as_deref(),
497            Some("synthesized answer")
498        );
499
500        // Can also clear it
501        state.apply(CognitiveStateUpdate {
502            synthesis_result: Some(None),
503            ..Default::default()
504        });
505        assert!(state.synthesis_result.is_none());
506    }
507
508    #[test]
509    fn test_apply_loaded_memories_appends() {
510        let mut state = base_state();
511        assert_eq!(state.loaded_memories, vec!["mem-1"]);
512
513        state.apply(CognitiveStateUpdate {
514            loaded_memories: Some(vec!["mem-2".to_string(), "mem-3".to_string()]),
515            ..Default::default()
516        });
517        assert_eq!(state.loaded_memories, vec!["mem-1", "mem-2", "mem-3"]);
518    }
519
520    #[test]
521    fn test_apply_none_fields_no_change() {
522        let mut state = base_state();
523        let original = state.clone();
524        state.apply(CognitiveStateUpdate::default());
525        assert_eq!(state.input, original.input);
526        assert_eq!(state.stream_outputs, original.stream_outputs);
527        assert_eq!(state.synthesis_result, original.synthesis_result);
528        assert_eq!(state.budget_tokens, original.budget_tokens);
529        assert_eq!(state.loaded_memories, original.loaded_memories);
530    }
531
532    #[test]
533    fn test_apply_multiple_fields_at_once() {
534        let mut state = base_state();
535        state.apply(CognitiveStateUpdate {
536            input: Some("new prompt".to_string()),
537            stream_outputs: Some(HashMap::from([(
538                "creative".to_string(),
539                LobeOutput::new("creative out", 0.8).with_lobe_name("creative"),
540            )])),
541            synthesis_result: Some(Some("final".to_string())),
542            budget_tokens: Some(Some(500)),
543            loaded_memories: Some(vec!["mem-4".to_string()]),
544            ..Default::default()
545        });
546        assert_eq!(state.input, "new prompt");
547        assert_eq!(state.stream_outputs.len(), 2); // logical + creative
548        assert_eq!(state.synthesis_result.as_deref(), Some("final"));
549        assert_eq!(state.budget_tokens, Some(500));
550        assert_eq!(state.loaded_memories, vec!["mem-1", "mem-4"]);
551    }
552
553    #[test]
554    fn test_apply_working_notes_appends() {
555        let mut state = base_state();
556        let note = WorkingNote::new("important finding", NoteCategory::Discovery);
557        state.apply(CognitiveStateUpdate {
558            working_notes: Some(vec![note]),
559            ..Default::default()
560        });
561        assert_eq!(state.working_notes.len(), 1);
562        assert_eq!(state.working_notes[0].content, "important finding");
563
564        // Second append
565        state.apply(CognitiveStateUpdate {
566            working_notes: Some(vec![WorkingNote::new("concern", NoteCategory::Concern)]),
567            ..Default::default()
568        });
569        assert_eq!(state.working_notes.len(), 2);
570    }
571
572    #[test]
573    fn test_apply_confidence_replaces() {
574        let mut state = base_state();
575        assert!((state.confidence - 0.5).abs() < f64::EPSILON);
576        state.apply(CognitiveStateUpdate {
577            confidence: Some(0.9),
578            ..Default::default()
579        });
580        assert!((state.confidence - 0.9).abs() < f64::EPSILON);
581    }
582
583    #[test]
584    fn test_apply_signals_appends() {
585        let mut state = base_state();
586        state.apply(CognitiveStateUpdate {
587            signals: Some(vec![CognitiveSignal::Proceed]),
588            ..Default::default()
589        });
590        state.apply(CognitiveStateUpdate {
591            signals: Some(vec![CognitiveSignal::SimplifyMode]),
592            ..Default::default()
593        });
594        assert_eq!(state.signals.len(), 2);
595    }
596
597    #[test]
598    fn test_apply_negative_knowledge_appends() {
599        use crate::self_model::Severity;
600        let mut state = base_state();
601        let nk = NegativeKnowledge::new("api", "max 100 items", Severity::High);
602        state.apply(CognitiveStateUpdate {
603            negative_knowledge: Some(vec![nk]),
604            ..Default::default()
605        });
606        assert_eq!(state.negative_knowledge.len(), 1);
607        assert_eq!(state.negative_knowledge[0].category, "api");
608    }
609
610    #[test]
611    fn test_apply_failure_records_appends() {
612        let mut state = base_state();
613        let record = FailureRecord::new("db_migration", "ALTER TABLE");
614        state.apply(CognitiveStateUpdate {
615            failure_records: Some(vec![record]),
616            ..Default::default()
617        });
618        assert_eq!(state.failure_records.len(), 1);
619    }
620
621    #[test]
622    fn test_cognitive_state_update_serialization() {
623        let update = CognitiveStateUpdate {
624            input: Some("test".to_string()),
625            ..Default::default()
626        };
627        let json = serde_json::to_string(&update).unwrap();
628        let deserialized: CognitiveStateUpdate = serde_json::from_str(&json).unwrap();
629        assert_eq!(deserialized.input.as_deref(), Some("test"));
630        assert!(deserialized.stream_outputs.is_none());
631    }
632
633    #[test]
634    fn test_replace_working_notes_overrides_existing() {
635        let mut state = base_state();
636        // First add some notes via append.
637        state.apply(CognitiveStateUpdate {
638            working_notes: Some(vec![
639                WorkingNote::new("old note 1", NoteCategory::Observation),
640                WorkingNote::new("old note 2", NoteCategory::Concern),
641                WorkingNote::new("old note 3", NoteCategory::Discovery),
642            ]),
643            ..Default::default()
644        });
645        assert_eq!(state.working_notes.len(), 3);
646
647        // Now replace with a smaller set (meditate consolidation).
648        state.apply(CognitiveStateUpdate {
649            replace_working_notes: Some(vec![WorkingNote::new(
650                "consolidated",
651                NoteCategory::Reflection,
652            )]),
653            ..Default::default()
654        });
655        assert_eq!(state.working_notes.len(), 1);
656        assert_eq!(state.working_notes[0].content, "consolidated");
657    }
658
659    #[test]
660    fn test_replace_takes_precedence_over_append() {
661        let mut state = base_state();
662        state.apply(CognitiveStateUpdate {
663            working_notes: Some(vec![WorkingNote::new("old", NoteCategory::Observation)]),
664            ..Default::default()
665        });
666        assert_eq!(state.working_notes.len(), 1);
667
668        // Both replace and append set — replace wins.
669        state.apply(CognitiveStateUpdate {
670            working_notes: Some(vec![WorkingNote::new("appended", NoteCategory::Concern)]),
671            replace_working_notes: Some(vec![WorkingNote::new("replaced", NoteCategory::Plan)]),
672            ..Default::default()
673        });
674        assert_eq!(state.working_notes.len(), 1);
675        assert_eq!(state.working_notes[0].content, "replaced");
676    }
677}