Skip to main content

rustev_core/ranking/
schedules.rs

1//! Request presentations and pure pairwise schedules (spec 039, 3.4 to 3.8).
2use super::{max_requests, permute};
3use rustev_contract::ranking::{Bias, PairwiseSchedule, RankMode};
4use rustev_contract::ranking_result::CycleCount;
5use std::collections::{BTreeMap, BTreeSet, VecDeque};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum ScheduleError {
9    InvalidControls,
10    InvalidPool,
11    Permutation(permute::PermuteError),
12    UnknownRequest,
13    AlreadyAnswered,
14    Finished,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct PresentedRequest {
19    /// One element to append to the caller's step instance key (3.4).
20    pub suffix: String,
21    pub presented: Vec<String>,
22}
23
24#[derive(Debug)]
25pub enum RequestPlan {
26    Independent(Vec<PresentedRequest>),
27    Pairwise(Box<Pairwise>),
28}
29
30/// `tie_order` is the resolved exact-derived `order_of` list, or `None`
31/// for id ascending. Unknown ids are ignored; missing ids follow by id.
32/// These building blocks do not construct projections or request identities.
33pub fn plan_requests(
34    pool: &[String],
35    mode: &RankMode,
36    bias: &Bias,
37    step: &str,
38    instance: &[String],
39    tie_order: Option<&[String]>,
40) -> Result<RequestPlan, ScheduleError> {
41    if pool.len() > 1024 || pool.iter().collect::<BTreeSet<_>>().len() != pool.len() {
42        return Err(ScheduleError::InvalidPool);
43    }
44    max_requests(pool.len() as u64, mode, bias).map_err(|_| ScheduleError::InvalidControls)?;
45    if let Bias::Shuffle { seed } | Bias::PermutationAverage { seed, .. } = bias
46        && (!(1..=64).contains(&seed.len())
47            || !seed
48                .bytes()
49                .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)))
50    {
51        return Err(ScheduleError::InvalidControls);
52    }
53    let context = Context {
54        bias: bias.clone(),
55        step: step.into(),
56        instance: instance.to_vec(),
57    };
58    if let RankMode::Pairwise {
59        schedule,
60        max_comparisons,
61    } = mode
62    {
63        if !(1..=4096).contains(max_comparisons) {
64            return Err(ScheduleError::InvalidControls);
65        }
66        return Ok(RequestPlan::Pairwise(Box::new(Pairwise::new(
67            pool,
68            *schedule,
69            *max_comparisons,
70            context,
71            tie_order,
72        )?)));
73    }
74    let mut requests = Vec::new();
75    if !pool.is_empty() {
76        match mode {
77            RankMode::Pointwise => {
78                requests.extend(pool.iter().map(|id| PresentedRequest {
79                    suffix: format!("cand/{id}"),
80                    presented: vec![id.clone()],
81                }));
82            }
83            RankMode::Listwise { .. } => {
84                for j in 0..context.orders() {
85                    let order = context.order(pool.len(), j, true)?;
86                    requests.push(PresentedRequest {
87                        suffix: format!("perm/{j}"),
88                        presented: order.into_iter().map(|p| pool[p].clone()).collect(),
89                    });
90                }
91            }
92            RankMode::Pairwise { .. } => return Err(ScheduleError::InvalidControls),
93        }
94    }
95    Ok(RequestPlan::Independent(requests))
96}
97
98#[derive(Debug, Clone)]
99struct Context {
100    bias: Bias,
101    step: String,
102    instance: Vec<String>,
103}
104impl Context {
105    fn orders(&self) -> u64 {
106        match self.bias {
107            Bias::PermutationAverage { orders, .. } => orders,
108            _ => 1,
109        }
110    }
111    fn order(&self, size: usize, j: u64, listwise: bool) -> Result<Vec<usize>, ScheduleError> {
112        let seed = match &self.bias {
113            Bias::Shuffle { seed } => Some(seed),
114            Bias::PermutationAverage { seed, .. } if listwise => Some(seed),
115            _ => None,
116        };
117        match seed {
118            Some(seed) => permute::permutation(size, seed, &self.step, &self.instance, j)
119                .map_err(ScheduleError::Permutation),
120            None => Ok((0..size).collect()),
121        }
122    }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum Answer {
127    First,
128    Second,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct PairwiseResult {
133    pub order: Vec<String>,
134    /// Counts aligned with the original pool order, including zero wins.
135    pub wins: Vec<u64>,
136    pub comparisons: u64,
137    pub inconsistent_pairs: u64,
138    pub cyclic_triples: CycleCount,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum ScheduleState {
143    Pending(Vec<PresentedRequest>),
144    Complete(PairwiseResult),
145    BudgetExhausted { comparisons: u64 },
146}
147
148// Numeric tuple ordering avoids sorting decimal comparison strings (3.4).
149type Key = (usize, usize, usize);
150#[derive(Debug)]
151struct Comparison {
152    pair: [usize; 2],
153    requests: Vec<PresentedRequest>,
154    answers: Vec<Option<Answer>>,
155    reversed: bool,
156}
157impl Comparison {
158    fn winner(&self) -> Option<Option<usize>> {
159        let mut winners = self.answers.iter().enumerate().map(|(o, answer)| {
160            answer.map(|a| self.pair[usize::from((a == Answer::Second) ^ self.reversed ^ (o == 1))])
161        });
162        let first = winners.next()??;
163        let mut agrees = true;
164        for winner in winners {
165            agrees &= winner? == first;
166        }
167        Some(agrees.then_some(first))
168    }
169}
170
171#[derive(Debug)]
172struct Merge {
173    left: VecDeque<usize>,
174    right: VecDeque<usize>,
175    out: Vec<usize>,
176    n: usize,
177}
178impl Merge {
179    fn pair(&self) -> Option<[usize; 2]> {
180        Some([*self.left.front()?, *self.right.front()?])
181    }
182    fn drain_tail(&mut self) {
183        if self.pair().is_none() {
184            self.out.extend(self.left.drain(..));
185            self.out.extend(self.right.drain(..));
186        }
187    }
188}
189
190/// Call `state` to advance answered comparisons and obtain pending requests in
191/// comparison order. `answer` accepts only a currently pending request, once.
192/// Unanswered presentations remain pending; one merge can advance independently
193/// of another. Backend failures/invalid labels must be handled by the evaluator,
194/// never converted into an `Answer` or a tie by this successful-answer machine.
195#[derive(Debug)]
196pub struct Pairwise {
197    pool: Vec<String>,
198    schedule: PairwiseSchedule,
199    cap: u64,
200    context: Context,
201    tie: Vec<usize>,
202    pending: BTreeMap<Key, Comparison>,
203    merges: Vec<Merge>,
204    level: usize,
205    wins: Vec<u64>,
206    edges: BTreeSet<(usize, usize)>,
207    issued: u64,
208    inconsistent: u64,
209    terminal: Option<ScheduleState>,
210}
211impl Pairwise {
212    fn new(
213        pool: &[String],
214        schedule: PairwiseSchedule,
215        cap: u64,
216        context: Context,
217        tie_order: Option<&[String]>,
218    ) -> Result<Self, ScheduleError> {
219        let mut tie: Vec<_> = (0..pool.len()).collect();
220        // Explicitly place absent ids last, with the normative id fallback.
221        tie.sort_by(|&a, &b| {
222            let position = |p: usize| {
223                tie_order
224                    .and_then(|ids| ids.iter().position(|id| id == &pool[p]))
225                    .unwrap_or(usize::MAX)
226            };
227            position(a)
228                .cmp(&position(b))
229                .then_with(|| pool[a].cmp(&pool[b]))
230        });
231        let order = if schedule == PairwiseSchedule::MergeSort {
232            context.order(pool.len(), 0, false)?
233        } else {
234            Vec::new()
235        };
236        let mut state = Self {
237            pool: pool.to_vec(),
238            schedule,
239            cap,
240            context,
241            tie,
242            pending: BTreeMap::new(),
243            merges: Vec::new(),
244            level: 0,
245            wins: vec![0; pool.len()],
246            edges: BTreeSet::new(),
247            issued: 0,
248            inconsistent: 0,
249            terminal: None,
250        };
251        match schedule {
252            PairwiseSchedule::RoundRobin => {
253                // All round-robin requests are pending together. Reserve that
254                // entire frontier before issuing any of it (3.7.2, 3.7.5).
255                let n = pool.len() as u64;
256                if n * n.saturating_sub(1) / 2 * state.context.orders() > cap {
257                    state.terminal = Some(ScheduleState::BudgetExhausted { comparisons: 0 });
258                    return Ok(state);
259                }
260                let mut c = 0;
261                for a in 0..pool.len() {
262                    for b in a + 1..pool.len() {
263                        if !state.issue((0, 0, c), [a, b])? {
264                            return Ok(state);
265                        }
266                        c += 1;
267                    }
268                }
269            }
270            PairwiseSchedule::MergeSort => {
271                state.start_level(order.chunks(1).map(Vec::from).collect())
272            }
273        }
274        Ok(state)
275    }
276
277    fn start_level(&mut self, runs: Vec<Vec<usize>>) {
278        self.merges = runs
279            .chunks(2)
280            .map(|pair| {
281                let mut merge = Merge {
282                    left: pair.first().cloned().unwrap_or_default().into(),
283                    right: pair.get(1).cloned().unwrap_or_default().into(),
284                    out: Vec::new(),
285                    n: 0,
286                };
287                merge.drain_tail();
288                merge
289            })
290            .collect();
291    }
292
293    // Positions are private: created from 0..pool.len(), then only moved
294    // between merge runs. All indexed pool/win accesses retain that invariant.
295    // At most 1024 candidates and 4096 presentations bound every counter.
296    fn issue(&mut self, key: Key, pair: [usize; 2]) -> Result<bool, ScheduleError> {
297        let orders = self.context.orders();
298        if self.issued + orders > self.cap {
299            self.terminal = Some(ScheduleState::BudgetExhausted {
300                comparisons: self.issued,
301            });
302            return Ok(false);
303        }
304        let c = match self.schedule {
305            PairwiseSchedule::RoundRobin => key.2.to_string(),
306            PairwiseSchedule::MergeSort => format!("{}.{}.{}", key.0, key.1, key.2),
307        };
308        // Averaging draws no permutation or presentation bit (3.8.3/3.8.4).
309        let reversed = match &self.context.bias {
310            Bias::Shuffle { seed } => {
311                permute::presentation_bit(seed, &self.context.step, &self.context.instance, &c)
312                    .map_err(ScheduleError::Permutation)?
313            }
314            _ => false,
315        };
316        let mut requests = Vec::new();
317        for o in 0..orders {
318            let flip = usize::from(reversed ^ (o == 1));
319            requests.push(PresentedRequest {
320                suffix: format!("pair/{c}/{o}"),
321                presented: vec![
322                    self.pool[pair[flip]].clone(),
323                    self.pool[pair[1 - flip]].clone(),
324                ],
325            });
326        }
327        self.issued += orders;
328        self.pending.insert(
329            key,
330            Comparison {
331                pair,
332                requests,
333                answers: vec![None; orders as usize],
334                reversed,
335            },
336        );
337        Ok(true)
338    }
339
340    pub fn answer(&mut self, suffix: &str, answer: Answer) -> Result<(), ScheduleError> {
341        if self.terminal.is_some() {
342            return Err(ScheduleError::Finished);
343        }
344        for comparison in self.pending.values_mut() {
345            if let Some(o) = comparison.requests.iter().position(|r| r.suffix == suffix) {
346                let slot = &mut comparison.answers[o];
347                if slot.is_some() {
348                    return Err(ScheduleError::AlreadyAnswered);
349                }
350                *slot = Some(answer);
351                return Ok(());
352            }
353        }
354        Err(ScheduleError::UnknownRequest)
355    }
356
357    pub fn state(&mut self) -> Result<ScheduleState, ScheduleError> {
358        if let Some(state) = &self.terminal {
359            return Ok(state.clone());
360        }
361        let ready: Vec<_> = self
362            .pending
363            .iter()
364            .filter_map(|(&key, c)| c.winner().map(|winner| (key, c.pair, winner)))
365            .collect();
366        for (key, pair, winner) in ready {
367            self.pending.remove(&key);
368            if let Some(winner) = winner {
369                self.wins[winner] += 1;
370                let loser = if winner == pair[0] { pair[1] } else { pair[0] };
371                self.edges.insert((winner, loser));
372            } else {
373                self.inconsistent += 1;
374            }
375            if let Some(merge) = self.merges.get_mut(key.1) {
376                let winner = winner.unwrap_or_else(|| {
377                    self.tie
378                        .iter()
379                        .copied()
380                        .find(|p| pair.contains(p))
381                        .unwrap_or(pair[0])
382                });
383                if merge.left.front() == Some(&winner) {
384                    merge.left.pop_front();
385                } else {
386                    merge.right.pop_front();
387                }
388                merge.out.push(winner);
389                merge.n += 1;
390                merge.drain_tail();
391            }
392        }
393        if self.schedule == PairwiseSchedule::MergeSort {
394            if self.merges.iter().all(|m| m.pair().is_none()) {
395                let runs: Vec<_> = self.merges.iter().map(|m| m.out.clone()).collect();
396                if runs.len() <= 1 {
397                    return Ok(self.finish(runs.into_iter().next().unwrap_or_default()));
398                }
399                self.level += 1;
400                self.start_level(runs);
401            }
402            let next: Vec<_> = self
403                .merges
404                .iter()
405                .enumerate()
406                .filter_map(|(m, merge)| {
407                    let key = (self.level, m, merge.n);
408                    merge
409                        .pair()
410                        .filter(|_| !self.pending.contains_key(&key))
411                        .map(|pair| (key, pair))
412                })
413                .collect();
414            // Reserve the whole returned frontier: never count requests that
415            // the caller cannot observe because a later merge exhausts the cap.
416            if self.issued + next.len() as u64 * self.context.orders() > self.cap {
417                let state = ScheduleState::BudgetExhausted {
418                    comparisons: self.issued,
419                };
420                self.terminal = Some(state.clone());
421                return Ok(state);
422            }
423            for (key, pair) in next {
424                self.issue(key, pair)?;
425            }
426        } else if self.pending.is_empty() {
427            let mut order = self.tie.clone();
428            order.sort_by_key(|&p| std::cmp::Reverse(self.wins[p]));
429            return Ok(self.finish(order));
430        }
431        Ok(ScheduleState::Pending(
432            self.pending
433                .values()
434                .flat_map(|c| {
435                    c.requests
436                        .iter()
437                        .zip(&c.answers)
438                        .filter(|(_, a)| a.is_none())
439                        .map(|(r, _)| r.clone())
440                })
441                .collect(),
442        ))
443    }
444
445    fn finish(&mut self, order: Vec<usize>) -> ScheduleState {
446        let cyclic_triples = if self.schedule == PairwiseSchedule::MergeSort {
447            CycleCount::NotObservable
448        } else {
449            let mut count = 0;
450            for a in 0..self.pool.len() {
451                for b in a + 1..self.pool.len() {
452                    for c in b + 1..self.pool.len() {
453                        let edge = |x, y| self.edges.contains(&(x, y));
454                        if (edge(a, b) && edge(b, c) && edge(c, a))
455                            || (edge(b, a) && edge(c, b) && edge(a, c))
456                        {
457                            count += 1;
458                        }
459                    }
460                }
461            }
462            CycleCount::Count(count)
463        };
464        let state = ScheduleState::Complete(PairwiseResult {
465            order: order.into_iter().map(|p| self.pool[p].clone()).collect(),
466            wins: self.wins.clone(),
467            comparisons: self.issued,
468            inconsistent_pairs: self.inconsistent,
469            cyclic_triples,
470        });
471        self.terminal = Some(state.clone());
472        state
473    }
474}