Skip to main content

agentic_memory/cli/
commands.rs

1//! CLI command implementations.
2
3use std::path::Path;
4
5use crate::engine::{
6    AnalogicalAnchor, AnalogicalParams, BeliefRevisionParams, CausalParams, CentralityAlgorithm,
7    CentralityParams, ConsolidationOp, ConsolidationParams, DriftParams, GapDetectionParams,
8    GapSeverity, HybridSearchParams, PatternParams, PatternSort, QueryEngine, ShortestPathParams,
9    TextSearchParams, TraversalParams, WriteEngine,
10};
11use crate::format::{AmemReader, AmemWriter};
12use crate::graph::traversal::TraversalDirection;
13use crate::graph::MemoryGraph;
14use crate::types::{AmemResult, CognitiveEvent, CognitiveEventBuilder, Edge, EdgeType, EventType};
15
16/// Create a new empty .amem file.
17pub fn cmd_create(path: &Path, dimension: usize) -> AmemResult<()> {
18    let graph = MemoryGraph::new(dimension);
19    let writer = AmemWriter::new(dimension);
20    writer.write_to_file(&graph, path)?;
21    println!("Created {}", path.display());
22    Ok(())
23}
24
25/// Display information about an .amem file.
26pub fn cmd_info(path: &Path, json: bool) -> AmemResult<()> {
27    let graph = AmemReader::read_from_file(path)?;
28    let file_size = std::fs::metadata(path)?.len();
29
30    if json {
31        let info = serde_json::json!({
32            "file": path.display().to_string(),
33            "version": 1,
34            "dimension": graph.dimension(),
35            "nodes": graph.node_count(),
36            "edges": graph.edge_count(),
37            "sessions": graph.session_index().session_count(),
38            "file_size": file_size,
39            "node_types": {
40                "facts": graph.type_index().count(EventType::Fact),
41                "decisions": graph.type_index().count(EventType::Decision),
42                "inferences": graph.type_index().count(EventType::Inference),
43                "corrections": graph.type_index().count(EventType::Correction),
44                "skills": graph.type_index().count(EventType::Skill),
45                "episodes": graph.type_index().count(EventType::Episode),
46            }
47        });
48        println!(
49            "{}",
50            serde_json::to_string_pretty(&info).unwrap_or_default()
51        );
52    } else {
53        println!("File: {}", path.display());
54        println!("Version: 1");
55        println!("Dimension: {}", graph.dimension());
56        println!("Nodes: {}", graph.node_count());
57        println!("Edges: {}", graph.edge_count());
58        println!("Sessions: {}", graph.session_index().session_count());
59        println!("File size: {}", format_size(file_size));
60        println!("Node types:");
61        println!("  Facts: {}", graph.type_index().count(EventType::Fact));
62        println!(
63            "  Decisions: {}",
64            graph.type_index().count(EventType::Decision)
65        );
66        println!(
67            "  Inferences: {}",
68            graph.type_index().count(EventType::Inference)
69        );
70        println!(
71            "  Corrections: {}",
72            graph.type_index().count(EventType::Correction)
73        );
74        println!("  Skills: {}", graph.type_index().count(EventType::Skill));
75        println!(
76            "  Episodes: {}",
77            graph.type_index().count(EventType::Episode)
78        );
79    }
80    Ok(())
81}
82
83/// Add a cognitive event to the graph.
84pub fn cmd_add(
85    path: &Path,
86    event_type: EventType,
87    content: &str,
88    session_id: u32,
89    confidence: f32,
90    supersedes: Option<u64>,
91    json: bool,
92) -> AmemResult<()> {
93    let mut graph = AmemReader::read_from_file(path)?;
94    let write_engine = WriteEngine::new(graph.dimension());
95
96    let id = if let Some(old_id) = supersedes {
97        write_engine.correct(&mut graph, old_id, content, session_id)?
98    } else {
99        let event = CognitiveEventBuilder::new(event_type, content)
100            .session_id(session_id)
101            .confidence(confidence)
102            .build();
103        graph.add_node(event)?
104    };
105
106    let writer = AmemWriter::new(graph.dimension());
107    writer.write_to_file(&graph, path)?;
108
109    if json {
110        println!(
111            "{}",
112            serde_json::json!({"id": id, "type": event_type.name()})
113        );
114    } else {
115        println!(
116            "Added node {} ({}) to {}",
117            id,
118            event_type.name(),
119            path.display()
120        );
121    }
122    Ok(())
123}
124
125/// Add an edge between two nodes.
126pub fn cmd_link(
127    path: &Path,
128    source_id: u64,
129    target_id: u64,
130    edge_type: EdgeType,
131    weight: f32,
132    json: bool,
133) -> AmemResult<()> {
134    let mut graph = AmemReader::read_from_file(path)?;
135    let edge = Edge::new(source_id, target_id, edge_type, weight);
136    graph.add_edge(edge)?;
137
138    let writer = AmemWriter::new(graph.dimension());
139    writer.write_to_file(&graph, path)?;
140
141    if json {
142        println!(
143            "{}",
144            serde_json::json!({"source": source_id, "target": target_id, "type": edge_type.name()})
145        );
146    } else {
147        println!(
148            "Linked {} --{}--> {}",
149            source_id,
150            edge_type.name(),
151            target_id
152        );
153    }
154    Ok(())
155}
156
157/// Get a specific node by ID.
158pub fn cmd_get(path: &Path, node_id: u64, json: bool) -> AmemResult<()> {
159    let graph = AmemReader::read_from_file(path)?;
160    let node = graph
161        .get_node(node_id)
162        .ok_or(crate::types::AmemError::NodeNotFound(node_id))?;
163
164    let edges_out = graph.edges_from(node_id).len();
165    let edges_in = graph.edges_to(node_id).len();
166
167    if json {
168        let info = serde_json::json!({
169            "id": node.id,
170            "type": node.event_type.name(),
171            "created_at": node.created_at,
172            "session_id": node.session_id,
173            "confidence": node.confidence,
174            "access_count": node.access_count,
175            "decay_score": node.decay_score,
176            "content": node.content,
177            "edges_out": edges_out,
178            "edges_in": edges_in,
179        });
180        println!(
181            "{}",
182            serde_json::to_string_pretty(&info).unwrap_or_default()
183        );
184    } else {
185        println!("Node {}", node.id);
186        println!("  Type: {}", node.event_type.name());
187        println!("  Created: {}", format_timestamp(node.created_at));
188        println!("  Session: {}", node.session_id);
189        println!("  Confidence: {:.2}", node.confidence);
190        println!("  Access count: {}", node.access_count);
191        println!("  Decay score: {:.2}", node.decay_score);
192        println!("  Content: {:?}", node.content);
193        println!("  Edges out: {}", edges_out);
194        println!("  Edges in: {}", edges_in);
195    }
196    Ok(())
197}
198
199/// Run a traversal query.
200#[allow(clippy::too_many_arguments)]
201pub fn cmd_traverse(
202    path: &Path,
203    start_id: u64,
204    edge_types: Vec<EdgeType>,
205    direction: TraversalDirection,
206    max_depth: u32,
207    max_results: usize,
208    min_confidence: f32,
209    json: bool,
210) -> AmemResult<()> {
211    let graph = AmemReader::read_from_file(path)?;
212    let query_engine = QueryEngine::new();
213
214    let et = if edge_types.is_empty() {
215        vec![
216            EdgeType::CausedBy,
217            EdgeType::Supports,
218            EdgeType::Contradicts,
219            EdgeType::Supersedes,
220            EdgeType::RelatedTo,
221            EdgeType::PartOf,
222            EdgeType::TemporalNext,
223        ]
224    } else {
225        edge_types
226    };
227
228    let result = query_engine.traverse(
229        &graph,
230        TraversalParams {
231            start_id,
232            edge_types: et,
233            direction,
234            max_depth,
235            max_results,
236            min_confidence,
237        },
238    )?;
239
240    if json {
241        let nodes_info: Vec<serde_json::Value> = result
242            .visited
243            .iter()
244            .map(|&id| {
245                let depth = result.depths.get(&id).copied().unwrap_or(0);
246                if let Some(node) = graph.get_node(id) {
247                    serde_json::json!({
248                        "id": id,
249                        "depth": depth,
250                        "type": node.event_type.name(),
251                        "content": node.content,
252                    })
253                } else {
254                    serde_json::json!({"id": id, "depth": depth})
255                }
256            })
257            .collect();
258        println!(
259            "{}",
260            serde_json::to_string_pretty(&nodes_info).unwrap_or_default()
261        );
262    } else {
263        for &id in &result.visited {
264            let depth = result.depths.get(&id).copied().unwrap_or(0);
265            let indent = "  ".repeat(depth as usize);
266            if let Some(node) = graph.get_node(id) {
267                println!(
268                    "{}[depth {}] Node {} ({}): {:?}",
269                    indent,
270                    depth,
271                    id,
272                    node.event_type.name(),
273                    node.content
274                );
275            }
276        }
277    }
278    Ok(())
279}
280
281/// Pattern search.
282#[allow(clippy::too_many_arguments)]
283pub fn cmd_search(
284    path: &Path,
285    event_types: Vec<EventType>,
286    session_ids: Vec<u32>,
287    min_confidence: Option<f32>,
288    max_confidence: Option<f32>,
289    created_after: Option<u64>,
290    created_before: Option<u64>,
291    sort_by: PatternSort,
292    limit: usize,
293    json: bool,
294) -> AmemResult<()> {
295    let graph = AmemReader::read_from_file(path)?;
296    let query_engine = QueryEngine::new();
297
298    let results = query_engine.pattern(
299        &graph,
300        PatternParams {
301            event_types,
302            min_confidence,
303            max_confidence,
304            session_ids,
305            created_after,
306            created_before,
307            min_decay_score: None,
308            max_results: limit,
309            sort_by,
310        },
311    )?;
312
313    if json {
314        let nodes: Vec<serde_json::Value> = results
315            .iter()
316            .map(|node| {
317                serde_json::json!({
318                    "id": node.id,
319                    "type": node.event_type.name(),
320                    "confidence": node.confidence,
321                    "content": node.content,
322                    "session_id": node.session_id,
323                })
324            })
325            .collect();
326        println!(
327            "{}",
328            serde_json::to_string_pretty(&nodes).unwrap_or_default()
329        );
330    } else {
331        for node in &results {
332            println!(
333                "Node {} ({}, confidence: {:.2}): {:?}",
334                node.id,
335                node.event_type.name(),
336                node.confidence,
337                node.content
338            );
339        }
340        println!("\n{} results", results.len());
341    }
342    Ok(())
343}
344
345/// Causal impact analysis.
346pub fn cmd_impact(path: &Path, node_id: u64, max_depth: u32, json: bool) -> AmemResult<()> {
347    let graph = AmemReader::read_from_file(path)?;
348    let query_engine = QueryEngine::new();
349
350    let result = query_engine.causal(
351        &graph,
352        CausalParams {
353            node_id,
354            max_depth,
355            dependency_types: vec![EdgeType::CausedBy, EdgeType::Supports],
356        },
357    )?;
358
359    if json {
360        let info = serde_json::json!({
361            "root_id": result.root_id,
362            "direct_dependents": result.dependency_tree.get(&node_id).map(|v| v.len()).unwrap_or(0),
363            "total_dependents": result.dependents.len(),
364            "affected_decisions": result.affected_decisions,
365            "affected_inferences": result.affected_inferences,
366            "dependents": result.dependents,
367        });
368        println!(
369            "{}",
370            serde_json::to_string_pretty(&info).unwrap_or_default()
371        );
372    } else {
373        println!("Impact analysis for node {}", node_id);
374        let direct = result
375            .dependency_tree
376            .get(&node_id)
377            .map(|v| v.len())
378            .unwrap_or(0);
379        println!("  Direct dependents: {}", direct);
380        println!("  Total dependents: {}", result.dependents.len());
381        println!("  Affected decisions: {}", result.affected_decisions);
382        println!("  Affected inferences: {}", result.affected_inferences);
383
384        if !result.dependents.is_empty() {
385            println!("\nDependency tree:");
386            print_dependency_tree(&graph, &result.dependency_tree, node_id, 1);
387        }
388    }
389    Ok(())
390}
391
392fn print_dependency_tree(
393    graph: &MemoryGraph,
394    tree: &std::collections::HashMap<u64, Vec<(u64, EdgeType)>>,
395    node_id: u64,
396    depth: usize,
397) {
398    if let Some(deps) = tree.get(&node_id) {
399        for (dep_id, edge_type) in deps {
400            let indent = "  ".repeat(depth);
401            if let Some(node) = graph.get_node(*dep_id) {
402                println!(
403                    "{}<- Node {} ({}, {})",
404                    indent,
405                    dep_id,
406                    node.event_type.name(),
407                    edge_type.name()
408                );
409            }
410            print_dependency_tree(graph, tree, *dep_id, depth + 1);
411        }
412    }
413}
414
415/// Resolve a node through SUPERSEDES chains.
416pub fn cmd_resolve(path: &Path, node_id: u64, json: bool) -> AmemResult<()> {
417    let graph = AmemReader::read_from_file(path)?;
418    let query_engine = QueryEngine::new();
419
420    let resolved = query_engine.resolve(&graph, node_id)?;
421
422    if json {
423        let info = serde_json::json!({
424            "original_id": node_id,
425            "resolved_id": resolved.id,
426            "type": resolved.event_type.name(),
427            "content": resolved.content,
428        });
429        println!(
430            "{}",
431            serde_json::to_string_pretty(&info).unwrap_or_default()
432        );
433    } else {
434        if resolved.id != node_id {
435            // Show chain
436            let mut chain = vec![node_id];
437            let mut current = node_id;
438            for _ in 0..100 {
439                let mut next = None;
440                for edge in graph.edges_to(current) {
441                    if edge.edge_type == EdgeType::Supersedes {
442                        next = Some(edge.source_id);
443                        break;
444                    }
445                }
446                match next {
447                    Some(n) => {
448                        chain.push(n);
449                        current = n;
450                    }
451                    None => break,
452                }
453            }
454            let chain_str: Vec<String> = chain.iter().map(|id| format!("Node {}", id)).collect();
455            println!("{} (current)", chain_str.join(" -> superseded by -> "));
456        } else {
457            println!("Node {} is already the current version", node_id);
458        }
459        println!("\nCurrent version:");
460        println!("  Node {}", resolved.id);
461        println!("  Type: {}", resolved.event_type.name());
462        println!("  Content: {:?}", resolved.content);
463    }
464    Ok(())
465}
466
467/// List sessions.
468pub fn cmd_sessions(path: &Path, limit: usize, json: bool) -> AmemResult<()> {
469    let graph = AmemReader::read_from_file(path)?;
470    let session_ids = graph.session_index().session_ids();
471
472    if json {
473        let sessions: Vec<serde_json::Value> = session_ids
474            .iter()
475            .rev()
476            .take(limit)
477            .map(|&sid| {
478                serde_json::json!({
479                    "session_id": sid,
480                    "node_count": graph.session_index().node_count(sid),
481                })
482            })
483            .collect();
484        println!(
485            "{}",
486            serde_json::to_string_pretty(&sessions).unwrap_or_default()
487        );
488    } else {
489        println!("Sessions in {}:", path.display());
490        for &sid in session_ids.iter().rev().take(limit) {
491            let count = graph.session_index().node_count(sid);
492            println!("  Session {}: {} nodes", sid, count);
493        }
494        println!("  Total: {} sessions", session_ids.len());
495    }
496    Ok(())
497}
498
499/// Export graph as JSON.
500pub fn cmd_export(
501    path: &Path,
502    nodes_only: bool,
503    session: Option<u32>,
504    pretty: bool,
505) -> AmemResult<()> {
506    let graph = AmemReader::read_from_file(path)?;
507
508    let nodes: Vec<&CognitiveEvent> = if let Some(sid) = session {
509        let ids = graph.session_index().get_session(sid);
510        ids.iter().filter_map(|&id| graph.get_node(id)).collect()
511    } else {
512        graph.nodes().iter().collect()
513    };
514
515    let nodes_json: Vec<serde_json::Value> = nodes
516        .iter()
517        .map(|n| {
518            serde_json::json!({
519                "id": n.id,
520                "event_type": n.event_type.name(),
521                "created_at": n.created_at,
522                "session_id": n.session_id,
523                "confidence": n.confidence,
524                "access_count": n.access_count,
525                "last_accessed": n.last_accessed,
526                "decay_score": n.decay_score,
527                "content": n.content,
528            })
529        })
530        .collect();
531
532    let output = if nodes_only {
533        serde_json::json!({"nodes": nodes_json})
534    } else {
535        let edges_json: Vec<serde_json::Value> = graph
536            .edges()
537            .iter()
538            .map(|e| {
539                serde_json::json!({
540                    "source_id": e.source_id,
541                    "target_id": e.target_id,
542                    "edge_type": e.edge_type.name(),
543                    "weight": e.weight,
544                    "created_at": e.created_at,
545                })
546            })
547            .collect();
548        serde_json::json!({"nodes": nodes_json, "edges": edges_json})
549    };
550
551    if pretty {
552        println!(
553            "{}",
554            serde_json::to_string_pretty(&output).unwrap_or_default()
555        );
556    } else {
557        println!("{}", serde_json::to_string(&output).unwrap_or_default());
558    }
559    Ok(())
560}
561
562/// Import nodes and edges from JSON.
563pub fn cmd_import(path: &Path, json_path: &Path) -> AmemResult<()> {
564    let mut graph = AmemReader::read_from_file(path)?;
565    let json_data = std::fs::read_to_string(json_path)?;
566    let parsed: serde_json::Value = serde_json::from_str(&json_data)
567        .map_err(|e| crate::types::AmemError::Compression(e.to_string()))?;
568
569    let mut added_nodes = 0;
570    let mut added_edges = 0;
571
572    if let Some(nodes) = parsed.get("nodes").and_then(|v| v.as_array()) {
573        for node_val in nodes {
574            let event_type = node_val
575                .get("event_type")
576                .and_then(|v| v.as_str())
577                .and_then(EventType::from_name)
578                .unwrap_or(EventType::Fact);
579            let content = node_val
580                .get("content")
581                .and_then(|v| v.as_str())
582                .unwrap_or("");
583            let session_id = node_val
584                .get("session_id")
585                .and_then(|v| v.as_u64())
586                .unwrap_or(0) as u32;
587            let confidence = node_val
588                .get("confidence")
589                .and_then(|v| v.as_f64())
590                .unwrap_or(1.0) as f32;
591
592            let event = CognitiveEventBuilder::new(event_type, content)
593                .session_id(session_id)
594                .confidence(confidence)
595                .build();
596            graph.add_node(event)?;
597            added_nodes += 1;
598        }
599    }
600
601    if let Some(edges) = parsed.get("edges").and_then(|v| v.as_array()) {
602        for edge_val in edges {
603            let source_id = edge_val
604                .get("source_id")
605                .and_then(|v| v.as_u64())
606                .unwrap_or(0);
607            let target_id = edge_val
608                .get("target_id")
609                .and_then(|v| v.as_u64())
610                .unwrap_or(0);
611            let edge_type = edge_val
612                .get("edge_type")
613                .and_then(|v| v.as_str())
614                .and_then(EdgeType::from_name)
615                .unwrap_or(EdgeType::RelatedTo);
616            let weight = edge_val
617                .get("weight")
618                .and_then(|v| v.as_f64())
619                .unwrap_or(1.0) as f32;
620
621            let edge = Edge::new(source_id, target_id, edge_type, weight);
622            if graph.add_edge(edge).is_ok() {
623                added_edges += 1;
624            }
625        }
626    }
627
628    let writer = AmemWriter::new(graph.dimension());
629    writer.write_to_file(&graph, path)?;
630
631    println!("Imported {} nodes and {} edges", added_nodes, added_edges);
632    Ok(())
633}
634
635/// Run decay calculations.
636pub fn cmd_decay(path: &Path, threshold: f32, json: bool) -> AmemResult<()> {
637    let mut graph = AmemReader::read_from_file(path)?;
638    let write_engine = WriteEngine::new(graph.dimension());
639    let current_time = crate::types::now_micros();
640    let report = write_engine.run_decay(&mut graph, current_time)?;
641
642    let writer = AmemWriter::new(graph.dimension());
643    writer.write_to_file(&graph, path)?;
644
645    let low: Vec<u64> = report
646        .low_importance_nodes
647        .iter()
648        .filter(|&&id| {
649            graph
650                .get_node(id)
651                .map(|n| n.decay_score < threshold)
652                .unwrap_or(false)
653        })
654        .copied()
655        .collect();
656
657    if json {
658        let info = serde_json::json!({
659            "nodes_decayed": report.nodes_decayed,
660            "low_importance_count": low.len(),
661            "low_importance_nodes": low,
662        });
663        println!(
664            "{}",
665            serde_json::to_string_pretty(&info).unwrap_or_default()
666        );
667    } else {
668        println!("Decay complete:");
669        println!("  Nodes updated: {}", report.nodes_decayed);
670        println!(
671            "  Low importance (below {}): {} nodes",
672            threshold,
673            low.len()
674        );
675    }
676    Ok(())
677}
678
679/// Detailed statistics.
680pub fn cmd_stats(path: &Path, json: bool) -> AmemResult<()> {
681    let graph = AmemReader::read_from_file(path)?;
682    let file_size = std::fs::metadata(path)?.len();
683
684    let node_count = graph.node_count();
685    let edge_count = graph.edge_count();
686    let avg_edges = if node_count > 0 {
687        edge_count as f64 / node_count as f64
688    } else {
689        0.0
690    };
691    let max_edges = graph
692        .nodes()
693        .iter()
694        .map(|n| graph.edges_from(n.id).len())
695        .max()
696        .unwrap_or(0);
697    let session_count = graph.session_index().session_count();
698    let avg_nodes_per_session = if session_count > 0 {
699        node_count as f64 / session_count as f64
700    } else {
701        0.0
702    };
703
704    // Confidence distribution
705    let mut conf_buckets = [0usize; 5];
706    for node in graph.nodes() {
707        let bucket = ((node.confidence * 5.0).floor() as usize).min(4);
708        conf_buckets[bucket] += 1;
709    }
710
711    if json {
712        let info = serde_json::json!({
713            "nodes": node_count,
714            "edges": edge_count,
715            "avg_edges_per_node": avg_edges,
716            "max_edges_per_node": max_edges,
717            "sessions": session_count,
718            "file_size": file_size,
719        });
720        println!(
721            "{}",
722            serde_json::to_string_pretty(&info).unwrap_or_default()
723        );
724    } else {
725        println!("Graph Statistics:");
726        println!("  Nodes: {}", node_count);
727        println!("  Edges: {}", edge_count);
728        println!("  Avg edges per node: {:.2}", avg_edges);
729        println!("  Max edges per node: {}", max_edges);
730        println!("  Sessions: {}", session_count);
731        println!("  Avg nodes per session: {:.0}", avg_nodes_per_session);
732        println!();
733        println!("  Confidence distribution:");
734        println!("    0.0-0.2: {} nodes", conf_buckets[0]);
735        println!("    0.2-0.4: {} nodes", conf_buckets[1]);
736        println!("    0.4-0.6: {} nodes", conf_buckets[2]);
737        println!("    0.6-0.8: {} nodes", conf_buckets[3]);
738        println!("    0.8-1.0: {} nodes", conf_buckets[4]);
739        println!();
740        println!("  Edge type distribution:");
741        for et_val in 0u8..=6 {
742            if let Some(et) = EdgeType::from_u8(et_val) {
743                let count = graph.edges().iter().filter(|e| e.edge_type == et).count();
744                if count > 0 {
745                    println!("    {}: {}", et.name(), count);
746                }
747            }
748        }
749    }
750    Ok(())
751}
752
753fn format_size(bytes: u64) -> String {
754    if bytes < 1024 {
755        format!("{} B", bytes)
756    } else if bytes < 1024 * 1024 {
757        format!("{:.1} KB", bytes as f64 / 1024.0)
758    } else if bytes < 1024 * 1024 * 1024 {
759        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
760    } else {
761        format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
762    }
763}
764
765fn format_timestamp(micros: u64) -> String {
766    let secs = (micros / 1_000_000) as i64;
767    let dt = chrono::DateTime::from_timestamp(secs, 0);
768    match dt {
769        Some(dt) => dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
770        None => format!("{} us", micros),
771    }
772}
773
774// ==================== New Query Expansion Commands ====================
775
776/// BM25 text search.
777pub fn cmd_text_search(
778    path: &Path,
779    query: &str,
780    event_types: Vec<EventType>,
781    session_ids: Vec<u32>,
782    limit: usize,
783    min_score: f32,
784    json: bool,
785) -> AmemResult<()> {
786    let graph = AmemReader::read_from_file(path)?;
787    let query_engine = QueryEngine::new();
788
789    let start = std::time::Instant::now();
790    let results = query_engine.text_search(
791        &graph,
792        graph.term_index(),
793        graph.doc_lengths(),
794        TextSearchParams {
795            query: query.to_string(),
796            max_results: limit,
797            event_types,
798            session_ids,
799            min_score,
800        },
801    )?;
802    let elapsed = start.elapsed();
803
804    if json {
805        let matches: Vec<serde_json::Value> = results
806            .iter()
807            .enumerate()
808            .map(|(i, m)| {
809                let node = graph.get_node(m.node_id);
810                serde_json::json!({
811                    "rank": i + 1,
812                    "node_id": m.node_id,
813                    "score": m.score,
814                    "matched_terms": m.matched_terms,
815                    "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
816                    "content": node.map(|n| n.content.as_str()).unwrap_or(""),
817                })
818            })
819            .collect();
820        println!(
821            "{}",
822            serde_json::to_string_pretty(&serde_json::json!({
823                "query": query,
824                "results": matches,
825                "total": results.len(),
826                "elapsed_ms": elapsed.as_secs_f64() * 1000.0,
827            }))
828            .unwrap_or_default()
829        );
830    } else {
831        println!("Text search for {:?} in {}:", query, path.display());
832        for (i, m) in results.iter().enumerate() {
833            if let Some(node) = graph.get_node(m.node_id) {
834                let preview = if node.content.len() > 60 {
835                    format!("{}...", &node.content[..60])
836                } else {
837                    node.content.clone()
838                };
839                println!(
840                    "  #{:<3} Node {} ({}) [score: {:.2}]  {:?}",
841                    i + 1,
842                    m.node_id,
843                    node.event_type.name(),
844                    m.score,
845                    preview
846                );
847            }
848        }
849        println!(
850            "  {} results ({:.1}ms)",
851            results.len(),
852            elapsed.as_secs_f64() * 1000.0
853        );
854    }
855    Ok(())
856}
857
858/// Hybrid BM25 + vector search.
859#[allow(clippy::too_many_arguments)]
860pub fn cmd_hybrid_search(
861    path: &Path,
862    query: &str,
863    text_weight: f32,
864    vec_weight: f32,
865    limit: usize,
866    event_types: Vec<EventType>,
867    json: bool,
868) -> AmemResult<()> {
869    let graph = AmemReader::read_from_file(path)?;
870    let query_engine = QueryEngine::new();
871
872    let results = query_engine.hybrid_search(
873        &graph,
874        graph.term_index(),
875        graph.doc_lengths(),
876        HybridSearchParams {
877            query_text: query.to_string(),
878            query_vec: None,
879            max_results: limit,
880            event_types,
881            text_weight,
882            vector_weight: vec_weight,
883            rrf_k: 60,
884        },
885    )?;
886
887    if json {
888        let matches: Vec<serde_json::Value> = results
889            .iter()
890            .enumerate()
891            .map(|(i, m)| {
892                let node = graph.get_node(m.node_id);
893                serde_json::json!({
894                    "rank": i + 1,
895                    "node_id": m.node_id,
896                    "combined_score": m.combined_score,
897                    "text_rank": m.text_rank,
898                    "vector_rank": m.vector_rank,
899                    "text_score": m.text_score,
900                    "vector_similarity": m.vector_similarity,
901                    "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
902                    "content": node.map(|n| n.content.as_str()).unwrap_or(""),
903                })
904            })
905            .collect();
906        println!(
907            "{}",
908            serde_json::to_string_pretty(&serde_json::json!({
909                "query": query,
910                "results": matches,
911                "total": results.len(),
912            }))
913            .unwrap_or_default()
914        );
915    } else {
916        println!("Hybrid search for {:?}:", query);
917        for (i, m) in results.iter().enumerate() {
918            if let Some(node) = graph.get_node(m.node_id) {
919                let preview = if node.content.len() > 60 {
920                    format!("{}...", &node.content[..60])
921                } else {
922                    node.content.clone()
923                };
924                println!(
925                    "  #{:<3} Node {} ({}) [score: {:.4}]  {:?}",
926                    i + 1,
927                    m.node_id,
928                    node.event_type.name(),
929                    m.combined_score,
930                    preview
931                );
932            }
933        }
934        println!("  {} results", results.len());
935    }
936    Ok(())
937}
938
939/// Centrality analysis.
940#[allow(clippy::too_many_arguments)]
941pub fn cmd_centrality(
942    path: &Path,
943    algorithm: &str,
944    damping: f32,
945    edge_types: Vec<EdgeType>,
946    event_types: Vec<EventType>,
947    limit: usize,
948    iterations: u32,
949    json: bool,
950) -> AmemResult<()> {
951    let graph = AmemReader::read_from_file(path)?;
952    let query_engine = QueryEngine::new();
953
954    let algo = match algorithm {
955        "degree" => CentralityAlgorithm::Degree,
956        "betweenness" => CentralityAlgorithm::Betweenness,
957        _ => CentralityAlgorithm::PageRank { damping },
958    };
959
960    let result = query_engine.centrality(
961        &graph,
962        CentralityParams {
963            algorithm: algo,
964            max_iterations: iterations,
965            tolerance: 1e-6,
966            top_k: limit,
967            event_types,
968            edge_types,
969        },
970    )?;
971
972    if json {
973        let scores: Vec<serde_json::Value> = result
974            .scores
975            .iter()
976            .enumerate()
977            .map(|(i, (id, score))| {
978                let node = graph.get_node(*id);
979                serde_json::json!({
980                    "rank": i + 1,
981                    "node_id": id,
982                    "score": score,
983                    "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
984                    "content": node.map(|n| n.content.as_str()).unwrap_or(""),
985                })
986            })
987            .collect();
988        println!(
989            "{}",
990            serde_json::to_string_pretty(&serde_json::json!({
991                "algorithm": algorithm,
992                "converged": result.converged,
993                "iterations": result.iterations,
994                "scores": scores,
995            }))
996            .unwrap_or_default()
997        );
998    } else {
999        let algo_name = match algorithm {
1000            "degree" => "Degree",
1001            "betweenness" => "Betweenness",
1002            _ => "PageRank",
1003        };
1004        println!(
1005            "{} centrality (converged: {}, iterations: {}):",
1006            algo_name, result.converged, result.iterations
1007        );
1008        for (i, (id, score)) in result.scores.iter().enumerate() {
1009            if let Some(node) = graph.get_node(*id) {
1010                let preview = if node.content.len() > 50 {
1011                    format!("{}...", &node.content[..50])
1012                } else {
1013                    node.content.clone()
1014                };
1015                println!(
1016                    "  #{:<3} Node {} ({}) [score: {:.6}]  {:?}",
1017                    i + 1,
1018                    id,
1019                    node.event_type.name(),
1020                    score,
1021                    preview
1022                );
1023            }
1024        }
1025    }
1026    Ok(())
1027}
1028
1029/// Shortest path.
1030#[allow(clippy::too_many_arguments)]
1031pub fn cmd_path(
1032    path: &Path,
1033    source_id: u64,
1034    target_id: u64,
1035    edge_types: Vec<EdgeType>,
1036    direction: TraversalDirection,
1037    max_depth: u32,
1038    weighted: bool,
1039    json: bool,
1040) -> AmemResult<()> {
1041    let graph = AmemReader::read_from_file(path)?;
1042    let query_engine = QueryEngine::new();
1043
1044    let result = query_engine.shortest_path(
1045        &graph,
1046        ShortestPathParams {
1047            source_id,
1048            target_id,
1049            edge_types,
1050            direction,
1051            max_depth,
1052            weighted,
1053        },
1054    )?;
1055
1056    if json {
1057        let path_info: Vec<serde_json::Value> = result
1058            .path
1059            .iter()
1060            .map(|&id| {
1061                let node = graph.get_node(id);
1062                serde_json::json!({
1063                    "node_id": id,
1064                    "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1065                    "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1066                })
1067            })
1068            .collect();
1069        let edges_info: Vec<serde_json::Value> = result
1070            .edges
1071            .iter()
1072            .map(|e| {
1073                serde_json::json!({
1074                    "source_id": e.source_id,
1075                    "target_id": e.target_id,
1076                    "edge_type": e.edge_type.name(),
1077                    "weight": e.weight,
1078                })
1079            })
1080            .collect();
1081        println!(
1082            "{}",
1083            serde_json::to_string_pretty(&serde_json::json!({
1084                "found": result.found,
1085                "cost": result.cost,
1086                "path": path_info,
1087                "edges": edges_info,
1088            }))
1089            .unwrap_or_default()
1090        );
1091    } else if result.found {
1092        println!(
1093            "Path from node {} to node {} ({} hops, cost: {:.2}):",
1094            source_id,
1095            target_id,
1096            result.path.len().saturating_sub(1),
1097            result.cost
1098        );
1099        // Print path as chain
1100        let mut parts: Vec<String> = Vec::new();
1101        for (i, &id) in result.path.iter().enumerate() {
1102            if let Some(node) = graph.get_node(id) {
1103                let label = format!("Node {} ({})", id, node.event_type.name());
1104                if i < result.edges.len() {
1105                    parts.push(format!(
1106                        "{} --[{}]-->",
1107                        label,
1108                        result.edges[i].edge_type.name()
1109                    ));
1110                } else {
1111                    parts.push(label);
1112                }
1113            }
1114        }
1115        println!("  {}", parts.join(" "));
1116    } else {
1117        println!(
1118            "No path found from node {} to node {}",
1119            source_id, target_id
1120        );
1121    }
1122    Ok(())
1123}
1124
1125/// Belief revision.
1126pub fn cmd_revise(
1127    path: &Path,
1128    hypothesis: &str,
1129    threshold: f32,
1130    max_depth: u32,
1131    confidence: f32,
1132    json: bool,
1133) -> AmemResult<()> {
1134    let graph = AmemReader::read_from_file(path)?;
1135    let query_engine = QueryEngine::new();
1136
1137    let report = query_engine.belief_revision(
1138        &graph,
1139        BeliefRevisionParams {
1140            hypothesis: hypothesis.to_string(),
1141            hypothesis_vec: None,
1142            contradiction_threshold: threshold,
1143            max_depth,
1144            hypothesis_confidence: confidence,
1145        },
1146    )?;
1147
1148    if json {
1149        let contradicted: Vec<serde_json::Value> = report
1150            .contradicted
1151            .iter()
1152            .map(|c| {
1153                let node = graph.get_node(c.node_id);
1154                serde_json::json!({
1155                    "node_id": c.node_id,
1156                    "strength": c.contradiction_strength,
1157                    "reason": c.reason,
1158                    "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1159                    "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1160                })
1161            })
1162            .collect();
1163        let weakened: Vec<serde_json::Value> = report
1164            .weakened
1165            .iter()
1166            .map(|w| {
1167                serde_json::json!({
1168                    "node_id": w.node_id,
1169                    "original_confidence": w.original_confidence,
1170                    "revised_confidence": w.revised_confidence,
1171                    "depth": w.depth,
1172                })
1173            })
1174            .collect();
1175        let cascade: Vec<serde_json::Value> = report
1176            .cascade
1177            .iter()
1178            .map(|s| {
1179                serde_json::json!({
1180                    "node_id": s.node_id,
1181                    "via_edge": s.via_edge.name(),
1182                    "from_node": s.from_node,
1183                    "depth": s.depth,
1184                })
1185            })
1186            .collect();
1187        println!(
1188            "{}",
1189            serde_json::to_string_pretty(&serde_json::json!({
1190                "hypothesis": hypothesis,
1191                "contradicted": contradicted,
1192                "weakened": weakened,
1193                "invalidated_decisions": report.invalidated_decisions,
1194                "total_affected": report.total_affected,
1195                "cascade": cascade,
1196            }))
1197            .unwrap_or_default()
1198        );
1199    } else {
1200        println!("Belief revision: {:?}\n", hypothesis);
1201        if report.contradicted.is_empty() {
1202            println!("  No contradictions found.");
1203        } else {
1204            println!("Directly contradicted:");
1205            for c in &report.contradicted {
1206                if let Some(node) = graph.get_node(c.node_id) {
1207                    println!(
1208                        "  X Node {} ({}): {:?} [score: {:.2}]",
1209                        c.node_id,
1210                        node.event_type.name(),
1211                        node.content,
1212                        c.contradiction_strength
1213                    );
1214                }
1215            }
1216        }
1217        if !report.weakened.is_empty() {
1218            println!("\nCascade effects:");
1219            for w in &report.weakened {
1220                if let Some(node) = graph.get_node(w.node_id) {
1221                    let action = if node.event_type == EventType::Decision {
1222                        "INVALIDATED"
1223                    } else {
1224                        "weakened"
1225                    };
1226                    println!(
1227                        "  ! Node {} ({}): {} ({:.2} -> {:.2})",
1228                        w.node_id,
1229                        node.event_type.name(),
1230                        action,
1231                        w.original_confidence,
1232                        w.revised_confidence
1233                    );
1234                }
1235            }
1236        }
1237        println!(
1238            "\nTotal affected: {} nodes ({} decisions)",
1239            report.total_affected,
1240            report.invalidated_decisions.len()
1241        );
1242    }
1243    Ok(())
1244}
1245
1246/// Gap detection.
1247#[allow(clippy::too_many_arguments)]
1248pub fn cmd_gaps(
1249    path: &Path,
1250    threshold: f32,
1251    min_support: u32,
1252    limit: usize,
1253    sort: &str,
1254    session_range: Option<(u32, u32)>,
1255    json: bool,
1256) -> AmemResult<()> {
1257    let graph = AmemReader::read_from_file(path)?;
1258    let query_engine = QueryEngine::new();
1259
1260    let sort_by = match sort {
1261        "recent" => GapSeverity::MostRecent,
1262        "confidence" => GapSeverity::LowestConfidence,
1263        _ => GapSeverity::HighestImpact,
1264    };
1265
1266    let report = query_engine.gap_detection(
1267        &graph,
1268        GapDetectionParams {
1269            confidence_threshold: threshold,
1270            min_support_count: min_support,
1271            max_results: limit,
1272            session_range,
1273            sort_by,
1274        },
1275    )?;
1276
1277    if json {
1278        let gaps: Vec<serde_json::Value> = report
1279            .gaps
1280            .iter()
1281            .map(|g| {
1282                let node = graph.get_node(g.node_id);
1283                serde_json::json!({
1284                    "node_id": g.node_id,
1285                    "gap_type": format!("{:?}", g.gap_type),
1286                    "severity": g.severity,
1287                    "description": g.description,
1288                    "downstream_count": g.downstream_count,
1289                    "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1290                    "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1291                })
1292            })
1293            .collect();
1294        println!(
1295            "{}",
1296            serde_json::to_string_pretty(&serde_json::json!({
1297                "gaps": gaps,
1298                "health_score": report.summary.health_score,
1299                "summary": {
1300                    "total_gaps": report.summary.total_gaps,
1301                    "unjustified_decisions": report.summary.unjustified_decisions,
1302                    "single_source_inferences": report.summary.single_source_inferences,
1303                    "low_confidence_foundations": report.summary.low_confidence_foundations,
1304                    "unstable_knowledge": report.summary.unstable_knowledge,
1305                    "stale_evidence": report.summary.stale_evidence,
1306                }
1307            }))
1308            .unwrap_or_default()
1309        );
1310    } else {
1311        println!("Reasoning gaps in {}:\n", path.display());
1312        for g in &report.gaps {
1313            let severity_marker = if g.severity > 0.8 {
1314                "CRITICAL"
1315            } else if g.severity > 0.5 {
1316                "WARNING"
1317            } else {
1318                "INFO"
1319            };
1320            if let Some(node) = graph.get_node(g.node_id) {
1321                println!(
1322                    "  {}: Node {} ({}) -- {:?}",
1323                    severity_marker,
1324                    g.node_id,
1325                    node.event_type.name(),
1326                    g.gap_type
1327                );
1328                let preview = if node.content.len() > 60 {
1329                    format!("{}...", &node.content[..60])
1330                } else {
1331                    node.content.clone()
1332                };
1333                println!("     {:?}", preview);
1334                println!(
1335                    "     Severity: {:.2} | {} downstream dependents",
1336                    g.severity, g.downstream_count
1337                );
1338                println!();
1339            }
1340        }
1341        println!(
1342            "Health score: {:.2} / 1.00  |  {} gaps found",
1343            report.summary.health_score, report.summary.total_gaps
1344        );
1345    }
1346    Ok(())
1347}
1348
1349/// Analogical query.
1350#[allow(clippy::too_many_arguments)]
1351pub fn cmd_analogy(
1352    path: &Path,
1353    description: &str,
1354    limit: usize,
1355    min_similarity: f32,
1356    exclude_sessions: Vec<u32>,
1357    depth: u32,
1358    json: bool,
1359) -> AmemResult<()> {
1360    let graph = AmemReader::read_from_file(path)?;
1361    let query_engine = QueryEngine::new();
1362
1363    // Find the best matching node to use as anchor
1364    let tokenizer = crate::engine::Tokenizer::new();
1365    let query_terms: std::collections::HashSet<String> =
1366        tokenizer.tokenize(description).into_iter().collect();
1367
1368    // Find the most relevant node as the anchor center
1369    let mut best_id = None;
1370    let mut best_score = -1.0f32;
1371    for node in graph.nodes() {
1372        let node_terms: std::collections::HashSet<String> =
1373            tokenizer.tokenize(&node.content).into_iter().collect();
1374        let overlap = query_terms.intersection(&node_terms).count();
1375        let score = if query_terms.is_empty() {
1376            0.0
1377        } else {
1378            overlap as f32 / query_terms.len() as f32
1379        };
1380        if score > best_score {
1381            best_score = score;
1382            best_id = Some(node.id);
1383        }
1384    }
1385
1386    let anchor = match best_id {
1387        Some(id) => AnalogicalAnchor::Node(id),
1388        None => {
1389            println!("No matching nodes found for the description.");
1390            return Ok(());
1391        }
1392    };
1393
1394    let results = query_engine.analogical(
1395        &graph,
1396        AnalogicalParams {
1397            anchor,
1398            context_depth: depth,
1399            max_results: limit,
1400            min_similarity,
1401            exclude_sessions,
1402        },
1403    )?;
1404
1405    if json {
1406        let analogies: Vec<serde_json::Value> = results
1407            .iter()
1408            .map(|a| {
1409                let node = graph.get_node(a.center_id);
1410                serde_json::json!({
1411                    "center_id": a.center_id,
1412                    "structural_similarity": a.structural_similarity,
1413                    "content_similarity": a.content_similarity,
1414                    "combined_score": a.combined_score,
1415                    "subgraph_nodes": a.subgraph_nodes,
1416                    "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1417                    "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1418                })
1419            })
1420            .collect();
1421        println!(
1422            "{}",
1423            serde_json::to_string_pretty(&serde_json::json!({
1424                "description": description,
1425                "analogies": analogies,
1426            }))
1427            .unwrap_or_default()
1428        );
1429    } else {
1430        println!("Analogies for {:?}:\n", description);
1431        for (i, a) in results.iter().enumerate() {
1432            if let Some(node) = graph.get_node(a.center_id) {
1433                println!(
1434                    "  #{} Node {} ({}) [combined: {:.3}]",
1435                    i + 1,
1436                    a.center_id,
1437                    node.event_type.name(),
1438                    a.combined_score
1439                );
1440                println!(
1441                    "     Structural: {:.3} | Content: {:.3} | Subgraph: {} nodes",
1442                    a.structural_similarity,
1443                    a.content_similarity,
1444                    a.subgraph_nodes.len()
1445                );
1446            }
1447        }
1448        if results.is_empty() {
1449            println!("  No analogies found.");
1450        }
1451    }
1452    Ok(())
1453}
1454
1455/// Consolidation.
1456#[allow(clippy::too_many_arguments)]
1457pub fn cmd_consolidate(
1458    path: &Path,
1459    deduplicate: bool,
1460    link_contradictions: bool,
1461    promote_inferences: bool,
1462    prune: bool,
1463    compress_episodes: bool,
1464    all: bool,
1465    threshold: f32,
1466    confirm: bool,
1467    backup: Option<std::path::PathBuf>,
1468    json: bool,
1469) -> AmemResult<()> {
1470    let mut graph = AmemReader::read_from_file(path)?;
1471    let query_engine = QueryEngine::new();
1472
1473    let dry_run = !confirm;
1474
1475    // Build operations list
1476    let mut ops = Vec::new();
1477    if deduplicate || all {
1478        ops.push(ConsolidationOp::DeduplicateFacts { threshold });
1479    }
1480    if link_contradictions || all {
1481        ops.push(ConsolidationOp::LinkContradictions {
1482            threshold: threshold.min(0.8),
1483        });
1484    }
1485    if promote_inferences || all {
1486        ops.push(ConsolidationOp::PromoteInferences {
1487            min_access: 3,
1488            min_confidence: 0.8,
1489        });
1490    }
1491    if prune || all {
1492        ops.push(ConsolidationOp::PruneOrphans { max_decay: 0.1 });
1493    }
1494    if compress_episodes || all {
1495        ops.push(ConsolidationOp::CompressEpisodes { group_size: 3 });
1496    }
1497
1498    if ops.is_empty() {
1499        eprintln!("No operations specified. Use --deduplicate, --link-contradictions, --promote-inferences, --prune, --compress-episodes, or --all");
1500        return Ok(());
1501    }
1502
1503    // If not dry-run, create backup first
1504    let backup_path = if !dry_run {
1505        let bp = backup.unwrap_or_else(|| {
1506            let mut p = path.to_path_buf();
1507            let name = p
1508                .file_stem()
1509                .unwrap_or_default()
1510                .to_string_lossy()
1511                .to_string();
1512            p.set_file_name(format!("{}.pre-consolidation.amem", name));
1513            p
1514        });
1515        std::fs::copy(path, &bp)?;
1516        Some(bp)
1517    } else {
1518        None
1519    };
1520
1521    let report = query_engine.consolidate(
1522        &mut graph,
1523        ConsolidationParams {
1524            session_range: None,
1525            operations: ops,
1526            dry_run,
1527            backup_path: backup_path.clone(),
1528        },
1529    )?;
1530
1531    // Write back if not dry-run
1532    if !dry_run {
1533        let writer = AmemWriter::new(graph.dimension());
1534        writer.write_to_file(&graph, path)?;
1535    }
1536
1537    if json {
1538        let actions: Vec<serde_json::Value> = report
1539            .actions
1540            .iter()
1541            .map(|a| {
1542                serde_json::json!({
1543                    "operation": a.operation,
1544                    "description": a.description,
1545                    "affected_nodes": a.affected_nodes,
1546                })
1547            })
1548            .collect();
1549        println!(
1550            "{}",
1551            serde_json::to_string_pretty(&serde_json::json!({
1552                "dry_run": dry_run,
1553                "deduplicated": report.deduplicated,
1554                "contradictions_linked": report.contradictions_linked,
1555                "inferences_promoted": report.inferences_promoted,
1556                "backup_path": backup_path.map(|p| p.display().to_string()),
1557                "actions": actions,
1558            }))
1559            .unwrap_or_default()
1560        );
1561    } else {
1562        if dry_run {
1563            println!("Consolidation DRY RUN (use --confirm to apply):\n");
1564        } else {
1565            println!("Consolidation applied:\n");
1566            if let Some(bp) = &backup_path {
1567                println!("  Backup: {}", bp.display());
1568            }
1569        }
1570        for a in &report.actions {
1571            println!("  [{}] {}", a.operation, a.description);
1572        }
1573        println!();
1574        println!("  Deduplicated: {}", report.deduplicated);
1575        println!("  Contradictions linked: {}", report.contradictions_linked);
1576        println!("  Inferences promoted: {}", report.inferences_promoted);
1577    }
1578    Ok(())
1579}
1580
1581/// Drift detection.
1582pub fn cmd_drift(
1583    path: &Path,
1584    topic: &str,
1585    limit: usize,
1586    min_relevance: f32,
1587    json: bool,
1588) -> AmemResult<()> {
1589    let graph = AmemReader::read_from_file(path)?;
1590    let query_engine = QueryEngine::new();
1591
1592    let report = query_engine.drift_detection(
1593        &graph,
1594        DriftParams {
1595            topic: topic.to_string(),
1596            topic_vec: None,
1597            max_results: limit,
1598            min_relevance,
1599        },
1600    )?;
1601
1602    if json {
1603        let timelines: Vec<serde_json::Value> = report
1604            .timelines
1605            .iter()
1606            .map(|t| {
1607                let snapshots: Vec<serde_json::Value> = t
1608                    .snapshots
1609                    .iter()
1610                    .map(|s| {
1611                        serde_json::json!({
1612                            "node_id": s.node_id,
1613                            "session_id": s.session_id,
1614                            "confidence": s.confidence,
1615                            "content_preview": s.content_preview,
1616                            "change_type": format!("{:?}", s.change_type),
1617                        })
1618                    })
1619                    .collect();
1620                serde_json::json!({
1621                    "snapshots": snapshots,
1622                    "change_count": t.change_count,
1623                    "correction_count": t.correction_count,
1624                    "contradiction_count": t.contradiction_count,
1625                })
1626            })
1627            .collect();
1628        println!(
1629            "{}",
1630            serde_json::to_string_pretty(&serde_json::json!({
1631                "topic": topic,
1632                "timelines": timelines,
1633                "stability": report.stability,
1634                "likely_to_change": report.likely_to_change,
1635            }))
1636            .unwrap_or_default()
1637        );
1638    } else {
1639        println!("Drift analysis for {:?}:\n", topic);
1640        for (i, t) in report.timelines.iter().enumerate() {
1641            println!(
1642                "Timeline {} ({} changes, stability: {:.1}):",
1643                i + 1,
1644                t.change_count,
1645                report.stability
1646            );
1647            for s in &t.snapshots {
1648                let change = format!("{:?}", s.change_type).to_uppercase();
1649                println!(
1650                    "  Session {:>3}: {:<12} {:?}  [{:.2}]",
1651                    s.session_id, change, s.content_preview, s.confidence
1652                );
1653            }
1654            println!();
1655        }
1656        if report.timelines.is_empty() {
1657            println!("  No relevant nodes found for this topic.");
1658        } else {
1659            let prediction = if report.likely_to_change {
1660                "LIKELY TO CHANGE"
1661            } else {
1662                "STABLE"
1663            };
1664            println!(
1665                "Overall stability: {:.2} | Prediction: {}",
1666                report.stability, prediction
1667            );
1668        }
1669    }
1670    Ok(())
1671}