use crate::agent::Agent;
use crate::common::{render_options, serialize_state, InternalQ, QType};
use crate::error::{LayaError, Result};
use crate::Questions;
use serde_json::{json, Map, Value};
pub const DEFAULT_SHORTLIST_K: usize = 20;
pub const DEFAULT_TOURNAMENT_GROUP: usize = 16;
pub const DEFAULT_EMBED_MAX_LENGTH: usize = 512;
pub const DEFAULT_EMBED_BATCH_SIZE: usize = 32;
type Ranked = (Vec<String>, Option<Vec<f64>>, bool, usize);
pub fn embed_fn_from_agent(
agent: &Agent,
max_length: usize,
batch_size: usize,
) -> impl Fn(&[String]) -> Vec<Vec<f64>> + '_ {
move |texts: &[String]| match agent.embed(texts, max_length, batch_size) {
Ok(rows) => rows
.into_iter()
.map(|row| row.into_iter().map(|x| x as f64).collect())
.collect(),
Err(_) => Vec::new(),
}
}
pub fn shortlist_choice<F>(
state: &Value,
criteria: &Value,
embed_fn: F,
k: usize,
instructions: Option<&str>,
) -> Result<Vec<String>>
where
F: Fn(&[String]) -> Vec<Vec<f64>>,
{
Ok(shortlist_choice_scored(state, criteria, embed_fn, k, instructions)?.0)
}
pub fn shortlist_choice_scored<F>(
state: &Value,
criteria: &Value,
embed_fn: F,
k: usize,
instructions: Option<&str>,
) -> Result<(Vec<String>, Option<Vec<f64>>)>
where
F: Fn(&[String]) -> Vec<Vec<f64>>,
{
let (labels, scores, _passthrough, _n) = rank(state, criteria, embed_fn, k, instructions)?;
Ok((labels, scores))
}
pub fn predict_shortlist<F>(
agent: &Agent,
state: &Value,
questions: &Questions,
embed_fn: F,
k: usize,
) -> Result<Value>
where
F: Fn(&[String]) -> Vec<Vec<f64>>,
{
let checked = check_k(k)?;
let mut reduced: Questions = Questions::new();
let mut meta = Map::new();
for (qid, qdef) in questions {
let is_choice = qdef.get("type").and_then(|v| v.as_str()) == Some("choice");
if !qdef.is_object() || !is_choice {
reduced.insert(qid.clone(), qdef.clone());
continue;
}
let criteria = qdef.get("criteria").ok_or_else(|| {
LayaError::InvalidQuestion(format!(
"question {:?} is a choice but has no criteria",
qid
))
})?;
let instructions = qdef.get("instructions").and_then(|v| v.as_str());
let (labels, scores, passthrough, n) =
rank(state, criteria, &embed_fn, checked, instructions)?;
meta.insert(
qid.clone(),
json!({
"labels": labels,
"scores": scores,
"k": checked,
"n": n,
"passthrough": passthrough,
}),
);
if passthrough {
reduced.insert(qid.clone(), qdef.clone());
continue;
}
let mut updated = qdef.clone();
updated["criteria"] = subset_criteria(criteria, &labels);
reduced.insert(qid.clone(), updated);
}
let result = agent.system_one(state, &reduced)?;
let mut out = result.to_json();
out.as_object_mut()
.unwrap()
.insert("shortlist".to_string(), Value::Object(meta));
Ok(out)
}
struct TournamentEntry {
labels: Vec<String>,
n: usize,
rounds: usize,
}
pub fn predict_tournament(
agent: &Agent,
state: &Value,
questions: &Questions,
group_size: usize,
) -> Result<Value> {
if group_size < 2 {
return Err(LayaError::InvalidQuestion(format!(
"group_size must be an integer of at least 2, got {}",
group_size
)));
}
let mut meta: Vec<(String, TournamentEntry)> = Vec::new();
for (qid, qdef) in questions {
if qdef.get("type").and_then(|v| v.as_str()) == Some("choice") {
let criteria = qdef.get("criteria").ok_or_else(|| {
LayaError::InvalidQuestion(format!(
"question {:?} is a choice but has no criteria",
qid
))
})?;
let labels = criteria_keys(criteria)?;
let n = labels.len();
meta.push((
qid.clone(),
TournamentEntry {
labels,
n,
rounds: 0,
},
));
}
}
let cut = |qid: &str, labels: &[String]| -> Value {
let qdef = &questions[qid];
let mut updated = qdef.clone();
updated["criteria"] = subset_criteria(qdef.get("criteria").unwrap(), labels);
updated
};
loop {
let mut groups: Vec<(usize, Vec<String>)> = Vec::new();
for (idx, (_qid, e)) in meta.iter().enumerate() {
let len = e.labels.len();
let parts = len.div_ceil(group_size);
if parts > 1 {
for i in 0..parts {
let lo = i * len / parts;
let hi = (i + 1) * len / parts;
groups.push((idx, e.labels[lo..hi].to_vec()));
}
}
}
if groups.is_empty() {
break;
}
let mut round_qs: Questions = Questions::new();
for (i, (idx, labels)) in groups.iter().enumerate() {
let qid = meta[*idx].0.clone();
round_qs.insert(i.to_string(), cut(&qid, labels));
}
let answers_json = agent.system_one(state, &round_qs)?.to_json();
let answers = answers_json
.get("answers")
.and_then(|v| v.as_object())
.ok_or_else(|| {
LayaError::InvalidQuestion("agent result missing 'answers'".to_string())
})?;
let mut winners: Vec<Vec<String>> = vec![Vec::new(); meta.len()];
for (i, (idx, _labels)) in groups.iter().enumerate() {
let choice = answers
.get(&i.to_string())
.and_then(|a| a.get("choice"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
winners[*idx].push(choice);
}
for (idx, (_qid, e)) in meta.iter_mut().enumerate() {
if !winners[idx].is_empty() {
e.labels = std::mem::take(&mut winners[idx]);
e.rounds += 1;
}
}
}
let mut final_qs: Questions = questions.clone();
for (qid, e) in &meta {
if e.rounds > 0 {
final_qs.insert(qid.clone(), cut(qid, &e.labels));
}
}
let mut out = agent.system_one(state, &final_qs)?.to_json();
let mut tmeta = Map::new();
for (qid, e) in &meta {
tmeta.insert(
qid.clone(),
json!({ "labels": e.labels, "n": e.n, "rounds": e.rounds }),
);
}
out.as_object_mut()
.unwrap()
.insert("tournament".to_string(), Value::Object(tmeta));
Ok(out)
}
fn rank<F>(
state: &Value,
criteria: &Value,
embed_fn: F,
k: usize,
instructions: Option<&str>,
) -> Result<Ranked>
where
F: Fn(&[String]) -> Vec<Vec<f64>>,
{
let checked = check_k(k)?;
let keys = criteria_keys(criteria)?;
let n = keys.len();
if checked >= n {
return Ok((keys, None, true, n));
}
let query = query_text(state, instructions);
let option_texts = option_texts(criteria)?;
let mut texts = Vec::with_capacity(1 + option_texts.len());
texts.push(query);
texts.extend(option_texts);
let matrix = embed_fn(&texts);
if matrix.len() != texts.len() {
return Err(LayaError::InvalidQuestion(format!(
"embed_fn must return {} vectors, got {}",
texts.len(),
matrix.len()
)));
}
let sims = cosine(&matrix[0], &matrix[1..]);
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| {
sims[b]
.partial_cmp(&sims[a])
.unwrap_or(std::cmp::Ordering::Equal)
});
order.truncate(checked);
let labels = order.iter().map(|&i| keys[i].clone()).collect();
let scores = order.iter().map(|&i| sims[i]).collect();
Ok((labels, Some(scores), false, n))
}
fn check_k(k: usize) -> Result<usize> {
if k < 1 {
return Err(LayaError::InvalidQuestion(
"k must be a positive integer".to_string(),
));
}
Ok(k)
}
fn criteria_keys(criteria: &Value) -> Result<Vec<String>> {
let keys: Vec<String> = match criteria {
Value::Object(m) => m.keys().cloned().collect(),
Value::Array(a) => a
.iter()
.map(|v| {
v.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| crate::common::py_json(v))
})
.collect(),
_ => {
return Err(LayaError::InvalidQuestion(
"choice criteria must be a dict or list".to_string(),
))
}
};
if keys.is_empty() {
return Err(LayaError::InvalidQuestion(
"choice criteria must contain at least one option".to_string(),
));
}
let mut seen = std::collections::HashSet::new();
for k in &keys {
if !seen.insert(k.clone()) {
return Err(LayaError::InvalidQuestion(format!(
"choice criteria label {:?} is duplicated",
k
)));
}
}
Ok(keys)
}
fn option_texts(criteria: &Value) -> Result<Vec<String>> {
let q = InternalQ {
t: QType::Choice,
ins: String::new(),
crit: normalize_choice_crit(criteria),
labels: None,
option_order: None,
};
render_options(&q)
}
fn normalize_choice_crit(criteria: &Value) -> Value {
match criteria {
Value::Array(a) => {
let mut m = Map::new();
for c in a {
let key = c
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| crate::common::py_json(c));
m.insert(key, Value::Null);
}
Value::Object(m)
}
other => other.clone(),
}
}
fn query_text(state: &Value, instructions: Option<&str>) -> String {
let body = serialize_state(state);
match instructions {
None | Some("") => body,
Some(ins) => format!("{}\n{}", ins, body),
}
}
fn subset_criteria(criteria: &Value, labels: &[String]) -> Value {
match criteria {
Value::Object(m) => {
let mut out = Map::new();
for l in labels {
if let Some(v) = m.get(l) {
out.insert(l.clone(), v.clone());
}
}
Value::Object(out)
}
_ => Value::Array(labels.iter().map(|l| json!(l)).collect()),
}
}
fn cosine(query: &[f64], docs: &[Vec<f64>]) -> Vec<f64> {
let qn = query.iter().map(|v| v * v).sum::<f64>().sqrt();
if qn == 0.0 || docs.is_empty() {
return vec![0.0; docs.len()];
}
docs.iter()
.map(|d| {
let dn = d.iter().map(|v| v * v).sum::<f64>().sqrt();
let denom = dn * qn;
if denom > 0.0 {
let dot: f64 = d.iter().zip(query).map(|(a, b)| a * b).sum();
(dot / denom).clamp(-1.0, 1.0)
} else {
0.0
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn stub_embed(texts: &[String]) -> Vec<Vec<f64>> {
texts
.iter()
.map(|t| match t.chars().next() {
Some('a') => vec![1.0, 0.0],
Some('b') => vec![0.0, 1.0],
Some('c') => vec![0.7, 0.7],
_ => vec![0.0, 1.0], })
.collect()
}
#[test]
fn scored_returns_signed_cosine_in_rank_order() {
let criteria = json!(["a", "b", "c"]);
let (labels, scores) =
shortlist_choice_scored(&json!("question"), &criteria, stub_embed, 2, None).unwrap();
assert_eq!(labels, vec!["b".to_string(), "c".to_string()]);
let scores = scores.expect("scores present when labels were dropped");
assert_eq!(scores.len(), 2);
assert!(scores[0] >= scores[1]);
assert!((scores[0] - 1.0).abs() < 1e-9);
}
#[test]
fn scored_passthrough_has_no_scores() {
let criteria = json!(["a", "b"]);
let (labels, scores) =
shortlist_choice_scored(&json!("q"), &criteria, stub_embed, 5, None).unwrap();
assert_eq!(labels, vec!["a".to_string(), "b".to_string()]);
assert!(scores.is_none());
}
}