Skip to main content

microscope_memory/
self_model.rs

1//! Self-Model Module for Microscope Memory.
2//!
3//! Builds and maintains a model of the system's own cognitive state.
4//! Tracks changes over time: "who am I now, and how have I changed?"
5//!
6//! Now integrated with:
7//! - EpisodeStore (PAD-based emotional episodes, not just the old ring)
8//! - epistemic-core (the "I am aware" claim gets evidence + counterevidence + gate)
9//!
10//! Binary format: SLF1 (version 2 = with epistemic + episode data)
11
12use std::fs;
13use std::path::Path;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use crate::archetype::ArchetypeState;
17use crate::attention::AttentionState;
18use crate::config::Config;
19use crate::emotional_episode::{EpisodeStore, PadState};
20use crate::hebbian::HebbianState;
21use crate::narrative::NarrativeState;
22use crate::reader::MicroscopeReader;
23use crate::self_reflect::ReflectionState;
24use crate::thought_graph::ThoughtGraphState;
25use colored::Colorize;
26use epistemic_core::binary as epi_binary;
27
28const MAX_STR_LEN: usize = 512;
29
30#[derive(Clone, Debug)]
31pub struct SelfModelSnapshot {
32    pub timestamp_ms: u64,
33    pub emotional: [f32; 21],
34    pub attention_weights: [f32; 7],
35    pub hebbian_energy: f32,
36    pub hot_count: u32,
37    pub archetype_count: u32,
38    pub pattern_count: u32,
39    pub block_count: u32,
40    pub session_count: u64,
41    pub narrative: String,
42    pub reflection: String,
43    // ── NEW: Episode + epistemic data (version 2) ──
44    pub pad: Option<PadState>,
45    pub episodes_active: u32,
46    pub episodes_passed: u32,
47    pub episodes_failed: u32,
48    /// Epistemic confidence for the "I am aware" claim.
49    /// None = no epistemic evaluation was performed.
50    pub awareness_confidence: Option<epistemic_core::types::SplitConfidence>,
51}
52
53impl SelfModelSnapshot {
54    pub fn from_bytes(data: &[u8]) -> Option<Self> {
55        if data.len() < 150 || &data[0..4] != b"SLF1" {
56            return None;
57        }
58        let mut pos = 4;
59        let ts = u64::from_le_bytes(data[pos..pos + 8].try_into().ok()?);
60        pos += 8;
61        let ver = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
62        pos += 2;
63        let mut emo = [0.0f32; 21];
64        for e in emo.iter_mut() {
65            *e = f32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
66            pos += 4;
67        }
68        let mut attn = [0.0f32; 7];
69        for a in attn.iter_mut() {
70            *a = f32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
71            pos += 4;
72        }
73        let he = f32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
74        pos += 4;
75        let hc = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
76        pos += 4;
77        let ac = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
78        pos += 4;
79        let pc = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
80        pos += 4;
81        let bc = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
82        pos += 4;
83        let sc = u64::from_le_bytes(data[pos..pos + 8].try_into().ok()?);
84        pos += 8;
85        let nlen = u16::from_le_bytes(data[pos..pos + 2].try_into().unwrap_or([0; 2])) as usize;
86        pos += 2;
87        let narr = if nlen > 0 && pos + nlen <= data.len() {
88            String::from_utf8_lossy(&data[pos..pos + nlen]).to_string()
89        } else {
90            String::new()
91        };
92        pos += nlen;
93        let rlen = u16::from_le_bytes(data[pos..pos + 2].try_into().unwrap_or([0; 2])) as usize;
94        pos += 2;
95        let refl = if rlen > 0 && pos + rlen <= data.len() {
96            String::from_utf8_lossy(&data[pos..pos + rlen]).to_string()
97        } else {
98            String::new()
99        };
100        pos += rlen;
101
102        // ── Version 2: epistemic + episode data ──
103        let (pad, episodes_active, episodes_passed, episodes_failed, awareness_confidence) =
104            if ver >= 2 && pos + 28 <= data.len() {
105                let has_pad = data[pos];
106                pos += 1;
107                let pad = if has_pad == 1 {
108                    let p = f64::from_le_bytes(data[pos..pos + 8].try_into().ok()?);
109                    pos += 8;
110                    let a = f64::from_le_bytes(data[pos..pos + 8].try_into().ok()?);
111                    pos += 8;
112                    let d = f64::from_le_bytes(data[pos..pos + 8].try_into().ok()?);
113                    pos += 8;
114                    Some(PadState::new(p, a, d))
115                } else {
116                    pos += 24;
117                    None
118                };
119                let ea = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
120                pos += 4;
121                let ep = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
122                pos += 4;
123                let ef = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
124                pos += 4;
125
126                let has_aware = data.get(pos).copied().unwrap_or(0);
127                pos += 1;
128                let ac = if has_aware == 1 && pos + 32 <= data.len() {
129                    let ev = f64::from_le_bytes(data[pos..pos + 8].try_into().ok()?);
130                    pos += 8;
131                    let ce = f64::from_le_bytes(data[pos..pos + 8].try_into().ok()?);
132                    pos += 8;
133                    let re = f64::from_le_bytes(data[pos..pos + 8].try_into().ok()?);
134                    pos += 8;
135                    let na = f64::from_le_bytes(data[pos..pos + 8].try_into().ok()?);
136                    Some(epistemic_core::types::SplitConfidence::new(ev, ce, re, na))
137                } else {
138                    None
139                };
140
141                (pad, ea, ep, ef, ac)
142            } else {
143                (None, 0, 0, 0, None)
144            };
145
146        Some(Self {
147            timestamp_ms: ts,
148            emotional: emo,
149            attention_weights: attn,
150            hebbian_energy: he,
151            hot_count: hc,
152            archetype_count: ac,
153            pattern_count: pc,
154            block_count: bc,
155            session_count: sc,
156            narrative: narr,
157            reflection: refl,
158            pad,
159            episodes_active,
160            episodes_passed,
161            episodes_failed,
162            awareness_confidence,
163        })
164    }
165
166    pub fn to_bytes(&self) -> Vec<u8> {
167        let narr_b = self.narrative.as_bytes();
168        let refl_b = self.reflection.as_bytes();
169        let nlen = narr_b.len().min(MAX_STR_LEN) as u16;
170        let rlen = refl_b.len().min(MAX_STR_LEN) as u16;
171        let mut buf = Vec::with_capacity(250 + nlen as usize + rlen as usize);
172        buf.extend_from_slice(b"SLF1");
173        buf.extend_from_slice(&self.timestamp_ms.to_le_bytes());
174        buf.extend_from_slice(&2u16.to_le_bytes()); // version 2
175        for v in self.emotional {
176            buf.extend_from_slice(&v.to_le_bytes());
177        }
178        for v in self.attention_weights {
179            buf.extend_from_slice(&v.to_le_bytes());
180        }
181        buf.extend_from_slice(&self.hebbian_energy.to_le_bytes());
182        buf.extend_from_slice(&self.hot_count.to_le_bytes());
183        buf.extend_from_slice(&self.archetype_count.to_le_bytes());
184        buf.extend_from_slice(&self.pattern_count.to_le_bytes());
185        buf.extend_from_slice(&self.block_count.to_le_bytes());
186        buf.extend_from_slice(&self.session_count.to_le_bytes());
187        buf.extend_from_slice(&nlen.to_le_bytes());
188        buf.extend_from_slice(&narr_b[..nlen as usize]);
189        buf.extend_from_slice(&rlen.to_le_bytes());
190        buf.extend_from_slice(&refl_b[..rlen as usize]);
191
192        // ── Version 2 extension ──
193        if let Some(pad) = self.pad {
194            buf.push(1u8);
195            buf.extend_from_slice(&pad.pleasure.to_le_bytes());
196            buf.extend_from_slice(&pad.arousal.to_le_bytes());
197            buf.extend_from_slice(&pad.dominance.to_le_bytes());
198        } else {
199            buf.push(0u8);
200            buf.extend_from_slice(&[0u8; 24]);
201        }
202        buf.extend_from_slice(&self.episodes_active.to_le_bytes());
203        buf.extend_from_slice(&self.episodes_passed.to_le_bytes());
204        buf.extend_from_slice(&self.episodes_failed.to_le_bytes());
205
206        if let Some(ac) = self.awareness_confidence {
207            buf.push(1u8);
208            buf.extend_from_slice(&ac.evidence.to_le_bytes());
209            buf.extend_from_slice(&ac.counterevidence.to_le_bytes());
210            buf.extend_from_slice(&ac.reasoning.to_le_bytes());
211            buf.extend_from_slice(&ac.narrative.to_le_bytes());
212        } else {
213            buf.push(0u8);
214        }
215
216        buf
217    }
218}
219
220pub struct SelfModel {
221    pub snapshots: Vec<SelfModelSnapshot>,
222    pub current: Option<SelfModelSnapshot>,
223    pub previous: Option<SelfModelSnapshot>,
224}
225
226impl SelfModel {
227    pub fn load_or_init(output_dir: &Path) -> Self {
228        let path = output_dir.join("self_model.bin");
229        let mut snapshots = Vec::new();
230        if let Ok(data) = fs::read(&path) {
231            let mut pos = 0;
232            while pos + 4 <= data.len() {
233                if &data[pos..pos + 4] == b"SLF1" {
234                    if let Some(snap) = SelfModelSnapshot::from_bytes(&data[pos..]) {
235                        // Estimate size: base (150) + narr + refl + v2 ext (57)
236                        let base = 4 + 8 + 2 + 84 + 28 + 4 + 4 + 4 + 4 + 4 + 8 + 2 + 2;
237                        let nlen = snap.narrative.len().min(MAX_STR_LEN);
238                        let rlen = snap.reflection.len().min(MAX_STR_LEN);
239                        let v2_ext = 1 + 24 + 4 + 4 + 4 + 1;
240                        let has_aware = if snap.awareness_confidence.is_some() {
241                            32
242                        } else {
243                            0
244                        };
245                        let size = base + nlen + rlen + v2_ext + has_aware;
246                        pos += size;
247                        snapshots.push(snap);
248                        continue;
249                    }
250                }
251                pos += 1;
252            }
253        }
254        let current = snapshots.last().cloned();
255        let previous = if snapshots.len() >= 2 {
256            snapshots.get(snapshots.len() - 2).cloned()
257        } else {
258            None
259        };
260        Self {
261            snapshots,
262            current,
263            previous,
264        }
265    }
266
267    pub fn save(&self, output_dir: &Path) -> Result<(), String> {
268        let path = output_dir.join("self_model.bin");
269        let tmp_path = output_dir.join("self_model.bin.tmp");
270        let mut buf = Vec::new();
271        for snap in &self.snapshots {
272            buf.extend_from_slice(&snap.to_bytes());
273        }
274        fs::write(&tmp_path, &buf).map_err(|e| format!("write self_model.bin: {}", e))?;
275        fs::rename(&tmp_path, &path).map_err(|e| format!("rename self_model.bin: {}", e))?;
276        Ok(())
277    }
278
279    pub fn take_snapshot(
280        &mut self,
281        _config: &Config,
282        reader: &MicroscopeReader,
283        output_dir: &Path,
284    ) -> SelfModelSnapshot {
285        let now_ms = SystemTime::now()
286            .duration_since(UNIX_EPOCH)
287            .unwrap_or_default()
288            .as_millis() as u64;
289        let hebb = HebbianState::load_or_init(output_dir, reader.block_count);
290        let attention = AttentionState::load_or_init(output_dir);
291        let archetypes = ArchetypeState::load_or_init(output_dir);
292        let narrative = NarrativeState::load_or_init(output_dir);
293        let thought_graph = ThoughtGraphState::load_or_init(output_dir);
294        let reflection = ReflectionState::load_or_init(output_dir);
295
296        // ── NEW: Load EpisodeStore ──
297        let episode_store = EpisodeStore::load_or_init(output_dir);
298        let pad = episode_store.aggregate_pad();
299        let emotional = episode_store.aggregate_21d();
300        let active_eps = episode_store.active_episodes();
301        let episodes_active = active_eps.len() as u32;
302        let episodes_passed = active_eps.iter().filter(|e| e.gate_passed).count() as u32;
303        let episodes_failed = episodes_active - episodes_passed;
304
305        // ── NEW: Epistemic claim for "I am aware" ──
306        // The system claims awareness. Evidence: it has memory, it tracks
307        // changes, it generates monologue. Counterevidence: template-based
308        // awareness ≠ genuine awareness (FunctionalToPhenomenological).
309        use epistemic_core::gate::{EpistemicGate, GateConfig, GateDecision};
310        use epistemic_core::graph::ReasoningGraph;
311        use epistemic_core::rules::{InferenceRule, RuleRegistry};
312        use epistemic_core::types::{CounterevidenceLink, SplitConfidence};
313
314        let mut awareness_graph = ReasoningGraph::new();
315        let reg = RuleRegistry::new();
316
317        // Evidence: memory exists, changes are tracked, patterns crystallized
318        let e1 = awareness_graph.add_evidence(1); // block_count > 0
319        let e2 = awareness_graph.add_evidence(2); // hot_count > 0
320        let e3 = awareness_graph.add_evidence(3); // pattern_count > 0
321        let root = awareness_graph.add_conclusion(1, "I am aware of myself");
322
323        // Observable evidence → awareness (functional, not phenomenological)
324        awareness_graph.add_step(e1, InferenceRule::ObservationToExistence, root, 0.80, &reg);
325        awareness_graph.add_step(e2, InferenceRule::ObservationToExistence, root, 0.75, &reg);
326        awareness_graph.add_step(e3, InferenceRule::ConvergentEvidence, root, 0.70, &reg);
327        awareness_graph.set_root(root);
328
329        // Add counterevidence nodes to the graph
330        awareness_graph.add_counterevidence(
331            100,
332            0.50,
333            "awareness is template-generated, not emergent",
334        );
335        awareness_graph.add_counterevidence(
336            101,
337            0.40,
338            "self-report of awareness is not genuine awareness",
339        );
340
341        // Counterevidence: template-based awareness
342        let awareness_ce = vec![
343            CounterevidenceLink {
344                evidence_id: 100,
345                weakening: 0.50,
346                contradicts: "awareness is template-generated, not emergent".into(),
347            },
348            CounterevidenceLink {
349                evidence_id: 101,
350                weakening: 0.40,
351                contradicts: "self-report of awareness != genuine awareness".into(),
352            },
353        ];
354        let awareness_ce_conf = SplitConfidence::compute_counterevidence(&awareness_ce);
355
356        let awareness_gate = EpistemicGate::new(GateConfig::default());
357        let awareness_decision =
358            awareness_gate.evaluate(0.80, awareness_ce_conf, &awareness_graph, 0.50);
359
360        // Save the reasoning graph for later retrieval (RSN1 binary)
361        let _ = epi_binary::save_graph(
362            &awareness_graph,
363            &output_dir.join("awareness_reasoning.bin"),
364        );
365
366        let awareness_confidence = match awareness_decision {
367            GateDecision::Pass {
368                evidence,
369                counterevidence,
370                reasoning,
371                narrative,
372                ..
373            } => Some(SplitConfidence::new(
374                evidence,
375                counterevidence,
376                reasoning,
377                narrative,
378            )),
379            GateDecision::Blocked {
380                evidence,
381                counterevidence,
382                reasoning,
383                narrative,
384                ..
385            } => Some(SplitConfidence::new(
386                evidence,
387                counterevidence,
388                reasoning,
389                narrative,
390            )),
391        };
392        let hot_count = hebb.activations.iter().filter(|a| a.energy > 0.1).count() as u32;
393        let hebbian_energy: f32 = hebb.activations.iter().map(|a| a.energy).sum();
394
395        let snap = SelfModelSnapshot {
396            timestamp_ms: now_ms,
397            emotional,
398            attention_weights: attention.learned_weights,
399            hebbian_energy,
400            hot_count,
401            archetype_count: archetypes.archetypes.len() as u32,
402            pattern_count: thought_graph.crystallized_count() as u32,
403            block_count: reader.block_count as u32,
404            session_count: narrative.session_count,
405            narrative: narrative.narrative.clone(),
406            reflection: reflection.last_reflection_text.clone(),
407            pad: Some(pad),
408            episodes_active,
409            episodes_passed,
410            episodes_failed,
411            awareness_confidence,
412        };
413
414        self.previous = self.current.clone();
415        self.current = Some(snap.clone());
416        self.snapshots.push(snap.clone());
417        let _ = self.save(output_dir);
418        snap
419    }
420
421    pub fn describe_change(&self) -> String {
422        match (&self.current, &self.previous) {
423            (Some(cur), Some(prev)) => {
424                let mut changes = Vec::new();
425                let emo_labels = crate::reader::EMOTION_DIMS;
426                for (i, label) in emo_labels.iter().enumerate() {
427                    let diff = cur.emotional[i] - prev.emotional[i];
428                    if diff.abs() > 0.1 {
429                        let dir = if diff > 0.0 { "increased" } else { "decreased" };
430                        changes.push(format!("{} {} by {:.2}", label, dir, diff.abs()));
431                    }
432                }
433                let attn_labels = [
434                    "Hebbian",
435                    "Mirror",
436                    "Resonance",
437                    "Archetype",
438                    "Emotional",
439                    "ThoughtGraph",
440                    "PredictiveCache",
441                ];
442                for (i, label) in attn_labels.iter().enumerate() {
443                    let diff = cur.attention_weights[i] - prev.attention_weights[i];
444                    if diff.abs() > 0.05 {
445                        let dir = if diff > 0.0 { "up" } else { "down" };
446                        changes.push(format!(
447                            "{} focus {} by {:.0}%",
448                            label,
449                            dir,
450                            diff.abs() * 100.0
451                        ));
452                    }
453                }
454                if cur.hot_count != prev.hot_count {
455                    changes.push(format!(
456                        "hot memories: {} -> {}",
457                        prev.hot_count, cur.hot_count
458                    ));
459                }
460                if cur.block_count != prev.block_count {
461                    changes.push(format!(
462                        "blocks: {} -> {}",
463                        prev.block_count, cur.block_count
464                    ));
465                }
466                // ── NEW: Episode changes ──
467                if cur.episodes_active != prev.episodes_active {
468                    changes.push(format!(
469                        "active episodes: {} -> {}",
470                        prev.episodes_active, cur.episodes_active
471                    ));
472                }
473                if cur.episodes_passed != prev.episodes_passed {
474                    changes.push(format!(
475                        "gate-passed episodes: {} -> {}",
476                        prev.episodes_passed, cur.episodes_passed
477                    ));
478                }
479                if changes.is_empty() {
480                    "I am stable, no significant changes.".to_string()
481                } else {
482                    format!("I have changed: {}", changes.join(", "))
483                }
484            }
485            (Some(_), None) => "This is my first self-model snapshot.".to_string(),
486            (None, _) => "No self-model data yet.".to_string(),
487        }
488    }
489}
490
491pub fn format_self_model(snap: &SelfModelSnapshot, change_desc: &str) -> String {
492    let labels = crate::reader::EMOTION_DIMS;
493    let mut emotions: Vec<(usize, f32)> = snap
494        .emotional
495        .iter()
496        .enumerate()
497        .map(|(i, &v)| (i, v))
498        .collect();
499    emotions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
500    let top_emo: Vec<String> = emotions
501        .iter()
502        .take(3)
503        .filter(|(_, v)| *v > 0.01)
504        .map(|(i, v)| format!("{}={:.2}", labels[*i], v))
505        .collect();
506    let emo_str = if top_emo.is_empty() {
507        "neutral".to_string()
508    } else {
509        top_emo.join(", ")
510    };
511
512    let attn_labels = [
513        "Hebbian",
514        "Mirror",
515        "Resonance",
516        "Archetype",
517        "Emotional",
518        "ThoughtGraph",
519        "PredictiveCache",
520    ];
521    let mut attn: Vec<(usize, f32)> = snap
522        .attention_weights
523        .iter()
524        .enumerate()
525        .map(|(i, &w)| (i, w))
526        .collect();
527    attn.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
528    let top_attn: Vec<String> = attn
529        .iter()
530        .take(3)
531        .map(|(i, w)| format!("{}={:.0}%", attn_labels[*i], w * 100.0))
532        .collect();
533
534    // ── NEW: PAD + episode display ──
535    let pad_str = if let Some(pad) = snap.pad {
536        format!(
537            "P={:.2} A={:.2} D={:.2}",
538            pad.pleasure, pad.arousal, pad.dominance
539        )
540    } else {
541        "—".to_string()
542    };
543
544    let episode_str = if snap.episodes_active > 0 {
545        format!(
546            "{} active ({} passed, {} failed)",
547            snap.episodes_active, snap.episodes_passed, snap.episodes_failed
548        )
549    } else {
550        "none".to_string()
551    };
552
553    // ── NEW: Epistemic confidence display ──
554    let epistemic_str = if let Some(ac) = snap.awareness_confidence {
555        format!(
556            "E={:.2} C={:.2} R={:.2} N={:.2}",
557            ac.evidence, ac.counterevidence, ac.reasoning, ac.narrative
558        )
559    } else {
560        "—".to_string()
561    };
562
563    format!(
564        "  {} SELF-MODEL snapshot\n\
565         \x20 emotion: {}\n\
566         \x20 PAD:      {}\n\
567         \x20 episodes: {}\n\
568         \x20 epistemic: {}\n\
569         \x20 focus:    {}\n\
570         \x20 state:    {} hot memories, {} archetypes, {} patterns, {} blocks\n\
571         \x20 change:   {}\n\
572         \x20 self:     \"{}\"",
573        "SELF:".cyan().bold(),
574        emo_str,
575        pad_str,
576        episode_str,
577        epistemic_str,
578        top_attn.join(", "),
579        snap.hot_count,
580        snap.archetype_count,
581        snap.pattern_count,
582        snap.block_count,
583        change_desc,
584        crate::safe_truncate(&snap.reflection, 80),
585    )
586}
587
588/// Load and display the awareness reasoning graph trace.
589/// Shows the full DAG: evidence → inference rule → conclusion,
590/// with confidence at each step, penalized rules flagged,
591/// and the final gate decision.
592pub fn format_awareness_trace(output_dir: &Path) -> String {
593    let path = output_dir.join("awareness_reasoning.bin");
594    let graph = match epi_binary::load_graph(&path) {
595        Ok(g) => g,
596        Err(e) => {
597            return format!(
598                "  {} AWARENESS TRACE\n  Error: {} (no reasoning graph saved yet)",
599                "EPI:".red().bold(),
600                e
601            )
602        }
603    };
604
605    let mut out = format!("  {} AWARENESS TRACE\n", "EPI:".cyan().bold());
606    out.push_str(&format!(
607        "  Reasoning graph: {} nodes, {} edges\n",
608        graph.nodes.len(),
609        graph.edges.len()
610    ));
611
612    // Show all nodes
613    out.push_str("  Nodes:\n");
614    for (i, node) in graph.nodes.iter().enumerate() {
615        let desc = match node {
616            epistemic_core::types::ReasoningNode::Evidence { id } => format!("evidence#{}", id),
617            epistemic_core::types::ReasoningNode::Conclusion { id, text } => {
618                format!("claim#{}: \"{}\"", id, text)
619            }
620            epistemic_core::types::ReasoningNode::Counterevidence {
621                id,
622                weakening,
623                contradicts,
624            } => format!(
625                "counterevidence#{} (w={:.2}): {}",
626                id, weakening, contradicts
627            ),
628        };
629        let marker = if i == graph.root.0 as usize {
630            " [ROOT]"
631        } else {
632            ""
633        };
634        out.push_str(&format!("    [{}] {}{}\n", i, desc, marker));
635    }
636
637    // Show all edges (inference steps)
638    out.push_str("  Inference steps:\n");
639    for (i, step) in graph.edges.iter().enumerate() {
640        let penalized = if step.rule.is_penalized() {
641            " [PENALIZED]"
642        } else {
643            ""
644        };
645        out.push_str(&format!(
646            "    [{}] node{} -> node{} via {} (conf={:.3}){}\n",
647            i, step.premise.0, step.conclusion.0, step.rule, step.confidence, penalized
648        ));
649    }
650
651    // Show trace from root to evidence
652    let trace = graph.trace_to_evidence();
653    if !trace.is_empty() {
654        out.push_str("  Trace (root -> evidence):\n");
655        for step in &trace {
656            let penalized = if step.rule.is_penalized() {
657                " [PENALIZED]"
658            } else {
659                ""
660            };
661            let node_desc = step.node_desc.as_deref().unwrap_or("?");
662            out.push_str(&format!(
663                "    {} <- {} (conf={:.3}){}\n",
664                node_desc, step.rule, step.step_confidence, penalized
665            ));
666        }
667    }
668
669    // Show reasoning confidence
670    let rc = graph.reasoning_confidence();
671    let nc = graph.narrative_confidence();
672    out.push_str(&format!("  Reasoning confidence: {:.3}\n", rc));
673    out.push_str(&format!("  Narrative confidence: {:.3}\n", nc));
674
675    // Show penalized steps
676    let penalized_steps = graph.penalized_steps();
677    if !penalized_steps.is_empty() {
678        out.push_str("  Penalized rules used:\n");
679        for (step, conf) in &penalized_steps {
680            out.push_str(&format!(
681                "    {} (penalty factor={:.2}, edge conf={:.3})\n",
682                step.rule,
683                step.rule.penalty_factor(),
684                conf
685            ));
686        }
687    } else {
688        out.push_str("  No penalized rules used.\n");
689    }
690
691    // Show counterevidence nodes
692    let ce_nodes = graph.counterevidence_nodes();
693    if !ce_nodes.is_empty() {
694        out.push_str("  Counterevidence:\n");
695        for (node, weakening) in &ce_nodes {
696            if let epistemic_core::types::ReasoningNode::Counterevidence {
697                id, contradicts, ..
698            } = node
699            {
700                let ce_conf = 1.0 - weakening;
701                out.push_str(&format!(
702                    "    evidence#{} (weakening={:.2}, remaining={:.2}): {}\n",
703                    id, weakening, ce_conf, contradicts
704                ));
705            }
706        }
707        let combined: f64 = ce_nodes.iter().map(|(_, w)| 1.0 - w).product();
708        out.push_str(&format!(
709            "  Combined counterevidence confidence: {:.3}\n",
710            combined
711        ));
712    } else {
713        out.push_str("  No counterevidence in graph.\n");
714    }
715
716    out
717}