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    CharClassItem, GRAMMAR_CONCEPTS, Grammar, GrammarExpr, GrammarRule, RuleKind,
9    grammar_expr_concept_id,
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-value",
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_expression".to_string(),
365        GrammarExpr::Terminal(_) | GrammarExpr::TerminalInsensitive(_) => "literal".to_string(),
366        GrammarExpr::CharRange(_, _) => "character_range".to_string(),
367        GrammarExpr::CharClass { .. } => "character_class".to_string(),
368        GrammarExpr::AnyChar => "any_character".to_string(),
369        GrammarExpr::NonTerminal(name) => sanitize_identifier(name),
370        GrammarExpr::Choice {
371            ordered,
372            alternatives,
373        } => {
374            let prefix = if *ordered {
375                "ordered_choice"
376            } else {
377                "unordered_choice"
378            };
379            format!("{prefix}_{}", alternatives.len())
380        }
381        GrammarExpr::Sequence(items) => format!("sequence_{}", items.len()),
382        GrammarExpr::Optional(inner) => format!("optional_{}", structural_stem(inner)),
383        GrammarExpr::ZeroOrMore(inner) => {
384            format!("zero_or_more_{}", structural_stem(inner))
385        }
386        GrammarExpr::OneOrMore(inner) => format!("one_or_more_{}", structural_stem(inner)),
387        GrammarExpr::Repeat { expr, .. } => format!("repeated_{}", structural_stem(expr)),
388        GrammarExpr::And(inner) => format!("{}_positive_predicate", structural_stem(inner)),
389        GrammarExpr::Not(inner) => format!("{}_negative_predicate", structural_stem(inner)),
390        GrammarExpr::Capture { label, expr } => label.as_deref().map_or_else(
391            || format!("{}_capture", structural_stem(expr)),
392            sanitize_identifier,
393        ),
394        GrammarExpr::Feature(feature) => sanitize_identifier(feature.head()),
395    }
396}
397
398fn structural_stem(expr: &GrammarExpr) -> String {
399    concept_from_expr(expr).map_or_else(
400        || match expr {
401            GrammarExpr::NonTerminal(name) => sanitize_identifier(name),
402            _ => sanitize_identifier(
403                grammar_expr_concept_id(expr)
404                    .rsplit('.')
405                    .next()
406                    .unwrap_or("rule"),
407            ),
408        },
409        |concept| concept.name.to_string(),
410    )
411}
412
413fn is_number_expr(expr: &GrammarExpr) -> bool {
414    match expr {
415        GrammarExpr::OneOrMore(inner) => is_digit_expr(inner),
416        GrammarExpr::Repeat { expr, min, max } => *min >= 1 && max.is_none() && is_digit_expr(expr),
417        GrammarExpr::Sequence(items) if items.len() == 2 => {
418            is_optional_sign(&items[0]) && is_number_expr(&items[1])
419        }
420        _ => false,
421    }
422}
423
424fn is_optional_sign(expr: &GrammarExpr) -> bool {
425    match expr {
426        GrammarExpr::Optional(inner) => matches!(
427            inner.as_ref(),
428            GrammarExpr::Terminal(value) if value == "-" || value == "+"
429        ),
430        _ => false,
431    }
432}
433
434fn is_digit_expr(expr: &GrammarExpr) -> bool {
435    match expr {
436        GrammarExpr::CharRange('0', '9') => true,
437        GrammarExpr::Terminal(value) => is_digit_text(value),
438        GrammarExpr::CharClass { negated, items } => {
439            !*negated
440                && items.iter().all(|item| {
441                    matches!(
442                        item,
443                        CharClassItem::Range('0', '9') | CharClassItem::Char('0'..='9')
444                    )
445                })
446        }
447        _ => false,
448    }
449}
450
451fn is_letter_expr(expr: &GrammarExpr) -> bool {
452    match expr {
453        GrammarExpr::CharRange('a', 'z') | GrammarExpr::CharRange('A', 'Z') => true,
454        GrammarExpr::Terminal(value) => is_letter_text(value),
455        GrammarExpr::CharClass { negated, items } => {
456            !*negated
457                && items.iter().all(|item| {
458                    matches!(
459                        item,
460                        CharClassItem::Range('a', 'z')
461                            | CharClassItem::Range('A', 'Z')
462                            | CharClassItem::Char('a'..='z' | 'A'..='Z' | '_')
463                    )
464                })
465        }
466        _ => false,
467    }
468}
469
470fn is_identifier_expr(expr: &GrammarExpr) -> bool {
471    match expr {
472        GrammarExpr::Sequence(items) if items.len() == 2 => {
473            is_letter_expr(&items[0])
474                && matches!(
475                    &items[1],
476                    GrammarExpr::ZeroOrMore(inner) if is_identifier_tail_expr(inner)
477                )
478        }
479        _ => false,
480    }
481}
482
483fn is_identifier_tail_expr(expr: &GrammarExpr) -> bool {
484    is_letter_expr(expr)
485        || is_digit_expr(expr)
486        || matches!(
487            expr,
488            GrammarExpr::Choice { alternatives, .. }
489                if alternatives
490                    .iter()
491                    .all(|alternative| is_letter_expr(alternative) || is_digit_expr(alternative))
492        )
493}
494
495fn is_integer_text(text: &str) -> bool {
496    let digits = text
497        .strip_prefix(['-', '+'])
498        .filter(|rest| !rest.is_empty())
499        .unwrap_or(text);
500    digits.chars().all(|character| character.is_ascii_digit())
501}
502
503fn is_digit_text(text: &str) -> bool {
504    let mut chars = text.chars();
505    chars
506        .next()
507        .is_some_and(|character| character.is_ascii_digit())
508        && chars.next().is_none()
509}
510
511fn is_letter_text(text: &str) -> bool {
512    let mut chars = text.chars();
513    chars
514        .next()
515        .is_some_and(|character| character.is_ascii_alphabetic())
516        && chars.next().is_none()
517}
518
519fn is_identifier_text(text: &str) -> bool {
520    let mut chars = text.chars();
521    let Some(first) = chars.next() else {
522        return false;
523    };
524    (first.is_ascii_alphabetic() || first == '_')
525        && chars.all(|character| character.is_ascii_alphanumeric() || character == '_')
526}
527
528fn sanitize_identifier(value: &str) -> String {
529    let mut output = String::new();
530    for character in value.chars() {
531        let normalized = if character.is_ascii_alphanumeric() || character == '_' {
532            character.to_ascii_lowercase()
533        } else {
534            '_'
535        };
536
537        if output.is_empty() && normalized.is_ascii_digit() {
538            output.push('_');
539        }
540        if normalized == '_' && output.ends_with('_') {
541            continue;
542        }
543        output.push(normalized);
544    }
545
546    let trimmed = output.trim_matches('_');
547    if trimmed.is_empty() {
548        "rule".to_string()
549    } else if trimmed
550        .chars()
551        .next()
552        .is_some_and(|character| character.is_ascii_digit())
553    {
554        format!("_{trimmed}")
555    } else {
556        trimmed.to_string()
557    }
558}
559
560fn unique_rule_name(base: &str, grammar: &Grammar) -> String {
561    if grammar.rule(base).is_none() {
562        return base.to_string();
563    }
564
565    let mut suffix = 2usize;
566    loop {
567        let candidate = format!("{base}_{suffix}");
568        if grammar.rule(&candidate).is_none() {
569            return candidate;
570        }
571        suffix = suffix.saturating_add(1);
572    }
573}
574
575fn validate_name_candidate(request: &NamingRequest<'_>, candidate: &NameCandidate) -> bool {
576    is_valid_identifier(&candidate.name)
577        && request.grammar.rule(&candidate.name).is_none()
578        && candidate.concept.as_deref().is_none_or(known_concept_id)
579}
580
581fn is_valid_identifier(value: &str) -> bool {
582    let mut chars = value.chars();
583    let Some(first) = chars.next() else {
584        return false;
585    };
586    (first.is_ascii_alphabetic() || first == '_')
587        && chars.all(|character| character.is_ascii_alphanumeric() || character == '_')
588}
589
590fn known_concept_id(concept: &str) -> bool {
591    GRAMMAR_CONCEPTS.iter().any(|known| known.id == concept)
592        || INFERENCE_NAMING_CONCEPTS
593            .iter()
594            .any(|known| known.id == concept)
595}
596
597fn validate_merge_scores(scores: Vec<MergeScore>, expected_len: usize) -> Option<Vec<MergeScore>> {
598    if scores.len() != expected_len || scores.iter().any(|score| !score.score.is_finite()) {
599        return None;
600    }
601
602    Some(
603        scores
604            .into_iter()
605            .map(|score| MergeScore {
606                score: score.score.clamp(0.0, 1.0),
607                source: score.source,
608            })
609            .collect(),
610    )
611}
612
613fn merge_candidate_grammar(grammar: &Grammar, candidate: &MergeCandidate) -> Option<Grammar> {
614    if candidate.winner == candidate.loser {
615        return Some(grammar.clone());
616    }
617
618    let winner_rule = grammar.rule(&candidate.winner)?;
619    let loser_rule = grammar.rule(&candidate.loser)?;
620    if grammar.start() == Some(loser_rule.name())
621        || !merge_metadata_compatible(winner_rule, loser_rule)
622    {
623        return None;
624    }
625
626    let winner_name = winner_rule.name().to_string();
627    let loser_name = loser_rule.name().to_string();
628    let replacement = GrammarExpr::non_terminal(&winner_name);
629    let merged_expr = merge_exprs(winner_rule.expr(), loser_rule.expr());
630
631    let mut next = Grammar::new();
632    if let Some(source_format) = grammar.source_format() {
633        next.set_source_format(source_format);
634    }
635
636    for rule in grammar.rules() {
637        if rule.name() == loser_name {
638            continue;
639        }
640
641        let mut next_rule = rule.clone();
642        if next_rule.name() == winner_name {
643            next_rule.expr = merged_expr.clone();
644        }
645        next_rule.expr = rewrite_nonterminal_refs(&next_rule.expr, &loser_name, &replacement);
646        next.add_rule(next_rule);
647    }
648
649    if let Some(start) = grammar.start() {
650        next.set_start(start);
651    }
652    Some(next)
653}
654
655fn merge_metadata_compatible(winner: &GrammarRule, loser: &GrammarRule) -> bool {
656    winner.kind() == loser.kind()
657        && winner.concept() == loser.concept()
658        && winner.doc() == loser.doc()
659        && winner.kind() == RuleKind::Normal
660}
661
662fn merge_exprs(winner: &GrammarExpr, loser: &GrammarExpr) -> GrammarExpr {
663    if winner == loser {
664        return winner.clone();
665    }
666
667    let mut alternatives = Vec::new();
668    push_merge_alternative(&mut alternatives, winner);
669    push_merge_alternative(&mut alternatives, loser);
670
671    if alternatives.len() == 1 {
672        alternatives.remove(0)
673    } else {
674        GrammarExpr::choice(false, alternatives)
675    }
676}
677
678fn push_merge_alternative(alternatives: &mut Vec<GrammarExpr>, expr: &GrammarExpr) {
679    if let GrammarExpr::Choice {
680        ordered: false,
681        alternatives: nested,
682    } = expr
683    {
684        for alternative in nested {
685            push_merge_alternative(alternatives, alternative);
686        }
687        return;
688    }
689
690    if !alternatives.contains(expr) {
691        alternatives.push(expr.clone());
692    }
693}
694
695fn rewrite_nonterminal_refs(
696    expr: &GrammarExpr,
697    loser_name: &str,
698    replacement: &GrammarExpr,
699) -> GrammarExpr {
700    match expr {
701        GrammarExpr::NonTerminal(name) if name == loser_name => replacement.clone(),
702        GrammarExpr::Choice {
703            ordered,
704            alternatives,
705        } => GrammarExpr::choice(
706            *ordered,
707            alternatives
708                .iter()
709                .map(|expr| rewrite_nonterminal_refs(expr, loser_name, replacement)),
710        ),
711        GrammarExpr::Sequence(items) => GrammarExpr::sequence(
712            items
713                .iter()
714                .map(|expr| rewrite_nonterminal_refs(expr, loser_name, replacement)),
715        ),
716        GrammarExpr::Optional(inner) => {
717            GrammarExpr::optional(rewrite_nonterminal_refs(inner, loser_name, replacement))
718        }
719        GrammarExpr::ZeroOrMore(inner) => {
720            GrammarExpr::zero_or_more(rewrite_nonterminal_refs(inner, loser_name, replacement))
721        }
722        GrammarExpr::OneOrMore(inner) => {
723            GrammarExpr::one_or_more(rewrite_nonterminal_refs(inner, loser_name, replacement))
724        }
725        GrammarExpr::Repeat { expr, min, max } => GrammarExpr::repeat(
726            rewrite_nonterminal_refs(expr, loser_name, replacement),
727            *min,
728            *max,
729        ),
730        GrammarExpr::And(inner) => {
731            GrammarExpr::and(rewrite_nonterminal_refs(inner, loser_name, replacement))
732        }
733        GrammarExpr::Not(inner) => {
734            GrammarExpr::not(rewrite_nonterminal_refs(inner, loser_name, replacement))
735        }
736        GrammarExpr::Capture { label, expr } => label.as_deref().map_or_else(
737            || {
738                GrammarExpr::capture_unlabeled(rewrite_nonterminal_refs(
739                    expr,
740                    loser_name,
741                    replacement,
742                ))
743            },
744            |label| {
745                GrammarExpr::capture(
746                    label,
747                    rewrite_nonterminal_refs(expr, loser_name, replacement),
748                )
749            },
750        ),
751        GrammarExpr::Feature(feature) => GrammarExpr::rewrite_feature(feature, |inner| {
752            rewrite_nonterminal_refs(inner, loser_name, replacement)
753        }),
754        GrammarExpr::Empty
755        | GrammarExpr::Terminal(_)
756        | GrammarExpr::TerminalInsensitive(_)
757        | GrammarExpr::CharRange(_, _)
758        | GrammarExpr::CharClass { .. }
759        | GrammarExpr::AnyChar
760        | GrammarExpr::NonTerminal(_) => expr.clone(),
761    }
762}
763
764fn score_from_delta(delta: f64) -> f64 {
765    if !delta.is_finite() {
766        return 0.0;
767    }
768
769    let magnitude = delta.abs() / (1.0 + delta.abs());
770    if delta < -COST_EPSILON {
771        f64::midpoint(1.0, magnitude)
772    } else if delta > COST_EPSILON {
773        f64::midpoint(1.0, -magnitude)
774    } else {
775        0.5
776    }
777}