Skip to content

Commit b4b4c38

Browse files
committed
Make parity-autofix a true live-R fidelity test
Previously neither CI workflow actually exercised live R: every parity test runs with CI=true, which forces tests/_r.py into offline/cache-only mode, so the gate only replayed the committed cache. parity-autofix even set up R and installed NNS but never called it. Turn parity-autofix into a real fidelity test against the R code that triggered it: - install_local_r_nns.py: add --source (install from a given package source, e.g. the upstream checkout at the recorded commit, instead of the vendored tools/NNS snapshot) and --expected-version (verify the recorded upstream version rather than hard-coding 13.0). - run_live_r_parity_for_changed_api.py: add --live mode. It moves the committed cache aside and runs the mapped parity subset with the offline toggles (CI, NNS_R_CACHE_ONLY, ...) cleared, so every mapped nns() call recomputes against the freshly installed live R and the test asserts Python matches it. The committed cache is always restored unchanged (backup uses the gitignored .json.bak name), so no regenerated values leak into the PR. A failure means real divergence. - parity-autofix.yml: install live R NNS from upstream/NNS at the recorded commit and run the parity step with --live. inspect-r-api stays the fast, R-free detection/cache gate and dispatches parity-autofix, which now performs the live-R fidelity check on any change that maps to parity tests.
1 parent 62c11b8 commit b4b4c38

3 files changed

Lines changed: 176 additions & 11 deletions

File tree

.github/workflows/parity-autofix.yml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,18 +112,24 @@ jobs:
112112
id: r_install
113113
continue-on-error: true
114114
run: |
115-
# install_local_r_nns.py installs from the vendored tools/NNS by
116-
# default; the upstream checkout is the recorded truth for this commit.
117-
python scripts/install_local_r_nns.py
115+
# Install the recorded R code itself (upstream checkout at r_commit),
116+
# not the vendored tools/NNS snapshot, so the fidelity test runs
117+
# against the exact R behavior that triggered this workflow.
118+
python scripts/install_local_r_nns.py \
119+
--source upstream/NNS \
120+
--expected-version "${{ steps.payload.outputs.r_version }}"
118121
119122
- name: Run live R parity to reproduce divergence
120123
id: live_parity
121124
continue-on-error: true
122125
run: |
126+
# --live recomputes the mapped parity subset from the freshly installed
127+
# live R (committed cache moved aside, offline toggles cleared) and
128+
# compares it against Python. A failure means real behavior diverged.
123129
python scripts/run_live_r_parity_for_changed_api.py \
124130
--plan sync/last_r_api_plan.json \
125131
--r-checkout upstream/NNS \
126-
--skip-install \
132+
--live \
127133
--out sync/last_live_r_parity_report.md
128134
129135
- name: Classify live parity outcome

scripts/install_local_r_nns.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from __future__ import annotations
1919

20+
import argparse
2021
import shutil
2122
import subprocess
2223
import sys
@@ -34,8 +35,24 @@
3435
)
3536

3637

37-
def _resolve_source() -> Path:
38-
"""Return the vendored NNS source path, preferring the extracted directory."""
38+
def _resolve_source(override: Path | None = None) -> Path:
39+
"""Return the NNS source path to install.
40+
41+
With no override, prefer the vendored extracted directory and fall back to
42+
the vendored tarball. With an override (for example an upstream checkout at a
43+
recorded R commit), install that path directly after validating it is a
44+
package source directory or tarball.
45+
"""
46+
47+
if override is not None:
48+
if override.is_dir() and (override / "DESCRIPTION").is_file():
49+
return override
50+
if override.is_file():
51+
return override
52+
raise SystemExit(
53+
f"ERROR: --source {override} is not an R package source. Expected a "
54+
"directory containing DESCRIPTION or a package tarball."
55+
)
3956

4057
if (_SOURCE_DIR / "DESCRIPTION").is_file():
4158
return _SOURCE_DIR
@@ -58,9 +75,32 @@ def _require(tool: str) -> str:
5875

5976

