use fnx_classes::{AttrMap, Graph};
use fnx_runtime::CgseValue;
use serde::Serialize;
use crate::db::{StoredMemory, StoredMemoryLink};
pub const STRUCTURAL_GRAPH_EVIDENCE_SCHEMA: &str = "ee.insights.structural_graph.v1";
pub const STRUCTURAL_GRAPH_TYPE: &str = "memory_links";
pub const STRUCTURAL_GRAPH_FILTER_POSTURE: &str =
"workspace_scoped_tombstone_filtered_canonical_undirected";
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StructuralGraphEdge {
pub a: String,
pub b: String,
pub weight: f64,
pub confidence: f64,
pub relation: String,
pub evidence_count: i64,
}
pub struct StructuralGraphInput {
pub graph: Graph,
pub node_count: usize,
pub edge_count: usize,
pub edges: Vec<StructuralGraphEdge>,
pub snapshot_version: Option<u64>,
pub graph_type: &'static str,
pub filter_posture: &'static str,
pub evidence_schema: &'static str,
}
impl StructuralGraphInput {
#[must_use]
pub fn is_empty(&self) -> bool {
self.node_count == 0 || self.edge_count == 0
}
}
#[must_use]
pub fn build_structural_graph_input(
memories: &[StoredMemory],
links: &[StoredMemoryLink],
snapshot_version: Option<u64>,
) -> StructuralGraphInput {
let mut graph = Graph::strict();
let mut node_ids: Vec<&str> = memories.iter().map(|memory| memory.id.as_str()).collect();
node_ids.sort_unstable();
node_ids.dedup();
for id in &node_ids {
graph.add_node((*id).to_owned());
}
let mut ordered: Vec<&StoredMemoryLink> = links.iter().collect();
ordered.sort_by(|left, right| {
canonical_pair(left)
.cmp(&canonical_pair(right))
.then_with(|| left.id.cmp(&right.id))
});
let mut edges: Vec<StructuralGraphEdge> = Vec::new();
for link in ordered {
let (a, b) = canonical_pair(link);
if a == b {
continue;
}
if edges.last().is_some_and(|edge| edge.a == a && edge.b == b) {
continue;
}
let mut attrs = AttrMap::new();
attrs.insert(
"weight".to_owned(),
CgseValue::Float(f64::from(link.weight)),
);
attrs.insert(
"confidence".to_owned(),
CgseValue::Float(f64::from(link.confidence)),
);
attrs.insert(
"relation".to_owned(),
CgseValue::String(link.relation.clone()),
);
attrs.insert("source".to_owned(), CgseValue::String(link.source.clone()));
attrs.insert(
"evidence_count".to_owned(),
CgseValue::Int(i64::from(link.evidence_count)),
);
if graph.add_edge_with_attrs(a, b, attrs).is_err() {
continue;
}
edges.push(StructuralGraphEdge {
a: a.to_owned(),
b: b.to_owned(),
weight: f64::from(link.weight),
confidence: f64::from(link.confidence),
relation: link.relation.clone(),
evidence_count: i64::from(link.evidence_count),
});
}
StructuralGraphInput {
node_count: node_ids.len(),
edge_count: edges.len(),
edges,
graph,
snapshot_version,
graph_type: STRUCTURAL_GRAPH_TYPE,
filter_posture: STRUCTURAL_GRAPH_FILTER_POSTURE,
evidence_schema: STRUCTURAL_GRAPH_EVIDENCE_SCHEMA,
}
}
fn canonical_pair(link: &StoredMemoryLink) -> (&str, &str) {
let src = link.src_memory_id.as_str();
let dst = link.dst_memory_id.as_str();
if src <= dst { (src, dst) } else { (dst, src) }
}
#[cfg(test)]
mod tests {
use super::*;
fn memory(id: &str) -> StoredMemory {
StoredMemory {
id: id.to_owned(),
workspace_id: "wsp_structural00000000000001".to_owned(),
level: "semantic".to_owned(),
kind: "fact".to_owned(),
content: "structural fixture".to_owned(),
workflow_id: None,
confidence: 0.5,
utility: 0.5,
importance: 0.5,
provenance_uri: None,
trust_class: "human_explicit".to_owned(),
trust_subclass: None,
provenance_chain_hash: None,
provenance_chain_hash_version: "ee.memory.provenance_chain.v1".to_owned(),
provenance_verification_status: "verified".to_owned(),
provenance_verified_at: None,
provenance_verification_note: None,
created_at: "2026-01-01T00:00:00Z".to_owned(),
updated_at: "2026-01-01T00:00:00Z".to_owned(),
tombstoned_at: None,
valid_from: None,
valid_to: None,
}
}
fn link(id: &str, src: &str, dst: &str) -> StoredMemoryLink {
StoredMemoryLink {
id: id.to_owned(),
src_memory_id: src.to_owned(),
dst_memory_id: dst.to_owned(),
relation: "supports".to_owned(),
weight: 0.8,
confidence: 0.7,
directed: false,
evidence_count: 2,
last_reinforced_at: None,
source: "auto".to_owned(),
created_at: "2026-01-01T00:00:00Z".to_owned(),
created_by: None,
metadata_json: None,
}
}
#[test]
fn empty_workspace_produces_empty_deterministic_input() {
let input = build_structural_graph_input(&[], &[], None);
assert_eq!(input.node_count, 0);
assert_eq!(input.edge_count, 0);
assert!(input.edges.is_empty());
assert!(input.is_empty());
assert_eq!(input.graph_type, STRUCTURAL_GRAPH_TYPE);
assert_eq!(input.filter_posture, STRUCTURAL_GRAPH_FILTER_POSTURE);
assert_eq!(input.evidence_schema, STRUCTURAL_GRAPH_EVIDENCE_SCHEMA);
assert_eq!(input.snapshot_version, None);
}
#[test]
fn triangle_projection_is_stable_and_truss_compatible() {
let memories = [memory("mem_a"), memory("mem_c"), memory("mem_b")];
let links = [
link("lnk_3", "mem_c", "mem_a"),
link("lnk_1", "mem_a", "mem_b"),
link("lnk_2", "mem_b", "mem_c"),
];
let input = build_structural_graph_input(&memories, &links, Some(7));
assert_eq!(input.node_count, 3);
assert_eq!(input.edge_count, 3);
assert_eq!(input.snapshot_version, Some(7));
let pairs: Vec<(&str, &str)> = input
.edges
.iter()
.map(|edge| (edge.a.as_str(), edge.b.as_str()))
.collect();
assert_eq!(
pairs,
vec![("mem_a", "mem_b"), ("mem_a", "mem_c"), ("mem_b", "mem_c")],
"edges must emit in canonical deterministic order"
);
assert!(!input.is_empty());
let truss = crate::graph::health::compute_k_truss(&input.graph);
assert_eq!(truss.max_k, 3, "a triangle is exactly a 3-truss");
}
#[test]
fn duplicate_and_reversed_links_canonicalize_stably() {
let memories = [memory("mem_a"), memory("mem_b")];
let forward = [
link("lnk_1", "mem_a", "mem_b"),
link("lnk_2", "mem_b", "mem_a"),
link("lnk_3", "mem_a", "mem_b"),
link("lnk_4", "mem_a", "mem_a"),
];
let reversed: Vec<StoredMemoryLink> = forward.iter().rev().cloned().collect();
let one = build_structural_graph_input(&memories, &forward, None);
let two = build_structural_graph_input(&memories, &reversed, None);
assert_eq!(
one.edge_count, 1,
"dup/reverse/self links collapse to one edge"
);
assert_eq!(
one.edges, two.edges,
"projection is input-order independent"
);
assert_eq!(
(one.edges[0].a.as_str(), one.edges[0].b.as_str()),
("mem_a", "mem_b")
);
}
}