Skip to content

Commit 61e00a0

Browse files
committed
feat(gsdc2023): divergence-gated acceptance for the 2-stage residual re-solve
Adds fgo_two_stage_divergence_p95_m (BridgeConfig, default 0 = disabled) and CLI --fgo-two-stage-divergence-p95-m. When > 0, the 2-stage re-solve only fires on chunks whose pass-1 fixed-linearization residual p95 (over active rows) exceeds the threshold — i.e. only chunks where pass-1 diverged. Healthy chunks are left at the pass-1 result, so the re-solve introduces no perturbation there. Motivation: a per-chunk diagnostic probe across win / wash / regression trips showed the unconditional re-solve's only net win (lax-o) is split into two parts: a divergence rescue on a couple of chunks where pass-1 blew up to multi-km residuals (pass-1 p95 ~39 km), and pervasive marginal NLOS masking on healthy chunks (p95 ~30 m). Every regression trip (lax-t/mtv-b/mtv-pe1 +10..11 cm) does only the marginal masking on a healthy fit (p95 <= ~50 m). The two are indistinguishable by the marginal residuals themselves, but the divergence chunks stand alone by orders of magnitude (pass-1 p95 38840 m vs <= 53 m everywhere else, position move 16908 m vs <= 3 m). Gating on pass-1 divergence (p95 > 500 m) cleanly captures the rescue and skips the marginal masking. An 8-trip A/B (div500 vs baseline) confirms: every +10 cm regression goes bit-identical to baseline, and lax-o keeps -9.3 cm (the divergence-rescue portion of its -54.6 cm unconditional win). The marginal-mask bulk of lax-o's win is inseparable from the regressions, so the gated lever is a clean zero-regression rescue (1 win / 0 reg / 7 wash) rather than the larger but net-wash unconditional lever. The Huber guard cannot do this (the rescue raises the robust cost: lax-o cost_ratio 1.32), so divergence gating is the correct discriminator. Default off keeps legacy behaviour; independent of the guard flag. 2 new unit tests.
1 parent eda5106 commit 61e00a0

4 files changed

Lines changed: 88 additions & 3 deletions

File tree

