[AZ-387] C5 smoothed-history → FDR side-channel

After every successful current_estimate(), emit one
c5.state.smoothed_history FDR record per newly-smoothed past
keyframe from IncrementalFixedLagSmoother. AC-4.5 (revised): the
smoothed stream goes ONLY to FDR; the C8 outbound forward-time
stream is unaffected.

Idempotency via _smoothed_fdr_watermark_s (smoother-native float
seconds); the same pose key is never emitted twice. Hook is
best-effort — internal failures log warnings but do not raise, so
a smoother divergence cannot contaminate the forward-time path.

Cross-task invariants documented:
- AC-3 ESKF no-op — AZ-386 installs an inert hook on the ESKF.
- AC-4 No C8 leak — enforced at the C8 boundary by AZ-261.

8 new unit tests against AC-1/2/5/6 + robustness (no-FDR-client,
marginals failure). Full suite: 640 passed, 2 skipped.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 07:13:44 +03:00
parent 7cbd17ee83
commit 098aabac0c
5 changed files with 479 additions and 1 deletions
@@ -0,0 +1,72 @@
# Batch 18 — Cycle 1 Implementation Report
**Batch**: 18 of N
**Tasks landed**: AZ-387 (`GtsamIsam2StateEstimator` — smoothed past-keyframe → FDR side-channel)
**Cycle**: 1
**Date**: 2026-05-11
## Scope
| Task | Component | Purpose |
|------|-----------|---------|
| AZ-387 | C5 state estimator | Implements the AC-4.5 (revised) onboard-only smoothed-history → FDR path: after every successful `current_estimate()`, walk the `IncrementalFixedLagSmoother` active POSE keys and emit one `c5.state.smoothed_history` FDR record per *newly-smoothed* past-keyframe. The watermark `_smoothed_fdr_watermark_s` (a smoother-native float-second timestamp) prevents the same key from being emitted twice. AC-3 (ESKF no-op) is structurally satisfied — AZ-386 owns the ESKF impl; when it lands it installs an inert hook because ESKF doesn't run a smoother. AC-4 (no leak to C8 outbound) remains a cross-task invariant enforced at the C8 boundary by AZ-261; this task only emits to `FdrClient` and never touches the C8 outbound queue. |
## Files added / modified
### Modified (prod)
- `src/gps_denied_onboard/components/c5_state/gtsam_isam2_estimator.py` — added the new private hook `_emit_smoothed_to_fdr_if_any(emitted_at_ns, source_label, last_anchor_age_ms)` (defined after `smoothed_history`); wired one call to it inside `current_estimate()` immediately after the AZ-388 `mark_successful_estimate` hook; introduced one new state field `_smoothed_fdr_watermark_s: float = -inf` for the timestamp watermark; new module-level imports `datetime` + `timezone` (for the FDR record `ts` field) and `FdrRecord` from `gps_denied_onboard.fdr_client.records`. The hook is best-effort by design: any internal failure (marginals divergence, per-key SPD failure, FDR queue overflow) logs a WARNING but does NOT raise — the forward-time estimate has already been computed and returned to the caller.
### Added (tests)
- `tests/unit/c5_state/test_az387_smoothed_history_fdr.py` — 8 tests across the AC-1/2/5/6 surface (the AZ-387-testable ACs) plus three robustness checks (no-FDR-client → no crash, marginals failure → no raise, AC-3 / AC-4 invariant marker). Uses a real `GtsamIsam2StateEstimator` with a seeded prior factor + iSAM2 update path; the `_seed_keys(n)` helper plants `n` real POSE keys with strictly-increasing smoother timestamps so the AC-5 watermark behaviour is exercised end-to-end. AC-3 (ESKF) and AC-4 (C8 leak) are documented as cross-task invariants pending AZ-386 / AZ-261 respectively; the test suite carries a marker test so the invariant is not silently lost.
## Architectural notes
- **Hook lives at the end of `current_estimate`** — placed AFTER the AZ-388 `mark_successful_estimate` so that a hook failure does NOT cause AC-5.2 fallback to engage. The fallback watcher only cares about the forward-time path; the smoothed-history emission is a forensic side-channel and must not contaminate the live-state lifecycle.
- **Watermark in smoother-native time, not monotonic_ns** — the smoother's `timestamps().at(key)` returns the value the caller passed at `update(timestamps=...)`, which by convention is the wall-clock decode timestamp converted to seconds. Storing the watermark in the same unit avoids any ns ↔ s drift and makes the comparison strictly correct against the smoother's own ordering. `float("-inf")` initial value means the first call emits every smoothed key in the window.
- **Per-key marginals compute is shared with the live path** — both the live `current_estimate` and the hook call `handle.compute_marginals()`, but they are SEPARATE calls. We could cache the live-path marginals to save one GTSAM call per `current_estimate`, but the iSAM2 graph could have advanced between the two calls (no, actually — `current_estimate` and the hook run on the same thread without intervening `update`s, so the graph state is identical). I deliberately did NOT cache the marginals — keeping them separate means future refactors (e.g. moving the hook off the C5 ingest thread) work without a hidden coupling. The cost is one extra `compute_marginals` per `current_estimate` p99 — well under the AZ-387 NFR of 5 ms.
- **`smoothed=True` flag is hard-coded in the payload** — the C5 contract says smoothed-history records MUST carry `smoothed=True`; if a future refactor reuses the hook for a non-smoothed path, the payload field needs to flip. Documented in the impl docstring.
- **FDR record kind is a single fixed string** — `c5.state.smoothed_history`, matching the AZ-272 record-schema namespace. The AZ-295 record-kind policy test runs against the FDR sink config; this kind is already on the allow-list (verified by the full-suite passing).
- **No leak to C8 outbound (AC-4) — single-sink enforcement** — the hook calls ONLY `self._fdr_client.enqueue`. There is no path from the hook to any other sink. C8's filter (AZ-261) catches any accidental leak at the boundary; this task's contribution to AC-4 is the structural guarantee that the hook itself doesn't introduce a leak.
- **ESKF no-op (AC-3) — structural by absence** — `EskfStateEstimator` (AZ-386) will not have a smoother instance to walk, so the equivalent hook there is just `def _emit_smoothed_to_fdr_if_any(self, *_args, **_kwargs) -> None: return`. The AZ-386 task gets a one-liner to satisfy AC-3.
## Test counts
| Suite | Before (B17) | After (B18) | Delta |
|-------|--------------|-------------|-------|
| Total passing | 632 | 640 | +8 |
| Skipped | 2 | 2 | 0 |
| AZ-387 (new) | 0 | 8 | +8 |
Run command: `PYTHONPATH=src pytest tests/ -q``640 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 (unused import).
- `ruff format` — 1 file reformatted, 17 unchanged; second pass clean.
- `ReadLints` on touched files — 0 errors.
## Acceptance evidence
| AC | Test(s) | Status |
|----|---------|--------|
| AC-1 iSAM2 emits smoothed records to FDR | `test_ac1_first_current_estimate_emits_smoothed_records` | PASS |
| AC-2 Records have `smoothed=True` | `test_ac2_smoothed_flag_is_true` | PASS |
| AC-3 ESKF no-op | `test_ac3_ac4_cross_task_invariants_documented` (marker; AZ-386 owns the impl) | DEFERRED |
| AC-4 No leak to C8 outbound | `test_ac3_ac4_cross_task_invariants_documented` (marker; AZ-261 enforces the filter) | DEFERRED |
| AC-5 Idempotency via watermark | `test_ac5_idempotent_no_new_keys_no_second_emission`, `test_ac5_idempotent_new_keys_only_emitted_once` | PASS |
| AC-6 FDR record shape | `test_ac6_fdr_record_has_required_shape` | PASS |
| Robustness — no FDR client → no crash | `test_estimator_without_fdr_client_does_not_crash_on_hook` | PASS |
| Robustness — marginals failure → no raise | `test_hook_marginals_failure_does_not_raise` | PASS |
## Known gaps / followups
- **AC-3 ESKF wire-up** — owned by AZ-386. The ESKF estimator MUST install a no-op `_emit_smoothed_to_fdr_if_any` (or simply skip the hook entirely). The marker test in this batch surfaces the invariant.
- **AC-4 C8 filter** — owned by AZ-261. The C8 inbound subscription / outbound emit path MUST drop any `EstimatorOutput` with `smoothed=True`. This task documents the invariant; AZ-261 owns the enforcement.
- **AZ-272 FDR record schema validation** — the FDR record produced by this task uses the v1 envelope with a domain-specific payload. The schema-validation hook in AZ-272 is opt-in per kind; future work may pin a JSON schema for `c5.state.smoothed_history` once the record shape is verified in flight tests.
## Risks accepted
- **Extra marginals compute per `current_estimate`** — measured well under the 5 ms NFR in current fixtures; if a profiling pass later reveals contention we can share the marginals between the live path and the hook.
- **Hook silent on partial failure** — by design (the forward-time path has already returned to the caller). Forensic logs land in the structured `c5.state.smoothed_history_fdr_*_failed` log records.
+1 -1
View File
@@ -8,7 +8,7 @@ status: in_progress
sub_step: sub_step:
phase: 6 phase: 6
name: implement-tasks name: implement-tasks
detail: "batch 17 of N committed (AZ-385 c5 source-label state machine + spoof-promotion gate: dwell + visual-consistency conditions + unsilenceable FDR/STATUSTEXT reject path + estimator wire-up via notify_gps_health/subscribe_spoof_rejection)" detail: "batch 18 of N committed (AZ-387 c5 smoothed-history \u2192 FDR side-channel: per-keyframe FDR emission + timestamp watermark idempotency + AC-4.5 onboard-only path + AC-3/AC-4 cross-task invariants documented)"
retry_count: 0 retry_count: 0
cycle: 1 cycle: 1
tracker: jira tracker: jira
@@ -32,6 +32,7 @@ from __future__ import annotations
import time import time
from collections import deque from collections import deque
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Final from typing import TYPE_CHECKING, Any, Final
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@@ -70,6 +71,7 @@ from gps_denied_onboard.components.c5_state.errors import (
StateEstimatorConfigError, StateEstimatorConfigError,
) )
from gps_denied_onboard.components.c5_state.interface import StateEstimator from gps_denied_onboard.components.c5_state.interface import StateEstimator
from gps_denied_onboard.fdr_client.records import FdrRecord
from gps_denied_onboard.helpers.wgs_converter import WgsConverter from gps_denied_onboard.helpers.wgs_converter import WgsConverter
from gps_denied_onboard.logging import get_logger from gps_denied_onboard.logging import get_logger
@@ -223,6 +225,15 @@ class GtsamIsam2StateEstimator(StateEstimator):
# ``LOST`` on a fatal SPD failure or GTSAM exception. # ``LOST`` on a fatal SPD failure or GTSAM exception.
self._isam2_state: IsamState = IsamState.INIT self._isam2_state: IsamState = IsamState.INIT
# AZ-387 state -----------------------------------------------------
# Watermark for the smoothed-history → FDR hook. We only emit a
# smoothed past-keyframe to FDR if its smoother timestamp is
# strictly greater than this watermark; the watermark is bumped
# to the largest emitted timestamp after every call. Idempotent
# by construction (AC-5 — same key never emitted twice). The
# watermark is in seconds (float, smoother native units).
self._smoothed_fdr_watermark_s: float = float("-inf")
# AZ-388 state ----------------------------------------------------- # AZ-388 state -----------------------------------------------------
# AC-5.2 fallback watcher — engages when ``current_estimate`` # AC-5.2 fallback watcher — engages when ``current_estimate``
# cannot produce a fresh output for ``no_estimate_fallback_s`` # cannot produce a fresh output for ``no_estimate_fallback_s``
@@ -729,6 +740,16 @@ class GtsamIsam2StateEstimator(StateEstimator):
# fires the recovery signal if we were previously engaged. # fires the recovery signal if we were previously engaged.
self._fallback.mark_successful_estimate(emitted_at) self._fallback.mark_successful_estimate(emitted_at)
# AZ-387: smoothed-history → FDR side-channel. Emits any
# newly-smoothed past-keyframes to FDR with ``smoothed=True``;
# idempotent via the timestamp watermark. AC-4.5 forbids
# routing smoothed estimates to C8 outbound — that filter is
# enforced on the C8 side and documented as a cross-task
# invariant. The hook is best-effort: a failure here is
# logged but does NOT raise (the forward-time estimate has
# already been computed).
self._emit_smoothed_to_fdr_if_any(emitted_at, source_label, last_anchor_age_ms)
return EstimatorOutput( return EstimatorOutput(
frame_id=uuid4(), frame_id=uuid4(),
position_wgs84=position_wgs84, position_wgs84=position_wgs84,
@@ -834,6 +855,124 @@ class GtsamIsam2StateEstimator(StateEstimator):
) )
return out return out
def _emit_smoothed_to_fdr_if_any(
self,
emitted_at_ns: int,
source_label: PoseSourceLabel,
last_anchor_age_ms: int,
) -> None:
"""AZ-387 hook — emit newly-smoothed past-keyframes to FDR.
AC-4.5 (revised): smoothed past-keyframes are an onboard-only
side-channel for forensics. They MUST land in FDR (so post-
flight tooling can refine the trajectory with later evidence)
and they MUST NOT reach C8 outbound (the FC expects a
forward-time stream). This hook walks the
``IncrementalFixedLagSmoother`` active POSE keys, filters to
those whose smoother timestamp is strictly newer than the
watermark, and enqueues one FDR record per key.
Idempotency (AC-5): the watermark is the largest smoother
timestamp ever emitted; the second call against the same
smoother state emits zero records. Failure path: a marginals
compute error logs ``c5.state.smoothed_history_marginals_failed``
but does NOT raise (the forward-time estimate succeeded and
was emitted to the caller already).
"""
if self._fdr_client is None:
return
try:
active_values = self._smoother.calculateEstimate()
ts_map = self._smoother.timestamps()
except Exception:
return
new_entries: list[tuple[int, float]] = []
for key in active_values.keys():
ikey = int(key)
if gtsam.symbolChr(ikey) != ord("x"):
continue
try:
ts_sec = float(ts_map.at(ikey))
except Exception:
continue
if ts_sec <= self._smoothed_fdr_watermark_s:
continue
new_entries.append((ikey, ts_sec))
if not new_entries:
return
new_entries.sort(key=lambda kt: kt[1])
try:
handle = self._require_handle()
marginals = handle.compute_marginals()
except Exception as exc:
self._log.warning(
"c5.state.smoothed_history_fdr_marginals_failed",
extra={
"kind": "c5.state.smoothed_history_fdr_marginals_failed",
"kv": {"error": str(exc)},
},
)
return
for key, ts_sec in new_entries:
try:
cov = np.asarray(marginals.marginalCovariance(key), dtype=np.float64)
_enforce_spd(cov)
pose = active_values.atPose3(key)
except Exception as exc:
self._log.warning(
"c5.state.smoothed_history_fdr_per_key_failed",
extra={
"kind": "c5.state.smoothed_history_fdr_per_key_failed",
"kv": {"pose_key": key, "error": str(exc)},
},
)
continue
position_wgs84 = self._enu_pose_to_wgs84(pose)
orientation = _quat_from_pose3(pose)
record = FdrRecord(
schema_version=1,
ts=datetime.now(tz=timezone.utc).isoformat(),
producer_id="c5_state",
kind="c5.state.smoothed_history",
payload={
"pose_key": key,
"smoother_ts_s": ts_sec,
"emitted_at": emitted_at_ns,
"position_wgs84": {
"lat_deg": position_wgs84.lat_deg,
"lon_deg": position_wgs84.lon_deg,
"alt_m": position_wgs84.alt_m,
},
"orientation_world_T_body": {
"w": orientation.w,
"x": orientation.x,
"y": orientation.y,
"z": orientation.z,
},
"covariance_6x6": cov.tolist(),
"source_label": source_label.value,
"last_satellite_anchor_age_ms": last_anchor_age_ms,
"smoothed": True,
},
)
try:
self._fdr_client.enqueue(record)
except Exception as exc:
self._log.warning(
"c5.state.smoothed_history_fdr_enqueue_failed",
extra={
"kind": "c5.state.smoothed_history_fdr_enqueue_failed",
"kv": {"pose_key": key, "error": repr(exc)},
},
)
continue
if ts_sec > self._smoothed_fdr_watermark_s:
self._smoothed_fdr_watermark_s = ts_sec
def health_snapshot(self) -> EstimatorHealth: def health_snapshot(self) -> EstimatorHealth:
"""Return the current iSAM2 health snapshot. """Return the current iSAM2 health snapshot.
@@ -0,0 +1,267 @@
"""AZ-387 — smoothed past-keyframe → FDR side-channel.
Six ACs from ``_docs/02_tasks/done/AZ-387_c5_smoothed_history_fdr.md``:
- AC-1 iSAM2 emits smoothed past-keyframes to FDR.
- AC-2 Every emitted record carries ``smoothed=True``.
- AC-3 ESKF emits zero smoothed FDR records (deferred — AZ-386 not
yet landed; documented here as a no-op cross-task invariant).
- AC-4 No leak to C8 outbound — enforced at the C8 boundary (AZ-261);
this task documents the invariant.
- AC-5 Idempotency — emitting the same smoothed past-keyframe twice
is prevented via the timestamp watermark.
- AC-6 FDR record shape — kind == ``c5.state.smoothed_history``;
payload carries the documented fields.
Tests use the same iSAM2 fixtures as AZ-388 — a real
``GtsamIsam2StateEstimator`` with a seeded prior factor so
``current_estimate`` returns a non-empty estimate.
"""
from __future__ import annotations
from unittest import mock
import gtsam
import gtsam_unstable
import numpy as np
import pytest
from gps_denied_onboard.components.c5_state.config import C5StateConfig
from gps_denied_onboard.components.c5_state.gtsam_isam2_estimator import (
GtsamIsam2StateEstimator,
create,
)
from gps_denied_onboard.runtime_root.state_factory import clear_state_registry
@pytest.fixture(autouse=True)
def _registry_isolation():
# Arrange
clear_state_registry()
yield
clear_state_registry()
def _build_estimator(
fdr_client: mock.MagicMock | None = None,
) -> tuple[GtsamIsam2StateEstimator, mock.MagicMock]:
block = C5StateConfig(strategy="gtsam_isam2", keyframe_window_size=15)
cfg = mock.MagicMock()
cfg.components = {"c5_state": block}
fdr = fdr_client if fdr_client is not None else mock.MagicMock()
estimator, _ = create(
config=cfg,
imu_preintegrator=mock.MagicMock(),
se3_utils=mock.MagicMock(),
wgs_converter=mock.MagicMock(),
fdr_client=fdr,
)
return estimator, fdr
def _seed_keys(estimator: GtsamIsam2StateEstimator, n: int = 3) -> list[int]:
"""Seed ``n`` POSE keys into the smoother + iSAM2 graph with
strictly-increasing timestamps. Returns the list of inserted keys.
"""
graph = gtsam.NonlinearFactorGraph()
values = gtsam.Values()
ts_map = gtsam_unstable.FixedLagSmootherKeyTimestampMap()
noise = gtsam.noiseModel.Isotropic.Sigma(6, 0.1)
keys: list[int] = []
for i in range(n):
key = gtsam.symbol("x", estimator._next_key_counter)
estimator._next_key_counter += 1
keys.append(key)
pose = gtsam.Pose3()
graph.add(gtsam.PriorFactorPose3(key, pose, noise))
values.insert(key, pose)
ts_map.insert((key, float(i) * 0.5))
estimator._isam2_handle.update(graph, values, timestamps=ts_map)
estimator._record_committed_pose_key(keys[-1])
return keys
def _smoothed_history_calls(fdr: mock.MagicMock) -> list:
return [
call
for call in fdr.enqueue.call_args_list
if call.args and call.args[0].kind == "c5.state.smoothed_history"
]
# ---------------------------------------------------------------------
# AC-1: iSAM2 emits smoothed past-keyframes to FDR
def test_ac1_first_current_estimate_emits_smoothed_records() -> None:
estimator, fdr = _build_estimator()
keys = _seed_keys(estimator, n=3)
estimator.current_estimate()
calls = _smoothed_history_calls(fdr)
assert len(calls) == len(keys)
emitted_keys = {call.args[0].payload["pose_key"] for call in calls}
assert emitted_keys == set(keys)
# ---------------------------------------------------------------------
# AC-2: smoothed=True on every record
def test_ac2_smoothed_flag_is_true() -> None:
estimator, fdr = _build_estimator()
_seed_keys(estimator, n=2)
estimator.current_estimate()
calls = _smoothed_history_calls(fdr)
for call in calls:
assert call.args[0].payload["smoothed"] is True
# ---------------------------------------------------------------------
# AC-5: idempotency — second call with no new keys emits nothing
def test_ac5_idempotent_no_new_keys_no_second_emission() -> None:
estimator, fdr = _build_estimator()
_seed_keys(estimator, n=3)
estimator.current_estimate()
initial_emit_count = len(_smoothed_history_calls(fdr))
assert initial_emit_count == 3
estimator.current_estimate()
assert len(_smoothed_history_calls(fdr)) == initial_emit_count
def test_ac5_idempotent_new_keys_only_emitted_once() -> None:
estimator, fdr = _build_estimator()
_seed_keys(estimator, n=2)
estimator.current_estimate()
assert len(_smoothed_history_calls(fdr)) == 2
# Add another key with a strictly newer timestamp.
new_keys = _seed_keys(estimator, n=1)
# The new key in the second `_seed_keys` call shares the
# smoother window — the second key's timestamp is 0.0 (same as
# the first seeded key from the first batch). The watermark
# mechanism filters everything <= the watermark, so a key
# with ts_s = 0.0 against a watermark of 1.0 (the third key of
# the first batch) is filtered. Use a clearly newer timestamp.
graph = gtsam.NonlinearFactorGraph()
values = gtsam.Values()
ts_map = gtsam_unstable.FixedLagSmootherKeyTimestampMap()
noise = gtsam.noiseModel.Isotropic.Sigma(6, 0.1)
fresh_key = gtsam.symbol("x", estimator._next_key_counter)
estimator._next_key_counter += 1
pose = gtsam.Pose3()
graph.add(gtsam.PriorFactorPose3(fresh_key, pose, noise))
values.insert(fresh_key, pose)
ts_map.insert((fresh_key, 5.0)) # well past the watermark
estimator._isam2_handle.update(graph, values, timestamps=ts_map)
estimator._record_committed_pose_key(fresh_key)
fdr.enqueue.reset_mock()
estimator.current_estimate()
calls = _smoothed_history_calls(fdr)
emitted_keys = {call.args[0].payload["pose_key"] for call in calls}
assert fresh_key in emitted_keys
# Both ``new_keys[0]`` (ts=0.0) and the original first-batch
# keys are below the watermark, so they should NOT re-emit.
assert new_keys[0] not in emitted_keys
# ---------------------------------------------------------------------
# AC-6: FDR record shape
def test_ac6_fdr_record_has_required_shape() -> None:
estimator, fdr = _build_estimator()
_seed_keys(estimator, n=1)
estimator.current_estimate()
calls = _smoothed_history_calls(fdr)
assert len(calls) == 1
record = calls[0].args[0]
assert record.kind == "c5.state.smoothed_history"
assert record.producer_id == "c5_state"
assert record.schema_version == 1
payload = record.payload
assert payload["smoothed"] is True
# Documented payload fields per the impl docstring.
expected_keys = {
"pose_key",
"smoother_ts_s",
"emitted_at",
"position_wgs84",
"orientation_world_T_body",
"covariance_6x6",
"source_label",
"last_satellite_anchor_age_ms",
"smoothed",
}
assert set(payload.keys()) == expected_keys
assert set(payload["position_wgs84"].keys()) == {"lat_deg", "lon_deg", "alt_m"}
assert set(payload["orientation_world_T_body"].keys()) == {"w", "x", "y", "z"}
# 6x6 covariance round-trips as a 6x6 list.
cov = np.asarray(payload["covariance_6x6"], dtype=np.float64)
assert cov.shape == (6, 6)
# ---------------------------------------------------------------------
# Robustness: no FDR client → no crash
def test_estimator_without_fdr_client_does_not_crash_on_hook() -> None:
estimator, _ = _build_estimator()
estimator._fdr_client = None # simulate no-FDR composition
_seed_keys(estimator, n=2)
# Should not raise — hook is a no-op without FDR.
estimator.current_estimate()
# ---------------------------------------------------------------------
# Robustness: marginals failure logs a warning but does NOT raise
def test_hook_marginals_failure_does_not_raise() -> None:
estimator, fdr = _build_estimator()
_seed_keys(estimator, n=1)
# Patch the handle's compute_marginals to raise. The hook is
# called AFTER ``current_estimate`` computed its own marginals
# (which succeed against the seeded prior), so we patch only
# subsequent calls.
real_compute = estimator._isam2_handle.compute_marginals
call_count = {"n": 0}
def _flaky() -> object:
call_count["n"] += 1
if call_count["n"] == 1:
return real_compute()
raise RuntimeError("marginals diverged on smoother")
with mock.patch.object(estimator._isam2_handle, "compute_marginals", side_effect=_flaky):
# The forward-time marginals succeed; the hook fails — the
# caller still sees the estimate, no exception propagates.
estimator.current_estimate()
# No smoothed FDR records emitted on the failure path.
assert len(_smoothed_history_calls(fdr)) == 0
# ---------------------------------------------------------------------
# AC-3 / AC-4 invariant docs (ESKF + C8 leak)
def test_ac3_ac4_cross_task_invariants_documented() -> None:
# AC-3 ESKF no-op — AZ-386 not yet landed; the AZ-386 estimator
# will install a no-op smoothed-FDR hook (ESKF doesn't smooth).
# AC-4 C8 outbound filter — owned by AZ-261; documented as a
# cross-task invariant. This test serves as the marker in the
# test suite that both invariants are owned and tracked.
assert True