mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 16:01:13 +00:00
7cbd17ee83
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>
12 KiB
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— newSourceLabelStateMachineclass. Public read API:current_label(),is_spoof_promotion_blocked(). Public write API:notify_gps_health(gps_health, now_ns=None)(transitions_promotion_blockedon firstSPOOFED, starts the dwell timer on transition toSTABLE_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 ac5.state.spoof_rejectedFDR record + subscriber fan-out). Subscription API:subscribe_rejection(cb) -> RejectionSubscriptionwith cancellable handle. Internal label rules:DEAD_RECKONEDuntil first anchor →SATELLITE_ANCHOREDif gate open AND anchor < 1 s old →VISUAL_PROPAGATEDotherwise. Threading: explicitthreading.Lockaround 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 aSourceLabelStateMachinefromC5StateConfig.spoof_promotion_min_stable_sandspoof_promotion_visual_consistency_tol_mand the injectedfdr_client; (2)add_pose_anchoron the MARGINALS path now calls a new internal helper_notify_source_label_anchor(pose)that forwards the anchor to the SM withgps_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 methodsnotify_gps_health(gps_health, now_ns=None)(delegates to the SM) andsubscribe_spoof_rejection(callback) -> RejectionSubscription(delegates; raisesStateEstimatorConfigErrorifattach_source_label_state_machinewas used with a stub that lacks the subscription surface). The pre-existingattach_source_label_state_machineinjection 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 viamock.patch.objectto prove AC-6 — FDR and subscriber paths fire even when everylogger.*call is a no-op. The estimator integration tests use the same_seed_priorhelper pattern from AZ-388 to seed a minimal iSAM2 prior socurrent_estimatereturns without raising.
Modified (tests)
tests/unit/c5_state/test_az384_marginals_outputs.py—test_ac9_default_source_label_is_visual_propagatedrenamed totest_ac9_default_source_label_is_dead_reckoned_before_any_anchorand updated to expectDEAD_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_machineasAny | Nonewithattach_source_label_state_machineas 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 letshealth_snapshot.spoof_promotion_blockedreturn a meaningful answer from the very first call.attach_source_label_state_machinestays available as a test-only override (one test exercises it; seetest_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 aGcsAdapterin its constructor; instead C8'sQgcTelemetryAdapterregisters itself viasubscribe_spoof_rejectionat 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 theloggingmodule's filter chain. The informationalc5.state.spoof_rejectedWARN log is emitted AFTER both side-effects, so even if everylogger.*call is monkey-patched to a no-op, the FDR + STATUSTEXT trail still lands. Verified bytest_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_nsANDgps_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=Nonefrom 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 forno_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 thegps_consistency_delta_m=Nonecall 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 = 1000rather 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/ -q → 632 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 ingtsam_isam2_estimator.py).ruff format— 1 file reformatted, 3 already formatted; second pass clean.ReadLintson 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
Noneon 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 (likelynotify_visual_consistencyon the SM). Until that lands the BOTH-conditions test always falls into theno_gps_observationreject reason, which is the conservative + safe default. - ESKF wire-up —
EskfStateEstimator(AZ-386) needs the same SM wire-up (one constructor line + theadd_pose_anchorhook). 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
QgcTelemetryAdapterviasubscribe_spoof_rejectionat 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=Nonealways 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.