root access

$ cat job-board-api/part-06.md

Building a Job Board API, Part 6: The Skill Match Score

Where we are

In the last part, I added four permission classes to my Job Board API, so every user can only do what their role allows. That was the first half of Phase 2’s goal: wrong user types get 403.

This part is the second half: match scores return correctly.

The match score is a number from 0 to 100. It tells a company how many of a job’s required skills a candidate actually has. In this part I built that calculation as a small pure function, and then showed its result inside the application data the API already returns.

It is a small feature. But it is also the first piece of real business logic in the project. Everything before this was plumbing: models, login, permissions. This is the first code that answers a question a user actually cares about: how well does this person fit this job?

It also uses the PostgreSQL array fields I designed back in Phase 1, Part 2. That decision is finally paying rent.

The rule

The score answers one question: how much of what the job needs does this candidate have?

Match score worked example diagram

Three details in this example matter:

  • ” Python ” matches “python”. Case and extra spaces are ignored. In Phase 1, I wrote down this exact requirement: matching must not care about case or extra spaces. I am happy I wrote it down, because the code below is where it had to be true.
  • aws changes nothing. Only the required skills count. A candidate with extra skills is not punished, and a candidate who lists fifty unrelated skills does not get a fake boost.
  • The result is a whole number. 2 out of 3 is 66.67%, which becomes 67.

And one special case: if a job requires no skills at all, the score is null (empty), not 100. I explain why in the decisions section.

Step 1: A pure function in matching.py

The whole rule lives in a new file, jobs/matching.py. It is only two small functions, and it imports nothing from Django.

Cleaning a list of skills

def normalize_skills(skills):
    """Lowercase, trim and de-duplicate a list of skill names."""
    if not skills:
        return set()
    return {skill.strip().lower() for skill in skills if skill and skill.strip()}

This is a set comprehension. It looks like a list comprehension, but with curly braces {} instead of square brackets, so Python builds a set. Reading the inside, for each skill in the list:

  • skill.strip().lower() removes spaces at the start and end, and makes everything lowercase. ” Python ” becomes “python”.
  • if skill and skill.strip() throws away empty values. None and "" fail the first part. A string of only spaces, ” “, passes the first part, but its strip is empty, so it fails the second.
  • Why throw them away? Without this filter, an empty string would count as a skill. A job with [“python”, “”, ” “] would look like it requires 3 skills when it really requires 1.
  • Why a set? Because duplicates disappear. If a company accidentally lists python twice, it still counts once.

Calculating the score

def match_score(required_skills, candidate_skills):
    """Percentage (0-100) of required skills the candidate has, or None."""
    required = normalize_skills(required_skills)
    if not required:
        return None
    matched = required & normalize_skills(candidate_skills)
    return round(100 * len(matched) / len(required))
  • if not required: uses the fact that an empty set is false in Python. If the job needs nothing, we return None before we can divide by zero.
  • required & normalize_skills(candidate_skills) is set intersection: the skills that appear in both sets. This one symbol, &, is the whole comparison.
  • len(matched) / len(required) gives a fraction. In Python 3, / always returns a decimal number: 2 / 3 is 0.666…, not 0. (Older Python, and many other languages, would give 0 for two whole numbers. A classic bug source.)
  • round(...) turns 66.67 into the whole number 67. A small Python detail I learned here: round() uses “round half to even”, so round(2.5) is 2, not 3. It does not hurt here, but it surprised me.

Why a pure function?

A pure function depends only on its inputs and changes nothing outside itself: no database, no request, no global state. Same inputs, same answer, every time. That buys two things:

  1. It is very easy to test. I can call it with two plain lists and check the answer. No database, no users, no login. You will see this in Step 3.
  2. It is reusable. It takes two lists, not a Job or an Application. So later I could use the same function to rank jobs for a candidate (“which jobs fit me best?”) without changing a line of it.

Step 2: Showing the score in the API

The function exists. Now the API needs to return it. I changed ApplicationSerializer in three small places.

The import

