Interactive results, working priority, fine-tuning mode, honest custom + model lookup
Browse files- app.py +7 -1
- engine/finetune.py +388 -0
- engine/hub_lookup.py +6 -1
- engine/real_advisor.py +74 -17
- static/app.js +110 -63
- static/index.html +10 -5
- static/style.css +52 -0
app.py
CHANGED
|
@@ -27,6 +27,7 @@ from fastapi.staticfiles import StaticFiles
|
|
| 27 |
from pydantic import BaseModel
|
| 28 |
|
| 29 |
from engine.real_advisor import advise_real, min_specs
|
|
|
|
| 30 |
from engine.ui_adapter import spec_from_payload
|
| 31 |
from model_brick import ask as model_ask
|
| 32 |
|
|
@@ -45,12 +46,17 @@ class AdviseIn(BaseModel):
|
|
| 45 |
usecase: str = "chat"
|
| 46 |
custom: str = ""
|
| 47 |
priority: str = "balanced"
|
|
|
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
@app.post("/api/advise")
|
| 51 |
def api_advise(payload: AdviseIn):
|
| 52 |
p = payload.model_dump()
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
class MinSpecsIn(BaseModel):
|
|
|
|
| 27 |
from pydantic import BaseModel
|
| 28 |
|
| 29 |
from engine.real_advisor import advise_real, min_specs
|
| 30 |
+
from engine.finetune import advise_finetune
|
| 31 |
from engine.ui_adapter import spec_from_payload
|
| 32 |
from model_brick import ask as model_ask
|
| 33 |
|
|
|
|
| 46 |
usecase: str = "chat"
|
| 47 |
custom: str = ""
|
| 48 |
priority: str = "balanced"
|
| 49 |
+
focus: str = "" # a specific model the user clicked — show ITS breakdown
|
| 50 |
+
mode: str = "run" # "run" = inference advice; "finetune" = training advice
|
| 51 |
|
| 52 |
|
| 53 |
@app.post("/api/advise")
|
| 54 |
def api_advise(payload: AdviseIn):
|
| 55 |
p = payload.model_dump()
|
| 56 |
+
spec = spec_from_payload(p)
|
| 57 |
+
if p.get("mode") == "finetune":
|
| 58 |
+
return advise_finetune(p, spec)
|
| 59 |
+
return advise_real(p, spec)
|
| 60 |
|
| 61 |
|
| 62 |
class MinSpecsIn(BaseModel):
|
engine/finetune.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fine-tuning mode: "what can I TRAIN on this machine?" — the mirror of the
|
| 3 |
+
inference advisor. Running a model and fine-tuning it have wildly different
|
| 4 |
+
memory costs (a 7B chats in ~5 GB but QLoRA-trains in ~12 GB, LoRA in ~21 GB,
|
| 5 |
+
full fine-tune in ~130 GB), so the honest answer is a different one.
|
| 6 |
+
|
| 7 |
+
The memory model is deterministic and conservative, mirroring the inference
|
| 8 |
+
engine's philosophy. Per-parameter byte constants and the calibration are
|
| 9 |
+
sourced (see FINETUNE-RESEARCH below); FitCheck deliberately lands at or above
|
| 10 |
+
Unsloth's published VRAM minimums, because most users run the vanilla
|
| 11 |
+
PEFT/TRL/bitsandbytes stack, not Unsloth's memory-optimised kernels.
|
| 12 |
+
|
| 13 |
+
Sources:
|
| 14 |
+
- Full FT = 16 bytes/trainable-param (fp16 weight+grad + fp32 master/mom/var):
|
| 15 |
+
EleutherAI Transformer Math; Google Cloud GPU-memory guide.
|
| 16 |
+
- QLoRA 4-bit NF4+double-quant base ~= 4.5 bits/param; paged-8bit optimiser on
|
| 17 |
+
the <1% trainable adapter: Dettmers et al. 2023 (arXiv:2305.14314).
|
| 18 |
+
- Activation term + gradient-checkpointing divisor calibrated so QLoRA totals
|
| 19 |
+
sit above Unsloth's requirements table.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from .hardware import HardwareSpec
|
| 23 |
+
from .real_advisor import (
|
| 24 |
+
USE_CASES, _SAFETY_FILL, catalogue, _by_use_case, catalogue_date,
|
| 25 |
+
_C_MODEL, _C_WORK, _VERDICT_WORD,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# Per-parameter core cost (weights + gradients + optimiser state), bytes/param.
|
| 29 |
+
_CORE_BYTES = {"full": 16.0, "lora": 2.0 + 0.16, "qlora": 0.5625 + 0.16}
|
| 30 |
+
_METHOD_PLAIN = {
|
| 31 |
+
"qlora": "QLoRA (4-bit base + adapters)",
|
| 32 |
+
"lora": "LoRA (16-bit base + adapters)",
|
| 33 |
+
"full": "Full fine-tune (all weights)",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def estimate_finetune_vram(params_b: float, method: str = "qlora",
|
| 38 |
+
seq_len: int = 2048, batch_size: int = 1,
|
| 39 |
+
grad_checkpointing: bool = True) -> float:
|
| 40 |
+
"""Conservative PEAK fine-tuning VRAM in GB. Deterministic; depends only on
|
| 41 |
+
parameter count + a few training knobs. Constants sourced above."""
|
| 42 |
+
p = max(params_b, 0.0)
|
| 43 |
+
core_gb = _CORE_BYTES[method] * p # weights + gradients + optimiser state
|
| 44 |
+
# Activations scale with model size and tokens (batch x seq), independent of
|
| 45 |
+
# the PEFT method. 0.6 GB/B at seq=2048, batch=1 WITH gradient checkpointing
|
| 46 |
+
# is calibrated so QLoRA/LoRA/full totals match the researched anchors and
|
| 47 |
+
# sit at or above Unsloth's published minimums.
|
| 48 |
+
act_gb = 0.6 * p * (batch_size * seq_len / 2048.0)
|
| 49 |
+
if not grad_checkpointing:
|
| 50 |
+
act_gb *= 4.0 # checkpointing saves ~5x; baseline assumes it on
|
| 51 |
+
subtotal = core_gb + act_gb + 0.75 # +0.75 GB framework / CUDA overhead
|
| 52 |
+
return round(subtotal * 1.20, 1) # +20% safety buffer (under-promise)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# --------------------------------------------------------------------------
|
| 56 |
+
# Non-LLM categories: family-level fine-tune feasibility (researched).
|
| 57 |
+
# These families don't share the LLM QLoRA formula; each has its own tooling
|
| 58 |
+
# and memory floor. Numbers are conservative consumer-hardware minimums.
|
| 59 |
+
# --------------------------------------------------------------------------
|
| 60 |
+
_CATEGORY_FT = {
|
| 61 |
+
"vision": {
|
| 62 |
+
"method": "Transfer learning (no LoRA — freeze the backbone)",
|
| 63 |
+
"min_vram": 8.0,
|
| 64 |
+
"tools": [
|
| 65 |
+
{"name": "Ultralytics YOLO", "what": "Fine-tune a pretrained checkpoint on your own images. Use freeze=N to cut memory.",
|
| 66 |
+
"install": "pip install ultralytics", "tag": "Start here"},
|
| 67 |
+
{"name": "PyTorch / timm", "what": "Full control for custom vision training.",
|
| 68 |
+
"install": "pytorch.org", "tag": "Advanced"},
|
| 69 |
+
],
|
| 70 |
+
"commands": [{"label": "Fine-tune YOLO on your dataset",
|
| 71 |
+
"code": "yolo detect train model=yolo11n.pt data=mydata.yaml epochs=100 imgsz=640"}],
|
| 72 |
+
"note": ("Vision models fine-tune by transfer learning from a pretrained checkpoint — "
|
| 73 |
+
"there is no LoRA here. The memory lever is <b>freeze=N</b> (freeze the backbone) "
|
| 74 |
+
"and a smaller <b>batch</b>. The n/s sizes train on ~8 GB; m/l want 12-16 GB."),
|
| 75 |
+
"pointer": "https://docs.ultralytics.com/modes/train",
|
| 76 |
+
},
|
| 77 |
+
"imagegen": {
|
| 78 |
+
"method": "LoRA / DreamBooth (the dominant method for diffusion)",
|
| 79 |
+
"min_vram": 8.0,
|
| 80 |
+
"tools": [
|
| 81 |
+
{"name": "kohya_ss", "what": "The community standard GUI/scripts for SD, SDXL and Flux LoRA training.",
|
| 82 |
+
"install": "github.com/bmaltais/kohya_ss", "tag": "Start here"},
|
| 83 |
+
{"name": "diffusers", "what": "Hugging Face's training scripts (train_dreambooth_lora_*).",
|
| 84 |
+
"install": "pip install diffusers", "tag": "Scriptable"},
|
| 85 |
+
{"name": "OneTrainer", "what": "All-in-one desktop trainer for diffusion models.",
|
| 86 |
+
"install": "github.com/Nerogar/OneTrainer", "tag": "GUI"},
|
| 87 |
+
],
|
| 88 |
+
"commands": [],
|
| 89 |
+
"note": ("Diffusion fine-tuning is LoRA-first. <b>SD 1.5</b> LoRA trains from ~6-8 GB, "
|
| 90 |
+
"<b>SDXL</b> LoRA wants ~10-12 GB (16-24 comfortable), and <b>Flux</b> needs "
|
| 91 |
+
"QLoRA/NF4 to fit ~9-16 GB (full FP16 wants 24 GB). A <i>full</i> fine-tune of "
|
| 92 |
+
"SDXL/Flux is datacentre-only (40 GB+)."),
|
| 93 |
+
"pointer": "https://huggingface.co/docs/diffusers/en/training/lora",
|
| 94 |
+
},
|
| 95 |
+
"audio": {
|
| 96 |
+
"method": "LoRA / PEFT (STT) or full fine-tune (small TTS)",
|
| 97 |
+
"min_vram": 8.0,
|
| 98 |
+
"tools": [
|
| 99 |
+
{"name": "HF Transformers", "what": "Fine-tune Whisper with Seq2SeqTrainer + PEFT (int8 + LoRA).",
|
| 100 |
+
"install": "pip install transformers peft bitsandbytes", "tag": "Start here"},
|
| 101 |
+
{"name": "Coqui XTTS", "what": "Voice-cloning / TTS fine-tuning with a Gradio pipeline.",
|
| 102 |
+
"install": "github.com/idiap/coqui-ai-TTS", "tag": "TTS"},
|
| 103 |
+
],
|
| 104 |
+
"commands": [],
|
| 105 |
+
"note": ("Whisper fine-tunes with int8 + LoRA in under 8 GB (even large-v2 on a free "
|
| 106 |
+
"Colab T4); tiny/base/small train comfortably on consumer GPUs. TTS "
|
| 107 |
+
"(SpeechT5, XTTS v2) wants ~12-16 GB."),
|
| 108 |
+
"pointer": "https://huggingface.co/blog/fine-tune-whisper",
|
| 109 |
+
},
|
| 110 |
+
"embed": {
|
| 111 |
+
"method": "Full fine-tune (these models are small)",
|
| 112 |
+
"min_vram": 6.0,
|
| 113 |
+
"tools": [
|
| 114 |
+
{"name": "sentence-transformers", "what": "Fine-tune embeddings with a few lines (MultipleNegativesRankingLoss).",
|
| 115 |
+
"install": "pip install sentence-transformers", "tag": "Start here"},
|
| 116 |
+
],
|
| 117 |
+
"commands": [],
|
| 118 |
+
"note": ("Embedding models are small — most fine-tune fully on ~6 GB. No quantisation "
|
| 119 |
+
"or LoRA needed for typical sizes."),
|
| 120 |
+
"pointer": "https://www.sbert.net/docs/training/overview.html",
|
| 121 |
+
},
|
| 122 |
+
"data": {
|
| 123 |
+
"method": "Full fine-tune / retrain (small models)",
|
| 124 |
+
"min_vram": 4.0,
|
| 125 |
+
"tools": [
|
| 126 |
+
{"name": "Python + the model's library", "what": "Forecasting/tabular models retrain from a small script.",
|
| 127 |
+
"install": "pip install (see the model card)", "tag": "Start here"},
|
| 128 |
+
],
|
| 129 |
+
"commands": [],
|
| 130 |
+
"note": "Time-series and tabular models are small and retrain on a CPU or a modest GPU.",
|
| 131 |
+
"pointer": "",
|
| 132 |
+
},
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# --------------------------------------------------------------------------
|
| 137 |
+
# Cloud fallback: when local hardware can't train the model they want.
|
| 138 |
+
# (Prices/limits drift — labelled as guidance, confirmed live at the links.)
|
| 139 |
+
# --------------------------------------------------------------------------
|
| 140 |
+
_CLOUD = [
|
| 141 |
+
{"name": "Google Colab (free)", "what": "Free T4 (16 GB). Comfortable for 7-9B QLoRA. Open an Unsloth notebook and Run all.",
|
| 142 |
+
"cost": "Free", "link": "https://unsloth.ai/docs/get-started/unsloth-notebooks"},
|
| 143 |
+
{"name": "Kaggle (free)", "what": "Free 2x T4 (32 GB total), ~30 GPU-hours/week — more VRAM than Colab free.",
|
| 144 |
+
"cost": "Free", "link": "https://www.kaggle.com/code"},
|
| 145 |
+
{"name": "Modal", "what": "Serverless GPUs from a Python script (gpu=\"A100-80GB\"). Per-second billing.",
|
| 146 |
+
"cost": "~$30/mo free credit", "link": "https://modal.com/docs/examples"},
|
| 147 |
+
{"name": "RunPod", "what": "Cheapest raw GPU-hours: rent an A100/H100 for a few dollars for a one-off run.",
|
| 148 |
+
"cost": "From ~$0.34/hr", "link": "https://www.runpod.io/pricing"},
|
| 149 |
+
]
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _qlora_command(repo_id: str) -> list[dict]:
|
| 153 |
+
"""The minimal real Unsloth QLoRA recipe + the GGUF export step, so the
|
| 154 |
+
fine-tuned model can then be run in Ollama / LM Studio."""
|
| 155 |
+
model = repo_id or "unsloth/Qwen2.5-7B-Instruct"
|
| 156 |
+
code = (
|
| 157 |
+
"from unsloth import FastLanguageModel\n"
|
| 158 |
+
"from trl import SFTTrainer, SFTConfig\n"
|
| 159 |
+
"from datasets import load_dataset\n\n"
|
| 160 |
+
f'model, tok = FastLanguageModel.from_pretrained(\n'
|
| 161 |
+
f' "{model}", max_seq_length=2048, load_in_4bit=True) # 4-bit = QLoRA\n'
|
| 162 |
+
"model = FastLanguageModel.get_peft_model(\n"
|
| 163 |
+
' model, r=16, lora_alpha=16, use_gradient_checkpointing="unsloth",\n'
|
| 164 |
+
' target_modules=["q_proj","k_proj","v_proj","o_proj",\n'
|
| 165 |
+
' "gate_proj","up_proj","down_proj"])\n'
|
| 166 |
+
'ds = load_dataset("your/dataset", split="train") # chat/messages JSONL\n'
|
| 167 |
+
"SFTTrainer(model=model, tokenizer=tok, train_dataset=ds,\n"
|
| 168 |
+
" args=SFTConfig(per_device_train_batch_size=2, gradient_accumulation_steps=8,\n"
|
| 169 |
+
' num_train_epochs=1, learning_rate=2e-4, optim="adamw_8bit",\n'
|
| 170 |
+
' bf16=True, output_dir="out")).train()'
|
| 171 |
+
)
|
| 172 |
+
export = ('model.save_pretrained_gguf("model", tok, quantization_method="q4_k_m")\n'
|
| 173 |
+
"# then: ollama create my-model -f Modelfile (FROM ./model-Q4_K_M.gguf)")
|
| 174 |
+
return [
|
| 175 |
+
{"label": "QLoRA fine-tune with Unsloth (lowest VRAM)", "code": code},
|
| 176 |
+
{"label": "Export to GGUF to run it in Ollama / LM Studio", "code": export},
|
| 177 |
+
]
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _llm_method_for(params_b: float, spec: HardwareSpec) -> dict:
|
| 181 |
+
"""Pick the lightest fine-tune method that fits, with a verdict."""
|
| 182 |
+
fast = spec.fast_budget_gb
|
| 183 |
+
total = spec.total_budget_gb
|
| 184 |
+
qlora = estimate_finetune_vram(params_b, "qlora")
|
| 185 |
+
lora = estimate_finetune_vram(params_b, "lora")
|
| 186 |
+
full = estimate_finetune_vram(params_b, "full")
|
| 187 |
+
if fast and qlora <= fast * _SAFETY_FILL:
|
| 188 |
+
# On the GPU. If LoRA also fits, mention it as the higher-quality option.
|
| 189 |
+
method = "lora" if lora <= fast * _SAFETY_FILL else "qlora"
|
| 190 |
+
return {"verdict": "great", "method": method, "need": qlora,
|
| 191 |
+
"qlora": qlora, "lora": lora, "full": full}
|
| 192 |
+
if qlora <= total * _SAFETY_FILL:
|
| 193 |
+
# Spills into system RAM via a paged optimiser — works, but slow.
|
| 194 |
+
return {"verdict": "tight", "method": "qlora", "need": qlora,
|
| 195 |
+
"qlora": qlora, "lora": lora, "full": full}
|
| 196 |
+
return {"verdict": "no", "method": "qlora", "need": qlora,
|
| 197 |
+
"qlora": qlora, "lora": lora, "full": full}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _ft_option(entry: dict, m: dict) -> dict:
|
| 201 |
+
feel = {"great": "Fits your GPU", "tight": "Works via system RAM — slow",
|
| 202 |
+
"no": "Too big locally — use the cloud"}[m["verdict"]]
|
| 203 |
+
return {
|
| 204 |
+
"verdict": m["verdict"],
|
| 205 |
+
"model": entry["name"],
|
| 206 |
+
"desc": entry.get("good_for", ""),
|
| 207 |
+
"setting": _METHOD_PLAIN[m["method"]],
|
| 208 |
+
"memory": "Too big" if m["verdict"] == "no" else f"{m['need']:g} GB",
|
| 209 |
+
"feel": feel,
|
| 210 |
+
"params_b": entry.get("params_b"),
|
| 211 |
+
"active_params_b": entry.get("active_params_b"),
|
| 212 |
+
"url": (entry.get("links") or {}).get("hf") or (entry.get("links") or {}).get("home", ""),
|
| 213 |
+
"license": entry.get("license", ""),
|
| 214 |
+
"license_note": entry.get("license_note", ""),
|
| 215 |
+
"gated": entry.get("gated", False),
|
| 216 |
+
"run": {}, "provenance": "estimated", "stale": entry.get("stale", False),
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _llm_finetune(uc, candidates, spec, focus) -> dict:
|
| 221 |
+
fast, total = spec.fast_budget_gb, spec.total_budget_gb
|
| 222 |
+
evald = [(e, _llm_method_for(e.get("params_b", 1.0), spec)) for e in candidates]
|
| 223 |
+
|
| 224 |
+
# Headline = the LARGEST model you can QLoRA locally (great); else largest
|
| 225 |
+
# that works tight; else the smallest (so we can show the cloud path).
|
| 226 |
+
great = [(e, m) for e, m in evald if m["verdict"] == "great"]
|
| 227 |
+
tight = [(e, m) for e, m in evald if m["verdict"] == "tight"]
|
| 228 |
+
|
| 229 |
+
def by_params(pair):
|
| 230 |
+
return pair[0].get("params_b", 0)
|
| 231 |
+
|
| 232 |
+
if focus:
|
| 233 |
+
hit = next((pair for pair in evald
|
| 234 |
+
if pair[0]["name"] == focus
|
| 235 |
+
or str(pair[0].get("repo_id", "")).lower() == focus.lower()), None)
|
| 236 |
+
chosen = hit or (max(great, key=by_params) if great else None)
|
| 237 |
+
else:
|
| 238 |
+
chosen = (max(great, key=by_params) if great else
|
| 239 |
+
max(tight, key=by_params) if tight else
|
| 240 |
+
min(evald, key=lambda pr: pr[1]["need"]) if evald else None)
|
| 241 |
+
|
| 242 |
+
options = [_ft_option(e, m) for e, m in evald]
|
| 243 |
+
repo = ""
|
| 244 |
+
if chosen:
|
| 245 |
+
e, m = chosen
|
| 246 |
+
repo = e.get("repo_id", "")
|
| 247 |
+
hv, need = m["verdict"], m["need"]
|
| 248 |
+
if hv == "great":
|
| 249 |
+
head = f"Yes — you can fine-tune {e['name']} on your machine."
|
| 250 |
+
detail = (
|
| 251 |
+
f"The honest pick for training is <b>{e['name']}</b> with "
|
| 252 |
+
f"<b>{_METHOD_PLAIN[m['method']]}</b>. It needs about <b>{need:g} GB</b> of GPU "
|
| 253 |
+
f"memory (you have ~<b>{fast:g} GB</b> on the fast path). For reference the same "
|
| 254 |
+
f"model is ~{m['lora']:g} GB with 16-bit LoRA and ~{m['full']:g} GB for a full "
|
| 255 |
+
f"fine-tune — which is why QLoRA is the consumer answer."
|
| 256 |
+
)
|
| 257 |
+
elif hv == "tight":
|
| 258 |
+
head = f"Sort of — {e['name']} will fine-tune, but it spills into system RAM."
|
| 259 |
+
detail = (
|
| 260 |
+
f"<b>{e['name']}</b> needs about <b>{need:g} GB</b> for QLoRA, more than your "
|
| 261 |
+
f"~<b>{fast:g} GB</b> of GPU memory. A paged optimiser can borrow ordinary RAM "
|
| 262 |
+
f"(you have ~{total:g} GB total) so it runs, but slowly. A bigger GPU — or a free "
|
| 263 |
+
f"cloud notebook — would make this comfortable."
|
| 264 |
+
)
|
| 265 |
+
else:
|
| 266 |
+
head = f"Training is a stretch on this machine — here's the honest path."
|
| 267 |
+
biggest_local = max((p for p in evald if p[1]["verdict"] != "no"),
|
| 268 |
+
key=by_params, default=None)
|
| 269 |
+
local_line = (f"Locally you can comfortably QLoRA up to <b>{biggest_local[0]['name']}</b>. "
|
| 270 |
+
if biggest_local else "This machine has no GPU fast path for training. ")
|
| 271 |
+
detail = (
|
| 272 |
+
f"{e['name']} needs about <b>{need:g} GB</b> to QLoRA, beyond what this machine "
|
| 273 |
+
f"offers. {local_line}For anything bigger, a rented or free cloud GPU is the cheapest "
|
| 274 |
+
f"path — see the options below."
|
| 275 |
+
)
|
| 276 |
+
scale = max(fast or total, need, 1) * 1.05
|
| 277 |
+
gauge = {
|
| 278 |
+
"need_gb": f"{need:g} GB needed to train",
|
| 279 |
+
"fast_gb": f"{fast:g} GB", "total_gb": f"{total:g} GB",
|
| 280 |
+
"fill_pct": round(min(need / scale, 1.0) * 100, 1),
|
| 281 |
+
"mark_pct": round(min((fast or total) / scale, 1.0) * 100, 1),
|
| 282 |
+
"breakdown": [
|
| 283 |
+
{"label": f"4-bit base {round(e.get('params_b',1)*0.5625,1):g} GB", "color": _C_MODEL},
|
| 284 |
+
{"label": f"Adapters, optimiser & activations {round(need - e.get('params_b',1)*0.5625,1):g} GB", "color": _C_WORK},
|
| 285 |
+
],
|
| 286 |
+
}
|
| 287 |
+
commands = {"intro": "A real QLoRA recipe for the pick above, then the step to run your "
|
| 288 |
+
"fine-tuned model locally.",
|
| 289 |
+
"items": _qlora_command(repo)}
|
| 290 |
+
provenance = ("Training memory is a conservative estimate (16 bytes/parameter for full "
|
| 291 |
+
"fine-tuning; ~4.5-bit base for QLoRA) sized to land at or above Unsloth's "
|
| 292 |
+
"published minimums — most setups use the vanilla PEFT/bitsandbytes stack.")
|
| 293 |
+
else:
|
| 294 |
+
hv = "no"
|
| 295 |
+
head = "Nothing in the catalogue fits training on this machine yet."
|
| 296 |
+
detail = "Try a smaller model, or use one of the free cloud notebooks below."
|
| 297 |
+
gauge, commands, provenance = {}, {"intro": "", "items": []}, ""
|
| 298 |
+
|
| 299 |
+
tools = [
|
| 300 |
+
{"name": "Unsloth", "what": "Fastest single-GPU QLoRA, lowest VRAM, built-in GGUF export. Beginner default.",
|
| 301 |
+
"install": "pip install unsloth", "tag": "Start here"},
|
| 302 |
+
{"name": "Hugging Face TRL + PEFT", "what": "The reference stack: SFTTrainer + LoRA/QLoRA, maximum compatibility.",
|
| 303 |
+
"install": "pip install trl peft bitsandbytes", "tag": "Reference"},
|
| 304 |
+
{"name": "Axolotl", "what": "One YAML config; best when you move to multi-GPU or long context.",
|
| 305 |
+
"install": "github.com/axolotl-ai-cloud/axolotl", "tag": "Scale up"},
|
| 306 |
+
]
|
| 307 |
+
return {
|
| 308 |
+
"verdict": hv, "verdict_word": _FT_VERDICT_WORD.get(hv, _VERDICT_WORD[hv]),
|
| 309 |
+
"headline": head, "detail": detail, "gauge": gauge, "options": options,
|
| 310 |
+
"tools": tools, "commands": commands, "provenance": provenance,
|
| 311 |
+
"cloud": _CLOUD, "speed": None,
|
| 312 |
+
"headline_model": chosen[0]["name"] if chosen else "",
|
| 313 |
+
"focus": focus or "",
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def _category_finetune(uc, candidates, spec) -> dict:
|
| 318 |
+
fam = uc.family
|
| 319 |
+
info = _CATEGORY_FT.get(fam)
|
| 320 |
+
fast = spec.fast_budget_gb
|
| 321 |
+
if not info:
|
| 322 |
+
return {"verdict": "tight", "verdict_word": "Depends on the model",
|
| 323 |
+
"headline": f"Fine-tuning {uc.plain_name.lower()} depends on the specific model.",
|
| 324 |
+
"detail": "Paste a specific model id in the box above to check it.",
|
| 325 |
+
"gauge": {}, "options": [], "tools": [], "commands": {"intro": "", "items": []},
|
| 326 |
+
"provenance": "", "cloud": _CLOUD, "speed": None, "headline_model": "", "focus": ""}
|
| 327 |
+
min_vram = info["min_vram"]
|
| 328 |
+
fits = fast >= min_vram
|
| 329 |
+
hv = "great" if fits else "no"
|
| 330 |
+
head = (f"Yes — you can fine-tune {uc.plain_name.lower()} on this machine."
|
| 331 |
+
if fits else
|
| 332 |
+
f"Fine-tuning {uc.plain_name.lower()} needs about {min_vram:g} GB of GPU memory — more than this machine has.")
|
| 333 |
+
detail = info["note"] + (f' <a href="{info["pointer"]}" target="_blank" rel="noopener">How to start.</a>'
|
| 334 |
+
if info.get("pointer") else "")
|
| 335 |
+
options = []
|
| 336 |
+
for e in candidates[:12]:
|
| 337 |
+
options.append({
|
| 338 |
+
"verdict": "great" if fits else "no",
|
| 339 |
+
"model": e["name"], "desc": e.get("good_for", ""),
|
| 340 |
+
"setting": info["method"],
|
| 341 |
+
"memory": f"~{min_vram:g} GB to train" if fits else "Use the cloud",
|
| 342 |
+
"feel": "Fits your GPU" if fits else "Too big locally",
|
| 343 |
+
"params_b": e.get("params_b"), "active_params_b": e.get("active_params_b"),
|
| 344 |
+
"url": (e.get("links") or {}).get("hf") or (e.get("links") or {}).get("home", ""),
|
| 345 |
+
"license": e.get("license", ""), "license_note": e.get("license_note", ""),
|
| 346 |
+
"gated": e.get("gated", False), "run": {}, "provenance": "estimated",
|
| 347 |
+
"stale": e.get("stale", False),
|
| 348 |
+
})
|
| 349 |
+
return {
|
| 350 |
+
"verdict": hv, "verdict_word": _FT_VERDICT_WORD.get(hv, _VERDICT_WORD[hv]),
|
| 351 |
+
"headline": head, "detail": detail, "gauge": {}, "options": options,
|
| 352 |
+
"tools": info["tools"], "commands": {"intro": "", "items": info.get("commands", [])},
|
| 353 |
+
"provenance": ("These are conservative family-level minimums; the exact figure varies "
|
| 354 |
+
"with model size, resolution and batch."),
|
| 355 |
+
"cloud": _CLOUD, "speed": None, "headline_model": "", "focus": "",
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
_FT_VERDICT_WORD = {"great": "You can train this", "tight": "Trainable, but tight",
|
| 360 |
+
"no": "Train in the cloud"}
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def advise_finetune(payload: dict, spec: HardwareSpec) -> dict:
|
| 364 |
+
"""Mirror of advise_real for fine-tuning. Same result shape so the same
|
| 365 |
+
renderer draws it; adds a `cloud` section and omits the speed chart."""
|
| 366 |
+
uc = USE_CASES.get(payload.get("usecase", "chat"), USE_CASES["chat"])
|
| 367 |
+
candidates = _by_use_case().get(uc.key, [])
|
| 368 |
+
focus = (payload.get("focus") or "").strip()
|
| 369 |
+
|
| 370 |
+
if not candidates:
|
| 371 |
+
base = {"verdict": "tight", "verdict_word": "Not covered yet",
|
| 372 |
+
"headline": "Our catalogue doesn't cover this goal yet.",
|
| 373 |
+
"detail": "Paste a specific Hugging Face model id above to check it for training.",
|
| 374 |
+
"gauge": {}, "options": [], "tools": [], "commands": {"intro": "", "items": []},
|
| 375 |
+
"provenance": "", "cloud": _CLOUD, "speed": None, "headline_model": "", "focus": ""}
|
| 376 |
+
elif uc.family in ("llm", "vlm"):
|
| 377 |
+
base = _llm_finetune(uc, candidates, spec, focus)
|
| 378 |
+
else:
|
| 379 |
+
base = _category_finetune(uc, candidates, spec)
|
| 380 |
+
|
| 381 |
+
base.update({
|
| 382 |
+
"mode": "finetune",
|
| 383 |
+
"catalogue_version": catalogue_date(),
|
| 384 |
+
"use_case": uc.plain_name, "usecase": uc.key,
|
| 385 |
+
"meets_goal": base["verdict"] in ("great", "tight"),
|
| 386 |
+
"note": "",
|
| 387 |
+
})
|
| 388 |
+
return base
|
engine/hub_lookup.py
CHANGED
|
@@ -118,8 +118,13 @@ def lookup(repo_input: str, payload: dict, spec: HardwareSpec) -> dict:
|
|
| 118 |
explain += " Add roughly 0.1–0.5 GB for the adapter file."
|
| 119 |
else:
|
| 120 |
explain += f"is <b>{entry['name']}</b> in our catalogue."
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
return {"found": True, "match": "catalogue", "chain": chain,
|
| 122 |
-
"explain": explain, "option": opt, "live": via is not None or info is not None
|
|
|
|
| 123 |
|
| 124 |
# 3) Raw math from parameter count — clearly labelled estimate.
|
| 125 |
st = getattr(info, "safetensors", None)
|
|
|
|
| 118 |
explain += " Add roughly 0.1–0.5 GB for the adapter file."
|
| 119 |
else:
|
| 120 |
explain += f"is <b>{entry['name']}</b> in our catalogue."
|
| 121 |
+
# The use case to re-render the full breakdown under (cross-family aware).
|
| 122 |
+
fam = entry.get("family")
|
| 123 |
+
uc_key = ("vlm" if fam == "vlm" else "chat" if fam == "llm"
|
| 124 |
+
else (entry.get("use_cases") or [None])[0])
|
| 125 |
return {"found": True, "match": "catalogue", "chain": chain,
|
| 126 |
+
"explain": explain, "option": opt, "live": via is not None or info is not None,
|
| 127 |
+
"focus_name": entry["name"], "uc_key": uc_key}
|
| 128 |
|
| 129 |
# 3) Raw math from parameter count — clearly labelled estimate.
|
| 130 |
st = getattr(info, "safetensors", None)
|
engine/real_advisor.py
CHANGED
|
@@ -97,8 +97,11 @@ USE_CASES = {u.key: u for u in [
|
|
| 97 |
]}
|
| 98 |
|
| 99 |
# Use cases answered by the whole LLM family (entries don't list these).
|
|
|
|
|
|
|
|
|
|
| 100 |
_TEXT_UCS = {"chat", "writing", "coding", "agents", "rag", "translate",
|
| 101 |
-
"finetune"
|
| 102 |
|
| 103 |
_TOOLS = {
|
| 104 |
"llm": [
|
|
@@ -338,24 +341,45 @@ def _option_json(r: dict, spec: HardwareSpec, bw: float | None = None) -> dict:
|
|
| 338 |
}
|
| 339 |
|
| 340 |
|
| 341 |
-
def _pick_headline(results: list[dict], uc: UC
|
|
|
|
| 342 |
great = [r for r in results if r["verdict"] == "great"]
|
| 343 |
tight = [r for r in results if r["verdict"] == "tight"]
|
| 344 |
|
| 345 |
def params(r):
|
| 346 |
return r["entry"].get("params_b", 0)
|
| 347 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
great_ok = [r for r in great if params(r) >= uc.min_b]
|
| 349 |
tight_ok = [r for r in tight if params(r) >= uc.min_b]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
if great_ok:
|
| 351 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
return max(great_ok, key=params), True
|
| 353 |
if tight_ok:
|
| 354 |
-
if uc.good_b > 0:
|
| 355 |
# LLMs: close to the ideal size, not needlessly oversized-and-slow.
|
|
|
|
| 356 |
below = [r for r in tight_ok if params(r) <= uc.good_b * 1.5]
|
| 357 |
return (max(below, key=params) if below else min(tight_ok, key=params)), True
|
| 358 |
-
# Non-LLM families: the biggest
|
| 359 |
return max(tight_ok, key=params), True
|
| 360 |
if great:
|
| 361 |
return max(great, key=params), False
|
|
@@ -390,25 +414,54 @@ def advise_real(payload: dict, spec: HardwareSpec) -> dict:
|
|
| 390 |
# Honest gap, not a fake answer: if the catalogue doesn't cover a goal yet,
|
| 391 |
# say so and point at the live lookup instead of inventing options.
|
| 392 |
if not candidates:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
return {
|
| 394 |
"catalogue_version": catalogue_date(),
|
| 395 |
-
"verdict": "tight", "verdict_word":
|
| 396 |
-
"headline":
|
| 397 |
-
"
|
| 398 |
-
|
| 399 |
-
"rather say so. If you know a specific model for it, paste its "
|
| 400 |
-
"Hugging Face id in the <b>'Have a specific model in mind?'</b> box "
|
| 401 |
-
"and we'll check that exact model against your machine."),
|
| 402 |
-
"note": "The catalogue grows every night; niche goals are next in line.",
|
| 403 |
-
"gauge": {}, "options": [], "tools": _TOOLS.get(uc.family, []),
|
| 404 |
"commands": {"intro": "", "items": []}, "provenance": "",
|
| 405 |
-
"meets_goal": False, "use_case": uc.plain_name,
|
|
|
|
| 406 |
}
|
| 407 |
|
| 408 |
results = [_evaluate(e, spec, uc) for e in candidates]
|
| 409 |
|
| 410 |
fast, total = spec.fast_budget_gb, spec.total_budget_gb
|
| 411 |
-
headline, meets_goal = _pick_headline(results, uc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
|
| 413 |
bw, bw_src = bandwidth_for_spec(spec)
|
| 414 |
options = [_option_json(r, spec, bw) for r in results]
|
|
@@ -422,8 +475,10 @@ def advise_real(payload: dict, spec: HardwareSpec) -> dict:
|
|
| 422 |
"using your computer's memory" if hv == "tight" else "")
|
| 423 |
if hv == "great":
|
| 424 |
head_text = f"Yes, you can run {e['name']} {where}, today."
|
| 425 |
-
|
| 426 |
head_text = f"Sort of. {e['name']} will run {where}, with trade-offs."
|
|
|
|
|
|
|
| 427 |
if e.get("quants"):
|
| 428 |
detail = (
|
| 429 |
f"For this goal, the honest pick is <b>{e['name']}</b> at the "
|
|
@@ -520,6 +575,8 @@ def advise_real(payload: dict, spec: HardwareSpec) -> dict:
|
|
| 520 |
"speed": speed,
|
| 521 |
"meets_goal": meets_goal,
|
| 522 |
"use_case": uc.plain_name,
|
|
|
|
|
|
|
| 523 |
"headline_model": headline["entry"]["name"] if headline else "",
|
| 524 |
}
|
| 525 |
|
|
|
|
| 97 |
]}
|
| 98 |
|
| 99 |
# Use cases answered by the whole LLM family (entries don't list these).
|
| 100 |
+
# "custom" is deliberately NOT here: we can't honestly map free-text goals to
|
| 101 |
+
# the catalogue, so it returns guidance (paste a model id) rather than a
|
| 102 |
+
# misleading generic LLM answer.
|
| 103 |
_TEXT_UCS = {"chat", "writing", "coding", "agents", "rag", "translate",
|
| 104 |
+
"finetune"}
|
| 105 |
|
| 106 |
_TOOLS = {
|
| 107 |
"llm": [
|
|
|
|
| 341 |
}
|
| 342 |
|
| 343 |
|
| 344 |
+
def _pick_headline(results: list[dict], uc: UC,
|
| 345 |
+
priority: str = "balanced") -> tuple[dict | None, bool]:
|
| 346 |
great = [r for r in results if r["verdict"] == "great"]
|
| 347 |
tight = [r for r in results if r["verdict"] == "tight"]
|
| 348 |
|
| 349 |
def params(r):
|
| 350 |
return r["entry"].get("params_b", 0)
|
| 351 |
|
| 352 |
+
def active(r):
|
| 353 |
+
return r["entry"].get("active_params_b") or params(r)
|
| 354 |
+
|
| 355 |
+
def ungated(r):
|
| 356 |
+
return not r["entry"].get("gated", False)
|
| 357 |
+
|
| 358 |
great_ok = [r for r in great if params(r) >= uc.min_b]
|
| 359 |
tight_ok = [r for r in tight if params(r) >= uc.min_b]
|
| 360 |
+
|
| 361 |
+
# "Fully open": prefer ungated, fully-open models when any exist — but never
|
| 362 |
+
# leave the user with nothing, so only filter when it doesn't empty the set.
|
| 363 |
+
if priority == "open":
|
| 364 |
+
if any(ungated(r) for r in great_ok):
|
| 365 |
+
great_ok = [r for r in great_ok if ungated(r)]
|
| 366 |
+
if any(ungated(r) for r in tight_ok):
|
| 367 |
+
tight_ok = [r for r in tight_ok if ungated(r)]
|
| 368 |
+
|
| 369 |
if great_ok:
|
| 370 |
+
if priority == "speed":
|
| 371 |
+
# Fastest = fewest active params, among the still-capable picks.
|
| 372 |
+
capable = [r for r in great_ok if params(r) >= uc.good_b] or great_ok
|
| 373 |
+
return min(capable, key=active), True
|
| 374 |
+
# Quality and balanced both want the biggest model that runs great.
|
| 375 |
return max(great_ok, key=params), True
|
| 376 |
if tight_ok:
|
| 377 |
+
if uc.good_b > 0 and priority != "quality":
|
| 378 |
# LLMs: close to the ideal size, not needlessly oversized-and-slow.
|
| 379 |
+
# "Best quality" overrides this and takes the biggest that still fits.
|
| 380 |
below = [r for r in tight_ok if params(r) <= uc.good_b * 1.5]
|
| 381 |
return (max(below, key=params) if below else min(tight_ok, key=params)), True
|
| 382 |
+
# Non-LLM families (and quality mode): the biggest that fits is best.
|
| 383 |
return max(tight_ok, key=params), True
|
| 384 |
if great:
|
| 385 |
return max(great, key=params), False
|
|
|
|
| 414 |
# Honest gap, not a fake answer: if the catalogue doesn't cover a goal yet,
|
| 415 |
# say so and point at the live lookup instead of inventing options.
|
| 416 |
if not candidates:
|
| 417 |
+
is_custom = uc.key == "custom"
|
| 418 |
+
typed = (payload.get("custom") or "").strip()
|
| 419 |
+
if is_custom:
|
| 420 |
+
head = "Tell us the exact model and we'll check it."
|
| 421 |
+
detail = (
|
| 422 |
+
(f"You described <b>“{typed}”</b>. " if typed else "")
|
| 423 |
+
+ "FitCheck answers from verified model data, so it won't guess which model "
|
| 424 |
+
"your description means. Paste the exact Hugging Face model id (e.g. "
|
| 425 |
+
"<b>lerobot/smolvla_base</b>) in the <b>'Have a specific model in mind?'</b> box "
|
| 426 |
+
"below and we'll check that model against your machine — or pick one of the "
|
| 427 |
+
"categories above for a curated recommendation."
|
| 428 |
+
)
|
| 429 |
+
word = "Name the model"
|
| 430 |
+
else:
|
| 431 |
+
head = "Our catalogue doesn't cover this goal yet."
|
| 432 |
+
detail = ("FitCheck only answers from verified model data, and nothing in the "
|
| 433 |
+
"current catalogue serves this goal — so rather than guess, we'd "
|
| 434 |
+
"rather say so. If you know a specific model for it, paste its "
|
| 435 |
+
"Hugging Face id in the <b>'Have a specific model in mind?'</b> box "
|
| 436 |
+
"and we'll check that exact model against your machine.")
|
| 437 |
+
word = "Not covered yet"
|
| 438 |
return {
|
| 439 |
"catalogue_version": catalogue_date(),
|
| 440 |
+
"verdict": "tight", "verdict_word": word,
|
| 441 |
+
"headline": head, "detail": detail,
|
| 442 |
+
"note": "" if is_custom else "The catalogue grows every night; niche goals are next in line.",
|
| 443 |
+
"gauge": {}, "options": [], "tools": [],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
"commands": {"intro": "", "items": []}, "provenance": "",
|
| 445 |
+
"meets_goal": False, "use_case": uc.plain_name, "usecase": uc.key,
|
| 446 |
+
"focus": "", "headline_model": "",
|
| 447 |
}
|
| 448 |
|
| 449 |
results = [_evaluate(e, spec, uc) for e in candidates]
|
| 450 |
|
| 451 |
fast, total = spec.fast_budget_gb, spec.total_budget_gb
|
| 452 |
+
headline, meets_goal = _pick_headline(results, uc, payload.get("priority", "balanced"))
|
| 453 |
+
|
| 454 |
+
# A specific model the user clicked / asked to focus on. We still show the
|
| 455 |
+
# honest verdict for THAT model — including "won't fit" — instead of the
|
| 456 |
+
# engine's own best pick. The whole breakdown below flows from `headline`.
|
| 457 |
+
focus = (payload.get("focus") or "").strip()
|
| 458 |
+
if focus:
|
| 459 |
+
fr = next((r for r in results
|
| 460 |
+
if r["entry"]["name"] == focus
|
| 461 |
+
or str(r["entry"].get("repo_id", "")).lower() == focus.lower()), None)
|
| 462 |
+
if fr:
|
| 463 |
+
headline = fr
|
| 464 |
+
meets_goal = fr["entry"].get("params_b", 0) >= uc.min_b
|
| 465 |
|
| 466 |
bw, bw_src = bandwidth_for_spec(spec)
|
| 467 |
options = [_option_json(r, spec, bw) for r in results]
|
|
|
|
| 475 |
"using your computer's memory" if hv == "tight" else "")
|
| 476 |
if hv == "great":
|
| 477 |
head_text = f"Yes, you can run {e['name']} {where}, today."
|
| 478 |
+
elif hv == "tight":
|
| 479 |
head_text = f"Sort of. {e['name']} will run {where}, with trade-offs."
|
| 480 |
+
else:
|
| 481 |
+
head_text = f"{e['name']} won't fit on this machine."
|
| 482 |
if e.get("quants"):
|
| 483 |
detail = (
|
| 484 |
f"For this goal, the honest pick is <b>{e['name']}</b> at the "
|
|
|
|
| 575 |
"speed": speed,
|
| 576 |
"meets_goal": meets_goal,
|
| 577 |
"use_case": uc.plain_name,
|
| 578 |
+
"usecase": uc.key,
|
| 579 |
+
"focus": focus or "",
|
| 580 |
"headline_model": headline["entry"]["name"] if headline else "",
|
| 581 |
}
|
| 582 |
|
static/app.js
CHANGED
|
@@ -49,10 +49,6 @@ const USE_CASES = [
|
|
| 49 |
{ id: "forecast", icon: "forecast", label: "Time-series forecasting" },
|
| 50 |
{ id: "tabular", icon: "tabular", label: "Predict from spreadsheets" },
|
| 51 |
]},
|
| 52 |
-
{ icon: "cat-train", name: "Train your own", items: [
|
| 53 |
-
{ id: "finetune", icon: "finetune", label: "Fine-tune an LLM (LoRA)" },
|
| 54 |
-
{ id: "train-vision", icon: "train-vision", label: "Train a vision model" },
|
| 55 |
-
]},
|
| 56 |
{ icon: "cat-custom", name: "Something else", items: [
|
| 57 |
{ id: "custom", icon: "custom", label: "Custom: describe it" },
|
| 58 |
]},
|
|
@@ -74,7 +70,7 @@ const GPUS = {
|
|
| 74 |
};
|
| 75 |
|
| 76 |
const $ = (s) => document.querySelector(s);
|
| 77 |
-
const state = { mode: "have", computer: "Windows laptop", provider: "none", priority: "balanced", usecases: ["chat"], checked: false };
|
| 78 |
let lastAdvice = null; // the most recent /api/advise result — facts the model explains
|
| 79 |
let multiCache = null; // {ucs, results} when several goals are checked at once
|
| 80 |
|
|
@@ -85,37 +81,17 @@ function applyMode() {
|
|
| 85 |
const el = $(s); if (el) el.style.display = buy ? "none" : "";
|
| 86 |
});
|
| 87 |
const repo = $("#repo-field"); if (repo) repo.style.display = buy ? "none" : "";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
$("#check-btn").innerHTML = (buy ? "What should I get? " : "Check my setup ")
|
| 89 |
+ '<span class="ic" data-ic="arrow"></span>';
|
| 90 |
hydrate($("#check-btn"));
|
| 91 |
}
|
| 92 |
|
| 93 |
-
// ---- Best-effort hardware hints from the browser (honest: vendor + floor) --
|
| 94 |
-
async function detectHardware() {
|
| 95 |
-
const bits = [];
|
| 96 |
-
try {
|
| 97 |
-
if (navigator.gpu) {
|
| 98 |
-
const ad = await navigator.gpu.requestAdapter();
|
| 99 |
-
const v = ((ad && ad.info && (ad.info.vendor || ad.info.description)) || "").toLowerCase();
|
| 100 |
-
for (const vendor of ["nvidia", "amd", "apple", "intel"]) {
|
| 101 |
-
if (v.includes(vendor)) {
|
| 102 |
-
state.provider = vendor;
|
| 103 |
-
setActive("#provider-seg", vendor);
|
| 104 |
-
fillGpu();
|
| 105 |
-
bits.push(`a ${vendor.toUpperCase().replace("APPLE","Apple")} GPU`);
|
| 106 |
-
break;
|
| 107 |
-
}
|
| 108 |
-
}
|
| 109 |
-
}
|
| 110 |
-
} catch (e) { /* detection is best-effort only */ }
|
| 111 |
-
if (navigator.deviceMemory) bits.push(`at least ${navigator.deviceMemory} GB of RAM`);
|
| 112 |
-
if (bits.length) {
|
| 113 |
-
const h = $("#detect-hint");
|
| 114 |
-
h.style.display = "";
|
| 115 |
-
h.textContent = `Your browser reports ${bits.join(" and ")} — browsers can't see exact specs, so please confirm below.`;
|
| 116 |
-
}
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
// ---- Build the use-case picker -------------------------------------------
|
| 120 |
function buildPicker() {
|
| 121 |
const wrap = $("#usecase-picker");
|
|
@@ -160,6 +136,18 @@ function setActive(id, val) {
|
|
| 160 |
$(id).querySelectorAll(".seg-btn").forEach(b => b.classList.toggle("active", b.dataset.val === val));
|
| 161 |
}
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
// ---- GPU select depends on provider --------------------------------------
|
| 164 |
function fillGpu() {
|
| 165 |
const list = GPUS[state.provider] || [];
|
|
@@ -227,6 +215,7 @@ function gather() {
|
|
| 227 |
usecases: state.usecases.slice(),
|
| 228 |
custom: $("#custom-uc").value.trim(),
|
| 229 |
priority: state.priority,
|
|
|
|
| 230 |
repo: $("#repo-check") ? $("#repo-check").value.trim() : "",
|
| 231 |
};
|
| 232 |
}
|
|
@@ -250,6 +239,9 @@ async function check() {
|
|
| 250 |
renderBuy(await res.json());
|
| 251 |
return;
|
| 252 |
}
|
|
|
|
|
|
|
|
|
|
| 253 |
if (state.usecases.length > 1) {
|
| 254 |
const results = await Promise.all(state.usecases.map(u =>
|
| 255 |
fetch("/api/advise", {
|
|
@@ -258,7 +250,6 @@ async function check() {
|
|
| 258 |
}).then(r => r.json())));
|
| 259 |
multiCache = { ucs: state.usecases.slice(), results };
|
| 260 |
renderMulti(results);
|
| 261 |
-
if (payload.repo) lookupRepo(payload);
|
| 262 |
return;
|
| 263 |
}
|
| 264 |
multiCache = null;
|
|
@@ -267,7 +258,6 @@ async function check() {
|
|
| 267 |
body: JSON.stringify(payload),
|
| 268 |
});
|
| 269 |
render(await res.json());
|
| 270 |
-
if (payload.repo) lookupRepo(payload); // optional live check, appended on top
|
| 271 |
} catch (e) {
|
| 272 |
$("#results").innerHTML = `<div class="empty-state"><div class="big"><span class="ic" data-ic="monitor"></span></div>
|
| 273 |
<p>Couldn't reach the advisor: ${e && e.message ? e.message : e}</p></div>`;
|
|
@@ -276,43 +266,63 @@ async function check() {
|
|
| 276 |
}
|
| 277 |
|
| 278 |
// ---- Live single-model lookup (the one online feature, labelled as such) ---
|
|
|
|
|
|
|
|
|
|
| 279 |
async function lookupRepo(payload) {
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
holder.innerHTML = `<div class="ans-loading"><span class="spinner"></span>Looking up ${payload.repo} on Hugging Face…</div>`;
|
| 283 |
try {
|
| 284 |
const res = await fetch("/api/lookup", {
|
| 285 |
method: "POST", headers: { "Content-Type": "application/json" },
|
| 286 |
body: JSON.stringify(payload),
|
| 287 |
});
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
}
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
<div class="lookup-meta">
|
| 303 |
${o.memory && o.memory !== "Too big" ? `<span><b>${o.memory}</b> needed (${o.setting})</span>` : `<span><b>Too big</b> for this machine</span>`}
|
| 304 |
${o.url ? `<a href="${o.url}" target="_blank" rel="noopener">View on Hugging Face</a>` : ""}
|
| 305 |
</div>
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
navigator.clipboard.writeText(decodeURIComponent(b.dataset.code));
|
| 310 |
-
b.textContent = "Copied ✓";
|
| 311 |
-
setTimeout(() => { b.textContent = "Copy"; }, 1500);
|
| 312 |
-
}));
|
| 313 |
-
} catch (e) {
|
| 314 |
-
holder.innerHTML = `<div class="ans-card ans-error"><h3>Lookup failed</h3><p>${e && e.message ? e.message : e}</p></div>`;
|
| 315 |
-
}
|
| 316 |
}
|
| 317 |
|
| 318 |
// ---- Multi-goal overview (several goals checked at once) -------------------
|
|
@@ -404,11 +414,13 @@ function render(d) {
|
|
| 404 |
return `<span class="lic${warn ? " warn" : ""}" title="${o.license_note || o.license}">${label}</span>`
|
| 405 |
+ (o.gated ? `<span class="lic gatechip" title="Accept the terms on Hugging Face once before downloading">gated</span>` : "");
|
| 406 |
};
|
|
|
|
| 407 |
const opts = (d.options || []).map(o => {
|
| 408 |
const ov = VMAP[o.verdict] || VMAP.tight;
|
| 409 |
const name = o.url
|
| 410 |
? `<a href="${o.url}" target="_blank" rel="noopener">${o.model}</a>` : o.model;
|
| 411 |
-
|
|
|
|
| 412 |
<div class="vdot">${ov.em}</div>
|
| 413 |
<div><div class="name">${name}${licChip(o)}</div><div class="desc">${o.desc}</div></div>
|
| 414 |
<div class="meta"><b>${o.memory}</b><div class="feel">${o.setting}${o.feel && o.feel !== "—" ? " · " + o.feel : ""}</div></div>
|
|
@@ -429,6 +441,18 @@ function render(d) {
|
|
| 429 |
<pre><code>${c.code}</code></pre>
|
| 430 |
</div>`).join("");
|
| 431 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
$("#results").innerHTML = `
|
| 433 |
<div class="reveal">
|
| 434 |
<div id="lookup-result"></div>
|
|
@@ -473,12 +497,14 @@ function render(d) {
|
|
| 473 |
</div>
|
| 474 |
</details>` : ""}
|
| 475 |
|
| 476 |
-
${opts ? `<div class="section-title">What you can run <span class="sub">
|
| 477 |
<div class="opt-grid">${opts}</div>` : ""}
|
| 478 |
|
| 479 |
-
${tools ? `<div class="section-title">How to actually run it</div>
|
| 480 |
<div class="tool-grid">${tools}</div>` : ""}
|
| 481 |
|
|
|
|
|
|
|
| 482 |
${cmds ? `<div class="section-title">Copy-paste to get started</div>
|
| 483 |
<p class="cmd-intro">${d.commands.intro || ""}</p>
|
| 484 |
<div class="cmd">${cmds}</div>` : ""}
|
|
@@ -513,9 +539,30 @@ function render(d) {
|
|
| 513 |
b.textContent = "Copied ✓"; b.classList.add("done");
|
| 514 |
setTimeout(() => { b.textContent = "Copy"; b.classList.remove("done"); }, 1500);
|
| 515 |
}));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
wireAsk();
|
| 517 |
}
|
| 518 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
// ---- "Why this speed?" roofline scatter (real LocalScore runs) ------------
|
| 520 |
let _rooflinePts = null;
|
| 521 |
async function getRooflinePoints() {
|
|
@@ -754,13 +801,13 @@ function init() {
|
|
| 754 |
wireSegmented("#computer-seg", "computer", () => { syncProviderForComputer(); $("#find-specs-body").innerHTML = findSpecsText(); });
|
| 755 |
wireSegmented("#provider-seg", "provider", fillGpu);
|
| 756 |
wireSegmented("#priority-seg", "priority");
|
|
|
|
| 757 |
["#ram","#gpu","#vram","#custom-uc","#repo-check"].forEach(s => { const el = $(s); if (el) el.addEventListener("change", maybeLiveUpdate); });
|
| 758 |
$("#paste").addEventListener("input", maybeLiveUpdate);
|
| 759 |
$("#check-btn").addEventListener("click", check);
|
| 760 |
const pb = $("#parse-btn"); if (pb) pb.addEventListener("click", parsePaste);
|
| 761 |
syncProviderForComputer();
|
| 762 |
$("#find-specs-body").innerHTML = findSpecsText();
|
| 763 |
-
detectHardware();
|
| 764 |
// Pre-filled share/preview links: ?go renders immediately; optional
|
| 765 |
// ?gpu=NVIDIA|RTX 3060 (12 GB)&ram=16&uc=chat pre-select a profile.
|
| 766 |
const q = new URLSearchParams(location.search);
|
|
|
|
| 49 |
{ id: "forecast", icon: "forecast", label: "Time-series forecasting" },
|
| 50 |
{ id: "tabular", icon: "tabular", label: "Predict from spreadsheets" },
|
| 51 |
]},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
{ icon: "cat-custom", name: "Something else", items: [
|
| 53 |
{ id: "custom", icon: "custom", label: "Custom: describe it" },
|
| 54 |
]},
|
|
|
|
| 70 |
};
|
| 71 |
|
| 72 |
const $ = (s) => document.querySelector(s);
|
| 73 |
+
const state = { mode: "have", task: "run", computer: "Windows laptop", provider: "none", priority: "balanced", usecases: ["chat"], checked: false };
|
| 74 |
let lastAdvice = null; // the most recent /api/advise result — facts the model explains
|
| 75 |
let multiCache = null; // {ucs, results} when several goals are checked at once
|
| 76 |
|
|
|
|
| 81 |
const el = $(s); if (el) el.style.display = buy ? "none" : "";
|
| 82 |
});
|
| 83 |
const repo = $("#repo-field"); if (repo) repo.style.display = buy ? "none" : "";
|
| 84 |
+
const tog = $("#task-toggle"); if (tog) tog.style.display = buy ? "none" : "";
|
| 85 |
+
if (buy && state.task !== "run") { // buy advice is inference-only
|
| 86 |
+
state.task = "run";
|
| 87 |
+
tog.classList.remove("train");
|
| 88 |
+
tog.querySelectorAll(".tt-opt").forEach(x => x.classList.toggle("active", x.dataset.task === "run"));
|
| 89 |
+
}
|
| 90 |
$("#check-btn").innerHTML = (buy ? "What should I get? " : "Check my setup ")
|
| 91 |
+ '<span class="ic" data-ic="arrow"></span>';
|
| 92 |
hydrate($("#check-btn"));
|
| 93 |
}
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
// ---- Build the use-case picker -------------------------------------------
|
| 96 |
function buildPicker() {
|
| 97 |
const wrap = $("#usecase-picker");
|
|
|
|
| 136 |
$(id).querySelectorAll(".seg-btn").forEach(b => b.classList.toggle("active", b.dataset.val === val));
|
| 137 |
}
|
| 138 |
|
| 139 |
+
// Run vs Train sliding switch — recomputes the recommendations for training.
|
| 140 |
+
function wireTaskToggle() {
|
| 141 |
+
const tog = $("#task-toggle");
|
| 142 |
+
if (!tog) return;
|
| 143 |
+
tog.querySelectorAll(".tt-opt").forEach(b => b.addEventListener("click", () => {
|
| 144 |
+
state.task = b.dataset.task;
|
| 145 |
+
tog.querySelectorAll(".tt-opt").forEach(x => x.classList.toggle("active", x === b));
|
| 146 |
+
tog.classList.toggle("train", state.task === "finetune");
|
| 147 |
+
maybeLiveUpdate();
|
| 148 |
+
}));
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
// ---- GPU select depends on provider --------------------------------------
|
| 152 |
function fillGpu() {
|
| 153 |
const list = GPUS[state.provider] || [];
|
|
|
|
| 215 |
usecases: state.usecases.slice(),
|
| 216 |
custom: $("#custom-uc").value.trim(),
|
| 217 |
priority: state.priority,
|
| 218 |
+
mode: state.task, // "run" (inference) or "finetune" (training)
|
| 219 |
repo: $("#repo-check") ? $("#repo-check").value.trim() : "",
|
| 220 |
};
|
| 221 |
}
|
|
|
|
| 239 |
renderBuy(await res.json());
|
| 240 |
return;
|
| 241 |
}
|
| 242 |
+
// A specific model named in the box takes over: we check THAT model and
|
| 243 |
+
// remake the whole answer for it, rather than show the goal list.
|
| 244 |
+
if (payload.repo) { multiCache = null; await lookupRepo(payload); return; }
|
| 245 |
if (state.usecases.length > 1) {
|
| 246 |
const results = await Promise.all(state.usecases.map(u =>
|
| 247 |
fetch("/api/advise", {
|
|
|
|
| 250 |
}).then(r => r.json())));
|
| 251 |
multiCache = { ucs: state.usecases.slice(), results };
|
| 252 |
renderMulti(results);
|
|
|
|
| 253 |
return;
|
| 254 |
}
|
| 255 |
multiCache = null;
|
|
|
|
| 258 |
body: JSON.stringify(payload),
|
| 259 |
});
|
| 260 |
render(await res.json());
|
|
|
|
| 261 |
} catch (e) {
|
| 262 |
$("#results").innerHTML = `<div class="empty-state"><div class="big"><span class="ic" data-ic="monitor"></span></div>
|
| 263 |
<p>Couldn't reach the advisor: ${e && e.message ? e.message : e}</p></div>`;
|
|
|
|
| 266 |
}
|
| 267 |
|
| 268 |
// ---- Live single-model lookup (the one online feature, labelled as such) ---
|
| 269 |
+
// Naming a model remakes the whole answer for it: a catalogue match (or a
|
| 270 |
+
// finetune that walks to a catalogue base) re-renders the full breakdown; a
|
| 271 |
+
// model we don't know falls back to clearly-labelled raw parameter math.
|
| 272 |
async function lookupRepo(payload) {
|
| 273 |
+
$("#results").innerHTML = `<div class="reveal"><div class="ans-loading"><span class="spinner"></span> Looking up ${payload.repo} on Hugging Face…</div></div>`;
|
| 274 |
+
let d;
|
|
|
|
| 275 |
try {
|
| 276 |
const res = await fetch("/api/lookup", {
|
| 277 |
method: "POST", headers: { "Content-Type": "application/json" },
|
| 278 |
body: JSON.stringify(payload),
|
| 279 |
});
|
| 280 |
+
d = await res.json();
|
| 281 |
+
} catch (e) {
|
| 282 |
+
$("#results").innerHTML = `<div class="ans-card ans-error"><h3>Lookup failed</h3><p>${e && e.message ? e.message : e}</p></div>`;
|
| 283 |
+
hydrate($("#results")); return;
|
| 284 |
+
}
|
| 285 |
+
if (d.error) {
|
| 286 |
+
$("#results").innerHTML = `<div class="ans-card ans-error"><h3>Couldn't check that model</h3><p>${d.error}</p></div>`;
|
| 287 |
+
hydrate($("#results")); return;
|
| 288 |
+
}
|
| 289 |
+
// Catalogue match (directly, or via the finetune -> base walk): remake the
|
| 290 |
+
// full breakdown for the resolved model, then explain the link on top.
|
| 291 |
+
if (d.match === "catalogue" && d.focus_name) {
|
| 292 |
+
const uc = d.uc_key || (lastAdvice && lastAdvice.usecase) || state.usecases[0];
|
| 293 |
+
const res = await fetch("/api/advise", {
|
| 294 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 295 |
+
body: JSON.stringify({ ...payload, usecase: uc, focus: d.focus_name }),
|
| 296 |
+
});
|
| 297 |
+
render(await res.json());
|
| 298 |
+
const wrap = $("#results").firstElementChild;
|
| 299 |
+
if (wrap) {
|
| 300 |
+
const banner = document.createElement("div");
|
| 301 |
+
banner.className = "lookup-banner";
|
| 302 |
+
banner.innerHTML = `<span class="live-tag">Live Hugging Face lookup</span><p>${d.explain || ""}</p>`;
|
| 303 |
+
wrap.prepend(banner);
|
| 304 |
}
|
| 305 |
+
return;
|
| 306 |
+
}
|
| 307 |
+
// We don't know this model: honest raw-parameter estimate, clearly labelled.
|
| 308 |
+
const o = d.option || {};
|
| 309 |
+
const v = VMAP[o.verdict] || VMAP.tight;
|
| 310 |
+
$("#results").innerHTML = `
|
| 311 |
+
<div class="reveal">
|
| 312 |
+
<div class="verdict" style="--status:${v.cls};--status-soft:${v.soft}">
|
| 313 |
+
<span class="badge"><span class="dot"></span>${v.word}</span>
|
| 314 |
+
<span class="live-tag">Live Hugging Face lookup</span>
|
| 315 |
+
<h2>${o.model || payload.repo}</h2>
|
| 316 |
+
<p>${d.explain || ""}</p>
|
| 317 |
+
</div>
|
| 318 |
+
<div class="lookup-card" style="--status:${v.cls};--status-soft:${v.soft}">
|
| 319 |
<div class="lookup-meta">
|
| 320 |
${o.memory && o.memory !== "Too big" ? `<span><b>${o.memory}</b> needed (${o.setting})</span>` : `<span><b>Too big</b> for this machine</span>`}
|
| 321 |
${o.url ? `<a href="${o.url}" target="_blank" rel="noopener">View on Hugging Face</a>` : ""}
|
| 322 |
</div>
|
| 323 |
+
</div>
|
| 324 |
+
</div>`;
|
| 325 |
+
hydrate($("#results"));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
}
|
| 327 |
|
| 328 |
// ---- Multi-goal overview (several goals checked at once) -------------------
|
|
|
|
| 414 |
return `<span class="lic${warn ? " warn" : ""}" title="${o.license_note || o.license}">${label}</span>`
|
| 415 |
+ (o.gated ? `<span class="lic gatechip" title="Accept the terms on Hugging Face once before downloading">gated</span>` : "");
|
| 416 |
};
|
| 417 |
+
const current = d.focus || d.headline_model;
|
| 418 |
const opts = (d.options || []).map(o => {
|
| 419 |
const ov = VMAP[o.verdict] || VMAP.tight;
|
| 420 |
const name = o.url
|
| 421 |
? `<a href="${o.url}" target="_blank" rel="noopener">${o.model}</a>` : o.model;
|
| 422 |
+
const cur = o.model === current ? " opt-current" : "";
|
| 423 |
+
return `<div class="opt${cur}" data-model="${o.model.replace(/"/g, """)}" style="--status:${ov.cls};--status-soft:${ov.soft}">
|
| 424 |
<div class="vdot">${ov.em}</div>
|
| 425 |
<div><div class="name">${name}${licChip(o)}</div><div class="desc">${o.desc}</div></div>
|
| 426 |
<div class="meta"><b>${o.memory}</b><div class="feel">${o.setting}${o.feel && o.feel !== "—" ? " · " + o.feel : ""}</div></div>
|
|
|
|
| 441 |
<pre><code>${c.code}</code></pre>
|
| 442 |
</div>`).join("");
|
| 443 |
|
| 444 |
+
const isFT = d.mode === "finetune";
|
| 445 |
+
const cloud = isFT && (d.cloud || []).length ? `
|
| 446 |
+
<div class="section-title">Train it in the cloud <span class="sub">when your machine is too small, or you'd rather not tie it up</span></div>
|
| 447 |
+
<div class="tool-grid">
|
| 448 |
+
${d.cloud.map(c => `
|
| 449 |
+
<div class="tool">
|
| 450 |
+
<div class="tool-head"><span class="tname">${c.name}</span><span class="tag mid">${c.cost}</span></div>
|
| 451 |
+
<div class="twhat">${c.what}</div>
|
| 452 |
+
<div class="tinstall"><a href="${c.link}" target="_blank" rel="noopener"><span class="ic" data-ic="arrow"></span>Open</a></div>
|
| 453 |
+
</div>`).join("")}
|
| 454 |
+
</div>` : "";
|
| 455 |
+
|
| 456 |
$("#results").innerHTML = `
|
| 457 |
<div class="reveal">
|
| 458 |
<div id="lookup-result"></div>
|
|
|
|
| 497 |
</div>
|
| 498 |
</details>` : ""}
|
| 499 |
|
| 500 |
+
${opts ? `<div class="section-title">${isFT ? "What you can fine-tune" : "What you can run"} <span class="sub">biggest to smallest — click any model for its full breakdown; names link to Hugging Face</span></div>
|
| 501 |
<div class="opt-grid">${opts}</div>` : ""}
|
| 502 |
|
| 503 |
+
${tools ? `<div class="section-title">${isFT ? "How to fine-tune it" : "How to actually run it"}</div>
|
| 504 |
<div class="tool-grid">${tools}</div>` : ""}
|
| 505 |
|
| 506 |
+
${cloud}
|
| 507 |
+
|
| 508 |
${cmds ? `<div class="section-title">Copy-paste to get started</div>
|
| 509 |
<p class="cmd-intro">${d.commands.intro || ""}</p>
|
| 510 |
<div class="cmd">${cmds}</div>` : ""}
|
|
|
|
| 539 |
b.textContent = "Copied ✓"; b.classList.add("done");
|
| 540 |
setTimeout(() => { b.textContent = "Copy"; b.classList.remove("done"); }, 1500);
|
| 541 |
}));
|
| 542 |
+
// Click any option card to re-render the whole breakdown for THAT model.
|
| 543 |
+
$("#results").querySelectorAll(".opt[data-model]").forEach(card =>
|
| 544 |
+
card.addEventListener("click", (ev) => {
|
| 545 |
+
if (ev.target.closest("a")) return; // let the Hugging Face link open
|
| 546 |
+
focusModel(card.dataset.model);
|
| 547 |
+
}));
|
| 548 |
wireAsk();
|
| 549 |
}
|
| 550 |
|
| 551 |
+
// Re-run the engine focused on one specific model and scroll the answer up.
|
| 552 |
+
async function focusModel(model) {
|
| 553 |
+
const uc = (lastAdvice && lastAdvice.usecase) || state.usecases[0];
|
| 554 |
+
try {
|
| 555 |
+
const res = await fetch("/api/advise", {
|
| 556 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 557 |
+
body: JSON.stringify({ ...gather(), usecase: uc, focus: model }),
|
| 558 |
+
});
|
| 559 |
+
render(await res.json());
|
| 560 |
+
$("#results").scrollIntoView({ behavior: "smooth", block: "start" });
|
| 561 |
+
} catch (e) {
|
| 562 |
+
/* leave the current view in place on a transient failure */
|
| 563 |
+
}
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
// ---- "Why this speed?" roofline scatter (real LocalScore runs) ------------
|
| 567 |
let _rooflinePts = null;
|
| 568 |
async function getRooflinePoints() {
|
|
|
|
| 801 |
wireSegmented("#computer-seg", "computer", () => { syncProviderForComputer(); $("#find-specs-body").innerHTML = findSpecsText(); });
|
| 802 |
wireSegmented("#provider-seg", "provider", fillGpu);
|
| 803 |
wireSegmented("#priority-seg", "priority");
|
| 804 |
+
wireTaskToggle();
|
| 805 |
["#ram","#gpu","#vram","#custom-uc","#repo-check"].forEach(s => { const el = $(s); if (el) el.addEventListener("change", maybeLiveUpdate); });
|
| 806 |
$("#paste").addEventListener("input", maybeLiveUpdate);
|
| 807 |
$("#check-btn").addEventListener("click", check);
|
| 808 |
const pb = $("#parse-btn"); if (pb) pb.addEventListener("click", parsePaste);
|
| 809 |
syncProviderForComputer();
|
| 810 |
$("#find-specs-body").innerHTML = findSpecsText();
|
|
|
|
| 811 |
// Pre-filled share/preview links: ?go renders immediately; optional
|
| 812 |
// ?gpu=NVIDIA|RTX 3060 (12 GB)&ram=16&uc=chat pre-select a profile.
|
| 813 |
const q = new URLSearchParams(location.search);
|
static/index.html
CHANGED
|
@@ -43,7 +43,6 @@
|
|
| 43 |
<!-- Step 1: machine -->
|
| 44 |
<div class="step" id="machine-step">
|
| 45 |
<div class="step-head"><span class="step-num">1</span><h2>Your computer</h2></div>
|
| 46 |
-
<div class="hint" id="detect-hint" style="display:none; margin-bottom:var(--s-3)"></div>
|
| 47 |
|
| 48 |
<div class="field">
|
| 49 |
<span class="label">Describe it in your own words <span class="optional">(fastest)</span></span>
|
|
@@ -101,6 +100,12 @@
|
|
| 101 |
<!-- Step 2: goal -->
|
| 102 |
<div class="step">
|
| 103 |
<div class="step-head"><span class="step-num">2</span><h2>What do you want to do? <span class="optional">(pick one or several)</span></h2></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
<div id="usecase-picker"><!-- rendered by app.js --></div>
|
| 105 |
<div class="field" id="custom-uc-field" style="display:none; margin-top:var(--s-3)">
|
| 106 |
<span class="label">Describe what you want to build</span>
|
|
@@ -118,10 +123,10 @@
|
|
| 118 |
<div class="step-head"><span class="step-num">3</span><h2>What matters most? <span class="optional">(optional)</span></h2></div>
|
| 119 |
<div class="field" style="margin-bottom:0">
|
| 120 |
<div class="segmented" id="priority-seg">
|
| 121 |
-
<button class="seg-btn active" data-val="balanced"><span class="ic" data-ic="balanced"></span>Balanced</button>
|
| 122 |
-
<button class="seg-btn" data-val="quality"><span class="ic" data-ic="quality"></span>Best quality</button>
|
| 123 |
-
<button class="seg-btn" data-val="speed"><span class="ic" data-ic="speed"></span>Fastest</button>
|
| 124 |
-
<button class="seg-btn" data-val="offline"><span class="ic" data-ic="offline"></span>Fully
|
| 125 |
</div>
|
| 126 |
</div>
|
| 127 |
</div>
|
|
|
|
| 43 |
<!-- Step 1: machine -->
|
| 44 |
<div class="step" id="machine-step">
|
| 45 |
<div class="step-head"><span class="step-num">1</span><h2>Your computer</h2></div>
|
|
|
|
| 46 |
|
| 47 |
<div class="field">
|
| 48 |
<span class="label">Describe it in your own words <span class="optional">(fastest)</span></span>
|
|
|
|
| 100 |
<!-- Step 2: goal -->
|
| 101 |
<div class="step">
|
| 102 |
<div class="step-head"><span class="step-num">2</span><h2>What do you want to do? <span class="optional">(pick one or several)</span></h2></div>
|
| 103 |
+
<div class="task-toggle" id="task-toggle" role="tablist"
|
| 104 |
+
data-tip="Running a model and fine-tuning it need very different memory. Switch this and the recommendations below recompute for training.">
|
| 105 |
+
<button class="tt-opt active" data-task="run" role="tab"><span class="ic" data-ic="chat"></span>Run a model</button>
|
| 106 |
+
<button class="tt-opt" data-task="finetune" role="tab"><span class="ic" data-ic="finetune"></span>Train / fine-tune</button>
|
| 107 |
+
<span class="tt-slider" aria-hidden="true"></span>
|
| 108 |
+
</div>
|
| 109 |
<div id="usecase-picker"><!-- rendered by app.js --></div>
|
| 110 |
<div class="field" id="custom-uc-field" style="display:none; margin-top:var(--s-3)">
|
| 111 |
<span class="label">Describe what you want to build</span>
|
|
|
|
| 123 |
<div class="step-head"><span class="step-num">3</span><h2>What matters most? <span class="optional">(optional)</span></h2></div>
|
| 124 |
<div class="field" style="margin-bottom:0">
|
| 125 |
<div class="segmented" id="priority-seg">
|
| 126 |
+
<button class="seg-btn active" data-val="balanced" data-tip="Our default: the best mix of quality and speed your machine can comfortably handle."><span class="ic" data-ic="balanced"></span>Balanced</button>
|
| 127 |
+
<button class="seg-btn" data-val="quality" data-tip="Pick the most capable model that runs, even if it's slower or tight on memory."><span class="ic" data-ic="quality"></span>Best quality</button>
|
| 128 |
+
<button class="seg-btn" data-val="speed" data-tip="Prefer the snappiest model (smaller, or mixture-of-experts) for instant replies."><span class="ic" data-ic="speed"></span>Fastest</button>
|
| 129 |
+
<button class="seg-btn" data-val="open" data-tip="Only fully-open, ungated models: no Hugging Face sign-in or licence to accept. (Every pick already runs 100% offline on your machine once downloaded.)"><span class="ic" data-ic="offline"></span>Fully open</button>
|
| 130 |
</div>
|
| 131 |
</div>
|
| 132 |
</div>
|
static/style.css
CHANGED
|
@@ -176,6 +176,18 @@ button { font-family: inherit; cursor: pointer; }
|
|
| 176 |
.seg-btn.active .ic.brand { color: var(--segc, var(--text-primary)); }
|
| 177 |
.seg-btn.disabled { opacity: .3; pointer-events: none; }
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
/* Each position gets its own accent when selected — coloured boundaries,
|
| 180 |
line icons stay line icons. */
|
| 181 |
.segmented .seg-btn:nth-child(1) { --segc: #60A5FA; }
|
|
@@ -211,6 +223,32 @@ select {
|
|
| 211 |
textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
|
| 212 |
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-3); }
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
/* Use-case picker */
|
| 215 |
.uc-group { margin-bottom: var(--s-4); }
|
| 216 |
.uc-cat {
|
|
@@ -385,6 +423,13 @@ details.disc > summary:hover { color: var(--text-primary); }
|
|
| 385 |
.opt .meta { text-align: right; font-size: 13px; color: var(--text-secondary); white-space: nowrap; }
|
| 386 |
.opt .meta b { color: var(--text-primary); font-family: var(--font-head); }
|
| 387 |
.opt .feel { font-size: 12.5px; color: var(--text-muted); margin-top: 2px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
|
| 389 |
/* Tool cards */
|
| 390 |
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px,1fr)); gap: var(--s-3); }
|
|
@@ -491,6 +536,13 @@ details.disc > summary:hover { color: var(--text-primary); }
|
|
| 491 |
}
|
| 492 |
.lookup-explain { color: var(--text-secondary); font-size: 14.5px; }
|
| 493 |
.lookup-meta { display: flex; gap: var(--s-4); margin-top: var(--s-2); font-size: 13.5px; color: var(--text-secondary); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
|
| 495 |
/* Ask a follow-up (the model brick) */
|
| 496 |
.ask-row { display: flex; gap: var(--s-2); }
|
|
|
|
| 176 |
.seg-btn.active .ic.brand { color: var(--segc, var(--text-primary)); }
|
| 177 |
.seg-btn.disabled { opacity: .3; pointer-events: none; }
|
| 178 |
|
| 179 |
+
/* Hover tooltip: explains a control on hover/focus (priority row, etc.). */
|
| 180 |
+
[data-tip] { position: relative; }
|
| 181 |
+
[data-tip]:hover::after, [data-tip]:focus-visible::after {
|
| 182 |
+
content: attr(data-tip);
|
| 183 |
+
position: absolute; left: 50%; bottom: calc(100% + 8px); transform: translateX(-50%);
|
| 184 |
+
width: max-content; max-width: 240px; z-index: 30;
|
| 185 |
+
background: var(--bg-raised); color: var(--text-secondary);
|
| 186 |
+
border: 1px solid var(--border-hi); border-radius: var(--r-sm);
|
| 187 |
+
padding: 8px 10px; font-size: 12px; font-weight: 500; line-height: 1.45; text-align: left;
|
| 188 |
+
box-shadow: var(--shadow-lg); pointer-events: none; white-space: normal;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
/* Each position gets its own accent when selected — coloured boundaries,
|
| 192 |
line icons stay line icons. */
|
| 193 |
.segmented .seg-btn:nth-child(1) { --segc: #60A5FA; }
|
|
|
|
| 223 |
textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
|
| 224 |
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-3); }
|
| 225 |
|
| 226 |
+
/* Run vs Train toggle: a sliding two-way switch above the use-case picker. */
|
| 227 |
+
.task-toggle {
|
| 228 |
+
position: relative; display: grid; grid-template-columns: 1fr 1fr;
|
| 229 |
+
gap: 0; background: var(--bg-inset); border: 1px solid var(--border);
|
| 230 |
+
border-radius: var(--r-pill); padding: 3px; margin-bottom: var(--s-4);
|
| 231 |
+
}
|
| 232 |
+
.tt-opt {
|
| 233 |
+
position: relative; z-index: 2; background: transparent; border: 0;
|
| 234 |
+
color: var(--text-secondary); font-size: 13.5px; font-weight: 600;
|
| 235 |
+
padding: 8px 12px; border-radius: var(--r-pill); cursor: pointer;
|
| 236 |
+
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
| 237 |
+
transition: color .2s;
|
| 238 |
+
}
|
| 239 |
+
.tt-opt .ic { font-size: 16px; }
|
| 240 |
+
.tt-opt.active { color: #fff; }
|
| 241 |
+
.tt-slider {
|
| 242 |
+
position: absolute; z-index: 1; top: 3px; bottom: 3px; left: 3px;
|
| 243 |
+
width: calc(50% - 3px); border-radius: var(--r-pill);
|
| 244 |
+
background: linear-gradient(135deg, var(--accent), #7C9CF6);
|
| 245 |
+
box-shadow: var(--shadow-sm); transition: transform .25s cubic-bezier(.4,0,.2,1);
|
| 246 |
+
}
|
| 247 |
+
.task-toggle.train .tt-slider { transform: translateX(100%); }
|
| 248 |
+
.task-toggle.train .tt-slider {
|
| 249 |
+
background: linear-gradient(135deg, #F472B6, #FB923C);
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
/* Use-case picker */
|
| 253 |
.uc-group { margin-bottom: var(--s-4); }
|
| 254 |
.uc-cat {
|
|
|
|
| 423 |
.opt .meta { text-align: right; font-size: 13px; color: var(--text-secondary); white-space: nowrap; }
|
| 424 |
.opt .meta b { color: var(--text-primary); font-family: var(--font-head); }
|
| 425 |
.opt .feel { font-size: 12.5px; color: var(--text-muted); margin-top: 2px; }
|
| 426 |
+
/* Every option card is clickable: re-renders the full breakdown for that model. */
|
| 427 |
+
.opt[data-model] { cursor: pointer; }
|
| 428 |
+
.opt[data-model]:hover { border-color: var(--border-hi); }
|
| 429 |
+
.opt-current {
|
| 430 |
+
border-color: var(--status) !important;
|
| 431 |
+
box-shadow: inset 0 0 0 1px var(--status), var(--shadow-lg);
|
| 432 |
+
}
|
| 433 |
|
| 434 |
/* Tool cards */
|
| 435 |
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px,1fr)); gap: var(--s-3); }
|
|
|
|
| 536 |
}
|
| 537 |
.lookup-explain { color: var(--text-secondary); font-size: 14.5px; }
|
| 538 |
.lookup-meta { display: flex; gap: var(--s-4); margin-top: var(--s-2); font-size: 13.5px; color: var(--text-secondary); }
|
| 539 |
+
/* Banner above a remade breakdown when the answer came from a live lookup. */
|
| 540 |
+
.lookup-banner {
|
| 541 |
+
display: flex; align-items: center; gap: var(--s-3); flex-wrap: wrap;
|
| 542 |
+
background: var(--accent-soft); border: 1px solid var(--accent);
|
| 543 |
+
border-radius: var(--r-md); padding: var(--s-3) var(--s-4); margin-bottom: var(--s-4);
|
| 544 |
+
}
|
| 545 |
+
.lookup-banner p { color: var(--text-secondary); font-size: 13.5px; margin: 0; }
|
| 546 |
|
| 547 |
/* Ask a follow-up (the model brick) */
|
| 548 |
.ask-row { display: flex; gap: var(--s-2); }
|