"""`meshnet-node doctor` — prove the selected shard actually runs. The doctor answers one question: *would the model/shard/recipe this node is configured to serve really execute here?* It answers it the only way that is not a guess — by loading the selection through the production backend path and pushing a bounded, real forward through the selected layers. Generic hardware probing (is there a GPU, can Torch allocate a tensor) proves nothing about a shard and is deliberately not what this reports on. Two shapes of probe, chosen by where the shard sits, never by which model it is: * head shard — tokenize a short prompt, embed it, run this shard's layers. * mid/tail shard — synthesize a small hidden-state tensor in the same wire format peers send, and push it through `forward_bytes`. A tail shard decodes it, which also exercises the final norm and `lm_head`. Everything here is model-agnostic: `model_id` is opaque, and no vendor or kernel name is a branch. Failures are reported as a category plus an actionable hint (never a raw traceback, unless the caller asks for one) and produce a *failed* capability report — a failure is evidence too, and NCA-003 refuses to register without a fresh passing one. """ from __future__ import annotations import base64 import struct import time from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Mapping, Sequence from .capability import ( STATUS_FAILED, STATUS_PASSED, CapabilityReport, build_capability_report, ) from .native_backend import NativeWorkerBackendAdapter from .recipe_manifest import ( DEFAULT_RECIPE_ID, Recipe, RecipeManifest, RecipeManifestError, load_recipe_manifest, ) # The probe is deliberately tiny: enough tokens to drive every layer in the # shard once, small enough that `doctor` costs seconds beyond the model load. PROBE_TOKENS = 4 PROBE_PROMPT = "meshnet capability probe" # Failure categories. These are what an operator acts on, so they name the thing # to fix, not the exception that surfaced it. CATEGORY_NO_MODEL = "no-model-selected" CATEGORY_MISSING_DEPENDENCY = "missing-dependency" CATEGORY_MODEL_UNAVAILABLE = "model-unavailable" CATEGORY_INSUFFICIENT_MEMORY = "insufficient-memory" CATEGORY_INVALID_SHARD = "invalid-shard" CATEGORY_UNSUPPORTED_RECIPE = "unsupported-recipe" CATEGORY_LOAD_FAILED = "load-failed" CATEGORY_FORWARD_FAILED = "forward-failed" CATEGORY_HINTS: Mapping[str, str] = { CATEGORY_NO_MODEL: ( "No model is selected. Pass --model , or run `meshnet-node` " "once to save a config." ), CATEGORY_MISSING_DEPENDENCY: ( "The model runtime is not installed. Install the node's model extras " "(torch, transformers, safetensors, accelerate, bitsandbytes)." ), CATEGORY_MODEL_UNAVAILABLE: ( "The model files could not be read. Check the model id, --download-dir, " "and that the artifact is downloaded or reachable." ), CATEGORY_INSUFFICIENT_MEMORY: ( "This shard does not fit in memory. Serve fewer layers (--shard-start / " "--shard-end) or use a smaller quantization (-q int8, -q nf4)." ), CATEGORY_INVALID_SHARD: ( "The requested layer range does not exist in this model. Check " "--shard-start / --shard-end against the model's layer count." ), CATEGORY_UNSUPPORTED_RECIPE: ( "The recipe asks for an execution setting this backend cannot apply. " "Select a different recipe with --recipe." ), CATEGORY_LOAD_FAILED: ( "The shard could not be loaded. Re-run with --debug for the full traceback." ), CATEGORY_FORWARD_FAILED: ( "The shard loaded but could not execute a forward pass. This node cannot " "serve this model/shard; re-run with --debug for the full traceback." ), } class DoctorError(RuntimeError): """A validation failure with an operator-facing category and hint.""" def __init__(self, category: str, message: str) -> None: super().__init__(message) self.category = category @property def hint(self) -> str: return CATEGORY_HINTS.get(self.category, "") @dataclass(frozen=True) class DoctorSelection: """The one model/shard/config combination startup would load.""" model_id: str shard_start: int shard_end: int quantization: str = "auto" cache_dir: Path | None = None force_cpu: bool = False @property def shard_label(self) -> str: return f"layers {self.shard_start}–{self.shard_end}" @dataclass(frozen=True) class RecipeResult: """One recipe's validation outcome, with the report it produced.""" recipe: Recipe report: CapabilityReport category: str | None = None error: BaseException | None = None @property def passed(self) -> bool: return self.report.passed @property def hint(self) -> str: return CATEGORY_HINTS.get(self.category or "", "") @dataclass(frozen=True) class DoctorResult: """The outcome of a doctor run over one or more recipes.""" selection: DoctorSelection results: tuple[RecipeResult, ...] = () @property def passed(self) -> bool: return bool(self.results) and all(r.passed for r in self.results) @property def reports(self) -> tuple[CapabilityReport, ...]: return tuple(r.report for r in self.results) @property def exit_code(self) -> int: return 0 if self.passed else 1 # --- selection: the same resolution startup performs ------------------------ def resolve_selection( cfg: Mapping[str, Any], *, detect_layers: Callable[[str, Path | None], int | None] | None = None, ) -> DoctorSelection: """Resolve config + flags into the selection startup would load. This mirrors `startup.run_startup`: the same model id, the same `bf16`→`bfloat16` quantization normalization, and the same shard default of the whole model when no range is pinned. It deliberately does *not* ask the tracker for a gap assignment — the doctor is an offline check of what this node can run, and startup re-validates whatever range it is finally given. """ model_id = _selected_model_id(cfg) if not model_id: raise DoctorError( CATEGORY_NO_MODEL, "no model is selected in config or flags" ) cache_dir = Path(cfg["download_dir"]) if cfg.get("download_dir") else None quantization = str(cfg.get("quantization") or "auto").replace("bf16", "bfloat16") shard_start = cfg.get("shard_start") shard_end = cfg.get("shard_end") if shard_start is None or shard_end is None: detect = detect_layers or _detect_layers total = detect(model_id, cache_dir) if total is None: raise DoctorError( CATEGORY_MODEL_UNAVAILABLE, f"could not read the layer count from the {model_id} config; " "pass --shard-start and --shard-end explicitly", ) shard_start = 0 if shard_start is None else shard_start shard_end = total - 1 if shard_end is None else shard_end if shard_start < 0 or shard_end < shard_start: raise DoctorError( CATEGORY_INVALID_SHARD, f"invalid shard range {shard_start}–{shard_end}: start must be " "non-negative and not greater than end", ) return DoctorSelection( model_id=model_id, shard_start=int(shard_start), shard_end=int(shard_end), quantization=quantization, cache_dir=cache_dir, force_cpu=bool(cfg.get("force_cpu", False)), ) def _selected_model_id(cfg: Mapping[str, Any]) -> str | None: """The HF repo startup would load, resolving a catalog alias if needed.""" hf_repo = str(cfg.get("model_hf_repo") or "").strip() if hf_repo: return hf_repo name = str(cfg.get("model_name") or "").strip() if not name: return None from .model_catalog import resolve_model_alias preset = resolve_model_alias(name) if preset is not None and preset.hf_repo: return preset.hf_repo return name if "/" in name else None def _detect_layers(model_id: str, cache_dir: Path | None) -> int | None: from .startup import _detect_num_layers return _detect_num_layers(model_id, cache_dir=cache_dir) # --- the bounded real forward ---------------------------------------------- @dataclass(frozen=True) class ProbeInput: """A synthetic hidden-state payload in the same wire format peers send.""" body: bytes shape: list[int] attention_mask_header: str | None position_ids_header: str | None def _int64_header(rows: Sequence[Sequence[int]]) -> str: """Encode an int64 tensor as `shape:base64`, matching the backend's format.""" flat = [int(v) for row in rows for v in row] raw = struct.pack(f"<{len(flat)}q", *flat) shape = f"{len(rows)},{len(rows[0])}" if rows else "0" return f"{shape}:{base64.b64encode(raw).decode('ascii')}" def build_probe_input(hidden_size: int, tokens: int = PROBE_TOKENS) -> ProbeInput: """Build a bounded mid-shard probe: `tokens` positions of bfloat16 zeros. Zeros are a legitimate hidden state; what is being proven is that the layers execute on this device, not that the output means anything. The payload is built with plain bytes so callers need no Torch import. """ if hidden_size <= 0: raise DoctorError( CATEGORY_FORWARD_FAILED, "the backend reports no hidden size, so no probe tensor can be built", ) ones = [[1] * tokens] positions = [list(range(tokens))] return ProbeInput( body=b"\x00" * (tokens * hidden_size * 2), # bfloat16 == 2 bytes shape=[1, tokens, hidden_size], attention_mask_header=_int64_header(ones), position_ids_header=_int64_header(positions), ) def probe_forward(backend: Any, *, tokens: int = PROBE_TOKENS) -> dict: """Run one bounded real forward through the shard `backend` holds. Returns a small detail dict for the human summary. Raises `DoctorError` (category `forward-failed`) if the shard cannot execute or returns nothing. """ is_head = bool(getattr(backend, "is_head", False)) is_tail = bool(getattr(backend, "is_tail", False)) try: if is_head: output = backend.encode_prompt(PROBE_PROMPT) kind = "prompt" if is_tail: # A head+tail shard owns the lm_head too. Re-entering above the # last layer runs no layer again — it only decodes — so the whole # selected shard is covered without a second forward through it. output = backend.forward_bytes( output.body, output.shape, output.attention_mask_header, output.position_ids_header, start_layer=int(getattr(backend, "shard_end", 0)) + 1, ) kind = "prompt+decode" else: probe = build_probe_input(int(getattr(backend, "hidden_size", 0) or 0)) output = backend.forward_bytes( probe.body, probe.shape, probe.attention_mask_header, probe.position_ids_header, start_layer=getattr(backend, "shard_start", None), ) kind = "hidden-states" except DoctorError: raise except Exception as exc: raise DoctorError(CATEGORY_FORWARD_FAILED, _describe(exc)) from exc return {"probe": kind, "tokens": tokens, **_describe_output(output)} def _describe_output(output: Any) -> dict: """Validate the forward produced real output, and summarize it.""" if output is None: raise DoctorError( CATEGORY_FORWARD_FAILED, "the shard forward returned no output" ) token_id = getattr(output, "token_id", None) if token_id is not None: # tail shard: decoded a token return {"output": "token", "token_id": int(token_id)} body = getattr(output, "body", None) shape = list(getattr(output, "shape", []) or []) if not body or not shape: raise DoctorError( CATEGORY_FORWARD_FAILED, "the shard forward returned an empty hidden-state payload", ) return {"output": "hidden-states", "shape": shape} # --- running the doctor ----------------------------------------------------- def default_load_backend( selection: DoctorSelection, recipe: Recipe, ) -> Any: """Load the shard through the exact path startup uses.""" from .torch_server import _load_backend return _load_backend( selection.model_id, selection.shard_start, selection.shard_end, selection.quantization, selection.cache_dir, force_cpu=selection.force_cpu, recipe_params=recipe.params, ) def select_recipes( manifest: RecipeManifest, *, recipe_id: str | None = None, all_recipes: bool = False, ) -> tuple[Recipe, ...]: """The recipes to validate: the selected one, or every one on request. `--all-recipes` is the only way to pay for validating recipes the node was not asked to serve; ordinary onboarding validates exactly one. """ if all_recipes: if recipe_id is not None: raise DoctorError( CATEGORY_UNSUPPORTED_RECIPE, "--recipe and --all-recipes are mutually exclusive", ) return manifest.recipes try: return (manifest.require(recipe_id or DEFAULT_RECIPE_ID),) except RecipeManifestError as exc: raise DoctorError(CATEGORY_UNSUPPORTED_RECIPE, str(exc)) from exc def run_doctor( selection: DoctorSelection, *, manifest: RecipeManifest | None = None, recipe_id: str | None = None, all_recipes: bool = False, load_backend: Callable[[DoctorSelection, Recipe], Any] | None = None, now: Callable[[], float] | None = None, ) -> DoctorResult: """Validate the selection, one bounded real forward per recipe. Never raises for a validation failure: every recipe yields a report, passed or failed, so the caller can write the evidence out either way. `DoctorError` only escapes for input the caller got wrong (an unknown recipe id). """ manifest = manifest or load_recipe_manifest() recipes = select_recipes(manifest, recipe_id=recipe_id, all_recipes=all_recipes) clock = now or time.time load = load_backend or default_load_backend results = [ _validate_recipe(selection, recipe, manifest, load, clock) for recipe in recipes ] return DoctorResult(selection=selection, results=tuple(results)) def validate_loaded_backend( backend: Any, selection: DoctorSelection, recipe: Recipe, manifest: RecipeManifest, *, now: Callable[[], float] | None = None, ) -> RecipeResult: """Validate a shard that is already loaded, without loading it a second time. Startup calls this on the very backend that would serve traffic, so the proof it produces is about that object, not about a re-load that might have landed on a different device. """ return _validate_recipe( selection, recipe, manifest, lambda *_: backend, now or time.time ) def _validate_recipe( selection: DoctorSelection, recipe: Recipe, manifest: RecipeManifest, load_backend: Callable[[DoctorSelection, Recipe], Any], clock: Callable[[], float], ) -> RecipeResult: started = time.monotonic() backend: Any = None category: str | None = None error: BaseException | None = None diagnostics: list[str] = [] try: backend = load_backend(selection, recipe) probe_forward(backend) except DoctorError as exc: category, error = exc.category, exc diagnostics = [str(exc), exc.hint] except Exception as exc: # noqa: BLE001 — every failure becomes a report category = classify_failure(exc) error = exc diagnostics = [_describe(exc), CATEGORY_HINTS.get(category, "")] duration_ms = int((time.monotonic() - started) * 1000) device = _backend_device(backend, selection) # Only the native adapter has an authoritative immutable GGUF report and # deployment pin. The Transformers path deliberately remains dark: a # model/config fingerprint is not an exact ArtifactIdentity. identity = backend.identity if isinstance(backend, NativeWorkerBackendAdapter) else None model_id = selection.model_id if identity is None else identity.artifact.artifact_id shard_start = selection.shard_start if identity is None else identity.shard_start shard_end = selection.shard_end if identity is None else identity.shard_end - 1 recipe_id = recipe.id if identity is None else identity.recipe.recipe_id recipe_version = recipe.version if identity is None else identity.recipe.recipe_version catalogue_version = ( manifest.catalogue_version if identity is None else identity.recipe.catalogue_version ) backend_id = recipe.backend_id if identity is None else identity.recipe.backend_id quantization = ( selection.quantization if identity is None else identity.recipe.weight_quantization ) runtime = _runtime_versions() model_config = _model_config(backend) revision = None if identity is not None: revision = identity.artifact.revision model_config = "sha256:" + identity.artifact.architecture_digest runtime = {**runtime, "native_runtime": identity.recipe.runtime_version} report = build_capability_report( model_id=model_id, shard_start=shard_start, shard_end=shard_end, recipe_id=recipe_id, recipe_version=recipe_version, catalogue_version=catalogue_version, backend_id=backend_id, device=device, device_name=_backend_device_name(device), quantization=quantization, runtime=runtime, revision=revision, model_config=model_config, status=STATUS_FAILED if category else STATUS_PASSED, duration_ms=duration_ms, diagnostics=[d for d in diagnostics if d] or None, validated_at=clock(), identity=identity, ) if category: return RecipeResult( recipe=recipe, report=report, category=category, error=error ) return RecipeResult(recipe=recipe, report=report) def classify_failure(exc: BaseException) -> str: """Map a backend exception to an operator-facing category. Matches on the backend's own error types, never on model or vendor names. """ from .model_backend import ( InsufficientVRAMError, MissingModelDependencyError, PartialModelLoadUnsupported, UnsupportedRecipeParam, ) if isinstance(exc, MissingModelDependencyError): return CATEGORY_MISSING_DEPENDENCY if isinstance(exc, InsufficientVRAMError): return CATEGORY_INSUFFICIENT_MEMORY if isinstance(exc, UnsupportedRecipeParam): return CATEGORY_UNSUPPORTED_RECIPE if isinstance(exc, PartialModelLoadUnsupported): return CATEGORY_LOAD_FAILED if isinstance(exc, ValueError): # shard range vs. the model's real layers return CATEGORY_INVALID_SHARD if isinstance(exc, (FileNotFoundError, OSError)): return CATEGORY_MODEL_UNAVAILABLE return CATEGORY_LOAD_FAILED def _describe(exc: BaseException) -> str: """A one-line, traceback-free description. Sanitized by the report.""" text = str(exc).strip() return f"{type(exc).__name__}: {text}" if text else type(exc).__name__ def _backend_device(backend: Any, selection: DoctorSelection) -> str: device = getattr(backend, "device", None) if device is None: # The load failed, so no device was chosen — record the one that was asked for. return "cpu" if selection.force_cpu else "unknown" return str(getattr(device, "type", device)) def _backend_device_name(device: str) -> str | None: """The accelerator's name, when the shard actually landed on one.""" if device != "cuda": return None from .hardware import detect_hardware try: return detect_hardware().get("gpu_name") or None except Exception: return None def _model_config(backend: Any) -> Any: """The loaded model's config, for the report's fingerprint.""" config = getattr(getattr(backend, "model", None), "config", None) to_dict = getattr(config, "to_dict", None) if not callable(to_dict): return None try: return to_dict() except Exception: return None def _runtime_versions() -> dict[str, str]: """Versions of the stack that ran the forward — opaque labels, never branches.""" versions: dict[str, str] = {} for name in ("torch", "transformers"): try: module = __import__(name) except Exception: continue version = getattr(module, "__version__", None) if version: versions[name] = str(version) return versions # --- output ----------------------------------------------------------------- DEFAULT_REPORT_FILENAME = "capability.json" def default_report_path() -> Path: from .config import config_path return config_path().parent / DEFAULT_REPORT_FILENAME def write_reports(reports: Sequence[CapabilityReport], path: Path) -> Path: """Write the capability report(s) as JSON. A failed run writes too.""" import json path.parent.mkdir(parents=True, exist_ok=True) if len(reports) == 1: path.write_text(reports[0].to_json(indent=2) + "\n", encoding="utf-8") else: payload = [r.to_dict() for r in reports] path.write_text( json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" ) return path def render_result(result: DoctorResult, *, report_path: Path | None = None) -> str: """The human summary: what was validated, what to do if it failed.""" selection = result.selection lines = [ "meshnet-node doctor", f" Model: {selection.model_id}", f" Shard: {selection.shard_label}", f" Quantization: {selection.quantization}", "", ] for item in result.results: mark = "PASS" if item.passed else "FAIL" device = item.report.backend.device lines.append( f" [{mark}] recipe {item.recipe.id} (v{item.recipe.version}) " f"on {device} — {item.report.duration_ms} ms" ) if not item.passed: for diagnostic in item.report.diagnostics: lines.append(f" {diagnostic}") lines.append("") if result.passed: count = len(result.results) what = "recipe" if count == 1 else "recipes" lines.append( f" OK — the selected shard ran a real forward for {count} {what}." ) else: failed = [r for r in result.results if not r.passed] categories = ", ".join(dict.fromkeys(r.category or "unknown" for r in failed)) lines.append(f" FAILED — {categories}. This node cannot serve this shard.") if report_path is not None: lines.append(f" Capability report: {report_path}") return "\n".join(lines)