Skip to content

Commit 8db4d25

Browse files
committed
G3: cap candidate walk depth so a stale query re-queries on a fresher scan
The live kidnap run spent ~95 s walking all 16 candidates of a stale first query (none of whose poses were the true location) before re-querying -- and it was the second query, on a newer more distinctive scan, that returned the true pose at rank 1 and locked. Past the top few BBS occupancy maxima a fresh query beats a deeper walk, so add max_walk_candidates (default 4): abandon the query after the top few fail to lock and re-query instead of grinding ranks 5-16. Pure policy change with two unit tests; wired through the node param and the supervisor_max_walk_candidates launch arg (set high to restore full walk).
1 parent bca5187 commit 8db4d25

5 files changed

Lines changed: 84 additions & 0 deletions

docs/g3_live_closed_loop.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,27 @@ make recovery faster and first-try. The test harness should also republish the s
225225
the localizer acknowledges (the transient-local discovery race still occasionally drops the
226226
healthy baseline at slow replay rates). But the gate question -- *does tracking recover
227227
after the guarded reset* -- is now answered yes.
228+
229+
## Speedup lever 1 -- bound the walk, re-query on a fresher scan
230+
231+
The dominant cost in that run was **not** the query itself but the walk: the first query
232+
(at t+8 s) returned 16 candidates clustered around `(95, -63)` and `(-65, -78)`, none of
233+
which was the true pose `(-76, 55)` -- the right place simply was not in that scan's
234+
candidate set. The supervisor nonetheless walked all 16 at `settle_timeout_sec` (6 s) each,
235+
burning **~95 s**, before cooling down and re-querying. The *second* query (on a newer, more
236+
distinctive scan) returned the true pose at rank 1 with score 1.0 and locked immediately.
237+
238+
So past the top few BBS occupancy maxima, a fresh query beats a deeper walk into a stale
239+
list. The policy now caps the walk at `max_walk_candidates` (default 4): after the top few
240+
candidates fail to lock, it treats the whole query as a miss and re-queries (spending one
241+
attempt) instead of grinding through ranks 5-16. On this run that turns ~95 s of doomed
242+
walking into ~24 s, then a fresh query -- the one that actually recovered. The cap is a pure
243+
policy change (`reinitialization_supervisor_policy.py`), unit-tested in
244+
`test_reinitialization_supervisor_policy.py` (`test_walk_is_bounded_by_max_walk_candidates`,
245+
`test_high_max_walk_candidates_restores_full_list_walk`), and exposed as the
246+
`supervisor_max_walk_candidates` launch arg. Set it high to restore walking the full list.
247+
248+
Note this does *not* help when the true candidate is genuinely in the list but ranked deep
249+
on registration quality -- that is what per-candidate registration scoring in G2 (lever 2,
250+
not yet built) would fix. The cap addresses the more common stale-query case observed here,
251+
where no rank would have recovered and the time is better spent on a new scan.

launch/global_localization_recovery.launch.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def generate_launch_description():
6666
# G2's BBS query can take ~10-25 s; query/settle timeouts must allow for it.
6767
supervisor_query_timeout_sec = LaunchConfiguration('supervisor_query_timeout_sec')
6868
supervisor_settle_timeout_sec = LaunchConfiguration('supervisor_settle_timeout_sec')
69+
supervisor_max_walk_candidates = LaunchConfiguration('supervisor_max_walk_candidates')
6970

7071
declared = [
7172
DeclareLaunchArgument(
@@ -100,6 +101,12 @@ def generate_launch_description():
100101
'supervisor_settle_timeout_sec', default_value='8.0',
101102
description='Time to observe post-reset recovery before counting the '
102103
'attempt failed.'),
104+
DeclareLaunchArgument(
105+
'supervisor_max_walk_candidates', default_value='4',
106+
description='Walk at most this many candidates from one (possibly stale) '
107+
'query before re-querying on a fresher scan; a stale query '
108+
'can return a full list none of whose poses lock '
109+
'(g3_live_closed_loop.md). Set high to walk the whole list.'),
103110
DeclareLaunchArgument(
104111
'g2_angular_resolution_deg', default_value='5.0',
105112
description='G2 BBS yaw sampling step; coarser = faster query, fresher '
@@ -170,6 +177,8 @@ def generate_launch_description():
170177
supervisor_query_timeout_sec, value_type=float),
171178
'settle_timeout_sec': ParameterValue(
172179
supervisor_settle_timeout_sec, value_type=float),
180+
'max_walk_candidates': ParameterValue(
181+
supervisor_max_walk_candidates, value_type=int),
173182
'use_sim_time': use_sim_time,
174183
}])
175184

scripts/reinitialization_supervisor_node.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def __init__(self) -> None:
8787
self.declare_parameter("query_timeout_sec", 10.0)
8888
self.declare_parameter("settle_timeout_sec", 8.0)
8989
self.declare_parameter("recovery_fitness_threshold", 1.5)
90+
self.declare_parameter("max_walk_candidates", 4)
9091

