feat(perception): define semantic object understanding
This commit is contained in:
@@ -0,0 +1,929 @@
|
||||
"""Versioned semantic, state and advisory-risk projection for an obstacle.
|
||||
|
||||
The projection composes an immutable :class:`ObstacleObservation` instead of
|
||||
changing the strict v1 geometry contract. Semantic identity, observed state,
|
||||
class priors and advisory risk remain separate claims with explicit evidence.
|
||||
None of them can create occupancy or acquire navigation, safety or actuation
|
||||
authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from .contracts import FalseAuthority, MotionState, ObstacleObservation
|
||||
|
||||
OBJECT_UNDERSTANDING_SCHEMA: Final = "missioncore.object-understanding/v1"
|
||||
OBJECT_SEMANTIC_VOCABULARY_SCHEMA: Final = "missioncore.object-semantic-vocabulary/v0"
|
||||
MAX_SEMANTIC_HYPOTHESES: Final = 5
|
||||
|
||||
_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$")
|
||||
_NORMALIZED_LABEL = re.compile(r"^[a-z0-9][a-z0-9_]{0,79}$")
|
||||
|
||||
|
||||
class ObjectUnderstandingError(ValueError):
|
||||
"""An object-understanding document or vocabulary is incompatible."""
|
||||
|
||||
|
||||
class EvidenceKind(StrEnum):
|
||||
DETECTOR = "detector"
|
||||
SEMANTIC_MASK = "semantic-mask"
|
||||
HUMAN_REVIEW = "human-review"
|
||||
GEOMETRY = "geometry"
|
||||
TEMPORAL = "temporal"
|
||||
POLICY = "policy"
|
||||
|
||||
|
||||
class SemanticResolution(StrEnum):
|
||||
UNRESOLVED = "unresolved"
|
||||
SELECTED = "selected"
|
||||
AMBIGUOUS = "ambiguous"
|
||||
CONFLICT = "conflict"
|
||||
|
||||
|
||||
class AgencyState(StrEnum):
|
||||
UNKNOWN = "unknown"
|
||||
INERT = "inert"
|
||||
ANIMATE = "animate"
|
||||
SELF_PROPELLED = "self-propelled"
|
||||
|
||||
|
||||
class StateBasis(StrEnum):
|
||||
UNKNOWN = "unknown"
|
||||
OBSERVED = "observed"
|
||||
CLASS_PRIOR = "class-prior"
|
||||
FUSED = "fused"
|
||||
|
||||
|
||||
class RiskLevel(StrEnum):
|
||||
UNKNOWN = "unknown"
|
||||
LOW = "low"
|
||||
ELEVATED = "elevated"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
class RiskBasis(StrEnum):
|
||||
UNKNOWN = "unknown"
|
||||
SEMANTIC_PRIOR = "semantic-prior"
|
||||
OBSERVED_STATE = "observed-state"
|
||||
GEOMETRY = "geometry"
|
||||
FUSED = "fused"
|
||||
|
||||
|
||||
class AdvisoryResponse(StrEnum):
|
||||
MONITOR = "monitor"
|
||||
REDUCE_SPEED = "reduce-speed"
|
||||
YIELD = "yield"
|
||||
STOP = "stop"
|
||||
ROUTE_AROUND = "route-around"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EvidenceProvenance:
|
||||
"""One source-bound evidence item used by semantic, state or risk claims."""
|
||||
|
||||
evidence_id: str
|
||||
kind: EvidenceKind
|
||||
source_id: str
|
||||
frame_id: str
|
||||
provider_id: str
|
||||
model_id: str | None
|
||||
model_revision: str | None
|
||||
preprocess_id: str | None
|
||||
prompt_set_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.kind, EvidenceKind):
|
||||
raise ObjectUnderstandingError("evidence kind is invalid")
|
||||
for value, label in (
|
||||
(self.evidence_id, "evidence id"),
|
||||
(self.source_id, "evidence source id"),
|
||||
(self.frame_id, "evidence frame id"),
|
||||
(self.provider_id, "evidence provider id"),
|
||||
):
|
||||
_identifier(value, label)
|
||||
for optional_value, label in (
|
||||
(self.model_id, "evidence model id"),
|
||||
(self.model_revision, "evidence model revision"),
|
||||
(self.preprocess_id, "evidence preprocess id"),
|
||||
(self.prompt_set_id, "evidence prompt-set id"),
|
||||
):
|
||||
_optional_identifier(optional_value, label)
|
||||
if self.kind in {EvidenceKind.DETECTOR, EvidenceKind.SEMANTIC_MASK} and (
|
||||
self.model_id is None or self.model_revision is None or self.preprocess_id is None
|
||||
):
|
||||
raise ObjectUnderstandingError(
|
||||
"model evidence requires model, revision and preprocess identity"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"evidence_id": self.evidence_id,
|
||||
"kind": self.kind.value,
|
||||
"source_id": self.source_id,
|
||||
"frame_id": self.frame_id,
|
||||
"provider_id": self.provider_id,
|
||||
"model_id": self.model_id,
|
||||
"model_revision": self.model_revision,
|
||||
"preprocess_id": self.preprocess_id,
|
||||
"prompt_set_id": self.prompt_set_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> EvidenceProvenance:
|
||||
document = _object(value, "evidence provenance")
|
||||
_exact_keys(
|
||||
document,
|
||||
{
|
||||
"evidence_id",
|
||||
"kind",
|
||||
"source_id",
|
||||
"frame_id",
|
||||
"provider_id",
|
||||
"model_id",
|
||||
"model_revision",
|
||||
"preprocess_id",
|
||||
"prompt_set_id",
|
||||
},
|
||||
"evidence provenance",
|
||||
)
|
||||
return cls(
|
||||
evidence_id=_string(document, "evidence_id"),
|
||||
kind=_enum(EvidenceKind, document.get("kind"), "evidence kind"),
|
||||
source_id=_string(document, "source_id"),
|
||||
frame_id=_string(document, "frame_id"),
|
||||
provider_id=_string(document, "provider_id"),
|
||||
model_id=_optional_string(document.get("model_id"), "model id"),
|
||||
model_revision=_optional_string(document.get("model_revision"), "model revision"),
|
||||
preprocess_id=_optional_string(document.get("preprocess_id"), "preprocess id"),
|
||||
prompt_set_id=_optional_string(document.get("prompt_set_id"), "prompt-set id"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticHypothesis:
|
||||
"""One ranked canonical class hypothesis, never an occupancy identity."""
|
||||
|
||||
rank: int
|
||||
class_id: str
|
||||
raw_label: str
|
||||
confidence: float
|
||||
evidence_ids: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_positive_integer(self.rank, "semantic rank")
|
||||
_identifier(self.class_id, "semantic class id")
|
||||
_label(self.raw_label, "raw semantic label")
|
||||
_confidence(self.confidence, "semantic confidence")
|
||||
_unique_identifiers(self.evidence_ids, "semantic evidence ids")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"rank": self.rank,
|
||||
"class_id": self.class_id,
|
||||
"raw_label": self.raw_label,
|
||||
"confidence": self.confidence,
|
||||
"evidence_ids": list(self.evidence_ids),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> SemanticHypothesis:
|
||||
document = _object(value, "semantic hypothesis")
|
||||
_exact_keys(
|
||||
document,
|
||||
{"rank", "class_id", "raw_label", "confidence", "evidence_ids"},
|
||||
"semantic hypothesis",
|
||||
)
|
||||
return cls(
|
||||
rank=_integer(document, "rank"),
|
||||
class_id=_string(document, "class_id"),
|
||||
raw_label=_string(document, "raw_label"),
|
||||
confidence=_number(document, "confidence"),
|
||||
evidence_ids=_string_tuple(document.get("evidence_ids"), "evidence ids"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SemanticDecision:
|
||||
"""Resolution over ranked hypotheses; ambiguity remains first-class."""
|
||||
|
||||
resolution: SemanticResolution
|
||||
selected_class_id: str | None
|
||||
selected_confidence: float | None
|
||||
reason_codes: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.resolution, SemanticResolution):
|
||||
raise ObjectUnderstandingError("semantic resolution is invalid")
|
||||
_optional_identifier(self.selected_class_id, "selected semantic class id")
|
||||
if self.selected_confidence is not None:
|
||||
_confidence(self.selected_confidence, "selected semantic confidence")
|
||||
_unique_identifiers(self.reason_codes, "semantic decision reasons")
|
||||
has_selection = self.selected_class_id is not None and self.selected_confidence is not None
|
||||
if self.resolution is SemanticResolution.SELECTED:
|
||||
if not has_selection:
|
||||
raise ObjectUnderstandingError("selected semantics require class and confidence")
|
||||
elif self.selected_class_id is not None or self.selected_confidence is not None:
|
||||
raise ObjectUnderstandingError("non-selected semantics cannot publish a selected class")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"resolution": self.resolution.value,
|
||||
"selected_class_id": self.selected_class_id,
|
||||
"selected_confidence": self.selected_confidence,
|
||||
"reason_codes": list(self.reason_codes),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> SemanticDecision:
|
||||
document = _object(value, "semantic decision")
|
||||
_exact_keys(
|
||||
document,
|
||||
{
|
||||
"resolution",
|
||||
"selected_class_id",
|
||||
"selected_confidence",
|
||||
"reason_codes",
|
||||
},
|
||||
"semantic decision",
|
||||
)
|
||||
return cls(
|
||||
resolution=_enum(
|
||||
SemanticResolution,
|
||||
document.get("resolution"),
|
||||
"semantic resolution",
|
||||
),
|
||||
selected_class_id=_optional_string(
|
||||
document.get("selected_class_id"), "selected class id"
|
||||
),
|
||||
selected_confidence=_optional_number(
|
||||
document.get("selected_confidence"), "selected confidence"
|
||||
),
|
||||
reason_codes=_string_tuple(document.get("reason_codes"), "reason codes"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ObjectStateEstimate:
|
||||
"""Observed motion and agency prior, with their bases kept explicit."""
|
||||
|
||||
motion: MotionState
|
||||
motion_confidence: float
|
||||
agency: AgencyState
|
||||
agency_basis: StateBasis
|
||||
evidence_ids: tuple[str, ...]
|
||||
reason_codes: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.motion, MotionState):
|
||||
raise ObjectUnderstandingError("motion state is invalid")
|
||||
if not isinstance(self.agency, AgencyState):
|
||||
raise ObjectUnderstandingError("agency state is invalid")
|
||||
if not isinstance(self.agency_basis, StateBasis):
|
||||
raise ObjectUnderstandingError("agency basis is invalid")
|
||||
_confidence(self.motion_confidence, "motion confidence")
|
||||
_unique_identifiers(self.evidence_ids, "state evidence ids", allow_empty=True)
|
||||
_unique_identifiers(self.reason_codes, "state reason codes")
|
||||
if self.agency is AgencyState.UNKNOWN:
|
||||
if self.agency_basis is not StateBasis.UNKNOWN:
|
||||
raise ObjectUnderstandingError("unknown agency must retain unknown evidence basis")
|
||||
elif self.agency_basis is StateBasis.UNKNOWN:
|
||||
raise ObjectUnderstandingError("agency claim requires an explicit basis")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"motion": self.motion.value,
|
||||
"motion_confidence": self.motion_confidence,
|
||||
"agency": self.agency.value,
|
||||
"agency_basis": self.agency_basis.value,
|
||||
"evidence_ids": list(self.evidence_ids),
|
||||
"reason_codes": list(self.reason_codes),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> ObjectStateEstimate:
|
||||
document = _object(value, "object state")
|
||||
_exact_keys(
|
||||
document,
|
||||
{
|
||||
"motion",
|
||||
"motion_confidence",
|
||||
"agency",
|
||||
"agency_basis",
|
||||
"evidence_ids",
|
||||
"reason_codes",
|
||||
},
|
||||
"object state",
|
||||
)
|
||||
return cls(
|
||||
motion=_enum(MotionState, document.get("motion"), "motion state"),
|
||||
motion_confidence=_number(document, "motion_confidence"),
|
||||
agency=_enum(AgencyState, document.get("agency"), "agency state"),
|
||||
agency_basis=_enum(StateBasis, document.get("agency_basis"), "agency basis"),
|
||||
evidence_ids=_string_tuple(document.get("evidence_ids"), "evidence ids"),
|
||||
reason_codes=_string_tuple(document.get("reason_codes"), "reason codes"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AdvisoryRiskAssessment:
|
||||
"""Evidence-qualified risk hint that is never a planner command."""
|
||||
|
||||
policy_id: str
|
||||
level: RiskLevel
|
||||
confidence: float
|
||||
basis: RiskBasis
|
||||
responses: tuple[AdvisoryResponse, ...]
|
||||
evidence_ids: tuple[str, ...]
|
||||
reason_codes: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.level, RiskLevel):
|
||||
raise ObjectUnderstandingError("risk level is invalid")
|
||||
if not isinstance(self.basis, RiskBasis):
|
||||
raise ObjectUnderstandingError("risk basis is invalid")
|
||||
_identifier(self.policy_id, "risk policy id")
|
||||
_confidence(self.confidence, "risk confidence")
|
||||
_unique_enum_values(self.responses, "advisory responses", allow_empty=True)
|
||||
_unique_identifiers(self.evidence_ids, "risk evidence ids", allow_empty=True)
|
||||
_unique_identifiers(self.reason_codes, "risk reason codes")
|
||||
if self.level is RiskLevel.UNKNOWN:
|
||||
if self.basis is not RiskBasis.UNKNOWN or self.confidence != 0.0:
|
||||
raise ObjectUnderstandingError(
|
||||
"unknown risk must retain unknown basis and zero confidence"
|
||||
)
|
||||
elif self.basis is RiskBasis.UNKNOWN:
|
||||
raise ObjectUnderstandingError("risk claim requires an explicit basis")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"policy_id": self.policy_id,
|
||||
"level": self.level.value,
|
||||
"confidence": self.confidence,
|
||||
"basis": self.basis.value,
|
||||
"responses": [item.value for item in self.responses],
|
||||
"evidence_ids": list(self.evidence_ids),
|
||||
"reason_codes": list(self.reason_codes),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> AdvisoryRiskAssessment:
|
||||
document = _object(value, "advisory risk")
|
||||
_exact_keys(
|
||||
document,
|
||||
{
|
||||
"policy_id",
|
||||
"level",
|
||||
"confidence",
|
||||
"basis",
|
||||
"responses",
|
||||
"evidence_ids",
|
||||
"reason_codes",
|
||||
},
|
||||
"advisory risk",
|
||||
)
|
||||
return cls(
|
||||
policy_id=_string(document, "policy_id"),
|
||||
level=_enum(RiskLevel, document.get("level"), "risk level"),
|
||||
confidence=_number(document, "confidence"),
|
||||
basis=_enum(RiskBasis, document.get("basis"), "risk basis"),
|
||||
responses=tuple(
|
||||
_enum(AdvisoryResponse, item, "advisory response")
|
||||
for item in _array(document, "responses")
|
||||
),
|
||||
evidence_ids=_string_tuple(document.get("evidence_ids"), "evidence ids"),
|
||||
reason_codes=_string_tuple(document.get("reason_codes"), "reason codes"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ObjectUnderstanding:
|
||||
"""Complete machine projection: geometry, semantics, state, risk and lineage."""
|
||||
|
||||
understanding_id: str
|
||||
vocabulary_id: str
|
||||
generated_monotonic_ns: int
|
||||
observation: ObstacleObservation
|
||||
hypotheses: tuple[SemanticHypothesis, ...]
|
||||
semantic: SemanticDecision
|
||||
state: ObjectStateEstimate
|
||||
risk: AdvisoryRiskAssessment
|
||||
provenance: tuple[EvidenceProvenance, ...]
|
||||
authority: FalseAuthority = FalseAuthority()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_identifier(self.understanding_id, "understanding id")
|
||||
_identifier(self.vocabulary_id, "semantic vocabulary id")
|
||||
_nonnegative_integer(self.generated_monotonic_ns, "generation time")
|
||||
if not isinstance(self.observation, ObstacleObservation):
|
||||
raise ObjectUnderstandingError("object geometry observation is invalid")
|
||||
if (
|
||||
not isinstance(self.hypotheses, tuple)
|
||||
or any(not isinstance(item, SemanticHypothesis) for item in self.hypotheses)
|
||||
or len(self.hypotheses) > MAX_SEMANTIC_HYPOTHESES
|
||||
):
|
||||
raise ObjectUnderstandingError("semantic hypothesis set is invalid")
|
||||
if tuple(item.rank for item in self.hypotheses) != tuple(
|
||||
range(1, len(self.hypotheses) + 1)
|
||||
):
|
||||
raise ObjectUnderstandingError("semantic hypotheses must have contiguous ranks")
|
||||
class_ids = tuple(item.class_id for item in self.hypotheses)
|
||||
if len(set(class_ids)) != len(class_ids):
|
||||
raise ObjectUnderstandingError("semantic hypothesis classes must be unique")
|
||||
if any(
|
||||
self.hypotheses[index].confidence < self.hypotheses[index + 1].confidence
|
||||
for index in range(len(self.hypotheses) - 1)
|
||||
):
|
||||
raise ObjectUnderstandingError("semantic hypotheses must be ordered by confidence")
|
||||
if not isinstance(self.semantic, SemanticDecision):
|
||||
raise ObjectUnderstandingError("semantic decision is invalid")
|
||||
if not isinstance(self.state, ObjectStateEstimate):
|
||||
raise ObjectUnderstandingError("object state is invalid")
|
||||
if not isinstance(self.risk, AdvisoryRiskAssessment):
|
||||
raise ObjectUnderstandingError("advisory risk is invalid")
|
||||
if not isinstance(self.authority, FalseAuthority):
|
||||
raise ObjectUnderstandingError("object understanding authority is invalid")
|
||||
if not isinstance(self.provenance, tuple) or any(
|
||||
not isinstance(item, EvidenceProvenance) for item in self.provenance
|
||||
):
|
||||
raise ObjectUnderstandingError("evidence provenance is invalid")
|
||||
evidence_ids = tuple(item.evidence_id for item in self.provenance)
|
||||
if len(set(evidence_ids)) != len(evidence_ids):
|
||||
raise ObjectUnderstandingError("evidence provenance ids must be unique")
|
||||
if any(
|
||||
item.source_id != self.observation.source_id
|
||||
or item.frame_id != self.observation.frame_id
|
||||
for item in self.provenance
|
||||
):
|
||||
raise ObjectUnderstandingError("object evidence escaped its geometry source frame")
|
||||
known_evidence = set(evidence_ids)
|
||||
claimed_evidence = (
|
||||
{evidence_id for item in self.hypotheses for evidence_id in item.evidence_ids}
|
||||
| set(self.state.evidence_ids)
|
||||
| set(self.risk.evidence_ids)
|
||||
)
|
||||
if claimed_evidence - known_evidence:
|
||||
raise ObjectUnderstandingError("object claim references unknown evidence")
|
||||
if (
|
||||
self.semantic.resolution
|
||||
in {
|
||||
SemanticResolution.AMBIGUOUS,
|
||||
SemanticResolution.CONFLICT,
|
||||
}
|
||||
and not self.hypotheses
|
||||
):
|
||||
raise ObjectUnderstandingError("ambiguous or conflicting semantics require hypotheses")
|
||||
if self.semantic.resolution is SemanticResolution.CONFLICT and len(self.hypotheses) < 2:
|
||||
raise ObjectUnderstandingError("semantic conflict requires two hypotheses")
|
||||
if self.semantic.resolution is SemanticResolution.SELECTED:
|
||||
selected = next(
|
||||
(
|
||||
item
|
||||
for item in self.hypotheses
|
||||
if item.class_id == self.semantic.selected_class_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected is None or selected.confidence != self.semantic.selected_confidence:
|
||||
raise ObjectUnderstandingError(
|
||||
"selected semantics must match one ranked hypothesis"
|
||||
)
|
||||
|
||||
@property
|
||||
def occupancy_identity(self) -> str:
|
||||
"""Semantic or risk changes never replace the geometry-owned identity."""
|
||||
|
||||
return self.observation.occupancy_identity
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": OBJECT_UNDERSTANDING_SCHEMA,
|
||||
"understanding_id": self.understanding_id,
|
||||
"vocabulary_id": self.vocabulary_id,
|
||||
"generated_monotonic_ns": self.generated_monotonic_ns,
|
||||
"observation": self.observation.to_dict(),
|
||||
"hypotheses": [item.to_dict() for item in self.hypotheses],
|
||||
"semantic": self.semantic.to_dict(),
|
||||
"state": self.state.to_dict(),
|
||||
"risk": self.risk.to_dict(),
|
||||
"provenance": [item.to_dict() for item in self.provenance],
|
||||
"authority": self.authority.to_dict(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> ObjectUnderstanding:
|
||||
document = _contract(
|
||||
value,
|
||||
OBJECT_UNDERSTANDING_SCHEMA,
|
||||
{
|
||||
"understanding_id",
|
||||
"vocabulary_id",
|
||||
"generated_monotonic_ns",
|
||||
"observation",
|
||||
"hypotheses",
|
||||
"semantic",
|
||||
"state",
|
||||
"risk",
|
||||
"provenance",
|
||||
"authority",
|
||||
},
|
||||
"object understanding",
|
||||
)
|
||||
return cls(
|
||||
understanding_id=_string(document, "understanding_id"),
|
||||
vocabulary_id=_string(document, "vocabulary_id"),
|
||||
generated_monotonic_ns=_integer(document, "generated_monotonic_ns"),
|
||||
observation=ObstacleObservation.from_dict(document.get("observation")),
|
||||
hypotheses=tuple(
|
||||
SemanticHypothesis.from_dict(item) for item in _array(document, "hypotheses")
|
||||
),
|
||||
semantic=SemanticDecision.from_dict(document.get("semantic")),
|
||||
state=ObjectStateEstimate.from_dict(document.get("state")),
|
||||
risk=AdvisoryRiskAssessment.from_dict(document.get("risk")),
|
||||
provenance=tuple(
|
||||
EvidenceProvenance.from_dict(item) for item in _array(document, "provenance")
|
||||
),
|
||||
authority=FalseAuthority.from_dict(document.get("authority")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CanonicalObjectClass:
|
||||
"""One class in the bounded experimental object vocabulary."""
|
||||
|
||||
class_id: str
|
||||
parent_id: str | None
|
||||
aliases: tuple[str, ...]
|
||||
agency_prior: AgencyState
|
||||
risk_traits: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.agency_prior, AgencyState):
|
||||
raise ObjectUnderstandingError("canonical agency prior is invalid")
|
||||
_identifier(self.class_id, "canonical class id")
|
||||
_optional_identifier(self.parent_id, "canonical parent id")
|
||||
if not self.aliases:
|
||||
raise ObjectUnderstandingError("canonical class aliases must be nonempty")
|
||||
normalized = tuple(normalize_raw_label(item) for item in self.aliases)
|
||||
if len(set(normalized)) != len(normalized):
|
||||
raise ObjectUnderstandingError("canonical class aliases must be unique")
|
||||
_unique_identifiers(self.risk_traits, "class risk traits", allow_empty=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ObjectSemanticVocabulary:
|
||||
"""Loaded executable vocabulary profile; not a runtime ontology service."""
|
||||
|
||||
vocabulary_id: str
|
||||
status: str
|
||||
scope: str
|
||||
classes: tuple[CanonicalObjectClass, ...]
|
||||
max_hypotheses: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_identifier(self.vocabulary_id, "vocabulary id")
|
||||
if self.status != "experimental":
|
||||
raise ObjectUnderstandingError("object vocabulary must remain experimental")
|
||||
_identifier(self.scope, "vocabulary scope")
|
||||
if not 1 <= self.max_hypotheses <= MAX_SEMANTIC_HYPOTHESES:
|
||||
raise ObjectUnderstandingError("vocabulary top-k bound is invalid")
|
||||
if not self.classes:
|
||||
raise ObjectUnderstandingError("object vocabulary must declare classes")
|
||||
by_id = {item.class_id: item for item in self.classes}
|
||||
if len(by_id) != len(self.classes):
|
||||
raise ObjectUnderstandingError("canonical class ids must be unique")
|
||||
for item in self.classes:
|
||||
if item.parent_id is not None and item.parent_id not in by_id:
|
||||
raise ObjectUnderstandingError("canonical class parent is undeclared")
|
||||
seen = {item.class_id}
|
||||
parent_id = item.parent_id
|
||||
while parent_id is not None:
|
||||
if parent_id in seen:
|
||||
raise ObjectUnderstandingError("canonical class hierarchy is cyclic")
|
||||
seen.add(parent_id)
|
||||
parent_id = by_id[parent_id].parent_id
|
||||
aliases = [normalize_raw_label(alias) for item in self.classes for alias in item.aliases]
|
||||
if len(set(aliases)) != len(aliases):
|
||||
raise ObjectUnderstandingError("canonical aliases must be globally unique")
|
||||
|
||||
def class_definition(self, class_id: str) -> CanonicalObjectClass:
|
||||
for item in self.classes:
|
||||
if item.class_id == class_id:
|
||||
return item
|
||||
raise ObjectUnderstandingError("canonical class is undeclared")
|
||||
|
||||
def resolve_label(self, raw_label: str) -> str | None:
|
||||
normalized = normalize_raw_label(raw_label)
|
||||
for item in self.classes:
|
||||
if normalized in {normalize_raw_label(alias) for alias in item.aliases}:
|
||||
return item.class_id
|
||||
return None
|
||||
|
||||
def ancestors(self, class_id: str) -> tuple[str, ...]:
|
||||
by_id = {item.class_id: item for item in self.classes}
|
||||
current = self.class_definition(class_id)
|
||||
result: list[str] = []
|
||||
while current.parent_id is not None:
|
||||
result.append(current.parent_id)
|
||||
current = by_id[current.parent_id]
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def load_object_semantic_vocabulary(path: Path) -> ObjectSemanticVocabulary:
|
||||
"""Load and fail-close an executable vocabulary profile."""
|
||||
|
||||
try:
|
||||
document = json.loads(path.expanduser().resolve(strict=True).read_text("utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ObjectUnderstandingError("object vocabulary cannot be read") from exc
|
||||
root = _object(document, "object semantic vocabulary")
|
||||
_exact_keys(
|
||||
root,
|
||||
{
|
||||
"schema_version",
|
||||
"vocabulary_id",
|
||||
"status",
|
||||
"scope",
|
||||
"classes",
|
||||
"policies",
|
||||
},
|
||||
"object semantic vocabulary",
|
||||
)
|
||||
if root.get("schema_version") != OBJECT_SEMANTIC_VOCABULARY_SCHEMA:
|
||||
raise ObjectUnderstandingError("object vocabulary schema is incompatible")
|
||||
policies = _object(root.get("policies"), "object vocabulary policies")
|
||||
_exact_keys(
|
||||
policies,
|
||||
{
|
||||
"occupancy_independent_of_semantics",
|
||||
"unknown_preserves_obstacle",
|
||||
"class_prior_is_not_observed_state",
|
||||
"risk_is_advisory_only",
|
||||
"planner_command_authority",
|
||||
"max_hypotheses",
|
||||
},
|
||||
"object vocabulary policies",
|
||||
)
|
||||
required_true = (
|
||||
"occupancy_independent_of_semantics",
|
||||
"unknown_preserves_obstacle",
|
||||
"class_prior_is_not_observed_state",
|
||||
"risk_is_advisory_only",
|
||||
)
|
||||
if (
|
||||
any(policies.get(key) is not True for key in required_true)
|
||||
or policies.get("planner_command_authority") is not False
|
||||
):
|
||||
raise ObjectUnderstandingError("object vocabulary authority policy changed")
|
||||
classes: list[CanonicalObjectClass] = []
|
||||
for raw in _array(root, "classes"):
|
||||
row = _object(raw, "canonical object class")
|
||||
_exact_keys(
|
||||
row,
|
||||
{"class_id", "parent_id", "aliases", "agency_prior", "risk_traits"},
|
||||
"canonical object class",
|
||||
)
|
||||
classes.append(
|
||||
CanonicalObjectClass(
|
||||
class_id=_string(row, "class_id"),
|
||||
parent_id=_optional_string(row.get("parent_id"), "parent id"),
|
||||
aliases=_string_tuple(row.get("aliases"), "aliases"),
|
||||
agency_prior=_enum(AgencyState, row.get("agency_prior"), "agency prior"),
|
||||
risk_traits=_string_tuple(row.get("risk_traits"), "risk traits"),
|
||||
)
|
||||
)
|
||||
return ObjectSemanticVocabulary(
|
||||
vocabulary_id=_string(root, "vocabulary_id"),
|
||||
status=_string(root, "status"),
|
||||
scope=_string(root, "scope"),
|
||||
classes=tuple(classes),
|
||||
max_hypotheses=_integer(policies, "max_hypotheses"),
|
||||
)
|
||||
|
||||
|
||||
def validate_object_understanding(
|
||||
value: ObjectUnderstanding,
|
||||
vocabulary: ObjectSemanticVocabulary,
|
||||
) -> None:
|
||||
"""Validate canonical class references without changing the document."""
|
||||
|
||||
if not isinstance(value, ObjectUnderstanding):
|
||||
raise ObjectUnderstandingError("object understanding is invalid")
|
||||
if value.vocabulary_id != vocabulary.vocabulary_id:
|
||||
raise ObjectUnderstandingError("object understanding vocabulary changed")
|
||||
declared = {item.class_id for item in vocabulary.classes}
|
||||
referenced = {item.class_id for item in value.hypotheses}
|
||||
if value.semantic.selected_class_id is not None:
|
||||
referenced.add(value.semantic.selected_class_id)
|
||||
if referenced - declared:
|
||||
raise ObjectUnderstandingError("object understanding uses undeclared classes")
|
||||
if len(value.hypotheses) > vocabulary.max_hypotheses:
|
||||
raise ObjectUnderstandingError("object understanding exceeds vocabulary top-k")
|
||||
|
||||
|
||||
def normalize_raw_label(value: str) -> str:
|
||||
"""Normalize a provider label only for alias lookup, never as class truth."""
|
||||
|
||||
_label(value, "raw semantic label")
|
||||
normalized = re.sub(r"[_\s-]+", "_", value.strip().lower())
|
||||
if _NORMALIZED_LABEL.fullmatch(normalized) is None:
|
||||
raise ObjectUnderstandingError("raw semantic label cannot be normalized")
|
||||
return normalized
|
||||
|
||||
|
||||
def _contract(
|
||||
value: object,
|
||||
schema: str,
|
||||
fields: set[str],
|
||||
label: str,
|
||||
) -> dict[str, object]:
|
||||
document = _object(value, label)
|
||||
_exact_keys(document, {"schema_version", *fields}, label)
|
||||
if document.get("schema_version") != schema:
|
||||
raise ObjectUnderstandingError(f"{label} schema is incompatible")
|
||||
return document
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
||||
raise ObjectUnderstandingError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _exact_keys(document: dict[str, object], expected: set[str], label: str) -> None:
|
||||
if set(document) != expected:
|
||||
raise ObjectUnderstandingError(f"{label} fields are incompatible")
|
||||
|
||||
|
||||
def _array(document: dict[str, object], key: str) -> list[object]:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, list):
|
||||
raise ObjectUnderstandingError(f"{key} must be an array")
|
||||
return value
|
||||
|
||||
|
||||
def _string(document: dict[str, object], key: str) -> str:
|
||||
return _string_value(document.get(key), key)
|
||||
|
||||
|
||||
def _string_value(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ObjectUnderstandingError(f"{label} must be a nonempty string")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_string(value: object, label: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return _string_value(value, label)
|
||||
|
||||
|
||||
def _integer(document: dict[str, object], key: str) -> int:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ObjectUnderstandingError(f"{key} must be an integer")
|
||||
return value
|
||||
|
||||
|
||||
def _number(document: dict[str, object], key: str) -> float:
|
||||
value = document.get(key)
|
||||
if (
|
||||
not isinstance(value, (int, float))
|
||||
or isinstance(value, bool)
|
||||
or not math.isfinite(float(value))
|
||||
):
|
||||
raise ObjectUnderstandingError(f"{key} must be finite")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _optional_number(value: object, label: str) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if (
|
||||
not isinstance(value, (int, float))
|
||||
or isinstance(value, bool)
|
||||
or not math.isfinite(float(value))
|
||||
):
|
||||
raise ObjectUnderstandingError(f"{label} must be finite")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _identifier(value: str, label: str) -> None:
|
||||
if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
|
||||
raise ObjectUnderstandingError(f"{label} is not a safe identifier")
|
||||
|
||||
|
||||
def _optional_identifier(value: str | None, label: str) -> None:
|
||||
if value is not None:
|
||||
_identifier(value, label)
|
||||
|
||||
|
||||
def _label(value: str, label: str) -> None:
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or not value
|
||||
or value != value.strip()
|
||||
or len(value) > 120
|
||||
or any(ord(character) < 32 for character in value)
|
||||
):
|
||||
raise ObjectUnderstandingError(f"{label} is invalid")
|
||||
|
||||
|
||||
def _nonnegative_integer(value: object, label: str) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ObjectUnderstandingError(f"{label} must be a nonnegative integer")
|
||||
return value
|
||||
|
||||
|
||||
def _positive_integer(value: object, label: str) -> int:
|
||||
result = _nonnegative_integer(value, label)
|
||||
if result == 0:
|
||||
raise ObjectUnderstandingError(f"{label} must be positive")
|
||||
return result
|
||||
|
||||
|
||||
def _confidence(value: object, label: str) -> float:
|
||||
if (
|
||||
not isinstance(value, (int, float))
|
||||
or isinstance(value, bool)
|
||||
or not math.isfinite(float(value))
|
||||
or not 0.0 <= float(value) <= 1.0
|
||||
):
|
||||
raise ObjectUnderstandingError(f"{label} must be within [0, 1]")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _unique_identifiers(
|
||||
values: tuple[str, ...],
|
||||
label: str,
|
||||
*,
|
||||
allow_empty: bool = False,
|
||||
) -> None:
|
||||
if (not values and not allow_empty) or len(set(values)) != len(values):
|
||||
raise ObjectUnderstandingError(f"{label} must be unique")
|
||||
for value in values:
|
||||
_identifier(value, label)
|
||||
|
||||
|
||||
def _unique_enum_values(
|
||||
values: tuple[AdvisoryResponse, ...],
|
||||
label: str,
|
||||
*,
|
||||
allow_empty: bool,
|
||||
) -> None:
|
||||
if (not values and not allow_empty) or len(set(values)) != len(values):
|
||||
raise ObjectUnderstandingError(f"{label} must be unique")
|
||||
if any(not isinstance(value, AdvisoryResponse) for value in values):
|
||||
raise ObjectUnderstandingError(f"{label} are invalid")
|
||||
|
||||
|
||||
def _string_tuple(value: object, label: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
raise ObjectUnderstandingError(f"{label} must be an array")
|
||||
return tuple(_string_value(item, label) for item in value)
|
||||
|
||||
|
||||
def _enum[ENUM: StrEnum](
|
||||
enum_type: type[ENUM],
|
||||
value: object,
|
||||
label: str,
|
||||
) -> ENUM:
|
||||
if not isinstance(value, str):
|
||||
raise ObjectUnderstandingError(f"{label} must be a string")
|
||||
try:
|
||||
return enum_type(value)
|
||||
except ValueError as exc:
|
||||
raise ObjectUnderstandingError(f"{label} is incompatible") from exc
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_SEMANTIC_HYPOTHESES",
|
||||
"OBJECT_SEMANTIC_VOCABULARY_SCHEMA",
|
||||
"OBJECT_UNDERSTANDING_SCHEMA",
|
||||
"AdvisoryResponse",
|
||||
"AdvisoryRiskAssessment",
|
||||
"AgencyState",
|
||||
"CanonicalObjectClass",
|
||||
"EvidenceKind",
|
||||
"EvidenceProvenance",
|
||||
"ObjectSemanticVocabulary",
|
||||
"ObjectStateEstimate",
|
||||
"ObjectUnderstanding",
|
||||
"ObjectUnderstandingError",
|
||||
"RiskBasis",
|
||||
"RiskLevel",
|
||||
"SemanticDecision",
|
||||
"SemanticHypothesis",
|
||||
"SemanticResolution",
|
||||
"StateBasis",
|
||||
"load_object_semantic_vocabulary",
|
||||
"normalize_raw_label",
|
||||
"validate_object_understanding",
|
||||
]
|
||||
Reference in New Issue
Block a user