6077
def main() -> int:
78+
parser = argparse.ArgumentParser(description=__doc__)
79+
parser.add_argument(
80+
"--source",
81+
type=Path,
82+
default=None,
83+
help=(
84+
"Install from this R package source (directory with DESCRIPTION or a "
85+
"tarball) instead of the vendored tools/NNS. Use an upstream checkout "
86+
"to install live R NNS at a recorded commit."
87+
),
88+
)
89+
parser.add_argument(
90+
"--expected-version",
91+
default=_EXPECTED_VERSION,
92+
help=(
93+
"Package version the install must report after loading. Defaults to "
94+
f"{_EXPECTED_VERSION!r}. Pass the recorded upstream version when "
95+
"installing from a non-vendored source."
96+
),
97+
)
98+
args = parser.parse_args()
99+
expected_version = args.expected_version or _EXPECTED_VERSION
100+
61101
r_bin = _require("R")
62102
rscript_bin = _require("Rscript")
63-
source = _resolve_source()
103+
source = _resolve_source(args.source)
64104

65105
print(f"Installing R NNS from local source: {source} (not CRAN)")
66106
install = subprocess.run(
@@ -86,15 +126,15 @@ def main() -> int:
86126

87127
installed_version = probe.stdout.strip()
88128
print(f"Installed NNS version: {installed_version}")
89-
if installed_version != _EXPECTED_VERSION:
129+
if installed_version != expected_version:
90130
print(
91131
"ERROR: installed NNS version "
92-
f"{installed_version!r} does not match expected {_EXPECTED_VERSION!r}.",
132+
f"{installed_version!r} does not match expected {expected_version!r}.",
93133
file=sys.stderr,
94134
)
95135
return 1
96136

97-
print(f"OK: R NNS {_EXPECTED_VERSION} installed from local source.")
137+
print(f"OK: R NNS {expected_version} installed from local source.")
98138
return 0
99139

100140

scripts/run_live_r_parity_for_changed_api.py

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,26 @@
33
import argparse
44
import json
55
import os
6+
import shutil
67
import subprocess
78
import sys
89
from pathlib import Path
910
from typing import Any
1011

1112
REPORT_DEFAULT = Path("sync/last_live_r_parity_report.md")
13+
R_CACHE_PATH = Path("tests/_r_cache.json")
14+
# Backup suffix is gitignored (see .gitignore) so it never lands in a PR.
15+
R_CACHE_BACKUP = R_CACHE_PATH.with_suffix(".json.bak")
16+
17+
# Toggles that force tests/_r.py into offline/cache-only mode. Clearing them lets
18+
# a parity test actually shell out to live R on a cache miss.
19+
OFFLINE_TOGGLES = (
20+
"CI",
21+
"NNS_R_CACHE_ONLY",
22+
"PYNNS_R_CACHE_ONLY",
23+
"NNS_OFFLINE",
24+
"PYNNS_OFFLINE",
25+
)
1226

1327

1428
def load_json(path: Path) -> Any:
@@ -21,21 +35,118 @@ def write_report(path: Path, lines: list[str]) -> None:
2135
print(path)
2236

2337

24-
def run(cmd: list[str], env_extra: dict[str, str] | None = None) -> int:
38+
def run(
39+
cmd: list[str],
40+
env_extra: dict[str, str] | None = None,
41+
clear_offline: bool = False,
42+
) -> int:
2543
env = os.environ.copy()
44+
if clear_offline:
45+
for name in OFFLINE_TOGGLES:
46+
env.pop(name, None)
2647
if env_extra:
2748
env.update(env_extra)
2849
print("+ " + " ".join(cmd))
2950
completed = subprocess.run(cmd, env=env)
3051
return completed.returncode
3152

3253

54+
def run_live_subset(out: Path, header: list[str], parity_tests: list[str]) -> None:
55+
"""Recompute the mapped parity subset from live R and compare against Python.
56+
57+
R NNS must already be installed (the workflow installs it from the upstream
58+
checkout at the recorded commit). The committed cache is moved aside so every
59+
mapped ``nns()`` call shells out to live R, the mapped tests run with the
60+
offline toggles cleared, then the committed cache is restored unchanged. A
61+
test failure means public Python behavior diverged from live R.
62+
"""
63+
64+
if shutil.which("Rscript") is None:
65+
lines = [*header,
66+
"## Result: live R unavailable",
67+
"",
68+
"`Rscript` is not on PATH, so live R parity could not be verified. "
69+
"Install R NNS before running with `--live`.",
70+
]
71+
write_report(out, lines)
72+
raise SystemExit(4)
73+
74+
existing_tests = [t for t in parity_tests if Path(t).exists()]
75+
missing_tests = [t for t in parity_tests if not Path(t).exists()]
76+
if not existing_tests:
77+
lines = [*header,
78+
"## Result: no mapped parity tests present",
79+
"",
80+
"No mapped parity test paths exist on disk; manual review is "
81+
"recommended.",
82+
]
83+
if missing_tests:
84+
lines.extend(["", "Missing mapped tests:", ""])
85+
lines.extend(f"- `{t}`" for t in missing_tests)
86+
write_report(out, lines)
87+
return
88+
89+
cmd = [sys.executable, "-m", "pytest", "-q", "-n", "0", *existing_tests]
90+
moved = False
91+
try:
92+
if R_CACHE_PATH.exists():
93+
shutil.move(str(R_CACHE_PATH), str(R_CACHE_BACKUP))
94+
moved = True
95+
# With the cache absent and offline toggles cleared, each mapped nns()
96+
# call recomputes against the freshly installed live R and the test
97+
# asserts the Python implementation matches it.
98+
code = run(cmd, clear_offline=True)
99+
finally:
100+
if R_CACHE_PATH.exists():
101+
R_CACHE_PATH.unlink()
102+
if moved:
103+
shutil.move(str(R_CACHE_BACKUP), str(R_CACHE_PATH))
104+
105+
if code != 0:
106+
lines = [*header,
107+
"## Result: live R parity diverged",
108+
"",
109+
"Mapped parity tests recomputed every R value from the freshly "
110+
"installed live R NNS and the Python implementation did not match. "
111+
"Public Python behavior differs from live R at the recorded commit.",
112+
"",
113+
f"Failing command: `{' '.join(cmd)}`",
114+
f"Exit status: `{code}`",
115+
]
116+
write_report(out, lines)
117+
raise SystemExit(code)
118+
119+
lines = [*header,
120+
"## Result: live R parity passed (recomputed from live R)",
121+
"",
122+
"Every mapped parity value was recomputed from the freshly installed "
123+
"live R NNS and matched the Python implementation.",
124+
"",
125+
"Tests run:",
126+
"",
127+
]
128+
lines.extend(f"- `{t}`" for t in existing_tests)
129+
if missing_tests:
130+
lines.extend(["", "Skipped missing mapped tests:", ""])
131+
lines.extend(f"- `{t}`" for t in missing_tests)
132+
write_report(out, lines)
133+
134+
33135
def main() -> None:
34136
parser = argparse.ArgumentParser()
35137
parser.add_argument("--plan", type=Path, default=Path("sync/last_r_api_plan.json"))
36138
parser.add_argument("--r-checkout", type=Path, required=False)
37139
parser.add_argument("--fresh-cache", action="store_true")
38140
parser.add_argument("--skip-install", action="store_true")
141+
parser.add_argument(
142+
"--live",
143+
action="store_true",
144+
help=(
145+
"Recompute the mapped parity subset from already-installed live R "
146+
"(toggles cleared, committed cache moved aside) and compare against "
147+
"Python. A failure means Python diverged from live R."
148+
),
149+
)
39150
parser.add_argument("--out", type=Path, default=REPORT_DEFAULT)
40151
args = parser.parse_args()
41152

@@ -51,6 +162,7 @@ def main() -> None:
51162
f"- R checkout: `{args.r_checkout}`",
52163
f"- Fresh cache requested: `{args.fresh_cache}`",
53164
f"- Skip install: `{args.skip_install}`",
165+
f"- Live R recompute: `{args.live}`",
54166
"",
55167
]
56168

@@ -67,6 +179,13 @@ def main() -> None:
67179
write_report(args.out, lines)
68180
raise SystemExit(2)
69181

182+
# Live recompute mode: the true fidelity test. It recomputes the mapped
183+
# subset from already-installed live R regardless of cache state, so it also
184+
# covers version bumps without a separate fresh-cache pass.
185+
if args.live:
186+
run_live_subset(args.out, header, parity_tests)
187+
return
188+
70189
# 3. Fresh cache required but not requested.
71190
if requires_fresh_cache and not args.fresh_cache:
72191
lines = [*header,

0 commit comments

Comments
 (0)