Rubricon

the number nobody checks

Your eval says 75%. The truth is 60%.

Rubricon is the eval reliability platform. Every other tool tells you how good your model is; this one tells you whether the number can be trusted, and what it is really worth once the judge's own bias is taken out of it.

$ pip install rubricon-eval

The distribution is rubricon-eval; the import and the command are both rubricon. Mind the hyphen: plain rubricon on PyPI is an unrelated project.
Calibration receipt judge helpfulness · 9ce49933 · n=40 · 24 labelled

This is checkable. The forty responses behind it were tagged as helpful or not when they were written, so the true rate really is 0.600. The raw score is fifteen points out — and no amount of extra samples would fix it. More data only tightens the interval around the wrong number.

a real run

One command. Everything it took to trust the number.

This is not a mock-up of a report. It is the report: the output of rubricon align against the bundled example, which you can clone and run yourself in about a minute.

rubricon align examples/support_bot · 40 samples · $0.03
$ rubricon align examples/support_bot/suite.yaml \
      --judge helpfulness --labels examples/support_bot/labels.jsonl

  Judge "helpfulness" · 9ce49933373f  ·  n=40 scored  ·  24 human-labelled
══════════════════════════════════════════════════════════════════════════

   Raw score (what your dashboard shows)          0.750
   Bias-corrected (stratified PPV/NPV)            0.561   95% CI [0.364, 0.708]  <-
   Bias-corrected (Rogan-Gladen)                  0.735   95% CI [0.411, 1.000]  biased by this design

   Judge precision (PPV)                          0.714   [0.454, 0.883]
   Judge NPV                                      0.900   [0.596, 0.982]
   Agreement with humans (kappa)                  0.589   (moderate)

     Verbosity bias    -0.079   controlling for true quality   (p=0.722)

    the labels were sampled by the judge's verdict rather than at random. That
     makes sensitivity and specificity biased — and so makes Rogan-Gladen biased —
     but leaves PPV and NPV valid, so prevalence is recovered from those instead.

     Your "75% pass rate" is really 36% to 71%.
     When your judge says pass it is right 71% of the time; when it says fail, 90%.
     Agreement with humans is only 0.59 (moderate). Below 0.60 the judge and the
      humans are measuring different things — fix the rubric before reading anything
      else here.
     n=40 is too small to see +3.0 pp. The smallest detectable improvement here is
      +21.0 pp; you would need n=2,137.

Three things in that output are the reason the tool exists. It picked a different estimator and said why: labelling evenly from the judge's passes and fails is the right way to label and it breaks Rogan-Gladen, so the biased one is marked rather than quietly printed. It refused to be confident at kappa 0.589. And it said the run could not answer the question that was probably being asked: +3 pp was never detectable at n=40.

the annoying part

A judge is a measuring instrument. Almost nobody calibrates theirs.

An LLM judge has imperfect sensitivity and specificity, and it drifts lenient — prompt-iterating on “is this response acceptable?” is a process that converges on yes. That makes a raw judge score a biased estimator, and the bias does not shrink as you collect more samples.

1 +15pp

The bias does not average out

More data tightens the interval around the wrong number. Only a correction moves the number itself, and a correction needs human labels the tool knows how to spend sparingly.

2 0.589

Below this, stop reading

Agreement with humans under 0.60 means the judge and your labellers are measuring different things. Rubricon says so instead of printing the score anyway.

3 +21pp

The smallest change the run could see

At n=40, a 3-point improvement was never detectable. “No significant change” and “no change” are not the same finding, and only one of them is a reason to ship.

4 4

Bias modes, measurable in an afternoon

Position, verbosity, self-preference and sycophancy probes ship with the tool and run beside every calibration. Almost nobody measures them.

the bake-off

The default judge model was chosen by measurement, not by vibes.

Four candidates, the same forty responses, each calibrated against the known truth of 0.600. examples/support_bot/bakeoff.py is the script that produced this table, and it is the same script you would point at your own data.

drag the table sideways →

