cn0303 commited on
Commit
e34beb2
·
verified ·
1 Parent(s): baeaf14

Real catalogue: 83 verified models, buy-advice mode, live model lookup, license-aware cards

Browse files
Files changed (7) hide show
  1. app.py +41 -240
  2. catalogue.json +0 -0
  3. engine/hub_lookup.py +155 -0
  4. engine/real_advisor.py +538 -0
  5. static/app.js +143 -7
  6. static/index.html +19 -4
  7. static/style.css +29 -0
app.py CHANGED
@@ -1,20 +1,24 @@
1
  """
2
  FitCheck — what AI can your computer actually run?
3
 
4
- This file wires three bricks together behind a `gr.Server` (which IS a FastAPI
5
- app) that serves the hand-built frontend in static/:
6
-
7
- - /api/advise : the honest verdict. For LLM goals it runs the REAL
8
- deterministic engine (engine/, via ui_adapter). Vision /
9
- image-gen / audio / data goals which the engine doesn't
10
- model yet still use the input-aware placeholder below.
11
- - /gradio_api/call/ask : the model brick (model_brick.ask), a small local LLM
12
- that explains the engine's numbers in plain words. Exposed as
13
- @app.api so it runs on Gradio's queue and gets a ZeroGPU
14
- allocation; called from the browser via @gradio/client.
 
 
 
 
 
15
  """
16
 
17
- import re
18
  from pathlib import Path
19
 
20
  import gradio as gr
@@ -22,8 +26,8 @@ from fastapi.responses import FileResponse
22
  from fastapi.staticfiles import StaticFiles
23
  from pydantic import BaseModel
24
 
25
- from engine import CATALOGUE_VERSION
26
- from engine.ui_adapter import advise_for_ui, is_llm_usecase
27
  from model_brick import ask as model_ask
28
 
29
  STATIC = Path(__file__).parent / "static"
@@ -31,227 +35,6 @@ STATIC = Path(__file__).parent / "static"
31
  app = gr.Server()
32
 
33
 
34
- # ==========================================================================
35
- # PLACEHOLDER engine — vision / image-gen / audio / data goals only.
36
- # The real engine (engine/) covers LLM goals; these families aren't modelled
37
- # there yet, so they keep these plausible, conservative placeholder numbers.
38
- # ==========================================================================
39
-
40
- _COLORS = {"model": "#818CF8", "chat": "#A5F3C4", "work": "#868E9C"}
41
-
42
- # Which broad family each goal belongs to (drives models/tools/commands).
43
- _FAMILY = {
44
- "chat": "llm", "writing": "llm", "coding": "llm", "agents": "llm",
45
- "rag": "llm", "translate": "llm", "finetune": "llm",
46
- "detect": "vision", "segment": "vision", "pose": "vision",
47
- "classify": "vision", "depth": "vision", "ocr": "vision", "train-vision": "vision",
48
- "imagegen": "gen", "inpaint": "gen", "upscale": "gen", "videogen": "gen", "bgremove": "gen",
49
- "stt": "audio", "tts": "audio", "music": "audio",
50
- "embed": "data", "forecast": "data", "tabular": "data",
51
- "custom": "llm",
52
- }
53
-
54
- # variant = (name, description, memory-need-GB at sensible setting, setting label)
55
- _VARIANTS = {
56
- "llm": [
57
- ("Huge (70B)", "Top open quality. Serious hardware only.", 42, "4-bit"),
58
- ("Very large (32B)", "Near-premium. Wants a strong GPU.", 20, "4-bit"),
59
- ("Large (14B)", "Noticeably smarter and more reliable.", 9, "4-bit"),
60
- ("Medium (7-9B)", "Solid all-rounder: chat, coding, reasoning.", 5.5, "4-bit"),
61
- ("Small (3-4B)", "Surprisingly capable everyday assistant.", 2.5, "4-bit"),
62
- ("Tiny (~1B)", "Quick simple chat. Runs on almost anything.", 1.2, "4-bit"),
63
- ],
64
- "vision": [
65
- ("Extra-large model", "Highest accuracy, slowest.", 6.0, "full"),
66
- ("Large model", "Great accuracy for real work.", 3.5, "full"),
67
- ("Medium model", "Balanced accuracy and speed.", 2.0, "full"),
68
- ("Small model", "Fast, good for live video.", 1.2, "full"),
69
- ("Nano model", "Real-time even on weak hardware.", 0.6, "full"),
70
- ],
71
- "gen": [
72
- ("Flux.1 (dev)", "State-of-the-art image quality.", 18, "full"),
73
- ("Flux.1 (schnell)", "Near-top quality, much faster.", 12, "8-bit"),
74
- ("SDXL", "Excellent 1024px images.", 8, "full"),
75
- ("SDXL Turbo", "Fast SDXL, fewer steps.", 7, "full"),
76
- ("Stable Diffusion 1.5", "Light, fast, huge community.", 3.5, "full"),
77
- ],
78
- "audio": [
79
- ("Whisper large-v3", "Best transcription accuracy.", 5.0, "full"),
80
- ("Whisper medium", "Strong accuracy, lighter.", 3.0, "full"),
81
- ("Whisper small", "Good for clear audio, fast.", 1.5, "full"),
82
- ("Whisper base", "Quick drafts, light hardware.", 0.8, "full"),
83
- ],
84
- "data": [
85
- ("Large embedder", "Best search relevance.", 2.5, "full"),
86
- ("Base embedder", "Great balance for most search.", 1.2, "full"),
87
- ("Small embedder", "Fast, runs on anything.", 0.5, "full"),
88
- ],
89
- }
90
-
91
- _TOOLS = {
92
- "llm": [
93
- ("Ollama", "Type one line; it downloads and runs the model for you.", "Get it from ollama.com", "Easiest"),
94
- ("LM Studio", "A point-and-click app with a chat window, no commands.", "Download from lmstudio.ai", "Easy"),
95
- ("llama.cpp", "The lightweight engine under the hood. Runs GGUF files directly.", "Releases on GitHub", "Advanced"),
96
- ],
97
- "vision": [
98
- ("Ultralytics", "One pip install, then detect objects from a webcam or file.", "pip install ultralytics", "Easiest"),
99
- ("Roboflow", "Browser tools to label data and run models, little code.", "roboflow.com", "Easy"),
100
- ("PyTorch", "Full control for custom pipelines and training.", "pytorch.org", "Advanced"),
101
- ],
102
- "gen": [
103
- ("Fooocus", "Image generation that 'just works': one folder, double-click.", "Download from GitHub", "Easiest"),
104
- ("ComfyUI", "Powerful visual node editor for image/video pipelines.", "Download from GitHub", "Moderate"),
105
- ("Automatic1111", "The classic full-featured Stable Diffusion web UI.", "Download from GitHub", "Moderate"),
106
- ],
107
- "audio": [
108
- ("faster-whisper", "Fast, accurate transcription with a tiny install.", "pip install faster-whisper", "Easiest"),
109
- ("whisper.cpp", "Runs Whisper efficiently on CPU and small machines.", "Build from GitHub", "Advanced"),
110
- ],
111
- "data": [
112
- ("sentence-transformers", "Turn text into searchable vectors in a few lines.", "pip install sentence-transformers", "Easiest"),
113
- ("Chroma", "A simple local database to store and search those vectors.", "pip install chromadb", "Easy"),
114
- ],
115
- }
116
-
117
- _COMMANDS = {
118
- "llm": [
119
- ("Easy way (Ollama)", "ollama run llama3.1:8b"),
120
- ("Power way (llama.cpp)", "llama-server -hf bartowski/Meta-Llama-3.1-8B-Instruct-GGUF:Q4_K_M -c 4096"),
121
- ],
122
- "vision": [
123
- ("Install", "pip install ultralytics"),
124
- ("Detect from your webcam", "yolo predict model=yolo11n.pt source=0 show=True"),
125
- ],
126
- "gen": [
127
- ("Easiest (Fooocus)", "# Download Fooocus, unzip, double-click run.bat"),
128
- ("Power (ComfyUI)", "git clone https://github.com/comfyanonymous/ComfyUI && cd ComfyUI && python main.py"),
129
- ],
130
- "audio": [
131
- ("Install", "pip install faster-whisper"),
132
- ("Transcribe a file", 'python -c "from faster_whisper import WhisperModel; m=WhisperModel(\'small\'); print(list(m.transcribe(\'audio.mp3\')[0]))"'),
133
- ],
134
- "data": [
135
- ("Install", "pip install sentence-transformers"),
136
- ("Make searchable vectors", 'python -c "from sentence_transformers import SentenceTransformer as S; print(S(\'all-MiniLM-L6-v2\').encode(\'hello world\').shape)"'),
137
- ],
138
- }
139
-
140
-
141
- def _parse_vram(label: str) -> float:
142
- m = re.search(r"(\d+(?:\.\d+)?)\s*GB", label or "")
143
- return float(m.group(1)) if m else 0.0
144
-
145
-
146
- def _budgets(p: dict):
147
- ram = float(p.get("ram_gb") or 16)
148
- provider = p.get("provider", "none")
149
- computer = p.get("computer", "Windows laptop")
150
-
151
- if provider == "apple":
152
- fast = round(ram * 0.70, 1)
153
- return fast, fast, ram, True
154
-
155
- vram = p.get("vram_gb")
156
- if not vram:
157
- vram = _parse_vram(p.get("gpu", ""))
158
- vram = float(vram or 0)
159
-
160
- fast = round(vram * 0.85, 1)
161
- reserve = 1.0 if "Mini PC" in computer else (3.0 if "desktop" in computer.lower() or computer == "Mac" else 3.5)
162
- total = round(vram + max(0.0, ram - reserve) * 0.9, 1)
163
- return fast, vram, total, False
164
-
165
-
166
- def advise_mock(p: dict) -> dict:
167
- fam = _FAMILY.get(p.get("usecase", "chat"), "llm")
168
- variants = _VARIANTS[fam]
169
- fast, _vram, total, is_apple = _budgets(p)
170
- is_finetune = p.get("usecase") in ("finetune", "train-vision")
171
- factor = 2.4 if is_finetune else 1.0
172
-
173
- # Classify each variant.
174
- def verdict_for(need):
175
- need *= factor
176
- if fast >= 1 and need <= fast * 0.9:
177
- return "great", need
178
- if need <= total * 0.9:
179
- return "tight", need
180
- return "no", need
181
-
182
- options = []
183
- for name, desc, need, setting in variants:
184
- vd, real_need = verdict_for(need)
185
- feel = {"great": "Snappy on your graphics card",
186
- "tight": "Runs, but slower (uses normal memory)",
187
- "no": "Not enough memory"}[vd]
188
- options.append({
189
- "verdict": vd, "model": name, "desc": desc,
190
- "setting": setting, "memory": ("—" if vd == "no" else f"{round(real_need,1):g} GB"),
191
- "feel": feel,
192
- })
193
-
194
- # Headline = best that runs great, else best that's tight, else smallest.
195
- great = [o for o in options if o["verdict"] == "great"]
196
- tight = [o for o in options if o["verdict"] == "tight"]
197
- head = great[0] if great else (tight[0] if tight else options[-1])
198
- hv = head["verdict"]
199
- need_gb = float(re.search(r"[\d.]+", head["memory"]).group()) if head["memory"] != "—" else verdict_for(variants[-1][2])[1]
200
-
201
- if is_apple and hv == "great":
202
- where = "on your Mac"
203
- elif hv == "great" and fast >= 1:
204
- where = "on your graphics card"
205
- elif hv == "tight":
206
- where = "using your computer's memory"
207
- else:
208
- where = ""
209
- verdict_word = {"great": "Runs great", "tight": "Tight, but works", "no": "Won't fit"}[hv]
210
- if hv == "great":
211
- headline = f"Yes, you can run the {head['model']} {where}, today."
212
- elif hv == "tight":
213
- headline = f"Sort of. The {head['model']} will run {where}, with trade-offs."
214
- else:
215
- headline = "This goal is a stretch on this machine. Here's the honest picture."
216
-
217
- detail = (f"For this goal, the sweet spot is a <b>{head['model']}</b> model "
218
- f"at the <b>{head['setting']}</b> setting. {head['desc']} "
219
- f"It needs about <b>{round(need_gb,1):g} GB</b>, and you have roughly "
220
- f"<b>{fast:g} GB</b> fast / <b>{total:g} GB</b> total to work with.")
221
- note = ("Fine-tuning needs roughly 2 to 3 times the memory of just using a model. That's baked in above."
222
- if is_finetune else "")
223
-
224
- # Gauge
225
- scale = max(total, need_gb, 1) * 1.05
226
- gauge = {
227
- "need_gb": f"{round(need_gb,1):g} GB needed",
228
- "fast_gb": f"{fast:g} GB", "total_gb": f"{total:g} GB",
229
- "fill_pct": round(need_gb / scale * 100, 1),
230
- "mark_pct": round(fast / scale * 100, 1),
231
- "breakdown": [
232
- {"label": f"Model {round(need_gb*0.8,1):g} GB", "color": _COLORS["model"]},
233
- {"label": f"Working space {round(need_gb*0.2,1):g} GB", "color": _COLORS["work"]},
234
- ],
235
- }
236
-
237
- tools = [{"name": n, "what": w, "install": i, "tag": t} for n, w, i, t in _TOOLS[fam]]
238
- cmd_intro = ("These get you a running model in minutes. Pick the easy one or the power one; "
239
- "they do the same job.")
240
- commands = {"intro": cmd_intro,
241
- "items": [{"label": l, "code": c} for l, c in _COMMANDS[fam]]}
242
-
243
- return {
244
- "catalogue_version": CATALOGUE_VERSION,
245
- "verdict": hv, "verdict_word": verdict_word,
246
- "headline": headline, "detail": detail, "note": note,
247
- "gauge": gauge, "options": options, "tools": tools, "commands": commands,
248
- }
249
-
250
-
251
- # ==========================================================================
252
- # Connector endpoint + static frontend
253
- # ==========================================================================
254
-
255
  class AdviseIn(BaseModel):
