Nemotron 3.5 Bolt vs Gemma 4 E4B on CPU: Does NVIDIA's Post-Training Help?

NVIDIA says Nemotron 3.5 Bolt runs in about 4GB. I pulled the e4b tag into Ollama on an older Xeon in my home lab and ran it against Gemma 4 E4B, the model it was post-trained from: file sizes, RAM, tok/s, tool calls and conversational mode.

LLMBits card: Nemotron 3.5 Bolt vs Gemma 4 E4B on CPU in Ollama

The first number I got out of Nemotron 3.5 Bolt was 1.34 tokens per second. Gemma 4 E4B, the model NVIDIA post-trained it from, did 3.38 on the same VM with the same prompt. The gap came from the DFlash draft model that Ollama ships inside the Bolt tag. With drafting turned off (draft_num_predict: 0), Bolt generated 3.47 tok/s and Gemma 3.51.

All of this ran on the llmbits VM in my home lab: Ollama 0.40.10 on Ubuntu 26.04.1, 8 vCPUs of an Intel Xeon E5-2670 (AVX, no AVX2), 16 GB of RAM and no GPU. It's the same VM from my Ollama install post and the tiny model CPU shootout.

What NVIDIA says Bolt is

NVIDIA released Nemotron 3.5 Bolt as a small model post-trained from google/gemma-4-E4B-it. These are NVIDIA's claims, from the Hugging Face model card and the NVIDIA research page. I didn't verify any of them on a GPU:

  • It runs "in as little as ~4GB in its target workloads". The blog's examples are an RTX 5070 at 3.5 GB of VRAM and 133.4 tok/s, and a Jetson Orin Nano 8GB at 4.2 GB and 15.5 tok/s.
  • DFlash, its speculative drafter, "delivers up to 21% higher throughput relative to Gemma-4-E4B's original MTP drafter" while "preserving the target model's output distribution".
  • It uses "up to ~80% fewer tokens vs peers".
  • A "conversational mode" makes it respond "concisely and without as much textual formatting".

NVIDIA's own benchmark table compares Bolt with Gemma 4 E4B at Q4, in reasoning mode, measured on NeMo Gym:

Benchmark (NVIDIA, Q4) Nemotron 3.5 Bolt Gemma 4 E4B
BFCL v3 67.31 65.03
When2Call MT 60.50 61.12
ToolSandbox 74.13 68.31
Tau2 52.32 38.72
IFBench 64.00 42.67
EQ-Bench v3 38.15 39.77

The question I wanted answered on my own hardware: on a CPU box, at the same quant, does the post-training show up in speed, RAM or tool calls?

Pull the e4b tag, not the default

The library page for nemotron-3.5-bolt lists :latest at 25GB with a 256K context. It's the 74B-A9B Mamba-2/Transformer MoE, which won't fit this VM. The small model is :e4b, and I pinned the quant so both models match:

ollama pull nemotron-3.5-bolt:e4b-q4_K_M
ollama pull gemma4:e4b-it-q4_K_M

The page lists :e4b as "7.2GB - 7.9GB" because it maps to more than one variant. On my Linux VM it resolved to digest 69f2be39b309, the same one as e4b-q4_K_M. ollama show reports a context length of 65536, which lines up with the 64K input limit on the model card. The tags page says 128K.

ollama show also lists thinking with a default of true for both models, so leave think unset and you get reasoning. Every test below sets think explicitly.

The "4GB" isn't the download

NVIDIA's ~4GB figure is VRAM in its GPU target workloads. The Ollama tag is 7.9 GB on disk, because it bundles the model, a vision projector and the DFlash drafter. These are the blob sizes on my VM:

Q4_K_M in Ollama Nemotron 3.5 Bolt e4b Gemma 4 E4B
Tag size (/api/tags) 7,908,813,050 B (7.9 GB) 6,583,656,505 B (6.6 GB)
Main GGUF 6,180,446,208 B (5.74 GiB, 6.56 BPW) 5,493,439,296 B (5.10 GiB, 5.83 BPW)
Drafter DFlash, 736,812,096 B (BF16, 360M params, drafts 7) MTP, 98,653,280 B (78M params, drafts 3)
Vision projector 991,551,808 B 991,552,256 B
Pull time on my line 3:05.94 2:23.32

