Skip to main content

ferrox_models/
minimax_engine.rs

1//! MiniMax M2 / M3 refusal, and why the reason it used to give was the
2//! wrong one.
3//!
4//! This module used to say MiniMax "needs a loader, 256-expert sigmoid
5//! MoE routing and MTP draft heads, none of which exist yet". Checked
6//! against llama.cpp, two of those three clauses are false and the third
7//! is only true of one of the two architectures:
8//!
9//! - **MTP does not exist in either model.** Neither
10//!   `.scratch/llama.cpp/src/models/minimax-m2.cpp` nor `minimax-m3.cpp`
11//!   creates a single `nextn.*` tensor, and `gguf-py`'s
12//!   `MODEL_ARCH.MINIMAXM2` / `.MINIMAXM3` tensor lists contain no
13//!   `NEXTN_*` entry, so no converter can emit MTP weights for these
14//!   files. `minimax-m3.cpp:9` states it: "MTP is not in released model
15//!   weights." A refusal naming a tensor family the checkpoint cannot
16//!   contain is unreachable by construction — glm4moe's `q_lora_rank`
17//!   defect in a different costume.
18//! - **Sigmoid MoE routing already exists here.** `loader.rs` reads
19//!   `{arch}.expert_gating_func` into `GatingFunction::Sigmoid`, loads
20//!   `blk.N.exp_probs_b.bias`, and reads `expert_weights_scale` and
21//!   `expert_weights_norm`. `ferrox_moe::route_top_k_sigmoid` is the
22//!   routing DeepSeek-V3 and GLM-4-MoE use. "256 experts" is an hparam,
23//!   not a ceiling.
24//! - **Block-sparse attention is M3's, not MiniMax's.** `minimax-m2.cpp`
25//!   builds ordinary dense GQA attention (:112). The ported selection in
26//!   [`ferrox_core::block_sparse`] is relevant only to M3's MSA, and is
27//!   the smallest piece of it.
28//!
29//! What is actually true, per architecture, is in [`crate::capability`]
30//! — and that is deliberately the ONLY copy. The live refusal a user
31//! hits is `LoadError::DedicatedArchitectureRequired`, built from
32//! `ArchPath::DedicatedOnly { reason }` in `loader.rs`; this module's
33//! [`MinimaxEngine::reject`] used to carry a second, longer, *different*
34//! reason that nothing ever printed. Two copies of one explanation is
35//! how they came to disagree, so `reject` now reads the catalog's string
36//! rather than repeating it.
37//!
38//! In short: `minimax-m2` is UNAUDITED (a fixture or a parity run would
39//! settle it — see `tests/minimax_refusal.rs`), while `minimax-m3` is
40//! genuinely UNIMPLEMENTED (the MSA indexer and its own KV cache).
41
42use thiserror::Error;
43
44#[derive(Debug, Error)]
45#[error("MiniMax dedicated engine not implemented: {reason}")]
46pub struct MinimaxUnavailable {
47    pub reason: &'static str,
48}
49
50pub struct MinimaxEngine {
51    pub arch: String,
52}
53
54impl MinimaxEngine {
55    /// Refuses, with the SAME reason the loader gives for `arch`.
56    ///
57    /// Deliberately delegating: the catalog owns the per-architecture
58    /// reason, so this cannot drift away from what a user actually sees.
59    pub fn reject(arch: &str) -> Result<(), MinimaxUnavailable> {
60        let reason = match crate::capability::resolve_architecture(arch) {
61            Some(crate::capability::ArchPath::DedicatedOnly { reason }) => reason,
62            // Any other path for a name routed here is itself the bug:
63            // say so rather than inventing an architecture reason.
64            _ => {
65                return Err(MinimaxUnavailable {
66                    reason: "not a MiniMax architecture: expected `minimax-m2` or `minimax-m3`, \
67                             which the capability catalog marks DedicatedOnly",
68                });
69            }
70        };
71        Err(MinimaxUnavailable { reason })
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn reject_is_fail_closed() {
81        assert!(MinimaxEngine::reject("minimax-m2").is_err());
82        assert!(MinimaxEngine::reject("minimax-m3").is_err());
83    }
84
85    /// The defect this module was fixed for: the engine's reason and the
86    /// loader's reason were two different strings, and only the loader's
87    /// was ever shown.
88    #[test]
89    fn reject_repeats_the_catalog_reason_verbatim() {
90        for arch in ["minimax-m2", "minimax-m3"] {
91            let Some(crate::capability::ArchPath::DedicatedOnly { reason }) =
92                crate::capability::resolve_architecture(arch)
93            else {
94                panic!("{arch} must be DedicatedOnly");
95            };
96            let err = MinimaxEngine::reject(arch).unwrap_err();
97            assert_eq!(
98                err.reason, reason,
99                "{arch}: the engine must not carry a second, different reason"
100            );
101        }
102    }
103
104    /// Neither reason may claim MTP again: no MiniMax GGUF can carry
105    /// `nextn.*` tensors, because `gguf-py`'s MINIMAXM2/MINIMAXM3 tensor
106    /// lists have no NEXTN entry and neither `minimax-m*.cpp` creates
107    /// one.
108    #[test]
109    fn no_minimax_reason_blames_mtp() {
110        for arch in ["minimax-m2", "minimax-m3"] {
111            let err = MinimaxEngine::reject(arch).unwrap_err();
112            let lower = err.reason.to_ascii_lowercase();
113            assert!(
114                !lower.contains("mtp") && !lower.contains("nextn"),
115                "{arch} may not be refused for MTP it cannot have: {}",
116                err.reason
117            );
118        }
119    }
120
121    #[test]
122    fn a_non_minimax_name_is_named_as_such() {
123        let err = MinimaxEngine::reject("llama").unwrap_err();
124        assert!(err.reason.contains("not a MiniMax architecture"));
125    }
126}