Machine learning5 min read

A missing await, no error, and 40% faster screening

Building a candidate-matching engine on ML.NET — why it stayed inside the .NET process, why ranking beat classification, and the bug that produced no exception at all.

Nikolozi KuridzeLead Software Engineer · Solution & AI Architect

I started working with AI the moment it became usable, years ago, and for a long time almost nothing worked. That is the honest version. Each release fixed some of what was broken, and each release gave me the chance to fix my side too — to adjust the way I was using it and refine it a little further. It improved gradually, in both directions.

The candidate-matching engine came out of that period. The brief was straightforward: someone was spending the better part of two working days a month deciding which CVs deserved a second look. Make that faster.

Why it stayed inside .NET

The obvious move is a Python service. I did not do that, and the reason had nothing to do with the language.

The platform was ASP.NET Core. A Python service meant a second runtime to deploy, a second dependency tree to patch, a second thing to monitor, and a network hop on the critical path of a user-facing screen. It also meant candidate data — names, employment history, contact details — crossing a process boundary that did not previously exist, which is a conversation with compliance rather than a technical decision.

ML.NET put the model in the same process as the code calling it. One deployment artifact, one set of logs, one authentication story, one release pipeline.

That is not a claim that ML.NET beats scikit-learn as a library. It is a claim that the cost of a second runtime usually lands on operations rather than on the person choosing the library, and it is routinely larger than the ecosystem gap.

The ranking problem hiding inside the classification problem

The first version treated this as binary classification: good fit or not. It scored well on paper and was not very useful.

A recruiter does not want a set of acceptable candidates. They want an ordered list — the first ten CVs to open this morning. A classifier marking sixty applicants as "good fit" has not reduced anyone's workload. It has moved the pile.

Reframing it as ranking changed the target and the evaluation together. The question stopped being "is this a match" and became "of these two, who should be looked at first". The metric stopped being accuracy and became how many genuinely strong candidates landed in the top ten. Accuracy can look excellent while the top of the list is worthless, and the top of the list is the only part anyone reads.

The features were unglamorous and almost entirely structural: skill overlap weighted by how rare each skill was in the pool, signed seniority distance — under-qualified and over-qualified are different failure modes and must not cancel out — domain adjacency, recency, and trajectory. Feature engineering was most of the work. The model was a small fraction of it.

The bug that produced no error

The one I still think about was not in the model at all.

In a hurry, I missed an await.

There was no exception. Nothing in the logs. No failed request, no stack trace, no red anything. The call site simply moved on, the work behind it never completed, and everything downstream carried on as though it had. On a codebase that size, we could not find where the problem was hiding — because from the outside there was no problem. There was just a result that was wrong, quietly, with nothing to grep for.

This is the specific cruelty of a forgotten await in C#. A synchronous bug announces itself. A fire-and-forget async call is indistinguishable from success right up until you notice the data is wrong, and by then you are looking for a failure that never happened.

Two things came out of it. Practically: treat compiler warnings about unawaited calls as errors, and stop pretending a warning list is something anyone reads. More generally — when a system is producing wrong output with no error at all, stop looking for what broke and start looking for what never ran. Those are different searches, and I spent a long time doing the first one.

Explainability was a requirement, not a nice-to-have

Every score carried the features that drove it. Not because a paper recommended it, but because the first question any recruiter asks is why is this person above that one — and "the model said so" ends adoption immediately.

It matters legally too. Automated screening of people invites scrutiny, and "we cannot explain it" is not an answer you want to give. That ruled out approaches that would have scored slightly better and could not be read. In regulated work, an explainable model that is two percent worse is not a compromise. It is the requirement.

Ranking stayed advisory. The system reordered the queue; it never rejected anyone. Every actual decision stayed with a person. That boundary is the difference between a tool people trust and a tool people quietly work around.

The results

  • Screening time down 40% — not because any single CV was read faster, but because the CVs worth reading arrived first.
  • 18+ hours of manual work removed per month, once automated workflow processing joined the same pipeline.
  • Zero automatic rejections. Every decision stayed human.

What I would tell someone starting this

Solve the ordering problem, not the classification problem. Most "does this match" questions in business software are really "what should I look at first", and the second framing is both more useful and easier to evaluate honestly.

Count the runtime, not just the library. The best model in a language your platform does not speak is often the more expensive choice once deployment, monitoring, patching and data boundaries are priced in.

If you cannot explain a score, you cannot ship it into anything that makes decisions about people. Build the explanation first and let it constrain the model.

And when there is no error, look for what never ran.

ML.NET.NETMachine learningDebugging

Keep reading