experiments/build_gsdc2023_bridge_submission.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,9 @@ def build_config(args: argparse.Namespace) -> BridgeConfig:
358358
fgo_two_stage_residual_guard=bool(
359359
getattr(args, "fgo_two_stage_residual_guard", False)
360360
),
361+
fgo_two_stage_divergence_p95_m=float(
362+
getattr(args, "fgo_two_stage_divergence_p95_m", 0.0)
363+
),
361364
)
362365
if bool(getattr(args, "taroz_marupaku", False)):
363366
cfg = apply_taroz_marupaku_preset(cfg)
@@ -619,6 +622,12 @@ def main(argv: list[str] | None = None) -> int:
619622
default=False,
620623
help="Gate the 2-stage re-solve behind a Huber trust-region cost (keep pass-2 only if the full-set Huber residual cost improves). OFF by default: the dense-urban win comes from dropping biased NLOS rows, which lowers position error but *raises* the Huber cost, so the guard rejects exactly the re-solves we want. Retained for experimentation.",
621624
)
625+
parser.add_argument(
626+
"--fgo-two-stage-divergence-p95-m",
627+
type=float,
628+
default=0.0,
629+
help="Recommended acceptance gate for the 2-stage re-solve (0 = disabled). When > 0, the re-solve only fires on chunks whose pass-1 fixed-linearization residual p95 (over active rows) exceeds this many metres — i.e. only chunks where pass-1 diverged. Keeps the lax-o divergence-rescue win while skipping the marginal masking that causes +10 cm regressions on healthy chunks.",
630+
)
622631
parser.add_argument("--stop-attitude-sigma-rad", type=float, default=0.0)
623632
parser.add_argument(
624633
"--taroz-fgo",

experiments/gsdc2023_bridge_config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,15 @@ class BridgeConfig:
295295
fgo_two_stage_residual_threshold_l5_m: float = 15.0
296296
fgo_two_stage_residual_min_keep: int = 5
297297
fgo_two_stage_residual_guard: bool = False
298+
# Recommended acceptance gate for the 2-stage re-solve (0 = disabled). When
299+
# > 0, the re-solve is attempted only on chunks whose pass-1 fixed-
300+
# linearization residual p95 (over active rows) exceeds this many metres —
301+
# i.e. only chunks where pass-1 diverged. A diagnostic probe showed the only
302+
# net win (lax-o) comes from such diverged chunks (p95 ~39 km) while every
303+
# regression trip masks marginal outliers on a healthy pass-1 fit (p95
304+
# <= ~50 m), so gating on divergence keeps the rescue and drops the +10 cm
305+
# regressions.
306+
fgo_two_stage_divergence_p95_m: float = 0.0
298307
tdcp_weight_scale: float = DEFAULT_TDCP_WEIGHT_SCALE
299308
tdcp_l5_weight_scale: float = 1.0
300309
tdcp_geometry_correction: bool = DEFAULT_TDCP_GEOMETRY_CORRECTION
@@ -412,6 +421,9 @@ def __post_init__(self) -> None:
412421
raise ValueError(f"{name} must be finite and > 0")
413422
if int(self.fgo_two_stage_residual_min_keep) < 4:
414423
raise ValueError("fgo_two_stage_residual_min_keep must be >= 4")
424+
divergence_p95 = float(self.fgo_two_stage_divergence_p95_m)
425+
if not np.isfinite(divergence_p95) or divergence_p95 < 0.0:
426+
raise ValueError("fgo_two_stage_divergence_p95_m must be finite and >= 0")
415427
for name in (
416428
"stop_velocity_huber_k",
417429
"stop_position_huber_k",

experiments/gsdc2023_raw_bridge.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2324,25 +2324,37 @@ def two_stage_residual_resolve_vd(
23242324
threshold_l5_m: float,
23252325
min_keep: int,
23262326
guard: bool = True,
2327+
divergence_p95_m: float = 0.0,
23272328
vd_kwargs: dict,
23282329
) -> tuple[int, float]:
23292330
"""taroz-style 2-stage residual re-solve for the VD path. Runs ``solver_fn``
23302331
(the VD solver), recomputes the fixed-linearization residual at the converged
23312332
``state``, masks outliers above threshold, and re-solves warm-started.
23322333
2334+
``divergence_p95_m`` is the recommended acceptance criterion (default 0 =
2335+
disabled). When > 0, the re-solve is attempted ONLY on chunks whose pass-1
2336+
fit has diverged — i.e. the 95th percentile of the pass-1 fixed-linearization
2337+
residual over active rows exceeds the threshold. A diagnostic probe across
2338+
win / wash / regression trips showed the only net win (lax-o) comes from a
2339+
handful of chunks where pass-1 diverged to multi-km residuals (p95 ~39 km),
2340+
while every regression trip masks only marginal outliers on a healthy pass-1
2341+
fit (p95 <= ~50 m); gating on pass-1 divergence keeps the rescue and skips
2342+
the perturbations that cause the +10 cm regressions. The chunk is left
2343+
untouched (pass-1 kept) when its pass-1 fit is healthy.
2344+
23332345
When ``guard`` is True the re-solve is kept only if it lowers the full-set
23342346
Huber guard cost (a trust region), otherwise ``state`` is restored to the
23352347
pass-1 result. **The guard is OFF by default in production**: the dense-urban
23362348
win comes from dropping systematically biased NLOS measurements, which snaps
23372349
the position toward truth but *raises* the robust residual cost (the dropped
23382350
outliers are re-added at their original weight, Huber-capped), so the guard
23392351
rejects exactly the re-solves we want. See PR / memory notes. The guard is
2340-
retained for experimentation only.
2352+
retained for experimentation only and is independent of the divergence gate.
23412353
23422354
Returns ``(iters, mse)`` and mutates ``state`` in place (matching the native
23432355
solver contract). A no-op returning the pass-1 result when fixed
2344-
linearization inputs are absent, nothing is masked, the re-solve fails, or the
2345-
guard (when enabled) rejects it.
2356+
linearization inputs are absent, the pass-1 fit is healthy (divergence gate),
2357+
nothing is masked, the re-solve fails, or the guard (when enabled) rejects it.
23462358
"""
23472359
iters1, mse1 = solver_fn(sat_ecef, pseudorange, weights, state, **vd_kwargs)
23482360
ref = vd_kwargs.get("pr_linearization_ref_ecef")
@@ -2356,6 +2368,14 @@ def two_stage_residual_resolve_vd(
23562368
sys_kind, pseudorange.shape, threshold_l1_m, threshold_l5_m
23572369
)
23582370
resid1 = _pr_linearized_residual(pseudorange, los, ref, state, sys_kind, n_clock)
2371+
if float(divergence_p95_m) > 0.0:
2372+
w = np.asarray(weights, dtype=np.float64)
2373+
active = np.isfinite(w) & (w > 0.0)
2374+
ar = np.abs(resid1)[active]
2375+
ar = ar[np.isfinite(ar)]
2376+
pass1_p95 = float(np.percentile(ar, 95)) if ar.size else 0.0
2377+
if pass1_p95 <= float(divergence_p95_m):
2378+
return iters1, mse1 # pass-1 fit is healthy: skip the re-solve
23592379
new_w, n_masked = _pr_two_stage_mask(resid1, weights, thresholds, int(min_keep))
23602380
if n_masked == 0:
23612381
return iters1, mse1
@@ -2687,6 +2707,7 @@ def run_fgo_chunked(
26872707
fgo_two_stage_residual_threshold_l5_m: float = 15.0,
26882708
fgo_two_stage_residual_min_keep: int = 5,
26892709
fgo_two_stage_residual_guard: bool = False,
2710+
fgo_two_stage_divergence_p95_m: float = 0.0,
26902711
fgo_seed_state: np.ndarray | None = None,
26912712
) -> "ChunkedFgoRun":
26922713
n_epoch = batch.sat_ecef.shape[0]
@@ -3251,6 +3272,7 @@ def run_fgo_chunked(
32513272
threshold_l5_m=fgo_two_stage_residual_threshold_l5_m,
32523273
min_keep=fgo_two_stage_residual_min_keep,
32533274
guard=fgo_two_stage_residual_guard,
3275+
divergence_p95_m=fgo_two_stage_divergence_p95_m,
32543276
vd_kwargs=vd_kwargs,
32553277
)
32563278
else:
@@ -3708,6 +3730,9 @@ def solve_trip(
37083730
fgo_run_kwargs["fgo_two_stage_residual_guard"] = bool(
37093731
getattr(config, "fgo_two_stage_residual_guard", False)
37103732
)
3733+
fgo_run_kwargs["fgo_two_stage_divergence_p95_m"] = float(
3734+
getattr(config, "fgo_two_stage_divergence_p95_m", 0.0)
3735+
)
37113736
taroz_candidate_sources = _taroz_fgo_candidate_sources_enabled(config)
37123737
taroz_candidate_base_run_kwargs = dict(fgo_run_kwargs)
37133738
effective_trip_type: str | None = None

tests/test_two_stage_residual_resolve.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,45 @@ def test_resolve_keeps_pass2_when_guard_disabled():
190190
assert state[0, 6] == -50.0 # pass-2 state kept despite worse Huber cost
191191

192192

193+
def test_divergence_gate_skips_healthy_pass1():
194+
# One row just over the 20 m mask threshold (would normally mask + re-solve),
195+
# but the pass-1 residual p95 is low (~18 m) -> the divergence gate skips the
196+
# re-solve entirely, so pass-1 is kept untouched.
197+
z = np.asarray([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 21.0]], dtype=np.float64)
198+
w = np.ones((1, 7), dtype=np.float64)
199+
state = _state([0.0, 0.0, 0.0], c0=0.0)
200+
los = np.zeros((1, 7, 3)); ref = np.zeros((1, 3))
201+
kw = dict(pr_linearization_ref_ecef=ref, pr_linearization_los_ecef=los,
202+
sys_kind=None, n_clock=1)
203+
solver = _MockSolver(pass1_c0=0.0, pass2_c0=1.0)
204+
iters, _mse = two_stage_residual_resolve_vd(
205+
solver, None, z, w, state,
206+
threshold_l1_m=20.0, threshold_l5_m=15.0, min_keep=5,
207+
divergence_p95_m=500.0, vd_kwargs=kw)
208+
assert solver.calls == 1 # healthy pass-1 -> gate skips the re-solve
209+
assert iters == 5
210+
assert state[0, 6] == 0.0
211+
212+
213+
def test_divergence_gate_fires_on_diverged_pass1():
214+
# Pass-1 diverged: every residual ~600 m (p95 > 500 m gate) -> the re-solve
215+
# fires even with the divergence gate enabled.
216+
z = np.full((1, 7), 600.0, dtype=np.float64)
217+
w = np.ones((1, 7), dtype=np.float64)
218+
state = _state([0.0, 0.0, 0.0], c0=0.0)
219+
los = np.zeros((1, 7, 3)); ref = np.zeros((1, 3))
220+
kw = dict(pr_linearization_ref_ecef=ref, pr_linearization_los_ecef=los,
221+
sys_kind=None, n_clock=1)
222+
solver = _MockSolver(pass1_c0=0.0, pass2_c0=1.0)
223+
iters, _mse = two_stage_residual_resolve_vd(
224+
solver, None, z, w, state,
225+
threshold_l1_m=20.0, threshold_l5_m=15.0, min_keep=5,
226+
divergence_p95_m=500.0, vd_kwargs=kw)
227+
assert solver.calls == 2 # diverged pass-1 -> re-solve fires
228+
assert iters == 12
229+
assert state[0, 6] == 1.0
230+
231+
193232
def test_resolve_noop_without_fixed_linearization():
194233
z = np.asarray([[100.0, 100.0, 100.0, 100.0, 100.0, 100.0]], dtype=np.float64)
195234
w = np.ones((1, 6), dtype=np.float64)

0 commit comments

Comments
 (0)