From b16577ac0fa4c64d852d98831cece4b8c999d2b2 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Sun, 18 Oct 2026 11:51:36 +0000 Subject: [PATCH 1/2] feat(task-program): resolve semantic asset role combinations --- agent_context/MAP.yaml | 5 +- .../asset-generalization.md | 11 + .../api_reference/asset_generalization.rst | 25 ++ .../task_program/semantics/asset_matching.py | 394 ++++++++++++++++++ .../semantics/test_asset_matching.py | 336 +++++++++++++++ 5 files changed, 769 insertions(+), 2 deletions(-) create mode 100644 embodichain/lab/task_program/semantics/asset_matching.py create mode 100644 tests/lab/task_program/semantics/test_asset_matching.py diff --git a/agent_context/MAP.yaml b/agent_context/MAP.yaml index e2659e4a4..38d5b2a67 100644 --- a/agent_context/MAP.yaml +++ b/agent_context/MAP.yaml @@ -445,11 +445,12 @@ topics: - id: asset-generalization title: Asset Generalization aliases: [asset generalization, asset provider, 资产泛化, 资产语义匹配] - keywords: [AssetDescriptor, PreparedAsset, AssetProvider, AssetRequirements, AssetRoleRequirement] + keywords: [AssetDescriptor, PreparedAsset, AssetProvider, AssetRequirements, AssetRoleRequirement, ResolvedAssetSet, resolve_asset_set] paths: [topics/asset-generalization/asset-generalization.md] source_of_truth: - embodichain/data/asset_catalog/ - embodichain/lab/task_program/semantics/asset_requirements.py - watch_paths: [embodichain/data/asset_catalog/, tests/data/asset_catalog/, embodichain/lab/task_program/semantics/asset_requirements.py, tests/lab/task_program/semantics/test_asset_requirements.py] + - embodichain/lab/task_program/semantics/asset_matching.py + watch_paths: [embodichain/data/asset_catalog/, tests/data/asset_catalog/, embodichain/lab/task_program/semantics/asset_requirements.py, tests/lab/task_program/semantics/test_asset_requirements.py, embodichain/lab/task_program/semantics/asset_matching.py, tests/lab/task_program/semantics/test_asset_matching.py] related_topics: [data-assets, task-programs, gen-sim, scene-services] status: active diff --git a/agent_context/topics/asset-generalization/asset-generalization.md b/agent_context/topics/asset-generalization/asset-generalization.md index 8cacc06d4..77e799617 100644 --- a/agent_context/topics/asset-generalization/asset-generalization.md +++ b/agent_context/topics/asset-generalization/asset-generalization.md @@ -33,3 +33,14 @@ the complete package into an immutable snapshot. `verify_prepared_asset` is the common integrity boundary for any provider's prepared output; its package-root, file-inventory and manifest contract is documented in that function. Changing source content creates a new identity; changing cached content is an error. + +## Task matching + +`semantics/asset_matching.py` owns bounded role matching. It resolves explicit +category aliases, checks hard properties/capabilities and dimensions, and prunes +upright support/containment assignments as roles are selected. It never builds +the full Cartesian product. Candidate and search-node budgets are independent; +exhaustion produces structured rejection evidence rather than a relaxed match. +`ResolvedAssetSet` freezes selected content and requirement identity with a seed. +Absolute cache paths are excluded from its identity. Same asset selection does +not establish reachable grasps, settled state or measured task success. diff --git a/docs/source/api_reference/asset_generalization.rst b/docs/source/api_reference/asset_generalization.rst index d6f6d87c4..c3d6f0589 100644 --- a/docs/source/api_reference/asset_generalization.rst +++ b/docs/source/api_reference/asset_generalization.rst @@ -146,3 +146,28 @@ Directory and explicit providers retain candidate metadata without claiming task :members: .. autofunction:: embodichain.data.asset_catalog.local.verify_prepared_asset + +embodichain.lab.task_program.semantics.asset_matching additions +--------------------------------------------------------------- + +Bounded deterministic selection applies hard role constraints and upright geometric relations before freezing an asset set. Unknown metadata cannot satisfy required constraints; a resolved set does not certify physical success. + +.. currentmodule:: embodichain.lab.task_program.semantics.asset_matching + +.. autosummary:: + + AssetRejection + AssetResolutionError + ResolvedAssetSet + resolve_asset_set + +.. autoclass:: embodichain.lab.task_program.semantics.asset_matching.AssetRejection + :members: + +.. autoclass:: embodichain.lab.task_program.semantics.asset_matching.AssetResolutionError + :members: + +.. autoclass:: embodichain.lab.task_program.semantics.asset_matching.ResolvedAssetSet + :members: + +.. autofunction:: embodichain.lab.task_program.semantics.asset_matching.resolve_asset_set diff --git a/embodichain/lab/task_program/semantics/asset_matching.py b/embodichain/lab/task_program/semantics/asset_matching.py new file mode 100644 index 000000000..b28a85543 --- /dev/null +++ b/embodichain/lab/task_program/semantics/asset_matching.py @@ -0,0 +1,394 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Bounded, deterministic matching of prepared assets to task roles. + +Relation checks use explicitly annotated axis-aligned regions in normalized +local meter/+Z frames. They certify geometric fit for upright placement, not +collision-free insertion, grasp feasibility, stability, or physical success. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +import hashlib +import json +from types import MappingProxyType +from typing import Any + +from embodichain.data.asset_catalog import PreparedAsset +from .asset_requirements import AssetRequirements, AssetRoleRequirement + +__all__ = [ + "AssetRejection", + "AssetResolutionError", + "ResolvedAssetSet", + "resolve_asset_set", +] + + +def _digest(value: object) -> str: + return hashlib.sha256( + json.dumps( + value, sort_keys=True, separators=(",", ":"), allow_nan=False + ).encode() + ).hexdigest() + + +@dataclass(frozen=True) +class AssetRejection: + """One candidate or relationship rejection with its affected role.""" + + role: str + asset_id: str | None + code: str + detail: str + target_role: str | None = None + + +class AssetResolutionError(ValueError): + """No assignment was found within the configured candidate/search bounds.""" + + def __init__( + self, + message: str, + *, + rejections: Sequence[AssetRejection], + search_nodes: int = 0, + budget_exhausted: bool = False, + ) -> None: + super().__init__(message) + self.rejections = tuple(rejections) + self.search_nodes = search_nodes + self.budget_exhausted = budget_exhausted + + +@dataclass(frozen=True) +class ResolvedAssetSet: + """Immutable role assignment with content and requirement identity. + + Absolute local paths are retained for loading but excluded from ``set_id``; + copying the same prepared content into another cache keeps its identity. + """ + + assets: Mapping[str, PreparedAsset] + seed: int + requirements_sha256: str + set_id: str = field(init=False) + + def __post_init__(self) -> None: + if type(self.seed) is not int: + raise TypeError("seed must be an integer.") + if not self.assets or any( + not isinstance(role, str) + or not role + or not isinstance(asset, PreparedAsset) + for role, asset in self.assets.items() + ): + raise ValueError("assets must map nonempty roles to PreparedAsset values.") + if len(self.requirements_sha256) != 64 or any( + char not in "0123456789abcdef" for char in self.requirements_sha256 + ): + raise ValueError("requirements_sha256 must be a SHA-256 hex digest.") + assets = dict(sorted(self.assets.items())) + object.__setattr__(self, "assets", MappingProxyType(assets)) + object.__setattr__( + self, + "set_id", + _digest( + { + "schema_version": 1, + "seed": self.seed, + "requirements_sha256": self.requirements_sha256, + "assets": { + role: _asset_identity(asset) for role, asset in assets.items() + }, + } + ), + ) + + def to_dict(self) -> dict[str, Any]: + """Return an independent JSON-compatible manifest for batch provenance.""" + return { + "schema_version": 1, + "set_id": self.set_id, + "seed": self.seed, + "requirements_sha256": self.requirements_sha256, + "assets": {role: asset.to_dict() for role, asset in self.assets.items()}, + } + + +def _asset_identity(asset: PreparedAsset) -> dict[str, Any]: + return { + "content_sha256": asset.content_sha256, + "descriptor": asset.descriptor.to_dict(), + } + + +def _category(value: str, aliases: Mapping[str, str]) -> str: + while value in aliases: + value = aliases[value] + return value + + +def _candidate_rejection( + role: str, + requirement: AssetRoleRequirement, + asset: PreparedAsset, + aliases: Mapping[str, str], +) -> AssetRejection | None: + descriptor = asset.descriptor + + def reject(code: str, detail: str) -> AssetRejection: + return AssetRejection(role, descriptor.asset_id, code, detail) + + if not descriptor.normalized: + return reject( + "not_normalized", "Asset must use normalized local meter/+Z coordinates." + ) + if descriptor.bounds_m is None: + return reject("missing_bounds", "Asset bounding geometry is unknown.") + query = requirement.query + if query.category is not None and ( + descriptor.category is None + or _category(descriptor.category, aliases) != _category(query.category, aliases) + ): + return reject( + "category_mismatch", + f"Expected category {query.category!r}; got {descriptor.category!r}.", + ) + for key, expected in sorted(query.attributes.items()): + actual = descriptor.attributes.get(key) + if ( + actual is None + or expected is None + or type(actual) is not type(expected) + or actual != expected + ): + return reject( + "attribute_mismatch", + f"Required attribute {key!r}={expected!r}; got {actual!r}.", + ) + missing = sorted(set(query.capabilities) - set(descriptor.capabilities)) + if missing: + return reject( + "missing_capability", f"Missing declared capabilities: {missing}." + ) + if ( + requirement.max_extent_m is not None + and max(descriptor.bounds_m.extents) > requirement.max_extent_m + ): + return reject( + "extent_exceeded", + f"Asset exceeds maximum extent {requirement.max_extent_m} m.", + ) + return None + + +def _relation_rejection( + requirements: AssetRequirements, + assigned: Mapping[str, PreparedAsset], + added_role: str, +) -> AssetRejection | None: + for relation in requirements.relations: + if added_role not in (relation.object_role, relation.target_role): + continue + if relation.object_role not in assigned or relation.target_role not in assigned: + continue + source = assigned[relation.object_role].descriptor + target = assigned[relation.target_role].descriptor + region = ( + target.container_region + if relation.kind == "fits_inside" + else target.support_region + ) + if region is None: + return AssetRejection( + relation.object_role, + source.asset_id, + "missing_relation_geometry", + f"Target {target.asset_id!r} has no annotated region for {relation.kind}.", + relation.target_role, + ) + # Prefilter guarantees source bounds. No inferred cavity from outer AABB. + assert source.bounds_m is not None + axes = 3 if relation.kind == "fits_inside" else 2 + if any( + source.bounds_m.extents[axis] + 2 * relation.clearance_m + > region.extents[axis] + for axis in range(axes) + ): + return AssetRejection( + relation.object_role, + source.asset_id, + "relation_does_not_fit", + f"{source.asset_id!r} cannot satisfy {relation.kind} with {target.asset_id!r} " + f"at {relation.clearance_m} m clearance per side.", + relation.target_role, + ) + return None + + +def resolve_asset_set( + requirements: AssetRequirements, + candidates_by_role: Mapping[str, Sequence[PreparedAsset]], + *, + seed: int = 0, + max_candidates_per_role: int = 100, + max_search_nodes: int = 1000, +) -> ResolvedAssetSet: + """Select the first valid seeded assignment using bounded iterative search. + + Candidate identities are sorted before seeded ranking, so provider iteration + order cannot affect selection. At most ``max_candidates_per_role`` unique + candidates per role are evaluated. This is a search budget, not a guarantee + that a satisfying assignment will be found. Pair constraints are checked as + soon as both roles are assigned; the Cartesian product is never allocated. + + Args: + requirements: Validated task-role constraints and explicit aliases. + candidates_by_role: Prepared candidates for exactly the required roles. + seed: Integer defining deterministic candidate ranking. + max_candidates_per_role: Candidate evaluation limit for each role. + max_search_nodes: Maximum attempted partial-assignment extensions. + + Raises: + ValueError: If input role sets or budgets are invalid. + AssetResolutionError: If no assignment passes within the budgets. + """ + if not isinstance(requirements, AssetRequirements): + raise TypeError("requirements must be AssetRequirements.") + for name, value in ( + ("seed", seed), + ("max_candidates_per_role", max_candidates_per_role), + ("max_search_nodes", max_search_nodes), + ): + if type(value) is not int or (name != "seed" and value <= 0): + raise ValueError( + f"{name} must be {'an integer' if name == 'seed' else 'a positive integer'}." + ) + if set(candidates_by_role) != set(requirements.roles): + raise ValueError("Candidate roles must exactly match requirement roles.") + + rejected: list[AssetRejection] = [] + truncated = False + + def record(rejection: AssetRejection) -> None: + # Diagnostics are bounded independently of candidate pool/search size. + if len(rejected) < 100: + rejected.append(rejection) + + pools: dict[str, list[PreparedAsset]] = {} + for role, requirement in sorted(requirements.roles.items()): + unique: dict[str, PreparedAsset] = {} + for asset in candidates_by_role[role]: + if not isinstance(asset, PreparedAsset): + raise TypeError( + f"Candidates for {role!r} must be PreparedAsset values." + ) + identity = _digest(_asset_identity(asset)) + existing = unique.get(identity) + # Source paths/evidence can differ for identical asset content. + # Break those ties too so the emitted provenance is reproducible. + if existing is None or _digest(asset.to_dict()) < _digest( + existing.to_dict() + ): + unique[identity] = asset + ranked = sorted( + unique, key=lambda identity: (_digest([seed, role, identity]), identity) + ) + if len(ranked) > max_candidates_per_role: + truncated = True + record( + AssetRejection( + role, + None, + "candidate_budget", + f"Evaluating {max_candidates_per_role} of {len(ranked)} unique candidates.", + ) + ) + pools[role] = [] + for identity in ranked[:max_candidates_per_role]: + asset = unique[identity] + rejection = _candidate_rejection( + role, requirement, asset, requirements.category_aliases + ) + if rejection is None: + pools[role].append(asset) + else: + record(rejection) + if not pools[role]: + record( + AssetRejection( + role, + None, + "no_candidates", + "No eligible candidate remains for this role.", + ) + ) + if any(not pool for pool in pools.values()): + raise AssetResolutionError( + "No eligible candidates for one or more roles.", + rejections=rejected, + budget_exhausted=truncated, + ) + + # Choose the smallest domain first. Sorted role names break equal-size ties. + roles = sorted(pools, key=lambda role: (len(pools[role]), role)) + assigned: dict[str, PreparedAsset] = {} + offsets = [0] * len(roles) + depth = 0 + visited = 0 + while depth >= 0: + if depth == len(roles): + return ResolvedAssetSet(assigned, seed, _digest(requirements.to_dict())) + role = roles[depth] + if offsets[depth] >= len(pools[role]): + offsets[depth] = 0 + assigned.pop(role, None) + depth -= 1 + continue + if visited >= max_search_nodes: + record( + AssetRejection( + role, + None, + "search_budget", + f"Stopped after {visited} partial assignments.", + ) + ) + raise AssetResolutionError( + "Asset assignment search budget exhausted.", + rejections=rejected, + search_nodes=visited, + budget_exhausted=True, + ) + assigned[role] = pools[role][offsets[depth]] + offsets[depth] += 1 + visited += 1 + rejection = _relation_rejection(requirements, assigned, role) + if rejection is None: + depth += 1 + else: + record(rejection) + assigned.pop(role, None) + raise AssetResolutionError( + "No compatible asset combination found.", + rejections=rejected, + search_nodes=visited, + budget_exhausted=truncated, + ) diff --git a/tests/lab/task_program/semantics/test_asset_matching.py b/tests/lab/task_program/semantics/test_asset_matching.py new file mode 100644 index 000000000..a94b5f142 --- /dev/null +++ b/tests/lab/task_program/semantics/test_asset_matching.py @@ -0,0 +1,336 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Tests for task-semantic matching and bounded combination selection.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError, replace +import hashlib + +import pytest + +from embodichain.data.asset_catalog import ( + AssetDescriptor, + AssetQuery, + Bounds3D, + PreparedAsset, +) +from embodichain.lab.task_program.semantics.asset_requirements import ( + AssetRequirements, + AssetRoleRequirement, + AssetRelationRequirement, +) +from embodichain.lab.task_program.semantics.asset_matching import ( + AssetResolutionError, + resolve_asset_set, +) + + +def _asset( + name="cup", + *, + category="cup", + size=0.10, + attributes=None, + capabilities=(), + container=None, + support=None, +): + descriptor = AssetDescriptor( + name, + category=category, + attributes=attributes or {}, + capabilities=capabilities, + bounds_m=Bounds3D((0, 0, 0), (size, size, size)), + normalized=True, + container_region=container, + support_region=support, + ) + return PreparedAsset( + descriptor, + f"/assets/{name}/visual.obj", + f"/assets/{name}/collision.obj", + hashlib.sha256(name.encode()).hexdigest(), + ) + + +def _requirements( + *, category="cup", attributes=None, capabilities=(), max_extent=None, aliases=None +): + return AssetRequirements( + { + "source": AssetRoleRequirement( + "source_object", + AssetQuery(category, attributes or {}, capabilities), + max_extent, + ) + }, + category_aliases=aliases or {}, + ) + + +def _pair_requirements(kind="fits_inside", clearance=0.0): + return AssetRequirements( + { + "source": AssetRoleRequirement("source_object", AssetQuery("cup")), + "target": AssetRoleRequirement("target_object", AssetQuery("tray")), + }, + (AssetRelationRequirement(kind, "source", "target", clearance),), + ) + + +def test_matches_all_semantics_and_freezes_selection(): + requirements = _requirements( + attributes={"color": "red"}, capabilities=("antipodal_grasp",), max_extent=0.15 + ) + selected = _asset(attributes={"color": "red"}, capabilities=("antipodal_grasp",)) + candidates = {"source": [selected]} + result = resolve_asset_set(requirements, candidates) + candidates["source"].clear() + assert result.assets["source"] == selected + with pytest.raises(TypeError): + result.assets["source"] = selected + with pytest.raises(FrozenInstanceError): + result.seed = 1 + manifest = result.to_dict() + manifest["assets"]["source"]["descriptor"]["attributes"]["color"] = "blue" + assert result.assets["source"].descriptor.attributes["color"] == "red" + + +@pytest.mark.parametrize( + ("asset", "requirements", "code"), + [ + (_asset(category="bowl"), _requirements(), "category_mismatch"), + (_asset(category=None), _requirements(), "category_mismatch"), + (_asset(), _requirements(attributes={"color": "red"}), "attribute_mismatch"), + ( + _asset(attributes={"color": "blue"}), + _requirements(attributes={"color": "red"}), + "attribute_mismatch", + ), + ( + _asset(attributes={"enabled": 1}), + _requirements(attributes={"enabled": True}), + "attribute_mismatch", + ), + ( + _asset(), + _requirements(capabilities=("antipodal_grasp",)), + "missing_capability", + ), + (_asset(size=0.20), _requirements(max_extent=0.15), "extent_exceeded"), + ], +) +def test_rejects_unknown_or_incompatible_role_metadata(asset, requirements, code): + with pytest.raises(AssetResolutionError) as captured: + resolve_asset_set(requirements, {"source": [asset]}) + assert code in {item.code for item in captured.value.rejections} + assert not captured.value.budget_exhausted + + +def test_category_aliases_are_explicit_and_transitive(): + mug = _asset(category="mug") + requirements = _requirements(aliases={"mug": "drinking_cup", "drinking_cup": "cup"}) + assert resolve_asset_set(requirements, {"source": [mug]}).assets["source"] == mug + with pytest.raises(AssetResolutionError): + resolve_asset_set(_requirements(), {"source": [mug]}) + + +def test_combination_rejects_individually_eligible_assets_that_do_not_fit(): + source = _asset(size=0.10) + target = _asset( + "tray", + category="tray", + size=0.30, + container=Bounds3D((0, 0, 0), (0.09, 0.20, 0.20)), + ) + with pytest.raises(AssetResolutionError) as captured: + resolve_asset_set( + _pair_requirements(), {"source": [source], "target": [target]} + ) + rejection = next( + item + for item in captured.value.rejections + if item.code == "relation_does_not_fit" + ) + assert rejection.target_role == "target" + assert "tray" in rejection.detail + + +def test_search_backtracks_to_valid_joint_combination(): + small = _asset("small", size=0.08) + large = _asset("large", size=0.20) + tray = _asset( + "tray", + category="tray", + size=0.30, + container=Bounds3D((0, 0, 0), (0.12, 0.12, 0.12)), + ) + result = resolve_asset_set( + _pair_requirements(clearance=0.01), + {"source": [large, small], "target": [tray]}, + seed=27, + ) + assert result.assets["source"] == small + + +def test_container_outer_bounds_do_not_prove_inner_space(): + with pytest.raises(AssetResolutionError) as captured: + resolve_asset_set( + _pair_requirements(), + { + "source": [_asset()], + "target": [_asset("tray", category="tray", size=0.5)], + }, + ) + assert "missing_relation_geometry" in { + item.code for item in captured.value.rejections + } + + +def test_support_checks_horizontal_footprint_not_object_height(): + support = _asset( + "tray", + category="tray", + size=0.30, + support=Bounds3D((0, 0, 0.30), (0.30, 0.30, 0.30)), + ) + source = _asset() + source = replace( + source, + descriptor=replace( + source.descriptor, bounds_m=Bounds3D((0, 0, 0), (0.10, 0.10, 0.80)) + ), + ) + result = resolve_asset_set( + _pair_requirements("supported_by", 0.01), + {"source": [source], "target": [support]}, + ) + assert result.assets["source"] == source + with pytest.raises(AssetResolutionError): + resolve_asset_set( + _pair_requirements("supported_by", 0.11), + {"source": [source], "target": [support]}, + ) + + +def test_seeded_selection_independent_of_provider_and_role_order(): + sources = [_asset(f"cup_{index}") for index in range(8)] + targets = [ + _asset( + f"tray_{index}", + category="tray", + size=0.30, + container=Bounds3D((0, 0, 0), (0.20, 0.20, 0.20)), + ) + for index in range(3) + ] + first = resolve_asset_set( + _pair_requirements(), {"source": sources, "target": targets}, seed=19 + ) + second = resolve_asset_set( + _pair_requirements(), + {"target": targets[::-1], "source": sources[::-1]}, + seed=19, + ) + assert first.to_dict() == second.to_dict() + + +def test_identity_tracks_metadata_requirements_and_content_not_cache_path(): + requirements = _requirements() + asset = _asset() + original = resolve_asset_set(requirements, {"source": [asset]}) + moved = replace( + asset, visual_path="/other/visual.obj", collision_path="/other/collision.obj" + ) + assert ( + resolve_asset_set(requirements, {"source": [moved]}).set_id == original.set_id + ) + changed = replace(asset, content_sha256="1" * 64) + assert ( + resolve_asset_set(requirements, {"source": [changed]}).set_id != original.set_id + ) + changed = replace( + asset, descriptor=replace(asset.descriptor, attributes={"color": "red"}) + ) + assert ( + resolve_asset_set(requirements, {"source": [changed]}).set_id != original.set_id + ) + assert ( + resolve_asset_set( + _requirements(max_extent=1), {"source": [asset]} + ).requirements_sha256 + != original.requirements_sha256 + ) + + +def test_candidate_budget_reports_truncation(): + candidates = [_asset(f"bad_{index}", category="bowl") for index in range(5)] + with pytest.raises(AssetResolutionError) as captured: + resolve_asset_set( + _requirements(), {"source": candidates}, max_candidates_per_role=2 + ) + assert captured.value.budget_exhausted + assert ( + sum(item.code == "category_mismatch" for item in captured.value.rejections) == 2 + ) + assert "candidate_budget" in {item.code for item in captured.value.rejections} + + +def test_search_budget_stops_before_complete_assignment(): + target = _asset( + "tray", + category="tray", + size=0.30, + container=Bounds3D((0, 0, 0), (0.20, 0.20, 0.20)), + ) + with pytest.raises(AssetResolutionError) as captured: + resolve_asset_set( + _pair_requirements(), + {"source": [_asset()], "target": [target]}, + max_search_nodes=1, + ) + assert captured.value.search_nodes == 1 + assert captured.value.budget_exhausted + + +def test_empty_role_pool_has_actionable_diagnostic(): + with pytest.raises(AssetResolutionError) as captured: + resolve_asset_set(_requirements(), {"source": []}) + assert captured.value.rejections[0].role == "source" + assert captured.value.rejections[0].code == "no_candidates" + + +@pytest.mark.parametrize( + "kwargs", [{"max_search_nodes": 0}, {"max_candidates_per_role": -1}, {"seed": True}] +) +def test_invalid_budget_inputs_fail_early(kwargs): + with pytest.raises(ValueError): + resolve_asset_set(_requirements(), {"source": [_asset()]}, **kwargs) + + +def test_unexpected_role_is_not_silently_ignored(): + with pytest.raises(ValueError, match="exactly match"): + resolve_asset_set(_requirements(), {"source": [_asset()], "typo": []}) + + +def test_duplicate_content_with_different_preparation_provenance_is_stable(): + asset = _asset() + other_source = replace(asset, provenance={"provider": "second"}) + first = resolve_asset_set(_requirements(), {"source": [asset, other_source]}) + second = resolve_asset_set(_requirements(), {"source": [other_source, asset]}) + assert first.to_dict() == second.to_dict() From 81ffe5cdc59b8ae6d41a4761195a37aa8d46f533 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Sun, 18 Oct 2026 16:02:28 +0000 Subject: [PATCH 2/2] fix(assets): retain role and search-budget rejection summaries --- .../task_program/semantics/asset_matching.py | 13 ++++-- .../semantics/test_asset_matching.py | 40 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/embodichain/lab/task_program/semantics/asset_matching.py b/embodichain/lab/task_program/semantics/asset_matching.py index b28a85543..8df8bfa62 100644 --- a/embodichain/lab/task_program/semantics/asset_matching.py +++ b/embodichain/lab/task_program/semantics/asset_matching.py @@ -285,12 +285,19 @@ def resolve_asset_set( raise ValueError("Candidate roles must exactly match requirement roles.") rejected: list[AssetRejection] = [] + detail_count = 0 truncated = False def record(rejection: AssetRejection) -> None: - # Diagnostics are bounded independently of candidate pool/search size. - if len(rejected) < 100: - rejected.append(rejection) + nonlocal detail_count + # Bound candidate details separately from per-role and budget summaries. + # Summaries have no asset ID and occur at most twice per role plus once + # for a stopped search, so they cannot grow with rejected combinations. + if rejection.asset_id is not None: + if detail_count >= 100: + return + detail_count += 1 + rejected.append(rejection) pools: dict[str, list[PreparedAsset]] = {} for role, requirement in sorted(requirements.roles.items()): diff --git a/tests/lab/task_program/semantics/test_asset_matching.py b/tests/lab/task_program/semantics/test_asset_matching.py index a94b5f142..8dd4b6269 100644 --- a/tests/lab/task_program/semantics/test_asset_matching.py +++ b/tests/lab/task_program/semantics/test_asset_matching.py @@ -334,3 +334,43 @@ def test_duplicate_content_with_different_preparation_provenance_is_stable(): first = resolve_asset_set(_requirements(), {"source": [asset, other_source]}) second = resolve_asset_set(_requirements(), {"source": [other_source, asset]}) assert first.to_dict() == second.to_dict() + + +def test_rejection_detail_limit_preserves_empty_role_summary(): + requirements = AssetRequirements( + { + "a": AssetRoleRequirement("source_a", AssetQuery("cup")), + "z": AssetRoleRequirement("source_z", AssetQuery("cup")), + } + ) + candidates = { + "a": [ + *[_asset(f"wrong-{i}", category="bottle") for i in range(100)], + _asset("valid"), + ], + "z": [], + } + with pytest.raises(AssetResolutionError) as caught: + resolve_asset_set(requirements, candidates, max_candidates_per_role=101) + errors = caught.value.rejections + assert sum(item.code == "category_mismatch" for item in errors) == 100 + assert any(item.role == "z" and item.code == "no_candidates" for item in errors) + + +def test_rejection_detail_limit_preserves_search_budget_summary(): + candidates = { + "source": [_asset(f"source-{i}", size=0.2) for i in range(20)], + "target": [ + _asset( + f"target-{i}", + category="tray", + container=Bounds3D((0, 0, 0), (0.05, 0.05, 0.05)), + ) + for i in range(10) + ], + } + with pytest.raises(AssetResolutionError) as caught: + resolve_asset_set(_pair_requirements(), candidates, max_search_nodes=150) + assert caught.value.budget_exhausted + assert len([item for item in caught.value.rejections if item.asset_id]) <= 100 + assert any(item.code == "search_budget" for item in caught.value.rejections)