Skip to main content

recall_echo/graph/
query.rs

1//! Hybrid query — combines semantic search, graph expansion, and episode search.
2//!
3//! Pipeline:
4//! 1. **Semantic phase**: HNSW KNN with `limit * 2` to gather candidates
5//! 2. **Graph phase**: 1-hop expansion from top-N results, scored as `parent_score * 0.5`
6//! 3. **Merge + deduplicate** by entity ID, keeping highest score
7//! 4. **Episode search** (optional) — separate KNN on episodes
8
9use std::collections::HashMap;
10
11use surrealdb::Surreal;
12
13use super::confidence;
14use super::embed::Embedder;
15use super::error::GraphError;
16use super::store::Db;
17use super::types::*;
18use crate::config::GraphScoringConfig;
19
20/// Run a hybrid query: semantic search + graph expansion + optional episode search.
21pub async fn query(
22    db: &Surreal<Db>,
23    embedder: &dyn Embedder,
24    scoring: &GraphScoringConfig,
25    query_text: &str,
26    options: &QueryOptions,
27) -> Result<QueryResult, GraphError> {
28    let limit = if options.limit == 0 {
29        10
30    } else {
31        options.limit
32    };
33
34    // Phase 1: Semantic search with 2x limit to get candidates
35    let semantic_options = SearchOptions {
36        limit: limit * 2,
37        entity_type: options.entity_type.clone(),
38        keyword: options.keyword.clone(),
39    };
40    let semantic_results =
41        super::search::search_with_options(db, embedder, scoring, query_text, &semantic_options)
42            .await?;
43
44    // Collect into dedup map (id -> ScoredEntity)
45    let mut entity_map: HashMap<String, ScoredEntity> = HashMap::new();
46    for result in semantic_results {
47        entity_map.insert(result.entity.id_string(), result);
48    }
49
50    // Phase 2: Graph expansion — 1-hop from top-N semantic results
51    if options.graph_depth > 0 {
52        let top_n: Vec<(String, f64)> = {
53            let mut entries: Vec<_> = entity_map
54                .values()
55                .map(|e| (e.entity.id_string(), e.score))
56                .collect();
57            entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
58            entries.truncate(3); // Expand from top 3
59            entries
60        };
61
62        for (parent_id, parent_score) in &top_n {
63            let parent_name = entity_map
64                .get(parent_id)
65                .map(|e| e.entity.name.clone())
66                .unwrap_or_default();
67
68            let neighbors = get_neighbor_details(db, parent_id).await?;
69
70            for (neighbor, rel_type, confidence) in neighbors {
71                let neighbor_id = neighbor.id_string();
72                if entity_map.contains_key(&neighbor_id) {
73                    continue; // Already in results
74                }
75
76                // Apply type filter
77                if let Some(ref et) = options.entity_type {
78                    if neighbor.entity_type.to_string() != *et {
79                        continue;
80                    }
81                }
82
83                let graph_score = parent_score * confidence;
84                entity_map.insert(
85                    neighbor_id,
86                    ScoredEntity {
87                        entity: neighbor,
88                        score: graph_score,
89                        source: MatchSource::Graph {
90                            parent: parent_name.clone(),
91                            rel_type,
92                        },
93                    },
94                );
95            }
96        }
97    }
98
99    // Sort by score descending, truncate to limit
100    let mut entities: Vec<ScoredEntity> = entity_map.into_values().collect();
101    entities.sort_by(|a, b| {
102        b.score
103            .partial_cmp(&a.score)
104            .unwrap_or(std::cmp::Ordering::Equal)
105    });
106    entities.truncate(limit);
107
108    // Phase 3: Episode search (optional)
109    let episodes = if options.include_episodes {
110        super::search::search_episodes(db, embedder, query_text, limit).await?
111    } else {
112        vec![]
113    };
114
115    Ok(QueryResult { entities, episodes })
116}
117
118/// Get 1-hop neighbors as L1 (EntityDetail) with the relationship type and effective confidence.
119async fn get_neighbor_details(
120    db: &Surreal<Db>,
121    entity_id: &str,
122) -> Result<Vec<(EntityDetail, String, f64)>, GraphError> {
123    let now = chrono::Utc::now();
124
125    // Outgoing
126    let mut response = db
127        .query(
128            r#"
129            SELECT rel_type, confidence, last_reinforced, valid_from, out AS target_id
130            FROM relates_to
131            WHERE in = type::record($id) AND valid_until IS NONE
132            "#,
133        )
134        .bind(("id", entity_id.to_string()))
135        .await?;
136
137    let outgoing: Vec<RelTarget> = super::deserialize_take(&mut response, 0)?;
138
139    // Incoming
140    let mut response = db
141        .query(
142            r#"
143            SELECT rel_type, confidence, last_reinforced, valid_from, in AS target_id
144            FROM relates_to
145            WHERE out = type::record($id) AND valid_until IS NONE
146            "#,
147        )
148        .bind(("id", entity_id.to_string()))
149        .await?;
150
151    let incoming: Vec<RelTarget> = super::deserialize_take(&mut response, 0)?;
152
153    let mut results = Vec::new();
154    let all_edges: Vec<_> = outgoing.into_iter().chain(incoming).collect();
155
156    for edge in all_edges {
157        // Apply temporal decay at read time
158        let effective = confidence::effective_confidence(
159            edge.confidence,
160            edge.last_reinforced.as_ref(),
161            &edge.valid_from,
162            &now,
163        );
164
165        // Filter by effective confidence
166        if effective < 0.1 {
167            continue;
168        }
169
170        let tid = match &edge.target_id {
171            serde_json::Value::String(s) => s.clone(),
172            other => other.to_string(),
173        };
174
175        if let Some(detail) = super::crud::get_entity_detail(db, &tid).await? {
176            results.push((detail, edge.rel_type, effective));
177        }
178    }
179
180    Ok(results)
181}
182
183fn default_rel_confidence() -> f64 {
184    1.0
185}
186
187#[derive(serde::Deserialize)]
188struct RelTarget {
189    rel_type: String,
190    target_id: serde_json::Value,
191    #[serde(default = "default_rel_confidence")]
192    confidence: f64,
193    #[serde(default)]
194    last_reinforced: Option<serde_json::Value>,
195    #[serde(default)]
196    valid_from: serde_json::Value,
197}
198
199// ── Pipeline queries ─────────────────────────────────────────────────
200
201/// Get all pipeline entities for a given stage, optionally filtered by status.
202pub async fn pipeline_entities(
203    db: &Surreal<Db>,
204    stage: &str,
205    status: Option<&str>,
206) -> Result<Vec<EntityDetail>, GraphError> {
207    let query = match status {
208        Some(_) => {
209            r#"SELECT id, name, entity_type, abstract, overview, attributes, access_count, updated_at, source
210               FROM entity
211               WHERE attributes.pipeline_stage = $stage
212                 AND attributes.pipeline_status = $status
213               ORDER BY updated_at DESC"#
214        }
215        None => {
216            r#"SELECT id, name, entity_type, abstract, overview, attributes, access_count, updated_at, source
217               FROM entity
218               WHERE attributes.pipeline_stage = $stage
219               ORDER BY updated_at DESC"#
220        }
221    };
222
223    let stage_owned = stage.to_string();
224    let mut response = match status {
225        Some(s) => {
226            let status_owned = s.to_string();
227            db.query(query)
228                .bind(("stage", stage_owned))
229                .bind(("status", status_owned))
230                .await?
231        }
232        None => db.query(query).bind(("stage", stage_owned)).await?,
233    };
234
235    let entities: Vec<EntityDetail> = super::deserialize_take(&mut response, 0)?;
236    Ok(entities)
237}
238
239/// Get pipeline stats: counts by (stage, status), stale entities.
240pub async fn pipeline_stats(
241    db: &Surreal<Db>,
242    staleness_days: u32,
243) -> Result<PipelineGraphStats, GraphError> {
244    // Count by stage and status
245    let mut response = db
246        .query(
247            r#"SELECT
248                 attributes.pipeline_stage AS stage,
249                 attributes.pipeline_status AS status,
250                 count() AS count
251               FROM entity
252               WHERE attributes.pipeline_stage IS NOT NONE
253               GROUP BY attributes.pipeline_stage, attributes.pipeline_status"#,
254        )
255        .await?;
256
257    let rows: Vec<StageStatusCount> = super::deserialize_take(&mut response, 0)?;
258
259    let mut by_stage: std::collections::HashMap<String, std::collections::HashMap<String, u64>> =
260        std::collections::HashMap::new();
261    let mut total = 0u64;
262
263    for row in rows {
264        total += row.count;
265        by_stage
266            .entry(row.stage)
267            .or_default()
268            .insert(row.status, row.count);
269    }
270
271    // Find stale thoughts — connectivity-aware: active thoughts with no relationships
272    // updated within staleness_days AND entity itself not updated within staleness_days.
273    let mut stale_response = db
274        .query(
275            r#"SELECT id, name, entity_type, abstract, overview, attributes, access_count, updated_at, source
276               FROM entity
277               WHERE attributes.pipeline_stage = 'thoughts'
278                 AND attributes.pipeline_status = 'active'
279                 AND updated_at < time::now() - type::duration($threshold)
280                 AND count(
281                     SELECT * FROM relates_to
282                     WHERE (in = $parent.id OR out = $parent.id)
283                       AND valid_from > time::now() - type::duration($threshold)
284                 ) = 0
285               ORDER BY updated_at ASC"#,
286        )
287        .bind(("threshold", format!("{staleness_days}d")))
288        .await?;
289
290    let stale_thoughts: Vec<EntityDetail> = super::deserialize_take(&mut stale_response, 0)?;
291
292    // Find stale questions — same connectivity-aware approach
293    let mut stale_q_response = db
294        .query(
295            r#"SELECT id, name, entity_type, abstract, overview, attributes, access_count, updated_at, source
296               FROM entity
297               WHERE attributes.pipeline_stage = 'curiosity'
298                 AND attributes.pipeline_status = 'active'
299                 AND attributes.sub_type IS NONE
300                 AND updated_at < time::now() - type::duration($threshold)
301                 AND count(
302                     SELECT * FROM relates_to
303                     WHERE (in = $parent.id OR out = $parent.id)
304                       AND valid_from > time::now() - type::duration($threshold)
305                 ) = 0
306               ORDER BY updated_at ASC"#,
307        )
308        .bind(("threshold", format!("{}d", staleness_days * 2)))
309        .await?;
310
311    let stale_questions: Vec<EntityDetail> = super::deserialize_take(&mut stale_q_response, 0)?;
312
313    // Orphan detection — active pipeline entities with zero graph connections
314    let mut orphan_response = db
315        .query(
316            r#"SELECT count() AS count FROM entity
317               WHERE attributes.pipeline_stage IS NOT NONE
318                 AND attributes.pipeline_status = 'active'
319                 AND count(SELECT * FROM relates_to WHERE in = $parent.id OR out = $parent.id) = 0
320               GROUP ALL"#,
321        )
322        .await?;
323
324    let orphan_rows: Vec<CountRow> = super::deserialize_take(&mut orphan_response, 0)?;
325    let orphan_count = orphan_rows.first().map(|r| r.count).unwrap_or(0);
326
327    // Last movement (most recent graduated/dissolved/explored entity)
328    let mut movement_response = db
329        .query(
330            r#"SELECT updated_at
331               FROM entity
332               WHERE attributes.pipeline_status IN ['graduated', 'dissolved', 'explored']
333               ORDER BY updated_at DESC
334               LIMIT 1"#,
335        )
336        .await?;
337
338    let movement_rows: Vec<UpdatedAtRow> = super::deserialize_take(&mut movement_response, 0)?;
339    let last_movement = movement_rows.first().map(|r| match &r.updated_at {
340        serde_json::Value::String(s) => s.clone(),
341        other => other.to_string(),
342    });
343
344    Ok(PipelineGraphStats {
345        by_stage,
346        stale_thoughts,
347        stale_questions,
348        orphan_count,
349        total_entities: total,
350        last_movement,
351    })
352}
353
354/// Trace the lineage of a pipeline entity through relationship chains.
355pub async fn pipeline_flow(
356    db: &Surreal<Db>,
357    entity_name: &str,
358) -> Result<Vec<(EntityDetail, String, EntityDetail)>, GraphError> {
359    // Get the entity
360    let entity = super::crud::get_entity_by_name(db, entity_name)
361        .await?
362        .ok_or_else(|| GraphError::NotFound(format!("entity: {entity_name}")))?;
363
364    let entity_id = entity.id_string();
365    let mut chain = Vec::new();
366
367    // Get all pipeline relationships (both directions)
368    let pipeline_rel_types = [
369        "EVOLVED_FROM",
370        "CRYSTALLIZED_FROM",
371        "INFORMED_BY",
372        "GRADUATED_TO",
373        "CONNECTED_TO",
374        "EXPLORES",
375        "ARCHIVED_FROM",
376    ];
377    let rel_types_str = pipeline_rel_types
378        .iter()
379        .map(|r| format!("'{r}'"))
380        .collect::<Vec<_>>()
381        .join(", ");
382
383    // Outgoing relationships
384    let query_out = format!(
385        r#"SELECT rel_type, out AS target_id
386           FROM relates_to
387           WHERE in = type::record($id) AND rel_type IN [{rel_types_str}] AND valid_until IS NONE"#
388    );
389    let mut response = db.query(&query_out).bind(("id", entity_id.clone())).await?;
390    let outgoing: Vec<RelTarget> = super::deserialize_take(&mut response, 0)?;
391
392    for edge in &outgoing {
393        let tid = match &edge.target_id {
394            serde_json::Value::String(s) => s.clone(),
395            other => other.to_string(),
396        };
397        if let Some(target) = super::crud::get_entity_detail(db, &tid).await? {
398            let source_detail = super::crud::get_entity_detail(db, &entity_id)
399                .await?
400                .unwrap();
401            chain.push((source_detail, edge.rel_type.clone(), target));
402        }
403    }
404
405    // Incoming relationships
406    let query_in = format!(
407        r#"SELECT rel_type, in AS target_id
408           FROM relates_to
409           WHERE out = type::record($id) AND rel_type IN [{rel_types_str}] AND valid_until IS NONE"#
410    );
411    let mut response = db.query(&query_in).bind(("id", entity_id.clone())).await?;
412    let incoming: Vec<RelTarget> = super::deserialize_take(&mut response, 0)?;
413
414    for edge in &incoming {
415        let tid = match &edge.target_id {
416            serde_json::Value::String(s) => s.clone(),
417            other => other.to_string(),
418        };
419        if let Some(source) = super::crud::get_entity_detail(db, &tid).await? {
420            let target_detail = super::crud::get_entity_detail(db, &entity_id)
421                .await?
422                .unwrap();
423            chain.push((source, edge.rel_type.clone(), target_detail));
424        }
425    }
426
427    Ok(chain)
428}
429
430fn lenient_string<'de, D>(deserializer: D) -> Result<String, D::Error>
431where
432    D: serde::Deserializer<'de>,
433{
434    use serde::de;
435    struct Visitor;
436    impl<'de> de::Visitor<'de> for Visitor {
437        type Value = String;
438        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
439            f.write_str("a string, integer, or null")
440        }
441        fn visit_str<E: de::Error>(self, v: &str) -> Result<String, E> {
442            Ok(v.to_string())
443        }
444        fn visit_string<E: de::Error>(self, v: String) -> Result<String, E> {
445            Ok(v)
446        }
447        fn visit_i64<E: de::Error>(self, v: i64) -> Result<String, E> {
448            Ok(v.to_string())
449        }
450        fn visit_u64<E: de::Error>(self, v: u64) -> Result<String, E> {
451            Ok(v.to_string())
452        }
453        fn visit_unit<E: de::Error>(self) -> Result<String, E> {
454            Ok("unknown".to_string())
455        }
456        fn visit_none<E: de::Error>(self) -> Result<String, E> {
457            Ok("unknown".to_string())
458        }
459        fn visit_bool<E: de::Error>(self, v: bool) -> Result<String, E> {
460            Ok(v.to_string())
461        }
462        fn visit_f64<E: de::Error>(self, v: f64) -> Result<String, E> {
463            Ok(v.to_string())
464        }
465    }
466    deserializer.deserialize_any(Visitor)
467}
468
469#[derive(serde::Deserialize)]
470struct StageStatusCount {
471    #[serde(deserialize_with = "lenient_string")]
472    stage: String,
473    #[serde(deserialize_with = "lenient_string")]
474    status: String,
475    count: u64,
476}
477
478#[derive(serde::Deserialize)]
479struct UpdatedAtRow {
480    updated_at: serde_json::Value,
481}
482
483#[derive(serde::Deserialize)]
484struct CountRow {
485    count: u64,
486}