From b4d6e9ffdc9c79b3084e49bdae5a56669e733dbf Mon Sep 17 00:00:00 2001 From: Dmitriy Vasilev Date: Fri, 9 Oct 2026 23:28:22 +0700 Subject: [PATCH] blog: every post carries a widget, as a t27 rule the work report enforces (Closes #1576) The owner's rule of 2026-10-09 now lives in apps/website/specs/policy/post_widget.t27. A post body that a PR adds, modifies or renames holds at least one terminal cast or figure in its English body and in its Russian ruBody. 72 older widgetless posts dated before 2026-09-27 are grandfathered by name. The spec has 5 tests, and t27c test-report counts 0 vacuous passes. pr-blog-report.yml saves each changed body as text from the PR head. pr_blog_report.py validate --posts reads the spec's constants and refuses a body without a widget. The workflow never runs that text. Foreign glue: 40 + 32 + 8 = 80 added lines. The 14 widgetless posts dated on or after 2026-09-27 get existing casts that match their text, in EN and RU. mask-over-scale has no matching recording, so it gets a figure built from its own numbers. The clocks post said one MMCM test fails; its recording shows two, so that sentence is corrected. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr-blog-report.yml | 9 +- apps/website/specs/policy/post_widget.t27 | 143 ++++++++++++++++++ .../bodies/a-course-pr-carries-its-post.ts | 14 ++ .../data/blog/bodies/an-eye-on-the-board.ts | 28 ++++ .../bodies/course-3-clocks-resets-and-cdc.ts | 32 +++- .../bodies/course-buses-and-peripherals.ts | 28 ++++ .../blog/bodies/course-three-goldenfloat.ts | 14 ++ .../bodies/course-three-verifying-hardware.ts | 28 ++++ .../blog/bodies/course-zero-t27-basics.ts | 14 ++ ...-over-scale-ternary-drafts-that-compile.ts | 10 ++ .../bodies/nine-modules-of-three-again.ts | 14 ++ ...one-regional-clock-twelve-pull-requests.ts | 14 ++ ...he-seed-moved-it-the-pins-moved-it-more.ts | 28 ++++ .../blog/bodies/three-dies-one-verdict.ts | 14 ++ .../bodies/tri-test-now-runs-the-tests.ts | 14 ++ .../two-courses-twenty-seven-lessons-each.ts | 28 ++++ docs/PR_BLOG_AUTOMATION.md | 11 ++ scripts/pr_blog_report.py | 40 +++++ scripts/test_pr_blog_report.py | 32 ++++ 19 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 apps/website/specs/policy/post_widget.t27 diff --git a/.github/workflows/pr-blog-report.yml b/.github/workflows/pr-blog-report.yml index 202e65587c..48628e846e 100644 --- a/.github/workflows/pr-blog-report.yml +++ b/.github/workflows/pr-blog-report.yml @@ -51,12 +51,19 @@ jobs: --jq '.[] | {filename, status, previous_filename}' > /tmp/pr-files.jsonl gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits?per_page=100" \ --jq '.[] | {sha, author: .author.login, committer: .committer.login, verified: .commit.verification.verified}' > /tmp/pr-commits.jsonl + - name: Load changed post bodies as text (apps/website/specs/policy/post_widget.t27) + run: | + set -euo pipefail + DIR=$(sed -n 's/^pub const POST_DIR : str = "\(.*\)";$/\1/p' apps/website/specs/policy/post_widget.t27) + SHA=$(jq -er '.head.sha' /tmp/pr.json); mkdir -p /tmp/pr-posts + jq -r --arg d "$DIR" 'select(.status != "removed" and (.filename | startswith($d) and endswith(".ts"))) | .filename' /tmp/pr-files.jsonl | + while read -r f; do gh api -H 'Accept: application/vnd.github.raw+json' "repos/$GITHUB_REPOSITORY/contents/$f?ref=$SHA" > "/tmp/pr-posts/${f##*/}"; done - name: Validate mandatory report and generate blog draft id: validate continue-on-error: true run: >- python3 scripts/pr_blog_report.py validate --event /tmp/event.json --output /tmp/pr-blog - --files /tmp/pr-files.jsonl --commits /tmp/pr-commits.jsonl + --files /tmp/pr-files.jsonl --commits /tmp/pr-commits.jsonl --posts /tmp/pr-posts - name: Bind the required status to the current PR head if: always() env: diff --git a/apps/website/specs/policy/post_widget.t27 b/apps/website/specs/policy/post_widget.t27 new file mode 100644 index 0000000000..dcae2392f2 --- /dev/null +++ b/apps/website/specs/policy/post_widget.t27 @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/policy/post_widget.t27 -- every blog post carries a widget +; Source of truth for scripts/pr_blog_report.py (gHashTag/trinity, repository root), which +; reads the str constants below when it validates a PR's work report with --posts. A post +; body under POST_DIR that the PR adds, modifies or renames must hold at least one block +; whose kind is in WIDGET_KINDS, in its English body and, when it has one, in its Russian +; ruBody, or the "T27 work report" status turns red with RULE as the reason. The rule's +; words live here and nowhere else. +; ASCII only (L3), English only (LANG-EN). + +; WHY: the owner's rule of 2026-10-09. A post shows the reader the real command running: +; a tri or t27c terminal cast (a recording under public/term//, replayed in the page by +; public/term/player.js) or a figure drawn from the post's own numbers. From 2026-10-07 to +; 2026-10-09 eleven posts in a row shipped as text only, because the rule was said once +; and written nowhere. A rule nobody wrote down is a rule nobody checks. + +; WHAT COUNTS: a block is a widget when its kind is one of WIDGET_KINDS ("terminal" is a +; recorded session, "figure" an SVG with a caption; see src/data/blog/types.ts). The check +; reads the body file as text and never runs it. A body that exports body and ruBody needs a +; widget in each; a body with no ruBody needs one in body. A removed body is not checked. + +; GRANDFATHERED: the posts dated before WIDGET_SINCE that carried no widget on the day this +; rule was written, by body file name without .ts. They may be edited without adding one. +; The list only shrinks: a post that gains a widget may leave it, and no post joins it. +; WIDGET_SINCE is the owner's cutoff: every post dated on or after it carries a widget from +; the change that adds this file on, so none of them is listed. + +; LIMIT: the workflow never checks out the PR (pull_request_target), so the check cannot +; see whether a terminal block's recording exists under public/term/; the site build and +; its player do. A kind is matched by its text, kind: 'terminal' or "kind": "terminal". +; phi^2 + 1/phi^2 = 3 | TRINITY + +module post_widget_policy; + +pub const KIND : str = "policy"; +pub const SCHEMA_VERSION : u8 = 1; +pub const RULE_ID : str = "widget-per-post"; +pub const RULE : str = "A blog post carries at least one widget, a terminal cast or a figure, in its English body and in its Russian ruBody."; +pub const POST_DIR : str = "apps/website/src/data/blog/bodies/"; +pub const WIDGET_KIND_COUNT : u8 = 2; +pub const WIDGET_KINDS : [2]str = ["terminal", "figure"]; +pub const WIDGET_SINCE : str = "2026-09-27"; +pub const GRANDFATHERED_COUNT : u8 = 72; +pub const GRANDFATHERED : [72]str = [ + "a-broken-reference-looks-exactly-like-broken-code", + "a-clean-merge-is-not-a-semantic-no-op", + "a-correct-gate-with-a-manual-remedy", + "a-gate-that-rejected-its-own-users", + "a-health-snapshot-changed-its-denominator", + "a-linux-target-with-no-abi-is-musl", + "a-measurement-number-needs-a-passport", + "a-multiplicity-correction-changed-the-deployment-reading", + "a-partial-module-needs-its-own-verdict", + "a-red-gate-was-missing-its-input", + "a-repair-reaches-only-the-copy-it-lands-in", + "a-small-agent-needs-an-exact-judge", + "a-suite-that-runs-nothing-exits-zero", + "an-inert-filter-is-safest-until-it-works", + "an-invitation-in-the-language-it-describes", + "clara-proposal-submitted-not-reviewed", + "context-length-resonance-not-power-law", + "each-half-imported-the-other", + "eight-theorems-audited", + "eleven-verdicts-were-windows-not-checkpoints", + "energy-asymmetry-activations", + "equal-stored-width-removed-an-accuracy-lead", + "features-that-change-no-bits", + "fifteen-merged-nine-credited", + "five-reasons-the-build-was-red", + "formal-was-green-and-had-never-run-a-solver", + "four-hundred-and-twelve-tests-that-were-sentences", + "four-languages-one-tri-extension", + "fourteen-rows-agreed-one-did-not", + "frame-length-margin-law", + "green-ci-does-not-mean-usable", + "how-to-join-the-swarm", + "i-wrote-the-post-then-did-the-thing", + "merge-diff-ci-boundaries", + "ninety-tests-were-unreachable", + "nobodys-example", + "one-commit-nine-workflow-outcomes", + "one-more-reading-then-it-cleared", + "one-saturation-rule-five-artefacts", + "open-gigabit-ethernet-artix7", + "phi-identity-machine-checked", + "physical-width-changed-the-question", + "queen-browser-actions-you-can-follow", + "queen-foundation-snapshot-contract", + "queen-phone-orientation-gate", + "queen-review-lifecycle-queues", + "readout-that-cannot-be-misread", + "real-value-in-integer-container", + "scale-field-width-already-published", + "signal-health-self-run32", + "six-and-a-half-years-in-a-discarded-return-value", + "ternary-won-the-wire-not-the-gate", + "the-auditor-made-the-mistake-it-audits", + "the-control-that-could-not-fail", + "the-experiment-that-could-not-answer", + "the-fpga-row-was-corrected", + "the-full-adder-made-the-cost-claim-comparable", + "the-gate-was-right-and-nothing-stopped", + "the-generated-file-was-three-years-old", + "the-only-stable-speed-belonged-to-the-tool", + "the-ratchet-counted-a-total-as-an-error", + "the-required-check-was-an-echo", + "the-scanner-scored-what-it-could-not-see", + "the-search-space-erased-a-significance-claim", + "the-silence-was-a-saturated-readout", + "the-tail-that-had-never-run", + "thirty-epochs-exposed-a-failure-rate-blind-spot", + "tri-claw-an-agent-you-can-audit", + "tri-mined-not-sold", + "twenty-merged-in-three-days", + "twenty-three-reference-models", + "two-bitstreams-one-bit-apart" +]; + +test the_rule_is_named_like_the_course_rule { + assert RULE_ID == "widget-per-post"; + assert KIND == "policy"; + assert SCHEMA_VERSION == 1; +} + +test a_widget_is_a_cast_or_a_figure { + assert WIDGET_KIND_COUNT == 2; + assert WIDGET_KINDS[0] == "terminal"; + assert WIDGET_KINDS[1] == "figure"; +} + +test posts_live_where_the_course_rule_looks { + assert POST_DIR == "apps/website/src/data/blog/bodies/"; +} + +test the_cutoff_date_is_pinned { + assert WIDGET_SINCE == "2026-09-27"; +} + +test the_grandfathered_list_is_closed { + assert GRANDFATHERED_COUNT == 72; + assert GRANDFATHERED[0] == "a-broken-reference-looks-exactly-like-broken-code"; + assert GRANDFATHERED[71] == "two-bitstreams-one-bit-apart"; +} diff --git a/apps/website/src/data/blog/bodies/a-course-pr-carries-its-post.ts b/apps/website/src/data/blog/bodies/a-course-pr-carries-its-post.ts index 495f84f33c..cfeace4223 100644 --- a/apps/website/src/data/blog/bodies/a-course-pr-carries-its-post.ts +++ b/apps/website/src/data/blog/bodies/a-course-pr-carries-its-post.ts @@ -9,6 +9,13 @@ export const body: Block[] = [ kind: 'p', text: 'Every trinity PR already carries a work report, and a merged PR later becomes a blog draft. A course change could still land with no post at all, so a reader could meet a new lesson before anything explained it. trinity#1461 closes that gap: a PR that changes a course now has to carry its blog post, English body and Russian ruBody, in the same PR, or the "T27 work report" status turns red. The PR is open and not merged yet.', }, + { + kind: 'terminal', + src: 'term/tri-blog-list/session.cast', + share: 'https://t27.ai/term/tri-blog-list/', + title: 'tri blog list: every t27.ai post and its receipt state', + caption: 'tri blog list, recorded on 3 October: 73 published posts, newest first, each with its date, its language and its revision. Under this rule a course change and the post that explains it reach this list together. 2.4 s.', + }, { kind: 'h', text: 'The rule lives in a t27 spec' }, { kind: 'p', @@ -57,6 +64,13 @@ export const ruBody: Block[] = [ kind: 'p', text: 'У каждого PR в trinity уже есть отчёт о работе, а влитый PR потом становится черновиком поста в блоге. Но изменение курса всё ещё могло попасть в main совсем без поста, и читатель встречал новый урок раньше, чем что-нибудь его объясняло. trinity#1461 закрывает эту дыру: PR, который меняет курс, обязан нести свой пост — английское body и русское ruBody — в том же PR, иначе статус «T27 work report» краснеет. PR открыт и ещё не влит.', }, + { + kind: 'terminal', + src: 'term/tri-blog-list/session.cast', + share: 'https://t27.ai/term/tri-blog-list/', + title: 'tri blog list: every t27.ai post and its receipt state', + caption: 'tri blog list, записано 3 октября: 73 опубликованных поста, сначала новые, у каждого дата, язык и ревизия. По этому правилу изменение курса и пост, который его объясняет, попадают в этот список вместе. 2,4 с.', + }, { kind: 'h', text: 'Правило живёт в спеке t27' }, { kind: 'p', diff --git a/apps/website/src/data/blog/bodies/an-eye-on-the-board.ts b/apps/website/src/data/blog/bodies/an-eye-on-the-board.ts index a1645ded45..74d871060a 100644 --- a/apps/website/src/data/blog/bodies/an-eye-on-the-board.ts +++ b/apps/website/src/data/blog/bodies/an-eye-on-the-board.ts @@ -15,6 +15,13 @@ export const body: Block[] = [ kind: 'p', text: 'The first frame read "ALINX" on the circuit board. Our hardware notes had called this board a QMTech Wukong for months, an XC7A200T chip in an FGG676 package. It is an ALINX AX7203: the same XC7A200T chip in an FBG484 package. Over JTAG the chip reports the same ID code, 0x3636093, in both packages, so no tool in the flow could tell them apart.', }, + { + kind: 'terminal', + src: 'term/tri-fpga-jtag/session.cast', + share: 'https://t27.ai/term/tri-fpga-jtag/', + title: 'tri fpga-jtag · which chip answered the cable', + caption: 'tri fpga-jtag names a die and a revision from an IDCODE, never a package: 0x03636093 decodes to an XC7A200T whether the chip sits in an FGG676 or an FBG484 package. Recorded on 3 October, five days before the camera\'s first frame; the self-test passes 17 of 17. 13.5 s.', + }, { kind: 'p', text: '[measured] A bitstream made earlier for the AX7203 settled it. Clocked from the AX7203\'s 200 MHz oscillator, it answered 512 of 512 additions bit-exact over the AX7203\'s serial port.', @@ -28,6 +35,13 @@ export const body: Block[] = [ kind: 'p', text: 'specs/fpga/eth_beacon.t27 builds one UDP broadcast frame, byte by byte: the preamble, the broadcast address, an IPv4 header with its checksum, a UDP header, a payload of "T27E" and a frame counter, and the Ethernet checksum (CRC-32). Its 9 tests pin the IPv4 checksum, the header bytes, the CRC of "123456789" and the last four bytes of frames 0 and 1. Those four bytes were first computed with zlib, before the spec was written.', }, + { + kind: 'terminal', + src: 'term/t27c-eth-crc/session.cast', + share: 'https://t27.ai/term/t27c-eth-crc/', + title: 't27c on eth_crc.t27 -- the FCS, native', + caption: 'Not the beacon spec itself but specs/fpga/eth_crc.t27, the Ethernet frame check sequence on its own: t27c test-report runs 8 of 8 tests natively, none vacuous, among them check_value_123456789 and receiver_residue_is_the_magic. Recorded on 8 October; 9.7 s.', + }, { kind: 'p', text: 'The Verilog around the spec is 40 lines of Xilinx primitives and nothing else: an input buffer for the 200 MHz clock, a PLL that makes 125 MHz plus a copy shifted by 90 degrees, and output cells that put each byte on the four RGMII wires as two 4-bit halves. The shifted copy is the transmit clock. It lands in the middle of each data bit, so the network chip needs no extra setup.', @@ -76,6 +90,13 @@ export const ruBody: Block[] = [ kind: 'p', text: 'На первом кадре на плате читалось «ALINX». Наши записи о железе несколько месяцев называли эту плату QMTech Wukong, то есть кристалл XC7A200T в корпусе FGG676. На деле это ALINX AX7203: тот же кристалл XC7A200T, но в корпусе FBG484. По JTAG кристалл сообщает один и тот же код, 0x3636093, в обоих корпусах, поэтому ни один инструмент в цепочке не мог их различить.', }, + { + kind: 'terminal', + src: 'term/tri-fpga-jtag/session.cast', + share: 'https://t27.ai/term/tri-fpga-jtag/', + title: 'tri fpga-jtag · which chip answered the cable', + caption: 'tri fpga-jtag называет по IDCODE кристалл и ревизию, но не корпус: 0x03636093 расшифровывается как XC7A200T, в корпусе FGG676 или FBG484 — всё равно. Записано 3 октября, за пять дней до первого кадра камеры; самопроверка проходит 17 из 17. 13,5 с.', + }, { kind: 'p', text: '[измерено] Вопрос закрыл битстрим, собранный раньше для AX7203. От генератора 200 МГц этой платы он ответил по её последовательному порту 512 из 512 сложений, совпавших до бита.', @@ -89,6 +110,13 @@ export const ruBody: Block[] = [ kind: 'p', text: 'specs/fpga/eth_beacon.t27 собирает один широковещательный UDP-кадр байт за байтом: преамбулу, широковещательный адрес, заголовок IPv4 с контрольной суммой, заголовок UDP, полезную нагрузку «T27E» со счётчиком кадров и контрольную сумму Ethernet (CRC-32). Его 9 тестов закрепляют контрольную сумму IPv4, байты заголовка, CRC строки «123456789» и последние четыре байта кадров 0 и 1. Эти четыре байта сначала посчитали через zlib, ещё до того как спецификация была написана.', }, + { + kind: 'terminal', + src: 'term/t27c-eth-crc/session.cast', + share: 'https://t27.ai/term/t27c-eth-crc/', + title: 't27c on eth_crc.t27 -- the FCS, native', + caption: 'Это не сама спецификация маячка, а specs/fpga/eth_crc.t27 — контрольная сумма кадра Ethernet отдельно: t27c test-report нативно прогоняет 8 из 8 тестов, ни один не пустой, среди них check_value_123456789 и receiver_residue_is_the_magic. Записано 8 октября; 9,7 с.', + }, { kind: 'p', text: 'Вокруг спецификации — 40 строк Verilog, только примитивы Xilinx: входной буфер для тактовой частоты 200 МГц, PLL, который делает 125 МГц и копию со сдвигом на 90 градусов, и выходные ячейки, которые выдают каждый байт на четыре провода RGMII двумя половинами по 4 бита. Сдвинутая копия служит тактом передачи. Её фронт попадает в середину каждого бита данных, поэтому сетевой микросхеме не нужна дополнительная настройка.', diff --git a/apps/website/src/data/blog/bodies/course-3-clocks-resets-and-cdc.ts b/apps/website/src/data/blog/bodies/course-3-clocks-resets-and-cdc.ts index 04a5e0539d..52656ab3a3 100644 --- a/apps/website/src/data/blog/bodies/course-3-clocks-resets-and-cdc.ts +++ b/apps/website/src/data/blog/bodies/course-3-clocks-resets-and-cdc.ts @@ -44,10 +44,24 @@ export const body: Block[] = [ ['mtbf.t27', '15', '4', 'The WP323 form of the equation; tau 50 ps and W 10 ps are labelled assumptions, vendors publish flop parameters only partially.'], ], }, + { + kind: 'terminal', + src: 'term/t27c-mtbf/session.cast', + share: 'https://t27.ai/term/t27c-mtbf/', + title: 't27c on mtbf.t27 -- MTBF in integer log2, native', + caption: 't27c 0.4.0 on a laptop: mtbf.t27 passes 15 of 15 tests natively, none vacuous, with 4 invariants proved at compile time; the grep shows the header lines that label the resolve time constant and the aperture as assumptions. 12.7 s.', + }, { kind: 'h', text: 'What the recordings show' }, { kind: 'p', - text: 'Sixteen new cast widgets join the gallery, one recording per recorded lesson, made with native t27c 0.4.0 on a laptop. A recording shows the run that matters and reads its verdict from the text. The MTBF lesson shows one flop leaving -200 ps of slack and a log2-MTBF of -19513 in Q10, and two flops leaving 9800 ps and 275887. The MMCM mutation recording widens the VCO window floor from 800000 to 400000 kHz with one sed line, and the one test that fails is the one that names the number it rejects. The FIFO mutation recording changes the head pointer increment from + 1 to + 3 and every test still passes, because the suite asserts fill counts and flags, never pointer values: the exact hole a torn multi-bit pointer would slip through.', + text: 'Sixteen new cast widgets join the gallery, one recording per recorded lesson, made with native t27c 0.4.0 on a laptop. A recording shows the run that matters and reads its verdict from the text. The MTBF lesson shows one flop leaving -200 ps of slack and a log2-MTBF of -19513 in Q10, and two flops leaving 9800 ps and 275887. The MMCM mutation recording widens the VCO window floor from 800000 to 400000 kHz with one sed line, and the two tests that fail are the two that check the 500 MHz configuration it now lets in. The FIFO mutation recording changes the head pointer increment from + 1 to + 3 and every test still passes, because the suite asserts fill counts and flags, never pointer values: the exact hole a torn multi-bit pointer would slip through.', + }, + { + kind: 'terminal', + src: 'term/t27c-mmcm-mutate/session.cast', + share: 'https://t27.ai/term/t27c-mmcm-mutate/', + title: 't27c on mmcm.t27 -- widen the VCO window, two tests object', + caption: 'One sed lowers the VCO floor from 800000 to 400000 kHz: 14 of 16 tests still pass, and the two that fail, vco_below_the_window_is_rejected and vco_out_of_range_is_an_error, both check the 500 MHz configuration the wider window now admits; git restores the spec. 26.6 s.', }, { kind: 'p', @@ -112,10 +126,24 @@ export const ruBody: Block[] = [ ['mtbf.t27', '15', '4', 'Форма уравнения из WP323; tau 50 пс и W 10 пс помечены как допущения — вендоры публикуют параметры триггеров лишь частично.'], ], }, + { + kind: 'terminal', + src: 'term/t27c-mtbf/session.cast', + share: 'https://t27.ai/term/t27c-mtbf/', + title: 't27c on mtbf.t27 -- MTBF in integer log2, native', + caption: 't27c 0.4.0 на ноутбуке: mtbf.t27 нативно проходит 15 из 15 тестов, ни один не пустой, 4 инварианта доказаны при компиляции; grep показывает строки заголовка, где постоянная разрешения и апертура помечены как допущения. 12,7 с.', + }, { kind: 'h', text: 'Что показывают записи' }, { kind: 'p', - text: 'В галерее шестнадцать новых карточек-кастов, по одной записи на записанный урок, снятых нативным t27c 0.4.0 на ноутбуке. Запись показывает запуск, который важен, и читает вердикт из текста. Урок MTBF показывает, как одна ступень оставляет -200 пс запаса и log2-MTBF -19513 в Q10, а две ступени — 9800 пс и 275887. Запись мутации MMCM расширяет нижнюю границу окна VCO с 800000 до 400000 кГц одной строкой sed, и падает ровно тот тест, который называет число, что он отвергает. Запись мутации FIFO меняет инкремент указателя головы с + 1 на + 3, и все тесты по-прежнему проходят, потому что набор утверждает заполнение и флаги, но никогда значения указателей: та самая дыра, в которую проскользнул бы разорванный многобитный указатель.', + text: 'В галерее шестнадцать новых карточек-кастов, по одной записи на записанный урок, снятых нативным t27c 0.4.0 на ноутбуке. Запись показывает запуск, который важен, и читает вердикт из текста. Урок MTBF показывает, как одна ступень оставляет -200 пс запаса и log2-MTBF -19513 в Q10, а две ступени — 9800 пс и 275887. Запись мутации MMCM расширяет нижнюю границу окна VCO с 800000 до 400000 кГц одной строкой sed, и падают ровно два теста — те, что проверяют конфигурацию на 500 МГц, которую окно теперь пропускает. Запись мутации FIFO меняет инкремент указателя головы с + 1 на + 3, и все тесты по-прежнему проходят, потому что набор утверждает заполнение и флаги, но никогда значения указателей: та самая дыра, в которую проскользнул бы разорванный многобитный указатель.', + }, + { + kind: 'terminal', + src: 'term/t27c-mmcm-mutate/session.cast', + share: 'https://t27.ai/term/t27c-mmcm-mutate/', + title: 't27c on mmcm.t27 -- widen the VCO window, two tests object', + caption: 'Одна строка sed опускает нижнюю границу VCO с 800000 до 400000 кГц: 14 из 16 тестов по-прежнему проходят, а два упавших, vco_below_the_window_is_rejected и vco_out_of_range_is_an_error, оба проверяют конфигурацию на 500 МГц, которую более широкое окно теперь пропускает; git возвращает спеку. 26,6 с.', }, { kind: 'p', diff --git a/apps/website/src/data/blog/bodies/course-buses-and-peripherals.ts b/apps/website/src/data/blog/bodies/course-buses-and-peripherals.ts index 19ee877a10..e01ce40311 100644 --- a/apps/website/src/data/blog/bodies/course-buses-and-peripherals.ts +++ b/apps/website/src/data/blog/bodies/course-buses-and-peripherals.ts @@ -36,6 +36,13 @@ export const body: Block[] = [ kind: 'p', text: 'The course needed a framable widget for every lesson, and minted 33 new cast recordings. Most of them run native t27c 0.4.0 on a laptop against the bus specs. But the native runner cannot take most of these specs whole: uart, spi, apb_bridge, axi4, bridge and top_level are BLOCKED for comptime resolution the runner cannot finish. Four of them run clean natively: memory.t27 -- 15 tests and 6 invariants proved comptime -- spi_tb.t27 with 7 tests, and the two new Ethernet specs, eth_crc.t27 with 8 tests and rgmii.t27 with 7 tests plus its comptime invariant. So the recordings show what the compiler does complete on every spec: t27c check (0 errors, 0 warnings), t27c gen-verilog (the synthesizable module, its port list, its wires), and t27c debug-hir (the hardware IR view). The lesson texts say which is which; no recording claims a test run that did not happen.', }, + { + kind: 'terminal', + src: 'term/t27c-verilog-uart/session.cast', + share: 'https://t27.ai/term/t27c-verilog-uart/', + title: 't27c gen-verilog on uart.t27 -- spec to RTL', + caption: 't27c gen-verilog on uart.t27: the module ZeroDSP_UART with its ports, and the divisor 100,000,000 / 115,200 and the 16-deep FIFO as localparams. uart.t27 is BLOCKED in the native test runner, so this recording shows Verilog, not a test run. 3.3 s.', + }, { kind: 'p', text: 'The bench module records the live tools instead: tri fpga-ioclients reading the registered IO clients, tri fpga-claim and tri fpga-release taken and given back back-to-back, tri fpga-next reading what runs next, and the Ethernet bring-up plan of tri fpga-steps E3, pre-registered step by step. The capstone lesson lowers the whole design: top_level.t27 carries CLK_FREQ_HZ 100,000,000, SYSTICK_HZ 1000, NUM_MAC_UNITS 8 and the four opcodes (CMD_NOP, CMD_MAC_MULT, CMD_MAC_DOT, CMD_UART_SEND) over 19 tests.', @@ -45,6 +52,13 @@ export const body: Block[] = [ kind: 'p', text: 'The Ethernet module needed specs the tree did not have. specs/fpga/eth_crc.t27 carries the CRC-32 the frame check sequence computes, and specs/fpga/rgmii.t27 the double-data-rate timing arithmetic, 125 MHz for gigabit and the single-data-rate downshift for 10 and 100. Both were written in gHashTag/t27 (PR 7626), then vendored into the website files tree -- the direction the own-language rule wants: the spec lives in the repo that owns the language, the site serves a copy it can prove identical. Every constant in them that is not from IEEE 802.3 is labelled as an assumption in the spec header, with the document it is not from. The first version of the CRC spec was wrong in a way its own CI could not see: the gates check that a spec compiles, not that its asserts hold, and 6 of its 8 tests failed natively -- every failing assert compared the running register against zlib post-inverted values, and the receiver test claimed the register returns to its preset, which no 802.3 CRC-32 does. The spec now carries the physical model: the FCS is the post-inverted register, and a receiver digesting frame plus FCS lands on the residue 0xDEBB20E3, computed with the same Python zlib the header names as oracle. All 8 tests pass, none vacuous.', }, + { + kind: 'terminal', + src: 'term/t27c-eth-crc/session.cast', + share: 'https://t27.ai/term/t27c-eth-crc/', + title: 't27c on eth_crc.t27 -- the FCS, native', + caption: 'The CRC spec as it landed: t27c test-report runs all 8 tests natively and none is vacuous; receiver_residue_is_the_magic is the receiver test the paragraph above describes. 9.7 s.', + }, { kind: 'h', text: 'Four specs that only looked compiled' }, { kind: 'p', @@ -100,6 +114,13 @@ export const ruBody: Block[] = [ kind: 'p', text: 'Курсу нужен виджет для каждого урока, и он отчеканил 33 новые записи. Большинство прогоняет нативный t27c 0.4.0 на ноутбуке по спекам шин. Но нативный раннер не берёт большинство этих спек целиком: uart, spi, apb_bridge, axi4, bridge и top_level BLOCKED по comptime-разрешению, которое раннер не завершает. Четыре проходят нативно чисто: memory.t27 — 15 тестов и 6 инвариантов, доказанных comptime, spi_tb.t27 — 7 тестов и обе новые спеки Ethernet: eth_crc.t27 с 8 тестами и rgmii.t27 с 7 тестами и comptime-инвариантом. Поэтому записи показывают то, что компилятор доводит до конца на каждой спеке: t27c check (0 ошибок, 0 предупреждений), t27c gen-verilog (синтезируемый модуль, его порты, его провода) и t27c debug-hir (взгляд аппаратного IR). Тексты уроков говорят, что есть что; ни одна запись не утверждает прогон тестов, которого не было.', }, + { + kind: 'terminal', + src: 'term/t27c-verilog-uart/session.cast', + share: 'https://t27.ai/term/t27c-verilog-uart/', + title: 't27c gen-verilog on uart.t27 -- spec to RTL', + caption: 't27c gen-verilog на uart.t27: модуль ZeroDSP_UART с его портами, а делитель 100,000,000 / 115,200 и FIFO глубиной 16 — в виде localparam. В нативном раннере тестов uart.t27 BLOCKED, поэтому запись показывает Verilog, а не прогон тестов. 3,3 с.', + }, { kind: 'p', text: 'Модуль стенда записывает живые инструменты: tri fpga-ioclients читает зарегистрированных клиентов IO, tri fpga-claim и tri fpga-release берут и возвращают захват подряд, tri fpga-next читает, что запускается дальше, а план запуска Ethernet из tri fpga-steps E3 зарегистрирован по шагам заранее. Капстоун опускает весь дизайн: top_level.t27 несёт CLK_FREQ_HZ 100,000,000, SYSTICK_HZ 1000, NUM_MAC_UNITS 8 и четыре кода операций (CMD_NOP, CMD_MAC_MULT, CMD_MAC_DOT, CMD_UART_SEND) на 19 тестах.', @@ -109,6 +130,13 @@ export const ruBody: Block[] = [ kind: 'p', text: 'Модулю Ethernet нужны были спеки, которых в дереве не было. specs/fpga/eth_crc.t27 несёт CRC-32, который вычисляет контрольная сумма кадра, а specs/fpga/rgmii.t27 — тайминговую арифметику с двойной скоростью данных, 125 МГц для гигабита и одинарное понижение для 10 и 100. Обе написаны в gHashTag/t27 (PR 7626) и вендорены в дерево файлов сайта — в ту сторону, куда указывает правило собственного языка: спека живёт в репозитории, которому принадлежит язык, а сайт отдаёт копию, которую умеет доказать идентичной. Каждая константа в них, взятая не из IEEE 802.3, помечена в шапке спеки как допущение — рядом с документом, из которого её нет. Первая версия CRC-спеки была неверна так, что её собственный CI этого не видел: гейты проверяют, что спека компилируется, а не что её asserts держатся, и нативно падали 6 тестов из 8 — каждый неудачный assert сравнивал бегущий регистр с проинвертированными значениями zlib, а тест приёмника утверждал возврат регистра к пресету, чего CRC-32 из 802.3 не делает. Теперь спека несёт физическую модель: FCS — это проинвертированный регистр, а приёмник, переваривший кадр с FCS, приходит к остатку 0xDEBB20E3, вычисленному тем же Python zlib, который шапка называет оракулом. Все 8 тестов проходят, пустых — нет.', }, + { + kind: 'terminal', + src: 'term/t27c-eth-crc/session.cast', + share: 'https://t27.ai/term/t27c-eth-crc/', + title: 't27c on eth_crc.t27 -- the FCS, native', + caption: 'Спека CRC в том виде, в каком она влита: t27c test-report нативно прогоняет все 8 тестов, ни один не пустой; receiver_residue_is_the_magic — тот самый тест приёмника, о котором абзац выше. 9,7 с.', + }, { kind: 'h', text: 'Четыре спеки, которые только выглядели компилирующимися' }, { kind: 'p', diff --git a/apps/website/src/data/blog/bodies/course-three-goldenfloat.ts b/apps/website/src/data/blog/bodies/course-three-goldenfloat.ts index 4aee7e506d..62f8127811 100644 --- a/apps/website/src/data/blog/bodies/course-three-goldenfloat.ts +++ b/apps/website/src/data/blog/bodies/course-three-goldenfloat.ts @@ -197,6 +197,13 @@ export const body: Block[] = [ ] ] }, + { + "kind": "terminal", + "src": "term/t27c-tnf17/session.cast", + "share": "https://t27.ai/term/t27c-tnf17/", + "title": "t27c on tnf17.t27 -- a 17-bit ternary-exponent float, native", + "caption": "Not a lesson of this course: tnf17.t27, from the AI numbers course. Its TNF17e also keeps the exponent in balanced trits, four of them where GF-T16 uses six. t27c runs 34 of 34 tests natively, none vacuous; XOR swapped for OR in tnf_negate fails exactly one, negate_is_an_involution; git restores the spec. 30.3 s." + }, { "kind": "h", "text": "What this course does not claim" @@ -401,6 +408,13 @@ export const ruBody: Block[] = [ ] ] }, + { + "kind": "terminal", + "src": "term/t27c-tnf17/session.cast", + "share": "https://t27.ai/term/t27c-tnf17/", + "title": "t27c on tnf17.t27 -- a 17-bit ternary-exponent float, native", + "caption": "Это не урок этого курса, а tnf17.t27 из курса «AI numbers». Его TNF17e тоже хранит порядок в сбалансированных тритах: их четыре там, где у GF-T16 шесть. t27c нативно прогоняет 34 из 34 тестов, ни один не пустой; замена XOR на OR в tnf_negate роняет ровно один тест, negate_is_an_involution; git возвращает спеку. 30,3 с." + }, { "kind": "h", "text": "Чего этот курс не утверждает" diff --git a/apps/website/src/data/blog/bodies/course-three-verifying-hardware.ts b/apps/website/src/data/blog/bodies/course-three-verifying-hardware.ts index 98dd54034e..3fbc58c5c9 100644 --- a/apps/website/src/data/blog/bodies/course-three-verifying-hardware.ts +++ b/apps/website/src/data/blog/bodies/course-three-verifying-hardware.ts @@ -42,6 +42,20 @@ export const body: Block[] = [ "kind": "p", "text": "The widgets are recordings of real tool runs, and the lesson texts use only numbers those recordings or the named spec show. The UART link that asked for 115,200 baud and got 115,385 on the wire -- a +0.16 % divider error invisible in code and obvious in the capture, while 3,000,000 divides exactly and the wire agrees. The 31 of 1,428 merged specs whose seals record no output, on 16 specs, 3 of them unnamed by the ledger that owes them a name. The 367 pull requests that arrived in 14 days, 11 of which deleted tests or asserts, one deleting all 26 tests in its file with nothing failing. A course about verification does not get to cite numbers it cannot show." }, + { + "kind": "terminal", + "src": "term/tri-fpga-wire/session.cast", + "share": "https://t27.ai/term/tri-fpga-wire/", + "title": "tri fpga-wire · the baud rate actually on the wire", + "caption": "tri fpga-wire on the bench's CP2102N, plugged in with no hub: 115,200 requested puts 115,385 on the wire (+0.16 %), 921,600 puts 923,077, and 3,000,000 is exact. 6.5 s." + }, + { + "kind": "terminal", + "src": "term/t27-tri-seals/session.cast", + "share": "https://t27.ai/term/t27-tri-seals/", + "title": "tri seals: hollow seals that pass every check", + "caption": "tri seals hollow on the t27 tree: 31 of 1,428 seals record no generated output, on 16 distinct specs, and 3 of those are missing from the ledger that records the debt. 4.9 s." + }, { "kind": "h", "text": "A new spec: coverage.t27" @@ -120,6 +134,20 @@ export const ruBody: Block[] = [ "kind": "p", "text": "Виджеты -- это записи настоящих прогонов инструментов, а тексты уроков используют только числа, которые показывают эти записи или названный в них spec. Линк UART, запросивший 115,200 бод и получивший на проводе 115,385 -- ошибку делителя +0.16 %, невидимую в коде и очевидную в захвате; 3,000,000 делится нацело, и провод согласен. 31 из 1,428 слитых spec, чьи печати не записывают вывода, на 16 spec, и 3 из них не названы журналом, который им это имя должен. 367 pull request'ов, пришедших за 14 дней, из которых 11 удалили тесты или ассерты, а один удалил все 26 тестов своего файла -- и ничего не упало. Курс о проверке не имеет права цитировать числа, которые не может показать." }, + { + "kind": "terminal", + "src": "term/tri-fpga-wire/session.cast", + "share": "https://t27.ai/term/tri-fpga-wire/", + "title": "tri fpga-wire · the baud rate actually on the wire", + "caption": "tri fpga-wire на CP2102N стенда, подключённом без хаба: запрос 115,200 даёт на проводе 115,385 (+0.16 %), 921,600 даёт 923,077, а 3,000,000 — ровно. 6,5 с." + }, + { + "kind": "terminal", + "src": "term/t27-tri-seals/session.cast", + "share": "https://t27.ai/term/t27-tri-seals/", + "title": "tri seals: hollow seals that pass every check", + "caption": "tri seals hollow на дереве t27: 31 печать из 1,428 не записывает сгенерированный вывод, за ними 16 разных спек, и 3 из них нет в реестре, где записан этот долг. 4,9 с." + }, { "kind": "h", "text": "Новый spec: coverage.t27" diff --git a/apps/website/src/data/blog/bodies/course-zero-t27-basics.ts b/apps/website/src/data/blog/bodies/course-zero-t27-basics.ts index 67a013dd92..c87bd70015 100644 --- a/apps/website/src/data/blog/bodies/course-zero-t27-basics.ts +++ b/apps/website/src/data/blog/bodies/course-zero-t27-basics.ts @@ -17,6 +17,13 @@ export const body: Block[] = [ "kind": "p", "text": "Every lesson opens one infographic and one spec. The infographic is a black-and-white table, and in 11 of the 27 lessons also a flow diagram; its words and rows live in a .t27 widget spec (specs/widgets/basics-.t27), and a widget spec test checks that the table has whole rows. The lesson spec lives under specs/basics/ and compiles clean on all 7 backends in the browser, with its tests passing; the course generator refuses to build if one of them does not." }, + { + "kind": "terminal", + "src": "term/t27c-hello-world/session.cast", + "share": "https://t27.ai/term/t27c-hello-world/", + "title": "t27c on hello_world.t27", + "caption": "The step after this course, as course 1 records it in lesson 11: native t27c 0.4.0 on specs/demos/hello_world.t27 prints spec-status IMPLEMENTED, the two tests and two invariants the Zig backend emits, and a Verilog module that says it has no data ports. 13.7 s." + }, { "kind": "p", "text": "The tables are real HTML tables with a caption and header cells, and the diagrams are SVG with a title, so a screen reader reads both. On a phone the table turns into one card per row." @@ -210,6 +217,13 @@ export const ruBody: Block[] = [ "kind": "p", "text": "Каждый урок открывает одну инфографику и одну спеку. Инфографика — чёрно-белая таблица, а в 11 из 27 уроков ещё и схема; её слова и строки живут в спеке виджета .t27 (specs/widgets/basics-<урок>.t27), и тест спеки проверяет, что строки таблицы целые. Спека урока лежит в specs/basics/ и чисто собирается всеми 7 бэкендами прямо в браузере, а её тесты проходят; генератор курса отказывается собирать курс, если хоть одна не собирается." }, + { + "kind": "terminal", + "src": "term/t27c-hello-world/session.cast", + "share": "https://t27.ai/term/t27c-hello-world/", + "title": "t27c on hello_world.t27", + "caption": "Шаг после этого курса, как его записывает курс 1 в уроке 11: нативный t27c 0.4.0 на specs/demos/hello_world.t27 печатает spec-status IMPLEMENTED, два теста и два инварианта, которые выдаёт бэкенд Zig, и модуль Verilog, который сам говорит, что у него нет портов данных. 13,7 с." + }, { "kind": "p", "text": "Таблицы — настоящие HTML-таблицы с подписью и ячейками заголовка, схемы — SVG с title, поэтому экранный чтец читает и то и другое. На телефоне таблица превращается в карточку на строку." diff --git a/apps/website/src/data/blog/bodies/mask-over-scale-ternary-drafts-that-compile.ts b/apps/website/src/data/blog/bodies/mask-over-scale-ternary-drafts-that-compile.ts index 6c22c881c5..8768a247c4 100644 --- a/apps/website/src/data/blog/bodies/mask-over-scale-ternary-drafts-that-compile.ts +++ b/apps/website/src/data/blog/bodies/mask-over-scale-ternary-drafts-that-compile.ts @@ -45,6 +45,11 @@ export const body: Block[] = [ kind: 'p', text: "For scale, the 100M fp model on the same bench scores 7.4% pass@10 while its ternary twin scores 2.3% -- both after the same t27 finetune, so the comparison is apples to apples -- and the gap grew from 1.5x to 3.2x as data quadrupled. Ternarity is expensive for code; that result is published too, and this post is not an argument against it -- it is the honest account of what a board-sized ternary model is actually for.", }, + { + "kind": "figure", + "svg": "Items that compile, union of three arms (30 items)Full strict loop (391 items, 30 rounds)3 temperatures, 1 mask rule3 of 303 mask rules, 1 temperature15 of 30a body that compiles283 (72%)a body that passes the tests4 (1.0%)", + "caption": "From the numbers in this post: three mask rules at one temperature cover 15 of 30 items at compile, three temperatures at one rule 3 of 30; over the full loop, 283 of the 391 strict items got a body that compiles and 4 got one that passes." + }, { kind: 'h', text: "The package", @@ -100,6 +105,11 @@ export const ruBody: Block[] = [ kind: 'p', text: "Для масштаба: модель на 100M с плавающими весами на том же бенчмарке даёт 7.4% pass@10, её тернарная пара — 2.3% (обе — после такого же t27-файнтюна, то есть сравнение честное), и разрыв вырос с 1.5x до 3.2x при учетверении данных. Тернарность дорога для кода; этот результат тоже опубликован, и этот пост — не спор с ним, а честный отчёт о том, для чего на самом деле нужна тернарная модель-board-размера.", }, + { + "kind": "figure", + "svg": "Айтемы, которые компилируются, объединение трёх веток (30 айтемов)Полная строгая петля (391 айтем, 30 раундов)3 температуры, 1 правило маски3 из 303 правила маски, 1 температура15 из 30тело, которое компилируется283 (72%)тело, которое проходит тесты4 (1,0%)", + "caption": "По числам из этого поста: три правила маски при одной температуре покрывают при компиляции 15 айтемов из 30, три температуры при одном правиле — 3 из 30; за всю петлю 283 из 391 строгого айтема получили тело, которое компилируется, и 4 — тело, которое проходит тесты." + }, { kind: 'h', text: "Пакет", diff --git a/apps/website/src/data/blog/bodies/nine-modules-of-three-again.ts b/apps/website/src/data/blog/bodies/nine-modules-of-three-again.ts index 6d6b1711ec..c154876264 100644 --- a/apps/website/src/data/blog/bodies/nine-modules-of-three-again.ts +++ b/apps/website/src/data/blog/bodies/nine-modules-of-three-again.ts @@ -81,6 +81,13 @@ export const body: Block[] = [ { "kind": "p", "text": "The course generator reads the catalog and refuses to build a course whose module count or lessons per module differ from it, or whose lesson does not sit in module floor(i / 3). Setting MODULES_PER_COURSE back to 27 turns the course check red; restoring the file turns it green again." + }, + { + "kind": "terminal", + "src": "term/t27c-gft-xornet/session.cast", + "share": "https://t27.ai/term/t27c-gft-xornet/", + "title": "t27c on gft_xornet.t27 -- XOR needs a bend, native", + "caption": "The same move on a lesson spec: the recording of lesson 21 of course 2, xor-needs-a-bend, the last lesson of module 7. t27c test-report passes 4 of 4 on gft_xornet.t27, none vacuous; one sed removes relu from h1 and exactly one test, x00, fails; git checkout puts the line back and sha256sum prints the hash from before the edit. 27.5 s." } ] @@ -162,5 +169,12 @@ export const ruBody: Block[] = [ { "kind": "p", "text": "Генератор курса читает каталог и отказывается собирать курс, у которого число модулей или уроков в модуле расходится с каталогом или урок стоит не в модуле floor(i / 3). Если вернуть MODULES_PER_COURSE = 27, проверка курса краснеет; если вернуть файл, снова зеленеет." + }, + { + "kind": "terminal", + "src": "term/t27c-gft-xornet/session.cast", + "share": "https://t27.ai/term/t27c-gft-xornet/", + "title": "t27c on gft_xornet.t27 -- XOR needs a bend, native", + "caption": "Тот же приём на спеке урока: запись урока 21 курса 2, xor-needs-a-bend, последнего урока модуля 7. t27c test-report проходит 4 из 4 на gft_xornet.t27, ни один не пустой; одна строка sed убирает relu из h1, и падает ровно один тест, x00; git checkout возвращает строку, и sha256sum печатает хеш, который был до правки. 27,5 с." } ] diff --git a/apps/website/src/data/blog/bodies/one-regional-clock-twelve-pull-requests.ts b/apps/website/src/data/blog/bodies/one-regional-clock-twelve-pull-requests.ts index 950d1e300b..03ec3ece36 100644 --- a/apps/website/src/data/blog/bodies/one-regional-clock-twelve-pull-requests.ts +++ b/apps/website/src/data/blog/bodies/one-regional-clock-twelve-pull-requests.ts @@ -21,6 +21,13 @@ export const body: Block[] = [ ['prjxray-db#30', 'The `CLK_PERF` mux rows were incomplete. Reading a bitstream (bit2fasm), one row fired falsely in 204 cases; writing one (fasm2frames), the enable bit was not set', 'Each row now carries its enable bit and its 2-bit source code, and the HCLK rows are added for kintex7, spartan7 and zynq7. Checked in both directions on all 608 of cavearr\'s bitstreams', 'open'], ], }, + { + kind: 'terminal', + src: 'term/perf2-knockouts/session.cast', + share: 'https://t27.ai/term/perf2-knockouts/', + title: 'Three PERF2 bits, and one knockout bitstream per bit', + caption: 'The bits behind prjxray-db#30, on the AX7203\'s xc7a200tfbg484-2: the BUFR design\'s frames differ in exactly 3 bits between the old and the new build, in CLK_PERF2 and PERFCLK2, rows that database commit c030ed6 has and master does not. Each knockout clears one bit, and xc7frames2bit turns it into a .bit 3 bytes away from the working one. 26.2 s.', + }, { kind: 'p', text: 'After #170 and #171 merged, cavearr ran their 23-design regression suite over both: no status, LUT/FF count or Fmax moved, and all 36 canonical FASM outputs were byte-identical.', @@ -97,6 +104,13 @@ export const ruBody: Block[] = [ ['prjxray-db#30', 'Строки мультиплексоров `CLK_PERF` были неполными. При чтении битстрима (bit2fasm) одна строка ложно срабатывала в 204 случаях; при записи (fasm2frames) не ставился бит включения', 'Каждая строка теперь несёт свой бит включения и 2-битный код источника; добавлены строки HCLK для kintex7, spartan7 и zynq7. Проверено в обе стороны на всех 608 битстримах cavearr', 'открыт'], ], }, + { + kind: 'terminal', + src: 'term/perf2-knockouts/session.cast', + share: 'https://t27.ai/term/perf2-knockouts/', + title: 'Three PERF2 bits, and one knockout bitstream per bit', + caption: 'Биты за prjxray-db#30, на xc7a200tfbg484-2 платы AX7203: кадры дизайна с BUFR в старой и новой сборке различаются ровно в 3 битах, в CLK_PERF2 и PERFCLK2 — строках, которые есть в коммите базы c030ed6 и нет в master. Каждый нокаут сбрасывает один бит, и xc7frames2bit превращает его в .bit, отличающийся от рабочего на 3 байта. 26,2 с.', + }, { kind: 'p', text: 'После мержа #170 и #171 cavearr прогнал по ним свой регрессионный набор из 23 дизайнов: не сдвинулись ни статус, ни LUT/FF, ни Fmax, а все 36 эталонных FASM-выходов совпали байт в байт.', diff --git a/apps/website/src/data/blog/bodies/the-seed-moved-it-the-pins-moved-it-more.ts b/apps/website/src/data/blog/bodies/the-seed-moved-it-the-pins-moved-it-more.ts index 8bee1ad238..233fbc9cd5 100644 --- a/apps/website/src/data/blog/bodies/the-seed-moved-it-the-pins-moved-it-more.ts +++ b/apps/website/src/data/blog/bodies/the-seed-moved-it-the-pins-moved-it-more.ts @@ -36,6 +36,13 @@ export const body: Block[] = [ kind: 'p', text: 'fpga-jtag reads IDCODEs and never programs. It handles two cable families. For a Xilinx Platform Cable USB II (or a DLC10 clone; USB VID 0x03fd), the cable comes up as 0x0013 with no firmware. With --load the command loads the firmware with fxload, without sudo, and waits for it to re-enumerate as 0x0008. It then runs xc3sprog and prints the firmware and CPLD versions and every device on the chain, by name and revision. For FTDI cables it asks openFPGALoader which probe is attached and runs --detect. --expect XC7A100T turns the result into an exit code: 0 for a match, 1 for no chain or a different chip, 2 for no cable.', }, + { + kind: 'terminal', + src: 'term/tri-fpga-jtag/session.cast', + share: 'https://t27.ai/term/tri-fpga-jtag/', + title: 'tri fpga-jtag · which chip answered the cable', + caption: 'tri fpga-jtag, offline: --decode names 0x03636093 an XC7A200T and 0x13631093 an XC7A100T, refuses an even code as no IDCODE at all, and --self-test passes 17 of 17. 13.5 s.', + }, { kind: 'p', text: 'On our bench that exit code did its job. The only cable attached today was the FTDI probe of the AX7203, and fpga-jtag --expect XC7A100T reported loc 0: 0x03636093 XC7A200T and exited 1. That is the right chip for that board and the wrong chip for the step we were about to take. It is a step we would rather have stopped at a mismatch than at a programming error.', @@ -53,6 +60,13 @@ export const body: Block[] = [ kind: 'p', text: 'Each command carries a --self-test that runs its parsers on recorded output: 17 checks for jtag (IDCODE names, firmware lines, the stuck-high and stuck-low TDO verdicts, the openFPGALoader revision mask) and 11 for seeds (unfinished logs, multiple clocks, differing netlists). tri fpga-tools runs every board-loop self-test together; it reports 14 of 14. Re-parsing the logs behind the table above reproduces the hand-made CSVs value for value.', }, + { + kind: 'terminal', + src: 'term/tri-fpga-tools/session.cast', + share: 'https://t27.ai/term/tri-fpga-tools/', + title: 'tri fpga-tools · 14 bench tools test themselves', + caption: 'tri fpga-tools runs every bench tool\'s own self-test in one sweep: 14 of 14 pass, jtag with its 17 checks and seeds with its 11 among them. 5.8 s.', + } ] export const ruBody: Block[] = [ @@ -91,6 +105,13 @@ export const ruBody: Block[] = [ kind: 'p', text: 'fpga-jtag читает IDCODE и никогда ничего не прошивает. Она работает с двумя семействами кабелей. Xilinx Platform Cable USB II (или клон DLC10; USB VID 0x03fd) поднимается как 0x0013 без прошивки. С --load команда загружает прошивку через fxload, без sudo, и ждёт, пока кабель переподключится как 0x0008. Затем она запускает xc3sprog и печатает версии прошивки и CPLD и каждое устройство в цепочке — по имени и ревизии. Для кабелей FTDI она спрашивает у openFPGALoader, какой зонд подключён, и запускает --detect. --expect XC7A100T превращает результат в код выхода: 0 при совпадении, 1 если цепочки нет или чип другой, 2 если нет кабеля.', }, + { + kind: 'terminal', + src: 'term/tri-fpga-jtag/session.cast', + share: 'https://t27.ai/term/tri-fpga-jtag/', + title: 'tri fpga-jtag · which chip answered the cable', + caption: 'tri fpga-jtag без обращения к кабелю: --decode называет 0x03636093 кристаллом XC7A200T, а 0x13631093 — XC7A100T, чётный код отвергает как вовсе не IDCODE, и --self-test проходит 17 из 17. 13,5 с.', + }, { kind: 'p', text: 'На нашем стенде этот код выхода сделал свою работу. Единственным подключённым сегодня кабелем был FTDI-зонд AX7203, и fpga-jtag --expect XC7A100T сообщила loc 0: 0x03636093 XC7A200T и вышла с кодом 1. Для этой платы это правильный чип, а для шага, который мы собирались сделать, — неправильный. Такой шаг лучше остановить на несовпадении, чем на ошибке прошивки.', @@ -108,4 +129,11 @@ export const ruBody: Block[] = [ kind: 'p', text: 'У каждой команды есть --self-test, который гоняет её разборщики на записанном выводе: 17 проверок для jtag (имена IDCODE, строки прошивки, вердикты TDO «все единицы» и «все нули», маска ревизии openFPGALoader) и 11 для seeds (незаконченные логи, несколько клоков, разные нетлисты). tri fpga-tools запускает все самопроверки board-loop вместе и сообщает 14 из 14. Повторный разбор логов, стоящих за таблицей выше, воспроизводит составленные вручную CSV значение в значение.', }, + { + kind: 'terminal', + src: 'term/tri-fpga-tools/session.cast', + share: 'https://t27.ai/term/tri-fpga-tools/', + title: 'tri fpga-tools · 14 bench tools test themselves', + caption: 'tri fpga-tools прогоняет самопроверки всех инструментов стенда за один проход: 14 из 14 проходят, среди них jtag со своими 17 проверками и seeds со своими 11. 5,8 с.', + } ] diff --git a/apps/website/src/data/blog/bodies/three-dies-one-verdict.ts b/apps/website/src/data/blog/bodies/three-dies-one-verdict.ts index 55e43f88e3..3c3eb47202 100644 --- a/apps/website/src/data/blog/bodies/three-dies-one-verdict.ts +++ b/apps/website/src/data/blog/bodies/three-dies-one-verdict.ts @@ -25,6 +25,13 @@ export const body: Block[] = [ kind: 'p', text: 'Phil\'s board sits on a Windows PC with no FPGA toolchain. Instead of building one there, operator B\'s Linux lab borrowed only his JTAG cable, over USB/IP inside a private Tailscale network. Phil, his own Claude and the owner all agreed to this in the open first (t27#7669). The bitstream took about 70 minutes to load at a 410 ms round trip, and the whole run took about 2.5 hours. The first attempt found an empty JTAG chain: the board had USB power but not its 12 V supply.', }, + { + kind: 'terminal', + src: 'term/tri-fpga-jtag/session.cast', + share: 'https://t27.ai/term/tri-fpga-jtag/', + title: 'tri fpga-jtag · which chip answered the cable', + caption: 'tri fpga-jtag, recorded on our bench on 3 October, before this run: it decodes IDCODEs offline (0x03636093 is an XC7A200T, an even code is no IDCODE at all), and its self-test passes 17 of 17, the empty-chain check among them. 13.5 s; it loads no bitstream.', + }, { kind: 'h', text: 'A second die found a bug the first one could not' }, { kind: 'p', @@ -69,6 +76,13 @@ export const ruBody: Block[] = [ kind: 'p', text: 'Плата Фила стоит на ПК с Windows без инструментов для ПЛИС. Вместо того чтобы ставить их там, Linux-лаборатория оператора B взяла только его JTAG-кабель, по USB/IP внутри закрытой сети Tailscale. Сначала на это открыто согласились Фил, его собственный Claude и владелец (t27#7669). При задержке 410 мс загрузка битстрима шла около 70 минут, весь прогон — около 2,5 часа. Первая попытка увидела пустую JTAG-цепочку: плата была подключена по USB, но без блока питания на 12 В.', }, + { + kind: 'terminal', + src: 'term/tri-fpga-jtag/session.cast', + share: 'https://t27.ai/term/tri-fpga-jtag/', + title: 'tri fpga-jtag · which chip answered the cable', + caption: 'tri fpga-jtag, записано на нашем стенде 3 октября, до этого прогона: команда расшифровывает IDCODE без платы (0x03636093 — это XC7A200T, чётный код — вовсе не IDCODE), и её самопроверка проходит 17 из 17, среди них проверка пустой цепочки. 13,5 с; битстрим она не загружает.', + }, { kind: 'h', text: 'Второй кристалл нашёл ошибку, которую первый найти не мог' }, { kind: 'p', diff --git a/apps/website/src/data/blog/bodies/tri-test-now-runs-the-tests.ts b/apps/website/src/data/blog/bodies/tri-test-now-runs-the-tests.ts index fe24ef73c0..bea7e0bcf0 100644 --- a/apps/website/src/data/blog/bodies/tri-test-now-runs-the-tests.ts +++ b/apps/website/src/data/blog/bodies/tri-test-now-runs-the-tests.ts @@ -22,6 +22,13 @@ export const body: Block[] = [ kind: 'p', text: 'In the PR\'s demo on the t27c lab, `gft_relu.t27` passes 4 of 4. A scratch copy with line 12 changed so that a negative input is returned instead of 0 gives 3 of 4, `negz` fails, and the command exits 1.', }, + { + kind: 'terminal', + src: 'term/t27c-gft-relu/session.cast', + share: 'https://t27.ai/term/t27c-gft-relu/', + title: 't27c on gft_relu.t27 -- a bend at zero, native', + caption: 'The demo\'s bug, recorded later and without #7400: t27c test-report passes 4 of 4 on gft_relu.t27, none vacuous; one sed makes line 12 return x for a negative input, and exactly negz fails; git checkout restores the line and sha256sum prints the original hash. t27c 0.5.1; the verdict is read from the report text, since test-report still exits 0. 28.8 s.', + }, { kind: 'h', text: 'tri mutate plant' }, { kind: 'p', @@ -76,6 +83,13 @@ export const ruBody: Block[] = [ kind: 'p', text: 'В демонстрации PR на лаборатории t27c `gft_relu.t27` проходит 4 из 4. Черновая копия, где строка 12 изменена так, что отрицательный вход возвращается вместо 0, даёт 3 из 4, падает `negz`, и команда возвращает 1.', }, + { + kind: 'terminal', + src: 'term/t27c-gft-relu/session.cast', + share: 'https://t27.ai/term/t27c-gft-relu/', + title: 't27c on gft_relu.t27 -- a bend at zero, native', + caption: 'Ошибка из демонстрации, записанная позже и без #7400: t27c test-report проходит 4 из 4 на gft_relu.t27, ни один не пустой; одна строка sed заставляет строку 12 возвращать x для отрицательного входа, и падает ровно negz; git checkout возвращает строку, а sha256sum печатает исходный хеш. t27c 0.5.1; вердикт читается из текста отчёта, потому что test-report по-прежнему возвращает 0. 28,8 с.', + }, { kind: 'h', text: 'tri mutate plant' }, { kind: 'p', diff --git a/apps/website/src/data/blog/bodies/two-courses-twenty-seven-lessons-each.ts b/apps/website/src/data/blog/bodies/two-courses-twenty-seven-lessons-each.ts index 5dd8d29425..8f70c4ba5a 100644 --- a/apps/website/src/data/blog/bodies/two-courses-twenty-seven-lessons-each.ts +++ b/apps/website/src/data/blog/bodies/two-courses-twenty-seven-lessons-each.ts @@ -48,6 +48,13 @@ export const body: Block[] = [ kind: 'p', text: 'Recordings pending for lessons 13 to 21. Their nine specs (gft_smul, gft_sadd, gft_signed_mac, gft_relu, gft_exp2, gft_argmax4, gft_nll, gft_sgd_step and gft_xornet) are recorded with tri test and tri mutate plant from t27#7400, which is not merged yet. Until it is, each of those lessons opens a placeholder page that says the recording is pending and shows no run; the lesson text says so too. Lessons 22 to 27 open their recordings now.', }, + { + kind: 'terminal', + src: 'term/t27c-bitnet-mlp/session.cast', + share: 'https://t27.ai/term/t27c-bitnet-mlp/', + title: 't27c on bitnet_mlp.t27 -- A whole ternary network, native', + caption: 'Lesson 27\'s recording, the small network that closes course 2: bitnet_mlp.t27 passes 4 of 4 tests natively on the t27c lab; one sed writes pack3\'s third trit one bit low, exactly pack3_zzz fails, and git restores the spec. 32.0 s.', + }, { kind: 'p', text: 'Update, 9 October 2026: the nine recordings exist now, without #7400, which is still not merged. Each one runs t27c test-report on its spec, where every test passes and none is vacuous, then one sed line that plants the bug in the table below, then t27c test-report again, where exactly the named test fails, and finally git checkout, after which sha256sum prints the hash the spec had before the edit. They were recorded with t27c 0.5.1 and zig 0.16.0 in a Claude Code cloud container, on t27 at 72f793357, and every one of the nine failed the test this table names. t27c test-report still exits 0 when a test fails (t27#7370), so the verdict in each recording is read from the report text.', @@ -73,6 +80,13 @@ export const body: Block[] = [ ['27 A whole network', 'bitnet_mlp.t27', '4', 'the third hidden trit lands on the wrong bit', 'pack3_zzz'], ], }, + { + kind: 'terminal', + src: 'term/t27c-gft-smul/session.cast', + share: 'https://t27.ai/term/t27c-gft-smul/', + title: 't27c on gft_smul.t27 -- a sign is one bit, native', + caption: 'One of the nine, lesson 13: gft_smul.t27 passes 3 of 3, none vacuous; a sed that makes the sign always 0 fails exactly m2, which multiplies 1.0 by -1.0; git checkout restores the spec and sha256sum prints the same hash. t27c 0.5.1 and zig 0.16.0, t27 at 72f793357. 35.4 s.', + }, { kind: 'p', text: "One planted bug shows that a test can fail. It says little about the tests that were never written. So each recording ends with `tri mutate spec`, which makes every one-line change it knows in the lesson's function and counts how many of them the tests notice.", @@ -201,6 +215,13 @@ export const ruBody: Block[] = [ kind: 'p', text: 'Записи уроков с 13 по 21 ещё не готовы. Их девяти спекам (gft_smul, gft_sadd, gft_signed_mac, gft_relu, gft_exp2, gft_argmax4, gft_nll, gft_sgd_step и gft_xornet) нужны команды tri test и tri mutate plant из t27#7400, который ещё не смержен. До тех пор каждый из этих уроков открывает страницу-заглушку, где сказано, что запись ещё не готова, и нет никакого запуска; текст урока говорит то же. Уроки с 22 по 27 открывают свои записи уже сейчас.', }, + { + kind: 'terminal', + src: 'term/t27c-bitnet-mlp/session.cast', + share: 'https://t27.ai/term/t27c-bitnet-mlp/', + title: 't27c on bitnet_mlp.t27 -- A whole ternary network, native', + caption: 'Запись урока 27, маленькой сети, которой заканчивается курс 2: bitnet_mlp.t27 нативно проходит 4 из 4 тестов на лаборатории t27c; одна строка sed пишет третий трит pack3 на бит ниже, падает ровно pack3_zzz, и git возвращает спеку. 32,0 с.', + }, { kind: 'p', text: 'Обновление от 9 октября 2026: девять записей теперь есть, без #7400, который так и не смержен. Каждая запускает t27c test-report на своей спеке, где все тесты проходят и ни один не пустой, затем одну строку sed, которая сажает ошибку из таблицы ниже, затем снова t27c test-report, где падает ровно названный тест, и в конце git checkout, после которого sha256sum печатает тот же хеш, что был у спеки до правки. Записи сделаны с t27c 0.5.1 и zig 0.16.0 в облачном контейнере Claude Code, на t27 в 72f793357, и в каждой из девяти упал именно тот тест, который называет таблица. t27c test-report по-прежнему выходит с кодом 0, когда тест падает (t27#7370), поэтому вердикт в каждой записи читается из текста отчёта.', @@ -226,6 +247,13 @@ export const ruBody: Block[] = [ ['27 Целая сеть', 'bitnet_mlp.t27', '4', 'третий скрытый трит попадает не в тот бит', 'pack3_zzz'], ], }, + { + kind: 'terminal', + src: 'term/t27c-gft-smul/session.cast', + share: 'https://t27.ai/term/t27c-gft-smul/', + title: 't27c on gft_smul.t27 -- a sign is one bit, native', + caption: 'Одна из девяти, урок 13: gft_smul.t27 проходит 3 из 3, ни один не пустой; sed, после которого знак всегда 0, роняет ровно m2, где 1.0 умножается на -1.0; git checkout возвращает спеку, и sha256sum печатает тот же хеш. t27c 0.5.1 и zig 0.16.0, t27 на 72f793357. 35,4 с.', + }, { kind: 'p', text: 'Одна подложенная ошибка показывает, что тест умеет падать. О тестах, которые никто не написал, она говорит мало. Поэтому каждая запись заканчивается командой `tri mutate spec`: она делает в функции урока все однострочные изменения, которые знает, и считает, сколько из них заметили тесты.', diff --git a/docs/PR_BLOG_AUTOMATION.md b/docs/PR_BLOG_AUTOMATION.md index ab7004fa0b..767ece30fc 100644 --- a/docs/PR_BLOG_AUTOMATION.md +++ b/docs/PR_BLOG_AUTOMATION.md @@ -82,6 +82,17 @@ merge. The rule, the course directory and the post directory live in one place, reads that spec and turns `T27 work report` red when a course file changed and no post body was added or modified. This note points at the spec and does not restate it. +## Every post carries a widget + +A post body that a PR adds, modifies or renames holds at least one widget block, a +`terminal` cast or a `figure`, in its English body and in its Russian ruBody. The rule, +the widget kinds and the grandfathered older posts live in +`apps/website/specs/policy/post_widget.t27`. The workflow saves each changed body as text +from the PR head, and `scripts/pr_blog_report.py validate --posts` turns `T27 work report` +red when one has no widget. The workflow never runs that text. Pick a recording under +`apps/website/public/term//` that shows the command the post discusses, or record one +with `tri cast`. + ## Automation and safety `pr-blog-report.yml` uses `pull_request_target` and checks out only trusted diff --git a/scripts/pr_blog_report.py b/scripts/pr_blog_report.py index f51a370736..3381f418e9 100644 --- a/scripts/pr_blog_report.py +++ b/scripts/pr_blog_report.py @@ -383,6 +383,42 @@ def course_post_refusal(event: dict[str, Any], files: list[dict[str, Any]], f"under {policy['post_dir']} (rule in {COURSE_POST_REL})"), None +# Every post carries a widget; the rule lives in a t27 spec. Bodies are text the workflow saved, never run. +POST_WIDGET_REL = "apps/website/specs/policy/post_widget.t27" +BLOCK_KIND, BODY_EXPORT = re.compile(r"""\bkind["']?\s*:\s*["']([a-z]+)["']"""), re.compile(r"\bexport const (body|ruBody)\b") + + +def post_widget_policy(path: Path = COURSE_POST_SPEC.parent / "post_widget.t27") -> dict[str, Any]: + """Read the widget rule from its t27 spec. A missing or malformed spec fails closed.""" + try: + source = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + fail(f"post-widget policy {path.name} is unreadable: {exc}") + strings = dict(STR_CONST.findall(source)) + lists = {name: STR_ITEM.findall(items) for name, size, items in STR_LIST_CONST.findall(source) + if len(STR_ITEM.findall(items)) == int(size)} # t27 does not check it (gHashTag/t27#7395) + if not (strings.get("RULE") and strings.get("POST_DIR", "").endswith("/") and "WIDGET_KINDS" in lists and "GRANDFATHERED" in lists): + fail(f"post-widget policy {path.name} needs RULE, POST_DIR ending with /, WIDGET_KINDS and GRANDFATHERED at their declared length") + return {"rule": strings["RULE"], "post_dir": strings["POST_DIR"], "kinds": frozenset(lists["WIDGET_KINDS"]), "old": frozenset(lists["GRANDFATHERED"])} + + +def post_widget_refusal(files: list[dict[str, Any]], posts: Path, policy: dict[str, Any]) -> str | None: + """Why a post body this PR adds or changes has no widget; None when every one has.""" + for item in files: + name, base = item.get("filename"), str(item.get("filename")).rsplit("/", 1)[-1] + if (item.get("status") == "removed" or not isinstance(name, str) or not name.startswith(policy["post_dir"]) + or not base.endswith(".ts") or base[:-3] in policy["old"]): + continue + try: + parts = BODY_EXPORT.split((posts / base).read_text(encoding="utf-8")) + except (OSError, UnicodeError) as exc: + return f"{name}: the post body was not saved as data, so its widgets cannot be read ({exc})" + bare = [parts[i] for i in range(1, len(parts) - 1, 2) if not policy["kinds"] & set(BLOCK_KIND.findall(parts[i + 1]))] + if bare or "body" not in parts[1::2]: + return f"{policy['rule']} {name} has none in {' or '.join(bare) or 'body'} (rule in {POST_WIDGET_REL})" + return None + + def lifecycle(report: dict[str, Any]) -> str: if report["merged"]: return "Merged PR; unpublished blog draft" @@ -573,6 +609,7 @@ def main(argv: list[str] | None = None) -> int: validate.add_argument("--files", type=Path, help="changed files as JSON lines {filename,status}") validate.add_argument("--commits", type=Path, help="PR commits as JSON lines {sha,author,committer,verified}") + validate.add_argument("--posts", type=Path, help="directory of the changed post bodies, saved as text") args = parser.parse_args(argv) try: with args.event.open("rb") as stream: @@ -603,6 +640,9 @@ def main(argv: list[str] | None = None) -> int: print(notice, file=sys.stderr) if refusal: fail(refusal) + refusal = post_widget_refusal(files, args.posts, post_widget_policy()) if files and args.posts else None + if refusal: + fail(refusal) write_artifacts(report, args.output) print(f"Validated {report['repository']}#{report['number']} at {report['head_sha']}: " f"{lifecycle(report)}; artifacts written to {args.output}") diff --git a/scripts/test_pr_blog_report.py b/scripts/test_pr_blog_report.py index f4ef339644..672d957f23 100644 --- a/scripts/test_pr_blog_report.py +++ b/scripts/test_pr_blog_report.py @@ -572,5 +572,37 @@ def test_cli_accepts_a_course_pr_with_its_post(self): self.assertTrue((Path(directory) / "out" / "report.json").exists()) +CAST, TEXT = "{ kind: 'terminal', src: 'term/x/session.cast' }", "{ \"kind\": \"p\", \"text\": \"kind: words\" }" + + +class PostWidgetTests(unittest.TestCase): + def refusal(self, bodies, status="added", name="new-post.ts", posts=None): + with tempfile.TemporaryDirectory() as directory: + (Path(directory) / name).write_text("".join(f"export const {k}: Block[] = [{v}]\n" for k, v in bodies.items())) + files = [{"filename": "apps/website/src/data/blog/bodies/" + name, "status": status}] + return report.post_widget_refusal(files, Path(posts or directory), report.post_widget_policy()) + + def test_the_real_policy_spec_is_read_and_a_broken_one_fails_closed(self): + policy = report.post_widget_policy() + self.assertEqual((policy["kinds"], len(policy["old"])), (frozenset({"terminal", "figure"}), 72)) + source = (Path(report.__file__).resolve().parent.parent / report.POST_WIDGET_REL).read_text(encoding="utf-8") + with tempfile.TemporaryDirectory() as directory: + (Path(directory) / "p.t27").write_text(source.replace("[2]str", "[3]str"), encoding="utf-8") + with self.assertRaisesRegex(report.ReportError, "declared length"): + report.post_widget_policy(Path(directory) / "p.t27") + + def test_a_post_needs_a_widget_in_each_language(self): + self.assertIsNone(self.refusal({"body": CAST, "ruBody": CAST.replace("terminal", "figure")})) + self.assertIsNone(self.refusal({"body": CAST}, status="modified")) + self.assertIn("has none in body or ruBody (rule in", self.refusal({"body": TEXT, "ruBody": TEXT})) + self.assertIn("has none in ruBody (rule in", self.refusal({"ruBody": TEXT, "body": CAST}, status="renamed")) + self.assertIn("has none in body", self.refusal({})) + + def test_grandfathered_removed_and_unsaved_bodies(self): + self.assertIsNone(self.refusal({"body": TEXT}, name="nobodys-example.ts")) + self.assertIsNone(self.refusal({"body": TEXT}, status="removed")) + self.assertIn("not saved as data", self.refusal({"body": CAST}, posts="/nonexistent")) + + if __name__ == "__main__": unittest.main()