What you'll have at the end
A folder of 17 files. The table below is the whole project; each step adds a few rows to it.
| File | Step | What it does |
|---|---|---|
requirements.txt, config.py, hello_tinker.py | 0–1 | Pinned versions, every setting in one place, API key check |
prep_data.py | 1 | Download HotpotQA, split train/val/test, build the 507k-paragraph corpus |
score.py, test_score.py | 1 | HotpotQA-style Exact Match and F1, 7 tests |
harness.py | 1–2 | Agent loop: model → <search> → results → <answer>, recording every token |
eval.py, show.py | 1–2 | Run a model on a split, score it, total the cost; read the failures |
search.py, test_harness.py | 2 | BM25 over the corpus; 6 harness tests, including a chat-template match test |
make_sft.py, datums.py, train_sft.py, ckpt.py | 3 | Collect correct traces, train LoRA on Tinker, look up checkpoint paths |
train_grpo.py | 4 | Online GRPO inside that same harness |
ablate.py | 5 | Compare runs on the same questions, with bootstrap confidence intervals |
Setup
0.1Create the folder and the Python environment
Use uv (Tinker recommends it too). If you don't have it: curl -LsSf https://astral.sh/uv/install.sh | sh.
mkdir wiki-researcher && cd wiki-researcher
uv venv --python 3.12 .venv
source .venv/bin/activateCreate requirements.txt with exactly the versions that were tested, then install:
tinker==0.36.0
transformers==5.22.1
jinja2==3.1.6
datasets==5.1.0
bm25s==0.3.16
PyStemmer==3.1.0uv pip install -r requirements.txt0.2Get a Tinker API key and set a spending limit
- Go to Tinker Console → Keys and create a key.
- Go to Billing, add a card and set a $40 spending limit. That covers the whole tutorial and stops a buggy loop from running all night.
export TINKER_API_KEY="tml-..." # add this line to ~/.zshrc so you don't retype it0.3One config file, one test call
config.py holds every setting. Change the model or the budget here and every script follows.
"""Shared settings. Every script imports from here, so change a value once."""
MODEL = "Qwen/Qwen3.5-4B" # Tinker ID of the model we post-train
LOCAL_MODEL = "Qwen/Qwen3.5-0.8B" # same chat template; runs free on a laptop or Colab
# Tinker prices for MODEL in USD per 1M tokens (tinker-docs Models & Pricing, 2026-11-01).
PRICE_PREFILL = 0.33
PRICE_SAMPLE = 1.005
PRICE_TRAIN = 0.737
TOP_K = 3 # paragraphs returned by one search
MAX_SEARCHES = 5 # research budget: after this many searches the model must answer
MAX_TOKENS = 512 # max tokens the model may write in one turn
LORA_RANK = 32"""Step 0: check that your key works and the model answers."""
import asyncio
import tinker
from config import MODEL
async def main():
service = tinker.ServiceClient()
client = await service.create_sampling_client_async(base_model=MODEL)
tok = client.get_tokenizer()
prompt = tok.apply_chat_template([{"role": "user", "content": "In one sentence: who wrote Hamlet?"}],
add_generation_prompt=True, enable_thinking=False,
tokenize=True, return_dict=False)
result = await client.sample_async(prompt=tinker.ModelInput.from_ints(prompt), num_samples=1,
sampling_params=tinker.SamplingParams(max_tokens=50, temperature=0.0))
print(tok.decode(result.sequences[0].tokens, skip_special_tokens=True))
asyncio.run(main())python hello_tinker.pyHamlet was written by William Shakespeare.hello_tinker.pyprints an answer- Billing has a spending limit
401/AuthenticationError: you haven't runexport TINKER_API_KEYin this terminalHF_TOKENwarning: safe to ignore, it's just Hugging Face's download rate limit
Eval: data, yardstick, floor score
Before training a single line, lock the test set and have one command that prints the score + the cost. Every later step reruns exactly this command.
1.1Download HotpotQA, split it, build the corpus
The distractor version of HotpotQA ships 10 Wikipedia paragraphs with every question. Pool them all, keep one paragraph per title, and you get a 507k-paragraph "mini Wikipedia" to search. Test and val come from the official dev split, train from the train split, so no question overlaps.
"""Step 1: download HotpotQA, write the question splits and the paragraph corpus.
data/train.jsonl 90k questions to train on
data/val.jsonl 200 questions to pick checkpoints with
data/test.jsonl 500 questions you only open in Step 5
data/corpus.jsonl every Wikipedia paragraph that appears in HotpotQA (one per title)
"""
import hashlib
import json
import os
import random
from datasets import load_dataset
def write(path, rows):
with open(path, "w") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
def question(row):
return {"id": row["id"], "question": row["question"], "answers": [row["answer"]],
"type": row["type"], "level": row["level"]}
os.makedirs("data", exist_ok=True)
train = load_dataset("hotpotqa/hotpot_qa", "distractor", split="train")
dev = load_dataset("hotpotqa/hotpot_qa", "distractor", split="validation")
corpus, seen = [], set()
for split in (train, dev):
for context in split["context"]:
for title, sentences in zip(context["title"], context["sentences"]):
if title not in seen:
seen.add(title)
corpus.append({"id": len(corpus), "title": title, "text": "".join(sentences).strip()})
write("data/corpus.jsonl", corpus)
rng = random.Random(0)
dev_rows = [question(r) for r in dev]
rng.shuffle(dev_rows)
write("data/test.jsonl", dev_rows[:500])
write("data/val.jsonl", dev_rows[500:700])
train_rows = [question(r) for r in train]
rng.shuffle(train_rows)
write("data/train.jsonl", train_rows)
digest = hashlib.sha256(open("data/test.jsonl", "rb").read()).hexdigest()[:16]
print(f"corpus {len(corpus):,} paragraphs | train {len(train_rows):,} | val 200 | test 500")
print(f"test.jsonl sha256 {digest} <- write this down; it must never change")python prep_data.pycorpus 507,494 paragraphs | train 90,447 | val 200 | test 500
test.jsonl sha256 cb4ad042f2c0ef38 <- write this down; it must never changecb4ad042f2c0ef38. If it differs, Hugging Face has changed the dataset version, and your numbers won't be comparable with anyone who ran this before. Put the hash in your project's README.1.2The scoring function and its tests
SQuAD-style normalization (lowercase, strip punctuation and articles), then Exact Match and token-level F1. Yes/no answers must match exactly, just like HotpotQA's official script.
"""Exact Match and F1, normalized the way SQuAD and HotpotQA do it."""
import re
import string
from collections import Counter
def normalize(text):
text = text.lower()
text = "".join(ch for ch in text if ch not in set(string.punctuation))
text = re.sub(r"\b(a|an|the)\b", " ", text)
return " ".join(text.split())
def exact_match(prediction, answers):
return float(any(normalize(prediction) == normalize(a) for a in answers))
def f1(prediction, answers):
best = 0.0
for answer in answers:
pred, gold = normalize(prediction).split(), normalize(answer).split()
# yes/no/noanswer must match exactly, as in the official HotpotQA script
if {" ".join(pred), " ".join(gold)} & {"yes", "no", "noanswer"} and pred != gold:
continue
common = sum((Counter(pred) & Counter(gold)).values())
if common == 0:
continue
precision, recall = common / len(pred), common / len(gold)
best = max(best, 2 * precision * recall / (precision + recall))
return bestimport unittest
from score import exact_match, f1, normalize
class ScoreTest(unittest.TestCase):
def test_articles_and_case(self):
self.assertEqual(exact_match("The United States", ["United States"]), 1.0)
def test_punctuation(self):
self.assertEqual(normalize("Arthur's Magazine."), "arthurs magazine")
def test_partial_credit(self):
self.assertAlmostEqual(f1("President Richard Nixon", ["Richard Nixon"]), 0.8)
def test_yes_no_is_all_or_nothing(self):
self.assertEqual(f1("yes", ["no"]), 0.0)
self.assertEqual(f1("no it was not", ["no"]), 0.0)
self.assertEqual(f1("Yes", ["yes"]), 1.0)
def test_numbers(self):
self.assertEqual(exact_match("1,844", ["1844"]), 1.0)
def test_best_of_several_answers(self):
self.assertEqual(f1("NYC", ["New York City", "NYC"]), 1.0)
def test_empty_prediction(self):
self.assertEqual(f1("", ["Paris"]), 0.0)
if __name__ == "__main__":
unittest.main()python -m unittest test_score.......
----------------------------------------------------------------------
Ran 7 tests in 0.000s
OK1.3Harness: one episode, every token recorded
The most important file in the project. Eval, SFT data collection and RL rollouts all call the same run_episode function, so the model is trained in exactly the environment it is scored in. Three things to watch for as you read it:
- Every token has a
sampledflag. 1 means the model wrote it, 0 means the harness inserted it (search results). SFT and RL learn only from tokens flagged 1. user_turnstitches tokens by hand instead of re-rendering the whole conversation. Qwen's template drops the empty<think></think>block from earlier turns, so re-rendering would change tokens the model already produced and break RL. Step 2 has a test that checks this.- Reward = F1 if there is an
<answer>, otherwise 0. Running out of tokens, taking no action, or going over budget all score 0.
"""The agent harness: question -> searches -> answer, recorded token by token.
The same code runs evaluation, collects SFT data and produces RL rollouts, so the
model is trained in exactly the environment it is tested in.
"""
import re
from dataclasses import dataclass, field
from config import MAX_SEARCHES, MAX_TOKENS, TOP_K
from score import f1
SYSTEM = """You are a research agent. You answer questions by searching Wikipedia.
Each reply contains exactly one action:
- To search, write a short query inside <search></search> and stop. The results arrive in the next message.
- When you know the answer, write only the answer inside <answer></answer>: a name, date, number, or yes/no.
Example. Question: Who directed the film in which Tom Hanks plays a FedEx employee stranded on an island?
Reply 1: <search>Tom Hanks FedEx employee stranded island film</search>
Reply 2: <search>Cast Away 2000 film director</search>
Reply 3: <answer>Robert Zemeckis</answer>
Most questions need two facts: find the first, then search for the second. Never answer before you have searched."""
SYSTEM_CLOSED_BOOK = """Answer the question from memory.
Write only the final answer inside <answer></answer>: a name, date, number, or yes/no."""
@dataclass
class Sample:
tokens: list
logprobs: list
stop_reason: str # "stop" (end of turn) or "length" (hit MAX_TOKENS)
@dataclass
class Episode:
id: str
question: str
answers: list
prompt: list # chat-template tokens of system + question
tokens: list = field(default_factory=list) # everything after the prompt
sampled: list = field(default_factory=list) # 1 = the model wrote this token, 0 = the harness did
logprobs: list = field(default_factory=list) # sampler logprob per token (0.0 for harness tokens)
searches: list = field(default_factory=list)
answer: str = ""
end: str = "" # answer | no_action | length | budget
prefill: int = 0 # tokens sent to the sampler, summed over turns (for cost)
generated: int = 0 # tokens the sampler produced
@property
def reward(self):
return f1(self.answer, self.answers) if self.end == "answer" else 0.0
class LocalPolicy:
"""Runs the model with transformers on your own machine: free, slow, no logprobs."""
def __init__(self, name):
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
self.torch = torch
self.tokenizer = AutoTokenizer.from_pretrained(name)
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
self.model = AutoModelForCausalLM.from_pretrained(name, dtype="auto").to(device)
self.end = self.tokenizer.convert_tokens_to_ids("<|im_end|>")
async def sample(self, ids, temperature):
x = self.torch.tensor([ids], device=self.model.device)
out = self.model.generate(x, attention_mask=self.torch.ones_like(x), max_new_tokens=MAX_TOKENS,
do_sample=temperature > 0, temperature=temperature or None,
top_p=None, top_k=None, eos_token_id=self.end)
new = out[0, len(ids):].tolist()
return Sample(new, [0.0] * len(new), "stop" if new and new[-1] == self.end else "length")
class TinkerPolicy:
"""Samples from a model on Tinker: a base model or one of your checkpoints."""
def __init__(self, sampling_client):
import tinker
self.tinker = tinker
self.client = sampling_client
self.tokenizer = sampling_client.get_tokenizer()
self.end = self.tokenizer.convert_tokens_to_ids("<|im_end|>")
async def sample(self, ids, temperature):
params = self.tinker.SamplingParams(max_tokens=MAX_TOKENS, temperature=temperature, stop=[self.end])
result = await self.client.sample_async(prompt=self.tinker.ModelInput.from_ints(ids),
num_samples=1, sampling_params=params)
seq = result.sequences[0]
if seq.logprobs is None:
raise RuntimeError("the sampler returned no logprobs; RL needs them")
return Sample(list(seq.tokens), list(seq.logprobs), seq.stop_reason)
def chat_prompt(tokenizer, system, question):
return tokenizer.apply_chat_template(
[{"role": "system", "content": system}, {"role": "user", "content": question}],
add_generation_prompt=True, enable_thinking=False, tokenize=True, return_dict=False)
def user_turn(tokenizer, content):
"""Tokens that close the model's turn, add a user message and open the next model turn.
Built by hand instead of re-rendering the whole chat: the Qwen template drops the
empty <think></think> from earlier turns, which would change tokens the model
already produced. test_harness.py checks this string against the real template.
"""
text = f"\n<|im_start|>user\n{content}<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n"
return tokenizer.encode(text, add_special_tokens=False)
def format_results(docs):
if not docs:
return "<result>\nNo results. Try other keywords.\n</result>"
return "<result>\n" + "\n\n".join(f"[{d['title']}] {d['text']}" for d in docs) + "\n</result>"
async def run_episode(policy, searcher, item, temperature=0.0, closed_book=False,
top_k=TOP_K, max_searches=MAX_SEARCHES):
tok = policy.tokenizer
system = SYSTEM_CLOSED_BOOK if closed_book else SYSTEM
ep = Episode(item["id"], item["question"], item["answers"], chat_prompt(tok, system, item["question"]))
def add(tokens, logprobs, sampled):
ep.tokens += tokens
ep.logprobs += logprobs
ep.sampled += [sampled] * len(tokens)
while True:
ids = ep.prompt + ep.tokens
out = await policy.sample(ids, temperature)
ep.prefill += len(ids)
ep.generated += len(out.tokens)
add(out.tokens, out.logprobs, 1)
if out.stop_reason == "length":
ep.end = "length"
return ep
if not out.tokens or out.tokens[-1] != policy.end: # some samplers leave out the stop token
add([policy.end], [0.0], 0)
reply = tok.decode(out.tokens, skip_special_tokens=True)
answer = re.search(r"<answer>(.*?)</answer>", reply, re.S)
if answer:
ep.answer, ep.end = answer.group(1).strip(), "answer"
return ep
query = re.search(r"<search>(.*?)</search>", reply, re.S)
if closed_book or not query:
ep.end = "no_action"
return ep
if len(ep.searches) == max_searches:
ep.end = "budget"
return ep
ep.searches.append(query.group(1).strip())
content = format_results(searcher.search(ep.searches[-1], top_k))
if len(ep.searches) == max_searches:
content += "\nThat was your last search. Reply with <answer></answer> now."
env = user_turn(tok, content)
add(env, [0.0] * len(env), 0)1.4The eval runner and the closed-book floor
eval.py runs 32 questions in parallel and writes runs/<name>/episodes.jsonl (one line per question, trace included) and summary.json. The cost column uses Tinker prices and is an upper bound, since it doesn't subtract the prefix cache.
"""Run the agent on a split, score every answer, write runs/<name>/.
python eval.py --name base base model on Tinker, val split
python eval.py --name closed --closed-book no search: the floor
python eval.py --name sft-e2 --model-path tinker://... one of your checkpoints
python eval.py --name local --local --limit 10 free, on your own machine
"""
import argparse
import asyncio
import json
import os
import time
from config import LOCAL_MODEL, MAX_SEARCHES, MODEL, PRICE_PREFILL, PRICE_SAMPLE, TOP_K
from harness import LocalPolicy, TinkerPolicy, run_episode
from score import exact_match, f1
async def make_policy(args):
if args.local:
return LocalPolicy(LOCAL_MODEL)
import tinker
service = tinker.ServiceClient()
if args.model_path:
return TinkerPolicy(await service.create_sampling_client_async(model_path=args.model_path))
return TinkerPolicy(await service.create_sampling_client_async(base_model=MODEL))
def cost(ep):
return (ep.prefill * PRICE_PREFILL + ep.generated * PRICE_SAMPLE) / 1e6
def record(ep, tokenizer, free=False):
return {"id": ep.id, "question": ep.question, "answers": ep.answers, "prediction": ep.answer,
"em": exact_match(ep.answer, ep.answers), "f1": f1(ep.answer, ep.answers),
"searches": ep.searches, "end": ep.end, "prefill": ep.prefill,
"generated": ep.generated, "usd": 0.0 if free else cost(ep),
"trace": tokenizer.decode(ep.tokens, skip_special_tokens=False)}
def summarize(rows):
n = len(rows)
mean = lambda key: sum(r[key] for r in rows) / n
return {"n": n, "em": mean("em"), "f1": mean("f1"),
"searches": sum(len(r["searches"]) for r in rows) / n,
"answered": sum(r["end"] == "answer" for r in rows) / n,
"usd_per_q": mean("usd"), "usd_total": sum(r["usd"] for r in rows)}
async def main(args):
items = [json.loads(line) for line in open(f"data/{args.split}.jsonl")][: args.limit]
policy = await make_policy(args)
searcher = None
if not args.closed_book:
from search import Searcher # built in Step 2
searcher = Searcher()
gate = asyncio.Semaphore(1 if args.local else args.concurrency)
out_dir = f"runs/{args.name}"
os.makedirs(out_dir, exist_ok=True)
started, rows = time.time(), []
async def one(item):
async with gate:
ep = await run_episode(policy, searcher, item, temperature=0.0, closed_book=args.closed_book,
top_k=args.top_k, max_searches=args.max_searches)
rows.append(record(ep, policy.tokenizer, free=args.local))
if len(rows) % 25 == 0 or args.local:
print(f" {len(rows)}/{len(items)} F1 so far {summarize(rows)['f1']:.3f}", flush=True)
await asyncio.gather(*(one(item) for item in items))
rows.sort(key=lambda r: r["id"])
with open(f"{out_dir}/episodes.jsonl", "w") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
summary = summarize(rows) | {"split": args.split, "model_path": args.model_path or MODEL,
"top_k": args.top_k, "max_searches": args.max_searches,
"seconds": round(time.time() - started)}
json.dump(summary, open(f"{out_dir}/summary.json", "w"), indent=2)
print(f"\n{args.name}: EM {summary['em']:.3f} F1 {summary['f1']:.3f} "
f"searches {summary['searches']:.2f} answered {summary['answered']:.0%} "
f"${summary['usd_per_q']:.4f}/q (total ${summary['usd_total']:.2f}, {summary['seconds']}s)")
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--name", required=True)
p.add_argument("--split", default="val", choices=["val", "test", "train"])
p.add_argument("--model-path", help="tinker://... checkpoint; default is the base model")
p.add_argument("--local", action="store_true", help="run LOCAL_MODEL with transformers")
p.add_argument("--closed-book", action="store_true")
p.add_argument("--limit", type=int)
p.add_argument("--top-k", type=int, default=TOP_K)
p.add_argument("--max-searches", type=int, default=MAX_SEARCHES)
p.add_argument("--concurrency", type=int, default=32)
asyncio.run(main(p.parse_args()))The floor: the model answers from memory, without searching.
python eval.py --name closed --closed-book 25/200 F1 so far …
…
closed: EM … F1 … searches 0.00 answered …% $…/q (total $…, …s)runs/closed/summary.jsonexists- The 7
score.pytests pass - You've written down the hash of
test.jsonl
- Opening
--split testbefore Step 5. The moment you tune a prompt on test, it is no longer a test apply_chat_template requires jinja2: you dropped thejinja2line from requirements
Harness: turn on search, read the traces
Give the model a search tool and measure the real starting point. Most of the time in this step goes into reading traces, not writing code.
2.1A BM25 index over 507k paragraphs
"""BM25 search over the paragraph corpus.
python search.py build the index into data/bm25 (once, ~2 min)
python search.py "Ed Wood director" try a query
"""
import json
import sys
import bm25s
import Stemmer
INDEX = "data/bm25"
stemmer = Stemmer.Stemmer("english")
def build():
corpus = [json.loads(line) for line in open("data/corpus.jsonl")]
texts = [f"{doc['title']}. {doc['text']}" for doc in corpus]
index = bm25s.BM25()
index.index(bm25s.tokenize(texts, stopwords="en", stemmer=stemmer))
index.save(INDEX, corpus=corpus)
print(f"indexed {len(corpus):,} paragraphs into {INDEX}")
class Searcher:
def __init__(self):
self.index = bm25s.BM25.load(INDEX, load_corpus=True)
def search(self, query, k=3):
tokens = bm25s.tokenize([query], stopwords="en", stemmer=stemmer, show_progress=False)
if not tokens.ids[0]: # the query was only stopwords
return []
docs, _ = self.index.retrieve(tokens, k=k, show_progress=False)
return list(docs[0])
if __name__ == "__main__":
if len(sys.argv) == 1:
build()
else:
for doc in Searcher().search(" ".join(sys.argv[1:])):
print(f"[{doc['title']}] {doc['text'][:200]}\n")python search.py # build, once
python search.py "Scott Derrickson nationality" # try a queryindexed 507,494 paragraphs into data/bm25[Scott Derrickson] Scott Derrickson (born July 16, 1966) is an American director, screenwriter and producer. He lives in Los Angeles, California. He is best known for directing horror films such as "Sinister", "The Exor
[Sinister (film)] Sinister is a 2012 supernatural horror film directed by Scott Derrickson and written by Derrickson and C. Robert Cargill. It stars Ethan Hawke as fictional true-crime writer Ellison Oswalt who discove
[Sinister 2] Sinister 2 is a 2015 American supernatural horror film directed by Ciaran Foy and written by Scott Derrickson and C. Robert Cargill. The sequel to the 2012 film "Sinister", the film stars James Ranson2.2Test the harness without a model
ScriptedPolicy replays pre-written replies, so the tests run in 0.1 seconds and cost nothing. The first test is the most important one: the hand-stitched tokens must match Qwen3.5's real chat template.
import asyncio
import unittest
from transformers import AutoTokenizer
from config import MODEL
from harness import SYSTEM, Sample, chat_prompt, run_episode, user_turn
tok = AutoTokenizer.from_pretrained(MODEL)
END = tok.convert_tokens_to_ids("<|im_end|>")
class ScriptedPolicy:
"""Replays fixed replies, so the harness can be tested without a model."""
def __init__(self, replies):
self.tokenizer, self.end, self.replies = tok, END, list(replies)
async def sample(self, ids, temperature):
tokens = tok.encode(self.replies.pop(0), add_special_tokens=False) + [END]
return Sample(tokens, [-0.5] * len(tokens), "stop")
class FakeSearcher:
def search(self, query, k=3):
return [{"title": "Arthur's Magazine", "text": "Arthur's Magazine was published from 1844 to 1846."}]
ITEM = {"id": "q1", "question": "Which magazine started first?", "answers": ["Arthur's Magazine"]}
def run(replies, **kw):
return asyncio.run(run_episode(ScriptedPolicy(replies), FakeSearcher(), ITEM, **kw))
class HarnessTest(unittest.TestCase):
def test_user_turn_matches_chat_template(self):
# Our hand-built tokens must equal what the template renders for the next turn.
msgs = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": "Q"}]
prompt = chat_prompt(tok, SYSTEM, "Q")
reply = tok.encode("<search>x</search>", add_special_tokens=False) + [END]
ours = prompt + reply + user_turn(tok, "<result>R</result>")
rendered = tok.apply_chat_template(
msgs + [{"role": "assistant", "content": "<search>x</search>"},
{"role": "user", "content": "<result>R</result>"}],
add_generation_prompt=True, enable_thinking=False, tokenize=False)
# The template drops the old turn's empty <think> block; everything else must match.
expected = rendered.replace("<|im_start|>assistant\n<search>", "<|im_start|>assistant\n<think>\n\n</think>\n\n<search>")
self.assertEqual(tok.decode(ours), expected)
def test_search_then_answer(self):
ep = run(["<search>Arthur's Magazine</search>", "<answer>Arthur's Magazine</answer>"])
self.assertEqual(ep.end, "answer")
self.assertEqual(ep.searches, ["Arthur's Magazine"])
self.assertEqual(ep.reward, 1.0)
def test_masks_line_up(self):
ep = run(["<search>a</search>", "<answer>b</answer>"])
self.assertEqual(len(ep.tokens), len(ep.sampled))
self.assertEqual(len(ep.tokens), len(ep.logprobs))
self.assertIn("1844", tok.decode([t for t, s in zip(ep.tokens, ep.sampled) if s == 0]))
self.assertNotIn("1844", tok.decode([t for t, s in zip(ep.tokens, ep.sampled) if s == 1]))
def test_no_action_scores_zero(self):
ep = run(["I think it is Arthur's Magazine."])
self.assertEqual((ep.end, ep.reward), ("no_action", 0.0))
def test_budget_forces_answer(self):
ep = run(["<search>a</search>"] * 3 + ["<answer>Arthur's Magazine</answer>"], max_searches=3)
self.assertEqual((len(ep.searches), ep.end), (3, "answer"))
self.assertIn("last search", tok.decode(ep.tokens))
def test_budget_exceeded(self):
ep = run(["<search>a</search>"] * 3, max_searches=2)
self.assertEqual(ep.end, "budget")
if __name__ == "__main__":
unittest.main()python -m unittest test_score test_harness.............
----------------------------------------------------------------------
Ran 13 tests in 0.057s
OK2.3A free trial run on your own machine
Before paying anything, run the whole loop with Qwen3.5-0.8B on your laptop (or Colab). This model is weak; the point is to see the loop run and catch format bugs.
uv pip install torch
python eval.py --name local --local --limit 10
python show.py runs/local --n 3 1/10 F1 so far 0.000
2/10 F1 so far 0.000
3/10 F1 so far 0.000
4/10 F1 so far 0.100
5/10 F1 so far 0.080
6/10 F1 so far 0.067
7/10 F1 so far 0.057
8/10 F1 so far 0.050
9/10 F1 so far 0.044
10/10 F1 so far 0.140
local: EM 0.100 F1 0.140 searches 0.40 answered 100% $0.0000/q (total $0.00, 19s)how episodes ended: {'answer': 10}
searches per episode: {0: 6, 1: 4}
9/10 episodes with F1 < 0.5. A random 1:
====================================================================================================
Q: An American football quarterback for the Chicago Bears was a star of a game released when?
GOLD: ['July 14, 2009'] PRED: '1933' END: answer
----------------------------------------------------------------------------------------------------
<search>Chicago Bears quarterback star game released year</search><|im_end|>
<|im_start|>user
<result>
[Joey Sternaman] Joseph Theodore Sternaman (February 1, 1900 – March 10, 1988) was a professional American football player, born in Springfield, Illinois, who played quarterback for nine seasons for the Chicago Bears and Duluth Kelleys. At 5'6" and 135 pounds he was called "the strongest little man I ever met" by sportswriter Grantland Rice. He played quarterback during the years Red Grange starred with the Bears. In 1926, he was the quarterback, head coach, and owner of the Chicago Bulls of the first American Football League. Joey was also the brother of Chicago Bears co-owner Dutch Sternaman.
[NFL Quarterback Club 96] NFL Quarterback Club 96 is an American football video game released in December 1995. The game was released on the Sega Saturn, Sega Genesis, Game Boy, Sega Game Gear, DOS, and Super Nintendo Entertainment System. The game's cover features San Francisco 49ers quarterback Steve Young passing while being tackled by Chicago Bears defensive lineman Chris Zorich and an unidentified defender. The Saturn, SNES and DOS versions were developed by Iguana Entertainment, while the Game Boy edition was developed by Condor Inc.
[NFL starting quarterback playoff records] The first official National Football League (NFL) playoff game was the 1933 NFL Championship Game between the Chicago Bears and New York Giants. A "playoff" game was played in 1932 between the Chicago Bears and Portsmouth Spartans to break a regular season tie, but is recorded in the team record books as a regular season game. Since then there have been a total over 525 NFL playoff games including games from the AFL, but not the AAFC. The following list shows career postseason records for each starting quarterback in the NFL playoffs.
</result><|im_end|>
<|im_start|>assistant
<think>
</think>
<answer>1933</answer><|im_end|><search>keywords</search>. The small model copied them verbatim: all 5 questions searched for the word "keywords" and answered 'final answer'. F1 = 0. One concrete example (the Cast Away question in SYSTEM) fixed it. Lesson: always read the traces before you trust a number.Q: What year was the magazine that Stanley Bing spent a decade at founded in?
GOLD: ['1933'] PRED: 'final answer'
<search>keywords</search><|im_end|>
<|im_start|>user
<result>
[Restrict] In the C programming language, as of the C99 standard, codice_1 is a keyword ...2.4The real starting point on Tinker
python eval.py --name base
python show.py runs/base --n 10 25/200 F1 so far …
…
base: EM … F1 … searches … answered …% $…/q (total $…, …s)As you read the 10 failures, give each one a label: bad query, right paragraph but misread, stopped after 1 hop, answer too long (low F1 even though the meaning is right), no action. Fix the most common label first, through the prompt or a setting in config.py, then rerun eval.py --name base-v2.
- All 13 tests pass
runs/basehas a score, ≥ 1.5 searches on average, and ≥ 90% of questions with an<answer>- You've read at least 10 failed traces
- If you change the harness after Step 3, every checkpoint must be re-evaluated: the model was trained in the old harness
- This corpus is easier than the real world because the answer is guaranteed to be in it. Say so in your report
SFT: learn from the times it got it right
No expensive teacher needed. Let the base model try each question 4 times, keep the shortest correct attempt (rejection sampling, the STaR idea), then train LoRA on those very traces. The model learns to use the tool more consistently and to ramble less.
3.1Collect correct traces
"""Step 3a: build SFT data by rejection sampling (STaR).
The base model tries each train question several times in the harness. We keep the
shortest attempt that searched at least once and got the answer right.
python make_sft.py --questions 1500 --tries 4
"""
import argparse
import asyncio
import json
import tinker
from config import MODEL, PRICE_PREFILL, PRICE_SAMPLE
from harness import TinkerPolicy, run_episode
from search import Searcher
async def main(args):
items = [json.loads(line) for line in open("data/train.jsonl")][: args.questions]
service = tinker.ServiceClient()
client = await (service.create_sampling_client_async(model_path=args.model_path) if args.model_path
else service.create_sampling_client_async(base_model=MODEL))
policy, searcher = TinkerPolicy(client), Searcher()
gate = asyncio.Semaphore(64)
kept, solved, usd, done = [], 0, 0.0, 0
async def attempt(item):
async with gate:
return await run_episode(policy, searcher, item, temperature=args.temperature)
async def one(item):
nonlocal solved, usd, done
episodes = await asyncio.gather(*(attempt(item) for _ in range(args.tries)))
usd += sum(e.prefill * PRICE_PREFILL + e.generated * PRICE_SAMPLE for e in episodes) / 1e6
good = [e for e in episodes if e.reward >= args.min_f1 and e.searches]
solved += any(e.reward >= args.min_f1 for e in episodes)
if good:
best = min(good, key=lambda e: len(e.tokens))
kept.append({"id": best.id, "question": best.question, "answers": best.answers,
"prompt": best.prompt, "tokens": best.tokens, "sampled": best.sampled,
"searches": best.searches, "reward": best.reward})
done += 1
if done % 100 == 0:
print(f" {done}/{len(items)} kept {len(kept)} ${usd:.2f}", flush=True)
await asyncio.gather(*(one(item) for item in items))
with open(args.out, "w") as f:
for row in kept:
f.write(json.dumps(row) + "\n")
print(f"\nkept {len(kept)}/{len(items)} questions | solved@{args.tries} {solved / len(items):.1%} | "
f"avg searches {sum(len(r['searches']) for r in kept) / max(len(kept), 1):.2f} | ${usd:.2f}")
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--questions", type=int, default=1500)
p.add_argument("--tries", type=int, default=4)
p.add_argument("--temperature", type=float, default=0.8)
p.add_argument("--min-f1", type=float, default=0.8)
p.add_argument("--model-path", help="sample from a checkpoint instead of the base model")
p.add_argument("--out", default="data/sft.jsonl")
asyncio.run(main(p.parse_args()))python make_sft.py --questions 1500 --tries 4 100/1500 kept … $…
…
kept …/1500 questions | solved@4 …% | avg searches … | $…Read one trace with your own eyes before training:
python -c "
import json; from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained('Qwen/Qwen3.5-4B')
r = json.loads(open('data/sft.jsonl').readline())
print(tok.decode(r['prompt'] + r['tokens']))"--tries 8 or loosen --min-f1 0.6. If you keep more than 90%, the set is too easy for the model; take harder questions (HotpotQA has a level column).3.2From episode to Datum
Tinker takes a Datum: the input is every token except the last, the target is every token except the first. SFT uses weights (1 on the tokens the model wrote). RL uses the sampler's logprobs and the advantages. Both functions live in one file so Step 4 can reuse them.
"""Turn episodes into Tinker training examples (Datum).
A model predicts the next token, so the input is every token except the last and the
target is every token except the first. Masks are shifted the same way.
"""
import tinker
def sft_datum(prompt, tokens, sampled):
"""Cross-entropy only on tokens the model wrote; prompt and search results get weight 0."""
full = prompt + tokens
weights = [0.0] * len(prompt) + [float(s) for s in sampled]
return tinker.Datum(model_input=tinker.ModelInput.from_ints(full[:-1]),
loss_fn_inputs={"target_tokens": full[1:], "weights": weights[1:]})
def rl_datum(ep, advantage):
"""Importance-sampling loss: push up the model's own tokens in proportion to the advantage."""
full = ep.prompt + ep.tokens
n = len(ep.prompt) - 1
return tinker.Datum(model_input=tinker.ModelInput.from_ints(full[:-1]),
loss_fn_inputs={"target_tokens": full[1:],
"logprobs": [0.0] * n + ep.logprobs,
"advantages": [0.0] * n + [advantage * s for s in ep.sampled]})3.3Train LoRA on Tinker
Each step is two calls: forward_backward_async computes the gradients, optim_step_async applies them. Send both before waiting so the server can pipeline them. At the end of each epoch it saves two things: a sampler for eval, and a state for Step 4 to keep training from.
"""Step 3b: LoRA SFT on Tinker, loss only on tokens the model wrote.
python train_sft.py --epochs 3
Prints a sampler path (for eval.py) and a state path (for train_grpo.py) after each epoch.
"""
import argparse
import asyncio
import json
import os
import random
import tinker
from config import LORA_RANK, MODEL, PRICE_TRAIN
from datums import sft_datum
async def main(args):
rows = [json.loads(line) for line in open(args.data)]
data = [sft_datum(r["prompt"], r["tokens"], r["sampled"]) for r in rows]
tokens_per_epoch = sum(d.model_input.length for d in data)
print(f"{len(data)} examples, {tokens_per_epoch:,} tokens/epoch, "
f"≈ ${tokens_per_epoch * args.epochs * PRICE_TRAIN / 1e6:.2f} to train")
service = tinker.ServiceClient()
trainer = await service.create_lora_training_client_async(base_model=MODEL, rank=LORA_RANK)
steps_per_epoch = (len(data) + args.batch - 1) // args.batch
total, step = steps_per_epoch * args.epochs, 0
rng = random.Random(0)
os.makedirs("runs", exist_ok=True)
for epoch in range(1, args.epochs + 1):
rng.shuffle(data)
for i in range(0, len(data), args.batch):
batch = data[i : i + args.batch]
lr = args.lr * (1 - step / total) # linear decay to 0
fwd = await trainer.forward_backward_async(batch, "cross_entropy")
opt = await trainer.optim_step_async(tinker.AdamParams(learning_rate=lr))
result = await fwd.result_async()
await opt.result_async()
step += 1
logprobs = [lp for out in result.loss_fn_outputs for lp in out["logprobs"].tolist()]
weights = [w for d in batch for w in d.loss_fn_inputs["weights"].tolist()]
loss = -sum(lp * w for lp, w in zip(logprobs, weights)) / sum(weights)
if step % 5 == 0 or step == total:
print(f"epoch {epoch} step {step}/{total} loss {loss:.4f} lr {lr:.2e}", flush=True)
name = f"{args.name}-e{epoch}"
sampler = await (await trainer.save_weights_for_sampler_async(name=name)).result_async()
state = await (await trainer.save_state_async(name=name + "-state")).result_async()
print(f"saved {name}\n sampler: {sampler.path}\n state: {state.path}")
with open("runs/checkpoints.jsonl", "a") as f:
f.write(json.dumps({"name": name, "sampler": sampler.path, "state": state.path}) + "\n")
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--data", default="data/sft.jsonl")
p.add_argument("--name", default="sft")
p.add_argument("--epochs", type=int, default=3)
p.add_argument("--batch", type=int, default=32)
p.add_argument("--lr", type=float, default=2e-4)
asyncio.run(main(p.parse_args()))"""Print a checkpoint path saved in runs/checkpoints.jsonl.
python ckpt.py sft-e2 sampler path, for eval.py --model-path
python ckpt.py sft-e2 state state path, for train_grpo.py --init-state
"""
import json
import sys
name, kind = sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "sampler"
paths = [json.loads(line)[kind] for line in open("runs/checkpoints.jsonl") if json.loads(line)["name"] == name]
if not paths:
sys.exit(f"no checkpoint named {name} in runs/checkpoints.jsonl")
print(paths[-1])python train_sft.py --epochs 3… examples, … tokens/epoch, ≈ $… to train
epoch 1 step 5/… loss … lr …
…
saved sft-e1
sampler: tinker://…/sampler_weights/sft-e1
state: tinker://…/weights/sft-e1-stateThe loss should drop clearly during epoch 1, then flatten. If it gets close to 0, the model is memorizing the data.
3.4Pick the epoch by val, not by loss
for e in 1 2 3; do
python eval.py --name sft-e$e --model-path $(python ckpt.py sft-e$e)
done
python ablate.py runs/base runs/sft-e1 runs/sft-e2 runs/sft-e3Pick the epoch with the highest val F1. Samaya did the same and picked epoch 3 of 5, not the last one. From here on it's called sft-eN.
- One SFT checkpoint has a higher val F1 than
runs/base - The average number of searches hasn't dropped (the model hasn't learned to skip the tool)
- Training on badly formatted traces: nice loss, bad score. Always read a trace in
sft.jsonl - Feeding val/test questions into
make_sft.py: the script reads onlytrain.jsonl, don't change that
GRPO: try, self-score, reinforce the good runs
Each step: 32 new questions, 8 episodes per question with the current weights. An episode whose F1 beats its group's average is pushed up; one below is pushed down. Groups where all 8 runs score the same teach nothing and are skipped.
4.1The GRPO loop
The settings come from Tinker's Search-R1 recipe for this exact model (LoRA rank 32, lr 4e-5, group of 8), with questions per step cut from 512 to 32 to fit the budget. It starts from the state of the best SFT epoch.
"""Step 4: online GRPO in the agent harness.
Each step: take Q new train questions, run G episodes per question with the current
weights, reward = F1, advantage = reward - mean reward of the same question's group.
Groups where every episode scored the same teach nothing and are skipped.
python train_grpo.py --init-state tinker://.../sft-e2-state --steps 30
"""
import argparse
import asyncio
import json
import os
import tinker
from config import LORA_RANK, MODEL, PRICE_PREFILL, PRICE_SAMPLE, PRICE_TRAIN
from datums import rl_datum
from harness import TinkerPolicy, run_episode
from search import Searcher
async def main(args):
service = tinker.ServiceClient()
if args.init_state:
trainer = await service.create_training_client_from_state_async(args.init_state)
else:
trainer = await service.create_lora_training_client_async(base_model=MODEL, rank=LORA_RANK)
items = [json.loads(line) for line in open("data/train.jsonl")][args.offset :]
searcher, gate = Searcher(), asyncio.Semaphore(128)
os.makedirs(f"runs/{args.name}", exist_ok=True)
spent = 0.0
for step in range(1, args.steps + 1):
policy = TinkerPolicy(await trainer.save_weights_and_get_sampling_client_async())
batch = items[(step - 1) * args.questions : step * args.questions]
async def attempt(item):
async with gate:
return await run_episode(policy, searcher, item, temperature=args.temperature)
groups = await asyncio.gather(*(asyncio.gather(*(attempt(it) for _ in range(args.group)))
for it in batch))
data, skipped = [], 0
for group in groups:
rewards = [e.reward for e in group]
if max(rewards) - min(rewards) < 1e-6:
skipped += 1
continue
mean = sum(rewards) / len(rewards)
data += [rl_datum(e, e.reward - mean) for e in group]
if data:
fwd = await trainer.forward_backward_async(data, "importance_sampling")
opt = await trainer.optim_step_async(tinker.AdamParams(learning_rate=args.lr))
await fwd.result_async()
await opt.result_async()
episodes = [e for g in groups for e in g]
spent += sum(e.prefill * PRICE_PREFILL + e.generated * PRICE_SAMPLE for e in episodes) / 1e6
spent += sum(d.model_input.length for d in data) * PRICE_TRAIN / 1e6
log = {"step": step, "reward": sum(e.reward for e in episodes) / len(episodes),
"skipped_groups": skipped / len(groups),
"searches": sum(len(e.searches) for e in episodes) / len(episodes),
"answered": sum(e.end == "answer" for e in episodes) / len(episodes),
"budget_hit": sum(e.end in ("budget", "length") for e in episodes) / len(episodes),
"tokens": sum(len(e.tokens) for e in episodes) / len(episodes), "usd": round(spent, 2)}
print(" ".join(f"{k} {v:.3f}" if isinstance(v, float) else f"{k} {v}" for k, v in log.items()), flush=True)
with open(f"runs/{args.name}/train_log.jsonl", "a") as f:
f.write(json.dumps(log) + "\n")
if step % args.save_every == 0 or step == args.steps:
name = f"{args.name}-s{step}"
sampler = await (await trainer.save_weights_for_sampler_async(name=name)).result_async()
state = await (await trainer.save_state_async(name=name + "-state")).result_async()
print(f"saved {name}\n sampler: {sampler.path}\n state: {state.path}")
with open("runs/checkpoints.jsonl", "a") as f:
f.write(json.dumps({"name": name, "sampler": sampler.path, "state": state.path}) + "\n")
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--init-state", help="tinker:// state path from train_sft.py; omit to start from the base model")
p.add_argument("--name", default="grpo")
p.add_argument("--steps", type=int, default=30)
p.add_argument("--questions", type=int, default=32, help="questions per step")
p.add_argument("--group", type=int, default=8, help="episodes per question")
p.add_argument("--lr", type=float, default=4e-5)
p.add_argument("--temperature", type=float, default=1.0)
p.add_argument("--offset", type=int, default=1500, help="skip the questions used for SFT")
p.add_argument("--save-every", type=int, default=10)
asyncio.run(main(p.parse_args()))python train_grpo.py --init-state $(python ckpt.py sft-e2 state) --steps 30 # change e2 to the epoch you pickedstep 1 reward … skipped_groups … searches … answered … budget_hit … tokens … usd …
step 2 reward … …
…
saved grpo-s10
sampler: tinker://…/sampler_weights/grpo-s10
state: tinker://…/weights/grpo-s10-state4.2Read the log like a practitioner
| Column | Healthy | Problem → what to do |
|---|---|---|
reward | Rises gradually; step-to-step noise is normal | Flat for 15 steps: raise --lr to 8e-5, or the questions are too hard |
skipped_groups | 0.2–0.6 | Near 1.0: every question is all right or all wrong. Switch to harder / easier questions |
searches | Stable or rising slightly | Drops to ~1: the model is learning to guess. Look at traces with eval + show.py |
budget_hit | < 5% | Climbing: the model is searching in circles. Lower MAX_SEARCHES or add a penalty |
tokens | Stable | Growing fast: the model is rambling to "stall". Read the traces now |
In Tinker's Search-R1 run with the same model, train reward went from 0.39 to 0.60 over 30 steps. That's the best "right ballpark" check you have before running eval.
4.3Evaluate the checkpoints on val
for s in 10 20 30; do
python eval.py --name grpo-s$s --model-path $(python ckpt.py grpo-s$s)
done
python ablate.py runs/sft-e2 runs/grpo-s10 runs/grpo-s20 runs/grpo-s30To run 20 more steps from s30: python train_grpo.py --init-state $(python ckpt.py grpo-s30 state) --name grpo2 --offset 2460. --offset keeps it from reusing questions already seen (1500 + 30 × 32 = 2460).
- One GRPO checkpoint has a higher val F1 than SFT, and
ablate.pygives a Δ confidence interval that doesn't contain 0
- Reward hacking: F1 gives partial credit, so the model can learn to answer long to "hit" a few words. If answer length grows, read the traces
- Picking the checkpoint by train reward instead of val
Ablation: open the test set, prove each part
The first and only run on test. Every comparison comes with a confidence interval, because with 500 questions a 2–3 point gap can be pure luck.
5.1A stage-by-stage table on test
"""Step 5: compare runs on the same questions with 95% bootstrap confidence intervals.
python ablate.py runs/test-base runs/test-sft runs/test-grpo
The first run is the baseline; every other run gets a paired difference against it.
"""
import json
import random
import sys
def load(run):
rows = {}
for line in open(f"{run}/episodes.jsonl"):
r = json.loads(line)
rows[r["id"]] = r
return rows
def ci(values, rng, n=1000):
means = sorted(sum(rng.choices(values, k=len(values))) / len(values) for _ in range(n))
return means[int(0.025 * n)], means[int(0.975 * n)]
runs = sys.argv[1:]
data = [load(r) for r in runs]
ids = sorted(set.intersection(*(set(d) for d in data)))
rng = random.Random(0)
base = [data[0][i]["f1"] for i in ids]
print(f"{len(ids)} shared questions\n")
print(f"{'run':<22}{'F1':>7}{'95% CI':>16}{'EM':>7}{'searches':>10}{'$/q':>9}{'Δ F1 vs first':>24}")
for run, d in zip(runs, data):
f1s = [d[i]["f1"] for i in ids]
lo, hi = ci(f1s, rng)
mean = lambda key: sum(d[i][key] for i in ids) / len(ids)
searches = sum(len(d[i]["searches"]) for i in ids) / len(ids)
delta = ""
if d is not data[0]:
diffs = [a - b for a, b in zip(f1s, base)]
dlo, dhi = ci(diffs, rng)
delta = f"{sum(diffs) / len(diffs):+.3f} [{dlo:+.3f}, {dhi:+.3f}]"
print(f"{run:<22}{mean('f1'):>7.3f}{f'[{lo:.3f}, {hi:.3f}]':>16}{mean('em'):>7.3f}"
f"{searches:>10.2f}{mean('usd'):>9.4f}{delta:>24}")
print("\nA difference is real only if its interval does not contain 0.")python eval.py --split test --name test-closed --closed-book
python eval.py --split test --name test-base
python eval.py --split test --name test-sft --model-path $(python ckpt.py sft-e2)
python eval.py --split test --name test-grpo --model-path $(python ckpt.py grpo-s30)
python ablate.py runs/test-base runs/test-closed runs/test-sft runs/test-grpo500 shared questions
run F1 95% CI EM searches $/q Δ F1 vs first
runs/test-base … [… , …] … … …
runs/test-closed … [… , …] … 0.00 … −… [−…, −…]
runs/test-sft … [… , …] … … … +… [+…, +…]
runs/test-grpo … [… , …] … … … +… [+…, +…]
A difference is real only if its interval does not contain 0.5.2Take the harness apart, one piece at a time
Same GRPO model, one setting changed at a time. No retraining needed.
python eval.py --split test --name test-grpo-k1 --model-path $(python ckpt.py grpo-s30) --top-k 1
python eval.py --split test --name test-grpo-b2 --model-path $(python ckpt.py grpo-s30) --max-searches 2
python ablate.py runs/test-grpo runs/test-grpo-k1 runs/test-grpo-b2The question this step answers: did the model get stronger because it searches better, or because it reads better? If k1 drops sharply, the model relies on being handed many paragraphs. If b2 is about the same, a research budget of 5 is overkill and you can save money.
5.3Write up the results
Fill this table from the output of ablate.py and summary.json. It's the core of your write-up, following the same outline as Samaya's post.
## Results on HotpotQA test (500 questions, 507k-paragraph corpus, BM25 top-3, budget of 5 searches)
| Stage | F1 [95% CI] | EM | Searches/q | $/q |
|----------------------|--------------------|-------|------------|---------|
| Closed book | | | 0 | |
| Base + harness | | | | |
| + SFT (epoch N) | | | | |
| + GRPO (step N) | | | | |
| GRPO, top-1 | | | | |
| GRPO, budget 2 | | | | |
Total Tinker cost: $.. test.jsonl sha256: cb4ad042f2c0ef38
Limitation: the corpus only contains paragraphs that appear in HotpotQA, so it is easier than full Wikipedia.- The table has all 6 rows, and every Δ has a confidence interval
- You can write one sentence: which part contributed how many points, at what price
- Running test several times and picking the best run
- Reporting F1 without a $/question column: half of Samaya's story is the price
Quick troubleshooting
| Error you see | Cause | Fix |
|---|---|---|
ImportError: apply_chat_template requires jinja2 | jinja2 is missing (actually hit while writing this) | uv pip install jinja2 |
... requires accelerate when running --local | Using device_map | The code here moves the model with .to(device); don't add device_map |
| The model searches for "keywords" and answers "final answer" | The prompt has placeholders and the small model copies them verbatim (actually hit) | Keep the concrete example in SYSTEM |
end is all no_action | The model writes an explanation without any tag | Read the traces; restate the tag rules at the end of SYSTEM |
RuntimeError: the sampler returned no logprobs | The sampler returned no logprobs | Stop: RL is wrong without logprobs. Update tinker and check again |
test_user_turn_matches_chat_template fails | You switched to a model whose template differs from Qwen's | Rewrite user_turn for the new template before running anything else |
| Index build gets killed | Out of RAM (needs ~2.5 GB) | Close some apps, or run it on Colab |
| Eval is slow or returns 429 | Too many parallel requests | --concurrency 8 |
Cost, worked out from tokens
Qwen3.5-4B prices on Tinker as of Nov 1, 2026: prefill $0.33, sample $1.005, train $0.737 per million tokens. One episode ≈ 3 turns, ~2,000 prefill tokens and ~80 generated tokens, so ≈ $0.0007. Every script prints its own real cost.
| Step | What runs | Estimate |
|---|---|---|
| 1 Eval | 200 closed-book questions | ≈ $0.05 |
| 2 Harness | 200 base questions ($0 locally) | ≈ $0.20 |
| 3 SFT | 6,000 episodes to collect traces + ~3.6M training tokens + 3 evals | ≈ $8 |
| 4 GRPO | 30 steps × 256 episodes + training on groups with variance + 3 evals | ≈ $12 |
| 5 Ablation | 2,500 episodes on test | ≈ $2 |
| Total | A $40 limit leaves plenty of room | ≈ $22 |
Sources
- Samaya AI: Samaya Sterling (Oct 28, 2026): the Eval → Harness → SFT → GRPO → Ablation recipe and the reference numbers on the map
- Tinker Quickstart and SDK Cheatsheet: the API used in the code
- Tinker Models & Pricing (read on Nov 1, 2026)
- tinker-cookbook: search_tool recipe: RL settings and Qwen3.5-4B reference numbers on HotpotQA
- HotpotQA (Hugging Face version)
- Search-R1 and STaR: the search/answer tag protocol and rejection sampling
- bm25s