use super::schedules::{Answer, RequestPlan, ScheduleState, plan_requests};
use crate::kinds::ScoreScope;
use rustev_contract::judgment::{Derivation, Lineage, Unresolved};
use rustev_contract::output::{RankingScores, RawOutput};
use rustev_contract::ranking::{Bias, RankMode, RankScores};
pub use rustev_contract::ranking_result::ModelRanking;
use rustev_contract::ranking_result::*;
use std::collections::{BTreeMap, BTreeSet};
pub struct RankingInput<'a> {
pub pool: &'a [String],
pub excluded: &'a [String],
pub mode: &'a RankMode,
pub bias: &'a Bias,
pub scope: &'a ScoreScope,
pub lineage: &'a Lineage,
pub tie_order: Option<&'a [String]>,
}
pub type Outputs = BTreeMap<String, Result<RawOutput, Unresolved>>;
#[derive(Debug, Clone, PartialEq)]
pub struct RankingResult {
pub value: Result<ModelRanking, Unresolved>,
pub evidence: RankingEvidence,
pub derivation: Derivation,
pub lineage: Lineage,
}
pub(crate) fn invalid(detail: &str) -> Unresolved {
Unresolved::InvalidBackendOutput {
detail: detail.into(),
}
}
impl RankingInput<'_> {
fn score(&self, candidate: &str, value: f64, suffix: Option<&str>) -> RankingScore {
let mut instance = self.scope.instance.clone();
if let Some(suffix) = suffix {
instance.push(suffix.into());
}
RankingScore {
candidate: candidate.into(),
plan: self.scope.plan.clone(),
step: self.scope.step.clone(),
instance,
value,
}
}
fn tie(&self, a: &str, b: &str) -> std::cmp::Ordering {
let pos = |id| {
self.tie_order
.and_then(|ids| ids.iter().position(|s| s == id))
.unwrap_or(usize::MAX)
};
pos(a).cmp(&pos(b)).then_with(|| a.cmp(b))
}
}
fn entry(id: &str) -> ModelRankEntry {
ModelRankEntry {
candidate: id.into(),
position: 0,
score: None,
wins: None,
points: None,
}
}
pub fn build(input: &RankingInput<'_>, outputs: &Outputs) -> RankingResult {
let mut evidence = RankingEvidence {
ranking: None,
requests: vec![],
comparisons: vec![],
};
let value = assemble(input, outputs, &mut evidence);
evidence.ranking = value.as_ref().ok().cloned();
RankingResult {
value,
evidence,
derivation: Derivation::ModelDerived,
lineage: input.lineage.clone(),
}
}
fn assemble(
i: &RankingInput<'_>,
outputs: &Outputs,
evidence: &mut RankingEvidence,
) -> Result<ModelRanking, Unresolved> {
let plan = plan_requests(
i.pool,
i.mode,
i.bias,
&i.scope.step,
&i.scope.instance,
i.tie_order,
)
.map_err(|_| Unresolved::InvalidInput {
fields: vec![],
detail: "invalid ranking controls or pool".into(),
})?;
let mut ranking = ModelRanking {
mode: i.mode.clone(),
pool: i.pool.to_vec(),
entries: vec![],
unranked: vec![],
dropped: vec![],
partial: false,
score_disagreements: 0,
pairwise: None,
};
let answer = |suffix: &str| {
outputs
.get(suffix)
.cloned()
.unwrap_or_else(|| Err(invalid("missing supplied ranking answer")))
};
match plan {
RequestPlan::Independent(requests) => {
let mut first_failure = None;
let mut points = BTreeMap::<String, u64>::new();
for request in requests {
let mut ev = RankingRequestEvidence {
presented: request.presented.clone(),
scores: vec![],
};
let validated = answer(&request.suffix).and_then(|output| match i.mode {
RankMode::Listwise { scores } => {
listwise(i, output, *scores, &request.suffix, &mut ev, &mut ranking)
}
RankMode::Pointwise => pointwise(i, output, &request.presented[0], &mut ev),
RankMode::Pairwise { .. } => unreachable!(),
});
evidence.requests.push(ev);
match validated {
Ok(entries) => {
if matches!(i.bias, Bias::PermutationAverage { .. }) {
let len = entries.len() as u64;
for (p, e) in entries.into_iter().enumerate() {
*points.entry(e.candidate).or_default() += len - p as u64;
}
} else {
ranking.entries.extend(entries);
}
}
Err(reason) => {
first_failure.get_or_insert_with(|| reason.clone());
if matches!(i.mode, RankMode::Pointwise) {
ranking.dropped.push(DroppedId {
id: request.presented[0].clone(),
reason: DropReason::Failed { reason },
});
}
}
}
}
if let Some(reason) = first_failure
&& (!matches!(i.mode, RankMode::Pointwise) || ranking.entries.is_empty())
{
return Err(reason);
}
if matches!(i.bias, Bias::PermutationAverage { .. }) {
ranking.entries = points
.into_iter()
.map(|(id, points)| {
let mut e = entry(&id);
e.points = Some(points);
e
})
.collect();
ranking.entries.sort_by(|a, b| {
b.points
.cmp(&a.points)
.then_with(|| i.tie(&a.candidate, &b.candidate))
});
} else if matches!(i.mode, RankMode::Pointwise) {
ranking.entries.sort_by(|a, b| {
b.score
.as_ref()
.unwrap()
.value
.partial_cmp(&a.score.as_ref().unwrap().value)
.unwrap()
.then_with(|| i.tie(&a.candidate, &b.candidate))
});
}
}
RequestPlan::Pairwise(mut schedule) => {
let mut records = BTreeMap::new();
let result = loop {
match schedule
.state()
.map_err(|_| invalid("invalid pairwise state"))?
{
ScheduleState::Pending(requests) => {
let mut progressed = false;
for r in requests {
let key = comparison_key(&r.suffix);
if records.contains_key(&key) {
continue;
}
let a = answer(&r.suffix).and_then(|o| match o {
RawOutput::Label(s) if s == "first" => Ok(Answer::First),
RawOutput::Label(s) if s == "second" => Ok(Answer::Second),
_ => Err(invalid("expected first or second label")),
});
if let Ok(a) = a {
schedule
.answer(&r.suffix, a)
.map_err(|_| invalid("invalid comparison answer"))?;
progressed = true;
}
records.insert(key, (r.presented, a));
}
if !progressed {
break None;
}
}
ScheduleState::Complete(result) => break Some(result),
ScheduleState::BudgetExhausted { .. } => break None,
}
};
let mut failure = None;
for (presented, a) in records.into_values() {
evidence.requests.push(RankingRequestEvidence {
presented,
scores: vec![],
});
evidence.comparisons.push(match a {
Ok(Answer::First) => ComparisonAnswer::First,
Ok(Answer::Second) => ComparisonAnswer::Second,
Err(reason) => {
failure.get_or_insert_with(|| reason.clone());
ComparisonAnswer::Failed { reason }
}
});
}
if let Some(reason) = failure {
return Err(reason);
}
let result = result.ok_or_else(|| Unresolved::BudgetExhausted {
resource: "comparisons".into(),
})?;
for id in result.order {
let p = i.pool.iter().position(|s| s == &id).unwrap();
let mut e = entry(&id);
e.wins = Some(result.wins[p]);
ranking.entries.push(e);
}
let RankMode::Pairwise {
max_comparisons, ..
} = i.mode
else {
unreachable!()
};
ranking.pairwise = Some(PairwiseCounts {
issued: result.comparisons,
max_comparisons: *max_comparisons,
cyclic_triples: result.cyclic_triples,
inconsistent_pairs: result.inconsistent_pairs,
});
}
}
for (p, e) in ranking.entries.iter_mut().enumerate() {
e.position = p as u64 + 1;
}
let ranked: BTreeSet<_> = ranking.entries.iter().map(|e| &e.candidate).collect();
ranking.unranked = i
.pool
.iter()
.filter(|id| !ranked.contains(id))
.cloned()
.collect();
ranking.partial = !ranking.unranked.is_empty();
Ok(ranking)
}
fn comparison_key(s: &str) -> Vec<u64> {
s.trim_start_matches("pair/")
.split(['.', '/'])
.map(|n| n.parse().unwrap())
.collect()
}
fn pointwise(
i: &RankingInput<'_>,
output: RawOutput,
id: &str,
ev: &mut RankingRequestEvidence,
) -> Result<Vec<ModelRankEntry>, Unresolved> {
let RawOutput::Scores(scores) = output else {
return Err(invalid("expected pointwise scores"));
};
let value = scores
.get(id)
.filter(|v| v.is_finite())
.filter(|_| scores.len() == 1)
.ok_or_else(|| invalid("expected exactly one finite score for presented id"))?;
let score = i.score(id, *value, None);
ev.scores.push(score.clone());
let mut e = entry(id);
e.score = Some(score);
Ok(vec![e])
}
fn listwise(
i: &RankingInput<'_>,
output: RawOutput,
policy: RankScores,
suffix: &str,
ev: &mut RankingRequestEvidence,
ranking: &mut ModelRanking,
) -> Result<Vec<ModelRankEntry>, Unresolved> {
let RawOutput::Ranking { ids, scores } = output else {
return Err(invalid("expected listwise ranking"));
};
let scores = match scores {
RankingScores::None => None,
RankingScores::List(scores) => Some(scores),
};
if (policy == RankScores::Required && scores.is_none())
|| (policy == RankScores::None && scores.is_some())
|| scores
.as_ref()
.is_some_and(|s| s.len() != ids.len() || s.iter().any(|v| !v.is_finite()))
{
return Err(invalid("invalid listwise scores"));
}
let mut seen = BTreeSet::new();
let mut entries = Vec::new();
for (p, id) in ids.iter().enumerate() {
let score = scores.as_ref().map(|s| i.score(id, s[p], Some(suffix)));
if let Some(s) = &score {
ev.scores.push(s.clone());
}
let reason = if !seen.insert(id) {
Some(DropReason::Duplicate)
} else if i.pool.contains(id) {
None
} else if i.excluded.contains(id) {
Some(DropReason::OutsidePool)
} else {
Some(DropReason::UnknownId)
};
if let Some(reason) = reason {
ranking.dropped.push(DroppedId {
id: id.clone(),
reason,
});
} else {
let mut e = entry(id);
e.score = score;
entries.push(e);
}
}
ranking.score_disagreements += entries
.windows(2)
.filter(|pair| match (&pair[0].score, &pair[1].score) {
(Some(a), Some(b)) => a.value < b.value,
_ => false,
})
.count() as u64;
Ok(entries)
}