A match score answers one question: how well does this person fit this posting, and which parts of the fit are weak? It is a ranking aid for a human, not a decision. The engine is deliberately arithmetic rather than statistical — no model, no training data, no drift — so any score can be traced back to the rules that produced it, and a recruiter who disagrees with a ranking can change the rule instead of appealing to a black box.
This page is written for two readers at once. Every dimension is stated first in plain terms — what it means and what makes it go up or down — and then as the exact arithmetic, for anyone who needs to reproduce or debug a number. A head of recruitment can stop after the plain sentence in each; a developer can skip straight to the formulas.
How a score is produced#
Five steps, in this order. The order matters — the gate is applied after the weighted sum, not instead of it.
- Normalise both sides. The posting's properties and the candidate's Contact properties are read into a neutral shape. Comma-separated, semicolon-separated, newline-separated and JSON-array text all parse into lists, so a skills field entered by hand and one written by a workflow behave the same.
- Score each dimension independently, 0–100. 10 dimensions, each with its own rule. Nothing at this stage knows about weights or about any other dimension.
- Drop the dimensions that do not apply. Three things exclude a dimension, and they are not interchangeable — § Two kinds of absence below. Excluded dimensions leave the denominator entirely.
- Combine by weight.
overall = Σ(score × weight) ÷ Σ(weight), over the included dimensions only. - Apply the must-have gate. If the must-have skills sub-score — the 0–100 number from step 2, not a count of skills — is below the threshold, cap or flag the result depending on the configured behaviour.
The result is rounded once, at the end.
Two kinds of absence#
Data can be missing on either side, and the two are handled completely differently. This is the most common source of a score nobody can explain, so it is worth getting straight before anything else.
| Missing on | What happens |
|---|---|
| The posting — you did not state a requirement | Four dimensions exclude themselves. The other six fall to missing-data behaviour, exactly as if the candidate were blank |
| The candidate — they did not supply the data | Always missing-data behaviour. Never an automatic zero, unless you configure one |
Only four dimensions exclude themselves when the posting is silent, and they are the four that are lists: must-have skills, nice-to-have skills, languages and keywords. An empty list has nothing to score against, so scoring it would be meaningless.
The other six — seniority, location, salary, experience, education, industry — are comparisons between two values. A blank on either side means the comparison cannot be made, and both blanks take the same route: missing-data behaviour, which by default scores 50 and stays in the denominator.
| Dimension | Posting silent | Candidate silent |
|---|---|---|
| Must-have skills · Nice-to-have skills · Languages · Keywords | Excluded | Missing-data behaviour |
| Seniority · Location · Salary · Experience · Education · Industry | Missing-data behaviour | Missing-data behaviour |
So a posting that says nothing about education does not remove education from the score — by default it gives every candidate 50 on it. If you want a silent posting to genuinely drop a dimension, either set its weight to 0, or set the relevant missing-data behaviour to ignore. Full treatment: § Missing data.
Important
A blank field can score higher than an honest one. Take a posting requiring a master's degree. A candidate who leaves education blank scores 50 on that dimension. A candidate who states a bachelor's — one level short, and truthful — scores 40. Verified 2026-08-09.
This is what neutral means, taken to its conclusion: not knowing is treated as better than knowing something disappointing. It is the right default for a small, well-curated pool and the wrong one for a large, patchy talent pool where incomplete profiles are common. Switch the missing-data behaviours to conservative if that describes yours.
The ten dimensions and their weights#
10 dimensions, 9 enabled by default. The defaults sum to 100, so out of the box a weight reads directly as a percentage — but only while every dimension participates. The share column is that best case; the real denominator is computed per candidate (see Normalisation below).
| Dimension | Setting key | Default weight | Share of the score |
|---|---|---|---|
| Must-have skills weight | weight_must_have_skills | 30 | 30% |
| Nice-to-have skills weight | weight_nice_to_have_skills | 10 | 10% |
| Seniority weight | weight_seniority | 15 | 15% |
| Location weight | weight_location | 15 | 15% |
| Salary weight | weight_salary | 10 | 10% |
| Experience years weight | weight_experience_years | 5 | 5% |
| Education weight | weight_education | 5 | 5% |
| Languages weight | weight_languages | 5 | 5% |
| Industry weight | weight_industry | 5 | 5% |
| Keywords weight (0 = disabled) | weight_keywords | 0 | disabled |
Weights are ordinary numbers, not percentages, and nothing requires them to sum to anything. The defaults sum to 100 because that makes them readable, not because the engine needs it. Doubling every weight changes nothing at all; doubling one changes its influence relative to the rest.
A weight of 0 disables the dimension — it is removed from the calculation rather than scored zero. That is how keywords ships: present, documented, and off.
Normalisation — why a weight is not a percentage#
This is the part that surprises people, and it is the single most useful thing to understand before tuning anything.
The denominator is the sum of the weights that actually participated for this candidate, not the sum of all weights. A dimension that was excluded — because the posting stated no requirement, or because its weight is 0 — is not in the numerator or the denominator.
So the same weight is worth more when fewer dimensions participate:
| Participating | Weights sum to | Location weight 15 is worth |
|---|---|---|
| All 9 enabled dimensions | 100 | 15% of the score |
| Must-have skills and location only | 45 | 33% of the score |
Neither is wrong — the second score genuinely rests on less, so what it does rest on counts for more. But it means a thin comparison produces a confident-looking number from very little evidence, and a recruiter comparing scores across postings is not comparing like with like. If that matters, require the same fields on every posting rather than trying to fix it with weights.
Note
Leaving fields off a posting does not, by itself, produce the second row. Only the four list dimensions exclude themselves when the posting is silent; the rest score a neutral 50 and stay in the denominator (§ Two kinds of absence). Reaching a genuinely small denominator takes a deliberate act — a weight of 0, or a missing-data behaviour of ignore. A sparse posting on default settings does something different and worse: it scores everyone 50 on the fields you left out, which flattens the ranking rather than sharpening it.
The must-have gate#
The gate exists because a weighted average is too forgiving on its own. A candidate missing every required skill can still reach a respectable overall score on seniority, location and salary alone — and no amount of weight tuning fixes that, because the problem is the averaging, not the weighting.
So must-have skills get a second, non-averaged test. must_have_match_behavior chooses what happens when a candidate's must-have sub-score falls below must_have_gate_threshold (default 60).
The threshold is compared against the 0–100 sub-score, not against a count of skills — and that has a consequence worth understanding before you set it, because the sub-score can only take as many values as the posting has skills. With 3 must-haves the only achievable scores are 0, 33, 67 and 100; a threshold anywhere from 34 to 67 means exactly the same thing, "at least 2 of 3".
So the same threshold is far stricter on a short list than on a long one:
| Must-haves listed | Achievable sub-scores | A threshold of 60 demands |
|---|---|---|
| 1 | 0, 100 | 1 of 1 — every skill |
| 2 | 0, 50, 100 | 2 of 2 — every skill |
| 3 | 0, 33, 67, 100 | 2 of 3 |
| 4 | 0, 25, 50, 75, 100 | 3 of 4 |
| 5 | 0, 20, 40, 60, 80, 100 | 3 of 5 |
Verified 2026-08-09 and asserted by the engine's test suite. Two practical readings: a posting with two must-haves is running an all-or-nothing gate whether you intended one or not, and adding a fifth must-have to a posting quietly loosens the gate from "3 of 4" to "3 of 5". If you want "every listed skill, always", set the threshold to 100 and stop thinking about list length.
| Behaviour | What happens below the threshold | Use it when |
|---|---|---|
gate (default) | The overall score is capped at must_have_gate_score_ceiling (default 30). The candidate still appears, visibly sunk | You want weak-on-requirements candidates ranked last but still visible |
weighted | Nothing. Must-have skills are just another weighted dimension | The "requirements" are aspirational and you do not want them enforced |
hard_exclude | The candidate is removed from the ranking, and flagged below_threshold: true | You do not want to see them at all |
Removed candidates are still counted: a ranking reports totalScored alongside belowThreshold, so a short list reads as three were dropped rather than as only two people applied. You can always see that somebody was excluded, just not who.
If you want them ranked last but still visible, that is what gate with a ceiling is for. salary_hard_filter removes a candidate the same way, on the salary dimension.
The gate is applied after the weighted sum, so a capped score of 30 means "this candidate scored well on paper but does not have what the role requires" — which is exactly what you want a recruiter to see, rather than a fabricated low average.
If the posting lists no must-have skills, the dimension is excluded and the gate never applies (gate_applied: false). A posting with no requirements cannot fail anyone on requirements.
How each dimension scores#
Each returns 0–100. score is the sub-score; excluded means the dimension left the calculation.
Skills — must-have and nice-to-have#
In plain terms: the proportion of the listed skills the candidate has. Two skills out of three is 67.
Arithmetic: round(matched ÷ required × 100). If the posting lists no skills for that field, the dimension is excluded.
Both fields use the same rule and the same matching method; they differ only in weight and in the fact that the gate watches must-have alone.
Matching method (skill_matching_method) decides what counts as the same skill:
| Method | Behaviour |
|---|---|
exact | Case-insensitive, whitespace-trimmed string equality. Node does not match Node.js |
synonym (default) | Both sides are canonicalised through skill_synonym_map first, so Node, nodejs and node.js are one skill |
semantic | Currently identical to synonym. The option exists; there are no embeddings behind it. It is not a wrong answer, just not the one the name implies |
Warning
Selecting semantic changes nothing. The engine falls through to the same synonym-map lookup. Nothing infers that React implies JavaScript, and nothing reads a CV. If you want wider matching today, the answer is the synonym map below, not this dropdown. The settings page says the same thing under the dropdown itself.
The synonym map is { "canonical": ["alias", …] }, edited as JSON on the settings page. Editing it is the highest-leverage single change available: adding your industry's vocabulary once fixes every posting at the same time, and costs nothing at scoring time.
Matching is per-term and literal. It does not infer that React implies JavaScript, and it does not read skills out of a CV — see § Known limits.
Seniority#
In plain terms: how far the candidate's level is from the level the posting asks for. Exactly right scores 100; a level under scores 60; two under scores 20. Over-qualified is penalised gently by default, because a level too senior is usually a conversation, not a rejection.
Arithmetic: both sides map to a numeric rank (intern/entry 1 → c_level 9), then gap = candidate − job:
| Gap | Score |
|---|---|
| 0 | 100 |
| +1, +2, +3 or more (over-qualified) | From the seniority_over_qualified_penalty_mode table below |
| −1 | seniority_gap_1_score (60), softened by the mode below |
| −2 | seniority_gap_2_score (20), softened by the mode below |
| −3 or more | 0 |
Under-qualification table, by seniority_under_qualified_penalty_mode:
| Mode | −1 level | −2 levels | −3 or more |
|---|---|---|---|
strict (default) | The gap score as set — 60 | The gap score as set — 20 | 0 |
mild | Halves the shortfall — 80 | Halves the shortfall — 60 | 0 |
mild is computed from the two gap settings rather than replacing them, so changing a gap score still moves both modes. Three or more levels short is a mismatch either way.
Over-qualification table, by mode:
| Mode | +1 level | +2 levels | +3 or more |
|---|---|---|---|
none | 100 | 100 | 80 |
mild (default) | 80 | 50 | 30 |
strict | 60 | 20 | 0 |
If either side's value is not in the rank map, the dimension falls to the global missing-data behaviour.
Location#
In plain terms: whether the candidate can realistically work where and how the job requires. A remote job and a remote candidate is a perfect fit; an on-site job in a city the candidate does not live in is not, unless they will move.
Arithmetic, branching on the posting's remote_model:
| Posting | Candidate preference | Score |
|---|---|---|
remote | remote | 100 |
remote | hybrid or flexible | 80 |
remote | anything else | location_remote_to_onsite_score (10) |
hybrid | hybrid, remote or flexible | 100 |
hybrid | anything else, including blank | 70 |
onsite | same city as the posting | 100 |
onsite | same country | location_same_country_score (40) |
onsite | willing to relocate | location_relocation_score (50) |
onsite | no city and no preference stated | Global missing-data behaviour |
onsite | none of the above | 0 |
A posting with no remote_model is treated as onsite.
The three on-site rules are independent, and the candidate gets the highest one that applies. Someone in the same country who has also ticked "willing to relocate" scores 50, not 40 — each rule is a separate reason they can work on site, so being available in two ways is never worse than being available in one.
Salary#
In plain terms: can you afford this candidate. If their expectation overlaps the posted range at all, it is a full match. Above the range, the score decays across a tolerance band and then hits zero.
Arithmetic: requires the posting's maximum and the candidate's minimum; without either it falls to salary_missing_data_behavior.
- If the candidate's minimum is at or below the posting's maximum — they are affordable → 100.
- Otherwise
excess = candidateMin − postingMaxandtolerance = postingMax × salary_tolerance_pct ÷ 100. If the excess is within tolerance →round(100 × (1 − excess ÷ tolerance)). - Otherwise 0, and the candidate is removed from the ranking when
salary_hard_filteris on.
The dimension measures affordability, not similarity. A candidate asking less than the range — even far less — is a full match, not a suspicious one. If you want to treat under-asking as a signal, that is a screening judgement for a person, not something this dimension will do for you.
Experience years#
In plain terms: the candidate's total years against the posting's minimum. At or above the minimum is a full match; below it decays to zero across a tolerance band.
Arithmetic: gap = postingMin − candidateYears, and floor is experience_years_under_1yr_score.
gap ≤ 0→ 1000 < gap ≤ experience_years_tolerance→round(floor + (100 − floor) × (1 − gap ÷ tolerance))- otherwise → 0
The two settings mean what their names say: tolerance is how far short you will tolerate, and under-by-1yr score is what a candidate scores at that limit. On the defaults, one year short scores exactly 50 and half a year short scores 75.
Education#
In plain terms: does the candidate hold the level of qualification the posting requires. Meeting or exceeding it is a full match; one level short scores 40; two or more short scores zero.
Arithmetic: both sides map to a rank (high_school 1, associate 2, bachelor 3, master/mba 4, phd/doctorate 5), then gap = required − candidate.
gap ≤ 0→ 100, or 80 when the candidate is above the requirement andeducation_over_qualified_penaltyis ongap = 1→education_gap_1_score(40)gap ≥ 2→ 0
Without a mapped value on either side, education_missing_data_behavior applies.
Languages#
In plain terms: the proportion of the required languages the candidate has, or all-or-nothing if you turn partial credit off.
Arithmetic: ratio = matched ÷ required.
language_partial_match_allowedon (default) →round(ratio × 100)- off → 100 if every required language is present, otherwise 0
The posting listing no languages excludes the dimension. The candidate listing none falls to the global missing-data behaviour. Comparison is on the literal value, so English and en are different languages unless you standardise the data.
Industry#
In plain terms: the same industry scores 100, a related one scores 40, anything else scores 0. There is no sliding scale between them — adjacency is a yes or no, and 40 is the whole answer for every adjacent pair.
Arithmetic: equal (case-insensitively) → 100. Otherwise, if either side appears in the other's list in industry_adjacency_map → industry_adjacent_score (40 by default). Otherwise 0. Either side blank → global missing-data behaviour.
So a fintech role and a banking candidate score 40 on this dimension, not 100 and not 50. Raise industry_adjacent_score if your sector genuinely treats adjacent experience as near-equivalent — it is one number and it applies to every adjacent pair at once.
Adjacency is checked in both directions, so declaring fintech: ["banking"] also makes banking adjacent to fintech. You do not need to list a pair twice.
Keywords#
In plain terms: free-text overlap between the posting's keywords and the candidate's. Off by default.
Arithmetic: min(100, round(overlap ÷ postingKeywords × 100)). No keywords on the posting excludes the dimension, and the default weight of 0 excludes it anyway.
Turn it on when you have a vocabulary that is genuinely not a skill — a clearance, a certification body, a domain. Turning it on without curating both sides adds noise, not signal.
Missing data#
§ Two kinds of absence covers which dimensions take this route and when. This is what happens once they do — a policy decision rather than an arithmetic one, and the one setting most worth changing deliberately. Three behaviours:
| Behaviour | Effect | What it says |
|---|---|---|
neutral (default) | Scores 50 and participates | "We do not know" — neither rewarded nor punished |
conservative | Scores 0 and participates | "Unstated is a miss" — pushes incomplete profiles down |
ignore | Excluded from the calculation entirely | "Score only what we know" — the remaining dimensions carry the whole weight |
Salary, experience and education each have their own setting; every other dimension uses missing_data_global_default.
The choice interacts with the normalisation rule above: ignore shrinks the denominator, so a candidate with almost no data can score highly on the one or two dimensions they do have. conservative is the safer default for a large, patchy talent pool; neutral is the safer one when you are matching a handful of well-filled records.
Each behaviour has a failure mode, and picking one means choosing which you would rather have:
| Behaviour | Its failure mode |
|---|---|
neutral | Blank beats honest — an unstated field outscores a stated-but-short one, and a profile of nothing but blanks lands near 50 rather than at the bottom |
conservative | A genuinely strong candidate whose record is thin ranks below a mediocre one whose record is complete. You are ranking data quality as much as fit |
ignore | The denominator shrinks to whatever the candidate happened to fill in, so scores stop being comparable between candidates — an empty profile can reach 100 on one dimension |
There is no safe universal answer. The useful question is which of those three sentences you would least mind explaining to a hiring manager.
Every setting#
All 39, as the engine reads them. Their values live as app_setting records on the portal under app_name = "ats-matching"; anything unset falls back to the default shown here.
The 29 rule settings, in 11 groups — the 10 weights above complete the 39. A blank range means the engine imposes no bound.
Must-Have Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Must-have behaviour | must_have_match_behavior | select | gate | gate · weighted · hard_exclude | Always |
| Gate threshold | must_have_gate_threshold | number | 60 | — | must_have_match_behavior is gate or hard_exclude |
| Gate score ceiling | must_have_gate_score_ceiling | number | 30 | — | must_have_match_behavior is gate |
Skill Matching
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Skill matching method | skill_matching_method | select | synonym | exact · synonym · semantic | Always |
| Skill synonym map | skill_synonym_map | json | 9 entries — javascript, typescript, react, … | JSON object | Always |
| Industry adjacency map | industry_adjacency_map | json | 5 entries — fintech, saas, ecommerce, … | JSON object | Always |
Seniority Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| One-level gap score | seniority_gap_1_score | number | 60 | — | Always |
| Two-level gap score | seniority_gap_2_score | number | 20 | — | Always |
| Over-qualification penalty | seniority_over_qualified_penalty_mode | select | mild | none · mild · strict | Always |
| Under-qualification penalty | seniority_under_qualified_penalty_mode | select | strict | mild · strict | Always |
Location Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Remote candidate on on-site role | location_remote_to_onsite_score | number | 10 | — | Always |
| Relocation willing score | location_relocation_score | number | 50 | — | Always |
| Same country score | location_same_country_score | number | 40 | — | Always |
| Enable radius matching | location_use_radius | boolean | false | true · false | Always |
| Radius (km) | location_radius_km | number | 50 | — | location_use_radius is true |
Salary Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Salary tolerance (%) | salary_tolerance_pct | number | 15 | — | Always |
| Hard salary filter | salary_hard_filter | boolean | false | true · false | Always |
| Missing salary data | salary_missing_data_behavior | select | neutral | neutral · conservative · ignore | Always |
Experience Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Experience tolerance (years) | experience_years_tolerance | number | 1 | — | Always |
| Under-by-1yr score | experience_years_under_1yr_score | number | 50 | — | Always |
| Missing experience data | experience_missing_data_behavior | select | neutral | neutral · conservative · ignore | Always |
Education Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| One-level gap score | education_gap_1_score | number | 40 | — | Always |
| Penalise over-qualification | education_over_qualified_penalty | boolean | false | true · false | Always |
| Missing education data | education_missing_data_behavior | select | neutral | neutral · conservative · ignore | Always |
Language Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Allow partial language match | language_partial_match_allowed | boolean | true | true · false | Always |
Industry Rules
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Adjacent industry score | industry_adjacent_score | number | 40 | — | Always |
Score Display
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Minimum score to display | minimum_display_score | number | 0 | — | Always |
| Minimum must-have score to display | minimum_must_have_score_to_display | number | 0 | — | Always |
Global Settings
| Setting | Key | Type | Default | Range / options | Shown when |
|---|---|---|---|---|---|
| Global missing data default | missing_data_global_default | select | neutral | neutral · conservative · ignore | Always |
Warning
2 of these 39 settings are not read by the engine. They save,
they persist, and they change nothing: location_use_radius, location_radius_km. Detected by scanning the
engine source for each key, so this warning removes itself when one is wired up.
Where weights can be changed#
Three places, in increasing order of precedence.
1. The settings page — portal-wide#
The app's Settings → Matching tab renders every setting above, grouped, with the conditional ones appearing only when they apply. Changes are staged and saved as a group: nothing takes effect until you press Save, and Discard restores what is stored.
This is the right place for a policy that is true of your hiring generally — your synonym vocabulary, your missing-data stance, your gate threshold.
2. Per posting — match_weight_override_json#
A property on ats_job_posting. Set it to a JSON object of weight keys and the posting scores with those weights instead of the portal's:
{ "weight_location": 40, "weight_salary": 0 }
Only the keys you name are overridden; everything else falls through to the portal settings. Only weight_* keys are read — the rules (thresholds, tolerances, maps) are portal-wide and cannot be overridden per posting.
This is for the posting that genuinely differs: an on-site role where location is the whole problem, or a role where budget is fixed and salary fit should not be scored at all.
3. The dry run — change nothing, see everything#
Under the settings, Dry-run calibration takes a job posting's record ID and ranks real candidates against it using the settings currently on screen, saved or not. It writes nothing: no score is persisted, no record is touched.
Use it as the loop: change a weight, preview, compare the order against what you would have done by hand, then save or discard. It is the difference between tuning and guessing, and it costs nothing to run repeatedly.
Tip
Calibrate against a posting you have already hired for, where you know who the right answer was. A ranking that puts your actual hire in the top three is calibrated; one that does not is telling you which dimension is mis-weighted.
A worked example#
A senior React role, and a candidate who fits it well. Portal defaults throughout. These numbers are asserted by the engine's own unit suite, so they cannot drift away from the code.
The posting: Senior React Engineer · Amsterdam, Netherlands · hybrid · €70,000–90,000 · must-have React, TypeScript, Node.js · nice-to-have GraphQL, Docker · English · bachelor's · 5 years minimum · technology.
The candidate: senior · Amsterdam, Netherlands · prefers hybrid · asking €75,000–85,000 · React, JS, TypeScript, Node, Docker, Python · English and Dutch · bachelor's · 6 years · technology.
| Dimension | Score | Why | Weight | Contribution |
|---|---|---|---|---|
| Must-have skills | 100 | 3 of 3 — Node canonicalised to node.js | 30 | 3,000 |
| Nice-to-have skills | 50 | Docker yes, GraphQL no | 10 | 500 |
| Seniority | 100 | Senior against senior, gap 0 | 15 | 1,500 |
| Location | 100 | Hybrid posting, hybrid candidate | 15 | 1,500 |
| Salary | 100 | €75–85k overlaps €70–90k | 10 | 1,000 |
| Experience years | 100 | 6 against a minimum of 5 | 5 | 500 |
| Education | 100 | Bachelor's against bachelor's | 5 | 500 |
| Languages | 100 | English required and held | 5 | 500 |
| Industry | 100 | Technology both sides | 5 | 500 |
| Keywords | — | Excluded: weight 0, and the posting lists none | 0 | — |
9,500 ÷ 100 = 95. Must-have is 100, comfortably above the gate's threshold of 60, so the gate is applied but not triggered. Overall: 95.
Two variations worth seeing, both also asserted by the suite:
- Switch
skill_matching_methodtoexactandNodeno longer matchesNode.js. Must-have drops to 67 — still above the threshold, so the gate stays open — and overall drops to 85. - Give the candidate only React and Python. Must-have is 33, below the threshold of 60, so the gate triggers and caps the overall at the ceiling: 30, down from what the weighted average alone would have produced.
Tuning recipes#
Concrete starting points. Each is a small change with a predictable effect — make one at a time and dry-run it.
| You want | Change |
|---|---|
| Requirements to be genuinely non-negotiable | Raise must_have_gate_threshold to 100 and leave the behaviour on gate. Anything short of every listed skill is capped, whatever the list length |
| The gate to mean the same thing on every posting | Either set the threshold to 100, or standardise how many must-haves a posting may list. At 60 the gate silently loosens as the list grows — § The must-have gate |
| A blank profile to stop outscoring an honest one | Set the missing-data behaviours to conservative. Under neutral, unstated beats stated-but-short |
| Weak-on-requirements candidates off the list entirely | Either must_have_match_behavior: hard_exclude, or minimum_must_have_score_to_display at your threshold. The first drops anyone under the gate threshold; the second is an independent floor and works whatever the behaviour |
| To stop rewarding profiles that are simply empty | Set missing_data_global_default to conservative, and the three per-dimension ones with it |
| Fewer false misses on skills | Extend skill_synonym_map with your own vocabulary before touching any weight. It is the cheapest accuracy you will get |
| To hire for potential rather than credentials | Drop weight_education and weight_experience_years to 0; raise weight_must_have_skills |
| Location to decide an on-site role | Override weight_location on that posting rather than changing the portal default |
| To see fewer, better candidates | Raise minimum_display_score. It filters the ranking without changing any score |
| To understand one specific ranking | Read match_details_json on the application — every sub-score, every matched and missing skill, and the settings used |
Two habits worth more than any single setting. Tune the data before the weights — most bad rankings are a posting with three fields filled, not a weighting error. And change one thing at a time, because the normalisation rule means a change to one dimension moves every other dimension's share of the result.
Known limits#
- Nothing reads CVs. Matching sees only structured properties. A portal that collects CV files and no property data has nothing to score, and every candidate will look identical.
semanticissynonym. The option is in the settings and behaves as synonym matching.- 2 settings are inert — both belong to radius matching, which needs geocoding that does not exist. They save and change nothing. The generated table above names them, detected by scanning the engine source, and the settings page marks each one "not yet wired up" under its own control — so you find out where you are making the change, not afterwards.
- The candidate pool is bounded. A posting-to-candidates run scores up to 2,000 Contacts, filtered to those marked open to work where the portal has that property. The response says whether that bound was hit; see ats-integration.md.
- No score is automatic until you build the workflow. Scores are produced on request — a card, a dry run, or the recalculate workflow action. To have every application arrive scored, add a workflow enrolling on application creation with the Recalculate match score action as its only step: one step, about a minute in the workflow editor. It is not provisioned for you, and that is a platform constraint rather than an omission — a workflow step using an app's own action has to be created in the editor, because HubSpot binds it to an app connection the API cannot set. Nothing recalculates when a Contact or a posting is later edited either; that is a second workflow, enrolling on the property changes you care about.
What the engine returns#
Every score carries its own explanation. The same object is what the API returns per candidate and what is persisted to match_details_json on the application.
{
"overall": 95,
"engine_version": "v3",
"must_have_score": 100,
"gate_applied": true,
"gate_triggered": false,
"below_threshold": false,
"dimensions": {
"must_have_skills": {
"weight": 30, "score": 100, "included": true,
"matched": ["React", "TypeScript", "Node.js"], "missing": []
},
"seniority": {
"weight": 15, "score": 100, "included": true,
"gap": 0, "candidate": "senior", "job": "senior"
},
"keywords": { "weight": 0, "included": false, "excluded": true }
},
"settings_snapshot": {
"must_have_match_behavior": "gate",
"skill_matching_method": "synonym"
}
}
Reading it:
included: falseis the dimension that left the denominator. Check this before concluding a weight did nothing.excluded: truedistinguishes "the posting stated no requirement" from "the weight is 0" — both exclude, for different reasons.engine_versionis the ruleset that produced the score, and it is also written to the application'smatch_score_version. It moves only when a change would make the same inputs score differently — so two scores carrying the same version are comparable, and two carrying different versions are not. Check it before comparing scores written at different times, and recalculate rather than compare across a change.settings_snapshotrecords the two settings that most change the meaning of a score, so a stored score from six months ago can still be read correctly.- Each dimension carries its own diagnostic fields —
matched/missingfor skills,gapfor seniority and education,candidate_min/job_maxfor salary.
Calling the API, persisting a score, and the workflow action are in ats-integration.md.
Change policy#
- The generated tables — the weights and the 39 settings — are regenerated from the engine's own registry and checked on every commit. They cannot drift from the code.
- The worked example and the dimension arithmetic are asserted by the engine's unit suite, which runs on every change to it. A rule that changes fails a test before it reaches this page.
- Defaults may change between versions. Anything you have explicitly saved is stored on your own portal and survives; anything left unset follows the default.
- The dimension set and the shape of
match_details_jsonare the stable parts. New dimensions would be additive, with a default weight of 0. - A rule change bumps
engine_version. That is the contract: if the same inputs would score differently, the version moves, so you can always tell whether two stored scores are comparable. A refactor or a new diagnostic field does not move it. - Everything else carries the
verified:date at the top of this page and no stronger guarantee.