1use std::collections::HashMap;
56
57use surrealdb::Surreal;
58
59use super::confidence;
60use super::embed::Embedder;
61use super::error::GraphError;
62use super::search::{compute_hotness, score_with_utility};
63use super::store::Db;
64use super::types::*;
65use crate::config::GraphScoringConfig;
66
67pub async fn query(
69 db: &Surreal<Db>,
70 embedder: &dyn Embedder,
71 scoring: &GraphScoringConfig,
72 query_text: &str,
73 options: &QueryOptions,
74) -> Result<QueryResult, GraphError> {
75 let limit = if options.limit == 0 {
76 10
77 } else {
78 options.limit
79 };
80
81 let semantic_options = SearchOptions {
83 limit: limit * 2,
84 entity_type: options.entity_type.clone(),
85 keyword: options.keyword.clone(),
86 };
87 let semantic_results =
88 super::search::search_with_options(db, embedder, scoring, query_text, &semantic_options)
89 .await?;
90
91 let mut entity_map: HashMap<String, ScoredEntity> = HashMap::new();
93 for result in semantic_results {
94 entity_map.insert(result.entity.id_string(), result);
95 }
96
97 if options.graph_depth > 0 {
99 let parents = top_expansion_parents(&entity_map);
100 let reached = collect_graph_candidates(db, &parents, options).await?;
101 merge_graph_candidates(&mut entity_map, reached, scoring);
102 }
103
104 let mut entities: Vec<ScoredEntity> = entity_map.into_values().collect();
106 entities.sort_by(|a, b| {
107 b.score
108 .partial_cmp(&a.score)
109 .unwrap_or(std::cmp::Ordering::Equal)
110 });
111 entities.truncate(limit);
112
113 let expanded_ids: Vec<String> = entities
119 .iter()
120 .filter(|e| matches!(e.source, MatchSource::Graph { .. }))
121 .map(|e| e.entity.id_string())
122 .collect();
123 super::crud::increment_access_counts(db, &expanded_ids).await?;
124
125 let episodes = if options.include_episodes {
127 super::search::search_episodes(db, embedder, query_text, limit).await?
128 } else {
129 vec![]
130 };
131
132 Ok(QueryResult { entities, episodes })
133}
134
135const EXPANSION_PARENTS: usize = 3;
137
138struct ExpansionParent {
143 id: String,
144 name: String,
145 similarity: f64,
146}
147
148struct GraphCandidate {
150 entity: EntityDetail,
151 parent: String,
152 rel_type: String,
153 effective_confidence: f64,
154 similarity: f64,
156}
157
158fn top_expansion_parents(entity_map: &HashMap<String, ScoredEntity>) -> Vec<ExpansionParent> {
163 let mut ranked: Vec<&ScoredEntity> = entity_map.values().collect();
164 ranked.sort_by(|a, b| {
165 b.score
166 .partial_cmp(&a.score)
167 .unwrap_or(std::cmp::Ordering::Equal)
168 .then_with(|| a.entity.id_string().cmp(&b.entity.id_string()))
169 });
170 ranked.truncate(EXPANSION_PARENTS);
171 ranked
172 .into_iter()
173 .map(|hit| ExpansionParent {
174 id: hit.entity.id_string(),
175 name: hit.entity.name.clone(),
176 similarity: hit.similarity,
177 })
178 .collect()
179}
180
181async fn collect_graph_candidates(
183 db: &Surreal<Db>,
184 parents: &[ExpansionParent],
185 options: &QueryOptions,
186) -> Result<HashMap<String, GraphCandidate>, GraphError> {
187 let mut reached: HashMap<String, GraphCandidate> = HashMap::new();
188
189 for parent in parents {
190 for (entity, rel_type, effective_confidence) in get_neighbor_details(db, &parent.id).await?
191 {
192 if let Some(ref et) = options.entity_type {
193 if entity.entity_type.to_string() != *et {
194 continue;
195 }
196 }
197
198 let id = entity.id_string();
199 if id == parent.id {
200 continue; }
202
203 let similarity = parent.similarity * effective_confidence;
204 if reached
205 .get(&id)
206 .is_some_and(|best| best.similarity >= similarity)
207 {
208 continue;
209 }
210
211 reached.insert(
212 id,
213 GraphCandidate {
214 entity,
215 parent: parent.name.clone(),
216 rel_type,
217 effective_confidence,
218 similarity,
219 },
220 );
221 }
222 }
223
224 Ok(reached)
225}
226
227fn merge_graph_candidates(
230 entity_map: &mut HashMap<String, ScoredEntity>,
231 reached: HashMap<String, GraphCandidate>,
232 scoring: &GraphScoringConfig,
233) {
234 let now = chrono::Utc::now();
235
236 for (id, candidate) in reached {
237 match entity_map.get_mut(&id) {
238 Some(existing) => {
239 let corroborated = corroborated_similarity(
240 scoring,
241 existing.similarity,
242 candidate.effective_confidence,
243 );
244 existing.similarity = corroborated;
245 existing.score = score_entity(scoring, &existing.entity, corroborated, &now);
246 }
247 None => {
248 let score = score_entity(scoring, &candidate.entity, candidate.similarity, &now);
249 entity_map.insert(
250 id,
251 ScoredEntity {
252 entity: candidate.entity,
253 similarity: candidate.similarity,
254 score,
255 source: MatchSource::Graph {
256 parent: candidate.parent,
257 rel_type: candidate.rel_type,
258 },
259 },
260 );
261 }
262 }
263 }
264}
265
266fn corroborated_similarity(
269 scoring: &GraphScoringConfig,
270 similarity: f64,
271 effective_confidence: f64,
272) -> f64 {
273 (similarity * (1.0 + scoring.corroboration_boost * effective_confidence)).min(1.0)
274}
275
276fn score_entity(
278 scoring: &GraphScoringConfig,
279 entity: &EntityDetail,
280 similarity: f64,
281 now: &chrono::DateTime<chrono::Utc>,
282) -> f64 {
283 let hotness = compute_hotness(entity.access_count, &entity.updated_at_string(), now);
284 score_with_utility(scoring, similarity, hotness, entity.utility_score)
285}
286
287async fn get_neighbor_details(
289 db: &Surreal<Db>,
290 entity_id: &str,
291) -> Result<Vec<(EntityDetail, String, f64)>, GraphError> {
292 let now = chrono::Utc::now();
293
294 let mut response = db
296 .query(
297 r#"
298 SELECT rel_type, confidence, last_reinforced, valid_from, out AS target_id
299 FROM relates_to
300 WHERE in = type::record($id) AND valid_until IS NONE
301 "#,
302 )
303 .bind(("id", entity_id.to_string()))
304 .await?;
305
306 let outgoing: Vec<RelTarget> = super::deserialize_take(&mut response, 0)?;
307
308 let mut response = db
310 .query(
311 r#"
312 SELECT rel_type, confidence, last_reinforced, valid_from, in AS target_id
313 FROM relates_to
314 WHERE out = type::record($id) AND valid_until IS NONE
315 "#,
316 )
317 .bind(("id", entity_id.to_string()))
318 .await?;
319
320 let incoming: Vec<RelTarget> = super::deserialize_take(&mut response, 0)?;
321
322 let mut results = Vec::new();
323 let all_edges: Vec<_> = outgoing.into_iter().chain(incoming).collect();
324
325 for edge in all_edges {
326 let effective = confidence::effective_confidence(
328 edge.confidence,
329 edge.last_reinforced.as_ref(),
330 &edge.valid_from,
331 &now,
332 );
333
334 if effective < 0.1 {
336 continue;
337 }
338
339 let tid = match &edge.target_id {
340 serde_json::Value::String(s) => s.clone(),
341 other => other.to_string(),
342 };
343
344 if let Some(detail) = super::crud::get_entity_detail(db, &tid).await? {
345 results.push((detail, edge.rel_type, effective));
346 }
347 }
348
349 Ok(results)
350}
351
352fn default_rel_confidence() -> f64 {
353 1.0
354}
355
356#[derive(serde::Deserialize)]
357struct RelTarget {
358 rel_type: String,
359 target_id: serde_json::Value,
360 #[serde(default = "default_rel_confidence")]
361 confidence: f64,
362 #[serde(default)]
363 last_reinforced: Option<serde_json::Value>,
364 #[serde(default)]
365 valid_from: serde_json::Value,
366}
367
368pub async fn pipeline_entities(
372 db: &Surreal<Db>,
373 stage: &str,
374 status: Option<&str>,
375) -> Result<Vec<EntityDetail>, GraphError> {
376 let query = match status {
377 Some(_) => {
378 r#"SELECT id, name, entity_type, abstract, overview, attributes, access_count, updated_at, source
379 FROM entity
380 WHERE attributes.pipeline_stage = $stage
381 AND attributes.pipeline_status = $status
382 ORDER BY updated_at DESC"#
383 }
384 None => {
385 r#"SELECT id, name, entity_type, abstract, overview, attributes, access_count, updated_at, source
386 FROM entity
387 WHERE attributes.pipeline_stage = $stage
388 ORDER BY updated_at DESC"#
389 }
390 };
391
392 let stage_owned = stage.to_string();
393 let mut response = match status {
394 Some(s) => {
395 let status_owned = s.to_string();
396 db.query(query)
397 .bind(("stage", stage_owned))
398 .bind(("status", status_owned))
399 .await?
400 }
401 None => db.query(query).bind(("stage", stage_owned)).await?,
402 };
403
404 let entities: Vec<EntityDetail> = super::deserialize_take(&mut response, 0)?;
405 Ok(entities)
406}
407
408pub async fn pipeline_stats(
410 db: &Surreal<Db>,
411 staleness_days: u32,
412) -> Result<PipelineGraphStats, GraphError> {
413 let mut response = db
415 .query(
416 r#"SELECT
417 attributes.pipeline_stage AS stage,
418 attributes.pipeline_status AS status,
419 count() AS count
420 FROM entity
421 WHERE attributes.pipeline_stage IS NOT NONE
422 GROUP BY attributes.pipeline_stage, attributes.pipeline_status"#,
423 )
424 .await?;
425
426 let rows: Vec<StageStatusCount> = super::deserialize_take(&mut response, 0)?;
427
428 let mut by_stage: std::collections::HashMap<String, std::collections::HashMap<String, u64>> =
429 std::collections::HashMap::new();
430 let mut total = 0u64;
431
432 for row in rows {
433 total += row.count;
434 by_stage
435 .entry(row.stage)
436 .or_default()
437 .insert(row.status, row.count);
438 }
439
440 let mut stale_response = db
443 .query(
444 r#"SELECT id, name, entity_type, abstract, overview, attributes, access_count, updated_at, source
445 FROM entity
446 WHERE attributes.pipeline_stage = 'thoughts'
447 AND attributes.pipeline_status = 'active'
448 AND updated_at < time::now() - type::duration($threshold)
449 AND count(
450 SELECT * FROM relates_to
451 WHERE (in = $parent.id OR out = $parent.id)
452 AND valid_from > time::now() - type::duration($threshold)
453 ) = 0
454 ORDER BY updated_at ASC"#,
455 )
456 .bind(("threshold", format!("{staleness_days}d")))
457 .await?;
458
459 let stale_thoughts: Vec<EntityDetail> = super::deserialize_take(&mut stale_response, 0)?;
460
461 let mut stale_q_response = db
463 .query(
464 r#"SELECT id, name, entity_type, abstract, overview, attributes, access_count, updated_at, source
465 FROM entity
466 WHERE attributes.pipeline_stage = 'curiosity'
467 AND attributes.pipeline_status = 'active'
468 AND attributes.sub_type IS NONE
469 AND updated_at < time::now() - type::duration($threshold)
470 AND count(
471 SELECT * FROM relates_to
472 WHERE (in = $parent.id OR out = $parent.id)
473 AND valid_from > time::now() - type::duration($threshold)
474 ) = 0
475 ORDER BY updated_at ASC"#,
476 )
477 .bind(("threshold", format!("{}d", staleness_days * 2)))
478 .await?;
479
480 let stale_questions: Vec<EntityDetail> = super::deserialize_take(&mut stale_q_response, 0)?;
481
482 let mut orphan_response = db
484 .query(
485 r#"SELECT count() AS count FROM entity
486 WHERE attributes.pipeline_stage IS NOT NONE
487 AND attributes.pipeline_status = 'active'
488 AND count(SELECT * FROM relates_to WHERE in = $parent.id OR out = $parent.id) = 0
489 GROUP ALL"#,
490 )
491 .await?;
492
493 let orphan_rows: Vec<CountRow> = super::deserialize_take(&mut orphan_response, 0)?;
494 let orphan_count = orphan_rows.first().map(|r| r.count).unwrap_or(0);
495
496 let mut movement_response = db
498 .query(
499 r#"SELECT updated_at
500 FROM entity
501 WHERE attributes.pipeline_status IN ['graduated', 'dissolved', 'explored']
502 ORDER BY updated_at DESC
503 LIMIT 1"#,
504 )
505 .await?;
506
507 let movement_rows: Vec<UpdatedAtRow> = super::deserialize_take(&mut movement_response, 0)?;
508 let last_movement = movement_rows.first().map(|r| match &r.updated_at {
509 serde_json::Value::String(s) => s.clone(),
510 other => other.to_string(),
511 });
512
513 Ok(PipelineGraphStats {
514 by_stage,
515 stale_thoughts,
516 stale_questions,
517 orphan_count,
518 total_entities: total,
519 last_movement,
520 })
521}
522
523pub async fn pipeline_flow(
525 db: &Surreal<Db>,
526 entity_name: &str,
527) -> Result<Vec<(EntityDetail, String, EntityDetail)>, GraphError> {
528 let entity = super::crud::get_entity_by_name(db, entity_name)
530 .await?
531 .ok_or_else(|| GraphError::NotFound(format!("entity: {entity_name}")))?;
532
533 let entity_id = entity.id_string();
534 let mut chain = Vec::new();
535
536 let pipeline_rel_types = [
538 "EVOLVED_FROM",
539 "CRYSTALLIZED_FROM",
540 "INFORMED_BY",
541 "GRADUATED_TO",
542 "CONNECTED_TO",
543 "EXPLORES",
544 "ARCHIVED_FROM",
545 ];
546 let rel_types_str = pipeline_rel_types
547 .iter()
548 .map(|r| format!("'{r}'"))
549 .collect::<Vec<_>>()
550 .join(", ");
551
552 let query_out = format!(
554 r#"SELECT rel_type, out AS target_id
555 FROM relates_to
556 WHERE in = type::record($id) AND rel_type IN [{rel_types_str}] AND valid_until IS NONE"#
557 );
558 let mut response = db.query(&query_out).bind(("id", entity_id.clone())).await?;
559 let outgoing: Vec<RelTarget> = super::deserialize_take(&mut response, 0)?;
560
561 for edge in &outgoing {
562 let tid = match &edge.target_id {
563 serde_json::Value::String(s) => s.clone(),
564 other => other.to_string(),
565 };
566 if let Some(target) = super::crud::get_entity_detail(db, &tid).await? {
567 let source_detail = super::crud::get_entity_detail(db, &entity_id)
568 .await?
569 .unwrap();
570 chain.push((source_detail, edge.rel_type.clone(), target));
571 }
572 }
573
574 let query_in = format!(
576 r#"SELECT rel_type, in AS target_id
577 FROM relates_to
578 WHERE out = type::record($id) AND rel_type IN [{rel_types_str}] AND valid_until IS NONE"#
579 );
580 let mut response = db.query(&query_in).bind(("id", entity_id.clone())).await?;
581 let incoming: Vec<RelTarget> = super::deserialize_take(&mut response, 0)?;
582
583 for edge in &incoming {
584 let tid = match &edge.target_id {
585 serde_json::Value::String(s) => s.clone(),
586 other => other.to_string(),
587 };
588 if let Some(source) = super::crud::get_entity_detail(db, &tid).await? {
589 let target_detail = super::crud::get_entity_detail(db, &entity_id)
590 .await?
591 .unwrap();
592 chain.push((source, edge.rel_type.clone(), target_detail));
593 }
594 }
595
596 Ok(chain)
597}
598
599fn lenient_string<'de, D>(deserializer: D) -> Result<String, D::Error>
600where
601 D: serde::Deserializer<'de>,
602{
603 use serde::de;
604 struct Visitor;
605 impl<'de> de::Visitor<'de> for Visitor {
606 type Value = String;
607 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
608 f.write_str("a string, integer, or null")
609 }
610 fn visit_str<E: de::Error>(self, v: &str) -> Result<String, E> {
611 Ok(v.to_string())
612 }
613 fn visit_string<E: de::Error>(self, v: String) -> Result<String, E> {
614 Ok(v)
615 }
616 fn visit_i64<E: de::Error>(self, v: i64) -> Result<String, E> {
617 Ok(v.to_string())
618 }
619 fn visit_u64<E: de::Error>(self, v: u64) -> Result<String, E> {
620 Ok(v.to_string())
621 }
622 fn visit_unit<E: de::Error>(self) -> Result<String, E> {
623 Ok("unknown".to_string())
624 }
625 fn visit_none<E: de::Error>(self) -> Result<String, E> {
626 Ok("unknown".to_string())
627 }
628 fn visit_bool<E: de::Error>(self, v: bool) -> Result<String, E> {
629 Ok(v.to_string())
630 }
631 fn visit_f64<E: de::Error>(self, v: f64) -> Result<String, E> {
632 Ok(v.to_string())
633 }
634 }
635 deserializer.deserialize_any(Visitor)
636}
637
638#[derive(serde::Deserialize)]
639struct StageStatusCount {
640 #[serde(deserialize_with = "lenient_string")]
641 stage: String,
642 #[serde(deserialize_with = "lenient_string")]
643 status: String,
644 count: u64,
645}
646
647#[derive(serde::Deserialize)]
648struct UpdatedAtRow {
649 updated_at: serde_json::Value,
650}
651
652#[derive(serde::Deserialize)]
653struct CountRow {
654 count: u64,
655}