You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
group_mask_scores (src/models/switch_layers.rs:681) is the shared grouped-expert routing mask behind every DeepSeek-V3-style MoE gate. It takes n_group and topk_group as plain i32 and never checks either one against the other or against the score row:
let k = n_group - topk_group;let group_idx = mlxcel_core::argpartition(&group_scores, k - 1, -2);// [n, n_group, 1]let group_idx = slice_axis(&group_idx, -2,0, k);// [n, k, 1]let grouped = mlxcel_core::put_along_axis(&grouped,&group_idx,&zero, -2);
slice_axis (src/lib/mlxcel-core/src/utils.rs:43) implements Python slice semantics: end == -1 means "to the end of the axis", and any other negative end is taken relative to the axis length. A negative k therefore does not raise, it selects a different set of groups.
The silent case: topk_group == n_group + 1
k is -1. argpartition(kth = k - 1 = -2, axis = -2) is fine because MLX normalizes a negative kth (mlx/ops.cpp:2757 at the pinned commit b7c3dd6d, int kth_ = kth < 0 ? kth + a.shape(axis) : kth;), giving n_group - 2. Then slice_axis(group_idx, -2, 0, -1) selects the whole group axis, so group_idx is a full permutation of the group indices and put_along_axis writes zero into every group of the row.
What the model emits is not zero output. Every caller masks a selection copy of the scores and gathers the combine weights from the untouched orig_scores, so the damage lands entirely on selection: with an all-zero (hence all-equal) selection row, argpartition returns a tie-break order that has nothing to do with the router, so every token routes to the same arbitrary expert set, and the combine weights are the genuine unbiased sigmoid/softmax scores at those arbitrary experts, renormalized and scaled as usual. The output is finite, in range, and plausible. The router has simply been switched off. Nothing throws and nothing logs.
The neighboring regimes
n_group + 2 <= topk_group <= 2 * n_group - 1: k <= -2, slice_axis resolves end to n_group + k, so the wrong number of groups is zeroed. Also silent, also wrong routing.
topk_group >= 2 * n_group: the normalized kth goes below zero and MLX throws std::invalid_argument. fn argpartition is declared non-Result in the bridge (src/lib/mlxcel-core/src/lib.rs:924), so the exception is an uncatchable abort at the first forward pass rather than a load error.
topk_group == 0: k == n_group, so the slice takes the whole group axis and every group is zeroed. Same silent-degenerate-routing shape as the n_group + 1 case.
num_experts % n_group != 0 fails the same way one step earlier: experts_per_group is computed by truncating integer division, so reshape(scores, [n, n_group, n_experts / n_group]) throws on the size mismatch and aborts.
Caller inventory
Eight production call sites, not six. Only Bailing MoE validates:
Call site
Passes
Validation
src/models/bailing_moe.rs:1491
self.topk_group
validate_routing, at load
src/models/deepseek_v3.rs:542
self.topk_group (stored as args.topk_group as i32 at :592)
none
src/models/deepseek_v32.rs:613
self.topk_group as i32
none
src/models/glm4_moe.rs:778
self.topk_group as i32
none
src/models/glm4_moe_lite.rs:640
self.topk_group (as i32 at :720)
none
src/models/exaone_moe.rs:340
self.topk_group as i32
none
src/models/solar_open.rs:617
self.topk_group as i32
none
src/models/dots1.rs:379
self.topk_group as i32
partial: the :378 guard skips the mask when n_group == topk_group, no upper bound
All eight already gate the call on self.n_group > 1, so the n_group <= 1 regime is covered by every current caller. That is a call-site convention rather than a property of the function, which is the same shape of exposure the rest of this issue describes: it holds until a ninth caller forgets it.
validate_routing (src/models/bailing_moe.rs:583) is the model to generalize from. On the grouped branch it checks num_experts % n_group == 0 (the reshape precondition), experts_per_group >= 2 (group scoring sums each group's top two, so a one-expert group is meaningless), and 1 <= topk_group <= n_group - 1, and it runs at load so a bad config is a load error with a message rather than wrong tokens.
The function's own // Used by: comment lists six callers ("BailingMoe, DeepSeekV3, DeepSeekV32, GLM4Moe, GLM4MoeLite, ExaOneMoe") and is missing SolarOpen and Dots1. docs/code-guidelines.md makes that comment the discovery mechanism for "what breaks if I change this", so the stale list is part of the problem here and is very likely why the caller count was under-counted in the first place.
Both the function and the seven unvalidated call sites predate #946 / #838. The Bailing MoE work is only where this surfaced; nothing in that PR introduced it.
Proposed Solution
Put the bounds check inside group_mask_scores so all eight call sites are covered at once, instead of copying validate_routing's topk_group clause into seven more model files (and having to remember it for the ninth caller).
The function has an unmasked identity available, so it can be made total: when the mask is not well defined, return the scores unmasked rather than computing a negative k. That covers every case above:
n_group <= 1, or topk_group >= n_group: mask nothing. This is what the math already means (you cannot keep more groups than exist) and it matches the n_group != topk_group guard dots1.rs:378 already carries.
topk_group == 0: today k == n_group, which zeroes every group. Same silent-degenerate-routing shape, so it belongs in the same guard.
n_experts % n_group != 0 or n_experts / n_group < 2: mask nothing instead of letting reshape or argpartition throw across a non-Result bridge and abort the process.
So that an out-of-range config is not merely silently ignored, pair the guard with a one-time diagnostic (a warning naming the model, n_group, and topk_group) on the skip path. Model families that want a hard failure can keep or add a load-time check like validate_routing; the in-function guard is the floor that stops wrong output and aborts, not a replacement for load-time validation. Note that tracing::warn! is a no-op in the mlxcel CLI binary (see the implementation context below), so the diagnostic has to be eprintln! to be visible on the CLI path.
Acceptance Criteria
group_mask_scores validates n_group, topk_group, and the score row width before computing k, and never reaches slice_axis or argpartition with a negative k or a negative normalized kth.
An out-of-range topk_group can no longer produce a mask that zeroes every group, and can no longer reach an MLX exception through the non-Result bridge.
Regression test that fails on main: call group_mask_scores directly with n_group = 4, topk_group = 5 and assert the returned row still carries the input scores. On main that call returns an all-zero row.
Regression test for the abort regime: n_group = 4, topk_group = 8 returns a value instead of terminating the process.
Regression test for topk_group == 0.
In-range behavior is bit-identical: for every valid (n_group, topk_group) pair the masked output matches main exactly, including the existing group_mask_scores_keeps_the_groups_with_the_largest_top_two_sum and the n_group = 4, topk_group = 3 case in src/models/bailing_moe_tests.rs.
Existing Bailing MoE behavior is unchanged: validate_routing still rejects the same configs at load with the same messages, and cargo test --release --lib models::bailing_moe passes with no test modified.
Real-checkpoint smoke run on at least one MoE family whose gate calls this function (for example models/ling-lite-1.5 or models/solar-open-100b-4bit), token-exact against main. Deferred to the orchestrator: the only local checkpoint in these families is models/ling-lite-1.5 at 33.6 GB, outside this unit's budget. Every published checkpoint in the eight families is in range, so the guard is a no-op for them by construction.
The // Used by: comment above group_mask_scores lists all eight callers.
Technical Considerations
Upstream divergence, offered as context and not as a required change. Bailing MoE rejects topk_group == n_group, and its error text says argpartition(kth = n_group - topk_group - 1) "is out of range as soon as topk_group reaches n_group". That reasoning does not hold against the pinned MLX: mlx/ops.cpp:2757 normalizes a negative kth, so kth = -1 becomes n_group - 1, which is in range, and k == 0 then makes the slice empty so no group is masked. Upstream mlx-lm accepts topk_group == n_group for exactly that reason. If the guard proposed above skips the mask for topk_group >= n_group, relaxing Bailing's check from >= n_group to > n_group would be bit-exact, because topk_group == n_group masks zero groups either way. Whoever picks this up should decide whether to relax it here or leave it to a separate change; either way the error message's stated rationale needs correcting.
Hot path. The guard runs once per MoE layer per forward, on scalars already held in the gate struct, so it is arithmetic on i32s with no array work and no measurable cost. Do not add an eval or a device round-trip to implement it.
Scalar casts. Several callers reach the function through args.topk_group as i32 from a usize config field (deepseek_v3.rs:592, glm4_moe_lite.rs:720, and the inline casts in the table above). A usize above i32::MAX wraps to a negative i32 there, before group_mask_scores ever sees it. That makes k larger than n_group rather than negative, which zeroes every group via the end.min(dim_size) clamp and then hands argpartition a kth at or above n_group, so it lands in the abort regime. bailing_moe.rs:1568 already saturates with i32::try_from(args.topk_group).unwrap_or(i32::MAX); the in-function guard should be written so a negative topk_group also reaches the mask-nothing path.
Implementation context (machine facts, validation method, and a recurring defect class)
Most relevant to this issue: the recurring defect class, and the note that // Used by: comments go stale. One of the unguarded regimes here reaches argpartition with a negative normalized kth, which is exactly the "config value violates an undocumented MLX precondition and aborts uncatchably at the first forward pass" pattern described below. And the stale // Used by: comment listing six callers where there are eight is the same one referenced below; it is why this issue's own caller count was initially under-counted.
Collected while porting five text-model families in #924, #926, #928, #930, and #946. This
is background for whoever picks this up, not additional scope.
Build and test on the current development machine (Apple M1 Ultra, macOS)
Every cargo invocation needs DEVELOPER_DIR=/Applications/Xcode-26.6.0.app/Contents/Developer, or cmake fails with xcrun: error: unable to find utility "metal". xcode-select still points at CommandLineTools.
Only the RELEASE profile is warm. A debug cargo check triggers a COLD MLX C++ build that takes many minutes. Use DEVELOPER_DIR=... cargo check --release --lib --tests --features metal,accelerate.
Narrow test selectors only: DEVELOPER_DIR=... cargo test --release --lib --features metal,accelerate models::<module>. A bare cargo test, cargo test --lib, or cargo clippy --workspace --all-targets runs long enough to stall an agent with a stream-idle watchdog, and a cold cargo build --release can exceed it on the MLX C++ link step alone.
cargo fmt --all -- --check, never a bare-path fmt check, which produces spurious edition-2024 diffs.
tracing::warn! is a NO-OP in the mlxcel CLI binary; only src/server/startup.rs installs a subscriber. Use eprintln! for any CLI-facing diagnostic.
A test that trips an MLX C++ throw aborts the whole test binary with SIGABRT rather than failing cleanly. That is expected, and the abort is stronger evidence than a clean assertion failure. One SIGABRT on the first run after a fresh link that does not reproduce on reruns is a cold Metal-initialization race, observed independently by two reviewers.
Validating model behavior: a token-exact reference oracle
Shape tests do not catch the failures that matter here. A wrong prefill/decode offset, a flat instead of interleaved QKV split, the wrong RoPE convention, or a routing bias applied to the wrong copy all produce correctly-shaped tensors and fluent, plausible output. Only the token id sequence separates them.
What worked: create a scratch venv, pip install mlx-lm, and drive mlx_lm.generate.stream_generate with make_sampler(temp=0.0), printing both text and token ids. Then compare against mlxcel generate. Pass --no-chat-template to mlxcel even when the checkpoint ships a chat template, so both sides see the same raw prompt, and run the templated path separately as a usability check.
Caveat: the oracle is blind wherever mlxcel and mlx-lm make the same choice. GELU was one such case, closed by monkey-patching the reference to the other variant and regenerating. Note mlxcel_core::utils::gelu_approx is erf-based despite its name, while MLX Python's nn.gelu_approx really is the tanh form.
A recurring defect class worth checking for
A value from config.json passes every Rust-side check, violates an undocumented precondition of an MLX C++ entry point, and kills the process at the FIRST FORWARD PASS rather than failing at load. Most cxx bridge functions are declared returning UniquePtr<MlxArray> rather than Result, so the C++ throw is an uncatchable std::terminate, not something catch_unwind contains. The model loads cleanly and the server dies on its first request. This class produced one CRITICAL and five HIGH findings across the five ports.
Specifics established by reading the pinned MLX checkout rather than assuming:
Gathers do NOT range-check positive indices. take wraps negative indices but an out-of-range positive index silently returns values belonging to no row, and that reaches the logits with no fault. Bound every gather by the real tensor shape, never by a config field.
slice CLAMPS an out-of-range stop instead of throwing, so a too-wide split silently loses trailing channels.
fast_rope requires dims even, positive, and no larger than the last axis.
rms_norm and layer_norm never inspect eps. A NaN or negative eps yields NaN hidden states with no error at all, which is harder to diagnose than a crash.
matmul throws on an inner-dimension mismatch, which is why an unchecked INPUT axis is fatal even where the row axis bounds only an argmax.
quantized_matmul divides by bits, so "bits": 0 is a division by zero. On AArch64 the divide returns 0 and std::invalid_argument fires; on x86-64 the hardware raises SIGFPE and kills the process before any exception exists.
Reconstruct a quantized input width the way MLX does, as scales.shape(-1) * group_size, and check .biases shape equals .scales shape. A self-consistency check cannot catch a checkpoint honestly packed for the wrong hidden_size, because such a checkpoint IS self-consistent.
Put zero checks BEFORE divisibility checks, because 0.is_multiple_of(0) is true.
Avoid unbounded probe loops over 0..n_layer in the load path: a huge n_layer hangs with a flat allocation footprint, so no OOM kill rescues it.
src/models/gpt_neox.rs and src/models/helium.rs carry the current ModelArgs::validate / validate_weights shape to copy from.
Conventions that bite
The // Used by: comments above shared helpers are the designated discovery mechanism for "what breaks if I change this", and they can themselves be stale. One was found listing six callers where there were eight. Verify by grep rather than trusting the comment, and update it when you touch the helper.
mlxcel arch is the architecture registry. mlxcel list lists downloaded models and will not tell you whether a family is supported.
Problem / Background
group_mask_scores(src/models/switch_layers.rs:681) is the shared grouped-expert routing mask behind every DeepSeek-V3-style MoE gate. It takesn_groupandtopk_groupas plaini32and never checks either one against the other or against the score row:slice_axis(src/lib/mlxcel-core/src/utils.rs:43) implements Python slice semantics:end == -1means "to the end of the axis", and any other negativeendis taken relative to the axis length. A negativektherefore does not raise, it selects a different set of groups.The silent case:
topk_group == n_group + 1kis-1.argpartition(kth = k - 1 = -2, axis = -2)is fine because MLX normalizes a negativekth(mlx/ops.cpp:2757at the pinned commitb7c3dd6d,int kth_ = kth < 0 ? kth + a.shape(axis) : kth;), givingn_group - 2. Thenslice_axis(group_idx, -2, 0, -1)selects the whole group axis, sogroup_idxis a full permutation of the group indices andput_along_axiswrites zero into every group of the row.What the model emits is not zero output. Every caller masks a selection copy of the scores and gathers the combine weights from the untouched
orig_scores, so the damage lands entirely on selection: with an all-zero (hence all-equal) selection row,argpartitionreturns a tie-break order that has nothing to do with the router, so every token routes to the same arbitrary expert set, and the combine weights are the genuine unbiased sigmoid/softmax scores at those arbitrary experts, renormalized and scaled as usual. The output is finite, in range, and plausible. The router has simply been switched off. Nothing throws and nothing logs.The neighboring regimes
n_group + 2 <= topk_group <= 2 * n_group - 1:k <= -2,slice_axisresolvesendton_group + k, so the wrong number of groups is zeroed. Also silent, also wrong routing.topk_group >= 2 * n_group: the normalizedkthgoes below zero and MLX throwsstd::invalid_argument.fn argpartitionis declared non-Resultin the bridge (src/lib/mlxcel-core/src/lib.rs:924), so the exception is an uncatchable abort at the first forward pass rather than a load error.topk_group == 0:k == n_group, so the slice takes the whole group axis and every group is zeroed. Same silent-degenerate-routing shape as then_group + 1case.num_experts % n_group != 0fails the same way one step earlier:experts_per_groupis computed by truncating integer division, soreshape(scores, [n, n_group, n_experts / n_group])throws on the size mismatch and aborts.Caller inventory
Eight production call sites, not six. Only Bailing MoE validates:
src/models/bailing_moe.rs:1491self.topk_groupvalidate_routing, at loadsrc/models/deepseek_v3.rs:542self.topk_group(stored asargs.topk_group as i32at:592)src/models/deepseek_v32.rs:613self.topk_group as i32src/models/glm4_moe.rs:778self.topk_group as i32src/models/glm4_moe_lite.rs:640self.topk_group(as i32at:720)src/models/exaone_moe.rs:340self.topk_group as i32src/models/solar_open.rs:617self.topk_group as i32src/models/dots1.rs:379self.topk_group as i32:378guard skips the mask whenn_group == topk_group, no upper boundAll eight already gate the call on
self.n_group > 1, so then_group <= 1regime is covered by every current caller. That is a call-site convention rather than a property of the function, which is the same shape of exposure the rest of this issue describes: it holds until a ninth caller forgets it.validate_routing(src/models/bailing_moe.rs:583) is the model to generalize from. On the grouped branch it checksnum_experts % n_group == 0(the reshape precondition),experts_per_group >= 2(group scoring sums each group's top two, so a one-expert group is meaningless), and1 <= topk_group <= n_group - 1, and it runs at load so a bad config is a load error with a message rather than wrong tokens.The function's own
// Used by:comment lists six callers ("BailingMoe, DeepSeekV3, DeepSeekV32, GLM4Moe, GLM4MoeLite, ExaOneMoe") and is missing SolarOpen and Dots1.docs/code-guidelines.mdmakes that comment the discovery mechanism for "what breaks if I change this", so the stale list is part of the problem here and is very likely why the caller count was under-counted in the first place.Both the function and the seven unvalidated call sites predate #946 / #838. The Bailing MoE work is only where this surfaced; nothing in that PR introduced it.
Proposed Solution
Put the bounds check inside
group_mask_scoresso all eight call sites are covered at once, instead of copyingvalidate_routing'stopk_groupclause into seven more model files (and having to remember it for the ninth caller).The function has an unmasked identity available, so it can be made total: when the mask is not well defined, return the scores unmasked rather than computing a negative
k. That covers every case above:n_group <= 1, ortopk_group >= n_group: mask nothing. This is what the math already means (you cannot keep more groups than exist) and it matches then_group != topk_groupguarddots1.rs:378already carries.topk_group == 0: todayk == n_group, which zeroes every group. Same silent-degenerate-routing shape, so it belongs in the same guard.n_experts % n_group != 0orn_experts / n_group < 2: mask nothing instead of lettingreshapeorargpartitionthrow across a non-Resultbridge and abort the process.So that an out-of-range config is not merely silently ignored, pair the guard with a one-time diagnostic (a warning naming the model,
n_group, andtopk_group) on the skip path. Model families that want a hard failure can keep or add a load-time check likevalidate_routing; the in-function guard is the floor that stops wrong output and aborts, not a replacement for load-time validation. Note thattracing::warn!is a no-op in themlxcelCLI binary (see the implementation context below), so the diagnostic has to beeprintln!to be visible on the CLI path.Acceptance Criteria
group_mask_scoresvalidatesn_group,topk_group, and the score row width before computingk, and never reachesslice_axisorargpartitionwith a negativekor a negative normalizedkth.topk_groupcan no longer produce a mask that zeroes every group, and can no longer reach an MLX exception through the non-Resultbridge.main: callgroup_mask_scoresdirectly withn_group = 4,topk_group = 5and assert the returned row still carries the input scores. Onmainthat call returns an all-zero row.n_group = 4,topk_group = 8returns a value instead of terminating the process.topk_group == 0.(n_group, topk_group)pair the masked output matchesmainexactly, including the existinggroup_mask_scores_keeps_the_groups_with_the_largest_top_two_sumand then_group = 4, topk_group = 3case insrc/models/bailing_moe_tests.rs.validate_routingstill rejects the same configs at load with the same messages, andcargo test --release --lib models::bailing_moepasses with no test modified.models/ling-lite-1.5ormodels/solar-open-100b-4bit), token-exact againstmain. Deferred to the orchestrator: the only local checkpoint in these families ismodels/ling-lite-1.5at 33.6 GB, outside this unit's budget. Every published checkpoint in the eight families is in range, so the guard is a no-op for them by construction.// Used by:comment abovegroup_mask_scoreslists all eight callers.Technical Considerations
Upstream divergence, offered as context and not as a required change. Bailing MoE rejects
topk_group == n_group, and its error text saysargpartition(kth = n_group - topk_group - 1)"is out of range as soon as topk_group reaches n_group". That reasoning does not hold against the pinned MLX:mlx/ops.cpp:2757normalizes a negativekth, sokth = -1becomesn_group - 1, which is in range, andk == 0then makes the slice empty so no group is masked. Upstream mlx-lm acceptstopk_group == n_groupfor exactly that reason. If the guard proposed above skips the mask fortopk_group >= n_group, relaxing Bailing's check from>= n_groupto> n_groupwould be bit-exact, becausetopk_group == n_groupmasks zero groups either way. Whoever picks this up should decide whether to relax it here or leave it to a separate change; either way the error message's stated rationale needs correcting.Hot path. The guard runs once per MoE layer per forward, on scalars already held in the gate struct, so it is arithmetic on
i32s with no array work and no measurable cost. Do not add anevalor a device round-trip to implement it.Scalar casts. Several callers reach the function through
args.topk_group as i32from ausizeconfig field (deepseek_v3.rs:592,glm4_moe_lite.rs:720, and the inline casts in the table above). Ausizeabovei32::MAXwraps to a negativei32there, beforegroup_mask_scoresever sees it. That makesklarger thann_grouprather than negative, which zeroes every group via theend.min(dim_size)clamp and then handsargpartitionakthat or aboven_group, so it lands in the abort regime.bailing_moe.rs:1568already saturates withi32::try_from(args.topk_group).unwrap_or(i32::MAX); the in-function guard should be written so a negativetopk_groupalso reaches the mask-nothing path.Implementation context (machine facts, validation method, and a recurring defect class)
Most relevant to this issue: the recurring defect class, and the note that
// Used by:comments go stale. One of the unguarded regimes here reachesargpartitionwith a negative normalizedkth, which is exactly the "config value violates an undocumented MLX precondition and aborts uncatchably at the first forward pass" pattern described below. And the stale// Used by:comment listing six callers where there are eight is the same one referenced below; it is why this issue's own caller count was initially under-counted.Collected while porting five text-model families in #924, #926, #928, #930, and #946. This
is background for whoever picks this up, not additional scope.
Build and test on the current development machine (Apple M1 Ultra, macOS)
DEVELOPER_DIR=/Applications/Xcode-26.6.0.app/Contents/Developer, or cmake fails withxcrun: error: unable to find utility "metal".xcode-selectstill points at CommandLineTools.cargo checktriggers a COLD MLX C++ build that takes many minutes. UseDEVELOPER_DIR=... cargo check --release --lib --tests --features metal,accelerate.DEVELOPER_DIR=... cargo test --release --lib --features metal,accelerate models::<module>. A barecargo test,cargo test --lib, orcargo clippy --workspace --all-targetsruns long enough to stall an agent with a stream-idle watchdog, and a coldcargo build --releasecan exceed it on the MLX C++ link step alone.cargo fmt --all -- --check, never a bare-path fmt check, which produces spurious edition-2024 diffs.cargo testat all (onlychanges,cargo-deny,cargo-fmt, and the cross-repo-ref guard). Local runs plus a real checkpoint are the only gate. Two deterministically failing tests were found sitting onmainduring this series for exactly that reason: see fix(mllama): ragged cross-attention test fails on main, and no CI job runs cargo test #939, and theFAMILY_ORDERentries fixed in feat(models): add Ant Group Ling / Bailing MoE (bailing_moe) text model support #946.tracing::warn!is a NO-OP in themlxcelCLI binary; onlysrc/server/startup.rsinstalls a subscriber. Useeprintln!for any CLI-facing diagnostic.Validating model behavior: a token-exact reference oracle
Shape tests do not catch the failures that matter here. A wrong prefill/decode offset, a flat instead of interleaved QKV split, the wrong RoPE convention, or a routing bias applied to the wrong copy all produce correctly-shaped tensors and fluent, plausible output. Only the token id sequence separates them.
What worked: create a scratch venv,
pip install mlx-lm, and drivemlx_lm.generate.stream_generatewithmake_sampler(temp=0.0), printing both text and token ids. Then compare againstmlxcel generate. Pass--no-chat-templateto mlxcel even when the checkpoint ships a chat template, so both sides see the same raw prompt, and run the templated path separately as a usability check.Caveat: the oracle is blind wherever mlxcel and mlx-lm make the same choice. GELU was one such case, closed by monkey-patching the reference to the other variant and regenerating. Note
mlxcel_core::utils::gelu_approxis erf-based despite its name, while MLX Python'snn.gelu_approxreally is the tanh form.A recurring defect class worth checking for
A value from
config.jsonpasses every Rust-side check, violates an undocumented precondition of an MLX C++ entry point, and kills the process at the FIRST FORWARD PASS rather than failing at load. Most cxx bridge functions are declared returningUniquePtr<MlxArray>rather thanResult, so the C++ throw is an uncatchablestd::terminate, not somethingcatch_unwindcontains. The model loads cleanly and the server dies on its first request. This class produced one CRITICAL and five HIGH findings across the five ports.Specifics established by reading the pinned MLX checkout rather than assuming:
takewraps negative indices but an out-of-range positive index silently returns values belonging to no row, and that reaches the logits with no fault. Bound every gather by the real tensor shape, never by a config field.sliceCLAMPS an out-of-range stop instead of throwing, so a too-wide split silently loses trailing channels.fast_roperequiresdimseven, positive, and no larger than the last axis.rms_normandlayer_normnever inspecteps. A NaN or negative eps yields NaN hidden states with no error at all, which is harder to diagnose than a crash.matmulthrows on an inner-dimension mismatch, which is why an unchecked INPUT axis is fatal even where the row axis bounds only an argmax.quantized_matmuldivides bybits, so"bits": 0is a division by zero. On AArch64 the divide returns 0 andstd::invalid_argumentfires; on x86-64 the hardware raisesSIGFPEand kills the process before any exception exists.scales.shape(-1) * group_size, and check.biasesshape equals.scalesshape. A self-consistency check cannot catch a checkpoint honestly packed for the wronghidden_size, because such a checkpoint IS self-consistent.0.is_multiple_of(0)is true.0..n_layerin the load path: a hugen_layerhangs with a flat allocation footprint, so no OOM kill rescues it.src/models/gpt_neox.rsandsrc/models/helium.rscarry the currentModelArgs::validate/validate_weightsshape to copy from.Conventions that bite
// Used by:comments above shared helpers are the designated discovery mechanism for "what breaks if I change this", and they can themselves be stale. One was found listing six callers where there were eight. Verify by grep rather than trusting the comment, and update it when you touch the helper.mlxcel archis the architecture registry.mlxcel listlists downloaded models and will not tell you whether a family is supported.