# C3.5 AdHoPRefiner — TRT engine + perspective preconditioning + conditional gate

**Task**: AZ-349_c3_5_adhop_refiner
**Name**: C3.5 `AdHoPRefiner` — production-default conditional refiner
**Description**: Implement `AdHoPRefiner`, the production-default `ConditionalRefiner` strategy. The strategy applies the conditional gate (`mr.reprojection_residual_px <= residual_threshold_px` → passthrough; otherwise → invoke), runs the OrthoLoC AdHoP TRT engine forward via the C7 `InferenceRuntime` to perform perspective preconditioning, recomputes inlier correspondences via the shared `RansacFilter` (AZ-282) on the preconditioned features, and writes the refined correspondences + new median residual back into a fresh `MatchResult` with `refinement_label = "adhop"`. On `RefinerBackboneError` (TRT exception, OOM, NaN, shape mismatch), the strategy CATCHES the exception inside `refine_if_needed`, logs ERROR + emits FDR record, and returns the input `MatchResult` unchanged with `refinement_label = "passthrough"` AND `was_invoked()` = True (Invariant 4 — passthrough fall-through). The strategy ALSO maintains a 60 s rolling invocation-rate counter for the WARN log when the rate exceeds `config.refiner.invocation_rate_warn_threshold` (per description.md § 9). Composition-root wired via the `build_refiner_strategy` factory (TBD AZ-? from the C3.5 Protocol task) when `config.refiner.strategy = "adhop"`.
**Complexity**: 5 points
**Dependencies**: AZ-348 (Protocol + factory + DTOs + errors + `PassthroughRefiner`), AZ-263_initial_structure, AZ-269_config_loader, AZ-282_ransac_filter (shared RANSAC helper), AZ-298_c7_tensorrt_runtime (AdHoP forward via TRT), AZ-299_c7_onnxrt_fallback (AdHoP forward via ONNX-RT fallback), AZ-281_engine_filename_schema (AdHoP engine self-describing filename), AZ-321_c10_engine_compiler (AdHoP engine compile path), AZ-266_log_module, AZ-272_fdr_record_schema
**Component**: c3_5_adhop (epic AZ-258 / E-C3.5)
**Tracker**: AZ-349
**Epic**: AZ-258 (E-C3.5)

### Document Dependencies

- `_docs/02_document/contracts/c3_5_adhop/conditional_refiner_protocol.md` — the public contract this task implements; producer/consumer split assigns this task as the AdHoPRefiner consumer.
- `_docs/02_document/components/05_c3_5_adhop/description.md` — § 1 architectural pattern; § 2 `ConditionalRefiner` interface + DTO enrichments (`refinement_label`, `refinement_added_latency_ms`); § 5 error handling (passthrough fall-through on `RefinerBackboneError`); § 7 caveats (threshold tuning); § 9 logging (per-frame DEBUG, rolling-rate WARN at 0.25, ERROR on backbone failure).
- `_docs/02_document/components/05_c3_5_adhop/tests.md` — C3.5-IT-01 (residual reduction ≥ 90% of invocations); C3.5-IT-02 (passthrough fall-through bit-identical); C3.5-IT-03 (invocation rate < 0.30 on Derkachi normal); C3.5-PT-01 (latency budget).
- `_docs/02_document/contracts/shared_helpers/ransac_filter.md` — RANSAC filtering API.
- `_docs/02_document/contracts/c7_inference/inference_runtime_protocol.md` — `InferenceRuntime` API.
- `_docs/02_document/architecture.md` — ADR-001, R10 (latency under thermal throttle).

## Problem

Without this task, C3.5 has no real refinement path; the system would always run the `PassthroughRefiner`, defeating the AC-2.2 hard-frame portion (cross-domain MRE < 2.5 px after refinement). The conditional gate also keeps AC-4.1's E2E latency budget intact on the steady-state path (passthrough costs ~0.5 ms while the AdHoP invocation costs ~30–90 ms; running it every frame would blow the F3 budget). Without proper passthrough fall-through on `RefinerBackboneError`, a single TRT failure would cascade into a frame skip → C5 visual_propagated → false health degradation → false F6 satellite re-localisation trigger.

## Outcome

