Regression tests for a rubric screener

Screening agents are usually tested once, by hand, in the week they are built. Someone runs twenty resumes through, reads the scores, nods, and ships it. Then a model version is deprecated, a rubric criterion gets reworded, a prompt gets tightened, and six weeks later the same resume scores 3 instead of 5. Nobody notices until a client asks why a shortlist looks different, or an auditor asks what the tool was doing in March.

This walkthrough builds the missing piece: a regression harness that scores a fixed set of candidates on every change and tells you what moved. It is the same test discipline you would apply to a billing calculation, applied to a scorer whose output is words and numbers instead of dollars.

Step one: freeze a golden set

A golden set is a small, fixed collection of candidate records with an agreed score per rubric criterion. Small is the point: 40 to 80 records covers more than most firms think, and a set nobody maintains is worse than none.

Build it from real submissions, de-identified, with a person's judgement recorded as the expected value. Two rules:

  • Cover the edges, not the average. Include the obvious yes, the obvious no, and a heavy majority of the awkward middle: the career changer, the contractor with a six-month gap, the resume with the right title and the wrong industry, the one where the certification is listed but expired.
  • Freeze the input bytes. Store the exact text the scorer sees, not a pointer to a record in the ATS that a recruiter can edit. Your test set must not change under you.
create table eval_cases (
  case_id       bigserial primary key,
  label         text not null,          -- 'expired-cert', 'career-changer'
  input_text    text not null,          -- exact scorer input, de-identified
  rubric_id     bigint not null,
  added_at      timestamptz not null default now(),
  retired_at    timestamptz
);

create table eval_expectations (
  case_id       bigint not null references eval_cases(case_id),
  criterion     text not null,
  expected      int  not null,          -- 1..5 on your rubric scale
  set_by        text not null,          -- the human who decided
  set_at        timestamptz not null default now(),
  primary key (case_id, criterion)
);

set_by matters. An expected score is a person's opinion at a point in time, and when a run disagrees with it, you need to know whose opinion to go and argue with.

Step two: pin what you are testing

A run is only comparable to another run if you know what changed between them. Record the whole configuration as a row, not as a git commit message.

create table eval_runs (
  run_id        bigserial primary key,
  started_at    timestamptz not null default now(),
  model_id      text not null,          -- exact version string, not an alias
  prompt_sha    text not null,
  rubric_sha    text not null,
  temperature   numeric not null,
  notes         text
);

create table eval_scores (
  run_id        bigint not null references eval_runs(run_id),
  case_id       bigint not null,
  criterion     text not null,
  score         int,
  rationale     text,
  latency_ms    int,
  primary key (run_id, case_id, criterion)
);

Use the exact model version string the provider gives you. Aliases that float to "latest" mean your scorer changes on the vendor's schedule instead of yours, which is a poor property in a tool that has to produce an audit record. Pin the version, keep a calendar note for the deprecation date, and treat an upgrade as a change that gets tested like any other.

Step three: run the set

The runner is dull on purpose: iterate cases, call the scorer, write rows. No retries that silently swallow a refusal, no cleverness in the middle.

def run_eval(conn, model_id, prompt_sha, rubric_sha, temperature=0.0):
    run_id = insert_run(conn, model_id, prompt_sha, rubric_sha, temperature)
    for case in load_active_cases(conn):
        for criterion, score, rationale, ms in score_candidate(case.input_text):
            insert_score(conn, run_id, case.case_id, criterion,
                         score, rationale, ms)
    return run_id

Run at temperature zero, or as close as the provider allows. You are not measuring creativity, you are measuring whether the same input still produces the same judgement. Then run the identical configuration twice and compare it with itself. If a scorer disagrees with its own previous run on a tenth of the criteria, no amount of prompt work will make the next comparison meaningful; fix the determinism first, usually by constraining the output format and removing sampling.

Step four: the diff that gets read

Two numbers decide whether a change ships.

Exact agreement, per criterion, against the expected scores:

select
  s.criterion,
  count(*)                                            as cases,
  sum((s.score = e.expected)::int)                    as exact,
  round(100.0 * sum((s.score = e.expected)::int) / count(*), 1) as pct_exact,
  round(avg(abs(s.score - e.expected)), 2)            as mean_abs_error
