All posts
Insights

The skill you were shown is not the skill you filter on

Skills go out display-formatted and come in as canonical tokens. Why ?skill=Machine Learning returns zero rows, and the heuristic that isn't an inverse.

HyperJobs Team8 min read

A response hands you a skills array. Something like this — illustrative, but the shape is exact:

JSON
{ "skills": ["Machine Learning", "pytorch", "CI/CD"] }

You render all three as chips. A user clicks one. You send it straight back:

BASH
curl -G https://api.hyperjobs.io/v1/jobs \
  -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "skill=Machine Learning"

200 OK. total: 0. Click pytorch instead and you get rows. Same array, same request shape, two different outcomes — and nothing in either response tells you which one you're looking at.

The value the API showed you is not the value the API accepts. That is the whole post.

Skills are tokens, not text#

Skills are stored as canonical lower-case tokens with no spaces and no punctuation. Not free text, not the words that appeared in the posting — a normalised token drawn from a fixed vocabulary. Machine Learning in a job description becomes the token machinelearning, and that token is the only string in the database.

So ?skill= is a token match. Not a substring match. That is the opposite of every other text filter on the same endpoint:

ParameterMatchingWhat that means
title=Substringjava finds JavaScript Engineer
description=SubstringSame — it matches anywhere in the body
skill=Tokenjava does not match javascript

And the sharper edge of the same rule: skill=py matches nothing at all. Not "few results" — zero. There is no prefix search on skills, no stemming, no partial anything. You send a token or you send nothing.

Two filters on one endpoint with opposite matching modes is a genuine inconsistency, and it is worth saying so rather than explaining it away. It is also defensible in one direction: skills is a closed-ish vocabulary, and substring-matching a vocabulary would make java silently return every javascript posting — a superset you never asked for and can't detect. Exact tokens make skill=java mean Java. The cost is that the filter is unforgiving in exactly the way title= is forgiving, and the API never returns a 400, so being wrong looks identical to being right about an empty niche.

Two alias tables, pointing opposite ways#

Here is why the token model isn't enough to explain the zero. There are two translation layers, and they are not inverses of each other.

Query aliasesDisplay aliases
DirectionInput — the value you sendOutput — the skills array on the way out
WhenRewrites before the query runsFormats a token for a human to read
Size8 entriesNot enumerated
Reverse mappingn/aNone

Display aliases are one-way. There is no reverse table, so a value the API rendered is not necessarily a value the API will resolve. Feed it back and you get an empty set that looks exactly like "no jobs match".

What makes this ship to production rather than getting caught in an afternoon is that most tokens have no display alias at all. The Job object example comes back as ["typescript", "graphql", "postgresql"] — raw tokens, which round-trip perfectly. Your chip component works. It works for weeks. Then someone clicks the one chip that got formatted.

The round trip, documented#

Straight from the skills guide:

You seeActually stored as?skill= with what you saw?skill= with the token
Machine Learningmachinelearning0 rows22,269
CI/CDcicd0 rows4,178
Quality Assurancequalityassurance0 rows
C++cpp✓ — input-aliased

Three of those four rows are the failure. The fourth is the exception, and the exception is the confusing part.

machinelearning
22,269
cpp
9,586
csharp
6,309
dotnet
4,912
cicd
4,178
Rows behind five canonical tokens. Every bar is a string the filter accepts. The strings the API showed you — `Machine Learning`, `CI/CD` — return zero for the same postings.

Why c++ works and Machine Learning doesn't#

?skill=c++ returns 9,586 rows. ?skill=c# returns 6,309. ?skill=.net returns 4,912. ?skill=node.js resolves too. All punctuated, all fine — which looks like evidence that the API is tolerant about punctuation.

It isn't. The punctuated forms are not stored. c++ as a stored token is 0 rows. c# is 0. Those queries work because the input alias rewrites them to cpp, csharp, dotnet and nodejs before the query ever runs. The token model is intact; a table of eight entries is papering over four of its corners.

That is the trap, stated precisely: only those eight query-alias entries survive the round trip. Everything else that gets display-formatted does not. C++ works and CI/CD doesn't, and there is nothing about the two strings that predicts which is which. You are pattern-matching on a lookup table you cannot see.

The heuristic, and what it isn't#

If a displayed skill contains a space or a slash, strip them and lower-case it before filtering:

JS
// Turns a displayed skill back into a queryable token.
const toToken = (displayed) => displayed.replace(/[\s/]/g, "").toLowerCase();
 
toToken("Machine Learning"); // → "machinelearning"
toToken("CI/CD");            // → "cicd"

Be clear about what that is. It is a heuristic, not the inverse function — no such function exists. Display aliasing has no reverse mapping, so there is no correct way to recover a token from a rendered string; there is only a rule that covers the observed failures. Spaces and slashes are the whole observed failure mode, so the rule earns its place. It is still a guess with a good track record, not a guarantee.

The robust version of your chip component doesn't call toToken at click time at all. It keeps the token it filtered with, renders the string the API sent, and never asks the display string to do a second job. Round-tripping through the UI is the bug; carrying the token alongside it is the fix.

Picking a classifier, and not picking three#

Skills are one of three ways to ask "what kind of job is this", and they trade off against each other:

UseVocabularyCoverageCost
taxonomies28 fixed Title-Case values64%Coarse; a job family, not a capability
skillOpen, canonical tokens84%Specific, but tokens only
titleFree text100%Substring, no stemming, noisy

skill is the highest-signal option with the best coverage of the three, so start there for anything technology-shaped. Use taxonomies to scope a broad query to a sector, not to find things — a third of postings carry no taxonomy and are invisible to it.

Don't reach for all three defensively. Coverage doesn't stack, it intersects. Every classifier you add is another AND, and each one silently drops the postings it never classified. 84%, 64% and 100% do not combine into reassurance; they combine into a much smaller set than you expect, with nothing in the response to say so.

The same applies inside skill itself — it's the one filter where comma still means AND:

BASH
# Tagged BOTH python AND aws. Not "either".
curl "…/v1/jobs?skill=python,aws"

Every other multi-value filter treats commas as OR, but skills compose requirements, so skill intersects. For a union across skills, run one request per value and merge on id — N values costs N requests against your rate limit.

The diagnostic#

When a skill filter returns a number you don't believe, ask in this order. Did the displayed string contain a space or a slash — that's toToken. Is it punctuated and not one of the eight — then it was never going to work. Did you comma-join values expecting OR — on skill, uniquely, you asked for AND. And if total didn't move at all when you added the filter, check what the filter actually testsskill is an open vocabulary, so a token nothing carries is a silent zero, not a 400.

The token table, the alias tables and the full round-trip rules live in skills & taxonomies.