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:
- Freeze 40 to 80 cases with expected scores and the name of who set them.
- Add the run and score tables. Record one baseline run and mark it accepted.
- Wire the harness into whatever runs your tests, on every prompt, rubric or model change.
- Add the rationale checks. Make protected-attribute hits a hard failure.
- 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.