TRF Rules for tests/models

mlinter rule proposal · revision 6, after review

Nine rules for tests/models

Every rule below enforces a convention transformers already documents in the testing guide or CONTRIBUTING — not one we are inventing. Each was measured against the repository first, so the burn-down is known before the number is claimed.

measured at d6a47b6ada 892 test files 340 test_modeling_*.py ids TRF060–TRF070, two withdrawn

Changed in revision 6

  • TRF063 rewritten, its premise conceded. "A skip must have a reason" was theatre — 2590 of 2617 already do, 93% over 30 characters. The objection that the explanations are poor is the finding: the rule now targets deferrals with nothing tracking them (100 of those, plus the 27 with no reason), and only 34 reasons in the whole suite reference an issue.
  • TRF064 dropped. Its count was my error (505 → 118, once **kwargs forwarding and num_beams / temperature / generation_config= are honored), and its last false-positive source lives in a hub file no static check can read. Shipping it off by default would have made it a docs page, not a check — transformers' own utils/rules.toml has two such rules, TRF003 and TRF037, and nothing there passes --enable-rules, so neither has ever fired. Every rule left in this set is enabled by default.
  • TRF066 moved behind a cutoff. A personal-account fixture is sometimes deliberate, so the 60 existing references are grandfathered; only new models start in the org, and a new intentional case still has trf-ignore.
  • TRF069 withdrawn. A mixin can require the reason as data instead of a comment, which is stronger than a lint rule and cheaper to satisfy — folded into the mixin sketch above.

Earlier rounds: TRF066 rewritten from "no generated weights" to fixture hosting, TRF070 added for third-party URLs, TRF066 extended to dataset repos, TRF061 rebuilt around the mixin.

The set at a glance

What each rule costs to turn on

RuleCatchesViolationsCompliantShip as
TRF060Real weights loaded without @slow26879fix now
TRF061Integration class that never cleans the device19335cutoff
TRF062Hardcoded "cuda" instead of torch_device24—fix now
TRF063Skip that defers a problem with nothing tracking it1272490fix now
TRF065Model test class without @require_torch9579fix now
TRF066Fixture checkpoint or dataset on a personal account60192cutoff
TRF067Causal-LM test not on CausalLMModelTest10684cutoff
TRF068Test file with no integration test at all18320fix now
TRF070Fixture fetched from a third-party URL202325cutoff

Discovery. mlinter/mlinter.py:61 already anticipates this: TEST_PATTERNS needs test_modeling_*.py added, and every rule gates on the file-name prefix exactly as TRF042 does. Cutoff dates below are placeholders — 2026-09-01 grandfathers everything already in the repository, so the rule only bites new models and burns down.

Upstream prerequisite

A mixin, not 370 copies of setUp

The strongest piece of review feedback, with the numbers behind it: tests/models holds 492 integration classes that hand-roll 370 lifecycle methods — 152 setUp, 174 tearDown, 31 setUpClass, 17 tearDownClass — nearly all doing the same two calls. The naming converged on its own with nobody enforcing it: test_small_model_integration_test in 44 classes, …_batch in 36, test_inference_no_head in 49, model_id as the checkpoint attribute in 37.

sketch · tests/test_integration_mixin.py
@slow
@require_torch
class IntegrationTesterMixin:
    """Lifecycle and standard checks for a model's real-weights tests."""

    model_id = None                  # the checkpoint under test
    prompts = ["Hello, world"]       # inputs the standard tests feed it
    expected_logits = None           # tensor or Expectations(...); None skips the logits test
    expected_texts = None            # None skips the generation test

    # what this model can be held to
    test_logits = True
    test_greedy_generation = True
    test_batched_generation = False   # opt in where padding matters
    has_public_checkpoint = True      # False records "checkpoint not released yet"

    def setUp(self):
        cleanup(torch_device, gc_collect=False)

    def tearDown(self):
        cleanup(torch_device, gc_collect=False)

