Can a small open model match a frontier model?
A fine-tuning case study on an invented trade-capture agent.
1. Summary
| Question | How small can an open model get and still do a reasoning-heavy agent task as well as a frontier model? |
| Bet | A fine-tuned model 5–10× cheaper per task than GPT-5.6 matches its accuracy, generalises, and knows when to hand a trade to a person. |
| Task | Read trade emails and their attachments (broker recaps, counterparty confirmations, corrections) at an invented energy trading company, and book the trades they confirm. |
| Deliverables | A 1,900-thread synthetic dataset, an evaluation harness, the filled results table, and the smallest model size that passes all four gates, or the reason none does. |
| Pass bar | Four gates (section 2): cheap, accurate, general, and routes well to a person. |
| Why it matters | A small model we own costs a fraction per task and is never retired by its provider. |
2. Hypothesis and the four gates
A fine-tuned open model small enough to run 5–10× cheaper per task than GPT-5.6 matches its accuracy, generalises beyond its training threads, and knows when to hand a trade to a person. The hypothesis is disproved if no model size passes all four gates below.
| Cheap |
|
| Accurate |
|
| General |
|
| Routes well |
|
Where the small model could win outright: spotting unusable input. A fine-tuned model learns what a complete, bookable thread looks like; general models tend to fill the gap and book anyway. If its rejection score beats GPT-5.6's, fine-tuning has added domain expertise, not only lower cost. Reported, not gated.
The thresholds are proposals; challenge them. The bar is GPT-5.6, the latest frontier model.
3. The task
The agent turns one email thread, with its attachments, into structured bookings.
3.1 The agent and where it sits
Kestrel Energy Trading GmbH (invented) trades German power and gas. Deals are agreed by phone or chat; what reaches trade support is email: broker recaps with PDF or Excel attachments, counterparty confirmations, corrections, cancellations and forwards, often in long reply chains. About 150 such emails arrive a day, and trade support re-keys every trade by hand. A missed correction or a wrong legal entity surfaces only when the confirmation or invoice fails to match.
The trade-capture agent reads each email thread when a new email arrives and books what the thread actually confirms. Each booking carries a confidence score: above a threshold it is booked straight away; below it, a person reviews it first. When the thread itself is unusable (an attachment that can't be read, a company we don't know) the agent books nothing and says what to fix.
Every thread needs four reasoning steps:
3.2 A worked thread
A short thread to teach the idea. The right column says how to read each part: the green row makes the trade, the red row cancels the add-on. Terms are under Trading terms.
| E1 subject | Recap 16-Feb: Kestrel buys GER Base Mar-27 | A broker recap: trades already agreed by phone |
| E1 body | Hi team, recap attached. Note the 3 MW add-on with Veltrona ET is subject to their credit approval. Tom | Row 2 is conditional |
| E1 recap.pdf row 1 | Buyer Kestrel | Seller Veltrona ET | GER Base Mar-27 | 5 MW | 78.90 | Done | We are the buyer: we buy 5 MW |
| E1 recap.pdf row 2 | Buyer Kestrel | Seller Veltrona ET | GER Base Mar-27 | 3 MW | 79.00 | Done (sub credit) | The add-on, pending credit |
| E2 body | RE: Recap 16-Feb. Credit declined on the add-on (row 2), please disregard it. Row 1 stands. | Cancels row 2 |
| E2 quoted | > Hi team, recap attached. Note the 3 MW add-on… | Old text repeated; nothing new |
Trading terms on this page
| Recap | A broker's summary of trades agreed by phone or thread, usually with a PDF or Excel table. |
| Confirmation | The counterparty's formal record of a trade, usually a PDF with legal text. |
| MW, MWh | Megawatt is the delivery rate; megawatt-hours is energy. 5 MW for 743 hours = 3,715 MWh. |
| Baseload, peak | Base delivers every hour; peak delivers on weekdays 08:00–20:00. |
| Product code | DE-BASE-M-2027-03 = German power, baseload, month, March 2027. |
| Broker, counterparty | The broker arranges the trade; the counterparty is the company on the other side. |
| Bid, offer | The price a buyer will pay, the price a seller asks. A buyer pays the offer; a seller gets the bid. |
| Working, indicative | An order not yet filled; a price that is only a guide. Neither is a trade. |
| Sub credit, CXL | Subject to a credit check; cancelled. |
| Spread | Two linked trades in one: buy one product, sell another. |
| Exchange-cleared | Settled through an exchange such as EEX rather than directly between the two companies. |
| Clock change | The last Sunday of March has 23 hours, the last Sunday of October 25. |
3.3 A realistic thread
Input at full scale: a test-set thread with 5 emails, 3 attachments and 5 bookings, read against a 214-entry counterparty list. The list under the answer shows where a small model slips.
The five emails and their attachments
E1 16 Feb 17:42 From: Tom Reyes <recaps@halden-brokers.example>
To: trade-support@kestrel.example
Subject: EOD recap 16-Feb - Kestrel
Hi all, EOD recap attached. Row 2 credit approved by VEH at 09:40. Any issues shout.
Tom Reyes | Halden Power Brokers | +44 20 ... | This email and any attachments are
confidential ...
Attachment: EOD_recap_HPB_16Feb.xlsx
Row | Ref | Buyer | Seller | Product | MW | Price | Status
1 | H-4471 | Kestrel | Veltrona ET | GER Base Mar-27 | 5 | 78.85 | Done
2 | H-4472 | Kestrel | Veltrona EH | GER Base Mar-27 | 5 | 78.95 | Done (sub credit)
3 | H-4473 | Kestrel | Brenntal | GER Base Q2-27 | 5 | 68.50 | Done
4 | H-4474 | Kestrel | Nordhafen | GER Base Apr-27 | 10 | 69.40 | CXL
5 | - | Kestrel bid | - | GER Base Q2-27 | 10 | 68.40 | Working
6 | H-4476 | Kessel | Kestrel | GER Base Q3-27 | 10 | 71.50 | Done
7 | H-4477 | Kestrel UK | Arcadia Mkts | UK Base Cal-28 | 2 | 74.20 | Done
E2 16 Feb 17:55 From: confirmations@veltrona-eh.example
Subject: Trade Confirmation VEH-2027-00871
Please find our confirmation attached. Kindly countersign within two business days.
Attachment: VEH-2027-00871.pdf (2 pages)
"... Veltrona Energiehandel GmbH (the Seller) and
Kestrel Energy Trading GmbH (the Buyer) ...
Delivery Period: 01.03.2027 00:00 – 01.04.2027 00:00 CET
Contract Capacity: 5 MW | Price: EUR 78,95/MWh
This Confirmation is governed by the EFET General Agreement ... clearing: none ..."
E3 16 Feb 18:03 From: ops@brenntal.example Subject: RE: our trade today (H-4473)
Hello, please note the contracting party on H-4473 is Brenntal Energie Vertrieb GmbH,
not the AG.
> From: Tom Reyes ... (quoted recap follows)
E4 16 Feb 18:10 From: Tom Reyes Subject: RE: EOD recap 16-Feb - Kestrel
Apologies, row 6 price was wrong. Corrected file attached, all else unchanged.
Attachment: EOD_recap_HPB_16Feb_v2.xlsx (identical except row 6: Price 71.05)
E5 16 Feb 18:20 From: J. Kahn <jk@kestrel.example> To: trade-support Subject: FW: Mar peak
pls also book this, agreed yday
---------- Forwarded message ----------
From: desk@kessler-strom.example
We confirm the reduction of trade T-88213 (GER Peak Mar-27) from 5 MW to 3 MW, price
unchanged at 88.10.
Reference data: counterparty list and yesterday's trades
Counterparty list: 13 of 214 entries shown.
CP-0417 Veltrona Energie Trading GmbH [Veltrona ET, VET] active
CP-0453 Veltrona Energie Trading GmbH [VET] inactive, merged into CP-0417
CP-0418 Veltrona Energiehandel GmbH [Veltrona EH, VEH] active
CP-0452 Veltrona Energy Trading UK Ltd [Veltrona UK, VET UK] active
CP-0611 Nordhafen Power AG [Nordhafen, NHP] active
CP-0630 Brenntal Energie AG [Brenntal, BEAG] active
CP-0631 Brenntal Energie Vertrieb GmbH [Brenntal Vertrieb] active
CP-0702 Kessler Strom GmbH [Kessler] active
CP-0703 Kessel Energy SE [Kessel] active
CP-0811 Arcadia Markets Ltd [Arcadia Mkts] active
IN-0002 Kestrel Energy Trading UK Ltd [Kestrel UK, KET UK] internal
BR-0007 Halden Power Brokers [Halden] broker
BR-0012 Arcadia Energy Brokers [Arcadia] broker
Yesterday's trades: 4 of 11 shown.
T-88213 buy 5 MW DE-PEAK-M-2027-03 88.10 CP-0702 Kessler Strom GmbH
T-88214 buy 5 MW DE-PEAK-M-2027-03 88.30 CP-0611 Nordhafen Power AG
T-88209 sell 5 MW DE-PEAK-M-2027-03 88.25 CP-0703 Kessel Energy SE
T-88197 sell 10 MW DE-BASE-Q-2027-Q2 68.90 CP-0703 Kessel Energy SE
Gold answer, computed by code:
| Status | Side | Product | MW | Price | Counterparty | Hours, MWh | Evidence | |
|---|---|---|---|---|---|---|---|---|
| A | new | buy | Mar-27 base | 5 | 78.85 | CP-0417 Veltrona Energie Trading | 743, 3,715 | E4 v2 row 1 |
| B | new | buy | Mar-27 base | 5 | 78.95 | CP-0418 Veltrona Energiehandel | 743, 3,715 | E4 v2 row 2, E1 body, E2 pdf |
| C | new | buy | Q2-27 base | 5 | 68.50 | CP-0631 Brenntal Energie Vertrieb | 2,184, 10,920 | E4 v2 row 3, E3 body |
| D | amendment of T-88213 | buy | Mar-27 peak | 3 | 88.10 | CP-0702 Kessler Strom | 276, 828 | E5 body |
| E | new | sell | Q3-27 base | 10 | 71.05 | CP-0703 Kessel Energy | 2,208, 22,080 | E4 v2 row 6 |
Not booked: row 4 (cancelled), row 5 (working order, never filled), row 7 (Kestrel UK's trade, booked by that entity). Nothing unsure; thread processed.
Where a small model slips, part by part:
- Two versions of one recap: v2 replaces v1, so row 6 is 71.05, not 71.50 (trap 3).
- Side from columns: we are the buyer in rows 1–3 and the seller in row 6.
- Status column: CXL, Working and "Done (sub credit)" mean three different things; the credit approval sits in the E1 body (traps 1, 3).
- Near-duplicate companies: VET is CP-0417, not the inactive CP-0453 with the same name or the UK entity; Brenntal's email moves C to the Vertrieb entity; "Kessel" in row 6 is not "Kessler" in E5 (trap 4).
- Rows that are not ours: row 7 belongs to Kestrel UK, an internal entity (trap 9).
- An amendment among look-alike trades: the forwarded Kessler email changes T-88213, not the Nordhafen or Kessel March peak trades (trap 8).
- Noise everywhere: signatures, disclaimers, quoted history and two pages of confirmation boilerplate that mention clearing (trap 7).
- Four hour counts: March base with the clock change, March peak on weekdays, Q2 and Q3 in full (trap 5).
3.4 Input and output
The agent gets what a trade-support person sees: the thread with its attachments as extracted text, plus our reference data. It returns what that person would book, leave out, flag or send back.
Input schema: thread, emails, attachments, counterparty list, prior trades
| Field | Type | Example | What it is, and why the agent needs it |
|---|---|---|---|
| About the thread | |||
thread_id | string | TH-20270216-0412 | Identifies the thread, so every booking can be traced back to it. |
processed_at | datetime, ISO 8601 | 2027-02-16T18:20+01:00 | When the latest email arrived. Relative dates ("next month") resolve against the sent date of the email that states them. |
timezone | string (IANA) | Europe/Berlin | Delivery hours are German local time, so clock changes count. |
The emails, emails[], oldest first | |||
email_id | string | E4 | Position in the thread; evidence points to it. |
sent_at | datetime | 2027-02-16T18:10+01:00 | Order decides which statement wins. |
from, to, cc | string, string[] | recaps@halden-brokers.example | Who says it. A counterparty's word on its own legal entity beats the broker's. |
subject | string | "RE: EOD recap 16-Feb - Kestrel" | Often misleading: "Confirmed" in a subject is not a confirmation. |
body | string | "Apologies, row 6 price was wrong…" | As written, with signatures, disclaimers and quoted earlier emails left in. |
Attachments, emails[].attachments[] | |||
file_name | string | EOD_recap_HPB_16Feb_v2.xlsx | Names often carry the version; evidence points to it. |
type | enum pdf | xlsx | csv | eml | image | xlsx | Recaps, confirmations, forwarded emails, scans. |
text | string | null | "Row \| Ref \| Book \| Buyer…" | Extracted text, tables as rows. Null when the file can't be read: a case for rejection. |
Who could be on the other side, counterparties[] | |||
id | string | CP-0417 | What the booking must contain. Scoring checks the ID, not the name. |
legal_name | string | Veltrona Energie Trading GmbH | Full company name. |
short_names | string[] | ["Veltrona ET", "VET"] | Names used in recaps. Look-alikes are in the list on purpose. |
role | enum counterparty | broker | internal | counterparty | Brokers and our own other entities are listed so the agent never books them as the other side. |
active | boolean | true | Merged or closed companies stay in the list, sometimes under the same name. Never book an inactive one. |
| What we already booked | |||
prior_trades | array, may be empty | [] | Recent bookings, with trade_id. Needed when an email changes an earlier trade. |
Output schema: status, trades, not_booked, unsure, input_problems
Examples are from the 3.2 thread.
| Field | Type | Example | What it is, and how it's decided |
|---|---|---|---|
| The whole thread | |||
thread_status | enum processed | rejected | processed | rejected when the input is not good enough to book from. Then trades is empty and input_problems says why. |
trades[]: which trade | |||
status | enum new | amendment | cancellation | new | Creates a trade, or changes or cancels one booked before. |
amends_trade_id | string | null | null | The earlier trade being changed. Required unless new. |
side | enum buy | sell | buy | From our side: the column or sentence naming Kestrel decides. |
| What is delivered, and when | |||
commodity | enum power | gas | power | |
product_code | string, from the rulebook | DE-BASE-M-2027-03 | Market, profile, length and period in one code. Anything the rulebook can't produce is wrong. |
delivery_start | datetime, ISO 8601 with offset | 2027-03-01T00:00+01:00 | First hour of delivery. |
delivery_end | datetime, ISO 8601 with offset | 2027-04-01T00:00+02:00 | End, exclusive. The offset changes at the clock change. |
| How much | |||
volume_mw | number | 5 | Delivery rate as agreed. |
delivery_hours | integer | 743 | Counted from the calendar: peak days, clock changes, leap years. Never looked up. |
volume_mwh | number | null | 3715 | MW × hours. Null if either is unknown. |
| Price | |||
price | number (2 dp) | null | 78.90 | Per MWh, as finally agreed. Null if the thread never settles it; never an older version, never 0. |
currency | enum EUR | EUR | Default. |
| With whom | |||
counterparty_id | string | null | CP-0417 | From the list. Null plus an unsure entry if two companies could be meant. |
broker_firm | string | null | Halden Power Brokers | From the sender of the recap; null for direct trades. |
desk | enum Power DE | Gas DE | Power DE | From the product. |
exchange_cleared | boolean | false | True only if the thread says the trade is cleared; boilerplate that mentions clearing isn't enough. |
| Proof | |||
evidence | string[], locators | ["E1/recap.pdf/row 1"] | Where the trade is stated, changed or cancelled: email, attachment and row or page. Must include the deciding one. |
| How sure | |||
confidence | number, 0–1 | 0.97 | The model's estimate that this booking is fully right. Above the threshold it is booked straight away; below it, a person reviews it (7.4). |
not_booked[]: mentioned, left out on purpose | |||
what, why | string | "add-on 3 MW at 79.00", "cancelled in E2" | The trade or row and the reason it isn't booked. |
evidence | string[], locators | ["E1/recap.pdf/row 2", "E2/body"] | Where it shows. |
unsure[]: questions for a person | |||
trade_index | integer | 0 | Which trade the question is about. |
field, reason | string | price, "agreed by phone, not in the thread" | What is missing and why it can't be read from the thread. |
input_problems[]: why a thread was rejected | |||
code | enum, from the rulebook | attachment_unreadable | What is wrong with the input (6.2). |
evidence | string[], locators | ["E1/recap_scan.png"] | Where the problem shows. |
fix_target | enum sender | counterparty_list | rulebook | routing | sender | What has to change for the thread to be bookable. Scored exactly. |
fix_hint | string | "The recap is a low-resolution scan; ask Halden for the Excel file." | One sentence a person can act on. Not scored. |
One training sample: the fields around each thread
A sample is one thread plus what training and scoring need: the gold answer, a realistic agent answer, and labels for the traps the thread tests.
| Field | Type | Example | Why it's there |
|---|---|---|---|
sample_id | string | S-00412 | Unique ID. |
split | enum train | dev | test | train | Test samples never reach the training team. |
trap_tags | enum[] | ["visible_signal", "calculated_number"] | Which traps the thread tests; gives the per-trap score. |
twin_of | string | null | S-00413 | Its twin, a copy with one edit; gives the twin-pair score. |
input | object | as above | What the model sees. |
gold_output | object | as above | The gold answer, computed by code. What the model is trained toward. |
agent_output | object | books the cancelled 3 MW add-on | A realistic agent answer, wrong in 20% of samples. Gives the model an agent run to critique and supplies the wrong side for preference tuning. |
verdicts | array | extra trade 3 MW at 79.00: wrong, status, "E2 cancels the add-on" | Per field: agent value, gold value, right | wrong, error type, reason. The notes a reviewer would write. |
label_noise | boolean | false | True on the 10% slice whose gold answer is deliberately wrong (trap 12). |
The 3.2 thread as input and output JSON
// input
{
"thread_id": "TH-20270216-0412", "processed_at": "2027-02-16T09:55+01:00", "timezone": "Europe/Berlin",
"emails": [
{"email_id": "E1", "sent_at": "2027-02-16T09:20+01:00",
"from": "recaps@halden-brokers.example", "to": ["trade-support@kestrel.example"], "cc": [],
"subject": "Recap 16-Feb: Kestrel buys GER Base Mar-27",
"body": "Hi team, recap attached. Note the 3 MW add-on with Veltrona ET is subject to their credit approval. Tom",
"attachments": [{"file_name": "recap.pdf", "type": "pdf",
"text": "Row | Buyer | Seller | Product | MW | Price | Status\n1 | Kestrel | Veltrona ET | GER Base Mar-27 | 5 | 78.90 | Done\n2 | Kestrel | Veltrona ET | GER Base Mar-27 | 3 | 79.00 | Done (sub credit)"}]},
{"email_id": "E2", "sent_at": "2027-02-16T09:55+01:00",
"from": "recaps@halden-brokers.example", "to": ["trade-support@kestrel.example"], "cc": [],
"subject": "RE: Recap 16-Feb: Kestrel buys GER Base Mar-27",
"body": "Credit declined on the add-on (row 2), please disregard it. Row 1 stands.\n> Hi team, recap attached. ...",
"attachments": []}
],
"counterparties": [
{"id": "CP-0417", "legal_name": "Veltrona Energie Trading GmbH", "short_names": ["Veltrona ET", "VET"], "role": "counterparty", "active": true},
{"id": "CP-0418", "legal_name": "Veltrona Energiehandel GmbH", "short_names": ["Veltrona EH", "VEH"], "role": "counterparty", "active": true},
{"id": "BR-0007", "legal_name": "Halden Power Brokers", "short_names": ["Halden"], "role": "broker", "active": true}
],
"prior_trades": []
}
// output
{
"thread_status": "processed",
"trades": [{
"status": "new", "amends_trade_id": null, "side": "buy", "commodity": "power",
"product_code": "DE-BASE-M-2027-03",
"delivery_start": "2027-03-01T00:00+01:00", "delivery_end": "2027-04-01T00:00+02:00",
"delivery_hours": 743, "volume_mw": 5, "volume_mwh": 3715,
"price": 78.90, "currency": "EUR", "counterparty_id": "CP-0417",
"broker_firm": "Halden Power Brokers", "desk": "Power DE", "exchange_cleared": false,
"evidence": ["E1/recap.pdf/row 1", "E2/body"], "confidence": 0.97
}],
"not_booked": [{"what": "add-on 3 MW at 79.00", "why": "subject to credit; credit declined in E2",
"evidence": ["E1/recap.pdf/row 2", "E2/body"]}],
"unsure": [], "input_problems": []
}
4. What makes it hard: the fifteen traps
A trap is a kind of thread where a shortcut, learned in place of the rule, gives the wrong answer. These are common failure modes of LLM agents reading messy business text. Traps 1–12 are about getting the answer right; 13–15 about knowing when to hand a thread to a person or send it back. The dataset is built so every shortcut fails somewhere.
Grouped by the reasoning steps in 3.1. ✗ is the shortcut's answer, ✓ the gold answer. A twin is a copy of a thread with one edit (6.3).
| # | Trap | Shortcut | Example | Caught by |
|---|---|---|---|---|
| What still stands | ||||
| 1 | Visible signal overridden | "Done" or "Confirmed" means a trade | Recap row "Done (sub credit)"; reply: "credit declined": book ✗, not booked ✓ | Twin whose reply says "credit approved" |
| 3 | Precedence | The first version is the truth | Recap v1 row 6 at 71.50, v2 at 71.05: 71.50 ✗, 71.05 ✓ | Threads with 2–3 versions; the answer is never in the first |
| 8 | Earlier history | Every mention is a new trade | Forwarded "reduce T-88213 from 5 to 3 MW": new 3 MW trade ✗, amendment ✓ | Test-only; without prior trades the answer is unsure |
| Who | ||||
| 4 | Look-alike parties | Closest string wins | "Veltrona ET": Energiehandel ✗, Energie Trading ✓; inactive twin with the same name ✗ | Look-alike and inactive entries in every list. A name matching nothing is trap 14 |
| 9 | Wrong or messy source | Every row in an attachment is ours | Kestrel UK's row booked here ✗; quoted history read as a new email ✗ | Recaps with other desks' and entities' rows; quoted replies; forwards. A thread for another company is rejected |
| The numbers | ||||
| 5 | Calculated number | Memorise hours per product | Mar-27 base: 744 h ✗, 743 h ✓ (clock change) | Train 2026–27, test 2028 |
| 10 | Off-list vocabulary | Copy the attachment's wording | "GER Base Mar-27", "78,95" ✗; product code, 78.95 ✓ | Schema check first |
| 11 | Rare tail | Never predict rare | Q1/Q2 spread row booked as one trade ✗, two legs ✓ | ≥ 15 per rare product; spreads test-only |
| What is unknown | ||||
| 2 | Safe fallback | Unfamiliar layout = no trade | New recap template with a "We sold" column: no trade ✗, sell ✓ | Recap layouts held out for test |
| 6 | Empty, zero, default | Always fill, or never | "Price as agreed by phone": last quoted price ✗, null plus an unsure entry ✓ | Empty, zero, default in equal numbers; the trade goes to review |
| 7 | Flags fire too easily | Keyword means yes | Confirmation boilerplate mentions clearing: cleared ✗ | Half the keyword mentions mean no |
| Knowing when to hand over | ||||
| 13 | Overconfidence | Every answer gets 0.95 | Books Veltrona Energiehandel at 0.97 ✗; same answer at 0.40, sent to review ✓ | Threshold fixed on the calibration split; calibration error; wrong trades booked straight away |
| 14 | Booking from broken input | Every thread yields a booking | Recap is an unreadable scan: values guessed from the subject ✗; rejected as attachment_unreadable, ask the sender ✓ | Usable vs unusable twin pairs; attachment_missing only in test |
| 15 | Over-caution | When in doubt, hand it over | Readable recap with OCR slips rejected as unreadable ✗, booked ✓; every trade sent to review ✗ | At most 2% of usable threads rejected; coverage within 5 points of frontier |
| Under all | ||||
| 12 | Noisy labels | Learn the labellers' mistakes | Gold answers that are wrong or follow older rules | Train with and without a 10% noisy slice |
Levers that help a small model, each tested as a variant:
- reasoning traces that restate what is still live;
- a calendar tool for counting hours;
- schema-constrained decoding;
- the rulebook kept in the prompt;
- look-alikes in every training counterparty list;
- a confidence score fitted on the calibration split (7.4).
5. The dataset
The dataset is 1,900 synthetic threads, delivered as JSONL. Four controls shape it: splits (what is held out), the variation matrix (what varies), reasoning ingredients (what forces reasoning) and tiers (how many ingredients one thread combines).
5.1 Splits
Blue is training data (300 clean single trades, 450 trap threads, 150 with a messy surface, 100 with deliberately noisy labels), grey is calibration, amber is test. Traps are oversampled so each shortcut fails often enough to measure.
| Split | Threads | Held by | Used for |
|---|---|---|---|
train | 900 | Training team | Fine-tuning |
dev | 100 | Training team | Choosing hyperparameters and checkpoints |
calibration | 300 | Evaluator | Fixing the confidence threshold, nothing else (7.4) |
test | 600 | Evaluator | The results table, in seven splits (7.2) |
5.2 Variation matrix
Scale per thread: 1–12 emails with 0–4 attachments (about 1,000–20,000 tokens), a counterparty list of 150–300 entries with 2–4 near-duplicates per active name and about 10% inactive, and 0–40 of yesterday's trades. Each training thread draws one level per dimension at the shares below. Quotas then make every trap occur with every delivery period and every way a trade is stated.
5.3 Reasoning ingredients by tier
An ingredient is a feature that forces a reasoning step; a tier is how many a thread combines: tier 1 none (300 plain threads), tier 2 one, tier 3 two or three, tier 4 four or more. Each cell counts training threads where the ingredient decides the answer. Every ingredient appears alone and combined, so a failure traces to the ingredient or the combination.
| Ingredient | Forces the model to | Tier 2 300 | Tier 3 280 | Tier 4 120 | Total |
|---|---|---|---|---|---|
| Revisions "disregard row 2", recap v2 | Keep state; apply later emails to earlier trades | 56 | 131 | 94 | 281 |
| Implicit or false confirmations "ok to book" confirms; "indicative" doesn't | Tell a trade from talk | 44 | 103 | 74 | 221 |
| Calculated numbers peak hours, clock changes, fills | Compute the value; copying fails | 44 | 103 | 74 | 221 |
| Look-alike parties "VET" vs "VEH" | Pick one ID among near-duplicates | 40 | 93 | 67 | 200 |
| Conflicting statements v1 vs v2; body vs attachment | Apply precedence | 36 | 84 | 60 | 180 |
| Interleaved trades one recap, seven rows, two entities | Track which row is which trade | 30 | 70 | 50 | 150 |
| Relative dates "next month", "bal-week" | Resolve against the email's sent date | 26 | 61 | 44 | 131 |
| Missing facts price agreed by phone | Abstain; guessing fails | 24 | 56 | 40 | 120 |
The realistic thread in 3.3 is a tier-4 case: it combines revisions, look-alike parties, conflicting versions, several trades, an amendment and four hour counts.
5.4 Agent answers: right and wrong
Every sample carries a realistic agent answer, the kind of output an agent run logs, so it can feed preference tuning. The agent answer is fully right in 80% of threads. In the other 20%, the error is one of the kinds below, most tied to a trap in section 4. About a quarter of the wrong answers also carry a routing error (traps 13–15): high confidence on a wrong trade, a booking from an unusable thread, or a usable thread rejected.
6. Building the dataset
How the threads are generated and checked.
6.1 Pipeline
- Paraphrase with a model that is not under test. A frontier baseline that also wrote the threads would be scored on its own style.
- Step 4 is a blind second read. A second model answers the thread without seeing the gold answer. Any disagreement goes to a person or the thread is discarded. Target: under 2% of threads discarded for a wrong gold answer.
- Train and test never share a template, a paraphrase seed, or any five-word sequence other than standard closing phrases.
6.2 Rulebook v0
The eleven rules that code computes the gold answers from. v1 is a deliverable (8).
The eleven rules
| Product code | {market}-{profile}-{tenor}-{period}. Market DE (power) or THE (gas, base only). Tenor D, WE, W, BOM, M, Q, Y. Period 2027-03, 2027-Q2, 2027-W09, 2027, or a start date for D, WE, BOM. |
| Hours | Local time, Europe/Berlin. Base: every hour. Peak: Mon–Fri 08:00–20:00, holidays included. Gas day 06:00–06:00. Clock changes count. |
| Relative products | Against the sent date of the email that states them: "next month" = following month; "bal-month" = tomorrow to month end; "bal-week" = tomorrow to Sunday; "WE" = coming Sat–Sun; "Q+1" = next quarter; "Cal" alone = next year. "Mar" alone = base. |
| What is a trade | Book recap rows marked done, confirmations, and trades an email explicitly confirms ("ok to book", "confirmed"). Working orders, indicative prices and cancelled rows are not trades. Our side is the column or sentence naming the Kestrel entity this mailbox books for; rows for other Kestrel entities are not ours. A buyer pays the offer; a seller gets the bid. |
| Precedence | 1. A later email beats an earlier one. 2. A newer attachment version replaces the older one. 3. A counterparty's statement of its own legal entity beats the broker's recap. 4. Firm beats indicative. 5. "Sub X": book only once X is confirmed in the thread; unresolved → unsure. 6. Quoted history is context, not new information; a forward from our own trader counts as an instruction. |
| Changes | Extra volume at the same terms → a separate trade. A corrected term in the same thread → one final record. A change to an earlier day's trade → amendment with amends_trade_id. |
| Counterparty | Legal name, then exact short name, then unique abbreviation. Two candidates → null + unsure. Never a broker or internal entry, never an inactive one. The counterparty's own correction of its entity overrides the recap. |
| Defaults | Currency EUR; exchange-cleared false; market DE for power; profile base. Desk and broker from the input. |
| Unknown | Never 0 for unknown: null plus an unsure entry. volume_mwh is null if MW or hours are unknown. |
| Numbers | "5" = 5 MW. Prices to 2 decimals; "78,90" = 78.90. Spread legs share the MW. |
| Unusable input | Reject the thread (thread_status rejected, no trades) when it refers to earlier emails that are not in it and the trade can't be rebuilt (thread_incomplete), names a company not in the list (counterparty_unknown), needs a product the rulebook can't encode (product_not_in_rulebook), belongs to another company or desk (wrong_mailbox), or its key attachment can't be read (attachment_unreadable) or is announced but absent (attachment_missing). One problem entry per issue. Never book from a rejected thread. |
6.3 Twins and wrong agent answers
A twin is a copy of a thread with one edit. Flip twins change one detail and must change the answer. Invariance twins change only the surface and must not. Traps 1–9, 11 and 14 each get 20 flip-twin pairs, spread across the test splits; a pair counts only if both threads are fully right. A wrong agent answer is the gold answer with one error applied.
The twin edit for each trap
| 1. Visible signal | Reply says credit approved vs declined |
| 2. Safe fallback | Known recap template vs a new layout saying the same |
| 3. Precedence | Swap which recap version is the latest |
| 4. Look-alikes | Abbreviation points to the other company |
| 5. Calculated number | Shift the month across a clock change or leap day |
| 6. Empty, zero, default | Price stated vs "as agreed by phone" |
| 7. Flags | Clearing stated in the body vs only in the boilerplate |
| 8. History | Prior trades given vs withheld |
| 9. Source | Row for our entity vs the same row for Kestrel UK |
| 10. Vocabulary | Invariance: "GER Base Mar-27", "DE base March", "M3-27" |
| 11. Rare tail | Spread row vs the same two products as separate rows |
| 13. Overconfidence | No twin: scored by calibration over every thread |
| 14. Broken input | Readable recap vs the same recap as an unreadable scan; a look-alike counterparty vs one missing from the list |
| 15. Over-caution | Clean recap vs the same recap with OCR slips, both bookable |
The nine error operators for wrong agent answers
Start from the gold answer and apply one error, chosen to fit the thread's trap, at the error mix in 5.4. The scorer then writes the verdicts, so they always agree with the answer.
| Drop | Set a filled field to null (left empty). |
| Decoy | Take a wrong value that is in the thread: the first price, the look-alike company, 744 hours. |
| Status | Book a cancelled, working or conditional row; miss a confirmed trade; book another entity's row. |
| Flip | Invert a yes/no flag or the side. |
| Invent | Fill an unknown with 0.00 or an earlier quoted value. |
| Drift | Write an off-list value: "monthly", "78,90", DE_BASE_MAR27. |
| Overconfident | Keep a wrong field and set its trade's confidence above the threshold. |
| Book anyway | Book a trade from an unusable thread instead of rejecting it. |
| Over-caution | Reject a usable thread, or send a clear trade to review. |
error_type takes exactly these nine values, or null when the field is right.
6.4 Quality gates and data rules
Every generated batch must pass six quality gates (separate from the four gates in section 2):
- Schema-valid, 100%.
- The blind second read agrees with the gold answer, or the thread is fixed or dropped.
- Human spot check of 5% per batch (50 for the first). The reviewer checks: realistic (scored 1–5), gold answer correct, trap present, only one answer possible. The gold answer is correct in at least 98%.
- Distribution within 2 points of the variation matrix.
- No train–test overlap (templates, seeds, long n-grams).
- Name scan: no real company or person names, zero hits.
Data rules:
- The generator reads only the rulebook; no real data goes in.
- No people appear, and every company is invented; the name scan (gate 6) blocks any thread with a real name.
7. Evaluation
7.1 The results table
One column per gate in section 2. Each row is a model at one size, averaged over three seeds; the cells stay empty until the study fills them.
| Model (per size) | Accurate | General | Routes well | Cheap | |
|---|---|---|---|---|---|
| Threads fully right, in-distribution | Drop on 450 held-out threads, points | Trades auto-booked at 99% precision | Unusable threads rejected | Cost per 1,000 threads | |
| Open base, untuned | · | · | · | · | · |
| Fine-tuned: gold | · | · | · | · | · |
| Fine-tuned: + reasoning | · | · | · | · | · |
| Fine-tuned: preference | · | · | · | · | · |
| GPT-5.6 (latest) | · | · | · | · | · |
| GPT-5.5 (previous generation) | · | · | · | · | · |
Every row uses the same prompt and rulebook, temperature 0, and schema-constrained decoding where supported. The three fine-tuned rows train on: gold answers only; gold answers plus distilled reasoning traces; preference pairs of the gold answer against the wrong agent answer.
Also checked, outside the table
- Pass-or-fail floors: ≥99% schema-valid output and ≥80% on each of traps 1–11 (Accurate); coverage within 5 points of frontier, ≥95% of unsure fields flagged, ECE ≤0.05, ≤2% of usable threads wrongly rejected and the right problem code in ≥90% of rejections (Routes well).
- Where a model breaks: the score on each of the six held-out splits (7.2) and on each trap. These locate a failure; the pooled drop decides the gate.
How a thread is scored: code, not an LLM judge
The gold answer is computed by code and the output is structured JSON, so scoring is a field-by-field comparison. No LLM judge: it would add noise a 5-point gate can't absorb, cost money per run, and a frontier judge may favour frontier-style answers.
- Schema check: validate against a JSON Schema (types, required fields, allowed values). Invalid output = thread wrong, and it counts against the 99% format bar.
- Normalise: numbers to 2 decimals, datetimes parsed with their offset, whitespace trimmed. No fuzzy mapping: an off-list value stays wrong.
- Match trades: pair predicted and gold trades one-to-one on side, product, counterparty and volume. An unpaired predicted trade is a wrong booking; an unpaired gold trade is a miss.
- Compare fields exactly on each pair.
not_bookeditems match on their evidence locators;unsureitems on (trade, field). The free-textwhyandreasonare not scored, andconfidenceis not part of "fully right": it is scored on its own (7.4). - Rejected threads: fully right when the gold answer also rejects and the problem codes and fix targets match. Booking from an unusable thread counts as a wrong booking.
- Evidence: passes if it includes the deciding email or attachment row, which the generator knows by construction.
The only LLM in the loop is the blind second read in the data quality gates (6.4), which catches wrong gold answers before they enter the dataset.
All metrics and their definitions
| Metric | Definition |
|---|---|
| Threads fully right (primary) | Trades, not-booked list and unsure list all match the gold answer. A trade matches when side, product, MW, price, counterparty and status all match after normalising. |
| Twin-pair accuracy | Both threads of a flip twin fully right. |
| Invariance | Same output for both threads of an invariance twin. |
| Field accuracy | Per field, over matched trades. |
| Wrong-booking rate | Share of threads with at least one booked trade that does not match the gold answer or should not exist. |
| Rejection precision, recall | Of rejected threads, the share that were unusable; of unusable threads, the share rejected. F1 combines them. |
| Problem diagnosis | Share of correct rejections with the right code and fix_target. |
| Coverage at 99% | Share of trades auto-booked when the threshold is set so 99% of auto-booked trades are fully right. |
| Risk–coverage (AURC) | Area under the curve of auto-booked error rate against coverage as the threshold sweeps; lower is better, no threshold needed. |
| Calibration (ECE) | Expected calibration error over 10 confidence bins: of trades given 0.9, are 90% right? |
| Review precision | Share of trades sent to review that were actually wrong: how much of a reviewer's time is well spent. |
| Escalation rate | Share of threads with at least one unsure entry, against the gold answer's own rate. |
| Abstention | Precision and recall of null and unsure where the gold answer is null or unsure. |
| Evidence | Share of trades whose evidence includes the deciding email or attachment row. |
| Schema validity | Share of outputs that parse and use only allowed values. |
| Cost, latency | Per 1,000 tasks: tokens × price for APIs, GPU-hours × rate for open models; p50 and p95 latency, reported but not gated, since the agent runs minutes after a thread ends. |
| Stability | Share of threads with identical output across the three seeds and two reruns; reported, not gated. |
7.2 Generalisation splits
The 600 test threads form seven splits. In-distribution (grey) is the reference; each of the other six (amber) holds out one thing that training never showed.
| Split | What changes vs training | Example | Threads |
|---|---|---|---|
| In-distribution | Nothing: the reference score | New threads from the same generator settings | 150 |
| Surface | Who wrote the text | Emails written by a model family never used for training data; new recap templates and attachment layouts | 75 |
| Composition | How ingredients combine | Ingredient pairs never seen together in training; 4–6 revisions where training had at most 3 | 100 |
| Unseen rules | Rules only in the rulebook, never in an example | Amendments to yesterday's trades; spreads; threads with an announced but missing attachment, a problem type never shown in training | 100 |
| Calendar | Dates outside training | 2028: leap year, clock changes on other dates, year-end products | 75 |
| Vocabulary | Words and names outside training | Unseen recap wording ("Purchased", "We sold"); a fresh counterparty list with new look-alikes | 50 |
| Rule swap | The rulebook itself | Peak redefined as 09:00–21:00 in the prompt; does the model follow the prompt or its training? | 50 |
Read each split as its gap from the in-distribution score. A model that is strong in-distribution and drops 20 points on composition or rule swap has memorised the training data.
7.3 At par, cost and ablations
- At par (the Accurate gate): the share of threads fully right is within 5 points of the best frontier model. The test: the lower end of a paired bootstrap 95% interval on the difference is above −5. 600 test threads give about ±3 points; 200 would give ±5.5.
- Cost: for a frontier model, measured tokens × list price. For a small model, GPU cost at realistic throughput, plus training and fees spread over 12 months, plus hosting. The result is plotted as accuracy against cost per task, one point per model size.
- Ablations: training size 125 / 250 / 500 / 900; with and without each of reasoning traces, the rulebook in the prompt, and the noisy slice.
7.4 Routing to a human: confidence and unusable input
Each booked trade carries a confidence between 0 and 1. A threshold turns it into a route: above it, the trade is booked straight away; below it, or with any unsure entry, a person reviews it first. A useful model is confident when right and unsure when wrong, so reviewers see the risky trades and little else.
How it is measured:
- Fit the threshold on the 300-thread calibration split: the lowest value at which auto-booked trades are at least 99% fully right.
- Apply it to test unchanged and report auto-book precision, coverage (share of trades booked straight away) and the wrong trades that slipped through.
- Risk–coverage curve: error of auto-booked trades as the threshold sweeps; its area compares models without picking a threshold.
- Calibration: expected calibration error and a reliability diagram per model and per test split.
- Flags that matter: recall of gold
unsurefields, and the share of reviewed trades that were actually wrong.
Two ways to produce confidence, compared as variants: (a) the log-probability of the booked fields, temperature-scaled on the calibration split; (b) a stated confidence the model is fine-tuned to give, trained on out-of-fold labels of whether its own earlier answers were right. Frontier models are scored the same way, with stated confidence and log-probabilities where the API exposes them.
Unusable input is the second route: the thread goes back to whoever can fix it, with the problem and the fix. Measured by rejection precision and recall (does it reject the right threads, and only those?), and by diagnosis: the right code and fix_target. Flip twins pair a usable thread with the same thread made unusable, so a model can't pass by rejecting everything that looks odd. One problem type, a missing attachment, appears only in test.
Why this is hard for a small model: fine-tuning on gold answers teaches it to sound certain, because every target is stated with full confidence. Calibration must be fitted or trained separately, and it must hold on the held-out splits, where errors are most likely.
7.5 Threats to validity
| Risk | What it would do | Guard |
|---|---|---|
| Synthetic-to-real gap | At par on synthetic threads, failing on threads people wrote | A later replication on human-written threads |
| Generator tells | Model scores by recognising generator phrasing | Held-out templates and styles; invariance twins |
| Paraphraser bias | A baseline that wrote the threads reads them best | Paraphrase with a model not under test |
| Wrong gold | Every model looks worse; differences blur | Gold answers from code; blind second read; 98% spot-check bar |
| Rulebook overfit | Model learns our wording of the rules in place of the rules | Ablation without the rulebook; the unseen-rules split |
| Too small a test | "At par" claimed on noise | 600 test threads, paired intervals, three seeds |
8. Deliverables and acceptance
| Deliverable | Accepted when |
|---|---|
| Rulebook v1 | Covers every output field, product code and closing word. |
| Review set, 50 samples | Rated realistic (at least 4 of 5) and gold answers correct. |
| Generator | The same seed reproduces the same data; every gold answer is computed by code. |
| Dataset, 1,900 threads, with data card | Passes the six quality gates (6.4): 100% schema-valid, gold answers correct in at least 98% of the spot check, distribution within 2 points of the variation matrix, no train–test overlap, zero name-scan hits. |
| Evaluation harness | Scores any model per field, trap, split and twin pair, including the evidence check. |
| Results table | Every row and column filled (7.1), three seeds, paired 95% intervals; a risk–coverage curve and reliability diagram per model; 20 failures explained. |
| Recommendation | Names the smallest model size that passes all four gates, or the gate each size failed. |