Skip to main content

rustev_core/ranking/
value.rs

1//! Pure validation and aggregation of supplied ranking answers (039, 3.5-3.9).
2use 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
11/// Inputs already resolved by the caller. `excluded` includes truncated ids.
12/// `tie_order` is a resolved, statically exact-derived order_of source.
13pub 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    /// Union of candidate, projection and tie-break source lineage (3.11.1).
20    pub lineage: &'a Lineage,
21    pub tie_order: Option<&'a [String]>,
22}
23
24/// Keys are the planner's request suffixes. Supply failures explicitly.
25pub 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
75/// Pure offline assembly; it does not issue requests or evaluate a plan.
76/// Failed results retain request evidence, but never a fabricated empty ranking.
77pub 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                                // Bounds: at most eight lists of at most 1024 ids.
149                                *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                // All scores are finite and share this ranking's scope. No arithmetic.
187                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            // Keep failed merges pending while other merges advance, so an earlier
200            // comparison's failure wins even if it becomes pending later.
201            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                            // `Answer` is `Copy`, so this binding copies and `a` stays
220                            // available for the record below.
221                            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    // Only planner-generated suffixes reach this helper.
294    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}