Bolt's main GGUF is byte-for-byte the same size as the Q4_K_M file on Hugging Face. It's 687 MB bigger than Gemma's at the same quant name, and llama.cpp reports 6.56 bits per weight against 5.83. My read is that NVIDIA's Q4_K_M keeps more tensors at higher precision, but the card doesn't say.

The Ollama page calls Bolt a "compact vision-language model" and ollama show lists vision and audio. The model card lists text in and text out. I only tested text.

How I ran the tests

Every request went through /api/chat with num_ctx 8192, using a small Python harness. Both models load as llama-server processes under Ollama 0.40.10 (Bolt's manifest requires 0.40.10, so I upgraded from 0.40.7 first). I stopped the other containers on the VM during the runs. The core call:

payload = {"model": "nemotron-3.5-bolt:e4b-q4_K_M", "stream": False, "think": False,
           "messages": [{"role": "user", "content": nonce + TEXT}],
           "options": {"num_ctx": 8192, "num_predict": 200, "temperature": 0, "seed": i,
                       "draft_num_predict": 0}}   # drafter off; omit this line for the default

The speed prompt is 1,875 tokens of lab notes with a random nonce up front so the prompt cache can't help, and every run generates exactly 200 tokens. I ran five runs per model with the drafters on (the tag defaults) and three with them off. A separate script sampled RSS of each llama-server PID every half second.

Speed and RAM

Bar charts of generation speed and peak RAM for Nemotron 3.5 Bolt e4b and Gemma 4 E4B Q4_K_M on an AVX-only Xeon, with drafters on and off
Medians of 5 runs (drafters on) and 3 runs (drafters off), 1,875-token prompt, 200 tokens out
Median, Q4_K_M, CPU only Prompt tok/s Gen tok/s Total for 200 tokens Peak RSS
Bolt, DFlash on (tag default) 25.37 1.34 227.9 s 9.8 GiB
Bolt, draft_num_predict 0 26.97 3.47 127.6 s 8.1 GiB
Gemma 4 E4B, MTP on (tag default) 25.95 3.38 132.4 s 7.7 GiB
Gemma 4 E4B, draft_num_predict 0 26.60 3.51 128.5 s 7.3 GiB

Cold loads took 21 to 25 seconds for either model. Prompt processing is the same for both, which fits two models with the same Gemma 4 E4B architecture. With the drafters off, generation is the same too.

The drafters are where they differ. The Ollama journal logs llama.cpp's acceptance stats after each request:

Ollama journal lines showing the DFlash drafter at about 23 percent draft acceptance for Bolt and the MTP drafter at 55 to 66 percent for Gemma 4 E4B
Journal lines from the speed runs on my VM

DFlash proposes 7 tokens per step and about 23% of them survive (125 of 515 on the first run). That's a mean of 2.6 tokens per step. Gemma's MTP head proposes 3 and keeps 55 to 66%, for a similar 2.6 to 3.0 per step. On this CPU neither drafter paid for itself: Gemma's MTP lost about 4% against no drafter, and Bolt's DFlash took generation from 3.47 down to 1.34 tok/s. NVIDIA says DFlash proposes all its draft tokens in one forward pass, which the target then verifies in parallel. My read is that on AVX-only cores a 360M BF16 drafter plus an 8-token verify pass costs far more than on the NVIDIA GPUs the card lists as test hardware, and at 23% acceptance that work never comes back. NVIDIA doesn't say which hardware the 21% figure comes from.

DFlash also cost 1.7 GiB of RSS. And ollama ps misreports both models while a drafter is loaded on CPU: it showed 1,167 MB for Bolt and 497 MB for Gemma with drafting on, against 7,292 MB and 6,443 MB with it off. Trust RSS for this one.

If you run Bolt on a CPU, turn the drafter off. I set it per request:

curl -s http://127.0.0.1:11434/api/chat -d '{
  "model": "nemotron-3.5-bolt:e4b-q4_K_M",
  "messages": [{"role": "user", "content": "hello"}],
  "options": {"draft_num_predict": 0}
}'

To make it stick, put it in a Modelfile. I checked this one on the VM: ollama show bolt-nodraft --parameters lists draft_num_predict 0, ollama ps shows 7.1 GB, and the journal logs "no implementations specified for speculative decoding".

cat > Modelfile.nodraft <<'EOF'
FROM nemotron-3.5-bolt:e4b-q4_K_M
PARAMETER draft_num_predict 0
EOF
ollama create bolt-nodraft -f Modelfile.nodraft

