cn0303 commited on
Commit
016026a
·
verified ·
1 Parent(s): c464c37

Full model-URL breakdowns, gauge clarity, CPU honesty, multi-platform buying guide, chart tooltips, runner icon

Browse files
app.py CHANGED
@@ -62,11 +62,12 @@ def api_advise(payload: AdviseIn):
62
  class MinSpecsIn(BaseModel):
63
  usecase: str = "chat"
64
  usecases: list[str] | None = None # multi-goal: union of requirements
 
65
 
66
 
67
  @app.post("/api/minspecs")
68
  def api_minspecs(payload: MinSpecsIn):
69
- return min_specs(payload.usecases or [payload.usecase])
70
 
71
 
72
  class LookupIn(AdviseIn):
 
62
  class MinSpecsIn(BaseModel):
63
  usecase: str = "chat"
64
  usecases: list[str] | None = None # multi-goal: union of requirements
65
+ mode: str = "run" # "run" = inference, "finetune" = training
66
 
67
 
68
  @app.post("/api/minspecs")
69
  def api_minspecs(payload: MinSpecsIn):
70
+ return min_specs(payload.usecases or [payload.usecase], payload.mode)
71
 
72
 
73
  class LookupIn(AdviseIn):
engine/finetune.py CHANGED
@@ -274,9 +274,17 @@ def _llm_finetune(uc, candidates, spec, focus) -> dict:
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": [
 
274
  f"path — see the options below."
275
  )
276
  scale = max(fast or total, need, 1) * 1.05
277
+ has_fast = spec.has_fast_path
278
+ if spec.is_apple_silicon:
279
+ fast_label, total_label = "GPU can use", "Unified memory"
280
+ elif has_fast:
281
+ fast_label, total_label = "On the GPU (VRAM)", "GPU + system RAM"
282
+ else:
283
+ fast_label, total_label = "", "System RAM (no GPU)"
284
  gauge = {
285
  "need_gb": f"{need:g} GB needed to train",
286
  "fast_gb": f"{fast:g} GB", "total_gb": f"{total:g} GB",
287
+ "fast_label": fast_label, "total_label": total_label, "has_fast": has_fast,
288
  "fill_pct": round(min(need / scale, 1.0) * 100, 1),
289
  "mark_pct": round(min((fast or total) / scale, 1.0) * 100, 1),
290
  "breakdown": [
engine/hub_lookup.py CHANGED
@@ -17,11 +17,54 @@ 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:
@@ -126,35 +169,23 @@ def lookup(repo_input: str, payload: dict, spec: HardwareSpec) -> dict:
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)
131
- total = getattr(st, "total", None) if st else None
132
- if not total:
 
133
  return {"error": f"'{repo_id}' exists, but doesn't share its size or a known "
134
  f"base model, so an honest estimate isn't possible."}
135
- params_b = total / 1e9
136
- weights_4bit = round(params_b * 4.85 / 8, 2) # effective 4-bit bits/weight
137
- need = round(weights_4bit * 1.25 + 0.58, 2)
138
- fast, total_b = spec.fast_budget_gb, spec.total_budget_gb
139
- if spec.has_fast_path and need <= fast * _SAFETY_FILL:
140
- verdict = "great"
141
- elif need <= total_b * _SAFETY_FILL:
142
- verdict = "tight"
143
- else:
144
- verdict = "no"
145
- return {
146
- "found": True, "match": "estimate", "chain": chain, "live": True,
147
- "explain": (f"<b>{repo_id.split('/')[-1]}</b> isn't in our catalogue and lists no "
148
- f"known base, so this is raw math from its {params_b:.1f}B parameters "
149
- f"at a 4-bit setting — an estimate, not a measured figure."),
150
- "option": {
151
- "verdict": verdict, "model": repo_id.split("/")[-1],
152
- "desc": f"{params_b:.1f}B parameters (from its files on Hugging Face)",
153
- "setting": "Balanced (4-bit)",
154
- "memory": "Too big" if verdict == "no" else f"{need:g} GB",
155
- "feel": "", "url": f"https://huggingface.co/{repo_id}",
156
- "license": "", "license_note": "", "gated": bool(getattr(info, "gated", False)),
157
- "run": {}, "provenance": "estimated", "stale": False,
158
- "params_b": round(params_b, 2), "active_params_b": None,
159
- },
160
- }
 
17
 
18
  from .hardware import HardwareSpec
19
  from .real_advisor import (
20
+ USE_CASES, _SAFETY_FILL, _evaluate, _option_json, catalogue, advise_real,
21
  )
22
 
23
  _RELATION = re.compile(r"^base_model:(finetune|adapter|quantized|merge):(.+)$")
24
 
25
+ # Hugging Face pipeline_tag -> our use-case key, so a looked-up model is
26
+ # compared against the right slice of the catalogue.
27
+ _PIPE_UC = {
28
+ "text-generation": "chat", "text2text-generation": "chat",
29
+ "image-text-to-text": "vlm", "visual-question-answering": "vlm", "image-to-text": "vlm",
30
+ "automatic-speech-recognition": "stt", "text-to-speech": "tts", "text-to-audio": "music",
31
+ "text-to-image": "imagegen", "image-to-image": "inpaint",
32
+ "object-detection": "detect", "image-segmentation": "segment",
33
+ "image-classification": "classify", "depth-estimation": "depth",
34
+ "feature-extraction": "embed", "sentence-similarity": "embed",
35
+ }
36
+
37
+ # Effective bits-per-weight for a synthesised quant ladder (k-quant layouts).
38
+ _SYNTH_QUANTS = [
39
+ ("Q8_0", "Near-full (8-bit)", 8.5), ("Q6_K", "High (6-bit)", 6.6),
40
+ ("Q5_K_M", "Balanced+ (5-bit)", 5.7), ("Q4_K_M", "Balanced (4-bit)", 4.85),
41
+ ("Q3_K_M", "Compact (3-bit)", 3.9),
42
+ ]
43
+
44
+
45
+ def _synthetic_entry(repo_id: str, info) -> dict | None:
46
+ """Build a catalogue-shaped entry for a model NOT in our catalogue, so it
47
+ runs through the same engine (memory gauge, speed chart, comparison). Sizes
48
+ are estimated from the parameter count and labelled as estimates."""
49
+ st = getattr(info, "safetensors", None)
50
+ total = getattr(st, "total", None) if st else None
51
+ if not total:
52
+ return None
53
+ params_b = round(total / 1e9, 2)
54
+ quants = [{"key": k, "plain": p, "file_gb": round(params_b * b / 8, 2)}
55
+ for k, p, b in _SYNTH_QUANTS]
56
+ card = getattr(info, "card_data", None)
57
+ lic = ""
58
+ if card:
59
+ lic = (card.get("license") if hasattr(card, "get") else getattr(card, "license", "")) or ""
60
+ return {
61
+ "key": "_lookup", "family": "llm", "name": repo_id.split("/")[-1],
62
+ "repo_id": repo_id, "params_b": params_b, "quants": quants,
63
+ "provenance": "estimated", "good_for": f"{params_b:g}B parameters, looked up live on Hugging Face.",
64
+ "license": lic, "gated": bool(getattr(info, "gated", False)),
65
+ "links": {"hf": f"https://huggingface.co/{repo_id}"}, "use_cases": [],
66
+ }
67
+
68
 
69
  @lru_cache(maxsize=1)
70
  def _index() -> dict:
 
169
  "explain": explain, "option": opt, "live": via is not None or info is not None,
170
  "focus_name": entry["name"], "uc_key": uc_key}
171
 
