diff --git a/tensorrt_llm/_torch/_experimental/modeling_v2/README.md b/tensorrt_llm/_torch/_experimental/modeling_v2/README.md index 4e86b1664588..f9c8568348f3 100644 --- a/tensorrt_llm/_torch/_experimental/modeling_v2/README.md +++ b/tensorrt_llm/_torch/_experimental/modeling_v2/README.md @@ -71,9 +71,8 @@ LLM(model=..., modeling_v2=...) -> ModelLoader +-- modeling_v2_resolve(config, llm_args) | _router_index.py +-- architectures[0] -> routing module - models//routing.py +-- route(config, mapping, compute_capability, - | spec_config, kv_cache_quant_algo, - | model_config, llm_args) + models//routing.py +-- route(model_config, llm_args, + | compute_capability) | -> target class, or NoTarget | config.modeling_v2_target = class diff --git a/tensorrt_llm/_torch/_experimental/modeling_v2/__init__.py b/tensorrt_llm/_torch/_experimental/modeling_v2/__init__.py index c5140e7c912b..96e5209c99dc 100644 --- a/tensorrt_llm/_torch/_experimental/modeling_v2/__init__.py +++ b/tensorrt_llm/_torch/_experimental/modeling_v2/__init__.py @@ -27,7 +27,6 @@ """ from ._router_index import ( - MODELING_V2_ROUTERS, MODELING_V2_VALIDATE_ENV, ModelingV2Mode, NoTarget, @@ -36,7 +35,6 @@ ) __all__ = [ - "MODELING_V2_ROUTERS", "MODELING_V2_VALIDATE_ENV", "ModelingV2Mode", "NoTarget", diff --git a/tensorrt_llm/_torch/_experimental/modeling_v2/_router_index.py b/tensorrt_llm/_torch/_experimental/modeling_v2/_router_index.py index fc1f9a56de1b..b9cd4f09fac1 100644 --- a/tensorrt_llm/_torch/_experimental/modeling_v2/_router_index.py +++ b/tensorrt_llm/_torch/_experimental/modeling_v2/_router_index.py @@ -1,9 +1,9 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Architecture name -> routing module, and the resolver that reads the table. +"""Architecture name -> ``route``, and the resolver that consults it. The checkpoint's own ``architectures[0]`` (``GptOssForCausalLM``, -``DeepseekV3ForCausalLM``) selects a routing module, never a target: one +``DeepseekV3ForCausalLM``) selects a family's ``route``, never a target: one architecture has many deployments and a target serves exactly one of them. ``modeling_v2_resolve`` returns the target *class*, the model loader records it on ``ModelConfig.modeling_v2_target``, and ``AutoModelForCausalLM._resolve_class`` @@ -11,35 +11,38 @@ Two levels, on purpose: -* This table maps ``architectures[0]`` -> routing module. Its keys are things - that already exist in every checkpoint's config.json, so adding a new - checkpoint, GPU arch or parallel topology never touches it -- only a new - architecture family does. -* The routing module's ``route`` owns the rest of the decision as one - forward-reading tree over the facts that fix which code runs: the - checkpoint's config, the parallel mapping, the GPU's compute capability, the - speculative-decoding config and the KV-cache algorithm the engine will run - with. It also receives the ``ModelConfig`` and the LLM arguments, from which a - family derives any other engine choice its targets depend on. It returns the - target class, or raises :class:`NoTarget` with the reason in words. Reading - that one file tells you where any configuration lands. +* Each family's ``routing.py`` declares the ``architectures[0]`` values its + ``route`` serves with ``@routes(...)``, and ``models/__init__.py`` imports + every family's ``routing.py``, which is what registers them. The names are + things that already exist in every checkpoint's config.json, so adding a + new checkpoint, GPU arch or parallel topology never touches either -- only + a new architecture family does. +* That ``route`` owns the rest of the decision as one + forward-reading tree over the facts that fix which code runs. It receives + the ``ModelConfig``, the LLM arguments and the GPU's compute capability, and + reads from the first two the checkpoint's config, the parallel mapping, the + speculative-decoding config, the KV-cache algorithm the engine will run with + (:func:`engine_kv_cache_quant_algo`) and any other engine choice its targets + depend on. It returns the target class, or raises :class:`NoTarget` with the + reason in words. Reading that one file tells you where any configuration + lands. Tuning knobs -- ``max_batch_size``, ``max_num_tokens``, CUDA graph settings -- are deliberately not routing inputs. They do not change which code runs, so a target claims a configuration regardless of them; ``require`` promises that the target ran, not that a particular knob setting was measured. -Routing modules are imported lazily: resolving a GptOss config never imports -the DeepSeek tree, and a target's modeling code is imported only on the -``route`` branch that returns it. +Routing modules are imported on the first resolve, all of them, and hold no +modeling code: a target's modeling code is imported only on the ``route`` +branch that returns it, so resolving a GptOss config never imports a DeepSeek +target. """ from __future__ import annotations import enum import os -from importlib import import_module -from typing import TYPE_CHECKING, Optional, Tuple +from typing import TYPE_CHECKING, Callable, Dict, Optional, Tuple from tensorrt_llm.logger import logger @@ -47,17 +50,29 @@ from tensorrt_llm._torch.model_config import ModelConfig from tensorrt_llm.llmapi.llm_args import TorchLlmArgs -_PACKAGE = "tensorrt_llm._torch._experimental.modeling_v2" +# architectures[0] -> the ``route`` that decides where it lands, filled by ``@routes``. +_ROUTES: Dict[str, Callable[..., type]] = {} -# architectures[0] -> routing module, relative to this package. -MODELING_V2_ROUTERS = { - "GptOssForCausalLM": "models.gpt_oss.routing", - "DeepseekV3ForCausalLM": "models.deepseek_v3.routing", - # Qwen3.8-Flash-Next publishes ``Qwen4ExpForConditionalGeneration``, and the - # config loader's composite branch writes the same name back. - "Qwen4ExpForConditionalGeneration": "models.qwen4_exp.routing", - "GlmMoeDsaForCausalLM": "models.glm_moe_dsa.routing", -} + +def routes(*architectures: str): + """Declare that the decorated ``route`` decides where these ``architectures[0]`` values land.""" + + def register(route): + for arch in architectures: + assert arch not in _ROUTES, ( + f"{arch} routed by both {_ROUTES[arch].__module__} and {route.__module__}" + ) + _ROUTES[arch] = route + return route + + return register + + +def registered_routes() -> Dict[str, Callable[..., type]]: + """architectures[0] -> ``route``, for every family ``models/__init__.py`` imports.""" + from . import models # noqa: F401 + + return _ROUTES #: Turns the step contract check on. Off by default, so a served engine runs @@ -99,7 +114,7 @@ def of(cls, llm_args: "TorchLlmArgs") -> "ModelingV2Mode": class NoTarget(Exception): - """Raised by a routing module's ``route`` when no target fits. + """Raised by a family's ``route`` when no target fits. The message says, in words, which fact ruled the targets out. Under ``auto`` the resolver logs it and falls back to the built-in implementation; @@ -130,14 +145,6 @@ def tp_reaches_across_nodes(mapping) -> bool: return MNNVLAllReduce.is_mnnvl(mapping, torch.bfloat16) -def routing_module(arch: str): - """Import and return the routing module for ``arch``, or None.""" - name = MODELING_V2_ROUTERS.get(arch) - if name is None: - return None - return import_module(f"{_PACKAGE}.{name}") - - def _device_compute_capability() -> Tuple[int, int]: import torch @@ -148,7 +155,7 @@ def _device_compute_capability() -> Tuple[int, int]: return torch.cuda.get_device_capability() -def _kv_cache_quant_algo(config: "ModelConfig", llm_args: "TorchLlmArgs"): +def engine_kv_cache_quant_algo(config: "ModelConfig", llm_args: "TorchLlmArgs"): """The KV-cache algorithm the engine will run with, which routing runs too early to read. The loader resolves the target before `validate_and_set_kv_cache_quant` @@ -172,8 +179,8 @@ def modeling_v2_resolve(config: "ModelConfig", llm_args: "TorchLlmArgs") -> Opti """Decide which modeling_v2 target, if any, this deployment builds. Returns the target class, or None for the built-in implementation: - when ``llm_args.modeling_v2`` is ``off``, when no routing module claims the - architecture, or when the routing module's ``route`` raises ``NoTarget``. + when ``llm_args.modeling_v2`` is ``off``, when no ``route`` is registered + for the architecture, or when that ``route`` raises ``NoTarget``. In ``require`` every one of those but ``off`` raises ``ValueError`` instead. """ mode = ModelingV2Mode.of(llm_args) @@ -186,21 +193,11 @@ def modeling_v2_resolve(config: "ModelConfig", llm_args: "TorchLlmArgs") -> Opti arch = pretrained_config.architectures[0] try: - routing = routing_module(arch) - if routing is None: - known = ", ".join(sorted(MODELING_V2_ROUTERS)) or "(none)" - raise NoTarget( - f"no routing module for this architecture; routed architectures: {known}" - ) - return routing.route( - pretrained_config, - config.mapping, - _device_compute_capability(), - config.spec_config, - _kv_cache_quant_algo(config, llm_args), - model_config=config, - llm_args=llm_args, - ) + route = registered_routes().get(arch) + if route is None: + known = ", ".join(sorted(registered_routes())) or "(none)" + raise NoTarget(f"no route for this architecture; routed architectures: {known}") + return route(config, llm_args, _device_compute_capability()) except NoTarget as why: if mode is ModelingV2Mode.REQUIRE: raise ValueError(f"modeling_v2='require', but no target fits {arch!r}: {why}") from None diff --git a/tensorrt_llm/_torch/_experimental/modeling_v2/models/__init__.py b/tensorrt_llm/_torch/_experimental/modeling_v2/models/__init__.py index 9da2ae758216..69dd53ea5e1b 100644 --- a/tensorrt_llm/_torch/_experimental/modeling_v2/models/__init__.py +++ b/tensorrt_llm/_torch/_experimental/modeling_v2/models/__init__.py @@ -5,4 +5,13 @@ ``models//`` corresponds one-to-one with the built-in zoo's ``modeling_.py``: the same architecture, split into one self-contained codebase per deployment target instead of one class serving every -checkpoint.""" +checkpoint. + +Importing this package imports every family's ``routing`` module, and each one +registers its ``route`` through ``@routes``. A new family adds one line here. +""" + +from .deepseek_v3 import routing as deepseek_v3_routing # noqa: F401 +from .glm_moe_dsa import routing as glm_moe_dsa_routing # noqa: F401 +from .gpt_oss import routing as gpt_oss_routing # noqa: F401 +from .qwen4_exp import routing as qwen4_exp_routing # noqa: F401 diff --git a/tensorrt_llm/_torch/_experimental/modeling_v2/models/deepseek_v3/routing.py b/tensorrt_llm/_torch/_experimental/modeling_v2/models/deepseek_v3/routing.py index 6f4dd9fd6c2f..aeaf7e8aa266 100644 --- a/tensorrt_llm/_torch/_experimental/modeling_v2/models/deepseek_v3/routing.py +++ b/tensorrt_llm/_torch/_experimental/modeling_v2/models/deepseek_v3/routing.py @@ -11,12 +11,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Optional, Tuple +from typing import TYPE_CHECKING, Tuple from tensorrt_llm.llmapi.llm_args import MTPDecodingConfig from tensorrt_llm.quantization.mode import QuantAlgo -from ..._router_index import NoTarget, tp_reaches_across_nodes +from ..._router_index import NoTarget, engine_kv_cache_quant_algo, routes, tp_reaches_across_nodes # The GPUs the target is certified on: sm_100 (B200, GB200) and sm_103 (B300, # GB300). They run the same kernels. @@ -74,24 +74,19 @@ from tensorrt_llm.llmapi.llm_args import TorchLlmArgs +@routes("DeepseekV3ForCausalLM") def route( - pretrained_config: Any, - mapping: Any, - compute_capability: Tuple[int, int], - spec_config: Optional[Any], - kv_cache_quant_algo: Optional[Any], - model_config: Optional[ModelConfig] = None, - llm_args: Optional[TorchLlmArgs] = None, + model_config: ModelConfig, llm_args: TorchLlmArgs, compute_capability: Tuple[int, int] ) -> type: """Return the target class for this deployment, or raise ``NoTarget`` saying why none fits. - ``kv_cache_quant_algo`` is the KV-cache algorithm the engine will run with - (see ``_router_index._kv_cache_quant_algo``), not the checkpoint's alone. - Each target is imported where it is returned, so declining costs no import - of modeling code. No engine choice in ``model_config`` or ``llm_args`` changes - which of these targets runs, so neither is read. + of modeling code. """ + pretrained_config = model_config.pretrained_config + mapping = model_config.mapping + spec_config = model_config.spec_config + kv_cache_quant_algo = engine_kv_cache_quant_algo(model_config, llm_args) # The checkpoint is recognized by its config shape; see the gpt_oss routing # module for what shape-sniffing does and does not pin. q_lora_rank is in # the shape because it changes the attention weight layout: a checkpoint diff --git a/tensorrt_llm/_torch/_experimental/modeling_v2/models/glm_moe_dsa/routing.py b/tensorrt_llm/_torch/_experimental/modeling_v2/models/glm_moe_dsa/routing.py index 7feda0cc4e16..6ab54ed99609 100644 --- a/tensorrt_llm/_torch/_experimental/modeling_v2/models/glm_moe_dsa/routing.py +++ b/tensorrt_llm/_torch/_experimental/modeling_v2/models/glm_moe_dsa/routing.py @@ -11,30 +11,35 @@ from __future__ import annotations -from typing import Any, Optional, Tuple +from typing import TYPE_CHECKING, Tuple from tensorrt_llm.llmapi.llm_args import MTPDecodingConfig from tensorrt_llm.quantization.mode import QuantAlgo -from ..._router_index import NoTarget +from ..._router_index import NoTarget, engine_kv_cache_quant_algo, routes + +if TYPE_CHECKING: + from tensorrt_llm._torch.model_config import ModelConfig + from tensorrt_llm.llmapi.llm_args import TorchLlmArgs # (layers, hidden, routed experts, q_lora_rank). Shape-sniffing also matches # fine-tunes of the same shape; the accuracy gate pins identity. _SHAPE = (78, 6144, 256, 2048) +@routes("GlmMoeDsaForCausalLM") def route( - pretrained_config: Any, - mapping: Any, - compute_capability: Tuple[int, int], - spec_config: Optional[Any], - kv_cache_quant_algo: Optional[Any], + model_config: ModelConfig, llm_args: TorchLlmArgs, compute_capability: Tuple[int, int] ) -> type: """Return the target class for this deployment, or raise ``NoTarget`` saying why none fits. Each target is imported where it is returned, so declining costs no import of modeling code. """ + pretrained_config = model_config.pretrained_config + mapping = model_config.mapping + spec_config = model_config.spec_config + kv_cache_quant_algo = engine_kv_cache_quant_algo(model_config, llm_args) shape = ( pretrained_config.num_hidden_layers, pretrained_config.hidden_size, diff --git a/tensorrt_llm/_torch/_experimental/modeling_v2/models/gpt_oss/routing.py b/tensorrt_llm/_torch/_experimental/modeling_v2/models/gpt_oss/routing.py index 7bd51d78e37a..b739817ff437 100644 --- a/tensorrt_llm/_torch/_experimental/modeling_v2/models/gpt_oss/routing.py +++ b/tensorrt_llm/_torch/_experimental/modeling_v2/models/gpt_oss/routing.py @@ -10,9 +10,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Optional, Tuple +from typing import TYPE_CHECKING, Tuple -from ..._router_index import NoTarget, tp_reaches_across_nodes +from ..._router_index import NoTarget, engine_kv_cache_quant_algo, routes, tp_reaches_across_nodes # The GPUs the target is certified on: sm_100 (B200, GB200) and sm_103 (B300, # GB300). They run the same kernels. @@ -47,24 +47,19 @@ from tensorrt_llm.llmapi.llm_args import TorchLlmArgs +@routes("GptOssForCausalLM") def route( - pretrained_config: Any, - mapping: Any, - compute_capability: Tuple[int, int], - spec_config: Optional[Any], - kv_cache_quant_algo: Optional[Any], - model_config: Optional[ModelConfig] = None, - llm_args: Optional[TorchLlmArgs] = None, + model_config: ModelConfig, llm_args: TorchLlmArgs, compute_capability: Tuple[int, int] ) -> type: """Return the target class for this deployment, or raise ``NoTarget`` saying why none fits. - ``kv_cache_quant_algo`` is the KV-cache algorithm the engine will run with - (see ``_router_index._kv_cache_quant_algo``), not the checkpoint's alone. - Each target is imported where it is returned, so declining costs no import - of modeling code. No engine choice in ``model_config`` or ``llm_args`` changes - which of these targets runs, so neither is read. + of modeling code. """ + pretrained_config = model_config.pretrained_config + mapping = model_config.mapping + spec_config = model_config.spec_config + kv_cache_quant_algo = engine_kv_cache_quant_algo(model_config, llm_args) # The checkpoint is recognized by its config shape, the upstream idiom # (``is_mla``, ``is_nemotron_hybrid`` do the same). A fine-tune with the # same shape routes here too; the accuracy gate is what pins the identity. diff --git a/tensorrt_llm/_torch/_experimental/modeling_v2/models/qwen4_exp/routing.py b/tensorrt_llm/_torch/_experimental/modeling_v2/models/qwen4_exp/routing.py index ef2f31f01234..af8a50119783 100644 --- a/tensorrt_llm/_torch/_experimental/modeling_v2/models/qwen4_exp/routing.py +++ b/tensorrt_llm/_torch/_experimental/modeling_v2/models/qwen4_exp/routing.py @@ -5,7 +5,7 @@ `route` compares the facts that fix which code runs against the one deployment the target was written for, and raises `NoTarget` stating them otherwise (`auto` falls back to the built-in implementation; `require` raises). The checkpoint's published -architecture name is kept by `load_pretrained_config`, so `_router_index` keys on it. +architecture name is kept by `load_pretrained_config`, so `route` registers under it. All criteria are read off `text_config` / `vision_config`; the composite parent's mirrored fields are unreliable. """ @@ -19,7 +19,7 @@ from tensorrt_llm.llmapi.llm_args import MTPDecodingConfig from tensorrt_llm.quantization.mode import QuantAlgo -from ..._router_index import NoTarget +from ..._router_index import NoTarget, engine_kv_cache_quant_algo, routes if TYPE_CHECKING: from tensorrt_llm._torch.model_config import ModelConfig @@ -77,7 +77,7 @@ def engine_choices(model_config: ModelConfig, llm_args: TorchLlmArgs) -> EngineC def _check_engine( - spec_config: Optional[Any], kv_cache_quant_algo: Optional[Any], engine: Optional[EngineChoices] + spec_config: Optional[Any], kv_cache_quant_algo: Optional[Any], engine: EngineChoices ) -> None: """Decline what the target's constructor or first forward would refuse. @@ -98,8 +98,6 @@ def _check_engine( f"a {kv_cache_quant_algo} KV cache; the target stores K/V as the checkpoint's bf16, " "as FP8 or as NVFP4" ) - if engine is None: - return if engine.torch_dtype != torch.bfloat16: raise NoTarget(f"the engine runs the model in {engine.torch_dtype}; the target is bf16") if str(engine.moe_backend).upper() not in _MOE_BACKENDS: @@ -134,24 +132,22 @@ def _check_engine( ) +# Qwen3.8-Flash-Next publishes ``Qwen4ExpForConditionalGeneration``, and the +# config loader's composite branch writes the same name back. +@routes("Qwen4ExpForConditionalGeneration") def route( - pretrained_config: Any, - mapping: Any, - compute_capability: Tuple[int, int], - spec_config: Optional[Any], - kv_cache_quant_algo: Optional[Any], - model_config: Optional[ModelConfig] = None, - llm_args: Optional[TorchLlmArgs] = None, + model_config: ModelConfig, llm_args: TorchLlmArgs, compute_capability: Tuple[int, int] ) -> type: """Return the target class for this deployment, or raise ``NoTarget`` saying why none fits. - ``kv_cache_quant_algo`` is the KV-cache algorithm the engine will run with - (see ``_router_index._kv_cache_quant_algo``), not the checkpoint's alone. - The target is imported where it is returned, so declining costs no import - of modeling code. With ``model_config`` and ``llm_args`` it also declines the - engine choices (`EngineChoices`) the target's constructor would refuse. + of modeling code. It also declines the engine choices (`EngineChoices`) the + target's constructor would refuse. """ + pretrained_config = model_config.pretrained_config + mapping = model_config.mapping + spec_config = model_config.spec_config + kv_cache_quant_algo = engine_kv_cache_quant_algo(model_config, llm_args) text = getattr(pretrained_config, "text_config", None) vision = getattr(pretrained_config, "vision_config", None) # Absent means this is not the normalized Qwen4-Exp composite at all, and @@ -213,12 +209,7 @@ def route( # isort: on # fmt: on - engine = ( - engine_choices(model_config, llm_args) - if model_config is not None and llm_args is not None - else None - ) - _check_engine(spec_config, kv_cache_quant_algo, engine) + _check_engine(spec_config, kv_cache_quant_algo, engine_choices(model_config, llm_args)) return ModelingV2Qwen38FlashNextNvfp4Sm103Tp1 raise NoTarget( diff --git a/tests/unittest/_torch/modeling_v2/test_modeling_v2_claims.py b/tests/unittest/_torch/modeling_v2/test_modeling_v2_claims.py index f52cafa47e16..6b28ed9f190f 100644 --- a/tests/unittest/_torch/modeling_v2/test_modeling_v2_claims.py +++ b/tests/unittest/_torch/modeling_v2/test_modeling_v2_claims.py @@ -19,27 +19,25 @@ import ast import re +import sys from pathlib import Path import pytest import tensorrt_llm._torch._experimental.modeling_v2 as _modeling_v2 -from tensorrt_llm._torch._experimental.modeling_v2._router_index import ( - MODELING_V2_ROUTERS, - routing_module, -) +from tensorrt_llm._torch._experimental.modeling_v2._router_index import registered_routes # The package, not this file: these paths address the tree under test, and this # test lives in tests/ while that tree lives in tensorrt_llm/. pytestmark = pytest.mark.cpu_only _ROOT = Path(_modeling_v2.__file__).resolve().parent -_ARCHS = sorted(MODELING_V2_ROUTERS) +_ARCHS = sorted(registered_routes()) -def _module_path(dotted: str) -> Path: - """Resolve a package-relative dotted module name to its file.""" - return _ROOT / (dotted.replace(".", "/") + ".py") +def _routing_path(arch: str) -> Path: + """The routing.py whose ``route`` is registered for ``arch``.""" + return Path(sys.modules[registered_routes()[arch].__module__].__file__).resolve() # ``route`` hands back the class itself, imported from the target directory @@ -50,23 +48,19 @@ def _module_path(dotted: str) -> Path: def _routed_targets(arch: str): """(target directory, class name) for every target ``route`` can return.""" - source = _module_path(MODELING_V2_ROUTERS[arch]).read_text() - family_dir = _module_path(MODELING_V2_ROUTERS[arch]).parent + source = _routing_path(arch).read_text() + family_dir = _routing_path(arch).parent return [(family_dir / target, name) for target, name in _TARGET_IMPORT.findall(source)] -def test_every_routed_architecture_has_an_importable_routing_module(): - for arch in _ARCHS: - assert routing_module(arch) is not None, arch - - def test_no_routing_module_is_orphaned(): - """A routing.py the index does not name would never be consulted.""" - indexed = {_module_path(m) for m in MODELING_V2_ROUTERS.values()} + """A routing.py that models/__init__.py does not import, or whose ``route`` + carries no ``@routes``, would never be consulted.""" + registered = {_routing_path(arch) for arch in _ARCHS} on_disk = set((_ROOT / "models").glob("*/routing.py")) - assert on_disk == indexed, ( - f"routing modules on disk but not in MODELING_V2_ROUTERS: " - f"{sorted(p.relative_to(_ROOT) for p in on_disk - indexed)}" + assert on_disk == registered, ( + f"routing modules on disk with no registered route: " + f"{sorted(p.relative_to(_ROOT) for p in on_disk - registered)}" ) @@ -74,7 +68,7 @@ def test_no_routing_module_is_orphaned(): def test_no_target_directory_is_orphaned(arch): """A target directory ``route`` never imports from is dead code: nothing else can reach it.""" - family_dir = _module_path(MODELING_V2_ROUTERS[arch]).parent + family_dir = _routing_path(arch).parent on_disk = {p.parent for p in family_dir.glob("*/modeling.py")} routed = {target_dir for target_dir, _ in _routed_targets(arch)} assert on_disk == routed, (