1use thiserror::Error;
5
6use crate::ai::provider::InferenceOutputs;
7use crate::ai::registry::{BackendKind, Task};
8
9#[derive(Debug, Error, PartialEq, Eq)]
11pub enum AdapterError {
12 #[error("classification requires the model's labels but none were provided")]
15 MissingLabels,
16
17 #[error("classifier chose index {index} but only {len} labels are defined")]
19 LabelIndexOutOfRange {
20 index: usize,
22 len: usize,
24 },
25
26 #[error("classifier returned no logits for a row")]
28 EmptyLogits,
29
30 #[error(
32 "sentiment scoring requires 'positive' and 'negative' among the labels, got {labels:?}"
33 )]
34 SentimentLabelsUnusable {
35 labels: Vec<String>,
37 },
38
39 #[error("remote sentiment reply had no parseable score: {reply:?}")]
41 UnparseableScore {
42 reply: String,
44 },
45
46 #[error("task '{task}' on the {kind:?} backend expected {expected} output, got {got}")]
48 UnexpectedOutputShape {
49 task: Task,
51 kind: BackendKind,
53 expected: &'static str,
55 got: &'static str,
57 },
58
59 #[error("task '{task}' is not supported on the {kind:?} backend")]
61 UnsupportedCombination {
62 task: Task,
64 kind: BackendKind,
66 },
67}
68
69pub fn parse_response(
80 task: Task,
81 kind: BackendKind,
82 raw: InferenceOutputs,
83 labels: Option<&[String]>,
84) -> Result<InferenceOutputs, AdapterError> {
85 match task {
86 Task::Classify => match kind {
87 BackendKind::Local => classify_from_logits(kind, raw, labels),
88 BackendKind::Remote => coerce_remote_classification(raw, labels),
89 },
90 Task::Sentiment => match kind {
91 BackendKind::Local => sentiment_from_logits(raw, labels),
92 BackendKind::Remote => sentiment_from_text(raw),
93 },
94 Task::Embed => expect_vectors(task, kind, raw),
95 Task::Complete | Task::Summarize | Task::Translate | Task::Gen | Task::Extract => {
96 if kind != BackendKind::Remote {
97 return Err(AdapterError::UnsupportedCombination { task, kind });
98 }
99 expect_text(task, kind, raw)
100 }
101 }
102}
103
104fn classify_from_logits(
106 kind: BackendKind,
107 raw: InferenceOutputs,
108 labels: Option<&[String]>,
109) -> Result<InferenceOutputs, AdapterError> {
110 let rows = match raw {
111 InferenceOutputs::Vectors(rows) => rows,
112 other => {
113 return Err(AdapterError::UnexpectedOutputShape {
114 task: Task::Classify,
115 kind,
116 expected: "vectors",
117 got: shape_name(&other),
118 });
119 }
120 };
121 let labels = labels.ok_or(AdapterError::MissingLabels)?;
122 let mut out = Vec::with_capacity(rows.len());
123 for logits in rows {
124 let index = argmax(&logits).ok_or(AdapterError::EmptyLogits)?;
125 let label = labels
126 .get(index)
127 .ok_or(AdapterError::LabelIndexOutOfRange {
128 index,
129 len: labels.len(),
130 })?;
131 out.push(label.clone());
132 }
133 Ok(InferenceOutputs::Text(out))
134}
135
136fn coerce_remote_classification(
139 raw: InferenceOutputs,
140 labels: Option<&[String]>,
141) -> Result<InferenceOutputs, AdapterError> {
142 let texts = match raw {
143 InferenceOutputs::Text(texts) => texts,
144 other => {
145 return Err(AdapterError::UnexpectedOutputShape {
146 task: Task::Classify,
147 kind: BackendKind::Remote,
148 expected: "text",
149 got: shape_name(&other),
150 });
151 }
152 };
153 let coerced = texts
154 .into_iter()
155 .map(|text| match labels {
156 Some(labels) => coerce_label(&text, labels),
157 None => text.trim().to_string(),
158 })
159 .collect();
160 Ok(InferenceOutputs::Text(coerced))
161}
162
163fn coerce_label(text: &str, labels: &[String]) -> String {
165 let trimmed = text.trim();
166 if let Some(label) = labels.iter().find(|l| l.eq_ignore_ascii_case(trimmed)) {
167 return label.clone();
168 }
169 let lower = trimmed.to_ascii_lowercase();
170 if let Some(label) = labels
171 .iter()
172 .find(|l| lower.contains(&l.to_ascii_lowercase()))
173 {
174 return label.clone();
175 }
176 trimmed.to_string()
177}
178
179fn expect_text(
181 task: Task,
182 kind: BackendKind,
183 raw: InferenceOutputs,
184) -> Result<InferenceOutputs, AdapterError> {
185 match raw {
186 InferenceOutputs::Text(text) => Ok(InferenceOutputs::Text(text)),
187 other => Err(AdapterError::UnexpectedOutputShape {
188 task,
189 kind,
190 expected: "text",
191 got: shape_name(&other),
192 }),
193 }
194}
195
196fn expect_vectors(
198 task: Task,
199 kind: BackendKind,
200 raw: InferenceOutputs,
201) -> Result<InferenceOutputs, AdapterError> {
202 match raw {
203 InferenceOutputs::Vectors(vectors) => Ok(InferenceOutputs::Vectors(vectors)),
204 other => Err(AdapterError::UnexpectedOutputShape {
205 task,
206 kind,
207 expected: "vectors",
208 got: shape_name(&other),
209 }),
210 }
211}
212
213fn shape_name(raw: &InferenceOutputs) -> &'static str {
215 match raw {
216 InferenceOutputs::Text(_) => "text",
217 InferenceOutputs::Vectors(_) => "vectors",
218 InferenceOutputs::Scores(_) => "scores",
219 }
220}
221
222fn sentiment_from_logits(
224 raw: InferenceOutputs,
225 labels: Option<&[String]>,
226) -> Result<InferenceOutputs, AdapterError> {
227 let rows = match raw {
228 InferenceOutputs::Vectors(rows) => rows,
229 other => {
230 return Err(AdapterError::UnexpectedOutputShape {
231 task: Task::Sentiment,
232 kind: BackendKind::Local,
233 expected: "vectors",
234 got: shape_name(&other),
235 });
236 }
237 };
238 let labels = labels.ok_or(AdapterError::MissingLabels)?;
239 let pos = labels
240 .iter()
241 .position(|l| l.eq_ignore_ascii_case("positive"));
242 let neg = labels
243 .iter()
244 .position(|l| l.eq_ignore_ascii_case("negative"));
245 let (Some(pos), Some(neg)) = (pos, neg) else {
246 return Err(AdapterError::SentimentLabelsUnusable {
247 labels: labels.to_vec(),
248 });
249 };
250 let mut scores = Vec::with_capacity(rows.len());
251 for logits in rows {
252 if logits.is_empty() {
253 return Err(AdapterError::EmptyLogits);
254 }
255 let probs = softmax(&logits);
256 let p_pos = probs.get(pos).copied().unwrap_or(0.0);
257 let p_neg = probs.get(neg).copied().unwrap_or(0.0);
258 scores.push(f64::from(p_pos - p_neg));
259 }
260 Ok(InferenceOutputs::Scores(scores))
261}
262
263fn sentiment_from_text(raw: InferenceOutputs) -> Result<InferenceOutputs, AdapterError> {
265 let texts = match raw {
266 InferenceOutputs::Text(texts) => texts,
267 other => {
268 return Err(AdapterError::UnexpectedOutputShape {
269 task: Task::Sentiment,
270 kind: BackendKind::Remote,
271 expected: "text",
272 got: shape_name(&other),
273 });
274 }
275 };
276 let mut scores = Vec::with_capacity(texts.len());
277 for reply in texts {
278 let value = parse_score(&reply).ok_or(AdapterError::UnparseableScore { reply })?;
279 scores.push(value.clamp(-1.0, 1.0));
280 }
281 Ok(InferenceOutputs::Scores(scores))
282}
283
284fn softmax(logits: &[f32]) -> Vec<f32> {
286 let max = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
287 let mut exps: Vec<f32> = logits.iter().map(|&v| (v - max).exp()).collect();
288 let sum: f32 = exps.iter().sum();
289 if sum > 0.0 {
290 for e in &mut exps {
291 *e /= sum;
292 }
293 }
294 exps
295}
296
297fn parse_score(reply: &str) -> Option<f64> {
299 let trimmed = reply.trim();
300 if let Ok(v) = trimmed.parse::<f64>() {
301 return Some(v);
302 }
303 trimmed.split_whitespace().find_map(|token| {
304 token
307 .trim_start_matches(|c: char| !(c.is_ascii_digit() || c == '-'))
308 .trim_end_matches(|c: char| !c.is_ascii_digit())
309 .parse::<f64>()
310 .ok()
311 })
312}
313
314fn argmax(values: &[f32]) -> Option<usize> {
316 let mut best: Option<(usize, f32)> = None;
317 for (index, &value) in values.iter().enumerate() {
318 match best {
319 Some((_, current)) if value <= current => {}
320 _ => best = Some((index, value)),
321 }
322 }
323 best.map(|(index, _)| index)
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 fn labels() -> Vec<String> {
331 vec!["negative".into(), "positive".into(), "neutral".into()]
332 }
333
334 #[test]
335 fn local_classify_argmaxes_logits_to_labels() {
336 let raw = InferenceOutputs::Vectors(vec![vec![0.1, 0.9, 0.2], vec![5.0, 0.0, 0.0]]);
337 let out = parse_response(Task::Classify, BackendKind::Local, raw, Some(&labels())).unwrap();
338 assert_eq!(
339 out,
340 InferenceOutputs::Text(vec!["positive".into(), "negative".into()])
341 );
342 }
343
344 #[test]
345 fn local_classify_requires_labels() {
346 let raw = InferenceOutputs::Vectors(vec![vec![0.1, 0.9]]);
347 assert_eq!(
348 parse_response(Task::Sentiment, BackendKind::Local, raw, None),
349 Err(AdapterError::MissingLabels)
350 );
351 }
352
353 #[test]
354 fn remote_classify_coerces_to_canonical_label() {
355 let raw = InferenceOutputs::Text(vec![
358 "The sentiment is Positive.".into(),
359 "NEGATIVE".into(),
360 "totally unrelated".into(),
361 ]);
362 let out =
363 parse_response(Task::Classify, BackendKind::Remote, raw, Some(&labels())).unwrap();
364 assert_eq!(
365 out,
366 InferenceOutputs::Text(vec![
367 "positive".into(),
368 "negative".into(),
369 "totally unrelated".into(),
370 ])
371 );
372 }
373
374 #[test]
375 fn local_sentiment_softmaxes_logits_to_a_signed_score() {
376 let raw = InferenceOutputs::Vectors(vec![vec![0.0, 2.0, 0.0], vec![3.0, 0.0, 0.0]]);
379 let out =
380 parse_response(Task::Sentiment, BackendKind::Local, raw, Some(&labels())).unwrap();
381 let InferenceOutputs::Scores(scores) = out else {
382 panic!("sentiment is numeric");
383 };
384 let denom = 2.0 + std::f32::consts::E.powi(2);
386 let expected0 = f64::from((std::f32::consts::E.powi(2) - 1.0) / denom);
387 assert!((scores[0] - expected0).abs() < 1e-6, "got {}", scores[0]);
388 assert!(scores[0] > 0.0 && scores[1] < 0.0);
389 assert!((-1.0..=1.0).contains(&scores[0]) && (-1.0..=1.0).contains(&scores[1]));
390 }
391
392 #[test]
393 fn local_sentiment_needs_positive_and_negative_labels() {
394 let raw = InferenceOutputs::Vectors(vec![vec![0.1, 0.9]]);
395 let stars = vec!["one_star".to_string(), "five_star".to_string()];
396 assert!(matches!(
397 parse_response(Task::Sentiment, BackendKind::Local, raw, Some(&stars)),
398 Err(AdapterError::SentimentLabelsUnusable { .. })
399 ));
400 }
401
402 #[test]
403 fn remote_sentiment_parses_and_clamps_a_number() {
404 let raw = InferenceOutputs::Text(vec![
405 "0.8".into(),
406 "The sentiment is -0.5.".into(),
407 "2.0".into(), ]);
409 let out = parse_response(Task::Sentiment, BackendKind::Remote, raw, None).unwrap();
410 let InferenceOutputs::Scores(scores) = out else {
411 panic!("sentiment is numeric");
412 };
413 assert!((scores[0] - 0.8).abs() < 1e-12);
414 assert!((scores[1] + 0.5).abs() < 1e-12);
415 assert!((scores[2] - 1.0).abs() < 1e-12);
416 }
417
418 #[test]
419 fn generation_is_remote_only() {
420 let raw = InferenceOutputs::Vectors(vec![vec![0.0]]);
421 assert_eq!(
422 parse_response(Task::Complete, BackendKind::Local, raw, None),
423 Err(AdapterError::UnsupportedCombination {
424 task: Task::Complete,
425 kind: BackendKind::Local,
426 })
427 );
428 }
429}