Skip to main content

recall_echo/graph/
inspect.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//! Inspection — what memory actually holds, for a person rather than a program.
6//!
7//! Nobody trusts a memory they cannot read. `graph status` counts rows and
8//! `graph search` answers questions, but neither answers the question a human
9//! actually has after a week of automatic ingestion: *what do you think you
10//! know about me, and how sure are you?*
11//!
12//! Two shapes answer it.
13//!
14//! [`MemoryOverview`] is the unprompted one: the strongest things the graph
15//! holds, grouped by entity type, next to an honest account of how well its
16//! relationships are believed — including the coherence tally, which is the
17//! one number that says "some of this confidence is me agreeing with myself".
18//!
19//! [`TopicReport`] is the prompted one. It runs the ordinary hybrid query — no
20//! second retrieval path, no second set of scoring weights — and then attaches
21//! the evidence behind each hit, which retrieval itself has no reason to carry.
22
23use serde::{Deserialize, Serialize};
24use surrealdb::Surreal;
25
26use super::edge_view::{self, EdgeView, NameCache};
27use super::embed::Embedder;
28use super::error::GraphError;
29use super::store::Db;
30use super::types::{EntityDetail, GraphStats, MatchSource, QueryOptions};
31use crate::config::GraphScoringConfig;
32
33/// At or above this posterior mean, a relationship is held firmly.
34pub const STRONG_CONFIDENCE: f64 = 0.8;
35/// Below this posterior mean, a relationship is barely believed at all.
36pub const DOUBTFUL_CONFIDENCE: f64 = 0.5;
37
38/// Edges listed under "least certain" and "believed partly by repetition".
39const HIGHLIGHT_EDGES: usize = 5;
40/// Relationships shown per entity in a topic report, before they are summarised
41/// as a count.
42const MAX_EDGES_PER_ENTITY: usize = 8;
43/// Graph expansion for a topic report — one hop, as the `graph query` CLI uses.
44const TOPIC_GRAPH_DEPTH: u32 = 1;
45
46/// What the graph holds, without being asked about anything in particular.
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub struct MemoryOverview {
49    /// Row counts, including the per-type breakdown.
50    pub stats: GraphStats,
51    /// The strongest entities of each type, most populous type first.
52    pub groups: Vec<TypeGroup>,
53    /// How firmly the live relationships are held, in aggregate.
54    pub confidence: ConfidenceSummary,
55    /// The least-believed live relationships — where memory is unsure.
56    pub uncertain: Vec<EdgeView>,
57    /// The relationships carrying the most self-authored corroboration —
58    /// where memory may be agreeing with itself.
59    pub self_reinforced: Vec<EdgeView>,
60}
61
62/// One entity type, and the strongest entities in it.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct TypeGroup {
65    pub entity_type: String,
66    /// How many entities of this type exist, not how many are listed.
67    pub count: u64,
68    pub top: Vec<KnownEntity>,
69}
70
71/// One entity as an overview lists it.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct KnownEntity {
74    pub id: String,
75    pub name: String,
76    pub entity_type: String,
77    #[serde(rename = "abstract")]
78    pub abstract_text: String,
79    #[serde(default)]
80    pub access_count: i64,
81    #[serde(default)]
82    pub utility_score: f64,
83}
84
85/// How well the live relationships are believed.
86#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
87pub struct ConfidenceSummary {
88    /// At or above [`STRONG_CONFIDENCE`].
89    pub strong: u64,
90    /// Between [`DOUBTFUL_CONFIDENCE`] and [`STRONG_CONFIDENCE`].
91    pub uncertain: u64,
92    /// Below [`DOUBTFUL_CONFIDENCE`].
93    pub doubtful: u64,
94}
95
96impl ConfidenceSummary {
97    /// Live relationships counted.
98    #[must_use]
99    pub fn total(&self) -> u64 {
100        self.strong + self.uncertain + self.doubtful
101    }
102
103    /// Tally one relationship's posterior mean.
104    fn record(&mut self, confidence: f64) {
105        if confidence >= STRONG_CONFIDENCE {
106            self.strong += 1;
107        } else if confidence >= DOUBTFUL_CONFIDENCE {
108            self.uncertain += 1;
109        } else {
110            self.doubtful += 1;
111        }
112    }
113}
114
115/// What the graph holds about one subject.
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct TopicReport {
118    pub topic: String,
119    pub entities: Vec<TopicEntity>,
120}
121
122/// One retrieved entity, with the claims it takes part in.
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct TopicEntity {
125    pub entity: EntityDetail,
126    /// Retrieval score, unchanged from the hybrid query that produced it.
127    pub score: f64,
128    /// Whether it was matched directly or reached over a relationship.
129    pub source: MatchSource,
130    /// Its live relationships, strongest first.
131    pub edges: Vec<EdgeView>,
132    /// Relationships beyond the ones listed.
133    #[serde(default)]
134    pub edges_omitted: usize,
135}
136
137/// Summarise the whole graph, listing `per_type` entities of each type.
138pub async fn overview(
139    db: &Surreal<Db>,
140    stats: GraphStats,
141    per_type: usize,
142) -> Result<MemoryOverview, GraphError> {
143    let mut groups = Vec::with_capacity(stats.entity_type_counts.len());
144    for (entity_type, count) in &stats.entity_type_counts {
145        groups.push(TypeGroup {
146            entity_type: entity_type.clone(),
147            count: *count,
148            top: strongest_of_type(db, entity_type, per_type).await?,
149        });
150    }
151    groups.sort_by(|left, right| {
152        right
153            .count
154            .cmp(&left.count)
155            .then_with(|| left.entity_type.cmp(&right.entity_type))
156    });
157
158    let mut cache = NameCache::new();
159    let uncertain = edge_view::views(db, &mut cache, &least_certain(db).await?).await?;
160    let self_reinforced =
161        edge_view::views(db, &mut cache, &most_self_reinforced(db).await?).await?;
162
163    Ok(MemoryOverview {
164        stats,
165        groups,
166        confidence: confidence_summary(db).await?,
167        uncertain,
168        self_reinforced,
169    })
170}
171
172/// Answer "what do you know about X" through the ordinary hybrid query, then
173/// attach the evidence behind each hit.
174pub async fn about(
175    db: &Surreal<Db>,
176    embedder: &dyn Embedder,
177    scoring: &GraphScoringConfig,
178    topic: &str,
179    limit: usize,
180) -> Result<TopicReport, GraphError> {
181    let options = QueryOptions {
182        limit,
183        entity_type: None,
184        keyword: None,
185        graph_depth: TOPIC_GRAPH_DEPTH,
186        include_episodes: false,
187    };
188    let result = super::query::query(db, embedder, scoring, topic, &options).await?;
189
190    let mut cache = NameCache::new();
191    let mut entities = Vec::with_capacity(result.entities.len());
192    for scored in result.entities {
193        let all = edge_view::live_edges_of(db, &scored.entity.id_string()).await?;
194        let edges_omitted = all.len().saturating_sub(MAX_EDGES_PER_ENTITY);
195        let shown = &all[..all.len().min(MAX_EDGES_PER_ENTITY)];
196        entities.push(TopicEntity {
197            entity: scored.entity,
198            score: scored.score,
199            source: scored.source,
200            edges: edge_view::views(db, &mut cache, shown).await?,
201            edges_omitted,
202        });
203    }
204
205    Ok(TopicReport {
206        topic: topic.to_string(),
207        entities,
208    })
209}
210
211// ── Queries ──────────────────────────────────────────────────────────────
212
213/// The entities of one type most worth showing first: the ones feedback has
214/// found useful, then the ones retrieval keeps returning, then the freshest.
215///
216/// This is a display order, not a retrieval score — nothing here feeds ranking.
217async fn strongest_of_type(
218    db: &Surreal<Db>,
219    entity_type: &str,
220    limit: usize,
221) -> Result<Vec<KnownEntity>, GraphError> {
222    #[derive(serde::Deserialize)]
223    struct Row {
224        id: serde_json::Value,
225        name: String,
226        entity_type: String,
227        #[serde(rename = "abstract")]
228        abstract_text: String,
229        #[serde(default, deserialize_with = "super::util::count_or_zero")]
230        access_count: i64,
231        #[serde(default)]
232        utility_score: Option<f64>,
233    }
234
235    // `updated_at` is projected because it is ordered on: SurrealDB requires
236    // every ORDER BY idiom to appear in the selection.
237    let query = format!(
238        r#"SELECT id, name, entity_type, abstract, access_count, utility_score, updated_at
239           FROM entity WHERE entity_type = $entity_type
240           ORDER BY utility_score DESC, access_count DESC, updated_at DESC
241           LIMIT {}"#,
242        limit.clamp(1, 100)
243    );
244    let mut response = db
245        .query(&query)
246        .bind(("entity_type", entity_type.to_string()))
247        .await?;
248
249    let rows: Vec<Row> = super::deserialize_take(&mut response, 0)?;
250    Ok(rows
251        .into_iter()
252        .map(|row| KnownEntity {
253            id: edge_view::record_id(&row.id),
254            name: row.name,
255            entity_type: row.entity_type,
256            abstract_text: row.abstract_text,
257            access_count: row.access_count,
258            utility_score: row.utility_score.unwrap_or(0.5),
259        })
260        .collect())
261}
262
263/// Band every live relationship by how firmly it is believed.
264async fn confidence_summary(db: &Surreal<Db>) -> Result<ConfidenceSummary, GraphError> {
265    let mut response = db
266        .query("SELECT VALUE confidence FROM relates_to WHERE valid_until IS NONE")
267        .await?;
268    let confidences: Vec<f64> = super::deserialize_take(&mut response, 0)?;
269
270    let mut summary = ConfidenceSummary::default();
271    for confidence in confidences {
272        summary.record(confidence);
273    }
274    Ok(summary)
275}
276
277/// The live relationships the graph believes least.
278async fn least_certain(db: &Surreal<Db>) -> Result<Vec<super::types::Relationship>, GraphError> {
279    let query = format!(
280        r#"SELECT * FROM relates_to
281           WHERE valid_until IS NONE AND confidence < {STRONG_CONFIDENCE}
282           ORDER BY confidence ASC
283           LIMIT {HIGHLIGHT_EDGES}"#
284    );
285    let mut response = db.query(&query).await?;
286    super::deserialize_take(&mut response, 0)
287}
288
289/// The relationships carrying the most self-authored corroboration.
290async fn most_self_reinforced(
291    db: &Surreal<Db>,
292) -> Result<Vec<super::types::Relationship>, GraphError> {
293    let query = format!(
294        r#"SELECT * FROM relates_to
295           WHERE self_reinforcements IS NOT NONE AND self_reinforcements > 0
296           ORDER BY self_reinforcements DESC
297           LIMIT {HIGHLIGHT_EDGES}"#
298    );
299    let mut response = db.query(&query).await?;
300    super::deserialize_take(&mut response, 0)
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn confidence_bands_split_at_the_documented_thresholds() {
309        let mut summary = ConfidenceSummary::default();
310        for confidence in [1.0, STRONG_CONFIDENCE, 0.79, DOUBTFUL_CONFIDENCE, 0.49, 0.0] {
311            summary.record(confidence);
312        }
313        assert_eq!(summary.strong, 2);
314        assert_eq!(summary.uncertain, 2);
315        assert_eq!(summary.doubtful, 2);
316        assert_eq!(summary.total(), 6);
317    }
318
319    #[test]
320    fn an_empty_graph_has_nothing_to_be_sure_of() {
321        let summary = ConfidenceSummary::default();
322        assert_eq!(summary.total(), 0);
323    }
324}