use ahash::AHashMap;
use crate::{
error::Error,
schema::{EdgeRecord, LabelId, NodeId, NodeRecord, TypeId},
storage::{
ids::{get_label, get_type},
lmdb::Storage,
props,
},
};
use super::Graph;
pub(crate) struct EdgeFanout {
generation: u64,
out_by_src_label: AHashMap<(LabelId, TypeId), u64>,
in_by_dst_label: AHashMap<(LabelId, TypeId), u64>,
triples: AHashMap<(LabelId, TypeId, LabelId), u64>,
}
impl EdgeFanout {
fn build(storage: &Storage, generation: u64) -> Result<Self, Error> {
let rtxn = storage.env.read_txn()?;
let mut node_labels: AHashMap<NodeId, Vec<LabelId>> = AHashMap::new();
for result in storage.nodes.iter(&rtxn)? {
let (id, bytes) = result?;
let rec: NodeRecord = props::decode(bytes)?;
if !rec.labels.is_empty() {
node_labels.insert(id, rec.labels);
}
}
let mut out_by_src_label: AHashMap<(LabelId, TypeId), u64> = AHashMap::new();
let mut in_by_dst_label: AHashMap<(LabelId, TypeId), u64> = AHashMap::new();
let mut triples: AHashMap<(LabelId, TypeId, LabelId), u64> = AHashMap::new();
for result in storage.edges.iter(&rtxn)? {
let (_edge_id, bytes) = result?;
let rec: EdgeRecord = props::decode(bytes)?;
let src_labels = node_labels.get(&rec.src);
let dst_labels = node_labels.get(&rec.dst);
if let Some(labels) = src_labels {
for &label in labels {
*out_by_src_label.entry((label, rec.edge_type)).or_insert(0) += 1;
}
}
if let Some(labels) = dst_labels {
for &label in labels {
*in_by_dst_label.entry((label, rec.edge_type)).or_insert(0) += 1;
}
}
if let (Some(srcs), Some(dsts)) = (src_labels, dst_labels) {
for &s in srcs {
for &d in dsts {
*triples.entry((s, rec.edge_type, d)).or_insert(0) += 1;
}
}
}
}
Ok(Self {
generation,
out_by_src_label,
in_by_dst_label,
triples,
})
}
}
impl Graph {
fn resolve_label_type(
&self,
label: &str,
rel_type: &str,
) -> Result<Option<(LabelId, TypeId)>, Error> {
let rtxn = self.storage.env.read_txn()?;
let label_id = match get_label(&self.storage, &rtxn, label)? {
Some(id) => id,
None => return Ok(None),
};
let type_id = match get_type(&self.storage, &rtxn, rel_type)? {
Some(id) => id,
None => return Ok(None),
};
Ok(Some((label_id, type_id)))
}
fn with_fanout<T>(&self, f: impl FnOnce(&EdgeFanout) -> T) -> Result<T, Error> {
let generation = self.csr_cache.current_gen();
let mut guard = self.edge_fanout.lock();
let fresh = guard.as_ref().is_some_and(|t| t.generation == generation);
if fresh {
if let Some(table) = guard.as_ref() {
return Ok(f(table));
}
}
let table = EdgeFanout::build(&self.storage, generation)?;
let result = f(&table);
*guard = Some(table);
Ok(result)
}
pub fn estimate_expand_fanout(
&self,
src_label: &str,
rel_type: &str,
incoming: bool,
) -> Result<Option<f64>, Error> {
let (label_id, type_id) = match self.resolve_label_type(src_label, rel_type)? {
Some(ids) => ids,
None => return Ok(None),
};
let node_count = self.node_count_by_label(src_label)?;
if node_count == 0 {
return Ok(None);
}
self.with_fanout(|table| {
let map = if incoming {
&table.in_by_dst_label
} else {
&table.out_by_src_label
};
match map.get(&(label_id, type_id)).copied() {
Some(edges) if edges > 0 => Some(edges as f64 / node_count as f64),
_ => None,
}
})
}
pub fn estimate_expand_fanout_to(
&self,
src_label: &str,
rel_type: &str,
dst_label: &str,
incoming: bool,
) -> Result<Option<f64>, Error> {
let (src_id, type_id) = match self.resolve_label_type(src_label, rel_type)? {
Some(ids) => ids,
None => return Ok(None),
};
let dst_id = {
let rtxn = self.storage.env.read_txn()?;
match get_label(&self.storage, &rtxn, dst_label)? {
Some(id) => id,
None => return Ok(None),
}
};
let node_count = self.node_count_by_label(src_label)?;
if node_count == 0 {
return Ok(None);
}
let key = if incoming {
(dst_id, type_id, src_id)
} else {
(src_id, type_id, dst_id)
};
self.with_fanout(|table| match table.triples.get(&key).copied() {
Some(edges) if edges > 0 => Some(edges as f64 / node_count as f64),
_ => None,
})
}
pub fn schema_has_edge(
&self,
src_label: &str,
rel_type: &str,
dst_label: &str,
) -> Result<Option<bool>, Error> {
let (src_id, type_id) = match self.resolve_label_type(src_label, rel_type)? {
Some(ids) => ids,
None => return Ok(None),
};
let dst_id = {
let rtxn = self.storage.env.read_txn()?;
match get_label(&self.storage, &rtxn, dst_label)? {
Some(id) => id,
None => return Ok(None),
}
};
self.with_fanout(|table| Some(table.triples.contains_key(&(src_id, type_id, dst_id))))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
fn open_graph() -> (TempDir, Graph) {
let dir = TempDir::new().unwrap();
let graph = Graph::open(dir.path(), 1).unwrap();
(dir, graph)
}
#[test]
fn expand_fanout_is_per_source_label() {
let (_dir, graph) = open_graph();
let p0 = graph.add_node("Person", &json!({})).unwrap();
let p1 = graph.add_node("Person", &json!({})).unwrap();
let p2 = graph.add_node("Person", &json!({})).unwrap();
let c0 = graph.add_node("City", &json!({})).unwrap();
graph.add_edge(p0, p1, "KNOWS", &json!({})).unwrap();
graph.add_edge(p0, p2, "KNOWS", &json!({})).unwrap();
graph.add_edge(p1, c0, "VISITED", &json!({})).unwrap();
let knows = graph
.estimate_expand_fanout("Person", "KNOWS", false)
.unwrap();
assert_eq!(knows, Some(2.0 / 3.0));
let visited = graph
.estimate_expand_fanout("Person", "VISITED", false)
.unwrap();
assert_eq!(visited, Some(1.0 / 3.0));
let visited_in = graph
.estimate_expand_fanout("City", "VISITED", true)
.unwrap();
assert_eq!(visited_in, Some(1.0));
let city_knows = graph
.estimate_expand_fanout("City", "KNOWS", false)
.unwrap();
assert_eq!(city_knows, None);
assert_eq!(
graph
.estimate_expand_fanout("Ghost", "KNOWS", false)
.unwrap(),
None
);
assert_eq!(
graph
.estimate_expand_fanout("Person", "GHOST", false)
.unwrap(),
None
);
}
#[test]
fn expand_fanout_refreshes_after_writes() {
let (_dir, graph) = open_graph();
let p0 = graph.add_node("Person", &json!({})).unwrap();
let p1 = graph.add_node("Person", &json!({})).unwrap();
graph.add_edge(p0, p1, "KNOWS", &json!({})).unwrap();
assert_eq!(
graph
.estimate_expand_fanout("Person", "KNOWS", false)
.unwrap(),
Some(0.5)
);
graph.add_edge(p1, p0, "KNOWS", &json!({})).unwrap();
assert_eq!(
graph
.estimate_expand_fanout("Person", "KNOWS", false)
.unwrap(),
Some(1.0)
);
}
#[test]
fn schema_has_edge_reflects_realized_triples() {
let (_dir, graph) = open_graph();
let p0 = graph.add_node("Person", &json!({})).unwrap();
let p1 = graph.add_node("Person", &json!({})).unwrap();
let c0 = graph.add_node("City", &json!({})).unwrap();
graph.add_edge(p0, p1, "KNOWS", &json!({})).unwrap();
graph.add_edge(p0, c0, "LIVES_IN", &json!({})).unwrap();
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "Person").unwrap(),
Some(true)
);
assert_eq!(
graph.schema_has_edge("Person", "LIVES_IN", "City").unwrap(),
Some(true)
);
assert_eq!(
graph.schema_has_edge("City", "KNOWS", "Person").unwrap(),
Some(false)
);
assert_eq!(
graph
.schema_has_edge("Person", "LIVES_IN", "Person")
.unwrap(),
Some(false)
);
assert_eq!(
graph.schema_has_edge("Ghost", "KNOWS", "Person").unwrap(),
None
);
assert_eq!(
graph.schema_has_edge("Person", "GHOST", "Person").unwrap(),
None
);
}
#[test]
fn expand_fanout_to_uses_destination_label() {
let (_dir, graph) = open_graph();
let p0 = graph.add_node("Person", &json!({})).unwrap();
let p1 = graph.add_node("Person", &json!({})).unwrap();
let c0 = graph.add_node("City", &json!({})).unwrap();
let c1 = graph.add_node("City", &json!({})).unwrap();
graph.add_edge(p0, p1, "KNOWS", &json!({})).unwrap();
graph.add_edge(p0, c0, "KNOWS", &json!({})).unwrap();
graph.add_edge(p0, c1, "KNOWS", &json!({})).unwrap();
assert_eq!(
graph
.estimate_expand_fanout("Person", "KNOWS", false)
.unwrap(),
Some(1.5)
);
assert_eq!(
graph
.estimate_expand_fanout_to("Person", "KNOWS", "Person", false)
.unwrap(),
Some(0.5)
);
assert_eq!(
graph
.estimate_expand_fanout_to("Person", "KNOWS", "City", false)
.unwrap(),
Some(1.0)
);
let p2 = graph.add_node("Robot", &json!({})).unwrap();
let _ = p2;
assert_eq!(
graph
.estimate_expand_fanout_to("Person", "KNOWS", "Robot", false)
.unwrap(),
None
);
}
}