Skip to main content

recall_echo/graph/
edge_view.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//! Relationships as a person reads them.
6//!
7//! A stored [`Relationship`] names its endpoints by record id and reports one
8//! number. Neither is what a human needs to decide whether the memory is
9//! right: they need the two entity names, and they need to know whether the
10//! number rests on thirty independent observations or on the agent having said
11//! the same thing thirty times.
12//!
13//! [`EdgeView`] is that shape, and it is shared by both human-facing surfaces —
14//! correction ([`super::correct`]) and inspection ([`super::inspect`]) — so the
15//! two never disagree about how an edge reads.
16
17use std::collections::HashMap;
18
19use serde::{Deserialize, Serialize};
20use surrealdb::Surreal;
21
22use super::error::GraphError;
23use super::store::Db;
24use super::types::Relationship;
25
26/// One relationship, with its endpoints resolved and its evidence exposed.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct EdgeView {
29    /// Record id, so a caller can act on exactly this edge.
30    pub id: String,
31    /// Name of the entity the edge points from.
32    pub from: String,
33    /// Name of the entity the edge points to.
34    pub to: String,
35    pub rel_type: String,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub description: Option<String>,
38    /// Posterior mean — what retrieval scores on.
39    pub confidence: f64,
40    /// Total evidence weight behind `confidence` (the Beta concentration).
41    /// The difference between "believed at 0.9" and "believed at 0.9 for good
42    /// reason".
43    pub evidence: f64,
44    /// Corroborations the agent produced itself, counted but never laundered
45    /// into `confidence`. Non-zero is the "I might be talking to myself"
46    /// signal, and it is exactly what a human should see.
47    pub self_reinforcements: i64,
48    /// A later relationship replaced this one.
49    pub superseded: bool,
50}
51
52impl EdgeView {
53    /// The edge in the notation every recall-echo surface writes it in.
54    #[must_use]
55    pub fn arrow(&self) -> String {
56        format!("{} —[{}]→ {}", self.from, self.rel_type, self.to)
57    }
58
59    /// The view of `relationship` with the two names already resolved.
60    #[must_use]
61    pub fn of(relationship: &Relationship, from: String, to: String) -> Self {
62        let evidence = relationship.evidence();
63        Self {
64            id: relationship.id_string(),
65            from,
66            to,
67            rel_type: relationship.rel_type.clone(),
68            description: relationship.description.clone(),
69            confidence: relationship.confidence,
70            evidence: evidence.concentration(),
71            self_reinforcements: relationship.self_reinforcements.unwrap_or(0).max(0),
72            superseded: relationship.valid_until.is_some(),
73        }
74    }
75}
76
77/// The record id of a stored link, as a string.
78#[must_use]
79pub fn record_id(value: &serde_json::Value) -> String {
80    match value {
81        serde_json::Value::String(id) => id.clone(),
82        other => other.to_string(),
83    }
84}
85
86/// Resolves record ids to entity names, once per id.
87///
88/// Rendering the edges of one entity asks for the same neighbour repeatedly;
89/// rendering a whole overview asks for the same hub over and over. One lookup
90/// each is enough.
91#[derive(Debug, Default)]
92pub struct NameCache {
93    names: HashMap<String, String>,
94}
95
96impl NameCache {
97    #[must_use]
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// The entity's name, or its record id when no entity is stored under it.
103    ///
104    /// A dangling edge is still worth showing — it is precisely the sort of
105    /// thing a person inspecting their memory should be able to see and delete.
106    pub async fn name_of(&mut self, db: &Surreal<Db>, id: &str) -> Result<String, GraphError> {
107        if let Some(name) = self.names.get(id) {
108            return Ok(name.clone());
109        }
110        let name = super::crud::get_entity_summary(db, id)
111            .await?
112            .map_or_else(|| id.to_string(), |summary| summary.name);
113        self.names.insert(id.to_string(), name.clone());
114        Ok(name)
115    }
116}
117
118/// Render `relationships` for a human, sharing one name cache.
119pub async fn views(
120    db: &Surreal<Db>,
121    cache: &mut NameCache,
122    relationships: &[Relationship],
123) -> Result<Vec<EdgeView>, GraphError> {
124    let mut views = Vec::with_capacity(relationships.len());
125    for relationship in relationships {
126        let from = cache.name_of(db, &record_id(&relationship.from_id)).await?;
127        let to = cache.name_of(db, &record_id(&relationship.to_id)).await?;
128        views.push(EdgeView::of(relationship, from, to));
129    }
130    Ok(views)
131}
132
133/// Every relationship of an entity that has not been superseded.
134pub async fn live_edges_of(
135    db: &Surreal<Db>,
136    entity_id: &str,
137) -> Result<Vec<Relationship>, GraphError> {
138    let mut response = db
139        .query(
140            r#"SELECT * FROM relates_to
141               WHERE (in = type::record($id) OR out = type::record($id))
142                 AND valid_until IS NONE
143               ORDER BY confidence DESC"#,
144        )
145        .bind(("id", entity_id.to_string()))
146        .await?;
147    super::deserialize_take(&mut response, 0)
148}
149
150/// Every relationship of an entity, superseded ones included.
151///
152/// What deleting the entity would actually take with it.
153pub async fn all_edges_of(
154    db: &Surreal<Db>,
155    entity_id: &str,
156) -> Result<Vec<Relationship>, GraphError> {
157    let mut response = db
158        .query(
159            r#"SELECT * FROM relates_to
160               WHERE in = type::record($id) OR out = type::record($id)
161               ORDER BY confidence DESC"#,
162        )
163        .bind(("id", entity_id.to_string()))
164        .await?;
165    super::deserialize_take(&mut response, 0)
166}
167
168/// Live relationships between two entities, in either direction.
169///
170/// Direction is not required to match what the user typed: someone correcting
171/// a memory says what they mean, not which way round the graph stored it.
172pub async fn live_edges_between(
173    db: &Surreal<Db>,
174    one: &str,
175    other: &str,
176) -> Result<Vec<Relationship>, GraphError> {
177    let mut response = db
178        .query(
179            r#"SELECT * FROM relates_to
180               WHERE valid_until IS NONE
181                 AND ((in = type::record($one) AND out = type::record($other))
182                   OR (in = type::record($other) AND out = type::record($one)))
183               ORDER BY confidence DESC"#,
184        )
185        .bind(("one", one.to_string()))
186        .bind(("other", other.to_string()))
187        .await?;
188    super::deserialize_take(&mut response, 0)
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use serde_json::json;
195
196    fn relationship(confidence: f64, self_reinforcements: Option<i64>) -> Relationship {
197        Relationship {
198            id: json!("relates_to:abc"),
199            from_id: json!("entity:rust"),
200            to_id: json!("entity:cargo"),
201            rel_type: "USES".into(),
202            description: Some("build tool".into()),
203            valid_from: json!("2026-01-01T00:00:00Z"),
204            valid_until: None,
205            confidence,
206            alpha: Some(18.0),
207            beta: Some(2.0),
208            self_reinforcements,
209            last_reinforced: None,
210            source: Some("archive-log-007".into()),
211        }
212    }
213
214    #[test]
215    fn a_view_carries_the_evidence_and_not_only_the_number() {
216        let view = EdgeView::of(&relationship(0.9, Some(4)), "Rust".into(), "Cargo".into());
217        assert_eq!(view.arrow(), "Rust —[USES]→ Cargo");
218        assert_eq!(view.confidence, 0.9);
219        assert_eq!(view.evidence, 20.0);
220        assert_eq!(view.self_reinforcements, 4);
221        assert!(!view.superseded);
222    }
223
224    #[test]
225    fn an_edge_predating_the_coherence_tally_reads_as_zero() {
226        let view = EdgeView::of(&relationship(0.9, None), "Rust".into(), "Cargo".into());
227        assert_eq!(view.self_reinforcements, 0);
228    }
229
230    #[test]
231    fn a_hand_edited_negative_tally_is_clamped() {
232        let view = EdgeView::of(&relationship(0.9, Some(-3)), "Rust".into(), "Cargo".into());
233        assert_eq!(view.self_reinforcements, 0);
234    }
235
236    #[test]
237    fn a_superseded_edge_says_so() {
238        let mut relationship = relationship(0.9, Some(0));
239        relationship.valid_until = Some(json!("2026-02-01T00:00:00Z"));
240        let view = EdgeView::of(&relationship, "Rust".into(), "Cargo".into());
241        assert!(view.superseded);
242    }
243
244    #[test]
245    fn record_ids_survive_both_stored_shapes() {
246        assert_eq!(record_id(&json!("entity:rust")), "entity:rust");
247        assert_eq!(record_id(&json!(7)), "7");
248    }
249}