"""Centauri Cognitive Simulator. Play a psychology experiment trial by trial while small foundation models of human cognition (Centauri / Qwentaur LoRA adapters over Qwen3-Base, Oh & Gobet 2026) predict what a *human* would do next. Everything the models see is plain Psych-101-formatted text, exactly as in training: choices are wrapped in `<<...>>` and the model's choice distribution is read off the next-token logits right after `You press <<`. """ import os os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") import spaces # noqa: E402 (must precede torch / transformers) import html import json import math import random import re import time from typing import Any, Dict, List, Optional, Tuple import gradio as gr import torch from peft import PeftModel from transformers import AutoModelForCausalLM, AutoTokenizer # -------------------------------------------------------------------------------------- # Models # -------------------------------------------------------------------------------------- PRIMARY = "socius/Qwentaur-8B-LoRA-r16" SMALL = "socius/Qwentaur-0.6B-LoRA-r16" BASE_8B = "unsloth/Qwen3-8B-Base" BASE_06B = "unsloth/Qwen3-0.6B-Base" # key -> display metadata. "big"/"small" are the fine-tuned adapters, "raw" is the # 8B base model with the adapter switched off (free — same weights in VRAM). PREDICTORS = [ ("big", "Qwentaur-8B", "#4f46e5", "LoRA r=16 on Qwen3-8B-Base"), ("small", "Qwentaur-0.6B", "#0d9488", "LoRA r=16 on Qwen3-0.6B-Base"), ("raw", "Qwen3-8B-Base", "#a1a1aa", "no fine-tuning (adapter off)"), ] PRED_LABEL = {k: label for k, label, _, _ in PREDICTORS} PRED_COLOR = {k: color for k, _, color, _ in PREDICTORS} def _load(base_id: str, adapter_id: str): tok = AutoTokenizer.from_pretrained(adapter_id) model = AutoModelForCausalLM.from_pretrained(base_id, dtype=torch.bfloat16) # torch_device="cpu" is required on ZeroGPU: peft otherwise asks safetensors to # materialise the adapter straight on CUDA at import time, when no GPU is attached. try: model = PeftModel.from_pretrained(model, adapter_id, torch_device="cpu") except TypeError: model = PeftModel.from_pretrained(model, adapter_id) model.eval() model = model.to("cuda") return tok, model print("Loading Qwentaur-8B (Qwen3-8B-Base + LoRA r=16) ...", flush=True) TOK_BIG, MODEL_BIG = _load(BASE_8B, PRIMARY) print("Loading Qwentaur-0.6B (Qwen3-0.6B-Base + LoRA r=16) ...", flush=True) TOK_SMALL, MODEL_SMALL = _load(BASE_06B, SMALL) print("Models ready.", flush=True) # Single-token uppercase letters, shared by both (identical Qwen3) tokenizers. LETTER_POOL = [ c for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if len(TOK_BIG(c, add_special_tokens=False).input_ids) == 1 and len(TOK_SMALL(c, add_special_tokens=False).input_ids) == 1 ] print(f"{len(LETTER_POOL)} single-token option letters available.", flush=True) MAX_SCORE_TOKENS = 3072 @torch.no_grad() def _next_letter_probs(model, tok, prefix: str, letters: List[str]) -> Dict[str, float]: """Renormalised P(letter) read off the next-token logits after `... You press <<`.""" ids = tok(prefix, return_tensors="pt", add_special_tokens=False).input_ids.to("cuda") if ids.shape[1] > MAX_SCORE_TOKENS: ids = ids[:, -MAX_SCORE_TOKENS:] logits = model(ids).logits[0, -1].float() logp = torch.log_softmax(logits, dim=-1) raw = {} for letter in letters: tid = tok(letter, add_special_tokens=False).input_ids[0] raw[letter] = float(logp[tid]) mx = max(raw.values()) ex = {k: math.exp(v - mx) for k, v in raw.items()} z = sum(ex.values()) return {k: v / z for k, v in ex.items()} def _predict_all(prefix: str, letters: List[str]) -> Dict[str, Dict[str, float]]: """Choice distributions from all three predictors for one upcoming choice.""" out = {"big": _next_letter_probs(MODEL_BIG, TOK_BIG, prefix, letters)} with MODEL_BIG.disable_adapter(): out["raw"] = _next_letter_probs(MODEL_BIG, TOK_BIG, prefix, letters) out["small"] = _next_letter_probs(MODEL_SMALL, TOK_SMALL, prefix, letters) return out # -------------------------------------------------------------------------------------- # Psych-101 task environments # # Wording is copied verbatim from the Psych-101 transcripts (Binz et al.) so the models # see text drawn from exactly the distribution they were fine-tuned on. Only the option # letters are re-randomised per session, as in the original dataset. # -------------------------------------------------------------------------------------- TASK_ORDER = ["bandit", "igt", "itc", "cue"] TASK_LABELS = { "bandit": "🎰 Horizon task — explore or exploit (Wilson et al., 2014)", "igt": "🃏 Iowa Gambling Task — learning under risk (Steingroever et al., 2015)", "itc": "⏳ Intertemporal choice — patience (Ruggeri et al., 2022)", "cue": "🔍 Multi-attribute inference — cue integration (Hilbig & Moshagen, 2014)", } LABEL_TO_TASK = {v: k for k, v in TASK_LABELS.items()} IGT_DECKS = { # (win, [loss schedule over a block of 10 cards]) — classic Bechara payoffs "bad_freq": (100.0, [0, 0, 150, 0, 300, 0, 200, 0, 250, 350]), "bad_rare": (100.0, [0, 0, 0, 0, 0, 0, 0, 0, 0, 1250]), "good_freq": (50.0, [0, 0, 50, 0, 50, 0, 50, 0, 50, 50]), "good_rare": (50.0, [0, 0, 0, 0, 0, 0, 0, 0, 0, 250]), } # The ten trials of the Ruggeri et al. intertemporal-choice block, verbatim. ITC_TRIALS = [ ("receiving 500$ immediately", "receiving 550$ in one year"), ("receiving 500$ immediately", "receiving 600$ in one year"), ("receiving 500$ immediately", "receiving 750$ in one year"), ("paying 500$ immediately", "paying 550$ in one year"), ("paying 500$ immediately", "paying 510$ in one year"), ("paying 500$ immediately", "paying 505$ in one year"), ("receiving 5000$ immediately", "receiving 5500$ in one year"), ("receiving 5000$ immediately", "receiving 6000$ in one year"), ("receiving 5000$ immediately", "receiving 7500$ in one year"), ("receiving 500$ in one year", "receiving 750$ in two years"), ] CUE_VALIDITIES = [0.9, 0.8, 0.7, 0.6] def new_session(task: str, seed: int, blind: bool) -> Dict[str, Any]: rng = random.Random(seed) st: Dict[str, Any] = { "task": task, "seed": int(seed), "blind": bool(blind), "trial": 0, "log": [], "pred": {}, "done": False, } if task == "bandit": letters = rng.sample(LETTER_POOL, 2) st["letters"] = letters low = rng.randint(30, 58) delta = rng.choice([4, 8, 12, 20, 30]) means = [low, low + delta] rng.shuffle(means) # Pre-generate the full (counterfactual) reward table for 4 instructed + 6 free trials. rewards = [ [max(1, min(99, int(round(rng.gauss(m, 8))))) for _ in range(10)] for m in means ] forced = ([0] * 3 + [1]) if rng.random() < 0.5 else [0, 0, 1, 1] rng.shuffle(forced) st["plan"] = {"rewards": rewards, "forced": forced, "means": means} st["n_trials"] = 6 st["text"] = ( f"You are participating in multiple games involving two slot machines, labeled {letters[0]} and {letters[1]}.\n" "The two slot machines are different across different games.\n" "Each time you choose a slot machine, you get some points.\n" "You choose a slot machine by pressing the corresponding key.\n" "Each slot machine tends to pay out about the same amount of points on average.\n" "Your goal is to choose the slot machines that will give you the most points across the experiment.\n" "The first 4 trials in each game are instructed trials where you will be told which slot machine to choose.\n" "After these instructed trials, you will have the freedom to choose for either 1 or 6 trials.\n" "\nGame 1. There are 10 trials in this game.\n" ) counts = [0, 0] observed: List[List[int]] = [[], []] for arm in forced: r = rewards[arm][counts[arm]] counts[arm] += 1 observed[arm].append(r) st["text"] += f"You are instructed to press {letters[arm]} and get {r} points.\n" st["counts"] = counts st["observed"] = observed st["total"] = sum(sum(o) for o in observed) elif task == "igt": letters = rng.sample(LETTER_POOL, 4) st["letters"] = letters kinds = list(IGT_DECKS.keys()) rng.shuffle(kinds) schedule = {} for letter, kind in zip(letters, kinds): win, losses = IGT_DECKS[kind] cards = [] for _ in range(4): # 40 cards per deck is plenty for 30 trials block = list(losses) rng.shuffle(block) cards += [(win, float(x)) for x in block] schedule[letter] = cards st["plan"] = {"schedule": schedule, "kinds": dict(zip(letters, kinds))} st["n_trials"] = 30 st["counts"] = {letter: 0 for letter in letters} st["net"] = {letter: 0.0 for letter in letters} st["balance"] = 2000.0 st["text"] = ( f"You see in front of you four decks of cards labeled {letters[0]}, {letters[1]}, {letters[2]}, and {letters[3]}.\n" "You get a loan of 2000$ of play money.\n" "You have to select one card at a time, from any of the four decks, for 100 trials.\n" "You select a card from a deck by pressing the corresponding key.\n" "After turning a card, you win some money, the amount varies with the deck.\n" "You sometimes also have to pay a penalty, which also varies with the deck.\n" "Your goal is to maximize profit on the loan of the play money.\n\n" ) elif task == "itc": letters = rng.sample(LETTER_POOL, 2) st["letters"] = letters st["plan"] = {"trials": ITC_TRIALS} st["n_trials"] = len(ITC_TRIALS) st["patient"] = 0 st["text"] = ( f"In the following you will be presented with multiple choices between two options {letters[0]} and {letters[1]}.\n" "Please name which option you would prefer by pressing the corresponding key.\n\n" ) elif task == "cue": letters = rng.sample(LETTER_POOL, 2) st["letters"] = letters stimuli = [] while len(stimuli) < 20: a = [rng.randint(0, 1) for _ in range(4)] b = [rng.randint(0, 1) for _ in range(4)] if a != b: stimuli.append([a, b]) st["plan"] = {"stimuli": stimuli} st["n_trials"] = 20 st["optimal"] = 0 st["text"] = ( f"You are repeatedly presented with two options, labeled {letters[0]} and {letters[1]}.\n" "Each option represents a fictitious product and you have to infer which product is superior in terms of quality.\n" "You select a product by pressing the corresponding key.\n" "For each decision, you are provided with four expert ratings (with 1 representing a positive and 0 representing a negative rating).\n" "The four experts differ in their validity.\n" "The ratings of experts are given in descending order of their validity (having validities of 90%, 80%, 70%, and 60%).\n\n" ) else: raise ValueError(task) return st def pre_text(st: Dict[str, Any]) -> str: """Text of the current trial up to (not including) ` <<`.""" task, letters, t = st["task"], st["letters"], st["trial"] if task in ("bandit", "igt"): return "You press" if task == "itc": a, b = st["plan"]["trials"][t] return ( f"You have the choice between {a} (press {letters[0]}) or " f"{b} (press {letters[1]}). You press" ) if task == "cue": a, b = st["plan"]["stimuli"][t] fmt = lambda v: "[" + " ".join(str(x) for x in v) + "]" # noqa: E731 return ( f"Product {letters[0]} ratings: {fmt(a)}. " f"Product {letters[1]} ratings: {fmt(b)}. You press" ) raise ValueError(task) def score_prefix(st: Dict[str, Any]) -> str: return st["text"] + pre_text(st) + " <<" def apply_choice(st: Dict[str, Any], letter: str) -> str: """Append the completed trial line to the transcript; returns a plain-text outcome.""" task, letters, t = st["task"], st["letters"], st["trial"] line = pre_text(st) + f" <<{letter}>>" if task == "bandit": arm = letters.index(letter) r = st["plan"]["rewards"][arm][st["counts"][arm]] st["counts"][arm] += 1 st["observed"][arm].append(r) st["total"] += r line += f" and get {r} points.\n" outcome = f"Machine {letter} paid {r} points." elif task == "igt": win, loss = st["plan"]["schedule"][letter][st["counts"][letter]] st["counts"][letter] += 1 st["net"][letter] += win - loss st["balance"] += win - loss line += f". You win {win:.1f}$ and lose {loss:.1f}$.\n" outcome = f"Deck {letter}: won ${win:.0f}" + (f", lost ${loss:.0f}" if loss else ", no penalty") elif task == "itc": if letter == letters[1]: st["patient"] += 1 line += ".\n" outcome = f"You chose option {letter}." elif task == "cue": a, b = st["plan"]["stimuli"][t] wa = sum(v * math.log(p / (1 - p)) for v, p in zip(a, CUE_VALIDITIES)) wb = sum(v * math.log(p / (1 - p)) for v, p in zip(b, CUE_VALIDITIES)) best = letters[0] if wa >= wb else letters[1] if letter == best: st["optimal"] += 1 line += ".\n" outcome = ( f"Optimal cue integration would pick {best}." if letter != best else f"{letter} is also what optimal cue integration picks." ) else: raise ValueError(task) st["text"] += line st["trial"] += 1 return outcome # -------------------------------------------------------------------------------------- # Rendering # -------------------------------------------------------------------------------------- CSS_BLOCK = """ """ def _pips(vals: List[int]) -> str: out = [] for v, p in zip(vals, CUE_VALIDITIES): col = "#16a34a" if v else "#d4d4d8" out.append( f"" ) return "".join(out) def render_board(st: Optional[Dict[str, Any]], outcome: str = "") -> str: if st is None: return ( CSS_BLOCK + "
No session yet
" "
Pick an experiment above and press Start experiment.
" ) task, letters, t, n = st["task"], st["letters"], st["trial"], st["n_trials"] done = st["trial"] >= n cards = [] if task == "bandit": head = ( f"Game 1 · free choice {min(t + 1, n)} of {n} · {st['total']} points so far" if not done else f"Game over · {st['total']} points" ) for i, letter in enumerate(letters): obs = st["observed"][i] seen = ", ".join(str(x) for x in obs) if obs else "never played" avg = f"mean {sum(obs)/len(obs):.1f}" if obs else "unknown" cards.append((letter, f"slot machine {letter}", [f"played {st['counts'][i]}×", seen, avg])) elif task == "igt": head = ( f"Card {min(t + 1, n)} of {n} · balance ${st['balance']:.0f}" if not done else f"Finished · balance ${st['balance']:.0f}" ) for letter in letters: cards.append( ( letter, f"deck {letter}", [f"played {st['counts'][letter]}×", f"net ${st['net'][letter]:+.0f}"], ) ) elif task == "itc": head = f"Choice {min(t + 1, n)} of {n} · you took the later option {st['patient']}×" if done: head = f"Finished · you took the later option {st['patient']}/{n} times" trial = st["plan"]["trials"][min(t, n - 1)] for j, (letter, opt) in enumerate(zip(letters, trial)): cards.append((letter, opt, ["the sooner option" if j == 0 else "the later option"])) else: # cue head = f"Decision {min(t + 1, n)} of {n} · matched optimal integration {st['optimal']}×" if done: head = f"Finished · matched optimal integration {st['optimal']}/{n} times" stim = st["plan"]["stimuli"][min(t, n - 1)] for letter, vals in zip(letters, stim): cards.append((letter, f"product {letter}", [_pips(vals), "experts 90/80/70/60%"])) last_pick = st["log"][-1]["choice"] if st["log"] else None body = [] for letter, title, lines in cards: sel = " sel" if letter == last_pick else "" subs = "
".join(lines) body.append( f"
{html.escape(letter)}
" f"
{title}
{subs}
" ) out = f"
{html.escape(outcome)}
" if outcome else "" return ( CSS_BLOCK + f"
{head}
" + f"
{''.join(body)}
{out}
" ) def _bars(probs: Dict[str, float], color: str, letters: List[str], pick: Optional[str]) -> str: rows = [] top = max(probs, key=probs.get) for letter in letters: p = probs[letter] mark = " ← you" if letter == pick else "" star = "★" if letter == top else "" rows.append( f"
{html.escape(letter)}
" f"
" f"
{p*100:.1f}%
{star}{mark}
" ) return "".join(rows) def render_predictions(st: Optional[Dict[str, Any]]) -> str: if st is None: return ( CSS_BLOCK + "
Model predictions
" "
Start a session to see what each model expects a human to do.
" ) letters, n_opt = st["letters"], len(st["letters"]) done = st["trial"] >= st["n_trials"] chance = 1.0 / n_opt blocks = [] if st["pred"] and not done and not st["blind"]: title = f"Predicted next choice · trial {st['trial'] + 1}" for key, label, color, note in PREDICTORS: probs = st["pred"][key] blocks.append( f"
{label}" f"{note}
{_bars(probs, color, letters, None)}
" ) elif st["log"]: title = "Last trial · what each model expected" rec = st["log"][-1] for key, label, color, note in PREDICTORS: probs = rec["probs"][key] blocks.append( f"
{label}" f"{note}
" f"{_bars(probs, color, letters, rec['choice'])}
" ) else: title = "Model predictions" blocks.append("
Waiting for the first trial…
") # scoreboard table = "" if st["log"]: rows = [] for key, label, _color, _note in PREDICTORS: nll = [-math.log(max(r["probs"][key][r["choice"]], 1e-9)) for r in st["log"]] hits = sum(1 for r in st["log"] if max(r["probs"][key], key=r["probs"][key].get) == r["choice"]) mean_nll = sum(nll) / len(nll) pr2 = 1.0 - mean_nll / math.log(n_opt) rows.append( f"{label}{mean_nll:.3f}{pr2*100:.0f}%" f"{hits}/{len(st['log'])}" ) rows.append( f"random guessing{math.log(n_opt):.3f}0%" f"{len(st['log'])/n_opt:.1f}/{len(st['log'])}" ) table = ( "" f"{''.join(rows)}
predictorlosspseudo-R²top-1 hits
" "
loss = mean negative log-likelihood of your choices " f"(nats/choice; {math.log(n_opt):.3f} = chance with {n_opt} options). " "This is the metric the paper reports on Psych-101.
" ) return ( CSS_BLOCK + f"
{title}
{''.join(blocks)}{table}" + (f"
Chance level is {chance*100:.0f}% per option.
" if not st["log"] else "") + "
" ) def render_chart(st: Optional[Dict[str, Any]]) -> str: if st is None or not st["log"]: return "" n = len(st["log"]) n_opt = len(st["letters"]) W, H, PL, PR, PT, PB = 660, 190, 34, 12, 14, 26 iw, ih = W - PL - PR, H - PT - PB def x(i): return PL + (iw * (i / max(1, n - 1)) if n > 1 else iw / 2) def y(p): return PT + ih * (1 - p) parts = [f""] for gy in (0.0, 0.25, 0.5, 0.75, 1.0): parts.append( f"" f"{int(gy*100)}%" ) ch = 1.0 / n_opt parts.append( f"" f"chance" ) for key, label, color, _note in PREDICTORS: pts = " ".join( f"{x(i):.1f},{y(r['probs'][key][r['choice']]):.1f}" for i, r in enumerate(st["log"]) ) parts.append( f"" ) for i, r in enumerate(st["log"]): parts.append( f"" ) parts.append( f"trial 1" f"trial {n}" ) legend = " ".join( f"" f"{l}" for _k, l, c, _n in PREDICTORS ) return ( CSS_BLOCK + "
Probability each model assigned to the choice " "you actually made
" + "".join(parts) + f"
{legend}
" ) def button_updates(st: Optional[Dict[str, Any]]) -> List[Any]: ups = [] for i in range(4): if st is None or st["trial"] >= st["n_trials"] or i >= len(st["letters"]): ups.append(gr.update(visible=False)) else: ups.append(gr.update(visible=True, value=f"Press {st['letters'][i]}")) return ups # -------------------------------------------------------------------------------------- # Interactive-session handlers (GPU) # -------------------------------------------------------------------------------------- @spaces.GPU(duration=15) def start_session( task_label: str, seed: float = 0, randomize_seed: bool = True, blind: bool = False ) -> Tuple[Any, ...]: """Start a fresh cognitive-experiment session and predict the first choice. Args: task_label: which Psych-101 experiment to run. seed: RNG seed for the experiment (stimuli, payoffs, option letters). randomize_seed: draw a new random seed instead of using `seed`. blind: hide the model predictions until after each trial is over. """ task = LABEL_TO_TASK.get(task_label, "bandit") seed = random.randint(0, 2**31 - 1) if randomize_seed else int(seed) st = new_session(task, seed, blind) st["pred"] = _predict_all(score_prefix(st), st["letters"]) return ( st, render_board(st), render_predictions(st), render_chart(st), st["text"] + pre_text(st) + " <<", gr.update(value=seed), *button_updates(st), ) @spaces.GPU(duration=15) def choose(st: Optional[Dict[str, Any]], index: int = 0) -> Tuple[Any, ...]: """Register the human's choice for the current trial and predict the next one. Args: st: opaque session state. index: which of the on-screen options was pressed. """ if st is None or st["trial"] >= st["n_trials"]: return ( st, render_board(st), render_predictions(st), render_chart(st), "" if st is None else st["text"], gr.update(), *button_updates(st), ) letter = st["letters"][int(index)] st["log"].append({"trial": st["trial"], "choice": letter, "probs": st["pred"]}) outcome = apply_choice(st, letter) if st["trial"] < st["n_trials"]: st["pred"] = _predict_all(score_prefix(st), st["letters"]) transcript = st["text"] + pre_text(st) + " <<" else: st["pred"] = {} st["done"] = True transcript = st["text"] outcome += " Session complete — start another to compare." return ( st, render_board(st, outcome), render_predictions(st), render_chart(st), transcript, gr.update(), *button_updates(st), ) # -------------------------------------------------------------------------------------- # Tab 2 — score a real Psych-101 transcript (the paper's own evaluation metric) # -------------------------------------------------------------------------------------- with open("psych101_examples.json") as f: EXAMPLES: Dict[str, Dict[str, str]] = json.load(f) EXAMPLE_NAMES = list(EXAMPLES) @torch.no_grad() def _score_text(model, tok, text: str) -> Tuple[List[float], List[bool], int]: """Per-choice negative log-likelihood over every `<<...>>` response in `text`. Mirrors the paper's evaluation: `DataCollatorForCompletionOnlyLM` with response template ` <<` and instruction template `>>` keeps loss on exactly the tokens *between* the markers, summed within a response. """ enc = tok(text, return_tensors="pt", add_special_tokens=False, return_offsets_mapping=True) ids = enc.input_ids[:, :MAX_SCORE_TOKENS].to("cuda") offsets = enc.offset_mapping[0][:MAX_SCORE_TOKENS].tolist() logits = model(ids).logits[0] # [T, V], bf16 tgt = ids[0] # Row i-1 predicts token i. Chunked so we never materialise a full [T, vocab] fp32 # tensor (vocab is ~152k, so an unchunked log_softmax is gigabytes). rows, targets = logits[:-1], tgt[1:] lp_parts, hit_parts = [], [] for s in range(0, rows.shape[0], 256): chunk = rows[s : s + 256].float() lp = torch.log_softmax(chunk, dim=-1) lp_parts.append(lp.gather(1, targets[s : s + 256, None]).squeeze(1)) hit_parts.append(chunk.argmax(dim=-1) == targets[s : s + 256]) # tok_lp[i] / argmax_hit[i] describe token i (index 0 is unpredictable -> padded). tok_lp = [0.0] + torch.cat(lp_parts).tolist() argmax_hit = [False] + torch.cat(hit_parts).tolist() nlls: List[float] = [] hits: List[bool] = [] for m in re.finditer(r"<<(.*?)>>", text, flags=re.S): # Score the choice only, never the ` <<` / `>>` markers: the boundary tokens # carry 4-16 nats of pure delimiter surprise and swamp the real signal. start, end = m.start() + 2, m.end() - 2 idxs = [i for i, (a, b) in enumerate(offsets) if b > a and a >= start and b <= end] if not idxs or idxs[0] == 0: continue nlls.append(-sum(tok_lp[i] for i in idxs)) hits.append(bool(argmax_hit[idxs[-1]])) used = int(ids.shape[1]) return nlls, hits, used def _score_impl(transcript: str, colour_by: str) -> Tuple[str, str]: """Shared, undecorated scoring implementation (never call GPU-decorated fns from GPU fns).""" text = (transcript or "").strip() if not text or "<<" not in text: return ( CSS_BLOCK + "
Paste a transcript whose human " "choices are wrapped in << >>.
", "", ) t0 = time.perf_counter() res: Dict[str, Tuple[List[float], List[bool], int]] = {} res["big"] = _score_text(MODEL_BIG, TOK_BIG, text) with MODEL_BIG.disable_adapter(): res["raw"] = _score_text(MODEL_BIG, TOK_BIG, text) res["small"] = _score_text(MODEL_SMALL, TOK_SMALL, text) dt = time.perf_counter() - t0 key = {v: k for k, v in PRED_LABEL.items()}.get(colour_by, "big") nlls = res[key][0] # annotate out, cursor, i = [], 0, 0 for m in re.finditer(r"<<(.*?)>>", text, flags=re.S): out.append(html.escape(text[cursor : m.start()])) if i < len(nlls): p = math.exp(-nlls[i]) hue = 120 * min(max(p, 0.0), 1.0) out.append( f"<<{html.escape(m.group(1))}>>" ) else: out.append(f"<<{html.escape(m.group(1))}>>") cursor = m.end() i += 1 out.append(html.escape(text[cursor:])) rows = [] for k, label, _c, note in PREDICTORS: nl, hit, used = res[k] if not nl: continue rows.append( f"{label} {note}" f"{sum(nl)/len(nl):.3f}" f"{100*sum(hit)/len(hit):.0f}%{len(nl)}" ) truncated = res["big"][2] >= MAX_SCORE_TOKENS table = ( CSS_BLOCK + "
Held-out choice prediction
" "" f"{''.join(rows)}
modelloss (nats/choice)top-1choices
" "
Loss is the mean negative log-likelihood of the human's actual " "choices — the same quantity reported in the paper (lower is more human-like). " f"Scored {res['big'][2]} tokens in {dt:.1f}s" + (" (transcript truncated to fit the context budget)." if truncated else ".") + "
" ) annotated = ( CSS_BLOCK + "
Green = the model expected that choice · " f"red = it was surprised (colours from {html.escape(colour_by)})
" f"
{''.join(out)}
" ) return annotated, table @spaces.GPU(duration=15) def score_transcript(transcript: str, colour_by: str = "Qwentaur-8B") -> Tuple[str, str]: """Score every human choice in a Psych-101-formatted transcript. Args: transcript: text with human choices wrapped in `<<...>>`. colour_by: which model's surprise colours the transcript. Returns: An annotated transcript and a per-model loss table. """ return _score_impl(transcript, colour_by) def load_example(name: str) -> str: """Load one bundled Psych-101 human transcript into the editor.""" rec = EXAMPLES.get(name) or next(iter(EXAMPLES.values())) return rec["text"] @spaces.GPU(duration=15) def run_example(name: str, colour_by: str = "Qwentaur-8B") -> Tuple[str, str, str]: """Load a bundled Psych-101 human session and score it. Args: name: which bundled human session to score. colour_by: which model's surprise colours the transcript. """ text = load_example(name) annotated, table = _score_impl(text, colour_by) return text, annotated, table # -------------------------------------------------------------------------------------- # UI # -------------------------------------------------------------------------------------- CSS = """ #col-container { max-width: 1180px; margin: 0 auto; } .dark .gradio-container { color: var(--body-text-color); } """ INTRO = """# 🧠 Centauri Cognitive Simulator Play a real psychology experiment while three models predict, trial by trial, what a **human** would do next — the fine-tuned **Qwentaur-8B**, the 13× smaller **Qwentaur-0.6B**, and the **un-tuned Qwen3-8B-Base** for contrast. The paper's headline claim is that sub-billion-parameter models already match a 70B Centaur at fitting human choices in-distribution — you can watch that happen here. [Paper](https://huggingface.co/papers/2608.05224) · [Code](https://github.com/socius-org/Centauri) · [Adapters](https://huggingface.co/collections/socius/centauri-6a72e25a4669e413571fe4ac) · [Psych-101](https://huggingface.co/datasets/marcelbinz/Psych-101) """ with gr.Blocks(title="Centauri Cognitive Simulator") as demo: with gr.Column(elem_id="col-container"): gr.Markdown(INTRO) with gr.Tabs(): with gr.Tab("Play an experiment"): # A hidden gr.JSON rather than gr.State: it serialises, so the session # round-trips through the REST / MCP API instead of resetting each call. state = gr.JSON(value=None, visible=False, label="session") with gr.Row(): task_radio = gr.Radio( choices=[TASK_LABELS[k] for k in TASK_ORDER], value=TASK_LABELS["bandit"], label="Experiment", scale=4, ) start_btn = gr.Button("Start experiment", variant="primary", scale=1) with gr.Row(): with gr.Column(scale=3): board = gr.HTML(render_board(None)) with gr.Row(): btns = [ gr.Button("—", visible=False, variant="secondary") for _ in range(4) ] # hidden constants so each button submits its own option index # (and so `index` is a real API argument, unlike a gr.State) idx_boxes = [ gr.Number(value=i, visible=False, precision=0) for i in range(4) ] chart = gr.HTML() with gr.Column(scale=2): preds = gr.HTML(render_predictions(None)) with gr.Accordion("What the models actually see (Psych-101 prompt)", open=False): transcript = gr.Textbox( label="Prompt", show_label=False, lines=14, max_lines=24, buttons=["copy"], interactive=False, ) with gr.Accordion("Advanced", open=False): with gr.Row(): seed_num = gr.Number(label="Seed", value=0, precision=0) rand_seed = gr.Checkbox(label="Randomize seed", value=True) blind_cb = gr.Checkbox( label="Blind mode (reveal predictions only after each trial)", value=False, ) gr.Markdown( "Choice probabilities are read straight off the next-token logits after " "`You press <<`, renormalised over the options — no sampling, no prompt " "engineering. Seeing the predictions before you act can bias you; turn on " "blind mode for a cleaner test of yourself." ) out_common = [ state, board, preds, chart, transcript, seed_num, *btns, ] start_btn.click( start_session, inputs=[task_radio, seed_num, rand_seed, blind_cb], outputs=out_common, api_name="start_session", ) for i, b in enumerate(btns): # One documented /choose endpoint; the other three buttons are the # same call with a different option index, so hide them. In Gradio 6 # `api_name=False` would name them "false", "false_1", ... extra = ( {"api_name": "choose"} if i == 0 else {"api_visibility": "private"} ) b.click( choose, inputs=[state, idx_boxes[i]], outputs=out_common, **extra, ) with gr.Tab("Score real human data"): gr.Markdown( "Score the models the way the paper does: how surprised is each one by the " "choices a **real participant** actually made? These transcripts come " "verbatim from [Psych-101]" "(https://huggingface.co/datasets/marcelbinz/Psych-101) (Binz et al., " "Apache-2.0), truncated to the first few trials." ) with gr.Row(): preset = gr.Dropdown( choices=EXAMPLE_NAMES, value=EXAMPLE_NAMES[0], label="Bundled human session", scale=3, ) colour_by = gr.Dropdown( choices=[label for _k, label, _c, _n in PREDICTORS], value="Qwentaur-8B", label="Colour transcript by", scale=2, ) score_btn = gr.Button("Score choices", variant="primary", scale=1) text_box = gr.Textbox( label="Transcript (editable — paste your own Psych-101-style text)", value=load_example(EXAMPLE_NAMES[0]), lines=10, max_lines=20, ) score_table = gr.HTML() annotated_out = gr.HTML() preset.change(load_example, inputs=[preset], outputs=[text_box]) score_btn.click( score_transcript, inputs=[text_box, colour_by], outputs=[annotated_out, score_table], api_name="score_transcript", ) gr.Examples( examples=[[n] for n in EXAMPLE_NAMES], inputs=[preset], outputs=[text_box, annotated_out, score_table], fn=run_example, cache_examples=True, cache_mode="lazy", label="Real human sessions from Psych-101", ) gr.Markdown( "Models: [socius/Qwentaur-8B-LoRA-r16](https://huggingface.co/socius/Qwentaur-8B-LoRA-r16) " "and [socius/Qwentaur-0.6B-LoRA-r16](https://huggingface.co/socius/Qwentaur-0.6B-LoRA-r16) " "(LoRA adapters on Qwen3-Base, Apache-2.0) from *Small Foundation Models of Human " "Cognition and Behaviour* (Oh & Gobet, 2026). Task wording and the bundled human " "sessions come from Psych-101 (Binz et al., 2025)." ) if __name__ == "__main__": # Gradio 6 moved `theme` / `css` from the Blocks constructor to launch(). demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)