1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct EdgeView {
29 pub id: String,
31 pub from: String,
33 pub to: String,
35 pub rel_type: String,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub description: Option<String>,
38 pub confidence: f64,
40 pub evidence: f64,
44 pub self_reinforcements: i64,
48 pub superseded: bool,
50}
51
52impl EdgeView {
53 #[must_use]
55 pub fn arrow(&self) -> String {
56 format!("{} —[{}]→ {}", self.from, self.rel_type, self.to)
57 }
58
59 #[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#[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#[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 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
118pub 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
133pub 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
150pub 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
168pub 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}