Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions experiments/exp_ppc_ctrbpf_fgo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
91 changes: 91 additions & 0 deletions experiments/ppc_gici_override.py
Original file line number Diff line number Diff line change
@@ -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]
7 changes: 4 additions & 3 deletions internal_docs/algorithm_architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -82,7 +83,7 @@ flowchart TD
subgraph RANK["ranker 層"]
direction TB
R1["features: cluster_min_rms_50cm (dominant)<br/>+ NLOS frac + path features"]
R2["per-run conditional:<br/>n/r2 のみ ranker_gici_cluster_override k=99<br/>※ mode 未コミット = replay blocker"]
R2["per-run conditional:<br/>n/r2 のみ ranker_gici_cluster_override k=99<br/>※ 2026-06-17 コミット済 (ppc_gici_override.py)"]
R1 --> R2
end
R --> RANK
Expand Down
16 changes: 11 additions & 5 deletions internal_docs/ppc_current_status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
128 changes: 128 additions & 0 deletions tests/test_ppc_gici_override.py
Original file line number Diff line number Diff line change
@@ -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"]))