Hands-on tutorial · copy-paste · runnable

Post-train Qwen3.5-4B into a Wikipedia research agent, one command at a time

Rebuild Samaya's recipe (Eval → Harness → SFT → GRPO → Ablation) with 16 Python files (~850 lines, tests included). Every step has a command to run, the full code to copy, the expected output, and a sign that tells you you're on track. No GPU of your own needed: search runs on your laptop, training runs on Tinker.

Model Qwen/Qwen3.5-4B Data HotpotQA + 507,494 Wikipedia paragraphs Search BM25, runs on CPU Cost ≈ $22 for all 5 steps Needs Python 3.11+, 4 GB free RAM, a Tinker account

Run for real on Nov 1, 2026 on a MacBook (Apple Silicon, 16 GB): data download, BM25 build, 13 unit tests, and the agent running locally with Qwen3.5-0.8B. Every "Real output" block below is copied from the terminal. The Tinker calls are written against SDK tinker 0.36.0, and the whole flow (SFT → GRPO → eval → ablation) was checked against a fake Tinker backend; your Tinker scores and bill are the first real numbers.

The big picture

A six-station factory: raw materials in, agent out

Each station is one or two scripts. Click a station to see what it takes in, which file it runs, and what it hands on. The yardstick built in Step 1 re-checks the product after every station.

Product moving on the conveyor QC: re-measured after every station Feedback loop to an earlier station 54.8 on a card: Samaya's reference score (FrontierFinance)
INPUT

Raw materials

