# Code Review Report

**Batch**: 58 — AZ-358 (C4 OpenCVGtsamPoseEstimator — Marginals) + AZ-361 (C4 Jacobian + thermal hybrid)
**Date**: 2026-05-14
**Verdict**: PASS_WITH_WARNINGS

## Scope

Changed files reviewed:

* `src/gps_denied_onboard/components/c4_pose/opencv_gtsam_estimator.py` (new, ~970 LOC)
* `src/gps_denied_onboard/components/c4_pose/_isam2_handle.py` (Protocol widened 1 → 5 methods)
* `src/gps_denied_onboard/components/c4_pose/config.py` (3 new fields + validation)
* `src/gps_denied_onboard/fdr_client/records.py` (2 new payload kinds)
* `src/gps_denied_onboard/runtime_root/pose_factory.py` (threads `fdr_client` + `clock`)
* `tests/unit/c4_pose/test_az358_361_opencv_gtsam_estimator.py` (new, 21 tests)
* `tests/unit/c4_pose/test_az355_pose_protocol.py` (Protocol fixture widened; lazy-import test corrected)
* `tests/unit/test_az272_fdr_record_schema.py` (payload fixture extended)
* `_docs/02_tasks/todo/AZ-358_c4_opencv_gtsam_marginals.md` (Option B + `PriorFactorPose3` deviation documented)

## Spec Compliance

### AZ-358 (Marginals path)

| AC | Verified by | Status |
|----|-------------|--------|
| AC-1 PnP success on synthetic correspondences | `test_az358_ac1_pnp_success_synthetic_within_tolerance` | PASS |
| AC-2 Degenerate inliers → `PnpFailureError` | `test_az358_ac2_insufficient_inliers_raises_pnp_failure` | PASS |
| AC-3 SPD covariance | `test_az358_ac3_4_5_marginals_success_invariants` | PASS |
| AC-4 `covariance_mode == MARGINALS` on success | same | PASS |
| AC-5 `source_label == SATELLITE_ANCHORED` | same | PASS |
| AC-6 WGS84 conversion uses shared `WgsConverter` | `test_az358_ac6_wgs_converter_injection` | PASS |
| AC-7 iSAM2 handle call sequence (no `add_factor`; 1× each of `get_pose_key`, `update`, `compute_marginals`) | `test_az358_ac7_isam2_handle_call_sequence` + `test_az358_update_carries_prior_factor_in_local_graph` | PASS |
| AC-8 (replaced by AZ-361 — throttle now runs Jacobian path, not `NotImplementedError`) | `test_az358_ac8_replacement_throttle_now_runs_jacobian_path` | PASS — deviation documented in the AZ-358 spec |
| AC-9 Non-SPD covariance defensive raise | `test_az358_ac9_non_spd_covariance_raises_pnp_failure` | PASS |
| AC-10 Composition-root wiring (INFO log + identity-shared deps) | `test_az358_ac10_composition_root_wiring_emits_ready_log` + `test_az358_ac10_create_module_factory_returns_protocol_instance` | PASS |
| AC-11 FDR `pose.frame_done` record shape | `test_az358_ac11_fdr_pose_frame_done_shape_on_success` | PASS |

Documented deviations from the original spec wording (now mirrored in the updated spec):

