From 92a5d0ab9c78e7dfa45389988861de3543bb44db Mon Sep 17 00:00:00 2001 From: Hualiang Xie Date: Fri, 10 Jul 2026 15:44:42 +0800 Subject: [PATCH 1/2] feat: add --submodel to perf and build commands --- src/winml/modelkit/commands/build.py | 26 ++++ src/winml/modelkit/commands/perf.py | 31 +++- tests/unit/commands/test_build.py | 158 +++++++++++++++++++++ tests/unit/commands/test_perf_composite.py | 60 ++++++++ 4 files changed, 273 insertions(+), 2 deletions(-) diff --git a/src/winml/modelkit/commands/build.py b/src/winml/modelkit/commands/build.py index d84989f5f..c0515ce77 100644 --- a/src/winml/modelkit/commands/build.py +++ b/src/winml/modelkit/commands/build.py @@ -507,6 +507,15 @@ def _validate_loader_tasks_for_model( @cli_utils.trust_remote_code_option( optional_message="Trust remote code for custom model architectures (e.g., Mu2)." ) +@click.option( + "--submodel", + type=str, + default=None, + help=( + "Build a specific sub-model from a composite model " + "(e.g., 'encoder', 'decoder'). Omit to build all sub-models automatically." + ), +) @cli_utils.verbosity_options() @cli_utils.no_color_option() @click.pass_context @@ -527,6 +536,7 @@ def build( max_optim_iterations: int | None, allow_unsupported_nodes: bool, trust_remote_code: bool, + submodel: str | None, verbose: int, quiet: bool, ) -> None: @@ -881,6 +891,22 @@ def _patch_device(cfg: WinMLBuildConfig) -> None: f"Composite model detection failed unexpectedly: {e}" ) from e + # ── --submodel validation ────────────────────────────────────── + if submodel is not None: + if components is None: + raise click.BadParameter( + f"'{submodel}' was specified, but '{model}' " + f"is not a composite model (no sub-models detected).", + param_hint="--submodel", + ) + if submodel not in components: + raise click.BadParameter( + f"Unknown sub-model '{submodel}'. " + f"Available: {', '.join(components.keys())}", + param_hint="--submodel", + ) + components = {submodel: components[submodel]} + if components: if use_cache: raise click.UsageError( diff --git a/src/winml/modelkit/commands/perf.py b/src/winml/modelkit/commands/perf.py index 64443d721..084b2739e 100644 --- a/src/winml/modelkit/commands/perf.py +++ b/src/winml/modelkit/commands/perf.py @@ -80,6 +80,7 @@ class BenchmarkConfig: model_id: str task: str | None = None + submodel: str | None = None device: str = "auto" precision: str = "auto" iterations: int = 100 @@ -632,6 +633,12 @@ def run(self) -> BenchmarkResult | dict[str, BenchmarkResult]: ".npz cannot address." ) return self._run_sub_models() + if self.config.submodel is not None: + raise click.UsageError( + f"--submodel '{self.config.submodel}' was specified, but " + f"'{self.config.model_id}' is not a composite model " + f"(no sub-models detected)." + ) return self._run_single() def _run_sub_models(self) -> dict[str, BenchmarkResult]: @@ -640,10 +647,19 @@ def _run_sub_models(self) -> dict[str, BenchmarkResult]: Each sub-model is itself a single-session ``WinMLAutoModel``, so it is benchmarked through the standard single-model pipeline by spawning a child ``PerfBenchmark`` with the already-loaded sub-model. Results are - keyed by sub-model name for per-component reporting. + keyed by sub-model name for per-component reporting. ``--submodel`` + narrows this to a single named sub-model. """ + sub_models = self._sub_models + if self.config.submodel is not None: + if self.config.submodel not in sub_models: + raise click.UsageError( + f"Unknown sub-model '{self.config.submodel}'. " + f"Available: {', '.join(sub_models)}" + ) + sub_models = {self.config.submodel: sub_models[self.config.submodel]} results: dict[str, BenchmarkResult] = {} - for name, sub in self._sub_models.items(): + for name, sub in sub_models.items(): logger.info("Benchmarking sub-model '%s'", name) Console(stderr=True).print(f"\n[bold]Sub-model:[/bold] {name}") child = PerfBenchmark(self.config) @@ -1868,6 +1884,15 @@ def _run_genai_runtime(ctx: click.Context, *, console: Console, json_mode: bool) default=None, help="Explicit task (e.g., 'image-classification'). Auto-detected if not specified.", ) +@click.option( + "--submodel", + type=str, + default=None, + help=( + "Benchmark a specific sub-model of a composite model " + "(e.g., 'text_model', 'vision_model'). Omit to benchmark all sub-models." + ), +) @click.option( "--iterations", type=click.IntRange(min=1), @@ -1992,6 +2017,7 @@ def perf( max_new_tokens: int, compile_timeout: int, task: str | None, + submodel: str | None, iterations: int, warmup: int, device: str, @@ -2227,6 +2253,7 @@ def perf( config = BenchmarkConfig( model_id=hf_model, task=task, + submodel=submodel, device=device.lower(), precision=precision.lower(), iterations=iterations, diff --git a/tests/unit/commands/test_build.py b/tests/unit/commands/test_build.py index 6db4a3003..0275faa8d 100644 --- a/tests/unit/commands/test_build.py +++ b/tests/unit/commands/test_build.py @@ -2460,3 +2460,161 @@ def test_composite_carries_outer_quant_when_component_quant_none( # Outer compile carried over too, deep-copied. assert built_config.compile is not None assert built_config.compile is not outer_cfg.compile + + +class TestBuildSubmodel: + """Test --submodel narrows a composite build to a single sub-model.""" + + def test_submodel_builds_only_requested_component( + self, + runner: CliRunner, + tmp_path: Path, + ) -> None: + """--submodel builds only the named component.""" + from winml.modelkit.commands.build import build + + components = { + "decoder_prefill": "feature-extraction", + "decoder_gen": "text2text-generation", + } + output_dir = tmp_path / "out" + + fake_cfg = MagicMock() + fake_cfg.quant = None + fake_cfg.compile = None + fake_cfg.loader = MagicMock(task=None) + + with ( + patch( + "winml.modelkit.loader.resolution.resolve_composite_components", + return_value=components, + ), + patch( + "winml.modelkit.config.generate_build_config", + return_value=fake_cfg, + ), + patch( + "winml.modelkit.commands.build._run_single_build", + ) as mock_single_build, + patch( + "winml.modelkit.commands.build._validate_loader_tasks_for_model", + return_value=None, + ), + ): + result = runner.invoke( + build, + [ + "-m", + "Qwen/Qwen3-0.6B", + "-o", + str(output_dir), + "--submodel", + "decoder_prefill", + ], + obj={"debug": False}, + ) + + assert result.exit_code == 0, result.output + # Only the requested component is built. + assert mock_single_build.call_count == 1 + assert mock_single_build.call_args.kwargs["cache_key"] == "decoder_prefill" + + def test_submodel_rejects_unknown_name( + self, + runner: CliRunner, + tmp_path: Path, + ) -> None: + """--submodel with an invalid name is a clean error.""" + from winml.modelkit.commands.build import build + + components = { + "decoder_prefill": "feature-extraction", + "decoder_gen": "text2text-generation", + } + + fake_cfg = MagicMock() + fake_cfg.quant = None + fake_cfg.compile = None + fake_cfg.loader = MagicMock(task=None) + + with ( + patch( + "winml.modelkit.loader.resolution.resolve_composite_components", + return_value=components, + ), + patch( + "winml.modelkit.config.generate_build_config", + return_value=fake_cfg, + ), + patch( + "winml.modelkit.commands.build._run_single_build", + ) as mock_single_build, + patch( + "winml.modelkit.commands.build._validate_loader_tasks_for_model", + return_value=None, + ), + ): + result = runner.invoke( + build, + [ + "-m", + "Qwen/Qwen3-0.6B", + "-o", + str(tmp_path / "out"), + "--submodel", + "encoder", + ], + obj={"debug": False}, + ) + + assert result.exit_code != 0 + assert "Unknown sub-model 'encoder'" in result.output + assert "decoder_prefill" in result.output + mock_single_build.assert_not_called() + + def test_submodel_rejects_non_composite( + self, + runner: CliRunner, + tmp_path: Path, + ) -> None: + """--submodel on a non-composite model is a clean error.""" + from winml.modelkit.commands.build import build + + fake_cfg = MagicMock() + fake_cfg.quant = None + fake_cfg.compile = None + fake_cfg.loader = MagicMock(task=None) + + with ( + patch( + "winml.modelkit.loader.resolution.resolve_composite_components", + return_value=None, + ), + patch( + "winml.modelkit.config.generate_build_config", + return_value=fake_cfg, + ), + patch( + "winml.modelkit.commands.build._run_single_build", + ) as mock_single_build, + patch( + "winml.modelkit.commands.build._validate_loader_tasks_for_model", + return_value=None, + ), + ): + result = runner.invoke( + build, + [ + "-m", + "prajjwal1/bert-tiny", + "-o", + str(tmp_path / "out"), + "--submodel", + "encoder", + ], + obj={"debug": False}, + ) + + assert result.exit_code != 0 + assert "not a composite model" in result.output + mock_single_build.assert_not_called() diff --git a/tests/unit/commands/test_perf_composite.py b/tests/unit/commands/test_perf_composite.py index e5188f4c4..8d29460cf 100644 --- a/tests/unit/commands/test_perf_composite.py +++ b/tests/unit/commands/test_perf_composite.py @@ -249,6 +249,66 @@ def test_empty_sub_models_returns_empty_dict(self) -> None: assert bench._run_sub_models() == {} +class TestPerfSubmodelFilter: + """--submodel narrows composite benchmarking to a single named sub-model.""" + + def _bench_with_submodel(self, submodel: str | None) -> PerfBenchmark: + config = BenchmarkConfig( + model_id="google/siglip-base-patch16-224", + task="zero-shot-image-classification", + device="gpu", + iterations=3, + warmup=1, + submodel=submodel, + ) + bench = PerfBenchmark(config) + bench._model = _siglip_like() + return bench + + def test_submodel_benchmarks_only_requested_component(self) -> None: + bench = self._bench_with_submodel("text-encoder") + results = bench._run_sub_models() + + assert set(results) == {"text-encoder"} + + def test_submodel_runs_only_requested_session(self) -> None: + bench = self._bench_with_submodel("text-encoder") + model = bench._model + assert isinstance(model, _FakeComposite) + bench._run_sub_models() + + # Only the selected sub-session is compiled/run; the other is untouched. + assert model.sub_models["text-encoder"]._session.compiled is True + assert model.sub_models["image-encoder"]._session.compiled is False + assert len(model.sub_models["image-encoder"]._session.run_log) == 0 + + def test_submodel_unknown_name_raises(self) -> None: + import click + + bench = self._bench_with_submodel("bogus") + with pytest.raises(click.UsageError, match="Unknown sub-model 'bogus'"): + bench._run_sub_models() + + def test_submodel_on_non_composite_raises(self) -> None: + import click + + config = BenchmarkConfig( + model_id="prajjwal1/bert-tiny", + device="cpu", + submodel="encoder", + ) + bench = PerfBenchmark(config) + bench._model = _FakeSubModel( + _io_config(["input_ids"], [[1, 8]], ["int64"], ["logits"], [[1, 2]]), + task="text-classification", + ) + # run() detects composite post-load; bypass the (network) load step. + bench._load_model = lambda: None # type: ignore[method-assign] + + with pytest.raises(click.UsageError, match="not a composite model"): + bench.run() + + class TestReportCompositeResults: """report_composite_results writes a combined per-component JSON report.""" From a122cf733d3e03616fac3d92f8c900bccba45588 Mon Sep 17 00:00:00 2001 From: Hualiang Xie Date: Mon, 13 Jul 2026 11:06:18 +0800 Subject: [PATCH 2/2] fix(perf): resolve composite --submodel without requiring --task --- src/winml/modelkit/commands/perf.py | 99 ++++++++++++++---- tests/unit/commands/test_perf_cli.py | 116 +++++++++++++++++++++ tests/unit/commands/test_perf_composite.py | 60 ----------- 3 files changed, 194 insertions(+), 81 deletions(-) diff --git a/src/winml/modelkit/commands/perf.py b/src/winml/modelkit/commands/perf.py index 04de25676..9be65730b 100644 --- a/src/winml/modelkit/commands/perf.py +++ b/src/winml/modelkit/commands/perf.py @@ -634,12 +634,6 @@ def run(self) -> BenchmarkResult | dict[str, BenchmarkResult]: ".npz cannot address." ) return self._run_sub_models() - if self.config.submodel is not None: - raise click.UsageError( - f"--submodel '{self.config.submodel}' was specified, but " - f"'{self.config.model_id}' is not a composite model " - f"(no sub-models detected)." - ) return self._run_single() def _run_sub_models(self) -> dict[str, BenchmarkResult]: @@ -648,19 +642,10 @@ def _run_sub_models(self) -> dict[str, BenchmarkResult]: Each sub-model is itself a single-session ``WinMLAutoModel``, so it is benchmarked through the standard single-model pipeline by spawning a child ``PerfBenchmark`` with the already-loaded sub-model. Results are - keyed by sub-model name for per-component reporting. ``--submodel`` - narrows this to a single named sub-model. + keyed by sub-model name for per-component reporting. """ - sub_models = self._sub_models - if self.config.submodel is not None: - if self.config.submodel not in sub_models: - raise click.UsageError( - f"Unknown sub-model '{self.config.submodel}'. " - f"Available: {', '.join(sub_models)}" - ) - sub_models = {self.config.submodel: sub_models[self.config.submodel]} results: dict[str, BenchmarkResult] = {} - for name, sub in sub_models.items(): + for name, sub in self._sub_models.items(): logger.info("Benchmarking sub-model '%s'", name) Console(stderr=True).print(f"\n[bold]Sub-model:[/bold] {name}") child = PerfBenchmark(self.config) @@ -1579,10 +1564,12 @@ def report_composite_results( json.dump(combined, f, indent=2) -def generate_output_path(model_id: str, *, module_class: str | None = None) -> Path: +def generate_output_path( + model_id: str, *, module_class: str | None = None, submodel: str | None = None +) -> Path: r"""Generate default output path under the user's cache directory. - Returns ``~/.cache/winml/perf/[/]/.json`` + Returns ``~/.cache/winml/perf/[/][/]/.json`` so repeated runs accumulate under a stable per-model directory without polluting CWD (see #551). The timestamp is generated at call time using local time, format ``YYYYMMDD-HHMMSS``. @@ -1590,7 +1577,8 @@ def generate_output_path(model_id: str, *, module_class: str | None = None) -> P For ONNX inputs, the file stem is used as the slug (e.g., ``model.onnx`` -> ``model``). For HF model IDs, ``/`` and ``\`` are replaced with ``_`` (e.g., ``microsoft/resnet-50`` -> - ``microsoft_resnet-50``). + ``microsoft_resnet-50``). A ``submodel`` (composite sub-component) is nested + under its own directory so per-sub-model reports don't collide. """ p = Path(model_id) slug = p.stem if p.suffix.lower() == ".onnx" else model_id.replace("/", "_").replace("\\", "_") @@ -1598,6 +1586,8 @@ def generate_output_path(model_id: str, *, module_class: str | None = None) -> P out_dir = Path.home() / ".cache" / "winml" / "perf" / slug if module_class: out_dir = out_dir / module_class + if submodel: + out_dir = out_dir / submodel timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") return out_dir / f"{timestamp}.json" @@ -1838,6 +1828,33 @@ def _run_genai_runtime(ctx: click.Context, *, console: Console, json_mode: bool) run_genai_perf(config, console=console, json_mode=json_mode) +def _resolve_composite_components_for_perf(model: str, task: str | None) -> dict[str, str] | None: + """Detect a composite model's sub-components (name -> component task), else None. + + Mirrors the registry-driven detection in ``winml export`` / ``winml build`` + so ``--submodel`` resolves the same components (and the same seq2seq bridge + when ``--task`` is omitted). Only the "not a resolvable HF config" case + (``OSError``) is suppressed (fall through to "not composite"); intentional + loud guards (empty registry, model-task incompatibility) and any unexpected + failure are surfaced rather than masked. + """ + from ..loader.resolution import resolve_composite_components + + try: + return resolve_composite_components(model, task=task) + except click.ClickException: + raise + except ValueError as e: + raise click.UsageError(str(e)) from e + except RuntimeError: + raise + except OSError as e: + logger.debug("Composite detection unavailable (config not resolvable): %s", e) + return None + except Exception as e: + raise click.ClickException(f"Composite model detection failed unexpectedly: {e}") from e + + @click.command("perf") @cli_utils.model_option(required=False) @click.option( @@ -2154,6 +2171,46 @@ def perf( if is_onnx and model_input.local_path and not Path(model_input.local_path).exists(): raise click.UsageError(f"ONNX file not found: {hf_model}") + # ========================================================================= + # --submodel: narrow a composite model to one sub-component, benchmarked as + # a standalone single-session model. The composite is detected the same way + # `winml export` / `winml build` / `winml inspect` do (registry-driven, via + # the seq2seq bridge), so it works even when --task is omitted. The selected + # component is then loaded through the normal single-model path using its own + # component task — exactly how the composite builds that sub-model — which + # sidesteps the config-ambiguous pipeline task (e.g. t5 translation vs + # summarization) and avoids building the other sub-models just to discard. + # ========================================================================= + if submodel is not None: + if is_onnx: + raise click.BadParameter( + "--submodel is not supported for ONNX files; a .onnx file is " + "already a single model.", + param_hint="--submodel", + ) + if module_class: + raise click.BadParameter( + "--submodel cannot be combined with --module.", + param_hint="--submodel", + ) + components = _resolve_composite_components_for_perf(hf_model, task) + if components is None: + raise click.BadParameter( + f"'{submodel}' was specified, but '{hf_model}' is not a " + f"composite model (no sub-models detected).", + param_hint="--submodel", + ) + if submodel not in components: + raise click.BadParameter( + f"Unknown sub-model '{submodel}'. Available: {', '.join(components)}", + param_hint="--submodel", + ) + # Load only this component, using its own task, via the single-model path. + task = components[submodel] + console.print( + f"[dim]Composite sub-model:[/dim] {submodel} (task={task}) [dim]from[/dim] {hf_model}" + ) + # ========================================================================= # MODULE MODE: per-module build + benchmark # ========================================================================= @@ -2248,7 +2305,7 @@ def perf( # Resolve output path if output is None: - output = generate_output_path(hf_model) + output = generate_output_path(hf_model, submodel=submodel) # Refuse to clobber an existing report unless the user opted in. cli_utils.guard_output(output, overwrite) diff --git a/tests/unit/commands/test_perf_cli.py b/tests/unit/commands/test_perf_cli.py index 0d43dc6c5..4ffb6db48 100644 --- a/tests/unit/commands/test_perf_cli.py +++ b/tests/unit/commands/test_perf_cli.py @@ -1286,3 +1286,119 @@ def test_format_text_shows_console_report( # Should NOT be parseable as JSON (it's console text) with pytest.raises(json.JSONDecodeError): json.loads(result.output) + + +class TestPerfSubmodel: + """--submodel narrows a composite model to a single sub-component.""" + + _COMPONENTS: ClassVar[dict[str, str]] = { + "encoder": "feature-extraction", + "decoder": "text2text-generation", + } + + def test_submodel_loads_component_as_single_with_its_task( + self, runner: CliRunner, tmp_path: Path + ) -> None: + """--submodel rewrites the load task to the component's own task.""" + captured: dict[str, BenchmarkConfig] = {} + + def capture_config(config: BenchmarkConfig) -> MagicMock: + captured["config"] = config + mock = MagicMock() + mock.run.return_value = MagicMock() + return mock + + with ( + patch( + "winml.modelkit.commands.perf._resolve_composite_components_for_perf", + return_value=dict(self._COMPONENTS), + ), + patch("winml.modelkit.commands.perf.PerfBenchmark", side_effect=capture_config), + patch("winml.modelkit.commands.perf.display_console_report"), + patch("winml.modelkit.commands.perf.write_json_report"), + ): + result = runner.invoke( + perf, + [ + "-m", + "google-t5/t5-small", + "--submodel", + "encoder", + "-o", + str(tmp_path / "out.json"), + ], + obj={}, + ) + + assert result.exit_code == 0, result.output + # The selected component is loaded as a single model using its own task. + assert captured["config"].task == "feature-extraction" + assert captured["config"].submodel == "encoder" + + def test_submodel_rejects_unknown_name(self, runner: CliRunner, tmp_path: Path) -> None: + """--submodel with an invalid name is a clean error listing the available ones.""" + with ( + patch( + "winml.modelkit.commands.perf._resolve_composite_components_for_perf", + return_value=dict(self._COMPONENTS), + ), + patch("winml.modelkit.commands.perf.PerfBenchmark") as mock_bench, + ): + result = runner.invoke( + perf, + ["-m", "google-t5/t5-small", "--submodel", "bogus"], + obj={}, + ) + + assert result.exit_code != 0 + assert "Unknown sub-model 'bogus'" in result.output + assert "encoder" in result.output + mock_bench.assert_not_called() + + def test_submodel_rejects_non_composite(self, runner: CliRunner, tmp_path: Path) -> None: + """--submodel on a non-composite model is a clean error.""" + with ( + patch( + "winml.modelkit.commands.perf._resolve_composite_components_for_perf", + return_value=None, + ), + patch("winml.modelkit.commands.perf.PerfBenchmark") as mock_bench, + ): + result = runner.invoke( + perf, + ["-m", "prajjwal1/bert-tiny", "--submodel", "encoder"], + obj={}, + ) + + assert result.exit_code != 0 + assert "not a composite model" in result.output + mock_bench.assert_not_called() + + def test_submodel_rejects_onnx_file(self, runner: CliRunner, tmp_path: Path) -> None: + """--submodel on an ONNX file is rejected (already a single model).""" + onnx_file = tmp_path / "model.onnx" + onnx_file.write_bytes(b"fake onnx") + + result = runner.invoke( + perf, + ["-m", str(onnx_file), "--submodel", "encoder"], + obj={}, + ) + + assert result.exit_code != 0 + assert "not supported for ONNX files" in result.output + + def test_submodel_rejects_with_module(self, runner: CliRunner, tmp_path: Path) -> None: + """--submodel cannot be combined with --module.""" + with patch( + "winml.modelkit.commands.perf._resolve_composite_components_for_perf", + return_value=dict(self._COMPONENTS), + ): + result = runner.invoke( + perf, + ["-m", "google-t5/t5-small", "--submodel", "encoder", "--module", "T5Block"], + obj={}, + ) + + assert result.exit_code != 0 + assert "cannot be combined with --module" in result.output diff --git a/tests/unit/commands/test_perf_composite.py b/tests/unit/commands/test_perf_composite.py index 8d29460cf..e5188f4c4 100644 --- a/tests/unit/commands/test_perf_composite.py +++ b/tests/unit/commands/test_perf_composite.py @@ -249,66 +249,6 @@ def test_empty_sub_models_returns_empty_dict(self) -> None: assert bench._run_sub_models() == {} -class TestPerfSubmodelFilter: - """--submodel narrows composite benchmarking to a single named sub-model.""" - - def _bench_with_submodel(self, submodel: str | None) -> PerfBenchmark: - config = BenchmarkConfig( - model_id="google/siglip-base-patch16-224", - task="zero-shot-image-classification", - device="gpu", - iterations=3, - warmup=1, - submodel=submodel, - ) - bench = PerfBenchmark(config) - bench._model = _siglip_like() - return bench - - def test_submodel_benchmarks_only_requested_component(self) -> None: - bench = self._bench_with_submodel("text-encoder") - results = bench._run_sub_models() - - assert set(results) == {"text-encoder"} - - def test_submodel_runs_only_requested_session(self) -> None: - bench = self._bench_with_submodel("text-encoder") - model = bench._model - assert isinstance(model, _FakeComposite) - bench._run_sub_models() - - # Only the selected sub-session is compiled/run; the other is untouched. - assert model.sub_models["text-encoder"]._session.compiled is True - assert model.sub_models["image-encoder"]._session.compiled is False - assert len(model.sub_models["image-encoder"]._session.run_log) == 0 - - def test_submodel_unknown_name_raises(self) -> None: - import click - - bench = self._bench_with_submodel("bogus") - with pytest.raises(click.UsageError, match="Unknown sub-model 'bogus'"): - bench._run_sub_models() - - def test_submodel_on_non_composite_raises(self) -> None: - import click - - config = BenchmarkConfig( - model_id="prajjwal1/bert-tiny", - device="cpu", - submodel="encoder", - ) - bench = PerfBenchmark(config) - bench._model = _FakeSubModel( - _io_config(["input_ids"], [[1, 8]], ["int64"], ["logits"], [[1, 2]]), - task="text-classification", - ) - # run() detects composite post-load; bypass the (network) load step. - bench._load_model = lambda: None # type: ignore[method-assign] - - with pytest.raises(click.UsageError, match="not a composite model"): - bench.run() - - class TestReportCompositeResults: """report_composite_results writes a combined per-component JSON report."""