Candidate Raw Corrected 95% CI Covers 0.600 Kappa Cost /40
google/gemini-2.5-flashdefault 0.750 0.589 [0.393, 0.714] yes 0.753 $0.0331
google/gemini-2.5-flash-lite 0.750 0.561 [0.364, 0.708] yes 0.589 $0.0073
openai/gpt-5-mini 0.775 0.568 [0.372, 0.708] yes 0.673 $0.0331
openai/gpt-oss-120b 0.800 0.550 [0.355, 0.701] yes 0.595 $0.0023

Every one of them covers the truth once corrected, and every raw score overstates it by 15 to 20 points in the same direction. That is the argument in one table: correction is what makes a cheap judge usable, not a better model. The corrected estimates barely separate the candidates; kappa does, and two of the four land below the 0.60 line at which this tool's own report tells you to stop reading. One honest caveat: 0.753 against 0.589 on 24 labels is about one standard error, so the ranking is not statistically established. What is not a question of significance is that one point estimate sits below the usability threshold and the other does not.

one level up from align

Your score moves depending on how you measured it.

align corrects one judge, set up one way. It cannot say whether a published improvement would survive someone else picking a different judge model, a different seed, or leaving the rubric's worked examples out. Nobody records those choices, and each of them moves the score. rubricon harness scores the same samples across a grid of them and reports how far the number travels.

$ rubricon harness suites/example.yaml \
      --judge-model gemini-2.5-flash,gpt-5-mini,gpt-oss-120b --detect 0.03

  Harness sweep · helpfulness · 4 cells · 40 samples · $0.0758

   0.750   judge_model=google/gemini-2.5-flash
   0.750   judge_model=google/gemini-2.5-flash-lite
   0.775   judge_model=openai/gpt-5-mini
   0.800   judge_model=openai/gpt-oss-120b

   spread     5.0 pp

    Your score moves 5.0 pp depending on how you measured, not on what you measured.
    That is larger than the 3.0 pp you are trying to detect. Pin the setup and
     report it before comparing anything measured under it.

Only measurement factors are swept — judge model, seed, reasoning effort, whether the rubric's anchors are shown — never the target or the dataset, because that is a different question and experiment already answers it. The target runs once and is cached, so only judge calls multiply with the grid, and --plan prices the whole sweep before anything is spent. Add seed or effort to the grid and the report decomposes the spread by factor, using the same variance-decomposition machinery align uses for prompt and temperature nuisance factors.

what you get

What is in the box today.

Everything below works end to end against real models. Interfaces may still move: this is pre-alpha, and the roadmap says what it is not yet.

Statistics
Wilson and BCa bootstrap intervals, Rogan-Gladen with a delta-method CI, PPI++, verdict-stratified PPV/NPV via Zou & Donner MOVER, Cohen's kappa and Krippendorff's alpha, pass@k / pass^k, power and MDE, four bias probes, variance decomposition, drift tests, Benjamini-Hochberg and Holm-Bonferroni. Depends on numpy and scipy only.
Judges
YAML rubrics with content-hash versioning, a structured-output ladder that degrades from json_schema to tool calls to prompting, any model family through OpenRouter, response caching, and a cost estimate before you spend anything.
Code scorers
Seventeen exact and free scorers: match, regex, JSON schema, latency, cost, and six retrieval metrics for RAG. No API key required for any of them.
Calibration
rubricon align: the receipt above, with automatic estimator selection, verdict-stratified annotation, and target-output caching so calibrating the same samples never re-runs your pipeline.
Experiments
rubricon experiment: paired bootstrap and sign-flip permutation test, a minimum detectable effect beside every delta, and slice analysis corrected for multiplicity so twenty slices do not manufacture one “significant” finding.
CI gates
--gate on both eval and experiment. A gate fires only on a statistically significant regression, because a gate that blocks on noise is a gate that gets switched off. A skipped gate does not pass.
Storage
SQLite by default, Postgres-compatible DDL, immutable judge and dataset versions. Every run, score, judge version and per-sample verdict is recorded.
Dashboard
rubricon serve: run history, per-scorer breakdown, per-sample verdicts, judge versions. A local React application, with a JSON twin of every page.