from . import matching

I import the module, not the function. So the call reads matching.match_score(...). Why? Because the name match_score will also be a serializer field (next step), and both would live in the same file. Keeping the module name in front means the two can never collide. This is a naming problem I now think about before it bites, not after. Part 4 and Part 5 both bit me with names.

The field

match_score = serializers.SerializerMethodField()

A SerializerMethodField is a field whose value comes from a method called get_<field name>. It is also read-only by design: a candidate cannot POST a match_score of 100 for themselves, because the value is always calculated by the server. (That is a nice free safety property. A writable score field would be a bug and a cheat code in one.)

I also added “match_score” to the fields list in Meta, right after “status”, so the two values a company cares about sit next to each other in the JSON.

The method

def get_match_score(self, obj):
    return matching.match_score(obj.job.required_skills, obj.candidate.skills)
  • DRF finds this method by its name: get_ plus the field name. And here is a nice contrast with my last two parts: this name lookup fails loudly. If I had typed get_matchscore, DRF would raise an error the first time the serializer runs. Not silently wrong like validateJob in Part 4, and not silently ignored like a permission class inside Meta in Part 5. Some framework names shout, some whisper. This one shouts.
  • I placed the method at the class level, next to validate_job. Not inside Meta. (I learned that one the hard way too.)
  • No extra database queries. My views already load each application’s job and candidate in one SQL JOIN (I used select_related for that in Phase 1). So obj.job and obj.candidate are already in memory when this runs. The score is pure Python set work on two small lists.

No new permission code

I didn’t write any new permission rules for the score, and I didn’t need to. The score is part of the application object, and the application already has its permissions from Part 5: only the candidate who owns it and the company that owns the job can read it. A new field inside an object inherits that object’s protection. But “inherits” is a claim, not a test. So I tested it anyway (the 403 case in Step 3). New surface, same checks. That is the habit Part 5 taught me.

Step 3: Testing it

Seven cases, no database

Because match_score is a pure function, I could test it with plain lists in the Django shell. No users, no login, no seeding. This alone justified the pure-function design, and it took about a minute:

from jobs.matching import match_score

cases = [
    (["python", "docker", "kubernetes"], ["Python", "docker", "aws"], 67),
    (["python", "docker", "kubernetes"], ["python", "docker", "kubernetes", "aws"], 100),
    (["python", "docker"], ["go", "rust"], 0),
    ([], ["python"], None),
    (["python"], [" python "], 100),
    (["python", "docker"], ["python", "python"], 50),
    (["python", "", " "], ["python"], 100),
]

for required, candidate, expected in cases:
    result = match_score(required, candidate)
    print(required, candidate, "->", result, "expected", expected, "OK" if result == expected else "FAIL")

This style is called a table-driven test. Each line is one case: the input and the expected answer. You read it like a table, and adding a new case is adding one line.

What each case proves:

  1. 67: a partial match, and Python matches python. It also checks the rounding of 66.67.
  2. 100: the extra skill aws doesn’t change the score.
  3. 0: no overlap gives 0, not an error and not None.
  4. None: a job with no required skills gives “no score”, and no division by zero.
  5. 100: spaces around a skill are ignored.
  6. 50: a duplicated python counts once. It is 1 of 2, not 2 of 3.
  7. 100: empty entries are ignored. It is 1 of 1, not 1 of 3.

All seven printed OK.

Through the API

Then I checked it end to end. Job 1 (Acme’s) requires python, docker and kubernetes. Job 3 (Initech’s) requires two different skills. Reading real applications, with tokens, the scores came back inside the application JSON:

  • sam reads his application to job 1: 67. He has 2 of the 3 required skills.
  • Acme lists job 1’s applicants: sam 67, dana 67. dana has python and docker, also 2 of 3. The applications list now shows a score per person, side by side.
  • Initech lists job 3’s applicants: sam 50, riya 50. Each has 1 of the 2 required skills.
  • dana tries to read sam’s application: 403.

