1use 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
16pub 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
25pub 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
83pub 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
125pub 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
157pub 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#[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#[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
345pub 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
415pub 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 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
467pub 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
499pub 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
562pub 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
635pub 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
679pub 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 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
774pub 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#[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#[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#[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 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
1125pub 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#[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#[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 let tokenizer = crate::engine::Tokenizer::new();
1365 let query_terms: std::collections::HashSet<String> =
1366 tokenizer.tokenize(description).into_iter().collect();
1367
1368 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#[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 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 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 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
1581pub 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}