9192
self.params = rsp.SupervisorParams(
9293
request_debounce_sec=float(self.get_parameter("request_debounce_sec").value),
@@ -98,6 +99,7 @@ def __init__(self) -> None:
9899
settle_timeout_sec=float(self.get_parameter("settle_timeout_sec").value),
99100
recovery_fitness_threshold=float(
100101
self.get_parameter("recovery_fitness_threshold").value),
102+
max_walk_candidates=int(self.get_parameter("max_walk_candidates").value),
101103
)
102104
self.state = rsp.initial_state()
103105

scripts/reinitialization_supervisor_policy.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@
5353
accepted pose), so walking cannot cause the Phase 3 *acceptance* blowup -- it only
5454
changes which seed is offered next.
5555
56+
The walk is bounded by ``max_walk_candidates``: the same live run showed a *stale*
57+
query can return a full ranked list none of whose poses lock, so walking all 16 burned
58+
~95 s at ``settle_timeout_sec`` each before the next query -- on a newer, more
59+
distinctive scan -- produced the candidate that recovered. Past the top few occupancy
60+
maxima a fresh query beats a deeper walk, so the supervisor abandons the query after
61+
``max_walk_candidates`` and re-queries (spending one attempt) instead.
62+
5663
These map directly onto the G3 Decision Gates in
5764
``docs/global_localization_roadmap.md``. ``test_reinitialization_supervisor_policy.py``
5865
fails if any of them regress.
@@ -113,6 +120,16 @@ class SupervisorParams:
113120
settle_timeout_sec: float = 8.0
114121
# Post-reset alignment fitness at or below this counts as recovered.
115122
recovery_fitness_threshold: float = 1.5
123+
# Walk at most this many candidates from one query (counting the top one)
124+
# before abandoning the whole query and re-querying with a fresh scan. The
125+
# 2026-06-15 live run (docs/g3_live_closed_loop.md) showed a stale query can
126+
# return a full ranked list none of whose poses lock, so walking all 16 just
127+
# burned ~95 s at settle_timeout_sec each before the *next* query -- on a
128+
# newer, more distinctive scan -- produced the candidate that recovered. Past
129+
# the top few BBS occupancy maxima a fresh query beats a deeper walk, so cap
130+
# the walk and spend the time on a new scan instead. Set very high to restore
131+
# walking the entire list.
132+
max_walk_candidates: int = 4
116133

117134

118135
@dataclass(frozen=True)
@@ -276,6 +293,7 @@ def decide(
276293
# spending another attempt -- only re-querying consumes max_attempts.
277294
next_index = state.candidate_index + 1
278295
if (next_index < len(state.candidate_scores)
296+
and next_index < params.max_walk_candidates
279297
and state.candidate_scores[next_index] >= params.min_candidate_score):
280298
return SupervisorDecision(
281299
ACTION_PUBLISH_RESET, "next_candidate",

test/test_reinitialization_supervisor_policy.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,37 @@ def test_walk_stops_at_min_candidate_score_floor():
308308
assert rsp.ACTION_GIVE_UP in actions(events)
309309

310310

311+
def test_walk_is_bounded_by_max_walk_candidates():
312+
# A stale query returns a long ranked list none of whose poses lock. The walk
313+
# must stop after max_walk_candidates instead of burning settle_timeout_sec on
314+
# all 16 -- past the top few occupancy maxima a fresh query is the better spend.
315+
# With max_attempts=1 (a single query) exactly max_walk_candidates resets fire.
316+
params = rsp.SupervisorParams(
317+
request_debounce_sec=1.0, min_candidate_score=0.6, settle_timeout_sec=3.0,
318+
max_attempts=1, max_walk_candidates=4)
319+
events = run_ranked(
320+
params, 120, requested=True,
321+
candidate_scores=[0.99] * 16, recover_on_index=None)
322+
assert published_indices(events) == [0, 1, 2, 3]
323+
assert actions(events).count(rsp.ACTION_QUERY) == 1
324+
assert rsp.ACTION_GIVE_UP in actions(events)
325+
assert events[-1][3] == rsp.STATE_EXHAUSTED
326+
327+
328+
def test_high_max_walk_candidates_restores_full_list_walk():
329+
# Raising the cap restores walking the entire ranked list: the true pose at
330+
# rank 5 (beyond the default cap of 4) is reached and recovers only because the
331+
# cap is lifted -- proving the cap is what gates the deeper walk.
332+
params = rsp.SupervisorParams(
333+
request_debounce_sec=1.0, min_candidate_score=0.6, settle_timeout_sec=3.0,
334+
max_attempts=1, max_walk_candidates=999)
335+
events = run_ranked(
336+
params, 120, requested=True,
337+
candidate_scores=[0.99] * 6, recover_on_index=5)
338+
assert published_indices(events) == [0, 1, 2, 3, 4, 5]
339+
assert events[-1][3] == rsp.STATE_STANDDOWN
340+
341+
311342
if __name__ == "__main__":
312343
import pytest
313344
raise SystemExit(pytest.main([__file__, "-v"]))

0 commit comments

Comments
 (0)