|
| 1 | +# Phase 1 — Data Model |
| 2 | + |
| 3 | +## Value objects |
| 4 | + |
| 5 | +### `HostCuda` |
| 6 | + |
| 7 | +Result of probing the host's runtime CUDA capability. Produced by `cuda_probe.detect_host_cuda()`. |
| 8 | + |
| 9 | +| Field | Type | Description | |
| 10 | +|---|---|---| |
| 11 | +| `version` | `tuple[int, int] \| None` | Major + minor, e.g. `(12, 9)`. `None` when no CUDA detected. | |
| 12 | +| `source` | `Literal["nvidia-smi", "nvcc", "none"]` | Which probe succeeded. `"none"` only when both failed. | |
| 13 | +| `raw` | `str` | Raw stdout the probe parsed (or `""` for `"none"`). Kept for the diagnostic in FR-004. | |
| 14 | + |
| 15 | +**Validation rules**: |
| 16 | +- `version is None` ⟺ `source == "none"`. |
| 17 | +- `version` has both elements `≥ 0` when present. |
| 18 | +- Immutable; produced fresh on every probe call (no class-level cache; caching is the caller's responsibility — `ensure_venv` records it in the marker). |
| 19 | + |
| 20 | +### `TorchIndex` |
| 21 | + |
| 22 | +The chosen index URL for the subsequent `uv pip install`. Produced by `cuda_probe.pick_torch_index(host_cuda, env_override=None)`. |
| 23 | + |
| 24 | +| Field | Type | Description | |
| 25 | +|---|---|---| |
| 26 | +| `url` | `str` | Full HTTPS URL of the chosen index. | |
| 27 | +| `tag` | `str` | Short identifier — `"cu128"`, `"cu126"`, `"cu124"`, `"cu121"`, `"cpu"`, or `"override"` when the env var supplied an arbitrary URL. | |
| 28 | +| `cuda_version` | `tuple[int, int] \| None` | The index's CUDA version, not the host's. `None` when tag is `"cpu"` or `"override"`. | |
| 29 | +| `source` | `Literal["static-map", "env-override"]` | How this index was selected. | |
| 30 | + |
| 31 | +**Validation rules**: |
| 32 | +- `tag == "override"` ⟺ `source == "env-override"`. |
| 33 | +- `tag == "cpu"` ⟺ `cuda_version is None and source == "static-map"`. |
| 34 | +- `url` starts with `https://`. |
| 35 | + |
| 36 | +## Marker schema (`.senselab-installed`) |
| 37 | + |
| 38 | +Stored at `~/.cache/senselab/venvs/<name>/.senselab-installed` after a successful venv build. |
| 39 | + |
| 40 | +**Current (pre-fix)**: |
| 41 | +```json |
| 42 | +{ |
| 43 | + "requirements": ["nemo_toolkit[asr,tts] @ git+...", "torch>=2.8,<2.9", ...], |
| 44 | + "python_version": "3.12" |
| 45 | +} |
| 46 | +``` |
| 47 | + |
| 48 | +**After this fix**: |
| 49 | +```json |
| 50 | +{ |
| 51 | + "requirements": ["nemo_toolkit[asr,tts] @ git+...", "torch>=2.8,<2.9", ...], |
| 52 | + "python_version": "3.12", |
| 53 | + "torch_index": { |
| 54 | + "tag": "cu128", |
| 55 | + "url": "https://download.pytorch.org/whl/cu128", |
| 56 | + "source": "static-map" |
| 57 | + } |
| 58 | +} |
| 59 | +``` |
| 60 | + |
| 61 | +**Mismatch rules** (any one triggers a rebuild): |
| 62 | +- `stored.get("requirements") != sorted(requirements)` — existing behavior, unchanged. |
| 63 | +- `stored.get("torch_index", {}).get("url")` differs from the currently-resolved `TorchIndex.url` — new clause. Absence of `torch_index` in the existing marker (old shape) counts as a mismatch, which is the intended one-time rebuild for users upgrading through this fix. |
| 64 | + |
| 65 | +No `schema_version` field — the marker is internal state, not a published contract. Adding fields by field-presence is sufficient; explicit versioning would be ceremony with no payoff. |
| 66 | + |
| 67 | +## State transitions |
| 68 | + |
| 69 | +`ensure_venv` state diagram: |
| 70 | + |
| 71 | +```text |
| 72 | + ┌─────────────────┐ |
| 73 | + call ─────► │ Acquire lock │ |
| 74 | + └────────┬────────┘ |
| 75 | + ▼ |
| 76 | + ┌─────────────────────────┐ |
| 77 | + │ Read marker (if exists) │ |
| 78 | + └────────┬────────────────┘ |
| 79 | + ▼ |
| 80 | + ┌───────── match? ──────────┐ |
| 81 | + │ │ |
| 82 | + match │ │ mismatch / missing |
| 83 | + ▼ ▼ |
| 84 | + Return venv_dir Probe host CUDA |
| 85 | + Pick torch index |
| 86 | + Apply env override |
| 87 | + │ |
| 88 | + ▼ |
| 89 | + uv venv (recreate) |
| 90 | + uv pip install |
| 91 | + with --index-url |
| 92 | + │ |
| 93 | + ┌───────────┴───────────┐ |
| 94 | + │ │ |
| 95 | + success failure |
| 96 | + │ │ |
| 97 | + ▼ ▼ |
| 98 | + Write marker w/ torch_index Wrap into |
| 99 | + Return venv_dir SenselabCudaCompatibilityError |
| 100 | + re-raise |
| 101 | +``` |
| 102 | + |
| 103 | +## Public API surface added |
| 104 | + |
| 105 | +```python |
| 106 | +# src/senselab/utils/cuda_probe.py |
| 107 | + |
| 108 | +@dataclass(frozen=True) |
| 109 | +class HostCuda: |
| 110 | + version: tuple[int, int] | None |
| 111 | + source: Literal["nvidia-smi", "nvcc", "none"] |
| 112 | + raw: str |
| 113 | + |
| 114 | +@dataclass(frozen=True) |
| 115 | +class TorchIndex: |
| 116 | + url: str |
| 117 | + tag: str |
| 118 | + cuda_version: tuple[int, int] | None |
| 119 | + source: Literal["static-map", "env-override"] |
| 120 | + |
| 121 | +class SenselabCudaCompatibilityError(RuntimeError): |
| 122 | + """Raised when no torch/torchaudio binary pair is installable on this host.""" |
| 123 | + |
| 124 | +def detect_host_cuda() -> HostCuda: ... |
| 125 | + |
| 126 | +def pick_torch_index( |
| 127 | + host_cuda: HostCuda, |
| 128 | + env_override: str | None = None, |
| 129 | +) -> TorchIndex: ... |
| 130 | +``` |
| 131 | + |
| 132 | +The two existing public functions in `subprocess_venv.py` (`ensure_venv`, `venv_python`) keep their signatures. The behavior of `ensure_venv` changes: it now probes CUDA and routes the install through the chosen index; on unsupported-CUDA hosts it raises `SenselabCudaCompatibilityError` instead of letting `uv pip install` fail with an opaque error. |
| 133 | + |
| 134 | +## Backwards compatibility |
| 135 | + |
| 136 | +- Existing marker files without a `torch_index` key: trigger one rebuild on first run after upgrade (intentional, see Mismatch rules). |
| 137 | +- Existing callers of `ensure_venv`: signature unchanged; no migration needed. |
| 138 | +- Existing subprocess backends (`canary_qwen.py`, `nemo.py`, `qwen.py`): no code change required. They benefit automatically. |
| 139 | +- Users without GPUs: behavior unchanged today — install used `cpu` wheels via PyPI's default torch resolution, post-fix they use `cpu` wheels via the explicit CPU index. Same wheel, different URL. |
0 commit comments