Since NVIDIA says DFlash preserves the target model's output distribution, I ran every test after this point with draft_num_predict 0 on both models.

Tool calling: 28 cases, same prompt for both

NVIDIA's biggest claimed gains are in agentic benchmarks (ToolSandbox, Tau2), so tool calling is where I expected post-training to show. I wrote a 28-case test against Ollama's native tools array with seven made-up home assistant tools (weather, timer, messages, lights, currency, notes, calendar). Both models get the same system prompt, tools and user turns at temperature 0, seed 42, think off:

  • 10 single calls with every argument given, and 2 turns that need two calls
  • 5 questions that need no tool, and 3 requests no tool can do (book a flight, order pizza, turn a light off when the tool only turns lights on)
  • 5 requests missing a required detail, where the right move is to ask
  • 3 multi-turn cases that include a tool result

A call case passes when the function names and required arguments match. A no-tool case passes on a text reply with no call, and a clarify case also needs a question mark in the reply. The scorer, from tools_test.py:

def score(kind, expected, out):
    msg = out.get("message", {})
    calls = [tc.get("function", {}) for tc in (msg.get("tool_calls") or [])]
    text = (msg.get("content") or "").strip()
    if kind == "call":
        if len(calls) != len(expected): return False, f"{len(calls)} calls, expected {len(expected)}"
        left = list(calls)
        for e in expected:
            hit = next((g for g in left if match_call(e, g)), None)
            if hit is None: return False, f"no match for {e['name']} {e['args']}"
            left.remove(hit)
        return True, "ok"
    if calls: return False, "called " + ",".join(c.get("name", "?") for c in calls)
    if not text: return False, "empty reply"
    if kind == "clarify" and "?" not in text: return False, "no question asked"
    return True, "ok"
Think off, 28 cases Nemotron 3.5 Bolt Gemma 4 E4B
Single and double calls (12) 12 11
No tool needed or possible (8) 8 8
Ask for a missing detail (5) 5 5
Multi-turn (3) 2 2
Script score 27 / 28 26 / 28
Median time per case 6.6 s 6.2 s
Tool test output from my VM: Bolt scores 27 of 28 and Gemma 4 E4B 26 of 28, with Gemma failing c10 and both failing m03
Tail of logs/05-tools-think-off.log on my VM

The two models were closer than NVIDIA's benchmark gap suggests. One miss is the scorer's fault. In case m03 the user asks the assistant to check which contact is their brother, and there's no contacts tool. Both models said so and asked for the name, without a question mark:

bolt:  I do not have access to your contact list to check names. Please provide your brother's contact name so I can send the message.
gemma: I do not have access to your contact list to check for names. If you tell me his name, I can send the message.

I'd count both as correct, which makes it 28 to 27. The one real difference is c10, "Add a team sync tomorrow at 9am called Standup." The system prompt says today is 2026-10-23. Bolt called create_event with 2026-10-24 and 09:00. Gemma replied "What is tomorrow's date?" and made no call.

Both models handled the cases I worried about most. Neither tried turn_on_light for "Turn off the bedroom light", and both asked for a length on "Set a timer." instead of guessing. Bolt asked "How many minutes should the timer be set for?" and Gemma asked "How long should the timer be?"

With thinking on, I reran 9 of the cases (the ones most likely to change: messages, two-call turns, no-tool and clarify cases, both multi-turn tool cases). Scores didn't move: 8 of 9 for each model, and the miss was m03 again, for the same missing question mark. The cost did move. Bolt's median per case went from 6.8 s to 52.4 s, and Gemma's from 6.9 s to 43.1 s. Bolt wrote a median of 230 tokens per case with thinking on and Gemma 198. NVIDIA says Bolt uses "up to ~80% fewer tokens vs peers". On these 9 tool cases it used more than the model it was trained from.

Reasoning on and off

For thinking, I used NVIDIA's recommended sampling (temperature 1.0, top_p 0.95) and three short prompts with checkable answers: a server count (5 owned, 3 running), the capital of Japan, and a UPS load question ((520 + 95) / 900 = 68.3%). This is one seed per prompt, so it's six answers per model. Treat it as a spot check.

