mod metrics;
mod recognizer;
mod sampler;
use std::error::Error;
use std::fmt;
use crate::grammar::Grammar;
pub trait MembershipOracle {
fn accepts(&self, text: &str) -> bool;
}
#[derive(Clone, Copy, Debug)]
pub struct GrammarOracle<'g>(pub &'g Grammar);
impl<'g> GrammarOracle<'g> {
#[must_use]
pub const fn new(grammar: &'g Grammar) -> Self {
Self(grammar)
}
#[must_use]
pub fn accepts(&self, text: &str) -> bool {
<Self as MembershipOracle>::accepts(self, text)
}
}
impl MembershipOracle for GrammarOracle<'_> {
fn accepts(&self, text: &str) -> bool {
recognizer::accepts(self.0, text)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SampleConfig {
pub seed: u64,
pub count: usize,
pub max_depth: usize,
pub repeat_cap: usize,
}
impl Default for SampleConfig {
fn default() -> Self {
Self {
seed: 0xD1E5_EED5_17A7_E001,
count: 256,
max_depth: 16,
repeat_cap: 4,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MetricScores {
pub precision: f64,
pub recall: f64,
pub f1: f64,
pub size_symbols: usize,
pub mdl_bits: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScoringMode {
GoldenGrammar,
Corpus,
}
#[derive(Clone, Debug, PartialEq)]
pub struct BenchmarkReport {
pub corpus: &'static str,
pub scores: MetricScores,
pub samples_drawn: usize,
pub seed: u64,
pub scoring_mode: ScoringMode,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EvalError {
EmptyGrammar,
EmptyCorpus,
EmptySample {
source: &'static str,
},
NonTerminating {
rule: String,
},
UnknownRule {
rule: String,
},
InvalidCharRange {
start: char,
end: char,
},
EmptyCharClass,
InvalidRepeat {
min: usize,
max: usize,
},
CorpusNotFound {
corpus: String,
},
}
impl fmt::Display for EvalError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyGrammar => formatter.write_str("grammar has no start rule"),
Self::EmptyCorpus => formatter.write_str("corpus-mode recall requires positives"),
Self::EmptySample { source } => write!(formatter, "{source} sampling produced no text"),
Self::NonTerminating { rule } => write!(formatter, "rule `{rule}` cannot terminate"),
Self::UnknownRule { rule } => write!(formatter, "unknown grammar rule `{rule}`"),
Self::InvalidCharRange { start, end } => {
write!(formatter, "invalid character range {start:?}..={end:?}")
}
Self::EmptyCharClass => formatter.write_str("character class has no sampleable member"),
Self::InvalidRepeat { min, max } => {
write!(formatter, "invalid repetition bounds {min}..={max}")
}
Self::CorpusNotFound { corpus } => write!(formatter, "unknown corpus `{corpus}`"),
}
}
}
impl Error for EvalError {}
#[derive(Clone, Copy, Debug)]
pub struct GoldenCorpus {
name: &'static str,
positives: &'static [&'static str],
golden_grammar: fn() -> Grammar,
}
impl GoldenCorpus {
#[must_use]
pub const fn new(
name: &'static str,
positives: &'static [&'static str],
golden_grammar: fn() -> Grammar,
) -> Self {
Self {
name,
positives,
golden_grammar,
}
}
#[must_use]
pub const fn name(&self) -> &'static str {
self.name
}
#[must_use]
pub const fn positives(&self) -> &'static [&'static str] {
self.positives
}
#[must_use]
pub fn golden_grammar(&self) -> Grammar {
(self.golden_grammar)()
}
}
const LIST_POSITIVES: &[&str] = &["a", "b", "a,b", "b,a"];
const ASSIGNMENT_POSITIVES: &[&str] = &["let a=1;", "let b=2;", "let x=9;"];
pub const GOLDEN_CORPORA: &[GoldenCorpus] = &[
GoldenCorpus::new("inference-eval:list", LIST_POSITIVES, list_corpus_grammar),
GoldenCorpus::new(
"inference-eval:assignment",
ASSIGNMENT_POSITIVES,
assignment_corpus_grammar,
),
];
pub fn sample(grammar: &Grammar, config: &SampleConfig) -> Result<Vec<String>, EvalError> {
sampler::sample(grammar, config)
}
pub fn evaluate(
inferred: &Grammar,
golden: &dyn MembershipOracle,
golden_sampler: Option<&Grammar>,
positives: &[&str],
config: &SampleConfig,
) -> Result<MetricScores, EvalError> {
evaluate_outcome(inferred, golden, golden_sampler, positives, config)
.map(|outcome| outcome.scores)
}
pub fn run_corpus(
corpus: &GoldenCorpus,
inferred: &Grammar,
config: &SampleConfig,
) -> Result<BenchmarkReport, EvalError> {
let golden = corpus.golden_grammar();
let oracle = GrammarOracle(&golden);
let outcome = evaluate_outcome(inferred, &oracle, Some(&golden), corpus.positives(), config)?;
Ok(BenchmarkReport {
corpus: corpus.name(),
scores: outcome.scores,
samples_drawn: outcome.samples_drawn,
seed: config.seed,
scoring_mode: outcome.scoring_mode,
})
}
pub fn run_named_corpus(
corpus: &str,
inferred: &Grammar,
config: &SampleConfig,
) -> Result<BenchmarkReport, EvalError> {
let descriptor = GOLDEN_CORPORA
.iter()
.find(|candidate| candidate.name() == corpus)
.ok_or_else(|| EvalError::CorpusNotFound {
corpus: corpus.to_string(),
})?;
run_corpus(descriptor, inferred, config)
}
#[must_use]
pub fn size_symbols(grammar: &Grammar) -> usize {
metrics::size_symbols(grammar)
}
#[must_use]
pub fn mdl(grammar: &Grammar, data: &[&str]) -> f64 {
metrics::mdl(grammar, data)
}
#[derive(Clone, Debug)]
struct EvaluationOutcome {
scores: MetricScores,
samples_drawn: usize,
scoring_mode: ScoringMode,
}
fn evaluate_outcome(
inferred: &Grammar,
golden: &dyn MembershipOracle,
golden_sampler: Option<&Grammar>,
positives: &[&str],
config: &SampleConfig,
) -> Result<EvaluationOutcome, EvalError> {
let inferred_samples = sample(inferred, config)?;
if inferred_samples.is_empty() {
return Err(EvalError::EmptySample { source: "inferred" });
}
let precision_hits = inferred_samples
.iter()
.filter(|text| golden.accepts(text))
.count();
let precision = metrics::ratio(precision_hits, inferred_samples.len());
let inferred_oracle = GrammarOracle(inferred);
let (recall, mdl_bits, samples_drawn, scoring_mode) =
if let Some(golden_grammar) = golden_sampler {
let reference_samples = sample(golden_grammar, config)?;
if reference_samples.is_empty() {
return Err(EvalError::EmptySample { source: "golden" });
}
let recall_hits = reference_samples
.iter()
.filter(|text| inferred_oracle.accepts(text))
.count();
let data = reference_samples
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
(
metrics::ratio(recall_hits, reference_samples.len()),
mdl(inferred, &data),
inferred_samples
.len()
.saturating_add(reference_samples.len()),
ScoringMode::GoldenGrammar,
)
} else {
if positives.is_empty() {
return Err(EvalError::EmptyCorpus);
}
let recall_hits = positives
.iter()
.filter(|text| inferred_oracle.accepts(text))
.count();
(
metrics::ratio(recall_hits, positives.len()),
mdl(inferred, positives),
inferred_samples.len().saturating_add(positives.len()),
ScoringMode::Corpus,
)
};
let f1 = if precision + recall == 0.0 {
0.0
} else {
2.0 * precision * recall / (precision + recall)
};
Ok(EvaluationOutcome {
scores: MetricScores {
precision,
recall,
f1,
size_symbols: size_symbols(inferred),
mdl_bits,
},
samples_drawn,
scoring_mode,
})
}
fn list_corpus_grammar() -> Grammar {
let expr = Grammar::expr();
Grammar::builder()
.start("list")
.rule(
"list",
expr.seq([
expr.nt("item"),
expr.rep0(expr.seq([expr.term(","), expr.nt("item")])),
]),
)
.rule(
"item",
expr.choice_unordered([expr.term("a"), expr.term("b")]),
)
.build()
}
fn assignment_corpus_grammar() -> Grammar {
let expr = Grammar::expr();
Grammar::builder()
.start("assignment")
.rule(
"assignment",
expr.seq([
expr.term("let "),
expr.nt("letter"),
expr.term("="),
expr.nt("digit"),
expr.term(";"),
]),
)
.rule("letter", expr.char_range('a', 'z'))
.rule("digit", expr.char_range('0', '9'))
.build()
}