CourseLarge Language Models: The Complete Course · Module 1: What a Large Language Model Is · part 10 of 103
Part 10 · Module 1: What a Large Language Model Is

Lesson 4: Measuring what a model can and cannot do

24 min read·13 Oct 2026
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.

SAME TASK, SAME MODEL, ONE FORMAT CHANGE passage:{} answer:{} 0.043 passage {} answer {} 0.826 LLaMA-2-7B, one task, accuracy (Sclar et al., ICLR 2024)
In Sclar and colleagues' study, swapping two colons for spaces moved one model from 4% to 83% on the same task (red: failure, green: correct): format alone can decide a score.

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 MEASUREMENT IS A LOOP OVER A LABELLED SET ticket 24 dev model one call label billing compare with gold count 20 of 24 next ticket
Every measurement in this lesson is the same loop: the model (amber) labels a ticket, your code (blue) compares it with Lena's gold label, and the right answers (green) are counted.

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.

python
%pip install -q -U langchain langgraph langchain-groq langchain-google-genai langchain-ollama
python
import 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: PROVIDER picks the provider, llm is the client.
python
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.

python
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_since counts calendar days to DATA_AS_OF, never to the computer's clock.
python
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.
python
# (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).

python
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

  • PACE spaces the 24 calls out, so the loop stays under the free tier's per-minute limit.
  • triage sends 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 prints no answer and counts as "none".
  • hosted maps each ticket id to (category, confidence); right lists the tickets it got right.
T-1004, TRACED "bought the annual Team plan 20 days ago ... decided not to use Quillpath. Can I get my money back?" gold cancellation run 1: billing sure: 100 run 2: cancellation sure: 100
T-1004 ends the plan, so Lena's gold label is cancellation (green); two runs of the same prompt answered billing (red) and then cancellation, both times 100% sure.

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.

HOSTED MODEL, 24 DEV TICKETS 0% 25% 50% 75% 100% 20 of 24 = 83% 64% 93%
The point (blue) is 83%, but 24 tickets only pin the true accuracy to the amber range, 64% to 93%: report the range.

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.

python
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

  • wilson takes k right out of n and returns the low and high ends; z=1.96 makes it a 95% interval.
  • most_common is 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%.

80% RIGHT: 95% INTERVAL WIDTH 24 tickets 31% wide 100 tickets 16% wide 400 tickets 8% wide
Doubt (amber) shrinks with the square root of the set size: 24 tickets leave a range 31 points wide, and it takes 400 to get it to 8.

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.

SCORE AGAINST THE FLOOR 0% 25% 50% 75% 100% always how_to 25% hosted model 83%
A trivial baseline (grey) that always answers how_to scores 25%; the hosted model (blue) is well above it, and the two intervals do not overlap.

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.

NINE CHECKABLE TASKS, ANSWERS COMPUTED IN CODE extract invoice, amount, name ok ok ok date maths both dates given ok ok ok count plans, ?, words ok miss miss
On our run the model extracted and did date arithmetic perfectly (green) and missed two of three counts (red); an earlier run missed all three.
python
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

  • LISTING is one line per ticket: id, plan, subject.
  • Each TASKS entry pairs a question with the answer Python computes from the same data; rstrip(".") forgives a full stop; a rejected reply prints no answer.
THREE COUNTS team-plan tickets code: 12 model: 10 subjects ending ? code: 3 model: 5 words in T-1001 code: 26 model: 26
Two of three counts were wrong (red), stated with no sign of doubt; code counts exactly every time (green).

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.

python
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, k counts the model's right answers among its tickets, with an interval.
ACCURACY BY CATEGORY, HOSTED MODEL 0% 25% 50% 75% 100% billing (5) 4/5 account_access (5) 4/5 how_to (6) 5/6 bug (4) 4/4 cancellation (2) 2/2 feature_request (2) 1/2
Categories with two tickets each (red) give intervals 66 and 82 points wide: the labelled set is too small to say anything about rare categories.

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.

