ADR-0006: NIS window monitor with adaptive covariance inflation¶
- Status: accepted
- Date: 2026-09
- Closes: B5 in
CONSTRAINTS.md - Builds on: ADR-0005 (chi-square gating), ADR-0002 (Joseph form)
Context¶
ADR-0005 made the filter's rejections honest by giving them a false-alarm rate and a memory. Applying it to the benchmark exposed a defect that the gate could not fix, because the gate was not what was wrong.
With GNSS denied for 15 s, the filter dead-reckons. Its position covariance collapses along with the visual anchor error it never modelled. When GNSS returns, the healthy fixes produce innovations that are enormous against that collapsed covariance, and the gate (correctly, given its premise) rejects them. The filter then dead-reckons the rest of the run while a working sensor shouts at it.
Measured: 51 of 75 fixes rejected, ATE 5.059 m, mean NEES 1996.5 against an expected 3, 16.0% coverage. The filter was confidently wrong while being useless.
The rejection is not a bug in the arithmetic. d² = yᵀ S⁻¹ y with
S = H P Hᵀ + R is correct, and the threshold is right for the covariance it
was given. The premise is wrong: the covariance is not calibrated, so a
correctly computed gate has nothing true to be strict about.
Two things follow, and they pull in opposite directions.
The filter is at fault here. It went blind, so it should not hold its position as tightly as it claims to.
The sensor is not. Those fixes are correct. Rejecting them is a filter error, not a sensor fault, and isolating the channel makes it worse.
So the fix cannot be a wider gate. A wider gate is the same decision with a larger number attached, and it costs the false-alarm rate ADR-0005 was written to establish. It would admit the sustained spoof that the whole subsystem exists to catch.
The distinction that makes it decidable is not the size of the innovation. It is whether the channel was silent. Silence is observable, comes from the filter's own state, and needs no assumption about the threat model. A channel that has been streaming at 5 Hz has not been starved, so an innovation it now produces is not something a covariance grown by its own outage explains.
Decision¶
- Add
fdir/nis_monitor.py: a bounded per-channel window of(d², accepted)pairs, andis_persistent_divergence, which reads a trailing run ofconsecutive_rejection_thresholdrejections rather than a count. A count cannot distinguish consecutive failures from a 90% rejection rate; a trailing run can, and the difference is what the second pass turns on. FdirManager.evaluate_and_adaptgates as before, then, only if the monitor reports a trailing run, re-gates under an inflated covariance. The first gate is unchanged: a rejection is a rejection, and one bad sample buys nothing.- Inflate the state block the measurement actually constrains, and recompute
S' = S + H ΔP Hᵀ. That is not an optimisation: it is exactly theSthe estimator will have once it honours the decision, becauseS = H P Hᵀ + Rand the inflation adds toPonly. Deriving it the other way would needRfrom the caller too, and would leave two chances for the re-gate and the fusion to disagree. - Bound the grant three ways, because each one closes a hole the other two leave:
- By the factor cap:
max_inflation_factor, so no innovation, including a non-finite one, buys unbounded covariance. - By the drift bound: at most
max_drift_sigma_mpsof sigma per second of the channel's longest single silence. A stream at 5 Hz has 200 ms of silence and so has almost no budget, however long the attack runs. This is the property that separates a genuine outage from a sustained spoof, and it is the reason the budget is measured from the longest gap rather than from elapsed time. - By the re-gate: the inflated innovation must pass with headroom, not merely pass. A second pass that only has to reach the line is one rounding step from reintroducing the original failure.
- Apply at most one grant per divergence episode. A filter that can re-inflate indefinitely is a filter walking itself onto whatever the measurements say.
- Only a genuine outlier is eligible for relief. The two ways a channel can
be excluded both read
accepted = Falseand are not the same thing: the numbers said the sample was impossible, or the numbers were happy and a declared fault is holding the channel out anyway. Only the first is evidence that the filter's own covariance is wrong.
This distinction is load-bearing, and getting it wrong inverts the mechanism over the range an attacker would actually pick. A 1 m sustained spoof is not a 1 m innovation for long: the filter is pulled toward it, the residual shrinks, and within a few epochs the innovation is comfortably inside the threshold. The gate is entirely happy. The channel is excluded by the fault it correctly declared, and a second pass that could not tell the two apart spent the drift budget on that exclusion, re-gated successfully, and moved the estimate 2.1 m. A 3 m spoof, whose innovation stays out of range, was refused. Measured before the fix: 1 m and 2 m each obtained a grant; 3 m and above obtained none.
A fault is a security decision, and a 0.1 m drift budget must not be able to
overturn one. GatingDecision.outlier carries the distinction. Both halves
are pinned by test, because a test on a large offset alone passes against the
broken code.
- Record the longest material silence per channel and reset the health counters
across it. A run of 26 accepted updates accumulated before a 15 s denial says
nothing about the first update after it, and carrying that count across is
what let a healthy history wipe the drift budget on exactly the epoch that
needed it.
- Grant inflation to the GNSS position block only. The units are metres per
second of translational drift, and a metre-derived variance added to a
radian block is a dimensional mistake that still yields a valid covariance,
so nothing downstream would object. The visual blocks keep the gate ADR-0005
gave them, unrelaxed, which is the conservative direction for a
security-relevant channel.
Consequences¶
- B5 closes. On the benchmark case: ATE 5.059 m to 2.541 m, rejections 51 to 5, mean NEES 1996.5 to 419.4. With camera frames dropped, 3.339 m to 1.872 m and 931.4 to 216.6.
- Scale (2026-09, seed sweep). All the figures above are the seed-0 draw.
Repeating
outage_visualover 10 noise realisations gives mean NEES 844 (range 211.7 to 2103.4), so 419.4 is a low draw rather than a central value, and the pre-inflation 1996.5 is likewise one sample. The mitigation's direction is robust (the filter is overconfident at every seed) but the magnitudes are single draws and should be read as orders of magnitude. The camera-drop pair is weaker still: its two cases have overlapping ranges, so 216.6 versus 419.4 does not establish that dropping frames helps. See N10 indocs/audit/06-revised-roadmap.md. - Correction (2026-09, audit). The figures above originally read "30% of camera frames dropped, 3.339 m to 2.005 m and 931.4 to 264.8". The benchmark config asked for a 30% drop but misspelled the key, so that case ran at the 20% default while being described as 30%. The direction and the conclusion are unchanged (B5 still closes and the degraded case is still better with the monitor) but the measured values are the 20% ones until re-measured, and the 30% numbers are now 1.872 m and 216.6.
- The blockage is not the same as calibration. A 2.54 m error with 0.161 m claimed is still overconfident, and the coverage column still says so. What is resolved is the filter's refusal to hear a working sensor; what is not resolved is the modelled visual anchor error that made the covariance wrong in the first place. The two are separate defects and only one is closed.
- The ATE column loses its accidental warning. Before this change the
dishonest aided case was also the inaccurate one, so sorting by error
happened to separate them. Recovering the error put the dishonest filter
back on top, and the table no longer flags it by itself. Ranking by error is
not a calibration check;
CONSTRAINTS.mdnow requires both columns. - A sustained offset delivered continuously earns no drift budget, so it never becomes plausible. Verified at 1, 2, 3, 5, 10, 20, 40 and 100 m: zero grants throughout, and at 1 m and 2 m the channel escalates to a declared fault. The 1 m and 2 m cases reject 49 and 60 fixes and then coast on the inertial solution, which is the intended trade: refusing a plausible-looking sensor is a smaller error than following it.
- The manager can be driven without
PandH(as ADR-0005's tests do), in which case the second pass declines. TheREJECTED_PERSISTENTlabel still applies, because it reports what the monitor observed rather than what the second pass could do with it. - Cost is one trailing-run check per rejection and, on a grant, one 3×3 recomputation. No new dependency.
STATUS_REJECTED_TRANSIENTbecameSTATUS_REJECTED_SPOOF. This is a policy name, not a diagnosis: the gate cannot distinguish multipath from a spoof from a miscalibrated filter, and ADR-0005 says so. The name records what the filter does (an unproven channel gets no relief) and the module docstring says the rest.
Alternatives rejected¶
- Widen the gate for reacquisition. No measurement distinguishes it from the sustained spoof it must not admit, and it discards the false-alarm rate ADR-0005 exists to provide.
- Reset
Pto a nominal covariance on the first rejected update after silence. Simpler, and it is what a bank of pre-outage covariance would do. It needs the same silence evidence but spends the whole covariance on one epoch, discarding what the filter had actually learned, and it has no bound on what "nominal" is. - Trust the sensor on return, unconditionally. This is the failure mode in the other direction, and it is the one an attacker actually wants.
- Inflate the full 21×21 state. Valid covariance, wrong filter: a position innovation would make the filter suddenly unsure of its own gyro bias.
- Apply the metre-based bound to the visual blocks. Dimensionally wrong in a way no test would catch, and the visual channels are the ones where relaxing protection is most expensive.
Follow-up¶
- The modelled visual anchor error is the remaining cause of the NEES gap, and
is tracked separately in
CONSTRAINTS.md. B5's closure is not a claim that the aided case is calibrated. reacq_sigma_mandmax_drift_sigma_mpswere chosen from the benchmark's own outage length and drift rate, not from a target error budget. They are numbers that can be argued with, which is why they are named inFdirConfigrather than hard-coded, and why the ADR records them.