# Matching and confidence

> The API tells you how each name matched and how the birth date agreed. Build the review queue on those two facts, not on the score alone.

- **HTML:** https://offendersearch.app/docs/guides/sex-offender/matching-and-confidence
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

## What you will build

A triage function that sorts every returned record into **accept** (identity confirmed well enough to act on automatically), **review** (a real candidate a person must look at) or **noise** (a widened match you choose to drop), using `matchDetail.strategies`, `matchedName.type` and `matchState`. Along the way you pick a match mode for your use case and learn when to widen a search with `prefixMatch` or `nameMatch`.

Who does this: anyone screening people by name — admissions, volunteer coordinators, visitor desks, platforms serving them. The result is a queue in which the strong hits are separated from the common-name noise before a reviewer sees them.

## Before you start

- A test key on `https://sandbox.offendersearch.app`. Sandbox people used here: **Exact Match** (`dob` `1980-01-01`, one legal-name hit), **Alias Match** (a registrant whose legal name is different; the hit is through an alias) and **Name Only** (three same-name candidates, no birth dates).
- Three match modes on the request: `strict`, `balanced` (default), `broad`. Two finer controls on `query`: `prefixMatch` and `nameMatch`. The full vocabulary is on [Matching & confidence](/docs/matching); this guide uses it rather than restating it.
- Two response fields carry the answer: `matchDetail.strategies` (one key per name field, `exact`, `nickname`, `initial`, `prefix`, `fuzzy`, `middle` or `absent`, plus `alias` when an alias produced the hit) and `matchState` (how the birth date agreed).

> **The one thing people get wrong.** `matchConfidence` measures how complete the record is, not whether the person at your desk is the registrant. **Exact Match** scores 0.9 with a `dob` sent (`matchState: "dob_match"`) and 0.9 with no `dob` at all (`matchState: null`). Gate on `strategies` and `matchState`; use the score to order the queue.

## Step by step

1. **Run the default mode.** `balanced` is the default: exact surname, and nicknames, initials, 3-character prefixes and one-edit typos on the given name, with aliases searched. It is the right mode for a national search; measured against a large competitor corpus it matched 98.28% and `broad` recovered only 0.11% more.
2. **See an alias hit, then switch to strict.** Search **Alias Match** in `balanced` and again with `"match": "strict"`. The alias hit disappears in `strict`, which searches the registered legal name only.
3. **Widen deliberately.** Use `prefixMatch` for a partial name (minimum 3 characters; it forces `broad`) or `nameMatch` to switch individual strategies on per field. Widening is a choice you make per request, never a default.
4. **Triage.** Implement the function below and feed it `records[]`. Send `review` items to a person with the photo, age, address and `sources[].recordUrl`.

### Step 2 — the alias hit in balanced, gone in strict

*POST /v1/search body (balanced is the default)*
```json
{ "query": { "firstName": "Alias", "lastName": "Match" } }
```

*Response record (trimmed)*
```json
{
  "recordId": "rec_ca28e3ec410c9ee37d7d",
  "personRef": "prs_ca28e3ec410c9ee37d7d",
  "matchConfidence": 0.55,
  "matchBasis": ["lastName", "firstName", "dob", "lastName:exact", "firstName:exact", "alias:exact", "alias_match"],
  "matchDetail": {
    "strategies": { "lastName": "exact", "firstName": "exact", "alias": "exact" },
    "fieldsPresent": ["lastName", "firstName", "dob"],
    "signals": ["matchedAlias"],
    "matchedNameType": "alias",
    "verification": null
  },
  "matchedName": { "value": "Alias Match", "type": "alias" },
  "name": { "first": "Jordan", "middle": "Sample", "last": "Placeholder", "suffix": "", "full": "Jordan Sample Placeholder" },
  "aliases": ["Alias Match", "J. Placeholder"],
  "dob": "1977-07-07", "dobPrecision": "exact",
  "matchState": null, "unverified": false
}
```

