# Contract: VioStrategy Protocol

**Component**: c1_vio
**Producer task**: AZ-331 — `_docs/02_tasks/todo/AZ-331_c1_vio_strategy_protocol.md`
**Consumer tasks**:
- AZ-332 (OKVIS2 implementation — implements)
- AZ-333 (VINS-Mono implementation — implements)
- AZ-334 (KLT/RANSAC implementation — implements)
- AZ-335 (warm-start + F8 reboot recovery wiring — invokes `reset_to_warm_start`)
- E-C5 state estimator tasks under AZ-260 (consume `VioOutput`)
- E-C13 FDR writer tasks under AZ-248 (consume `VioHealth`)
- `runtime_root` composition under AZ-270 (selects strategy by config)
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10

## Purpose

Defines the typed boundary between the on-Jetson visual / visual-inertial odometry runtime and every downstream consumer (C5 state estimator, C13 FDR, runtime_root composition). The Protocol is the single point of contact that lets ADR-001 select between three concrete strategies (OKVIS2 production-default, VINS-Mono research-only, KLT/RANSAC mandatory simple-baseline) at startup without consumers caring which is wired. Per-frame DTOs (`VioOutput`, `VioHealth`) are frozen here so C5 fusion and C13 FDR records do not drift across implementations.

## Shape

### Protocol surface

The Protocol is `typing.Protocol` (PEP 544 structural typing) with `runtime_checkable=True`.

| Method | Signature | Throws / Errors | Blocking? |
|--------|-----------|-----------------|-----------|
| `process_frame` | `(frame: NavCameraFrame, imu: ImuWindow, calibration: CameraCalibration) -> VioOutput` | `VioInitializingError`, `VioDegradedError`, `VioFatalError` | sync (camera-ingest hot path; bound by C1-PT-01 latency budget) |
| `reset_to_warm_start` | `(hint: WarmStartPose) -> None` | `VioFatalError` (only on irrecoverable backend init failure) | sync |
| `health_snapshot` | `() -> VioHealth` | — | sync |
| `current_strategy_label` | `() -> Literal["okvis2", "vins_mono", "klt_ransac"]` | — | sync |

### DTOs

`NavCameraFrame`, `ImuSample`, `ImuWindow`, `ImuBias`, `CameraCalibration` are owned by `gps_denied_onboard._types.nav` (AZ-263). This contract owns `WarmStartPose`, `VioOutput`, `VioHealth`, `FeatureQuality`, and the `VioState` enum, all `@dataclass(frozen=True)` (or `enum.Enum`). `VioOutput` and `VioHealth` are placed in `_types/nav.py` for cross-component access; the `VioStrategy` Protocol itself lives in `components/c1_vio/interface.py`.

```python
from dataclasses import dataclass
from enum import Enum
from typing import Protocol, Literal, runtime_checkable
from gps_denied_onboard._types.nav import (
    NavCameraFrame, ImuWindow, ImuBias, CameraCalibration,
)
from gps_denied_onboard._types.geom import SE3, Vector3, Matrix6


class VioState(str, Enum):
    INIT = "init"
    TRACKING = "tracking"
    DEGRADED = "degraded"
    LOST = "lost"


@dataclass(frozen=True)
class WarmStartPose:
    body_T_world: SE3
    velocity_b: Vector3
    bias: ImuBias
    captured_at_ns: int  # monotonic_ns when the hint was produced


@dataclass(frozen=True)
class FeatureQuality:
    tracked: int
    new: int
    lost: int
    mean_parallax: float
    mre_px: float


@dataclass(frozen=True)
class VioOutput:
    frame_id: str  # echoes NavCameraFrame.frame_id
    relative_pose_T: SE3
    pose_covariance_6x6: Matrix6
    imu_bias: ImuBias
    feature_quality: FeatureQuality
    emitted_at_ns: int


@dataclass(frozen=True)
class VioHealth:
    state: VioState
    consecutive_lost: int
    bias_norm: float


@runtime_checkable
class VioStrategy(Protocol):
    def process_frame(
        self,
        frame: NavCameraFrame,
        imu: ImuWindow,
        calibration: CameraCalibration,
    ) -> VioOutput: ...

    def reset_to_warm_start(self, hint: WarmStartPose) -> None: ...

    def health_snapshot(self) -> VioHealth: ...

    def current_strategy_label(self) -> Literal["okvis2", "vins_mono", "klt_ransac"]: ...
```

### Error hierarchy

All under `gps_denied_onboard.components.c1_vio.errors`:

```
VioError                       (base; subclasses Exception)
├── VioInitializingError       (state == INIT; no VioOutput emitted; C5 falls back to FC IMU prior)
├── VioDegradedError           (state == DEGRADED; output IS still emitted with inflated covariance — see Invariants)
└── VioFatalError              (state == LOST after configurable consecutive frames; AC-5.2 fallback path)
```

`VioDegradedError` is documented but is **not raised** during normal `process_frame` returns when degraded — degraded operation returns a `VioOutput` with inflated covariance and `VioHealth.state = DEGRADED`. The error type exists for the rare case where degradation transitions to fatality and consumer wrappers want to catch the family.

### Composition-root selection