One seed, temp 1.0 Bolt, think off Bolt, think on Gemma, think off Gemma, think on
Servers: 5 owned, 3 running "5 owned, 5 running" (wrong) Correct, 193 tokens, 30.3 s "Own: 5 Running: 5" (wrong) "5 servers are running" (wrong), no thinking
Capital of Japan Tokyo, 2.1 s Tokyo, 73 tokens, 11.2 s Tokyo, 2.2 s Tokyo, 42 tokens, 7.3 s
UPS load, 68.3% 61.11% (wrong) 68.33%, 195 tokens, 32.8 s Correct, 11.6 s 85.71% (wrong), no thinking

Bolt with thinking on was the only setup that got all three. It spent 250 to 584 characters of thinking on each and about 30 seconds on the two math prompts at this CPU's 3.5 tok/s. Gemma with think: true returned an empty thinking field on two of the three prompts and answered both of those wrong. In the tool test it always produced thinking, so this looks like Gemma deciding the prompt was too simple to think about. I can't tell that from one seed.

If I were running Bolt as a local assistant on this box, I'd leave thinking off for tool routing, where it changed nothing and cost 45 seconds a turn, and turn it on per request for arithmetic.

Conversational mode has no Ollama switch

NVIDIA documents conversational mode as a chat template flag, passed through an OpenAI-style client:

extra_body={"chat_template_kwargs": {"conversational_mode": True}}

Ollama doesn't use that template. ollama show lists renderer gemma4-nemotron and parser gemma4, so Ollama formats the prompt with its own Go renderer and the model runs under llama-server with Jinja off. There's no chat_template_kwargs in Ollama's API. The template in the Ollama blob shows what the flag does. It adds one system instruction, after your own system message if you have one:

You are having a spoken conversation with the user. Respond as briefly as possible while still addressing their questions fully.

So I sent that exact text as a system message, think off, temperature 1.0, one seed per prompt. I gave the same message to Gemma, which was never trained for it:

