Files
Oleksandr Bezdieniezhnykh 362e93c626 [AZ-390] [AZ-392] C8 FC/GCS adapter foundation + covariance projector
Adds the C8 foundation:
- FcAdapter / GcsAdapter / ReplaySink Protocols + contract DTOs in
  _types/fc.py (PortConfig, FcKind, FlightState, GpsStatus, Severity,
  TelemetryKind, FcTelemetryFrame, FlightStateSignal, GpsHealth,
  OperatorCommand, Subscription, Imu/Attitude samples).
- Disjoint FcAdapterError / GcsAdapterError trees with
  SourceSetSwitchNotSupportedError <: SourceSetSwitchError per AC-9.
- FcConfig + GcsConfig cross-cutting Config blocks with config-load
  validation (unknown strategy rejected at __post_init__).
- runtime_root/fc_factory.py: build_fc_adapter / build_gcs_adapter
  with BUILD_FC_*/BUILD_GCS_* flag gating + INFO log on load +
  single-writer outbound-thread binding.
- CovarianceProjector (helper, AZ-392): 6x6 -> 3x3 -> 2x2 ->
  sqrt(lambda_max) reduction; AP returns float m, iNav returns int mm
  with uint16 clamp + WARN + FDR record. Non-SPD / NaN / wrong-shape
  raise FcEmitError and emit an FDR ERROR record carrying frame_id.

Contracts:
- composition_root_protocol.md 1.1.0 -> 1.2.0 (added fc/gcs blocks +
  build_fc_adapter / build_gcs_adapter + outbound-thread binding).
- fc_adapter_protocol.md unchanged (this batch implements v1.0.0).

Tests: 410 pass / 2 skip / 0 fail (+53 new tests in batch 8).

AZ-391 (inbound subscription) deferred to batch 9 — pulls YAMSPy as
a new external dependency (iNav MSP2 decode).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 04:17:59 +03:00

9.8 KiB
Raw Permalink Blame History

Batch 08 — Code Review

Batch: 8 of N Tasks: AZ-390 (FcAdapter / GcsAdapter Protocols + DTOs + factories + composition), AZ-392 (CovarianceProjector helper) Reviewer: autodev (7-phase) Verdict: PASS_WITH_INFO Date: 2026-05-11

Scope

Task Component / Concern Files touched (prod) Files touched (tests)
AZ-390 C8 public Protocols + DTOs + errors + factories + Config blocks _types/{fc.py,emitted.py,nav.py}, components/c8_fc_adapter/{__init__.py,interface.py,errors.py}, config/{schema.py,loader.py,__init__.py}, runtime_root/{__init__.py,fc_factory.py} tests/unit/c8_fc_adapter/{test_az390_adapter_protocol.py,test_smoke.py}, tests/unit/test_ac1_scaffold_layout.py
AZ-392 C8 covariance projector components/c8_fc_adapter/_covariance_projector.py tests/unit/c8_fc_adapter/test_az392_covariance_projector.py

Phase 1 — AC compliance

Task ACs Coverage
AZ-390 10 ACs (Protocol conformance, frozen-slot DTOs, enum membership, flag-OFF rejection, config-load rejection, single-writer thread, GCS factory, public-API gate, error hierarchy, INFO log) + NFR-perf All passing in test_az390_adapter_protocol.py (36 tests). Public-API gate also covered by test_smoke.py::test_internal_modules_not_in_public_all.
AZ-392 7 ACs (reduction correctness, AP float m, iNav int mm, SPD violation, NaN guard, bit-stability, uint16 clamp) + NFR-perf All passing in test_az392_covariance_projector.py (17 tests).

53 new tests added in batch; 410 total in suite (was 357), 2 pre-existing skips, 0 failures.