SAME TICKETS, TWO REQUEST SHAPES 24 requests one ticket + confidence 1 request all 24 tickets, 532 words 20 of 24 right 22 of 24 right T-1003 T-1007 T-1020 T-1021 4 labels changed between the two
Sending all 24 tickets at once did not hurt (22 right against 20), but four labels changed (amber): the shape of the request moves answers.

Does a long request hurt? This sends all 24 tickets in one request instead of one at a time.

python
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

  • LONG joins the 24 tickets; FORMAT_A asks for one line per ticket.
  • re.findall pulls each "ticket id, then a word" pair from the reply; a skipped ticket, or every ticket after no 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.

SCORING THE LABELS ON SMOLLM2 prompt definitions + T-1004 how_to 0.55 billing 0.24 cancellation 0.13 other three 0.08 probability of writing each label next
Instead of parsing a reply, each of the six labels is scored by the small model's probability of writing it next (amber), and the most likely wins: for T-1004, how_to at 0.55.

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.

python
%pip install -q transformers==5.19.0 torch
python
import 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_REV pins the download to one commit (course-guide/02).
  • LABEL_IDS holds the token ids of each category name; account_access is several tokens.
  • label_probs builds the chat prompt, appends each label and runs all six in one batch; padding after the label cannot affect it.
  • scores adds each label's token log-probabilities; softmax turns the six totals into probabilities.
python
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_A is the hosted prompt with the confidence instruction removed.
  • only_hosted and only_small list the tickets where exactly one model is right.
24 DEV TICKETS, 95% INTERVALS 0% 25% 50% 75% 100% always how_to 25% SmolLM2-135M 54% hosted model 83%
SmolLM2 (amber) scores twice the baseline (grey), but its interval overlaps the hosted model's (blue): two totals on 24 tickets cannot rank them for sure.

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.

TICKET BY TICKET, WHERE EXACTLY ONE IS RIGHT 8 tickets hosted right, small wrong 1 ticket small right, hosted wrong the other 15: both right or both wrong
On the same tickets the hosted model (blue) wins 8 disagreements and the small model (amber) wins 1: a paired view shows a gap the overlapping totals hid.

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.

SAME PROMPT, THREE RUNS format A run 1 format A run 2 format A run 3 0 changed noise floor
Repeating the identical request three times changed no labels on our run (green): any change bigger than this noise floor needs explaining.

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.

THREE FORMATS, ONE MEANING format A - billing: Charges... - cancellation: ... T-1001: category format B BILLING = Charges... CANCELLATION = ... T-1001 -> CATEGORY format C 1. feature_request (...) 2. how_to (...) T-1001: category
The three formats ask the same thing: A lists the definitions, B shouts them with equals signs, C numbers them in reverse order.
python
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_B shouts, with equals signs and an arrow; FORMAT_C numbers the categories in reverse. Neither changes what is asked.
  • runs adds two repeats and two formats to run 1; flips lists labels that differ from run 1.
LABELS CHANGED, HOSTED MODEL repeat A (x2) 0 format B 0 format C 1 request shape 4
For the hosted model, format barely mattered (0 and 1 changes), while changing the request shape changed four labels (red).

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.

python
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_B shouts everything except the label names, which are what we score.
  • top_label returns 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 became billing.
SMOLLM2, SAME TICKETS format A: 13 right labels spread out format B: 6 right 19 of 24 called billing 16 of 24 labels changed; format C changed 3
Shouting the same instructions dropped SmolLM2 from 13 right to 6 (red), with 19 tickets pushed into billing: small models are far more format-sensitive.

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.

CALIBRATED: CONFIDENCE MATCHES ACCURACY 100 answers said "80% sure" about 80 right, 20 wrong
A calibrated model's 80% means 80 right (green) and 20 wrong (red) out of every 100 such answers; illustrative, not measured.

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.

python
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

  • calibration groups answers into bands between the cuts and prints each band's accuracy.
  • Hosted confidence is the number it wrote; SmolLM2's is the probability of its chosen label.
