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}