1use std::fmt::Write as _;
18
19use serde_json::Value;
20
21use crate::graph::traverse::format_traversal;
22use crate::graph::types::{
23 EntityDetail, EpisodeSearchResult, GraphStats, MatchSource, QueryResult, ScoredEntity,
24 TraversalNode,
25};
26
27const MAX_ABSTRACT_CHARS: usize = 300;
29const MAX_OVERVIEW_CHARS: usize = 400;
32const MAX_EPISODE_CHARS: usize = 1_200;
34const MAX_RESULT_CHARS: usize = 24_000;
37const NEUTRAL_UTILITY: f64 = 0.5;
40
41#[must_use]
43pub fn entities(query: &str, results: &[ScoredEntity]) -> String {
44 if results.is_empty() {
45 return format!(
46 "No entities in memory match \"{query}\".\n\
47 Entities are distilled knowledge; the raw conversations may still hold it — try \
48 recall_episodes. If recall_status shows an empty graph, nothing has been ingested \
49 yet."
50 );
51 }
52
53 let mut out = format!(
54 "{} {} in memory for \"{query}\":\n",
55 results.len(),
56 plural(results.len(), "entity", "entities")
57 );
58 for (index, result) in results.iter().enumerate() {
59 write_entity(&mut out, index + 1, result);
60 }
61 budget(out)
62}
63
64#[must_use]
66pub fn query_result(query: &str, result: &QueryResult) -> String {
67 if result.entities.is_empty() && result.episodes.is_empty() {
68 return format!(
69 "Memory holds nothing about \"{query}\".\n\
70 Either it was never discussed, or it has not been ingested yet — recall_status \
71 says which."
72 );
73 }
74
75 let mut out = format!("Memory for \"{query}\":\n");
76
77 if result.entities.is_empty() {
78 out.push_str("\nNo distilled entities matched, but these conversations did.\n");
79 } else {
80 let _ = writeln!(
81 out,
82 "\n{} {}:",
83 result.entities.len(),
84 plural(result.entities.len(), "entity", "entities")
85 );
86 for (index, entity) in result.entities.iter().enumerate() {
87 write_entity(&mut out, index + 1, entity);
88 }
89 }
90
91 if !result.episodes.is_empty() {
92 let _ = writeln!(
93 out,
94 "\n{} conversation {}:",
95 result.episodes.len(),
96 plural(result.episodes.len(), "fragment", "fragments")
97 );
98 for (index, episode) in result.episodes.iter().enumerate() {
99 write_episode(&mut out, index + 1, episode);
100 }
101 }
102
103 budget(out)
104}
105
106#[must_use]
108pub fn episodes(query: &str, results: &[EpisodeSearchResult]) -> String {
109 if results.is_empty() {
110 return format!(
111 "No past conversation in memory matches \"{query}\".\n\
112 If recall_status shows episodes exist, the topic is genuinely absent; if it shows \
113 none, no sessions have been archived into the graph yet."
114 );
115 }
116
117 let mut out = format!(
118 "{} conversation {} for \"{query}\":\n",
119 results.len(),
120 plural(results.len(), "fragment", "fragments")
121 );
122 for (index, result) in results.iter().enumerate() {
123 write_episode(&mut out, index + 1, result);
124 }
125 budget(out)
126}
127
128#[must_use]
130pub fn traversal(entity: &str, depth: u32, node: &TraversalNode) -> String {
131 if node.edges.is_empty() {
132 return format!(
133 "\"{}\" ({}) exists in memory but has no relationships recorded within {depth} \
134 {}.\nIts own description: {}",
135 node.entity.name,
136 node.entity.entity_type,
137 plural(depth as usize, "hop", "hops"),
138 clip(&node.entity.abstract_text, MAX_ABSTRACT_CHARS)
139 );
140 }
141
142 let tree = format_traversal(node, 0);
143 let mut out = format!(
144 "Relationships from \"{entity}\", up to {depth} {}:\n\n{tree}",
145 plural(depth as usize, "hop", "hops")
146 );
147 if tree.contains('%') || tree.contains("[superseded]") {
148 out.push_str(
149 "\nA percentage is the edge's accumulated confidence (absent means fully \
150 corroborated); [superseded] marks a relationship that was true once and no longer \
151 is.\n",
152 );
153 }
154 budget(out)
155}
156
157#[must_use]
159pub fn status(stats: &GraphStats) -> String {
160 let mut out = format!(
161 "Memory graph: {} {}, {} {}, {} conversation {}.\n",
162 stats.entity_count,
163 plural(stats.entity_count as usize, "entity", "entities"),
164 stats.relationship_count,
165 plural(
166 stats.relationship_count as usize,
167 "relationship",
168 "relationships"
169 ),
170 stats.episode_count,
171 plural(stats.episode_count as usize, "episode", "episodes"),
172 );
173
174 if !stats.entity_type_counts.is_empty() {
175 let mut types: Vec<_> = stats.entity_type_counts.iter().collect();
176 types.sort_by(|left, right| right.1.cmp(left.1).then_with(|| left.0.cmp(right.0)));
177 let listed: Vec<String> = types
178 .iter()
179 .map(|(name, count)| format!("{name} {count}"))
180 .collect();
181 let _ = writeln!(out, "By type: {}.", listed.join(", "));
182 }
183
184 if stats.entity_count == 0 && stats.episode_count == 0 {
185 out.push_str(
186 "The graph is empty: no sessions have been ingested, so recall tools will find \
187 nothing.\n",
188 );
189 } else if stats.entity_count == 0 {
190 out.push_str(
191 "Conversations have been ingested but never distilled into entities, so \
192 recall_search and recall_query will be thin — recall_episodes still works.\n",
193 );
194 }
195
196 budget(out)
197}
198
199fn write_entity(out: &mut String, position: usize, result: &ScoredEntity) {
202 let entity = &result.entity;
203 let _ = writeln!(
204 out,
205 "\n{position}. {} [{}] — score {:.2}, {}",
206 entity.name,
207 entity.entity_type,
208 result.score,
209 match_source(&result.source)
210 );
211 let _ = writeln!(
212 out,
213 " {}",
214 clip(&entity.abstract_text, MAX_ABSTRACT_CHARS)
215 );
216 if adds_detail(entity) {
217 let _ = writeln!(out, " {}", clip(&entity.overview, MAX_OVERVIEW_CHARS));
218 }
219 if let Some(provenance) = entity_provenance(entity) {
220 let _ = writeln!(out, " {provenance}");
221 }
222}
223
224fn adds_detail(entity: &EntityDetail) -> bool {
227 let overview = entity.overview.trim();
228 !overview.is_empty() && overview != entity.abstract_text.trim()
229}
230
231fn entity_provenance(entity: &EntityDetail) -> Option<String> {
233 let mut parts = Vec::new();
234 let updated = short_time(&entity.updated_at);
235 if !updated.is_empty() {
236 parts.push(format!("updated {updated}"));
237 }
238 if let Some(source) = entity.source.as_deref().filter(|s| !s.trim().is_empty()) {
239 parts.push(format!("from {source}"));
240 }
241 if (entity.utility_score - NEUTRAL_UTILITY).abs() > 0.005 {
242 parts.push(format!("usefulness {:.2}", entity.utility_score));
243 }
244 (!parts.is_empty()).then(|| parts.join(" · "))
245}
246
247fn match_source(source: &MatchSource) -> String {
248 match source {
249 MatchSource::Semantic => "matched directly".to_string(),
250 MatchSource::Keyword => "matched by keyword".to_string(),
251 MatchSource::Graph { parent, rel_type } => {
252 format!("reached from \"{parent}\" via {rel_type}")
253 }
254 }
255}
256
257fn write_episode(out: &mut String, position: usize, result: &EpisodeSearchResult) {
258 let episode = &result.episode;
259 let mut header = format!("\n{position}. session {}", episode.session_id);
260 if let Some(log) = episode.log_number {
261 let _ = write!(header, ", archive log #{log}");
262 }
263 let timestamp = short_time(&episode.timestamp);
264 if !timestamp.is_empty() {
265 let _ = write!(header, ", {timestamp}");
266 }
267 let _ = writeln!(
268 out,
269 "{header} — score {:.2}, similarity {:.2}",
270 result.score,
271 1.0 - result.distance
272 );
273 let _ = writeln!(
274 out,
275 " {}",
276 clip(&episode.abstract_text, MAX_ABSTRACT_CHARS)
277 );
278
279 if let Some(content) = episode.content.as_deref().filter(|c| !c.trim().is_empty()) {
282 let excerpt = clip(content, MAX_EPISODE_CHARS);
283 if excerpt != episode.abstract_text.trim() {
284 let _ = writeln!(out, " ---\n{}", indent(&excerpt, " "));
285 }
286 }
287}
288
289fn clip(text: &str, max: usize) -> String {
293 let text = text.trim();
294 if text.chars().count() <= max {
295 return text.to_string();
296 }
297 let mut clipped: String = text.chars().take(max).collect();
298 clipped.push_str(" […]");
299 clipped
300}
301
302fn indent(text: &str, prefix: &str) -> String {
303 text.lines()
304 .map(|line| format!("{prefix}{line}"))
305 .collect::<Vec<_>>()
306 .join("\n")
307}
308
309fn short_time(value: &Value) -> String {
312 let raw = match value {
313 Value::Null => return String::new(),
314 Value::String(text) => text.clone(),
315 other => other.to_string(),
316 };
317 match raw.find('.') {
318 Some(dot) if raw.contains('T') => raw[..dot].to_string(),
319 _ => raw,
320 }
321}
322
323fn plural(count: usize, one: &'static str, many: &'static str) -> &'static str {
324 if count == 1 {
325 one
326 } else {
327 many
328 }
329}
330
331fn budget(text: String) -> String {
333 if text.chars().count() <= MAX_RESULT_CHARS {
334 return text;
335 }
336 let mut clipped: String = text.chars().take(MAX_RESULT_CHARS).collect();
337 clipped.push_str("\n\n[result truncated — ask a narrower question or lower `limit`]");
338 clipped
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::graph::types::{EntitySummary, EntityType, Episode, TraversalEdge};
345 use serde_json::json;
346
347 fn entity(name: &str) -> EntityDetail {
348 EntityDetail {
349 id: json!(format!("entity:{name}")),
350 name: name.to_string(),
351 entity_type: EntityType::Tool,
352 abstract_text: format!("{name} is a thing."),
353 overview: format!("{name} does something in more words than the abstract."),
354 attributes: None,
355 access_count: 3,
356 utility_score: NEUTRAL_UTILITY,
357 updated_at: json!("2026-05-01T09:15:30.123456Z"),
358 source: Some("archive-log-042".into()),
359 }
360 }
361
362 fn scored(name: &str, score: f64, source: MatchSource) -> ScoredEntity {
363 ScoredEntity {
364 entity: entity(name),
365 score,
366 similarity: score,
369 source,
370 }
371 }
372
373 fn episode(session: &str, content: &str) -> EpisodeSearchResult {
374 EpisodeSearchResult {
375 episode: Episode {
376 id: json!("episode:1"),
377 session_id: session.to_string(),
378 timestamp: json!("2026-04-02T18:00:00Z"),
379 abstract_text: "A chat about deploys.".into(),
380 overview: None,
381 content: Some(content.to_string()),
382 embedding: None,
383 log_number: Some(42),
384 provenance: Some("human".into()),
385 access_count: 0,
386 },
387 score: 0.71,
388 distance: 0.32,
389 }
390 }
391
392 #[test]
393 fn empty_entity_search_points_at_the_next_move() {
394 let text = entities("deploys", &[]);
395 assert!(text.contains("No entities"));
396 assert!(text.contains("recall_episodes"));
397 assert!(text.contains("recall_status"));
398 }
399
400 #[test]
401 fn entities_carry_name_type_score_and_provenance() {
402 let text = entities("rust", &[scored("Rust", 0.812, MatchSource::Semantic)]);
403 assert!(
404 text.contains("1. Rust [tool] — score 0.81, matched directly"),
405 "{text}"
406 );
407 assert!(text.contains("Rust is a thing."), "{text}");
408 assert!(text.contains("updated 2026-05-01T09:15:30"), "{text}");
409 assert!(text.contains("from archive-log-042"), "{text}");
410 assert!(!text.contains("usefulness"), "{text}");
412 }
413
414 #[test]
415 fn graph_reached_entities_say_how_they_were_reached() {
416 let text = entities(
417 "rust",
418 &[scored(
419 "Cargo",
420 0.4,
421 MatchSource::Graph {
422 parent: "Rust".into(),
423 rel_type: "USES".into(),
424 },
425 )],
426 );
427 assert!(text.contains("reached from \"Rust\" via USES"), "{text}");
428 }
429
430 #[test]
431 fn moved_usefulness_is_reported() {
432 let mut result = scored("Rust", 0.5, MatchSource::Semantic);
433 result.entity.utility_score = 0.82;
434 let text = entities("rust", &[result]);
435 assert!(text.contains("usefulness 0.82"), "{text}");
436 }
437
438 #[test]
439 fn identical_overview_is_not_repeated() {
440 let mut result = scored("Rust", 0.5, MatchSource::Semantic);
441 result.entity.overview = result.entity.abstract_text.clone();
442 let text = entities("rust", &[result]);
443 assert_eq!(text.matches("Rust is a thing.").count(), 1, "{text}");
444 }
445
446 #[test]
447 fn episodes_report_similarity_and_the_original_text() {
448 let text = episodes(
449 "deploys",
450 &[episode("abc123", "We ran cargo dist and it broke.")],
451 );
452 assert!(text.contains("session abc123"), "{text}");
453 assert!(text.contains("archive log #42"), "{text}");
454 assert!(text.contains("similarity 0.68"), "{text}");
455 assert!(text.contains("We ran cargo dist and it broke."), "{text}");
456 }
457
458 #[test]
459 fn long_episode_content_is_clipped() {
460 let long = "x".repeat(MAX_EPISODE_CHARS * 2);
461 let text = episodes("deploys", &[episode("abc123", &long)]);
462 assert!(text.contains("[…]"), "{text}");
463 assert!(text.chars().count() < long.chars().count());
464 }
465
466 #[test]
467 fn query_result_separates_entities_from_fragments() {
468 let result = QueryResult {
469 entities: vec![scored("Rust", 0.9, MatchSource::Semantic)],
470 episodes: vec![episode("abc123", "some talk")],
471 };
472 let text = query_result("rust", &result);
473 assert!(text.contains("1 entity:"), "{text}");
474 assert!(text.contains("1 conversation fragment:"), "{text}");
475 }
476
477 #[test]
478 fn empty_query_result_explains_the_two_possibilities() {
479 let result = QueryResult {
480 entities: Vec::new(),
481 episodes: Vec::new(),
482 };
483 let text = query_result("nothing", &result);
484 assert!(text.contains("recall_status"), "{text}");
485 }
486
487 fn leaf(name: &str) -> TraversalNode {
488 TraversalNode {
489 entity: EntitySummary {
490 id: json!(format!("entity:{name}")),
491 name: name.to_string(),
492 entity_type: EntityType::Tool,
493 abstract_text: format!("{name} is a thing."),
494 },
495 edges: Vec::new(),
496 }
497 }
498
499 #[test]
500 fn a_lone_entity_says_so_instead_of_printing_an_empty_tree() {
501 let text = traversal("Rust", 2, &leaf("Rust"));
502 assert!(text.contains("no relationships recorded"), "{text}");
503 assert!(text.contains("Rust is a thing."), "{text}");
504 }
505
506 #[test]
507 fn uncertain_edges_get_a_legend() {
508 let mut root = leaf("Rust");
509 root.edges.push(TraversalEdge {
510 rel_type: "USES".into(),
511 direction: "->".into(),
512 target: leaf("Cargo"),
513 valid_from: json!("2026-01-01T00:00:00Z"),
514 valid_until: None,
515 confidence: 0.62,
516 });
517 let text = traversal("Rust", 1, &root);
518 assert!(text.contains("[62%]"), "{text}");
519 assert!(text.contains("accumulated confidence"), "{text}");
520 }
521
522 #[test]
523 fn certain_edges_get_no_legend() {
524 let mut root = leaf("Rust");
525 root.edges.push(TraversalEdge {
526 rel_type: "USES".into(),
527 direction: "->".into(),
528 target: leaf("Cargo"),
529 valid_from: json!("2026-01-01T00:00:00Z"),
530 valid_until: None,
531 confidence: 1.0,
532 });
533 let text = traversal("Rust", 1, &root);
534 assert!(!text.contains("accumulated confidence"), "{text}");
535 }
536
537 #[test]
538 fn status_reports_counts_and_flags_an_empty_graph() {
539 let empty = GraphStats {
540 entity_count: 0,
541 relationship_count: 0,
542 episode_count: 0,
543 entity_type_counts: Default::default(),
544 };
545 let text = status(&empty);
546 assert!(text.contains("0 entities"), "{text}");
547 assert!(text.contains("The graph is empty"), "{text}");
548 }
549
550 #[test]
551 fn status_flags_episodes_without_entities() {
552 let stats = GraphStats {
553 entity_count: 0,
554 relationship_count: 0,
555 episode_count: 120,
556 entity_type_counts: Default::default(),
557 };
558 let text = status(&stats);
559 assert!(text.contains("never distilled"), "{text}");
560 assert!(text.contains("recall_episodes"), "{text}");
561 }
562
563 #[test]
564 fn status_lists_types_by_descending_count() {
565 let mut counts = std::collections::HashMap::new();
566 counts.insert("tool".to_string(), 3);
567 counts.insert("project".to_string(), 9);
568 let stats = GraphStats {
569 entity_count: 12,
570 relationship_count: 4,
571 episode_count: 1,
572 entity_type_counts: counts,
573 };
574 let text = status(&stats);
575 assert!(text.contains("By type: project 9, tool 3."), "{text}");
576 assert!(text.contains("1 conversation episode."), "{text}");
577 }
578
579 #[test]
580 fn results_stay_inside_the_character_budget() {
581 let long = "y".repeat(MAX_RESULT_CHARS * 2);
582 let clipped = budget(long);
583 assert!(clipped.contains("result truncated"));
584 assert!(clipped.chars().count() < MAX_RESULT_CHARS + 100);
585 }
586
587 #[test]
588 fn clip_respects_character_boundaries() {
589 let text = "é".repeat(10);
590 assert_eq!(clip(&text, 3), "ééé […]");
591 assert_eq!(clip(&text, 50), text);
592 }
593}