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}