Total over 3 prompts Bolt, default Bolt, spoken system message Gemma, default Gemma, spoken system message
Answer characters 6,880 143 8,828 348
Generated tokens 1,892 48 2,321 140
Markdown marks (**, #, bullets, fences) 174 0 190 0
Time 359.1 s 15.1 s 464.4 s 28.3 s

Without the instruction, Bolt's answers were already about 22% shorter than Gemma's, and Gemma's port answer ran into the 1,024-token cap. With it, both dropped the Markdown and got short. Bolt answered the port question with "Use lsof -i :8080 or netstat -tulnp | grep 8080." in 28 tokens.

Short also cost accuracy. The UPS question asks for the percentage under load, and the right answer is 68.3%. In default mode both models worked it through and wrote 68.33%. In spoken mode Bolt replied "82.3%" and nothing else, and Gemma added the idle draw too and got 102.78%. One seed per prompt again, so take it as a warning rather than a rate. For anything with arithmetic I'd keep thinking on or drop the spoken instruction.

The tool test scripts

These are the two files behind the tool results, unedited. common.py wraps /api/chat with urllib and reads OPTS='{"draft_num_predict":0}' for extra options. tools_test.py takes THINK=on for thinking and CASES=c03,m02 for a subset, and writes one raw JSON response per case plus a CSV.

import json, time, urllib.request, subprocess, threading, os

OLLAMA = "http://127.0.0.1:11434"
MODELS = {
    "bolt": "nemotron-3.5-bolt:e4b-q4_K_M",
    "gemma": "gemma4:e4b-it-q4_K_M",
}
NUM_CTX = 8192

EXTRA_OPTS = json.loads(os.environ.get("OPTS", "{}"))  # e.g. OPTS='{"draft_num_predict":0}'

def chat(payload, timeout=7200):
    if EXTRA_OPTS:
        payload.setdefault("options", {}).update(EXTRA_OPTS)
    req = urllib.request.Request(OLLAMA + "/api/chat", data=json.dumps(payload).encode(),
                                 headers={"Content-Type": "application/json"})
    t0 = time.time()
    with urllib.request.urlopen(req, timeout=timeout) as r:
        out = json.loads(r.read())
    out["_wall_s"] = round(time.time() - t0, 3)
    return out

def unload(model):
    try:
        req = urllib.request.Request(OLLAMA + "/api/generate",
            data=json.dumps({"model": model, "keep_alive": 0}).encode(),
            headers={"Content-Type": "application/json"})
        urllib.request.urlopen(req, timeout=120).read()
    except Exception as e:
        print("unload err", e)
    time.sleep(3)

def runner_rss_kb():
    """Sum RSS of the model process. Ollama 0.40.x runs llama-server; older builds run 'ollama runner'."""
    try:
        out = subprocess.run(["ps", "-eo", "rss,args"], capture_output=True, text=True).stdout
    except Exception:
        return 0
    tot = 0
    for line in out.splitlines()[1:]:
        parts = line.strip().split(None, 1)
        if len(parts) == 2 and "ollama" in parts[1] and ("runner" in parts[1] or "llama-server" in parts[1]):
            tot += int(parts[0])
    return tot

class RSSSampler:
    def __init__(self, interval=0.5):
        self.interval = interval; self.peak = 0; self._stop = False
        self.t = threading.Thread(target=self._run, daemon=True)
    def _run(self):
        while not self._stop:
            self.peak = max(self.peak, runner_rss_kb()); time.sleep(self.interval)
    def __enter__(self): self.t.start(); return self
    def __exit__(self, *a): self._stop = True; self.t.join()

def mem_available_kb():
    for line in open("/proc/meminfo"):
        if line.startswith("MemAvailable"):
            return int(line.split()[1])

def ollama_ps():
    with urllib.request.urlopen(OLLAMA + "/api/ps", timeout=30) as r:
        return json.loads(r.read())

def stats(out):
    pe_n = out.get("prompt_eval_count", 0); pe_d = out.get("prompt_eval_duration", 0) / 1e9
    ev_n = out.get("eval_count", 0); ev_d = out.get("eval_duration", 0) / 1e9
    return dict(prompt_tokens=pe_n, prompt_s=round(pe_d, 3),
                prompt_tps=round(pe_n / pe_d, 2) if pe_d else None,
                gen_tokens=ev_n, gen_s=round(ev_d, 3),
                gen_tps=round(ev_n / ev_d, 2) if ev_d else None,
                load_s=round(out.get("load_duration", 0) / 1e9, 3),
                total_s=round(out.get("total_duration", 0) / 1e9, 3),
                wall_s=out.get("_wall_s"))
"""Tool-calling test: identical system prompt, tools and user turns for both models.
Ollama /api/chat with the native tools array. temperature 0, seed 42, think per THINK env.
Each case has an expectation:
  call     -> exactly these function calls (names + required args), order-insensitive
  none     -> answer in text, no tool call
  clarify  -> no tool call, and the reply asks a question (contains '?')
Writes raw/tools__<model>__<think>__<case>.json and tools_<think>.csv."""
import json, os, csv, sys
from common import *

THINK = os.environ.get("THINK", "off") == "on"
TAG = "think-on" if THINK else "think-off"
os.makedirs("raw", exist_ok=True)

def fn(name, desc, props, req):
    return {"type": "function", "function": {"name": name, "description": desc,
            "parameters": {"type": "object", "properties": props, "required": req}}}
S = lambda d: {"type": "string", "description": d}
N = lambda d: {"type": "number", "description": d}
TOOLS = [
    fn("get_weather", "Get the current weather for a city.", {"city": S("City name, e.g. Paris"), "unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "description": "Temperature unit. Default celsius."}}, ["city"]),
    fn("set_timer", "Start a countdown timer.", {"minutes": N("Length of the timer in minutes"), "label": S("Optional label")}, ["minutes"]),
    fn("send_message", "Send a text message to one of the user's contacts.", {"contact": S("Contact name"), "text": S("Message body")}, ["contact", "text"]),
    fn("turn_on_light", "Turn on the smart light in a room.", {"room": S("Room name, e.g. kitchen"), "brightness": N("Brightness percent 1-100. Default 100.")}, ["room"]),
    fn("convert_currency", "Convert an amount between currencies using today's rate.", {"amount": N("Amount to convert"), "from_currency": S("ISO code, e.g. USD"), "to_currency": S("ISO code, e.g. EUR")}, ["amount", "from_currency", "to_currency"]),
    fn("search_notes", "Search the user's saved notes.", {"query": S("Search text")}, ["query"]),
    fn("create_event", "Add an event to the user's calendar.", {"title": S("Event title"), "date": S("Date as YYYY-MM-DD"), "time": S("24-hour time HH:MM")}, ["title", "date", "time"]),
]
SYSTEM = ("You are a home assistant running on a local device. Today is 2026-10-23 (Friday). "
          "Use a tool only when it is needed to carry out the user's request. "
          "If a required detail is missing, ask the user for it instead of guessing. "
          "If no tool can do what the user asks, say so briefly. Keep replies short.")

def U(t): return [{"role": "user", "content": t}]
def C(name, **args): return {"name": name, "args": args}

CASES = [
  # --- single call, all args given (10)
  ("c01", "call", U("What's the weather in Lisbon right now?"), [C("get_weather", city="lisbon")]),
  ("c02", "call", U("Set a timer for 12 minutes."), [C("set_timer", minutes=12)]),
  ("c03", "call", U("Text Maria that I'm running 10 minutes late."), [C("send_message", contact="maria", text="*late*")]),
  ("c04", "call", U("Turn on the garage light at 40 percent."), [C("turn_on_light", room="garage", brightness=40)]),
  ("c05", "call", U("How much is 250 US dollars in euros?"), [C("convert_currency", amount=250, from_currency="usd", to_currency="eur")]),
  ("c06", "call", U("Find my notes about the Proxmox backup schedule."), [C("search_notes", query="*proxmox*")]),
  ("c07", "call", U("Put 'Dentist' on my calendar for 2026-11-03 at 14:30."), [C("create_event", title="dentist", date="2026-11-03", time="14:30")]),
  ("c08", "call", U("Is it raining in Tokyo? Give it to me in fahrenheit."), [C("get_weather", city="tokyo", unit="fahrenheit")]),
  ("c09", "call", U("Half an hour timer for the laundry please."), [C("set_timer", minutes=30)]),
  ("c10", "call", U("Add a team sync tomorrow at 9am called Standup."), [C("create_event", title="standup", date="2026-10-24", time="09:00")]),
  # --- two calls in one turn (2)
  ("c11", "call", U("What's the weather in Paris and in Berlin?"), [C("get_weather", city="paris"), C("get_weather", city="berlin")]),
  ("c12", "call", U("Start a 5 minute tea timer and turn on the kitchen light."), [C("set_timer", minutes=5), C("turn_on_light", room="kitchen")]),
  # --- no tool needed (5)
  ("n01", "none", U("What is 17 times 23?"), []),
  ("n02", "none", U("What does an HTTP 404 status code mean?"), []),
  ("n03", "none", U("Thanks, that's all for now."), []),
  ("n04", "none", U("What's the difference between a timer and an alarm?"), []),
  ("n05", "none", U("Translate 'good morning' into Spanish."), []),
  # --- no tool can do it (3)
  ("n06", "none", U("Book me a flight to Denver on Monday."), []),
  ("n07", "none", U("Order a large pepperoni pizza."), []),
  ("n08", "none", U("Turn off the bedroom light."), []),
  # --- missing a required detail: should ask (5)
  ("q01", "clarify", U("Set a timer."), []),
  ("q02", "clarify", U("Send a message to Alex."), []),
  ("q03", "clarify", U("Turn on the light."), []),
  ("q04", "clarify", U("Convert 80 dollars."), []),
  ("q05", "clarify", U("Schedule a haircut for next week."), []),
  # --- multi-turn with a tool result (3)
  ("m01", "none", [*U("What's the weather in Oslo?"),
      {"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "get_weather", "arguments": {"city": "Oslo"}}}]},
      {"role": "tool", "tool_name": "get_weather", "content": json.dumps({"city": "Oslo", "temp_c": 4, "conditions": "light snow"})}], []),
  ("m02", "call", [*U("Find my note with the Wi-Fi password and text it to Sam."),
      {"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "search_notes", "arguments": {"query": "Wi-Fi password"}}}]},
      {"role": "tool", "tool_name": "search_notes", "content": json.dumps({"results": [{"title": "Guest Wi-Fi", "body": "Network: lab-guest, password: orange-kettle-42"}]})}],
      [C("send_message", contact="sam", text="*orange-kettle-42*")]),
  ("m03", "clarify", [*U("Text my brother happy birthday."),
      {"role": "assistant", "content": "Sure. What's your brother's name as it appears in your contacts?"},
      *U("Hmm, actually I'm not sure which contact. Can you check?")], []),
]
# m03: there is no contacts tool, so the right move is to say it can't look that up / ask for the name.

def norm(v):
    if isinstance(v, str):
        s = v.strip().lower()
        try: return float(s)
        except ValueError: return s
    if isinstance(v, (int, float)): return float(v)
    return v

def arg_ok(exp, got):
    if isinstance(exp, str) and exp.startswith("*") and exp.endswith("*"):
        return isinstance(got, str) and exp.strip("*") in got.lower()
    return norm(exp) == norm(got)

def match_call(exp, got):
    if got.get("name") != exp["name"]: return False
    a = got.get("arguments") or {}
    if isinstance(a, str):
        try: a = json.loads(a)
        except Exception: return False
    return all(k in a and arg_ok(v, a[k]) for k, v in exp["args"].items())

def score(kind, expected, out):
    msg = out.get("message", {})
    calls = [tc.get("function", {}) for tc in (msg.get("tool_calls") or [])]
    text = (msg.get("content") or "").strip()
    if kind == "call":
        if len(calls) != len(expected): return False, f"{len(calls)} calls, expected {len(expected)}"
        left = list(calls)
        for e in expected:
            hit = next((g for g in left if match_call(e, g)), None)
            if hit is None: return False, f"no match for {e['name']} {e['args']}"
            left.remove(hit)
        return True, "ok"
    if calls: return False, "called " + ",".join(c.get("name", "?") for c in calls)
    if not text: return False, "empty reply"
    if kind == "clarify" and "?" not in text: return False, "no question asked"
    return True, "ok"

rows = []
only = sys.argv[1:]  # optional model keys
for key, model in MODELS.items():
    if only and key not in only: continue
    for m in MODELS.values(): unload(m)
    SUBSET = [c for c in os.environ.get("CASES", "").split(",") if c]
    for cid, kind, msgs, expected in CASES:
        if SUBSET and cid not in SUBSET: continue
        path = f"raw/tools__{key}__{TAG}__{cid}.json"
        if os.path.exists(path):
            out = json.load(open(path))
        else:
            payload = {"model": model, "stream": False, "think": THINK, "tools": TOOLS,
                       "messages": [{"role": "system", "content": SYSTEM}, *msgs],
                       "options": {"num_ctx": NUM_CTX, "temperature": 0, "seed": 42, "num_predict": 2048}}
            out = chat(payload)
            out["_request"] = payload
            json.dump(out, open(path, "w"), indent=1)
        ok, why = score(kind, expected, out)
        msg = out.get("message", {})
        st = stats(out)
        row = dict(model_key=key, model=model, think=TAG, case=cid, kind=kind, passed=int(ok), why=why,
                   tool_calls=json.dumps([tc.get("function") for tc in (msg.get("tool_calls") or [])]),
                   reply=(msg.get("content") or "")[:300].replace("\n", " "),
                   thinking_chars=len(msg.get("thinking") or ""), gen_tokens=st["gen_tokens"],
                   total_s=st["total_s"], gen_tps=st["gen_tps"],
                   extra_opts=json.dumps(out.get("_request", {}).get("options", {})))
        rows.append(row); print(cid, key, TAG, "PASS" if ok else "FAIL", why, flush=True)
    unload(model)
fn_csv = f"tools_{TAG}.csv"
with open(fn_csv, "w", newline="") as f:
    w = csv.DictWriter(f, fieldnames=list(rows[0].keys())); w.writeheader(); w.writerows(rows)
for key in MODELS:
    r = [x for x in rows if x["model_key"] == key]
    if r: print(key, TAG, sum(x["passed"] for x in r), "/", len(r))
print("TOOLSDONE", TAG)

What I'd do next

The Ollama defaults on this tag hurt on CPU. If you pull nemotron-3.5-bolt:e4b on a box without a GPU, set draft_num_predict to 0 before you judge it. Then it runs at Gemma 4 E4B's speed with about 0.8 GiB more RAM and a 1.3 GB bigger download.

On my tests, the post-training showed up in small places. Bolt resolved "tomorrow" in c10 where Gemma asked, and with thinking on it got the two math prompts Gemma missed. It didn't show up as a different class of tool caller. Both models passed every no-tool and clarify case. NVIDIA's ToolSandbox and Tau2 numbers cover longer multi-step tasks than mine, so my next run is a multi-turn agent loop with real tool results on this same VM, plus the DFlash drafter on a machine with AVX2 to see whether the slowdown is this Xeon or CPU inference in general.

Great! Next, complete checkout for full access to LLMBits.
Welcome back! You've successfully signed in.
You've successfully subscribed to LLMBits.
Success! Your account is fully activated, you now have access to all content.
Success! Your billing info has been updated.
Your billing was not updated.