Text Generation
MLX
Safetensors
sensenova_loop
sensenova
sensenova-loop
looped-transformer
mhc
qwen3_5
apple-silicon
conversational
6-bit
Instructions to use netprojects/SenseNova-Loop-9B-Preview-MLX-6bit with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- MLX
How to use netprojects/SenseNova-Loop-9B-Preview-MLX-6bit with MLX:
# Make sure mlx-lm is installed # pip install --upgrade mlx-lm # Generate text with mlx-lm from mlx_lm import load, generate model, tokenizer = load("netprojects/SenseNova-Loop-9B-Preview-MLX-6bit") prompt = "Write a story about Einstein" messages = [{"role": "user", "content": prompt}] prompt = tokenizer.apply_chat_template( messages, add_generation_prompt=True ) text = generate(model, tokenizer, prompt=prompt, verbose=True) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- LM Studio
- Pi
How to use netprojects/SenseNova-Loop-9B-Preview-MLX-6bit with Pi:
Start the MLX server
# Install MLX LM: uv tool install mlx-lm # Start a local OpenAI-compatible server: mlx_lm.server --model "netprojects/SenseNova-Loop-9B-Preview-MLX-6bit"
Configure the model in Pi
# Install Pi: npm install -g @earendil-works/pi-coding-agent # Add to ~/.pi/agent/models.json: { "providers": { "mlx-lm": { "baseUrl": "http://localhost:8080/v1", "api": "openai-completions", "apiKey": "none", "models": [ { "id": "netprojects/SenseNova-Loop-9B-Preview-MLX-6bit" } ] } } }Run Pi
# Start Pi in your project directory: pi
- MLX LM
How to use netprojects/SenseNova-Loop-9B-Preview-MLX-6bit with MLX LM:
Generate or start a chat session
# Install MLX LM uv tool install mlx-lm # Interactive chat REPL mlx_lm.chat --model "netprojects/SenseNova-Loop-9B-Preview-MLX-6bit"
Run an OpenAI-compatible server
# Install MLX LM uv tool install mlx-lm # Start the server mlx_lm.server --model "netprojects/SenseNova-Loop-9B-Preview-MLX-6bit" # Calling the OpenAI-compatible server with curl curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "netprojects/SenseNova-Loop-9B-Preview-MLX-6bit", "messages": [ {"role": "user", "content": "Hello"} ] }' - Hermes Agent
How to use netprojects/SenseNova-Loop-9B-Preview-MLX-6bit with Hermes Agent:
Start the MLX server
# Install MLX LM: uv tool install mlx-lm # Start a local OpenAI-compatible server: mlx_lm.server --model "netprojects/SenseNova-Loop-9B-Preview-MLX-6bit"
Configure Hermes
# Install Hermes: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash hermes setup # Point Hermes at the local server: hermes config set model.provider custom hermes config set model.base_url http://127.0.0.1:8080/v1 hermes config set model.default netprojects/SenseNova-Loop-9B-Preview-MLX-6bit
Run Hermes
hermes
- Atomic Chat
- OpenClaw
How to use netprojects/SenseNova-Loop-9B-Preview-MLX-6bit with OpenClaw:
Start the MLX server
# Install MLX LM: uv tool install mlx-lm # Start a local OpenAI-compatible server: mlx_lm.server --model "netprojects/SenseNova-Loop-9B-Preview-MLX-6bit"
Configure OpenClaw
# Install OpenClaw: npm install -g openclaw@latest # Register the local server and set it as the default model: openclaw onboard --non-interactive --mode local \ --auth-choice custom-api-key \ --custom-base-url http://127.0.0.1:8080/v1 \ --custom-model-id "netprojects/SenseNova-Loop-9B-Preview-MLX-6bit" \ --custom-provider-id mlx-lm \ --custom-compatibility openai \ --custom-text-input \ --accept-risk \ --skip-health
Run OpenClaw
openclaw agent --local --agent main --message "Hello from Hugging Face"
Download sensenova_loop.py from netprojects/SenseNova-Loop-9B-Preview-MLX-6bit: direct link, hf CLI and curl.
- Browser
- Download file 11.4 kB
-
https://huggingface.co/netprojects/SenseNova-Loop-9B-Preview-MLX-6bit/resolve/main/sensenova_loop.py
- Command line
-
hf download hf://netprojects/SenseNova-Loop-9B-Preview-MLX-6bit/sensenova_loop.py
-
curl -L -o sensenova_loop.py https://huggingface.co/netprojects/SenseNova-Loop-9B-Preview-MLX-6bit/resolve/main/sensenova_loop.py
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 | |
| 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) | |
| 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 | |
| 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] | |
| 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) | |
| 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()} | |
| def layers(self): | |
| return self.language_model.model.layers | |
| def make_cache(self): | |
| return self.language_model.make_cache() | |
| def quant_predicate(self): | |
| return None | |
| def cast_predicate(self): | |
| return self.language_model.cast_predicate | |