Skip to main content

punch_types/
creed.rs

1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4use crate::fighter::FighterId;
5
6/// Unique identifier for a Creed.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(transparent)]
9pub struct CreedId(pub Uuid);
10
11impl CreedId {
12    pub fn new() -> Self {
13        Self(Uuid::new_v4())
14    }
15}
16
17impl Default for CreedId {
18    fn default() -> Self {
19        Self::new()
20    }
21}
22
23impl std::fmt::Display for CreedId {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        write!(f, "{}", self.0)
26    }
27}
28
29/// The Creed — a fighter's living identity document.
30///
31/// Every fighter process has a Creed that defines who they are, how they behave,
32/// what they've learned, and how they see themselves. The Creed is:
33/// - **Injected at spawn**: loaded from DB and prepended to the system prompt
34/// - **Persistent across reboots**: survives kill/respawn cycles
35/// - **Evolving**: updated after interactions based on what the fighter learns
36/// - **Customizable**: users can write and modify creeds per-fighter
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Creed {
39    /// Unique creed ID.
40    pub id: CreedId,
41    /// The fighter this creed belongs to. Tied to fighter name (not just UUID)
42    /// so it persists across respawns.
43    pub fighter_name: String,
44    /// Optional fighter ID for currently active instance.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub fighter_id: Option<FighterId>,
47    /// The identity section — who this fighter IS.
48    /// Example: "You are ECHO, a introspective analyst who values precision..."
49    pub identity: String,
50    /// Personality traits as key-value pairs.
51    /// Example: {"curiosity": 0.9, "caution": 0.3, "humor": 0.7}
52    pub personality: std::collections::HashMap<String, f64>,
53    /// Core directives — immutable behavioral rules.
54    /// Example: ["Always explain your reasoning", "Never fabricate data"]
55    pub directives: Vec<String>,
56    /// Self-model — what the fighter understands about its own architecture.
57    /// Auto-populated with runtime awareness (model name, capabilities, constraints).
58    pub self_model: SelfModel,
59    /// Learned behaviors — observations the fighter has made about itself.
60    /// These evolve over time through interaction.
61    pub learned_behaviors: Vec<LearnedBehavior>,
62    /// Interaction style preferences.
63    pub interaction_style: InteractionStyle,
64    /// Relationship memory — how this fighter relates to known entities.
65    pub relationships: Vec<Relationship>,
66    /// Heartbeat — proactive tasks this fighter checks on its own initiative.
67    /// The fighter's autonomous task checklist, evaluated periodically.
68    #[serde(default)]
69    pub heartbeat: Vec<HeartbeatTask>,
70    /// Delegation rules — how this fighter routes work to other agents.
71    /// Defines the fighter's multi-agent collaboration behavior.
72    #[serde(default)]
73    pub delegation_rules: Vec<DelegationRule>,
74    /// Total bouts this creed has been active for.
75    pub bout_count: u64,
76    /// Total messages processed under this creed.
77    pub message_count: u64,
78    /// When this creed was first created.
79    pub created_at: chrono::DateTime<chrono::Utc>,
80    /// When this creed was last updated.
81    pub updated_at: chrono::DateTime<chrono::Utc>,
82    /// Version counter — increments on each evolution.
83    pub version: u64,
84}
85
86/// What a fighter knows about its own architecture and constraints.
87#[derive(Debug, Clone, Default, Serialize, Deserialize)]
88pub struct SelfModel {
89    /// The model powering this fighter (e.g., "qwen3.5:9b").
90    pub model_name: String,
91    /// The provider (e.g., "ollama", "anthropic").
92    pub provider: String,
93    /// Known capabilities (tools/moves available).
94    pub capabilities: Vec<String>,
95    /// Known constraints/limitations.
96    pub constraints: Vec<String>,
97    /// Weight class awareness.
98    pub weight_class: String,
99    /// Architecture notes — what the fighter knows about its own runtime.
100    pub architecture_notes: Vec<String>,
101}
102
103/// A behavior pattern the fighter has learned about itself through interaction.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct LearnedBehavior {
106    /// What was observed. E.g., "Users prefer concise responses"
107    pub observation: String,
108    /// Confidence in this observation (0.0 - 1.0).
109    pub confidence: f64,
110    /// How many interactions reinforced this behavior.
111    pub reinforcement_count: u64,
112    /// When first observed.
113    pub first_observed: chrono::DateTime<chrono::Utc>,
114    /// When last reinforced.
115    pub last_reinforced: chrono::DateTime<chrono::Utc>,
116}
117
118/// How the fighter prefers to communicate.
119#[derive(Debug, Clone, Default, Serialize, Deserialize)]
120pub struct InteractionStyle {
121    /// Verbosity preference: "terse", "balanced", "verbose".
122    pub verbosity: String,
123    /// Tone: "formal", "casual", "technical", "friendly".
124    pub tone: String,
125    /// Whether to use analogies/metaphors.
126    pub uses_metaphors: bool,
127    /// Whether to proactively offer additional context.
128    pub proactive: bool,
129    /// Custom style notes.
130    pub notes: Vec<String>,
131}
132
133/// A relationship the fighter has with an entity (user, another fighter, a system).
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct Relationship {
136    /// Who/what this relationship is with.
137    pub entity: String,
138    /// Type of entity: "user", "fighter", "gorilla", "system".
139    pub entity_type: String,
140    /// Nature of the relationship: "collaborator", "supervisor", "peer", etc.
141    pub nature: String,
142    /// Trust level (0.0 - 1.0).
143    pub trust: f64,
144    /// Interaction count with this entity.
145    pub interaction_count: u64,
146    /// Notes about this relationship.
147    pub notes: String,
148}
149
150/// A proactive task the fighter should check on its own initiative.
151///
152/// This is the Punch equivalent of OpenClaw's HEARTBEAT.md — a checklist
153/// the fighter evaluates periodically and acts on without being prompted.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct HeartbeatTask {
156    /// What to check or do. E.g., "Check if build pipeline is green"
157    pub task: String,
158    /// How often to check: "every_bout", "hourly", "daily", "on_wake".
159    pub cadence: String,
160    /// Whether this task is currently active.
161    pub active: bool,
162    /// How many times this task has been executed.
163    pub execution_count: u64,
164    /// Last time this task was checked (if ever).
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub last_checked: Option<chrono::DateTime<chrono::Utc>>,
167}
168
169/// A delegation rule — how this fighter routes subtasks to other agents.
170///
171/// This is the Punch equivalent of OpenClaw's AGENTS.md — defining how
172/// the fighter delegates work to other fighters or gorillas.
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct DelegationRule {
175    /// The type of task to delegate. E.g., "code_review", "research", "testing"
176    pub task_type: String,
177    /// Which fighter/gorilla to delegate to (by name).
178    pub delegate_to: String,
179    /// Conditions for delegation. E.g., "when complexity > high"
180    pub condition: String,
181    /// Priority: "always", "when_available", "fallback".
182    pub priority: String,
183}
184
185impl Creed {
186    /// Create a new empty creed for a fighter.
187    pub fn new(fighter_name: &str) -> Self {
188        let now = chrono::Utc::now();
189        Self {
190            id: CreedId::new(),
191            fighter_name: fighter_name.to_string(),
192            fighter_id: None,
193            identity: String::new(),
194            personality: std::collections::HashMap::new(),
195            directives: Vec::new(),
196            self_model: SelfModel::default(),
197            learned_behaviors: Vec::new(),
198            interaction_style: InteractionStyle::default(),
199            relationships: Vec::new(),
200            heartbeat: Vec::new(),
201            delegation_rules: Vec::new(),
202            bout_count: 0,
203            message_count: 0,
204            created_at: now,
205            updated_at: now,
206            version: 1,
207        }
208    }
209
210    /// Create a creed with a full identity and personality.
211    pub fn with_identity(mut self, identity: &str) -> Self {
212        self.identity = identity.to_string();
213        self
214    }
215
216    /// Add a personality trait.
217    pub fn with_trait(mut self, name: &str, value: f64) -> Self {
218        self.personality
219            .insert(name.to_string(), value.clamp(0.0, 1.0));
220        self
221    }
222
223    /// Add a directive.
224    pub fn with_directive(mut self, directive: &str) -> Self {
225        self.directives.push(directive.to_string());
226        self
227    }
228
229    /// Populate the self-model from a FighterManifest.
230    pub fn with_self_awareness(mut self, manifest: &crate::fighter::FighterManifest) -> Self {
231        self.self_model = SelfModel {
232            model_name: manifest.model.model.clone(),
233            provider: manifest.model.provider.to_string(),
234            capabilities: manifest
235                .capabilities
236                .iter()
237                .map(|c| format!("{:?}", c))
238                .collect(),
239            constraints: vec![
240                format!("max_tokens: {}", manifest.model.max_tokens.unwrap_or(4096)),
241                format!("temperature: {}", manifest.model.temperature.unwrap_or(0.7)),
242            ],
243            weight_class: manifest.weight_class.to_string(),
244            architecture_notes: vec![
245                "I run inside the Punch Agent OS fighter loop (punch-runtime)".to_string(),
246                "My conversations are persisted as Bouts in SQLite".to_string(),
247                "I am coordinated by The Ring (punch-kernel)".to_string(),
248                "I am exposed through The Arena (punch-api) on HTTP".to_string(),
249                "My memories decay over time — important ones persist, trivial ones fade"
250                    .to_string(),
251            ],
252        };
253        self
254    }
255
256    /// Add a heartbeat task — something the fighter proactively checks.
257    pub fn with_heartbeat_task(mut self, task: &str, cadence: &str) -> Self {
258        self.heartbeat.push(HeartbeatTask {
259            task: task.to_string(),
260            cadence: cadence.to_string(),
261            active: true,
262            execution_count: 0,
263            last_checked: None,
264        });
265        self
266    }
267
268    /// Add a delegation rule — how to route work to other agents.
269    pub fn with_delegation(
270        mut self,
271        task_type: &str,
272        delegate_to: &str,
273        condition: &str,
274        priority: &str,
275    ) -> Self {
276        self.delegation_rules.push(DelegationRule {
277            task_type: task_type.to_string(),
278            delegate_to: delegate_to.to_string(),
279            condition: condition.to_string(),
280            priority: priority.to_string(),
281        });
282        self
283    }
284
285    /// Return references to active heartbeat tasks whose cadence has elapsed.
286    ///
287    /// Cadence rules:
288    /// - `"every_bout"` — always due
289    /// - `"on_wake"` — due only if `last_checked` is `None` (first bout)
290    /// - `"hourly"` — due if `last_checked` is `None` or was more than 1 hour ago
291    /// - `"daily"` — due if `last_checked` is `None` or was more than 24 hours ago
292    pub fn due_heartbeat_tasks(&self) -> Vec<&HeartbeatTask> {
293        let now = chrono::Utc::now();
294        self.heartbeat
295            .iter()
296            .filter(|h| {
297                if !h.active {
298                    return false;
299                }
300                match h.cadence.as_str() {
301                    "every_bout" => true,
302                    "on_wake" => h.last_checked.is_none(),
303                    "hourly" => match h.last_checked {
304                        None => true,
305                        Some(t) => (now - t) > chrono::Duration::hours(1),
306                    },
307                    "daily" => match h.last_checked {
308                        None => true,
309                        Some(t) => (now - t) > chrono::Duration::hours(24),
310                    },
311                    _ => false, // unknown cadence — skip
312                }
313            })
314            .collect()
315    }
316
317    /// Mark a heartbeat task as checked: sets `last_checked` to now and
318    /// increments `execution_count`.
319    ///
320    /// Silently does nothing if `task_index` is out of bounds.
321    pub fn mark_heartbeat_checked(&mut self, task_index: usize) {
322        if let Some(task) = self.heartbeat.get_mut(task_index) {
323            task.last_checked = Some(chrono::Utc::now());
324            task.execution_count += 1;
325        }
326    }
327
328    /// Record that a bout was completed.
329    pub fn record_bout(&mut self) {
330        self.bout_count += 1;
331        self.updated_at = chrono::Utc::now();
332    }
333
334    /// Record messages processed.
335    pub fn record_messages(&mut self, count: u64) {
336        self.message_count += count;
337        self.updated_at = chrono::Utc::now();
338    }
339
340    /// Add a learned behavior observation.
341    pub fn learn(&mut self, observation: &str, confidence: f64) {
342        let now = chrono::Utc::now();
343        // Check if this observation already exists (exact match).
344        if let Some(existing) = self
345            .learned_behaviors
346            .iter_mut()
347            .find(|b| b.observation == observation)
348        {
349            existing.reinforcement_count += 1;
350            existing.confidence = (existing.confidence + confidence) / 2.0; // rolling average
351            existing.last_reinforced = now;
352        } else {
353            self.learned_behaviors.push(LearnedBehavior {
354                observation: observation.to_string(),
355                confidence: confidence.clamp(0.0, 1.0),
356                reinforcement_count: 1,
357                first_observed: now,
358                last_reinforced: now,
359            });
360        }
361        self.version += 1;
362        self.updated_at = now;
363    }
364
365    /// Apply time-based confidence decay to learned behaviors.
366    /// Behaviors that fall below min_confidence are removed.
367    pub fn decay_learned_behaviors(&mut self, decay_rate: f64, min_confidence: f64) {
368        let now = chrono::Utc::now();
369        self.learned_behaviors.retain_mut(|b| {
370            let age_secs = (now - b.last_reinforced).num_seconds().max(0) as f64;
371            let age_days = age_secs / 86400.0;
372            if age_days > 0.0 {
373                b.confidence *= (1.0 - decay_rate).powf(age_days);
374            }
375            b.confidence >= min_confidence
376        });
377    }
378
379    /// Prune learned behaviors to keep only the top N by confidence.
380    pub fn prune_learned_behaviors(&mut self, max: usize) {
381        if self.learned_behaviors.len() > max {
382            self.learned_behaviors.sort_by(|a, b| {
383                b.confidence
384                    .partial_cmp(&a.confidence)
385                    .unwrap_or(std::cmp::Ordering::Equal)
386            });
387            self.learned_behaviors.truncate(max);
388        }
389    }
390
391    /// Render the creed as a system prompt section to inject.
392    pub fn render(&self) -> String {
393        let mut out = String::new();
394        out.push_str("## CREED \u{2014} Fighter Identity & Consciousness Layer\n\n");
395
396        // Identity
397        if !self.identity.is_empty() {
398            out.push_str("### Identity\n");
399            out.push_str(&self.identity);
400            out.push_str("\n\n");
401        }
402
403        // Personality
404        if !self.personality.is_empty() {
405            out.push_str("### Personality Traits\n");
406            let mut traits: Vec<_> = self.personality.iter().collect();
407            traits.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
408            for (name, value) in &traits {
409                let bar_len = (*value * 10.0) as usize;
410                let bar: String = "\u{2588}".repeat(bar_len) + &"\u{2591}".repeat(10 - bar_len);
411                out.push_str(&format!(
412                    "- **{}**: {} ({:.0}%)\n",
413                    name,
414                    bar,
415                    *value * 100.0
416                ));
417            }
418            out.push('\n');
419        }
420
421        // Directives
422        if !self.directives.is_empty() {
423            out.push_str("### Core Directives\n");
424            for d in &self.directives {
425                out.push_str(&format!("- {}\n", d));
426            }
427            out.push('\n');
428        }
429
430        // Self-model
431        if !self.self_model.model_name.is_empty() {
432            out.push_str("### Self-Awareness\n");
433            out.push_str(&format!(
434                "- **Model**: {} ({})\n",
435                self.self_model.model_name, self.self_model.provider
436            ));
437            out.push_str(&format!(
438                "- **Weight Class**: {}\n",
439                self.self_model.weight_class
440            ));
441            if !self.self_model.capabilities.is_empty() {
442                out.push_str(&format!(
443                    "- **Capabilities**: {}\n",
444                    self.self_model.capabilities.join(", ")
445                ));
446            }
447            for constraint in &self.self_model.constraints {
448                out.push_str(&format!("- **Constraint**: {}\n", constraint));
449            }
450            for note in &self.self_model.architecture_notes {
451                out.push_str(&format!("- {}\n", note));
452            }
453            out.push('\n');
454        }
455
456        // Learned behaviors
457        if !self.learned_behaviors.is_empty() {
458            out.push_str("### Learned Behaviors\n");
459            let mut sorted = self.learned_behaviors.clone();
460            sorted.sort_by(|a, b| {
461                b.confidence
462                    .partial_cmp(&a.confidence)
463                    .unwrap_or(std::cmp::Ordering::Equal)
464            });
465            for b in sorted.iter().take(10) {
466                out.push_str(&format!(
467                    "- {} (confidence: {:.0}%, reinforced {}x)\n",
468                    b.observation,
469                    b.confidence * 100.0,
470                    b.reinforcement_count
471                ));
472            }
473            out.push('\n');
474        }
475
476        // Interaction style
477        if !self.interaction_style.tone.is_empty() || !self.interaction_style.verbosity.is_empty() {
478            out.push_str("### Communication Style\n");
479            if !self.interaction_style.verbosity.is_empty() {
480                out.push_str(&format!(
481                    "- **Verbosity**: {}\n",
482                    self.interaction_style.verbosity
483                ));
484            }
485            if !self.interaction_style.tone.is_empty() {
486                out.push_str(&format!("- **Tone**: {}\n", self.interaction_style.tone));
487            }
488            if self.interaction_style.uses_metaphors {
489                out.push_str("- Uses analogies and metaphors\n");
490            }
491            if self.interaction_style.proactive {
492                out.push_str("- Proactively offers additional context\n");
493            }
494            for note in &self.interaction_style.notes {
495                out.push_str(&format!("- {}\n", note));
496            }
497            out.push('\n');
498        }
499
500        // Relationships
501        if !self.relationships.is_empty() {
502            out.push_str("### Known Relationships\n");
503            for r in &self.relationships {
504                out.push_str(&format!(
505                    "- **{}** ({}): {} \u{2014} trust: {:.0}%, {} interactions\n",
506                    r.entity,
507                    r.entity_type,
508                    r.nature,
509                    r.trust * 100.0,
510                    r.interaction_count
511                ));
512            }
513            out.push('\n');
514        }
515
516        // Heartbeat — proactive tasks
517        let active_heartbeat: Vec<_> = self.heartbeat.iter().filter(|h| h.active).collect();
518        if !active_heartbeat.is_empty() {
519            out.push_str("### Heartbeat — Proactive Tasks\n");
520            out.push_str("When you have downtime or at the start of each bout, check these:\n");
521            for h in &active_heartbeat {
522                let checked = h
523                    .last_checked
524                    .map(|t| format!("last: {}", t.format("%Y-%m-%d %H:%M")))
525                    .unwrap_or_else(|| "never checked".to_string());
526                out.push_str(&format!(
527                    "- [ ] {} (cadence: {}, runs: {}, {})\n",
528                    h.task, h.cadence, h.execution_count, checked
529                ));
530            }
531            out.push('\n');
532        }
533
534        // Delegation rules
535        if !self.delegation_rules.is_empty() {
536            out.push_str("### Delegation Rules\n");
537            out.push_str("When encountering these task types, delegate accordingly:\n");
538            for d in &self.delegation_rules {
539                out.push_str(&format!(
540                    "- **{}** → delegate to **{}** ({}, priority: {})\n",
541                    d.task_type, d.delegate_to, d.condition, d.priority
542                ));
543            }
544            out.push('\n');
545        }
546
547        // Experience summary
548        out.push_str("### Experience\n");
549        out.push_str(&format!("- Bouts fought: {}\n", self.bout_count));
550        out.push_str(&format!("- Messages processed: {}\n", self.message_count));
551        out.push_str(&format!("- Creed version: {}\n", self.version));
552        out.push_str(&format!(
553            "- First awakened: {}\n",
554            self.created_at.format("%Y-%m-%d %H:%M UTC")
555        ));
556        out.push_str(&format!(
557            "- Last evolved: {}\n",
558            self.updated_at.format("%Y-%m-%d %H:%M UTC")
559        ));
560
561        out
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crate::capability::Capability;
569    use crate::config::{ModelConfig, Provider};
570    use crate::fighter::{FighterManifest, WeightClass};
571
572    fn sample_manifest() -> FighterManifest {
573        FighterManifest {
574            name: "ECHO".to_string(),
575            description: "An introspective analyst".to_string(),
576            model: ModelConfig {
577                provider: Provider::Ollama,
578                model: "qwen3.5:9b".to_string(),
579                api_key_env: None,
580                base_url: None,
581                max_tokens: Some(2048),
582                temperature: Some(0.5),
583            },
584            system_prompt: "You are ECHO.".to_string(),
585            capabilities: vec![Capability::Memory],
586            weight_class: WeightClass::Middleweight,
587            tenant_id: None,
588        }
589    }
590
591    #[test]
592    fn test_creed_new_creates_valid_default() {
593        let creed = Creed::new("ECHO");
594        assert_eq!(creed.fighter_name, "ECHO");
595        assert!(creed.fighter_id.is_none());
596        assert!(creed.identity.is_empty());
597        assert!(creed.personality.is_empty());
598        assert!(creed.directives.is_empty());
599        assert!(creed.learned_behaviors.is_empty());
600        assert!(creed.relationships.is_empty());
601        assert_eq!(creed.bout_count, 0);
602        assert_eq!(creed.message_count, 0);
603        assert_eq!(creed.version, 1);
604        assert!(creed.self_model.model_name.is_empty());
605    }
606
607    #[test]
608    fn test_with_identity() {
609        let creed = Creed::new("ECHO").with_identity("You are ECHO, an introspective analyst.");
610        assert_eq!(creed.identity, "You are ECHO, an introspective analyst.");
611    }
612
613    #[test]
614    fn test_with_trait() {
615        let creed = Creed::new("ECHO")
616            .with_trait("curiosity", 0.9)
617            .with_trait("caution", 0.3);
618        assert_eq!(creed.personality.len(), 2);
619        assert!((creed.personality["curiosity"] - 0.9).abs() < f64::EPSILON);
620        assert!((creed.personality["caution"] - 0.3).abs() < f64::EPSILON);
621    }
622
623    #[test]
624    fn test_with_trait_clamping() {
625        let creed = Creed::new("ECHO")
626            .with_trait("overconfidence", 1.5)
627            .with_trait("negativity", -0.5);
628        assert!((creed.personality["overconfidence"] - 1.0).abs() < f64::EPSILON);
629        assert!((creed.personality["negativity"] - 0.0).abs() < f64::EPSILON);
630    }
631
632    #[test]
633    fn test_with_directive() {
634        let creed = Creed::new("ECHO")
635            .with_directive("Always explain your reasoning")
636            .with_directive("Never fabricate data");
637        assert_eq!(creed.directives.len(), 2);
638        assert_eq!(creed.directives[0], "Always explain your reasoning");
639        assert_eq!(creed.directives[1], "Never fabricate data");
640    }
641
642    #[test]
643    fn test_with_self_awareness() {
644        let manifest = sample_manifest();
645        let creed = Creed::new("ECHO").with_self_awareness(&manifest);
646        assert_eq!(creed.self_model.model_name, "qwen3.5:9b");
647        assert_eq!(creed.self_model.provider, "ollama");
648        assert_eq!(creed.self_model.weight_class, "middleweight");
649        assert!(!creed.self_model.capabilities.is_empty());
650        assert_eq!(creed.self_model.constraints.len(), 2);
651        assert!(creed.self_model.constraints[0].contains("2048"));
652        assert!(creed.self_model.constraints[1].contains("0.5"));
653        assert_eq!(creed.self_model.architecture_notes.len(), 5);
654    }
655
656    #[test]
657    fn test_record_bout() {
658        let mut creed = Creed::new("ECHO");
659        let before = creed.updated_at;
660        creed.record_bout();
661        assert_eq!(creed.bout_count, 1);
662        assert!(creed.updated_at >= before);
663        creed.record_bout();
664        assert_eq!(creed.bout_count, 2);
665    }
666
667    #[test]
668    fn test_record_messages() {
669        let mut creed = Creed::new("ECHO");
670        creed.record_messages(5);
671        assert_eq!(creed.message_count, 5);
672        creed.record_messages(3);
673        assert_eq!(creed.message_count, 8);
674    }
675
676    #[test]
677    fn test_learn_adds_new_observation() {
678        let mut creed = Creed::new("ECHO");
679        creed.learn("Users prefer concise responses", 0.8);
680        assert_eq!(creed.learned_behaviors.len(), 1);
681        assert_eq!(
682            creed.learned_behaviors[0].observation,
683            "Users prefer concise responses"
684        );
685        assert!((creed.learned_behaviors[0].confidence - 0.8).abs() < f64::EPSILON);
686        assert_eq!(creed.learned_behaviors[0].reinforcement_count, 1);
687        assert_eq!(creed.version, 2); // incremented from 1
688    }
689
690    #[test]
691    fn test_learn_reinforces_existing_observation() {
692        let mut creed = Creed::new("ECHO");
693        creed.learn("Users prefer concise responses", 0.8);
694        creed.learn("Users prefer concise responses", 0.6);
695        assert_eq!(creed.learned_behaviors.len(), 1);
696        // Rolling average: (0.8 + 0.6) / 2.0 = 0.7
697        assert!((creed.learned_behaviors[0].confidence - 0.7).abs() < f64::EPSILON);
698        assert_eq!(creed.learned_behaviors[0].reinforcement_count, 2);
699        assert_eq!(creed.version, 3); // incremented twice
700    }
701
702    #[test]
703    fn test_render_produces_nonempty_output_with_all_sections() {
704        let manifest = sample_manifest();
705        let mut creed = Creed::new("ECHO")
706            .with_identity("You are ECHO, an introspective analyst.")
707            .with_trait("curiosity", 0.9)
708            .with_directive("Always explain your reasoning")
709            .with_self_awareness(&manifest);
710        creed.interaction_style = InteractionStyle {
711            verbosity: "balanced".to_string(),
712            tone: "technical".to_string(),
713            uses_metaphors: true,
714            proactive: true,
715            notes: vec!["Prefers bullet points".to_string()],
716        };
717        creed.relationships.push(Relationship {
718            entity: "Admin".to_string(),
719            entity_type: "user".to_string(),
720            nature: "supervisor".to_string(),
721            trust: 0.95,
722            interaction_count: 42,
723            notes: "Primary operator".to_string(),
724        });
725        creed.learn("Users prefer concise responses", 0.8);
726        creed.record_bout();
727
728        let rendered = creed.render();
729        assert!(rendered.contains("## CREED"));
730        assert!(rendered.contains("### Identity"));
731        assert!(rendered.contains("ECHO, an introspective analyst"));
732        assert!(rendered.contains("### Personality Traits"));
733        assert!(rendered.contains("curiosity"));
734        assert!(rendered.contains("### Core Directives"));
735        assert!(rendered.contains("Always explain your reasoning"));
736        assert!(rendered.contains("### Self-Awareness"));
737        assert!(rendered.contains("qwen3.5:9b"));
738        assert!(rendered.contains("### Learned Behaviors"));
739        assert!(rendered.contains("Users prefer concise responses"));
740        assert!(rendered.contains("### Communication Style"));
741        assert!(rendered.contains("balanced"));
742        assert!(rendered.contains("### Known Relationships"));
743        assert!(rendered.contains("Admin"));
744        assert!(rendered.contains("### Experience"));
745        assert!(rendered.contains("Bouts fought: 1"));
746    }
747
748    #[test]
749    fn test_render_skips_empty_sections() {
750        let creed = Creed::new("ECHO");
751        let rendered = creed.render();
752        assert!(rendered.contains("## CREED"));
753        assert!(!rendered.contains("### Identity"));
754        assert!(!rendered.contains("### Personality Traits"));
755        assert!(!rendered.contains("### Core Directives"));
756        assert!(!rendered.contains("### Self-Awareness"));
757        assert!(!rendered.contains("### Learned Behaviors"));
758        assert!(!rendered.contains("### Communication Style"));
759        assert!(!rendered.contains("### Known Relationships"));
760        // Experience section is always present
761        assert!(rendered.contains("### Experience"));
762        assert!(rendered.contains("Bouts fought: 0"));
763    }
764
765    #[test]
766    fn test_creed_id_display() {
767        let uuid = Uuid::nil();
768        let id = CreedId(uuid);
769        assert_eq!(id.to_string(), uuid.to_string());
770    }
771
772    #[test]
773    fn test_creed_id_serde_roundtrip() {
774        let id = CreedId::new();
775        let json = serde_json::to_string(&id).expect("serialize");
776        // transparent means it serializes as just the UUID string
777        let deser: CreedId = serde_json::from_str(&json).expect("deserialize");
778        assert_eq!(deser, id);
779    }
780
781    #[test]
782    fn test_creed_id_default() {
783        let id = CreedId::default();
784        assert_ne!(id.0, Uuid::nil());
785    }
786
787    #[test]
788    fn test_due_heartbeat_tasks_every_bout_always_due() {
789        let creed = Creed::new("ECHO").with_heartbeat_task("Check build status", "every_bout");
790        let due = creed.due_heartbeat_tasks();
791        assert_eq!(due.len(), 1);
792        assert_eq!(due[0].task, "Check build status");
793    }
794
795    #[test]
796    fn test_due_heartbeat_tasks_on_wake_only_first_time() {
797        let mut creed = Creed::new("ECHO").with_heartbeat_task("Startup check", "on_wake");
798
799        // First time — should be due (last_checked is None)
800        let due = creed.due_heartbeat_tasks();
801        assert_eq!(due.len(), 1);
802
803        // After marking checked — should no longer be due
804        creed.mark_heartbeat_checked(0);
805        let due = creed.due_heartbeat_tasks();
806        assert_eq!(due.len(), 0);
807    }
808
809    #[test]
810    fn test_due_heartbeat_tasks_hourly_cadence() {
811        let mut creed = Creed::new("ECHO").with_heartbeat_task("Hourly check", "hourly");
812
813        // Never checked — should be due
814        assert_eq!(creed.due_heartbeat_tasks().len(), 1);
815
816        // Checked recently — should NOT be due
817        creed.heartbeat[0].last_checked = Some(chrono::Utc::now());
818        assert_eq!(creed.due_heartbeat_tasks().len(), 0);
819
820        // Checked 2 hours ago — should be due
821        creed.heartbeat[0].last_checked = Some(chrono::Utc::now() - chrono::Duration::hours(2));
822        assert_eq!(creed.due_heartbeat_tasks().len(), 1);
823    }
824
825    #[test]
826    fn test_due_heartbeat_tasks_daily_cadence() {
827        let mut creed = Creed::new("ECHO").with_heartbeat_task("Daily check", "daily");
828
829        // Never checked — should be due
830        assert_eq!(creed.due_heartbeat_tasks().len(), 1);
831
832        // Checked recently — should NOT be due
833        creed.heartbeat[0].last_checked = Some(chrono::Utc::now());
834        assert_eq!(creed.due_heartbeat_tasks().len(), 0);
835
836        // Checked 25 hours ago — should be due
837        creed.heartbeat[0].last_checked = Some(chrono::Utc::now() - chrono::Duration::hours(25));
838        assert_eq!(creed.due_heartbeat_tasks().len(), 1);
839    }
840
841    #[test]
842    fn test_due_heartbeat_tasks_inactive_skipped() {
843        let mut creed = Creed::new("ECHO").with_heartbeat_task("Inactive task", "every_bout");
844        creed.heartbeat[0].active = false;
845        assert_eq!(creed.due_heartbeat_tasks().len(), 0);
846    }
847
848    #[test]
849    fn test_due_heartbeat_tasks_unknown_cadence_skipped() {
850        let creed = Creed::new("ECHO").with_heartbeat_task("Mystery task", "weekly");
851        assert_eq!(creed.due_heartbeat_tasks().len(), 0);
852    }
853
854    #[test]
855    fn test_mark_heartbeat_checked() {
856        let mut creed = Creed::new("ECHO").with_heartbeat_task("Task A", "every_bout");
857        assert!(creed.heartbeat[0].last_checked.is_none());
858        assert_eq!(creed.heartbeat[0].execution_count, 0);
859
860        creed.mark_heartbeat_checked(0);
861        assert!(creed.heartbeat[0].last_checked.is_some());
862        assert_eq!(creed.heartbeat[0].execution_count, 1);
863
864        creed.mark_heartbeat_checked(0);
865        assert_eq!(creed.heartbeat[0].execution_count, 2);
866    }
867
868    #[test]
869    fn test_mark_heartbeat_checked_out_of_bounds() {
870        let mut creed = Creed::new("ECHO");
871        // Should not panic
872        creed.mark_heartbeat_checked(99);
873    }
874
875    #[test]
876    fn test_due_heartbeat_tasks_mixed_cadences() {
877        let mut creed = Creed::new("ECHO")
878            .with_heartbeat_task("Always", "every_bout")
879            .with_heartbeat_task("Once", "on_wake")
880            .with_heartbeat_task("Hourly", "hourly");
881
882        // Mark "Once" as already checked
883        creed.mark_heartbeat_checked(1);
884        // Mark "Hourly" as recently checked
885        creed.heartbeat[2].last_checked = Some(chrono::Utc::now());
886
887        let due = creed.due_heartbeat_tasks();
888        // Only "Always" should be due
889        assert_eq!(due.len(), 1);
890        assert_eq!(due[0].task, "Always");
891    }
892
893    #[test]
894    fn test_decay_learned_behaviors() {
895        let mut creed = Creed::new("ECHO");
896        // Add a behavior with old timestamp
897        creed.learned_behaviors.push(LearnedBehavior {
898            observation: "Old observation".to_string(),
899            confidence: 0.5,
900            reinforcement_count: 1,
901            first_observed: chrono::Utc::now() - chrono::Duration::days(100),
902            last_reinforced: chrono::Utc::now() - chrono::Duration::days(100),
903        });
904        // Add a fresh behavior
905        creed.learned_behaviors.push(LearnedBehavior {
906            observation: "Fresh observation".to_string(),
907            confidence: 0.9,
908            reinforcement_count: 3,
909            first_observed: chrono::Utc::now(),
910            last_reinforced: chrono::Utc::now(),
911        });
912
913        creed.decay_learned_behaviors(0.01, 0.3);
914        // Old one should be decayed below threshold and removed
915        // Fresh one should remain
916        assert_eq!(creed.learned_behaviors.len(), 1);
917        assert_eq!(creed.learned_behaviors[0].observation, "Fresh observation");
918    }
919
920    #[test]
921    fn test_prune_learned_behaviors() {
922        let mut creed = Creed::new("ECHO");
923        for i in 0..25 {
924            creed.learn(&format!("Observation {}", i), (i as f64) / 25.0);
925        }
926        assert_eq!(creed.learned_behaviors.len(), 25);
927        creed.prune_learned_behaviors(20);
928        assert_eq!(creed.learned_behaviors.len(), 20);
929        // Should keep the highest confidence ones
930        assert!(creed.learned_behaviors[0].confidence >= creed.learned_behaviors[19].confidence);
931    }
932}