256
  computer: str = "Windows laptop"
257
  ram_gb: float | None = 16
@@ -267,10 +50,28 @@ class AdviseIn(BaseModel):
267
  @app.post("/api/advise")
268
  def api_advise(payload: AdviseIn):
269
  p = payload.model_dump()
270
- # LLM goals -> the real, audited engine. Other families -> placeholder.
271
- if is_llm_usecase(p.get("usecase", "chat")):
272
- return advise_for_ui(p, CATALOGUE_VERSION)
273
- return advise_mock(p)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
 
275
 
276
  @app.api(name="ask")
@@ -279,7 +80,7 @@ def api_ask(question: str, facts: str = "") -> dict:
279
 
280
  Exposed at /gradio_api/call/ask (NOT a plain POST) so it runs through
281
  Gradio's queue and gets a ZeroGPU allocation. `facts` is the JSON string of
282
- the last /api/advise result. Returns {headline, why, next_step}.
283
  """
284
  return model_ask(question, facts)
285
 
 
1
  """
2
  FitCheck — what AI can your computer actually run?
3
 
4
+ Four bricks behind a `gr.Server` (which IS a FastAPI app) serving the
5
+ hand-built frontend in static/:
6
+
7
+ - /api/advise : the honest verdict. Deterministic engine (engine/) over
8
+ catalogue.json 83 real models with exact GGUF file sizes,
9
+ licenses, and links, refreshed from the Hugging Face API at
10
+ build time. The running app makes no network calls here.
11
+ - /api/minspecs : the reverse question "what machine do I need for X?"
12
+ Same engine, inverted over a hardware ladder. Offline.
13
+ - /api/lookup : OPTIONAL live check of any pasted HF repo id. Walks the
14
+ model-tree (finetune -> base) to a catalogue entry, or does
15
+ labelled raw math. The one endpoint that touches the
16
+ network, and the UI says so.
17
+ - /gradio_api/call/ask : the model brick (model_brick.ask) — a small local
18
+ LLM that explains the engine's numbers in plain words.
19
+ @app.api so it runs on Gradio's queue and gets ZeroGPU.
20
  """
21
 
 
22
  from pathlib import Path
23
 
24
  import gradio as gr
 
26
  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
 
33
  STATIC = Path(__file__).parent / "static"
 
35
  app = gr.Server()
36
 
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  class AdviseIn(BaseModel):
39
  computer: str = "Windows laptop"
40
  ram_gb: float | None = 16
 
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):
57
+ usecase: str = "chat"
58
+
59
+
60
+ @app.post("/api/minspecs")
61
+ def api_minspecs(payload: MinSpecsIn):
62
+ return min_specs(payload.usecase)
63
+
64
+
65
+ class LookupIn(AdviseIn):
66
+ repo: str = ""
67
+
68
+
69
+ @app.post("/api/lookup")
70
+ def api_lookup(payload: LookupIn):
71
+ """Live lookup of one HF repo id (labelled online in the UI)."""
72
+ from engine.hub_lookup import lookup
73
+ p = payload.model_dump()
74
+ return lookup(p.get("repo", ""), p, spec_from_payload(p))
75
 
76
 
77
  @app.api(name="ask")
 
80
 
81
  Exposed at /gradio_api/call/ask (NOT a plain POST) so it runs through
82
  Gradio's queue and gets a ZeroGPU allocation. `facts` is the JSON string of
83
+ the last /api/advise result. Returns {headline, why, next_step} or {error}.
84
  """
85
  return model_ask(question, facts)
86
 
catalogue.json ADDED
The diff for this file is too large to render. See raw diff
 