Attribute names are a sketch for discussion, not a proposal to merge as written. What matters for the rules:

  • TRF061 does not block on it. The rule asks for cleanup at both ends and resolves base classes, so a hand-written pair passes today and inheriting the mixin passes the day it lands — no rule change, no re-run of the burn-down.
  • TRF068 gets a better record. has_public_checkpoint = False is a structured way to say "no integration test yet, checkpoint is not out", which a # trf-ignore comment only approximates. The comment is the mechanism until the mixin exists; after that, the flag is.
  • It absorbs the former TRF064. The mixin's own generation test passes do_sample=False, so every model that adopts it gets deterministic generation by construction — no rule, no 118-call burn-down, and nothing for a reviewer to remember.
  • It replaces TRF069 outright. Rather than a lint rule demanding a comment beside test_resize_embeddings = False, the flag can carry its own reason — unsupported = {"resize_embeddings": "Acme ties encoder and decoder embeddings"}, or a string value in place of False — and the mixin asserts it is non-empty. Enforced by construction, no rule number, and the reason ends up somewhere a test report can print it. That is why TRF069 is withdrawn from this set.

Conversion cost, and the one decision it needs

The mixin makes TRF061's burn-down larger in lines touched — 290 class declarations rewritten rather than two methods added to each — but entirely mechanical: a base-class insertion plus a model_id attribute the class usually already has under that name, so a script can do the bulk and leave the odd setUpClass case for review.

Which leaves one call to make: does the conversion land as its own PR ahead of the rules, or does it ride along with the cutoff — new models comply on arrival, existing ones convert whenever their file is next touched? The first gives TRF061 clean numbers immediately and one large diff to review; the second costs nothing up front and leaves the 193 leaking classes leaking a while longer. The rule text is identical either way.

Tier 1 — five rules

Settled convention, small burn-down

Compliance is already 96–99% on each of these. They can land enabled, with the existing violations fixed in the same PR.

TRF060fix now

A test that loads real pretrained weights must be marked @slow

26 in test_modeling_* 879 already correct 122 more in tokenizer/processor tests, for a later widening

The testing guide states it outright: mark a test @slow if it downloads weights. This is the checkable proxy for “heavy” — not a GPU requirement, which 60 fast tests carry deliberately.

rules.toml
[rules.TRF060]
description = "A test that loads real pretrained weights must be marked @slow."
default_enabled = true
allowlist_models = ["auto"]

[rules.TRF060.explanation]
what_it_does = "In tests/models/*/test_modeling_*.py, flags a test method that calls from_pretrained() on a hub id -- an `org/name` string literal, or a class-level constant holding one -- unless the method or its class carries @slow. Ids that name random weights (anything matching `tiny-`, `-tiny`, `tiny-random` or `random-`) are not real weights and are left to the fast suite. `auto` is allowlisted: test_modeling_auto.py resolves remote-code repositories by design, and those fixtures are the subject under test rather than model weights."
why_bad = "The pull-request CI runs every test that is not marked @slow, on every pull request in the repository. A test that pulls real weights charges its download and its inference to contributors touching unrelated files, and turns a hub hiccup into a red CI on their branch. @slow is what moves it to the nightly run that is provisioned for it."
diff = '''
 class AcmeIntegrationTest(unittest.TestCase):
+    @slow
     def test_inference_logits(self):
         model = AcmeForCausalLM.from_pretrained("acme-ai/acme-7b").to(torch_device)
'''
TRF062fix now

Tests must take the device from torch_device, never a hardcoded "cuda" string

9 device literals 15 direct torch.cuda.* calls zero burn-down risk

The slow suite runs on XPU, NPU and ROCm too. Comparisons like torch_device == "cuda" and Expectations SM keys stay untouched — naming the hardware is the point in both.

rules.toml
[rules.TRF062]
description = "Tests must take the device from torch_device, never a hardcoded \"cuda\" string."
default_enabled = true
allowlist_models = []

