Skip to content

tools: template_bom.py — the chat template as a CycloneDX component (the proposal, executable) - #1131

Merged
bong-water-water-bong merged 4 commits into
mainfrom
i-tmplsec-template-bom
Nov 5, 2026
Merged

bong-water-water-bong merged 4 commits into
mainfrom
i-tmplsec-template-bom

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

What

docs/template-publishing.md (merged with #1089) 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 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:

  • input is a .gguf (only the metadata header is read) or a model directory — the template comes from
    tokenizer_config.json's chat_template, else chat_template.jinja beside it, i.e. the same order the
    engine 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;
  • output is a CycloneDX 1.6 data component: the digest in hashes, the provenance in properties
    (onebit:template:path, onebit:template:source-file, onebit:template:bytes); --bom wraps a whole
    document, the default is JSON Lines so it composes with the other tools;
  • a model that carries no template gets a component with no hash and a description that says why, so
    "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-test builds two fixtures 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.

Verification (this box, 2026-11-05)

$ tools/template_bom.py --self-test
ok   gguf: sha256 / source / bytes        ok   dir: sha256 / source        ok   no template: no hashes / source
$ tools/template_bom.py ~/models/zaya1-8b-Q4_K_M.gguf
{"bom-ref": "pkg:generic/zaya1-8b-Q4_K_M.gguf?chat-template", "hashes": [{"alg": "SHA-256",
  "content": "b55ecfbbbcbb238a011465bfa139366db18790103f9205060cf2855a3f51b30f"}], ...}
$ tools/template_bom.py ~/Qwen3-0.6B-NPU2            # a Q4NX container
{"bom-ref": "pkg:generic/Qwen3-0.6B-NPU2?chat-template", "hashes": [… "a136047ba140347b…"], ...}

Both digests are the ones the merged engine prints at load (1bit serve: template sha256=…) and the
ones the template audit recorded — the BOM, the log and the card agree by construction, not by bookkeeping.

Not in this PR

  • No release/BOM output is changed: this is the emitter, not a wiring decision. Which artifact ships a BOM
    and where it is attached is the release lanes' call.
  • No schema validation (no cyclonedx CLI in the build images); the shape follows the snippet in the doc
    and is asserted by --self-test.

…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.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

#1131 ready for review at f8ea7a2 (i-tmplsec-template-bom, off main 6211c2fe). Tool + doc + one CI line; --self-test 7/7 and both real-file outputs match the digests the merged engine prints at load. Note for the reviewer: the push needed the SSH remote — the HTTPS token cannot update .github/workflows/ci.yml (workflow scope), so if the CI line is unwanted, dropping that one line is the whole diff.

…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).
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Second commit (bd48b3d) closes the rule's other half in the same tool: --sums emits the SHA256SUMS line a published file needs, so one command gives the CycloneDX component and the line a card/checksum file takes —

$ tools/template_bom.py --sums ~/models/zaya1-8b-Q4_K_M.gguf ~/Qwen3-0.6B-NPU2
b55ecfbbbcbb238a011465bfa139366db18790103f9205060cf2855a3f51b30f  zaya1-8b-Q4_K_M.gguf#tokenizer.chat_template
a136047ba140347b359d17b6ff8fa1bf8cb29d23f0c07bef7e0bf479b8680793  /home/bcloud/Qwen3-0.6B-NPU2/tokenizer_config.json#chat_template

A model that carries no template prints nothing (verified on ~/models/zyphra-orig/BlackMamba-2.8B-bf16.gguf), so a publisher's SHA256SUMS stays clean instead of gaining a 'none' row — the three no-template rows in the audit and both BlackMamba/Zamba-7B-v1 picks in the quant lane's checklist are exactly that case. Self-test is now 10 assertions (added: the two key shapes and the silence).

That makes the whole item-4 rule executable end to end: --sums for the checksum file, --bom for the provenance document, 1bit serve's load line for what actually runs.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

#1131 ready for review at bd48b3d (i-tmplsec-template-bom, off main 6211c2fe). Two commits: the CycloneDX component emitter, then --sums for the SHA256SUMS line, so the merged publishing rule (docs/template-publishing.md) is executable on both of its outputs. --self-test 10/10, wired into CI's python step; both real-file digests match what 1bit serve prints at load. Push needed the SSH remote (workflow scope for the one CI line) — dropping that line is the whole diff if it is unwanted.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() in app/template_pin.h:50-70 reads through std::ifstream in(path, std::ios::binary), so it reports 5782d1e7… 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:87 tells the reader to check the .jinja case with sha256sum chat_template.jinja — which is 5782d1e7….

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….
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