The registered name is Jordan Sample Placeholder; the name you sent is one of the aliases. `matchedName.type` is `alias`, `strategies.alias` is `exact`, and the score is capped at 0.55 — the lowest the engine produces — because an alias-only hit is never presented as certainty. The same request with `"match": "strict"` returns `counts.records: 0`.

### Step 3 — widening on purpose

**prefixMatch**
```json
{ "query": { "firstName": "Jordan", "lastName": "Riv", "prefixMatch": "lastName" } }
// 3+ characters required (a shorter prefix is a 422); forces match=broad;
// an exact hit always ranks above a prefix hit; prefix hits cap at 0.60.
```

**nameMatch**
```json
{ "query": { "firstName": "Jordan", "lastName": "Rivera",
             "nameMatch": { "firstName": ["exact", "nickname"], "lastName": ["exact"], "aliases": true } } }
// per-field control; anything omitted from a list is OFF for that field;
// exact is always on; nameMatch overrides both prefixMatch and match.
// lastName accepts exact, prefix, fuzzy only — asking for "nickname" on it is a 422.
```

**strict**
```json
{ "query": { "firstName": "Jordan", "lastName": "Rivera", "dob": "1988-04-12" }, "match": "strict" }
// exact given name and surname on the registered legal name; aliases not searched.
```

### Step 4 — the triage function

**Python**
```python
STRONG_DOB = {"dob_match"}
WEAK_DOB = {"year_match", "age_match", "no_dob_age_year", None}

def triage(record, queried=("lastName", "firstName")):
    """accept | review | noise for one record of POST /v1/search."""
    strategies = record["matchDetail"]["strategies"]
    legal = record["matchDetail"]["matchedNameType"] == "legal"
    all_exact = all(strategies.get(f) == "exact" for f in queried)
    if all_exact and legal and record["matchState"] in STRONG_DOB:
        return "accept"          # exact legal name + confirmed full date of birth
    if record["matchState"] == "age_mismatch":
        return "noise"           # only present when you sent onAgeMismatch: "flag"
    if strategies.get("lastName") == "fuzzy" and record["matchState"] in WEAK_DOB:
        return "noise"           # broad-mode surname typo with nothing confirming it
    return "review"              # nickname / initial / prefix / alias / middle, or a weak date

def queue(result):
    rows = sorted(result["records"], key=lambda r: -(r["matchConfidence"] or 0))
    return {"accept": [r for r in rows if triage(r) == "accept"],
            "review": [r for r in rows if triage(r) == "review"],
            "incomplete": result["status"] != "complete"}
```

**JavaScript**
```javascript
const STRONG_DOB = new Set(['dob_match']);
const WEAK_DOB = new Set(['year_match', 'age_match', 'no_dob_age_year', null]);

/** accept | review | noise for one record of POST /v1/search. */
function triage(record, queried = ['lastName', 'firstName']) {
  const { strategies, matchedNameType } = record.matchDetail;
  const allExact = queried.every((f) => strategies[f] === 'exact');
  if (allExact && matchedNameType === 'legal' && STRONG_DOB.has(record.matchState)) return 'accept';
  if (record.matchState === 'age_mismatch') return 'noise';           // only with onAgeMismatch: "flag"
  if (strategies.lastName === 'fuzzy' && WEAK_DOB.has(record.matchState)) return 'noise';
  return 'review';
}

function queue(result) {
  const rows = [...result.records].sort((a, b) => (b.matchConfidence ?? 0) - (a.matchConfidence ?? 0));
  return {
    accept: rows.filter((r) => triage(r) === 'accept'),
    review: rows.filter((r) => triage(r) === 'review'),
    incomplete: result.status !== 'complete',
  };
}
```

Run it on the three sandbox people: **Exact Match** with `dob` lands in `accept`; **Alias Match** lands in `review` (alias, no date sent); each **Name Only** record lands in `review` (`no_dob_age_year`). "Accept" here means the identity evidence is strong enough for your automatic path — it is still a registry match to be confirmed against the source page, not a verdict about the person.

