Lesson 4: Measuring what a model can and cannot do
Module 1 · Lesson 4 · About 45 minutes · Needs: Lesson 1 (the tickets and the first triage call); Lesson 3 helps for SmolLM2
The situation
It is Monday 8 June 2026, week 2. Lena and Ravi have labelled the 24 dev tickets. Maya asks the question every pilot starts with: "How good is it, and where does it go wrong?" Tomasz adds: "And would the small model we can run ourselves do?"
One good answer proves nothing, so this lesson measures. It follows T-1004: Yuki Sato wants her money back on an annual plan bought 20 days ago. Lena labelled it cancellation.
A real case, first
In "Quantifying Language Models' Sensitivity to Spurious Features in Prompt Design" (ICLR 2024), Melanie Sclar, Yejin Choi, Yulia Tsvetkov and Alane Suhr changed prompts in ways that keep the meaning: spacing, separators, capital letters. On one task, LLaMA-2-7B scored 0.043 with passage:{}\n answer:{} and 0.826 with passage {}\n answer {}. Across tasks, such changes moved LLaMA-2-13B by up to 76 accuracy points.
The sensitivity survived larger models, more examples and instruction tuning. Their advice: report "a range of performance across plausible prompt formats", not one number. This lesson applies that advice to Quillpath's tickets.
What you will be able to do
- Score a model on a labelled set and state the result as an interval, not a point.
- Find where a model fails: counting, dates, rare categories, requests that change shape.
- Measure the noise floor of repeated runs and compare it with prompt-format changes.
- Check whether a model's confidence means anything, and spot confident wrong answers.
- Show why a model's knowledge and its reports about itself must be checked, not trusted.
1. What models are reliably good at, measured (Essential)
"Good at triage" is a claim. A score on tickets with known answers is evidence.
A labelled set is a list of inputs with the right answer written down by a person. Ours is the 24 dev tickets: send each, compare the label with the gold label, count.
%pip install -q -U langchain langgraph langchain-groq langchain-google-genai langchain-ollamaimport os
from langchain.chat_models import init_chat_model
PROVIDER = "groq" # "groq" or "gemini" in Colab; "ollama" on your own computer
MODELS = {"groq": "groq:openai/gpt-oss-20b", "gemini": "google_genai:gemini-3.5-flash-lite", "ollama": "ollama:llama3.2:3b"}
KEY = {"groq": "GROQ_API_KEY", "gemini": "GOOGLE_API_KEY", "ollama": None}[PROVIDER]
if KEY and KEY not in os.environ:
from google.colab import userdata # keys live in Colab's Secrets panel
os.environ[KEY] = userdata.get(KEY)
llm = init_chat_model(MODELS[PROVIDER], temperature=0, max_retries=6)Code walkthrough
- The standard setup from Lesson 1:
PROVIDERpicks the provider,llmis the client.
def ask(model, messages):
"""Returns (reply, None), or (None, reason) after two refusals."""
for attempt in range(2): # one retry: the next reply is usually fine
try:
return model.invoke(messages), None
except Exception as err:
last = err
return None, f"provider_error: {type(last).__name__}"Code walkthrough: ask (course-guide/02) retries a reply the provider rejects once, then returns no reply and the reason provider_error.
Now the slice of shared data this lesson needs: dates, labels and the dev tickets, copied from course-guide/06.
from datetime import date
COURSE_DATE = date(2026, 10, 9) # facts, model names and prices checked on this day
DATA_AS_OF = date(2026, 6, 8) # the shared data is a snapshot from this morning
def days_since(iso_day, today=DATA_AS_OF):
"""Calendar days from an ISO date string to `today` (the refund rule counts calendar days)."""
return (today - date.fromisoformat(iso_day)).days
print(DATA_AS_OF.strftime("%A %d %B %Y"))
print(days_since("2026-05-19"), days_since("2026-05-30"))Code walkthrough
days_sincecounts calendar days toDATA_AS_OF, never to the computer's clock.
CATEGORIES = ("billing", "cancellation", "account_access", "bug", "how_to", "feature_request")
PRIORITIES = ("low", "normal", "high", "urgent")
CATEGORY_DEFINITIONS = {
"billing": "Charges, invoices, prices, plan changes, and refund requests that do not end the plan.",
"cancellation": "The customer wants to end a paid plan, with or without a refund.",
"account_access": "Signing in, passwords, two-factor, SSO, and deleting a personal account.",
"bug": "Something in Quillpath does not work as documented, including slowness and outages.",
"how_to": "How to do something Quillpath already supports, or where to find a document.",
"feature_request": "Something Quillpath does not do today that the customer would like.",
}
PRIORITY_DEFINITIONS = {
"urgent": "A whole team cannot work right now.",
"high": "One person cannot work, money was taken wrongly, or data is being lost.",
"normal": "Everything else that needs an answer within a working day.",
"low": "Ideas and questions with no time pressure, stated or implied.",
}
assert set(CATEGORY_DEFINITIONS) == set(CATEGORIES) and set(PRIORITY_DEFINITIONS) == set(PRIORITIES)
print(len(CATEGORIES), "categories,", len(PRIORITIES), "priorities")Code walkthrough
- The six categories and the definitions Lena and Ravi labelled from. This lesson scores categories only.
# (id, language, plan, subject, body, category, priority, gold article or None)
DEV = [
("T-1001", "en", "team", "Charged twice this month", "Hi, my card was charged 288 USD twice on 1 June for the Team plan (invoice INV-2026-004512). Please refund the duplicate. Ana Duarte, owner, Ardent Design", "billing", "high", "billing-refunds"),
("T-1002", "en", "business", "Switch to annual billing?", "We are on Business monthly with 8 seats. If we move to annual, what do we pay and when does it start?", "billing", "normal", "billing-plans"),
("T-1003", "en", "team", "VAT number on invoices", "Our accountant needs our VAT number on every invoice. Where do I add it, and can you reissue May's invoice?", "billing", "normal", "billing-invoices"),
("T-1004", "en", "team", "Refund for annual plan", "I bought the annual Team plan 20 days ago and we have decided not to use Quillpath. Can I get my money back? Yuki Sato, Studio Nami", "cancellation", "normal", "billing-refunds"),
("T-1005", "en", "business", "Cancel our subscription", "Please cancel our Business plan at the end of this month. We are moving to another tool.", "cancellation", "normal", "billing-refunds"),
("T-1006", "en", "free", "Forgot my password", "The reset email never arrived and I checked spam. My login is kofi.osei@example.com, phone +44 7700 900321.", "account_access", "high", "account-login"),
("T-1007", "en", "business", "SSO login loop", "Since this morning everyone in our team is sent back to the login page after signing in through our identity provider. Nobody can get in.", "account_access", "urgent", "account-sso"),
("T-1008", "en", "team", "Locked out", "I typed my password wrong a few times and now it says my account is locked. I have a client demo in an hour.", "account_access", "high", "account-login"),
("T-1009", "en", "team", "Add a teammate", "How do I invite a new person to our workspace, and will it change our bill?", "how_to", "low", "billing-plans"),
("T-1010", "en", "team", "Automations not firing", "Our rule 'move card to Done when checklist complete' stopped running yesterday. We use a lot of automations. Nothing changed on our side.", "bug", "normal", "boards-automations"),
("T-1011", "en", "business", "Export all our boards", "We need a full export of every board with comments for an audit. What formats do you support?", "how_to", "normal", "exports-data"),
("T-1012", "en", "business", "Slack posts duplicated", "Every card update now posts twice in our Slack channel.", "bug", "normal", "integrations-slack"),
("T-1013", "en", "team", "App crashes on start", "Since Friday's update the Android app closes as soon as I open it. Android 16. I already reinstalled and cleared the cache.", "bug", "normal", None),
("T-1014", "en", "free", "Dark mode please", "Would love a dark mode in the web app. My eyes would thank you.", "feature_request", "low", "feature-requests"),
("T-1015", "en", "business", "Is Quillpath down?", "Boards are loading very slowly for the whole team and most pages time out.", "bug", "urgent", "status-incidents"),
("T-1016", "en", "enterprise", "Data processing agreement", "Our legal team needs your DPA and the list of subprocessors before we renew.", "how_to", "normal", "data-privacy"),
("T-1017", "de", "team", "Doppelte Abbuchung", "Hallo, am 1. Juni wurden uns 60 USD zweimal abgebucht (Rechnung INV-2026-004530). Bitte erstatten Sie die doppelte Zahlung. Jonas Feldmann", "billing", "high", "billing-refunds"),
("T-1018", "es", "business", "No puedo entrar", "Desde ayer no puedo iniciar sesión. Dice que mi contraseña es incorrecta, pero la acabo de cambiar.", "account_access", "high", "account-login"),
("T-1019", "en", "team", "Delete my account", "Please delete my personal account and my data. I am leaving the company.", "account_access", "normal", "data-privacy"),
("T-1020", "en", "team", "Gantt view?", "Is there a timeline or Gantt view? I could not find one.", "feature_request", "low", "feature-requests"),
("T-1021", "en", "business", "Recurring cards", "Can I make a card that comes back every Monday?", "how_to", "low", "boards-automations"),
("T-1022", "en", "team", "Charged after cancelling", "I cancelled on 2 June but was charged again on 5 June. Why?", "billing", "high", None),
("T-1023", "en", "enterprise", "Contract question", "Our procurement team wants to change the liability clause in our Enterprise agreement. Who should they talk to?", "how_to", "normal", None),
("T-1024", "ja", "team", "請求書のダウンロード", "請求書をPDFでダウンロードするにはどうすればいいですか?", "how_to", "low", "billing-invoices"),
]
print(len(DEV), "dev tickets")Code walkthrough
- Each ticket: id, language, plan, subject, body, gold category, priority, article.
DEV[3]is T-1004.
The triage prompt is Lesson 1's, but the model also says how sure it is (section 5).
import re, time
PACE = 4 # seconds between calls: Gemini's free tier allows about 15 requests a minute
SYSTEM = ("You triage support tickets for Quillpath, a project-management app.\nCategories:\n"
+ "\n".join(f"- {c}: {d}" for c, d in CATEGORY_DEFINITIONS.items())
+ "\nReply with the category, then a space, then how sure you are that it is right, as a number from 0 to 100.")
def triage(row):
reply, err = ask(llm, [("system", SYSTEM), ("human", f"Subject: {row[3]}\n\n{row[4]}")])
if reply is None:
print(row[0], "no answer:", err)
return "none", None
reply = reply.text.lower()
category = next((c for c in CATEGORIES if c in reply), "none")
sure = re.search(r"\d+", reply)
return category, int(sure.group()) if sure else None
hosted = {}
for row in DEV:
hosted[row[0]] = triage(row)
time.sleep(PACE)
gold = {row[0]: row[5] for row in DEV}
right = [tid for tid in gold if hosted[tid][0] == gold[tid]]
print(f"{len(right)} of {len(gold)} right")
print("T-1004:", hosted["T-1004"], "| gold:", gold["T-1004"])
print("wrong:", {tid: hosted[tid] for tid in gold if tid not in right})Code walkthrough
PACEspaces the 24 calls out, so the loop stays under the free tier's per-minute limit.triagesends one ticket and finds which category name appears in the reply ("none"if none, say an empty reply) and the first number; a rejected reply printsno answerand counts as"none".hostedmaps each ticket id to (category, confidence);rightlists the tickets it got right.
On our Gemini run (10 October 2026), 20 of 24 were right; the misses were T-1003, T-1007, T-1020 and T-1023. T-1004 came back cancellation 100. An earlier run of the same cell scored 19: it answered T-1004 billing 100, missing "decided not to use Quillpath". Same prompt, opposite answers, both at 100.
Is the model "83% accurate"? On another 24 tickets it could score 18 or 23. A confidence interval gives the range of true accuracies that fit the result; the Wilson interval behaves well on small sets.
from math import sqrt
def wilson(k, n, z=1.96):
"""95% interval for a success rate of k out of n (the Wilson score interval)."""
p = k / n
centre = (p + z * z / (2 * n)) / (1 + z * z / n)
half = z * sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
return centre - half, centre + half
k, n = len(right), len(gold)
low, high = wilson(k, n)
print(f"hosted model: {k / n:.0%}, 95% interval {low:.0%} to {high:.0%}")
most_common = max(CATEGORIES, key=list(gold.values()).count)
k0 = list(gold.values()).count(most_common)
print(f"always {most_common}: {k0 / n:.0%}, 95% interval {wilson(k0, n)[0]:.0%} to {wilson(k0, n)[1]:.0%}")
for size in (24, 100, 400):
lo, hi = wilson(round(0.8 * size), size)
print(f"80% right on {size:3} tickets: interval {hi - lo:.0%} wide")Code walkthrough
wilsontakes k right out of n and returns the low and high ends;z=1.96makes it a 95% interval.most_commonis the most frequent gold label; always answering it is the floor to beat.- The last loop shows the width shrinking as the set grows.
The honest sentence is: 83%, somewhere between 64% and 93%.
Width shrinks with the square root of the set size: 31 points on 24 tickets, 16 on 100, 8 on 400. Until Module 10's eval suite, read a difference of two or three tickets as noise.
A "model" that always answers how_to scores 25% (12% to 45%). A baseline like this tells you what a score is worth: 83% is far above it.
2. Where it breaks: counting, dates, rare labels (Essential)
Labelling clear tickets is what models are reliably good at: the answer is in the text. Other jobs look as easy and are not. Test them against answers code computes.
LISTING = "\n".join(f"{t[0]} | {t[2]} | {t[3]}" for t in DEV)
TASKS = [ # (kind, question, the answer code computes)
("count", f"Tickets (id | plan | subject):\n{LISTING}\n\nHow many tickets are from the team plan? Reply with a number only.", str(sum(t[2] == "team" for t in DEV))),
("count", f"Tickets (id | plan | subject):\n{LISTING}\n\nHow many subjects end with a question mark? Reply with a number only.", str(sum(t[3].endswith("?") for t in DEV))),
("count", f"Ticket text: {DEV[0][4]}\n\nHow many words are in this text? Count words separated by spaces. Reply with a number only.", str(len(DEV[0][4].split()))),
("date", "How many days are there from 2026-05-19 to 2026-06-08? Reply with a number only.", str(days_since("2026-05-19"))),
("date", "How many days are there from 2026-02-17 to 2026-06-08? Reply with a number only.", str(days_since("2026-02-17"))),
("date", "What day of the week is 2026-05-19? Reply with the day only.", date(2026, 5, 19).strftime("%A")),
("extract", f"Ticket text: {DEV[0][4]}\n\nWhat is the invoice number? Reply with it only.", "INV-2026-004512"),
("extract", f"Ticket text: {DEV[16][4]}\n\nWhat amount was charged twice, in USD? Reply with a number only.", "60"),
("extract", f"Ticket text: {DEV[3][4]}\n\nWho sent this ticket? Reply with the name only.", "Yuki Sato"),
]
for kind, question, answer in TASKS:
reply, err = ask(llm, question)
if reply is None:
print(f"{kind:8} code: {answer:16} no answer ({err})")
else:
reply = reply.text.strip().rstrip(".")
print(f"{kind:8} code: {answer:16} model: {reply[:20]:20} {'ok' if reply.lower() == answer.lower() else 'WRONG'}")
time.sleep(PACE)Code walkthrough
LISTINGis one line per ticket: id, plan, subject.- Each
TASKSentry pairs a question with the answer Python computes from the same data;rstrip(".")forgives a full stop; a rejected reply printsno answer.
On our run, extraction and date arithmetic (both dates given) went 3 of 3. Counting went 1 of 3: 10 team-plan tickets instead of 12 and 5 question marks instead of 3; the word count was right. The earlier run missed all three. Small samples, but the lesson holds: count in code.
for c in CATEGORIES:
ids = [tid for tid in gold if gold[tid] == c]
k = sum(hosted[tid][0] == c for tid in ids)
lo, hi = wilson(k, len(ids))
print(f"{c:16} {k} of {len(ids)} interval {lo:.0%} to {hi:.0%}")Code walkthrough
- For each category,
kcounts the model's right answers among its tickets, with an interval.
Rare categories are where a labelled set runs out. cancellation and feature_request have two dev tickets each. The model got both cancellations (interval 34% to 100%) and one feature request (9% to 91%): ranges that say almost nothing.
Does a long request hurt? This sends all 24 tickets in one request instead of one at a time.
LONG = "\n\n".join(f"{t[0]}\nSubject: {t[3]}\n{t[4]}" for t in DEV)
FORMAT_A = ("Categories:\n" + "\n".join(f"- {c}: {d}" for c, d in CATEGORY_DEFINITIONS.items())
+ "\nLabel every ticket below. Reply with one line per ticket, in the form T-1001: category.")
def triage_all(instructions):
system = "You triage support tickets for Quillpath, a project-management app.\n" + instructions
reply, err = ask(llm, [("system", system), ("human", LONG)])
if reply is None:
print("no answer:", err)
found = dict(re.findall(r"(T-\d{4})\W+([A-Za-z_]+)", reply.text if reply else ""))
return {tid: found.get(tid, "missing").lower() for tid in gold}
batch = triage_all(FORMAT_A)
print(len(LONG.split()), "words in one request")
print(sum(batch[t] == gold[t] for t in gold), "of 24 right; missing:", sum(v == "missing" for v in batch.values()))
print("changed from one at a time:", {t: (hosted[t][0], batch[t]) for t in gold if batch[t] != hosted[t][0]})Code walkthrough
LONGjoins the 24 tickets;FORMAT_Aasks for one line per ticket.re.findallpulls each "ticket id, then a word" pair from the reply; a skipped ticket, or every ticket afterno answer, is"missing".
At 532 words the long request did not hurt: 22 of 24, none missing. But four labels changed: T-1003, T-1007, T-1020 and T-1021. Same tickets, a different request shape: section 4 returns to this. Module 2 tests truly long inputs.
3. The same test on a small local model (Essential)
Tomasz's question has a measurable answer. SmolLM2-135M-Instruct (Lesson 3) runs on a laptop with no key and no fee.
Small models ramble, so we score the labels: the model's probability of writing each category name next. The highest wins. This is generous: the model cannot answer off the list.
%pip install -q transformers==5.19.0 torchimport torch
from transformers import AutoTokenizer, AutoModelForCausalLM
SMALL, SMALL_REV = "HuggingFaceTB/SmolLM2-135M-Instruct", "12fd25f77366fa6b3b4b768ec3050bf629380bac" # pinned revision (course-guide/02)
tok = AutoTokenizer.from_pretrained(SMALL, revision=SMALL_REV)
small = AutoModelForCausalLM.from_pretrained(SMALL, revision=SMALL_REV).eval()
LABEL_IDS = [tok(c, add_special_tokens=False).input_ids for c in CATEGORIES]
def label_probs(system, row):
"""Probability of each category name as the reply, renormalised over the six."""
chat = [{"role": "system", "content": system}, {"role": "user", "content": f"Subject: {row[3]}\n\n{row[4]}"}]
p = tok(tok.apply_chat_template(chat, tokenize=False, add_generation_prompt=True), add_special_tokens=False).input_ids
longest = max(map(len, LABEL_IDS))
batch_ids = torch.tensor([p + l + [0] * (longest - len(l)) for l in LABEL_IDS]) # pad after each label
with torch.no_grad():
logp = torch.log_softmax(small(batch_ids).logits, dim=-1)
scores = torch.stack([sum(logp[i, len(p) - 1 + j, t] for j, t in enumerate(l)) for i, l in enumerate(LABEL_IDS)])
return dict(zip(CATEGORIES, torch.softmax(scores, 0).tolist()))Code walkthrough
SMALL_REVpins the download to one commit (course-guide/02).LABEL_IDSholds the token ids of each category name;account_accessis several tokens.label_probsbuilds the chat prompt, appends each label and runs all six in one batch; padding after the label cannot affect it.scoresadds each label's token log-probabilities;softmaxturns the six totals into probabilities.
SMALL_A = SYSTEM.split("\nReply")[0] + "\nReply with the category only."
small_probs = {row[0]: label_probs(SMALL_A, row) for row in DEV} # about a minute on a CPU
small_pick = {t: max(p, key=p.get) for t, p in small_probs.items()}
ks = sum(small_pick[t] == gold[t] for t in gold)
print(f"SmolLM2-135M: {ks} of 24 = {ks / 24:.0%}, interval {wilson(ks, 24)[0]:.0%} to {wilson(ks, 24)[1]:.0%}")
print("T-1004:", small_pick["T-1004"], round(small_probs["T-1004"][small_pick["T-1004"]], 2))
only_hosted = [t for t in gold if hosted[t][0] == gold[t] != small_pick[t]]
only_small = [t for t in gold if small_pick[t] == gold[t] != hosted[t][0]]
print("hosted right, small wrong:", len(only_hosted), "| small right, hosted wrong:", len(only_small), only_small)Code walkthrough
SMALL_Ais the hosted prompt with the confidence instruction removed.only_hostedandonly_smalllist the tickets where exactly one model is right.
SmolLM2 scored 13 of 24, 54% (35% to 72%), and called T-1004 how_to: twice the baseline. Its interval overlaps the hosted model's, so the totals alone cannot settle it.
A paired comparison can: on the same tickets, the hosted model was right where the small one was wrong 8 times; the reverse happened once, on T-1003. Module 10 turns this into a proper test.
4. Consistency and sensitivity to prompt phrasing (Essential)
Before crediting a prompt change, know how much answers move when nothing changes.
Repeat the same request and count changed labels: the noise floor. Then change only the format, as Sclar's team did. Prompt sensitivity is answers changing with wording or format that keeps the meaning.
FORMAT_B = ("CATEGORIES\n" + "\n".join(f"{c.upper()} = {d}" for c, d in CATEGORY_DEFINITIONS.items())
+ "\nLABEL EVERY TICKET BELOW. ONE LINE PER TICKET, IN THE FORM T-1001 -> CATEGORY.")
FORMAT_C = ("Categories:\n" + "\n".join(f"{i}. {c} ({d})" for i, (c, d) in enumerate(reversed(CATEGORY_DEFINITIONS.items()), 1))
+ "\nLabel every ticket below. Reply with one line per ticket, in the form T-1001: category.")
runs = {"A, run 1": batch}
for name, fmt in [("A, run 2", FORMAT_A), ("A, run 3", FORMAT_A), ("B", FORMAT_B), ("C", FORMAT_C)]:
time.sleep(PACE)
runs[name] = triage_all(fmt)
for name, labels in runs.items():
flips = [t for t in gold if labels[t] != batch[t]]
print(f"{name:9} {sum(labels[t] == gold[t] for t in gold)} of 24 right, {len(flips)} changed from run 1 {flips}")Code walkthrough
FORMAT_Bshouts, with equals signs and an arrow;FORMAT_Cnumbers the categories in reverse. Neither changes what is asked.runsadds two repeats and two formats to run 1;flipslists labels that differ from run 1.
On our Gemini run the three repeats agreed on every ticket: a noise floor of zero for this request. Format B changed nothing; format C changed one label, T-1003. Bigger moves came elsewhere: the request shape in section 2 changed four labels, and two runs of section 1's loop disagreed on T-1004. Measure the floor for each request you use.
Break it: shout the prompt at the small model
Give SmolLM2 the same two formats.
SMALL_B = ("You triage support tickets for Quillpath, a project-management app.\nCATEGORIES\n"
+ "\n".join(f"{c} = {d}" for c, d in CATEGORY_DEFINITIONS.items()) + "\nREPLY WITH THE CATEGORY ONLY.")
SMALL_C = ("You triage support tickets for Quillpath, a project-management app.\nCategories:\n"
+ "\n".join(f"{i}. {c} ({d})" for i, (c, d) in enumerate(reversed(CATEGORY_DEFINITIONS.items()), 1))
+ "\nReply with the category only.")
def top_label(system, row):
probs = label_probs(system, row)
return max(probs, key=probs.get)
for name, system in [("B", SMALL_B), ("C", SMALL_C)]:
pick = {row[0]: top_label(system, row) for row in DEV} # about a minute each
print(f"format {name}: {sum(pick[t] == gold[t] for t in gold)} of 24 right, "
f"{sum(pick[t] != small_pick[t] for t in gold)} changed, {list(pick.values()).count('billing')} called billing")Code walkthrough
SMALL_Bshouts everything except the label names, which are what we score.top_labelreturns the label with the highest probability. The loop scores all 24 tickets under each format and counts right answers, changed labels, and how many tickets becamebilling.
Format B dropped SmolLM2 from 13 right to 6 and changed 16 of 24 labels: it called 19 tickets billing. Format C changed 3 labels. Sclar's finding, on our tickets: a meaningless format change can wreck a small model. Keep prompts in files and re-measure on every change (Module 4).
5. Calibration and confident wrong answers (Essential)
If confidence meant something, Maya could send low-confidence tickets to a person. Whether it does is a measurement.
A model is calibrated when its confidence matches its accuracy: of all answers given at 80%, about 80% are right. A confident wrong answer is a mistake stated with high confidence, the kind no threshold catches.
def calibration(rows, cuts):
"""rows: (confidence, right?) pairs. Prints the accuracy inside each confidence band."""
for lo, hi in zip(cuts, cuts[1:]):
band = [ok for conf, ok in rows if lo <= conf < hi]
if band:
print(f" said {lo:g} to {hi:g}: {len(band):2} answers, {sum(band) / len(band):.0%} right")
print("hosted model, stated confidence 0 to 100:")
calibration([(s or 0, c == gold[t]) for t, (c, s) in hosted.items()], [0, 90, 100, 101])
print("small model, probability of its top label:")
calibration([(max(p.values()), small_pick[t] == gold[t]) for t, p in small_probs.items()], [0, 0.5, 0.7, 1.01])
print("said 100 and wrong:", [t for t, (c, s) in hosted.items() if s == 100 and c != gold[t]])Code walkthrough
calibrationgroups answers into bands between thecutsand prints each band's accuracy.- Hosted confidence is the number it wrote; SmolLM2's is the probability of its chosen label.
The hosted model wrote 100 on 20 tickets and was right on 18, 90%. Of its four lower scores (85 to 99), two were wrong: a little signal, but "100" does not mean certain. SmolLM2's probabilities, read straight from the model, show no clear pattern: right on 4 of 6 answers below 0.5, 4 of 11 between 0.5 and 0.7, and 5 of 7 above.
T-1007 and T-1020 came with 100 and were wrong, as T-1004 did in the earlier run; "send to a person below 90" lets them all through. Kadavath and colleagues ("Language Models (Mostly) Know What They Know", Anthropic, 2022) found large models well calibrated on multiple-choice questions, and weaker on new tasks.
6. Knowledge cutoffs and stale facts (Essential)
A refund answer depends on today's date. The model has no clock, and its knowledge stops at a date.
Pretraining text stops at a knowledge cut-off. Google's model card gives March 2026 for Gemini 3.5 Flash-Lite, adding that in some domains knowledge may be "limited to January 2025"; OpenAI's card gives June 2024 for gpt-oss (checked October 2026). A stale fact is one that was true in the training text and is no longer.
PROBES = {
"today": "What is today's date? Reply with the date only, as YYYY-MM-DD.",
"newest Gemini": "What is the newest Gemini model you know of? Reply with its name only.",
"refund, no date": "Quillpath refunds annual plans cancelled within 14 days of renewal. A customer's annual plan renewed on 2026-05-30 and they cancel today. Is a refund due? Reply yes or no, then the date you took as today.",
}
PROBES["refund, date given"] = f"Today is {DATA_AS_OF}. " + PROBES["refund, no date"]
for name, question in PROBES.items():
reply, err = ask(llm, question)
print(f"{name:19} {reply.text.strip()[:60] if reply else f'no answer ({err})'}")
time.sleep(PACE)
print("rule in code:", "yes" if days_since("2026-05-30") <= 14 else "no", f"({days_since('2026-05-30')} days to {DATA_AS_OF})")Code walkthrough
- Three questions the model cannot answer from its training text, then the refund question with
DATA_AS_OFin front; a rejected reply printsno answer. - The last line makes the decision in code: 9 days, inside the window.
Gemini said today was 2025-02-17 (2025-05-19 in the earlier run) and named Gemini 1.5 Pro as the newest Gemini model, while running as Gemini 3.5 Flash-Lite. Without a date, the refund question got a request for the date; the earlier run just said no. Given 8 June it said yes, matching the rule. Cheng and colleagues ("Dated Data", 2024) found that a model's effective cut-off often differs from the reported one, as here. The fix: put dates and current facts in the prompt, and keep date arithmetic in code.
7. Model self-knowledge and its limits (Recommended)
Asking a model about itself is cheap. Whether the answer is true is another matter.
Self-knowledge is how well a model can report its own abilities and limits. Ask for its cut-off and a predicted score, and compare.
DOCUMENTED = {"groq": "June 2024", "gemini": "March 2026", "ollama": "December 2023"} # model cards, checked October 2026
said, err1 = ask(llm, "What is the knowledge cutoff of your training data? Reply with the month and year only.")
time.sleep(PACE)
guess, err2 = ask(llm, "You will label 24 customer support tickets for a project-management app into six categories "
"(billing, cancellation, account_access, bug, how_to, feature_request), one category each. "
"What percentage do you expect to get right? Reply with a number only.")
print("says its cut-off is:", said.text.strip() if said else f"no answer ({err1})", "| model card:", DOCUMENTED[PROVIDER])
print("predicted accuracy:", guess.text.strip() if guess else f"no answer ({err2})", "| measured:", f"{len(right) / len(gold):.0%}")Code walkthrough
DOCUMENTEDholds each default model's published cut-off (Llama 3.2's from its model card, Lesson 2).- The prints set each answer, or
no answer, beside the documented or measured value.
Gemini said "March 2026", exactly its card. It predicted 100% (90% in the earlier run) and scored 83%. The cut-off answer is right, yet section 6 showed knowledge that behaves older. Self-reports are claims to test, never measurements.
In production
Teams turn this lesson into habits. Every claim comes with its labelled set, its size and an interval. Baseline and noise floor are measured before a prompt change is credited. Counting, dates and money arithmetic go to code. Confidence routes tickets only after a calibration check on the team's own data; confident wrong answers are found by review. Module 4 freezes prompts and Module 10 builds the eval suite that gates every change.
What you added to the Quillpath support desk
- A scored baseline for Maya: 83% on 24 dev tickets (64% to 93%), against 25% for always
how_to. - A list of where the model fails: counting, rare categories, request shape, repeat runs, and anything that needs today's date.
- A measured answer for Tomasz: SmolLM2-135M-Instruct scored 54% and broke under a shouted prompt.
wilson,calibrationand the noise-floor check, for Modules 4 and 10.
Check yourself
- The model scored 20 of 24. Why is "83% accurate" an incomplete claim, and what is the complete one?
- Three repeats of one prompt changed no labels; a format change changed one. What would you conclude, and what would you need to be sure?
- Why is the paired comparison more convincing than the two totals?
- The model wrote 100 on T-1007 and was wrong. Why can a confidence threshold not catch this?
- The model reported its cut-off correctly. Why does that not settle what it knows?
- It leaves out the uncertainty from a small set. Complete: 83%, with a 95% interval of 64% to 93%, on 24 dev tickets.
- One change sits just above a zero noise floor from three runs; it may be real or chance. More tickets and more repeats.
- Both models saw the same tickets, so the 8 against 1 count compares them ticket by ticket, removing differences in which tickets were hard.
- The threshold only acts on low confidence; a wrong answer at 100 passes any threshold.
- It also named Gemini 1.5 Pro as the newest model and guessed a 2025 date: what it says and what it knows differ.
Exercise
Maya wants the interval for the hosted model no wider than 10 points. Before running the cell, guess how many labelled tickets that needs at about 80% accuracy. Then run it.
def tickets_needed(p, width):
"""Smallest set size whose 95% Wilson interval at accuracy p is at most `width` wide."""
n = 10
while True:
lo, hi = wilson(round(p * n), n)
if hi - lo <= width:
return n
n += 10
n_needed = tickets_needed(0.80, 0.10)
lo, hi = wilson(round(0.80 * n_needed), n_needed)
assert hi - lo <= 0.10, "the interval is still wider than 10 points"
print(f"{n_needed} tickets: interval {lo:.0%} to {hi:.0%}; that is {n_needed / 24:.0f} times the dev set")Code walkthrough
tickets_neededgrows the set ten tickets at a time until the interval fits; theassertchecks the answer.- Then write one sentence for Maya on what labelling that many tickets would cost Lena and Ravi, and whether a per-category number for
cancellationis realistic.
Key terms
| Term | Meaning |
|---|---|
| Labelled set | Inputs with the right answer written down by a person, used to score a model |
| Confidence interval | The range of true accuracies consistent with a measured score |
| Baseline | The score of a trivial method, such as always giving the most common label |
| Paired comparison | Comparing two models ticket by ticket on the same inputs |
| Noise floor | How much answers change when the same request is repeated |
| Prompt sensitivity | Answers changing with wording or format that keeps the meaning |
| Calibrated | Stated confidence matches the measured accuracy |
| Confident wrong answer | A mistake given with high confidence |
| Knowledge cut-off | The date the training text stops |
| Self-knowledge | How well a model can report its own abilities and limits |
Sources
- Sclar, Choi, Tsvetkov, Suhr, "Quantifying Language Models' Sensitivity to Spurious Features in Prompt Design", ICLR 2024 (arXiv October 2023). https://arxiv.org/abs/2310.11324
- Kadavath, Conerly, Askell, Henighan and others (Anthropic), "Language Models (Mostly) Know What They Know", 2022. https://arxiv.org/abs/2207.05221
- Cheng, Marone, Weller, Lawrie, Khashabi, Van Durme, "Dated Data: Tracing Knowledge Cutoffs in Large Language Models", 2024. https://arxiv.org/abs/2403.12958
- Google DeepMind, Gemini 3.5 Flash-Lite model card, July 2026, checked October 2026. https://deepmind.google/models/model-cards/gemini-3-5-flash-lite/
- OpenAI, "gpt-oss-120b & gpt-oss-20b Model Card", August 2025. https://arxiv.org/abs/2508.10925
- Hugging Face, SmolLM2-135M-Instruct model card, checked October 2026. https://huggingface.co/HuggingFaceTB/SmolLM2-135M-Instruct
- E. B. Wilson, "Probable Inference, the Law of Succession, and Statistical Inference", Journal of the American Statistical Association, 1927. https://doi.org/10.1080/01621459.1927.10502953