Replaces AZ-382 NotImplementedError placeholders with real GTSAM factor adds wired against the iSAM2 graph handle: - add_vio -> BetweenFactorPose3 between consecutive VIO pose keys (first call primes the chain; AZ-388 owns first-keyframe seeding). - add_pose_anchor -> mode-dispatch per pose.covariance_mode: "marginals" -> PriorFactorPose3 + handle.update(); "jacobian" -> skip iSAM2 add per AZ-361 contract. Both paths bump _last_anchor_ns via time.monotonic_ns(). - add_fc_imu -> shared ImuPreintegrator.integrate_window + reset_for_new_keyframe; builds a CombinedImuFactor between the prev/curr (X, V, B) keyframe triple. Introduces new 'v' (velocity) and 'b' (bias) GTSAM key namespaces decoupled from the VIO/pose frame_id mapping. Invariant 2 - non-decreasing timestamps - enforced per call with EstimatorDegradedError + c5.state.out_of_order log. Every successful add emits a structured DEBUG *_ok log; every failure emits a structured ERROR *_failed log and raises through the C5 error hierarchy (R05). Contract-vs-reality fix-ups also landed: - StateEstimator Protocol: add_fc_imu(ImuWindow) - was incorrectly annotated as ImuTelemetrySample by AZ-381. - _last_anchor_ns semantics switched to monotonic_ns() to match last_anchor_age_ms. - create() factory back-wires the ISam2GraphHandle to the estimator via the new attach_handle() method. Tests: +21 in tests/unit/c5_state/test_az383_factor_adds.py covering all 8 ACs with mock ISam2GraphHandle instances. Three obsolete AZ-382 tests (test_ac10_add_*_raises_named_az383) removed. Full suite: 565 passed, 2 skipped. Co-authored-by: Cursor <cursoragent@cursor.com>
11 KiB
Batch 14 — Cycle 1 Implementation Report
Batch: 14 of N
Tasks landed: AZ-383 (GtsamIsam2StateEstimator — VIO + Pose + IMU factor adds)
Cycle: 1
Date: 2026-05-11
Scope
| Task | Component | Purpose |
|---|---|---|
| AZ-383 | C5 state estimator | Replaces the three AZ-382 NotImplementedError placeholders on GtsamIsam2StateEstimator (add_vio, add_pose_anchor, add_fc_imu) with real factor-add bodies wired against the AZ-382 ISam2GraphHandle. add_vio emits a BetweenFactorPose3 between consecutive VIO pose keys (first call primes the chain only). add_pose_anchor mode-dispatches per pose.covariance_mode: "marginals" → PriorFactorPose3 + handle.update(); "jacobian" → SKIP the iSAM2 add per the AZ-361 cross-task contract (the running estimate still consumes the pose downstream, the graph stops growing under throttle). Both paths bump _last_anchor_ns via time.monotonic_ns() so last_anchor_age_ms and the spoof-promotion gate (AZ-385) see a recent satellite anchor. add_fc_imu feeds the shared ImuPreintegrator, closes the PIM with reset_for_new_keyframe(), and builds a CombinedImuFactor between the previous and current (X, V, B) keyframe triple — first call primes the chain without emitting a factor. Every successful add emits a DEBUG *_ok log; every failure emits an ERROR *_failed log and raises through the C5 error hierarchy (R05). Invariant 2 — non-decreasing timestamps — is enforced per call with an EstimatorDegradedError + c5.state.out_of_order log on violation. The remaining three Protocol output methods (current_estimate / smoothed_history / health_snapshot) still raise NotImplementedError naming AZ-384. |
Decision record
The task started as a "~5pt drop-in", but inspecting the contracts surfaced four contract-vs-reality conflicts that the user chose to resolve in full alignment with the C5 contract rather than paper over (re-estimate to ~7–8pt, one batch):
- Protocol
add_fc_imutype mismatch — AZ-381's Protocol annotatedadd_fc_imu(ImuTelemetrySample)(a single sample), but the C5 contract has always specifiedImuWindowbecause the sharedImuPreintegratoroperates on batches. The composition root will batch the C8ImuTelemetrySamplestream intoImuWindows before callingadd_fc_imu. Fixedinterface.pyhere with a docstring note. ImuPreintegratorAPI mismatch — task spec assumedpreintegrate(window) -> CombinedImuFactor. Real API per AZ-276 isintegrate_window(window)+reset_for_new_keyframe() -> PreintegratedCombinedMeasurements. C5 builds theCombinedImuFactoritself from the closed PIM + the (X, V, B) key triple — that's not the preintegrator's job.- Anchor freshness semantics — task spec said
_last_anchor_ns = pose.emitted_at, butPoseEstimatehas noemitted_atfield and the contract describes the value as a monotonic age (consumed bylast_anchor_age_mswhich usestime.monotonic_ns()). Switched to_last_anchor_ns = time.monotonic_ns(). CombinedImuFactorkey management — the factor needs a(prev_X, prev_V, prev_B, curr_X, curr_V, curr_B)6-tuple of keys. Introduced two new GTSAM key namespaces ('v'for velocity,'b'for bias) with an independent_imu_keyframe_counter. The'x'(pose) namespace is shared with VIO / pose-anchor via_next_key_counter, but the IMU chain steps it independently per IMU keyframe — the composition root will arrange the higher-level scheduling that fuses the chains.
Files added / modified
Modified (prod)
src/gps_denied_onboard/components/c5_state/interface.py— correctedadd_fc_imuProtocol annotation fromImuTelemetrySampletoImuWindow; added a docstring note explaining the AZ-381 → AZ-383 fix-up.src/gps_denied_onboard/components/c5_state/gtsam_isam2_estimator.py— replaced the threeNotImplementedErrorplaceholders with real factor-add bodies; added_DEFAULT_POSE_SIGMA = 0.1module constant; added new state fields:_log,_isam2_handle(back-reference to the handle),_last_added_ts_ns(Invariant 2 monotonic guard),_prev_vio(between-factor wiring),_imu_keyframe_counter+_prev_imu_x_key/_prev_imu_v_key/_prev_imu_b_key(IMU chain); addedattach_handle(handle)method; extendedkey_for_framesignature toUUID | int; added internal helpers_require_handle,_guard_timestamp,_reset_staging; added module-level pure helpers_datetime_to_ns,_pose_se3_to_gtsam,_build_pose_noise,_make_timestamp_map; updatedcreate(...)to back-wire the handle to the estimator viaattach_handle(...).
Added (tests)
tests/unit/c5_state/test_az383_factor_adds.py— 21 new tests covering all 8 ACs using mockISam2GraphHandleinstances (real iSAM2 would require seeded initial values which AZ-388 owns — see "Test strategy" below).
Modified (tests)
tests/unit/c5_state/test_az382_isam2_smoother_wiring.py— removed the three now-obsoletetest_ac10_add_*_raises_named_az383tests; their behaviour is now governed by AZ-383's AC tests. Replaced with a comment block pointing readers totest_az383_factor_adds.py. The three remainingtest_ac10_*_raises_named_az384tests (the marginals/outputs methods) were retained.
Test strategy — why mock handles
A smoke test of add_vio against the real ISam2GraphHandleImpl failed with iSAM2.update failed: Attempting to … the key "x0", which does not exist in the Values. The reason: a between-factor between x0 and x1 requires both keys to already be in gtsam.Values. The first add_vio call would need to ALSO seed x0 with a prior or an initial value, which is the responsibility of AZ-388 (AC-5.2 fallback). Rather than smuggle that responsibility into AZ-383, the AC tests verify only what AZ-383 owns:
- The correct GTSAM factor TYPE is constructed (
BetweenFactorPose3/PriorFactorPose3/CombinedImuFactor). - The correct keys are passed.
handle.add_factorandhandle.updateare called the expected number of times.- The mode-dispatch in
add_pose_anchorcorrectly SKIPS the add on the JACOBIAN path. - Invariant 2 enforcement raises
EstimatorDegradedError+ emits the structuredc5.state.out_of_orderlog. - Every success path emits a
*_okDEBUG log; every failure path emits a*_failedERROR log and raises through the C5 error hierarchy. _last_anchor_nsis updated on BOTH pose-anchor paths.
The full end-to-end iSAM2 convergence will be tested in AZ-388 once the chain is seedable.
Architectural notes
_FRAME_PERIOD_Sconstant — inherited from AZ-382 (D-C5-3 fixes the keyframe rate at 3 Hz). AZ-383 did not touch the smoother window logic.- (X, V, B) chain is independent of the frame-id mapping — IMU keyframes are NOT VIO keyframes; the contract documents this. The composition root will fuse the chains; AZ-383's per-window factor add is self-contained.
- Defensive logging is mandatory (R05) — every successful add:
c5.state.add_vio_ok/c5.state.add_pose_anchor_ok/c5.state.add_fc_imu_ok. Every failure:c5.state.add_vio_failed/c5.state.add_pose_anchor_failed/c5.state.add_fc_imu_failed/c5.state.add_fc_imu_preintegrate_failed. JACOBIAN-path skip emits INFOc5.state.skip_isam2_jacobian_path. Out-of-order: ERRORc5.state.out_of_order. The C5 contract documents this surface because silent factor-add failures bit the prototype. - First-call priming — both
add_vioandadd_fc_imuSHORT-CIRCUIT the first call (_prev_vio is None/_prev_imu_x_key is None) because there is no previous frame to relate to. This is documented in both method docstrings. _reset_staging()after every successful flush — the staging_graph/_valuesare cleared post-flush so the nextadd_*builds a fresh delta. This matches the iSAM2 incremental-update idiom (the handle'supdate(graph, values, …)accepts the DELTA, not the cumulative graph).
Test counts
| Suite | Before (B13) | After (B14) | Delta |
|---|---|---|---|
| Total passing | 547 | 565 | +18 |
| Skipped | 2 | 2 | 0 |
| AZ-383 (new) | 0 | 21 | +21 |
| AZ-382 (preserved) | 27 | 24 | −3 (obsolete test_ac10_add_*_raises_named_az383 tests removed; behaviour now lives in AZ-383 tests) |
Run command: PYTHONPATH=src pytest tests/ -q → 565 passed, 2 skipped in ~20s.
Lint / type
ruff check src/gps_denied_onboard/components/c5_state/ tests/unit/c5_state/— clean (1RUF002ambiguous-Unicode in a docstring auto-fixed during the cycle).ruff format src/gps_denied_onboard/components/c5_state/ tests/unit/c5_state/— clean (1 file reformatted).ReadLintsonsrc/gps_denied_onboard/components/c5_state/+tests/unit/c5_state/— 0 errors.
Acceptance evidence
| AC | Test(s) | Status |
|---|---|---|
| AC-1 VIO factor add | test_ac1_vio_first_call_records_without_factor, test_ac1_vio_second_call_adds_between_factor |
PASS |
| AC-2 Pose-anchor MARGINALS path | test_ac2_pose_anchor_marginals_adds_prior_factor_and_updates, test_ac2_pose_anchor_marginals_bumps_last_anchor_ns |
PASS |
| AC-3 Pose-anchor JACOBIAN skip | test_ac3_pose_anchor_jacobian_skips_factor_add, test_ac3_pose_anchor_jacobian_still_bumps_last_anchor_ns |
PASS |
| AC-4 IMU factor add via ImuPreintegrator | test_ac4_imu_first_window_primes_chain_no_factor, test_ac4_imu_second_window_adds_combined_imu_factor |
PASS |
| AC-5 Timestamp ordering | test_ac5_vio_out_of_order_raises_degraded, test_ac5_pose_anchor_out_of_order_raises_degraded, test_ac5_imu_out_of_order_raises_degraded, test_ac5_emits_out_of_order_log |
PASS |
| AC-6 Defensive logging | test_ac6_vio_success_emits_debug_log, test_ac6_pose_anchor_success_emits_debug_log, test_ac6_vio_failure_emits_error_log_and_raises |
PASS |
| AC-7 Update exactly once | test_ac7_vio_triggers_update_once, test_ac7_pose_anchor_marginals_triggers_update_once, test_ac7_pose_anchor_jacobian_triggers_no_update, test_ac7_imu_second_window_triggers_update_once |
PASS |
AC-8 _last_anchor_ns accuracy |
test_ac8_last_anchor_age_ms_after_anchor_is_small |
PASS |
| Defensive — no-handle | test_no_handle_raises_state_estimator_config_error |
PASS |
Known forward actions (not in scope this batch)
- AZ-388 (AC-5.2 fallback) — first-keyframe seeding (prior factor + initial values on x0) so an end-to-end smoke test of
add_vio→ real iSAM2 update converges. The current AZ-383 tests use mock handles to avoid coupling the factor-add semantics to that scheduling. - AZ-384 —
current_estimate/smoothed_history/health_snapshotbodies; will read marginals from the handle and translate them into the C5EstimatorOutput/EstimatorHealthDTOs. - Composition root batching — turn the C8
ImuTelemetrySamplestream intoImuWindowinstances before callingadd_fc_imu. Owned by the composition-root binding task. - Cross-stream timestamp merge — AZ-383 enforces per-call monotonicity but does not order the three streams against each other; the composition root will arrange that.