That last one is a regression check. A regression is when something that used to work breaks after a change. Part 5 made applications private; adding a field should not have opened them up again, and it didn’t.

With these results, the second half of Phase 2’s goal is met: match scores return correctly.

The decisions, and what they cost

No bugs this time. So instead, I want to be honest about the trade-offs I chose, because every one of them has a price I will pay later.

1. The score is calculated when you read it, not stored.

The score is never saved in the database. It is calculated again every time someone reads an application.

  • The good part: no database migration, and the score is always up to date. If a candidate adds a skill to their profile today, every score changes instantly and correctly.
  • The cost: the database doesn’t know the score, so I can’t sort or filter by it in SQL. A company can’t ask for “only applicants above 80” without the API first calculating every score in Python. For a small project that is fine. For a big one it is the first thing I would change.
  • The other option was a stored match_score column, set when the candidate applies. It is sortable, but it goes stale when skills change, unless I also add update logic everywhere skills can change. I chose fresh over sortable, on purpose.

2. No required skills means null, not 100.

If a job requires nothing, there is nothing to measure. A score of 100 would look like a perfect match, but it would really mean “we know nothing about this job”. That is misleading to a company making a decision. null is the honest answer: no score exists. The same thinking applies whenever a number is missing: show the gap, don’t invent a value.

3. Only the required skills count.

I considered a different formula called Jaccard similarity: the overlap divided by all skills from both sides combined. It punishes extra skills. For a job board that felt wrong: a candidate who knows python, docker and five other things is not a worse fit for a python + docker job than one who knows exactly two things. My formula asks “of what this job needs, how much do you have?”, which is what a company actually wants to know.

4. No synonyms.

Right now, k8s and kubernetes are different skills. Fixing that needs a list of skill names and their aliases, which is a whole data problem of its own. I am leaving it out on purpose: exact, case-insensitive matching is predictable and explainable. A wrong synonym table would quietly change scores and nobody would know why.

5. A whole number, not decimals.

A score of 66.67 suggests a precision that doesn’t really exist. 67 is easier to read, and the rounding rules are simple to explain.

6. Only the score, no lists.

The API could also return which skills matched and which are missing. That would be useful in a user interface (“you are missing: kubernetes”). But it is also more API surface to design, document and keep consistent. The function already computes matched internally, so adding it later is easy. Deferred, not rejected.

What I learned

  • Keep business rules out of the framework. matching.py imports nothing from Django. That made it the easiest code in the whole project to test, and it will survive any future refactor of views or serializers.
  • Pure functions are a gift to your future self. Two lists in, one number out. No setup, no mocks, no database to clean.
  • Sets are the right tool for “what do these have in common?”. Duplicates disappear, and & gives the overlap in one symbol. Using lists and loops for this would have been longer and buggier.
  • None can be the most honest answer. A fake 100 is worse than “no score”.
  • Calculated vs stored is a real trade-off. Always fresh, but not sortable. Know which one you chose and why.
  • New fields inherit their object’s permissions, but test it anyway.
  • Not every framework name fails silently. get_match_score fails loudly if misspelled. Knowing which names shout and which whisper is becoming a theme of this project.

What comes next

Both halves of Phase 2’s goal are now met: wrong user types get 403, and match scores return correctly.

This is Part 6 of my Job Board API series, in Root Access. The story so far: Part 1 built the foundation, Part 2 designed the data model, Part 3 added JWT signup and login, Part 4 fixed validators that never ran, and Part 5 added the permissions.

Next: Part 7, filtering and search. Companies will be able to search jobs and filter applications, and the limitations of a score you cannot sort by will stop being theoretical.

Reza

$ ls -t root-access/ | head -3

Building a Job Board API, Part 5: Who Is Allowed to Do What job-board-api · 5/6 14 min Building a Job Board API, Part 4: My Validators Never Ran job-board-api · 4/6 16 min Building a Job Board API, Part 3: JWT Login and Signup job-board-api · 3/6 15 min

Leave a comment

Your email address will not be published. Required fields are marked *