1use std::path::Path;
4
5use crate::engine::{
6 AnalogicalAnchor, AnalogicalParams, BeliefRevisionParams, CausalParams, CentralityAlgorithm,
7 CentralityParams, ConsolidationOp, ConsolidationParams, DriftParams, GapDetectionParams,
8 GapSeverity, HybridSearchParams, MemoryQualityParams, PatternParams, PatternSort, QueryEngine,
9 ShortestPathParams, 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
753pub fn cmd_quality(
755 path: &Path,
756 low_confidence: f32,
757 stale_decay: f32,
758 limit: usize,
759 json: bool,
760) -> AmemResult<()> {
761 let graph = AmemReader::read_from_file(path)?;
762 let query_engine = QueryEngine::new();
763 let report = query_engine.memory_quality(
764 &graph,
765 MemoryQualityParams {
766 low_confidence_threshold: low_confidence.clamp(0.0, 1.0),
767 stale_decay_threshold: stale_decay.clamp(0.0, 1.0),
768 max_examples: limit.max(1),
769 },
770 )?;
771
772 if json {
773 let out = serde_json::json!({
774 "status": report.status,
775 "summary": {
776 "nodes": report.node_count,
777 "edges": report.edge_count,
778 "low_confidence_count": report.low_confidence_count,
779 "stale_count": report.stale_count,
780 "orphan_count": report.orphan_count,
781 "decisions_without_support_count": report.decisions_without_support_count,
782 "contradiction_edges": report.contradiction_edges,
783 "supersedes_edges": report.supersedes_edges,
784 },
785 "examples": {
786 "low_confidence": report.low_confidence_examples,
787 "stale": report.stale_examples,
788 "orphan": report.orphan_examples,
789 "unsupported_decisions": report.unsupported_decision_examples,
790 }
791 });
792 println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
793 } else {
794 println!("Memory quality report for {}", path.display());
795 println!(" Status: {}", report.status.to_uppercase());
796 println!(" Nodes: {}", report.node_count);
797 println!(" Edges: {}", report.edge_count);
798 println!(
799 " Weak confidence (<{:.2}): {}",
800 low_confidence, report.low_confidence_count
801 );
802 println!(" Stale (<{:.2}): {}", stale_decay, report.stale_count);
803 println!(" Orphan nodes: {}", report.orphan_count);
804 println!(
805 " Decisions without support edges: {}",
806 report.decisions_without_support_count
807 );
808 println!(" Contradiction edges: {}", report.contradiction_edges);
809 println!(" Supersedes edges: {}", report.supersedes_edges);
810 if !report.low_confidence_examples.is_empty() {
811 println!(
812 " Low-confidence examples: {:?}",
813 report.low_confidence_examples
814 );
815 }
816 if !report.unsupported_decision_examples.is_empty() {
817 println!(
818 " Unsupported decision examples: {:?}",
819 report.unsupported_decision_examples
820 );
821 }
822 println!(
823 " Next: amem runtime-sync {} --workspace . --write-episode",
824 path.display()
825 );
826 }
827
828 Ok(())
829}
830
831#[derive(Default)]
832struct ArtifactScanReport {
833 amem_files: Vec<std::path::PathBuf>,
834 acb_files: Vec<std::path::PathBuf>,
835 avis_files: Vec<std::path::PathBuf>,
836 io_errors: usize,
837}
838
839pub fn cmd_runtime_sync(
841 path: &Path,
842 workspace: &Path,
843 max_depth: u32,
844 session_id: u32,
845 write_episode: bool,
846 json: bool,
847) -> AmemResult<()> {
848 let mut graph = AmemReader::read_from_file(path)?;
849 let report = scan_workspace_artifacts(workspace, max_depth);
850
851 let mut episode_id = None;
852 if write_episode {
853 let sid = if session_id == 0 {
854 graph
855 .session_index()
856 .session_ids()
857 .iter()
858 .copied()
859 .max()
860 .unwrap_or(0)
861 } else {
862 session_id
863 };
864 let content = format!(
865 "Runtime sync snapshot for workspace {}: amem={} acb={} avis={} (depth={})",
866 workspace.display(),
867 report.amem_files.len(),
868 report.acb_files.len(),
869 report.avis_files.len(),
870 max_depth
871 );
872 let event = CognitiveEventBuilder::new(EventType::Episode, content)
873 .session_id(sid)
874 .confidence(0.95)
875 .build();
876 let id = graph.add_node(event)?;
877 episode_id = Some(id);
878 let writer = AmemWriter::new(graph.dimension());
879 writer.write_to_file(&graph, path)?;
880 }
881
882 if json {
883 let out = serde_json::json!({
884 "workspace": workspace.display().to_string(),
885 "max_depth": max_depth,
886 "amem_count": report.amem_files.len(),
887 "acb_count": report.acb_files.len(),
888 "avis_count": report.avis_files.len(),
889 "io_errors": report.io_errors,
890 "episode_written": episode_id.is_some(),
891 "episode_id": episode_id,
892 "sample": {
893 "amem": report.amem_files.iter().take(5).map(|p| p.display().to_string()).collect::<Vec<_>>(),
894 "acb": report.acb_files.iter().take(5).map(|p| p.display().to_string()).collect::<Vec<_>>(),
895 "avis": report.avis_files.iter().take(5).map(|p| p.display().to_string()).collect::<Vec<_>>(),
896 }
897 });
898 println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
899 } else {
900 println!(
901 "Runtime sync scan in {} (depth {})",
902 workspace.display(),
903 max_depth
904 );
905 println!(" .amem files: {}", report.amem_files.len());
906 println!(" .acb files: {}", report.acb_files.len());
907 println!(" .avis files: {}", report.avis_files.len());
908 if report.io_errors > 0 {
909 println!(" Scan IO errors: {}", report.io_errors);
910 }
911 if let Some(id) = episode_id {
912 println!(" Wrote episode node: {}", id);
913 } else {
914 println!(" Episode write: skipped");
915 }
916 }
917
918 Ok(())
919}
920
921pub fn cmd_budget(path: &Path, max_bytes: u64, horizon_years: u32, json: bool) -> AmemResult<()> {
923 let graph = AmemReader::read_from_file(path)?;
924 let current_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
925 let projected = estimate_projected_size(&graph, current_size, horizon_years);
926 let over_budget = current_size > max_bytes || projected.map(|v| v > max_bytes).unwrap_or(false);
927
928 let years = horizon_years.max(1) as f64;
930 let bytes_per_day = max_bytes as f64 / (years * 365.25);
931
932 if json {
933 let out = serde_json::json!({
934 "file": path.display().to_string(),
935 "current_size_bytes": current_size,
936 "projected_size_bytes": projected,
937 "max_budget_bytes": max_bytes,
938 "horizon_years": horizon_years,
939 "over_budget": over_budget,
940 "daily_budget_bytes": bytes_per_day,
941 "daily_budget_kb": bytes_per_day / 1024.0,
942 "guidance": {
943 "recommended_policy_mode": if over_budget { "auto-rollup" } else { "warn" },
944 "env": {
945 "AMEM_STORAGE_BUDGET_MODE": "auto-rollup|warn|off",
946 "AMEM_STORAGE_BUDGET_BYTES": max_bytes,
947 "AMEM_STORAGE_BUDGET_HORIZON_YEARS": horizon_years
948 }
949 }
950 });
951 println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
952 } else {
953 println!("Storage budget estimate for {}", path.display());
954 println!(" Current size: {}", format_size(current_size));
955 if let Some(v) = projected {
956 println!(" Projected size ({}y): {}", horizon_years, format_size(v));
957 } else {
958 println!(
959 " Projected size ({}y): unavailable (insufficient timeline history)",
960 horizon_years
961 );
962 }
963 println!(" Budget target: {}", format_size(max_bytes));
964 println!(" Over budget: {}", if over_budget { "yes" } else { "no" });
965 println!(
966 " Daily budget guidance: {:.1} KB/day",
967 bytes_per_day / 1024.0
968 );
969 println!(" Suggested env:");
970 println!(" AMEM_STORAGE_BUDGET_MODE=auto-rollup");
971 println!(" AMEM_STORAGE_BUDGET_BYTES={}", max_bytes);
972 println!(" AMEM_STORAGE_BUDGET_HORIZON_YEARS={}", horizon_years);
973 }
974
975 Ok(())
976}
977
978fn scan_workspace_artifacts(root: &Path, max_depth: u32) -> ArtifactScanReport {
979 let mut report = ArtifactScanReport::default();
980 scan_dir_recursive(root, 0, max_depth, &mut report);
981 report
982}
983
984fn scan_dir_recursive(path: &Path, depth: u32, max_depth: u32, report: &mut ArtifactScanReport) {
985 if depth > max_depth {
986 return;
987 }
988 let entries = match std::fs::read_dir(path) {
989 Ok(v) => v,
990 Err(_) => {
991 report.io_errors += 1;
992 return;
993 }
994 };
995
996 for entry in entries {
997 let entry = match entry {
998 Ok(v) => v,
999 Err(_) => {
1000 report.io_errors += 1;
1001 continue;
1002 }
1003 };
1004 let p = entry.path();
1005 if p.is_dir() {
1006 if should_skip_dir(&p) {
1007 continue;
1008 }
1009 scan_dir_recursive(&p, depth + 1, max_depth, report);
1010 continue;
1011 }
1012 let Some(ext) = p.extension().and_then(|e| e.to_str()) else {
1013 continue;
1014 };
1015 match ext.to_ascii_lowercase().as_str() {
1016 "amem" => report.amem_files.push(p),
1017 "acb" => report.acb_files.push(p),
1018 "avis" => report.avis_files.push(p),
1019 _ => {}
1020 }
1021 }
1022}
1023
1024fn should_skip_dir(path: &Path) -> bool {
1025 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
1026 return false;
1027 };
1028 matches!(
1029 name,
1030 ".git" | "target" | "node_modules" | ".venv" | ".idea" | ".vscode" | "__pycache__"
1031 )
1032}
1033
1034fn estimate_projected_size(
1035 graph: &MemoryGraph,
1036 current_size: u64,
1037 horizon_years: u32,
1038) -> Option<u64> {
1039 if current_size == 0 || graph.node_count() < 2 {
1040 return None;
1041 }
1042
1043 let mut min_ts = u64::MAX;
1044 let mut max_ts = 0u64;
1045 for node in graph.nodes() {
1046 min_ts = min_ts.min(node.created_at);
1047 max_ts = max_ts.max(node.created_at);
1048 }
1049 if min_ts == u64::MAX || max_ts <= min_ts {
1050 return None;
1051 }
1052
1053 let span_secs_raw = (max_ts - min_ts) / 1_000_000;
1054 let span_secs = span_secs_raw.max(7 * 24 * 3600) as f64;
1055 let per_sec = current_size as f64 / span_secs;
1056 let horizon_secs = (horizon_years.max(1) as f64) * 365.25 * 24.0 * 3600.0;
1057 let projected = (per_sec * horizon_secs).round();
1058 Some(projected.max(0.0).min(u64::MAX as f64) as u64)
1059}
1060
1061fn format_size(bytes: u64) -> String {
1062 if bytes < 1024 {
1063 format!("{} B", bytes)
1064 } else if bytes < 1024 * 1024 {
1065 format!("{:.1} KB", bytes as f64 / 1024.0)
1066 } else if bytes < 1024 * 1024 * 1024 {
1067 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
1068 } else {
1069 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
1070 }
1071}
1072
1073fn format_timestamp(micros: u64) -> String {
1074 let secs = (micros / 1_000_000) as i64;
1075 let dt = chrono::DateTime::from_timestamp(secs, 0);
1076 match dt {
1077 Some(dt) => dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
1078 None => format!("{} us", micros),
1079 }
1080}
1081
1082pub fn cmd_text_search(
1086 path: &Path,
1087 query: &str,
1088 event_types: Vec<EventType>,
1089 session_ids: Vec<u32>,
1090 limit: usize,
1091 min_score: f32,
1092 json: bool,
1093) -> AmemResult<()> {
1094 let graph = AmemReader::read_from_file(path)?;
1095 let query_engine = QueryEngine::new();
1096
1097 let start = std::time::Instant::now();
1098 let results = query_engine.text_search(
1099 &graph,
1100 graph.term_index(),
1101 graph.doc_lengths(),
1102 TextSearchParams {
1103 query: query.to_string(),
1104 max_results: limit,
1105 event_types,
1106 session_ids,
1107 min_score,
1108 },
1109 )?;
1110 let elapsed = start.elapsed();
1111
1112 if json {
1113 let matches: Vec<serde_json::Value> = results
1114 .iter()
1115 .enumerate()
1116 .map(|(i, m)| {
1117 let node = graph.get_node(m.node_id);
1118 serde_json::json!({
1119 "rank": i + 1,
1120 "node_id": m.node_id,
1121 "score": m.score,
1122 "matched_terms": m.matched_terms,
1123 "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1124 "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1125 })
1126 })
1127 .collect();
1128 println!(
1129 "{}",
1130 serde_json::to_string_pretty(&serde_json::json!({
1131 "query": query,
1132 "results": matches,
1133 "total": results.len(),
1134 "elapsed_ms": elapsed.as_secs_f64() * 1000.0,
1135 }))
1136 .unwrap_or_default()
1137 );
1138 } else {
1139 println!("Text search for {:?} in {}:", query, path.display());
1140 for (i, m) in results.iter().enumerate() {
1141 if let Some(node) = graph.get_node(m.node_id) {
1142 let preview = if node.content.len() > 60 {
1143 format!("{}...", &node.content[..60])
1144 } else {
1145 node.content.clone()
1146 };
1147 println!(
1148 " #{:<3} Node {} ({}) [score: {:.2}] {:?}",
1149 i + 1,
1150 m.node_id,
1151 node.event_type.name(),
1152 m.score,
1153 preview
1154 );
1155 }
1156 }
1157 println!(
1158 " {} results ({:.1}ms)",
1159 results.len(),
1160 elapsed.as_secs_f64() * 1000.0
1161 );
1162 }
1163 Ok(())
1164}
1165
1166#[allow(clippy::too_many_arguments)]
1168pub fn cmd_hybrid_search(
1169 path: &Path,
1170 query: &str,
1171 text_weight: f32,
1172 vec_weight: f32,
1173 limit: usize,
1174 event_types: Vec<EventType>,
1175 json: bool,
1176) -> AmemResult<()> {
1177 let graph = AmemReader::read_from_file(path)?;
1178 let query_engine = QueryEngine::new();
1179
1180 let results = query_engine.hybrid_search(
1181 &graph,
1182 graph.term_index(),
1183 graph.doc_lengths(),
1184 HybridSearchParams {
1185 query_text: query.to_string(),
1186 query_vec: None,
1187 max_results: limit,
1188 event_types,
1189 text_weight,
1190 vector_weight: vec_weight,
1191 rrf_k: 60,
1192 },
1193 )?;
1194
1195 if json {
1196 let matches: Vec<serde_json::Value> = results
1197 .iter()
1198 .enumerate()
1199 .map(|(i, m)| {
1200 let node = graph.get_node(m.node_id);
1201 serde_json::json!({
1202 "rank": i + 1,
1203 "node_id": m.node_id,
1204 "combined_score": m.combined_score,
1205 "text_rank": m.text_rank,
1206 "vector_rank": m.vector_rank,
1207 "text_score": m.text_score,
1208 "vector_similarity": m.vector_similarity,
1209 "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1210 "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1211 })
1212 })
1213 .collect();
1214 println!(
1215 "{}",
1216 serde_json::to_string_pretty(&serde_json::json!({
1217 "query": query,
1218 "results": matches,
1219 "total": results.len(),
1220 }))
1221 .unwrap_or_default()
1222 );
1223 } else {
1224 println!("Hybrid search for {:?}:", query);
1225 for (i, m) in results.iter().enumerate() {
1226 if let Some(node) = graph.get_node(m.node_id) {
1227 let preview = if node.content.len() > 60 {
1228 format!("{}...", &node.content[..60])
1229 } else {
1230 node.content.clone()
1231 };
1232 println!(
1233 " #{:<3} Node {} ({}) [score: {:.4}] {:?}",
1234 i + 1,
1235 m.node_id,
1236 node.event_type.name(),
1237 m.combined_score,
1238 preview
1239 );
1240 }
1241 }
1242 println!(" {} results", results.len());
1243 }
1244 Ok(())
1245}
1246
1247#[allow(clippy::too_many_arguments)]
1249pub fn cmd_centrality(
1250 path: &Path,
1251 algorithm: &str,
1252 damping: f32,
1253 edge_types: Vec<EdgeType>,
1254 event_types: Vec<EventType>,
1255 limit: usize,
1256 iterations: u32,
1257 json: bool,
1258) -> AmemResult<()> {
1259 let graph = AmemReader::read_from_file(path)?;
1260 let query_engine = QueryEngine::new();
1261
1262 let algo = match algorithm {
1263 "degree" => CentralityAlgorithm::Degree,
1264 "betweenness" => CentralityAlgorithm::Betweenness,
1265 _ => CentralityAlgorithm::PageRank { damping },
1266 };
1267
1268 let result = query_engine.centrality(
1269 &graph,
1270 CentralityParams {
1271 algorithm: algo,
1272 max_iterations: iterations,
1273 tolerance: 1e-6,
1274 top_k: limit,
1275 event_types,
1276 edge_types,
1277 },
1278 )?;
1279
1280 if json {
1281 let scores: Vec<serde_json::Value> = result
1282 .scores
1283 .iter()
1284 .enumerate()
1285 .map(|(i, (id, score))| {
1286 let node = graph.get_node(*id);
1287 serde_json::json!({
1288 "rank": i + 1,
1289 "node_id": id,
1290 "score": score,
1291 "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1292 "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1293 })
1294 })
1295 .collect();
1296 println!(
1297 "{}",
1298 serde_json::to_string_pretty(&serde_json::json!({
1299 "algorithm": algorithm,
1300 "converged": result.converged,
1301 "iterations": result.iterations,
1302 "scores": scores,
1303 }))
1304 .unwrap_or_default()
1305 );
1306 } else {
1307 let algo_name = match algorithm {
1308 "degree" => "Degree",
1309 "betweenness" => "Betweenness",
1310 _ => "PageRank",
1311 };
1312 println!(
1313 "{} centrality (converged: {}, iterations: {}):",
1314 algo_name, result.converged, result.iterations
1315 );
1316 for (i, (id, score)) in result.scores.iter().enumerate() {
1317 if let Some(node) = graph.get_node(*id) {
1318 let preview = if node.content.len() > 50 {
1319 format!("{}...", &node.content[..50])
1320 } else {
1321 node.content.clone()
1322 };
1323 println!(
1324 " #{:<3} Node {} ({}) [score: {:.6}] {:?}",
1325 i + 1,
1326 id,
1327 node.event_type.name(),
1328 score,
1329 preview
1330 );
1331 }
1332 }
1333 }
1334 Ok(())
1335}
1336
1337#[allow(clippy::too_many_arguments)]
1339pub fn cmd_path(
1340 path: &Path,
1341 source_id: u64,
1342 target_id: u64,
1343 edge_types: Vec<EdgeType>,
1344 direction: TraversalDirection,
1345 max_depth: u32,
1346 weighted: bool,
1347 json: bool,
1348) -> AmemResult<()> {
1349 let graph = AmemReader::read_from_file(path)?;
1350 let query_engine = QueryEngine::new();
1351
1352 let result = query_engine.shortest_path(
1353 &graph,
1354 ShortestPathParams {
1355 source_id,
1356 target_id,
1357 edge_types,
1358 direction,
1359 max_depth,
1360 weighted,
1361 },
1362 )?;
1363
1364 if json {
1365 let path_info: Vec<serde_json::Value> = result
1366 .path
1367 .iter()
1368 .map(|&id| {
1369 let node = graph.get_node(id);
1370 serde_json::json!({
1371 "node_id": id,
1372 "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1373 "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1374 })
1375 })
1376 .collect();
1377 let edges_info: Vec<serde_json::Value> = result
1378 .edges
1379 .iter()
1380 .map(|e| {
1381 serde_json::json!({
1382 "source_id": e.source_id,
1383 "target_id": e.target_id,
1384 "edge_type": e.edge_type.name(),
1385 "weight": e.weight,
1386 })
1387 })
1388 .collect();
1389 println!(
1390 "{}",
1391 serde_json::to_string_pretty(&serde_json::json!({
1392 "found": result.found,
1393 "cost": result.cost,
1394 "path": path_info,
1395 "edges": edges_info,
1396 }))
1397 .unwrap_or_default()
1398 );
1399 } else if result.found {
1400 println!(
1401 "Path from node {} to node {} ({} hops, cost: {:.2}):",
1402 source_id,
1403 target_id,
1404 result.path.len().saturating_sub(1),
1405 result.cost
1406 );
1407 let mut parts: Vec<String> = Vec::new();
1409 for (i, &id) in result.path.iter().enumerate() {
1410 if let Some(node) = graph.get_node(id) {
1411 let label = format!("Node {} ({})", id, node.event_type.name());
1412 if i < result.edges.len() {
1413 parts.push(format!(
1414 "{} --[{}]-->",
1415 label,
1416 result.edges[i].edge_type.name()
1417 ));
1418 } else {
1419 parts.push(label);
1420 }
1421 }
1422 }
1423 println!(" {}", parts.join(" "));
1424 } else {
1425 println!(
1426 "No path found from node {} to node {}",
1427 source_id, target_id
1428 );
1429 }
1430 Ok(())
1431}
1432
1433pub fn cmd_revise(
1435 path: &Path,
1436 hypothesis: &str,
1437 threshold: f32,
1438 max_depth: u32,
1439 confidence: f32,
1440 json: bool,
1441) -> AmemResult<()> {
1442 let graph = AmemReader::read_from_file(path)?;
1443 let query_engine = QueryEngine::new();
1444
1445 let report = query_engine.belief_revision(
1446 &graph,
1447 BeliefRevisionParams {
1448 hypothesis: hypothesis.to_string(),
1449 hypothesis_vec: None,
1450 contradiction_threshold: threshold,
1451 max_depth,
1452 hypothesis_confidence: confidence,
1453 },
1454 )?;
1455
1456 if json {
1457 let contradicted: Vec<serde_json::Value> = report
1458 .contradicted
1459 .iter()
1460 .map(|c| {
1461 let node = graph.get_node(c.node_id);
1462 serde_json::json!({
1463 "node_id": c.node_id,
1464 "strength": c.contradiction_strength,
1465 "reason": c.reason,
1466 "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1467 "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1468 })
1469 })
1470 .collect();
1471 let weakened: Vec<serde_json::Value> = report
1472 .weakened
1473 .iter()
1474 .map(|w| {
1475 serde_json::json!({
1476 "node_id": w.node_id,
1477 "original_confidence": w.original_confidence,
1478 "revised_confidence": w.revised_confidence,
1479 "depth": w.depth,
1480 })
1481 })
1482 .collect();
1483 let cascade: Vec<serde_json::Value> = report
1484 .cascade
1485 .iter()
1486 .map(|s| {
1487 serde_json::json!({
1488 "node_id": s.node_id,
1489 "via_edge": s.via_edge.name(),
1490 "from_node": s.from_node,
1491 "depth": s.depth,
1492 })
1493 })
1494 .collect();
1495 println!(
1496 "{}",
1497 serde_json::to_string_pretty(&serde_json::json!({
1498 "hypothesis": hypothesis,
1499 "contradicted": contradicted,
1500 "weakened": weakened,
1501 "invalidated_decisions": report.invalidated_decisions,
1502 "total_affected": report.total_affected,
1503 "cascade": cascade,
1504 }))
1505 .unwrap_or_default()
1506 );
1507 } else {
1508 println!("Belief revision: {:?}\n", hypothesis);
1509 if report.contradicted.is_empty() {
1510 println!(" No contradictions found.");
1511 } else {
1512 println!("Directly contradicted:");
1513 for c in &report.contradicted {
1514 if let Some(node) = graph.get_node(c.node_id) {
1515 println!(
1516 " X Node {} ({}): {:?} [score: {:.2}]",
1517 c.node_id,
1518 node.event_type.name(),
1519 node.content,
1520 c.contradiction_strength
1521 );
1522 }
1523 }
1524 }
1525 if !report.weakened.is_empty() {
1526 println!("\nCascade effects:");
1527 for w in &report.weakened {
1528 if let Some(node) = graph.get_node(w.node_id) {
1529 let action = if node.event_type == EventType::Decision {
1530 "INVALIDATED"
1531 } else {
1532 "weakened"
1533 };
1534 println!(
1535 " ! Node {} ({}): {} ({:.2} -> {:.2})",
1536 w.node_id,
1537 node.event_type.name(),
1538 action,
1539 w.original_confidence,
1540 w.revised_confidence
1541 );
1542 }
1543 }
1544 }
1545 println!(
1546 "\nTotal affected: {} nodes ({} decisions)",
1547 report.total_affected,
1548 report.invalidated_decisions.len()
1549 );
1550 }
1551 Ok(())
1552}
1553
1554#[allow(clippy::too_many_arguments)]
1556pub fn cmd_gaps(
1557 path: &Path,
1558 threshold: f32,
1559 min_support: u32,
1560 limit: usize,
1561 sort: &str,
1562 session_range: Option<(u32, u32)>,
1563 json: bool,
1564) -> AmemResult<()> {
1565 let graph = AmemReader::read_from_file(path)?;
1566 let query_engine = QueryEngine::new();
1567
1568 let sort_by = match sort {
1569 "recent" => GapSeverity::MostRecent,
1570 "confidence" => GapSeverity::LowestConfidence,
1571 _ => GapSeverity::HighestImpact,
1572 };
1573
1574 let report = query_engine.gap_detection(
1575 &graph,
1576 GapDetectionParams {
1577 confidence_threshold: threshold,
1578 min_support_count: min_support,
1579 max_results: limit,
1580 session_range,
1581 sort_by,
1582 },
1583 )?;
1584
1585 if json {
1586 let gaps: Vec<serde_json::Value> = report
1587 .gaps
1588 .iter()
1589 .map(|g| {
1590 let node = graph.get_node(g.node_id);
1591 serde_json::json!({
1592 "node_id": g.node_id,
1593 "gap_type": format!("{:?}", g.gap_type),
1594 "severity": g.severity,
1595 "description": g.description,
1596 "downstream_count": g.downstream_count,
1597 "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1598 "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1599 })
1600 })
1601 .collect();
1602 println!(
1603 "{}",
1604 serde_json::to_string_pretty(&serde_json::json!({
1605 "gaps": gaps,
1606 "health_score": report.summary.health_score,
1607 "summary": {
1608 "total_gaps": report.summary.total_gaps,
1609 "unjustified_decisions": report.summary.unjustified_decisions,
1610 "single_source_inferences": report.summary.single_source_inferences,
1611 "low_confidence_foundations": report.summary.low_confidence_foundations,
1612 "unstable_knowledge": report.summary.unstable_knowledge,
1613 "stale_evidence": report.summary.stale_evidence,
1614 }
1615 }))
1616 .unwrap_or_default()
1617 );
1618 } else {
1619 println!("Reasoning gaps in {}:\n", path.display());
1620 for g in &report.gaps {
1621 let severity_marker = if g.severity > 0.8 {
1622 "CRITICAL"
1623 } else if g.severity > 0.5 {
1624 "WARNING"
1625 } else {
1626 "INFO"
1627 };
1628 if let Some(node) = graph.get_node(g.node_id) {
1629 println!(
1630 " {}: Node {} ({}) -- {:?}",
1631 severity_marker,
1632 g.node_id,
1633 node.event_type.name(),
1634 g.gap_type
1635 );
1636 let preview = if node.content.len() > 60 {
1637 format!("{}...", &node.content[..60])
1638 } else {
1639 node.content.clone()
1640 };
1641 println!(" {:?}", preview);
1642 println!(
1643 " Severity: {:.2} | {} downstream dependents",
1644 g.severity, g.downstream_count
1645 );
1646 println!();
1647 }
1648 }
1649 println!(
1650 "Health score: {:.2} / 1.00 | {} gaps found",
1651 report.summary.health_score, report.summary.total_gaps
1652 );
1653 }
1654 Ok(())
1655}
1656
1657#[allow(clippy::too_many_arguments)]
1659pub fn cmd_analogy(
1660 path: &Path,
1661 description: &str,
1662 limit: usize,
1663 min_similarity: f32,
1664 exclude_sessions: Vec<u32>,
1665 depth: u32,
1666 json: bool,
1667) -> AmemResult<()> {
1668 let graph = AmemReader::read_from_file(path)?;
1669 let query_engine = QueryEngine::new();
1670
1671 let tokenizer = crate::engine::Tokenizer::new();
1673 let query_terms: std::collections::HashSet<String> =
1674 tokenizer.tokenize(description).into_iter().collect();
1675
1676 let mut best_id = None;
1678 let mut best_score = -1.0f32;
1679 for node in graph.nodes() {
1680 let node_terms: std::collections::HashSet<String> =
1681 tokenizer.tokenize(&node.content).into_iter().collect();
1682 let overlap = query_terms.intersection(&node_terms).count();
1683 let score = if query_terms.is_empty() {
1684 0.0
1685 } else {
1686 overlap as f32 / query_terms.len() as f32
1687 };
1688 if score > best_score {
1689 best_score = score;
1690 best_id = Some(node.id);
1691 }
1692 }
1693
1694 let anchor = match best_id {
1695 Some(id) => AnalogicalAnchor::Node(id),
1696 None => {
1697 println!("No matching nodes found for the description.");
1698 return Ok(());
1699 }
1700 };
1701
1702 let results = query_engine.analogical(
1703 &graph,
1704 AnalogicalParams {
1705 anchor,
1706 context_depth: depth,
1707 max_results: limit,
1708 min_similarity,
1709 exclude_sessions,
1710 },
1711 )?;
1712
1713 if json {
1714 let analogies: Vec<serde_json::Value> = results
1715 .iter()
1716 .map(|a| {
1717 let node = graph.get_node(a.center_id);
1718 serde_json::json!({
1719 "center_id": a.center_id,
1720 "structural_similarity": a.structural_similarity,
1721 "content_similarity": a.content_similarity,
1722 "combined_score": a.combined_score,
1723 "subgraph_nodes": a.subgraph_nodes,
1724 "type": node.map(|n| n.event_type.name()).unwrap_or("unknown"),
1725 "content": node.map(|n| n.content.as_str()).unwrap_or(""),
1726 })
1727 })
1728 .collect();
1729 println!(
1730 "{}",
1731 serde_json::to_string_pretty(&serde_json::json!({
1732 "description": description,
1733 "analogies": analogies,
1734 }))
1735 .unwrap_or_default()
1736 );
1737 } else {
1738 println!("Analogies for {:?}:\n", description);
1739 for (i, a) in results.iter().enumerate() {
1740 if let Some(node) = graph.get_node(a.center_id) {
1741 println!(
1742 " #{} Node {} ({}) [combined: {:.3}]",
1743 i + 1,
1744 a.center_id,
1745 node.event_type.name(),
1746 a.combined_score
1747 );
1748 println!(
1749 " Structural: {:.3} | Content: {:.3} | Subgraph: {} nodes",
1750 a.structural_similarity,
1751 a.content_similarity,
1752 a.subgraph_nodes.len()
1753 );
1754 }
1755 }
1756 if results.is_empty() {
1757 println!(" No analogies found.");
1758 }
1759 }
1760 Ok(())
1761}
1762
1763#[allow(clippy::too_many_arguments)]
1765pub fn cmd_consolidate(
1766 path: &Path,
1767 deduplicate: bool,
1768 link_contradictions: bool,
1769 promote_inferences: bool,
1770 prune: bool,
1771 compress_episodes: bool,
1772 all: bool,
1773 threshold: f32,
1774 confirm: bool,
1775 backup: Option<std::path::PathBuf>,
1776 json: bool,
1777) -> AmemResult<()> {
1778 let mut graph = AmemReader::read_from_file(path)?;
1779 let query_engine = QueryEngine::new();
1780
1781 let dry_run = !confirm;
1782
1783 let mut ops = Vec::new();
1785 if deduplicate || all {
1786 ops.push(ConsolidationOp::DeduplicateFacts { threshold });
1787 }
1788 if link_contradictions || all {
1789 ops.push(ConsolidationOp::LinkContradictions {
1790 threshold: threshold.min(0.8),
1791 });
1792 }
1793 if promote_inferences || all {
1794 ops.push(ConsolidationOp::PromoteInferences {
1795 min_access: 3,
1796 min_confidence: 0.8,
1797 });
1798 }
1799 if prune || all {
1800 ops.push(ConsolidationOp::PruneOrphans { max_decay: 0.1 });
1801 }
1802 if compress_episodes || all {
1803 ops.push(ConsolidationOp::CompressEpisodes { group_size: 3 });
1804 }
1805
1806 if ops.is_empty() {
1807 eprintln!("No operations specified. Use --deduplicate, --link-contradictions, --promote-inferences, --prune, --compress-episodes, or --all");
1808 return Ok(());
1809 }
1810
1811 let backup_path = if !dry_run {
1813 let bp = backup.unwrap_or_else(|| {
1814 let mut p = path.to_path_buf();
1815 let name = p
1816 .file_stem()
1817 .unwrap_or_default()
1818 .to_string_lossy()
1819 .to_string();
1820 p.set_file_name(format!("{}.pre-consolidation.amem", name));
1821 p
1822 });
1823 std::fs::copy(path, &bp)?;
1824 Some(bp)
1825 } else {
1826 None
1827 };
1828
1829 let report = query_engine.consolidate(
1830 &mut graph,
1831 ConsolidationParams {
1832 session_range: None,
1833 operations: ops,
1834 dry_run,
1835 backup_path: backup_path.clone(),
1836 },
1837 )?;
1838
1839 if !dry_run {
1841 let writer = AmemWriter::new(graph.dimension());
1842 writer.write_to_file(&graph, path)?;
1843 }
1844
1845 if json {
1846 let actions: Vec<serde_json::Value> = report
1847 .actions
1848 .iter()
1849 .map(|a| {
1850 serde_json::json!({
1851 "operation": a.operation,
1852 "description": a.description,
1853 "affected_nodes": a.affected_nodes,
1854 })
1855 })
1856 .collect();
1857 println!(
1858 "{}",
1859 serde_json::to_string_pretty(&serde_json::json!({
1860 "dry_run": dry_run,
1861 "deduplicated": report.deduplicated,
1862 "contradictions_linked": report.contradictions_linked,
1863 "inferences_promoted": report.inferences_promoted,
1864 "backup_path": backup_path.map(|p| p.display().to_string()),
1865 "actions": actions,
1866 }))
1867 .unwrap_or_default()
1868 );
1869 } else {
1870 if dry_run {
1871 println!("Consolidation DRY RUN (use --confirm to apply):\n");
1872 } else {
1873 println!("Consolidation applied:\n");
1874 if let Some(bp) = &backup_path {
1875 println!(" Backup: {}", bp.display());
1876 }
1877 }
1878 for a in &report.actions {
1879 println!(" [{}] {}", a.operation, a.description);
1880 }
1881 println!();
1882 println!(" Deduplicated: {}", report.deduplicated);
1883 println!(" Contradictions linked: {}", report.contradictions_linked);
1884 println!(" Inferences promoted: {}", report.inferences_promoted);
1885 }
1886 Ok(())
1887}
1888
1889pub fn cmd_drift(
1891 path: &Path,
1892 topic: &str,
1893 limit: usize,
1894 min_relevance: f32,
1895 json: bool,
1896) -> AmemResult<()> {
1897 let graph = AmemReader::read_from_file(path)?;
1898 let query_engine = QueryEngine::new();
1899
1900 let report = query_engine.drift_detection(
1901 &graph,
1902 DriftParams {
1903 topic: topic.to_string(),
1904 topic_vec: None,
1905 max_results: limit,
1906 min_relevance,
1907 },
1908 )?;
1909
1910 if json {
1911 let timelines: Vec<serde_json::Value> = report
1912 .timelines
1913 .iter()
1914 .map(|t| {
1915 let snapshots: Vec<serde_json::Value> = t
1916 .snapshots
1917 .iter()
1918 .map(|s| {
1919 serde_json::json!({
1920 "node_id": s.node_id,
1921 "session_id": s.session_id,
1922 "confidence": s.confidence,
1923 "content_preview": s.content_preview,
1924 "change_type": format!("{:?}", s.change_type),
1925 })
1926 })
1927 .collect();
1928 serde_json::json!({
1929 "snapshots": snapshots,
1930 "change_count": t.change_count,
1931 "correction_count": t.correction_count,
1932 "contradiction_count": t.contradiction_count,
1933 })
1934 })
1935 .collect();
1936 println!(
1937 "{}",
1938 serde_json::to_string_pretty(&serde_json::json!({
1939 "topic": topic,
1940 "timelines": timelines,
1941 "stability": report.stability,
1942 "likely_to_change": report.likely_to_change,
1943 }))
1944 .unwrap_or_default()
1945 );
1946 } else {
1947 println!("Drift analysis for {:?}:\n", topic);
1948 for (i, t) in report.timelines.iter().enumerate() {
1949 println!(
1950 "Timeline {} ({} changes, stability: {:.1}):",
1951 i + 1,
1952 t.change_count,
1953 report.stability
1954 );
1955 for s in &t.snapshots {
1956 let change = format!("{:?}", s.change_type).to_uppercase();
1957 println!(
1958 " Session {:>3}: {:<12} {:?} [{:.2}]",
1959 s.session_id, change, s.content_preview, s.confidence
1960 );
1961 }
1962 println!();
1963 }
1964 if report.timelines.is_empty() {
1965 println!(" No relevant nodes found for this topic.");
1966 } else {
1967 let prediction = if report.likely_to_change {
1968 "LIKELY TO CHANGE"
1969 } else {
1970 "STABLE"
1971 };
1972 println!(
1973 "Overall stability: {:.2} | Prediction: {}",
1974 report.stability, prediction
1975 );
1976 }
1977 }
1978 Ok(())
1979}