Skip to main content

contextgraph_host/compose/
ranking.rs

1//! Cross-provider ranking policy (`SPEC.md` §6.6, F10).
2//!
3//! `score` is **provider-local and ordinal**: it orders one provider's frames
4//! against one query, and nothing in the protocol makes two providers' numbers
5//! commensurable. A host still has to put the frames in *some* order — a prompt
6//! is a sequence, and a budget is spent front-first — so every host is making a
7//! cross-provider ranking decision whether or not it admits to one. F10's rule
8//! is that the decision is the host's policy, named as such.
9//!
10//! [`RankingStrategy`] is where that policy lives. [`super::compose_for_prompt`]
11//! keeps ranking by raw `score` ([`ScoreDescending`]) so an existing host's
12//! output does not move; [`super::compose_for_prompt_with`] takes any strategy,
13//! and two that need no configuration ship here:
14//!
15//! - [`RoundRobinByRank`] — every provider's best frame, then every provider's
16//!   second, and so on. Uses only within-provider rank, where the ordering is
17//!   meaningful.
18//! - [`PerProviderQuota`] — the same idea in blocks of `k`: each provider's top
19//!   `k`, then each provider's next `k`.
20//!
21//! # Why raw score starves a provider
22//!
23//! Consider a semantic-search provider that reports cosine similarity in the
24//! `0.8`–`0.95` band and a lexical provider that reports a normalized BM25 rank
25//! topping out near `0.4`. Both are honest about their own frames. Rank the
26//! union by raw `score` under a budget that fits four frames and the lexical
27//! provider contributes nothing — not because its evidence is worse, but
28//! because its retriever's number is smaller. The prompt's evidence set was
29//! decided by an implementation detail of someone else's scorer.
30//! `starvation_*` in this module's tests is that scenario, run.
31//!
32//! # Determinism
33//!
34//! Ranking must be a pure function of the input *set*: two runs over the same
35//! frames must produce the same order, or a host's prompt stops being
36//! reproducible and its provider prompt cache stops hitting (`super`'s module
37//! docs). Every strategy here derives its provider ordering from a
38//! [`BTreeMap`] and breaks every remaining tie on the canonical
39//! [`FrameId`], so no ordering ever depends on
40//! hash iteration or arrival order.
41
42use std::cmp::Ordering;
43use std::collections::BTreeMap;
44use std::num::NonZeroUsize;
45
46use contextgraph_types::{ContextFrame, FrameId};
47
48/// A host's policy for ordering frames drawn from more than one provider —
49/// the choice `SPEC.md` §6.6 (F10) says a host owns and must name.
50///
51/// A strategy reports a **permutation of the input indices, best first**. It
52/// ranks; it never filters. Dropping evidence is the budget packer's job in
53/// [`super::compose_for_prompt_with`], which excludes a frame with a recorded
54/// [`ExclusionReason`](super::ExclusionReason) so the audit still explains
55/// every drop. A strategy that quietly returned fewer indices would put frames
56/// into neither the prompt nor the audit.
57///
58/// # Contract
59///
60/// - `order(frames)` returns each index in `0..frames.len()` exactly once.
61/// - The result depends only on `frames` as a set — not on their arrival
62///   order, and not on anything that varies between two runs over the same
63///   input. A `HashMap` iteration is the usual way to get this wrong.
64///
65/// [`rank_with`] holds the first half rather than trusting it: an index out of
66/// range or repeated is skipped, and any frame a strategy failed to place is
67/// appended in canonical order, so a third-party strategy cannot make the
68/// reference host lose evidence. It repairs rather than panicking — a library
69/// that aborted a host's turn over a ranking bug would be a worse failure than
70/// the one it caught. [`is_ranking_permutation`] is the assertion to put in a
71/// strategy's own tests.
72pub trait RankingStrategy {
73    /// The policy's name, for a host that has to state which cross-provider
74    /// ordering it applied. F10 requires a host to document the choice; a
75    /// strategy that cannot say what it is makes that impossible, which is why
76    /// this has no default implementation.
77    fn policy_name(&self) -> &str;
78
79    /// Order the input indices best-first. See the trait contract.
80    fn order(&self, frames: &[(String, ContextFrame)]) -> Vec<usize>;
81}
82
83impl<T: RankingStrategy + ?Sized> RankingStrategy for &T {
84    fn policy_name(&self) -> &str {
85        (**self).policy_name()
86    }
87
88    fn order(&self, frames: &[(String, ContextFrame)]) -> Vec<usize> {
89        (**self).order(frames)
90    }
91}
92
93/// Apply a [`RankingStrategy`], returning the frames best-first.
94///
95/// The strategy's permutation is checked, not trusted: an out-of-range or
96/// repeated index is skipped and anything the strategy left unplaced is
97/// appended in canonical order (`score` descending, `FrameId` ascending). The
98/// output is therefore always a permutation of the input, whoever wrote the
99/// strategy — which is what keeps the composition audit a total partition of
100/// the evidence the host offered.
101pub fn rank_with<S: RankingStrategy + ?Sized>(
102    strategy: &S,
103    frames: Vec<(String, ContextFrame)>,
104) -> Vec<(String, ContextFrame)> {
105    let n = frames.len();
106    let proposed = strategy.order(&frames);
107    let mut placed = vec![false; n];
108    let mut order: Vec<usize> = Vec::with_capacity(n);
109    for index in proposed {
110        if index < n && !placed[index] {
111            placed[index] = true;
112            order.push(index);
113        }
114    }
115    if order.len() < n {
116        // Repair, deterministically: whatever the strategy failed to place
117        // follows in canonical order rather than vanishing.
118        let mut missing: Vec<usize> = (0..n).filter(|index| !placed[*index]).collect();
119        missing.sort_by(|&a, &b| by_score_desc(&frames[a], &frames[b]));
120        order.extend(missing);
121    }
122
123    let mut slots: Vec<Option<(String, ContextFrame)>> = frames.into_iter().map(Some).collect();
124    order
125        .into_iter()
126        .map(|index| slots[index].take().expect("each index placed once"))
127        .collect()
128}
129
130/// Whether `order` is exactly the indices `0..n`, each once — the
131/// [`RankingStrategy`] contract, as an assertion a strategy's own tests can
132/// make.
133///
134/// [`rank_with`] repairs a violation rather than calling this, because a host
135/// mid-turn needs its evidence more than it needs a panic. That leaves a
136/// strategy author with nothing to fail on, which is what this is for.
137pub fn is_ranking_permutation(order: &[usize], n: usize) -> bool {
138    if order.len() != n {
139        return false;
140    }
141    let mut seen = vec![false; n];
142    for &index in order {
143        match seen.get_mut(index) {
144            Some(slot) if !*slot => *slot = true,
145            _ => return false,
146        }
147    }
148    true
149}
150
151/// The canonical within-provider ordering: `score` descending, canonical
152/// [`FrameId`] ascending as the tiebreak.
153///
154/// Comparing `score` here is comparing two frames **from one provider**, which
155/// is the only comparison F10 says means anything. Every strategy in this
156/// module uses it for exactly that, and never to rank one provider's frame
157/// against another's.
158fn by_score_desc(a: &(String, ContextFrame), b: &(String, ContextFrame)) -> Ordering {
159    b.1.score
160        .total_cmp(&a.1.score)
161        .then_with(|| a.1.identity(&a.0).cmp(&b.1.identity(&b.0)))
162}
163
164/// One frame's position in the lane structure every interleaving strategy
165/// ranks over: which provider it came from, and how good it is *within that
166/// provider*.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168struct Lane {
169    /// The provider's ordinal in the set's providers sorted by id — an
170    /// arbitrary but stable order. See [`provider_lanes`].
171    provider: usize,
172    /// `0` for this provider's best frame, `1` for its second, and so on.
173    rank: usize,
174}
175
176/// Each frame's [`Lane`].
177///
178/// Providers are ordinalized through a [`BTreeMap`], so the traversal is by
179/// provider id and never by hash order — the determinism this module's docs
180/// promise. Ranks come from [`by_score_desc`] applied inside one provider,
181/// which is the only place F10 says a score comparison means anything.
182///
183/// The provider ordinal is **arbitrary but stable**, and deliberately so: once
184/// two frames sit in the same tier, the protocol offers nothing that ranks one
185/// provider above another, and reaching for `score` there would be exactly the
186/// cross-provider comparison F10 says is not a measurement. An alphabetical
187/// order admits it is a coin toss; a score comparison would dress one up as a
188/// judgement.
189fn provider_lanes(frames: &[(String, ContextFrame)]) -> Vec<Lane> {
190    let mut by_provider: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
191    for (index, (provider_id, _)) in frames.iter().enumerate() {
192        by_provider
193            .entry(provider_id.as_str())
194            .or_default()
195            .push(index);
196    }
197    let mut lanes = vec![
198        Lane {
199            provider: 0,
200            rank: 0
201        };
202        frames.len()
203    ];
204    for (provider, indices) in by_provider.into_values().enumerate() {
205        let mut indices = indices;
206        indices.sort_by(|&a, &b| by_score_desc(&frames[a], &frames[b]));
207        for (rank, index) in indices.into_iter().enumerate() {
208            lanes[index] = Lane { provider, rank };
209        }
210    }
211    lanes
212}
213
214/// Sort `0..frames.len()` by a per-frame key, canonical `FrameId` ascending as
215/// the standing final tiebreak so the order is total and reproducible.
216///
217/// The keys are computed once rather than inside the comparator, so a strategy
218/// pays for one `identity()` per frame instead of one per comparison.
219fn order_by_key<K: Ord>(frames: &[(String, ContextFrame)], key: impl Fn(usize) -> K) -> Vec<usize> {
220    let mut keyed: Vec<(K, FrameId, usize)> = (0..frames.len())
221        .map(|index| {
222            (
223                key(index),
224                frames[index].1.identity(&frames[index].0),
225                index,
226            )
227        })
228        .collect();
229    keyed.sort();
230    keyed.into_iter().map(|(_, _, index)| index).collect()
231}
232
233/// Rank the whole mixed set by raw `score`, descending — the reference host's
234/// documented default (`SPEC.md` §6.6), and what
235/// [`super::order_by_value`] has always done.
236///
237/// It is a real policy with a real cost: the provider that scores most
238/// generously wins the top of the prompt and the front of the budget, whatever
239/// its evidence is worth. It stays the default because it is the only strategy
240/// here that changes nothing for a host that never asked for a ranking policy,
241/// and because with a single provider it is simply the right answer — see
242/// [ADR 0015](https://github.com/macanderson/context-graph-protocol/blob/main/docs/adr/0015-cross-provider-ranking-strategies.md).
243#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
244pub struct ScoreDescending;
245
246impl RankingStrategy for ScoreDescending {
247    fn policy_name(&self) -> &str {
248        "score-descending"
249    }
250
251    fn order(&self, frames: &[(String, ContextFrame)]) -> Vec<usize> {
252        let mut order: Vec<usize> = (0..frames.len()).collect();
253        order.sort_by(|&a, &b| by_score_desc(&frames[a], &frames[b]));
254        order
255    }
256}
257
258/// Interleave providers by **within-provider rank**: every provider's best
259/// frame first, then every provider's second-best, and so on.
260///
261/// The only score comparisons are within one provider, where F10 says the
262/// ordering is meaningful. Across providers the order is by provider id, which
263/// is arbitrary and stable rather than a claim that one source outranks
264/// another.
265///
266/// With one provider the ranks are `0, 1, 2, …` in score order, so this is
267/// identical to [`ScoreDescending`] — the cross-provider question does not
268/// arise, and no strategy here invents one.
269#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
270pub struct RoundRobinByRank;
271
272impl RankingStrategy for RoundRobinByRank {
273    fn policy_name(&self) -> &str {
274        "round-robin-by-rank"
275    }
276
277    fn order(&self, frames: &[(String, ContextFrame)]) -> Vec<usize> {
278        let lanes = provider_lanes(frames);
279        // Rank first, provider second: every provider's best frame, then every
280        // provider's second.
281        order_by_key(frames, |index| (lanes[index].rank, lanes[index].provider))
282    }
283}
284
285/// Give every provider its top `k` before any provider gets its `k + 1`th:
286/// each provider's best `k` frames, then each provider's next `k`, and so on.
287///
288/// The difference from [`RoundRobinByRank`] is contiguity. A round robin deals
289/// one frame per provider per turn; a quota deals `k` at a time, so a
290/// provider's block of evidence stays together in the prompt. `k = 1` is a
291/// round robin.
292///
293/// The quota **tiers, it does not truncate.** A provider's `k + 1`th frame is
294/// ranked lower, never dropped: dropping is the budget packer's decision, and
295/// it records a reason for the audit. A strategy that discarded frames would
296/// leave them out of both the prompt and the record of why.
297///
298/// With one provider every frame sits in its own tier position in score order,
299/// so this too degenerates to [`ScoreDescending`].
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub struct PerProviderQuota {
302    per_provider: NonZeroUsize,
303}
304
305impl PerProviderQuota {
306    /// A quota of `per_provider` frames per provider per tier. `0` is read as
307    /// `1`: a quota of nothing would rank every frame into one tier and mean
308    /// nothing, and returning an error for a number a caller can only have
309    /// meant as "one at a time" buys the caller nothing.
310    pub fn new(per_provider: usize) -> Self {
311        Self {
312            per_provider: NonZeroUsize::new(per_provider).unwrap_or(NonZeroUsize::MIN),
313        }
314    }
315
316    /// The frames each provider contributes per tier.
317    pub fn per_provider(&self) -> usize {
318        self.per_provider.get()
319    }
320}
321
322impl Default for PerProviderQuota {
323    /// Three frames per provider per tier — enough that a provider's evidence
324    /// arrives as a block rather than a single frame, small enough that a
325    /// second provider is reached inside any realistic budget. A host with a
326    /// measured number should pass it to [`PerProviderQuota::new`].
327    fn default() -> Self {
328        Self::new(3)
329    }
330}
331
332impl RankingStrategy for PerProviderQuota {
333    fn policy_name(&self) -> &str {
334        "per-provider-quota"
335    }
336
337    fn order(&self, frames: &[(String, ContextFrame)]) -> Vec<usize> {
338        let lanes = provider_lanes(frames);
339        let k = self.per_provider.get();
340        // Tier first, then provider, then rank within the provider — so one
341        // provider's `k` frames stay contiguous inside the tier, which is the
342        // whole difference from a round robin.
343        order_by_key(frames, |index| {
344            (
345                lanes[index].rank / k,
346                lanes[index].provider,
347                lanes[index].rank,
348            )
349        })
350    }
351}