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"]))