[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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 07:06:38 +03:00
parent 31a300f8a2
commit 7cbd17ee83
7 changed files with 1148 additions and 11 deletions
@@ -412,13 +412,16 @@ def test_ac9_state_machine_drives_source_label() -> None:
assert out.source_label == PoseSourceLabel.SATELLITE_ANCHORED
def test_ac9_default_source_label_is_visual_propagated() -> None:
def test_ac9_default_source_label_is_dead_reckoned_before_any_anchor() -> None:
# AZ-385 superseded the AZ-384 default: the auto-constructed
# SourceLabelStateMachine returns DEAD_RECKONED until the first
# satellite anchor is observed (AC-1 of AZ-385 + Invariant 5).
estimator = _build_estimator()
_seed_prior(estimator)
out = estimator.current_estimate()
assert out.source_label == PoseSourceLabel.VISUAL_PROPAGATED
assert out.source_label == PoseSourceLabel.DEAD_RECKONED
# ---------------------------------------------------------------------