172
+ # 3) Not in the catalogue: build a synthetic entry from its real parameter
173
+ # count and run it through the FULL engine — same memory gauge, speed chart
174
+ # and comparison as any catalogue model, with sizes labelled as estimates.
175
+ synth = _synthetic_entry(repo_id, info)
176
+ if synth is None:
177
  return {"error": f"'{repo_id}' exists, but doesn't share its size or a known "
178
  f"base model, so an honest estimate isn't possible."}
179
+ pipe = getattr(info, "pipeline_tag", None) or ""
180
+ uc_key = _PIPE_UC.get(pipe, payload.get("usecase") or "chat")
181
+ look_payload = dict(payload)
182
+ look_payload["usecase"] = uc_key
183
+ look_payload["focus"] = synth["name"]
184
+ advice = advise_real(look_payload, spec, extra_entries=[synth])
185
+ name = repo_id.split("/")[-1]
186
+ explain = (f"<b>{name}</b> isn't in our catalogue, so its sizes are estimated from its "
187
+ f"{synth['params_b']:g}B parameters (not exact file sizes). Everything below — "
188
+ f"the memory breakdown, the speed chart, the comparison — is computed the same "
189
+ f"way as for catalogue models.")
190
+ return {"found": True, "match": "estimate", "chain": chain, "live": True,
191
+ "explain": explain, "advice": advice}
 
 
 
 
 
 
 
 
 
 
 
 
 
engine/real_advisor.py CHANGED
@@ -407,9 +407,14 @@ def _provenance_line(headline: dict | None) -> str:
407
  return "The memory figure is estimated from the model's size — conservative, not measured."
408
 
409
 
410
- def advise_real(payload: dict, spec: HardwareSpec) -> dict:
411
  uc = USE_CASES.get(payload.get("usecase", "chat"), USE_CASES["chat"])
412
- candidates = _by_use_case().get(uc.key, [])
 
 
 
 
 
413
 
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.
@@ -470,29 +475,51 @@ def advise_real(payload: dict, spec: HardwareSpec) -> dict:
470
  e, est, q = headline["entry"], headline["est"], headline["quant"]
471
  hv = headline["verdict"]
472
  need = est["total"]
 
473
  where = ("on your Mac" if spec.is_apple_silicon and hv == "great" else
474
  "on your graphics card" if hv == "great" and spec.has_fast_path else
 
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 "
485
  f"<b>{q.get('plain', q['key'])}</b> setting. {e.get('good_for','')} "
486
  f"It needs about <b>{need:g} GB</b> "
487
- f"(the model file is {est['weights']:g} GB — exact size on Hugging Face — "
488
- f"plus {est['kv']:g} GB chat memory and {est['overhead']:g} GB working space), "
489
- f"and you have roughly <b>{fast:g} GB</b> fast / <b>{total:g} GB</b> total."
490
  )
491
  else:
492
  detail = (
493
  f"For this goal, the honest pick is <b>{e['name']}</b>. "
494
- f"{e.get('good_for','')} It needs about <b>{need:g} GB</b>, and you have "
495
- f"roughly <b>{fast:g} GB</b> fast / <b>{total:g} GB</b> total."
496
  )
497
  model_part, work_part = est["weights"], round(need - est["weights"], 2)
498
  else:
@@ -523,9 +550,18 @@ def advise_real(payload: dict, spec: HardwareSpec) -> dict:
523
  note_bits.append("This model is gated: accept its terms on Hugging Face once before downloading.")
524
 
525
  scale = max(total, need, 1) * 1.05
 
 
 
 
 
 
 