- `src/gps_denied_onboard/components/c3_5_adhop/adhop_refiner.py` defining:
  - `AdHoPRefiner` class implementing the `ConditionalRefiner` Protocol.
  - Constructor: `__init__(self, runtime: InferenceRuntime, ransac_filter: RansacFilter, fdr_client: FdrClient, config: RefinerConfig, adhop_engine_handle)`. The `adhop_engine_handle` is the loaded TRT engine returned by `runtime.load_engine(...)` at `create` time.
  - `_was_invoked: bool` (private flag; reset to False at every call).
  - `_invocation_window`: a rolling-60s counter of (timestamp_ns, was_invoked) tuples for the rate WARN log. Bounded to ~180 entries at 3 Hz; pruned by lazy expiry.
  - `refine_if_needed(frame, mr, residual_threshold_px) -> MatchResult`:
    1. `start_ns = time.monotonic_ns()`.
    2. Defensive: `if residual_threshold_px <= 0: raise ValueError(...)`.
    3. **Gate**: `if mr.reprojection_residual_px <= residual_threshold_px:`
       - `self._was_invoked = False`.
       - Append `(now, False)` to `_invocation_window`; prune.
       - Return `mr` unchanged (object reference; refinement_label stays at default `"passthrough"`).
    4. **Invoked path**: `self._was_invoked = True`. Append `(now, True)` to `_invocation_window`; prune.
    5. Compute current invocation rate over the 60s window. If rate > `config.refiner.invocation_rate_warn_threshold` (default 0.25), emit ONE WARN log per minute (rate-limited): `kind="c3_5.refiner.invocation_rate_high"` with `{rate, target_threshold, frame_id}`.
    6. **Try**:
       a. Decode + preprocess the nav-camera frame ONCE.
       b. For the BEST candidate only (`mr.per_candidate[mr.best_candidate_idx]`): decode + preprocess the candidate tile pixels via the existing `tile_pixels_handle`.
       c. Run AdHoP TRT engine forward (single forward; output: perspective-preconditioned correspondences). Implementation detail: AdHoP takes the original correspondences AS INPUT and produces refined correspondences with the perspective preconditioning applied; this is method-agnostic per OrthoLoC.
       d. RANSAC + median residual via `self._ransac_filter.filter(refined_correspondences, threshold_px=...)` — same helper as C3.
       e. Build a NEW `CandidateMatchSet` for the best candidate with the refined `inlier_correspondences`, refined `inlier_count`, refined `per_candidate_residual_px`. Other candidates in `per_candidate` left unchanged.
       f. Build a NEW `MatchResult` via `dataclasses.replace(mr, per_candidate=[...new best, *unchanged others], reprojection_residual_px=new_best_residual, refinement_label="adhop", refinement_added_latency_ms=elapsed_ms)`.
       g. INFO log `kind="c3_5.refiner.frame_done"` (DEBUG-level per description.md § 9; promote to INFO only on the SUCCESS-after-many-failures recovery transition; details in implementation).
       h. FDR `refiner.frame_done` record with `{frame_id, was_invoked: true, refinement_label: "adhop", refinement_added_latency_ms, pre_residual_px, post_residual_px, inlier_count_before, inlier_count_after}`.
       i. Return the new `MatchResult`.
    7. **Except `RefinerBackboneError`** (or any TRT-runtime failure that the `InferenceRuntime` raises and a guard layer maps to `RefinerBackboneError` per ADR-001 error contract):
       - ERROR log `kind="c3_5.refiner.backbone_error"` with `{frame_id, exc_type, phase}`.
       - FDR `refiner.frame_done` record with `{frame_id, was_invoked: true, refinement_label: "passthrough", refinement_added_latency_ms: elapsed_ms_so_far, error: true}`.
       - Return the input `mr` unchanged (refinement_label stays at default `"passthrough"`).
       - **Critical**: the exception is NEVER re-raised out of `refine_if_needed` (Invariant 4). Other exception types (e.g., `MemoryError`) ARE re-raised because the runtime contract is that only the documented `RefinerBackboneError` class is convertible to passthrough.
  - `was_invoked() -> bool`: return `self._was_invoked`.
  - Module-level `create(config, ransac_filter, inference_runtime) -> ConditionalRefiner`:
    1. `adhop_weights_path = config.refiner.adhop_weights_path` (TRT engine produced by AZ-321).
    2. Load AdHoP engine via `inference_runtime.load_engine(adhop_weights_path)` — happens ONCE at startup.
    3. Construct `AdHoPRefiner(runtime=inference_runtime, ransac_filter=ransac_filter, fdr_client=..., config=config.refiner, adhop_engine_handle=adhop_engine_handle)`.
- Composition-root wiring path: when `config.refiner.strategy == "adhop"` AND the AdHoP engine compile artifact is present, the AZ-? factory invokes `adhop_refiner.create(...)`.
- All FDR + log records have `refinement_label` set per the post-refinement outcome.

## Scope

### Included
- `AdHoPRefiner` implementation per the `ConditionalRefiner` Protocol.
- Conditional gate (`<=` semantics, inclusive, deterministic).
- AdHoP TRT engine forward via C7 `InferenceRuntime`.
- RANSAC + median residual recomputation via the shared `RansacFilter`.
- Passthrough fall-through on `RefinerBackboneError` (Invariant 4).
- Bit-identical correspondence preservation when the gate decides passthrough (Invariant 5).
- 60 s rolling invocation-rate counter + WARN log emission (rate-limited to ONE warning per minute).
- Per-frame FDR record emission with full provenance fields.
- Composition-root wiring path.
- Unit tests covering Invariants 1–9 + gate semantics + passthrough fall-through + invocation-rate accounting.

