diff --git a/atlas/cli/commands/model_registry.py b/atlas/cli/commands/model_registry.py index 348a6350..8c9e2930 100644 --- a/atlas/cli/commands/model_registry.py +++ b/atlas/cli/commands/model_registry.py @@ -170,6 +170,13 @@ def _unsloth_qwen35_url(repo: str, file: str) -> str: f"{_UNSLOTH_QWEN35_COMMIT}/{file}") +_UNSLOTH_QWEN36_COMMIT = "5cb35eb3dcbf52dbce5f87dbc64df6aaffadcace" + +def _unsloth_qwen36_url(file: str) -> str: + return (f"https://huggingface.co/unsloth/Qwen3.6-27B-MTP-GGUF/resolve/" + f"{_UNSLOTH_QWEN36_COMMIT}/{file}") + + # Single source of truth. Order: by tier (cpu → xlarge), then by quant. # # Truthful state today: @@ -333,6 +340,34 @@ def _unsloth_qwen35_url(repo: str, file: str) -> str: "llama.cpp model only — G(x) verification will silently " "no-op (--no-lens to acknowledge). See PC-058 roadmap.", ), + Model( + name="Qwen3.6-27B-MTP-UD-Q4_K_XL", + tier="xlarge", + model_file="Qwen3.6-27B-UD-Q4_K_XL.gguf", + model_display="Qwen3.6 27B MTP (UD-Q4_K_XL)", + model_size_gb=16.7, + lens_status="no-artifacts", + download_url=_unsloth_qwen36_url("Qwen3.6-27B-UD-Q4_K_XL.gguf"), + sha256="4085665ee36d82a672a238a43f0e5643f2f0e39f2d7bd5d373f0ef10ecf53095", + license="Apache-2.0", + requires_hf_token=False, + asa_status="supported", + asa_artifact_files=["ast_edit_steering.gguf"], + asa_hf_repo="yogthos/atlas-asa-qwen3.6-27b-mtp-ud-q4_k_xl", + notes="Qwen3.6-27B with Multi-Token Prediction. Gated DeltaNet + " + "Gated Attention hybrid architecture, 5120-dim hidden. " + "MTP supported in llama.cpp since PR #22673. Requires " + "--spec-type draft-mtp --spec-draft-n-max 2 flags. " + "ASA control vector (ast_edit_steering.gguf, layer 27, " + "5120-dim) published at " + "https://huggingface.co/yogthos/atlas-asa-qwen3.6-27b-mtp-ud-q4_k_xl. " + "Lens C(x) cost_field.pt trained locally (5120-dim, " + "200-epoch contrastive) but not yet published on HF, and " + "the G(x) classifier half (gx_*.json) is untrained — G(x) " + "will silently no-op until both lens halves ship via " + "`atlas lens publish`. macOS Metal: MTP disabled by default " + "(ATLAS_ENABLE_MTP=0); see llama.cpp #23011/#23752.", + ), Model( name="gemma-4-12b-it-Q4_K_M", tier="medium", diff --git a/docs/models/qwen3.6.md b/docs/models/qwen3.6.md new file mode 100644 index 00000000..8cc699a5 --- /dev/null +++ b/docs/models/qwen3.6.md @@ -0,0 +1,127 @@ +# Qwen3.6-27B-MTP on ATLAS (macOS Metal) + +## Model specs + +- Architecture: Gated DeltaNet + Gated Attention hybrid, 5120-dim hidden, 64 layers +- MTP: Multi-Token Prediction (inline draft head, `--spec-type draft-mtp`) +- Context: 262K native, 32K recommended for 64GB M1 Max +- Quantization: UD-Q4_K_XL (19 GB on disk, ~24 GB VRAM at 32K context) +- License: Apache 2.0 + +## What works + +- Base inference via Metal-accelerated llama.cpp +- Lens C(x) cost-field scoring (5120-dim, 200-epoch contrastive training) +- Full V3 pipeline (budget forcing, plan search, candidate selection, refinement loop) + +## What doesn't + +- **MTP speculative decoding**: crashes during draft context init (`GGML_ASSERT missing result_norm/result_embd`) and has net throughput loss on Metal even when it doesn't crash (llama.cpp #23752). Disabled by default (`ATLAS_ENABLE_MTP=0`). +- **ASA steering vectors**: no vector trained for Qwen3.6 residuals. Would need `atlas asa build` against this model. +- **e2e_smoke in atlas doctor**: 300 tokens × ~5 tok/s on M1 Max exceeds the 60s hard timeout. The model itself works fine for interactive use. + +## Fresh clone setup + +```bash +# 1. Clone and configure +git clone https://github.com/itigges22/ATLAS +cd ATLAS +cp .env.example .env + +# Edit .env: +# ATLAS_MODEL_FILE=Qwen3.6-27B-UD-Q4_K_XL.gguf +# ATLAS_MODEL_NAME=Qwen3.6-27B-MTP-UD-Q4_K_XL +# ATLAS_ENABLE_MTP=0 + +# 2. Download the model (17.9 GB) +hf download unsloth/Qwen3.6-27B-MTP-GGUF \ + --include "*UD-Q4_K_XL*" \ + --local-dir ./models + +# 3. Build native Metal llama-server +./scripts/atlas-setup-macos.sh + +# 4. Start the native server (keep this terminal open) +source .env +./scripts/atlas-llama-macos.sh + +# 5. Start Docker services (new terminal) +docker compose -f docker-compose.yml -f docker-compose.macos.yml up -d + +# 6. Verify +export ATLAS_MODEL_FILE=Qwen3.6-27B-UD-Q4_K_XL.gguf +atlas doctor +``` + +## Lens training (optional, ~2h on CPU) + +```bash +# Download pre-computed 5120-dim embeddings +curl -sL "https://huggingface.co/datasets/itigges22/ATLAS/resolve/main/embeddings/training_embeddings_5120d.json" \ + -o ./geometric-lens/geometric_lens/models/qwen36-27b/training_embeddings_5120d.json + +# Copy data into the running geometric-lens container +docker cp ./geometric-lens/geometric_lens/models/qwen36-27b/training_embeddings_5120d.json \ + atlas-geometric-lens-1:/tmp/ + +# Train C(x) inside the container (torch is pre-installed there) +docker exec -w /app atlas-geometric-lens-1 python3 -c " +import json, os +from geometric_lens.training import train_cost_field, save_cost_field + +with open('/tmp/training_embeddings_5120d.json') as f: + data = json.load(f) +data['labels'] = [1 if l == 'PASS' else 0 for l in data['labels']] +print(f'Loaded {len(data[\"embeddings\"])} embeddings, dim={data[\"dim\"]}') + +result = train_cost_field(data, epochs=200) +test_auc = result.get('best_test_auc') or result.get('final_test_auc') or 0.0 +print(f'Test AUC: {test_auc:.4f}') + +os.makedirs('/tmp/qwen36-27b', exist_ok=True) +cost_path = save_cost_field(result['model'], save_dir='/tmp/qwen36-27b') +print(f'Saved: {cost_path}') +" + +# Copy trained artifact back to host +docker cp atlas-geometric-lens-1:/tmp/qwen36-27b/cost_field.pt \ + ./geometric-lens/geometric_lens/models/qwen36-27b/ + +# Doctor requires metric_tensor.pt on disk (runtime uses XGBoost, not this file) +touch ./geometric-lens/geometric_lens/models/qwen36-27b/metric_tensor.pt + +# Verify Lens +export ATLAS_LENS_MODELS=./geometric-lens/geometric_lens/models/qwen36-27b +atlas doctor +``` + +## Expected doctor output + +``` + ✓ health/llama ok + ✓ model_file Qwen3.6-27B-UD-Q4_K_XL.gguf (16.7 GB) + ✓ lens_weights cost_field.pt + metric_tensor.pt (after training) + ⚠ asa_steering expected — no vector trained for Qwen3.6 + ⚠ tier_constraints disk free — Qwen3.6 is 17.9 GB + ✗ e2e_smoke timeout — 27B on M1 Max is slow, not a bug + + 19 passed, 2 warnings, 1 failed, 1 skipped + ATLAS install has failures — re-run with -v for detail. +``` + +## Registry entry + +The model is registered in `atlas/cli/commands/model_registry.py` as: + +- `name`: `Qwen3.6-27B-MTP-UD-Q4_K_XL` +- `tier`: `xlarge` +- `lens_status`: `no-artifacts` (until artifacts are published to HF) +- `download_url`: `None` (manual download via `hf download`) + +## Key files + +- `atlas/cli/commands/model_registry.py` — registry entry +- `inference/Dockerfile.v31` — llama.cpp SHA `25558268` (MTP merge) +- `inference/patches/expose-hidden-states.patch` — regenerated for new upstream +- `scripts/atlas-llama-macos.sh` — MTP flag gating via `ATLAS_ENABLE_MTP` +- `geometric-lens/geometric_lens/models/qwen36-27b/` — Lens artifacts diff --git a/geometric-lens/geometric_lens/models/qwen36-27b/cost_field.pt b/geometric-lens/geometric_lens/models/qwen36-27b/cost_field.pt new file mode 100644 index 00000000..69f6b758 Binary files /dev/null and b/geometric-lens/geometric_lens/models/qwen36-27b/cost_field.pt differ diff --git a/geometric-lens/geometric_lens/models/qwen36-27b/train_cx.py b/geometric-lens/geometric_lens/models/qwen36-27b/train_cx.py new file mode 100644 index 00000000..02a406ed --- /dev/null +++ b/geometric-lens/geometric_lens/models/qwen36-27b/train_cx.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Train C(x) cost field from pre-computed 5120-dim embeddings.""" +import json, os, sys, time + +path = "/app/geometric_lens/models/qwen36-27b/training_embeddings_5120d.json" +with open(path) as f: + data = json.load(f) + +sys.stderr.write(f"Loaded {len(data['embeddings'])} embeddings, dim={data['dim']}\n") +labels = [1 if l == "PASS" else 0 for l in data["labels"]] +data["labels"] = labels +sys.stderr.write(f"Labels: {sum(labels)} PASS, {len(labels) - sum(labels)} FAIL\n") +sys.stderr.flush() + +from geometric_lens.training import train_cost_field, save_cost_field + +start = time.time() +result = train_cost_field(data, epochs=200, lr=1e-3, margin=1.0) +elapsed = time.time() - start + +test_auc = result.get("best_test_auc") or result.get("final_test_auc") or 0.0 +train_auc = result.get("final_train_auc") or 0.0 +sys.stderr.write(f"Train AUC: {train_auc:.4f} | Test AUC: {test_auc:.4f} | Time: {elapsed:.0f}s\n") +sys.stderr.flush() + +artifact_dir = "/app/geometric_lens/models/qwen36-27b" +cost_path = save_cost_field(result["model"], save_dir=artifact_dir) +sys.stderr.write(f"Saved: {cost_path}\n") +sys.stderr.flush() +print("DONE") diff --git a/inference/Dockerfile.v31 b/inference/Dockerfile.v31 index 575de64d..f9170927 100644 --- a/inference/Dockerfile.v31 +++ b/inference/Dockerfile.v31 @@ -62,7 +62,7 @@ COPY patches/ /patches/ # 1. There's a llama.cpp release with a feature/fix we want # 2. The PC-202 patch has been regenerated against the new upstream # Override at build time: --build-arg LLAMA_CPP_REV= -ARG LLAMA_CPP_REV=2e97c5f96f9fe2bb26f794a348e05d7a1c74baa1 +ARG LLAMA_CPP_REV=255582687b8dd211fdbc582e43ab842491554e94 # #117: Split fetch -> patch -> build into three RUN layers so a # LLAMA_CPP_REV bump or a patches/ change invalidates only the affected # layer + everything below, not the whole giant step. The build is the diff --git a/inference/patches/expose-hidden-states.patch b/inference/patches/expose-hidden-states.patch index d97d4814..53d5e186 100644 --- a/inference/patches/expose-hidden-states.patch +++ b/inference/patches/expose-hidden-states.patch @@ -1,5 +1,5 @@ diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp -index 4b28033..b7a7a9d 100644 +index 4d162f8..fc26f20 100644 --- a/tools/server/server-context.cpp +++ b/tools/server/server-context.cpp @@ -18,11 +18,16 @@ @@ -74,10 +74,10 @@ index 4b28033..b7a7a9d 100644 + return true; +} + - static void server_prompt_checkpoint_update(server_prompt_checkpoint & ckpt, llama_context * ctx, int id, int64_t n_tokens, bool on_device, llama_pos pos_min = -1, llama_pos pos_max = -1) { - if (pos_min == -1) { - pos_min = llama_memory_seq_pos_min(llama_get_memory(ctx), id); -@@ -705,6 +761,10 @@ private: + // state diagram: https://github.com/ggml-org/llama.cpp/pull/9283 + enum slot_state { + SLOT_STATE_IDLE, +@@ -689,6 +745,10 @@ private: bool sleeping = false; @@ -88,7 +88,7 @@ index 4b28033..b7a7a9d 100644 void destroy() { llama_init.reset(); -@@ -757,6 +817,12 @@ private: +@@ -735,6 +795,12 @@ private: params_base = params; @@ -100,32 +100,25 @@ index 4b28033..b7a7a9d 100644 + llama_init = common_init_from_params(params_base); - model = llama_init->model(); -@@ -1714,6 +1780,32 @@ private: + model_tgt = llama_init->model(); +@@ -1739,6 +1805,25 @@ private: res->embedding.emplace_back(embd, embd + n_embd_out); } -+ // ATLAS PC-202: slice per-token hidden states for this slot from the -+ // batch-level capture buffer. Each layer's flat buffer is laid out as -+ // [hidden_dim, batch_n_tokens] (ggml convention: ne[0] is fastest dim), -+ // so token at batch position j starts at offset j * hidden_dim. -+ // We collect ALL of slot.id's tokens (not just batch.logits[i]==1), so -+ // PC-207 lens-as-PRM gets per-step states across the full prompt. ++ // ATLAS PC-202: slice per-token hidden states for this slot + if (!slot.task->params.hidden_states_layers.empty() + && hidden_states_capture.hidden_dim > 0) { + const int H = hidden_states_capture.hidden_dim; + res->hidden_dim = H; + for (int layer_idx : slot.task->params.hidden_states_layers) { + auto it = hidden_states_capture.captured.find(layer_idx); -+ if (it == hidden_states_capture.captured.end()) { -+ continue; // layer was not captured (e.g. cb_eval skipped due to dtype) -+ } ++ if (it == hidden_states_capture.captured.end()) continue; + const auto & flat = it->second; + auto & out_layer = res->hidden_states[layer_idx]; + for (int j = 0; j < batch.n_tokens; ++j) { + if (batch.seq_id[j][0] != slot.id) continue; + const size_t off = (size_t)j * (size_t)H; -+ if (off + (size_t)H > flat.size()) break; // bounds guard ++ if (off + (size_t)H > flat.size()) break; + out_layer.emplace_back(flat.begin() + off, flat.begin() + off + H); + } + } @@ -134,14 +127,11 @@ index 4b28033..b7a7a9d 100644 SLT_DBG(slot, "%s", "sending embeddings\n"); queue_results.send(std::move(res)); -@@ -2799,6 +2891,25 @@ private: +@@ -2951,6 +3036,22 @@ private: batch.logits + i, }; + // ATLAS PC-202: populate hidden-states capture state for THIS decode pass. -+ // The cb_eval already installed at init consults this state; if no slot -+ // wants hidden states, layers_wanted stays empty and the callback is a no-op -+ // (just a name strncmp). + hidden_states_capture.clear(); + for (auto & slot : slots) { + if (slot.is_processing() @@ -154,19 +144,18 @@ index 4b28033..b7a7a9d 100644 + } + if (!hidden_states_capture.layers_wanted.empty()) { + hidden_states_capture.batch_n_tokens = n_tokens; -+ hidden_states_capture.hidden_dim = llama_model_n_embd(model); ++ hidden_states_capture.hidden_dim = llama_model_n_embd(model_tgt); + } + - const int ret = llama_decode(ctx, batch_view); + const int ret = llama_decode(ctx_tgt, batch_view); metrics.on_decoded(slots); -@@ -4309,6 +4420,50 @@ std::unique_ptr server_routes::handle_embeddings_impl(cons +@@ -4535,6 +4636,49 @@ std::unique_ptr server_routes::handle_embeddings_impl(cons } } -+ // ATLAS PC-202: optional per-layer residual capture. Available on the legacy -+ // /embedding and /embeddings paths only — OAI-compat /v1/embeddings clients -+ // don't expect the extension and would fail schema validation. ++ ++ // ATLAS PC-202: optional per-layer residual capture + std::vector hidden_states_layers; + if (body.count("layers") != 0) { + if (res_type == TASK_RESPONSE_TYPE_OAI_EMBD) { @@ -187,7 +176,7 @@ index 4b28033..b7a7a9d 100644 + ERROR_TYPE_INVALID_REQUEST)); + return res; + } -+ const int n_layer = llama_model_n_layer(ctx_server.model); ++ const int n_layer = llama_model_n_layer(ctx_server.model_tgt); + std::set seen; + for (const auto & v : layers_json) { + if (!v.is_number_integer()) { @@ -211,7 +200,7 @@ index 4b28033..b7a7a9d 100644 // create and queue the task json responses = json::array(); auto & rd = res->rd; -@@ -4324,6 +4479,9 @@ std::unique_ptr server_routes::handle_embeddings_impl(cons +@@ -4550,6 +4694,9 @@ std::unique_ptr server_routes::handle_embeddings_impl(cons task.params.res_type = res_type; task.params.embd_normalize = embd_normalize; @@ -221,84 +210,67 @@ index 4b28033..b7a7a9d 100644 tasks.push_back(std::move(task)); } rd.post_tasks(std::move(tasks)); -diff --git a/tools/server/server-task.cpp b/tools/server/server-task.cpp -index 45e5168..dcf3149 100644 ---- a/tools/server/server-task.cpp -+++ b/tools/server/server-task.cpp -@@ -9,6 +9,7 @@ - #include "sampling.h" - #include "speculative.h" - #include "server-common.h" -+#include "base64.hpp" - - using json = nlohmann::ordered_json; - -@@ -1823,10 +1824,38 @@ json server_task_result_embd::to_json() { - } - - json server_task_result_embd::to_json_non_oaicompat() { -- return json { -+ json out = json { - {"index", index}, - {"embedding", embedding}, - }; -+ -+ // ATLAS PC-202: include per-layer residual hidden states when captured. -+ // Each layer is flattened as float32 row-major [n_tokens × hidden_dim] -+ // and base64-encoded (matches the existing /embedding encoding_format=base64 -+ // wire convention; saves ~5x bandwidth vs. JSON arrays of floats). -+ if (!hidden_states.empty() && hidden_dim > 0) { -+ json hs = json::object(); -+ for (const auto & kv : hidden_states) { -+ const int layer_idx = kv.first; -+ const auto & per_token = kv.second; -+ // flatten [n_tokens][hidden_dim] -> contiguous float buffer -+ std::vector flat; -+ flat.reserve(per_token.size() * hidden_dim); -+ for (const auto & tok_vec : per_token) { -+ flat.insert(flat.end(), tok_vec.begin(), tok_vec.end()); -+ } -+ const char * data_ptr = reinterpret_cast(flat.data()); -+ const size_t data_size = flat.size() * sizeof(float); -+ hs[std::to_string(layer_idx)] = base64::encode(data_ptr, data_size); -+ } -+ out["hidden_states"] = std::move(hs); -+ out["hidden_states_n_tokens"] = (int32_t)(hidden_states.begin()->second.size()); -+ out["hidden_states_dim"] = hidden_dim; -+ out["hidden_states_dtype"] = "float32"; -+ out["hidden_states_encoding"] = "base64"; -+ } -+ -+ return out; - } - - json server_task_result_embd::to_json_oaicompat() { diff --git a/tools/server/server-task.h b/tools/server/server-task.h -index 289e1fb..1606fd0 100644 +index 64bdecd..f0d4721 100644 --- a/tools/server/server-task.h +++ b/tools/server/server-task.h -@@ -87,6 +87,11 @@ struct task_params { +@@ -87,6 +87,9 @@ struct task_params { // Embeddings int32_t embd_normalize = 2; // (-1=none, 0=max absolute int16, 1=taxicab, 2=Euclidean/L2, >2=p-norm) + // ATLAS PC-202: per-layer residual stream capture (post-block, "l_out-{N}") -+ // Empty = no capture (default behavior). Each entry must be in [0, n_layer). -+ // Capacity is bounded server-side (max 8 layers per request) to limit memory. + std::vector hidden_states_layers; + json format_logit_bias(const std::vector & logit_bias) const; json to_json(bool only_metrics = false) const; }; -@@ -470,6 +475,12 @@ struct server_task_result_embd : server_task_result { +@@ -470,6 +473,10 @@ struct server_task_result_embd : server_task_result { int32_t n_tokens; -+ // ATLAS PC-202: per-layer residual hidden states (post-block "l_out-{N}"). -+ // Outer key = layer index. Inner = [n_tokens][hidden_dim] row-major. -+ // Empty when no layers were requested (preserves wire-compat). ++ // ATLAS PC-202: per-layer residual hidden states + std::map>> hidden_states; + int32_t hidden_dim = 0; + // response formatting task_response_type res_type = TASK_RESPONSE_TYPE_NONE; +diff --git a/tools/server/server-task.cpp b/tools/server/server-task.cpp +index 0000000..0000000 100644 +--- a/tools/server/server-task.cpp ++++ b/tools/server/server-task.cpp +@@ -1,5 +1,6 @@ + #include "server-task.h" + ++#include "base64.hpp" + #include "build-info.h" + #include "server-chat.h" + #include "chat.h" +@@ -1824,10 +1825,26 @@ json server_task_result_embd::to_json() { + } + + json server_task_result_embd::to_json_non_oaicompat() { +- return json { ++ json result = { + {"index", index}, + {"embedding", embedding}, + }; ++ if (!hidden_states.empty()) { ++ json hs_obj = json::object(); ++ for (const auto & [layer, tokens] : hidden_states) { ++ std::vector flat; ++ for (const auto & row : tokens) { ++ flat.insert(flat.end(), row.begin(), row.end()); ++ } ++ const char * bytes = reinterpret_cast(flat.data()); ++ size_t byte_len = flat.size() * sizeof(float); ++ hs_obj[std::to_string(layer)] = base64::encode(bytes, byte_len); ++ } ++ result["hidden_states"] = hs_obj; ++ result["hidden_states_n_tokens"] = n_tokens; ++ result["hidden_states_dim"] = hidden_dim; ++ } ++ return result; + } + + json server_task_result_embd::to_json_oaicompat() { diff --git a/scripts/atlas-llama-macos.sh b/scripts/atlas-llama-macos.sh index 57480212..81735415 100755 --- a/scripts/atlas-llama-macos.sh +++ b/scripts/atlas-llama-macos.sh @@ -124,6 +124,14 @@ EOF SLOT_SAVE_PATH="${TMPDIR:-/tmp}/atlas-slots" mkdir -p "$SLOT_SAVE_PATH" +# MTP (Multi-Token Prediction) flags — enabled via ATLAS_ENABLE_MTP=1 in .env +MTP_FLAGS="" +if [ "${ATLAS_ENABLE_MTP:-0}" = "1" ]; then + MTP_DRAFT_MAX="${ATLAS_MTP_DRAFT_N_MAX:-2}" + MTP_FLAGS="--spec-type draft-mtp --spec-draft-n-max $MTP_DRAFT_MAX" + echo " MTP: ENABLED (spec-type draft-mtp, draft-n-max=$MTP_DRAFT_MAX)" +fi + exec "$LLAMA_SERVER" \ -m "$MODEL_FILE" \ -c "$CTX_LENGTH" \ @@ -141,4 +149,5 @@ exec "$LLAMA_SERVER" \ --no-cache-prompt \ --embeddings \ --jinja \ + $MTP_FLAGS \ $CVECTOR_FLAGS diff --git a/tests/cli/test_model.py b/tests/cli/test_model.py index 3e9c7d53..3d27801a 100644 --- a/tests/cli/test_model.py +++ b/tests/cli/test_model.py @@ -80,8 +80,8 @@ def test_list_json_structure(tmp_path, capsys): payload = json.loads(capsys.readouterr().out) assert payload["models_dir"] == str(tmp_path) assert isinstance(payload["models"], list) - # PC-056.1: three 9B quants; PC-215 added gemma-4-12b. - assert len(payload["models"]) == 7 + # PC-056.1: 6 base entries + Qwen3.6-27B-MTP + gemma-4-12b (PC-215) = 8. + assert len(payload["models"]) == 8 nine = next(m for m in payload["models"] if m["name"] == "Qwen3.5-9B-Q6_K") assert nine["lens_status"] == "supported" assert nine["installed"] is False diff --git a/tests/cli/test_model_registry.py b/tests/cli/test_model_registry.py index 14e39150..ea3072c8 100644 --- a/tests/cli/test_model_registry.py +++ b/tests/cli/test_model_registry.py @@ -20,14 +20,16 @@ def test_registry_has_known_qwen_entries(): """PC-056 shipped with 4 tier presets; PC-056.1 added Q4_K_M and - Q8_0 variants of the 9B for a total of 6. Adding more is a - deliberate scope change and should be a separate ticket — flag - it loudly here.""" - assert len(model_registry.REGISTRY) == 7 + Q8_0 variants of the 9B for a total of 6. Qwen3.6-27B-MTP added as + experimental entry; PC-215 added gemma-4-12b via the publish flow + (8 total). Adding more is a deliberate scope change and should be a + separate ticket — flag it loudly here.""" + assert len(model_registry.REGISTRY) == 8 names = {m.name for m in model_registry.REGISTRY} assert names == {"Qwen3.5-7B-Q4_K_M", "Qwen3.5-9B-Q4_K_M", "Qwen3.5-9B-Q6_K", "Qwen3.5-9B-Q8_0", "Qwen3.5-14B-Q5_K_M", "Qwen3.5-32B-Q5_K_M", + "Qwen3.6-27B-MTP-UD-Q4_K_XL", "gemma-4-12b-it-Q4_K_M"} @@ -45,12 +47,12 @@ def test_only_9b_quants_are_publicly_installable(): """PC-056.1: gated entries got download_urls populated (so HF_TOKEN-authenticated users CAN install them) but they're flagged requires_hf_token. The "publicly installable without auth" set is - just the three 9B quants.""" + the three 9B quants plus Qwen3.6-27B.""" public = [m for m in model_registry.REGISTRY if m.can_install and not m.requires_hf_token] public_names = {m.name for m in public} assert public_names == {"Qwen3.5-9B-Q4_K_M", "Qwen3.5-9B-Q6_K", - "Qwen3.5-9B-Q8_0"} + "Qwen3.5-9B-Q8_0", "Qwen3.6-27B-MTP-UD-Q4_K_XL"} def test_9b_has_verified_download_metadata(): @@ -173,10 +175,69 @@ def test_by_name_unknown_returns_none(): def test_models_for_tier_returns_only_matches(): - """Medium tier: the three 9B quants plus gemma-4-12b (PC-215).""" + """Medium tier: the three 9B quants plus gemma-4-12b (PC-215) = 4. + xlarge tier: 32B-Q5_K_M + Qwen3.6-27B-MTP = 2.""" medium = model_registry.models_for_tier("medium") assert all(m.tier == "medium" for m in medium) assert len(medium) == 4 + xlarge = model_registry.models_for_tier("xlarge") + assert all(m.tier == "xlarge" for m in xlarge) + assert len(xlarge) == 2 + + +# --------------------------------------------------------------------------- +# Qwen3.6-27B-MTP — experimental entry +# --------------------------------------------------------------------------- + +def test_qwen36_entry_basics(): + """Qwen3.6-27B-MTP is an experimental xlarge entry: lens C(x) trained + but unpublished (G(x) half untrained, so lens_status no-artifacts), + ASA vector published to HF, public download URL from unsloth, correct + size.""" + m = model_registry.by_name("Qwen3.6-27B-MTP-UD-Q4_K_XL") + assert m is not None + assert m.tier == "xlarge" + assert m.model_file == "Qwen3.6-27B-UD-Q4_K_XL.gguf" + assert m.model_size_gb == 16.7 + assert m.lens_status == "no-artifacts" + # ASA vector published to HF (lens G(x) half still untrained). + assert m.asa_status == "supported" + assert m.asa_artifact_files == ["ast_edit_steering.gguf"] + assert m.asa_hf_repo == "yogthos/atlas-asa-qwen3.6-27b-mtp-ud-q4_k_xl" + assert m.requires_hf_token is False + assert m.download_url is not None + assert "/Qwen3.6-27B-MTP-GGUF/" in m.download_url + assert m.sha256 is not None + assert len(m.sha256) == 64 + + +def test_qwen36_entry_has_mtp_notes(): + """The Qwen3.6 notes must mention MTP, the architecture (DeltaNet+Attention), + and Lens training status.""" + m = model_registry.by_name("Qwen3.6-27B-MTP-UD-Q4_K_XL") + assert m is not None + n = m.notes.lower() + assert "mtp" in n + assert "draft-mtp" in n + assert "delta net" in n or "deltanet" in n + assert "lens" in n + assert "5120" in n + assert "cost_field.pt" in n or "trained locally" in n + + +def test_qwen36_now_publicly_installable(): + """Qwen3.6 has download_url from unsloth so it IS in the can_install set.""" + m = model_registry.by_name("Qwen3.6-27B-MTP-UD-Q4_K_XL") + assert m is not None + assert m.can_install is True + + +def test_qwen36_not_in_supported_models(): + """Qwen3.6 has no Lens artifacts — supported_models() returns the + 9B Q6_K and gemma-4-12b (PC-215), not Qwen3.6.""" + supported_names = {m.name for m in model_registry.supported_models()} + assert "Qwen3.6-27B-MTP-UD-Q4_K_XL" not in supported_names + assert supported_names == {"Qwen3.5-9B-Q6_K", "gemma-4-12b-it-Q4_K_M"} # --------------------------------------------------------------------------- @@ -263,11 +324,12 @@ def test_shim_callers_can_access_old_field_names(): # PC-056.1 schema additions: 9B variants, commit-pinned URLs, requires_hf_token # --------------------------------------------------------------------------- -def test_pc0561_registry_now_has_six_entries(): - """PC-056.1 added Q4_K_M and Q8_0 variants of the 9B; PC-215 added - gemma-4-12b via the publish flow. Adding more is a deliberate scope - change — flag it loudly here.""" - assert len(model_registry.REGISTRY) == 7 +def test_pc0561_registry_now_has_eight_entries(): + """PC-056.1 added Q4_K_M and Q8_0 variants of the 9B (6 total). + Qwen3.6-27B-MTP added as experimental; PC-215 added gemma-4-12b via + the publish flow (8 total). Adding more is a deliberate scope change + — flag it loudly here.""" + assert len(model_registry.REGISTRY) == 8 def test_pc0561_three_quants_for_9b(): @@ -304,8 +366,9 @@ def test_pc0561_urls_pinned_to_commit_hash(): continue assert "/main/" not in m.download_url, ( f"{m.name} URL not commit-pinned: {m.download_url}") - # Should contain the unsloth Qwen3.5 commit hash - assert "/3885219b" in m.download_url, ( + # Qwen3.5 models share one commit; Qwen3.6 has its own + assert "/3885219b" in m.download_url or \ + "/5cb35eb3" in m.download_url, ( f"{m.name} not pinned to expected commit: {m.download_url}")