Python Scorers
When no built-in scorer expresses your notion of correctness, write it in Python. There are two types, and the difference is what the function receives:
| Type | Called | Use for |
|---|---|---|
python |
Once per sample | Scoring each row on its own. The common case. |
python_all_samples |
Once, with every sample | Scores that depend on the whole set - deduplication, ranking, relative comparisons. |
Prefer a built-in scorer where one fits: built-ins are validated when the task is created, whereas a snippet fails at run time, on the fifth sample, after you have already paid for four model calls.
Per-Sample Scorer
The snippet defines compute_scores and returns a dictionary of score values. Both def and async def work.
tasks/geography/score.py
from __future__ import annotations
from typing import Any
from latticeflow.core.dtypes import RawSample
from latticeflow.core.dtypes import SolverOutput
def compute_scores(sample: RawSample, solver_output: SolverOutput) -> dict[str, Any]:
prediction = (solver_output.trace.get_last_assistant_text() or "").strip().lower()
expected = sample["capital"].lower()
return {
"is_correct": prediction == expected,
"prediction": prediction,
}tasks/geography/task.yaml
definition:
scorers:
- key: capital_city
type: python
compute_scores_snippet: !include "./score.py"
metrics:
- type: mean
field: is_correct
name: "Accuracy"Accepted Signatures
Argument names are fixed and only these four combinations are accepted:
| Signature | Use |
|---|---|
compute_scores(sample) |
Dataset-only tasks - scoring the data itself, no model involved. |
compute_scores(sample, solver_output) |
The common case. |
compute_scores(sample, task_config) |
Dataset-only, with the task’s resolved configuration. |
compute_scores(sample, solver_output, task_config) |
Everything. |
The names must match exactly. compute_scores(sample, output) is rejected before the task runs, and asking for solver_output in a task that has no solver fails with an explicit error.
task_config gives you the resolved configuration parameters, including any models referenced by a model config parameter - so a Python scorer can call a judge model itself when the built-in judges are not enough.
Return Shapes
Two shapes are accepted:
Flat - every key becomes a score value:
return {"is_correct": True, "edit_distance": 3}Structured - separate the numbers from the explanation:
return {
"scores": {"is_correct": True},
"metadata": {"reason": "Matched after normalising whitespace."},
}Use the structured shape when you want a human-readable justification in the evidence without it becoming something metrics aggregate over. metadata is recorded per sample and shown alongside the scores; only scores is available to metrics and to action rule filters.
The two shapes are distinguished structurally: a returned dict whose keys are exactly scores (and optionally metadata) is read as structured, anything else as flat. So a flat scorer must not name one of its score fields scores.
Returning anything other than a dict fails the sample with The Python snippet must return a dict….
Raising Instead of Scoring
Raising an exception marks that sample as failed rather than scoring it zero - which is what you want when the result is undeterminable rather than wrong. A timeout tells you nothing about the model’s correctness, so recording a 0 would quietly bias the metric downwards.
def compute_scores(sample, solver_output):
outcome = solver_output.trace.items[-1].content
if outcome.get("exception_type") == "ReadTimeout":
raise ValueError(
"The endpoint timed out; cannot determine whether the input was rejected. "
f"Original error: {outcome['reason']}"
)
return {"scores": {"rejected": 1 if outcome["rejected"] else 0}}Failed samples are reported separately in the evaluation results, so the distinction stays visible.
All-Samples Scorer
python_all_samples receives the full list and must return one score dictionary per sample, in the same order.
tasks/diversity/score_all.py
from __future__ import annotations
from typing import Any
from latticeflow.core.dtypes import RawSample
def compute_scores(samples: list[RawSample]) -> list[dict[str, Any]]:
seen: set[str] = set()
results: list[dict[str, Any]] = []
for sample in samples:
answer = sample["answer"].strip().lower()
results.append({"is_novel": answer not in seen})
seen.add(answer)
return resultsAccepted signatures are compute_scores(samples) and compute_scores(samples, task_config). Returning anything other than a list, or a list of the wrong length, fails the task.
An all-samples scorer holds the entire evaluation in memory at once and cannot be parallelised across samples. Use it only when the score genuinely depends on other samples - otherwise python is faster and its failures are isolated to one row.
Reading Solver Output
What solver_output looks like depends on the solver:
| Solver | Read it as |
|---|---|
single_turn_solver, multi_turn_solver, pass_through_solver, python |
solver_output.trace - use get_last_assistant_text(), items, turns, function_calls. |
grouped_single_turn_solver |
solver_output.solver_outputs - a dict or list of outputs, each with its own .trace. |
# Grouped solver: compare each perturbation against the baseline.
def compute_scores(sample, solver_output):
outputs = solver_output.solver_outputs
baseline = outputs["base"].trace.get_last_assistant_text()
perturbed = [
output.trace.get_last_assistant_text()
for key, output in outputs.items()
if key.startswith("perturbation_")
]
return {"num_unchanged": sum(1 for text in perturbed if text == baseline)}Reach for trace.items rather than trace.turns when the conversation may not start with a user message - an imported trace with only a system prompt and an assistant reply keeps everything in the trace preamble, leaving turns and get_last_assistant_text() empty. See Core Types: Traces.
Runtime
Snippets run server-side in a fixed Python runtime with a fixed library set; they cannot install packages. See Python Snippets for what is available.
Configuration parameters are substituted textually before the snippet runs, so keep placeholders inside string literals:
threshold = float("<< config.similarity_threshold >>")Field-level reference: Python Scorer and Python Batch Scorer.