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}