diff --git a/experiments/exp_ppc_ctrbpf_fgo.py b/experiments/exp_ppc_ctrbpf_fgo.py index a9e147f6..92b7ee4b 100644 --- a/experiments/exp_ppc_ctrbpf_fgo.py +++ b/experiments/exp_ppc_ctrbpf_fgo.py @@ -36,6 +36,7 @@ if str(_SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPT_DIR)) +from ppc_gici_override import RANKER_GICI_HIGH_RISK, gici_cluster_override # noqa: E402 from exp_urbannav_baseline import run_wls # noqa: E402 from gnss_gpu.io.nav_rinex import read_gps_klobuchar_from_nav_header from gnss_gpu.io.ppc import PPCDatasetLoader @@ -4620,7 +4621,10 @@ def _select_rtkdiag_fallback( is_fusion = select_mode in {"wavg3", "wavg5"} is_consensus = select_mode in {"consensus3", "consensus5"} is_cluster_vote = select_mode == "cluster_vote" - is_ranker = select_mode == "ranker" + # ranker_gici_cluster_override reuses the base ranker pick, then may + # re-pick within the xd_gici family (see _gici_cluster_override). + ranker_gici_override = select_mode == "ranker_gici_cluster_override" + is_ranker = select_mode in {"ranker", "ranker_gici_cluster_override"} temporal_prevdist_alpha = { "temporal_n2_v1": 0.001, "temporal_n2_v2": 0.0006, @@ -5089,6 +5093,14 @@ def _select_rtkdiag_fallback( collected, key=lambda c: _diag_float(c[2], "final_residual_rms"), ) + if ( + ranker_gici_override + and best_cand is not None + and str(best_cand[0]) in RANKER_GICI_HIGH_RISK + ): + _override_cand = gici_cluster_override(best_cand, collected) + if _override_cand is not None: + best_cand = _override_cand label = best_cand[0] + "+rnk" selected_pos = best_cand[1] _selected_diag = best_cand[2] @@ -9185,7 +9197,7 @@ def main() -> None: parser.add_argument("--rtkdiag-candidate-proposal-spread-m", type=float, default=0.25, help="Position spread [m] for --rtkdiag-candidate-proposal-cloud") parser.add_argument("--rtkdiag-candidate-select-mode", - choices=("residual", "ratio", "score", "maxabs", "nrows", "hybrid_anchor", "wavg3", "wavg5", "consensus3", "consensus5", "cluster_vote", "ranker", + choices=("residual", "ratio", "score", "maxabs", "nrows", "hybrid_anchor", "wavg3", "wavg5", "consensus3", "consensus5", "cluster_vote", "ranker", "ranker_gici_cluster_override", "rms_per_row", "score_per_row", "score_per_row2", "score_per_row3", "rms_minus_alpha_rows", "log_combined", "composite_3axis_n2", "composite_3axis_t2", "composite_3axis_n1", "composite_n2_v2", "composite_n3_v2", "composite_n1_v2", "composite_t2_v2", @@ -9198,7 +9210,10 @@ def main() -> None: default="residual", help="How to choose among multiple gated RTK candidates (default residual). " "hybrid_anchor picks the candidate closest to the hybrid floor; " - "useful when hybrid is reliable") + "useful when hybrid is reliable. ranker_gici_cluster_override applies " + "the supervised ranker, then re-picks within the xd_gici family toward a " + "tight low-RMS cluster when the pick is a high-risk GICI variant " + "(plan.md Phase 43/71)") parser.add_argument("--rtkdiag-candidate-emit-mode", choices=("pf", "candidate-on-drift", "candidate"), default="pf", diff --git a/experiments/ppc_gici_override.py b/experiments/ppc_gici_override.py new file mode 100644 index 00000000..026fce1a --- /dev/null +++ b/experiments/ppc_gici_override.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""GICI cluster override for the PPC2024 candidate-selection ranker. + +Pure, dependency-light (numpy only) so it is unit-testable without the heavy +``exp_ppc_ctrbpf_fgo`` import graph. Implements the documented runtime rule from +``internal_docs/plan.md`` ("Phase 43 runtime GICI override summary"): + + When the base supervised ranker pick is a *high-risk* GICI variant, re-pick + within the same ``xd_gici`` family toward a tight, low-RMS cluster near the + original pick. This recovers some of the Phase 42 oracle span gains without + using truth, and is the mode the Phase 43/71 production runs used for + nagoya/run2. + +Candidate items are ``(label, pos_ecef, diag_row, key)`` tuples, matching the +``collected`` list built in ``exp_ppc_ctrbpf_fgo``. Distances are Euclidean in +ECEF metres (≈ local ENU metres at these separations). +""" + +from __future__ import annotations + +from typing import Optional, Sequence + +import numpy as np + +# High-risk GICI variants that trigger the override when picked by the ranker. +RANKER_GICI_HIGH_RISK = frozenset( + {"xd_gici_c4", "xd_gici_oa", "xd_gici_combo", "xd_gici_z", "xd_gici_hs"} +) + + +def _rms(cand: Sequence) -> float: + try: + return float(cand[2]["final_residual_rms"]) + except (KeyError, TypeError, ValueError, IndexError): + return float("nan") + + +def gici_cluster_override( + pick: Sequence, + collected: Sequence[Sequence], + *, + rms_rank_max: int = 12, + cluster50_min: int = 6, + dist_to_pick_max_m: float = 0.8, + cluster_radius_m: float = 0.5, +) -> Optional[Sequence]: + """Re-pick within the xd_gici family toward a tight low-RMS cluster. + + Among the ``xd_gici`` family (synthetic ``pf_bridge`` excluded), keep + candidates whose rank by ``final_residual_rms`` within the family is + ``<= rms_rank_max``, that sit in a cluster of ``>= cluster50_min`` family + members within ``cluster_radius_m``, and lie within ``dist_to_pick_max_m`` of + ``pick``. Return the one with the largest cluster (tie-break: lowest RMS), or + ``None`` to keep the original pick. + + The defaults reproduce the documented Phase 43/71 thresholds (12 / 6 / 0.8). + The caller is responsible for only invoking this when ``pick``'s label is in + :data:`RANKER_GICI_HIGH_RISK`. + """ + pick_pos = np.asarray(pick[1], dtype=np.float64) + family = [ + c + for c in collected + if str(c[0]).startswith("xd_gici") and str(c[0]) != "pf_bridge" + ] + if len(family) < cluster50_min: + return None + rms_order = sorted(family, key=_rms) + rms_rank = {id(c): r + 1 for r, c in enumerate(rms_order)} + positions = {id(c): np.asarray(c[1], dtype=np.float64) for c in family} + + eligible: list[tuple[int, float, Sequence]] = [] + for c in family: + cpos = positions[id(c)] + if float(np.linalg.norm(cpos - pick_pos)) > dist_to_pick_max_m: + continue + if rms_rank[id(c)] > rms_rank_max: + continue + cluster50 = sum( + 1 + for o in family + if float(np.linalg.norm(positions[id(o)] - cpos)) <= cluster_radius_m + ) + if cluster50 < cluster50_min: + continue + eligible.append((cluster50, _rms(c), c)) + if not eligible: + return None + # Largest cluster wins; tie-break by lowest RMS. + eligible.sort(key=lambda e: (-e[0], e[1])) + return eligible[0][2] diff --git a/internal_docs/algorithm_architecture.md b/internal_docs/algorithm_architecture.md index 98077c2c..d180ae77 100644 --- a/internal_docs/algorithm_architecture.md +++ b/internal_docs/algorithm_architecture.md @@ -54,8 +54,9 @@ flowchart TD candidate pool(per-run の LibGNSS++ `.pos` + 診断 `.csv`、GICI `rtk_imu_tc` を 含む多 variant)を 3 層で絞り込む。gici_tc は selector pool に投入済。90% 突破の -残 lever は最下流の n/r2 ranker 層だが、`ranker_gici_cluster_override` mode が -未コミットで production replay 不可という blocker 付き。 +残 lever は最下流の n/r2 ranker 層。`ranker_gici_cluster_override` mode は +2026-06-17 にコミット済(`experiments/ppc_gici_override.py`、テスト +`tests/test_ppc_gici_override.py`)。厳密な score 再現は候補 pool の再生成が前提。 ```mermaid flowchart TD @@ -82,7 +83,7 @@ flowchart TD subgraph RANK["ranker 層"] direction TB R1["features: cluster_min_rms_50cm (dominant)
+ NLOS frac + path features"] - R2["per-run conditional:
n/r2 のみ ranker_gici_cluster_override k=99
※ mode 未コミット = replay blocker"] + R2["per-run conditional:
n/r2 のみ ranker_gici_cluster_override k=99
※ 2026-06-17 コミット済 (ppc_gici_override.py)"] R1 --> R2 end R --> RANK diff --git a/internal_docs/ppc_current_status.md b/internal_docs/ppc_current_status.md index 0a151e9f..55db74a5 100644 --- a/internal_docs/ppc_current_status.md +++ b/internal_docs/ppc_current_status.md @@ -103,11 +103,17 @@ Tracing the production pool settled the question: `gici_tc_esdfix` (`def`, 88.85%) vs the new *combo4* (95.0%). But combo4 is already in the pool as `xd_gici_c4` (95.0%). There is no better build to swap in, so a swap treatment is identical to baseline. -4. **The exact production replay is not reproducible from committed code anyway:** - the n/r2 selector mode `ranker_gici_cluster_override` is absent from - `exp_ppc_ctrbpf_fgo.py`'s argparse choices (invalid-choice error) and - `git log -S` shows it was never committed to that file — the 86.21% run used - an uncommitted local build. +4. **The n/r2 selector mode `ranker_gici_cluster_override` is now committed** + (2026-06-17): implemented in `experiments/ppc_gici_override.py` and wired into + `exp_ppc_ctrbpf_fgo.py`'s argparse choices, per the documented Phase 43/71 rule + (high-risk GICI pick → re-pick within the xd_gici family toward a tight + ≥6-member 50 cm cluster within 0.8 m; cluster count priority, RMS tie-break). + Logic is covered by `tests/test_ppc_gici_override.py`. **Caveat:** exact + *score* replay of the 86.21% run still needs the regenerated candidate pool + (the `libgnss_diag_phase10/19` dirs + `selector_ranker_predictions_v5_nlos.csv` + are regenerable via the plan.md CLIs, not all checked in), and some thresholds + in the original uncommitted build were under-documented — the committed + defaults follow plan.md (12 / 6 / 0.8 / 0.5 m). Conclusion: the GICI candidate-injection avenue is **closed** (already realized). The `eval_gici_tc_ppc2024_batch.py` / `materialize_gici_tc_ppc_candidate.py` diff --git a/tests/test_ppc_gici_override.py b/tests/test_ppc_gici_override.py new file mode 100644 index 00000000..5ac80142 --- /dev/null +++ b/tests/test_ppc_gici_override.py @@ -0,0 +1,128 @@ +"""Unit tests for the PPC2024 GICI cluster override (experiments/ppc_gici_override.py). + +Pure-numpy logic, no native kernels — runs anywhere pytest + numpy are present. +Covers the documented Phase 43/71 rule: high-risk GICI pick + a tight low-RMS +xd_gici cluster within 0.8 m -> override; otherwise keep the pick. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "experiments")) + +from ppc_gici_override import ( # noqa: E402 + RANKER_GICI_HIGH_RISK, + gici_cluster_override, +) + + +def _cand(label: str, xyz, rms: float): + """Build a (label, pos_ecef, diag_row, key) candidate tuple.""" + return (label, np.asarray(xyz, dtype=float), {"final_residual_rms": str(rms)}, (rms, rms)) + + +def _tight_cluster(base, n=6, start_rms=0.20): + """n xd_gici members packed within ~10 cm of `base`.""" + return [ + _cand(f"xd_gici_m{k}", np.asarray(base) + [0.10 + 0.01 * k, 0.0, 0.0], start_rms + 0.001 * k) + for k in range(n) + ] + + +def test_high_risk_set_matches_spec(): + assert RANKER_GICI_HIGH_RISK == { + "xd_gici_c4", + "xd_gici_oa", + "xd_gici_combo", + "xd_gici_z", + "xd_gici_hs", + } + + +def test_override_picks_tight_nearby_cluster(): + base = np.zeros(3) + pick = _cand("xd_gici_c4", base, 0.30) # high-risk pick + collected = [pick, *_tight_cluster(base)] + out = gici_cluster_override(pick, collected) + assert out is not None + assert out[0].startswith("xd_gici_m") + + +def test_far_low_rms_candidate_is_not_chosen(): + base = np.zeros(3) + pick = _cand("xd_gici_c4", base, 0.30) + far = _cand("xd_gici_far", [5.0, 0.0, 0.0], 0.01) # best RMS but >0.8 m away + collected = [pick, far, *_tight_cluster(base)] + out = gici_cluster_override(pick, collected) + assert out is not None + assert out[0] != "xd_gici_far" + assert float(np.linalg.norm(out[1] - pick[1])) <= 0.8 + + +def test_pf_bridge_excluded_from_family(): + base = np.zeros(3) + pick = _cand("xd_gici_c4", base, 0.30) + # A pf_bridge sits right on the cluster but must never be the override target. + bridge = _cand("pf_bridge", [0.1, 0.0, 0.0], 0.001) + collected = [pick, bridge, *_tight_cluster(base)] + out = gici_cluster_override(pick, collected) + assert out is not None + assert out[0] != "pf_bridge" + + +def test_no_override_when_cluster_too_small(): + base = np.zeros(3) + pick = _cand("xd_gici_c4", base, 0.30) + collected = [pick, *_tight_cluster(base, n=4)] # only 4 < cluster50_min=6 + assert gici_cluster_override(pick, collected) is None + + +def test_no_override_when_cluster_outside_dist(): + base = np.zeros(3) + pick = _cand("xd_gici_c4", base, 0.30) + # A full 6-member cluster, but 2 m away from the pick. + far_base = np.asarray([2.0, 0.0, 0.0]) + collected = [pick, *_tight_cluster(far_base)] + assert gici_cluster_override(pick, collected) is None + + +def test_largest_cluster_wins_over_smaller(): + base = np.zeros(3) + pick = _cand("xd_gici_c4", base, 0.30) + # Two *spatially isolated* clusters (separated by >cluster_radius=0.5 m so + # neither counts the other's members): a 7-member higher-RMS cluster near the + # pick, and a 6-member lower-RMS cluster further out (still within 0.8 m). + big = [ + _cand(f"xd_gici_m{k}", base + [0.10 + 0.005 * k, 0.0, 0.0], 0.25 + 0.001 * k) + for k in range(7) + ] + small = [ + _cand(f"xd_gici_s{k}", base + [0.70 + 0.005 * k, 0.0, 0.0], 0.10 + 0.001 * k) + for k in range(6) + ] + out = gici_cluster_override(pick, [pick, *big, *small]) + assert out is not None + # The larger cluster (the "m" members) should win despite worse RMS. + assert out[0].startswith("xd_gici_m") + + +def test_rms_rank_gate_excludes_low_ranked_members(): + base = np.zeros(3) + pick = _cand("xd_gici_c4", base, 0.30) + # 6 tight members but all with very high RMS so their family rms_rank is poor; + # pad the family with 12 better-RMS but far-away members to push ranks > 12. + cluster = _tight_cluster(base, n=6, start_rms=5.0) + fillers = [ + _cand(f"xd_gici_f{k}", [10.0 + k, 0.0, 0.0], 0.10 + 0.001 * k) for k in range(12) + ] + out = gici_cluster_override(pick, [pick, *cluster, *fillers]) + assert out is None + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"]))