## Reading the result

| Field | Values | What it means for the queue |
| --- | --- | --- |
| `matchDetail.strategies.lastName` | `exact`, `prefix`, `fuzzy` | `exact` is the only surname strategy in `strict` and `balanced`; `prefix`/`fuzzy` appear only in `broad` and cap the score at 0.60. |
| `matchDetail.strategies.firstName` | `exact`, `nickname`, `initial`, `prefix`, `fuzzy`, `middle`, `absent` | `exact` has no cap; `nickname` caps at 0.75; the rest at 0.60. `absent` means the registry publishes no given name — kept, never a confirmation. |
| `matchDetail.strategies.alias` | `exact`, `prefix`, `fuzzy` (present only on an alias hit) | An alias produced the hit; `matchedName.type` is `alias`; cap 0.55. Always review. |
| `matchDetail.matchedNameType` | `legal`, `alias` | Which name matched. Auto-accept only `legal`. |
| `matchState` | `dob_match`, `year_match`, `age_match`, `no_dob_age_year`, `age_mismatch`, `null` | A confirmed full date lifts every cap; a confirmed year raises the floor to 0.80. `null` means you sent no `dob`/`age`. |
| `matchConfidence` | 0–1, or `null` on a nameless search | Order the queue with it. Several strategies share a ceiling, so it cannot tell them apart. |
| `matchBasis[]` | mixed tokens, e.g. `lastName:exact`, `alias:prefix`, a bare `dob` | A flat explanation for a reviewer. Prefer `matchDetail` in code. |

Strategy ranks, ceilings and the exact rules behind each mode are in [Matching & confidence](/docs/matching). The birth-date side of the decision has its own guide: [Date-of-birth matching](/docs/guides/sex-offender/date-of-birth-matching).

## Edge cases and pitfalls

- **Typos in `match` are refused**, not widened: `"match": "strick"` answers `422` with `Unknown match 'strick'. Allowed: balanced, broad, strict.`
- **`broad` fuzzes the surname** and returns roughly 13.5% surname false positives by design, every one labelled `lastName:fuzzy` in `matchBasis`. It also reads far more candidate rows and can come back `partial` at national scope; scope it with `jurisdictions` or `locationScoped`.
- **Prefix hits need 3 characters.** `{"lastName": "Ma", "prefixMatch": "lastName"}` is a `422`: `prefixMatch prefix matching on lastName requires at least 3 characters`.
- **A common surname with no given name or date** can hit the per-registry candidate cap and come back `partial` with `incompleteReason: "truncated"`. Add a `firstName`, `dob` or `age` rather than retrying.
- **Compound surnames match on one token**: a `Smith` query matches `Hamilton-Smith` as `exact`, never `Goldsmith`.
- **No phonetic matching.** `Smyth` → `Smith` works because it is one edit; `Schmidt` → `Smith` does not. Send a second query for a variant you need.
- **A missing `strategies` key** for a field you queried means that field did not drive the match; treat it as `review`, as the triage function does.

## Production checklist

- Mode chosen per use case and sent explicitly (`balanced` for screening; `strict` where you hold the exact legal name; `broad` only scoped).
- Auto-accept requires `strategies` all `exact`, `matchedNameType: "legal"` and `matchState: "dob_match"`; everything else is reviewed by a person.
- Reviewers see `matchedName`, `aliases[]`, `images[]`, `age`, `addresses[]` and `sources[].recordUrl`, and record verdicts as [clearances](/docs/clearances).
- The queue is ordered by `matchConfidence` but never gated by it alone.

## Related

- [Matching & confidence](https://offendersearch.app/docs/matching)
- [Date-of-birth matching](https://offendersearch.app/docs/guides/sex-offender/date-of-birth-matching)
- [Understanding search results](https://offendersearch.app/docs/guides/sex-offender/understanding-results)
- [Clearances](https://offendersearch.app/docs/clearances)
- [Enumerations](https://offendersearch.app/docs/enums)
