1use crate::constants::{
4 DEFAULT_K_RECALL, DEFAULT_MAX_HOPS, DEFAULT_MIN_WEIGHT, TEXT_DESCRIPTION_PREVIEW_LEN,
5};
6use crate::errors::AppError;
7use crate::i18n::errors_msg;
8use crate::output::{self, OutputFormat};
9use crate::paths::AppPaths;
10use crate::storage::connection::open_ro;
11use rusqlite::{params, Connection};
12use serde::Serialize;
13use std::collections::{HashSet, VecDeque};
14
15enum SeedKind {
17 Memory(i64),
18 Entity(i64),
19}
20
21type Neighbour = (i64, String, String, String, f64);
24
25#[derive(clap::Args)]
26#[command(after_long_help = "EXAMPLES:\n \
27 # List memories connected to a memory via the entity graph (default 2 hops)\n \
28 sqlite-graphrag related onboarding\n\n \
29 # Increase hop distance and filter by relation type\n \
30 sqlite-graphrag related onboarding --max-hops 3 --relation related\n\n \
31 # Cap result count and require minimum edge weight\n \
32 sqlite-graphrag related onboarding --limit 5 --min-weight 0.5")]
33pub struct RelatedArgs {
35 #[arg(
37 value_name = "NAME",
38 conflicts_with = "name",
39 help = "Memory name whose neighbours to traverse; alternative to --name"
40 )]
41 pub name_positional: Option<String>,
42 #[arg(long, alias = "from")]
44 pub name: Option<String>,
45 #[arg(long, alias = "hops", default_value_t = DEFAULT_MAX_HOPS)]
47 pub max_hops: u32,
48 #[arg(long, value_parser = crate::parsers::parse_relation)]
53 pub relation: Option<String>,
54 #[arg(long, default_value_t = DEFAULT_MIN_WEIGHT)]
56 pub min_weight: f64,
57 #[arg(long, default_value_t = DEFAULT_K_RECALL)]
59 pub limit: usize,
60 #[arg(long)]
62 pub namespace: Option<String>,
63 #[arg(long, value_enum, default_value = "json")]
65 pub format: OutputFormat,
66 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
68 pub json: bool,
69 #[arg(long)]
71 pub db: Option<String>,
72}
73
74#[derive(Serialize)]
75struct RelatedResponse {
76 name: String,
79 max_hops: u32,
81 results: Vec<RelatedMemory>,
82 related_memories: Vec<RelatedMemory>,
84 elapsed_ms: u64,
85}
86
87#[derive(Serialize, Clone)]
88struct RelatedMemory {
89 memory_id: i64,
90 name: String,
91 namespace: String,
92 #[serde(rename = "type")]
93 memory_type: String,
94 description: String,
95 hop_distance: u32,
96 source_entity: Option<String>,
97 target_entity: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 from: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 to: Option<String>,
104 relation: Option<String>,
105 weight: Option<f64>,
106}
107
108pub fn run(args: RelatedArgs) -> Result<(), AppError> {
110 let inicio = std::time::Instant::now();
111 let name = args
112 .name_positional
113 .as_deref()
114 .or(args.name.as_deref())
115 .ok_or_else(|| {
116 AppError::Validation(
117 "name required: pass as positional argument or via --name".to_string(),
118 )
119 })?
120 .to_string();
121
122 if name.trim().is_empty() {
123 return Err(AppError::Validation(crate::i18n::validation::name_must_not_be_empty()));
124 }
125
126 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
127 let paths = AppPaths::resolve(args.db.as_deref())?;
128
129 crate::storage::connection::ensure_db_ready(&paths)?;
130
131 let conn = open_ro(&paths.db)?;
132
133 let seed = match conn.query_row(
135 "SELECT id FROM memories WHERE namespace = ?1 AND name = ?2 AND deleted_at IS NULL",
136 params![namespace, name],
137 |r| r.get::<_, i64>(0),
138 ) {
139 Ok(id) => SeedKind::Memory(id),
140 Err(rusqlite::Error::QueryReturnedNoRows) => {
141 match crate::storage::entities::find_entity_id(&conn, &namespace, &name)? {
142 Some(id) => SeedKind::Entity(id),
143 None => {
144 return Err(AppError::NotFound(errors_msg::memory_or_entity_not_found(
145 &name, &namespace,
146 )))
147 }
148 }
149 }
150 Err(e) => return Err(AppError::Database(e)),
151 };
152
153 let (seed_memory_id, seed_entity_ids): (i64, Vec<i64>) = match &seed {
155 SeedKind::Memory(id) => {
156 let mem_id = *id;
157 let mut stmt =
158 conn.prepare_cached("SELECT entity_id FROM memory_entities WHERE memory_id = ?1")?;
159 let rows: Vec<i64> = stmt
160 .query_map(params![mem_id], |r| r.get(0))?
161 .collect::<Result<Vec<i64>, _>>()?;
162 (mem_id, rows)
163 }
164 SeedKind::Entity(entity_id) => {
165 (-1, vec![*entity_id])
168 }
169 };
170
171 let relation_filter = args.relation;
172 if let Some(ref r) = relation_filter {
173 crate::parsers::warn_if_non_canonical(r);
174 }
175 let results = traverse_related(
176 &conn,
177 seed_memory_id,
178 &seed_entity_ids,
179 &namespace,
180 args.max_hops,
181 args.min_weight,
182 relation_filter.as_deref(),
183 args.limit,
184 )?;
185
186 match args.format {
187 OutputFormat::Json => {
188 let related_memories = results.clone();
189 output::emit_json(&RelatedResponse {
190 name: name.clone(),
191 max_hops: args.max_hops,
192 results,
193 related_memories,
194 elapsed_ms: inicio.elapsed().as_millis() as u64,
195 })?;
196 }
197 OutputFormat::Text => {
198 for item in &results {
199 if item.description.is_empty() {
200 output::emit_text(&format!(
201 "{}. {} ({})",
202 item.hop_distance, item.name, item.namespace
203 ));
204 } else {
205 let preview: String = item
206 .description
207 .chars()
208 .take(TEXT_DESCRIPTION_PREVIEW_LEN)
209 .collect();
210 output::emit_text(&format!(
211 "{}. {} ({}): {}",
212 item.hop_distance, item.name, item.namespace, preview
213 ));
214 }
215 }
216 }
217 OutputFormat::Markdown => {
218 for item in &results {
219 if item.description.is_empty() {
220 output::emit_text(&format!(
221 "- **{}** ({}) — hop {}",
222 item.name, item.namespace, item.hop_distance
223 ));
224 } else {
225 let preview: String = item
226 .description
227 .chars()
228 .take(TEXT_DESCRIPTION_PREVIEW_LEN)
229 .collect();
230 output::emit_text(&format!(
231 "- **{}** ({}) — hop {}: {}",
232 item.name, item.namespace, item.hop_distance, preview
233 ));
234 }
235 }
236 }
237 }
238
239 Ok(())
240}
241
242#[allow(clippy::too_many_arguments)]
243fn traverse_related(
244 conn: &Connection,
245 seed_memory_id: i64,
246 seed_entity_ids: &[i64],
247 namespace: &str,
248 max_hops: u32,
249 min_weight: f64,
250 relation_filter: Option<&str>,
251 limit: usize,
252) -> Result<Vec<RelatedMemory>, AppError> {
253 if seed_entity_ids.is_empty() || max_hops == 0 {
254 return Ok(Vec::new());
255 }
256
257 let mut visited: HashSet<i64> = seed_entity_ids.iter().copied().collect();
260 let mut entity_hop: crate::hash::AHashMap<i64, u32> =
261 crate::hash::AHashMap::with_capacity_and_hasher(max_hops as usize * 10, Default::default());
262 for &e in seed_entity_ids {
263 entity_hop.insert(e, 0);
264 }
265 let mut entity_edge: crate::hash::AHashMap<i64, (String, String, String, f64)> =
268 crate::hash::AHashMap::with_capacity_and_hasher(max_hops as usize * 10, Default::default());
269
270 let mut queue: VecDeque<i64> = seed_entity_ids.iter().copied().collect();
271
272 while let Some(current_entity) = queue.pop_front() {
273 let current_hop = *entity_hop.get(¤t_entity).unwrap_or(&0);
274 if current_hop >= max_hops {
275 continue;
276 }
277
278 let neighbours =
279 fetch_neighbours(conn, current_entity, namespace, min_weight, relation_filter)?;
280
281 for (neighbour_id, source_name, target_name, relation, weight) in neighbours {
282 if visited.insert(neighbour_id) {
283 entity_hop.insert(neighbour_id, current_hop + 1);
284 entity_edge.insert(neighbour_id, (source_name, target_name, relation, weight));
285 queue.push_back(neighbour_id);
286 }
287 }
288 }
289
290 let mut out: Vec<RelatedMemory> = Vec::with_capacity(limit);
292 let mut dedup_ids: crate::hash::AHashSet<i64> =
293 crate::hash::AHashSet::with_capacity_and_hasher(limit, Default::default());
294 dedup_ids.insert(seed_memory_id);
295
296 let mut ordered_entities: Vec<(i64, u32)> = entity_hop
298 .iter()
299 .filter(|(id, _)| !seed_entity_ids.contains(id))
300 .map(|(id, hop)| (*id, *hop))
301 .collect();
302 ordered_entities.sort_by(|a, b| {
303 let weight_a = entity_edge.get(&a.0).map(|e| e.3).unwrap_or(0.0);
304 let weight_b = entity_edge.get(&b.0).map(|e| e.3).unwrap_or(0.0);
305 a.1.cmp(&b.1).then_with(|| {
306 weight_b
307 .partial_cmp(&weight_a)
308 .unwrap_or(std::cmp::Ordering::Equal)
309 })
310 });
311
312 for (entity_id, hop) in ordered_entities {
313 let mut stmt = conn.prepare_cached(
314 "SELECT m.id, m.name, m.namespace, m.type, m.description
315 FROM memory_entities me
316 JOIN memories m ON m.id = me.memory_id
317 WHERE me.entity_id = ?1 AND m.deleted_at IS NULL",
318 )?;
319 let rows = stmt
320 .query_map(params![entity_id], |r| {
321 Ok((
322 r.get::<_, i64>(0)?,
323 r.get::<_, String>(1)?,
324 r.get::<_, String>(2)?,
325 r.get::<_, String>(3)?,
326 r.get::<_, String>(4)?,
327 ))
328 })?
329 .collect::<Result<Vec<_>, _>>()?;
330
331 for (mid, name, ns, mtype, desc) in rows {
332 if !dedup_ids.insert(mid) {
333 continue;
334 }
335 let edge = entity_edge.get(&entity_id);
336 let src = edge.map(|e| e.0.clone());
337 let tgt = edge.map(|e| e.1.clone());
338 out.push(RelatedMemory {
339 memory_id: mid,
340 name,
341 namespace: ns,
342 memory_type: mtype,
343 description: desc,
344 hop_distance: hop,
345 source_entity: src.clone(),
346 target_entity: tgt.clone(),
347 from: src,
348 to: tgt,
349 relation: edge.map(|e| e.2.clone()),
350 weight: edge.map(|e| e.3),
351 });
352 if out.len() >= limit {
353 return Ok(out);
354 }
355 }
356 }
357 Ok(out)
358}
359
360fn fetch_neighbours(
361 conn: &Connection,
362 entity_id: i64,
363 namespace: &str,
364 min_weight: f64,
365 relation_filter: Option<&str>,
366) -> Result<Vec<Neighbour>, AppError> {
367 let base_sql = "\
370 SELECT r.target_id, se.name, te.name, r.relation, r.weight
371 FROM relationships r
372 JOIN entities se ON se.id = r.source_id
373 JOIN entities te ON te.id = r.target_id
374 WHERE r.source_id = ?1 AND r.weight >= ?2 AND r.namespace = ?3";
375
376 let reverse_sql = "\
377 SELECT r.source_id, se.name, te.name, r.relation, r.weight
378 FROM relationships r
379 JOIN entities se ON se.id = r.source_id
380 JOIN entities te ON te.id = r.target_id
381 WHERE r.target_id = ?1 AND r.weight >= ?2 AND r.namespace = ?3";
382
383 let mut results: Vec<Neighbour> = Vec::with_capacity(16);
384
385 let forward_sql = match relation_filter {
386 Some(_) => format!("{base_sql} AND r.relation = ?4"),
387 None => base_sql.to_string(),
388 };
389 let rev_sql = match relation_filter {
390 Some(_) => format!("{reverse_sql} AND r.relation = ?4"),
391 None => reverse_sql.to_string(),
392 };
393
394 let mut stmt = conn.prepare_cached(&forward_sql)?;
395 let rows: Vec<_> = if let Some(rel) = relation_filter {
396 stmt.query_map(params![entity_id, min_weight, namespace, rel], |r| {
397 Ok((
398 r.get::<_, i64>(0)?,
399 r.get::<_, String>(1)?,
400 r.get::<_, String>(2)?,
401 r.get::<_, String>(3)?,
402 r.get::<_, f64>(4)?,
403 ))
404 })?
405 .collect::<Result<Vec<_>, _>>()?
406 } else {
407 stmt.query_map(params![entity_id, min_weight, namespace], |r| {
408 Ok((
409 r.get::<_, i64>(0)?,
410 r.get::<_, String>(1)?,
411 r.get::<_, String>(2)?,
412 r.get::<_, String>(3)?,
413 r.get::<_, f64>(4)?,
414 ))
415 })?
416 .collect::<Result<Vec<_>, _>>()?
417 };
418 results.extend(rows);
419
420 let mut stmt = conn.prepare_cached(&rev_sql)?;
421 let rows: Vec<_> = if let Some(rel) = relation_filter {
422 stmt.query_map(params![entity_id, min_weight, namespace, rel], |r| {
423 Ok((
424 r.get::<_, i64>(0)?,
425 r.get::<_, String>(1)?,
426 r.get::<_, String>(2)?,
427 r.get::<_, String>(3)?,
428 r.get::<_, f64>(4)?,
429 ))
430 })?
431 .collect::<Result<Vec<_>, _>>()?
432 } else {
433 stmt.query_map(params![entity_id, min_weight, namespace], |r| {
434 Ok((
435 r.get::<_, i64>(0)?,
436 r.get::<_, String>(1)?,
437 r.get::<_, String>(2)?,
438 r.get::<_, String>(3)?,
439 r.get::<_, f64>(4)?,
440 ))
441 })?
442 .collect::<Result<Vec<_>, _>>()?
443 };
444 results.extend(rows);
445
446 Ok(results)
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 fn setup_related_db() -> rusqlite::Connection {
454 let conn = rusqlite::Connection::open_in_memory().expect("failed to open in-memory db");
455 conn.execute_batch(
456 "CREATE TABLE memories (
457 id INTEGER PRIMARY KEY AUTOINCREMENT,
458 name TEXT NOT NULL,
459 namespace TEXT NOT NULL DEFAULT 'global',
460 type TEXT NOT NULL DEFAULT 'fact',
461 description TEXT NOT NULL DEFAULT '',
462 deleted_at INTEGER
463 );
464 CREATE TABLE entities (
465 id INTEGER PRIMARY KEY AUTOINCREMENT,
466 namespace TEXT NOT NULL,
467 name TEXT NOT NULL
468 );
469 CREATE TABLE relationships (
470 id INTEGER PRIMARY KEY AUTOINCREMENT,
471 namespace TEXT NOT NULL,
472 source_id INTEGER NOT NULL,
473 target_id INTEGER NOT NULL,
474 relation TEXT NOT NULL DEFAULT 'related_to',
475 weight REAL NOT NULL DEFAULT 1.0
476 );
477 CREATE TABLE memory_entities (
478 memory_id INTEGER NOT NULL,
479 entity_id INTEGER NOT NULL
480 );",
481 )
482 .expect("failed to create test tables");
483 conn
484 }
485
486 fn insert_memory(conn: &rusqlite::Connection, name: &str, namespace: &str) -> i64 {
487 conn.execute(
488 "INSERT INTO memories (name, namespace) VALUES (?1, ?2)",
489 rusqlite::params![name, namespace],
490 )
491 .expect("failed to insert memory");
492 conn.last_insert_rowid()
493 }
494
495 fn insert_entity(conn: &rusqlite::Connection, name: &str, namespace: &str) -> i64 {
496 conn.execute(
497 "INSERT INTO entities (name, namespace) VALUES (?1, ?2)",
498 rusqlite::params![name, namespace],
499 )
500 .expect("failed to insert entity");
501 conn.last_insert_rowid()
502 }
503
504 fn link_memory_entity(conn: &rusqlite::Connection, memory_id: i64, entity_id: i64) {
505 conn.execute(
506 "INSERT INTO memory_entities (memory_id, entity_id) VALUES (?1, ?2)",
507 rusqlite::params![memory_id, entity_id],
508 )
509 .expect("failed to link memory-entity");
510 }
511
512 fn insert_relationship(
513 conn: &rusqlite::Connection,
514 namespace: &str,
515 source_id: i64,
516 target_id: i64,
517 relation: &str,
518 weight: f64,
519 ) {
520 conn.execute(
521 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight)
522 VALUES (?1, ?2, ?3, ?4, ?5)",
523 rusqlite::params![namespace, source_id, target_id, relation, weight],
524 )
525 .expect("failed to insert relationship");
526 }
527
528 #[test]
529 fn related_response_serializes_results_and_elapsed_ms() {
530 let mem = RelatedMemory {
531 memory_id: 1,
532 name: "neighbor-mem".to_string(),
533 namespace: "global".to_string(),
534 memory_type: "document".to_string(),
535 description: "desc".to_string(),
536 hop_distance: 1,
537 source_entity: Some("entity-a".to_string()),
538 target_entity: Some("entity-b".to_string()),
539 from: Some("entity-a".to_string()),
540 to: Some("entity-b".to_string()),
541 relation: Some("related_to".to_string()),
542 weight: Some(0.9),
543 };
544 let resp = RelatedResponse {
545 name: "seed-mem".to_string(),
546 max_hops: 2,
547 related_memories: vec![mem.clone()],
548 results: vec![mem],
549 elapsed_ms: 7,
550 };
551 let json = serde_json::to_value(&resp).expect("serialization failed");
552 assert!(json["results"].is_array());
553 assert_eq!(json["results"].as_array().unwrap().len(), 1);
554 assert_eq!(json["elapsed_ms"], 7u64);
555 assert_eq!(json["results"][0]["type"], "document");
556 assert_eq!(json["results"][0]["hop_distance"], 1);
557 }
558
559 #[test]
560 fn traverse_related_returns_empty_without_seed_entities() {
561 let conn = setup_related_db();
562 let result = traverse_related(&conn, 1, &[], "global", 2, 0.0, None, 10)
563 .expect("traverse_related failed");
564 assert!(result.is_empty());
565 }
566
567 #[test]
568 fn traverse_related_returns_empty_with_max_hops_zero() {
569 let conn = setup_related_db();
570 let mem_id = insert_memory(&conn, "seed", "global");
571 let ent_id = insert_entity(&conn, "global", "ent");
572 let result = traverse_related(&conn, mem_id, &[ent_id], "global", 0, 0.0, None, 10)
573 .expect("traverse_related failed");
574 assert!(result.is_empty());
575 }
576
577 #[test]
578 fn traverse_related_discovers_neighbor_memory_via_graph() {
579 let conn = setup_related_db();
580 let seed_id = insert_memory(&conn, "seed", "global");
581 let ent_a = insert_entity(&conn, "global", "ent-a");
582 let ent_b = insert_entity(&conn, "global", "ent-b");
583 let neighbor_id = insert_memory(&conn, "neighbor", "global");
584 link_memory_entity(&conn, seed_id, ent_a);
585 link_memory_entity(&conn, neighbor_id, ent_b);
586 insert_relationship(&conn, "global", ent_a, ent_b, "related_to", 1.0);
587 let result = traverse_related(&conn, seed_id, &[ent_a], "global", 2, 0.0, None, 10)
588 .expect("traverse_related failed");
589 assert_eq!(result.len(), 1);
590 assert_eq!(result[0].name, "neighbor");
591 }
592
593 #[test]
594 fn traverse_related_respects_limit() {
595 let conn = setup_related_db();
596 let seed_id = insert_memory(&conn, "seed", "global");
597 let ent_seed = insert_entity(&conn, "global", "ent-seed");
598 link_memory_entity(&conn, seed_id, ent_seed);
599 for i in 0..5 {
600 let ent_id = insert_entity(&conn, "global", &format!("ent-{i}"));
601 let mem_id = insert_memory(&conn, &format!("mem-{i}"), "global");
602 link_memory_entity(&conn, mem_id, ent_id);
603 insert_relationship(&conn, "global", ent_seed, ent_id, "related_to", 1.0);
604 }
605 let result = traverse_related(&conn, seed_id, &[ent_seed], "global", 1, 0.0, None, 3)
606 .expect("traverse_related failed");
607 assert_eq!(
608 result.len(),
609 3,
610 "limit=3 must constrain to at most 3 results"
611 );
612 }
613
614 #[test]
615 fn related_memory_optional_null_fields_serialized() {
616 let mem = RelatedMemory {
617 memory_id: 99,
618 name: "no-relation".to_string(),
619 namespace: "ns".to_string(),
620 memory_type: "concept".to_string(),
621 description: "".to_string(),
622 hop_distance: 2,
623 source_entity: None,
624 target_entity: None,
625 from: None,
626 to: None,
627 relation: None,
628 weight: None,
629 };
630 let json = serde_json::to_value(&mem).expect("serialization failed");
631 assert!(json["source_entity"].is_null());
632 assert!(json["target_entity"].is_null());
633 assert!(json["relation"].is_null());
634 assert!(json["weight"].is_null());
635 assert_eq!(json["hop_distance"], 2);
636 }
637}