Skip to main content

recall_echo/graph/
query.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Hybrid query — combines semantic search, graph expansion, and episode search.
6//!
7//! Pipeline:
8//! 1. **Semantic phase**: HNSW KNN with `limit * 2` to gather candidates
9//! 2. **Graph phase**: 1-hop expansion from the top 3 candidates
10//! 3. **Merge** by entity ID — corroborating an existing candidate or adding a
11//!    new one
12//! 4. **Episode search** (optional) — separate KNN on episodes
13//!
14//! # Scoring
15//!
16//! Both channels score through [`super::search::score_with_utility`]:
17//!
18//! ```text
19//! score = w_semantic * similarity + w_hotness * hotness + w_utility * utility
20//! ```
21//!
22//! The channel decides only where `similarity` comes from. A semantic
23//! candidate measures it against the query vector; a graph candidate
24//! *propagates* it — the parent's similarity discounted by the edge's
25//! effective (decayed) confidence:
26//!
27//! ```text
28//! similarity_graph = similarity_parent * effective_confidence
29//! ```
30//!
31//! Hotness and utility are read off the neighbor itself, exactly as they are
32//! for a semantic hit. Confidence therefore still orders neighbors — a decayed
33//! edge ranks below a fresh one — without a graph candidate having to overcome
34//! a base every semantic candidate gets for free.
35//!
36//! When an entity arrives on **both** channels the graph corroborates the
37//! query, and its measured relevance is raised — bounded, and proportional to
38//! how much the edge is believed:
39//!
40//! ```text
41//! similarity = min(1.0, similarity_semantic
42//!                       * (1 + corroboration_boost * effective_confidence))
43//! ```
44//!
45//! The measurement stays the base: a direct reading of this entity against
46//! this query outranks an estimate propagated from a neighbor, so
47//! corroboration adds to it rather than replacing it. Two clamps keep it from
48//! running away — the similarity ceiling of `1.0`, and a boost default cut to
49//! the width of the similarity band it perturbs (see
50//! [`GraphScoringConfig::corroboration_boost`]). An entity reachable from
51//! several expanded parents is credited once, over its strongest path: the
52//! parents are the top hits of a single query and are not independent
53//! witnesses. Self-edges corroborate nothing and are skipped.
54
55use 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
67/// Run a hybrid query: semantic search + graph expansion + optional episode search.
68pub 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    // Phase 1: Semantic search with 2x limit to get candidates
82    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    // Collect into dedup map (id -> ScoredEntity)
92    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    // Phase 2: Graph expansion — 1-hop from the top semantic results
98    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    // Sort by score descending, truncate to limit
105    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    // The semantic phase already counted its own results. Counting only that
114    // channel is what keeps the graph tail permanently cold: an entity that is
115    // only ever reached over an edge can never accumulate the accesses that
116    // feed hotness, so it can never rise. Entities corroborated by the graph
117    // kept `MatchSource::Semantic` and are deliberately not counted twice.
118    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    // Phase 3: Episode search (optional)
126    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
135/// How many semantic hits expansion runs from.
136const EXPANSION_PARENTS: usize = 3;
137
138/// A semantic hit that expansion runs from.
139///
140/// Snapshotted before any merging, so a parent that is itself corroborated
141/// mid-merge cannot change what its own neighbors inherit.
142struct ExpansionParent {
143    id: String,
144    name: String,
145    similarity: f64,
146}
147
148/// A neighbor reached by expansion, over the strongest path that reached it.
149struct GraphCandidate {
150    entity: EntityDetail,
151    parent: String,
152    rel_type: String,
153    effective_confidence: f64,
154    /// The parent's similarity, discounted by `effective_confidence`.
155    similarity: f64,
156}
157
158/// The top semantic hits, best score first.
159///
160/// Ties break on entity id: candidates arrive from a `HashMap`, and which of
161/// two equally scored hits gets expanded must not depend on hash order.
162fn 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
181/// Walk one hop out of every parent, keeping each neighbor's strongest path.
182async 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; // A self-edge is not a second witness.
201            }
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
227/// Fold the expanded neighborhood into the semantic candidates: corroborate
228/// what is already there, add what is not.
229fn 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
266/// Raise a measured relevance that the graph agrees with, bounded by the
267/// similarity ceiling so corroboration can never outrank a perfect match.
268fn 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
276/// Score an entity from a relevance term plus its own hotness and utility.
277fn 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
287/// Get 1-hop neighbors as L1 (EntityDetail) with the relationship type and effective confidence.
288async 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    // Outgoing
295    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    // Incoming
309    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        // Apply temporal decay at read time
327        let effective = confidence::effective_confidence(
328            edge.confidence,
329            edge.last_reinforced.as_ref(),
330            &edge.valid_from,
331            &now,
332        );
333
334        // Filter by effective confidence
335        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
368// ── Pipeline queries ─────────────────────────────────────────────────
369
370/// Get all pipeline entities for a given stage, optionally filtered by status.
371pub 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
408/// Get pipeline stats: counts by (stage, status), stale entities.
409pub async fn pipeline_stats(
410    db: &Surreal<Db>,
411    staleness_days: u32,
412) -> Result<PipelineGraphStats, GraphError> {
413    // Count by stage and status
414    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    // Find stale thoughts — connectivity-aware: active thoughts with no relationships
441    // updated within staleness_days AND entity itself not updated within staleness_days.
442    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    // Find stale questions — same connectivity-aware approach
462    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    // Orphan detection — active pipeline entities with zero graph connections
483    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    // Last movement (most recent graduated/dissolved/explored entity)
497    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
523/// Trace the lineage of a pipeline entity through relationship chains.
524pub async fn pipeline_flow(
525    db: &Surreal<Db>,
526    entity_name: &str,
527) -> Result<Vec<(EntityDetail, String, EntityDetail)>, GraphError> {
528    // Get the entity
529    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    // Get all pipeline relationships (both directions)
537    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    // Outgoing relationships
553    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    // Incoming relationships
575    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}