Skip to main content

meta_language/grammar/inference/advisor/
llm.rs

1use super::{
2    AdviceSource, ConceptNamingAdvisor, INFERENCE_NAMING_CONCEPTS, MdlMergeAdvisor, MergeAdvisor,
3    MergeRequest, MergeScore, NameCandidate, NamingAdvisor, NamingRequest, validate_name_candidate,
4};
5
6/// Provider-agnostic LLM boundary for optional inference acceleration.
7pub trait LlmClient: Send + Sync {
8    /// Completes one prompt.
9    fn complete(&self, prompt: &str) -> Result<String, LlmError>;
10}
11
12/// Error returned by an optional LLM client.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct LlmError {
15    message: String,
16}
17
18impl LlmError {
19    /// Builds an LLM client error.
20    #[must_use]
21    pub fn new(message: impl Into<String>) -> Self {
22        Self {
23            message: message.into(),
24        }
25    }
26
27    /// Human-readable error message.
28    #[must_use]
29    pub fn message(&self) -> &str {
30        &self.message
31    }
32}
33
34impl std::fmt::Display for LlmError {
35    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        formatter.write_str(&self.message)
37    }
38}
39
40impl std::error::Error for LlmError {}
41
42/// Optional LLM-backed naming advisor with deterministic fallback.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct LlmNamingAdvisor<C> {
45    client: C,
46    fallback: ConceptNamingAdvisor,
47}
48
49impl<C> LlmNamingAdvisor<C> {
50    /// Builds an LLM naming advisor with the default deterministic fallback.
51    #[must_use]
52    pub const fn new(client: C) -> Self {
53        Self {
54            client,
55            fallback: ConceptNamingAdvisor,
56        }
57    }
58
59    /// Builds an LLM naming advisor with an explicit deterministic fallback.
60    #[must_use]
61    pub const fn with_fallback(client: C, fallback: ConceptNamingAdvisor) -> Self {
62        Self { client, fallback }
63    }
64}
65
66impl<C> NamingAdvisor for LlmNamingAdvisor<C>
67where
68    C: LlmClient,
69{
70    fn propose_names(&self, request: &NamingRequest<'_>) -> Vec<NameCandidate> {
71        let deterministic = self.fallback.propose_names(request);
72        let Ok(response) = self.client.complete(&naming_prompt(request)) else {
73            return deterministic;
74        };
75
76        let candidates = parse_name_candidates(&response)
77            .into_iter()
78            .filter(|candidate| validate_name_candidate(request, candidate))
79            .collect::<Vec<_>>();
80
81        if candidates.is_empty() {
82            deterministic
83        } else {
84            candidates
85        }
86    }
87}
88
89/// Optional LLM-backed merge advisor with deterministic fallback.
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct LlmMergeAdvisor<C> {
92    client: C,
93    fallback: MdlMergeAdvisor,
94}
95
96impl<C> LlmMergeAdvisor<C> {
97    /// Builds an LLM merge advisor with the default deterministic fallback.
98    #[must_use]
99    pub const fn new(client: C) -> Self {
100        Self {
101            client,
102            fallback: MdlMergeAdvisor,
103        }
104    }
105
106    /// Builds an LLM merge advisor with an explicit deterministic fallback.
107    #[must_use]
108    pub const fn with_fallback(client: C, fallback: MdlMergeAdvisor) -> Self {
109        Self { client, fallback }
110    }
111}
112
113impl<C> MergeAdvisor for LlmMergeAdvisor<C>
114where
115    C: LlmClient,
116{
117    fn rank_merges(&self, request: &MergeRequest<'_>) -> Vec<MergeScore> {
118        let deterministic = self.fallback.rank_merges(request);
119        let Ok(response) = self.client.complete(&merge_prompt(request)) else {
120            return deterministic;
121        };
122        let Some(parsed) = parse_merge_scores(&response, request.candidates.len()) else {
123            return deterministic;
124        };
125
126        parsed
127            .into_iter()
128            .zip(deterministic)
129            .map(|(score, deterministic)| {
130                let mut score = score.clamp(0.0, 1.0);
131                if deterministic.score < 0.5 {
132                    score = score.min(deterministic.score);
133                }
134                MergeScore {
135                    score,
136                    source: AdviceSource::Llm,
137                }
138            })
139            .collect()
140    }
141}
142
143fn naming_prompt(request: &NamingRequest<'_>) -> String {
144    let concepts = INFERENCE_NAMING_CONCEPTS
145        .iter()
146        .map(|concept| format!("{}={}", concept.id, concept.name))
147        .collect::<Vec<_>>()
148        .join(", ");
149    format!(
150        "Suggest one grammar rule name as name|concept.\nExpression: {}\nSamples: {:?}\nConcepts: {concepts}",
151        request.rule_expr, request.sample_yields
152    )
153}
154
155fn merge_prompt(request: &MergeRequest<'_>) -> String {
156    let candidates = request
157        .candidates
158        .iter()
159        .map(|candidate| format!("{}<-{}", candidate.winner, candidate.loser))
160        .collect::<Vec<_>>()
161        .join(", ");
162    format!("Score merge candidates in order with numbers from 0 to 1: {candidates}")
163}
164
165fn parse_name_candidates(response: &str) -> Vec<NameCandidate> {
166    response.lines().filter_map(parse_name_candidate).collect()
167}
168
169fn parse_name_candidate(line: &str) -> Option<NameCandidate> {
170    let line = line.trim();
171    if line.is_empty() {
172        return None;
173    }
174
175    let (name, concept) = line
176        .split_once('|')
177        .map_or((line, None), |(name, concept)| {
178            let concept = concept.trim();
179            let concept = if concept.is_empty() || concept.eq_ignore_ascii_case("none") {
180                None
181            } else {
182                Some(concept.to_string())
183            };
184            (name, concept)
185        });
186    let name = name.trim().trim_matches(['"', '\'', '`']);
187    (!name.is_empty()).then(|| NameCandidate {
188        name: name.to_string(),
189        concept,
190        source: AdviceSource::Llm,
191    })
192}
193
194fn parse_merge_scores(response: &str, expected_len: usize) -> Option<Vec<f64>> {
195    let scores = response
196        .split(|character: char| character.is_ascii_whitespace() || matches!(character, ',' | ';'))
197        .filter_map(|token| token.trim().parse::<f64>().ok())
198        .collect::<Vec<_>>();
199
200    (scores.len() == expected_len && scores.iter().all(|score| score.is_finite())).then_some(scores)
201}