Skip to main content

meta_language/grammar/inference/
eval.rs

1//! Deterministic evaluation utilities for inferred grammars.
2
3mod metrics;
4mod recognizer;
5mod sampler;
6
7use std::error::Error;
8use std::fmt;
9
10use crate::grammar::Grammar;
11
12/// Decides language membership for a target language.
13pub trait MembershipOracle {
14    /// Returns `true` when `text` belongs to the oracle's language.
15    fn accepts(&self, text: &str) -> bool;
16}
17
18/// [`Grammar`] backed membership oracle.
19#[derive(Clone, Copy, Debug)]
20pub struct GrammarOracle<'g>(pub &'g Grammar);
21
22impl<'g> GrammarOracle<'g> {
23    /// Builds an oracle over `grammar`.
24    #[must_use]
25    pub const fn new(grammar: &'g Grammar) -> Self {
26        Self(grammar)
27    }
28
29    /// Returns `true` when `text` is accepted by the wrapped grammar.
30    #[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/// Deterministic sampler configuration.
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub struct SampleConfig {
45    /// Deterministic PRNG seed.
46    pub seed: u64,
47    /// Number of derivations to draw before duplicate removal.
48    pub count: usize,
49    /// Maximum non-terminal recursion depth before shortest terminating choices are forced.
50    pub max_depth: usize,
51    /// Maximum generated repetitions for `*`, `+`, and unbounded counted repetition.
52    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/// Primary inference-evaluation metrics.
67#[derive(Clone, Debug, PartialEq)]
68pub struct MetricScores {
69    /// Fraction of inferred samples accepted by the golden oracle.
70    pub precision: f64,
71    /// Fraction of golden samples or held-out positives accepted by the inferred grammar.
72    pub recall: f64,
73    /// Harmonic mean of [`Self::precision`] and [`Self::recall`].
74    pub f1: f64,
75    /// Raw grammar size measured as rule-name symbols plus expression nodes.
76    pub size_symbols: usize,
77    /// Deterministic two-part MDL score in bits; lower is better.
78    pub mdl_bits: f64,
79}
80
81/// Recall source used by an evaluation report.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum ScoringMode {
84    /// Recall was measured by sampling a golden grammar.
85    GoldenGrammar,
86    /// Recall was measured against held-out positive corpus examples.
87    Corpus,
88}
89
90/// End-to-end benchmark result for one corpus.
91#[derive(Clone, Debug, PartialEq)]
92pub struct BenchmarkReport {
93    /// Corpus identifier.
94    pub corpus: &'static str,
95    /// Metric values for this corpus run.
96    pub scores: MetricScores,
97    /// Number of unique samples/examples considered by precision and recall.
98    pub samples_drawn: usize,
99    /// Sampler seed used for this run.
100    pub seed: u64,
101    /// Recall source used for this report.
102    pub scoring_mode: ScoringMode,
103}
104
105/// Evaluation failure.
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub enum EvalError {
108    /// The grammar has no start rule to sample or recognize.
109    EmptyGrammar,
110    /// Corpus-mode recall was requested without held-out positive examples.
111    EmptyCorpus,
112    /// Sampling produced no unique strings for the named source.
113    EmptySample {
114        /// Source that produced no samples.
115        source: &'static str,
116    },
117    /// A reachable rule cannot produce a finite string.
118    NonTerminating {
119        /// Rule name that cannot terminate.
120        rule: String,
121    },
122    /// A reachable non-terminal references a missing rule.
123    UnknownRule {
124        /// Missing rule name.
125        rule: String,
126    },
127    /// A character range has its start after its end.
128    InvalidCharRange {
129        /// Inclusive range start.
130        start: char,
131        /// Inclusive range end.
132        end: char,
133    },
134    /// A character class has no character that the sampler can emit.
135    EmptyCharClass,
136    /// A counted repetition has a maximum smaller than its minimum.
137    InvalidRepeat {
138        /// Minimum repetition count.
139        min: usize,
140        /// Maximum repetition count.
141        max: usize,
142    },
143    /// A named corpus was not registered.
144    CorpusNotFound {
145        /// Requested corpus name.
146        corpus: String,
147    },
148    /// A reachable expression is a grammar feature-union form the sampler and
149    /// recognizer do not evaluate; the native grammar executor runs it.
150    UnsupportedExpression {
151        /// Head word of the form, such as `precedence`.
152        construct: String,
153    },
154}
155
156impl fmt::Display for EvalError {
157    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
158        match self {
159            Self::EmptyGrammar => formatter.write_str("grammar has no start rule"),
160            Self::EmptyCorpus => formatter.write_str("corpus-mode recall requires positives"),
161            Self::EmptySample { source } => write!(formatter, "{source} sampling produced no text"),
162            Self::NonTerminating { rule } => write!(formatter, "rule `{rule}` cannot terminate"),
163            Self::UnknownRule { rule } => write!(formatter, "unknown grammar rule `{rule}`"),
164            Self::InvalidCharRange { start, end } => {
165                write!(formatter, "invalid character range {start:?}..={end:?}")
166            }
167            Self::EmptyCharClass => formatter.write_str("character class has no sampleable member"),
168            Self::InvalidRepeat { min, max } => {
169                write!(formatter, "invalid repetition bounds {min}..={max}")
170            }
171            Self::CorpusNotFound { corpus } => write!(formatter, "unknown corpus `{corpus}`"),
172            Self::UnsupportedExpression { construct } => {
173                write!(
174                    formatter,
175                    "sampling does not evaluate `{construct}` expressions"
176                )
177            }
178        }
179    }
180}
181
182impl Error for EvalError {}
183
184/// Registry entry for an in-repository golden corpus.
185#[derive(Clone, Copy, Debug)]
186pub struct GoldenCorpus {
187    name: &'static str,
188    positives: &'static [&'static str],
189    golden_grammar: fn() -> Grammar,
190}
191
192impl GoldenCorpus {
193    /// Builds a corpus descriptor.
194    #[must_use]
195    pub const fn new(
196        name: &'static str,
197        positives: &'static [&'static str],
198        golden_grammar: fn() -> Grammar,
199    ) -> Self {
200        Self {
201            name,
202            positives,
203            golden_grammar,
204        }
205    }
206
207    /// Corpus identifier.
208    #[must_use]
209    pub const fn name(&self) -> &'static str {
210        self.name
211    }
212
213    /// Held-out positive examples for corpus-mode recall or MDL data.
214    #[must_use]
215    pub const fn positives(&self) -> &'static [&'static str] {
216        self.positives
217    }
218
219    /// Builds the corpus golden grammar.
220    #[must_use]
221    pub fn golden_grammar(&self) -> Grammar {
222        (self.golden_grammar)()
223    }
224}
225
226const LIST_POSITIVES: &[&str] = &["a", "b", "a,b", "b,a"];
227const ASSIGNMENT_POSITIVES: &[&str] = &["let a=1;", "let b=2;", "let x=9;"];
228
229/// Built-in smoke corpora for exercising the harness without vendored competitors.
230pub const GOLDEN_CORPORA: &[GoldenCorpus] = &[
231    GoldenCorpus::new("inference-eval:list", LIST_POSITIVES, list_corpus_grammar),
232    GoldenCorpus::new(
233        "inference-eval:assignment",
234        ASSIGNMENT_POSITIVES,
235        assignment_corpus_grammar,
236    ),
237];
238
239/// Generates deterministic strings from `grammar`.
240///
241/// The sampler uses `SplitMix64` with the exact transition implemented in this
242/// module, never system entropy. Duplicate draws are removed while preserving
243/// first-seen order. Reachable rules that cannot emit any finite string return
244/// [`EvalError::NonTerminating`].
245pub fn sample(grammar: &Grammar, config: &SampleConfig) -> Result<Vec<String>, EvalError> {
246    sampler::sample(grammar, config)
247}
248
249/// Evaluates an inferred grammar against a golden oracle.
250///
251/// Precision is the fraction of samples drawn from `inferred` that `golden`
252/// accepts. When `golden_sampler` is present, recall is the fraction of samples
253/// drawn from that grammar that `inferred` accepts. When `golden_sampler` is
254/// absent, recall is the fraction of `positives` accepted by `inferred`.
255pub fn evaluate(
256    inferred: &Grammar,
257    golden: &dyn MembershipOracle,
258    golden_sampler: Option<&Grammar>,
259    positives: &[&str],
260    config: &SampleConfig,
261) -> Result<MetricScores, EvalError> {
262    evaluate_outcome(inferred, golden, golden_sampler, positives, config)
263        .map(|outcome| outcome.scores)
264}
265
266/// Runs one registered corpus against an inferred grammar.
267pub fn run_corpus(
268    corpus: &GoldenCorpus,
269    inferred: &Grammar,
270    config: &SampleConfig,
271) -> Result<BenchmarkReport, EvalError> {
272    let golden = corpus.golden_grammar();
273    let oracle = GrammarOracle(&golden);
274    let outcome = evaluate_outcome(inferred, &oracle, Some(&golden), corpus.positives(), config)?;
275
276    Ok(BenchmarkReport {
277        corpus: corpus.name(),
278        scores: outcome.scores,
279        samples_drawn: outcome.samples_drawn,
280        seed: config.seed,
281        scoring_mode: outcome.scoring_mode,
282    })
283}
284
285/// Runs a registered corpus by name.
286pub fn run_named_corpus(
287    corpus: &str,
288    inferred: &Grammar,
289    config: &SampleConfig,
290) -> Result<BenchmarkReport, EvalError> {
291    let descriptor = GOLDEN_CORPORA
292        .iter()
293        .find(|candidate| candidate.name() == corpus)
294        .ok_or_else(|| EvalError::CorpusNotFound {
295            corpus: corpus.to_string(),
296        })?;
297    run_corpus(descriptor, inferred, config)
298}
299
300/// Counts grammar symbols as rule-name symbols plus expression nodes.
301#[must_use]
302pub fn size_symbols(grammar: &Grammar) -> usize {
303    metrics::size_symbols(grammar)
304}
305
306/// Computes the deterministic two-part MDL score for `grammar` and `data`.
307///
308/// `L(G)` is `size_symbols(G) * ceil(log2(alphabet(G)))`, where the alphabet is
309/// the distinct set of rule names, terminals, character primitives, and grammar
310/// operators. `L(D | G)` uses a fixed deterministic code: accepted examples cost
311/// one emitted-symbol bit per Unicode scalar plus a stop bit; rejected examples
312/// fall back to their UTF-8 byte length plus a 64-bit escape penalty.
313#[must_use]
314pub fn mdl(grammar: &Grammar, data: &[&str]) -> f64 {
315    metrics::mdl(grammar, data)
316}
317
318#[derive(Clone, Debug)]
319struct EvaluationOutcome {
320    scores: MetricScores,
321    samples_drawn: usize,
322    scoring_mode: ScoringMode,
323}
324
325fn evaluate_outcome(
326    inferred: &Grammar,
327    golden: &dyn MembershipOracle,
328    golden_sampler: Option<&Grammar>,
329    positives: &[&str],
330    config: &SampleConfig,
331) -> Result<EvaluationOutcome, EvalError> {
332    let inferred_samples = sample(inferred, config)?;
333    if inferred_samples.is_empty() {
334        return Err(EvalError::EmptySample { source: "inferred" });
335    }
336
337    let precision_hits = inferred_samples
338        .iter()
339        .filter(|text| golden.accepts(text))
340        .count();
341    let precision = metrics::ratio(precision_hits, inferred_samples.len());
342    let inferred_oracle = GrammarOracle(inferred);
343
344    let (recall, mdl_bits, samples_drawn, scoring_mode) =
345        if let Some(golden_grammar) = golden_sampler {
346            let reference_samples = sample(golden_grammar, config)?;
347            if reference_samples.is_empty() {
348                return Err(EvalError::EmptySample { source: "golden" });
349            }
350            let recall_hits = reference_samples
351                .iter()
352                .filter(|text| inferred_oracle.accepts(text))
353                .count();
354            let data = reference_samples
355                .iter()
356                .map(String::as_str)
357                .collect::<Vec<_>>();
358            (
359                metrics::ratio(recall_hits, reference_samples.len()),
360                mdl(inferred, &data),
361                inferred_samples
362                    .len()
363                    .saturating_add(reference_samples.len()),
364                ScoringMode::GoldenGrammar,
365            )
366        } else {
367            if positives.is_empty() {
368                return Err(EvalError::EmptyCorpus);
369            }
370            let recall_hits = positives
371                .iter()
372                .filter(|text| inferred_oracle.accepts(text))
373                .count();
374            (
375                metrics::ratio(recall_hits, positives.len()),
376                mdl(inferred, positives),
377                inferred_samples.len().saturating_add(positives.len()),
378                ScoringMode::Corpus,
379            )
380        };
381
382    let f1 = if precision + recall == 0.0 {
383        0.0
384    } else {
385        2.0 * precision * recall / (precision + recall)
386    };
387
388    Ok(EvaluationOutcome {
389        scores: MetricScores {
390            precision,
391            recall,
392            f1,
393            size_symbols: size_symbols(inferred),
394            mdl_bits,
395        },
396        samples_drawn,
397        scoring_mode,
398    })
399}
400
401fn list_corpus_grammar() -> Grammar {
402    let expr = Grammar::expr();
403    Grammar::builder()
404        .start("list")
405        .rule(
406            "list",
407            expr.seq([
408                expr.nt("item"),
409                expr.rep0(expr.seq([expr.term(","), expr.nt("item")])),
410            ]),
411        )
412        .rule(
413            "item",
414            expr.choice_unordered([expr.term("a"), expr.term("b")]),
415        )
416        .build()
417}
418
419fn assignment_corpus_grammar() -> Grammar {
420    let expr = Grammar::expr();
421    Grammar::builder()
422        .start("assignment")
423        .rule(
424            "assignment",
425            expr.seq([
426                expr.term("let "),
427                expr.nt("letter"),
428                expr.term("="),
429                expr.nt("digit"),
430                expr.term(";"),
431            ]),
432        )
433        .rule("letter", expr.char_range('a', 'z'))
434        .rule("digit", expr.char_range('0', '9'))
435        .build()
436}