Skip to main content

deciduous/
pulse.rs

1//! Pulse - current state health report for the decision graph
2//!
3//! Shows active goals, confidence distribution, orphan nodes,
4//! recent activity, and coverage gaps.
5
6use crate::db::{Database, DecisionEdge, DecisionNode};
7use colored::Colorize;
8use serde::Serialize;
9use std::collections::{HashMap, HashSet};
10
11/// Full pulse report
12#[derive(Debug, Serialize)]
13pub struct PulseReport {
14    pub summary: PulseSummary,
15    pub active_goals: Vec<GoalTree>,
16    pub orphan_nodes: Vec<NodeRef>,
17    pub recent_nodes: Vec<NodeRef>,
18    pub coverage_gaps: Vec<CoverageGap>,
19}
20
21/// High-level summary statistics
22#[derive(Debug, Serialize)]
23pub struct PulseSummary {
24    pub total_nodes: usize,
25    pub total_edges: usize,
26    pub by_type: HashMap<String, usize>,
27    pub by_status: HashMap<String, usize>,
28    pub confidence: ConfidenceDistribution,
29}
30
31/// Confidence level buckets
32#[derive(Debug, Serialize)]
33pub struct ConfidenceDistribution {
34    pub high: usize,   // 80-100
35    pub medium: usize, // 50-79
36    pub low: usize,    // 1-49
37    pub unset: usize,  // no confidence
38}
39
40/// An active goal with its child tree
41#[derive(Debug, Serialize)]
42pub struct GoalTree {
43    pub goal: NodeRef,
44    pub children: Vec<NodeRef>,
45}
46
47/// Lightweight node reference
48#[derive(Debug, Serialize)]
49pub struct NodeRef {
50    pub id: i32,
51    pub node_type: String,
52    pub title: String,
53    pub status: String,
54    pub confidence: Option<u8>,
55    pub created_at: String,
56}
57
58/// A gap in graph coverage
59#[derive(Debug, Serialize)]
60pub struct CoverageGap {
61    pub node_id: i32,
62    pub node_type: String,
63    pub title: String,
64    pub gap_type: String,
65}
66
67fn node_to_ref(node: &DecisionNode) -> NodeRef {
68    let confidence = node
69        .metadata_json
70        .as_ref()
71        .and_then(|m| serde_json::from_str::<serde_json::Value>(m).ok())
72        .and_then(|v| v.get("confidence").and_then(|c| c.as_u64()))
73        .map(|c| c.min(100) as u8);
74    NodeRef {
75        id: node.id,
76        node_type: node.node_type.clone(),
77        title: node.title.clone(),
78        status: node.status.clone(),
79        confidence,
80        created_at: node.created_at.clone(),
81    }
82}
83
84fn get_branch(node: &DecisionNode) -> Option<String> {
85    node.metadata_json
86        .as_ref()
87        .and_then(|m| serde_json::from_str::<serde_json::Value>(m).ok())
88        .and_then(|v| {
89            v.get("branch")
90                .and_then(|b| b.as_str())
91                .map(|s| s.to_string())
92        })
93}
94
95/// Generate the full pulse report
96pub fn generate_pulse(
97    db: &Database,
98    branch: Option<&str>,
99    recent_count: usize,
100) -> Result<PulseReport, String> {
101    let all_nodes = db.get_all_nodes().map_err(|e| e.to_string())?;
102    let all_edges = db.get_all_edges().map_err(|e| e.to_string())?;
103
104    // Apply branch filter
105    let nodes: Vec<&DecisionNode> = if let Some(br) = branch {
106        all_nodes
107            .iter()
108            .filter(|n| get_branch(n).as_deref() == Some(br))
109            .collect()
110    } else {
111        all_nodes.iter().collect()
112    };
113
114    let node_ids: HashSet<i32> = nodes.iter().map(|n| n.id).collect();
115
116    // Filter edges to only those connecting filtered nodes
117    let edges: Vec<&DecisionEdge> = all_edges
118        .iter()
119        .filter(|e| node_ids.contains(&e.from_node_id) && node_ids.contains(&e.to_node_id))
120        .collect();
121
122    // Summary
123    let mut by_type: HashMap<String, usize> = HashMap::new();
124    let mut by_status: HashMap<String, usize> = HashMap::new();
125    let mut confidence = ConfidenceDistribution {
126        high: 0,
127        medium: 0,
128        low: 0,
129        unset: 0,
130    };
131
132    for node in &nodes {
133        *by_type.entry(node.node_type.clone()).or_insert(0) += 1;
134        *by_status.entry(node.status.clone()).or_insert(0) += 1;
135        let ref_node = node_to_ref(node);
136        match ref_node.confidence {
137            Some(c) if c >= 80 => confidence.high += 1,
138            Some(c) if c >= 50 => confidence.medium += 1,
139            Some(_) => confidence.low += 1,
140            None => confidence.unset += 1,
141        }
142    }
143
144    let summary = PulseSummary {
145        total_nodes: nodes.len(),
146        total_edges: edges.len(),
147        by_type,
148        by_status,
149        confidence,
150    };
151
152    // Active goal trees
153    let active_goals: Vec<&DecisionNode> = nodes
154        .iter()
155        .filter(|n| n.node_type == "goal" && n.status != "superseded" && n.status != "abandoned")
156        .copied()
157        .collect();
158
159    // Build adjacency for BFS
160    let mut outgoing: HashMap<i32, Vec<i32>> = HashMap::new();
161    for edge in &edges {
162        outgoing
163            .entry(edge.from_node_id)
164            .or_default()
165            .push(edge.to_node_id);
166    }
167
168    let node_map: HashMap<i32, &DecisionNode> = nodes.iter().map(|n| (n.id, *n)).collect();
169
170    let goal_trees: Vec<GoalTree> = active_goals
171        .iter()
172        .map(|goal| {
173            let mut children = Vec::new();
174            let mut visited = HashSet::new();
175            let mut queue = std::collections::VecDeque::new();
176            visited.insert(goal.id);
177            if let Some(outs) = outgoing.get(&goal.id) {
178                for &child_id in outs {
179                    queue.push_back(child_id);
180                }
181            }
182            while let Some(nid) = queue.pop_front() {
183                if visited.insert(nid) {
184                    if let Some(node) = node_map.get(&nid) {
185                        children.push(node_to_ref(node));
186                    }
187                    if let Some(outs) = outgoing.get(&nid) {
188                        for &child_id in outs {
189                            queue.push_back(child_id);
190                        }
191                    }
192                }
193            }
194            GoalTree {
195                goal: node_to_ref(goal),
196                children,
197            }
198        })
199        .collect();
200
201    // Orphan nodes (no edges at all, excluding root goals)
202    let mut nodes_with_edges: HashSet<i32> = HashSet::new();
203    for edge in &edges {
204        nodes_with_edges.insert(edge.from_node_id);
205        nodes_with_edges.insert(edge.to_node_id);
206    }
207    let orphans: Vec<NodeRef> = nodes
208        .iter()
209        .filter(|n| !nodes_with_edges.contains(&n.id) && n.node_type != "goal")
210        .map(|n| node_to_ref(n))
211        .collect();
212
213    // Recent activity
214    let mut recent_sorted: Vec<&DecisionNode> = nodes.to_vec();
215    recent_sorted.sort_by(|a, b| b.created_at.cmp(&a.created_at));
216    let recent: Vec<NodeRef> = recent_sorted
217        .iter()
218        .take(recent_count)
219        .map(|n| node_to_ref(n))
220        .collect();
221
222    // Coverage gaps
223    let mut gaps = Vec::new();
224    for node in &nodes {
225        let has_outgoing = outgoing.contains_key(&node.id);
226        match node.node_type.as_str() {
227            "goal"
228                if !has_outgoing && node.status != "superseded" && node.status != "abandoned" =>
229            {
230                gaps.push(CoverageGap {
231                    node_id: node.id,
232                    node_type: node.node_type.clone(),
233                    title: node.title.clone(),
234                    gap_type: "goal_without_options".to_string(),
235                });
236            }
237            "decision"
238                if !has_outgoing && node.status != "superseded" && node.status != "abandoned" =>
239            {
240                gaps.push(CoverageGap {
241                    node_id: node.id,
242                    node_type: node.node_type.clone(),
243                    title: node.title.clone(),
244                    gap_type: "decision_without_actions".to_string(),
245                });
246            }
247            "action"
248                if !has_outgoing && node.status != "superseded" && node.status != "abandoned" =>
249            {
250                gaps.push(CoverageGap {
251                    node_id: node.id,
252                    node_type: node.node_type.clone(),
253                    title: node.title.clone(),
254                    gap_type: "action_without_outcomes".to_string(),
255                });
256            }
257            _ => {}
258        }
259    }
260
261    Ok(PulseReport {
262        summary,
263        active_goals: goal_trees,
264        orphan_nodes: orphans,
265        recent_nodes: recent,
266        coverage_gaps: gaps,
267    })
268}
269
270/// Print the pulse report in colored terminal format
271pub fn print_pulse(report: &PulseReport, summary_only: bool) {
272    println!("{}", "=== PULSE ===".bold());
273    println!();
274
275    // Summary
276    println!("{}:", "Summary".bold());
277    println!(
278        "  Nodes: {} | Edges: {}",
279        report.summary.total_nodes.to_string().cyan(),
280        report.summary.total_edges.to_string().cyan()
281    );
282
283    // Types
284    let type_order = [
285        "goal",
286        "option",
287        "decision",
288        "action",
289        "outcome",
290        "observation",
291        "revisit",
292    ];
293    let type_parts: Vec<String> = type_order
294        .iter()
295        .filter_map(|t| {
296            report
297                .summary
298                .by_type
299                .get(*t)
300                .map(|c| format!("{}({})", t, c))
301        })
302        .collect();
303    if !type_parts.is_empty() {
304        println!("  Types:  {}", type_parts.join(" "));
305    }
306
307    // Status
308    let status_parts: Vec<String> = report
309        .summary
310        .by_status
311        .iter()
312        .map(|(s, c)| format!("{}({})", s, c))
313        .collect();
314    if !status_parts.is_empty() {
315        println!("  Status: {}", status_parts.join(" "));
316    }
317
318    // Confidence
319    let conf = &report.summary.confidence;
320    println!(
321        "  Confidence: high({}) medium({}) low({}) unset({})",
322        conf.high, conf.medium, conf.low, conf.unset
323    );
324
325    if summary_only {
326        return;
327    }
328
329    // Active goals
330    if !report.active_goals.is_empty() {
331        println!();
332        println!("{}:", "Active Goals".bold());
333        for tree in &report.active_goals {
334            let conf_str = tree
335                .goal
336                .confidence
337                .map(|c| format!(" {}%", c))
338                .unwrap_or_default();
339            println!(
340                "  #{} {} {}{}",
341                tree.goal.id,
342                format!("[{}]", tree.goal.node_type).yellow(),
343                tree.goal.title,
344                conf_str.dimmed()
345            );
346            for child in &tree.children {
347                let child_conf = child
348                    .confidence
349                    .map(|c| format!(" {}%", c))
350                    .unwrap_or_default();
351                let status_color = match child.status.as_str() {
352                    "superseded" => child.status.dimmed().to_string(),
353                    "abandoned" => child.status.red().to_string(),
354                    _ => child.status.green().to_string(),
355                };
356                println!(
357                    "    ├── #{} {} {} ({}){}",
358                    child.id,
359                    format!("[{}]", child.node_type).blue(),
360                    child.title,
361                    status_color,
362                    child_conf.dimmed()
363                );
364            }
365        }
366    }
367
368    // Orphans
369    if !report.orphan_nodes.is_empty() {
370        println!();
371        println!("{} ({}):", "Orphan Nodes".bold(), report.orphan_nodes.len());
372        for node in &report.orphan_nodes {
373            println!(
374                "  #{} {} \"{}\" - {}",
375                node.id,
376                format!("[{}]", node.node_type).yellow(),
377                node.title,
378                "no connections".red()
379            );
380        }
381    }
382
383    // Recent activity
384    if !report.recent_nodes.is_empty() {
385        println!();
386        println!("{}:", "Recent Activity".bold());
387        for node in &report.recent_nodes {
388            let date = node.created_at.get(..10).unwrap_or(&node.created_at);
389            let conf_str = node
390                .confidence
391                .map(|c| format!(" {}%", c))
392                .unwrap_or_default();
393            println!(
394                "  {}  #{:<3} {} {}{}",
395                date.dimmed(),
396                node.id,
397                format!("[{:<11}]", node.node_type).blue(),
398                node.title,
399                conf_str.dimmed()
400            );
401        }
402    }
403
404    // Coverage gaps
405    if !report.coverage_gaps.is_empty() {
406        println!();
407        println!(
408            "{} ({}):",
409            "Coverage Gaps".bold(),
410            report.coverage_gaps.len()
411        );
412        for gap in &report.coverage_gaps {
413            let gap_desc = match gap.gap_type.as_str() {
414                "goal_without_options" => "no options/decisions",
415                "decision_without_actions" => "no actions",
416                "action_without_outcomes" => "no outcomes",
417                _ => &gap.gap_type,
418            };
419            println!(
420                "  #{:<3} {} \"{}\" - {}",
421                gap.node_id,
422                format!("[{}]", gap.node_type).yellow(),
423                gap.title,
424                gap_desc.red()
425            );
426        }
427    }
428}