#!/usr/bin/env bash # # pull.sh — v0.8.0 Pull-Gate orchestrator (PR #147, STEP P4). # # Derives an HF repo via [A], gates it through the LOCKED 6-stratum abort # taxonomy (deriver-errors → --profile-like → [C0] → [C2a] → eligibility → # [B]→[C1] → Path-A [D] dry-run), and on a curated, [D]-emittable, gate- # passing Path A run hands the validated registry key to the existing #141 # generator for real emission. Path B (--dry-run / any non-curated slug) # prints a §7-caveated verdict and NEVER calls [D] / downloads. # # Honest by construction (design §1): every non-eligible / non-pass outcome # hard-stops with a precise structured reason; only `exact × fits-clean` # reaches `proceed` silently (§4.1). `--force-download` is a no-op + notice # this phase (download/telemetry deferred to the Loop phase). # # Usage: # scripts/pull.sh --profile-like [opts] # # # Path A — curated pull-and-emit: # scripts/pull.sh Lorbus/Qwen3.6-27B-int4-AutoRound \ # --profile-like vllm/minimal --out /tmp/qwen.yml # # # Path B — universal evaluate (never emits/downloads): # scripts/pull.sh some-org/Some-Llama-7B --profile-like vllm/minimal --dry-run # # # Failure on-ramp — submit a captured failed pull (a SEPARATE, consented # # verb: the ONLY step that touches the network, and only after an # # explicit y; reuses the shipped dedup; needs no slug/--profile-like): # scripts/pull.sh --submit-last # the most-recent capture # scripts/pull.sh --submit # an explicit bundle dir # # Opts: --yes --force-download --experimental-arch --trust-remote-code # --hf-home DIR --out FILE (Path A) --hardware SM (override nvidia-smi) # # All decision logic lives in scripts/lib/profiles/pull.py (this is a thin # argv pass-through, matching the generate-compose.sh / diagnose-profile.sh # pattern). Exit: 0 = download-eligible / clean verdict; 3 = needs a flag # (confirm→proceed / advisory); 2 = honest hard-stop; 64 = usage. # # --profile-like ... --dry-run --json (structured swap_path) — TUI contract # ------------------------------------------------------------------------- # ADDITIVE: a machine-readable form of the `--profile-like ... --dry-run` # gate verdict. Emits a single JSON object: # # {"fit_verdict": , # "arch": , # "eligible": , # "note": , # "swap_path": {"route": "B"|"C"|null, # "sibling_slug": , # "quant_match": , # "drop_spec_config": }} # # CRITICAL (per the contract): the BRING_YOUR_OWN swap path that the human # gate bakes into the NOTE message *string* is surfaced here as STRUCTURED # `swap_path` FIELDS — derived from the SAME `arch_model_xref` registry the # gate's own `_hint` consults (read-only reuse, never a sentence parse), so # the TUI reads fields instead of parsing prose. # # STRICTLY ADDITIVE: `--json` is intercepted HERE in the wrapper and stripped # before the gate would ever see it; WITHOUT `--json` this script's behaviour # (and pull.py's argv) is byte-for-byte unchanged. `--json` forces the # evaluate-only Path-B gate (it never emits/downloads), matching the # `--dry-run` contract surface. set -euo pipefail ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" # Intercept --json (strictly additive). Absent -> the original exec path, # byte-identical. Present -> strip it and hand the remaining argv to the # structured-emit helper (which forces the evaluate-only gate). _pull_json=0 _pull_args=() for _a in "$@"; do if [ "$_a" = "--json" ]; then _pull_json=1 else _pull_args+=("$_a") fi done if [ "$_pull_json" -eq 0 ]; then exec python3 "${ROOT_DIR}/scripts/lib/profiles/pull.py" "$@" fi # --json path: run the SHIPPED gate (run_pull, evaluate-only) and serialize # its verdict + a structurally-derived swap_path. pull.py / deriver / # generate_compose are imported READ-ONLY (no edit) — this reuses their # logic, never reimplements it. exec python3 - "${ROOT_DIR}" "${_pull_args[@]+"${_pull_args[@]}"}" <<'PY' from __future__ import annotations import argparse import json import sys from pathlib import Path root = Path(sys.argv[1]) if str(root) not in sys.path: sys.path.insert(0, str(root)) from scripts.lib.profiles import pull as P # P4 gate — READ-ONLY from scripts.lib.profiles import deriver as D # P2 — READ-ONLY from scripts.lib import generate_compose as gc # [D] — READ-ONLY # Mirror the gate parser's REQUIRED surface (slug + --profile-like) plus the # evaluate-only flags that are meaningful for the JSON verdict. argparse # error() defaults to exit 2; the JSON contract wants a clean usage code, so # fall back to 64 (matching pull.py's _UsageExit64Parser intent). class _Ap(argparse.ArgumentParser): def error(self, message): self.exit(64, f"pull.sh --json: error: {message}\n") ap = _Ap(prog="pull.sh --json") ap.add_argument("slug") ap.add_argument("--profile-like", required=True, dest="profile_like") ap.add_argument("--dry-run", action="store_true") # implied; accepted ap.add_argument("--yes", action="store_true") ap.add_argument("--force-download", action="store_true") ap.add_argument("--experimental-arch", action="store_true") ap.add_argument("--trust-remote-code", action="store_true") ap.add_argument("--hf-home") ap.add_argument("--hardware", type=float, default=None) ap.add_argument("--hardware-gpus", default=None) # Tolerate (and ignore for the verdict) Path-A-only / presentation flags so a # caller can append --json to an existing command line without a usage error. ap.add_argument("--out") ap.add_argument("--recommend", action="store_true") args = ap.parse_args(sys.argv[2:]) gpu_topology = None if args.hardware_gpus: vram: list[int] = [] names: list[str] = [] for tok in args.hardware_gpus.split(","): tok = tok.strip() if not tok: continue v, n = (tok.split(":", 1) if ":" in tok else (tok, "GPU")) vram.append(int(float(v))) names.append(n.strip() or "GPU") if vram: gpu_topology = (len(vram), vram, names) def _swap_path(slug, der, eligible): """Surface the BRING_YOUR_OWN swap path as STRUCTURED fields, derived from the SAME `arch_model_xref` registry the gate's `_hint` consults (read-only) — never by parsing the human NOTE sentence. route "C": the uncurated arch maps to a curated hybrid/MoE model we serve — reuse that model's compose (point --model at the weights). sibling_slug/quant_match populated. route "B": no curated sibling — the self-contained GGUF fallback (copy the closest llama.cpp/ik compose). route null: not a swap situation (curated hit, or the verdict is not a no-fit-model eligibility stop). """ blank = {"route": None, "sibling_slug": None, "quant_match": None, "drop_spec_config": False} if der is None or der.error is not None: return blank # A curated Tier-1 hit needs no swap (its own compose serves it). if der.tier1 is not None: return blank # Only the no-fit-model eligibility stop carries a swap path. if eligible or der.generic_dense_eligible: return blank arch = (der.profile or {}).get("arch") try: rt = gc._load_yaml(root, "scripts/lib/profiles/profile_runtime.yml") canon, row = gc.resolve_arch_from_config(rt, gc.load_arches(root), arch) except Exception: canon, row = None, None sibling = None family = None if row is not None: family = row.get("family") slugs = (((rt.get("arch_model_xref") or {}).get(canon) or {}) .get("model_slugs") or []) if slugs: sibling = slugs[0] # Route C iff the arch resolves to a curated hybrid/MoE sibling we serve # (the families pull.py prices via the curated path, NOT generic-dense). if sibling is not None and family in P._FAMILY_WEIGHT_FIELDS: quant_match = None try: from scripts.lib.profiles.compat import load_profiles model = load_profiles().models.get(sibling) if model is not None and getattr(model, "weights", None): # Prefer the quant FORMAT the curated compose loads (what the # user must match on their own repo); fall back to the slug. first_slug = next(iter(model.weights)) meta = model.weights[first_slug] or {} quant_match = meta.get("format") or first_slug except Exception: quant_match = None return {"route": "C", "sibling_slug": sibling, "quant_match": quant_match, # The curated configs use a built-in MTP head; a generic repo # carries no MTP head, so drop --speculative-config unless an # -MTP variant is brought (mirrors the human NOTE + docs). "drop_spec_config": True} # No curated sibling -> the self-contained GGUF fallback (route B). return {"route": "B", "sibling_slug": None, "quant_match": "gguf", "drop_spec_config": False} # Derive once (read-only) for arch + the swap-path xref; run the SHIPPED gate # (evaluate-only: --json never emits/downloads) for the authoritative verdict. der = D.derive(args.slug, hf_home=args.hf_home) res = P.run_pull( args.slug, args.profile_like, dry_run=True, # --json is evaluate-only by contract yes=args.yes, force_download=args.force_download, experimental_arch=args.experimental_arch, trust_remote_code=args.trust_remote_code, hf_home=args.hf_home, hardware_sm=args.hardware, gpu_topology=gpu_topology, ) # fit_verdict: the most specific machine signal the gate produced — the raw # [B] verdict when [B] was reached, else the terminal, else the abort reason. fit_verdict = res.raw_verdict or res.terminal or res.abort_reason obj = { "fit_verdict": fit_verdict, "arch": (der.profile or {}).get("arch") if der.error is None else None, "eligible": bool(res.ok), "note": res.detail or "", "swap_path": _swap_path(args.slug, der, bool(res.ok)), } print(json.dumps(obj, sort_keys=True)) sys.exit(0 if res.ok else 0) # JSON emit always exits 0 (verdict is in-band) PY