diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa2b23ec..4ee6a2fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,12 +93,25 @@ jobs: name_suffix: -gemma-e2b models: mlx-community/gemma-4-e2b-it-4bit test_model: mlx-community/gemma-4-e2b-it-4bit + # LFM2.5 used to be absent here and was fetched by the server mid-test, which + # is how it silently picked up a republished revision whose chat template was + # one brace short of valid and took main red. Prefetching it at a pinned + # revision is what makes this job depend on our code rather than on what + # upstream published that afternoon. - modality: vision - models: mlx-community/Qwen2-VL-2B-Instruct-4bit + models: mlx-community/Qwen2-VL-2B-Instruct-4bit LiquidAI/LFM2.5-VL-450M-MLX-4bit@10ce3604e42cd595497c47aaf67b7890e1e2a3b4 - modality: audio models: mlx-community/gemma-4-e4b-it-4bit - modality: graph models: "" + # Synthetic checkpoints committed to the repository, a few hundred KB each, + # so this entry downloads nothing and needs no model cache. It covers the + # weight-and-config shapes behind #118, #120 and the b674 regression — the + # class of defect #128 found the unit suite has never caught, and which real + # checkpoints are too large to cover here (gemma-4-e2b alone is 3.6 GB + # against a 10 GB per-repository cache budget). + - modality: fixtures + models: "" # OpenAI-compatibility contract: what the client receives over the wire, once # real tokenisation decides chunk boundaries. Runs on the smallest model — # these assert server behaviour, not model quality (issue #128). diff --git a/.github/workflows/update_dependencies.yml b/.github/workflows/update_dependencies.yml index 66d02715..85a599c2 100644 --- a/.github/workflows/update_dependencies.yml +++ b/.github/workflows/update_dependencies.yml @@ -1,12 +1,25 @@ name: Dependency Automation +# Prepares a submodule bump when mlx-swift or mlx-swift-lm cuts a release, and stops +# at a pushed branch rather than opening a pull request. +# +# Opening the PR from a workflow needs a personal access token, because GitHub does +# not start workflow runs for events raised by GITHUB_TOKEN. A bot-opened PR would +# therefore arrive with no checks at all — permanently pending, never green — and this +# repository gates releases on CI concluding successfully (see release.yml). A branch +# is the honest stopping point: opening the PR yourself takes one click, and CI then +# runs normally because the event is yours. +# +# That a human sees the bump before it merges is a feature. Bumps here have needed a +# pointer check, an umbrella build and a smoke test to be trustworthy; the automation +# does the mechanical part and leaves the judgement. + on: repository_dispatch: types: [dependency_bump] permissions: contents: write - pull-requests: write jobs: bump-dependencies: @@ -18,34 +31,100 @@ jobs: submodules: recursive fetch-depth: 0 - - name: Update swift-mlx dependencies - if: ${{ github.event.client_payload.source_repo == 'mlx-swift' }} + # client_payload is attacker-controlled in principle — anything able to dispatch + # to this repository chooses these strings — and they end up in shell and in a + # ref name. Validate them here and pass them onward through the environment + # rather than interpolating ${{ }} into a run block, where a crafted tag would + # be executed rather than compared. + - name: Validate dispatch payload + env: + PAYLOAD_SOURCE_REPO: ${{ github.event.client_payload.source_repo }} + PAYLOAD_NEW_TAG: ${{ github.event.client_payload.new_tag }} run: | - echo "Bumping mlx-swift dependency to ${{ github.event.client_payload.new_tag }}" - # In Package.swift we depend on branch main, but if we wanted to depend on a tag: - # SwiftPM resolves "main" to the latest commit automatically, but updating the SPM resolved file ensures deterministic builds: - swift package update mlx-swift + set -euo pipefail + case "$PAYLOAD_SOURCE_REPO" in + mlx-swift|mlx-swift-lm) ;; + *) + echo "::error::unexpected source_repo '$PAYLOAD_SOURCE_REPO' — expected mlx-swift or mlx-swift-lm" + exit 1 + ;; + esac + if ! printf '%s' "$PAYLOAD_NEW_TAG" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$'; then + echo "::error::refusing tag '$PAYLOAD_NEW_TAG' — not a plain tag name" + exit 1 + fi + { + echo "SOURCE_REPO=$PAYLOAD_SOURCE_REPO" + echo "NEW_TAG=$PAYLOAD_NEW_TAG" + } >> "$GITHUB_ENV" - - name: Update swift-mlx-lm dependencies - if: ${{ github.event.client_payload.source_repo == 'mlx-swift-lm' }} + # Both dependencies are `.package(path: "./…")` in Package.swift, backed by git + # submodules, so bumping either one is a pointer move. `swift package update` + # does nothing for a path dependency — SwiftPM takes whatever is on disk — which + # is why the mlx-swift branch of the previous version of this workflow could only + # ever have produced an empty commit. + - name: Move submodule to the released tag run: | - echo "Bumping local mlx-swift-lm submodule to ${{ github.event.client_payload.new_tag }}" - git submodule update --remote mlx-swift-lm - # Force the submodule onto the specific new release tag - cd mlx-swift-lm - git checkout ${{ github.event.client_payload.new_tag }} - cd .. - git add mlx-swift-lm - - - name: Create Pull Request - uses: peter-evans/create-pull-request@v6 - with: - token: ${{ secrets.SWIFTLM_PR_TOKEN }} - commit-message: "chore(deps): bump ${{ github.event.client_payload.source_repo }} to ${{ github.event.client_payload.new_tag }}" - title: "Update ${{ github.event.client_payload.source_repo }} Dependency to ${{ github.event.client_payload.new_tag }}" - body: | - Automated dependency update triggered by release `${{ github.event.client_payload.new_tag }}` in `SharpAI/${{ github.event.client_payload.source_repo }}`. - - This PR ensures SwiftLM is tracking the latest validated architectural improvements. - branch: "auto-update/${{ github.event.client_payload.source_repo }}-${{ github.event.client_payload.new_tag }}" - base: main + set -euo pipefail + git -C "$SOURCE_REPO" fetch --tags --force origin + if ! git -C "$SOURCE_REPO" rev-parse -q --verify "refs/tags/${NEW_TAG}^{commit}" >/dev/null; then + echo "::error::tag $NEW_TAG does not exist in $SOURCE_REPO" + exit 1 + fi + before=$(git rev-parse "HEAD:$SOURCE_REPO") + git -C "$SOURCE_REPO" checkout --detach "refs/tags/$NEW_TAG" + after=$(git -C "$SOURCE_REPO" rev-parse HEAD) + { + echo "BEFORE_SHA=$before" + echo "AFTER_SHA=$after" + } >> "$GITHUB_ENV" + if [ "$before" = "$after" ]; then + echo "ALREADY_CURRENT=1" >> "$GITHUB_ENV" + fi + + - name: Report an already-current submodule and stop + if: env.ALREADY_CURRENT == '1' + run: | + { + echo "### Nothing to bump" + echo + echo "\`$SOURCE_REPO\` is already at \`$NEW_TAG\` (\`${AFTER_SHA:0:7}\`)." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Push the bump branch + if: env.ALREADY_CURRENT != '1' + run: | + set -euo pipefail + branch="auto-update/${SOURCE_REPO}-${NEW_TAG}" + git checkout -B "$branch" + git add "$SOURCE_REPO" + git \ + -c user.name='github-actions[bot]' \ + -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + commit -m "chore(deps): bump $SOURCE_REPO to $NEW_TAG + + Moves the $SOURCE_REPO submodule from ${BEFORE_SHA:0:7} to ${AFTER_SHA:0:7}, + the commit tagged $NEW_TAG. + + Prepared automatically; opened by hand so that CI runs against it." + # The auto-update/* namespace belongs to this workflow, so replacing a branch + # left by an earlier run for the same tag is safe and keeps re-runs idempotent. + git push --force origin "$branch" + echo "BUMP_BRANCH=$branch" >> "$GITHUB_ENV" + + - name: Summarise, with a link that opens the pull request + if: env.ALREADY_CURRENT != '1' + run: | + { + echo "### \`$SOURCE_REPO\` → \`$NEW_TAG\` is ready" + echo + echo "Branch \`$BUMP_BRANCH\` pushed, moving the submodule from" + echo "\`${BEFORE_SHA:0:7}\` to \`${AFTER_SHA:0:7}\`." + echo + echo "**[Open the pull request](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/main...${BUMP_BRANCH}?expand=1)**" + echo + echo "No PR is opened here on purpose: GitHub does not start workflow runs" + echo "for events raised by \`GITHUB_TOKEN\`, so a bot-opened PR would never" + echo "get CI. Opening it yourself gets the checks this repository gates" + echo "releases on." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/Sources/SwiftLM/Server.swift b/Sources/SwiftLM/Server.swift index 626e4b80..f876a62c 100644 --- a/Sources/SwiftLM/Server.swift +++ b/Sources/SwiftLM/Server.swift @@ -43,15 +43,50 @@ private struct HubDownloader: Downloader, Sendable { } private struct TransformersTokenizerLoader: TokenizerLoader, Sendable { + /// Carried purely so template diagnostics can name the offending checkpoint; the + /// on-disk directory is a snapshot hash and means nothing to the person reading it. + let modelId: String + init(modelId: String = "this model") { self.modelId = modelId } + func load(from directory: URL) async throws -> any MLXLMCommon.Tokenizer { let t = try await AutoTokenizer.from(modelFolder: directory) - return TransformersTokenizerBridge(t) + return TransformersTokenizerBridge(t, modelId: modelId) + } +} + +/// A chat template that the Jinja parser rejected. +/// +/// Worth its own type because the underlying error says only what the parser choked on +/// — `parser('Unexpected token type: closeExpression')` — with no hint that the subject +/// is a file shipped inside someone else's checkpoint. A model published with a +/// malformed template loads fine, starts fine, and then fails every request, which +/// reads as "the server is broken" rather than "this checkpoint is broken". That +/// happened for real: LiquidAI republished LFM2.5-VL-450M-MLX-4bit with +/// `{- bos_token -}}` (one brace short) and identifying it took a bisect across two +/// model revisions. +struct MalformedChatTemplate: Error, CustomStringConvertible { + let modelId: String + let underlying: String + + var description: String { + """ + The chat template shipped with \(modelId) is not valid Jinja and could not be \ + parsed: \(underlying). This is a defect in the model's chat_template.jinja (or \ + the chat_template field of its tokenizer_config.json), not in the request — \ + report it to whoever publishes the checkpoint. Pinning to an earlier revision \ + of the model is the usual workaround. + """ } } private struct TransformersTokenizerBridge: MLXLMCommon.Tokenizer, Sendable { let upstream: any Tokenizers.Tokenizer - init(_ upstream: any Tokenizers.Tokenizer) { self.upstream = upstream } + /// Only used to name the model in template diagnostics. + let modelId: String + init(_ upstream: any Tokenizers.Tokenizer, modelId: String = "this model") { + self.upstream = upstream + self.modelId = modelId + } func encode(text: String, addSpecialTokens: Bool) -> [Int] { upstream.encode(text: text, addSpecialTokens: addSpecialTokens) } @@ -73,6 +108,12 @@ private struct TransformersTokenizerBridge: MLXLMCommon.Tokenizer, Sendable { messages: messages, tools: tools, additionalContext: additionalContext) } catch Tokenizers.TokenizerError.missingChatTemplate { throw MLXLMCommon.TokenizerError.missingChatTemplate + } catch { + // A missing template is a legitimate state (base models have none) and keeps + // its own error above. Anything else here means the template exists but the + // parser would not take it, which is worth naming precisely. + throw MalformedChatTemplate( + modelId: modelId, underlying: String(describing: error)) } } } @@ -598,7 +639,7 @@ struct MLXServer: AsyncParsableCommand { print("[SwiftLM] Loading Omni-Language Model (Text + Vision + Audio)...") container = try await OmniModelFactory.shared.loadContainer( from: downloader, - using: TransformersTokenizerLoader(), + using: TransformersTokenizerLoader(modelId: resolvedModelId), configuration: modelConfig ) { progress in tracker.printProgress(progress) @@ -607,7 +648,7 @@ struct MLXServer: AsyncParsableCommand { print("[SwiftLM] Loading VLM (vision-language model)...") container = try await VLMModelFactory.shared.loadContainer( from: downloader, - using: TransformersTokenizerLoader(), + using: TransformersTokenizerLoader(modelId: resolvedModelId), configuration: modelConfig ) { progress in tracker.printProgress(progress) @@ -618,7 +659,7 @@ struct MLXServer: AsyncParsableCommand { // and the native prepareForMultimodal path extracts real mel features. container = try await OmniModelFactory.shared.loadContainer( from: downloader, - using: TransformersTokenizerLoader(), + using: TransformersTokenizerLoader(modelId: resolvedModelId), configuration: modelConfig ) { progress in tracker.printProgress(progress) @@ -627,7 +668,7 @@ struct MLXServer: AsyncParsableCommand { print("[SwiftLM] Loading LLM (large language model)...") container = try await LLMModelFactory.shared.loadContainer( from: downloader, - using: TransformersTokenizerLoader(), + using: TransformersTokenizerLoader(modelId: resolvedModelId), configuration: modelConfig ) { progress in tracker.printProgress(progress) @@ -674,7 +715,7 @@ struct MLXServer: AsyncParsableCommand { let draftDownloader = HubDownloader(hub: HubApi(downloadBase: cacheRoot)) let draftContainer = try await LLMModelFactory.shared.loadContainer( from: draftDownloader, - using: TransformersTokenizerLoader(), + using: TransformersTokenizerLoader(modelId: resolvedModelId), configuration: draftConfig ) { progress in // Silent loading for draft model @@ -709,7 +750,7 @@ struct MLXServer: AsyncParsableCommand { let assistantDownloader = HubDownloader(hub: HubApi(downloadBase: cacheRoot)) let assistantContainer = try await LLMModelFactory.shared.loadContainer( from: assistantDownloader, - using: TransformersTokenizerLoader(), + using: TransformersTokenizerLoader(modelId: resolvedModelId), configuration: assistantConfig ) { _ in } mtpAssistantModelRef = await assistantContainer.perform { assistantContext in @@ -837,6 +878,34 @@ struct MLXServer: AsyncParsableCommand { print("[SwiftLM] 🧠 Auto-calibration (Wisdom) bypassed for SSD Streaming") } + // Render the chat template once before opening the port. A checkpoint whose + // template does not parse otherwise loads cleanly, reports ready, and then fails + // every single request — a shape that reads as a broken server rather than a + // broken model, and that survives restarts without ever explaining itself. + // Failing here instead costs one template render and makes the cause the first + // thing anybody sees. + // + // A model with no chat template at all is a different and legitimate case (base + // models ship without one, and /v1/completions does not need it), so that is + // passed over rather than treated as a defect. + do { + let probeTokenizer = await container.tokenizer + // Shaped like the simplest real request rather than a bare minimum: one user + // turn with a generation prompt is what every chat template is written to + // handle, so a failure here is the template's, not the probe's. + _ = try probeTokenizer.applyChatTemplate( + messages: [["role": "user", "content": "ping"]], + tools: nil, + additionalContext: ["add_generation_prompt": true] + ) + } catch let error as MalformedChatTemplate { + print("[SwiftLM] ❌ \(error.description)") + throw error + } catch { + // Missing template, or a template that needs context this probe does not + // supply. Neither is a reason to refuse to start. + } + print("[SwiftLM] Model loaded. Starting HTTP server on \(host):\(port)") // ── Capture CLI defaults into a shared config ── diff --git a/mlx-swift-lm b/mlx-swift-lm index 6a2c1799..bfc2462b 160000 --- a/mlx-swift-lm +++ b/mlx-swift-lm @@ -1 +1 @@ -Subproject commit 6a2c179998723107bb3d271963eeb9061056bee5 +Subproject commit bfc2462b975bc278411be041d5761299e836c846 diff --git a/scripts/ci-download-models.sh b/scripts/ci-download-models.sh index a2fa868a..e0632346 100755 --- a/scripts/ci-download-models.sh +++ b/scripts/ci-download-models.sh @@ -30,12 +30,26 @@ has_partial_files() { } download_one() { - local repo="$1" + local spec="$1" + # `repo@revision` pins to an immutable commit. Upstream repositories are mutable: + # LiquidAI republished LFM2.5-VL-450M-MLX-4bit with a malformed chat template + # (`{- bos_token -}}`, one brace short), which turned every request into an HTTP 500 + # and took main red with no change on our side. A floating tag means CI results + # depend on what a third party did that afternoon. + local repo="${spec%@*}" + local revision="" + if [ "$spec" != "$repo" ]; then + revision="${spec##*@}" + fi local dir="$HUB_DIR/models--${repo//\//--}" for attempt in $(seq 1 "$ATTEMPTS"); do - echo "--- $repo (attempt $attempt/$ATTEMPTS, per-request timeout ${HF_HUB_DOWNLOAD_TIMEOUT}s)" - if hf download "$repo"; then + echo "--- $repo${revision:+ @ $revision} (attempt $attempt/$ATTEMPTS, per-request timeout ${HF_HUB_DOWNLOAD_TIMEOUT}s)" + # Spelled out rather than assembling an args array: the runners are macOS, which + # ships bash 3.2, where expanding an empty array under `set -u` is an unbound + # variable error rather than nothing at all. That fails every unpinned download. + if { [ -n "$revision" ] && hf download "$repo" --revision "$revision"; } \ + || { [ -z "$revision" ] && hf download "$repo"; }; then if has_partial_files "$dir"; then echo "::warning::$repo downloaded but .incomplete files remain; retrying" else diff --git a/scripts/make-test-fixtures.py b/scripts/make-test-fixtures.py new file mode 100755 index 00000000..e61ecf35 --- /dev/null +++ b/scripts/make-test-fixtures.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Generate tiny synthetic checkpoints that exercise the real model-load path. + +Issue #128 observed that every defect in the #108/#110/#112 cycle was caught either +by loading a real checkpoint or by code review, and none by the unit suite — because +the bugs lived in weight-and-config-shape assumptions that only a checkpoint on disk +exercises. The obvious fix, running CI against real models, runs into arithmetic: +gemma-4-e2b is 3.6 GB and GitHub allows 10 GB of cache for the whole repository. + +llama.cpp solved the same problem by publishing purpose-built tiny models +(`ggml-org/test-model-stories260K`, 1.2 MB) rather than shrinking real ones. Their +files are GGUF and unusable here, but the technique transfers: a checkpoint with the +same *shape* as a real one — same config fields, same weight keys, random values, +a ~300-token vocabulary instead of 150k — runs the same loading code and weighs a +few hundred kilobytes. + +What these fixtures test is the plumbing: config parsing, weight-key resolution, +sanitisation, layer materialisation, quantisation metadata. They say nothing about +whether the arithmetic is correct, because the weights are noise. Real checkpoints +remain the only way to judge output quality. + +Usage: python3 scripts/make-test-fixtures.py [output-dir] +""" +import json +import os +import sys + +import numpy as np +from safetensors.numpy import save_file +from tokenizers import Tokenizer, decoders, models, pre_tokenizers, processors + +ROOT = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures" + +VOCAB = 288 +SPECIALS = ["<|endoftext|>", "<|im_start|>", "<|im_end|>", ""] +CHAT_TEMPLATE = ( + "{% for m in messages %}<|im_start|>{{ m['role'] }}\n{{ m['content'] }}<|im_end|>\n" + "{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}" +) + + +def write_tokenizer(out): + """A byte-level BPE, which is what Qwen/GPT-2 ship. + + Not a WordLevel model: swift-transformers rejects those with "BPETokenizer + requires merges". Merges must also be spelled in the byte-level alphabet — "Ġ" + for a space rather than a raw 0x20 — or the tokenizers library refuses to build. + """ + vocab = {t: i for i, t in enumerate(SPECIALS)} + for ch in sorted(pre_tokenizers.ByteLevel.alphabet()): + vocab[ch] = len(vocab) + merges = [("h", "e"), ("l", "l"), ("he", "ll"), ("t", "e")] + for a, b in merges: + vocab.setdefault(a + b, len(vocab)) + while len(vocab) < VOCAB: + vocab[f"<|unused{len(vocab)}|>"] = len(vocab) + + tok = Tokenizer(models.BPE(vocab=vocab, merges=merges, unk_token=None, fuse_unk=False)) + tok.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False) + tok.decoder = decoders.ByteLevel() + tok.post_processor = processors.ByteLevel(trim_offsets=False) + tok.add_special_tokens(SPECIALS) + tok.save(os.path.join(out, "tokenizer.json")) + + json.dump( + { + "tokenizer_class": "PreTrainedTokenizerFast", + "bos_token": "<|endoftext|>", + "eos_token": "<|im_end|>", + "pad_token": "", + "unk_token": "<|endoftext|>", + "chat_template": CHAT_TEMPLATE, + }, + open(os.path.join(out, "tokenizer_config.json"), "w"), + indent=2, + ) + + +def rand(rng, *shape): + return (rng.standard_normal(shape) * 0.02).astype(np.float16) + + +def ones(n): + return np.ones(n, np.float16) + + +def build_dense(out, stray_shard=False): + """A plain Qwen2. With stray_shard, an extra .safetensors sits beside the real one + without appearing in the weight index — the shape behind #118, where files outside + the index were being loaded and their keys rejected.""" + H, L, HEADS, KVH, INTER = 64, 2, 4, 2, 128 + HD = H // HEADS + rng = np.random.default_rng(0) + + json.dump( + { + "model_type": "qwen2", + "architectures": ["Qwen2ForCausalLM"], + "vocab_size": VOCAB, + "hidden_size": H, + "intermediate_size": INTER, + "num_hidden_layers": L, + "num_attention_heads": HEADS, + "num_key_value_heads": KVH, + "max_position_embeddings": 512, + "rms_norm_eps": 1e-6, + "rope_theta": 10000.0, + "tie_word_embeddings": False, + }, + open(os.path.join(out, "config.json"), "w"), + indent=2, + ) + + w = { + "model.embed_tokens.weight": rand(rng, VOCAB, H), + "model.norm.weight": ones(H), + "lm_head.weight": rand(rng, VOCAB, H), + } + for i in range(L): + p = f"model.layers.{i}" + w[f"{p}.self_attn.q_proj.weight"] = rand(rng, HEADS * HD, H) + w[f"{p}.self_attn.q_proj.bias"] = rand(rng, HEADS * HD) + w[f"{p}.self_attn.k_proj.weight"] = rand(rng, KVH * HD, H) + w[f"{p}.self_attn.k_proj.bias"] = rand(rng, KVH * HD) + w[f"{p}.self_attn.v_proj.weight"] = rand(rng, KVH * HD, H) + w[f"{p}.self_attn.v_proj.bias"] = rand(rng, KVH * HD) + w[f"{p}.self_attn.o_proj.weight"] = rand(rng, H, HEADS * HD) + w[f"{p}.mlp.gate_proj.weight"] = rand(rng, INTER, H) + w[f"{p}.mlp.up_proj.weight"] = rand(rng, INTER, H) + w[f"{p}.mlp.down_proj.weight"] = rand(rng, H, INTER) + w[f"{p}.input_layernorm.weight"] = ones(H) + w[f"{p}.post_attention_layernorm.weight"] = ones(H) + + save_file(w, os.path.join(out, "model.safetensors"), metadata={"format": "pt"}) + + if stray_shard: + # Deliberately absent from weight_map. A loader that globs *.safetensors instead + # of reading the index picks this up and fails on the unknown key. + save_file( + {"not_a_real_module.weight": rand(rng, 8, 8)}, + os.path.join(out, "extra-not-in-index.safetensors"), + metadata={"format": "pt"}, + ) + json.dump( + { + "metadata": {"total_size": 0}, + "weight_map": {k: "model.safetensors" for k in w}, + }, + open(os.path.join(out, "model.safetensors.index.json"), "w"), + ) + return len(w) + + +def build_gemma4_kv_shared(out, vestigial): + """Gemma 4 text with KV-shared layers. + + Two real checkpoints disagree about what a shared layer ships: gemma-4-e4b omits + its k/v projections, gemma-4-e2b includes them anyway. #120 was the first case + failing to load; the b674 regression was the second, after a fix that assumed the + first was universal. `vestigial=True` is the e2b shape. + + Shapes mirror a real gemma-4-e2b checkpoint rather than being guessed. + """ + H, L, SHARED, HEADS, KVH, HD = 64, 4, 2, 4, 2, 16 + INTER, PLI, VPLI = 128, 32, 16 + rng = np.random.default_rng(0) + + json.dump( + { + "model_type": "gemma4_text", + "architectures": ["Gemma4ForCausalLM"], + "hidden_size": H, + "num_hidden_layers": L, + "intermediate_size": INTER, + "num_attention_heads": HEADS, + "head_dim": HD, + "global_head_dim": HD, + "rms_norm_eps": 1e-6, + "vocab_size": VOCAB, + "num_key_value_heads": KVH, + "rope_traditional": False, + "rope_theta": 10000.0, + "sliding_window": 128, + "sliding_window_pattern": 1, + "max_position_embeddings": 512, + "num_kv_shared_layers": SHARED, + "use_double_wide_mlp": False, + "tie_word_embeddings": True, + "hidden_size_per_layer_input": PLI, + "vocab_size_per_layer_input": VPLI, + "final_logit_softcapping": 30.0, + "enable_moe_block": False, + "attention_k_eq_v": False, + }, + open(os.path.join(out, "config.json"), "w"), + indent=2, + ) + + w = { + "model.embed_tokens.weight": rand(rng, VOCAB, H), + "model.norm.weight": ones(H), + "model.embed_tokens_per_layer.weight": rand(rng, VPLI, L * PLI), + "model.per_layer_model_projection.weight": rand(rng, L * PLI, H), + "model.per_layer_projection_norm.weight": ones(PLI), + } + boundary = L - SHARED + for i in range(L): + p = f"model.layers.{i}" + w[f"{p}.self_attn.q_proj.weight"] = rand(rng, HEADS * HD, H) + w[f"{p}.self_attn.o_proj.weight"] = rand(rng, H, HEADS * HD) + w[f"{p}.self_attn.q_norm.weight"] = ones(HD) + w[f"{p}.layer_scalar"] = np.ones(1, np.float16) + if i < boundary or vestigial: + w[f"{p}.self_attn.k_proj.weight"] = rand(rng, KVH * HD, H) + w[f"{p}.self_attn.v_proj.weight"] = rand(rng, KVH * HD, H) + w[f"{p}.self_attn.k_norm.weight"] = ones(HD) + w[f"{p}.mlp.gate_proj.weight"] = rand(rng, INTER, H) + w[f"{p}.mlp.up_proj.weight"] = rand(rng, INTER, H) + w[f"{p}.mlp.down_proj.weight"] = rand(rng, H, INTER) + w[f"{p}.input_layernorm.weight"] = ones(H) + w[f"{p}.post_attention_layernorm.weight"] = ones(H) + w[f"{p}.pre_feedforward_layernorm.weight"] = ones(H) + w[f"{p}.post_feedforward_layernorm.weight"] = ones(H) + w[f"{p}.per_layer_input_gate.weight"] = rand(rng, PLI, H) + w[f"{p}.per_layer_projection.weight"] = rand(rng, H, PLI) + w[f"{p}.post_per_layer_input_norm.weight"] = ones(H) + + save_file(w, os.path.join(out, "model.safetensors"), metadata={"format": "pt"}) + return len(w) + + +FIXTURES = { + "dense": (build_dense, {}), + "stray-shard": (build_dense, {"stray_shard": True}), + "kv-shared-absent": (build_gemma4_kv_shared, {"vestigial": False}), + "kv-shared-present": (build_gemma4_kv_shared, {"vestigial": True}), +} + +if __name__ == "__main__": + os.makedirs(ROOT, exist_ok=True) + total = 0 + for name, (fn, kwargs) in FIXTURES.items(): + out = os.path.join(ROOT, name) + os.makedirs(out, exist_ok=True) + n = fn(out, **kwargs) + write_tokenizer(out) + size = sum(os.path.getsize(os.path.join(out, f)) for f in os.listdir(out)) + total += size + print(f" {name:<20} {n:>3} tensors {size/1024:>7.1f} KB") + print(f" {'total':<20} {'':>3} {total/1024:>7.1f} KB") diff --git a/tests/fixtures/dense/config.json b/tests/fixtures/dense/config.json new file mode 100644 index 00000000..5f3594c0 --- /dev/null +++ b/tests/fixtures/dense/config.json @@ -0,0 +1,16 @@ +{ + "model_type": "qwen2", + "architectures": [ + "Qwen2ForCausalLM" + ], + "vocab_size": 288, + "hidden_size": 64, + "intermediate_size": 128, + "num_hidden_layers": 2, + "num_attention_heads": 4, + "num_key_value_heads": 2, + "max_position_embeddings": 512, + "rms_norm_eps": 1e-06, + "rope_theta": 10000.0, + "tie_word_embeddings": false +} \ No newline at end of file diff --git a/tests/fixtures/dense/model.safetensors b/tests/fixtures/dense/model.safetensors new file mode 100644 index 00000000..6d98f758 Binary files /dev/null and b/tests/fixtures/dense/model.safetensors differ diff --git a/tests/fixtures/dense/tokenizer.json b/tests/fixtures/dense/tokenizer.json new file mode 100644 index 00000000..21c0e134 --- /dev/null +++ b/tests/fixtures/dense/tokenizer.json @@ -0,0 +1,380 @@ +{ + "version": "1.0", + "truncation": null, + "padding": null, + "added_tokens": [ + { + "id": 0, + "content": "<|endoftext|>", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + }, + { + "id": 1, + "content": "<|im_start|>", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + }, + { + "id": 2, + "content": "<|im_end|>", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + }, + { + "id": 3, + "content": "", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + } + ], + "normalizer": null, + "pre_tokenizer": { + "type": "ByteLevel", + "add_prefix_space": false, + "trim_offsets": true, + "use_regex": true + }, + "post_processor": { + "type": "ByteLevel", + "add_prefix_space": true, + "trim_offsets": false, + "use_regex": true + }, + "decoder": { + "type": "ByteLevel", + "add_prefix_space": true, + "trim_offsets": true, + "use_regex": true + }, + "model": { + "type": "BPE", + "dropout": null, + "unk_token": null, + "continuing_subword_prefix": null, + "end_of_word_suffix": null, + "fuse_unk": false, + "byte_fallback": false, + "ignore_merges": false, + "vocab": { + "<|endoftext|>": 0, + "<|im_start|>": 1, + "<|im_end|>": 2, + "": 3, + "!": 4, + "\"": 5, + "#": 6, + "$": 7, + "%": 8, + "&": 9, + "'": 10, + "(": 11, + ")": 12, + "*": 13, + "+": 14, + ",": 15, + "-": 16, + ".": 17, + "/": 18, + "0": 19, + "1": 20, + "2": 21, + "3": 22, + "4": 23, + "5": 24, + "6": 25, + "7": 26, + "8": 27, + "9": 28, + ":": 29, + ";": 30, + "<": 31, + "=": 32, + ">": 33, + "?": 34, + "@": 35, + "A": 36, + "B": 37, + "C": 38, + "D": 39, + "E": 40, + "F": 41, + "G": 42, + "H": 43, + "I": 44, + "J": 45, + "K": 46, + "L": 47, + "M": 48, + "N": 49, + "O": 50, + "P": 51, + "Q": 52, + "R": 53, + "S": 54, + "T": 55, + "U": 56, + "V": 57, + "W": 58, + "X": 59, + "Y": 60, + "Z": 61, + "[": 62, + "\\": 63, + "]": 64, + "^": 65, + "_": 66, + "`": 67, + "a": 68, + "b": 69, + "c": 70, + "d": 71, + "e": 72, + "f": 73, + "g": 74, + "h": 75, + "i": 76, + "j": 77, + "k": 78, + "l": 79, + "m": 80, + "n": 81, + "o": 82, + "p": 83, + "q": 84, + "r": 85, + "s": 86, + "t": 87, + "u": 88, + "v": 89, + "w": 90, + "x": 91, + "y": 92, + "z": 93, + "{": 94, + "|": 95, + "}": 96, + "~": 97, + "¡": 98, + "¢": 99, + "£": 100, + "¤": 101, + "¥": 102, + "¦": 103, + "§": 104, + "¨": 105, + "©": 106, + "ª": 107, + "«": 108, + "¬": 109, + "®": 110, + "¯": 111, + "°": 112, + "±": 113, + "²": 114, + "³": 115, + "´": 116, + "µ": 117, + "¶": 118, + "·": 119, + "¸": 120, + "¹": 121, + "º": 122, + "»": 123, + "¼": 124, + "½": 125, + "¾": 126, + "¿": 127, + "À": 128, + "Á": 129, + "Â": 130, + "Ã": 131, + "Ä": 132, + "Å": 133, + "Æ": 134, + "Ç": 135, + "È": 136, + "É": 137, + "Ê": 138, + "Ë": 139, + "Ì": 140, + "Í": 141, + "Î": 142, + "Ï": 143, + "Ð": 144, + "Ñ": 145, + "Ò": 146, + "Ó": 147, + "Ô": 148, + "Õ": 149, + "Ö": 150, + "×": 151, + "Ø": 152, + "Ù": 153, + "Ú": 154, + "Û": 155, + "Ü": 156, + "Ý": 157, + "Þ": 158, + "ß": 159, + "à": 160, + "á": 161, + "â": 162, + "ã": 163, + "ä": 164, + "å": 165, + "æ": 166, + "ç": 167, + "è": 168, + "é": 169, + "ê": 170, + "ë": 171, + "ì": 172, + "í": 173, + "î": 174, + "ï": 175, + "ð": 176, + "ñ": 177, + "ò": 178, + "ó": 179, + "ô": 180, + "õ": 181, + "ö": 182, + "÷": 183, + "ø": 184, + "ù": 185, + "ú": 186, + "û": 187, + "ü": 188, + "ý": 189, + "þ": 190, + "ÿ": 191, + "Ā": 192, + "ā": 193, + "Ă": 194, + "ă": 195, + "Ą": 196, + "ą": 197, + "Ć": 198, + "ć": 199, + "Ĉ": 200, + "ĉ": 201, + "Ċ": 202, + "ċ": 203, + "Č": 204, + "č": 205, + "Ď": 206, + "ď": 207, + "Đ": 208, + "đ": 209, + "Ē": 210, + "ē": 211, + "Ĕ": 212, + "ĕ": 213, + "Ė": 214, + "ė": 215, + "Ę": 216, + "ę": 217, + "Ě": 218, + "ě": 219, + "Ĝ": 220, + "ĝ": 221, + "Ğ": 222, + "ğ": 223, + "Ġ": 224, + "ġ": 225, + "Ģ": 226, + "ģ": 227, + "Ĥ": 228, + "ĥ": 229, + "Ħ": 230, + "ħ": 231, + "Ĩ": 232, + "ĩ": 233, + "Ī": 234, + "ī": 235, + "Ĭ": 236, + "ĭ": 237, + "Į": 238, + "į": 239, + "İ": 240, + "ı": 241, + "IJ": 242, + "ij": 243, + "Ĵ": 244, + "ĵ": 245, + "Ķ": 246, + "ķ": 247, + "ĸ": 248, + "Ĺ": 249, + "ĺ": 250, + "Ļ": 251, + "ļ": 252, + "Ľ": 253, + "ľ": 254, + "Ŀ": 255, + "ŀ": 256, + "Ł": 257, + "ł": 258, + "Ń": 259, + "he": 260, + "ll": 261, + "hell": 262, + "te": 263, + "<|unused264|>": 264, + "<|unused265|>": 265, + "<|unused266|>": 266, + "<|unused267|>": 267, + "<|unused268|>": 268, + "<|unused269|>": 269, + "<|unused270|>": 270, + "<|unused271|>": 271, + "<|unused272|>": 272, + "<|unused273|>": 273, + "<|unused274|>": 274, + "<|unused275|>": 275, + "<|unused276|>": 276, + "<|unused277|>": 277, + "<|unused278|>": 278, + "<|unused279|>": 279, + "<|unused280|>": 280, + "<|unused281|>": 281, + "<|unused282|>": 282, + "<|unused283|>": 283, + "<|unused284|>": 284, + "<|unused285|>": 285, + "<|unused286|>": 286, + "<|unused287|>": 287 + }, + "merges": [ + [ + "h", + "e" + ], + [ + "l", + "l" + ], + [ + "he", + "ll" + ], + [ + "t", + "e" + ] + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/dense/tokenizer_config.json b/tests/fixtures/dense/tokenizer_config.json new file mode 100644 index 00000000..ee42569a --- /dev/null +++ b/tests/fixtures/dense/tokenizer_config.json @@ -0,0 +1,8 @@ +{ + "tokenizer_class": "PreTrainedTokenizerFast", + "bos_token": "<|endoftext|>", + "eos_token": "<|im_end|>", + "pad_token": "", + "unk_token": "<|endoftext|>", + "chat_template": "{% for m in messages %}<|im_start|>{{ m['role'] }}\n{{ m['content'] }}<|im_end|>\n{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}" +} \ No newline at end of file diff --git a/tests/fixtures/kv-shared-absent/config.json b/tests/fixtures/kv-shared-absent/config.json new file mode 100644 index 00000000..78cfc80b --- /dev/null +++ b/tests/fixtures/kv-shared-absent/config.json @@ -0,0 +1,28 @@ +{ + "model_type": "gemma4_text", + "architectures": [ + "Gemma4ForCausalLM" + ], + "hidden_size": 64, + "num_hidden_layers": 4, + "intermediate_size": 128, + "num_attention_heads": 4, + "head_dim": 16, + "global_head_dim": 16, + "rms_norm_eps": 1e-06, + "vocab_size": 288, + "num_key_value_heads": 2, + "rope_traditional": false, + "rope_theta": 10000.0, + "sliding_window": 128, + "sliding_window_pattern": 1, + "max_position_embeddings": 512, + "num_kv_shared_layers": 2, + "use_double_wide_mlp": false, + "tie_word_embeddings": true, + "hidden_size_per_layer_input": 32, + "vocab_size_per_layer_input": 16, + "final_logit_softcapping": 30.0, + "enable_moe_block": false, + "attention_k_eq_v": false +} \ No newline at end of file diff --git a/tests/fixtures/kv-shared-absent/model.safetensors b/tests/fixtures/kv-shared-absent/model.safetensors new file mode 100644 index 00000000..6515c0f4 Binary files /dev/null and b/tests/fixtures/kv-shared-absent/model.safetensors differ diff --git a/tests/fixtures/kv-shared-absent/tokenizer.json b/tests/fixtures/kv-shared-absent/tokenizer.json new file mode 100644 index 00000000..21c0e134 --- /dev/null +++ b/tests/fixtures/kv-shared-absent/tokenizer.json @@ -0,0 +1,380 @@ +{ + "version": "1.0", + "truncation": null, + "padding": null, + "added_tokens": [ + { + "id": 0, + "content": "<|endoftext|>", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + }, + { + "id": 1, + "content": "<|im_start|>", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + }, + { + "id": 2, + "content": "<|im_end|>", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + }, + { + "id": 3, + "content": "", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + } + ], + "normalizer": null, + "pre_tokenizer": { + "type": "ByteLevel", + "add_prefix_space": false, + "trim_offsets": true, + "use_regex": true + }, + "post_processor": { + "type": "ByteLevel", + "add_prefix_space": true, + "trim_offsets": false, + "use_regex": true + }, + "decoder": { + "type": "ByteLevel", + "add_prefix_space": true, + "trim_offsets": true, + "use_regex": true + }, + "model": { + "type": "BPE", + "dropout": null, + "unk_token": null, + "continuing_subword_prefix": null, + "end_of_word_suffix": null, + "fuse_unk": false, + "byte_fallback": false, + "ignore_merges": false, + "vocab": { + "<|endoftext|>": 0, + "<|im_start|>": 1, + "<|im_end|>": 2, + "": 3, + "!": 4, + "\"": 5, + "#": 6, + "$": 7, + "%": 8, + "&": 9, + "'": 10, + "(": 11, + ")": 12, + "*": 13, + "+": 14, + ",": 15, + "-": 16, + ".": 17, + "/": 18, + "0": 19, + "1": 20, + "2": 21, + "3": 22, + "4": 23, + "5": 24, + "6": 25, + "7": 26, + "8": 27, + "9": 28, + ":": 29, + ";": 30, + "<": 31, + "=": 32, + ">": 33, + "?": 34, + "@": 35, + "A": 36, + "B": 37, + "C": 38, + "D": 39, + "E": 40, + "F": 41, + "G": 42, + "H": 43, + "I": 44, + "J": 45, + "K": 46, + "L": 47, + "M": 48, + "N": 49, + "O": 50, + "P": 51, + "Q": 52, + "R": 53, + "S": 54, + "T": 55, + "U": 56, + "V": 57, + "W": 58, + "X": 59, + "Y": 60, + "Z": 61, + "[": 62, + "\\": 63, + "]": 64, + "^": 65, + "_": 66, + "`": 67, + "a": 68, + "b": 69, + "c": 70, + "d": 71, + "e": 72, + "f": 73, + "g": 74, + "h": 75, + "i": 76, + "j": 77, + "k": 78, + "l": 79, + "m": 80, + "n": 81, + "o": 82, + "p": 83, + "q": 84, + "r": 85, + "s": 86, + "t": 87, + "u": 88, + "v": 89, + "w": 90, + "x": 91, + "y": 92, + "z": 93, + "{": 94, + "|": 95, + "}": 96, + "~": 97, + "¡": 98, + "¢": 99, + "£": 100, + "¤": 101, + "¥": 102, + "¦": 103, + "§": 104, + "¨": 105, + "©": 106, + "ª": 107, + "«": 108, + "¬": 109, + "®": 110, + "¯": 111, + "°": 112, + "±": 113, + "²": 114, + "³": 115, + "´": 116, + "µ": 117, + "¶": 118, + "·": 119, + "¸": 120, + "¹": 121, + "º": 122, + "»": 123, + "¼": 124, + "½": 125, + "¾": 126, + "¿": 127, + "À": 128, + "Á": 129, + "Â": 130, + "Ã": 131, + "Ä": 132, + "Å": 133, + "Æ": 134, + "Ç": 135, + "È": 136, + "É": 137, + "Ê": 138, + "Ë": 139, + "Ì": 140, + "Í": 141, + "Î": 142, + "Ï": 143, + "Ð": 144, + "Ñ": 145, + "Ò": 146, + "Ó": 147, + "Ô": 148, + "Õ": 149, + "Ö": 150, + "×": 151, + "Ø": 152, + "Ù": 153, + "Ú": 154, + "Û": 155, + "Ü": 156, + "Ý": 157, + "Þ": 158, + "ß": 159, + "à": 160, + "á": 161, + "â": 162, + "ã": 163, + "ä": 164, + "å": 165, + "æ": 166, + "ç": 167, + "è": 168, + "é": 169, + "ê": 170, + "ë": 171, + "ì": 172, + "í": 173, + "î": 174, + "ï": 175, + "ð": 176, + "ñ": 177, + "ò": 178, + "ó": 179, + "ô": 180, + "õ": 181, + "ö": 182, + "÷": 183, + "ø": 184, + "ù": 185, + "ú": 186, + "û": 187, + "ü": 188, + "ý": 189, + "þ": 190, + "ÿ": 191, + "Ā": 192, + "ā": 193, + "Ă": 194, + "ă": 195, + "Ą": 196, + "ą": 197, + "Ć": 198, + "ć": 199, + "Ĉ": 200, + "ĉ": 201, + "Ċ": 202, + "ċ": 203, + "Č": 204, + "č": 205, + "Ď": 206, + "ď": 207, + "Đ": 208, + "đ": 209, + "Ē": 210, + "ē": 211, + "Ĕ": 212, + "ĕ": 213, + "Ė": 214, + "ė": 215, + "Ę": 216, + "ę": 217, + "Ě": 218, + "ě": 219, + "Ĝ": 220, + "ĝ": 221, + "Ğ": 222, + "ğ": 223, + "Ġ": 224, + "ġ": 225, + "Ģ": 226, + "ģ": 227, + "Ĥ": 228, + "ĥ": 229, + "Ħ": 230, + "ħ": 231, + "Ĩ": 232, + "ĩ": 233, + "Ī": 234, + "ī": 235, + "Ĭ": 236, + "ĭ": 237, + "Į": 238, + "į": 239, + "İ": 240, + "ı": 241, + "IJ": 242, + "ij": 243, + "Ĵ": 244, + "ĵ": 245, + "Ķ": 246, + "ķ": 247, + "ĸ": 248, + "Ĺ": 249, + "ĺ": 250, + "Ļ": 251, + "ļ": 252, + "Ľ": 253, + "ľ": 254, + "Ŀ": 255, + "ŀ": 256, + "Ł": 257, + "ł": 258, + "Ń": 259, + "he": 260, + "ll": 261, + "hell": 262, + "te": 263, + "<|unused264|>": 264, + "<|unused265|>": 265, + "<|unused266|>": 266, + "<|unused267|>": 267, + "<|unused268|>": 268, + "<|unused269|>": 269, + "<|unused270|>": 270, + "<|unused271|>": 271, + "<|unused272|>": 272, + "<|unused273|>": 273, + "<|unused274|>": 274, + "<|unused275|>": 275, + "<|unused276|>": 276, + "<|unused277|>": 277, + "<|unused278|>": 278, + "<|unused279|>": 279, + "<|unused280|>": 280, + "<|unused281|>": 281, + "<|unused282|>": 282, + "<|unused283|>": 283, + "<|unused284|>": 284, + "<|unused285|>": 285, + "<|unused286|>": 286, + "<|unused287|>": 287 + }, + "merges": [ + [ + "h", + "e" + ], + [ + "l", + "l" + ], + [ + "he", + "ll" + ], + [ + "t", + "e" + ] + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/kv-shared-absent/tokenizer_config.json b/tests/fixtures/kv-shared-absent/tokenizer_config.json new file mode 100644 index 00000000..ee42569a --- /dev/null +++ b/tests/fixtures/kv-shared-absent/tokenizer_config.json @@ -0,0 +1,8 @@ +{ + "tokenizer_class": "PreTrainedTokenizerFast", + "bos_token": "<|endoftext|>", + "eos_token": "<|im_end|>", + "pad_token": "", + "unk_token": "<|endoftext|>", + "chat_template": "{% for m in messages %}<|im_start|>{{ m['role'] }}\n{{ m['content'] }}<|im_end|>\n{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}" +} \ No newline at end of file diff --git a/tests/fixtures/kv-shared-present/config.json b/tests/fixtures/kv-shared-present/config.json new file mode 100644 index 00000000..78cfc80b --- /dev/null +++ b/tests/fixtures/kv-shared-present/config.json @@ -0,0 +1,28 @@ +{ + "model_type": "gemma4_text", + "architectures": [ + "Gemma4ForCausalLM" + ], + "hidden_size": 64, + "num_hidden_layers": 4, + "intermediate_size": 128, + "num_attention_heads": 4, + "head_dim": 16, + "global_head_dim": 16, + "rms_norm_eps": 1e-06, + "vocab_size": 288, + "num_key_value_heads": 2, + "rope_traditional": false, + "rope_theta": 10000.0, + "sliding_window": 128, + "sliding_window_pattern": 1, + "max_position_embeddings": 512, + "num_kv_shared_layers": 2, + "use_double_wide_mlp": false, + "tie_word_embeddings": true, + "hidden_size_per_layer_input": 32, + "vocab_size_per_layer_input": 16, + "final_logit_softcapping": 30.0, + "enable_moe_block": false, + "attention_k_eq_v": false +} \ No newline at end of file diff --git a/tests/fixtures/kv-shared-present/model.safetensors b/tests/fixtures/kv-shared-present/model.safetensors new file mode 100644 index 00000000..ecab1a6e Binary files /dev/null and b/tests/fixtures/kv-shared-present/model.safetensors differ diff --git a/tests/fixtures/kv-shared-present/tokenizer.json b/tests/fixtures/kv-shared-present/tokenizer.json new file mode 100644 index 00000000..21c0e134 --- /dev/null +++ b/tests/fixtures/kv-shared-present/tokenizer.json @@ -0,0 +1,380 @@ +{ + "version": "1.0", + "truncation": null, + "padding": null, + "added_tokens": [ + { + "id": 0, + "content": "<|endoftext|>", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + }, + { + "id": 1, + "content": "<|im_start|>", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + }, + { + "id": 2, + "content": "<|im_end|>", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + }, + { + "id": 3, + "content": "", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + } + ], + "normalizer": null, + "pre_tokenizer": { + "type": "ByteLevel", + "add_prefix_space": false, + "trim_offsets": true, + "use_regex": true + }, + "post_processor": { + "type": "ByteLevel", + "add_prefix_space": true, + "trim_offsets": false, + "use_regex": true + }, + "decoder": { + "type": "ByteLevel", + "add_prefix_space": true, + "trim_offsets": true, + "use_regex": true + }, + "model": { + "type": "BPE", + "dropout": null, + "unk_token": null, + "continuing_subword_prefix": null, + "end_of_word_suffix": null, + "fuse_unk": false, + "byte_fallback": false, + "ignore_merges": false, + "vocab": { + "<|endoftext|>": 0, + "<|im_start|>": 1, + "<|im_end|>": 2, + "": 3, + "!": 4, + "\"": 5, + "#": 6, + "$": 7, + "%": 8, + "&": 9, + "'": 10, + "(": 11, + ")": 12, + "*": 13, + "+": 14, + ",": 15, + "-": 16, + ".": 17, + "/": 18, + "0": 19, + "1": 20, + "2": 21, + "3": 22, + "4": 23, + "5": 24, + "6": 25, + "7": 26, + "8": 27, + "9": 28, + ":": 29, + ";": 30, + "<": 31, + "=": 32, + ">": 33, + "?": 34, + "@": 35, + "A": 36, + "B": 37, + "C": 38, + "D": 39, + "E": 40, + "F": 41, + "G": 42, + "H": 43, + "I": 44, + "J": 45, + "K": 46, + "L": 47, + "M": 48, + "N": 49, + "O": 50, + "P": 51, + "Q": 52, + "R": 53, + "S": 54, + "T": 55, + "U": 56, + "V": 57, + "W": 58, + "X": 59, + "Y": 60, + "Z": 61, + "[": 62, + "\\": 63, + "]": 64, + "^": 65, + "_": 66, + "`": 67, + "a": 68, + "b": 69, + "c": 70, + "d": 71, + "e": 72, + "f": 73, + "g": 74, + "h": 75, + "i": 76, + "j": 77, + "k": 78, + "l": 79, + "m": 80, + "n": 81, + "o": 82, + "p": 83, + "q": 84, + "r": 85, + "s": 86, + "t": 87, + "u": 88, + "v": 89, + "w": 90, + "x": 91, + "y": 92, + "z": 93, + "{": 94, + "|": 95, + "}": 96, + "~": 97, + "¡": 98, + "¢": 99, + "£": 100, + "¤": 101, + "¥": 102, + "¦": 103, + "§": 104, + "¨": 105, + "©": 106, + "ª": 107, + "«": 108, + "¬": 109, + "®": 110, + "¯": 111, + "°": 112, + "±": 113, + "²": 114, + "³": 115, + "´": 116, + "µ": 117, + "¶": 118, + "·": 119, + "¸": 120, + "¹": 121, + "º": 122, + "»": 123, + "¼": 124, + "½": 125, + "¾": 126, + "¿": 127, + "À": 128, + "Á": 129, + "Â": 130, + "Ã": 131, + "Ä": 132, + "Å": 133, + "Æ": 134, + "Ç": 135, + "È": 136, + "É": 137, + "Ê": 138, + "Ë": 139, + "Ì": 140, + "Í": 141, + "Î": 142, + "Ï": 143, + "Ð": 144, + "Ñ": 145, + "Ò": 146, + "Ó": 147, + "Ô": 148, + "Õ": 149, + "Ö": 150, + "×": 151, + "Ø": 152, + "Ù": 153, + "Ú": 154, + "Û": 155, + "Ü": 156, + "Ý": 157, + "Þ": 158, + "ß": 159, + "à": 160, + "á": 161, + "â": 162, + "ã": 163, + "ä": 164, + "å": 165, + "æ": 166, + "ç": 167, + "è": 168, + "é": 169, + "ê": 170, + "ë": 171, + "ì": 172, + "í": 173, + "î": 174, + "ï": 175, + "ð": 176, + "ñ": 177, + "ò": 178, + "ó": 179, + "ô": 180, + "õ": 181, + "ö": 182, + "÷": 183, + "ø": 184, + "ù": 185, + "ú": 186, + "û": 187, + "ü": 188, + "ý": 189, + "þ": 190, + "ÿ": 191, + "Ā": 192, + "ā": 193, + "Ă": 194, + "ă": 195, + "Ą": 196, + "ą": 197, + "Ć": 198, + "ć": 199, + "Ĉ": 200, + "ĉ": 201, + "Ċ": 202, + "ċ": 203, + "Č": 204, + "č": 205, + "Ď": 206, + "ď": 207, + "Đ": 208, + "đ": 209, + "Ē": 210, + "ē": 211, + "Ĕ": 212, + "ĕ": 213, + "Ė": 214, + "ė": 215, + "Ę": 216, + "ę": 217, + "Ě": 218, + "ě": 219, + "Ĝ": 220, + "ĝ": 221, + "Ğ": 222, + "ğ": 223, + "Ġ": 224, + "ġ": 225, + "Ģ": 226, + "ģ": 227, + "Ĥ": 228, + "ĥ": 229, + "Ħ": 230, + "ħ": 231, + "Ĩ": 232, + "ĩ": 233, + "Ī": 234, + "ī": 235, + "Ĭ": 236, + "ĭ": 237, + "Į": 238, + "į": 239, + "İ": 240, + "ı": 241, + "IJ": 242, + "ij": 243, + "Ĵ": 244, + "ĵ": 245, + "Ķ": 246, + "ķ": 247, + "ĸ": 248, + "Ĺ": 249, + "ĺ": 250, + "Ļ": 251, + "ļ": 252, + "Ľ": 253, + "ľ": 254, + "Ŀ": 255, + "ŀ": 256, + "Ł": 257, + "ł": 258, + "Ń": 259, + "he": 260, + "ll": 261, + "hell": 262, + "te": 263, + "<|unused264|>": 264, + "<|unused265|>": 265, + "<|unused266|>": 266, + "<|unused267|>": 267, + "<|unused268|>": 268, + "<|unused269|>": 269, + "<|unused270|>": 270, + "<|unused271|>": 271, + "<|unused272|>": 272, + "<|unused273|>": 273, + "<|unused274|>": 274, + "<|unused275|>": 275, + "<|unused276|>": 276, + "<|unused277|>": 277, + "<|unused278|>": 278, + "<|unused279|>": 279, + "<|unused280|>": 280, + "<|unused281|>": 281, + "<|unused282|>": 282, + "<|unused283|>": 283, + "<|unused284|>": 284, + "<|unused285|>": 285, + "<|unused286|>": 286, + "<|unused287|>": 287 + }, + "merges": [ + [ + "h", + "e" + ], + [ + "l", + "l" + ], + [ + "he", + "ll" + ], + [ + "t", + "e" + ] + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/kv-shared-present/tokenizer_config.json b/tests/fixtures/kv-shared-present/tokenizer_config.json new file mode 100644 index 00000000..ee42569a --- /dev/null +++ b/tests/fixtures/kv-shared-present/tokenizer_config.json @@ -0,0 +1,8 @@ +{ + "tokenizer_class": "PreTrainedTokenizerFast", + "bos_token": "<|endoftext|>", + "eos_token": "<|im_end|>", + "pad_token": "", + "unk_token": "<|endoftext|>", + "chat_template": "{% for m in messages %}<|im_start|>{{ m['role'] }}\n{{ m['content'] }}<|im_end|>\n{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}" +} \ No newline at end of file diff --git a/tests/fixtures/stray-shard/config.json b/tests/fixtures/stray-shard/config.json new file mode 100644 index 00000000..5f3594c0 --- /dev/null +++ b/tests/fixtures/stray-shard/config.json @@ -0,0 +1,16 @@ +{ + "model_type": "qwen2", + "architectures": [ + "Qwen2ForCausalLM" + ], + "vocab_size": 288, + "hidden_size": 64, + "intermediate_size": 128, + "num_hidden_layers": 2, + "num_attention_heads": 4, + "num_key_value_heads": 2, + "max_position_embeddings": 512, + "rms_norm_eps": 1e-06, + "rope_theta": 10000.0, + "tie_word_embeddings": false +} \ No newline at end of file diff --git a/tests/fixtures/stray-shard/extra-not-in-index.safetensors b/tests/fixtures/stray-shard/extra-not-in-index.safetensors new file mode 100644 index 00000000..78bbfc4a Binary files /dev/null and b/tests/fixtures/stray-shard/extra-not-in-index.safetensors differ diff --git a/tests/fixtures/stray-shard/model.safetensors b/tests/fixtures/stray-shard/model.safetensors new file mode 100644 index 00000000..6d98f758 Binary files /dev/null and b/tests/fixtures/stray-shard/model.safetensors differ diff --git a/tests/fixtures/stray-shard/model.safetensors.index.json b/tests/fixtures/stray-shard/model.safetensors.index.json new file mode 100644 index 00000000..b9ffbbec --- /dev/null +++ b/tests/fixtures/stray-shard/model.safetensors.index.json @@ -0,0 +1 @@ +{"metadata": {"total_size": 0}, "weight_map": {"model.embed_tokens.weight": "model.safetensors", "model.norm.weight": "model.safetensors", "lm_head.weight": "model.safetensors", "model.layers.0.self_attn.q_proj.weight": "model.safetensors", "model.layers.0.self_attn.q_proj.bias": "model.safetensors", "model.layers.0.self_attn.k_proj.weight": "model.safetensors", "model.layers.0.self_attn.k_proj.bias": "model.safetensors", "model.layers.0.self_attn.v_proj.weight": "model.safetensors", "model.layers.0.self_attn.v_proj.bias": "model.safetensors", "model.layers.0.self_attn.o_proj.weight": "model.safetensors", "model.layers.0.mlp.gate_proj.weight": "model.safetensors", "model.layers.0.mlp.up_proj.weight": "model.safetensors", "model.layers.0.mlp.down_proj.weight": "model.safetensors", "model.layers.0.input_layernorm.weight": "model.safetensors", "model.layers.0.post_attention_layernorm.weight": "model.safetensors", "model.layers.1.self_attn.q_proj.weight": "model.safetensors", "model.layers.1.self_attn.q_proj.bias": "model.safetensors", "model.layers.1.self_attn.k_proj.weight": "model.safetensors", "model.layers.1.self_attn.k_proj.bias": "model.safetensors", "model.layers.1.self_attn.v_proj.weight": "model.safetensors", "model.layers.1.self_attn.v_proj.bias": "model.safetensors", "model.layers.1.self_attn.o_proj.weight": "model.safetensors", "model.layers.1.mlp.gate_proj.weight": "model.safetensors", "model.layers.1.mlp.up_proj.weight": "model.safetensors", "model.layers.1.mlp.down_proj.weight": "model.safetensors", "model.layers.1.input_layernorm.weight": "model.safetensors", "model.layers.1.post_attention_layernorm.weight": "model.safetensors"}} \ No newline at end of file diff --git a/tests/fixtures/stray-shard/tokenizer.json b/tests/fixtures/stray-shard/tokenizer.json new file mode 100644 index 00000000..21c0e134 --- /dev/null +++ b/tests/fixtures/stray-shard/tokenizer.json @@ -0,0 +1,380 @@ +{ + "version": "1.0", + "truncation": null, + "padding": null, + "added_tokens": [ + { + "id": 0, + "content": "<|endoftext|>", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + }, + { + "id": 1, + "content": "<|im_start|>", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + }, + { + "id": 2, + "content": "<|im_end|>", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + }, + { + "id": 3, + "content": "", + "single_word": false, + "lstrip": false, + "rstrip": false, + "normalized": false, + "special": true + } + ], + "normalizer": null, + "pre_tokenizer": { + "type": "ByteLevel", + "add_prefix_space": false, + "trim_offsets": true, + "use_regex": true + }, + "post_processor": { + "type": "ByteLevel", + "add_prefix_space": true, + "trim_offsets": false, + "use_regex": true + }, + "decoder": { + "type": "ByteLevel", + "add_prefix_space": true, + "trim_offsets": true, + "use_regex": true + }, + "model": { + "type": "BPE", + "dropout": null, + "unk_token": null, + "continuing_subword_prefix": null, + "end_of_word_suffix": null, + "fuse_unk": false, + "byte_fallback": false, + "ignore_merges": false, + "vocab": { + "<|endoftext|>": 0, + "<|im_start|>": 1, + "<|im_end|>": 2, + "": 3, + "!": 4, + "\"": 5, + "#": 6, + "$": 7, + "%": 8, + "&": 9, + "'": 10, + "(": 11, + ")": 12, + "*": 13, + "+": 14, + ",": 15, + "-": 16, + ".": 17, + "/": 18, + "0": 19, + "1": 20, + "2": 21, + "3": 22, + "4": 23, + "5": 24, + "6": 25, + "7": 26, + "8": 27, + "9": 28, + ":": 29, + ";": 30, + "<": 31, + "=": 32, + ">": 33, + "?": 34, + "@": 35, + "A": 36, + "B": 37, + "C": 38, + "D": 39, + "E": 40, + "F": 41, + "G": 42, + "H": 43, + "I": 44, + "J": 45, + "K": 46, + "L": 47, + "M": 48, + "N": 49, + "O": 50, + "P": 51, + "Q": 52, + "R": 53, + "S": 54, + "T": 55, + "U": 56, + "V": 57, + "W": 58, + "X": 59, + "Y": 60, + "Z": 61, + "[": 62, + "\\": 63, + "]": 64, + "^": 65, + "_": 66, + "`": 67, + "a": 68, + "b": 69, + "c": 70, + "d": 71, + "e": 72, + "f": 73, + "g": 74, + "h": 75, + "i": 76, + "j": 77, + "k": 78, + "l": 79, + "m": 80, + "n": 81, + "o": 82, + "p": 83, + "q": 84, + "r": 85, + "s": 86, + "t": 87, + "u": 88, + "v": 89, + "w": 90, + "x": 91, + "y": 92, + "z": 93, + "{": 94, + "|": 95, + "}": 96, + "~": 97, + "¡": 98, + "¢": 99, + "£": 100, + "¤": 101, + "¥": 102, + "¦": 103, + "§": 104, + "¨": 105, + "©": 106, + "ª": 107, + "«": 108, + "¬": 109, + "®": 110, + "¯": 111, + "°": 112, + "±": 113, + "²": 114, + "³": 115, + "´": 116, + "µ": 117, + "¶": 118, + "·": 119, + "¸": 120, + "¹": 121, + "º": 122, + "»": 123, + "¼": 124, + "½": 125, + "¾": 126, + "¿": 127, + "À": 128, + "Á": 129, + "Â": 130, + "Ã": 131, + "Ä": 132, + "Å": 133, + "Æ": 134, + "Ç": 135, + "È": 136, + "É": 137, + "Ê": 138, + "Ë": 139, + "Ì": 140, + "Í": 141, + "Î": 142, + "Ï": 143, + "Ð": 144, + "Ñ": 145, + "Ò": 146, + "Ó": 147, + "Ô": 148, + "Õ": 149, + "Ö": 150, + "×": 151, + "Ø": 152, + "Ù": 153, + "Ú": 154, + "Û": 155, + "Ü": 156, + "Ý": 157, + "Þ": 158, + "ß": 159, + "à": 160, + "á": 161, + "â": 162, + "ã": 163, + "ä": 164, + "å": 165, + "æ": 166, + "ç": 167, + "è": 168, + "é": 169, + "ê": 170, + "ë": 171, + "ì": 172, + "í": 173, + "î": 174, + "ï": 175, + "ð": 176, + "ñ": 177, + "ò": 178, + "ó": 179, + "ô": 180, + "õ": 181, + "ö": 182, + "÷": 183, + "ø": 184, + "ù": 185, + "ú": 186, + "û": 187, + "ü": 188, + "ý": 189, + "þ": 190, + "ÿ": 191, + "Ā": 192, + "ā": 193, + "Ă": 194, + "ă": 195, + "Ą": 196, + "ą": 197, + "Ć": 198, + "ć": 199, + "Ĉ": 200, + "ĉ": 201, + "Ċ": 202, + "ċ": 203, + "Č": 204, + "č": 205, + "Ď": 206, + "ď": 207, + "Đ": 208, + "đ": 209, + "Ē": 210, + "ē": 211, + "Ĕ": 212, + "ĕ": 213, + "Ė": 214, + "ė": 215, + "Ę": 216, + "ę": 217, + "Ě": 218, + "ě": 219, + "Ĝ": 220, + "ĝ": 221, + "Ğ": 222, + "ğ": 223, + "Ġ": 224, + "ġ": 225, + "Ģ": 226, + "ģ": 227, + "Ĥ": 228, + "ĥ": 229, + "Ħ": 230, + "ħ": 231, + "Ĩ": 232, + "ĩ": 233, + "Ī": 234, + "ī": 235, + "Ĭ": 236, + "ĭ": 237, + "Į": 238, + "į": 239, + "İ": 240, + "ı": 241, + "IJ": 242, + "ij": 243, + "Ĵ": 244, + "ĵ": 245, + "Ķ": 246, + "ķ": 247, + "ĸ": 248, + "Ĺ": 249, + "ĺ": 250, + "Ļ": 251, + "ļ": 252, + "Ľ": 253, + "ľ": 254, + "Ŀ": 255, + "ŀ": 256, + "Ł": 257, + "ł": 258, + "Ń": 259, + "he": 260, + "ll": 261, + "hell": 262, + "te": 263, + "<|unused264|>": 264, + "<|unused265|>": 265, + "<|unused266|>": 266, + "<|unused267|>": 267, + "<|unused268|>": 268, + "<|unused269|>": 269, + "<|unused270|>": 270, + "<|unused271|>": 271, + "<|unused272|>": 272, + "<|unused273|>": 273, + "<|unused274|>": 274, + "<|unused275|>": 275, + "<|unused276|>": 276, + "<|unused277|>": 277, + "<|unused278|>": 278, + "<|unused279|>": 279, + "<|unused280|>": 280, + "<|unused281|>": 281, + "<|unused282|>": 282, + "<|unused283|>": 283, + "<|unused284|>": 284, + "<|unused285|>": 285, + "<|unused286|>": 286, + "<|unused287|>": 287 + }, + "merges": [ + [ + "h", + "e" + ], + [ + "l", + "l" + ], + [ + "he", + "ll" + ], + [ + "t", + "e" + ] + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/stray-shard/tokenizer_config.json b/tests/fixtures/stray-shard/tokenizer_config.json new file mode 100644 index 00000000..ee42569a --- /dev/null +++ b/tests/fixtures/stray-shard/tokenizer_config.json @@ -0,0 +1,8 @@ +{ + "tokenizer_class": "PreTrainedTokenizerFast", + "bos_token": "<|endoftext|>", + "eos_token": "<|im_end|>", + "pad_token": "", + "unk_token": "<|endoftext|>", + "chat_template": "{% for m in messages %}<|im_start|>{{ m['role'] }}\n{{ m['content'] }}<|im_end|>\n{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}" +} \ No newline at end of file diff --git a/tests/test-fixtures.sh b/tests/test-fixtures.sh new file mode 100755 index 00000000..230fa3a0 --- /dev/null +++ b/tests/test-fixtures.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# test-fixtures.sh — load every synthetic checkpoint shape and generate a token. +# +# These fixtures are a few hundred kilobytes each and live in the repository, so this +# needs no download, no model cache, and no network. See scripts/make-test-fixtures.py +# for why they exist and what they do and do not cover. +# +# Each shape corresponds to a defect that reached users: +# dense baseline — nothing special, catches gross breakage +# stray-shard #118: a .safetensors beside the index but absent from it +# kv-shared-absent #120: gemma-4-e4b shape, shared layers ship no k/v +# kv-shared-present b674: gemma-4-e2b shape, shared layers ship k/v anyway +# +# The output is gibberish by construction — the weights are random. A fixture passes +# when the server loads it and produces *a* token, which is what exercises config +# parsing, weight-key resolution, sanitisation and layer materialisation. +# +# Usage: ./tests/test-fixtures.sh [binary] [port] + +set -uo pipefail + +BINARY="${1:-.build/release/SwiftLM}" +PORT="${2:-15460}" +HOST="127.0.0.1" +FIXTURE_DIR="$(cd "$(dirname "$0")" && pwd)/fixtures" + +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' +PASS=0; FAIL=0 + +log() { echo -e "${YELLOW}[fixtures]${NC} $*"; } +pass() { PASS=$((PASS + 1)); echo -e " ${GREEN}✅ PASS${NC}: $*"; } +fail() { FAIL=$((FAIL + 1)); echo -e " ${RED}❌ FAIL${NC}: $*"; } + +SERVER_PID="" +cleanup() { [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null; SERVER_PID=""; } +trap cleanup EXIT + +run_fixture() { + local name="$1" + local dir="$FIXTURE_DIR/$name" + local url="http://$HOST:$PORT" + local logfile="/tmp/SwiftLM-test-fixture-$name.log" + + if [ ! -d "$dir" ]; then + fail "$name: fixture directory missing — run scripts/make-test-fixtures.py" + return + fi + + "$BINARY" --model "$dir" --port "$PORT" --host "$HOST" > "$logfile" 2>&1 & + SERVER_PID=$! + + local ready=0 + for _ in $(seq 1 60); do + if curl -sf "$url/health" >/dev/null 2>&1; then ready=1; break; fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then break; fi + sleep 1 + done + + if [ "$ready" -ne 1 ]; then + fail "$name: server did not start — $(grep -m1 -E '^Error|Fatal' "$logfile" || echo 'see '"$logfile")" + cleanup + return + fi + + local body + body=$(curl -sf --max-time 60 "$url/v1/chat/completions" \ + -H 'Content-Type: application/json' \ + -d '{"messages":[{"role":"user","content":"ping"}],"max_tokens":4,"stream":false}' 2>/dev/null) + + # Random weights make the text meaningless, so assert on the token count instead. + if [ -n "$body" ] && echo "$body" | python3 -c ' +import json, sys +d = json.load(sys.stdin) +sys.exit(0 if d["usage"]["completion_tokens"] >= 1 else 1)' 2>/dev/null; then + pass "$name loaded and generated" + else + fail "$name: no completion — $(echo "$body" | head -c 120)" + fi + + cleanup + sleep 1 +} + +log "Binary: $BINARY" +for name in dense stray-shard kv-shared-absent kv-shared-present; do + log "Shape: $name" + run_fixture "$name" +done + +log "═══════════════════════════════════════" +log "Results: $PASS passed, $FAIL failed" +log "═══════════════════════════════════════" +[ "$FAIL" -eq 0 ] diff --git a/tests/test-vision.sh b/tests/test-vision.sh index c33276d0..13e0f39a 100755 --- a/tests/test-vision.sh +++ b/tests/test-vision.sh @@ -89,8 +89,37 @@ mkdir -p /tmp/vision_test # 28x28 black PNG (requires multiple of 28 for Qwen2-VL patch embedder) BASE64_IMG="iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAIAAAD9b0jDAAAAGUlEQVR4nO3BMQEAAADCoPVPbQdvoAAA6DQJTAABRMAOLAAAAABJRU5ErkJggg==" +# A model id names a moving target. On 2026-08-12 LiquidAI republished +# LFM2.5-VL-450M-MLX-4bit with a chat template one brace short of valid — +# `{- bos_token -}}` where the previous revision had `{{- bos_token -}}` — so every +# request became `parser('Unexpected token type: closeExpression')`, HTTP 500, and main +# went red five minutes later with nothing changed on our side. Restoring that single +# brace locally makes the same revision answer normally, so the fault is upstream, not +# a compatibility gap worth chasing here. +# +# Pinning is what stops a third party's afternoon from deciding whether this repository +# has a green build. Re-point it deliberately, when someone means to test a newer +# revision, and treat the failure that follows as a real result. +LFM_REPO="LiquidAI/LFM2.5-VL-450M-MLX-4bit" +LFM_REVISION="10ce3604e42cd595497c47aaf67b7890e1e2a3b4" + +# Resolve to the pinned snapshot on disk. Falling back to the bare repo id keeps a local +# `./tests/test-vision.sh` working without a prefetch, but says so — a run that quietly +# tested a different revision than CI did is worse than one that took a moment longer. +pinned_snapshot() { + local repo="$1" revision="$2" + local hub="${HF_HUB_CACHE:-${HF_HOME:-$HOME/.cache/huggingface}/hub}" + local dir="$hub/models--${repo//\//--}/snapshots/$revision" + if [ -d "$dir" ]; then + echo "$dir" + else + log "note: pinned revision ${revision:0:7} of $repo is not in the cache; using the floating id" + echo "$repo" + fi +} + run_case "mlx-community/Qwen2-VL-2B-Instruct-4bit" "$BASE_PORT" "yes" -run_case "LiquidAI/LFM2.5-VL-450M-MLX-4bit" "$((BASE_PORT + 1))" "yes" +run_case "$(pinned_snapshot "$LFM_REPO" "$LFM_REVISION")" "$((BASE_PORT + 1))" "yes" rm -rf /tmp/vision_test exit 0