1mod metrics;
4mod recognizer;
5mod sampler;
6
7use std::error::Error;
8use std::fmt;
9
10use crate::grammar::Grammar;
11
12pub trait MembershipOracle {
14 fn accepts(&self, text: &str) -> bool;
16}
17
18#[derive(Clone, Copy, Debug)]
20pub struct GrammarOracle<'g>(pub &'g Grammar);
21
22impl<'g> GrammarOracle<'g> {
23 #[must_use]
25 pub const fn new(grammar: &'g Grammar) -> Self {
26 Self(grammar)
27 }
28
29 #[must_use]
31 pub fn accepts(&self, text: &str) -> bool {
32 <Self as MembershipOracle>::accepts(self, text)
33 }
34}
35
36impl MembershipOracle for GrammarOracle<'_> {
37 fn accepts(&self, text: &str) -> bool {
38 recognizer::accepts(self.0, text)
39 }
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub struct SampleConfig {
45 pub seed: u64,
47 pub count: usize,
49 pub max_depth: usize,
51 pub repeat_cap: usize,
53}
54
55impl Default for SampleConfig {
56 fn default() -> Self {
57 Self {
58 seed: 0xD1E5_EED5_17A7_E001,
59 count: 256,
60 max_depth: 16,
61 repeat_cap: 4,
62 }
63 }
64}
65
66#[derive(Clone, Debug, PartialEq)]
68pub struct MetricScores {
69 pub precision: f64,
71 pub recall: f64,
73 pub f1: f64,
75 pub size_symbols: usize,
77 pub mdl_bits: f64,
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum ScoringMode {
84 GoldenGrammar,
86 Corpus,
88}
89
90#[derive(Clone, Debug, PartialEq)]
92pub struct BenchmarkReport {
93 pub corpus: &'static str,
95 pub scores: MetricScores,
97 pub samples_drawn: usize,
99 pub seed: u64,
101 pub scoring_mode: ScoringMode,
103}
104
105#[derive(Clone, Debug, PartialEq, Eq)]
107pub enum EvalError {
108 EmptyGrammar,
110 EmptyCorpus,
112 EmptySample {
114 source: &'static str,
116 },
117 NonTerminating {
119 rule: String,
121 },
122 UnknownRule {
124 rule: String,
126 },
127 InvalidCharRange {
129 start: char,
131 end: char,
133 },
134 EmptyCharClass,
136 InvalidRepeat {
138 min: usize,
140 max: usize,
142 },
143 CorpusNotFound {
145 corpus: String,
147 },
148}
149
150impl fmt::Display for EvalError {
151 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
152 match self {
153 Self::EmptyGrammar => formatter.write_str("grammar has no start rule"),
154 Self::EmptyCorpus => formatter.write_str("corpus-mode recall requires positives"),
155 Self::EmptySample { source } => write!(formatter, "{source} sampling produced no text"),
156 Self::NonTerminating { rule } => write!(formatter, "rule `{rule}` cannot terminate"),
157 Self::UnknownRule { rule } => write!(formatter, "unknown grammar rule `{rule}`"),
158 Self::InvalidCharRange { start, end } => {
159 write!(formatter, "invalid character range {start:?}..={end:?}")
160 }
161 Self::EmptyCharClass => formatter.write_str("character class has no sampleable member"),
162 Self::InvalidRepeat { min, max } => {
163 write!(formatter, "invalid repetition bounds {min}..={max}")
164 }
165 Self::CorpusNotFound { corpus } => write!(formatter, "unknown corpus `{corpus}`"),
166 }
167 }
168}
169
170impl Error for EvalError {}
171
172#[derive(Clone, Copy, Debug)]
174pub struct GoldenCorpus {
175 name: &'static str,
176 positives: &'static [&'static str],
177 golden_grammar: fn() -> Grammar,
178}
179
180impl GoldenCorpus {
181 #[must_use]
183 pub const fn new(
184 name: &'static str,
185 positives: &'static [&'static str],
186 golden_grammar: fn() -> Grammar,
187 ) -> Self {
188 Self {
189 name,
190 positives,
191 golden_grammar,
192 }
193 }
194
195 #[must_use]
197 pub const fn name(&self) -> &'static str {
198 self.name
199 }
200
201 #[must_use]
203 pub const fn positives(&self) -> &'static [&'static str] {
204 self.positives
205 }
206
207 #[must_use]
209 pub fn golden_grammar(&self) -> Grammar {
210 (self.golden_grammar)()
211 }
212}
213
214const LIST_POSITIVES: &[&str] = &["a", "b", "a,b", "b,a"];
215const ASSIGNMENT_POSITIVES: &[&str] = &["let a=1;", "let b=2;", "let x=9;"];
216
217pub const GOLDEN_CORPORA: &[GoldenCorpus] = &[
219 GoldenCorpus::new("inference-eval:list", LIST_POSITIVES, list_corpus_grammar),
220 GoldenCorpus::new(
221 "inference-eval:assignment",
222 ASSIGNMENT_POSITIVES,
223 assignment_corpus_grammar,
224 ),
225];
226
227pub fn sample(grammar: &Grammar, config: &SampleConfig) -> Result<Vec<String>, EvalError> {
234 sampler::sample(grammar, config)
235}
236
237pub fn evaluate(
244 inferred: &Grammar,
245 golden: &dyn MembershipOracle,
246 golden_sampler: Option<&Grammar>,
247 positives: &[&str],
248 config: &SampleConfig,
249) -> Result<MetricScores, EvalError> {
250 evaluate_outcome(inferred, golden, golden_sampler, positives, config)
251 .map(|outcome| outcome.scores)
252}
253
254pub fn run_corpus(
256 corpus: &GoldenCorpus,
257 inferred: &Grammar,
258 config: &SampleConfig,
259) -> Result<BenchmarkReport, EvalError> {
260 let golden = corpus.golden_grammar();
261 let oracle = GrammarOracle(&golden);
262 let outcome = evaluate_outcome(inferred, &oracle, Some(&golden), corpus.positives(), config)?;
263
264 Ok(BenchmarkReport {
265 corpus: corpus.name(),
266 scores: outcome.scores,
267 samples_drawn: outcome.samples_drawn,
268 seed: config.seed,
269 scoring_mode: outcome.scoring_mode,
270 })
271}
272
273pub fn run_named_corpus(
275 corpus: &str,
276 inferred: &Grammar,
277 config: &SampleConfig,
278) -> Result<BenchmarkReport, EvalError> {
279 let descriptor = GOLDEN_CORPORA
280 .iter()
281 .find(|candidate| candidate.name() == corpus)
282 .ok_or_else(|| EvalError::CorpusNotFound {
283 corpus: corpus.to_string(),
284 })?;
285 run_corpus(descriptor, inferred, config)
286}
287
288#[must_use]
290pub fn size_symbols(grammar: &Grammar) -> usize {
291 metrics::size_symbols(grammar)
292}
293
294#[must_use]
302pub fn mdl(grammar: &Grammar, data: &[&str]) -> f64 {
303 metrics::mdl(grammar, data)
304}
305
306#[derive(Clone, Debug)]
307struct EvaluationOutcome {
308 scores: MetricScores,
309 samples_drawn: usize,
310 scoring_mode: ScoringMode,
311}
312
313fn evaluate_outcome(
314 inferred: &Grammar,
315 golden: &dyn MembershipOracle,
316 golden_sampler: Option<&Grammar>,
317 positives: &[&str],
318 config: &SampleConfig,
319) -> Result<EvaluationOutcome, EvalError> {
320 let inferred_samples = sample(inferred, config)?;
321 if inferred_samples.is_empty() {
322 return Err(EvalError::EmptySample { source: "inferred" });
323 }
324
325 let precision_hits = inferred_samples
326 .iter()
327 .filter(|text| golden.accepts(text))
328 .count();
329 let precision = metrics::ratio(precision_hits, inferred_samples.len());
330 let inferred_oracle = GrammarOracle(inferred);
331
332 let (recall, mdl_bits, samples_drawn, scoring_mode) =
333 if let Some(golden_grammar) = golden_sampler {
334 let reference_samples = sample(golden_grammar, config)?;
335 if reference_samples.is_empty() {
336 return Err(EvalError::EmptySample { source: "golden" });
337 }
338 let recall_hits = reference_samples
339 .iter()
340 .filter(|text| inferred_oracle.accepts(text))
341 .count();
342 let data = reference_samples
343 .iter()
344 .map(String::as_str)
345 .collect::<Vec<_>>();
346 (
347 metrics::ratio(recall_hits, reference_samples.len()),
348 mdl(inferred, &data),
349 inferred_samples
350 .len()
351 .saturating_add(reference_samples.len()),
352 ScoringMode::GoldenGrammar,
353 )
354 } else {
355 if positives.is_empty() {
356 return Err(EvalError::EmptyCorpus);
357 }
358 let recall_hits = positives
359 .iter()
360 .filter(|text| inferred_oracle.accepts(text))
361 .count();
362 (
363 metrics::ratio(recall_hits, positives.len()),
364 mdl(inferred, positives),
365 inferred_samples.len().saturating_add(positives.len()),
366 ScoringMode::Corpus,
367 )
368 };
369
370 let f1 = if precision + recall == 0.0 {
371 0.0
372 } else {
373 2.0 * precision * recall / (precision + recall)
374 };
375
376 Ok(EvaluationOutcome {
377 scores: MetricScores {
378 precision,
379 recall,
380 f1,
381 size_symbols: size_symbols(inferred),
382 mdl_bits,
383 },
384 samples_drawn,
385 scoring_mode,
386 })
387}
388
389fn list_corpus_grammar() -> Grammar {
390 let expr = Grammar::expr();
391 Grammar::builder()
392 .start("list")
393 .rule(
394 "list",
395 expr.seq([
396 expr.nt("item"),
397 expr.rep0(expr.seq([expr.term(","), expr.nt("item")])),
398 ]),
399 )
400 .rule(
401 "item",
402 expr.choice_unordered([expr.term("a"), expr.term("b")]),
403 )
404 .build()
405}
406
407fn assignment_corpus_grammar() -> Grammar {
408 let expr = Grammar::expr();
409 Grammar::builder()
410 .start("assignment")
411 .rule(
412 "assignment",
413 expr.seq([
414 expr.term("let "),
415 expr.nt("letter"),
416 expr.term("="),
417 expr.nt("digit"),
418 expr.term(";"),
419 ]),
420 )
421 .rule("letter", expr.char_range('a', 'z'))
422 .rule("digit", expr.char_range('0', '9'))
423 .build()
424}