# C4 PoseEstimator Protocol + Factory + DTOs + Composition **Task**: AZ-355_c4_pose_protocol **Name**: C4 `PoseEstimator` Protocol + Factory + DTOs + Composition **Description**: Define the public `PoseEstimator` Protocol (PEP 544 `@runtime_checkable`), the C4 DTOs (`PoseEstimate`, `LatLonAlt`, `Quat`, `CovarianceMode` enum, `PoseSourceLabel` enum), the error hierarchy (`PoseEstimatorError`, `PnpFailureError`, `CovarianceDegradedWarning` — note: a `Warning` subclass NOT an `Exception`), and the composition-root factory `build_pose_estimator(config, ransac_filter, wgs_converter, se3_utils, isam2_graph_handle) -> PoseEstimator`. The shared `RansacFilter` (AZ-282), `WgsConverter` (AZ-279), and `SE3Utils` (AZ-277) helpers are constructor-injected. The C5 iSAM2 graph handle is constructor-injected from the runtime root (ADR-003 shared substrate; C4 NEVER owns the graph). This task delivers the foundational scaffolding the Marginals (TBD AZ-?) and Hybrid (TBD AZ-?) tasks depend on; no PnP / GTSAM / Jacobian implementation is in scope here. **Complexity**: 3 points **Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-270_compose_root, AZ-282_ransac_filter, AZ-279_wgs_converter, AZ-277_se3_utils, AZ-266_log_module **Component**: c4_pose (epic AZ-259 / E-C4) **Tracker**: AZ-355 **Epic**: AZ-259 (E-C4) ### Document Dependencies - `_docs/02_document/contracts/c4_pose/pose_estimator_protocol.md` — the public contract this task implements. - `_docs/02_document/components/06_c4_pose/description.md` — § 1 architectural pattern (single concrete impl behind a Protocol); § 2 `PoseEstimator` interface + `PoseEstimate` DTO; § 5 error handling; § 9 logging. - `_docs/02_document/module-layout.md` — `c4_pose` Per-Component Mapping; joint native ownership note (C5 owns `cpp/gtsam_bindings/`; C4 imports READ-ONLY). - `_docs/02_document/architecture.md` — ADR-001, ADR-003 (shared GTSAM substrate), ADR-006 (Jacobian fallback acceptance), ADR-009. - `_docs/02_document/contracts/c3_5_adhop/conditional_refiner_protocol.md` — `MatchResult` shape consumed at input. ## Problem Without this task, the C4 PnP/Marginals consumers (TBD) and the downstream C5 StateEstimator (AZ-260) would each invent their own ad-hoc interface for the pose-estimator boundary, breaking ADR-001 (Strategy + composition root) and ADR-009 (interface-first DI). The DTO surface (`PoseEstimate`, `CovarianceMode`, `PoseSourceLabel`) is also consumed by C5 + C8 + C13 — defining it once in `_types/pose.py` prevents drift across consumers. The error hierarchy choice (`CovarianceDegradedWarning` as a `Warning` subclass, NOT `Exception`) is also non-obvious and needs to be codified before any caller writes a `try/except` around `estimate(...)` and accidentally swallows the warning. ## Outcome - `src/gps_denied_onboard/components/c4_pose/interface.py` defining the `PoseEstimator` Protocol with `estimate` + `current_covariance_mode`. - `src/gps_denied_onboard/components/c4_pose/__init__.py` re-exporting `PoseEstimator`, `PoseEstimate`, `EstimatorOutput` (per module-layout `c4_pose` Public API row), `CovarianceMode`, `PoseSourceLabel`. - `src/gps_denied_onboard/_types/pose.py` defining the frozen + slotted dataclasses `LatLonAlt`, `Quat`, `PoseEstimate`, plus the `CovarianceMode` and `PoseSourceLabel` enums. - `src/gps_denied_onboard/components/c4_pose/errors.py` defining `PoseEstimatorError`, `PnpFailureError`, and `CovarianceDegradedWarning` (the latter as a `Warning` subclass). - `src/gps_denied_onboard/runtime_root/pose_factory.py` exporting `build_pose_estimator(config, ransac_filter, wgs_converter, se3_utils, isam2_graph_handle) -> PoseEstimator`. Single-strategy resolution table (`"opencv_gtsam"` only). Lazy-imports the concrete module via `importlib.import_module(...)` for symmetry with other component factories. - Composition-root `compose_root` extension: invoke `build_pose_estimator` AFTER `RansacFilter`, `WgsConverter`, `SE3Utils`, and the C5 iSAM2 graph handle are constructed; bind the result to the SAME ingest thread as C5. - Config schema extension to AZ-269: `config.pose.strategy` (default `"opencv_gtsam"`), `config.pose.ransac_iterations` (default 200), `config.pose.ransac_reprojection_threshold_px` (default 4.0), `config.pose.thermal_throttle_threshold_celsius` (default 75.0; informational). - INFO log on every successful `build_pose_estimator`: `kind="c4.pose.strategy_loaded"` with strategy name + thresholds. - `ISam2GraphHandle` Protocol stub at `src/gps_denied_onboard/components/c4_pose/_isam2_handle.py` (READ-ONLY view; allows C5 to provide a duck-typed handle without prematurely defining C5's graph internals). Documents the ONE method C4 needs: `get_pose_key(frame_id) -> int`. ## Scope ### Included - The `PoseEstimator` Protocol with `estimate` + `current_covariance_mode`. - The five DTOs / enums in `_types/pose.py`. - The error hierarchy (note: `CovarianceDegradedWarning` is a `Warning`, NOT `Exception`). - The composition-root factory. - Config schema extension. - The `ISam2GraphHandle` Protocol stub (consumed-side surface only; concrete impl owned by E-C5 / AZ-260). - Composition-root wiring path. - Unit tests covering Protocol conformance, DTO immutability + slots, factory rejection on unknown strategy, factory acceptance, INFO log emission, error-hierarchy distinction (`CovarianceDegradedWarning` IS-A `Warning`, NOT `Exception`). ### Excluded - The `OpenCVGtsamPoseEstimator` concrete implementation — owned by the Marginals task (TBD). - The Jacobian fallback path + thermal switch — owned by the Hybrid task (TBD). - The GTSAM `Marginals` factor add — owned by the Marginals task. - The C5 iSAM2 graph implementation — owned by AZ-260. - C4-IT-01..04 + C4-PT-01 — deferred to E-BBT (AZ-262). - The C7 `ThermalState` source — owned by AZ-302. ## Acceptance Criteria **AC-1: Protocol conformance — `runtime_checkable`** A `FakePoseEstimator` test double implementing both methods passes `isinstance`; missing-method fakes fail. **AC-2: DTOs are frozen + slots** `LatLonAlt`, `Quat`, `PoseEstimate` are `frozen=True, slots=True`. Mutation raises `FrozenInstanceError`. `__slots__` non-empty. **AC-3: Enums have the documented values** `CovarianceMode` has exactly `MARGINALS` and `JACOBIAN` (string-valued). `PoseSourceLabel` has exactly `SATELLITE_ANCHORED`, `VISUAL_PROPAGATED`, `DEAD_RECKONED`. **AC-4: `CovarianceDegradedWarning` IS-A `Warning`, NOT `Exception`** `issubclass(CovarianceDegradedWarning, Warning)` is True; `issubclass(CovarianceDegradedWarning, Exception)` is False (in Python's hierarchy `Warning` is NOT an `Exception` subclass at the catch-by-default level — `try/except Exception` does NOT catch warnings emitted via `warnings.warn`). Test verifies that a `try/except Exception` around `warnings.warn(CovarianceDegradedWarning(...))` does NOT catch the warning. **AC-5: `PnpFailureError` IS-A `Exception`** `issubclass(PnpFailureError, PoseEstimatorError)` AND `issubclass(PnpFailureError, Exception)` both True. **AC-6: Factory rejects unknown strategy** `config.pose.strategy = "garbage"` → `PoseEstimatorConfigError` raised; ERROR log emitted. **AC-7: Factory accepts `"opencv_gtsam"` and emits INFO log** Successful construction; ONE INFO log `kind="c4.pose.strategy_loaded"` with structured fields. **AC-8: Public API surface — `__init__.py` re-exports** `from gps_denied_onboard.components.c4_pose import PoseEstimator, PoseEstimate, CovarianceMode, PoseSourceLabel` resolves; `_isam2_handle` and internal classes NOT in `__all__`. **AC-9: Strategy bound to single ingest thread (same thread as C5)** Composition root binds C4 + C5 to the same thread; binding C4 to a different thread raises `RuntimeError`. **AC-10: `ISam2GraphHandle` Protocol stub conforms to `runtime_checkable`** A test double implementing `get_pose_key(frame_id) -> int` passes `isinstance(fake, ISam2GraphHandle)`. ## Non-Functional Requirements **Performance** - `build_pose_estimator` p99 ≤ 50 ms. **Compatibility** - Protocol method-signature changes are MAJOR; DTO field additions are MINOR. **Reliability** - Single-thread invariant enforced at composition-root binding (AC-9). - `CovarianceDegradedWarning` semantics codified — callers MUST use `warnings.catch_warnings` if they need to programmatically observe warnings. ## Unit Tests | AC Ref | What to Test | Required Outcome | |--------|--------------|------------------| | AC-1 | Protocol conformance | Fake passes; partial fake fails | | AC-2 | DTO immutability + slots | `FrozenInstanceError`; non-empty `__slots__` | | AC-3 | Enum values | Documented values present | | AC-4 | Warning vs Exception hierarchy | `try/except Exception` does NOT catch the warning | | AC-5 | `PnpFailureError` IS-A Exception | `try/except Exception` catches | | AC-6 | Unknown-strategy rejection | `PoseEstimatorConfigError`; ERROR log | | AC-7 | Successful factory load | INFO log with structured fields | | AC-8 | Public API re-exports | Public names resolve; internals not | | AC-9 | Single-thread binding | Second binding (different thread) raises `RuntimeError` | | AC-10 | `ISam2GraphHandle` Protocol | Fake passes | ## Constraints - **`@runtime_checkable` MUST be used** on both `PoseEstimator` and `ISam2GraphHandle`. - **DTOs MUST be `frozen=True, slots=True`.** - **`CovarianceDegradedWarning` MUST be a `Warning` subclass** (NOT `Exception`). Documents R10 acceptance. - **The factory does NOT instantiate `RansacFilter`, `WgsConverter`, `SE3Utils`, or the iSAM2 graph handle** — runtime root constructs ONCE and passes references. - **Single-thread binding** with C5 is enforced at composition root; ADR-003 shared GTSAM substrate is non-thread-safe. ## Risks & Mitigation **Risk 1: `ISam2GraphHandle` Protocol stub couples C4 prematurely to C5** - *Mitigation*: the stub defines ONLY `get_pose_key(frame_id) -> int` — the minimal surface C4 needs to attach factors. C5 (AZ-260) implements the concrete handle; if C5's graph design changes, the stub may grow but the Protocol surface stays stable as long as C4's needs don't change. **Risk 2: `CovarianceDegradedWarning` semantics confuse callers expecting an exception-like flow** - *Mitigation*: documented in the contract (Invariant 9) AND codified in AC-4. Description.md § 5 also states explicitly "NOT a fatal condition". **Risk 3: Composition root needs to construct C4 and C5 in lockstep (chicken-and-egg)** - *Mitigation*: ADR-003 documents this. The composition root constructs the iSAM2 graph FIRST (C5), then C4 (passing the handle). The Protocol task creates the stub Protocol so C5 can be implemented in parallel without C4 implementations being ready. ## Runtime Completeness - **Named capability**: `PoseEstimator` Protocol + `PoseEstimate` DTO + `ISam2GraphHandle` Protocol stub + composition-root factory. - **Production code that must exist**: real Protocol + real DTOs + real error hierarchy + real factory + real config schema extension + real composition-root wiring path that binds C4 to the same thread as C5. - **Allowed external stubs**: `FakePoseEstimator`, `FakeISam2GraphHandle`, `FakeRansacFilter`, `FakeWgsConverter`, `FakeSE3Utils` for tests. - **Unacceptable substitutes**: making `CovarianceDegradedWarning` an `Exception` subclass (would change warning semantics for all callers); skipping the `ISam2GraphHandle` stub (would force C4 implementations to import C5's concrete graph type → cycle). ## Contract This task produces/implements the contract at `_docs/02_document/contracts/c4_pose/pose_estimator_protocol.md`. Consumers MUST read that file — not this task spec — to discover the interface.