Files
Oleksandr Bezdieniezhnykh 7cbd17ee83 [AZ-385] C5 SourceLabelStateMachine + spoof-promotion gate
Implements Invariants 5 + 8 + AC-NEW-2 / AC-NEW-8: the
EstimatorOutput.source_label now reflects a real state machine
(DEAD_RECKONED → SATELLITE_ANCHORED ↔ VISUAL_PROPAGATED) governed by
a spoof-promotion gate that latches closed on FC SPOOFED GPS health
and re-opens only when BOTH conditions hold — ≥10 s
STABLE_NON_SPOOFED AND next anchor within
spoof_promotion_visual_consistency_tol_m.

Every reject emits a c5.state.spoof_rejected FDR record plus a
subscriber-fan-out STATUSTEXT (severity WARNING, 50-char cap per
MAVLink). FDR and subscriber paths bypass the standard logger so
silencing logs cannot suppress the spoof trail (R07 / AC-6).

GtsamIsam2StateEstimator now eagerly builds the SM from C5StateConfig
in __init__; new public methods notify_gps_health() (delegates to
SM, called by composition root from C8 inbound) and
subscribe_spoof_rejection() (composition root attaches C8's
QgcTelemetryAdapter here). health_snapshot.spoof_promotion_blocked
+ current_estimate.source_label now flow from the live SM.

25 new unit tests across all 12 ACs plus cancellation, subscriber
exception isolation, and estimator wire-up integration cases. One
AZ-384 test renamed + updated to expect DEAD_RECKONED before any
anchor (was VISUAL_PROPAGATED placeholder pre-AZ-385).

Full suite: 632 passed, 2 skipped.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 07:06:38 +03:00

12 KiB

Batch 17 — Cycle 1 Implementation Report

Batch: 17 of N Tasks landed: AZ-385 (SourceLabelStateMachine + AC-NEW-2 / AC-NEW-8 spoof-promotion gate) Cycle: 1 Date: 2026-05-11

Scope

