Task Spec Format
An eval harness is only as good as the contract its tasks honour. Freeze the JSONL shape and the metric vocabulary before you write a single scoring function.
Lesson 68 of 85Build~90 minPython
Before this: Phase 19 Track B foundations
On this page
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-73) 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.
ci-task-spec-gate
Why a frozen spec
A research codebase will accumulate eval scripts faster than it accumulates tests. Six months in, every notebook has its own JSON shape, every metric is reimplemented twice, and nothing can be compared across runs. The fix is boring. Pick a schema. Write a validator. Reject everything else. That is what this lesson does.
The shape borrows ideas from BIG-bench, HELM, and lm-eval style harnesses, but the field names are ours. Every field has a single owner. The runner reads the task. The metric reads the targets. The post-process step normalises the generation. No field is mutable mid-pipeline.
The record shape
A task is a JSON object on a single line. The harness reads tasks.jsonl and validates each line independently. A bad line aborts that record, not the run.
{
"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"}
}
The required fields are task_id, category, prompt, targets, metric_name, post_process. few_shot_examples and metadata are optional. Unknown top-level fields fail validation.
Field rules
task_id is a string with no whitespace. The validator enforces uniqueness across the file.
category is one of arithmetic, mcq, code_exec, classification, summary. The category constrains which metric and post-process pair is legal. A code_exec task must use metric_name = code_exec and a mcq task must use metric_name = exact_match against a single-letter target.
prompt is a non-empty string. The validator forbids trailing whitespace and rejects records that already contain a few-shot block in the prompt body. Few-shot rendering happens in the runner, not the author.
targets is a non-empty list of strings. For exact_match, any element matching 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. A new metric requires a new lesson and a new entry here.
few_shot_examples is a list of {prompt, completion} pairs. The validator caps the list 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 has a single deterministic behaviour. The validator forbids combining rules.
Validator behaviour
The validator returns two lists: validated records and error records with the offending line, the violated rule, and the field at fault. The runner refuses to start if the error list is non-empty unless an explicit --allow-bad-tasks flag is set.
Few-shot rendering
The runner concatenates few-shot examples in front of the prompt with a blank line separator. The same code path runs for every model, so the only source of variance is the model itself. Authors write examples once, not once per provider.
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)
Post-process rules
The post-process step runs after generation, before the metric. It is deterministic and stateless.
nonereturns the string unchanged.strip_whitespacestrips leading and trailing whitespace.lowerlowercases the string.extract_letterreturns the first character that matches[A-E], used for MCQ.extract_code_blockreturns the body of the first triple-backtick fenced block, used for code-exec.extract_first_linereturns the first non-empty line, used for summary classification.
A task that needs a rule outside this list belongs in a new lesson.
What this lesson does not do
It does not score. It does not call a model. It does not run code. Those come in lessons 71, 72, and 75. This lesson freezes the contract that all of them honour.
The 10-task fixture covers two arithmetic items, two MCQ items, two code-exec items, two classification items, and two summarisation items. The validator passes on all 10. A separate fixture (tasks_bad.jsonl) trips every rule and the validator returns exactly that many errors.
How to read the code
main.py defines TaskSpec, validate_task, validate_file, and a CLI entry point. The fixture loader is load_fixtures. The render and post-process helpers live next to validation so the runner in lesson 75 imports a single module.
Read main.py top to bottom. Then read code/tests/test_spec.py. The tests pin every validation rule and every post-process behaviour. The demo at the bottom of main.py validates the bundled fixture and prints a summary.
Going further
Real eval suites grow categories the way schemas grow columns. The sober move is to refuse to add a category without also adding a metric, a post-process rule, and at least one fixture task. Treat the spec like a database migration. Every change is reviewed, versioned, and accompanied by tests. The validator in this lesson is the gate.
Check yourself
Quick quiz
Why freeze the task record schema before writing scoring code?
- It locks the contract so metrics, runners, and post-processors agree on field names
- It allows arbitrary new metric names without further changes
- It makes the JSON parser run faster
- It removes the need for a validator
Show the answer
A. It locks the contract so metrics, runners, and post-processors agree on field names The schema is the contract every downstream consumer reads from. Locking it lets you change components independently without breaking the rest.
What is the closed vocabulary that the metric_name field accepts?
- Any string the task author chooses
- Only exact_match and f1
- Whatever the runner is configured to recognise
- exact_match, f1, bleu_4, rouge_l, accuracy, code_exec
Show the answer
D. exact_match, f1, bleu_4, rouge_l, accuracy, code_exec The lesson pins the metric vocabulary to six names. Adding one requires a new lesson and a new validator entry.
What does the validator do when it sees an unknown top-level field on a record?
- Logs a warning but accepts the record
- Strips it and continues
- Renames it to metadata.unknown
- Returns a validation error and rejects the record
Show the answer
D. Returns a validation error and rejects the record Unknown fields are a forward-compatibility hazard. The validator rejects them so the schema stays the only source of truth.
Why are few-shot examples attached to the task record rather than computed by the runner?
- Few-shot is only used during fine-tuning
- Author intent is part of the eval; binding examples to the task removes per-model variance in prompt construction
- It saves disk space
- The runner does not have file I/O
Show the answer
B. Author intent is part of the eval; binding examples to the task removes per-model variance in prompt construction Few-shot composition is part of the eval definition, not the model interface. Putting it in the task means every model sees the same prompt.
Which category-metric pair is illegal under the validator?
- code_exec + code_exec
- summary + rouge_l
- arithmetic + exact_match
- mcq + bleu_4
Show the answer
D. mcq + bleu_4 BLEU on a single letter is meaningless. The validator enforces that mcq tasks use exact_match or accuracy only.
What does the post_process rule extract_letter return when the generation is `Answer: C is correct`?
- Answer
- An empty string
- The full string unchanged
- C
Show the answer
D. C extract_letter returns the first character matching [A-E] in the generation. The string has C at the start of the body.
Questions people ask
What should an AI evaluation task include?
An id, the input or prompt, the expected answer or test, the metric to score it, and any metadata such as category or difficulty. A fixed format lets one runner handle every task and keeps scores comparable over time.
What is JSONL?
JSONL, or JSON Lines, stores one JSON object per line in a text file. It is easy to append to, stream and process line by line, which makes it popular for datasets, logs and evaluation tasks.
Lessons from AI Engineering from Scratch by Rohit Ghumare and contributors, MIT license. Notes for owners by Lozo. License