engine/hub_lookup.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Optional ONLINE lookup: "will this exact Hugging Face model run on my machine?"
3
+
4
+ Deterministic — no AI involved. Given any repo id (or model page URL), this:
5
+ 1. checks the local catalogue (offline) by repo id and aliases;
6
+ 2. otherwise makes ONE metadata call to the Hub, reads the model-tree tags
7
+ (base_model:finetune/adapter/quantized/merge), and walks up to 3 hops to
8
+ find a catalogue ancestor — "your finetune runs because its base runs";
9
+ 3. otherwise falls back to raw parameter-count math, clearly labelled.
10
+
11
+ This is the only part of FitCheck that touches the network at runtime, and the
12
+ UI labels it as a live lookup. The core advisor stays fully offline.
13
+ """
14
+
15
+ import re
16
+ from functools import lru_cache
17
+
18
+ from .hardware import HardwareSpec
19
+ from .real_advisor import (
20
+ USE_CASES, _SAFETY_FILL, _evaluate, _option_json, catalogue,
21
+ )
22
+
23
+ _RELATION = re.compile(r"^base_model:(finetune|adapter|quantized|merge):(.+)$")
24
+
25
+
26
+ @lru_cache(maxsize=1)
27
+ def _index() -> dict:
28
+ idx = {}
29
+ for e in catalogue()["entries"]:
30
+ idx[e["repo_id"].lower()] = e
31
+ for a in e.get("aliases", []):
32
+ idx[a.lower()] = e
33
+ return idx
34
+
35
+
36
+ def normalize_repo_id(text: str) -> str:
37
+ """Accept a bare repo id or any huggingface.co URL."""
38
+ text = (text or "").strip().rstrip("/")
39
+ m = re.search(r"huggingface\.co/([\w.-]+/[\w.-]+)", text)
40
+ if m:
41
+ return m.group(1)
42
+ return text
43
+
44
+
45
+ def _relations(info) -> list[tuple[str, str]]:
46
+ out = []
47
+ for t in (getattr(info, "tags", None) or []):
48
+ m = _RELATION.match(t)
49
+ if m:
50
+ out.append((m.group(1), m.group(2)))
51
+ if not out:
52
+ # cardData fallback only when tags carry no typed relation — the tag
53
+ # knows whether it's a finetune or a quantized copy; cardData doesn't.
54
+ card = getattr(info, "card_data", None)
55
+ if card:
56
+ base = card.get("base_model") if hasattr(card, "get") else getattr(card, "base_model", None)
57
+ if isinstance(base, str):
58
+ out.append(("finetune", base))
59
+ elif isinstance(base, list):
60
+ out.extend(("finetune", b) for b in base if isinstance(b, str))
61
+ return out
62
+
63
+
64
+ def lookup(repo_input: str, payload: dict, spec: HardwareSpec) -> dict:
65
+ """Returns {found, model, chain, verdict-ish fields} or {error}."""
66
+ repo_id = normalize_repo_id(repo_input)
67
+ if not re.fullmatch(r"[\w.-]+/[\w.-]+", repo_id):
68
+ return {"error": f"'{repo_input}' doesn't look like a Hugging Face repo id "
69
+ f"(expected something like author/model-name)."}
70
+
71
+ uc = USE_CASES.get(payload.get("usecase", "chat"), USE_CASES["chat"])
72
+ chain = [repo_id]
73
+
74
+ # 1) Offline: direct catalogue hit (also via aliases).
75
+ entry = _index().get(repo_id.lower())
76
+ via = None
77
+
78
+ # 2) Online: one metadata call + base-model walk.
79
+ info = None
80
+ if entry is None:
81
+ from huggingface_hub import HfApi
82
+ api = HfApi()
83
+ current = repo_id
84
+ try:
85
+ info = api.model_info(current, expand=["tags", "safetensors", "cardData",
86
+ "pipeline_tag", "gated"])
87
+ except Exception as exc: # noqa: BLE001 — surface the real failure
88
+ return {"error": f"Couldn't find '{repo_id}' on Hugging Face "
89
+ f"({type(exc).__name__}). Check the spelling?"}
90
+ hop_info = info
91
+ for _hop in range(3):
92
+ rels = _relations(hop_info)
93
+ if not rels:
94
+ break
95
+ # Prefer finetune/merge (same memory as base) over quantized.
96
+ rels.sort(key=lambda r: 0 if r[0] in ("finetune", "merge", "adapter") else 1)
97
+ rel, parent = rels[0]
98
+ chain.append(parent)
99
+ entry = _index().get(parent.lower())
100
+ if entry is not None:
101
+ via = {"relation": rel, "base": parent}
102
+ break
103
+ try:
104
+ hop_info = api.model_info(parent, expand=["tags", "cardData"])
105
+ except Exception: # noqa: BLE001 — chain ends here
106
+ break
107
+
108
+ if entry is not None:
109
+ r = _evaluate(entry, spec, uc)
110
+ opt = _option_json(r, spec)
111
+ explain = f"<b>{repo_id.split('/')[-1]}</b> "
112
+ if via:
113
+ word = {"finetune": "is fine-tuned from", "merge": "is merged from",
114
+ "adapter": "is an adapter on", "quantized": "is a compressed copy of"}[via["relation"]]
115
+ explain += (f"{word} <b>{entry['name']}</b> — if the base runs, this runs, "
116
+ f"with the same memory needs.")
117
+ if via["relation"] == "adapter":
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)
126
+ total = getattr(st, "total", None) if st else None
127
+ if not total:
128
+ return {"error": f"'{repo_id}' exists, but doesn't share its size or a known "
129
+ f"base model, so an honest estimate isn't possible."}
130
+ params_b = total / 1e9
131
+ weights_4bit = round(params_b * 4.85 / 8, 2) # effective 4-bit bits/weight
132
+ need = round(weights_4bit * 1.25 + 0.58, 2)
133
+ fast, total_b = spec.fast_budget_gb, spec.total_budget_gb
134
+ if spec.has_fast_path and need <= fast * _SAFETY_FILL:
135
+ verdict = "great"
136
+ elif need <= total_b * _SAFETY_FILL:
137
+ verdict = "tight"
138
+ else:
139
+ verdict = "no"
140
+ return {
141
+ "found": True, "match": "estimate", "chain": chain, "live": True,
142
+ "explain": (f"<b>{repo_id.split('/')[-1]}</b> isn't in our catalogue and lists no "
143
+ f"known base, so this is raw math from its {params_b:.1f}B parameters "
144
+ f"at a 4-bit setting — an estimate, not a measured figure."),
145
+ "option": {
146
+ "verdict": verdict, "model": repo_id.split("/")[-1],
147
+ "desc": f"{params_b:.1f}B parameters (from its files on Hugging Face)",
148
+ "setting": "Balanced (4-bit)",
149
+ "memory": "Too big" if verdict == "no" else f"{need:g} GB",
150
+ "feel": "", "url": f"https://huggingface.co/{repo_id}",
151
+ "license": "", "license_note": "", "gated": bool(getattr(info, "gated", False)),
152
+ "run": {}, "provenance": "estimated", "stale": False,
153
+ "params_b": round(params_b, 2), "active_params_b": None,
154
+ },
155
+ }
engine/real_advisor.py ADDED
@@ -0,0 +1,538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Engine v2: honest verdicts over REAL models from catalogue.json.
3
+
4
+ This replaces both the size-class advisor and the placeholder families. Every
5
+ option it returns is an actual model with a Hugging Face link, a license, and
6
+ memory figures with provenance:
7
+
8
+ - LLM / VLM weights = the EXACT GGUF file size in bytes from the Hub
9
+ (ground truth — better than any params-times-bits estimate).
10
+ - Chat memory (KV cache) = GQA-aware math from the model's real config
11
+ (layers, hidden, kv-heads) when available; a conservative parameter-count
12
+ heuristic when the repo is gated (labelled as estimated).
13
+ - Working space includes a +0.577 GB buffer — the 95% load-success margin
14
+ oobabooga fitted over 19,517 real measurements (gguf-vram-formula).
15
+ - Non-GGUF families (vision / image gen / audio / embeddings / data) carry a
16
+ single memory figure whose provenance is vendor-published, community-
17
+ reported, or estimated — and the UI says which.
18
+
19
+ The catalogue is baked into the repo at build time (refreshed by
20
+ scripts/refresh_catalogue.py), so the running app makes no network calls.
21
+ """
22
+
23
+ import json
24
+ from functools import lru_cache
25
+ from pathlib import Path
26
+
27
+ from .hardware import HardwareSpec
28
+ from .runtimes import pick_runtimes
29
+
30
+ _CATALOGUE_PATH = Path(__file__).resolve().parent.parent / "catalogue.json"
31
+
32
+ # We only fill a budget to this fraction — the rest is breathing room.
33
+ _SAFETY_FILL = 0.90
34
+ # oobabooga's fitted 95%-load-success buffer (GB), cited in the UI footnote.
35
+ _CONFIDENCE_BUFFER_GB = 0.577
36
+
37
+ _VERDICT_WORD = {"great": "Runs great", "tight": "Tight, but works", "no": "Won't fit"}
38
+ _C_MODEL = "#818CF8"
39
+ _C_WORK = "#868E9C"
40
+
41
+ # Quant ladder quality order (matches scripts/refresh_catalogue.py).
42
+ _QUANT_ORDER = ["Q8_0", "Q6_K", "Q5_K_M", "Q4_K_M", "IQ4_XS", "Q3_K_M", "Q2_K"]
43
+ _FOUR_BIT_RANK = _QUANT_ORDER.index("IQ4_XS") # >= this index quality = sub-4-bit
44
+ _COMPROMISE_QUANTS = ["Q4_K_M", "IQ4_XS", "Q3_K_M", "Q2_K"]
45
+
46
+
47
+ # --------------------------------------------------------------------------
48
+ # Use cases
49
+ # --------------------------------------------------------------------------
50
+
51
+ class UC:
52
+ def __init__(self, key, plain, family, ctx=4096, min_b=0.5, good_b=3.0,
53
+ factor=1.0, note=""):
54
+ self.key, self.plain_name, self.family = key, plain, family
55
+ self.context_tokens, self.min_b, self.good_b = ctx, min_b, good_b
56
+ self.overhead_factor, self.note = factor, note
57
+
58
+
59
+ USE_CASES = {u.key: u for u in [
60
+ UC("chat", "Just chatting / asking questions", "llm", 4096, 0.5, 3.0),
61
+ UC("writing", "Writing & summarising", "llm", 4096, 1.5, 7.0),
62
+ UC("coding", "Coding help", "llm", 8192, 3.0, 7.0,
63
+ note="Bigger models are much more reliable for code."),
64
+ UC("agents", "Agents & tool use", "llm", 8192, 7.0, 7.0, 1.15,
65
+ note="Needs steady instruction-following — go medium or larger."),
66
+ UC("rag", "Chat with your documents", "llm", 16384, 3.0, 7.0,
67
+ note="Long documents use extra memory for context — that's included here."),
68
+ UC("translate", "Translation", "llm", 4096, 1.5, 7.0),
69
+ UC("finetune", "Fine-tune an LLM (LoRA)", "llm", 2048, 3.0, 7.0, 2.2,
70
+ note="Training needs roughly 2-3x the memory of just chatting. That's baked into these numbers."),
71
+ UC("custom", "Your custom goal", "llm", 4096, 0.5, 7.0),
72
+ UC("vlm", "Chat about images & video", "vlm", 4096, 2.0, 4.0),
73
+ UC("detect", "Object detection", "vision"),
74
+ UC("segment", "Image segmentation", "vision"),
75
+ UC("pose", "Pose estimation (2D & 6-DoF)", "vision"),
76
+ UC("classify", "Image classification", "vision"),
77
+ UC("depth", "Depth estimation", "vision"),
78
+ UC("ocr", "Read text from images (OCR)", "vision"),
79
+ UC("train-vision", "Train a vision model", "vision", factor=3.0,
80
+ note="Training needs roughly 3x the memory of running the same model."),
81
+ UC("imagegen", "Generate images", "imagegen"),
82
+ UC("inpaint", "Edit / inpaint images", "imagegen"),
83
+ UC("upscale", "Upscale / restore images", "imagegen"),
84
+ UC("videogen", "Generate video", "imagegen"),
85
+ UC("bgremove", "Remove backgrounds", "imagegen"),
86
+ UC("stt", "Speech to text", "audio"),
87
+ UC("tts", "Text to speech / voice", "audio"),
88
+ UC("music", "Generate music", "audio"),
89
+ UC("embed", "Semantic search / embeddings", "embed"),
90
+ UC("forecast", "Time-series forecasting", "data"),
91
+ UC("tabular", "Predict from spreadsheets", "data"),
92
+ ]}
93
+
94
+ # Use cases answered by the whole LLM family (entries don't list these).
95
+ _TEXT_UCS = {"chat", "writing", "coding", "agents", "rag", "translate",
96
+ "finetune", "custom"}
97
+
98
+ _TOOLS = {
99
+ "llm": [
100
+ {"name": "Ollama", "what": "Type one line; it downloads and runs the model for you.",
101
+ "install": "Get it from ollama.com", "tag": "Easiest"},
102
+ {"name": "LM Studio", "what": "A point-and-click app with a chat window, no commands.",
103
+ "install": "Download from lmstudio.ai", "tag": "Easy"},
104
+ {"name": "llama.cpp", "what": "The lightweight engine under the hood. Runs GGUF files directly.",
105
+ "install": "Releases on GitHub", "tag": "Advanced"},
106
+ ],
107
+ "vision": [
108
+ {"name": "Ultralytics", "what": "One pip install, then detect objects from a webcam or file.",
109
+ "install": "pip install ultralytics", "tag": "Easiest"},
110
+ {"name": "PyTorch", "what": "Full control for custom pipelines and training.",
111
+ "install": "pytorch.org", "tag": "Advanced"},
112
+ ],
113
+ "imagegen": [
114
+ {"name": "ComfyUI", "what": "Powerful visual node editor for image/video pipelines.",
115
+ "install": "Download from GitHub", "tag": "Moderate"},
116
+ {"name": "diffusers", "what": "Hugging Face's Python library for generation pipelines.",
117
+ "install": "pip install diffusers", "tag": "Moderate"},
118
+ {"name": "Fooocus", "what": "Image generation that 'just works': one folder, double-click.",
119
+ "install": "Download from GitHub", "tag": "Easiest"},
120
+ ],
121
+ "audio": [
122
+ {"name": "faster-whisper", "what": "Fast, accurate transcription with a tiny install.",
123
+ "install": "pip install faster-whisper", "tag": "Easiest"},
124
+ {"name": "whisper.cpp", "what": "Runs Whisper efficiently on CPU and small machines.",
125
+ "install": "Build from GitHub", "tag": "Advanced"},
126
+ ],
127
+ "embed": [
128
+ {"name": "sentence-transformers", "what": "Turn text into searchable vectors in a few lines.",
129
+ "install": "pip install sentence-transformers", "tag": "Easiest"},
130
+ {"name": "Chroma", "what": "A simple local database to store and search those vectors.",
131
+ "install": "pip install chromadb", "tag": "Easy"},
132
+ ],
133
+ "data": [
134
+ {"name": "Python + pip", "what": "These models ship as small Python packages.",
135
+ "install": "pip install (see the model card)", "tag": "Easiest"},
136
+ ],
137
+ }
138
+ _TOOLS["vlm"] = _TOOLS["llm"]
139
+
140
+
141
+ # --------------------------------------------------------------------------
142
+ # Catalogue access
143
+ # --------------------------------------------------------------------------
144
+
145
+ @lru_cache(maxsize=1)
146
+ def catalogue() -> dict:
147
+ return json.loads(_CATALOGUE_PATH.read_text(encoding="utf-8"))
148
+
149
+
150
+ @lru_cache(maxsize=1)
151
+ def _by_use_case() -> dict:
152
+ out: dict[str, list[dict]] = {}
153
+ for e in catalogue()["entries"]:
154
+ if e["family"] in ("llm", "vlm"):
155
+ ucs = list(_TEXT_UCS) if e["family"] == "llm" else ["vlm"]
156
+ else:
157
+ ucs = e.get("use_cases", [])
158
+ for uc in ucs:
159
+ out.setdefault(uc, []).append(e)
160
+ for uc in out:
161
+ out[uc].sort(key=lambda e: e.get("params_b", 0), reverse=True)
162
+ return out
163
+
164
+
165
+ def catalogue_date() -> str:
166
+ return catalogue().get("generated_at", "")[:10]
167
+
168
+
169
+ # --------------------------------------------------------------------------
170
+ # Memory math
171
+ # --------------------------------------------------------------------------
172
+
173
+ # Fallback architecture shapes by parameter count (conservative typicals),
174
+ # used only when a gated repo hides its config.json.
175
+ _ARCH_FALLBACK = [
176
+ (1.5, 24, 2048), (4.5, 28, 3072), (9.0, 32, 4096),
177
+ (16.0, 40, 5120), (40.0, 48, 6656), (1e9, 80, 8192),
178
+ ]
179
+
180
+
181
+ def _kv_gb(entry: dict, ctx: int) -> tuple[float, bool]:
182
+ """KV-cache GB for `ctx` tokens. Returns (gb, exact?)."""
183
+ ctx = min(ctx, entry.get("context_len") or ctx)
184
+ arch = entry.get("arch")
185
+ if arch:
186
+ per_layer = arch["hidden"] * arch["n_kv_heads"] / arch["n_heads"]
187
+ return 2 * arch["n_layers"] * per_layer * ctx * 2 / 1e9, True
188
+ params = entry.get("params_b", 4.0)
189
+ for cap, layers, hidden in _ARCH_FALLBACK:
190
+ if params <= cap:
191
+ return 2 * layers * hidden * ctx * 2 * 0.30 / 1e9, False
192
+ return 1.0, False
193
+
194
+
195
+ def _overhead_gb(weights: float, factor: float) -> float:
196
+ if factor >= 2.0: # training: optimizer state + activations dominate
197
+ return round(_CONFIDENCE_BUFFER_GB + weights * (factor - 1.0), 2)
198
+ return round((_CONFIDENCE_BUFFER_GB + 0.08 * weights) * factor, 2)
199
+
200
+
201
+ def _estimate(entry: dict, quant: dict, ctx: int, factor: float) -> dict:
202
+ weights = quant["file_gb"]
203
+ kv, kv_exact = _kv_gb(entry, ctx)
204
+ kv = round(kv, 2)
205
+ overhead = _overhead_gb(weights, factor)
206
+ return {"weights": weights, "kv": kv, "overhead": overhead,
207
+ "total": round(weights + kv + overhead, 2), "kv_exact": kv_exact}
208
+
209
+
210
+ # --------------------------------------------------------------------------
211
+ # Per-entry evaluation
212
+ # --------------------------------------------------------------------------
213
+
214
+ def _quant_rank(key: str) -> int:
215
+ return _QUANT_ORDER.index(key) if key in _QUANT_ORDER else len(_QUANT_ORDER)
216
+
217
+
218
+ def _feel(entry: dict, verdict: str, spec: HardwareSpec) -> str:
219
+ if verdict == "no":
220
+ return "—"
221
+ active = entry.get("active_params_b") or entry.get("params_b", 4)
222
+ if verdict == "tight":
223
+ if entry.get("active_params_b"):
224
+ return f"Usable even part-offloaded (only {entry['active_params_b']:g}B active per word)"
225
+ return "Slow — usable for short tasks, not snappy chat"
226
+ if active <= 4:
227
+ return "Fast — replies feel instant"
228
+ if active <= 14:
229
+ return "Comfortable — quick enough for live chat"
230
+ return "Steady — fine, just not instant on big answers"
231
+
232
+
233
+ def _eval_gguf(entry: dict, spec: HardwareSpec, uc: UC) -> dict:
234
+ """Verdict for an LLM/VLM entry with a real quant ladder."""
235
+ fast, total = spec.fast_budget_gb, spec.total_budget_gb
236
+ quants = sorted(entry.get("quants", []), key=lambda q: _quant_rank(q["key"]))
237
+ ctx, factor = uc.context_tokens, uc.overhead_factor
238
+
239
+ # Fast path: best quality quant >= 4-bit that fits the GPU budget.
240
+ if spec.has_fast_path:
241
+ for q in quants:
242
+ if _quant_rank(q["key"]) > _FOUR_BIT_RANK:
243
+ break # don't call a sub-4-bit squeeze "runs great"
244
+ est = _estimate(entry, q, ctx, factor)
245
+ if est["total"] <= fast * _SAFETY_FILL:
246
+ return {"verdict": "great", "quant": q, "est": est}
247
+
248
+ # Compromise: spill into ordinary RAM, shrinking quality only if needed.
249
+ for qkey in _COMPROMISE_QUANTS:
250
+ q = next((x for x in quants if x["key"] == qkey), None)
251
+ if not q:
252
+ continue
253
+ est = _estimate(entry, q, ctx, factor)
254
+ if est["total"] <= total * _SAFETY_FILL:
255
+ return {"verdict": "tight", "quant": q, "est": est}
256
+
257
+ q = quants[-1] if quants else {"key": "Q4_K_M", "plain": "Balanced (4-bit)",
258
+ "file_gb": entry.get("params_b", 4) * 0.6}
259
+ return {"verdict": "no", "quant": q, "est": _estimate(entry, q, ctx, factor)}
260
+
261
+
262
+ def _eval_flat(entry: dict, spec: HardwareSpec, uc: UC) -> dict:
263
+ """Verdict for a non-GGUF entry with one memory figure."""
264
+ need = round(entry.get("mem_gb", 4.0) * uc.overhead_factor, 2)
265
+ fast, total = spec.fast_budget_gb, spec.total_budget_gb
266
+ est = {"weights": need, "kv": 0.0, "overhead": 0.0, "total": need, "kv_exact": False}
267
+ setting = {"key": "full", "plain": "Full model", "file_gb": need}
268
+ if spec.has_fast_path and need <= fast * _SAFETY_FILL:
269
+ return {"verdict": "great", "quant": setting, "est": est}
270
+ # Image/video generation without a GPU is minutes-per-image: say so.
271
+ if entry["family"] == "imagegen" and not spec.has_fast_path and need > 4:
272
+ return {"verdict": "no", "quant": setting, "est": est}
273
+ if need <= total * _SAFETY_FILL:
274
+ return {"verdict": "tight", "quant": setting, "est": est}
275
+ return {"verdict": "no", "quant": setting, "est": est}
276
+
277
+
278
+ def _evaluate(entry: dict, spec: HardwareSpec, uc: UC) -> dict:
279
+ if entry.get("quants"):
280
+ r = _eval_gguf(entry, spec, uc)
281
+ else:
282
+ r = _eval_flat(entry, spec, uc)
283
+ r["entry"] = entry
284
+ return r
285
+
286
+
287
+ # --------------------------------------------------------------------------
288
+ # Advise: full UI-shaped result
289
+ # --------------------------------------------------------------------------
290
+
291
+ def _option_json(r: dict, spec: HardwareSpec) -> dict:
292
+ e, v = r["entry"], r["verdict"]
293
+ feel = _feel(e, v, spec)
294
+ if not e.get("quants") and v == "tight" and not spec.has_fast_path:
295
+ feel = "Runs on the processor — slow but workable"
296
+ lic_label = e.get("license", "")
297
+ return {
298
+ "verdict": v,
299
+ "model": e["name"],
300
+ "desc": e.get("good_for", ""),
301
+ "setting": r["quant"].get("plain", "Full model"),
302
+ "memory": "Too big" if v == "no" else f"{r['est']['total']:g} GB",
303
+ "feel": feel,
304
+ "params_b": e.get("params_b"),
305
+ "active_params_b": e.get("active_params_b"),
306
+ "url": (e.get("links") or {}).get("hf") or (e.get("links") or {}).get("home", ""),
307
+ "license": lic_label,
308
+ "license_note": e.get("license_note", ""),
309
+ "gated": e.get("gated", False),
310
+ "run": e.get("run", {}),
311
+ "provenance": e.get("provenance", "estimated"),
312
+ "stale": e.get("stale", False),
313
+ }
314
+
315
+
316
+ def _pick_headline(results: list[dict], uc: UC) -> tuple[dict | None, bool]:
317
+ great = [r for r in results if r["verdict"] == "great"]
318
+ tight = [r for r in results if r["verdict"] == "tight"]
319
+
320
+ def params(r):
321
+ return r["entry"].get("params_b", 0)
322
+
323
+ great_ok = [r for r in great if params(r) >= uc.min_b]
324
+ tight_ok = [r for r in tight if params(r) >= uc.min_b]
325
+ if great_ok:
326
+ # Fast-and-capable is the best answer: biggest model that runs great.
327
+ return max(great_ok, key=params), True
328
+ if tight_ok:
329
+ # Compromise: close to the ideal size, not needlessly oversized-and-slow.
330
+ below = [r for r in tight_ok if params(r) <= uc.good_b * 1.5]
331
+ return (max(below, key=params) if below else min(tight_ok, key=params)), True
332
+ if great:
333
+ return max(great, key=params), False
334
+ if tight:
335
+ return min(tight, key=params), False
336
+ return None, False
337
+
338
+
339
+ def _provenance_line(headline: dict | None) -> str:
340
+ if not headline:
341
+ return ""
342
+ e = headline["entry"]
343
+ prov = e.get("provenance", "estimated")
344
+ if prov == "filesize":
345
+ line = ("Model size is the exact file size on Hugging Face. Chat memory and "
346
+ "working space are conservative estimates with a 0.58 GB safety buffer "
347
+ "(the 95% load-success margin fitted from ~19,500 real measurements).")
348
+ if not headline["est"].get("kv_exact"):
349
+ line += " This repo hides its exact shape, so chat memory is estimated from its size."
350
+ return line
351
+ if prov == "vendor":
352
+ return "The memory figure is the maker's own published number."
353
+ if prov == "community":
354
+ return "The memory figure is community-reported, not vendor-published — treat it as a good estimate."
355
+ return "The memory figure is estimated from the model's size — conservative, not measured."
356
+
357
+
358
+ def advise_real(payload: dict, spec: HardwareSpec) -> dict:
359
+ uc = USE_CASES.get(payload.get("usecase", "chat"), USE_CASES["chat"])
360
+ candidates = _by_use_case().get(uc.key, [])
361
+ results = [_evaluate(e, spec, uc) for e in candidates]
362
+
363
+ fast, total = spec.fast_budget_gb, spec.total_budget_gb
364
+ headline, meets_goal = _pick_headline(results, uc)
365
+
366
+ options = [_option_json(r, spec) for r in results]
367
+
368
+ if headline:
369
+ e, est, q = headline["entry"], headline["est"], headline["quant"]
370
+ hv = headline["verdict"]
371
+ need = est["total"]
372
+ where = ("on your Mac" if spec.is_apple_silicon and hv == "great" else
373
+ "on your graphics card" if hv == "great" and spec.has_fast_path else
374
+ "using your computer's memory" if hv == "tight" else "")
375
+ if hv == "great":
376
+ head_text = f"Yes, you can run {e['name']} {where}, today."
377
+ else:
378
+ head_text = f"Sort of. {e['name']} will run {where}, with trade-offs."
379
+ if e.get("quants"):
380
+ detail = (
381
+ f"For this goal, the honest pick is <b>{e['name']}</b> at the "
382
+ f"<b>{q.get('plain', q['key'])}</b> setting. {e.get('good_for','')} "
383
+ f"It needs about <b>{need:g} GB</b> "
384
+ f"(the model file is {est['weights']:g} GB — exact size on Hugging Face — "
385
+ f"plus {est['kv']:g} GB chat memory and {est['overhead']:g} GB working space), "
386
+ f"and you have roughly <b>{fast:g} GB</b> fast / <b>{total:g} GB</b> total."
387
+ )
388
+ else:
389
+ detail = (
390
+ f"For this goal, the honest pick is <b>{e['name']}</b>. "
391
+ f"{e.get('good_for','')} It needs about <b>{need:g} GB</b>, and you have "
392
+ f"roughly <b>{fast:g} GB</b> fast / <b>{total:g} GB</b> total."
393
+ )
394
+ model_part, work_part = est["weights"], round(need - est["weights"], 2)
395
+ else:
396
+ hv = "no"
397
+ smallest = min(results, key=lambda r: r["est"]["total"], default=None)
398
+ need = smallest["est"]["total"] if smallest else 1.0
399
+ head_text = "This goal is a stretch on this machine. Here's the honest picture."
400
+ detail = (
401
+ f"Even the lightest option here needs about <b>{need:g} GB</b>, but this "
402
+ f"machine can offer only about <b>{total:g} GB</b> once the operating system "
403
+ f"has its share. That's not a failure — small computers just have small "
404
+ f"budgets. Adding memory, or a free cloud notebook, would open this up."
405
+ )
406
+ model_part, work_part = round(need * 0.8, 2), round(need * 0.2, 2)
407
+
408
+ note_bits = []
409
+ if headline and not meets_goal:
410
+ note_bits.append(
411
+ f"This is the best this machine can do, but it's on the small side for "
412
+ f"{uc.plain_name.lower()} — treat results as 'okay', not great.")
413
+ if uc.note:
414
+ note_bits.append(uc.note)
415
+ if headline and headline["entry"].get("mem_note"):
416
+ note_bits.append(headline["entry"]["mem_note"])
417
+ if headline and headline["entry"].get("license_note"):
418
+ note_bits.append(headline["entry"]["license_note"])
419
+ if headline and headline["entry"].get("gated"):
420
+ note_bits.append("This model is gated: accept its terms on Hugging Face once before downloading.")
421
+
422
+ scale = max(total, need, 1) * 1.05
423
+ gauge = {
424
+ "need_gb": f"{need:g} GB needed",
425
+ "fast_gb": f"{fast:g} GB", "total_gb": f"{total:g} GB",
426
+ "fill_pct": round(min(need / scale, 1.0) * 100, 1),
427
+ "mark_pct": round(min(fast / scale, 1.0) * 100, 1),
428
+ "breakdown": [
429
+ {"label": f"Model {model_part:g} GB", "color": _C_MODEL},
430
+ {"label": f"Chat memory + working space {work_part:g} GB", "color": _C_WORK},
431
+ ],
432
+ }
433
+
434
+ if uc.family == "llm":
435
+ tools = [{"name": r.name, "what": r.plain_what, "install": r.install_hint,
436
+ "tag": r.difficulty} for r in pick_runtimes(spec)]
437
+ else:
438
+ tools = _TOOLS.get(uc.family, [])
439
+
440
+ commands = {"intro": "These get you running in minutes — real commands for the exact pick above.",
441
+ "items": []}
442
+ if headline:
443
+ run = headline["entry"].get("run", {})
444
+ if run.get("ollama"):
445
+ commands["items"].append({"label": "Easy way (Ollama)", "code": run["ollama"]})
446
+ if run.get("llamacpp"):
447
+ commands["items"].append({"label": "Power way (llama.cpp)", "code": run["llamacpp"]})
448
+ if run.get("pip"):
449
+ commands["items"].append({"label": "Install", "code": run["pip"]})
450
+
451
+ return {
452
+ "catalogue_version": catalogue_date(),
453
+ "verdict": hv,
454
+ "verdict_word": _VERDICT_WORD[hv],
455
+ "headline": head_text,
456
+ "detail": detail,
457
+ "note": " ".join(note_bits),
458
+ "gauge": gauge,
459
+ "options": options,
460
+ "tools": tools,
461
+ "commands": commands,
462
+ "provenance": _provenance_line(headline),
463
+ "meets_goal": meets_goal,
464
+ "use_case": uc.plain_name,
465
+ }
466
+
467
+
468
+ # --------------------------------------------------------------------------
469
+ # Reverse mode: "what machine do I need for X?"
470
+ # --------------------------------------------------------------------------
471
+
472
+ # Ladders are cheap -> expensive. Budget hints are rough 2026 street prices for
473
+ # a whole sensible build, shown as guidance, not gospel.
474
+ _PC_LADDER = [
475
+ ("Any old laptop (8 GB RAM, no GPU)", dict(ram_gb=8, vram_gb=0, vendor="none"), "what you may already own"),
476
+ ("16 GB RAM laptop, no GPU", dict(ram_gb=16, vram_gb=0, vendor="none"), "~$500"),
477
+ ("16 GB RAM + RTX 4060 (8 GB)", dict(ram_gb=16, vram_gb=8, vendor="nvidia"), "~$800"),
478
+ ("16 GB RAM + RTX 3060 (12 GB)", dict(ram_gb=16, vram_gb=12, vendor="nvidia"), "~$900"),
479
+ ("32 GB RAM + RTX 5070 (12 GB)", dict(ram_gb=32, vram_gb=12, vendor="nvidia"), "~$1,300"),
480
+ ("32 GB RAM + RTX 5070 Ti (16 GB)", dict(ram_gb=32, vram_gb=16, vendor="nvidia"), "~$1,600"),
481
+ ("32 GB RAM + RTX 4090 (24 GB)", dict(ram_gb=32, vram_gb=24, vendor="nvidia"), "~$2,500"),
482
+ ("64 GB RAM + RTX 5090 (32 GB)", dict(ram_gb=64, vram_gb=32, vendor="nvidia"), "~$3,500+"),
483
+ ]
484
+ _MAC_LADDER = [
485
+ ("Mac with 16 GB unified memory", dict(ram_gb=16), "~$1,000"),
486
+ ("Mac with 24 GB unified memory", dict(ram_gb=24), "~$1,400"),
487
+ ("Mac with 32 GB unified memory", dict(ram_gb=32), "~$1,800"),
488
+ ("Mac with 64 GB unified memory", dict(ram_gb=64), "~$2,800"),
489
+ ("Mac with 128 GB unified memory", dict(ram_gb=128), "~$4,500+"),
490
+ ]
491
+
492
+
493
+ def _spec_for_tier(kind: str, hw: dict) -> HardwareSpec:
494
+ if kind == "mac":
495
+ return HardwareSpec(os="macos", ram_gb=hw["ram_gb"], gpu_vendor="apple",
496
+ is_apple_silicon=True, form_factor="mac")
497
+ return HardwareSpec(os="windows", ram_gb=hw["ram_gb"],
498
+ gpu_vendor=hw.get("vendor", "none"),
499
+ vram_gb=hw.get("vram_gb", 0.0), form_factor="desktop")
500
+
501
+
502
+ def min_specs(usecase: str) -> dict:
503
+ """For a goal: cheapest tier that works at all, the comfortable tier, and
504
+ what each would actually run. Pure engine inversion — fully offline."""
505
+ uc = USE_CASES.get(usecase, USE_CASES["chat"])
506
+
507
+ def walk(kind, ladder):
508
+ minimum = comfortable = None
509
+ for label, hw, price in ladder:
510
+ spec = _spec_for_tier(kind, hw)
511
+ res = advise_real({"usecase": uc.key}, spec)
512
+ great = [o for o in res["options"] if o["verdict"] == "great"]
513
+ fits = [o for o in res["options"] if o["memory"] != "Too big"]
514
+ best = (max(great, key=lambda o: o.get("params_b") or 0) if great
515
+ else (max(fits, key=lambda o: o.get("params_b") or 0) if fits else None))
516
+ tier = {"label": label, "price": price,
517
+ "runs": best["model"] if best else "",
518
+ "verdict": res["verdict"]}
519
+ if minimum is None and res["verdict"] in ("great", "tight") and res["meets_goal"]:
520
+ minimum = tier
521
+ if comfortable is None and res["verdict"] == "great" and res["meets_goal"]:
522
+ comfortable = tier
523
+ if minimum and comfortable:
524
+ break
525
+ return minimum, comfortable
526
+
527
+ pc_min, pc_comfy = walk("pc", _PC_LADDER)
528
+ mac_min, mac_comfy = walk("mac", _MAC_LADDER)
529
+ return {
530
+ "use_case": uc.plain_name,
531
+ "catalogue_version": catalogue_date(),
532
+ "note": uc.note,
533
+ "pc": {"minimum": pc_min, "comfortable": pc_comfy},
534
+ "mac": {"minimum": mac_min, "comfortable": mac_comfy},
535
+ "disclaimer": ("Price hints are rough 2026 street prices for a sensible whole "
536
+ "build — they vary a lot by region and second-hand luck. The "
537
+ "memory math is the same conservative engine as the main check."),
538
+ }
static/app.js CHANGED
@@ -24,6 +24,7 @@ const USE_CASES = [
24
  { id: "translate", icon: "translate", label: "Translation" },
25
  ]},