[rules.TRF062.explanation]
what_it_does = "Flags a hardcoded accelerator string in a test file: `.to(\"cuda\")`, a `device=` or `device_map=` keyword whose value is a `cuda` literal, and direct `torch.cuda.*` calls that have a device-agnostic helper in testing_utils (`empty_cache` -> `backend_empty_cache`, `device_count` -> `backend_device_count`, `manual_seed_all` -> `backend_manual_seed_all`). Comparisons such as `torch_device == \"cuda\"` and Expectations SM keys such as `(\"cuda\", (8, 6))` are not flagged: naming the hardware is the point in both."
why_bad = "The slow suite runs on XPU, NPU and ROCm as well as CUDA. A hardcoded device string either errors on those runners or, worse, quietly leaves the tensor somewhere the model is not, so the test measures nothing and reports green. torch_device and the backend_* helpers are already resolved for the runner in hand."
diff = '''
-inputs = tokenizer("Hello", return_tensors="pt").to("cuda")
-torch.cuda.empty_cache()
+inputs = tokenizer("Hello", return_tensors="pt").to(torch_device)
+backend_empty_cache(torch_device)
'''
TRF063fix nowrewritten

A skip that defers a problem must link the issue tracking it

100 untracked deferrals 27 with no reason at all 34 of 2590 reasons carry a link

The original rule — "a skip must have a reason" — was not worth a number: 2590 of 2617 skips already have one, 93% over 30 characters. The complaint that the explanations are poor is the real finding, and it is checkable: "This is broken now, no idea why", "TODO Arthur I have to skip for now because I don't understand it", "Some undefined behavior encountered with test versions of this model. Skip for now." (23 uses). A deferral without a tracking link never comes back.

rules.toml
[rules.TRF063]
description = "A skip that defers a problem must link the issue tracking it."
default_enabled = true
allowlist_models = []

[rules.TRF063.explanation]
what_it_does = "In any tests/models test file, flags `@unittest.skip` whose reason is missing, is a bare verdict (`TODO`, `Not implemented`, `Very flaky`), or defers the problem without naming where it is tracked. Deferral is read from the wording -- `for now`, `temporarily`, `to investigate`, `needs a fix`, `undefined behaviour`, `no idea why` -- and a reason carrying an issue reference (`#12345`) or a URL satisfies the rule however it is phrased. A reason that states a permanent architectural fact (`Acme has no inputs_embeds`, `this module does not support standalone training`) is not a deferral and needs no link."
why_bad = "Requiring any reason at all would be theatre: 2590 of 2617 skips already carry one. What is missing is the 4% that say a test was turned off and nothing else -- 23 copies of `Skip for now`, `broken now, no idea why`, `keep hitting edge cases`. Those are the ones that never get switched back on, because there is nothing to close and nobody owns them; only 34 reasons in the whole suite reference an issue. A link turns a silent hole in coverage into tracked work."
diff = '''
-@unittest.skip(reason="Some undefined behavior with test versions of this model. Skip for now.")
+@unittest.skip(reason="Tiny checkpoint emits NaN logits past 2 layers, see #43210")
 def test_batched_generation(self):
'''
TRF065fix now

A collected model test class must be decorated with @require_torch

9 undecorated classes 579 already correct

Without it the class still gets collected in a torch-free environment, and every test errors on a NameError instead of skipping. Mostly encoder-only classes: T5EncoderOnlyModelTest, UdopEncoderOnlyModelTest.

rules.toml
[rules.TRF065]
description = "A collected model test class must be decorated with @require_torch."
default_enabled = true
allowlist_models = []

[rules.TRF065.explanation]
what_it_does = "In test_modeling_*.py, flags a collected test class that mixes in the shared suite (ModelTesterMixin, or one of CausalLMModelTest / VLMModelTest / ALMModelTest) and carries no `require_torch` decorator. The narrower gates count: require_torch_gpu, require_torch_accelerator, require_torch_multi_gpu and friends all imply torch."
why_bad = "Model test files import their torch symbols under `if is_torch_available():`, so without the decorator the class is still collected in an environment without torch and every one of its tests errors on a NameError instead of skipping. The decorator is what turns that into an honest skip."
diff = '''
+@require_torch
 class AcmeModelTest(ModelTesterMixin, unittest.TestCase):
     all_model_classes = (AcmeModel, AcmeForCausalLM)
'''
TRF068fix now

