#!/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_apply_swap=0 _pull_emit_only=0 _pull_args=() for _a in "$@"; do if [ "$_a" = "--json" ]; then _pull_json=1 elif [ "$_a" = "--apply-swap" ]; then _pull_apply_swap=1 elif [ "$_a" = "--emit-only" ]; then _pull_emit_only=1 else _pull_args+=("$_a") fi done # --apply-swap: the BYO Route-C weight-swap ACTION (strictly additive, like # --json). It NEVER runs the locked 6-stratum gate — a curated-arch fine-tune # hard-stops there at stratum-5 `no-fit-model`, which is correct (nothing to # price). Instead it downloads the brought weights SHA-verified and emits a # serve-locally compose that CLONES the --profile-like sibling with --model # re-pointed at those weights. The c3 [D] press on a Route-C fit-check is the # explicit opt-in; without --apply-swap this script's behaviour is unchanged. # # --emit-only (with --apply-swap): skip the SHA-verified download and JUST emit # the serve-locally compose (do_download=False). For when the weights are # already on disk — c3's ② Serve uses this so a present-weights serve needs no # [D] download step (the mount points at the existing pull dir). if [ "$_pull_apply_swap" -eq 1 ]; then export _PULL_EMIT_ONLY="$_pull_emit_only" exec python3 - "${ROOT_DIR}" "${_pull_args[@]+"${_pull_args[@]}"}" <<'PY' from __future__ import annotations import argparse import os 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 swap_apply as SA # the apply-swap action ap = argparse.ArgumentParser(prog="pull.sh --apply-swap", add_help=False) ap.add_argument("slug") ap.add_argument("--profile-like", required=True, dest="profile_like") ap.add_argument("--hf-home", default=None) # tolerate (ignore) the flags a caller may carry over from the fit-check line for _f in ("--dry-run", "--yes", "--force-download", "--experimental-arch", "--trust-remote-code", "--recommend"): ap.add_argument(_f, action="store_true") ap.add_argument("--hardware", default=None) ap.add_argument("--hardware-gpus", default=None) ap.add_argument("--out", default=None) args, _unknown = ap.parse_known_args(sys.argv[2:]) _emit_only = os.environ.get("_PULL_EMIT_ONLY") == "1" print(f"[apply-swap] resolving Route-C swap for {args.slug} " f"(profile-like={args.profile_like})" + (" [emit-only — weights on disk, no download]" if _emit_only else ""), flush=True) res = SA.apply_swap(root, args.slug, args.profile_like, hf_home=args.hf_home, do_download=not _emit_only) if not res.get("ok"): # rc=3 = "already downloading" (distinct from rc=1 failure): a concurrent # download of this slug holds the pull-dir lock; do NOT start a duplicate # (club-3090 #617). Callers (c3) treat rc=3 as "reflect the live download". if res.get("in_progress"): print(f"[apply-swap] already in progress ({res.get('detail','')}) " "— not starting a duplicate", flush=True) sys.exit(3) print(f"[apply-swap] ERROR: {res.get('error')}", file=sys.stderr, flush=True) if res.get("detail"): print(f"[apply-swap] detail: {res['detail']}", file=sys.stderr, flush=True) sys.exit(1) mtp = "MTP kept" if res.get("has_mtp_head") else "MTP dropped (no head)" print(f"[apply-swap] sibling={res['sibling_slug']} served-as={res['served_model_name']}" f" ({mtp})", flush=True) print(f"[apply-swap] weights: {res['weights_dir']}", flush=True) # The final, machine-parseable line the c3 lane greps for → serve this compose. print(f"[apply-swap] compose: {res['compose_path']}", flush=True) print("[apply-swap] ok", flush=True) PY fi 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 # Keep --speculative-config IFF the brought checkpoint actually carries # an MTP head (deriver.detect_mtp_head: config declares MTP layers + a # dedicated mtp weights file). The old blanket drop served head- # preserving fine-tunes (e.g. ThinkingCap AutoRound) MTP-off; a plain # re-quant without the head still drops. has_mtp = bool((der.profile or {}).get("has_mtp_head")) return {"route": "C", "sibling_slug": sibling, "quant_match": quant_match, "drop_spec_config": not has_mtp} # 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