Skip to content

Release 1.10.2 - #1373

Merged
JustVugg merged 70 commits into
mainfrom
dev
Sep 6, 2026
Merged

JustVugg merged 70 commits into
mainfrom
dev

Conversation

@JustVugg

@JustVugg JustVugg commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Patch release. Three of these fixes answer reports made against 1.10.1 in the days after it shipped. No new engine; one behaviour change, opt-in.

If you hit one of these on 1.10.1, this is the fix

coli convert produced a GLM-5.3-Flash container the engine refused (#1368). It ran GLM-5.2's converter on everything; Flash nests its language model under the vision wrapper, so the embedding matched no rule, fell through to the generic fallback, and was quantized. The engine was right to refuse it, hours later, inside coli web. coli convert now reads the checkpoint's config.json and picks the converter from it. GLM-5.2's path is unchanged. An option the target converter does not take is refused, not silently dropped (#1369). And tools/convert_glm53.py, the right tool for Flash, is in the archive for the first time (#1364).

coli doctor said two core tensors were missing from a healthy GLM-5.3-Flash (#1365). The check held three literal tensor names taken from GLM-5.2. It now matches roles, prefix-agnostically, so it holds for any family that nests, including ones not written yet (#1366). Nothing was ever missing from those downloads.

The archive was missing files coli reaches (#1359): iq3_pack.py and its grid, reachable only through a subprocess-launched script. The packer follows that edge now, and data files next to a module (#1364).

Also fixed

Supported checkpoints, stated plainly

Changed

Docs

Build


Merge with Create a merge commit, not squash: main already has two commits dev does not (the #1363 Discord link merge), and a squash would break ancestry again as it did for 1.10.0. Then tag v1.10.2 and paste the text above into the release.

Donovan Yohan and others added 30 commits August 30, 2026 14:06
make tracks file timestamps, not #include graphs, and c/Makefile has no
-MMD/-include dependency generation. Each engine rule names its prerequisites
by hand, and those lists have drifted behind what the sources actually
include. The result is the worst kind of build outcome: make reports success,
changes nothing, and leaves a STALE binary that looks freshly built.

Reproduce on a clean checkout:

    cd c
    make colibri                          # builds
    touch edge_runtime.h && make colibri
    make: 'colibri' is up to date.        # but colibri.c includes it

Touching a header the rule DOES list (st.h) relinks as expected, so this is
specifically the omitted ones. Missing per engine, all of them headers the
source includes and that exist in c/:

    colibri   13  abl.h backend_cuda.h backend_metal.h backend_vulkan.h
                  decode_batch.h edge_adapters.h edge_runtime.h
                  edge_tok_internal.h schema_gbnf.h segment_adapter_internal.h
                  segment_adapters.h segment_runtime.h tier.h
    inkling    8  backend_cuda_ink.h backend_metal.h edge_adapters.h
                  edge_runtime.h edge_tok_internal.h
                  segment_adapter_internal.h segment_adapters.h
                  segment_runtime.h
    kimi_k3    9  backend_metal.h backend_vulkan.h edge_adapters.h
                  edge_runtime.h edge_tok_internal.h hybrid_split.h
                  segment_adapter_internal.h segment_adapters.h
                  segment_runtime.h
    olmoe      7  edge_adapters.h edge_runtime.h edge_tok_internal.h
                  fused_simd.h segment_adapter_internal.h segment_adapters.h
                  segment_runtime.h
    qwen36     6  edge_adapter_internal.h edge_adapters.h edge_runtime.h
                  segment_adapter_internal.h segment_adapters.h
                  segment_runtime.h

Adds tests/test_makefile_deps.py so the two lists cannot drift apart again.
It compares each engine rule's prerequisites against the headers its source
includes, counting only headers that exist in c/ (system headers and
generated files are not prerequisites). Verified to go red against the
unfixed Makefile and green with it, so it discriminates rather than passing
vacuously. Picked up by the existing `unittest discover -p 'test_*.py'`.

make -C c test-python: 705 tests, OK (skipped=35).
Both follow-ups asked for in review of #1284, rebased from main onto dev so
the two engines that only exist here are in scope.

1. glm53 and qwen38.

The branch was cut from main, which has five engines; dev has seven. So the
first version of this fix left glm53 and qwen38 with exactly the bug it fixed
for the other five. Verified before touching anything, and without a compiler
-- `make -q` answers the timestamp question the recipe would answer, so this
needs no toolchain and no build:

    touch glm53 && sleep 1 && touch edge_runtime.h
    make -q glm53; echo $?
    0        # "up to date", though glm53.c includes it

Missing per engine, all of them headers the source includes and that exist
in c/:

    glm53    7  backend_vulkan.h edge_adapter_internal.h edge_adapters.h
                edge_runtime.h segment_adapter_internal.h segment_adapters.h
                segment_runtime.h
    qwen38   7  edge_adapter_internal.h edge_adapters.h edge_runtime.h
                qwen38_vision.h segment_adapter_internal.h segment_adapters.h
                segment_runtime.h

After the fix `make -q` exits 1 -- would relink -- for every engine against a
header that was previously missing from its rule: glm53 (edge_runtime.h,
backend_vulkan.h), qwen38 (edge_runtime.h, qwen38_vision.h), colibri, inkling,
kimi_k3, olmoe, qwen36.

2. ENGINE_RULES is gone; the list comes from the Makefile.

The review is right that the hand-kept dict was the same class of bug as the
one being fixed. `_prereqs_by_target()` filtered on it, so
`assertEqual(set(prereqs), set(ENGINE_RULES))` was load-bearing in one
direction only: it caught a rule renamed or removed, and could not catch a
rule that was never added. That is why it passed green while glm53 was broken.

Now anything matching `NAME$(EXE):` with a `NAME.c` beside it is checked, so
there is no list to keep in sync and the next engine is covered without
anyone remembering. Measured, old test against new:

    scenario                                    old        new
    fixed Makefile (baseline)                   PASS       PASS
    glm53+qwen38 unfixed (the real bug)         PASS       FAIL
    a new engine rule omitting st.h             PASS       FAIL
    rule syntax changed, parse matches nothing  FAIL       FAIL

Two things fall out of deriving rather than listing:

  - iobench is now covered too. It is not an engine, but it is built from one
    translation unit by the same kind of rule, so it has the same failure mode
    and gets the same guarantee for free. Its list is already complete, so this
    adds no Makefile change. fp8_bench is correctly not picked up -- there is
    no fp8_bench.c, it is built from backend_cuda.cu.
  - deriving can go vacuous in a way listing cannot. If the rule syntax ever
    changes, the parse matches nothing and a header check over zero targets
    passes, which reads exactly like a clean tree. So the parse is now asserted
    non-empty and cross-checked against FAMILIES -- the registry is already the
    authority on what must be buildable, and test_family_registry already pins
    that each family has a rule, so this reuses that rather than keeping a
    second list. deepseek_v4 is excluded by name: it is built by
    Makefile.deepseek-v4 through a phony target and has no prerequisite list
    here to check.

Line continuations are folded before parsing, with the same
`re.sub(r"\\\n[ \t]*", " ", ...)` idiom test_family_registry uses: a rule
wrapped across lines would otherwise present a truncated prerequisite list and
report headers as missing that are listed on the next line. No engine rule
wraps today; the failure would be a false positive rather than a silent pass,
but it costs one line to not have it.

The include regex now tolerates leading whitespace. No engine has an indented
`#include "..."` today (checked, 0 across all eight sources), but one inside an
`#ifdef` is exactly where a new header would land, and missing it fails in the
silent direction.
#1304: `coli convert` su Qwen/Qwen3.8-Flash-Next. Il convertitore e' di
GLM-5.2 e non controlla cosa gli viene dato: i tensori BF16 che non
riconosce come esperti GLM passano upcastati a f32, ogni shard
raddoppia (3.36 GB -> 6.71 GB nel log del reporter), e il run si e'
fermato a 424 GB scritti su 360 di sorgente, a disco pieno.

Ora check_model_family legge il model_type e decide PRIMA del primo
shard: config.json pesa 5 KB e stabilisce se i prossimi 300 GB hanno
senso. GLM-5.2 passa; ogni altra famiglia viene rifiutata con il
puntatore giusto (Qwen3.8: nessuna conversione esiste, si esegue l'FP8
ufficiale; GLM-5.3: convert_glm53.py; e cosi' via); l'ignoto viene
rifiutato senza indovinare. --indir senza config.json procede come
sempre: le fixture sintetiche non ne hanno. --repo con config.json
illeggibile procede con un avviso: il fallimento utile e' sul
model_type sbagliato, non sulla rete.

Gli insiemi della guardia sono copie di family_registry, e una copia
che diverge e' il difetto che la guardia cura: il test li confronta
col registry e fallisce alla prima famiglia aggiunta o rinominata.
Se GLM-5.2 rinominasse un model_type senza aggiornare qui, il
convertitore rifiuterebbe il modello che esiste per servire -- e' il
caso che il contratto blocca per primo.

Provato end-to-end: --indir con config qwen4_exp esce 1 col messaggio
per-famiglia; --selftest GLM invariato; i test esistenti del
convertitore restano verdi.

Co-authored-by: JustVugg <JustVugg@users.noreply.github.com>
…mparison (#1272)

Five rows for the 12900K + 990 Pro box (commit-stamped 72d3d37, July 2026): the
MTP=0 result with its mechanism (600 experts/token against 1,062 with MTP on),
the CUDA expert tier separated from the config change it rode in with, a two-drive
file-level split, and six consecutive REPIN rounds showing where learned pins
converge.

One row for an M4 Pro running OLMoE fully resident, comparing cap 32 against cap
64 in interleaved pairs: cap 32 wins 7 of 8, median 19.1 against 16.3 tok/s, at
half the resident footprint.

Two notes. The cap comparison is paired because unpaired sweeps on that host do
not agree with each other about the sign -- one five-run sweep put cap 64 ahead,
a later ten-run block put cap 32 ahead by 35%, and cap 32 alone ranged 13.97 to
20.62 across sessions. Same-session alternation is the only thing that resolves
it, which is the control the decode failure ledger already asks for. And the
file-level split is recorded as the negative half of #1249: same class of
hardware, +5.5% against +37.5%, because splitting whole shards only parallelises
reads that are already concurrent and that box sat at queue depth ~1.
#1318)

#1317: 141 shard su 142 presenti e una riga sola di errore, "missing
tensor: model.embed_tokens.weight". La diagnostica che conta gli shard,
vede i buchi nella numerazione e suggerisce il resume col flag anti-xet
ESISTEVA gia' -- st_die_missing, dal 25 luglio -- ma sette punti fatali
di st.h uscivano con la riga nuda senza chiamarla. Il percorso
dell'embed, cioe' il primo tensore che ogni caricamento tocca, era fra
questi: il fallimento piu' comune al mondo era l'unico senza
spiegazione. Riprodotto sul motore attuale prima di toccare qualcosa.

I sette siti fatali ora chiamano st_die_missing. L'ottavo sito, la
probe che ritorna -1, resta com'era: non e' fatale e i chiamanti
gestiscono il ritorno.

Il test costruisce il container a mano, byte per byte -- niente numpy,
niente torch, nessuna fixture esterna: config GLM tiny piu' uno shard
senza embed, lo scenario esatto della segnalazione. Pretende il
conteggio shard, la frase sul core tensor e il suggerimento di resume,
e vieta il ritorno della riga nuda. Controllo negativo: con i sette
siti ripristinati fallisce; ripristinando UN sito solo non fallisce,
perche' vede solo la prima morte del percorso embed -- il contratto
protegge il caso comune, non ogni sito singolarmente, ed e' detto qui
perche' resti vero.

Il job Python della CI ora compila anche colibri: l'ha preteso il
contratto di #1297 da solo, appena il test nuovo e' comparso con la sua
guardia sul binario.

Co-authored-by: JustVugg <JustVugg@users.noreply.github.com>
v4_hwinfo_emit() reads /proc/cpuinfo for the CPU name and /proc/meminfo
for memory. Neither exists on macOS, both fopen() calls return NULL, and
the line goes out with zeroes and no error:

    HWINFO 16 0.0 0.0 0 0.0 unknown|v4-cpu

The gateway forwards that faithfully, so the dashboard's Runtime panel
renders "unknown" with "0 GB RAM / 0 GB free" while correctly showing the
core count. Nothing looks broken enough to investigate, which is why it
survived.

Add a Darwin branch using machdep.cpu.brand_string and hw.memsize, and
delegate availability to coli_v4_os_available_memory(), which already
carries a reviewed Darwin branch (free + inactive + purgeable pages, the
MemAvailable equivalent) rather than repeating the detection. It fills
only what /proc could not supply, so the Linux path is byte-identical.

Units follow the Linux branch exactly: it reports kB/1e6, i.e. decimal
GB, which is the contract the web UI was built against, so this uses
bytes/1e9 rather than bytes/2^30.

The include sits directly above its only caller rather than with the
other platform includes near the top. This file is an amalgamation
compiled once per -DCOLI_V4_UNIT_*, and that upper region is not part of
the unit containing v4_hwinfo_emit; placing it there produces "call to
undeclared function 'sysctlbyname'".

Verified on macOS 15, Apple M3 Max, by running the serve engine and
reading the emitted line:

    before: HWINFO 16   0.0   0.0 0 0.0 unknown|v4-cpu
    after:  HWINFO 16 137.4 105.5 0 0.0 Apple M3 Max|v4-cpu

which matches the machine: sysctl machdep.cpu.brand_string reports
"Apple M3 Max" and hw.memsize reports 137.4 GB.

Co-authored-by: Vincenzo Fornaro <vincenzofornaro89@gmail.com>
…16_order (#1314)

13b8f16 deliberately traded throughput for determinism so the reference
matvec accumulates in the same order as the optimized rows16 path,
recording 155 against 235 GFLOP/s and roughly 6 to 9 percent end to end.

That ordering guarantee can be kept while recovering much of the speed.
Processing four output rows per pass, each with its own accumulator,
leaves every row's addition sequence untouched: same operands, same
column order, same intermediate roundings. Only the interleaving of four
independent chains differs, so results are unchanged. The gain is
latency hiding plus one activation load feeding four rows, and a short
tail clamps spare row indices with guarded stores.

Measured on an Apple M3 Max at p256 / 40 tokens with the companion
matmul_fp8 change, N=2, deterministic: decode 49.97s to 36.51s and
0.7805 to 1.0683 tok/s, plus 36.80 percent, time to first token down
18.0 percent. Output md5 is identical to the unmodified engine.

Applied on its own this recovers part of that; the quoted figure is for
both changes together.

Co-authored-by: Vincenzo Fornaro <vincenzofornaro89@gmail.com>
The Darwin arm of the Makefile finds libomp with `brew --prefix libomp`.
On a CLT-only Mac driven non-interactively (`make` from a script, over
SSH, in CI) Homebrew is routinely installed but not on PATH, the probe
prints nothing, and the OpenMP arm never engages. Nothing warns: the
build is a valid single-threaded engine with libomp sitting right there
on disk.

Measured on an M4 Max with the Qwen3.6-35B qpack: 0.87 tok/s from the
silent single-threaded build, 4.44 tok/s (range 3.80-4.76, thermal) once
OpenMP engaged, TTFT 3.89 s to about 0.7 s, output byte-identical by
cmp. Engine code is unchanged by this commit -- every omp site is
row-partitioned and deterministic by construction -- so the difference
is entirely the missing flag.

When the brew probe comes back empty, fall back to the two standard
Homebrew prefixes (/opt/homebrew/opt/libomp on Apple silicon,
/usr/local/opt/libomp on Intel). The existing artifact check still runs
after the fallback: both omp.h and libomp.* must exist before any flag
is added, so a bare prefix directory without the library still yields
the plain build, as before.

(cherry picked from commit 44ddb39f98a05a332921dd47491b53d7b55fa3c7)
load_tokenizer indexed the BPE merge table from tokenizer.json by
splitting each entry on a space: the legacy "a b" string spelling.
tokenizers >= 0.20 (transformers 4.45+) writes each merge as a
two-element array ["a","b"] instead, and that is what the Qwen3.6
checkpoints ship. Every entry failed the string test, `continue` skipped
it, and the reader finished with ZERO merges and no message.

Nothing downstream treats an empty merge table as an error: bpe_piece
reads it as "nothing to merge", so encode_text degraded to one token per
byte-symbol. Measured on a real-model run before the fix: a 24-character
prompt became 24 tokens, and the model was fed a sequence it had never
seen in training.

Accept both spellings. A string entry splits on the first space as
before; a two-element array of strings takes its halves directly; any
other shape is skipped as it was. Both forms index into the same
smap_put(&g_merge, key, r) table with the same 0x1F-joined key, so the
rest of the tokenizer is unchanged.

tests/test_qwen36_tok_merges writes two tokenizer.json files for the
same five-piece vocabulary that differ only in the merge spelling and
asserts that both encode "in in" to the same merged ids [in, Ġin] -- one
plain merge (i+n) and one chained merge (Ġ+in) whose input the first
produces. On the pre-fix reader the pair form loads zero merges and the
encoding comes back as one id per byte-symbol, so the gate fails. The
scratch directories the test binaries write (tests/tmp_*) are added to
.gitignore.

(cherry picked from commit d783505d3e0b97b0fa078d7320877f3c4bd740e1)
Moves the idea from #1324 to where it belongs: one primitive at the st.h
layer instead of a per-engine table, so every engine that adopts it pays for
one implementation, not N.

Adds st_map_shard_range(fd, off, nbytes) next to the existing st_map_raw:
maps a whole FILE once (not per tensor/range -- expert weight pieces sit at
byte offsets that are not page-aligned, and mapping each of a model's
~12000+ expert pieces individually would mean thousands of small mappings
for no benefit over one mapping per shard), keeps it for the process
lifetime, and serves any byte range inside it with no allocation or copy.
Portable via the existing compat_map_readonly (already used by st_map_raw),
so Windows gets the same path as POSIX. A failed or disabled mapping
returns NULL; every call site here falls back to its unmodified pread path
on NULL, so this primitive never changes behavior on its own.

Off by default (COLI_MAP_EXPERTS unset or 0): st_map_experts_enabled()
short-circuits to NULL immediately and every engine takes the exact pread
path it always has. This is deliberate per review on #1324 -- the flip to
mapped-by-default should follow a measurement of the global-page-cache-
scarcity regime (small box, large model), not precede it.

Wires it into the two engines that had their own bespoke version of this
(glm53.c's g_fmap table from #1324, qwen38's q38_shard_map table from the
qwen38 port) -- both now call the shared primitive instead, removing the
duplicate table/logic. Neither engine's default behavior changes.

Verified on both, same prompt, --temp 0, pread vs COLI_MAP_EXPERTS=1:

  GLM-5.3-Flash int4-g64:        teacher_forcing md5 identical, 1.103 -> 1.983 tok/s
  Qwen3.8-Flash-Next-FP8:        generated-text md5 identical

qwen38 (default, CPU-only target) and qwen38 VK=1 both still build and link
with no Vulkan dependency, unaffected by this change. glm53, kimi_k3 and
colibri also build clean (kimi_k3.c already used st_map_raw from the same
file; this addition is purely additive).

Supersedes the glm53-only version in #1324, which should close in favor of
this one.
… ours (#1327)

GLM-5.3 non sa non ragionare. Nel suo chat_template.jinja
`effective_reasoning_effort` ha un ramo else che vale 'max', quindi non
e' mai none e la riga di sistema esce a ogni render; e il prompt di
generazione apre sempre <think>. Di enable_thinking, in quel template,
non c'e' traccia.

Il nostro --no-think produceva invece

    [gMASK]<sop><|user|>ciao<|assistant|><think></think>

che si discosta in due punti da qualunque forma il modello abbia visto:
manca la riga di effort, e il blocco e' chiuso invece che aperto. E'
la causa di #1278 -- gli si chiede una posizione fuori distribuzione e
lui continua a ragionare, che e' cio' per cui e' addestrato.

Meta' di quella deviazione l'ho aggiunta io in #1282, togliendo la riga
di effort e giustificandolo con un meccanismo ("il modello riapre un
<think>") che ho poi misurato falso e ritirato su #1278. Ho reso il
prompt piu' lontano dal template mentre annunciavo una correzione.

Ora enable_thinking=False significa "il minimo che il modello
supporta", Reasoning Effort: Low, con il blocco aperto come sempre.
Nascondere il pensiero e' compito del gateway, non del prompt. Le tre
modalita' rendono byte per byte come il template renderizzato con
jinja2 (verificato accanto).

Il test proteggeva l'errore: pretendeva le due deviazioni come
contratto, col commento che spiegava la teoria ritirata. Ora confronta
col template reso con effort 'low' invece che con una forma scritta a
mano, e rifiuta esplicitamente il ritorno del blocco chiuso e la
sparizione della riga di effort. Controllo negativo: rimessa la forma
di #1282, fallisce mostrando i due prompt.

Il commento nel renderer che sosteneva che <think></think> fosse "uno
stato su cui il modello e' addestrato" e' corretto: e' vero per un
turno passato, falso per la posizione da cui il modello scrive.

Diagnosi di acedogblast in #1311. 205 test server verdi, template a 25
rese identiche.

Co-authored-by: JustVugg <JustVugg@users.noreply.github.com>
…1329)

#1328: test_cancels_generation_after_consumer_disconnects falliva a caso
sul job Windows con "Lists differ: [] != ['x']", su PR che non toccano
quel percorso. Non era Windows: era una corsa, e Windows e' solo dove
la finestra e' piu' larga.

cancelled tornava True da subito, quindi il ramo idle -- che esiste
apposta per cancellare PRIMA del primo frame (#908) -- correva contro
l'arrivo del frame stesso. Se vinceva l'idle, nessun token veniva
consegnato e l'asserzione su output falliva senza che nulla fosse rotto.
Fra l'altro i due test si sovrapponevano: il caso pre-primo-frame ha
gia' il suo, deterministico.

Ora il flag si alza dentro il sink. Nel ramo "data" decode() consegna il
token PRIMA che cancelled() venga interrogato, quindi l'ordine lo
garantisce il codice e non lo scheduler -- ed e' lo scenario che il nome
del test promette: il consumatore se ne va dopo aver ricevuto qualcosa.

Il tetto sui poll e' l'altra meta', e l'ho aggiunto dopo aver misurato
che serviva: con il solo flag, un guasto che impedisce la consegna del
token lo lascia basso per sempre e il test si APPENDE invece di
fallire. Il ramo idle interroga cancelled() ogni 50 ms, quindi dopo un
secondo si cancella comunque. Rotto decode() apposta: fallisce in 2s con
"[] != ['x']", che e' la diagnosi giusta.

Controlli: 20 giri su 20 verdi; decode rotto -> fallimento in 2s; suite
test_openai_server verde. Disabilitare il cancel del ramo data NON fa
fallire il test, ed e' corretto: il contratto e' che la cancellazione
avvenga, non quale ramo la esegua.

Co-authored-by: JustVugg <JustVugg@users.noreply.github.com>
#1333 di jmpmachado: dsa_score_single -- l'unica funzione che leggerebbe
la cache Ic per scegliere le posizioni top-K -- non ha chiamanti. Una
grep su tutto il repo trova solo la sua definizione. Confermato.

Guardando meglio, il buco e' piu' largo di come e' stato segnalato: i
tensori caricati per ogni layer DSA sono CINQUE (w_k, w_q, w_p, kn_w,
kn_b) e di questi w_q, w_p e kn_b non sono toccati NEMMENO dal codice
morto -- si caricano dal disco e si liberano a fine vita, e basta. Piu'
la cache Ic da [max_t * index_hd] float per layer, piu' per ogni token
un matmul, un rmsnorm e una rope.

Ora tutto questo sta dietro KIMI_DSA_INDEXER, spento di default. Non
cancellato: quel che manca e' collegare dsa_score_single, e chi lo fara'
ha bisogno di questi pesi.

L'avvertenza sta nel commento, perche' e' la parte che conta: collegarlo
CAMBIA le uscite, l'attenzione diventa sparsa dove oggi e' densa, quindi
va fatto contro l'oracolo token-exact e non a occhio.

Prove: l'oracolo tiny passa identico con l'interruttore spento e acceso
(76/76 posizioni in teacher forcing, kimi-k3-ckpt compreso) -- cioe'
quel lavoro non serviva a nessuno, che e' il punto della segnalazione.
Sulla fixture il risparmio e' 8 KB perche' la fixture e' 1.7 MB; sul
modello vero scala con index_hd x hidden x 5 x layer piu' la cache, e
non ho l'hardware per misurarlo qui.

Un primo confronto l'avevo fatto sull'md5 dello stdout e dava
"identici": era l'md5 della stringa vuota, entrambe le esecuzioni non
stampavano niente. L'oracolo sopra e' la misura vera.
#1332 di jmpmachado: il protocollo documenta "CANCEL <id> (abort
current turn)", ma in qwen36 l'unico posto che legge un CANCEL --
serve_read_req -- viene chiamato solo FRA una richiesta e l'altra. Il
comando arrivava quando il turno che doveva fermare era gia' finito.

Il difetto e' piu' largo di come e' stato segnalato: cinque motori su
sette non guardano stdin durante il decode. colibri lo fa dal #678
(mux_ctl_poll) e kimi_k3 ha il suo; glm53, qwen36, qwen38, inkling,
olmoe e deepseek_v4 no -- e glm53 lo ammette nel proprio commento, "un
CANCEL arriva sempre per una che non e' piu' in volo", rispondendo
NOT_FOUND.

Conta perche' il gateway, dopo aver mandato CANCEL, aspetta l'ack
tenendo l'ammissione dello scheduler: su quei motori un client che si
disconnette non libera niente, si aspetta comunque la fine del turno.

serve_poll.h estrae la primitiva di colibri -- select con timeout zero
su POSIX, PeekNamedPipe su Windows -- perche' gli altri non debbano
reinventarla. Guarda soltanto se c'e' qualcosa da leggere: il formato
delle righe resta al chiamante, che lo conosce.

Regola sui casi ignoti: se la piattaforma non sa dire se stdin e'
pronto si risponde "non pronto" e si torna al comportamento di prima.
Mai fingere "pronto": una lettura bloccante dentro il ciclo di decode
fermerebbe la generazione per sempre in attesa di un comando che
potrebbe non arrivare mai.

qwen36 e' cablato come riferimento: una guardata per token, e al CANCEL
si scarica l'UTF-8 parziale, si risponde ERROR <id> CANCELLED e si
esce. Gli altri quattro seguiranno lo stesso schema.

Il test misura il TEMPO, non solo il valore di ritorno: una versione
bloccante passerebbe un controllo sul solo risultato. Controllo
negativo: portato il timeout della select a 2 secondi, fallisce su due
asserzioni.

Il diff su qwen36 e' di sole aggiunte dentro serve_one: generate() non
e' toccata, quindi l'oracolo non puo' cambiare per costruzione.
…n branch

pipe/dup2 su MinGW hanno l'underscore: rosso su Windows UCRT64 mentre
serve_poll.h compilava benissimo. Con gli alias il test gira anche li',
ed e' quello che conta: la pipe anonima di _pipe e' esattamente cio' che
PeekNamedPipe sa interrogare, quindi il ramo Windows viene esercitato
invece di restare non provato.
Il test, appena ha potuto girare su Windows, ha trovato una differenza
di semantica vera: PeekNamedPipe FALLISCE su una pipe rotta, mentre
select() POSIX la dichiara leggibile -- e select ha ragione, perche' una
lettura li' non blocca, ritorna EOF.

Con la versione precedente, su Windows, un gateway che muore o chiude
lasciava il motore cieco fino a fine turno: coli_serve_stdin_ready
avrebbe risposto 'niente da leggere' per sempre. Ora ERROR_BROKEN_PIPE
vale 'pronto' e il chiamante distingue leggendo, come su POSIX.

Su Linux i due casi coincidono, quindi questa differenza sarebbe partita
per la produzione senza che me ne accorgessi: e' esattamente il motivo
per cui il test e' stato portato su MinGW invece che saltato li'.
…ving for nothing (#1334)

* fix(qwen36): the VRAM tier now promotes int8 experts instead of reserving for nothing

#1331 di jmpmachado, verificata riga per riga: su un container int8
qt_plan_fill riservava budget e metteva planned=1 senza guardare il
formato, mentre la promozione esigeva i puntatori int4 -- NULL, perche'
su int8 non c'e' niente da impacchettare. Il budget restava prenotato,
il flag alzato, e "if(resident||queued||planned) continue" garantiva che
quell'esperto non venisse mai piu' riconsiderato. Zero esperti in VRAM
per tutta la vita del processo, nessun messaggio, e un tier che
dall'esterno sembrava acceso e semplicemente freddo.

La risposta non e' avvisare che non funziona: e' farlo funzionare.
backend_cuda sa gia' leggere fmt=1 (int8) -- backend_cuda.cu:174 per la
taglia, :307 per la decodifica -- mancava solo che il tier glielo
offrisse. Ora il formato lo decide qt_init e lo seguono tutti:

  - G.wfmt: 4 = int4 impacchettato, 1 = int8
  - exp_bytes conta un byte per elemento su int8, mezzo su int4: senza
    questo si prometterebbe il doppio degli esperti che ci stanno
  - stage() copia i byte int8 senza lo XOR 0x88, che serve a portare i
    nibble int4 da complemento a due a binario sfalsato e su byte interi
    sarebbe corruzione silenziosa
  - lo staging e l'upload usano il passo giusto fra le tre matrici

Il caso che il kernel non sa esprimere -- int8 con scale raggruppate,
perche' backend_cuda onora gs solo per fmt=4 -- viene rifiutato in
qt_init PRIMA di riservare qualunque budget, dicendolo. Promuoverlo
darebbe numeri sbagliati; riservare e non promuovere era il difetto.

Il formato lo rileva il chiamante dalla TAGLIA SU DISCO del primo
esperto, non da meta.ebits: esiste un container i8 che dichiara ebits=4,
ed e' il motivo per cui anche il loader guarda nbytes.

Il test gira senza GPU: definisce i coli_cuda_* e REGISTRA cosa riceve
il backend. Un test fermo a "qt_init ritorna 1" sarebbe passato anche
col difetto; questo pretende che un esperto arrivi davvero, con fmt=1,
con i byte intatti. Controlli negativi: rimesso il fmt cablato a 4
fallisce su tre asserzioni; tolto lo skip dello XOR prende la
corruzione dei pesi.

* test(qwen36): setenv shim so the tier test builds on MinGW

Il test usava setenv, che MinGW non ha: rosso su Windows UCRT64 mentre
il codice di prodotto compilava benissimo. _putenv_s fa la stessa cosa.
Il tier legge la sua configurazione dall'ambiente, quindi il test deve
poterla impostare su ogni piattaforma dove gira.

---------

Co-authored-by: JustVugg <JustVugg@users.noreply.github.com>
#1333 di jmpmachado: dsa_score_single -- l'unica funzione che leggerebbe
la cache Ic per scegliere le posizioni top-K -- non ha chiamanti. Una
grep su tutto il repo trova solo la sua definizione. Confermato.

Guardando meglio, il buco e' piu' largo di come e' stato segnalato: i
tensori caricati per ogni layer DSA sono CINQUE (w_k, w_q, w_p, kn_w,
kn_b) e di questi w_q, w_p e kn_b non sono toccati NEMMENO dal codice
morto -- si caricano dal disco e si liberano a fine vita, e basta. Piu'
la cache Ic da [max_t * index_hd] float per layer, piu' per ogni token
un matmul, un rmsnorm e una rope.

Ora tutto questo sta dietro KIMI_DSA_INDEXER, spento di default. Non
cancellato: quel che manca e' collegare dsa_score_single, e chi lo fara'
ha bisogno di questi pesi.

L'avvertenza sta nel commento, perche' e' la parte che conta: collegarlo
CAMBIA le uscite, l'attenzione diventa sparsa dove oggi e' densa, quindi
va fatto contro l'oracolo token-exact e non a occhio.

Prove: l'oracolo tiny passa identico con l'interruttore spento e acceso
(76/76 posizioni in teacher forcing, kimi-k3-ckpt compreso) -- cioe'
quel lavoro non serviva a nessuno, che e' il punto della segnalazione.
Sulla fixture il risparmio e' 8 KB perche' la fixture e' 1.7 MB; sul
modello vero scala con index_hd x hidden x 5 x layer piu' la cache, e
non ho l'hardware per misurarlo qui.

Un primo confronto l'avevo fatto sull'md5 dello stdout e dava
"identici": era l'md5 della stringa vuota, entrambe le esecuzioni non
stampavano niente. L'oracolo sopra e' la misura vera.

Co-authored-by: JustVugg <JustVugg@users.noreply.github.com>
fix(serve): honor CANCEL while the turn is still running
qt_init allocated G.is_x as 32*D floats total (one device's worth),
but qt_issue strides each device's block by 8*D floats and then
writes up to 32 rows into it. Device di's block starts at 8*di*D but
can span 32*D floats, so any di>0 with a full 32-row issue overruns
its own slice and the end of the allocation -- silent heap corruption
on multi-GPU setups.

Add G.is_x_floats, size G.is_x as ndev*32*D floats, and stride each
device's block by 32*D (its true max row count) instead of 8*D.

Also factors the fake CUDA backend shared by the tier's tests out of
test_qwen36_tier_int8.c into tests/qwen36_fake_cuda.h (unchanged
behaviour, verified by rerunning it), with fake_ndev and
fake_issue_hook hooks the new multi-device test needs.

test_qwen36_tier_multidev.c fails to compile against the pre-fix tier
(G.is_x_floats does not exist yet) and passes once the fix lands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
qt_shutdown set G.th_stop and signalled only G.cv, then pthread_join()d
the uploader. But the uploader's LFRU victim path -- and qt_note_block,
qt_note_planned, qt_fill_wait -- all wait on G.cv_take, which nothing in
qt_shutdown ever broadcasts. If a caller issued a group (qt_issue sets
G.issue_open=1) and never called qt_take (the only thing that clears it
and broadcasts cv_take), a queued LFRU swap parks the uploader on that
wait forever and qt_shutdown hangs.

Broadcast G.cv_take alongside G.cv when th_stop is set. That alone
unparks the wait loop, but the uploader would then fall through to
freeing the victim's tensors and uploading into it while a group may
still reference them; instead, when th_stop is set and issue_open is
still set, abandon the swap: free the staged upload, clear the hot
slot's queued flag, and restore the victim's resident flag (it was
already cleared by qt_lfru_tick_locked before enqueue) so it stays
consistent with the tensor it still holds.

test_qwen36_tier_shutdown.c reproduces the sequence with the fake CUDA
backend and an alarm(10) watchdog: it prints "FAIL: qt_shutdown hung"
and hits the 10s alarm before the fix, returns in single-digit
milliseconds after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
The warmstart hands the tier the live RAM weights for an int8 container
(`wg = expert_is_int4 ? e->g4 : (const uint8_t *)e->g`), qt_note_planned
parks that pointer in the tier's slot, and the next statement freed it
unconditionally. The comment justified that with "on LFRU eviction
slot_ensure_int8() rematerializes from g4" -- true only for int4.

On an int8 container there is no second copy: e->g4 is NULL, so
slot_ensure_int8() returns early (`if (s->g || !s->g4) return;`) and the
CPU fallback in the decode loop dereferences NULL, while the pointer the
tier kept dangles for any later stage(). Free the int8 block only when
the container is int4, where g4/u4/d4 remain the source of truth and the
peak-RSS win still applies; COLI_KEEP_INT8 keeps its meaning there.

The warmstart body moves out of main into tier_warmstart(Model *,
int expert_is_int4) -- same code, same messages, the QT_NO_WARMSTART
check stays at the call site -- so a test can drive it without main.

tests/test_qwen36_tier_int8_engine.c is that test: it includes qwen36.c
(the test_qwen36_ctx.c pattern), the shared fake CUDA backend and
qwen36_tier.c in one TU, builds an in-memory int8 model with no
container behind it (slots pre-populated via slot_ensure_allocated and
published in the layer index, so expert_get takes its hit path), runs
the warmstart against the fake backend and checks that every planned
expert is VRAM-resident, that slot_ensure_int8 still leaves g/u/d with
the exact bytes the loader wrote, and that qs(0,eid)->g4 still points at
the live block. A second in-memory model with packed int4 slots pins the
behaviour the old line existed for: e->g is still dropped after staging
and rebuilt bit-exact from g4/u4/d4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
In mm_gemv's fmt=8 FP8 E4M3 dequantization branch, normal values previously
computed 2^(exp - 7) via exp2(float(int(exp) - 7)).

When the Metal shader library compiles with default fast-math enabled,
exp2() on Apple7 GPUs (Apple M1) introduces a 1-2 ULP underestimation for
negative arguments (exp in 1..6, representing 2^-6 .. 2^-1). This caused
96 out of 256 byte codes in tests/test_backend_metal.mm:run_fp8_lut() to
fail the strict bit-exactness check against the CPU reference decoder on M1
hardware.

Synthesize the exact power-of-two float directly by shifting the biased
exponent (exp - 7 + 127 = exp + 120) into the IEEE-754 binary32 exponent
field with as_type<float>((uint(exp) + 120u) << 23). This guarantees
0 ULP error across all GPU architectures without disabling fast-math
globally, resolving the test failure on M1.
webinariotilabs and others added 29 commits September 5, 2026 18:09
_image_bytes_from_url reads a local path with the server process's own
permissions. On a server that binds beyond loopback (which already requires
an API key) an authenticated client could send "file:///etc/passwd" and read
any file the process can reach, and the OSError message echoed the full path
back, confirming a file's existence and permissions.

Refuse '..' outright (never needed for a real image path), let COLI_IMAGE_ROOT
confine reads to one directory the same way serve_static uses relative_to(),
and make the error generic. The documented loopback single-user case (a plain
path or file:// URI) is unchanged. Covered by ImageUrlPathGuard.
Third-party actions were referenced by mutable tags (msys2/setup-msys2@v2,
hadolint/hadolint-action@v3.1.0, Jimver/cuda-toolkit@v0.2.19,
ilammy/msvc-dev-cmd@v1). A tag can be moved by whoever controls the action's
account, so a compromised maintainer would run arbitrary code with the job's
GITHUB_TOKEN, including the contents:write token in release.yml. Pin each to
the commit SHA the tag currently points at, keeping the version in a comment.

ci.yml had no permissions block; it only reads the tree, so pin its token to
contents:read as defense in depth. Official actions/* are left on tags.
The invite in the READMEs and the site returns "Invite is expired"
(Discord API code 50270), so every Discord link we publish is currently
dead: 4 READMEs plus site/index.html, 2 occurrences each.

Replaces MAaKtQRc with FkyrEeJR across all 10.
docs: replace the expired Discord invite (all 4 READMEs + site)
pack_python.py reaches a file two ways: by following imports out from
coli, and by spotting scripts that are launched as subprocesses. Only the
first kind had its own imports followed, so a module that only a
subprocess-launched script imports was left out of the release archive
while --check still reported the archive complete.

On dev this happens twice: tools/iq3_pack.py, which
tools/convert_fp8_to_int4.py imports inside quant_e8(), and
tools/engine_evidence.py, which this branch's eval_glm.py imports. Both
are now reached. The test suite grows a real-tree assertion as well as
fixture cases, because every existing case builds a disposable tree and
so could not have caught either omission.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Following the imports of a subprocess-launched script (previous commit)
packages tools/iq3_pack.py, but --xbits e8 still dies from an archive:
iq3_pack.py opens tools/iq3xxs_grid.json next to itself at line 50, and
the packer copied only .py files. The import edge was closed; the data
edge was not.

needed() now collects, along the same walk, files named by a string
literal that exist next to the module and carry a data suffix. Verified
end to end from a packed archive: iq3_pack.encode() runs and loads its
256x4 grid; delete the JSON and it is FileNotFoundError again.

The suffix allowlist is deliberate and not "anything that is not .py":
coli names colibri.c, family_registry.py names five engine sources, and
none of those belong in a release archive. tools/iq3_encode.c stays out
for the same reason it is optional in iq3_pack.py's own docstring -- the
numpy path works without it, and compiling it needs a toolchain on the
user's machine. That is a decision, not an heuristic's side effect.

The summary line now counts the two kinds separately. Saying "N Python
files" while shipping data files is how a display starts agreeing with
a bug instead of catching it.

Tests: five new cases, and the real-tree assertion now names the two
files this was about instead of an invented one. Both defects restored
individually make the suite fail (3 and 2 failures).
fix(release): close both edges the packer was missing (#1359)
coli doctor failed model.required with "2 required core tensor(s) are
missing" on every GLM-5.3-Flash checkpoint. Nothing was missing. The
check held three literal names taken from GLM-5.2, and Flash's root is
the vision wrapper, so its language model is nested:

  model.language_model.embed_tokens.weight
  model.language_model.norm.weight

Two of three names miss, hence exactly two. Reported on Discord by a
user who re-downloaded 195 GB and re-ran the converter to test the
theory, because a count does not say what was looked for.

The check now matches roles, prefix-agnostically: a token embedding, a
final norm outside the layer stack, an output head. That is what makes
it hold for families nobody has written yet, which is the real defect --
every engine already discovers its prefix at load time (qwen38.c probes
the nested name and falls back to the flat one), and doctor was the one
place that treated the prefix as a constant.

Two exclusions the tail match needs, both from real names in the tree:
.layers. so a block norm cannot stand in for the final one, and
layernorm so the vision tower's post_layernorm cannot either. Tied
embeddings satisfy the head role without an lm_head tensor, which
avoids trading this bug for the same bug on a different checkpoint.

The summary now names the roles it could not fill instead of counting
them.

Verified against three real checkpoints: both GLM-5.3-Flash containers
go from 2 missing to none, and the 116,915-tensor GLM-5.3 full container
stays complete. 46 doctor tests green; restoring the literal list turns
7 of the 8 new ones red.
fix(doctor): require core tensor roles, not GLM-5.2's literal names (#1365)
A GLM-5.3 container was announced as "GLM-5.2 744B". The engine loaded
the right weights and ran them correctly; the banner named a different
model.

This is not fixable by detection, and that is the point of the change
rather than an excuse for it. Z.ai state on GLM-5.3's own card that it
"uses the same base model as GLM-5.2 -- every gain comes from
post-training", and the checkpoints agree: diffing the two real
config.json leaves one extra key (moe_router_dtype) and the
transformers_version that wrote the file. Same 78 layers, 256 experts,
hidden 6144, moe_intermediate 2048. #1326's rule for Qwen -- name a
checkpoint by its geometry -- cannot apply, because the geometry is
identical. No rule over the configuration can name one and not the
other, now or later.

So the family says what it actually loads: GLM-5.2/5.3, at the 744B
scale both share. Verified by resolving both real configs through the
registry.

The docs move with it wherever the family is ENUMERATED: the "families
run today" line in all four READMEs, the model table and the RAM table
in README.md, the model card and the nav entry on the site. Mentions of
GLM-5.2 as a fact -- DSA came from its lightning indexer, its native MTP
head, benchmarks measured on it, the pre-converted 5.2 container on
Hugging Face -- are still true and are left alone.

Three tests pin the reason, because the obvious later "improvement" is
to add detection: both configs resolve to one family, neither differing
key may be read by the registry, and the name must state both models.
Reverting the name fails the third and nothing else.

751 Python tests green.
fix(registry): the GLM family names both models it loads (GLM-5.2/5.3)
coli convert ran tools/convert_fp8_to_int4.py on whatever it was given.
That converter is GLM-5.2's and classifies tensors by FLAT name:

    if name in ("model.embed_tokens.weight", "lm_head.weight"): return "io"

GLM-5.3-Flash nests its language model under the vision wrapper, so its
embedding is model.language_model.embed_tokens.weight. It matched no
rule, fell into the final endswith(".weight") fallback, and was
quantized. Hours later glm53.c read it with load_f32, refused the U8,
and the engine died inside coli web. Nothing on that path was wrong
except the first choice.

The family now declares its converter and which coli options that script
actually takes, and coli reads config.json -- 5 KB, the same hook the
converter's own guard uses -- to choose. GLM-5.2's command is unchanged,
including the int8 MTP second pass. GLM-5.3-Flash reaches
convert_glm53.py, with no precision flags and one pass, because that
script has neither. Families coli cannot drive keep today's path, where
the #1305 guard refuses with a per-family pointer; keeping a second copy
of that map here is the defect this commit is closing.

An option the target does not take is REFUSED, not dropped: --ebits on a
converter with no such flag would otherwise give a precision the user
never chose, silently. When the family cannot be determined -- no
network, no huggingface_hub -- the old command runs unchanged, so a
working conversion never fails because a metadata fetch did.

Two things this surfaced.

tools/convert_glm53.py was never in any release archive, on dev or in
1.10.1. The documented fix for #1368 could not be run from a download.
It is packaged now: naming a script from the registry is a third way to
reach a file, and pack_python.py follows it like the other two. The
existing release test caught this as a regression from moving the name
out of a literal call site, which is exactly the job it was written for.

The generic banner said "GLM-5.2 · 744B" when no model was given: an
example that aged, and since #1367 that family is not even called that.
It now counts the families from the registry.

Negative controls: reverting the dispatch fails the routing test,
reverting the packer rule fails the release test.
The refusal of an option the target converter does not take scanned
sys.argv for the literal flag. That misses --ebits=3 and every
abbreviation argparse accepts (--eb 3), so those forms were dropped
silently -- the exact failure the refusal exists to prevent.

The four precision options now default to None in the parser, so
"written" is "not None" whatever the spelling, and the real defaults
are applied in one place afterwards. Verified through the real parser
with --ebits 3, --ebits=3, --eb 3 and --xbits=4 against a glm5_next
checkpoint: all four refused before any converter is launched; an
accepted --group-size=128 passes through as 128.
fix(convert): coli convert picks the converter from the checkpoint (#1368)
#1310 (merged 2026-09-02, not in 1.10.1) made the REAP-pruned DeepSeek
V4 Flash load: puwaer/DeepSeek-V4-Flash-0731-reap-150b, 85 GB, 132 of
the 256 routed experts, same engine, no conversion. A second user
confirmed it runs. Nothing in README or docs said so.

Documented in the model table, the RAM table and the V4 download
section, with the layout detail that made #1310 necessary.

And one label fixed on the way. The banner announced that checkpoint as
"DeepSeek V4 Flash 284B": display_scale is the reference checkpoint's
number, printed for every checkpoint of the family. That is #1367 again,
except that here the config can tell the two apart -- 43 layers in both,
256 experts against 132 -- so the banner must. The family now records the
reference expert count (from the vendor config.json) and, when a
checkpoint's count differs, the banner prints the measured geometry the
existing unknown-model path already produces: 43L x 132E MoE. Checked on
the real config.json of both checkpoints; the official one still says
284B. Only deepseek_v4 declares a reference, so no other family's banner
changes. Reverting the branch fails exactly the new REAP test.
alarm()/SIGALRM exist on POSIX and not on MinGW, and the Windows UCRT64
job is what proved it: the test could not build there. The test already
uses pthread and nanosleep, so a detached watchdog thread does the same
job everywhere it builds: sleep up to 10 s in 100 ms steps, and if
qt_shutdown() has not returned by then, print the same line and _exit(1).

Verified on Linux: the test passes; a probe with the flag never set exits
1 after 10 s.
Version in the six places it lives (version.py, four README badges, the
site's shipping line); Cargo.lock's 1.10.1 is httparse/hyper, not ours.

CHANGELOG had stopped at 1.7.0: the [Unreleased] block was Qwen3.8, which
shipped as 1.10.0 on 2026-08-31 per that release's notes, so it is
labelled as such, 1.10.1 is recorded as the packaging repair it was, and
1.10.2 lists what dev accumulated since.
docs+banner: DeepSeek V4 Flash REAP-150B is supported, and is not 284B
fix(qwen36): three CUDA expert-tier bugs (#1339, #1340, #1341)
Resolves the conflict on the glm53 FamilyLimits line: dev had raised the
interactive output cap to 16384 (#1302) on the same line this PR changes.
Kept dev's arguments and this PR's max_kv_slots=16, which matches
GLM53_MAX_SLOTS in glm53.c.
docs: lead Windows DeepSeek users to release launcher
No conflicts. Pushed by a maintainer so that CI runs on this PR: as a
first-time contribution it had never been allowed to run.
fix(glm53): expose supported KV slots
Security: confine image_url local reads and pin CI actions by SHA
@JustVugg
JustVugg merged commit fd93c41 into main Sep 6, 2026
50 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.