fuel_dispatch/ranker/chained_selector.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `ChainedSelector` — the production Picker 2 composition:
3//! VRAM-pressure GUARD → Judge-measured RANK → static-winner
4//! fallback.
5//!
6//! Picker-arc step 3 (2026-06-10). Phase 5.2 shipped
7//! [`super::VramPressureSelector`] and [`super::JudgeAwareSelector`]
8//! as standalone selectors with zero production consumers. The two
9//! cannot be composed through [`RuntimeSelector::select`] — `select`
10//! returns a single pick, not a narrowed set — and `JudgeAwareSelector`
11//! must be constructed per decision point while the executor takes
12//! ONE selector per realize. This module ships the composition as
13//! its own selector with explicit semantics, reading the per-node
14//! `(op, dtype, size_class)` from the [`DecisionContext`] that
15//! `compile_plan` stamps on every [`AlternativeSet`].
16//!
17//! # Semantics
18//!
19//! For each candidate, compute a sort key
20//! `(pressure_tier, load_tier, latency_ns, original_index)` and pick
21//! the minimum:
22//!
23//! 1. **Pressure tier (the guard).** Query the candidate backend's
24//! [`fuel_backend_contract::backend::BackendRuntime::would_fit`] for
25//! the estimated output bytes:
26//! - [`FitStatus::WontFit`] → the candidate is SKIPPED — never
27//! picked while any alternative survives.
28//! - [`FitStatus::Tight`] → tier 1 (demoted below all tier-0
29//! candidates — pressure reorders only under contention).
30//! - [`FitStatus::Comfortable`] / [`FitStatus::Unknown`] →
31//! tier 0. Unknown is honest "no signal", NOT pressure — it
32//! ties with Comfortable (post-remediation semantics).
33//!
34//! No runtime lookup configured, or no handle for the pair, also
35//! yields Unknown.
36//! 2. **Load tier (the live-load re-pick — Step E Phase C / C2).** A
37//! coarse bucket of the candidate device's live in-flight count over
38//! its slot capacity ([`super::device_load::load_tier`]), read off the
39//! SAME runtime handle the guard queried, through its Tier-2
40//! [`fuel_backend_contract::backend::BackendStreams`] seam: idle (0),
41//! moderate (1), saturated (2). A busy device's arm sorts after an
42//! idle device's arm **of the same VRAM tier** — "which path drains
43//! the queues fastest right now". A handle with no `BackendStreams`
44//! (CPU / Reference), no handle at all, or a `None`
45//! `pending_work_count` ⇒ tier 0 — an honest no-signal, identical to
46//! a VRAM `Unknown`, NEVER a fabricated "idle".
47//!
48//! **VRAM outranks load (critical).** `pressure_tier` is the FIRST key
49//! component, so the load leg reorders arms ONLY within a VRAM fit
50//! tier — it can never lift a `WontFit` (skipped outright) or a
51//! `Tight` (tier 1) arm above a `Comfortable` (tier 0) one to balance
52//! load. Load is a tie-break *under* the OOM-safety guard, not a peer
53//! of it.
54//! 3. **Latency (the rank).** Within a (pressure, load) tier, candidates
55//! rank on one uniform nanosecond axis: the Judge's measured latency
56//! at `(ctx.op, ctx.principal_dtype, ctx.size_class, backend,
57//! kernel_source)` when a measurement exists, else
58//! [`composite_ns`] of the candidate's (plan-refined) static
59//! cost. Measured and unmeasured candidates compete on equal
60//! footing — see the 2026-06-08 adversarial finding against
61//! "measured trumps unmeasured". No judge, or no
62//! [`DecisionContext`] on the set, means every candidate uses
63//! its static composite.
64//! 4. **Original index.** Ties resolve toward Picker 1's static
65//! rank, preserving determinism.
66//!
67//! If EVERY candidate is `WontFit`, fall back to `set.winner()` —
68//! the realize is in trouble either way; surfacing the static winner
69//! gives the executor the cleanest OOM error site instead of
70//! violating the trait's "non-empty set MUST produce a pick"
71//! contract (mirrors `VramPressureSelector`).
72//!
73//! # Degenerate-fallback guarantee
74//!
75//! With no signals at all (no judge data, all fit statuses
76//! Unknown/Comfortable, no `BackendStreams` load), every key is
77//! `(0, 0, composite_ns(static), i)`. `compile_plan` ranks sets
78//! ascending by that same composite before truncation, so the minimum
79//! key is index 0 — `set.winner()`, byte-identical to
80//! [`super::WinnerSelector`] on every plan-produced set. The added
81//! `load_tier` leg is a constant 0 when no device reports load, so it
82//! drops out of the comparison entirely: C2 changes nothing unless
83//! devices genuinely contend (design §3.2).
84
85use std::sync::Arc;
86
87use fuel_ir::backend::FitStatus;
88
89use super::{
90 AlternativeSet, BackendRuntimeLookup, Candidate, DecisionContext, JudgeOracle,
91 OutputBytesEstimator, RuntimeSelector, composite_ns, default_backend_rates,
92 default_estimate_output_bytes,
93};
94
95/// Production runtime selector: VRAM-pressure guard + Judge-measured
96/// rank + static-winner fallback. See the module docs for the exact
97/// key semantics.
98///
99/// Both signal sources are optional so the selector degrades
100/// honestly:
101///
102/// - `judge: None` → the rank leg uses static composite costs only.
103/// - `backend_runtime_lookup: None` → the guard leg sees every
104/// candidate as [`FitStatus::Unknown`] (tier 0, no skips).
105/// - Both `None` → exact [`super::WinnerSelector`] behavior on
106/// plan-produced (cost-ranked) sets.
107#[derive(Clone)]
108pub struct ChainedSelector {
109 judge: Option<Arc<dyn JudgeOracle>>,
110 backend_runtime_lookup: Option<BackendRuntimeLookup>,
111 estimate_output_bytes: OutputBytesEstimator,
112}
113
114impl ChainedSelector {
115 /// Construct with explicit signal sources + output-bytes
116 /// estimator. See [`super::default_estimate_output_bytes`] for
117 /// the estimator contract (deliberately pessimistic default).
118 pub fn new(
119 judge: Option<Arc<dyn JudgeOracle>>,
120 backend_runtime_lookup: Option<BackendRuntimeLookup>,
121 estimate_output_bytes: OutputBytesEstimator,
122 ) -> Self {
123 Self {
124 judge,
125 backend_runtime_lookup,
126 estimate_output_bytes,
127 }
128 }
129
130 /// Construct with the conservative
131 /// [`default_estimate_output_bytes`] estimator — the shape the
132 /// production bridge uses until per-node output shapes are
133 /// threaded through.
134 pub fn with_default_estimator(
135 judge: Option<Arc<dyn JudgeOracle>>,
136 backend_runtime_lookup: Option<BackendRuntimeLookup>,
137 ) -> Self {
138 Self::new(
139 judge,
140 backend_runtime_lookup,
141 Arc::new(default_estimate_output_bytes),
142 )
143 }
144
145 /// Guard + load legs from ONE lookup: the candidate's VRAM fit status
146 /// AND its live load tier, both read off the same runtime handle.
147 ///
148 /// - **fit status** — `would_fit(estimated_output_bytes)`; `Unknown`
149 /// when no lookup is configured, the lookup has no handle for the
150 /// `(backend, device)` pair, or the backend can't answer.
151 /// - **load tier** — the candidate device's coarse live in-flight
152 /// bucket via the handle's Tier-2 `BackendStreams` seam
153 /// ([`super::device_load::load_tier_of_handle`]);
154 /// [`super::device_load::LOAD_TIER_IDLE`] (tier 0 — honest no-signal)
155 /// when there is no lookup / no handle / no `BackendStreams` (CPU,
156 /// Reference) / a `None` `pending_work_count`. NEVER a fabricated
157 /// "idle".
158 ///
159 /// Reading both off one handle (rather than a second lookup call for
160 /// load) keeps the per-candidate cost a single lookup + the SAME
161 /// `DeviceRuntimeHandle` the bridge hands out — which already
162 /// implements `BackendStreams`, so the VRAM lookup IS the load lookup
163 /// (design §3.3: one load source serves all backends).
164 fn fit_and_load_for(&self, c: &Candidate) -> (FitStatus, u8) {
165 let Some(lookup) = self.backend_runtime_lookup.as_ref() else {
166 return (FitStatus::Unknown, super::device_load::LOAD_TIER_IDLE);
167 };
168 match lookup(c.backend, c.device) {
169 Some(runtime) => {
170 let fit = runtime.would_fit((self.estimate_output_bytes)(c));
171 let load = super::device_load::load_tier_of_handle(runtime.as_ref());
172 (fit, load)
173 }
174 None => (FitStatus::Unknown, super::device_load::LOAD_TIER_IDLE),
175 }
176 }
177
178 /// Rank leg: one uniform nanosecond figure per candidate —
179 /// measured latency when the Judge has the cell, static
180 /// composite otherwise — plus the plan-time inbound-transfer
181 /// term (Stage 2). Plan-produced sets are device-pruned so the
182 /// term is uniform across the set in practice; adding it keeps
183 /// the selector's scale consistent with the plan rank.
184 fn latency_ns(&self, c: &Candidate, ctx: Option<&DecisionContext>) -> u64 {
185 let kernel_ns = (|| {
186 if let (Some(judge), Some(ctx)) = (self.judge.as_ref(), ctx)
187 && let Some(measured) = judge.measured_latency_ns(
188 ctx.op,
189 ctx.principal_dtype,
190 ctx.size_class,
191 c.backend,
192 c.kernel_source,
193 c.kernel_revision_hash,
194 )
195 {
196 return measured;
197 }
198 let (cr, bw) = default_backend_rates(c.backend);
199 composite_ns(&c.static_cost, cr, bw)
200 })();
201 kernel_ns.saturating_add(c.inbound_transfer_ns)
202 }
203}
204
205impl std::fmt::Debug for ChainedSelector {
206 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207 f.debug_struct("ChainedSelector")
208 .field("judge", &self.judge.as_ref().map(|_| "<dyn JudgeOracle>"))
209 .field(
210 "backend_runtime_lookup",
211 &self.backend_runtime_lookup.as_ref().map(|_| "<closure>"),
212 )
213 .field("estimate_output_bytes", &"<closure>")
214 .finish()
215 }
216}
217
218impl RuntimeSelector for ChainedSelector {
219 fn select<'a>(&self, set: &'a AlternativeSet) -> Option<&'a Candidate> {
220 let alts = set.alternatives();
221 if alts.is_empty() {
222 return None;
223 }
224 let ctx = set.context();
225
226 // Walk in original (cost-ranked) order, tracking the minimum
227 // (pressure_tier, load_tier, ns, idx) key. WontFit candidates are
228 // skipped outright. `load_tier` slots BELOW the VRAM guard and
229 // ABOVE the latency rank, so load reorders arms only within a VRAM
230 // fit tier (VRAM outranks load) and never overrides the static
231 // rank by more than a tie-break under that guard (Step E / C2).
232 let mut best: Option<(u8, u8, u64, usize)> = None;
233 for (i, c) in alts.iter().enumerate() {
234 let (fit, load_tier) = self.fit_and_load_for(c);
235 let pressure_tier = match fit {
236 FitStatus::WontFit => continue,
237 FitStatus::Comfortable | FitStatus::Unknown => 0u8,
238 FitStatus::Tight => 1u8,
239 };
240 let key = (pressure_tier, load_tier, self.latency_ns(c, ctx), i);
241 if best.is_none_or(|b| key < b) {
242 best = Some(key);
243 }
244 }
245
246 match best {
247 Some((_, _, _, i)) => alts.get(i),
248 // Every candidate WontFit → static winner, so the
249 // executor surfaces a clean OOM at the planner-blessed
250 // pick instead of the selector breaking the non-empty-
251 // set contract.
252 None => set.winner(),
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::fused::{CostEstimate, PrecisionGuarantee};
261 use crate::kernel::{KernelCaps, OpParams};
262 use crate::ranker::{HashMapJudge, WinnerSelector};
263 use fuel_backend_contract::backend::BackendRuntime;
264 use fuel_ir::dispatch::{OpKind, SizeClass};
265 use fuel_ir::probe::BackendId;
266 use fuel_ir::{DType, DeviceLocation, Layout, Result};
267 use fuel_memory::Storage;
268 use std::sync::{Arc, RwLock};
269
270 fn noop_kernel(
271 _i: &[Arc<RwLock<Storage>>],
272 _o: &mut [Arc<RwLock<Storage>>],
273 _l: &[Layout],
274 _p: &OpParams,
275 ) -> Result<()> {
276 Ok(())
277 }
278
279 /// `composite_ns` of this candidate equals `cost_ns` (flops
280 /// dominate; bytes_moved = cost so memory_ns = cost/4 < cost).
281 fn make_candidate(backend: BackendId, device: DeviceLocation, cost_ns: u64) -> Candidate {
282 Candidate {
283 kernel: noop_kernel,
284 caps: KernelCaps::empty(),
285 backend,
286 device,
287 precision: PrecisionGuarantee::PRIMITIVE_DETERMINISTIC_CPU,
288 static_cost: CostEstimate {
289 flops: cost_ns,
290 bytes_moved: cost_ns,
291 kernel_overhead_ns: 0,
292 },
293 inbound_transfer_ns: 0,
294 op_params: OpParams::None,
295 coupling: Vec::new(),
296 kernel_source: "",
297 kernel_revision_hash: 0,
298 }
299 }
300
301 fn ctx() -> DecisionContext {
302 DecisionContext {
303 op: OpKind::MatMul,
304 principal_dtype: DType::F32,
305 size_class: SizeClass(16),
306 }
307 }
308
309 /// Cost-ranked two-candidate set (what compile_plan produces):
310 /// CUDA wins at 100ns, CPU runner-up at 200ns. Context stamped.
311 fn ranked_set() -> AlternativeSet {
312 let mut set = AlternativeSet::empty();
313 // CUDA raw work is 3000 FLOPs, but at the GPU throughput prior
314 // (30 FLOPs/ns) that composites to 100 ns — the value these
315 // tests reason about. CPU's 200 FLOPs composites to 200 ns at
316 // the 1 FLOP/ns baseline. So the static rank is still CUDA(100)
317 // < CPU(200), and the measured-vs-unmeasured thresholds below
318 // are relative to CUDA's 100 ns composite.
319 set.push(make_candidate(
320 BackendId::Cuda,
321 DeviceLocation::Cuda { gpu_id: 0 },
322 3000,
323 ));
324 set.push(make_candidate(BackendId::Cpu, DeviceLocation::Cpu, 200));
325 set.set_context(ctx());
326 set
327 }
328
329 struct MockRuntime {
330 available: Option<u64>,
331 total: Option<u64>,
332 }
333 impl BackendRuntime for MockRuntime {
334 fn available_bytes(&self) -> Option<u64> {
335 self.available
336 }
337 fn total_bytes(&self) -> Option<u64> {
338 self.total
339 }
340 }
341
342 /// Lookup with per-backend (available, total); backends not in
343 /// the list resolve to `None` (= Unknown).
344 fn lookup_for(entries: Vec<(BackendId, Option<u64>, Option<u64>)>) -> BackendRuntimeLookup {
345 Arc::new(move |b, _d| {
346 entries
347 .iter()
348 .find(|(eb, _, _)| *eb == b)
349 .map(|&(_, a, t)| {
350 Box::new(MockRuntime {
351 available: a,
352 total: t,
353 }) as Box<dyn BackendRuntime + Send + Sync>
354 })
355 })
356 }
357
358 // ===== Step E Phase C / C2: the load leg =====
359
360 /// A runtime handle carrying BOTH a VRAM signal (available/total) AND
361 /// a Tier-2 `BackendStreams` live-load signal (pending/capacity). The
362 /// production `DeviceRuntimeHandle` is exactly this shape — one handle
363 /// answering both `would_fit` and `pending_work_count` — so the
364 /// ChainedSelector reads load off the same handle it queries for VRAM.
365 struct MockRuntimeWithLoad {
366 available: Option<u64>,
367 total: Option<u64>,
368 pending: Option<u32>,
369 capacity: u32,
370 }
371 impl BackendRuntime for MockRuntimeWithLoad {
372 fn available_bytes(&self) -> Option<u64> {
373 self.available
374 }
375 fn total_bytes(&self) -> Option<u64> {
376 self.total
377 }
378 fn as_backend_streams(
379 &self,
380 ) -> Option<&dyn fuel_backend_contract::backend::BackendStreams> {
381 Some(self)
382 }
383 }
384 impl fuel_backend_contract::backend::BackendStreams for MockRuntimeWithLoad {
385 fn pending_work_count(&self) -> Option<u32> {
386 self.pending
387 }
388 fn slot_capacity(&self) -> u32 {
389 self.capacity
390 }
391 fn flush(&self) -> Result<()> {
392 Ok(())
393 }
394 }
395
396 /// Per-backend `(available, total, pending, capacity)` lookup handing
397 /// out the combined VRAM+load handle; absent backends ⇒ `None`.
398 fn load_lookup_for(
399 entries: Vec<crate::ranker::device_load::MockCombinedEntry>,
400 ) -> BackendRuntimeLookup {
401 Arc::new(move |b, _d| {
402 entries
403 .iter()
404 .find(|(eb, _, _, _, _)| *eb == b)
405 .map(|&(_, a, t, p, cap)| {
406 Box::new(MockRuntimeWithLoad {
407 available: a,
408 total: t,
409 pending: p,
410 capacity: cap,
411 }) as Box<dyn BackendRuntime + Send + Sync>
412 })
413 })
414 }
415
416 /// LOAD LEG: two arms, both VRAM-Comfortable, but the cost-winner
417 /// (CUDA) is SATURATED while the runner-up (CPU) is idle. The load
418 /// tier demotes the saturated arm below the idle one within the shared
419 /// VRAM tier, flipping the pick to the unloaded CPU arm. This is the
420 /// headline C2 mechanic at the selector level: "which path drains the
421 /// queues fastest right now".
422 #[test]
423 fn load_demotes_saturated_arm_within_vram_tier() {
424 let set = ranked_set();
425 // CUDA: ample VRAM (Comfortable) but 4 ops in flight on 1 slot
426 // (saturated). CPU: ample VRAM, idle (0 in flight).
427 let lookup = load_lookup_for(vec![
428 (BackendId::Cuda, Some(10_000), Some(10_000), Some(4), 1),
429 (BackendId::Cpu, Some(10_000), Some(10_000), Some(0), 1),
430 ]);
431 let chained = ChainedSelector::with_default_estimator(None, Some(lookup));
432 assert_eq!(
433 chained.select(&set).expect("non-empty").backend,
434 BackendId::Cpu,
435 "saturated cost-winner demoted below the idle arm by the load leg",
436 );
437 }
438
439 /// VRAM OUTRANKS LOAD (the critical guard): the cost-winner (CUDA)
440 /// WONT FIT, and the runner-up (CPU) is SATURATED. Load would prefer
441 /// to stay off the saturated CPU — but VRAM is the FIRST key leg, so
442 /// the WontFit CUDA is skipped outright and the picker takes the
443 /// saturated-but-fitting CPU arm. Load NEVER lifts a WontFit device to
444 /// balance load.
445 #[test]
446 fn vram_wont_fit_outranks_load() {
447 let set = ranked_set();
448 // CUDA: 1 byte free of 10_000 → WontFit (skipped). CPU: ample VRAM
449 // but saturated (4 in flight / 1 slot).
450 let lookup = load_lookup_for(vec![
451 (BackendId::Cuda, Some(1), Some(10_000), Some(0), 1),
452 (BackendId::Cpu, Some(8_000), Some(10_000), Some(4), 1),
453 ]);
454 let chained = ChainedSelector::with_default_estimator(None, Some(lookup));
455 assert_eq!(
456 chained.select(&set).expect("non-empty").backend,
457 BackendId::Cpu,
458 "WontFit CUDA skipped; saturated-but-fitting CPU taken — VRAM \
459 outranks load",
460 );
461 }
462
463 /// VRAM OUTRANKS LOAD, the Tight case: the cost-winner (CUDA) is Tight
464 /// (VRAM tier 1) but IDLE; the runner-up (CPU) is Comfortable (tier 0)
465 /// but SATURATED. The Tight CUDA's idle load tier (0) does NOT lift it
466 /// above the Comfortable CPU — pressure_tier (1 vs 0) is compared
467 /// first, so CPU (Comfortable) wins despite being the more loaded
468 /// device. Load reorders only WITHIN a fit tier.
469 #[test]
470 fn tight_idle_still_loses_to_comfortable_saturated() {
471 let set = ranked_set();
472 // CUDA: available==total==100, alloc 100 → 100% used → Tight; idle.
473 // CPU: ample VRAM → Comfortable; saturated.
474 let lookup = load_lookup_for(vec![
475 (BackendId::Cuda, Some(100), Some(100), Some(0), 1),
476 (BackendId::Cpu, Some(10_000), Some(10_000), Some(4), 1),
477 ]);
478 let chained = ChainedSelector::with_default_estimator(None, Some(lookup));
479 assert_eq!(
480 chained.select(&set).expect("non-empty").backend,
481 BackendId::Cpu,
482 "Tight+idle CUDA stays demoted below Comfortable+saturated CPU; \
483 VRAM tier compared before load tier",
484 );
485 }
486
487 /// No-load degenerate: both arms VRAM-Comfortable and BOTH idle (0 in
488 /// flight) ⇒ every load_tier is 0 ⇒ the key reduces to the pre-C2
489 /// `(pressure, latency, idx)` ⇒ the static cost-winner (CUDA) is
490 /// preserved. Byte-identical to pre-C2 dispatch.
491 #[test]
492 fn no_load_preserves_static_winner() {
493 let set = ranked_set();
494 let lookup = load_lookup_for(vec![
495 (BackendId::Cuda, Some(10_000), Some(10_000), Some(0), 1),
496 (BackendId::Cpu, Some(10_000), Some(10_000), Some(0), 1),
497 ]);
498 let chained = ChainedSelector::with_default_estimator(None, Some(lookup));
499 assert_eq!(
500 chained.select(&set).expect("non-empty").backend,
501 BackendId::Cuda,
502 "no load anywhere ⇒ load leg is constant 0 ⇒ static winner stands",
503 );
504 }
505
506 /// `pending_work_count() == None` (a streaming backend that can't
507 /// report depth) is an honest no-signal = tier 0, NOT a fabricated
508 /// load. The cost-winner is preserved even though it "has" a streams
509 /// handle — because the handle reports `None`.
510 #[test]
511 fn none_count_is_no_signal_not_load() {
512 let set = ranked_set();
513 let lookup = load_lookup_for(vec![
514 (BackendId::Cuda, Some(10_000), Some(10_000), None, 1),
515 (BackendId::Cpu, Some(10_000), Some(10_000), None, 1),
516 ]);
517 let chained = ChainedSelector::with_default_estimator(None, Some(lookup));
518 assert_eq!(
519 chained.select(&set).expect("non-empty").backend,
520 BackendId::Cuda,
521 "None pending_work_count ⇒ tier 0 (no signal) ⇒ static winner",
522 );
523 }
524
525 /// No judge data + no runtime lookup → the pick equals
526 /// WinnerSelector's on a cost-ranked set. The degenerate-
527 /// fallback guarantee the production bridge relies on.
528 #[test]
529 fn no_signals_matches_winner_selector_exactly() {
530 let set = ranked_set();
531 let chained = ChainedSelector::with_default_estimator(None, None);
532 let baseline = WinnerSelector.select(&set).expect("non-empty");
533 let pick = chained.select(&set).expect("non-empty");
534 assert_eq!(pick.backend, baseline.backend);
535 assert_eq!(pick.backend, BackendId::Cuda, "static winner preserved");
536 }
537
538 /// Empty judge (no measurements) + Comfortable everywhere is
539 /// also a no-signal case — still the static winner.
540 #[test]
541 fn empty_judge_and_comfortable_pressure_keeps_winner() {
542 let set = ranked_set();
543 let judge: Arc<dyn JudgeOracle> = Arc::new(HashMapJudge::new());
544 let lookup = lookup_for(vec![
545 (BackendId::Cuda, Some(1_000_000), Some(2_000_000)),
546 (BackendId::Cpu, Some(1_000_000), Some(2_000_000)),
547 ]);
548 let chained = ChainedSelector::with_default_estimator(Some(judge), Some(lookup));
549 let pick = chained.select(&set).expect("non-empty");
550 assert_eq!(pick.backend, BackendId::Cuda);
551 }
552
553 /// GUARD: a WontFit winner is skipped; the next admissible
554 /// candidate is picked even though it loses on static cost.
555 #[test]
556 fn guard_skips_wont_fit_winner() {
557 let set = ranked_set();
558 // CUDA has 50 bytes free; default estimator projects
559 // bytes_moved = 100 → WontFit. CPU has plenty.
560 let lookup = lookup_for(vec![
561 (BackendId::Cuda, Some(50), Some(10_000)),
562 (BackendId::Cpu, Some(8_000), Some(10_000)),
563 ]);
564 let chained = ChainedSelector::with_default_estimator(None, Some(lookup));
565 let pick = chained.select(&set).expect("non-empty");
566 assert_eq!(pick.backend, BackendId::Cpu, "WontFit winner skipped");
567 }
568
569 /// GUARD: Tight demotes below Comfortable even when the Tight
570 /// candidate wins on latency — pressure reorders under
571 /// contention.
572 #[test]
573 fn tight_demoted_below_comfortable() {
574 let set = ranked_set();
575 // CUDA: available == total == 100, alloc 100 → 100% used →
576 // Tight (fits, but over the 0.85 threshold). CPU: ample.
577 let lookup = lookup_for(vec![
578 (BackendId::Cuda, Some(100), Some(100)),
579 (BackendId::Cpu, Some(10_000), Some(10_000)),
580 ]);
581 let chained = ChainedSelector::with_default_estimator(None, Some(lookup));
582 let pick = chained.select(&set).expect("non-empty");
583 assert_eq!(pick.backend, BackendId::Cpu, "Tight loses to Comfortable");
584 }
585
586 /// GUARD: Unknown ties with Comfortable — no handle for the
587 /// winner's backend must NOT demote it.
588 #[test]
589 fn unknown_ties_with_comfortable_preserves_winner() {
590 let set = ranked_set();
591 // Only CPU has a handle (Comfortable); CUDA is Unknown.
592 let lookup = lookup_for(vec![(BackendId::Cpu, Some(10_000), Some(10_000))]);
593 let chained = ChainedSelector::with_default_estimator(None, Some(lookup));
594 let pick = chained.select(&set).expect("non-empty");
595 assert_eq!(
596 pick.backend,
597 BackendId::Cuda,
598 "Unknown is no-signal, not pressure",
599 );
600 }
601
602 /// GUARD: every candidate WontFit → fall back to the static
603 /// winner (clean OOM site, non-empty-set contract upheld).
604 #[test]
605 fn all_wont_fit_falls_back_to_winner() {
606 let set = ranked_set();
607 let lookup = lookup_for(vec![
608 (BackendId::Cuda, Some(1), Some(10_000)),
609 (BackendId::Cpu, Some(1), Some(10_000)),
610 ]);
611 let chained = ChainedSelector::with_default_estimator(None, Some(lookup));
612 let pick = chained.select(&set).expect("non-empty per contract");
613 assert_eq!(pick.backend, BackendId::Cuda);
614 }
615
616 /// RANK: a Judge measurement on the runner-up flips the pick
617 /// when it beats the winner's figure; a slow measurement does
618 /// not.
619 #[test]
620 fn judge_measurement_flips_winner_both_directions() {
621 let c = ctx();
622
623 // CPU measured at 50ns < CUDA's unmeasured composite 100ns
624 // → CPU wins.
625 let mut judge = HashMapJudge::new();
626 judge.insert(
627 c.op,
628 c.principal_dtype,
629 c.size_class,
630 BackendId::Cpu,
631 "",
632 50,
633 0,
634 );
635 let chained = ChainedSelector::with_default_estimator(
636 Some(Arc::new(judge) as Arc<dyn JudgeOracle>),
637 None,
638 );
639 let set = ranked_set();
640 assert_eq!(
641 chained.select(&set).expect("non-empty").backend,
642 BackendId::Cpu,
643 "fast measurement beats slower unmeasured composite",
644 );
645
646 // CPU measured at 5000ns > CUDA's 100ns → CUDA stays.
647 let mut judge = HashMapJudge::new();
648 judge.insert(
649 c.op,
650 c.principal_dtype,
651 c.size_class,
652 BackendId::Cpu,
653 "",
654 5_000,
655 0,
656 );
657 let chained = ChainedSelector::with_default_estimator(
658 Some(Arc::new(judge) as Arc<dyn JudgeOracle>),
659 None,
660 );
661 let set = ranked_set();
662 assert_eq!(
663 chained.select(&set).expect("non-empty").backend,
664 BackendId::Cuda,
665 "slow measurement does not trump a faster unmeasured candidate",
666 );
667 }
668
669 /// RANK respects the guard: judge prefers a WontFit candidate,
670 /// but the guard's skip wins — measured speed never overrides
671 /// "the bytes don't fit".
672 #[test]
673 fn guard_overrides_judge_preference() {
674 let c = ctx();
675 let mut judge = HashMapJudge::new();
676 // CUDA measured blazing fast...
677 judge.insert(
678 c.op,
679 c.principal_dtype,
680 c.size_class,
681 BackendId::Cuda,
682 "",
683 1,
684 0,
685 );
686 // ...but CUDA can't fit the output.
687 let lookup = lookup_for(vec![
688 (BackendId::Cuda, Some(50), Some(10_000)),
689 (BackendId::Cpu, Some(8_000), Some(10_000)),
690 ]);
691 let chained = ChainedSelector::with_default_estimator(
692 Some(Arc::new(judge) as Arc<dyn JudgeOracle>),
693 Some(lookup),
694 );
695 let set = ranked_set();
696 assert_eq!(
697 chained.select(&set).expect("non-empty").backend,
698 BackendId::Cpu,
699 );
700 }
701
702 /// Judge ranks WITHIN a pressure tier: two Comfortable
703 /// candidates re-order by measurement while a Tight one stays
704 /// demoted regardless of its measurement.
705 #[test]
706 fn judge_ranks_within_pressure_tier() {
707 let c = ctx();
708 let mut set = AlternativeSet::empty();
709 set.push(make_candidate(
710 BackendId::Cuda,
711 DeviceLocation::Cuda { gpu_id: 0 },
712 100,
713 ));
714 set.push(make_candidate(BackendId::Cpu, DeviceLocation::Cpu, 200));
715 set.push(make_candidate(
716 BackendId::Vulkan,
717 DeviceLocation::Vulkan { gpu_id: 0 },
718 300,
719 ));
720 set.set_context(c);
721
722 // CUDA is Tight; CPU + Vulkan Comfortable.
723 let lookup = lookup_for(vec![
724 (BackendId::Cuda, Some(100), Some(100)),
725 (BackendId::Cpu, Some(10_000), Some(10_000)),
726 (BackendId::Vulkan, Some(10_000), Some(10_000)),
727 ]);
728 // Judge: CUDA fastest overall (doesn't matter — Tight),
729 // Vulkan beats CPU within tier 0.
730 let mut judge = HashMapJudge::new();
731 judge.insert(
732 c.op,
733 c.principal_dtype,
734 c.size_class,
735 BackendId::Cuda,
736 "",
737 1,
738 0,
739 );
740 judge.insert(
741 c.op,
742 c.principal_dtype,
743 c.size_class,
744 BackendId::Cpu,
745 "",
746 90,
747 0,
748 );
749 judge.insert(
750 c.op,
751 c.principal_dtype,
752 c.size_class,
753 BackendId::Vulkan,
754 "",
755 40,
756 0,
757 );
758
759 let chained = ChainedSelector::with_default_estimator(
760 Some(Arc::new(judge) as Arc<dyn JudgeOracle>),
761 Some(lookup),
762 );
763 assert_eq!(
764 chained.select(&set).expect("non-empty").backend,
765 BackendId::Vulkan,
766 "judge re-ranks the Comfortable tier; Tight stays demoted",
767 );
768 }
769
770 /// Sibling kernels at the same backend rank independently via
771 /// kernel_source — the end-to-end reason kernel_source is on
772 /// both Candidate and the Judge key.
773 #[test]
774 fn kernel_source_disambiguates_cpu_siblings() {
775 let c = ctx();
776 let mut set = AlternativeSet::empty();
777 let mut aocl = make_candidate(BackendId::Cpu, DeviceLocation::Cpu, 500);
778 aocl.kernel_source = "aocl";
779 let mut mkl = make_candidate(BackendId::Cpu, DeviceLocation::Cpu, 500);
780 mkl.kernel_source = "mkl";
781 set.push(aocl);
782 set.push(mkl);
783 set.set_context(c);
784
785 let mut judge = HashMapJudge::new();
786 judge.insert(
787 c.op,
788 c.principal_dtype,
789 c.size_class,
790 BackendId::Cpu,
791 "aocl",
792 9_000,
793 0,
794 );
795 judge.insert(
796 c.op,
797 c.principal_dtype,
798 c.size_class,
799 BackendId::Cpu,
800 "mkl",
801 1_000,
802 0,
803 );
804 let chained = ChainedSelector::with_default_estimator(
805 Some(Arc::new(judge) as Arc<dyn JudgeOracle>),
806 None,
807 );
808 assert_eq!(
809 chained.select(&set).expect("non-empty").kernel_source,
810 "mkl",
811 );
812 }
813
814 /// No DecisionContext on the set → the judge leg is inert even
815 /// when measurements exist; static order stands.
816 #[test]
817 fn missing_context_disables_judge_leg() {
818 let c = ctx();
819 let mut set = AlternativeSet::empty();
820 set.push(make_candidate(
821 BackendId::Cuda,
822 DeviceLocation::Cuda { gpu_id: 0 },
823 100,
824 ));
825 set.push(make_candidate(BackendId::Cpu, DeviceLocation::Cpu, 200));
826 // NOTE: no set_context.
827
828 let mut judge = HashMapJudge::new();
829 judge.insert(
830 c.op,
831 c.principal_dtype,
832 c.size_class,
833 BackendId::Cpu,
834 "",
835 1,
836 0,
837 );
838 let chained = ChainedSelector::with_default_estimator(
839 Some(Arc::new(judge) as Arc<dyn JudgeOracle>),
840 None,
841 );
842 assert_eq!(
843 chained.select(&set).expect("non-empty").backend,
844 BackendId::Cuda,
845 "no context → no judge key → static winner",
846 );
847 }
848
849 /// Empty set → None per the trait contract.
850 #[test]
851 fn empty_set_returns_none() {
852 let set = AlternativeSet::empty();
853 let chained = ChainedSelector::with_default_estimator(None, None);
854 assert!(chained.select(&set).is_none());
855 }
856
857 /// Debug impl doesn't panic on the closure / dyn fields.
858 #[test]
859 fn debug_does_not_panic() {
860 let chained = ChainedSelector::with_default_estimator(
861 Some(Arc::new(HashMapJudge::new()) as Arc<dyn JudgeOracle>),
862 Some(Arc::new(|_, _| None)),
863 );
864 let s = format!("{chained:?}");
865 assert!(s.contains("ChainedSelector"));
866 }
867}