A model test file must contain at least one @slow integration test

18 files with none 320 have one escape hatch added

CONTRIBUTING asks contributors to prioritize exactly this test. A model whose checkpoint is not public yet writes # trf-ignore: TRF068 naming what it is waiting on, so the owed test is greppable when the release lands; permanent exemptions (backbone files) go in allowlist_models.

rules.toml
[rules.TRF068]
description = "A model test file must contain at least one @slow integration test."
default_enabled = true
allowlist_models = []

[rules.TRF068.explanation]
what_it_does = "Flags a tests/models/*/test_modeling_*.py file in which no test method or test class carries @slow. Reported once per file, on the first collected test class. A model whose checkpoint is not published yet records that on the class -- `has_public_checkpoint = False` once the integration mixin exists, or `# trf-ignore: TRF068` above the class until it does, naming the checkpoint it is waiting on -- the directive is the record that the integration test is still owed, and grep finds every one of them when a release lands. Files covering a backbone or sub-component that the parent model's file already exercises end-to-end belong in allowlist_models instead, since that exemption is permanent."
why_bad = "The mixin suite runs tiny random configs and checks shapes, gradients and save/load round-trips. It passes on a model whose attention scaling, rope layout or mask is wrong, because wrong numbers have the right shape. A file with no integration test has never once compared this architecture against a checkpoint, which is the only check that would have caught that -- and CONTRIBUTING asks contributors to prioritize exactly that test."
diff = '''
+@slow
+@require_torch
+class AcmeIntegrationTest(unittest.TestCase):
+    def test_inference_logits(self):
+        model = AcmeForCausalLM.from_pretrained("acme-ai/acme-7b").to(torch_device)
+        expected = torch.tensor([[-1.2345, 0.6789, 2.3456]])
+        torch.testing.assert_close(out.logits[0, :1, :3], expected, atol=1e-4, rtol=1e-4)
'''

Tier 2 — four rules

Documented or agreed, widely unfollowed — land behind a cutoff date

Each of these is written down, or agreed in review, and followed by a minority of models. The cutoff mechanism is what makes them shippable: new models comply, existing ones burn down, and an intentional exception still has trf-ignore.

TRF061cutoff 2026-09-01rewritten

An integration test class must clean the accelerator up in setUp and tearDown

193 classes with neither 62 with one of the two 35 fully correct

The leak lands on someone else's PR: the slow suite runs model after model in one process, so weights left resident OOM the next model's tests while the class that leaked reports green. An inherited lifecycle counts — that is the point of the mixin above, and why the rule needs no revision when it lands.

rules.toml
[rules.TRF061]
description = "An integration test class must clean the accelerator up in setUp and tearDown."
default_enabled = true
allowlist_models = []
cutoff_date = "2026-09-01"

[rules.TRF061.explanation]
what_it_does = "In test_modeling_*.py, for a collected test class whose @slow tests load pretrained weights, checks that the class cleans the device up at both ends of every test. Inheriting a base that does it -- the proposed IntegrationTesterMixin -- satisfies the rule outright; so does calling `cleanup(torch_device, ...)`, or backend_empty_cache plus gc.collect, in the class's own setUp and tearDown. Bases are resolved in the same file and one hop into another model's test class, as TRF042 does, so a class that inherits its lifecycle is never reported for not repeating it. Reported once per class, on the class definition."
why_bad = "The slow suite runs model after model in one process on one accelerator. A class that returns without freeing its weights leaves them resident, and the next model in the run hits an out-of-memory error -- so the failure surfaces in someone else's file, and the model that actually leaked looks green. Cleanup at both ends is what makes a class survive a predecessor that skipped its own. The rule accepts an inherited lifecycle deliberately: 492 integration classes currently hand-roll 370 setUp/tearDown methods to do the same two calls, and one shared mixin is both less to write and the only way the next change to what cleanup means -- another cache to drop, another reset to call -- lands in one place instead of 370."
diff = '''
-class AcmeIntegrationTest(unittest.TestCase):
+class AcmeIntegrationTest(IntegrationTesterMixin, unittest.TestCase):
+    model_id = "acme-ai/acme-7b"
+
     @slow
     def test_inference_logits(self):
'''
TRF066cutoff 2026-09-01grandfathered

