"""
Optional ONLINE lookup: "will this exact Hugging Face model run on my machine?"
Deterministic — no AI involved. Given any repo id (or model page URL), this:
1. checks the local catalogue (offline) by repo id and aliases;
2. otherwise makes ONE metadata call to the Hub, reads the model-tree tags
(base_model:finetune/adapter/quantized/merge), and walks up to 3 hops to
find a catalogue ancestor — "your finetune runs because its base runs";
3. otherwise falls back to raw parameter-count math, clearly labelled.
This is the only part of FitCheck that touches the network at runtime, and the
UI labels it as a live lookup. The deterministic advisor itself makes no
network calls (the model bricks do: ZeroGPU narrator + spec parser).
"""
import re
from functools import lru_cache
from .hardware import HardwareSpec
from .real_advisor import (
USE_CASES, _SAFETY_FILL, _C_MODEL, _C_WORK, _VERDICT_WORD,
_evaluate, _option_json, catalogue, catalogue_date, advise_real,
)
_RELATION = re.compile(r"^base_model:(finetune|adapter|quantized|merge):(.+)$")
# Hugging Face pipeline_tag -> (our family, comparison use-case). Only text
# families (llm/vlm) get a decode-speed chart and a catalogue comparison; other
# families get a memory estimate only. (Compute-roofline FPS/latency exists for
# vision-detection and diffusion, but it needs per-model GFLOPs calibrated in
# the catalogue, which an arbitrary looked-up repo does not carry — so lookup
# stays memory-only there rather than guessing.) Anything unrecognised is
# "other": a specialised model (robot policy / VLA / 3D / etc.) we size but
# don't pretend to benchmark or run in a chatbot app.
_FAMILY_UC = {
"text-generation": ("llm", "chat"), "text2text-generation": ("llm", "chat"),
"image-text-to-text": ("vlm", "vlm"), "visual-question-answering": ("vlm", "vlm"),
"image-to-text": ("vlm", "vlm"),
"automatic-speech-recognition": ("audio", None), "text-to-speech": ("audio", None),
"text-to-audio": ("audio", None),
"text-to-image": ("imagegen", None), "image-to-image": ("imagegen", None),
"object-detection": ("vision", None), "image-segmentation": ("vision", None),
"image-classification": ("vision", None), "depth-estimation": ("vision", None),
"image-feature-extraction": ("vision", None),
"feature-extraction": ("embed", None), "sentence-similarity": ("embed", None),
}
# Effective bits-per-weight for a synthesised quant ladder (k-quant layouts).
_SYNTH_QUANTS = [
("Q8_0", "Near-full (8-bit)", 8.5), ("Q6_K", "High (6-bit)", 6.6),
("Q5_K_M", "Balanced+ (5-bit)", 5.7), ("Q4_K_M", "Balanced (4-bit)", 4.85),
("Q3_K_M", "Compact (3-bit)", 3.9),
]
def _family_uc(pipeline_tag: str) -> tuple[str, str | None]:
return _FAMILY_UC.get(pipeline_tag or "", ("other", None))
def _family_from_arch(arch) -> tuple[str, str | None] | None:
"""Fallback classifier from config.json `architectures` when pipeline_tag is
missing (common for base/instruct LLM repos like Mistral-Nemo)."""
names = " ".join(arch or []).lower()
if not names:
return None
if "forcausallm" in names or "forconditionalgeneration" in names:
if any(k in names for k in ("vl", "vision", "llava", "idefics", "paligemma", "florence")):
return ("vlm", "vlm")
return ("llm", "chat")
if any(k in names for k in ("objectdetection", "imageclassification",
"segmentation", "depthestimation")):
return ("vision", None)
return None
def _synthetic_entry(repo_id: str, info, family: str) -> dict | None:
"""Build a catalogue-shaped entry for a model NOT in our catalogue. Text
families get a quant ladder (so they flow through the GGUF memory + speed
path); everything else gets one estimated memory figure and NO speed chart."""
st = getattr(info, "safetensors", None)
total = getattr(st, "total", None) if st else None
if not total:
return None
params_b = round(total / 1e9, 2)
card = getattr(info, "card_data", None)
lic = ""
if card:
lic = (card.get("license") if hasattr(card, "get") else getattr(card, "license", "")) or ""
e = {
"key": "_lookup", "name": repo_id.split("/")[-1], "repo_id": repo_id,
"params_b": params_b, "provenance": "estimated",
"good_for": f"{params_b:g}B parameters, looked up live on Hugging Face.",
"license": lic, "gated": bool(getattr(info, "gated", False)),
"links": {"hf": f"https://huggingface.co/{repo_id}"}, "use_cases": [],
}
if family in ("llm", "vlm"):
e["family"] = family
e["quants"] = [{"key": k, "plain": p, "file_gb": round(params_b * b / 8, 2)}
for k, p, b in _SYNTH_QUANTS]
else:
# Flat memory: fp16 weights (~2 GB/B) plus working overhead. Conservative.
e["family"] = family if family in ("vision", "imagegen", "audio", "embed") else "other"
e["mem_gb"] = round(params_b * 2.2 + 0.6, 2)
e["mem_provenance"] = "estimated"
return e
# Honest "how to run it" for a LOOKED-UP model: we have NOT verified a GGUF /
# Ollama tag for it (unlike catalogue entries), so we never assert "ollama run
# X". We point at the model card and the realistic tool category instead.
def _lookup_run_guidance(family: str, repo_id: str) -> list[dict]:
card = f"https://huggingface.co/{repo_id}"
start = {"name": "Read the model card", "tag": "Start here",
"what": "The model's own page lists the exact, up-to-date way to run it. Always start here.",
"install": card}
by_family = {
"llm": [
{"name": "Ollama / LM Studio", "tag": "If a GGUF exists",
"what": "These run GGUF builds. Many popular models have one, some don't — search the model name on ollama.com or in LM Studio first.",
"install": "ollama.com / lmstudio.ai"},
{"name": "Transformers or vLLM", "tag": "Universal",
"what": "Run the original weights directly, no GGUF needed. The fallback that always works.",
"install": "pip install transformers"},
],
"vlm": [
{"name": "Transformers", "tag": "Universal",
"what": "Most vision-language models run via Hugging Face Transformers. Some also have GGUF builds for Ollama/LM Studio — check the card.",
"install": "pip install transformers"},
],
"vision": [
{"name": "Its own library", "tag": "Common",
"what": "Vision models usually run through their framework (e.g. Ultralytics for YOLO) or Hugging Face Transformers. Not Ollama.",
"install": "pip install transformers"},
],
"imagegen": [
{"name": "diffusers / ComfyUI", "tag": "Common",
"what": "Image and video models run via Hugging Face diffusers or ComfyUI, not chat apps. The card says which.",
"install": "pip install diffusers"},
],
"audio": [
{"name": "Transformers", "tag": "Common",
"what": "Speech and audio models run via Hugging Face Transformers or the model's own library.",
"install": "pip install transformers"},
],
"embed": [
{"name": "sentence-transformers", "tag": "Common",
"what": "Embedding models load with sentence-transformers or Transformers.",
"install": "pip install sentence-transformers"},
],
"other": [
{"name": "Its own framework", "tag": "Specialised",
"what": "This is a specialised model (for example a robot policy / VLA, or a 3D model). It does NOT run in a chatbot app like Ollama. Follow the model card; these usually need a dedicated framework (e.g. the lerobot library for LeRobot policies).",
"install": "see the model card"},
],
}
return [start] + by_family.get(family, by_family["other"])
def _standalone_estimate(entry: dict, spec: HardwareSpec, finetune: bool) -> dict:
"""A memory-only estimate for a non-text model (no catalogue comparison, no
decode-speed chart — both would be misleading for these families)."""
r = _evaluate(entry, spec, USE_CASES["chat"])
est, v, need = r["est"], r["verdict"], r["est"]["total"]
fast, total = spec.fast_budget_gb, spec.total_budget_gb
has_fast = spec.has_fast_path
if spec.is_apple_silicon:
fast_label, total_label = "GPU can use", "Unified memory"
elif has_fast:
fast_label, total_label = "On the GPU (VRAM)", "GPU + system RAM"
else:
fast_label, total_label = "", "System RAM (no GPU)"
name = entry["name"]
opt = _option_json(r, spec)
if finetune:
# Training a specialised non-text model is NOT the inference path: do not
# imply run-time "feel" or a fit verdict for fine-tuning. Be explicit that
# we don't compute training memory for this family (audit P1 #11).
opt["feel"] = ""
head = f"Fine-tuning {name} isn't auto-estimated for this model type."
detail = (f"{name} is a specialised model (~{entry['params_b']:g}B). FitCheck doesn't compute "
f"training memory for this family, so it won't give a fit verdict here. For "
f"reference, running it takes ~{need:g} GB (parameter-count estimate, not "
f"measured). Follow the model card for its training recipe.")
note = "Training memory and method for this family aren't modelled — this is a run-only estimate."
else:
head = (f"{name} should fit on this machine." if v == "great" else
f"{name} is tight here, but should run." if v == "tight" else
f"{name} is too big for this machine.")
detail = (f"{name} is about {entry['params_b']:g}B parameters. A rough estimate puts it at "
f"~{need:g} GB to run (weights plus working space), and you have ~"
f"{fast:g} GB on the GPU / {total:g} GB total. This is a parameter-count "
f"estimate for a specialised model, not a measured figure.")
note = ""
scale = max(total, need, 1) * 1.05
gauge = {
"need_gb": f"{need:g} GB needed", "fast_gb": f"{fast:g} GB", "total_gb": f"{total:g} GB",
"fast_label": fast_label, "total_label": total_label, "has_fast": has_fast,
"fill_pct": round(min(need / scale, 1.0) * 100, 1),
"mark_pct": round(min(fast / scale, 1.0) * 100, 1),
"total_pct": round(min(total / scale, 1.0) * 100, 1),
"breakdown": [{"label": f"Model + working space {need:g} GB", "color": _C_MODEL}],
}
return {
"catalogue_version": catalogue_date(), "verdict": ("tight" if finetune else v),
"verdict_word": ("Training not estimated" if finetune else _VERDICT_WORD[v]),
"headline": head, "detail": detail, "note": note, "gauge": gauge,
"options": [opt], "tools": [], "commands": {"intro": "", "items": []},
"provenance": "The memory figure is estimated from the model's parameter count — conservative, not measured.",
"speed": None, "meets_goal": (False if finetune else v in ("great", "tight")),
"use_case": "this model", "usecase": "", "focus": name, "headline_model": name,
}
@lru_cache(maxsize=1)
def _index() -> dict:
idx = {}
for e in catalogue()["entries"]:
idx[e["repo_id"].lower()] = e
for a in e.get("aliases", []):
idx[a.lower()] = e
return idx
def normalize_repo_id(text: str) -> str:
"""Accept a bare repo id or any huggingface.co URL."""
text = (text or "").strip().rstrip("/")
m = re.search(r"huggingface\.co/([\w.-]+/[\w.-]+)", text)
if m:
return m.group(1)
return text
def _relations(info) -> list[tuple[str, str]]:
out = []
for t in (getattr(info, "tags", None) or []):
m = _RELATION.match(t)
if m:
out.append((m.group(1), m.group(2)))
if not out:
# cardData fallback only when tags carry no typed relation — the tag
# knows whether it's a finetune or a quantized copy; cardData doesn't.
card = getattr(info, "card_data", None)
if card:
base = card.get("base_model") if hasattr(card, "get") else getattr(card, "base_model", None)
if isinstance(base, str):
out.append(("finetune", base))
elif isinstance(base, list):
out.extend(("finetune", b) for b in base if isinstance(b, str))
return out
def lookup(repo_input: str, payload: dict, spec: HardwareSpec) -> dict:
"""Returns {found, model, chain, verdict-ish fields} or {error}."""
repo_id = normalize_repo_id(repo_input)
if not re.fullmatch(r"[\w.-]+/[\w.-]+", repo_id):
return {"error": f"'{repo_input}' doesn't look like a Hugging Face repo id "
f"(expected something like author/model-name)."}
uc = USE_CASES.get(payload.get("usecase", "chat"), USE_CASES["chat"])
chain = [repo_id]
# 1) Offline: direct catalogue hit (also via aliases).
entry = _index().get(repo_id.lower())
via = None
# 2) Online: one metadata call + base-model walk.
info = None
if entry is None:
from huggingface_hub import HfApi
api = HfApi()
current = repo_id
try:
info = api.model_info(current, expand=["tags", "safetensors", "cardData",
"pipeline_tag", "config", "gated"],
timeout=10)
except Exception as exc: # noqa: BLE001 — surface the real failure
return {"error": f"Couldn't find '{repo_id}' on Hugging Face "
f"({type(exc).__name__}). Check the spelling?"}
hop_info = info
for _hop in range(3):
rels = _relations(hop_info)
if not rels:
break
# Prefer finetune/merge (same memory as base) over quantized.
rels.sort(key=lambda r: 0 if r[0] in ("finetune", "merge", "adapter") else 1)
rel, parent = rels[0]
chain.append(parent)
entry = _index().get(parent.lower())
if entry is not None:
via = {"relation": rel, "base": parent}
break
try:
hop_info = api.model_info(parent, expand=["tags", "cardData"], timeout=10)
except Exception: # noqa: BLE001 — chain ends here
break
if entry is not None:
r = _evaluate(entry, spec, uc)
opt = _option_json(r, spec)
explain = f"{repo_id.split('/')[-1]} "
if via:
word = {"finetune": "is fine-tuned from", "merge": "is merged from",
"adapter": "is an adapter on", "quantized": "is a compressed copy of"}[via["relation"]]
explain += (f"{word} {entry['name']} — if the base runs, this runs, "
f"with the same memory needs.")
if via["relation"] == "adapter":
explain += " Add roughly 0.1–0.5 GB for the adapter file."
else:
explain += f"is {entry['name']} in our catalogue."
# The use case to re-render the full breakdown under (cross-family aware).
fam = entry.get("family")
uc_key = ("vlm" if fam == "vlm" else "chat" if fam == "llm"
else (entry.get("use_cases") or [None])[0])
return {"found": True, "match": "catalogue", "chain": chain,
"explain": explain, "option": opt, "live": via is not None or info is not None,
"focus_name": entry["name"], "uc_key": uc_key}
# 3) Not in the catalogue: build a synthetic entry from its real parameter
# count, sized and described per its model TYPE (so a VLA / detector / image
# model is not treated as a chatbot). Text models flow through the full
# engine (gauge + speed + comparison); other models get a memory-only
# estimate with honest run guidance. Sizes are labelled estimates.
pipe = getattr(info, "pipeline_tag", None) or ""
family, uc_key = _family_uc(pipe)
if family == "other": # no usable pipeline_tag — try the model architecture
cfg = getattr(info, "config", None) or {}
arch = cfg.get("architectures") if isinstance(cfg, dict) else getattr(cfg, "architectures", None)
fb = _family_from_arch(arch)
if fb:
family, uc_key = fb
synth = _synthetic_entry(repo_id, info, family)
if synth is None:
return {"error": f"'{repo_id}' exists, but doesn't share its size or a known "
f"base model, so an honest estimate isn't possible."}
name = repo_id.split("/")[-1]
finetune = payload.get("mode") == "finetune"
if uc_key: # text family: full breakdown, comparison, and (capped) speed
look_payload = dict(payload)
look_payload["usecase"] = uc_key
look_payload["focus"] = name
if finetune:
from .finetune import advise_finetune
advice = advise_finetune(look_payload, spec, extra_entries=[synth])
else:
advice = advise_real(look_payload, spec, extra_entries=[synth])
extra = " the speed chart," if not finetune else ""
explain = (f"{name} isn't in our catalogue, so its sizes are estimated from its "
f"{synth['params_b']:g}B parameters (not exact file sizes). The memory breakdown,"
f"{extra} and the comparison are computed the same way as for catalogue models.")
else: # vision / image / audio / embed / other: memory-only estimate
advice = _standalone_estimate(synth, spec, finetune)
kind = {"vision": "a vision model", "imagegen": "an image/video model",
"audio": "an audio model", "embed": "an embedding model"}.get(family,
"a specialised model (it doesn't run in chatbot apps)")
explain = (f"{name} isn't in our catalogue. It looks like {kind}, so this is a "
f"memory estimate from its {synth['params_b']:g}B parameters — no decode-speed "
f"chart, because tokens/second doesn't describe this kind of model.")
# For RUNNING a looked-up model we have NOT verified a GGUF/Ollama tag, so
# replace any asserted run commands with honest, model-card-first guidance.
# Fine-tuning guidance (Unsloth/TRL + the QLoRA recipe) is generic and stays.
if not (finetune and uc_key):
advice["tools"] = _lookup_run_guidance(family, repo_id)
advice["commands"] = {"intro": "", "items": []}
return {"found": True, "match": "estimate", "chain": chain, "live": True,
"explain": explain, "advice": advice}