Paris99's picture
SenseNova-Loop-9B-Preview (9937e91) MLX 6-bit: weights, MLX model code, card, Apache-2.0 LICENSE and NOTICE
1b03326 verified
Raw History Blame Contribute Delete
11.4 kB
# Copyright © 2026 NetProjects (Paris Michalas). Apache-2.0.
# MLX port of SenseNova-Loop (sensenova/SenseNova-Loop-9B-Preview, modeling_sensenova_loop.py @ 9937e91).
# Not affiliated with SenseTime / SenseNova.
#
# SenseNova-Loop is Qwen3.5 (text) with two changes:
# 1. Looped execution: physical layers [start, end) run `eval_count` times with shared weights. Every execution
# ("visit") has its own cache slot (KV for full attention, conv + recurrent state for Gated DeltaNet).
# 2. Two-stream mHC (manifold-constrained hyper-connections) on layers [mhc.start, mhc.end): the hidden state is
# copied into `streams` residual streams at the first mHC layer and averaged back at the last one, on every pass.
# Each mHC layer has two fp32 routers (mhc_attn, mhc_ffn) that give pre / post gates and a Sinkhorn mixing matrix.
#
# Works as mlx_lm/models/sensenova_loop.py (model_type "sensenova_loop") or as a custom model_file with
# trust_remote_code=True.
import os
from dataclasses import dataclass, field
from functools import partial
from typing import Any, Dict, List, Optional, Tuple
import mlx.core as mx
import mlx.nn as nn
try:
from . import qwen3_5 as q35
from .base import BaseModelArgs, create_attention_mask, create_ssm_mask
from .cache import ArraysCache, KVCache
except ImportError: # loaded as a model_file
from mlx_lm.models import qwen3_5 as q35
from mlx_lm.models.base import BaseModelArgs, create_attention_mask, create_ssm_mask
from mlx_lm.models.cache import ArraysCache, KVCache
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
text_config: dict
loop_settings: dict = field(default_factory=dict)
mhc: dict = field(default_factory=dict)
def execution_plan(num_layers: int, loop: dict, mhc: dict) -> Tuple[int, ...]:
if not loop:
return tuple(range(num_layers))
start, end = int(loop["start"]), int(loop["end"])
count = int(loop.get("eval_count", (loop.get("counts") or [1])[0]))
if "counts" in loop and list(loop["counts"]) != [count]:
raise ValueError(f"loop_settings.counts {loop['counts']} must be [{count}]")
if not (0 <= start < end <= num_layers) or count < 1:
raise ValueError(f"bad loop_settings {loop}")
if mhc:
ms, me = int(mhc["start"]), int(mhc["end"])
inside = (start <= ms < me <= end) or (me <= start) or (ms >= end)
if not (0 <= ms < me <= num_layers) or not inside:
raise ValueError(f"mhc range [{ms}, {me}) must sit inside one loop phase")
return tuple(range(start)) + tuple(range(start, end)) * count + tuple(range(end, num_layers))
def sinkhorn(logits: mx.array, iterations: int) -> mx.array:
"""Log-space Sinkhorn: rows then columns, `iterations` times, then exp (fp32)."""
logits = logits.astype(mx.float32)
for _ in range(iterations):
logits = logits - mx.logsumexp(logits, axis=-1, keepdims=True)
logits = logits - mx.logsumexp(logits, axis=-2, keepdims=True)
return mx.exp(logits)
class MHCRouter(nn.Module):
"""fp32 router. Kept as raw parameters (not nn.Linear) so convert never quantizes it."""
def __init__(self, dim: int, streams: int, iterations: int, fixed_identity: bool, pre_scale: float):
super().__init__()
self.streams = streams
self.iterations = iterations
self.fixed_identity = fixed_identity
self.pre_scale = pre_scale
out = 2 * streams + (0 if fixed_identity else streams * streams)
self.weight = mx.zeros((out, streams * dim), dtype=mx.float32)
self.bias = mx.zeros((out,), dtype=mx.float32)
def __call__(self, residual: mx.array):
# residual: (..., streams, dim) in the model dtype
fn = _route_fn(self.streams, self.iterations, self.fixed_identity, self.pre_scale)
branch, post, mix = fn(residual, self.weight, self.bias)
return branch, post, (None if self.fixed_identity else mix)
@staticmethod
def merge(residual: mx.array, branch: mx.array, post: mx.array, mix: Optional[mx.array]) -> mx.array:
if mix is None:
return residual + post.astype(branch.dtype)[..., None] * branch[..., None, :]
return _merge_fn()(residual, branch, post, mix)
def _route(residual, weight, bias, s, iterations, fixed_identity, pre_scale):
flat = residual.reshape(*residual.shape[:-2], -1).astype(mx.float32)
normalized = flat * mx.rsqrt(mx.mean(mx.square(flat), axis=-1, keepdims=True) + 1e-6)
logits = normalized @ weight.astype(mx.float32).T + bias.astype(mx.float32)
pre = mx.sigmoid(logits[..., :s])
post = 2 * mx.sigmoid(logits[..., s : 2 * s])
if fixed_identity:
mix = mx.zeros((1,), dtype=mx.float32)
else:
mix = sinkhorn(logits[..., 2 * s :].reshape(*logits.shape[:-1], s, s), iterations)
branch = (pre.astype(residual.dtype)[..., None, :] @ residual).squeeze(-2)
if pre_scale != 1.0:
branch = branch * pre_scale
return branch, post, mix
def _merge(residual, branch, post, mix):
return mix.astype(residual.dtype) @ residual + post.astype(branch.dtype)[..., None] * branch[..., None, :]
# The router is ~45 tiny ops (20 Sinkhorn iterations) and runs 96 times per token, so it is compiled into one kernel
# graph; SN_NO_COMPILE=1 runs it eagerly (same math).
_COMPILE = os.environ.get("SN_NO_COMPILE", "0") != "1"
_ROUTE_CACHE: Dict[tuple, Any] = {}
def _route_fn(s, iterations, fixed_identity, pre_scale):
key = (s, iterations, fixed_identity, pre_scale)
if key not in _ROUTE_CACHE:
f = partial(_route, s=s, iterations=iterations, fixed_identity=fixed_identity, pre_scale=pre_scale)
_ROUTE_CACHE[key] = mx.compile(f) if _COMPILE else f
return _ROUTE_CACHE[key]
_MERGE = []
def _merge_fn():
if not _MERGE:
_MERGE.append(mx.compile(_merge) if _COMPILE else _merge)
return _MERGE[0]
class MHCDecoderLayer(q35.DecoderLayer):
def __init__(self, args: q35.TextModelArgs, layer_idx: int, mhc: dict):
super().__init__(args, layer_idx)
self.mhc_start, self.mhc_end = int(mhc["start"]), int(mhc["end"])
self.layer_idx = layer_idx
self.streams = int(mhc["streams"])
kw = dict(
dim=args.hidden_size,
streams=self.streams,
iterations=int(mhc["iterations"]),
fixed_identity=bool(mhc.get("fixed_identity", False)),
pre_scale=float(mhc.get("pre_scale", 1.0)),
)
self.mhc_attn = MHCRouter(**kw)
self.mhc_ffn = MHCRouter(**kw)
def __call__(self, x: mx.array, mask=None, cache=None) -> mx.array:
# enter: copy into streams at the first mHC layer, otherwise x already carries (B, L, streams, D)
if self.layer_idx == self.mhc_start:
residual = mx.broadcast_to(x[..., None, :], (*x.shape[:-1], self.streams, x.shape[-1]))
else:
residual = x
branch, post, mix = self.mhc_attn(residual)
branch = self.input_layernorm(branch)
if self.is_linear:
branch = self.linear_attn(branch, mask, cache)
else:
branch = self.self_attn(branch, mask, cache)
residual = MHCRouter.merge(residual, branch, post, mix)
branch, post, mix = self.mhc_ffn(residual)
branch = self.mlp(self.post_attention_layernorm(branch))
out = MHCRouter.merge(residual, branch, post, mix)
# leave: average the streams after the last mHC layer
return out.mean(axis=-2) if self.layer_idx == self.mhc_end - 1 else out
class SenseNovaLoopTextModel(nn.Module):
def __init__(self, args: q35.TextModelArgs, loop: dict, mhc: dict):
super().__init__()
self.args = args
self.embed_tokens = nn.Embedding(args.vocab_size, args.hidden_size)
ms, me = (int(mhc["start"]), int(mhc["end"])) if mhc else (0, 0)
self.layers = [
MHCDecoderLayer(args, i, mhc) if ms <= i < me else q35.DecoderLayer(args, i)
for i in range(args.num_hidden_layers)
]
self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.plan = execution_plan(args.num_hidden_layers, loop, mhc)
self.fa_visit = next((e for e, p in enumerate(self.plan) if not self.layers[p].is_linear), None)
self.ssm_visit = next((e for e, p in enumerate(self.plan) if self.layers[p].is_linear), None)
def __call__(self, inputs: mx.array, cache=None, input_embeddings: Optional[mx.array] = None) -> mx.array:
h = input_embeddings if input_embeddings is not None else self.embed_tokens(inputs)
if cache is None:
cache = [None] * len(self.plan)
if len(cache) != len(self.plan):
raise ValueError(f"cache has {len(cache)} slots, the execution plan needs {len(self.plan)}")
fa_mask = create_attention_mask(h, cache[self.fa_visit]) if self.fa_visit is not None else None
ssm_mask = create_ssm_mask(h, cache[self.ssm_visit]) if self.ssm_visit is not None else None
for physical, c in zip(self.plan, cache):
layer = self.layers[physical]
h = layer(h, mask=ssm_mask if layer.is_linear else fa_mask, cache=c)
return self.norm(h)
class TextModel(q35.TextModel):
def __init__(self, args: q35.TextModelArgs, loop: dict, mhc: dict):
nn.Module.__init__(self)
self.args = args
self.model_type = args.model_type
self.model = SenseNovaLoopTextModel(args, loop, mhc)
if not args.tie_word_embeddings:
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
self.sharding_group = None
@property
def layers(self):
return self.model.layers
def make_cache(self):
return [ArraysCache(size=2) if self.model.layers[p].is_linear else KVCache() for p in self.model.plan]
@property
def cast_predicate(self):
def predicate(path: str):
return not (path.endswith("A_log") or ".mhc_attn." in path or ".mhc_ffn." in path)
return predicate
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.language_model = TextModel(q35.TextModelArgs.from_dict(args.text_config), args.loop_settings, args.mhc)
def __call__(self, inputs: mx.array, cache=None, input_embeddings: Optional[mx.array] = None):
return self.language_model(inputs, cache=cache, input_embeddings=input_embeddings)
@property
def model(self):
return self.language_model.model
def sanitize(self, weights):
# q35: drops model.visual.* and mtp.*, renames model.language_model -> language_model.model, conv1d layout,
# +1 on norms for raw HF checkpoints. Routers stay fp32 (the reference refuses to run them in bf16).
weights = q35.Model.sanitize(self, weights)
return {k: (v.astype(mx.float32) if (".mhc_attn." in k or ".mhc_ffn." in k) else v) for k, v in weights.items()}
@property
def layers(self):
return self.language_model.model.layers
def make_cache(self):
return self.language_model.make_cache()
@property
def quant_predicate(self):
return None
@property
def cast_predicate(self):
return self.language_model.cast_predicate