26
  { icon: "cat-vision", name: "See & understand images", items: [
 
27
  { id: "detect", icon: "detect", label: "Object detection (YOLO)" },
28
  { id: "segment", icon: "segment", label: "Image segmentation" },
29
  { id: "pose", icon: "pose", label: "Pose / 6-DoF (FoundationPose)" },
@@ -73,9 +74,47 @@ const GPUS = {
73
  };
74
 
75
  const $ = (s) => document.querySelector(s);
76
- const state = { computer: "Windows laptop", provider: "none", priority: "balanced", usecase: "chat", checked: false };
77
  let lastAdvice = null; // the most recent /api/advise result — facts the model explains
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  // ---- Build the use-case picker -------------------------------------------
80
  function buildPicker() {
81
  const wrap = $("#usecase-picker");
@@ -164,6 +203,7 @@ function gather() {
164
  usecase: state.usecase,
165
  custom: $("#custom-uc").value.trim(),
166
  priority: state.priority,
 
167
  };
168
  }
169
 
@@ -176,19 +216,100 @@ function maybeLiveUpdate() {
176
 
177
  async function check() {
178
  state.checked = true;
 
179
  try {
 
 
 
 
 
 
 
 
180
  const res = await fetch("/api/advise", {
181
  method: "POST", headers: { "Content-Type": "application/json" },
182
- body: JSON.stringify(gather()),
183
  });
184
  render(await res.json());
 
185
  } catch (e) {
186
  $("#results").innerHTML = `<div class="empty-state"><div class="big"><span class="ic" data-ic="monitor"></span></div>
187
- <p>Couldn't reach the advisor. Is the server running?</p></div>`;
188
  hydrate($("#results"));
189
  }
190
  }
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  // ---- Render results -------------------------------------------------------
193
  const VMAP = {
194
  great: { cls: "var(--ok)", soft: "var(--ok-soft)", word: "Runs great", em: "✓" },
@@ -202,12 +323,23 @@ function render(d) {
202
  const g = d.gauge || {};
203
  $("#cat-version").textContent = d.catalogue_version || "—";
204
 
 
 
 
 
 
 
 
 
 
205
  const opts = (d.options || []).map(o => {
206
  const ov = VMAP[o.verdict] || VMAP.tight;
 
 
207
  return `<div class="opt" style="--status:${ov.cls};--status-soft:${ov.soft}">
208
  <div class="vdot">${ov.em}</div>
209
- <div><div class="name">${o.model}</div><div class="desc">${o.desc}</div></div>
210
- <div class="meta"><b>${o.memory}</b><div class="feel">${o.setting} · ${o.feel}</div></div>
211
  </div>`;
212
  }).join("");
213
 
@@ -227,6 +359,7 @@ function render(d) {
227
 
228
  $("#results").innerHTML = `
229
  <div class="reveal">
 
230
  <div class="verdict" style="--status:${v.cls};--status-soft:${v.soft}">
231
  <span class="badge"><span class="dot"></span>${d.verdict_word || v.word}</span>
232
  <h2>${d.headline || ""}</h2>
@@ -246,9 +379,10 @@ function render(d) {
246
  <span class="item marker">Fast: ${g.fast_gb}</span>
247
  <span class="item marker">Total: ${g.total_gb}</span>
248
  </div>
 
249
  </div>` : ""}
250
 
251
- ${opts ? `<div class="section-title">What you can run <span class="sub">honestly, biggest to smallest</span></div>
252
  <div class="opt-grid">${opts}</div>` : ""}
253
 
254
  ${tools ? `<div class="section-title">How to actually run it</div>
@@ -364,14 +498,16 @@ async function callAskRaw(question, facts) {
364
  function init() {
365
  hydrate(document);
366
  buildPicker();
 
367
  wireSegmented("#computer-seg", "computer", () => { syncProviderForComputer(); $("#find-specs-body").innerHTML = findSpecsText(); });
368
  wireSegmented("#provider-seg", "provider", fillGpu);
369
  wireSegmented("#priority-seg", "priority");
370
- ["#ram","#gpu","#vram","#custom-uc"].forEach(s => $(s).addEventListener("change", maybeLiveUpdate));
371
  $("#paste").addEventListener("input", maybeLiveUpdate);
372
  $("#check-btn").addEventListener("click", check);
373
  fillGpu();
374
  $("#find-specs-body").innerHTML = findSpecsText();
 
375
  // Pre-filled share/preview links: /?go renders results immediately.
376
  if (new URLSearchParams(location.search).has("go")) check();
377
  }
 
24
  { id: "translate", icon: "translate", label: "Translation" },
25
  ]},
26
  { icon: "cat-vision", name: "See & understand images", items: [
27
+ { id: "vlm", icon: "classify", label: "Chat about images (VLM)" },
28
  { id: "detect", icon: "detect", label: "Object detection (YOLO)" },
29
  { id: "segment", icon: "segment", label: "Image segmentation" },
30
  { id: "pose", icon: "pose", label: "Pose / 6-DoF (FoundationPose)" },
 
74
  };
75
 
76
  const $ = (s) => document.querySelector(s);
77
+ const state = { mode: "have", computer: "Windows laptop", provider: "none", priority: "balanced", usecase: "chat", checked: false };
78
  let lastAdvice = null; // the most recent /api/advise result — facts the model explains
79
 
80
+ // ---- Buy-vs-check mode -----------------------------------------------------
81
+ function applyMode() {
82
+ const buy = state.mode === "buy";
83
+ ["#machine-step", "#priority-step", "#find-specs"].forEach(s => {
84
+ const el = $(s); if (el) el.style.display = buy ? "none" : "";
85
+ });
86
+ const repo = $("#repo-field"); if (repo) repo.style.display = buy ? "none" : "";
87
+ $("#check-btn").innerHTML = (buy ? "What should I get? " : "Check my setup ")
88
+ + '<span class="ic" data-ic="arrow"></span>';
89
+ hydrate($("#check-btn"));
90
+ }
91
+
92
+ // ---- Best-effort hardware hints from the browser (honest: vendor + floor) --
93
+ async function detectHardware() {
94
+ const bits = [];
95
+ try {
96
+ if (navigator.gpu) {
97
+ const ad = await navigator.gpu.requestAdapter();
98
+ const v = ((ad && ad.info && (ad.info.vendor || ad.info.description)) || "").toLowerCase();
99
+ for (const vendor of ["nvidia", "amd", "apple", "intel"]) {
100
+ if (v.includes(vendor)) {
101
+ state.provider = vendor;
102
+ setActive("#provider-seg", vendor);
103
+ fillGpu();
104
+ bits.push(`a ${vendor.toUpperCase().replace("APPLE","Apple")} GPU`);
105
+ break;
106
+ }
107
+ }
108
+ }
109
+ } catch (e) { /* detection is best-effort only */ }
110
+ if (navigator.deviceMemory) bits.push(`at least ${navigator.deviceMemory} GB of RAM`);
111
+ if (bits.length) {
112
+ const h = $("#detect-hint");
113
+ h.style.display = "";
114
+ h.textContent = `Your browser reports ${bits.join(" and ")} — browsers can't see exact specs, so please confirm below.`;
115
+ }
116
+ }
117
+
118
  // ---- Build the use-case picker -------------------------------------------
119
  function buildPicker() {
120
  const wrap = $("#usecase-picker");
 
203
  usecase: state.usecase,
204
  custom: $("#custom-uc").value.trim(),
205
  priority: state.priority,
206
+ repo: $("#repo-check") ? $("#repo-check").value.trim() : "",
207
  };
208
  }
209
 
 
216
 
217
  async function check() {
218
  state.checked = true;
219
+ const payload = gather();
220
  try {
221
+ if (state.mode === "buy") {
222
+ const res = await fetch("/api/minspecs", {
223
+ method: "POST", headers: { "Content-Type": "application/json" },
224
+ body: JSON.stringify({ usecase: state.usecase }),
225
+ });
226
+ renderBuy(await res.json());
227
+ return;
228
+ }
229
  const res = await fetch("/api/advise", {
230
  method: "POST", headers: { "Content-Type": "application/json" },
231
+ body: JSON.stringify(payload),
232
  });
233
  render(await res.json());
234
+ if (payload.repo) lookupRepo(payload); // optional live check, appended on top
235
  } catch (e) {
236
  $("#results").innerHTML = `<div class="empty-state"><div class="big"><span class="ic" data-ic="monitor"></span></div>
237
+ <p>Couldn't reach the advisor: ${e && e.message ? e.message : e}</p></div>`;
238
  hydrate($("#results"));
239
  }
240
  }
241
 
242
+ // ---- Live single-model lookup (the one online feature, labelled as such) ---
243
+ async function lookupRepo(payload) {
244
+ const holder = $("#lookup-result");
245
+ if (!holder) return;
246
+ holder.innerHTML = `<div class="ans-loading"><span class="spinner"></span>Looking up ${payload.repo} on Hugging Face…</div>`;
247
+ try {
248
+ const res = await fetch("/api/lookup", {
249
+ method: "POST", headers: { "Content-Type": "application/json" },
250
+ body: JSON.stringify(payload),
251
+ });
252
+ const d = await res.json();
253
+ if (d.error) {
254
+ holder.innerHTML = `<div class="ans-card ans-error"><h3>Couldn't check that model</h3><p>${d.error}</p></div>`;
255
+ return;
256
+ }
257
+ const o = d.option || {};
258
+ const v = VMAP[o.verdict] || VMAP.tight;
259
+ holder.innerHTML = `
260
+ <div class="lookup-card reveal" style="--status:${v.cls};--status-soft:${v.soft}">
261
+ <div class="lookup-head">
262
+ <span class="badge"><span class="dot"></span>${v.word}</span>
263
+ <span class="live-tag">Live Hugging Face lookup</span>
264
+ </div>
265
+ <p class="lookup-explain">${d.explain || ""}</p>
266
+ <div class="lookup-meta">
267
+ ${o.memory && o.memory !== "Too big" ? `<span><b>${o.memory}</b> needed (${o.setting})</span>` : `<span><b>Too big</b> for this machine</span>`}
268
+ ${o.url ? `<a href="${o.url}" target="_blank" rel="noopener">View on Hugging Face</a>` : ""}
269
+ </div>
270
+ ${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>` : ""}
271
+ </div>`;
272
+ holder.querySelectorAll(".copy-btn").forEach(b => b.addEventListener("click", () => {
273
+ navigator.clipboard.writeText(decodeURIComponent(b.dataset.code));
274
+ b.textContent = "Copied ✓";
275
+ setTimeout(() => { b.textContent = "Copy"; }, 1500);
276
+ }));
277
+ } catch (e) {
278
+ holder.innerHTML = `<div class="ans-card ans-error"><h3>Lookup failed</h3><p>${e && e.message ? e.message : e}</p></div>`;
279
+ }
280
+ }
281
+
282
+ // ---- Buy-advice render ------------------------------------------------------
283
+ function renderBuy(d) {
284
+ const lane = (title, icon, lanes) => {
285
+ const tier = (t, kind) => t ? `
286
+ <div class="tool" style="border-left:3px solid ${kind === "min" ? "var(--warn)" : "var(--ok)"}">
287
+ <div class="tool-head"><span class="tname">${kind === "min" ? "Minimum" : "Comfortable"}</span>
288
+ <span class="tag ${kind === "min" ? "mid" : "best"}">${t.price}</span></div>
289
+ <div class="twhat"><b>${t.label}</b></div>
290
+ <div class="twhat">Runs: ${t.runs}${kind === "min" && t.verdict === "tight" ? " (with trade-offs)" : ""}</div>
291
+ </div>` : `
292
+ <div class="tool"><div class="twhat">No tier on this ladder handles it comfortably — this goal wants workstation hardware.</div></div>`;
293
+ return `
294
+ <div class="section-title"><span class="ic" data-ic="${icon}"></span>${title}</div>
295
+ <div class="tool-grid">${tier(lanes.minimum, "min")}${tier(lanes.comfortable, "comfy")}</div>`;
296
+ };
297
+ $("#results").innerHTML = `
298
+ <div class="reveal">
299
+ <div class="verdict" style="--status:var(--accent);--status-soft:var(--accent-soft)">
300
+ <span class="badge"><span class="dot"></span>Buying advice</span>
301
+ <h2>What you need for ${(d.use_case || "this").toLowerCase()}</h2>
302
+ <p>Two honest tiers per platform: the cheapest setup that genuinely works, and the one that feels good daily.</p>
303
+ ${d.note ? `<div class="note">${d.note}</div>` : ""}
304
+ </div>
305
+ ${lane("Windows / Linux PC", "brand-windows", d.pc || {})}
306
+ ${lane("Mac (Apple Silicon)", "brand-apple", d.mac || {})}
307
+ <p class="cmd-intro" style="margin-top:var(--s-4)">${d.disclaimer || ""}</p>
308
+ </div>`;
309
+ hydrate($("#results"));
310
+ $("#cat-version").textContent = d.catalogue_version || "—";
311
+ }
312
+
313
  // ---- Render results -------------------------------------------------------
314
  const VMAP = {
315
  great: { cls: "var(--ok)", soft: "var(--ok-soft)", word: "Runs great", em: "✓" },
 
323
  const g = d.gauge || {};
324
  $("#cat-version").textContent = d.catalogue_version || "—";
325
 
326
+ const licChip = (o) => {
327
+ if (!o.license) return "";
328
+ const note = (o.license_note || "").toLowerCase();
329
+ const warn = note.includes("non-commercial") || note.includes("agpl") || note.includes("research");
330
+ const label = o.license.replace("apache-2.0", "Apache 2.0").replace("mit", "MIT")
331
+ .replace("agpl-3.0", "AGPL").replace("cc-by-nc-4.0", "CC-NC");
332
+ return `<span class="lic${warn ? " warn" : ""}" title="${o.license_note || o.license}">${label}</span>`
333
+ + (o.gated ? `<span class="lic gatechip" title="Accept the terms on Hugging Face once before downloading">gated</span>` : "");
334
+ };
335
  const opts = (d.options || []).map(o => {
336
  const ov = VMAP[o.verdict] || VMAP.tight;
337
+ const name = o.url
338
+ ? `<a href="${o.url}" target="_blank" rel="noopener">${o.model}</a>` : o.model;
339
  return `<div class="opt" style="--status:${ov.cls};--status-soft:${ov.soft}">
340
  <div class="vdot">${ov.em}</div>
341
+ <div><div class="name">${name}${licChip(o)}</div><div class="desc">${o.desc}</div></div>
342
+ <div class="meta"><b>${o.memory}</b><div class="feel">${o.setting}${o.feel && o.feel !== "—" ? " · " + o.feel : ""}</div></div>
343
  </div>`;
344
  }).join("");
345
 
 
359
 
360
  $("#results").innerHTML = `
361
  <div class="reveal">
362
+ <div id="lookup-result"></div>
363
  <div class="verdict" style="--status:${v.cls};--status-soft:${v.soft}">
364
  <span class="badge"><span class="dot"></span>${d.verdict_word || v.word}</span>
365
  <h2>${d.headline || ""}</h2>
 
379
  <span class="item marker">Fast: ${g.fast_gb}</span>
380
  <span class="item marker">Total: ${g.total_gb}</span>
381
  </div>
382
+ ${d.provenance ? `<div class="prov">${d.provenance}</div>` : ""}
383
  </div>` : ""}
384
 
385
+ ${opts ? `<div class="section-title">What you can run <span class="sub">real models, biggest to smallest — names link to Hugging Face</span></div>
386
  <div class="opt-grid">${opts}</div>` : ""}
387
 
388
  ${tools ? `<div class="section-title">How to actually run it</div>
 
498
  function init() {
499
  hydrate(document);
500
  buildPicker();
501
+ wireSegmented("#mode-seg", "mode", applyMode);
502
  wireSegmented("#computer-seg", "computer", () => { syncProviderForComputer(); $("#find-specs-body").innerHTML = findSpecsText(); });
503
  wireSegmented("#provider-seg", "provider", fillGpu);
504
  wireSegmented("#priority-seg", "priority");
505
+ ["#ram","#gpu","#vram","#custom-uc","#repo-check"].forEach(s => { const el = $(s); if (el) el.addEventListener("change", maybeLiveUpdate); });
506
  $("#paste").addEventListener("input", maybeLiveUpdate);
507
  $("#check-btn").addEventListener("click", check);
508
  fillGpu();
509
  $("#find-specs-body").innerHTML = findSpecsText();
510
+ detectHardware();
511
  // Pre-filled share/preview links: /?go renders results immediately.
512
  if (new URLSearchParams(location.search).has("go")) check();
513
  }
static/index.html CHANGED
@@ -32,9 +32,18 @@
32
  <div class="layout">
33
  <!-- ============================== INPUTS ============================== -->
34
  <aside class="panel form-panel">
 
 
 
 
 
 
 
 
35
  <!-- Step 1: machine -->
36
- <div class="step">
37
  <div class="step-head"><span class="step-num">1</span><h2>Your computer</h2></div>
 
38
 
39
  <div class="field">
40
  <span class="label">What kind of computer?</span>
@@ -94,10 +103,15 @@
94
  <span class="label">Describe what you want to build</span>
95
  <input type="text" id="custom-uc" placeholder="e.g. real-time hand tracking from my webcam" />
96
  </div>
 
 
 
 
 
97
  </div>
98
 
99
  <!-- Step 3: preference -->
100
- <div class="step">
101
  <div class="step-head"><span class="step-num">3</span><h2>What matters most? <span class="optional">(optional)</span></h2></div>
102
  <div class="field" style="margin-bottom:0">
103
  <div class="segmented" id="priority-seg">
@@ -144,8 +158,9 @@
144
 
145
  <footer class="foot" id="how">
146
  <p>FitCheck gives <b>conservative</b> estimates from a transparent rules engine.
147
- It would rather under-promise than over-promise. Real speed depends on your exact chip, drivers and settings.</p>
148
- <p style="margin-top:8px">Catalogue last updated <b id="cat-version">—</b>. Built for the Hugging Face Build Small hackathon.</p>
 
149
  </footer>
150
  </main>
151
 
 
32
  <div class="layout">
33
  <!-- ============================== INPUTS ============================== -->
34
  <aside class="panel form-panel">
35
+ <!-- Mode: check what I own, or advise what to buy -->
36
+ <div class="field" style="margin-bottom:var(--s-5)">
37
+ <div class="segmented" id="mode-seg">
38
+ <button class="seg-btn active" data-val="have"><span class="ic" data-ic="monitor"></span>Check my computer</button>
39
+ <button class="seg-btn" data-val="buy"><span class="ic" data-ic="search"></span>Help me pick one</button>
40
+ </div>
41
+ </div>
42
+
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">What kind of computer?</span>
 
103
  <span class="label">Describe what you want to build</span>
104
  <input type="text" id="custom-uc" placeholder="e.g. real-time hand tracking from my webcam" />
105
  </div>
106
+ <div class="field" id="repo-field" style="margin-top:var(--s-4); margin-bottom:0">
107
+ <span class="label">Have a specific model in mind? <span class="optional">(optional)</span></span>
108
+ <input type="text" id="repo-check" placeholder="Paste a Hugging Face model id or link, e.g. lerobot/smolvla_base" />
109
+ <div class="hint">Checks that exact model with a live Hugging Face lookup (the rest of FitCheck runs fully offline).</div>
110
+ </div>
111
  </div>
112
 
113
  <!-- Step 3: preference -->
114
+ <div class="step" id="priority-step">
115
  <div class="step-head"><span class="step-num">3</span><h2>What matters most? <span class="optional">(optional)</span></h2></div>
116
  <div class="field" style="margin-bottom:0">
117
  <div class="segmented" id="priority-seg">
 
158
 
159
  <footer class="foot" id="how">
160
  <p>FitCheck gives <b>conservative</b> estimates from a transparent rules engine.
161
+ Model sizes are the <b>exact file sizes on Hugging Face</b>; the rest is careful, deliberately
162
+ pessimistic math. It would rather under-promise than over-promise. Real speed depends on your exact chip, drivers and settings.</p>
163
+ <p style="margin-top:8px">Catalogue of real models last verified <b id="cat-version">—</b>. Built for the Hugging Face Build Small hackathon.</p>
164
  </footer>
165
  </main>
166
 
static/style.css CHANGED
@@ -326,6 +326,20 @@ details.disc > summary:hover { color: var(--text-primary); }
326
  display: grid; place-items: center; font-size: 15px; font-weight: 700;
327
  }
328
  .opt .name { font-weight: 700; font-family: var(--font-head); font-size: 15px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  .opt .desc { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
330
  .opt .meta { text-align: right; font-size: 13px; color: var(--text-secondary); white-space: nowrap; }
331
  .opt .meta b { color: var(--text-primary); font-family: var(--font-head); }
@@ -363,6 +377,21 @@ details.disc > summary:hover { color: var(--text-primary); }
363
  .copy-btn:hover { color: var(--text-primary); border-color: var(--border-hi); }
364
  .copy-btn.done { color: var(--ok); border-color: var(--ok); }
365
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  /* Ask a follow-up (the model brick) */
367
  .ask-row { display: flex; gap: var(--s-2); }
368
  .ask-row input {
 
326
  display: grid; place-items: center; font-size: 15px; font-weight: 700;
327
  }
328
  .opt .name { font-weight: 700; font-family: var(--font-head); font-size: 15px; }
329
+ .opt .name a { color: var(--text-primary); }
330
+ .opt .name a:hover { color: var(--accent); text-decoration: none; }
331
+ .lic {
332
+ display: inline-block; margin-left: 8px; padding: 1px 8px; vertical-align: 1px;
333
+ border-radius: var(--r-pill); border: 1px solid var(--border-hi);
334
+ font: 600 10.5px/1.6 var(--font-body); letter-spacing: .02em;
335
+ color: var(--text-muted); text-transform: uppercase;
336
+ }
337
+ .lic.warn { color: var(--warn); border-color: var(--warn); opacity: .9; }
338
+ .lic.gatechip { color: var(--accent); border-color: var(--accent); }
339
+ .prov {
340
+ margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border);
341
+ font-size: 12px; color: var(--text-muted); line-height: 1.55;
342
+ }
343
  .opt .desc { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
344
  .opt .meta { text-align: right; font-size: 13px; color: var(--text-secondary); white-space: nowrap; }
345
  .opt .meta b { color: var(--text-primary); font-family: var(--font-head); }
 
377
  .copy-btn:hover { color: var(--text-primary); border-color: var(--border-hi); }
378
  .copy-btn.done { color: var(--ok); border-color: var(--ok); }
379
 
380
+ /* Live single-model lookup card */
381
+ .lookup-card {
382
+ border: 1px solid var(--border); border-left: 4px solid var(--status, var(--accent));
383
+ border-radius: var(--r-md); background: var(--bg-raised);
384
+ padding: var(--s-4) var(--s-5); margin-bottom: var(--s-5);
385
+ }
386
+ .lookup-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--s-2); }
387
+ .live-tag {
388
+ font: 600 11px/1 var(--font-body); text-transform: uppercase; letter-spacing: .05em;
389
+ color: var(--accent); border: 1px dashed var(--accent); border-radius: var(--r-pill);
390
+ padding: 4px 10px; opacity: .85;
391
+ }
392
+ .lookup-explain { color: var(--text-secondary); font-size: 14.5px; }
393
+ .lookup-meta { display: flex; gap: var(--s-4); margin-top: var(--s-2); font-size: 13.5px; color: var(--text-secondary); }
394
+
395
  /* Ask a follow-up (the model brick) */
396
  .ask-row { display: flex; gap: var(--s-2); }
397
  .ask-row input {