### Excluded
- The Protocol + DTO extension + errors + factory + `PassthroughRefiner` — owned by AZ-? (Protocol task).
- The `RansacFilter` helper — already AZ-282.
- The C7 `InferenceRuntime` — owned by AZ-297..AZ-300.
- The AdHoP TRT engine compile path — owned by AZ-321.
- C3.5-IT-01..03 + C3.5-PT-01 component-internal acceptance tests — deferred to E-BBT (AZ-262). Unit tests in this task cover the per-method invariant smoke.
- The `OrthoLoC` upstream code drop — vendored separately (Plan-phase pin); this task consumes the runtime-loadable AdHoP engine only.

## Acceptance Criteria

**AC-1: Protocol conformance**
`AdHoPRefiner` instance passes `isinstance(refiner, ConditionalRefiner)`.

**AC-2: Gate inclusive semantics (Invariant 3)**
Given `mr.reprojection_residual_px == residual_threshold_px` (equality)
When `refine_if_needed` is called
Then the strategy returns `mr` unchanged AND `was_invoked()` is False.
And: `mr.reprojection_residual_px = residual_threshold_px + 1e-6` triggers the invoked path AND `was_invoked()` is True.

**AC-3: Successful AdHoP refinement produces enriched `MatchResult`**
Given a `MatchResult` with `reprojection_residual_px = 5.0`, `residual_threshold_px = 2.5`, and a stub AdHoP engine that produces refined correspondences with median residual `1.2`
When `refine_if_needed` is called
Then the output `MatchResult` has:
- `refinement_label == "adhop"`
- `reprojection_residual_px ≈ 1.2`
- `refinement_added_latency_ms > 0`
- The best candidate's `inlier_correspondences` reflects the refined coordinates (NOT byte-identical to input).
- `was_invoked()` returns True.

**AC-4: Passthrough fall-through on `RefinerBackboneError` (Invariant 4)**
Given a stub AdHoP engine that raises `RefinerBackboneError` on forward
When `refine_if_needed` is called with a residual above threshold
Then:
- The output `MatchResult` IS the input `mr` (object reference).
- `refinement_label == "passthrough"` (default value preserved).
- `was_invoked()` returns True (the attempt counted).
- ERROR log `kind="c3_5.refiner.backbone_error"` emitted ONCE.
- FDR `refiner.frame_done` record emitted with `error: true`.
- The exception is NEVER re-raised (test asserts no exception escapes).

**AC-5: Other exception types DO re-raise**
Given a stub AdHoP engine that raises `MemoryError` (NOT `RefinerBackboneError`)
When `refine_if_needed` is called
Then `MemoryError` propagates out (not converted to passthrough). Documents the closed-set semantics of Invariant 4.

**AC-6: Bit-identical correspondences on gate-decided passthrough (Invariant 5)**
Given `mr.reprojection_residual_px = 1.0` AND `residual_threshold_px = 2.5` (gate → passthrough)
When `refine_if_needed` is called
Then for every candidate `i`, `out.per_candidate[i].inlier_correspondences IS mr.per_candidate[i].inlier_correspondences` (same object reference). Output's `refinement_label` stays at default `"passthrough"`.

**AC-7: `_invocation_window` accuracy**
Given a sequence of 30 frames at 3 Hz with 10 invoked + 20 gate-passthroughs
When `refine_if_needed` has been called for each
Then the strategy's internal rate calculation reports `10/30 == 0.333` over the 10s window.

**AC-8: Invocation-rate WARN is rate-limited**
Given the invocation rate exceeds `invocation_rate_warn_threshold = 0.25` for 60 consecutive seconds
When the strategy is exercised over that period
Then ONE (and only ONE) WARN log per 60 s window is emitted (rate-limited).

**AC-9: `was_invoked()` semantics matches Invariant 8**
- Gate-decided passthrough → False.
- AdHoP-success → True.
- AdHoP-fall-through (backbone error) → True.

**AC-10: Composition-root wiring**
Given `config.refiner.strategy = "adhop"` AND the AdHoP engine artifact path is valid
When `compose_root(config)` runs
Then an `AdHoPRefiner` instance is wired; ONE INFO log `kind="c3_5.refiner.ready"` with `{strategy: "adhop", residual_threshold_px}` is emitted; the strategy holds reference to the SAME `RansacFilter` instance as C3 + C4 (identity-shared).

**AC-11: FDR `refiner.frame_done` shape**
Every `refine_if_needed` call (regardless of gate decision) emits exactly ONE FDR record with the documented field set.

