use std::sync::Arc;
use axum::{extract::State, Json};
use frink_models::EmbeddingModel;
use crate::openai_extra::Call;
use crate::{join_error_response, ApiError, AppState};
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ScoreRequest {
#[serde(default)]
pub model: Option<String>,
pub text_1: TextInput,
pub text_2: TextInput,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(untagged)]
pub enum TextInput {
One(String),
Many(Vec<String>),
}
impl TextInput {
fn as_slice(&self) -> &[String] {
match self {
TextInput::One(_) => std::slice::from_ref(self.single()),
TextInput::Many(v) => v,
}
}
fn single(&self) -> &String {
match self {
TextInput::One(s) => s,
TextInput::Many(v) => &v[0],
}
}
}
pub(crate) fn pairs(a: &[String], b: &[String]) -> Result<Vec<(usize, usize)>, ApiError> {
if a.is_empty() || b.is_empty() {
return Err(crate::invalid_request(
"`text_1` and `text_2` must each name at least one text",
"text_1",
));
}
if a.len() == 1 {
return Ok((0..b.len()).map(|j| (0, j)).collect());
}
if b.len() == 1 {
return Ok((0..a.len()).map(|i| (i, 0)).collect());
}
if a.len() != b.len() {
return Err(crate::invalid_request(
&format!(
"`text_1` has {} entries and `text_2` has {}; give one text on either side, \
or the same number on both",
a.len(),
b.len()
),
"text_2",
));
}
Ok((0..a.len()).map(|i| (i, i)).collect())
}
pub(crate) fn cosine(a: &[f32], b: &[f32]) -> Option<f32> {
if a.len() != b.len() {
return None;
}
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
let c = dot / (na * nb);
c.is_finite().then_some(c)
}
pub async fn score(
State(state): State<Arc<AppState>>,
headers: axum::http::HeaderMap,
Json(req): Json<ScoreRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let call = Call::new(&headers);
let result = score_inner(&state, req).await;
let usage = result
.as_ref()
.ok()
.map(|(_, prompt_tokens)| frink_api::Usage::new(*prompt_tokens, 0));
call.record(
&state,
frink_api::routes::V1_SCORE,
state.embedding_model_name(),
&result,
usage.as_ref(),
);
result.map(|(body, _)| Json(body))
}
fn require_encoder(state: &AppState) -> Result<Arc<EmbeddingModel>, ApiError> {
state.embedding_model().ok_or_else(|| {
state.require_active().err().unwrap_or_else(|| {
crate::unsupported_feature(&format!(
"the loaded model '{}' is not an encoder. {} needs an embedding or \
cross-encoder checkpoint, loaded as FRINK_MODEL_PATH or beside this one as \
FRINK_EMBEDDING_MODEL_PATH",
state.active_model_name().unwrap_or_else(|| "?".to_string()),
frink_api::routes::V1_SCORE,
))
})
})
}
async fn score_inner(
state: &AppState,
req: ScoreRequest,
) -> Result<(serde_json::Value, usize), ApiError> {
let left: Vec<String> = req.text_1.as_slice().to_vec();
let right: Vec<String> = req.text_2.as_slice().to_vec();
let wanted = pairs(&left, &right)?;
let encoder = require_encoder(state)?;
let model_name = req
.model
.clone()
.unwrap_or_else(|| encoder.name().to_string());
let cross = encoder.rank_head().is_some();
let (scores, prompt_tokens) = tokio::task::spawn_blocking({
let encoder = Arc::clone(&encoder);
let wanted = wanted.clone();
move || -> Result<(Vec<f32>, usize), ApiError> {
let mut scores = Vec::with_capacity(wanted.len());
let mut prompt_tokens = 0usize;
let mut memo: std::collections::HashMap<usize, Vec<f32>> =
std::collections::HashMap::new();
for (n, &(i, j)) in wanted.iter().enumerate() {
if cross {
let pair = encoder
.rerank_input(&left[i], &right[j])
.map_err(|e| pair_error(n, e))?;
prompt_tokens += pair.tokens.len();
scores.push(encoder.rerank_score(&pair).map_err(|e| pair_error(n, e))?);
continue;
}
for (key, text) in [(i, &left[i]), (usize::MAX - j, &right[j])] {
if let std::collections::hash_map::Entry::Vacant(slot) = memo.entry(key) {
prompt_tokens += encoder.token_ids(text).len();
slot.insert(
encoder
.embed(text, false)
.map_err(|e| pair_error(n, e))?,
);
}
}
let c = cosine(&memo[&i], &memo[&(usize::MAX - j)]).ok_or_else(|| {
crate::invalid_request(
&format!(
"pair {n} embeds to a zero-length vector, so the angle between \
the two texts is undefined"
),
"text_1",
)
})?;
scores.push(c);
}
Ok((scores, prompt_tokens))
}
})
.await
.map_err(join_error_response)??;
let data: Vec<serde_json::Value> = scores
.iter()
.enumerate()
.map(|(index, s)| serde_json::json!({ "index": index, "object": "score", "score": s }))
.collect();
Ok((
serde_json::json!({
"object": "list",
"model": model_name,
"data": data,
"frink_score_kind": if cross { "cross_encoder" } else { "cosine_similarity" },
"usage": {
"prompt_tokens": prompt_tokens,
"total_tokens": prompt_tokens,
}
}),
prompt_tokens,
))
}
fn pair_error(index: usize, e: frink_models::EmbedError) -> ApiError {
crate::invalid_request(&format!("pair {index}: {e}"), "text_1")
}
#[cfg(test)]
mod tests {
use super::*;
fn v(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn one_against_many_pairs_in_both_directions() {
assert_eq!(
pairs(&v(&["q"]), &v(&["a", "b", "c"])).expect("valid"),
vec![(0, 0), (0, 1), (0, 2)]
);
assert_eq!(
pairs(&v(&["a", "b"]), &v(&["q"])).expect("valid"),
vec![(0, 0), (1, 0)]
);
}
#[test]
fn equal_lists_pair_element_by_element() {
assert_eq!(
pairs(&v(&["a", "b"]), &v(&["x", "y"])).expect("valid"),
vec![(0, 0), (1, 1)]
);
}
#[test]
fn two_lists_of_different_lengths_are_a_bad_request() {
let err = pairs(&v(&["a", "b", "c"]), &v(&["x", "y"])).expect_err("refused");
assert_eq!(err.0, axum::http::StatusCode::BAD_REQUEST);
let message = err.1["error"]["message"].as_str().unwrap_or_default();
assert!(message.contains('3') && message.contains('2'), "{message}");
}
#[test]
fn an_empty_side_is_a_bad_request() {
assert!(pairs(&[], &v(&["a"])).is_err());
assert!(pairs(&v(&["a"]), &[]).is_err());
}
#[test]
fn cosine_of_identical_vectors_is_one() {
let a = [1.0f32, 2.0, 3.0];
let c = cosine(&a, &a).expect("defined");
assert!((c - 1.0).abs() < 1e-6, "{c}");
let opposite = [-1.0f32, -2.0, -3.0];
let c = cosine(&a, &opposite).expect("defined");
assert!((c + 1.0).abs() < 1e-6, "{c}");
}
#[test]
fn a_zero_vector_has_no_cosine() {
assert_eq!(cosine(&[0.0, 0.0], &[1.0, 1.0]), None);
assert_eq!(cosine(&[1.0], &[1.0, 1.0]), None, "mismatched widths");
}
}