Skip to main content

rlx_runtime/
check.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Static graph checker — the analysis behind `cargo rlx check` and the
6//! `#[rlx_model(check)]` self-check hook.
7//!
8//! rlx already *computes* everything a language server would surface, but only
9//! exposes it through env-gated `eprintln!` (`RLX_DISPATCH_REPORT`,
10//! `RLX_FUSION_REPORT`, `RLX_LINT_NUMERICS`) or compile-time panics. This module
11//! folds those same pure functions into one structured [`CheckReport`].
12//!
13//! Six axes, all but execution-legality fully device-free (no GPU, no driver):
14//! * **shape / dtype** — [`rlx_ir::verify::verify_all`] (errors).
15//! * **backend dispatch** — per backend, ops that run native vs. portable
16//!   common-IR (a perf note) vs. can't be lowered (an error), resolved from the
17//!   backend's real op claim via the registry.
18//! * **fusion** — patterns that should have collapsed but didn't (warnings).
19//! * **numerics** — constant subgraphs that provably fold to NaN/Inf (warnings).
20//! * **representation** — [`rlx_ir::repr_check`]: a consumer whose producer
21//!   declares a different stride / encoding / arity (errors).
22//! * **schedule** — GPU tile utilization from [`rlx_gpu_dispatch::cost`]
23//!   (notes).
24//!
25//! The severities are the design, not decoration. They are the three
26//! dispositions a pre-compile analysis can have, and collapsing them would make
27//! at least one unusable:
28//!
29//! * **gate** (errors) — shape and representation. These do not degrade output,
30//!   they compute a *different function*, so compilation must not proceed.
31//! * **report** (warnings) — fusion and numerics. The program is well-formed
32//!   but something is probably not what the author meant.
33//! * **hint** (notes) — schedule. Nothing is wrong. The answer is correct and
34//!   the machine is being wasted producing it. A finding that could fail a
35//!   build here would mean rejecting correct programs for being slow.
36//!
37//! Execution legality is only reported for backends compiled into the build.
38//! CPU is always available and portable; other backends are opt-in behind the
39//! consuming crate's Cargo features (the claim is read driver-free — backend
40//! factories build unit structs and `supported_ops()` is a const).
41
42use std::collections::HashSet;
43use std::panic::{AssertUnwindSafe, catch_unwind};
44
45use rlx_ir::verify::VerifyError;
46use rlx_ir::{Graph, node_label};
47use rlx_opt::rlx_compile::KernelDispatchConfig;
48use rlx_opt::rlx_compile::dispatch_report::{DispatchPath, prepare_graph_for_backend_with_report};
49use rlx_opt::rlx_compile::fusion_pipeline::{Fuse, FusionTarget};
50use serde::Serialize;
51
52use crate::Device;
53use crate::registry::backend_for;
54
55/// Diagnostic severity, ordered most-severe first for rendering.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
57#[serde(rename_all = "lowercase")]
58pub enum Severity {
59    Error,
60    Warning,
61    Note,
62}
63
64impl Severity {
65    fn rank(self) -> u8 {
66        match self {
67            Severity::Error => 0,
68            Severity::Warning => 1,
69            Severity::Note => 2,
70        }
71    }
72
73    fn label(self) -> &'static str {
74        match self {
75            Severity::Error => "error",
76            Severity::Warning => "warning",
77            Severity::Note => "note",
78        }
79    }
80}
81
82/// A single finding about the graph.
83#[derive(Debug, Clone, Serialize)]
84pub struct Diagnostic {
85    pub severity: Severity,
86    /// Stable kebab-case slug, e.g. `shape`, `unsupported-op`, `missed-fusion`, `numeric`.
87    pub code: String,
88    pub message: String,
89    /// Offending node id (the `%N` in the graph), when one applies.
90    pub node: Option<u32>,
91    /// Node label / name for context (`node_label`).
92    pub context: Option<String>,
93    /// Actionable remedy, reused from the underlying analysis where available.
94    pub hint: Option<String>,
95    /// Backend this pertains to (dispatch findings); `None` = backend-agnostic.
96    pub backend: Option<String>,
97}
98
99/// Execution-legality rollup against a backend's real op claim.
100///
101/// Only present when the backend is compiled into the build. Resolved
102/// statically from the registry — no live driver is needed, since backend
103/// factories build unit structs and `supported_ops()` is a const.
104#[derive(Debug, Clone, Serialize)]
105pub struct Legality {
106    /// True when no op is left unsupported after rewrite.
107    pub compile_ready: bool,
108    pub native_kinds: usize,
109    pub common_ir_kinds: usize,
110    pub rewritten_kinds: usize,
111    pub unsupported_kinds: usize,
112}
113
114/// Per-backend rollup: fusion coverage (always) + execution legality (when the
115/// backend is compiled in).
116#[derive(Debug, Clone, Serialize)]
117pub struct BackendSummary {
118    pub backend: String,
119    /// `None` when this backend isn't compiled into the build — fusion coverage
120    /// is still reported; execution legality is not.
121    pub legality: Option<Legality>,
122    /// Count of fused ops produced by the fusion pipeline for this target.
123    pub fused_ops: usize,
124    pub missed_fusions: usize,
125}
126
127/// The full result of [`check_graph`].
128#[derive(Debug, Clone, Serialize)]
129pub struct CheckReport {
130    pub graph: String,
131    pub nodes: usize,
132    pub diagnostics: Vec<Diagnostic>,
133    pub backends: Vec<BackendSummary>,
134}
135
136/// What to check and against which backends.
137#[derive(Debug, Clone)]
138pub struct CheckOptions {
139    /// Backends whose op claim + fusion pipeline to analyze against.
140    pub backends: Vec<FusionTarget>,
141    pub dispatch: bool,
142    pub fusion: bool,
143    pub numeric: bool,
144    /// Producer–consumer representation compatibility
145    /// ([`rlx_ir::repr_check`]). On by default: it is a static, device-free
146    /// check whose whole purpose is to catch representation mismatches BEFORE a
147    /// backend computes a wrong number from them.
148    pub repr: bool,
149    /// Physical-schedule quality for GPU dispatch
150    /// ([`rlx_gpu_dispatch::cost`]). Notes only — nothing here is wrong, it is
151    /// work the hardware will do and throw away.
152    pub schedule: bool,
153    /// Program-safety and schedule-semantics gates over the memory plan
154    /// ([`rlx_compile::plan_check`]). Errors: an overlapping live buffer does
155    /// not degrade output, it returns another tensor's bytes.
156    pub plan: bool,
157}
158
159impl Default for CheckOptions {
160    fn default() -> Self {
161        Self {
162            backends: default_backends(),
163            dispatch: true,
164            fusion: true,
165            numeric: true,
166            repr: true,
167            schedule: true,
168            plan: true,
169        }
170    }
171}
172
173/// A representative cross-vendor default set (CPU + the three big GPU families).
174pub fn default_backends() -> Vec<FusionTarget> {
175    vec![
176        FusionTarget::Cpu,
177        FusionTarget::Metal,
178        FusionTarget::Cuda,
179        FusionTarget::Wgpu,
180    ]
181}
182
183/// Every fusion target rlx models a static op claim for.
184pub fn all_backends() -> Vec<FusionTarget> {
185    vec![
186        FusionTarget::Cpu,
187        FusionTarget::Metal,
188        FusionTarget::Mlx,
189        FusionTarget::Wgpu,
190        FusionTarget::Cuda,
191        FusionTarget::Rocm,
192        FusionTarget::Tpu,
193    ]
194}
195
196/// Stable lowercase name for a backend target.
197pub fn backend_name(t: FusionTarget) -> &'static str {
198    match t {
199        FusionTarget::Cpu => "cpu",
200        FusionTarget::Metal => "metal",
201        FusionTarget::Mlx => "mlx",
202        FusionTarget::Wgpu => "wgpu",
203        FusionTarget::Cuda => "cuda",
204        FusionTarget::Rocm => "rocm",
205        FusionTarget::Tpu => "tpu",
206    }
207}
208
209/// Parse a backend name (with common aliases) into a [`FusionTarget`].
210pub fn parse_backend(s: &str) -> Option<FusionTarget> {
211    match s.trim().to_ascii_lowercase().as_str() {
212        "cpu" => Some(FusionTarget::Cpu),
213        "metal" | "mps" | "mtl" => Some(FusionTarget::Metal),
214        "mlx" => Some(FusionTarget::Mlx),
215        "wgpu" | "gpu" | "webgpu" => Some(FusionTarget::Wgpu),
216        "cuda" | "nvidia" => Some(FusionTarget::Cuda),
217        "rocm" | "hip" | "amd" => Some(FusionTarget::Rocm),
218        "tpu" => Some(FusionTarget::Tpu),
219        _ => None,
220    }
221}
222
223/// The execution [`Device`] whose op claim answers legality for a fusion target.
224pub fn backend_device(t: FusionTarget) -> Device {
225    match t {
226        FusionTarget::Cpu => Device::Cpu,
227        FusionTarget::Metal => Device::Metal,
228        FusionTarget::Mlx => Device::Mlx,
229        FusionTarget::Wgpu => Device::Gpu,
230        FusionTarget::Cuda => Device::Cuda,
231        FusionTarget::Rocm => Device::Rocm,
232        FusionTarget::Tpu => Device::Tpu,
233    }
234}
235
236/// The backend's real execution op-claim, or `None` if it isn't compiled in.
237///
238/// Driver-free: the factory builds a unit struct and `supported_ops()` is a
239/// const, so this works for any compiled-in backend even without its hardware.
240fn execution_claim(device: Device) -> Option<&'static [rlx_ir::OpKind]> {
241    catch_unwind(AssertUnwindSafe(|| {
242        backend_for(device).map(|be| be.supported_ops())
243    }))
244    .ok()
245    .flatten()
246}
247
248impl CheckReport {
249    pub fn count(&self, sev: Severity) -> usize {
250        self.diagnostics
251            .iter()
252            .filter(|d| d.severity == sev)
253            .count()
254    }
255
256    pub fn errors(&self) -> usize {
257        self.count(Severity::Error)
258    }
259
260    pub fn warnings(&self) -> usize {
261        self.count(Severity::Warning)
262    }
263
264    pub fn has_errors(&self) -> bool {
265        self.errors() > 0
266    }
267
268    /// Compact JSON for machine consumers / editors.
269    pub fn to_json(&self) -> String {
270        serde_json::to_string_pretty(self).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
271    }
272
273    /// rustc-style human report.
274    pub fn render(&self) -> String {
275        use std::fmt::Write as _;
276        let mut s = String::new();
277        let _ = writeln!(
278            s,
279            "rlx check — graph \"{}\" ({} node{})",
280            self.graph,
281            self.nodes,
282            if self.nodes == 1 { "" } else { "s" }
283        );
284
285        let mut diags: Vec<&Diagnostic> = self.diagnostics.iter().collect();
286        diags.sort_by_key(|d| d.severity.rank());
287
288        if diags.is_empty() {
289            let _ = writeln!(s, "\n  no findings.");
290        } else {
291            let _ = writeln!(s);
292            for d in diags {
293                let on = d
294                    .backend
295                    .as_deref()
296                    .map(|b| format!(" on {b}"))
297                    .unwrap_or_default();
298                let _ = writeln!(s, "{}[{}]{on}: {}", d.severity.label(), d.code, d.message);
299                if let Some(n) = d.node {
300                    // Skip the label when it's just the node id again (unnamed node).
301                    match &d.context {
302                        Some(c) if !c.is_empty() && *c != format!("%{n}") => {
303                            let _ = writeln!(s, "  --> %{n} ({c})");
304                        }
305                        _ => {
306                            let _ = writeln!(s, "  --> %{n}");
307                        }
308                    }
309                }
310                if let Some(h) = &d.hint {
311                    let _ = writeln!(s, "  = help: {h}");
312                }
313            }
314        }
315
316        if !self.backends.is_empty() {
317            let _ = writeln!(s, "\nbackends:");
318            for b in &self.backends {
319                match &b.legality {
320                    Some(l) => {
321                        let status = if l.compile_ready {
322                            "ready  "
323                        } else {
324                            "BLOCKED"
325                        };
326                        let _ = writeln!(
327                            s,
328                            "  {:<6} {status}  native={} common-ir={} rewritten={} unsupported={}  fused={} missed={}",
329                            b.backend,
330                            l.native_kinds,
331                            l.common_ir_kinds,
332                            l.rewritten_kinds,
333                            l.unsupported_kinds,
334                            b.fused_ops,
335                            b.missed_fusions,
336                        );
337                    }
338                    None => {
339                        let _ = writeln!(
340                            s,
341                            "  {:<6} legality n/a (build --features {})           fused={} missed={}",
342                            b.backend, b.backend, b.fused_ops, b.missed_fusions,
343                        );
344                    }
345                }
346            }
347        }
348
349        let _ = writeln!(
350            s,
351            "\nsummary: {} error(s), {} warning(s), {} note(s) across {} backend(s)",
352            self.errors(),
353            self.warnings(),
354            self.count(Severity::Note),
355            self.backends.len(),
356        );
357        s
358    }
359}
360
361/// `Some(n)` for a statically-known extent, `None` for a dynamic one.
362fn static_dim(d: rlx_ir::Dim) -> Option<usize> {
363    match d {
364        rlx_ir::Dim::Static(n) => Some(n),
365        rlx_ir::Dim::Dynamic(_) => None,
366    }
367}
368
369/// Whether this target dispatches through the shared GPU tile table at all.
370/// CPU and TPU do not, so a tile note would be meaningless for them.
371fn is_gpu_target(t: FusionTarget) -> bool {
372    matches!(
373        t,
374        FusionTarget::Metal
375            | FusionTarget::Mlx
376            | FusionTarget::Wgpu
377            | FusionTarget::Cuda
378            | FusionTarget::Rocm
379    )
380}
381
382/// Modelled speedup a non-default tile must show before the graph is worth
383/// mentioning at all.
384///
385/// Higher than the tuner's own `MIN_SPEEDUP` (1.02) on purpose. The tuner is
386/// deciding what to install from real timings and can afford a thin margin; a
387/// static hint is spending the reader's attention on an *uncalibrated* ranking,
388/// so it should only speak when the prize is clearly worth a tuning run.
389const MIN_MODELLED_GAIN: f64 = 1.25;
390
391/// A single note on GPU tile fit for the whole graph.
392///
393/// Device-free: it compares the compile-time default tile against the rest of
394/// the candidate set, which is what runs on every arch absent a measured
395/// override.
396///
397/// **One note, not one per node — and not one per shape.** Two earlier shapes
398/// of this analysis were both noise. Per-node reporting emitted 196 identical
399/// lines for a 28-layer decode graph. Per-shape reporting cut that to 3, but
400/// the modelled gain turns out to be near-constant within a regime (~1.56x
401/// across every decode shape, ~1.27x across every prefill shape), so those 3
402/// lines were one fact about rlx's default tile restated per shape. The
403/// information content is "this graph has untuned GEMM shapes, run the tuner",
404/// which is worth saying once.
405///
406/// It also reports *available gain*, not *wasted arithmetic*, because the two
407/// are not related the way the wording implied: a shape using 1.6% of the
408/// tile's MACs has 1.56x available, while one using 93.8% has 1.16x. "98.4%
409/// discarded" reads like a 60x opportunity and is not one.
410fn schedule_notes(graph: &Graph) -> Vec<Diagnostic> {
411    use rlx_gpu_dispatch::cost::TileCostModel;
412    use rlx_gpu_dispatch::dispatch::Workload;
413    use rlx_gpu_dispatch::tiles::{MATMUL_TILE_CANDIDATES, TileParams};
414
415    let default_tile = TileParams::DEFAULT_MATMUL;
416    // `structural()`, not `sm86()`: this walks a graph with no device in sight,
417    // so claiming a calibrated model would claim a measurement never taken.
418    let model = TileCostModel::structural();
419
420    // (shape, gain, winning tile, node count, first node) per distinct shape.
421    let mut shapes: Vec<(
422        (usize, usize, usize),
423        f64,
424        TileParams,
425        usize,
426        rlx_ir::NodeId,
427    )> = Vec::new();
428
429    for node in graph.nodes() {
430        if !matches!(node.op, rlx_ir::Op::MatMul) || node.inputs.len() != 2 {
431            continue;
432        }
433        // A @ B: the output's trailing dims are [m, n]; A's trailing dim is k.
434        let out_dims = node.shape.dims();
435        let lhs_dims = graph.shape(node.inputs[0]).dims();
436        if out_dims.len() < 2 || lhs_dims.is_empty() {
437            continue;
438        }
439        // A dynamic extent has no schedule to assess — the tile that runs
440        // depends on a value this analysis cannot see. Skipping is the honest
441        // answer; guessing a size would produce a confident claim about a shape
442        // that may never occur.
443        let (Some(m), Some(n), Some(k)) = (
444            static_dim(out_dims[out_dims.len() - 2]),
445            static_dim(out_dims[out_dims.len() - 1]),
446            static_dim(lhs_dims[lhs_dims.len() - 1]),
447        ) else {
448            continue;
449        };
450
451        if let Some(slot) = shapes.iter_mut().find(|(sh, ..)| *sh == (m, k, n)) {
452            slot.3 += 1;
453            continue;
454        }
455
456        let w = Workload::Matmul { m, k, n };
457        let Some(base) = model.estimate(&default_tile, &w) else {
458            continue;
459        };
460        let Some((best_tile, best)) = MATMUL_TILE_CANDIDATES
461            .iter()
462            .filter_map(|t| model.estimate(t, &w).map(|c| (*t, c)))
463            .min_by(|a, b| {
464                a.1.score
465                    .partial_cmp(&b.1.score)
466                    .unwrap_or(std::cmp::Ordering::Equal)
467            })
468        else {
469            continue;
470        };
471        if best.score <= 0.0 {
472            continue;
473        }
474        shapes.push(((m, k, n), base.score / best.score, best_tile, 1, node.id));
475    }
476
477    let worth_tuning: Vec<_> = shapes
478        .iter()
479        .filter(|(_, gain, ..)| *gain >= MIN_MODELLED_GAIN)
480        .collect();
481    let Some(worst) = worth_tuning
482        .iter()
483        .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
484    else {
485        return Vec::new();
486    };
487
488    let nodes: usize = worth_tuning.iter().map(|(_, _, _, c, _)| *c).sum();
489    let ((wm, wk, wn), gain, best_tile, _, anchor) = **worst;
490
491    vec![Diagnostic {
492        severity: Severity::Note,
493        code: "schedule-tile".to_string(),
494        message: format!(
495            "{} matmul shape(s) across {nodes} node(s) model faster on a non-default tile; \
496             largest modelled gain {gain:.2}x at {wm}x{wk}x{wn} ({} vs the default {})",
497            worth_tuning.len(),
498            best_tile.label(),
499            default_tile.label()
500        ),
501        node: Some(anchor.0),
502        context: Some(node_label(graph, anchor)),
503        hint: Some(
504            "these are UNCALIBRATED model rankings, not measurements — run \
505             `tune_dispatch` on the target device to confirm and install; the dispatch \
506             table only accepts a measured winner that also clears a held-out check"
507                .to_string(),
508        ),
509        backend: None,
510    }]
511}
512
513fn verify_diag(graph: &Graph, e: &VerifyError, code: &str) -> Diagnostic {
514    let hint = if e.message.contains("shape mismatch") {
515        Some(
516            "declared out-shape disagrees with the inferred shape — fix the builder's \
517             out_shape argument (or a dtype mismatch between the operands)"
518                .to_string(),
519        )
520    } else if e.message.contains("not a DAG") {
521        Some("an input references a later node — build nodes in topological order".to_string())
522    } else if e.message.contains("expects") && e.message.contains("inputs") {
523        Some("wrong operand count for this op".to_string())
524    } else {
525        None
526    };
527    Diagnostic {
528        severity: Severity::Error,
529        code: code.to_string(),
530        message: e.message.clone(),
531        node: e.node.map(|n| n.0),
532        context: e.node.map(|n| node_label(graph, n)),
533        hint,
534        backend: None,
535    }
536}
537
538/// Run every enabled check against `graph` and collect the findings.
539///
540/// Pure: no backend is instantiated beyond reading its (driver-free) op claim.
541/// Backend dispatch + fusion analysis are skipped when the graph fails
542/// structural or shape verification — fix those first, since downstream passes
543/// assume a well-formed, shape-consistent graph.
544pub fn check_graph(graph: &Graph, opts: &CheckOptions) -> CheckReport {
545    let mut diagnostics = Vec::new();
546
547    // 1. Structural integrity (DAG, arity, output refs) — device-independent.
548    let structural = rlx_ir::verify::verify(graph);
549    for e in &structural {
550        diagnostics.push(verify_diag(graph, e, "graph-structure"));
551    }
552
553    // 2. Shape / dtype consistency — device-independent.
554    let shape_errors = if structural.is_empty() {
555        rlx_ir::verify::verify_shapes(graph)
556    } else {
557        Vec::new()
558    };
559    for e in &shape_errors {
560        diagnostics.push(verify_diag(graph, e, "shape"));
561    }
562
563    let graph_ok = structural.is_empty() && shape_errors.is_empty();
564
565    // 3. Provable numeric blow-ups (constant folds to NaN/Inf) — device-independent.
566    if opts.numeric && graph_ok {
567        for l in rlx_opt::rlx_compile::lint_numerics(graph) {
568            diagnostics.push(Diagnostic {
569                severity: Severity::Warning,
570                code: "numeric".to_string(),
571                message: format!("{} produced here — {}", l.kind.as_str(), l.reason),
572                node: Some(l.node.0),
573                context: Some(l.label),
574                hint: l.fix.map(str::to_string),
575                backend: None,
576            });
577        }
578    }
579
580    // 3b. Producer–consumer representation compatibility — device-free.
581    //
582    // Reported as ERRORS, not warnings: a stride/encoding/arity mismatch does
583    // not degrade output, it produces a different function. Every finding here
584    // corresponds to a class of defect this tree has actually shipped.
585    if opts.repr && graph_ok {
586        let report = rlx_ir::repr_check::check_graph(graph);
587        for f in &report.findings {
588            diagnostics.push(Diagnostic {
589                severity: Severity::Error,
590                code: format!("repr-{}", f.kind.as_str()),
591                message: format!(
592                    "{:?} expects {} but its producer declares {}",
593                    f.op, f.expected, f.actual
594                ),
595                node: Some(f.node.0),
596                context: f.input.map(|i| format!("input[{i}]")),
597                hint: Some(f.why.to_string()),
598                backend: None,
599            });
600        }
601    }
602
603    // 3c. Physical-schedule quality for GPU dispatch — device-free.
604    //
605    // Reported as NOTES, and the distinction from 3b is the point. A repr
606    // mismatch computes a different function; a bad tile computes the right
607    // one and wastes the machine doing it. CAKE separates these dispositions
608    // for the same reason: something that must block compilation and something
609    // that should tell you what to change are not the same signal, and folding
610    // them together makes one of them unusable.
611    if opts.schedule && graph_ok && opts.backends.iter().any(|t| is_gpu_target(*t)) {
612        diagnostics.extend(schedule_notes(graph));
613    }
614
615    // 3d. Program safety + schedule semantics over the memory plan.
616    //
617    // Errors, like 3b: a buffer that overlaps a live neighbour does not
618    // produce a worse answer, it produces another tensor's bytes. Both
619    // categories are reported under distinct codes because they name
620    // different repair targets — one is where a buffer sits, the other is
621    // what order the schedule runs in.
622    if opts.plan && graph_ok {
623        let plan = rlx_opt::rlx_compile::memory::plan_memory(graph);
624        let report = rlx_opt::rlx_compile::plan_check::check_plan(graph, &plan);
625        for f in &report.findings {
626            diagnostics.push(Diagnostic {
627                severity: Severity::Error,
628                code: format!("plan-{}", f.kind.as_str()),
629                message: f.detail.clone(),
630                node: Some(f.node.0),
631                context: Some(node_label(graph, f.node)),
632                hint: Some(
633                    if f.kind.is_program_safety() {
634                        "a memory-use hazard: the arena assignment lets one tensor's bytes \
635                         be read or written as another's"
636                    } else {
637                        "a schedule-structure violation: the declared execution order does \
638                         not satisfy the graph's dependencies"
639                    }
640                    .to_string(),
641                ),
642                backend: None,
643            });
644        }
645    }
646
647    // 4. Per-backend: fusion coverage (device-free) + execution legality
648    //    (only for backends compiled into this build).
649    let mut backends = Vec::new();
650    // Missed fusions are largely authoring-level (structural); dedup across
651    // targets so the same miss isn't reported once per backend.
652    let mut seen_miss: HashSet<(String, u32, String)> = HashSet::new();
653    let mut legality_gaps: Vec<&'static str> = Vec::new();
654
655    if graph_ok {
656        for &t in &opts.backends {
657            let name = backend_name(t);
658            let mut summary = BackendSummary {
659                backend: name.to_string(),
660                legality: None,
661                fused_ops: 0,
662                missed_fusions: 0,
663            };
664
665            // -- execution legality against the backend's REAL op claim --
666            match execution_claim(backend_device(t)) {
667                Some(claim) => {
668                    let (_g, report) = prepare_graph_for_backend_with_report(
669                        graph.clone(),
670                        name,
671                        claim,
672                        KernelDispatchConfig::default(),
673                    );
674                    let mut leg = Legality {
675                        compile_ready: report.compile_ready,
676                        native_kinds: 0,
677                        common_ir_kinds: 0,
678                        rewritten_kinds: 0,
679                        unsupported_kinds: 0,
680                    };
681                    for kind_summary in &report.summaries {
682                        match kind_summary.path {
683                            DispatchPath::Native => leg.native_kinds += 1,
684                            DispatchPath::CommonIr => leg.common_ir_kinds += 1,
685                            DispatchPath::Rewritten => leg.rewritten_kinds += 1,
686                            DispatchPath::Unsupported => leg.unsupported_kinds += 1,
687                        }
688                    }
689                    if opts.dispatch {
690                        for (id, kind) in &report.still_unsupported {
691                            diagnostics.push(Diagnostic {
692                                severity: Severity::Error,
693                                code: "unsupported-op".to_string(),
694                                message: format!(
695                                    "{kind:?} cannot be lowered on {name} (still unsupported after rewrite)"
696                                ),
697                                node: Some(id.0),
698                                context: Some(node_label(graph, *id)),
699                                hint: Some(
700                                    "add a native thunk + list the OpKind in Backend::supported_ops, \
701                                     or add a rewrite/common-IR body in rlx-fusion"
702                                        .to_string(),
703                                ),
704                                backend: Some(name.to_string()),
705                            });
706                        }
707                        for kind in &report.common_lowered_kinds {
708                            diagnostics.push(Diagnostic {
709                                severity: Severity::Note,
710                                code: "common-ir".to_string(),
711                                message: format!(
712                                    "{kind:?} runs via portable common-IR on {name} (correct, but off the native fast path)"
713                                ),
714                                node: None,
715                                context: None,
716                                hint: Some(
717                                    "list this OpKind in the backend's supported_ops to dispatch a native kernel"
718                                        .to_string(),
719                                ),
720                                backend: Some(name.to_string()),
721                            });
722                        }
723                    }
724                    summary.legality = Some(leg);
725                }
726                None => legality_gaps.push(name),
727            }
728
729            // -- fusion: what fused, what was left on the table (device-free) --
730            let fusion = catch_unwind(AssertUnwindSafe(|| {
731                Fuse::new(t).run_with_report(graph.clone())
732            }));
733            if let Ok((_fused, freport)) = fusion {
734                summary.fused_ops = freport.fused_matmul_bias_act
735                    + freport.fused_swiglu
736                    + freport.fused_residual_ln
737                    + freport.fused_residual_rms_norm
738                    + freport.fused_attention_block
739                    + freport.fused_transformer_layer;
740                summary.missed_fusions = freport.missed.len();
741                if opts.fusion {
742                    for m in &freport.missed {
743                        let key = (m.pattern.to_string(), m.node.0, format!("{:?}", m.reason));
744                        if seen_miss.insert(key) {
745                            diagnostics.push(Diagnostic {
746                                severity: Severity::Warning,
747                                code: "missed-fusion".to_string(),
748                                message: format!(
749                                    "{} fusion not applied ({:?})",
750                                    m.pattern, m.reason
751                                ),
752                                node: Some(m.node.0),
753                                context: m.context.clone(),
754                                hint: m.hint.clone(),
755                                backend: None,
756                            });
757                        }
758                    }
759                }
760            }
761
762            backends.push(summary);
763        }
764
765        // One note (not one-per-backend) for backends whose real op claim isn't
766        // compiled in — those rows show fusion only.
767        if !legality_gaps.is_empty() {
768            diagnostics.push(Diagnostic {
769                severity: Severity::Note,
770                code: "legality-unavailable".to_string(),
771                message: format!(
772                    "execution legality not checked for [{}] — those backends aren't compiled \
773                     into this build (fusion coverage is still shown)",
774                    legality_gaps.join(", ")
775                ),
776                node: None,
777                context: None,
778                hint: Some(
779                    "rebuild with the matching backend feature to check native/unsupported dispatch"
780                        .to_string(),
781                ),
782                backend: None,
783            });
784        }
785    }
786
787    CheckReport {
788        graph: graph.name.clone(),
789        nodes: graph.len(),
790        diagnostics,
791        backends,
792    }
793}
794
795/// Self-check hook injected by `#[rlx_model(check)]` right after the model's
796/// graph is traced. Runs [`check_graph`] and reports findings to stderr.
797///
798/// Controlled at runtime by `RLX_CHECK`:
799/// * unset / `warn` / `1` — print the report when there are findings (default).
800/// * `off` / `0` / `false` — do nothing (production escape hatch).
801/// * `all` — check every backend target and always print.
802/// * `strict` — additionally **panic** on any error-level finding.
803///
804/// Focused on the CPU reference backend by default (that's what `#[rlx_model]`
805/// compiles for); `RLX_CHECK=all` widens to every target.
806pub fn model_self_check(name: &str, graph: &Graph) {
807    let mode = rlx_ir::env::var("RLX_CHECK").unwrap_or_default();
808    let mode = mode.trim().to_ascii_lowercase();
809    if matches!(mode.as_str(), "0" | "off" | "false") {
810        return;
811    }
812    let strict = mode == "strict";
813    let all = mode == "all";
814    let verbose = all || mode == "verbose";
815
816    let opts = CheckOptions {
817        backends: if all {
818            all_backends()
819        } else {
820            vec![FusionTarget::Cpu]
821        },
822        ..CheckOptions::default()
823    };
824    let report = check_graph(graph, &opts);
825
826    if verbose || report.errors() > 0 || report.warnings() > 0 {
827        eprint!("rlx-check [{name}]\n{}", report.render());
828    }
829    if strict && report.has_errors() {
830        panic!(
831            "rlx-check: model `{name}` has {} error-level finding(s) — see report above \
832             (RLX_CHECK=strict)",
833            report.errors()
834        );
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841    use rlx_ir::infer::GraphExt;
842    use rlx_ir::{DType, Shape};
843
844    fn f32s(d: &[usize]) -> Shape {
845        Shape::new(d, DType::F32)
846    }
847
848    #[test]
849    fn clean_mlp_is_cpu_ready() {
850        let mut g = Graph::new("mlp");
851        let x = g.input("x", f32s(&[4, 16]));
852        let w = g.param("w", f32s(&[16, 8]));
853        let h = g.mm(x, w);
854        let y = g.gelu(h);
855        g.set_outputs(vec![y]);
856
857        let r = check_graph(&g, &CheckOptions::default());
858        assert_eq!(r.errors(), 0, "unexpected errors: {:#?}", r.diagnostics);
859        let cpu = r.backends.iter().find(|b| b.backend == "cpu").unwrap();
860        let leg = cpu.legality.as_ref().expect("cpu legality");
861        assert!(leg.compile_ready);
862        assert_eq!(leg.unsupported_kinds, 0);
863    }
864
865    #[test]
866    fn self_check_hook_runs_without_panic() {
867        // Default RLX_CHECK behavior must never panic on a clean graph.
868        let mut g = Graph::new("hooked");
869        let x = g.input("x", f32s(&[2, 4]));
870        let w = g.param("w", f32s(&[4, 4]));
871        let y = g.mm(x, w);
872        g.set_outputs(vec![y]);
873        model_self_check("hooked", &g);
874    }
875}