meta_language/grammar/inference/
minimize.rs1mod cost;
9mod transform;
10
11use super::eval::{sample, GrammarOracle, SampleConfig};
12use crate::grammar::Grammar;
13use transform::{apply_candidate, enumerate_candidates, Candidate, CandidateKind};
14
15const DEFAULT_PRECISION_BUDGET: f64 = 0.0;
16const DEFAULT_SAMPLE_BUDGET: usize = 256;
17const DEFAULT_MAX_ITERATIONS: usize = 64;
18const COST_EPSILON: f64 = 1e-9;
19
20#[derive(Clone, Copy, Debug, PartialEq)]
22pub struct Mdl {
23 pub grammar_bits: f64,
25 pub data_bits: f64,
27}
28
29impl Mdl {
30 #[must_use]
32 pub fn total(self) -> f64 {
33 self.grammar_bits + self.data_bits
34 }
35}
36
37#[derive(Clone, Copy, Debug, PartialEq)]
39pub struct MinimizeOptions {
40 pub precision_budget: f64,
42 pub sample_budget: usize,
44 pub max_iterations: usize,
46}
47
48impl Default for MinimizeOptions {
49 fn default() -> Self {
50 Self {
51 precision_budget: DEFAULT_PRECISION_BUDGET,
52 sample_budget: DEFAULT_SAMPLE_BUDGET,
53 max_iterations: DEFAULT_MAX_ITERATIONS,
54 }
55 }
56}
57
58#[derive(Clone, Debug, Default, PartialEq, Eq)]
60pub struct MinimizeReport {
61 pub merges_applied: usize,
63 pub inlines_applied: usize,
65 pub factorings_applied: usize,
67 pub prunes_applied: usize,
69 pub candidates_rejected_by_mdl: usize,
71 pub candidates_rejected_by_gate: usize,
73}
74
75#[derive(Clone, Debug, PartialEq)]
77pub struct MinimizeResult {
78 pub grammar: Grammar,
80 pub before: Mdl,
82 pub after: Mdl,
84 pub report: MinimizeReport,
86}
87
88#[must_use]
95pub fn mdl_cost(grammar: &Grammar, examples: &[String]) -> Mdl {
96 cost::mdl_cost(grammar, examples)
97}
98
99#[must_use]
106pub fn minimize(grammar: &Grammar, examples: &[String], opts: MinimizeOptions) -> MinimizeResult {
107 let before = mdl_cost(grammar, examples);
108 let mut current = grammar.clone();
109 let mut current_cost = before;
110 let mut report = MinimizeReport::default();
111
112 for _ in 0..opts.max_iterations {
113 let candidates = enumerate_candidates(¤t);
114 if candidates.is_empty() {
115 break;
116 }
117
118 let mut best: Option<ScoredCandidate> = None;
119 for (order, candidate) in candidates.into_iter().enumerate() {
120 let trial = apply_candidate(¤t, candidate);
121 if trial == current {
122 report.candidates_rejected_by_mdl =
123 report.candidates_rejected_by_mdl.saturating_add(1);
124 continue;
125 }
126
127 let trial_cost = mdl_cost(&trial, examples);
128 let delta = trial_cost.total() - current_cost.total();
129 if delta >= -COST_EPSILON {
130 report.candidates_rejected_by_mdl =
131 report.candidates_rejected_by_mdl.saturating_add(1);
132 continue;
133 }
134
135 if !passes_gate(grammar, &trial, examples, opts) {
136 report.candidates_rejected_by_gate =
137 report.candidates_rejected_by_gate.saturating_add(1);
138 continue;
139 }
140
141 let scored = ScoredCandidate {
142 order,
143 candidate,
144 grammar: trial,
145 cost: trial_cost,
146 delta,
147 };
148 if best
149 .as_ref()
150 .map_or(true, |best| scored.is_better_than(best))
151 {
152 best = Some(scored);
153 }
154 }
155
156 let Some(best) = best else {
157 break;
158 };
159
160 record_acceptance(&mut report, best.candidate.kind());
161 current = best.grammar;
162 current_cost = best.cost;
163 }
164
165 MinimizeResult {
166 grammar: current,
167 before,
168 after: current_cost,
169 report,
170 }
171}
172
173#[derive(Clone, Debug, PartialEq)]
174struct ScoredCandidate {
175 order: usize,
176 candidate: Candidate,
177 grammar: Grammar,
178 cost: Mdl,
179 delta: f64,
180}
181
182impl ScoredCandidate {
183 fn is_better_than(&self, other: &Self) -> bool {
184 self.delta < other.delta - COST_EPSILON
185 || ((self.delta - other.delta).abs() <= COST_EPSILON && self.order < other.order)
186 }
187}
188
189fn record_acceptance(report: &mut MinimizeReport, kind: CandidateKind) {
190 match kind {
191 CandidateKind::Merge => {
192 report.merges_applied = report.merges_applied.saturating_add(1);
193 }
194 CandidateKind::Inline => {
195 report.inlines_applied = report.inlines_applied.saturating_add(1);
196 }
197 CandidateKind::Factor => {
198 report.factorings_applied = report.factorings_applied.saturating_add(1);
199 }
200 CandidateKind::Prune => {
201 report.prunes_applied = report.prunes_applied.saturating_add(1);
202 }
203 }
204}
205
206fn passes_gate(
207 baseline: &Grammar,
208 trial: &Grammar,
209 examples: &[String],
210 opts: MinimizeOptions,
211) -> bool {
212 let trial_oracle = GrammarOracle::new(trial);
213 if !examples.iter().all(|example| trial_oracle.accepts(example)) {
214 return false;
215 }
216
217 if opts.sample_budget == 0 {
218 return true;
219 }
220
221 let config = SampleConfig {
222 count: opts.sample_budget.max(1),
223 max_depth: opts.max_iterations.max(1),
224 ..SampleConfig::default()
225 };
226 let Ok(samples) = sample(trial, &config) else {
227 return false;
228 };
229 if samples.is_empty() {
230 return false;
231 }
232
233 let baseline_oracle = GrammarOracle::new(baseline);
234 let accepted = samples
235 .iter()
236 .filter(|sample| baseline_oracle.accepts(sample))
237 .count();
238 let precision = ratio(accepted, samples.len());
239 let budget = if opts.precision_budget.is_finite() {
240 opts.precision_budget.max(0.0)
241 } else {
242 DEFAULT_PRECISION_BUDGET
243 };
244
245 precision + budget + COST_EPSILON >= 1.0
246}
247
248fn ratio(numerator: usize, denominator: usize) -> f64 {
249 debug_assert!(denominator > 0);
250 usize_to_f64(numerator) / usize_to_f64(denominator)
251}
252
253#[allow(clippy::cast_precision_loss)]
254const fn usize_to_f64(value: usize) -> f64 {
255 value as f64
256}