Skip to main content

kermit_bench/
definition.rs

1//! YAML-schema Serde types for benchmark definitions.
2//!
3//! Each benchmark lives in a single YAML file with one [`BenchmarkDefinition`]
4//! at the top level. The schema is documented in the workspace
5//! `benchmarks/README.md`.
6
7use {crate::error::BenchError, std::collections::HashSet};
8
9/// A benchmark definition loaded from a YAML file.
10///
11/// A benchmark is either *static* — `relations` and `queries` are populated
12/// directly from the YAML — or *generated* — `generator` describes how to
13/// materialise the data on demand via a `kermit-rdf` pipeline. The two are
14/// mutually exclusive; [`BenchmarkDefinition::validate`] enforces the XOR.
15#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
16pub struct BenchmarkDefinition {
17    /// Unique benchmark name. Must match the filename stem.
18    pub name: String,
19    /// Human-readable description, shown in `bench list` output.
20    pub description: String,
21    /// The relations referenced by this benchmark's queries. Empty for a
22    /// generator-driven YAML; the relations are produced by the generator
23    /// pipeline and recorded in the cache-side `benchmark.yml`.
24    #[serde(default)]
25    pub relations: Vec<RelationSource>,
26    /// One or more named queries to run against the relations. Empty for a
27    /// generator-driven YAML; the queries are produced by the generator
28    /// pipeline.
29    #[serde(default)]
30    pub queries: Vec<QueryDefinition>,
31    /// Optional declarative generator spec. When present, `bench run` runs
32    /// the corresponding `kermit-rdf` pipeline on first invocation and
33    /// caches the artefacts; subsequent runs short-circuit on the cached
34    /// `meta.json` if the spec hash matches.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub generator: Option<GeneratorSpec>,
37}
38
39/// A declarative generator spec. Tagged on the `kind` field
40/// (`kind: watdiv` or `kind: lubm`).
41#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
42#[serde(tag = "kind", rename_all = "kebab-case")]
43pub enum GeneratorSpec {
44    /// Drives the WatDiv pipeline (`kermit_rdf::pipeline::run_pipeline`).
45    Watdiv {
46        /// Scale factor passed to `watdiv -d` (>= 1).
47        scale: u32,
48        /// Stress-template parameters. Defaults to the same values as the
49        /// `bench gen watdiv` CLI defaults.
50        #[serde(default)]
51        stress: WatdivStressSpec,
52    },
53    /// Drives the WatDiv Basic Testing workload — the 20 canonical L/S/F/C
54    /// query templates (`kermit_rdf::pipeline::run_basic_pipeline`). Carries
55    /// no stress parameters: the templates are fixed.
56    WatdivBasic {
57        /// Scale factor passed to `watdiv -d` (>= 1).
58        scale: u32,
59    },
60    /// Drives the LUBM pipeline
61    /// (`kermit_rdf::lubm::pipeline::run_lubm_pipeline`).
62    Lubm {
63        /// Universities to generate (`-u`); must be >= 1.
64        scale: u32,
65        /// RNG seed (`-s`). Default `0`.
66        #[serde(default = "default_lubm_seed")]
67        seed: u32,
68        /// Worker thread count (`-t`). Default `1` for reproducibility.
69        #[serde(default = "default_lubm_threads")]
70        threads: u32,
71        /// Starting university index (`-i`). Default `0`.
72        #[serde(default)]
73        start_index: u32,
74        /// Ontology IRI (`--ontology`). Default
75        /// [`DEFAULT_LUBM_ONTOLOGY`].
76        #[serde(default = "default_lubm_ontology")]
77        ontology: String,
78        /// Optional subset of the 14 LUBM queries to run, by stem
79        /// (`q1` … `q14`). `None` or omitted = all 14.
80        #[serde(default, skip_serializing_if = "Option::is_none")]
81        queries: Option<Vec<String>>,
82    },
83}
84
85/// WatDiv stress-template parameters. Field defaults match the `bench gen
86/// watdiv` CLI defaults.
87#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
88pub struct WatdivStressSpec {
89    /// `<max-query-size>` in stress templates. Default `5`.
90    #[serde(default = "default_watdiv_max_query_size")]
91    pub max_query_size: u32,
92    /// `<query-count>` per template. Default `20`.
93    #[serde(default = "default_watdiv_query_count")]
94    pub query_count: u32,
95    /// `<constants-per-query>`. Default `2`.
96    #[serde(default = "default_watdiv_constants_per_query")]
97    pub constants_per_query: u32,
98    /// `<allow-join-vertex>`. Default `false`.
99    #[serde(default)]
100    pub allow_join_vertex: bool,
101}
102
103impl Default for WatdivStressSpec {
104    fn default() -> Self {
105        Self {
106            max_query_size: default_watdiv_max_query_size(),
107            query_count: default_watdiv_query_count(),
108            constants_per_query: default_watdiv_constants_per_query(),
109            allow_join_vertex: false,
110        }
111    }
112}
113
114/// Default LUBM ontology IRI. Mirrors
115/// `kermit_rdf::lubm::driver::DEFAULT_ONTOLOGY_IRI`.
116pub const DEFAULT_LUBM_ONTOLOGY: &str = "http://www.lehigh.edu/~zhp2/2004/0401/univ-bench.owl";
117
118fn default_watdiv_max_query_size() -> u32 { 5 }
119fn default_watdiv_query_count() -> u32 { 20 }
120fn default_watdiv_constants_per_query() -> u32 { 2 }
121fn default_lubm_seed() -> u32 { 0 }
122fn default_lubm_threads() -> u32 { 1 }
123fn default_lubm_ontology() -> String { DEFAULT_LUBM_ONTOLOGY.to_string() }
124
125impl GeneratorSpec {
126    /// Computes the canonical SHA-256 hash of this spec, used by the
127    /// materialization layer to detect parameter drift against a cached
128    /// `meta.json`. Hashes the YAML serialization of the spec; field
129    /// ordering is fixed by the struct/enum definition so the output is
130    /// deterministic across runs and platforms (no `HashMap` fields).
131    ///
132    /// # Panics
133    ///
134    /// Panics if `serde_yaml::to_string` fails for `Self`. The serializer
135    /// is total over all `GeneratorSpec` values.
136    pub fn spec_hash(&self) -> String {
137        use sha2::{Digest, Sha256};
138        let yaml = serde_yaml::to_string(self).expect("GeneratorSpec serializes to YAML");
139        let mut h = Sha256::new();
140        h.update(yaml.as_bytes());
141        format!("{:x}", h.finalize())
142    }
143}
144
145/// A relation source with a name and download URL.
146#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
147pub struct RelationSource {
148    /// Relation identifier; matched against predicate names in Datalog
149    /// queries.
150    pub name: String,
151    /// HTTP(S) URL of a Parquet file containing the relation's tuples.
152    pub url: String,
153}
154
155/// A named query within a benchmark.
156#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
157pub struct QueryDefinition {
158    /// Query identifier; used to select a specific query via the CLI.
159    pub name: String,
160    /// Human-readable description shown in `bench list` output.
161    pub description: String,
162    /// The Datalog query string (see `kermit-parser` for grammar).
163    pub query: String,
164}
165
166impl BenchmarkDefinition {
167    /// Validates structural invariants of the benchmark definition.
168    ///
169    /// Checks that `name`, `relations`, and `queries` are non-empty, that
170    /// every query has a non-empty `name` and `query`, and that relation
171    /// names and query names are unique within the benchmark.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`BenchError::Invalid`] describing the first failing
176    /// constraint.
177    ///
178    /// # Example
179    ///
180    /// ```
181    /// use kermit_bench::{BenchmarkDefinition, QueryDefinition, RelationSource};
182    ///
183    /// let def = BenchmarkDefinition {
184    ///     name: "triangle".into(),
185    ///     description: "Triangle query".into(),
186    ///     relations: vec![RelationSource {
187    ///         name: "edge".into(),
188    ///         url: "https://example.com/edge.parquet".into(),
189    ///     }],
190    ///     queries: vec![QueryDefinition {
191    ///         name: "triangle".into(),
192    ///         description: "triangle".into(),
193    ///         query: "T(X, Y, Z) :- edge(X, Y), edge(Y, Z), edge(X, Z).".into(),
194    ///     }],
195    ///     generator: None,
196    /// };
197    /// assert!(def.validate().is_ok());
198    /// ```
199    pub fn validate(&self) -> Result<(), BenchError> {
200        if self.name.is_empty() {
201            return Err(BenchError::Invalid {
202                name: self.name.clone(),
203                reason: "name must not be empty".to_string(),
204            });
205        }
206
207        // The name doubles as a cache subdir component (and, for static
208        // benchmarks, a downloaded relation's parent dir). The
209        // materialization layer also calls `fs::remove_dir_all` on the
210        // cache subdir during `--force` regeneration, so the name must not
211        // be able to escape it. Restrict to portable filename characters.
212        if !is_portable_filename(&self.name) {
213            return Err(BenchError::Invalid {
214                name: self.name.clone(),
215                reason: "name may only contain ASCII alphanumerics, '.', '_', or '-' (no path \
216                         separators or '..')"
217                    .to_string(),
218            });
219        }
220
221        let has_static = !self.relations.is_empty() || !self.queries.is_empty();
222        match (&self.generator, has_static) {
223            | (Some(_), true) => {
224                return Err(BenchError::Invalid {
225                    name: self.name.clone(),
226                    reason: "benchmark cannot mix `generator` with `relations`/`queries`; pick one"
227                        .to_string(),
228                });
229            },
230            | (None, false) => {
231                return Err(BenchError::Invalid {
232                    name: self.name.clone(),
233                    reason: "benchmark must declare either `relations`+`queries` or `generator`"
234                        .to_string(),
235                });
236            },
237            | (Some(spec), false) => return validate_generator(&self.name, spec),
238            | (None, true) => {},
239        }
240
241        if self.relations.is_empty() {
242            return Err(BenchError::Invalid {
243                name: self.name.clone(),
244                reason: "relations must not be empty".to_string(),
245            });
246        }
247
248        if self.queries.is_empty() {
249            return Err(BenchError::Invalid {
250                name: self.name.clone(),
251                reason: "queries must not be empty".to_string(),
252            });
253        }
254
255        for q in &self.queries {
256            if q.name.is_empty() {
257                return Err(BenchError::Invalid {
258                    name: self.name.clone(),
259                    reason: "query name must not be empty".to_string(),
260                });
261            }
262            if q.query.is_empty() {
263                return Err(BenchError::Invalid {
264                    name: self.name.clone(),
265                    reason: format!("query '{}' has empty query string", q.name),
266                });
267            }
268        }
269
270        let mut seen = HashSet::new();
271        for rel in &self.relations {
272            if !seen.insert(&rel.name) {
273                return Err(BenchError::Invalid {
274                    name: self.name.clone(),
275                    reason: format!("duplicate relation name: {}", rel.name),
276                });
277            }
278        }
279
280        seen.clear();
281        for q in &self.queries {
282            if !seen.insert(&q.name) {
283                return Err(BenchError::Invalid {
284                    name: self.name.clone(),
285                    reason: format!("duplicate query name: {}", q.name),
286                });
287            }
288        }
289
290        Ok(())
291    }
292}
293
294fn validate_generator(bench_name: &str, spec: &GeneratorSpec) -> Result<(), BenchError> {
295    match spec {
296        | GeneratorSpec::Watdiv {
297            scale, ..
298        } => {
299            if *scale == 0 {
300                return Err(BenchError::Invalid {
301                    name: bench_name.to_string(),
302                    reason: "watdiv generator scale must be >= 1".to_string(),
303                });
304            }
305        },
306        | GeneratorSpec::WatdivBasic {
307            scale,
308        } => {
309            if *scale == 0 {
310                return Err(BenchError::Invalid {
311                    name: bench_name.to_string(),
312                    reason: "watdiv-basic generator scale must be >= 1".to_string(),
313                });
314            }
315        },
316        | GeneratorSpec::Lubm {
317            scale,
318            queries,
319            ontology,
320            ..
321        } => {
322            if *scale == 0 {
323                return Err(BenchError::Invalid {
324                    name: bench_name.to_string(),
325                    reason: "lubm generator scale must be >= 1".to_string(),
326                });
327            }
328            if ontology.is_empty() {
329                return Err(BenchError::Invalid {
330                    name: bench_name.to_string(),
331                    reason: "lubm generator ontology must not be empty".to_string(),
332                });
333            }
334            if let Some(qs) = queries {
335                if qs.is_empty() {
336                    return Err(BenchError::Invalid {
337                        name: bench_name.to_string(),
338                        reason: "lubm generator queries list must be non-empty if provided (omit \
339                                 to run all 14)"
340                            .to_string(),
341                    });
342                }
343                let mut seen = HashSet::new();
344                for q in qs {
345                    if !is_valid_lubm_query_name(q) {
346                        return Err(BenchError::Invalid {
347                            name: bench_name.to_string(),
348                            reason: format!("lubm generator query '{q}' is not one of q1..q14"),
349                        });
350                    }
351                    if !seen.insert(q) {
352                        return Err(BenchError::Invalid {
353                            name: bench_name.to_string(),
354                            reason: format!("duplicate lubm query name: {q}"),
355                        });
356                    }
357                }
358            }
359        },
360    }
361    Ok(())
362}
363
364fn is_valid_lubm_query_name(name: &str) -> bool {
365    let Some(rest) = name.strip_prefix('q') else {
366        return false;
367    };
368    // Reject leading zeros (`q01`) so canonical-form `q1`..`q14` is the only
369    // accepted spelling. Otherwise `u32::parse` would happily accept padded
370    // forms here that fail at materialize time when matched exactly against
371    // `lubm_query_specs()`'s canonical names.
372    if rest.len() > 1 && rest.starts_with('0') {
373        return false;
374    }
375    matches!(rest.parse::<u32>(), Ok(n) if (1..=14).contains(&n))
376}
377
378/// Returns true if `name` is safe to use as a cache subdir component.
379/// Restricts to ASCII alphanumerics plus `.`, `_`, `-`. Rejects anything
380/// containing path separators (`/`, `\`) or relative-path tokens (`..`).
381fn is_portable_filename(name: &str) -> bool {
382    if name == "." || name == ".." || name.is_empty() {
383        return false;
384    }
385    name.chars()
386        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    fn make_query(name: &str, query: &str) -> QueryDefinition {
394        QueryDefinition {
395            name: name.to_string(),
396            description: format!("{name} query"),
397            query: query.to_string(),
398        }
399    }
400
401    #[test]
402    fn deserialize_valid_yaml() {
403        let yaml = r#"
404name: triangle
405description: "Triangle query"
406relations:
407  - name: edge
408    url: "https://example.com/edge.parquet"
409queries:
410  - name: triangle
411    description: "Triangle query"
412    query: "T(X, Y, Z) :- edge(X, Y), edge(Y, Z), edge(X, Z)."
413"#;
414        let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
415        assert_eq!(def.name, "triangle");
416        assert_eq!(def.relations.len(), 1);
417        assert_eq!(def.relations[0].name, "edge");
418        assert_eq!(def.queries.len(), 1);
419        assert_eq!(def.queries[0].name, "triangle");
420        assert!(def.validate().is_ok());
421    }
422
423    #[test]
424    fn deserialize_multiple_relations() {
425        let yaml = r#"
426name: path
427description: "Path query"
428relations:
429  - name: edge
430    url: "https://example.com/edge.parquet"
431  - name: node
432    url: "https://example.com/node.parquet"
433queries:
434  - name: path
435    description: "Path query"
436    query: "P(X, Z) :- edge(X, Y), node(Y), edge(Y, Z)."
437"#;
438        let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
439        assert_eq!(def.relations.len(), 2);
440        assert!(def.validate().is_ok());
441    }
442
443    #[test]
444    fn deserialize_multiple_queries() {
445        let yaml = r#"
446name: graph
447description: "Graph queries"
448relations:
449  - name: edge
450    url: "https://example.com/edge.parquet"
451queries:
452  - name: triangle
453    description: "Triangle query"
454    query: "T(X, Y, Z) :- edge(X, Y), edge(Y, Z), edge(X, Z)."
455  - name: two-hop
456    description: "Two-hop path"
457    query: "P(X, Z) :- edge(X, Y), edge(Y, Z)."
458"#;
459        let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
460        assert_eq!(def.name, "graph");
461        assert_eq!(def.queries.len(), 2);
462        assert_eq!(def.queries[0].name, "triangle");
463        assert_eq!(def.queries[1].name, "two-hop");
464        assert!(def.validate().is_ok());
465    }
466
467    #[test]
468    fn validate_empty_name() {
469        let def = BenchmarkDefinition {
470            name: String::new(),
471            description: "test".to_string(),
472            relations: vec![RelationSource {
473                name: "r".to_string(),
474                url: "http://x".to_string(),
475            }],
476            queries: vec![make_query("q", "Q(X) :- r(X).")],
477            generator: None,
478        };
479        assert!(def.validate().is_err());
480    }
481
482    #[test]
483    fn validate_empty_relations() {
484        let def = BenchmarkDefinition {
485            name: "test".to_string(),
486            description: "test".to_string(),
487            relations: vec![],
488            queries: vec![make_query("q", "Q(X) :- r(X).")],
489            generator: None,
490        };
491        assert!(def.validate().is_err());
492    }
493
494    #[test]
495    fn validate_empty_queries() {
496        let def = BenchmarkDefinition {
497            name: "test".to_string(),
498            description: "test".to_string(),
499            relations: vec![RelationSource {
500                name: "r".to_string(),
501                url: "http://x".to_string(),
502            }],
503            queries: vec![],
504            generator: None,
505        };
506        assert!(def.validate().is_err());
507    }
508
509    #[test]
510    fn validate_empty_query_name() {
511        let def = BenchmarkDefinition {
512            name: "test".to_string(),
513            description: "test".to_string(),
514            relations: vec![RelationSource {
515                name: "r".to_string(),
516                url: "http://x".to_string(),
517            }],
518            queries: vec![make_query("", "Q(X) :- r(X).")],
519            generator: None,
520        };
521        assert!(def.validate().is_err());
522    }
523
524    #[test]
525    fn validate_empty_query_string() {
526        let def = BenchmarkDefinition {
527            name: "test".to_string(),
528            description: "test".to_string(),
529            relations: vec![RelationSource {
530                name: "r".to_string(),
531                url: "http://x".to_string(),
532            }],
533            queries: vec![make_query("q", "")],
534            generator: None,
535        };
536        assert!(def.validate().is_err());
537    }
538
539    #[test]
540    fn validate_duplicate_relation_names() {
541        let def = BenchmarkDefinition {
542            name: "test".to_string(),
543            description: "test".to_string(),
544            relations: vec![
545                RelationSource {
546                    name: "edge".to_string(),
547                    url: "http://x".to_string(),
548                },
549                RelationSource {
550                    name: "edge".to_string(),
551                    url: "http://y".to_string(),
552                },
553            ],
554            queries: vec![make_query("q", "Q(X) :- edge(X).")],
555            generator: None,
556        };
557        assert!(def.validate().is_err());
558    }
559
560    #[test]
561    fn validate_duplicate_query_names() {
562        let def = BenchmarkDefinition {
563            name: "test".to_string(),
564            description: "test".to_string(),
565            relations: vec![RelationSource {
566                name: "r".to_string(),
567                url: "http://x".to_string(),
568            }],
569            queries: vec![
570                make_query("q", "Q(X) :- r(X)."),
571                make_query("q", "Q(Y) :- r(Y)."),
572            ],
573            generator: None,
574        };
575        assert!(def.validate().is_err());
576    }
577
578    #[test]
579    fn missing_required_fields_fails_validation() {
580        let yaml = r#"
581name: triangle
582description: "Triangle query"
583relations:
584  - name: edge
585    url: "https://example.com/edge.parquet"
586"#;
587        let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
588        assert!(
589            def.validate().is_err(),
590            "queries-less static benchmark must fail validation"
591        );
592    }
593
594    #[test]
595    fn deserialize_watdiv_generator() {
596        let yaml = r#"
597name: watdiv-100
598description: "watdiv at scale 100"
599generator:
600  kind: watdiv
601  scale: 100
602  stress:
603    max_query_size: 5
604    query_count: 20
605    constants_per_query: 2
606    allow_join_vertex: false
607"#;
608        let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
609        assert!(def.relations.is_empty());
610        assert!(def.queries.is_empty());
611        match def.generator.as_ref().unwrap() {
612            | GeneratorSpec::Watdiv {
613                scale,
614                stress,
615            } => {
616                assert_eq!(*scale, 100);
617                assert_eq!(stress.query_count, 20);
618            },
619            | other => panic!("expected watdiv, got {other:?}"),
620        }
621        assert!(def.validate().is_ok());
622    }
623
624    #[test]
625    fn deserialize_watdiv_generator_with_default_stress() {
626        let yaml = r#"
627name: watdiv-1
628description: "watdiv default stress"
629generator:
630  kind: watdiv
631  scale: 1
632"#;
633        let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
634        match def.generator.as_ref().unwrap() {
635            | GeneratorSpec::Watdiv {
636                stress, ..
637            } => {
638                assert_eq!(stress, &WatdivStressSpec::default());
639            },
640            | _ => panic!("expected watdiv"),
641        }
642        assert!(def.validate().is_ok());
643    }
644
645    #[test]
646    fn deserialize_lubm_generator_full() {
647        let yaml = r#"
648name: lubm-2
649description: "lubm scale 2"
650generator:
651  kind: lubm
652  scale: 2
653  seed: 7
654  threads: 4
655  start_index: 1
656  ontology: "http://example.com/onto"
657  queries: [q1, q3, q14]
658"#;
659        let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
660        match def.generator.as_ref().unwrap() {
661            | GeneratorSpec::Lubm {
662                scale,
663                seed,
664                threads,
665                start_index,
666                ontology,
667                queries,
668            } => {
669                assert_eq!(*scale, 2);
670                assert_eq!(*seed, 7);
671                assert_eq!(*threads, 4);
672                assert_eq!(*start_index, 1);
673                assert_eq!(ontology, "http://example.com/onto");
674                assert_eq!(queries.as_ref().unwrap(), &vec!["q1", "q3", "q14"]);
675            },
676            | _ => panic!("expected lubm"),
677        }
678        assert!(def.validate().is_ok());
679    }
680
681    #[test]
682    fn deserialize_lubm_generator_minimal() {
683        let yaml = r#"
684name: lubm-1
685description: "lubm minimal"
686generator:
687  kind: lubm
688  scale: 1
689"#;
690        let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
691        match def.generator.as_ref().unwrap() {
692            | GeneratorSpec::Lubm {
693                scale,
694                seed,
695                threads,
696                start_index,
697                ontology,
698                queries,
699            } => {
700                assert_eq!(*scale, 1);
701                assert_eq!(*seed, 0);
702                assert_eq!(*threads, 1);
703                assert_eq!(*start_index, 0);
704                assert_eq!(ontology, DEFAULT_LUBM_ONTOLOGY);
705                assert!(queries.is_none());
706            },
707            | _ => panic!("expected lubm"),
708        }
709        assert!(def.validate().is_ok());
710    }
711
712    #[test]
713    fn xor_rejects_generator_with_relations_and_queries() {
714        let yaml = r#"
715name: hybrid
716description: "both"
717relations:
718  - name: r
719    url: "http://x"
720queries:
721  - name: q
722    description: "default"
723    query: "Q(X) :- r(X)."
724generator:
725  kind: watdiv
726  scale: 1
727"#;
728        let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
729        let err = def.validate().unwrap_err();
730        let msg = err.to_string();
731        assert!(msg.contains("cannot mix"), "expected XOR error, got: {msg}");
732    }
733
734    #[test]
735    fn xor_rejects_neither_generator_nor_relations() {
736        let yaml = r#"
737name: empty
738description: "nothing"
739"#;
740        let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
741        let err = def.validate().unwrap_err();
742        let msg = err.to_string();
743        assert!(
744            msg.contains("must declare"),
745            "expected XOR error, got: {msg}"
746        );
747    }
748
749    #[test]
750    fn lubm_invalid_query_name_rejected() {
751        let def = BenchmarkDefinition {
752            name: "lubm-bad".to_string(),
753            description: "bad query".to_string(),
754            relations: vec![],
755            queries: vec![],
756            generator: Some(GeneratorSpec::Lubm {
757                scale: 1,
758                seed: 0,
759                threads: 1,
760                start_index: 0,
761                ontology: DEFAULT_LUBM_ONTOLOGY.to_string(),
762                queries: Some(vec!["q15".to_string()]),
763            }),
764        };
765        assert!(def.validate().is_err());
766    }
767
768    #[test]
769    fn lubm_zero_scale_rejected() {
770        let def = BenchmarkDefinition {
771            name: "lubm-zero".to_string(),
772            description: "zero scale".to_string(),
773            relations: vec![],
774            queries: vec![],
775            generator: Some(GeneratorSpec::Lubm {
776                scale: 0,
777                seed: 0,
778                threads: 1,
779                start_index: 0,
780                ontology: DEFAULT_LUBM_ONTOLOGY.to_string(),
781                queries: None,
782            }),
783        };
784        assert!(def.validate().is_err());
785    }
786
787    #[test]
788    fn watdiv_zero_scale_rejected() {
789        let def = BenchmarkDefinition {
790            name: "watdiv-zero".to_string(),
791            description: "zero scale".to_string(),
792            relations: vec![],
793            queries: vec![],
794            generator: Some(GeneratorSpec::Watdiv {
795                scale: 0,
796                stress: WatdivStressSpec::default(),
797            }),
798        };
799        assert!(def.validate().is_err());
800    }
801
802    #[test]
803    fn spec_hash_is_deterministic() {
804        let a = GeneratorSpec::Watdiv {
805            scale: 10,
806            stress: WatdivStressSpec::default(),
807        };
808        let b = GeneratorSpec::Watdiv {
809            scale: 10,
810            stress: WatdivStressSpec::default(),
811        };
812        assert_eq!(a.spec_hash(), b.spec_hash());
813    }
814
815    #[test]
816    fn spec_hash_differs_on_param_change() {
817        let a = GeneratorSpec::Watdiv {
818            scale: 10,
819            stress: WatdivStressSpec::default(),
820        };
821        let b = GeneratorSpec::Watdiv {
822            scale: 20,
823            stress: WatdivStressSpec::default(),
824        };
825        assert_ne!(a.spec_hash(), b.spec_hash());
826    }
827
828    #[test]
829    fn name_with_path_traversal_rejected() {
830        for evil in [
831            "../escape",
832            "..",
833            ".",
834            "with/slash",
835            "with\\backslash",
836            "has space",
837            "has:colon",
838        ] {
839            let def = BenchmarkDefinition {
840                name: evil.to_string(),
841                description: "x".to_string(),
842                relations: vec![RelationSource {
843                    name: "r".to_string(),
844                    url: "http://x".to_string(),
845                }],
846                queries: vec![make_query("q", "Q(X) :- r(X).")],
847                generator: None,
848            };
849            assert!(
850                def.validate().is_err(),
851                "expected rejection for name = {evil:?}"
852            );
853        }
854    }
855
856    #[test]
857    fn lubm_zero_padded_query_name_rejected() {
858        for bad in ["q01", "q014", "q00", "q001"] {
859            let def = BenchmarkDefinition {
860                name: "lubm-zero-pad".to_string(),
861                description: "x".to_string(),
862                relations: vec![],
863                queries: vec![],
864                generator: Some(GeneratorSpec::Lubm {
865                    scale: 1,
866                    seed: 0,
867                    threads: 1,
868                    start_index: 0,
869                    ontology: DEFAULT_LUBM_ONTOLOGY.to_string(),
870                    queries: Some(vec![bad.to_string()]),
871                }),
872            };
873            assert!(
874                def.validate().is_err(),
875                "expected rejection for query name = {bad:?}"
876            );
877        }
878    }
879
880    #[test]
881    fn deserialize_watdiv_basic_generator() {
882        let yaml = r#"
883name: watdiv-basic
884description: "watdiv basic"
885generator:
886  kind: watdiv-basic
887  scale: 10
888"#;
889        let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
890        def.validate().unwrap();
891        match def.generator.as_ref().unwrap() {
892            | GeneratorSpec::WatdivBasic {
893                scale,
894            } => assert_eq!(*scale, 10),
895            | other => panic!("expected WatdivBasic, got {other:?}"),
896        }
897    }
898
899    #[test]
900    fn watdiv_basic_scale_zero_is_invalid() {
901        let spec = GeneratorSpec::WatdivBasic {
902            scale: 0,
903        };
904        assert!(validate_generator("b", &spec).is_err());
905    }
906
907    #[test]
908    fn watdiv_basic_spec_hash_differs_from_watdiv() {
909        let basic = GeneratorSpec::WatdivBasic {
910            scale: 10,
911        };
912        let stress = GeneratorSpec::Watdiv {
913            scale: 10,
914            stress: WatdivStressSpec::default(),
915        };
916        assert_ne!(basic.spec_hash(), stress.spec_hash());
917    }
918
919    #[test]
920    fn spec_hash_distinguishes_lubm_query_subset() {
921        let a = GeneratorSpec::Lubm {
922            scale: 1,
923            seed: 0,
924            threads: 1,
925            start_index: 0,
926            ontology: DEFAULT_LUBM_ONTOLOGY.to_string(),
927            queries: None,
928        };
929        let b = GeneratorSpec::Lubm {
930            scale: 1,
931            seed: 0,
932            threads: 1,
933            start_index: 0,
934            ontology: DEFAULT_LUBM_ONTOLOGY.to_string(),
935            queries: Some(vec!["q1".to_string(), "q2".to_string()]),
936        };
937        assert_ne!(a.spec_hash(), b.spec_hash());
938    }
939}