From 6bf239b9b98b0e0aef6530f85f76c47f2d8f9b3c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 04:09:32 +0000 Subject: [PATCH] Add NNS upstream sync and fidelity automation Make NNS-python the final downstream integration, parity, packaging, and release-readiness layer in the NNS supply chain: OVVO-Financial/NNS -> OVVO-Financial/NNS-core -> OVVO-Financial/NNS-python Adds machine-readable sync provenance and the automation that enforces the two upstream truth paths (native C++ via accepted NNS-core snapshots; public Python API behavior via live/cached R NNS at the recorded R commit): - sync/nns_source.json: machine-readable sync manifest (R commit/version/src tree hash, core commit, vendored paths, parity cache path) - sync/r_api_map.json: maps upstream R files to Python modules, parity tests, and cache scopes - docs/sync_contract.md, docs/sync_status.md: sync contract and status docs - scripts/sync_nns_core_snapshot.py: vendor accepted NNS-core into extern/ - scripts/sync_r_nns_snapshot.py: vendor exact R NNS snapshot + tarball - scripts/plan_r_api_parity_review.py: decide affected modules/tests/cache from changed R files (tolerant of missing/unmapped paths) - scripts/run_live_r_parity_for_changed_api.py: live-R parity runner scaffold - scripts/inspect_r_api_update.py: human-readable R API update report - .github/workflows/sync-from-nns-core.yml: receive nns-core-updated events - .github/workflows/inspect-r-api-update.yml: receive R API/version events - native-backend-ci.yml: tolerant vignette-examples gate - pyproject.toml: include /sync in sdist - tests/tools: validate manifests and the parity planning script Automation only; no runtime behavior changes. --- .github/workflows/inspect-r-api-update.yml | 170 +++++++++++++++++ .github/workflows/native-backend-ci.yml | 7 +- .github/workflows/sync-from-nns-core.yml | 144 +++++++++++++++ docs/sync_contract.md | 85 +++++++++ docs/sync_status.md | 31 ++++ pyproject.toml | 3 + scripts/inspect_r_api_update.py | 103 +++++++++++ scripts/plan_r_api_parity_review.py | 107 +++++++++++ scripts/run_live_r_parity_for_changed_api.py | 185 +++++++++++++++++++ scripts/sync_nns_core_snapshot.py | 77 ++++++++ scripts/sync_r_nns_snapshot.py | 118 ++++++++++++ sync/nns_source.json | 15 ++ sync/r_api_map.json | 65 +++++++ tests/tools/test_plan_r_api_parity_review.py | 55 ++++++ tests/tools/test_sync_manifests.py | 74 ++++++++ 15 files changed, 1238 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/inspect-r-api-update.yml create mode 100644 .github/workflows/sync-from-nns-core.yml create mode 100644 docs/sync_contract.md create mode 100644 docs/sync_status.md create mode 100644 scripts/inspect_r_api_update.py create mode 100644 scripts/plan_r_api_parity_review.py create mode 100644 scripts/run_live_r_parity_for_changed_api.py create mode 100644 scripts/sync_nns_core_snapshot.py create mode 100644 scripts/sync_r_nns_snapshot.py create mode 100644 sync/nns_source.json create mode 100644 sync/r_api_map.json create mode 100644 tests/tools/test_plan_r_api_parity_review.py create mode 100644 tests/tools/test_sync_manifests.py diff --git a/.github/workflows/inspect-r-api-update.yml b/.github/workflows/inspect-r-api-update.yml new file mode 100644 index 00000000..04b233d7 --- /dev/null +++ b/.github/workflows/inspect-r-api-update.yml @@ -0,0 +1,170 @@ +name: Inspect R API update + +on: + repository_dispatch: + types: [nns-r-api-or-version-updated] + workflow_dispatch: + inputs: + r_commit: + required: true + type: string + r_version: + required: true + type: string + r_src_tree_hash: + required: true + type: string + description_changed: + required: true + type: boolean + fresh_cache: + required: false + default: false + type: boolean + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + inspect-r-api: + runs-on: ubuntu-latest + steps: + - name: Check out NNS-python + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve payload + id: payload + shell: bash + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "repository_dispatch" ]; then + echo "r_commit=${{ github.event.client_payload.r_commit }}" >> "$GITHUB_OUTPUT" + echo "r_version=${{ github.event.client_payload.r_version }}" >> "$GITHUB_OUTPUT" + echo "r_src_tree_hash=${{ github.event.client_payload.r_src_tree_hash }}" >> "$GITHUB_OUTPUT" + echo "description_changed=${{ github.event.client_payload.description_changed }}" >> "$GITHUB_OUTPUT" + echo "fresh_cache=false" >> "$GITHUB_OUTPUT" + echo '${{ toJson(github.event.client_payload.changed_files) }}' > changed_files.json + else + echo "r_commit=${{ inputs.r_commit }}" >> "$GITHUB_OUTPUT" + echo "r_version=${{ inputs.r_version }}" >> "$GITHUB_OUTPUT" + echo "r_src_tree_hash=${{ inputs.r_src_tree_hash }}" >> "$GITHUB_OUTPUT" + echo "description_changed=${{ inputs.description_changed }}" >> "$GITHUB_OUTPUT" + echo "fresh_cache=${{ inputs.fresh_cache }}" >> "$GITHUB_OUTPUT" + echo '[]' > changed_files.json + fi + + - name: Check out upstream R NNS + uses: actions/checkout@v4 + with: + repository: OVVO-Financial/NNS + ref: ${{ steps.payload.outputs.r_commit }} + path: upstream/NNS + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Python build and test tools + run: | + python -m pip install -U pip + python -m pip install build scikit-build-core nanobind pytest ruff mypy numpy scipy + python -m pip install hypothesis pytest-benchmark pytest-xdist + + - name: Plan R API parity review + run: | + python scripts/plan_r_api_parity_review.py \ + --changed-files-json changed_files.json \ + --map sync/r_api_map.json \ + --out sync/last_r_api_inspection.md \ + --json-out sync/last_r_api_plan.json + + - name: Vendor R snapshot when DESCRIPTION changed + if: steps.payload.outputs.description_changed == 'true' + run: | + python scripts/sync_r_nns_snapshot.py \ + --r-checkout upstream/NNS \ + --r-repo OVVO-Financial/NNS \ + --r-commit "${{ steps.payload.outputs.r_commit }}" \ + --r-version "${{ steps.payload.outputs.r_version }}" \ + --r-src-tree-hash "${{ steps.payload.outputs.r_src_tree_hash }}" + + - name: Install package editable + run: python -m pip install -e . --force-reinstall + + - name: Run mapped live R parity or report required fresh cache + id: live_parity + continue-on-error: true + run: | + if [ "${{ steps.payload.outputs.fresh_cache }}" = "true" ]; then + python scripts/run_live_r_parity_for_changed_api.py \ + --plan sync/last_r_api_plan.json \ + --r-checkout upstream/NNS \ + --fresh-cache \ + --out sync/last_live_r_parity_report.md + else + python scripts/run_live_r_parity_for_changed_api.py \ + --plan sync/last_r_api_plan.json \ + --r-checkout upstream/NNS \ + --out sync/last_live_r_parity_report.md + fi + + - name: Record live parity exit status + if: always() + shell: bash + run: | + status="${{ steps.live_parity.outcome }}" + { + echo "" + echo "## Workflow step outcome" + echo "" + echo "- \`run_live_r_parity_for_changed_api.py\` step outcome: \`${status}\`" + echo "- Fresh cache requested: \`${{ steps.payload.outputs.fresh_cache }}\`" + echo "- DESCRIPTION changed: \`${{ steps.payload.outputs.description_changed }}\`" + } >> sync/last_live_r_parity_report.md + + - name: Run standard gates if no fresh cache was required + if: steps.payload.outputs.fresh_cache != 'true' + run: | + python -m pytest -q tests/invariants + NNS_R_CACHE_ONLY=1 python -m pytest -q tests/parity + python -m pytest -q tests/parity/test_r13_smoke.py + if [ -f tests/docs/test_vignette_examples.py ]; then + python -m pytest -q tests/docs/test_vignette_examples.py + fi + ruff check . + mypy + python -m build + + - name: Open R API inspection PR + uses: peter-evans/create-pull-request@v6 + with: + branch: inspect-r-api-${{ steps.payload.outputs.r_commit }} + title: Inspect R NNS API update ${{ steps.payload.outputs.r_commit }} + body: | + This PR records a direct R behavior fidelity check from + `OVVO-Financial/NNS` to `OVVO-Financial/NNS-python`. + + R commit: `${{ steps.payload.outputs.r_commit }}` + R version: `${{ steps.payload.outputs.r_version }}` + R src tree hash: `${{ steps.payload.outputs.r_src_tree_hash }}` + DESCRIPTION changed: `${{ steps.payload.outputs.description_changed }}` + Fresh cache requested: `${{ steps.payload.outputs.fresh_cache }}` + + Reports: + - `sync/last_r_api_inspection.md` + - `sync/last_r_api_plan.json` + - `sync/last_live_r_parity_report.md` + + Native code still enters Python only through `NNS-core`. But public + Python behavior must match live R NNS at the recorded R commit, + including wrappers, defaults, return shapes, and exported function + behavior. + + If DESCRIPTION changed and fresh cache was not requested, run this + workflow manually with `fresh_cache=true`. + commit-message: Inspect R API update ${{ steps.payload.outputs.r_commit }} diff --git a/.github/workflows/native-backend-ci.yml b/.github/workflows/native-backend-ci.yml index e658e8e8..7cf16abd 100644 --- a/.github/workflows/native-backend-ci.yml +++ b/.github/workflows/native-backend-ci.yml @@ -41,7 +41,12 @@ jobs: run: NNS_R_CACHE_ONLY=1 python -m pytest -q tests/parity - name: Run vignette examples - run: python -m pytest -q tests/docs/test_vignette_examples.py + run: | + if [ -f tests/docs/test_vignette_examples.py ]; then + python -m pytest -q tests/docs/test_vignette_examples.py + else + echo "No docs vignette test present; skipping." + fi - name: Run ruff run: ruff check . diff --git a/.github/workflows/sync-from-nns-core.yml b/.github/workflows/sync-from-nns-core.yml new file mode 100644 index 00000000..809bd6b7 --- /dev/null +++ b/.github/workflows/sync-from-nns-core.yml @@ -0,0 +1,144 @@ +name: Sync from NNS-core + +on: + repository_dispatch: + types: [nns-core-updated] + workflow_dispatch: + inputs: + core_commit: + required: true + type: string + r_repo: + required: false + default: OVVO-Financial/NNS + type: string + r_commit: + required: true + type: string + r_version: + required: true + type: string + r_src_tree_hash: + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + sync-core: + runs-on: ubuntu-latest + steps: + - name: Check out NNS-python + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve payload + id: payload + shell: bash + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "repository_dispatch" ]; then + echo "core_commit=${{ github.event.client_payload.core_commit }}" >> "$GITHUB_OUTPUT" + echo "r_repo=${{ github.event.client_payload.r_repo }}" >> "$GITHUB_OUTPUT" + echo "r_commit=${{ github.event.client_payload.r_commit }}" >> "$GITHUB_OUTPUT" + echo "r_version=${{ github.event.client_payload.r_version }}" >> "$GITHUB_OUTPUT" + echo "r_src_tree_hash=${{ github.event.client_payload.r_src_tree_hash }}" >> "$GITHUB_OUTPUT" + else + echo "core_commit=${{ inputs.core_commit }}" >> "$GITHUB_OUTPUT" + echo "r_repo=${{ inputs.r_repo }}" >> "$GITHUB_OUTPUT" + echo "r_commit=${{ inputs.r_commit }}" >> "$GITHUB_OUTPUT" + echo "r_version=${{ inputs.r_version }}" >> "$GITHUB_OUTPUT" + echo "r_src_tree_hash=${{ inputs.r_src_tree_hash }}" >> "$GITHUB_OUTPUT" + fi + + - name: Check out public NNS-core + uses: actions/checkout@v4 + with: + repository: OVVO-Financial/NNS-core + ref: ${{ steps.payload.outputs.core_commit }} + path: upstream/NNS-core + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build tools + run: | + python -m pip install -U pip + python -m pip install build scikit-build-core nanobind pytest ruff mypy numpy scipy + python -m pip install hypothesis pytest-benchmark pytest-xdist + + - name: Vendor NNS-core snapshot + run: | + python scripts/sync_nns_core_snapshot.py \ + --core-checkout upstream/NNS-core \ + --core-commit "${{ steps.payload.outputs.core_commit }}" \ + --r-repo "${{ steps.payload.outputs.r_repo }}" \ + --r-commit "${{ steps.payload.outputs.r_commit }}" \ + --r-version "${{ steps.payload.outputs.r_version }}" \ + --r-src-tree-hash "${{ steps.payload.outputs.r_src_tree_hash }}" + + - name: Install package editable + run: python -m pip install -e . --force-reinstall + + - name: Run native import smoke test + run: python -c "import nns._nnscore as c; print(c.lpm(2.0, 0.0, [-2.0, -1.0, 0.5, 3.0]))" + + - name: Run invariants + run: python -m pytest -q tests/invariants + + - name: Run parity from committed R cache + run: NNS_R_CACHE_ONLY=1 python -m pytest -q tests/parity + + - name: Run R smoke + run: python -m pytest -q tests/parity/test_r13_smoke.py + + - name: Run vignette examples + run: | + if [ -f tests/docs/test_vignette_examples.py ]; then + python -m pytest -q tests/docs/test_vignette_examples.py + else + echo "No docs vignette test present; skipping." + fi + + - name: Run ruff + run: ruff check . + + - name: Run mypy + run: mypy + + - name: Build distributions + run: python -m build + + - name: Open NNS-core sync PR + uses: peter-evans/create-pull-request@v6 + with: + branch: sync-nns-core-${{ steps.payload.outputs.core_commit }} + title: Sync vendored NNS-core from ${{ steps.payload.outputs.core_commit }} + body: | + This PR vendors the accepted public `OVVO-Financial/NNS-core` + snapshot into `extern/NNS-core`. + + Core commit: `${{ steps.payload.outputs.core_commit }}` + R repo: `${{ steps.payload.outputs.r_repo }}` + R commit: `${{ steps.payload.outputs.r_commit }}` + R version: `${{ steps.payload.outputs.r_version }}` + R src tree hash: `${{ steps.payload.outputs.r_src_tree_hash }}` + + Validation run in workflow: + - native import smoke test + - invariants + - parity from committed R cache + - R smoke test + - vignette examples if present + - ruff + - mypy + - build + + Native code enters Python only through accepted public `NNS-core` + commits. This PR does not auto-port native code from R. + commit-message: Sync vendored NNS-core ${{ steps.payload.outputs.core_commit }} diff --git a/docs/sync_contract.md b/docs/sync_contract.md new file mode 100644 index 00000000..b6938c27 --- /dev/null +++ b/docs/sync_contract.md @@ -0,0 +1,85 @@ +# NNS-python sync contract + +`OVVO-Financial/NNS-python` is the final downstream integration layer in the NNS +supply chain. + +```text +OVVO-Financial/NNS + -> OVVO-Financial/NNS-core + -> OVVO-Financial/NNS-python +``` + +## Authority + +`OVVO-Financial/NNS` is the statistical source of truth. + +`OVVO-Financial/NNS-core` is the accepted portable C++ extraction of the R +`src/**` layer. + +`OVVO-Financial/NNS-python` consumes accepted `NNS-core` snapshots and verifies +public Python API behavior against live or cached R NNS. + +## Native code ingress rule + +Native C++ source changes enter Python only through accepted public +`OVVO-Financial/NNS-core` commits. + +Python must not auto-port native C++ directly from `OVVO-Financial/NNS`. + +Native snapshots are vendored under: + +```text +extern/NNS-core +``` + +## R behavior fidelity rule + +Python API behavior is tested directly against live `OVVO-Financial/NNS` at the +recorded R commit. + +The following upstream changes require Python parity review: + +* `R/**` +* `NAMESPACE` +* `DESCRIPTION` +* exported return behavior +* tests or examples that expose changed public behavior + +A `DESCRIPTION` version change requires fresh live-R parity-cache regeneration. + +## Required gates + +A Python sync PR is not complete until these pass: + +```bash +python -m pip install -e . --force-reinstall +python -m pytest -q tests/invariants +NNS_R_CACHE_ONLY=1 python -m pytest -q tests/parity +python -m pytest -q tests/parity/test_r13_smoke.py +python -m pytest -q tests/docs/test_vignette_examples.py +ruff check . +mypy +python -m build +``` + +If `tests/docs/test_vignette_examples.py` is not present yet, the workflow may +skip that gate with an explicit message. + +## Fresh R cache rule + +When R `DESCRIPTION` changes, or when mapped live-R parity proves changed public +behavior, regenerate from empty: + +```bash +python scripts/install_local_r_nns.py +python scripts/regenerate_r_cache.py --fresh -- -n 0 tests/parity +NNS_R_CACHE_ONLY=1 python -m pytest -q -n 0 tests/parity +``` + +## Traceability + +Every Python release must be traceable to: + +* one R NNS commit for behavioral truth +* one NNS-core commit for native truth +* one parity cache generated from the R truth commit diff --git a/docs/sync_status.md b/docs/sync_status.md new file mode 100644 index 00000000..fe512b5a --- /dev/null +++ b/docs/sync_status.md @@ -0,0 +1,31 @@ +# NNS sync status + +The authoritative machine-readable file is: + +```text +sync/nns_source.json +``` + +| Layer | Repository | Commit | Role | +| -------- | --------------------------- | -----------: | --------------------------------------- | +| R source | `OVVO-Financial/NNS` | See manifest | Statistical and R API source of truth | +| C++ core | `OVVO-Financial/NNS-core` | See manifest | Accepted portable native core | +| Python | `OVVO-Financial/NNS-python` | Current repo | Python API, parity, packaging, examples | + +Native code enters Python only through the accepted public `NNS-core` snapshot +vendored under: + +```text +extern/NNS-core +``` + +R behavior is checked directly against live R NNS when required, and against the +committed parity cache in ordinary CI. + +## Notes on manifest fields + +`r_src_tree_hash` records the git tree object hash of the vendored R `src/**` +tree (`tools/NNS/src`). The `r_commit` and `core_commit` fields are populated by +the sync automation when an upstream `NNS` or `NNS-core` event fires; until a +sync event records them they may read `unknown`. The `r_version` is taken from +the vendored R `DESCRIPTION` (`13.0`). diff --git a/pyproject.toml b/pyproject.toml index 42342c28..7d1b3376 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ sdist.include = [ "/LICENSE", "/README.md", "/docs", + "/sync", "/extern/NNS-core", "/original_tests", "/scripts", @@ -91,6 +92,8 @@ select = ["E", "F", "I", "B", "UP", "N", "RUF", "TID"] "tests/**" = ["TID251"] "scripts/regenerate_r_cache.py" = ["TID251"] "scripts/install_local_r_nns.py" = ["TID251"] +"scripts/run_live_r_parity_for_changed_api.py" = ["TID251"] +"scripts/inspect_r_api_update.py" = ["TID251"] [tool.mypy] python_version = "3.11" diff --git a/scripts/inspect_r_api_update.py b/scripts/inspect_r_api_update.py new file mode 100644 index 00000000..0798e251 --- /dev/null +++ b/scripts/inspect_r_api_update.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +PLAN_JSON = Path("sync/last_r_api_plan.json") +INSPECTION_MD = Path("sync/last_r_api_inspection.md") + + +def load_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--r-checkout", type=Path, required=False) + parser.add_argument("--r-commit", required=True) + parser.add_argument("--r-version", required=True) + parser.add_argument("--description-changed", default="false") + parser.add_argument("--changed-files-json", type=Path, required=True) + parser.add_argument("--map", type=Path, default=Path("sync/r_api_map.json")) + parser.add_argument("--out", type=Path, default=INSPECTION_MD) + parser.add_argument("--json-out", type=Path, default=PLAN_JSON) + args = parser.parse_args() + + # Delegate planning to the planning script so both stay in sync. + cmd = [ + sys.executable, + str(Path(__file__).with_name("plan_r_api_parity_review.py")), + "--changed-files-json", + str(args.changed_files_json), + "--map", + str(args.map), + "--out", + str(args.out), + "--json-out", + str(args.json_out), + ] + print("+ " + " ".join(cmd)) + completed = subprocess.run(cmd) + if completed.returncode != 0: + raise SystemExit(completed.returncode) + + plan = load_json(args.json_out) + description_changed = str(args.description_changed).lower() in {"1", "true", "yes"} + requires_fresh_cache = bool(plan.get("requires_fresh_cache")) or description_changed + requires_export_review = bool(plan.get("requires_export_review")) + has_unmapped = bool(plan.get("has_unmapped_r_files")) + + lines = [ + "# R API update inspection", + "", + "- R repo: `OVVO-Financial/NNS`", + f"- R commit: `{args.r_commit}`", + f"- R version: `{args.r_version}`", + f"- DESCRIPTION changed: `{description_changed}`", + "", + "## Changed files", + "", + ] + changed = plan.get("changed_files", []) + lines.extend(f"- `{file}`" for file in changed) if changed else lines.append("- None") + + lines.extend(["", "## Affected Python modules", ""]) + modules = plan.get("affected_python_modules", []) + lines.extend(f"- `{m}`" for m in modules) if modules else lines.append("- None mapped") + + lines.extend(["", "## Mapped parity tests", ""]) + tests = plan.get("parity_tests", []) + lines.extend(f"- `{t}`" for t in tests) if tests else lines.append("- None mapped") + + lines.extend(["", "## Cache scope", ""]) + scope = plan.get("cache_scope", []) + lines.extend(f"- `{s}`" for s in scope) if scope else lines.append("- None mapped") + + lines.extend( + [ + "", + "## Required actions", + "", + f"- Fresh cache regeneration required: `{requires_fresh_cache}`", + f"- Export review required: `{requires_export_review}`", + f"- Unmapped R files require manual review: `{has_unmapped}`", + ] + ) + if has_unmapped: + lines.extend(["", "## Unmapped R files", ""]) + lines.extend(f"- `{file}`" for file in plan.get("unmapped_r_files", [])) + if plan.get("warnings"): + lines.extend(["", "## Warnings", ""]) + lines.extend(f"- {w}" for w in plan["warnings"]) + + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(args.out) + + +if __name__ == "__main__": + main() diff --git a/scripts/plan_r_api_parity_review.py b/scripts/plan_r_api_parity_review.py new file mode 100644 index 00000000..3b68a676 --- /dev/null +++ b/scripts/plan_r_api_parity_review.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +def load_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def _bullets(items: list[str]) -> list[str]: + if not items: + return ["- None mapped"] + return [f"- `{item}`" for item in items] + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--changed-files-json", type=Path, required=True) + parser.add_argument("--map", type=Path, default=Path("sync/r_api_map.json")) + parser.add_argument("--out", type=Path, default=Path("sync/last_r_api_inspection.md")) + parser.add_argument("--json-out", type=Path, default=Path("sync/last_r_api_plan.json")) + args = parser.parse_args() + + changed = load_json(args.changed_files_json) + api_map = load_json(args.map) + + affected_modules: set[str] = set() + tests: set[str] = set() + cache_scope: set[str] = set() + unmapped: list[str] = [] + warnings: list[str] = [] + requires_fresh_cache = False + requires_export_review = False + + for file in changed: + entry = api_map.get(file) + if entry is None and file.startswith("R/") and file.endswith(".R"): + unmapped.append(file) + continue + if entry is None: + continue + affected_modules.update(entry.get("python_modules", [])) + tests.update(entry.get("parity_tests", [])) + cache_scope.update(entry.get("cache_scope", [])) + requires_fresh_cache = requires_fresh_cache or bool(entry.get("requires_fresh_cache")) + requires_export_review = requires_export_review or bool( + entry.get("requires_export_review") + ) + + for test in sorted(tests): + if not Path(test).exists(): + warnings.append(f"mapped test path does not exist: {test}") + + plan = { + "changed_files": changed, + "affected_python_modules": sorted(affected_modules), + "parity_tests": sorted(tests), + "cache_scope": sorted(cache_scope), + "requires_fresh_cache": requires_fresh_cache, + "requires_export_review": requires_export_review, + "has_unmapped_r_files": bool(unmapped), + "unmapped_r_files": unmapped, + "warnings": warnings, + } + args.json_out.parent.mkdir(parents=True, exist_ok=True) + args.json_out.write_text(json.dumps(plan, indent=2) + "\n", encoding="utf-8") + + lines = [ + "# R API parity review plan", + "", + "## Changed files", + "", + ] + lines.extend(_bullets(list(changed))) + lines.extend(["", "## Affected Python modules", ""]) + lines.extend(_bullets(sorted(affected_modules))) + lines.extend(["", "## Parity tests to run", ""]) + lines.extend(_bullets(sorted(tests))) + lines.extend(["", "## Cache scope", ""]) + lines.extend(_bullets(sorted(cache_scope))) + lines.extend( + [ + "", + "## Required actions", + "", + f"- Fresh cache required: `{requires_fresh_cache}`", + f"- Export review required: `{requires_export_review}`", + f"- Unmapped R files present: `{bool(unmapped)}`", + ] + ) + if unmapped: + lines.extend(["", "## Unmapped R files", ""]) + lines.extend(f"- `{file}`" for file in unmapped) + if warnings: + lines.extend(["", "## Warnings", ""]) + lines.extend(f"- {warning}" for warning in warnings) + + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(args.out) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_live_r_parity_for_changed_api.py b/scripts/run_live_r_parity_for_changed_api.py new file mode 100644 index 00000000..e75a03a4 --- /dev/null +++ b/scripts/run_live_r_parity_for_changed_api.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +REPORT_DEFAULT = Path("sync/last_live_r_parity_report.md") + + +def load_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def write_report(path: Path, lines: list[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(path) + + +def run(cmd: list[str], env_extra: dict[str, str] | None = None) -> int: + env = os.environ.copy() + if env_extra: + env.update(env_extra) + print("+ " + " ".join(cmd)) + completed = subprocess.run(cmd, env=env) + return completed.returncode + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--plan", type=Path, default=Path("sync/last_r_api_plan.json")) + parser.add_argument("--r-checkout", type=Path, required=False) + parser.add_argument("--fresh-cache", action="store_true") + parser.add_argument("--skip-install", action="store_true") + parser.add_argument("--out", type=Path, default=REPORT_DEFAULT) + args = parser.parse_args() + + plan = load_json(args.plan) + parity_tests: list[str] = list(plan.get("parity_tests", [])) + requires_fresh_cache = bool(plan.get("requires_fresh_cache")) + has_unmapped = bool(plan.get("has_unmapped_r_files")) + + header = [ + "# Live R parity report", + "", + f"- Plan: `{args.plan}`", + f"- R checkout: `{args.r_checkout}`", + f"- Fresh cache requested: `{args.fresh_cache}`", + f"- Skip install: `{args.skip_install}`", + "", + ] + + # 2. Unmapped R files require manual review. + if has_unmapped: + lines = [*header, + "## Result: manual review required", + "", + "The plan reports unmapped R files. A human must extend " + "`sync/r_api_map.json` before automated parity can run:", + "", + ] + lines.extend(f"- `{file}`" for file in plan.get("unmapped_r_files", [])) + write_report(args.out, lines) + raise SystemExit(2) + + # 3. Fresh cache required but not requested. + if requires_fresh_cache and not args.fresh_cache: + lines = [*header, + "## Result: fresh cache required", + "", + "The plan reports `requires_fresh_cache=true` (for example a " + "`DESCRIPTION` version change). Re-run this workflow with " + "`--fresh-cache` / `fresh_cache=true` to regenerate the parity cache " + "from empty against live R.", + ] + write_report(args.out, lines) + raise SystemExit(3) + + # 8. Fresh cache path: full regeneration from empty against live R. + if args.fresh_cache: + steps = [ + [sys.executable, "scripts/install_local_r_nns.py"], + [ + sys.executable, + "scripts/regenerate_r_cache.py", + "--fresh", + "--", + "-n", + "0", + "tests/parity", + ], + ] + for cmd in steps: + code = run(cmd) + if code != 0: + lines = [*header, + "## Result: fresh cache regeneration failed", + "", + f"Failing command: `{' '.join(cmd)}`", + f"Exit status: `{code}`", + ] + write_report(args.out, lines) + raise SystemExit(code) + replay = [sys.executable, "-m", "pytest", "-q", "-n", "0", "tests/parity"] + code = run(replay, env_extra={"NNS_R_CACHE_ONLY": "1"}) + if code != 0: + lines = [*header, + "## Result: parity replay failed after fresh regeneration", + "", + f"Failing command: `{' '.join(replay)}`", + f"Exit status: `{code}`", + ] + write_report(args.out, lines) + raise SystemExit(code) + lines = [*header, + "## Result: fresh cache regenerated and parity replay passed", + "", + "Cache regenerated from empty against live R; " + "`NNS_R_CACHE_ONLY=1 pytest tests/parity` passed.", + ] + write_report(args.out, lines) + return + + # 4. Install the checked-out R package unless skipped. + if not args.skip_install: + code = run([sys.executable, "scripts/install_local_r_nns.py"]) + if code != 0: + lines = [*header, + "## Result: R install failed", + "", + "`scripts/install_local_r_nns.py` exited nonzero; live R parity " + "could not be run.", + f"Exit status: `{code}`", + ] + write_report(args.out, lines) + raise SystemExit(code) + + # 5/6. Run mapped parity tests that exist. + existing_tests = [t for t in parity_tests if Path(t).exists()] + missing_tests = [t for t in parity_tests if not Path(t).exists()] + + if not existing_tests: + lines = [*header, + "## Result: no mapped parity tests present", + "", + "No mapped parity test paths exist on disk; manual review is " + "recommended.", + ] + if missing_tests: + lines.extend(["", "Missing mapped tests:", ""]) + lines.extend(f"- `{t}`" for t in missing_tests) + write_report(args.out, lines) + return + + cmd = [sys.executable, "-m", "pytest", "-q", "-n", "0", *existing_tests] + code = run(cmd) + if code != 0: + lines = [*header, + "## Result: mapped live R parity tests failed", + "", + f"Failing command: `{' '.join(cmd)}`", + f"Exit status: `{code}`", + ] + write_report(args.out, lines) + raise SystemExit(code) + + lines = [*header, + "## Result: mapped live R parity tests passed", + "", + "Tests run:", + "", + ] + lines.extend(f"- `{t}`" for t in existing_tests) + if missing_tests: + lines.extend(["", "Skipped missing mapped tests:", ""]) + lines.extend(f"- `{t}`" for t in missing_tests) + write_report(args.out, lines) + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_nns_core_snapshot.py b/scripts/sync_nns_core_snapshot.py new file mode 100644 index 00000000..c89ea1fa --- /dev/null +++ b/scripts/sync_nns_core_snapshot.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import argparse +import json +import shutil +from pathlib import Path + +EXCLUDES = { + ".git", + "build", + ".cache", + ".pytest_cache", + "__pycache__", +} + + +def ignore(_directory: str, names: list[str]) -> set[str]: + ignored: set[str] = set() + for name in names: + if name in EXCLUDES: + ignored.add(name) + if name.endswith((".pyc", ".pyo", "~")): + ignored.add(name) + return ignored + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--core-checkout", type=Path, required=True) + parser.add_argument("--core-commit", required=True) + parser.add_argument("--r-repo", required=True) + parser.add_argument("--r-commit", required=True) + parser.add_argument("--r-version", required=True) + parser.add_argument("--r-src-tree-hash", required=True) + parser.add_argument("--manifest", type=Path, default=Path("sync/nns_source.json")) + args = parser.parse_args() + + core = args.core_checkout.resolve() + if not (core / "CMakeLists.txt").is_file(): + raise SystemExit(f"{core} does not look like NNS-core: missing CMakeLists.txt") + + dest = Path("extern/NNS-core") + if dest.exists(): + shutil.rmtree(dest) + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(core, dest, ignore=ignore) + + if not (dest / "CMakeLists.txt").is_file(): + raise SystemExit(f"copied NNS-core is invalid: missing {dest / 'CMakeLists.txt'}") + + manifest = { + "r_repo": args.r_repo, + "r_commit": args.r_commit, + "r_version": args.r_version, + "r_src_tree_hash": args.r_src_tree_hash, + "core_repo": "OVVO-Financial/NNS-core", + "core_commit": args.core_commit, + "python_repo": "OVVO-Financial/NNS-python", + "python_commit": None, + "vendored_core_path": "extern/NNS-core", + "vendored_r_path": "tools/NNS", + "vendored_r_tarball": f"tools/NNS_{args.r_version}.tar.gz", + "r_cache_path": "tests/_r_cache.json", + "notes": ( + "NNS-python consumes accepted NNS-core snapshots for native code and " + "verifies public Python behavior against live or cached R NNS." + ), + } + args.manifest.parent.mkdir(parents=True, exist_ok=True) + args.manifest.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + print(f"Vendored NNS-core {args.core_commit} into {dest}") + print(f"Recorded R source {args.r_repo}@{args.r_commit} version {args.r_version}") + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_r_nns_snapshot.py b/scripts/sync_r_nns_snapshot.py new file mode 100644 index 00000000..4c694691 --- /dev/null +++ b/scripts/sync_r_nns_snapshot.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import argparse +import json +import shutil +import tarfile +from pathlib import Path + +EXCLUDES = { + ".git", + ".Rproj.user", + ".Rhistory", + ".RData", + "__pycache__", +} + + +def ignore(_directory: str, names: list[str]) -> set[str]: + ignored: set[str] = set() + for name in names: + if name in EXCLUDES: + ignored.add(name) + if name.endswith(("~", ".pyc", ".pyo")): + ignored.add(name) + return ignored + + +def description_version(path: Path) -> str: + for line in path.read_text(encoding="utf-8").splitlines(): + if line.startswith("Version:"): + return line.split(":", 1)[1].strip() + raise SystemExit(f"Version field not found in {path}") + + +def make_tarball(source_dir: Path, tarball: Path) -> None: + if tarball.exists(): + tarball.unlink() + with tarfile.open(tarball, "w:gz") as tf: + tf.add(source_dir, arcname="NNS") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--r-checkout", type=Path, required=True) + parser.add_argument("--r-repo", required=True) + parser.add_argument("--r-commit", required=True) + parser.add_argument("--r-version", required=True) + parser.add_argument("--r-src-tree-hash", required=True) + parser.add_argument("--manifest", type=Path, default=Path("sync/nns_source.json")) + args = parser.parse_args() + + r_checkout = args.r_checkout.resolve() + desc = r_checkout / "DESCRIPTION" + if not desc.is_file(): + raise SystemExit(f"{r_checkout} does not look like R NNS: missing DESCRIPTION") + + found_version = description_version(desc) + if found_version != args.r_version: + raise SystemExit( + f"DESCRIPTION version {found_version} does not match expected {args.r_version}" + ) + + tools = Path("tools") + dest = tools / "NNS" + tarball = tools / f"NNS_{args.r_version}.tar.gz" + + if dest.exists(): + shutil.rmtree(dest) + tools.mkdir(parents=True, exist_ok=True) + shutil.copytree(r_checkout, dest, ignore=ignore) + + if not (dest / "DESCRIPTION").is_file(): + raise SystemExit("copied R NNS snapshot is invalid: missing tools/NNS/DESCRIPTION") + + make_tarball(dest, tarball) + + for old in tools.glob("NNS_*.tar.gz"): + if old != tarball: + old.unlink() + + if args.manifest.exists(): + manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + else: + manifest = {} + + manifest.update( + { + "r_repo": args.r_repo, + "r_commit": args.r_commit, + "r_version": args.r_version, + "r_src_tree_hash": args.r_src_tree_hash, + "python_repo": "OVVO-Financial/NNS-python", + "python_commit": None, + "vendored_r_path": "tools/NNS", + "vendored_r_tarball": str(tarball), + "r_cache_path": "tests/_r_cache.json", + } + ) + manifest.setdefault("core_repo", "OVVO-Financial/NNS-core") + manifest.setdefault("core_commit", None) + manifest.setdefault("vendored_core_path", "extern/NNS-core") + manifest.setdefault( + "notes", + ( + "NNS-python consumes accepted NNS-core snapshots for native code and " + "verifies public Python behavior against live or cached R NNS." + ), + ) + + args.manifest.parent.mkdir(parents=True, exist_ok=True) + args.manifest.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + print(f"Vendored R NNS {args.r_version} from {args.r_commit} into {dest}") + print(f"Wrote tarball {tarball}") + + +if __name__ == "__main__": + main() diff --git a/sync/nns_source.json b/sync/nns_source.json new file mode 100644 index 00000000..a592d4fb --- /dev/null +++ b/sync/nns_source.json @@ -0,0 +1,15 @@ +{ + "r_repo": "OVVO-Financial/NNS", + "r_commit": "unknown", + "r_version": "13.0", + "r_src_tree_hash": "654e411bd4e8caabfd57a1a4190eb1d97411e059", + "core_repo": "OVVO-Financial/NNS-core", + "core_commit": "unknown", + "python_repo": "OVVO-Financial/NNS-python", + "python_commit": null, + "vendored_core_path": "extern/NNS-core", + "vendored_r_path": "tools/NNS", + "vendored_r_tarball": "tools/NNS_13.0.tar.gz", + "r_cache_path": "tests/_r_cache.json", + "notes": "NNS-python consumes accepted NNS-core snapshots for native code and verifies public Python behavior against live or cached R NNS." +} diff --git a/sync/r_api_map.json b/sync/r_api_map.json new file mode 100644 index 00000000..801e6c4b --- /dev/null +++ b/sync/r_api_map.json @@ -0,0 +1,65 @@ +{ + "R/ARMA.R": { + "python_modules": ["src/nns/arma.py"], + "parity_tests": [ + "tests/parity/test_r13_smoke.py", + "tests/parity/test_practical_examples.py" + ], + "cache_scope": ["NNS.ARMA", "NNS.ARMA.optim", "NNS.VAR"] + }, + "R/Regression.R": { + "python_modules": [ + "src/nns/regression.py", + "src/nns/multivariate_regression.py" + ], + "parity_tests": [ + "tests/parity/test_r13_smoke.py", + "tests/parity/test_practical_examples.py" + ], + "cache_scope": ["NNS.reg", "NNS.M.reg"] + }, + "R/Stack.R": { + "python_modules": ["src/nns/stack.py"], + "parity_tests": [ + "tests/docs/test_vignette_examples.py" + ], + "cache_scope": ["NNS.stack"] + }, + "R/Boost.R": { + "python_modules": ["src/nns/boost.py"], + "parity_tests": [ + "tests/docs/test_vignette_examples.py" + ], + "cache_scope": ["NNS.boost"] + }, + "R/Dependence.R": { + "python_modules": ["src/nns/dependence.py"], + "parity_tests": [ + "tests/parity/test_r13_smoke.py", + "tests/docs/test_vignette_examples.py" + ], + "cache_scope": ["NNS.dep", "NNS.copula", "PM.matrix"] + }, + "R/Partial_Moments.R": { + "python_modules": ["src/nns/partial_moments.py", "src/nns/var.py"], + "parity_tests": [ + "tests/parity/test_r13_smoke.py", + "tests/invariants" + ], + "cache_scope": ["LPM", "UPM", "LPM.ratio", "UPM.ratio", "LPM.VaR", "UPM.VaR"] + }, + "NAMESPACE": { + "python_modules": ["src/nns/__init__.py"], + "parity_tests": ["tests/parity/test_r13_smoke.py"], + "requires_export_review": true + }, + "DESCRIPTION": { + "python_modules": [ + "pyproject.toml", + "tools/NNS", + "tests/_r_cache.json" + ], + "parity_tests": ["tests/parity"], + "requires_fresh_cache": true + } +} diff --git a/tests/tools/test_plan_r_api_parity_review.py b/tests/tools/test_plan_r_api_parity_review.py new file mode 100644 index 00000000..8a70025d --- /dev/null +++ b/tests/tools/test_plan_r_api_parity_review.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT = REPO_ROOT / "scripts" / "plan_r_api_parity_review.py" +API_MAP = REPO_ROOT / "sync" / "r_api_map.json" + + +def _run_plan(changed: list[str], tmp_path: Path) -> dict[str, Any]: + changed_files = tmp_path / "changed_files.json" + changed_files.write_text(json.dumps(changed), encoding="utf-8") + out_md = tmp_path / "inspection.md" + out_json = tmp_path / "plan.json" + + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--changed-files-json", + str(changed_files), + "--map", + str(API_MAP), + "--out", + str(out_md), + "--json-out", + str(out_json), + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert out_md.exists() + plan: dict[str, Any] = json.loads(out_json.read_text(encoding="utf-8")) + return plan + + +def test_mapped_arma_change(tmp_path: Path) -> None: + plan = _run_plan(["R/ARMA.R"], tmp_path) + assert "src/nns/arma.py" in plan["affected_python_modules"] + assert "NNS.ARMA" in plan["cache_scope"] + assert plan["has_unmapped_r_files"] is False + assert plan["unmapped_r_files"] == [] + + +def test_unmapped_r_file_is_reported(tmp_path: Path) -> None: + plan = _run_plan(["R/NewFunction.R"], tmp_path) + assert plan["has_unmapped_r_files"] is True + assert "R/NewFunction.R" in plan["unmapped_r_files"] diff --git a/tests/tools/test_sync_manifests.py b/tests/tools/test_sync_manifests.py new file mode 100644 index 00000000..f0eefb2d --- /dev/null +++ b/tests/tools/test_sync_manifests.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[2] +MANIFEST = REPO_ROOT / "sync" / "nns_source.json" +API_MAP = REPO_ROOT / "sync" / "r_api_map.json" + +REQUIRED_MANIFEST_KEYS = { + "r_repo", + "r_commit", + "r_version", + "r_src_tree_hash", + "core_repo", + "core_commit", + "python_repo", + "python_commit", + "vendored_core_path", + "vendored_r_path", + "vendored_r_tarball", + "r_cache_path", + "notes", +} + +# Python module paths that are intentionally mapped but may not exist as a +# standalone module in this repository. Partial-moment primitives (LPM/UPM) +# live in src/nns/core.py and src/nns/var.py rather than a partial_moments.py. +ALLOWED_MISSING_MODULES = { + "src/nns/partial_moments.py", +} + + +def _load(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def test_manifest_has_required_keys() -> None: + manifest = _load(MANIFEST) + assert isinstance(manifest, dict) + missing = REQUIRED_MANIFEST_KEYS - set(manifest) + assert not missing, f"sync/nns_source.json missing keys: {sorted(missing)}" + assert manifest["r_repo"] == "OVVO-Financial/NNS" + assert manifest["core_repo"] == "OVVO-Financial/NNS-core" + assert manifest["python_repo"] == "OVVO-Financial/NNS-python" + + +def test_api_map_is_valid_json() -> None: + api_map = _load(API_MAP) + assert isinstance(api_map, dict) + assert api_map, "sync/r_api_map.json must not be empty" + + +def test_mapped_python_modules_exist_or_allowed_missing() -> None: + api_map = _load(API_MAP) + problems: list[str] = [] + for r_file, entry in api_map.items(): + for module in entry.get("python_modules", []): + if module in ALLOWED_MISSING_MODULES: + continue + if not (REPO_ROOT / module).exists(): + problems.append(f"{r_file} -> {module}") + assert not problems, f"mapped python_modules do not exist: {problems}" + + +def test_description_requires_fresh_cache() -> None: + api_map = _load(API_MAP) + assert api_map["DESCRIPTION"].get("requires_fresh_cache") is True + + +def test_namespace_requires_export_review() -> None: + api_map = _load(API_MAP) + assert api_map["NAMESPACE"].get("requires_export_review") is True