The scripts are in the repository, not in the installed package. Clone it,
then
cd recipes/community/same-entrypoint-before-after before running the commands below. Browse this recipe on GitHub.no_change_detected (-0.025), but could not tell whether that
meant “the fine-tune did nothing” or “the two arms went through different code paths”. Its
baseline ran through wai.hosted_model, which sends chat_template_kwargs={"enable_thinking": false};
its trained model ran through wai.local_model, which has no way to send it
(#264). The confound sat exactly on the
before/after axis. Does the delta survive putting both arms through the same entry point?
What you will learn: how to pin a task set across a model swap, why the noise floor has to come
first, and the one thing that turned out to matter more than the fine-tune — that grading a
reply which still contains its own reasoning moves this eval’s headline number by 26 points,
which is an order of magnitude more than the adapter moved it.
You need WHILEAI_API_KEY. No training run and no wai.serve call: both models were already
hosted on the account. --dry-run needs no key and no GPU.
Run it
What I actually ran
- Base arm: served model
qwen3-4b-think(adapterRunId: None, i.e. the bareQwen/Qwen3-4B) - Trained arm: served model
billing-boring-half(adapterRunId: run_327b614f3682cae5, the previous seat’s SFT run: 87 rows, LoRA r=16, 2 epochs, held-out loss 5.4585 → 1.7336) - Both through
wai.local_model(ENDPOINT, name, tools=..., api_key=..., temperature=0.8) - 37 pinned held-out tasks × 4 repeats ≈ 151 rows per pass,
concurrency=8 - Three base passes (seeds 101/202/303) for the noise floor, one adapter pass (seed 101)
Results
Noise floor first. Three passes of the same base over the same 37 pinned tasks, uniformly regraded: 0.750 / 0.794 / 0.801.run_std 0.0275, noise_band 0.055,
stability: high_variance, tasks_in_every_run: 37. Anything under ~5.5 points is nothing.
The before/after, base → trained adapter, both arms through local_model, paired on 37 of 37
tasks (n_unpaired_tasks: 0):
Null A/B control (base pass 2 → base pass 3, same policy resampled): every metric
no_difference_detected, regressions: [], replicated: True. The eval does not cry wolf.
The answer to the question I asked
No — the fine-tune’s headline win does not survive a clean measurement, and that is a real answer rather than a confounded one. pass@1 moved +6.8 points, just above the 5.5-point noise band, but the paired interval covers zero on 37 tasks. With both arms through the same entry point there is no<think> asymmetry left to blame: the honest reading is “not detectable at
this sample size”, and 37 tasks is too few to resolve a 7-point effect.
But the headline was hiding a trade, in both directions. The adapter genuinely learned the
thing it was trained for — no_invented_amount +0.183, interval clear of zero — and paid for
it somewhere nobody was looking: escalated_over_200 −0.123, interval clear of zero. The base
model never once issued a credit above $200; the trained model does it about 22% of the time.
That is a policy violation the fine-tune introduced, and a single pass@1 number nets the two
against each other and reports a shrug.
delta_report caught it without being asked — slipped: ['marker:escalated_over_200'] plus a
warning naming the drop and its interval. It did not fail the run, because ok: True only
reflects must_not_regress=, and I had guarded no_invented_amount — the marker that improved.
I guessed the wrong marker to protect. The lesson for anyone copying this: must_not_regress
should list the behaviours you are not training, not the one you are.
It also volunteered ceiling: True — “the before run already passes 19 of 37 paired tasks every
time, so there is little room to measure improvement; use harder situations” — which is the same
warning the previous seat got and the reason to take item 2 under Next seriously.
The <think> sensitivity, measured on both arms
Both arms shift by ~26 and ~18 points. Because the shift is large and unequal between arms, a
before/after that strips on one side and not the other — which is what
hosted_model vs
local_model does for you — can manufacture or erase a result of this size at will.
What did not work, and what to copy
Copy this: grade a thinking model’s reply with the reasoning stripped, and say which you did. Every one of 151 rows came back with<think> still in final_text, and 34 of them were cut off
before </think> — so final_text was reasoning and nothing else. Markers that read prose then
score the model’s hypotheticals (“if the invoice were $500…”) as claims it made. Stripping the
reasoning moved no_invented_amount from 0.310 to 0.577 and pass@1 from 0.485 to 0.750. The
noise band from three re-runs of the same model on the same pinned tasks is 0.055, so the
artefact is about 4.8× the noise band — much larger than the thing the SDK correctly tells
you to worry about, and invisible unless you go looking.
This is the whole reason the previous run’s -0.025 was uninterpretable: with one arm through
hosted_model and one through local_model, that 26-point artefact is applied to one side only.
Do not trust a marker pinned at exactly 1.000 — but do not delete it either. On the base arm
escalated_over_200 and used_a_tool both read 1.000 with a zero-width interval. That is not a
pass, it is a marker that never had a chance to fail: across 151 base rows the model made 2
issue_credit calls against 63 escalate_to_human calls, so the guarded branch was barely
exercised. The ledger has warned about this shape twice and I still shipped two of them
(#270).
The twist is that escalated_over_200 turned out to be the most valuable marker in the run.
Degenerate on the before side, it had plenty of variance on the after side — the adapter
issues large credits the base never did — and that is exactly how the regression surfaced. So the
rule is not “drop markers that pin at 1.000”; it is “a marker pinned at 1.000 is not yet
evidence of anything, and you will not know until something moves”. Keep it, and do not report
it as a pass.
split_pseudo_production does not give you a task-disjoint holdout
(#268). It is prompt-disjoint, which is what
its docstring promises, but 16–17 of ~28–37 held-out scenario_ids also appear in train, and
decontaminate reports the pair clean because it compares prompts. Since the engine’s whole job is
writing rephrasings of one situation, prompt-disjoint and situation-disjoint are very different
sets. The script prints the task overlap so you cannot miss it.
Things that cost me time and are worth knowing before you start:
- The row handed to
grader=hassteps, notmessages. - A callable agent’s own step dict uses
args; the simulated model’s steps usearguments. My>$200check readargsonly, so it silently never fired — and a marker that never fires reads as a perfect 1.000. - The declared
returnsshape is not what comes back. I declaredamount_usd; the simulator returned{"status": "ok", "amount": 455.0}. Match on values, not key names. marker_summaryentries carry the rate undermean. There is noratekey; asking for one gets youNonenext to a populatedci95.wai.local_modelhas a zero-character docstring (#269), and it is the only way to evaluate a model you serve. I read the signature.- Thinking mode costs about 8× wall clock: 151 rows in 492 s here, against ~124 rows in 38–57 s
for the previous seat’s
hosted_modelpass at the same concurrency.
Cost
No training run was started and nothing new was served — both models were already hosted, so this is inference only: four passes × ~151 rows ≈ 33 minutes of an already-warm A10G, well under $1. The SDK still reports no cost anywhere (run, wai.models(), whileai status), so that is an
estimate from the serving GPU’s published rate, not a number the product gave me.
Next
- Re-run with the reasoning turned off at the server, not stripped in the grader, once
#264 has a
thinking=False. Stripping is a workaround; the 34 truncated rows are lost, not recoverable, because the model spent its whole budget reasoning and never answered. - Build the eval on situations that actually exercise the
>$200branch. Neitherscenario_dimensionsnor the prompt carries the invoice amount — the simulator invents it — so the only way I can see to force the branch isresult_shapes=/fault_plans=onlocal_model. Untested. - Someone should run
method="grpo"on the hosted path.list_runs()still shows onlysftand one faileddpoon this account.