diff --git a/.aiwg/architecture/adr/ADR-001-jetson-runtime-support.md b/.aiwg/architecture/adr/ADR-001-jetson-runtime-support.md index 5060166..afa1cbc 100644 --- a/.aiwg/architecture/adr/ADR-001-jetson-runtime-support.md +++ b/.aiwg/architecture/adr/ADR-001-jetson-runtime-support.md @@ -5,6 +5,11 @@ Date: 2026-08-21 Issue: https://github.com/elder-plinius/OBLITERATUS/issues/31 Public plan: [docs/platforms/jetson.md](../../../docs/platforms/jetson.md) +Implementation status: generic ARM64 preflight, vendor-PyTorch-preserving +bootstrap, physical-device gate, sanitized evidence collector, manual runner +workflow, and contributor issue form are implemented. The ADR remains Proposed +until physical-device evidence fixes the initial supported matrix. + ## Context OBLITERATUS currently resolves CPU-only PyTorch for Linux through its default @@ -69,10 +74,12 @@ audited base-image and package provenance. 1. Inventory the available Jetson and record its exact JetPack/L4T stack. 2. Select and pin the initial Orin/JetPack 6.2.x matrix at an exact patch. 3. Add generic ARM64 package/import preflight without changing default PR - dependencies. -4. Add the Jetson constraints/profile and preferred container recipe. + dependencies. (Implemented.) +4. Add the Jetson constraints/profile and preferred container recipe. (The + native/container bootstrap boundary is implemented; an exact image remains + dependent on the selected hardware matrix.) 5. Add the `jetson-runtime` policy entry, test probe, trusted workflow job, and - retained evidence artifact. + retained evidence artifact. (Implemented.) 6. Validate CUDA discovery, device selection, a tiny CUDA operation, and the offloaded-surgery probe. Validate source-built bitsandbytes separately. 7. Claim support only for matrices with fresh green evidence on the exact diff --git a/.aiwg/testing/jetson-support-assessment.md b/.aiwg/testing/jetson-support-assessment.md index 71aef40..54233f4 100644 --- a/.aiwg/testing/jetson-support-assessment.md +++ b/.aiwg/testing/jetson-support-assessment.md @@ -260,6 +260,11 @@ the Jetson compatibility matrix must be version-pinned, not “latest by default ## Recommended implementation path +Repository status as of 2026-08-21: steps 2 through 4 are implemented with a +manual-only physical runner, a hosted ARM64 preflight, CPU-testable policy +contracts, and a sanitized contributor evidence flow. Physical Jetson results +are still required before completing steps 5 and 6 or publishing support. + 1. Keep issue #31 open as a feature/specification item until the acceptance matrix is merged. 2. Add a Jetson conditional policy entry and workflow job with an explicit diff --git a/.github/ISSUE_TEMPLATE/jetson-runtime.yml b/.github/ISSUE_TEMPLATE/jetson-runtime.yml new file mode 100644 index 0000000..eb5ea7e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/jetson-runtime.yml @@ -0,0 +1,77 @@ +name: Jetson runtime report +description: Report a physical Jetson bootstrap, build, or CUDA validation result +title: "[Jetson] " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Thank you for testing OBLITERATUS on physical Jetson hardware. Run the + documented `jetson-runtime` probe and attach its sanitized report. The + report intentionally omits host identity, network, serial, token, and + local-path data. + - type: dropdown + id: device + attributes: + label: Jetson device + options: + - Jetson AGX Orin + - Jetson Orin NX + - Jetson Orin Nano + - Jetson AGX Xavier + - Jetson AGX Thor + validations: + required: true + - type: input + id: jetpack + attributes: + label: Exact JetPack version + description: Include the patch version, for example 6.2.1. + validations: + required: true + - type: input + id: commit + attributes: + label: Exact OBLITERATUS commit + description: Paste the 40-character commit SHA that was tested. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction commands + description: Provide the smallest command sequence that demonstrates the result. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected result + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual result + description: Include the complete error text, but remove any secrets before submitting. + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Sanitized Jetson evidence + description: Attach conditional-evidence/jetson-report.json or paste its JSON contents. + validations: + required: true + - type: checkboxes + id: confirmations + attributes: + label: Confirmations + options: + - label: I ran this validation on physical Jetson hardware. + required: true + - label: I reviewed the report and removed any secrets or personal identifiers. + required: true + validations: + required: true diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index a85f6a0..22ac661 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -1,6 +1,6 @@ self-hosted-runner: # Project-owned capability labels used by conditional test runners. - labels: [cuda, mps, mlx] + labels: [cuda, jetson, mps, mlx] # Configuration variables in array of strings defined in your repository or # organization. `null` means disabling configuration variables check. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e712d58..3354cbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,6 +225,45 @@ jobs: scripts/check_supply_chain_policy.py scripts/gemma4_12b_recursive_loop.py || true + arm64-preflight: + name: Linux ARM64 preflight + if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') + runs-on: ubuntu-24.04-arm + timeout-minutes: 10 + env: + CUDA_VISIBLE_DEVICES: "" + HF_DATASETS_OFFLINE: "1" + HF_HUB_DISABLE_TELEMETRY: "1" + HF_HUB_OFFLINE: "1" + TRANSFORMERS_OFFLINE: "1" + TEST_ENV: /tmp/obliteratus-arm64-preflight + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: | + pyproject.toml + uv.lock + - name: Install locked CPU runtime + run: | + python -m pip install "uv==${UV_VERSION}" + UV_PROJECT_ENVIRONMENT="$TEST_ENV" \ + uv sync --locked --no-default-groups --extra dev --no-editable + - name: Verify ARM64 package and core runtime + run: | + "$TEST_ENV/bin/python" -c \ + 'import platform; assert platform.machine().lower() in {"aarch64", "arm64"}' + "$TEST_ENV/bin/python" -m build --wheel + "$TEST_ENV/bin/python" -c 'import obliteratus; print(obliteratus.__version__)' + "$TEST_ENV/bin/python" -m obliteratus --help + "$TEST_ENV/bin/python" -m pytest \ + tests/test_module_imports.py tests/test_device_boundaries.py \ + tests/test_jetson_support_tooling.py -q --no-cov + pr-core: name: Pull request core if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') diff --git a/.github/workflows/conditional-tests.yml b/.github/workflows/conditional-tests.yml index 16442b8..c23f020 100644 --- a/.github/workflows/conditional-tests.yml +++ b/.github/workflows/conditional-tests.yml @@ -23,6 +23,10 @@ on: description: Run CUDA and bitsandbytes on the labeled self-hosted runner type: boolean default: false + run_jetson: + description: Run Jetson CUDA on the trusted labeled physical runner + type: boolean + default: false run_mps: description: Run MPS on the labeled Apple Silicon runner type: boolean @@ -239,7 +243,7 @@ jobs: run: | python -m pip install "uv==${UV_VERSION}" UV_PROJECT_ENVIRONMENT="$CONDITIONAL_ENV" \ - uv sync --locked --no-default-groups --extra dev --no-editable + uv sync --locked --no-default-groups --extra dev --extra quantization --no-editable CUDA_TORCH_VERSION="$("$CONDITIONAL_ENV/bin/python" -c \ 'import torch; print(torch.__version__.split("+", 1)[0])')" UV_TORCH_BACKEND=cu130 uv pip install \ @@ -264,6 +268,52 @@ jobs: if-no-files-found: error retention-days: 30 + jetson: + name: NVIDIA Jetson runtime + needs: policy + if: github.event_name == 'workflow_dispatch' && inputs.run_jetson + runs-on: [self-hosted, linux, ARM64, jetson] + timeout-minutes: 45 + env: + JETSON_ENV: /tmp/obliteratus-jetson-${{ github.run_id }}-${{ github.run_attempt }} + JETSON_TOOLS: /tmp/obliteratus-jetson-tools-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - name: Check out trusted candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install pinned bootstrap tooling outside the JetPack runtime + run: | + python3 -m venv "$JETSON_TOOLS" + "$JETSON_TOOLS/bin/python" -m pip install "uv==${UV_VERSION}" + - name: Preserve JetPack PyTorch and install OBLITERATUS + run: >- + python3 scripts/setup_jetson.py + --python python3 + --uv-python "$JETSON_TOOLS/bin/python" + --venv "$JETSON_ENV" + - name: Run physical Jetson CUDA probe + run: >- + "$JETSON_ENV/bin/python" scripts/run_conditional_gate.py jetson-runtime + - name: Collect sanitized Jetson evidence + if: always() + run: | + JETSON_PYTHON=python3 + if [ -x "$JETSON_ENV/bin/python" ]; then + JETSON_PYTHON="$JETSON_ENV/bin/python" + fi + "$JETSON_PYTHON" scripts/jetson_support.py \ + --check \ + --gate-evidence conditional-evidence/jetson-runtime.json \ + --output conditional-evidence/jetson-report.json \ + --issue-body conditional-evidence/jetson-issue.md + - name: Upload Jetson evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: conditional-jetson-${{ github.run_attempt }} + path: conditional-evidence/ + if-no-files-found: error + retention-days: 30 + mps: name: Apple MPS runtime needs: policy @@ -393,20 +443,22 @@ jobs: summary: name: Conditional result and freshness summary if: always() - needs: [policy, model_runtime, network_services, operator_ui, cuda, mps, mlx, remote] + needs: [policy, model_runtime, network_services, operator_ui, cuda, jetson, mps, mlx, remote] runs-on: ubuntu-latest timeout-minutes: 5 env: CONDITIONAL_RESULTS: >- {"policy":"${{ needs.policy.result }}","model_runtime":"${{ needs.model_runtime.result }}", "network_services":"${{ needs.network_services.result }}","operator_ui":"${{ needs.operator_ui.result }}", - "cuda":"${{ needs.cuda.result }}","mps":"${{ needs.mps.result }}","mlx":"${{ needs.mlx.result }}", + "cuda":"${{ needs.cuda.result }}","jetson":"${{ needs.jetson.result }}", + "mps":"${{ needs.mps.result }}","mlx":"${{ needs.mlx.result }}", "remote":"${{ needs.remote.result }}"} CONDITIONAL_SELECTED: >- {"model_runtime":${{ github.event_name != 'workflow_dispatch' || inputs.run_model }}, "network_services":${{ github.event_name != 'workflow_dispatch' || inputs.run_network }}, "operator_ui":${{ github.event_name != 'workflow_dispatch' || inputs.run_ui }}, "cuda":${{ (github.event_name == 'workflow_dispatch' && inputs.run_cuda) || (github.event_name != 'workflow_dispatch' && vars.ENABLE_CUDA_GATE == 'true') }}, + "jetson":${{ github.event_name == 'workflow_dispatch' && inputs.run_jetson }}, "mps":${{ (github.event_name == 'workflow_dispatch' && inputs.run_mps) || (github.event_name != 'workflow_dispatch' && vars.ENABLE_MPS_GATE == 'true') }}, "mlx":${{ (github.event_name == 'workflow_dispatch' && inputs.run_mlx) || (github.event_name != 'workflow_dispatch' && vars.ENABLE_MLX_GATE == 'true') }}, "remote":${{ (github.event_name == 'workflow_dispatch' && inputs.run_remote) || (github.event_name != 'workflow_dispatch' && vars.ENABLE_REMOTE_GATE == 'true') }}} diff --git a/.gitignore b/.gitignore index dc750a1..db7a058 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build/ .eggs/ *.egg .venv/ +.venv-jetson/ venv/ env/ .env diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03612c1..d1a1d2c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -206,8 +206,8 @@ them in the managed environment. ## Conditional and hardware testing -GPU, MPS, MLX, model-download, external-evaluation, network, operator-UI, and -remote-execution checks are conditional release or risk-surface gates. A unit test +GPU, Jetson, MPS, MLX, model-download, external-evaluation, network, operator-UI, +and remote-execution checks are conditional release or risk-surface gates. A unit test with a mocked device is still required; hardware evidence complements deterministic contract coverage and never replaces it. @@ -222,6 +222,13 @@ Current operator hardware includes Titan for CUDA/bitsandbytes probes and Mutsu, machines are attached to a public pull-request workflow; CI or a maintainer will record whether the mapped conditional gate ran. +Jetson contributors do not need project-owned hardware access. Follow the +[Jetson contributor bootstrap](docs/platforms/jetson.md#experimental-contributor-bootstrap) +on a physical device, then submit the generated sanitized evidence through the +[Jetson runtime report](https://github.com/elder-plinius/OBLITERATUS/issues/new?template=jetson-runtime.yml). Maintainers +will reproduce, add missing test depth, and integrate compatible changes. Never +attach a contributor-controlled runner to untrusted pull-request execution. + ## Security and supply-chain expectations - Treat issue text, pull requests, patches, model repositories, checkpoints, diff --git a/README.md b/README.md index 9743dcd..84d20c5 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,11 @@ obliteratus ui --auth user:pass # add basic auth The `obliteratus ui` command adds a Rich terminal startup with GPU detection and hardware-appropriate model recommendations. You can also run `python app.py` directly (same thing the Space uses). +Install `.[spaces,quantization]` instead when the UI must load supported +bitsandbytes 8-bit or 4-bit models. Jetson users must follow the dedicated +[Jetson bootstrap](docs/platforms/jetson.md); its bitsandbytes path is not yet +supported. + ### 3. Google Colab (free GPU) [![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/elder-plinius/OBLITERATUS/blob/main/notebooks/abliterate.ipynb) @@ -457,6 +462,12 @@ This sets `CUDA_VISIBLE_DEVICES` before CUDA initializes. The model is then shar The `--dtype` flag controls the precision of model weights, which directly determines how much VRAM you need. Lower precision means smaller memory footprint at the cost of some numerical fidelity: +Install the optional backend before selecting a bitsandbytes mode: + +```bash +pip install -e ".[quantization]" +``` + | Dtype | Bytes/param | 7B model | 70B model | 405B model | |-------|-----------|---------|----------|-----------| | `float32` | 4 | 28 GB | 280 GB | 1620 GB | @@ -783,9 +794,10 @@ run the full Python matrix with 75% repository statement coverage, 60% repositor branch coverage, touched-module regression checks, mature-scope floors, deterministic property/order-repeat checks, selective mutation, package contracts, Windows portability, and supply-chain certification. -Eight environment-bound test files run through the separately documented conditional -workflow for model downloads, network services, operator UI, CUDA, bitsandbytes, MPS, -MLX, and least-privileged remote execution. +Nine environment-bound test files run through the separately documented conditional +workflow for model downloads, network services, operator UI, CUDA, bitsandbytes, +physical Jetson CUDA, MPS, MLX, and least-privileged remote execution. The Jetson +lane is a trusted manual hardware probe; hosted ARM64 CI checks portability only. ## License diff --git a/ci/conditional-test-policy.json b/ci/conditional-test-policy.json index 0394c84..b1ab181 100644 --- a/ci/conditional-test-policy.json +++ b/ci/conditional-test-policy.json @@ -154,6 +154,19 @@ "expected_cost": "included in cuda", "coverage_paths": ["obliteratus/models/loader.py"] }, + { + "id": "jetson-runtime", + "job": "jetson", + "marker": "gpu", + "runner": "self-hosted, linux, ARM64, jetson", + "prerequisites": "trusted manual dispatch on physical Jetson hardware with a JetPack-aligned CUDA PyTorch runtime", + "expected_cost": "under 30 self-hosted runner-minutes", + "coverage_paths": [ + "obliteratus/device.py", + "obliteratus/models/loader.py", + "obliteratus/models/offload_surgery.py" + ] + }, { "id": "mps-runtime", "job": "mps", diff --git a/ci/pr-test-policy.json b/ci/pr-test-policy.json index 95cfeb9..7077141 100644 --- a/ci/pr-test-policy.json +++ b/ci/pr-test-policy.json @@ -13,13 +13,17 @@ ".github/workflows/**", "ci/**", "scripts/check_*.py", + "scripts/jetson_*.py", "scripts/select_pr_tests.py", + "scripts/setup_jetson.py", "pyproject.toml", "uv.lock" ], "infrastructure_tests": [ "tests/test_aiwg_workspace_contracts.py", "tests/test_ci_policy.py", + "tests/test_conditional_gate_scripts.py", + "tests/test_jetson_support_tooling.py", "tests/test_pr_test_selection.py", "tests/test_quality_policy.py", "tests/test_quality_gate_scripts.py", diff --git a/ci/test-risk-map.json b/ci/test-risk-map.json index 289ef64..de745b7 100644 --- a/ci/test-risk-map.json +++ b/ci/test-risk-map.json @@ -78,7 +78,8 @@ "tests/test_offload_surgery.py", "tests/test_persistence_contracts.py", "tests/test_persistence_pipeline.py", - "tests/conditional/test_cuda_runtime.py" + "tests/conditional/test_cuda_runtime.py", + "tests/conditional/test_jetson_runtime.py" ] }, { @@ -134,7 +135,8 @@ "tests/test_model_profile.py", "tests/test_model_profile_contracts.py", "tests/test_runtime_contracts.py", - "tests/test_study_presets.py" + "tests/test_study_presets.py", + "tests/conditional/test_jetson_runtime.py" ] }, { @@ -442,6 +444,7 @@ ], "conditional_gates": [ "cuda-runtime", + "jetson-runtime", "mps-runtime" ] }, @@ -466,6 +469,7 @@ "conditional_gates": [ "model-download-runtime", "cuda-runtime", + "jetson-runtime", "bitsandbytes-runtime" ] }, @@ -489,7 +493,8 @@ "tests/conditional/test_cuda_runtime.py" ], "conditional_gates": [ - "cuda-runtime" + "cuda-runtime", + "jetson-runtime" ] }, { diff --git a/docs/conditional-testing.md b/docs/conditional-testing.md index 655e3da..07b4d61 100644 --- a/docs/conditional-testing.md +++ b/docs/conditional-testing.md @@ -70,21 +70,28 @@ probes. For an operator run on the labeled machine: ```bash -uv sync --locked --extra dev +uv sync --locked --extra dev --extra quantization CUDA_TORCH_VERSION="$(.venv/bin/python -c \ 'import torch; print(torch.__version__.split("+", 1)[0])')" UV_TORCH_BACKEND=cu130 uv pip install --python .venv/bin/python \ --reinstall-package torch "torch==$CUDA_TORCH_VERSION" uv pip check --python .venv/bin/python -uv run --extra dev python scripts/run_conditional_gate.py cuda-runtime -uv run --extra dev python scripts/run_conditional_gate.py bitsandbytes-runtime +uv run --extra dev --extra quantization python scripts/run_conditional_gate.py cuda-runtime +uv run --extra dev --extra quantization python scripts/run_conditional_gate.py bitsandbytes-runtime ``` -Jetson CUDA support is tracked separately from this generic x64 CUDA lane. A -generic Linux ARM build can prove package portability, but it does not prove -Jetson GPU support because Jetson depends on a JetPack/L4T-matched CUDA, cuDNN, -and PyTorch runtime. The support plan, recommended container path, and acceptance -criteria are documented in [NVIDIA Jetson support plan](platforms/jetson.md). +Jetson CUDA support is tracked separately from this generic x64 CUDA lane. The +mandatory `Linux ARM64 preflight` uses GitHub's hosted `ubuntu-24.04-arm` runner +to prove locked CPU packaging, imports, CLI startup, and Jetson tooling contracts. +It is not GPU evidence. Jetson depends on a JetPack/L4T-matched CUDA, cuDNN, and +PyTorch runtime. + +Physical testing uses only a trusted manual dispatch on the labels `self-hosted`, +`linux`, `ARM64`, and `jetson`. The job preserves NVIDIA's vendor PyTorch, runs +`jetson-runtime`, and uploads the sanitized `conditional-jetson-` +artifact for 30 days. It never runs for a pull request, schedule, or release. +Contributor bootstrap, runner isolation, reporting commands, and acceptance +criteria are documented in the [NVIDIA Jetson support plan](platforms/jetson.md). ## Apple MPS and MLX diff --git a/docs/platforms/jetson.md b/docs/platforms/jetson.md index 4be9af7..2120546 100644 --- a/docs/platforms/jetson.md +++ b/docs/platforms/jetson.md @@ -79,6 +79,41 @@ from being selected: upstream documents that wheel as SBSA/server ARM and says Jetson L4T/JetPack requires a source build. Until a pinned source build passes on the selected device, bitsandbytes is unsupported for that tier. +### Experimental contributor bootstrap + +Start with NVIDIA's PyTorch wheel or PyTorch iGPU container for the exact +JetPack patch installed on the device. Confirm that `python3 -c 'import torch; +assert torch.cuda.is_available()'` succeeds before installing OBLITERATUS. Then, +from a checkout of the exact commit under test, run: + +```bash +python3 -m venv .venv-jetson-tools +.venv-jetson-tools/bin/python -m pip install "uv==0.12.4" +.venv-jetson-tools/bin/python scripts/setup_jetson.py \ + --python python3 \ + --uv-python .venv-jetson-tools/bin/python \ + --venv .venv-jetson +.venv-jetson/bin/python scripts/run_conditional_gate.py jetson-runtime +.venv-jetson/bin/python scripts/jetson_support.py \ + --check \ + --gate-evidence conditional-evidence/jetson-runtime.json \ + --output conditional-evidence/jetson-report.json \ + --issue-body conditional-evidence/jetson-issue.md +``` + +The bootstrap validates ARM64, L4T, and CUDA before changing the environment. +It creates a virtual environment with `--system-site-packages`, exports the +committed lock, and installs locked OBLITERATUS dependencies without replacing +the vendor `torch`. It also excludes bitsandbytes. The generic bitsandbytes +package is now an explicit `quantization` extra for supported non-Jetson +environments; Jetson quantization remains a separate source-build milestone. + +For JetPack 6.2, NVIDIA publishes the +`nvcr.io/nvidia/pytorch:25.06-py3-igpu` container. Run it only on Jetson hardware +with the NVIDIA runtime, mount a reviewed checkout, and use the same bootstrap +inside the container. Match other JetPack releases through NVIDIA's +compatibility table rather than substituting a `latest` tag. + ## Conditional gate Add a new gate instead of modifying the x64 CUDA gate: @@ -93,6 +128,35 @@ Add a new gate instead of modifying the x64 CUDA gate: The job must run only from a trusted ref or reviewed maintainer dispatch. A persistent self-hosted Jetson must never execute untrusted pull-request code. +### Attaching a contributor-owned runner + +Register the runner using GitHub's self-hosted runner instructions, on the +Jetson itself, and add the custom label `jetson`. GitHub supplies the +`self-hosted`, `linux`, and `ARM64` default labels. Verify that the repository +shows exactly these required labels before dispatching the job: + +```text +self-hosted, linux, ARM64, jetson +``` + +Use a dedicated, non-personal runner account and a disposable or resettable +workspace. Do not place Hugging Face, SSH, cloud, or signing credentials on the +runner. Only a maintainer should manually dispatch `Conditional tests` against +a reviewed commit; the Jetson job is deliberately unavailable to pull-request, +scheduled, and release triggers. Remove the runner registration token after +setup and keep the runner offline when it is not being used for reviewed work. + +### Reporting results without a project-owned Jetson + +Open the [Jetson runtime report](https://github.com/elder-plinius/OBLITERATUS/issues/new?template=jetson-runtime.yml) +issue form and attach `conditional-evidence/jetson-report.json`, or paste the +generated `conditional-evidence/jetson-issue.md`. The collector reports only an +allow-listed architecture, OS/JetPack, PyTorch/CUDA, device-class, test-result, +and commit profile. It excludes environment variables, hostnames, usernames, +network addresses, device serials, tokens, and local filesystem paths. Review +the file yourself before publishing it. A failed report is useful evidence and +does not imply that the contributor must diagnose the compatibility problem. + The gate should verify: - `platform.machine()` is `aarch64` or equivalent ARM64. @@ -146,3 +210,6 @@ them. - [Astral: Using uv with PyTorch](https://docs.astral.sh/uv/guides/integration/pytorch/) - [Hugging Face: bitsandbytes installation guide](https://huggingface.co/docs/bitsandbytes/installation) - [GitHub: secure use of self-hosted runners](https://docs.github.com/en/actions/reference/security/secure-use#hardening-for-self-hosted-runners) +- [GitHub: use self-hosted runner labels](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/use-in-a-workflow) +- [GitHub: hosted ARM64 runners](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) +- [NVIDIA: PyTorch 25.06 for JetPack 6.2](https://docs.nvidia.com/deeplearning/frameworks/pytorch-release-notes/rel-25-06.html) diff --git a/pyproject.toml b/pyproject.toml index c044473..1bd7a82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,6 @@ dependencies = [ "numpy>=1.24", "scikit-learn>=1.3", "tqdm>=4.64", - "bitsandbytes>=0.46.1", ] [project.urls] @@ -51,6 +50,7 @@ dev = [ "pytest-cov==7.1.0", "ruff==0.16.2", ] +quantization = ["bitsandbytes>=0.46.1"] spaces = ["gradio>=6.7,<7.0"] [dependency-groups] diff --git a/scripts/jetson_support.py b/scripts/jetson_support.py new file mode 100644 index 0000000..6ce37a4 --- /dev/null +++ b/scripts/jetson_support.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Collect privacy-safe Jetson runtime evidence for CI and issue reports.""" + +from __future__ import annotations + +import argparse +import importlib.metadata +import json +import os +import platform +import re +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Sequence + + +ISSUE_URL = "https://github.com/elder-plinius/OBLITERATUS/issues/31" +JETSON_RELEASE = Path("/etc/nv_tegra_release") +OS_RELEASE = Path("/etc/os-release") +SHA = re.compile(r"^[0-9a-f]{40}$") + + +def _read_first_line(path: Path, *, limit: int = 500) -> str | None: + try: + return path.read_text(encoding="utf-8", errors="replace").splitlines()[0][:limit] + except (OSError, IndexError): + return None + + +def _os_release(path: Path) -> dict[str, str]: + try: + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return {} + values: dict[str, str] = {} + for line in lines: + key, separator, value = line.partition("=") + if separator and key in {"ID", "VERSION_ID", "PRETTY_NAME"}: + values[key.lower()] = value.strip().strip('"')[:200] + return values + + +def _capture(command: Sequence[str], *, timeout: int = 10) -> str | None: + try: + result = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + value = result.stdout.strip() + return value[:500] or None + + +def _candidate_sha() -> str: + candidate = os.environ.get("GITHUB_SHA", "") + if SHA.fullmatch(candidate): + return candidate + local = _capture(["git", "rev-parse", "HEAD"]) + return local if local is not None and SHA.fullmatch(local) else "local" + + +def collect_host_facts( + *, + tegra_release: Path = JETSON_RELEASE, + os_release: Path = OS_RELEASE, +) -> dict[str, object]: + """Return an allow-listed host profile without identity or network data.""" + + return { + "architecture": platform.machine(), + "python_version": platform.python_version(), + "os": _os_release(os_release), + "l4t_release": _read_first_line(tegra_release), + "jetpack_package": _capture( + ["dpkg-query", "-W", "-f=${Version}", "nvidia-jetpack"], + ), + } + + +def collect_runtime_facts() -> dict[str, object]: + """Return an allow-listed PyTorch/GPU profile without serials or file paths.""" + + facts: dict[str, object] = { + "torch_imported": False, + "torch_version": None, + "torch_cuda_version": None, + "cuda_available": False, + "cuda_device_count": 0, + "device_name": None, + "compute_capability": None, + "total_memory_gb": None, + "bitsandbytes_version": None, + } + try: + import torch + except Exception as exc: # pragma: no cover - exact vendor loader failures vary + facts["torch_import_error"] = type(exc).__name__ + return facts + + facts.update({ + "torch_imported": True, + "torch_version": str(torch.__version__), + "torch_cuda_version": torch.version.cuda, + "cuda_available": bool(torch.cuda.is_available()), + "cuda_device_count": int(torch.cuda.device_count()), + }) + if facts["cuda_available"] and facts["cuda_device_count"]: + properties = torch.cuda.get_device_properties(0) + facts.update({ + "device_name": str(properties.name)[:200], + "compute_capability": list(torch.cuda.get_device_capability(0)), + "total_memory_gb": round(properties.total_memory / 1024 ** 3, 2), + }) + try: + facts["bitsandbytes_version"] = importlib.metadata.version("bitsandbytes") + except importlib.metadata.PackageNotFoundError: + pass + return facts + + +def validate_report(report: dict[str, object]) -> tuple[list[str], list[str]]: + """Return blocking errors and non-blocking compatibility warnings.""" + + host = report["host"] + runtime = report["runtime"] + assert isinstance(host, dict) + assert isinstance(runtime, dict) + errors: list[str] = [] + warnings: list[str] = [] + if str(host.get("architecture", "")).lower() not in {"aarch64", "arm64"}: + errors.append("host architecture is not ARM64") + if not host.get("l4t_release"): + errors.append("/etc/nv_tegra_release is unavailable; this is not a Jetson L4T runtime") + if not runtime.get("torch_imported"): + errors.append("PyTorch could not be imported from the JetPack-aligned runtime") + elif not runtime.get("torch_cuda_version"): + errors.append("PyTorch is not a CUDA build") + elif not runtime.get("cuda_available"): + errors.append("PyTorch cannot access the Jetson CUDA device") + if runtime.get("bitsandbytes_version"): + warnings.append( + "bitsandbytes is installed but remains unsupported until its pinned Jetson " + "source build passes the separate quantization probe", + ) + return errors, warnings + + +def _gate_summary(path: Path | None) -> dict[str, object] | None: + if path is None: + return None + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"status": "unavailable"} + if not isinstance(value, dict): + return {"status": "invalid"} + summary: dict[str, object] = {} + for key in ("gate", "status"): + if isinstance(value.get(key), str): + summary[key] = value[key][:100] + git_sha = value.get("git_sha") + if isinstance(git_sha, str) and (SHA.fullmatch(git_sha) or git_sha == "local"): + summary["git_sha"] = git_sha + counts = value.get("counts") + if isinstance(counts, dict): + summary["counts"] = { + key: counts[key] + for key in ("tests", "failures", "errors", "skipped") + if isinstance(counts.get(key), int) and counts[key] >= 0 + } + return summary + + +def build_report(*, gate_evidence: Path | None = None) -> dict[str, object]: + report: dict[str, object] = { + "schema_version": 1, + "generated_at": datetime.now(timezone.utc).isoformat(), + "issue": ISSUE_URL, + "git_sha": _candidate_sha(), + "host": collect_host_facts(), + "runtime": collect_runtime_facts(), + } + errors, warnings = validate_report(report) + report["validation"] = {"errors": errors, "warnings": warnings} + gate = _gate_summary(gate_evidence) + if gate is not None: + report["gate_evidence"] = gate + return report + + +def issue_body(report: dict[str, object]) -> str: + return "\n".join([ + "## Jetson runtime report", + "", + "### What happened", + "", + "", + "### Reproduction", + "", + "", + "### Sanitized environment evidence", + "", + "```json", + json.dumps(report, indent=2, sort_keys=True), + "```", + "", + "This report intentionally excludes environment variables, hostnames, usernames,", + "network addresses, GPU serials, tokens, and local filesystem paths.", + "", + ]) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", + type=Path, + default=Path("conditional-evidence/jetson-report.json"), + ) + parser.add_argument("--gate-evidence", type=Path) + parser.add_argument("--issue-body", type=Path) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + + report = build_report(gate_evidence=args.gate_evidence) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + if args.issue_body is not None: + args.issue_body.parent.mkdir(parents=True, exist_ok=True) + args.issue_body.write_text(issue_body(report), encoding="utf-8") + validation = report["validation"] + assert isinstance(validation, dict) + errors = validation["errors"] + warnings = validation["warnings"] + assert isinstance(errors, list) + assert isinstance(warnings, list) + for warning in warnings: + print(f"WARNING: {warning}", file=sys.stderr) + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + print(args.output) + return 2 if args.check and errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_conditional_gate.py b/scripts/run_conditional_gate.py index 1e78ac9..6a73e53 100644 --- a/scripts/run_conditional_gate.py +++ b/scripts/run_conditional_gate.py @@ -7,6 +7,7 @@ import argparse import importlib.util import json import os +import platform import subprocess import sys from datetime import datetime, timezone @@ -21,24 +22,31 @@ GATES = { "operator-ui": "tests/conditional/test_operator_ui.py", "cuda-runtime": "tests/conditional/test_cuda_runtime.py", "bitsandbytes-runtime": "tests/conditional/test_cuda_runtime.py", + "jetson-runtime": "tests/conditional/test_jetson_runtime.py", "mps-runtime": "tests/conditional/test_mps_runtime.py", "mlx-runtime": "tests/conditional/test_mlx_runtime.py", "remote-execution": "tests/conditional/test_remote_runtime.py", } +JETSON_RELEASE = Path("/etc/nv_tegra_release") def missing_prerequisites(gate: str) -> list[str]: missing: list[str] = [] - if gate in {"cuda-runtime", "bitsandbytes-runtime", "mps-runtime"}: + if gate in {"cuda-runtime", "bitsandbytes-runtime", "jetson-runtime", "mps-runtime"}: import torch - if gate.startswith("cuda") or gate.startswith("bitsandbytes"): + if gate.startswith(("cuda", "bitsandbytes", "jetson")): if not torch.cuda.is_available(): missing.append("a CUDA-capable PyTorch runtime") elif not (hasattr(torch.backends, "mps") and torch.backends.mps.is_available()): missing.append("an available Apple MPS backend") if gate == "bitsandbytes-runtime" and importlib.util.find_spec("bitsandbytes") is None: missing.append("bitsandbytes") + if gate == "jetson-runtime": + if platform.machine().lower() not in {"aarch64", "arm64"}: + missing.append("an ARM64 host") + if not JETSON_RELEASE.is_file(): + missing.append("a Jetson L4T runtime") if gate == "mlx-runtime": for module in ("mlx", "mlx_lm"): if importlib.util.find_spec(module) is None: diff --git a/scripts/setup_jetson.py b/scripts/setup_jetson.py new file mode 100644 index 0000000..68ba6de --- /dev/null +++ b/scripts/setup_jetson.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Create an OBLITERATUS venv without replacing JetPack's PyTorch runtime.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Sequence + + +EXCLUDED_PACKAGES = {"bitsandbytes", "torch"} +NORMALIZE = re.compile(r"[-_.]+") + + +def _run(command: Sequence[str], *, cwd: Path) -> None: + print("+ " + " ".join(command)) + subprocess.run(command, cwd=cwd, check=True) + + +def _require_new_or_reusable_venv(venv: Path, *, reuse: bool) -> None: + if not venv.exists(): + return + if not reuse: + raise ValueError(f"virtual environment already exists: {venv}; pass --reuse to use it") + config = venv / "pyvenv.cfg" + try: + contents = config.read_text(encoding="utf-8").lower() + except OSError as exc: + raise ValueError(f"existing path is not a reusable virtual environment: {venv}") from exc + if "include-system-site-packages = true" not in contents: + raise ValueError(f"existing virtual environment does not expose JetPack packages: {venv}") + + +def _require_safe_target(venv: Path, project: Path) -> None: + resolved = venv.resolve() + forbidden = {Path("/").resolve(), Path.home().resolve(), project.resolve()} + if resolved in forbidden: + raise ValueError(f"refusing unsafe virtual environment target: {resolved}") + + +def _require_exclusions(requirements: Path) -> None: + emitted: set[str] = set() + for raw_line in requirements.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith(("#", "--")): + continue + name = re.split(r"[<>=!~;@\[]", line, maxsplit=1)[0].strip() + emitted.add(NORMALIZE.sub("-", name).lower()) + unexpected = sorted(EXCLUDED_PACKAGES & emitted) + if unexpected: + raise RuntimeError(f"Jetson export contains forbidden packages: {unexpected}") + + +def prepare( + *, + project: Path, + venv: Path, + python: str, + uv_python: str, + reuse: bool, +) -> None: + project = project.resolve() + support_script = project / "scripts" / "jetson_support.py" + if not support_script.is_file() or not (project / "uv.lock").is_file(): + raise ValueError(f"not an OBLITERATUS checkout: {project}") + _require_safe_target(venv, project) + _require_new_or_reusable_venv(venv, reuse=reuse) + + with tempfile.TemporaryDirectory(prefix="obliteratus-jetson-") as temp_value: + temp = Path(temp_value) + _run( + [python, str(support_script), "--check", "--output", str(temp / "host.json")], + cwd=project, + ) + _run([uv_python, "-m", "uv", "--version"], cwd=project) + if not venv.exists(): + _run([python, "-m", "venv", "--system-site-packages", str(venv)], cwd=project) + + target_python = venv / "bin" / "python" + _run( + [ + str(target_python), + str(support_script), + "--check", + "--output", + str(temp / "venv.json"), + ], + cwd=project, + ) + requirements = temp / "requirements-jetson.txt" + _run( + [ + uv_python, + "-m", + "uv", + "export", + "--locked", + "--no-default-groups", + "--extra", + "dev", + "--no-emit-project", + "--no-emit-package", + "torch", + "--no-emit-package", + "bitsandbytes", + "--no-annotate", + "--no-header", + "--no-hashes", + "--output-file", + str(requirements), + ], + cwd=project, + ) + _require_exclusions(requirements) + _run( + [ + uv_python, + "-m", + "uv", + "pip", + "install", + "--python", + str(target_python), + "--no-deps", + "--requirements", + str(requirements), + ], + cwd=project, + ) + _run( + [ + uv_python, + "-m", + "uv", + "pip", + "install", + "--python", + str(target_python), + "--no-deps", + "--editable", + str(project), + ], + cwd=project, + ) + _run( + [uv_python, "-m", "uv", "pip", "check", "--python", str(target_python)], + cwd=project, + ) + + print("Jetson environment prepared without replacing vendor PyTorch.") + print(f"Run: {target_python} scripts/run_conditional_gate.py jetson-runtime") + print( + f"Then: {target_python} scripts/jetson_support.py --check " + "--gate-evidence conditional-evidence/jetson-runtime.json " + "--issue-body conditional-evidence/jetson-issue.md", + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--project", type=Path, default=Path(__file__).parents[1]) + parser.add_argument("--venv", type=Path, default=Path(".venv-jetson")) + parser.add_argument("--python", default=sys.executable) + parser.add_argument( + "--uv-python", + default=sys.executable, + help="interpreter containing the pinned uv module (defaults to this interpreter)", + ) + parser.add_argument("--reuse", action="store_true") + args = parser.parse_args() + try: + prepare( + project=args.project, + venv=args.venv, + python=args.python, + uv_python=args.uv_python, + reuse=args.reuse, + ) + except (OSError, RuntimeError, subprocess.CalledProcessError, ValueError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/conditional/test_jetson_runtime.py b/tests/conditional/test_jetson_runtime.py new file mode 100644 index 0000000..22d3d34 --- /dev/null +++ b/tests/conditional/test_jetson_runtime.py @@ -0,0 +1,53 @@ +"""Physical NVIDIA Jetson CUDA placement and operation probe.""" + +from __future__ import annotations + +import platform +from pathlib import Path + +import pytest +import torch +import torch.nn as nn +from accelerate.hooks import AlignDevicesHook, add_hook_to_module + +from obliteratus import device +from obliteratus.abliterate import AbliterationPipeline + + +pytestmark = pytest.mark.gpu + + +def test_jetson_cuda_runtime_contract(): + assert platform.machine().lower() in {"aarch64", "arm64"} + assert Path("/etc/nv_tegra_release").is_file() + assert torch.version.cuda is not None + assert torch.cuda.is_available() + assert torch.cuda.device_count() > 0 + assert device.is_cuda() + assert device.get_device("auto") == "cuda" + tensor = torch.arange(16, device="cuda", dtype=torch.float32).reshape(4, 4) + result = tensor @ tensor.T + assert result.device.type == "cuda" + assert torch.isfinite(result).all() + + +def test_jetson_cuda_offloaded_surgery_contract(): + module = nn.Module() + module.proj = nn.Linear(4, 4, bias=False) + original = module.proj.weight.detach().clone() + hook = AlignDevicesHook(execution_device="cuda", offload=True) + add_hook_to_module(module.proj, hook) + + count = AbliterationPipeline._project_out_advanced( + module, + torch.tensor([[1.0], [0.0], [0.0], [0.0]], device="cuda"), + ["proj"], + ) + output = module.proj(torch.ones(1, 4, device="cuda")) + + expected = original.clone() + expected[:, 0] = 0 + assert count == 1 + assert output.device.type == "cuda" + assert module.proj.weight.device.type == "meta" + torch.testing.assert_close(hook.weights_map["weight"], expected) diff --git a/tests/test_ci_policy.py b/tests/test_ci_policy.py index 576b005..aa3adfc 100644 --- a/tests/test_ci_policy.py +++ b/tests/test_ci_policy.py @@ -129,6 +129,24 @@ def test_pull_request_gate_is_fast_risk_mapped_and_uses_shared_floor(): assert "tests/conditional/" in policy["excluded_test_prefixes"] +def test_arm64_preflight_proves_portability_without_claiming_jetson_cuda(): + workflow = WORKFLOW.read_text(encoding="utf-8") + arm = workflow.split(" arm64-preflight:\n", maxsplit=1)[1].split( + " pr-core:\n", + maxsplit=1, + )[0] + + assert "runs-on: ubuntu-24.04-arm" in arm + assert "github.event_name == 'pull_request'" in arm + assert "github.event_name == 'push' && github.ref == 'refs/heads/main'" in arm + assert "-m build --wheel" in arm + assert "import obliteratus" in arm + assert "-m obliteratus --help" in arm + assert "tests/test_device_boundaries.py" in arm + assert "tests/test_jetson_support_tooling.py" in arm + assert "jetson-runtime" not in arm + + def test_release_depth_jobs_only_run_for_tags_or_manual_validation(): workflow = WORKFLOW.read_text(encoding="utf-8") release_condition = ( diff --git a/tests/test_conditional_gate_scripts.py b/tests/test_conditional_gate_scripts.py index 0dc1f34..80d4822 100644 --- a/tests/test_conditional_gate_scripts.py +++ b/tests/test_conditional_gate_scripts.py @@ -42,6 +42,7 @@ def test_cuda_job_replaces_locked_cpu_torch_with_same_version_cuda_build(): cuda_job = workflow.split(" cuda:\n", maxsplit=1)[1].split(" mps:\n", maxsplit=1)[0] assert "torch.__version__.split" in cuda_job + assert "--extra quantization" in cuda_job assert "UV_TORCH_BACKEND=cu130 uv pip install" in cuda_job assert "--reinstall-package torch" in cuda_job assert '"torch==$CUDA_TORCH_VERSION"' in cuda_job @@ -49,6 +50,28 @@ def test_cuda_job_replaces_locked_cpu_torch_with_same_version_cuda_build(): assert 'uv pip check --python "$CONDITIONAL_ENV/bin/python"' in cuda_job +def test_jetson_job_is_manual_physical_trusted_and_retains_sanitized_evidence(): + workflow = (ROOT / ".github" / "workflows" / "conditional-tests.yml").read_text() + jetson_job = workflow.split(" jetson:\n", maxsplit=1)[1].split( + " mps:\n", + maxsplit=1, + )[0] + + assert "github.event_name == 'workflow_dispatch' && inputs.run_jetson" in jetson_job + assert "runs-on: [self-hosted, linux, ARM64, jetson]" in jetson_job + assert "scripts/setup_jetson.py" in jetson_job + assert "scripts/run_conditional_gate.py jetson-runtime" in jetson_job + assert "scripts/jetson_support.py" in jetson_job + assert "conditional-jetson-${{ github.run_attempt }}" in jetson_job + assert "retention-days: 30" in jetson_job + assert "actions/setup-python" not in jetson_job + + policy = json.loads((ROOT / "ci" / "conditional-test-policy.json").read_text()) + gate = next(value for value in policy["gates"] if value["id"] == "jetson-runtime") + assert gate["job"] == "jetson" + assert gate["runner"] == "self-hosted, linux, ARM64, jetson" + + def test_policy_rejects_unknown_cpu_exclusion_gate(tmp_path): policy = json.loads((ROOT / "ci" / "conditional-test-policy.json").read_text()) quality = { diff --git a/tests/test_jetson_support_tooling.py b/tests/test_jetson_support_tooling.py new file mode 100644 index 0000000..2a0ddb8 --- /dev/null +++ b/tests/test_jetson_support_tooling.py @@ -0,0 +1,254 @@ +"""CPU-testable contracts for the experimental Jetson support path.""" + +from __future__ import annotations + +import importlib.metadata +import json +import sys +import tomllib +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml + +from scripts import jetson_support +from scripts import run_conditional_gate +from scripts import setup_jetson + + +ROOT = Path(__file__).parents[1] + + +def test_host_evidence_is_allow_listed(monkeypatch, tmp_path): + tegra = tmp_path / "nv_tegra_release" + tegra.write_text("# R36 (release), REVISION: 4.3\nserial=secret\n") + os_release = tmp_path / "os-release" + os_release.write_text( + 'ID=ubuntu\nVERSION_ID="22.04"\nPRETTY_NAME="Ubuntu 22.04"\nSECRET=value\n' + ) + monkeypatch.setattr(jetson_support.platform, "machine", lambda: "aarch64") + monkeypatch.setattr(jetson_support.platform, "python_version", lambda: "3.10.12") + monkeypatch.setattr(jetson_support, "_capture", lambda command, **kwargs: "6.2+b17") + + facts = jetson_support.collect_host_facts( + tegra_release=tegra, + os_release=os_release, + ) + + assert facts == { + "architecture": "aarch64", + "python_version": "3.10.12", + "os": { + "id": "ubuntu", + "version_id": "22.04", + "pretty_name": "Ubuntu 22.04", + }, + "l4t_release": "# R36 (release), REVISION: 4.3", + "jetpack_package": "6.2+b17", + } + assert "secret" not in json.dumps(facts).lower() + + +def test_runtime_evidence_reports_cuda_without_device_identity(monkeypatch): + properties = SimpleNamespace(name="Orin", total_memory=64 * 1024**3) + fake_cuda = SimpleNamespace( + is_available=lambda: True, + device_count=lambda: 1, + get_device_properties=lambda _index: properties, + get_device_capability=lambda _index: (8, 7), + ) + fake_torch = SimpleNamespace( + __version__="2.8.0a0+nv25.06", + version=SimpleNamespace(cuda="12.6"), + cuda=fake_cuda, + ) + monkeypatch.setitem(sys.modules, "torch", fake_torch) + + def missing_package(_name): + raise importlib.metadata.PackageNotFoundError + + monkeypatch.setattr(jetson_support.importlib.metadata, "version", missing_package) + + facts = jetson_support.collect_runtime_facts() + + assert facts["cuda_available"] is True + assert facts["device_name"] == "Orin" + assert facts["compute_capability"] == [8, 7] + assert facts["total_memory_gb"] == 64.0 + assert set(facts) == { + "torch_imported", + "torch_version", + "torch_cuda_version", + "cuda_available", + "cuda_device_count", + "device_name", + "compute_capability", + "total_memory_gb", + "bitsandbytes_version", + } + + +def test_report_validation_blocks_non_jetson_or_non_cuda_and_warns_on_bnb(): + report = { + "host": {"architecture": "x86_64", "l4t_release": None}, + "runtime": { + "torch_imported": True, + "torch_cuda_version": None, + "cuda_available": False, + "bitsandbytes_version": "0.47.0", + }, + } + + errors, warnings = jetson_support.validate_report(report) + + assert len(errors) == 3 + assert any("ARM64" in error for error in errors) + assert any("Jetson L4T" in error for error in errors) + assert any("not a CUDA build" in error for error in errors) + assert warnings and "unsupported" in warnings[0] + + +def test_gate_evidence_and_issue_body_cannot_copy_arbitrary_fields(tmp_path): + evidence = tmp_path / "gate.json" + evidence.write_text(json.dumps({ + "gate": "jetson-runtime", + "status": "passed", + "git_sha": "a" * 40, + "counts": {"tests": 1, "token": "nested-secret"}, + "token": "must-not-escape", + "hostname": "must-not-escape", + })) + + summary = jetson_support._gate_summary(evidence) + body = jetson_support.issue_body({"gate_evidence": summary}) + + assert summary == { + "gate": "jetson-runtime", + "status": "passed", + "git_sha": "a" * 40, + "counts": {"tests": 1}, + } + assert "must-not-escape" not in body + assert "nested-secret" not in body + assert "excludes environment variables" in body + + +def test_dependency_export_rejects_torch_and_bitsandbytes(tmp_path): + requirements = tmp_path / "requirements.txt" + requirements.write_text("transformers==4.56.0\npytest==8.4.1\n") + setup_jetson._require_exclusions(requirements) + + for forbidden in ( + "torch==2.8.0", + "torch @ https://example.invalid/torch.whl", + "bitsandbytes[diagnostics]==0.47.0", + ): + requirements.write_text(forbidden + "\n") + with pytest.raises(RuntimeError, match="forbidden packages"): + setup_jetson._require_exclusions(requirements) + + +def test_jetson_bootstrap_preserves_vendor_runtime_and_uses_locked_no_deps( + monkeypatch, + tmp_path, +): + project = tmp_path / "project" + (project / "scripts").mkdir(parents=True) + (project / "scripts" / "jetson_support.py").write_text("# fixture\n") + (project / "uv.lock").write_text("# fixture\n") + venv = tmp_path / "jetson-venv" + commands: list[list[str]] = [] + + def fake_run(command, *, cwd): + command = list(command) + commands.append(command) + if command[1:4] == ["-m", "venv", "--system-site-packages"]: + (venv / "bin").mkdir(parents=True) + (venv / "pyvenv.cfg").write_text("include-system-site-packages = true\n") + if "--output-file" in command: + output = Path(command[command.index("--output-file") + 1]) + output.write_text("transformers==4.56.0\n") + + monkeypatch.setattr(setup_jetson, "_run", fake_run) + + setup_jetson.prepare( + project=project, + venv=venv, + python="vendor-python", + uv_python="tool-python", + reuse=False, + ) + + assert commands[0][0] == "vendor-python" + assert "--check" in commands[0] + export = next(command for command in commands if "export" in command) + assert export.count("--no-emit-package") == 2 + assert "torch" in export and "bitsandbytes" in export + installs = [command for command in commands if "install" in command] + assert len(installs) == 2 + assert all("--no-deps" in command for command in installs) + assert any("check" in command for command in commands) + + +def test_bootstrap_rejects_unsafe_or_non_vendor_reusable_targets(tmp_path): + project = tmp_path / "project" + project.mkdir() + with pytest.raises(ValueError, match="unsafe"): + setup_jetson._require_safe_target(project, project) + + existing = tmp_path / "existing" + existing.mkdir() + (existing / "pyvenv.cfg").write_text("include-system-site-packages = false\n") + with pytest.raises(ValueError, match="does not expose JetPack"): + setup_jetson._require_new_or_reusable_venv(existing, reuse=True) + + +def test_bitsandbytes_is_opt_in_for_quantization_only(): + metadata = tomllib.loads((ROOT / "pyproject.toml").read_text()) + base = metadata["project"]["dependencies"] + extras = metadata["project"]["optional-dependencies"] + + assert not any(value.startswith("bitsandbytes") for value in base) + assert extras["quantization"] == ["bitsandbytes>=0.46.1"] + + +def test_jetson_issue_form_requires_reproducible_sanitized_hardware_evidence(): + form = yaml.safe_load( + (ROOT / ".github" / "ISSUE_TEMPLATE" / "jetson-runtime.yml").read_text(), + ) + fields = {value.get("id"): value for value in form["body"] if value.get("id")} + + assert set(fields) == { + "device", + "jetpack", + "commit", + "reproduction", + "expected", + "actual", + "evidence", + "confirmations", + } + assert all(value.get("validations", {}).get("required") for value in fields.values()) + confirmations = fields["confirmations"]["attributes"]["options"] + assert all(option["required"] for option in confirmations) + assert any("secrets" in option["label"] for option in confirmations) + + +def test_jetson_conditional_prerequisites_are_physical_and_cuda(monkeypatch, tmp_path): + fake_torch = SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True)) + monkeypatch.setitem(sys.modules, "torch", fake_torch) + monkeypatch.setattr(run_conditional_gate.platform, "machine", lambda: "aarch64") + tegra = tmp_path / "nv_tegra_release" + tegra.write_text("# R36\n") + monkeypatch.setattr(run_conditional_gate, "JETSON_RELEASE", tegra) + assert run_conditional_gate.missing_prerequisites("jetson-runtime") == [] + + monkeypatch.setattr(run_conditional_gate.platform, "machine", lambda: "x86_64") + fake_torch.cuda.is_available = lambda: False + tegra.unlink() + assert run_conditional_gate.missing_prerequisites("jetson-runtime") == [ + "a CUDA-capable PyTorch runtime", + "an ARM64 host", + "a Jetson L4T runtime", + ] diff --git a/uv.lock b/uv.lock index 53f8a6d..bb2c903 100644 --- a/uv.lock +++ b/uv.lock @@ -15,7 +15,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-08-11T18:17:23.461940614Z" +exclude-newer = "2026-08-19T00:05:48.361273363Z" exclude-newer-span = "P3D" [manifest] @@ -2602,7 +2602,6 @@ version = "0.1.2" source = { editable = "." } dependencies = [ { name = "accelerate" }, - { name = "bitsandbytes" }, { name = "datasets" }, { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "matplotlib", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -2631,6 +2630,9 @@ dev = [ { name = "pytest-cov" }, { name = "ruff" }, ] +quantization = [ + { name = "bitsandbytes" }, +] spaces = [ { name = "gradio" }, ] @@ -2652,7 +2654,7 @@ quality = [ [package.metadata] requires-dist = [ { name = "accelerate", specifier = ">=0.24" }, - { name = "bitsandbytes", specifier = ">=0.46.1" }, + { name = "bitsandbytes", marker = "extra == 'quantization'", specifier = ">=0.46.1" }, { name = "build", marker = "extra == 'dev'", specifier = "==1.2.2.post1" }, { name = "datasets", specifier = ">=2.14" }, { name = "gradio", marker = "extra == 'spaces'", specifier = ">=6.7,<7.0" }, @@ -2674,7 +2676,7 @@ requires-dist = [ { name = "tqdm", specifier = ">=4.64" }, { name = "transformers", specifier = ">=4.40" }, ] -provides-extras = ["dev", "spaces"] +provides-extras = ["dev", "quantization", "spaces"] [package.metadata.requires-dev] ci = [