from eval_scores s
join eval_expectations e using (case_id, criterion)
where s.run_id = :run_id
group by s.criterion
order by pct_exact;

Sorting by the worst criterion first is deliberate. It is nearly always the vaguest one on the rubric: "communication", "culture fit", "seniority". A criterion that scores badly against human expectation on every model is not a model problem, it is a rubric that needs rewriting into something observable.

Movement against the last accepted run, which is the regression test proper:

select
  c.label,
  n.criterion,
  o.score as before,
  n.score as after
from eval_scores n
join eval_scores o
  on o.case_id = n.case_id
 and o.criterion = n.criterion
 and o.run_id = :baseline_run
join eval_cases c on c.case_id = n.case_id
where n.run_id = :candidate_run
  and n.score is distinct from o.score
order by abs(n.score - o.score) desc;

Set a gate you are willing to enforce: no criterion drops more than a stated number of points on agreement, no more than a stated share of cases move at all, and no case crosses the advance/reject threshold without a person reading it. Movement is not automatically bad. Unexplained movement is.

Step five: check the rationale, not just the number

A rubric screener that logs a rationale per criterion has a second failure mode: the score stays put and the reasoning quietly rots. That matters for the record you keep as well as for the recruiter reading it, so test the text too.

Cheap checks that catch most of it:

  • Grounding. Does the rationale cite something that appears in the input? A rationale mentioning a certification the resume never lists is a fabrication, and it is worse than a wrong score because it looks like evidence.
  • Forbidden terms. Scan for references to age, national origin, family status, pregnancy, disability, school graduation years, or anything else your rubric has no business considering. Fail the run, do not warn.
  • Shape. Non-empty, one to three sentences, references the criterion by name. A rationale that has degenerated into a restatement of the score is a rationale nobody will defend in an audit.
BANNED = ("age", "married", "pregnan", "disab", "accent", "nationality")

def rationale_flags(case, criterion, score, rationale):
    flags = []
    if not rationale or len(rationale.split()) < 8:
        flags.append("too-short")
    if any(t in rationale.lower() for t in BANNED):
        flags.append("protected-attribute")
    if criterion.lower() not in rationale.lower():
        flags.append("criterion-not-named")
    return flags

Keyword lists are blunt. They are also the check that would have caught most of the rationale problems we have actually seen, and they cost nothing to run on every case.

Step six: put the run in the audit trail

Firms using automated employment decision tools in New York City under Local Law 144 carry the notice and bias-audit duties themselves, and Illinois HB 3773 puts the employment-decision duties on the employer as well. Neither asks for a regression harness. Both are much easier to answer if you have one, because the question underneath an audit is always "what was this tool doing on that date, and how do you know".

So keep the runs. Store the run rows and score rows alongside your production screening log, and record in your change log which run_id justified each configuration change: model version, prompt hash, rubric hash, the agreement numbers, and the name of the person who accepted it. When an auditor asks whether the scorer changed mid-quarter, the answer is a table rather than a recollection.

The same rows are useful commercially. A firm that can show its screener was tested against a fixed set before each change is in a different conversation with a client's procurement team than one that cannot.

Where this sits in the build

Order of operations, if you are starting from a working scorer:

  1. Freeze 40 to 80 cases with expected scores and the name of who set them.
  2. Add the run and score tables. Record one baseline run and mark it accepted.
  3. Wire the harness into whatever runs your tests, on every prompt, rubric or model change.
  4. Add the rationale checks. Make protected-attribute hits a hard failure.
  5. Re-score the golden set quarterly with humans, and retire cases that no longer reflect the roles you fill.

None of it makes the screener autonomous, and that is the point. The scores go to a recruiter who decides, exactly as in our Rubric Screener builds; the harness only tells you whether the thing feeding that recruiter still behaves the way it did the last time somebody checked.

If you are running a scorer against your own rubric and have no idea what it would do to last quarter's shortlist, that is the gap to close first. Contact us with your ATS and how screening decisions are recorded today.