use {crate::error::BenchError, std::collections::HashSet};
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct BenchmarkDefinition {
pub name: String,
pub description: String,
#[serde(default)]
pub relations: Vec<RelationSource>,
#[serde(default)]
pub queries: Vec<QueryDefinition>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generator: Option<GeneratorSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum GeneratorSpec {
Watdiv {
scale: u32,
#[serde(default)]
stress: WatdivStressSpec,
},
WatdivBasic {
scale: u32,
},
Lubm {
scale: u32,
#[serde(default = "default_lubm_seed")]
seed: u32,
#[serde(default = "default_lubm_threads")]
threads: u32,
#[serde(default)]
start_index: u32,
#[serde(default = "default_lubm_ontology")]
ontology: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
queries: Option<Vec<String>>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct WatdivStressSpec {
#[serde(default = "default_watdiv_max_query_size")]
pub max_query_size: u32,
#[serde(default = "default_watdiv_query_count")]
pub query_count: u32,
#[serde(default = "default_watdiv_constants_per_query")]
pub constants_per_query: u32,
#[serde(default)]
pub allow_join_vertex: bool,
}
impl Default for WatdivStressSpec {
fn default() -> Self {
Self {
max_query_size: default_watdiv_max_query_size(),
query_count: default_watdiv_query_count(),
constants_per_query: default_watdiv_constants_per_query(),
allow_join_vertex: false,
}
}
}
pub const DEFAULT_LUBM_ONTOLOGY: &str = "http://www.lehigh.edu/~zhp2/2004/0401/univ-bench.owl";
fn default_watdiv_max_query_size() -> u32 { 5 }
fn default_watdiv_query_count() -> u32 { 20 }
fn default_watdiv_constants_per_query() -> u32 { 2 }
fn default_lubm_seed() -> u32 { 0 }
fn default_lubm_threads() -> u32 { 1 }
fn default_lubm_ontology() -> String { DEFAULT_LUBM_ONTOLOGY.to_string() }
impl GeneratorSpec {
pub fn spec_hash(&self) -> String {
use sha2::{Digest, Sha256};
let yaml = serde_yaml::to_string(self).expect("GeneratorSpec serializes to YAML");
let mut h = Sha256::new();
h.update(yaml.as_bytes());
format!("{:x}", h.finalize())
}
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct RelationSource {
pub name: String,
pub url: String,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct QueryDefinition {
pub name: String,
pub description: String,
pub query: String,
}
impl BenchmarkDefinition {
pub fn validate(&self) -> Result<(), BenchError> {
if self.name.is_empty() {
return Err(BenchError::Invalid {
name: self.name.clone(),
reason: "name must not be empty".to_string(),
});
}
if !is_portable_filename(&self.name) {
return Err(BenchError::Invalid {
name: self.name.clone(),
reason: "name may only contain ASCII alphanumerics, '.', '_', or '-' (no path \
separators or '..')"
.to_string(),
});
}
let has_static = !self.relations.is_empty() || !self.queries.is_empty();
match (&self.generator, has_static) {
| (Some(_), true) => {
return Err(BenchError::Invalid {
name: self.name.clone(),
reason: "benchmark cannot mix `generator` with `relations`/`queries`; pick one"
.to_string(),
});
},
| (None, false) => {
return Err(BenchError::Invalid {
name: self.name.clone(),
reason: "benchmark must declare either `relations`+`queries` or `generator`"
.to_string(),
});
},
| (Some(spec), false) => return validate_generator(&self.name, spec),
| (None, true) => {},
}
if self.relations.is_empty() {
return Err(BenchError::Invalid {
name: self.name.clone(),
reason: "relations must not be empty".to_string(),
});
}
if self.queries.is_empty() {
return Err(BenchError::Invalid {
name: self.name.clone(),
reason: "queries must not be empty".to_string(),
});
}
for q in &self.queries {
if q.name.is_empty() {
return Err(BenchError::Invalid {
name: self.name.clone(),
reason: "query name must not be empty".to_string(),
});
}
if q.query.is_empty() {
return Err(BenchError::Invalid {
name: self.name.clone(),
reason: format!("query '{}' has empty query string", q.name),
});
}
}
let mut seen = HashSet::new();
for rel in &self.relations {
if !seen.insert(&rel.name) {
return Err(BenchError::Invalid {
name: self.name.clone(),
reason: format!("duplicate relation name: {}", rel.name),
});
}
}
seen.clear();
for q in &self.queries {
if !seen.insert(&q.name) {
return Err(BenchError::Invalid {
name: self.name.clone(),
reason: format!("duplicate query name: {}", q.name),
});
}
}
Ok(())
}
}
fn validate_generator(bench_name: &str, spec: &GeneratorSpec) -> Result<(), BenchError> {
match spec {
| GeneratorSpec::Watdiv {
scale, ..
} => {
if *scale == 0 {
return Err(BenchError::Invalid {
name: bench_name.to_string(),
reason: "watdiv generator scale must be >= 1".to_string(),
});
}
},
| GeneratorSpec::WatdivBasic {
scale,
} => {
if *scale == 0 {
return Err(BenchError::Invalid {
name: bench_name.to_string(),
reason: "watdiv-basic generator scale must be >= 1".to_string(),
});
}
},
| GeneratorSpec::Lubm {
scale,
queries,
ontology,
..
} => {
if *scale == 0 {
return Err(BenchError::Invalid {
name: bench_name.to_string(),
reason: "lubm generator scale must be >= 1".to_string(),
});
}
if ontology.is_empty() {
return Err(BenchError::Invalid {
name: bench_name.to_string(),
reason: "lubm generator ontology must not be empty".to_string(),
});
}
if let Some(qs) = queries {
if qs.is_empty() {
return Err(BenchError::Invalid {
name: bench_name.to_string(),
reason: "lubm generator queries list must be non-empty if provided (omit \
to run all 14)"
.to_string(),
});
}
let mut seen = HashSet::new();
for q in qs {
if !is_valid_lubm_query_name(q) {
return Err(BenchError::Invalid {
name: bench_name.to_string(),
reason: format!("lubm generator query '{q}' is not one of q1..q14"),
});
}
if !seen.insert(q) {
return Err(BenchError::Invalid {
name: bench_name.to_string(),
reason: format!("duplicate lubm query name: {q}"),
});
}
}
}
},
}
Ok(())
}
fn is_valid_lubm_query_name(name: &str) -> bool {
let Some(rest) = name.strip_prefix('q') else {
return false;
};
if rest.len() > 1 && rest.starts_with('0') {
return false;
}
matches!(rest.parse::<u32>(), Ok(n) if (1..=14).contains(&n))
}
fn is_portable_filename(name: &str) -> bool {
if name == "." || name == ".." || name.is_empty() {
return false;
}
name.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_query(name: &str, query: &str) -> QueryDefinition {
QueryDefinition {
name: name.to_string(),
description: format!("{name} query"),
query: query.to_string(),
}
}
#[test]
fn deserialize_valid_yaml() {
let yaml = r#"
name: triangle
description: "Triangle query"
relations:
- name: edge
url: "https://example.com/edge.parquet"
queries:
- name: triangle
description: "Triangle query"
query: "T(X, Y, Z) :- edge(X, Y), edge(Y, Z), edge(X, Z)."
"#;
let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
assert_eq!(def.name, "triangle");
assert_eq!(def.relations.len(), 1);
assert_eq!(def.relations[0].name, "edge");
assert_eq!(def.queries.len(), 1);
assert_eq!(def.queries[0].name, "triangle");
assert!(def.validate().is_ok());
}
#[test]
fn deserialize_multiple_relations() {
let yaml = r#"
name: path
description: "Path query"
relations:
- name: edge
url: "https://example.com/edge.parquet"
- name: node
url: "https://example.com/node.parquet"
queries:
- name: path
description: "Path query"
query: "P(X, Z) :- edge(X, Y), node(Y), edge(Y, Z)."
"#;
let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
assert_eq!(def.relations.len(), 2);
assert!(def.validate().is_ok());
}
#[test]
fn deserialize_multiple_queries() {
let yaml = r#"
name: graph
description: "Graph queries"
relations:
- name: edge
url: "https://example.com/edge.parquet"
queries:
- name: triangle
description: "Triangle query"
query: "T(X, Y, Z) :- edge(X, Y), edge(Y, Z), edge(X, Z)."
- name: two-hop
description: "Two-hop path"
query: "P(X, Z) :- edge(X, Y), edge(Y, Z)."
"#;
let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
assert_eq!(def.name, "graph");
assert_eq!(def.queries.len(), 2);
assert_eq!(def.queries[0].name, "triangle");
assert_eq!(def.queries[1].name, "two-hop");
assert!(def.validate().is_ok());
}
#[test]
fn validate_empty_name() {
let def = BenchmarkDefinition {
name: String::new(),
description: "test".to_string(),
relations: vec![RelationSource {
name: "r".to_string(),
url: "http://x".to_string(),
}],
queries: vec![make_query("q", "Q(X) :- r(X).")],
generator: None,
};
assert!(def.validate().is_err());
}
#[test]
fn validate_empty_relations() {
let def = BenchmarkDefinition {
name: "test".to_string(),
description: "test".to_string(),
relations: vec![],
queries: vec![make_query("q", "Q(X) :- r(X).")],
generator: None,
};
assert!(def.validate().is_err());
}
#[test]
fn validate_empty_queries() {
let def = BenchmarkDefinition {
name: "test".to_string(),
description: "test".to_string(),
relations: vec![RelationSource {
name: "r".to_string(),
url: "http://x".to_string(),
}],
queries: vec![],
generator: None,
};
assert!(def.validate().is_err());
}
#[test]
fn validate_empty_query_name() {
let def = BenchmarkDefinition {
name: "test".to_string(),
description: "test".to_string(),
relations: vec![RelationSource {
name: "r".to_string(),
url: "http://x".to_string(),
}],
queries: vec![make_query("", "Q(X) :- r(X).")],
generator: None,
};
assert!(def.validate().is_err());
}
#[test]
fn validate_empty_query_string() {
let def = BenchmarkDefinition {
name: "test".to_string(),
description: "test".to_string(),
relations: vec![RelationSource {
name: "r".to_string(),
url: "http://x".to_string(),
}],
queries: vec![make_query("q", "")],
generator: None,
};
assert!(def.validate().is_err());
}
#[test]
fn validate_duplicate_relation_names() {
let def = BenchmarkDefinition {
name: "test".to_string(),
description: "test".to_string(),
relations: vec![
RelationSource {
name: "edge".to_string(),
url: "http://x".to_string(),
},
RelationSource {
name: "edge".to_string(),
url: "http://y".to_string(),
},
],
queries: vec![make_query("q", "Q(X) :- edge(X).")],
generator: None,
};
assert!(def.validate().is_err());
}
#[test]
fn validate_duplicate_query_names() {
let def = BenchmarkDefinition {
name: "test".to_string(),
description: "test".to_string(),
relations: vec![RelationSource {
name: "r".to_string(),
url: "http://x".to_string(),
}],
queries: vec![
make_query("q", "Q(X) :- r(X)."),
make_query("q", "Q(Y) :- r(Y)."),
],
generator: None,
};
assert!(def.validate().is_err());
}
#[test]
fn missing_required_fields_fails_validation() {
let yaml = r#"
name: triangle
description: "Triangle query"
relations:
- name: edge
url: "https://example.com/edge.parquet"
"#;
let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
assert!(
def.validate().is_err(),
"queries-less static benchmark must fail validation"
);
}
#[test]
fn deserialize_watdiv_generator() {
let yaml = r#"
name: watdiv-100
description: "watdiv at scale 100"
generator:
kind: watdiv
scale: 100
stress:
max_query_size: 5
query_count: 20
constants_per_query: 2
allow_join_vertex: false
"#;
let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
assert!(def.relations.is_empty());
assert!(def.queries.is_empty());
match def.generator.as_ref().unwrap() {
| GeneratorSpec::Watdiv {
scale,
stress,
} => {
assert_eq!(*scale, 100);
assert_eq!(stress.query_count, 20);
},
| other => panic!("expected watdiv, got {other:?}"),
}
assert!(def.validate().is_ok());
}
#[test]
fn deserialize_watdiv_generator_with_default_stress() {
let yaml = r#"
name: watdiv-1
description: "watdiv default stress"
generator:
kind: watdiv
scale: 1
"#;
let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
match def.generator.as_ref().unwrap() {
| GeneratorSpec::Watdiv {
stress, ..
} => {
assert_eq!(stress, &WatdivStressSpec::default());
},
| _ => panic!("expected watdiv"),
}
assert!(def.validate().is_ok());
}
#[test]
fn deserialize_lubm_generator_full() {
let yaml = r#"
name: lubm-2
description: "lubm scale 2"
generator:
kind: lubm
scale: 2
seed: 7
threads: 4
start_index: 1
ontology: "http://example.com/onto"
queries: [q1, q3, q14]
"#;
let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
match def.generator.as_ref().unwrap() {
| GeneratorSpec::Lubm {
scale,
seed,
threads,
start_index,
ontology,
queries,
} => {
assert_eq!(*scale, 2);
assert_eq!(*seed, 7);
assert_eq!(*threads, 4);
assert_eq!(*start_index, 1);
assert_eq!(ontology, "http://example.com/onto");
assert_eq!(queries.as_ref().unwrap(), &vec!["q1", "q3", "q14"]);
},
| _ => panic!("expected lubm"),
}
assert!(def.validate().is_ok());
}
#[test]
fn deserialize_lubm_generator_minimal() {
let yaml = r#"
name: lubm-1
description: "lubm minimal"
generator:
kind: lubm
scale: 1
"#;
let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
match def.generator.as_ref().unwrap() {
| GeneratorSpec::Lubm {
scale,
seed,
threads,
start_index,
ontology,
queries,
} => {
assert_eq!(*scale, 1);
assert_eq!(*seed, 0);
assert_eq!(*threads, 1);
assert_eq!(*start_index, 0);
assert_eq!(ontology, DEFAULT_LUBM_ONTOLOGY);
assert!(queries.is_none());
},
| _ => panic!("expected lubm"),
}
assert!(def.validate().is_ok());
}
#[test]
fn xor_rejects_generator_with_relations_and_queries() {
let yaml = r#"
name: hybrid
description: "both"
relations:
- name: r
url: "http://x"
queries:
- name: q
description: "default"
query: "Q(X) :- r(X)."
generator:
kind: watdiv
scale: 1
"#;
let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
let err = def.validate().unwrap_err();
let msg = err.to_string();
assert!(msg.contains("cannot mix"), "expected XOR error, got: {msg}");
}
#[test]
fn xor_rejects_neither_generator_nor_relations() {
let yaml = r#"
name: empty
description: "nothing"
"#;
let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
let err = def.validate().unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("must declare"),
"expected XOR error, got: {msg}"
);
}
#[test]
fn lubm_invalid_query_name_rejected() {
let def = BenchmarkDefinition {
name: "lubm-bad".to_string(),
description: "bad query".to_string(),
relations: vec![],
queries: vec![],
generator: Some(GeneratorSpec::Lubm {
scale: 1,
seed: 0,
threads: 1,
start_index: 0,
ontology: DEFAULT_LUBM_ONTOLOGY.to_string(),
queries: Some(vec!["q15".to_string()]),
}),
};
assert!(def.validate().is_err());
}
#[test]
fn lubm_zero_scale_rejected() {
let def = BenchmarkDefinition {
name: "lubm-zero".to_string(),
description: "zero scale".to_string(),
relations: vec![],
queries: vec![],
generator: Some(GeneratorSpec::Lubm {
scale: 0,
seed: 0,
threads: 1,
start_index: 0,
ontology: DEFAULT_LUBM_ONTOLOGY.to_string(),
queries: None,
}),
};
assert!(def.validate().is_err());
}
#[test]
fn watdiv_zero_scale_rejected() {
let def = BenchmarkDefinition {
name: "watdiv-zero".to_string(),
description: "zero scale".to_string(),
relations: vec![],
queries: vec![],
generator: Some(GeneratorSpec::Watdiv {
scale: 0,
stress: WatdivStressSpec::default(),
}),
};
assert!(def.validate().is_err());
}
#[test]
fn spec_hash_is_deterministic() {
let a = GeneratorSpec::Watdiv {
scale: 10,
stress: WatdivStressSpec::default(),
};
let b = GeneratorSpec::Watdiv {
scale: 10,
stress: WatdivStressSpec::default(),
};
assert_eq!(a.spec_hash(), b.spec_hash());
}
#[test]
fn spec_hash_differs_on_param_change() {
let a = GeneratorSpec::Watdiv {
scale: 10,
stress: WatdivStressSpec::default(),
};
let b = GeneratorSpec::Watdiv {
scale: 20,
stress: WatdivStressSpec::default(),
};
assert_ne!(a.spec_hash(), b.spec_hash());
}
#[test]
fn name_with_path_traversal_rejected() {
for evil in [
"../escape",
"..",
".",
"with/slash",
"with\\backslash",
"has space",
"has:colon",
] {
let def = BenchmarkDefinition {
name: evil.to_string(),
description: "x".to_string(),
relations: vec![RelationSource {
name: "r".to_string(),
url: "http://x".to_string(),
}],
queries: vec![make_query("q", "Q(X) :- r(X).")],
generator: None,
};
assert!(
def.validate().is_err(),
"expected rejection for name = {evil:?}"
);
}
}
#[test]
fn lubm_zero_padded_query_name_rejected() {
for bad in ["q01", "q014", "q00", "q001"] {
let def = BenchmarkDefinition {
name: "lubm-zero-pad".to_string(),
description: "x".to_string(),
relations: vec![],
queries: vec![],
generator: Some(GeneratorSpec::Lubm {
scale: 1,
seed: 0,
threads: 1,
start_index: 0,
ontology: DEFAULT_LUBM_ONTOLOGY.to_string(),
queries: Some(vec![bad.to_string()]),
}),
};
assert!(
def.validate().is_err(),
"expected rejection for query name = {bad:?}"
);
}
}
#[test]
fn deserialize_watdiv_basic_generator() {
let yaml = r#"
name: watdiv-basic
description: "watdiv basic"
generator:
kind: watdiv-basic
scale: 10
"#;
let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
def.validate().unwrap();
match def.generator.as_ref().unwrap() {
| GeneratorSpec::WatdivBasic {
scale,
} => assert_eq!(*scale, 10),
| other => panic!("expected WatdivBasic, got {other:?}"),
}
}
#[test]
fn watdiv_basic_scale_zero_is_invalid() {
let spec = GeneratorSpec::WatdivBasic {
scale: 0,
};
assert!(validate_generator("b", &spec).is_err());
}
#[test]
fn watdiv_basic_spec_hash_differs_from_watdiv() {
let basic = GeneratorSpec::WatdivBasic {
scale: 10,
};
let stress = GeneratorSpec::Watdiv {
scale: 10,
stress: WatdivStressSpec::default(),
};
assert_ne!(basic.spec_hash(), stress.spec_hash());
}
#[test]
fn spec_hash_distinguishes_lubm_query_subset() {
let a = GeneratorSpec::Lubm {
scale: 1,
seed: 0,
threads: 1,
start_index: 0,
ontology: DEFAULT_LUBM_ONTOLOGY.to_string(),
queries: None,
};
let b = GeneratorSpec::Lubm {
scale: 1,
seed: 0,
threads: 1,
start_index: 0,
ontology: DEFAULT_LUBM_ONTOLOGY.to_string(),
queries: Some(vec!["q1".to_string(), "q2".to_string()]),
};
assert_ne!(a.spec_hash(), b.spec_hash());
}
}