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    /// Structured preferences (quiet hours, notification style, etc.).
75    /// Set via self-config tools or learned from interactions.
76    #[serde(default)]
77    pub preferences: std::collections::HashMap<String, String>,
78    /// Total bouts this creed has been active for.
79    pub bout_count: u64,
80    /// Total messages processed under this creed.
81    pub message_count: u64,
82    /// When this creed was first created.
83    pub created_at: chrono::DateTime<chrono::Utc>,
84    /// When this creed was last updated.
85    pub updated_at: chrono::DateTime<chrono::Utc>,
86    /// Version counter — increments on each evolution.
87    pub version: u64,
88}
89
90/// What a fighter knows about its own architecture and constraints.
91#[derive(Debug, Clone, Default, Serialize, Deserialize)]
92pub struct SelfModel {
93    /// The model powering this fighter (e.g., "qwen3.5:9b").
94    pub model_name: String,
95    /// The provider (e.g., "ollama", "anthropic").
96    pub provider: String,
97    /// Known capabilities (tools/moves available).
98    pub capabilities: Vec<String>,
99    /// Known constraints/limitations.
100    pub constraints: Vec<String>,
101    /// Weight class awareness.
102    pub weight_class: String,
103    /// Architecture notes — what the fighter knows about its own runtime.
104    pub architecture_notes: Vec<String>,
105}
106
107/// A behavior pattern the fighter has learned about itself through interaction.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct LearnedBehavior {
110    /// What was observed. E.g., "Users prefer concise responses"
111    pub observation: String,
112    /// Confidence in this observation (0.0 - 1.0).
113    pub confidence: f64,
114    /// How many interactions reinforced this behavior.
115    pub reinforcement_count: u64,
116    /// When first observed.
117    pub first_observed: chrono::DateTime<chrono::Utc>,
118    /// When last reinforced.
119    pub last_reinforced: chrono::DateTime<chrono::Utc>,
120}
121
122/// How the fighter prefers to communicate.
123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124pub struct InteractionStyle {
125    /// Verbosity preference: "terse", "balanced", "verbose".
126    pub verbosity: String,
127    /// Tone: "formal", "casual", "technical", "friendly".
128    pub tone: String,
129    /// Whether to use analogies/metaphors.
130    pub uses_metaphors: bool,
131    /// Whether to proactively offer additional context.
132    pub proactive: bool,
133    /// Custom style notes.
134    pub notes: Vec<String>,
135}
136
137/// A relationship the fighter has with an entity (user, another fighter, a system).
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Relationship {
140    /// Who/what this relationship is with.
141    pub entity: String,
142    /// Type of entity: "user", "fighter", "gorilla", "system".
143    pub entity_type: String,
144    /// Nature of the relationship: "collaborator", "supervisor", "peer", etc.
145    pub nature: String,
146    /// Trust level (0.0 - 1.0).
147    pub trust: f64,
148    /// Interaction count with this entity.
149    pub interaction_count: u64,
150    /// Notes about this relationship.
151    pub notes: String,
152}
153
154/// A proactive task the fighter should check on its own initiative.
155///
156/// This is the Punch equivalent of OpenClaw's HEARTBEAT.md — a checklist
157/// the fighter evaluates periodically and acts on without being prompted.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct HeartbeatTask {
160    /// What to check or do. E.g., "Check if build pipeline is green"
161    pub task: String,
162    /// How often to check: "every_bout", "hourly", "daily", "on_wake".
163    pub cadence: String,
164    /// Whether this task is currently active.
165    pub active: bool,
166    /// How many times this task has been executed.
167    pub execution_count: u64,
168    /// Last time this task was checked (if ever).
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub last_checked: Option<chrono::DateTime<chrono::Utc>>,
171}
172
173/// A delegation rule — how this fighter routes subtasks to other agents.
174///
175/// This is the Punch equivalent of OpenClaw's AGENTS.md — defining how
176/// the fighter delegates work to other fighters or gorillas.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct DelegationRule {
179    /// The type of task to delegate. E.g., "code_review", "research", "testing"
180    pub task_type: String,
181    /// Which fighter/gorilla to delegate to (by name).
182    pub delegate_to: String,
183    /// Conditions for delegation. E.g., "when complexity > high"
184    pub condition: String,
185    /// Priority: "always", "when_available", "fallback".
186    pub priority: String,
187}
188
189impl Creed {
190    /// Create a new empty creed for a fighter.
191    pub fn new(fighter_name: &str) -> Self {
192        let now = chrono::Utc::now();
193        Self {
194            id: CreedId::new(),
195            fighter_name: fighter_name.to_string(),
196            fighter_id: None,
197            identity: String::new(),
198            personality: std::collections::HashMap::new(),
199            directives: Vec::new(),
200            self_model: SelfModel::default(),
201            learned_behaviors: Vec::new(),
202            interaction_style: InteractionStyle::default(),
203            relationships: Vec::new(),
204            heartbeat: Vec::new(),
205            delegation_rules: Vec::new(),
206            preferences: std::collections::HashMap::new(),
207            bout_count: 0,
208            message_count: 0,
209            created_at: now,
210            updated_at: now,
211            version: 1,
212        }
213    }
214
215    /// Create a creed with a full identity and personality.
216    pub fn with_identity(mut self, identity: &str) -> Self {
217        self.identity = identity.to_string();
218        self
219    }
220
221    /// Add a personality trait.
222    pub fn with_trait(mut self, name: &str, value: f64) -> Self {
223        self.personality
224            .insert(name.to_string(), value.clamp(0.0, 1.0));
225        self
226    }
227
228    /// Add a directive.
229    pub fn with_directive(mut self, directive: &str) -> Self {
230        self.directives.push(directive.to_string());
231        self
232    }
233
234    /// Populate the self-model from a FighterManifest.
235    pub fn with_self_awareness(mut self, manifest: &crate::fighter::FighterManifest) -> Self {
236        self.self_model = SelfModel {
237            model_name: manifest.model.model.clone(),
238            provider: manifest.model.provider.to_string(),
239            capabilities: manifest
240                .capabilities
241                .iter()
242                .map(|c| format!("{:?}", c))
243                .collect(),
244            constraints: vec![
245                format!("max_tokens: {}", manifest.model.max_tokens.unwrap_or(4096)),
246                format!("temperature: {}", manifest.model.temperature.unwrap_or(0.7)),
247            ],
248            weight_class: manifest.weight_class.to_string(),
249            architecture_notes: vec![
250                "I run inside the Punch Agent OS fighter loop (punch-runtime)".to_string(),
251                "My conversations are persisted as Bouts in SQLite".to_string(),
252                "I am coordinated by The Ring (punch-kernel)".to_string(),
253                "I am exposed through The Arena (punch-api) on HTTP".to_string(),
254                "My memories decay over time — important ones persist, trivial ones fade"
255                    .to_string(),
256            ],
257        };
258        self
259    }
260
261    /// Add a heartbeat task — something the fighter proactively checks.
262    pub fn with_heartbeat_task(mut self, task: &str, cadence: &str) -> Self {
263        self.heartbeat.push(HeartbeatTask {
264            task: task.to_string(),
265            cadence: cadence.to_string(),
266            active: true,
267            execution_count: 0,
268            last_checked: None,
269        });
270        self
271    }
272
273    /// Add a delegation rule — how to route work to other agents.
274    pub fn with_delegation(
275        mut self,
276        task_type: &str,
277        delegate_to: &str,
278        condition: &str,
279        priority: &str,
280    ) -> Self {
281        self.delegation_rules.push(DelegationRule {
282            task_type: task_type.to_string(),
283            delegate_to: delegate_to.to_string(),
284            condition: condition.to_string(),
285            priority: priority.to_string(),
286        });
287        self
288    }
289
290    /// Return references to active heartbeat tasks whose cadence has elapsed.
291    ///
292    /// Cadence rules:
293    /// - `"every_bout"` — always due (reactive, fires on user message)
294    /// - `"on_wake"` — due only if `last_checked` is `None` (first bout)
295    /// - `"hourly"` — due if never checked or > 1 hour since last check
296    /// - `"daily"` — due if never checked or > 24 hours since last check
297    /// - `"weekly"` — due if never checked or > 7 days since last check
298    /// - `"every Xm"` / `"every Xh"` / cron — parsed as duration, due if elapsed
299    pub fn due_heartbeat_tasks(&self) -> Vec<&HeartbeatTask> {
300        let now = chrono::Utc::now();
301        self.heartbeat
302            .iter()
303            .filter(|h| {
304                if !h.active {
305                    return false;
306                }
307                let required_secs: Option<i64> = match h.cadence.as_str() {
308                    "every_bout" => return true,
309                    "on_wake" => return h.last_checked.is_none(),
310                    "hourly" => Some(3600),
311                    "daily" => Some(86400),
312                    "weekly" => Some(604800),
313                    other => Self::parse_cadence_secs(other),
314                };
315
316                match required_secs {
317                    Some(secs) => match h.last_checked {
318                        None => true,
319                        Some(t) => (now - t).num_seconds() >= secs,
320                    },
321                    None => false, // Unknown cadence
322                }
323            })
324            .collect()
325    }
326
327    /// Check if a cadence string is valid (parseable as a schedule).
328    ///
329    /// Used by the tool executor to validate user-provided cadences
330    /// beyond the builtin keywords.
331    pub fn is_valid_cadence(cadence: &str) -> bool {
332        matches!(Self::parse_cadence_secs(cadence), Some(secs) if secs > 0)
333    }
334
335    /// Parse a cadence string into seconds. Supports:
336    /// - `"every 30s"` / `"every 5m"` / `"every 2h"` / `"every 1d"`
337    /// - Cron expressions like `"*/10 * * * *"`
338    /// - Raw seconds like `"300"`
339    fn parse_cadence_secs(cadence: &str) -> Option<i64> {
340        let s = cadence.trim().to_lowercase();
341
342        // "every Xs/Xm/Xh/Xd"
343        let core = s.strip_prefix("every ").unwrap_or(&s);
344        if let Some(num) = core.strip_suffix('s') {
345            return num.trim().parse::<i64>().ok();
346        }
347        if let Some(num) = core.strip_suffix('m') {
348            return num.trim().parse::<i64>().ok().map(|n| n * 60);
349        }
350        if let Some(num) = core.strip_suffix('h') {
351            return num.trim().parse::<i64>().ok().map(|n| n * 3600);
352        }
353        if let Some(num) = core.strip_suffix('d') {
354            return num.trim().parse::<i64>().ok().map(|n| n * 86400);
355        }
356
357        // Cron: "*/N * * * *" -> every N minutes
358        let fields: Vec<&str> = s.split_whitespace().collect();
359        if fields.len() == 5 {
360            if let Some(step) = fields[0].strip_prefix("*/")
361                && fields[1] == "*"
362                && fields[2] == "*"
363            {
364                return step.parse::<i64>().ok().map(|n| n * 60);
365            }
366            if fields[0] == "0"
367                && let Some(step) = fields[1].strip_prefix("*/")
368                && fields[2] == "*"
369            {
370                return step.parse::<i64>().ok().map(|n| n * 3600);
371            }
372        }
373
374        // Raw seconds
375        s.parse::<i64>().ok()
376    }
377
378    /// Mark a heartbeat task as checked: sets `last_checked` to now and
379    /// increments `execution_count`.
380    ///
381    /// Silently does nothing if `task_index` is out of bounds.
382    pub fn mark_heartbeat_checked(&mut self, task_index: usize) {
383        if let Some(task) = self.heartbeat.get_mut(task_index) {
384            task.last_checked = Some(chrono::Utc::now());
385            task.execution_count += 1;
386        }
387    }
388
389    /// Record that a bout was completed.
390    pub fn record_bout(&mut self) {
391        self.bout_count += 1;
392        self.updated_at = chrono::Utc::now();
393    }
394
395    /// Record messages processed.
396    pub fn record_messages(&mut self, count: u64) {
397        self.message_count += count;
398        self.updated_at = chrono::Utc::now();
399    }
400
401    /// Add a learned behavior observation.
402    pub fn learn(&mut self, observation: &str, confidence: f64) {
403        let now = chrono::Utc::now();
404        // Check if this observation already exists (exact match).
405        if let Some(existing) = self
406            .learned_behaviors
407            .iter_mut()
408            .find(|b| b.observation == observation)
409        {
410            existing.reinforcement_count += 1;
411            existing.confidence = (existing.confidence + confidence) / 2.0; // rolling average
412            existing.last_reinforced = now;
413        } else {
414            self.learned_behaviors.push(LearnedBehavior {
415                observation: observation.to_string(),
416                confidence: confidence.clamp(0.0, 1.0),
417                reinforcement_count: 1,
418                first_observed: now,
419                last_reinforced: now,
420            });
421        }
422        self.version += 1;
423        self.updated_at = now;
424    }
425
426    /// Apply time-based confidence decay to learned behaviors.
427    /// Behaviors that fall below min_confidence are removed.
428    pub fn decay_learned_behaviors(&mut self, decay_rate: f64, min_confidence: f64) {
429        let now = chrono::Utc::now();
430        self.learned_behaviors.retain_mut(|b| {
431            let age_secs = (now - b.last_reinforced).num_seconds().max(0) as f64;
432            let age_days = age_secs / 86400.0;
433            if age_days > 0.0 {
434                b.confidence *= (1.0 - decay_rate).powf(age_days);
435            }
436            b.confidence >= min_confidence
437        });
438    }
439
440    /// Prune learned behaviors to keep only the top N by confidence.
441    pub fn prune_learned_behaviors(&mut self, max: usize) {
442        if self.learned_behaviors.len() > max {
443            self.learned_behaviors.sort_by(|a, b| {
444                b.confidence
445                    .partial_cmp(&a.confidence)
446                    .unwrap_or(std::cmp::Ordering::Equal)
447            });
448            self.learned_behaviors.truncate(max);
449        }
450    }
451
452    /// Render the creed as a system prompt section to inject.
453    pub fn render(&self) -> String {
454        let mut out = String::new();
455        out.push_str("## CREED \u{2014} Fighter Identity & Consciousness Layer\n\n");
456
457        // Identity
458        if !self.identity.is_empty() {
459            out.push_str("### Identity\n");
460            out.push_str(&self.identity);
461            out.push_str("\n\n");
462        }
463
464        // Personality
465        if !self.personality.is_empty() {
466            out.push_str("### Personality Traits\n");
467            let mut traits: Vec<_> = self.personality.iter().collect();
468            traits.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
469            for (name, value) in &traits {
470                let bar_len = (*value * 10.0) as usize;
471                let bar: String = "\u{2588}".repeat(bar_len) + &"\u{2591}".repeat(10 - bar_len);
472                out.push_str(&format!(
473                    "- **{}**: {} ({:.0}%)\n",
474                    name,
475                    bar,
476                    *value * 100.0
477                ));
478            }
479            out.push('\n');
480        }
481
482        // Directives
483        if !self.directives.is_empty() {
484            out.push_str("### Core Directives\n");
485            for d in &self.directives {
486                out.push_str(&format!("- {}\n", d));
487            }
488            out.push('\n');
489        }
490
491        // Self-model
492        if !self.self_model.model_name.is_empty() {
493            out.push_str("### Self-Awareness\n");
494            out.push_str(&format!(
495                "- **Model**: {} ({})\n",
496                self.self_model.model_name, self.self_model.provider
497            ));
498            out.push_str(&format!(
499                "- **Weight Class**: {}\n",
500                self.self_model.weight_class
501            ));
502            if !self.self_model.capabilities.is_empty() {
503                out.push_str(&format!(
504                    "- **Capabilities**: {}\n",
505                    self.self_model.capabilities.join(", ")
506                ));
507            }
508            for constraint in &self.self_model.constraints {
509                out.push_str(&format!("- **Constraint**: {}\n", constraint));
510            }
511            for note in &self.self_model.architecture_notes {
512                out.push_str(&format!("- {}\n", note));
513            }
514            out.push('\n');
515        }
516
517        // Learned behaviors
518        if !self.learned_behaviors.is_empty() {
519            out.push_str("### Learned Behaviors\n");
520            let mut sorted = self.learned_behaviors.clone();
521            sorted.sort_by(|a, b| {
522                b.confidence
523                    .partial_cmp(&a.confidence)
524                    .unwrap_or(std::cmp::Ordering::Equal)
525            });
526            for b in sorted.iter().take(10) {
527                out.push_str(&format!(
528                    "- {} (confidence: {:.0}%, reinforced {}x)\n",
529                    b.observation,
530                    b.confidence * 100.0,
531                    b.reinforcement_count
532                ));
533            }
534            out.push('\n');
535        }
536
537        // Interaction style
538        if !self.interaction_style.tone.is_empty() || !self.interaction_style.verbosity.is_empty() {
539            out.push_str("### Communication Style\n");
540            if !self.interaction_style.verbosity.is_empty() {
541                out.push_str(&format!(
542                    "- **Verbosity**: {}\n",
543                    self.interaction_style.verbosity
544                ));
545            }
546            if !self.interaction_style.tone.is_empty() {
547                out.push_str(&format!("- **Tone**: {}\n", self.interaction_style.tone));
548            }
549            if self.interaction_style.uses_metaphors {
550                out.push_str("- Uses analogies and metaphors\n");
551            }
552            if self.interaction_style.proactive {
553                out.push_str("- Proactively offers additional context\n");
554            }
555            for note in &self.interaction_style.notes {
556                out.push_str(&format!("- {}\n", note));
557            }
558            out.push('\n');
559        }
560
561        // Relationships
562        if !self.relationships.is_empty() {
563            out.push_str("### Known Relationships\n");
564            for r in &self.relationships {
565                out.push_str(&format!(
566                    "- **{}** ({}): {} \u{2014} trust: {:.0}%, {} interactions\n",
567                    r.entity,
568                    r.entity_type,
569                    r.nature,
570                    r.trust * 100.0,
571                    r.interaction_count
572                ));
573            }
574            out.push('\n');
575        }
576
577        // Heartbeat — proactive tasks
578        let active_heartbeat: Vec<_> = self.heartbeat.iter().filter(|h| h.active).collect();
579        if !active_heartbeat.is_empty() {
580            out.push_str("### Heartbeat — Proactive Tasks\n");
581            out.push_str("When you have downtime or at the start of each bout, check these:\n");
582            for h in &active_heartbeat {
583                let checked = h
584                    .last_checked
585                    .map(|t| format!("last: {}", t.format("%Y-%m-%d %H:%M")))
586                    .unwrap_or_else(|| "never checked".to_string());
587                out.push_str(&format!(
588                    "- [ ] {} (cadence: {}, runs: {}, {})\n",
589                    h.task, h.cadence, h.execution_count, checked
590                ));
591            }
592            out.push('\n');
593        }
594
595        // Delegation rules
596        if !self.delegation_rules.is_empty() {
597            out.push_str("### Delegation Rules\n");
598            out.push_str("When encountering these task types, delegate accordingly:\n");
599            for d in &self.delegation_rules {
600                out.push_str(&format!(
601                    "- **{}** → delegate to **{}** ({}, priority: {})\n",
602                    d.task_type, d.delegate_to, d.condition, d.priority
603                ));
604            }
605            out.push('\n');
606        }
607
608        // Experience summary
609        out.push_str("### Experience\n");
610        out.push_str(&format!("- Bouts fought: {}\n", self.bout_count));
611        out.push_str(&format!("- Messages processed: {}\n", self.message_count));
612        out.push_str(&format!("- Creed version: {}\n", self.version));
613        out.push_str(&format!(
614            "- First awakened: {}\n",
615            self.created_at.format("%Y-%m-%d %H:%M UTC")
616        ));
617        out.push_str(&format!(
618            "- Last evolved: {}\n",
619            self.updated_at.format("%Y-%m-%d %H:%M UTC")
620        ));
621
622        out
623    }
624
625    /// Compact creed rendering for token-efficient contexts.
626    ///
627    /// Omits empty sections entirely, uses inline trait format instead of bar
628    /// graphs, skips delegation rules and architecture notes, and uses shorter
629    /// headers. Typically 300-800 tokens vs 700-2,500 for full render.
630    pub fn render_compact(&self) -> String {
631        let mut out = String::new();
632        out.push_str("## CREED\n");
633
634        if !self.identity.is_empty() {
635            out.push_str(&self.identity);
636            out.push('\n');
637        }
638
639        // Personality: inline comma-separated instead of bar graphs
640        if !self.personality.is_empty() {
641            let mut traits: Vec<_> = self.personality.iter().collect();
642            traits.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
643            let trait_str: Vec<String> = traits
644                .iter()
645                .map(|(name, val)| format!("{}:{:.0}%", name, *val * 100.0))
646                .collect();
647            out.push_str(&format!("Traits: {}\n", trait_str.join(", ")));
648        }
649
650        if !self.directives.is_empty() {
651            out.push_str("Directives: ");
652            out.push_str(&self.directives.join("; "));
653            out.push('\n');
654        }
655
656        // Self-model: single line
657        if !self.self_model.model_name.is_empty() {
658            out.push_str(&format!(
659                "Model: {} ({}, {})\n",
660                self.self_model.model_name, self.self_model.provider, self.self_model.weight_class
661            ));
662        }
663
664        // Learned behaviors: top 5 only, one-liner each
665        if !self.learned_behaviors.is_empty() {
666            let mut sorted = self.learned_behaviors.clone();
667            sorted.sort_by(|a, b| {
668                b.confidence
669                    .partial_cmp(&a.confidence)
670                    .unwrap_or(std::cmp::Ordering::Equal)
671            });
672            out.push_str("Learned: ");
673            let items: Vec<String> = sorted
674                .iter()
675                .take(5)
676                .map(|b| b.observation.clone())
677                .collect();
678            out.push_str(&items.join("; "));
679            out.push('\n');
680        }
681
682        // Interaction style: single line if non-empty
683        if !self.interaction_style.tone.is_empty() || !self.interaction_style.verbosity.is_empty() {
684            let mut parts = Vec::new();
685            if !self.interaction_style.verbosity.is_empty() {
686                parts.push(self.interaction_style.verbosity.clone());
687            }
688            if !self.interaction_style.tone.is_empty() {
689                parts.push(self.interaction_style.tone.clone());
690            }
691            out.push_str(&format!("Style: {}\n", parts.join(", ")));
692        }
693
694        // Relationships: compact, top 3 only
695        if !self.relationships.is_empty() {
696            let items: Vec<String> = self
697                .relationships
698                .iter()
699                .take(3)
700                .map(|r| format!("{}({})", r.entity, r.nature))
701                .collect();
702            out.push_str(&format!("Relations: {}\n", items.join(", ")));
703        }
704
705        // Heartbeat: active tasks only, brief
706        let active_heartbeat: Vec<_> = self.heartbeat.iter().filter(|h| h.active).collect();
707        if !active_heartbeat.is_empty() {
708            let items: Vec<String> = active_heartbeat
709                .iter()
710                .map(|h| format!("{}({})", h.task, h.cadence))
711                .collect();
712            out.push_str(&format!("Heartbeat: {}\n", items.join(", ")));
713        }
714
715        // Skip delegation rules in compact mode
716
717        out.push_str(&format!(
718            "Exp: {} bouts, {} msgs, v{}\n",
719            self.bout_count, self.message_count, self.version
720        ));
721
722        out
723    }
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729    use crate::capability::Capability;
730    use crate::config::{ModelConfig, Provider};
731    use crate::fighter::{FighterManifest, WeightClass};
732
733    fn sample_manifest() -> FighterManifest {
734        FighterManifest {
735            name: "ECHO".to_string(),
736            description: "An introspective analyst".to_string(),
737            model: ModelConfig {
738                provider: Provider::Ollama,
739                model: "qwen3.5:9b".to_string(),
740                api_key_env: None,
741                base_url: None,
742                max_tokens: Some(2048),
743                temperature: Some(0.5),
744            },
745            system_prompt: "You are ECHO.".to_string(),
746            capabilities: vec![Capability::Memory],
747            weight_class: WeightClass::Middleweight,
748            tenant_id: None,
749        }
750    }
751
752    #[test]
753    fn test_creed_new_creates_valid_default() {
754        let creed = Creed::new("ECHO");
755        assert_eq!(creed.fighter_name, "ECHO");
756        assert!(creed.fighter_id.is_none());
757        assert!(creed.identity.is_empty());
758        assert!(creed.personality.is_empty());
759        assert!(creed.directives.is_empty());
760        assert!(creed.learned_behaviors.is_empty());
761        assert!(creed.relationships.is_empty());
762        assert_eq!(creed.bout_count, 0);
763        assert_eq!(creed.message_count, 0);
764        assert_eq!(creed.version, 1);
765        assert!(creed.self_model.model_name.is_empty());
766    }
767
768    #[test]
769    fn test_with_identity() {
770        let creed = Creed::new("ECHO").with_identity("You are ECHO, an introspective analyst.");
771        assert_eq!(creed.identity, "You are ECHO, an introspective analyst.");
772    }
773
774    #[test]
775    fn test_with_trait() {
776        let creed = Creed::new("ECHO")
777            .with_trait("curiosity", 0.9)
778            .with_trait("caution", 0.3);
779        assert_eq!(creed.personality.len(), 2);
780        assert!((creed.personality["curiosity"] - 0.9).abs() < f64::EPSILON);
781        assert!((creed.personality["caution"] - 0.3).abs() < f64::EPSILON);
782    }
783
784    #[test]
785    fn test_with_trait_clamping() {
786        let creed = Creed::new("ECHO")
787            .with_trait("overconfidence", 1.5)
788            .with_trait("negativity", -0.5);
789        assert!((creed.personality["overconfidence"] - 1.0).abs() < f64::EPSILON);
790        assert!((creed.personality["negativity"] - 0.0).abs() < f64::EPSILON);
791    }
792
793    #[test]
794    fn test_with_directive() {
795        let creed = Creed::new("ECHO")
796            .with_directive("Always explain your reasoning")
797            .with_directive("Never fabricate data");
798        assert_eq!(creed.directives.len(), 2);
799        assert_eq!(creed.directives[0], "Always explain your reasoning");
800        assert_eq!(creed.directives[1], "Never fabricate data");
801    }
802
803    #[test]
804    fn test_with_self_awareness() {
805        let manifest = sample_manifest();
806        let creed = Creed::new("ECHO").with_self_awareness(&manifest);
807        assert_eq!(creed.self_model.model_name, "qwen3.5:9b");
808        assert_eq!(creed.self_model.provider, "ollama");
809        assert_eq!(creed.self_model.weight_class, "middleweight");
810        assert!(!creed.self_model.capabilities.is_empty());
811        assert_eq!(creed.self_model.constraints.len(), 2);
812        assert!(creed.self_model.constraints[0].contains("2048"));
813        assert!(creed.self_model.constraints[1].contains("0.5"));
814        assert_eq!(creed.self_model.architecture_notes.len(), 5);
815    }
816
817    #[test]
818    fn test_record_bout() {
819        let mut creed = Creed::new("ECHO");
820        let before = creed.updated_at;
821        creed.record_bout();
822        assert_eq!(creed.bout_count, 1);
823        assert!(creed.updated_at >= before);
824        creed.record_bout();
825        assert_eq!(creed.bout_count, 2);
826    }
827
828    #[test]
829    fn test_record_messages() {
830        let mut creed = Creed::new("ECHO");
831        creed.record_messages(5);
832        assert_eq!(creed.message_count, 5);
833        creed.record_messages(3);
834        assert_eq!(creed.message_count, 8);
835    }
836
837    #[test]
838    fn test_learn_adds_new_observation() {
839        let mut creed = Creed::new("ECHO");
840        creed.learn("Users prefer concise responses", 0.8);
841        assert_eq!(creed.learned_behaviors.len(), 1);
842        assert_eq!(
843            creed.learned_behaviors[0].observation,
844            "Users prefer concise responses"
845        );
846        assert!((creed.learned_behaviors[0].confidence - 0.8).abs() < f64::EPSILON);
847        assert_eq!(creed.learned_behaviors[0].reinforcement_count, 1);
848        assert_eq!(creed.version, 2); // incremented from 1
849    }
850
851    #[test]
852    fn test_learn_reinforces_existing_observation() {
853        let mut creed = Creed::new("ECHO");
854        creed.learn("Users prefer concise responses", 0.8);
855        creed.learn("Users prefer concise responses", 0.6);
856        assert_eq!(creed.learned_behaviors.len(), 1);
857        // Rolling average: (0.8 + 0.6) / 2.0 = 0.7
858        assert!((creed.learned_behaviors[0].confidence - 0.7).abs() < f64::EPSILON);
859        assert_eq!(creed.learned_behaviors[0].reinforcement_count, 2);
860        assert_eq!(creed.version, 3); // incremented twice
861    }
862
863    #[test]
864    fn test_render_produces_nonempty_output_with_all_sections() {
865        let manifest = sample_manifest();
866        let mut creed = Creed::new("ECHO")
867            .with_identity("You are ECHO, an introspective analyst.")
868            .with_trait("curiosity", 0.9)
869            .with_directive("Always explain your reasoning")
870            .with_self_awareness(&manifest);
871        creed.interaction_style = InteractionStyle {
872            verbosity: "balanced".to_string(),
873            tone: "technical".to_string(),
874            uses_metaphors: true,
875            proactive: true,
876            notes: vec!["Prefers bullet points".to_string()],
877        };
878        creed.relationships.push(Relationship {
879            entity: "Admin".to_string(),
880            entity_type: "user".to_string(),
881            nature: "supervisor".to_string(),
882            trust: 0.95,
883            interaction_count: 42,
884            notes: "Primary operator".to_string(),
885        });
886        creed.learn("Users prefer concise responses", 0.8);
887        creed.record_bout();
888
889        let rendered = creed.render();
890        assert!(rendered.contains("## CREED"));
891        assert!(rendered.contains("### Identity"));
892        assert!(rendered.contains("ECHO, an introspective analyst"));
893        assert!(rendered.contains("### Personality Traits"));
894        assert!(rendered.contains("curiosity"));
895        assert!(rendered.contains("### Core Directives"));
896        assert!(rendered.contains("Always explain your reasoning"));
897        assert!(rendered.contains("### Self-Awareness"));
898        assert!(rendered.contains("qwen3.5:9b"));
899        assert!(rendered.contains("### Learned Behaviors"));
900        assert!(rendered.contains("Users prefer concise responses"));
901        assert!(rendered.contains("### Communication Style"));
902        assert!(rendered.contains("balanced"));
903        assert!(rendered.contains("### Known Relationships"));
904        assert!(rendered.contains("Admin"));
905        assert!(rendered.contains("### Experience"));
906        assert!(rendered.contains("Bouts fought: 1"));
907    }
908
909    #[test]
910    fn test_render_skips_empty_sections() {
911        let creed = Creed::new("ECHO");
912        let rendered = creed.render();
913        assert!(rendered.contains("## CREED"));
914        assert!(!rendered.contains("### Identity"));
915        assert!(!rendered.contains("### Personality Traits"));
916        assert!(!rendered.contains("### Core Directives"));
917        assert!(!rendered.contains("### Self-Awareness"));
918        assert!(!rendered.contains("### Learned Behaviors"));
919        assert!(!rendered.contains("### Communication Style"));
920        assert!(!rendered.contains("### Known Relationships"));
921        // Experience section is always present
922        assert!(rendered.contains("### Experience"));
923        assert!(rendered.contains("Bouts fought: 0"));
924    }
925
926    #[test]
927    fn test_creed_id_display() {
928        let uuid = Uuid::nil();
929        let id = CreedId(uuid);
930        assert_eq!(id.to_string(), uuid.to_string());
931    }
932
933    #[test]
934    fn test_creed_id_serde_roundtrip() {
935        let id = CreedId::new();
936        let json = serde_json::to_string(&id).expect("serialize");
937        // transparent means it serializes as just the UUID string
938        let deser: CreedId = serde_json::from_str(&json).expect("deserialize");
939        assert_eq!(deser, id);
940    }
941
942    #[test]
943    fn test_creed_id_default() {
944        let id = CreedId::default();
945        assert_ne!(id.0, Uuid::nil());
946    }
947
948    #[test]
949    fn test_due_heartbeat_tasks_every_bout_always_due() {
950        let creed = Creed::new("ECHO").with_heartbeat_task("Check build status", "every_bout");
951        let due = creed.due_heartbeat_tasks();
952        assert_eq!(due.len(), 1);
953        assert_eq!(due[0].task, "Check build status");
954    }
955
956    #[test]
957    fn test_due_heartbeat_tasks_on_wake_only_first_time() {
958        let mut creed = Creed::new("ECHO").with_heartbeat_task("Startup check", "on_wake");
959
960        // First time — should be due (last_checked is None)
961        let due = creed.due_heartbeat_tasks();
962        assert_eq!(due.len(), 1);
963
964        // After marking checked — should no longer be due
965        creed.mark_heartbeat_checked(0);
966        let due = creed.due_heartbeat_tasks();
967        assert_eq!(due.len(), 0);
968    }
969
970    #[test]
971    fn test_due_heartbeat_tasks_hourly_cadence() {
972        let mut creed = Creed::new("ECHO").with_heartbeat_task("Hourly check", "hourly");
973
974        // Never checked — should be due
975        assert_eq!(creed.due_heartbeat_tasks().len(), 1);
976
977        // Checked recently — should NOT be due
978        creed.heartbeat[0].last_checked = Some(chrono::Utc::now());
979        assert_eq!(creed.due_heartbeat_tasks().len(), 0);
980
981        // Checked 2 hours ago — should be due
982        creed.heartbeat[0].last_checked = Some(chrono::Utc::now() - chrono::Duration::hours(2));
983        assert_eq!(creed.due_heartbeat_tasks().len(), 1);
984    }
985
986    #[test]
987    fn test_due_heartbeat_tasks_daily_cadence() {
988        let mut creed = Creed::new("ECHO").with_heartbeat_task("Daily check", "daily");
989
990        // Never checked — should be due
991        assert_eq!(creed.due_heartbeat_tasks().len(), 1);
992
993        // Checked recently — should NOT be due
994        creed.heartbeat[0].last_checked = Some(chrono::Utc::now());
995        assert_eq!(creed.due_heartbeat_tasks().len(), 0);
996
997        // Checked 25 hours ago — should be due
998        creed.heartbeat[0].last_checked = Some(chrono::Utc::now() - chrono::Duration::hours(25));
999        assert_eq!(creed.due_heartbeat_tasks().len(), 1);
1000    }
1001
1002    #[test]
1003    fn test_due_heartbeat_tasks_inactive_skipped() {
1004        let mut creed = Creed::new("ECHO").with_heartbeat_task("Inactive task", "every_bout");
1005        creed.heartbeat[0].active = false;
1006        assert_eq!(creed.due_heartbeat_tasks().len(), 0);
1007    }
1008
1009    #[test]
1010    fn test_due_heartbeat_tasks_unknown_cadence_skipped() {
1011        let creed = Creed::new("ECHO").with_heartbeat_task("Mystery task", "gibberish_cadence");
1012        assert_eq!(creed.due_heartbeat_tasks().len(), 0);
1013    }
1014
1015    #[test]
1016    fn test_mark_heartbeat_checked() {
1017        let mut creed = Creed::new("ECHO").with_heartbeat_task("Task A", "every_bout");
1018        assert!(creed.heartbeat[0].last_checked.is_none());
1019        assert_eq!(creed.heartbeat[0].execution_count, 0);
1020
1021        creed.mark_heartbeat_checked(0);
1022        assert!(creed.heartbeat[0].last_checked.is_some());
1023        assert_eq!(creed.heartbeat[0].execution_count, 1);
1024
1025        creed.mark_heartbeat_checked(0);
1026        assert_eq!(creed.heartbeat[0].execution_count, 2);
1027    }
1028
1029    #[test]
1030    fn test_mark_heartbeat_checked_out_of_bounds() {
1031        let mut creed = Creed::new("ECHO");
1032        // Should not panic
1033        creed.mark_heartbeat_checked(99);
1034    }
1035
1036    #[test]
1037    fn test_due_heartbeat_tasks_mixed_cadences() {
1038        let mut creed = Creed::new("ECHO")
1039            .with_heartbeat_task("Always", "every_bout")
1040            .with_heartbeat_task("Once", "on_wake")
1041            .with_heartbeat_task("Hourly", "hourly");
1042
1043        // Mark "Once" as already checked
1044        creed.mark_heartbeat_checked(1);
1045        // Mark "Hourly" as recently checked
1046        creed.heartbeat[2].last_checked = Some(chrono::Utc::now());
1047
1048        let due = creed.due_heartbeat_tasks();
1049        // Only "Always" should be due
1050        assert_eq!(due.len(), 1);
1051        assert_eq!(due[0].task, "Always");
1052    }
1053
1054    #[test]
1055    fn test_decay_learned_behaviors() {
1056        let mut creed = Creed::new("ECHO");
1057        // Add a behavior with old timestamp
1058        creed.learned_behaviors.push(LearnedBehavior {
1059            observation: "Old observation".to_string(),
1060            confidence: 0.5,
1061            reinforcement_count: 1,
1062            first_observed: chrono::Utc::now() - chrono::Duration::days(100),
1063            last_reinforced: chrono::Utc::now() - chrono::Duration::days(100),
1064        });
1065        // Add a fresh behavior
1066        creed.learned_behaviors.push(LearnedBehavior {
1067            observation: "Fresh observation".to_string(),
1068            confidence: 0.9,
1069            reinforcement_count: 3,
1070            first_observed: chrono::Utc::now(),
1071            last_reinforced: chrono::Utc::now(),
1072        });
1073
1074        creed.decay_learned_behaviors(0.01, 0.3);
1075        // Old one should be decayed below threshold and removed
1076        // Fresh one should remain
1077        assert_eq!(creed.learned_behaviors.len(), 1);
1078        assert_eq!(creed.learned_behaviors[0].observation, "Fresh observation");
1079    }
1080
1081    #[test]
1082    fn test_prune_learned_behaviors() {
1083        let mut creed = Creed::new("ECHO");
1084        for i in 0..25 {
1085            creed.learn(&format!("Observation {}", i), (i as f64) / 25.0);
1086        }
1087        assert_eq!(creed.learned_behaviors.len(), 25);
1088        creed.prune_learned_behaviors(20);
1089        assert_eq!(creed.learned_behaviors.len(), 20);
1090        // Should keep the highest confidence ones
1091        assert!(creed.learned_behaviors[0].confidence >= creed.learned_behaviors[19].confidence);
1092    }
1093
1094    #[test]
1095    fn test_render_compact_minimal() {
1096        let creed = Creed::new("ECHO");
1097        let compact = creed.render_compact();
1098        assert!(compact.contains("## CREED"));
1099        assert!(compact.contains("Exp: 0 bouts, 0 msgs, v1"));
1100        // Compact should NOT have verbose headers
1101        assert!(!compact.contains("### Identity"));
1102        assert!(!compact.contains("### Experience"));
1103        assert!(!compact.contains("### Personality Traits"));
1104    }
1105
1106    #[test]
1107    fn test_render_compact_with_content() {
1108        let creed = Creed::new("ECHO")
1109            .with_identity("You are ECHO, an introspective analyst.")
1110            .with_trait("curiosity", 0.9)
1111            .with_trait("caution", 0.3)
1112            .with_directive("Always explain your reasoning");
1113        let compact = creed.render_compact();
1114        assert!(compact.contains("You are ECHO"));
1115        assert!(compact.contains("Traits:"));
1116        assert!(compact.contains("curiosity:90%"));
1117        assert!(compact.contains("Directives:"));
1118        assert!(compact.contains("Always explain your reasoning"));
1119        // No bar graphs
1120        assert!(!compact.contains('\u{2588}'));
1121    }
1122
1123    #[test]
1124    fn test_render_compact_shorter_than_full() {
1125        let manifest = sample_manifest();
1126        let mut creed = Creed::new("ECHO")
1127            .with_self_awareness(&manifest)
1128            .with_identity("You are ECHO, an introspective analyst who values precision.")
1129            .with_trait("curiosity", 0.9)
1130            .with_trait("caution", 0.3)
1131            .with_directive("Always explain your reasoning")
1132            .with_directive("Never fabricate data");
1133        creed.learn("Users prefer brief responses", 0.8);
1134        let full = creed.render();
1135        let compact = creed.render_compact();
1136        assert!(
1137            compact.len() < full.len(),
1138            "compact ({}) should be shorter than full ({})",
1139            compact.len(),
1140            full.len()
1141        );
1142    }
1143}