In
    Run
      Out

        On the conveyor after this station: raw materials

        Score across the stationsSamaya reference, 0–100% scale
        Base in harness 42.5 SFT +12.3 RL +4.1 → 58.9 · $0.61/question

        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.

        FileStepWhat it does
        requirements.txt, config.py, hello_tinker.py0–1Pinned versions, every setting in one place, API key check
        prep_data.py1Download HotpotQA, split train/val/test, build the 507k-paragraph corpus
        score.py, test_score.py1HotpotQA-style Exact Match and F1, 7 tests
        harness.py1–2Agent loop: model → <search> → results → <answer>, recording every token
        eval.py, show.py1–2Run a model on a split, score it, total the cost; read the failures
        search.py, test_harness.py2BM25 over the corpus; 6 harness tests, including a chat-template match test
        make_sft.py, datums.py, train_sft.py, ckpt.py3Collect correct traces, train LoRA on Tinker, look up checkpoint paths
        train_grpo.py4Online GRPO inside that same harness
        ablate.py5Compare runs on the same questions, with bootstrap confidence intervals
        Shortcut. Don't want to copy file by file? Download the zip at the top of the page, unzip it, and go straight to the commands in each step. The code in the zip and on the page is the same version.
        STEP 0

        Setup

        10 min$0Laptop

        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.

        Terminal
        mkdir wiki-researcher && cd wiki-researcher
        uv venv --python 3.12 .venv
        source .venv/bin/activate

        Create requirements.txt with exactly the versions that were tested, then install:

        requirements.txt · 6 linesdownload
        tinker==0.36.0
        transformers==5.22.1
        jinja2==3.1.6
        datasets==5.1.0
        bm25s==0.3.16
        PyStemmer==3.1.0
        Terminal
        uv pip install -r requirements.txt

        0.2Get a Tinker API key and set a spending limit

        1. Go to Tinker Console → Keys and create a key.
        2. 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.
        Terminal
        export TINKER_API_KEY="tml-..."   # add this line to ~/.zshrc so you don't retype it

        0.3One config file, one test call

        config.py holds every setting. Change the model or the budget here and every script follows.

        config.py · 14 linesdownload
        """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
        hello_tinker.py · 21 linesdownload
        """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())
        Terminal
        python hello_tinker.py
        You'll see a sentence like this (the first tokenizer download takes ~10 seconds)
        Hamlet was written by William Shakespeare.
        Done when
        • hello_tinker.py prints an answer
        • Billing has a spending limit
        If it fails
        • 401 / AuthenticationError: you haven't run export TINKER_API_KEY in this terminal
        • HF_TOKEN warning: safe to ignore, it's just Hugging Face's download rate limit
        STEP 1

        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.

        30 min≈ $0.05Laptop + Tinker

        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.

        prep_data.py · 51 linesdownload
        """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")
        Terminal
        python prep_data.py
        Real output (13 seconds after the download, ~1.4 GB RAM)
        corpus 507,494 paragraphs | train 90,447 | val 200 | test 500
        test.jsonl sha256 cb4ad042f2c0ef38  <- write this down; it must never change
        Tip. Your hash must be exactly cb4ad042f2c0ef38. 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.

        score.py · 30 linesdownload
        """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 best
        test_score.py · 32 linesdownload
        import 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()
        Terminal
        python -m unittest test_score
        Real output
        .......
        ----------------------------------------------------------------------
        Ran 7 tests in 0.000s
        
        OK

        1.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 sampled flag. 1 means the model wrote it, 0 means the harness inserted it (search results). SFT and RL learn only from tokens flagged 1.
        • user_turn stitches 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.
        harness.py · 161 linesdownload
        """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.

        eval.py · 95 linesdownload
        """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.

        Terminal
        python eval.py --name closed --closed-book
        Output format (the numbers are yours)
          25/200  F1 so far …
          …
        closed: EM …  F1 …  searches 0.00  answered …%  $…/q  (total $…, …s)
        Done when
        • runs/closed/summary.json exists
        • The 7 score.py tests pass
        • You've written down the hash of test.jsonl
        Traps
        • Opening --split test before Step 5. The moment you tune a prompt on test, it is no longer a test
        • apply_chat_template requires jinja2: you dropped the jinja2 line from requirements
        STEP 2

        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.

        1–2 hours≈ $0.20Laptop (BM25 runs on CPU)

        2.1A BM25 index over 507k paragraphs

        search.py · 42 linesdownload
        """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")
        Terminal
        python search.py                                   # build, once
        python search.py "Scott Derrickson nationality"    # try a query
        Real output: 28-second build, 2.5 GB peak RAM, 442 MB index
        indexed 507,494 paragraphs into data/bm25
        Real output of the test query
        [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 Ranson

        2.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.

        test_harness.py · 79 linesdownload
        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()
        Terminal
        python -m unittest test_score test_harness
        Real output
        .............
        ----------------------------------------------------------------------
        Ran 13 tests in 0.057s
        
        OK

        2.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.

        Terminal
        uv pip install torch
        python eval.py --name local --local --limit 10
        python show.py runs/local --n 3
        Real output (MacBook, Apple chip, 19 seconds for 10 questions)
          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)
        Real output of show.py (one trace, trimmed)
        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|>
        What actually happened while writing this. The first prompt described the actions with placeholders: <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.
        Real failing trace, before the prompt fix
        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

        Terminal
        python eval.py --name base
        python show.py runs/base --n 10
        Output format (the numbers are yours)
          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.

        Reference numbers. Tinker's own Search-R1 recipe measured Qwen3.5-4B (no thinking) on HotpotQA: 0.47 before RL, 0.70 after 30 steps. They used the full 2018 Wikipedia with embedding search, so your numbers will differ; use theirs to tell whether you're in the same range or way off.
        Done when
        • All 13 tests pass
        • runs/base has a score, ≥ 1.5 searches on average, and ≥ 90% of questions with an <answer>
        • You've read at least 10 failed traces
        Traps
        • 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
        STEP 3

        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.

        2–3 hours≈ $8Tinker

        3.1Collect correct traces

        make_sft.py · 63 linesdownload
        """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()))
        Terminal
        python make_sft.py --questions 1500 --tries 4
        Output format
          100/1500  kept …  $…
          …
        kept …/1500 questions  |  solved@4 …%  |  avg searches …  |  $…

        Read one trace with your own eyes before training:

        Terminal
        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']))"
        Tip. If you keep fewer than 300 questions, don't train yet: raise --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.

        datums.py · 24 linesdownload
        """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.

        train_sft.py · 63 linesdownload
        """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()))
        ckpt.py · 13 linesdownload
        """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])
        Terminal
        python train_sft.py --epochs 3
        Output format
        … 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-state

        The 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

        Terminal
        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-e3

        Pick 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.

        Done when
        • 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)
        Traps
        • 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 only train.jsonl, don't change that
        STEP 4

        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.

        3–5 hours≈ $12Tinker
        advantage = F1(episode) − mean F1 of the 8 episodes for the same question  ·  loss = importance_sampling on tokens with sampled = 1

        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.

        train_grpo.py · 91 linesdownload
        """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()))
        Terminal
        python train_grpo.py --init-state $(python ckpt.py sft-e2 state) --steps 30   # change e2 to the epoch you picked
        Log format, one line per step
        step 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-state

        4.2Read the log like a practitioner

        ColumnHealthyProblem → what to do
        rewardRises gradually; step-to-step noise is normalFlat for 15 steps: raise --lr to 8e-5, or the questions are too hard
        skipped_groups0.2–0.6Near 1.0: every question is all right or all wrong. Switch to harder / easier questions
        searchesStable or rising slightlyDrops 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
        tokensStableGrowing 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

        Terminal
        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-s30

        To 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).

        Done when
        • One GRPO checkpoint has a higher val F1 than SFT, and ablate.py gives a Δ confidence interval that doesn't contain 0
        Traps
        • 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
        STEP 5

        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.

        1–2 hours≈ $2Tinker

        5.1A stage-by-stage table on test

        ablate.py · 43 linesdownload
        """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.")
        Terminal
        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-grpo
        Output format
        500 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.

        Terminal
        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-b2

        The 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.

        Report template (copy into README.md)
        ## 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.
        Done when
        • 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
        Traps
        • 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 seeCauseFix
        ImportError: apply_chat_template requires jinja2jinja2 is missing (actually hit while writing this)uv pip install jinja2
        ... requires accelerate when running --localUsing device_mapThe 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_actionThe model writes an explanation without any tagRead the traces; restate the tag rules at the end of SYSTEM
        RuntimeError: the sampler returned no logprobsThe sampler returned no logprobsStop: RL is wrong without logprobs. Update tinker and check again
        test_user_turn_matches_chat_template failsYou switched to a model whose template differs from Qwen'sRewrite user_turn for the new template before running anything else
        Index build gets killedOut of RAM (needs ~2.5 GB)Close some apps, or run it on Colab
        Eval is slow or returns 429Too 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.

        StepWhat runsEstimate
        1 Eval200 closed-book questions≈ $0.05
        2 Harness200 base questions ($0 locally)≈ $0.20
        3 SFT6,000 episodes to collect traces + ~3.6M training tokens + 3 evals≈ $8
        4 GRPO30 steps × 256 episodes + training on groups with variance + 3 evals≈ $12
        5 Ablation2,500 episodes on test≈ $2
        TotalA $40 limit leaves plenty of room≈ $22

        Sources

        1. Samaya AI: Samaya Sterling (Oct 28, 2026): the Eval → Harness → SFT → GRPO → Ablation recipe and the reference numbers on the map
        2. Tinker Quickstart and SDK Cheatsheet: the API used in the code
        3. Tinker Models & Pricing (read on Nov 1, 2026)
        4. tinker-cookbook: search_tool recipe: RL settings and Qwen3.5-4B reference numbers on HotpotQA
        5. HotpotQA (Hugging Face version)
        6. Search-R1 and STaR: the search/answer tag protocol and rejection sampling
        7. bm25s