diff --git a/config/perception/lab-v1-vegetation-provider-label-map-v1.json b/config/perception/lab-v1-vegetation-provider-label-map-v1.json new file mode 100644 index 0000000..0b20580 --- /dev/null +++ b/config/perception/lab-v1-vegetation-provider-label-map-v1.json @@ -0,0 +1,84 @@ +{ + "schema_version": "missioncore.vegetation-provider-label-map/v1", + "profile_id": "lab-v1-vegetation-provider-label-map/v1", + "policy_profile_id": "lab-v1-vegetation-mission-policy/v1", + "providers": { + "goose-fine-64": { + "unmapped_policy": "no-material-claim", + "class_granularity": "fine", + "can_distinguish_low_and_high_grass": true, + "can_distinguish_tree_trunk": true, + "labels": { + "asphalt": "hard_surface", + "bikeway": "hard_surface", + "cobble": "hard_surface", + "sidewalk": "hard_surface", + "gravel": "bare_soil", + "soil": "bare_soil", + "low_grass": "grass", + "high_grass": "herbaceous_vegetation", + "moss": "herbaceous_vegetation", + "crops": "cultivated_vegetation", + "bush": "woody_shrub", + "hedge": "woody_shrub", + "tree_trunk": "tree_or_trunk", + "tree_root": "tree_or_trunk", + "forest": "vegetation_unknown", + "leaves": "vegetation_unknown", + "scenery_vegetation": "vegetation_unknown", + "tree_crown": "vegetation_unknown" + } + }, + "goose-category-12": { + "unmapped_policy": "no-material-claim", + "class_granularity": "coarse", + "can_distinguish_low_and_high_grass": false, + "can_distinguish_tree_trunk": false, + "labels": { + "artificial_ground": "hard_surface", + "vegetation": "vegetation_unknown" + } + }, + "wildscenes-19": { + "unmapped_policy": "no-material-claim", + "class_granularity": "medium", + "can_distinguish_low_and_high_grass": false, + "can_distinguish_tree_trunk": true, + "labels": { + "asphalt/concrete": "hard_surface", + "dirt": "bare_soil", + "gravel": "bare_soil", + "grass": "grass", + "bush": "woody_shrub", + "tree-trunk": "tree_or_trunk", + "tree-foliage": "vegetation_unknown" + } + }, + "rellis-20": { + "unmapped_policy": "no-material-claim", + "class_granularity": "medium", + "can_distinguish_low_and_high_grass": false, + "can_distinguish_tree_trunk": false, + "labels": { + "asphalt": "hard_surface", + "concrete": "hard_surface", + "grass": "grass", + "bush": "woody_shrub", + "tree": "tree_or_trunk" + } + }, + "orfd-binary-freespace": { + "unmapped_policy": "no-material-claim", + "class_granularity": "binary-geometry-only", + "can_distinguish_low_and_high_grass": false, + "can_distinguish_tree_trunk": false, + "labels": {} + } + }, + "invariants": { + "unmapped_label_means_free": false, + "coarse_vegetation_means_grass": false, + "binary_freespace_selects_material": false, + "provider_label_grants_actuation": false + } +} diff --git a/src/k1link/laboratory/vegetation_mission_policy.py b/src/k1link/laboratory/vegetation_mission_policy.py index a56366b..a9b262c 100644 --- a/src/k1link/laboratory/vegetation_mission_policy.py +++ b/src/k1link/laboratory/vegetation_mission_policy.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any, Final SCHEMA: Final = "missioncore.vegetation-mission-policy/v1" +PROVIDER_MAP_SCHEMA: Final = "missioncore.vegetation-provider-label-map/v1" VEHICLE_SCHEMA: Final = "missioncore.m49-physical-safety-shadow-profile/v1" _MAX_JSON_BYTES: Final = 1024 * 1024 @@ -163,6 +164,64 @@ def load_vegetation_mission_policy( return policy +def load_vegetation_provider_label_map( + path: Path, + *, + policy: dict[str, Any], +) -> dict[str, Any]: + """Load the provider-label adapter without inventing material claims.""" + + label_map = _json(path, "vegetation provider label map") + providers = label_map.get("providers") + invariants = label_map.get("invariants") + if ( + label_map.get("schema_version") != PROVIDER_MAP_SCHEMA + or label_map.get("policy_profile_id") != policy.get("profile_id") + or not isinstance(providers, dict) + or not providers + or not isinstance(invariants, dict) + or any(value is not False for value in invariants.values()) + ): + raise VegetationMissionPolicyError("provider label map identity changed") + + materials = set(policy["material_classes"]) + for provider_id, provider in providers.items(): + if not isinstance(provider_id, str) or not isinstance(provider, dict): + raise VegetationMissionPolicyError("provider label map is invalid") + labels = provider.get("labels") + if ( + provider.get("unmapped_policy") != "no-material-claim" + or not isinstance(provider.get("class_granularity"), str) + or not isinstance(provider.get("can_distinguish_low_and_high_grass"), bool) + or not isinstance(provider.get("can_distinguish_tree_trunk"), bool) + or not isinstance(labels, dict) + or any( + not isinstance(label, str) + or not label + or not isinstance(material, str) + or material not in materials + for label, material in labels.items() + ) + ): + raise VegetationMissionPolicyError(f"provider label map is invalid: {provider_id}") + return label_map + + +def map_provider_material( + label_map: dict[str, Any], + *, + provider_id: str, + provider_label: str, +) -> str | None: + """Return an exact material claim, or none when the provider did not make one.""" + + provider = label_map["providers"].get(provider_id) + if not isinstance(provider, dict): + raise VegetationMissionPolicyError(f"unknown vegetation provider: {provider_id}") + material = provider["labels"].get(provider_label) + return material if isinstance(material, str) else None + + def resolve_terrain_policy( policy: dict[str, Any], *, diff --git a/tests/test_vegetation_mission_policy.py b/tests/test_vegetation_mission_policy.py index dbd6e1c..5b7545a 100644 --- a/tests/test_vegetation_mission_policy.py +++ b/tests/test_vegetation_mission_policy.py @@ -8,6 +8,8 @@ import pytest from k1link.laboratory.vegetation_mission_policy import ( VegetationMissionPolicyError, load_vegetation_mission_policy, + load_vegetation_provider_label_map, + map_provider_material, resolve_terrain_policy, ) @@ -21,12 +23,23 @@ CANDIDATE_MANIFEST_PATH = ( / "perception" / "lab-v1-vegetation-candidate-manifest-v1.json" ) +PROVIDER_LABEL_MAP_PATH = ( + REPOSITORY_ROOT + / "config" + / "perception" + / "lab-v1-vegetation-provider-label-map-v1.json" +) def _policy() -> dict[str, object]: return load_vegetation_mission_policy(POLICY_PATH, repository_root=REPOSITORY_ROOT) +def _provider_label_map() -> dict[str, object]: + policy = _policy() + return load_vegetation_provider_label_map(PROVIDER_LABEL_MAP_PATH, policy=policy) + + def test_presets_are_defaults_and_explicit_mission_rule_wins() -> None: policy = _policy() @@ -147,3 +160,65 @@ def test_candidate_manifest_freezes_unique_assets_sources_and_no_authority() -> assert manifest["invariants"]["missing_or_unknown_is_free"] is False assert manifest["invariants"]["navigation_authority"] is False assert manifest["invariants"]["actuation_authority"] is False + + +@pytest.mark.parametrize( + ("provider_id", "provider_label", "material"), + [ + ("goose-fine-64", "low_grass", "grass"), + ("goose-fine-64", "high_grass", "herbaceous_vegetation"), + ("goose-fine-64", "bush", "woody_shrub"), + ("goose-fine-64", "tree_trunk", "tree_or_trunk"), + ("wildscenes-19", "tree-trunk", "tree_or_trunk"), + ("rellis-20", "tree", "tree_or_trunk"), + ], +) +def test_fine_provider_labels_map_to_separate_material_claims( + provider_id: str, + provider_label: str, + material: str, +) -> None: + assert ( + map_provider_material( + _provider_label_map(), + provider_id=provider_id, + provider_label=provider_label, + ) + == material + ) + + +def test_coarse_vegetation_never_becomes_grass_permission() -> None: + material = map_provider_material( + _provider_label_map(), + provider_id="goose-category-12", + provider_label="vegetation", + ) + assert material == "vegetation_unknown" + decision = resolve_terrain_policy( + _policy(), + preset_id="offroad", + material_class=material, + evidence_state="SUPPORTED_GROUND", + ) + assert decision.effective_action == "NO_GO" + + +def test_binary_freespace_and_unmapped_labels_make_no_material_claim() -> None: + label_map = _provider_label_map() + assert ( + map_provider_material( + label_map, + provider_id="orfd-binary-freespace", + provider_label="freespace", + ) + is None + ) + assert ( + map_provider_material( + label_map, + provider_id="goose-fine-64", + provider_label="person", + ) + is None + )