VibeThinker-3B is just Qwen2.5-Coder-3B with a reasoning post-train bolted on. We re-ran it ourselves — one harness, thinking enabled on the big models, two benchmark bugs caught and fixed along the way. The result: it ties a 27B reasoner on AIME and HMMT, and leaps +90 points over its own base. It is also the weakest model here on broad science. Both of those are true, and that's the whole story. Here is exactly what we ran.
The subject against four controls — its own untrained base, a larger off-the-shelf small reasoner, and two models 4× and 9× its size. Same questions, same harness, served-model logged per answer.
Three contest-math sets where reasoning is the whole game, plus a graduate-science set as the knowledge control — the axis where the paper itself admits the model is weak. Identical inputs for every model; the served model is logged on every single answer.
Two headlines do the work: what the training bought (vs its own base), and how it stacks against a model 9× its size. Bars fill to score.
| Model | AIME24 | AIME25 | HMMT25 | GPQA |
|---|---|---|---|---|
| VibeThinker-3B | 93.3% | 93.3% | 70.0% | 62.0% |
| Qwen2.5-Coder-3B · base | 3.3% | 0.0% | 0.0% | 28.0% |
| Qwen3-4B-Thinking | 86.7% | 80.0% | 40.0% | 64.0% |
| Gemma-4-12B | 76.7% | 53.3% | 23.3% | 66.0% |
| Qwen-27B · thinking | 93.3% | 83.3% | 70.0% | 78.0% |
Green = best (or tied-best) in column. The 3B owns every math column; it finishes last among reasoners on GPQA.
You're reading the quick verdict. The full case — the lineup, the protocol, the two benchmark bugs we caught, and the honest caveats — is in the deep dive →
VibeThinker starts from Qwen2.5-Coder-3B and changes nothing but the training. So the gap between these two rows is the cleanest number in the whole study: it is entirely the SSP reasoning post-train, with size held fixed at 3B.
| Benchmark | base 3B | VibeThinker 3B | what training bought |
|---|---|---|---|
| AIME 2024 | 3.3% | 93.3% | +90.0 |
| AIME 2025 | 0.0% | 93.3% | +93.3 |
| HMMT 2025 | 0.0% | 70.0% | +70.0 |
| GPQA-Diamond | 28.0% | 62.0% | +34.0 |
The base sits at random chance on GPQA (28% ≈ the 25% you'd get guessing) and flat zero on the 2025 math. This is the democratization result in one table: frontier-grade contest reasoning, distilled into weights a consumer can run on a single card.
A clean-looking leaderboard hides bugs. Re-running it ourselves surfaced two that would have shipped the wrong verdict — one inflating a number, one deflating it.
16 of 30 HMMT answers aren't integers — they're fractions and radicals like 9√23⁄23. The naive grader fell back to exact-string match and marked correct answers wrong. We swapped in symbolic equivalence checking; VibeThinker's HMMT rose from a deflated 63% to its true 70%.
All 198 gold answers sat in position A — the options were never shuffled. That doesn't measure knowledge, it measures A-bias. We reshuffled every item to a fixed-but-uniform position and re-ran. VibeThinker's spread came out even across A/B/C/D — so its score is real reasoning, not positional luck — and the honest number settled at 62% (the inflated original read 72%).
The 27B's deployed config runs with thinking disabled — which would have handed VibeThinker an unearned win on math. We overrode it to think for every math item. That's why the headline is a fair tie, not a rigged one.
The served-model id is logged on every item — no self-comparison. The base model's first run errored on all 140 requests (its window rejected our token budget); we caught it from an impossible 0% and re-ran correctly. VibeThinker's verbosity blew a too-small GPQA budget on 16 items; we raised it and re-ran. Silent truncation never became a fake score.
The same 3B that ties a 27B on olympiad math finishes dead last among the reasoners on graduate science — below the 4B, below the 12B, sixteen points under the 27B. It didn't get smaller-but-general. It got narrow-but-deep.
This is the real lesson, and it cuts both ways. Reasoning can be trained into a tiny model — scale isn't the moat people think. Knowledge breadth still scales with parameters: the 27B's edge shows up exactly, and only, on the axis that rewards remembering more.
The viral claim is "beats frontier models" — but every model above is open weights. To close that gap we ran the same 140-item battery, the same grader, through two actual frontier models: Claude Haiku 4.5 (the small tier) and Claude Sonnet 4.6 (the strong tier). Closed-book, no tools, reasoning only — exactly the inputs the open models got.
| Model | AIME24 | AIME25 | HMMT25 | GPQA |
|---|---|---|---|---|
| VibeThinker-3B | 93.3% | 93.3% | 70.0% | 62.0% |
| Claude Haiku 4.5 · small frontier | 33.3% | 20.0% | 10.0% | 64.0% |
| Claude Sonnet 4.6 · strong frontier | 96.7% | 83.3% | 83.3% | 80.0% |
Green = best in column. The 3B beats the small frontier model on every math axis (+60 / +73 / +60) and ties it on knowledge. On AIME it goes toe-to-toe with the strong one — a one-item gap on 2024, and it actually leads on 2025. Sonnet pulls clear only on the harder HMMT set and on GPQA.
So "beats frontier models" is half true — and the honest half is the better story: a 3B that matches or beats a strong frontier model on AIME-class math and routs a small one, then loses, fair and square, on harder olympiad math and on what it simply knows.
Two caveats, in plain sight. The Claude models are capability reference points, not contamination-controlled — we have zero visibility into their training and AIME/GPQA are public, so read their scores as a generous bound, not a clean one. And they ran without extended thinking (in-context reasoning only) — yet Sonnet still cleared 83–97 on math, which is the tell that the setup is fair and the small model's low scores are real capability, not a handicap.
VibeThinker buys its reasoning with tokens — chains that run tens of thousands long, occasionally past a 60k cap. Truncation counts are the tell: the models that run away vs the one that stays tight. (Truncated items across all four benchmarks, out of 140.)
VibeThinker's reasoning isn't free — on the hardest items it generates 10k–60k-token chains, and a handful overrun even a generous budget. Notably, raising the cap on those runaways barely moved its score: the traces that won't terminate are usually the ones it was going to miss anyway. Length is the cost of its method, not the source of its edge.
This is avg@1 on 30–50 item sets — confidence intervals around ±9–13%. So the right claim is "matches/rivals" the 27B, not "beats": the AIME tie and the +10 on AIME25 are within noise of each other. The +90 over its own base is far beyond any noise.
The two big models ran as their real local GGUF quants on llama.cpp; the small models ran BF16 on vLLM. That's the honest "as a consumer runs them" setup — but it's a mixed-precision comparison, and the 27B was GGUF-only so we couldn't cross-check it on a second engine.
The viral "beats Gemini 3 / Opus 4.5" posts are test-time-scaled consensus numbers on a narrow axis — not pass@1. Our clean single-sample number is lower and more honest: it rivals far larger models on contest math, full stop. Not a frontier generalist.
"Good" here means good at verifiable math reasoning. GPQA shows the breadth limit; this is a specialist. One harness, one quant per model, one GPU — a controlled read, not a universal law. Gemma's math is mildly understated by truncation (it never flips a result).
One runner, the exact seeded items every model saw, and every per-item prediction tagged with the served_model that produced it — including the _gpqa_origA.jsonl.bak that proves the all-"A" bug. Read the code below, or take the bundle and re-score it yourself. The only redaction is the llama-swap host.
#!/usr/bin/env python3
"""Convert VibeThinker repo math parquets -> flat jsonl the harness eats.
Source rows (verl format):
prompt: [{"role":"user","content": "<problem ... \\boxed{}>"}]
reward_model: {"ground_truth": "<answer>", "style": "rule"}
Output rows:
{"id","category","question","answer"}
The prompt already carries the "output the final answer within \\boxed{}"
instruction, so we send it verbatim with NO extra system prompt -- identical
input for every model under test (the controlled-input invariant).
"""
import os, json, glob, argparse
import pandas as pd
DEFAULT_SRC = os.path.expanduser(
"~/Documents/AI/VibeThinker/eval/math/data") # adjust on the host
def convert(src, out):
os.makedirs(out, exist_ok=True)
total = 0
for f in sorted(glob.glob(os.path.join(src, "*.parquet"))):
cat = os.path.splitext(os.path.basename(f))[0] # aime / aime25 / hmmt25 / gpqa
df = pd.read_parquet(f)
rows = []
for i, r in df.iterrows():
prompt = r["prompt"]
# prompt is a numpy array / list of {role,content}
content = prompt[0]["content"] if not isinstance(prompt, str) else prompt
gt = r["reward_model"]["ground_truth"]
rows.append({
"id": f"{cat}-{i:03d}",
"category": cat,
"question": content,
"answer": str(gt).strip(),
})
with open(os.path.join(out, f"{cat}.jsonl"), "w") as fh:
for row in rows:
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
print(f"{cat:<8} {len(rows):>4} items -> {cat}.jsonl")
total += len(rows)
print(f"TOTAL {total} items")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--src", default=DEFAULT_SRC)
ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "data"))
a = ap.parse_args()
convert(a.src, a.out)
#!/usr/bin/env python3
"""avg@k eval against an OpenAI-compatible vLLM endpoint, for VibeThinker-3B
and its controls (Qwen2.5-Coder-3B-Instruct, Qwen3-4B-Thinking-2507).
Faithful to the paper's protocol: NO system prompt (the \\boxed instruction is
in the question), temp=1.0, top_p=0.95, top_k=-1, k independent samples/item.
Reports the same metrics the report uses:
pass@1 = mean correctness over all k samples (the headline avg@k number)
pass@k = any of the k correct
cons@k = majority-vote (self-consistency) correctness
Crucially logs finish_reason per sample so truncated traces (finish_reason=
"length") are visible -- a truncated long CoT scores 0 and would otherwise read
as a real miss (the false-zero trap).
Usage (one resident model at a time):
python3 vibe_eval.py --base http://localhost:8000 --model vibethinker-3b \
--tag vibe3b --n 16
python3 vibe_eval.py --compare results/vibe3b.jsonl results/base3b.jsonl
"""
import json, os, re, time, argparse, urllib.request, glob
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
HERE = os.path.dirname(os.path.abspath(__file__))
DATA = os.path.join(HERE, "data")
RES = os.path.join(HERE, "results"); os.makedirs(RES, exist_ok=True)
# ---------- io ----------
def load_items(cats=None):
items = []
for f in sorted(glob.glob(os.path.join(DATA, "*.jsonl"))):
cat = os.path.splitext(os.path.basename(f))[0]
if cats and cat not in cats:
continue
with open(f) as fh:
items += [json.loads(l) for l in fh if l.strip()]
return items
def call_one(base, model, key, question, max_tokens, temp, top_p, timeout, think=False):
# ONE sample per request. llama.cpp's OpenAI endpoint ignores n>1, so we fire
# k independent requests instead (portable across llama.cpp and vLLM).
# No system prompt: identical input for every model.
payload = {"model": model, "temperature": temp, "top_p": top_p,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": question}]}
if think: # override server's enable_thinking:false
payload["chat_template_kwargs"] = {"enable_thinking": True}
body = json.dumps(payload).encode()
req = urllib.request.Request(base.rstrip("/") + "/v1/chat/completions", data=body,
headers={"Content-Type": "application/json",
**({"Authorization": f"Bearer {key}"} if key else {})})
with urllib.request.urlopen(req, timeout=timeout) as r:
d = json.load(r)
ch = d["choices"][0]
msg = ch.get("message", {})
text = msg.get("content") or msg.get("reasoning_content") or ""
return {"text": text, "finish": ch.get("finish_reason")}, d.get("model")
# ---------- grading ----------
def extract(text):
"""Return (pred, from_box). from_box=True means it came from \\boxed{},
which lets math grading apply a STRICT integer parse (reject fractions)."""
b = list(re.finditer(r"\\boxed\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}", text))
if b: return b[-1].group(1).strip(), True
m = list(re.finditer(r"ANSWER:\s*(.+)", text, re.I))
if m: return m[-1].group(1).strip(), False
return (text.strip().splitlines()[-1].strip() if text.strip() else ""), False
def strict_int(s): # boxed content must BE an integer
s = s.replace(",", "").replace("$", "").replace(" ", "").strip()
m = re.fullmatch(r"-?\d+", s) or re.fullmatch(r"(-?\d+)\.0*", s)
return int(m.group(1) if m and m.lastindex else s) if m else None
def loose_int(s): # only for the no-box fallback path
s = s.replace(",", "").replace("$", "").replace("\\", "").strip()
m = re.search(r"-?\d+", s)
return int(m.group(0)) if m else None
try: # symbolic LaTeX equivalence (HMMT non-int answers)
from math_verify import parse as _mv_parse, verify as _mv_verify
_HAS_MV = True
except Exception:
_HAS_MV = False
def math_equiv(pred, gold):
"""Symbolic equivalence for non-integer golds (fractions, radicals, sets).
e.g. \\dfrac{1}{576}==\\frac{1}{576}, 8\\sqrt{10}==\\sqrt{640}. Falls back to
exact-string match if math_verify is unavailable or a parse throws."""
if not _HAS_MV:
return pred.strip() == gold.strip()
try:
return bool(_mv_verify(_mv_parse(f"${gold}$"), _mv_parse(f"${pred}$")))
except Exception:
return pred.strip() == gold.strip()
def grade(category, raw, gold):
pred, from_box = extract(raw)
if category == "gpqa": # multiple-choice A-D
m = re.search(r"\b([A-D])\b", pred.upper()) or re.search(r"([A-D])", pred.upper())
return bool(m) and m.group(1) == gold.strip().upper(), pred
g = strict_int(gold) # aime / hmmt -> integer answers
p = strict_int(pred) if from_box else loose_int(pred)
if g is not None and p is not None:
return p == g, pred
if g is None: # non-integer gold (hmmt fractions/radicals)
return math_equiv(pred, gold), pred
return pred.strip() == gold.strip(), pred
# ---------- run ----------
def run(args):
items = load_items(set(args.cats.split(",")) if args.cats else None)
if args.limit:
items = items[:args.limit]
out = os.path.join(RES, f"{args.tag}.jsonl")
print(f"[{args.tag}] {len(items)} items x avg@{args.n} -> {args.model} @ {args.base}")
# Flatten to (item, sample) tasks -> k independent requests/item (portable).
tasks = [(it, s) for it in items for s in range(args.n)]
print(f"[{args.tag}] {len(items)} items x avg@{args.n} = {len(tasks)} gens "
f"-> {args.model} @ {args.base}")
def do_task(task):
it, _ = task
try:
s, served = call_one(args.base, args.model, args.key, it["question"],
args.max_tokens, args.temp, args.top_p, args.timeout,
think=args.think)
ok, pred = grade(it["category"], s["text"], it["answer"])
return {"id": it["id"], "ok": int(ok), "pred": pred,
"trunc": int(s["finish"] == "length"), "served": served, "err": None}
except Exception as e:
return {"id": it["id"], "ok": 0, "pred": None,
"trunc": 0, "served": None, "err": str(e)}
by_id = {it["id"]: it for it in items}
order = [it["id"] for it in items]
agg = {it["id"]: {"ok": [], "pred": [], "trunc": 0, "err": 0, "served": set()}
for it in items}
def finalize(iid):
it = by_id[iid]; a = agg[iid]; scores = a["ok"]; n = len(scores)
preds = [p for p in a["pred"] if p]
cons_pred = Counter(preds).most_common(1)[0][0] if preds else ""
cons_ok = grade(it["category"], "\\boxed{%s}" % cons_pred, it["answer"])[0] if cons_pred else False
return {"id": iid, "category": it["category"], "answer": it["answer"],
"pass1": (sum(scores) / n if n else 0.0), "passk": int(any(scores)),
"cons": int(cons_ok), "n": n, "trunc": a["trunc"], "err": a["err"],
"pred": cons_pred, "preds": a["pred"], # persist model answer(s) for offline re-grading
"served_model": sorted(a["served"])}
rows, done, total_trunc = [], 0, 0
fout = open(out, "w")
with ThreadPoolExecutor(max_workers=args.concurrency) as ex:
for r in ex.map(do_task, tasks): # yields in task order -> grouped by item
a = agg[r["id"]]
a["ok"].append(r["ok"]); a["pred"].append(r["pred"]); a["trunc"] += r["trunc"]
if r["err"]: a["err"] += 1
if r["served"]: a["served"].add(r["served"])
done += 1
if done % args.n == 0: # one item's k samples are all in
row = finalize(order[done // args.n - 1])
rows.append(row); fout.write(json.dumps(row, ensure_ascii=False) + "\n"); fout.flush()
total_trunc += row["trunc"]
tr = f" *** TRUNC={row['trunc']} ***" if row["trunc"] else ""
er = f" err={row['err']}" if row["err"] else ""
print(f" [{done//args.n:>3}/{len(items)}] {row['pass1']*100:3.0f}% "
f"{row['id']:<12} gold={row['answer'][:8]:<8}{er}{tr}", flush=True)
fout.close()
if total_trunc:
print(f"\n!!! {total_trunc} truncated samples (finish_reason=length) — traces exceeded "
f"the per-slot ctx. Drop llama-server to --parallel 1 and re-run. !!!")
served = sorted({s for r in rows for s in r["served_model"]})
print(f"\nSERVED MODEL(S): {served}")
summarize(rows, args.tag)
print(f"saved -> {out}")
def summarize(rows, tag):
cats = {}
for r in rows:
c = cats.setdefault(r["category"], {"n": 0, "p1": 0.0, "pk": 0, "cons": 0, "tr": 0})
c["n"] += 1; c["p1"] += r["pass1"]; c["pk"] += r["passk"]
c["cons"] += r["cons"]; c["tr"] += r.get("trunc", 0)
print(f"\n=== {tag} ===")
print(f"{'category':<10}{'pass@1':>9}{'pass@k':>9}{'cons@k':>9}{'trunc':>8}")
for c, v in sorted(cats.items()):
n = v["n"]
print(f"{c:<10}{100*v['p1']/n:>8.1f}%{100*v['pk']/n:>8.0f}%{100*v['cons']/n:>8.0f}%{v['tr']:>8}")
def compare(a, b):
def load(p): return {r["id"]: r for r in (json.loads(l) for l in open(p))}
A, B = load(a), load(b)
ta, tb = os.path.basename(a).split(".")[0], os.path.basename(b).split(".")[0]
cats = {}
for i in A:
if i not in B: continue
c = cats.setdefault(A[i]["category"], [0, 0.0, 0.0])
c[0] += 1; c[1] += A[i]["pass1"]; c[2] += B[i]["pass1"]
print(f"{'category':<10}{ta:>10}{tb:>10}{'delta':>9}")
for c, (n, x, y) in sorted(cats.items()):
print(f"{c:<10}{100*x/n:>9.1f}%{100*y/n:>9.1f}%{100*(x-y)/n:>+8.1f}")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--base"); ap.add_argument("--model"); ap.add_argument("--key")
ap.add_argument("--tag", default="run")
ap.add_argument("--n", type=int, default=16, help="samples per item (avg@k)")
ap.add_argument("--limit", type=int, help="cap items per run (e.g. subsample gpqa)")
ap.add_argument("--cats", help="comma list e.g. aime,aime25,hmmt25 (default: all)")
ap.add_argument("--max-tokens", type=int, default=40960, dest="max_tokens")
ap.add_argument("--temp", type=float, default=1.0)
ap.add_argument("--top-p", type=float, default=0.95, dest="top_p")
ap.add_argument("--timeout", type=int, default=1800)
ap.add_argument("--think", action="store_true",
help="send chat_template_kwargs enable_thinking=true (override server default)")
ap.add_argument("--concurrency", type=int, default=4,
help="match llama-server --parallel (4); bump to 16+ for vLLM")
ap.add_argument("--compare", nargs=2)
a = ap.parse_args()
if a.compare: compare(*a.compare)
else: run(a)
#!/usr/bin/env python3
"""Grade the Haiku sub-agent battery against the withheld gold key, reusing the
EXACT grader from vibe_eval (math-verify symbolic equiv for non-int golds, strict
int for AIME, letter-match for GPQA). Each agent returned a JSON object mapping
problem id -> final answer in its last assistant message; we extract that JSON
from the agent transcript files without dumping them into anyone's context.
"""
import json, re, sys, glob, os
from collections import defaultdict
from vibe_eval import grade # reuse identical grading
GOLD = json.load(open("/tmp/haiku_gold.json")) # id -> [category, answer]
TASKDIR = sys.argv[1] # dir holding <agentId>.output files
AGENTS = sys.argv[2:] # agentIds to scan
ID_RE = re.compile(r'^(aime|aime25|hmmt25|gpqa)-\d+$')
def extract_preds(path):
"""Find every JSON object in the transcript whose keys look like problem ids;
merge them (last write wins). Robust to prose around the JSON."""
raw = open(path, encoding="utf-8", errors="ignore").read()
preds = {}
# find balanced-ish {...} blobs; greedy per-line and whole-text candidates
for m in re.finditer(r'\{[^{}]*\}', raw):
blob = m.group(0)
if '"aime' not in blob and '"hmmt' not in blob and '"gpqa' not in blob:
continue
try:
d = json.loads(blob)
except Exception:
continue
for k, v in d.items():
if ID_RE.match(k) and isinstance(v, str):
preds[k] = v
return preds
allpreds = {}
for aid in AGENTS:
p = os.path.join(TASKDIR, f"{aid}.output")
if not os.path.exists(p):
print(f"!! missing {aid}"); continue
allpreds.update(extract_preds(p))
# grade
cats = defaultdict(lambda: {"n": 0, "ok": 0, "miss": 0})
missing = []
for iid, (cat, gold) in GOLD.items():
cats[cat]["n"] += 1
if iid not in allpreds:
cats[cat]["miss"] += 1; missing.append(iid); continue
raw = "\\boxed{%s}" % allpreds[iid] # same wrapping the harness uses for cons grading
ok, _ = grade(cat, raw, gold)
cats[cat]["ok"] += int(ok)
order = ["aime", "aime25", "hmmt25", "gpqa"]
print("\n=== HAIKU 4.5 (closed-book, no-tools, sub-agent path) ===")
print(f"{'category':<10}{'pass@1':>9}{'correct':>9}{'n':>5}{'missing':>9}")
for c in order:
if c not in cats: continue
v = cats[c]; n = v["n"]
print(f"{c:<10}{100*v['ok']/n:>8.1f}%{v['ok']:>9}{n:>5}{v['miss']:>9}")
if missing:
print("\nmissing ids (no answer returned):", missing)
print(f"\ntotal answers parsed: {len(allpreds)}/140")
#!/usr/bin/env bash
# Drive the eval against the llama-swap endpoint on the Ubuntu box.
# llama-swap auto-loads/swaps the backend by the request's "model" field, so you
# do NOT serve models manually -- just run the three tags below in sequence.
#
# Runtime reality (llama.cpp, 5090, --parallel 4, ~500 tok/s aggregate):
# llama.cpp splits -c across slots, so concurrency is capped -- this is MUCH
# slower than vLLM for long reasoning traces. Ballpark:
# math avg@16 (90 items) : ~8h vibe + ~10h qwen3-think + ~1h base ~= 19h
# gpqa avg@16 (198 items) : ~9h each reasoner ~= 20h
# => avg@16 on everything is ~1.5-2 DAYS. So: math stays avg@16 (headline,
# needs tight CI); GPQA drops to avg@4 (it's the control, expected weak).
# Bump N_GPQA if you want, or move to the vLLM path for ~5-10x throughput.
set -euo pipefail
BASE="${BASE:-http://LLAMA_SWAP_HOST:8000}"
N_MATH=16
N_GPQA=4
MATH="aime,aime25,hmmt25"
COMMON="--base $BASE --concurrency 4 --max-tokens 40960"
case "${1:?usage: BASE=... ./run_all.sh vibe3b|base3b|qwen3think}" in
vibe3b)
python3 vibe_eval.py $COMMON --model vibethinker-3b --tag vibe3b --n $N_MATH --temp 1.0 --cats $MATH
python3 vibe_eval.py $COMMON --model vibethinker-3b --tag vibe3b_gpqa --n $N_GPQA --temp 1.0 --cats gpqa ;;
base3b)
python3 vibe_eval.py $COMMON --model base-3b --tag base3b --n $N_MATH --temp 0.7 --cats $MATH
python3 vibe_eval.py $COMMON --model base-3b --tag base3b_gpqa --n $N_GPQA --temp 0.7 --cats gpqa ;;
qwen3think)
python3 vibe_eval.py $COMMON --model qwen3-4b-think --tag qwen3think --n $N_MATH --temp 0.6 --cats $MATH
python3 vibe_eval.py $COMMON --model qwen3-4b-think --tag qwen3think_gpqa --n $N_GPQA --temp 0.6 --cats gpqa ;;
esac