1use std::fmt::Write as _;
18
19use serde_json::Value;
20
21use crate::graph::edge_view::EdgeView;
22use crate::graph::inspect::{MemoryOverview, DOUBTFUL_CONFIDENCE, STRONG_CONFIDENCE};
23use crate::graph::traverse::format_traversal;
24use crate::graph::types::{
25 EntityDetail, EpisodeSearchResult, GraphStats, MatchSource, QueryResult, ScoredEntity,
26 TraversalNode,
27};
28
29const MAX_ABSTRACT_CHARS: usize = 300;
31const MAX_OVERVIEW_CHARS: usize = 400;
34const MAX_EPISODE_CHARS: usize = 1_200;
36const MAX_RESULT_CHARS: usize = 24_000;
39const NEUTRAL_UTILITY: f64 = 0.5;
42
43#[must_use]
45pub fn entities(query: &str, results: &[ScoredEntity]) -> String {
46 if results.is_empty() {
47 return format!(
48 "No entities in memory match \"{query}\".\n\
49 Entities are distilled knowledge; the raw conversations may still hold it — try \
50 recall_episodes. If recall_status shows an empty graph, nothing has been ingested \
51 yet."
52 );
53 }
54
55 let mut out = format!(
56 "{} {} in memory for \"{query}\":\n",
57 results.len(),
58 plural(results.len(), "entity", "entities")
59 );
60 for (index, result) in results.iter().enumerate() {
61 write_entity(&mut out, index + 1, result);
62 }
63 budget(out)
64}
65
66#[must_use]
68pub fn query_result(query: &str, result: &QueryResult) -> String {
69 if result.entities.is_empty() && result.episodes.is_empty() {
70 return format!(
71 "Memory holds nothing about \"{query}\".\n\
72 Either it was never discussed, or it has not been ingested yet — recall_status \
73 says which."
74 );
75 }
76
77 let mut out = format!("Memory for \"{query}\":\n");
78
79 if result.entities.is_empty() {
80 out.push_str("\nNo distilled entities matched, but these conversations did.\n");
81 } else {
82 let _ = writeln!(
83 out,
84 "\n{} {}:",
85 result.entities.len(),
86 plural(result.entities.len(), "entity", "entities")
87 );
88 for (index, entity) in result.entities.iter().enumerate() {
89 write_entity(&mut out, index + 1, entity);
90 }
91 }
92
93 if !result.episodes.is_empty() {
94 let _ = writeln!(
95 out,
96 "\n{} conversation {}:",
97 result.episodes.len(),
98 plural(result.episodes.len(), "fragment", "fragments")
99 );
100 for (index, episode) in result.episodes.iter().enumerate() {
101 write_episode(&mut out, index + 1, episode);
102 }
103 }
104
105 budget(out)
106}
107
108#[must_use]
110pub fn episodes(query: &str, results: &[EpisodeSearchResult]) -> String {
111 if results.is_empty() {
112 return format!(
113 "No past conversation in memory matches \"{query}\".\n\
114 If recall_status shows episodes exist, the topic is genuinely absent; if it shows \
115 none, no sessions have been archived into the graph yet."
116 );
117 }
118
119 let mut out = format!(
120 "{} conversation {} for \"{query}\":\n",
121 results.len(),
122 plural(results.len(), "fragment", "fragments")
123 );
124 for (index, result) in results.iter().enumerate() {
125 write_episode(&mut out, index + 1, result);
126 }
127 budget(out)
128}
129
130#[must_use]
132pub fn traversal(entity: &str, depth: u32, node: &TraversalNode) -> String {
133 if node.edges.is_empty() {
134 return format!(
135 "\"{}\" ({}) exists in memory but has no relationships recorded within {depth} \
136 {}.\nIts own description: {}",
137 node.entity.name,
138 node.entity.entity_type,
139 plural(depth as usize, "hop", "hops"),
140 clip(&node.entity.abstract_text, MAX_ABSTRACT_CHARS)
141 );
142 }
143
144 let tree = format_traversal(node, 0);
145 let mut out = format!(
146 "Relationships from \"{entity}\", up to {depth} {}:\n\n{tree}",
147 plural(depth as usize, "hop", "hops")
148 );
149 if tree.contains('%') || tree.contains("[superseded]") {
150 out.push_str(
151 "\nA percentage is the edge's accumulated confidence (absent means fully \
152 corroborated); [superseded] marks a relationship that was true once and no longer \
153 is.\n",
154 );
155 }
156 budget(out)
157}
158
159#[must_use]
161pub fn status(stats: &GraphStats) -> String {
162 let mut out = format!(
163 "Memory graph: {} {}, {} {}, {} conversation {}.\n",
164 stats.entity_count,
165 plural(stats.entity_count as usize, "entity", "entities"),
166 stats.relationship_count,
167 plural(
168 stats.relationship_count as usize,
169 "relationship",
170 "relationships"
171 ),
172 stats.episode_count,
173 plural(stats.episode_count as usize, "episode", "episodes"),
174 );
175
176 if !stats.entity_type_counts.is_empty() {
177 let mut types: Vec<_> = stats.entity_type_counts.iter().collect();
178 types.sort_by(|left, right| right.1.cmp(left.1).then_with(|| left.0.cmp(right.0)));
179 let listed: Vec<String> = types
180 .iter()
181 .map(|(name, count)| format!("{name} {count}"))
182 .collect();
183 let _ = writeln!(out, "By type: {}.", listed.join(", "));
184 }
185
186 if stats.entity_count == 0 && stats.episode_count == 0 {
187 out.push_str(
188 "The graph is empty: no sessions have been ingested, so recall tools will find \
189 nothing.\n",
190 );
191 } else if stats.entity_count == 0 {
192 out.push_str(
193 "Conversations have been ingested but never distilled into entities, so \
194 recall_search and recall_query will be thin — recall_episodes still works.\n",
195 );
196 }
197
198 budget(out)
199}
200
201#[must_use]
207pub fn overview(overview: &MemoryOverview) -> String {
208 let stats = &overview.stats;
209 if stats.entity_count == 0 && stats.episode_count == 0 {
210 return "Memory is empty: no sessions have been ingested, so there is nothing known \
211 about this user or their work yet.\n"
212 .to_string();
213 }
214
215 let mut out = format!(
216 "Memory holds {} {}, {} {} and {} conversation {}.\n",
217 stats.entity_count,
218 plural(stats.entity_count as usize, "entity", "entities"),
219 stats.relationship_count,
220 plural(
221 stats.relationship_count as usize,
222 "relationship",
223 "relationships"
224 ),
225 stats.episode_count,
226 plural(stats.episode_count as usize, "episode", "episodes"),
227 );
228
229 if stats.entity_count == 0 {
230 out.push_str(
231 "Nothing has been distilled from those conversations yet, so only recall_episodes \
232 will find anything.\n",
233 );
234 return budget(out);
235 }
236
237 for group in &overview.groups {
238 let _ = writeln!(out, "\n{} ({}):", group.entity_type, group.count);
239 for entity in &group.top {
240 let _ = writeln!(
241 out,
242 "- {} — {}",
243 entity.name,
244 clip(&entity.abstract_text, MAX_ABSTRACT_CHARS)
245 );
246 }
247 let listed = group.top.len() as u64;
248 if group.count > listed {
249 let _ = writeln!(out, "- … and {} more", group.count - listed);
250 }
251 }
252
253 let confidence = &overview.confidence;
254 if confidence.total() == 0 {
255 out.push_str(
256 "\nNo relationships are recorded: these entities are known but unconnected.\n",
257 );
258 } else {
259 let _ = writeln!(
260 out,
261 "\nConfidence: {} of {} relationships firmly held (at or above {:.0}%), {} uncertain, \
262 {} doubtful (below {:.0}%).",
263 confidence.strong,
264 confidence.total(),
265 STRONG_CONFIDENCE * 100.0,
266 confidence.uncertain,
267 confidence.doubtful,
268 DOUBTFUL_CONFIDENCE * 100.0,
269 );
270 }
271
272 write_edge_list(
273 &mut out,
274 "Least certain",
275 &overview.uncertain,
276 "Treat these as open questions rather than facts.",
277 );
278 write_edge_list(
279 &mut out,
280 "Resting on repetition",
281 &overview.self_reinforced,
282 "self×N counts corroborations the agent authored itself. That tally is deliberately \
283 kept out of the confidence above, so a high one means the belief rests on being \
284 restated rather than on independent evidence — say so rather than asserting it.",
285 );
286
287 budget(out)
288}
289
290fn write_edge_list(out: &mut String, heading: &str, edges: &[EdgeView], note: &str) {
291 if edges.is_empty() {
292 return;
293 }
294 let _ = writeln!(out, "\n{heading}:");
295 for edge in edges {
296 let coherence = if edge.self_reinforcements > 0 {
297 format!(", self×{}", edge.self_reinforcements)
298 } else {
299 String::new()
300 };
301 let _ = writeln!(
302 out,
303 "- {} ({:.0}%{coherence})",
304 edge.arrow(),
305 edge.confidence * 100.0
306 );
307 }
308 let _ = writeln!(out, "{note}");
309}
310
311fn write_entity(out: &mut String, position: usize, result: &ScoredEntity) {
314 let entity = &result.entity;
315 let _ = writeln!(
316 out,
317 "\n{position}. {} [{}] — score {:.2}, {}",
318 entity.name,
319 entity.entity_type,
320 result.score,
321 match_source(&result.source)
322 );
323 let _ = writeln!(
324 out,
325 " {}",
326 clip(&entity.abstract_text, MAX_ABSTRACT_CHARS)
327 );
328 if adds_detail(entity) {
329 let _ = writeln!(out, " {}", clip(&entity.overview, MAX_OVERVIEW_CHARS));
330 }
331 if let Some(provenance) = entity_provenance(entity) {
332 let _ = writeln!(out, " {provenance}");
333 }
334}
335
336fn adds_detail(entity: &EntityDetail) -> bool {
339 let overview = entity.overview.trim();
340 !overview.is_empty() && overview != entity.abstract_text.trim()
341}
342
343fn entity_provenance(entity: &EntityDetail) -> Option<String> {
345 let mut parts = Vec::new();
346 let updated = short_time(&entity.updated_at);
347 if !updated.is_empty() {
348 parts.push(format!("updated {updated}"));
349 }
350 if let Some(source) = entity.source.as_deref().filter(|s| !s.trim().is_empty()) {
351 parts.push(format!("from {source}"));
352 }
353 if (entity.utility_score - NEUTRAL_UTILITY).abs() > 0.005 {
354 parts.push(format!("usefulness {:.2}", entity.utility_score));
355 }
356 (!parts.is_empty()).then(|| parts.join(" · "))
357}
358
359fn match_source(source: &MatchSource) -> String {
360 match source {
361 MatchSource::Semantic => "matched directly".to_string(),
362 MatchSource::Keyword => "matched by keyword".to_string(),
363 MatchSource::Graph { parent, rel_type } => {
364 format!("reached from \"{parent}\" via {rel_type}")
365 }
366 }
367}
368
369fn write_episode(out: &mut String, position: usize, result: &EpisodeSearchResult) {
370 let episode = &result.episode;
371 let mut header = format!("\n{position}. session {}", episode.session_id);
372 if let Some(log) = episode.log_number {
373 let _ = write!(header, ", archive log #{log}");
374 }
375 let timestamp = short_time(&episode.timestamp);
376 if !timestamp.is_empty() {
377 let _ = write!(header, ", {timestamp}");
378 }
379 let _ = writeln!(
380 out,
381 "{header} — score {:.2}, similarity {:.2}",
382 result.score,
383 1.0 - result.distance
384 );
385 let _ = writeln!(
386 out,
387 " {}",
388 clip(&episode.abstract_text, MAX_ABSTRACT_CHARS)
389 );
390
391 if let Some(content) = episode.content.as_deref().filter(|c| !c.trim().is_empty()) {
394 let excerpt = clip(content, MAX_EPISODE_CHARS);
395 if excerpt != episode.abstract_text.trim() {
396 let _ = writeln!(out, " ---\n{}", indent(&excerpt, " "));
397 }
398 }
399}
400
401fn clip(text: &str, max: usize) -> String {
405 let text = text.trim();
406 if text.chars().count() <= max {
407 return text.to_string();
408 }
409 let mut clipped: String = text.chars().take(max).collect();
410 clipped.push_str(" […]");
411 clipped
412}
413
414fn indent(text: &str, prefix: &str) -> String {
415 text.lines()
416 .map(|line| format!("{prefix}{line}"))
417 .collect::<Vec<_>>()
418 .join("\n")
419}
420
421fn short_time(value: &Value) -> String {
424 let raw = match value {
425 Value::Null => return String::new(),
426 Value::String(text) => text.clone(),
427 other => other.to_string(),
428 };
429 match raw.find('.') {
430 Some(dot) if raw.contains('T') => raw[..dot].to_string(),
431 _ => raw,
432 }
433}
434
435fn plural(count: usize, one: &'static str, many: &'static str) -> &'static str {
436 if count == 1 {
437 one
438 } else {
439 many
440 }
441}
442
443fn budget(text: String) -> String {
445 if text.chars().count() <= MAX_RESULT_CHARS {
446 return text;
447 }
448 let mut clipped: String = text.chars().take(MAX_RESULT_CHARS).collect();
449 clipped.push_str("\n\n[result truncated — ask a narrower question or lower `limit`]");
450 clipped
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use crate::graph::types::{EntitySummary, EntityType, Episode, TraversalEdge};
457 use serde_json::json;
458
459 fn entity(name: &str) -> EntityDetail {
460 EntityDetail {
461 id: json!(format!("entity:{name}")),
462 name: name.to_string(),
463 entity_type: EntityType::Tool,
464 abstract_text: format!("{name} is a thing."),
465 overview: format!("{name} does something in more words than the abstract."),
466 attributes: None,
467 access_count: 3,
468 utility_score: NEUTRAL_UTILITY,
469 updated_at: json!("2026-05-01T09:15:30.123456Z"),
470 source: Some("archive-log-042".into()),
471 }
472 }
473
474 fn scored(name: &str, score: f64, source: MatchSource) -> ScoredEntity {
475 ScoredEntity {
476 entity: entity(name),
477 score,
478 similarity: score,
481 source,
482 }
483 }
484
485 fn episode(session: &str, content: &str) -> EpisodeSearchResult {
486 EpisodeSearchResult {
487 episode: Episode {
488 id: json!("episode:1"),
489 session_id: session.to_string(),
490 timestamp: json!("2026-04-02T18:00:00Z"),
491 abstract_text: "A chat about deploys.".into(),
492 overview: None,
493 content: Some(content.to_string()),
494 embedding: None,
495 log_number: Some(42),
496 provenance: Some("human".into()),
497 access_count: 0,
498 },
499 score: 0.71,
500 distance: 0.32,
501 }
502 }
503
504 #[test]
505 fn empty_entity_search_points_at_the_next_move() {
506 let text = entities("deploys", &[]);
507 assert!(text.contains("No entities"));
508 assert!(text.contains("recall_episodes"));
509 assert!(text.contains("recall_status"));
510 }
511
512 #[test]
513 fn entities_carry_name_type_score_and_provenance() {
514 let text = entities("rust", &[scored("Rust", 0.812, MatchSource::Semantic)]);
515 assert!(
516 text.contains("1. Rust [tool] — score 0.81, matched directly"),
517 "{text}"
518 );
519 assert!(text.contains("Rust is a thing."), "{text}");
520 assert!(text.contains("updated 2026-05-01T09:15:30"), "{text}");
521 assert!(text.contains("from archive-log-042"), "{text}");
522 assert!(!text.contains("usefulness"), "{text}");
524 }
525
526 #[test]
527 fn graph_reached_entities_say_how_they_were_reached() {
528 let text = entities(
529 "rust",
530 &[scored(
531 "Cargo",
532 0.4,
533 MatchSource::Graph {
534 parent: "Rust".into(),
535 rel_type: "USES".into(),
536 },
537 )],
538 );
539 assert!(text.contains("reached from \"Rust\" via USES"), "{text}");
540 }
541
542 #[test]
543 fn moved_usefulness_is_reported() {
544 let mut result = scored("Rust", 0.5, MatchSource::Semantic);
545 result.entity.utility_score = 0.82;
546 let text = entities("rust", &[result]);
547 assert!(text.contains("usefulness 0.82"), "{text}");
548 }
549
550 #[test]
551 fn identical_overview_is_not_repeated() {
552 let mut result = scored("Rust", 0.5, MatchSource::Semantic);
553 result.entity.overview = result.entity.abstract_text.clone();
554 let text = entities("rust", &[result]);
555 assert_eq!(text.matches("Rust is a thing.").count(), 1, "{text}");
556 }
557
558 #[test]
559 fn episodes_report_similarity_and_the_original_text() {
560 let text = episodes(
561 "deploys",
562 &[episode("abc123", "We ran cargo dist and it broke.")],
563 );
564 assert!(text.contains("session abc123"), "{text}");
565 assert!(text.contains("archive log #42"), "{text}");
566 assert!(text.contains("similarity 0.68"), "{text}");
567 assert!(text.contains("We ran cargo dist and it broke."), "{text}");
568 }
569
570 #[test]
571 fn long_episode_content_is_clipped() {
572 let long = "x".repeat(MAX_EPISODE_CHARS * 2);
573 let text = episodes("deploys", &[episode("abc123", &long)]);
574 assert!(text.contains("[…]"), "{text}");
575 assert!(text.chars().count() < long.chars().count());
576 }
577
578 #[test]
579 fn query_result_separates_entities_from_fragments() {
580 let result = QueryResult {
581 entities: vec![scored("Rust", 0.9, MatchSource::Semantic)],
582 episodes: vec![episode("abc123", "some talk")],
583 };
584 let text = query_result("rust", &result);
585 assert!(text.contains("1 entity:"), "{text}");
586 assert!(text.contains("1 conversation fragment:"), "{text}");
587 }
588
589 #[test]
590 fn empty_query_result_explains_the_two_possibilities() {
591 let result = QueryResult {
592 entities: Vec::new(),
593 episodes: Vec::new(),
594 };
595 let text = query_result("nothing", &result);
596 assert!(text.contains("recall_status"), "{text}");
597 }
598
599 fn leaf(name: &str) -> TraversalNode {
600 TraversalNode {
601 entity: EntitySummary {
602 id: json!(format!("entity:{name}")),
603 name: name.to_string(),
604 entity_type: EntityType::Tool,
605 abstract_text: format!("{name} is a thing."),
606 },
607 edges: Vec::new(),
608 }
609 }
610
611 #[test]
612 fn a_lone_entity_says_so_instead_of_printing_an_empty_tree() {
613 let text = traversal("Rust", 2, &leaf("Rust"));
614 assert!(text.contains("no relationships recorded"), "{text}");
615 assert!(text.contains("Rust is a thing."), "{text}");
616 }
617
618 #[test]
619 fn uncertain_edges_get_a_legend() {
620 let mut root = leaf("Rust");
621 root.edges.push(TraversalEdge {
622 rel_type: "USES".into(),
623 direction: "->".into(),
624 target: leaf("Cargo"),
625 valid_from: json!("2026-01-01T00:00:00Z"),
626 valid_until: None,
627 confidence: 0.62,
628 });
629 let text = traversal("Rust", 1, &root);
630 assert!(text.contains("[62%]"), "{text}");
631 assert!(text.contains("accumulated confidence"), "{text}");
632 }
633
634 #[test]
635 fn certain_edges_get_no_legend() {
636 let mut root = leaf("Rust");
637 root.edges.push(TraversalEdge {
638 rel_type: "USES".into(),
639 direction: "->".into(),
640 target: leaf("Cargo"),
641 valid_from: json!("2026-01-01T00:00:00Z"),
642 valid_until: None,
643 confidence: 1.0,
644 });
645 let text = traversal("Rust", 1, &root);
646 assert!(!text.contains("accumulated confidence"), "{text}");
647 }
648
649 #[test]
650 fn status_reports_counts_and_flags_an_empty_graph() {
651 let empty = GraphStats {
652 entity_count: 0,
653 relationship_count: 0,
654 episode_count: 0,
655 entity_type_counts: Default::default(),
656 };
657 let text = status(&empty);
658 assert!(text.contains("0 entities"), "{text}");
659 assert!(text.contains("The graph is empty"), "{text}");
660 }
661
662 #[test]
663 fn status_flags_episodes_without_entities() {
664 let stats = GraphStats {
665 entity_count: 0,
666 relationship_count: 0,
667 episode_count: 120,
668 entity_type_counts: Default::default(),
669 };
670 let text = status(&stats);
671 assert!(text.contains("never distilled"), "{text}");
672 assert!(text.contains("recall_episodes"), "{text}");
673 }
674
675 #[test]
676 fn status_lists_types_by_descending_count() {
677 let mut counts = std::collections::HashMap::new();
678 counts.insert("tool".to_string(), 3);
679 counts.insert("project".to_string(), 9);
680 let stats = GraphStats {
681 entity_count: 12,
682 relationship_count: 4,
683 episode_count: 1,
684 entity_type_counts: counts,
685 };
686 let text = status(&stats);
687 assert!(text.contains("By type: project 9, tool 3."), "{text}");
688 assert!(text.contains("1 conversation episode."), "{text}");
689 }
690
691 #[test]
692 fn results_stay_inside_the_character_budget() {
693 let long = "y".repeat(MAX_RESULT_CHARS * 2);
694 let clipped = budget(long);
695 assert!(clipped.contains("result truncated"));
696 assert!(clipped.chars().count() < MAX_RESULT_CHARS + 100);
697 }
698
699 #[test]
700 fn clip_respects_character_boundaries() {
701 let text = "é".repeat(10);
702 assert_eq!(clip(&text, 3), "ééé […]");
703 assert_eq!(clip(&text, 50), text);
704 }
705}