A test fixture — generated checkpoint or fixture dataset — must live under hf-internal-testing

17 checkpoint refs on personal accounts 43 dataset refs, 16 repos 192 already team-owned

Hosting a fixture on a personal account is sometimes a deliberate call, so the rule ships behind a cutoff: the 60 existing references are grandfathered and only new models have to start in the org, with # trf-ignore: TRF066 for a new case that is genuinely intentional. Published corpora — facebook/xnli, mozilla-foundation/common_voice_11_0 — are data the test consumes, not fixtures, and are never flagged.

rules.toml
[rules.TRF066]
description = "A test fixture -- generated checkpoint or fixture dataset -- should live under hf-internal-testing."
default_enabled = true
allowlist_models = []
cutoff_date = "2026-09-01"

[rules.TRF066.explanation]
what_it_does = "Flags two kinds of fixture reference in a tests/models test file whose owner is not hf-internal-testing. First, a from_pretrained() hub id that names generated weights (`tiny-random-*`, `*-random-tiny`, `random-*`, `dummy-*`); a published checkpoint that merely has `tiny` or `small` in its name -- facebook/convnext-tiny-224, openai/whisper-tiny, asapp/sew-d-tiny-100k -- is real released weights and is never flagged. Second, a load_dataset(), hf_hub_download() or snapshot_download() repo id whose name reads as a test fixture: `dummy`, `fixtures`, `example`, `sample`, `test`, `testing`, `demo` or `ci` as a whole token. A published corpus keeps its own name and is left alone -- facebook/xnli, mozilla-foundation/common_voice_11_0 and google/code_x_glue_ct_code_to_text are data the test consumes, not fixtures the CI owns. Whether the test is @slow does not matter: a fixture the CI depends on has to be hosted where the team can restore it either way."
why_bad = "Generated checkpoints and hand-made fixture assets are both legitimate -- the smallest published checkpoint often does not fit the runner, and a deterministic logits check catches a numerical regression even when the output is garbage. What matters is who can delete the fixture. 17 checkpoint references and 43 dataset references point at personal accounts: patrickvonplaten/longformer-random-tiny, sgugger/funnel-random-tiny, nielsr/example-pdf, raushan-testing-hf/videos-test, anton-l/superb_dummy. Any of them takes a model's whole suite down the day that person tidies their profile, and nobody else can restore it. hf-internal-testing is the org the team owns, utils/create_dummy_models.py already uploads checkpoints there, and fixture assets belong beside them in an hf-internal-testing/fixtures_* dataset repo."
diff = '''
 @slow
 def test_inference_logits(self):
-    model = AcmeForCausalLM.from_pretrained("jdoe/tiny-random-acme")
+    model = AcmeForCausalLM.from_pretrained("hf-internal-testing/tiny-random-acme")
-    path = hf_hub_download(repo_id="jdoe/acme-test-images", filename="cats.jpg", repo_type="dataset")
+    path = hf_hub_download(
+        repo_id="hf-internal-testing/fixtures_acme", filename="cats.jpg", repo_type="dataset"
+    )
'''
TRF067cutoff 2026-09-01

A causal-LM model test should build on CausalLMModelTest, not hand-rolled mixins

106 hand-assembled 84 on the shared base cross-file: reads modeling_*.py

This is TRF042's shape applied to modeling tests. A hand-assembled class silently ships without training and tensor-parallel coverage, and stops inheriting whatever the shared base gains next.