install

Calibrate first. Everything downstream is anchored to that number.

Code scorers need no API key at all, so the first three commands cost nothing. Judges route through OpenRouter; put the key in the environment or a .env file.

# scaffold a project
pip install "rubricon-eval[all]"
rubricon init my-eval

# what will this cost? spends nothing
rubricon eval suites/example.yaml --plan
rubricon eval suites/example.yaml

# label a sample, stratified by the judge's verdict
rubricon annotate suites/example.yaml --judge helpfulness

# the calibration receipt
rubricon align suites/example.yaml --judge helpfulness \
    --labels labels/labels.jsonl

# A/B two versions, paired, with a minimum detectable effect
rubricon experiment base.yaml candidate.yaml --gate

# local dashboard on :6006
rubricon serve

Use the statistics without the rest

rubricon.stats imports numpy and scipy and nothing else, including nothing else from Rubricon. Point it at scores from Langfuse, Phoenix, Braintrust or a CSV.

from rubricon.stats import (
    ConfusionMatrix, stratified_prevalence,
)

cm = ConfusionMatrix.from_labels(
    human_labels, judge_labels,
)

# the honest number, not the raw one
print(stratified_prevalence(
    0.75,                        # raw
    cm.precision().value,        # PPV
    cm.npv().value,
    n_test=1000,
    n_labelled_positive=150,
    n_labelled_negative=150,
))

Block a regression, not a coin flip

The gate fires on a significant paired difference. If the run was underpowered to see the delta you care about, it says that instead of passing you quietly.

# .github/workflows/eval.yml
- name: Evaluate the candidate
  run: |
    rubricon experiment \
      suites/main.yaml \
      suites/candidate.yaml \
      --gate
  env:
    OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}

# exit 1 only on a significant
# regression; noise does not
# block the merge

before you install it

Questions worth answering first.

Do I need an API key?

Not for the statistics layer or the seventeen code scorers: match, regex, JSON schema, latency, cost and the retrieval metrics all run locally and free. You need one only for LLM judges, which route through OpenRouter so a single client reaches every model family. rubricon eval --plan prices a run before it spends anything.

How many human labels does a calibration need?

Fewer than you would guess, and the report tells you rather than making you estimate. The example above corrects a 40-sample run from 24 labels. Labels are drawn stratified by the judge's verdict, which is what makes the rarer class reachable at all, and Neyman allocation decides how to split them. The report then states the minimum detectable effect, so you know whether the run could have answered your question before you read the answer.

Which judge model does it use?

Whichever you say. The model resolves per judge, then per suite, then per project, then from the packaged default, and --model overrides all four. The default is google/gemini-2.5-flash, and it holds that position because of the bake-off table above, not because of a preference. Re-run bakeoff.py on your own data and the answer may well be different.

Does it send my data anywhere?

Local-first: no account, no cloud, no telemetry. Runs are stored in a local SQLite file and the dashboard is a process on your machine. The only egress is the judge call to your own provider, with your own key, and pure code scorers make none at all.

Can I use it alongside the eval tool I already have?

That is the intended path. The eval SDK layer (tracing, judge templates, scorer registries) is a commodity now; statistical trustworthiness of the resulting number is not, and that is the only thing Rubricon claims. rubricon.stats depends on numpy and scipy and nothing else, not even the rest of this package, so you can point it at an array of scores and labels from Langfuse, Phoenix, Braintrust or a CSV without adopting anything else.

Why pip install rubricon-eval and not rubricon?

Plain rubricon on PyPI already belongs to an unrelated project in a confusingly adjacent domain. Publishing under it is not an option and squatting near it would be worse, so the distribution is rubricon-eval while the import package and the command stay rubricon.

one minute, three cents

Find out what your 75% is really worth.

Clone the repo, run the bundled example, and compare the corrected interval against a truth you can check by opening the file.