Commits
Activate ZAYA tool-aware template from source_model.architecture
Guard split-subscript traps across all SSM/hybrid mixers
Default-on gen-suffix-stripped SSM cache reuse for hybrid models
The gen-suffix-stripped store's correctness comment referenced
osaurusagent-35b, which has been deleted. Re-proved cache-ON == cache-OFF
byte-identically (temp=0, fresh disk cache, differential ground-truth) on
qwen-agentworld-35b-a3b MXFP8 and qwen3.6-35b-a3b MXFP8 (GatedDeltaNet MoE),
nemotron-omni-nano (Mamba-2); correctly inert on dense gemma-4-e2b.
Comment-only; no behavior change.
`shouldUseZayaToolAwareTemplate` / `shouldUseZayaVLToolAwareTemplate` gated
solely on `capabilities.family` being `zaya1*`. Bundles that stamp only
`source_model.architecture = zaya1_vl` and omit `capabilities.family` (e.g.
ZAYA1-VL JANGTQ) therefore fell through: the tool-aware template never
materialized, the bare role-only chat template was used, and the model —
given no tool-call format — leaked raw `<tool_call><function>...` XML as
visible assistant text (the parser expects `<zyphra_tool_call>`, so nothing
matched).
The gate's own doc comment says the check is meant to be "data-driven, not
family-name driven." Honor that: recognize ZAYA from `capabilities.family`
OR `source_model.architecture`, keeping the parser / think_in_template /
supports_tools contract unchanged.
Additive and low-risk: bundles that already set `capabilities.family` are
unaffected (family is still checked first); non-ZAYA bundles never match the
`zaya1*` idents. Verified live: with the fix, ZAYA1-VL-8B-JANGTQ4 activates
the tool-aware template (emits `<zyphra_tool_response>` and passes single
tool-call cases) instead of leaking the hallucinated `<response><tool_call>`
format. (The JANGTQ4 quant is still too weak to follow the format on every
multi-step turn — a separate quantization issue, not this template gate.)
PR #123 replaced the process-aborting `split(...)[i]` traps with count
guards in NemotronH, but the identical pattern — a `split` that records an
MLX error and returns an empty vector, then gets subscripted, trapping the
process before the recorded error can surface — survives untouched in every
sibling mixer. Complete the sweep with the same guard/bail idiom (return the
input so the enclosing withError scope throws the real diagnostic at exit;
the guard only trips when split already recorded an error).
15 guards across 8 files:
- Qwen35.swift — GatedDeltaNet conv split (q/k/v) + query/gate split.
Backs every qwen3.5 / 3.6 / Ornith / osaurusagent / Holo3 model.
- LFM2.swift / LFM2MoE.swift — B/C/x in_proj split (LFM2.5 Top Pick).
- BailingMoe.swift — q/k/v split (Ling-2.6 Flash).
- FalconH1.swift — in_proj + conv split.
- GraniteMoeHybrid.swift — in_proj + conv split.
- Qwen3Next.swift — query/gate, conv, qkvz (4-way), and ba splits.
- Jamba.swift — ssmStep deltaBC + processSequence xz splits.
Defensive-depth only — these fire solely on a malformed / quant-mismatched
checkpoint (a split whose width disagrees with the config). Correctly
converted bundles never hit them. Builds clean.
Hybrid-SSM chat models (Qwen3.5/3.6 & Ornith GatedDeltaNet, Nemotron-H
Mamba-2, ZAYA CCA, LFM2) never reused prefill across growing chat turns:
the stored prompt boundary ends in the chat template's generation-prompt
suffix (`<|im_start|>assistant\n`, …), which the next turn replaces with
the assistant reply + following user turn, so the full-prompt key never
matches as a prefix. Every turn recomputed the entire context.
Fix: after generation, also store the *generation-prompt-stripped* prompt
— everything before the final turn-start token — which the next turn DOES
contain as an exact prefix. Clean SSM/GatedDeltaNet state at that position
comes from the re-derive path (enableSSMReDerive), not the live
post-generation state (ahead by the gen suffix). KV is exact-sliceable.
Wired identically across all three generation paths: solo TokenIterator
(Evaluate), MTP speculation (NativeMTPTokenIterator), and BatchEngine.
Default ON for hybrid models (isHybrid), disable with
VMLX_HYBRID_STRIPPED_STORE=0. Text-only (skips on hasMediaContent so VL
turns are unaffected). The re-derive runs after the response is delivered,
so it never adds to the current turn's TTFT or token rate. Disk-restore
now restores SSM state for GatedDeltaNet ArraysCache (slotCount-based, so
a fresh all-nil cache receives the restored state) even at fmtV>=2.
Supporting plumbing: genPromptSuffixTokens computed at load by diffing the
chat template with/without add_generation_prompt (ModelContainer), threaded
through CacheCoordinator; boundary snapshots gain an allowDiskBackedRederive
/ forceRederive path that bypasses the path-dependent-cache skip guard
(safe post evalLock).
Proven live cache-ON == cache-OFF coherent on osaurusagent-35b-a3b
(GatedDeltaNet MoE, ssm=60) and Qwen3.6-27B-MXFP8-CRACK-MTP (D3
speculation, ssm=96): growing turns HIT the stripped boundary with SSM
restored and stay coherent. Dense / sliding-window models are excluded
(they already reuse via the post-answer boundary).
Fix production crash traps: Qwen3VL position ids, error-array subscripts, compiled-closure failures
Correct compiled-decode 'minimal impact' comment with measured data
The prior comment claimed disabling compiled decode had 'minimal' impact
(small GELU/SwiGLU/softcap fusions). That is wrong: compile(shapeless:)
fuses the whole single-slot decode step, and local benchmarks show a
substantial decode-throughput gain from enabling it. State the effect as
a ratio range rather than a single absolute, since RunBench direct decode
and the full server path differ substantially in absolute tok/s. Keep the
gate off by default — it is disabled for #1173 model-switch safety, not
because it is cheap.
* Extract nested-XML tool args for ZAYA rows
Some ZAYA rows emit a fully nested XML tool body —
<function>file_write
<parameter><name>path</name><value>out.txt</value></parameter>
— instead of the attribute form <function=name><parameter=key>value</parameter>
this parser was written for. The attribute scan finds no <parameter=, so every
argument is dropped: live file_write parses but 'path' is reported missing
(invalid_tool_arguments), and the <function>name (no =) variant fails the
function regex entirely and leaks the raw envelope as visible text.
Add a nested-form parse path, gated on the distinctive <name>/<value> markers so
the attribute path is left completely untouched (no regression). It reuses the
same value post-processing and schema validation, and handles both <function>name
and <function=name> openers.
ZayaNestedToolArgTests: red without the fix (path -> nil), green with it, plus
attribute-style and plain-prose regression guards.
* Test zaya nested-XML tool-arg extraction (values, closed-tag, no-fabrication)
Locks the live-captured JANGTQ4 wire shapes the nested parser handles:
- interleaved <type> tag between <name> and <value>
- closed <function>name</function> opener (tool_choice:required capture)
- names/types with NO <value> must not fabricate args (auto-mode skeleton)
---------
Co-authored-by: Eric <eric@erics-m5-max2.tailcd5195.ts.net>
Stream incremental tool-call envelope progress (Generation.toolCallProgress)
Local models that write files through a tool produce long silent gaps in
streaming consumers: while the model generates a tool-call envelope, the
tool-call processor correctly buffers it (so a partial envelope never leaks
into visible text) and the stream emits nothing until the parsed call is
complete. For a large file write that is tens of seconds with no events, so a
UI cannot preview the code as it streams.
Add a scoped progress channel:
- Generation.toolCallProgress(String): raw envelope-text deltas emitted while
a committed tool call is being collected. The complete parsed call still
arrives as a single .toolCall when the envelope closes, so .toolCall remains
the only actionable tool event; consumers with a default branch are
unaffected (additive case).
- ToolCallProcessor.collectingToolCallText: the raw buffer of the call being
collected. Deliberately scoped — nil in .potentialToolCall (an ambiguous
prefix that may still revert to plain text and be re-emitted as a visible
chunk) and nil for strip-only processors (whose parsed call is discarded, so
a progress delta would never be followed by a .toolCall). It is not a general
text tap.
- routeGenerationText diffs the collecting buffer around each processed chunk,
so every streaming path (token loop, BatchEngine, SpecDec) gains progress
events with no per-family parser change; a call that completes or reverts
within a chunk reports no growth.
Adds ToolCallProgressRoutingTests (multi-chunk envelope -> ordered deltas then
one call, plain prose -> no progress, strip-only -> no progress) and wires it
into the focused-tests source list.
NormConventionResolver: unrecognized norm_convention falls back to the vote
Serialize GPU-stream drivers + model-code guards (fix Metal concurrency crash class)
The Qwen3.6 loader-propagation test still asserted the pre-refactor helper
usesQwenPlusOneNormConvention, which 18f803c9 replaced with the shared
deterministic NormConventionResolver.shouldApplyPlusOneShift. The assertion
has been failing since that refactor merged. Repoint it at the current symbol
so the test again pins the real metadata->shift wiring.
The resolver treated ANY declared norm_convention string as authoritative
(`return usesPlusOne(explicit)`), but only three exact markers map to the
(1+weight) shift — so any other non-empty value (a converter typo, or a
descriptive string like "rms_norm") silently returned no-shift. A raw/JangQ
qwen3_5 bundle carrying such a declaration would load with its RMSNorm weights
unshifted (mean ~0, never lifted to ~1) and produce degraded/garbage output.
Only a RECOGNIZED (1+weight) marker is now authoritative; a present-but-
unrecognized declaration defers to the order-independent majority vote, which
reads the bundle's actual storage state. Adds NormConventionResolverTests
covering the recognized-marker, no-declaration, unrecognized-declaration, and
mixed-precedence cases.
Fixes an empty-array crash (Sentry 1A residual): when input.image.pixels is
present but the bundle loaded neither vision_tower nor vision_embedder
(text/audio-only Gemma4 or a partial checkpoint), the per-image loop appended
nothing and featuresList[0] / concatenated([]) at the scatter step crashed.
Guard with a typed VLMError.processing instead of aborting.
MLX is not thread-safe: mlx_eval/mlx_async_eval run gpu::eval inline on the
calling thread, encoding into and committing the shared Metal command buffer,
and the C++ stream mutex only guards the stream-map lookup (not the encoder).
Since evalLock was dropped from the eval hot path, a request's prefill
async_eval could run concurrently with a teardown/unload Stream.synchronize()
on the same command buffer.
Live-reproduced SIGABRT (disconnect-during-cold-load + retry): 'Completed
handler provided after commit' in addCompletedHandler, and AGX 'command
encoder is already encoding'. Two threads: unload->Stream.gpu.synchronize()
committing the buffer while startSoloFastPath->TokenIterator.prepare->async_eval
added a completion handler to it.
Fix: hold evalLock across the brief CPU-side encode+commit in eval/asyncEval/
MLXArray.eval (Stream.synchronize/clearCache already take it). The GPU executes
asynchronously after the lock releases, so CPU->GPU pipeline parallelism is
preserved; the lock is uncontended in steady-state single-producer decode and
only serializes during teardown/cancel/cross-model.
Also guard compiled activations (safeGeluApproximate/compiledSwiGLU/compiledGeGLU)
with CompiledDecodeTrace.isActive to avoid an illegal nested compile inside the
compiled-decode trace, and drain the stream on generateLoopTask cancel/error
early-exits.
Validated live: disconnect repro 35/35 clean (was crash at iter 1-2), concurrent
GPU 15/15 clean, decode 76.6 tok/s (no regression), multiturn coherent.
Live ZAYA / Gemma-4 AppleScript rows emit stray
</parameter></function></zyphra_tool_call> runs with no matching opener
after several agent-loop steps (MODEL_ISSUES_TRIAGE Issue 3). The
tool-call state machine only enters collection on an OPEN tag, so those
protocol closers streamed to the user as literal text.
Adds an opt-in orphanStripTags registry to ToolCallParser (default
empty, no behavior change for other formats) and teaches
ToolCallProcessor to strip registered orphan closers — including runs,
chunk-split partials, and closers embedded mid-buffer — while
unregistered tag-looking prose like </div> still flushes verbatim.
XMLFunctionParser and Gemma4ToolCallParser register their closers.
Live ZAYA / Gemma-4 AppleScript rows emit stray
</parameter></function></zyphra_tool_call> runs with no matching opener
after several agent-loop steps (MODEL_ISSUES_TRIAGE Issue 3). The
tool-call state machine only enters collection on an OPEN tag, so those
protocol closers streamed to the user as literal text.
Adds an opt-in orphanStripTags registry to ToolCallParser (default
empty, no behavior change for other formats) and teaches
ToolCallProcessor to strip registered orphan closers — including runs,
chunk-split partials, and closers embedded mid-buffer — while
unregistered tag-looking prose like </div> still flushes verbatim.
XMLFunctionParser and Gemma4ToolCallParser register their closers.
Serialize disk-cache restore evals under MLXCacheIOLock
The disk-tier restore (TQDiskSerializer .item() reads, asType
conversions, and the materializing MLX.eval of the restored cache) ran
outside MLXCacheIOLock in both restore paths (TokenIterator.init and
BatchEngine's slot prefill). Serving layers tokenize the NEXT request's
input under that lock while a producer restores; unserialized, the two
encode concurrently on the shared Metal command queue and abort:
-[_MTLCommandBuffer addCompletedHandler:]: failed assertion
'Completed handler provided after commit call'
Deterministic repro (osaurus): disconnect a streaming request whose
prompt has a warm disk-cache entry, send an immediate follow-up — the
orphan producer restores while the follow-up tokenizes. Matches the
production Sentry cluster on the same assertion. Both restore paths now
run their full restore-and-materialize region inside the lock,
consistent with the lock's documented purpose (cache tensor I/O vs
concurrent MLX materialization).
BatchEngine.shutdown: await producer exit + drain GPU before returning
shutdown() cancelled the solo/loop producer tasks and returned
immediately. Cancellation is only observed at the producer's next
chunk/token boundary, so 'shutdown returned' did NOT mean 'no producer
will touch the GPU again' — and serving-layer teardown that frees model
weights right after shutdownEngine raced the dying producer's encodes:
SIGSEGV in objc_msgSend / AGX...CommandBuffer encoder creation,
orphan thread still in TokenIterator.prepare -> asyncEval
(osaurus cold-load disconnect: hangup triggers unload of the
just-loading model; the model lease is held by the serving wrapper
task, not this producer, so the lease drain passes while the producer
still encodes.)
shutdown() is now async: after cancelling and finishing all streams it
awaits both producer tasks' completion, then issues a final
Stream().synchronize() for their in-flight asyncEval work. Bounded by
the chunk/token cancellation points (#111); the producers never call
back into the engine actor, so the await cannot deadlock, and callers
that interleave during the suspension fail closed on isShutdown. All
call sites already awaited the actor method.
Prefill: honor task cancellation between chunks (orphan-producer GPU aborts)
Prefill was the only long-running generation phase with no cancellation
points: generateLoopTask checks Task.isCancelled between decode tokens,
but a cancelled producer inside TokenIterator.prepare kept encoding GPU
work until the whole prompt was consumed. A client that disconnects
mid-prefill therefore leaves an uncancellable orphan producer on the
shared Metal command queue; when serving layers react to the hangup by
tearing the engine out of their registry, the follow-up request's fresh
engine races that orphan and the process aborts:
AGX...CommandBuffer tryCoalescingPreviousComputeCommandEncoder...
failed assertion 'A command encoder is already encoding to this
command buffer'
(100%-reproducible via disconnect-during-cold-load + immediate retry;
matches the production crash cluster on the same assertion.)
Fix: Task.checkCancellation() at every chunk boundary in the five
chunked-prefill loops (LLMModel.prepare default, chunkedPrefillEmbedding
+ its three VLM callers, Gemma4 and Qwen35 inline loops), bounding the
orphan window to a single chunk. All prepare call sites already handle
throws (BatchEngine finishes the slot as .cancelled; the solo/deferred
path yields a .cancelled info); generateLoopTask now finishes a
cancelled iterator construction quietly instead of logging it as an
iterator-construction error.
Tests: prepare and chunkedPrefillEmbedding bail with zero chunks encoded
when the task is already cancelled; live tasks complete unchanged.
Mistral: recover tool calls emitted as a bare JSON array (VL-history turns)
Mistral V7/V11 checkpoints drop the leading [TOOL_CALLS] token on tool
turns whose conversation history contains images, emitting the call as
plain text -- deterministic at temperature 0 on
mistral-small-3.1-24b-instruct-2503:
content: '[{"name": "get_weather", "arguments": {"city": "Tokyo"}}]'
The streamed bytes first look like a potential [TOOL_CALLS] tag (they
start with '['), fail the prefix match at the second character, and
flush to visible text, so the call never reaches the parser.
Fix: a new per-parser capability, supportsBareJSONArrayToolFallback
(Mistral only). When a failed '['-tag candidate is still consistent
with the exact shape '[' '{' "name" ':' "<registered tool>", the
processor buffers it as an inline tool-call array, completes on
string-aware bracket balance, and routes the body through parseEOS
(multi-call arrays keep every call). Any deviation -- markdown links,
JSON examples with non-registered names, prefixed-but-unknown tool
names, missing tool schemas, EOS mid-array -- flushes the held bytes
verbatim, so ordinary prose stays byte-exact.
12 new tests cover the streamed and single-chunk repro, multi-call
arrays, whitespace tolerance, brackets inside argument strings, and
every pass-through guard, plus a tagged-path regression check.
Stop strings: discard post-stop buffers at end-of-stream instead of leaking them
A matched stop string truncates the response, but the engine decodes a few
more tokens before the halt lands, and the NaiveStreamingDetokenizer holds
back ~24 trailing characters. At end-of-stream the solo-path handler
(TextToolTokenLoopHandler.onGenerationEnd) flushed that held tail -- text
chronologically AFTER the stop match -- back through a StopStringMatcher
whose buffer was cleared at match time, so post-stop text leaked into the
response past the truncation point.
Live repro (osaurus solo path, Mistral-Small, temperature 0):
stop=["five"] -> "one, two, three, four, six, " (" six, " leaked)
stop=["three"] -> "one,two,our,fi" (mangled tail leaked)
Fixes, mirrored across both pipelines:
- Evaluate.swift onGenerationEnd: early-return when stopSequenceHit --
everything still held downstream of the match (detokenizer tail,
reasoning/tool buffers) is post-stop text and must be discarded.
(BatchEngine's batched pump() already had this gate; the solo handler,
which osaurus uses by default via the solo fast path, did not.)
- BatchEngine batched flush(): same gate (flushGenerationText and the
reasoning-parser drain were previously emitted even after a stop).
- Both paths: route the tool processor's end-of-stream remainder through
the stop matcher instead of around it, so a stop occurrence held in the
tool buffer at EOS still truncates, and the matcher tail drain can no
longer reorder characters (older held text after newer flush text).
TextToolTokenLoopHandler becomes internal (was private) so the truncation
contract is unit-testable. New StopStringPostStopLeakTests drive the
handler with a deterministic tokenizer: mid-stream match truncates exactly
and end-of-stream flush leaks nothing; no-stop and unmatched-stop cases
still emit the full text; a stop string arriving only in the final flush
still truncates.
Follow-up to #102: eliminate the whole class of order-dependent (reload-flipping) load decisions
#102 fixed ONE instance of a bug class: a load-time decision made by taking
the first element of a Swift Dictionary/Set, whose iteration order is
randomized per process. The symptom is a model that loads correctly on some
process launches and degenerates into garbage on others, "fixed" by reloading.
A sweep of the loader + every model sanitize/config path found more instances
of the same class -- all now made deterministic:
Routed-expert quant bit-width probes (dict["routed_expert"] ?? dict.values.first
over a JSON-decoded [String: Int] role->bits map). When the named key is absent
and the map is multi-valued, .values.first picked a per-process-random bit
width, corrupting MoE dequant on a fraction of loads:
- LLMModelFactory.swift (Zaya routed-bit probe)
- Zaya.swift (RoutedExpertBitMetadata)
- BailingHybrid.swift (mxtqBits)
- Qwen35JANGTQ.swift (mxtqBits)
- MiMoV2Flash.swift (nested projections map)
Fixed: prefer named aliases, then a deterministic reduction -- the MINIMUM
declared width (routed experts are JANG's most aggressively quantized tier);
the nested-map case picks the smallest-bit role, tie-broken by key.
Load.swift shard selection: with two COMPLETE shard sets in one directory,
totalsSeen.first(where: key == value) picked a random one -- and the comment
said "pick the LARGEST". Now .filter { ... }.keys.max() (deterministic and
matches the stated intent).
JangLoader shared-default quant: counts.values.max(by: count) was
order-dependent on a frequency TIE between distinct (bits, gs) pairs. Now
total-ordered (count, then higher bits, then larger gs).
All deterministic given the same bundle; no behavior change when the named
key / strict winner is present.
Mistral3 VLM: honor bundle longest_edge instead of clamping images to 336px
Mistral3VLMProcessor.preprocessImage hard-clamped the resized longest edge
to patch_size*24 (336px) regardless of the bundle's declared longest_edge
(e.g. 1540 in preprocessor_config.json). Any detailed image — handwriting,
dense documents, small text, screenshots — was downsampled to a 336px
thumbnail before the vision tower ever saw it, producing garbled or empty
responses on content the model reads fine at native resolution.
The Pixtral/Mistral3 vision tower is variable-resolution: its 2D RoPE grid is
precomputed for image_size/patch_size (=1540/14=110) patches per side and the
meshgrid indexes into that full grid, so images up to the declared longest
edge are fully supported (Pixtral.swift already honors longest_edge; only
Mistral3.swift carried the stale clamp). Honor longest_edge, falling back to a
Pixtral-typical ~1024px only when the bundle declares no size.
Verified on Mistral-Small-3.1-24B: a 2451x3267 handwriting note that
transcribed as garbage at 336px ('Daddy, darling ... feel quite fine') is now
read verbatim at 1540px ('Dear diary ... I felt quite cheerful').
Qwen3.5: make the (1+weight) RMSNorm shift deterministic (fix intermittent ~7.5% degenerate loads)
Bump mlx submodule to 9dabb6c4: guard the MLX scheduler default_streams_/streams_ and metal::Device stream_map_ with dedicated mutexes (find/emplace only; refs stable; no GPU serialization, no deadlock). Fixes the concurrent-GPU EXC_BAD_ACCESS Sentry clusters (default_stream, end_encoding/get_command_buffer). Verified: compiles, concurrent multi-model stress no crash/deadlock, no regression.
VLM Qwen35.prepare was single-shot -> no PrefillProgress frames -> frozen 0/N counter for hybrid Ornith/qwen3_5. Chunk text-only prefill in windowSize steps + reportCompletedUnits per chunk (mirrors Gemma4); image/video stays single-shot. Numerically identical (positions+causal mask from cache.offset). Live-proven on osaurus dev app: counter advances, multiturn/tools/reasoning/VL all coherent, no regression.
fix writePNG blocking the main thread
Qwen3.5 (qwen3_5 / qwen3_5_moe) stores RMSNorm weights as the deviation from 1
and applies (1 + weight) at load. Whether to apply that shift was decided by a
heuristic, `baseNormWeightsNeedShift`, that returned on the FIRST
`.input_layernorm` / `.post_attention_layernorm` weight it encountered while
iterating a Swift `Dictionary`. Dictionary iteration order is randomized per
process, and a few norm weights in a raw bundle legitimately have mean > 0.5, so
on the fraction of runs where the random first pick landed on one of them
(~7.5% on the bundle that surfaced this) the +1 shift was skipped for ALL norms
-- silently loading a degenerate model (flat logits, garbage output). Same
bytes, same machine, ~1-in-13 loads broken at random; no error, no log. The bug
is a single-sample decision over a noisy signal; the fix is to decide over the
whole population, order-independently.
Note the shift is a per-BUNDLE storage property, not an architecture constant:
the same qwen3_5 arch ships raw (norm mean ~= 0, needs +1) AND already-shifted
(norm mean ~= 1, must NOT be shifted again) bundles, so "declare the arch and
always shift" regresses the already-shifted ones by double-applying the offset.
The two states are cleanly bimodal, so a majority vote discriminates reliably.
Fix: resolve the convention with a deterministic precedence -- a declaration is
authoritative and short-circuits the vote; the vote runs only when nothing can be
declared.
* NormConventionResolver (MLXLMCommon): one shared resolver. Precedence:
per-bundle declaration (safetensors metadata -> config.json `norm_convention`)
-> architecture declaration -> order-independent MAJORITY VOTE over all probe
norms. A declaration wins outright because it states the storage state
authoritatively. The vote is the per-bundle measurement used when nothing is
declared; the signal is bimodal (norm mean ~0 raw vs ~1 shifted) so the vote
is unambiguous and independent of Dictionary iteration order.
* LanguageModel.declaredNormConvention: a new protocol property (default nil),
the per-architecture declaration seam -- valid ONLY for architectures whose
bundles all share one storage state. Non-breaking for existing conformers.
* Qwen35 (both the MLXLLM text model and the MLXVLM model) deliberately does
NOT declare a class convention: the same class ships raw (JangQ, needs +1)
AND already-shifted (MXFP4, must not) bundles, so no truthful class-level
claim exists -- an authoritative class declaration would wrongly short-circuit
the vote and degrade one of the two states. It adds a `norm_convention` config
field for a per-bundle override and routes sanitize through the resolver,
relying on that field or the vote. The duplicated per-copy first-element
heuristic and convention constants are deleted.
Qwen35 is now the template for any other (1+weight) architecture: declare the
convention, call shouldApplyPlusOneShift(...). The fragile first-element probe is
gone; the population-level decision lives in one shared place.
Validated on device across both storage states (mmlu, logprob), tracing the norm
sum-of-squares through sanitize: Ornith-1.0-35B-JANG_6M (raw) 64 -> 2112 shifted,
80%; Holo3-35B-A3B-mxfp4 (pre-shifted) 2106 -> 2106 left alone, 75%;
Ornith-1.0-35B-MXFP4 (pre-shifted) 2112 -> 2112 left alone, 80%. Plus 160 idle
loads / 0 degenerate on the raw bundle (baseline ~7.5% ~= 12 expected).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JANGTQ: filter shape-walk candidate bits to valid affine set (fixes MiniMax-M2.7-Small crash)
MiniMax-M2.7-Small-JANGTQ crashed on the first forward:
[JangLoader] ... declared (bits=8, gs=32) -> shape-inferred (bits=16, gs=32)
Fatal error: [quantized_matmul] Last dimension (...,3072) does not match the
expanded quantized matrix (1536,8192) ... group_size=32, bits=16
Root cause: jang_config.json mxtq_bits includes a non-affine sentinel
(norms_router: 16 = fp16 passthrough) that unions into bitWidthsUsed=[2,8,16]
(quantization is null, so topLevelBitWidths drives it). The fused self_attn.qkv_proj
(built in MiniMaxJANGTQ.sanitize before the shape-walk) has no hint branch and the
declared top-level gs=32 is wrong for it (real gs=64), so inferBitWidthAndGroupSize
computes bits=(768*32)/1536=16, finds 16 in the candidate list, and returns (16,32).
The real weight is genuine 8-bit affine at gs=64 (U32 + scales + biases, in=3072);
(16,32) expands in-dim to 1536 != 3072 -> quantized_matmul crash.
Fix: affine quantized_matmul only supports {2,3,4,5,6,8}; filter bitWidthsUsed to
that set inside inferBitWidthAndGroupSize so an impossible width (16) can never be
selected. qkv_proj then re-resolves via the preferred list to the correct (8,64).
Safe: real affine weights are never packed >8b; fp16-passthrough layers carry no
scales so never enter this walk; genuine mis-declarations (the walk's purpose) stay
within valid widths.
Proven: M2.7-Small-JANGTQ now loads and decodes correctly
(shape-inferred (bits=8, gs=64)); '288 - 17 = 271' coherent, clean stop, no crash.
Mistral chat hardening: catch-path fallback arm + VLM image-token round-trip (follow-ups to #100)
Follow-ups to #100, found by a regression audit of the recent template overhaul.
F1 (HIGH): when a Mistral model's NATIVE template throws in swift-jinja, the
catch-block family sniff had no Mistral arm, so it fell to orderedFallbacks
(Gemma/Nemotron/DSV4) and tried Gemma4WithTools first -> a Mistral model got a
Gemma/ChatML prompt -> looping/incoherence. Same defect class as #100, reached
via the catch ladder instead of the reroute ladder. Reachable for Mistral-3 2512
/ Devstral packs that ship [AVAILABLE_TOOLS] (so JangLoader keeps their native
template instead of swapping in mistral3CompleteMinimal). Fix: add a Mistral arm
(round-trip [INST] + [SYSTEM_PROMPT] sentinels) that selects
mistral3CompleteMinimal, applied to both macro copies.
F2 (low, same pattern): Mistral3/Pixtral image-token resolution used
convertTokenToId(imageToken) != nil, whose else-branch is dead (returns the
<unk> id for an absent token). Round-trip it so an absent [IMG] correctly falls
back to id 10 instead of using the unk id as the image placeholder.
Verified via BENCH_TEMPLATE_SMOKE: no regression — Mistral-Small-3.1-2503 still
renders native [SYSTEM_PROMPT]/[INST], Gemma-4 still routes Gemma4WithTools.
Fix Mistral chat looping/incoherence: round-trip special-token sentinel checks
The chat-template auto-correction in the tokenizer macro detected model family
via `convertTokenToId("<|im_end|>") != nil` (and the same for <|im_start|>,
<|tool_call>, <|turn>, <tool_call>, <im_patch>, [MODEL_SETTINGS], <think>, …).
That is NOT an existence test: convertTokenToId returns the <unk> id for ANY
absent token, so it is non-nil even for models that lack the token.
Effect: a tool-bearing Mistral request (bos "<s>", NO <|im_end|>) matched the
Nemotron reroute (and, once that was guarded, the Gemma reroute), so the engine
fed the Mistral model a ChatML / Gemma prompt (<|im_start|>, <|turn>, <think>,
<tool_call>) it has no tokens for. The model then produced incoherent output and
never stopped (<|im_end|> is not its EOS), i.e. looping. osaurus agent use always
injects tools, so this hit on essentially every Mistral chat.
Fix: replace every sentinel existence check with a round-trip
(convertIdToToken(convertTokenToId(t)) == t). Strictly more correct — models that
genuinely have the token still round-trip and reroute exactly as before; models
that lack it (Mistral) no longer false-match. Same pitfall fixed before for the
bridge detection (Mistral-on-Gemma-CRACK misfire).
Proven via BENCH_TEMPLATE_SMOKE on Mistral-Small-3.1-24B-2503 (no weights): all
tool cases now render native <s>[SYSTEM_PROMPT]…[/SYSTEM_PROMPT]…[AVAILABLE_TOOLS]
…[/AVAILABLE_TOOLS][INST]…[/INST] with NO <|im_start|>/<|turn>/<think>; non-tool
cases unchanged; no fallback reroute engaged.
Add hunyuan_v1_dense model support (Tencent Hunyuan v1 dense)
Add Rampart PII NER MLX library
The published model (sledgedev/rampart-mlx) ships MLX 4-bit affine-quantized
weights (config.json quantization group_size=64 bits=4): every Embedding/Linear
and the classifier is packed U32 weight + scales/biases. The dense modules
couldn't accept them, so RampartPII(directory:) threw
UpdateError.mismatchedSize(classifier.weight [35,384] vs [35,48]) before any
inference — i.e. the library could not load its own target model.
Fix mirrors the reference rampart_mlx demo.load(): decode config.quantization
and run quantize(model:groupSize:bits:) over the Embedding/Linear/classifier
modules (LayerNorms skipped) before update(). Verified: detections are now
byte-identical to the reference neural model_spans across names/email/phone/
city/routing + accented input.
Also hard-cap the tokenizer body at maxLength-1 before [SEP]: one word can emit
several subwords past the limit in a single append, which could push the
sequence past maxPositionEmbeddings and index the position embedding out of
bounds on long inputs.
Ports HunYuanDenseV1ForCausalLM (model_type=hunyuan_v1_dense): a Llama-style
dense GQA decoder with two deltas vs plain Llama:
- per-head query_layernorm/key_layernorm (RMSNorm over head_dim), applied
AFTER RoPE (note: opposite order from the hy_v3 MoE sibling)
- DynamicNTKAlphaRoPE, which collapses to a standard RoPE with the base
rescaled by alpha**(dims/(dims-2)) from rope_scaling.alpha
Registers the type in LLMModelFactory. Resolves 'Unsupported model type:
hunyuan_v1_dense'.
Live-proven on HY-MT1.5-7B-mlx-8Bit (affine 8-bit, tied embeddings, alpha=1000):
deterministic load+decode, clean EOS stop, no loop/leaks. EN->FR and EN->zh
translations are fluent and correct.
The lfm2ToolMinimal fallback (applied by the osaurus tokenizer bridge for all
lfm2/lfm2_moe models when tools are present) rendered tools as a bare
'Available functions: - name: description' summary with no parameter schema.
Every LFM2 model (LFM2.5-230M, LFM2.5-8B-A1B, ...) is trained on the native
'List of tools: [<full JSON schemas>]' system format + <|tool_call_start|>
emission, so it did not recognize the summary as callable tools and answered in
prose — auto tool-calling was silently broken for ALL LFM2 models.
Regression introduced 2026-05-29 in 46973d1 'Remove LFM tool schema markup',
which replaced 'List of tools: ' ~ (tools | tojson) with the name/description
summary. This restores the native JSON tool-list (keeping the required-tool-choice
native-call-shape guidance untouched).
Proven live (osaurus dev app, lfm2.5-230m-mxfp8, corrected template):
- auto -> get_weather{city:Tokyo} (finish=tool_calls)
- required -> get_weather{city:Paris}
- multiturn -> consumes tool result: 'sunny ... 22C'
Co-authored-by: Eric <eric@exploit.bot>
* DeepSeek-OCR/Unlimited-OCR: port plan + configuration
Begin DeepseekOCRForCausalLM (top model_type deepseek_vl_v2) support: a port of
mlx-vlm's deepseekocr covering deepseek-ai/DeepSeek-OCR and baidu/Unlimited-OCR.
This commit adds the port spec (architecture, file plan, weight sanitize map,
behavioral verification gate) and the Codable configuration (text DeepSeek-V2 MoE
decoder, CLIP-L/14 + SAM-ViT-B DeepEncoder, MLP projector, 2D tiling).
* DeepSeek-OCR: language decoder, SAM + CLIP DeepEncoder, processor, top model + factory
WIP engine port (pre-build-verification). Adds the DeepSeek-V2 MoE decoder
(standard attention), SAM-ViT-B + CLIP-L/14 DeepEncoder, 2D-tiling image
processor, and the top model orchestration (get_input_embeddings + sanitize),
registered under model_type deepseek_vl_v2 / deepseekocr. Compile + behavioral
verification against the mlx-vlm Python reference next.
* DeepSeek-OCR: compile-fixes — MLXVLM target builds green
Mechanical fixes only (no logic/math/weight-key changes): Decodable/Encodable
conformance, fileprivate access levels for private inner types, try on chained
decodeIfPresent, SAM init arg order. swift build --target MLXVLM now completes.
Behavioral verification vs mlx-vlm Python reference is the next gate.
* DeepSeek-OCR: status + ground truth + critical finding (mlx-vlm image-injection is buggy)
Captured official-PyTorch ground-truth OCR for 2 test images. Documented that
mlx-vlm 0.6.3's deepseekocr collapses on image-feature injection (bf16+int8) — so
our faithful port likely inherits it; the correctness gate is to verify/fix
inputEmbeddings against official modeling_deepseekocr.py, not mlx-vlm.
* DeepSeek-OCR: live state — model recognized + loads to SAM neck weight-key mismatch
* DeepSeek-OCR: true architecture + cache (engine+osaurus) + divergence/fix reference
Synthesized from official HF reference vs our port + vmlx cache + osaurus serving.
Decoder port is faithful. Collapse is in vision towers + zero-init newline/separator
params (not the assembly). Documents fix order, KVCacheSimple-vs-Rotating, and the
osaurus OCR-delegate blueprint.
* DeepSeek-OCR engine: load clean + correct OCR on both test images
Fixes the DeepseekOCR vmlx port so it loads the 8-bit unlimited-ocr-mlx pack
and reproduces the PyTorch ground-truth OCR text.
- SAM neck: model as an @ModuleInfo [UnaryLayer] array keyed "neck" (the
checkpoint serializes the nn.Sequential as numeric paths neck.0..neck.3 →
MLX parses them as array indices). Adds a channel-axis LayerNorm2dNHWC so
neck.{1,3} normalize over channels in NHWC. Resolves the "Unhandled keys
[neck]" load error; quantization (LM + projector only; SAM/CLIP bf16) then
applies generically.
- prepare(): derive the pixel working dtype from image_newline (bf16) instead
of the text embed weight, which is uint32 under quantization. Casting [-1,1]
pixels to uint32 zeroed the whole global view, tripping the imageOri.sum()==0
guard and silently dropping image injection.
- getAbsPosSAM(): bicubically interpolate the SAM pos-embed (64x64 → crop-tile
grid) instead of returning identity, matching deepencoder.get_abs_pos_sam.
Required for the 640px crop tiles (multi-tile docs).
- Processor prompt: match the official sft_format='plain' (empty roles/seps) —
prompt is just <image>\n{instruction}, no <|User|>/<|Assistant|> markers.
- newCache: always KVCacheSimple (full-attention decoder; drop RotatingKVCache).
- Config: decode language/vision/projector sub-configs with defaults so the
same struct loads from processor_config.json (no language_config there).
- Register processor_class "DeepseekOCRProcessor" alias.
- Add tools/DeepseekOCRSmoke fast load+OCR harness.
Verified vs /tmp/ocr_reference_output.txt:
img1 -> OSAURUS OCR TEST / DeepSeek-OCR 2026
img2 -> Invoice #2026-0042 / Date: June 25, 2026 / Total: $1,337.00 USD
* DeepSeek-OCR: verified working (3 images, both paths) + document tokenizer-pack defect + prompt guidance
---------
Co-authored-by: Eric <eric@exploit.bot>
The gen-suffix-stripped store's correctness comment referenced
osaurusagent-35b, which has been deleted. Re-proved cache-ON == cache-OFF
byte-identically (temp=0, fresh disk cache, differential ground-truth) on
qwen-agentworld-35b-a3b MXFP8 and qwen3.6-35b-a3b MXFP8 (GatedDeltaNet MoE),
nemotron-omni-nano (Mamba-2); correctly inert on dense gemma-4-e2b.
Comment-only; no behavior change.
`shouldUseZayaToolAwareTemplate` / `shouldUseZayaVLToolAwareTemplate` gated
solely on `capabilities.family` being `zaya1*`. Bundles that stamp only
`source_model.architecture = zaya1_vl` and omit `capabilities.family` (e.g.
ZAYA1-VL JANGTQ) therefore fell through: the tool-aware template never
materialized, the bare role-only chat template was used, and the model —
given no tool-call format — leaked raw `<tool_call><function>...` XML as
visible assistant text (the parser expects `<zyphra_tool_call>`, so nothing
matched).
The gate's own doc comment says the check is meant to be "data-driven, not
family-name driven." Honor that: recognize ZAYA from `capabilities.family`
OR `source_model.architecture`, keeping the parser / think_in_template /
supports_tools contract unchanged.
Additive and low-risk: bundles that already set `capabilities.family` are
unaffected (family is still checked first); non-ZAYA bundles never match the
`zaya1*` idents. Verified live: with the fix, ZAYA1-VL-8B-JANGTQ4 activates
the tool-aware template (emits `<zyphra_tool_response>` and passes single
tool-call cases) instead of leaking the hallucinated `<response><tool_call>`
format. (The JANGTQ4 quant is still too weak to follow the format on every
multi-step turn — a separate quantization issue, not this template gate.)
PR #123 replaced the process-aborting `split(...)[i]` traps with count
guards in NemotronH, but the identical pattern — a `split` that records an
MLX error and returns an empty vector, then gets subscripted, trapping the
process before the recorded error can surface — survives untouched in every
sibling mixer. Complete the sweep with the same guard/bail idiom (return the
input so the enclosing withError scope throws the real diagnostic at exit;
the guard only trips when split already recorded an error).
15 guards across 8 files:
- Qwen35.swift — GatedDeltaNet conv split (q/k/v) + query/gate split.
Backs every qwen3.5 / 3.6 / Ornith / osaurusagent / Holo3 model.
- LFM2.swift / LFM2MoE.swift — B/C/x in_proj split (LFM2.5 Top Pick).
- BailingMoe.swift — q/k/v split (Ling-2.6 Flash).
- FalconH1.swift — in_proj + conv split.
- GraniteMoeHybrid.swift — in_proj + conv split.
- Qwen3Next.swift — query/gate, conv, qkvz (4-way), and ba splits.
- Jamba.swift — ssmStep deltaBC + processSequence xz splits.
Defensive-depth only — these fire solely on a malformed / quant-mismatched
checkpoint (a split whose width disagrees with the config). Correctly
converted bundles never hit them. Builds clean.
Hybrid-SSM chat models (Qwen3.5/3.6 & Ornith GatedDeltaNet, Nemotron-H
Mamba-2, ZAYA CCA, LFM2) never reused prefill across growing chat turns:
the stored prompt boundary ends in the chat template's generation-prompt
suffix (`<|im_start|>assistant\n`, …), which the next turn replaces with
the assistant reply + following user turn, so the full-prompt key never
matches as a prefix. Every turn recomputed the entire context.
Fix: after generation, also store the *generation-prompt-stripped* prompt
— everything before the final turn-start token — which the next turn DOES
contain as an exact prefix. Clean SSM/GatedDeltaNet state at that position
comes from the re-derive path (enableSSMReDerive), not the live
post-generation state (ahead by the gen suffix). KV is exact-sliceable.
Wired identically across all three generation paths: solo TokenIterator
(Evaluate), MTP speculation (NativeMTPTokenIterator), and BatchEngine.
Default ON for hybrid models (isHybrid), disable with
VMLX_HYBRID_STRIPPED_STORE=0. Text-only (skips on hasMediaContent so VL
turns are unaffected). The re-derive runs after the response is delivered,
so it never adds to the current turn's TTFT or token rate. Disk-restore
now restores SSM state for GatedDeltaNet ArraysCache (slotCount-based, so
a fresh all-nil cache receives the restored state) even at fmtV>=2.
Supporting plumbing: genPromptSuffixTokens computed at load by diffing the
chat template with/without add_generation_prompt (ModelContainer), threaded
through CacheCoordinator; boundary snapshots gain an allowDiskBackedRederive
/ forceRederive path that bypasses the path-dependent-cache skip guard
(safe post evalLock).
Proven live cache-ON == cache-OFF coherent on osaurusagent-35b-a3b
(GatedDeltaNet MoE, ssm=60) and Qwen3.6-27B-MXFP8-CRACK-MTP (D3
speculation, ssm=96): growing turns HIT the stripped boundary with SSM
restored and stay coherent. Dense / sliding-window models are excluded
(they already reuse via the post-answer boundary).
The prior comment claimed disabling compiled decode had 'minimal' impact
(small GELU/SwiGLU/softcap fusions). That is wrong: compile(shapeless:)
fuses the whole single-slot decode step, and local benchmarks show a
substantial decode-throughput gain from enabling it. State the effect as
a ratio range rather than a single absolute, since RunBench direct decode
and the full server path differ substantially in absolute tok/s. Keep the
gate off by default — it is disabled for #1173 model-switch safety, not
because it is cheap.
* Extract nested-XML tool args for ZAYA rows
Some ZAYA rows emit a fully nested XML tool body —
<function>file_write
<parameter><name>path</name><value>out.txt</value></parameter>
— instead of the attribute form <function=name><parameter=key>value</parameter>
this parser was written for. The attribute scan finds no <parameter=, so every
argument is dropped: live file_write parses but 'path' is reported missing
(invalid_tool_arguments), and the <function>name (no =) variant fails the
function regex entirely and leaks the raw envelope as visible text.
Add a nested-form parse path, gated on the distinctive <name>/<value> markers so
the attribute path is left completely untouched (no regression). It reuses the
same value post-processing and schema validation, and handles both <function>name
and <function=name> openers.
ZayaNestedToolArgTests: red without the fix (path -> nil), green with it, plus
attribute-style and plain-prose regression guards.
* Test zaya nested-XML tool-arg extraction (values, closed-tag, no-fabrication)
Locks the live-captured JANGTQ4 wire shapes the nested parser handles:
- interleaved <type> tag between <name> and <value>
- closed <function>name</function> opener (tool_choice:required capture)
- names/types with NO <value> must not fabricate args (auto-mode skeleton)
---------
Co-authored-by: Eric <eric@erics-m5-max2.tailcd5195.ts.net>
Local models that write files through a tool produce long silent gaps in
streaming consumers: while the model generates a tool-call envelope, the
tool-call processor correctly buffers it (so a partial envelope never leaks
into visible text) and the stream emits nothing until the parsed call is
complete. For a large file write that is tens of seconds with no events, so a
UI cannot preview the code as it streams.
Add a scoped progress channel:
- Generation.toolCallProgress(String): raw envelope-text deltas emitted while
a committed tool call is being collected. The complete parsed call still
arrives as a single .toolCall when the envelope closes, so .toolCall remains
the only actionable tool event; consumers with a default branch are
unaffected (additive case).
- ToolCallProcessor.collectingToolCallText: the raw buffer of the call being
collected. Deliberately scoped — nil in .potentialToolCall (an ambiguous
prefix that may still revert to plain text and be re-emitted as a visible
chunk) and nil for strip-only processors (whose parsed call is discarded, so
a progress delta would never be followed by a .toolCall). It is not a general
text tap.
- routeGenerationText diffs the collecting buffer around each processed chunk,
so every streaming path (token loop, BatchEngine, SpecDec) gains progress
events with no per-family parser change; a call that completes or reverts
within a chunk reports no growth.
Adds ToolCallProgressRoutingTests (multi-chunk envelope -> ordered deltas then
one call, plain prose -> no progress, strip-only -> no progress) and wires it
into the focused-tests source list.
The Qwen3.6 loader-propagation test still asserted the pre-refactor helper
usesQwenPlusOneNormConvention, which 18f803c9 replaced with the shared
deterministic NormConventionResolver.shouldApplyPlusOneShift. The assertion
has been failing since that refactor merged. Repoint it at the current symbol
so the test again pins the real metadata->shift wiring.
The resolver treated ANY declared norm_convention string as authoritative
(`return usesPlusOne(explicit)`), but only three exact markers map to the
(1+weight) shift — so any other non-empty value (a converter typo, or a
descriptive string like "rms_norm") silently returned no-shift. A raw/JangQ
qwen3_5 bundle carrying such a declaration would load with its RMSNorm weights
unshifted (mean ~0, never lifted to ~1) and produce degraded/garbage output.
Only a RECOGNIZED (1+weight) marker is now authoritative; a present-but-
unrecognized declaration defers to the order-independent majority vote, which
reads the bundle's actual storage state. Adds NormConventionResolverTests
covering the recognized-marker, no-declaration, unrecognized-declaration, and
mixed-precedence cases.
Fixes an empty-array crash (Sentry 1A residual): when input.image.pixels is
present but the bundle loaded neither vision_tower nor vision_embedder
(text/audio-only Gemma4 or a partial checkpoint), the per-image loop appended
nothing and featuresList[0] / concatenated([]) at the scatter step crashed.
Guard with a typed VLMError.processing instead of aborting.
MLX is not thread-safe: mlx_eval/mlx_async_eval run gpu::eval inline on the
calling thread, encoding into and committing the shared Metal command buffer,
and the C++ stream mutex only guards the stream-map lookup (not the encoder).
Since evalLock was dropped from the eval hot path, a request's prefill
async_eval could run concurrently with a teardown/unload Stream.synchronize()
on the same command buffer.
Live-reproduced SIGABRT (disconnect-during-cold-load + retry): 'Completed
handler provided after commit' in addCompletedHandler, and AGX 'command
encoder is already encoding'. Two threads: unload->Stream.gpu.synchronize()
committing the buffer while startSoloFastPath->TokenIterator.prepare->async_eval
added a completion handler to it.
Fix: hold evalLock across the brief CPU-side encode+commit in eval/asyncEval/
MLXArray.eval (Stream.synchronize/clearCache already take it). The GPU executes
asynchronously after the lock releases, so CPU->GPU pipeline parallelism is
preserved; the lock is uncontended in steady-state single-producer decode and
only serializes during teardown/cancel/cross-model.
Also guard compiled activations (safeGeluApproximate/compiledSwiGLU/compiledGeGLU)
with CompiledDecodeTrace.isActive to avoid an illegal nested compile inside the
compiled-decode trace, and drain the stream on generateLoopTask cancel/error
early-exits.
Validated live: disconnect repro 35/35 clean (was crash at iter 1-2), concurrent
GPU 15/15 clean, decode 76.6 tok/s (no regression), multiturn coherent.
Live ZAYA / Gemma-4 AppleScript rows emit stray
</parameter></function></zyphra_tool_call> runs with no matching opener
after several agent-loop steps (MODEL_ISSUES_TRIAGE Issue 3). The
tool-call state machine only enters collection on an OPEN tag, so those
protocol closers streamed to the user as literal text.
Adds an opt-in orphanStripTags registry to ToolCallParser (default
empty, no behavior change for other formats) and teaches
ToolCallProcessor to strip registered orphan closers — including runs,
chunk-split partials, and closers embedded mid-buffer — while
unregistered tag-looking prose like </div> still flushes verbatim.
XMLFunctionParser and Gemma4ToolCallParser register their closers.
Live ZAYA / Gemma-4 AppleScript rows emit stray
</parameter></function></zyphra_tool_call> runs with no matching opener
after several agent-loop steps (MODEL_ISSUES_TRIAGE Issue 3). The
tool-call state machine only enters collection on an OPEN tag, so those
protocol closers streamed to the user as literal text.
Adds an opt-in orphanStripTags registry to ToolCallParser (default
empty, no behavior change for other formats) and teaches
ToolCallProcessor to strip registered orphan closers — including runs,
chunk-split partials, and closers embedded mid-buffer — while
unregistered tag-looking prose like </div> still flushes verbatim.
XMLFunctionParser and Gemma4ToolCallParser register their closers.
The disk-tier restore (TQDiskSerializer .item() reads, asType
conversions, and the materializing MLX.eval of the restored cache) ran
outside MLXCacheIOLock in both restore paths (TokenIterator.init and
BatchEngine's slot prefill). Serving layers tokenize the NEXT request's
input under that lock while a producer restores; unserialized, the two
encode concurrently on the shared Metal command queue and abort:
-[_MTLCommandBuffer addCompletedHandler:]: failed assertion
'Completed handler provided after commit call'
Deterministic repro (osaurus): disconnect a streaming request whose
prompt has a warm disk-cache entry, send an immediate follow-up — the
orphan producer restores while the follow-up tokenizes. Matches the
production Sentry cluster on the same assertion. Both restore paths now
run their full restore-and-materialize region inside the lock,
consistent with the lock's documented purpose (cache tensor I/O vs
concurrent MLX materialization).
shutdown() cancelled the solo/loop producer tasks and returned
immediately. Cancellation is only observed at the producer's next
chunk/token boundary, so 'shutdown returned' did NOT mean 'no producer
will touch the GPU again' — and serving-layer teardown that frees model
weights right after shutdownEngine raced the dying producer's encodes:
SIGSEGV in objc_msgSend / AGX...CommandBuffer encoder creation,
orphan thread still in TokenIterator.prepare -> asyncEval
(osaurus cold-load disconnect: hangup triggers unload of the
just-loading model; the model lease is held by the serving wrapper
task, not this producer, so the lease drain passes while the producer
still encodes.)
shutdown() is now async: after cancelling and finishing all streams it
awaits both producer tasks' completion, then issues a final
Stream().synchronize() for their in-flight asyncEval work. Bounded by
the chunk/token cancellation points (#111); the producers never call
back into the engine actor, so the await cannot deadlock, and callers
that interleave during the suspension fail closed on isShutdown. All
call sites already awaited the actor method.
Prefill was the only long-running generation phase with no cancellation
points: generateLoopTask checks Task.isCancelled between decode tokens,
but a cancelled producer inside TokenIterator.prepare kept encoding GPU
work until the whole prompt was consumed. A client that disconnects
mid-prefill therefore leaves an uncancellable orphan producer on the
shared Metal command queue; when serving layers react to the hangup by
tearing the engine out of their registry, the follow-up request's fresh
engine races that orphan and the process aborts:
AGX...CommandBuffer tryCoalescingPreviousComputeCommandEncoder...
failed assertion 'A command encoder is already encoding to this
command buffer'
(100%-reproducible via disconnect-during-cold-load + immediate retry;
matches the production crash cluster on the same assertion.)
Fix: Task.checkCancellation() at every chunk boundary in the five
chunked-prefill loops (LLMModel.prepare default, chunkedPrefillEmbedding
+ its three VLM callers, Gemma4 and Qwen35 inline loops), bounding the
orphan window to a single chunk. All prepare call sites already handle
throws (BatchEngine finishes the slot as .cancelled; the solo/deferred
path yields a .cancelled info); generateLoopTask now finishes a
cancelled iterator construction quietly instead of logging it as an
iterator-construction error.
Tests: prepare and chunkedPrefillEmbedding bail with zero chunks encoded
when the task is already cancelled; live tasks complete unchanged.
Mistral V7/V11 checkpoints drop the leading [TOOL_CALLS] token on tool
turns whose conversation history contains images, emitting the call as
plain text -- deterministic at temperature 0 on
mistral-small-3.1-24b-instruct-2503:
content: '[{"name": "get_weather", "arguments": {"city": "Tokyo"}}]'
The streamed bytes first look like a potential [TOOL_CALLS] tag (they
start with '['), fail the prefix match at the second character, and
flush to visible text, so the call never reaches the parser.
Fix: a new per-parser capability, supportsBareJSONArrayToolFallback
(Mistral only). When a failed '['-tag candidate is still consistent
with the exact shape '[' '{' "name" ':' "<registered tool>", the
processor buffers it as an inline tool-call array, completes on
string-aware bracket balance, and routes the body through parseEOS
(multi-call arrays keep every call). Any deviation -- markdown links,
JSON examples with non-registered names, prefixed-but-unknown tool
names, missing tool schemas, EOS mid-array -- flushes the held bytes
verbatim, so ordinary prose stays byte-exact.
12 new tests cover the streamed and single-chunk repro, multi-call
arrays, whitespace tolerance, brackets inside argument strings, and
every pass-through guard, plus a tagged-path regression check.
A matched stop string truncates the response, but the engine decodes a few
more tokens before the halt lands, and the NaiveStreamingDetokenizer holds
back ~24 trailing characters. At end-of-stream the solo-path handler
(TextToolTokenLoopHandler.onGenerationEnd) flushed that held tail -- text
chronologically AFTER the stop match -- back through a StopStringMatcher
whose buffer was cleared at match time, so post-stop text leaked into the
response past the truncation point.
Live repro (osaurus solo path, Mistral-Small, temperature 0):
stop=["five"] -> "one, two, three, four, six, " (" six, " leaked)
stop=["three"] -> "one,two,our,fi" (mangled tail leaked)
Fixes, mirrored across both pipelines:
- Evaluate.swift onGenerationEnd: early-return when stopSequenceHit --
everything still held downstream of the match (detokenizer tail,
reasoning/tool buffers) is post-stop text and must be discarded.
(BatchEngine's batched pump() already had this gate; the solo handler,
which osaurus uses by default via the solo fast path, did not.)
- BatchEngine batched flush(): same gate (flushGenerationText and the
reasoning-parser drain were previously emitted even after a stop).
- Both paths: route the tool processor's end-of-stream remainder through
the stop matcher instead of around it, so a stop occurrence held in the
tool buffer at EOS still truncates, and the matcher tail drain can no
longer reorder characters (older held text after newer flush text).
TextToolTokenLoopHandler becomes internal (was private) so the truncation
contract is unit-testable. New StopStringPostStopLeakTests drive the
handler with a deterministic tokenizer: mid-stream match truncates exactly
and end-of-stream flush leaks nothing; no-stop and unmatched-stop cases
still emit the full text; a stop string arriving only in the final flush
still truncates.
#102 fixed ONE instance of a bug class: a load-time decision made by taking
the first element of a Swift Dictionary/Set, whose iteration order is
randomized per process. The symptom is a model that loads correctly on some
process launches and degenerates into garbage on others, "fixed" by reloading.
A sweep of the loader + every model sanitize/config path found more instances
of the same class -- all now made deterministic:
Routed-expert quant bit-width probes (dict["routed_expert"] ?? dict.values.first
over a JSON-decoded [String: Int] role->bits map). When the named key is absent
and the map is multi-valued, .values.first picked a per-process-random bit
width, corrupting MoE dequant on a fraction of loads:
- LLMModelFactory.swift (Zaya routed-bit probe)
- Zaya.swift (RoutedExpertBitMetadata)
- BailingHybrid.swift (mxtqBits)
- Qwen35JANGTQ.swift (mxtqBits)
- MiMoV2Flash.swift (nested projections map)
Fixed: prefer named aliases, then a deterministic reduction -- the MINIMUM
declared width (routed experts are JANG's most aggressively quantized tier);
the nested-map case picks the smallest-bit role, tie-broken by key.
Load.swift shard selection: with two COMPLETE shard sets in one directory,
totalsSeen.first(where: key == value) picked a random one -- and the comment
said "pick the LARGEST". Now .filter { ... }.keys.max() (deterministic and
matches the stated intent).
JangLoader shared-default quant: counts.values.max(by: count) was
order-dependent on a frequency TIE between distinct (bits, gs) pairs. Now
total-ordered (count, then higher bits, then larger gs).
All deterministic given the same bundle; no behavior change when the named
key / strict winner is present.
Mistral3VLMProcessor.preprocessImage hard-clamped the resized longest edge
to patch_size*24 (336px) regardless of the bundle's declared longest_edge
(e.g. 1540 in preprocessor_config.json). Any detailed image — handwriting,
dense documents, small text, screenshots — was downsampled to a 336px
thumbnail before the vision tower ever saw it, producing garbled or empty
responses on content the model reads fine at native resolution.
The Pixtral/Mistral3 vision tower is variable-resolution: its 2D RoPE grid is
precomputed for image_size/patch_size (=1540/14=110) patches per side and the
meshgrid indexes into that full grid, so images up to the declared longest
edge are fully supported (Pixtral.swift already honors longest_edge; only
Mistral3.swift carried the stale clamp). Honor longest_edge, falling back to a
Pixtral-typical ~1024px only when the bundle declares no size.
Verified on Mistral-Small-3.1-24B: a 2451x3267 handwriting note that
transcribed as garbage at 336px ('Daddy, darling ... feel quite fine') is now
read verbatim at 1540px ('Dear diary ... I felt quite cheerful').
Bump mlx submodule to 9dabb6c4: guard the MLX scheduler default_streams_/streams_ and metal::Device stream_map_ with dedicated mutexes (find/emplace only; refs stable; no GPU serialization, no deadlock). Fixes the concurrent-GPU EXC_BAD_ACCESS Sentry clusters (default_stream, end_encoding/get_command_buffer). Verified: compiles, concurrent multi-model stress no crash/deadlock, no regression.
VLM Qwen35.prepare was single-shot -> no PrefillProgress frames -> frozen 0/N counter for hybrid Ornith/qwen3_5. Chunk text-only prefill in windowSize steps + reportCompletedUnits per chunk (mirrors Gemma4); image/video stays single-shot. Numerically identical (positions+causal mask from cache.offset). Live-proven on osaurus dev app: counter advances, multiturn/tools/reasoning/VL all coherent, no regression.
Qwen3.5 (qwen3_5 / qwen3_5_moe) stores RMSNorm weights as the deviation from 1
and applies (1 + weight) at load. Whether to apply that shift was decided by a
heuristic, `baseNormWeightsNeedShift`, that returned on the FIRST
`.input_layernorm` / `.post_attention_layernorm` weight it encountered while
iterating a Swift `Dictionary`. Dictionary iteration order is randomized per
process, and a few norm weights in a raw bundle legitimately have mean > 0.5, so
on the fraction of runs where the random first pick landed on one of them
(~7.5% on the bundle that surfaced this) the +1 shift was skipped for ALL norms
-- silently loading a degenerate model (flat logits, garbage output). Same
bytes, same machine, ~1-in-13 loads broken at random; no error, no log. The bug
is a single-sample decision over a noisy signal; the fix is to decide over the
whole population, order-independently.
Note the shift is a per-BUNDLE storage property, not an architecture constant:
the same qwen3_5 arch ships raw (norm mean ~= 0, needs +1) AND already-shifted
(norm mean ~= 1, must NOT be shifted again) bundles, so "declare the arch and
always shift" regresses the already-shifted ones by double-applying the offset.
The two states are cleanly bimodal, so a majority vote discriminates reliably.
Fix: resolve the convention with a deterministic precedence -- a declaration is
authoritative and short-circuits the vote; the vote runs only when nothing can be
declared.
* NormConventionResolver (MLXLMCommon): one shared resolver. Precedence:
per-bundle declaration (safetensors metadata -> config.json `norm_convention`)
-> architecture declaration -> order-independent MAJORITY VOTE over all probe
norms. A declaration wins outright because it states the storage state
authoritatively. The vote is the per-bundle measurement used when nothing is
declared; the signal is bimodal (norm mean ~0 raw vs ~1 shifted) so the vote
is unambiguous and independent of Dictionary iteration order.
* LanguageModel.declaredNormConvention: a new protocol property (default nil),
the per-architecture declaration seam -- valid ONLY for architectures whose
bundles all share one storage state. Non-breaking for existing conformers.
* Qwen35 (both the MLXLLM text model and the MLXVLM model) deliberately does
NOT declare a class convention: the same class ships raw (JangQ, needs +1)
AND already-shifted (MXFP4, must not) bundles, so no truthful class-level
claim exists -- an authoritative class declaration would wrongly short-circuit
the vote and degrade one of the two states. It adds a `norm_convention` config
field for a per-bundle override and routes sanitize through the resolver,
relying on that field or the vote. The duplicated per-copy first-element
heuristic and convention constants are deleted.
Qwen35 is now the template for any other (1+weight) architecture: declare the
convention, call shouldApplyPlusOneShift(...). The fragile first-element probe is
gone; the population-level decision lives in one shared place.
Validated on device across both storage states (mmlu, logprob), tracing the norm
sum-of-squares through sanitize: Ornith-1.0-35B-JANG_6M (raw) 64 -> 2112 shifted,
80%; Holo3-35B-A3B-mxfp4 (pre-shifted) 2106 -> 2106 left alone, 75%;
Ornith-1.0-35B-MXFP4 (pre-shifted) 2112 -> 2112 left alone, 80%. Plus 160 idle
loads / 0 degenerate on the raw bundle (baseline ~7.5% ~= 12 expected).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MiniMax-M2.7-Small-JANGTQ crashed on the first forward:
[JangLoader] ... declared (bits=8, gs=32) -> shape-inferred (bits=16, gs=32)
Fatal error: [quantized_matmul] Last dimension (...,3072) does not match the
expanded quantized matrix (1536,8192) ... group_size=32, bits=16
Root cause: jang_config.json mxtq_bits includes a non-affine sentinel
(norms_router: 16 = fp16 passthrough) that unions into bitWidthsUsed=[2,8,16]
(quantization is null, so topLevelBitWidths drives it). The fused self_attn.qkv_proj
(built in MiniMaxJANGTQ.sanitize before the shape-walk) has no hint branch and the
declared top-level gs=32 is wrong for it (real gs=64), so inferBitWidthAndGroupSize
computes bits=(768*32)/1536=16, finds 16 in the candidate list, and returns (16,32).
The real weight is genuine 8-bit affine at gs=64 (U32 + scales + biases, in=3072);
(16,32) expands in-dim to 1536 != 3072 -> quantized_matmul crash.
Fix: affine quantized_matmul only supports {2,3,4,5,6,8}; filter bitWidthsUsed to
that set inside inferBitWidthAndGroupSize so an impossible width (16) can never be
selected. qkv_proj then re-resolves via the preferred list to the correct (8,64).
Safe: real affine weights are never packed >8b; fp16-passthrough layers carry no
scales so never enter this walk; genuine mis-declarations (the walk's purpose) stay
within valid widths.
Proven: M2.7-Small-JANGTQ now loads and decodes correctly
(shape-inferred (bits=8, gs=64)); '288 - 17 = 271' coherent, clean stop, no crash.
Follow-ups to #100, found by a regression audit of the recent template overhaul.
F1 (HIGH): when a Mistral model's NATIVE template throws in swift-jinja, the
catch-block family sniff had no Mistral arm, so it fell to orderedFallbacks
(Gemma/Nemotron/DSV4) and tried Gemma4WithTools first -> a Mistral model got a
Gemma/ChatML prompt -> looping/incoherence. Same defect class as #100, reached
via the catch ladder instead of the reroute ladder. Reachable for Mistral-3 2512
/ Devstral packs that ship [AVAILABLE_TOOLS] (so JangLoader keeps their native
template instead of swapping in mistral3CompleteMinimal). Fix: add a Mistral arm
(round-trip [INST] + [SYSTEM_PROMPT] sentinels) that selects
mistral3CompleteMinimal, applied to both macro copies.
F2 (low, same pattern): Mistral3/Pixtral image-token resolution used
convertTokenToId(imageToken) != nil, whose else-branch is dead (returns the
<unk> id for an absent token). Round-trip it so an absent [IMG] correctly falls
back to id 10 instead of using the unk id as the image placeholder.
Verified via BENCH_TEMPLATE_SMOKE: no regression — Mistral-Small-3.1-2503 still
renders native [SYSTEM_PROMPT]/[INST], Gemma-4 still routes Gemma4WithTools.
The chat-template auto-correction in the tokenizer macro detected model family
via `convertTokenToId("<|im_end|>") != nil` (and the same for <|im_start|>,
<|tool_call>, <|turn>, <tool_call>, <im_patch>, [MODEL_SETTINGS], <think>, …).
That is NOT an existence test: convertTokenToId returns the <unk> id for ANY
absent token, so it is non-nil even for models that lack the token.
Effect: a tool-bearing Mistral request (bos "<s>", NO <|im_end|>) matched the
Nemotron reroute (and, once that was guarded, the Gemma reroute), so the engine
fed the Mistral model a ChatML / Gemma prompt (<|im_start|>, <|turn>, <think>,
<tool_call>) it has no tokens for. The model then produced incoherent output and
never stopped (<|im_end|> is not its EOS), i.e. looping. osaurus agent use always
injects tools, so this hit on essentially every Mistral chat.
Fix: replace every sentinel existence check with a round-trip
(convertIdToToken(convertTokenToId(t)) == t). Strictly more correct — models that
genuinely have the token still round-trip and reroute exactly as before; models
that lack it (Mistral) no longer false-match. Same pitfall fixed before for the
bridge detection (Mistral-on-Gemma-CRACK misfire).
Proven via BENCH_TEMPLATE_SMOKE on Mistral-Small-3.1-24B-2503 (no weights): all
tool cases now render native <s>[SYSTEM_PROMPT]…[/SYSTEM_PROMPT]…[AVAILABLE_TOOLS]
…[/AVAILABLE_TOOLS][INST]…[/INST] with NO <|im_start|>/<|turn>/<think>; non-tool
cases unchanged; no fallback reroute engaged.
The published model (sledgedev/rampart-mlx) ships MLX 4-bit affine-quantized
weights (config.json quantization group_size=64 bits=4): every Embedding/Linear
and the classifier is packed U32 weight + scales/biases. The dense modules
couldn't accept them, so RampartPII(directory:) threw
UpdateError.mismatchedSize(classifier.weight [35,384] vs [35,48]) before any
inference — i.e. the library could not load its own target model.
Fix mirrors the reference rampart_mlx demo.load(): decode config.quantization
and run quantize(model:groupSize:bits:) over the Embedding/Linear/classifier
modules (LayerNorms skipped) before update(). Verified: detections are now
byte-identical to the reference neural model_spans across names/email/phone/
city/routing + accented input.
Also hard-cap the tokenizer body at maxLength-1 before [SEP]: one word can emit
several subwords past the limit in a single append, which could push the
sequence past maxPositionEmbeddings and index the position embedding out of
bounds on long inputs.
Ports HunYuanDenseV1ForCausalLM (model_type=hunyuan_v1_dense): a Llama-style
dense GQA decoder with two deltas vs plain Llama:
- per-head query_layernorm/key_layernorm (RMSNorm over head_dim), applied
AFTER RoPE (note: opposite order from the hy_v3 MoE sibling)
- DynamicNTKAlphaRoPE, which collapses to a standard RoPE with the base
rescaled by alpha**(dims/(dims-2)) from rope_scaling.alpha
Registers the type in LLMModelFactory. Resolves 'Unsupported model type:
hunyuan_v1_dense'.
Live-proven on HY-MT1.5-7B-mlx-8Bit (affine 8-bit, tied embeddings, alpha=1000):
deterministic load+decode, clean EOS stop, no loop/leaks. EN->FR and EN->zh
translations are fluent and correct.
The lfm2ToolMinimal fallback (applied by the osaurus tokenizer bridge for all
lfm2/lfm2_moe models when tools are present) rendered tools as a bare
'Available functions: - name: description' summary with no parameter schema.
Every LFM2 model (LFM2.5-230M, LFM2.5-8B-A1B, ...) is trained on the native
'List of tools: [<full JSON schemas>]' system format + <|tool_call_start|>
emission, so it did not recognize the summary as callable tools and answered in
prose — auto tool-calling was silently broken for ALL LFM2 models.
Regression introduced 2026-05-29 in 46973d1 'Remove LFM tool schema markup',
which replaced 'List of tools: ' ~ (tools | tojson) with the name/description
summary. This restores the native JSON tool-list (keeping the required-tool-choice
native-call-shape guidance untouched).
Proven live (osaurus dev app, lfm2.5-230m-mxfp8, corrected template):
- auto -> get_weather{city:Tokyo} (finish=tool_calls)
- required -> get_weather{city:Paris}
- multiturn -> consumes tool result: 'sunny ... 22C'
Co-authored-by: Eric <eric@exploit.bot>
* DeepSeek-OCR/Unlimited-OCR: port plan + configuration
Begin DeepseekOCRForCausalLM (top model_type deepseek_vl_v2) support: a port of
mlx-vlm's deepseekocr covering deepseek-ai/DeepSeek-OCR and baidu/Unlimited-OCR.
This commit adds the port spec (architecture, file plan, weight sanitize map,
behavioral verification gate) and the Codable configuration (text DeepSeek-V2 MoE
decoder, CLIP-L/14 + SAM-ViT-B DeepEncoder, MLP projector, 2D tiling).
* DeepSeek-OCR: language decoder, SAM + CLIP DeepEncoder, processor, top model + factory
WIP engine port (pre-build-verification). Adds the DeepSeek-V2 MoE decoder
(standard attention), SAM-ViT-B + CLIP-L/14 DeepEncoder, 2D-tiling image
processor, and the top model orchestration (get_input_embeddings + sanitize),
registered under model_type deepseek_vl_v2 / deepseekocr. Compile + behavioral
verification against the mlx-vlm Python reference next.
* DeepSeek-OCR: compile-fixes — MLXVLM target builds green
Mechanical fixes only (no logic/math/weight-key changes): Decodable/Encodable
conformance, fileprivate access levels for private inner types, try on chained
decodeIfPresent, SAM init arg order. swift build --target MLXVLM now completes.
Behavioral verification vs mlx-vlm Python reference is the next gate.
* DeepSeek-OCR: status + ground truth + critical finding (mlx-vlm image-injection is buggy)
Captured official-PyTorch ground-truth OCR for 2 test images. Documented that
mlx-vlm 0.6.3's deepseekocr collapses on image-feature injection (bf16+int8) — so
our faithful port likely inherits it; the correctness gate is to verify/fix
inputEmbeddings against official modeling_deepseekocr.py, not mlx-vlm.
* DeepSeek-OCR: live state — model recognized + loads to SAM neck weight-key mismatch
* DeepSeek-OCR: true architecture + cache (engine+osaurus) + divergence/fix reference
Synthesized from official HF reference vs our port + vmlx cache + osaurus serving.
Decoder port is faithful. Collapse is in vision towers + zero-init newline/separator
params (not the assembly). Documents fix order, KVCacheSimple-vs-Rotating, and the
osaurus OCR-delegate blueprint.
* DeepSeek-OCR engine: load clean + correct OCR on both test images
Fixes the DeepseekOCR vmlx port so it loads the 8-bit unlimited-ocr-mlx pack
and reproduces the PyTorch ground-truth OCR text.
- SAM neck: model as an @ModuleInfo [UnaryLayer] array keyed "neck" (the
checkpoint serializes the nn.Sequential as numeric paths neck.0..neck.3 →
MLX parses them as array indices). Adds a channel-axis LayerNorm2dNHWC so
neck.{1,3} normalize over channels in NHWC. Resolves the "Unhandled keys
[neck]" load error; quantization (LM + projector only; SAM/CLIP bf16) then
applies generically.
- prepare(): derive the pixel working dtype from image_newline (bf16) instead
of the text embed weight, which is uint32 under quantization. Casting [-1,1]
pixels to uint32 zeroed the whole global view, tripping the imageOri.sum()==0
guard and silently dropping image injection.
- getAbsPosSAM(): bicubically interpolate the SAM pos-embed (64x64 → crop-tile
grid) instead of returning identity, matching deepencoder.get_abs_pos_sam.
Required for the 640px crop tiles (multi-tile docs).
- Processor prompt: match the official sft_format='plain' (empty roles/seps) —
prompt is just <image>\n{instruction}, no <|User|>/<|Assistant|> markers.
- newCache: always KVCacheSimple (full-attention decoder; drop RotatingKVCache).
- Config: decode language/vision/projector sub-configs with defaults so the
same struct loads from processor_config.json (no language_config there).
- Register processor_class "DeepseekOCRProcessor" alias.
- Add tools/DeepseekOCRSmoke fast load+OCR harness.
Verified vs /tmp/ocr_reference_output.txt:
img1 -> OSAURUS OCR TEST / DeepSeek-OCR 2026
img2 -> Invoice #2026-0042 / Date: June 25, 2026 / Total: $1,337.00 USD
* DeepSeek-OCR: verified working (3 images, both paths) + document tokenizer-pack defect + prompt guidance
---------
Co-authored-by: Eric <eric@exploit.bot>