```python
def build_vio_strategy(config: Config, *, fdr_client: FdrClient) -> VioStrategy: ...
```

Lives at `src/gps_denied_onboard/runtime_root/vio_factory.py`. Selects the strategy by `config.vio.strategy` (`okvis2 | vins_mono | klt_ransac`) and respects compile-time `BUILD_*` gating (`BUILD_OKVIS2`, `BUILD_VINS_MONO`, `BUILD_KLT_RANSAC`). Requesting a strategy whose `BUILD_*` flag is OFF raises `StrategyNotAvailableError` at composition time (NOT at first frame). Lazy-imports the concrete strategy module so a Tier-0 workstation build without OKVIS2 native libs still composes successfully when only KLT/RANSAC is requested.

## Invariants

- **6×6 SPD covariance always returned**: `pose_covariance_6x6` is symmetric and positive-definite for every `VioOutput`. Implementations MUST NOT return a "tightened" covariance (smaller Frobenius norm) during a degradation event; honest covariance is the safety floor for AC-NEW-4 and AC-NEW-7. A test (covariance-monotonicity contract test, deferred to Step 9 / E-BBT) asserts this across all three strategies.
- **`frame_id` echo**: `VioOutput.frame_id` equals the input `NavCameraFrame.frame_id`. C5 relies on this for time-aligned factor insertion.
- **Single-threaded by contract**: each `VioStrategy` instance is bound to one writer thread (the camera ingest thread). Concurrent calls to `process_frame` on the same instance are undefined behaviour. The composition root binds one instance per ingest thread.
- **`reset_to_warm_start` is destructive**: clears the strategy's keyframe window, IMU integration state, and feature track buffer; subsequent `process_frame` calls re-initialise from the hint. Calling `reset_to_warm_start` mid-flight is allowed (F8 reboot recovery) but must not be issued concurrently with a `process_frame` call on the same instance.
- **`current_strategy_label()` is constant per instance**: returns the same string for the lifetime of the instance and matches `config.vio.strategy` exactly. The label is FDR-stamped on every `VioHealth` event for AC-NEW-3 audit.
- **No ambient state**: implementations MUST NOT read environment variables, wall clock, or filesystem inside `process_frame`; calibration arrives via constructor + per-call argument; logging uses the injected logger only.
- **Error envelope is closed**: `process_frame` raises only members of `VioError` (the family). Lower-level exceptions from OpenCV / OKVIS2 / VINS-Mono / GTSAM MUST be caught and rewrapped.

## Non-Goals

- IMU preintegration mathematics — owned by AZ-276 / `helpers.imu_preintegrator`. Strategies feed `ImuWindow` to the helper; they do NOT implement preintegration internally.
- Bias estimation policy — each strategy decides when to update its bias; the contract does not prescribe a schedule.
- WarmStartPose persistence (write to disk after takeoff, read after F8 reboot) — owned by the warm-start + F8 reboot recovery wiring task in this same epic. The contract here only defines the in-memory DTO and the `reset_to_warm_start` method.
- C5 fusion semantics — owned by E-C5; this contract only delivers `VioOutput`.
- Multi-camera strategies — out of scope this cycle (single nav-camera per ADR / RESTRICT-UAV-3).

## Versioning Rules

- **Breaking changes** (method renamed/removed, parameter type changed, return type changed, invariant relaxed) require a new major version + a deprecation pass through every consumer task in the header.
- **Non-breaking additions** (new optional method, new diagnostic accessor that does not mutate state, new `VioState` enum variant added at the end) require a minor version bump.
- The `VioState` enum is treated as a closed set for switch-style consumer code (C5 fusion); adding a new variant is a minor bump but consumers MUST handle the new state defensively (default branch → treat as LOST).

## Test Cases

| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| protocol-conformance | three concrete strategy classes | `isinstance(impl, VioStrategy)` returns True for each | Catches drift between impl and Protocol surface |
| frozen-dto-mutation | a constructed `VioOutput` instance and an attempt to set `.relative_pose_T` | `dataclasses.FrozenInstanceError` raised | Confirms DTOs are immutable |
| error-family-catchable | each of `VioInitializingError`, `VioDegradedError`, `VioFatalError` raised | `except VioError` catches all three; `except ValueError` does NOT | Confirms error envelope |
| factory-build-flag-respected | `config.vio.strategy = "vins_mono"` and `BUILD_VINS_MONO=OFF` | `StrategyNotAvailableError` raised at composition; `sys.modules` has no `vins_mono` entry | Confirms lazy-import gating |
| current-strategy-label-exact-match | each strategy constructed via factory with matching config | `current_strategy_label()` returns the literal config value | AC-NEW-3 audit gate |
| frame-id-echoed | a `NavCameraFrame` with a known UUID fed into `process_frame` | the returned `VioOutput.frame_id` equals the input UUID | C5 alignment invariant |
| covariance-spd | inspect 100 emitted `VioOutput.pose_covariance_6x6` matrices | every matrix is symmetric and positive-definite (eigenvalues > 0) | AC-1.4 floor |

## Change Log

| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract derived from `_docs/02_document/components/01_c1_vio/description.md` § 2 + AZ-254 epic child issue #1 | autodev decompose Step 2 |
