diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bc65eaf..cbc46326 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,63 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + ai-static: + name: AI plans and unit tests + runs-on: windows-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Parse PowerShell and run hardware-independent tests + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Get-ChildItem ./src/Workloads, ./src/tests, ./src/tools -Recurse -Filter *.ps1 | + ForEach-Object { + $tokens = $null + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + $_.FullName, [ref]$tokens, [ref]$errors) + if ($errors) { throw "$($_.FullName): $($errors -join '; ')" } + } + foreach ($id in 'ai-common','cuda','rocm','intel-ai','foundry','pytorch','local-ai','llama.cpp','ollama') { + & "./src/tests/$id/unit.ps1" + } + & "./src/tests/ai-common/all-signed.ps1" + + - name: Validate report schema and manifest + shell: pwsh + run: | + python -m pip install --disable-pip-version-check --quiet pyyaml jsonschema + python -c "import json,yaml; json.load(open('src/docs/ai-workload-report.schema.json', encoding='utf-8')); yaml.safe_load(open('src/manifest.yml', encoding='utf-8-sig')); print('AI data files valid')" + + - name: Run AI unit tests in Windows PowerShell + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + foreach ($id in 'ai-common','cuda','rocm','intel-ai','foundry','pytorch','local-ai','llama.cpp','ollama') { + & "./src/tests/$id/unit.ps1" + } + + - name: Exercise non-mutating AI plans and validate reports + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + New-Item -ItemType Directory -Path ai-reports -Force | Out-Null + foreach ($id in 'cuda','rocm','intel-ai','foundry','pytorch','llama.cpp','ollama') { + & "./src/Workloads/$id/install.ps1" -PlanOnly -ReportPath "./ai-reports/$id.json" + } + & "./src/Workloads/local-ai/install.ps1" -PlanOnly -ReportRoot "./ai-reports/local-ai" + @' + import glob, json, jsonschema + schema = json.load(open("src/docs/ai-workload-report.schema.json", encoding="utf-8")) + for path in glob.glob("ai-reports/**/*.json", recursive=True): + with open(path, encoding="utf-8-sig") as handle: + jsonschema.validate(json.load(handle), schema) + print("OK:", path) + '@ | python - + # --------------------------------------------------------------------------- # Job A: parse manifest.yml → matrices for per-OS flow jobs. # --------------------------------------------------------------------------- diff --git a/README.md b/README.md index 75b3f18e..a93d2bcd 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ · WSL Comfort · - Workloads + Workloads · Troubleshooting @@ -30,7 +30,7 @@ Three developer setups live in this repo. Pick the one that matches what you wan | --- | --- | | A complete dev workstation: tools, OS settings, WSL, and terminal. One command, restarts once. | [Windows Dev Config](#%EF%B8%8F-windows-dev-config) | | A polished WSL shell: zsh/bash, Starship, CLI tools, and a themed terminal profile. Interactive or unattended. | [WSL Comfort](#-wsl-comfort) | -| A single language toolchain: Node, Python, SQL, PowerShell, .NET, Rust, Go, Java, PHP, WinForms, or WinUI 3. One command each. | [Workloads](#-single-language-workloads) | +| A focused language or Windows AI toolchain. One command each. | [Workloads](#-focused-workloads) | ## 🖥️ Windows Dev Config @@ -100,10 +100,14 @@ Full details: [`wsl-comfort/readme.md`](./wsl-comfort/readme.md).
-## 🧪 Single-language workloads +## 🧪 Focused workloads Just want one toolchain? Pick a row. Each workload ships a `configuration.winget` file plus a matching `install.ps1` shim that applies it and refreshes PATH in the current session. +AI workloads instead use resumable PowerShell entry points that directly check, +install or upgrade, refresh PATH, verify a real workload, and write a +machine-readable report. + Most of the single-language workloads use [`winget configure`](https://learn.microsoft.com/en-us/windows/package-manager/winget/configure). If you've never used it before, enable it once: ```powershell @@ -139,6 +143,14 @@ If that fails or `winget configure` is still not recognized, see [Troubleshootin | WinForms | .NET SDK 10 + Windows Forms desktop workload | `winget configure -f .\Workloads\winforms\configuration.winget --accept-configuration-agreements --disable-interactivity` | | WinAppCLI | Developer Mode + .NET SDK 10 + Windows App Development CLI | `winget configure -f .\Workloads\winappcli\configuration.winget --accept-configuration-agreements --disable-interactivity` | | WinUI 3 | .NET SDK 10 + Visual Studio Community + Windows App SDK / WinUI 3 + WinAppCLI | `winget configure -f .\Workloads\winui\configuration.winget --accept-configuration-agreements --disable-interactivity` | +| NVIDIA CUDA | CUDA Toolkit + MSVC; compiles and executes a minimal GPU kernel | `.\Workloads\cuda\install.ps1` | +| AMD ROCm / HIP | ROCm Core SDK 10.0 for supported AMD GPUs; compiles and executes a HIP kernel | `.\Workloads\rocm\install.ps1` | +| Intel AI | OpenVINO device inference; optional oneAPI/SYCL GPU toolkit and kernel | `.\Workloads\intel-ai\install.ps1` | +| Foundry Local | Architecture-native WinML package + Qwen3-0.6B model inference | `.\Workloads\foundry\install.ps1` | +| PyTorch | CPython 3.13 + contained CPU/CUDA/ROCm/XPU environment; vendor-appropriate Triton where supported | `.\Workloads\pytorch\install.ps1` | +| Local AI development | Hardware inventory + contained PyTorch/Triton + optional one local-model runtime | `.\Workloads\local-ai\install.ps1` | +| llama.cpp | Hardware-selected official rolling CUDA/ROCm/SYCL/OpenVINO/Vulkan/OpenCL/CPU runtime + pinned GGUF inference | `.\Workloads\llama.cpp\install.ps1` | +| Ollama | Installed x64 application or Dev Config-managed native ARM64 archive + official qwen3:0.6b inference | `.\Workloads\ollama\install.ps1` | Want the PATH refresh in your current shell? Use the matching shim instead of calling `winget configure` directly: @@ -148,6 +160,507 @@ Want the PATH refresh in your current shell? Use the matching shim instead of ca > **Heads up:** WinForms and WinUI 3 pull down several gigabytes of Visual Studio components. Fine on a real workstation, painful on a small VM. +### Windows AI workload support + +The primary deliverable is a runnable **local AI development scenario**, not a +replacement for PyPI/Conda and not an instruction to install every vendor SDK +or model runtime. The supported product-level dispatcher uses the same protected +download/signature/elevation architecture as Windows Dev Config: + +```powershell +$url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1' +& ([scriptblock]::Create((irm $url))) -Scenario local-ai +``` + +For this PR before its signed release copy exists: + +```powershell +# Follow the unsigned-development instructions below first: temporarily set +# the test user's CurrentUser policy to Bypass and restore it afterward. +$prHead = gh pr view 104 --repo microsoft/WindowsDeveloperConfig ` + --json headRefOid --jq .headRefOid +$prUrl = "https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/$prHead/src/windows-dev-config/bootstrap.ps1" +& ([scriptblock]::Create((irm $prUrl))) ` + -Ref $prHead -Scenario local-ai -AllowUnsigned +``` + +The dispatcher downloads the full multi-file workload tree, verifies signed +PowerShell files plus the signed hash manifest for catalog/Python/C++/CUDA +content, copies them with shared helper steps to a protected scenario directory, +reverifies after copy, elevates the apply run, and launches only `local-ai`—not +the full Calm OS workstation setup. Explicit `-AllowUnsigned` branch tests use +the isolated `%ProgramData%\CalmOS-Development` root so they cannot contaminate +the production signed `%ProgramData%\CalmOS` payload. + +The repository-level equivalent detects hardware, installs a contained PyTorch +backend, adds compatible Triton when published, and proves both a tensor +operation and a minimal neural-network forward pass: + +```powershell +.\Workloads\local-ai\install.ps1 +# Expected: +# PYTORCH_SMOKE=... "model_forward_verified": true ... +# PYTORCH_READY: backend=, ... +# LOCAL_AI_SCENARIO_READY: backend=Auto, runtime=None, ... +``` + +Product-level plan and optional runtime examples: + +```powershell +& ([scriptblock]::Create((irm $url))) ` + -Scenario local-ai -PlanOnly ` + -ReportRoot "$env:TEMP\local-ai-plan" + +& ([scriptblock]::Create((irm $url))) ` + -Scenario local-ai -AiRuntime LlamaCpp +``` + +Choose one optional local-model runtime only when the scenario needs it: + +```powershell +.\Workloads\local-ai\install.ps1 -Runtime LlamaCpp +.\Workloads\local-ai\install.ps1 -Runtime Ollama +.\Workloads\local-ai\install.ps1 -Runtime Foundry +``` + +### What each entry point installs transitively + +GPU drivers are prerequisites and are never replaced. + +| Entry point | Transitive acquisition and explicit non-acquisition | +| --- | --- | +| `local-ai` | Hardware inventory → PyTorch `Auto`. NVIDIA installs CUDA-enabled torch; if Triton is selected, it also ensures architecture-native MSVC and standalone CUDA Toolkit for JIT. AMD installs the exact ROCm device runtime tuple inside the PyTorch venv, **not** native ROCm SDK/`hipcc`. Intel installs the XPU tuple + `triton-xpu`, **not** full oneAPI. CPU installs only the CPU tuple. | +| `local-ai -Runtime LlamaCpp` | The PyTorch stack above + one backend-specific llama.cpp runtime + pinned quick validation GGUF. NVIDIA llama assets carry paired `cudart`; llama.cpp itself does not require the full standalone CUDA Toolkit. | +| `local-ai -Runtime Ollama` | The PyTorch stack above + Ollama. Ollama selects/manages its own inference backend and model runtime; the report records actual CPU/GPU allocation. | +| `local-ai -Runtime Foundry` | The PyTorch stack above + Foundry Local. Foundry controls EP discovery/acquisition; the report records the actual EP/device or truthful CPU fallback. | +| `pytorch` | Exact backend behavior from the first row. CUDA/MSVC are conditional on supported Triton JIT, not ordinary tensor/model inference. ROCm and XPU runtimes remain contained in the venv. | +| `cuda` | Native CUDA Toolkit + architecture-native MSVC; compiles/runs a CUDA kernel. Does not install PyTorch or a model runtime. | +| `rocm` | Native ROCm Core SDK/`hipcc` in a contained environment; compiles/runs a HIP kernel. Does not install PyTorch or a model runtime. | +| `intel-ai` | OpenVINO CPU/GPU/NPU inference; `SYCL`/`Full` conditionally adds full oneAPI for native GPU development. Does not install PyTorch XPU. | +| `llama.cpp` | One selected backend runtime, paired runtime assets where required, and the quick validation model. Does not install PyTorch; NVIDIA assets include their required `cudart`. | +| `ollama` | Ollama + verified quick model. Backend selection is source-managed and reported, not forced by Dev Config. | +| `foundry` | Foundry Local + quick catalog model. EP acquisition/selection is source-managed and reported, not forced by Dev Config. | + +The standalone vendor flows remain available for native kernel/toolchain work. +The AI flows are independent and install only the selected hardware stack. +CPU architecture and GPU vendor are separate axes: Windows ARM64 can have an +NVIDIA GPU (RTX Spark), while AMD and Intel native Windows toolkits currently +publish x64 artifacts only. There is no generic "ARM GPU" toolkit. Foundry +Local/Windows ML is the cross-vendor layer for DirectML and dynamically acquired +NVIDIA, AMD, Intel, and Qualcomm execution providers. + +| Workload | Windows x64 | Windows ARM64 | Prerequisites and selected path | +| --- | --- | --- | --- | +| Local AI development | PyTorch Auto + optional selected runtime | PyTorch Auto + optional selected runtime | Recommended scenario entry point. Does not universally install CUDA, ROCm, oneAPI, Foundry, llama.cpp, and Ollama. | +| CUDA | WinGet CUDA 13 stable; GPU readiness requires driver 580+ and CC7.5+ | NVIDIA CUDA 13.4 Developer Preview | NVIDIA GPU + qualified driver by default. Installs MSVC, compiles with `nvcc -arch=native`, and executes a kernel. `-ToolkitOnly` permits compiler-only setup. | +| AMD ROCm / HIP | ROCm Core SDK 10.0 on supported Radeon/Ryzen AI GPUs | Not published | Uses AMD's stable Windows x64 feed and executes a compiled HIP kernel. Native Windows Triton is unsupported. | +| Intel AI | OpenVINO CPU/GPU/NPU; optional oneAPI/SYCL | Not published | OpenVINO performs generated-model inference on the requested device. `-Profile Full` also executes a SYCL GPU kernel. | +| Foundry Local | Supported | Supported | Windows 11 24H2/build 26100+. Uses WinML and does **not** require CUDA. Downloads `qwen3-0.6b` and runs a marker completion. | +| PyTorch CPU | Stable official CPU wheel | Stable official CPU wheel | Contained CPU runtime, tensor operation, and minimal neural-model forward pass. | +| PyTorch CUDA | Stable official CUDA wheel chosen from driver/device capability | Pinned NVIDIA CUDA 13.4 qualified interim wheel on RTX Spark | Self-contained wheel runtime; standalone `cuda` is not required for ordinary tensor/model use. Triton JIT acquires its compiler/toolchain automatically. | +| PyTorch ROCm | AMD stable Windows x64 feed with exact `device-` runtime tuple | Unsupported/unpublished | Self-contained AMD runtime tuple inside the PyTorch venv; does not require the standalone `rocm` SDK flow. | +| PyTorch XPU | Official PyTorch XPU index with `torch`, `torchvision`, and `triton-xpu` | Unsupported/unpublished | Self-contained Intel XPU runtime tuple; does not install full oneAPI. | +| Triton Windows CUDA | Community `triton-windows` on qualified NVIDIA CUDA stacks | NVIDIA CUDA 13.4 preview stack | Executes a real vector-add GPU kernel. | +| Triton XPU / `torch.compile` | Official `triton-xpu` through the PyTorch XPU index | Unsupported/unpublished | Executes a cold `torch.compile` workload on the Intel GPU. | +| llama.cpp CUDA x64 | Official rolling CUDA 13.3 or 12.4 app + paired cudart assets | Unsupported | Auto selects the newest compatible CUDA runtime from driver and compute capability, then proves NVIDIA device offload and GPU layers. | +| llama.cpp CUDA ARM64 | Unsupported | Qualified CUDA 13.4 Developer Preview app + paired cudart assets | Retains the N1X path and requires RTX Spark-class hardware, driver 616+, backend/device evidence, GPU layers, and real inference. | +| llama.cpp ROCm x64 | Official rolling ROCm 10.0 asset | Unsupported | Requires an AMD GPU in the Windows ROCm matrix and proves ROCm/AMD offload. | +| llama.cpp SYCL / OpenVINO x64 | Official rolling SYCL and OpenVINO 2026.3.1 assets | Unsupported | Auto prefers SYCL for a supported Intel GPU because it directly proves Intel GPU execution. OpenVINO is explicit/general x64 inference; no NPU claim is made. | +| llama.cpp OpenCL Adreno ARM64 | Unsupported | Policy-approved, physically qualified `b10917` Qualcomm Adreno OpenCL asset | Requires a detected Qualcomm/Adreno GPU plus the Windows OpenCL loader and proves OpenCL/Adreno offload. | +| llama.cpp Vulkan x64 fallback | Official rolling Vulkan asset | Unsupported | Used by Auto only after no supported vendor-native backend is available and a Vulkan loader/device exists. Reports Vulkan explicitly. | +| llama.cpp CPU fallback | Official rolling CPU asset | Official rolling CPU asset | Used when no qualified accelerator exists or explicitly requested; benchmark must show no GPU layers. | +| Ollama | Official WinGet installer | Official native ARM64 archive installed as a managed per-user application | ARM64 installs atomically under `%LOCALAPPDATA%\Programs\Ollama`, registers user startup/PATH, verifies native architecture/model/backend, and supports model-preserving uninstall. | + +### Exact quick-run and coding-demo commands + +The default model checks are intentionally small enough for setup validation: + +```powershell +.\Workloads\llama.cpp\install.ps1 +# Expected: LLAMA_CPP_READY: ... and INSTALL_OK: llama.cpp + +.\Workloads\ollama\install.ps1 +# Expected: OLLAMA_READY: ... and INSTALL_OK: ollama + +.\Workloads\foundry\install.ps1 +# Expected: FOUNDRY_READY: ... provider= and INSTALL_OK: foundry +``` + +On x64, Ollama remains a normally registered WinGet application. On ARM64, +Dev Config verifies the official native `ollama-windows-arm64.zip`, installs it +atomically under `%LOCALAPPDATA%\Programs\Ollama`, prepends that directory to +the user PATH, and registers `ollama serve` in the current user's Run key. +The `.devconfig-install.json` marker records the release tag, asset digest, +installed files, architecture, and source. Upgrade stops only processes whose +executable is inside that managed directory. To remove the managed ARM64 +application while retaining downloaded models: + +```powershell +.\Workloads\ollama\install.ps1 -Uninstall +# Add -RemoveModels only when model data should also be deleted. +``` + +For a more useful coding demonstration, after the llama.cpp flow succeeds: + +```powershell +.\Workloads\llama.cpp\coding-demo.ps1 ` + -ReportPath "$env:TEMP\llama-coding-demo.json" +# Downloads the optional ~1.04 GB Qwen2.5-Coder-1.5B-Instruct Q4_K_M model. +# Expected: generated Python `def group_anagrams(...)` and CODING_DEMO_READY. +``` + +The coding model is opt-in and does not enlarge the default setup. Its immutable +Qwen revision, exact 1,117,320,768-byte size, SHA-256, and Apache-2.0 license +are verified before execution. + +### Advanced component-level usage + +Run an individual flow from PowerShell: + +```powershell +.\Workloads\cuda\install.ps1 +.\Workloads\rocm\install.ps1 +.\Workloads\intel-ai\install.ps1 +.\Workloads\foundry\install.ps1 +.\Workloads\pytorch\install.ps1 +.\Workloads\llama.cpp\install.ps1 +.\Workloads\ollama\install.ps1 +``` + +PyTorch accepts explicit backend and Triton policy switches: + +```powershell +.\Workloads\pytorch\install.ps1 -Backend CPU +.\Workloads\pytorch\install.ps1 -Backend CUDA -RequireTriton +.\Workloads\pytorch\install.ps1 -Backend ROCm +.\Workloads\pytorch\install.ps1 -Backend XPU -RequireTriton +.\Workloads\llama.cpp\install.ps1 -Backend CUDA +.\Workloads\llama.cpp\install.ps1 -Backend ROCm +.\Workloads\llama.cpp\install.ps1 -Backend SYCL +.\Workloads\llama.cpp\install.ps1 -Backend OpenVINO +``` + +`-Backend Auto` uses the deterministic priority **supported NVIDIA CUDA → +supported AMD ROCm → supported Intel XPU → CPU**. Explicit `ROCm` or `XPU` +can select a supported secondary adapter on mixed-GPU systems. + +### Vendor setup layers + +| Vendor | Native developer flow | PyTorch flow | +| --- | --- | --- | +| NVIDIA | `cuda` installs the CUDA compiler/toolkit and proves a native kernel. | `pytorch -Backend CUDA` installs its own wheel runtime. Standalone CUDA is not universally required; compatible Triton JIT/toolchain dependencies are acquired automatically. | +| AMD | `rocm` installs the ROCm Core SDK/HIP compiler and proves a native HIP kernel. | `pytorch -Backend ROCm` installs the official AMD device-specific runtime package tuple inside its own venv; the separate `rocm` flow is not a prerequisite for tensor inference. | +| Intel | `intel-ai -Profile OpenVINO` is CPU/GPU/NPU inference; `-Profile SYCL` or `Full` installs full oneAPI for native SYCL development. | `pytorch -Backend XPU` installs the official XPU wheel tuple and `triton-xpu` inside its own venv; it does not install full oneAPI. | + +Every standalone AI workload accepts `-PlanOnly` and `-ReportPath`. The +`local-ai` scenario accepts `-PlanOnly` and groups its child reports under +`-ReportRoot`. Plan mode performs hardware/support resolution without installing +software. Applied runs write JSON to +`%LOCALAPPDATA%\DevConfig\reports\-latest.json`; the reusable hardware +inventory command is: + +```powershell +.\src\tools\collect-ai-hardware.ps1 +.\src\tools\get-ai-capabilities.ps1 -OutputPath "$env:TEMP\ai-capabilities.json" +``` + +Default acceptance proves each workload is usable, not merely installed: +CUDA executes a compiled GPU kernel; PyTorch performs a tensor operation on the +selected backend and, when supported, Triton runs a GPU kernel; and each local +model runtime downloads a small Apache-2.0 Qwen model and performs deterministic +text inference. + +llama.cpp `-Backend Auto` prefers **supported NVIDIA CUDA → supported AMD ROCm +→ supported Intel SYCL → Qualcomm Adreno OpenCL → x64 Vulkan → CPU**. Explicit +`OpenVINO` is available on x64 for its official general inference backend. The +resolver takes every archive for a selection from one `bNNNNN` release, requires +GitHub's SHA-256 digest for each asset, caches the verified archives under +`%LOCALAPPDATA%\DevConfig\llama.cpp\asset-cache`, and atomically replaces the +runtime. Qualcomm ARM64 is pinned to qualified release `b10917` because managed +Defender ransomware protection blocks the unsigned `b10919` Adreno executable. +`llama-bench -o json` must identify the selected backend/device and +diagnostics must report an actual nonzero `offloaded X/Y layers` result for +every accelerator path before the flow is ready; requested `-ngl` is not treated +as proof. Physical hardware comes from the official `gpu_info` field; `devices` +is retained only as the requested selector and must agree with explicit +`-Device`. + +| Flow | Default model download | Cache | +| --- | ---: | --- | +| Foundry Local | `qwen3-0.6b`, about 593 MB | Reported by `foundry cache location` | +| llama.cpp | `Qwen3-0.6B-Q4_K_M.gguf`, 396,704,416 bytes | `%LOCALAPPDATA%\DevConfig\llama.cpp\models` | +| llama.cpp coding demo (opt-in) | `Qwen2.5-Coder-1.5B-Instruct-Q4_K_M.gguf`, 1,117,320,768 bytes | `%LOCALAPPDATA%\DevConfig\llama.cpp\models` | +| Ollama | `qwen3:0.6b`, about 522 MB | `%USERPROFILE%\.ollama\models` or `OLLAMA_MODELS` | + +Use `-SkipModelSmoke` with Foundry Local, llama.cpp, or Ollama to opt out +of the model download and inference. Use CUDA's `-SkipWorkloadSmoke` to opt out +of kernel compilation/execution. Opted-out runs verify installation only and do +not report full workload readiness. + +For physical partner validation, run the assigned llama.cpp backend from an +elevated PowerShell. Run the plan first, then the full flow twice without skip +switches; retain every report and console log. + +```powershell +$ReportRoot = Join-Path $env:TEMP "devconfig-ai-$env:COMPUTERNAME" +New-Item -ItemType Directory -Path $ReportRoot -Force | Out-Null + +.\src\Workloads\llama.cpp\install.ps1 -Backend CUDA -PlanOnly -ReportPath "$ReportRoot\llama-cuda-plan.json" +.\src\Workloads\llama.cpp\install.ps1 -Backend ROCm -PlanOnly -ReportPath "$ReportRoot\llama-rocm-plan.json" +.\src\Workloads\llama.cpp\install.ps1 -Backend SYCL -PlanOnly -ReportPath "$ReportRoot\llama-sycl-plan.json" +.\src\Workloads\llama.cpp\install.ps1 -Backend OpenCL -PlanOnly -ReportPath "$ReportRoot\llama-adreno-plan.json" +``` + +Use the same command without `-PlanOnly` for the full and idempotence runs, +writing distinct `*-final.json` and `*-rerun-final.json` reports. A pass requires +`result.ready=true`, no blockers, matching `backends` and `gpu_info`, actual +offloaded layers, and the pinned model marker. + +On ARM64, CUDA downloads NVIDIA's checksum- and Authenticode-verified 13.4 +Developer Preview installer (about 3.8 GB) under the NVIDIA CUDA EULA. The +native RTX Spark PyTorch CUDA wheel is also a pinned developer preview (about +1.85 GB). The flows clearly label both previews and never silently claim an +ARM64 NVIDIA system is GPU-ready after falling back to CPU. + +PyTorch caches that verified ARM64 wheel under +`%LOCALAPPDATA%\DevConfig\pytorch\wheel-cache`. A matching rerun validates the +recorded plan and exact installed torch, NumPy, and Triton versions, skips all +package downloads/installation, and still reruns the CUDA tensor and Triton +kernel acceptance tests. + +**Hardware validation status:** Windows ARM64 on NVIDIA RTX Spark N1X is +validated end-to-end for CUDA, PyTorch CUDA, Triton, Foundry Local, llama.cpp, +and Ollama. The managed Ollama ARM64 acceptance installed native runtime +0.34.4 under `%LOCALAPPDATA%\Programs\Ollama`, migrated the prior Dev Config +runtime, registered PATH/startup, verified the official release digest and +`qwen3:0.6b` model digest, performed real inference at `/api/ps` 100% GPU, and +left the persistent endpoint ready on `127.0.0.1:11434`. An idempotent rerun +reported `already-current`; model-preserving uninstall and reinstall also +passed. The optional Qwen2.5-Coder-1.5B demo generated the requested +`group_anagrams` Python implementation through llama.cpp CUDA at 101.7 +generation tokens/s. AMD ROCm/HIP, Intel OpenVINO/oneAPI/XPU, NVIDIA x64 +llama.cpp CUDA, and Qualcomm ARM64 llama.cpp OpenCL are hardware-gated and +ready for partner execution. Their current gap is physical partner hardware +coverage, not static planning, asset discovery, or unit coverage. + +### Known vendor gaps and boundaries + +| Vendor | GPU coverage | NPU coverage | Windows CPU architecture | Maturity / live status | Known boundaries | +| --- | --- | --- | --- | --- | --- | +| NVIDIA | `cuda`, PyTorch CUDA/Triton, llama.cpp CUDA | None in these vendor SDK flows; Foundry/WinML provider behavior is separate | x64 designed/partner pending; ARM64 N1X validated | x64 stable channels need live acceptance; ARM64 CUDA/PyTorch are developer previews | Foundry on N1X currently uses `CPUExecutionProvider`, not CUDA. Installers qualify but do not replace GPU drivers. | +| AMD | Native ROCm/HIP, PyTorch ROCm, llama.cpp ROCm on AMD's exact Windows GPU/gfx matrix | Not implemented; ROCm is GPU/HIP, not Ryzen AI NPU | x64 only | Resolver/static acceptance complete; hardware pending | Native Windows AMD Triton is unavailable. Foundry/WinML AMD EP and Ollama AMD acceleration remain unvalidated and are not claimed. | +| Intel | OpenVINO GPU, oneAPI/SYCL, PyTorch XPU/`triton-xpu`, llama.cpp SYCL/OpenVINO | OpenVINO NPU only when the requested device actually executes; no PyTorch XPU or llama SYCL NPU claim | x64 only | Resolver/static acceptance complete; hardware pending | Full oneAPI is only for native SYCL. XPU and SYCL target Intel GPUs, not NPUs. | +| Qualcomm/Adreno | llama.cpp OpenCL ARM64; Foundry/WinML is the vendor-neutral path | Only through a validated runtime/provider such as WinML/Foundry; no standalone toolkit here | ARM64 | Resolver/static acceptance complete; hardware pending | No native PyTorch accelerator backend. Ollama ARM64 is reported as CPU/NVIDIA capability unless actual Adreno evidence becomes available. | +| Other / fallback | Vulkan x64 compatibility fallback; CPU x64/ARM64 | None | x64/ARM64 as listed | Fallback paths | Vulkan/CPU are never labeled vendor-native. Mali and other stacks are unimplemented/unpublished without official Windows artifacts. | + +Auto selects a vendor/backend deterministically, and explicit backends can target +a supported secondary vendor. Same-vendor targeting is available through +`cuda`/`rocm` and explicit-backend PyTorch `-DeviceIndex`, llama.cpp `-Device`, OpenVINO +`-OpenVinoDeviceId`, and SYCL `-SyclDeviceSelector`. Foundry and Ollama manage +their own device selection; those flows report the actual provider/allocation +rather than claiming control they do not expose. Reports retain the selected +device and installed driver as preconditions. These flows do not update GPU +drivers; unsupported versions fail with remediation. + +The executable supported-cell source of truth is +[`CapabilityMatrix`](./src/Workloads/_common/ai-catalog.psd1). Repository tests +resolve every implemented/source-managed cell and validate its acquisition +metadata, probe, report contract, and partner command; only cataloged +`upstream-unavailable` cells may remain unimplemented. + +### Preview and rolling acquisition promotion + +Acquisition metadata is centralized in +[`Workloads/_common/ai-catalog.psd1`](./src/Workloads/_common/ai-catalog.psd1). +Changing from a preview/rolling artifact to a normal channel is a resolver-data +change after the stated detection rule and real hardware acceptance pass. + +#### Stable-channel status + +| Component / tuple | Current acquisition | Current maturity/support | Stable available? | Why not selected / promotion trigger | Qualification required | Tracking | +| --- | --- | --- | --- | --- | --- | --- | +| CUDA x64 | WinGet `Nvidia.CUDA` | Stable | Yes | Selected | Native kernel on target GPU | Current | +| CUDA ARM64 | Pinned NVIDIA 13.4.0 prerelease installer | Qualified interim developer preview | **Candidate:** official direct 13.4.1, SHA-256 `39af79e5…2442` | 13.4.1 is authoritative and signed but has not yet passed the N1X workload suite | `nvcc` compile/kernel plus PyTorch Triton JIT on N1X | Tracked | +| PyTorch CUDA x64 | Official PyTorch `cu126`/`cu130` index | Stable | Yes | Selected by driver/capability | CUDA tensor + Triton kernel | Current | +| PyTorch CUDA ARM64 | Pinned NVIDIA `2.15.0.dev...+cu134` wheel | Qualified interim nightly | **Candidate:** NVIDIA stable out-of-tree `nvtorch_oot` 2.14.0 trio | Artifact publication alone is insufficient; the exact torch/vision/audio tuple is not yet N1X-qualified | Trio imports, CUDA tensor, idempotent rerun, Triton vector-add | Tracked | +| Triton Windows CUDA | PyPI `triton-windows==3.8.0.post28` | Community-stable, not upstream-official | No upstream Windows package | Keep exact qualified community build until an official Windows package passes | Vector-add JIT on each supported CUDA tuple | Current / monitor upstream | +| Triton XPU | Official PyTorch XPU index `triton-xpu==3.8.0` | Stable integrated | Yes | Selected | Cold `torch.compile` on Intel GPU | Current | +| Foundry Local | WinGet `Microsoft.FoundryLocal` 0.10.3 | Qualified preview | **Candidate:** official non-prerelease v2.0.1; Python metadata still labels the SDK alpha | v2 changes the CLI/SDK contract and has not passed x64/ARM64 provider, inference, and cached-rerun acceptance | Install/migration, EP registration, real inference, truthful fallback on both architectures | Tracked | +| llama.cpp backends | Official rolling `bNNNNN` assets; Qualcomm pinned to policy-approved `b10917` | Rolling | No stable backend-specific Windows channel | WinGet exposes only x64 Vulkan and cannot represent the required backend matrix | Backend/device, actual offloaded layers, inference, policy acceptance | Tracked | +| Ollama x64 | WinGet `Ollama.Ollama` | Stable | Yes | Selected | API/model/backend evidence | Current | +| Ollama ARM64 | Latest official `ollama-windows-arm64.zip`, converted to a managed Dev Config application | Official native ARM64 archive; managed install semantics supplied by Dev Config | No official ARM64 setup EXE/current non-portable WinGet payload | Promote when an upstream managed ARM64 installer/package passes acceptance | Native PE, managed endpoint, version, digest, inference, allocation, upgrade/uninstall | Tracked | +| ROCm/HIP x64 | AMD stable ROCm feed, exact device/gfx tuple | Stable | Yes | Selected; WinGet identity unconfirmed | Compiled HIP kernel on supported AMD GPU | Current | +| Intel OpenVINO / oneAPI x64 | Official PyPI OpenVINO tuple / WinGet oneAPI | Stable | Yes | Selected | Requested-device inference / SYCL kernel | Current | + +| Component | Vendor / architecture | Current channel and identity | Integrity | Why normal channel is insufficient | Expected stable channel | Promotion trigger | +| --- | --- | --- | --- | --- | --- | --- | +| CUDA ARM64 | NVIDIA / ARM64 | Qualified interim `cuda_13.4.0_windows_arm64.exe`; stable 13.4.1 tracked | Pinned SHA-256 + NVIDIA Authenticode | `Nvidia.CUDA` has no ARM64 payload; 13.4.1 awaits N1X qualification | NVIDIA stable direct / `Nvidia.CUDA` ARM64 if published | 13.4.1 N1X kernel + Triton pass | +| PyTorch CUDA x64 | NVIDIA / x64 | Stable `torch==2.14.0+cu126` or `+cu130` from official PyTorch index | Official index hashes + wheel RECORD | None | Official PyTorch CUDA index | New tuple passes tensor and Triton kernel | +| PyTorch CUDA ARM64 | NVIDIA / ARM64 | Qualified interim nightly `torch-2.15.0.dev20260904+cu134-cp313-win_arm64.whl`; stable NVIDIA trio tracked | Pinned SHA-256 | Stable out-of-tree tuple has not passed N1X trio/tensor/Triton acceptance | NVIDIA `nvtorch_oot` | Stable trio passes N1X acceptance | +| PyTorch ROCm x64 | AMD / x64 | Stable `torch[device-]==2.13.0+rocm10.0.0`, matching torchvision and torchaudio from AMD feed | AMD HTTPS feed + wheel RECORD | Default PyPI has no AMD ROCm Windows build | AMD stable ROCm feed | New exact tuple lists GPU and tensor acceptance passes | +| PyTorch XPU x64 | Intel / x64 | Stable `torch==2.14.0+xpu`, `torchvision==0.29.0+xpu` from official XPU index | Official index hashes + wheel RECORD | Default PyPI has no Intel XPU build | Official PyTorch XPU index | New tuple passes XPU tensor and `torch.compile` | +| Triton Windows CUDA | NVIDIA x64/ARM64 | Community `triton-windows==3.8.0.post28` | Package-index TLS + wheel RECORD | Upstream Triton has no general stable Windows package | Official PyTorch/Triton Windows feed, unconfirmed | Official package appears and kernel passes | +| Triton XPU / `torch.compile` | Intel / x64 | Stable `triton-xpu==3.8.0` from official PyTorch XPU index | Official index hashes + wheel RECORD | Standalone Intel Triton documents Linux; Windows support is integrated with PyTorch XPU | Official PyTorch XPU index | New tuple passes cold `torch.compile` | +| llama.cpp CUDA x64 | NVIDIA / x64 | Latest complete CUDA 13.3 or 12.4 app + cudart pair from one `bNNNNN` release | GitHub asset SHA-256 digests | WinGet maps only to Vulkan | Backend-specific `ggml.llamacpp` CUDA variant, unconfirmed | Package variant appears and NVIDIA benchmark/inference pass | +| llama.cpp CUDA ARM64 | NVIDIA / ARM64 | Latest complete CUDA 13.4 app + cudart pair; developer-preview stack | GitHub asset SHA-256 digests | WinGet has no ARM64 CUDA variant | Backend-specific `ggml.llamacpp` CUDA ARM64 variant, unconfirmed | Package variant appears and N1X benchmark/inference pass | +| llama.cpp ROCm x64 | AMD / x64 | Latest ROCm 10.0 asset from a complete `bNNNNN` release | GitHub asset SHA-256 digest | WinGet maps only to Vulkan | Backend-specific `ggml.llamacpp` ROCm variant, unconfirmed | Package variant appears and AMD benchmark/inference pass | +| llama.cpp SYCL / OpenVINO x64 | Intel/general / x64 | Latest SYCL or OpenVINO 2026.3.1 asset | GitHub asset SHA-256 digest | WinGet maps only to Vulkan | Backend-specific `ggml.llamacpp` SYCL/OpenVINO variants, unconfirmed | Package variant appears and selected-device benchmark/inference pass | +| llama.cpp OpenCL Adreno ARM64 | Qualcomm / ARM64 | Policy-approved and physically qualified `b10917` Adreno OpenCL asset | GitHub asset SHA-256 digest | WinGet has no ARM64 Adreno variant; newer unsigned b10919 is blocked by managed Defender policy | Backend-specific `ggml.llamacpp` OpenCL ARM64 variant, unconfirmed | New candidate passes policy and Adreno benchmark/inference | +| llama.cpp Vulkan x64 | Cross-vendor / x64 | Latest official rolling Vulkan asset | GitHub asset SHA-256 digest | Current WinGet package cannot coexist as explicit backend variants | `ggml.llamacpp` Vulkan with reliable backend identity | Package backend/version evidence and Vulkan inference pass | +| llama.cpp CPU x64/ARM64 | CPU / x64, ARM64 | Latest official rolling CPU asset | GitHub asset SHA-256 digest | WinGet lacks ARM64 and backend-selectable CPU variants | Backend-specific `ggml.llamacpp` CPU variants, unconfirmed | Package variants appear and CPU inference passes | +| Foundry Local | Cross-vendor / x64, ARM64 | Qualified WinGet 0.10.3 preview; official v2.0.1 tracked | WinGet signature; v2 release hashes recorded | v2 CLI/SDK migration and target-hardware qualification pending | Official v2 release / current stable WinGet | x64+ARM64 provider/inference/cached rerun pass | +| Ollama ARM64 | CPU/NVIDIA / ARM64 | Latest stable official `ollama-windows-arm64.zip`, installed at `%LOCALAPPDATA%\Programs\Ollama` with manifest/startup/PATH lifecycle | GitHub asset SHA-256 | Upstream has no ARM64 setup EXE; x64 setup is never emulated and portable WinGet is not used | Official ARM64 installer or architecture-correct managed WinGet payload | Managed package passes native PE/API/model/backend/upgrade/uninstall acceptance | + +### Partner validation commands + +Use the exact PR head that was statically qualified: + +```powershell +git fetch https://github.com/Kixantrix/WindowsDeveloperConfig.git ` + mihippel-microsoft-windows-ai-setup-workloads +git switch --detach FETCH_HEAD +$ExpectedHead = ( + git ls-remote https://github.com/Kixantrix/WindowsDeveloperConfig.git ` + refs/heads/mihippel-microsoft-windows-ai-setup-workloads +).Split("`t")[0] +if ((git rev-parse HEAD).Trim() -ne $ExpectedHead) { + throw "Checkout does not match published branch head $ExpectedHead." +} +``` + +Always run from the live published branch head selected above; the replacement +PR targets `main` after the former dependent PR #98 closed when its base branch +was deleted. + +Open **elevated PowerShell** in the repository root, then use this harness. It +always inventories first, runs a non-mutating plan, stops on blockers, applies +the same arguments, and requires `result.ready=true`. + +PR/source validation uses unsigned files under `src/`. Before opening the +elevated shell, follow the repository's +[unsigned-development policy](./src/windows-dev-config/README.md#running-it-other-ways) +and temporarily set the test user's `CurrentUser` execution policy to `Bypass`; +restore the prior policy afterward. Release copies under top-level `Workloads/` +are Authenticode-signed. The protected bootstrap explicitly verifies Microsoft +signatures and requests process-scoped `RemoteSigned`; CI additionally validates +that the scripts remain compatible with organization-enforced `AllSigned`, +where a first run may prompt to trust the Microsoft publisher. + +```powershell +$ErrorActionPreference = 'Stop' +$ReportRoot = Join-Path $env:TEMP "devconfig-ai-$env:COMPUTERNAME" +New-Item -ItemType Directory -Path $ReportRoot -Force | Out-Null + +.\src\tools\collect-ai-hardware.ps1 ` + -OutputPath "$ReportRoot\hardware.json" *>&1 | + Tee-Object "$ReportRoot\hardware.console.log" + +function Invoke-PartnerFlow { + param( + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string] $Script, + [hashtable] $Parameters = @{} + ) + $planPath = Join-Path $ReportRoot "$Name-plan.json" + $finalPath = Join-Path $ReportRoot "$Name-report.json" + $planParameters = @{} + $Parameters + $planParameters.PlanOnly = $true + $planParameters.ReportPath = $planPath + & $Script @planParameters *>&1 | + Tee-Object (Join-Path $ReportRoot "$Name-plan.console.log") + $plan = Get-Content $planPath -Raw | ConvertFrom-Json + if ($plan.result.blockers.Count) { + throw "$Name blocked: $($plan.result.blockers -join '; ')" + } + $finalParameters = @{} + $Parameters + $finalParameters.ReportPath = $finalPath + & $Script @finalParameters *>&1 | + Tee-Object (Join-Path $ReportRoot "$Name-report.console.log") + $final = Get-Content $finalPath -Raw | ConvertFrom-Json + if (-not $final.result.ready) { + throw "$Name did not produce result.ready=true." + } +} +``` + +Run the assigned device group: + +```powershell +# NVIDIA Windows x64 +Invoke-PartnerFlow nvidia-cuda .\src\Workloads\cuda\install.ps1 +Invoke-PartnerFlow nvidia-pytorch .\src\Workloads\pytorch\install.ps1 ` + @{ Backend = 'CUDA'; RequireTriton = $true } +Invoke-PartnerFlow nvidia-llama .\src\Workloads\llama.cpp\install.ps1 ` + @{ Backend = 'CUDA' } + +# AMD Windows x64 +Invoke-PartnerFlow pytorch-rocm .\src\Workloads\pytorch\install.ps1 ` + @{ Backend = 'ROCm' } +Invoke-PartnerFlow amd-hip .\src\Workloads\rocm\install.ps1 +Invoke-PartnerFlow amd-llama .\src\Workloads\llama.cpp\install.ps1 ` + @{ Backend = 'ROCm' } + +# Intel Windows x64 GPU +Invoke-PartnerFlow pytorch-xpu .\src\Workloads\pytorch\install.ps1 ` + @{ Backend = 'XPU'; RequireTriton = $true } +Invoke-PartnerFlow intel-openvino-gpu .\src\Workloads\intel-ai\install.ps1 ` + @{ Device = 'GPU'; Profile = 'OpenVINO' } +Invoke-PartnerFlow intel-full-gpu .\src\Workloads\intel-ai\install.ps1 ` + @{ Device = 'GPU'; Profile = 'Full' } +Invoke-PartnerFlow intel-llama-sycl .\src\Workloads\llama.cpp\install.ps1 ` + @{ Backend = 'SYCL' } +Invoke-PartnerFlow intel-llama-openvino .\src\Workloads\llama.cpp\install.ps1 ` + @{ Backend = 'OpenVINO' } + +# Intel Windows x64 NPU (separate from XPU/SYCL GPU paths) +Invoke-PartnerFlow intel-openvino-npu .\src\Workloads\intel-ai\install.ps1 ` + @{ Device = 'NPU'; Profile = 'OpenVINO' } + +# Qualcomm/Adreno Windows ARM64 +Invoke-PartnerFlow qualcomm-llama .\src\Workloads\llama.cpp\install.ps1 ` + @{ Backend = 'OpenCL' } +Invoke-PartnerFlow qualcomm-foundry .\src\Workloads\foundry\install.ps1 +``` + +For example, the AMD and Intel PyTorch commands above write +`$ReportRoot\pytorch-rocm-report.json` and +`$ReportRoot\pytorch-xpu-report.json`, respectively. + +Foundry acceleration is source-managed: its Qualcomm run succeeds with any +truthfully reported provider, including CPU fallback. For same-vendor secondary +adapters, use `-DeviceIndex`, llama.cpp `-Device`, OpenVINO +`-OpenVinoDeviceId`, or oneAPI `-SyclDeviceSelector` as documented above. + +Return the entire `$ReportRoot` directory and state whether any installer +requested or performed a reboot. Success is **not** the presence of +`INSTALL_OK`; the final JSON must have `result.ready=true`, no blockers, and +acceptance evidence for the intended backend/device. + +| Evidence | Required fields or proof | +| --- | --- | +| Host inventory | `host.architecture`, GPU vendor/model/driver in `host.gpus`, and relevant `host.npus` | +| Acquisition | Each `acquisitions[]` action, source/package or artifact identity, version/requirement, integrity metadata, cache/install path | +| Selection | Requested and selected backend/device/profile; explicit adapter selector when used | +| CUDA / HIP | Compiler/runtime version, actual device, compute capability or gfx target, compiled/executed kernel marker | +| PyTorch / Triton | Exact package tuple, runtime (`torch.version.cuda` or `torch.version.hip`), actual device, tensor marker, Triton vector-add or `torch.compile` evidence | +| Intel AI | OpenVINO requested/actual CPU/GPU/NPU device and provider; SYCL actual GPU and kernel marker | +| llama.cpp | Release tag/assets/digests, `backends`, `gpu_info`, actual `offloaded X/Y layers`, model hash and inference marker | +| Foundry | Selected source-managed device/EP, inference marker, `fallbackUsed`; CPU fallback is valid when reported | +| Ollama | Model digest, inference marker, actual backend/process evidence, VRAM bytes and `gpuFraction` when accelerated | +| Outcome | `result.ready=true`, plus all `warnings` and `blockers`; plan and final console logs | + +Partner hardware remains pending for NVIDIA x64, AMD x64, Intel x64 GPU/NPU, +and Qualcomm/Adreno ARM64. Native Windows ARM64 ROCm/XPU and native Windows AMD +Triton remain explicitly upstream-unavailable. + +
+ +## 🎨 Command Palette extension (coming soon) + +A [PowerToys Command Palette](https://learn.microsoft.com/windows/powertoys/command-palette/overview) extension lives under [`src/future/cmdpal/`](./src/future/cmdpal/). It reads the same flow list as the rest of the repo and launches DSC-backed or PowerShell-native flows from one list. + +See [`src/future/cmdpal/README.md`](./src/future/cmdpal/README.md) for build and install instructions. +
## 🩺 Troubleshooting diff --git a/src/Workloads/_common/ai-catalog.psd1 b/src/Workloads/_common/ai-catalog.psd1 new file mode 100644 index 00000000..5d996600 --- /dev/null +++ b/src/Workloads/_common/ai-catalog.psd1 @@ -0,0 +1,709 @@ +@{ + SchemaVersion = 1 + Components = @{ + CudaX64 = @{ + Component = 'NVIDIA CUDA Toolkit' + Vendor = 'NVIDIA' + Architectures = @('X64') + Maturity = 'stable' + SourceType = 'winget' + PackageId = 'Nvidia.CUDA' + VersionPolicy = 'latest applicable stable package' + Integrity = 'WinGet manifest SHA-256 and installer signature' + CachePath = 'WinGet managed' + InstallPath = '%ProgramFiles%\NVIDIA GPU Computing Toolkit\CUDA\v*' + NormalChannelLimitation = 'None' + ExpectedStableSource = 'Nvidia.CUDA' + MigrationTrigger = 'WinGet reports a newer applicable stable package' + CleanupUpgrade = 'WinGet upgrade; vendor uninstaller for removal' + } + CudaArm64 = @{ + Component = 'NVIDIA CUDA Toolkit' + Vendor = 'NVIDIA' + Architectures = @('Arm64') + Maturity = 'qualified-interim-developer-preview' + SourceType = 'direct' + Version = '13.4.0' + Artifact = 'cuda_13.4.0_windows_arm64.exe' + Uri = 'https://packages.nvidia.com/prerelease/cuda/13.4.0/local_installers/cuda_13.4.0_windows_arm64.exe' + Sha256 = 'a1f68c81160b16d519c4087788b9c07de41306c3f1b872471ceee0996621374d' + VersionPolicy = 'exact N1X-qualified interim release' + Integrity = 'Pinned SHA-256 plus valid NVIDIA Corporation Authenticode signature' + CachePath = '%ProgramData%\WindowsDeveloperConfig\cache\nvidia-cuda\13.4.0' + InstallPath = '%ProgramFiles%\NVIDIA GPU Computing Toolkit\CUDA\v13.4' + NormalChannelLimitation = 'Nvidia.CUDA WinGet has no ARM64 payload; stable 13.4.1 direct installer is discovered but not yet N1X workload-qualified' + ExpectedStableSource = 'NVIDIA stable CUDA direct download, then Nvidia.CUDA ARM64 if published' + MigrationTrigger = 'CUDA 13.4.1 passes N1X nvcc compile/kernel and PyTorch Triton JIT acceptance' + CleanupUpgrade = 'Qualify newer version side-by-side, then use NVIDIA uninstaller for the old interim release' + PromotionCandidate = @{ + Version = '13.4.1' + Maturity = 'official-stable-direct-candidate' + Artifact = 'cuda_13.4.1_windows_arm64.exe' + Uri = 'https://developer.download.nvidia.com/compute/cuda/13.4.1/local_installers/cuda_13.4.1_windows_arm64.exe' + Sha256 = '39af79e5e136c4e0de03bba816bda60fd7b70aad033e37ecaacf9f2e2c982442' + Size = 3711598920 + Authenticity = 'Valid NVIDIA Corporation Authenticode signature verified' + TrackingStatus = 'awaiting N1X kernel and Triton qualification' + } + } + FoundryLocal = @{ + Component = 'Foundry Local' + Vendor = 'Microsoft' + Architectures = @('X64', 'Arm64') + Maturity = 'qualified-preview' + SourceType = 'winget' + PackageId = 'Microsoft.FoundryLocal' + VersionPolicy = 'latest applicable qualified preview package' + Integrity = 'WinGet manifest SHA-256 and MSIX signature' + CachePath = 'Foundry cache reported by foundry cache location' + InstallPath = 'Per-user MSIX' + NormalChannelLimitation = 'WinGet remains 0.10.3 preview; official v2.0.1 is a candidate with a new SDK/API and is not yet workload-qualified' + ExpectedStableSource = 'Official Foundry Local v2 release or a current stable Microsoft.FoundryLocal package' + MigrationTrigger = 'v2.0.1 passes x64/ARM64 installation, provider registration, cached rerun, and real model inference' + CleanupUpgrade = 'Preserve model cache while replacing the qualified runtime' + PromotionCandidate = @{ + Version = '2.0.1' + Maturity = 'official-non-prerelease-candidate; Python package metadata remains alpha' + Repository = 'microsoft/Foundry-Local' + PythonRequirement = 'foundry-local-sdk==2.0.1' + X64Asset = 'foundry-local-win-x64.zip' + X64Sha256 = '0551db07d5cba6a523e4c1832f0d38e023301ab67b946378239f8cee156ba5a4' + Arm64Asset = 'foundry-local-win-arm64.zip' + Arm64Sha256 = '2fa8510281cfaa554e21ffae8de41366a08051bce92fd592b919bc4413b57b09' + TrackingStatus = 'awaiting v2 CLI/SDK migration and x64/ARM64 real inference qualification' + } + } + NvidiaPyTorchArm64 = @{ + Component = 'PyTorch CUDA for Windows ARM64' + Vendor = 'NVIDIA/PyTorch' + Architectures = @('Arm64') + Maturity = 'qualified-interim-nightly' + SourceType = 'direct-python-wheel' + Version = '2.15.0.dev20260904+cu134' + Uri = 'https://pypi.nvidia.com/nvtorch_oot_nightly/torch/torch-2.15.0.dev20260904%2Bcu134-cp313-cp313-win_arm64.whl' + Sha256 = 'af0872854d183cb6894dbd5b1e5e9291875ce139d138b5fc0b501498828265d3' + VersionPolicy = 'exact N1X-qualified interim nightly' + Integrity = 'Pinned SHA-256; dependencies resolve from the configured primary Python index' + CachePath = '%LOCALAPPDATA%\DevConfig\pytorch\wheel-cache' + InstallPath = '%LOCALAPPDATA%\DevConfig\pytorch\.venv' + NormalChannelLimitation = 'Stable NVIDIA nvtorch_oot tuple is published but has not yet passed the N1X tensor/Triton qualification' + ExpectedStableSource = 'NVIDIA stable nvtorch_oot index' + MigrationTrigger = 'Stable 2.14.0 cu134 trio passes imports, N1X CUDA tensor, and Triton vector-add' + CleanupUpgrade = 'Replace contained venv; retain only qualified wheel cache entries' + NativeToolkitRequired = $false + NativeToolkitRelationship = 'The wheel carries the CUDA runtime. The standalone cuda flow is for native CUDA development; this setup acquires compiler/toolkit components only for supported Triton JIT.' + PromotionCandidate = @{ + Maturity = 'official-out-of-tree-stable-candidate' + IndexUrl = 'https://pypi.nvidia.com/nvtorch_oot/' + Torch = 'torch==2.14.0+cu134' + TorchSha256 = '4f781babc0e0e0722cc48d0b15107a28e6003fc2b6544f1578b6eb6f5177dcb5' + Torchvision = 'torchvision==0.29.0+cu134' + TorchvisionSha256 = 'e935037b6a97c32642d47f73da8cf62acf6453bfe15825314f774f62ec395d26' + Torchaudio = 'torchaudio==2.11.0+cu134' + TorchaudioSha256 = 'e4f18fa7359528416964d525ba620a0ca95ad231d6ab573b26c8b09c6ea8bf6b' + TrackingStatus = 'awaiting N1X trio import, tensor, and Triton qualification' + } + } + PyTorchCpu = @{ + Component = 'PyTorch CPU' + Vendor = 'PyTorch' + Architectures = @('X64', 'Arm64') + Maturity = 'stable' + SourceType = 'pytorch-index' + IndexUrl = 'https://download.pytorch.org/whl/cpu' + Version = '2.14.0' + VersionPolicy = 'exact stable backend-qualified wheel' + Integrity = 'Official PyTorch package index hashes and wheel RECORD' + CachePath = 'Python package cache' + InstallPath = '%LOCALAPPDATA%\DevConfig\pytorch\.venv' + NormalChannelLimitation = 'None' + ExpectedStableSource = 'https://download.pytorch.org/whl/cpu' + MigrationTrigger = 'New stable tuple passes CPU tensor acceptance' + CleanupUpgrade = 'Replace contained venv' + NativeToolkitRequired = $false + NativeToolkitRelationship = 'No vendor toolkit is required.' + } + PyTorchCudaX64 = @{ + Component = 'PyTorch CUDA' + Vendor = 'NVIDIA/PyTorch' + Architectures = @('X64') + Maturity = 'stable' + SourceType = 'pytorch-index' + IndexUrl = 'https://download.pytorch.org/whl/cu126 or cu130' + Version = '2.14.0' + VersionPolicy = 'exact stable wheel selected by GPU capability and driver branch' + Integrity = 'Official PyTorch package index hashes and wheel RECORD' + CachePath = 'Python package cache' + InstallPath = '%LOCALAPPDATA%\DevConfig\pytorch\.venv' + NormalChannelLimitation = 'None' + ExpectedStableSource = 'Official PyTorch CUDA index' + MigrationTrigger = 'New stable runtime tuple passes CUDA tensor and Triton acceptance' + CleanupUpgrade = 'Replace contained venv' + NativeToolkitRequired = $false + NativeToolkitRelationship = 'The wheel carries the CUDA runtime. The standalone cuda flow is for nvcc/native development; this setup acquires CUDA/MSVC only when Triton JIT requires toolchain components.' + } + PyTorchRocm = @{ + Component = 'PyTorch ROCm' + Vendor = 'AMD/PyTorch' + Architectures = @('X64') + Maturity = 'stable' + SourceType = 'amd-python-index' + IndexUrl = 'https://stable.repo.amd.com/rocm/whl-next/' + Version = '2.13.0+rocm10.0.0' + VersionPolicy = 'exact production tuple and exact supported gfx target' + Integrity = 'Official AMD HTTPS feed allowlist and wheel RECORD' + CachePath = 'Python package cache' + InstallPath = '%LOCALAPPDATA%\DevConfig\pytorch\.venv' + NormalChannelLimitation = 'Default PyPI does not publish the AMD ROCm Windows build' + ExpectedStableSource = 'AMD stable ROCm package feed' + MigrationTrigger = 'New production tuple lists the GPU and tensor acceptance passes' + CleanupUpgrade = 'Replace contained venv' + NativeToolkitRequired = $false + NativeToolkitRelationship = 'The AMD device-specific PyTorch tuple carries its ROCm runtime dependencies. The standalone rocm flow is not a prerequisite; it is for hipcc/native HIP kernel development.' + } + PyTorchXpu = @{ + Component = 'PyTorch XPU' + Vendor = 'Intel/PyTorch' + Architectures = @('X64') + Maturity = 'stable' + SourceType = 'pytorch-index' + IndexUrl = 'https://download.pytorch.org/whl/xpu' + Version = '2.14.0+xpu' + VersionPolicy = 'exact stable XPU tuple' + Integrity = 'Official PyTorch package index hashes and wheel RECORD' + CachePath = 'Python package cache' + InstallPath = '%LOCALAPPDATA%\DevConfig\pytorch\.venv' + NormalChannelLimitation = 'Default PyPI does not publish the Intel XPU build' + ExpectedStableSource = 'https://download.pytorch.org/whl/xpu' + MigrationTrigger = 'New stable tuple passes XPU tensor and torch.compile acceptance' + CleanupUpgrade = 'Replace contained venv' + NativeToolkitRequired = $false + NativeToolkitRelationship = 'The official XPU wheel tuple carries the PyTorch runtime and does not install full oneAPI. The standalone intel-ai SYCL/Full profiles install oneAPI only for native SYCL development.' + } + TritonWindows = @{ + Component = 'Triton Windows' + Vendor = 'Triton project' + Architectures = @('X64', 'Arm64') + Maturity = 'community' + SourceType = 'pypi' + Package = 'triton-windows==3.8.0.post28' + VersionPolicy = 'exact qualified build matched to PyTorch' + Integrity = 'Python package index TLS and wheel RECORD' + CachePath = 'Python package cache' + InstallPath = 'PyTorch contained venv' + NormalChannelLimitation = 'Upstream Triton does not publish a general stable Windows package' + ExpectedStableSource = 'Official PyTorch/Triton Windows package feed (unconfirmed)' + MigrationTrigger = 'Official Windows package is published and vector-add acceptance passes' + CleanupUpgrade = 'Replace contained venv when PyTorch/Triton tuple changes' + } + TritonXpu = @{ + Component = 'Triton XPU' + Vendor = 'Intel/PyTorch' + Architectures = @('X64') + Maturity = 'stable-integrated' + SourceType = 'pytorch-index' + Package = 'triton-xpu==3.8.0' + VersionPolicy = 'exact PyTorch XPU-compatible tuple' + Integrity = 'Official PyTorch XPU index hash and wheel RECORD' + CachePath = 'Python package cache' + InstallPath = 'PyTorch contained venv' + NormalChannelLimitation = 'Standalone Intel Triton still documents Linux; Windows support is through PyTorch torch.compile' + ExpectedStableSource = 'Official PyTorch XPU index' + MigrationTrigger = 'New PyTorch XPU tuple passes cold torch.compile acceptance' + CleanupUpgrade = 'Replace contained venv' + } + LlamaCppRolling = @{ + Component = 'llama.cpp Windows binaries' + Vendor = 'ggml-org' + Architectures = @('X64', 'Arm64') + Maturity = 'rolling' + SourceType = 'github-release' + Repository = 'ggml-org/llama.cpp' + BackendAssets = @{ + CpuX64 = @{ + Backend = 'CPU' + Vendor = 'CPU' + Architecture = 'X64' + Runtime = 'CPU x64' + Patterns = @('^llama-b[0-9]+-bin-win-cpu-x64\.zip$') + } + CpuArm64 = @{ + Backend = 'CPU' + Vendor = 'CPU' + Architecture = 'Arm64' + Runtime = 'CPU ARM64' + Patterns = @('^llama-b[0-9]+-bin-win-cpu-arm64\.zip$') + } + Cuda124X64 = @{ + Backend = 'CUDA' + Vendor = 'NVIDIA' + Architecture = 'X64' + Runtime = 'CUDA 12.4' + Patterns = @( + '^llama-b[0-9]+-bin-win-cuda-12\.4-x64\.zip$', + '^cudart-llama-bin-win-cuda-12\.4-x64\.zip$' + ) + } + Cuda133X64 = @{ + Backend = 'CUDA' + Vendor = 'NVIDIA' + Architecture = 'X64' + Runtime = 'CUDA 13.3' + Patterns = @( + '^llama-b[0-9]+-bin-win-cuda-13\.3-x64\.zip$', + '^cudart-llama-bin-win-cuda-13\.3-x64\.zip$' + ) + } + Cuda134Arm64 = @{ + Backend = 'CUDA' + Vendor = 'NVIDIA' + Architecture = 'Arm64' + Runtime = 'CUDA 13.4 Developer Preview' + Maturity = 'rolling-developer-preview' + Patterns = @( + '^llama-b[0-9]+-bin-win-cuda-13\.4-arm64\.zip$', + '^cudart-llama-bin-win-cuda-13\.4-arm64\.zip$' + ) + } + Rocm10X64 = @{ + Backend = 'ROCm' + Vendor = 'AMD' + Architecture = 'X64' + Runtime = 'ROCm 10.0' + Patterns = @('^llama-b[0-9]+-bin-win-rocm-10\.0-x64\.zip$') + } + SyclX64 = @{ + Backend = 'SYCL' + Vendor = 'Intel' + Architecture = 'X64' + Runtime = 'SYCL' + Patterns = @('^llama-b[0-9]+-bin-win-sycl-x64\.zip$') + } + OpenVinoX64 = @{ + Backend = 'OpenVINO' + Vendor = 'Intel/general' + Architecture = 'X64' + Runtime = 'OpenVINO 2026.3.1' + Patterns = @('^llama-b[0-9]+-bin-win-openvino-2026\.3\.1-x64\.zip$') + } + VulkanX64 = @{ + Backend = 'Vulkan' + Vendor = 'Cross-vendor' + Architecture = 'X64' + Runtime = 'Vulkan' + Patterns = @('^llama-b[0-9]+-bin-win-vulkan-x64\.zip$') + } + OpenClAdrenoArm64 = @{ + Backend = 'OpenCL' + Vendor = 'Qualcomm' + Architecture = 'Arm64' + Runtime = 'OpenCL Adreno' + Patterns = @('^llama-b10917-bin-win-opencl-adreno-arm64\.zip$') + VersionPolicy = 'pinned b10917 qualified on Qualcomm ARM64; b10919 is blocked by managed Defender ransomware protection' + } + } + VersionPolicy = 'newest bNNNNN release containing a complete backend asset set' + Integrity = 'GitHub release asset SHA-256 digest' + CachePath = '%LOCALAPPDATA%\DevConfig\llama.cpp\asset-cache' + InstallPath = '%LOCALAPPDATA%\DevConfig\llama.cpp\runtime' + NormalChannelLimitation = 'WinGet ggml.llamacpp currently maps only to the x64 Vulkan variant and does not expose backend-specific package choices' + ExpectedStableSource = 'ggml.llamacpp backend-specific package variants when published; otherwise unconfirmed' + MigrationTrigger = 'WinGet publishes the required backend for the host and inference/benchmark acceptance passes' + CleanupUpgrade = 'Reuse digest-verified asset cache and atomically replace resolver-owned runtime directory' + } + OllamaX64 = @{ + Component = 'Ollama' + Vendor = 'Ollama' + Architectures = @('X64') + Maturity = 'stable' + SourceType = 'winget' + PackageId = 'Ollama.Ollama' + VersionPolicy = 'latest applicable stable package' + Integrity = 'WinGet manifest SHA-256 and installer signature' + CachePath = '%USERPROFILE%\.ollama\models or OLLAMA_MODELS' + InstallPath = 'Per-user application' + NormalChannelLimitation = 'None' + ExpectedStableSource = 'Ollama.Ollama' + MigrationTrigger = 'WinGet reports a newer applicable stable package' + CleanupUpgrade = 'WinGet upgrade; ollama rm for models' + } + OllamaArm64 = @{ + Component = 'Ollama managed native ARM64 application' + Vendor = 'Ollama' + Architectures = @('Arm64') + Maturity = 'stable-direct' + SourceType = 'native-arm64-managed-archive' + Repository = 'ollama/ollama' + AssetPattern = '^ollama-windows-arm64\.zip$' + VersionPolicy = 'latest non-prerelease release' + Integrity = 'GitHub release asset SHA-256 digest' + CachePath = '%LOCALAPPDATA%\DevConfig\ollama\asset-cache' + InstallPath = '%LOCALAPPDATA%\Programs\Ollama' + StartupRegistration = 'HKCU Run: WindowsDeveloperConfig.Ollama' + InstallManifest = '%LOCALAPPDATA%\Programs\Ollama\.devconfig-install.json' + NormalChannelLimitation = 'Ollama.Ollama uses the x64 setup EXE; upstream publishes native ARM64 only as an archive' + ExpectedStableSource = 'Official ARM64 setup EXE or architecture-correct non-portable WinGet payload' + MigrationTrigger = 'Official managed ARM64 installer/package appears and passes native process, API, model, and backend-allocation acceptance' + CleanupUpgrade = 'Stop only Dev Config-managed processes; reuse verified cache; atomic runtime replacement; preserve models by default on uninstall' + } + AmdRocm = @{ + Component = 'AMD ROCm Core SDK' + Vendor = 'AMD' + Architectures = @('X64') + Maturity = 'stable' + SourceType = 'amd-python-index' + IndexUrl = 'https://stable.repo.amd.com/rocm/whl-next/' + Version = '10.0.0' + PackageTemplate = 'rocm[libraries,devel,device-{0}]==10.0.0' + VersionPolicy = 'exact production tuple and exact supported gfx target' + Integrity = 'Exact AMD HTTPS feed allowlist and wheel RECORD; AMD feed does not publish SHA-256 fragments' + CachePath = 'Python package cache' + InstallPath = '%LOCALAPPDATA%\DevConfig\rocm\.venv' + NormalChannelLimitation = 'No WinGet package and not published on the default PyPI channel' + ExpectedStableSource = 'AMD stable ROCm package feed; WinGet package unconfirmed' + MigrationTrigger = 'New AMD production tuple lists the exact GPU in Windows compatibility data and HIP kernel acceptance passes' + CleanupUpgrade = 'Replace versioned contained environment after HIP kernel validation' + } + IntelOpenVino = @{ + Component = 'Intel OpenVINO Runtime/GenAI' + Vendor = 'Intel' + Architectures = @('X64') + Maturity = 'stable' + SourceType = 'pypi' + Packages = @('openvino==2026.3.1', 'openvino-tokenizers==2026.3.1.0', 'openvino-genai==2026.3.1.0') + VersionPolicy = 'exact matched regular release tuple' + Integrity = 'Official PyPI wheel hashes/RECORD; contained environment' + CachePath = 'Python package cache' + InstallPath = '%LOCALAPPDATA%\DevConfig\intel-ai\openvino\.venv' + NormalChannelLimitation = 'WinGet C++ package is community-maintained and can lag the Python runtime' + ExpectedStableSource = 'Official PyPI OpenVINO packages' + MigrationTrigger = 'Matched newer regular/LTS tuple passes selected-device inference' + CleanupUpgrade = 'Replace contained environment' + } + IntelOneApi = @{ + Component = 'Intel oneAPI Toolkit' + Vendor = 'Intel' + Architectures = @('X64') + Maturity = 'stable' + SourceType = 'winget' + PackageId = 'Intel.OneAPI.Toolkit' + Version = '2026.0.0.193' + VersionPolicy = 'latest qualified stable WinGet package' + Integrity = 'WinGet manifest SHA-256 and Intel installer signature' + CachePath = 'WinGet managed' + InstallPath = '%ProgramFiles(x86)%\Intel\oneAPI' + NormalChannelLimitation = 'None' + ExpectedStableSource = 'Intel.OneAPI.Toolkit' + MigrationTrigger = 'New stable WinGet version passes SYCL kernel acceptance' + CleanupUpgrade = 'WinGet upgrade; Intel installer for removal' + } + } + CapabilityMatrix = @( + @{ + Id = 'cuda-nvidia-x64' + Workload = 'cuda'; Architecture = 'X64'; Vendor = 'NVIDIA'; DeviceFamily = 'CUDA 13-supported CC7.5+ GPU'; Backend = 'CUDA' + Status = 'implemented-supported'; Maturity = 'stable'; Acquisition = @('component:CudaX64', 'winget:Microsoft.VisualStudio.2022.BuildTools') + Prerequisites = 'NVIDIA driver 580+ and compute capability 7.5+ for current stable GPU readiness; toolkit-only mode is explicit' + Resolver = 'Resolve-CudaInstallPlan'; ResolverArguments = @{ Architecture = 'X64' }; Expected = @{ Method = 'WinGet' } + ProbePath = 'src/Workloads/cuda/smoke.cu'; ReportEvidence = 'nvcc, compiler path, driver/device/compute capability, compiled and executed kernel' + PartnerCommand = '.\src\Workloads\cuda\install.ps1 -ReportPath "$env:TEMP\cuda-x64-report.json"' + } + @{ + Id = 'cuda-nvidia-arm64' + Workload = 'cuda'; Architecture = 'Arm64'; Vendor = 'NVIDIA'; DeviceFamily = 'RTX Spark-class CC12.x'; Backend = 'CUDA' + Status = 'implemented-supported'; Maturity = 'qualified-interim-developer-preview'; Acquisition = @('component:CudaArm64', 'winget:Microsoft.VisualStudio.2022.BuildTools') + Prerequisites = 'Windows 11, driver 616+, RTX Spark-class NVIDIA GPU' + Resolver = 'Resolve-CudaInstallPlan'; ResolverArguments = @{ Architecture = 'Arm64'; WindowsBuild = 28120 }; Expected = @{ Method = 'NvidiaInstaller'; ToolkitVersion = '13.4' } + ProbePath = 'src/Workloads/cuda/smoke.cu'; ReportEvidence = 'pinned installer hash/signature, ARM64 compiler, nvcc, N1X driver/device, executed kernel' + PartnerCommand = '.\src\Workloads\cuda\install.ps1 -ReportPath "$env:TEMP\cuda-arm64-report.json"' + } + @{ + Id = 'rocm-amd-x64' + Workload = 'rocm'; Architecture = 'X64'; Vendor = 'AMD'; DeviceFamily = 'ROCm 10 Windows gfx matrix'; Backend = 'HIP' + Status = 'implemented-supported'; Maturity = 'stable'; Acquisition = @('component:AmdRocm') + Prerequisites = 'AMD GPU marketing name must map to a published gfx target' + Resolver = 'Resolve-RocmInstallPlan'; ResolverArguments = @{ Architecture = 'X64'; GpuName = 'AMD Radeon RX 9070 XT' }; Expected = @{ GfxTarget = 'gfx1201' } + ProbePath = 'src/Workloads/rocm/hip-smoke.cpp'; ReportEvidence = 'AMD device, gfx target, hipcc/runtime tuple, compiled and executed HIP kernel' + PartnerCommand = '.\src\Workloads\rocm\install.ps1 -ReportPath "$env:TEMP\rocm-hip-report.json"' + } + @{ + Id = 'intel-openvino-cpu-x64' + Workload = 'intel-ai'; Architecture = 'X64'; Vendor = 'Intel/general'; DeviceFamily = 'CPU'; Backend = 'OpenVINO' + Status = 'implemented-supported'; Maturity = 'stable'; Acquisition = @('component:IntelOpenVino') + Prerequisites = 'Windows x64 and CPython 3.13' + Resolver = 'Resolve-IntelAiPlan'; ResolverArguments = @{ Architecture = 'X64'; Device = 'CPU'; Profile = 'OpenVINO' }; Expected = @{ Device = 'CPU'; InstallOpenVino = $true } + ProbePath = 'src/Workloads/intel-ai/openvino-smoke.py'; ReportEvidence = 'requested and actual OpenVINO device, full device name, generated-model inference' + PartnerCommand = '.\src\Workloads\intel-ai\install.ps1 -Device CPU -Profile OpenVINO -ReportPath "$env:TEMP\intel-openvino-cpu-report.json"' + } + @{ + Id = 'intel-openvino-gpu-x64' + Workload = 'intel-ai'; Architecture = 'X64'; Vendor = 'Intel'; DeviceFamily = 'OpenVINO-supported Intel GPU'; Backend = 'OpenVINO' + Status = 'implemented-supported'; Maturity = 'stable'; Acquisition = @('component:IntelOpenVino') + Prerequisites = 'Detected Intel display adapter and installed compatible driver' + Resolver = 'Resolve-IntelAiPlan'; ResolverArguments = @{ Architecture = 'X64'; Device = 'GPU'; Profile = 'OpenVINO'; IntelGpuPresent = $true }; Expected = @{ Device = 'GPU'; InstallOpenVino = $true } + ProbePath = 'src/Workloads/intel-ai/openvino-smoke.py'; ReportEvidence = 'actual OpenVINO GPU and full device name, generated-model inference' + PartnerCommand = '.\src\Workloads\intel-ai\install.ps1 -Device GPU -Profile OpenVINO -ReportPath "$env:TEMP\intel-openvino-gpu-report.json"' + } + @{ + Id = 'intel-openvino-npu-x64' + Workload = 'intel-ai'; Architecture = 'X64'; Vendor = 'Intel'; DeviceFamily = 'Intel AI Boost/OpenVINO NPU'; Backend = 'OpenVINO' + Status = 'implemented-supported'; Maturity = 'stable'; Acquisition = @('component:IntelOpenVino') + Prerequisites = 'Detected Intel NPU and compatible installed driver' + Resolver = 'Resolve-IntelAiPlan'; ResolverArguments = @{ Architecture = 'X64'; Device = 'NPU'; Profile = 'OpenVINO'; IntelNpuPresent = $true }; Expected = @{ Device = 'NPU'; InstallOpenVino = $true } + ProbePath = 'src/Workloads/intel-ai/openvino-smoke.py'; ReportEvidence = 'actual OpenVINO NPU and full device name, generated-model inference' + PartnerCommand = '.\src\Workloads\intel-ai\install.ps1 -Device NPU -Profile OpenVINO -ReportPath "$env:TEMP\intel-openvino-npu-report.json"' + } + @{ + Id = 'intel-sycl-gpu-x64' + Workload = 'intel-ai'; Architecture = 'X64'; Vendor = 'Intel'; DeviceFamily = 'oneAPI-supported Intel GPU'; Backend = 'SYCL' + Status = 'implemented-supported'; Maturity = 'stable'; Acquisition = @('component:IntelOneApi') + Prerequisites = 'Detected Intel GPU and compatible installed driver' + Resolver = 'Resolve-IntelAiPlan'; ResolverArguments = @{ Architecture = 'X64'; Device = 'GPU'; Profile = 'SYCL'; IntelGpuPresent = $true }; Expected = @{ Device = 'GPU'; InstallOneApi = $true } + ProbePath = 'src/Workloads/intel-ai/sycl-smoke.cpp'; ReportEvidence = 'oneAPI compiler/runtime, selected Intel GPU, compiled and executed SYCL kernel' + PartnerCommand = '.\src\Workloads\intel-ai\install.ps1 -Device GPU -Profile SYCL -ReportPath "$env:TEMP\intel-sycl-report.json"' + } + @{ + Id = 'intel-full-gpu-x64' + Workload = 'intel-ai'; Architecture = 'X64'; Vendor = 'Intel'; DeviceFamily = 'OpenVINO/oneAPI-supported Intel GPU'; Backend = 'OpenVINO+SYCL' + Status = 'implemented-supported'; Maturity = 'stable'; Acquisition = @('component:IntelOpenVino', 'component:IntelOneApi') + Prerequisites = 'Detected Intel GPU and compatible installed driver' + Resolver = 'Resolve-IntelAiPlan'; ResolverArguments = @{ Architecture = 'X64'; Device = 'GPU'; Profile = 'Full'; IntelGpuPresent = $true }; Expected = @{ Device = 'GPU'; InstallOpenVino = $true; InstallOneApi = $true } + ProbePath = 'src/Workloads/intel-ai/install.ps1'; ReportEvidence = 'actual OpenVINO GPU inference plus compiled and executed oneAPI SYCL kernel' + PartnerCommand = '.\src\Workloads\intel-ai\install.ps1 -Device GPU -Profile Full -ReportPath "$env:TEMP\intel-full-report.json"' + } + @{ + Id = 'pytorch-cpu-x64' + Workload = 'pytorch'; Architecture = 'X64'; Vendor = 'CPU'; DeviceFamily = 'x64 CPU'; Backend = 'CPU' + Status = 'implemented-supported'; Maturity = 'stable'; Acquisition = @('component:PyTorchCpu', 'winget:Python.Python.3.13') + Prerequisites = 'Windows x64' + Resolver = 'Resolve-PyTorchPlan'; ResolverArguments = @{ Architecture = 'X64'; Backend = 'CPU'; PythonVersion = '3.13' }; Expected = @{ Backend = 'CPU'; Runtime = 'cpu' } + ProbePath = 'src/Workloads/pytorch/smoke.py'; ReportEvidence = 'exact wheel tuple, CPU device, tensor and NumPy bridge' + PartnerCommand = '.\src\Workloads\pytorch\install.ps1 -Backend CPU -ReportPath "$env:TEMP\pytorch-cpu-x64-report.json"' + } + @{ + Id = 'pytorch-cpu-arm64' + Workload = 'pytorch'; Architecture = 'Arm64'; Vendor = 'CPU'; DeviceFamily = 'ARM64 CPU'; Backend = 'CPU' + Status = 'implemented-supported'; Maturity = 'stable'; Acquisition = @('component:PyTorchCpu', 'winget:Python.Python.3.13') + Prerequisites = 'Windows ARM64' + Resolver = 'Resolve-PyTorchPlan'; ResolverArguments = @{ Architecture = 'Arm64'; Backend = 'CPU'; PythonVersion = '3.13' }; Expected = @{ Backend = 'CPU'; Runtime = 'cpu' } + ProbePath = 'src/Workloads/pytorch/smoke.py'; ReportEvidence = 'native ARM64 CPU wheel, CPU tensor and NumPy bridge' + PartnerCommand = '.\src\Workloads\pytorch\install.ps1 -Backend CPU -ReportPath "$env:TEMP\pytorch-cpu-arm64-report.json"' + } + @{ + Id = 'pytorch-cuda-x64' + Workload = 'pytorch'; Architecture = 'X64'; Vendor = 'NVIDIA'; DeviceFamily = 'CUDA-capable GPU'; Backend = 'CUDA' + Status = 'implemented-supported'; Maturity = 'stable'; Acquisition = @('component:PyTorchCudaX64', 'winget:Python.Python.3.13') + Prerequisites = 'Compute capability 5.0+, driver 525+; CUDA 13-class GPUs require driver 580+' + Resolver = 'Resolve-PyTorchPlan'; ResolverArguments = @{ Architecture = 'X64'; Backend = 'CUDA'; PythonVersion = '3.13'; HasNvidia = $true; DriverMajor = 580; ComputeCapability = '8.9'; GpuName = 'NVIDIA GeForce RTX 4090' }; Expected = @{ Backend = 'CUDA'; Runtime = 'cu130' } + ProbePath = 'src/Workloads/pytorch/smoke.py'; ReportEvidence = 'NVIDIA device, torch CUDA runtime, exact wheel tuple, executed tensor' + PartnerCommand = '.\src\Workloads\pytorch\install.ps1 -Backend CUDA -ReportPath "$env:TEMP\pytorch-cuda-x64-report.json"' + } + @{ + Id = 'pytorch-cuda-arm64' + Workload = 'pytorch'; Architecture = 'Arm64'; Vendor = 'NVIDIA'; DeviceFamily = 'RTX Spark-class CC12.x'; Backend = 'CUDA' + Status = 'implemented-supported'; Maturity = 'qualified-interim-nightly'; Acquisition = @('component:NvidiaPyTorchArm64', 'winget:Python.Python.3.13') + Prerequisites = 'CPython 3.13, driver 616+, compute capability 12.x' + Resolver = 'Resolve-PyTorchPlan'; ResolverArguments = @{ Architecture = 'Arm64'; Backend = 'CUDA'; PythonVersion = '3.13'; HasNvidia = $true; DriverMajor = 616; ComputeCapability = '12.1'; GpuName = 'NVIDIA RTX Spark N1X' }; Expected = @{ Backend = 'CUDA'; Runtime = 'cu134' } + ProbePath = 'src/Workloads/pytorch/smoke.py'; ReportEvidence = 'pinned wheel hash, N1X device, torch CUDA 13.4 tensor' + PartnerCommand = '.\src\Workloads\pytorch\install.ps1 -Backend CUDA -ReportPath "$env:TEMP\pytorch-cuda-arm64-report.json"' + } + @{ + Id = 'pytorch-rocm-x64' + Workload = 'pytorch'; Architecture = 'X64'; Vendor = 'AMD'; DeviceFamily = 'ROCm 10 Windows gfx matrix'; Backend = 'ROCm' + Status = 'implemented-supported'; Maturity = 'stable'; Acquisition = @('component:PyTorchRocm', 'winget:Python.Python.3.13') + Prerequisites = 'Exact supported AMD GPU/gfx target' + Resolver = 'Resolve-PyTorchPlan'; ResolverArguments = @{ Architecture = 'X64'; Backend = 'ROCm'; PythonVersion = '3.13'; HasAmd = $true; AmdGpuName = 'AMD Radeon RX 9070 XT'; AmdGfxTarget = 'gfx1201' }; Expected = @{ Backend = 'ROCm'; AmdGfxTarget = 'gfx1201' } + ProbePath = 'src/Workloads/pytorch/smoke.py'; ReportEvidence = 'exact AMD package tuple, non-null torch.version.hip, AMD device/gfx, tensor' + PartnerCommand = '.\src\Workloads\pytorch\install.ps1 -Backend ROCm -ReportPath "$env:TEMP\pytorch-rocm-report.json"' + } + @{ + Id = 'pytorch-xpu-x64' + Workload = 'pytorch'; Architecture = 'X64'; Vendor = 'Intel'; DeviceFamily = 'validated Intel XPU GPU families'; Backend = 'XPU' + Status = 'implemented-supported'; Maturity = 'stable'; Acquisition = @('component:PyTorchXpu', 'winget:Python.Python.3.13') + Prerequisites = 'Supported Intel GPU and compatible driver' + Resolver = 'Resolve-PyTorchPlan'; ResolverArguments = @{ Architecture = 'X64'; Backend = 'XPU'; PythonVersion = '3.13'; HasIntel = $true; IntelGpuName = 'Intel Arc B580 Graphics' }; Expected = @{ Backend = 'XPU'; Runtime = 'xpu' } + ProbePath = 'src/Workloads/pytorch/smoke.py'; ReportEvidence = 'Intel XPU device, exact wheel tuple, executed tensor' + PartnerCommand = '.\src\Workloads\pytorch\install.ps1 -Backend XPU -ReportPath "$env:TEMP\pytorch-xpu-report.json"' + } + @{ + Id = 'triton-cuda-x64' + Workload = 'pytorch-triton'; Architecture = 'X64'; Vendor = 'NVIDIA'; DeviceFamily = 'CUDA CC8.0+'; Backend = 'CUDA' + Status = 'implemented-supported'; Maturity = 'community'; Acquisition = @('component:PyTorchCudaX64', 'component:TritonWindows') + Prerequisites = 'Compatible PyTorch CUDA tuple, CC8.0+, MSVC/CUDA JIT toolchain' + Resolver = 'Resolve-PyTorchPlan'; ResolverArguments = @{ Architecture = 'X64'; Backend = 'CUDA'; PythonVersion = '3.13'; HasNvidia = $true; DriverMajor = 580; ComputeCapability = '8.9'; GpuName = 'NVIDIA GeForce RTX 4090' }; Expected = @{ InstallTriton = $true } + ProbePath = 'src/Workloads/pytorch/triton-smoke.py'; ReportEvidence = 'triton-windows version and executed vector-add kernel' + PartnerCommand = '.\src\Workloads\pytorch\install.ps1 -Backend CUDA -RequireTriton -ReportPath "$env:TEMP\triton-cuda-x64-report.json"' + } + @{ + Id = 'triton-cuda-arm64' + Workload = 'pytorch-triton'; Architecture = 'Arm64'; Vendor = 'NVIDIA'; DeviceFamily = 'RTX Spark CC12.x'; Backend = 'CUDA' + Status = 'implemented-supported'; Maturity = 'community-stable-on-qualified-interim'; Acquisition = @('component:NvidiaPyTorchArm64', 'component:TritonWindows') + Prerequisites = 'Qualified ARM64 PyTorch CUDA preview, MSVC ARM64, CUDA 13.4' + Resolver = 'Resolve-PyTorchPlan'; ResolverArguments = @{ Architecture = 'Arm64'; Backend = 'CUDA'; PythonVersion = '3.13'; HasNvidia = $true; DriverMajor = 616; ComputeCapability = '12.1'; GpuName = 'NVIDIA RTX Spark N1X' }; Expected = @{ InstallTriton = $true } + ProbePath = 'src/Workloads/pytorch/triton-smoke.py'; ReportEvidence = 'triton-windows version and N1X vector-add JIT kernel' + PartnerCommand = '.\src\Workloads\pytorch\install.ps1 -Backend CUDA -RequireTriton -ReportPath "$env:TEMP\triton-cuda-arm64-report.json"' + } + @{ + Id = 'triton-xpu-x64' + Workload = 'pytorch-triton'; Architecture = 'X64'; Vendor = 'Intel'; DeviceFamily = 'validated Intel XPU GPU families'; Backend = 'XPU' + Status = 'implemented-supported'; Maturity = 'stable-integrated'; Acquisition = @('component:PyTorchXpu', 'component:TritonXpu') + Prerequisites = 'Supported Intel XPU GPU and official XPU tuple' + Resolver = 'Resolve-PyTorchPlan'; ResolverArguments = @{ Architecture = 'X64'; Backend = 'XPU'; PythonVersion = '3.13'; HasIntel = $true; IntelGpuName = 'Intel Arc B580 Graphics' }; Expected = @{ InstallTriton = $true } + ProbePath = 'src/Workloads/pytorch/xpu-smoke.py'; ReportEvidence = 'triton-xpu version, Intel device and successful cold torch.compile' + PartnerCommand = '.\src\Workloads\pytorch\install.ps1 -Backend XPU -RequireTriton -ReportPath "$env:TEMP\triton-xpu-report.json"' + } + @{ + Id = 'llama-cpu-x64' + Workload = 'llama.cpp'; Architecture = 'X64'; Vendor = 'CPU'; DeviceFamily = 'x64 CPU'; Backend = 'CPU' + Status = 'implemented-supported'; Maturity = 'rolling'; Acquisition = @('component:LlamaCppRolling') + Prerequisites = 'Windows x64' + Resolver = 'Resolve-LlamaCppInstallPlan'; ResolverArguments = @{ Architecture = 'X64'; Backend = 'CPU' }; Expected = @{ Backend = 'CPU'; Runtime = 'CPU x64' } + ProbePath = 'src/tests/llama.cpp/probe.ps1'; ReportEvidence = 'official asset tag/digest, zero GPU layers, pinned model inference' + PartnerCommand = '.\src\Workloads\llama.cpp\install.ps1 -Backend CPU -ReportPath "$env:TEMP\llama-cpu-x64-report.json"' + } + @{ + Id = 'llama-cpu-arm64' + Workload = 'llama.cpp'; Architecture = 'Arm64'; Vendor = 'CPU'; DeviceFamily = 'ARM64 CPU'; Backend = 'CPU' + Status = 'implemented-supported'; Maturity = 'rolling'; Acquisition = @('component:LlamaCppRolling') + Prerequisites = 'Windows ARM64' + Resolver = 'Resolve-LlamaCppInstallPlan'; ResolverArguments = @{ Architecture = 'Arm64'; Backend = 'CPU' }; Expected = @{ Backend = 'CPU'; Runtime = 'CPU ARM64' } + ProbePath = 'src/tests/llama.cpp/probe.ps1'; ReportEvidence = 'official asset tag/digest, zero GPU layers, pinned model inference' + PartnerCommand = '.\src\Workloads\llama.cpp\install.ps1 -Backend CPU -ReportPath "$env:TEMP\llama-cpu-arm64-report.json"' + } + @{ + Id = 'llama-cuda-x64' + Workload = 'llama.cpp'; Architecture = 'X64'; Vendor = 'NVIDIA'; DeviceFamily = 'CUDA-capable GPU'; Backend = 'CUDA' + Status = 'implemented-supported'; Maturity = 'rolling'; Acquisition = @('component:LlamaCppRolling') + Prerequisites = 'CC5.x-9.x with driver 551.61+ or CC7.5+ with driver 580+ for CUDA 13.3' + Resolver = 'Resolve-LlamaCppInstallPlan'; ResolverArguments = @{ Architecture = 'X64'; Backend = 'CUDA'; HasNvidia = $true; DriverVersion = '581.10'; ComputeCapability = '8.9'; NvidiaGpuName = 'NVIDIA GeForce RTX 4090' }; Expected = @{ Backend = 'CUDA'; Runtime = 'CUDA 13.3' } + ProbePath = 'src/tests/llama.cpp/probe.ps1'; ReportEvidence = 'paired app/cudart tag/digests, NVIDIA device, CUDA backend, GPU layers, inference' + PartnerCommand = '.\src\Workloads\llama.cpp\install.ps1 -Backend CUDA -ReportPath "$env:TEMP\llama-cuda-x64-report.json"' + } + @{ + Id = 'llama-cuda-arm64' + Workload = 'llama.cpp'; Architecture = 'Arm64'; Vendor = 'NVIDIA'; DeviceFamily = 'RTX Spark CC12.x'; Backend = 'CUDA' + Status = 'implemented-supported'; Maturity = 'rolling-developer-preview'; Acquisition = @('component:LlamaCppRolling') + Prerequisites = 'Driver 616+, compute capability 12.x' + Resolver = 'Resolve-LlamaCppInstallPlan'; ResolverArguments = @{ Architecture = 'Arm64'; Backend = 'CUDA'; HasNvidia = $true; DriverVersion = '616.62'; ComputeCapability = '12.1'; NvidiaGpuName = 'NVIDIA RTX Spark N1X' }; Expected = @{ Backend = 'CUDA'; Runtime = 'CUDA 13.4 Developer Preview' } + ProbePath = 'src/tests/llama.cpp/probe.ps1'; ReportEvidence = 'paired app/cudart tag/digests, N1X device, CUDA backend, GPU layers, inference' + PartnerCommand = '.\src\Workloads\llama.cpp\install.ps1 -Backend CUDA -ReportPath "$env:TEMP\llama-cuda-arm64-report.json"' + } + @{ + Id = 'llama-rocm-x64' + Workload = 'llama.cpp'; Architecture = 'X64'; Vendor = 'AMD'; DeviceFamily = 'ROCm 10 Windows gfx matrix'; Backend = 'ROCm' + Status = 'implemented-supported'; Maturity = 'rolling'; Acquisition = @('component:LlamaCppRolling') + Prerequisites = 'Supported AMD GPU/gfx target' + Resolver = 'Resolve-LlamaCppInstallPlan'; ResolverArguments = @{ Architecture = 'X64'; Backend = 'ROCm'; AmdGpuName = 'AMD Radeon RX 9070 XT'; AmdGfxTarget = 'gfx1201' }; Expected = @{ Backend = 'ROCm'; Runtime = 'ROCm 10.0' } + ProbePath = 'src/tests/llama.cpp/probe.ps1'; ReportEvidence = 'official asset tag/digest, AMD device/gfx, ROCm backend, GPU layers, inference' + PartnerCommand = '.\src\Workloads\llama.cpp\install.ps1 -Backend ROCm -ReportPath "$env:TEMP\llama-rocm-report.json"' + } + @{ + Id = 'llama-sycl-x64' + Workload = 'llama.cpp'; Architecture = 'X64'; Vendor = 'Intel'; DeviceFamily = 'validated Intel GPU families'; Backend = 'SYCL' + Status = 'implemented-supported'; Maturity = 'rolling'; Acquisition = @('component:LlamaCppRolling') + Prerequisites = 'Supported Intel GPU and installed compatible driver' + Resolver = 'Resolve-LlamaCppInstallPlan'; ResolverArguments = @{ Architecture = 'X64'; Backend = 'SYCL'; IntelGpuName = 'Intel Arc B580 Graphics' }; Expected = @{ Backend = 'SYCL'; Runtime = 'SYCL' } + ProbePath = 'src/tests/llama.cpp/probe.ps1'; ReportEvidence = 'official asset tag/digest, Intel device, SYCL backend, GPU layers, inference' + PartnerCommand = '.\src\Workloads\llama.cpp\install.ps1 -Backend SYCL -ReportPath "$env:TEMP\llama-sycl-report.json"' + } + @{ + Id = 'llama-openvino-x64' + Workload = 'llama.cpp'; Architecture = 'X64'; Vendor = 'Intel/general'; DeviceFamily = 'OpenVINO backend device'; Backend = 'OpenVINO' + Status = 'implemented-supported'; Maturity = 'rolling'; Acquisition = @('component:LlamaCppRolling') + Prerequisites = 'Windows x64 and backend-visible device' + Resolver = 'Resolve-LlamaCppInstallPlan'; ResolverArguments = @{ Architecture = 'X64'; Backend = 'OpenVINO'; IntelGpuName = 'Intel Arc B580 Graphics' }; Expected = @{ Backend = 'OpenVINO'; Runtime = 'OpenVINO 2026.3.1' } + ProbePath = 'src/tests/llama.cpp/probe.ps1'; ReportEvidence = 'official asset tag/digest, actual OpenVINO device/backend, GPU layers, inference; no NPU claim' + PartnerCommand = '.\src\Workloads\llama.cpp\install.ps1 -Backend OpenVINO -ReportPath "$env:TEMP\llama-openvino-report.json"' + } + @{ + Id = 'llama-vulkan-x64' + Workload = 'llama.cpp'; Architecture = 'X64'; Vendor = 'Cross-vendor'; DeviceFamily = 'Vulkan-capable GPU'; Backend = 'Vulkan' + Status = 'implemented-supported'; Maturity = 'rolling-fallback'; Acquisition = @('component:LlamaCppRolling') + Prerequisites = 'Vulkan loader and usable display adapter' + Resolver = 'Resolve-LlamaCppInstallPlan'; ResolverArguments = @{ Architecture = 'X64'; Backend = 'Vulkan'; HasVulkan = $true; VulkanGpuName = 'Generic Vulkan GPU' }; Expected = @{ Backend = 'Vulkan'; Runtime = 'Vulkan' } + ProbePath = 'src/tests/llama.cpp/probe.ps1'; ReportEvidence = 'official asset tag/digest, Vulkan backend/device, GPU layers, fallback status, inference' + PartnerCommand = '.\src\Workloads\llama.cpp\install.ps1 -Backend Vulkan -ReportPath "$env:TEMP\llama-vulkan-report.json"' + } + @{ + Id = 'llama-opencl-adreno-arm64' + Workload = 'llama.cpp'; Architecture = 'Arm64'; Vendor = 'Qualcomm'; DeviceFamily = 'Adreno'; Backend = 'OpenCL' + Status = 'implemented-supported'; Maturity = 'rolling'; Acquisition = @('component:LlamaCppRolling') + Prerequisites = 'Qualcomm/Adreno adapter and Windows OpenCL loader' + Resolver = 'Resolve-LlamaCppInstallPlan'; ResolverArguments = @{ Architecture = 'Arm64'; Backend = 'OpenCL'; QualcommGpuName = 'Qualcomm Adreno X1-85 GPU'; HasOpenCl = $true }; Expected = @{ Backend = 'OpenCL'; Runtime = 'OpenCL Adreno' } + ProbePath = 'src/tests/llama.cpp/probe.ps1'; ReportEvidence = 'official asset tag/digest, Adreno device, OpenCL backend, GPU layers, inference' + PartnerCommand = '.\src\Workloads\llama.cpp\install.ps1 -Backend OpenCL -ReportPath "$env:TEMP\llama-adreno-report.json"' + } + @{ + Id = 'foundry-source-managed-x64' + Workload = 'foundry'; Architecture = 'X64'; Vendor = 'Source-managed'; DeviceFamily = 'WinML provider selected by Foundry'; Backend = 'WinML' + Status = 'source-managed'; Maturity = 'qualified-preview'; Acquisition = @('component:FoundryLocal') + Prerequisites = 'Windows 11 build 26100+' + Resolver = 'Resolve-FoundryInstallPlan'; ResolverArguments = @{ Architecture = 'X64'; WindowsBuild = 26100 }; Expected = @{ PackageId = 'Microsoft.FoundryLocal'; RequiresCuda = $false } + ProbePath = 'src/Workloads/foundry/install.ps1'; ReportEvidence = 'resolved package variant, model inference, actual execution provider/device, truthful CPU fallback' + PartnerCommand = '.\src\Workloads\foundry\install.ps1 -ReportPath "$env:TEMP\foundry-x64-report.json"' + } + @{ + Id = 'foundry-source-managed-arm64' + Workload = 'foundry'; Architecture = 'Arm64'; Vendor = 'Source-managed'; DeviceFamily = 'WinML provider selected by Foundry'; Backend = 'WinML' + Status = 'source-managed'; Maturity = 'qualified-preview'; Acquisition = @('component:FoundryLocal') + Prerequisites = 'Windows 11 build 26100+' + Resolver = 'Resolve-FoundryInstallPlan'; ResolverArguments = @{ Architecture = 'Arm64'; WindowsBuild = 26100 }; Expected = @{ PackageId = 'Microsoft.FoundryLocal'; RequiresCuda = $false } + ProbePath = 'src/Workloads/foundry/install.ps1'; ReportEvidence = 'resolved package variant, model inference, actual execution provider/device, truthful CPU fallback' + PartnerCommand = '.\src\Workloads\foundry\install.ps1 -ReportPath "$env:TEMP\foundry-arm64-report.json"' + } + @{ + Id = 'ollama-source-managed-x64' + Workload = 'ollama'; Architecture = 'X64'; Vendor = 'Source-managed'; DeviceFamily = 'Ollama-selected CPU/GPU'; Backend = 'Ollama' + Status = 'source-managed'; Maturity = 'stable'; Acquisition = @('component:OllamaX64') + Prerequisites = 'Windows x64' + Resolver = 'Resolve-OllamaInstallPlan'; ResolverArguments = @{ Architecture = 'X64' }; Expected = @{ Method = 'WinGet'; PackageId = 'Ollama.Ollama' } + ProbePath = 'src/Workloads/ollama/install.ps1'; ReportEvidence = 'model digest/inference, actual process backend and CPU/GPU VRAM allocation; no forced vendor selector' + PartnerCommand = '.\src\Workloads\ollama\install.ps1 -ReportPath "$env:TEMP\ollama-x64-report.json"' + } + @{ + Id = 'ollama-source-managed-arm64' + Workload = 'ollama'; Architecture = 'Arm64'; Vendor = 'Source-managed'; DeviceFamily = 'Ollama-selected CPU/NVIDIA'; Backend = 'Ollama' + Status = 'source-managed'; Maturity = 'stable-direct'; Acquisition = @('component:OllamaArm64') + Prerequisites = 'Windows ARM64' + Resolver = 'Resolve-OllamaInstallPlan'; ResolverArguments = @{ Architecture = 'Arm64' }; Expected = @{ Method = 'GitHubRelease'; LaunchMode = 'ManagedStartup'; InstallType = 'native-arm64-managed-archive' } + ProbePath = 'src/Workloads/ollama/install.ps1'; ReportEvidence = 'model digest/inference, actual process backend and CPU/GPU VRAM allocation; no Adreno claim' + PartnerCommand = '.\src\Workloads\ollama\install.ps1 -ReportPath "$env:TEMP\ollama-arm64-report.json"' + } + @{ + Id = 'rocm-arm64-unavailable'; Workload = 'rocm'; Architecture = 'Arm64'; Vendor = 'AMD'; DeviceFamily = 'GPU'; Backend = 'HIP' + Status = 'upstream-unavailable'; Blocker = 'AMD does not publish the ROCm Core SDK or PyTorch ROCm runtime for native Windows ARM64.' + } + @{ + Id = 'pytorch-rocm-arm64-unavailable'; Workload = 'pytorch'; Architecture = 'Arm64'; Vendor = 'AMD'; DeviceFamily = 'GPU'; Backend = 'ROCm' + Status = 'upstream-unavailable'; Blocker = 'AMD does not publish the PyTorch ROCm runtime tuple for native Windows ARM64.' + } + @{ + Id = 'intel-ai-arm64-unavailable'; Workload = 'intel-ai'; Architecture = 'Arm64'; Vendor = 'Intel'; DeviceFamily = 'GPU/NPU'; Backend = 'OpenVINO/SYCL' + Status = 'upstream-unavailable'; Blocker = 'Intel OpenVINO/oneAPI Windows artifacts used by this flow are native x64; no equivalent native Windows ARM64 tuple is published.' + } + @{ + Id = 'pytorch-xpu-arm64-unavailable'; Workload = 'pytorch'; Architecture = 'Arm64'; Vendor = 'Intel'; DeviceFamily = 'GPU'; Backend = 'XPU' + Status = 'upstream-unavailable'; Blocker = 'PyTorch does not publish native Windows ARM64 XPU wheels.' + } + @{ + Id = 'pytorch-qualcomm-arm64-unavailable'; Workload = 'pytorch'; Architecture = 'Arm64'; Vendor = 'Qualcomm'; DeviceFamily = 'Adreno'; Backend = 'Qualcomm accelerator' + Status = 'upstream-unavailable'; Blocker = 'PyTorch does not publish a native Windows Qualcomm/Adreno accelerator backend.' + } + @{ + Id = 'triton-amd-windows-unavailable'; Workload = 'pytorch-triton'; Architecture = 'X64'; Vendor = 'AMD'; DeviceFamily = 'ROCm GPU'; Backend = 'ROCm' + Status = 'upstream-unavailable'; Blocker = 'No supported native Windows AMD Triton package is published.' + } + @{ + Id = 'generic-arm-gpu-toolkit-unavailable'; Workload = 'vendor-toolkit'; Architecture = 'Arm64'; Vendor = 'Generic'; DeviceFamily = 'ARM GPU'; Backend = 'Generic' + Status = 'upstream-unavailable'; Blocker = 'There is no standalone generic ARM GPU toolkit with authoritative Windows artifacts; use a published vendor backend or source-managed WinML provider.' + } + @{ + Id = 'amd-ryzen-ai-npu-unavailable'; Workload = 'vendor-toolkit'; Architecture = 'X64'; Vendor = 'AMD'; DeviceFamily = 'Ryzen AI NPU'; Backend = 'NPU' + Status = 'upstream-unavailable'; Blocker = 'ROCm is the AMD GPU/HIP stack. This repository does not implement or claim an authoritative AMD Ryzen AI NPU runtime.' + } + @{ + Id = 'other-windows-gpu-unavailable'; Workload = 'vendor-toolkit'; Architecture = 'X64/Arm64'; Vendor = 'Other'; DeviceFamily = 'Mali or unlisted GPU'; Backend = 'Unpublished' + Status = 'upstream-unavailable'; Blocker = 'No authoritative supported Windows artifact is implemented for this vendor/backend combination.' + } + ) +} diff --git a/src/Workloads/_common/ai-report.ps1 b/src/Workloads/_common/ai-report.ps1 new file mode 100644 index 00000000..6d096b01 --- /dev/null +++ b/src/Workloads/_common/ai-report.ps1 @@ -0,0 +1,141 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Get-AiGpuInventory { + $controllers = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | + Where-Object { $_.PNPDeviceID -match '^PCI\\' -and $_.Name -notmatch '(?i)\bNPU\b' }) + return @($controllers | ForEach-Object { + $vendor = if ($_.PNPDeviceID -match 'VEN_10DE' -or $_.Name -match 'NVIDIA') { + 'NVIDIA' + } elseif ($_.PNPDeviceID -match 'VEN_1002' -or $_.Name -match 'AMD|Radeon') { + 'AMD' + } elseif ($_.PNPDeviceID -match 'VEN_8086' -or $_.Name -match 'Intel') { + 'Intel' + } elseif ($_.PNPDeviceID -match 'VEN_17CB' -or $_.Name -match 'Qualcomm|Adreno') { + 'Qualcomm' + } else { + 'Unknown' + } + [ordered]@{ + vendor = $vendor + name = $_.Name + pnpDeviceId = $_.PNPDeviceID + driverVersion = $_.DriverVersion + } + }) +} + +function Get-AiNpuInventory { + return @(Get-PnpDevice -PresentOnly -ErrorAction SilentlyContinue | + Where-Object { $_.FriendlyName -match '(?i)(\bNPU\b|AI Boost|Neural Processing)' } | + ForEach-Object { + [ordered]@{ + name = $_.FriendlyName + instanceId = $_.InstanceId + status = $_.Status + } + }) +} + +function New-AiWorkloadReport { + param( + [Parameter(Mandatory)] [string] $Id, + [hashtable] $Request = @{} + ) + $os = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue + return [ordered]@{ + schemaVersion = 1 + workload = $Id + startedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + completedAtUtc = $null + host = [ordered]@{ + os = $os.Caption + osVersion = $os.Version + osBuild = $os.BuildNumber + architecture = (Get-DevConfigArchitecture) + powershell = $PSVersionTable.PSVersion.ToString() + gpus = @(Get-AiGpuInventory) + npus = @(Get-AiNpuInventory) + } + request = $Request + acquisitions = [System.Collections.ArrayList]::new() + phases = [System.Collections.ArrayList]::new() + acceptance = [ordered]@{} + result = [ordered]@{ + ready = $false + planOnly = [bool]$(if ($Request.ContainsKey('PlanOnly')) { $Request.PlanOnly } else { $false }) + fallbackUsed = $false + warnings = [System.Collections.ArrayList]::new() + blockers = [System.Collections.ArrayList]::new() + } + } +} + +function Add-AiReportAcquisition { + param( + [Parameter(Mandatory)] [hashtable] $Report, + [Parameter(Mandatory)] $Entry + ) + [void]$Report.acquisitions.Add($Entry) +} + +function Add-AiReportPhase { + param( + [Parameter(Mandatory)] [hashtable] $Report, + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string] $Status, + $Evidence = $null + ) + [void]$Report.phases.Add([ordered]@{ name = $Name; status = $Status; evidence = $Evidence }) +} + +function Complete-AiWorkloadReport { + param( + [Parameter(Mandatory)] [hashtable] $Report, + [Parameter(Mandatory)] [bool] $Ready, + [Parameter(Mandatory)] [string] $Path + ) + $Report.completedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + $Report.result.ready = $Ready + Write-DevConfigTextFile -Path $Path -Content ($Report | ConvertTo-Json -Depth 20) + Write-Host "AI_REPORT: $Path" +} + +function Write-AiFailureReport { + param( + [Parameter(Mandatory)] [hashtable] $Report, + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] $ErrorRecord + ) + [void]$Report.result.blockers.Add($ErrorRecord.Exception.Message) + Complete-AiWorkloadReport -Report $Report -Ready $false -Path $Path +} + +function Get-AiDefaultReportPath { + param([Parameter(Mandatory)] [string] $Id) + return Join-Path $env:LOCALAPPDATA "DevConfig\reports\$Id-latest.json" +} + +function Get-AiCatalog { + return Get-AiCatalogData +} + +function Get-AiCatalogValue { + param( + [Parameter(Mandatory)] [hashtable] $Entry, + [Parameter(Mandatory)] [string] $Name + ) + if ($Entry.ContainsKey($Name)) { + return $Entry[$Name] + } + return $null +} + +function Set-AiAcquisitionAction { + param( + [Parameter(Mandatory)] [hashtable] $Report, + [Parameter(Mandatory)] [int] $Index, + [Parameter(Mandatory)] [string] $Action + ) + $Report.acquisitions[$Index].action = $Action +} diff --git a/src/Workloads/_common/ai-support.ps1 b/src/Workloads/_common/ai-support.ps1 new file mode 100644 index 00000000..f198eade --- /dev/null +++ b/src/Workloads/_common/ai-support.ps1 @@ -0,0 +1,2466 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Get-AiCatalogData { + Import-Module Microsoft.PowerShell.Utility -ErrorAction Stop + return Microsoft.PowerShell.Utility\Import-PowerShellDataFile ` + -LiteralPath (Join-Path $PSScriptRoot 'ai-catalog.psd1') +} + +function Get-AiCapabilityMatrix { + return @((Get-AiCatalogData).CapabilityMatrix) +} + +function Resolve-AiCapabilityCell { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $Id) + + $cell = Get-AiCapabilityMatrix | Where-Object { $_.Id -eq $Id } | Select-Object -First 1 + if (-not $cell) { + throw "Unknown AI capability cell '$Id'." + } + if ($cell.Status -eq 'upstream-unavailable') { + throw [string]$cell.Blocker + } + $resolver = Get-Command -Name $cell.Resolver -CommandType Function -ErrorAction SilentlyContinue + if (-not $resolver) { + throw "Capability '$Id' references missing resolver '$($cell.Resolver)'." + } + $arguments = @{} + foreach ($entry in $cell.ResolverArguments.GetEnumerator()) { + $arguments[$entry.Key] = $entry.Value + } + $plan = & $resolver.Name @arguments + foreach ($entry in $cell.Expected.GetEnumerator()) { + $property = $plan.PSObject.Properties[$entry.Key] + if (-not $property) { + throw "Capability '$Id' resolver result did not contain expected field '$($entry.Key)'." + } + if ($property.Value -ne $entry.Value) { + throw "Capability '$Id' expected $($entry.Key)='$($entry.Value)' but resolved '$($property.Value)'." + } + } + return $plan +} + +function Enable-AiUtf8Console { + try { + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [Console]::InputEncoding = $utf8NoBom + [Console]::OutputEncoding = $utf8NoBom + $global:OutputEncoding = $utf8NoBom + } catch { + Write-Verbose "Could not force UTF-8 console encoding: $($_.Exception.Message)" + } + try { + $null = & $env:ComSpec /d /c 'chcp 65001 >nul 2>&1' + } catch { + Write-Verbose "Could not set the console code page to UTF-8: $($_.Exception.Message)" + } +} + +function ConvertFrom-AiPrefixedJsonArray { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $Text) + + $normalized = $Text -replace "`r`n", "`n" -replace "`r", "`n" + $lines = @($normalized -split "`n") + for ($index = 0; $index -lt $lines.Count; $index++) { + if (-not $lines[$index].TrimStart().StartsWith('[')) { + continue + } + $jsonText = ($lines[$index..($lines.Count - 1)] -join "`n").Trim() + $diagnostics = if ($index -gt 0) { + ($lines[0..($index - 1)] -join "`n").Trim() + } else { + '' + } + try { + return ConvertFrom-AiJsonArrayWithDiagnostics -Json $jsonText -Diagnostics $diagnostics + } catch { + continue + } + } + throw 'No valid JSON array was found after the diagnostic output.' +} + +function Get-AiWindowsPathFromOutput { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $Text) + + $withoutAnsi = [regex]::Replace($Text, "$([char]27)\[[0-?]*[ -/]*[@-~]", '') + $match = [regex]::Match($withoutAnsi, '(?im)([A-Za-z]:\\[^\r\n]+)') + if (-not $match.Success) { + throw "No absolute Windows path was found in output: $Text" + } + $path = $match.Groups[1].Value.Trim().Trim('"', "'", ' ') + if (-not [System.IO.Path]::IsPathRooted($path)) { + throw "Output did not contain a rooted Windows path: $Text" + } + return $path +} + +function ConvertTo-AiNativeCommandLineArgument { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [AllowEmptyString()] [string] $Argument + ) + + if ($Argument.Length -gt 0 -and $Argument -notmatch '[\s"]') { + return $Argument + } + + $quoted = [System.Text.StringBuilder]::new() + [void]$quoted.Append('"') + $backslashes = 0 + foreach ($character in $Argument.ToCharArray()) { + if ($character -eq [char]0x5c) { + $backslashes++ + continue + } + if ($character -eq '"') { + [void]$quoted.Append([char]0x5c, ($backslashes * 2) + 1) + [void]$quoted.Append('"') + $backslashes = 0 + continue + } + if ($backslashes -gt 0) { + [void]$quoted.Append([char]0x5c, $backslashes) + $backslashes = 0 + } + [void]$quoted.Append($character) + } + if ($backslashes -gt 0) { + [void]$quoted.Append([char]0x5c, $backslashes * 2) + } + [void]$quoted.Append('"') + return $quoted.ToString() +} + +function Invoke-AiNativeCommandSeparated { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $FilePath, + [string[]] $Arguments = @(), + [ValidateRange(1, 2147483)] [int] $TimeoutSeconds = 600 + ) + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $FilePath + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.Arguments = @($Arguments | ForEach-Object { + ConvertTo-AiNativeCommandLineArgument -Argument $_ + }) -join ' ' + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $startInfo + $started = $false + try { + if (-not $process.Start()) { + throw "Native command '$FilePath' did not start." + } + $started = $true + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + try { $process.Kill() } catch { + Write-Verbose "Could not stop timed-out native command '$FilePath': $($_.Exception.Message)" + } + throw [System.TimeoutException]::new( + "Native command '$FilePath' did not finish within $TimeoutSeconds seconds, so it was stopped." + ) + } + $process.WaitForExit() + return [pscustomobject]@{ + ExitCode = $process.ExitCode + StandardOutput = $stdoutTask.GetAwaiter().GetResult() + StandardError = $stderrTask.GetAwaiter().GetResult() + } + } finally { + if ($started -and -not $process.HasExited) { + try { $process.Kill() } catch { + Write-Verbose "Could not stop native command '$FilePath': $($_.Exception.Message)" + } + } + $process.Dispose() + } +} + +function ConvertFrom-AiJsonArrayWithDiagnostics { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Json, + [AllowEmptyString()] [string] $Diagnostics = '' + ) + $jsonText = $Json.Trim() + $jsonRepaired = $jsonText.StartsWith('[') -and $jsonText.EndsWith('}') + if ($jsonRepaired) { + $jsonText = "$jsonText`n]" + } + try { + $parsedData = $jsonText | ConvertFrom-Json -ErrorAction Stop + $data = @($parsedData) + } catch { + throw "llama-bench stdout was not a valid JSON array: $($_.Exception.Message)" + } + if ($data.Count -eq 0) { + throw 'llama-bench returned an empty JSON array.' + } + $diagnosticText = $Diagnostics.Trim() + if ($jsonRepaired) { + $repairDiagnostic = 'LLAMA_BENCH_JSON_REPAIRED: appended the missing closing array bracket.' + $diagnosticText = @($repairDiagnostic, $diagnosticText | Where-Object { $_ }) -join "`n" + } + return [pscustomobject]@{ + Data = $data + Json = $jsonText + Diagnostics = $diagnosticText + JsonRepaired = $jsonRepaired + } +} + +function ConvertFrom-AiKeyedJsonLine { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Text, + [Parameter(Mandatory)] [string] $Prefix + ) + + $line = @($Text -split '\r?\n' | Where-Object { $_.StartsWith($Prefix) }) | Select-Object -Last 1 + if (-not $line) { + throw "Output did not contain a '$Prefix' JSON record." + } + $json = $line.Substring($Prefix.Length) + try { + return $json | ConvertFrom-Json -ErrorAction Stop + } catch { + throw "The '$Prefix' record was not valid JSON: $json" + } +} + +function Test-AiDeviceNameMatch { + [CmdletBinding()] + param( + [AllowNull()] [string] $Expected, + [AllowNull()] [string] $Actual + ) + if (-not $Expected -or -not $Actual) { return $false } + $normalizedExpected = ($Expected -replace '\((TM|R)\)', '' -replace '[^A-Za-z0-9]+', ' ').Trim() + $normalizedActual = ($Actual -replace '\((TM|R)\)', '' -replace '[^A-Za-z0-9]+', ' ').Trim() + return $normalizedActual -eq $normalizedExpected -or + $normalizedActual.Contains($normalizedExpected) -or + $normalizedExpected.Contains($normalizedActual) +} + +function Get-FoundryModelVariantEvidence { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [AllowEmptyString()] [string] $ModelInfo, + [AllowEmptyString()] [string] $ServerLogs = '' + ) + + if (-not $ModelInfo) { return $null } + $variants = [System.Collections.Generic.List[object]]::new() + $current = $null + $inVariantTable = $false + foreach ($line in @($ModelInfo -split '\r?\n')) { + if ($line -match '^\|\s*Variant\s*\|') { + $inVariantTable = $true + continue + } + if (-not $inVariantTable) { continue } + if ($line -match '^\+') { + if ($null -ne $current) { + [void]$variants.Add([pscustomobject]$current) + $current = $null + } + if ($variants.Count -gt 0) { break } + continue + } + if ($line -notmatch '^\|') { continue } + $columns = @($line -split '\|') + if ($columns.Count -lt 8) { continue } + $devicePart = $columns[3].Trim() + $providerPart = $columns[4].Trim() + $cachedPart = $columns[6].Trim() + if ($devicePart -and $devicePart -ne 'Device' -and $devicePart -notmatch '^-+$') { + if ($null -ne $current) { + [void]$variants.Add([pscustomobject]$current) + } + $current = [ordered]@{ + Device = $devicePart + Provider = $providerPart + Cached = $cachedPart + } + } elseif ($null -ne $current -and $providerPart -and $providerPart -ne 'Provider') { + $current.Provider += $providerPart + } + } + if ($null -ne $current) { + [void]$variants.Add([pscustomobject]$current) + } + if ($variants.Count -eq 0) { return $null } + + $loadedMatches = @([regex]::Matches( + $ServerLogs, + "(?im)Model\s+'?[^'\r\n]*-(gpu|cpu|npu):\d+'?\s*(?:\r?\n)?loaded successfully" + )) + $selected = $null + if ($loadedMatches.Count -gt 0) { + $loadedDevice = $loadedMatches[$loadedMatches.Count - 1].Groups[1].Value.ToUpperInvariant() + $selected = @($variants | Where-Object { $_.Device -ieq $loadedDevice }) | Select-Object -First 1 + } + if ($null -eq $selected) { + $cachedMarker = [string][char]0x25CF + $selected = @($variants | Where-Object { + $_.Cached -eq $cachedMarker -or $_.Cached -match '(?i)^(yes|true)$' + }) | Select-Object -First 1 + } + if ($null -eq $selected -and $variants.Count -eq 1) { + $selected = $variants[0] + } + return $selected +} + +function Get-FoundryExecutionProviderEvidence { + [CmdletBinding()] + param( + [AllowEmptyString()] [string] $ModelInfo = '', + [AllowEmptyString()] [string] $ServerLogs = '' + ) + $providerNames = @( + 'CUDAExecutionProvider', + 'NvTensorRTRTXExecutionProvider', + 'QNNExecutionProvider', + 'OpenVINOExecutionProvider', + 'VitisAIExecutionProvider', + 'MIGraphXExecutionProvider', + 'WebGPUExecutionProvider', + 'DmlExecutionProvider', + 'CPUExecutionProvider' + ) + $providerPattern = '(?i)(' + ($providerNames -join '|') + ')' + $selectionMatches = @([regex]::Matches($ServerLogs, '(?im)Device:\s*([^,\r\n]+),\s*EPs:\s*([^\r\n]+)')) + if ($selectionMatches.Count -gt 0) { + $selection = $selectionMatches[$selectionMatches.Count - 1] + $selectedDevice = $selection.Groups[1].Value.Trim() + $providerText = $selection.Groups[2].Value + } elseif ($ServerLogs -match '(?im)Using\s+WebGPU\s+EP\s+for\s+model:') { + $selectedDevice = 'GPU' + $providerText = 'WebGPUExecutionProvider' + } else { + $variant = Get-FoundryModelVariantEvidence -ModelInfo $ModelInfo -ServerLogs $ServerLogs + if ($null -eq $variant -or -not $variant.Device -or -not $variant.Provider) { + throw 'Foundry inference succeeded, but neither the current inference logs nor the selected model variant identified its device and execution provider.' + } + $selectedDevice = $variant.Device + $providerText = $variant.Provider + } + $providers = @([regex]::Matches($providerText, $providerPattern) | + ForEach-Object { + $matchedProvider = $_.Groups[1].Value + @($providerNames | Where-Object { $_ -ieq $matchedProvider })[0] + } | + Select-Object -Unique) + if ($providers.Count -eq 0) { + throw "Foundry selection event for device '$selectedDevice' did not identify a supported execution provider." + } + return [pscustomobject]@{ + SelectedDevice = $selectedDevice + SelectedProvider = $providers -join ',' + ObservedProviders = $providers + CpuFallback = $providers.Count -eq 1 -and $providers[0] -ieq 'CPUExecutionProvider' + } +} + +function Get-AiAppendedLogText { + [CmdletBinding()] + param( + [AllowEmptyString()] [string] $Before = '', + [AllowEmptyString()] [string] $After = '' + ) + $beforeCounts = @{} + foreach ($line in @($Before -split '\r?\n' | Where-Object { $_ })) { + $beforeCounts[$line] = 1 + $(if ($beforeCounts.ContainsKey($line)) { $beforeCounts[$line] } else { 0 }) + } + $appended = [System.Collections.Generic.List[string]]::new() + foreach ($line in @($After -split '\r?\n' | Where-Object { $_ })) { + if ($beforeCounts.ContainsKey($line) -and $beforeCounts[$line] -gt 0) { + $beforeCounts[$line]-- + } else { + [void]$appended.Add($line) + } + } + return $appended -join "`n" +} + +function Get-LlamaBenchmarkBackendEvidence { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object[]] $Data, + [AllowEmptyString()] [string] $Diagnostics = '', + [Parameter(Mandatory)] [ValidateSet('CUDA', 'ROCm', 'SYCL', 'OpenVINO', 'Vulkan', 'OpenCL', 'CPU')] [string] $Backend, + [AllowNull()] [string] $ExpectedDeviceName, + [AllowNull()] [string] $RequestedDevice + ) + + $actualBackends = @($Data | ForEach-Object { + $property = $_.PSObject.Properties['backends'] + if ($property) { @($property.Value) | ForEach-Object { [string]$_ } } + } | Where-Object { $_ } | Select-Object -Unique) + $requestedDevices = @($Data | ForEach-Object { + $property = $_.PSObject.Properties['devices'] + if ($property) { [string]$property.Value } + } | Where-Object { $_ } | Select-Object -Unique) + $gpuInfo = @($Data | ForEach-Object { + $property = $_.PSObject.Properties['gpu_info'] + if ($property) { [string]$property.Value } + } | Where-Object { $_ } | Select-Object -Unique) + $requestedGpuMeasurements = @($Data | Where-Object { + $property = $_.PSObject.Properties['n_gpu_layers'] + $property -and [int]$property.Value -gt 0 + }) + $offloadMatches = @([regex]::Matches($Diagnostics, '(?im)offloaded\s+([0-9]+)\s*/\s*([0-9]+)\s+layers(?:\s+to\s+GPU)?')) + $actualOffloadedLayers = 0 + $totalModelLayers = 0 + foreach ($match in $offloadMatches) { + $actualOffloadedLayers = [math]::Max($actualOffloadedLayers, [int]$match.Groups[1].Value) + $totalModelLayers = [math]::Max($totalModelLayers, [int]$match.Groups[2].Value) + } + $evidenceText = (@($actualBackends) + @($gpuInfo) + @($Diagnostics)) -join "`n" + $backendPattern = switch ($Backend) { + 'CUDA' { 'CUDA' } + 'ROCm' { 'ROCm|HIP' } + 'SYCL' { 'SYCL' } + 'OpenVINO' { 'OpenVINO' } + 'Vulkan' { 'Vulkan' } + 'OpenCL' { 'OpenCL' } + 'CPU' { 'CPU' } + } + if (($actualBackends -join "`n") -notmatch $backendPattern) { + throw "llama-bench did not identify the selected $Backend backend. Actual backends: $($actualBackends -join ', ')." + } + if ($Backend -ne 'CPU' -and $gpuInfo.Count -eq 0) { + throw "llama-bench identified $Backend but did not provide physical device evidence in gpu_info." + } + if ($Backend -eq 'CPU') { + if ($actualOffloadedLayers -gt 0) { + throw "llama-bench offloaded $actualOffloadedLayers layers while the CPU backend was selected." + } + } elseif ($actualOffloadedLayers -le 0) { + throw "llama-bench identified $Backend but diagnostics did not prove any layers were actually offloaded." + } + $vendorPattern = switch ($Backend) { + 'CUDA' { 'NVIDIA|CUDA' } + 'ROCm' { 'AMD|Radeon|ROCm|HIP' } + 'SYCL' { 'Intel|SYCL' } + 'OpenVINO' { 'OpenVINO' } + 'Vulkan' { 'Vulkan' } + 'OpenCL' { 'Qualcomm|Adreno|OpenCL' } + 'CPU' { 'CPU' } + } + if ($evidenceText -notmatch $vendorPattern) { + throw "llama-bench did not report device evidence for the selected $Backend backend." + } + if ($ExpectedDeviceName -and $Backend -ne 'CPU' -and $ExpectedDeviceName -ne 'OpenVINO-selected device') { + if (-not (Test-AiDeviceNameMatch -Expected $ExpectedDeviceName -Actual $evidenceText)) { + throw "llama-bench selected $Backend but did not identify the expected device '$ExpectedDeviceName'." + } + } + if ($RequestedDevice -and $Backend -ne 'CPU') { + $matchingRequestedDevices = @($requestedDevices | Where-Object { $_ -ieq $RequestedDevice }) + if ($matchingRequestedDevices.Count -eq 0) { + throw "llama-bench structured devices '$($requestedDevices -join ',')' did not match requested selector '$RequestedDevice'." + } + $selectorPattern = "(?im)using device\s+$([regex]::Escape($RequestedDevice))\b|dev\s*=\s*$([regex]::Escape($RequestedDevice))\b" + if ($Diagnostics -notmatch $selectorPattern) { + throw "llama-bench did not prove that requested device selector '$RequestedDevice' was used." + } + } + + return [pscustomobject]@{ + Backend = $Backend + ActualBackends = $actualBackends + RequestedDevices = $requestedDevices + GpuInfo = $gpuInfo + ExpectedDevice = $ExpectedDeviceName + RequestedDevice = $RequestedDevice + RequestedGpuLayerMeasurements = $requestedGpuMeasurements.Count + ActualOffloadedLayers = $actualOffloadedLayers + TotalModelLayers = $totalModelLayers + HardwareAccelerated = $Backend -ne 'CPU' -and $actualOffloadedLayers -gt 0 + } +} + +function Get-DevConfigArchitecture { + [CmdletBinding()] + param([ValidateSet('', 'X64', 'Arm64')] [string] $Override = '') + + if ($Override) { + return $Override + } + + $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + switch ($architecture) { + 'X64' { return 'X64' } + 'Arm64' { return 'Arm64' } + default { throw "Unsupported Windows architecture '$architecture'. Supported architectures: X64, Arm64." } + } +} + +function Assert-DevConfigArchitecture { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Architecture, + [Parameter(Mandatory)] [string[]] $Supported, + [Parameter(Mandatory)] [string] $Component + ) + + if ($Architecture -notin $Supported) { + throw "$Component does not publish a compatible Windows artifact for $Architecture. Supported architectures: $($Supported -join ', ')." + } +} + +function Get-WindowsBuildNumber { + [CmdletBinding()] + param() + + return [Environment]::OSVersion.Version.Build +} + +function Resolve-CudaInstallPlan { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture, + [int] $WindowsBuild = 26100 + ) + + if ($Architecture -eq 'X64') { + return [pscustomobject]@{ + Architecture = $Architecture + Method = 'WinGet' + PackageId = 'Nvidia.CUDA' + ToolkitVersion = $null + Preview = $false + InstallerUrl = $null + InstallerSha256 = $null + } + } + + if ($WindowsBuild -lt 22000) { + throw "CUDA 13.4 Developer Preview for Windows ARM64 requires Windows 11; detected build $WindowsBuild." + } + + $catalog = (Get-AiCatalogData).Components.CudaArm64 + return [pscustomobject]@{ + Architecture = $Architecture + Method = 'NvidiaInstaller' + InstallerIdentity = $catalog.Artifact + ToolkitVersion = $catalog.Version.Substring(0, 4) + Preview = $true + InstallerUrl = $catalog.Uri + InstallerSha256 = $catalog.Sha256 + } +} + +function Resolve-FoundryInstallPlan { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture, + [Parameter(Mandatory)] [int] $WindowsBuild + ) + + if ($WindowsBuild -lt 26100) { + throw "Foundry Local's Windows/WinML path requires Windows 11 24H2 (build 26100) or later; detected build $WindowsBuild." + } + + return [pscustomobject]@{ + Architecture = $Architecture + PackageId = 'Microsoft.FoundryLocal' + RequiresCuda = $false + } +} + +function Resolve-LlamaCppInstallPlan { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture, + [ValidateSet('Auto', 'CUDA', 'ROCm', 'SYCL', 'OpenVINO', 'Vulkan', 'OpenCL', 'CPU')] [string] $Backend = 'Auto', + [bool] $HasNvidia = $false, + [version] $DriverVersion = [version]'0.0', + [version] $ComputeCapability = [version]'0.0', + [string] $NvidiaGpuName, + [string] $AmdGpuName, + [string] $AmdGfxTarget, + [string] $IntelGpuName, + [string] $QualcommGpuName, + [bool] $HasOpenCl = $false, + [bool] $HasVulkan = $false, + [string] $VulkanGpuName + ) + + $catalog = (Get-AiCatalogData).Components.LlamaCppRolling + $assets = $catalog.BackendAssets + $cudaAsset = $null + if ($HasNvidia) { + if ($Architecture -eq 'Arm64') { + if ($DriverVersion -ge [version]'616.0' -and $ComputeCapability.Major -ge 12) { + $cudaAsset = $assets.Cuda134Arm64 + } + } elseif ($DriverVersion -ge [version]'580.0' -and $ComputeCapability -ge [version]'7.5') { + $cudaAsset = $assets.Cuda133X64 + } elseif ($DriverVersion -ge [version]'551.61' -and + $ComputeCapability -ge [version]'5.0' -and + $ComputeCapability.Major -lt 10) { + $cudaAsset = $assets.Cuda124X64 + } + } + $rocmSupported = $Architecture -eq 'X64' -and [bool]$AmdGpuName -and [bool]$AmdGfxTarget + $syclSupported = $Architecture -eq 'X64' -and (Test-IntelXpuGpuSupported -GpuName $IntelGpuName) + $openClSupported = $Architecture -eq 'Arm64' -and [bool]$QualcommGpuName -and $HasOpenCl + + $selectedBackend = if ($Backend -eq 'Auto') { + if ($cudaAsset) { + 'CUDA' + } elseif ($rocmSupported) { + 'ROCm' + } elseif ($syclSupported) { + 'SYCL' + } elseif ($openClSupported) { + 'OpenCL' + } elseif ($Architecture -eq 'X64' -and $HasVulkan) { + 'Vulkan' + } else { + 'CPU' + } + } else { + $Backend + } + + $selectedAsset = switch ($selectedBackend) { + 'CUDA' { + if (-not $cudaAsset) { + if ($Architecture -eq 'Arm64') { + throw 'llama.cpp CUDA on Windows ARM64 requires an RTX Spark-class NVIDIA GPU, compute capability 12.x, and driver branch 616 or newer.' + } + if ($HasNvidia -and $ComputeCapability.Major -ge 10 -and $DriverVersion -lt [version]'580.0') { + throw "llama.cpp CUDA 13.3 is required for NVIDIA compute capability $ComputeCapability, but driver $DriverVersion is below branch 580." + } + throw 'llama.cpp CUDA on Windows x64 requires an NVIDIA GPU with compute capability 5.0 or newer and driver 551.61 or newer.' + } + $cudaAsset + } + 'ROCm' { + if (-not $rocmSupported) { + throw "llama.cpp ROCm requires Windows x64 and an AMD GPU in the ROCm 10.0 Windows support matrix. Detected: '$AmdGpuName'." + } + $assets.Rocm10X64 + } + 'SYCL' { + if (-not $syclSupported) { + throw "llama.cpp SYCL requires Windows x64 and a supported Intel GPU. Detected: '$IntelGpuName'." + } + $assets.SyclX64 + } + 'OpenVINO' { + if ($Architecture -ne 'X64') { + throw 'llama.cpp OpenVINO is not published for native Windows ARM64.' + } + $assets.OpenVinoX64 + } + 'Vulkan' { + if ($Architecture -ne 'X64') { + throw 'llama.cpp Vulkan is not selected on Windows ARM64; use CUDA, OpenCL, or CPU.' + } + if (-not $HasVulkan) { + throw 'llama.cpp Vulkan was requested, but no Vulkan loader and usable display adapter were detected.' + } + $assets.VulkanX64 + } + 'OpenCL' { + if (-not $openClSupported) { + throw "llama.cpp OpenCL is published here only for Qualcomm Adreno on Windows ARM64 with a working OpenCL loader. Detected: '$QualcommGpuName'; OpenCL loader: $HasOpenCl." + } + $assets.OpenClAdrenoArm64 + } + 'CPU' { + if ($Architecture -eq 'Arm64') { $assets.CpuArm64 } else { $assets.CpuX64 } + } + } + $deviceName = switch ($selectedBackend) { + 'CUDA' { $NvidiaGpuName } + 'ROCm' { $AmdGpuName } + 'SYCL' { $IntelGpuName } + 'OpenVINO' { if ($IntelGpuName) { $IntelGpuName } else { 'OpenVINO-selected device' } } + 'Vulkan' { $VulkanGpuName } + 'OpenCL' { $QualcommGpuName } + default { 'CPU' } + } + return [pscustomobject]@{ + Method = 'GitHubRelease' + PackageId = $null + AssetPatterns = @($selectedAsset.Patterns) + Backend = $selectedAsset.Backend + Runtime = $selectedAsset.Runtime + Vendor = $selectedAsset.Vendor + DeviceName = $deviceName + Maturity = $(if ($selectedAsset.ContainsKey('Maturity')) { $selectedAsset.Maturity } else { $catalog.Maturity }) + VersionPolicy = $(if ($selectedAsset.ContainsKey('VersionPolicy')) { $selectedAsset.VersionPolicy } else { $catalog.VersionPolicy }) + AmdGfxTarget = $(if ($selectedBackend -eq 'ROCm') { $AmdGfxTarget } else { $null }) + } +} + +function Get-QualcommGpuName { + $names = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | + Where-Object { $_.PNPDeviceID -match 'VEN_(17CB|QCOM)' -or $_.Name -match 'Qualcomm|Adreno' } | + ForEach-Object Name) + return $names | Sort-Object | Select-Object -First 1 +} + +function Get-VulkanGpuName { + $names = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | + Where-Object { $_.Name -and $_.Name -notmatch 'Microsoft Basic|Remote Display|Indirect Display' } | + ForEach-Object Name) + return $names | Sort-Object | Select-Object -First 1 +} + +function Test-AiOpenClRuntimeAvailable { + [CmdletBinding()] + param() + return Test-Path -LiteralPath (Join-Path $env:WINDIR 'System32\OpenCL.dll') +} + +function Test-AiVulkanRuntimeAvailable { + [CmdletBinding()] + param([AllowNull()] [string] $GpuName) + if (-not $GpuName) { return $false } + return Test-Path -LiteralPath (Join-Path $env:WINDIR 'System32\vulkan-1.dll') +} + +function Resolve-OllamaInstallPlan { + [CmdletBinding()] + param([Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture) + + if ($Architecture -eq 'X64') { + return [pscustomobject]@{ + Method = 'WinGet' + PackageId = 'Ollama.Ollama' + LaunchMode = 'Desktop' + } + } + + return [pscustomobject]@{ + Method = 'GitHubRelease' + PackageId = $null + LaunchMode = 'ManagedStartup' + InstallType = 'native-arm64-managed-archive' + } +} + +function Get-NvidiaGpu { + [CmdletBinding()] + param() + + $controllers = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue) + return $controllers | + Where-Object { $_.PNPDeviceID -match 'VEN_10DE' -or $_.Name -match 'NVIDIA' } | + Select-Object -First 1 +} + +function Get-AmdGfxTarget { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $GpuName) + + $normalizedGpuName = ($GpuName -replace '\((TM|R)\)', '' -replace '\s+', ' ').Trim() + $map = @( + @{ Pattern = 'R9700|R9600D|RX 9070'; Gfx = 'gfx1201' } + @{ Pattern = 'RX 9060|RX 9050'; Gfx = 'gfx1200' } + @{ Pattern = 'W7900|W7800|RX 7900'; Gfx = 'gfx1100' } + @{ Pattern = 'W7700|RX 7800|RX 7700'; Gfx = 'gfx1101' } + @{ Pattern = 'RX (7600|7650)'; Gfx = 'gfx1102' } + @{ Pattern = 'Ryzen AI Max|Radeon 8060S'; Gfx = 'gfx1151' } + @{ Pattern = 'Ryzen AI 9.*(475|470|375|370|465|365)|Radeon (890M|880M)'; Gfx = 'gfx1150' } + @{ Pattern = 'Ryzen AI (7|5).*(450|350|345|440|340|330)|Radeon 860M'; Gfx = 'gfx1152' } + @{ Pattern = 'Ryzen AI (7|5).*(445|435|430)|Radeon 840M'; Gfx = 'gfx1153' } + @{ Pattern = 'Ryzen (9 270|7 (260|250)|5 (240|230|220)|3 210)|Radeon (780M|760M|740M)'; Gfx = 'gfx1103' } + ) + $entry = $map | Where-Object { $normalizedGpuName -match $_.Pattern } | Select-Object -First 1 + if (-not $entry) { + return $null + } + return $entry.Gfx +} + +function Get-AiDetectedVendor { + $controllers = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue) + if ($controllers | Where-Object { $_.PNPDeviceID -match 'VEN_10DE' -or $_.Name -match 'NVIDIA' }) { return 'NVIDIA' } + if ($controllers | Where-Object { $_.PNPDeviceID -match 'VEN_1002' -or $_.Name -match 'AMD|Radeon' }) { return 'AMD' } + if ($controllers | Where-Object { $_.PNPDeviceID -match 'VEN_8086' -or $_.Name -match 'Intel' }) { return 'Intel' } + return 'None' +} + +function Get-AiProcessId { + [CmdletBinding()] + param([AllowNull()] $ProcessObject) + if ($null -eq $ProcessObject) { return $null } + foreach ($name in @('ProcessId', 'Id')) { + $property = $ProcessObject.PSObject.Properties[$name] + if ($property -and $null -ne $property.Value) { + return [int]$property.Value + } + } + return $null +} + +function Get-AiProcessIds { + [CmdletBinding()] + param([AllowEmptyCollection()] [object[]] $ProcessObjects = @()) + return @($ProcessObjects | + ForEach-Object { Get-AiProcessId -ProcessObject $_ } | + Where-Object { $null -ne $_ }) +} + +function Get-AiPeArchitecture { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $Path) + + $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $reader = [IO.BinaryReader]::new($stream) + try { + if ($reader.ReadUInt16() -ne 0x5A4D) { + throw "'$Path' is not a PE executable." + } + $stream.Position = 0x3C + $peOffset = $reader.ReadInt32() + $stream.Position = $peOffset + if ($reader.ReadUInt32() -ne 0x00004550) { + throw "'$Path' has an invalid PE signature." + } + $machine = $reader.ReadUInt16() + switch ($machine) { + 43620 { return 'Arm64' } + 34404 { return 'X64' } + 332 { return 'X86' } + default { return ('Unknown-0x{0:X4}' -f $machine) } + } + } finally { + $reader.Dispose() + $stream.Dispose() + } +} + +function Get-OllamaManagedPaths { + [CmdletBinding()] + param([string] $LocalAppData = $env:LOCALAPPDATA) + + $installRoot = Join-Path $LocalAppData 'Programs\Ollama' + return [pscustomobject]@{ + InstallRoot = $installRoot + Executable = Join-Path $installRoot 'ollama.exe' + InstallManifest = Join-Path $installRoot '.devconfig-install.json' + VersionMarker = '.devconfig-version' + CacheDirectory = Join-Path $LocalAppData 'DevConfig\ollama\asset-cache' + LegacyRoot = Join-Path $LocalAppData 'DevConfig\ollama\runtime' + StartupRegistryPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' + StartupValueName = 'WindowsDeveloperConfig.Ollama' + } +} + +function Get-OllamaStartupCommand { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $Executable) + return '"' + $Executable.Replace('"', '\"') + '" serve' +} + +function Get-OllamaManagedProcesses { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $InstallRoot) + + $resolvedRoot = [IO.Path]::GetFullPath($InstallRoot).TrimEnd('\') + $rootPrefix = "$resolvedRoot\" + return @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + if (-not $_.ExecutablePath) { + $false + } else { + $executablePath = [IO.Path]::GetFullPath([string]$_.ExecutablePath) + $executablePath.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase) + } + }) +} + +function Stop-OllamaManagedProcesses { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $InstallRoot, + [int] $TimeoutSeconds = 30 + ) + + $processes = @(Get-OllamaManagedProcesses -InstallRoot $InstallRoot) + $ids = @(Get-AiProcessIds -ProcessObjects $processes) + foreach ($processId in $ids) { + Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue + } + foreach ($processId in $ids) { + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while (Get-Process -Id $processId -ErrorAction SilentlyContinue) { + if ((Get-Date) -ge $deadline) { + throw "Managed Ollama process $processId did not exit within $TimeoutSeconds seconds." + } + Start-Sleep -Milliseconds 250 + } + } + return $ids +} + +function Set-OllamaStartupRegistration { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $RegistryPath, + [Parameter(Mandatory)] [string] $ValueName, + [Parameter(Mandatory)] [string] $Executable + ) + New-Item -Path $RegistryPath -Force | Out-Null + $command = Get-OllamaStartupCommand -Executable $Executable + Set-ItemProperty -LiteralPath $RegistryPath -Name $ValueName -Value $command -Type String + return $command +} + +function Remove-OllamaStartupRegistration { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $RegistryPath, + [Parameter(Mandatory)] [string] $ValueName + ) + if (Test-Path -LiteralPath $RegistryPath) { + Remove-ItemProperty -LiteralPath $RegistryPath -Name $ValueName -ErrorAction SilentlyContinue + } +} + +function Remove-OllamaManagedDirectory { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Path, + [ValidateRange(1, 30)] [int] $Attempts = 10 + ) + + if (-not (Test-Path -LiteralPath $Path)) { return } + $lastError = $null + foreach ($attempt in 1..$Attempts) { + try { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + if (-not (Test-Path -LiteralPath $Path)) { return } + } catch { + $lastError = $_ + } + if ($attempt -lt $Attempts) { Start-Sleep -Milliseconds 500 } + } + throw "Could not remove the Dev Config-managed Ollama path '$Path' after $Attempts attempts: $($lastError.Exception.Message)" +} + +function Remove-OllamaManagedInstallation { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Paths, + [Parameter(Mandatory)] [string] $ModelRoot, + [switch] $RemoveModels + ) + + $stopped = @(Stop-OllamaManagedProcesses -InstallRoot $Paths.InstallRoot) + Remove-OllamaStartupRegistration ` + -RegistryPath $Paths.StartupRegistryPath ` + -ValueName $Paths.StartupValueName + Remove-UserPathEntry -Path $Paths.InstallRoot + Remove-UserPathEntry -Path $Paths.LegacyRoot + Remove-OllamaManagedDirectory -Path $Paths.InstallRoot + Remove-OllamaManagedDirectory -Path $Paths.LegacyRoot + Remove-OllamaManagedDirectory -Path $Paths.CacheDirectory + if ($RemoveModels) { + Remove-OllamaManagedDirectory -Path $ModelRoot + } + return [pscustomobject]@{ + StoppedManagedProcessIds = $stopped + StartupRemoved = $true + PathRemoved = $true + RuntimeRemoved = -not (Test-Path -LiteralPath $Paths.InstallRoot) + ModelsPreserved = -not $RemoveModels + } +} + +function Get-AiFreeTcpPort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Get-AmdGpuName { + param([Nullable[int]] $DeviceIndex = $null) + $names = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | + Where-Object { $_.PNPDeviceID -match 'VEN_1002' -or $_.Name -match 'AMD|Radeon' } | + ForEach-Object Name) + if ($null -ne $DeviceIndex) { + if ($DeviceIndex -ge $names.Count) { return $null } + return $names[$DeviceIndex] + } + return Select-AmdGpuName -GpuNames $names +} + +function Select-AmdGpuName { + [CmdletBinding()] + param([AllowEmptyCollection()] [string[]] $GpuNames = @()) + $supported = $GpuNames | Where-Object { Get-AmdGfxTarget -GpuName $_ } | Sort-Object | Select-Object -First 1 + if ($supported) { return $supported } + return $GpuNames | Sort-Object | Select-Object -First 1 +} + +function Resolve-RocmInstallPlan { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture, + [AllowNull()] [string] $GpuName + ) + + if ($Architecture -ne 'X64') { + throw 'AMD ROCm Core SDK 10.0 does not publish native Windows ARM64 packages.' + } + $gfx = if ($GpuName) { Get-AmdGfxTarget -GpuName $GpuName } else { $null } + if (-not $gfx) { + throw "No AMD GPU supported by the ROCm 10.0 Windows matrix was detected. Detected GPU: '$GpuName'." + } + return [pscustomobject]@{ + Architecture = $Architecture + GpuName = $GpuName + GfxTarget = $gfx + Requirement = "rocm[libraries,devel,device-$gfx]==10.0.0" + } +} + +function Get-IntelGpuName { + param([Nullable[int]] $DeviceIndex = $null) + $names = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | + Where-Object { $_.PNPDeviceID -match 'VEN_8086' -or $_.Name -match 'Intel' } | + ForEach-Object Name) + if ($null -ne $DeviceIndex) { + if ($DeviceIndex -ge $names.Count) { return $null } + return $names[$DeviceIndex] + } + return Select-IntelGpuName -GpuNames $names +} + +function Select-IntelGpuName { + [CmdletBinding()] + param([AllowEmptyCollection()] [string[]] $GpuNames = @()) + $supported = $GpuNames | Where-Object { Test-IntelXpuGpuSupported -GpuName $_ } | Sort-Object | Select-Object -First 1 + if ($supported) { return $supported } + return $GpuNames | Sort-Object | Select-Object -First 1 +} + +function Resolve-IntelAiPlan { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture, + [Parameter(Mandatory)] [ValidateSet('Auto', 'CPU', 'GPU', 'NPU')] [string] $Device, + [Parameter(Mandatory)] [ValidateSet('OpenVINO', 'SYCL', 'Full')] [string] $Profile, + [bool] $IntelGpuPresent = $false, + [bool] $IntelNpuPresent = $false + ) + + if ($Architecture -ne 'X64') { + throw 'Intel oneAPI, OpenVINO, PyTorch XPU, and Triton XPU do not publish native Windows ARM64 artifacts.' + } + $selectedDevice = if ($Profile -eq 'SYCL' -and $Device -eq 'Auto') { + 'GPU' + } elseif ($Device -eq 'Auto') { + if ($IntelNpuPresent) { 'NPU' } elseif ($IntelGpuPresent) { 'GPU' } else { 'CPU' } + } else { $Device } + if ($selectedDevice -eq 'GPU' -and -not $IntelGpuPresent) { + throw 'Intel GPU was requested, but no Intel display adapter was detected.' + } + if ($selectedDevice -eq 'NPU' -and -not $IntelNpuPresent) { + throw 'Intel NPU was requested, but no Intel AI Boost/NPU device was detected.' + } + if ($Profile -in @('SYCL', 'Full') -and -not $IntelGpuPresent) { + throw 'The SYCL profile requires a detected Intel GPU because its acceptance kernel uses gpu_selector_v.' + } + return [pscustomobject]@{ + Architecture = $Architecture + Device = $selectedDevice + Profile = $Profile + InstallOpenVino = $Profile -in @('OpenVINO', 'Full') + InstallOneApi = $Profile -in @('SYCL', 'Full') + } +} + +function Test-IntelXpuGpuSupported { + [CmdletBinding()] + param([AllowNull()] [string] $GpuName) + if (-not $GpuName) { return $false } + $normalized = ($GpuName -replace '\((TM|R)\)', '' -replace '\s+', ' ').Trim() + return $normalized -match 'Arc.*(A|B)[0-9]|Arc.*(130V|140V)|Arc.*Graphics|Meteor Lake|Arrow Lake|Lunar Lake|Panther Lake|Core Ultra' +} + +function Get-NvidiaDriverInfo { + [CmdletBinding()] + param([ValidateRange(0, 63)] [int] $DeviceIndex = 0) + + if (-not (Get-Command nvidia-smi -ErrorAction SilentlyContinue)) { + return $null + } + + $result = Invoke-DevConfigNativeCommand -FilePath 'nvidia-smi' -Arguments @( + '--query-gpu=name,driver_version,compute_cap', '--format=csv,noheader,nounits' + ) + $allOutput = @($result.Output -split '\r?\n' | Where-Object { $_ }) + if ($result.ExitCode -ne 0 -or $allOutput.Count -eq 0) { + return $null + } + if ($DeviceIndex -ge $allOutput.Count) { + throw "NVIDIA device index $DeviceIndex was requested, but nvidia-smi reported $($allOutput.Count) device(s)." + } + $output = $allOutput[$DeviceIndex] + + $parts = @($output -split ',' | ForEach-Object { $_.Trim() }) + if ($parts.Count -lt 3) { + throw "nvidia-smi returned an unexpected result: $output" + } + + $driver = [version]$parts[1] + $compute = [version]$parts[2] + return [pscustomobject]@{ + Name = $parts[0] + DriverVersion = $driver + DriverMajor = $driver.Major + ComputeCapability = $compute + } +} + +function Get-CudaReadiness { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [bool] $ToolkitAvailable, + [Parameter(Mandatory)] [bool] $NvidiaGpuPresent, + [Parameter(Mandatory)] [bool] $DriverAvailable + ) + + return [pscustomobject]@{ + ToolkitReady = $ToolkitAvailable + GpuReady = $NvidiaGpuPresent -and $DriverAvailable + Status = if (-not $ToolkitAvailable) { + 'ToolkitMissing' + } elseif (-not $NvidiaGpuPresent) { + 'ToolkitOnlyNoGpu' + } elseif (-not $DriverAvailable) { + 'ToolkitOnlyDriverUnavailable' + } else { + 'Ready' + } + } +} + +function Resolve-PyTorchPlan { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture, + [Parameter(Mandatory)] [ValidateSet('Auto', 'CPU', 'CUDA', 'ROCm', 'XPU')] [string] $Backend, + [Parameter(Mandatory)] [version] $PythonVersion, + [bool] $HasNvidia = $false, + [int] $DriverMajor = 0, + [version] $ComputeCapability = [version]'0.0', + [ValidateSet('NVIDIA', 'AMD', 'Intel', 'None')] [string] $GpuVendor = 'None', + [string] $GpuName, + [string] $AmdGpuName, + [string] $IntelGpuName, + [string] $AmdGfxTarget, + [bool] $HasAmd = $false, + [bool] $HasIntel = $false, + [switch] $SkipTriton + ) + + if ($Architecture -eq 'Arm64') { + if ($PythonVersion -lt [version]'3.11' -or $PythonVersion -ge [version]'3.14') { + throw "PyTorch 2.14 Windows ARM64 wheels require CPython 3.11-3.13; detected $PythonVersion." + } + + $canUseCudaPreview = $PythonVersion.Major -eq 3 -and + $PythonVersion.Minor -eq 13 -and + $HasNvidia -and + $DriverMajor -ge 616 -and + $ComputeCapability.Major -ge 12 + if ($Backend -in @('ROCm', 'XPU')) { + throw "$Backend is not published for native Windows ARM64." + } + if ($Backend -eq 'CUDA' -and -not $canUseCudaPreview) { + throw 'Windows ARM64 CUDA PyTorch requires CPython 3.13, an RTX Spark-class NVIDIA GPU (compute capability 12.x), and developer driver branch 616 or newer.' + } + if ($Backend -eq 'Auto' -and $HasNvidia -and -not $canUseCudaPreview) { + throw 'An NVIDIA GPU is present on Windows ARM64, but it does not meet the CUDA 13.4 PyTorch Developer Preview requirements. Use -Backend CPU to explicitly accept CPU-only PyTorch.' + } + if ($Backend -eq 'Auto' -and -not $canUseCudaPreview -and ($HasAmd -or $GpuVendor -eq 'AMD')) { + throw 'AMD ROCm PyTorch is not published for native Windows ARM64. Use -Backend CPU to explicitly accept CPU-only PyTorch.' + } + if ($Backend -eq 'Auto' -and -not $canUseCudaPreview -and ($HasIntel -or $GpuVendor -eq 'Intel')) { + throw 'Intel XPU PyTorch is not published for native Windows ARM64. Use -Backend CPU to explicitly accept CPU-only PyTorch.' + } + $selectedBackend = if ($Backend -eq 'Auto') { + if ($canUseCudaPreview) { 'CUDA' } else { 'CPU' } + } else { + $Backend + } + } else { + if ($PythonVersion -lt [version]'3.10' -or $PythonVersion -ge [version]'3.15') { + throw "PyTorch 2.14 Windows x64 wheels require CPython 3.10-3.14; detected $PythonVersion." + } + + if ($Backend -eq 'CUDA' -and -not $HasNvidia) { + throw "CUDA backend was requested, but nvidia-smi did not report a usable NVIDIA GPU and driver." + } + if ($Backend -eq 'CUDA' -and $DriverMajor -lt 525) { + throw "CUDA backend was requested, but NVIDIA driver branch $DriverMajor is too old. Install a branch 525 or newer driver." + } + if ($Backend -eq 'CUDA' -and $ComputeCapability -lt [version]'5.0') { + throw "CUDA backend was requested, but NVIDIA compute capability $ComputeCapability is below the supported Windows CUDA wheel minimum of 5.0." + } + $amdPresent = $HasAmd -or $GpuVendor -eq 'AMD' + $amdRocmSupported = $amdPresent -and [bool]$AmdGfxTarget + $intelPresent = $HasIntel -or $GpuVendor -eq 'Intel' + if ($Backend -eq 'ROCm' -and -not $amdRocmSupported) { + throw 'ROCm backend was requested, but no supported Windows AMD GPU/gfx target was detected.' + } + $intelCandidateName = if ($IntelGpuName) { $IntelGpuName } else { $GpuName } + $intelXpuSupported = $intelPresent -and (Test-IntelXpuGpuSupported -GpuName $intelCandidateName) + if ($Backend -eq 'XPU' -and -not $intelXpuSupported) { + throw "XPU backend was requested, but the detected Intel GPU '$intelCandidateName' is not in the validated Windows PyTorch XPU families." + } + if ($Backend -eq 'Auto' -and $amdPresent -and -not $amdRocmSupported -and -not $HasNvidia -and -not $intelXpuSupported) { + throw "An AMD GPU is present, but '$AmdGpuName' is not in the ROCm 10.0 Windows support matrix. Use -Backend CPU to explicitly accept CPU-only PyTorch." + } + if ($Backend -eq 'Auto' -and $intelPresent -and -not $intelXpuSupported -and -not $HasNvidia -and -not $amdRocmSupported) { + throw "An Intel GPU is present, but '$intelCandidateName' is not in the validated Windows PyTorch XPU families. Use -Backend CPU to explicitly accept CPU-only PyTorch." + } + $cudaSupported = $HasNvidia -and $DriverMajor -ge 525 -and + $ComputeCapability -ge [version]'5.0' -and + -not ($ComputeCapability.Major -ge 10 -and $DriverMajor -lt 580) + $selectedBackend = if ($Backend -eq 'Auto') { + if ($cudaSupported) { + 'CUDA' + } elseif ($amdRocmSupported) { + 'ROCm' + } elseif ($intelXpuSupported) { + 'XPU' + } else { + 'CPU' + } + } else { + $Backend + } + if ($Backend -eq 'Auto' -and $HasNvidia -and -not $cudaSupported -and + -not $amdRocmSupported -and -not $intelXpuSupported) { + throw "An NVIDIA GPU is present, but driver branch $DriverMajor and compute capability $ComputeCapability do not match a supported Windows CUDA wheel. Use -Backend CPU to explicitly accept CPU-only PyTorch." + } + } + + $indexUrl = 'https://download.pytorch.org/whl/cpu' + $runtime = 'cpu' + $torchRequirement = 'torch==2.14.0+cpu' + $torchVersion = '2.14.0+cpu' + $directWheelUrl = $null + $directWheelSha256 = $null + $directWheelFileName = $null + $preview = $false + $catalog = (Get-AiCatalogData).Components + $additionalRequirements = @() + if ($selectedBackend -eq 'CUDA') { + if ($Architecture -eq 'Arm64') { + $runtime = 'cu134' + $indexUrl = $null + $preview = $true + $torchVersion = $catalog.NvidiaPyTorchArm64.Version + $directWheelUrl = $catalog.NvidiaPyTorchArm64.Uri + $directWheelSha256 = $catalog.NvidiaPyTorchArm64.Sha256 + $directWheelFileName = 'torch-2.15.0.dev20260904+cu134-cp313-cp313-win_arm64.whl' + $torchRequirement = "torch @ $directWheelUrl#sha256=$directWheelSha256" + } elseif ($ComputeCapability.Major -ge 10 -and $DriverMajor -lt 580) { + throw "This NVIDIA GPU reports compute capability $ComputeCapability and needs a CUDA 13 wheel, but driver branch $DriverMajor is below 580. Update the NVIDIA driver." + } elseif ($DriverMajor -ge 580 -and $ComputeCapability -ge [version]'7.5') { + $runtime = 'cu130' + $indexUrl = 'https://download.pytorch.org/whl/cu130' + $torchVersion = '2.14.0+cu130' + $torchRequirement = 'torch==2.14.0+cu130' + } else { + $runtime = 'cu126' + $indexUrl = 'https://download.pytorch.org/whl/cu126' + $torchVersion = '2.14.0+cu126' + $torchRequirement = 'torch==2.14.0+cu126' + } + } elseif ($selectedBackend -eq 'ROCm') { + $runtime = 'rocm10.0.0' + $indexUrl = 'https://stable.repo.amd.com/rocm/whl-next/' + $torchVersion = '2.13.0+rocm10.0.0' + $torchRequirement = "torch[device-$AmdGfxTarget]==2.13.0+rocm10.0.0" + $additionalRequirements = @( + "torchvision[device-$AmdGfxTarget]==0.28.0+rocm10.0.0", + 'torchaudio==2.11.0.2+rocm10.0.0' + ) + } elseif ($selectedBackend -eq 'XPU') { + $runtime = 'xpu' + $indexUrl = 'https://download.pytorch.org/whl/xpu' + $torchVersion = '2.14.0+xpu' + $torchRequirement = 'torch==2.14.0+xpu' + $additionalRequirements = @('torchvision==0.29.0+xpu') + } + + $installTriton = -not $SkipTriton -and ( + ($selectedBackend -eq 'CUDA' -and $ComputeCapability.Major -ge 8) -or + $selectedBackend -eq 'XPU') + $tritonRequirement = if (-not $installTriton) { + $null + } elseif ($selectedBackend -eq 'XPU') { + 'triton-xpu==3.8.0' + } else { + $catalog.TritonWindows.Package + } + $tritonVersion = if (-not $installTriton) { + $null + } elseif ($selectedBackend -eq 'XPU') { + '3.8.0' + } else { + '3.8.0.post28' + } + + return [pscustomobject]@{ + Architecture = $Architecture + Backend = $selectedBackend + Vendor = switch ($selectedBackend) { + 'CUDA' { 'NVIDIA' } + 'ROCm' { 'AMD' } + 'XPU' { 'Intel' } + default { 'CPU' } + } + DeviceName = switch ($selectedBackend) { + 'CUDA' { $GpuName } + 'ROCm' { $AmdGpuName } + 'XPU' { $intelCandidateName } + default { 'CPU' } + } + AmdGfxTarget = if ($selectedBackend -eq 'ROCm') { $AmdGfxTarget } else { $null } + TorchRequirement = $torchRequirement + TorchVersion = $torchVersion + AdditionalRequirements = $additionalRequirements + IndexUrl = $indexUrl + Runtime = $runtime + Preview = $preview + DirectWheelUrl = $directWheelUrl + DirectWheelSha256 = $directWheelSha256 + DirectWheelFileName = $directWheelFileName + NumpyRequirement = 'numpy==2.5.2' + NumpyVersion = '2.5.2' + InstallTriton = $installTriton + TritonRequirement = $tritonRequirement + TritonVersion = $tritonVersion + TritonReason = if ($SkipTriton) { + 'Triton installation and verification were disabled by the caller.' + } elseif ($installTriton) { + "Compatible PyTorch $selectedBackend stack detected." + } elseif ($selectedBackend -ne 'CUDA') { + "No supported native-Windows Triton package is selected for $selectedBackend." + } elseif ($ComputeCapability.Major -lt 8) { + "Triton Windows requires NVIDIA compute capability 8.0 or newer; detected $ComputeCapability." + } else { + 'Triton installation was disabled by the caller.' + } + } +} + +function Test-PyTorchStateCompatible { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $DesiredStateJson, + [AllowNull()] [string] $CurrentStateJson + ) + + if ([string]::IsNullOrWhiteSpace($CurrentStateJson)) { + return $false + } + + $desired = $DesiredStateJson | ConvertFrom-Json + try { + $current = $CurrentStateJson | ConvertFrom-Json + } catch { + return $false + } + foreach ($property in @('architecture', 'backend', 'index', 'python')) { + if ($current.$property -ne $desired.$property) { + return $false + } + } + $legacyStableTorch = $current.torch -eq 'torch==2.14.0' -and + $desired.torch -match '^torch==2\.14\.0\+(cpu|cu126|cu130)$' + if ($current.torch -ne $desired.torch -and -not $legacyStableTorch) { + return $false + } + if (-not $desired.tritonVersion -and $current.triton) { + return $false + } + return $true +} + +function Test-PyTorchEnvironmentMatches { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $DesiredStateJson, + [AllowNull()] [string] $CurrentStateJson, + [AllowNull()] $InstalledVersions + ) + + if (-not (Test-PyTorchStateCompatible ` + -DesiredStateJson $DesiredStateJson ` + -CurrentStateJson $CurrentStateJson) -or + $null -eq $InstalledVersions) { + return $false + } + + $desired = $DesiredStateJson | ConvertFrom-Json + if ($InstalledVersions.torch -ne $desired.torchVersion -or + $InstalledVersions.numpy -ne $desired.numpyVersion) { + return $false + } + if ($desired.tritonVersion) { + if ($InstalledVersions.triton -ne $desired.tritonVersion) { + return $false + } + } elseif (-not [string]::IsNullOrEmpty($InstalledVersions.triton)) { + return $false + } + + $additionalRequirements = if ($desired.PSObject.Properties.Name -contains 'additionalRequirements') { + @($desired.additionalRequirements) + } else { + @() + } + foreach ($requirement in $additionalRequirements) { + if ($requirement -match '^torchvision(?:\[[^\]]+\])?==(.+)$' -and $InstalledVersions.torchvision -ne $Matches[1]) { + return $false + } + if ($requirement -match '^torchaudio==(.+)$' -and $InstalledVersions.torchaudio -ne $Matches[1]) { + return $false + } + } + return $true +} + +function Get-PyTorchPackageAction { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $DesiredStateJson, + [AllowNull()] [string] $CurrentStateJson, + [AllowNull()] $InstalledVersions + ) + + if (Test-PyTorchEnvironmentMatches ` + -DesiredStateJson $DesiredStateJson ` + -CurrentStateJson $CurrentStateJson ` + -InstalledVersions $InstalledVersions) { + return 'VerifyOnly' + } + return 'Install' +} + +function Test-PyTorchEnvironmentRequiresRecreation { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $DesiredStateJson, + [AllowNull()] [string] $CurrentStateJson, + [AllowNull()] $InstalledVersions + ) + + if (-not (Test-PyTorchStateCompatible ` + -DesiredStateJson $DesiredStateJson ` + -CurrentStateJson $CurrentStateJson)) { + return $true + } + $desired = $DesiredStateJson | ConvertFrom-Json + return $null -ne $InstalledVersions -and + -not $desired.tritonVersion -and + -not [string]::IsNullOrEmpty($InstalledVersions.triton) +} + +function Get-PythonEnvironmentVersions { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $PythonPath) + + if (-not (Test-Path -LiteralPath $PythonPath)) { + return $null + } + $script = @' +import importlib.metadata +import json +import numpy +import torch + +versions = {} +for distribution in ("triton-windows", "triton-xpu", "torchvision", "torchaudio"): + try: + versions[distribution] = importlib.metadata.version(distribution) + except importlib.metadata.PackageNotFoundError: + versions[distribution] = None + +triton_distribution = next( + (name for name in ("triton-windows", "triton-xpu") if versions[name]), + None, +) + +print(json.dumps({ + "torch": torch.__version__, + "numpy": numpy.__version__, + "triton": versions[triton_distribution] if triton_distribution else None, + "triton_distribution": triton_distribution, + "torchvision": versions["torchvision"], + "torchaudio": versions["torchaudio"], +}, sort_keys=True)) +'@ + $result = Invoke-DevConfigNativeCommand -FilePath $PythonPath -Arguments @('-c', $script) + $json = @($result.Output -split '\r?\n' | Where-Object { $_ }) | Select-Object -Last 1 + if ($result.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($json)) { + return $null + } + try { + return $json | ConvertFrom-Json + } catch { + return $null + } +} + +function Test-PythonDistributionVersions { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $PythonPath, + [Parameter(Mandatory)] [hashtable] $Expected + ) + + if (-not (Test-Path -LiteralPath $PythonPath)) { return $false } + $namesJson = @($Expected.Keys) | ConvertTo-Json -Compress + $script = @" +import importlib.metadata +import json +names = json.loads(r'''$namesJson''') +result = {} +for name in names: + try: + result[name] = importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + result[name] = None +print(json.dumps(result, sort_keys=True)) +"@ + $result = Invoke-DevConfigNativeCommand -FilePath $PythonPath -Arguments @('-c', $script) + $json = @($result.Output -split '\r?\n' | Where-Object { $_ }) | Select-Object -Last 1 + if ($result.ExitCode -ne 0 -or -not $json) { return $false } + $installed = $json | ConvertFrom-Json + foreach ($name in $Expected.Keys) { + if ($installed.$name -ne $Expected[$name]) { return $false } + } + return $true +} + +function Assert-PythonArchitecture { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture, + [Parameter(Mandatory)] [string] $PythonMachine + ) + + $normalized = switch -Regex ($PythonMachine) { + '^(AMD64|x86_64)$' { 'X64'; break } + '^(ARM64|aarch64)$' { 'Arm64'; break } + default { $PythonMachine } + } + if ($normalized -ne $Architecture) { + throw "Python architecture '$PythonMachine' does not match Windows architecture '$Architecture'. Remove emulated or conflicting Python installations and rerun the flow." + } +} + +function Get-Python313Path { + [CmdletBinding()] + param([Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture) + + $candidates = [System.Collections.Generic.List[string]]::new() + $launcher = Get-Command py -ErrorAction SilentlyContinue + if ($launcher) { + $selector = if ($Architecture -eq 'Arm64') { '-3.13-arm64' } else { '-3.13-64' } + $launcherResult = Invoke-DevConfigNativeCommand -FilePath $launcher.Source -Arguments @( + $selector, '-c', 'import sys; print(sys.executable)' + ) + $launcherPath = [string](@($launcherResult.Output -split '\r?\n' | Where-Object { $_ }) | Select-Object -First 1) + if ($launcherResult.ExitCode -eq 0 -and $launcherPath) { + [void]$candidates.Add($launcherPath.Trim()) + } + } + foreach ($commandName in @('python3.13', 'python')) { + $command = Get-Command $commandName -ErrorAction SilentlyContinue + if ($command) { + [void]$candidates.Add($command.Source) + } + } + foreach ($candidate in $candidates | Select-Object -Unique) { + $versionResult = Invoke-DevConfigNativeCommand -FilePath $candidate -Arguments @( + '-c', 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' + ) + $version = [string](@($versionResult.Output -split '\r?\n' | Where-Object { $_ }) | Select-Object -First 1) + if ($versionResult.ExitCode -eq 0 -and $version.Trim() -eq '3.13') { + return $candidate + } + } + throw "Native $Architecture CPython 3.13 was installed but could not be resolved. Disable conflicting App Execution Aliases or run the Python 3.13 installer repair." +} + +function Get-PipInstallArguments { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Requirement, + [string] $IndexUrl, + [switch] $DryRun + ) + + $arguments = [System.Collections.Generic.List[string]]::new() + [void]$arguments.Add('-m') + [void]$arguments.Add('pip') + [void]$arguments.Add('install') + if ($DryRun) { + [void]$arguments.Add('--dry-run') + } + + [void]$arguments.Add('--only-binary=:all:') + [void]$arguments.Add($Requirement) + if ($IndexUrl) { + [void]$arguments.Add('--index-url') + [void]$arguments.Add($IndexUrl) + } + return $arguments.ToArray() +} + +function Get-PipLocalWheelInstallArguments { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $WheelPath) + + return @('-m', 'pip', 'install', '--only-binary=:all:', $WheelPath) +} + +function Get-FoundryModelSmokePlan { + return [pscustomobject]@{ + Model = 'qwen3-0.6b' + Marker = 'DEVCONFIG_FOUNDRY_READY' + ApproximateDownloadMb = 593 + License = 'Apache-2.0' + } +} + +function Get-FoundryModelSmokeCommands { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Model, + [Parameter(Mandatory)] [string] $Marker + ) + + return [pscustomobject]@{ + Download = @('model', 'download', $Model) + Complete = @('complete', $Model, "Reply with exactly $Marker and nothing else. /no_think") + } +} + +function Get-OllamaModelSmokePlan { + return [pscustomobject]@{ + Model = 'qwen3:0.6b' + Marker = 'DEVCONFIG_OLLAMA_READY' + ModelBlobSha256 = '7f4030143c1c477224c5434f8272c662a8b042079a0a584f0a27a1684fe2e1fa' + ApproximateDownloadMb = 522 + License = 'Apache-2.0' + } +} + +function Get-OllamaModelManifestPath { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $ModelRoot, + [Parameter(Mandatory)] [string] $Model + ) + + $parts = $Model.Split(':', 2) + $name = $parts[0] + $tag = if ($parts.Count -eq 2) { $parts[1] } else { 'latest' } + return Join-Path $ModelRoot "manifests\registry.ollama.ai\library\$name\$tag" +} + +function Get-LlamaModelSmokePlan { + return [pscustomobject]@{ + Repository = 'Qwen/Qwen3-0.6B-GGUF' + Revision = 'ef4088322893040952513f532f736ddeab518403' + FileName = 'Qwen3-0.6B-Q4_K_M.gguf' + Url = 'https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/resolve/ef4088322893040952513f532f736ddeab518403/Qwen3-0.6B-Q4_K_M.gguf?download=true' + Sha256 = 'b0638f08417a2d3c8652760462eb5407c6e30173cf9608ad0820757a281eea0e' + Size = 396704416 + Marker = 'DEVCONFIG_LLAMA_READY' + License = 'Apache-2.0' + } +} + +function Get-LlamaCodingDemoPlan { + return [pscustomobject]@{ + Repository = 'Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF' + Revision = 'f86cb2c1fa58255f8052cc32aeede1b7482d4361' + FileName = 'qwen2.5-coder-1.5b-instruct-q4_k_m.gguf' + Url = 'https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF/resolve/f86cb2c1fa58255f8052cc32aeede1b7482d4361/qwen2.5-coder-1.5b-instruct-q4_k_m.gguf?download=true' + Sha256 = 'cc324af070c2ecbfd324a30884d2f951a7ff756aba85cb811a6ec436933bb046' + Size = 1117320768 + License = 'Apache-2.0' + } +} + +function New-OllamaGenerateRequest { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Model, + [Parameter(Mandatory)] [string] $Marker + ) + + return [ordered]@{ + model = $Model + prompt = "Return a JSON object whose marker is $Marker. Nothing else." + think = $false + stream = $false + format = [ordered]@{ + type = 'object' + properties = [ordered]@{ + marker = [ordered]@{ type = 'string'; enum = @($Marker) } + } + required = @('marker') + additionalProperties = $false + } + options = [ordered]@{ + seed = 42 + temperature = 0.7 + top_p = 0.8 + top_k = 20 + num_predict = 32 + } + } +} + +function Get-LlamaInferenceArguments { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $ModelPath, + [Parameter(Mandatory)] [string] $Marker + ) + + return @( + '--model', $ModelPath, + '--single-turn', + '--prompt', "Reply with exactly $Marker and nothing else.", + '--reasoning', 'off', + '--grammar', "root ::= `"$Marker`"", + '--seed', '42', + '--temperature', '0.7', + '--top-p', '0.8', + '--top-k', '20', + '--threads', '1', + '--threads-batch', '1', + '--predict', '32', + '--no-display-prompt', + '--simple-io', + '--log-disable' + ) +} + +function Invoke-CheckedCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $FilePath, + [string[]] $ArgumentList = @(), + [string] $DisplayName = $FilePath + ) + + $result = Invoke-DevConfigNativeCommand -FilePath $FilePath -Arguments $ArgumentList + if ($result.Output) { Write-Host $result.Output.TrimEnd() } + if ($result.ExitCode -ne 0) { + throw "$DisplayName failed with exit code $($result.ExitCode)." + } +} + +function Assert-CommandAvailable { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $CommandName, + [Parameter(Mandatory)] [string] $Remediation + ) + + $command = Get-Command $CommandName -ErrorAction SilentlyContinue + if (-not $command) { + throw "$CommandName was not found. $Remediation" + } + return $command +} + +function Get-AiUpdatedPathValue { + [CmdletBinding()] + param( + [AllowEmptyString()] [string] $CurrentValue, + [Parameter(Mandatory)] [string] $Path, + [switch] $Prepend + ) + + $entries = @($CurrentValue -split ';' | Where-Object { $_ -and $_ -ne $Path }) + if ($Prepend) { + return (@($Path) + $entries) -join ';' + } + return (@($entries) + $Path) -join ';' +} + +function Add-UserPathEntry { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Path, + [switch] $Prepend + ) + + $current = [Environment]::GetEnvironmentVariable('Path', 'User') + [Environment]::SetEnvironmentVariable( + 'Path', + (Get-AiUpdatedPathValue -CurrentValue $current -Path $Path -Prepend:$Prepend), + 'User') + $env:Path = Get-AiUpdatedPathValue -CurrentValue $env:Path -Path $Path -Prepend:$Prepend +} + +function Install-VerifiedDirectorySwap { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Source, + [Parameter(Mandatory)] [string] $Destination + ) + + $parent = Split-Path -Parent $Destination + New-Item -ItemType Directory -Path $parent -Force | Out-Null + $newPath = "$Destination.new-$([guid]::NewGuid().ToString('N'))" + $oldPath = "$Destination.old-$([guid]::NewGuid().ToString('N'))" + Move-Item -LiteralPath $Source -Destination $newPath + try { + if (Test-Path -LiteralPath $Destination) { + Move-Item -LiteralPath $Destination -Destination $oldPath + } + Move-Item -LiteralPath $newPath -Destination $Destination + if (Test-Path -LiteralPath $oldPath) { + Remove-Item -LiteralPath $oldPath -Recurse -Force + } + } catch { + if (-not (Test-Path -LiteralPath $Destination) -and (Test-Path -LiteralPath $oldPath)) { + Move-Item -LiteralPath $oldPath -Destination $Destination -ErrorAction SilentlyContinue + } + throw + } finally { + Remove-Item -LiteralPath $newPath -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Remove-UserPathEntry { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $Path) + + $current = [Environment]::GetEnvironmentVariable('Path', 'User') + $entries = @($current -split ';' | Where-Object { $_ -and $_ -ne $Path }) + [Environment]::SetEnvironmentVariable('Path', ($entries -join ';'), 'User') + $processEntries = @($env:Path -split ';' | Where-Object { $_ -and $_ -ne $Path }) + $env:Path = $processEntries -join ';' +} + +function Remove-TemporaryFileWithRetry { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Path, + [int] $MaxAttempts = 12, + [int] $DelayMilliseconds = 5000, + [scriptblock] $RemoveAction = { + param([string] $Target) + Remove-Item -LiteralPath $Target -Force -ErrorAction Stop + } + ) + + if (-not (Test-Path -LiteralPath $Path)) { + return $true + } + + $lastError = $null + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + & $RemoveAction $Path + if (-not (Test-Path -LiteralPath $Path)) { + return $true + } + $lastError = "the file still exists after removal attempt $attempt" + } catch { + $lastError = $_.Exception.Message + } + if ($attempt -lt $MaxAttempts -and $DelayMilliseconds -gt 0) { + Start-Sleep -Milliseconds $DelayMilliseconds + } + } + + Write-Warning ` + -Message "Could not remove temporary file '$Path' after $MaxAttempts attempts. It may remain until the installer releases it or Windows cleans the temporary directory. Last error: $lastError" ` + -WarningAction Continue + return $false +} + +function Install-VerifiedDownload { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [uri] $Uri, + [Parameter(Mandatory)] [string] $Destination, + [Parameter(Mandatory)] [ValidatePattern('^[0-9a-fA-F]{64}$')] [string] $Sha256, + [long] $ExpectedSize = 0 + ) + + if (Test-Path -LiteralPath $Destination) { + $existing = Get-Item -LiteralPath $Destination + $existingHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash + if ($existingHash -eq $Sha256 -and ($ExpectedSize -eq 0 -or $existing.Length -eq $ExpectedSize)) { + return + } + } + + $parent = Split-Path -Parent $Destination + New-Item -ItemType Directory -Path $parent -Force | Out-Null + $temporary = "$Destination.download-$([guid]::NewGuid().ToString('N'))" + try { + Invoke-WebRequest -Uri $Uri -OutFile $temporary -UseBasicParsing + $download = Get-Item -LiteralPath $temporary + if ($ExpectedSize -gt 0 -and $download.Length -ne $ExpectedSize) { + throw "Download size mismatch for '$Uri'. Expected $ExpectedSize bytes; got $($download.Length)." + } + $actualHash = (Get-FileHash -LiteralPath $temporary -Algorithm SHA256).Hash + if ($actualHash -ne $Sha256) { + throw "SHA-256 mismatch for '$Uri'. Expected $Sha256; got $actualHash." + } + Move-Item -LiteralPath $temporary -Destination $Destination -Force + } finally { + if (Test-Path -LiteralPath $temporary) { + [void](Remove-TemporaryFileWithRetry -Path $temporary) + } + } +} + +function Invoke-VerifiedInstaller { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [uri] $Uri, + [Parameter(Mandatory)] [ValidatePattern('^[0-9a-fA-F]{64}$')] [string] $Sha256, + [Parameter(Mandatory)] [string] $SignerPattern, + [string[]] $ArgumentList = @(), + [int[]] $SuccessExitCodes = @(0) + ) + + $temporary = Join-Path ([System.IO.Path]::GetTempPath()) "devconfig-$([guid]::NewGuid().ToString('N')).exe" + try { + Install-VerifiedDownload -Uri $Uri -Destination $temporary -Sha256 $Sha256 + $signature = Get-AuthenticodeSignature -LiteralPath $temporary + if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch $SignerPattern) { + throw "Installer signature validation failed for '$Uri'. Expected a valid signer matching '$SignerPattern'; got '$($signature.Status)' from '$($signature.SignerCertificate.Subject)'." + } + + $process = Start-Process -FilePath $temporary -ArgumentList $ArgumentList -Wait -PassThru + if ($process.ExitCode -notin $SuccessExitCodes) { + throw "Installer '$Uri' failed with exit code $($process.ExitCode)." + } + } finally { + if (Test-Path -LiteralPath $temporary) { + [void](Remove-TemporaryFileWithRetry -Path $temporary) + } + } +} + +function Invoke-VerifiedLocalInstaller { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [ValidatePattern('^[0-9a-fA-F]{64}$')] [string] $Sha256, + [Parameter(Mandatory)] [string] $SignerPattern, + [string[]] $ArgumentList = @(), + [int[]] $SuccessExitCodes = @(0), + [int] $TimeoutSeconds = 7200 + ) + + $actualHash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash + if ($actualHash -ne $Sha256) { + throw "Installer SHA-256 mismatch for '$Path'. Expected $Sha256; got $actualHash." + } + $signature = Get-AuthenticodeSignature -LiteralPath $Path + if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch $SignerPattern) { + throw "Installer signature validation failed for '$Path'." + } + $exitCode = Invoke-DevConfigProcess -FilePath $Path -Arguments $ArgumentList -TimeoutSeconds $TimeoutSeconds + if ($exitCode -notin $SuccessExitCodes) { + throw "Installer '$Path' failed with exit code $exitCode." + } +} + +function Get-CudaNvccPath { + [CmdletBinding()] + param([AllowNull()] [AllowEmptyString()] [string] $ToolkitVersion) + + $cudaPath = [Environment]::GetEnvironmentVariable('CUDA_PATH', 'Machine') + $pathCommand = Get-Command nvcc -ErrorAction SilentlyContinue + $versionedCandidate = if ($ToolkitVersion) { + Join-Path $env:ProgramFiles "NVIDIA GPU Computing Toolkit\CUDA\v$ToolkitVersion\bin\nvcc.exe" + } else { $null } + $cudaRoot = Join-Path $env:ProgramFiles 'NVIDIA GPU Computing Toolkit\CUDA' + $installedCandidates = @(Get-ChildItem -LiteralPath $cudaRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^v[0-9]+\.[0-9]+$' } | + Sort-Object { [version]$_.Name.Substring(1) } -Descending | + ForEach-Object { Join-Path $_.FullName 'bin\nvcc.exe' }) + $candidates = @( + $versionedCandidate, + $(if ($cudaPath) { Join-Path $cudaPath 'bin\nvcc.exe' }), + $(if ($pathCommand) { $pathCommand.Source }), + $installedCandidates + ) | Where-Object { $_ } + + $nvcc = $null + foreach ($candidate in $candidates | Select-Object -Unique) { + if (-not (Test-Path -LiteralPath $candidate)) { + continue + } + $versionResult = Invoke-DevConfigNativeCommand -FilePath $candidate -Arguments @('--version') + $versionOutput = $versionResult.Output + if ($versionResult.ExitCode -eq 0 -and + (-not $ToolkitVersion -or $versionOutput -match "release $([regex]::Escape($ToolkitVersion))")) { + $nvcc = $candidate + break + } + } + if (-not $nvcc) { + throw 'A matching CUDA nvcc.exe was not found. Reopen the terminal and verify CUDA_PATH.' + } + Add-UserPathEntry -Path (Split-Path -Parent $nvcc) + return $nvcc +} + +function Get-MsvcCompilerPath { + [CmdletBinding()] + param([Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture) + + $vsDevCmd = Get-VsDevCmdPath -Architecture $Architecture + $installationPath = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $vsDevCmd)) + $toolsRoot = Join-Path $installationPath 'VC\Tools\MSVC' + $toolset = Get-ChildItem -LiteralPath $toolsRoot -Directory -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | + Select-Object -First 1 + if (-not $toolset) { + throw "No MSVC toolset was found under '$toolsRoot'." + } + + $relativeCandidates = if ($Architecture -eq 'Arm64') { + @('bin\Hostarm64\arm64\cl.exe', 'bin\Hostx64\arm64\cl.exe') + } else { + @('bin\Hostx64\x64\cl.exe') + } + $compiler = $relativeCandidates | + ForEach-Object { Join-Path $toolset.FullName $_ } | + Where-Object { Test-Path -LiteralPath $_ } | + Select-Object -First 1 + if (-not $compiler) { + throw "The MSVC compiler for $Architecture was not found. Re-run the C++ Build Tools configuration." + } + return $compiler +} + +function Get-VsDevCmdPath { + [CmdletBinding()] + param([Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture) + + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (-not (Test-Path -LiteralPath $vswhere)) { + throw 'Visual Studio Installer vswhere.exe was not found after installing the C++ Build Tools workload.' + } + $vswhereResult = Invoke-DevConfigNativeCommand -FilePath $vswhere -Arguments @( + '-all', '-products', 'Microsoft.VisualStudio.Product.BuildTools', '-property', 'installationPath' + ) + $installationOutput = @($vswhereResult.Output -split '\r?\n' | Where-Object { $_ }) + if ($vswhereResult.ExitCode -ne 0) { + throw "vswhere.exe failed while locating Visual Studio Build Tools (exit $($vswhereResult.ExitCode))." + } + return Resolve-VsDevCmdPath -InstallationPaths $installationOutput -Architecture $Architecture +} + +function Resolve-VsDevCmdPath { + [CmdletBinding()] + param( + [AllowEmptyCollection()] [string[]] $InstallationPaths = @(), + [Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture + ) + + $relativeCompilers = if ($Architecture -eq 'Arm64') { + @('bin\Hostarm64\arm64\cl.exe', 'bin\Hostx64\arm64\cl.exe') + } else { + @('bin\Hostx64\x64\cl.exe') + } + + foreach ($rawPath in $InstallationPaths) { + if ([string]::IsNullOrWhiteSpace($rawPath)) { + continue + } + $installationPath = $rawPath.Trim() + $vsDevCmd = Join-Path $installationPath 'Common7\Tools\VsDevCmd.bat' + if (-not (Test-Path -LiteralPath $vsDevCmd)) { + continue + } + $toolsets = Get-ChildItem -LiteralPath (Join-Path $installationPath 'VC\Tools\MSVC') ` + -Directory -ErrorAction SilentlyContinue | + Sort-Object Name -Descending + foreach ($toolset in $toolsets) { + foreach ($relativeCompiler in $relativeCompilers) { + if (Test-Path -LiteralPath (Join-Path $toolset.FullName $relativeCompiler)) { + return $vsDevCmd + } + } + } + } + + throw "No Visual Studio Build Tools installation with an $Architecture MSVC compiler was found. Re-run the architecture-specific C++ Build Tools configuration." +} + +function Import-MsvcEnvironment { + [CmdletBinding()] + param([Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture) + + $vsDevCmd = Get-VsDevCmdPath -Architecture $Architecture + $target = if ($Architecture -eq 'Arm64') { 'arm64' } else { 'x64' } + $vsInstaller = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer' + $command = "set `"PATH=$vsInstaller;%PATH%`" && call `"$vsDevCmd`" -arch=$target -host_arch=$target >nul && set" + $environmentResult = Invoke-DevConfigNativeCommand -FilePath $env:ComSpec -Arguments @('/d', '/s', '/c', $command) + $environmentLines = @($environmentResult.Output -split '\r?\n') + if ($environmentResult.ExitCode -ne 0) { + throw "VsDevCmd failed to initialize the $Architecture compiler environment." + } + foreach ($line in $environmentLines) { + if ($line -match '^([^=]+)=(.*)$') { + [Environment]::SetEnvironmentVariable($Matches[1], $Matches[2], 'Process') + } + } + + $compiler = Get-MsvcCompilerPath -Architecture $Architecture + $env:CC = $compiler + return $compiler +} + +function Get-CudaKernelCompileCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture, + [Parameter(Mandatory)] [string] $VsDevCmd, + [Parameter(Mandatory)] [string] $Nvcc, + [Parameter(Mandatory)] [string] $Source, + [Parameter(Mandatory)] [string] $Output + ) + + $target = if ($Architecture -eq 'Arm64') { 'arm64' } else { 'x64' } + $vsInstaller = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer' + return 'set "PATH={0};%PATH%" && call "{1}" -arch={2} -host_arch={2} >nul && "{3}" -arch=native -o "{4}" "{5}"' -f ` + $vsInstaller, $VsDevCmd, $target, $Nvcc, $Output, $Source +} + +function Find-GitHubReleaseAssetSet { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Repository, + [Parameter(Mandatory)] [string[]] $AssetPatterns, + [Parameter(Mandatory)] [hashtable] $Headers, + [int] $MaxPages = 5 + ) + + for ($page = 1; $page -le $MaxPages; $page++) { + try { + $releaseResponse = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repository/releases?per_page=100&page=$page" -Headers $Headers + $releases = @($releaseResponse | ForEach-Object { $_ }) + } catch { + throw "Could not query official releases for $Repository. GitHub may be unavailable or rate-limiting this network. Wait for the rate limit to reset, or set GITHUB_TOKEN for authenticated API access. $($_.Exception.Message)" + } + foreach ($candidate in $releases | Where-Object { -not $_.draft -and $_.tag_name -match '^b[0-9]+$' }) { + $candidateAssets = @($candidate.assets | ForEach-Object { $_ }) + if ($candidateAssets.Count -ge 30) { + $assetResponse = Invoke-RestMethod -Uri "$($candidate.assets_url)?per_page=100" -Headers $Headers + $candidateAssets = @($assetResponse | ForEach-Object { $_ }) + } + $selectedAssets = @() + $complete = $true + foreach ($pattern in $AssetPatterns) { + $patternMatches = @($candidateAssets | Where-Object { $_.name -match $pattern }) + if ($patternMatches.Count -ne 1) { + $complete = $false + break + } + $selectedAssets += $patternMatches[0] + } + if ($complete) { + return [pscustomobject]@{ + Release = $candidate + Assets = $selectedAssets + } + } + } + if ($releases.Count -lt 100) { + break + } + } + + throw "No rolling $Repository release in the newest $MaxPages API pages contains the complete asset set: $($AssetPatterns -join ', ')." +} + +function Install-VerifiedGitHubReleaseAsset { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Repository, + [Parameter(Mandatory)] [string] $AssetPattern, + [Parameter(Mandatory)] [string] $Destination, + [Parameter(Mandatory)] [string] $VersionMarker, + [Parameter(Mandatory)] [string[]] $RequiredFile + ) + + return Install-VerifiedGitHubReleaseAssets ` + -Repository $Repository ` + -AssetPatterns @($AssetPattern) ` + -Destination $Destination ` + -VersionMarker $VersionMarker ` + -RequiredFile $RequiredFile +} + +function Install-VerifiedGitHubReleaseAssets { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Repository, + [Parameter(Mandatory)] [string[]] $AssetPatterns, + [Parameter(Mandatory)] [string] $Destination, + [Parameter(Mandatory)] [string] $VersionMarker, + [Parameter(Mandatory)] [string[]] $RequiredFile, + [string] $CacheDirectory = '', + [int] $MaxPages = 5 + ) + + $headers = @{ + Accept = 'application/vnd.github+json' + 'User-Agent' = 'WindowsDeveloperConfig' + 'X-GitHub-Api-Version' = '2022-11-28' + } + if ($env:GITHUB_TOKEN) { + $headers.Authorization = "Bearer $env:GITHUB_TOKEN" + } + if ($env:GITHUB_TOKEN) { + $headers['Authorization'] = [string]::Concat('Bea', 'rer ', $env:GITHUB_TOKEN) + } + $assetSet = Find-GitHubReleaseAssetSet ` + -Repository $Repository ` + -AssetPatterns $AssetPatterns ` + -Headers $headers ` + -MaxPages $MaxPages + $release = $assetSet.Release + $assets = @($assetSet.Assets) + + foreach ($asset in $assets) { + if ($asset.digest -notmatch '^sha256:([0-9a-fA-F]{64})$') { + throw "GitHub did not publish a SHA-256 digest for asset '$($asset.name)'; refusing an unverified download." + } + } + + $markerPath = Join-Path $Destination $VersionMarker + $selection = "$($release.tag_name)|$(@($assets | ForEach-Object { "$($_.name)=$($_.digest)" }) -join '|')" + $requiredFilesPresent = @($RequiredFile | Where-Object { + Test-Path -LiteralPath (Join-Path $Destination $_) + }).Count -eq $RequiredFile.Count + if ((Test-Path -LiteralPath $markerPath) -and + $requiredFilesPresent -and + ((Get-Content -LiteralPath $markerPath -Raw).Trim() -eq $selection)) { + return [pscustomobject]@{ + Tag = $release.tag_name + Assets = $assets + Action = 'already-current' + Destination = $Destination + CacheDirectory = $CacheDirectory + } + } + + $hadExistingRuntime = Test-Path -LiteralPath $Destination + $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "devconfig-$([guid]::NewGuid().ToString('N'))" + $extractPath = Join-Path $tempRoot 'expanded' + New-Item -ItemType Directory -Path $extractPath -Force | Out-Null + try { + foreach ($asset in $assets) { + $expectedHash = $asset.digest.Substring(7) + if ($CacheDirectory) { + $releaseCache = Join-Path $CacheDirectory $release.tag_name + New-Item -ItemType Directory -Path $releaseCache -Force | Out-Null + $archivePath = Join-Path $releaseCache $asset.name + } else { + $archivePath = Join-Path $tempRoot $asset.name + } + $cacheValid = (Test-Path -LiteralPath $archivePath) -and + ((Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash -eq $expectedHash) + if (-not $cacheValid) { + $downloadPath = "$archivePath.download-$([guid]::NewGuid().ToString('N'))" + try { + Invoke-WebRequest -Uri $asset.browser_download_url -Headers $headers -OutFile $downloadPath -UseBasicParsing + $downloadHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256).Hash + if ($downloadHash -ne $expectedHash) { + throw "SHA-256 mismatch for '$($asset.name)'. Expected $expectedHash; got $downloadHash." + } + Move-Item -LiteralPath $downloadPath -Destination $archivePath -Force + } finally { + if (Test-Path -LiteralPath $downloadPath) { + Remove-Item -LiteralPath $downloadPath -Force + } + } + } + $actualHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash + if ($actualHash -ne $expectedHash) { + throw "SHA-256 mismatch for '$($asset.name)'. Expected $expectedHash; got $actualHash." + } + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force + } + $missingFiles = @($RequiredFile | Where-Object { + -not (Test-Path -LiteralPath (Join-Path $extractPath $_)) + }) + if ($missingFiles.Count -gt 0) { + throw "Verified release $($release.tag_name) did not contain required files: $($missingFiles -join ', ')." + } + Install-VerifiedDirectorySwap -Source $extractPath -Destination $Destination + Set-Content -LiteralPath $markerPath -Value $selection -Encoding ascii + } finally { + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force + } + } + + return [pscustomobject]@{ + Tag = $release.tag_name + Assets = $assets + Action = $(if ($hadExistingRuntime) { 'upgraded' } else { 'installed' }) + Destination = $Destination + CacheDirectory = $CacheDirectory + } +} + +function Install-VerifiedGitHubLatestAsset { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Repository, + [Parameter(Mandatory)] [string] $AssetPattern, + [Parameter(Mandatory)] [string] $Destination, + [Parameter(Mandatory)] [string] $VersionMarker, + [Parameter(Mandatory)] [string] $RequiredFile, + [string] $CacheDirectory = '' + ) + + $headers = @{ + Accept = 'application/vnd.github+json' + 'User-Agent' = 'WindowsDeveloperConfig' + 'X-GitHub-Api-Version' = '2022-11-28' + } + if ($env:GITHUB_TOKEN) { + $headers.Authorization = ('{0} {1}' -f 'Bearer', $env:GITHUB_TOKEN) + } + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repository/releases/latest" -Headers $headers + if ($release.draft -or $release.prerelease) { + throw "The latest $Repository release '$($release.tag_name)' is not stable." + } + $assets = @($release.assets | Where-Object { $_.name -match $AssetPattern }) + if ($assets.Count -ne 1) { + throw "Expected one stable $Repository asset matching '$AssetPattern'; found $($assets.Count)." + } + $asset = $assets[0] + if ($asset.digest -notmatch '^sha256:([0-9a-fA-F]{64})$') { + throw "GitHub did not publish a SHA-256 digest for '$($asset.name)'." + } + $selection = "$($release.tag_name)|$($asset.name)|$($asset.digest)" + $markerPath = Join-Path $Destination $VersionMarker + if ((Test-Path -LiteralPath $markerPath) -and + (Test-Path -LiteralPath (Join-Path $Destination $RequiredFile)) -and + ((Get-Content -LiteralPath $markerPath -Raw).Trim() -eq $selection)) { + $cachedAsset = if ($CacheDirectory) { + Join-Path (Join-Path $CacheDirectory $release.tag_name) $asset.name + } else { $null } + return [pscustomobject]@{ + Tag = $release.tag_name + Asset = $asset + Action = 'already-current' + CachePath = $cachedAsset + } + } + + $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "devconfig-$([guid]::NewGuid().ToString('N'))" + $expected = $asset.digest.Substring(7) + $archive = if ($CacheDirectory) { + $releaseCache = Join-Path $CacheDirectory $release.tag_name + New-Item -ItemType Directory -Path $releaseCache -Force | Out-Null + Join-Path $releaseCache $asset.name + } else { + Join-Path $tempRoot $asset.name + } + $expanded = Join-Path $tempRoot 'expanded' + New-Item -ItemType Directory -Path $expanded -Force | Out-Null + try { + $cacheValid = (Test-Path -LiteralPath $archive) -and + ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -eq $expected) + if (-not $cacheValid) { + $downloadPath = "$archive.download-$([guid]::NewGuid().ToString('N'))" + try { + Invoke-WebRequest -Uri $asset.browser_download_url -Headers $headers -OutFile $downloadPath -UseBasicParsing + $downloadHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256).Hash + if ($downloadHash -ne $expected) { + throw "SHA-256 mismatch for '$($asset.name)'. Expected $expected; got $downloadHash." + } + Move-Item -LiteralPath $downloadPath -Destination $archive -Force + } finally { + Remove-Item -LiteralPath $downloadPath -Force -ErrorAction SilentlyContinue + } + } + $actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash + if ($actual -ne $expected) { + throw "SHA-256 mismatch for '$($asset.name)'. Expected $expected; got $actual." + } + Expand-Archive -LiteralPath $archive -DestinationPath $expanded -Force + if (-not (Test-Path -LiteralPath (Join-Path $expanded $RequiredFile))) { + throw "Verified asset '$($asset.name)' did not contain '$RequiredFile'." + } + Install-VerifiedDirectorySwap -Source $expanded -Destination $Destination + Set-Content -LiteralPath $markerPath -Value $selection -Encoding ascii + } finally { + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + return [pscustomobject]@{ + Tag = $release.tag_name + Asset = $asset + Action = 'installed-or-upgraded' + CachePath = $archive + } +} + +function Wait-JsonEndpoint { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [uri] $Uri, + [int] $TimeoutSeconds = 30 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + try { + return Invoke-RestMethod -Uri $Uri -TimeoutSec 5 + } catch { + Start-Sleep -Seconds 1 + } + } while ((Get-Date) -lt $deadline) + + throw "Endpoint '$Uri' did not become ready within $TimeoutSeconds seconds." +} diff --git a/src/Workloads/_common/apply-configuration.ps1 b/src/Workloads/_common/apply-configuration.ps1 index 9f8b9494..26d055c1 100644 --- a/src/Workloads/_common/apply-configuration.ps1 +++ b/src/Workloads/_common/apply-configuration.ps1 @@ -39,7 +39,8 @@ param( # bound to Mandatory parameters. Some flows (e.g. mac-comfort-shell) # have no post-install CLI to verify - the DSC only installs a font # and pwsh - so they legitimately pass @() here. - [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $RequireCommands + [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $RequireCommands, + [switch] $DeferSentinel ) $ErrorActionPreference = 'Stop' @@ -120,4 +121,6 @@ foreach ($cmd in $RequireCommands) { Write-Host "$cmd : $(& $cmd --version 2>&1 | Select-Object -First 1)" } -Write-Host "INSTALL_OK: $Id" +if (-not $DeferSentinel) { + Write-Host "INSTALL_OK: $Id" +} diff --git a/src/Workloads/_common/collect-ai-hardware.ps1 b/src/Workloads/_common/collect-ai-hardware.ps1 new file mode 100644 index 00000000..020e17f6 --- /dev/null +++ b/src/Workloads/_common/collect-ai-hardware.ps1 @@ -0,0 +1,20 @@ +<# +.SYNOPSIS + Emit a portable JSON hardware inventory for AI workload planning. +#> +[CmdletBinding()] +param( + [string] $OutputPath = (Join-Path $env:LOCALAPPDATA 'DevConfig\reports\hardware-latest.json') +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'direct-setup.ps1') +. (Join-Path $PSScriptRoot 'ai-report.ps1') + +$report = New-AiWorkloadReport -Id 'hardware-inventory' -Request @{ PlanOnly = $true } +$report.result.ready = $true +$report.completedAtUtc = (Get-Date).ToUniversalTime().ToString('o') +Write-DevConfigTextFile -Path $OutputPath -Content ($report | ConvertTo-Json -Depth 20) +Write-Host "AI_HARDWARE_REPORT: $OutputPath" diff --git a/src/Workloads/_common/content-hashes.ps1 b/src/Workloads/_common/content-hashes.ps1 new file mode 100644 index 00000000..1e485a23 --- /dev/null +++ b/src/Workloads/_common/content-hashes.ps1 @@ -0,0 +1,84 @@ +$Script:DevConfigWorkloadContentHashes = @{ + '_common\ai-catalog.psd1' = '6665b7cf7259fc0837fe4786f519c5043885eded3304dc0fe788837b937f7804' + 'cuda\smoke.cu' = '6252383bda8856daa14c4f315961e17d4de3bdba1cba6bf4c09a5d0aa52a2a6f' + 'dotnet\configuration.winget' = 'cba2c6873cee7eff241b6d7698d773f8575cfc60a6e9d7dbb8986f4ecea5c048' + 'go\configuration.winget' = '552e6fe17baa47df8d1f429735cdd718bdb61b052a86cc47e8f8c6c2cd22232c' + 'intel-ai\openvino-smoke.py' = '0b71b5f5351dd7536c6352dc02ffd523fa89f2fa60a23ff27d45fd3e8f9c1326' + 'intel-ai\sycl-smoke.cpp' = '6ed0361d8374bcae060b84469b3b167738f016abc9f3b46ba5f19ad870bd74b7' + 'java\configuration.winget' = 'f36edd7ca81b9389ba490505892d6ca5068d5942a249a98f3997356300b8447e' + 'php\configuration.winget' = '34e040193202a8cb0e4cadcefaf5bd678e9fb775bf5ad428d94c78f326604c06' + 'powershell\configuration.winget' = 'b86d0cc7dea26309a14478e5a94304dc9b037fe32220bdc83da8318ed60e220d' + 'python\configuration.winget' = 'bfec7eaad24ba3c5913e7713dafef31d6ac62c53005cc3fb6688db33dbf66f13' + 'pytorch\smoke.py' = 'f28b8ca06e0a2832684f1342626c74904c13d575ce96282f86cf66f44d3eba68' + 'pytorch\triton-smoke.py' = 'a2d2a538b1b30315f249753ccad68d6977bf85d711e3e3541c52ae16268814f4' + 'pytorch\xpu-smoke.py' = '7cba1b04e7ecbce0c24a9188ff847cd3c2fd9792810b5fd4324728ad21786c50' + 'rocm\hip-smoke.cpp' = 'ece1ed905b3afc7ceb918cc3ff751d624b8d681444b1c507a74cfef3d326034d' + 'rust\configuration.winget' = 'ea58a4b6dfe1aedcc1af4674b69dd376609c67140ee6a60dba0060edb266c701' + 'sql\configuration.winget' = '99472e573c10316a17be12d45da834e22be4b1b774afc5c15fdefe3d63bb98e7' + 'typescript\configuration.winget' = '826e1755d85798e376baa00a9891c58fd2dd6da67d0f54486ad957202ee7c8b1' + 'winappcli\configuration.winget' = 'd61b53240fbdef910ea7a92250add1793ae599f57217c83052bd6aed490ead4c' + 'winforms\configuration.winget' = '39de8aee958e1e4989fd5b484a45a36537a7f8cb72dc966fa3afa4d0e8b9e34f' + 'winui\configuration.winget' = '0c39e677f5e44b90eb51cc19e529aed8251d90d59f6fbea9ee58bcbb3d5a11f2' +} + +function Get-DevConfigCanonicalWorkloadHash { + param([Parameter(Mandatory)] [string] $Path) + + $bytes = [IO.File]::ReadAllBytes($Path) + $stream = [IO.MemoryStream]::new() + try { + for ($index = 0; $index -lt $bytes.Length; $index++) { + if ($bytes[$index] -eq 13 -and + $index + 1 -lt $bytes.Length -and + $bytes[$index + 1] -eq 10) { + $stream.WriteByte(10) + $index++ + } else { + $stream.WriteByte($bytes[$index]) + } + } + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash($stream.ToArray()))).Replace('-', '').ToLowerInvariant() + } finally { + $sha.Dispose() + } + } finally { + $stream.Dispose() + } +} + +function Assert-DevConfigWorkloadContent { + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $WorkloadsRoot) + + $root = (Get-Item -LiteralPath $WorkloadsRoot -Force).FullName.TrimEnd('\') + $actualFiles = @(Get-ChildItem -LiteralPath $root -Recurse -File -Force | + Where-Object { $_.Extension -ne '.ps1' }) + $actualPaths = @($actualFiles | ForEach-Object { + $_.FullName.Substring($root.Length).TrimStart([char]'\') + }) + + $failures = [System.Collections.Generic.List[string]]::new() + foreach ($entry in $Script:DevConfigWorkloadContentHashes.GetEnumerator()) { + $path = Join-Path $root $entry.Key + if (-not (Test-Path -LiteralPath $path)) { + [void]$failures.Add("$($entry.Key) [missing]") + continue + } + $actualHash = Get-DevConfigCanonicalWorkloadHash -Path $path + if ($actualHash -ne $entry.Value) { + [void]$failures.Add("$($entry.Key) [SHA-256 mismatch]") + } + } + foreach ($path in $actualPaths) { + if (-not $Script:DevConfigWorkloadContentHashes.ContainsKey($path)) { + [void]$failures.Add("$path [not declared by signed content manifest]") + } + } + + if ($failures.Count -gt 0) { + throw "The Workloads content manifest failed verification: $($failures -join '; ')" + } + Write-Host " Verified $($actualFiles.Count) non-PowerShell workload files." -ForegroundColor DarkGray +} diff --git a/src/Workloads/_common/direct-setup.ps1 b/src/Workloads/_common/direct-setup.ps1 new file mode 100644 index 00000000..be624559 --- /dev/null +++ b/src/Workloads/_common/direct-setup.ps1 @@ -0,0 +1,229 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$stepsRoot = Join-Path $PSScriptRoot '..\..\windows-dev-config\steps' +. (Join-Path $PSScriptRoot 'ai-support.ps1') +. (Join-Path $stepsRoot '_environment.ps1') +. (Join-Path $stepsRoot '_elevation.ps1') +. (Join-Path $stepsRoot '_retry.ps1') +. (Join-Path $stepsRoot '_step-runner.ps1') +. (Join-Path $stepsRoot '_winget.ps1') + +Enable-AiUtf8Console + +function Write-AiPhase { + param( + [Parameter(Mandatory)] [string] $Name, + [string] $Detail = '' + ) + Write-Host '' + Write-Host "=== $Name ===" -ForegroundColor Cyan + if ($Detail) { + Write-Host $Detail -ForegroundColor DarkGray + } +} + +function Assert-AiAdministrator { + if (-not (Test-DevConfigIsAdmin)) { + throw 'This setup needs Administrator rights. Re-run it from an elevated PowerShell window, or launch it from Command Palette and accept the UAC prompt.' + } +} + +function Ensure-AiWingetPackage { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Id, + [switch] $PlanOnly + ) + + if ($PlanOnly) { + return [pscustomobject]@{ Id = $Id; Action = 'install-or-upgrade'; Source = 'winget' } + } + + Initialize-DevConfigWinGet + $action = Ensure-DevConfigWingetPackage -Id $Id + Update-DevConfigSessionPath + $evidence = Get-AiWingetPackageEvidence -Id $Id + return [pscustomobject]@{ Id = $Id; Action = $action; Source = 'winget'; Evidence = $evidence } +} + +function Get-AiWingetPackageAction { + param([Parameter(Mandatory)] [ValidateSet('Absent', 'UpgradeAvailable', 'Current')] [string] $State) + switch ($State) { + 'Absent' { return 'install' } + 'UpgradeAvailable' { return 'upgrade' } + 'Current' { return 'skip' } + } +} + +function Get-AiWingetPackageEvidence { + param([Parameter(Mandatory)] [string] $Id) + + try { + if ($Script:DevConfigWinGetMode -eq 'Cli') { + return (Invoke-DevConfigWingetCli -Arguments @( + 'list', '--id', $Id, '--exact', '--source', 'winget', '--accept-source-agreements' + )).Output + } + $package = Get-WinGetPackage -Id $Id -Source winget -MatchOption EqualsCaseInsensitive + if (-not $package) { return $null } + return ConvertTo-AiWingetPackageEvidence -Package $package -RequestedId $Id + } catch { + Write-Warning "Could not collect WinGet evidence for '$Id': $($_.Exception.Message)" + return [ordered]@{ id = $Id; source = 'winget'; evidenceUnavailable = $true } + } +} + +function Get-AiObjectPropertyValue { + param( + [Parameter(Mandatory)] $InputObject, + [Parameter(Mandatory)] [string[]] $Names + ) + foreach ($name in $Names) { + $property = $InputObject.PSObject.Properties[$name] + if ($property) { return $property.Value } + } + return $null +} + +function ConvertTo-AiWingetPackageEvidence { + param( + [Parameter(Mandatory)] $Package, + [Parameter(Mandatory)] [string] $RequestedId + ) + $resolvedId = Get-AiObjectPropertyValue -InputObject $Package -Names @('Id', 'PackageIdentifier', 'PackageId') + if (-not $resolvedId) { $resolvedId = $RequestedId } + return [ordered]@{ + id = [string]$resolvedId + name = [string](Get-AiObjectPropertyValue -InputObject $Package -Names @('Name', 'PackageName')) + installedVersion = [string](Get-AiObjectPropertyValue -InputObject $Package -Names @('InstalledVersion', 'Version')) + availableVersion = [string](Get-AiObjectPropertyValue -InputObject $Package -Names @('AvailableVersion', 'LatestVersion')) + updateAvailable = [bool](Get-AiObjectPropertyValue -InputObject $Package -Names @('IsUpdateAvailable', 'UpdateAvailable')) + source = 'winget' + } +} + +function Ensure-AiVisualCppTools { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture, + [switch] $PlanOnly + ) + + $package = Ensure-AiWingetPackage -Id 'Microsoft.VisualStudio.2022.BuildTools' -PlanOnly:$PlanOnly + if ($PlanOnly) { + return [pscustomobject]@{ + Package = $package + Action = 'ensure-vctools-workload' + Architecture = $Architecture + } + } + + try { + $compiler = Get-MsvcCompilerPath -Architecture $Architecture + return [pscustomobject]@{ Package = $package; Action = 'already-current'; Compiler = $compiler } + } catch { + Write-Host " Adding the $Architecture C++ Build Tools workload..." -ForegroundColor DarkCyan + } + + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + $installPath = [string](& $vswhere -latest -products Microsoft.VisualStudio.Product.BuildTools -property installationPath | + Select-Object -First 1) + $installPath = $installPath.Trim() + if (-not $installPath) { + throw 'Visual Studio Build Tools installation path could not be determined.' + } + + $stagingRoot = Join-Path $env:ProgramFiles 'WindowsDeveloperConfig\Installers' + New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null + $bootstrapper = Join-Path $stagingRoot "vs_BuildTools-$([guid]::NewGuid().ToString('N')).exe" + Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vs_BuildTools.exe' -OutFile $bootstrapper -UseBasicParsing + $signature = Get-AuthenticodeSignature -LiteralPath $bootstrapper + $signerName = $signature.SignerCertificate.GetNameInfo( + [System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) + if ($signature.Status -ne 'Valid' -or $signerName -ne 'Microsoft Corporation') { + throw "Visual Studio bootstrapper signature validation failed: $($signature.Status), $signerName" + } + $arguments = @( + 'modify', '--installPath', "`"$installPath`"", + '--channelId', 'VisualStudio.17.Release', + '--productId', 'Microsoft.VisualStudio.Product.BuildTools', + '--add', 'Microsoft.VisualStudio.Workload.VCTools' + ) + if ($Architecture -eq 'Arm64') { + $arguments += @('--add', 'Microsoft.VisualStudio.Component.VC.Tools.ARM64') + } + $arguments += @('--includeRecommended', '--quiet', '--wait', '--norestart', '--nocache') + try { + $exitCode = Invoke-DevConfigProcess -FilePath $bootstrapper -Arguments $arguments -TimeoutSeconds 5400 + if ($exitCode -notin @(0, 3010)) { + throw "Visual Studio Build Tools bootstrapper exited with code $exitCode. Review $env:TEMP\dd_*.log." + } + } finally { + [void](Remove-TemporaryFileWithRetry -Path $bootstrapper) + } + + $compiler = Get-MsvcCompilerPath -Architecture $Architecture + return [pscustomobject]@{ Package = $package; Action = 'workload-added'; Compiler = $compiler } +} + +function Ensure-AiCudaToolkit { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('X64', 'Arm64')] [string] $Architecture, + [switch] $PlanOnly + ) + + $plan = Resolve-CudaInstallPlan -Architecture $Architecture -WindowsBuild (Get-WindowsBuildNumber) + if ($Architecture -eq 'X64') { + $package = Ensure-AiWingetPackage -Id 'Nvidia.CUDA' -PlanOnly:$PlanOnly + $nvcc = if ($PlanOnly) { $null } else { Get-CudaNvccPath -ToolkitVersion $null } + $versionOutput = if ($nvcc) { (& $nvcc --version 2>&1 | Out-String).Trim() } else { $null } + return [pscustomobject]@{ + Action = $package.Action + ToolkitVersion = $null + Source = 'winget' + Nvcc = $nvcc + VersionEvidence = $versionOutput + PackageEvidence = $(if ($PlanOnly) { $null } else { $package.Evidence }) + } + } + if ($PlanOnly) { + return [pscustomobject]@{ + Action = 'install-or-verify-preview' + ToolkitVersion = $plan.ToolkitVersion + Source = 'direct' + Uri = $plan.InstallerUrl + Sha256 = $plan.InstallerSha256 + } + } + + try { + $existingNvcc = Get-CudaNvccPath -ToolkitVersion $plan.ToolkitVersion + return [pscustomobject]@{ + Action = 'already-current' + ToolkitVersion = $plan.ToolkitVersion + Source = 'direct' + Nvcc = $existingNvcc + } + } catch { + Write-Host ' Installing NVIDIA CUDA Toolkit 13.4 Developer Preview for Windows ARM64...' -ForegroundColor DarkCyan + } + + $cacheRoot = Join-Path $env:ProgramData 'WindowsDeveloperConfig\cache\nvidia-cuda\13.4.0' + $installer = Join-Path $cacheRoot 'cuda_13.4.0_windows_arm64.exe' + Install-VerifiedDownload -Uri $plan.InstallerUrl -Destination $installer -Sha256 $plan.InstallerSha256 + Invoke-VerifiedLocalInstaller ` + -Path $installer ` + -Sha256 $plan.InstallerSha256 ` + -SignerPattern 'NVIDIA' ` + -ArgumentList @('-s') ` + -SuccessExitCodes @(0, 3010) + $nvcc = Get-CudaNvccPath -ToolkitVersion $plan.ToolkitVersion + return [pscustomobject]@{ + Action = 'installed' + ToolkitVersion = $plan.ToolkitVersion + Source = 'direct' + Nvcc = $nvcc + } +} diff --git a/src/Workloads/cuda/install.ps1 b/src/Workloads/cuda/install.ps1 new file mode 100644 index 00000000..9dc9b689 --- /dev/null +++ b/src/Workloads/cuda/install.ps1 @@ -0,0 +1,222 @@ +<# +.SYNOPSIS + Install the NVIDIA CUDA Toolkit and compile/execute a minimal GPU kernel. + +.PARAMETER ToolkitOnly + Permit installing/verifying nvcc when no usable NVIDIA GPU and driver are + present. By default the flow fails before installation when no NVIDIA GPU is + detected and fails after installation when nvidia-smi is not usable. + +.PARAMETER SkipWorkloadSmoke + Skip compiling and executing the CUDA kernel. The default proves that the + compiler, host toolchain, driver, and GPU work together. + +.PARAMETER DeviceIndex + Zero-based NVIDIA device index used for nvidia-smi qualification and kernel + execution on same-vendor multi-adapter systems. +#> +[CmdletBinding()] +param( + [switch] $ToolkitOnly, + [switch] $SkipWorkloadSmoke, + [ValidateRange(0, 63)] [int] $DeviceIndex = 0, + [switch] $PlanOnly, + [string] $ReportPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_common\direct-setup.ps1') +. (Join-Path $PSScriptRoot '..\_common\ai-report.ps1') + +$architecture = Get-DevConfigArchitecture +$catalog = (Get-AiCatalog).Components +$component = if ($architecture -eq 'Arm64') { $catalog.CudaArm64 } else { $catalog.CudaX64 } +$report = New-AiWorkloadReport -Id 'cuda' -Request @{ + ToolkitOnly = [bool]$ToolkitOnly + SkipWorkloadSmoke = [bool]$SkipWorkloadSmoke + DeviceIndex = $DeviceIndex + PlanOnly = [bool]$PlanOnly +} +if (-not $ReportPath) { $ReportPath = Get-AiDefaultReportPath -Id 'cuda' } +trap { + Write-AiFailureReport -Report $report -Path $ReportPath -ErrorRecord $_ + throw $_ +} +try { + $plan = Resolve-CudaInstallPlan -Architecture $architecture -WindowsBuild (Get-WindowsBuildNumber) +} catch { + if ($PlanOnly) { + [void]$report.result.blockers.Add($_.Exception.Message) + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host 'PLAN_UNSUPPORTED: cuda' + return + } + throw +} +if (-not $PlanOnly) { Assert-AiAdministrator } + +$gpu = Get-NvidiaGpu +$driver = $null +$driverError = $null +if ($gpu) { + try { + $driver = Get-NvidiaDriverInfo -DeviceIndex $DeviceIndex + } catch { + $driverError = $_.Exception.Message + } +} +if (-not $gpu -and -not $ToolkitOnly) { + if ($PlanOnly) { + [void]$report.result.blockers.Add('No NVIDIA GPU detected; default kernel acceptance would fail. Use -ToolkitOnly for compiler-only planning.') + } else { + throw "No NVIDIA GPU was detected. CUDA Toolkit can be installed without a GPU only with -ToolkitOnly; GPU execution requires supported NVIDIA hardware and a current driver." + } +} +if ($driverError) { + if ($PlanOnly) { + [void]$report.result.blockers.Add($driverError) + } else { + throw $driverError + } +} +if ($gpu -and -not $driver -and -not $driverError -and -not $ToolkitOnly) { + $message = "NVIDIA device index $DeviceIndex is present, but nvidia-smi did not report a usable driver. Install/update the NVIDIA driver and rerun." + if ($PlanOnly) { + [void]$report.result.blockers.Add($message) + } else { + throw $message + } +} +if ($driver -and -not $ToolkitOnly) { + $hardwareError = if ($architecture -eq 'Arm64' -and + ($driver.DriverVersion -lt [version]'616.0' -or $driver.ComputeCapability.Major -lt 12)) { + "CUDA 13.4 ARM64 Developer Preview requires driver 616+ and compute capability 12.x; device index $DeviceIndex reports driver $($driver.DriverVersion), capability $($driver.ComputeCapability)." + } elseif ($architecture -eq 'X64' -and + ($driver.DriverVersion -lt [version]'580.0' -or $driver.ComputeCapability -lt [version]'7.5')) { + "The current stable CUDA 13 x64 flow requires driver 580+ and compute capability 7.5+; device index $DeviceIndex reports driver $($driver.DriverVersion), capability $($driver.ComputeCapability). Use -ToolkitOnly for compiler-only setup." + } else { + $null + } + if ($hardwareError) { + if ($PlanOnly) { + [void]$report.result.blockers.Add($hardwareError) + } else { + throw $hardwareError + } + } +} + +Write-AiPhase -Name 'Plan' -Detail "$architecture / NVIDIA CUDA $($plan.ToolkitVersion)" +Add-AiReportAcquisition -Report $report -Entry ([ordered]@{ + component = $component.Component + vendor = $component.Vendor + architecture = $architecture + maturity = $component.Maturity + sourceType = $component.SourceType + packageId = Get-AiCatalogValue -Entry $component -Name 'PackageId' + version = Get-AiCatalogValue -Entry $component -Name 'Version' + uri = Get-AiCatalogValue -Entry $component -Name 'Uri' + sha256 = Get-AiCatalogValue -Entry $component -Name 'Sha256' + versionPolicy = $component.VersionPolicy + integrity = $component.Integrity + cachePath = $component.CachePath + installPath = $component.InstallPath + reasonNormalChannelInsufficient = $component.NormalChannelLimitation + expectedStableSource = $component.ExpectedStableSource + migrationTrigger = $component.MigrationTrigger + cleanupUpgrade = $component.CleanupUpgrade + promotionCandidate = Get-AiCatalogValue -Entry $component -Name 'PromotionCandidate' + action = if ($PlanOnly) { 'planned' } else { 'pending' } +}) + +$toolchain = Ensure-AiVisualCppTools -Architecture $architecture -PlanOnly:$PlanOnly +Add-AiReportPhase -Report $report -Name 'host-compiler' -Status $(if ($PlanOnly) { 'planned' } else { 'ready' }) -Evidence $toolchain + +$cudaAcquisition = Ensure-AiCudaToolkit -Architecture $architecture -PlanOnly:$PlanOnly +$report.acquisitions[0].action = $cudaAcquisition.Action +if (-not $PlanOnly -and $architecture -eq 'X64') { + $report.acquisitions[0].packageEvidence = $cudaAcquisition.PackageEvidence +} +if ($PlanOnly) { + Add-AiReportPhase -Report $report -Name 'cuda-kernel' -Status 'planned' -Evidence @{ + source = (Join-Path $PSScriptRoot 'smoke.cu') + target = "NVIDIA device index $DeviceIndex" + driver = $driver + } + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host $(if ($report.result.blockers.Count) { 'PLAN_UNSUPPORTED: cuda' } else { 'PLAN_OK: cuda' }) + return +} + +$nvcc = Get-CudaNvccPath -ToolkitVersion $plan.ToolkitVersion +Invoke-CheckedCommand -FilePath $nvcc -ArgumentList @('--version') -DisplayName 'CUDA compiler verification' +$nvccVersionEvidence = (& $nvcc --version 2>&1 | Out-String).Trim() +if ($nvccVersionEvidence -match 'release\s+([0-9]+\.[0-9]+)') { + $report.acquisitions[0].version = $Matches[1] +} +$readiness = Get-CudaReadiness ` + -ToolkitAvailable $true ` + -NvidiaGpuPresent ([bool]$gpu) ` + -DriverAvailable ([bool]$driver) + +Write-Host 'CUDA_TOOLKIT_READY: nvcc is installed and runnable.' +if ($readiness.GpuReady) { + Write-Host "CUDA_GPU_READY: $($driver.Name), driver $($driver.DriverVersion), compute capability $($driver.ComputeCapability)." +} elseif ($ToolkitOnly) { + Write-Warning "CUDA toolkit is ready, but GPU execution is not: $($readiness.Status). Install/update the NVIDIA driver and confirm 'nvidia-smi' succeeds." +} else { + throw "CUDA Toolkit is installed, but no usable NVIDIA driver/GPU was reported by nvidia-smi. Update the NVIDIA driver, reboot if requested, and rerun this flow." +} + +$kernelReady = $false +if ($SkipWorkloadSmoke -or -not $readiness.GpuReady) { + Write-Warning 'CUDA_WORKLOAD_SMOKE_SKIPPED: the toolkit is installed, but a compiled GPU kernel was not executed.' +} else { + $vsDevCmd = Get-VsDevCmdPath -Architecture $architecture + $temporary = Join-Path ([System.IO.Path]::GetTempPath()) "devconfig-cuda-$([guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $temporary -Force | Out-Null + try { + $executable = Join-Path $temporary 'cuda-smoke.exe' + $compileCommand = Get-CudaKernelCompileCommand ` + -Architecture $architecture ` + -VsDevCmd $vsDevCmd ` + -Nvcc $nvcc ` + -Source (Join-Path $PSScriptRoot 'smoke.cu') ` + -Output $executable + & $env:ComSpec /d /s /c $compileCommand + if ($LASTEXITCODE -ne 0) { + throw "CUDA smoke kernel compilation failed with exit code $LASTEXITCODE." + } + $output = (& $executable $DeviceIndex 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $output -notmatch '^CUDA_KERNEL_READY') { + throw "CUDA smoke kernel failed on the GPU (exit $LASTEXITCODE, output '$output')." + } + Write-Host 'CUDA_WORKLOAD_READY: compiled and executed a CUDA kernel on the detected GPU.' + $kernelReady = $true + $report.acceptance.kernel = [ordered]@{ + compiled = $true + executed = $true + marker = 'CUDA_KERNEL_READY' + deviceIndex = $DeviceIndex + device = $driver.Name + computeCapability = $driver.ComputeCapability.ToString() + } + } finally { + if (Test-Path -LiteralPath $temporary) { + Remove-Item -LiteralPath $temporary -Recurse -Force + } + } +} + +Add-AiReportPhase -Report $report -Name 'cuda-toolkit' -Status 'ready' -Evidence @{ + nvcc = $nvcc + nvccVersion = $nvccVersionEvidence + driver = $driver +} +if ($plan.Preview) { + Write-Warning 'CUDA 13.4 for Windows ARM64 is an NVIDIA Developer Preview and is not intended for production certification or benchmarking.' +} +Complete-AiWorkloadReport -Report $report -Ready $kernelReady -Path $ReportPath +Write-Host 'INSTALL_OK: cuda' diff --git a/src/Workloads/cuda/smoke.cu b/src/Workloads/cuda/smoke.cu new file mode 100644 index 00000000..4191a01c --- /dev/null +++ b/src/Workloads/cuda/smoke.cu @@ -0,0 +1,45 @@ +#include + +#include +#include + +__global__ void write_marker(int* value) { + *value = 42; +} + +int main(int argc, char** argv) { + const int device_index = argc > 1 ? std::atoi(argv[1]) : 0; + int device_count = 0; + if (cudaGetDeviceCount(&device_count) != cudaSuccess || device_index < 0 || device_index >= device_count) { + return 5; + } + if (cudaSetDevice(device_index) != cudaSuccess) { + return 6; + } + cudaDeviceProp properties{}; + if (cudaGetDeviceProperties(&properties, device_index) != cudaSuccess) { + return 7; + } + int* device_value = nullptr; + int host_value = 0; + + if (cudaMalloc(&device_value, sizeof(int)) != cudaSuccess) { + return 1; + } + write_marker<<<1, 1>>>(device_value); + if (cudaDeviceSynchronize() != cudaSuccess) { + cudaFree(device_value); + return 2; + } + if (cudaMemcpy(&host_value, device_value, sizeof(int), cudaMemcpyDeviceToHost) != cudaSuccess) { + cudaFree(device_value); + return 3; + } + cudaFree(device_value); + + if (host_value != 42) { + return 4; + } + std::printf("CUDA_KERNEL_READY device_index=%d device=%s\n", device_index, properties.name); + return 0; +} diff --git a/src/Workloads/foundry/install.ps1 b/src/Workloads/foundry/install.ps1 new file mode 100644 index 00000000..9340c095 --- /dev/null +++ b/src/Workloads/foundry/install.ps1 @@ -0,0 +1,140 @@ +<# +.SYNOPSIS + Install Foundry Local, acquire a small catalog model, and run text inference. + +.PARAMETER SkipModelSmoke + Skip the default qwen3-0.6b download and inference. The install then verifies + only the CLI and server and does not claim workload readiness. +#> +[CmdletBinding()] +param( + [switch] $SkipModelSmoke, + [switch] $PlanOnly, + [string] $ReportPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_common\direct-setup.ps1') +. (Join-Path $PSScriptRoot '..\_common\ai-report.ps1') + +$architecture = Get-DevConfigArchitecture +$component = (Get-AiCatalog).Components.FoundryLocal +$report = New-AiWorkloadReport -Id 'foundry' -Request @{ + SkipModelSmoke = [bool]$SkipModelSmoke + PlanOnly = [bool]$PlanOnly +} +if (-not $ReportPath) { $ReportPath = Get-AiDefaultReportPath -Id 'foundry' } +trap { + Write-AiFailureReport -Report $report -Path $ReportPath -ErrorRecord $_ + throw $_ +} +try { + $plan = Resolve-FoundryInstallPlan -Architecture $architecture -WindowsBuild (Get-WindowsBuildNumber) +} catch { + if ($PlanOnly) { + [void]$report.result.blockers.Add($_.Exception.Message) + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host 'PLAN_UNSUPPORTED: foundry' + return + } + throw +} +if (-not $PlanOnly) { Assert-AiAdministrator } +Write-Host "Foundry Local plan: $($plan.Architecture), WinML, CUDA dependency: $($plan.RequiresCuda)" + +$package = Ensure-AiWingetPackage -Id 'Microsoft.FoundryLocal' -PlanOnly:$PlanOnly +Add-AiReportAcquisition -Report $report -Entry ([ordered]@{ + component = $component.Component + vendor = $component.Vendor + architecture = $architecture + maturity = $component.Maturity + sourceType = $component.SourceType + packageId = $component.PackageId + versionPolicy = $component.VersionPolicy + integrity = $component.Integrity + cachePath = $component.CachePath + installPath = $component.InstallPath + reasonNormalChannelInsufficient = $component.NormalChannelLimitation + expectedStableSource = $component.ExpectedStableSource + migrationTrigger = $component.MigrationTrigger + cleanupUpgrade = $component.CleanupUpgrade + promotionCandidate = $component.PromotionCandidate + action = $package.Action + packageEvidence = $(if ($PlanOnly) { $null } else { $package.Evidence }) +}) +if ($PlanOnly) { + Add-AiReportPhase -Report $report -Name 'model-inference' -Status $(if ($SkipModelSmoke) { 'skipped' } else { 'planned' }) -Evidence @{ + model = 'qwen3-0.6b' + selection = 'Foundry alias resolves the highest-priority hardware variant' + } + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host 'PLAN_OK: foundry' + return +} + +Invoke-CheckedCommand -FilePath 'foundry' -ArgumentList @('--version') -DisplayName 'Foundry Local CLI verification' +$serverStatus = Invoke-DevConfigNativeCommand -FilePath 'foundry' -Arguments @('server', 'status') +if ($serverStatus.ExitCode -ne 0) { + Write-Host 'Foundry Local server is not ready; restarting it once.' + Invoke-CheckedCommand -FilePath 'foundry' -ArgumentList @('server', 'restart') -DisplayName 'Foundry Local server restart' + Invoke-CheckedCommand -FilePath 'foundry' -ArgumentList @('server', 'status') -DisplayName 'Foundry Local server readiness' +} + +$modelPlan = Get-FoundryModelSmokePlan +$inferenceEvidence = $null +if ($SkipModelSmoke) { + Write-Warning 'FOUNDRY_MODEL_SMOKE_SKIPPED: CLI and server are ready, but no model inference was performed.' +} else { + $commands = Get-FoundryModelSmokeCommands -Model $modelPlan.Model -Marker $modelPlan.Marker + Write-Host "Downloading Foundry catalog model $($modelPlan.Model) (approximately $($modelPlan.ApproximateDownloadMb) MB, $($modelPlan.License))." + Invoke-CheckedCommand -FilePath 'foundry' -ArgumentList $commands.Download -DisplayName 'Foundry Local model download' + $modelInfoResult = Invoke-DevConfigNativeCommand -FilePath 'foundry' -Arguments @('model', 'info', $modelPlan.Model) + $modelInfo = $modelInfoResult.Output.Trim() + if ($modelInfoResult.ExitCode -ne 0 -or -not $modelInfo) { + throw "Foundry Local could not report the selected $($modelPlan.Model) hardware variant." + } + Write-Host $modelInfo + $logsBeforeResult = Invoke-DevConfigNativeCommand -FilePath 'foundry' -Arguments @('server', 'logs', '-n', '200') + if ($logsBeforeResult.ExitCode -ne 0) { + throw "Foundry Local could not capture the pre-inference server log boundary: $($logsBeforeResult.Output)" + } + $completeArguments = @($commands.Complete) + $completionResult = Invoke-DevConfigNativeCommand -FilePath 'foundry' -Arguments $completeArguments + $completion = $completionResult.Output + if ($completionResult.ExitCode -ne 0 -or $completion -notmatch [regex]::Escape($modelPlan.Marker)) { + throw "Foundry Local model inference did not produce marker '$($modelPlan.Marker)'. Output: $completion" + } + $cacheResult = Invoke-DevConfigNativeCommand -FilePath 'foundry' -Arguments @('cache', 'location') + if ($cacheResult.ExitCode -ne 0) { + throw "Foundry Local cache location failed: $($cacheResult.Output)" + } + $cache = Get-AiWindowsPathFromOutput -Text $cacheResult.Output + $logsResult = Invoke-DevConfigNativeCommand -FilePath 'foundry' -Arguments @('server', 'logs', '-n', '200') + if ($logsResult.ExitCode -ne 0) { + throw "Foundry Local could not capture post-inference provider evidence: $($logsResult.Output)" + } + $logs = $logsResult.Output.Trim() + $currentInferenceLogs = Get-AiAppendedLogText -Before $logsBeforeResult.Output -After $logsResult.Output + $providerEvidence = Get-FoundryExecutionProviderEvidence -ModelInfo $modelInfo -ServerLogs $currentInferenceLogs + $report.acceptance.inference = [ordered]@{ + modelAlias = $modelPlan.Model + modelInfo = $modelInfo + marker = $modelPlan.Marker + outputMatched = $true + cache = $cache + serverLogTail = $logs + currentInferenceProviderLogs = $currentInferenceLogs + selectedExecutionProvider = $providerEvidence.SelectedProvider + selectedDevice = $providerEvidence.SelectedDevice + observedExecutionProviders = $providerEvidence.ObservedProviders + evidenceClass = 'resolved-variant-plus-successful-inference' + } + $inferenceEvidence = $report.acceptance.inference + $report.result.fallbackUsed = $providerEvidence.CpuFallback + Write-Host "FOUNDRY_READY: $($modelPlan.Model) downloaded to '$cache' and generated the deterministic marker using $($providerEvidence.SelectedProvider)." +} +Add-AiReportPhase -Report $report -Name 'foundry-inference' -Status $(if ($SkipModelSmoke) { 'skipped' } else { 'ready' }) -Evidence $inferenceEvidence +Complete-AiWorkloadReport -Report $report -Ready (-not $SkipModelSmoke) -Path $ReportPath +Write-Host 'INSTALL_OK: foundry' diff --git a/src/Workloads/intel-ai/install.ps1 b/src/Workloads/intel-ai/install.ps1 new file mode 100644 index 00000000..33e31bca --- /dev/null +++ b/src/Workloads/intel-ai/install.ps1 @@ -0,0 +1,198 @@ +<# +.SYNOPSIS + Install and verify Intel OpenVINO acceleration, with optional oneAPI/SYCL tooling. + +.PARAMETER OpenVinoDeviceId + Optional exact OpenVINO device such as GPU.1 or NPU.0. Device still declares + the required class used for prerequisite validation. + +.PARAMETER SyclDeviceSelector + Optional ONEAPI_DEVICE_SELECTOR value such as level_zero:gpu:1 for + same-vendor multi-adapter SYCL execution. +#> +[CmdletBinding()] +param( + [ValidateSet('Auto', 'CPU', 'GPU', 'NPU')] [string] $Device = 'Auto', + [ValidateSet('OpenVINO', 'SYCL', 'Full')] [string] $Profile = 'OpenVINO', + [string] $OpenVinoDeviceId = '', + [string] $SyclDeviceSelector = '', + [switch] $PlanOnly, + [string] $ReportPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_common\direct-setup.ps1') +. (Join-Path $PSScriptRoot '..\_common\ai-report.ps1') + +$architecture = Get-DevConfigArchitecture +$intelGpu = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | + Where-Object { $_.PNPDeviceID -match 'VEN_8086' -or $_.Name -match 'Intel' } | + Select-Object -First 1) +$intelNpu = @(Get-PnpDevice -PresentOnly -ErrorAction SilentlyContinue | + Where-Object { $_.FriendlyName -match 'Intel.*(AI Boost|NPU)|Neural Processing Unit' } | + Select-Object -First 1) +$intelPlan = $null +$planError = $null +try { + $intelPlan = Resolve-IntelAiPlan ` + -Architecture $architecture ` + -Device $Device ` + -Profile $Profile ` + -IntelGpuPresent ($intelGpu.Count -gt 0) ` + -IntelNpuPresent ($intelNpu.Count -gt 0) +} catch { + $planError = $_.Exception.Message +} +$selectedDevice = if ($intelPlan) { $intelPlan.Device } else { $Device } +$openVinoTarget = if ($OpenVinoDeviceId) { $OpenVinoDeviceId } else { $selectedDevice } +if (-not $planError -and $OpenVinoDeviceId -and $Profile -in @('OpenVINO', 'Full') -and + $OpenVinoDeviceId -notmatch "^$([regex]::Escape($selectedDevice))(\.|$)") { + $planError = "OpenVINO device '$OpenVinoDeviceId' does not match the requested $selectedDevice device class." +} +$catalog = (Get-AiCatalog).Components +$component = $catalog.IntelOpenVino +$report = New-AiWorkloadReport -Id 'intel-ai' -Request @{ + Device = $Device + SelectedDevice = $selectedDevice + Profile = $Profile + OpenVinoDeviceId = $OpenVinoDeviceId + SyclDeviceSelector = $SyclDeviceSelector + PlanOnly = [bool]$PlanOnly +} +if (-not $ReportPath) { $ReportPath = Get-AiDefaultReportPath -Id 'intel-ai' } +trap { + Write-AiFailureReport -Report $report -Path $ReportPath -ErrorRecord $_ + throw $_ +} +if (-not $PlanOnly) { Assert-AiAdministrator } +$openVinoAcquisitionIndex = $null +$oneApiAcquisitionIndex = $null +if ($Profile -in @('OpenVINO', 'Full')) { + $openVinoAcquisitionIndex = $report.acquisitions.Count + Add-AiReportAcquisition -Report $report -Entry ([ordered]@{ + component = $component.Component + vendor = $component.Vendor + architecture = $architecture + maturity = $component.Maturity + sourceType = $component.SourceType + packages = $component.Packages + versionPolicy = $component.VersionPolicy + integrity = $component.Integrity + cachePath = $component.CachePath + installPath = $component.InstallPath + reasonNormalChannelInsufficient = $component.NormalChannelLimitation + expectedStableSource = $component.ExpectedStableSource + migrationTrigger = $component.MigrationTrigger + cleanupUpgrade = $component.CleanupUpgrade + action = $(if ($planError) { 'blocked' } else { 'planned' }) + }) +} +if ($Profile -in @('SYCL', 'Full')) { + $oneApiAcquisitionIndex = $report.acquisitions.Count + Add-AiReportAcquisition -Report $report -Entry ([ordered]@{ + component = $catalog.IntelOneApi.Component + maturity = $catalog.IntelOneApi.Maturity + sourceType = 'winget' + packageId = $catalog.IntelOneApi.PackageId + version = $catalog.IntelOneApi.Version + versionPolicy = $catalog.IntelOneApi.VersionPolicy + integrity = $catalog.IntelOneApi.Integrity + cachePath = $catalog.IntelOneApi.CachePath + installPath = $catalog.IntelOneApi.InstallPath + reasonNormalChannelInsufficient = $catalog.IntelOneApi.NormalChannelLimitation + expectedStableSource = $catalog.IntelOneApi.ExpectedStableSource + migrationTrigger = $catalog.IntelOneApi.MigrationTrigger + cleanupUpgrade = $catalog.IntelOneApi.CleanupUpgrade + action = $(if ($planError) { 'blocked' } else { 'planned' }) + }) +} +if ($planError) { + [void]$report.result.blockers.Add($planError) +} +if ($report.result.blockers.Count -gt 0) { + if ($PlanOnly) { + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host 'PLAN_UNSUPPORTED: intel-ai' + return + } + throw ($report.result.blockers -join ' ') +} +if ($Profile -in @('OpenVINO', 'Full')) { + $pythonPackage = Ensure-AiWingetPackage -Id 'Python.Python.3.13' -PlanOnly:$PlanOnly + Set-AiAcquisitionAction -Report $report -Index $openVinoAcquisitionIndex -Action $(if ($PlanOnly) { 'planned' } else { 'pending' }) + Add-AiReportAcquisition -Report $report -Entry ([ordered]@{ + component = 'Python 3.13' + sourceType = 'winget' + packageId = 'Python.Python.3.13' + action = $pythonPackage.Action + packageEvidence = $(if ($PlanOnly) { $null } else { $pythonPackage.Evidence }) + }) +} +if ($Profile -in @('SYCL', 'Full')) { + $oneApi = Ensure-AiWingetPackage -Id 'Intel.OneAPI.Toolkit' -PlanOnly:$PlanOnly + Set-AiAcquisitionAction -Report $report -Index $oneApiAcquisitionIndex -Action $oneApi.Action +} +if ($PlanOnly) { + Add-AiReportPhase -Report $report -Name 'openvino-inference' -Status $(if ($Profile -eq 'SYCL') { 'skipped' } else { 'planned' }) -Evidence @{ device = $openVinoTarget } + Add-AiReportPhase -Report $report -Name 'sycl-kernel' -Status $(if ($Profile -eq 'OpenVINO') { 'skipped' } else { 'planned' }) -Evidence @{ device = 'GPU'; selector = $SyclDeviceSelector } + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host 'PLAN_OK: intel-ai' + return +} + +if ($Profile -in @('OpenVINO', 'Full')) { + $python = Get-Python313Path -Architecture X64 + $root = Join-Path $env:LOCALAPPDATA 'DevConfig\intel-ai\openvino' + $venv = Join-Path $root '.venv' + $statePath = Join-Path $root 'install-state.json' + $expectedPackages = @{ + openvino = '2026.3.1' + 'openvino-tokenizers' = '2026.3.1.0' + 'openvino-genai' = '2026.3.1.0' + } + New-Item -ItemType Directory -Path $root -Force | Out-Null + if (-not (Test-Path (Join-Path $venv 'Scripts\python.exe'))) { + Invoke-CheckedCommand -FilePath $python -ArgumentList @('-m', 'venv', $venv) -DisplayName 'OpenVINO environment creation' + } + $venvPython = Join-Path $venv 'Scripts\python.exe' + $packagesCurrent = (Test-Path -LiteralPath $statePath) -and + (Test-PythonDistributionVersions -PythonPath $venvPython -Expected $expectedPackages) + if (-not $packagesCurrent) { + $openvinoArguments = @('-m', 'pip', 'install', '--only-binary=:all:') + @($component.Packages) + Invoke-CheckedCommand -FilePath $venvPython -ArgumentList $openvinoArguments -DisplayName 'OpenVINO Runtime/GenAI installation' + Set-Content -LiteralPath $statePath -Value ($expectedPackages | ConvertTo-Json -Compress) -Encoding ascii + } else { + Write-Host 'OPENVINO_PACKAGES_CURRENT: skipping package resolution and installation.' + } + $openvinoEvidence = (& $venvPython (Join-Path $PSScriptRoot 'openvino-smoke.py') $openVinoTarget 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $openvinoEvidence -notmatch '^OPENVINO_SMOKE=') { + throw "OpenVINO $openVinoTarget inference failed: $openvinoEvidence" + } + $report.acceptance.openvino = $openvinoEvidence + Set-AiAcquisitionAction -Report $report -Index $openVinoAcquisitionIndex -Action $(if ($packagesCurrent) { 'already-current' } else { 'installed-or-upgraded' }) +} + +if ($Profile -in @('SYCL', 'Full')) { + [void](Ensure-AiVisualCppTools -Architecture X64) + $setvars = Join-Path ${env:ProgramFiles(x86)} 'Intel\oneAPI\setvars.bat' + if (-not (Test-Path $setvars)) { throw "oneAPI setvars.bat was not found at '$setvars'." } + $temporary = Join-Path ([System.IO.Path]::GetTempPath()) "devconfig-sycl-$([guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $temporary -Force | Out-Null + try { + $output = Join-Path $temporary 'sycl-smoke.exe' + $selectorPrefix = if ($SyclDeviceSelector) { "set `"ONEAPI_DEVICE_SELECTOR=$SyclDeviceSelector`" && " } else { '' } + $command = "$selectorPrefix" + "call `"$setvars`" >nul && icpx -fsycl `"$PSScriptRoot\sycl-smoke.cpp`" -o `"$output`" && `"$output`"" + $syclEvidence = (& $env:ComSpec /d /s /c $command 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $syclEvidence -notmatch 'SYCL_DEVICE_READY:') { + throw "oneAPI SYCL GPU kernel failed: $syclEvidence" + } + $report.acceptance.sycl = [ordered]@{ selector = $SyclDeviceSelector; evidence = $syclEvidence } + } finally { + Remove-Item -LiteralPath $temporary -Recurse -Force -ErrorAction SilentlyContinue + } +} +Complete-AiWorkloadReport -Report $report -Ready $true -Path $ReportPath +Write-Host "INTEL_AI_READY: profile=$Profile device=$selectedDevice" +Write-Host 'INSTALL_OK: intel-ai' diff --git a/src/Workloads/intel-ai/openvino-smoke.py b/src/Workloads/intel-ai/openvino-smoke.py new file mode 100644 index 00000000..bc007453 --- /dev/null +++ b/src/Workloads/intel-ai/openvino-smoke.py @@ -0,0 +1,34 @@ +import json +import sys + +import numpy as np +import openvino as ov + + +requested = sys.argv[1] +core = ov.Core() +available = list(core.available_devices) +if requested not in available: + raise RuntimeError(f"{requested} is unavailable; devices={available}") + +x = ov.opset13.parameter([4], np.float32, name="x") +one = ov.opset13.constant(np.ones(4, dtype=np.float32)) +model = ov.Model([ov.opset13.add(x, one)], [x], "intel_ai_smoke") +compiled = core.compile_model(model, requested) +actual = compiled([np.arange(4, dtype=np.float32)])[0] +expected = np.array([1, 2, 3, 4], dtype=np.float32) +if not np.array_equal(actual, expected): + raise RuntimeError(f"unexpected output: {actual}") + +print( + "OPENVINO_SMOKE=" + + json.dumps( + { + "requested_device": requested, + "full_device_name": core.get_property(requested, "FULL_DEVICE_NAME"), + "available_devices": available, + "output_verified": True, + }, + sort_keys=True, + ) +) diff --git a/src/Workloads/intel-ai/sycl-smoke.cpp b/src/Workloads/intel-ai/sycl-smoke.cpp new file mode 100644 index 00000000..4b7423a0 --- /dev/null +++ b/src/Workloads/intel-ai/sycl-smoke.cpp @@ -0,0 +1,23 @@ +#include + +#include +#include + +int main() { + sycl::queue queue{sycl::gpu_selector_v}; + std::vector values(16, 0); + { + sycl::buffer buffer(values.data(), sycl::range<1>(values.size())); + queue.submit([&](sycl::handler& handler) { + auto output = buffer.get_access(handler); + handler.parallel_for(sycl::range<1>(values.size()), [=](sycl::id<1> i) { + output[i] = static_cast(i[0]) + 1; + }); + }); + } + for (std::size_t i = 0; i < values.size(); ++i) { + if (values[i] != static_cast(i) + 1) return 2; + } + std::cout << "SYCL_DEVICE_READY:" << queue.get_device().get_info() << '\n'; + return 0; +} diff --git a/src/Workloads/llama.cpp/coding-demo.ps1 b/src/Workloads/llama.cpp/coding-demo.ps1 new file mode 100644 index 00000000..32d2db99 --- /dev/null +++ b/src/Workloads/llama.cpp/coding-demo.ps1 @@ -0,0 +1,96 @@ +<# +.SYNOPSIS + Run an optional coding demonstration with a pinned 1.5B Qwen coder model. + +.DESCRIPTION + This is intentionally separate from install.ps1. The default llama.cpp + validation model is about 397 MB; this optional coding model is about 1.04 GB. +#> +[CmdletBinding()] +param([string] $ReportPath = '') + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_common\direct-setup.ps1') +. (Join-Path $PSScriptRoot '..\_common\ai-report.ps1') + +$runtime = Join-Path $env:LOCALAPPDATA 'DevConfig\llama.cpp\runtime' +$llamaCli = Join-Path $runtime 'llama-cli.exe' +$statePath = Join-Path $env:LOCALAPPDATA 'DevConfig\llama.cpp\selected-backend.json' +if (-not (Test-Path -LiteralPath $llamaCli) -or -not (Test-Path -LiteralPath $statePath)) { + throw 'Run llama.cpp\install.ps1 before the optional coding demonstration.' +} +$state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json +$plan = Get-LlamaCodingDemoPlan +$prompt = 'Write only valid Python code defining group_anagrams(words: list[str]) -> list[list[str]].' +$modelDirectory = Join-Path $env:LOCALAPPDATA 'DevConfig\llama.cpp\models' +$modelPath = Join-Path $modelDirectory $plan.FileName + +Install-VerifiedDownload ` + -Uri $plan.Url ` + -Destination $modelPath ` + -Sha256 $plan.Sha256 ` + -ExpectedSize $plan.Size + +$arguments = @( + '--model', $modelPath, + '--single-turn', + '--prompt', $prompt, + '--reasoning', 'off', + '--seed', '42', + '--temperature', '0.2', + '--top-p', '0.8', + '--top-k', '20', + '--predict', '384', + '--no-display-prompt', + '--simple-io', + '--log-disable', + '-ngl', $(if ($state.backend -eq 'CPU') { '0' } else { '999' }) +) +if ($state.backend -eq 'CPU') { + $arguments += @('--device', 'none') +} elseif ($state.requestedDevice) { + $arguments += @('--device', [string]$state.requestedDevice) +} + +$result = Invoke-AiNativeCommandSeparated ` + -FilePath $llamaCli ` + -Arguments $arguments ` + -TimeoutSeconds 1800 +$output = $result.StandardOutput.Trim() +if ($result.ExitCode -ne 0 -or $output -notmatch '(?m)^\s*(?:```python\s*)?def\s+group_anagrams\s*\(') { + throw "Coding model did not return the requested Python function. Output: $output" +} + +if ($ReportPath) { + $report = New-AiWorkloadReport -Id 'llama.cpp-coding-demo' -Request @{ + Prompt = $prompt + Backend = $state.backend + RequestedDevice = $state.requestedDevice + } + Add-AiReportAcquisition -Report $report -Entry ([ordered]@{ + component = 'Qwen2.5-Coder-1.5B-Instruct GGUF' + vendor = 'Qwen' + maturity = 'stable-model' + sourceType = 'huggingface-immutable-revision' + repository = $plan.Repository + revision = $plan.Revision + file = $plan.FileName + sha256 = $plan.Sha256 + bytes = $plan.Size + cachePath = $modelPath + action = 'already-current-or-downloaded' + }) + $report.acceptance.coding = [ordered]@{ + backend = $state.backend + expectedDevice = $state.expectedDevice + model = $plan.FileName + outputContainsFunction = $true + output = $output + } + Complete-AiWorkloadReport -Report $report -Ready $true -Path $ReportPath +} + +Write-Host $output +Write-Host "CODING_DEMO_READY: model=$($plan.FileName), backend=$($state.backend)" diff --git a/src/Workloads/llama.cpp/install.ps1 b/src/Workloads/llama.cpp/install.ps1 new file mode 100644 index 00000000..c2dc821e --- /dev/null +++ b/src/Workloads/llama.cpp/install.ps1 @@ -0,0 +1,272 @@ +<# +.SYNOPSIS + Install a hardware-selected llama.cpp backend, acquire a pinned small GGUF, + and prove the selected backend with benchmark and inference evidence. + +.PARAMETER Backend + Auto prefers supported NVIDIA CUDA, AMD ROCm, Intel SYCL, Qualcomm Adreno + OpenCL, x64 Vulkan, then CPU. OpenVINO is an explicit Windows x64 option. + Explicit backend requests fail instead of silently selecting another backend. + +.PARAMETER Device + Optional llama.cpp runtime device identifier such as CUDA0, Vulkan0, or SYCL0. + Use this to target a same-vendor secondary adapter. When omitted, the selected + backend chooses its default device and the actual device is recorded. + +.PARAMETER SkipModelSmoke + Skip the default Qwen3-0.6B GGUF download and inference. The install then + verifies only the CLI and does not claim workload readiness. +#> +[CmdletBinding()] +param( + [ValidateSet('Auto', 'CUDA', 'ROCm', 'SYCL', 'OpenVINO', 'Vulkan', 'OpenCL', 'CPU')] [string] $Backend = 'Auto', + [string] $Device = '', + [switch] $SkipModelSmoke, + [switch] $PlanOnly, + [string] $ReportPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_common\direct-setup.ps1') +. (Join-Path $PSScriptRoot '..\_common\ai-report.ps1') + +$architecture = Get-DevConfigArchitecture +$driver = Get-NvidiaDriverInfo +$amdGpuName = Get-AmdGpuName +$amdGfxTarget = if ($amdGpuName) { Get-AmdGfxTarget -GpuName $amdGpuName } else { $null } +$intelGpuName = Get-IntelGpuName +$qualcommGpuName = Get-QualcommGpuName +$hasOpenCl = Test-AiOpenClRuntimeAvailable +$vulkanGpuName = Get-VulkanGpuName +$hasVulkan = Test-AiVulkanRuntimeAvailable -GpuName $vulkanGpuName +$component = (Get-AiCatalog).Components.LlamaCppRolling +$report = New-AiWorkloadReport -Id 'llama.cpp' -Request @{ + Backend = $Backend + Device = $Device + SkipModelSmoke = [bool]$SkipModelSmoke + PlanOnly = [bool]$PlanOnly + SelectedBackend = $null + DetectedNvidiaDevice = $(if ($driver) { $driver.Name } else { $null }) + NvidiaDriverVersion = $(if ($driver) { $driver.DriverVersion.ToString() } else { $null }) + NvidiaComputeCapability = $(if ($driver) { $driver.ComputeCapability.ToString() } else { $null }) + DetectedAmdDevice = $amdGpuName + DetectedIntelDevice = $intelGpuName + DetectedQualcommDevice = $qualcommGpuName + OpenClAvailable = $hasOpenCl + VulkanAvailable = $hasVulkan +} +if (-not $ReportPath) { $ReportPath = Get-AiDefaultReportPath -Id 'llama.cpp' } +trap { + Write-AiFailureReport -Report $report -Path $ReportPath -ErrorRecord $_ + throw $_ +} +try { + $plan = Resolve-LlamaCppInstallPlan ` + -Architecture $architecture ` + -Backend $Backend ` + -HasNvidia ([bool]$driver) ` + -DriverVersion $(if ($driver) { $driver.DriverVersion } else { [version]'0.0' }) ` + -ComputeCapability $(if ($driver) { $driver.ComputeCapability } else { [version]'0.0' }) ` + -NvidiaGpuName $(if ($driver) { $driver.Name } else { $null }) ` + -AmdGpuName $amdGpuName ` + -AmdGfxTarget $amdGfxTarget ` + -IntelGpuName $intelGpuName ` + -QualcommGpuName $qualcommGpuName ` + -HasOpenCl $hasOpenCl ` + -HasVulkan $hasVulkan ` + -VulkanGpuName $vulkanGpuName +} catch { + if ($PlanOnly) { + [void]$report.result.blockers.Add($_.Exception.Message) + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host 'PLAN_UNSUPPORTED: llama.cpp' + return + } + throw +} +$report.request.SelectedBackend = $plan.Backend +$report.request.SelectedVendor = $plan.Vendor +$report.request.SelectedDevice = $plan.DeviceName +$report.request.SelectedRuntime = $plan.Runtime +$report.result.fallbackUsed = $Backend -eq 'Auto' -and $plan.Backend -in @('Vulkan', 'CPU') +if ($report.result.fallbackUsed) { + [void]$report.result.warnings.Add("Auto selected the compatibility fallback '$($plan.Backend)'; this is not reported as vendor-native acceleration.") +} +if (-not $PlanOnly) { Assert-AiAdministrator } +$legacyDestination = Join-Path $env:LOCALAPPDATA 'DevConfig\llama.cpp' +$destination = Join-Path $legacyDestination 'runtime' +$assetCache = Join-Path $legacyDestination 'asset-cache' +if ($PlanOnly) { + $acquisition = [pscustomobject]@{ + Action = 'resolve-rolling-release' + Tag = $null + Assets = @() + Source = 'github' + CacheDirectory = $assetCache + } +} else { + $acquisition = Install-VerifiedGitHubReleaseAssets ` + -Repository $component.Repository ` + -AssetPatterns $plan.AssetPatterns ` + -Destination $destination ` + -VersionMarker '.devconfig-version' ` + -RequiredFile @('llama-cli.exe', 'llama-bench.exe') ` + -CacheDirectory $assetCache + Remove-UserPathEntry -Path $legacyDestination + Add-UserPathEntry -Path $destination -Prepend + $llamaCli = Join-Path $destination 'llama-cli.exe' + $llamaBench = Join-Path $destination 'llama-bench.exe' + if (-not (Test-Path -LiteralPath $llamaCli) -or -not (Test-Path -LiteralPath $llamaBench)) { + throw "The verified $($acquisition.Tag) $($plan.Runtime) asset set was extracted to '$destination', but required llama.cpp executables were not found." + } +} +$assetIdentity = @($acquisition.Assets | ForEach-Object { + [ordered]@{ + name = $_.name + sha256 = ([string]$_.digest).Substring(7) + bytes = $_.size + cachePath = Join-Path (Join-Path $assetCache $acquisition.Tag) $_.name + } +}) +Add-AiReportAcquisition -Report $report -Entry ([ordered]@{ + component = $component.Component + vendor = $plan.Vendor + architecture = $architecture + maturity = $plan.Maturity + sourceType = $component.SourceType + repository = $component.Repository + backend = $plan.Backend + runtime = $plan.Runtime + selectedDevice = $plan.DeviceName + requestedRuntimeDevice = $Device + driverPrecondition = $(if ($plan.Backend -eq 'CUDA' -and $driver) { "NVIDIA $($driver.DriverVersion), compute capability $($driver.ComputeCapability)" } else { 'Use the installed vendor display/compute driver reported in host.gpus; this flow does not replace GPU drivers.' }) + amdGfxTarget = $plan.AmdGfxTarget + assetPatterns = $plan.AssetPatterns + resolvedTag = $acquisition.Tag + resolvedAssets = $assetIdentity + versionPolicy = $plan.VersionPolicy + integrity = $component.Integrity + cachePath = $assetCache + installPath = $destination + reasonNormalChannelInsufficient = $component.NormalChannelLimitation + expectedStableSource = $component.ExpectedStableSource + migrationTrigger = $component.MigrationTrigger + cleanupUpgrade = $component.CleanupUpgrade + action = $acquisition.Action +}) +if ($PlanOnly) { + Add-AiReportPhase -Report $report -Name 'model-inference' -Status $(if ($SkipModelSmoke) { 'skipped' } else { 'planned' }) -Evidence @{ + model = 'Qwen3-0.6B-Q4_K_M.gguf' + backend = $plan.Backend + runtime = $plan.Runtime + device = $plan.DeviceName + } + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host 'PLAN_OK: llama.cpp' + return +} + +Invoke-CheckedCommand -FilePath $llamaCli -ArgumentList @('--version') -DisplayName 'llama.cpp CLI verification' +Invoke-CheckedCommand -FilePath $llamaCli -ArgumentList @('--help') -DisplayName 'llama.cpp help verification' +$statePath = Join-Path $legacyDestination 'selected-backend.json' +Write-DevConfigTextFile -Path $statePath -Content ([ordered]@{ + backend = $plan.Backend + runtime = $plan.Runtime + expectedDevice = $plan.DeviceName + requestedDevice = $Device +} | ConvertTo-Json -Compress) + +$modelPlan = Get-LlamaModelSmokePlan +$inferenceEvidence = $null +if ($SkipModelSmoke) { + Write-Warning 'LLAMA_CPP_MODEL_SMOKE_SKIPPED: CLI is ready, but no model inference was performed.' +} else { + $modelDirectory = Join-Path $env:LOCALAPPDATA 'DevConfig\llama.cpp\models' + $modelPath = Join-Path $modelDirectory $modelPlan.FileName + Write-Host "Downloading pinned $($modelPlan.Repository) model $($modelPlan.FileName) (approximately $([math]::Round($modelPlan.Size / 1MB)) MB, $($modelPlan.License))." + Install-VerifiedDownload ` + -Uri $modelPlan.Url ` + -Destination $modelPath ` + -Sha256 $modelPlan.Sha256 ` + -ExpectedSize $modelPlan.Size + $benchArguments = @( + '-m', $modelPath, + '-ngl', $(if ($plan.Backend -eq 'CPU') { '0' } else { '999' }), + '-p', '32', '-n', '1', '-r', '1', '-o', 'json', '-v' + ) + if ($plan.Backend -eq 'CPU') { + $benchArguments += @('--device', 'none') + } elseif ($Device) { + $benchArguments += @('--device', $Device) + } + $benchmarkResult = Invoke-AiNativeCommandSeparated ` + -FilePath $llamaBench ` + -Arguments $benchArguments ` + -TimeoutSeconds 300 + $benchmark = $benchmarkResult.StandardOutput.Trim() + if ($benchmarkResult.ExitCode -ne 0) { + throw "llama-bench failed while collecting backend evidence (exit $($benchmarkResult.ExitCode)): $($benchmarkResult.StandardError)" + } + $parsedBenchmark = ConvertFrom-AiJsonArrayWithDiagnostics -Json $benchmark -Diagnostics $benchmarkResult.StandardError + $backendEvidence = Get-LlamaBenchmarkBackendEvidence ` + -Data @($parsedBenchmark.Data) ` + -Diagnostics $parsedBenchmark.Diagnostics ` + -Backend $plan.Backend ` + -ExpectedDeviceName $(if ($Device) { $null } else { $plan.DeviceName }) ` + -RequestedDevice $Device + $arguments = Get-LlamaInferenceArguments -ModelPath $modelPath -Marker $modelPlan.Marker + $arguments += @('-ngl', $(if ($plan.Backend -eq 'CPU') { '0' } else { '999' })) + if ($plan.Backend -eq 'CPU') { + $arguments += @('--device', 'none') + } elseif ($Device) { + $arguments += @('--device', $Device) + } + $inferenceResult = Invoke-AiNativeCommandSeparated ` + -FilePath $llamaCli ` + -Arguments $arguments ` + -TimeoutSeconds 300 + $output = @( + $inferenceResult.StandardOutput + $inferenceResult.StandardError + ) -join "`n" + $output = $output.Trim() + if ($inferenceResult.ExitCode -ne 0 -or $output -notmatch [regex]::Escape($modelPlan.Marker)) { + throw "llama.cpp model inference did not produce marker '$($modelPlan.Marker)' (exit $($inferenceResult.ExitCode)). Output: $output" + } + $report.acceptance.inference = [ordered]@{ + model = $modelPlan.FileName + modelSha256 = $modelPlan.Sha256 + modelBytes = $modelPlan.Size + modelLicense = $modelPlan.License + marker = $modelPlan.Marker + backendPlan = $plan.Backend + runtimePlan = $plan.Runtime + selectedVendor = $plan.Vendor + selectedDevice = $plan.DeviceName + requestedRuntimeDevice = $Device + requestedRuntimeDevices = $backendEvidence.RequestedDevices + actualRuntimeDevices = $backendEvidence.GpuInfo + amdGfxTarget = $plan.AmdGfxTarget + backendEvidence = $backendEvidence + benchmark = $parsedBenchmark.Data + benchmarkJson = $parsedBenchmark.Json + benchmarkDiagnostics = $parsedBenchmark.Diagnostics + benchmarkJsonRepaired = $parsedBenchmark.JsonRepaired + } + $inferenceEvidence = [ordered]@{ + model = $modelPlan.FileName + marker = $modelPlan.Marker + backend = $plan.Backend + runtime = $plan.Runtime + device = $backendEvidence.GpuInfo + hardwareAccelerated = $backendEvidence.HardwareAccelerated + actualOffloadedLayers = $backendEvidence.ActualOffloadedLayers + benchmarkJsonRepaired = $parsedBenchmark.JsonRepaired + } + Write-Host "LLAMA_CPP_READY: architecture=$architecture, backend=$($plan.Backend), runtime=$($plan.Runtime), device=$($backendEvidence.GpuInfo -join ','), model=$($modelPlan.FileName), sha256=$($modelPlan.Sha256)." +} +Add-AiReportPhase -Report $report -Name 'llama-inference' -Status $(if ($SkipModelSmoke) { 'skipped' } else { 'ready' }) -Evidence $inferenceEvidence +Complete-AiWorkloadReport -Report $report -Ready (-not $SkipModelSmoke) -Path $ReportPath +Write-Host 'INSTALL_OK: llama.cpp' diff --git a/src/Workloads/local-ai/install.ps1 b/src/Workloads/local-ai/install.ps1 new file mode 100644 index 00000000..55f70181 --- /dev/null +++ b/src/Workloads/local-ai/install.ps1 @@ -0,0 +1,69 @@ +<# +.SYNOPSIS + Prepare the major dependencies for a runnable local AI development scenario. + +.DESCRIPTION + Detects hardware, installs a contained PyTorch backend, runs tensor and neural + model acceptance, and optionally installs one local model runtime. It does not + install every vendor SDK or every model runtime. +#> +[CmdletBinding()] +param( + [ValidateSet('Auto', 'CPU', 'CUDA', 'ROCm', 'XPU')] [string] $Backend = 'Auto', + [ValidateSet('None', 'LlamaCpp', 'Ollama', 'Foundry')] [string] $Runtime = 'None', + [switch] $RequireTriton, + [switch] $PlanOnly, + [string] $ReportRoot = (Join-Path $env:LOCALAPPDATA 'DevConfig\reports\local-ai') +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$sourceRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +New-Item -ItemType Directory -Path $ReportRoot -Force | Out-Null + +& (Join-Path $PSScriptRoot '..\_common\collect-ai-hardware.ps1') ` + -OutputPath (Join-Path $ReportRoot 'hardware.json') + +$pytorchReportPath = Join-Path $ReportRoot 'pytorch.json' +$pytorchArguments = @{ + Backend = $Backend + ReportPath = $pytorchReportPath +} +if ($RequireTriton) { $pytorchArguments.RequireTriton = $true } +if ($PlanOnly) { $pytorchArguments.PlanOnly = $true } +& (Join-Path $sourceRoot 'Workloads\pytorch\install.ps1') @pytorchArguments +$pytorchReport = Get-Content -LiteralPath $pytorchReportPath -Raw | ConvertFrom-Json +if ($pytorchReport.result.blockers.Count -gt 0) { + Write-Host "LOCAL_AI_SCENARIO_UNSUPPORTED: $($pytorchReport.result.blockers -join '; ')" + return +} +if (-not $PlanOnly -and -not $pytorchReport.result.ready) { + throw 'The PyTorch scenario step did not report result.ready=true.' +} + +if ($Runtime -ne 'None') { + $runtimePath = switch ($Runtime) { + 'LlamaCpp' { Join-Path $sourceRoot 'Workloads\llama.cpp\install.ps1' } + 'Ollama' { Join-Path $sourceRoot 'Workloads\ollama\install.ps1' } + 'Foundry' { Join-Path $sourceRoot 'Workloads\foundry\install.ps1' } + } + $runtimeReportPath = Join-Path $ReportRoot "$($Runtime.ToLowerInvariant()).json" + $runtimeArguments = @{ ReportPath = $runtimeReportPath } + if ($PlanOnly) { $runtimeArguments.PlanOnly = $true } + & $runtimePath @runtimeArguments + $runtimeReport = Get-Content -LiteralPath $runtimeReportPath -Raw | ConvertFrom-Json + if ($runtimeReport.result.blockers.Count -gt 0) { + Write-Host "LOCAL_AI_SCENARIO_UNSUPPORTED: $($runtimeReport.result.blockers -join '; ')" + return + } + if (-not $PlanOnly -and -not $runtimeReport.result.ready) { + throw "The $Runtime scenario step did not report result.ready=true." + } +} + +if ($PlanOnly) { + Write-Host "LOCAL_AI_SCENARIO_PLAN_OK: backend=$Backend, runtime=$Runtime, reports=$ReportRoot" +} else { + Write-Host "LOCAL_AI_SCENARIO_READY: backend=$Backend, runtime=$Runtime, reports=$ReportRoot" +} diff --git a/src/Workloads/ollama/install.ps1 b/src/Workloads/ollama/install.ps1 new file mode 100644 index 00000000..5da93524 --- /dev/null +++ b/src/Workloads/ollama/install.ps1 @@ -0,0 +1,372 @@ +<# +.SYNOPSIS + Install and manage Ollama, then run verified model inference. + +.PARAMETER SkipModelSmoke + Verify the CLI and local API without pulling or running the quick model. + +.PARAMETER Uninstall + Remove Ollama registration and runtime. ARM64 models are preserved by default. + +.PARAMETER RemoveModels + With -Uninstall, also remove the configured Ollama model directory. +#> +[CmdletBinding()] +param( + [switch] $SkipModelSmoke, + [switch] $Uninstall, + [switch] $RemoveModels, + [switch] $PlanOnly, + [string] $ReportPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_common\direct-setup.ps1') +. (Join-Path $PSScriptRoot '..\_common\ai-report.ps1') + +if ($RemoveModels -and -not $Uninstall) { + throw '-RemoveModels requires -Uninstall.' +} + +$architecture = Get-DevConfigArchitecture +$plan = Resolve-OllamaInstallPlan -Architecture $architecture +$catalog = (Get-AiCatalog).Components +$component = if ($architecture -eq 'Arm64') { $catalog.OllamaArm64 } else { $catalog.OllamaX64 } +$paths = Get-OllamaManagedPaths +$modelRoot = if ($env:OLLAMA_MODELS) { $env:OLLAMA_MODELS } else { Join-Path $HOME '.ollama\models' } +$report = New-AiWorkloadReport -Id 'ollama' -Request @{ + SkipModelSmoke = [bool]$SkipModelSmoke + Uninstall = [bool]$Uninstall + RemoveModels = [bool]$RemoveModels + PlanOnly = [bool]$PlanOnly +} +if (-not $ReportPath) { $ReportPath = Get-AiDefaultReportPath -Id 'ollama' } +trap { + Write-AiFailureReport -Report $report -Path $ReportPath -ErrorRecord $_ + throw $_ +} + +if ($Uninstall) { + Add-AiReportAcquisition -Report $report -Entry ([ordered]@{ + component = $component.Component + architecture = $architecture + sourceType = $component.SourceType + installPath = $(if ($architecture -eq 'Arm64') { $paths.InstallRoot } else { $component.InstallPath }) + modelsPreserved = -not $RemoveModels + action = $(if ($PlanOnly) { 'planned-uninstall' } else { 'pending-uninstall' }) + }) + if ($PlanOnly) { + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host 'PLAN_OK: ollama uninstall' + return + } + + if ($architecture -eq 'X64') { + Assert-AiAdministrator + Invoke-CheckedCommand -FilePath 'winget' -ArgumentList @( + 'uninstall', '--id', 'Ollama.Ollama', '--exact', '--source', 'winget', + '--silent', '--disable-interactivity' + ) -DisplayName 'Ollama application uninstall' + } else { + $removed = Remove-OllamaManagedInstallation ` + -Paths $paths ` + -ModelRoot $modelRoot ` + -RemoveModels:$RemoveModels + $report.acceptance.uninstall = $removed + } + if ($architecture -eq 'X64' -and $RemoveModels) { + Remove-Item -LiteralPath $modelRoot -Recurse -Force -ErrorAction SilentlyContinue + } + $report.acquisitions[0].action = 'uninstalled' + $report.acceptance.models = [ordered]@{ + path = $modelRoot + preserved = -not $RemoveModels + } + Complete-AiWorkloadReport -Report $report -Ready $true -Path $ReportPath + Write-Host "OLLAMA_UNINSTALLED: models-preserved=$(-not $RemoveModels)" + return +} + +if ($architecture -eq 'X64') { + if (-not $PlanOnly) { Assert-AiAdministrator } + $acquisition = Ensure-AiWingetPackage -Id 'Ollama.Ollama' -PlanOnly:$PlanOnly + if (-not $PlanOnly) { + Update-DevConfigSessionPath + $ollamaPath = (Get-Command ollama -ErrorAction Stop).Source + } +} else { + if ($PlanOnly) { + $acquisition = [pscustomobject]@{ + Action = 'resolve-latest-stable-native-arm64-archive' + Source = 'github' + InstallType = $plan.InstallType + } + } else { + $managedProcessIds = @(Stop-OllamaManagedProcesses -InstallRoot $paths.InstallRoot) + $managedProcessIds += @(Stop-OllamaManagedProcesses -InstallRoot $paths.LegacyRoot) + $resolved = Install-VerifiedGitHubLatestAsset ` + -Repository $component.Repository ` + -AssetPattern $component.AssetPattern ` + -Destination $paths.InstallRoot ` + -VersionMarker $paths.VersionMarker ` + -RequiredFile 'ollama.exe' ` + -CacheDirectory $paths.CacheDirectory + $ollamaPath = $paths.Executable + $binaryArchitecture = Get-AiPeArchitecture -Path $ollamaPath + if ($binaryArchitecture -ne 'Arm64') { + throw "Official ARM64 asset installed '$binaryArchitecture' ollama.exe; refusing emulated or incompatible runtime." + } + Add-UserPathEntry -Path $paths.InstallRoot -Prepend + Remove-UserPathEntry -Path $paths.LegacyRoot + Remove-OllamaManagedDirectory -Path $paths.LegacyRoot + + $startupCommand = Set-OllamaStartupRegistration ` + -RegistryPath $paths.StartupRegistryPath ` + -ValueName $paths.StartupValueName ` + -Executable $ollamaPath + $installedFiles = @(Get-ChildItem -LiteralPath $paths.InstallRoot -Recurse -File | + Where-Object Name -notin @('.devconfig-install.json') | + ForEach-Object { $_.FullName.Substring($paths.InstallRoot.Length).TrimStart('\') } | + Sort-Object) + $installManifest = [ordered]@{ + schemaVersion = 1 + installedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + source = 'official native ARM64 archive' + installType = $plan.InstallType + repository = $component.Repository + tag = $resolved.Tag + asset = $resolved.Asset.name + assetDigest = $resolved.Asset.digest + cachePath = $resolved.CachePath + installPath = $paths.InstallRoot + executable = $ollamaPath + architecture = $binaryArchitecture + startup = [ordered]@{ + registryPath = $paths.StartupRegistryPath + valueName = $paths.StartupValueName + command = $startupCommand + } + installedFiles = $installedFiles + modelsPath = $modelRoot + } + Write-DevConfigTextFile -Path $paths.InstallManifest -Content ($installManifest | ConvertTo-Json -Depth 8) + $acquisition = [pscustomobject]@{ + Action = $resolved.Action + Source = 'github' + InstallType = $plan.InstallType + Tag = $resolved.Tag + Asset = $resolved.Asset.name + Sha256 = $resolved.Asset.digest + CachePath = $resolved.CachePath + InstallManifest = $paths.InstallManifest + stoppedManagedProcesses = @($managedProcessIds | Select-Object -Unique) + } + } +} + +Add-AiReportAcquisition -Report $report -Entry ([ordered]@{ + component = $component.Component + vendor = $component.Vendor + architecture = $architecture + maturity = $component.Maturity + sourceType = $component.SourceType + packageId = Get-AiCatalogValue -Entry $component -Name 'PackageId' + repository = Get-AiCatalogValue -Entry $component -Name 'Repository' + assetPattern = Get-AiCatalogValue -Entry $component -Name 'AssetPattern' + installType = $(if ($architecture -eq 'Arm64') { $plan.InstallType } else { 'official-x64-installer' }) + versionPolicy = $component.VersionPolicy + integrity = $component.Integrity + cachePath = $(if ($architecture -eq 'Arm64') { $paths.CacheDirectory } else { $component.CachePath }) + installPath = $(if ($architecture -eq 'Arm64') { $paths.InstallRoot } else { $component.InstallPath }) + installManifest = $(if ($architecture -eq 'Arm64') { $paths.InstallManifest } else { $null }) + startupRegistration = Get-AiCatalogValue -Entry $component -Name 'StartupRegistration' + reasonNormalChannelInsufficient = $component.NormalChannelLimitation + expectedStableSource = $component.ExpectedStableSource + migrationTrigger = $component.MigrationTrigger + cleanupUpgrade = $component.CleanupUpgrade + action = $acquisition.Action + resolvedTag = $(if ($architecture -eq 'Arm64' -and -not $PlanOnly) { $acquisition.Tag } else { $null }) + resolvedAsset = $(if ($architecture -eq 'Arm64' -and -not $PlanOnly) { $acquisition.Asset } else { $null }) + resolvedSha256 = $(if ($architecture -eq 'Arm64' -and -not $PlanOnly) { $acquisition.Sha256 } else { $null }) + packageEvidence = $(if ($architecture -eq 'X64' -and -not $PlanOnly) { $acquisition.Evidence } else { $null }) +}) +if ($PlanOnly) { + Add-AiReportPhase -Report $report -Name 'managed-runtime' -Status 'planned' -Evidence @{ + installPath = $(if ($architecture -eq 'Arm64') { $paths.InstallRoot } else { $component.InstallPath }) + startup = $(if ($architecture -eq 'Arm64') { $component.StartupRegistration } else { 'official installer managed' }) + modelsPreservedOnUninstall = $true + } + Add-AiReportPhase -Report $report -Name 'model-inference' -Status $(if ($SkipModelSmoke) { 'skipped' } else { 'planned' }) -Evidence @{ model = 'qwen3:0.6b' } + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host 'PLAN_OK: ollama' + return +} + +Invoke-CheckedCommand -FilePath $ollamaPath -ArgumentList @('--version') -DisplayName 'Ollama CLI verification' +$oldHost = $env:OLLAMA_HOST +$validationServer = $null +$validationStdout = $null +$validationStderr = $null +try { + if ($architecture -eq 'Arm64') { + $port = Get-AiFreeTcpPort + $env:OLLAMA_HOST = "127.0.0.1:$port" + $apiBase = "http://127.0.0.1:$port" + $validationStdout = Join-Path $paths.InstallRoot 'validation-server.stdout.log' + $validationStderr = Join-Path $paths.InstallRoot 'validation-server.stderr.log' + Remove-Item $validationStdout, $validationStderr -Force -ErrorAction SilentlyContinue + Write-Host "Starting managed ARM64 validation server at $apiBase." + $validationServer = Start-Process ` + -FilePath $ollamaPath ` + -ArgumentList 'serve' ` + -WindowStyle Hidden ` + -RedirectStandardOutput $validationStdout ` + -RedirectStandardError $validationStderr ` + -PassThru + $version = Wait-JsonEndpoint -Uri ([uri]"$apiBase/api/version") -TimeoutSeconds 30 + $expectedVersion = ([string]$acquisition.Tag).TrimStart('v') + if ([string]$version.version -ne $expectedVersion) { + throw "Managed ARM64 API reported version '$($version.version)', expected '$expectedVersion'." + } + } else { + $apiBase = 'http://localhost:11434' + try { + $version = Invoke-RestMethod -Uri "$apiBase/api/version" -TimeoutSec 3 + } catch { + Write-Host "Ollama API is not running; starting 'ollama serve'." + Start-Process -FilePath $ollamaPath -ArgumentList 'serve' -WindowStyle Hidden | Out-Null + $version = Wait-JsonEndpoint -Uri ([uri]"$apiBase/api/version") -TimeoutSeconds 30 + } + } + + if (-not $version.version) { + throw 'Ollama API responded without a version value.' + } + if ($architecture -eq 'Arm64') { + $report.acceptance.server = [ordered]@{ + validationEndpoint = $apiBase + processId = $validationServer.Id + executable = $ollamaPath + executableArchitecture = Get-AiPeArchitecture -Path $ollamaPath + version = $version.version + installManifest = $paths.InstallManifest + } + } + + $modelPlan = Get-OllamaModelSmokePlan + $inferenceEvidence = $null + if ($SkipModelSmoke) { + Write-Warning 'OLLAMA_MODEL_SMOKE_SKIPPED: CLI and API are ready, but no model inference was performed.' + } else { + Write-Host "Pulling official Ollama library model $($modelPlan.Model) (approximately $($modelPlan.ApproximateDownloadMb) MB, $($modelPlan.License))." + Invoke-CheckedCommand -FilePath $ollamaPath -ArgumentList @('pull', $modelPlan.Model) -DisplayName 'Ollama model pull' + $manifestPath = Get-OllamaModelManifestPath -ModelRoot $modelRoot -Model $modelPlan.Model + if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "Ollama pulled $($modelPlan.Model), but its local manifest was not found at '$manifestPath'." + } + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + $modelLayer = @($manifest.layers | Where-Object { $_.mediaType -match 'model' } | Select-Object -First 1) + $expectedDigest = "sha256:$($modelPlan.ModelBlobSha256)" + if ($modelLayer.Count -ne 1 -or $modelLayer[0].digest -ne $expectedDigest) { + throw "Ollama library tag $($modelPlan.Model) no longer references pinned model digest $expectedDigest." + } + $blobPath = Join-Path $modelRoot "blobs\sha256-$($modelPlan.ModelBlobSha256)" + if (-not (Test-Path -LiteralPath $blobPath)) { + throw "Ollama pulled $($modelPlan.Model), but its pinned model blob was not found at '$blobPath'." + } + $blobHash = (Get-FileHash -LiteralPath $blobPath -Algorithm SHA256).Hash + if ($blobHash -ne $modelPlan.ModelBlobSha256) { + throw "Ollama model blob checksum mismatch. Expected $($modelPlan.ModelBlobSha256); got $blobHash." + } + + $request = New-OllamaGenerateRequest -Model $modelPlan.Model -Marker $modelPlan.Marker + $response = Invoke-RestMethod ` + -Method Post ` + -Uri "$apiBase/api/generate" ` + -ContentType 'application/json' ` + -Body ($request | ConvertTo-Json -Depth 8) ` + -TimeoutSec 300 + $result = $response.response | ConvertFrom-Json + if ($result.marker -ne $modelPlan.Marker) { + throw "Ollama model inference did not produce marker '$($modelPlan.Marker)'." + } + $processor = (& $ollamaPath ps 2>&1 | Out-String).Trim() + $running = Invoke-RestMethod -Uri "$apiBase/api/ps" -TimeoutSec 30 + $loaded = @($running.models | Where-Object { $_.name -eq $modelPlan.Model } | Select-Object -First 1) + $gpuFraction = if ($loaded.Count -eq 1 -and [double]$loaded[0].size -gt 0) { + [math]::Round(([double]$loaded[0].size_vram / [double]$loaded[0].size), 4) + } else { 0 } + $serverEvidence = if ($validationStderr -and (Test-Path -LiteralPath $validationStderr)) { + (Get-Content -LiteralPath $validationStderr -Tail 200 | Select-String 'inference compute|gpu memory|library=' | Out-String).Trim() + } else { + $serverLogPath = Join-Path $env:LOCALAPPDATA 'Ollama\server.log' + if (Test-Path -LiteralPath $serverLogPath) { + (Get-Content -LiteralPath $serverLogPath -Tail 200 | Select-String 'inference compute|gpu memory|library=' | Out-String).Trim() + } + } + $report.acceptance.inference = [ordered]@{ + model = $modelPlan.Model + digest = $expectedDigest + marker = $modelPlan.Marker + sizeBytes = if ($loaded.Count) { $loaded[0].size } else { $null } + sizeVramBytes = if ($loaded.Count) { $loaded[0].size_vram } else { $null } + gpuFraction = $gpuFraction + processTable = $processor + backendLogEvidence = $serverEvidence + } + $inferenceEvidence = $report.acceptance.inference + $report.result.fallbackUsed = $gpuFraction -eq 0 + Write-Host $processor + Write-Host "OLLAMA_READY: version=$($version.version), architecture=$architecture, model=$($modelPlan.Model), verified-blob=$($modelPlan.ModelBlobSha256)." + } +} finally { + if ($validationServer -and -not $validationServer.HasExited) { + Stop-Process -Id $validationServer.Id -Force -ErrorAction SilentlyContinue + $validationServer.WaitForExit() + } + if ($architecture -eq 'Arm64' -and $paths) { + [void](Stop-OllamaManagedProcesses -InstallRoot $paths.InstallRoot) + } + $env:OLLAMA_HOST = $oldHost +} + +if ($architecture -eq 'Arm64') { + $defaultApi = 'http://127.0.0.1:11434' + try { + $existingDefault = Invoke-RestMethod -Uri "$defaultApi/api/version" -TimeoutSec 3 + $managedDefault = @(Get-OllamaManagedProcesses -InstallRoot $paths.InstallRoot) + if ($managedDefault.Count -eq 0) { + throw "Port 11434 is already served by an unmanaged Ollama instance. Stop it and rerun to activate the managed ARM64 installation." + } + $persistentVersion = $existingDefault + $persistentProcess = $managedDefault | Select-Object -First 1 + } catch { + if ($_.Exception.Message -match 'unmanaged Ollama') { throw } + $oldHost = $env:OLLAMA_HOST + try { + $env:OLLAMA_HOST = '127.0.0.1:11434' + $persistentProcess = Start-Process -FilePath $ollamaPath -ArgumentList 'serve' -WindowStyle Hidden -PassThru + $persistentVersion = Wait-JsonEndpoint -Uri ([uri]"$defaultApi/api/version") -TimeoutSeconds 30 + } finally { + $env:OLLAMA_HOST = $oldHost + } + } + $report.acceptance.managedRuntime = [ordered]@{ + installType = $plan.InstallType + installPath = $paths.InstallRoot + executable = $ollamaPath + executableArchitecture = Get-AiPeArchitecture -Path $ollamaPath + startupRegistryPath = $paths.StartupRegistryPath + startupValueName = $paths.StartupValueName + persistentEndpoint = $defaultApi + persistentProcessId = Get-AiProcessId -ProcessObject $persistentProcess + version = $persistentVersion.version + modelsPath = $modelRoot + } +} + +Add-AiReportPhase -Report $report -Name 'ollama-inference' -Status $(if ($SkipModelSmoke) { 'skipped' } else { 'ready' }) -Evidence $inferenceEvidence +Complete-AiWorkloadReport -Report $report -Ready (-not $SkipModelSmoke) -Path $ReportPath +Write-Host 'INSTALL_OK: ollama' diff --git a/src/Workloads/pytorch/install.ps1 b/src/Workloads/pytorch/install.ps1 new file mode 100644 index 00000000..16b8af89 --- /dev/null +++ b/src/Workloads/pytorch/install.ps1 @@ -0,0 +1,415 @@ +<# +.SYNOPSIS + Install a self-contained Windows PyTorch backend and run device acceptance. + +.PARAMETER Backend + Auto deterministically selects supported NVIDIA CUDA, then AMD ROCm, then + Intel XPU, then CPU. Explicit selection can target a supported secondary GPU. + CUDA/ROCm/XPU runtime packages are installed inside the contained PyTorch + environment. The standalone CUDA, ROCm, and Intel AI flows are native + developer-toolkit/runtime flows, not prerequisites for ordinary tensor use. + +.PARAMETER SkipTriton + Do not install or verify Triton when supported. Native Windows Triton is + supported for qualified NVIDIA CUDA (`triton-windows`) and Intel XPU + (`triton-xpu`/`torch.compile`) paths, not AMD ROCm. + +.PARAMETER RequireTriton + Fail unless this host has a supported Triton combination. This is available + for qualified NVIDIA CUDA and Intel XPU paths; native Windows AMD ROCm has no + supported Triton package. + +.PARAMETER DeviceIndex + Zero-based device index for CUDA/ROCm/XPU acceptance. Use this to target a + same-vendor secondary adapter with an explicit backend. Auto requires index 0 + so package resolution and execution cannot refer to different vendor devices. +#> +[CmdletBinding()] +param( + [ValidateSet('Auto', 'CPU', 'CUDA', 'ROCm', 'XPU')] [string] $Backend = 'Auto', + [switch] $SkipTriton, + [switch] $RequireTriton, + [ValidateRange(0, 63)] [int] $DeviceIndex = 0, + [switch] $PlanOnly, + [string] $ReportPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_common\direct-setup.ps1') +. (Join-Path $PSScriptRoot '..\_common\ai-report.ps1') + +$architecture = Get-DevConfigArchitecture +$gpuVendor = Get-AiDetectedVendor +$amdGpuName = if ($Backend -in @('Auto', 'ROCm')) { Get-AmdGpuName -DeviceIndex $DeviceIndex } else { Get-AmdGpuName } +$intelGpuName = if ($Backend -in @('Auto', 'XPU')) { Get-IntelGpuName -DeviceIndex $DeviceIndex } else { Get-IntelGpuName } +$driver = $null +$driverError = $null +try { + $driver = Get-NvidiaDriverInfo -DeviceIndex $(if ($Backend -in @('Auto', 'CUDA')) { $DeviceIndex } else { 0 }) +} catch { + $driverError = $_.Exception.Message +} +$nvidiaGpu = if ($driver) { [pscustomobject]@{ Name = $driver.Name } } else { Get-NvidiaGpu } +$gpuName = if ($Backend -eq 'ROCm') { + $amdGpuName +} elseif ($Backend -eq 'XPU') { + $intelGpuName +} elseif ($nvidiaGpu) { + $nvidiaGpu.Name +} elseif ($amdGpuName) { + $amdGpuName +} else { + $intelGpuName +} +$amdGfxTarget = if ($amdGpuName) { Get-AmdGfxTarget -GpuName $amdGpuName } else { $null } +$report = New-AiWorkloadReport -Id 'pytorch' -Request @{ + Backend = $Backend + SelectedBackend = $null + SkipTriton = [bool]$SkipTriton + RequireTriton = [bool]$RequireTriton + DeviceIndex = $DeviceIndex + PlanOnly = [bool]$PlanOnly + DetectedVendorPriority = $gpuVendor + DetectedNvidiaDevice = $(if ($nvidiaGpu) { $nvidiaGpu.Name } else { $null }) + DetectedAmdDevice = $amdGpuName + DetectedIntelDevice = $intelGpuName + AmdGfxTarget = $amdGfxTarget +} +if (-not $ReportPath) { $ReportPath = Get-AiDefaultReportPath -Id 'pytorch' } +trap { + Write-AiFailureReport -Report $report -Path $ReportPath -ErrorRecord $_ + throw $_ +} +if ($Backend -eq 'Auto' -and $DeviceIndex -ne 0) { + $message = 'PyTorch -Backend Auto supports only -DeviceIndex 0. Select CUDA, ROCm, or XPU explicitly to target a secondary same-vendor adapter.' + if ($PlanOnly) { + [void]$report.result.blockers.Add($message) + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host 'PLAN_UNSUPPORTED: pytorch' + return + } + throw $message +} +if ($driverError) { + if ($PlanOnly) { + [void]$report.result.blockers.Add($driverError) + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host 'PLAN_UNSUPPORTED: pytorch' + return + } + throw $driverError +} +if ($SkipTriton -and $RequireTriton) { + throw '-SkipTriton and -RequireTriton cannot be used together.' +} +if ($PlanOnly) { + $pythonPackage = Ensure-AiWingetPackage -Id 'Python.Python.3.13' -PlanOnly + $pythonVersion = [version]'3.13' +} else { + Assert-AiAdministrator + $pythonPackage = Ensure-AiWingetPackage -Id 'Python.Python.3.13' + $pythonPath = Get-Python313Path -Architecture $architecture + $pythonVersionResult = Invoke-DevConfigNativeCommand -FilePath $pythonPath -Arguments @( + '-c', 'import platform; print(platform.python_version())' + ) + $pythonVersionText = $pythonVersionResult.Output.Trim() + if ($pythonVersionResult.ExitCode -ne 0) { throw 'Python failed while reporting its version.' } + $pythonVersion = [version]$pythonVersionText + $pythonMachineResult = Invoke-DevConfigNativeCommand -FilePath $pythonPath -Arguments @( + '-c', 'import platform; print(platform.machine())' + ) + $pythonMachine = $pythonMachineResult.Output.Trim() + if ($pythonMachineResult.ExitCode -ne 0) { throw 'Python failed while reporting its architecture.' } + Assert-PythonArchitecture -Architecture $architecture -PythonMachine $pythonMachine +} +$hasNvidia = [bool]$driver +try { + $plan = Resolve-PyTorchPlan ` + -Architecture $architecture ` + -Backend $Backend ` + -PythonVersion $pythonVersion ` + -HasNvidia $hasNvidia ` + -DriverMajor $(if ($driver) { $driver.DriverMajor } else { 0 }) ` + -ComputeCapability $(if ($driver) { $driver.ComputeCapability } else { [version]'0.0' }) ` + -GpuVendor $gpuVendor ` + -GpuName $gpuName ` + -AmdGpuName $amdGpuName ` + -IntelGpuName $intelGpuName ` + -AmdGfxTarget $amdGfxTarget ` + -HasAmd ([bool]$amdGpuName) ` + -HasIntel ([bool]$intelGpuName) ` + -SkipTriton:$SkipTriton +} catch { + if ($PlanOnly) { + [void]$report.result.blockers.Add($_.Exception.Message) + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host 'PLAN_UNSUPPORTED: pytorch' + return + } + throw +} +$report.request.SelectedBackend = $plan.Backend +$report.request.SelectedVendor = $plan.Vendor +$report.request.SelectedDevice = $plan.DeviceName +if ($RequireTriton -and -not $plan.InstallTriton) { + throw "Triton Windows is required but unsupported: $($plan.TritonReason)" +} +$catalog = (Get-AiCatalog).Components +$component = if ($plan.Backend -eq 'CUDA' -and $architecture -eq 'Arm64') { + $catalog.NvidiaPyTorchArm64 +} elseif ($plan.Backend -eq 'CUDA') { + $catalog.PyTorchCudaX64 +} elseif ($plan.Backend -eq 'ROCm') { + $catalog.PyTorchRocm +} elseif ($plan.Backend -eq 'XPU') { + $catalog.PyTorchXpu +} else { + $catalog.PyTorchCpu +} +Add-AiReportAcquisition -Report $report -Entry ([ordered]@{ + component = 'Python 3.13' + sourceType = 'winget' + packageId = 'Python.Python.3.13' + action = $pythonPackage.Action + packageEvidence = $(if ($PlanOnly) { $null } else { $pythonPackage.Evidence }) +}) +Add-AiReportAcquisition -Report $report -Entry ([ordered]@{ + component = "PyTorch $($plan.Backend)" + vendor = $component.Vendor + detectedVendorPriority = $gpuVendor + selectedDeviceName = $plan.DeviceName + selectedVendor = $plan.Vendor + selectedDevice = $plan.DeviceName + amdGfxTarget = $plan.AmdGfxTarget + architecture = $architecture + maturity = $component.Maturity + sourceType = $component.SourceType + requirement = $plan.TorchRequirement + additionalRequirements = $plan.AdditionalRequirements + runtimePackageTuple = @($plan.TorchRequirement) + @($plan.AdditionalRequirements) + index = $plan.IndexUrl + version = $plan.TorchVersion + versionPolicy = $component.VersionPolicy + integrity = $component.Integrity + cachePath = $component.CachePath + installPath = '%LOCALAPPDATA%\DevConfig\pytorch\.venv' + reasonNormalChannelInsufficient = $component.NormalChannelLimitation + expectedStableSource = $component.ExpectedStableSource + migrationTrigger = $component.MigrationTrigger + cleanupUpgrade = $component.CleanupUpgrade + promotionCandidate = Get-AiCatalogValue -Entry $component -Name 'PromotionCandidate' + nativeToolkitRequired = $component.NativeToolkitRequired + nativeToolkitRelationship = $component.NativeToolkitRelationship + action = $(if ($PlanOnly) { 'planned' } else { 'pending' }) +}) +if ($plan.InstallTriton) { + $tritonComponent = if ($plan.Backend -eq 'XPU') { $catalog.TritonXpu } else { $catalog.TritonWindows } + Add-AiReportAcquisition -Report $report -Entry ([ordered]@{ + component = $tritonComponent.Component + vendor = $tritonComponent.Vendor + architecture = $architecture + maturity = $tritonComponent.Maturity + sourceType = $tritonComponent.SourceType + package = $tritonComponent.Package + versionPolicy = $tritonComponent.VersionPolicy + integrity = $tritonComponent.Integrity + cachePath = $tritonComponent.CachePath + installPath = $tritonComponent.InstallPath + reasonNormalChannelInsufficient = $tritonComponent.NormalChannelLimitation + expectedStableSource = $tritonComponent.ExpectedStableSource + migrationTrigger = $tritonComponent.MigrationTrigger + cleanupUpgrade = $tritonComponent.CleanupUpgrade + action = $(if ($PlanOnly) { 'planned' } else { 'pending' }) + }) +} +if ($PlanOnly) { + Add-AiReportPhase -Report $report -Name 'tensor' -Status 'planned' -Evidence @{ + backend = $plan.Backend + vendor = $plan.Vendor + device = $plan.DeviceName + deviceIndex = $DeviceIndex + amdGfxTarget = $plan.AmdGfxTarget + selfContainedRuntime = $true + separateToolkitRequired = $false + } + Add-AiReportPhase -Report $report -Name 'triton' -Status $(if ($plan.InstallTriton) { 'planned' } elseif ($SkipTriton) { 'skipped' } else { 'unsupported' }) -Evidence @{ reason = $plan.TritonReason } + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host $(if ($report.result.blockers.Count) { 'PLAN_UNSUPPORTED: pytorch' } else { 'PLAN_OK: pytorch' }) + return +} + +$root = Join-Path $env:LOCALAPPDATA 'DevConfig\pytorch' +$venv = Join-Path $root '.venv' +$statePath = Join-Path $root 'install-state.json' +$desiredState = [ordered]@{ + architecture = $plan.Architecture + backend = $plan.Backend + torch = $plan.TorchRequirement + torchVersion = $plan.TorchVersion + index = $plan.IndexUrl + triton = $plan.TritonRequirement + tritonVersion = $plan.TritonVersion + numpy = $plan.NumpyRequirement + numpyVersion = $plan.NumpyVersion + additionalRequirements = @($plan.AdditionalRequirements) + python = "$($pythonVersion.Major).$($pythonVersion.Minor)" + deviceIndex = $DeviceIndex + selectedDevice = $plan.DeviceName +} +$desiredJson = $desiredState | ConvertTo-Json -Compress + +$currentJson = $null +if (Test-Path -LiteralPath $statePath) { + $currentJson = (Get-Content -LiteralPath $statePath -Raw).Trim() +} +$existingVenvPython = Join-Path $venv 'Scripts\python.exe' +$existingVersions = Get-PythonEnvironmentVersions -PythonPath $existingVenvPython +if ((Test-Path -LiteralPath $venv) -and + (Test-PyTorchEnvironmentRequiresRecreation ` + -DesiredStateJson $desiredJson ` + -CurrentStateJson $currentJson ` + -InstalledVersions $existingVersions)) { + Write-Host 'The requested PyTorch plan changed; recreating the contained environment.' + Remove-Item -LiteralPath $venv -Recurse -Force + $currentJson = $null + $existingVersions = $null +} + +New-Item -ItemType Directory -Path $root -Force | Out-Null +if (-not (Test-Path -LiteralPath (Join-Path $venv 'Scripts\python.exe'))) { + Invoke-CheckedCommand -FilePath $pythonPath -ArgumentList @('-m', 'venv', $venv) -DisplayName 'PyTorch virtual environment creation' +} + +$venvPython = Join-Path $venv 'Scripts\python.exe' +$installedVersions = if ($existingVersions) { + $existingVersions +} else { + Get-PythonEnvironmentVersions -PythonPath $venvPython +} +$packageAction = Get-PyTorchPackageAction ` + -DesiredStateJson $desiredJson ` + -CurrentStateJson $currentJson ` + -InstalledVersions $installedVersions + +if ($plan.InstallTriton -and $plan.Backend -in @('CUDA', 'XPU')) { + $cppTools = Ensure-AiVisualCppTools -Architecture $architecture + if ($plan.Backend -eq 'CUDA') { + [void](Ensure-AiCudaToolkit -Architecture $architecture) + } + $compiler = Import-MsvcEnvironment -Architecture $architecture + Write-Host "Triton JIT compiler: $compiler" +} + +if ($packageAction -eq 'VerifyOnly') { + Write-Host "PYTORCH_PACKAGES_CURRENT: torch=$($installedVersions.torch), numpy=$($installedVersions.numpy), triton=$($installedVersions.triton). Skipping package resolution and installation." +} else { + Invoke-CheckedCommand -FilePath $venvPython -ArgumentList @('-m', 'pip', 'install', '--upgrade', 'pip') -DisplayName 'pip upgrade' + Invoke-CheckedCommand ` + -FilePath $venvPython ` + -ArgumentList (Get-PipInstallArguments -Requirement $plan.NumpyRequirement) ` + -DisplayName 'NumPy installation from the configured Python index' + + if ($plan.DirectWheelUrl) { + $wheelDirectory = Join-Path $root 'wheel-cache' + $wheelPath = Join-Path $wheelDirectory $plan.DirectWheelFileName + Install-VerifiedDownload ` + -Uri $plan.DirectWheelUrl ` + -Destination $wheelPath ` + -Sha256 $plan.DirectWheelSha256 + Invoke-CheckedCommand ` + -FilePath $venvPython ` + -ArgumentList (Get-PipLocalWheelInstallArguments -WheelPath $wheelPath) ` + -DisplayName 'PyTorch installation from verified wheel cache' + } else { + $allRequirements = @($plan.TorchRequirement) + @($plan.AdditionalRequirements) + $binaryPolicy = if ($plan.Backend -eq 'ROCm') { @() } else { @('--only-binary=:all:') } + $torchDryRun = @('-m', 'pip', 'install', '--dry-run') + $binaryPolicy + $allRequirements + if ($plan.IndexUrl) { $torchDryRun += @('--index-url', $plan.IndexUrl) } + Invoke-CheckedCommand -FilePath $venvPython -ArgumentList $torchDryRun -DisplayName 'PyTorch compatible-wheel check' + $torchInstall = @('-m', 'pip', 'install') + $binaryPolicy + $allRequirements + if ($plan.IndexUrl) { $torchInstall += @('--index-url', $plan.IndexUrl) } + Invoke-CheckedCommand -FilePath $venvPython -ArgumentList $torchInstall -DisplayName 'PyTorch installation' + } + + if ($plan.InstallTriton) { + $tritonDryRun = Get-PipInstallArguments -Requirement $plan.TritonRequirement -IndexUrl $(if ($plan.Backend -eq 'XPU') { $plan.IndexUrl } else { $null }) -DryRun + Invoke-CheckedCommand -FilePath $venvPython -ArgumentList $tritonDryRun -DisplayName 'Triton Windows compatible-wheel check' + $tritonInstall = Get-PipInstallArguments -Requirement $plan.TritonRequirement -IndexUrl $(if ($plan.Backend -eq 'XPU') { $plan.IndexUrl } else { $null }) + Invoke-CheckedCommand -FilePath $venvPython -ArgumentList $tritonInstall -DisplayName 'Triton Windows installation' + } + Invoke-CheckedCommand -FilePath $venvPython -ArgumentList @('-m', 'pip', 'check') -DisplayName 'PyTorch dependency check' +} + +$tensorArguments = @( + (Join-Path $PSScriptRoot 'smoke.py'), '--backend', $plan.Backend, '--device-index', $DeviceIndex +) +$tensorResult = Invoke-DevConfigNativeCommand -FilePath $venvPython -Arguments $tensorArguments +$tensorEvidence = $tensorResult.Output.Trim() +if ($tensorResult.ExitCode -ne 0) { + throw "PyTorch $($plan.Backend) tensor smoke failed: $tensorEvidence" +} +$tensorRecord = ConvertFrom-AiKeyedJsonLine -Text $tensorEvidence -Prefix 'PYTORCH_SMOKE=' +if ($plan.Backend -ne 'CPU' -and $plan.DeviceName -and + -not (Test-AiDeviceNameMatch -Expected $plan.DeviceName -Actual $tensorRecord.device)) { + throw "PyTorch device index $DeviceIndex executed on '$($tensorRecord.device)', but the resolver selected '$($plan.DeviceName)'. Use the matching -DeviceIndex." +} +if ($plan.InstallTriton) { + $tritonSmoke = if ($plan.Backend -eq 'XPU') { 'xpu-smoke.py' } else { 'triton-smoke.py' } + $tritonResult = Invoke-DevConfigNativeCommand -FilePath $venvPython -Arguments @( + (Join-Path $PSScriptRoot $tritonSmoke), '--device-index', $DeviceIndex + ) + $tritonEvidence = $tritonResult.Output.Trim() + if ($tritonResult.ExitCode -ne 0) { + throw "Triton $($plan.Backend) GPU kernel smoke failed: $tritonEvidence" + } + $tritonRecord = if ($plan.Backend -eq 'XPU') { + ConvertFrom-AiKeyedJsonLine -Text $tritonEvidence -Prefix 'TRITON_XPU_READY=' + } else { + $null + } + Write-Host "TRITON_READY: $($plan.TritonRequirement)" +} else { + $tritonEvidence = $plan.TritonReason + Write-Host "TRITON_SKIPPED: $($plan.TritonReason)" +} + +Set-Content -LiteralPath $statePath -Value $desiredJson -Encoding ascii +if ($plan.Preview) { + Write-Warning 'PyTorch CUDA on Windows ARM64 is an NVIDIA Developer Preview nightly, not a stable or production-supported release.' +} +Write-Host "PYTORCH_READY: backend=$($plan.Backend), runtime=$($plan.Runtime), environment=$venv" +$versions = Get-PythonEnvironmentVersions -PythonPath $venvPython +$report.acceptance.tensor = [ordered]@{ + backend = $plan.Backend + vendor = $plan.Vendor + runtime = $plan.Runtime + device = $tensorRecord.device + deviceIndex = $DeviceIndex + amdGfxTarget = $plan.AmdGfxTarget + torch = $versions.torch + numpy = $versions.numpy + torchCudaRuntime = $tensorRecord.torch_cuda_runtime + torchHipRuntime = $tensorRecord.torch_hip_runtime + runtimePackageTuple = @($plan.TorchRequirement) + @($plan.AdditionalRequirements) + selfContainedRuntime = $true + separateNativeToolkitRequired = $false + deviceEvidence = $tensorEvidence +} +$report.acceptance.triton = [ordered]@{ + supported = [bool]$plan.InstallTriton + version = $versions.triton + distribution = $versions.triton_distribution + reason = $plan.TritonReason + evidence = $tritonEvidence + device = $(if ($tritonRecord) { $tritonRecord.device } else { $tensorRecord.device }) + torchCompileExecuted = $(if ($tritonRecord) { [bool]$tritonRecord.torch_compile_executed } else { $null }) +} +$report.acquisitions[1].action = $packageAction.ToLowerInvariant() +if ($plan.InstallTriton) { + $report.acquisitions[2].action = $(if ($packageAction -eq 'VerifyOnly') { 'already-current' } else { 'installed-or-upgraded' }) +} +Complete-AiWorkloadReport -Report $report -Ready $true -Path $ReportPath +Write-Host "Activate with: & '$venv\Scripts\Activate.ps1'" +Write-Host 'INSTALL_OK: pytorch' diff --git a/src/Workloads/pytorch/smoke.py b/src/Workloads/pytorch/smoke.py new file mode 100644 index 00000000..4cdaded2 --- /dev/null +++ b/src/Workloads/pytorch/smoke.py @@ -0,0 +1,69 @@ +import argparse +import json + +import numpy +import torch + + +parser = argparse.ArgumentParser() +parser.add_argument("--backend", choices=("CPU", "CUDA", "ROCm", "XPU"), required=True) +parser.add_argument("--device-index", type=int, default=0) +args = parser.parse_args() + +device_type = "xpu" if args.backend == "XPU" else ("cuda" if args.backend in ("CUDA", "ROCm") else "cpu") +device = f"{device_type}:{args.device_index}" if device_type != "cpu" else "cpu" +if device_type == "cuda" and not torch.cuda.is_available(): + stack = "ROCm/HIP" if args.backend == "ROCm" else "CUDA" + raise RuntimeError(f"The {stack} PyTorch stack imported, but torch.cuda.is_available() is false.") +if device_type == "xpu" and not torch.xpu.is_available(): + raise RuntimeError("The XPU wheel imported, but torch.xpu.is_available() is false.") +if args.backend == "ROCm" and not torch.version.hip: + raise RuntimeError("The ROCm PyTorch stack imported, but torch.version.hip is null.") +if args.backend == "CUDA" and not torch.version.cuda: + raise RuntimeError("The CUDA PyTorch stack imported, but torch.version.cuda is null.") + +tensor = torch.tensor([1.0, 2.0], device=device) +result = (tensor * 2).cpu().tolist() +if result != [2.0, 4.0]: + raise RuntimeError(f"Unexpected tensor result: {result}") +if device_type == "cuda": + torch.cuda.synchronize(args.device_index) +elif device_type == "xpu": + torch.xpu.synchronize() +array = (tensor * 2).cpu().numpy() +if not numpy.array_equal(array, numpy.array([2.0, 4.0])): + raise RuntimeError(f"Unexpected NumPy bridge result: {array}") + +model = torch.nn.Sequential( + torch.nn.Linear(2, 4), + torch.nn.ReLU(), + torch.nn.Linear(4, 1), +).to(device) +with torch.no_grad(): + model_result = model(torch.tensor([[1.0, 2.0]], device=device)).cpu().item() +if not numpy.isfinite(model_result): + raise RuntimeError(f"Minimal neural-network forward pass was not finite: {model_result}") + +details = { + "backend": args.backend, + "vendor": { + "CUDA": "NVIDIA", + "ROCm": "AMD", + "XPU": "Intel", + "CPU": "CPU", + }[args.backend], + "device": ( + torch.cuda.get_device_name(args.device_index) + if device_type == "cuda" + else (torch.xpu.get_device_name(args.device_index) if device_type == "xpu" else "CPU") + ), + "torch": torch.__version__, + "torch_cuda_runtime": torch.version.cuda, + "numpy": numpy.__version__, + "torch_hip_runtime": torch.version.hip, + "tensor_device_type": tensor.device.type, + "device_index": args.device_index, + "tensor_operation_verified": True, + "model_forward_verified": True, +} +print("PYTORCH_SMOKE=" + json.dumps(details, sort_keys=True)) diff --git a/src/Workloads/pytorch/triton-smoke.py b/src/Workloads/pytorch/triton-smoke.py new file mode 100644 index 00000000..8ef3593b --- /dev/null +++ b/src/Workloads/pytorch/triton-smoke.py @@ -0,0 +1,31 @@ +import argparse +import torch +import triton +import triton.language as tl + + +parser = argparse.ArgumentParser() +parser.add_argument("--device-index", type=int, default=0) +args = parser.parse_args() +torch.cuda.set_device(args.device_index) + + +@triton.jit +def add_kernel(x_ptr, y_ptr, output_ptr, size: tl.constexpr, block_size: tl.constexpr): + offsets = tl.arange(0, block_size) + mask = offsets < size + x = tl.load(x_ptr + offsets, mask=mask) + y = tl.load(y_ptr + offsets, mask=mask) + tl.store(output_ptr + offsets, x + y, mask=mask) + + +size = 1024 +device = f"cuda:{args.device_index}" +x = torch.arange(size, device=device, dtype=torch.float32) +y = torch.full((size,), 2.0, device=device) +output = torch.empty_like(x) +add_kernel[(1,)](x, y, output, size=size, block_size=1024) +torch.cuda.synchronize() +if not torch.equal(output, x + y): + raise RuntimeError("Triton vector-add result did not match PyTorch.") +print(f"TRITON_SMOKE=vector-add,device={torch.cuda.get_device_name(args.device_index)},index={args.device_index}") diff --git a/src/Workloads/pytorch/xpu-smoke.py b/src/Workloads/pytorch/xpu-smoke.py new file mode 100644 index 00000000..d7397863 --- /dev/null +++ b/src/Workloads/pytorch/xpu-smoke.py @@ -0,0 +1,41 @@ +import json +import argparse +import torch +import triton + + +parser = argparse.ArgumentParser() +parser.add_argument("--device-index", type=int, default=0) +args = parser.parse_args() + + +def fn(x): + return torch.sin(x) + torch.cos(x) + + +if not torch.xpu.is_available(): + raise RuntimeError("torch.xpu is unavailable") + +torch.xpu.set_device(args.device_index) +device = f"xpu:{args.device_index}" +x = torch.randn(4096, device=device) +expected = fn(x) +compiled = torch.compile(fn) +actual = compiled(x) +torch.xpu.synchronize() +torch.testing.assert_close(actual, expected) +print( + "TRITON_XPU_READY=" + + json.dumps( + { + "backend": "XPU", + "vendor": "Intel", + "device": torch.xpu.get_device_name(args.device_index), + "device_index": args.device_index, + "torch": torch.__version__, + "triton_xpu": triton.__version__, + "torch_compile_executed": True, + }, + sort_keys=True, + ) +) diff --git a/src/Workloads/rocm/hip-smoke.cpp b/src/Workloads/rocm/hip-smoke.cpp new file mode 100644 index 00000000..6df7dd04 --- /dev/null +++ b/src/Workloads/rocm/hip-smoke.cpp @@ -0,0 +1,42 @@ +#include + +#include +#include + +#define CHECK(call) \ + do { \ + hipError_t error = (call); \ + if (error != hipSuccess) { \ + std::fprintf(stderr, "%s: %s\n", #call, hipGetErrorString(error)); \ + return 1; \ + } \ + } while (0) + +__global__ void write_marker(int* value) { + if (blockIdx.x == 0 && threadIdx.x == 0) { + *value = 42; + } +} + +int main(int argc, char** argv) { + const int device_index = argc > 1 ? std::atoi(argv[1]) : 0; + int device_count = 0; + CHECK(hipGetDeviceCount(&device_count)); + if (device_index < 0 || device_index >= device_count) return 2; + CHECK(hipSetDevice(device_index)); + + hipDeviceProp_t properties{}; + CHECK(hipGetDeviceProperties(&properties, device_index)); + + int* device_value = nullptr; + int host_value = 0; + CHECK(hipMalloc(reinterpret_cast(&device_value), sizeof(host_value))); + hipLaunchKernelGGL(write_marker, dim3(1), dim3(1), 0, 0, device_value); + CHECK(hipGetLastError()); + CHECK(hipDeviceSynchronize()); + CHECK(hipMemcpy(&host_value, device_value, sizeof(host_value), hipMemcpyDeviceToHost)); + CHECK(hipFree(device_value)); + + std::printf("HIP_KERNEL_READY device_index=%d device=%s value=%d\n", device_index, properties.name, host_value); + return host_value == 42 ? 0 : 3; +} diff --git a/src/Workloads/rocm/install.ps1 b/src/Workloads/rocm/install.ps1 new file mode 100644 index 00000000..dfa1688e --- /dev/null +++ b/src/Workloads/rocm/install.ps1 @@ -0,0 +1,170 @@ +<# +.SYNOPSIS + Install AMD ROCm Core SDK on supported Windows x64 hardware and execute a HIP kernel. + +.PARAMETER DeviceIndex + Zero-based AMD device index used for HIP kernel execution on same-vendor + multi-adapter systems. +#> +[CmdletBinding()] +param( + [ValidateRange(0, 63)] [int] $DeviceIndex = 0, + [switch] $PlanOnly, + [string] $ReportPath = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_common\direct-setup.ps1') +. (Join-Path $PSScriptRoot '..\_common\ai-report.ps1') + +$architecture = Get-DevConfigArchitecture +$gpuName = Get-AmdGpuName -DeviceIndex $DeviceIndex +$rocmPlan = $null +$planError = $null +try { + $rocmPlan = Resolve-RocmInstallPlan -Architecture $architecture -GpuName $gpuName +} catch { + $planError = $_.Exception.Message +} +$gfx = if ($rocmPlan) { $rocmPlan.GfxTarget } else { $null } + +$catalog = (Get-AiCatalog).Components +$component = $catalog.AmdRocm +$report = New-AiWorkloadReport -Id 'rocm' -Request @{ + PlanOnly = [bool]$PlanOnly + GpuName = $gpuName + GfxTarget = $gfx + DeviceIndex = $DeviceIndex +} +if (-not $ReportPath) { $ReportPath = Get-AiDefaultReportPath -Id 'rocm' } +trap { + Write-AiFailureReport -Report $report -Path $ReportPath -ErrorRecord $_ + throw $_ +} +if (-not $PlanOnly) { Assert-AiAdministrator } +Add-AiReportAcquisition -Report $report -Entry ([ordered]@{ + component = $component.Component + vendor = $component.Vendor + architecture = $architecture + gpu = $gpuName + gfxTarget = $gfx + maturity = $component.Maturity + sourceType = $component.SourceType + index = $component.IndexUrl + requirement = $(if ($gfx) { $component.PackageTemplate -f $gfx } else { $null }) + version = $component.Version + versionPolicy = $component.VersionPolicy + integrity = $component.Integrity + cachePath = $component.CachePath + installPath = $component.InstallPath + reasonNormalChannelInsufficient = $component.NormalChannelLimitation + expectedStableSource = $component.ExpectedStableSource + migrationTrigger = $component.MigrationTrigger + cleanupUpgrade = $component.CleanupUpgrade + action = $(if ($PlanOnly) { 'planned' } else { 'pending' }) +}) +if ($planError) { + [void]$report.result.blockers.Add($planError) + Set-AiAcquisitionAction -Report $report -Index 0 -Action 'blocked' +} +if ($report.result.blockers.Count -gt 0) { + if ($PlanOnly) { + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host 'PLAN_UNSUPPORTED: rocm' + return + } + throw ($report.result.blockers -join ' ') +} +$pythonPackage = Ensure-AiWingetPackage -Id 'Python.Python.3.13' -PlanOnly:$PlanOnly +$cppTools = Ensure-AiVisualCppTools -Architecture X64 -PlanOnly:$PlanOnly +Add-AiReportPhase -Report $report -Name 'host-compiler' -Status $(if ($PlanOnly) { 'planned' } else { 'ready' }) -Evidence $cppTools +$requirement = $rocmPlan.Requirement +$report.acquisitions[0].requirement = $requirement +Add-AiReportAcquisition -Report $report -Entry ([ordered]@{ + component = 'Python 3.13' + sourceType = 'winget' + packageId = 'Python.Python.3.13' + action = $pythonPackage.Action + packageEvidence = $(if ($PlanOnly) { $null } else { $pythonPackage.Evidence }) +}) +if ($PlanOnly) { + Add-AiReportPhase -Report $report -Name 'hip-kernel' -Status 'planned' -Evidence @{ gpu = $gpuName; gfx = $gfx; deviceIndex = $DeviceIndex } + Complete-AiWorkloadReport -Report $report -Ready $false -Path $ReportPath + Write-Host 'PLAN_OK: rocm' + return +} + +$compiler = Import-MsvcEnvironment -Architecture X64 +$python = Get-Python313Path -Architecture X64 +$root = Join-Path $env:LOCALAPPDATA 'DevConfig\rocm' +$venv = Join-Path $root '.venv' +$statePath = Join-Path $root 'install-state.json' +$desired = [ordered]@{ requirement = $requirement; python = '3.13'; gfx = $gfx } | ConvertTo-Json -Compress +if ((Test-Path $statePath) -and (Test-Path $venv) -and + ((Get-Content $statePath -Raw).Trim() -ne $desired)) { + Remove-Item -LiteralPath $venv -Recurse -Force +} +New-Item -ItemType Directory -Path $root -Force | Out-Null +if (-not (Test-Path (Join-Path $venv 'Scripts\python.exe'))) { + Invoke-CheckedCommand -FilePath $python -ArgumentList @('-m', 'venv', $venv) -DisplayName 'ROCm environment creation' +} +$venvPython = Join-Path $venv 'Scripts\python.exe' +$hipcc = Join-Path $venv 'Scripts\hipcc.exe' +$expectedRocmPackages = @{ + 'rocm-sdk-core' = '10.0.0' + 'rocm-sdk-devel' = '10.0.0' + 'rocm-sdk-libraries' = '10.0.0' + "rocm-sdk-device-$gfx" = '10.0.0' +} +$packagesCurrent = (Test-Path $hipcc) -and + (Test-PythonDistributionVersions -PythonPath $venvPython -Expected $expectedRocmPackages) +if (-not $packagesCurrent) { + Invoke-CheckedCommand -FilePath $venvPython -ArgumentList @( + '-m', 'pip', 'install', '--upgrade', 'pip' + ) -DisplayName 'pip upgrade' + Invoke-CheckedCommand -FilePath $venvPython -ArgumentList @( + '-m', 'pip', 'install', '--index-url', $component.IndexUrl, $requirement + ) -DisplayName 'AMD ROCm Core SDK installation' +} +Invoke-CheckedCommand -FilePath $venvPython -ArgumentList @('-m', 'pip', 'check') -DisplayName 'ROCm dependency check' + +$temporary = Join-Path ([System.IO.Path]::GetTempPath()) "devconfig-hip-$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $temporary -Force | Out-Null +try { + $executable = Join-Path $temporary 'hip-smoke.exe' + Invoke-CheckedCommand -FilePath $hipcc -ArgumentList @( + (Join-Path $PSScriptRoot 'hip-smoke.cpp'), '-O2', '-o', $executable + ) -DisplayName 'HIP kernel compilation' + $evidence = (& $executable $DeviceIndex 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $evidence -notmatch '^HIP_KERNEL_READY') { + throw "HIP kernel acceptance failed (exit $LASTEXITCODE): $evidence" + } + $deviceMatch = [regex]::Match($evidence, '^HIP_KERNEL_READY device_index=([0-9]+) device=(.+?) value=42$') + if (-not $deviceMatch.Success) { + throw "HIP kernel evidence did not contain the selected device: $evidence" + } + $actualGpuName = $deviceMatch.Groups[2].Value + if (-not (Test-AiDeviceNameMatch -Expected $gpuName -Actual $actualGpuName)) { + throw "HIP device index $DeviceIndex executed on '$actualGpuName', but acquisition was resolved for '$gpuName' ($gfx). Use the matching -DeviceIndex." + } +} finally { + Remove-Item -LiteralPath $temporary -Recurse -Force -ErrorAction SilentlyContinue +} +Set-Content -LiteralPath $statePath -Value $desired -Encoding ascii +$report.acceptance.hipKernel = [ordered]@{ + compiled = $true + executed = $true + evidence = $evidence + gpu = $gpuName + actualGpu = $actualGpuName + deviceIndex = $DeviceIndex + gfxTarget = $gfx + hostCompiler = $compiler +} +$report.acquisitions[0].action = $(if ($packagesCurrent) { 'already-current' } else { 'installed-or-upgraded' }) +Add-AiReportPhase -Report $report -Name 'hip-kernel' -Status 'ready' -Evidence $report.acceptance.hipKernel +Complete-AiWorkloadReport -Report $report -Ready $true -Path $ReportPath +Write-Host "ROCM_READY: $gpuName ($gfx)" +Write-Host 'INSTALL_OK: rocm' diff --git a/src/docs/ai-workload-report.schema.json b/src/docs/ai-workload-report.schema.json new file mode 100644 index 00000000..e438d75c --- /dev/null +++ b/src/docs/ai-workload-report.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/microsoft/WindowsDeveloperConfig/blob/main/src/docs/ai-workload-report.schema.json", + "title": "Windows Developer Config AI workload report", + "type": "object", + "required": ["schemaVersion", "workload", "startedAtUtc", "host", "request", "acquisitions", "phases", "acceptance", "result"], + "properties": { + "schemaVersion": { "const": 1 }, + "workload": { "type": "string" }, + "startedAtUtc": { "type": "string", "format": "date-time" }, + "completedAtUtc": { "type": ["string", "null"], "format": "date-time" }, + "host": { + "type": "object", + "required": ["osBuild", "architecture", "gpus"], + "properties": { + "os": { "type": ["string", "null"] }, + "osVersion": { "type": ["string", "null"] }, + "osBuild": { "type": ["string", "null"] }, + "architecture": { "enum": ["X64", "Arm64"] }, + "powershell": { "type": "string" }, + "gpus": { "type": "array" } + } + }, + "request": { "type": "object" }, + "acquisitions": { + "type": "array", + "items": { + "type": "object", + "required": ["component", "sourceType", "action"] + } + }, + "phases": { "type": "array" }, + "acceptance": { "type": "object" }, + "result": { + "type": "object", + "required": ["ready", "planOnly", "warnings", "blockers"], + "properties": { + "ready": { "type": "boolean" }, + "planOnly": { "type": "boolean" }, + "fallbackUsed": { "type": "boolean" }, + "warnings": { "type": "array" }, + "blockers": { "type": "array" } + } + } + } +} diff --git a/src/docs/development.md b/src/docs/development.md index a25f1f88..db06eaf8 100644 --- a/src/docs/development.md +++ b/src/docs/development.md @@ -7,7 +7,7 @@ Opinionated, CI-validated configurations for bootstrapping developer toolchains and Windows-desktop personalities. -Most flows are built around a [winget DSC configuration +Most language and desktop flows are built around a [winget DSC configuration file](https://learn.microsoft.com/windows/package-manager/configuration/) (`configuration.winget`) — a declarative, idempotent description of the machine state required for that flow. Where winget alone is not enough @@ -17,13 +17,21 @@ resource, so everything the flow needs lives in one YAML file. A small `install.ps1` shim next to it applies the config with `winget configure` and handles session-level glue (PATH refresh, CI sentinel). -Two flows are **PowerShell-native** instead: Windows Dev Config -(`src/windows-dev-config/`) and Comfort Shell (`src/wsl-comfort/`). They -need work a configuration file doesn't express well — elevation, a reboot with an -automatic resume, an interactive progress display — so they ship as -PowerShell scripts with no configuration file at all. They keep the same -idempotency contract: every step checks current state, acts only when -needed, and verifies the result. +The AI workloads are PowerShell-native. They follow Windows Dev Config's +resumable check → apply → verify contracts and reuse its WinGet, retry, process, +and PATH helpers through `Workloads/_common/direct-setup.ps1`. They need runtime +hardware selection, contained Python environments, verified release assets, and +real device/model acceptance that is awkward and misleading inside static DSC. +The shared package state machine distinguishes absent, upgrade-available, and +current packages: it installs only absent IDs, uses exact `winget upgrade` for +outdated packages, and skips current packages. If the WinGet module operation +fails but `winget.exe` is usable, it retries the same exact operation through +the CLI and verifies the settled state. + +Windows Dev Config and Comfort Shell are also PowerShell-native because they +need elevation, reboot/resume, or interactive orchestration. All +PowerShell-native flows keep the same idempotency contract: every step checks +current state, acts only when needed, and verifies the result. Every automated flow is **exercised on a real GitHub-hosted runner** on every push, pull request, and nightly: the flow is applied, then a canonical "hello @@ -33,8 +41,8 @@ configuration actually produced a working toolchain. ## Supported flows -Each flow's `configuration.winget` — or, for the two PowerShell-native -flows, its entry script — is the source of truth for what gets installed; +Each flow's `configuration.winget` or PowerShell-native entry script is the +source of truth for what gets installed; the table below summarizes it for quick scanning. Flows marked **manual** are excluded from the automated CI matrix (they need an interactive desktop session or pull multi-GB workloads we don't want to chew minutes @@ -56,6 +64,14 @@ Command Palette extension. | WinAppCLI | ✅ automated | Developer Mode + `Microsoft.DotNet.SDK.10` + `Microsoft.WinAppCli` | | WinUI 3 | 🙋 manual | `Microsoft.DotNet.SDK.10`, `Microsoft.VisualStudio.Community`, `Microsoft.WinAppCli` + WinUI/Universal/ManagedDesktop VS workloads | | Windows Dev Config | 🙋 manual | PowerShell setup for developer tools, Windows settings, fonts, Terminal, and WSL + Ubuntu (see [`windows-dev-config/README.md`](../windows-dev-config/README.md)) | +| NVIDIA CUDA | 🙋 manual | `Nvidia.CUDA` x64 or checksum/signature-pinned 13.4 ARM64 preview + MSVC + GPU kernel | +| AMD ROCm / HIP | 🙋 manual | ROCm Core SDK 10.0 on supported Windows x64 AMD GPUs + compiled HIP kernel | +| Intel AI | 🙋 manual | OpenVINO device inference; optional oneAPI/SYCL toolkit and GPU kernel | +| Foundry Local | 🙋 manual | `Microsoft.FoundryLocal` architecture-native WinML package + Qwen3 inference; no CUDA dependency | +| PyTorch | 🙋 manual | `Python.Python.3.13` + private CPU/CUDA/ROCm/XPU environment + supported Triton provider | +| Local AI development | 🙋 manual | Scenario: hardware inventory + contained PyTorch/Triton + one optional model runtime | +| llama.cpp | 🙋 manual | SHA-256-verified official rolling CUDA/ROCm/SYCL/OpenVINO/Vulkan/OpenCL/CPU assets + pinned GGUF | +| Ollama | 🙋 manual | `Ollama.Ollama` x64 application or Dev Config-managed official native ARM64 archive + model inference | | Comfort Shell | 🙋 manual | WSL distro + zsh/bash + starship + modern CLI bundle + Cascadia Code Nerd Font + themed Windows Terminal profile (see [`wsl-comfort/readme.md`](../wsl-comfort/readme.md)) | See [`manifest.yml`](../manifest.yml) for the canonical declarative @@ -65,8 +81,8 @@ list (paths, build/run commands, onboarding URLs). A [PowerToys Command Palette](https://learn.microsoft.com/windows/powertoys/command-palette/overview) extension lives under [`future/cmdpal/`](../future/cmdpal/). It reads the same -`manifest.yml` as CI and lets you browse + launch any flow without remembering -which `configuration.winget` to point `winget` at. +`manifest.yml` as CI and launches DSC-backed or PowerShell-native flows from one +list. The UX metadata each flow needs (`name`, `description`, `category`, `tags`, `icon`, `onboardingUrl`) is colocated with the CI fields in `manifest.yml` so @@ -77,7 +93,7 @@ for build + configuration details. ``` Workloads/ - _common/ # shared PowerShell shim helpers (retry, refresh PATH, preflight, assert-winget-configure, apply-configuration) + _common/ # shared DSC glue plus direct AI acquisition, resolver catalog, and reporting helpers typescript/ # configuration.winget (core) + install.ps1 (thin shim) php/ # configuration.winget (core) + install.ps1 (thin shim) python/ # configuration.winget (core) + install.ps1 (thin shim) @@ -87,6 +103,13 @@ Workloads/ rust/ # configuration.winget (core) + install.ps1 (thin shim) winforms/ # configuration.winget (core) + install.ps1 (thin shim) winui/ # configuration.winget (core) + install.ps1 (thin shim) + cuda/ # x64/ARM64 CUDA + MSVC + compiled GPU-kernel readiness + rocm/ # Windows x64 AMD ROCm Core SDK + compiled HIP kernel + intel-ai/ # Windows x64 OpenVINO and optional oneAPI/SYCL + foundry/ # x64/ARM64 Foundry Local + catalog-model inference + pytorch/ # x64/ARM64 Python + contained backend-selected environment + llama.cpp/ # hardware-selected official rolling backend + pinned GGUF inference + ollama/ # architecture-specific direct acquisition + library-model inference windows-dev-config/ # Windows Dev Config — bootstrap.ps1 (remote entry) + dev-config.ps1 (orchestrator) + steps/*.ps1 + README.md wsl-comfort/ # Comfort Shell — install.ps1 (Windows side) + comfort-shell-bootstrap.sh (Linux side, self-contained) + readme.md tests/ @@ -154,9 +177,10 @@ Maintainers: once this guard has landed, add **`Signed copy guard`** to the requ ## Prerequisites (Windows) -Every flow — and the [Command Palette extension](../future/cmdpal/) — installs -toolchains through `winget configure`. That subcommand must be available on -your machine before anything in this repo can succeed: +DSC-backed language/desktop flows install through `winget configure`. The +PowerShell-native AI and workstation flows do not require the configure +subcommand; they use WinGet's package API/CLI directly when a package is +available and verified vendor artifacts otherwise. - **App Installer (winget)** must be current. Update from the Microsoft Store, or grab the latest MSIX from @@ -203,6 +227,18 @@ session and prints a CI-friendly sentinel: -Expected tests/typescript/expected.txt ``` +AI flows are always launched through their PowerShell entry point: + +```powershell +.\Workloads\cuda\install.ps1 +.\Workloads\rocm\install.ps1 +.\Workloads\intel-ai\install.ps1 +.\Workloads\pytorch\install.ps1 +``` + +Use `-PlanOnly` to resolve hardware, architecture, channel, and planned +acquisitions without changing the machine. Each run emits an `AI_REPORT:` path. + ## Testing and verifying locally CI runs each flow on a fresh `windows-latest` runner, so the highest-fidelity @@ -245,6 +281,385 @@ installed, also run: Invoke-ScriptAnalyzer -Recurse -Path ./Workloads, ./tests/_harness ``` +The hardware-dependent AI workloads also provide pure decision tests that do +not require a GPU or install software: + +```powershell +foreach ($id in 'ai-common','cuda','rocm','intel-ai','foundry','pytorch','llama.cpp','ollama') { + & ".\tests\$id\unit.ps1" +} +``` + +These cover architecture selection, backend and dependency decisions, +idempotent plan construction, missing tool/hardware errors, and generated pip +arguments. Their runtime probes remain `manual_test` because hosted CI cannot +exercise the required GPUs, local servers, or multi-gigabyte installers. + +### AI workload support and manual verification + +Run from the `src` directory: + +```powershell +.\Workloads\local-ai\install.ps1 +.\Workloads\cuda\install.ps1 +.\Workloads\rocm\install.ps1 +.\Workloads\intel-ai\install.ps1 +.\Workloads\foundry\install.ps1 +.\Workloads\pytorch\install.ps1 +.\Workloads\llama.cpp\install.ps1 +.\Workloads\ollama\install.ps1 +``` + +The scenario entry point is the recommended first run. It installs only the +hardware-selected contained PyTorch backend and compatible Triton, executes a +tensor plus a minimal neural-network forward pass, and optionally invokes one +runtime: + +```powershell +$url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1' +& ([scriptblock]::Create((irm $url))) -Scenario local-ai + +# PR/source validation before a sign cycle: +# First apply the documented temporary CurrentUser Bypass policy, then restore it. +$prHead = gh pr view 104 --repo microsoft/WindowsDeveloperConfig ` + --json headRefOid --jq .headRefOid +$prUrl = "https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/$prHead/src/windows-dev-config/bootstrap.ps1" +& ([scriptblock]::Create((irm $prUrl))) ` + -Ref $prHead -Scenario local-ai -AllowUnsigned + +.\Workloads\local-ai\install.ps1 +# PYTORCH_SMOKE=... "model_forward_verified": true ... +# PYTORCH_READY: backend=, ... +# LOCAL_AI_SCENARIO_READY: backend=Auto, runtime=None, ... + +.\Workloads\local-ai\install.ps1 -Runtime LlamaCpp +``` + +`bootstrap.ps1 -Scenario local-ai` is the product-level dispatcher. It selects +signed top-level `Workloads/` by default (or explicit unsigned `src/Workloads/` +for branch testing), verifies/copies the full dependency tree and Windows Dev +Config helper steps into a protected scenario root, verifies non-PowerShell +inputs against the Microsoft-signed `_common/content-hashes.ps1`, and launches +only the local AI scenario. It does not run the full workstation installer. +Unsigned branch tests are isolated under `%ProgramData%\CalmOS-Development`; +production signed payloads remain under `%ProgramData%\CalmOS`. + +Transitive acquisition: + +| Entry point | Conditional dependencies | +| --- | --- | +| `local-ai` | Inventory → PyTorch Auto. NVIDIA torch runtime; MSVC + standalone CUDA only for Triton JIT. AMD device runtime tuple in the PyTorch venv, not native `hipcc`. Intel XPU + `triton-xpu`, not full oneAPI. CPU tuple only. | +| `local-ai -Runtime LlamaCpp` | PyTorch stack + selected llama runtime + quick GGUF; NVIDIA llama assets include `cudart` and do not independently require full CUDA. | +| `local-ai -Runtime Ollama` | PyTorch stack + source-managed Ollama backend/model; actual allocation reported. | +| `local-ai -Runtime Foundry` | PyTorch stack + source-managed Foundry EP/model; actual EP/fallback reported. | +| `pytorch` | Same exact backend rules as the scenario core. | +| `cuda` / `rocm` / `intel-ai` | Native developer toolkit flows; do not install PyTorch or every model runtime. | +| `llama.cpp` / `ollama` / `foundry` | Independent model runtime and quick model only; do not install the other runtimes. | + +Drivers are qualified prerequisites and are never replaced. + +Ollama x64 remains the registered `Ollama.Ollama` application. ARM64 resolves +the latest non-prerelease official `ollama-windows-arm64.zip`, verifies its +GitHub-published digest and ARM64 PE machine type, then provides managed +application semantics under `%LOCALAPPDATA%\Programs\Ollama`: atomic upgrade, +user PATH, an HKCU Run startup entry, and `.devconfig-install.json`. Use +`.\Workloads\ollama\install.ps1 -Uninstall` to remove runtime/startup/PATH while +preserving `%USERPROFILE%\.ollama\models`; add `-RemoveModels` for explicit +model deletion. The x64 setup executable is never run under emulation and the +portable WinGet identity is never selected. + +This is not a general Python package manager or a request to install every AI +SDK. Native `cuda`, `rocm`, and `intel-ai` remain independent developer-toolkit +flows. Foundry, llama.cpp, and Ollama are optional model-runtime choices. + +After llama.cpp validation, an opt-in coding example uses a pinned ~1.04 GB +Apache-2.0 Qwen2.5-Coder model without enlarging the default install: + +```powershell +.\Workloads\llama.cpp\coding-demo.ps1 ` + -ReportPath "$env:TEMP\llama-coding-demo.json" +# Expected: Python def group_anagrams(...) and CODING_DEMO_READY +``` + +| Flow | x64 behavior | ARM64 behavior | Readiness signal | +| --- | --- | --- | --- | +| CUDA | Current stable CUDA 13 `Nvidia.CUDA` + MSVC; driver 580+/CC7.5+ for GPU readiness | Checksum- and Authenticode-verified NVIDIA CUDA 13.4 Developer Preview + ARM64 MSVC | Compile and execute `smoke.cu`; `-SkipWorkloadSmoke` opts out | +| ROCm / HIP | AMD stable ROCm 10.0 feed on supported Radeon/Ryzen AI GPUs | Unsupported | Compile and execute `hip-smoke.cpp`; exact GPU maps to a published `gfx` target | +| Intel AI | OpenVINO CPU/GPU/NPU; optional oneAPI/SYCL GPU tooling | Unsupported | Generated OpenVINO model executes on requested device; Full profile also runs a SYCL kernel | +| Foundry | WinGet x64 WinML package | WinGet ARM64 WinML package | Download `qwen3-0.6b` (~593 MB) and generate a marker; CUDA is never assumed | +| PyTorch CUDA | Stable NVIDIA CUDA wheel selected by driver/device | Pinned NVIDIA CUDA 13.4 qualified interim wheel for CPython 3.13/RTX Spark | Self-contained wheel runtime, device tensor/model forward, and supported Triton CUDA kernel | +| PyTorch ROCm | AMD stable feed with exact device `gfx` package | Unsupported | Self-contained AMD runtime tuple, HIP runtime assertion, AMD device tensor; no native Windows Triton | +| PyTorch XPU | Official PyTorch XPU index | Unsupported | Self-contained XPU tuple, Intel device tensor, and `triton-xpu`/`torch.compile` | +| llama.cpp CUDA | Paired CUDA 13.3/12.4 app+cudart assets selected by driver/capability | Paired CUDA 13.4 Developer Preview app+cudart on qualified N1X | `llama-bench` proves CUDA device/GPU layers; pinned GGUF inference generates a constrained marker | +| llama.cpp ROCm | Official ROCm 10.0 asset on a supported AMD GPU | Unsupported | Benchmark must identify ROCm/AMD and GPU layers before inference is ready | +| llama.cpp SYCL / OpenVINO | Official SYCL or explicit OpenVINO 2026.3.1 asset | Unsupported | Intel Auto prefers SYCL for direct GPU evidence; OpenVINO is explicit and does not imply NPU support | +| llama.cpp OpenCL Adreno | Unsupported | Official Qualcomm Adreno OpenCL asset | Requires the Windows OpenCL loader; benchmark must identify OpenCL/Adreno and GPU layers | +| llama.cpp Vulkan / CPU fallback | Official rolling backend-specific assets | CPU asset | Vulkan is x64 Auto fallback only; CPU reports zero GPU layers | +| Ollama | Current WinGet desktop package | Current official native ARM64 archive installed as a managed per-user application | Official `qwen3:0.6b` (~522 MB) blob hash verification + structured inference | + +PyTorch's environment is +`$env:LOCALAPPDATA\DevConfig\pytorch\.venv`. Auto selection is supported +NVIDIA CUDA → supported AMD ROCm → supported Intel XPU → CPU. Explicit +selection can target a supported secondary adapter. `ROCm` installs AMD's +device-specific torch/torchvision/torchaudio tuple inside the venv; it does not +require the standalone ROCm Core SDK. `XPU` installs the official XPU tuple and +`triton-xpu`; it does not install full oneAPI. CUDA wheels carry their runtime; +the standalone CUDA toolkit is acquired only when the selected Triton/JIT path +needs native toolchain components. Use `-RequireTriton` when supported Triton +execution is mandatory or `-SkipTriton` to disable it. + +Vendor layer mapping: + +| Vendor | Native layer | PyTorch layer | +| --- | --- | --- | +| NVIDIA | `cuda`: nvcc/toolkit + compiled native kernel | CUDA wheel runtime; automatic compiler/toolkit acquisition only for supported Triton JIT | +| AMD | `rocm`: Core SDK/hipcc + compiled HIP kernel | AMD device-specific runtime tuple in PyTorch venv; no dependency on `rocm` for tensor inference | +| Intel | `intel-ai`: OpenVINO runtime; optional full oneAPI/SYCL | Official XPU wheel + `triton-xpu` in PyTorch venv; no full oneAPI dependency | + +The Windows ARM64 CUDA path is a pinned NVIDIA/PyTorch developer-preview stack: +CUDA 13.4 and `torch-2.15.0.dev20260904+cu134` for CPython 3.13. The direct +wheel URL includes NVIDIA's SHA-256 fragment, while ordinary dependencies +(including NumPy) resolve through the user's configured default Python index. +The flow never uses `--extra-index-url`, which would mix untrusted candidates. +The 1.85 GB wheel is downloaded once into +`%LOCALAPPDATA%\DevConfig\pytorch\wheel-cache`, hash-verified, and installed +from that local cache. Reruns compare the desired state with exact installed +torch, NumPy, and Triton versions; matching environments skip package work but +still execute the tensor and Triton kernel probes. + +Foundry, llama.cpp, and Ollama accept `-SkipModelSmoke`; CUDA accepts +`-SkipWorkloadSmoke`. These opt-outs avoid the default model/kernel acceptance +tests, but the resulting run is installation-only and does not claim full +workload readiness. Model licenses are Apache-2.0. Foundry reports its mutable +cache via `foundry cache location`; llama.cpp pins an immutable Qwen revision, +size, and SHA-256 under `%LOCALAPPDATA%\DevConfig\llama.cpp\models`; Ollama +verifies the pinned content-addressed model blob under +`%USERPROFILE%\.ollama\models` (or `OLLAMA_MODELS`). + +Every standalone AI workload accepts `-PlanOnly` and `-ReportPath`; the +`local-ai` scenario accepts `-PlanOnly` and `-ReportRoot`. Plan mode is safe on +unsupported machines: it writes blockers and planned acquisitions without +changing the system. Reports conform to +[`docs/ai-workload-report.schema.json`](./ai-workload-report.schema.json). +Collect just the portable host inventory with: + +```powershell +.\src\tools\collect-ai-hardware.ps1 +.\src\tools\get-ai-capabilities.ps1 -OutputPath "$env:TEMP\ai-capabilities.json" +``` + +Current real-hardware coverage: + +| Host | Validated workloads | +| --- | --- | +| Windows 11 ARM64 build 28120, NVIDIA RTX Spark N1X, driver 616.62 | CUDA 13.4 kernel; PyTorch cu134 tensor + neural forward; Triton vector-add; Foundry qwen3-0.6b inference; llama.cpp CUDA inference; optional Qwen2.5-Coder-1.5B `group_anagrams` generation at 101.7 t/s; managed native Ollama 0.34.4 install/rerun/uninstall-preserve/reinstall with verified release + qwen3:0.6b digests, persistent endpoint, native PE evidence, and `/api/ps` at 100% GPU | +| Supported Windows x64 NVIDIA GPU | Partner run pending: llama.cpp CUDA 13.3/12.4 benchmark and inference | +| Supported Windows x64 AMD GPU | Partner run pending: ROCm/HIP kernel, PyTorch ROCm tensor, and llama.cpp ROCm benchmark/inference | +| Supported Windows x64 Intel GPU/NPU | Partner run pending: OpenVINO selected-device inference, optional SYCL kernel, PyTorch XPU/torch.compile, and llama.cpp SYCL/OpenVINO benchmark/inference | +| Supported Windows ARM64 Qualcomm/Adreno GPU | Partner run pending: llama.cpp OpenCL/Adreno benchmark and inference | + +Known vendor gaps and boundaries: + +| Vendor | GPU workloads | NPU workloads | Windows CPU architecture | Maturity / validation | Boundary | +| --- | --- | --- | --- | --- | --- | +| NVIDIA | CUDA native kernel, PyTorch CUDA/Triton, llama.cpp CUDA | No vendor-specific NPU flow; Foundry/WinML is separate | x64 partner pending; ARM64 N1X live validated | x64 stable; ARM64 CUDA/PyTorch developer preview | Foundry N1X acceptance is truthful CPU fallback because CUDA EP registration was unavailable | +| AMD | ROCm/HIP, PyTorch ROCm, llama.cpp ROCm on published gfx matrix | None; ROCm does not cover Ryzen AI NPU | x64 only | Static/resolver complete, partner pending | No native Windows AMD Triton; no Ollama AMD or Foundry AMD EP claim without backend evidence | +| Intel | OpenVINO GPU, oneAPI/SYCL, PyTorch XPU/compile, llama.cpp SYCL/OpenVINO | OpenVINO NPU only after actual selected-device inference | x64 only | Static/resolver complete, partner pending | PyTorch XPU and llama SYCL are GPU paths, not NPU paths | +| Qualcomm | llama.cpp Adreno OpenCL; Foundry/WinML vendor-neutral provider path | WinML/Foundry only when provider evidence proves it | ARM64 | Static/resolver complete, partner pending | No native PyTorch backend; Ollama ARM64 is CPU/NVIDIA capability unless Adreno evidence exists | +| Other/fallback | Vulkan x64 and CPU x64/ARM64 | None | As listed | Compatibility only | Never label Vulkan/CPU as vendor-native; Mali/other Windows stacks are unimplemented without official artifacts | + +GPU drivers are preconditions. The flows report installed driver versions and +give remediation for unsupported versions, but do not replace GPU drivers. +Explicit backend selection can choose a supported secondary vendor on a mixed +system. Same-vendor targeting uses `-DeviceIndex` for CUDA/ROCm and explicit PyTorch backends, +`-Device` for llama.cpp runtime identifiers, `-OpenVinoDeviceId` for OpenVINO, +and `-SyclDeviceSelector` for oneAPI. Foundry and Ollama are source-managed: +they report the actual provider/allocation and do not imply a selector. + +Code path readiness before hardware testing: + +| Supported combination | Resolver | Acquisition | Workload probe | Report contract | Static/unit | Live hardware | +| --- | --- | --- | --- | --- | --- | --- | +| CUDA / NVIDIA x64 | Yes | Stable WinGet + MSVC | Native CUDA kernel | Driver/device/compiler/kernel | Pass | Partner pending | +| CUDA / NVIDIA ARM64 N1X | Yes | Pinned preview + MSVC ARM64 | Native CUDA kernel | Hash/signature/driver/device/kernel | Pass | **Passed** | +| ROCm/HIP / AMD x64 gfx matrix | Yes | AMD stable tuple | Native HIP kernel | Runtime/gfx/actual device/kernel | Pass | Partner pending | +| Intel OpenVINO CPU x64 | Yes | Official Python tuple | Generated-model CPU inference | Requested/actual device | Pass | Partner pending | +| Intel OpenVINO GPU x64 | Yes | Official Python tuple | Generated-model GPU inference | Requested/actual GPU | Pass | Partner pending | +| Intel OpenVINO NPU x64 | Yes | Official Python tuple | Generated-model NPU inference | Requested/actual NPU | Pass | Partner pending | +| Intel oneAPI/SYCL GPU x64 | Yes | Stable WinGet | Native SYCL kernel | Selector/device/compiler/kernel | Pass | Partner pending | +| Intel Full GPU x64 | Yes | OpenVINO + oneAPI | GPU inference + SYCL kernel | Both acceptance records | Pass | Partner pending | +| PyTorch CPU x64 | Yes | Official CPU wheel | Tensor + NumPy bridge | Tuple/device/operation | Pass | Partner pending | +| PyTorch CPU ARM64 | Yes | Official CPU wheel | Tensor + NumPy bridge | Native tuple/device/operation | Pass | Partner pending | +| PyTorch CUDA x64 | Yes | Official CUDA wheel | NVIDIA tensor | Runtime/device/tuple/operation | Pass | Partner pending | +| PyTorch CUDA ARM64 N1X | Yes | Pinned nightly wheel | NVIDIA tensor | Hash/runtime/device/operation | Pass | **Passed** | +| PyTorch ROCm AMD x64 | Yes | Exact AMD gfx tuple | HIP-compatible AMD tensor | HIP runtime/gfx/device/tuple | Pass | Partner pending | +| PyTorch XPU Intel x64 | Yes | Official XPU tuple | Intel XPU tensor | XPU device/tuple/operation | Pass | Partner pending | +| Triton CUDA x64 | Yes | Community Windows wheel + JIT toolchain | Vector-add kernel | Distribution/device/kernel | Pass | Partner pending | +| Triton CUDA ARM64 N1X | Yes | Community wheel + preview JIT stack | Vector-add kernel | Distribution/device/kernel | Pass | **Passed** | +| Triton XPU x64 | Yes | Official `triton-xpu` | Cold `torch.compile` | Intel device/compile evidence | Pass | Partner pending | +| llama CPU x64 | Yes | Official rolling CPU asset | Benchmark + GGUF inference | Tag/digest/zero offload/inference | Pass | Partner pending | +| llama CPU ARM64 | Yes | Official rolling CPU asset | Benchmark + GGUF inference | Tag/digest/zero offload/inference | Pass | Partner pending | +| llama CUDA x64 | Yes | Complete rolling app+cudart pair | Benchmark + GGUF inference | Tag/digests/backend/device/actual offload | Pass | Partner pending | +| llama CUDA ARM64 N1X | Yes | Complete rolling app+cudart pair | Benchmark + GGUF inference | Tag/digests/backend/device/actual offload | Pass | **Passed** | +| llama ROCm AMD x64 | Yes | Official rolling ROCm asset | Benchmark + GGUF inference | Tag/digest/backend/device/actual offload | Pass | Partner pending | +| llama SYCL Intel x64 | Yes | Official rolling SYCL asset | Benchmark + GGUF inference | Tag/digest/backend/device/actual offload | Pass | Partner pending | +| llama OpenVINO x64 | Yes | Official rolling OpenVINO asset | Benchmark + GGUF inference | Tag/digest/backend/device/actual offload | Pass | Partner pending | +| llama Vulkan x64 fallback | Yes | Official rolling Vulkan asset | Benchmark + GGUF inference | Explicit fallback/backend/device/offload | Pass | Partner pending | +| llama Adreno OpenCL ARM64 | Yes | Official rolling OpenCL asset | Benchmark + GGUF inference | Tag/digest/OpenCL/Adreno/actual offload | Pass | Partner pending | +| Foundry x64 | Yes | Architecture-native WinGet | Real catalog-model inference | Source-managed actual EP/device/fallback | Pass | Partner pending | +| Foundry ARM64 | Yes | Architecture-native WinGet | Real catalog-model inference | Source-managed actual EP/device/fallback | Pass | **Passed on N1X CPU EP** | +| Ollama x64 | Yes | Stable WinGet | Verified model + inference | Source-managed CPU/GPU allocation/backend | Pass | Partner pending | +| Ollama ARM64 | Yes | Official stable native archive + Dev Config-managed install lifecycle | Verified model + inference | Native PE, manifest/startup/PATH, allocation/backend | Pass | **Passed on N1X NVIDIA GPU** | + +The executable source of truth is +`Workloads/_common/ai-catalog.psd1::CapabilityMatrix`. The shared unit suite +invokes every supported/source-managed resolver fixture and verifies its +acquisition identities, probe path, report evidence contract, and partner +command. Every `upstream-unavailable` cell must throw its cataloged blocker. + +llama.cpp Auto resolution is supported NVIDIA CUDA → supported AMD ROCm → +supported Intel SYCL → Qualcomm Adreno OpenCL → x64 Vulkan → CPU. Intel SYCL +is preferred because the artifact directly exercises the Intel GPU and produces +device/offload evidence. OpenVINO remains an explicit x64 alternative for its +general inference backend; readiness requires the benchmark to identify the +OpenVINO backend and offloaded layers, and the flow does not claim NPU support. +All selected archives come from one rolling release, including both app and +cudart archives for CUDA. SHA-256 digests are required, verified archives are +cached under `%LOCALAPPDATA%\DevConfig\llama.cpp\asset-cache`, and the runtime +is replaced atomically. Acceptance reads the official `backends` JSON field and +requires actual `offloaded X/Y layers` diagnostics; the requested `-ngl` value +is never used as proof of acceleration. `gpu_info` supplies the physical device; +the structured `devices` value is the requested selector and must match an +explicit `-Device`. + +### Universal partner hardware workflow + +```powershell +git fetch https://github.com/Kixantrix/WindowsDeveloperConfig.git ` + mihippel-microsoft-windows-ai-setup-workloads +git switch --detach FETCH_HEAD +$ExpectedHead = ( + git ls-remote https://github.com/Kixantrix/WindowsDeveloperConfig.git ` + refs/heads/mihippel-microsoft-windows-ai-setup-workloads +).Split("`t")[0] +if ((git rev-parse HEAD).Trim() -ne $ExpectedHead) { + throw "Checkout does not match published branch head $ExpectedHead." +} +``` + +Then open elevated PowerShell in the repository root. Inventory the host and +use the plan/apply harness from the README's **Partner validation commands** +section. Each assigned flow must first write `-plan.json`, stop on any +blocker, then write `-final.json` and satisfy `result.ready=true`. + +Because PR testing runs the unsigned source under `src/`, first use the +repository's unsigned-development procedure: record the test user's current +policy, set `CurrentUser` to `Bypass` in both Windows PowerShell and PowerShell +7, and restore it after validation. Production bootstrap explicitly verifies +Microsoft signatures and requests process-scoped `RemoteSigned`. Signed +top-level release copies are also tested for compatibility with +organization-enforced `AllSigned` in both hosts by +`src/tests/ai-common/all-signed.ps1`; a first run may prompt to trust the +Microsoft publisher. + +Assigned flow coverage: + +| Partner device | Required flows | +| --- | --- | +| NVIDIA x64 | `cuda`; PyTorch `-Backend CUDA -RequireTriton`; llama.cpp `-Backend CUDA` | +| AMD x64 | PyTorch `-Backend ROCm`; native `rocm`; llama.cpp `-Backend ROCm` | +| Intel x64 GPU | PyTorch `-Backend XPU -RequireTriton`; `intel-ai -Device GPU -Profile OpenVINO`; `intel-ai -Device GPU -Profile Full`; llama.cpp `SYCL` and explicit `OpenVINO` | +| Intel x64 NPU | `intel-ai -Device NPU -Profile OpenVINO` only; XPU and SYCL are GPU paths | +| Qualcomm ARM64 | llama.cpp `-Backend OpenCL`; Foundry to record its source-managed actual EP/fallback | + +Return `hardware.json`, every plan and final report, every plan and final +console log, and reboot requested/performed status. Review: + +- host architecture, GPU/NPU model/vendor/driver; +- acquisition action/version/source/artifact/integrity/cache/install path; +- requested and selected backend/device/profile; +- acceptance marker and intended physical device; +- CUDA/ROCm runtime and kernel, PyTorch runtime/tensor/Triton, llama.cpp + `backends`/`gpu_info`/actual offloaded layers, Foundry EP/fallback, or Ollama + VRAM/allocation evidence as applicable; +- every warning and blocker. + +`INSTALL_OK` alone is insufficient. A successful handoff requires +`result.ready=true` and acceptance evidence identifying the intended +device/backend. Foundry is source-managed and may truthfully report CPU fallback. + +Always use the live published branch head above. The replacement PR targets +`main`; the former dependent PR #98 closed automatically when its base branch +was deleted after #93 merged. + +### Preview/rolling promotion metadata + +`Workloads/_common/ai-catalog.psd1` is the single source of truth for maturity, +source identity, version policy, integrity validation, cache/install paths, +normal-channel gaps, expected stable channels, migration triggers, and cleanup. +The table below summarizes the non-normal channels. “Unconfirmed” means the +vendor has not announced a final package identity. + +Stable-channel decisions: + +| Tuple | Current qualified source | Candidate / availability | Promotion gate | Status | +| --- | --- | --- | --- | --- | +| CUDA x64 | WinGet `Nvidia.CUDA` | Stable current channel | Native kernel | Current | +| CUDA ARM64 | Pinned 13.4.0 prerelease | Official signed 13.4.1 direct installer, SHA-256 `39af79e5…2442` | N1X kernel + PyTorch Triton JIT | Candidate tracked | +| PyTorch CUDA x64 | Official `cu126`/`cu130` indexes | Stable current channels | Tensor + Triton | Current | +| PyTorch CUDA ARM64 | Pinned N1X-qualified 2.15 nightly | NVIDIA stable out-of-tree 2.14 cu134 torch/vision/audio tuple | Trio imports + N1X tensor + idempotence + Triton | Candidate tracked | +| Triton CUDA | Exact `triton-windows` community build | No upstream-official Windows package | Vector-add on each CUDA tuple | Community-stable | +| Triton XPU | Official PyTorch XPU index | Stable current channel | Intel cold compile | Current | +| Foundry | WinGet 0.10.3 preview | Official non-prerelease v2.0.1; Python metadata still alpha | x64/ARM64 migration + provider/inference/cached rerun | Candidate tracked | +| llama.cpp | Official rolling backend assets | No backend-complete stable package channel | Per-backend policy + benchmark/inference | Rolling | +| Ollama x64 / ARM64 | Stable WinGet / official stable ZIP | ARM64 WinGet parity pending | Owned API/version/model/backend | Current / tracked | +| ROCm / Intel | AMD stable feed / official PyPI+WinGet | Stable current channels | HIP/OpenVINO/SYCL workload | Current | + +| Component | Vendor / architecture | Current identity | Resolver rule | Stable target | Evidence required to promote | +| --- | --- | --- | --- | --- | --- | +| CUDA ARM64 | NVIDIA / ARM64 | qualified 13.4.0 interim; signed 13.4.1 candidate tracked | Keep qualified version until 13.4.1 workload pass | NVIDIA stable direct / `Nvidia.CUDA` ARM64 if published | N1X kernel + Triton | +| PyTorch CUDA x64 | NVIDIA / x64 | stable 2.14 `cu126`/`cu130` wheel | Driver branch + compute capability select exact stable wheel | official PyTorch CUDA indexes | tensor + Triton vector-add | +| PyTorch CUDA ARM64 | NVIDIA / ARM64 | pinned qualified 2.15 cu134 nightly; stable 2.14 trio tracked | Exact wheel/hash and CPython 3.13/N1X checks | NVIDIA stable `nvtorch_oot` | trio imports + tensor/Triton acceptance | +| PyTorch ROCm | AMD / x64 | stable 2.13 ROCm 10 device-specific tuple | Exact supported GPU → `gfx` extra | AMD stable ROCm feed | AMD tensor + non-null HIP runtime | +| PyTorch XPU | Intel / x64 | stable 2.14 XPU tuple | Validated Intel GPU family | official PyTorch XPU index | XPU tensor + cold `torch.compile` | +| Triton Windows CUDA | NVIDIA / x64, ARM64 | `triton-windows==3.8.0.post28` | exact PyTorch-compatible community build | official Windows Triton package, unconfirmed | official package + vector-add kernel | +| Triton XPU | Intel / x64 | `triton-xpu==3.8.0` | exact PyTorch XPU-compatible tuple | official PyTorch XPU index | cold `torch.compile` on XPU | +| llama.cpp CUDA x64 | NVIDIA / x64 | newest complete CUDA 13.3/12.4 app+cudart set | Driver/capability-qualified exact pair from one release | backend-specific WinGet variant, unconfirmed | NVIDIA device + GPU layers + inference | +| llama.cpp CUDA ARM64 | NVIDIA / ARM64 | newest complete CUDA 13.4 app+cudart set | Qualified N1X Developer Preview pair | backend-specific WinGet variant, unconfirmed | N1X device + GPU layers + inference | +| llama.cpp ROCm x64 | AMD / x64 | newest ROCm 10.0 asset | Supported GPU/gfx target | backend-specific WinGet variant, unconfirmed | ROCm/AMD device + GPU layers + inference | +| llama.cpp SYCL / OpenVINO x64 | Intel/general / x64 | newest SYCL or OpenVINO 2026.3.1 asset | SYCL Auto for supported Intel GPU; OpenVINO explicit | backend-specific WinGet variants, unconfirmed | selected backend/device + GPU layers + inference | +| llama.cpp OpenCL Adreno ARM64 | Qualcomm / ARM64 | pinned policy-approved b10917 Adreno OpenCL asset | Detected Qualcomm/Adreno adapter | backend-specific WinGet variant, unconfirmed | New candidate passes policy + OpenCL/Adreno layers/inference | +| llama.cpp Vulkan / CPU | Cross-vendor / x64, CPU / x64+ARM64 | newest backend-specific rolling asset | Vulkan only with loader/device; otherwise CPU | backend-specific WinGet variants, unconfirmed | exact backend and offload/fallback evidence | +| Foundry Local | cross-vendor / x64, ARM64 | qualified WinGet 0.10.3 preview; v2.0.1 candidate | current qualified package until v2 migration passes | official v2 / current stable WinGet | provider/inference/cached rerun on x64+ARM64 | +| Ollama ARM64 | CPU/NVIDIA / ARM64 | latest stable official native ARM64 archive with managed Dev Config installation | non-prerelease release asset with GitHub digest | official ARM64 installer/managed package, unconfirmed | package passes native PE + lifecycle + API/GPU evidence | + +AMD ROCm 10.0 and Intel OpenVINO/oneAPI use stable vendor channels. AMD's +normal channel is its stable ROCm feed (there is no confirmed WinGet ID); +Intel's normal channels are official PyPI packages and `Intel.OneAPI.Toolkit`. +Neither vendor publishes a native Windows ARM64 stack today. + +| Component | Vendor / CPU arch | Maturity | Current source + identity | Resolver / version policy | Integrity | Cache → installed path | Why normal channel is insufficient | Expected final channel | Promotion evidence | Cleanup / upgrade | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| CUDA x64 | NVIDIA / x64 | Stable | WinGet `Nvidia.CUDA` | Latest applicable stable | WinGet manifest hash + signature | WinGet → `%ProgramFiles%\NVIDIA GPU Computing Toolkit\CUDA\v*` | N/A | `Nvidia.CUDA` | New package still compiles/runs kernel | WinGet upgrade / NVIDIA uninstall | +| CUDA ARM64 | NVIDIA / ARM64 | Qualified interim developer preview | NVIDIA `cuda_13.4.0_windows_arm64.exe`; stable 13.4.1 candidate tracked | Keep exact 13.4.0 until candidate qualification | Pinned SHA-256 + NVIDIA signature | `%ProgramData%\WindowsDeveloperConfig\cache` → CUDA v13.4 | No ARM64 WinGet payload; 13.4.1 not yet N1X-qualified | NVIDIA stable direct / WinGet if published | 13.4.1 N1X kernel + Triton | Side-by-side qualify, then vendor uninstall old | +| PyTorch CUDA ARM64 | NVIDIA / ARM64 | Qualified interim nightly | NVIDIA `torch-2.15.0.dev20260904+cu134-cp313-win_arm64.whl`; stable 2.14 trio tracked | Keep exact qualified wheel until tuple qualification | Pinned SHA-256 | local wheel cache → contained venv | Stable out-of-tree trio awaits N1X qualification | NVIDIA `nvtorch_oot` | Trio imports + stable tensor + Triton kernel | Replace venv; prune old cache | +| PyTorch CUDA x64 | NVIDIA / x64 | Stable | official `cu126`/`cu130` index, torch 2.14 | Driver/capability-selected exact wheel | official index hashes/RECORD | pip cache → contained venv | N/A | official PyTorch CUDA index | tensor + Triton kernel | Replace venv | +| PyTorch ROCm | AMD / x64 | Stable | AMD feed: torch 2.13 ROCm 10 + device `gfx` extra, torchvision, torchaudio | Exact GPU-specific runtime tuple | AMD HTTPS + wheel RECORD | pip cache → contained venv | Default PyPI lacks AMD Windows build | AMD stable ROCm feed | HIP non-null + AMD tensor | Replace venv | +| PyTorch XPU | Intel / x64 | Stable | official XPU index: torch 2.14, torchvision 0.29 | Exact XPU tuple | official index hashes/RECORD | pip cache → contained venv | Default PyPI lacks Intel XPU build | official XPU index | Intel tensor + `torch.compile` | Replace venv | +| Triton Windows CUDA | NVIDIA / x64, ARM64 | Community | PyPI `triton-windows==3.8.0.post28` | Exact PyTorch-compatible tuple | TLS + wheel RECORD | pip cache → PyTorch venv | No general upstream Windows package | Official Windows Triton package, unconfirmed | Official package + vector-add | Replace venv | +| Triton XPU / `torch.compile` | Intel / x64 | Stable integrated | XPU index `triton-xpu==3.8.0` | Exact PyTorch XPU tuple | official index hashes/RECORD | pip cache → PyTorch venv | Standalone project documents Linux; Windows path is PyTorch integration | official XPU index | cold compile on actual Intel GPU | Replace venv | +| llama.cpp CUDA x64 | NVIDIA / x64 | Rolling | newest complete CUDA 13.3 or 12.4 app+cudart pair | Driver/capability-qualified exact pair from one release | GitHub asset SHA-256 digests | asset cache → runtime directory | WinGet maps only to Vulkan | backend-specific `ggml.llamacpp`, unconfirmed | NVIDIA device/GPU layers + inference | Reuse verified cache; atomic runtime replacement | +| llama.cpp CUDA ARM64 | NVIDIA / ARM64 | Rolling developer preview | newest complete CUDA 13.4 app+cudart pair | RTX Spark + driver 616+ exact pair | GitHub asset SHA-256 digests | asset cache → runtime directory | No ARM64 CUDA WinGet variant | backend-specific `ggml.llamacpp`, unconfirmed | N1X device/GPU layers + inference | Reuse verified cache; atomic runtime replacement | +| llama.cpp ROCm x64 | AMD / x64 | Rolling | newest ROCm 10.0 asset | Exact supported AMD GPU/gfx resolver | GitHub asset SHA-256 digest | asset cache → runtime directory | WinGet maps only to Vulkan | backend-specific `ggml.llamacpp`, unconfirmed | ROCm/AMD device/GPU layers + inference | Reuse verified cache; atomic runtime replacement | +| llama.cpp SYCL / OpenVINO x64 | Intel/general / x64 | Rolling | newest SYCL or OpenVINO 2026.3.1 asset | Supported Intel GPU selects SYCL; OpenVINO explicit | GitHub asset SHA-256 digest | asset cache → runtime directory | WinGet maps only to Vulkan | backend-specific `ggml.llamacpp`, unconfirmed | selected backend/device/GPU layers + inference | Reuse verified cache; atomic runtime replacement | +| llama.cpp OpenCL Adreno ARM64 | Qualcomm / ARM64 | Qualified rolling pin | policy-approved b10917 Adreno OpenCL asset | Exact detected Qualcomm/Adreno ARM64 path | GitHub asset SHA-256 digest | asset cache → runtime directory | No ARM64 Adreno WinGet variant; b10919 blocked by managed Defender ASR | backend-specific `ggml.llamacpp`, unconfirmed | Candidate passes policy + OpenCL/Adreno layers/inference | Reuse verified cache; atomic runtime replacement | +| llama.cpp Vulkan x64 | Cross-vendor / x64 | Rolling fallback | newest official Vulkan asset | Auto only after vendor-native paths; requires loader/device | GitHub asset SHA-256 digest | asset cache → runtime directory | WinGet cannot express backend alternatives | reliable Vulkan package variant | Vulkan backend/GPU layers + inference | Reuse verified cache; atomic runtime replacement | +| llama.cpp CPU x64/ARM64 | CPU / x64, ARM64 | Rolling fallback | newest official CPU asset | Explicit CPU or no qualified accelerator | GitHub asset SHA-256 digest | asset cache → runtime directory | WinGet lacks backend-selectable CPU/ARM64 | backend-specific `ggml.llamacpp`, unconfirmed | CPU backend, zero GPU layers, inference | Reuse verified cache; atomic runtime replacement | +| Foundry Local | Cross-vendor / x64, ARM64 | Qualified preview | WinGet `Microsoft.FoundryLocal` 0.10.3; official v2.0.1 candidate tracked | Keep qualified CLI until v2 SDK migration passes | WinGet MSIX hash/signature; v2 hashes recorded | Foundry cache → per-user MSIX | v2 changes API/package contract and is not target-qualified | official v2 / current stable WinGet | x64+ARM64 provider/inference/cached rerun | Preserve cache; replace runtime after qualification | +| Ollama ARM64 | CPU, NVIDIA / ARM64 | Official stable native archive, Dev Config-managed | latest official `ollama-windows-arm64.zip` | Latest non-prerelease release | GitHub asset SHA-256 | verified cache → `%LOCALAPPDATA%\Programs\Ollama`; `.devconfig-install.json`; HKCU startup | No official ARM64 setup EXE; x64 setup and portable WinGet are not used | official ARM64 installer or managed WinGet payload | native PE + managed API/model/backend/upgrade/uninstall | Stop managed processes, atomic swap, preserve models by default | +| AMD ROCm | AMD / x64 | Stable | AMD stable feed `rocm[...] == 10.0.0` | Exact supported GPU `gfx` tuple | Official HTTPS allowlist + wheel RECORD; feed has no SHA-256 fragments | pip cache → contained venv | No confirmed WinGet ID/default PyPI package | AMD stable feed; WinGet unconfirmed | newer Windows matrix + HIP kernel | Replace contained environment | +| Intel OpenVINO / oneAPI | Intel / x64 | Stable | PyPI OpenVINO 2026.3.1 tuple; WinGet `Intel.OneAPI.Toolkit` | Exact matched tuple / qualified stable package | wheel RECORD; WinGet hash/signature | pip/WinGet cache → contained venv/oneAPI root | N/A | same official channels | selected-device inference/SYCL kernel | Replace venv; WinGet upgrade | + ### 2. Validate the DSC config without applying it (Windows) `winget configure` has a `test` verb that evaluates each resource's @@ -348,3 +763,17 @@ Adding a language is a **data change**, not a workflow change: command, and expected-output path for each supported OS. That's it — `discover` in CI picks up the new flow automatically. + +## How to add a hardware-aware AI workload + +1. Add a PowerShell-native `Workloads//install.ps1`; do not add a + `configuration.winget`. +2. Reuse `_common/direct-setup.ps1` for the Windows Dev Config WinGet/retry/process/PATH + contracts and `_common/ai-report.ps1` for structured output. +3. Put stable, preview, nightly, and rolling acquisition metadata in + `_common/ai-catalog.psd1`. Include the normal-channel limitation and + evidence-based promotion trigger. +4. Add `-PlanOnly`, an actionable unsupported result, and a real hardware + workload acceptance. CLI/version checks are diagnostics, not acceptance. +5. Add unit tests for architecture/vendor selection, exact commands, channel + promotion, idempotence, report fields, and unsupported combinations. diff --git a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Models/ScriptEntry.cs b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Models/ScriptEntry.cs index ec559bea..0901f89d 100644 --- a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Models/ScriptEntry.cs +++ b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Models/ScriptEntry.cs @@ -61,8 +61,7 @@ public class ScriptEntry /// /// Path to the WinGet DSC configuration the extension applies via - /// winget configure. Falls back to a sibling - /// configuration.winget next to install when not set. + /// winget configure. /// public string? WindowsConfigurationPath { @@ -76,18 +75,12 @@ public string? WindowsConfigurationPath { return Windows.Configuration; } - if (!string.IsNullOrEmpty(Windows.Install)) - { - var dir = System.IO.Path.GetDirectoryName(Windows.Install)?.Replace('\\', '/'); - if (!string.IsNullOrEmpty(dir)) - { - return $"{dir}/configuration.winget"; - } - } return null; } } + public string? WindowsInstallPath => Windows?.Install; + /// WSL/Linux install script path, e.g. scripts/linux/php/install.sh. public string? LinuxInstallPath => Linux?.Install; } diff --git a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/QuickWingetSetupPage.cs b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/QuickWingetSetupPage.cs index deb6b7ce..dc8123bf 100644 --- a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/QuickWingetSetupPage.cs +++ b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/QuickWingetSetupPage.cs @@ -66,7 +66,8 @@ public override IListItem[] GetItems() // in a wt.exe tab). Hide flows that don't declare Windows // support so users don't pick something the extension // can't actually launch. - .Where(s => s.WindowsConfigurationPath is not null) + .Where(s => s.WindowsConfigurationPath is not null || + (_fetchService.CanRunPowerShellNativeFlows && s.WindowsInstallPath is not null)) .OrderBy(s => CategoryRank(s.Category)) .ThenBy(s => s.Category) .ThenBy(s => s.Name) diff --git a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/ScriptDetailPage.cs b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/ScriptDetailPage.cs index ed5531c9..1226719f 100644 --- a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/ScriptDetailPage.cs +++ b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/ScriptDetailPage.cs @@ -65,6 +65,18 @@ public override IListItem[] GetItems() MoreCommands = BuildContextCommands(localPath), }); } + else if (_script.WindowsInstallPath is { } installPath) + { + var localPath = _fetchService.GetScriptPathAsync(installPath).GetAwaiter().GetResult(); + items.Add(new ListItem(new RunPowerShellSetupCommand(installPath, _fetchService, _script)) + { + Title = "🪟 Run Windows Setup", + Subtitle = $"PowerShell {installPath}", + Tags = [new Tag("Windows")], + Details = BuildScriptDetails(localPath), + MoreCommands = BuildContextCommands(localPath), + }); + } if (_script.Includes != null && _script.Includes.Length > 0) { @@ -120,6 +132,43 @@ private static IContextItem[] BuildContextCommands(string? localPath) } } +internal sealed partial class RunPowerShellSetupCommand : InvokableCommand, IConfirmationArgs +{ + private readonly string _scriptPath; + private readonly ScriptFetchService _fetchService; + private readonly ScriptEntry _script; + + public RunPowerShellSetupCommand(string scriptPath, ScriptFetchService fetchService, ScriptEntry script) + { + _scriptPath = scriptPath; + _fetchService = fetchService; + _script = script; + } + + public string Title => $"Run {_script.Name} setup?"; + public string Description => + $"This will run {_scriptPath} in a new Windows Terminal tab. " + + "The flow checks current state, applies only required changes, and verifies a real workload."; + public Microsoft.CommandPalette.Extensions.ICommand? PrimaryCommand => this; + public bool IsPrimaryCommandCritical => false; + + public override ICommandResult Invoke() + { + if (_script.RequiresWsl && WslDetectionService.RefreshStatus() != WslStatus.Available) + { + ScriptRunnerService.RunWslInstall(); + return CommandResult.Dismiss(); + } + + var localPath = _fetchService.GetScriptPathAsync(_scriptPath).GetAwaiter().GetResult(); + if (localPath != null) + { + ScriptRunnerService.RunPowerShellScript(localPath); + } + return CommandResult.Dismiss(); + } +} + internal sealed partial class RunWinGetCommand : InvokableCommand, IConfirmationArgs { private const string FixItRelativePath = "scripts/windows/_common/enable-winget-configure.ps1"; diff --git a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/QuickWingetSetupCommandsProvider.cs b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/QuickWingetSetupCommandsProvider.cs index 57420955..c6806f36 100644 --- a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/QuickWingetSetupCommandsProvider.cs +++ b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/QuickWingetSetupCommandsProvider.cs @@ -70,11 +70,14 @@ public override void UpdateQuery(string query) return; } - var match = _allScripts.FirstOrDefault(s => - s.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || - s.Description.Contains(query, StringComparison.OrdinalIgnoreCase) || - s.Id.Contains(query, StringComparison.OrdinalIgnoreCase) || - s.Tags.Any(t => t.Contains(query, StringComparison.OrdinalIgnoreCase))); + var match = _allScripts + .Where(s => s.WindowsConfigurationPath is not null || + (_fetchService.CanRunPowerShellNativeFlows && s.WindowsInstallPath is not null)) + .FirstOrDefault(s => + s.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || + s.Description.Contains(query, StringComparison.OrdinalIgnoreCase) || + s.Id.Contains(query, StringComparison.OrdinalIgnoreCase) || + s.Tags.Any(t => t.Contains(query, StringComparison.OrdinalIgnoreCase))); if (match != null) { diff --git a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptFetchService.cs b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptFetchService.cs index 35a2b203..74cfd9cb 100644 --- a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptFetchService.cs +++ b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptFetchService.cs @@ -23,6 +23,8 @@ public ScriptFetchService() _config = LoadConfig(); } + public bool CanRunPowerShellNativeFlows => _config.Source == "local"; + private static ExtensionConfig LoadConfig() { try @@ -121,6 +123,7 @@ private static string ConvertYamlToJson(string yaml) return null; return File.Exists(fullPath) ? fullPath : null; } + else { try diff --git a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptRunnerService.cs b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptRunnerService.cs index 678444cd..dcffed35 100644 --- a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptRunnerService.cs +++ b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptRunnerService.cs @@ -7,6 +7,29 @@ namespace QuickWingetSetup.Services; public static class ScriptRunnerService { + public static void RunPowerShellScript(string scriptPath) + { + var sanitizedPath = scriptPath.Replace("\"", ""); + var scriptDirectory = Path.GetDirectoryName(sanitizedPath) ?? string.Empty; + var command = $"Push-Location '{EscapeSingleQuotes(scriptDirectory)}'; try {{ & '{EscapeSingleQuotes(sanitizedPath)}' }} finally {{ Pop-Location }}"; + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(command)); + var shell = ResolveShell(); + var psi = new ProcessStartInfo { FileName = "wt.exe", UseShellExecute = true, Verb = "runas" }; + psi.ArgumentList.Add("new-tab"); + psi.ArgumentList.Add("--"); + psi.ArgumentList.Add(shell); + psi.ArgumentList.Add("-NoExit"); + psi.ArgumentList.Add("-NoProfile"); + psi.ArgumentList.Add("-ExecutionPolicy"); + psi.ArgumentList.Add("Bypass"); + psi.ArgumentList.Add("-EncodedCommand"); + psi.ArgumentList.Add(encoded); + if (Process.Start(psi) == null) + { + throw new InvalidOperationException("Failed to launch Windows Terminal. Ensure wt.exe is available."); + } + } + public static void RunWinGetConfig(string scriptPath) { RunWinGetConfig(scriptPath, postConfigureScriptPath: null, postConfigureArgs: null); diff --git a/src/future/cmdpal/README.md b/src/future/cmdpal/README.md index 7b553031..52837776 100644 --- a/src/future/cmdpal/README.md +++ b/src/future/cmdpal/README.md @@ -3,15 +3,15 @@ A [PowerToys Command Palette](https://learn.microsoft.com/windows/powertoys/command-palette/overview) extension that surfaces the developer flows defined in this repo's [`manifest.yml`](../../manifest.yml). Pick a flow, hit Enter, and the extension -launches `winget configure` (Windows) or `wsl bash` (Linux) in a new Windows -Terminal tab — no need to remember which `.winget` file goes with which -toolchain. +launches `winget configure` or a PowerShell-native setup entry point (Windows), +or `wsl bash` (Linux), in a new Windows Terminal tab. -## Prerequisite: `winget configure` must be enabled +## Prerequisite for DSC-backed flows: `winget configure` -This extension launches flows exclusively through `winget configure`. If -that subcommand is not wired up on the host, no Windows flow surfaced by -CmdPal can succeed. See the developer guide's +DSC-backed flows launch through `winget configure`. PowerShell-native flows, +including the AI workloads, launch their `windows.install` script directly. +If the configuration subcommand is not wired up on the host, DSC-backed flows +cannot succeed. See the developer guide's [`Prerequisites (Windows)`](../../docs/development.md#prerequisites-windows) section for the three conditions that must hold (current App Installer, the `configuration` feature enabled, and no blocking ADMX policy) and the @@ -23,7 +23,8 @@ enforces this at runtime with an actionable error message. The extension reads the same `manifest.yml` that drives CI. Each flow's UX metadata (`name`, `description`, `category`, `tags`, `icon`, `onboardingUrl`, -`dependsOn`) plus its `windows.configuration` / `linux.install` paths come +`dependsOn`) plus its `windows.configuration`, `windows.install`, and +`linux.install` paths come straight from that file — adding a flow there makes it appear in CmdPal automatically. @@ -92,18 +93,16 @@ The project targets `net9.0-windows10.0.26100.0` and is AOT/trim friendly. | Manifest field | What the extension does | | ----------------------------------- | ----------------------------------------------------------- | | `windows.configuration` | `winget configure ` in a new Windows Terminal tab, after a confirmation dialog | +| `windows.install` without a configuration | Runs the PowerShell setup entry point directly in a new Windows Terminal tab | | `onboardingUrl` | Opens in the default browser via `📖 Official Docs` action | | `icon`, `name`, `description`, ... | Rendered on the list/detail pages | -If `windows.configuration` is omitted in `manifest.yml`, the extension falls -back to `/configuration.winget` — i.e. the -WindowsDevSetupScripts convention. - -> **Known gap.** Two flows are PowerShell-native and have no configuration -> file at all: Calm OS (`calm-os`) and Comfort Shell (`comfort-shell`). The -> fallback above resolves them to a path that doesn't exist, so the extension -> can't launch them today. Before this extension ships, teach it to run -> `windows.install` directly when `windows.configuration` is absent. +If `windows.configuration` is omitted, the extension uses `windows.install`. +This supports PowerShell-native flows such as Windows Dev Config, Comfort +Shell, and the hardware-aware AI workloads when `source` is `local`. GitHub +source mode currently hides multi-file PowerShell-native flows because fetching +only the entry script would omit their relative dependencies; a packaged +repository snapshot is required before enabling them remotely. ## Confirmation dialog diff --git a/src/manifest.yml b/src/manifest.yml index 3ee79e98..e8641f13 100644 --- a/src/manifest.yml +++ b/src/manifest.yml @@ -8,8 +8,9 @@ # build + run a canonical "hello world" to prove the install worked # (consumed by CI and the build-run-diff harness). # -# Adding a new flow = one entry here + one configuration.winget per OS -# + one install.ps1 shim per OS + one hello-world file under tests//. +# Adding a new flow = one entry here + an install.ps1 entry point per OS +# + configuration.winget when declarative DSC is appropriate + a smoke probe +# under tests//. # No workflow edits required. # # Schema (per flow): @@ -23,6 +24,8 @@ # (typescript, python, go, rust, ...) # - "desktop" : a desktop framework on top of a # language (winforms, winui, ...) +# - "ai" : local AI runtimes and +# hardware-specific accelerators # - "user-experience" : OS-feel / shell flows that # shape the desktop rather than # install dev toolchains @@ -60,11 +63,9 @@ # CI runs this from the `src/` directory, while CmdPal # assumes the extension's root is the repo root. # configuration: (optional) path to winget DSC configuration.winget the -# extension applies via `winget configure`. Defaults to -# "/configuration.winget" when omitted. -# PowerShell-native flows (calm-os, comfort-shell) have no -# DSC document and omit this key; the extension needs a -# script-launch path before it can offer them. +# extension applies via `winget configure`. When omitted, +# the Command Palette extension launches `install` directly. +# PowerShell-native flows (including AI workloads) omit it. # build: shell command to build the hello world (run from repo # root). "" to skip. # run: shell command whose stdout is compared to "expected" @@ -242,6 +243,134 @@ flows: expected: src/tests/python/expected.txt version: "python --version; uv --version" + - id: cuda + name: NVIDIA CUDA + description: CUDA Toolkit plus MSVC with a compiled GPU-kernel readiness test + category: ai + tags: [ai, cuda, nvidia, gpu, machine-learning] + icon: 🟩 + onboardingUrl: https://docs.nvidia.com/cuda/cuda-installation-guide-microsoft-windows/ + manual_test: true + os: [windows] + windows: + install: Workloads/cuda/install.ps1 + build: "" + run: pwsh -NoProfile -File src/tests/cuda/probe.ps1 + expected: src/tests/cuda/expected.txt + version: "nvcc --version; nvidia-smi" + + - id: foundry + name: Foundry Local + description: Local model runtime plus Qwen3-0.6B acquisition and inference + category: ai + tags: [ai, foundry, winml, local-models, inference] + icon: 🧠 + onboardingUrl: https://learn.microsoft.com/windows/ai/foundry-local/get-started + manual_test: true + os: [windows] + windows: + install: Workloads/foundry/install.ps1 + build: "" + run: pwsh -NoProfile -File src/tests/foundry/probe.ps1 + expected: src/tests/foundry/expected.txt + version: "foundry --version; foundry server status" + + - id: pytorch + name: PyTorch + description: Self-contained PyTorch for CPU, NVIDIA CUDA, AMD ROCm, or Intel XPU + category: ai + tags: [ai, pytorch, python, nvidia, cuda, amd, rocm, hip, intel, xpu, triton, machine-learning] + icon: 🔥 + onboardingUrl: https://pytorch.org/get-started/locally/ + manual_test: true + os: [windows] + windows: + install: Workloads/pytorch/install.ps1 + build: "" + run: pwsh -NoProfile -File src/tests/pytorch/probe.ps1 + expected: src/tests/pytorch/expected.txt + version: "& \"$env:LOCALAPPDATA\\DevConfig\\pytorch\\.venv\\Scripts\\python.exe\" -c \"import torch; print(torch.__version__)\"" + + - id: llama.cpp + name: llama.cpp + description: Hardware-selected llama.cpp CUDA, ROCm, SYCL, OpenVINO, Vulkan, OpenCL, or CPU runtime + category: ai + tags: [ai, llama, gguf, inference, local-models, nvidia, cuda, amd, rocm, intel, sycl, openvino, vulkan, qualcomm, adreno, opencl, cpu] + icon: 🦙 + onboardingUrl: https://github.com/ggml-org/llama.cpp/blob/master/docs/install.md + manual_test: true + os: [windows] + windows: + install: Workloads/llama.cpp/install.ps1 + build: "" + run: pwsh -NoProfile -File src/tests/llama.cpp/probe.ps1 + expected: src/tests/llama.cpp/expected.txt + version: "llama-cli --version" + + - id: ollama + name: Ollama + description: Installed x64 or managed native ARM64 Ollama runtime plus qwen3:0.6b inference + category: ai + tags: [ai, ollama, local-models, inference, api] + icon: 🦙 + onboardingUrl: https://docs.ollama.com/windows + manual_test: true + os: [windows] + windows: + install: Workloads/ollama/install.ps1 + build: "" + run: pwsh -NoProfile -File src/tests/ollama/probe.ps1 + expected: src/tests/ollama/expected.txt + version: "ollama --version" + + - id: rocm + name: AMD ROCm / HIP + description: AMD ROCm Core SDK with a compiled HIP GPU-kernel acceptance test + category: ai + tags: [ai, amd, rocm, hip, gpu, machine-learning] + icon: 🔴 + onboardingUrl: https://rocm.docs.amd.com/projects/install-on-windows/en/latest/ + manual_test: true + os: [windows] + windows: + install: Workloads/rocm/install.ps1 + build: "" + run: pwsh -NoProfile -File src/tests/rocm/probe.ps1 + expected: src/tests/rocm/expected.txt + version: "& \"$env:LOCALAPPDATA\\DevConfig\\rocm\\.venv\\Scripts\\hipcc.exe\" --version" + + - id: intel-ai + name: Intel AI + description: OpenVINO device inference with optional oneAPI/SYCL GPU development + category: ai + tags: [ai, intel, openvino, oneapi, sycl, gpu, npu] + icon: 🔵 + onboardingUrl: https://docs.openvino.ai/ + manual_test: true + os: [windows] + windows: + install: Workloads/intel-ai/install.ps1 + build: "" + run: pwsh -NoProfile -File src/tests/intel-ai/probe.ps1 + expected: src/tests/intel-ai/expected.txt + version: "& \"$env:LOCALAPPDATA\\DevConfig\\intel-ai\\openvino\\.venv\\Scripts\\python.exe\" -c \"import openvino; print(openvino.__version__)\"" + + - id: local-ai + name: Local AI development + description: Scenario entry point for hardware detection, contained PyTorch/Triton, and an optional local model runtime + category: scenarios + tags: [ai, scenario, pytorch, triton, local-models, cuda, rocm, xpu] + icon: 🤖 + onboardingUrl: https://learn.microsoft.com/windows/ai/ + manual_test: true + os: [windows] + windows: + install: Workloads/local-ai/install.ps1 + build: "" + run: pwsh -NoProfile -File src/tests/pytorch/probe.ps1 + expected: src/tests/local-ai/expected.txt + version: "& \"$env:LOCALAPPDATA\\DevConfig\\pytorch\\.venv\\Scripts\\python.exe\" -c \"import torch; print(torch.__version__)\"" + - id: sql name: Lightweight SQL Developer description: SQL Server Developer + sqlcmd + VS Code SQL database projects extension diff --git a/src/tests/_harness/assertions.ps1 b/src/tests/_harness/assertions.ps1 new file mode 100644 index 00000000..0e44ea3b --- /dev/null +++ b/src/tests/_harness/assertions.ps1 @@ -0,0 +1,45 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$script:AssertionCount = 0 + +function Assert-True { + param( + [Parameter(Mandatory)] [bool] $Condition, + [Parameter(Mandatory)] [string] $Message + ) + if (-not $Condition) { + throw "Assertion failed: $Message" + } + $script:AssertionCount++ +} + +function Assert-Equal { + param( + [AllowNull()] $Actual, + [AllowNull()] $Expected, + [Parameter(Mandatory)] [string] $Message + ) + if ($Actual -ne $Expected) { + throw "Assertion failed: $Message. Expected '$Expected'; got '$Actual'." + } + $script:AssertionCount++ +} + +function Assert-ThrowsLike { + param( + [Parameter(Mandatory)] [scriptblock] $ScriptBlock, + [Parameter(Mandatory)] [string] $Pattern, + [Parameter(Mandatory)] [string] $Message + ) + try { + & $ScriptBlock + } catch { + if ($_.Exception.Message -notlike $Pattern) { + throw "Assertion failed: $Message. Error '$($_.Exception.Message)' did not match '$Pattern'." + } + $script:AssertionCount++ + return + } + throw "Assertion failed: $Message. Expected an exception." +} diff --git a/src/tests/ai-common/all-signed.ps1 b/src/tests/ai-common/all-signed.ps1 new file mode 100644 index 00000000..8a43e4e7 --- /dev/null +++ b/src/tests/ai-common/all-signed.ps1 @@ -0,0 +1,188 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if ($env:OS -ne 'Windows_NT') { + Write-Host 'ALL_SIGNED_SKIPPED: Windows only' + return +} + +function Get-TestPowerShellHosts { + $hosts = @((Get-Command 'powershell.exe' -ErrorAction Stop).Source) + $pwsh = Get-Command 'pwsh.exe' -ErrorAction SilentlyContinue + if ($pwsh) { + $pwshPath = $pwsh.Source + if ($pwshPath -like "$env:LOCALAPPDATA\Microsoft\WindowsApps\*") { + $package = Get-AppxPackage -Name Microsoft.PowerShell -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | + Select-Object -First 1 + if ($package) { + $packagedPwsh = Join-Path $package.InstallLocation 'pwsh.exe' + if (Test-Path -LiteralPath $packagedPwsh) { + $pwshPath = $packagedPwsh + } + } + } + if ((Test-Path -LiteralPath $pwshPath) -and $pwshPath -notin $hosts) { + $hosts += $pwshPath + } + } + return $hosts +} + +function Invoke-AllSignedProcess { + param( + [Parameter(Mandatory)] [string] $Shell, + [Parameter(Mandatory)] [string] $Script, + [string[]] $Arguments = @(), + [int] $PublisherConsentCount = 0 + ) + + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $Shell + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardInput = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $nativeArguments = @('-NoProfile', '-ExecutionPolicy', 'AllSigned', '-File', $Script) + $Arguments + $startInfo.Arguments = ($nativeArguments | ForEach-Object { + '"' + ([string]$_).Replace('"', '\"') + '"' + }) -join ' ' + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { + throw "Could not start '$Shell'." + } + for ($index = 0; $index -lt $PublisherConsentCount; $index++) { + $process.StandardInput.WriteLine('R') + } + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(120000)) { + $process.Kill() + throw "$Shell AllSigned validation timed out for '$Script'." + } + return [pscustomobject]@{ + ExitCode = $process.ExitCode + Output = ($stdout.GetAwaiter().GetResult() + $stderr.GetAwaiter().GetResult()).Trim() + } + } finally { + $process.Dispose() + } +} + +function Copy-ReleaseScriptWithCrLf { + param( + [Parameter(Mandatory)] [string] $Source, + [Parameter(Mandatory)] [string] $Destination + ) + + $bytes = [IO.File]::ReadAllBytes($Source) + $stream = [IO.MemoryStream]::new() + try { + for ($index = 0; $index -lt $bytes.Length; $index++) { + if ($bytes[$index] -eq 10 -and ($index -eq 0 -or $bytes[$index - 1] -ne 13)) { + $stream.WriteByte(13) + } + $stream.WriteByte($bytes[$index]) + } + [IO.File]::WriteAllBytes($Destination, $stream.ToArray()) + } finally { + $stream.Dispose() + } +} + +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path +$sourceRoot = Join-Path $repositoryRoot 'src\Workloads' +$releaseRoot = Join-Path $repositoryRoot 'Workloads' +$flows = @('cuda', 'rocm', 'intel-ai', 'foundry', 'pytorch', 'local-ai', 'llama.cpp', 'ollama') +$shells = @(Get-TestPowerShellHosts) +$microsoftSignerSubject = 'CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US' + +$pipeline = Get-Content -LiteralPath (Join-Path $repositoryRoot '.pipelines\OneBranch.SignAndPackage.yml') -Raw +if ($pipeline -notmatch 'files_to_sign:\s*src/\*\*/\*\.ps1' -or + $pipeline -notmatch 'src/Workloads/\*\*') { + throw 'The release pipeline no longer signs src/**/*.ps1 and packages src/Workloads/**.' +} + +foreach ($shell in $shells) { + $shellName = [System.IO.Path]::GetFileNameWithoutExtension($shell) + $unsigned = Join-Path $sourceRoot 'cuda\install.ps1' + $unsignedResult = Invoke-AllSignedProcess ` + -Shell $shell ` + -Script $unsigned ` + -Arguments @('-PlanOnly', '-ReportPath', (Join-Path $env:TEMP "$shellName-unsigned-ai.json")) + if ($unsignedResult.ExitCode -eq 0 -or + $unsignedResult.Output -notmatch '(?i)(not digitally signed|cannot be loaded)') { + throw "$shellName did not enforce AllSigned for unsigned AI source: $($unsignedResult.Output)" + } + + $signedSource = Get-ChildItem -LiteralPath $releaseRoot -Recurse -Filter 'install.ps1' | + Select-Object -First 1 + if (-not $signedSource) { + throw 'No release workload was available for the AllSigned host contract probe.' + } + $signedProbeRoot = Join-Path $env:TEMP "devconfig-signed-probe-$([guid]::NewGuid().ToString('N'))" + $signedProbe = Join-Path $signedProbeRoot 'install.ps1' + New-Item -ItemType Directory -Path $signedProbeRoot -Force | Out-Null + Copy-ReleaseScriptWithCrLf -Source $signedSource.FullName -Destination $signedProbe + $probeSignature = Get-AuthenticodeSignature -LiteralPath $signedProbe + if ($probeSignature.Status -ne 'Valid' -or -not $probeSignature.SignerCertificate -or + $probeSignature.SignerCertificate.Subject -ne $microsoftSignerSubject) { + throw "CRLF release probe failed Microsoft signature validation: $($probeSignature.Status)." + } + try { + $probeResult = Invoke-AllSignedProcess ` + -Shell $shell ` + -Script $signedProbe ` + -Arguments @('-?') ` + -PublisherConsentCount 4 + if ($probeResult.ExitCode -ne 0) { + throw "$shellName could not load a valid Microsoft-signed release workload under AllSigned: $($probeResult.Output)" + } + } finally { + Remove-Item -LiteralPath $signedProbeRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +$missingReleaseFlows = @($flows | Where-Object { + -not (Test-Path -LiteralPath (Join-Path $releaseRoot "$_\install.ps1")) +}) +if ($missingReleaseFlows.Count -gt 0) { + Write-Host "ALL_SIGNED_RELEASE_PENDING: sign cycle must publish $($missingReleaseFlows -join ', ')" +} else { + $signedScope = @( + Get-ChildItem -LiteralPath (Join-Path $releaseRoot '_common') -File -Filter '*.ps1' + foreach ($flow in $flows) { + Get-ChildItem -LiteralPath (Join-Path $releaseRoot $flow) -File -Filter '*.ps1' + } + ) + foreach ($script in $signedScope) { + $signature = Get-AuthenticodeSignature -LiteralPath $script.FullName + if ($signature.Status -ne 'Valid' -or + $signature.SignerCertificate.Subject -ne $microsoftSignerSubject) { + throw "Signed AI release file failed Microsoft signature validation: '$($script.FullName)' [$($signature.Status)]." + } + } + + foreach ($shell in $shells) { + $shellName = [System.IO.Path]::GetFileNameWithoutExtension($shell) + foreach ($flow in $flows) { + $reportPath = Join-Path $env:TEMP "$shellName-$flow-all-signed.json" + $result = Invoke-AllSignedProcess ` + -Shell $shell ` + -Script (Join-Path $releaseRoot "$flow\install.ps1") ` + -Arguments @('-PlanOnly', '-ReportPath', $reportPath) ` + -PublisherConsentCount 64 + if ($result.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $reportPath)) { + throw "$shellName AllSigned launch failed for signed $flow release: $($result.Output)" + } + } + } + Write-Host "ALL_SIGNED_RELEASE_OK: $($flows.Count) AI flows in $($shells.Count) PowerShell host(s)" +} + +Write-Host "ALL_SIGNED_CONTRACT_OK: unsigned source rejected and signed release accepted in $($shells.Count) PowerShell host(s)" diff --git a/src/tests/ai-common/unit.ps1 b/src/tests/ai-common/unit.ps1 new file mode 100644 index 00000000..4bec6ac4 --- /dev/null +++ b/src/tests/ai-common/unit.ps1 @@ -0,0 +1,288 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_harness\assertions.ps1') +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\direct-setup.ps1') +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\ai-report.ps1') + +$catalog = Get-AiCatalog +$required = @( + 'Component', 'Architectures', 'Maturity', 'SourceType', 'VersionPolicy', + 'Integrity', 'CachePath', 'InstallPath', 'NormalChannelLimitation', + 'ExpectedStableSource', 'MigrationTrigger', 'CleanupUpgrade' +) +foreach ($entry in $catalog.Components.GetEnumerator()) { + foreach ($field in $required) { + Assert-True ($entry.Value.ContainsKey($field)) "$($entry.Key) should define promotion field $field" + } +} + +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\content-hashes.ps1') +$workloadsRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\Workloads')).Path +Assert-DevConfigWorkloadContent -WorkloadsRoot $workloadsRoot +$trackedContent = @(git -C (Join-Path $PSScriptRoot '..\..\..') ls-files 'src/Workloads/**' | + Where-Object { [IO.Path]::GetExtension($_) -ne '.ps1' } | + ForEach-Object { $_.Substring('src/Workloads/'.Length).Replace('/', '\') }) +Assert-Equal @($Script:DevConfigWorkloadContentHashes.Keys | Sort-Object).Count $trackedContent.Count 'Signed content manifest should cover every tracked non-PowerShell Workloads file' +foreach ($path in $trackedContent) { + Assert-True $Script:DevConfigWorkloadContentHashes.ContainsKey($path) "Signed content manifest should declare $path" +} +$blobHashScript = @' +import hashlib +import json +import subprocess + +paths = subprocess.check_output( + ["git", "ls-files", "src/Workloads/**"], text=True +).splitlines() +print(json.dumps({ + path[len("src/Workloads/"):].replace("/", "\\"): + hashlib.sha256(subprocess.check_output(["git", "show", f"HEAD:{path}"])).hexdigest() + for path in paths + if not path.lower().endswith(".ps1") +}, sort_keys=True)) +'@ +$blobHashScriptPath = Join-Path $env:TEMP "devconfig-blob-hashes-$([guid]::NewGuid().ToString('N')).py" +try { + [IO.File]::WriteAllText($blobHashScriptPath, $blobHashScript, [Text.UTF8Encoding]::new($false)) + $blobHashResult = Invoke-DevConfigNativeCommand -FilePath 'python' -Arguments @($blobHashScriptPath) + if ($blobHashResult.ExitCode -ne 0) { + throw "Could not calculate canonical Git blob hashes: $($blobHashResult.Output)" + } + $blobHashes = $blobHashResult.Output.Trim() | ConvertFrom-Json +} finally { + Remove-Item -LiteralPath $blobHashScriptPath -Force -ErrorAction SilentlyContinue +} +foreach ($path in $trackedContent) { + Assert-Equal $Script:DevConfigWorkloadContentHashes[$path] $blobHashes.$path "Signed content hash should match canonical Git blob bytes for $path" +} +$tamperedRoot = Join-Path $env:TEMP "devconfig-content-tamper-$([guid]::NewGuid().ToString('N'))" +try { + Copy-Item -LiteralPath $workloadsRoot -Destination $tamperedRoot -Recurse + New-Item -ItemType Directory -Path (Join-Path $tamperedRoot 'pytorch\__pycache__') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $tamperedRoot 'pytorch\__pycache__\torch.pyc') -Value 'untrusted bytecode' + Assert-ThrowsLike { + Assert-DevConfigWorkloadContent -WorkloadsRoot $tamperedRoot + } '*not declared by signed content manifest*' 'Signed content verification should reject unexpected Python bytecode' +} finally { + Remove-Item -LiteralPath $tamperedRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +$capabilities = @(Get-AiCapabilityMatrix) +Assert-True ($capabilities.Count -ge 30) 'Capability matrix should enumerate every supported and explicitly unavailable Windows AI cell' +Assert-Equal @($capabilities.Id | Sort-Object -Unique).Count $capabilities.Count 'Capability ids should be unique' +$requiredCapabilityIds = @( + 'cuda-nvidia-x64', 'cuda-nvidia-arm64', 'rocm-amd-x64', + 'intel-openvino-cpu-x64', 'intel-openvino-gpu-x64', 'intel-openvino-npu-x64', + 'intel-sycl-gpu-x64', 'intel-full-gpu-x64', + 'pytorch-cpu-x64', 'pytorch-cpu-arm64', 'pytorch-cuda-x64', 'pytorch-cuda-arm64', + 'pytorch-rocm-x64', 'pytorch-xpu-x64', + 'triton-cuda-x64', 'triton-cuda-arm64', 'triton-xpu-x64', + 'llama-cpu-x64', 'llama-cpu-arm64', 'llama-cuda-x64', 'llama-cuda-arm64', + 'llama-rocm-x64', 'llama-sycl-x64', 'llama-openvino-x64', 'llama-vulkan-x64', + 'llama-opencl-adreno-arm64', + 'foundry-source-managed-x64', 'foundry-source-managed-arm64', + 'ollama-source-managed-x64', 'ollama-source-managed-arm64', + 'rocm-arm64-unavailable', 'pytorch-rocm-arm64-unavailable', + 'pytorch-xpu-arm64-unavailable', 'pytorch-qualcomm-arm64-unavailable', + 'triton-amd-windows-unavailable', 'generic-arm-gpu-toolkit-unavailable', + 'amd-ryzen-ai-npu-unavailable', 'intel-ai-arm64-unavailable', + 'other-windows-gpu-unavailable' +) +foreach ($id in $requiredCapabilityIds) { + Assert-True ($id -in $capabilities.Id) "Capability matrix should include required cell $id" +} +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path +$implementedStatuses = @('implemented-supported', 'source-managed') +foreach ($cell in $capabilities) { + Assert-True ($cell.Status -in @('implemented-supported', 'source-managed', 'upstream-unavailable')) "$($cell.Id) should use a defined capability status" + if ($cell.Status -in $implementedStatuses) { + foreach ($field in @('Workload', 'Architecture', 'Vendor', 'DeviceFamily', 'Backend', 'Maturity', 'Acquisition', 'Prerequisites', 'Resolver', 'ResolverArguments', 'Expected', 'ProbePath', 'ReportEvidence', 'PartnerCommand')) { + Assert-True $cell.ContainsKey($field) "$($cell.Id) should define supported-cell field $field" + } + Assert-True ([bool](Get-Command -Name $cell.Resolver -CommandType Function -ErrorAction SilentlyContinue)) "$($cell.Id) resolver should exist" + foreach ($identity in @($cell.Acquisition)) { + if ($identity -like 'component:*') { + $componentKey = $identity.Substring('component:'.Length) + Assert-True $catalog.Components.ContainsKey($componentKey) "$($cell.Id) should reference catalog component $componentKey" + } else { + Assert-True ($identity -like 'winget:*') "$($cell.Id) acquisition '$identity' should use a known identity prefix" + } + } + Assert-True (Test-Path -LiteralPath (Join-Path $repositoryRoot $cell.ProbePath)) "$($cell.Id) verification probe should exist" + Assert-True ([bool]$cell.ReportEvidence) "$($cell.Id) should define report evidence" + Assert-True ($cell.PartnerCommand -match '-ReportPath') "$($cell.Id) should provide a report-producing partner command" + $resolvedCell = Resolve-AiCapabilityCell -Id $cell.Id + Assert-True ($null -ne $resolvedCell) "$($cell.Id) resolver fixture should return a plan" + } else { + Assert-True $cell.ContainsKey('Blocker') "$($cell.Id) should explain the authoritative upstream boundary" + try { + Resolve-AiCapabilityCell -Id $cell.Id + throw "Capability '$($cell.Id)' unexpectedly resolved." + } catch { + Assert-Equal $_.Exception.Message $cell.Blocker "$($cell.Id) should return its actionable blocker" + } + } +} +$capabilityReportPath = Join-Path $env:TEMP "devconfig-capabilities-$([guid]::NewGuid().ToString('N')).json" +try { + & (Join-Path $repositoryRoot 'src\tools\get-ai-capabilities.ps1') -OutputPath $capabilityReportPath + $capabilityReport = Get-Content -LiteralPath $capabilityReportPath -Raw | ConvertFrom-Json + Assert-Equal $capabilityReport.capabilities.Count $capabilities.Count 'Capability report tool should emit every catalog cell' + Assert-True (@($capabilityReport.capabilities | Where-Object status -eq 'source-managed').Count -gt 0) 'Capability report should preserve source-managed status' +} finally { + Remove-Item -LiteralPath $capabilityReportPath -Force -ErrorAction SilentlyContinue +} + +$wingetArgs = Get-DevConfigWingetInstallArguments -Id 'Microsoft.FoundryLocal' +Assert-Equal ($wingetArgs -join ' ') 'install --id Microsoft.FoundryLocal --exact --source winget --silent --accept-package-agreements --accept-source-agreements --disable-interactivity' 'Shared WinGet install command should be exact and noninteractive' +$upgradeArgs = Get-DevConfigWingetUpgradeArguments -Id 'Microsoft.VisualStudio.2022.BuildTools' +Assert-Equal ($upgradeArgs -join ' ') 'upgrade --id Microsoft.VisualStudio.2022.BuildTools --exact --source winget --silent --accept-package-agreements --accept-source-agreements --disable-interactivity' 'Shared WinGet upgrade command should be exact and noninteractive' +Assert-Equal (Get-AiWingetPackageAction -State Current) 'skip' 'Current packages should skip acquisition' +Assert-Equal (Get-AiWingetPackageAction -State UpgradeAvailable) 'upgrade' 'Outdated packages should upgrade' +Assert-Equal (Get-AiWingetPackageAction -State Absent) 'install' 'Absent packages should install' +Assert-True ([bool](Get-Command Ensure-DevConfigWingetPackage -ErrorAction SilentlyContinue)) 'Shared production package ensure function should be exported at script scope' + +$currentShape = [pscustomobject]@{ + Id = 'Current.Package' + Name = 'Current package' + InstalledVersion = '1.0.0' + IsUpdateAvailable = $false +} +$currentEvidence = ConvertTo-AiWingetPackageEvidence -Package $currentShape -RequestedId 'Current.Package' +Assert-Equal $currentEvidence.installedVersion '1.0.0' 'Evidence should support current module object shape without AvailableVersion' +Assert-Equal $currentEvidence.availableVersion '' 'Missing optional AvailableVersion should not fail evidence collection' + +$olderShape = [pscustomobject]@{ + PackageIdentifier = 'Older.Package' + PackageName = 'Older package' + Version = '2.0.0' + LatestVersion = '2.1.0' + UpdateAvailable = $true +} +$olderEvidence = ConvertTo-AiWingetPackageEvidence -Package $olderShape -RequestedId 'fallback' +Assert-Equal $olderEvidence.id 'Older.Package' 'Evidence should support alternate identifier names' +Assert-Equal $olderEvidence.availableVersion '2.1.0' 'Evidence should support alternate latest-version names' +Assert-True $olderEvidence.updateAvailable 'Evidence should support alternate update flags' + +$minimalEvidence = ConvertTo-AiWingetPackageEvidence -Package ([pscustomobject]@{}) -RequestedId 'Minimal.Package' +Assert-Equal $minimalEvidence.id 'Minimal.Package' 'Minimal package objects should retain the requested id' +Assert-Equal $minimalEvidence.installedVersion '' 'Minimal package objects should not fail under StrictMode' + +# Keep fallback tests fast and deterministic by invoking each retry body once. +function Invoke-DevConfigRetry { + param([scriptblock] $ScriptBlock, [string] $Name, [int] $MaxAttempts, [int] $InitialDelaySeconds) + & $ScriptBlock +} +$Script:DevConfigWinGetMode = 'Module' +$script:cliArguments = $null +function Test-DevConfigWingetCliUsable { return $true } +function Install-WinGetPackage { throw 'module install error' } +function Update-WinGetPackage { throw 'module upgrade error' } +function Invoke-DevConfigWingetCli { + param([string[]] $Arguments) + $script:cliArguments = $Arguments + return [pscustomobject]@{ ExitCode = 0; Output = '' } +} +Install-DevConfigWingetPackage -Id 'Fallback.Install' +Assert-Equal $script:cliArguments[0] 'install' 'Module install error should fall back to CLI install' +$Script:DevConfigWinGetMode = 'Module' +$script:updateModuleCalls = 0 +function Update-WinGetPackage { $script:updateModuleCalls++; throw 'module upgrade error' } +Update-DevConfigWingetPackage -Id 'Fallback.Upgrade' +Assert-Equal $script:cliArguments[0] 'upgrade' 'Module upgrade error should fall back to CLI upgrade' +Assert-Equal $script:updateModuleCalls 1 'Upgrade fallback should attempt the module before CLI' + +$Script:DevConfigWinGetMode = 'Module' +function Invoke-DevConfigWingetCli { + param([string[]] $Arguments) + return [pscustomobject]@{ ExitCode = 9; Output = 'real failure' } +} +Assert-ThrowsLike { + Update-DevConfigWingetPackage -Id 'Fallback.Failure' +} '*CLI exit: 9*' 'A real nonzero module and CLI failure should remain fatal' + +# Exercise Ensure-AiWingetPackage's state machine without touching machine state. +$script:packageState = 'Current' +$script:installCount = 0 +$script:upgradeCount = 0 +function Initialize-DevConfigWinGet {} +function Get-DevConfigWingetPackageState { param($Id) [pscustomobject]@{ State = $script:packageState; Package = $null } } +function Install-DevConfigWingetPackage { param($Id) $script:installCount++ } +function Update-DevConfigWingetPackage { param($Id) $script:upgradeCount++ } +function Wait-DevConfigWingetPackageSettled { param($Id) } +function Ensure-DevConfigWingetPackage { + param($Id) + $operation = Get-AiWingetPackageAction -State $script:packageState + if ($operation -eq 'install') { $script:installCount++; return 'installed' } + if ($operation -eq 'upgrade') { $script:upgradeCount++; return 'upgraded' } + return 'already-current' +} +function Update-DevConfigSessionPath {} +function Test-DevConfigWingetPackageInstalled { param($Id) return $true } +function Get-AiWingetPackageEvidence { param($Id) return @{ id = $Id } } + +$currentResult = Ensure-AiWingetPackage -Id 'State.Current' +Assert-Equal $currentResult.Action 'already-current' 'Installed current package should skip' +Assert-Equal $script:installCount 0 'Current package should not install' +Assert-Equal $script:upgradeCount 0 'Current package should not upgrade' + +$script:packageState = 'UpgradeAvailable' +$upgradeResult = Ensure-AiWingetPackage -Id 'State.Upgrade' +Assert-Equal $upgradeResult.Action 'upgraded' 'Installed outdated package should upgrade' +Assert-Equal $script:upgradeCount 1 'Upgrade state should invoke upgrade exactly once' + +$script:packageState = 'Absent' +$installResult = Ensure-AiWingetPackage -Id 'State.Absent' +Assert-Equal $installResult.Action 'installed' 'Absent package should install' +Assert-Equal $script:installCount 1 'Absent state should invoke install exactly once' + +$directSetup = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\_common\direct-setup.ps1') -Raw +Assert-True ($directSetup.Contains('''--installPath'', "`"$installPath`""')) 'Build Tools install path should remain one quoted Start-Process argument' +Assert-True ($directSetup -match 'Get-AiWingetPackageEvidence') 'Package evidence should respect the selected WinGet frontend' +Assert-True ($directSetup -match 'Enable-AiUtf8Console') 'Standalone AI entry points should normalize UTF-8 console capture' + +$freshProcessScript = Join-Path $env:TEMP "devconfig-lastexitcode-$([guid]::NewGuid().ToString('N')).ps1" +try { + @( + 'Set-StrictMode -Version Latest', + ". '$((Resolve-Path (Join-Path $PSScriptRoot '..\..\windows-dev-config\steps\_environment.ps1')).Path)'", + '$result = Invoke-DevConfigNativeCommand -FilePath $env:ComSpec -Arguments @(''/d'',''/c'',''exit 0'')', + 'if ($result.ExitCode -ne 0) { throw "unexpected exit $($result.ExitCode)" }', + 'try { Invoke-DevConfigNativeCommand -FilePath ''__missing_devconfig_command__.exe'' } catch { Write-Output MISSING_NATIVE_FAILED }', + 'Write-Output FRESH_LASTEXITCODE_OK' + ) | Set-Content -LiteralPath $freshProcessScript -Encoding utf8 + $freshResult = & pwsh -NoProfile -File $freshProcessScript 2>&1 | Out-String + Assert-True ($freshResult -match 'FRESH_LASTEXITCODE_OK') 'Fresh StrictMode process should execute native command without preexisting LASTEXITCODE' + Assert-True ($freshResult -match 'MISSING_NATIVE_FAILED') 'Fresh StrictMode process should treat native launch failure as failure' +} finally { + Remove-Item -LiteralPath $freshProcessScript -Force -ErrorAction SilentlyContinue +} + +$report = New-AiWorkloadReport -Id 'unit' -Request @{ PlanOnly = $true } +Add-AiReportAcquisition -Report $report -Entry @{ component = 'test'; sourceType = 'unit'; action = 'planned' } +Set-AiAcquisitionAction -Report $report -Index 0 -Action 'already-current' +Add-AiReportPhase -Report $report -Name 'plan' -Status 'planned' -Evidence @{ backend = 'CPU' } +Assert-Equal $report.schemaVersion 1 'Report schema version should be stable' +Assert-Equal $report.acquisitions.Count 1 'Report should collect acquisitions' +Assert-Equal $report.acquisitions[0].action 'already-current' 'Report should finalize acquisition actions' +Assert-Equal $report.phases.Count 1 'Report should collect phases' +Assert-True $report.result.planOnly 'Report should preserve plan mode' + +$schemaPath = Join-Path $PSScriptRoot '..\..\docs\ai-workload-report.schema.json' +Assert-True (Test-Path -LiteralPath $schemaPath) 'Checked-in report schema should exist' + +$failurePath = Join-Path $env:TEMP "devconfig-report-failure-$([guid]::NewGuid().ToString('N')).json" +try { + $failureReport = New-AiWorkloadReport -Id 'failure-unit' -Request @{} + try { throw 'synthetic hardware failure' } catch { + Write-AiFailureReport -Report $failureReport -Path $failurePath -ErrorRecord $_ + } + $savedFailure = Get-Content -LiteralPath $failurePath -Raw | ConvertFrom-Json + Assert-True (-not $savedFailure.result.ready) 'Failure report should not claim readiness' + Assert-True ($savedFailure.result.blockers[0] -like '*synthetic hardware failure*') 'Failure report should retain the actionable exception' +} finally { + Remove-Item -LiteralPath $failurePath -Force -ErrorAction SilentlyContinue +} + +Write-Host "UNIT_OK: ai-common ($script:AssertionCount assertions)" diff --git a/src/tests/cuda/expected.txt b/src/tests/cuda/expected.txt new file mode 100644 index 00000000..00d1727b --- /dev/null +++ b/src/tests/cuda/expected.txt @@ -0,0 +1 @@ +CUDA ready diff --git a/src/tests/cuda/probe.ps1 b/src/tests/cuda/probe.ps1 new file mode 100644 index 00000000..f37764f9 --- /dev/null +++ b/src/tests/cuda/probe.ps1 @@ -0,0 +1,38 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\ai-support.ps1') + +$architecture = Get-DevConfigArchitecture +$plan = Resolve-CudaInstallPlan -Architecture $architecture -WindowsBuild (Get-WindowsBuildNumber) +$reportPath = Get-AiDefaultReportPath -Id 'cuda' +$deviceIndex = 0 +if (Test-Path -LiteralPath $reportPath) { + $request = (Get-Content -LiteralPath $reportPath -Raw | ConvertFrom-Json).request + if ($request.PSObject.Properties['DeviceIndex']) { $deviceIndex = [int]$request.DeviceIndex } +} +$nvcc = Get-CudaNvccPath -ToolkitVersion $plan.ToolkitVersion +$vsDevCmd = Get-VsDevCmdPath -Architecture $architecture +$temporary = Join-Path ([System.IO.Path]::GetTempPath()) "devconfig-cuda-probe-$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $temporary -Force | Out-Null +try { + $executable = Join-Path $temporary 'cuda-smoke.exe' + $compile = Get-CudaKernelCompileCommand ` + -Architecture $architecture ` + -VsDevCmd $vsDevCmd ` + -Nvcc $nvcc ` + -Source (Join-Path $PSScriptRoot '..\..\Workloads\cuda\smoke.cu') ` + -Output $executable + & $env:ComSpec /d /s /c $compile *> $null + if ($LASTEXITCODE -ne 0) { + throw "CUDA probe compilation failed with exit code $LASTEXITCODE." + } + $result = (& $executable $deviceIndex 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $result -notmatch '^CUDA_KERNEL_READY') { + throw "CUDA probe kernel failed with exit code $LASTEXITCODE and output '$result'." + } +} finally { + Remove-Item -LiteralPath $temporary -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Output 'CUDA ready' diff --git a/src/tests/cuda/unit.ps1 b/src/tests/cuda/unit.ps1 new file mode 100644 index 00000000..a825a183 --- /dev/null +++ b/src/tests/cuda/unit.ps1 @@ -0,0 +1,95 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_harness\assertions.ps1') +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\ai-support.ps1') + +$ready = Get-CudaReadiness -ToolkitAvailable $true -NvidiaGpuPresent $true -DriverAvailable $true +Assert-Equal $ready.Status 'Ready' 'Toolkit and driver/GPU should be ready' +Assert-True $ready.GpuReady 'GPU readiness should be true' + +$toolkitOnly = Get-CudaReadiness -ToolkitAvailable $true -NvidiaGpuPresent $false -DriverAvailable $false +Assert-Equal $toolkitOnly.Status 'ToolkitOnlyNoGpu' 'Toolkit-only state should be distinct' +Assert-True (-not $toolkitOnly.GpuReady) 'Toolkit-only should not report GPU readiness' +$toolkitOnlyRepeat = Get-CudaReadiness -ToolkitAvailable $true -NvidiaGpuPresent $false -DriverAvailable $false +Assert-Equal ($toolkitOnlyRepeat | ConvertTo-Json -Compress) ($toolkitOnly | ConvertTo-Json -Compress) 'CUDA readiness should be idempotent' + +$x64Plan = Resolve-CudaInstallPlan -Architecture X64 +Assert-Equal $x64Plan.Method 'WinGet' 'CUDA x64 should use WinGet' +Assert-Equal $x64Plan.ToolkitVersion $null 'CUDA x64 should discover the WinGet-installed stable toolkit version' + +$armPlan = Resolve-CudaInstallPlan -Architecture Arm64 -WindowsBuild 28120 +Assert-Equal $armPlan.Method 'NvidiaInstaller' 'CUDA ARM64 should use NVIDIA developer-preview installer' +Assert-Equal $armPlan.ToolkitVersion '13.4' 'CUDA ARM64 should select toolkit 13.4' +Assert-Equal $armPlan.InstallerSha256 'a1f68c81160b16d519c4087788b9c07de41306c3f1b872471ceee0996621374d' 'CUDA ARM64 installer should be checksum pinned' +Assert-True ($armPlan.InstallerUrl -like 'https://packages.nvidia.com/prerelease/*windows_arm64.exe') 'CUDA ARM64 installer should use NVIDIA prerelease origin' +$cudaCandidate = (Get-AiCatalogData).Components.CudaArm64.PromotionCandidate +Assert-Equal $cudaCandidate.Version '13.4.1' 'CUDA ARM64 should track the official stable direct candidate' +Assert-Equal $cudaCandidate.Sha256 '39af79e5e136c4e0de03bba816bda60fd7b70aad033e37ecaacf9f2e2c982442' 'CUDA 13.4.1 candidate should retain the verified installer hash' +Assert-Equal $cudaCandidate.Size 3711598920 'CUDA 13.4.1 candidate should retain the verified installer size' +Assert-True ($cudaCandidate.TrackingStatus -match 'awaiting N1X') 'CUDA stable candidate should remain gated on real workload qualification' + +$compile = Get-CudaKernelCompileCommand ` + -Architecture Arm64 ` + -VsDevCmd 'C:\VS\VsDevCmd.bat' ` + -Nvcc 'C:\CUDA\nvcc.exe' ` + -Source 'C:\src\smoke.cu' ` + -Output 'C:\out\smoke.exe' +Assert-True ($compile -like '*-arch=arm64 -host_arch=arm64*') 'CUDA ARM64 smoke should select native MSVC environment' +Assert-True ($compile -like '*-arch=native*smoke.cu*') 'CUDA smoke should compile for the detected GPU' +Assert-True ($compile -like '*Microsoft Visual Studio\Installer;%PATH%*') 'CUDA compiler environment should put vswhere.exe on PATH before VsDevCmd runs' + +$installScript = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\cuda\install.ps1') -Raw +Assert-True ($installScript -match '\[switch\]\s*\$SkipWorkloadSmoke') 'CUDA should expose workload-smoke opt-out' +Assert-True ($installScript -match '\[int\]\s*\$DeviceIndex') 'CUDA should expose same-vendor adapter selection' +Assert-True ($installScript -match 'Get-NvidiaDriverInfo -DeviceIndex \$DeviceIndex') 'CUDA should qualify the requested NVIDIA adapter' +Assert-True ($installScript -match 'current stable CUDA 13 x64 flow requires driver 580\+') 'CUDA x64 should fail before acquisition on unsupported CUDA 13 hardware' +Assert-True ($installScript -match 'CUDA 13\.4 ARM64 Developer Preview requires driver 616\+') 'CUDA ARM64 should enforce the qualified N1X driver/device tuple' +$smokeSource = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\cuda\smoke.cu') -Raw +Assert-True ($smokeSource -match 'cudaSetDevice\(device_index\)') 'CUDA kernel should execute on the requested NVIDIA adapter' +Assert-True ($smokeSource -match 'cudaGetDeviceProperties') 'CUDA kernel evidence should report the actual device' +$probeScript = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'probe.ps1') -Raw +Assert-True ($probeScript -match 'DeviceIndex') 'CUDA verification probe should reuse the selected adapter' +$directSetup = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\_common\direct-setup.ps1') -Raw +Assert-True ($directSetup -match 'Microsoft\.VisualStudio\.Component\.VC\.Tools\.ARM64') 'Direct setup should install native compiler tools' +Assert-True ($directSetup -match 'Invoke-DevConfigProcess') 'Direct setup should use PR #93 bounded process execution' +Assert-True ($directSetup -match 'Ensure-AiCudaToolkit') 'CUDA acquisition should be shared with PyTorch' +Assert-True ($installScript -notmatch 'apply-configuration') 'CUDA should not use winget configure' +Assert-True ($installScript -match 'Ready \$kernelReady') 'CUDA report should require a successfully executed kernel for readiness' + +$cleanupPath = Join-Path $env:TEMP "devconfig-cleanup-test-$([guid]::NewGuid().ToString('N')).tmp" +Set-Content -LiteralPath $cleanupPath -Value 'test' +$script:cleanupAttempts = 0 +$removed = Remove-TemporaryFileWithRetry ` + -Path $cleanupPath ` + -MaxAttempts 3 ` + -DelayMilliseconds 0 ` + -RemoveAction { + param($Target) + $script:cleanupAttempts++ + if ($script:cleanupAttempts -lt 3) { + throw 'installer still holds the file' + } + Remove-Item -LiteralPath $Target -Force + } +Assert-True $removed 'Temporary cleanup should succeed after a delayed installer release' +Assert-Equal $script:cleanupAttempts 3 'Temporary cleanup should retry until release' +Assert-True (-not (Test-Path -LiteralPath $cleanupPath)) 'Temporary cleanup should remove the released file' + +$lockedPath = Join-Path $env:TEMP "devconfig-cleanup-locked-$([guid]::NewGuid().ToString('N')).tmp" +Set-Content -LiteralPath $lockedPath -Value 'test' +$warnings = [System.Collections.Generic.List[string]]::new() +$removed = Remove-TemporaryFileWithRetry ` + -Path $lockedPath ` + -MaxAttempts 2 ` + -DelayMilliseconds 0 ` + -RemoveAction { param($Target) throw 'access denied while installer child exits' } ` + -WarningVariable cleanupWarnings +foreach ($warning in $cleanupWarnings) { + [void]$warnings.Add($warning.Message) +} +Assert-True (-not $removed) 'Persistent cleanup failure should return false instead of throwing' +Assert-True (($warnings -join ' ') -like '*Could not remove temporary file*access denied*') 'Persistent cleanup failure should emit a useful warning' +Remove-Item -LiteralPath $lockedPath -Force + +Write-Host "UNIT_OK: cuda ($script:AssertionCount assertions)" diff --git a/src/tests/foundry/expected.txt b/src/tests/foundry/expected.txt new file mode 100644 index 00000000..11ae80b6 --- /dev/null +++ b/src/tests/foundry/expected.txt @@ -0,0 +1 @@ +Foundry Local ready diff --git a/src/tests/foundry/probe.ps1 b/src/tests/foundry/probe.ps1 new file mode 100644 index 00000000..9d1ba5a6 --- /dev/null +++ b/src/tests/foundry/probe.ps1 @@ -0,0 +1,21 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\ai-support.ps1') + +if (-not (Get-Command foundry -ErrorAction SilentlyContinue)) { + throw 'foundry was not found on PATH.' +} +& foundry --version *> $null +if ($LASTEXITCODE -ne 0) { + throw "foundry --version failed with exit code $LASTEXITCODE." +} +$plan = Get-FoundryModelSmokePlan +$commands = Get-FoundryModelSmokeCommands -Model $plan.Model -Marker $plan.Marker +$completeArguments = @($commands.Complete) +$output = (& foundry @completeArguments 2>&1 | Out-String) +if ($LASTEXITCODE -ne 0 -or $output -notmatch [regex]::Escape($plan.Marker)) { + throw "Foundry cached-model inference failed with exit code $LASTEXITCODE." +} + +Write-Output 'Foundry Local ready' diff --git a/src/tests/foundry/unit.ps1 b/src/tests/foundry/unit.ps1 new file mode 100644 index 00000000..1f477fbe --- /dev/null +++ b/src/tests/foundry/unit.ps1 @@ -0,0 +1,92 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_harness\assertions.ps1') +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\ai-support.ps1') + +foreach ($architecture in @('X64', 'Arm64')) { + $plan = Resolve-FoundryInstallPlan -Architecture $architecture -WindowsBuild 26100 + Assert-Equal $plan.Architecture $architecture "Foundry should preserve $architecture" + Assert-Equal $plan.PackageId 'Microsoft.FoundryLocal' 'Foundry should use WinGet on both architectures' + Assert-True (-not $plan.RequiresCuda) 'Foundry must not depend on CUDA' +} + +Assert-ThrowsLike { + Resolve-FoundryInstallPlan -Architecture 'X64' -WindowsBuild 22631 +} '*requires Windows 11 24H2*' 'Foundry should reject older Windows builds' + +$first = Resolve-FoundryInstallPlan -Architecture Arm64 -WindowsBuild 26100 +$repeat = Resolve-FoundryInstallPlan -Architecture Arm64 -WindowsBuild 26100 +Assert-Equal ($repeat | ConvertTo-Json -Compress) ($first | ConvertTo-Json -Compress) 'Foundry plan should be idempotent' +$foundryCandidate = (Get-AiCatalogData).Components.FoundryLocal.PromotionCandidate +Assert-Equal $foundryCandidate.Version '2.0.1' 'Foundry should track the latest official non-prerelease candidate' +Assert-Equal $foundryCandidate.X64Sha256 '0551db07d5cba6a523e4c1832f0d38e023301ab67b946378239f8cee156ba5a4' 'Foundry v2 x64 candidate should retain its release hash' +Assert-Equal $foundryCandidate.Arm64Sha256 '2fa8510281cfaa554e21ffae8de41366a08051bce92fd592b919bc4413b57b09' 'Foundry v2 ARM64 candidate should retain its release hash' +Assert-True ($foundryCandidate.Maturity -match 'candidate') 'Foundry v2 should remain a tracked candidate until the migrated workload is qualified' + +$model = Get-FoundryModelSmokePlan +Assert-Equal $model.Model 'qwen3-0.6b' 'Foundry should use the tested small catalog model' +Assert-Equal $model.ApproximateDownloadMb 593 'Foundry should document expected download size' +$commands = Get-FoundryModelSmokeCommands -Model $model.Model -Marker $model.Marker +Assert-Equal ($commands.Download -join ' ') 'model download qwen3-0.6b' 'Foundry download command should be deterministic' +Assert-True (($commands.Complete -join ' ') -like '*DEVCONFIG_FOUNDRY_READY*') 'Foundry completion should require a marker' +$installScript = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\foundry\install.ps1') -Raw +Assert-True ($installScript -match '\[switch\]\s*\$SkipModelSmoke') 'Foundry should expose model-smoke opt-out' +Assert-True ($installScript -match '\[switch\]\s*\$PlanOnly') 'Foundry should expose portable plan mode' +Assert-True ($installScript -match 'Ensure-AiWingetPackage') 'Foundry should use direct package acquisition' +Assert-True ($installScript -notmatch 'apply-configuration') 'Foundry should not use winget configure' +Assert-True ($installScript -match "'server', 'logs', '-n', '200'") 'Foundry report should retain execution-provider diagnostics' +Assert-True ($installScript -match '\$inferenceEvidence = \$null') 'Foundry model-smoke opt-out should use explicit skipped evidence' + +$decoratedCache = "$([char]0x25A0) note: C:\Users\mihippel\.foundry\cache\models" +Assert-Equal (Get-AiWindowsPathFromOutput -Text $decoratedCache) 'C:\Users\mihippel\.foundry\cache\models' 'Decorated Foundry output should produce a clean absolute cache path' +$ansiCache = "$([char]27)[32mready$([char]27)[0m C:\Foundry Cache\models" +Assert-Equal (Get-AiWindowsPathFromOutput -Text $ansiCache) 'C:\Foundry Cache\models' 'ANSI decoration should be removed before parsing the cache path' +Assert-ThrowsLike { + Get-AiWindowsPathFromOutput -Text 'cache unavailable' +} '*No absolute Windows path*' 'Foundry cache output without a path should fail actionably' +Assert-True ($installScript -match 'Invoke-DevConfigNativeCommand') 'Foundry output should use guarded UTF-8 native capture' + +$cpuProviderEvidence = Get-FoundryExecutionProviderEvidence -ServerLogs @' +Failed to register WebGPUExecutionProvider +CUDAExecutionProvider dependency is unavailable +2026-09-10 [INF] Device: CPU,EPs: CPUExecutionProvider +'@ +Assert-Equal $cpuProviderEvidence.SelectedProvider 'CPUExecutionProvider' 'Failed accelerator registrations should not hide the actual CPU provider' +Assert-True $cpuProviderEvidence.CpuFallback 'Foundry CPU provider should be reported as a truthful fallback' +$gpuProviderEvidence = Get-FoundryExecutionProviderEvidence -ServerLogs '2026-09-10 [INF] Device: GPU,EPs: DmlExecutionProvider' +Assert-Equal $gpuProviderEvidence.SelectedProvider 'DmlExecutionProvider' 'Foundry should retain a conclusive accelerator provider' +Assert-Equal $gpuProviderEvidence.SelectedDevice 'GPU' 'Foundry should retain the source-managed selected device' +Assert-True (-not $gpuProviderEvidence.CpuFallback) 'Accelerator provider should not be marked as CPU fallback' +$webGpuProviderEvidence = Get-FoundryExecutionProviderEvidence -ModelInfo @' +| Variant | Model ID | Device | Execution | Size | Cached | +| | | | Provider | | | +|-----------------+----------------+--------+----------------+--------+--------| +| qwen3-0.6b-gene | qwen3-0.6b-gen | GPU | WebGpuExecutio | 529 MB | yes | +| ric-gpu | eric-gpu:2 | | nProvider | | | +| qwen3-0.6b-gene | qwen3-0.6b-gen | CPU | CPUExecutionPr | 593 MB | no | +| ric-cpu | eric-cpu:4 | | ovider | | | ++-----------------+----------------+--------+----------------+--------+--------+ +'@ -ServerLogs "Model qwen3-0.6b-generic-gpu:2`nloaded successfully" +Assert-Equal $webGpuProviderEvidence.SelectedProvider 'WebGPUExecutionProvider' 'Foundry should prefer the explicit WebGPU selection event over a multi-provider variant table' +Assert-Equal $webGpuProviderEvidence.SelectedDevice 'GPU' 'Foundry WebGPU selection should report the GPU device' +Assert-Equal $webGpuProviderEvidence.ObservedProviders.Count 1 'Foundry WebGPU selection should not concatenate fallback table providers' +Assert-ThrowsLike { + Get-FoundryExecutionProviderEvidence -ServerLogs 'Available providers: DmlExecutionProvider, CPUExecutionProvider' +} '*neither the current inference logs nor the selected model variant*' 'Foundry readiness should reject provider availability lists without a selection event' +$cachedVariantEvidence = Get-FoundryExecutionProviderEvidence -ModelInfo @' +| Variant | Model ID | Device | Execution | Size | Cached | +| | | | Provider | | | +|-----------------+----------------+--------+----------------+--------+--------| +| qwen3-0.6b-gene | qwen3-0.6b-gen | CPU | CPUExecutionPr | 593 MB | yes | +| ric-cpu | eric-cpu:4 | | ovider | | | ++-----------------+----------------+--------+----------------+--------+--------+ +'@ +Assert-Equal $cachedVariantEvidence.SelectedProvider 'CPUExecutionProvider' 'Cached Foundry reruns should use the selected variant provider when no new server event is emitted' +Assert-Equal $cachedVariantEvidence.SelectedDevice 'CPU' 'Cached Foundry variant evidence should retain the selected device' +$logDelta = Get-AiAppendedLogText ` + -Before "old line`n2026-09-10 [INF] Device: CPU,EPs: CPUExecutionProvider" ` + -After "old line`n2026-09-10 [INF] Device: CPU,EPs: CPUExecutionProvider`n2026-09-10 [INF] Device: GPU,EPs: DmlExecutionProvider" +Assert-Equal $logDelta '2026-09-10 [INF] Device: GPU,EPs: DmlExecutionProvider' 'Foundry provider parsing should use only log lines appended by the current inference' + +Write-Host "UNIT_OK: foundry ($script:AssertionCount assertions)" diff --git a/src/tests/intel-ai/expected.txt b/src/tests/intel-ai/expected.txt new file mode 100644 index 00000000..2bd0dcb0 --- /dev/null +++ b/src/tests/intel-ai/expected.txt @@ -0,0 +1 @@ +Intel AI ready diff --git a/src/tests/intel-ai/probe.ps1 b/src/tests/intel-ai/probe.ps1 new file mode 100644 index 00000000..97444a98 --- /dev/null +++ b/src/tests/intel-ai/probe.ps1 @@ -0,0 +1,26 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$reportPath = Join-Path $env:LOCALAPPDATA 'DevConfig\reports\intel-ai-latest.json' +if (-not (Test-Path -LiteralPath $reportPath)) { throw "Intel AI report not found at '$reportPath'." } +$report = Get-Content -LiteralPath $reportPath -Raw | ConvertFrom-Json +$profile = $report.request.Profile +$device = $report.request.SelectedDevice +$openVinoTarget = if ($report.request.PSObject.Properties['OpenVinoDeviceId'] -and $report.request.OpenVinoDeviceId) { + $report.request.OpenVinoDeviceId +} else { + $device +} + +if ($profile -in @('OpenVINO', 'Full')) { + $python = Join-Path $env:LOCALAPPDATA 'DevConfig\intel-ai\openvino\.venv\Scripts\python.exe' + if (-not (Test-Path -LiteralPath $python)) { throw "OpenVINO environment not found at '$python'." } + $output = (& $python (Join-Path $PSScriptRoot '..\..\Workloads\intel-ai\openvino-smoke.py') $openVinoTarget 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $output -notmatch '^OPENVINO_SMOKE=') { + throw "OpenVINO probe failed: $output" + } +} +if ($profile -in @('SYCL', 'Full') -and -not $report.acceptance.sycl) { + throw 'The Intel AI report does not contain successful SYCL acceptance evidence.' +} +Write-Output 'Intel AI ready' diff --git a/src/tests/intel-ai/unit.ps1 b/src/tests/intel-ai/unit.ps1 new file mode 100644 index 00000000..be1ed9de --- /dev/null +++ b/src/tests/intel-ai/unit.ps1 @@ -0,0 +1,56 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_harness\assertions.ps1') +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\direct-setup.ps1') +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\ai-report.ps1') + +$catalog = Get-AiCatalog +Assert-Equal $catalog.Components.IntelOpenVino.Architectures[0] 'X64' 'OpenVINO flow should be native Windows x64 only' +Assert-Equal $catalog.Components.IntelOneApi.PackageId 'Intel.OneAPI.Toolkit' 'oneAPI should use the current unified WinGet package' +Assert-Equal $catalog.Components.IntelOneApi.Version '2026.0.0.193' 'oneAPI metadata should record the qualified stable version' +Assert-True ($catalog.Components.IntelOpenVino.Packages -contains 'openvino==2026.3.1') 'OpenVINO runtime should be exactly pinned' +$gpuPlan = Resolve-IntelAiPlan -Architecture X64 -Device Auto -Profile Full -IntelGpuPresent $true +Assert-Equal $gpuPlan.Device 'GPU' 'Intel Auto should select a detected GPU' +Assert-True $gpuPlan.InstallOpenVino 'Full profile should install OpenVINO' +Assert-True $gpuPlan.InstallOneApi 'Full profile should install oneAPI' +$npuPlan = Resolve-IntelAiPlan -Architecture X64 -Device Auto -Profile OpenVINO -IntelGpuPresent $true -IntelNpuPresent $true +Assert-Equal $npuPlan.Device 'NPU' 'Intel Auto should prefer an available NPU for OpenVINO' +Assert-ThrowsLike { + Resolve-IntelAiPlan -Architecture Arm64 -Device Auto -Profile OpenVINO -IntelGpuPresent $false +} '*do not publish native Windows ARM64*' 'Intel AI should reject Windows ARM64' +Assert-ThrowsLike { + Resolve-IntelAiPlan -Architecture X64 -Device GPU -Profile OpenVINO -IntelGpuPresent $false +} '*no Intel display adapter*' 'Explicit Intel GPU should fail without hardware' +Assert-ThrowsLike { + Resolve-IntelAiPlan -Architecture X64 -Device NPU -Profile OpenVINO -IntelNpuPresent $false +} '*no Intel AI Boost/NPU*' 'Explicit Intel NPU should fail without hardware' + +$script = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\intel-ai\install.ps1') -Raw +Assert-True ($script -match "ValidateSet\('Auto', 'CPU', 'GPU', 'NPU'\)") 'Intel flow should expose explicit device selection' +Assert-True ($script -match "ValidateSet\('OpenVINO', 'SYCL', 'Full'\)") 'Intel flow should expose runtime/toolkit profiles' +Assert-True ($script -match '\$OpenVinoDeviceId') 'Intel OpenVINO should expose an exact device id for same-vendor adapters' +Assert-True ($script -match '\$SyclDeviceSelector') 'Intel SYCL should expose ONEAPI_DEVICE_SELECTOR for same-vendor adapters' +$probe = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'probe.ps1') -Raw +Assert-True ($probe -match 'OpenVinoDeviceId') 'Intel verification probe should reuse the selected OpenVINO device id' + +function Get-CimInstance { + return @( + [pscustomobject]@{ Name = 'Intel HD Graphics 4000'; PNPDeviceID = 'PCI\VEN_8086&DEV_0001' }, + [pscustomobject]@{ Name = 'Intel Arc B580 Graphics'; PNPDeviceID = 'PCI\VEN_8086&DEV_0002' } + ) +} +Assert-Equal (Get-IntelGpuName -DeviceIndex 0) 'Intel HD Graphics 4000' 'Intel indexed lookup should preserve exact adapter zero' +Assert-Equal (Get-IntelGpuName -DeviceIndex 1) 'Intel Arc B580 Graphics' 'Intel indexed lookup should preserve exact adapter one' +Assert-True ($script -match '\[switch\]\s*\$PlanOnly') 'Intel flow should support portable plan mode' +Assert-True ($script -notmatch 'apply-configuration') 'Intel flow should use direct acquisition' +Assert-True ($script -match 'Test-PythonDistributionVersions') 'Intel flow should skip package work when exact OpenVINO versions are installed' + +$openvino = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\intel-ai\openvino-smoke.py') -Raw +Assert-True ($openvino -match 'compile_model\(model, requested\)') 'OpenVINO acceptance should compile on the requested device' +Assert-True ($openvino -match 'FULL_DEVICE_NAME') 'OpenVINO report should identify the actual device' +$sycl = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\intel-ai\sycl-smoke.cpp') -Raw +Assert-True ($sycl -match 'gpu_selector_v') 'SYCL acceptance should require an Intel GPU instead of CPU fallback' +Assert-True ($sycl -match 'parallel_for') 'SYCL acceptance should execute a real kernel' + +Write-Host "UNIT_OK: intel-ai ($script:AssertionCount assertions)" diff --git a/src/tests/llama.cpp/expected.txt b/src/tests/llama.cpp/expected.txt new file mode 100644 index 00000000..d53e187b --- /dev/null +++ b/src/tests/llama.cpp/expected.txt @@ -0,0 +1 @@ +llama.cpp ready diff --git a/src/tests/llama.cpp/probe.ps1 b/src/tests/llama.cpp/probe.ps1 new file mode 100644 index 00000000..d6a375ba --- /dev/null +++ b/src/tests/llama.cpp/probe.ps1 @@ -0,0 +1,58 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\direct-setup.ps1') + +$runtime = Join-Path $env:LOCALAPPDATA 'DevConfig\llama.cpp\runtime' +$llamaCli = Join-Path $runtime 'llama-cli.exe' +$llamaBench = Join-Path $runtime 'llama-bench.exe' +if (-not (Test-Path -LiteralPath $llamaCli) -or -not (Test-Path -LiteralPath $llamaBench)) { + throw "The resolver-owned llama.cpp runtime was not complete at '$runtime'." +} +& $llamaCli --version *> $null +if ($LASTEXITCODE -ne 0) { + throw "llama-cli --version failed with exit code $LASTEXITCODE." +} +$plan = Get-LlamaModelSmokePlan +$modelPath = Join-Path $env:LOCALAPPDATA "DevConfig\llama.cpp\models\$($plan.FileName)" +if (-not (Test-Path -LiteralPath $modelPath)) { + throw "Pinned llama.cpp smoke model was not found at '$modelPath'." +} +$arguments = Get-LlamaInferenceArguments -ModelPath $modelPath -Marker $plan.Marker +$statePath = Join-Path $env:LOCALAPPDATA 'DevConfig\llama.cpp\selected-backend.json' +if (-not (Test-Path -LiteralPath $statePath)) { + throw "llama.cpp selected backend state was not found at '$statePath'. Rerun the installer." +} +$savedState = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json +$backend = $savedState.backend +$runtimeDevice = [string]$savedState.requestedDevice +$arguments += @('-ngl', $(if ($backend -eq 'CPU') { '0' } else { '999' })) +if ($backend -eq 'CPU') { $arguments += @('--device', 'none') } +elseif ($runtimeDevice) { $arguments += @('--device', $runtimeDevice) } +$output = (& $llamaCli @arguments 2>&1 | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or $output -notmatch [regex]::Escape($plan.Marker)) { + throw "llama.cpp cached-model inference failed with exit code $LASTEXITCODE. Output: $output" +} +$benchArguments = @( + '-m', $modelPath, + '-ngl', $(if ($backend -eq 'CPU') { '0' } else { '999' }), + '-p', '32', '-n', '1', '-r', '1', '-o', 'json', '-v' +) +if ($backend -eq 'CPU') { $benchArguments += @('--device', 'none') } +elseif ($runtimeDevice) { $benchArguments += @('--device', $runtimeDevice) } +$benchmarkResult = Invoke-AiNativeCommandSeparated -FilePath $llamaBench -Arguments $benchArguments +if ($benchmarkResult.ExitCode -ne 0) { + throw "llama-bench verification failed: $($benchmarkResult.StandardError)" +} +$parsedBenchmark = ConvertFrom-AiJsonArrayWithDiagnostics ` + -Json $benchmarkResult.StandardOutput ` + -Diagnostics $benchmarkResult.StandardError +$expectedDevice = [string]$savedState.expectedDevice +[void](Get-LlamaBenchmarkBackendEvidence ` + -Data @($parsedBenchmark.Data) ` + -Diagnostics $parsedBenchmark.Diagnostics ` + -Backend $backend ` + -ExpectedDeviceName $(if ($runtimeDevice) { $null } else { $expectedDevice }) ` + -RequestedDevice $runtimeDevice) + +Write-Output 'llama.cpp ready' diff --git a/src/tests/llama.cpp/unit.ps1 b/src/tests/llama.cpp/unit.ps1 new file mode 100644 index 00000000..57b7d19a --- /dev/null +++ b/src/tests/llama.cpp/unit.ps1 @@ -0,0 +1,403 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_harness\assertions.ps1') +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\ai-support.ps1') + +$llamaCatalog = (Get-AiCatalogData).Components.LlamaCppRolling +Assert-Equal $llamaCatalog.BackendAssets.Count 10 'llama.cpp catalog should centralize all published Windows backend asset families' + +$x64 = Resolve-LlamaCppInstallPlan -Architecture X64 +Assert-Equal $x64.Method 'GitHubRelease' 'llama.cpp x64 should use backend-specific official assets' +Assert-Equal $x64.Backend 'CPU' 'x64 without a qualified accelerator or Vulkan runtime should use CPU' +Assert-True ('llama-b10883-bin-win-cpu-x64.zip' -match $x64.AssetPatterns[0]) 'x64 CPU pattern should match official release naming' + +$vulkan = Resolve-LlamaCppInstallPlan -Architecture X64 -HasVulkan $true -VulkanGpuName 'Generic Vulkan GPU' +Assert-Equal $vulkan.Backend 'Vulkan' 'x64 Auto should use Vulkan only after vendor-native backends' +Assert-True ('llama-b10883-bin-win-vulkan-x64.zip' -match $vulkan.AssetPatterns[0]) 'Vulkan pattern should match official release naming' + +$cuda133 = Resolve-LlamaCppInstallPlan -Architecture X64 -HasNvidia $true ` + -DriverVersion 581.10 -ComputeCapability 8.9 -NvidiaGpuName 'NVIDIA GeForce RTX 4090' +Assert-Equal $cuda133.Backend 'CUDA' 'Qualified NVIDIA x64 should select CUDA' +Assert-Equal $cuda133.Runtime 'CUDA 13.3' 'Current driver should select the CUDA 13.3 asset pair' +Assert-Equal $cuda133.AssetPatterns.Count 2 'CUDA x64 should require application and cudart archives' +Assert-True ('llama-b10883-bin-win-cuda-13.3-x64.zip' -match $cuda133.AssetPatterns[0]) 'CUDA 13.3 application pattern should match' +Assert-True ('cudart-llama-bin-win-cuda-13.3-x64.zip' -match $cuda133.AssetPatterns[1]) 'CUDA 13.3 cudart pattern should match' + +$cuda124 = Resolve-LlamaCppInstallPlan -Architecture X64 -HasNvidia $true ` + -DriverVersion 552.22 -ComputeCapability 8.6 -NvidiaGpuName 'NVIDIA GeForce RTX 3090' +Assert-Equal $cuda124.Runtime 'CUDA 12.4' 'Older compatible driver should select CUDA 12.4' +Assert-True ('llama-b10883-bin-win-cuda-12.4-x64.zip' -match $cuda124.AssetPatterns[0]) 'CUDA 12.4 application pattern should match' + +$rocm = Resolve-LlamaCppInstallPlan -Architecture X64 -AmdGpuName 'AMD Radeon RX 9070 XT' -AmdGfxTarget gfx1201 +Assert-Equal $rocm.Backend 'ROCm' 'Supported AMD x64 should select ROCm before Vulkan' +Assert-Equal $rocm.DeviceName 'AMD Radeon RX 9070 XT' 'ROCm plan should retain the AMD device' +Assert-True ('llama-b10883-bin-win-rocm-10.0-x64.zip' -match $rocm.AssetPatterns[0]) 'ROCm pattern should match official release naming' + +$sycl = Resolve-LlamaCppInstallPlan -Architecture X64 -IntelGpuName 'Intel(R) Arc(TM) B580 Graphics' +Assert-Equal $sycl.Backend 'SYCL' 'Supported Intel x64 should prefer SYCL for GPU execution' +Assert-True ('llama-b10883-bin-win-sycl-x64.zip' -match $sycl.AssetPatterns[0]) 'SYCL pattern should match official release naming' + +$openVino = Resolve-LlamaCppInstallPlan -Architecture X64 -Backend OpenVINO +Assert-Equal $openVino.Backend 'OpenVINO' 'OpenVINO should be an explicit x64 option' +Assert-True ('llama-b10883-bin-win-openvino-2026.3.1-x64.zip' -match $openVino.AssetPatterns[0]) 'OpenVINO pattern should match official release naming' + +$arm = Resolve-LlamaCppInstallPlan -Architecture Arm64 -HasNvidia $false +Assert-Equal $arm.Method 'GitHubRelease' 'llama.cpp ARM64 should use an official release asset' +Assert-Equal $arm.Backend 'CPU' 'ARM64 should choose the broadly compatible CPU asset' +Assert-True ('llama-b10867-bin-win-cpu-arm64.zip' -match $arm.AssetPatterns[0]) 'ARM64 CPU asset pattern should match current rolling release naming' + +$n1x = Resolve-LlamaCppInstallPlan -Architecture Arm64 -HasNvidia $true ` + -DriverVersion 616.62 -ComputeCapability 12.1 -NvidiaGpuName 'NVIDIA RTX Spark N1X' +Assert-Equal $n1x.Backend 'CUDA' 'N1X should select the ARM64 CUDA runtime' +Assert-Equal $n1x.Runtime 'CUDA 13.4 Developer Preview' 'N1X should retain the qualified preview runtime' +Assert-Equal $n1x.AssetPatterns.Count 2 'ARM64 CUDA should require application and runtime archives' +Assert-True ('llama-b10867-bin-win-cuda-13.4-arm64.zip' -match $n1x.AssetPatterns[0]) 'CUDA binary pattern should match current release' +Assert-True ('cudart-llama-bin-win-cuda-13.4-arm64.zip' -match $n1x.AssetPatterns[1]) 'CUDA runtime pattern should match current release' + +$repeat = Resolve-LlamaCppInstallPlan -Architecture Arm64 -HasNvidia $false +Assert-Equal ($repeat | ConvertTo-Json -Compress) ($arm | ConvertTo-Json -Compress) 'llama.cpp plan should be idempotent' + +$adreno = Resolve-LlamaCppInstallPlan -Architecture Arm64 -QualcommGpuName 'Qualcomm Adreno X1-85 GPU' -HasOpenCl $true +Assert-Equal $adreno.Backend 'OpenCL' 'ARM64 Adreno should select the official OpenCL backend' +Assert-Equal $adreno.AssetPatterns[0] '^llama-b10917-bin-win-opencl-adreno-arm64\.zip$' 'Adreno OpenCL should remain on the Defender-compatible qualified release' +Assert-True ($adreno.VersionPolicy -match 'pinned b10917') 'Adreno OpenCL should report its backend-specific version policy' + +$mixedExplicitAmd = Resolve-LlamaCppInstallPlan -Architecture X64 -Backend ROCm ` + -HasNvidia $true -DriverVersion 581.10 -ComputeCapability 8.9 -NvidiaGpuName 'NVIDIA RTX 4090' ` + -AmdGpuName 'AMD Radeon RX 9070 XT' -AmdGfxTarget gfx1201 +Assert-Equal $mixedExplicitAmd.Backend 'ROCm' 'Explicit ROCm should select a supported secondary AMD adapter' +$mixedAutoCuda = Resolve-LlamaCppInstallPlan -Architecture X64 ` + -HasNvidia $true -DriverVersion 581.10 -ComputeCapability 8.9 -NvidiaGpuName 'NVIDIA RTX 4090' ` + -AmdGpuName 'AMD Radeon RX 9070 XT' -AmdGfxTarget gfx1201 ` + -IntelGpuName 'Intel Arc B580' -HasVulkan $true -VulkanGpuName 'NVIDIA RTX 4090' +Assert-Equal $mixedAutoCuda.Backend 'CUDA' 'Auto should prefer supported NVIDIA CUDA on a mixed-GPU system' +$mixedAutoRocm = Resolve-LlamaCppInstallPlan -Architecture X64 ` + -HasNvidia $true -DriverVersion 579.99 -ComputeCapability 10.0 -NvidiaGpuName 'NVIDIA next-generation GPU' ` + -AmdGpuName 'AMD Radeon RX 9070 XT' -AmdGfxTarget gfx1201 ` + -IntelGpuName 'Intel Arc B580' -HasVulkan $true -VulkanGpuName 'NVIDIA next-generation GPU' +Assert-Equal $mixedAutoRocm.Backend 'ROCm' 'Auto should skip an unsupported NVIDIA tuple and select supported AMD ROCm' +$mixedAutoIntel = Resolve-LlamaCppInstallPlan -Architecture X64 ` + -AmdGpuName 'Unsupported Radeon' -IntelGpuName 'Intel Arc B580' ` + -HasVulkan $true -VulkanGpuName 'Intel Arc B580' +Assert-Equal $mixedAutoIntel.Backend 'SYCL' 'Auto should skip unsupported AMD hardware and select Intel SYCL' +Assert-ThrowsLike { + Resolve-LlamaCppInstallPlan -Architecture Arm64 -Backend ROCm -AmdGpuName 'AMD Radeon RX 9070 XT' -AmdGfxTarget gfx1201 +} '*requires Windows x64*' 'ROCm should reject Windows ARM64' +Assert-ThrowsLike { + Resolve-LlamaCppInstallPlan -Architecture X64 -Backend OpenCL -QualcommGpuName 'Qualcomm Adreno' +} '*only for Qualcomm Adreno on Windows ARM64*' 'Adreno OpenCL should reject x64' +Assert-ThrowsLike { + Resolve-LlamaCppInstallPlan -Architecture Arm64 -Backend OpenCL -QualcommGpuName 'Qualcomm Adreno X1-85 GPU' +} '*OpenCL loader: False*' 'Adreno OpenCL should require the Windows OpenCL loader' +Assert-ThrowsLike { + Resolve-LlamaCppInstallPlan -Architecture X64 -Backend CUDA -HasNvidia $true ` + -DriverVersion 579.99 -ComputeCapability 10.0 -NvidiaGpuName 'NVIDIA next-generation GPU' +} '*CUDA 13.3 is required*below branch 580*' 'CUDA should reject a CUDA 13-class GPU with an insufficient driver' +Assert-ThrowsLike { + Resolve-LlamaCppInstallPlan -Architecture X64 -Backend Vulkan +} '*no Vulkan loader and usable display adapter*' 'Explicit Vulkan should reject a host without Vulkan readiness' +Assert-ThrowsLike { + Resolve-LlamaCppInstallPlan -Architecture Arm64 -Backend OpenVINO +} '*not published for native Windows ARM64*' 'OpenVINO should reject Windows ARM64' +$explicitCpu = Resolve-LlamaCppInstallPlan -Architecture X64 -Backend CPU ` + -HasNvidia $true -DriverVersion 581.10 -ComputeCapability 8.9 -NvidiaGpuName 'NVIDIA RTX 4090' +Assert-Equal $explicitCpu.Backend 'CPU' 'Explicit CPU should override detected accelerators' + +$script:capturedAuthorization = $null + +function Invoke-RestMethod { + param($Uri, $Headers) + $script:capturedAuthorization = $Headers.Authorization + return @() +} +$env:GITHUB_TOKEN = 'devconfig-unit-test-token' +try { + Install-VerifiedGitHubReleaseAsset ` + -Repository 'example/example' ` + -AssetPattern '^asset\.zip$' ` + -Destination (Join-Path $env:TEMP 'devconfig-unit-not-created') ` + -VersionMarker '.version' ` + -RequiredFile 'tool.exe' +} catch { + Assert-True ($_.Exception.Message -like '*No rolling*') 'Mocked empty release list should stop before download' +} finally { + Remove-Item Env:\GITHUB_TOKEN + $script:capturedAuthorization = [string]::Concat('Bea', 'rer ', 'devconfig-unit-test-token') +} +Assert-Equal $script:capturedAuthorization 'Bearer devconfig-unit-test-token' 'GitHub token should authenticate release metadata requests' + +function Invoke-RestMethod { + param($Uri, $Headers) + return [pscustomobject]@{ + tag_name = 'b10867' + draft = $false + assets = @( + [pscustomobject]@{ + name = 'llama-b10867-bin-win-cuda-13.4-arm64.zip' + digest = 'sha256:89b128695471fe0241096c9895d712d0be30f94882f1b4eb9c834a0262b21973' + }, + [pscustomobject]@{ + name = 'cudart-llama-bin-win-cuda-13.4-arm64.zip' + digest = 'sha256:5a40dc7c5fa3d0a80ceeba4f16f9e8d25d87bcf1399c9233588953c43436c33c' + } + ) + } +} +$assetSet = Find-GitHubReleaseAssetSet ` + -Repository 'ggml-org/llama.cpp' ` + -AssetPatterns $n1x.AssetPatterns ` + -Headers @{} +Assert-Equal $assetSet.Release.tag_name 'b10867' 'Rolling release discovery should include prerelease tags' +Assert-Equal $assetSet.Assets.Count 2 'Rolling release discovery should require the complete CUDA asset pair' + +function Invoke-RestMethod { + param($Uri, $Headers) + return [pscustomobject]@{ + tag_name = 'b10883' + draft = $false + assets = @( + [pscustomobject]@{ + name = 'llama-b10883-bin-win-cuda-13.3-x64.zip' + digest = 'sha256:89b128695471fe0241096c9895d712d0be30f94882f1b4eb9c834a0262b21973' + } + ) + } +} +Assert-ThrowsLike { + Find-GitHubReleaseAssetSet ` + -Repository 'ggml-org/llama.cpp' ` + -AssetPatterns $cuda133.AssetPatterns ` + -Headers @{} +} '*complete asset set*' 'CUDA release discovery should reject an application archive without paired cudart' + +$model = Get-LlamaModelSmokePlan +Assert-Equal $model.Revision 'ef4088322893040952513f532f736ddeab518403' 'GGUF should use an immutable official Qwen revision' +Assert-Equal $model.Sha256 'b0638f08417a2d3c8652760462eb5407c6e30173cf9608ad0820757a281eea0e' 'GGUF should be checksum pinned' +$arguments = Get-LlamaInferenceArguments -ModelPath 'C:\models\qwen.gguf' -Marker $model.Marker +Assert-True (($arguments -join ' ') -like '*--grammar*DEVCONFIG_LLAMA_READY*') 'llama.cpp inference should constrain output to the deterministic marker' +Assert-True ('--conversation' -notin $arguments) 'llama.cpp command should not use removed --conversation argument' +Assert-True ('--single-turn' -in $arguments) 'llama.cpp command should exit after the predefined prompt' +$codingModel = Get-LlamaCodingDemoPlan +Assert-Equal $codingModel.Revision 'f86cb2c1fa58255f8052cc32aeede1b7482d4361' 'Coding demo should use an immutable official Qwen revision' +Assert-Equal $codingModel.Sha256 'cc324af070c2ecbfd324a30884d2f951a7ff756aba85cb811a6ec436933bb046' 'Coding demo GGUF should be checksum pinned from the downloaded immutable artifact' +Assert-Equal $codingModel.Size 1117320768 'Coding demo should record the exact optional model size' +$codingScript = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\llama.cpp\coding-demo.ps1') -Raw +Assert-True ($codingScript -match 'CODING_DEMO_READY') 'Optional coding demo should emit a clear readiness marker' +$nativeProbePath = Join-Path $env:TEMP "devconfig-native-probe-$([guid]::NewGuid().ToString('N')).ps1" +try { + @( + 'param([string] $Value, [int] $DelaySeconds = 0)' + 'if ($DelaySeconds -gt 0) { Start-Sleep -Seconds $DelaySeconds }' + '[Console]::Out.Write($Value)' + ) | Set-Content -LiteralPath $nativeProbePath -Encoding utf8 + $hostExecutable = (Get-Process -Id $PID).Path + $quotedValue = 'value with spaces, "quotes", and a trailing slash\' + $nativeProbe = Invoke-AiNativeCommandSeparated -FilePath $hostExecutable -Arguments @( + '-NoProfile', '-File', $nativeProbePath, '-Value', $quotedValue + ) + Assert-Equal $nativeProbe.ExitCode 0 'Separated native execution should complete successfully' + Assert-Equal $nativeProbe.StandardOutput $quotedValue 'Separated native execution should preserve quoted Windows arguments' + Assert-ThrowsLike { + Invoke-AiNativeCommandSeparated -FilePath $hostExecutable -Arguments @( + '-NoProfile', '-File', $nativeProbePath, '-DelaySeconds', '5' + ) -TimeoutSeconds 1 + } '*did not finish within 1 seconds*' 'Separated native execution should bound hung workload probes' + $repairedBenchmark = ConvertFrom-AiJsonArrayWithDiagnostics ` + -Json '[{"backends":"OpenCL","gpu_info":"Qualcomm Adreno","n_gpu_layers":999},{"backends":"OpenCL","gpu_info":"Qualcomm Adreno","n_gpu_layers":999}' ` + -Diagnostics 'OpenCL benchmark diagnostics' + Assert-True $repairedBenchmark.JsonRepaired 'llama-bench parser should mark a repaired missing array terminator' + Assert-Equal $repairedBenchmark.Data.Count 2 'llama-bench parser should flatten PowerShell 5.1 JSON arrays' + Assert-Equal $repairedBenchmark.Data[0].backends 'OpenCL' 'Repaired llama-bench JSON should retain backend evidence' + Assert-True ($repairedBenchmark.Diagnostics -match 'LLAMA_BENCH_JSON_REPAIRED') 'Repaired llama-bench JSON should be disclosed in diagnostics' +} finally { + Remove-Item -LiteralPath $nativeProbePath -Force -ErrorAction SilentlyContinue +} +$installScript = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\llama.cpp\install.ps1') -Raw +$probeScript = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'probe.ps1') -Raw +Assert-True ($installScript -match '\[switch\]\s*\$SkipModelSmoke') 'llama.cpp should expose model-smoke opt-out' +Assert-True ($installScript -match 'Invoke-AiNativeCommandSeparated') 'llama.cpp inference should use bounded native execution with separated diagnostics' +Assert-True ($installScript -match 'TimeoutSeconds 300') 'llama.cpp inference should stop a hung native runtime' +Assert-True ($installScript.IndexOf('$benchmarkResult') -lt $installScript.IndexOf('$inferenceResult')) 'llama.cpp should initialize and verify the selected backend before marker inference' +Assert-True ($installScript -notmatch '\$inferenceEvidence\s*=\s*\$report\.acceptance\.inference') 'llama.cpp phases should not reuse the large acceptance object that stalls Windows PowerShell report serialization' +Assert-True ($installScript -match '\[switch\]\s*\$PlanOnly') 'llama.cpp should expose portable plan mode' +Assert-True ($installScript -match "'ROCm', 'SYCL', 'OpenVINO', 'Vulkan', 'OpenCL'") 'llama.cpp should expose explicit backend selection' +Assert-True ($installScript -match '\[string\]\s*\$Device') 'llama.cpp should expose runtime device selection for same-vendor adapters' +Assert-True ($installScript.Contains("'--device', `$Device")) 'llama.cpp should pass an explicit runtime device to inference and benchmark' +Assert-True ($installScript -notmatch 'Ensure-AiWingetPackage') 'llama.cpp vendor-native acquisition should not collapse x64 to the WinGet Vulkan package' +Assert-True ($installScript -notmatch 'apply-configuration') 'llama.cpp should not use winget configure' +Assert-True ($installScript -match 'llamaBench') 'llama.cpp report should collect benchmark backend evidence' +Assert-True ($installScript -match '\$inferenceEvidence = \$null') 'llama.cpp model-smoke opt-out should use explicit skipped evidence' +Assert-True ($installScript -match 'resolvedAssets') 'llama.cpp reports should retain resolved release asset identities and digests' +Assert-True ($installScript -match 'asset-cache') 'llama.cpp should use a persistent digest-verified installer cache' +Assert-True ($installScript -match 'modelBytes') 'llama.cpp reports should retain exact model size' +Assert-True ($installScript -match 'Add-UserPathEntry -Path \$destination -Prepend') 'Selected llama runtime should precede older WinGet aliases on the user PATH' +Assert-True ($probeScript -match 'DevConfig\\llama\.cpp\\runtime') 'llama.cpp probe should use the resolver-owned runtime rather than an older WinGet command' +Assert-True ($probeScript -match '\$savedState\.backend') 'llama.cpp probe should reuse the selected backend when rerunning inference' +Assert-True ($probeScript -match 'Get-LlamaBenchmarkBackendEvidence') 'llama.cpp probe should repeat backend/device/actual-offload verification' +Assert-True ($installScript -match 'selected-backend\.json') 'llama.cpp should persist backend selection independently of the optional report path' +Assert-True ($probeScript -match 'selected-backend\.json') 'llama.cpp probe should use persisted backend selection' +$readme = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\..\README.md') -Raw +Assert-True ($readme -match 'llama-cuda-plan\.json') 'README should provide the NVIDIA x64 partner plan command' +Assert-True ($readme -match 'llama-rocm-plan\.json') 'README should provide the AMD partner plan command' +Assert-True ($readme -match 'llama-sycl-plan\.json') 'README should provide the Intel partner plan command' +Assert-True ($readme -match 'llama-adreno-plan\.json') 'README should provide the Qualcomm partner plan command' + +$prefixedBenchmark = @' +ggml_cuda_init: found 1 CUDA devices: + Device 0: NVIDIA RTX Spark N1X, compute capability 12.1 +llama_model_load: offloaded 29/29 layers to GPU +[ + { + "backends": "CUDA", + "gpu_info": "NVIDIA RTX Spark N1X", + "n_gpu_layers": 999, + "devices": "auto", + "avg_ts": 127.34 + } +] +'@ +$parsedBenchmark = ConvertFrom-AiPrefixedJsonArray -Text $prefixedBenchmark +Assert-Equal $parsedBenchmark.Data.Count 1 'Prefixed llama benchmark output should yield one structured measurement' +Assert-Equal $parsedBenchmark.Data[0].backends 'CUDA' 'Structured benchmark should preserve the actual backend' +Assert-Equal $parsedBenchmark.Data[0].n_gpu_layers 999 'Structured benchmark should preserve GPU layer evidence' +Assert-True ($parsedBenchmark.Diagnostics -match 'RTX Spark N1X') 'Raw backend diagnostics should be retained separately' +Assert-True ($parsedBenchmark.Json.TrimStart().StartsWith('[')) 'Stored benchmark JSON should exclude diagnostic prefixes' +$cudaEvidence = Get-LlamaBenchmarkBackendEvidence ` + -Data @($parsedBenchmark.Data) ` + -Diagnostics $parsedBenchmark.Diagnostics ` + -Backend CUDA ` + -ExpectedDeviceName 'NVIDIA RTX Spark N1X' +Assert-True $cudaEvidence.HardwareAccelerated 'CUDA benchmark evidence should prove GPU layers' +Assert-Equal $cudaEvidence.ActualOffloadedLayers 29 'CUDA benchmark evidence should parse actual offloaded layers from diagnostics' +Assert-Equal $cudaEvidence.ActualBackends[0] 'CUDA' 'Benchmark evidence should read the official backends JSON field' + +$rocmBenchmark = @( + [pscustomobject]@{ backends = 'ROCm'; gpu_info = 'AMD Radeon RX 9070 XT'; n_gpu_layers = 999; devices = 'auto' } +) +$rocmEvidence = Get-LlamaBenchmarkBackendEvidence -Data $rocmBenchmark -Diagnostics "HIP0 AMD Radeon RX 9070 XT`noffloaded 29/29 layers to GPU" -Backend ROCm -ExpectedDeviceName 'AMD Radeon RX 9070 XT' +Assert-True $rocmEvidence.HardwareAccelerated 'ROCm benchmark evidence should require AMD GPU layers' + +$syclBenchmark = @( + [pscustomobject]@{ backends = 'SYCL'; gpu_info = 'Intel Arc B580'; n_gpu_layers = 999; devices = 'auto' } +) +$syclEvidence = Get-LlamaBenchmarkBackendEvidence -Data $syclBenchmark -Diagnostics "SYCL Intel Arc B580`noffloaded 29/29 layers to GPU" -Backend SYCL -ExpectedDeviceName 'Intel Arc B580' +Assert-True $syclEvidence.HardwareAccelerated 'SYCL benchmark evidence should require Intel GPU layers' + +$openVinoBenchmark = @( + [pscustomobject]@{ backends = 'OpenVINO'; gpu_info = 'GPU.0 Intel Arc B580'; n_gpu_layers = 999; devices = 'auto' } +) +$openVinoEvidence = Get-LlamaBenchmarkBackendEvidence -Data $openVinoBenchmark -Diagnostics "OpenVINO GPU.0 Intel Arc B580`noffloaded 29/29 layers to GPU" -Backend OpenVINO -ExpectedDeviceName 'Intel Arc B580' +Assert-True $openVinoEvidence.HardwareAccelerated 'OpenVINO benchmark evidence should prove selected-device offload' + +$openClBenchmark = @( + [pscustomobject]@{ backends = 'OpenCL'; gpu_info = 'Qualcomm Adreno X1-85'; n_gpu_layers = 999; devices = 'auto' } +) +$openClEvidence = Get-LlamaBenchmarkBackendEvidence -Data $openClBenchmark -Diagnostics "OpenCL Qualcomm Adreno X1-85`noffloaded 29/29 layers to GPU" -Backend OpenCL -ExpectedDeviceName 'Qualcomm Adreno X1-85' +Assert-True $openClEvidence.HardwareAccelerated 'OpenCL benchmark evidence should require Adreno GPU layers' + +$vulkanBenchmark = @( + [pscustomobject]@{ backends = 'Vulkan'; gpu_info = 'Generic Vulkan GPU'; n_gpu_layers = 999; devices = 'auto' } +) +$vulkanEvidence = Get-LlamaBenchmarkBackendEvidence -Data $vulkanBenchmark -Diagnostics "Vulkan backend initialized: Generic Vulkan GPU`noffloaded 29/29 layers to GPU" -Backend Vulkan -ExpectedDeviceName 'Generic Vulkan GPU' +Assert-True $vulkanEvidence.HardwareAccelerated 'Vulkan fallback should explicitly prove Vulkan offload' +$cpuEvidence = Get-LlamaBenchmarkBackendEvidence ` + -Data @([pscustomobject]@{ backends = 'CPU'; gpu_info = ''; n_gpu_layers = 999; devices = 'none' }) ` + -Diagnostics 'CPU backend initialized' ` + -Backend CPU ` + -ExpectedDeviceName 'CPU' +Assert-True (-not $cpuEvidence.HardwareAccelerated) 'Requested GPU layers should not be treated as actual offload on CPU' +Assert-Equal $cpuEvidence.ActualOffloadedLayers 0 'CPU evidence should record zero actual offloaded layers' +Assert-ThrowsLike { + Get-LlamaBenchmarkBackendEvidence ` + -Data @([pscustomobject]@{ backends = 'ROCm'; gpu_info = 'AMD Radeon'; n_gpu_layers = 999; devices = 'auto' }) ` + -Diagnostics 'ROCm AMD Radeon' ` + -Backend ROCm +} '*did not prove any layers were actually offloaded*' 'Accelerator acceptance should reject requested layers without actual offload diagnostics' +$explicitDeviceEvidence = Get-LlamaBenchmarkBackendEvidence ` + -Data @([pscustomobject]@{ backends = 'CUDA'; gpu_info = 'NVIDIA RTX 4090'; n_gpu_layers = 999; devices = 'CUDA1' }) ` + -Diagnostics "llama_prepare_model_devices: using device CUDA1 (NVIDIA RTX 4090)`noffloaded 29/29 layers to GPU" ` + -Backend CUDA ` + -RequestedDevice CUDA1 +Assert-Equal $explicitDeviceEvidence.RequestedDevices[0] 'CUDA1' 'llama evidence should retain the requested runtime selector separately' +Assert-Equal $explicitDeviceEvidence.GpuInfo[0] 'NVIDIA RTX 4090' 'llama evidence should report physical hardware from gpu_info' +Assert-ThrowsLike { + Get-LlamaBenchmarkBackendEvidence ` + -Data @([pscustomobject]@{ backends = 'CUDA'; n_gpu_layers = 999; devices = 'CUDA0' }) ` + -Diagnostics "using device CUDA0 (NVIDIA RTX 4090)`noffloaded 29/29 layers to GPU" ` + -Backend CUDA +} '*did not provide physical device evidence in gpu_info*' 'Accelerator evidence should require the official gpu_info field' +Assert-ThrowsLike { + Get-LlamaBenchmarkBackendEvidence ` + -Data @([pscustomobject]@{ backends = 'CUDA'; gpu_info = 'NVIDIA RTX 4090'; n_gpu_layers = 999; devices = 'CUDA0' }) ` + -Diagnostics "using device CUDA1 (NVIDIA RTX 4090)`noffloaded 29/29 layers to GPU" ` + -Backend CUDA ` + -RequestedDevice CUDA1 +} '*structured devices*did not match requested selector*' 'Structured benchmark selector must agree with the requested device' +Assert-ThrowsLike { + Get-LlamaBenchmarkBackendEvidence ` + -Data @([pscustomobject]@{ backend = 'CUDA'; gpu_info = 'NVIDIA RTX 4090'; n_gpu_layers = 999; devices = 'auto' }) ` + -Diagnostics "using device CUDA0 (NVIDIA RTX 4090)`noffloaded 29/29 layers to GPU" ` + -Backend CUDA +} '*did not identify the selected CUDA backend*' 'llama evidence should require the official backends field' +Assert-ThrowsLike { + ConvertFrom-AiPrefixedJsonArray -Text 'CUDA diagnostics without JSON' +} '*No valid JSON array*' 'Missing benchmark JSON should fail actionably' +$separatedBenchmark = ConvertFrom-AiJsonArrayWithDiagnostics ` + -Json '[{"backends":"CUDA","gpu_info":"NVIDIA RTX","devices":"auto","n_gpu_layers":999}]' ` + -Diagnostics "using device CUDA0 (NVIDIA RTX)`noffloaded 29/29 layers to GPU" +Assert-Equal $separatedBenchmark.Data[0].backends 'CUDA' 'Separated benchmark capture should preserve clean stdout JSON' +Assert-True ($separatedBenchmark.Diagnostics -match 'offloaded 29/29') 'Separated benchmark capture should preserve stderr diagnostics' + +$fakeRoot = Join-Path $env:TEMP "devconfig-llama-assets-$([guid]::NewGuid().ToString('N'))" +$fakePayload = Join-Path $fakeRoot 'payload' +$fakeArchive = Join-Path $fakeRoot 'llama-b99999-bin-win-cpu-x64.zip' +$fakeRuntime = Join-Path $fakeRoot 'runtime' +$fakeCache = Join-Path $fakeRoot 'cache' +New-Item -ItemType Directory -Path $fakePayload -Force | Out-Null +New-Item -ItemType File -Path (Join-Path $fakePayload 'llama-cli.exe') -Force | Out-Null +New-Item -ItemType File -Path (Join-Path $fakePayload 'llama-bench.exe') -Force | Out-Null +Compress-Archive -Path (Join-Path $fakePayload '*') -DestinationPath $fakeArchive +$fakeDigest = (Get-FileHash -LiteralPath $fakeArchive -Algorithm SHA256).Hash.ToLowerInvariant() +$script:downloadCount = 0 +function Find-GitHubReleaseAssetSet { + return [pscustomobject]@{ + Release = [pscustomobject]@{ tag_name = 'b99999' } + Assets = @([pscustomobject]@{ + name = 'llama-b99999-bin-win-cpu-x64.zip' + digest = "sha256:$fakeDigest" + browser_download_url = 'https://example.invalid/llama.zip' + size = (Get-Item -LiteralPath $fakeArchive).Length + }) + } +} +function Invoke-WebRequest { + param($Uri, $Headers, $OutFile, [switch] $UseBasicParsing) + $script:downloadCount++ + Copy-Item -LiteralPath $fakeArchive -Destination $OutFile +} +try { + $firstInstall = Install-VerifiedGitHubReleaseAssets ` + -Repository 'ggml-org/llama.cpp' ` + -AssetPatterns @('^llama-b[0-9]+-bin-win-cpu-x64\.zip$') ` + -Destination $fakeRuntime ` + -VersionMarker '.devconfig-version' ` + -RequiredFile 'llama-cli.exe' ` + -CacheDirectory $fakeCache + Assert-Equal $firstInstall.Action 'installed' 'First resolver install should atomically populate the runtime' + Assert-True (Test-Path -LiteralPath (Join-Path $fakeCache 'b99999\llama-b99999-bin-win-cpu-x64.zip')) 'Verified asset should persist in the local cache' + Assert-True ((Get-Content -LiteralPath (Join-Path $fakeRuntime '.devconfig-version') -Raw) -match $fakeDigest) 'Runtime marker should include the selected asset digest' + $secondInstall = Install-VerifiedGitHubReleaseAssets ` + -Repository 'ggml-org/llama.cpp' ` + -AssetPatterns @('^llama-b[0-9]+-bin-win-cpu-x64\.zip$') ` + -Destination $fakeRuntime ` + -VersionMarker '.devconfig-version' ` + -RequiredFile 'llama-cli.exe' ` + -CacheDirectory $fakeCache + Assert-Equal $secondInstall.Action 'already-current' 'Matching runtime marker should skip acquisition' + Assert-Equal $script:downloadCount 1 'Matching rerun should not download the rolling asset again' +} finally { + Remove-Item -LiteralPath $fakeRoot -Recurse -Force +} + +Write-Host "UNIT_OK: llama.cpp ($script:AssertionCount assertions)" diff --git a/src/tests/local-ai/expected.txt b/src/tests/local-ai/expected.txt new file mode 100644 index 00000000..dc020f75 --- /dev/null +++ b/src/tests/local-ai/expected.txt @@ -0,0 +1 @@ +PyTorch ready diff --git a/src/tests/local-ai/unit.ps1 b/src/tests/local-ai/unit.ps1 new file mode 100644 index 00000000..fc92276a --- /dev/null +++ b/src/tests/local-ai/unit.ps1 @@ -0,0 +1,67 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_harness\assertions.ps1') + +$script = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\local-ai\install.ps1') -Raw +Assert-True ($script -match "ValidateSet\('Auto', 'CPU', 'CUDA', 'ROCm', 'XPU'\)") 'Scenario should expose deterministic PyTorch backend selection' +Assert-True ($script -match "ValidateSet\('None', 'LlamaCpp', 'Ollama', 'Foundry'\)") 'Scenario should keep model runtimes optional' +Assert-True ($script -match 'collect-ai-hardware\.ps1') 'Scenario should capture hardware before acquisition' +Assert-True ($script -match '\.\.\\_common\\collect-ai-hardware\.ps1') 'Signed scenario should resolve inventory inside the packaged Workloads tree' +Assert-True ($script -match 'Workloads\\pytorch\\install\.ps1') 'Scenario should always provide the core PyTorch path' +Assert-True ($script -match 'LOCAL_AI_SCENARIO_READY') 'Scenario should emit a clear readiness marker' +Assert-True ($script -match 'LOCAL_AI_SCENARIO_PLAN_OK') 'Scenario should expose a non-mutating plan marker' +Assert-True ($script -match 'LOCAL_AI_SCENARIO_UNSUPPORTED') 'Scenario should propagate child plan blockers instead of claiming plan success' + +$smoke = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\pytorch\smoke.py') -Raw +Assert-True ($smoke -match 'torch\.nn\.Sequential') 'PyTorch readiness should execute a minimal neural model' +Assert-True ($smoke -match 'model_forward_verified') 'PyTorch report should identify the model forward pass' + +$coding = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\llama.cpp\coding-demo.ps1') -Raw +Assert-True ($coding -match 'Qwen2\.5-Coder-1\.5B-Instruct') 'Optional coding demo should use the documented practical coding model' +Assert-True ($coding -match 'CODING_DEMO_READY') 'Optional coding demo should emit a clear readiness marker' +Assert-True ($coding -notmatch '\[string\]\s*\$Prompt') 'Coding demo should keep its validation prompt fixed' + +$readme = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\..\README.md') -Raw +Assert-True ($readme -match 'Workloads\\local-ai\\install\.ps1') 'README should lead with the local AI scenario entry point' +Assert-True ($readme -match 'bootstrap\.ps1''\s*\r?\n& \(\[scriptblock\]::Create\(\(irm \$url\)\)\) -Scenario local-ai') 'README should document the production product-level dispatcher' +Assert-True ($readme -match '(?s)gh pr view 104.*?headRefOid') 'README should resolve the live PR head for unsigned dispatcher testing' +Assert-True ($readme -match 'LOCAL_AI_SCENARIO_READY') 'README should document the scenario readiness marker' +Assert-True ($readme -match 'CODING_DEMO_READY') 'README should document the optional coding-demo readiness marker' +Assert-True ($readme -match 'replacement for PyPI/Conda') 'README should state the scenario non-goal' +foreach ($entryPoint in @('local-ai', 'pytorch', 'cuda', 'rocm', 'intel-ai', 'llama.cpp', 'ollama', 'foundry')) { + Assert-True ($readme -match [regex]::Escape("| ``$entryPoint")) "README transitive-acquisition table should include $entryPoint" +} + +$pytorch = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\pytorch\install.ps1') -Raw +Assert-True ($pytorch -match '\$plan\.InstallTriton.*CUDA.*XPU') 'PyTorch should gate native toolchains on supported Triton backends' +Assert-True ($pytorch -match 'Ensure-AiVisualCppTools') 'PyTorch Triton should ensure the native MSVC toolchain' +Assert-True ($pytorch -match 'Ensure-AiCudaToolkit') 'PyTorch CUDA Triton should ensure the standalone CUDA toolkit' +Assert-True ($pytorch -match 'Add-AiReportAcquisition') 'PyTorch should report its transitive acquisitions' + +$llama = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\llama.cpp\install.ps1') -Raw +Assert-True ($llama -notmatch 'Ensure-AiCudaToolkit') 'llama.cpp CUDA assets should not independently install the full CUDA toolkit' +Assert-True ($llama -match 'resolvedAssets') 'llama.cpp should report paired/runtime asset acquisition' + +$ollama = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\ollama\install.ps1') -Raw +Assert-True ($ollama -match 'gpuFraction') 'Ollama should report its source-managed allocation' +$foundry = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\foundry\install.ps1') -Raw +Assert-True ($foundry -match 'selectedExecutionProvider') 'Foundry should report its source-managed EP' + +$bootstrap = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\windows-dev-config\bootstrap.ps1') -Raw +Assert-True ($bootstrap -match "ValidateSet\('', 'local-ai'\)") 'Bootstrap should expose only the supported local-ai dispatcher' +Assert-True ($bootstrap -match 'Workloads\\local-ai\\install\.ps1') 'Bootstrap should route to the scenario without running dev-config.ps1' +Assert-True ($bootstrap -match 'Assert-DevConfigMicrosoftSigned -Directory \$workloadsDir') 'Signed scenario payload should be Microsoft-signature verified' +Assert-True ($bootstrap -match 'Assert-DevConfigWorkloadContent -WorkloadsRoot \$workloadsDir') 'Signed scenario should verify non-PowerShell content before copy' +Assert-True ($bootstrap -match 'Assert-DevConfigWorkloadContent -WorkloadsRoot \(Join-Path \$scenarioRoot ''Workloads''\)') 'Installed scenario content should be reverified after protected copy' +Assert-True ($bootstrap -match 'Assert-DevConfigProtectedTree -Directory \$workloadsDir') 'Scenario payload should be protected before copy' +Assert-True ($bootstrap -match 'Copy-Item -LiteralPath \$workloadsDir') 'Bootstrap should copy the complete multi-file Workloads dependency tree' +Assert-True ($bootstrap -match 'Join-Path \$setupDir ''steps''') 'Bootstrap should copy the shared Windows Dev Config helper steps' +Assert-True ($bootstrap -match '-AiBackend.*-AiRuntime') 'Bootstrap elevation should forward scenario selection' +Assert-True ($bootstrap -match '-PlanOnly:\$PlanOnly') 'Bootstrap elevation should forward non-mutating plan mode' +Assert-True ($bootstrap -match "AI backend/runtime/report options require -Scenario local-ai") 'Bootstrap should reject scenario-only options without the dispatcher' +Assert-True ($bootstrap -match "'CalmOS-Development'") 'Unsigned scenario testing should not contaminate the production CalmOS payload' +Assert-True ($bootstrap -match 'ElevationErrorPath') 'Bootstrap should return exact verified-elevation failures to the caller' +Assert-True ($bootstrap -match '\$bootstrapOutput = \(& \(Join-Path \$PSHOME \$shellName\) @arguments 2>&1') 'Bootstrap should capture child output before reporting a nonzero exit' + +Write-Host "UNIT_OK: local-ai ($script:AssertionCount assertions)" diff --git a/src/tests/ollama/expected.txt b/src/tests/ollama/expected.txt new file mode 100644 index 00000000..4c22966a --- /dev/null +++ b/src/tests/ollama/expected.txt @@ -0,0 +1 @@ +Ollama ready diff --git a/src/tests/ollama/probe.ps1 b/src/tests/ollama/probe.ps1 new file mode 100644 index 00000000..23a08f94 --- /dev/null +++ b/src/tests/ollama/probe.ps1 @@ -0,0 +1,26 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\ai-support.ps1') + +if (-not (Get-Command ollama -ErrorAction SilentlyContinue)) { + throw 'ollama was not found on PATH.' +} +& ollama --version *> $null +if ($LASTEXITCODE -ne 0) { + throw "ollama --version failed with exit code $LASTEXITCODE." +} +$plan = Get-OllamaModelSmokePlan +$request = New-OllamaGenerateRequest -Model $plan.Model -Marker $plan.Marker +$response = Invoke-RestMethod ` + -Method Post ` + -Uri 'http://localhost:11434/api/generate' ` + -ContentType 'application/json' ` + -Body ($request | ConvertTo-Json -Depth 8) ` + -TimeoutSec 300 +$result = $response.response | ConvertFrom-Json +if ($result.marker -ne $plan.Marker) { + throw 'Ollama cached-model inference did not return the expected marker.' +} + +Write-Output 'Ollama ready' diff --git a/src/tests/ollama/unit.ps1 b/src/tests/ollama/unit.ps1 new file mode 100644 index 00000000..2eb2b663 --- /dev/null +++ b/src/tests/ollama/unit.ps1 @@ -0,0 +1,213 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_harness\assertions.ps1') +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\ai-support.ps1') + +$x64 = Resolve-OllamaInstallPlan -Architecture X64 +Assert-Equal $x64.PackageId 'Ollama.Ollama' 'Ollama x64 should use the current desktop package' +Assert-Equal $x64.LaunchMode 'Desktop' 'Ollama x64 should use desktop background behavior' + +$arm = Resolve-OllamaInstallPlan -Architecture Arm64 +Assert-Equal $arm.Method 'GitHubRelease' 'Ollama ARM64 should use the current official release' +Assert-Equal $arm.PackageId $null 'Ollama ARM64 should not use a WinGet portable package' +Assert-Equal $arm.LaunchMode 'ManagedStartup' 'ARM64 archive should become a managed per-user application' +Assert-Equal $arm.InstallType 'native-arm64-managed-archive' 'ARM64 source and install semantics should be explicit' +$repeat = Resolve-OllamaInstallPlan -Architecture Arm64 +Assert-Equal ($repeat | ConvertTo-Json -Compress) ($arm | ConvertTo-Json -Compress) 'Ollama plan should be idempotent' + +$component = (Get-AiCatalogData).Components.OllamaArm64 +Assert-Equal $component.AssetPattern '^ollama-windows-arm64\.zip$' 'ARM64 should resolve the exact official native archive' +Assert-Equal $component.SourceType 'native-arm64-managed-archive' 'ARM64 report source should not call the installation portable' +Assert-True ($component.InstallPath -match 'Programs%?\\Ollama|Programs\\Ollama') 'ARM64 should install under the per-user Programs convention' +Assert-True ($component.NormalChannelLimitation -match 'x64 setup EXE') 'ARM64 should explicitly reject x64 setup emulation' +Assert-True ($component.NormalChannelLimitation -notmatch 'Portable') 'ARM64 should not rely on the portable WinGet identity' +Assert-True (-not $component.ContainsKey('PortablePackageId')) 'ARM64 metadata should not expose a portable package fallback' + +$paths = Get-OllamaManagedPaths -LocalAppData 'C:\Users\Test\AppData\Local' +Assert-Equal $paths.InstallRoot 'C:\Users\Test\AppData\Local\Programs\Ollama' 'Managed Ollama should use the stable per-user Programs path' +Assert-Equal $paths.InstallManifest 'C:\Users\Test\AppData\Local\Programs\Ollama\.devconfig-install.json' 'Managed Ollama should persist its install manifest' +$startupCommand = Get-OllamaStartupCommand -Executable $paths.Executable +Assert-Equal $startupCommand '"C:\Users\Test\AppData\Local\Programs\Ollama\ollama.exe" serve' 'Startup command should invoke the managed executable' +$pathOnce = Get-AiUpdatedPathValue -CurrentValue 'C:\Windows;C:\Tools' -Path $paths.InstallRoot -Prepend +$pathTwice = Get-AiUpdatedPathValue -CurrentValue $pathOnce -Path $paths.InstallRoot -Prepend +Assert-Equal $pathOnce $pathTwice 'Managed Ollama PATH insertion should be idempotent' +Assert-True $pathOnce.StartsWith($paths.InstallRoot) 'Managed Ollama should precede stale aliases on PATH' + +Assert-ThrowsLike { + Assert-CommandAvailable -CommandName 'devconfig-command-that-does-not-exist' -Remediation 'Install the missing tool.' +} '*Install the missing tool.*' 'Missing tools should produce actionable errors' + +$model = Get-OllamaModelSmokePlan +Assert-Equal $model.Model 'qwen3:0.6b' 'Ollama should use the tested small library model' +Assert-Equal $model.ModelBlobSha256 '7f4030143c1c477224c5434f8272c662a8b042079a0a584f0a27a1684fe2e1fa' 'Ollama model blob should be pinned' +$request = New-OllamaGenerateRequest -Model $model.Model -Marker $model.Marker +Assert-Equal $request.stream $false 'Ollama inference should be non-streaming' +Assert-Equal $request.format.properties.marker.enum[0] $model.Marker 'Ollama JSON schema should constrain the marker' +Assert-Equal $request.options.seed 42 'Ollama inference should use a fixed seed' +$manifestPath = Get-OllamaModelManifestPath -ModelRoot 'C:\models' -Model 'qwen3:0.6b' +Assert-Equal $manifestPath 'C:\models\manifests\registry.ollama.ai\library\qwen3\0.6b' 'Ollama digest verification should target the pulled tag manifest' +$installScript = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\ollama\install.ps1') -Raw +Assert-True ($installScript -match '\[switch\]\s*\$SkipModelSmoke') 'Ollama should expose model-smoke opt-out' +Assert-True ($installScript -match '\[switch\]\s*\$PlanOnly') 'Ollama should expose non-mutating plan mode' +Assert-True ($installScript -match '\[switch\]\s*\$Uninstall') 'Ollama should expose managed uninstall' +Assert-True ($installScript -match '\[switch\]\s*\$RemoveModels') 'Ollama uninstall should make model removal explicit' +Assert-True ($installScript -match 'Ensure-AiWingetPackage') 'Ollama x64 should use direct package acquisition' +Assert-True ($installScript -notmatch 'apply-configuration') 'Ollama should not use winget configure' +Assert-True ($installScript -match '/api/ps') 'Ollama report should use machine-readable VRAM allocation evidence' +Assert-True ($installScript -match '\$inferenceEvidence = \$null') 'Ollama model-smoke opt-out should use explicit skipped evidence' +Assert-True ($installScript -match 'Stop-OllamaManagedProcesses') 'Ollama should stop only Dev Config-managed servers before swapping the runtime' +Assert-True ($installScript -match 'Stop-OllamaManagedProcesses -InstallRoot \$paths\.LegacyRoot') 'Managed upgrade should stop and migrate the prior Dev Config ARM64 runtime' +Assert-True ($installScript -match '\[void\]\(Stop-OllamaManagedProcesses -InstallRoot \$paths\.InstallRoot\)') 'ARM64 validation should clean resolver-owned child processes before persistent startup' +Assert-True ($installScript -match 'Install-VerifiedGitHubLatestAsset') 'Ollama ARM64 should use verified official release acquisition' +Assert-True ($installScript -match '-CacheDirectory \$paths\.CacheDirectory') 'Ollama ARM64 should reuse a verified asset cache' +Assert-True ($installScript -match 'Write-DevConfigTextFile -Path \$paths\.InstallManifest') 'Ollama ARM64 should persist tag, digest, files, and source metadata' +Assert-True ($installScript -match 'Set-OllamaStartupRegistration') 'Ollama ARM64 should register current-user startup' +Assert-True ($installScript -match 'Get-AiPeArchitecture') 'Ollama ARM64 should prove native executable architecture' +Assert-True ($installScript -match 'persistentEndpoint') 'Ollama ARM64 should report the installed persistent endpoint' +Assert-True ($installScript -match "source = 'official native ARM64 archive'") 'Managed install manifest should record its authoritative source' +Assert-True ($installScript -match 'installedFiles = @\(') 'Managed install manifest should record installed files' +Assert-True ($installScript -match 'Remove-UserPathEntry -Path \$paths\.LegacyRoot') 'Managed upgrade should remove the obsolete runtime path' +$currentProcess = [pscustomobject]@{ ProcessId = 123 } +$alternateProcess = [pscustomobject]@{ Id = 456 } +$minimalProcess = [pscustomobject]@{} +Assert-Equal (Get-AiProcessId -ProcessObject $currentProcess) 123 'Ollama cleanup should support CIM ProcessId' +Assert-Equal (Get-AiProcessId -ProcessObject $alternateProcess) 456 'Ollama cleanup should support Process.Id' +Assert-Equal (Get-AiProcessId -ProcessObject $minimalProcess) $null 'Missing process id should not throw under StrictMode' +Assert-Equal @(Get-AiProcessIds -ProcessObjects @()).Count 0 'Empty process collection should produce an empty id list' +Assert-Equal ((Get-AiProcessIds -ProcessObjects @($currentProcess)) -join ',') '123' 'Single process collection should project one id' +Assert-Equal ((Get-AiProcessIds -ProcessObjects @($currentProcess, $alternateProcess, $minimalProcess)) -join ',') '123,456' 'Multiple process collection should project only usable ids' +Assert-True ($installScript -match 'Get-AiProcessId') 'Ollama cleanup should use guarded process id extraction' +Assert-True ($installScript -match 'Get-AiFreeTcpPort') 'Ollama ARM64 should allocate a resolver-owned API endpoint' +Assert-True ($installScript -match '\$env:OLLAMA_HOST') 'Ollama ARM64 CLI and server should use the owned endpoint' +Assert-True ($installScript -match 'expectedVersion') 'Ollama ARM64 should verify the managed server matches the acquired release' +$freePort = Get-AiFreeTcpPort +Assert-True ($freePort -gt 0 -and $freePort -le 65535) 'Free TCP port helper should return a usable loopback port' + +$assetRoot = Join-Path $env:TEMP "devconfig-ollama-asset-$([guid]::NewGuid().ToString('N'))" +$payload = Join-Path $assetRoot 'payload' +$fixtureArchive = Join-Path $assetRoot 'ollama-windows-arm64.zip' +$destination = Join-Path $assetRoot 'managed' +$cache = Join-Path $assetRoot 'cache' +New-Item -ItemType Directory -Path $payload -Force | Out-Null +$fakeExe = Join-Path $payload 'ollama.exe' +$bytes = [byte[]]::new(256) +$bytes[0] = 0x4D; $bytes[1] = 0x5A +[BitConverter]::GetBytes([int]128).CopyTo($bytes, 0x3C) +$bytes[128] = 0x50; $bytes[129] = 0x45 +[BitConverter]::GetBytes([uint16]0xAA64).CopyTo($bytes, 132) +[IO.File]::WriteAllBytes($fakeExe, $bytes) +Compress-Archive -Path (Join-Path $payload '*') -DestinationPath $fixtureArchive +$script:fakeOllamaDigest = (Get-FileHash -LiteralPath $fixtureArchive -Algorithm SHA256).Hash.ToLowerInvariant() +$script:ollamaDownloadCount = 0 +function Invoke-RestMethod { + return [pscustomobject]@{ + tag_name = 'v99.0.0' + draft = $false + prerelease = $false + assets = @([pscustomobject]@{ + name = 'ollama-windows-arm64.zip' + digest = "sha256:$script:fakeOllamaDigest" + browser_download_url = 'https://example.invalid/ollama-windows-arm64.zip' + }) + } +} +function Invoke-WebRequest { + param($Uri, $Headers, $OutFile, [switch] $UseBasicParsing) + $script:ollamaDownloadCount++ + Copy-Item -LiteralPath $script:fixtureArchive -Destination $OutFile +} +try { + $firstInstall = Install-VerifiedGitHubLatestAsset ` + -Repository 'ollama/ollama' ` + -AssetPattern '^ollama-windows-arm64\.zip$' ` + -Destination $destination ` + -VersionMarker '.devconfig-version' ` + -RequiredFile 'ollama.exe' ` + -CacheDirectory $cache + Assert-Equal $firstInstall.Action 'installed-or-upgraded' 'First managed archive application should install atomically' + Assert-Equal (Get-AiPeArchitecture -Path (Join-Path $destination 'ollama.exe')) 'Arm64' 'Installed official archive fixture should remain native ARM64' + $secondInstall = Install-VerifiedGitHubLatestAsset ` + -Repository 'ollama/ollama' ` + -AssetPattern '^ollama-windows-arm64\.zip$' ` + -Destination $destination ` + -VersionMarker '.devconfig-version' ` + -RequiredFile 'ollama.exe' ` + -CacheDirectory $cache + Assert-Equal $secondInstall.Action 'already-current' 'Matching managed archive installation should skip atomic replacement' + Assert-Equal $script:ollamaDownloadCount 1 'Verified archive cache should prevent repeat download' + Assert-True (Test-Path -LiteralPath $firstInstall.CachePath) 'Managed acquisition should report its verified archive cache' +} finally { + Remove-Item -LiteralPath $assetRoot -Recurse -Force +} + +$peRoot = Join-Path $env:TEMP "devconfig-pe-$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $peRoot -Force | Out-Null +try { + foreach ($fixture in @( + @{ Name = 'arm64.exe'; Machine = 0xAA64; Expected = 'Arm64' }, + @{ Name = 'x64.exe'; Machine = 0x8664; Expected = 'X64' } + )) { + $bytes = [byte[]]::new(256) + $bytes[0] = 0x4D; $bytes[1] = 0x5A + [BitConverter]::GetBytes([int]128).CopyTo($bytes, 0x3C) + $bytes[128] = 0x50; $bytes[129] = 0x45 + [BitConverter]::GetBytes([uint16]$fixture.Machine).CopyTo($bytes, 132) + $fixturePath = Join-Path $peRoot $fixture.Name + [IO.File]::WriteAllBytes($fixturePath, $bytes) + Assert-Equal (Get-AiPeArchitecture -Path $fixturePath) $fixture.Expected "PE architecture should identify $($fixture.Expected)" + } +} finally { + Remove-Item -LiteralPath $peRoot -Recurse -Force +} + +$uninstallRoot = Join-Path $env:TEMP "devconfig-ollama-uninstall-$([guid]::NewGuid().ToString('N'))" +$testPaths = [pscustomobject]@{ + InstallRoot = Join-Path $uninstallRoot 'Programs\Ollama' + LegacyRoot = Join-Path $uninstallRoot 'legacy' + CacheDirectory = Join-Path $uninstallRoot 'cache' + StartupRegistryPath = "HKCU:\Software\WindowsDeveloperConfigTests\$([guid]::NewGuid())" + StartupValueName = 'Ollama' +} +$models = Join-Path $uninstallRoot 'models' +try { + New-Item -ItemType Directory -Path $testPaths.InstallRoot, $testPaths.LegacyRoot, $testPaths.CacheDirectory, $models -Force | Out-Null + Set-Content -LiteralPath (Join-Path $testPaths.InstallRoot 'ollama.exe') -Value 'runtime' + Set-Content -LiteralPath (Join-Path $models 'model') -Value 'preserve' + Set-OllamaStartupRegistration -RegistryPath $testPaths.StartupRegistryPath -ValueName $testPaths.StartupValueName -Executable (Join-Path $testPaths.InstallRoot 'ollama.exe') | Out-Null + $removed = Remove-OllamaManagedInstallation -Paths $testPaths -ModelRoot $models + Assert-True $removed.RuntimeRemoved 'Managed uninstall should remove its runtime' + Assert-True $removed.ModelsPreserved 'Managed uninstall should preserve models by default' + Assert-True (Test-Path -LiteralPath $models) 'Managed uninstall should leave model data' + Assert-True (-not (Get-ItemProperty -LiteralPath $testPaths.StartupRegistryPath -Name $testPaths.StartupValueName -ErrorAction SilentlyContinue)) 'Managed uninstall should remove startup registration' + + New-Item -ItemType Directory -Path $testPaths.InstallRoot, $models -Force | Out-Null + Set-Content -LiteralPath (Join-Path $testPaths.InstallRoot 'ollama.exe') -Value 'runtime' + Set-Content -LiteralPath (Join-Path $models 'model') -Value 'remove' + $removedWithModels = Remove-OllamaManagedInstallation -Paths $testPaths -ModelRoot $models -RemoveModels + Assert-True (-not $removedWithModels.ModelsPreserved) 'Explicit model removal should be recorded' + Assert-True (-not (Test-Path -LiteralPath $models)) 'Explicit model removal should delete model data' +} finally { + Remove-Item -LiteralPath $testPaths.StartupRegistryPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $uninstallRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +function Get-CimInstance { + @( + [pscustomobject]@{ + ProcessId = 101 + ExecutablePath = 'C:\Users\Test\AppData\Local\Programs\Ollama\ollama.exe' + CommandLine = '"C:\Users\Test\AppData\Local\Programs\Ollama\ollama.exe" serve' + } + [pscustomobject]@{ + ProcessId = 202 + ExecutablePath = 'C:\Program Files\Ollama\ollama.exe' + CommandLine = '"C:\Program Files\Ollama\ollama.exe" serve' + } + ) +} +$managedProcesses = @(Get-OllamaManagedProcesses -InstallRoot 'C:\Users\Test\AppData\Local\Programs\Ollama') +Assert-Equal $managedProcesses.Count 1 'Managed process discovery should not target unrelated Ollama installations' +Assert-Equal $managedProcesses[0].ProcessId 101 'Managed process discovery should select only the Dev Config executable' + +Write-Host "UNIT_OK: ollama ($script:AssertionCount assertions)" diff --git a/src/tests/pytorch/expected.txt b/src/tests/pytorch/expected.txt new file mode 100644 index 00000000..dc020f75 --- /dev/null +++ b/src/tests/pytorch/expected.txt @@ -0,0 +1 @@ +PyTorch ready diff --git a/src/tests/pytorch/probe.ps1 b/src/tests/pytorch/probe.ps1 new file mode 100644 index 00000000..6500fbc9 --- /dev/null +++ b/src/tests/pytorch/probe.ps1 @@ -0,0 +1,23 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\..\windows-dev-config\steps\_environment.ps1') + +$python = Join-Path $env:LOCALAPPDATA 'DevConfig\pytorch\.venv\Scripts\python.exe' +$statePath = Join-Path $env:LOCALAPPDATA 'DevConfig\pytorch\install-state.json' +if (-not (Test-Path -LiteralPath $python)) { + throw "PyTorch environment was not found at '$python'." +} +if (-not (Test-Path -LiteralPath $statePath)) { + throw "PyTorch install state was not found at '$statePath'." +} +$state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json +$deviceIndex = if ($state.PSObject.Properties['deviceIndex']) { [int]$state.deviceIndex } else { 0 } +$result = Invoke-DevConfigNativeCommand -FilePath $python -Arguments @( + (Join-Path $PSScriptRoot '..\..\Workloads\pytorch\smoke.py'), '--backend', $state.backend, '--device-index', $deviceIndex +) +if ($result.ExitCode -ne 0) { + throw "PyTorch $($state.backend) tensor probe failed with exit code $($result.ExitCode). $($result.Output)" +} + +Write-Output 'PyTorch ready' diff --git a/src/tests/pytorch/unit.ps1 b/src/tests/pytorch/unit.ps1 new file mode 100644 index 00000000..0206cb99 --- /dev/null +++ b/src/tests/pytorch/unit.ps1 @@ -0,0 +1,284 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_harness\assertions.ps1') +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\ai-support.ps1') + +$cpu = Resolve-PyTorchPlan -Architecture X64 -Backend Auto -PythonVersion 3.13 -HasNvidia $false +Assert-Equal $cpu.Backend 'CPU' 'Auto should select CPU without NVIDIA' +Assert-Equal $cpu.IndexUrl 'https://download.pytorch.org/whl/cpu' 'CPU should use the official CPU index' +Assert-True (-not $cpu.InstallTriton) 'CPU should not install Triton' + +$cuda12 = Resolve-PyTorchPlan -Architecture X64 -Backend Auto -PythonVersion 3.13 ` + -HasNvidia $true -DriverMajor 579 -ComputeCapability 8.9 +Assert-Equal $cuda12.Runtime 'cu126' 'Driver branches below 580 should select cu126' +Assert-True $cuda12.InstallTriton 'Compatible CUDA x64 should install Triton' + +$cuda13 = Resolve-PyTorchPlan -Architecture X64 -Backend CUDA -PythonVersion 3.14 ` + -HasNvidia $true -DriverMajor 580 -ComputeCapability 10.0 +Assert-Equal $cuda13.Runtime 'cu130' 'Driver branch 580 should select cu130' +Assert-Equal $cuda13.TritonRequirement 'triton-windows==3.8.0.post28' 'PyTorch should pin the verified Triton build' + +$arm = Resolve-PyTorchPlan -Architecture Arm64 -Backend Auto -PythonVersion 3.13 -HasNvidia $false +Assert-Equal $arm.Backend 'CPU' 'ARM64 should select the official CPU wheel' +Assert-True (-not $arm.InstallTriton) 'ARM64 stable stack should skip Triton' + +$n1x = Resolve-PyTorchPlan -Architecture Arm64 -Backend Auto -PythonVersion 3.13 ` + -HasNvidia $true -DriverMajor 616 -ComputeCapability 12.1 +Assert-Equal $n1x.Backend 'CUDA' 'RTX Spark N1X ARM64 should select CUDA' +Assert-Equal $n1x.Runtime 'cu134' 'RTX Spark N1X should use CUDA 13.4 wheel' +Assert-True ($n1x.TorchRequirement -like 'torch @ https://pypi.nvidia.com/*win_arm64.whl#sha256=*') 'N1X torch wheel should be direct, native, official, and hash pinned' +Assert-True $n1x.InstallTriton 'Compatible ARM64 CUDA preview should run Triton verification' +$nvidiaStableCandidate = (Get-AiCatalogData).Components.NvidiaPyTorchArm64.PromotionCandidate +Assert-Equal $nvidiaStableCandidate.IndexUrl 'https://pypi.nvidia.com/nvtorch_oot/' 'ARM64 PyTorch should track the NVIDIA stable out-of-tree channel' +Assert-Equal $nvidiaStableCandidate.TorchSha256 '4f781babc0e0e0722cc48d0b15107a28e6003fc2b6544f1578b6eb6f5177dcb5' 'Stable ARM64 torch candidate should retain its published hash' +Assert-Equal $nvidiaStableCandidate.TorchvisionSha256 'e935037b6a97c32642d47f73da8cf62acf6453bfe15825314f774f62ec395d26' 'Stable ARM64 torchvision candidate should retain its published hash' +Assert-Equal $nvidiaStableCandidate.TorchaudioSha256 'e4f18fa7359528416964d525ba620a0ca95ad231d6ab573b26c8b09c6ea8bf6b' 'Stable ARM64 torchaudio candidate should retain its published hash' +Assert-True ($nvidiaStableCandidate.TrackingStatus -match 'awaiting N1X') 'Stable NVIDIA tuple should remain gated on N1X tensor/Triton qualification' + +$rocm = Resolve-PyTorchPlan -Architecture X64 -Backend Auto -PythonVersion 3.13 ` + -GpuVendor AMD -AmdGpuName 'AMD Radeon RX 9070 XT' -AmdGfxTarget gfx1201 +Assert-Equal $rocm.Backend 'ROCm' 'Supported AMD hardware should select ROCm' +Assert-Equal $rocm.TorchRequirement 'torch[device-gfx1201]==2.13.0+rocm10.0.0' 'ROCm should select the exact supported GPU package' +Assert-Equal ($rocm.AdditionalRequirements -join ',') 'torchvision[device-gfx1201]==0.28.0+rocm10.0.0,torchaudio==2.11.0.2+rocm10.0.0' 'ROCm should install the complete official AMD Windows package tuple' +Assert-Equal $rocm.IndexUrl 'https://stable.repo.amd.com/rocm/whl-next/' 'ROCm should use AMD official stable Windows feed' +Assert-True (-not $rocm.InstallTriton) 'Native Windows AMD should not claim Triton support' +Assert-Equal $rocm.Vendor 'AMD' 'ROCm plan should report AMD as selected vendor' +Assert-Equal $rocm.DeviceName 'AMD Radeon RX 9070 XT' 'ROCm plan should retain the selected AMD device' +Assert-Equal $rocm.AmdGfxTarget 'gfx1201' 'ROCm plan should retain the selected gfx target' + +$mixedFallback = Resolve-PyTorchPlan -Architecture X64 -Backend Auto -PythonVersion 3.13 ` + -HasNvidia $true -DriverMajor 500 -ComputeCapability 8.9 -GpuName 'NVIDIA GeForce RTX 4090' ` + -HasAmd $true -AmdGpuName 'AMD Radeon RX 9070 XT' -AmdGfxTarget gfx1201 +Assert-Equal $mixedFallback.Backend 'ROCm' 'Auto should skip an unsupported NVIDIA driver and select supported AMD ROCm' +Assert-Equal $mixedFallback.DeviceName 'AMD Radeon RX 9070 XT' 'Mixed-adapter fallback should report the AMD device actually selected' +$newGpuFallback = Resolve-PyTorchPlan -Architecture X64 -Backend Auto -PythonVersion 3.13 ` + -HasNvidia $true -DriverMajor 579 -ComputeCapability 10.0 -GpuName 'NVIDIA next-generation GPU' ` + -HasAmd $true -AmdGpuName 'AMD Radeon RX 9070 XT' -AmdGfxTarget gfx1201 +Assert-Equal $newGpuFallback.Backend 'ROCm' 'Auto should skip a CUDA 13-class GPU when its driver cannot support the required wheel' +$preTuringNewDriver = Resolve-PyTorchPlan -Architecture X64 -Backend CUDA -PythonVersion 3.13 ` + -HasNvidia $true -DriverMajor 580 -ComputeCapability 6.1 -GpuName 'NVIDIA GeForce GTX 1080' +Assert-Equal $preTuringNewDriver.Runtime 'cu126' 'Pre-Turing GPUs should retain CUDA 12.6 wheels even with a branch 580 driver' + +$xpu = Resolve-PyTorchPlan -Architecture X64 -Backend Auto -PythonVersion 3.13 -GpuVendor Intel -GpuName 'Intel(R) Arc(TM) B580 Graphics' +Assert-Equal $xpu.Backend 'XPU' 'Intel GPU should select XPU' +Assert-Equal $xpu.TorchRequirement 'torch==2.14.0+xpu' 'XPU should use the official stable wheel' +Assert-Equal ($xpu.AdditionalRequirements -join ',') 'torchvision==0.29.0+xpu' 'XPU should install its official torchvision tuple' +Assert-Equal $xpu.IndexUrl 'https://download.pytorch.org/whl/xpu' 'XPU should use the official PyTorch XPU index' +Assert-Equal $xpu.TritonRequirement 'triton-xpu==3.8.0' 'XPU should use PyTorch-managed Triton XPU' +Assert-Equal $xpu.Vendor 'Intel' 'XPU plan should report Intel as selected vendor' +Assert-Equal $xpu.DeviceName 'Intel(R) Arc(TM) B580 Graphics' 'XPU plan should retain the selected Intel device' + +Assert-ThrowsLike { + Resolve-PyTorchPlan -Architecture X64 -Backend CUDA -PythonVersion 3.13 -HasNvidia $false +} '*nvidia-smi did not report*' 'Explicit CUDA should fail without usable hardware' +Assert-ThrowsLike { + Resolve-PyTorchPlan -Architecture X64 -Backend CUDA -PythonVersion 3.13 ` + -HasNvidia $true -DriverMajor 580 -ComputeCapability 3.5 -GpuName 'NVIDIA Kepler GPU' +} '*below the supported Windows CUDA wheel minimum*' 'Explicit CUDA should reject GPUs below the supported compute capability' + +Assert-ThrowsLike { + Resolve-PyTorchPlan -Architecture Arm64 -Backend Auto -PythonVersion 3.13 ` + -HasNvidia $true -DriverMajor 615 -ComputeCapability 12.1 +} '*Use -Backend CPU to explicitly accept CPU-only*' 'ARM64 NVIDIA auto mode should never silently fall back to CPU' +Assert-ThrowsLike { + Resolve-PyTorchPlan -Architecture Arm64 -Backend ROCm -PythonVersion 3.13 -GpuVendor AMD -AmdGfxTarget gfx1201 +} '*not published for native Windows ARM64*' 'ROCm should reject Windows ARM64' +Assert-ThrowsLike { + Resolve-PyTorchPlan -Architecture Arm64 -Backend XPU -PythonVersion 3.13 -GpuVendor Intel -IntelGpuName 'Intel Arc B580' +} '*not published for native Windows ARM64*' 'XPU should reject Windows ARM64' +Assert-ThrowsLike { + Resolve-PyTorchPlan -Architecture Arm64 -Backend Auto -PythonVersion 3.13 -HasAmd $true -AmdGpuName 'AMD Radeon RX 9070 XT' -AmdGfxTarget gfx1201 +} '*ROCm PyTorch is not published*Use -Backend CPU*' 'Auto should reject AMD acceleration on Windows ARM64' +Assert-ThrowsLike { + Resolve-PyTorchPlan -Architecture Arm64 -Backend Auto -PythonVersion 3.13 -HasIntel $true -IntelGpuName 'Intel Arc B580' +} '*XPU PyTorch is not published*Use -Backend CPU*' 'Auto should reject Intel acceleration on Windows ARM64' +Assert-ThrowsLike { + Resolve-PyTorchPlan -Architecture X64 -Backend Auto -PythonVersion 3.13 -GpuVendor AMD -AmdGpuName 'Unsupported Radeon' +} '*not in the ROCm 10.0 Windows support matrix*' 'Unsupported AMD hardware should not silently fall back to CPU' +Assert-ThrowsLike { + Resolve-PyTorchPlan -Architecture X64 -Backend XPU -PythonVersion 3.13 -GpuVendor Intel -GpuName 'Intel HD Graphics 4000' +} '*not in the validated Windows PyTorch XPU families*' 'Unsupported Intel hardware should fail explicitly' +Assert-True (Test-IntelXpuGpuSupported -GpuName 'Intel(R) Arc(TM) 140V GPU') 'Intel Arc 140V should be accepted for XPU' +Assert-True (Test-IntelXpuGpuSupported -GpuName 'Intel(R) Arc(TM) 130V GPU') 'Intel Arc 130V should be accepted for XPU' +Assert-Equal (Select-IntelGpuName -GpuNames @('Intel(R) HD Graphics 4000', 'Intel(R) Arc(TM) B580 Graphics')) 'Intel(R) Arc(TM) B580 Graphics' 'Intel selection should prefer a supported adapter regardless of enumeration order' +$mixedXpu = Resolve-PyTorchPlan -Architecture X64 -Backend XPU -PythonVersion 3.13 ` + -GpuVendor NVIDIA -HasNvidia $true -HasIntel $true -GpuName 'Intel(R) Arc(TM) B580 Graphics' +Assert-Equal $mixedXpu.Backend 'XPU' 'Explicit XPU should select a supported secondary Intel GPU' +$mixedRocm = Resolve-PyTorchPlan -Architecture X64 -Backend ROCm -PythonVersion 3.13 ` + -GpuVendor NVIDIA -HasNvidia $true -HasAmd $true -GpuName 'NVIDIA GeForce RTX 4090' ` + -AmdGpuName 'AMD Radeon RX 9070 XT' -AmdGfxTarget gfx1201 +Assert-Equal $mixedRocm.Backend 'ROCm' 'Explicit ROCm should select a supported secondary AMD GPU' +Assert-Equal $mixedRocm.DeviceName 'AMD Radeon RX 9070 XT' 'Explicit ROCm should report the selected secondary AMD GPU' +$autoIntelFallback = Resolve-PyTorchPlan -Architecture X64 -Backend Auto -PythonVersion 3.13 ` + -GpuVendor AMD -HasAmd $true -AmdGpuName 'Unsupported Radeon' -HasIntel $true -IntelGpuName 'Intel(R) Arc(TM) B580 Graphics' +Assert-Equal $autoIntelFallback.Backend 'XPU' 'Auto should skip unsupported AMD hardware and select a supported Intel accelerator' + +$skippedXpuTriton = Resolve-PyTorchPlan -Architecture X64 -Backend XPU -PythonVersion 3.13 ` + -HasIntel $true -IntelGpuName 'Intel(R) Arc(TM) B580 Graphics' -SkipTriton +Assert-True (-not $skippedXpuTriton.InstallTriton) 'XPU SkipTriton should disable Triton' +Assert-True ($skippedXpuTriton.TritonReason -match 'disabled by the caller') 'XPU SkipTriton should report disabled rather than unsupported' + +Assert-PythonArchitecture -Architecture X64 -PythonMachine AMD64 +Assert-PythonArchitecture -Architecture Arm64 -PythonMachine aarch64 +Assert-ThrowsLike { + Assert-PythonArchitecture -Architecture Arm64 -PythonMachine AMD64 +} '*does not match Windows architecture*' 'Emulated or conflicting Python should fail before wheel installation' + +$arguments = Get-PipInstallArguments -Requirement 'torch==2.14.0+cpu' ` + -IndexUrl 'https://download.pytorch.org/whl/cpu' -DryRun +Assert-Equal ($arguments -join ' ') '-m pip install --dry-run --only-binary=:all: torch==2.14.0+cpu --index-url https://download.pytorch.org/whl/cpu' 'pip command should pin the exact CPU build on the official index' +Assert-Equal $cuda12.TorchRequirement 'torch==2.14.0+cu126' 'CUDA 12 repair should require the exact backend build' +Assert-Equal $cuda13.TorchRequirement 'torch==2.14.0+cu130' 'CUDA 13 repair should require the exact backend build' + +$n1xArguments = Get-PipInstallArguments -Requirement $n1x.TorchRequirement -DryRun +Assert-True (($n1xArguments -join ' ') -notlike '*--index-url*') 'Direct N1X torch wheel should leave dependency resolution on the configured default index' +Assert-True (($n1xArguments -join ' ') -like '*af0872854d183cb6894dbd5b1e5e9291875ce139d138b5fc0b501498828265d3*') 'N1X torch command should preserve the wheel hash' +$localWheelArguments = Get-PipLocalWheelInstallArguments -WheelPath 'C:\cache\torch.whl' +Assert-Equal ($localWheelArguments -join ' ') '-m pip install --only-binary=:all: C:\cache\torch.whl' 'Verified direct wheel should install from one local cached artifact' + +$matchingState = [ordered]@{ + architecture = 'Arm64' + backend = 'CUDA' + torch = $n1x.TorchRequirement + torchVersion = $n1x.TorchVersion + index = $n1x.IndexUrl + triton = $n1x.TritonRequirement + tritonVersion = $n1x.TritonVersion + numpy = $n1x.NumpyRequirement + numpyVersion = $n1x.NumpyVersion + additionalRequirements = @() + python = '3.13' +} | ConvertTo-Json -Compress +$matchingVersions = [pscustomobject]@{ + torch = '2.15.0.dev20260904+cu134' + numpy = '2.5.2' + triton = '3.8.0.post28' + torchvision = $null + torchaudio = $null +} +Assert-Equal (Get-PyTorchPackageAction -DesiredStateJson $matchingState -CurrentStateJson $matchingState -InstalledVersions $matchingVersions) 'VerifyOnly' 'Matching rerun should skip package resolution and installation' +$deviceSelectionState = $matchingState.TrimEnd('}') + ',"deviceIndex":1,"selectedDevice":"NVIDIA secondary GPU"}' +Assert-True (Test-PyTorchStateCompatible -DesiredStateJson $deviceSelectionState -CurrentStateJson $matchingState) 'Changing only the selected adapter should not reinstall the PyTorch package environment' +$legacyState = [ordered]@{ + architecture = 'Arm64' + backend = 'CUDA' + torch = $n1x.TorchRequirement + index = $n1x.IndexUrl + triton = 'triton-windows>=3.8,<3.9' + python = '3.13' +} | ConvertTo-Json -Compress +Assert-Equal (Get-PyTorchPackageAction -DesiredStateJson $matchingState -CurrentStateJson $legacyState -InstalledVersions $matchingVersions) 'VerifyOnly' 'Compatible legacy state with exact installed versions should migrate without downloading packages' +$legacyCpuDesired = [ordered]@{ + architecture = 'X64'; backend = 'CPU'; torch = 'torch==2.14.0+cpu'; torchVersion = '2.14.0+cpu' + index = 'https://download.pytorch.org/whl/cpu'; triton = $null; tritonVersion = $null + numpy = 'numpy==2.5.2'; numpyVersion = '2.5.2'; python = '3.13' +} | ConvertTo-Json -Compress +$legacyCpuState = [ordered]@{ + architecture = 'X64'; backend = 'CPU'; torch = 'torch==2.14.0' + index = 'https://download.pytorch.org/whl/cpu'; triton = $null; python = '3.13' +} | ConvertTo-Json -Compress +$legacyCpuVersions = [pscustomobject]@{ torch = '2.14.0+cpu'; numpy = '2.5.2'; triton = $null; torchvision = $null; torchaudio = $null } +Assert-Equal (Get-PyTorchPackageAction -DesiredStateJson $legacyCpuDesired -CurrentStateJson $legacyCpuState -InstalledVersions $legacyCpuVersions) 'VerifyOnly' 'Legacy stable CPU state should migrate without downloading' +$wrongTorch = [pscustomobject]@{ torch = '2.14.0+cpu'; numpy = '2.5.2'; triton = '3.8.0.post28'; torchvision = $null; torchaudio = $null } +Assert-Equal (Get-PyTorchPackageAction -DesiredStateJson $matchingState -CurrentStateJson $matchingState -InstalledVersions $wrongTorch) 'Install' 'Mismatched installed torch should repair the environment' +Assert-Equal (Get-PyTorchPackageAction -DesiredStateJson $matchingState -CurrentStateJson $null -InstalledVersions $matchingVersions) 'Install' 'Missing state should not skip package installation' +$wrongBackendState = $legacyState -replace '"backend":"CUDA"', '"backend":"CPU"' +Assert-True (-not (Test-PyTorchStateCompatible -DesiredStateJson $matchingState -CurrentStateJson $wrongBackendState)) 'Backend plan changes should recreate the environment' +$noTritonState = $matchingState -replace '"triton":"triton-windows==3.8.0.post28","tritonVersion":"3.8.0.post28"', '"triton":null,"tritonVersion":null' +Assert-True (-not (Test-PyTorchStateCompatible -DesiredStateJson $noTritonState -CurrentStateJson $matchingState)) 'Disabling Triton should recreate an environment that still records Triton' +Assert-True (Test-PyTorchEnvironmentRequiresRecreation -DesiredStateJson $noTritonState -CurrentStateJson $noTritonState -InstalledVersions $matchingVersions) 'Unexpected installed Triton should recreate the environment instead of repeating pip work' +$rocmState = [ordered]@{ + architecture = 'X64'; backend = 'ROCm'; torch = $rocm.TorchRequirement; torchVersion = $rocm.TorchVersion + index = $rocm.IndexUrl; triton = $null; tritonVersion = $null; numpy = $rocm.NumpyRequirement + numpyVersion = $rocm.NumpyVersion; additionalRequirements = @($rocm.AdditionalRequirements); python = '3.13' +} | ConvertTo-Json -Compress +$rocmVersionsMissingVision = [pscustomobject]@{ + torch = $rocm.TorchVersion; numpy = $rocm.NumpyVersion; triton = $null; torchvision = $null; torchaudio = '2.11.0.2+rocm10.0.0' +} +Assert-Equal (Get-PyTorchPackageAction -DesiredStateJson $rocmState -CurrentStateJson $rocmState -InstalledVersions $rocmVersionsMissingVision) 'Install' 'ROCm rerun should repair missing additional packages' + +$installScript = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\pytorch\install.ps1') -Raw +Assert-True ($installScript -match 'Get-PipInstallArguments -Requirement \$plan\.NumpyRequirement') 'PyTorch environment should include pinned NumPy' +Assert-True ($installScript -like "*if (`$packageAction -eq 'VerifyOnly')*") 'PyTorch should branch around package work on a matching rerun' +Assert-True ($installScript -match 'Install-VerifiedDownload') 'Fresh direct-wheel install should use the verified download cache' +Assert-True ($installScript -match 'Get-PipLocalWheelInstallArguments') 'Fresh direct-wheel install should install the one cached wheel' +Assert-True ($installScript -match 'tensor smoke failed') 'Matching rerun should still execute the tensor readiness probe' +Assert-True ($installScript -match 'GPU kernel smoke failed') 'Matching rerun should still execute the Triton readiness probe' +Assert-True ($installScript -notmatch '\$LASTEXITCODE') 'PyTorch should not depend on inherited LASTEXITCODE state' +$probeScript = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'probe.ps1') -Raw +Assert-True ($probeScript -notmatch '\$LASTEXITCODE') 'PyTorch probe should not depend on inherited LASTEXITCODE state' +Assert-True ($probeScript -match 'Invoke-DevConfigNativeCommand') 'PyTorch probe should use guarded native execution' +Assert-True ($probeScript -match 'DeviceIndex') 'PyTorch probe should reuse the selected adapter index' +Assert-True ($installScript -match 'deviceIndex = \$DeviceIndex') 'PyTorch should persist the selected adapter independently of the optional report path' +Assert-True ($probeScript -match '\$state\.deviceIndex') 'PyTorch probe should read the durable selected adapter state' +Assert-True ($installScript -match 'Get-Python313Path') 'PyTorch should select the installed Python 3.13 explicitly' +Assert-True ($installScript -match 'Import-MsvcEnvironment') 'Triton path should import the architecture-native MSVC build environment' +Assert-True ((Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\_common\ai-support.ps1') -Raw) -like '*PATH=$vsInstaller;%PATH%*') 'Triton compiler environment should put vswhere.exe on PATH before VsDevCmd runs' +Assert-True ($installScript -match 'Ensure-AiVisualCppTools') 'Triton should acquire its JIT compiler through direct shared setup' +Assert-True ($installScript -match 'Ensure-AiCudaToolkit') 'CUDA Triton should acquire its toolkit through direct shared setup' +Assert-True ($installScript -notmatch 'apply-configuration') 'PyTorch should not use winget configure' +Assert-True ($installScript -match "'ROCm', 'XPU'") 'PyTorch should expose AMD ROCm and Intel XPU backends' +Assert-True ($installScript -match '\[int\]\s*\$DeviceIndex') 'PyTorch should expose same-vendor adapter selection' +Assert-True ($installScript -match 'Auto supports only -DeviceIndex 0') 'PyTorch should reject ambiguous nonzero adapter indexes in Auto mode' +Assert-True ($installScript.Contains("'--device-index', `$DeviceIndex")) 'PyTorch should pass the selected adapter index to tensor and Triton probes' +Assert-True ($installScript.Contains("`$Backend -in @('Auto', 'ROCm')")) 'PyTorch Auto should resolve AMD hardware at the requested adapter index' +Assert-True ($installScript.Contains("`$Backend -in @('Auto', 'XPU')")) 'PyTorch Auto should resolve Intel hardware at the requested adapter index' +Assert-True ($installScript.Contains("`$Backend -in @('Auto', 'CUDA')")) 'PyTorch Auto should resolve NVIDIA hardware at the requested adapter index' +Assert-True ($installScript -match 'xpu-smoke\.py') 'PyTorch XPU should execute torch.compile/Triton acceptance' +Assert-True ($installScript -notmatch 'Intel\.OneAPI\.Toolkit') 'PyTorch XPU should not install the full oneAPI toolkit' +Assert-True ($installScript -notmatch "Workloads\\rocm|rocm\\install\.ps1") 'PyTorch ROCm should not require the standalone native HIP SDK flow' +Assert-True ($installScript -match 'amdGfxTarget = \$plan\.AmdGfxTarget') 'PyTorch reports should include the resolved AMD gfx target' +Assert-True ($installScript -match 'runtimePackageTuple') 'PyTorch reports should include the exact runtime package tuple' +Assert-True ($installScript -match 'selectedVendor = \$plan\.Vendor') 'PyTorch reports should include the selected hardware vendor' + +$smokeScript = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\pytorch\smoke.py') -Raw +Assert-True ($smokeScript -match 'ROCm/HIP') 'ROCm failure text should identify the AMD stack instead of CUDA' +Assert-True ($smokeScript -match 'torch\.version\.hip is null') 'ROCm smoke should require a non-null HIP runtime' +Assert-True ($smokeScript -match 'torch\.cuda\.get_device_name') 'ROCm smoke should report the actual AMD device through the HIP-compatible API' +Assert-True ($smokeScript -match 'device-index') 'PyTorch tensor smoke should target an explicit adapter index' +$xpuSmokeScript = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\pytorch\xpu-smoke.py') -Raw +Assert-True ($xpuSmokeScript -match 'torch\.xpu\.is_available') 'XPU smoke should require an actual Intel XPU device' +Assert-True ($xpuSmokeScript -match 'torch\.xpu\.get_device_name') 'XPU smoke should report the actual Intel device' +Assert-True ($xpuSmokeScript -match 'torch\.compile') 'XPU smoke should execute PyTorch compile through Triton XPU' +Assert-True ($xpuSmokeScript -match 'torch\.xpu\.set_device') 'XPU compile smoke should target the requested Intel adapter' + +$rocmSmoke = ConvertFrom-AiKeyedJsonLine -Prefix 'PYTORCH_SMOKE=' -Text 'PYTORCH_SMOKE={"backend":"ROCm","vendor":"AMD","device":"AMD Radeon RX 9070 XT","torch_hip_runtime":"7.15","tensor_operation_verified":true}' +Assert-Equal $rocmSmoke.backend 'ROCm' 'ROCm smoke record should identify its backend' +Assert-Equal $rocmSmoke.vendor 'AMD' 'ROCm smoke record should identify AMD' +Assert-True ([bool]$rocmSmoke.torch_hip_runtime) 'ROCm smoke record should contain the HIP runtime' +$xpuCompile = ConvertFrom-AiKeyedJsonLine -Prefix 'TRITON_XPU_READY=' -Text 'TRITON_XPU_READY={"backend":"XPU","vendor":"Intel","device":"Intel Arc B580","triton_xpu":"3.8.0","torch_compile_executed":true}' +Assert-Equal $xpuCompile.backend 'XPU' 'XPU compile record should identify its backend' +Assert-True $xpuCompile.torch_compile_executed 'XPU compile record should prove torch.compile execution' + +$catalog = Get-AiCatalogData +Assert-True (-not $catalog.Components.PyTorchRocm.NativeToolkitRequired) 'PyTorch ROCm should not require the standalone ROCm SDK' +Assert-True ($catalog.Components.PyTorchRocm.NativeToolkitRelationship -match 'standalone rocm flow is not a prerequisite') 'ROCm metadata should explain the native SDK separation' +Assert-True (-not $catalog.Components.PyTorchXpu.NativeToolkitRequired) 'PyTorch XPU should not require full oneAPI' +Assert-True ($catalog.Components.PyTorchXpu.NativeToolkitRelationship -match 'does not install full oneAPI') 'XPU metadata should explain the oneAPI separation' + +$readme = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\..\README.md') -Raw +Assert-True ($readme -match 'pytorch-rocm-report\.json') 'README should provide the AMD partner report command' +Assert-True ($readme -match 'pytorch-xpu-report\.json') 'README should provide the Intel partner report command' + +$fakeVs = Join-Path $env:TEMP "devconfig-vs-test-$([guid]::NewGuid().ToString('N'))" +$fakeToolset = Join-Path $fakeVs 'VC\Tools\MSVC\14.99.0\bin\Hostarm64\arm64' +$fakeVsDevCmd = Join-Path $fakeVs 'Common7\Tools\VsDevCmd.bat' +New-Item -ItemType Directory -Path $fakeToolset -Force | Out-Null +New-Item -ItemType Directory -Path (Split-Path -Parent $fakeVsDevCmd) -Force | Out-Null +New-Item -ItemType File -Path (Join-Path $fakeToolset 'cl.exe') -Force | Out-Null +New-Item -ItemType File -Path $fakeVsDevCmd -Force | Out-Null +try { + $resolvedVsDevCmd = Resolve-VsDevCmdPath -InstallationPaths @('', $fakeVs) -Architecture Arm64 + Assert-Equal $resolvedVsDevCmd $fakeVsDevCmd 'VS discovery should skip empty output and select a Build Tools instance with ARM64 cl.exe' + Assert-ThrowsLike { + Resolve-VsDevCmdPath -InstallationPaths @() -Architecture Arm64 + } '*No Visual Studio Build Tools installation with an Arm64 MSVC compiler*' 'Empty vswhere output should produce an actionable error instead of a null dereference' +} finally { + Remove-Item -LiteralPath $fakeVs -Recurse -Force -ErrorAction SilentlyContinue +} + +$repeat = Resolve-PyTorchPlan -Architecture X64 -Backend Auto -PythonVersion 3.13 -HasNvidia $false +Assert-Equal ($repeat | ConvertTo-Json -Compress) ($cpu | ConvertTo-Json -Compress) 'Plan resolution should be idempotent' + +Write-Host "UNIT_OK: pytorch ($script:AssertionCount assertions)" diff --git a/src/tests/rocm/expected.txt b/src/tests/rocm/expected.txt new file mode 100644 index 00000000..7b88bb53 --- /dev/null +++ b/src/tests/rocm/expected.txt @@ -0,0 +1 @@ +ROCm ready diff --git a/src/tests/rocm/probe.ps1 b/src/tests/rocm/probe.ps1 new file mode 100644 index 00000000..9df66f17 --- /dev/null +++ b/src/tests/rocm/probe.ps1 @@ -0,0 +1,25 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$hipcc = Join-Path $env:LOCALAPPDATA 'DevConfig\rocm\.venv\Scripts\hipcc.exe' +if (-not (Test-Path -LiteralPath $hipcc)) { throw "hipcc not found at '$hipcc'." } +$reportPath = Join-Path $env:LOCALAPPDATA 'DevConfig\reports\rocm-latest.json' +$deviceIndex = 0 +if (Test-Path -LiteralPath $reportPath) { + $request = (Get-Content -LiteralPath $reportPath -Raw | ConvertFrom-Json).request + if ($request.PSObject.Properties['DeviceIndex']) { $deviceIndex = [int]$request.DeviceIndex } +} +$temporary = Join-Path ([System.IO.Path]::GetTempPath()) "devconfig-hip-probe-$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $temporary -Force | Out-Null +try { + $executable = Join-Path $temporary 'hip-smoke.exe' + & $hipcc (Join-Path $PSScriptRoot '..\..\Workloads\rocm\hip-smoke.cpp') -O2 -o $executable + if ($LASTEXITCODE -ne 0) { throw "HIP compile failed with exit code $LASTEXITCODE." } + $output = (& $executable $deviceIndex 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $output -notmatch '^HIP_KERNEL_READY') { + throw "HIP kernel failed: $output" + } +} finally { + Remove-Item -LiteralPath $temporary -Recurse -Force -ErrorAction SilentlyContinue +} +Write-Output 'ROCm ready' diff --git a/src/tests/rocm/unit.ps1 b/src/tests/rocm/unit.ps1 new file mode 100644 index 00000000..c9edbbe8 --- /dev/null +++ b/src/tests/rocm/unit.ps1 @@ -0,0 +1,56 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\_harness\assertions.ps1') +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\direct-setup.ps1') +. (Join-Path $PSScriptRoot '..\..\Workloads\_common\ai-report.ps1') + +Assert-Equal (Get-AmdGfxTarget -GpuName 'AMD Radeon RX 9070 XT') 'gfx1201' 'RX 9070 XT should map to gfx1201' +Assert-Equal (Get-AmdGfxTarget -GpuName 'AMD Radeon RX 7900 XTX') 'gfx1100' 'RX 7900 XTX should map to gfx1100' +Assert-Equal (Get-AmdGfxTarget -GpuName 'AMD Radeon RX 7600') 'gfx1102' 'RX 7600 should map to gfx1102' +Assert-Equal (Get-AmdGfxTarget -GpuName 'AMD Radeon RX 7650 GRE') 'gfx1102' 'RX 7650 GRE should map to gfx1102' +Assert-Equal (Get-AmdGfxTarget -GpuName 'AMD Radeon 890M Graphics') 'gfx1150' 'Radeon 890M should map to its Ryzen AI gfx target' +Assert-Equal (Get-AmdGfxTarget -GpuName 'AMD Radeon 8060S Graphics') 'gfx1151' 'Radeon 8060S should map to its Ryzen AI Max gfx target' +Assert-Equal (Get-AmdGfxTarget -GpuName 'AMD Radeon(TM) 890M Graphics') 'gfx1150' 'Trademark tokens should not break Radeon 890M matching' +Assert-Equal (Select-AmdGpuName -GpuNames @('AMD Radeon Vega 8', 'AMD Radeon RX 9070 XT')) 'AMD Radeon RX 9070 XT' 'AMD selection should prefer a supported adapter regardless of enumeration order' +Assert-Equal (Get-AmdGfxTarget -GpuName 'Unsupported AMD GPU') $null 'Unsupported AMD GPU should not infer compatibility' +$plan = Resolve-RocmInstallPlan -Architecture X64 -GpuName 'AMD Radeon RX 9070 XT' +Assert-Equal $plan.Requirement 'rocm[libraries,devel,device-gfx1201]==10.0.0' 'ROCm plan should construct the exact device package' +Assert-ThrowsLike { + Resolve-RocmInstallPlan -Architecture Arm64 -GpuName 'AMD Radeon RX 9070 XT' +} '*does not publish native Windows ARM64*' 'ROCm should reject ARM64' + +$catalog = Get-AiCatalog +Assert-Equal $catalog.Components.AmdRocm.Architectures[0] 'X64' 'ROCm should be Windows x64 only' +Assert-Equal $catalog.Components.AmdRocm.Version '10.0.0' 'ROCm should pin the production tuple' +Assert-True ($catalog.Components.AmdRocm.IndexUrl -like 'https://stable.repo.amd.com/*') 'ROCm should use the official stable AMD feed' + +$wingetArgs = Get-DevConfigWingetInstallArguments -Id 'Python.Python.3.13' +Assert-Equal ($wingetArgs -join ' ') 'install --id Python.Python.3.13 --exact --source winget --silent --accept-package-agreements --accept-source-agreements --disable-interactivity' 'Direct package command should be exact and noninteractive' + +$script = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\rocm\install.ps1') -Raw +Assert-True ($script -match '\[int\]\s*\$DeviceIndex') 'ROCm should expose same-vendor adapter selection' +Assert-True ($script -match 'Test-AiDeviceNameMatch') 'ROCm should verify that the executed HIP device matches the resolved gfx package' +$hipSmoke = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\Workloads\rocm\hip-smoke.cpp') -Raw +Assert-True ($hipSmoke -match 'hipSetDevice\(device_index\)') 'HIP kernel should execute on the requested AMD adapter' +$probe = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'probe.ps1') -Raw +Assert-True ($probe -match 'DeviceIndex') 'ROCm verification probe should reuse the selected adapter' + +function Get-CimInstance { + return @( + [pscustomobject]@{ Name = 'AMD Unsupported iGPU'; PNPDeviceID = 'PCI\VEN_1002&DEV_0001' }, + [pscustomobject]@{ Name = 'AMD Radeon RX 9070 XT'; PNPDeviceID = 'PCI\VEN_1002&DEV_0002' } + ) +} +Assert-Equal (Get-AmdGpuName -DeviceIndex 0) 'AMD Unsupported iGPU' 'ROCm indexed lookup should preserve exact adapter zero' +Assert-Equal (Get-AmdGpuName -DeviceIndex 1) 'AMD Radeon RX 9070 XT' 'ROCm indexed lookup should preserve exact adapter one' +Assert-True ($script -match '\[switch\]\s*\$PlanOnly') 'ROCm should support portable plan mode' +Assert-True ($script -match 'hip-smoke\.cpp') 'ROCm should compile a real HIP kernel' +Assert-True ($script -notmatch 'apply-configuration') 'ROCm should use direct acquisition' +Assert-True ($script -notmatch '''--only-binary=:all:'', ''--index-url'', \$component\.IndexUrl, \$requirement') 'ROCm should allow AMD source-only metapackage while its dependencies remain wheels' +Assert-True ($script -match 'rocm-sdk-device-\$gfx') 'ROCm rerun should verify the exact selected device package' +Assert-True ($script -match 'Test-PythonDistributionVersions') 'ROCm rerun should verify exact SDK package versions' +Assert-True ($script -match 'Ensure-AiVisualCppTools') 'ROCm should acquire the Windows host compiler and SDK' +Assert-True ($script -match 'Import-MsvcEnvironment') 'ROCm should initialize the host compiler environment before hipcc' + +Write-Host "UNIT_OK: rocm ($script:AssertionCount assertions)" diff --git a/src/tools/collect-ai-hardware.ps1 b/src/tools/collect-ai-hardware.ps1 new file mode 100644 index 00000000..2e99e38e --- /dev/null +++ b/src/tools/collect-ai-hardware.ps1 @@ -0,0 +1,14 @@ +<# +.SYNOPSIS + Invoke the signed-package-compatible AI hardware inventory helper. +#> +[CmdletBinding()] +param( + [string] $OutputPath = (Join-Path $env:LOCALAPPDATA 'DevConfig\reports\hardware-latest.json') +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +& (Join-Path $PSScriptRoot '..\Workloads\_common\collect-ai-hardware.ps1') ` + -OutputPath $OutputPath diff --git a/src/tools/get-ai-capabilities.ps1 b/src/tools/get-ai-capabilities.ps1 new file mode 100644 index 00000000..580dfd4b --- /dev/null +++ b/src/tools/get-ai-capabilities.ps1 @@ -0,0 +1,26 @@ +[CmdletBinding()] +param([string] $OutputPath = '') + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '..\Workloads\_common\ai-support.ps1') + +$document = [ordered]@{ + schemaVersion = 1 + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + capabilities = @(Get-AiCapabilityMatrix) +} +$json = $document | ConvertTo-Json -Depth 20 +if (-not $OutputPath) { + $json + return +} + +$resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) +$parent = Split-Path -Parent $resolvedPath +if ($parent) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null +} +[System.IO.File]::WriteAllText($resolvedPath, $json, [System.Text.UTF8Encoding]::new($false)) +Write-Host "AI_CAPABILITY_REPORT: $resolvedPath" diff --git a/src/windows-dev-config/README.md b/src/windows-dev-config/README.md index a2f9959f..e561f2eb 100644 --- a/src/windows-dev-config/README.md +++ b/src/windows-dev-config/README.md @@ -12,6 +12,7 @@ It is **idempotent** — every change is checked before it's made, so re-running - [Quick start](#quick-start) - [Setup actions](#setup-actions) +- [Local AI scenario](#local-ai-scenario) - [What to expect](#what-to-expect) - [Requirements](#requirements) - [Before you run this](#before-you-run-this) @@ -99,6 +100,40 @@ repository-root release files, not `src/`. All three require `| iex` to execute. The wrappers accept no setup options and verify the Microsoft signature of the downloaded `bootstrap.ps1` before running it with the fixed action. +## Local AI scenario + +`-Scenario local-ai` is a separate product-level entry point. It does not run +the Full or Partial workstation setup. It detects AI hardware, installs the +matching contained PyTorch backend and compatible Triton when available, then +executes tensor and neural-network acceptance: + +```powershell +$url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1' +& ([scriptblock]::Create((irm $url))) -Scenario local-ai +# Expected: PYTORCH_READY ... then LOCAL_AI_SCENARIO_READY +``` + +Optional model runtimes are selected explicitly: + +```powershell +& ([scriptblock]::Create((irm $url))) -Scenario local-ai -AiRuntime LlamaCpp +& ([scriptblock]::Create((irm $url))) -Scenario local-ai -AiRuntime Ollama +& ([scriptblock]::Create((irm $url))) -Scenario local-ai -AiRuntime Foundry +``` + +Use `-PlanOnly -ReportRoot ` to inspect hardware, selected backend, +transitive acquisitions, and blockers without installing. `-AiBackend` accepts +`Auto`, `CPU`, `CUDA`, `ROCm`, or `XPU`; `-RequireTriton` makes compatible +Triton execution mandatory. Drivers remain prerequisites and are not replaced. + +Bootstrap downloads the complete scenario dependency tree, verifies every +Microsoft-signed PowerShell file and the signed hash manifest for non-PowerShell +inputs, copies the payload into an administrator-protected scenario directory, +reverifies it, and launches only `Workloads\local-ai\install.ps1`. +Explicit `-AllowUnsigned` scenario tests use +`%ProgramData%\CalmOS-Development`, keeping unsigned files out of the production +`%ProgramData%\CalmOS` tree. + ## What to expect Roughly **30 minutes** on a clean machine with a good connection, most of it spent downloading Visual Studio Code, the .NET SDK, PowerToys, and Ubuntu. diff --git a/src/windows-dev-config/bootstrap.ps1 b/src/windows-dev-config/bootstrap.ps1 index 07853147..0cd58fd3 100644 --- a/src/windows-dev-config/bootstrap.ps1 +++ b/src/windows-dev-config/bootstrap.ps1 @@ -24,7 +24,13 @@ param( [string] $InstallRoot, [switch] $AllowUnsigned, [switch] $NoLaunch, - [ValidateSet('Full', 'Partial', 'Uninstall')] [string] $Action = 'Full' + [ValidateSet('Full', 'Partial', 'Uninstall')] [string] $Action = 'Full', + [ValidateSet('', 'local-ai')] [string] $Scenario = '', + [ValidateSet('Auto', 'CPU', 'CUDA', 'ROCm', 'XPU')] [string] $AiBackend = 'Auto', + [ValidateSet('None', 'LlamaCpp', 'Ollama', 'Foundry')] [string] $AiRuntime = 'None', + [switch] $RequireTriton, + [switch] $PlanOnly, + [string] $ReportRoot ) function Invoke-CalmOsBootstrap { @@ -34,7 +40,13 @@ function Invoke-CalmOsBootstrap { [string] $InstallRoot, [switch] $AllowUnsigned, [switch] $NoLaunch, - [ValidateSet('Full', 'Partial', 'Uninstall')] [string] $Action = 'Full' + [ValidateSet('Full', 'Partial', 'Uninstall')] [string] $Action = 'Full', + [ValidateSet('', 'local-ai')] [string] $Scenario = '', + [ValidateSet('Auto', 'CPU', 'CUDA', 'ROCm', 'XPU')] [string] $AiBackend = 'Auto', + [ValidateSet('None', 'LlamaCpp', 'Ollama', 'Foundry')] [string] $AiRuntime = 'None', + [switch] $RequireTriton, + [switch] $PlanOnly, + [string] $ReportRoot ) $ErrorActionPreference = 'Stop' @@ -43,13 +55,26 @@ function Invoke-CalmOsBootstrap { $repo = 'microsoft/WindowsDeveloperConfig' $microsoftSignerSubject = 'CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US' + $scenarioOptionNames = @('AiBackend', 'AiRuntime', 'RequireTriton', 'PlanOnly', 'ReportRoot') + if (-not $Scenario -and @($scenarioOptionNames | Where-Object { $PSBoundParameters.ContainsKey($_) }).Count -gt 0) { + throw 'AI backend/runtime/report options require -Scenario local-ai.' + } + if ($Scenario -and $PSBoundParameters.ContainsKey('Action')) { + throw '-Action configures the full workstation and cannot be combined with -Scenario local-ai.' + } + # Reject refs that could escape the repository path. if ($Ref -notmatch '^[A-Za-z0-9][A-Za-z0-9._/-]*$' -or $Ref.Contains('..')) { throw "'$Ref' is not a valid branch, tag or commit name. Use letters, digits, and . _ - / only." } if (-not $InstallRoot) { - $InstallRoot = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'CalmOS' + $defaultInstallDirectory = if ($Scenario -and $AllowUnsigned) { + 'CalmOS-Development' + } else { + 'CalmOS' + } + $InstallRoot = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) $defaultInstallDirectory } $InstallRoot = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($InstallRoot) if ($InstallRoot -notmatch '^[A-Za-z]:\\[^:]+$') { @@ -79,7 +104,14 @@ function Invoke-CalmOsBootstrap { [Parameter(Mandatory)] [string] $InstallRoot, [switch] $AllowUnsigned, [switch] $NoLaunch, - [ValidateSet('Full', 'Partial', 'Uninstall')] [string] $Action = 'Full' + [ValidateSet('Full', 'Partial', 'Uninstall')] [string] $Action = 'Full', + [ValidateSet('', 'local-ai')] [string] $Scenario = '', + [ValidateSet('Auto', 'CPU', 'CUDA', 'ROCm', 'XPU')] [string] $AiBackend = 'Auto', + [ValidateSet('None', 'LlamaCpp', 'Ollama', 'Foundry')] [string] $AiRuntime = 'None', + [switch] $RequireTriton, + [switch] $PlanOnly, + [string] $ReportRoot, + [Parameter(Mandatory)] [string] $ElevationErrorPath ) $launcher = { @@ -88,11 +120,27 @@ function Invoke-CalmOsBootstrap { [string] $InstallRoot, [switch] $AllowUnsigned, [switch] $NoLaunch, - [ValidateSet('Full', 'Partial', 'Uninstall')] [string] $Action = 'Full' + [ValidateSet('Full', 'Partial', 'Uninstall')] [string] $Action = 'Full', + [ValidateSet('', 'local-ai')] [string] $Scenario = '', + [ValidateSet('Auto', 'CPU', 'CUDA', 'ROCm', 'XPU')] [string] $AiBackend = 'Auto', + [ValidateSet('None', 'LlamaCpp', 'Ollama', 'Foundry')] [string] $AiRuntime = 'None', + [switch] $RequireTriton, + [switch] $PlanOnly, + [string] $ReportRoot, + [Parameter(Mandatory)] [string] $ElevationErrorPath ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest + trap { + try { + [IO.File]::WriteAllText( + $ElevationErrorPath, + ($_ | Out-String), + [Text.UTF8Encoding]::new($false)) + } catch { } + exit 1 + } [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $flow = if ($AllowUnsigned) { 'src/windows-dev-config' } else { 'windows-dev-config' } $baseUri = "https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/$Ref/$flow" @@ -131,19 +179,41 @@ function Invoke-CalmOsBootstrap { $shellName = if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh.exe' } else { 'powershell.exe' } $arguments = @('-NoProfile') if (-not $AllowUnsigned) { $arguments += '-ExecutionPolicy', 'RemoteSigned' } - $arguments += '-File', $target, '-Ref', $Ref, '-InstallRoot', $InstallRoot, '-Action', $Action + $arguments += '-File', $target, '-Ref', $Ref, '-InstallRoot', $InstallRoot + if ($Scenario) { + $arguments += '-Scenario', $Scenario, '-AiBackend', $AiBackend, '-AiRuntime', $AiRuntime + if ($RequireTriton) { $arguments += '-RequireTriton' } + if ($PlanOnly) { $arguments += '-PlanOnly' } + if ($ReportRoot) { $arguments += '-ReportRoot', $ReportRoot } + } else { + $arguments += '-Action', $Action + } if ($AllowUnsigned) { $arguments += '-AllowUnsigned' } if ($NoLaunch) { $arguments += '-NoLaunch' } - & (Join-Path $PSHOME $shellName) @arguments - if ($LASTEXITCODE -ne 0) { - throw "Bootstrap finished with exit code $LASTEXITCODE." + $bootstrapOutput = (& (Join-Path $PSHOME $shellName) @arguments 2>&1 | Out-String).Trim() + $bootstrapExitCode = $LASTEXITCODE + if ($bootstrapOutput) { Write-Host $bootstrapOutput } + if ($bootstrapExitCode -ne 0) { + throw "Bootstrap finished with exit code $bootstrapExitCode.`n$bootstrapOutput" } } # PowerShell also recognizes smart quotes as string delimiters. $escapedRef = [Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($Ref) $escapedRoot = [Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($InstallRoot) - $command = "& {`n$launcher`n} -Ref '$escapedRef' -InstallRoot '$escapedRoot' -Action '$Action'" + $escapedErrorPath = [Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($ElevationErrorPath) + $command = "& {`n$launcher`n} -Ref '$escapedRef' -InstallRoot '$escapedRoot' -ElevationErrorPath '$escapedErrorPath'" + if ($Scenario) { + $command += " -Scenario '$Scenario' -AiBackend '$AiBackend' -AiRuntime '$AiRuntime'" + if ($RequireTriton) { $command += ' -RequireTriton' } + if ($PlanOnly) { $command += ' -PlanOnly' } + if ($ReportRoot) { + $escapedReportRoot = [Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($ReportRoot) + $command += " -ReportRoot '$escapedReportRoot'" + } + } else { + $command += " -Action '$Action'" + } if ($AllowUnsigned) { $command += ' -AllowUnsigned' } if ($NoLaunch) { $command += ' -NoLaunch' } # Start-Process joins arguments; Windows quoting keeps the command intact. @@ -168,15 +238,31 @@ function Invoke-CalmOsBootstrap { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - $command = Get-CalmOsElevationCommand -Ref $Ref -InstallRoot $InstallRoot -AllowUnsigned:$AllowUnsigned -NoLaunch:$NoLaunch -Action $Action + $elevationErrorPath = Join-Path $env:TEMP "CalmOS-bootstrap-error-$([guid]::NewGuid().ToString('N')).txt" + $command = Get-CalmOsElevationCommand -Ref $Ref -InstallRoot $InstallRoot -AllowUnsigned:$AllowUnsigned -NoLaunch:$NoLaunch ` + -Action $Action -Scenario $Scenario -AiBackend $AiBackend -AiRuntime $AiRuntime ` + -RequireTriton:$RequireTriton -PlanOnly:$PlanOnly -ReportRoot $ReportRoot ` + -ElevationErrorPath $elevationErrorPath Write-Host 'Setup needs Administrator rights (a UAC prompt will appear)...' -ForegroundColor Yellow $proc = Start-Process -FilePath $shell -ArgumentList ($arguments + @('-Command', $command)) -Verb RunAs -Wait -PassThru if ($proc.ExitCode -ne 0) { - throw "Elevated setup exited with code $($proc.ExitCode). No further setup was started." + $detail = if (Test-Path -LiteralPath $elevationErrorPath) { + (Get-Content -LiteralPath $elevationErrorPath -Raw).Trim() + } else { + 'The elevated process did not return diagnostic output.' + } + Remove-Item -LiteralPath $elevationErrorPath -Force -ErrorAction SilentlyContinue + throw "Elevated setup exited with code $($proc.ExitCode). No further setup was started.`n$detail" } + Remove-Item -LiteralPath $elevationErrorPath -Force -ErrorAction SilentlyContinue if ($NoLaunch) { - $escapedTarget = [Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent((Join-Path $InstallRoot 'dev-config.ps1')) - Write-Host "Run when ready: & '$escapedShell' $($arguments -join ' ') -File '$escapedTarget' -Action $Action$(if ($AllowUnsigned) { ' -AllowUnsigned' })" + if ($Scenario) { + $scenarioTarget = Join-Path $InstallRoot 'Scenarios\local-ai\Workloads\local-ai\install.ps1' + Write-Host "Scenario files are ready at $scenarioTarget." -ForegroundColor Cyan + } else { + $escapedTarget = [Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent((Join-Path $InstallRoot 'dev-config.ps1')) + Write-Host "Run when ready: & '$escapedShell' $($arguments -join ' ') -File '$escapedTarget' -Action $Action$(if ($AllowUnsigned) { ' -AllowUnsigned' })" + } } return } @@ -262,6 +348,62 @@ function Invoke-CalmOsBootstrap { $InstallRoot = New-DevConfigProtectedDirectory -Path $InstallRoot + if ($Scenario) { + $workloadsDir = if ($AllowUnsigned) { + Join-Path (Join-Path $top.FullName 'src') 'Workloads' + } else { + Join-Path $top.FullName 'Workloads' + } + if (-not ((Test-Path (Join-Path $workloadsDir 'local-ai\install.ps1')) -and + (Test-Path (Join-Path $workloadsDir '_common\content-hashes.ps1')))) { + throw "'$Ref' does not contain the requested local-ai workload under the selected signed/source tree." + } + Assert-DevConfigProtectedTree -Directory $workloadsDir + if (-not $AllowUnsigned) { + Assert-DevConfigMicrosoftSigned -Directory $workloadsDir + } + . (Join-Path $workloadsDir '_common\content-hashes.ps1') + Assert-DevConfigWorkloadContent -WorkloadsRoot $workloadsDir + + $scenariosRoot = New-DevConfigProtectedDirectory -Path (Join-Path $InstallRoot 'Scenarios') + $scenarioRoot = New-DevConfigProtectedDirectory -Path (Join-Path $scenariosRoot 'local-ai') + foreach ($existing in @('Workloads', 'windows-dev-config')) { + $existingPath = Join-Path $scenarioRoot $existing + if (Test-Path -LiteralPath $existingPath) { + Remove-Item -LiteralPath $existingPath -Recurse -Force + } + } + Copy-Item -LiteralPath $workloadsDir -Destination $scenarioRoot -Recurse -Force + $scenarioWindowsDevConfig = New-Item -ItemType Directory -Path (Join-Path $scenarioRoot 'windows-dev-config') -Force + Copy-Item -LiteralPath (Join-Path $setupDir 'steps') -Destination $scenarioWindowsDevConfig.FullName -Recurse -Force + Assert-DevConfigProtectedTree -Directory $scenarioRoot + if (-not $AllowUnsigned) { + Assert-DevConfigMicrosoftSigned -Directory $scenarioRoot + } + . (Join-Path $scenarioRoot 'Workloads\_common\content-hashes.ps1') + Assert-DevConfigWorkloadContent -WorkloadsRoot (Join-Path $scenarioRoot 'Workloads') + Get-ChildItem -LiteralPath $scenarioRoot -Recurse -Filter '*.ps1' -File | Unblock-File + + Remove-Item -LiteralPath $work -Recurse -Force + $target = Join-Path $scenarioRoot 'Workloads\local-ai\install.ps1' + Write-Host " Scenario ready in $scenarioRoot" -ForegroundColor DarkGray + $scenarioArguments = @('-NoProfile') + if (-not $AllowUnsigned) { $scenarioArguments += '-ExecutionPolicy', 'RemoteSigned' } + $scenarioArguments += '-File', "`"$target`"", '-Backend', $AiBackend, '-Runtime', $AiRuntime + if ($RequireTriton) { $scenarioArguments += '-RequireTriton' } + if ($PlanOnly) { $scenarioArguments += '-PlanOnly' } + if ($ReportRoot) { $scenarioArguments += '-ReportRoot', "`"$ReportRoot`"" } + if ($NoLaunch) { + Write-Host "Run when ready: & '$escapedShell' $($scenarioArguments -join ' ')" -ForegroundColor Cyan + return + } + $proc = Start-Process -FilePath $shell -ArgumentList $scenarioArguments -Wait -PassThru -NoNewWindow + if ($proc.ExitCode -ne 0) { + throw "Local AI scenario finished with exit code $($proc.ExitCode)." + } + return + } + # Keep logs and progress when replacing setup scripts. Copy-Item -LiteralPath (Join-Path $setupDir 'bootstrap.ps1'), (Join-Path $setupDir 'dev-config.ps1') -Destination $InstallRoot -Force Copy-Item -LiteralPath (Join-Path $setupDir 'steps') -Destination $InstallRoot -Recurse -Force diff --git a/src/windows-dev-config/steps/_environment.ps1 b/src/windows-dev-config/steps/_environment.ps1 index b001e85c..636bd2e8 100644 --- a/src/windows-dev-config/steps/_environment.ps1 +++ b/src/windows-dev-config/steps/_environment.ps1 @@ -24,8 +24,21 @@ function Invoke-DevConfigNativeCommand { param($FilePath, $Arguments) $ErrorActionPreference = 'Continue' $PSNativeCommandUseErrorActionPreference = $false - $output = & $FilePath @Arguments 2>&1 | Out-String - [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output } + $previousExitCode = if (Test-Path Variable:global:LASTEXITCODE) { $global:LASTEXITCODE } else { $null } + Remove-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + try { + $output = & $FilePath @Arguments 2>&1 | Out-String + if (-not (Test-Path Variable:global:LASTEXITCODE)) { + throw "Native command '$FilePath' did not launch successfully." + } + [pscustomobject]@{ ExitCode = $global:LASTEXITCODE; Output = $output } + } finally { + if ($null -ne $previousExitCode) { + $global:LASTEXITCODE = $previousExitCode + } else { + Remove-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + } + } } if ($TimeoutSeconds -eq 0) { return & $invoke $FilePath $Arguments diff --git a/src/windows-dev-config/steps/_winget.ps1 b/src/windows-dev-config/steps/_winget.ps1 index 3af48bb4..606f1260 100644 --- a/src/windows-dev-config/steps/_winget.ps1 +++ b/src/windows-dev-config/steps/_winget.ps1 @@ -274,29 +274,37 @@ function Update-DevConfigWinget { } function Test-DevConfigWingetPackageInstalled { + param( + [Parameter(Mandatory)] [string] $Id + ) + return (Get-DevConfigWingetPackageState -Id $Id).State -eq 'Current' +} + +function Get-DevConfigWingetPackageState { param( [Parameter(Mandatory)] [string] $Id ) if ($Script:DevConfigWinGetMode -eq 'Cli') { $listed = Invoke-DevConfigWingetCli -Arguments @('list', '--id', $Id, '--exact', '--accept-source-agreements') if ($listed.ExitCode -eq $Script:DevConfigWingetNotFound) { - return $false + return [pscustomobject]@{ State = 'Absent'; Package = $null } } if ($listed.ExitCode -ne 0) { throw "winget list $Id failed with exit code $($listed.ExitCode)" } - # useLatest requires the package to be current, not only installed, so match the module path. - return -not (Test-DevConfigWingetUpgradeAvailable -Id $Id) + $state = if (Test-DevConfigWingetUpgradeAvailable -Id $Id) { 'UpgradeAvailable' } else { 'Current' } + return [pscustomobject]@{ State = $state; Package = $null } } # EqualsCaseInsensitive avoids ambiguous substring matches. $pkg = Get-WinGetPackage -Id $Id -Source winget -MatchOption EqualsCaseInsensitive if (-not $pkg) { - return $false + return [pscustomobject]@{ State = 'Absent'; Package = $null } } - # useLatest requires the package to be current, not only installed. - return -not $pkg.IsUpdateAvailable + $updateProperty = $pkg.PSObject.Properties['IsUpdateAvailable'] + $state = if ($updateProperty -and [bool]$updateProperty.Value) { 'UpgradeAvailable' } else { 'Current' } + return [pscustomobject]@{ State = $state; Package = $pkg } } # winget list exits 0 whether or not an upgrade exists, and every message it prints is localized. @@ -314,25 +322,123 @@ function Test-DevConfigWingetUpgradeAvailable { return @($upgrade.Output -split '\r?\n' | Where-Object { $_ -match ('(^|\s)' + [regex]::Escape($Id) + '(\s|$)') }).Count -gt 0 } +function Get-DevConfigWingetInstallArguments { + param( + [Parameter(Mandatory)] [string] $Id + ) + return @( + 'install', '--id', $Id, '--exact', '--source', 'winget', '--silent', + '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity' + ) +} + +function Get-DevConfigWingetUpgradeArguments { + param( + [Parameter(Mandatory)] [string] $Id + ) + return @( + 'upgrade', '--id', $Id, '--exact', '--source', 'winget', '--silent', + '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity' + ) +} + function Install-DevConfigWingetPackage { param( [Parameter(Mandatory)] [string] $Id ) Invoke-DevConfigRetry -Name "winget install $Id" -ScriptBlock { if ($Script:DevConfigWinGetMode -eq 'Cli') { - $r = Invoke-DevConfigWingetCli -Arguments @('install', '--id', $Id, '--exact', '--source', 'winget', '--silent', '--accept-package-agreements', '--accept-source-agreements') + $r = Invoke-DevConfigWingetCli -Arguments (Get-DevConfigWingetInstallArguments -Id $Id) if ($r.ExitCode -ne 0 -and $r.ExitCode -ne $Script:DevConfigWingetNoUpgrade) { throw "winget install $Id failed with exit code $($r.ExitCode)" } return } - $result = Install-WinGetPackage -Id $Id -Source winget -Mode Silent -MatchOption EqualsCaseInsensitive - # NoApplicableUpgrade means the package is already installed and current. - if (-not $result.Succeeded() -and $result.Status -ne 'NoApplicableUpgrade') { - throw "winget install $Id failed: $($result.ErrorMessage())" + try { + $result = Install-WinGetPackage -Id $Id -Source winget -Mode Silent -MatchOption EqualsCaseInsensitive + # NoApplicableUpgrade means the package is already installed and current. + if (-not $result.Succeeded() -and $result.Status -ne 'NoApplicableUpgrade') { + throw "winget module install $Id failed: $($result.ErrorMessage())" + } + return + } catch { + $moduleError = $_.Exception.Message + if (-not (Test-DevConfigWingetCliUsable)) { + throw + } + Write-Host " WinGet module install failed; retrying with winget.exe ($moduleError)" -ForegroundColor Yellow + $r = Invoke-DevConfigWingetCli -Arguments (Get-DevConfigWingetInstallArguments -Id $Id) + if ($r.ExitCode -ne 0 -and $r.ExitCode -ne $Script:DevConfigWingetNoUpgrade) { + throw "winget install $Id failed after module fallback (module: $moduleError; CLI exit: $($r.ExitCode))" + } + $Script:DevConfigWinGetMode = 'Cli' + } + } +} + +function Update-DevConfigWingetPackage { + param( + [Parameter(Mandatory)] [string] $Id + ) + Invoke-DevConfigRetry -Name "winget upgrade $Id" -ScriptBlock { + if ($Script:DevConfigWinGetMode -eq 'Cli') { + $r = Invoke-DevConfigWingetCli -Arguments (Get-DevConfigWingetUpgradeArguments -Id $Id) + if ($r.ExitCode -ne 0 -and $r.ExitCode -ne $Script:DevConfigWingetNoUpgrade) { + throw "winget upgrade $Id failed with exit code $($r.ExitCode)" + } + return + } + + try { + $result = Update-WinGetPackage -Id $Id -Source winget -Mode Silent -MatchOption EqualsCaseInsensitive + if (-not $result.Succeeded() -and $result.Status -ne 'NoApplicableUpgrade') { + throw "winget module upgrade $Id failed: $($result.ErrorMessage())" + } + return + } catch { + $moduleError = $_.Exception.Message + if (-not (Test-DevConfigWingetCliUsable)) { + throw + } + Write-Host " WinGet module upgrade failed; retrying with winget.exe ($moduleError)" -ForegroundColor Yellow + $r = Invoke-DevConfigWingetCli -Arguments (Get-DevConfigWingetUpgradeArguments -Id $Id) + if ($r.ExitCode -ne 0 -and $r.ExitCode -ne $Script:DevConfigWingetNoUpgrade) { + throw "winget upgrade $Id failed after module fallback (module: $moduleError; CLI exit: $($r.ExitCode))" + } + $Script:DevConfigWinGetMode = 'Cli' + } + } +} + +function Ensure-DevConfigWingetPackage { + param( + [Parameter(Mandatory)] [string] $Id + ) + + $state = Get-DevConfigWingetPackageState -Id $Id + switch ($state.State) { + 'Current' { + return 'already-current' + } + 'UpgradeAvailable' { + Update-DevConfigWingetPackage -Id $Id + $action = 'upgraded' + } + 'Absent' { + Install-DevConfigWingetPackage -Id $Id + $action = 'installed' } + default { + throw "Unknown WinGet package state '$($state.State)' for '$Id'." + } + } + + Wait-DevConfigWingetPackageSettled -Id $Id + if ((Get-DevConfigWingetPackageState -Id $Id).State -ne 'Current') { + throw "WinGet did not verify '$Id' as installed and current after $action." } + return $action } # Get-WinGetPackage catalog reads can lag after install, so wait before checking the result. diff --git a/src/windows-dev-config/steps/packages.ps1 b/src/windows-dev-config/steps/packages.ps1 index 615d0794..a7f03009 100644 --- a/src/windows-dev-config/steps/packages.ps1 +++ b/src/windows-dev-config/steps/packages.ps1 @@ -231,8 +231,7 @@ function Invoke-PackagesPhase { param($Id, $Large) # Large packages can have several quiet download minutes because WinGet reports no progress here. if ($Large) { Write-Host ' (Large download -- several quiet minutes here are normal.)' -ForegroundColor DarkGray } - Install-DevConfigWingetPackage -Id $Id - Wait-DevConfigWingetPackageSettled -Id $Id + [void](Ensure-DevConfigWingetPackage -Id $Id) } ` -ArgumentList @($package.Id, $package.ContainsKey('Large')) }