## Non-Functional Requirements

**Performance**
- `refine_if_needed` p95 (gate-passthrough) ≤ 1 ms (per C3.5-PT-01 with margin).
- `refine_if_needed` p95 (AdHoP-invoked) ≤ 90 ms target / 150 ms hard limit (per C3.5-PT-01).
- `_invocation_window` update p99 ≤ 5 µs (deque-based or ring-buffer; pruning is amortised O(1)).

**Compatibility**
- AdHoP engine file format owned by C10 + C7.

**Reliability**
- Single-thread by contract (Invariant 1); no internal locking.
- TRT errors NEVER cascade out of `refine_if_needed` (Invariant 4).

## Unit Tests

| AC Ref | What to Test | Required Outcome |
|--------|--------------|------------------|
| AC-1 | Protocol conformance | Passes `isinstance` |
| AC-2 | Gate `<=` inclusive | equality → passthrough; +ε → invoked |
| AC-3 | AdHoP success path | `refinement_label="adhop"`; refined residual; latency > 0 |
| AC-4 | Backbone error → passthrough fall-through | Same-reference output; ERROR log; no escape |
| AC-5 | Other exceptions re-raise | `MemoryError` propagates |
| AC-6 | Gate passthrough byte-identical | Same object reference |
| AC-7 | Invocation rate accuracy | `10/30 == 0.333` |
| AC-8 | WARN log rate-limited | One per 60 s |
| AC-9 | `was_invoked()` semantics | Three cases match |
| AC-10 | Composition wiring | Ready log; identity-shared `RansacFilter` |
| AC-11 | FDR record shape | Exactly one per call; documented fields |

## Constraints

- **Single-threaded** by contract; the `_invocation_window` is non-thread-safe by design.
- **AdHoP backbone errors NEVER propagate out** of `refine_if_needed` (Invariant 4). Other exception types DO propagate (AC-5 closed-set semantics).
- **Gate uses `<=` not `<`** — equality at the threshold means passthrough (deterministic, documented).
- **Engine load is ONCE at `create` time** — never lazy on first frame; ensures the F1 takeoff cold-start cost is bounded and deterministic.
- **`RansacFilter` is constructor-injected, identity-shared** with C3 + C4 — composition root constructs ONE instance.
- **WARN log is rate-limited to ONE per 60 s** — avoids log flooding when the threshold is mis-tuned.

## Risks & Mitigation

**Risk 1: AdHoP TRT engine OOM on Jetson under thermal-throttle conditions**
- *Mitigation*: passthrough fall-through (Invariant 4) on `RefinerBackboneError`; downstream pose estimator handles the same `MatchResult` shape regardless of refinement outcome. The repeated-OOM scenario is detectable via the `_invocation_window` "almost-always-error" pattern → operator-tooling pre-flight raises the residual threshold per R10.

**Risk 2: AdHoP refinement produces a result with HIGHER residual than the input** (degenerate frames)
- *Mitigation*: per the contract, the strategy returns whatever AdHoP produces; downstream pose estimator gates on the new residual. C3.5-IT-01 measures the improvement rate (≥ 90%); the remaining ≤10% are accepted (documented).

**Risk 3: `_invocation_window` deque grows unbounded if `refine_if_needed` is called from the wrong thread / without pruning**
- *Mitigation*: lazy prune at every append; bounded by ~180 entries at 3 Hz × 60 s. Memory upper bound is ~6 KB. Single-thread invariant covers the racing concern.

**Risk 4: AdHoP weights / engine file path missing at startup**
- *Mitigation*: `create(...)` raises `RefinerConfigError` (caught at composition root) before the strategy is wired in. F1 takeoff abort follows the existing error handling pattern.

## Runtime Completeness

- **Named capability**: `AdHoPRefiner` — production-default `ConditionalRefiner` (architecture / E-C3.5 / `solution.md` / R10).
- **Production code that must exist**: real `AdHoPRefiner` calling real C7 `InferenceRuntime` with real TRT-compiled AdHoP engine; real shared `RansacFilter` for inlier filtering + median residual; real conditional gate at the documented `<=` semantics; real passthrough fall-through on `RefinerBackboneError`; real 60 s rolling invocation-rate counter + rate-limited WARN log; real per-frame FDR record emission; real composition-root wiring.
- **Allowed external stubs**: `FakeInferenceRuntime`, `FakeRansacFilter`, `FakeFdrClient` for tests.
- **Unacceptable substitutes**: a Python+NumPy AdHoP forward (would not satisfy the latency budget); using a different RANSAC implementation; allowing `RefinerBackboneError` to propagate out (Invariant 4 violation); deferring the `_invocation_window` to a future task (C3.5-IT-03 fails without it); using `<` instead of `<=` for the gate (would create a deterministic-replay divergence on equality-ish frames).
