From 281b8683f42342e6fdd57764b76ba1f39ded2356 Mon Sep 17 00:00:00 2001 From: jinman Date: Wed, 4 Nov 2026 16:22:52 +0900 Subject: [PATCH 1/3] Add opt-in model YAML family inheritance with identity proofs Preserve existing alias and update semantics across 334 configs and 595 variants. Add ordered resolution and CPU preprocessing regression tests without changing shipped model definitions. Co-Authored-By: Claude Haiku 5.5 --- .claude/skills/mblt-vision/SKILL.md | 3 + AGENTS.md | 4 + mblt_vision/README.md | 34 ++++++ mblt_vision/_config.py | 120 ++++++++++++++++++++ mblt_vision/wrapper.py | 62 +---------- pyproject.toml | 1 + tests/test_config_inheritance.py | 164 ++++++++++++++++++++++++++++ 7 files changed, 329 insertions(+), 59 deletions(-) create mode 100644 mblt_vision/_config.py create mode 100644 tests/test_config_inheritance.py diff --git a/.claude/skills/mblt-vision/SKILL.md b/.claude/skills/mblt-vision/SKILL.md index 1b9b9c5..b695725 100644 --- a/.claude/skills/mblt-vision/SKILL.md +++ b/.claude/skills/mblt-vision/SKILL.md @@ -28,6 +28,9 @@ description: >- Use file_cfg.filename for MXQ and derive the same-stem ONNX artifact unless onnx_filename is required. - Every post_cfg declares dataset; resolve output taxonomy from the dataset/task pair. +- For opt-in YAML family inheritance, follow `mblt_vision/README.md`'s configuration + inheritance rules. Keep legacy `update` shallow and run `tests/test_config_inheritance.py` + to prove every existing variant's values and processing order remain identical. - Reuse wrapper.download_hub_artifact for any model that needs more than one Hub artifact rather than duplicating Hub-resolution logic. - Registering a new dataset requires readiness (`_*_ready` + `dataset_ready` diff --git a/AGENTS.md b/AGENTS.md index 5fd0aa6..06a243b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,10 @@ The current ownership boundary is deliberate: floors, and rounding differs by a pixel at 224/0.9 and 224/0.95. Several names (ConvNext_Base) resolve in timm yet carry torchvision's transform here, so read the expected value from that model's source.yaml provenance rather than from the name. +- To share processing defaults without changing legacy models, opt new family YAMLs into + `extends: bases/family.yaml#DEFAULT`. See the configuration-inheritance section of + `mblt_vision/README.md` for ordering and merge rules. Keep legacy `update` shallow and + prove every existing variant order-identical with `tests/test_config_inheritance.py`. - Require post_cfg.dataset in every model YAML and resolve output class counts using the dataset/task pair. Do not assume one output taxonomy for every model in a task. - Preserve automatic .mxq/.onnx framework detection and the fail-fast error when a local diff --git a/mblt_vision/README.md b/mblt_vision/README.md index 30bbddc..0187f74 100644 --- a/mblt_vision/README.md +++ b/mblt_vision/README.md @@ -60,6 +60,40 @@ and `list_models()` are the preferred discovery and loading APIs for new code. Top-level model imports remain available for compatibility. `obb` is the sole oriented-bounding-box task name. +## Model configuration inheritance + +Model variants may opt into local family YAMLs with `extends`. This keeps the +artifact, preprocessing and dataset connection together without duplicating a +family's processing defaults. Existing `update: VARIANT` and alias behavior is +unchanged; no shipped model YAML has been migrated by this loader refactor. + +```yaml +DEFAULT: + extends: bases/classifier.yaml#DEFAULT + file_cfg: + repo_id: mobilint/YourPublishedModel + filename: model.mxq + revision: approved-hf-commit + pre_cfg: + Resize: + size: 256 +``` + +The example is a schema illustration, not a downloadable model registration. +The base path is relative to the declaring YAML; omit `#DEFAULT` to select the +same default variant. Family bases belong in `models/bases/`, outside top-level +model discovery. `extends` recursively merges mappings and replaces lists and +scalars. Inherited mapping order is preserved: existing preprocessing operations +stay in place and new operations append. Use a standalone pipeline when operation +order must change. In contrast, `update` retains its historical one-section merge, +so replacing `pre_cfg.Resize` replaces that whole operation's parameters. A variant +cannot combine `update` and `extends`; an `update` may reference another variant +that uses `extends`. References are local only and cycles fail before inference. + +`tests/test_config_inheritance.py` compares every existing YAML variant with the +legacy resolver, including ordered processing mappings and derived ONNX filenames, +and checks exact CPU preprocessing equality for representative model families. + ## Pre-Trained Vision Models This section lists the publicly pre-trained models supported by the vision framework. diff --git a/mblt_vision/_config.py b/mblt_vision/_config.py new file mode 100644 index 0000000..ab2fee2 --- /dev/null +++ b/mblt_vision/_config.py @@ -0,0 +1,120 @@ +"""Local YAML model inheritance, independent of inference backends.""" + +from __future__ import annotations + +import copy +from pathlib import Path +from typing import Any + +import yaml + + +def _merge( + base: dict[str, Any], child: dict[str, Any], *, recursive: bool +) -> dict[str, Any]: + """Merge independent mappings while retaining preprocessing operation order.""" + + result = copy.deepcopy(base) + for key, value in child.items(): + if isinstance(result.get(key), dict) and isinstance(value, dict): + if recursive: + result[key] = _merge(result[key], value, recursive=True) + else: + # Historical `update` merges exactly one section level. + result[key].update(copy.deepcopy(value)) + else: + result[key] = copy.deepcopy(value) + return result + + +def load_model_variant( + config_path: str | Path, variant: str = "DEFAULT" +) -> dict[str, Any]: + """Resolve a local YAML variant, its aliases, and optional family inheritance. + + ``update`` retains the legacy same-file, one-section merge semantics. + ``extends: bases/family.yaml#DEFAULT`` opts into recursive mapping inheritance; + the fragment defaults to DEFAULT and paths are relative to the declaring file. + Lists and scalars replace their base values. Mapping key order is retained, so + inherited preprocessing operations retain their order. New operations append; + replace a complete operation sequence with a standalone config when reordering. + Only local files are read; each result is independently deep-copied. + """ + + documents: dict[Path, dict[str, Any]] = {} + resolving: set[tuple[Path, str]] = set() + + def document(path: Path) -> dict[str, Any]: + if path not in documents: + with path.open(encoding="utf-8") as stream: + parsed = yaml.safe_load(stream) + if not isinstance(parsed, dict): + raise TypeError( + f"Model configuration '{path}' should define a dictionary." + ) + documents[path] = parsed + return documents[path] + + def resolve(path: Path, key: str) -> dict[str, Any]: + identity = (path, key) + if identity in resolving: + raise ValueError( + f"Circular model configuration reference detected for '{path}#{key}'." + ) + resolving.add(identity) + try: + full = document(path) + part = full.get(key) + if part is None: + raise ValueError(f"Model type '{key}' not found in configuration.") + if isinstance(part, str): + if part not in full: + raise ValueError( + f"Model alias '{part}' not found in configuration." + ) + return resolve(path, part) + if not isinstance(part, dict): + raise TypeError( + f"Resolved model configuration for '{key}' is not a dictionary." + ) + child = copy.deepcopy(part) + base_key = child.pop("update", None) + has_extends = "extends" in child + reference = child.pop("extends", None) + if base_key is not None and has_extends: + raise ValueError( + "A model variant cannot combine 'update' and 'extends'." + ) + if base_key is not None: + if not isinstance(base_key, str) or base_key not in full: + raise ValueError( + f"Base configuration '{base_key}' not found for update." + ) + return _merge(resolve(path, base_key), child, recursive=False) + if has_extends: + if not isinstance(reference, str) or not reference: + raise ValueError( + "Model 'extends' must be a nonempty local YAML reference." + ) + filename, separator, base_variant = reference.partition("#") + if ( + not filename + or "://" in filename + or (separator and not base_variant) + ): + raise ValueError( + f"Invalid local model inheritance reference: {reference!r}." + ) + if "#" in base_variant: + raise ValueError( + f"Invalid local model inheritance reference: {reference!r}." + ) + base_path = (path.parent / filename).resolve() + return _merge( + resolve(base_path, base_variant or "DEFAULT"), child, recursive=True + ) + return child + finally: + resolving.remove(identity) + + return resolve(Path(config_path).resolve(), variant) diff --git a/mblt_vision/wrapper.py b/mblt_vision/wrapper.py index a15296f..d451284 100644 --- a/mblt_vision/wrapper.py +++ b/mblt_vision/wrapper.py @@ -16,11 +16,11 @@ import numpy as np import torch -import yaml from huggingface_hub import hf_hub_download from huggingface_hub.errors import EntryNotFoundError - from mblt_npu import MobilintNPUBackend, ONNXBackend, normalize_target_device + +from ._config import load_model_variant from ._model_paths import resolve_framework as _resolve_framework from ._model_paths import split_model_paths as _split_model_paths from ._model_paths import ( @@ -365,63 +365,7 @@ def resolve_model_config( if not config_path.is_file(): config_path = MODEL_CONFIG_DIR / _model_name_aliasing(model_cls) - with config_path.open(encoding="utf-8") as config_file: - full_config = yaml.safe_load(config_file) - if not isinstance(full_config, dict): - raise TypeError( - f"Model configuration '{config_path}' should define a dictionary." - ) - - resolving: set[str] = set() - - def _resolve_variant(variant: str) -> dict[str, Any]: - if variant in resolving: - raise ValueError( - f"Circular model configuration reference detected for '{variant}'." - ) - resolving.add(variant) - try: - model_config_part = full_config.get(variant) - if model_config_part is None: - raise ValueError(f"Model type '{variant}' not found in configuration.") - if isinstance(model_config_part, str): - if model_config_part not in full_config: - raise ValueError( - f"Model alias '{model_config_part}' not found in configuration." - ) - return _resolve_variant(model_config_part) - if not isinstance(model_config_part, dict): - raise TypeError( - f"Resolved model configuration for '{variant}' is not a dictionary." - ) - - resolved = copy.deepcopy(model_config_part) - base_config_key = resolved.pop("update", None) - if base_config_key is None: - return resolved - if ( - not isinstance(base_config_key, str) - or base_config_key not in full_config - ): - raise ValueError( - f"Base configuration '{base_config_key}' not found for update." - ) - - merged_config = _resolve_variant(base_config_key) - for key, value in resolved.items(): - if ( - key in merged_config - and isinstance(merged_config[key], dict) - and isinstance(value, dict) - ): - merged_config[key].update(value) - else: - merged_config[key] = value - return merged_config - finally: - resolving.remove(variant) - - return _normalize_model_artifacts(_resolve_variant(model_type)) + return _normalize_model_artifacts(load_model_variant(config_path, model_type)) class MBLT_Engine: diff --git a/pyproject.toml b/pyproject.toml index 5514221..9850c02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ version = { attr = "mblt_vision.__version__" } "mblt_vision" = [ "py.typed", "models/*.yaml", + "models/bases/*.yaml", "datasets/*.yaml", "datasets/*.txt", ] diff --git a/tests/test_config_inheritance.py b/tests/test_config_inheritance.py new file mode 100644 index 0000000..a36907b --- /dev/null +++ b/tests/test_config_inheritance.py @@ -0,0 +1,164 @@ +"""CPU proofs for legacy resolution and opt-in local YAML inheritance.""" + +from __future__ import annotations + +import copy +from pathlib import Path + +import numpy as np +import pytest +import yaml + +from mblt_vision._config import load_model_variant +from mblt_vision.utils.preprocess import build_preprocess +from mblt_vision.wrapper import MODEL_CONFIG_DIR, resolve_model_config + + +def legacy_resolve(document: dict, variant: str) -> dict: + """Independent pre-refactor resolution, including ONNX artifact derivation.""" + + part = document[variant] + if isinstance(part, str): + return legacy_resolve(document, part) + child = copy.deepcopy(part) + base = child.pop("update", None) + if base is not None: + resolved = legacy_resolve(document, base) + for key, value in child.items(): + if isinstance(resolved.get(key), dict) and isinstance(value, dict): + resolved[key].update(value) + else: + resolved[key] = value + return resolved + return child + + +def ordered(value): + """Deep equality alone misses insertion order, which defines pre-op order.""" + + if isinstance(value, dict): + return [(key, ordered(item)) for key, item in value.items()] + if isinstance(value, list): + return [ordered(item) for item in value] + return value + + +def legacy_config(document: dict, variant: str) -> dict: + config = legacy_resolve(document, variant) + files = config.get("file_cfg", {}) + if files.get("filename") and "onnx_filename" not in files: + files["onnx_filename"] = str(Path(files["filename"]).with_suffix(".onnx")) + return config + + +CONFIG_VARIANTS = [ + (path, variant) + for path in sorted(MODEL_CONFIG_DIR.glob("*.yaml")) + for variant in yaml.safe_load(path.read_text(encoding="utf-8")) +] + + +@pytest.mark.parametrize("path,variant", CONFIG_VARIANTS, ids=lambda value: str(value)) +def test_every_legacy_variant_is_order_identical(path: Path, variant: str) -> None: + expected = legacy_config(yaml.safe_load(path.read_text(encoding="utf-8")), variant) + actual = resolve_model_config(str(path), variant) + assert actual == expected + assert ordered(actual) == ordered(expected) + + +def write_config(path: Path, config: dict) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8") + return path + + +def test_recursive_extends_keeps_order_and_independent_results(tmp_path: Path) -> None: + write_config( + tmp_path / "bases/base.yaml", + { + "DEFAULT": { + "pre_cfg": { + "Reader": {"style": "pil", "color_mode": "RGB"}, + "Resize": {"size": [32, 32], "interpolation": "bilinear"}, + }, + "post_cfg": {"nested": {"labels": [1, 2], "other": True}}, + } + }, + ) + child = write_config( + tmp_path / "child.yaml", + { + "DEFAULT": { + "extends": "bases/base.yaml", + "pre_cfg": {"Resize": {"size": [16, 16]}, "SetOrder": {"shape": "HWC"}}, + "post_cfg": {"nested": {"labels": [3]}}, + } + }, + ) + result = load_model_variant(child) + assert list(result["pre_cfg"]) == ["Reader", "Resize", "SetOrder"] + assert result["pre_cfg"]["Resize"] == { + "size": [16, 16], + "interpolation": "bilinear", + } + assert result["post_cfg"]["nested"] == {"labels": [3], "other": True} + result["post_cfg"]["nested"]["labels"].append(4) + assert load_model_variant(child)["post_cfg"]["nested"]["labels"] == [3] + + +def test_update_stays_shallow_with_extends_base(tmp_path: Path) -> None: + write_config( + tmp_path / "base.yaml", + {"DEFAULT": {"pre_cfg": {"Resize": {"size": 32, "interpolation": "bilinear"}}}}, + ) + path = write_config( + tmp_path / "child.yaml", + { + "BASE": {"extends": "base.yaml"}, + "DEFAULT": {"update": "BASE", "pre_cfg": {"Resize": {"size": 16}}}, + }, + ) + assert load_model_variant(path)["pre_cfg"]["Resize"] == {"size": 16} + + +@pytest.mark.parametrize( + "reference", + [None, "", 123, "#DEFAULT", "base.yaml#", "https://host/base.yaml", "a#b#c"], +) +def test_invalid_extends(tmp_path: Path, reference) -> None: + path = write_config(tmp_path / "child.yaml", {"DEFAULT": {"extends": reference}}) + with pytest.raises(ValueError): + load_model_variant(path) + + +def test_cross_file_cycle(tmp_path: Path) -> None: + a = write_config(tmp_path / "a.yaml", {"DEFAULT": {"extends": "b.yaml"}}) + write_config(tmp_path / "b.yaml", {"DEFAULT": {"extends": "./a.yaml"}}) + with pytest.raises(ValueError, match="Circular"): + load_model_variant(a) + + +def test_missing_base_and_conflicting_modes(tmp_path: Path) -> None: + path = write_config( + tmp_path / "child.yaml", {"DEFAULT": {"extends": "absent.yaml"}} + ) + with pytest.raises(FileNotFoundError): + load_model_variant(path) + write_config( + path, {"BASE": {}, "DEFAULT": {"extends": "absent.yaml", "update": "BASE"}} + ) + with pytest.raises(ValueError, match="cannot combine"): + load_model_variant(path) + + +@pytest.mark.parametrize("model", ["ResNet50", "YOLOv8n", "YOLOX-s", "DAMO-YOLO-T"]) +def test_cpu_preprocessing_numeric_identity(model: str) -> None: + path = MODEL_CONFIG_DIR / f"{model}.yaml" + expected = legacy_config( + yaml.safe_load(path.read_text(encoding="utf-8")), "DEFAULT" + ) + actual = resolve_model_config(str(path)) + image = np.random.default_rng(0).integers(0, 256, (71, 103, 3), dtype=np.uint8) + before = build_preprocess(expected["pre_cfg"])(image.copy()) + after = build_preprocess(actual["pre_cfg"])(image.copy()) + np.testing.assert_array_equal(np.asarray(after), np.asarray(before)) From e8fe25c01c7ec9513485dfa164957cc3ab138a8e Mon Sep 17 00:00:00 2001 From: jinman Date: Wed, 4 Nov 2026 16:30:30 +0900 Subject: [PATCH 2/3] Add read-only Hub artifact resolution checks Verify exact runtime board paths using metadata only, preserve local-only restrictions and distinguish live commits from approval provenance. Co-Authored-By: Claude Haiku 5.5 --- .claude/skills/mblt-vision/SKILL.md | 3 + AGENTS.md | 3 + mblt_vision/_hub_validation.py | 98 +++++++++++++++++++++++++++++ tests/TEST.md | 16 +++++ tests/test_hub_validation.py | 97 ++++++++++++++++++++++++++++ 5 files changed, 217 insertions(+) create mode 100644 mblt_vision/_hub_validation.py create mode 100644 tests/test_hub_validation.py diff --git a/.claude/skills/mblt-vision/SKILL.md b/.claude/skills/mblt-vision/SKILL.md index b695725..643c86b 100644 --- a/.claude/skills/mblt-vision/SKILL.md +++ b/.claude/skills/mblt-vision/SKILL.md @@ -28,6 +28,9 @@ description: >- Use file_cfg.filename for MXQ and derive the same-stem ONNX artifact unless onnx_filename is required. - Every post_cfg declares dataset; resolve output taxonomy from the dataset/task pair. +- For Hub linkage changes, run `tests/test_hub_validation.py` as described in `tests/TEST.md`. + The metadata checker follows runtime board paths without downloading weights; live commits + are diagnostics, not substitutes for missing historical approval provenance. - For opt-in YAML family inheritance, follow `mblt_vision/README.md`'s configuration inheritance rules. Keep legacy `update` shallow and run `tests/test_config_inheritance.py` to prove every existing variant's values and processing order remain identical. diff --git a/AGENTS.md b/AGENTS.md index 06a243b..a6f72b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,9 @@ The current ownership boundary is deliberate: onnx_path as compatibility aliases. - Update task-package exports and lazy top-level exports together. Confirm that list_models() discovers every public model class. +- Verify Hub configuration changes with the metadata-only checks in + `tests/test_hub_validation.py` (see `tests/TEST.md`). Use exact runtime board paths; + treat live Hub commits as diagnostics, never as invented approval provenance. - Keep each model YAML's file_cfg, pre_cfg, and post_cfg shape stable. file_cfg.filename is the canonical MXQ Hub artifact; derive the same-stem ONNX filename unless the Hub artifact requires an explicit onnx_filename. diff --git a/mblt_vision/_hub_validation.py b/mblt_vision/_hub_validation.py new file mode 100644 index 0000000..d1f136d --- /dev/null +++ b/mblt_vision/_hub_validation.py @@ -0,0 +1,98 @@ +"""Read-only Hub metadata checks for resolved model artifact configurations.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from huggingface_hub import HfApi +from mblt_npu import normalize_target_device + + +@dataclass(frozen=True) +class HubArtifactCheck: + """A metadata-only resolution result; a live commit is not approval provenance.""" + + repo_id: str + revision: str + commit: str | None + framework: str + path: str + status: str + + +def check_hub_artifacts( + config: dict[str, Any], + *, + target_device: str = "aries-rb", + frameworks: tuple[str, ...] = ("mxq", "onnx"), + api: HfApi | None = None, +) -> tuple[HubArtifactCheck, ...]: + """Check exact runtime Hub paths without downloading model bytes. + + Accept a resolved configuration (``resolve_model_config`` derives the ONNX + filename). MXQ is checked only under the normalized target board; ONNX is + checked at the repository root, matching the engine. Never fall back to a + different board, revision or filename. Local-only configs are reported as + skipped, without contacting the Hub. Hub/API exceptions propagate so missing + access or network failure cannot be mistaken for a missing file. + + The returned live commit is diagnostic, not evidence that a commit was + approved. This function does not modify YAMLs or infer approved revisions. + """ + + if not frameworks or any( + framework not in {"mxq", "onnx"} for framework in frameworks + ): + raise ValueError("frameworks must contain only 'mxq' and/or 'onnx'.") + files = config.get("file_cfg") + if not isinstance(files, dict): + raise TypeError("Resolved model configuration requires a file_cfg mapping.") + repo_id, revision = files.get("repo_id"), files.get("revision") + if ( + not isinstance(repo_id, str) + or not repo_id + or not isinstance(revision, str) + or not revision + ): + raise ValueError( + "Hub verification requires nonempty repo_id and revision strings." + ) + board = normalize_target_device(target_device) + paths = [] + for framework in dict.fromkeys(frameworks): + filename = files.get("filename" if framework == "mxq" else "onnx_filename") + if not isinstance(filename, str) or not filename: + raise ValueError( + f"Resolved configuration requires the {framework} artifact filename." + ) + paths.append( + (framework, f"{board}/{filename}" if framework == "mxq" else filename) + ) + if files.get("local_artifact_only"): + return tuple( + HubArtifactCheck( + repo_id, revision, None, framework, path, "local_artifact_only" + ) + for framework, path in paths + ) + client = HfApi() if api is None else api + info = client.model_info(repo_id=repo_id, revision=revision) + if not isinstance(info.sha, str) or not info.sha: + raise ValueError("Hub model metadata did not return a commit SHA.") + # Pin the file listing to the just-resolved commit: symbolic branches can move + # between API calls, and results must describe a single repository snapshot. + available = set( + client.list_repo_files(repo_id=repo_id, revision=info.sha, repo_type="model") + ) + return tuple( + HubArtifactCheck( + repo_id, + revision, + info.sha, + framework, + path, + "resolved" if path in available else "missing", + ) + for framework, path in paths + ) diff --git a/tests/TEST.md b/tests/TEST.md index ffd156d..bea1fcf 100644 --- a/tests/TEST.md +++ b/tests/TEST.md @@ -35,6 +35,22 @@ After authenticating with Hugging Face Hub, exercise representative ONNX models: pytest tests/test_onnx_classification.py -m requires_network ``` +To check representative Hub configurations without downloading model bytes or +using an inference backend: + +```bash +pytest tests/test_hub_validation.py -m requires_network +``` + +These read-only checks use the exact configured `repo_id` and `revision`, verify +MXQ under the normalized target board and ONNX at the repository root, and pin +metadata listing to the resolved commit to avoid races with moving branches. +Local-only artifacts are skipped without a network request. An observed current +commit is diagnostic metadata, not evidence of a historically approved revision; +missing approval provenance must never be replaced with today's `main` tip. +Offline mocked tests cover missing files, wrong boards, API failures and local-only +restrictions. No YAML revision or model artifact is changed by these checks. + To run MXQ inference, add a configured NPU and the shared runtime options: ```bash diff --git a/tests/test_hub_validation.py b/tests/test_hub_validation.py new file mode 100644 index 0000000..f702c88 --- /dev/null +++ b/tests/test_hub_validation.py @@ -0,0 +1,97 @@ +"""Offline unit tests and explicit metadata-only Hub integration checks.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from mblt_vision._hub_validation import check_hub_artifacts +from mblt_vision.wrapper import resolve_model_config + + +def config(**overrides): + return { + "file_cfg": { + "repo_id": "mobilint/example", + "revision": "approved-commit", + "filename": "model.mxq", + "onnx_filename": "model.onnx", + **overrides, + } + } + + +def api_with_files(files): + api = Mock() + api.model_info.return_value = SimpleNamespace(sha="exact-live-commit") + api.list_repo_files.return_value = files + return api + + +def test_exact_paths_and_commit_snapshot() -> None: + api = api_with_files(["aries-rb/model.mxq", "model.onnx"]) + checks = check_hub_artifacts(config(), api=api) + assert [item.status for item in checks] == ["resolved", "resolved"] + assert [item.path for item in checks] == ["aries-rb/model.mxq", "model.onnx"] + assert all(item.commit == "exact-live-commit" for item in checks) + api.model_info.assert_called_once_with( + repo_id="mobilint/example", revision="approved-commit" + ) + api.list_repo_files.assert_called_once_with( + repo_id="mobilint/example", revision="exact-live-commit", repo_type="model" + ) + + +def test_wrong_board_never_falls_back() -> None: + api = api_with_files(["aries-rb/model.mxq", "model.mxq", "model.onnx"]) + checks = check_hub_artifacts(config(), target_device="regulus-ra", api=api) + assert checks[0].path == "regulus-ra/model.mxq" + assert checks[0].status == "missing" + assert checks[1].status == "resolved" + + +def test_local_artifact_only_does_not_contact_hub() -> None: + api = Mock() + checks = check_hub_artifacts(config(local_artifact_only=True), api=api) + assert all( + item.status == "local_artifact_only" and item.commit is None for item in checks + ) + assert not api.mock_calls + + +def test_api_errors_remain_errors() -> None: + api = Mock() + api.model_info.side_effect = ConnectionError("offline") + with pytest.raises(ConnectionError, match="offline"): + check_hub_artifacts(config(), api=api) + + +@pytest.mark.parametrize("frameworks", [(), ("other",)]) +def test_invalid_frameworks(frameworks) -> None: + with pytest.raises(ValueError, match="frameworks"): + check_hub_artifacts(config(), frameworks=frameworks) + + +@pytest.mark.parametrize( + "override", + [{"repo_id": ""}, {"revision": None}, {"filename": None}, {"onnx_filename": ""}], +) +def test_missing_required_fields(override) -> None: + with pytest.raises(ValueError): + check_hub_artifacts(config(**override), api=Mock()) + + +def test_framework_selection_does_not_require_other_filename() -> None: + api = api_with_files(["model.onnx"]) + checks = check_hub_artifacts(config(filename=None), frameworks=("onnx",), api=api) + assert len(checks) == 1 + assert checks[0].status == "resolved" + + +@pytest.mark.requires_network +@pytest.mark.parametrize("model", ["AlexNet", "ResNet50", "YOLOv8n"]) +def test_representative_hub_metadata_resolves(model: str) -> None: + checks = check_hub_artifacts(resolve_model_config(model)) + assert all(item.status == "resolved" for item in checks), checks From 11d32827a7d8ba9cad3f63f0fd12e5a8eca4d5ed Mon Sep 17 00:00:00 2001 From: jinman Date: Wed, 4 Nov 2026 17:18:08 +0900 Subject: [PATCH 3/3] Follow configured target board in metadata verification Use explicit override then file_cfg target_device then Aries default, matching runtime precedence. Co-Authored-By: Claude Haiku 5.5 --- mblt_vision/_hub_validation.py | 6 ++++-- tests/test_hub_validation.py | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/mblt_vision/_hub_validation.py b/mblt_vision/_hub_validation.py index d1f136d..c387ce1 100644 --- a/mblt_vision/_hub_validation.py +++ b/mblt_vision/_hub_validation.py @@ -24,7 +24,7 @@ class HubArtifactCheck: def check_hub_artifacts( config: dict[str, Any], *, - target_device: str = "aries-rb", + target_device: str | None = None, frameworks: tuple[str, ...] = ("mxq", "onnx"), api: HfApi | None = None, ) -> tuple[HubArtifactCheck, ...]: @@ -58,7 +58,9 @@ def check_hub_artifacts( raise ValueError( "Hub verification requires nonempty repo_id and revision strings." ) - board = normalize_target_device(target_device) + board = normalize_target_device( + files.get("target_device", "aries-rb") if target_device is None else target_device + ) paths = [] for framework in dict.fromkeys(frameworks): filename = files.get("filename" if framework == "mxq" else "onnx_filename") diff --git a/tests/test_hub_validation.py b/tests/test_hub_validation.py index f702c88..06bdb2b 100644 --- a/tests/test_hub_validation.py +++ b/tests/test_hub_validation.py @@ -52,6 +52,15 @@ def test_wrong_board_never_falls_back() -> None: assert checks[1].status == "resolved" +def test_configured_board_and_explicit_override_precedence() -> None: + api = api_with_files(["regulus-ra/model.mxq", "model.onnx"]) + configured = config(target_device="regulus-ra") + assert check_hub_artifacts(configured, api=api)[0].path == "regulus-ra/model.mxq" + override = check_hub_artifacts(configured, target_device="aries-rb", api=api) + assert override[0].path == "aries-rb/model.mxq" + assert override[0].status == "missing" + + def test_local_artifact_only_does_not_contact_hub() -> None: api = Mock() checks = check_hub_artifacts(config(local_artifact_only=True), api=api)