rules.toml
[rules.TRF067]
description = "A causal-LM model test should build on CausalLMModelTest, not hand-rolled mixins."
default_enabled = true
allowlist_models = []
cutoff_date = "2026-09-01"

[rules.TRF067.explanation]
what_it_does = "When the model directory defines a <Model>ForCausalLM, flags a collected test class in test_modeling_*.py that mixes ModelTesterMixin in directly instead of inheriting CausalLMModelTest. Reads the companion modeling_*.py to decide whether the model is a causal LM; a test class whose bases cannot be resolved is inconclusive and is skipped. Encoder-decoder and standalone-decoder test classes in a file that also has a causal-LM class are not the target -- the rule reports the class that stands in for the model's main suite."
why_bad = "CausalLMModelTest is the recommended base in the testing guide, and it carries TrainingTesterMixin and TensorParallelTesterMixin on top of the three shared mixins. A hand-assembled class silently ships without training and tensor-parallel coverage, and it stops inheriting whatever the shared base gains next -- which is how the same model ends up with a hundred fewer tests than the model merged beside it."
diff = '''
-class AcmeModelTest(ModelTesterMixin, GenerationTesterMixin, PipelineTesterMixin, unittest.TestCase):
-    all_model_classes = (AcmeModel, AcmeForCausalLM)
-
-    def setUp(self):
-        self.model_tester = AcmeModelTester(self)
+class AcmeModelTester(CausalLMModelTester):
+    if is_torch_available():
+        base_model_class = AcmeModel
+
+
+class AcmeModelTest(CausalLMModelTest, unittest.TestCase):
+    model_tester_class = AcmeModelTester
'''
TRF070cutoff 2026-09-01new

A remote test fixture must be fetched from the Hub, not a third-party URL

202 third-party URLs 90 test files affected 325 already hub-hosted

16 hosts nobody here controls, including thumbs.dreamstime.com, cdn.britannica.com, a personal GitHub raw file — and picsum.photos, used 12 times, which returns a different random image on every request.

rules.toml
[rules.TRF070]
description = "A remote test fixture must be fetched from the Hub, not a third-party URL."
default_enabled = true
allowlist_models = []
cutoff_date = "2026-09-01"

[rules.TRF070.explanation]
what_it_does = "Flags an `http(s)://` string literal in a tests/models test file whose host is not huggingface.co or hf.co -- the images, videos, audio clips and PDFs that tests download as inputs. Hub repo ids passed to from_pretrained(), load_dataset() or hf_hub_download() are not URLs and are unaffected, so the fix is always to upload the asset to a dataset repo (`hf-internal-testing/fixtures_*`, as test_modeling_pix2struct.py already does with fixtures_docvqa) and reference it by id."
why_bad = "202 fixture URLs across 90 test files point at hosts nobody here controls: images.cocodataset.org, cdn.britannica.com, thumbs.dreamstime.com, a personal file in a GitHub repo, and picsum.photos, which returns a different random image on every request. Each one is a way for a model's integration test to break, or to quietly start testing a different input, because of a change made by a stranger -- and the expected logits in the test were recorded against the old asset. A fixture on the Hub is as permanent and as reviewable as the test that uses it."
diff = '''
-url = "http://images.cocodataset.org/val2017/000000039769.jpg"
-image = Image.open(requests.get(url, stream=True).raw)
+path = hf_hub_download(
+    repo_id="hf-internal-testing/fixtures_acme", filename="cats.jpg", repo_type="dataset"
+)
+image = Image.open(path)
'''

Due diligence

Considered and rejected

Known gap

What the extended TRF066 still misses

TRF066 keys off fixture naming, so a real checkpoint or asset pulled from a personal account under a plain name — nielsr/audio-spectogram-transformer-checkpoint in test_modeling_audio_spectrogram_transformer.py:227 — reads exactly like a published repo to a static check. Nothing in the id separates a personal account from an org, so catching those needs a hub lookup (out of scope: mlinter is static analysis only) or a maintained org allowlist. Noted so nobody assumes the rule covers it.