use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use surrealdb::Surreal;
use super::error::GraphError;
use super::store::Db;
use super::types::Relationship;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EdgeView {
pub id: String,
pub from: String,
pub to: String,
pub rel_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub confidence: f64,
pub evidence: f64,
pub self_reinforcements: i64,
pub superseded: bool,
}
impl EdgeView {
#[must_use]
pub fn arrow(&self) -> String {
format!("{} —[{}]→ {}", self.from, self.rel_type, self.to)
}
#[must_use]
pub fn of(relationship: &Relationship, from: String, to: String) -> Self {
let evidence = relationship.evidence();
Self {
id: relationship.id_string(),
from,
to,
rel_type: relationship.rel_type.clone(),
description: relationship.description.clone(),
confidence: relationship.confidence,
evidence: evidence.concentration(),
self_reinforcements: relationship.self_reinforcements.unwrap_or(0).max(0),
superseded: relationship.valid_until.is_some(),
}
}
}
#[must_use]
pub fn record_id(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(id) => id.clone(),
other => other.to_string(),
}
}
#[derive(Debug, Default)]
pub struct NameCache {
names: HashMap<String, String>,
}
impl NameCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub async fn name_of(&mut self, db: &Surreal<Db>, id: &str) -> Result<String, GraphError> {
if let Some(name) = self.names.get(id) {
return Ok(name.clone());
}
let name = super::crud::get_entity_summary(db, id)
.await?
.map_or_else(|| id.to_string(), |summary| summary.name);
self.names.insert(id.to_string(), name.clone());
Ok(name)
}
}
pub async fn views(
db: &Surreal<Db>,
cache: &mut NameCache,
relationships: &[Relationship],
) -> Result<Vec<EdgeView>, GraphError> {
let mut views = Vec::with_capacity(relationships.len());
for relationship in relationships {
let from = cache.name_of(db, &record_id(&relationship.from_id)).await?;
let to = cache.name_of(db, &record_id(&relationship.to_id)).await?;
views.push(EdgeView::of(relationship, from, to));
}
Ok(views)
}
pub async fn live_edges_of(
db: &Surreal<Db>,
entity_id: &str,
) -> Result<Vec<Relationship>, GraphError> {
let mut response = db
.query(
r#"SELECT * FROM relates_to
WHERE (in = type::record($id) OR out = type::record($id))
AND valid_until IS NONE
ORDER BY confidence DESC"#,
)
.bind(("id", entity_id.to_string()))
.await?;
super::deserialize_take(&mut response, 0)
}
pub async fn all_edges_of(
db: &Surreal<Db>,
entity_id: &str,
) -> Result<Vec<Relationship>, GraphError> {
let mut response = db
.query(
r#"SELECT * FROM relates_to
WHERE in = type::record($id) OR out = type::record($id)
ORDER BY confidence DESC"#,
)
.bind(("id", entity_id.to_string()))
.await?;
super::deserialize_take(&mut response, 0)
}
pub async fn live_edges_between(
db: &Surreal<Db>,
one: &str,
other: &str,
) -> Result<Vec<Relationship>, GraphError> {
let mut response = db
.query(
r#"SELECT * FROM relates_to
WHERE valid_until IS NONE
AND ((in = type::record($one) AND out = type::record($other))
OR (in = type::record($other) AND out = type::record($one)))
ORDER BY confidence DESC"#,
)
.bind(("one", one.to_string()))
.bind(("other", other.to_string()))
.await?;
super::deserialize_take(&mut response, 0)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn relationship(confidence: f64, self_reinforcements: Option<i64>) -> Relationship {
Relationship {
id: json!("relates_to:abc"),
from_id: json!("entity:rust"),
to_id: json!("entity:cargo"),
rel_type: "USES".into(),
description: Some("build tool".into()),
valid_from: json!("2026-01-01T00:00:00Z"),
valid_until: None,
confidence,
alpha: Some(18.0),
beta: Some(2.0),
self_reinforcements,
last_reinforced: None,
source: Some("archive-log-007".into()),
}
}
#[test]
fn a_view_carries_the_evidence_and_not_only_the_number() {
let view = EdgeView::of(&relationship(0.9, Some(4)), "Rust".into(), "Cargo".into());
assert_eq!(view.arrow(), "Rust —[USES]→ Cargo");
assert_eq!(view.confidence, 0.9);
assert_eq!(view.evidence, 20.0);
assert_eq!(view.self_reinforcements, 4);
assert!(!view.superseded);
}
#[test]
fn an_edge_predating_the_coherence_tally_reads_as_zero() {
let view = EdgeView::of(&relationship(0.9, None), "Rust".into(), "Cargo".into());
assert_eq!(view.self_reinforcements, 0);
}
#[test]
fn a_hand_edited_negative_tally_is_clamped() {
let view = EdgeView::of(&relationship(0.9, Some(-3)), "Rust".into(), "Cargo".into());
assert_eq!(view.self_reinforcements, 0);
}
#[test]
fn a_superseded_edge_says_so() {
let mut relationship = relationship(0.9, Some(0));
relationship.valid_until = Some(json!("2026-02-01T00:00:00Z"));
let view = EdgeView::of(&relationship, "Rust".into(), "Cargo".into());
assert!(view.superseded);
}
#[test]
fn record_ids_survive_both_stored_shapes() {
assert_eq!(record_id(&json!("entity:rust")), "entity:rust");
assert_eq!(record_id(&json!(7)), "7");
}
}