Skip to main content

frink_models/
router_input.rs

1//! **WHICH TENSOR THE MoE ROUTER READS** -- the operand of
2//! `ffn_gate_inp`, as one value a `ModelConfig` carries and one table
3//! that says which architecture reads what.
4//!
5//! # What it is
6//!
7//! llama.cpp's `build_moe_ffn` (`llama-graph.cpp:1914-1948`) computes
8//! the router logits itself, `logits = gate_inp · cur`, from the SAME
9//! `cur` the experts then read -- the normed FFN input -- UNLESS the
10//! caller hands it a precomputed `probs_in`, in which case `gate_inp`
11//! is unused and the caller decided the operand. Every frink MoE body
12//! computed `router · normed2`, which is the default and right for
13//! every graph that takes it.
14//!
15//! # Who passes `probs_in` -- MEASURED, not read off one file
16//!
17//! Every `build_moe_ffn(` call in all 155 `src/models/*.cpp` was parsed
18//! for its `gate_inp` and `probs_in` arguments (2026-09-11). Fifty-nine
19//! call sites; four pass a precomputed `probs_in`:
20//!
21//! | arch | router operand | why precomputed | engine here | line |
22//! |---|---|---|---|---|
23//! | `smallthinker` | `inpL` -- the RAW LAYER INPUT, before `attn_norm`, before attention | the operand is different | generic GQA | `smallthinker.cpp:111,151-161` |
24//! | `grovemoe` | `cur` -- the normed FFN input, the default | shared between TWO `build_moe_ffn` calls (the expert bank and the chunk-expert bank) | generic GQA, refused for the second bank | `grovemoe.cpp:133,137-148,153-164` |
25//! | `gemma4` | `rms_norm(attn_out) * (1/sqrt(n_embd)) * ffn_gate_inp_s` -- the attention output, its own norm, a scale tensor | the operand is different | its own engine (`gemma4_engine`) | `gemma4.cpp:289-294` |
26//! | `nemotron-h` | `cur` -- the FFN input BEFORE the latent down-projection the experts read | the experts read `inp_latent`, the router does not | hybrid recurrent engine | `nemotron-h.cpp:210-232` |
27//!
28//! Two more route on something other than a variable named `cur` and
29//! are the default anyway: `llama4.cpp:221` passes `ffn_inp_normed`
30//! (the normed FFN input) and `cohere2moe.cpp:234,389` pass `ffn_inp`
31//! (the parallel-residual topology's one normed input, which its
32//! experts read too). Fifty-three sites pass `nullptr` or the 13-arg
33//! overload and route on `cur`.
34//!
35//! So `smallthinker` is the ONLY generic-path graph whose router
36//! operand is not what the experts read, and [`RouterInput`] had two
37//! variants rather than four: `gemma4`'s and `nemotron-h`'s shapes
38//! live on engines that do not read this field, and a variant with no
39//! caller is the OLMo lesson (`capability::WEIGHTED_LAYER_NORM`).
40//! `grovemoe` shares the mechanism (a precomputed `probs`) and NOT the
41//! cause; that is why the table is keyed by what the router reads and
42//! not by whether `probs_in` is non-null.
43//!
44//! # The third variant: a different `cur` -- `arctic`
45//!
46//! `arctic.cpp:135-152` passes NO `probs_in`; its router reads `cur`,
47//! the default mechanism. What differs is `cur` itself:
48//! `build_norm(inpSA, ffn_norm_exps)` at `:136-139` -- the residual
49//! stream as it ENTERS the layer (`inpSA = inpL`, `:69`), before
50//! attention, normed by a SECOND per-layer weight -- and the routed
51//! experts read that same vector, while the layer's dense FFN
52//! (`:118-132`, `crate::parallel_dense_ffn`) reads the ordinary
53//! `ffn_norm(ffn_inp)`. `grep -l FFN_NORM_EXPS src/models/*.cpp` over
54//! all 155 graphs is `arctic.cpp` (2026-09-12), so
55//! [`RouterInput::NormedLayerInput`] has one row and carries the fact
56//! that distinguishes it from `smallthinker`'s: the EXPERTS read it
57//! too ([`RouterInput::experts_read_router_operand`]). The bodies
58//! capture it at the same point as `smallthinker`'s logits -- where
59//! `attn_norm` is applied, before attention -- through
60//! `Decoder::router_operand`, and every fused Metal MoE launch refuses
61//! it through the predicate that already refused `RawLayerInput`.
62//!
63//! # What `inpL` is, exactly
64//!
65//! `smallthinker.cpp:86` sets `inpL = build_inp_embd(...)` and `:172`
66//! sets `inpL = cur` at the bottom of every layer, so at layer `il` it
67//! is the residual stream as it ENTERS the layer: the scaled embedding
68//! row at layer 0, the previous layer's output after both residual
69//! adds otherwise. `:111` reads it BEFORE `:115` norms it for
70//! attention, so the router sees no norm at all. frink captures it at
71//! the same point (`Decoder::router_operand`, called where the row's
72//! `attn_norm` is applied) and computes the logits there, in the same
73//! order llama.cpp does, so the operand cannot be the post-attention
74//! residual by mistake.
75//!
76//! Everything downstream of the logits is the ordinary
77//! `build_moe_ffn` (`:151-161`): `expert_gating_func` from the file
78//! (`conversion/smallthinker.py:27-30` writes SOFTMAX or SIGMOID),
79//! `norm_w = true` as a literal, `expert_weights_scale` unset
80//! (skipped at 0), no `exp_probs_b`, no shared expert, no groups.
81//! `Decoder::route_for_layer` already implements all of that.
82//!
83//! # Where it is served, and where it refuses
84//!
85//! The CPU row body and both batched host bodies take the operand
86//! from `Decoder::router_operand`, ONE function, and hand it to the
87//! ONE FFN body per shape (`decoder/ffn_block.rs`). Every Metal path
88//! that runs the router on the GPU reads `normed2` and nothing else,
89//! so `Decoder::gpu_router_matches_host_routing` -- the predicate all
90//! of them already share -- answers false for [`RouterInput::
91//! RawLayerInput`], and those launches fall back to the host bodies
92//! rather than routing on the wrong tensor.
93
94/// The operand of the MoE router's matmul.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum RouterInput {
97    /// `gate_inp · ffn_norm(ffn_inp)` -- the normed FFN input, the
98    /// same vector the experts read. `build_moe_ffn`'s own
99    /// computation, and every generic-path graph but one.
100    #[default]
101    NormedFfnInput,
102    /// `gate_inp · inpL` -- the residual stream as it enters the
103    /// layer, unnormed, before attention (`smallthinker.cpp:111`). The
104    /// experts still read the normed FFN input.
105    RawLayerInput,
106    /// `gate_inp · ffn_norm_exps(inpSA)` -- the residual stream as it
107    /// enters the layer, normed by `blk.N.ffn_norm_exps` (REQUIRED,
108    /// `arctic.cpp:45,136-139`). The routed experts read the SAME
109    /// vector; the layer's dense FFN reads `ffn_norm(ffn_inp)`.
110    NormedLayerInput,
111}
112
113impl RouterInput {
114    /// Whether the routed experts read the router's operand rather
115    /// than the normed FFN input: true for [`Self::NormedLayerInput`]
116    /// alone. `RawLayerInput`'s experts read `ffn_norm(ffn_inp)`
117    /// (`smallthinker.cpp:151`), as the default's do.
118    pub fn experts_read_router_operand(self) -> bool {
119        matches!(self, RouterInput::NormedLayerInput)
120    }
121
122    /// Whether the layer carries a `ffn_norm_exps` weight for the
123    /// operand.
124    pub fn needs_exps_norm(self) -> bool {
125        matches!(self, RouterInput::NormedLayerInput)
126    }
127}
128
129/// Which operand each architecture's router reads. The table behind
130/// the census above, restricted to the generic path; the two rows on
131/// other engines are documented there and not here, because nothing
132/// on those engines asks this question.
133pub const ROUTER_INPUT_TABLE: &[(&str, RouterInput, &str)] = &[
134    (
135        "smallthinker",
136        RouterInput::RawLayerInput,
137        "src/models/smallthinker.cpp:111,151-161",
138    ),
139    (
140        "arctic",
141        RouterInput::NormedLayerInput,
142        "src/models/arctic.cpp:45,135-152",
143    ),
144];
145
146/// The router operand for an architecture: the table's entry, or the
147/// default for every architecture the table does not name.
148pub fn router_input(arch: &str) -> RouterInput {
149    ROUTER_INPUT_TABLE
150        .iter()
151        .find(|(name, _, _)| *name == arch)
152        .map(|(_, input, _)| *input)
153        .unwrap_or_default()
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    /// The one row, and the default everywhere else -- including the
161    /// three other graphs that pass a precomputed `probs_in`, none of
162    /// which is this shape (`grovemoe`) or on this engine (`gemma4`,
163    /// `nemotron-h`).
164    #[test]
165    fn only_smallthinker_routes_on_the_raw_layer_input() {
166        assert_eq!(router_input("smallthinker"), RouterInput::RawLayerInput);
167        assert_eq!(router_input("arctic"), RouterInput::NormedLayerInput);
168        assert!(!RouterInput::RawLayerInput.experts_read_router_operand());
169        assert!(RouterInput::NormedLayerInput.experts_read_router_operand());
170        assert!(!RouterInput::NormedFfnInput.needs_exps_norm());
171        for arch in [
172            "llama",
173            "qwen3moe",
174            "olmoe",
175            "deepseek",
176            "grovemoe",
177            "gemma4",
178            "nemotron_h_moe",
179            "llama4",
180            "cohere2moe",
181        ] {
182            assert_eq!(router_input(arch), RouterInput::NormedFfnInput, "{arch}");
183        }
184        assert_eq!(RouterInput::default(), RouterInput::NormedFfnInput);
185    }
186
187    /// Every table row is an architecture the generic loader can
188    /// reach, so the seam it names is a seam something asks.
189    #[test]
190    fn every_table_row_is_on_the_generic_path() {
191        for (arch, _, line) in ROUTER_INPUT_TABLE {
192            let profile = crate::capability::resolve_profile(arch)
193                .unwrap_or_else(|| panic!("`{arch}` ({line}) is not a registered architecture"));
194            assert!(
195                matches!(profile.path, crate::capability::ArchPath::GenericGqa { .. }),
196                "`{arch}` ({line}) is {:?}, and only the generic decoder reads this table",
197                profile.path
198            );
199            assert!(
200                crate::capability::AUDITED_GENERIC_GQA.contains(arch),
201                "`{arch}` is served here and must be audited, or the seam is unevidenced"
202            );
203        }
204    }
205}