Skip to main content

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}