
# ATS — candidate matching

> Load this file when: tuning how candidates are ranked, or working out why one scored what they did — the dimension weights, the must-have gate, `weight_*` and the other 39 `ats-matching` settings, `match_score`, `match_details_json`, the per-posting `match_weight_override_json`, or the dry-run calibrator. What the app is: [ats-reference.md](ats-reference.md). Calling the match API: [ats-integration.md](ats-integration.md).

> 🔒 **Works by Design system — not a HubSpot platform feature.** The ATS matching engine is ours. Portable regardless, and the reason this page exists: a ranking model whose every rule is a stored, inspectable setting rather than a constant in code, that returns the breakdown it used, and that drops a dimension out of the denominator instead of scoring it zero when the data is absent.

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.

1. **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.
2. **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.
3. **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.
4. **Combine by weight.** `overall = Σ(score × weight) ÷ Σ(weight)`, over the included dimensions only.
5. **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

<!-- match-weights:ats — GENERATED from routes/apps/ats/match-settings-registry.js (app_setting rows under app_name "ats-matching") by gen-match-settings.mjs. Do not edit by hand. -->

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** |

<!-- /match-weights:ats -->

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`.

1. If the candidate's minimum is at or below the posting's maximum — they are affordable → **100**.
2. Otherwise `excess = candidateMin − postingMax` and `tolerance = postingMax × salary_tolerance_pct ÷ 100`. If the excess is within tolerance → `round(100 × (1 − excess ÷ tolerance))`.
3. Otherwise **0**, and the candidate is removed from the ranking when `salary_hard_filter` is 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` → **100**
- `0 < 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 and `education_over_qualified_penalty` is on
- `gap = 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_allowed` on (**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.

<!-- match-settings:ats — GENERATED from routes/apps/ats/match-settings-registry.js (app_setting rows under app_name "ats-matching") by gen-match-settings.mjs. Do not edit by hand. -->

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.

<!-- /match-settings:ats -->

## 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:

```json
{ "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_method` to `exact`** and `Node` no longer matches `Node.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.
- **`semantic` is `synonym`.** 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](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.

```json
{
  "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: false`** is the dimension that left the denominator. Check this before concluding a weight did nothing.
- **`excluded: true`** distinguishes "the posting stated no requirement" from "the weight is 0" — both exclude, for different reasons.
- **`engine_version`** is the ruleset that produced the score, and it is also written to the application's `match_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_snapshot`** records 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`/`missing` for skills, `gap` for seniority and education, `candidate_min`/`job_max` for salary.

Calling the API, persisting a score, and the workflow action are in [ats-integration.md](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_json`** are 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.
