1use std::collections::{BTreeMap, BTreeSet};
10use std::error::Error;
11use std::fmt;
12use std::fmt::Write as _;
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::time::Instant;
16
17use serde_json::Value;
18
19use crate::{
20 emit_gbnf, evaluate, infer_cfg, Grammar, GrammarExpr, GrammarFormat, GrammarOracle,
21 GrammarRule, InferenceOptions, MetricScores, PositiveOnlyOracle, SampleConfig, ScoringMode,
22};
23
24pub const DEFAULT_CORPUS_MANIFEST: &str = "benches/corpus-manifest.json";
26pub const DEFAULT_CORPORA_ROOT: &str = "benches/corpora";
28pub const PUBLISHED_TREEVADA_AVG_F1: f64 = 0.32;
30pub const PUBLISHED_NATGI_AVG_F1: f64 = 0.57;
32pub const D5_REQUIRED_AVG_F1: f64 = PUBLISHED_NATGI_AVG_F1;
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct CompetitorManifest {
38 pub schema: u64,
40 pub entries: Vec<CorpusManifestEntry>,
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct CorpusManifestEntry {
47 pub tool: String,
49 pub subject: String,
51 pub source: String,
53 pub commit: String,
55 pub license: String,
57 pub files: usize,
59 pub bytes: u64,
61 pub included: bool,
63 pub exclude_reason: String,
65 pub example_paths: Vec<String>,
67 pub golden: String,
69}
70
71impl CorpusManifestEntry {
72 #[must_use]
74 pub fn id(&self) -> String {
75 format!("{}/{}", self.tool, self.subject)
76 }
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct SkippedCorpus {
82 pub id: String,
84 pub reason: String,
86}
87
88#[derive(Clone, Debug, PartialEq)]
90pub struct CompetitorRun {
91 pub id: String,
93 pub tool: String,
95 pub subject: String,
97 pub examples: usize,
99 pub scores: MetricScores,
101 pub samples_drawn: usize,
103 pub seed: u64,
105 pub scoring_mode: ScoringMode,
107 pub wall_clock_ms: u128,
109 pub inferred_rules: usize,
111 pub required_f1: f64,
113 pub gbnf_emitted: bool,
115}
116
117#[derive(Clone, Debug, PartialEq, Eq)]
119pub struct SecondaryMetricRow {
120 pub metric: &'static str,
122 pub value: String,
124}
125
126#[derive(Clone, Debug, PartialEq, Eq)]
128pub struct BenchmarkFailure {
129 pub corpus: Option<String>,
131 pub message: String,
133}
134
135impl BenchmarkFailure {
136 fn new(corpus: Option<String>, message: impl Into<String>) -> Self {
137 Self {
138 corpus,
139 message: message.into(),
140 }
141 }
142}
143
144#[derive(Clone, Debug, PartialEq)]
146pub struct CompetitorSuiteReport {
147 pub seed: u64,
149 pub runs: Vec<CompetitorRun>,
151 pub skipped: Vec<SkippedCorpus>,
153 pub secondary: Vec<SecondaryMetricRow>,
155 pub failures: Vec<BenchmarkFailure>,
157}
158
159#[derive(Debug)]
161pub enum BenchmarkError {
162 Io {
164 path: PathBuf,
166 source: std::io::Error,
168 },
169 Json {
171 path: PathBuf,
173 source: serde_json::Error,
175 },
176 Manifest(String),
178}
179
180impl fmt::Display for BenchmarkError {
181 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
182 match self {
183 Self::Io { path, source } => {
184 write!(formatter, "{}: {source}", path.display())
185 }
186 Self::Json { path, source } => {
187 write!(formatter, "{}: {source}", path.display())
188 }
189 Self::Manifest(message) => formatter.write_str(message),
190 }
191 }
192}
193
194impl Error for BenchmarkError {
195 fn source(&self) -> Option<&(dyn Error + 'static)> {
196 match self {
197 Self::Io { source, .. } => Some(source),
198 Self::Json { source, .. } => Some(source),
199 Self::Manifest(_) => None,
200 }
201 }
202}
203
204pub fn run_competitor_suite(
210 config: &SampleConfig,
211) -> Result<CompetitorSuiteReport, BenchmarkError> {
212 run_competitor_suite_from_paths(
213 Path::new(DEFAULT_CORPUS_MANIFEST),
214 Path::new(DEFAULT_CORPORA_ROOT),
215 config,
216 )
217}
218
219pub fn run_competitor_suite_from_paths(
225 manifest_path: &Path,
226 corpora_root: &Path,
227 config: &SampleConfig,
228) -> Result<CompetitorSuiteReport, BenchmarkError> {
229 let manifest = load_manifest(manifest_path)?;
230 let mut report = validate_manifest(&manifest, corpora_root)?;
231 report.seed = config.seed;
232
233 for entry in manifest.entries.iter().filter(|entry| entry.included) {
234 match run_manifest_entry(entry, corpora_root, config) {
235 Ok(run) => {
236 if run.scores.f1 < run.required_f1 {
237 report.failures.push(BenchmarkFailure::new(
238 Some(run.id.clone()),
239 format!(
240 "F1 {:.3} is below required NatGI bar {:.3}",
241 run.scores.f1, run.required_f1
242 ),
243 ));
244 }
245 report.runs.push(run);
246 }
247 Err(message) => {
248 report
249 .failures
250 .push(BenchmarkFailure::new(Some(entry.id()), message));
251 }
252 }
253 }
254
255 if report.runs.is_empty() {
256 report.failures.push(BenchmarkFailure::new(
257 None,
258 "manifest includes no always-on competitor corpus subjects",
259 ));
260 }
261
262 report.secondary = secondary_rows(&report.runs);
263 Ok(report)
264}
265
266pub fn load_manifest(path: &Path) -> Result<CompetitorManifest, BenchmarkError> {
272 let text = fs::read_to_string(path).map_err(|source| BenchmarkError::Io {
273 path: path.to_path_buf(),
274 source,
275 })?;
276 let value = serde_json::from_str::<Value>(&text).map_err(|source| BenchmarkError::Json {
277 path: path.to_path_buf(),
278 source,
279 })?;
280 parse_manifest(&value)
281}
282
283#[must_use]
285pub fn render_competitor_report(report: &CompetitorSuiteReport) -> String {
286 let mut output = String::new();
287 let _ = writeln!(
288 output,
289 "D1/D5 competitor benchmark suite (seed {})",
290 report.seed
291 );
292 let _ = writeln!(
293 output,
294 "Published bars: TreeVada avg F1 ~= {PUBLISHED_TREEVADA_AVG_F1:.2}; NatGI avg F1 ~= {PUBLISHED_NATGI_AVG_F1:.2}"
295 );
296
297 if !report.skipped.is_empty() {
298 let _ = writeln!(output);
299 for skipped in &report.skipped {
300 let _ = writeln!(output, "SKIPPED {}: {}", skipped.id, skipped.reason);
301 }
302 }
303
304 let _ = writeln!(output);
305 let _ = writeln!(
306 output,
307 "| corpus | examples | precision | recall | F1 | required F1 | wall-clock ms | rules | samples |"
308 );
309 let _ = writeln!(output, "|---|---:|---:|---:|---:|---:|---:|---:|---:|");
310 for run in &report.runs {
311 let _ = writeln!(
312 output,
313 "| {} | {} | {:.3} | {:.3} | {:.3} | {:.3} | {} | {} | {} |",
314 run.id,
315 run.examples,
316 run.scores.precision,
317 run.scores.recall,
318 run.scores.f1,
319 run.required_f1,
320 run.wall_clock_ms,
321 run.inferred_rules,
322 run.samples_drawn
323 );
324 }
325
326 let _ = writeln!(output);
327 let _ = writeln!(output, "| secondary metric | value |");
328 let _ = writeln!(output, "|---|---|");
329 for row in &report.secondary {
330 let _ = writeln!(output, "| {} | {} |", row.metric, row.value);
331 }
332
333 if !report.failures.is_empty() {
334 let _ = writeln!(output);
335 let _ = writeln!(output, "Failures:");
336 for failure in &report.failures {
337 match &failure.corpus {
338 Some(corpus) => {
339 let _ = writeln!(output, "- {corpus}: {}", failure.message);
340 }
341 None => {
342 let _ = writeln!(output, "- {}", failure.message);
343 }
344 }
345 }
346 }
347
348 output
349}
350
351fn parse_manifest(value: &Value) -> Result<CompetitorManifest, BenchmarkError> {
352 let object = value
353 .as_object()
354 .ok_or_else(|| BenchmarkError::Manifest("manifest root must be a JSON object".into()))?;
355 let schema = required_u64(object, "schema")?;
356 let corpus = object
357 .get("corpus")
358 .and_then(Value::as_array)
359 .ok_or_else(|| BenchmarkError::Manifest("manifest corpus must be an array".into()))?;
360 let mut entries = Vec::with_capacity(corpus.len());
361 for (index, entry) in corpus.iter().enumerate() {
362 let object = entry.as_object().ok_or_else(|| {
363 BenchmarkError::Manifest(format!("manifest corpus[{index}] must be an object"))
364 })?;
365 entries.push(CorpusManifestEntry {
366 tool: required_string(object, "tool")?,
367 subject: required_string(object, "subject")?,
368 source: required_string(object, "source")?,
369 commit: required_string(object, "commit")?,
370 license: required_string(object, "license")?,
371 files: usize::try_from(required_u64(object, "files")?).map_err(|_| {
372 BenchmarkError::Manifest(format!("manifest corpus[{index}].files overflows usize"))
373 })?,
374 bytes: required_u64(object, "bytes")?,
375 included: required_bool(object, "included")?,
376 exclude_reason: required_string(object, "exclude_reason")?,
377 example_paths: required_string_array(object, "example_paths")?,
378 golden: required_string(object, "golden")?,
379 });
380 }
381
382 Ok(CompetitorManifest { schema, entries })
383}
384
385fn required_string(
386 object: &serde_json::Map<String, Value>,
387 key: &str,
388) -> Result<String, BenchmarkError> {
389 object
390 .get(key)
391 .and_then(Value::as_str)
392 .map(ToOwned::to_owned)
393 .ok_or_else(|| BenchmarkError::Manifest(format!("manifest field `{key}` must be a string")))
394}
395
396fn required_u64(object: &serde_json::Map<String, Value>, key: &str) -> Result<u64, BenchmarkError> {
397 object
398 .get(key)
399 .and_then(Value::as_u64)
400 .ok_or_else(|| BenchmarkError::Manifest(format!("manifest field `{key}` must be a u64")))
401}
402
403fn required_bool(
404 object: &serde_json::Map<String, Value>,
405 key: &str,
406) -> Result<bool, BenchmarkError> {
407 object
408 .get(key)
409 .and_then(Value::as_bool)
410 .ok_or_else(|| BenchmarkError::Manifest(format!("manifest field `{key}` must be a bool")))
411}
412
413fn required_string_array(
414 object: &serde_json::Map<String, Value>,
415 key: &str,
416) -> Result<Vec<String>, BenchmarkError> {
417 object
418 .get(key)
419 .and_then(Value::as_array)
420 .ok_or_else(|| {
421 BenchmarkError::Manifest(format!("manifest field `{key}` must be an array"))
422 })?
423 .iter()
424 .map(|value| {
425 value.as_str().map(ToOwned::to_owned).ok_or_else(|| {
426 BenchmarkError::Manifest(format!("manifest field `{key}` must contain strings"))
427 })
428 })
429 .collect()
430}
431
432fn validate_manifest(
433 manifest: &CompetitorManifest,
434 corpora_root: &Path,
435) -> Result<CompetitorSuiteReport, BenchmarkError> {
436 let mut report = CompetitorSuiteReport {
437 seed: 0,
438 runs: Vec::new(),
439 skipped: Vec::new(),
440 secondary: Vec::new(),
441 failures: Vec::new(),
442 };
443 let mut entries = BTreeMap::<String, &CorpusManifestEntry>::new();
444
445 for entry in &manifest.entries {
446 let id = entry.id();
447 if entries.insert(id.clone(), entry).is_some() {
448 report.failures.push(BenchmarkFailure::new(
449 Some(id.clone()),
450 "duplicate manifest entry",
451 ));
452 }
453
454 if !entry.included {
455 if entry.exclude_reason.trim().is_empty() {
456 report.failures.push(BenchmarkFailure::new(
457 Some(id.clone()),
458 "excluded corpus must provide a non-empty exclude_reason",
459 ));
460 } else {
461 report.skipped.push(SkippedCorpus {
462 id: id.clone(),
463 reason: entry.exclude_reason.clone(),
464 });
465 }
466 }
467
468 let subject_dir = corpora_root.join(&entry.tool).join(&entry.subject);
469 if !subject_dir.is_dir() {
470 report.failures.push(BenchmarkFailure::new(
471 Some(id.clone()),
472 format!(
473 "vendored subject directory {} is missing",
474 subject_dir.display()
475 ),
476 ));
477 continue;
478 }
479
480 let (files, bytes) =
481 count_subject_files(&subject_dir).map_err(|source| BenchmarkError::Io {
482 path: subject_dir.clone(),
483 source,
484 })?;
485 if files != entry.files {
486 report.failures.push(BenchmarkFailure::new(
487 Some(id.clone()),
488 format!("manifest files={} but vendored files={files}", entry.files),
489 ));
490 }
491 if bytes != entry.bytes {
492 report.failures.push(BenchmarkFailure::new(
493 Some(id.clone()),
494 format!("manifest bytes={} but vendored bytes={bytes}", entry.bytes),
495 ));
496 }
497
498 if entry.included && entry.example_paths.is_empty() {
499 report.failures.push(BenchmarkFailure::new(
500 Some(id),
501 "included corpus must list at least one example path",
502 ));
503 }
504 }
505
506 for subject in vendored_subjects(corpora_root)? {
507 if !entries.contains_key(&subject) {
508 report.failures.push(BenchmarkFailure::new(
509 Some(subject),
510 "vendored subject is missing from manifest",
511 ));
512 }
513 }
514
515 Ok(report)
516}
517
518fn run_manifest_entry(
519 entry: &CorpusManifestEntry,
520 corpora_root: &Path,
521 config: &SampleConfig,
522) -> Result<CompetitorRun, String> {
523 if entry.golden != "exact_examples" {
524 return Err(format!(
525 "unsupported included golden oracle mode `{}`",
526 entry.golden
527 ));
528 }
529
530 let id = entry.id();
531 let subject_dir = corpora_root.join(&entry.tool).join(&entry.subject);
532 let examples = load_examples(&subject_dir, &entry.example_paths)
533 .map_err(|error| format!("failed to load examples: {error}"))?;
534 if examples.is_empty() {
535 return Err("included corpus loaded no positive examples".into());
536 }
537
538 let start = Instant::now();
539 let inferred = infer_cfg(&examples, &PositiveOnlyOracle, InferenceOptions::default());
540 let golden = exact_examples_grammar(&examples);
541 let oracle = GrammarOracle::new(&golden);
542 let positive_refs = examples.iter().map(String::as_str).collect::<Vec<_>>();
543 let scores = evaluate(
544 &inferred.grammar,
545 &oracle,
546 Some(&golden),
547 &positive_refs,
548 config,
549 )
550 .map_err(|error| format!("D1 evaluation failed: {error}"))?;
551 let samples_drawn = sample_count(&inferred.grammar, &golden, config)
552 .map_err(|error| format!("D1 sample accounting failed: {error}"))?;
553 let gbnf_emitted = emit_gbnf(&inferred.grammar).is_ok_and(|(text, _)| !text.trim().is_empty());
554
555 Ok(CompetitorRun {
556 id,
557 tool: entry.tool.clone(),
558 subject: entry.subject.clone(),
559 examples: examples.len(),
560 scores,
561 samples_drawn,
562 seed: config.seed,
563 scoring_mode: ScoringMode::GoldenGrammar,
564 wall_clock_ms: start.elapsed().as_millis(),
565 inferred_rules: inferred.report.rules,
566 required_f1: D5_REQUIRED_AVG_F1,
567 gbnf_emitted,
568 })
569}
570
571fn sample_count(
572 inferred: &Grammar,
573 golden: &Grammar,
574 config: &SampleConfig,
575) -> Result<usize, crate::EvalError> {
576 let inferred_count = crate::sample(inferred, config)?.len();
577 let golden_count = crate::sample(golden, config)?.len();
578 Ok(inferred_count.saturating_add(golden_count))
579}
580
581fn exact_examples_grammar(examples: &[String]) -> Grammar {
582 let alternatives = examples.iter().map(|example| {
583 if example.is_empty() {
584 GrammarExpr::Empty
585 } else {
586 GrammarExpr::Terminal(example.clone())
587 }
588 });
589 Grammar::new()
590 .with_source_format(GrammarFormat::Inferred)
591 .with_rule(GrammarRule::new(
592 "Root",
593 finish_choice(alternatives.collect()),
594 ))
595 .with_start("Root")
596}
597
598fn finish_choice(alternatives: Vec<GrammarExpr>) -> GrammarExpr {
599 let mut unique = BTreeMap::<String, GrammarExpr>::new();
600 for alternative in alternatives {
601 unique
602 .entry(format!("{alternative:?}"))
603 .or_insert(alternative);
604 }
605
606 match unique.len() {
607 0 => GrammarExpr::Empty,
608 1 => unique
609 .into_values()
610 .next()
611 .expect("one choice alternative must exist"),
612 _ => GrammarExpr::Choice {
613 ordered: false,
614 alternatives: unique.into_values().collect(),
615 },
616 }
617}
618
619fn load_examples(subject_dir: &Path, example_paths: &[String]) -> Result<Vec<String>, String> {
620 let mut files = Vec::new();
621 for relative in example_paths {
622 let path = subject_dir.join(relative);
623 if path.is_file() {
624 files.push(path);
625 } else if path.is_dir() {
626 collect_files(&path, &mut files)
627 .map_err(|error| format!("{}: {error}", path.display()))?;
628 } else {
629 return Err(format!("example path {} is missing", path.display()));
630 }
631 }
632 files.sort();
633 files.dedup();
634
635 files
636 .iter()
637 .map(|path| {
638 fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))
639 })
640 .collect()
641}
642
643fn count_subject_files(path: &Path) -> Result<(usize, u64), std::io::Error> {
644 let mut files = Vec::new();
645 collect_files(path, &mut files)?;
646 let mut bytes = 0u64;
647 for file in &files {
648 bytes = bytes.saturating_add(fs::metadata(file)?.len());
649 }
650 Ok((files.len(), bytes))
651}
652
653fn collect_files(path: &Path, files: &mut Vec<PathBuf>) -> Result<(), std::io::Error> {
654 for entry in fs::read_dir(path)? {
655 let entry = entry?;
656 let path = entry.path();
657 if path.is_dir() {
658 collect_files(&path, files)?;
659 } else if path.is_file() {
660 files.push(path);
661 }
662 }
663 Ok(())
664}
665
666fn vendored_subjects(corpora_root: &Path) -> Result<BTreeSet<String>, BenchmarkError> {
667 let mut subjects = BTreeSet::new();
668 let tools = fs::read_dir(corpora_root).map_err(|source| BenchmarkError::Io {
669 path: corpora_root.to_path_buf(),
670 source,
671 })?;
672 for tool in tools {
673 let tool = tool.map_err(|source| BenchmarkError::Io {
674 path: corpora_root.to_path_buf(),
675 source,
676 })?;
677 let tool_path = tool.path();
678 if !tool_path.is_dir() {
679 continue;
680 }
681 let tool_name = tool.file_name().to_string_lossy().into_owned();
682 for subject in fs::read_dir(&tool_path).map_err(|source| BenchmarkError::Io {
683 path: tool_path.clone(),
684 source,
685 })? {
686 let subject = subject.map_err(|source| BenchmarkError::Io {
687 path: tool_path.clone(),
688 source,
689 })?;
690 let subject_path = subject.path();
691 if subject_path.is_dir() {
692 let subject_name = subject.file_name().to_string_lossy().into_owned();
693 subjects.insert(format!("{tool_name}/{subject_name}"));
694 }
695 }
696 }
697 Ok(subjects)
698}
699
700fn secondary_rows(runs: &[CompetitorRun]) -> Vec<SecondaryMetricRow> {
701 let gbnf_successes = runs.iter().filter(|run| run.gbnf_emitted).count();
702
703 vec![
704 SecondaryMetricRow {
705 metric: "format coverage",
706 value: "n/a (pending B*/C* cross-format coverage aggregation)".to_string(),
707 },
708 SecondaryMetricRow {
709 metric: "round-trip fidelity",
710 value: "n/a (pending F2 fidelity matrix)".to_string(),
711 },
712 SecondaryMetricRow {
713 metric: "GBNF emit",
714 value: format!(
715 "{gbnf_successes}/{} included grammars emitted non-empty GBNF",
716 runs.len()
717 ),
718 },
719 SecondaryMetricRow {
720 metric: "cross-language translation",
721 value: "n/a (pending C6 full metric wiring)".to_string(),
722 },
723 ]
724}