Skip to main content

fuel_dispatch/ranker/
filter.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `AlternativeFilter` trait + `FilterClass` + `FilterContext`.
3//!
4//! Phase 1.1 of the picker-work arc. The trait shape is the
5//! contract every concrete filter in Phase 1.3+ (precision floor,
6//! tolerance budget, strided-input preference, bit-stable
7//! preference, Judge fastest) implements.
8//!
9//! Trait object dispatch (`Vec<Box<dyn AlternativeFilter>>`) is the
10//! deliberate choice — plan time isn't a hot loop, the heap cost is
11//! one allocation per filter per decision point, and runtime
12//! composition lets Phase 3's Judge filter slot in without touching
13//! the consumer's chain construction.
14
15use fuel_ir::dispatch::OpKind;
16use fuel_ir::{DType, Layout};
17
18use super::candidate::Candidate;
19
20/// One filter in the optimizer ranker's chain. Given the current
21/// candidate list + context, returns the indices of candidates to
22/// keep. The chain applies filters in order; each one sees the
23/// survivors of the previous.
24///
25/// # Implementor contract
26///
27/// - **Pure function of inputs.** Don't read globals or mutate
28///   external state — the filter is called at plan time, possibly
29///   from a compiler thread, and idempotency matters for plan-cache
30///   reuse.
31/// - **Sorted, distinct, in-range indices.** The returned `Vec<usize>`
32///   must be sorted strictly ascending, contain only indices `<
33///   alts.len()`, and have no duplicates. The chain pipeline calls
34///   [`crate::ranker::AlternativeSet::retain_indices`] which
35///   `debug_assert!`s these invariants.
36/// - **Empty result = "I reject everything."** Soft filters that
37///   would over-restrict are skipped by the pipeline; hard filters
38///   are surfaced as `Error::FilterRejected`. Filters themselves
39///   don't need to know which class they're in — just return what
40///   they think is admissible.
41pub trait AlternativeFilter: Send + Sync {
42    /// Apply this filter against the candidate list. Return the
43    /// indices to keep (sorted ascending, distinct, in-range).
44    fn filter(&self, alts: &[Candidate], ctx: &FilterContext) -> Vec<usize>;
45
46    /// Is this a hard filter (may filter to zero, surfaces as error)
47    /// or soft (may only filter if at least `min_remaining` remain)?
48    fn classification(&self) -> FilterClass;
49
50    /// Short identifier shown in diagnostics. Convention: short
51    /// kebab-case ("precision-floor", "strided-input-pref",
52    /// "judge-fastest"). Used in [`fuel_ir::Error::FilterRejected`].
53    fn name(&self) -> &'static str;
54}
55
56/// How aggressive a filter is allowed to be when shrinking the
57/// candidate set.
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum FilterClass {
60    /// The filter may filter to zero. If it does, the pipeline
61    /// surfaces [`fuel_ir::Error::FilterRejected`] — the
62    /// user asked for something the binding-table can't deliver and
63    /// we surface it rather than silently substituting a
64    /// non-admissible alternative.
65    ///
66    /// Use for filters that encode a user-requested correctness
67    /// floor: precision bound, tolerance budget, explicit backend
68    /// pin (if a future filter encodes that). Not for preferences.
69    Hard,
70
71    /// The filter must leave at least `min_remaining` candidates.
72    /// If applying it would drop below this threshold, the pipeline
73    /// skips this filter for this call (logs that it saturated).
74    ///
75    /// `min_remaining = 1` is the typical preference shape: "narrow
76    /// the set when possible, but don't leave nothing." Larger
77    /// values reserve diversity for downstream filters that might
78    /// further narrow — e.g., a strided-input-preference filter
79    /// with `min_remaining = 2` ensures the subsequent Judge filter
80    /// still has at least two options to rank empirically.
81    Soft { min_remaining: usize },
82}
83
84/// Read-only side-channel the filter chain passes to each
85/// [`AlternativeFilter`]. The fields are the union of what any
86/// shipped or planned filter needs; new fields are additive.
87///
88/// Borrowed lifetime — the context lives for the duration of one
89/// `apply_filter_chain` call, no allocation beyond what the caller
90/// already has on hand.
91#[derive(Clone, Copy, Debug)]
92pub struct FilterContext<'a> {
93    /// Op kind at this decision point. Filters that index by op
94    /// (a fused-op-specific preference, a Judge lookup) read this.
95    pub op: OpKind,
96    /// Per-operand dtype list — inputs in order, then outputs.
97    /// Matches the binding-table lookup key shape.
98    pub dtypes: &'a [DType],
99    /// Layouts of the input operands. Caps-preference filters read
100    /// this to decide whether to prefer kernels that advertise
101    /// `strided_input`.
102    pub input_layouts: &'a [Layout],
103    /// Optional caller-supplied diagnostic tag. The pipeline echoes
104    /// it into `Error::FilterRejected`'s `ctx_summary` when a hard
105    /// filter rejects everything.
106    pub tag: Option<&'a str>,
107}
108
109impl<'a> FilterContext<'a> {
110    /// Build a context with no tag — the common case.
111    pub fn new(op: OpKind, dtypes: &'a [DType], input_layouts: &'a [Layout]) -> Self {
112        Self {
113            op,
114            dtypes,
115            input_layouts,
116            tag: None,
117        }
118    }
119
120    /// Builder-style: attach a diagnostic tag (e.g. the graph
121    /// `NodeId`'s display form) for richer error messages.
122    pub fn with_tag(mut self, tag: &'a str) -> Self {
123        self.tag = Some(tag);
124        self
125    }
126
127    /// Short diagnostic summary used in `Error::FilterRejected`'s
128    /// `ctx_summary` field. Format chosen for grep-ability —
129    /// `op=MatMul dtypes=[F32,F32,F32] layouts=2 tag=NodeId(42)`.
130    pub fn summary(&self) -> String {
131        let layouts = self.input_layouts.len();
132        match self.tag {
133            Some(t) => format!(
134                "op={:?} dtypes={:?} input_layouts={} tag={}",
135                self.op, self.dtypes, layouts, t,
136            ),
137            None => format!(
138                "op={:?} dtypes={:?} input_layouts={}",
139                self.op, self.dtypes, layouts,
140            ),
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use fuel_ir::Shape;
149
150    /// Mock filter for tests — keeps exactly the indices it was
151    /// configured with, regardless of input.
152    pub(crate) struct MockFilter {
153        pub keep: Vec<usize>,
154        pub class: FilterClass,
155        pub name: &'static str,
156    }
157
158    impl AlternativeFilter for MockFilter {
159        fn filter(&self, _alts: &[Candidate], _ctx: &FilterContext) -> Vec<usize> {
160            self.keep.clone()
161        }
162        fn classification(&self) -> FilterClass {
163            self.class
164        }
165        fn name(&self) -> &'static str {
166            self.name
167        }
168    }
169
170    #[test]
171    fn filter_trait_is_dyn_compatible() {
172        // The trait must be usable as a trait object for the
173        // `Vec<Box<dyn AlternativeFilter>>` chain shape; this is a
174        // compile-time check encoded as a runtime construction.
175        let f: Box<dyn AlternativeFilter> = Box::new(MockFilter {
176            keep: vec![0, 2],
177            class: FilterClass::Soft { min_remaining: 1 },
178            name: "mock",
179        });
180        let layouts = [Layout::contiguous(Shape::from(vec![4]))];
181        let ctx = FilterContext::new(OpKind::AddElementwise, &[DType::F32], &layouts);
182        assert_eq!(f.filter(&[], &ctx), vec![0, 2]);
183        assert_eq!(f.name(), "mock");
184        assert!(matches!(
185            f.classification(),
186            FilterClass::Soft { min_remaining: 1 }
187        ));
188    }
189
190    #[test]
191    fn filter_context_summary_includes_op_and_dtypes() {
192        let layouts = [Layout::contiguous(Shape::from(vec![4]))];
193        let ctx = FilterContext::new(
194            OpKind::MatMul,
195            &[DType::F32, DType::F32, DType::F32],
196            &layouts,
197        );
198        let s = ctx.summary();
199        assert!(s.contains("MatMul"));
200        assert!(s.contains("F32"));
201        assert!(s.contains("input_layouts=1"));
202    }
203
204    #[test]
205    fn filter_context_with_tag_appears_in_summary() {
206        let layouts: [Layout; 0] = [];
207        let ctx = FilterContext::new(OpKind::AddElementwise, &[DType::F32], &layouts)
208            .with_tag("NodeId(42)");
209        assert!(ctx.summary().contains("NodeId(42)"));
210    }
211
212    #[test]
213    fn filter_class_variants_distinguishable() {
214        assert_ne!(FilterClass::Hard, FilterClass::Soft { min_remaining: 1 },);
215        assert_ne!(
216            FilterClass::Soft { min_remaining: 1 },
217            FilterClass::Soft { min_remaining: 2 },
218        );
219    }
220}