AI systems · Evaluation

Asking a model to score a match, and storing the reasons

A number a model produced, with nothing attached, is a number nobody can argue with or learn from. The interesting part isn't the score. It's what the score points at.

The problem

Someone applies for a role. A reviewer wants to know, quickly, whether this application is worth opening — and a keyword overlap between a job description and a profile is a famously bad proxy for that.

The obvious move is to ask a language model. The obvious implementation — prompt for a score, store the number — is worse than useless, because a bare number carries no way to check it.

Score, evidence, explanation

What we store for each application is three things, not one:

  • A score from 0 to 100.
  • References to the specific records that drove it — which endorsements and which recommendations the model actually matched against the requirements.
  • A short explanation, length-capped, saying why.

The references are what make the score defensible. A reviewer who disagrees with an 82 can see exactly which three pieces of evidence produced it and judge for themselves. A reviewer looking at a bare 82 can only trust it or ignore it, and after one bad experience they will ignore it forever.

An unexplained score gets used until it's wrong once, and abandoned after. An explained score survives being wrong, because the reader can see where the reasoning went astray.

Making the references real

Asking a model to name supporting evidence in free text gets you plausible-sounding descriptions of things that may not exist. The fix is the same one that makes structured extraction reliable: give it a constrained set to choose from, and validate what comes back.

// The model picks from a supplied list; ids are then checked against it.
const { score, matchedIds, explanation } = await evaluate({
  requirements: opportunity.requirements,
  candidateEvidence: evidence.map(e => ({ id: e.id, text: e.summary })),
});

const allowed = new Set(evidence.map(e => e.id));
const matched = matchedIds.filter(id => allowed.has(id));  // drop invented

if (score < 0 || score > 100) throw new ValidationError('bad score');

Filtering rather than trusting means a hallucinated reference degrades the result quietly instead of producing a dangling link a reviewer clicks and gets a 404 from.

Where scoring goes wrong

The scale isn't calibrated. Nothing makes 70 mean the same thing on Tuesday as it did on Monday, or across two different roles. Models cluster their outputs — you'll often find most scores landing between 60 and 85 with almost nothing at the extremes. The number is useful for ranking within one opportunity and misleading as an absolute grade.

The prompt encodes a judgement. Whatever the template tells the model to weigh — recent experience, specific skills, seniority signals — is a hiring policy. It deserves to be written down and reviewed as one, not buried in a string that whoever wrote it chose in an afternoon.

Scores get treated as decisions. The moment a reviewer sorts by score and only reads the top ten, the model is doing the shortlisting. That may be intended, but it should be a decision someone made rather than an emergent property of the UI putting the number in a sortable column.

The operational part

Scoring on the application request means every applicant waits on a model call, and an outage in the provider becomes an outage in applying for jobs. It belongs on a queue, with the application saved first and the score attached when it arrives — which brings the same reliability requirements as every other worker: bounded retries and a terminal state.

And the score needs a nullable state that the UI handles. "Not scored yet" and "scored zero" are very different things, and conflating them is how a queue backlog turns into every candidate looking unqualified.

← All engineering notes