From 025bf1919eece79fb2358924bbcd50bf409b8e51 Mon Sep 17 00:00:00 2001 From: Daoyuan Li <94409450+DaoyuanLi2816@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:42:16 -0700 Subject: [PATCH] Add bounded CUDA hardware probe --- CHANGELOG.md | 6 + PROJECT_STATE.md | 8 + PYPI.md | 6 +- README.md | 6 +- README.zh-CN.md | 4 +- docs/hardware-planning.md | 36 ++++ docs/release-checklist.md | 9 +- mkdocs.yml | 1 + scripts/check_docs_visual.py | 1 + src/miniverl/bridge/opd_plan.py | 21 ++ src/miniverl/bridge/opd_probe.py | 283 ++++++++++++++++++++++++++ src/miniverl/cli.py | 36 +++- tests/unit/test_opd_hardware_probe.py | 135 ++++++++++++ tests/unit/test_opd_immutable_plan.py | 21 ++ 14 files changed, 557 insertions(+), 16 deletions(-) create mode 100644 docs/hardware-planning.md create mode 100644 src/miniverl/bridge/opd_probe.py create mode 100644 tests/unit/test_opd_hardware_probe.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 317f0f0..b516e5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ All notable changes to miniVERL are recorded here. The format follows validation. The plan digest is carried by run manifests, teacher caches and checkpoints; direct `run --config` remains supported. +### Bounded hardware probe + +- Implemented explicit `plan --probe` calibration with sequential role loading, + tiny rollout/teacher-score/selected-position-backward phases, exact cache + identity, zero optimizer updates and post-release CUDA-memory verification. + ### Verl config UX - Added safe trailing Hydra-style overrides and repeatable plain/JSON diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 9947a06..3a019cd 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -35,6 +35,14 @@ bytes without recompiling; the canonical plan digest propagates into the run manifest, teacher cache and every checkpoint. Tokenizer structural identity is truthfully deferred until tokenizers are loaded by the later bounded probe. +The bounded probe is now implemented and was exercised on the local RTX 4080. +It loads the exact pinned Qwen pair sequentially, measures tiny actor rollout, +teacher score and selected-position backward phases, performs zero optimizer +updates and refuses results that do not release role allocation near baseline. +Probe caches bind hardware/software, model, tokenizer, quantization, token and +plan identities; cached and fresh publication of the same measurement produces +byte-identical immutable plans. + ## v0.8.1 product surface The landing pages now lead with the documented one-GPU verl-style OPD journey, diff --git a/PYPI.md b/PYPI.md index fa4cc51..9c9bcea 100644 --- a/PYPI.md +++ b/PYPI.md @@ -167,8 +167,10 @@ the [exact smoke record and limitations](https://github.com/DaoyuanLi2816/mini-v Automatic BF16/FP16 selection follows device support; it is not inferred from marketing names such as 3070, 4080, 5090 or Titan. `miniverl doctor` reports the -installed CUDA/PyTorch path, and the planner keeps estimates visibly separate -from measurements. There is no automatic downgrade to a different model, +installed CUDA/PyTorch path. Normal planning is weight-free; explicit +`plan --probe` adds bounded, cached CUDA measurements with zero optimizer +updates. See [hardware planning](https://github.com/DaoyuanLi2816/mini-verl/blob/main/docs/hardware-planning.md). There is no +automatic downgrade to a different model, teacher, context, top-k or loss when memory is tight. ## Data and artifact interoperability diff --git a/README.md b/README.md index 38170b5..55be171 100644 --- a/README.md +++ b/README.md @@ -167,8 +167,10 @@ the [exact smoke record and limitations](docs/opd-quickstart.md). Automatic BF16/FP16 selection follows device support; it is not inferred from marketing names such as 3070, 4080, 5090 or Titan. `miniverl doctor` reports the -installed CUDA/PyTorch path, and the planner keeps estimates visibly separate -from measurements. There is no automatic downgrade to a different model, +installed CUDA/PyTorch path. Normal planning is weight-free; explicit +`plan --probe` adds bounded, cached CUDA measurements with zero optimizer +updates. See [hardware planning](docs/hardware-planning.md). There is no +automatic downgrade to a different model, teacher, context, top-k or loss when memory is tight. ## Data and artifact interoperability diff --git a/README.zh-CN.md b/README.zh-CN.md index 757bc1d..1bf73ba 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -139,7 +139,9 @@ OPD update,**peak reserved VRAM 为 3.1758 GiB**,首次 update 在 **12.0224 | 显存更充足 | 增大各阶段 physical batch | 数据与 optimizer 意图 | BF16/FP16 自动选择取决于设备能力,而不是 3070、4080、5090 或 Titan 等市场名称。 -`miniverl doctor` 报告实际 CUDA/PyTorch 路径,planner 会把 estimate 与 measurement 分开。 +`miniverl doctor` 报告实际 CUDA/PyTorch 路径;普通 plan 不加载权重,显式 +`plan --probe` 才执行零 optimizer update 的有界 CUDA 测量。详见 +[硬件规划](docs/hardware-planning.md)。 显存紧张时不会静默更换模型、teacher、context、top-k 或 loss。 ## 数据与产物互操作 diff --git a/docs/hardware-planning.md b/docs/hardware-planning.md new file mode 100644 index 0000000..fb809be --- /dev/null +++ b/docs/hardware-planning.md @@ -0,0 +1,36 @@ +# Hardware planning and bounded probes + +Normal planning is CPU-safe and weight-free. It labels memory as estimated and +time as unknown: + +```bash +miniverl plan --config verl-opd.yaml --out plan.json --offline +``` + +After reviewing the config, an explicit probe can calibrate the exact pinned +models on one visible CUDA GPU: + +```bash +miniverl plan --config verl-opd.yaml \ + --accept-local-reinterpretations --out probed-plan.json \ + --probe --offline +``` + +The bounded probe loads roles sequentially. It measures actor static memory, +greedy 2-token rollout candidates, one selected-position backward, teacher +static memory, and one top-k teacher score. It creates no optimizer, performs +zero parameter updates, and publishes no checkpoint. Role objects and temporary +tensors are destroyed between phases; failure to return allocation near the +starting CUDA baseline invalidates the probe. + +Results separate measured phase values, recommendations and failed OOM +candidates. The cache key binds GPU UUID/name/capability/memory, driver, CUDA +runtime, Torch and miniVERL versions, plan digest, model/tokenizer revisions, +quantization, LoRA, token bounds and top-k. A mismatched or modified cache is +never reused. Use `--force-probe` to remeasure deliberately. + +The probe is calibration, not training or a throughput benchmark. Its tiny +inputs do not prove that the full logical workload fits. Retain configured +headroom and treat the recommended batches as conservative starting points; +the runtime still fails closed rather than changing model, teacher, context, +top-k or loss semantics after an OOM. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 61f585e..16bcc0d 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -26,7 +26,8 @@ verl check before squash merge `8d3ebb2`. provenance while preserving the repeatable `--set` interface. - [x] Bind execution to an immutable plan artifact and fail closed when its config, data, model, tokenizer or compatibility acceptance has drifted. -- [ ] Add a bounded hardware probe, transactional model materialization and a +- [x] Add a bounded hardware probe with strict cache identity and no updates. +- [ ] Add transactional model materialization and a realistic one-GPU quickstart without widening the documented algorithm. - [ ] Preserve every frozen scientific artifact and keep distributed verl, policy-gradient OPD and unsupported objective semantics fail-closed. @@ -49,6 +50,12 @@ source YAML, Parquet files, acceptance, native config, cache identity and checkpoint identity. Ruff, mypy, actionlint, strict MkDocs, package/Twine, generated-description, link/text and all four Playwright viewports also pass. +The bounded-probe candidate passed 2,203 local non-GPU/non-network tests at +84.54% branch coverage, 8 RTX 4080 GPU tests and 13 network tests with 2 +environment-dependent skips. A real offline RTX 4080 probe of the pinned Qwen3 +pair completed in 13.63 seconds with zero parameter updates and no checkpoint; +fresh and exact-cache reuse produced byte-identical plans. + ## v0.8.0 single-GPU verl OPD pivot - [x] Implement and validate the documented `verl-opd-v0.8-single-gpu-v1` diff --git a/mkdocs.yml b/mkdocs.yml index 986f21b..80e3c10 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ nav: - For verl users: for-verl-users.md - Config overrides: config-overrides.md - Immutable plans: immutable-plans.md + - Hardware planning: hardware-planning.md - Run verl-style OPD: opd-quickstart.md - Start on one GPU: single-gpu-guide.md - Align: diff --git a/scripts/check_docs_visual.py b/scripts/check_docs_visual.py index caa0745..da32c84 100644 --- a/scripts/check_docs_visual.py +++ b/scripts/check_docs_visual.py @@ -34,6 +34,7 @@ "/for-verl-users/", "/config-overrides/", "/immutable-plans/", + "/hardware-planning/", "/alignment-lab/alignment-lab-v1/", "/alignment-external/alignment-external-v1/", "/consumer-runtime/", diff --git a/src/miniverl/bridge/opd_plan.py b/src/miniverl/bridge/opd_plan.py index 3e5a116..ee17d85 100644 --- a/src/miniverl/bridge/opd_plan.py +++ b/src/miniverl/bridge/opd_plan.py @@ -51,6 +51,7 @@ class ImmutableOPDPlan(BaseModel): tokenizers: dict[str, Any] loss: dict[str, Any] execution_recommendations: dict[str, Any] + hardware_probe: dict[str, Any] | None = None plan_digest: str @@ -213,6 +214,7 @@ def build_immutable_opd_plan( "time_to_first_update": system.time_to_first_update, "evidence_status": "estimated_or_unknown_not_measured", }, + "hardware_probe": None, "plan_digest": "0" * 64, } digest = _digest_payload(payload) @@ -287,3 +289,22 @@ def load_and_verify_immutable_opd_plan(path: str | Path) -> tuple[ImmutableOPDPl def write_immutable_opd_plan(path: str | Path, plan: ImmutableOPDPlan) -> None: """Atomically publish a canonical plan artifact.""" write_json_atomic(Path(path), plan.model_dump(mode="json")) + + +def attach_hardware_probe( + plan: ImmutableOPDPlan, hardware_probe: dict[str, Any] +) -> ImmutableOPDPlan: + """Bind one measured probe to a new immutable plan digest.""" + payload = plan.model_dump(mode="json") + bound_probe = json.loads(json.dumps(hardware_probe)) + # Cache path/reuse is invocation-local transport metadata. The measured + # payload is identical whether freshly measured or loaded from its exact- + # identity cache, so the immutable plan must also remain identical. + bound_probe.pop("cache", None) + payload["hardware_probe"] = bound_probe + payload["plan_digest"] = "0" * 64 + payload["resolved_native_config"]["run"]["execution_plan_digest"] = None + digest = _digest_payload(payload) + payload["plan_digest"] = digest + payload["resolved_native_config"]["run"]["execution_plan_digest"] = digest + return ImmutableOPDPlan.model_validate(payload) diff --git a/src/miniverl/bridge/opd_probe.py b/src/miniverl/bridge/opd_probe.py new file mode 100644 index 0000000..2af0f50 --- /dev/null +++ b/src/miniverl/bridge/opd_probe.py @@ -0,0 +1,283 @@ +"""Bounded CUDA calibration for immutable OPD execution plans.""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +import time +from pathlib import Path +from typing import Any + +from miniverl import __version__ +from miniverl.config.models import RunConfig, VerlParquetSourceConfig +from miniverl.errors import ConfigError +from miniverl.utils.runs import canonical_json, write_json_atomic + +__all__ = ["run_hardware_probe"] + + +def _digest(payload: Any) -> str: + return hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest() + + +def _device_identity() -> dict[str, Any] | None: + try: + import torch + except ImportError: + return None + if not torch.cuda.is_available(): + return None + index = torch.cuda.current_device() + properties = torch.cuda.get_device_properties(index) + uuid = getattr(properties, "uuid", None) + try: + driver = ( + subprocess.run( + ["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"], + check=True, + capture_output=True, + text=True, + timeout=10, + ) + .stdout.splitlines()[index] + .strip() + ) + except (FileNotFoundError, IndexError, OSError, subprocess.SubprocessError): + driver = "unavailable" + return { + "name": properties.name, + "uuid": str(uuid) if uuid is not None else None, + "compute_capability": [properties.major, properties.minor], + "total_memory_bytes": int(properties.total_memory), + "driver": driver, + "torch": torch.__version__, + "cuda_runtime": str(torch.version.cuda), + } + + +def _identity(native: RunConfig, *, plan_digest: str, device: dict[str, Any]) -> dict[str, Any]: + if not isinstance(native.source, VerlParquetSourceConfig): + raise ConfigError("OPD hardware probe requires a verl Parquet source") + return { + "schema_version": 1, + "miniverl_version": __version__, + "plan_digest": plan_digest, + "device": device, + "student": { + "model_id": native.models.student.model_id, + "revision": native.models.student.revision, + "tokenizer_id": native.models.student.tokenizer_id, + "tokenizer_revision": native.models.student.tokenizer_revision, + "quantization": native.models.student.quantization.value, + "dtype": native.models.student.dtype.value, + "lora": native.models.student.lora.model_dump(mode="json"), + }, + "teacher": { + "model_id": native.models.teacher.model_id, + "revision": native.models.teacher.revision, + "tokenizer_id": native.models.teacher.tokenizer_id, + "tokenizer_revision": native.models.teacher.tokenizer_revision, + "quantization": native.models.teacher.quantization.value, + "dtype": native.models.teacher.dtype.value, + "adapter": ( + native.models.teacher.adapter.model_dump(mode="json") + if native.models.teacher.adapter is not None + else None + ), + }, + "token_bounds": { + "max_prompt": native.source.max_prompt_length, + "max_response": native.source.max_response_length, + "max_total": native.rollout.max_total_tokens, + }, + "top_k": native.loss.top_k, + } + + +def _memory() -> dict[str, Any]: + from miniverl.utils import gpu + + return gpu.snapshot().to_dict() + + +def _measure_probe( + native: RunConfig, + *, + identity: dict[str, Any], + offline: bool, +) -> dict[str, Any]: + """Load roles sequentially and exercise inference/backward without an optimizer.""" + import torch + + from miniverl.models.factory import build_student, build_teacher, build_tokenizer + from miniverl.utils import gpu + + started = time.perf_counter() + baseline = _memory() + tokenizer = build_tokenizer(native, local_files_only=offline) + if not isinstance(native.source, VerlParquetSourceConfig): # pragma: no cover - typed caller + raise ConfigError("OPD hardware probe requires a verl Parquet source") + prompt = "<|im_start|>user\nCompute 2 + 2.<|im_end|>\n<|im_start|>assistant\n" + ids = tokenizer.encode(prompt) + if len(ids) > native.source.max_prompt_length: + ids = ids[-native.source.max_prompt_length :] + if len(ids) < 2: + raise ConfigError("probe prompt produced fewer than two tokens") + phases: dict[str, Any] = {} + failed: list[dict[str, Any]] = [] + student = None + teacher = None + hidden: Any = None + logits: Any = None + loss: Any = None + outputs: Any = None + rollout_ok = 0 + try: + gpu.empty_cache() + gpu.reset_peak_stats() + phase_started = time.perf_counter() + student = build_student(native, tokenizer, device="cuda", local_files_only=offline) + phases["actor_static"] = { + "seconds": time.perf_counter() - phase_started, + "memory": _memory(), + } + for batch in sorted({1, 2, 4, native.rollout.prompt_batch_size}): + try: + gpu.reset_peak_stats() + phase_started = time.perf_counter() + outputs = student.generate_batch( + [ids] * batch, + max_new_tokens=min(2, native.rollout.max_new_tokens_per_turn), + temperature=0.0, + ) + rollout_ok = batch + phases[f"rollout_batch_{batch}"] = { + "seconds": time.perf_counter() - phase_started, + "generated_tokens": sum(len(item.token_ids) for item in outputs), + "memory": _memory(), + } + except BaseException as exc: + if not gpu.is_oom_error(exc): + raise + failed.append({"phase": "rollout", "batch_size": batch, "reason": "cuda_oom"}) + gpu.empty_cache() + break + positions = list(range(max(0, len(ids) - 2), len(ids) - 1)) + gpu.reset_peak_stats() + phase_started = time.perf_counter() + hidden = student.hidden_states_at(ids, positions, with_grad=True) + logits = student.project(hidden).float() + loss = logits.square().mean() + loss.backward() + phases["selected_position_backward"] = { + "seconds": time.perf_counter() - phase_started, + "positions": len(positions), + "memory": _memory(), + } + finally: + hidden = None + logits = None + loss = None + outputs = None + if student is not None: + student.release() + student = None + gpu.empty_cache() + try: + gpu.reset_peak_stats() + phase_started = time.perf_counter() + teacher = build_teacher(native, tokenizer, device="cuda", local_files_only=offline) + phases["teacher_static"] = { + "seconds": time.perf_counter() - phase_started, + "memory": _memory(), + } + gpu.reset_peak_stats() + phase_started = time.perf_counter() + with torch.no_grad(): + hidden = teacher.hidden_states_at(ids, [len(ids) - 2], with_grad=False) + logits = teacher.project(hidden).float() + torch.topk(logits, k=min(native.loss.top_k, logits.shape[-1]), dim=-1) + phases["teacher_score_batch_1"] = { + "seconds": time.perf_counter() - phase_started, + "positions": 1, + "memory": _memory(), + } + finally: + hidden = None + logits = None + if teacher is not None: + teacher.release() + teacher = None + gpu.empty_cache() + released = _memory() + release_allowance = 64 * 1024**2 + if released["allocated_bytes"] > baseline["allocated_bytes"] + release_allowance: + raise ConfigError( + "hardware probe did not release role memory back near its CUDA baseline", + hint="close other references and retry; no training checkpoint was written", + ) + return { + "status": "measured", + "identity": identity, + "tokenizer_identity": getattr(tokenizer, "identity", {}), + "measurements": { + "phases": phases, + "baseline": baseline, + "after_release": released, + "duration_seconds": round(time.perf_counter() - started, 4), + "parameter_updates": 0, + "checkpoint_published": False, + }, + "recommendations": { + "rollout_batch_size": max(1, rollout_ok), + "teacher_score_batch_size": 1, + "update_trajectory_batch_size": 1, + "headroom_gib": native.memory.auto_swap_vram_headroom_gb, + "basis": "bounded_probe", + }, + "failed_candidates": failed, + } + + +def run_hardware_probe( + native: RunConfig, + *, + plan_digest: str, + cache_dir: str | Path, + offline: bool, + force: bool = False, +) -> dict[str, Any]: + """Return an exact-identity cached probe or perform one bounded measurement.""" + device = _device_identity() + if device is None: + raise ConfigError( + "--probe requires one visible CUDA device", + hint="run weight-free planning without --probe on CPU", + ) + identity = _identity(native, plan_digest=plan_digest, device=device) + key = _digest(identity) + root = Path(cache_dir) + target = root / f"{key}.json" + if target.is_file() and not force: + try: + payload = json.loads(target.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ConfigError(f"cannot read cached hardware probe {target}: {exc}") from exc + if payload.get("identity") != identity or payload.get("identity_digest") != key: + raise ConfigError("hardware probe cache identity does not match its filename") + if payload.get("probe_digest") != _digest( + {k: v for k, v in payload.items() if k not in {"probe_digest", "cache"}} + ): + raise ConfigError("hardware probe cache digest mismatch") + payload["cache"] = {"reused": True, "path": str(target)} + return payload + measured = _measure_probe(native, identity=identity, offline=offline) + if measured.get("measurements", {}).get("parameter_updates") != 0: + raise ConfigError("hardware probe updated parameters; refusing the result") + payload = {**measured, "identity_digest": key} + payload["probe_digest"] = _digest(payload) + root.mkdir(parents=True, exist_ok=True) + write_json_atomic(target, payload) + payload["cache"] = {"reused": False, "path": str(target)} + return payload diff --git a/src/miniverl/cli.py b/src/miniverl/cli.py index 5280309..703196f 100644 --- a/src/miniverl/cli.py +++ b/src/miniverl/cli.py @@ -936,12 +936,17 @@ def plan_command( ), as_json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."), offline: bool = typer.Option(False, "--offline", help="Do not access the network."), - probe: bool = typer.Option( - False, "--probe", help="Load models for a bounded probe (not available in v0.8.0)." + probe: bool = typer.Option(False, "--probe", help="Run a bounded no-update CUDA calibration."), + probe_cache: Path = typer.Option( + Path.home() / ".cache" / "miniverl" / "probes", + "--probe-cache", + help="Exact-identity hardware probe cache directory.", + ), + force_probe: bool = typer.Option( + False, "--force-probe", help="Ignore a compatible cached probe and measure again." ), ) -> None: """Plan pinned single-GPU verl-style OPD without loading model weights.""" - del offline try: from miniverl.bridge.opd_runtime import build_system_plan from miniverl.bridge.opd_v08 import ( @@ -953,11 +958,6 @@ def plan_command( raise ConfigError( f"unsupported OPD profile {profile!r}", hint=f"use --profile {VERL_OPD_V08_PROFILE}" ) - if probe: - raise ConfigError( - "--probe is not implemented in v0.8.0", - hint="run without --probe for the weight-free estimate", - ) compiled = load_verl_opd_v08_source( config, override_files=override_files, @@ -968,14 +968,30 @@ def plan_command( plan = build_system_plan(compiled) payload = plan.model_dump(mode="json") artifact = None - if out is not None: + if out is not None or probe: from miniverl.bridge.opd_plan import ( + attach_hardware_probe, build_immutable_opd_plan, write_immutable_opd_plan, ) artifact = build_immutable_opd_plan(compiled, source=config, system_plan=plan) - write_immutable_opd_plan(out, artifact) + if probe: + _require_training_stack("miniverl plan --probe") + from miniverl.bridge.opd_probe import run_hardware_probe + from miniverl.config import RunConfig + + native = RunConfig.model_validate(artifact.resolved_native_config) + measured = run_hardware_probe( + native, + plan_digest=artifact.plan_digest, + cache_dir=probe_cache, + offline=offline, + force=force_probe, + ) + artifact = attach_hardware_probe(artifact, measured) + if out is not None: + write_immutable_opd_plan(out, artifact) payload = artifact.model_dump(mode="json") except MiniVerlError as exc: _fail(exc) diff --git a/tests/unit/test_opd_hardware_probe.py b/tests/unit/test_opd_hardware_probe.py new file mode 100644 index 0000000..970b648 --- /dev/null +++ b/tests/unit/test_opd_hardware_probe.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from miniverl.bridge.opd_runtime import compile_native_run_config +from miniverl.bridge.opd_v08 import load_verl_opd_v08_source +from miniverl.errors import ConfigError + + +def _native(): + return compile_native_run_config(load_verl_opd_v08_source("builtin:qwen3-0.6b-1.7b-opd")) + + +def test_probe_fails_before_model_loading_without_cuda(monkeypatch, tmp_path: Path) -> None: + from miniverl.bridge import opd_probe + + monkeypatch.setattr(opd_probe, "_device_identity", lambda: None) + with pytest.raises(ConfigError, match="CUDA"): + opd_probe.run_hardware_probe( + _native(), plan_digest="a" * 64, cache_dir=tmp_path, offline=True + ) + + +def test_probe_cache_is_identity_bound_and_reused(monkeypatch, tmp_path: Path) -> None: + from miniverl.bridge import opd_probe + + device = { + "name": "NVIDIA GeForce RTX 4080", + "uuid": "GPU-test", + "compute_capability": [8, 9], + "total_memory_bytes": 16 * 1024**3, + "driver": "test-driver", + "torch": "test-torch", + "cuda_runtime": "test-cuda", + } + monkeypatch.setattr(opd_probe, "_device_identity", lambda: device) + calls = 0 + + def measure(native, *, identity, offline): + nonlocal calls + calls += 1 + return { + "status": "measured", + "identity": identity, + "measurements": {"parameter_updates": 0}, + "recommendations": {"rollout_batch_size": 1}, + "failed_candidates": [], + } + + monkeypatch.setattr(opd_probe, "_measure_probe", measure) + first = opd_probe.run_hardware_probe( + _native(), plan_digest="a" * 64, cache_dir=tmp_path, offline=True + ) + second = opd_probe.run_hardware_probe( + _native(), plan_digest="a" * 64, cache_dir=tmp_path, offline=True + ) + assert calls == 1 + assert first["cache"]["reused"] is False + assert second["cache"]["reused"] is True + assert second["probe_digest"] == first["probe_digest"] + cached = json.loads(next(tmp_path.glob("*.json")).read_text(encoding="utf-8")) + assert cached["identity"]["plan_digest"] == "a" * 64 + + +def test_probe_cache_miss_when_plan_or_token_bounds_change(monkeypatch, tmp_path: Path) -> None: + from miniverl.bridge import opd_probe + + monkeypatch.setattr( + opd_probe, + "_device_identity", + lambda: { + "name": "GPU", + "uuid": "GPU-x", + "compute_capability": [8, 9], + "total_memory_bytes": 1, + "driver": "d", + "torch": "t", + "cuda_runtime": "c", + }, + ) + calls = 0 + + def measure(native, *, identity, offline): + nonlocal calls + calls += 1 + return { + "status": "measured", + "identity": identity, + "measurements": {"parameter_updates": 0}, + "recommendations": {}, + "failed_candidates": [], + } + + monkeypatch.setattr(opd_probe, "_measure_probe", measure) + opd_probe.run_hardware_probe(_native(), plan_digest="a" * 64, cache_dir=tmp_path, offline=True) + changed = _native() + changed.source.max_response_length += 1 + opd_probe.run_hardware_probe(changed, plan_digest="b" * 64, cache_dir=tmp_path, offline=True) + assert calls == 2 + + +def test_probe_result_must_prove_zero_parameter_updates(monkeypatch, tmp_path: Path) -> None: + from miniverl.bridge import opd_probe + + monkeypatch.setattr( + opd_probe, + "_device_identity", + lambda: { + "name": "GPU", + "uuid": "GPU-x", + "compute_capability": [8, 9], + "total_memory_bytes": 1, + "driver": "d", + "torch": "t", + "cuda_runtime": "c", + }, + ) + monkeypatch.setattr( + opd_probe, + "_measure_probe", + lambda *args, **kwargs: { + "status": "measured", + "identity": kwargs["identity"], + "measurements": {"parameter_updates": 1}, + "recommendations": {}, + "failed_candidates": [], + }, + ) + with pytest.raises(ConfigError, match="updated parameters"): + opd_probe.run_hardware_probe( + _native(), plan_digest="a" * 64, cache_dir=tmp_path, offline=True + ) diff --git a/tests/unit/test_opd_immutable_plan.py b/tests/unit/test_opd_immutable_plan.py index 842b400..7fac429 100644 --- a/tests/unit/test_opd_immutable_plan.py +++ b/tests/unit/test_opd_immutable_plan.py @@ -152,3 +152,24 @@ def test_external_plan_requires_acceptance_before_publication(tmp_path: Path) -> assert refused.exit_code == 1 assert "--accept-local-reinterpretations" in refused.output assert not target.exists() + + +def test_probe_cache_transport_metadata_does_not_change_plan_digest(tmp_path: Path) -> None: + from miniverl.bridge.opd_plan import attach_hardware_probe, build_immutable_opd_plan + from miniverl.bridge.opd_v08 import load_verl_opd_v08_source + + profile, _ = _external_profile(tmp_path) + compiled = load_verl_opd_v08_source(profile, accept_local_reinterpretations=True) + base = build_immutable_opd_plan(compiled, source=profile) + measured = { + "status": "measured", + "identity": {"plan_digest": base.plan_digest}, + "identity_digest": "a" * 64, + "probe_digest": "b" * 64, + "measurements": {"parameter_updates": 0}, + "recommendations": {}, + "failed_candidates": [], + } + fresh = attach_hardware_probe(base, {**measured, "cache": {"reused": False, "path": "one"}}) + reused = attach_hardware_probe(base, {**measured, "cache": {"reused": True, "path": "two"}}) + assert fresh == reused