From b5a9d01402596fa05c0bd9eeb35a7255803596ab Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Wed, 30 Sep 2026 00:07:13 -0300 Subject: [PATCH 1/2] Port the census watcher: the newest HF models against the generated registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1bit-MONSTER's watcher (Testing/hf_new_models.py) compiled a C++ probe of rcpp_arch_from_string through census_coverage.build_mapper(). This repository's registry is generated data (registry/architectures.json), keyed by the HF architecture class the census counts, so the watcher is a plain lookup: no g++ probe and no strip_arch. That is how PORTING.md's step 5 could land the sweep (census.yml) and drop the watch half without a compile-time cost. tools/census_watch.py polls the newest text-generation models (createdAt descending), reads each one's class from the config inline in the listing and compares it against the registry; exit 1 is the alert. scripts/census-watch.sh is the entry point (log file + optional origin/main refresh), the 1bit-census-watch.{service,timer} pair is the 04:30 UTC timer from a ~/census-main worktree, and census-watch.yml is the CI path with the deduped alert issue. State lives outside the checkout ($CENSUS_WATCH_STATE, default ~/.1bit/census-watch-state.json), so a run never dirties the tree it reads. Two bugs the first live runs found, both pinned by the test: - Pagination. HF ignores `p=` on a sorted listing: every page returned the same first 100 models, so --limit 120 fetched 101 and still called it 120. The walk follows the Link header's cursor instead, the way tools/census.py sweeps. - State shape. classify() returned (id, reason) tuples for the unverifiable models, so remember() wrote tuple keys and json.dump failed on the sorted keys — the run reported its alert and then died before saving state. First run against main's registry, the 120 newest text-generation models: 102 with an architecture, 94 mapped, so six uncovered classes the alert had been blind to since it stopped running on 2026-09-23 -- Qwen3_5MVLAAbsorbedForCausalLM (2 models, e.g. TelperionAI/Qwen3.8-27B-GLA-g2), ArgonneModel, GPT, Glm5NextForConditionalGeneration, NcpSmolForCausalLM and NimbusMindForCausalLM. NcpSmolForCausalLM sits past model 101, so the pagination bug had made it unreachable. ctest census_watch drives classify()/report()/remember()/sweep_newest() against synthetic batches, synthetic pages and the committed registry (offline: no network), so a regression cannot leave the daily alert permanently green. --- .github/workflows/census-watch.yml | 111 ++++++++++++ CMakeLists.txt | 3 + docs/registry.md | 32 ++++ scripts/1bit-census-watch.service | 59 +++++++ scripts/1bit-census-watch.timer | 35 ++++ scripts/census-watch.sh | 84 +++++++++ tests/census_watch_test.py | 212 +++++++++++++++++++++++ tools/census_watch.py | 263 +++++++++++++++++++++++++++++ tools/copyright.py | 3 +- 9 files changed, 801 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/census-watch.yml create mode 100644 scripts/1bit-census-watch.service create mode 100644 scripts/1bit-census-watch.timer create mode 100755 scripts/census-watch.sh create mode 100644 tests/census_watch_test.py create mode 100644 tools/census_watch.py diff --git a/.github/workflows/census-watch.yml b/.github/workflows/census-watch.yml new file mode 100644 index 00000000..6188dffc --- /dev/null +++ b/.github/workflows/census-watch.yml @@ -0,0 +1,111 @@ +# Copyright 2026 bong-water-water-bong +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Intent: run the daily HF new-model watcher (tools/census_watch.py, via +# scripts/census-watch.sh) and alert when a newly-published model carries an architecture +# class the registry (registry/architectures.json) does not map. That is the one thing +# that silently moves the mapped share the census reports — this workflow turns it into a +# red check plus a deduped alert issue. +# +# The same entry point backs the 04:30 systemd timer on the development box +# (scripts/1bit-census-watch.{service,timer}); CI here is the second, self-hosted-free +# alert path (ubuntu-latest, stdlib python only — the registry is generated data, so +# unlike 1bit-MONSTER's watcher there is no g++ probe to compile). +# +# NOTE: scheduled runs only fire on the default branch. workflow_dispatch is the manual +# trigger for feature branches. +# +# Deliberately no pull_request trigger. The live watch is red whenever any newest model's +# class is unmapped — that is the alert — and the census reports a mapped share below +# 100% by design, so a PR gate would be red on every pull request and mean nothing. The +# watcher's own contract is checked offline by ctest `census_watch` (tests/census_watch_test.py, +# run by ci.yml), which needs no network and no HF state; the live alert runs on schedule. +# +# Gated/no-config models do NOT fail the run — they are unverifiable without an HF token +# and expected; only genuinely uncovered classes alert. + +name: Census watch + +on: + schedule: + - cron: "30 4 * * *" # 04:30 UTC daily, ahead of the 05:17 census sweep + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: census-watch + cancel-in-progress: false + +jobs: + watch: + name: New HF model classes are covered + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + # The watcher's state file (the model ids already classified) lives outside the + # checkout and is not carried between CI runs, so the newest models are re-checked + # every run — harmless, and the issue side dedupes. + - name: Run the new-model watcher + id: watch + run: | + bash scripts/census-watch.sh --limit 120 2>&1 | tee /tmp/census-watch.log + exit ${PIPESTATUS[0]} + + # Only alert on scheduled/manual runs — on PRs the red check itself is the signal. + # Guard on the watch step specifically so a checkout/setup failure doesn't file a + # misleading issue. + - name: File an alert issue on uncovered classes + if: failure() && steps.watch.conclusion == 'failure' && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: | + grep -E "UNCOVERED|UNVERIFIABLE" /tmp/census-watch.log || true + gh label create census-watch 2>/dev/null || true + EXISTING=$(gh issue list --label census-watch --state open --json number --jq 'length') + if [ "$EXISTING" -gt 0 ]; then + echo "census-watch issue already open — skipping" + exit 0 + fi + { + echo "## Census coverage breach" + echo "" + echo "The daily new-model watcher found HF models whose architecture class is not" + echo "mapped by the engine registry (\`registry/architectures.json\`):" + echo "" + echo '```' + grep -E "UNCOVERED|UNVERIFIABLE" /tmp/census-watch.log || true + echo '```' + echo "" + echo "Fix: map the class in \`registry/architectures.json\` when a pinned backend's" + echo "code really accepts the architecture, or record why it is not an alias in" + echo "\`registry/significant.json\` + \`docs/arch-gaps.md\` (a class reviewed as" + echo "SIGNIFICANT needs real engine support, not a mapping). Re-run" + echo "\`python3 tools/census_watch.py\` to confirm, and \`python3 tools/census.py" + echo "--report\` for the coverage the census would report." + echo "" + echo "Report: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + } > /tmp/census_issue.md + gh issue create \ + --title "census: new HF model class uncovered by the registry" \ + --label census-watch \ + --body-file /tmp/census_issue.md diff --git a/CMakeLists.txt b/CMakeLists.txt index 1557d933..35432892 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -358,6 +358,9 @@ if(Python3_Interpreter_FOUND) add_test(NAME registry_gaps_fire COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/registry_gaps_test.py) # a pin bump that did not regenerate the registry (only the gitlinks: no checkouts) add_test(NAME registry_pins COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/registry_build.py --check-pins) + # the new-model watcher's alert contract (offline: synthetic batches against the + # committed registry), so a regression cannot leave the daily alert permanently green. + add_test(NAME census_watch COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/census_watch_test.py) endif() add_executable(npu_full_elf_test tests/npu_full_elf_test.cpp) diff --git a/docs/registry.md b/docs/registry.md index d7bd607e..f166e761 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -111,6 +111,36 @@ of those models was run. The unmapped architectures with the most models are `Step1MoEForCausalLM` (2,882), `OPTForCausalLM` (2,097), `ParlerTTSForConditionalGeneration` (1,586) and `GPTNeoForCausalLM` (1,565). `tools/census.py --pr-body` lists the top ten. +## The watch + +The sweep is a snapshot, so a class that appears *tomorrow* moves the mapped share +quietly. `tools/census_watch.py` polls the newest text-generation models (createdAt +descending), reads each one's architecture class from the config inline in the listing, +and compares it against `registry/architectures.json` — the same read `census.py`'s +coverage() makes, so "uncovered" here means "not in the number the census reports". No +per-model fetch and no compiled probe: the registry is generated data, keyed by the HF +architecture class the census counts. + +Exit 0 means every newest model is mapped; **exit 1 is the alert** — a class arrived that +no backend accepts. It is run by the `census-watch.yml` workflow daily at 04:30 UTC (ahead +of the 05:17 sweep) and by the systemd timer on the development box +(`scripts/1bit-census-watch.{service,timer}`, from the `~/census-main` worktree that +tracks `origin/main`). The workflow files a deduped `census-watch` issue; the timer marks +its unit failed, and every run lands in `~/.1bit/logs/census-watch-*.log`. + +A class the watcher flags is one of two things, and the run says which: + +- **new class** — map it in `registry/architectures.json`, but only when a pinned + backend's code really accepts the architecture (rule 3 in CONTRIBUTING.md: recognized + is not verified). +- **reviewed, not an alias** — already in `registry/significant.json` + ([arch-gaps.md](arch-gaps.md)); it needs real engine support, and if a backend starts + accepting it, the reason is recorded as `mapped_ok`. + +Gated repos (no config without a token) are reported as unverifiable and never fail the +run; derivatives (a quantized GGUF, a LoRA adapter) have no config by design and are +skipped, because the release they derive from carries the config and is what gets checked. + ## Commands ``` @@ -119,4 +149,6 @@ tools/registry_build.py --check # exit 1 if it is stale tools/registry_check.py build/1bit --models ~/models # run the checks on Strix Halo tools/census.py # full HF sweep (about 420 pages) tools/census.py --report # recompute coverage from the saved counts +tools/census_watch.py # newest models vs the registry; exit 1 on an uncovered class +scripts/census-watch.sh # the same, with the log file and the origin/main refresh ``` diff --git a/scripts/1bit-census-watch.service b/scripts/1bit-census-watch.service new file mode 100644 index 00000000..f2777e6c --- /dev/null +++ b/scripts/1bit-census-watch.service @@ -0,0 +1,59 @@ +# Copyright 2026 bong-water-water-bong +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# 1bit-census-watch.service — run the daily HF new-model census watcher +# +# One-shot unit that runs tools/census_watch.py through scripts/census-watch.sh from a +# DEDICATED checkout that tracks origin/main (~/census-main, a worktree), so the watcher +# always reads the registry that is actually on main. Exit 1 (an uncovered architecture +# class) is the ALERT: systemd marks the unit failed, which a journald watch or health +# check can surface, and the full log lands in ~/.1bit/logs/census-watch-*.log. Exit 0 +# means the newest HF models are all mapped — silent, as intended. +# +# Schedules via scripts/1bit-census-watch.timer (04:30 daily, persistent); can also be +# triggered manually: +# systemctl --user start 1bit-census-watch +# +# ── Install (user units, no sudo) ───────────────────────────────────────── +# git clone https://github.com/1bit-MONSTER/engine.git ~/engine +# git -C ~/engine worktree add --detach ~/census-main origin/main +# install -d ~/.config/systemd/user +# install -m644 ~/engine/scripts/1bit-census-watch.{service,timer} ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now 1bit-census-watch.timer +# systemctl --user list-timers 1bit-census-watch +# journalctl --user -u 1bit-census-watch -f +# +# ── Paths ───────────────────────────────────────────────────────────────── +# %h/census-main must match your worktree (the checkout the watcher runs from). Change if +# it lives elsewhere. CENSUS_WATCH_REFRESH=1 fast-forwards that checkout to origin/main +# before each run; the script refuses a checkout that is on a branch or has local changes. +# Logs go to ~/.1bit/logs (override with CENSUS_WATCH_LOG_DIR). + +[Unit] +Description=1bit census: watch HF for new model classes the registry does not map +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/census-main +Environment=CENSUS_WATCH_REFRESH=1 +ExecStart=%h/census-main/scripts/census-watch.sh +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=default.target diff --git a/scripts/1bit-census-watch.timer b/scripts/1bit-census-watch.timer new file mode 100644 index 00000000..a0fde9d9 --- /dev/null +++ b/scripts/1bit-census-watch.timer @@ -0,0 +1,35 @@ +# Copyright 2026 bong-water-water-bong +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# 1bit-census-watch.timer — 04:30 UTC daily, persisted across downtime. +# +# UTC explicitly, because that is what the census-watch workflow's cron uses: the box +# run and the CI run then see the same day's arrivals. (A bare 04:30 is the machine's +# local time, which on the development box is three hours later.) The census sweep +# itself follows at 05:17 UTC (census.yml). +# +# systemctl --user enable --now 1bit-census-watch.timer +# systemctl --user list-timers 1bit-census-watch + +[Unit] +Description=Daily 1bit census: new HF model classes are covered + +[Timer] +OnCalendar=*-*-* 04:30:00 UTC +Persistent=true +Unit=1bit-census-watch.service + +[Install] +WantedBy=timers.target diff --git a/scripts/census-watch.sh b/scripts/census-watch.sh new file mode 100755 index 00000000..61d77342 --- /dev/null +++ b/scripts/census-watch.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Copyright 2026 bong-water-water-bong +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# census-watch.sh — daily HF new-model watcher entry point +# +# Wraps tools/census_watch.py so the daily watch is one command, usable from the systemd +# units (scripts/1bit-census-watch.{service,timer}) and the GitHub Actions workflow +# (.github/workflows/census-watch.yml). The automation is reproducible from the repo +# instead of living ad-hoc on one box. +# +# What it does: polls the newest HF text-generation models, reads each one's architecture +# class from the config inline in the listing, and compares it against the generated +# registry (registry/architectures.json) — the same read tools/census.py's coverage() +# makes. Any new class the registry does not map is the alert: that is the one thing that +# silently moves the mapped share the census reports. +# +# Exit codes (same contract as tools/census_watch.py): +# 0 no uncovered classes among the newest models +# 1 an uncovered class arrived (the alert) +# 2 the refresh step refused to touch the checkout +# * runtime failure (no network, unreadable registry, ...) +# +# Usage: +# scripts/census-watch.sh [--limit N] # N newest models, default 120 +# +# Every run is appended to $CENSUS_WATCH_LOG_DIR (default ~/.1bit/logs) AND echoed to +# stdout, so systemd/journald/CI all see the same output. Watcher state (the model ids +# already classified) lives at $CENSUS_WATCH_STATE, outside the checkout. +# +# CENSUS_WATCH_REFRESH=1 fast-forwards the checkout to origin/main before each run. It +# refuses to touch a checkout that is on a branch or has local changes, so pointing this +# at a working lane can never wipe it. It is meant for the ~/census-main worktree, which +# is a `git worktree add --detach ~/census-main origin/main`. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LOG_DIR="${CENSUS_WATCH_LOG_DIR:-${HOME}/.1bit/logs}" +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +LOG="${LOG_DIR}/census-watch-${STAMP}.log" + +mkdir -p "${LOG_DIR}" + +if [ "${CENSUS_WATCH_REFRESH:-0}" = "1" ]; then + branch="$(git -C "${ROOT}" rev-parse --abbrev-ref HEAD)" + if [ "${branch}" != "HEAD" ]; then + echo "census-watch: refusing to refresh: ${ROOT} is on branch ${branch}" >&2 + exit 2 + fi + if ! git -C "${ROOT}" diff --quiet || ! git -C "${ROOT}" diff --cached --quiet; then + echo "census-watch: refusing to refresh: ${ROOT} has local changes" >&2 + exit 2 + fi + git -C "${ROOT}" fetch -q origin main + git -C "${ROOT}" reset -q --hard origin/main + echo "census-watch: refreshed ${ROOT} to origin/main ($(git -C "${ROOT}" rev-parse --short HEAD))" +fi + +echo "== census-watch $(date -u +%FT%TZ) ==" | tee "${LOG}" + +set +e +python3 "${ROOT}/tools/census_watch.py" "$@" 2>&1 | tee -a "${LOG}" +RC=${PIPESTATUS[0]} +set -e + +if [ "${RC}" -ne 0 ]; then + echo "census-watch: EXIT ${RC} (an uncovered class needs a registry mapping;" \ + "full log: ${LOG})" | tee -a "${LOG}" +fi + +exit "${RC}" diff --git a/tests/census_watch_test.py b/tests/census_watch_test.py new file mode 100644 index 00000000..65ae7290 --- /dev/null +++ b/tests/census_watch_test.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +# Copyright 2026 bong-water-water-bong +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Pin the census watcher's alert contract (tools/census_watch.py, ctest census_watch). + +The daily watch exits 0 when every newest model's architecture is mapped and 1 when one +is not, so a regression that made classify() stop flagging an unmapped class would turn +the alert permanently green — the failure mode this whole tool exists to prevent. This +test drives classify() and report() against synthetic batches and a synthetic registry +(the same approach tests/registry_gaps_test.py takes for the gap guard), then checks the +committed registry still parses and still maps a known class. + +No network: the listing walk (sweep_newest) is exercised by the workflow and the daily +timer, not here. +""" +import io +import json +import os +import sys +import contextlib + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "tools")) +import census_watch as cw # noqa: E402 + +ARCHS = { + "Qwen3ForCausalLM": {"gguf": "qwen3", "backends": ["vulkan", "hrx"]}, + "EmptyForCausalLM": {"gguf": "empty", "backends": []}, +} +SIG = {"Qwen3_5MVLAAbsorbedForCausalLM": {"model_type": "qwen3_5_text", "family": "Qwen3.5 MVLA"}} + +failures = [] + + +def check(name, cond): + if cond: + print(f"ok {name}") + else: + print(f"FAIL {name}") + failures.append(name) + + +def model(mid, arch=None, tags=(), library=None): + m = {"id": mid, "tags": list(tags)} + if library: + m["library_name"] = library + if arch: + m["config"] = {"architectures": [arch]} + return m + + +def rc_of(models, archs=ARCHS, sig=SIG, seen=()): + """The exit code the run would return, with report()'s output captured.""" + res = cw.classify(models, archs, sig, seen) + with contextlib.redirect_stdout(io.StringIO()): + return cw.report(models, res, seen), res + + +def text_of(models, archs=ARCHS, sig=SIG, seen=()): + """The exit code and the report's text, so a misleading summary can be pinned.""" + res = cw.classify(models, archs, sig, seen) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + rc = cw.report(models, res, seen) + return rc, buf.getvalue() + + +# 1. A mapped class is covered and the run is green. +rc, res = rc_of([model("a/one", "Qwen3ForCausalLM")]) +check("mapped class -> covered, exit 0", rc == 0 and res[cw.COVERED] == ["a/one"]) + +# 2. An unmapped class is the alert. +rc, res = rc_of([model("b/two", "NimbusMindForCausalLM")]) +check("unmapped class -> uncovered, exit 1", rc == 1 and res[cw.UNCOVERED] == ["b/two"]) +check("unmapped class is grouped under its class", list(res["classes"]) == ["NimbusMindForCausalLM"]) + +# 3. A registry entry with no backend is not covered (it maps nothing to run on). +rc, res = rc_of([model("c/three", "EmptyForCausalLM")]) +check("empty backends -> uncovered", rc == 1 and res[cw.UNCOVERED] == ["c/three"]) + +# 4. A reviewed not-an-alias class is still the alert, and is labeled as such. +rc, res = rc_of([model("d/four", "Qwen3_5MVLAAbsorbedForCausalLM")]) +check("significant class -> uncovered, exit 1", rc == 1) +check("significant class is labeled", res["significant"] == ["Qwen3_5MVLAAbsorbedForCausalLM"]) + +# 5. A derivative with no config is expected, not an alert. +rc, res = rc_of([model("e/five", tags=["gguf", "text-generation"])]) +check("derivative -> no alert, not unverifiable", + rc == 0 and res[cw.DERIVATIVE] == ["e/five"] and not res[cw.UNVERIFIABLE]) +rc, res = rc_of([model("e/six", library="peft")]) +check("peft library -> derivative", rc == 0 and res[cw.DERIVATIVE] == ["e/six"]) + +# 6. A gated repo (no config, no derivative tag) is unverifiable: reported, not an alert. +rc, res = rc_of([model("f/seven", tags=["text-generation"])]) +check("no config -> unverifiable, exit 0", + rc == 0 and res[cw.UNVERIFIABLE] == ["f/seven"] + and res["reasons"]["f/seven"] == "no config / no architecture") + +# 7. An unverifiable model is reported once, then remembered. +rc, res = rc_of([model("f/seven", tags=["text-generation"])], seen={"f/seven"}) +check("unverifiable remembered -> quiet", rc == 0 and res[cw.UNVERIFIABLE] == []) + +# 8. A mixed batch: the alert fires, and the summary counts each group. +rc, res = rc_of([ + model("g/eight", "Qwen3ForCausalLM"), + model("g/nine", "ArgonneModel"), + model("g/ten", tags=["gguf"]), + model("g/eleven", tags=["text-generation"]), +]) +check("mixed batch -> exit 1, one uncovered class", + rc == 1 and list(res["classes"]) == ["ArgonneModel"] + and len(res[cw.COVERED]) == 1 and len(res[cw.DERIVATIVE]) == 1 and len(res[cw.UNVERIFIABLE]) == 1) + +# 9. A run with nothing to check says so, instead of reporting a green "no uncovered +# class" over zero models (the first production run after a stale state did exactly +# that: the horizon break stopped the walk and the summary read as all-clear). +rc, text = text_of([]) +check("no new models -> exit 0 and says nothing new", rc == 0 and "nothing new" in text) +rc, text = text_of([model("i/sixteen", "Qwen3ForCausalLM")]) +check("a run that checked models does not claim nothing new", + rc == 0 and "nothing new" not in text) + +# 10. remember() produces a state that serializes, every group included. (A tuple key here +# broke json.dump in the first live run.) +import tempfile # noqa: E402 +with tempfile.TemporaryDirectory() as td: + path = os.path.join(td, "nested", "state.json") + cw.save_state(path, {"last_run": "t", "seen": {"x": "covered"}}) + check("state round-trips", cw.load_state(path)["seen"] == {"x": "covered"}) + cw.save_state(path, {"last_run": "t", "seen": {f"m{i}": "covered" for i in range(cw.MAX_SEEN + 10)}}) + check("state is capped at MAX_SEEN", len(cw.load_state(path)["seen"]) == cw.MAX_SEEN) + check("missing state file loads empty", cw.load_state(os.path.join(td, "absent.json"))["seen"] == {}) + + _, mixed = rc_of([ + model("h/twelve", "Qwen3ForCausalLM"), + model("h/thirteen", "NimbusMindForCausalLM"), + model("h/fourteen", tags=["gguf"]), + model("h/fifteen", tags=["text-generation"]), + ]) + state = cw.remember({"last_run": "t", "seen": {}}, mixed) + check("remember() records every group", + state["seen"] == {"h/twelve": cw.COVERED, "h/thirteen": cw.UNCOVERED, + "h/fourteen": cw.DERIVATIVE, "h/fifteen": cw.UNVERIFIABLE}) + cw.save_state(path, state) + check("remembered state serializes and reloads", cw.load_state(path)["seen"] == state["seen"]) + rc, res = rc_of([model("h/fifteen", tags=["text-generation"])], seen=set(cw.load_state(path)["seen"])) + check("a reloaded state quiets the next run's unverifiable", rc == 0 and res[cw.UNVERIFIABLE] == []) + +# 11. The listing walk follows the Link cursor, not `p=`. HF ignores `p=` on a sorted +# listing (every page returns the first one), which capped the first live run at 101 +# models however large --limit was. +def pages(*batches): + """A sweep_newest fetch serving synthetic pages; records the URLs requested.""" + urls = [] + + def fetch(url): + urls.append(url) + i = len(urls) - 1 + if i >= len(batches): + return [], "" + batch = [model(mid) for mid in batches[i]] + if i + 1 < len(batches): + return batch, f'; rel="next"' + return batch, "" + + return fetch, urls + + +fetch, urls = pages([f"a/{i}" for i in range(100)], [f"b/{i}" for i in range(100)]) +out = cw.sweep_newest(120, seen=set(), fetch=fetch) +check("sweep reaches the limit across two pages", + len(out) == 120 and out[0]["id"] == "a/0" and out[119]["id"] == "b/19") +check("sweep uses the cursor, never p=", + len(urls) == 2 and "p=" not in urls[0] and "cursor=PAGE1" in urls[1]) + +fetch, urls = pages([f"a/{i}" for i in range(100)]) +out = cw.sweep_newest(5, seen={f"a/{i}" for i in range(95)}, fetch=fetch) +check("sweep skips already-classified models", + [m["id"] for m in out] == [f"a/{i}" for i in range(95, 100)]) + +fetch, urls = pages([f"a/{i}" for i in range(3)]) +out = cw.sweep_newest(10, seen={"a/0", "a/1", "a/2"}, fetch=fetch) +check("sweep stops at the horizon after one page", out == [] and len(urls) == 1) + +fetch, urls = pages([]) +check("sweep tolerates an empty first page", cw.sweep_newest(10, seen=set(), fetch=fetch) == []) + +# 12. The committed registry parses and still maps a class the engine runs. +archs, sig = cw.load_registry() +check("committed registry has entries", len(archs) > 100) +check("committed registry maps Qwen3ForCausalLM", + bool(archs.get("Qwen3ForCausalLM", {}).get("backends"))) +check("committed significant list parses", isinstance(sig, dict) and len(sig) > 0) +check("no committed registry entry has a class with no backend field", + all("backends" in e and "gguf" in e for e in archs.values())) + +if failures: + print(f"\n{len(failures)} check(s) failed") + sys.exit(1) +print("\nall checks passed") diff --git a/tools/census_watch.py b/tools/census_watch.py new file mode 100644 index 00000000..fbd3153a --- /dev/null +++ b/tools/census_watch.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +# Copyright 2026 bong-water-water-bong +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Watch HF for new text-generation models the registry does not map. + +`tools/census.py` is a snapshot: it sweeps every page and reports coverage. New models +land on HF every day, and a new architecture class is exactly what moves the mapped +share without anyone noticing. This watcher polls the newest text-generation models, +reads each one's architecture class from its config (inline in the listing, so there is +no per-model fetch), and compares it against `registry/architectures.json` — the same +read `tools/census.py`'s coverage() makes, so "uncovered" here means "not in the number +the census reports". + +The port drops the old C++ probe (1bit-MONSTER's `Testing/census_coverage.py` compiled +`rcpp_arch_from_string` with g++): this repository's registry is generated data +(`tools/registry_build.py`), so the class is a plain lookup. There is no `strip_arch` +either — `architectures.json` is keyed by the HF architecture class, which is the key +the census counts. + +usage: tools/census_watch.py [--limit N] [--state PATH] + +Exit codes (the alert contract): + 0 no uncovered classes among the newest models + 1 an uncovered class arrived (the alert) + * runtime failure (no network, unreadable registry, ...) + +State (the model ids already classified) lives outside the checkout, at +$CENSUS_WATCH_STATE (default ~/.1bit/census-watch-state.json), so a run never dirties +the tree it reads. A model whose config is missing (a gated repo) is reported the first +time it is seen and then remembered, so a routine gated upload never fails a run twice — +only genuinely uncovered classes are the alert. + +The daily systemd timer on the development box (scripts/1bit-census-watch.{service,timer}) +and the census-watch workflow both run scripts/census-watch.sh, which wraps this. +""" +import argparse +import json +import os +import re +import sys +import time +import urllib.request + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +REG = os.path.join(ROOT, "registry") +API = "https://huggingface.co/api/models" +DEFAULT_LIMIT = 120 +DEFAULT_STATE = os.path.join(os.path.expanduser("~"), ".1bit", "census-watch-state.json") +MAX_SEEN = 5000 # cap state growth; oldest dropped +_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"') + +# A derivative carries no config.json of its own by design (a quantized GGUF, a LoRA +# adapter); the raw release it derives from carries the config and is what gets checked. +DERIVATIVE_TAGS = ("gguf", "lora", "peft") + +COVERED, UNCOVERED, UNVERIFIABLE, DERIVATIVE = "covered", "uncovered", "unverifiable", "derivative" + + +def hf_get_page(url, tries=4): + """GET one listing page: (models, the rel="next" cursor or ""). + + HF ignores `p=` when the listing is sorted (every page returns the first one), so + pagination follows the Link header's cursor — the same way tools/census.py walks the + full sweep. A page fetched with `p=` overlaps the previous one 100%, which silently + capped this watcher at 100 models however large --limit was. + """ + for t in range(tries): + try: + req = urllib.request.Request(url, headers={"User-Agent": "1bit-census-watch"}) + with urllib.request.urlopen(req, timeout=60) as r: + return json.load(r), r.headers.get("Link", "") + except Exception as e: + if t == tries - 1: + print(f"hf_get_page {url}: {e}", file=sys.stderr) + return [], "" + time.sleep(2 * (t + 1)) + return [], "" + + +def load_registry(): + """The committed registry and the reviewed not-an-alias list (docs/arch-gaps.md).""" + archs = json.load(open(os.path.join(REG, "architectures.json")))["architectures"] + sig = json.load(open(os.path.join(REG, "significant.json")))["classes"] + return archs, sig + + +def architectures_of(model): + """The config's architecture class, or '' when the listing carried no config.""" + cfg = model.get("config") or {} + archs = cfg.get("architectures") or [] + return archs[0] if archs and isinstance(archs[0], str) else "" + + +def is_derivative(model): + tags = model.get("tags") or [] + return (model.get("library_name") == "peft" + or any(t in tags for t in DERIVATIVE_TAGS)) + + +def classify(models, archs, sig, previously_seen=()): + """Classify a batch of HF model records against the registry. + + Pure — no network, no disk — so tests/census_watch_test.py can drive it. A model is + covered when its architecture class is a registry key with at least one backend; + uncovered when it is not, which is the alert. A model with no config is unverifiable + (gated repos need a token) unless it is a derivative, which is expected and silent. + + `previously_seen` suppresses repeat reporting of unverifiable models. + """ + out = {COVERED: [], UNCOVERED: [], UNVERIFIABLE: [], DERIVATIVE: []} + reasons = {} # model id -> why it could not be classified + classes = {} # uncovered class -> [model ids] + for m in models: + mid = m.get("id", "") + arch = architectures_of(m) + if not arch: + if is_derivative(m): + out[DERIVATIVE].append(mid) + continue + if mid not in previously_seen: + out[UNVERIFIABLE].append(mid) + reasons[mid] = "no config / no architecture" + continue + entry = archs.get(arch) + if entry and entry.get("backends"): + out[COVERED].append(mid) + continue + out[UNCOVERED].append(mid) + classes.setdefault(arch, []).append(mid) + out["classes"] = classes + out["reasons"] = reasons + out["significant"] = sorted(c for c in classes if c in sig) + return out + + +def sweep_newest(limit, seen, tag="text-generation", pages_guard=20, fetch=None): + """The newest `limit` models whose ids are not in `seen`, newest first. + + Walks the createdAt-descending listing by its Link cursor until it has `limit` unseen + models, the cursor runs out, or a whole page is already known (the horizon: everything + older has been classified). `fetch(url) -> (models, next_cursor)` is injectable so + tests can drive the walk offline. + """ + fetch = fetch or hf_get_page + fresh, batch_ids = [], set() + url = f"{API}?pipeline_tag={tag}&sort=createdAt&direction=-1&limit=100&config=true" + page = 0 + while url and len(fresh) < limit and page < pages_guard: + batch, link = fetch(url) + if not batch: + break + for m in batch: + mid = m.get("id") + if mid and mid not in seen and mid not in batch_ids: + batch_ids.add(mid) + fresh.append(m) + if all(m.get("id") in seen for m in batch): + break + nxt = _NEXT.search(link or "") + url = nxt.group(1) if nxt else None + page += 1 + time.sleep(0.3) + return fresh[:limit] + + +def load_state(path): + try: + with open(path) as f: + state = json.load(f) + except Exception: + return {"last_run": None, "seen": {}} + state.setdefault("seen", {}) + return state + + +def save_state(path, state): + seen = state["seen"] + if len(seen) > MAX_SEEN: + state["seen"] = dict(list(seen.items())[-MAX_SEEN:]) + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + with open(path, "w") as f: + json.dump(state, f, indent=1, sort_keys=True) + f.write("\n") + + +def remember(state, res): + """Record what was classified, so the next walk moves past it.""" + for group in (COVERED, UNCOVERED, UNVERIFIABLE, DERIVATIVE): + for mid in res[group]: + state["seen"][mid] = group + return state + + +def report(models, res, previously_seen): + """Print the run's summary and return the exit code (1 when a class is uncovered).""" + in_scope = len(res[COVERED]) + len(res[UNCOVERED]) + print(f"census-watch: {len(models)} newest text-generation models checked, " + f"{in_scope} with an architecture, {len(res[COVERED])} mapped, " + f"{len(res['classes'])} uncovered class(es), " + f"{len(res[UNVERIFIABLE])} unverifiable (gated/no-config), " + f"{len(res[DERIVATIVE])} derivative (no config by design)") + if not models: + print(f"census-watch: nothing new since the last run " + f"({len(previously_seen)} models already classified)") + for cls, ids in sorted(res["classes"].items(), key=lambda kv: (-len(kv[1]), kv[0])): + if cls in res["significant"]: + print(f" !! UNCOVERED {cls} (reviewed, not an alias — docs/arch-gaps.md): " + f"{len(ids)} model(s), e.g. {ids[0]}") + print(f" -> needs real engine support, not a mapping; if a backend starts " + f"accepting it, record why in registry/significant.json ('mapped_ok')") + else: + print(f" !! UNCOVERED {cls} (new class): {len(ids)} model(s), e.g. {ids[0]}") + print(f" -> map it in registry/architectures.json (a pinned backend's code " + f"must accept the architecture)") + for mid in sorted(res[UNVERIFIABLE]): + print(f" ? UNVERIFIABLE {mid} ({res['reasons'][mid]}) — gated repos need a token; " + f"retried next run") + return 1 if res["classes"] else 0 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--limit", type=int, default=DEFAULT_LIMIT, + help=f"newest models to check (default {DEFAULT_LIMIT})") + ap.add_argument("--state", default=os.environ.get("CENSUS_WATCH_STATE", DEFAULT_STATE), + help=f"state file (default {DEFAULT_STATE})") + a = ap.parse_args() + + archs, sig = load_registry() + state = load_state(a.state) + previously_seen = set(state["seen"]) + state["last_run"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + models = sweep_newest(a.limit, previously_seen) + res = classify(models, archs, sig, previously_seen) + rc = report(models, res, previously_seen) + + # Remember what was classified, so the walk moves past it. An unverifiable model is + # remembered too but reported only the first time it is seen. + remember(state, res) + if models and rc == 0: + print("census-watch: OK — no uncovered class in the newest models") + save_state(a.state, state) + print(f"census-watch: state {a.state}") + if rc != 0: + print(f"census-watch: EXIT {rc} (an uncovered class arrived in the newest models)") + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/copyright.py b/tools/copyright.py index 379df349..6343aaea 100644 --- a/tools/copyright.py +++ b/tools/copyright.py @@ -32,7 +32,8 @@ LINE_STYLES = [ (("*.cpp", "*.h", "*.inc", "*.hpp", "*.c", "*.cc", "*.rs"), "//"), (("*.py", "*.sh", "*.yml", "*.yaml", "*.cmake", "*.cmake.in", "CMakeLists.txt", ".gitmodules", - ".gitignore", ".gitattributes", ".clang-format", "*.toml", "*.config", "*.fragment"), "#"), + ".gitignore", ".gitattributes", ".clang-format", "*.toml", "*.config", "*.fragment", + "*.service", "*.timer"), "#"), ] BLOCK_STYLES = [(("*.md", "*.html", "*.svg"), ("")), (("*.css", "*.js"), ("/*", "*/"))] From 4107b77500b4d549f8acf349321151900fe77d94 Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Wed, 30 Sep 2026 05:06:38 -0300 Subject: [PATCH 2/2] census watch: drop the Actions workflow until an RFC covers issues: write The systemd timer is the alert path for now; the CI path (a workflow with issues: write) goes through an RFC first, per CONTRIBUTING. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/census-watch.yml | 111 ----------------------------- docs/registry.md | 10 +-- scripts/census-watch.sh | 3 +- tests/census_watch_test.py | 2 +- tools/census_watch.py | 2 +- 5 files changed, 8 insertions(+), 120 deletions(-) delete mode 100644 .github/workflows/census-watch.yml diff --git a/.github/workflows/census-watch.yml b/.github/workflows/census-watch.yml deleted file mode 100644 index 6188dffc..00000000 --- a/.github/workflows/census-watch.yml +++ /dev/null @@ -1,111 +0,0 @@ -# Copyright 2026 bong-water-water-bong -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# Intent: run the daily HF new-model watcher (tools/census_watch.py, via -# scripts/census-watch.sh) and alert when a newly-published model carries an architecture -# class the registry (registry/architectures.json) does not map. That is the one thing -# that silently moves the mapped share the census reports — this workflow turns it into a -# red check plus a deduped alert issue. -# -# The same entry point backs the 04:30 systemd timer on the development box -# (scripts/1bit-census-watch.{service,timer}); CI here is the second, self-hosted-free -# alert path (ubuntu-latest, stdlib python only — the registry is generated data, so -# unlike 1bit-MONSTER's watcher there is no g++ probe to compile). -# -# NOTE: scheduled runs only fire on the default branch. workflow_dispatch is the manual -# trigger for feature branches. -# -# Deliberately no pull_request trigger. The live watch is red whenever any newest model's -# class is unmapped — that is the alert — and the census reports a mapped share below -# 100% by design, so a PR gate would be red on every pull request and mean nothing. The -# watcher's own contract is checked offline by ctest `census_watch` (tests/census_watch_test.py, -# run by ci.yml), which needs no network and no HF state; the live alert runs on schedule. -# -# Gated/no-config models do NOT fail the run — they are unverifiable without an HF token -# and expected; only genuinely uncovered classes alert. - -name: Census watch - -on: - schedule: - - cron: "30 4 * * *" # 04:30 UTC daily, ahead of the 05:17 census sweep - workflow_dispatch: - -permissions: - contents: read - issues: write - -concurrency: - group: census-watch - cancel-in-progress: false - -jobs: - watch: - name: New HF model classes are covered - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.12" - - # The watcher's state file (the model ids already classified) lives outside the - # checkout and is not carried between CI runs, so the newest models are re-checked - # every run — harmless, and the issue side dedupes. - - name: Run the new-model watcher - id: watch - run: | - bash scripts/census-watch.sh --limit 120 2>&1 | tee /tmp/census-watch.log - exit ${PIPESTATUS[0]} - - # Only alert on scheduled/manual runs — on PRs the red check itself is the signal. - # Guard on the watch step specifically so a checkout/setup failure doesn't file a - # misleading issue. - - name: File an alert issue on uncovered classes - if: failure() && steps.watch.conclusion == 'failure' && github.event_name != 'pull_request' - env: - GH_TOKEN: ${{ github.token }} - run: | - grep -E "UNCOVERED|UNVERIFIABLE" /tmp/census-watch.log || true - gh label create census-watch 2>/dev/null || true - EXISTING=$(gh issue list --label census-watch --state open --json number --jq 'length') - if [ "$EXISTING" -gt 0 ]; then - echo "census-watch issue already open — skipping" - exit 0 - fi - { - echo "## Census coverage breach" - echo "" - echo "The daily new-model watcher found HF models whose architecture class is not" - echo "mapped by the engine registry (\`registry/architectures.json\`):" - echo "" - echo '```' - grep -E "UNCOVERED|UNVERIFIABLE" /tmp/census-watch.log || true - echo '```' - echo "" - echo "Fix: map the class in \`registry/architectures.json\` when a pinned backend's" - echo "code really accepts the architecture, or record why it is not an alias in" - echo "\`registry/significant.json\` + \`docs/arch-gaps.md\` (a class reviewed as" - echo "SIGNIFICANT needs real engine support, not a mapping). Re-run" - echo "\`python3 tools/census_watch.py\` to confirm, and \`python3 tools/census.py" - echo "--report\` for the coverage the census would report." - echo "" - echo "Report: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - } > /tmp/census_issue.md - gh issue create \ - --title "census: new HF model class uncovered by the registry" \ - --label census-watch \ - --body-file /tmp/census_issue.md diff --git a/docs/registry.md b/docs/registry.md index f166e761..b0ca543a 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -122,11 +122,11 @@ per-model fetch and no compiled probe: the registry is generated data, keyed by architecture class the census counts. Exit 0 means every newest model is mapped; **exit 1 is the alert** — a class arrived that -no backend accepts. It is run by the `census-watch.yml` workflow daily at 04:30 UTC (ahead -of the 05:17 sweep) and by the systemd timer on the development box -(`scripts/1bit-census-watch.{service,timer}`, from the `~/census-main` worktree that -tracks `origin/main`). The workflow files a deduped `census-watch` issue; the timer marks -its unit failed, and every run lands in `~/.1bit/logs/census-watch-*.log`. +no backend accepts. It is run daily at 04:30 UTC (ahead of the 05:17 sweep) by the systemd +timer on the development box (`scripts/1bit-census-watch.{service,timer}`, from the +`~/census-main` worktree that tracks `origin/main`). The timer marks its unit failed, and +every run lands in `~/.1bit/logs/census-watch-*.log`. A GitHub Actions path that files an +alert issue needs `issues: write`, so it waits for an RFC. A class the watcher flags is one of two things, and the run says which: diff --git a/scripts/census-watch.sh b/scripts/census-watch.sh index 61d77342..e9ed2b55 100755 --- a/scripts/census-watch.sh +++ b/scripts/census-watch.sh @@ -17,8 +17,7 @@ # census-watch.sh — daily HF new-model watcher entry point # # Wraps tools/census_watch.py so the daily watch is one command, usable from the systemd -# units (scripts/1bit-census-watch.{service,timer}) and the GitHub Actions workflow -# (.github/workflows/census-watch.yml). The automation is reproducible from the repo +# units (scripts/1bit-census-watch.{service,timer}) or by hand. The automation is reproducible from the repo # instead of living ad-hoc on one box. # # What it does: polls the newest HF text-generation models, reads each one's architecture diff --git a/tests/census_watch_test.py b/tests/census_watch_test.py index 65ae7290..0ac1e90f 100644 --- a/tests/census_watch_test.py +++ b/tests/census_watch_test.py @@ -22,7 +22,7 @@ (the same approach tests/registry_gaps_test.py takes for the gap guard), then checks the committed registry still parses and still maps a known class. -No network: the listing walk (sweep_newest) is exercised by the workflow and the daily +No network: the listing walk (sweep_newest) is exercised by the daily timer, not here. """ import io diff --git a/tools/census_watch.py b/tools/census_watch.py index fbd3153a..a8b1f4e1 100644 --- a/tools/census_watch.py +++ b/tools/census_watch.py @@ -43,7 +43,7 @@ only genuinely uncovered classes are the alert. The daily systemd timer on the development box (scripts/1bit-census-watch.{service,timer}) -and the census-watch workflow both run scripts/census-watch.sh, which wraps this. +runs scripts/census-watch.sh, which wraps this. """ import argparse import json