Skip to main content

meta_language/grammar/inference/
advisor.rs

1//! Advisory naming and merge ranking for grammar inference.
2//!
3//! Advisors are intentionally narrow: deterministic inference can use the
4//! default implementations without a model or network, while optional
5//! accelerators can suggest names or merge ordering behind the same traits.
6
7use crate::grammar::{
8    grammar_expr_concept_id, CharClassItem, Grammar, GrammarExpr, GrammarRule, RuleKind,
9    GRAMMAR_CONCEPTS,
10};
11
12use super::minimize::mdl_cost;
13
14const COST_EPSILON: f64 = 1e-9;
15
16const INFERENCE_NAMING_CONCEPTS: &[InferenceNamingConcept] = &[
17    InferenceNamingConcept {
18        id: "grammar.number",
19        name: "number",
20    },
21    InferenceNamingConcept {
22        id: "grammar.digit",
23        name: "digit",
24    },
25    InferenceNamingConcept {
26        id: "grammar.letter",
27        name: "letter",
28    },
29    InferenceNamingConcept {
30        id: "grammar.identifier",
31        name: "identifier",
32    },
33    InferenceNamingConcept {
34        id: "grammar.string",
35        name: "string",
36    },
37    InferenceNamingConcept {
38        id: "grammar.boolean",
39        name: "boolean",
40    },
41    InferenceNamingConcept {
42        id: "grammar.null",
43        name: "null",
44    },
45    InferenceNamingConcept {
46        id: "grammar.value",
47        name: "value",
48    },
49    InferenceNamingConcept {
50        id: "grammar.item",
51        name: "item",
52    },
53    InferenceNamingConcept {
54        id: "grammar.list",
55        name: "list",
56    },
57    InferenceNamingConcept {
58        id: "grammar.name",
59        name: "name",
60    },
61    InferenceNamingConcept {
62        id: "grammar.object",
63        name: "object",
64    },
65    InferenceNamingConcept {
66        id: "grammar.member",
67        name: "member",
68    },
69];
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72struct InferenceNamingConcept {
73    id: &'static str,
74    name: &'static str,
75}
76
77/// Source of an advisory inference decision.
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum AdviceSource {
80    /// Deterministic local heuristic with no model or network.
81    Deterministic,
82    /// Optional LLM-backed accelerator.
83    Llm,
84}
85
86/// Kind of inference decision recorded for evaluation reports.
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub enum AdviceDecisionKind {
89    /// A non-terminal naming decision.
90    Naming,
91    /// A rule-merge ranking or selection decision.
92    Merge,
93}
94
95/// Provenance record for an advisory inference decision.
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub struct AdviceDecision {
98    /// Decision kind.
99    pub kind: AdviceDecisionKind,
100    /// Rule or candidate target associated with the decision.
101    pub target: String,
102    /// Advisor source used for this decision.
103    pub source: AdviceSource,
104}
105
106impl AdviceDecision {
107    /// Builds an advice provenance record.
108    #[must_use]
109    pub fn new(kind: AdviceDecisionKind, target: impl Into<String>, source: AdviceSource) -> Self {
110        Self {
111            kind,
112            target: target.into(),
113            source,
114        }
115    }
116}
117
118/// Request for naming an inferred non-terminal.
119#[derive(Clone, Copy, Debug)]
120pub struct NamingRequest<'a> {
121    /// Grammar context used to avoid name collisions.
122    pub grammar: &'a Grammar,
123    /// Right-hand-side expression to name.
124    pub rule_expr: &'a GrammarExpr,
125    /// Example substrings this expression derives.
126    pub sample_yields: &'a [String],
127}
128
129/// Candidate non-terminal name proposed by a naming advisor.
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct NameCandidate {
132    /// Proposed rule name.
133    pub name: String,
134    /// Grounding concept id, when the name is concept-backed.
135    pub concept: Option<String>,
136    /// Source that produced this suggestion.
137    pub source: AdviceSource,
138}
139
140/// Request for ranking candidate rule merges.
141#[derive(Clone, Copy, Debug)]
142pub struct MergeRequest<'a> {
143    /// Grammar to score candidate merges against.
144    pub grammar: &'a Grammar,
145    /// Candidate rule pairs. The winner name survives; the loser is rewritten.
146    pub candidates: &'a [MergeCandidate],
147    /// Positive examples used for the data component of the MDL score.
148    ///
149    /// Passing an empty slice makes the score grammar-size-only.
150    pub examples: &'a [String],
151}
152
153/// Candidate merge of two named rules.
154#[derive(Clone, Debug, PartialEq, Eq)]
155pub struct MergeCandidate {
156    /// Rule name to keep.
157    pub winner: String,
158    /// Rule name to rewrite and remove.
159    pub loser: String,
160}
161
162impl MergeCandidate {
163    /// Builds a merge candidate from winner and loser rule names.
164    #[must_use]
165    pub fn new(winner: impl Into<String>, loser: impl Into<String>) -> Self {
166        Self {
167            winner: winner.into(),
168            loser: loser.into(),
169        }
170    }
171}
172
173/// Score for one merge candidate.
174#[derive(Clone, Copy, Debug, PartialEq)]
175pub struct MergeScore {
176    /// Score in `[0.0, 1.0]`; higher means more promising.
177    pub score: f64,
178    /// Source that produced the score.
179    pub source: AdviceSource,
180}
181
182/// Proposes human-meaningful names for inferred non-terminals.
183pub trait NamingAdvisor {
184    /// Returns ranked candidate names, best first.
185    fn propose_names(&self, request: &NamingRequest<'_>) -> Vec<NameCandidate>;
186}
187
188/// Ranks candidate rule merges during inference and minimization.
189pub trait MergeAdvisor {
190    /// Returns one score per candidate in request order.
191    fn rank_merges(&self, request: &MergeRequest<'_>) -> Vec<MergeScore>;
192}
193
194/// Deterministic naming advisor grounded in grammar concepts and stable shapes.
195#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
196pub struct ConceptNamingAdvisor;
197
198impl NamingAdvisor for ConceptNamingAdvisor {
199    fn propose_names(&self, request: &NamingRequest<'_>) -> Vec<NameCandidate> {
200        let (base_name, concept) = concept_name_for_request(request).map_or_else(
201            || (structural_name(request.rule_expr), None),
202            |concept| (concept.name.to_string(), Some(concept.id.to_string())),
203        );
204        let name = unique_rule_name(&sanitize_identifier(&base_name), request.grammar);
205
206        vec![NameCandidate {
207            name,
208            concept,
209            source: AdviceSource::Deterministic,
210        }]
211    }
212}
213
214/// Deterministic merge advisor using the existing MDL objective.
215#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
216pub struct MdlMergeAdvisor;
217
218impl MergeAdvisor for MdlMergeAdvisor {
219    fn rank_merges(&self, request: &MergeRequest<'_>) -> Vec<MergeScore> {
220        let baseline = mdl_cost(request.grammar, request.examples).total();
221
222        request
223            .candidates
224            .iter()
225            .map(|candidate| {
226                let score =
227                    merge_candidate_grammar(request.grammar, candidate).map_or(0.0, |trial| {
228                        let delta = mdl_cost(&trial, request.examples).total() - baseline;
229                        score_from_delta(delta)
230                    });
231                MergeScore {
232                    score,
233                    source: AdviceSource::Deterministic,
234                }
235            })
236            .collect()
237    }
238}
239
240/// Wraps an optional accelerator with a deterministic fallback advisor.
241#[derive(Clone, Debug, PartialEq, Eq)]
242pub struct FallbackAdvisor<A, D> {
243    accelerator: Option<A>,
244    deterministic: D,
245}
246
247impl<A, D> FallbackAdvisor<A, D> {
248    /// Builds a fallback advisor.
249    #[must_use]
250    pub const fn new(accelerator: Option<A>, deterministic: D) -> Self {
251        Self {
252            accelerator,
253            deterministic,
254        }
255    }
256
257    /// Builds a deterministic-only fallback advisor.
258    #[must_use]
259    pub const fn deterministic(deterministic: D) -> Self {
260        Self {
261            accelerator: None,
262            deterministic,
263        }
264    }
265}
266
267impl<A, D> NamingAdvisor for FallbackAdvisor<A, D>
268where
269    A: NamingAdvisor,
270    D: NamingAdvisor,
271{
272    fn propose_names(&self, request: &NamingRequest<'_>) -> Vec<NameCandidate> {
273        let deterministic = self.deterministic.propose_names(request);
274        let Some(accelerator) = &self.accelerator else {
275            return deterministic;
276        };
277
278        let accelerated = accelerator
279            .propose_names(request)
280            .into_iter()
281            .filter(|candidate| validate_name_candidate(request, candidate))
282            .collect::<Vec<_>>();
283
284        if accelerated.is_empty() {
285            deterministic
286        } else {
287            accelerated
288        }
289    }
290}
291
292impl<A, D> MergeAdvisor for FallbackAdvisor<A, D>
293where
294    A: MergeAdvisor,
295    D: MergeAdvisor,
296{
297    fn rank_merges(&self, request: &MergeRequest<'_>) -> Vec<MergeScore> {
298        let deterministic = self.deterministic.rank_merges(request);
299        let Some(accelerator) = &self.accelerator else {
300            return deterministic;
301        };
302
303        validate_merge_scores(accelerator.rank_merges(request), request.candidates.len())
304            .unwrap_or(deterministic)
305    }
306}
307
308#[cfg(feature = "llm-assist")]
309mod llm;
310#[cfg(feature = "llm-assist")]
311pub use llm::{LlmClient, LlmError, LlmMergeAdvisor, LlmNamingAdvisor};
312
313fn concept_name_for_request(request: &NamingRequest<'_>) -> Option<InferenceNamingConcept> {
314    concept_from_samples(request.sample_yields).or_else(|| concept_from_expr(request.rule_expr))
315}
316
317fn concept_from_samples(samples: &[String]) -> Option<InferenceNamingConcept> {
318    if samples.is_empty() || samples.iter().any(String::is_empty) {
319        return None;
320    }
321
322    if samples.iter().all(|sample| is_integer_text(sample)) {
323        return inference_concept("grammar.number");
324    }
325    if samples.iter().all(|sample| is_digit_text(sample)) {
326        return inference_concept("grammar.digit");
327    }
328    if samples.iter().all(|sample| is_identifier_text(sample)) {
329        return inference_concept("grammar.identifier");
330    }
331    if samples.iter().all(|sample| is_letter_text(sample)) {
332        return inference_concept("grammar.letter");
333    }
334
335    None
336}
337
338fn concept_from_expr(expr: &GrammarExpr) -> Option<InferenceNamingConcept> {
339    if is_number_expr(expr) {
340        return inference_concept("grammar.number");
341    }
342    if is_digit_expr(expr) {
343        return inference_concept("grammar.digit");
344    }
345    if is_identifier_expr(expr) {
346        return inference_concept("grammar.identifier");
347    }
348    if is_letter_expr(expr) {
349        return inference_concept("grammar.letter");
350    }
351
352    None
353}
354
355fn inference_concept(id: &str) -> Option<InferenceNamingConcept> {
356    INFERENCE_NAMING_CONCEPTS
357        .iter()
358        .copied()
359        .find(|concept| concept.id == id)
360}
361
362fn structural_name(expr: &GrammarExpr) -> String {
363    match expr {
364        GrammarExpr::Empty => "empty".to_string(),
365        GrammarExpr::Terminal(_) | GrammarExpr::TerminalInsensitive(_) => "literal".to_string(),
366        GrammarExpr::CharRange(_, _) => "char_range".to_string(),
367        GrammarExpr::CharClass { .. } => "char_class".to_string(),
368        GrammarExpr::AnyChar => "any_char".to_string(),
369        GrammarExpr::NonTerminal(name) => sanitize_identifier(name),
370        GrammarExpr::Choice {
371            ordered,
372            alternatives,
373        } => {
374            let prefix = if *ordered { "ordered_choice" } else { "choice" };
375            format!("{prefix}_{}", alternatives.len())
376        }
377        GrammarExpr::Sequence(items) => format!("seq_{}", items.len()),
378        GrammarExpr::Optional(inner) => format!("{}_opt", structural_stem(inner)),
379        GrammarExpr::ZeroOrMore(inner) => format!("{}_star", structural_stem(inner)),
380        GrammarExpr::OneOrMore(inner) => format!("{}_plus", structural_stem(inner)),
381        GrammarExpr::Repeat { expr, .. } => format!("{}_repeat", structural_stem(expr)),
382        GrammarExpr::And(inner) => format!("{}_and", structural_stem(inner)),
383        GrammarExpr::Not(inner) => format!("{}_not", structural_stem(inner)),
384        GrammarExpr::Capture { label, expr } => label.as_deref().map_or_else(
385            || format!("{}_capture", structural_stem(expr)),
386            sanitize_identifier,
387        ),
388    }
389}
390
391fn structural_stem(expr: &GrammarExpr) -> String {
392    concept_from_expr(expr).map_or_else(
393        || match expr {
394            GrammarExpr::NonTerminal(name) => sanitize_identifier(name),
395            _ => sanitize_identifier(
396                grammar_expr_concept_id(expr)
397                    .rsplit('.')
398                    .next()
399                    .unwrap_or("rule"),
400            ),
401        },
402        |concept| concept.name.to_string(),
403    )
404}
405
406fn is_number_expr(expr: &GrammarExpr) -> bool {
407    match expr {
408        GrammarExpr::OneOrMore(inner) => is_digit_expr(inner),
409        GrammarExpr::Repeat { expr, min, max } => *min >= 1 && max.is_none() && is_digit_expr(expr),
410        GrammarExpr::Sequence(items) if items.len() == 2 => {
411            is_optional_sign(&items[0]) && is_number_expr(&items[1])
412        }
413        _ => false,
414    }
415}
416
417fn is_optional_sign(expr: &GrammarExpr) -> bool {
418    match expr {
419        GrammarExpr::Optional(inner) => matches!(
420            inner.as_ref(),
421            GrammarExpr::Terminal(value) if value == "-" || value == "+"
422        ),
423        _ => false,
424    }
425}
426
427fn is_digit_expr(expr: &GrammarExpr) -> bool {
428    match expr {
429        GrammarExpr::CharRange('0', '9') => true,
430        GrammarExpr::Terminal(value) => is_digit_text(value),
431        GrammarExpr::CharClass { negated, items } => {
432            !*negated
433                && items.iter().all(|item| {
434                    matches!(
435                        item,
436                        CharClassItem::Range('0', '9') | CharClassItem::Char('0'..='9')
437                    )
438                })
439        }
440        _ => false,
441    }
442}
443
444fn is_letter_expr(expr: &GrammarExpr) -> bool {
445    match expr {
446        GrammarExpr::CharRange('a', 'z') | GrammarExpr::CharRange('A', 'Z') => true,
447        GrammarExpr::Terminal(value) => is_letter_text(value),
448        GrammarExpr::CharClass { negated, items } => {
449            !*negated
450                && items.iter().all(|item| {
451                    matches!(
452                        item,
453                        CharClassItem::Range('a', 'z')
454                            | CharClassItem::Range('A', 'Z')
455                            | CharClassItem::Char('a'..='z' | 'A'..='Z' | '_')
456                    )
457                })
458        }
459        _ => false,
460    }
461}
462
463fn is_identifier_expr(expr: &GrammarExpr) -> bool {
464    match expr {
465        GrammarExpr::Sequence(items) if items.len() == 2 => {
466            is_letter_expr(&items[0])
467                && matches!(
468                    &items[1],
469                    GrammarExpr::ZeroOrMore(inner) if is_identifier_tail_expr(inner)
470                )
471        }
472        _ => false,
473    }
474}
475
476fn is_identifier_tail_expr(expr: &GrammarExpr) -> bool {
477    is_letter_expr(expr)
478        || is_digit_expr(expr)
479        || matches!(
480            expr,
481            GrammarExpr::Choice { alternatives, .. }
482                if alternatives
483                    .iter()
484                    .all(|alternative| is_letter_expr(alternative) || is_digit_expr(alternative))
485        )
486}
487
488fn is_integer_text(text: &str) -> bool {
489    let digits = text
490        .strip_prefix(['-', '+'])
491        .filter(|rest| !rest.is_empty())
492        .unwrap_or(text);
493    digits.chars().all(|character| character.is_ascii_digit())
494}
495
496fn is_digit_text(text: &str) -> bool {
497    let mut chars = text.chars();
498    chars
499        .next()
500        .is_some_and(|character| character.is_ascii_digit())
501        && chars.next().is_none()
502}
503
504fn is_letter_text(text: &str) -> bool {
505    let mut chars = text.chars();
506    chars
507        .next()
508        .is_some_and(|character| character.is_ascii_alphabetic())
509        && chars.next().is_none()
510}
511
512fn is_identifier_text(text: &str) -> bool {
513    let mut chars = text.chars();
514    let Some(first) = chars.next() else {
515        return false;
516    };
517    (first.is_ascii_alphabetic() || first == '_')
518        && chars.all(|character| character.is_ascii_alphanumeric() || character == '_')
519}
520
521fn sanitize_identifier(value: &str) -> String {
522    let mut output = String::new();
523    for character in value.chars() {
524        let normalized = if character.is_ascii_alphanumeric() || character == '_' {
525            character.to_ascii_lowercase()
526        } else {
527            '_'
528        };
529
530        if output.is_empty() && normalized.is_ascii_digit() {
531            output.push('_');
532        }
533        if normalized == '_' && output.ends_with('_') {
534            continue;
535        }
536        output.push(normalized);
537    }
538
539    let trimmed = output.trim_matches('_');
540    if trimmed.is_empty() {
541        "rule".to_string()
542    } else if trimmed
543        .chars()
544        .next()
545        .is_some_and(|character| character.is_ascii_digit())
546    {
547        format!("_{trimmed}")
548    } else {
549        trimmed.to_string()
550    }
551}
552
553fn unique_rule_name(base: &str, grammar: &Grammar) -> String {
554    if grammar.rule(base).is_none() {
555        return base.to_string();
556    }
557
558    let mut suffix = 2usize;
559    loop {
560        let candidate = format!("{base}_{suffix}");
561        if grammar.rule(&candidate).is_none() {
562            return candidate;
563        }
564        suffix = suffix.saturating_add(1);
565    }
566}
567
568fn validate_name_candidate(request: &NamingRequest<'_>, candidate: &NameCandidate) -> bool {
569    is_valid_identifier(&candidate.name)
570        && request.grammar.rule(&candidate.name).is_none()
571        && candidate.concept.as_deref().map_or(true, known_concept_id)
572}
573
574fn is_valid_identifier(value: &str) -> bool {
575    let mut chars = value.chars();
576    let Some(first) = chars.next() else {
577        return false;
578    };
579    (first.is_ascii_alphabetic() || first == '_')
580        && chars.all(|character| character.is_ascii_alphanumeric() || character == '_')
581}
582
583fn known_concept_id(concept: &str) -> bool {
584    GRAMMAR_CONCEPTS.iter().any(|known| known.id == concept)
585        || INFERENCE_NAMING_CONCEPTS
586            .iter()
587            .any(|known| known.id == concept)
588}
589
590fn validate_merge_scores(scores: Vec<MergeScore>, expected_len: usize) -> Option<Vec<MergeScore>> {
591    if scores.len() != expected_len || scores.iter().any(|score| !score.score.is_finite()) {
592        return None;
593    }
594
595    Some(
596        scores
597            .into_iter()
598            .map(|score| MergeScore {
599                score: score.score.clamp(0.0, 1.0),
600                source: score.source,
601            })
602            .collect(),
603    )
604}
605
606fn merge_candidate_grammar(grammar: &Grammar, candidate: &MergeCandidate) -> Option<Grammar> {
607    if candidate.winner == candidate.loser {
608        return Some(grammar.clone());
609    }
610
611    let winner_rule = grammar.rule(&candidate.winner)?;
612    let loser_rule = grammar.rule(&candidate.loser)?;
613    if grammar.start() == Some(loser_rule.name())
614        || !merge_metadata_compatible(winner_rule, loser_rule)
615    {
616        return None;
617    }
618
619    let winner_name = winner_rule.name().to_string();
620    let loser_name = loser_rule.name().to_string();
621    let replacement = GrammarExpr::non_terminal(&winner_name);
622    let merged_expr = merge_exprs(winner_rule.expr(), loser_rule.expr());
623
624    let mut next = Grammar::new();
625    if let Some(source_format) = grammar.source_format() {
626        next.set_source_format(source_format);
627    }
628
629    for rule in grammar.rules() {
630        if rule.name() == loser_name {
631            continue;
632        }
633
634        let mut next_rule = rule.clone();
635        if next_rule.name() == winner_name {
636            next_rule.expr = merged_expr.clone();
637        }
638        next_rule.expr = rewrite_nonterminal_refs(&next_rule.expr, &loser_name, &replacement);
639        next.add_rule(next_rule);
640    }
641
642    if let Some(start) = grammar.start() {
643        next.set_start(start);
644    }
645    Some(next)
646}
647
648fn merge_metadata_compatible(winner: &GrammarRule, loser: &GrammarRule) -> bool {
649    winner.kind() == loser.kind()
650        && winner.concept() == loser.concept()
651        && winner.doc() == loser.doc()
652        && winner.kind() == RuleKind::Normal
653}
654
655fn merge_exprs(winner: &GrammarExpr, loser: &GrammarExpr) -> GrammarExpr {
656    if winner == loser {
657        return winner.clone();
658    }
659
660    let mut alternatives = Vec::new();
661    push_merge_alternative(&mut alternatives, winner);
662    push_merge_alternative(&mut alternatives, loser);
663
664    if alternatives.len() == 1 {
665        alternatives.remove(0)
666    } else {
667        GrammarExpr::choice(false, alternatives)
668    }
669}
670
671fn push_merge_alternative(alternatives: &mut Vec<GrammarExpr>, expr: &GrammarExpr) {
672    if let GrammarExpr::Choice {
673        ordered: false,
674        alternatives: nested,
675    } = expr
676    {
677        for alternative in nested {
678            push_merge_alternative(alternatives, alternative);
679        }
680        return;
681    }
682
683    if !alternatives.contains(expr) {
684        alternatives.push(expr.clone());
685    }
686}
687
688fn rewrite_nonterminal_refs(
689    expr: &GrammarExpr,
690    loser_name: &str,
691    replacement: &GrammarExpr,
692) -> GrammarExpr {
693    match expr {
694        GrammarExpr::NonTerminal(name) if name == loser_name => replacement.clone(),
695        GrammarExpr::Choice {
696            ordered,
697            alternatives,
698        } => GrammarExpr::choice(
699            *ordered,
700            alternatives
701                .iter()
702                .map(|expr| rewrite_nonterminal_refs(expr, loser_name, replacement)),
703        ),
704        GrammarExpr::Sequence(items) => GrammarExpr::sequence(
705            items
706                .iter()
707                .map(|expr| rewrite_nonterminal_refs(expr, loser_name, replacement)),
708        ),
709        GrammarExpr::Optional(inner) => {
710            GrammarExpr::optional(rewrite_nonterminal_refs(inner, loser_name, replacement))
711        }
712        GrammarExpr::ZeroOrMore(inner) => {
713            GrammarExpr::zero_or_more(rewrite_nonterminal_refs(inner, loser_name, replacement))
714        }
715        GrammarExpr::OneOrMore(inner) => {
716            GrammarExpr::one_or_more(rewrite_nonterminal_refs(inner, loser_name, replacement))
717        }
718        GrammarExpr::Repeat { expr, min, max } => GrammarExpr::repeat(
719            rewrite_nonterminal_refs(expr, loser_name, replacement),
720            *min,
721            *max,
722        ),
723        GrammarExpr::And(inner) => {
724            GrammarExpr::and(rewrite_nonterminal_refs(inner, loser_name, replacement))
725        }
726        GrammarExpr::Not(inner) => {
727            GrammarExpr::not(rewrite_nonterminal_refs(inner, loser_name, replacement))
728        }
729        GrammarExpr::Capture { label, expr } => label.as_deref().map_or_else(
730            || {
731                GrammarExpr::capture_unlabeled(rewrite_nonterminal_refs(
732                    expr,
733                    loser_name,
734                    replacement,
735                ))
736            },
737            |label| {
738                GrammarExpr::capture(
739                    label,
740                    rewrite_nonterminal_refs(expr, loser_name, replacement),
741                )
742            },
743        ),
744        GrammarExpr::Empty
745        | GrammarExpr::Terminal(_)
746        | GrammarExpr::TerminalInsensitive(_)
747        | GrammarExpr::CharRange(_, _)
748        | GrammarExpr::CharClass { .. }
749        | GrammarExpr::AnyChar
750        | GrammarExpr::NonTerminal(_) => expr.clone(),
751    }
752}
753
754fn score_from_delta(delta: f64) -> f64 {
755    if !delta.is_finite() {
756        return 0.0;
757    }
758
759    let magnitude = delta.abs() / (1.0 + delta.abs());
760    if delta < -COST_EPSILON {
761        0.5 + magnitude * 0.5
762    } else if delta > COST_EPSILON {
763        0.5 - magnitude * 0.5
764    } else {
765        0.5
766    }
767}