Phase 2 — Contract drift

  • composition_root_protocol.md v1.1.0 → v1.2.0 (minor): added cross-cutting fc: FcConfig + gcs: GcsConfig blocks on Config, and build_fc_adapter / build_gcs_adapter factories on the composition-root surface. Backwards-compatible — default FcConfig() + GcsConfig() preserve existing semantics; the four new env vars per block all have documented defaults.
  • fc_adapter_protocol.md is unchanged at v1.0.0 — this batch implements the v1.0.0 surface as specified.

Phase 3 — Architectural compliance

  • No new dependencies. Every new module uses stdlib + numpy + pyyaml (all already pinned). YAMSPy entry is gated on AZ-391 (batch 9).
  • Module-layout adherence: _covariance_projector.py is prefixed _ per module-layout.md rule "internal helpers MUST start with _"; c8_fc_adapter/__init__.__all__ exposes ONLY the contract symbols (asserted by test_smoke.test_internal_modules_not_in_public_all).
  • Layer 1 (helpers) discipline: fc_factory.py imports only from gps_denied_onboard.config, gps_denied_onboard.logging, and gps_denied_onboard.components.c8_fc_adapter.{errors,interface}. No upward imports from c8 internals into the composition root.
  • ADR-002 build-time exclusion: the _FC_BUILD_FLAGS mapping is the single source of truth tying a strategy slug to its build flag. The factory consults os.environ (not config) because flag state is a build-time artifact per the ADR.
  • ADR-009 interface-first DI: all factories take **deps keyword args; concrete strategies are registered via register_fc_adapter(slug, factory) from per-binary bootstrap modules, never imported directly by the composition root.
  • AC-NEW-3 (every payload class from t=0) preserved: this batch did NOT wire take_off to call build_fc_adapter (the strict ordering is already enforced by batch 7's take_off); only the factory itself landed. The wire-up happens when the first concrete adapter (AZ-393) lands.
  • Single-writer outbound thread (AC-6 / Invariant 8): bind_outbound_emit_thread is process-global state guarded by a single lock; idempotent on same-thread re-binds (test-isolation friendly).

Phase 4 — Performance & reliability

  • Factory build path is O(strategy-name-lookup): two dict lookups + one env-var read; AC-NFR sanity check verifies < 50 ms (typically < 100 µs measured locally).
  • CovarianceProjector is O(1) per call: 2×2 closed-form eigenvalue, no LAPACK dispatch. test_nfr_perf_projector_under_100us_per_call verifies < 100 µs avg over 1k iterations.
  • iNav clamp path emits at most one WARN + one FDR record per occurrence — no rate-limit needed because the clamp itself is a per-frame event already gated by the projector call rate (5 Hz emit per AC-1.4).
  • SPD violation path is allocation-light: one numpy view (cov_arr[:3, :3]), one 2×2 sub-view, two scalar reads. The FDR enqueue happens BEFORE the raise so the operator gets the post-flight forensic record even if the upstream caller swallows the exception.

Phase 5 — Test quality

  • AC-1 Protocol tests use real stub classes (not mock.MagicMock) so isinstance(x, FcAdapter) exercises the @runtime_checkable protocol-fingerprint check honestly.
  • AC-2 DTO tests parametrise over EVERY contract DTO, asserting both frozen-instance immutability AND __slots__ presence. Forgetting slots=True on a new DTO fails the matching parametrise.
  • AC-3 enum tests assert exact member set (not a subset) — adding an unintended enum member also fails the gate.
  • AC-4 / AC-5 tests separate the config-load gate from the build-time gate: unknown strategy fails at FcConfig(...) construction; flag-OFF fails at build_fc_adapter(config); unregistered-but-known-strategy fails at build_fc_adapter with a clear "registered strategies: [...]" message.
  • AC-6 cross-thread rebind test uses a real threading.Thread — not just a different thread_ident argument — so the lock's cross-thread visibility is exercised.
  • AC-10 INFO-log tests pin caplog.at_level(...) to the exact logger name so they don't accidentally pass on unrelated INFO records elsewhere in the process.
  • AZ-392 SPD-violation tests assert both the raise AND the FDR record carrying reason + frame_id — a regression that drops the FDR record would not pass the raise-only test alone.
  • AZ-392 bit-stability test calls 20 times on the same input and asserts len(set(results)) == 1 — drift between calls (e.g. from numpy.eigvalsh-style round-off) fails the gate.
  • AZ-392 NFR-perf test uses time.perf_counter with 1 k iterations and a 100 µs/call budget — sufficient headroom for jittery CI runners while still catching an order-of-magnitude regression.

Arrange / Act / Assert pattern consistently applied in all new tests.

Phase 6 — Logging & FDR coverage

  • fc_factory.build_fc_adapter: INFO log per build (kind="c8.adapter.strategy_loaded", kv={strategy, port_device}).
  • fc_factory.build_gcs_adapter: INFO log per build (kind="c8.gcs.strategy_loaded", kv={strategy, port_device}).
  • CovarianceProjector: WARN log on iNav clamp (kind="c8.cov_projector.inav_clamped", kv={radius_mm_raw, clamped_to, frame_id}).
  • CovarianceProjector: FDR ERROR record on every projection rejection (kind="log", payload.level="ERROR", payload.kind="c8.cov_projector.spd_violation", payload.kv={reason, frame_id, …}). Uses the existing log record kind so no FDR schema bump is needed.
  • All log records follow the kind + kv convention required by AZ-266's JsonFormatter.

Phase 7 — Security & risk surface

  • RESTRICT-COMM-2 (iNav has no signing): enforced at config-load by FcConfig.__post_init__ — passing adapter="inav" with any signing_key_source != "none" raises ConfigError with the restriction code in the message. Covered by test_ac5_inav_signing_key_combination_rejected.
  • Build-flag gate uppercases the env-var value (os.environ.get(...).upper() == "OFF") so BUILD_FC_INAV=off is also honoured. The DEFAULT (no env var set) is ON-per-binary; concrete binaries SHOULD set it explicitly to keep the registry → flag mapping honest.
  • Strategy registry rejects duplicate registrations with different factories (FcAdapterConfigError); same-factory re-registration is idempotent. This prevents a second bootstrap module from silently overriding the first.
  • No silent fallback: every config-load / build / projection failure raises or emits an FDR record. The covariance-projector FDR enqueue is best-effort but logs its own c8.cov_projector.fdr_enqueue_failed so a silenced FDR doesn't hide the SPD failure.
  • bind_outbound_emit_thread does not unbind on its ownclear_outbound_thread_binding() is intentionally scoped to test-isolation. A process that wants to re-bind in production must restart, which matches the single-writer invariant.

Informational findings (non-blocking)

  1. take_off is not yet wired to call build_fc_adapter / build_gcs_adapter — this is the carry-over from batch 7's PASS_WITH_INFO finding #3. The wire-up will happen when the first concrete adapter lands (AZ-393), where the fc_factory can be exercised end-to-end with a real adapter. Documented in the contract bump.
  2. Subscription Protocol has only cancel() — the contract spec leaves open whether a Subscription should also be a context manager. We did NOT add __enter__ / __exit__ because no AC requires it and AZ-391 hasn't started consuming the type yet. If AZ-391 needs ctx-manager semantics, that's a v1.1.0 contract bump on fc_adapter_protocol.md, not a regression.
  3. _covariance_projector.py is intentionally NOT in c8_fc_adapter/__init__.__all__ — concrete adapters (AZ-393 / AZ-394) instantiate it via direct module import, since it is an internal helper. The smoke test asserts the public-API gate; no contract bump required because the projector is a concrete helper, not a Protocol.

Verdict

PASS_WITH_INFO — all ACs satisfied, all tests green, no architectural drift, one minor contract bump (composition_root_protocol.md 1.1.0 → 1.2.0) documented inline with migration notes. The three informational findings are forward actions tied to upcoming batches, not blockers.