1. `PriorFactorPose3` instead of `GenericProjectionFactorCal3DS2` — mathematically equivalent on the pose marginal, avoids unbounded landmark variables, no new georef infra needed.
2. `handle.update(graph, values, None)` instead of no-arg `update()` — Option B (C4 builds the per-call diff itself; aligns with C5's `ISam2GraphHandleImpl`).
3. AC-8 supplanted by AZ-361 — no `NotImplementedError` placeholder is shipped.

### AZ-361 (Jacobian + thermal hybrid)

| AC | Verified by | Status |
|----|-------------|--------|
| AC-1 Per-frame mode dispatch | `test_az361_ac1_2_4_per_frame_mode_dispatch_alternates` | PASS |
| AC-2 Mode-switch latency ≤ 1 frame | same | PASS |
| AC-3 Jacobian covariance SPD | `test_az361_ac3_5_jacobian_success_invariants` | PASS |
| AC-4 `covariance_mode == JACOBIAN` | same + AC-1 test | PASS |
| AC-5 Source label SATELLITE_ANCHORED both paths | AC-3 + AC-5 tests | PASS |
| AC-6 `CovarianceDegradedWarning` via `warnings.warn` (not raised) | `test_az361_ac6_warning_emitted_via_warnings_warn` | PASS |
| AC-7 Warning rate-limited per 60 s window | `test_az361_ac7_8_warning_and_log_rate_limited` | PASS |
| AC-8 WARN log rate-limited similarly | same | PASS |
| AC-9 Marginals path unchanged by refactor | full AZ-358 AC suite still passes | PASS |
| AC-10 Near-singular Jacobian → defensive raise | `test_az361_ac10_near_singular_jacobian_raises_pnp_failure` | PASS |
| AC-11 Jacobian within ~5–10 % of Marginals (informational) | not tested — see F5 | PARTIAL (spec marks informational) |
| AC-12 Jacobian path skips iSAM2 factor add | `test_az361_ac12_jacobian_path_skips_isam2_update` | PASS |
| AC-13 FDR `mode` field distinguishes path | `test_az361_ac13_fdr_mode_field_distinguishes_path` | PASS |

### Contract verification

`_docs/02_document/contracts/c4_pose/pose_estimator_protocol.md` v1.0.0:

* Public `PoseEstimator.estimate` signature matches the implementation's `estimate(match_result, calibration, thermal_state) -> PoseEstimate`.
* Invariant 1 (single-threaded) — not enforced structurally (Python GIL + composition-root contract); documented.
* Invariant 5 (SPD covariance) — runtime `np.linalg.cholesky` check on both paths; on failure raises `PnpFailureError`.
* Invariant 6 (covariance_mode matches path actually taken) — set per-path before assemble.
* Invariant 7 (source_label always SATELLITE_ANCHORED on success) — hard-coded in `_assemble_pose_estimate`.
* Invariant 8 (last_satellite_anchor_age_ms sourced from C5) — comes via `handle.last_anchor_age_ms()`; C4 never computes it.
* Invariant 9 (only `PnpFailureError` escapes) — verified by error-path tests.

The `ISam2GraphHandle` Protocol stub at `c4_pose/_isam2_handle.py` is widened to five methods in lockstep with C5's superset implementation (per ADR-003). C5 is documented to already provide the superset, so no C5-side source change is required this batch.

## Findings

| # | Severity | Category | File:Line | Title |
|---|----------|----------|-----------|-------|
| 1 | Medium | Maintainability | `opencv_gtsam_estimator.py:254-345,381-435` | Duplicated FDR-error + ERROR-log + raise blocks across Marginals and Jacobian paths |
| 2 | Medium | Performance | `opencv_gtsam_estimator.py:617-657` | `_tile_pixels_to_enu_world` runs a Python `for` loop with per-point `pyproj` Transformer calls |
| 3 | Low | Style | `opencv_gtsam_estimator.py:289-296` | Bare `except Exception: pass` for GTSAM duplicate-key insert; could narrow to `RuntimeError` or pre-check via `Values.exists(key)` |
| 4 | Low | Performance | `opencv_gtsam_estimator.py:535-540, 575-586` | `cv2.projectPoints` called twice on the Marginals path (residuals + Jacobian); a single call returning both outputs would halve that cost |
| 5 | Low | Spec-Gap | `tests/unit/c4_pose/test_az358_361_opencv_gtsam_estimator.py` | AZ-361 AC-11 (Jacobian RMSE within 1.10× Marginals on synthetic baseline) is not directly asserted; spec explicitly tags this AC informational |

### Finding details

**F1: Duplicated FDR-error + ERROR-log + raise blocks** (Medium / Maintainability)

* Location: `opencv_gtsam_estimator.py` — three near-identical 10-line blocks inside `_estimate_marginals_path` (PnP-fail, marginals-fail, SPD-fail) and two more inside `_estimate_jacobian_path` (PnP-fail, SPD-fail). All five do: `_emit_pose_frame_done_fdr_error(...)` → `self._log.error(_LOG_KIND_PNP_FAILURE, extra={...})` → `raise PnpFailureError(...)`.
* Description: shared error-emit shape; only the `stage` key + the wrapping exception message change.
* Suggestion: extract a private `_fail(frame_id, mode, stage, exc, message) -> NoReturn` helper. Keeps the path methods readable and removes 4 of 5 duplications.
* Task: AZ-358 + AZ-361. Non-blocking — internal cleanup only.

**F2: Per-point pyproj loop in `_tile_pixels_to_enu_world`** (Medium / Performance)

* Location: `opencv_gtsam_estimator.py:617-657`.
* Description: for each inlier, the helper calls `WgsConverter.latlonalt_to_local_enu(origin, LatLonAlt(...))`, which in turn calls `_ECEF_FROM_LLA.transform(...)` once. On a 50-inlier frame this is 50 individual pyproj boundary crossings (~ms each). Pose-path p95 target is 90 ms for the whole Marginals path; this leaves little headroom.
* Suggestion: collect `(lon, lat, alt)` arrays once, call `_ECEF_FROM_LLA.transform(lons, lats, alts)` in vectorized form, then apply the (single) ENU rotation in NumPy. Behavior-preserving.
* Task: AZ-358. Non-blocking for AC compliance; meaningful for NFT-LIM-04 / C4-PT-01 latency margin.

**F3: Bare `except Exception` on `local_values.insert`** (Low / Style)

* Location: `opencv_gtsam_estimator.py:289-296`.
* Description: catches *all* exceptions to swallow GTSAM's duplicate-insert error. A real bug elsewhere (wrong type, wrong key) would be silently masked.
* Suggestion: replace with `if not local_values.exists(pose_key): local_values.insert(...)`. Removes the swallow entirely.
* Task: AZ-358. Non-blocking.

**F4: Two `cv2.projectPoints` calls per Marginals frame** (Low / Performance)

* Location: `opencv_gtsam_estimator.py:_compute_reprojection_residuals` + `opencv_gtsam_estimator.py:_jacobian_covariance`.
* Description: `cv2.projectPoints` is called once to compute residuals (no Jacobian requested) and once more to compute the Jacobian (no residual reuse). On the Marginals path both fire per frame.
* Suggestion: in `_run_pnp`, request Jacobian on the residual call and stash it on `_PnpResult`; `_jacobian_covariance` reuses the stashed Jacobian.
* Task: AZ-358. Non-blocking.

**F5: AZ-361 AC-11 (Jacobian-vs-Marginals RMSE) not asserted in tests** (Low / Spec-Gap)

* Location: `tests/unit/c4_pose/test_az358_361_opencv_gtsam_estimator.py`.
* Description: the spec marks AC-11 informational and explicitly states it does NOT block. Still, no test exists today that runs both paths on a fixed synthetic and asserts the RMSE ratio.
* Suggestion: add a single synthetic-baseline test that runs both paths on the same `MatchResult` and asserts `rmse_jacobian / rmse_marginals <= 1.10`. Defer to a follow-up batch — does not block this review.
* Task: AZ-361.

## Phase 4 — Security Quick-Scan

No findings. No SQL paths, subprocess usage, hardcoded credentials, network I/O, or unbounded deserialization in the changed surface. Log/FDR payloads carry only telemetry (frame ids, covariance norms, residuals, public coordinates) — no operator PII.

## Phase 5 — Performance Scan

* F2 and F4 above are the only meaningful items.
* No `O(n^2)` patterns; no N+1 queries; no blocking I/O.
* NumPy `np.asarray(..., copy=False)` and `np.ascontiguousarray(...)` used appropriately on hot paths.

## Phase 6 — Cross-Task Consistency

AZ-358 and AZ-361 land in the same file and share `_run_pnp`, `_jacobian_covariance`, `_assemble_pose_estimate`, `_emit_pose_frame_done_fdr`, and `_emit_pose_frame_done_fdr_error`. Both paths emit the same `pose.frame_done` schema; only AZ-361 additionally emits `pose.covariance_degraded`. No conflicting patterns.

The `ISam2GraphHandle` Protocol widening lands in lockstep with both tasks and with the AZ-355 test fixture (`_FakeISam2GraphHandle` now implements all five methods). A new test (`test_ac10_isam2_graph_handle_rejects_partial_surface`) was added to the AZ-355 test file to keep the runtime-checkable Protocol from regressing.

## Phase 7 — Architecture Compliance

Imports in `opencv_gtsam_estimator.py`:

* `_types/*` (L1) — OK.
* `clock/wall_clock` (L1) — OK.
* `components/c4_pose/*` (same component, L3) — OK.
* `fdr_client/records` (L2 cross-cutting) — OK.
* `helpers/wgs_converter` (L2 helpers) — OK.
* `logging` (L1) — OK.

No cross-component imports into C5/C6/C7 internals (the only C5 touch-point is the `ISam2GraphHandle` Protocol owned by `c4_pose/_isam2_handle.py`). No new module cycles introduced (verified by full-suite run). No duplicated symbols (Quat / LatLonAlt / PoseEstimate are imported, not redefined). Cross-cutting concerns (logging, FDR, WGS conversion, clock) are consumed via shared modules, not re-implemented.

`runtime_root/pose_factory.py` legitimately imports both the C4 public surface (via `__init__`) and the private `_isam2_handle` module — the latter is documented as composition-root-only, which `pose_factory.py` is.

No Architecture findings.

## Verdict

**PASS_WITH_WARNINGS** — every AC that the spec marks blocking has at least one passing unit test. Five non-blocking findings (Medium ×2, Low ×3) are documented above for follow-up. No Critical or High findings.

## Test outcomes

* `tests/unit/c4_pose/` + `tests/unit/test_az272_fdr_record_schema.py`: **101 passed**.
* Full repo suite: **1958 passed, 1 failed, 84 skipped**. The single failure (`tests/unit/c12_operator_orchestrator/test_cli_console_script.py::test_cold_start_under_500ms_p99`) is a host-dependent CLI cold-start performance assertion (500 ms NFR vs. 700-1100 ms on this macOS dev box) — unrelated to the C4 / FDR surface and pre-existing flake on this hardware.
