TAI BUI
Back to curriculum
CapstonePython

Task Spec Format

A task spec is the contract an eval harness's tasks have to meet before anything gets scored. Freeze the JSONL shape and the metric vocabulary before you write a scoring function.

TIME: ~90 minPREREQUISITES: Phase 19 Track B foundations

Learning Objectives

  • Define a JSONL task record schema that covers arithmetic, multiple-choice, code execution, classification, and free-text summarisation in one shape.
  • Pin a closed vocabulary of metric names so downstream lessons (71, 72, and 75) can dispatch on a single field.
  • Specify few-shot examples and post-processing rules as part of the task, not the runner, so the same prompt produces the same target across models.
  • Implement a strict validator that rejects malformed records before they reach the runner.
  • Ship a 10-task fixture set that exercises every branch of the spec so the validator has something real to chew on.

Why a frozen spec

A frozen spec is one task schema, one closed set of metric names, and a validator that rejects records outside them. The validator checks every record before it reaches the runner.

Without one, a research codebase picks up eval scripts faster than it picks up tests. Six months in, notebooks carry their own JSON shapes, metrics get reimplemented twice, and runs can't be compared. The fix is boring: pick a schema, write a validator, reject the rest.

The shape borrows ideas from BIG-bench, HELM, and lm-eval style harnesses, but the field names are ours. Each field has one owner. The runner reads the task, the metric reads the targets, and the post-process step normalises the generation. No field changes mid-pipeline.

Check: Why a frozen spec0/2
1. What is a frozen spec in an eval harness?Define
2. The spec gives every field exactly one owner. What does that prevent?Explain

The record shape

A task record is one JSON object on one line. The harness reads tasks.jsonl and validates each line independently, so a bad line fails that record and leaves the run alone.

json
{
  "task_id": "arith_001",
  "category": "arithmetic",
  "prompt": "Compute the result. Question: 17 + 24\nAnswer:",
  "targets": ["41"],
  "metric_name": "exact_match",
  "few_shot_examples": [
    {"prompt": "Question: 2 + 2\nAnswer:", "completion": "4"}
  ],
  "post_process": "strip_whitespace",
  "metadata": {"difficulty": "easy"}
}

Six fields are required: task_id, category, prompt, targets, metric_name, post_process. few_shot_examples and metadata are optional. A top-level field outside those eight fails validation.

Check: The record shape0/3
1. What is a task record?Define
2. Why does the spec use one record shape for arithmetic, multiple-choice, code, classification and summary tasks?Explain
3. What does the validator do when a record has an unknown top-level field?Apply

Field rules

