Skip to content

Commit ddaa383

Browse files
authored
Add reference pose-arbitration consumer for the calibrated covariance (#72) (#96)
The competitive-roadmap 'Next' gate asks that the covariance be 'documented enough to be consumed by fusion or arbitration logic'. docs/pose_covariance.md already says the calibrated error_floor covariance is usable for arbitration *provided the consumer also watches /alignment_status*, but shipped no reference implementation of such a consumer. This adds one, in the Phase 3 fixture + ROS-free policy + regression-test style. Two consumers make the contract explicit: * covariance_only -- trusts purely on the published 2-sigma radius. On the error_floor model this is *actively* unsafe: a degenerate-geometry pose has low fitness, and low fitness yields a TIGHT covariance, so this consumer assigns full trust to exactly the bias-tail poses that carry multi-metre error. On the calibration fixtures it makes 4 dangerous trusts. * covariance_plus_category -- the reference consumer. It vetoes first on the diagnostics covariance cannot represent (an active degeneracy/overload failure_category, a reject streak, or an open reinitialization request) and only then weights the surviving geometrically-sound pose by its 2-sigma radius. 0 dangerous trusts. Fixtures are grounded in the calibration data (calibration_2026_06_12.json) and the documented bias tail (4.2 m at fitness 0.5-1.0 on Koide). Each fixture declares the expected verdict from both consumers so policy and fixtures cannot drift. scripts/demo_pose_arbitration.py prints the side-by-side scorecard and exits non-zero if the reference consumer ever makes a dangerous trust. test/test_pose_arbitration_policy.py: 5 tests green. docs/pose_covariance.md gets a 'Reference arbitration consumer' section.
1 parent fbf8a73 commit ddaa383

12 files changed

Lines changed: 572 additions & 0 deletions

docs/pose_covariance.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,47 @@ Cannot:
128128
| Arbitration between two pose sources | usable for 2σ-level comparisons; never trust covariance alone in degenerate geometry |
129129
| Safety-critical gating | covariance is not sufficient; use `/alignment_status` reject reasons and `reinitialization_requested` |
130130

131+
## Reference arbitration consumer
132+
133+
The guidance above ("usable, but also watch `/alignment_status`") is backed by a
134+
runnable, tested reference consumer rather than left as prose:
135+
136+
- policy: `experiments/pose_arbitration/policy.py`
137+
- fixtures: `experiments/pose_arbitration/fixtures/`
138+
- regression test: `test/test_pose_arbitration_policy.py`
139+
- demo: `python3 scripts/demo_pose_arbitration.py`
140+
141+
It contrasts two consumers of a published pose:
142+
143+
| Consumer | Inputs | Result on the calibration fixtures |
144+
| --- | --- | --- |
145+
| `covariance_only` | published 2σ radius only | **4 dangerous trusts** — fooled by the bias tail |
146+
| `covariance_plus_category` | 2σ radius **+** `failure_category`, `consecutive_rejected_updates`, `reinitialization_requested` | **0 dangerous trusts** |
147+
148+
The decisive case is the **bias tail**: a degenerate-geometry pose has *low*
149+
fitness, and in `error_floor` mode low fitness yields a *tight* covariance, so a
150+
covariance-only consumer assigns full trust to exactly the poses carrying
151+
multi-metre error. The reference consumer first vetoes on the diagnostics
152+
covariance cannot represent — an active degeneracy/overload category
153+
(`weak_overlap` is the tell), a reject streak, or an open reinitialization
154+
request — and only then uses the 2σ radius to weight the surviving,
155+
geometrically-sound pose. Demo output:
156+
157+
```text
158+
fixture true_err cov2sig covariance_only covariance_plus_category
159+
degenerate_low_fitness_bias_tail_should_reject 4.20m 0.56m trust w=1.00! reject w=0.00
160+
healthy_tight_should_trust 0.18m 0.46m trust w=1.00 trust w=1.00
161+
loose_covariance_should_downweight 0.24m 1.20m downweight w=0.83 downweight w=0.83
162+
reinit_requested_should_reject 12.00m 0.60m trust w=1.00! reject w=0.00
163+
reject_streak_should_reject 3.10m 0.50m trust w=1.00! reject w=0.00
164+
stale_prediction_jump_should_reject 7.50m 0.60m trust w=1.00! reject w=0.00
165+
```
166+
167+
This is the consumable-covariance contract: covariance decides *how much* to
168+
trust a geometrically-sound pose; `/alignment_status` decides *whether the pose
169+
is sound at all*. Either signal alone is insufficient; together they are safe for
170+
fusion and arbitration.
171+
131172
## Related docs
132173

133174
- [troubleshooting.md](troubleshooting.md)`/alignment_status` field guide,

experiments/pose_arbitration/__init__.py

Whitespace-only changes.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "degenerate_low_fitness_bias_tail_should_reject",
3+
"description": "THE money case. Degenerate geometry (e.g. a long corridor / open field) produces an accepted pose with LOW fitness (~0.8) yet multi-metre true error (4.2 m, the value docs/pose_covariance.md records for fitness 0.5-1.0 on Koide). In error_floor mode low fitness yields a TIGHT covariance (std ~0.28 m, 2sigma 0.56 m), so the covariance-only consumer assigns it FULL trust -- it is fooled into maximal confidence on the most-wrong pose. The degeneracy shows up as weak_overlap in /alignment_status, which the reference consumer vetoes on. This fixture is exactly the limit the covariance doc says covariance cannot bound and a consumer must watch failure_category for.",
4+
"source": "docs/pose_covariance.md 'systematically biased tail' (4.2 m at fitness 0.5-1.0, Koide); weak_overlap from alignment_failure_taxonomy.hpp",
5+
"expectation": {
6+
"covariance_only": "trust",
7+
"covariance_plus_category": "reject"
8+
},
9+
"sample": {
10+
"index": 77,
11+
"cov_xy_std_m": 0.28,
12+
"cov_yaw_std_deg": 2.8,
13+
"failure_category": "weak_overlap",
14+
"consecutive_rejected_updates": 0,
15+
"reinitialization_requested": false,
16+
"abs_xy_error_m": 4.2
17+
}
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "healthy_tight_should_trust",
3+
"description": "Real healthy tracking: low fitness -> tight error_floor covariance (std ~0.23 m, 2sigma 0.46 m) and a small true error. Both consumers should TRUST -- the calibrated covariance is doing its job here.",
4+
"source": "koide outdoor_hard_01a calibration (calibration_2026_06_12.json, koide_baseline fitness<0.5 bin: p68 0.18 m)",
5+
"expectation": {
6+
"covariance_only": "trust",
7+
"covariance_plus_category": "trust"
8+
},
9+
"sample": {
10+
"index": 10,
11+
"cov_xy_std_m": 0.23,
12+
"cov_yaw_std_deg": 2.3,
13+
"failure_category": "healthy",
14+
"consecutive_rejected_updates": 0,
15+
"reinitialization_requested": false,
16+
"abs_xy_error_m": 0.18
17+
}
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "loose_covariance_should_downweight",
3+
"description": "Accepted but middling match: fitness ~4 -> error_floor loosens to std ~0.6 m (2sigma 1.2 m), above the 1.0 m trust radius. Both consumers should DOWNWEIGHT, not trust. This is the regime where covariance alone IS informative -- a worse match correctly produces a looser, lower-weight pose.",
4+
"source": "koide outdoor_hard_01a calibration (fitness 2-4 bin: p68 0.17-0.24 m); error_floor std = 0.2 + 0.1*fitness",
5+
"expectation": {
6+
"covariance_only": "downweight",
7+
"covariance_plus_category": "downweight"
8+
},
9+
"sample": {
10+
"index": 42,
11+
"cov_xy_std_m": 0.6,
12+
"cov_yaw_std_deg": 6.0,
13+
"failure_category": "healthy",
14+
"consecutive_rejected_updates": 0,
15+
"reinitialization_requested": false,
16+
"abs_xy_error_m": 0.24
17+
}
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "reinit_requested_should_reject",
3+
"description": "The localizer has raised /reinitialization_requested -- it has judged tracking lost. No covariance value on a pose published in this state is trustworthy for fusion. covariance-only ignores the signal; the reference consumer rejects unconditionally.",
4+
"source": "alignment_status reinitialization_requested (the G3 recovery trigger)",
5+
"expectation": {
6+
"covariance_only": "trust",
7+
"covariance_plus_category": "reject"
8+
},
9+
"sample": {
10+
"index": 240,
11+
"cov_xy_std_m": 0.3,
12+
"cov_yaw_std_deg": 3.0,
13+
"failure_category": "bad_match",
14+
"consecutive_rejected_updates": 2,
15+
"reinitialization_requested": true,
16+
"abs_xy_error_m": 12.0
17+
}
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "reject_streak_should_reject",
3+
"description": "The last published pose still looks clean (healthy category, tight covariance), but the localizer has rejected several scans in a row since -- tracking is degrading and the held pose is going stale. covariance-only keeps trusting the last tight covariance; the reference consumer stops trusting once the reject streak crosses max_consecutive_rejects.",
4+
"source": "alignment_status consecutive_rejected_updates; Phase 3 lesson that drift hides behind a clean last-accepted pose",
5+
"expectation": {
6+
"covariance_only": "trust",
7+
"covariance_plus_category": "reject"
8+
},
9+
"sample": {
10+
"index": 200,
11+
"cov_xy_std_m": 0.25,
12+
"cov_yaw_std_deg": 2.5,
13+
"failure_category": "healthy",
14+
"consecutive_rejected_updates": 5,
15+
"reinitialization_requested": false,
16+
"abs_xy_error_m": 3.1
17+
}
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "stale_prediction_jump_should_reject",
3+
"description": "A long gap since the last accepted pose makes the prediction seed stale; the pose locks onto a wrong basin with low fitness (tight covariance ~0.3 m) but a large true error. stale_prediction is active. covariance-only trusts the tight covariance; the reference consumer vetoes on the category.",
4+
"source": "alignment_failure_taxonomy.hpp stale_prediction (gap > stale_prediction_min_gap_sec)",
5+
"expectation": {
6+
"covariance_only": "trust",
7+
"covariance_plus_category": "reject"
8+
},
9+
"sample": {
10+
"index": 120,
11+
"cov_xy_std_m": 0.3,
12+
"cov_yaw_std_deg": 3.0,
13+
"failure_category": "stale_prediction",
14+
"consecutive_rejected_updates": 0,
15+
"reinitialization_requested": false,
16+
"abs_xy_error_m": 7.5
17+
}
18+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Interface for the reference pose-arbitration consumer (issue #72 follow-up).
2+
3+
`docs/pose_covariance.md` says the calibrated `error_floor` covariance is
4+
"usable for 2-sigma-level comparisons" by fusion / arbitration logic, but adds a
5+
hard caveat: *never trust covariance alone in degenerate geometry* -- a pose can
6+
be accepted with multi-metre error at *low* fitness, and in `error_floor` mode a
7+
low fitness yields a *small* covariance, so a covariance-only consumer is
8+
actively misled into high confidence exactly when it is most wrong. The doc tells
9+
consumers to also watch `/alignment_status` (`failure_category`,
10+
`consecutive_rejected_updates`, `reinitialization_requested`) -- but ships no
11+
reference implementation of a consumer that does so.
12+
13+
This module is that reference consumer, built ROS-free in the Phase 3 fixture +
14+
policy + regression-test style so the claim "covariance is consumable by
15+
arbitration" is backed by a runnable, tested artifact instead of prose.
16+
17+
Each consumer maps a published pose plus its alignment diagnostics to a trust
18+
verdict (TRUST / DOWNWEIGHT / REJECT) and a fusion weight. The fixtures carry the
19+
ground-truth `abs_xy_error_m` so the demo can score whether a verdict was
20+
*correct* (trusting a pose whose true error exceeds the trust radius is the
21+
failure we are guarding against).
22+
"""
23+
24+
from __future__ import annotations
25+
26+
from dataclasses import dataclass
27+
from typing import Protocol
28+
29+
# Failure categories, mirroring include/lidar_localization/alignment_failure_taxonomy.hpp
30+
# so fixtures can be copied from recorded /alignment_status verbatim.
31+
CATEGORY_HEALTHY = "healthy"
32+
CATEGORY_MISSING_MAP = "missing_map"
33+
CATEGORY_MISSING_INITIAL_POSE = "missing_initial_pose"
34+
CATEGORY_WEAK_OVERLAP = "weak_overlap"
35+
CATEGORY_BAD_MATCH = "bad_match"
36+
CATEGORY_STALE_PREDICTION = "stale_prediction"
37+
CATEGORY_OVERLOAD = "overload"
38+
39+
# Trust verdicts.
40+
TRUST = "trust"
41+
DOWNWEIGHT = "downweight"
42+
REJECT = "reject"
43+
44+
45+
@dataclass(frozen=True)
46+
class PoseSample:
47+
"""One published `/pcl_pose` as a downstream fusion consumer sees it.
48+
49+
The covariance fields are the *published* standard deviations (the square
50+
roots of the diagonal terms), i.e. what `error_floor` mode emitted -- a
51+
consumer never sees the true error, only this covariance and the diagnostics.
52+
``abs_xy_error_m`` is ground-truth-only and is used solely to *score* a
53+
verdict in tests / the demo; a real consumer must not have it.
54+
"""
55+
56+
index: int
57+
cov_xy_std_m: float
58+
cov_yaw_std_deg: float
59+
failure_category: str
60+
consecutive_rejected_updates: int
61+
reinitialization_requested: bool
62+
# Ground-truth label for scoring only (not an input to any consumer).
63+
abs_xy_error_m: float
64+
65+
66+
@dataclass(frozen=True)
67+
class ArbitrationVerdict:
68+
trust: str # TRUST / DOWNWEIGHT / REJECT
69+
weight: float # fusion weight in [0, 1]
70+
reason: str
71+
72+
73+
class PoseConsumer(Protocol):
74+
name: str
75+
design: str
76+
77+
def decide(self, sample: PoseSample) -> ArbitrationVerdict:
78+
...
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""Reference pose-arbitration consumers for the calibrated covariance.
2+
3+
Two consumers are defined so the comparison is explicit:
4+
5+
* ``CovarianceOnlyConsumer`` -- the naive baseline the doc warns against: it
6+
trusts a pose purely on its published 2-sigma radius. On the calibrated
7+
``error_floor`` model this is *actively* unsafe, because a degenerate-geometry
8+
pose has low fitness, and low fitness yields a *small* covariance, so this
9+
consumer assigns highest confidence to exactly the biased-tail poses that carry
10+
multi-metre error. It exists to demonstrate the failure, not to be used.
11+
12+
* ``CovariancePlusCategoryConsumer`` -- the reference consumer the doc's
13+
guidance describes: it still *consumes* the covariance (the 2-sigma radius is
14+
the trust gate and sets the fusion weight), but it first vetoes on the
15+
diagnostics that covariance cannot represent -- an active degeneracy/overload
16+
category (``weak_overlap`` is the tell for the bias tail), a reject streak, or
17+
an open reinitialization request. Covariance decides *how much* to trust a
18+
geometrically-sound pose; the category decides *whether the pose is
19+
geometrically sound at all*.
20+
21+
Both are pure and deterministic; ``test_pose_arbitration_policy.py`` pins that
22+
the covariance-only consumer trusts the bias tail (the failure) and the reference
23+
consumer rejects it (the fix), while both agree on the healthy and
24+
covariance-loosened cases.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
from dataclasses import dataclass
30+
31+
from experiments.pose_arbitration.interface import (
32+
ArbitrationVerdict,
33+
CATEGORY_HEALTHY,
34+
DOWNWEIGHT,
35+
PoseSample,
36+
REJECT,
37+
TRUST,
38+
)
39+
40+
# Categories that mean "the geometry/timing behind this pose is not sound", so a
41+
# tight covariance on it is not trustworthy regardless of its value. bad_match is
42+
# included for completeness though an accepted, published pose is rarely flagged
43+
# bad_match (its fitness cleared the gate); weak_overlap / stale_prediction /
44+
# overload routinely co-occur with an accepted-but-degenerate pose.
45+
_UNSOUND_CATEGORIES = frozenset({
46+
"weak_overlap",
47+
"bad_match",
48+
"stale_prediction",
49+
"overload",
50+
"missing_map",
51+
"missing_initial_pose",
52+
})
53+
54+
55+
@dataclass(frozen=True)
56+
class ArbitrationParams:
57+
# A pose is trustworthy for tight fusion only if its 2-sigma xy radius is
58+
# within this bound (metres). Above it the pose is down-weighted, not vetoed.
59+
trust_xy_2sigma_m: float = 1.0
60+
# Down-weighted (rather than trusted) once the 2-sigma radius exceeds the
61+
# trust bound; rejected outright past this far looser bound.
62+
reject_xy_2sigma_m: float = 6.0
63+
# A reject streak at or above this length means tracking is degrading even if
64+
# the last accepted pose looks clean; stop trusting it for fusion.
65+
max_consecutive_rejects: int = 3
66+
67+
68+
def _two_sigma_xy(sample: PoseSample) -> float:
69+
return 2.0 * sample.cov_xy_std_m
70+
71+
72+
class CovarianceOnlyConsumer:
73+
"""Trust purely on the published 2-sigma radius (the unsafe baseline)."""
74+
75+
name = "covariance_only"
76+
design = (
77+
"Trust if 2-sigma xy radius <= trust bound; down-weight up to the reject "
78+
"bound; reject beyond it. Ignores failure_category -- so on the "
79+
"error_floor model it is fooled by tight covariance on low-fitness "
80+
"degenerate poses."
81+
)
82+
83+
def __init__(self, params: ArbitrationParams | None = None) -> None:
84+
self.params = params or ArbitrationParams()
85+
86+
def decide(self, sample: PoseSample) -> ArbitrationVerdict:
87+
two_sigma = _two_sigma_xy(sample)
88+
if two_sigma <= self.params.trust_xy_2sigma_m:
89+
return ArbitrationVerdict(
90+
TRUST, 1.0, "2sigma_within_trust_radius")
91+
if two_sigma <= self.params.reject_xy_2sigma_m:
92+
weight = self.params.trust_xy_2sigma_m / two_sigma
93+
return ArbitrationVerdict(DOWNWEIGHT, weight, "2sigma_loose")
94+
return ArbitrationVerdict(REJECT, 0.0, "2sigma_beyond_reject_radius")
95+
96+
97+
class CovariancePlusCategoryConsumer:
98+
"""Consume covariance, but veto on diagnostics it cannot represent."""
99+
100+
name = "covariance_plus_category"
101+
design = (
102+
"Veto first on failure_category (degeneracy/overload), reject streak, or "
103+
"an open reinitialization request -- the bias-tail signals covariance "
104+
"cannot encode -- then use the 2-sigma radius to weight the surviving, "
105+
"geometrically-sound pose. This is the consumer docs/pose_covariance.md "
106+
"prescribes."
107+
)
108+
109+
def __init__(self, params: ArbitrationParams | None = None) -> None:
110+
self.params = params or ArbitrationParams()
111+
112+
def decide(self, sample: PoseSample) -> ArbitrationVerdict:
113+
# 1) Diagnostics veto -- these are the cases the calibrated covariance is
114+
# documented as unable to bound (the systematically biased tail).
115+
if sample.reinitialization_requested:
116+
return ArbitrationVerdict(REJECT, 0.0, "reinitialization_requested")
117+
if sample.consecutive_rejected_updates >= self.params.max_consecutive_rejects:
118+
return ArbitrationVerdict(REJECT, 0.0, "reject_streak")
119+
if sample.failure_category in _UNSOUND_CATEGORIES:
120+
return ArbitrationVerdict(
121+
REJECT, 0.0, "unsound_category:%s" % sample.failure_category)
122+
if sample.failure_category != CATEGORY_HEALTHY:
123+
# Unknown / future category: be conservative, do not fully trust.
124+
return ArbitrationVerdict(
125+
DOWNWEIGHT, 0.5, "unknown_category:%s" % sample.failure_category)
126+
127+
# 2) Geometrically sound -- now the covariance is meaningful, weight by it.
128+
two_sigma = _two_sigma_xy(sample)
129+
if two_sigma <= self.params.trust_xy_2sigma_m:
130+
return ArbitrationVerdict(TRUST, 1.0, "healthy_2sigma_within_trust")
131+
if two_sigma <= self.params.reject_xy_2sigma_m:
132+
weight = self.params.trust_xy_2sigma_m / two_sigma
133+
return ArbitrationVerdict(DOWNWEIGHT, weight, "healthy_2sigma_loose")
134+
return ArbitrationVerdict(REJECT, 0.0, "2sigma_beyond_reject_radius")
135+
136+
137+
def verdict_is_correct(sample: PoseSample, verdict: ArbitrationVerdict,
138+
trust_radius_m: float) -> bool:
139+
"""Score a verdict against ground truth.
140+
141+
A verdict is *wrong* only when it TRUSTs (weight 1.0) a pose whose true xy
142+
error exceeds the trust radius -- that is the dangerous outcome we guard
143+
against (feeding a confidently-wrong pose into fusion). DOWNWEIGHT and REJECT
144+
are conservative and counted correct regardless of the true error; trusting a
145+
genuinely accurate pose is likewise correct.
146+
"""
147+
trusted = verdict.trust == TRUST
148+
accurate = sample.abs_xy_error_m <= trust_radius_m
149+
if trusted and not accurate:
150+
return False
151+
return True

0 commit comments

Comments
 (0)