Grading a Whole Rubric in One Call¶
Let an LLM judge answer every criterion of an item in one call instead of one call per criterion, and check on labelled data that the cheaper calls still grade the way you need.
The Scenario¶
You grade a few thousand student answers each term against an eight-criterion rubric with an LLM judge. By default the judge makes one call per criterion, and every call re-sends the grading instructions and the whole answer, so each answer is paid for eight times. CriterionGrader(llm_calls="per_item") asks the same judge for all eight verdicts in a single call. It is clearly cheaper; what you need to know is whether it grades as well. Teaching assistants have labelled a few hundred answers criterion by criterion, and you want to check the single call against them before switching.
What You'll Learn¶
- Switching a judge, a panel or a cascade to one call per item with
llm_calls="per_item" - What a single call changes: tokens and cost, latency,
max_tokens, failure scope - Validating the switch on labelled data with
compute_metrics, the judge's MET rate, and verdict agreement within and across modes over replicate runs - What a live comparison on an essay set and a RiceChem sample found: cheaper and faster on both, stricter on RiceChem
- Deciding when one call per item is a good trade, and what to watch after switching
The Solution¶
One call per item is a cost tool. It pays off when the single call grades your rubric the way per-criterion calls do, and whether it does depends on the rubric and the model. This recipe measures that on labelled data before anything is switched over.
Step 1: Switch the Judge to One Call per Item¶
from autorubric import LLMConfig
from autorubric.graders import CriterionGrader
grader = CriterionGrader(
judge_model_config=LLMConfig(model="openai/gpt-4.1-mini"),
llm_calls="per_item", # the default is "per_criterion"
)
report = await rubric.grade(to_grade=answer, grader=grader, query=question) # one call
llm_calls applies to every LLM judge of the grader and never to a decision-model judge: each judge of a panel (judges=[...]) makes one call per item, and so does a cascade's escalation judge for an item with any escalated criterion. Enabling it covers these cases and the settings it cannot be combined with.
The name counts LLM calls per item. It is unrelated to per-item rubrics, where each dataset item carries its own Rubric, and to compute_metrics(per_item_metrics=...).
Step 2: Know What Changes¶
The judge gets the same per-criterion instructions, wrapped for a whole rubric: the system prompt embeds the instructions for each kind of criterion the rubric has, the user prompt lists every criterion under an id (c0, c1, ...) and the submission once, and the answer holds one structured judgment per criterion. Grading a whole rubric in one call describes the call in full, including its cost model and how few-shot examples and thinking traces work under it. What matters for the switch:
- Tokens and cost. The system prompt and the submission are sent once per item instead of once per criterion, so input tokens no longer multiply with the number of criteria. Output shrinks less, since every criterion still gets an explanation.
- Latency. One request per item instead of one per criterion raises throughput under a concurrency cap or a rate limit, but one response writes every criterion's explanation in turn, so a single item can take longer.
max_tokensandtimeout. Output grows with the rubric, about 80-100 tokens per criterion plus any thinking. Leavemax_tokensunset, or size it at about 100 tokens per criterion plus the thinking budget: a reply cut off atmax_tokensis not valid JSON and fails every criterion of the item. The default 60-secondtimeoutcan also be too short for a thinking model.- Failure scope. A failed call fails every criterion of the item, and a single judge's item then has no score. A missing or unusable answer for one criterion still fails that criterion alone.
Step 3: Validate Before Switching¶
Grade the same labelled sample with both modes, twice each, and hold everything else fixed: the same model and settings, the same grader seed, and the response cache off, as it is by default, so that a mode's second run does not replay its first. The seed pins multi-choice option order, which is the same in both modes. It also makes each mode's few-shot selection reproducible, but the two modes select examples differently (a per_item example is a whole training item), so with training_data the comparison also changes the examples the judge sees.
The second run is what makes the comparison readable. An LLM judge is not deterministic: a reasoning model samples at its provider's default temperature, which is what it gets when temperature is unset (AutoRubric then sends none), and even at a low temperature most providers do not guarantee identical outputs. Two runs of the same mode therefore disagree on some verdicts, and a difference between the modes means something only if it is larger than that.
from autorubric import LLMConfig, RubricDataset, evaluate
from autorubric.graders import CriterionGrader
dataset = RubricDataset.from_file("labelled_answers.json") # items with ground truth
judge = LLMConfig(model="openai/gpt-4.1-mini", max_parallel_requests=16)
MODES, REPLICATES = ("per_criterion", "per_item"), (1, 2)
runs = {}
for rep in REPLICATES:
for mode in MODES:
grader = CriterionGrader(judge_model_config=judge, llm_calls=mode, seed=42)
runs[mode, rep] = await evaluate(dataset, grader, show_progress=False)
Each evaluate() call without an experiment_name is a new experiment, so re-running the block grades again. If you name the runs, remember that evaluate() resumes an experiment of the same name whenever the dataset's name, prompt, number of items and number of criteria match, without checking the model, its settings, the rubric's wording or the labels, and that a resumed run does not retry the items that failed: give each configuration its own names.
Score each run against the labels, and compare how often the judge says MET with how often the labels do:
from autorubric import CriterionVerdict, compute_metrics
MET, UNMET = CriterionVerdict.MET, CriterionVerdict.UNMET
def verdicts(result):
"""Final verdict of each judged binary (item, criterion) cell; failed judgments left out."""
return {
(r.item_idx, c): cr.final_verdict
for r in result.item_results
if r.error is None
for c, cr in enumerate(r.report.report)
if not cr.is_error and cr.final_verdict is not None
}
def fmt(x, spec=".3f"):
return "n/a" if x is None else format(x, spec)
truth = [v for item in dataset for v in item.ground_truth]
print(f"ground truth: MET rate {truth.count(MET) / len(truth):.1%}")
for (mode, rep), result in runs.items():
metrics = compute_metrics(result, dataset)
judged = list(verdicts(result).values())
print(
f"{mode}#{rep}: accuracy {fmt(metrics.criterion_accuracy)}, "
f"kappa {fmt(metrics.mean_kappa)}, MET rate {judged.count(MET) / len(judged):.1%}, "
f"cost ${fmt(result.total_completion_cost, '.4f')}"
)
Then compare the runs with each other, cell by cell: two runs of the same mode give the run-to-run baseline, and runs of different modes give the mode effect on top of it. The direction of the cross-mode disagreements tells you whether one mode is stricter.
import itertools
from sklearn.metrics import cohen_kappa_score
def agreement(a, b):
"""Share of the cells both runs judged on which they agree, and Cohen's kappa."""
va, vb = verdicts(a), verdicts(b)
cells = sorted(va.keys() & vb.keys())
x, y = [va[k].value for k in cells], [vb[k].value for k in cells]
return sum(p == q for p, q in zip(x, y)) / len(cells), cohen_kappa_score(x, y)
for (m1, r1), (m2, r2) in itertools.combinations(runs, 2):
kind = "within" if m1 == m2 else "across"
share, kappa = agreement(runs[m1, r1], runs[m2, r2])
print(f"{kind} modes, {m1}#{r1} vs {m2}#{r2}: {share:.1%} agree, kappa {kappa:.2f}")
for rep in REPLICATES:
a, b = verdicts(runs["per_criterion", rep]), verdicts(runs["per_item", rep])
cells = a.keys() & b.keys()
stricter = sum(a[k] == MET and b[k] == UNMET for k in cells)
laxer = sum(a[k] == UNMET and b[k] == MET for k in cells)
print(f"run {rep}: per_item UNMET where per_criterion MET: {stricter}; the reverse: {laxer}")
The snippets compare binary verdicts and skip multi-choice criteria; to compare those too, key their cells on cr.final_multi_choice_verdict.selected_index instead of cr.final_verdict. Read the output this way:
- Cross-mode agreement below both within-mode agreements means the modes grade differently, beyond run-to-run noise. Agreement within the range of the within-mode pairs means this sample cannot tell them apart.
- A MET rate that moves the same way in both runs, with the disagreements lopsided in one direction, says which way the single call leans.
- Accuracy and kappa against the labels say whether the shift costs or gains agreement with your graders. Put an interval on the difference before reading much into it: in Step 4, on 100 answers, the 95% interval of the accuracy difference was [−4.1, +0.4] points around −1.8, and its width depends on your sample.
compute_metrics(..., bootstrap=True)gives each run's confidence intervals, and the companion script in the appendix computes a paired, item-level interval for the difference. metrics.per_criterionshows where the shift lands: it can help some criteria and hurt others.
Grade one item first
Before paying for four full runs, grade one item with each mode and check that no criterion failed (cr.error is None on every criterion report). A judge that is misconfigured fails every call and, by default, the run still finishes; evaluate(..., fail_fast=True) stops it at the first failed item instead. See the LiteLLM warning in Step 4.
Step 4: What a Live Comparison Found¶
We ran Step 3 on 2026-09-27 on two labelled datasets that ship in examples/data/, with openai/gpt-6-luna at a low reasoning effort. The measured configuration, for each mode:
judge = LLMConfig(
model="openai/gpt-6-luna",
thinking="low", # sent as reasoning_effort="low"
max_parallel_requests=16,
cache_enabled=False, # replicates must not replay each other's answers
extra_params={"allowed_openai_params": ["reasoning_effort"]}, # see the warning below
)
grader = CriterionGrader(judge_model_config=judge, llm_calls=mode, seed=42)
temperature and max_tokens were left unset, so no temperature and no output cap were sent. Each mode ran twice, and the eight runs ran one after another: for each dataset, the first run of each mode, then the second. The whole experiment, including a one-item smoke test, cost about $0.31.
| Dataset | Items | Criteria | Ground truth | Calls per run (per_criterion / per_item) |
|---|---|---|---|---|
Essay grading (essay_grading_dataset.json) |
All 11 | 5 binary: Causes, Effects, Structure, Britain, Errors |
55 cells, 4 of them CANNOT_ASSESS; MET rate 41.8% |
55 / 11 |
RiceChem Q1 (ricechem/q1.json) |
100 of 327, sampled with random.Random(0).sample(range(327), 100) |
8 binary | Teaching assistants' labels (Sonkar et al., 2024), 800 cells; MET rate 56.1% | 800 / 100 |
LiteLLM and reasoning_effort on new OpenAI models
LiteLLM 1.95.0 recognizes OpenAI reasoning models by name (gpt-5 and the o-series). For openai/gpt-6-luna it therefore refuses reasoning_effort, which thinking="low" sends, with UnsupportedParamsError, raised client-side before any request is made. AutoRubric files that as an infrastructure failure, so every criterion abstains; with a single judge, every item ends with no score and the error Every criterion's judgment failed: infrastructure: .... Nothing raises by default: each failed call is only logged as a warning, easy to miss, and the run's failed_items counts the failed items once it has finished; fail_fast=True stops at the first failed item. In a panel, such a judge contributes nothing without any item failing (#40). extra_params={"allowed_openai_params": ["reasoning_effort"]} lets the parameter through.
How to read the tables and figures below:
- Each table gives both runs of each mode. Where the text quotes one number per mode, it is the mean of that mode's two runs, and a ratio is a ratio of those means.
- Accuracy is
criterion_accuracy, kappa ismean_kappaand score RMSE isscore_rmse, all fromcompute_metricswith its defaults, which leave out cells whose verdict or ground truth isCANNOT_ASSESS. MET rates count every cell. - An accuracy difference with an interval is item-level: each item's accuracy is averaged over its mode's two runs, the items' differences are averaged, and items are resampled 5,000 times for a 95% bootstrap interval. It weights every item equally, so where items have different numbers of labelled cells (the essay set) it differs slightly from the difference of the pooled accuracies.
- Recorded cost is what LiteLLM reported. It is below list price, which we attribute to OpenAI's automatic prompt caching; cached-token counts were not recorded, so that is inferred. List-price cost bills every prompt token at the uncached rate: $0.10 per million prompt tokens and $0.50 per million completion tokens (for this model, cached prompt tokens are billed at $0.01 per million, 90% less).
- Wall-clock time is each run's elapsed time. Values are rounded half up.
RiceChem Q1, 100 answers × 8 criteria
| per_criterion #1 | per_criterion #2 | per_item #1 | per_item #2 | |
|---|---|---|---|---|
| Criterion accuracy | 0.739 | 0.751 | 0.726 | 0.728 |
| Mean kappa | 0.524 | 0.542 | 0.470 | 0.469 |
| Score RMSE | 0.154 | 0.138 | 0.204 | 0.201 |
| Judge MET rate (ground truth 56.1%) | 56.3% | 56.0% | 45.3% | 45.1% |
| Prompt tokens | 1,889,628 | 1,889,628 | 302,941 | 302,941 |
| Completion tokens | 148,550 | 146,838 | 64,879 | 64,780 |
| Recorded cost | $0.1284 | $0.0925 | $0.0438 | $0.0354 |
| List-price cost | $0.2632 | $0.2624 | $0.0627 | $0.0627 |
| Wall-clock | 126.8 s | 123.3 s | 36.6 s | 38.9 s |
Essay grading, 11 essays × 5 criteria
| per_criterion #1 | per_criterion #2 | per_item #1 | per_item #2 | |
|---|---|---|---|---|
| Criterion accuracy | 0.922 | 0.882 | 0.922 | 0.902 |
| Mean kappa | 0.855 | 0.726 | 0.846 | 0.803 |
| Score RMSE | 0.322 | 0.333 | 0.310 | 0.330 |
| Judge MET rate (ground truth 41.8%) | 34.5% | 34.5% | 38.2% | 36.4% |
| Prompt tokens | 118,627 | 118,627 | 30,255 | 30,255 |
| Completion tokens | 4,087 | 4,042 | 3,928 | 3,287 |
| Recorded cost | $0.0042 | $0.0032 | $0.0028 | $0.0019 |
| List-price cost | $0.0139 | $0.0139 | $0.0050 | $0.0047 |
| Wall-clock | 5.9 s | 7.1 s | 4.0 s | 3.6 s |
No run had a failed item or a failed criterion, and no verdict was CANNOT_ASSESS.
Verdict agreement between runs (cells on which two runs gave the same verdict, and Cohen's kappa between them)
| Runs compared | Kind | Essay (55 cells) | RiceChem (800 cells) |
|---|---|---|---|
| per_criterion #1 vs #2 | within a mode | 51 (92.7%), κ 0.84 | 720 (90.0%), κ 0.80 |
| per_item #1 vs #2 | within a mode | 54 (98.2%), κ 0.96 | 739 (92.4%), κ 0.85 |
| per_criterion #1 vs per_item #1 | across modes | 51 (92.7%), κ 0.84 | 666 (83.3%), κ 0.67 |
| per_criterion #1 vs per_item #2 | across modes | 52 (94.5%), κ 0.88 | 667 (83.4%), κ 0.67 |
| per_criterion #2 vs per_item #1 | across modes | 53 (96.4%), κ 0.92 | 682 (85.3%), κ 0.71 |
| per_criterion #2 vs per_item #2 | across modes | 54 (98.2%), κ 0.96 | 683 (85.4%), κ 0.71 |
RiceChem: cheaper, faster, and stricter¶
Cost and time. Per run, per_item sent 6.2x fewer prompt tokens and 2.3x fewer completion tokens than per_criterion. It cost 2.8x less as recorded ($0.0396 against \(0.1104; 2.9x between the first runs and 2.6x between the second) and 4.2x less at list price (\)0.0627 against $0.2628), and it finished 3.3x sooner (37.7 s against 125.0 s).
Completion tokens fell less than prompt tokens, since every criterion still gets its own explanation. That they fell at all is probably because one reasoning pass now covers the whole rubric instead of one per criterion; reasoning tokens were not recorded separately.
The recorded ratio is smaller than the list-price one, which is consistent with prompt caching: caching discounts only prompt tokens, and at list price those are most of per_criterion's bill, since it repeats the system prompt and the answer in every call, while completion tokens are about half of per_item's. Each mode's second run also cost less than its first, consistent with OpenAI's cache still holding prompts the first run had sent word for word; recorded costs depend on what ran before.
The single call graded stricter. per_item marked 45% of cells MET, against 56% for per_criterion and 56% in the ground truth, and did so in both runs. Comparing the two modes' first runs with each other, and their second runs with each other, they disagreed on 251 of 1,600 cells; on 213 of them per_criterion said MET and per_item UNMET, and on 38 the reverse. The ground truth sided with per_criterion on 140 of the 251 and with per_item on 111. Stricter calls traded recall for precision: pooled over both runs, MET precision rose from 0.77 to 0.82 and MET recall fell from 0.77 to 0.66.
Beyond run-to-run noise. Two runs of the same mode agreed on 90.0% of cells (κ 0.80) for per_criterion and 92.4% (κ 0.85) for per_item; runs of different modes agreed on 83.3% to 85.4% (κ 0.67 to 0.71). Every cross-mode pair agreed less than either within-mode pair, so the shift is larger than the disagreement between two runs of the same mode: it comes from the mode, not from the judge's run-to-run randomness. With one pair of runs per mode, that baseline was measured once.
Accuracy lower, but not significantly; kappa and scores worse in every run. Criterion accuracy was 0.727 for per_item against 0.745 for per_criterion, an item-level difference of −1.8 points with a 95% bootstrap interval of [−4.1, +0.4]: not significant on 100 answers. Mean kappa was 0.47 against 0.53, and score RMSE 0.202 against 0.146; each per_item run had a lower kappa and a higher RMSE than each per_criterion run, but no interval was computed for either difference. Every RiceChem criterion has a positive weight, so stricter verdicts pull item scores down. Averaged over each mode's two runs, the two modes' item scores correlate at r = 0.845.
Where the shift lands. The mode columns pool both runs, 200 verdicts per criterion; the ground truth covers the 100 sampled answers.
| Criterion | Ground truth MET | per_criterion MET | per_item MET | per_criterion accuracy | per_item accuracy |
|---|---|---|---|---|---|
decreased_repulsion |
82.0% | 77.5% | 80.0% | 0.935 | 0.960 |
repulsion_potential_energy |
57.0% | 35.5% | 24.0% | 0.745 | 0.650 |
same_core_charge |
47.0% | 52.5% | 44.5% | 0.895 | 0.925 |
same_shell_radius |
54.0% | 26.0% | 13.5% | 0.680 | 0.595 |
higher_core_charge |
53.0% | 54.0% | 41.5% | 0.860 | 0.775 |
smaller_radius |
83.0% | 64.0% | 57.0% | 0.780 | 0.720 |
full_pe_ie_explanation |
52.0% | 49.0% | 41.5% | 0.800 | 0.865 |
partial_pe_ie_explanation |
21.0% | 90.5% | 59.5% | 0.265 | 0.325 |
per_item lowered the MET rate on seven of the eight criteria. It lost 6.0 to 9.5 points of accuracy on four: three on which per_criterion already marked MET well below the ground truth's rate (repulsion_potential_energy, same_shell_radius and smaller_radius), and higher_core_charge, which per_criterion called at about the right rate. It gained 2.5 to 6.5 points on the other four, including partial_pe_ie_explanation, which both modes over-call. With 100 answers per criterion, none of these per-criterion differences was tested for significance.
The full and partial explanation criteria. full_pe_ie_explanation reads "correctly explains relationship of potential energy to ionization energy" and partial_pe_ie_explanation "partially explains relationship between potential energy and ionization energy". The teaching assistants treated them as mutually exclusive: none of the 327 Q1 answers has both MET (175 have only the full one, 56 only the partial one, 96 neither). The rubric does not say so, and neither mode graded them that way:
| (full, partial) | Ground truth (100 answers) | per_criterion (200 verdict pairs) | per_item (200 verdict pairs) |
|---|---|---|---|
| MET, UNMET | 52 | 0 | 1 |
| UNMET, MET | 21 | 83 | 37 |
| UNMET, UNMET | 27 | 19 | 80 |
| MET, MET | 0 | 98 | 82 |
The ground truth's most common pattern, full credit without partial credit, almost never appears in either mode: both judges treat a full explanation as a partial one too. per_item marks both MET somewhat less often (82 of 200 against 98), but its better accuracy on partial_pe_ie_explanation (0.325 against 0.265) comes mostly from marking both UNMET far more often (80 against 19), part of its general strictness, rather than from treating the pair as exclusive. In this comparison, seeing both criteria in the same call did not make the judge apply a relation the rubric does not state.
Essay set: no detectable difference¶
On the essay set the modes cannot be told apart:
- MET rate.
per_itemmarked 37.3% of cells MET against 34.5% forper_criterion(ground truth 41.8%). - Disagreements. Pairing runs as above, the modes disagreed on 5 of 110 cells: 1 that
per_criterionmarked MET andper_itemUNMET, 4 the reverse. The ground truth sided withper_criterionon 2 and withper_itemon 3. - Agreement. Cross-mode agreement, 92.7% to 98.2% (κ 0.84 to 0.96), lies within the range of the two within-mode pairs: 92.7% (κ 0.84) for
per_criterionand 98.2% (κ 0.96) forper_item. - Accuracy. Criterion accuracy was 0.912 against 0.902; the item-level difference is +0.9 points, 95% interval [−3.6, +5.5]. Averaged over each mode's two runs, item scores correlate at r = 0.986 between the modes.
- Per criterion. The modes' MET rates and accuracies were identical on three of the five criteria, and both under-call
Structure(36.4% MET against 63.6% in the ground truth). - Cost and time. The single call was cheaper and faster here too: 3.9x fewer prompt tokens, 1.6x lower recorded cost (2.9x at list price), and 1.7x faster (3.8 s against 6.5 s).
Eleven essays, though, can reveal only a large difference.
Reading the results¶
One call per item was cheaper and faster on both datasets. On RiceChem, the same model also graded measurably stricter in one call: a lower MET rate in both runs, disagreements lopsided toward UNMET, and cross-mode agreement below run-to-run agreement. Its accuracy there was 1.8 points lower, within noise on 100 answers, while kappa was lower and score RMSE higher in every run (kappa 0.470 and 0.469 against 0.524 and 0.542; RMSE 0.204 and 0.201 against 0.154 and 0.138). On the 11-essay set the two modes could not be told apart. The results do not say which mode is right in general; they say that grading in one call changed how this model graded the RiceChem answers.
The limits of this comparison:
- One model,
openai/gpt-6-luna, atreasoning_effort="low", and one provider, OpenAI; Anthropic, Gemini and Groq models were not tested. - Two runs per mode. RiceChem is a seeded random sample of 100 of Q1's 327 answers, and the essay set has only 11 items (55 cells, 4 with
CANNOT_ASSESSground truth). - Both rubrics are binary, with no multi-choice criteria, no few-shot examples, no guidelines and no cascade.
- Recorded costs include OpenAI's automatic prompt-caching discount. Cached-token counts were not recorded per run, so the discount is inferred from recorded cost against list price.
- Wall-clock times are for runs made one after another, with
max_parallel_requests=16and the default item concurrency; they depend on rate limits and provider load.
Step 5: Decide¶
per_item is a good trade when:
- Your own Step 3 shows no shift you care about: cross-mode agreement close to within-mode agreement, a MET rate that matches
per_criterion's, and accuracy and kappa within noise. - Cost or throughput is the constraint. The savings grow with the number of criteria and the length of the submissions, and fewer requests help most under a rate limit.
- A larger failure unit is acceptable, with
max_tokensandtimeoutsized for the whole rubric.
What to watch:
- The MET-rate shift. Compare each mode's MET rate with the ground truth's, overall and per criterion. A shift in the same direction in both runs, with the disagreements lopsided, is systematic; on RiceChem it was about 11 points.
- Criteria the judge already under-calls. Three of RiceChem's four accuracy losses were on criteria
per_criterionalready marked MET well below the labels' rate; the fourth,higher_core_charge, it called at about the right rate. Per-criterion differences on 100 answers were not tested for significance, so treat this as where to look first, not as a rule. - Interdependent criteria. On RiceChem, the full and partial explanation criteria, exclusive in the labels but not in the rubric, were not graded as exclusive in either mode. If your rubric has criteria meant to exclude or imply each other, check their joint pattern against your labels in both modes. Stating the relation in the requirements or the rubric's guidelines may help; this comparison did not test that.
- Scores as well as verdicts. If you use item scores, a shift in verdicts moves every score: on RiceChem, score RMSE rose from 0.146 to 0.202 while accuracy moved 1.8 points.
- Panels as a hedge. A panel of
per_itemjudges from different model families (judges=[...]withllm_calls="per_item") can dilute one model's shift if the models do not all shift the same way, and each judge still sends an item only once. This comparison did not measure a panel. - Every change of rubric, data or model. Whether a shift appears is not a property of the model alone: with the same model, the 11-essay set showed no detectable shift (its MET rate rose slightly, from 34.5% to 37.3%) while RiceChem's fell about 11 points, and this comparison cannot say which difference between the datasets matters. Re-run Step 3 when you change the rubric, the model or its thinking level.
- A new model's configuration. Before a paid run on a model LiteLLM may not know yet, grade one item and check that no criterion failed, or run with
fail_fast=True, as the LiteLLM warning in Step 4 shows.
Key Takeaways¶
- One call per item is a cost tool. On the RiceChem sample it cost 2.8x less as recorded (4.2x at list price) and ran 3.3x faster than one call per criterion.
- It can change how the judge grades. On RiceChem the same model graded stricter in one call: 45% of cells MET against 56%, and 213 of 251 disagreements toward UNMET. On the 11-essay set the modes were indistinguishable.
- Validate with replicates. Grade the same labelled sample twice with each mode; a mode effect must exceed the disagreement between two runs of the same mode.
- Look past accuracy. A 1.8-point accuracy difference within noise came with a MET rate about 11 points lower in both runs, lower MET recall, and a lower kappa and higher score RMSE in every run.
- Check interdependent criteria yourself. In this comparison, seeing every criterion at once did not make the judge treat full and partial credit as exclusive.
- Size
max_tokensandtimeoutfor the whole rubric, since a failed call fails every criterion of the item.
Going Further¶
- LLM Judges: Grading a whole rubric in one call: the call, its cost model, failure scoping, few-shot examples and thinking traces
- Judge Validation: metrics against human labels and bootstrap confidence intervals
- Cost Optimization: response caching, prompt caching and model choice
- Cheap First-Pass Grading with Jev and an LLM Fallback:
per_itemescalation judges in a cascade - Extended Thinking: one reasoning trace per call under
llm_calls="per_item" - API Reference: Graders
Appendix: Complete Code¶
The validation of Step 3 as one script:
"""Validate llm_calls="per_item" against per-criterion grading on labelled data."""
import asyncio
import itertools
from sklearn.metrics import cohen_kappa_score
from autorubric import CriterionVerdict, LLMConfig, RubricDataset, compute_metrics, evaluate
from autorubric.graders import CriterionGrader
MODES, REPLICATES = ("per_criterion", "per_item"), (1, 2)
MET, UNMET = CriterionVerdict.MET, CriterionVerdict.UNMET
def verdicts(result):
"""Final verdict of each judged binary (item, criterion) cell; failed judgments left out."""
return {
(r.item_idx, c): cr.final_verdict
for r in result.item_results
if r.error is None
for c, cr in enumerate(r.report.report)
if not cr.is_error and cr.final_verdict is not None
}
def agreement(a, b):
"""Share of the cells both runs judged on which they agree, and Cohen's kappa."""
va, vb = verdicts(a), verdicts(b)
cells = sorted(va.keys() & vb.keys())
x, y = [va[k].value for k in cells], [vb[k].value for k in cells]
return sum(p == q for p, q in zip(x, y)) / len(cells), cohen_kappa_score(x, y)
def fmt(x, spec=".3f"):
return "n/a" if x is None else format(x, spec)
async def main():
dataset = RubricDataset.from_file("labelled_answers.json") # items with ground truth
judge = LLMConfig(model="openai/gpt-4.1-mini", max_parallel_requests=16)
# Two runs of each mode, first runs first; each call is a new experiment.
runs = {}
for rep in REPLICATES:
for mode in MODES:
grader = CriterionGrader(judge_model_config=judge, llm_calls=mode, seed=42)
runs[mode, rep] = await evaluate(dataset, grader, show_progress=False)
# Each run against the labels.
truth = [v for item in dataset for v in item.ground_truth]
print(f"ground truth: MET rate {truth.count(MET) / len(truth):.1%}")
for (mode, rep), result in runs.items():
metrics = compute_metrics(result, dataset)
judged = list(verdicts(result).values())
print(
f"{mode}#{rep}: accuracy {fmt(metrics.criterion_accuracy)}, "
f"kappa {fmt(metrics.mean_kappa)}, MET rate {judged.count(MET) / len(judged):.1%}, "
f"cost ${fmt(result.total_completion_cost, '.4f')}"
)
# Run-to-run baseline (within a mode) against the mode effect (across modes).
for (m1, r1), (m2, r2) in itertools.combinations(runs, 2):
kind = "within" if m1 == m2 else "across"
share, kappa = agreement(runs[m1, r1], runs[m2, r2])
print(f"{kind} modes, {m1}#{r1} vs {m2}#{r2}: {share:.1%} agree, kappa {kappa:.2f}")
# Which way the cross-mode disagreements go.
for rep in REPLICATES:
a, b = verdicts(runs["per_criterion", rep]), verdicts(runs["per_item", rep])
cells = a.keys() & b.keys()
stricter = sum(a[k] == MET and b[k] == UNMET for k in cells)
laxer = sum(a[k] == UNMET and b[k] == MET for k in cells)
print(f"run {rep}: per_item UNMET where per_criterion MET: {stricter}; the reverse: {laxer}")
if __name__ == "__main__":
asyncio.run(main())
Reproducing the live comparison¶
examples/single_call_grading_comparison.py reruns Step 4: both datasets, both modes, two runs each in Step 4's order, with the configuration shown there. For each dataset it prints every run's accuracy, kappa, MET count (MET cells out of judged cells), tokens, cost and wall-clock time, the agreement between every pair of runs, the item-level accuracy difference with its bootstrap interval, and the direction of the cross-mode disagreements. The model, the reasoning level, the number of runs, the sample, the seeds and the concurrency cap are constants at the top of the script (MODEL, REASONING, REPLICATES, RICECHEM_SAMPLE, SAMPLE_SEED, GRADER_SEED, MAX_PARALLEL_REQUESTS). Run it from a checkout of the repository, since examples/ is not part of the installed package:
The script makes paid calls
Every run sends real, billed requests to the model's provider. With the constants as shipped, the script makes the same 1,932 calls as Step 4 (two runs of each mode: 55 or 11 calls per essay run, 800 or 100 per RiceChem run), which cost about $0.31 in our run with OpenAI's caching discount, or about $0.69 at list price. The judge is sampled, so your numbers will differ from Step 4's.
Each run is an EvalRunner experiment under experiments/single_call_grading/, in a directory named after the model, the reasoning level and the grader seed, and each run's name includes its dataset's name, which carries the RiceChem sample's size and seed. An interrupted run resumes without paying again for the items it graded, and re-running the script reads finished runs back instead of grading them again (their wall-clock time then shows as n/a); changing one of those constants starts new runs, which are paid for. A run with a failed item stops the script, since a resumed run never retries its failed items: fix the cause, delete the directory the script names, and run it again.