# Fit rubric: All employers

How a job is scored against a candidate profile. Deterministic: the same profile and the same API response
always give the same tier. The agent computes it locally; nothing leaves the machine.

## Inputs

- `candidate-profile.json`, shaped by `candidate-profile.schema.json`.
- A job, or an array of jobs, from `https://www.openrolesindex.com/orgs/all/v1/active-ats` with `include_beta=true` (for `ai_requirements`).

## 1. Hard filters (any failure: `excluded`, and say which)

| Filter | Rule | When it cannot be checked |
|---|---|---|
| Location | a job location (`locations_derived`) contains one of the candidate's city, region or country; or, with `lat`/`lng` + `radius_km` in the profile, a job coordinate lies within the radius. Skipped when the job is `Remote OK` / `Remote Solely` or the candidate has `relocation: true` | no location on the posting: flag, do not exclude |
| Work arrangement | `ai_work_arrangement` is in the candidate's list | null: flag "not stated", do not exclude |
| Employment type | `ai_employment_type` overlaps the candidate's list | null: pass |
| Pay floor | top of range (`ai_salary_max_value`) below the floor: excluded. Bottom (`ai_salary_min_value`) below the floor but top at or above it: **not** excluded, flagged "your floor is inside the posted range; the bottom is below it". A single figure (`ai_salary_value`) is compared directly. Units convert at 2080 h, 260 days, 52 weeks, 12 months a year, flagged as your arithmetic | no pay: flag "pay not stated", never estimate |
| Exclusions | the title or category contains a word from `preferences.exclusions` | - |

## 2. Requirement matching

For each item in `ai_requirements`, the best claim of the **same `kind`**:

1. **exact**: same label after lower-casing, folding whitespace, hyphens and curly quotes, and dropping bracketed
   suffixes ("BLS [Required]" is "BLS");
2. **abbreviation**: an abbreviation the posting gives in parentheses ("Basic Life Support (BLS)") equals the
   claim's label ("BLS"), or the claim gives one the posting uses;
3. **synonym**: a label listed for this requirement in the profile's `synonyms` (normalise claims to
   `vocabulary.json` first, so most matches are exact);
4. **judgement**: one label contains the other ("BLS" in "BLS certification").

Exact and abbreviation are the data's (the posting itself states the equivalence). Report synonym and judgement matches as your judgement: "I am treating your
X as the posting's Y." When several claims match, take the one with the most years, then the highest level.

- **met**: a claim exists, `years >= min_years` when the posting states years, `level >= level` when it states a level.
- **partial**: a claim exists but a threshold fails. Counts as unmet.
- **gap**: no claim.
- `implied` items (visible only from the duties) are listed, never scored.

A posting's `level` is extracted from its wording and is indicative. When a level alone makes an item
partial, say that, and let the candidate judge.

## 3. Scores and tiers

required = met required / required total · preferred = met preferred / preferred total

| Tier | Rule |
|---|---|
| strong | no hard-filter failure, every required item met, preferred >= 0.5 (or no preferred items) |
| good | every required item met |
| reach | exactly one required item unmet |
| stretch | two or more required items unmet |
| unscored | the job has no `ai_requirements` yet. Read `description_text`; every conclusion is your reading |
| excluded | a hard filter failed |

Sort: tier; within a tier, distance (when the profile has coordinates), then shift match, then pay (top of
range, annualised). Shift is not an API field: read the posting's "Shift:" / hours wording from
`description_text` and label it "from the description, my reading". `fit.py` sorts by tier, distance and pay;
re-sort within a tier once you have read the shifts.

**One-cert-away**: a `reach` job whose single unmet required item has `kind: certification`. Group these by
label to say "this certification unlocks N jobs here".

## 4. Phrasing

Where a claim's verification comes from: a claim taken from a **document** the candidate provided (a résumé
file, a certificate, a licence lookup page) is `evidence_linked`, with the file and the line in `evidence`. A
claim from pasted prose or conversation is `self_reported`. Before drafting anything the candidate will send,
ask for the documents behind the matched `self_reported` claims and link them; what stays unlinked stays out.

The verification level changes the words, never the arithmetic.

| Claim verification | Say |
|---|---|
| `self_reported` | "you say you have X" |
| `evidence_linked` | "your <document> shows X" |
| `assessed` | "X, assessed on <date>" |
| `verified` | "X, verified by <registry> on <date>" |

Every match and every gap quotes the requirement's `evidence`:

> **Reach.** 3 of 4 required met. Gap, required: "<evidence>"; the profile has no matching claim.
> Preferred: 2 of 2 met.

Then name the hard-filter flags ("pay not stated", "work arrangement not stated") so the candidate knows what
was not checked.

## 5. The script

`fit.py` in this pack implements sections 1-3 (`curl -sO https://www.openrolesindex.com/orgs/all/agent/fit.py`). Run
`python3 fit.py candidate-profile.json jobs.json > fit.json`, with `jobs.json` from `fetch.py`. Python 3.9+,
stdlib only. Output: one object per job with `tier`, the counts, `matches`, `partials`, `gaps`, `implied` (each
with the posting's `evidence`), `filters`, `flags`, `judgements` (every synonym or containment match, to report
as yours), `distance_km` and `pay_top_annual`.

`flags` may include `source_contradiction`: the posting offers alternatives ("RN or LPN license") but elsewhere rules one out ("LPN applicants are not eligible"). An alternative the posting rules out never meets the requirement; say so, quote both passages, and tell the candidate to check with the employer. Keep `exclusions.py` (in this pack) next to `fit.py`: `fit.py` imports it to find those statements.
