This project has been created as part of the 42 curriculum by zleullie.
Turn natural-language requests into structured function calls with a small language model (Qwen/Qwen3-0.6B by default; Qwen3.5-0.8B and Gemma 3 270M also work), using constrained decoding so that every output is valid, schema-compliant JSON.
Given a list of available functions and a prompt such as "What is the sum of
2 and 3?", the program does not answer 5. It returns the function to call
and its typed arguments:
{"prompt": "What is the sum of 2 and 3?", "name": "fn_add_numbers", "parameters": {"a": 2.0, "b": 3.0}}A 0.6B-parameter model asked to "please answer in JSON" fails often. This project does not rely on the model behaving. It drives the model one token at a time and, at every step, only lets it choose among the tokens that keep the output valid: a real function name, a real parameter name, and a value of the declared type. The model still makes every choice (which function, which values); the decoder only removes the choices that would break the format.
Overview of the pipeline:
- Load and validate the function definitions, the prompts and the prompt configuration (pydantic).
- Build a chat prompt for the chosen model that lists the functions in Qwen's tool-calling format.
- Generate the call under constraints: function name, then each parameter name and value. Among the allowed tokens, the one the model scores highest is taken (greedy decoding).
- Show the call live on the terminal as it is written, then write all
results to
data/output/function_calls.json.
- Linux, Python 3.10 to 3.14, and uv.
- No GPU is needed or used: the project installs the CPU-only build of PyTorch.
- The model is downloaded from Hugging Face on the first run. The default
model (Qwen3-0.6B) needs no account. Gemma is gated: accept its licence on
Hugging Face and put
HF_TOKEN=...in a.envfile;make runloads it.
make installThis runs uv sync. torch comes from PyTorch's CPU index
(download.pytorch.org/whl/cpu, about 200 MB) instead of PyPI, whose Linux
wheel also pulls several GB of NVIDIA libraries that a CPU run never uses.
On 42 computers, if ~/goinfre exists, the Makefile puts everything large
there: the virtual environment, uv's Python installs, the Hugging Face cache
(the models) and the mypy cache, plus the uv cache unless UV_CACHE_DIR is
already set. The home directory quota is too small for torch and the models,
and goinfre is fast local storage. It is ephemeral, so after a reset
make install simply downloads everything again. Outside 42 nothing changes,
and any of these variables can still be overridden from the environment.
uv run python -m src [--functions_definition <file>] [--input <file>] [--output <file>] [--prompts_config <file>]| Option | Default |
|---|---|
--functions_definition |
data/input/functions_definition.json |
--input |
data/input/function_calling_tests.json |
--output |
data/output/function_calls.json |
--prompts_config |
config/prompts.json (model name, prompt texts, limits) |
Outside 42, uv run works as shown. On a 42 computer, use make run so the
goinfre paths above are set.
| Rule | What it does |
|---|---|
make install |
uv sync (CPU-only torch) |
make run |
python -m src with the default paths |
make debug |
Runs the program under pdb |
make test |
Runs the tests in tests/ with pytest |
make lint |
flake8 . and mypy with the flags required by the subject |
make lint-strict |
flake8 . and mypy . --strict |
make clean |
Removes caches and data/output/ |
config/prompts.json holds everything about the prompt and decoding that is
data rather than code: the model name, the system prompt, the chat template,
the prefixes the program writes itself and the token limit. It is validated
by the PromptConfig pydantic model.
There is one such file per supported model. They differ by model_name and
by chat_template, which holds each model's own turn markers:
| File | Model |
|---|---|
config/prompts.json (default) |
Qwen/Qwen3-0.6B |
config/qwen3.5-0.8b.json |
Qwen/Qwen3.5-0.8B |
config/gemma-3-270m-it.json |
google/gemma-3-270m-it (gated) |
make run ARGS="--prompts_config config/qwen3.5-0.8b.json"config/prompts.schema.json is the JSON schema of that model, and
prompts.json points to it with "$schema", so an editor such as VS Code can
autocomplete the keys and flag invalid values (for example a negative
max_value_tokens). After changing PromptConfig, regenerate it with:
uv run python -m src.modelsDefault paths:
make runCustom paths:
uv run python -m src \
--functions_definition data/input/functions_definition.json \
--input data/input/function_calling_tests.json \
--output data/output/function_calls.jsonWhen stderr is a terminal, each call is drawn in gray while the model writes it, redrawn on every token:
fn_
fn_add
fn_add_numbers(
fn_add_numbers(a
fn_add_numbers(a=2
fn_add_numbers(a=2.0, b
fn_add_numbers(a=2.0, b=3.0)
(shown here one state per line; on the terminal it is a single line that updates in place). Once the call is complete the gray line is erased and the final call is printed. The full result goes to the output file:
fn_add_numbers(a=2.0, b=3.0)
fn_greet(name="shrek")
fn_reverse_string(s="hello")
fn_get_square_root(a=16.0)
fn_substitute_string_with_regex(source_string="Programming is fun", regex="([aeiou])", replacement="*")
[
{"prompt": "What is the sum of 2 and 3?", "name": "fn_add_numbers", "parameters": {"a": 2.0, "b": 3.0}},
{"prompt": "Greet shrek", "name": "fn_greet", "parameters": {"name": "shrek"}}
]The prompt follows the model's chat template (Qwen3's is shown here) and
Qwen's Hermes-style tool format. Each function from
functions_definition.json is converted to a JSON schema and listed inside
<tools></tools>. The
assistant turn is then pre-filled up to the first character the model has to
choose:
<|im_start|>system
You call tools. Tools: <tools>
{"type": "function", "function": {"name": "fn_greet", ...}}
</tools>. To call, start with <tool_call>.
<|im_end|>
<|im_start|>user
Greet shrek<|im_end|>
<|im_start|>assistant
<think>
</think>
<tool_call>
{"name": "
The empty <think></think> block is what Qwen3 itself writes when thinking
is disabled; it is part of the chat template in the config. Gemma has no
system role and no thinking block: its template puts the system text at the
top of the user turn. Everything up to {"name": " is fixed text written by
the program, not by the model.
All constrained generation goes through one function, _generate_with(ids, allowed_next). allowed_next receives the tokens written so far and returns
the set of tokens allowed next; an empty set means "done".
loop:
allowed = allowed_next(written)
if allowed is empty: stop
if only one token is allowed: append it without calling the model
else: logits = model(ids); append the allowed token with the highest logit
Two kinds of allowed_next are built on top of it, one for fixed strings
and one for free values. The loop also takes an optional on_token callback,
which the live display uses.
_best_allowed(ids, allowed) asks the model for its logits (one score per
token of the vocabulary) and returns the allowed token with the highest
score. Tokens outside allowed are never looked at, so they can never be
chosen. This is greedy decoding: there is no randomness, and the same prompt
always gives the same call.
_choose turns each candidate into token ids, together with the text around
it (fn_greet" for a function name, "name": for a parameter name), so the
model gets the same tokens it would produce naturally, such as ":. At each
position, the allowed tokens are the next token of every candidate that
matches what was written so far. When only one candidate is left, its
remaining tokens are forced without running the model.
The call is built like this:
- Choose the function name among all function names.
- Append
, "arguments": {. - While some parameters are missing: let the model choose the next
parameter name among the remaining ones, then generate its value. The
value ends with
,, or with}}for the last one.
So every required parameter is always present, none is repeated, and no unknown key can appear.
Strings and numbers have no fixed list of candidates. Their format is
described by a small state machine over characters, for the text
<optional space><value><end>:
| Phase | Allowed next characters |
|---|---|
start |
a space (→ space), or the first character of the value |
string: after the opening " |
anything but a control character; \ → escape; " → end |
string: escape |
one of " \ / b f n r t → back to the string |
| number | an optional leading -, digits, at most one . followed by digits |
| integer | the same as number, but . is never allowed |
end |
the characters of end (, or }}), in order |
A token is allowed if every character of its text can be fed to the
state machine from the current state. The token must not run past the end
(", is fine for end = ",", ", " is not). This character-level check is
what lets the model use its usual merged tokens, such as "[, ]", or
"}}, instead of being forced to write each quote as a separate token.
For each (type, end, state) the set of allowed tokens, and the state each
one leads to, is computed once over the whole vocabulary and cached. Later
steps are dictionary lookups.
To make sure a value always ends, after max_value_tokens (64) tokens only
tokens that close the value are allowed. The written text is then parsed:
json.loads for strings, float for numbers, int for integers. Numbers
are always output as floats (2 → 2.0), as in the subject's examples;
integers stay integers (7), since a function declared with n: int may
check isinstance(n, int) and reject 7.0.
The decoder always knows which part of the call it is writing, so it builds
the name(a=2.0, b view directly instead of parsing half-written JSON:
- while choosing a name, the tokens written so far are decoded and the
surrounding quotes, spaces and
:are stripped (_choose); - while writing a value, the state machine says how many characters of
endwere already written, and those are cut off (_choose_value); - finished arguments are kept as
name=json.dumps(value).
After each token the whole line is sent to the on_call_update callback,
which erases the previous line and prints the new one.
The vocabulary (read from tokenizer.json, which every supported model
ships; Gemma has no vocab.json) stores tokens in an internal form (Ġ or
▁ for a space, Ċ for a newline), so its keys are not the real text. At
startup, each token id is decoded once with the SDK's decode to get its
actual text, and tokens that decode to an empty string are dropped (special
tokens; they could loop forever without progress). This takes about 0.5 to
1 s.
get_logits_from_input_ids runs the model on the whole sequence at every
step. get_logits_cached (added to the SDK) keeps the keys and values the
model computed for the previous call, and only runs the tokens added since.
Writing one more token then costs one token instead of the whole prompt.
The cache can only move forward. When the next prompt arrives, the ids no longer continue the previous ones. Plain attention layers could simply drop the last tokens, but Gemma's sliding-window layers and Qwen3.5's recurrent layers cannot. So the SDK never removes tokens: the first time a new prompt arrives, it rebuilds the cache up to what both prompts share (the system prompt and the tool list) and keeps a copy of that state. Every later prompt restarts from the copy. The decoder falls back to the uncached method when the SDK does not have the cached one.
- The program writes the JSON structure, the model fills the gaps. Rather
than a full JSON grammar, the fixed parts (
{"name": ",, "arguments": {) are appended directly and only the variable parts are constrained. This is simpler to check and saves model calls. - Qwen's native tool format. Listing tools the way Qwen3 was trained to read them (rendered from the model's own chat template) keeps the prompt "in distribution" for the model.
- Greedy decoding, no sampling. An earlier version drew tokens with the sampling settings Qwen recommends (temperature 0.6, top-p 0.95, top-k 20), because Qwen's documentation discourages greedy decoding for free text. In this project most of the text is fixed or masked, the answers are short, and a wrong draw cannot be corrected later. Greedy is simpler, needs no seed, and gives the same output on every run. The benchmark below compares both.
- Forced tokens skip the model. When only one token is allowed, no forward pass is run. Once a function name is unambiguous, the rest of it costs nothing.
- A long system prompt with examples. The prompt has three few-shot examples (percentages, units, "city, STATE") that target real value errors seen on a benchmark. Without a KV cache every prompt token was processed again at every step, which made this prompt too slow on the CPU. With the cache it is processed once and reused for every prompt.
- No thinking. An earlier version let the model reason freely before answering. It cost hundreds of forward passes per prompt and did not pay for itself (see the benchmark), so the code was removed. The prompt keeps the empty thinking block Qwen3 expects.
- One answer format for every model. All three models are made to write
the same JSON call. This keeps one grammar, but it is not what Qwen3.5 was
trained to write (its template uses
<function=...>tags), and Gemma 3 270M was not trained on any tool format. - Prompts live in
config/prompts.json, with a JSON schema. System prompt, chat template, prefixes and limits are data, validated by thePromptConfigpydantic model, so they can be tuned without touching the decoder. The schema is generated from the same model, so the editor and the program agree on what is valid. - pydantic everywhere. Function definitions, prompts, results and the
prompt config are all pydantic models, and files are read and written
through
TypeAdapter. - stderr for progress, file for results. The terminal display (the call
being written in gray, then
name(args)lines) never mixes with the output file. The live display is only enabled when stderr is a terminal, because erasing needs cursor movement escape codes. - CPU only, performance cores only. torch is the CPU build. On a hybrid Intel CPU the SDK restricts the process to one logical CPU per performance core and uses at most that many threads: a thread that lands on an efficiency core makes the others wait (see Speed).
- goinfre on 42 computers. torch and the models do not fit in the home
quota, so the Makefile moves the venv, Python, the caches and the Hugging
Face cache to
~/goinfrewhen it exists.
The output is valid JSON by construction: the JSON structure is written by the program, names are chosen from fixed lists, and values can only be completed along the state machine before being serialised by pydantic. Every parameter of the chosen function is present exactly once, with the declared type.
Measured on 12 November 2026 with the tooling in bench/ (see
Benchmark), on three data sets:
- public: the 11 sample prompts of
data/input, graded by the moulinette (it runs the function with the arguments and compares the result); - private: the moulinette's 11 private prompts, graded the same way;
- xLAM: 37 single-call cases picked from Salesforce/xlam-function-calling-60k, each with its own list of 1 to 6 tools. "Strict" means the right function and exactly the expected arguments.
| Setup | Model | public | private | xLAM function | xLAM strict |
|---|---|---|---|---|---|
| thinking + sampling (before) | Qwen3-0.6B | 9/11 | 10/11 | 37/37 | 30/37 |
| greedy, no thinking (default) | Qwen3-0.6B | 10/11 | 9/11 | 37/37 | 30/37 |
| greedy, no thinking | Qwen3.5-0.8B | 10/11 | 11/11 | 37/37 | 31/37 |
| greedy, no thinking | Gemma 3 270M | 8/11 | 6/11 | 29/37 | 21/37 |
What the failures show (each one was read):
- Removing thinking and sampling cost nothing measurable. Both Qwen3
setups score 49 of 59 over the three sets. The sampled run is a single
draw with seed 42, so a difference of one case either way is noise. With
sampling the model wrote
Shrekfor "Greet shrek"; greedy copiesshrek. - The function is never wrong with the Qwen models. All their misses are argument values.
- Regexes are the weak spot on the sample prompts:
34instead of\d+for "replace all numbers" (Qwen3-0.6B only; Qwen3.5 writes\d+).catinstead of\bcat\band[aeiou]without capitals fail a strict comparison but give the right result, so the moulinette accepts them. - Most xLAM misses come from the missing parameter descriptions. The
project's function format only has a name and a type per parameter, so
the model writes
"French"where the dataset expects"fr", or"2023-01-01"for"2023-01". One case expects a player id (60) that is not in the question at all. These say more about the format than about the model. - Gemma 3 270M is too small for this prompt: it picks the wrong function in 8 of 37 xLAM cases and 5 of 22 subject prompts, and sometimes fills arguments with numbers that are not in the question. Its output is still always valid, which is what the decoder guarantees.
- Earlier reference, different data: in October, greedy decoding on 302
questions of the Berkeley Function Calling Leaderboard (
simple_python) gave about 79 % strict and 91 % after reading the failures by hand. That tooling was not kept.
Possible bias to keep in mind: xLAM's expected answers were themselves generated and checked by language models, and the 37 cases were selected by rules that favour values copied from the question, so these numbers are optimistic compared with real requests.
Intel i7-12700 (8 performance cores, 4 efficiency cores), CPU only, model loading excluded (it takes about 2 s once the files are in the disk cache).
| Setup | Model | 11 sample prompts | per xLAM case | forward passes per call |
|---|---|---|---|---|
| thinking + sampling (before) | Qwen3-0.6B | 198 s | 25.0 s | about 115 to 160 |
| greedy, no thinking (default) | Qwen3-0.6B | 27 s | 3.3 s | about 10 to 14 |
| greedy, no thinking | Qwen3.5-0.8B | 33 s | 3.5 s | about 10 to 12 |
| greedy, no thinking | Gemma 3 270M | 7 s | 1.5 s | about 8 to 17 |
- Where the time goes: almost all of it is the model's forward pass, about 0.2 s per call on Qwen3-0.6B (profiled: 26 of 29 s inside torch's matrix products). The decoder's own Python work is small.
- Thinking was the main cost: about ten times more forward passes per call, for the same accuracy. The subject's limit is 5 minutes; the thinking setup used 3 min 18 s of it on the samples, the default 27 s.
- The KV cache makes each step cost one token instead of the whole prompt (about 720 tokens with the tool list).
- Threads and cores (11 sample prompts, Qwen3-0.6B): 49 s on 1 performance core, 36 s on 2, 30 s on 4, 27 s on 8. The same 4 threads on the efficiency cores take 45 s, and 20 threads over all cores take 70 s. More cores help little because one step mostly reads the model's 2.4 GB of weights from memory. The SDK therefore uses the performance cores only, one thread each.
- What keeps it fast: no thinking, the KV cache, forced tokens without a model call, and the cached token tables.
The tooling is in bench/ and is not needed to run the project.
python bench/make_suites.py # public and private suites from the moulinette
python bench/xlam_select.py # xLAM cases (needs HF_TOKEN, gated dataset)
python bench/run.py --suite bench/data/xlam.json --config config/prompts.json --label my-run
python bench/score.py bench/results/<run> [<other run>] [--failures]Each run writes to a new folder in bench/results/ and never touches an
older one. bench/data/ and bench/results/ are not committed: the xLAM
rows are gated, only their ids are in bench/xlam_ids.json. Run one model
at a time: each takes 3 to 4 GB of RAM.
- Only the
string,numberandintegerparameter types are supported. Any other type (for examplebooleanor an array) raises an error. - Numbers cannot use exponent notation (
1e-9), so the model has to write them out in full. Leading zeros (007) are accepted and parsed as7. \uXXXXescapes are not supported inside strings.- Every parameter of the chosen function is always written: there is no way to leave out an optional one.
- The function format has no parameter descriptions, so the model only sees each parameter's name and type.
- Missing or invalid input files are not yet caught in
main, so they end with a Python traceback instead of a clear error message. - Core pinning only knows Linux on hybrid Intel CPUs; elsewhere the SDK just uses 8 threads.
uv synchas not been tested on a fresh clone outside 42.
- GPU builds of torch. The project first ran on AMD GPUs through ROCm builds of torch, with one uv extra per GPU. It worked on the GPU, but the ROCm build's CPU fallback was about 30 times slower than the CPU build, and 42 computers have no usable GPU. The whole setup was replaced by the CPU-only build.
- A KV cache that cannot go backwards. The first cache removed the last
tokens (
crop) when a new prompt arrived. That works on Qwen3 but fails on Gemma (sliding-window attention) and Qwen3.5 (recurrent layers). The cache now restarts from a saved copy instead. - More threads, slower run. With one thread per logical CPU (20), the sample run took 70 s instead of 27 s: the 4 efficiency cores hold the other threads back. The SDK now pins itself to the performance cores.
- Disk space on 42 computers. The venv, torch and the model do not fit in the home quota, which led to moving all caches to goinfre from the Makefile.
- The vocabulary is not text. Comparing
vocab.jsonkeys to characters never matched spaces or newlines, because they are stored asĠandĊ. Decoding each token id once gave the real text to work with. - Quotes merged with other characters. The first version only accepted a
bare
"to close a string. The model wanted to write]",and couldn't, so it kept writing the rest of the JSON inside the string until the token limit, producing values likeregex="aeiouAEIOU\", \"replacement\": ...". Checking each token's text character by character with the state machine fixed it. - Slow generation. It looked like a bug in the decoder, so a minimal separate decoder was written to compare. It was just as slow: the cost came from re-running the model on the full prompt at every step. The KV cache and skipping forced tokens were the useful levers.
- Values that are well-formed but wrong. Percentages, units and dates were the main errors on the benchmark. Constraints can't fix these; few-shot examples in the prompt targeted them.
- Showing the call live. Erasing the gray text means counting how many
rows were printed, including lines the terminal wrapped. The hard part was
cutting off a partly written end (
"}of"}}) without also cutting a,that belongs inside a string. It also does not work in PyCharm's Run console, which is not a terminal unless "Emulate terminal" is on.
- Lint and types:
make lint(flake8 and mypy with the subject's flags) passes on the project. - Unit tests (
make test):tests/test_decoder_grammar.py: the character state machine, without any model (integers, fractions, signs, ends).tests/test_decoder_calls.py: whole calls with a fake model that uses the real Qwen tokenizer and always prefers the tokens of a target call. It checks that integers stayint, numbers becomefloat, and strings with quotes, backslashes, commas and}}come back unchanged.tests/test_llm_sdk_cache.py: the KV cache gives the same logits as the uncached method on all three models, for every way the ids can change between calls (a model that cannot be downloaded is skipped).tests/test_bench_*.py: the benchmark's scoring and selection rules.
- End-to-end runs on
data/inputafter each change, checking the function names, argument names and types in the output file. - Benchmark: the runs described in Accuracy, graded by the moulinette for the subject's prompts, with each failure read by hand.
Edge cases still worth testing before an evaluation: empty strings, very large
numbers, special characters and quotes in prompts, ambiguous prompts that fit
two functions, functions with many or zero parameters, string values that
contain , or }}, and missing or malformed input files.
The only reference I used directly is Qwen's own documentation:
- Qwen: Function Calling: the tool-call prompt format.
- Qwen/Qwen3-0.6B model card: the model, its chat template, thinking mode and recommended sampling settings.
Everything else (sampling methods, BPE, the JSON grammar, ANSI escape codes, uv and pydantic details) came from web searches, Wikipedia and AI explanations (see below), not from specific sources I can cite.
I used Claude Code (Anthropic) throughout the project: nine sessions on 15 and 16 October 2026, one on 7 November 2026 and one on 12 November 2026. The summary below comes from those session logs. Everything that stayed in the project, I reviewed and can explain.
Learning and explanations. Before writing the decoder I asked how to
approach the project and which vocabulary to look up (logits, greedy
decoding, BPE, logit masking), how generation works with the SDK, what
encode returns (a [1, n] tensor), whether a KV cache was feasible, why the
CPU was so slow, and what the decoder does and does not enforce (return
types, negative numbers, exponents). In November I asked again what made CPU
inference slow; Claude read the code and the running process and confirmed
the forward pass without KV cache, on the full prompt, as the main cost.
Environment and tooling. Claude set up ROCm torch through uv (the AMD
index and the gfx1200 and gfx1034 extras), the GPU detection and .env
loading in the Makefile, the accelerate dependency, typing-only fixes and a
py.typed marker in llm_sdk, and the flake8 exclude for extra virtual
environments. It also diagnosed a broken ROCm install and PyCharm run
configuration problems, and in November added my GitHub repository as a
second remote and merged its commits with my uncommitted work.
Code in src/. I wrote the project structure, the pydantic models, the
file loading helpers and a first version of the decoder: the prompt, two
loops for choosing names, and a first, crude version of the generic
_generate_with(ids, allowed_next) loop, which I designed. I also wrote the
sampling (_select), the sampling fields in PromptConfig and the JSON
schema generation. I asked Claude to:
- split the decoder into functions and move the prompts into
config/prompts.json; - refactor the name choice into
_choose_tokens, and my_generate_withloop into its current, cleaner form; - add value generation for strings and numbers, then replace it with the character-level state machine when merged tokens broke string closing. This is the core of the constraint logic, and it was written by Claude at my request; I reviewed it afterwards;
- add the thinking display (callbacks, gray streaming, erasing),
think_prefixand thethinkconfig flag; - add the live call display (
_show_call, theon_tokenhook in_generate_with,ThoughtPrinter.rewrite), following thefn_add_numbers(a=1, bformat I specified; - switch the prompts to Qwen's tool format, output numbers as floats, and fix the default output path to the one in the subject.
Prompt engineering. Claude analysed the benchmark failures and suggested prompt changes. I picked which ones to apply (remove "you may guess parameters", add few-shot examples for percentages, units and locations). Replacing that prompt with the minimal one was my decision.
Benchmark tooling. Claude wrote a converter for the BFCL simple_python
dataset and a runner that scored outputs against expected answers. These were
development tools and are not part of the submitted project. One lesson from
this: a benchmark run started by Claude overwrote 113 result files from a
25-minute run of mine. The runner was changed to write each run to a new
folder and never delete anything, and I now check what an agent is about to
run before letting it touch outputs.
Review and documentation. Claude checked the project against the subject before delivery (finding the lint failure, the wrong output file name and this README being a stub) and drafted this README from the subject and the session logs, then updated it in November for the new decisions.
Session of 12 November 2026. I gave Claude a list of changes and it planned and carried them out, with one subagent for the benchmark tooling. What I decided: remove thinking, go back to greedy decoding, drop the GPU setup for CPU-only torch, add Qwen3.5-0.8B and Gemma 3 270M, pin the performance cores, and which data to benchmark on. What Claude wrote:
- the removal of the thinking and sampling code (including my
_select), and the three-line greedy_best_allowed; model_nameinPromptConfig, the per-model config files, and reading the vocabulary fromtokenizer.json;- the rewrite of the SDK's KV cache to restart from a saved copy
(
_rewind), after its first version failed on Gemma and Qwen3.5, and the performance-core pinning (_performance_cpus); - the CPU-only
pyproject.tomlfiles and Makefile changes; tests/test_decoder_calls.pyand the extended cache test;- everything in
bench/(written by a subagent, reviewed by Claude): the runner, the scorer and the xLAM case selection; - the benchmark runs, the reading of the failures and this README update.
These are parts I still have to review line by line and be able to explain before an evaluation.
What I did not delegate: deciding the overall design (generic constrained loop, config file, thinking on or off, sampling instead of greedy, the minimal prompt), noticing that Qwen's thoughts always start with "Okay, the user wants to", proposing the minimal-prompt experiment, running the benchmarks, and choosing which suggestions to accept.