526
  gauge = {
527
  "need_gb": f"{need:g} GB needed",
528
  "fast_gb": f"{fast:g} GB", "total_gb": f"{total:g} GB",
 
 
529
  "fill_pct": round(min(need / scale, 1.0) * 100, 1),
530
  "mark_pct": round(min(fast / scale, 1.0) * 100, 1),
531
  "breakdown": [
@@ -582,44 +618,96 @@ def advise_real(payload: dict, spec: HardwareSpec) -> dict:
582
 
583
 
584
  # --------------------------------------------------------------------------
585
- # Reverse mode: "what machine do I need for X?"
586
  # --------------------------------------------------------------------------
587
 
588
- # Ladders are cheap -> expensive. Budget hints are rough 2026 street prices for
589
- # a whole sensible build, shown as guidance, not gospel.
590
- _PC_LADDER = [
591
- ("Any old laptop (8 GB RAM, no GPU)", dict(ram_gb=8, vram_gb=0, vendor="none"), "what you may already own"),
592
- ("16 GB RAM laptop, no GPU", dict(ram_gb=16, vram_gb=0, vendor="none"), "~$500"),
593
- ("16 GB RAM + RTX 4060 (8 GB)", dict(ram_gb=16, vram_gb=8, vendor="nvidia"), "~$800"),
594
- ("16 GB RAM + RTX 3060 (12 GB)", dict(ram_gb=16, vram_gb=12, vendor="nvidia"), "~$900"),
595
- ("32 GB RAM + RTX 5070 (12 GB)", dict(ram_gb=32, vram_gb=12, vendor="nvidia"), "~$1,300"),
596
- ("32 GB RAM + RTX 5070 Ti (16 GB)", dict(ram_gb=32, vram_gb=16, vendor="nvidia"), "~$1,600"),
597
- ("32 GB RAM + RTX 4090 (24 GB)", dict(ram_gb=32, vram_gb=24, vendor="nvidia"), "~$2,500"),
598
- ("64 GB RAM + RTX 5090 (32 GB)", dict(ram_gb=64, vram_gb=32, vendor="nvidia"), "~$3,500+"),
599
- ]
600
- _MAC_LADDER = [
601
- ("Mac with 16 GB unified memory", dict(ram_gb=16), "~$1,000"),
602
- ("Mac with 24 GB unified memory", dict(ram_gb=24), "~$1,400"),
603
- ("Mac with 32 GB unified memory", dict(ram_gb=32), "~$1,800"),
604
- ("Mac with 64 GB unified memory", dict(ram_gb=64), "~$2,800"),
605
- ("Mac with 128 GB unified memory", dict(ram_gb=128), "~$4,500+"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
606
  ]
607
 
608
 
609
- def _spec_for_tier(kind: str, hw: dict) -> HardwareSpec:
610
- if kind == "mac":
611
- return HardwareSpec(os="macos", ram_gb=hw["ram_gb"], gpu_vendor="apple",
 
 
 
612
  is_apple_silicon=True, form_factor="mac")
613
  return HardwareSpec(os="windows", ram_gb=hw["ram_gb"],
614
  gpu_vendor=hw.get("vendor", "none"),
615
  vram_gb=hw.get("vram_gb", 0.0), form_factor="desktop")
616
 
617
 
618
- def min_specs(usecases) -> dict:
619
- """For one OR several goals: the cheapest tier where EVERY goal genuinely
620
- works (the union of requirements, not a sum), the tier where every goal
621
- runs great, and what each goal would actually run on those tiers.
622
- Pure engine inversion — fully offline."""
623
  if isinstance(usecases, str):
624
  usecases = [usecases]
625
  seen = set()
@@ -630,19 +718,29 @@ def min_specs(usecases) -> dict:
630
  seen.add(uc.key)
631
  ucs.append(uc)
632
 
633
- def walk(kind, ladder):
 
 
 
 
 
 
 
 
 
634
  minimum = comfortable = None
635
- for label, hw, price in ladder:
636
- spec = _spec_for_tier(kind, hw)
637
  per_goal, all_meet, all_great = [], True, True
638
  for uc in ucs:
639
- res = advise_real({"usecase": uc.key}, spec)
640
  all_meet &= res["meets_goal"] and res["verdict"] in ("great", "tight")
641
  all_great &= res["meets_goal"] and res["verdict"] == "great"
642
  per_goal.append({"goal": uc.plain_name,
643
  "model": res["headline_model"] or "nothing realistic",
644
  "verdict": res["verdict"]})
645
- tier = {"label": label, "price": price, "goals": per_goal,
 
646
  "runs": "; ".join(f"{g['goal']}: {g['model']}" for g in per_goal)
647
  if len(per_goal) > 1 else per_goal[0]["model"]}
648
  if minimum is None and all_meet:
@@ -653,19 +751,25 @@ def min_specs(usecases) -> dict:
653
  break
654
  return minimum, comfortable
655
 
656
- pc_min, pc_comfy = walk("pc", _PC_LADDER)
657
- mac_min, mac_comfy = walk("mac", _MAC_LADDER)
 
 
 
 
658
  notes = [uc.note for uc in ucs if uc.note]
 
659
  return {
660
  "use_case": " + ".join(uc.plain_name for uc in ucs),
661
  "goals": [uc.plain_name for uc in ucs],
 
662
  "catalogue_version": catalogue_date(),
663
  "note": " ".join(notes),
664
- "pc": {"minimum": pc_min, "comfortable": pc_comfy},
665
- "mac": {"minimum": mac_min, "comfortable": mac_comfy},
666
- "disclaimer": ("Price hints are rough 2026 street prices for a sensible whole "
667
- "build — they vary a lot by region and second-hand luck. The "
668
- "memory math is the same conservative engine as the main check."
669
- + (" Tiers are the union of every goal you picked: each one has "
670
- "to genuinely work." if len(ucs) > 1 else "")),
671
  }
 
407
  return "The memory figure is estimated from the model's size — conservative, not measured."
408
 
409
 
410
+ def advise_real(payload: dict, spec: HardwareSpec, extra_entries: list | None = None) -> dict:
411
  uc = USE_CASES.get(payload.get("usecase", "chat"), USE_CASES["chat"])
412
+ candidates = list(_by_use_case().get(uc.key, []))
413
+ # A live-looked-up model not in the catalogue is injected here as a synthetic
414
+ # candidate, so it flows through the same gauge / speed-chart / comparison
415
+ # pipeline as catalogue models (its sizes are estimates, labelled as such).
416
+ if extra_entries:
417
+ candidates = list(extra_entries) + candidates
418
 
419
  # Honest gap, not a fake answer: if the catalogue doesn't cover a goal yet,
420
  # say so and point at the live lookup instead of inventing options.
 
475
  e, est, q = headline["entry"], headline["est"], headline["quant"]
476
  hv = headline["verdict"]
477
  need = est["total"]
478
+ no_gpu = not spec.has_fast_path
479
  where = ("on your Mac" if spec.is_apple_silicon and hv == "great" else
480
  "on your graphics card" if hv == "great" and spec.has_fast_path else
481
+ "on the processor" if hv == "tight" and no_gpu else
482
  "using your computer's memory" if hv == "tight" else "")
483
  if hv == "great":
484
  head_text = f"Yes, you can run {e['name']} {where}, today."
485
+ elif hv == "tight" and no_gpu:
486
+ # CPU inference of a small quantised model is genuinely usable, just
487
+ # not instant — say that plainly rather than "with trade-offs".
488
+ head_text = f"Yes, with no graphics card {e['name']} runs {where} (slower, but it works)."
489
  elif hv == "tight":
490
  head_text = f"Sort of. {e['name']} will run {where}, with trade-offs."
491
  else:
492
  head_text = f"{e['name']} won't fit on this machine."
493
+ # "What you have" reads differently with vs without a dedicated GPU.
494
+ if spec.is_apple_silicon:
495
+ have_clause = (f"and your Mac shares <b>{total:g} GB</b> of unified memory between "
496
+ f"the processor and graphics.")
497
+ elif no_gpu:
498
+ have_clause = (f"and with no separate graphics card it runs on the processor using "
499
+ f"your <b>{total:g} GB</b> of system memory — slower than a GPU, but "
500
+ f"it works, especially at a 4-bit setting.")
501
+ else:
502
+ have_clause = (f"and you have about <b>{fast:g} GB</b> on the graphics card, or "
503
+ f"<b>{total:g} GB</b> counting system memory it can spill into.")
504
  if e.get("quants"):
505
+ # Be honest about whether the weights figure is an exact file size
506
+ # (catalogue / GGUF) or a parameter-count estimate (live lookup).
507
+ if e.get("provenance") == "filesize":
508
+ weights_phrase = f"the model file is {est['weights']:g} GB — exact size on Hugging Face"
509
+ else:
510
+ weights_phrase = (f"the weights are about {est['weights']:g} GB at this setting "
511
+ f"(estimated from its {e.get('params_b', 0):g}B parameters)")
512
  detail = (
513
  f"For this goal, the honest pick is <b>{e['name']}</b> at the "
514
  f"<b>{q.get('plain', q['key'])}</b> setting. {e.get('good_for','')} "
515
  f"It needs about <b>{need:g} GB</b> "
516
+ f"({weights_phrase} plus {est['kv']:g} GB chat memory and "
517
+ f"{est['overhead']:g} GB working space), {have_clause}"
 
518
  )
519
  else:
520
  detail = (
521
  f"For this goal, the honest pick is <b>{e['name']}</b>. "
522
+ f"{e.get('good_for','')} It needs about <b>{need:g} GB</b>, {have_clause}"
 
523
  )
524
  model_part, work_part = est["weights"], round(need - est["weights"], 2)
525
  else:
 
550
  note_bits.append("This model is gated: accept its terms on Hugging Face once before downloading.")
551
 
552
  scale = max(total, need, 1) * 1.05
553
+ has_fast = spec.has_fast_path
554
+ if spec.is_apple_silicon:
555
+ fast_label, total_label = "GPU can use", "Unified memory"
556
+ elif has_fast:
557
+ fast_label, total_label = "On the GPU (VRAM)", "GPU + system RAM"
558
+ else:
559
+ fast_label, total_label = "", "System RAM (no GPU)"
560
  gauge = {
561
  "need_gb": f"{need:g} GB needed",
562
  "fast_gb": f"{fast:g} GB", "total_gb": f"{total:g} GB",
563
+ "fast_label": fast_label, "total_label": total_label,
564
+ "has_fast": has_fast,
565
  "fill_pct": round(min(need / scale, 1.0) * 100, 1),
566
  "mark_pct": round(min(fast / scale, 1.0) * 100, 1),
567
  "breakdown": [
 
618
 
619
 
620
  # --------------------------------------------------------------------------
621
+ # Reverse mode: "what machine do I need for X?" Multi-platform, 2026 hardware.
622
  # --------------------------------------------------------------------------
623
 
624
+ # Each platform is a cheap -> expensive ladder of representative builds. The
625
+ # memory numbers feed the SAME engine (so recommendations are grounded, not
626
+ # hand-waved); `spec` and `detail` power the human-readable + click-to-expand
627
+ # card. Prices are rough mid-2026 street figures (the DRAM shortage moved them),
628
+ # shown as guidance, not gospel. Sources: TechPowerUp/Tom's (bandwidth), vendor
629
+ # pages; software-support notes from ROCm/MLX/CUDA reporting.
630
+ _PLATFORMS = [
631
+ {
632
+ "key": "nvidia", "name": "NVIDIA PC", "icon": "brand-nvidia",
633
+ "blurb": "The path of least resistance: CUDA, so every tool just works. Best if you might fine-tune.",
634
+ "tiers": [
635
+ ("RTX 5060 Ti 16GB build", "~$1,000", dict(ram_gb=32, vram_gb=16, vendor="nvidia"),
636
+ "8-core CPU, 32 GB DDR5, RTX 5060 Ti 16GB, 1 TB NVMe",
637
+ "16 GB of VRAM is the real sweet spot: it runs 13-14B models comfortably and starts to fine-tune 7B with QLoRA. The 5060 Ti's narrow memory bus makes it slower than a 5070 Ti, but it is the cheapest 16 GB CUDA card. Desktop over laptop here: you get more VRAM per dollar and can swap the GPU later."),
638
+ ("RTX 5070 Ti build", "~$1,500", dict(ram_gb=32, vram_gb=16, vendor="nvidia"),
639
+ "8-core CPU, 32-64 GB DDR5, RTX 5070 Ti 16GB, 2 TB NVMe",
640
+ "Same 16 GB ceiling but much faster generation (896 GB/s). The mainstream pick for someone who wants snappy local AI without overspending. A desktop card runs cooler and faster under sustained load than the same chip in a laptop, which throttles."),
641
+ ("RTX 4090 / 24 GB build", "~$2,400", dict(ram_gb=64, vram_gb=24, vendor="nvidia"),
642
+ "12-core CPU, 64 GB DDR5, 24 GB GPU (RTX 4090, used), 2 TB NVMe",
643
+ "24 GB runs ~30B models comfortably and QLoRA-fine-tunes up to ~30B. The 4090 is end-of-life and scarcity-priced, so buy used carefully or wait for a 24 GB 50-series. 64 GB of system RAM lets big models spill over when needed."),
644
+ ("RTX 5090 build", "~$3,800", dict(ram_gb=64, vram_gb=32, vendor="nvidia"),
645
+ "12-16 core CPU, 64-128 GB DDR5, RTX 5090 32GB, 2 TB NVMe",
646
+ "The fastest consumer card and the only one with 32 GB, so it runs ~30B at full speed and big diffusion models. Workstation territory in price. If you mainly fine-tune, two 24 GB cards can beat one 5090 for multi-GPU training."),
647
+ ],
648
+ },
649
+ {
650
+ "key": "amd", "name": "AMD PC", "icon": "brand-amd",
651
+ "blurb": "Cheaper VRAM for inference. On Windows use the Vulkan path (LM Studio); rougher for training.",
652
+ "tiers": [
653
+ ("RX 9070 XT build", "~$1,200", dict(ram_gb=32, vram_gb=16, vendor="amd"),
654
+ "8-core CPU, 32 GB DDR5, Radeon RX 9070 XT 16GB, 2 TB NVMe",
655
+ "16 GB at a good price; runs up to ~14B models well. For 'open a model and chat' the AMD-vs-NVIDIA gap is now mostly price. Caveat: on Windows use llama.cpp / LM Studio via Vulkan (near plug-and-play); ROCm is Linux-first and fine-tuning support lags CUDA."),
656
+ ("RX 7900 XTX build", "~$1,500", dict(ram_gb=64, vram_gb=24, vendor="amd"),
657
+ "12-core CPU, 64 GB DDR5, Radeon RX 7900 XTX 24GB, 2 TB NVMe",
658
+ "The AMD standout for local AI: 24 GB and 960 GB/s for under $1k on the card, runs 70B at 4-bit. Best VRAM-per-dollar at this tier. Still AMD-software caveats for training, but excellent for inference."),
659
+ ],
660
+ },
661
+ {
662
+ "key": "apple", "name": "Mac (Apple Silicon)", "icon": "brand-apple",
663
+ "blurb": "Unified memory runs models no consumer GPU can hold, quietly and efficiently. Inference-focused.",
664
+ "tiers": [
665
+ ("Mac mini / Air M4, 16 GB", "~$1,000", dict(ram_gb=16, apple=True),
666
+ "Apple M4, 16 GB unified memory",
667
+ "The shared memory pool means the GPU can use almost all 16 GB. Runs small and mid models quietly. Buy the RAM you need up front: Apple memory is soldered and cannot be upgraded later, ever."),
668
+ ("Mac M4 Pro, 36 GB", "~$1,700", dict(ram_gb=36, apple=True),
669
+ "Apple M4 Pro, 36 GB unified memory, 273 GB/s",
670
+ "A strong quiet inference machine: 36 GB holds ~30B-class models, and the Pro's higher bandwidth keeps them usable. Great everyday computer too. Weaker than NVIDIA for fine-tuning (MLX does small LoRA; real training wants CUDA)."),
671
+ ("Mac M4 Max, 64 GB", "~$2,800", dict(ram_gb=64, apple=True),
672
+ "Apple M4 Max, 64 GB unified memory, 410-546 GB/s",
673
+ "64 GB of unified memory runs models a 24 GB GPU simply cannot load, at higher bandwidth than the mini-PC boxes. The sweet spot for running big models quietly. Studio (desktop) or MacBook Pro (laptop) both work; the Studio sustains load better."),
674
+ ("Mac Studio M4 Max / Ultra, 128 GB+", "~$4,000+", dict(ram_gb=128, apple=True),
675
+ "Apple M4 Max / M3 Ultra, 128-512 GB unified memory",
676
+ "Capacity king: 128 GB holds 70-120B-class models, and an M3 Ultra at 512 GB holds 600B+ MoE models no other single box can. The win is capacity and quiet, not raw speed (a 5090 is faster on whatever fits its 32 GB)."),
677
+ ],
678
+ },
679
+ {
680
+ "key": "mini", "name": "Mini-PC (unified memory)", "icon": "monitor",
681
+ "blurb": "Big models in a small, silent, low-power box. They fit huge models; they run them at a steady jog, not a sprint.",
682
+ "tiers": [
683
+ ("Ryzen AI Max+ 395, 64 GB", "~$1,500", dict(ram_gb=64, unified=True, vendor="amd"),
684
+ "AMD Ryzen AI Max+ 395 'Strix Halo', 64 GB unified memory, ~256 GB/s",
685
+ "16 Zen 5 cores plus a strong integrated GPU sharing 64 GB, in a tiny quiet box, no discrete card. Runs ~30B comfortably. Bandwidth is far below a discrete GPU, so big models fit and are usable but not fast. Vulkan works well; ROCm is fiddlier."),
686
+ ("Ryzen AI Max+ 395, 128 GB", "~$2,400", dict(ram_gb=128, unified=True, vendor="amd"),
687
+ "AMD Ryzen AI Max+ 395 'Strix Halo', 128 GB unified memory, ~256 GB/s",
688
+ "128 GB of unified memory runs 70B-class models (AMD demos 128B in LM Studio) for far less than a Mac with the same capacity, and sips power. Best capacity-per-dollar for big-model experimentation; accept moderate speed. The DGX Spark is a CUDA-compatible alternative but costs ~2x and is slower per token."),
689
+ ],
690
+ },
691
  ]
692
 
693
 
694
+ def _spec_for_tier(hw: dict) -> HardwareSpec:
695
+ # Apple and unified-memory mini-PCs share one memory pool (model like Apple);
696
+ # a normal PC has separate VRAM + system RAM.
697
+ if hw.get("apple") or hw.get("unified"):
698
+ return HardwareSpec(os="macos" if hw.get("apple") else "linux",
699
+ ram_gb=hw["ram_gb"], gpu_vendor=hw.get("vendor", "apple"),
700
  is_apple_silicon=True, form_factor="mac")
701
  return HardwareSpec(os="windows", ram_gb=hw["ram_gb"],
702
  gpu_vendor=hw.get("vendor", "none"),
703
  vram_gb=hw.get("vram_gb", 0.0), form_factor="desktop")
704
 
705
 
706
+ def min_specs(usecases, mode: str = "run") -> dict:
707
+ """For one OR several goals, across every platform: the cheapest build where
708
+ EVERY goal genuinely works (the union of requirements), and the build where
709
+ every goal runs great. `mode="finetune"` sizes for training instead of
710
+ inference. Pure engine inversion — fully offline."""
711
  if isinstance(usecases, str):
712
  usecases = [usecases]
713
  seen = set()
 
718
  seen.add(uc.key)
719
  ucs.append(uc)
720
 
721
+ finetune = mode == "finetune"
722
+ if finetune:
723
+ from .finetune import advise_finetune
724
+
725
+ def assess(uc, spec):
726
+ if finetune:
727
+ return advise_finetune({"usecase": uc.key}, spec)
728
+ return advise_real({"usecase": uc.key}, spec)
729
+
730
+ def walk(tiers):
731
  minimum = comfortable = None
732
+ for label, price, hw, spec_line, detail in tiers:
733
+ spec = _spec_for_tier(hw)
734
  per_goal, all_meet, all_great = [], True, True
735
  for uc in ucs:
736
+ res = assess(uc, spec)
737
  all_meet &= res["meets_goal"] and res["verdict"] in ("great", "tight")
738
  all_great &= res["meets_goal"] and res["verdict"] == "great"
739
  per_goal.append({"goal": uc.plain_name,
740
  "model": res["headline_model"] or "nothing realistic",
741
  "verdict": res["verdict"]})
742
+ tier = {"label": label, "price": price, "spec": spec_line, "detail": detail,
743
+ "goals": per_goal,
744
  "runs": "; ".join(f"{g['goal']}: {g['model']}" for g in per_goal)
745
  if len(per_goal) > 1 else per_goal[0]["model"]}
746
  if minimum is None and all_meet:
 
751
  break
752
  return minimum, comfortable
753
 
754
+ platforms = []
755
+ for p in _PLATFORMS:
756
+ mn, comfy = walk(p["tiers"])
757
+ platforms.append({"key": p["key"], "name": p["name"], "icon": p["icon"],
758
+ "blurb": p["blurb"], "minimum": mn, "comfortable": comfy})
759
+
760
  notes = [uc.note for uc in ucs if uc.note]
761
+ verb = "fine-tune" if finetune else "run"
762
  return {
763
  "use_case": " + ".join(uc.plain_name for uc in ucs),
764
  "goals": [uc.plain_name for uc in ucs],
765
+ "mode": mode,
766
  "catalogue_version": catalogue_date(),
767
  "note": " ".join(notes),
768
+ "platforms": platforms,
769
+ "disclaimer": (f"Two honest builds per platform: the cheapest that genuinely lets you "
770
+ f"{verb} these goals, and the one that does it comfortably. Click any card "
771
+ f"for what to look for. Prices are rough mid-2026 street figures and drift; "
772
+ f"the memory math is the same conservative engine as the main check."
773
+ + (" Builds cover the union of every goal you picked: each must work."
774
+ if len(ucs) > 1 else "")),
775
  }
static/app.js CHANGED
@@ -81,12 +81,8 @@ function applyMode() {
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"));
@@ -117,11 +113,21 @@ function buildPicker() {
117
  state.usecases.push(uc);
118
  p.classList.add("active");
119
  }
120
- $("#custom-uc-field").style.display = state.usecases.includes("custom") ? "block" : "none";
121
  maybeLiveUpdate();
122
  }));
123
  }
124
 
 
 
 
 
 
 
 
 
 
 
125
  // ---- Segmented controls ---------------------------------------------------
126
  function wireSegmented(id, key, after) {
127
  $(id).querySelectorAll(".seg-btn").forEach(b => b.addEventListener("click", () => {
@@ -213,7 +219,6 @@ function gather() {
213
  paste: $("#paste").value.trim(),
214
  usecase: state.usecases[0],
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() : "",
@@ -234,7 +239,7 @@ async function check() {
234
  if (state.mode === "buy") {
235
  const res = await fetch("/api/minspecs", {
236
  method: "POST", headers: { "Content-Type": "application/json" },
237
- body: JSON.stringify({ usecases: state.usecases }),
238
  });
239
  renderBuy(await res.json());
240
  return;
@@ -304,24 +309,21 @@ async function lookupRepo(payload) {
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
 
@@ -357,38 +359,50 @@ function renderMulti(results) {
357
  $("#cat-version").textContent = (results[0] || {}).catalogue_version || "—";
358
  }
359
 
360
- // ---- Buy-advice render ------------------------------------------------------
361
  function renderBuy(d) {
362
- const goalLines = (t) => (t.goals && t.goals.length > 1)
363
- ? `<ul class="goal-lines">${t.goals.map(g =>
364
- `<li><span>${g.goal}</span><b>${g.model}</b>${g.verdict === "tight" ? " <i>(trade-offs)</i>" : ""}</li>`).join("")}</ul>`
365
- : `<div class="twhat">Runs: ${t.runs}${t.goals && t.goals[0] && t.goals[0].verdict === "tight" ? " (with trade-offs)" : ""}</div>`;
366
- const lane = (title, icon, lanes) => {
367
- const tier = (t, kind) => t ? `
368
- <div class="tool" style="border-left:3px solid ${kind === "min" ? "var(--warn)" : "var(--ok)"}">
369
- <div class="tool-head"><span class="tname">${kind === "min" ? "Minimum" : "Comfortable"}</span>
370
- <span class="tag ${kind === "min" ? "mid" : "best"}">${t.price}</span></div>
371
- <div class="twhat"><b>${t.label}</b></div>
372
- ${goalLines(t)}
373
- </div>` : `
374
- <div class="tool"><div class="twhat">No tier on this ladder handles ${d.goals && d.goals.length > 1 ? "all of these together" : "it"} comfortably — this combination wants workstation hardware.</div></div>`;
375
- return `
376
- <div class="section-title"><span class="ic" data-ic="${icon}"></span>${title}</div>
377
- <div class="tool-grid">${tier(lanes.minimum, "min")}${tier(lanes.comfortable, "comfy")}</div>`;
 
 
378
  };
 
 
 
 
 
 
379
  $("#results").innerHTML = `
380
  <div class="reveal">
381
  <div class="verdict" style="--status:var(--accent);--status-soft:var(--accent-soft)">
382
- <span class="badge"><span class="dot"></span>Buying advice</span>
383
- <h2>What you need for ${(d.use_case || "this").toLowerCase()}</h2>
384
- <p>Two honest tiers per platform: the cheapest setup that genuinely works, and the one that feels good daily.</p>
385
  ${d.note ? `<div class="note">${d.note}</div>` : ""}
386
  </div>
387
- ${lane("Windows / Linux PC", "brand-windows", d.pc || {})}
388
- ${lane("Mac (Apple Silicon)", "brand-apple", d.mac || {})}
389
- <p class="cmd-intro" style="margin-top:var(--s-4)">${d.disclaimer || ""}</p>
390
  </div>`;
391
  hydrate($("#results"));
 
 
 
 
 
 
392
  $("#cat-version").textContent = d.catalogue_version || "—";
393
  }
394
 
@@ -468,12 +482,12 @@ function render(d) {
468
  <div class="gauge-top"><span class="label" style="margin:0">Memory needed vs. what you have</span>
469
  <span class="need">${g.need_gb}</span></div>
470
  <div class="gauge" style="--pct:${g.fill_pct};--gcolor:${v.cls};--needpct:${g.mark_pct}">
471
- <div class="gauge-fill"></div><div class="gauge-need"></div>
472
  </div>
473
  <div class="gauge-legend">
474
  ${(g.breakdown||[]).map(b=>`<span class="item"><span class="sw" style="background:${b.color}"></span>${b.label}</span>`).join("")}
475
- <span class="item marker">Fast: ${g.fast_gb}</span>
476
- <span class="item marker">Total: ${g.total_gb}</span>
477
  </div>
478
  ${d.provenance ? `<div class="prov">${d.provenance}</div>` : ""}
479
  </div>` : ""}
@@ -601,9 +615,10 @@ async function drawRoofline(speed) {
601
 
602
  // real measurement dots, shaded by model size
603
  const shade = p => p.params_b <= 2 ? "rl-p1" : (p.params_b <= 9 ? "rl-p8" : "rl-p14");
 
604
  for (const p of pts) {
605
  if (p.bw < xmin || p.tps < ymin) continue;
606
- s += `<circle cx="${lx(Math.min(p.bw, xmax)).toFixed(1)}" cy="${ly(Math.min(p.tps, ymax)).toFixed(1)}" r="2.6" class="rl-dot ${shade(p)}"><title>${p.accel} — ${p.model}: ${p.tps} tok/s</title></circle>`;
607
  }
608
 
609
  if (speed) {
@@ -618,7 +633,7 @@ async function drawRoofline(speed) {
618
  s += `<line x1="${ux}" y1="${ly(Math.min(Math.max(speed.lo, ymin), ymax))}" x2="${ux}" y2="${ly(Math.min(Math.max(speed.hi, ymin), ymax))}" class="rl-band"/>`;
619
  // flip the label to the left when the dot sits near the right edge
620
  const flip = ux > W * 0.72;
621
- s += `<circle cx="${ux}" cy="${uy}" r="6" class="rl-you"/>` +
622
  `<text x="${flip ? ux - 10 : ux + 10}" y="${uy + 4}" class="rl-you-label" text-anchor="${flip ? "end" : "start"}">you ≈${speed.tps} tok/s</text>`;
623
  }
624
  s += `</svg>
@@ -630,6 +645,31 @@ async function drawRoofline(speed) {
630
  <span class="item"><span class="sw rl-you-sw"></span>your machine</span>
631
  </div>`;
632
  host.innerHTML = s;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
  }
634
 
635
  // ---- Live progress ticker (gradio-style elapsed seconds) -------------------
@@ -802,7 +842,7 @@ function init() {
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);
 
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
+ // The Run/Train toggle applies in buy mode too: "what should I buy to run X"
85
+ // vs "to fine-tune X" are very different machines.
 
 
 
 
86
  $("#check-btn").innerHTML = (buy ? "What should I get? " : "Check my setup ")
87
  + '<span class="ic" data-ic="arrow"></span>';
88
  hydrate($("#check-btn"));
 
113
  state.usecases.push(uc);
114
  p.classList.add("active");
115
  }
116
+ syncCustomRepo();
117
  maybeLiveUpdate();
118
  }));
119
  }
120
 
121
+ // When "Custom: describe it" is the goal, the model box is the way in, so drop
122
+ // its "(optional)" tag and flag it as the expected input.
123
+ function syncCustomRepo() {
124
+ const isCustom = state.usecases.includes("custom");
125
+ const opt = $("#repo-optional");
126
+ if (opt) opt.style.display = isCustom ? "none" : "";
127
+ const field = $("#repo-field");
128
+ if (field) field.classList.toggle("required", isCustom);
129
+ }
130
+
131
  // ---- Segmented controls ---------------------------------------------------
132
  function wireSegmented(id, key, after) {
133
  $(id).querySelectorAll(".seg-btn").forEach(b => b.addEventListener("click", () => {
 
219
  paste: $("#paste").value.trim(),
220
  usecase: state.usecases[0],
221
  usecases: state.usecases.slice(),
 
222
  priority: state.priority,
223
  mode: state.task, // "run" (inference) or "finetune" (training)
224
  repo: $("#repo-check") ? $("#repo-check").value.trim() : "",
 
239
  if (state.mode === "buy") {
240
  const res = await fetch("/api/minspecs", {
241
  method: "POST", headers: { "Content-Type": "application/json" },
242
+ body: JSON.stringify({ usecases: state.usecases, mode: state.task }),
243
  });
244
  renderBuy(await res.json());
245
  return;
 
309
  }
310
  return;
311
  }
312
+ // Not in the catalogue: the engine still built a FULL breakdown from the
313
+ // looked-up parameter count. Render it like any result, with an estimate banner.
314
+ if (d.match === "estimate" && d.advice) {
315
+ render(d.advice);
316
+ const wrap = $("#results").firstElementChild;
317
+ if (wrap) {
318
+ const banner = document.createElement("div");
319
+ banner.className = "lookup-banner estimate";
320
+ banner.innerHTML = `<span class="live-tag">Live Hugging Face lookup · estimate</span><p>${d.explain || ""}</p>`;
321
+ wrap.prepend(banner);
322
+ }
323
+ return;
324
+ }
325
+ // Last resort: a bare error/explain with no advice payload.
326
+ $("#results").innerHTML = `<div class="ans-card"><h3>${payload.repo}</h3><p>${d.explain || "No estimate available."}</p></div>`;
 
 
 
327
  hydrate($("#results"));
328
  }
329
 
 
359
  $("#cat-version").textContent = (results[0] || {}).catalogue_version || "—";
360
  }
361
 
362
+ // ---- Buy-advice render: multi-platform, click any card for what to look for -
363
  function renderBuy(d) {
364
+ const ft = d.mode === "finetune";
365
+ const verb = ft ? "fine-tune" : "run";
366
+ const multi = (d.goals || []).length > 1;
367
+ const tierCard = (t, kind) => {
368
+ if (!t) return `<div class="buy-card empty">
369
+ <div class="bc-head"><span class="bc-kind ${kind}">${kind === "min" ? "Minimum" : "Comfortable"}</span></div>
370
+ <p class="bc-none">No build on this platform ${ft ? "trains" : "runs"} ${multi ? "all of these" : "this"}${ft ? "" : " well"} — it wants more than this ladder offers.</p>
371
+ </div>`;
372
+ const goalsHtml = multi
373
+ ? `<ul class="bc-goals">${t.goals.map(g => `<li><span>${g.goal}</span><b>${g.model}</b>${g.verdict === "tight" ? " <i>(tight)</i>" : ""}</li>`).join("")}</ul>`
374
+ : `<div class="bc-runs">${ft ? "Can train up to" : "Runs"} <b>${t.runs}</b>${t.goals[0] && t.goals[0].verdict === "tight" ? " <i>(tight)</i>" : ""}</div>`;
375
+ return `<div class="buy-card">
376
+ <div class="bc-head"><span class="bc-kind ${kind}">${kind === "min" ? "Minimum" : "Comfortable"}</span><span class="tag ${kind === "min" ? "mid" : "best"}">${t.price}</span></div>
377
+ <div class="bc-label">${t.label}</div>
378
+ ${goalsHtml}
379
+ <button class="bc-more">What to look for <span class="chev ic" data-ic="chevron"></span></button>
380
+ <div class="bc-detail" hidden><div class="bc-spec">${t.spec}</div><p>${t.detail}</p></div>
381
+ </div>`;
382
  };
383
+ const platforms = (d.platforms || []).map(p => `
384
+ <div class="buy-platform">
385
+ <div class="bp-head"><span class="ic" data-ic="${p.icon}"></span><span class="bp-name">${p.name}</span></div>
386
+ <div class="bp-blurb">${p.blurb}</div>
387
+ <div class="buy-grid">${tierCard(p.minimum, "min")}${tierCard(p.comfortable, "comfy")}</div>
388
+ </div>`).join("");
389
  $("#results").innerHTML = `
390
  <div class="reveal">
391
  <div class="verdict" style="--status:var(--accent);--status-soft:var(--accent-soft)">
392
+ <span class="badge"><span class="dot"></span>Buying advice · to ${verb}</span>
393
+ <h2>What to buy to ${verb} ${(d.use_case || "this").toLowerCase()}</h2>
394
+ <p>${d.disclaimer || ""}</p>
395
  ${d.note ? `<div class="note">${d.note}</div>` : ""}
396
  </div>
397
+ ${platforms}
 
 
398
  </div>`;
399
  hydrate($("#results"));
400
+ $("#results").querySelectorAll(".buy-card .bc-more").forEach(b => b.addEventListener("click", () => {
401
+ const det = b.nextElementSibling;
402
+ const opening = det.hasAttribute("hidden");
403
+ if (opening) det.removeAttribute("hidden"); else det.setAttribute("hidden", "");
404
+ b.classList.toggle("open", opening);
405
+ }));
406
  $("#cat-version").textContent = d.catalogue_version || "—";
407
  }
408
 
 
482
  <div class="gauge-top"><span class="label" style="margin:0">Memory needed vs. what you have</span>
483
  <span class="need">${g.need_gb}</span></div>
484
  <div class="gauge" style="--pct:${g.fill_pct};--gcolor:${v.cls};--needpct:${g.mark_pct}">
485
+ <div class="gauge-fill"></div>${g.has_fast ? `<div class="gauge-need" data-tip="Your GPU memory limit. A model that fits left of this line runs at full GPU speed; past it, the overflow runs from slower system RAM."></div>` : ""}
486
  </div>
487
  <div class="gauge-legend">
488
  ${(g.breakdown||[]).map(b=>`<span class="item"><span class="sw" style="background:${b.color}"></span>${b.label}</span>`).join("")}
489
+ ${g.has_fast && g.fast_label ? `<span class="item marker" data-tip="Your graphics card's own memory. Models that fit here run at full speed.">${g.fast_label}: ${g.fast_gb}</span>` : ""}
490
+ <span class="item marker" data-tip="${g.has_fast ? "GPU memory plus a slice of system RAM the model can spill into (the spilled part runs slower)." : "With no separate graphics card, the model runs on the processor using your system memory."}">${g.total_label || "Total"}: ${g.total_gb}</span>
491
  </div>
492
  ${d.provenance ? `<div class="prov">${d.provenance}</div>` : ""}
493
  </div>` : ""}
 
615
 
616
  // real measurement dots, shaded by model size
617
  const shade = p => p.params_b <= 2 ? "rl-p1" : (p.params_b <= 9 ? "rl-p8" : "rl-p14");
618
+ const esc = t => String(t == null ? "" : t).replace(/"/g, "&quot;");
619
  for (const p of pts) {
620
  if (p.bw < xmin || p.tps < ymin) continue;
621
+ s += `<circle cx="${lx(Math.min(p.bw, xmax)).toFixed(1)}" cy="${ly(Math.min(p.tps, ymax)).toFixed(1)}" r="2.6" class="rl-dot ${shade(p)}" data-m="${esc(p.model)}" data-a="${esc(p.accel)}" data-t="${esc(p.tps)}" data-b="${esc(Math.round(p.bw))}" data-p="${esc(p.params_b)}"></circle>`;
622
  }
623
 
624
  if (speed) {
 
633
  s += `<line x1="${ux}" y1="${ly(Math.min(Math.max(speed.lo, ymin), ymax))}" x2="${ux}" y2="${ly(Math.min(Math.max(speed.hi, ymin), ymax))}" class="rl-band"/>`;
634
  // flip the label to the left when the dot sits near the right edge
635
  const flip = ux > W * 0.72;
636
+ s += `<circle cx="${ux}" cy="${uy}" r="6" class="rl-you" data-you="1" data-m="${esc(speed.model)}" data-t="${esc(speed.tps)}" data-b="${esc(Math.round(speed.eff_bw || speed.bw))}"/>` +
637
  `<text x="${flip ? ux - 10 : ux + 10}" y="${uy + 4}" class="rl-you-label" text-anchor="${flip ? "end" : "start"}">you ≈${speed.tps} tok/s</text>`;
638
  }
639
  s += `</svg>
 
645
  <span class="item"><span class="sw rl-you-sw"></span>your machine</span>
646
  </div>`;
647
  host.innerHTML = s;
648
+
649
+ // Hover any dot for a plain-words info bubble: what model, what hardware,
650
+ // how fast, at what bandwidth. The whole point of the chart is legibility.
651
+ host.style.position = "relative";
652
+ const tip = document.createElement("div");
653
+ tip.className = "rl-tip"; tip.hidden = true;
654
+ host.appendChild(tip);
655
+ const svg = host.querySelector("svg");
656
+ if (svg) {
657
+ svg.addEventListener("mousemove", (e) => {
658
+ const t = e.target;
659
+ const cls = t.getAttribute && t.getAttribute("class");
660
+ if (!cls || (!cls.includes("rl-dot") && !cls.includes("rl-you"))) { tip.hidden = true; return; }
661
+ const d = t.dataset;
662
+ tip.innerHTML = d.you
663
+ ? `<b>Your machine</b><br>${d.m || "your pick"} ≈ <b>${d.t} tok/s</b><br><span class="rl-tip-sub">at ~${d.b} GB/s memory bandwidth</span>`
664
+ : `<b>${d.m || "model"}</b>${d.p ? ` · ${d.p}B` : ""}<br>${d.a || ""}<br><b>${d.t} tok/s</b> <span class="rl-tip-sub">at ~${d.b} GB/s</span>`;
665
+ tip.hidden = false;
666
+ const r = host.getBoundingClientRect();
667
+ const x = e.clientX - r.left, y = e.clientY - r.top;
668
+ tip.style.left = Math.min(x + 14, host.clientWidth - 150) + "px";
669
+ tip.style.top = Math.max(y - 10, 0) + "px";
670
+ });
671
+ svg.addEventListener("mouseleave", () => { tip.hidden = true; });
672
+ }
673
  }
674
 
675
  // ---- Live progress ticker (gradio-style elapsed seconds) -------------------
 
842
  wireSegmented("#provider-seg", "provider", fillGpu);
843
  wireSegmented("#priority-seg", "priority");
844
  wireTaskToggle();
845
+ ["#ram","#gpu","#vram","#repo-check"].forEach(s => { const el = $(s); if (el) el.addEventListener("change", maybeLiveUpdate); });
846
  $("#paste").addEventListener("input", maybeLiveUpdate);
847
  $("#check-btn").addEventListener("click", check);
848
  const pb = $("#parse-btn"); if (pb) pb.addEventListener("click", parsePaste);
static/icons.js CHANGED
@@ -1,6 +1,7 @@
1
  /* Auto-generated by build_icons.py. Inline icons = offline-safe. */
2
  window.ICONS = {
3
  "chat": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719\" /> </svg>",
 
4
  "writing": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\" /> <path d=\"M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z\" /> </svg>",
5
  "coding": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"m18 16 4-4-4-4\" /> <path d=\"m6 8-4 4 4 4\" /> <path d=\"m14.5 4-5 16\" /> </svg>",
6
  "agents": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 8V4H8\" /> <rect x=\"4\" y=\"8\" rx=\"2\" /> <path d=\"M2 14h2\" /> <path d=\"M20 14h2\" /> <path d=\"M15 13v2\" /> <path d=\"M9 13v2\" /> </svg>",
 
1
  /* Auto-generated by build_icons.py. Inline icons = offline-safe. */
2
  window.ICONS = {
3
  "chat": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719\" /> </svg>",
4
+ "run": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <circle cx=\"14\" cy=\"4\" r=\"1.8\" /> <path d=\"m5 19 3.5-4.5-2-3.5 4.5-2.5\" /> <path d=\"m9.5 10.5 4.5 2 .8 6.5\" /> <path d=\"M11.5 8 15.5 6.5l1.7 3.2 3.3.8\" /> </svg>",
5
  "writing": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\" /> <path d=\"M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z\" /> </svg>",
6
  "coding": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"m18 16 4-4-4-4\" /> <path d=\"m6 8-4 4 4 4\" /> <path d=\"m14.5 4-5 16\" /> </svg>",
7
  "agents": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 8V4H8\" /> <rect x=\"4\" y=\"8\" rx=\"2\" /> <path d=\"M2 14h2\" /> <path d=\"M20 14h2\" /> <path d=\"M15 13v2\" /> <path d=\"M9 13v2\" /> </svg>",
static/index.html CHANGED
@@ -102,19 +102,15 @@
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>
112
- <input type="text" id="custom-uc" placeholder="e.g. real-time hand tracking from my webcam" />
113
- </div>
114
  <div class="field" id="repo-field" style="margin-top:var(--s-4); margin-bottom:0">
115
- <span class="label">Have a specific model in mind? <span class="optional">(optional)</span></span>
116
  <input type="text" id="repo-check" placeholder="Paste a Hugging Face model id or link, e.g. lerobot/smolvla_base" />
117
- <div class="hint">Checks that exact model with a live Hugging Face lookup (the rest of FitCheck runs fully offline).</div>
118
  </div>
119
  </div>
120
 
 
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="run"></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="repo-field" style="margin-top:var(--s-4); margin-bottom:0">
111
+ <span class="label">Check a specific model <span class="optional" id="repo-optional">(optional)</span></span>
112
  <input type="text" id="repo-check" placeholder="Paste a Hugging Face model id or link, e.g. lerobot/smolvla_base" />
113
+ <div class="hint">Paste any model and FitCheck gives the full breakdown for it — memory, speed, and how it compares — <b>even models not in our catalogue</b>, via a live Hugging Face lookup. (The rest of FitCheck runs fully offline.)</div>
114
  </div>
115
  </div>
116
 
static/style.css CHANGED
@@ -448,6 +448,46 @@ details.disc > summary:hover { color: var(--text-primary); }
448
  .tool .tinstall { font-size: 12.5px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
449
  .tool .tinstall .ic { font-size: 14px; }
450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  /* Command block */
452
  .cmd-intro { font-size: 14px; color: var(--text-secondary); margin: var(--s-2) 0 var(--s-3); }
453
  .cmd { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-3); }
@@ -470,7 +510,17 @@ details.disc > summary:hover { color: var(--text-primary); }
470
  .rl-grid { stroke: var(--border); stroke-width: 1; opacity: .5; }
471
  .rl-tick { fill: var(--text-muted); font: 500 10.5px var(--font-body); }
472
  .rl-axis { fill: var(--text-secondary); font: 600 11.5px var(--font-body); }
473
- .rl-dot { opacity: .6; }
 
 
 
 
 
 
 
 
 
 
474
  .rl-p1 { fill: #2DD4BF; } /* ~1B runs: teal */
475
  .rl-p8 { fill: #F472B6; } /* ~8B runs: pink */
476
  .rl-p14 { fill: #93C5FD; } /* ~14B runs: light blue */
@@ -543,6 +593,9 @@ details.disc > summary:hover { color: var(--text-primary); }
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); }
 
448
  .tool .tinstall { font-size: 12.5px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
449
  .tool .tinstall .ic { font-size: 14px; }
450
 
451
+ /* Buy advice: per-platform, click-to-expand cards */
452
+ .buy-platform { margin-top: var(--s-5); }
453
+ .bp-head { display: flex; align-items: center; gap: 9px; }
454
+ .bp-head .ic { font-size: 19px; color: var(--text-secondary); }
455
+ .bp-head .ic.brand { color: var(--text-primary); }
456
+ .bp-name { font-family: var(--font-head); font-weight: 800; font-size: 16px; }
457
+ .bp-blurb { font-size: 13px; color: var(--text-muted); margin: 4px 0 var(--s-3); line-height: 1.5; }
458
+ .buy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-3); }
459
+ @media (max-width: 620px) { .buy-grid { grid-template-columns: 1fr; } }
460
+ .buy-card {
461
+ background: var(--bg-raised); border: 1px solid var(--border);
462
+ border-radius: var(--r-md); padding: var(--s-3) var(--s-4);
463
+ }
464
+ .buy-card.empty { opacity: .7; }
465
+ .bc-head { display: flex; align-items: center; justify-content: space-between; }
466
+ .bc-kind { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); }
467
+ .bc-kind.min { color: var(--warn); } .bc-kind.comfy { color: var(--ok); }
468
+ .bc-label { font-family: var(--font-head); font-weight: 700; font-size: 15px; margin: 6px 0; }
469
+ .bc-runs { font-size: 13.5px; color: var(--text-secondary); }
470
+ .bc-runs b { color: var(--text-primary); }
471
+ .bc-none { font-size: 13px; color: var(--text-muted); margin: 6px 0 0; line-height: 1.5; }
472
+ .bc-goals { margin: 6px 0 0; padding: 0; list-style: none; font-size: 13px; }
473
+ .bc-goals li { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-top: 1px solid var(--border); }
474
+ .bc-goals li:first-child { border-top: 0; }
475
+ .bc-goals b { color: var(--text-primary); font-weight: 600; text-align: right; }
476
+ .bc-more {
477
+ margin-top: var(--s-3); background: none; border: 0; cursor: pointer; padding: 0;
478
+ color: var(--accent); font-size: 12.5px; font-weight: 600;
479
+ display: inline-flex; align-items: center; gap: 5px;
480
+ }
481
+ .bc-more .chev { font-size: 14px; transition: transform .2s; }
482
+ .bc-more.open .chev { transform: rotate(180deg); }
483
+ .bc-detail { margin-top: var(--s-3); border-top: 1px solid var(--border); padding-top: var(--s-3); }
484
+ .bc-spec {
485
+ font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12px;
486
+ color: var(--text-secondary); background: var(--bg-base);
487
+ border: 1px solid var(--border); border-radius: var(--r-sm); padding: 7px 10px; margin-bottom: var(--s-2);
488
+ }
489
+ .bc-detail p { font-size: 13px; color: var(--text-secondary); line-height: 1.6; margin: 0; }
490
+
491
  /* Command block */
492
  .cmd-intro { font-size: 14px; color: var(--text-secondary); margin: var(--s-2) 0 var(--s-3); }
493
  .cmd { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-3); }
 
510
  .rl-grid { stroke: var(--border); stroke-width: 1; opacity: .5; }
511
  .rl-tick { fill: var(--text-muted); font: 500 10.5px var(--font-body); }
512
  .rl-axis { fill: var(--text-secondary); font: 600 11.5px var(--font-body); }
513
+ .rl-dot { opacity: .6; transition: r .1s, opacity .1s; cursor: pointer; }
514
+ .rl-dot:hover { opacity: 1; r: 4.5; }
515
+ .rl-you { cursor: pointer; }
516
+ /* Hover info bubble for chart points */
517
+ .rl-tip {
518
+ position: absolute; z-index: 20; pointer-events: none;
519
+ background: var(--bg-raised); border: 1px solid var(--border-hi);
520
+ border-radius: var(--r-sm); padding: 7px 10px; font-size: 12px; line-height: 1.5;
521
+ color: var(--text-primary); box-shadow: var(--shadow-lg); max-width: 200px;
522
+ }
523
+ .rl-tip-sub { color: var(--text-muted); }
524
  .rl-p1 { fill: #2DD4BF; } /* ~1B runs: teal */
525
  .rl-p8 { fill: #F472B6; } /* ~8B runs: pink */
526
  .rl-p14 { fill: #93C5FD; } /* ~14B runs: light blue */
 
593
  border-radius: var(--r-md); padding: var(--s-3) var(--s-4); margin-bottom: var(--s-4);
594
  }
595
  .lookup-banner p { color: var(--text-secondary); font-size: 13.5px; margin: 0; }
596
+ .lookup-banner.estimate { background: var(--warn-soft); border-color: var(--warn); }
597
+ /* When the goal is "Custom", the model box is the expected input, not optional. */
598
+ #repo-field.required input { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
599
 
600
  /* Ask a follow-up (the model brick) */
601
  .ask-row { display: flex; gap: var(--s-2); }