CHANGES addressed at d82ae927 — you were right on both counts, and the fix is your first option: read the directory source as bytes, so every branch hashes what is on disk.

The measurement, same shape, before and after:

$ sha256sum cont/chat_template.jinja
d66cf707b336fdfd5956028a535f527869c7adf05b88f30fb10304f61d7705bc   (64 B, CRLF)

$ tools/template_bom.py --sums cont/        # at bd48b3dc / f8ea7a21
580f68ef79d2ecf9b1cc6e5ef4036da51e6c00a3eadfdaed201b419339abc220  cont/chat_template.jinja#chat_template

$ tools/template_bom.py --sums cont/        # at d82ae927
d66cf707b336fdfd5956028a535f527869c7adf05b88f30fb10304f61d7705bc  cont/chat_template.jinja#chat_template

The line now equals sha256sum, i.e. the digest container_chat_template_sha() reports for that container.

What changed (read_template() returns bytes, no decode on the path to the digest):

  • chat_template.jinja → open(jinja, "rb").read(). No BOM strip either: the engine hashes the raw file, so stripping here would recreate the same class of disagreement on a BOM'd file.
  • GGUF metadata part → used as-is (bytes(field.parts[-1])), so len()/sha256() are over the stored bytes rather than over a decode(..., "replace") round-trip.
  • tokenizer_config.json → the JSON string .encode()d; as you noted that path cannot have the bug, and this keeps one representation.
  • onebit:template:bytes is now len(bytes_on_disk).

The fixture you asked for: --self-test writes a CRLF template with "wb" and asserts (a) the component digest equals sha256 of the bytes written, (b) it is not the LF-normalised digest, (c) the byte count and the --sums digest match the same raw bytes. (a)+(b) fail on the previous head by construction — the old reader produced exactly the LF-normalised value — and the set is 14/14 here.

Nothing recorded moved (both branches re-run against the real files):

~/models/zaya1-8b-Q4_K_M.gguf   b55ecfbbbcbb238a011465bfa139366db18790103f9205060cf2855a3f51b30f   (unchanged)
~/Qwen3-0.6B-NPU2              a136047ba140347b359d17b6ff8fa1bf8cb29d23f0c07bef7e0bf479b8680793   (unchanged)

so no pin in config/template-pins.json and no card row in docs/template-publishing.md changes. tools/copyright.py --check rc 0, py_compile clean. The doc paragraph that says the BOM digest equals the serve log's now also states the CRLF case explicitly, since that was the property that did not hold.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
…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.
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

1089 - Partially compliant

Compliant requirements:

  • Name the GGUF's chat-template sha256 at every load
  • Refuse a template that is not the pinned one
  • Add a pin table in config/template-pins.json
  • Support --template-pin-strict flag
  • Support ONEBIT_TEMPLATE_PIN_STRICT=1
  • Support --template-pin FILE to replace the table
  • Support --no-template-pin to log and compare nothing
  • Ensure models with no template and no pin print nothing

Non-compliant requirements:

  • None

Requires further human verification:

  • Verification of the actual hardware behavior with the specified flags and environment variables
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Potential Inconsistent Hashing

The tool reads template bytes directly from files and computes SHA-256 hashes. However, it's possible that the way the tool reads and hashes the bytes may differ from how the C++ code in app/template_pin.h reads and hashes the same bytes, especially for files with different line ending styles (CRLF vs LF). This could lead to a mismatch in the computed digest, causing the BOM to report a different hash than what the engine would compute during runtime.

expect("crlf: sha256 is the file's", c["hashes"][0]["content"], hashlib.sha256(raw).hexdigest())
expect("crlf: not the LF-normalised digest",
       c["hashes"][0]["content"] != hashlib.sha256(raw.replace(b"\r\n", b"\n")).hexdigest(), True)
expect("crlf: bytes", next(p["value"] for p in c["properties"] if p["name"] == "onebit:template:bytes"),
       str(len(raw)))
expect("crlf: sums digest", sums_line(crlf).split("  ")[0], hashlib.sha256(raw).hexdigest())

@bong-water-water-bong
bong-water-water-bong merged commit 1f87cca into main Nov 5, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the i-tmplsec-template-bom branch November 5, 2026 20:05
bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
…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.
bong-water-water-bong added a commit that referenced this pull request Nov 5, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant