Repository navigation
tools: template_bom.py — the chat template as a CycloneDX component (the proposal, executable) - #1131
Conversation
…the proposal, executable) `docs/template-publishing.md` proposes treating a model's `tokenizer.chat_template` as a signed part rather than an anonymous string, because a Jinja program that runs on every request should be in the dependency graph the rest of our provenance lives in. This is that proposal implemented, so the BOM, the serve log and the card row can carry the same digest instead of three bookkeepers. - `tools/template_bom.py`: for a `.gguf` (metadata header only) or a model directory (`tokenizer_config.json`'s `chat_template`, else `chat_template.jinja` — the same order the engine uses since #1095), emit a CycloneDX 1.6 `data` component with the digest in `hashes` and the provenance in `properties`; `--bom` wraps one document, default is JSON Lines so it composes. A model that carries no template gets a component with no hash and a description that says why, so "no template" is a claim a checker can verify too. - `--self-test`: two fixtures built in a temp dir (a header-only GGUF carrying a template, a directory with only `chat_template.jinja`) plus a no-template model — 7 assertions; wired into CI's python step. - `docs/template-publishing.md`: the proposal now points at the implementation, with its output. The digests it prints are the ones the merged engine prints at load: on this box `~/models/zaya1-8b-Q4_K_M.gguf` → `b55ecfbbbcbb238a…` and `~/Qwen3-0.6B-NPU2` → `a136047ba140347b…`, i.e. the same numbers `1bit serve` reports and the audit recorded, by construction rather than by bookkeeping.
|
#1131 ready for review at f8ea7a2 ( |
…eeds The tool emitted the CycloneDX component, and the publishing rule's other half — the `SHA256SUMS` line (`<sha256> <file>#tokenizer.chat_template`, or `<dir>/<source-file>#chat_template` for a container) — was still the snippet in the doc. Now one command gives both: tools/template_bom.py --sums model.gguf container-dir/ b55ecfbbbcbb238a… zaya1-8b-Q4_K_M.gguf#tokenizer.chat_template a136047ba140347b… /path/Qwen3-0.6B-NPU2/tokenizer_config.json#chat_template A model that carries no template prints nothing, so the file a publisher appends to stays clean rather than gaining a 'none' row. Self-test grows to 10 assertions (the two keys, and the no-template silence).
|
Second commit (bd48b3d) closes the rule's other half in the same tool: A model that carries no template prints nothing (verified on That makes the whole item-4 rule executable end to end: |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at f8ea7a21 — one item, and it is the property the PR exists to provide: on a container whose template is CRLF, the BOM digest and the serve log's digest differ, and the tool normalises silently.
The measurement, on the very shape the fixture names ("the shape our OGA pack imports have"):
$ sha256sum /tmp/844-out-clean/chat_template.jinja -> 8aa40ce145adb73c… (2561 bytes, 54 CRLF pairs)
$ tools/template_bom.py --bom /tmp/844-out-clean -> cd8e9439f0570856… (reports 2507 bytes)
$ python3 -c "b=open(...,'rb').read(); t=b.decode(); print(hashlib.sha256(t.replace('\r\n','\n').encode()).hexdigest())"
-> cd8e9439f0570856… <- exactly the tool's number
So the tool hashes open(…, encoding="utf-8").read(), i.e. Python's universal-newline form, while the engine's serve path (container_chat_template_sha(), #1089/#1095) opens with std::ios::binary and hashes the raw bytes. On this container that is 8aa40ce1 vs cd8e9439 — two digests for one template, which is exactly the confusion this PR is meant to end (its own words: "the BOM, the serve log and the card row can carry the same digest instead").
The GGUF half is right and I checked it against two independent references — --bom ZR1-1.5B-bf16.gguf gives b6835114b7303ddd…, which is what I computed myself from the GGUF's tokenizer.chat_template earlier tonight, and what config/template-pins.json carries. So the defect is specific to the directory source.
Why the self-test cannot catch it: the fixture writes its template with open(…, "w", encoding="utf-8") (LF only) and then expects hashlib.sha256(tpl.encode()) — the same in-memory string the reader will produce. The test compares the tool to itself, so a normalisation difference can never show up. A fixture that writes b"…\r\n…" in binary and expects the raw digest would.
Fix, either way round, but pick one and say it: read the directory source as bytes ("rb") so both branches hash what is on disk; or, if normalising is deliberate (Jinja semantics), normalise the GGUF branch identically and have the engine normalise too — otherwise the three consumers still disagree. I would take the first: hashes[].content in a BOM is supposed to identify the artefact.
Everything else checks out, and I checked the one that bit me before: tools/copyright.py --check → rc 0 (the new file has its header — I have missed that on a new file tonight, so it is now the first thing I run), py_compile clean, --self-test 5/5, the output is valid CycloneDX 1.6 with SHA-256 and onebit:template:{path,bytes,source-file} properties, and the directory branch correctly prefers tokenizer_config.json's chat_template and falls back to chat_template.jinja.
|
#1131 ready for review at bd48b3d ( |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES — one item, unchanged in cause, and bd48b3dc widens its reach
The new commit is good work on its own terms: --sums emits the right two keys (<file>#tokenizer.chat_template, <dir>/<chat_template.jinja>#chat_template), prints nothing for a model with no template, and the self-test's two new assertions cover both. tools/copyright.py --check rc 0. But it also publishes digests, so the read-mode defect from my last review is now on the surface a publisher copies into a SHA256SUMS file.
Still open at bd48b3dc: the container's .jinja template is read in text mode (open(jinja, encoding="utf-8")), i.e. through Python's universal-newline translation, while every other consumer of that digest hashes raw bytes. On a CRLF template — the shape the tool's own docstring names, an OGA pack import:
$ ls -l cont/chat_template.jinja 1368 B
$ sha256sum cont/chat_template.jinja
5782d1e7a9a30a955dd6ca80fff4bd7a4eed2a8a25c0d5fd3e18301a270fccb6
$ python3 tools/template_bom.py --sums cont/
6b8c0ab94928fedad9390a78bb54e27cad68b3d73aefcb3be276094066c0b7c9 cont/chat_template.jinja#chat_template
$ python3 -c 'import hashlib;print(hashlib.sha256(open("cont/chat_template.jinja","rb").read().replace(b"\r\n", b"\n")).hexdigest())'
6b8c0ab94928fedad9390a78bb54e27cad68b3d73aefcb3be276094066c0b7c9 <- 1314 B, i.e. the LF-normalised file
Two digest names for one template, and the published line is the one that does not match the file it names. Both other parties agree on raw bytes:
- the served side:
container_chat_template_sha()inapp/template_pin.h:50-70reads throughstd::ifstream in(path, std::ios::binary), so it reports5782d1e7…for this container and would log a mismatch against the line beside it; - the doc the tool is meant to make mechanical:
docs/template-publishing.md:87tells the reader to check the.jinjacase withsha256sum chat_template.jinja— which is5782d1e7….
So the tool is the odd one out, and the #chat_template line it now prints is exactly the artefact that is supposed to be checkable.
Why the self-test still cannot catch it, at the new head: the fixture is written with open(…, "w", encoding="utf-8") and the expectation is sha256(tpl.encode()) — the same in-memory string the reader will produce. Ten assertions, all passing, all blind to the newline mode. The set also gained dir: sums key, which asserts only the key suffix, not the digest.
Fix, one line and one fixture: read the .jinja as bytes (open(jinja, "rb").read().decode("utf-8"); a BOM strip is fine, newline translation is not), and add a CRLF template to --self-test whose expected digest is sha256 of the bytes written. That assertion fails today and passes after.
Not affected, so nobody needs to re-check them: the GGUF half is right (sha256 of the metadata bytes); and the tokenizer_config.json path cannot have this bug, because a JSON string value carries no raw newline, so json.load() and the C++ j["chat_template"].get<std::string>() see the same characters.
CI at this head: build and CodeQL success, hw_gate skipped, pr_agent red (expected while coder-llm is off).
…and the serve log agree
Review: on a CRLF container the component's digest and the digest `1bit serve` prints were two
different sha256 values for one template, because the directory branch read chat_template.jinja in
text mode — Python's universal-newline translation — while `container_chat_template_sha()` reads the
file as binary and `docs/template-publishing.md` tells a reader to check it with `sha256sum`.
$ sha256sum cont/chat_template.jinja d66cf707b336fdfd…
$ template_bom.py --sums cont/ 580f68ef79d2ecf9… (before: 1314 B, LF-normalised)
d66cf707b336fdfd… (after: the file's own digest)
read_template() now returns bytes and every branch hashes what is stored: the .jinja file as `"rb"`,
the GGUF metadata part as-is, and tokenizer_config.json's JSON string encoded (a JSON string carries no
raw newline, so it already equalled the C++ json read). --self-test gains a CRLF fixture written with
`"wb"` whose expected digest is sha256 of the bytes written, plus an assertion that it is NOT the
LF-normalised digest — that pair fails on the previous head and 14/14 pass here.
The two recorded digests are unchanged, so no committed pin or card row moves:
zaya1-8b-Q4_K_M.gguf b55ecfbbbcbb238a…, ~/Qwen3-0.6B-NPU2 a136047ba140347b….
|
CHANGES addressed at The measurement, same shape, before and after: The line now equals What changed (
The fixture you asked for: Nothing recorded moved (both branches re-run against the real files): so no pin in |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY — my CHANGES is closed, and closed on a real container, not just a fixture
The root cause is exactly what the push says it is: read_template() now returns bytes ("rb" for the Jinja file, the GGUF metadata field raw instead of a decode(…, "replace") roundtrip, t.encode() for the JSON string value, which is correct because a JSON string carries no raw newline). Every consumer hashes bytes: component(), sums_line(), and the onebit:template:bytes property.
The container this fleet actually ships shows the defect, and it is not synthetic. ~/ogaload/oga13/ctr-llama-b8 — the Llama-3.2 pack container that #1140 is about — has chat_template.jinja of 3920 B with 93 CRLF pairs and no lone CR or LF:
raw sha256 (sha256sum) b582d2fd… <- what the serve log and sha256sum report
LF-normalised digest 5816fce1… <- computed by me, from the raw bytes
old tool (bd48b3dc) 5816fce1… <- the defect, on a real container
new tool (d82ae927) b582d2fd… <- == sha256sum
My own synthetic CRLF fixture says the same thing (5782d1e7… / 1368 B now, 6b8c0ab9… / 1314 B before), and so does the --bom onebit:template:bytes property.
And the new self-test really does fail without the fix — I put the read back to text mode and ran it:
fixed (d82ae927): 14 ok, 0 FAIL, rc 0
read back to text mode: 10 ok, 4 FAIL, rc 3
FAIL crlf: sha256 is the file's: 'f5cb18bb…'
FAIL crlf: not the LF-normalised digest: False
FAIL crlf: bytes: '61'
FAIL crlf: sums digest: 'f5cb18bb…'
Four assertions, one of which asserts the negative (that the digest is not the LF-normalised one) — that is the assertion that would have caught the original bug, and it is now in the set.
No real pin moved, checked by running both heads over files on this box rather than trusting the body:
| file | old | new |
|---|---|---|
Qwen3.8-27B-NPU2 container (template from tokenizer_config.json) |
c3cf9e34… |
c3cf9e34… — and equal to my own sha256(t.encode()) |
Qwen3-0.6B-Q4_K_M.gguf, Qwen3-4B-Q4_K_M.gguf |
5da44855… |
5da44855… |
So the GGUF half is byte-for-byte what it was (the decode/encode roundtrip removal cannot change a valid-UTF-8 field, and here it did not), and the JSON path is untouched by construction.
python3 -m py_compile tools/template_bom.py clean, tools/copyright.py --check rc 0.
One non-nit for the record: the two LF-fixture assertions still compare against tpl.encode(), i.e. the in-memory string — that is the shape that hid the bug in the first place. It is fine now because the CRLF fixture pins byte fidelity explicitly, but if that fixture ever goes, the LF ones go back to being self-referential.
CI at this head: Analyze (actions/python/javascript-typescript) success, Analyze (c-cpp)/Analyze (rust)/build still in progress, hw_gate skipped, pr_agent cancelled (a concurrency cancellation, not a finding) — BLOCKED here is checks-not-finished.
…s JSON file
The pin is only as good as the number it records. Four tracked files are Q4NX containers, and for them
the tool had two defects that cancel each other out:
- `remote_template()` fell through to "hash the text file verbatim" for anything that is not a `.gguf`,
so for a `tokenizer_config.json` it would have hashed the whole JSON file (51015f78470ac7f7… for
Qwen3-0.6B-NPU2) while the engine's serve line names the `chat_template` **string**
(a136047ba140347b…). Two numbers for one template — the disagreement #1131 was about.
- `--bump` only re-read a template that was not already `null`, so the three FastFlowLM entries could
never gain one: pinned at `null`, they claimed a container carries no template when it carries
Qwen3's.
Now a `.json` path is parsed and its `chat_template` string hashed (a named-template list is refused
rather than guessed), `chat_template.jinja` stays verbatim, and `--bump`/`--pin` read the template of
every tracked file, so a `null` is a measured "no template" rather than an old one.
Measured, and cross-checked against the engine's own line:
FastFlowLM/*-NPU2 tokenizer_config.json null -> a136047ba140347b… (= the serve-log digest recorded
in #1095 for a real container)
1bit-MONSTER/Qwen3.6-35B-A3B-NPU chat_template.jinja was not tracked at all; its
tokenizer_config.json carries no template, so the template
this container runs was unpinned. Now tracked:
e84f32a23fdda276… 7764 B (== sha256sum of the raw file)
`--check` over all 147 pins is unchanged (1 moved, same body, rc 1), `--self-test` 29 -> 35.
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
…s JSON file
The pin is only as good as the number it records. Four tracked files are Q4NX containers, and for them
the tool had two defects that cancel each other out:
- `remote_template()` fell through to "hash the text file verbatim" for anything that is not a `.gguf`,
so for a `tokenizer_config.json` it would have hashed the whole JSON file (51015f78470ac7f7… for
Qwen3-0.6B-NPU2) while the engine's serve line names the `chat_template` **string**
(a136047ba140347b…). Two numbers for one template — the disagreement #1131 was about.
- `--bump` only re-read a template that was not already `null`, so the three FastFlowLM entries could
never gain one: pinned at `null`, they claimed a container carries no template when it carries
Qwen3's.
Now a `.json` path is parsed and its `chat_template` string hashed (a named-template list is refused
rather than guessed), `chat_template.jinja` stays verbatim, and `--bump`/`--pin` read the template of
every tracked file, so a `null` is a measured "no template" rather than an old one.
Measured, and cross-checked against the engine's own line:
FastFlowLM/*-NPU2 tokenizer_config.json null -> a136047ba140347b… (= the serve-log digest recorded
in #1095 for a real container)
1bit-MONSTER/Qwen3.6-35B-A3B-NPU chat_template.jinja was not tracked at all; its
tokenizer_config.json carries no template, so the template
this container runs was unpinned. Now tracked:
e84f32a23fdda276… 7764 B (== sha256sum of the raw file)
`--check` over all 147 pins is unchanged (1 moved, same body, rc 1), `--self-test` 29 -> 35.
…m model we depend on, checked daily (#1148) * models/pins.json + tools/pin_watch.py: which revision of each upstream model we depend on, checked daily The census counts what the engine can run and nothing recorded WHICH revision of an upstream model we serve, ship or convert from, so unsloth's 2026-11-04 re-quantization of Qwen3.8-Flash-Next-GGUF ("Better UD 3.1 quants"; UD-IQ4_XS went 3 shards -> 4) went unnoticed: the files we re-host are the 2026-11-02 ones, under unchanged names. models/pins.json pins 146 repos (184 files): the laya-first serve targets, the stick catalogue's rows, our published re-hosts, and the upstream repos behind them. Each entry records the commit, the files WE HOLD (content identity + size + the sha256 of the chat template that file carries) and what uses it — so "upstream moved" and "the bytes we hold are stale" are both checkable, the second one because a pin taken after a vendor re-push would otherwise store our older copy under a newer commit. tools/pin_watch.py --check (pin-watch.yml, daily 05:43 UTC) compares every pin with its upstream main, writes a Markdown report and exits 1 when anything moved; for a moved file it re-reads the chat template from the new revision (GGUF metadata prefix over an HTTP range request, no tensor) and names it as unchanged / TEMPLATE CHANGED / added / removed. It opens one PR per changed repo, bumping only that repo's entry with the report as the body; --bump refuses a pin whose tracked file vanished upstream rather than dropping it, and nothing is adopted automatically. First run against the committed file, the finding is the one that prompted this: unsloth/Qwen3.8-Flash-Next-GGUF HEAD d9620119, our copies are not its bytes UD-Q4_K_XL/…-00001-of-00004.gguf we hold 10,946,624 B 4448186216b3…, upstream 10,946,618 B acc44040f74d… UD-Q4_K_XL/…-00003-of-00004.gguf we hold 49,376,141,504 B 56758f40269c…, upstream 32,803,030,176 B 8c76e79d444b… templates unchanged (12827f24b742…, c3cf9e34abf4…) — a re-quantization, not a template change --self-test: 16 assertions on fixtures (a GGUF metadata read with and without a template, a moved pin, a stale copy at the same commit, an unreadable repo, the bump refusal), wired into ci.yml. The other 145 pins are clean today. * models/pins.json: pin the model a stick image bakes, from the image's own HF cache The "stage on the stick" half of the rule had no provenance: the image tree records which snapshot it was built from (~/stage-c25/hf/models--<org>--<name>/snapshots/<commit>), and that is a commit sha, not a guess. - `stabilityai/sd-turbo` was the one model the c25 image bakes that had no pin: added at the snapshot the cache names (`b261bac6fd2cf515557d5d0707481eafa0485ec2`), with its 5,214,561,328-byte safetensors hashed locally and found equal to the `lfs.oid` upstream serves at that commit (3f067a1b943cf162…). It is an image model and carries no chat template, so the pin records none. - The image's two chat models were already pinned from the catalogue, and the independent check agrees: both pins sit at exactly the commits the c25 cache names (`unsloth/Qwen3-4B-Instruct-2507-GGUF` @ a06e946b, `FastFlowLM/Qwen3-0.6B-NPU2` @ b5847992). 147 repos / 185 files. `--check` still reports the one stale pin (`unsloth/Qwen3.8-Flash-Next-GGUF`) and nothing else. docs/model-pins.md now says how a baked model is pinned and measures the coverage of the 154 staged files on the box: 11 match a pinned file by name, the rest are test vocabularies, conversion intermediates, or extra quant variants of repos that are pinned at revision level. * pin_watch: --self-test must not touch the network (it was taking 33 s) The fixture that exercises a moved pin reached `commits_since()`, which was not faked, so the test made a real Hugging Face call, got a 404 for `fake/moved`, and sat through the retry backoff: 33 s of wall clock for 0.08 s of CPU. Faking it takes the self-test to 84 ms and, while there, asserts the upstream commit title reaches both the change record and the report. 18 assertions, same coverage as before plus the commit-title path that the PR body renders. * pin_watch: a repo that could not be read may not look like a clean day The reviewer reproduced the failure this watcher exists to prevent: with a pins file whose only repository does not exist, `--check` printed "0 of 1 moved" and "Every pinned upstream repo is at the revision models/pins.json records", exited 0, and in pin-watch.yml that is `moved=0` -> no PR -> a green daily run. A vendor deleting, renaming or privatising a model we bake into a stick image was indistinguishable from a quiet day. Three outcomes are now three outcomes (docs/model-pins.md carries the table): - 404 is its own state: the repo is carried as `gone upstream` and reported as a finding ("a delete, a rename and a privatisation look exactly like this"), never as a skipped check. `--bump` refuses it with that sentence instead of a traceback, and `--pr-body` returns the operator's decision text. - the first line counts what was read whenever anything was not: "N of M checked, K not checked, J moved", and the all-clear sentence is printed only when nothing was left unread. - exit 2 when no pin could be read at all (len(notes) == len(pins)): the workflow fails on any code outside 0/1, so a Hub outage or a rate-limit is a red job rather than a green one. Before (the reviewer's repro, one fake repo): # Model pins: 0 of 1 moved (2026-11-05) Every pinned upstream repo is at the revision `models/pins.json` records. ## Not checked - fake/…: could not be read (HTTP Error 404: Not Found) rc=0 After: # Model pins: 1 of 1 moved (2026-11-05) ## `fake/definitely-not-a-model-1bit` **Gone upstream** — the Hub answers `404` for this repository, so every pin on it is unverified: a delete, a rename and a privatisation look exactly like this. rc=1 and for a read that failed for another reason (429/timeout, fixture in --self-test): "0 of 2 checked, 2 not checked", no all-clear, rc=2. A healthy day is byte-identical: --check over all 147 pins still writes "# Model pins: 1 of 147 moved" with the same body (diff empty), rc 1 — notes is empty on a normal run, so the strict rule costs nothing. --self-test 20 -> 29 assertions (the 404 state, the count line, the no-all-clear pair, --bump's refusal, --pr-body's text, and the rc=2 run), network-free. * pin_watch: a container's template is its chat_template string, not its JSON file The pin is only as good as the number it records. Four tracked files are Q4NX containers, and for them the tool had two defects that cancel each other out: - `remote_template()` fell through to "hash the text file verbatim" for anything that is not a `.gguf`, so for a `tokenizer_config.json` it would have hashed the whole JSON file (51015f78470ac7f7… for Qwen3-0.6B-NPU2) while the engine's serve line names the `chat_template` **string** (a136047ba140347b…). Two numbers for one template — the disagreement #1131 was about. - `--bump` only re-read a template that was not already `null`, so the three FastFlowLM entries could never gain one: pinned at `null`, they claimed a container carries no template when it carries Qwen3's. Now a `.json` path is parsed and its `chat_template` string hashed (a named-template list is refused rather than guessed), `chat_template.jinja` stays verbatim, and `--bump`/`--pin` read the template of every tracked file, so a `null` is a measured "no template" rather than an old one. Measured, and cross-checked against the engine's own line: FastFlowLM/*-NPU2 tokenizer_config.json null -> a136047ba140347b… (= the serve-log digest recorded in #1095 for a real container) 1bit-MONSTER/Qwen3.6-35B-A3B-NPU chat_template.jinja was not tracked at all; its tokenizer_config.json carries no template, so the template this container runs was unpinned. Now tracked: e84f32a23fdda276… 7764 B (== sha256sum of the raw file) `--check` over all 147 pins is unchanged (1 moved, same body, rc 1), `--self-test` 29 -> 35. --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
What
docs/template-publishing.md(merged with #1089) proposes treating a model'stokenizer.chat_templateasa signed part rather than an anonymous string — because a Jinja program that runs on every request
should live in the dependency graph the rest of our provenance lives in. This PR implements the proposal,
so the BOM, the serve log and the card row can carry the same digest instead of three bookkeepers.
tools/template_bom.py:.gguf(only the metadata header is read) or a model directory — the template comes fromtokenizer_config.json'schat_template, elsechat_template.jinjabeside it, i.e. the same order theengine has used since serve: name a model directory's chat template too, from its tokenizer_config.json (closes #1091) #1095, so the tool and the load path cannot disagree;
datacomponent: the digest inhashes, the provenance inproperties(
onebit:template:path,onebit:template:source-file,onebit:template:bytes);--bomwraps a wholedocument, the default is JSON Lines so it composes with the other tools;
"no template" is a verifiable claim too — which is the case for three published rows and both
BlackMamba/Zamba-7B-v1 picks in the quant lane's checklist;
--self-testbuilds two fixtures in a temp dir (a header-only GGUF carrying a template, a directory withonly
chat_template.jinja) plus a no-template model, 7 assertions; wired into CI's python step.Verification (this box, 2026-11-05)
Both digests are the ones the merged engine prints at load (
1bit serve: template sha256=…) and theones the template audit recorded — the BOM, the log and the card agree by construction, not by bookkeeping.
Not in this PR
and where it is attached is the release lanes' call.
cyclonedxCLI in the build images); the shape follows the snippet in the docand is asserted by
--self-test.