1use super::schedules::{Answer, RequestPlan, ScheduleState, plan_requests};
3use crate::kinds::ScoreScope;
4use rustev_contract::judgment::{Derivation, Lineage, Unresolved};
5use rustev_contract::output::{RankingScores, RawOutput};
6use rustev_contract::ranking::{Bias, RankMode, RankScores};
7pub use rustev_contract::ranking_result::ModelRanking;
8use rustev_contract::ranking_result::*;
9use std::collections::{BTreeMap, BTreeSet};
10
11pub struct RankingInput<'a> {
14 pub pool: &'a [String],
15 pub excluded: &'a [String],
16 pub mode: &'a RankMode,
17 pub bias: &'a Bias,
18 pub scope: &'a ScoreScope,
19 pub lineage: &'a Lineage,
21 pub tie_order: Option<&'a [String]>,
22}
23
24pub type Outputs = BTreeMap<String, Result<RawOutput, Unresolved>>;
26
27#[derive(Debug, Clone, PartialEq)]
28pub struct RankingResult {
29 pub value: Result<ModelRanking, Unresolved>,
30 pub evidence: RankingEvidence,
31 pub derivation: Derivation,
32 pub lineage: Lineage,
33}
34
35pub(crate) fn invalid(detail: &str) -> Unresolved {
36 Unresolved::InvalidBackendOutput {
37 detail: detail.into(),
38 }
39}
40
41impl RankingInput<'_> {
42 fn score(&self, candidate: &str, value: f64, suffix: Option<&str>) -> RankingScore {
43 let mut instance = self.scope.instance.clone();
44 if let Some(suffix) = suffix {
45 instance.push(suffix.into());
46 }
47 RankingScore {
48 candidate: candidate.into(),
49 plan: self.scope.plan.clone(),
50 step: self.scope.step.clone(),
51 instance,
52 value,
53 }
54 }
55 fn tie(&self, a: &str, b: &str) -> std::cmp::Ordering {
56 let pos = |id| {
57 self.tie_order
58 .and_then(|ids| ids.iter().position(|s| s == id))
59 .unwrap_or(usize::MAX)
60 };
61 pos(a).cmp(&pos(b)).then_with(|| a.cmp(b))
62 }
63}
64
65fn entry(id: &str) -> ModelRankEntry {
66 ModelRankEntry {
67 candidate: id.into(),
68 position: 0,
69 score: None,
70 wins: None,
71 points: None,
72 }
73}
74
75pub fn build(input: &RankingInput<'_>, outputs: &Outputs) -> RankingResult {
78 let mut evidence = RankingEvidence {
79 ranking: None,
80 requests: vec![],
81 comparisons: vec![],
82 };
83 let value = assemble(input, outputs, &mut evidence);
84 evidence.ranking = value.as_ref().ok().cloned();
85 RankingResult {
86 value,
87 evidence,
88 derivation: Derivation::ModelDerived,
89 lineage: input.lineage.clone(),
90 }
91}
92
93fn assemble(
94 i: &RankingInput<'_>,
95 outputs: &Outputs,
96 evidence: &mut RankingEvidence,
97) -> Result<ModelRanking, Unresolved> {
98 let plan = plan_requests(
99 i.pool,
100 i.mode,
101 i.bias,
102 &i.scope.step,
103 &i.scope.instance,
104 i.tie_order,
105 )
106 .map_err(|_| Unresolved::InvalidInput {
107 fields: vec![],
108 detail: "invalid ranking controls or pool".into(),
109 })?;
110 let mut ranking = ModelRanking {
111 mode: i.mode.clone(),
112 pool: i.pool.to_vec(),
113 entries: vec![],
114 unranked: vec![],
115 dropped: vec![],
116 partial: false,
117 score_disagreements: 0,
118 pairwise: None,
119 };
120 let answer = |suffix: &str| {
121 outputs
122 .get(suffix)
123 .cloned()
124 .unwrap_or_else(|| Err(invalid("missing supplied ranking answer")))
125 };
126 match plan {
127 RequestPlan::Independent(requests) => {
128 let mut first_failure = None;
129 let mut points = BTreeMap::<String, u64>::new();
130 for request in requests {
131 let mut ev = RankingRequestEvidence {
132 presented: request.presented.clone(),
133 scores: vec![],
134 };
135 let validated = answer(&request.suffix).and_then(|output| match i.mode {
136 RankMode::Listwise { scores } => {
137 listwise(i, output, *scores, &request.suffix, &mut ev, &mut ranking)
138 }
139 RankMode::Pointwise => pointwise(i, output, &request.presented[0], &mut ev),
140 RankMode::Pairwise { .. } => unreachable!(),
141 });
142 evidence.requests.push(ev);
143 match validated {
144 Ok(entries) => {
145 if matches!(i.bias, Bias::PermutationAverage { .. }) {
146 let len = entries.len() as u64;
147 for (p, e) in entries.into_iter().enumerate() {
148 *points.entry(e.candidate).or_default() += len - p as u64;
150 }
151 } else {
152 ranking.entries.extend(entries);
153 }
154 }
155 Err(reason) => {
156 first_failure.get_or_insert_with(|| reason.clone());
157 if matches!(i.mode, RankMode::Pointwise) {
158 ranking.dropped.push(DroppedId {
159 id: request.presented[0].clone(),
160 reason: DropReason::Failed { reason },
161 });
162 }
163 }
164 }
165 }
166 if let Some(reason) = first_failure
167 && (!matches!(i.mode, RankMode::Pointwise) || ranking.entries.is_empty())
168 {
169 return Err(reason);
170 }
171 if matches!(i.bias, Bias::PermutationAverage { .. }) {
172 ranking.entries = points
173 .into_iter()
174 .map(|(id, points)| {
175 let mut e = entry(&id);
176 e.points = Some(points);
177 e
178 })
179 .collect();
180 ranking.entries.sort_by(|a, b| {
181 b.points
182 .cmp(&a.points)
183 .then_with(|| i.tie(&a.candidate, &b.candidate))
184 });
185 } else if matches!(i.mode, RankMode::Pointwise) {
186 ranking.entries.sort_by(|a, b| {
188 b.score
189 .as_ref()
190 .unwrap()
191 .value
192 .partial_cmp(&a.score.as_ref().unwrap().value)
193 .unwrap()
194 .then_with(|| i.tie(&a.candidate, &b.candidate))
195 });
196 }
197 }
198 RequestPlan::Pairwise(mut schedule) => {
199 let mut records = BTreeMap::new();
202 let result = loop {
203 match schedule
204 .state()
205 .map_err(|_| invalid("invalid pairwise state"))?
206 {
207 ScheduleState::Pending(requests) => {
208 let mut progressed = false;
209 for r in requests {
210 let key = comparison_key(&r.suffix);
211 if records.contains_key(&key) {
212 continue;
213 }
214 let a = answer(&r.suffix).and_then(|o| match o {
215 RawOutput::Label(s) if s == "first" => Ok(Answer::First),
216 RawOutput::Label(s) if s == "second" => Ok(Answer::Second),
217 _ => Err(invalid("expected first or second label")),
218 });
219 if let Ok(a) = a {
222 schedule
223 .answer(&r.suffix, a)
224 .map_err(|_| invalid("invalid comparison answer"))?;
225 progressed = true;
226 }
227 records.insert(key, (r.presented, a));
228 }
229 if !progressed {
230 break None;
231 }
232 }
233 ScheduleState::Complete(result) => break Some(result),
234 ScheduleState::BudgetExhausted { .. } => break None,
235 }
236 };
237 let mut failure = None;
238 for (presented, a) in records.into_values() {
239 evidence.requests.push(RankingRequestEvidence {
240 presented,
241 scores: vec![],
242 });
243 evidence.comparisons.push(match a {
244 Ok(Answer::First) => ComparisonAnswer::First,
245 Ok(Answer::Second) => ComparisonAnswer::Second,
246 Err(reason) => {
247 failure.get_or_insert_with(|| reason.clone());
248 ComparisonAnswer::Failed { reason }
249 }
250 });
251 }
252 if let Some(reason) = failure {
253 return Err(reason);
254 }
255 let result = result.ok_or_else(|| Unresolved::BudgetExhausted {
256 resource: "comparisons".into(),
257 })?;
258 for id in result.order {
259 let p = i.pool.iter().position(|s| s == &id).unwrap();
260 let mut e = entry(&id);
261 e.wins = Some(result.wins[p]);
262 ranking.entries.push(e);
263 }
264 let RankMode::Pairwise {
265 max_comparisons, ..
266 } = i.mode
267 else {
268 unreachable!()
269 };
270 ranking.pairwise = Some(PairwiseCounts {
271 issued: result.comparisons,
272 max_comparisons: *max_comparisons,
273 cyclic_triples: result.cyclic_triples,
274 inconsistent_pairs: result.inconsistent_pairs,
275 });
276 }
277 }
278 for (p, e) in ranking.entries.iter_mut().enumerate() {
279 e.position = p as u64 + 1;
280 }
281 let ranked: BTreeSet<_> = ranking.entries.iter().map(|e| &e.candidate).collect();
282 ranking.unranked = i
283 .pool
284 .iter()
285 .filter(|id| !ranked.contains(id))
286 .cloned()
287 .collect();
288 ranking.partial = !ranking.unranked.is_empty();
289 Ok(ranking)
290}
291
292fn comparison_key(s: &str) -> Vec<u64> {
293 s.trim_start_matches("pair/")
295 .split(['.', '/'])
296 .map(|n| n.parse().unwrap())
297 .collect()
298}
299
300fn pointwise(
301 i: &RankingInput<'_>,
302 output: RawOutput,
303 id: &str,
304 ev: &mut RankingRequestEvidence,
305) -> Result<Vec<ModelRankEntry>, Unresolved> {
306 let RawOutput::Scores(scores) = output else {
307 return Err(invalid("expected pointwise scores"));
308 };
309 let value = scores
310 .get(id)
311 .filter(|v| v.is_finite())
312 .filter(|_| scores.len() == 1)
313 .ok_or_else(|| invalid("expected exactly one finite score for presented id"))?;
314 let score = i.score(id, *value, None);
315 ev.scores.push(score.clone());
316 let mut e = entry(id);
317 e.score = Some(score);
318 Ok(vec![e])
319}
320
321fn listwise(
322 i: &RankingInput<'_>,
323 output: RawOutput,
324 policy: RankScores,
325 suffix: &str,
326 ev: &mut RankingRequestEvidence,
327 ranking: &mut ModelRanking,
328) -> Result<Vec<ModelRankEntry>, Unresolved> {
329 let RawOutput::Ranking { ids, scores } = output else {
330 return Err(invalid("expected listwise ranking"));
331 };
332 let scores = match scores {
333 RankingScores::None => None,
334 RankingScores::List(scores) => Some(scores),
335 };
336 if (policy == RankScores::Required && scores.is_none())
337 || (policy == RankScores::None && scores.is_some())
338 || scores
339 .as_ref()
340 .is_some_and(|s| s.len() != ids.len() || s.iter().any(|v| !v.is_finite()))
341 {
342 return Err(invalid("invalid listwise scores"));
343 }
344 let mut seen = BTreeSet::new();
345 let mut entries = Vec::new();
346 for (p, id) in ids.iter().enumerate() {
347 let score = scores.as_ref().map(|s| i.score(id, s[p], Some(suffix)));
348 if let Some(s) = &score {
349 ev.scores.push(s.clone());
350 }
351 let reason = if !seen.insert(id) {
352 Some(DropReason::Duplicate)
353 } else if i.pool.contains(id) {
354 None
355 } else if i.excluded.contains(id) {
356 Some(DropReason::OutsidePool)
357 } else {
358 Some(DropReason::UnknownId)
359 };
360 if let Some(reason) = reason {
361 ranking.dropped.push(DroppedId {
362 id: id.clone(),
363 reason,
364 });
365 } else {
366 let mut e = entry(id);
367 e.score = score;
368 entries.push(e);
369 }
370 }
371 ranking.score_disagreements += entries
372 .windows(2)
373 .filter(|pair| match (&pair[0].score, &pair[1].score) {
374 (Some(a), Some(b)) => a.value < b.value,
375 _ => false,
376 })
377 .count() as u64;
378 Ok(entries)
379}