use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use areev_core::decide::{Answer, DecideError, DecideRequest, Decision, DecisionBackend, Question};
use serde_json::{json, Value};
pub const RELEVANCE_LEVELS: [&str; 4] = ["off-topic", "tangential", "relevant", "directly answers"];
pub const MAX_STATE_TOKENS: usize = 28_000;
pub const CANDIDATE_TEXT_CHARS: usize = 600;
pub const DEFAULT_DROP_BELOW: f32 = 0.10;
pub const DEFAULT_FULL_ABOVE: f32 = 0.50;
pub const INTENT_QUESTION: &str = "intent";
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Judgment {
pub relevance: f32,
pub p_verbatim: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct JudgeProvenance {
pub provider: String,
pub model: String,
pub calibrated: bool,
pub requests: u32,
pub latency_ms: u64,
}
impl JudgeProvenance {
fn from_decisions(ds: &[Decision]) -> Self {
let join = |f: fn(&Decision) -> &str| {
let mut seen: Vec<&str> = Vec::new();
for d in ds {
if !seen.contains(&f(d)) {
seen.push(f(d));
}
}
seen.join(",")
};
JudgeProvenance {
provider: join(|d| d.provider.as_str()),
model: join(|d| d.model.as_str()),
calibrated: ds.iter().all(|d| d.calibrated),
requests: ds.len() as u32,
latency_ms: ds.iter().map(|d| d.latency_ms).sum(),
}
}
pub fn merge(&self, other: &JudgeProvenance) -> JudgeProvenance {
let join = |a: &str, b: &str| {
let mut parts: Vec<&str> = a.split(',').filter(|s| !s.is_empty()).collect();
for p in b.split(',').filter(|s| !s.is_empty()) {
if !parts.contains(&p) {
parts.push(p);
}
}
parts.join(",")
};
JudgeProvenance {
provider: join(&self.provider, &other.provider),
model: join(&self.model, &other.model),
calibrated: self.calibrated && other.calibrated,
requests: self.requests + other.requests,
latency_ms: self.latency_ms + other.latency_ms,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Judgments {
pub per_candidate: Vec<Judgment>,
pub provenance: JudgeProvenance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueryIntent {
Timeline,
CurrentState,
General,
}
impl QueryIntent {
pub fn as_str(self) -> &'static str {
match self {
QueryIntent::Timeline => "timeline",
QueryIntent::CurrentState => "current_state",
QueryIntent::General => "general",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct IntentJudgment {
pub intent: QueryIntent,
pub provenance: JudgeProvenance,
}
pub fn cap_chars(s: &str, max: usize) -> &str {
match s.char_indices().nth(max) {
Some((i, _)) => &s[..i],
None => s,
}
}
pub fn intent_request(query: &str) -> DecideRequest {
let mut qs = BTreeMap::new();
qs.insert(
INTENT_QUESTION.to_string(),
Question::choice(
"Which kind of answer does `query` ask for?",
[
("timeline", "the question is about sequence, history, what happened when"),
("current_state", "the question wants the latest/current value"),
("general", "any other question"),
],
),
);
DecideRequest::new(json!({ "query": query }), qs)
}
fn candidate_questions(n: usize, qs: &mut BTreeMap<String, Question>) {
qs.insert(
format!("rel_{n}"),
Question::score(
format!("How relevant is candidates[{n}] to `query` for answering it?"),
RELEVANCE_LEVELS,
),
);
qs.insert(
format!("verbatim_{n}"),
Question::noul(format!(
"Would a one-line summary of candidates[{n}] lose a detail `query` needs?"
)),
);
}
pub fn disclosure_requests(query: &str, texts: &[String]) -> Vec<(usize, DecideRequest)> {
let budget_chars = MAX_STATE_TOKENS * 4;
let base = json!({ "query": query, "candidates": [] }).to_string().len();
let mut out = Vec::new();
let mut start = 0usize;
let mut cands: Vec<Value> = Vec::new();
let mut size = base;
let flush = |start: usize, cands: &mut Vec<Value>, out: &mut Vec<(usize, DecideRequest)>| {
let mut qs = BTreeMap::new();
for n in 0..cands.len() {
candidate_questions(n, &mut qs);
}
let state = json!({ "query": query, "candidates": std::mem::take(cands) });
out.push((start, DecideRequest::new(state, qs)));
};
for (idx, text) in texts.iter().enumerate() {
let n = cands.len();
let item = json!({ "i": n, "text": cap_chars(text, CANDIDATE_TEXT_CHARS) });
let item_len = item.to_string().len() + 1; if !cands.is_empty() && size + item_len > budget_chars {
flush(start, &mut cands, &mut out);
start = idx;
size = base;
let item = json!({ "i": 0, "text": cap_chars(text, CANDIDATE_TEXT_CHARS) });
size += item.to_string().len() + 1;
cands.push(item);
continue;
}
size += item_len;
cands.push(item);
}
if !cands.is_empty() {
flush(start, &mut cands, &mut out);
}
out
}
fn remaining(deadline: Option<Duration>, started: Instant) -> Result<Option<Duration>, DecideError> {
match deadline {
None => Ok(None),
Some(d) => match d.checked_sub(started.elapsed()) {
Some(r) if !r.is_zero() => Ok(Some(r)),
_ => Err(DecideError::Deadline(format!(
"the {} ms judgment budget was spent before every request was sent",
d.as_millis()
))),
},
}
}
pub fn judge_candidates(
backend: &dyn DecisionBackend,
query: &str,
texts: &[String],
deadline: Option<Duration>,
) -> Result<Judgments, DecideError> {
let started = Instant::now();
let mut per_candidate = Vec::with_capacity(texts.len());
let mut decisions = Vec::new();
for (start, req) in disclosure_requests(query, texts) {
let req = req.with_deadline(remaining(deadline, started)?);
let decision = backend.decide(&req)?;
let count = req.questions.len() / 2;
for n in 0..count {
let rel = match decision.answers.get(&format!("rel_{n}")) {
Some(Answer::Score { score, .. }) if score.is_finite() && (0.0..=3.0).contains(score) => {
score / 3.0
}
other => {
return Err(DecideError::Malformed(format!(
"candidate {}: rel_{n} is not a score in 0..=3 ({other:?})",
start + n
)))
}
};
let verbatim = match decision.answers.get(&format!("verbatim_{n}")) {
Some(Answer::Noul { p }) if p.is_finite() && (0.0..=1.0).contains(p) => *p,
other => {
return Err(DecideError::Malformed(format!(
"candidate {}: verbatim_{n} is not a noul in 0..=1 ({other:?})",
start + n
)))
}
};
per_candidate.push(Judgment { relevance: rel, p_verbatim: verbatim });
}
decisions.push(decision);
}
Ok(Judgments { per_candidate, provenance: JudgeProvenance::from_decisions(&decisions) })
}
pub fn judge_intent(
backend: &dyn DecisionBackend,
query: &str,
deadline: Option<Duration>,
) -> Result<IntentJudgment, DecideError> {
let req = intent_request(query).with_deadline(deadline);
let decision = backend.decide(&req)?;
let intent = match decision.answers.get(INTENT_QUESTION) {
Some(Answer::Choice { choice, .. }) => match choice.as_str() {
"timeline" => QueryIntent::Timeline,
"current_state" => QueryIntent::CurrentState,
"general" => QueryIntent::General,
other => return Err(DecideError::Malformed(format!("intent choice {other:?} is not an option"))),
},
other => return Err(DecideError::Malformed(format!("intent is not a choice ({other:?})"))),
};
Ok(IntentJudgment { intent, provenance: JudgeProvenance::from_decisions(&[decision]) })
}
pub fn by_relevance_desc(idx: &mut [usize], judgments: &[Judgment]) {
idx.sort_by(|&a, &b| {
judgments[b]
.relevance
.partial_cmp(&judgments[a].relevance)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requests_split_under_the_state_cap_and_renumber() {
let texts: Vec<String> = (0..400).map(|i| format!("{i:03}").repeat(200)).collect();
let reqs = disclosure_requests("q", &texts);
assert!(reqs.len() >= 2, "400 × 600 chars must split");
let mut covered = 0;
for (start, r) in &reqs {
assert_eq!(*start, covered);
assert!(r.state.to_string().len() / 4 <= MAX_STATE_TOKENS);
let cands = r.state["candidates"].as_array().unwrap();
assert_eq!(cands[0]["i"], 0);
assert_eq!(r.questions.len(), cands.len() * 2);
assert!(r.validate().is_ok());
covered += cands.len();
}
assert_eq!(covered, texts.len());
}
#[test]
fn candidate_text_is_capped_on_a_char_boundary() {
let long = "é".repeat(700);
let reqs = disclosure_requests("q", &[long]);
let text = reqs[0].1.state["candidates"][0]["text"].as_str().unwrap();
assert_eq!(text.chars().count(), CANDIDATE_TEXT_CHARS);
}
#[test]
fn intent_request_is_askable() {
let r = intent_request("when did it happen?");
assert!(r.validate().is_ok());
assert_eq!(r.questions[INTENT_QUESTION].kind(), "choice");
}
#[test]
fn provenance_merge_joins_distinct_and_ands_calibration() {
let a = JudgeProvenance { provider: "x".into(), model: "m".into(), calibrated: true, requests: 1, latency_ms: 3 };
let b = JudgeProvenance { provider: "y".into(), model: "m".into(), calibrated: false, requests: 2, latency_ms: 4 };
let m = a.merge(&b);
assert_eq!(m.provider, "x,y");
assert_eq!(m.model, "m");
assert!(!m.calibrated);
assert_eq!((m.requests, m.latency_ms), (3, 7));
}
}