STATED CONFIDENCE AGAINST RESULTS said 100: 20 tickets right on 18 (90%) said 85 to 99: 4 tickets 2 right, 2 wrong SmolLM2, top probability: below 0.5: 4 of 6 right 0.5 to 0.7: 4 of 11 right 0.7 and up: 5 of 7 right
The hosted model's "100" was right 90% of the time (amber: uncertain) and half its lower scores were wrong (red); SmolLM2's probabilities show no clear pattern.

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.

SAID 100, AND WRONG T-1007 said bug gold account_access T-1020 said how_to gold feature_request T-1004 (run 1) said billing gold cancellation a "below 90 goes to a person" rule lets every one through
Tickets answered wrongly at confidence 100 (red), T-1004 in the earlier run: no confidence threshold catches a confident wrong answer.

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.

DATES THE MODEL DOES AND DOES NOT KNOW Jan 2025 some domains 2025-02-17 it guessed today Mar 2026 card cut-off 8 Jun 2026 data date 9 Oct 2026 course date
The card's cut-off is March 2026 (amber), but the model guessed today was February 2025 (red); Quillpath's data date (blue) and the course date (grey) are later still.

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.

python
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_OF in front; a rejected reply prints no answer.
  • The last line makes the decision in code: 9 days, inside the window.
RENEWED 2026-05-30: REFUND DUE? no date given asked for it; once said no "Today is 2026-06-08" model: yes rule in code: 9 days, inside the 14-day window: yes
Without today's date the model could not answer reliably (red); told it was 8 June it answered yes, matching the rule in code (green): dates go in the prompt.

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.

WHAT IT SAID AGAINST WHAT WAS MEASURED cut-off said: March 2026 card: March 2026 accuracy said: 100% measured: 83% newest Gemini said: 1.5 Pro it is 3.5 Flash-Lite
The model's self-reports (amber) were right about its cut-off (green) but over-predicted its accuracy and did not know itself (red): test self-reports, never trust them.

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.

python
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

  • DOCUMENTED holds 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

WHO DOES WHAT IN THE PILOT code counts, dates refund rule model labels, extracts drafts text person rare categories money, review every claim backed by a labelled set and an interval
The measurements decide the split: code (blue) does exact work, the model (amber) does what it measured well at, and a person (green) covers what the numbers cannot vouch for.

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, calibration and the noise-floor check, for Modules 4 and 10.

Check yourself

  1. The model scored 20 of 24. Why is "83% accurate" an incomplete claim, and what is the complete one?
  2. 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?
  3. Why is the paired comparison more convincing than the two totals?
  4. The model wrote 100 on T-1007 and was wrong. Why can a confidence threshold not catch this?
  5. The model reported its cut-off correctly. Why does that not settle what it knows?
  1. It leaves out the uncertainty from a small set. Complete: 83%, with a 95% interval of 64% to 93%, on 24 dev tickets.
  2. One change sits just above a zero noise floor from three runs; it may be real or chance. More tickets and more repeats.
  3. Both models saw the same tickets, so the 8 against 1 count compares them ticket by ticket, removing differences in which tickets were hard.
  4. The threshold only acts on low confidence; a wrong answer at 100 passes any threshold.
  5. 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.

python
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_needed grows the set ten tickets at a time until the interval fits; the assert checks 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 cancellation is realistic.

Key terms

TermMeaning
Labelled setInputs with the right answer written down by a person, used to score a model
Confidence intervalThe range of true accuracies consistent with a measured score
BaselineThe score of a trivial method, such as always giving the most common label
Paired comparisonComparing two models ticket by ticket on the same inputs
Noise floorHow much answers change when the same request is repeated
Prompt sensitivityAnswers changing with wording or format that keeps the meaning
CalibratedStated confidence matches the measured accuracy
Confident wrong answerA mistake given with high confidence
Knowledge cut-offThe date the training text stops
Self-knowledgeHow 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