use ahash::AHashMap;
use crate::{
error::Error,
schema::{AdjEntry, LabelId, NodeId, TypeId},
storage::{
Storage,
ids::{get_label, get_label_count, get_type, get_type_count},
},
};
use super::{Graph, composite_key};
const SCHEMA_PROBE_BUDGET: u64 = 32_768;
const STALE_FANOUT_GROWTH_FACTOR: u64 = 2;
fn split_label_key(key: &[u8]) -> Result<(LabelId, NodeId), Error> {
let label: [u8; 4] = key
.get(..4)
.and_then(|b| b.try_into().ok())
.ok_or(Error::Corrupt("label_idx key has wrong length"))?;
let node: [u8; 8] = key
.get(4..)
.and_then(|b| b.try_into().ok())
.ok_or(Error::Corrupt("label_idx key has wrong length"))?;
Ok((u32::from_be_bytes(label), u64::from_be_bytes(node)))
}
pub(crate) struct EdgeFanout {
generation: u64,
edges_by_type: AHashMap<TypeId, 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::with_capacity(storage.label_idx.len(&rtxn)? as usize);
for result in storage.label_idx.iter(&rtxn)? {
let (key, _) = result?;
let (label, node) = split_label_key(key)?;
node_labels.entry(node).or_default().push(label);
}
let mut edges_by_type: AHashMap<TypeId, u64> = AHashMap::new();
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();
let mut cached_src: Option<NodeId> = None;
let mut src_labels: &[LabelId] = &[];
for result in storage.out_adj.iter(&rtxn)? {
let (src, bytes) = result?;
if cached_src != Some(src) {
cached_src = Some(src);
src_labels = node_labels.get(&src).map(Vec::as_slice).unwrap_or_default();
}
let entry = AdjEntry::decode_value(bytes)?;
let edge_type = entry.edge_type;
let dst = entry.other;
let dst_labels = node_labels.get(&dst).map(Vec::as_slice).unwrap_or_default();
*edges_by_type.entry(edge_type).or_insert(0) += 1;
for &label in src_labels {
*out_by_src_label.entry((label, edge_type)).or_insert(0) += 1;
}
for &label in dst_labels {
*in_by_dst_label.entry((label, edge_type)).or_insert(0) += 1;
}
for &s in src_labels {
for &d in dst_labels {
*triples.entry((s, edge_type, d)).or_insert(0) += 1;
}
}
}
Ok(Self {
generation,
edges_by_type,
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()?;
self.resolve_label_type_in(&rtxn, label, rel_type)
}
fn resolve_label_type_in(
&self,
rtxn: &crate::storage::RoTxn,
label: &str,
rel_type: &str,
) -> Result<Option<(LabelId, TypeId)>, Error> {
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_current_fanout<T>(&self, f: impl FnOnce(&EdgeFanout) -> T) -> Result<Option<T>, Error> {
let guard = self.edge_fanout.lock();
let generation = self.csr_cache.current_gen();
match guard.as_ref() {
Some(table) if table.generation == generation => Ok(Some(f(table))),
_ => Ok(None),
}
}
fn with_possibly_stale_fanout<T>(
&self,
rel_type: TypeId,
f: impl FnOnce(&EdgeFanout) -> T,
) -> Result<Option<T>, Error> {
let guard = self.edge_fanout.lock();
let generation = self.csr_cache.current_gen();
let table = match guard.as_ref() {
Some(table) => table,
None => return Ok(None),
};
if table.generation == generation {
return Ok(Some(f(table)));
}
let live = {
let rtxn = self.storage.env.read_txn()?;
get_type_count(&self.storage, &rtxn, rel_type)?
};
let at_build = table.edges_by_type.get(&rel_type).copied().unwrap_or(0);
if live > at_build.saturating_mul(STALE_FANOUT_GROWTH_FACTOR) {
return Ok(None);
}
Ok(Some(f(table)))
}
pub fn materialize_edge_statistics(&self) -> Result<(), Error> {
let generation = {
let guard = self.edge_fanout.lock();
let generation = self.csr_cache.current_gen();
if guard.as_ref().is_some_and(|t| t.generation == generation) {
return Ok(());
}
generation
};
let table = EdgeFanout::build(&self.storage, generation)?;
let mut guard = self.edge_fanout.lock();
if guard.as_ref().is_some_and(|t| t.generation > generation) {
return Ok(());
}
*guard = Some(table);
Ok(())
}
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_possibly_stale_fanout(type_id, |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,
}
})
.map(Option::flatten)
}
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_possibly_stale_fanout(type_id, |table| match table.triples.get(&key).copied() {
Some(edges) if edges > 0 => Some(edges as f64 / node_count as f64),
_ => None,
})
.map(Option::flatten)
}
pub fn schema_has_edge(
&self,
src_label: &str,
rel_type: &str,
dst_label: &str,
) -> Result<Option<bool>, Error> {
let probe_gen = self.csr_cache.current_gen();
let rtxn = self.storage.env.read_txn()?;
let (src_id, type_id) = match self.resolve_label_type_in(&rtxn, src_label, rel_type)? {
Some(ids) => ids,
None => return Ok(None),
};
let dst_id = match get_label(&self.storage, &rtxn, dst_label)? {
Some(id) => id,
None => return Ok(None),
};
if let Some(answer) = self
.with_current_fanout(|table| table.triples.contains_key(&(src_id, type_id, dst_id)))?
{
return Ok(Some(answer));
}
let key = (src_id, type_id, dst_id);
if let Some(memo) = self.cached_schema_probe(key) {
return Ok(memo);
}
let verdict =
self.probe_schema_edge(&rtxn, src_id, type_id, dst_id, SCHEMA_PROBE_BUDGET)?;
#[cfg(test)]
super::TestHooks::fire(&self.test_hooks.before_schema_memoize);
self.memoize_schema_probe(key, verdict, probe_gen);
Ok(verdict)
}
fn cached_schema_probe(&self, key: (LabelId, TypeId, LabelId)) -> Option<Option<bool>> {
let guard = self.schema_probes.lock();
let generation = self.csr_cache.current_gen();
if guard.0 != generation {
return None;
}
guard.1.get(&key).copied()
}
fn memoize_schema_probe(
&self,
key: (LabelId, TypeId, LabelId),
verdict: Option<bool>,
probe_gen: u64,
) {
let mut guard = self.schema_probes.lock();
let generation = self.csr_cache.current_gen();
if generation != probe_gen {
return;
}
if guard.0 != generation {
guard.0 = generation;
guard.1.clear();
}
guard.1.insert(key, verdict);
}
fn probe_schema_edge(
&self,
rtxn: &crate::storage::RoTxn,
src_label: LabelId,
rel_type: TypeId,
dst_label: LabelId,
budget: u64,
) -> Result<Option<bool>, Error> {
let src_count = get_label_count(&self.storage, rtxn, src_label)?;
let dst_count = get_label_count(&self.storage, rtxn, dst_label)?;
let (walk_label, other_label, outgoing) = if src_count <= dst_count {
(src_label, dst_label, true)
} else {
(dst_label, src_label, false)
};
let other_prefix = other_label.to_be_bytes();
if self
.storage
.label_idx
.prefix_iter(rtxn, &other_prefix)?
.next()
.is_none()
{
return Ok(Some(false));
}
let adj = if outgoing {
&self.storage.out_adj
} else {
&self.storage.in_adj
};
let mut budget = budget;
let prefix = walk_label.to_be_bytes();
for result in self.storage.label_idx.prefix_iter(rtxn, &prefix)? {
let (key, _) = result?;
let (_, node) = split_label_key(key)?;
if budget == 0 {
return Ok(None);
}
budget -= 1;
let dups = match adj.get_duplicates(rtxn, &node)? {
Some(iter) => iter,
None => continue,
};
for result in dups {
let (_, bytes) = result?;
if budget == 0 {
return Ok(None);
}
budget -= 1;
let entry = AdjEntry::decode_value(bytes)?;
if entry.edge_type != rel_type {
continue;
}
if budget == 0 {
return Ok(None);
}
budget -= 1;
if self
.storage
.label_idx
.get(rtxn, &composite_key(other_label, entry.other))?
.is_some()
{
return Ok(Some(true));
}
}
}
Ok(Some(false))
}
}
#[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();
graph.materialize_edge_statistics().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 a_write_does_not_rebuild_the_table() {
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();
let c0 = graph.add_node("City", &json!({})).unwrap();
graph.materialize_edge_statistics().unwrap();
assert_eq!(
graph
.estimate_expand_fanout("Person", "KNOWS", false)
.unwrap(),
Some(0.5)
);
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "City").unwrap(),
Some(false),
"a built table decides the unrealized triple"
);
graph.add_edge(p0, c0, "KNOWS", &json!({})).unwrap();
assert_eq!(
graph
.estimate_expand_fanout("Person", "KNOWS", false)
.unwrap(),
Some(0.5)
);
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "City").unwrap(),
Some(true),
"a stale table must not deny a triple the write just realized"
);
graph.materialize_edge_statistics().unwrap();
assert_eq!(
graph
.estimate_expand_fanout("Person", "KNOWS", false)
.unwrap(),
Some(1.0)
);
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "City").unwrap(),
Some(true)
);
}
#[test]
fn no_reader_builds_the_table() {
let (_dir, graph) = open_graph();
let p0 = graph.add_node("Person", &json!({})).unwrap();
let p1 = graph.add_node("Person", &json!({})).unwrap();
let _city = graph.add_node("City", &json!({})).unwrap();
graph.add_edge(p0, p1, "KNOWS", &json!({})).unwrap();
assert_eq!(
graph
.estimate_expand_fanout("Person", "KNOWS", false)
.unwrap(),
None
);
assert_eq!(
graph
.estimate_expand_fanout_to("Person", "KNOWS", "Person", false)
.unwrap(),
None
);
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "Person").unwrap(),
Some(true),
"the probe decides a realized triple with no table"
);
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "City").unwrap(),
Some(false),
"the probe decides an unrealized triple with no table"
);
assert!(
graph.edge_fanout.lock().is_none(),
"reading must not have populated the table as a side effect"
);
graph.materialize_edge_statistics().unwrap();
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "Person").unwrap(),
Some(true)
);
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "City").unwrap(),
Some(false)
);
}
#[test]
fn probe_and_table_agree() {
let (_dir, graph) = open_graph();
let hybrid = graph
.add_node_multi(&["Person", "Robot"], &json!({}))
.unwrap();
let person = graph.add_node("Person", &json!({})).unwrap();
let city = graph.add_node("City", &json!({})).unwrap();
let bare = graph.add_node_multi(&[], &json!({})).unwrap();
graph.add_edge(hybrid, hybrid, "KNOWS", &json!({})).unwrap();
graph.add_edge(hybrid, person, "KNOWS", &json!({})).unwrap();
graph.add_edge(hybrid, person, "KNOWS", &json!({})).unwrap();
graph.add_edge(person, bare, "KNOWS", &json!({})).unwrap();
graph
.add_edge(person, city, "LIVES_IN", &json!({}))
.unwrap();
let questions = [
("Person", "KNOWS", "Person"),
("Person", "KNOWS", "Robot"),
("Robot", "KNOWS", "Person"),
("Robot", "KNOWS", "Robot"),
("Person", "KNOWS", "City"),
("City", "KNOWS", "Person"),
("Person", "LIVES_IN", "City"),
("City", "LIVES_IN", "Person"),
("Robot", "LIVES_IN", "City"),
];
let probed: Vec<_> = questions
.iter()
.map(|&(s, t, d)| graph.schema_has_edge(s, t, d).unwrap())
.collect();
assert!(
graph.edge_fanout.lock().is_none(),
"the probe must not have built the table"
);
graph.materialize_edge_statistics().unwrap();
let tabled: Vec<_> = questions
.iter()
.map(|&(s, t, d)| graph.schema_has_edge(s, t, d).unwrap())
.collect();
assert_eq!(probed, tabled, "probe and table disagree on {questions:?}");
assert_eq!(
tabled,
vec![
Some(true),
Some(true),
Some(true),
Some(true),
Some(false),
Some(false),
Some(true),
Some(false),
Some(false),
]
);
}
#[test]
fn an_exhausted_probe_budget_declines() {
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();
let (person, knows) = graph
.resolve_label_type("Person", "KNOWS")
.unwrap()
.unwrap();
let probe = |budget: u64| {
let rtxn = graph.storage.env.read_txn().unwrap();
graph
.probe_schema_edge(&rtxn, person, knows, person, budget)
.unwrap()
};
assert_eq!(probe(0), None);
assert_eq!(probe(1), None);
assert_eq!(probe(2), None);
assert_eq!(probe(3), Some(true));
}
#[test]
fn visiting_edgeless_nodes_spends_the_probe_budget() {
let (_dir, graph) = open_graph();
for _ in 0..3 {
graph.add_node("City", &json!({})).unwrap();
}
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 p3 = graph.add_node("Person", &json!({})).unwrap();
graph.add_edge(p0, p1, "KNOWS", &json!({})).unwrap();
let _ = (p2, p3);
let (city, knows) = graph.resolve_label_type("City", "KNOWS").unwrap().unwrap();
let person = graph
.resolve_label_type("Person", "KNOWS")
.unwrap()
.unwrap()
.0;
let probe = |budget: u64| {
let rtxn = graph.storage.env.read_txn().unwrap();
graph
.probe_schema_edge(&rtxn, city, knows, person, budget)
.unwrap()
};
assert_eq!(probe(1), None);
assert_eq!(probe(2), None);
assert_eq!(probe(3), Some(false));
}
#[test]
fn an_empty_endpoint_population_decides_immediately() {
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();
let ghost = graph.add_node("Ghost", &json!({})).unwrap();
graph.delete_node(ghost).unwrap();
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "Ghost").unwrap(),
Some(false)
);
let (person, knows) = graph
.resolve_label_type("Person", "KNOWS")
.unwrap()
.unwrap();
let ghost_label = graph
.resolve_label_type("Ghost", "KNOWS")
.unwrap()
.unwrap()
.0;
assert_eq!(
graph
.probe_schema_edge(
&graph.storage.env.read_txn().unwrap(),
person,
knows,
ghost_label,
0
)
.unwrap(),
Some(false),
"deciding on the population must not need any budget"
);
}
#[test]
fn a_stale_table_still_serves_the_advisory_estimate() {
let (_dir, graph) = open_graph();
let p0 = graph.add_node("Person", &json!({})).unwrap();
let p1 = graph.add_node("Person", &json!({})).unwrap();
let city = graph.add_node("City", &json!({})).unwrap();
for _ in 0..4 {
graph.add_edge(p0, p1, "KNOWS", &json!({})).unwrap();
}
graph.materialize_edge_statistics().unwrap();
assert_eq!(
graph
.estimate_expand_fanout("Person", "KNOWS", false)
.unwrap(),
Some(2.0)
);
graph.add_edge(p0, city, "KNOWS", &json!({})).unwrap();
assert_eq!(
graph
.estimate_expand_fanout("Person", "KNOWS", false)
.unwrap(),
Some(2.0),
"the stale ratio is served, not recomputed: five edges would give 2.5"
);
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "City").unwrap(),
Some(true),
"the schema question does not read the stale table"
);
}
#[test]
fn growth_past_the_factor_refuses_the_stale_table() {
let (_dir, graph) = open_graph();
let p0 = graph.add_node("Person", &json!({})).unwrap();
let p1 = graph.add_node("Person", &json!({})).unwrap();
for _ in 0..2 {
graph.add_edge(p0, p1, "KNOWS", &json!({})).unwrap();
}
graph.materialize_edge_statistics().unwrap();
assert_eq!(
graph
.estimate_expand_fanout("Person", "KNOWS", false)
.unwrap(),
Some(1.0)
);
for _ in 0..3 {
graph.add_edge(p0, p1, "KNOWS", &json!({})).unwrap();
}
assert_eq!(
graph
.estimate_expand_fanout("Person", "KNOWS", false)
.unwrap(),
None,
"a table describing a type this much smaller is refused"
);
assert!(
graph.edge_fanout.lock().is_some(),
"refusing to serve it must not have dropped or rebuilt it"
);
graph.materialize_edge_statistics().unwrap();
assert_eq!(
graph
.estimate_expand_fanout("Person", "KNOWS", false)
.unwrap(),
Some(2.5)
);
}
#[test]
fn the_staleness_bound_is_per_relationship_type() {
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();
for _ in 0..9 {
graph.add_edge(p0, c0, "LIVES_IN", &json!({})).unwrap();
}
graph.add_edge(p0, p1, "KNOWS", &json!({})).unwrap();
graph.materialize_edge_statistics().unwrap();
for _ in 0..3 {
graph.add_edge(p0, p1, "KNOWS", &json!({})).unwrap();
}
assert_eq!(
graph
.estimate_expand_fanout("Person", "KNOWS", false)
.unwrap(),
None,
"the type quadrupled, so its stale estimate is refused"
);
assert_eq!(
graph
.estimate_expand_fanout("Person", "LIVES_IN", false)
.unwrap(),
Some(4.5),
"LIVES_IN did not grow, so its estimate is still served"
);
}
#[test]
fn a_type_absent_at_build_time_is_refused_once_it_has_edges() {
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();
graph.materialize_edge_statistics().unwrap();
graph.add_edge(p0, p1, "LIVES_IN", &json!({})).unwrap();
assert_eq!(
graph
.estimate_expand_fanout("Person", "LIVES_IN", false)
.unwrap(),
None
);
}
#[test]
fn a_probe_verdict_is_memoized_per_generation() {
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();
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "City").unwrap(),
Some(false)
);
assert_eq!(
graph.schema_probes.lock().1.len(),
1,
"the verdict is remembered"
);
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "City").unwrap(),
Some(false)
);
assert_eq!(graph.schema_probes.lock().1.len(), 1);
graph.add_edge(p0, c0, "KNOWS", &json!({})).unwrap();
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "City").unwrap(),
Some(true),
"a memo from an earlier generation must not deny a realized triple"
);
}
#[test]
fn a_mid_probe_write_does_not_stamp_a_pre_commit_verdict() {
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();
let (person, knows) = graph
.resolve_label_type("Person", "KNOWS")
.unwrap()
.unwrap();
let city = graph
.resolve_label_type("City", "KNOWS")
.unwrap()
.unwrap()
.0;
let probe_gen = graph.csr_cache.current_gen();
let verdict = {
let rtxn = graph.storage.env.read_txn().unwrap();
graph
.probe_schema_edge(&rtxn, person, knows, city, SCHEMA_PROBE_BUDGET)
.unwrap()
};
assert_eq!(verdict, Some(false));
graph.add_edge(p0, c0, "KNOWS", &json!({})).unwrap();
graph.memoize_schema_probe((person, knows, city), verdict, probe_gen);
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "City").unwrap(),
Some(true),
"the pre-commit verdict must not be served for the post-commit generation"
);
}
#[test]
fn a_mid_probe_write_is_not_memoized_end_to_end() {
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();
let writer = graph.clone();
graph
.test_hooks
.before_schema_memoize
.lock()
.replace(Box::new(move || {
writer.add_edge(p0, c0, "KNOWS", &json!({})).unwrap();
}));
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "City").unwrap(),
Some(false)
);
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "City").unwrap(),
Some(true),
"the pre-commit verdict must not be served for the post-commit generation"
);
}
#[test]
fn the_table_decides_what_the_probe_gives_up_on() {
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();
let (person, knows) = graph
.resolve_label_type("Person", "KNOWS")
.unwrap()
.unwrap();
{
let rtxn = graph.storage.env.read_txn().unwrap();
assert_eq!(
graph
.probe_schema_edge(&rtxn, person, knows, person, 0)
.unwrap(),
None
);
}
graph.materialize_edge_statistics().unwrap();
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "Person").unwrap(),
Some(true)
);
assert!(
graph.schema_probes.lock().1.is_empty(),
"the table path must not have probed at all"
);
}
#[test]
fn build_follows_later_label_changes() {
let (_dir, graph) = open_graph();
let a = graph.add_node("Person", &json!({})).unwrap();
let b = graph.add_node("Person", &json!({})).unwrap();
graph.add_edge(a, b, "KNOWS", &json!({})).unwrap();
graph.add_label(b, "Admin").unwrap();
graph.materialize_edge_statistics().unwrap();
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "Admin").unwrap(),
Some(true),
"the added label realizes a new triple"
);
assert_eq!(
graph
.estimate_expand_fanout("Admin", "KNOWS", true)
.unwrap(),
Some(1.0),
"one incoming KNOWS over one Admin node"
);
graph.remove_label(b, "Admin").unwrap();
graph.materialize_edge_statistics().unwrap();
assert_eq!(
graph.schema_has_edge("Person", "KNOWS", "Admin").unwrap(),
Some(false),
"removing the label unrealizes it"
);
}
#[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();
graph.materialize_edge_statistics().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();
graph.materialize_edge_statistics().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;
graph.materialize_edge_statistics().unwrap();
assert_eq!(
graph
.estimate_expand_fanout_to("Person", "KNOWS", "Person", false)
.unwrap(),
Some(0.5),
"the table is current, so a decline below is about the schema"
);
assert_eq!(
graph
.estimate_expand_fanout_to("Person", "KNOWS", "Robot", false)
.unwrap(),
None
);
}
}