Task Component Purpose
AZ-385 C5 state estimator Implements Invariant 5 (source_label reflects gate state) + Invariant 8 (spoof-rejection always lands in FDR + GCS STATUSTEXT, unsilenceable) + AC-NEW-2 / AC-NEW-8 (gate opens only when BOTH ≥10 s STABLE_NON_SPOOFED AND next anchor within tol_m). Adds a new SourceLabelStateMachine helper module owned by C5; the estimator constructs one eagerly per instance and exposes two new public surfaces — notify_gps_health(GpsHealth, now_ns=None) (forwarded from C8 inbound; transitions the gate latch + the stable-dwell timer) and subscribe_spoof_rejection(cb) -> RejectionSubscription (composition root wires C8's QgcTelemetryAdapter.send_statustext here so STATUSTEXT mirrors fire on the C8 outbound thread). health_snapshot.spoof_promotion_blocked is now backed by the live state machine; current_estimate.source_label likewise reflects the live label rather than the AZ-384-era hard-coded default. Every reject emits a c5.state.spoof_rejected FDR record AND a subscriber callback in the same call — both paths bypass the standard logger so silencing logs cannot suppress the spoof trail (R07 / AC-6).

Files added / modified

Added (prod)

  • src/gps_denied_onboard/components/c5_state/_source_label_sm.py — new SourceLabelStateMachine class. Public read API: current_label(), is_spoof_promotion_blocked(). Public write API: notify_gps_health(gps_health, now_ns=None) (transitions _promotion_blocked on first SPOOFED, starts the dwell timer on transition to STABLE_NON_SPOOFED, clears it on any other status), notify_satellite_anchor(now_ns, gps_consistency_delta_m) (updates _last_anchored_frame_ns; while blocked, tries to lift the gate using the BOTH-conditions test, otherwise emits a c5.state.spoof_rejected FDR record + subscriber fan-out). Subscription API: subscribe_rejection(cb) -> RejectionSubscription with cancellable handle. Internal label rules: DEAD_RECKONED until first anchor → SATELLITE_ANCHORED if gate open AND anchor < 1 s old → VISUAL_PROPAGATED otherwise. Threading: explicit threading.Lock around every state mutation; subscriber callbacks dispatched OUTSIDE the lock so a slow subscriber doesn't block writers.

Modified (prod)

  • src/gps_denied_onboard/components/c5_state/gtsam_isam2_estimator.py — three changes: (1) the constructor now eagerly builds a SourceLabelStateMachine from C5StateConfig.spoof_promotion_min_stable_s and spoof_promotion_visual_consistency_tol_m and the injected fdr_client; (2) add_pose_anchor on the MARGINALS path now calls a new internal helper _notify_source_label_anchor(pose) that forwards the anchor to the SM with gps_consistency_delta_m=None (the visual-vs-GPS delta will be supplied by AZ-389 orthorectifier or the composition root once that wiring lands); (3) new public methods notify_gps_health(gps_health, now_ns=None) (delegates to the SM) and subscribe_spoof_rejection(callback) -> RejectionSubscription (delegates; raises StateEstimatorConfigError if attach_source_label_state_machine was used with a stub that lacks the subscription surface). The pre-existing attach_source_label_state_machine injection point is preserved and re-documented as a test-only override (production code uses the eagerly-built instance).

Added (tests)

  • tests/unit/c5_state/test_az385_source_label_spoof_gate.py — 25 tests across all 12 ACs plus subscription cancellation, subscriber-exception isolation, and three estimator-wire-up integration tests. Uses a deterministic synthetic _Clock (no real wall-clock dependence). Mocks the SM's logger via mock.patch.object to prove AC-6 — FDR and subscriber paths fire even when every logger.* call is a no-op. The estimator integration tests use the same _seed_prior helper pattern from AZ-388 to seed a minimal iSAM2 prior so current_estimate returns without raising.

Modified (tests)

  • tests/unit/c5_state/test_az384_marginals_outputs.pytest_ac9_default_source_label_is_visual_propagated renamed to test_ac9_default_source_label_is_dead_reckoned_before_any_anchor and updated to expect DEAD_RECKONED. The previous behaviour (no SM attached → VISUAL_PROPAGATED) was an AZ-384-era placeholder; AZ-385 supersedes it by attaching the real SM eagerly. Inline comment cites AC-1 of AZ-385 + Invariant 5 for traceability.

Architectural notes

  • Eager construction over lazy injection — AZ-384 left _source_label_machine as Any | None with attach_source_label_state_machine as the wiring seam, on the theory that AZ-385 would own the actual machine instance. With the SM now landed, eagerly constructing it in __init__ removes the "is it attached yet?" branch from every read path AND lets health_snapshot.spoof_promotion_blocked return a meaningful answer from the very first call. attach_source_label_state_machine stays available as a test-only override (one test exercises it; see test_estimator_subscribe_after_attach_with_stub_raises).
  • Subscriber pattern over direct GCS dependency — same shape as AZ-388's FallbackWatcher.subscribe_engaged. The C5 estimator does NOT take a GcsAdapter in its constructor; instead C8's QgcTelemetryAdapter registers itself via subscribe_spoof_rejection at composition time. This keeps the C5 module's import graph free of C8 dependencies and matches the contract's "C8 owns the STATUSTEXT broadcast wire" phrasing.
  • Unsilenceable logging path (R07 / AC-6) — both the FDR record and the subscriber fan-out go through dedicated methods (FdrClient.enqueue, direct callback invocation) that do NOT touch the logging module's filter chain. The informational c5.state.spoof_rejected WARN log is emitted AFTER both side-effects, so even if every logger.* call is monkey-patched to a no-op, the FDR + STATUSTEXT trail still lands. Verified by test_ac6_silenced_logger_does_not_suppress_fdr_or_subscriber.
  • BOTH-conditions promotion (AC-NEW-2 / AC-NEW-8) — the gate is lifted ONLY when (now - _gps_health_stable_since_ns) >= min_stable_ns AND gps_consistency_delta_m is not None AND <= consistency_tol_m. Each condition is tested in isolation (test_ac5_only_stable_dwell_does_not_lift_block, test_ac5_only_consistency_does_not_lift_block) — the AND semantics are not paraphrased into OR by accident.
  • Default gps_consistency_delta_m=None from the estimator — the production wire-up cannot compute the visual-vs-GPS delta yet (AZ-389 orthorectifier supplies the rectified ENU position for cross-check; the composition root will own the comparison once that task lands). Until then, ALL in-block anchor attempts emit a reject for no_gps_observation — which is the conservative + correct behaviour (you can't unblock until you have evidence). When AZ-389 lands, the composition root will replace the gps_consistency_delta_m=None call with the real delta and the dwell+consistency AND-test will be exercised end-to-end.
  • Anchor-staleness threshold = 1 s — encoded as a module-level constant _ANCHOR_STALENESS_THRESHOLD_MS = 1000 rather than a new config field. The C5 contract's "Config schema additions" section already enumerates the fields the operator may tune; adding a sixth field for an internal SM-only decision would be over-configuration. Documented in the module docstring.
  • STATUSTEXT cap at 50 chars (AC-12) — the formatter pre-caps the message to 50 chars in _format_statustext; reason tokens (gps_spoofed, dwell_short, consistency, no_gps_obs) are intentionally short so the full "GPS spoof rejected: <reason>" fits without truncation in the common case.
  • Reject-reason classification ladder — the four reasons cover the four possible failure modes of the AND-test: GPS still SPOOFED (highest-priority — never check anything else), no GPS observation at all (gps_consistency_delta_m is None), dwell insufficient, consistency violation. The ladder is deterministic; tests assert on the reason string per case.

Test counts

Suite Before (B16) After (B17) Delta
Total passing 607 632 +25
Skipped 2 2 0
AZ-385 (new) 0 25 +25
AZ-384 (preserved) 27 27 0 (test renamed + flipped expectation; count unchanged)

Run command: PYTHONPATH=src pytest tests/ -q632 passed, 2 skipped in ~32s.

Lint / type

  • ruff check src/gps_denied_onboard/components/c5_state/ tests/unit/c5_state/ — clean after one auto-fix (import order in gtsam_isam2_estimator.py).
  • ruff format — 1 file reformatted, 3 already formatted; second pass clean.
  • ReadLints on touched files — 0 errors.

Acceptance evidence

AC Test(s) Status
AC-1 Initial label DEAD_RECKONED test_ac1_initial_label_is_dead_reckoned, test_ac1_initial_label_remains_dead_reckoned_after_gps_only PASS
AC-2 First anchor → SATELLITE_ANCHORED test_ac2_first_anchor_promotes_to_satellite_anchored, test_ac2_first_anchor_when_blocked_emits_reject PASS
AC-3 Stale anchor → VISUAL_PROPAGATED test_ac3_stale_anchor_falls_back_to_visual_propagated PASS
AC-4 Spoof detection latches + emits reject test_ac4_spoofed_status_latches_gate_closed, test_ac4_reject_fires_subscriber_callback, test_ac4_reject_fires_fdr_record PASS
AC-5 BOTH conditions required test_ac5_only_stable_dwell_does_not_lift_block, test_ac5_only_consistency_does_not_lift_block, test_ac5_both_conditions_lift_block PASS
AC-6 Logging cannot be silenced test_ac6_silenced_logger_does_not_suppress_fdr_or_subscriber PASS
AC-7 is_spoof_promotion_blocked() test_ac7_block_query_reflects_latch PASS
AC-8 health_snapshot.spoof_promotion_blocked wired test_ac8_health_snapshot_reflects_spoof_block PASS
AC-9 Configurable thresholds test_ac9_min_stable_30s_shifts_dwell_window, test_ac9_consistency_tol_5m_rejects_15m_delta PASS
AC-10 Label-change INFO log test_ac10_label_change_emits_info_log PASS
AC-11 Reject FDR record shape test_ac11_reject_fdr_record_shape PASS
AC-12 STATUSTEXT severity + 50-char cap test_ac12_statustext_severity_is_warning, test_ac12_statustext_max_50_chars PASS
Subscription cancellation test_subscription_cancel_stops_callbacks PASS
Subscriber exception isolation test_subscriber_exception_does_not_break_state_machine PASS
Estimator wire-up — label flows test_estimator_current_estimate_label_reflects_sm PASS
Estimator wire-up — subscription delegates test_estimator_subscribe_spoof_rejection_returns_handle PASS
Stub injection raises on subscribe test_estimator_subscribe_after_attach_with_stub_raises PASS

Known gaps / followups

  • Visual-vs-GPS consistency delta — currently the estimator passes None on every anchor notification. AZ-389 (orthorectifier → C6) will supply the rectified ENU position; the composition root will then compute the delta against the FC GPS WGS84 → ENU position and pass it in via a new method (likely notify_visual_consistency on the SM). Until that lands the BOTH-conditions test always falls into the no_gps_observation reject reason, which is the conservative + safe default.
  • ESKF wire-upEskfStateEstimator (AZ-386) needs the same SM wire-up (one constructor line + the add_pose_anchor hook). Per the AZ-385 contract "both estimators participate"; the AZ-386 wire-up cost will be a single line each.
  • C5-IT-06 / C5-IT-07 / C5-ST-01 — scoped out per AZ-385 § Excluded; live in E-BBT.
  • C8 outbound STATUSTEXT subscription — AZ-261 will register the C8 QgcTelemetryAdapter via subscribe_spoof_rejection at composition time. AZ-385 only exposes the subscription point.

Risks accepted

  • SM lock contention under high anchor rate — anchor cadence is bounded by D-C5-3 (~3 Hz worst case); lock-acquire is microseconds. Verified informally; if profiling reveals contention later we can swap to a CAS pattern or split into a state-machine-per-input.
  • gps_consistency_delta_m=None always rejecting under spoof — by design until AZ-389 lands. The conservative behaviour is "stay blocked until evidence proves otherwise", which is exactly what R07 wants.