use std::collections::HashMap;
use issundb::{Graph, GraphQueryExt, NodeId};
use serde::Deserialize;
use serde_json::json;
use tempfile::TempDir;
const TOLERANCE: f64 = 1e-9;
const VECTOR_INDEX_TOLERANCE: f64 = 1e-6;
#[derive(Deserialize)]
struct Corpus {
graphs: Vec<GraphCase>,
values: ValueCases,
}
#[derive(Deserialize)]
struct GraphCase {
id: String,
n: usize,
edges: Vec<[usize; 2]>,
link: Vec<LinkRow>,
closeness: Vec<f64>,
clustering: Vec<f64>,
#[serde(rename = "triangleAssignments")]
triangle_assignments: u64,
}
#[derive(Deserialize)]
struct LinkRow {
a: usize,
b: usize,
#[serde(rename = "commonNeighbors")]
common_neighbors: f64,
jaccard: f64,
#[serde(rename = "adamicAdar")]
adamic_adar: f64,
#[serde(rename = "resourceAllocation")]
resource_allocation: f64,
#[serde(rename = "preferentialAttachment")]
preferential_attachment: f64,
}
#[derive(Deserialize)]
struct ValueCases {
similarity: Vec<SimilarityCase>,
distance: Vec<DistanceCase>,
}
#[derive(Deserialize)]
struct SimilarityCase {
a: Vec<i64>,
b: Vec<i64>,
jaccard: f64,
overlap: f64,
}
#[derive(Deserialize)]
struct DistanceCase {
a: Vec<f64>,
b: Vec<f64>,
#[serde(rename = "cosineDefined")]
cosine_defined: bool,
cosine: Option<f64>,
euclidean: f64,
}
fn load_corpus() -> Corpus {
let raw = include_str!("fixtures/function_oracle.json");
serde_json::from_str(raw).expect("the function oracle corpus must parse")
}
fn build(case: &GraphCase) -> (TempDir, Graph, Vec<NodeId>) {
let dir = TempDir::new().unwrap();
let g = Graph::open(dir.path(), 1).unwrap();
let ids: Vec<NodeId> = (0..case.n)
.map(|i| g.add_node("N", &json!({ "idx": i as i64 })).unwrap())
.collect();
for [s, d] in &case.edges {
g.add_edge(ids[*s], ids[*d], "R", &json!({})).unwrap();
}
(dir, g, ids)
}
fn both_paths(g: &Graph, cypher: &str) -> [Vec<Vec<serde_json::Value>>; 2] {
let fast = g
.query(cypher)
.unwrap_or_else(|e| panic!("fast path failed for {cypher}: {e}"));
let slow = {
unsafe { std::env::set_var("ISSUNDB_ROW_PIPELINE_ONLY", "1") };
let out = g.query(cypher);
unsafe { std::env::remove_var("ISSUNDB_ROW_PIPELINE_ONLY") };
out.unwrap_or_else(|e| panic!("row pipeline failed for {cypher}: {e}"))
};
[
fast.records.into_iter().map(|r| r.values).collect(),
slow.records.into_iter().map(|r| r.values).collect(),
]
}
fn approx(got: f64, want: f64, what: &str) {
approx_within(got, want, TOLERANCE, what);
}
fn approx_within(got: f64, want: f64, tolerance: f64, what: &str) {
assert!(
(got - want).abs() < tolerance,
"{what}: expected {want}, got {got} (tolerance {tolerance})",
);
}
#[test]
fn link_prediction_functions_match_ground_truth() {
let corpus = load_corpus();
for case in &corpus.graphs {
let (_dir, g, ids) = build(case);
for row in &case.link {
let cypher = format!(
"MATCH (a:N), (b:N) WHERE id(a) = {} AND id(b) = {} \
RETURN issundb.link.commonNeighbors(a, b) AS cn, \
issundb.link.jaccard(a, b) AS jac, \
issundb.link.adamicAdar(a, b) AS aa, \
issundb.link.resourceAllocation(a, b) AS ra, \
issundb.link.preferentialAttachment(a, b) AS pa",
ids[row.a], ids[row.b],
);
for (path, rows) in both_paths(&g, &cypher).iter().enumerate() {
let values = &rows[0];
let where_ = format!("case {} pair ({}, {}) path {path}", case.id, row.a, row.b);
let num = |i: usize| values[i].as_f64().expect("a number");
approx(
num(0),
row.common_neighbors,
&format!("{where_} commonNeighbors"),
);
approx(num(1), row.jaccard, &format!("{where_} jaccard"));
approx(num(2), row.adamic_adar, &format!("{where_} adamicAdar"));
approx(
num(3),
row.resource_allocation,
&format!("{where_} resourceAllocation"),
);
approx(
num(4),
row.preferential_attachment,
&format!("{where_} preferentialAttachment"),
);
}
}
}
}
#[test]
fn closeness_matches_ground_truth() {
let corpus = load_corpus();
for case in &corpus.graphs {
let (_dir, g, ids) = build(case);
let index_of: HashMap<NodeId, usize> =
ids.iter().enumerate().map(|(i, &id)| (id, i)).collect();
let cypher = "CALL issundb.closeness() YIELD nodeId, score \
RETURN nodeId, score ORDER BY nodeId";
for (path, rows) in both_paths(&g, cypher).iter().enumerate() {
for values in rows {
let node = values[0].as_u64().expect("a node id");
let idx = index_of[&node];
approx(
values[1].as_f64().expect("a score"),
case.closeness[idx],
&format!("case {} closeness node {idx} path {path}", case.id),
);
}
}
}
}
#[test]
fn clustering_coefficient_matches_ground_truth() {
let corpus = load_corpus();
for case in &corpus.graphs {
let (_dir, g, ids) = build(case);
let index_of: HashMap<NodeId, usize> =
ids.iter().enumerate().map(|(i, &id)| (id, i)).collect();
let cypher = "CALL issundb.clusteringCoefficient() YIELD nodeId, score \
RETURN nodeId, score ORDER BY nodeId";
for (path, rows) in both_paths(&g, cypher).iter().enumerate() {
for values in rows {
let node = values[0].as_u64().expect("a node id");
let idx = index_of[&node];
approx(
values[1].as_f64().expect("a score"),
case.clustering[idx],
&format!("case {} clustering node {idx} path {path}", case.id),
);
}
}
}
}
#[test]
fn triangle_count_matches_ground_truth() {
let corpus = load_corpus();
for case in &corpus.graphs {
let (_dir, g, _ids) = build(case);
let cypher = "CALL issundb.triangleCount() YIELD count RETURN count";
for (path, rows) in both_paths(&g, cypher).iter().enumerate() {
let got = rows[0][0].as_u64().expect("a count");
assert_eq!(
got, case.triangle_assignments,
"case {} triangle count path {path}",
case.id,
);
}
}
}
#[test]
fn value_functions_match_ground_truth() {
let corpus = load_corpus();
let dir = TempDir::new().unwrap();
let g = Graph::open(dir.path(), 1).unwrap();
let list = |v: &[i64]| {
let parts: Vec<String> = v.iter().map(|x| x.to_string()).collect();
format!("[{}]", parts.join(", "))
};
let flist = |v: &[f64]| {
let parts: Vec<String> = v.iter().map(|x| format!("{x:?}")).collect();
format!("[{}]", parts.join(", "))
};
for case in &corpus.values.similarity {
let cypher = format!(
"RETURN issundb.similarity.jaccard({a}, {b}) AS jac, \
issundb.similarity.overlap({a}, {b}) AS ovl",
a = list(&case.a),
b = list(&case.b),
);
for (path, rows) in both_paths(&g, &cypher).iter().enumerate() {
approx(
rows[0][0].as_f64().expect("a number"),
case.jaccard,
&format!("similarity.jaccard {:?} {:?} path {path}", case.a, case.b),
);
approx(
rows[0][1].as_f64().expect("a number"),
case.overlap,
&format!("similarity.overlap {:?} {:?} path {path}", case.a, case.b),
);
}
}
for case in &corpus.values.distance {
let cypher = format!(
"RETURN issundb.distance.cosine({a}, {b}) AS cos, \
issundb.distance.euclidean({a}, {b}) AS euc, \
vector_dist({a}, {b}) AS vd",
a = flist(&case.a),
b = flist(&case.b),
);
for (path, rows) in both_paths(&g, &cypher).iter().enumerate() {
approx(
rows[0][1].as_f64().expect("a number"),
case.euclidean,
&format!("distance.euclidean {:?} {:?} path {path}", case.a, case.b),
);
if !case.cosine_defined {
continue;
}
let want = case.cosine.expect("a defined cosine carries its value");
approx(
rows[0][0].as_f64().expect("a number"),
want,
&format!("distance.cosine {:?} {:?} path {path}", case.a, case.b),
);
approx_within(
rows[0][2].as_f64().expect("a number"),
want,
VECTOR_INDEX_TOLERANCE,
&format!("vector_dist {:?} {:?} path {path}", case.a, case.b),
);
}
}
}