Skip to content

models/pins.json + tools/pin_watch.py: which revision of each upstream model we depend on, checked daily - #1148

Merged
bong-water-water-bong merged 6 commits into
mainfrom
i-tmplsec-pin-watch
Nov 5, 2026
Merged

bong-water-water-bong merged 6 commits into
mainfrom
i-tmplsec-pin-watch

Conversation

@bong-water-water-bong

@bong-water-water-bong bong-water-water-bong commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

CHANGES addressed at 9b3b5ca8 — a watcher whose "clean" and "could not check" were the same
outcome. 404 is now its own state (gone upstream, a finding that exits 1), the report's first line
counts what was read and the all-clear sentence only appears when nothing was left unread, and a run
that could read no pin exits 2, which pin-watch.yml fails on. Before/after in the commit
message; --self-test 35/35; a healthy day is byte-identical (1 of 147 moved, same body, rc 1).
The assertion counts below are corrected (the tool printed 20 at e19c85e7, not 16/18).

Third commit 5c2a3c3c — the pin recorded null for four container files that carry a template,
and would have hashed the whole JSON file instead of the chat_template string (two numbers for one
template, the #1131 defect). Now null for the three FastFlowLM packs becomes a136047ba140347b…,
the digest #1095's engine line printed on a real container, and Qwen3.6-35B-A3B-NPU's
chat_template.jinja — the template that container actually runs — is tracked for the first time.

Why

The owner asked on 2026-11-05 whether we have a daily pin for unsloth's models. We did not: tools/census.py measures which architectures the engine can run across HF, and nothing in the repository recorded which revision of an upstream model we serve, stage on the stick, benchmark, or convert from. So unsloth's 2026-11-04 re-quantization of Qwen3.8-Flash-Next-GGUF — "Better UD 3.1 quants", "mix n match engram", UD-IQ4_XS from 3 shards to 4 — went unnoticed, and the files we re-host are the 2026-11-02 ones under unchanged names.

What this adds

file what it is
models/pins.json 147 upstream repos, 186 files: the laya-first serve targets, the stick catalogue's rows, our published re-hosts, and the upstream repos behind them
tools/pin_watch.py --check (compare + report + template re-read), --pr-body, --pin, --bump, --self-test; exit 0 clean, 1 moved-or-gone, 2 nothing could be read
.github/workflows/pin-watch.yml daily 05:43 UTC: one PR per changed repo bumping only that repo's entry, report as the body
docs/model-pins.md the rule: what a pin is, why it records the bytes we hold, adoption flow, what is deliberately not pinned
docs/registry.md points at it, next to the census it complements

A pin is per repo: the commit we pinned, and per file we take from it the content identity Hugging Face reports (lfs.oid, the sha256 of the bytes; the git blob id for a small text file), the size, and the sha256 of the chat template that file carries. used_for says which serve class, catalogue row or re-host the repo is for.

files records what we hold, not only what upstream said, and the check reconciles both — a pin taken after a vendor re-push would otherwise store our older copy under the newer commit and look clean:

  • commit vs the repo's current main → "upstream moved", plus its commit titles and any new .gguf/.q4nx;
  • each file's identity vs the same path at head → "the bytes we hold are not what it serves".

For a mismatched file it also re-reads the template at the new revision — a GGUF's tokenizer.chat_template from the metadata prefix over an HTTP range request (no tensor is ever fetched) — and reports unchanged / TEMPLATE CHANGED <old> -> <new> — re-audit before adopting / added / removed. That is the gate a re-quantization has to pass before anything may adopt it: weights moving under a stable name is ordinary, the instructions the model obeys on every request moving under it is not (docs/template-publishing.md).

The first run, against the committed file — the case that prompted it

# Model pins: 1 of 147 moved (2026-11-05)
## `unsloth/Qwen3.8-Flash-Next-GGUF`
Head is still `d9620119`, but the bytes it serves are not the ones we hold at `models/pins.json`'s `2026-11-05` pin.

 MTP/mtp-Qwen3.8-Flash-Next-Q8_0.gguf     we hold 4,137,429,120 B cd87e5d1a4da…, upstream 4,137,429,120 B 58a47d6a2163…   unchanged (c3cf9e34abf4…)
 UD-Q4_K_XL/…-00001-of-00004.gguf          we hold   10,946,624 B 4448186216b3…, upstream    10,946,618 B acc44040f74d…   unchanged (12827f24b742…)
 UD-Q4_K_XL/…-00002-of-00004.gguf          we hold 49,859,583,136 B 3f342f1c1580…, upstream 49,720,539,552 B 45792ae86907…   no template, as before
 UD-Q4_K_XL/…-00003-of-00004.gguf          we hold 49,376,141,504 B 56758f40269c…, upstream 32,803,030,176 B 8c76e79d444b…   no template, as before
 UD-Q4_K_XL/…-00004-of-00004.gguf          we hold 12,087,983,520 B 753bda48b98b…, upstream 28,800,138,432 B 5de8b5dbf894…   no template, as before

Note the MTP row: same size, different bytes. A size-only check would have called that clean. The template column is the reassuring half — this re-quantization did not touch the template, so adopting it is a weights decision, not a template one.

prism-ml/Ternary-Bonsai-2-27B-gguf is pinned at its head with no held files: its rename on 2026-11-04 left no path in common with our copy, so the entry watches the revision and nothing else (recorded in the entry's used_for).

What is deliberately not pinned

  • base_model on a card is provenance, not a dependency — what we took is the file we re-hosted (upstream_gguf), and that file is pinned.
  • private repos: this repository is public, so the one private model we serve is tracked in its lane state instead.
  • third_party/* git pins keep their own mechanisms (tools/check_pins.py, the weekly tools/weekly_changes.py).

Verification

  • tools/pin_watch.py --self-test → 35/35 on fixtures, no network (0.06 s): a GGUF metadata read with and without a template, a moved pin with a changed template, a stale copy at the same commit (the case above), a transient read failure (a note, not a change), a 404 (a finding: gone upstream), the report's N of M checked, K not checked first line with and without a note, exit 2 when nothing could be read, and --bump refusing both a pin whose tracked file vanished upstream and a repo that is gone. Wired into ci.yml.
  • Real runs: --check over all 147 pins (1 moved, 146 clean); --bump on a copy of models/pins.json for the moved repo, then --check again → 0 moved, exit 0; --pr-body on the same repo produces the body the workflow posts. The reviewer's repro (a pins file whose only repo does not exist) now reports **Gone upstream** and exits 1, and a run whose reads all fail exits 2.
  • tools/copyright.py --check → rc 0.

Two notes for whoever reviews:

  1. The workflow reads an optional HF_TOKEN secret for the range reads; without it the Hub is read anonymously and the tool's existing backoff covers the rate limit. Only HRX_BUMP_TOKEN is required, as in census.yml.
  2. The push had to go over ssh: the HTTPS token has no workflow scope, so it cannot create .github/workflows/pin-watch.yml or edit ci.yml. Both files are in this PR for review.

Merging this adds the record and the daily check — it adopts nothing. The one pin that is stale (unsloth/Qwen3.8-Flash-Next-GGUF) stays as it is, listing the bytes we actually hold, until someone decides to re-take the new quants; the report is what makes that decision visible instead of invisible.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Second commit (abd0c750) closes the "stage on the stick" half of the rule with provenance instead of a guess, and it is worth a look because it turned into an independent check of the first commit.

A stick image records which snapshot it was built from — ~/stage-c25/hf/models--<org>--<name>/snapshots/<commit> — so a baked model's pin has a commit sha that is not something I chose:

baked in the c25 image pin agrees?
unsloth/Qwen3-4B-Instruct-2507-GGUF a06e946bb6b655725eafa393f4a9745d460374c9 yes, the cache names the same commit
FastFlowLM/Qwen3-0.6B-NPU2 (the Q4NX container) b584799232c962f693e0e5be99606fc4eca6811e yes
stabilityai/sd-turbo was not pinned — now added at b261bac6fd2cf515557d5d0707481eafa0485ec2 it is the only model the image bakes that the catalogue sweep had missed (an image model, no chat template)

For sd-turbo I hashed the staged file rather than trusting the name:

local  ~/stage-c25/hf/models--stabilityai--sd-turbo/snapshots/b261bac6…/sd_turbo.safetensors
       5,214,561,328 B  3f067a1b943cf162f2b8f8588f6cf5824bd5b4c7d1d88d87164b9ca123616549
upstream lfs.oid at b261bac6…                                       3f067a1b943cf162…  EQUAL

So the two chat models the image bakes pin to exactly the commits the image was built from — the first commit's pins were derived from the catalogue and the image cache was not consulted, which makes this a cross-check rather than a restatement.

Also in this commit, docs/model-pins.md now measures the staging coverage instead of implying it:

Of the 154 model files staged on this box, the 11 whose names match a pinned file are pinned at file level; the rest are test vocabularies (ggml-vocab-*), conversion intermediates, or additional quant variants of repos that are pinned at revision level — the revision check still catches a vendor re-push for those, which is the case that matters.

--check over the 147 pins still reports exactly one moved repo — unsloth/Qwen3.8-Flash-Next-GGUF, the stale copies — and nothing else; --self-test 16/16; tools/copyright.py --check rc 0 (it caught a missing Apache notice on docs/model-pins.md, now fixed).

Note for the record: the c25 cache also shows the staged Qwen3.8-Flash-Next-UD-IQ4_XS shards are the 3-shard revision, i.e. the same stale quant the first commit's report flags from the upstream side. Two independent observations of one fact.

@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 — two items, both small; the record itself is right and I reproduced its central claim independently

1. The CI build is red on this head, at the check the body says is green

$ python3 tools/copyright.py --check
run: python3 tools/copyright.py --fix
missing copyright notice: docs/model-pins.md          rc=1

$ gh run view 42085447379 --log-failed
build  Copyright notice on every file  ... python3 tools/copyright.py --check
build  Copyright notice on every file  missing copyright notice: docs/model-pins.md
build  Copyright notice on every file  ##[error]Process completed with exit code 1

docs/model-pins.md starts at # Model pins: …; every other new file here has the block, and docs/registry.md shows the house shape. python3 tools/copyright.py --fix then re-commit closes it. Why the body says rc 0 is the trap I have hit myself: copyright.py walks git-tracked files, so it passes in a tree where the new file is still untracked and only starts failing after the commit that adds it.

2. The daily check has three outcomes but two exit codes — an outage, and a deleted upstream, both look clean

check() folds every unreadable repo into notes and main() ends return 1 if changes else 0, so skips cannot affect the exit code. report() then prints the all-clear regardless of notes:

if not changes:
    out += ["Every pinned upstream repo is at the revision `models/pins.json` records.", ""]

I demonstrated it twice rather than reading it:

$ HTTPS_PROXY=http://127.0.0.1:9 python3 tools/pin_watch.py --check --pins /tmp/two-pins.json
# Model pins: 0 of 2 moved (2026-11-05)
Every pinned upstream repo is at the revision `models/pins.json` records.
## Not checked
- …Apertus-8B-Instruct-2509-GGUF: could not be read (<urlopen error [Errno 111] Connection refused>)
- …BlackMamba-1.5B-GGUF: could not be read (<urlopen error [Errno 111] Connection refused>)
exit=0

$ # a repo that has been deleted or made private:
# Model pins: 0 of 3 moved (2026-11-05)
Every pinned upstream repo is at the revision `models/pins.json` records.
- unsloth/this-repo-does-not-exist-1148: could not be read (HTTP Error 404: Not Found) — the pin was not checked
exit=0

In pin-watch.yml both are moved=0 → the bump step is skipped → a green daily run. Two things make this worse than a missing feature:

  • the summary line is an assertion that every pin was verified, printed in the run where none was — the same class as #1111's skip markers printing PASS, which this fleet has now fixed twice;
  • a 404 is the event a pin watcher exists for (the vendor deleted, renamed or privatised what we depend on) and it is indistinguishable here from a transient blip — docs/model-pins.md says "It exits non-zero when anything moved, so the run is visible even if the PR is not opened", which is true for moves and false for the case where nothing could be read at all.

Two small fixes, and the second needs no workflow change:

  1. report(): with notes, the first line must count what was actually checked (N of M checked, K not checked) and must not print the all-clear;
  2. main(): return 2 when nothing (or nothing but notes) was checked, and give a 404 its own state (upstream gone / private) rather than folding it into "could not be read". pin-watch.yml already fails on any rc other than 0/1 — if [ "$rc" -ne 0 ] && [ "$rc" -ne 1 ]; then exit "$rc"; fi — so 2 fails the job as it should, and census-watch.yml (#664) is the precedent for separating "the watcher found something" from "the watcher did not run".

What I verified, independently of the tool

The central claim reproduces from the Hub with my own instrument, which is the part that matters most here:

unsloth/Qwen3.8-Flash-Next-GGUF   pinned commit d9620119…   HF main d9620119…   match=True
 MTP/…Q8_0.gguf            held cd87e5d1a4da…  head 58a47d6a2163…  size 4,137,429,120 / 4,137,429,120  same=False
 UD-Q4_K_XL/…00001-of-00004 held 4448186216b3… head acc44040f74d…  size 10,946,624 / 10,946,618      same=False
 UD-Q4_K_XL/…00002-of-00004 held 3f342f1c1580… head 45792ae86907…  size 49,859,583,136 / 49,720,539,552 same=False
1bit-MONSTER/Apertus-8B-Instruct-2509-GGUF  commit 91b2ce8c… = main; its Q4_K_M oid identical   clean

So the "head is still d9620119, but the bytes it serves are not the ones we hold" row is real, and the MTP row is the argument for the whole design: same size, different bytes — a size-and-name check would have called it clean. --check's structure (146 repos, 184 files, 134 with a template sha, 1 fileless entry) matches the body exactly, and ci.yml runs only the fixture self-test, which is the right split.

Two things I looked at in the tool that are right and easy to get wrong: a truncated metadata read is a hard error, not "no template" (if len(blob) < size: raise ValueError(... no complete GGUF metadata)) with doubling to a 192 MiB cap, so a partial fetch cannot masquerade as a template removal; and --bump refuses when a tracked file vanished upstream instead of silently dropping it.

Nit: --self-test prints 18 ok lines, not the 16 in the body.

@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.

Full run over all 146 pins on this box, for the record — the claim holds, and it narrows the fix in item 2:

$ python3 tools/pin_watch.py --check
# Model pins: 1 of 146 moved (2026-11-05)
rc=1
moved: unsloth/Qwen3.8-Flash-Next-GGUF          not checked: 0

The five file rows are the body's table character for character (MTP cd87e5d1a4da…/4,137,429,120 B vs 58a47d6a2163…/same size; shard 1 4448186216b3… 10,946,624 → acc44040f74d… 10,946,618; shards 2-4 as listed), and I had already reproduced those from the Hub API with my own script, so the tool's data and its report agree with an independent instrument.

What that adds to item 2: on a healthy run there is no ## Not checked section at all — notes is empty, so the false all-clear I demonstrated only ever appears in exactly the runs where something could not be read. That means the fix can be strict without any cost to a normal day:

  • notes non-empty → the first line must be N of M checked, K not checked, never "Every pinned upstream repo is at the revision …";
  • len(notes) == len(pins) (nothing checked) → exit 2, which pin-watch.yml already turns into a failed job;
  • a 404 keeps its own state, because "the upstream is gone" is a finding, not a skip.

Since a clean day produces no notes, nothing about the everyday report changes.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

CI is green at abd0c750 — build 6m21s, CodeQL and all five Analyze jobs pass, hw_gate skipped, pr_agent red only because coder-llm is off. The Model pins step ran in CI for the first time and all its assertions passed.

That step also exposed a defect in my own test, now fixed in e19c85e7: the fixture for a moved pin reached commits_since(), which I had not faked, so it made a real Hub call, got a 404 for fake/moved, and sat through the retry backoff — 33 s of wall clock for 0.08 s of CPU, visible in CI's step timing (18:38:09 → 18:38:41) and reproducible here:

$ time python3 tools/pin_watch.py --self-test
real 0m32.849s   user 0m0.079s   sys 0m0.020s     # before
real 0m0.084s   user 0m0.063s   sys 0m0.006s     # after

Faking it takes the self-test to 84 ms and adds two assertions on the path the PR body renders (the upstream commit title reaches the change record and the report), so it is 20 assertions now rather than 16 — a fixture test should never be the thing that needs the network.

@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

Re-verdicted at e19c85e7. One of my two blockers is closed, the other is intact and the new commit did not touch it.

Closed: the copyright notice. tools/copyright.py --check -> rc 0 at this head (it was rc 1 on docs/model-pins.md at abd0c750, which is what reddened the Copyright notice on every file step).

Still open: a watcher whose "clean" and "could not check" are the same outcome, which is the failure this watcher exists for. Reproduced at this head, with a pins file whose only repository does not exist:

$ python3 tools/pin_watch.py --check --no-template --pins <one fake repo> --out report.md
# Model pins: 0 of 1 moved (2026-11-05)

Every pinned upstream repo is at the revision `models/pins.json` records.

## Not checked

- fake/definitely-not-a-model-1bit: could not be read (HTTP Error 404: Not Found) — the pin was not checked

$ echo $?
0

Exit 0, headline claiming every pin was verified, while the one pin was not checked at all. In pin-watch.yml that is moved=0 -> no PR -> a green daily run, so a vendor deleting, renaming or privatising a model we bake into a stick image is indistinguishable from a quiet day. main() still ends return 1 if changes else 0 (tools/pin_watch.py:531); the return 2 at :502 is the no-mode/help path, not this one. The fix I asked for stands and is small: first line counts what was checked, rc 2 when nothing could be checked (the workflow fails on anything outside 0/1), and 404 keeps its own state as --bump already does (:510-513, "gone upstream"). census-watch.yml (#664) is the precedent for the exit-code split.

What the new commit did fix, and it is a real fix: --self-test is network-free now — time python3 tools/pin_watch.py --self-test -> 0.057 s and 20 ok lines (the fixtures no longer reach commits_since()), so CI's fixture step no longer depends on Hugging Face being up. The body still says 16 and the commit message says 18 assertions; both are behind the 20 the tool prints at this head.

models/pins.json is unchanged between abd0c750 and this head, so the pin record I re-derived earlier stands: --check over all 146 pins gives 1 of 146 moved, rc 1, 0 not checked, with the five unsloth rows matching the body character for character (MTP cd87e5d1a4da… vs 58a47d6a2163… at the same 4,137,429,120 B; shard 1 4448186216b3… 10,946,624 -> acc44040f74d… 10,946,618 B). A healthy run prints no ## Not checked section at all, so the strict rule costs a normal day nothing.

Nothing else changed at this head, and the adoption-gate question from my earlier review is unchanged and still not a blocker.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

CHANGES addressed at 9b3b5ca8 — the blocker was exactly as you described: clean and could not check were the same outcome.

Your repro, now (a pins file whose only repository does not exist):

# 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. Nothing here can be bumped automatically;
decide, then edit or drop the pin (`docs/model-pins.md`).
rc=1

All three things you asked for, and they are separate:

  1. 404 keeps its own state. check() catches HTTPError 404 into a change with gone: true; the report says **Gone upstream** and the all-clear sentence is never printed. --bump refuses it with that sentence instead of a traceback (it now reads head() first, so the refusal happens before anything else can raise), and --pr-body returns the operator's decision text rather than a bump narrative.
  2. The first line counts what was read whenever anything was not: # Model pins: {checked} of {total} checked, {K} not checked, {J} moved. The Every pinned upstream repo is at the revision… sentence is now printed only when not changes and not notes; when nothing moved but something was unread it says so in words.
  3. Exit 2 when nothing could be read (notes and len(notes) == len(pins)), and pin-watch.yml already fails on anything outside 0/1, so a Hub outage or a rate-limit is a red job. Transient failures (429/timeout) stay notes — an unread repo is reported, not silently counted as clean.

A healthy day costs nothing, as you predicted from notes being empty: --check over all 147 pins still writes # Model pins: 1 of 147 moved and the body is diff-identical to the run before the fix (rc 1). Only the pathological runs changed shape.

--self-test 29/29 (was 20), network-free at 0.06 s: the 404 finding, gone in the report, no all-clear over it, --bump refusing a gone repo and leaving the pin alone, --pr-body's text, 0 of 2 checked, 2 not checked in the first line for a 429-style failure, and exit 2 for that run. tools/copyright.py --check rc 0. docs/model-pins.md now carries the 0/1/2 table instead of "exits non-zero when anything moved".

Body corrected too: the assertion counts you caught (20 at e19c85e7, not 16 in the body or 18 in the commit message) and the repo/file totals (147/185).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

#1148 ready for review at 9b3b5ca (3 -> 4 commits).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

A third commit at 5c2a3c3c, found while closing the blocker above — it is the same class of defect as #1131, inside this PR's own artifact, so I did not leave it for later.

The pin recorded null for four container files that carry a template, and would have computed a different number for the same template. Two defects that cancel each other:

  1. remote_template() fell through to "hash the text file verbatim" for anything not .gguf, so for a tokenizer_config.json it would have hashed the whole JSON file (51015f78470ac7f7… for FastFlowLM/Qwen3-0.6B-NPU2) while the engine's serve line names the chat_template string (a136047ba140347b…, the digest serve: name a model directory's chat template too, from its tokenizer_config.json (closes #1091) #1095 recorded for a real container). Two numbers for one template — exactly what you rejected in tools: template_bom.py — the chat template as a CycloneDX component (the proposal, executable) #1131.
  2. --bump only re-read a template that was not already null, so those entries could never gain one.

Now a .json path is parsed and its chat_template string hashed (a named-template list is refused rather than guessed, shortcut: comment), .jinja stays verbatim, and both --bump and --pin read the template of every tracked file — a null becomes a measured "no template" instead of an old one.

Measured:

  • FastFlowLM/{0.6B,1.7B,4B}-NPU2 tokenizer_config.json: null → a136047ba140347b…, which is the digest the engine's own line printed on a real container in serve: name a model directory's chat template too, from its tokenizer_config.json (closes #1091) #1095. Independent agreement, and the three files are the same 10055-byte blob (c5e960f8f860…).
  • 1bit-MONSTER/Qwen3.6-35B-A3B-NPU: its tokenizer_config.json has no chat_template; the template it actually runs is chat_template.jinja, which the pin did not track at all. Now tracked: e84f32a23fdda276…, 7764 B, equal to sha256sum of the raw file.

--check over all 147 pins is unchanged (1 moved, body diff-identical to the run before this commit, rc 1), --self-test 35/35, tools/copyright.py --check rc 0. Docs gain one clause on the container rule.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

#1148 ready for review at 5c2a3c3 (5 commits).

@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

Both blockers are closed, and the second one is closed by the shapes I asked for rather than by a doc sentence.

Blocker 2 — the watcher can no longer call an unread repo a clean day. Re-run of my own repros at 5c2a3c3c:

  • the only pinned repo answers 404: exit 1, first line # Model pins: 1 of 1 moved (2026-11-05), and a dedicated **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 section. It no longer reads as "every pin is at the revision".
  • the only pinned repo cannot be read for any other reason: exit 2 — pin_watch: no pin could be checked (1 unreadable) — this is not a clean run on stderr, and the report's first line is # Model pins: 0 of 1 checked, 1 not checked, 0 moved with No pin moved among the 0 repo(s) that could be read; 1 could not be checked and are listed under Not checked — an unread repo is not a clean one. That is the code pin-watch.yml fails on, and report() now counts what it checked rather than asserting what it did not.

The new template defect is real and its two numbers reproduce. A container's template is the chat_template string, not the file, and the tool now hashes that:

FastFlowLM/Qwen3-0.6B-NPU2  tokenizer_config.json
  file  sha256       51015f78470ac7f7…      <- the number it used to record
  chat_template sha  a136047ba140347b…      <- what the engine's serve line names, now pinned
1bit-MONSTER/Qwen3.6-35B-A3B-NPU  chat_template.jinja
  raw file: 7764 B   e84f32a23fdda276…      <- == sha256sum of the file, as the commit says
  its tokenizer_config.json carries no `chat_template` key  -> pinned `null`, a measured "no template"

All three FastFlowLM NPU2 rows now carry a136047ba140347b…; the JINJA row carries e84f32a2… and 7764 B; and I confirmed by fetching the file that tokenizer_config.json for that container genuinely has no chat_template, so null is a measurement rather than the old "never looked".

--self-test: 35 ok lines, 0.05 s, still no network. tools/copyright.py --check rc 0. Full --check over every pin: 1 of 147 moved, rc 1, and the moved repo is the same unsloth/Qwen3.8-Flash-Next-GGUF with the same rows (MTP cd87e5d1a4da… vs 58a47d6a2163… at the same 4,137,429,120 B; shard 1 4448186216b3… 10,946,624 -> acc44040f74d… 10,946,618 B) — so the reading is unchanged by the template fix, which is what "the pin is only as good as the number it records" needs.

One thing to note for the merge, not a blocker: --check builds changes[...]["pinned"] on the 404 path, so a hand-written pins file without that key raises KeyError instead of a diagnostic. My first fixture was exactly that; real entries all carry pinned (schema commit/pinned/used_for/files), so this only bites a crafted file, and it fails loudly rather than quietly.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

CI green at 5c2a3c3c: build 6m30s, CodeQL + all five Analyze pass, hw_gate skipped, pr_agent red only because coder-llm is off.

bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
…pen PR would add

The reviewer's blocker, and it is the same class as the text being fixed: the paragraph said the digests
for the four rows "are already measured — models/pins.json records the template of every file we serve and
publish", and `models/pins.json` does not exist in this tree (`ls` fails, `git cat-file -e
origin/main:models/pins.json` fails). It is engine #1148, open, so a reader on `main` was sent to a path
that is not there, and the "already measured" claim rested on an unmerged working tree — including the one
digest that exists nowhere else yet (`short`'s 57f1fd00f0…).

Now the paragraph says what a reader can check today:

  - three of the four rows are in `config/template-pins.json` **now** — zaya1-8b-q4_k_m.gguf
    b55ecfbbbc…, qwen3-coder-30b-a3b-instruct-q4_k_m.gguf 87710339d2…, qwen3.8-27b-ud-q4_k_xl.gguf
    12827f24b7… — because they are the same files the load-time check already pins (verified against the
    tree at this head);
  - the fourth, `short`'s Qwen3-0.6B-Q8_0.gguf 57f1fd00f0…, is **not in any table in this tree yet**; it is
    measured, it is added by #1158, and it is recorded with every other served/published file in #1148's
    models/pins.json — said as such rather than implied to be here.

The Q4NX sentence now links docs/npu-chat-template.md, which is in this tree (#1115). Nothing else in the
paragraph changed: what runs today, what it catches, and what step 3 adds all verified in the review.
bong-water-water-bong added 5 commits November 5, 2026 17:23
…m 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.
… 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.
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.
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.
…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 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.

Conflict note, verdict unchanged (READY): #1148 is CONFLICTING/DIRTY as of 17:5x AST, in .github/workflows/ci.yml:

$ git merge-tree --write-tree --name-only origin/main 5c2a3c3c
Auto-merging .github/workflows/ci.yml
CONFLICT (content): Merge conflict in .github/workflows/ci.yml

ci.yml has moved under it several times tonight (the census/release lanes each add a job), so this is a rebase-and-re-resolve in one file; the PR's own content - models/pins.json, tools/pin_watch.py, docs/model-pins.md - is untouched by the conflict.

Two things worth carrying into the rebase rather than losing: the watcher's exit-code contract (rc 2 when nothing could be checked, and 404 as its own "gone upstream" state) and the container-template rule (a .json path hashes its chat_template string, not the file). Both are covered by --self-test's 35 ok lines, so a bad merge will fail there.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Rebased onto main — the branch was CONFLICTING and now is not. The only conflict was .github/workflows/ci.yml, and it was #1131 merging: main's "GGUF → Q4NX repack" step gained python3 tools/template_bom.py --self-test, which is exactly where this PR had appended its own step. Resolved by keeping both — main's line stays inside the run: | block, my Model pins step stays its own step after it:

          python3 tests/hrx_long_prompt_test.py
          python3 tools/template_bom.py --self-test          # the chat-template BOM component
      - name: Model pins
        # fixtures only: models/pins.json itself is compared with the Hub by pin-watch.yml
        run: python3 tools/pin_watch.py --self-test

ci.yml parses; the workflow keeps both checks.

Verified after the rebase, not before (all five commits rewritten, head 20122d78):

  • --self-test 35/35, tools/copyright.py --check rc 0;
  • full --check over all 147 pins: # Model pins: 1 of 147 moved, body diff-identical to the pre-rebase run, rc 1 — the rebase changed history, not behaviour;
  • models/pins.json untouched by the rebase (still 147 repos / 186 files).

The earlier verdict was on 5c2a3c3c; the shas changed, so please re-verdict at 20122d78.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

#1148 ready for review at 20122d7 (5 commits, rebased onto main; conflict in ci.yml resolved keeping both checks).

@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

Re-verdicted at 20122d78, and the conflict I flagged is gone: MERGEABLE/BEHIND now, after the rebase onto current main.

The rebase preserved the reviewed content exactly - git diff 5c2a3c3c 20122d78 is empty for tools/pin_watch.py, models/pins.json and docs/model-pins.md, so every check from my 5c2a3c3c review still applies to these bytes:

  • rc 2 when nothing could be checked (pin_watch: no pin could be checked (N unreadable) — this is not a clean run), and 404 as its own **Gone upstream** state instead of a clean-day headline;
  • a container's template is its chat_template string, not the file: FastFlowLM/Qwen3-0.6B-NPU2 file 51015f78… vs string a136047b…, and the chat_template.jinja row e84f32a2… at 7764 B;
  • --self-test 35 ok lines in 0.05 s, no network;
  • full --check over every pin: 1 of 147 moved, rc 1, the same unsloth/Qwen3.8-Flash-Next-GGUF rows.

Merge note unchanged: #1157 cites models/pins.json and names it as this PR's, so this one goes first (or #1157 is re-read after it lands).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

CI green at 20122d78 (the rebased head): build 5m5s, CodeQL pass, hw_gate skipped, pr_agent red only because coder-llm is off — and the merged #1131's check still runs beside mine in the same job (both template_bom --self-test and Model pins are in the step list).

@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 🔶

1131 - Partially compliant

Compliant requirements:

  • Input is a .gguf or a model directory, output is a CycloneDX 1.6 data component
  • A model that carries no template gets a component with no hash and a description that says why
  • --self-test builds fixtures and performs 7 assertions

Non-compliant requirements:

  • The BOM, serve log, and card row should carry the same digest instead of three bookkeepers

Requires further human verification:

  • Verification of the BOM, serve log, and card row carrying the same digest
⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Template Hashing Logic

The tool correctly reads chat templates from GGUF metadata headers and container JSON files, but there's a potential issue in how it handles template hashing. Specifically, when a container's tokenizer_config.json is processed, it should hash only the chat_template string, not the entire JSON file. This is important to ensure that the digest matches what the engine's serve line names. The code correctly implements this by using container_template() function, but the logic could be more robust to prevent accidental hashing of the JSON file instead of the string.

def container_template(blob: bytes) -> bytes | None:
    """The chat template a container's `tokenizer_config.json` carries, as the engine's NPU / model-directory
    route reads it (docs/npu-chat-template.md): the `chat_template` **string**, whose bytes are what the
    serve line names. Hashing the JSON file itself would be a second number for one template — the
    disagreement #1131 was about."""
    try:
        doc = json.loads(blob.decode("utf-8"))
    except (UnicodeDecodeError, ValueError) as e:
        raise ValueError(f"tokenizer_config.json is not UTF-8 JSON ({e})")
    t = doc.get("chat_template")
    if t is None:
        return None
    if not isinstance(t, str):
        # shortcut: a named-template list is not pinned; the engine's directory route reads one named
        # template, so such a pack needs its served name decided first. Upgrade when one appears in a row.
        raise ValueError(f"chat_template is a {type(t).__name__}, not the string the serve line reads")
    return t.encode("utf-8")
HTTP Error Handling

The tool differentiates between a 404 HTTP error and other transient errors when checking repository availability. However, the handling of HTTP errors could be improved to ensure that all non-200 responses are treated consistently. Specifically, a 404 error should be treated as a definitive "gone upstream" state, while other errors should be retried or logged appropriately.

except urllib.error.HTTPError as e:
    if e.code == 404:
        # "the upstream is gone" is the answer, not a failed read: a delete, a rename and a
        # privatisation all look like this, and none of them may pass as a clean day.
        changes.append({"repo": repo, "old": pin["commit"], "new": None, "pinned": pin["pinned"],
                        "files": [], "added": [], "commits": [], "gone": True})
    else:
        notes.append(f"{repo}: could not be read ({e}) — the pin was not checked")
    continue
except Exception as e:
    notes.append(f"{repo}: could not be read ({e}) — the pin was not checked")
    continue
Template Change Detection

The tool re-reads chat templates for moved files to detect changes, but it could be more efficient by caching the templates or using a more granular change detection mechanism. This would reduce the number of HTTP requests and improve performance, especially when dealing with many repositories.

if want_template:
    try:
        tpl = remote_template(repo, now, path)
    except Exception as e:
        row["template"] = f"could not be read ({e})"
    else:
        old = held.get("template_sha256")
        if tpl is None and old is None:
            row["template"] = "no template, as before"
        elif tpl is None:
            row["template"] = f"TEMPLATE REMOVED (we hold {old[:16]}…)"
        elif old is None:
            row["template"] = f"template ADDED ({tpl[:16]}…)"
        elif tpl == old:
            row["template"] = f"unchanged ({tpl[:16]}…)"
        else:
            row["template"] = f"TEMPLATE CHANGED {old[:16]}… -> {tpl[:16]}… — re-audit before adopting"
else:
    row["template"] = "not checked (--no-template)"
rows.append(row)

⚠️ Review coverage: The following files were not included in this review because of the token budget:

  • models/pins.json

@bong-water-water-bong
bong-water-water-bong merged commit 5d04458 into main Nov 5, 2026
9 checks passed
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Post-merge smoke of the merged code (not my branch), run at origin/main 1cc9c87c:

  • tools/pin_watch.py --self-test → 35/35, rc 0.
  • --check over all 147 pins → # Model pins: 1 of 147 moved, the one repo being unsloth/Qwen3.8-Flash-Next-GGUF. Same repo as the day-one report; nothing else has moved since.
  • The day-one row is all five tracked files, not the three I quoted in the 14:4x evidence: the four UD-Q4_K_XL-0000{1..4} shards plus the MTP head. Sizes 10,946,618 / 49,720,539,552 / 32,803,030,176 / 28,800,138,432 B at head d9620119. Templates are unchanged (c3cf9e34abf4f9e3… MTP, 12827f24b742ea4e… shard 1; shards 2-4 carry none, as before) — so adopting the new revision is a weights decision, as the report says.
  • --bump on a copy of models/pins.json (--pins /tmp/pins-copy.json, tree untouched) re-pinned that repo at d9620119 and rewrote all five identities; rc 0. So the bump action itself works against the real Hub.

One defect found while checking that the workflow can do this unattended: the step that opens the bump PR had never been executed (it runs only when moved != 0), and it does not parse — bash: unexpected EOF while looking for matching '"' (git commit -m "pin: $repo at $newsha""). #1181 fixes it and adds a test that bash -ns every workflow's run: blocks, so the first scheduled run after it lands is the one that opens this PR rather than dying on the way to it.

bong-water-water-bong added a commit that referenced this pull request Nov 5, 2026
…one that does not (#1157)

* docs/laya-first: say which template check runs today, and that the per-row digest is step 3

The section ended with a present-tense claim the tree does not meet: that
`~/.local/share/1bit/laya-first.json` "ships with the digests measured on
2026-11-05" and that "the table's validator (§8 step 3) fails" a row that skips
one. Measured on main: `config/laya-first.json` — the file compiled in as
`laya_first_default.h` — has no `template_sha256` in any of its four rows,
nothing in `app/`, `src/`, `tests/`, `include/` or `os/` reads one, and no
installed `<laya home>/laya-first.json` exists on the c25/c26 media, the staging
trees or the box (§8 step 3 is where that field and the pull-time check are
scheduled). A design note that asserts a check which does not run is the exact
failure this row is supposed to prevent, so the paragraph now states what is
true:

  - the one template check on a routed pull today is the load-time one, against
    the engine's `config/template-pins.json` — 7 names, three of them these rows;
  - that check warns unless the row ships `--template-pin-strict` (§9), and it
    catches one thing: the same name carrying a different template;
  - the field, the per-row requirement and the pull-time refusal are §8 step 3,
    and the digests they will need are already measured — `models/pins.json`
    records 57f1fd00f0… (short), b55ecfbbbc… (prose), 87710339d2… (code),
    12827f24b7… (long_doc).

Doc only; no code, no table and no operand changed.

* docs/laya-first: cite the digests that are in this tree, not one an open PR would add

The reviewer's blocker, and it is the same class as the text being fixed: the paragraph said the digests
for the four rows "are already measured — models/pins.json records the template of every file we serve and
publish", and `models/pins.json` does not exist in this tree (`ls` fails, `git cat-file -e
origin/main:models/pins.json` fails). It is engine #1148, open, so a reader on `main` was sent to a path
that is not there, and the "already measured" claim rested on an unmerged working tree — including the one
digest that exists nowhere else yet (`short`'s 57f1fd00f0…).

Now the paragraph says what a reader can check today:

  - three of the four rows are in `config/template-pins.json` **now** — zaya1-8b-q4_k_m.gguf
    b55ecfbbbc…, qwen3-coder-30b-a3b-instruct-q4_k_m.gguf 87710339d2…, qwen3.8-27b-ud-q4_k_xl.gguf
    12827f24b7… — because they are the same files the load-time check already pins (verified against the
    tree at this head);
  - the fourth, `short`'s Qwen3-0.6B-Q8_0.gguf 57f1fd00f0…, is **not in any table in this tree yet**; it is
    measured, it is added by #1158, and it is recorded with every other served/published file in #1148's
    models/pins.json — said as such rather than implied to be here.

The Q4NX sentence now links docs/npu-chat-template.md, which is in this tree (#1115). Nothing else in the
paragraph changed: what runs today, what it catches, and what step 3 adds all verified in the review.

---------

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