task_id is a string made of letters, digits, underscores, and hyphens only (`^[A-Za-z0-9_-]+), unique across the file.

category is one of arithmetic, mcq, code_exec, classification, summary. It decides which metric and post-process pair is legal. The legal pairs are: arithmetic takes exact_match or f1; mcq takes exact_match or accuracy; code_exec takes only code_exec; classification takes exact_match, accuracy, or f1; summary takes exact_match, f1, bleu_4, or rouge_l. An mcq task also needs exactly one target, a single letter from A to E.

prompt is a non-empty string with no trailing whitespace. A prompt that looks like it carries its own few-shot block is rejected when few_shot_examples is empty. The check is a heuristic: it lowercases the prompt and flags it if more than one of question:, q:, or example: appears. Few-shot rendering happens in the runner, and the author supplies the examples in few_shot_examples.

targets is a non-empty list of strings. For exact_match, a match on any element counts. For f1 and rouge_l, the highest-scoring target wins. For mcq, the list holds exactly one element.

metric_name is one of exact_match, f1, bleu_4, rouge_l, accuracy, code_exec. The vocabulary is closed, so a new metric means a new lesson and a new entry here.

few_shot_examples is a list of {prompt, completion} pairs, capped at eight entries to keep prompts bounded.

post_process is one of none, strip_whitespace, lower, extract_letter, extract_code_block, extract_first_line. Each rule does one deterministic thing, and rules can't be combined.

Check: Field rules0/11
1. What does a task's category field decide?Define
2. What does it mean that the metric vocabulary is closed?Define
3. What is the few_shot_examples field on a task record?Define
4. Later lessons dispatch on metric_name. Why does the spec keep that vocabulary closed?Explain
5. A task lists several targets. How do exact_match and f1 use that list differently?Explain
6. Which category and metric pairing does the validator reject?Apply
7. A record has nine few_shot_examples entries, each a valid prompt and completion pair. What does the validator do?Apply
8. A record uses the task_id `arith.003`. What does the validator report?Apply
9. A task has an empty few_shot_examples list, and its prompt contains `Question: 2 + 2 Answer: 4` followed by a second `Question:` line. What happens?Apply
10. A single-question prompt reads `Question: Name the capital of France.`, then `Example: answer with one word.`, then `Answer:`. few_shot_examples is empty and the record is rejected as embedded_few_shot. What explains it?Analyze
11. A teammate needs a chrf metric for summary tasks, and downstream code dispatches on metric_name. Which plan keeps the spec closed and consistent?Design

Validator behaviour

Mermaid diagram

The validator returns two lists. One holds the validated records. The other holds error records, each with the offending line, the violated rule, and the field at fault. Each record gets one error at most, for the first rule it breaks. A task id seen twice and a line that is not valid JSON are reported too. main.py stops at returning the two lists. Deciding what a non-empty error list means for a run is the runner's job in lesson 75, and the intended policy is that it refuses to start.

Check: Validator behaviour0/6
1. What does the validator hand back after checking a tasks file?Define
2. Why does the harness run the validator before the runner sees any task?Explain
3. A tasks file has a record with no task_id field. What does its error record show?Apply
4. A file has the same valid record on line 1 and again on line 2. What does validate_file return?Apply
5. Line 1 of a file is the broken text `{not json` and line 2 is a valid record. What does validate_file return?Apply
6. Two teams run the same tasks.jsonl and get incomparable results because one loader skips bad lines and the other stops at the first. Which part of the spec's design addresses this?Analyze

Few-shot rendering

Few-shot rendering is the step that turns a task's examples and prompt into the text a model sees. The runner puts the examples in front of the prompt, separated by blank lines. One code path serves all models, so the model is the only source of variance. Authors write examples once, not once per provider.

python
def render(task):
    parts = []
    for ex in task.get("few_shot_examples", []):
        parts.append(ex["prompt"] + " " + ex["completion"])
    parts.append(task["prompt"])
    return "\n\n".join(parts)
Check: Few-shot rendering0/4
1. What is few-shot rendering?Define
2. Why do few-shot examples live on the task record instead of in each model's runner code?Explain
3. A task has one few-shot example (prompt `Q:1+1\nA:`, completion `2`) and the prompt `Q:3+3\nA:`. How is the text built by render_prompt laid out?Apply
4. You want five worked examples on hard arithmetic tasks and none on easy ones, for every model you evaluate. Where should that choice live?Design

Post-process rules

Post-processing is the step between generation and metric that turns raw model output into something comparable to a target. It is deterministic and stateless.

  • none returns the string unchanged.
  • strip_whitespace strips leading and trailing whitespace.
  • lower lowercases the string.
  • extract_letter returns the first standalone letter from A to E (one not touching another letter), and only if there is none falls back to the first A-E character anywhere, or an empty string. It is used for MCQ. In Answer: C is correct it returns C, because the A in Answer is part of a word.
  • extract_code_block returns the body of the first triple-backtick fenced block, used for code-exec. If the text has no fence it returns the whole text unchanged.
  • extract_first_line returns the first non-empty line, used for summary classification.

A task that needs a rule outside this list belongs in a new lesson.

Check: Post-process rules0/5
1. What is post-processing in the task spec?Define
2. What does the extract_letter post_process rule return for the generation `Answer: C is correct`?Apply
3. What does extract_letter return for the generation `Cab D`?Apply
4. A model replies to a code_exec task without any fenced block. What does extract_code_block return?Apply
5. An mcq task has target B, metric exact_match and post_process none. The model replies `B) Mercury`, which a person would mark correct, yet it scores wrong. Which choice explains this?Analyze

What the spec does not do

The spec doesn't score, call a model, or run code. Those come in lessons 71, 72, and 75. What it does is freeze the contract they all honour.

The 10-task fixture holds two arithmetic items, two MCQ items, two code-exec items, two classification items, and two summarisation items, and the validator passes all 10. A separate fixture (tasks_bad.jsonl) holds 10 records built to fail, and the validator returns exactly one error for each. They cover a missing field, a bad id, a bad category, an illegal pair, empty targets, trailing whitespace, an unknown field, a bad metric, a multi-target MCQ, and a bad post-process rule. The embedded few-shot, malformed JSON, and duplicate id checks are not in that file. The tests in tests/test_spec.py cover them.

Check: What the spec does not do0/4
1. What is the fixture set in this lesson?Define
2. Why is each record in tasks_bad.jsonl built to break exactly one rule?Explain
3. The spec validates tasks. Which job does it deliberately leave to later lessons?Explain
4. What does the validator return for the 10 records in tasks_bad.jsonl?Apply

Key Terms

TermWhat it means
Frozen specOne task schema, one closed set of metric names, and a validator that rejects records outside them
Task recordOne JSON object on one line of tasks.jsonl, validated independently of every other line
CategoryOne of arithmetic, mcq, code_exec, classification, summary; decides which metric and post-process pair is legal
Closed metric vocabularyThe fixed list exact_match, f1, bleu_4, rouge_l, accuracy, code_exec; a new metric means a new lesson
Few-shot examplesA list of {prompt, completion} pairs on the task record, capped at eight, rendered in front of the prompt by the runner
Few-shot renderingThe step that turns a task's examples and prompt into the text a model sees, with one code path for all models
Post-processingThe deterministic, stateless step between generation and metric that makes raw output comparable to a target
ValidatorChecks each record against the spec and returns the validated records plus error records, with at most one error per record
Fixture setThe 10-task tasks.jsonl that the validator passes, paired with tasks_bad.jsonl, whose 10 records each fail with exactly one error
Author's Note

I'd fight to keep the metric vocabulary closed. In main.py it is a tuple of names plus a table mapping each category to its legal metrics, so a bad metric or a bad pairing fails a lookup and the record is rejected. Adding a metric then means editing both, which is the friction I want.

I'd also keep few-shot examples on the task record and out of the runner. render_prompt in main.py takes only the task, so two models get the same text. My bet is that prompt construction is where cross-model comparisons quietly go wrong, and I'd rather take that variable away than document it.

How to read the code

main.py defines TaskSpec, validate_task, validate_file, and load_fixtures, which writes both fixture files into a directory you give it. There is no CLI beyond demo(), which runs when you execute the file. The render and post-process helpers sit next to validation, so the runner in lesson 75 can import a single module.

Read main.py top to bottom, then tests/test_spec.py. The tests pin each validation rule and each post-process behaviour. The demo at the bottom of main.py writes both fixtures to a temporary directory, validates them, and prints a summary.

Going further

Real eval suites grow categories the way schemas grow columns. The sober move is to refuse a new category until it arrives with a metric, a post-process rule, and at least one fixture task. Treat the spec like a database migration: review the change, version it, and ship tests with it. The validator is the gate.

Warm-up0/3
1. Your team has five eval scripts, each storing its tasks in a different JSON layout. What is the main cost when you try to compare results across them?Warm-up
2. A task file has 1,000 lines and line 412 is malformed. Which loader behaviour is most useful?Warm-up
3. You compare two models on one question. Model X sees three worked examples first and model Y sees none. What is wrong with the comparison?Warm-up
Check: Going further0/1
1. A teammate proposes a new category, tool_call, and the pull request only adds the name to the categories tuple. What should review require before merging?Design

Code