cn0303 commited on
Commit
0a032c8
·
verified ·
1 Parent(s): 7303ce7

Interactive results, working priority, fine-tuning mode, honest custom + model lookup

Browse files
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
- return advise_real(p, spec_from_payload(p))
 
 
 
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", "custom"}
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) -> tuple[dict | None, bool]:
 
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
- # Fast-and-capable is the best answer: biggest model that runs great.
 
 
 
 
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 model that fits is simply the best one.
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": "Not covered yet",
396
- "headline": "Our catalogue doesn't cover this goal yet.",
397
- "detail": ("FitCheck only answers from verified model data, and nothing in the "
398
- "current catalogue serves this goal — so rather than guess, we'd "
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
- else:
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
- const holder = $("#lookup-result");
281
- if (!holder) return;
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
- const d = await res.json();
289
- if (d.error) {
290
- holder.innerHTML = `<div class="ans-card ans-error"><h3>Couldn't check that model</h3><p>${d.error}</p></div>`;
291
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  }
293
- const o = d.option || {};
294
- const v = VMAP[o.verdict] || VMAP.tight;
295
- holder.innerHTML = `
296
- <div class="lookup-card reveal" style="--status:${v.cls};--status-soft:${v.soft}">
297
- <div class="lookup-head">
298
- <span class="badge"><span class="dot"></span>${v.word}</span>
299
- <span class="live-tag">Live Hugging Face lookup</span>
300
- </div>
301
- <p class="lookup-explain">${d.explain || ""}</p>
 
 
 
 
 
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
- ${o.run && o.run.ollama ? `<div class="cmd-box" style="margin-top:10px"><div class="cmd-label">Run it<button class="copy-btn" data-code="${encodeURIComponent(o.run.ollama)}">Copy</button></div><pre><code>${o.run.ollama}</code></pre></div>` : ""}
307
- </div>`;
308
- holder.querySelectorAll(".copy-btn").forEach(b => b.addEventListener("click", () => {
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
- return `<div class="opt" style="--status:${ov.cls};--status-soft:${ov.soft}">
 
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">real models, biggest to smallest — names link to Hugging Face</span></div>
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, "&quot;")}" 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 offline</button>
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); }