Threshold Tuning for Identity Validation
Operating within the broader Patron Validation & Privacy Data Routing architecture, this guide covers the decision layer that every consortial or municipal library hits the moment two source systems disagree about who a patron is: given a computed similarity score between an inbound record and an existing patron row, what does the pipeline do with it? Merge them? Queue them for a human? Reject the match outright? Threshold tuning is where that judgment is encoded — the deterministic gate that turns a floating-point match score into a routing action with legal consequences. Library-tech staff meet this problem during registration deduplication, nightly ILS reconciliation, and every federated-login flow that has to reconcile a campus SIS identity against an existing borrower profile.
This page walks the tuning problem end to end: the scoring contract and the confidence bands that map scores to actions, the environment you need, an annotated Python implementation of the streaming evaluator, the compliance checkpoints that keep identity assertions auditable, the quarantine patterns that keep an ambiguous match from silently corrupting a patron record, how to keep the evaluator inside its memory and query budget, and how to verify a tuning change before it touches production. Because a wrong merge is far more damaging than a missed one — it fuses two people’s circulation histories — this page treats false-positive avoidance as the primary design constraint.
Specification & Contract
The contract has one rule everything else follows from: a score is a probability, not a permission. The scorer produces a number in [0.0, 1.0]; the threshold layer — and only the threshold layer — decides what that number authorizes. Keeping scoring and gating separate is what lets you re-tune the bands without touching the matching math, and it is the same separation-of-concerns discipline the ILS Schema Translation Patterns layer applies to vendor payloads.
Scores are computed across a fixed set of patron attributes so that a change in weighting is reproducible and reviewable. Each attribute contributes a normalized sub-score; the aggregate is a weighted sum. The table below is the minimum scoring contract most pipelines start from — treat each row as a rule enforced in tests, not a suggestion.
| Attribute | Comparison method | Weight | Notes |
|---|---|---|---|
last_name |
trigram similarity (pg_trgm) |
0.30 | Normalize case and diacritics before hashing; store last_name_hash for the index |
birth_year |
exact / ±1 tolerance | 0.20 | ±1 absorbs off-by-one data-entry error without collapsing distinct people |
barcode |
exact after check-digit strip | 0.25 | A barcode match is strong but not decisive — cards get reissued and reused |
institution_id |
exact | 0.15 | A hard blocking key; candidates must share it before scoring runs |
address_token |
trigram similarity on hashed token | 0.10 | Lowest weight — addresses churn and are shared by household members |
Those sub-scores collapse into three confidence bands, and the bands are the actual contract downstream stages depend on:
| Adjusted score | Decision | Routing action |
|---|---|---|
≥ 0.85 |
AUTO_MERGE |
Automated profile update or record merge |
0.65 – 0.84 |
HITL_REVIEW |
Human-in-the-loop review queue |
< 0.65 |
REJECT |
Rejection with a structured reason code |
Two rules govern the whole contract. First, thresholds are configuration, not code — the cutoffs live in a versioned config object keyed by source system, so a tuning change is a reviewable one-line edit tied to a change ticket, never a scattered hunt through business logic. Second, static cutoffs fail under real conditions: data quality varies across consortium branches, municipal integrations, and third-party identity providers, so the raw score is multiplied by a source_reliability weight and stale records decay before they are gated. A match derived from a freshly refreshed municipal ID registry should outrank one derived from a legacy barcode-only export, and the band logic must express that difference rather than pretend all sources are equal.
Prerequisites & Environment Setup
The evaluator is pure Python plus a PostgreSQL candidate store. Pin versions so a coercion or similarity-operator change never silently alters which records pass the gate.
Install and freeze in one shot:
python -m venv .venv
source .venv/bin/activate
pip install "structlog>=24.1" "psycopg[binary]>=3.1"
pip freeze > requirements.txt
A common early pitfall: comparing scores as float. Floating-point rounding means a record scoring exactly on a band edge (0.6500000001) can route inconsistently across runs. Use decimal.Decimal for every band comparison so 0.65 means 0.65, and reserve float for the intermediate similarity math only.
Core Implementation
The evaluator has three labeled stages: resolve candidates for an inbound record, score each candidate against it, then gate the best score into a routing decision. Each stage is independently testable, and the gate is a pure function so it scales horizontally during peak reconciliation windows.
Step 1 — The typed records and the masking primitive
Define the data the evaluator moves and the one function that keeps identifiers out of logs. Decimal on the score field is deliberate — it makes band comparison exact.
from __future__ import annotations
import hashlib
import hmac
import os
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Iterator, Literal
Decision = Literal["AUTO_MERGE", "HITL_REVIEW", "REJECT"]
def _load_salt() -> bytes:
"""Fetch the HMAC salt at startup; fail fast if it is missing."""
salt = os.environ.get("PII_MASKING_SALT")
if not salt:
raise RuntimeError("PII_MASKING_SALT is not set; refusing to start")
return salt.encode("utf-8")
_SALT = _load_salt()
def mask_id(raw_value: str) -> str:
"""Deterministic, salted correlation token for audit-safe logging.
HMAC-SHA256 (not a bare hash) so the token cannot be reversed with a
precomputed rainbow table of known barcodes or patron ids.
"""
return hmac.new(_SALT, raw_value.encode("utf-8"), hashlib.sha256).hexdigest()[:16]
@dataclass(frozen=True)
class PatronRecord:
patron_id: str
institution_id: str
last_name_hash: str
birth_year: int
barcode: str
@dataclass(frozen=True)
class MatchResult:
candidate_id: str
score: Decimal
decision: Decision
reason: str
audit_payload: dict = field(default_factory=dict)
Step 2 — The candidate query
Never score against the whole patron table. Block on institution_id and the hashed name, then let pg_trgm rank the shortlist. The parameterized query below returns only pre-filtered candidates, keeping the per-record comparison count bounded regardless of table size.
import psycopg
CANDIDATE_SQL = """
SELECT patron_id,
similarity(last_name_hash, %(name)s) AS name_sim,
birth_year,
barcode
FROM patron
WHERE institution_id = %(inst)s
AND last_name_hash %% %(name)s -- % is the pg_trgm similarity operator
AND abs(birth_year - %(birth)s) <= 1
ORDER BY name_sim DESC
LIMIT 25;
"""
def fetch_candidates(conn: psycopg.Connection, record: PatronRecord) -> list[dict]:
"""Return at most 25 blocking-key candidates for one inbound record."""
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
CANDIDATE_SQL,
{"inst": record.institution_id, "name": record.last_name_hash,
"birth": record.birth_year},
)
return cur.fetchall()
The LIMIT 25 is a guardrail, not a tuning parameter: if a single blocking key returns more than 25 near-identical names, the data is too coarse to auto-merge safely and the record belongs in review regardless of score.
Step 3 — Scoring and the adaptive gate
Scoring produces the raw probability; the gate applies the source-reliability weight and the configured bands. Keep both pure — no I/O, no logging — so they are trivial to unit-test at the band edges.
def score_candidate(record: PatronRecord, candidate: dict) -> Decimal:
"""Weighted sum of per-attribute sub-scores in [0.0, 1.0]."""
name_sim = Decimal(str(candidate["name_sim"])) # from pg_trgm
birth_match = Decimal("1") if candidate["birth_year"] == record.birth_year else Decimal("0.5")
barcode_match = Decimal("1") if candidate["barcode"] == record.barcode else Decimal("0")
return (name_sim * Decimal("0.30")
+ birth_match * Decimal("0.20")
+ barcode_match * Decimal("0.25")
+ Decimal("0.15") # institution_id already matched
+ Decimal("0")).quantize(Decimal("0.0001"))
def gate(
candidate_id: str,
raw_score: Decimal,
source_reliability: Decimal = Decimal("1.0"),
auto_cutoff: Decimal = Decimal("0.85"),
review_cutoff: Decimal = Decimal("0.65"),
) -> MatchResult:
"""Apply source weighting and return the routing decision."""
adjusted = (raw_score * source_reliability).quantize(Decimal("0.0001"))
if adjusted >= auto_cutoff:
decision, reason = "AUTO_MERGE", "high-confidence match; automated reconciliation"
elif adjusted >= review_cutoff:
decision, reason = "HITL_REVIEW", "moderate confidence; staff verification required"
else:
decision, reason = "REJECT", "low confidence; potential false positive"
return MatchResult(
candidate_id=candidate_id,
score=adjusted,
decision=decision,
reason=reason,
audit_payload={"raw_score": str(raw_score), "reliability_weight": str(source_reliability)},
)
Step 4 — The streaming evaluation loop
Nightly reconciliation can process millions of records; materializing them into a list exhausts the heap on a memory-constrained container. Drive the evaluator from a generator so records are scored and released one at a time, and let structured logging emit a masked audit event per decision. This backpressure-friendly shape mirrors the streaming discipline used for bulk record movement in Async Batch Processing for Catalog Updates.
import structlog
logger = structlog.get_logger()
def evaluate_stream(
conn: psycopg.Connection,
records: Iterator[PatronRecord],
source_reliability: Decimal = Decimal("1.0"),
) -> Iterator[MatchResult]:
"""Score each inbound record against its candidates, yielding one result each."""
for record in records:
candidates = fetch_candidates(conn, record)
if not candidates:
yield MatchResult(candidate_id="", score=Decimal("0"),
decision="REJECT", reason="no candidate in blocking window")
continue
best = max(
(gate(c["patron_id"], score_candidate(record, c), source_reliability)
for c in candidates),
key=lambda r: r.score,
)
logger.info(
"patron_identity_decision",
patron_id=mask_id(record.patron_id),
candidate_id=mask_id(best.candidate_id) if best.candidate_id else None,
score=str(best.score),
decision=best.decision,
reason=best.reason,
**best.audit_payload,
)
yield best
Note the two failure exits: a record with no blocking-window candidate is rejected with an explicit reason rather than silently dropped, and only the highest-scoring candidate is gated so one inbound record can never merge into two profiles.
PII & Compliance Checkpoints
Threshold tuning touches the most sensitive fields a library holds — names, birth years, addresses, and circulation-linked barcodes — so every stage carries a compliance obligation, not just the export at the end.
- Mask at the boundary. No raw
patron_id, barcode, name, or address may reach a log sink. Themask_idHMAC token above is deterministic, so an operator can correlate events across a run without ever seeing an identifier. The same salted-token discipline governs downstream exports under PII Masking in Patron Data Exports; use the identical salt so tokens join across the two systems. - Merges are irreversible re-identification events. An
AUTO_MERGEfuses two circulation histories. Before the merge writes, confirm the routing side-effects are compatible with Circulation History Routing & Anonymization so a merge cannot re-identify a record that was previously anonymized for analytics. - Student records carry extra statute. When an inbound identity originates from a campus SIS, FERPA constraints apply on top of state library-confidentiality law; route those decisions through the controls in Automating FERPA Compliance in Student Patron Records.
- Audit the decision, not the data. The audit event captures the computed score, applied thresholds, and outcome — never the compared attribute values. That keeps the trail useful for governance review while staying inside the boundary model described in Data Privacy Boundaries in Library Systems.
- Threshold changes are governed changes. Every cutoff adjustment is version-controlled and tied to a change-management ticket, because state auditors routinely request proof that identity-routing logic was not altered without approval.
Error Handling & Quarantine Patterns
An identity pipeline fails in two distinct ways, and they need different handling. A transient failure — the candidate database is briefly unreachable — should retry. A permanent failure — an inbound payload is unscoreable because a blocking key is missing or malformed — must never be retried into a loop; it belongs in quarantine for inspection, exactly as malformed bibliographic records route to a schema validation quarantine queue.
import time
class UnscoreableRecord(Exception):
"""Raised when a payload cannot be gated (missing blocking key, bad type)."""
def evaluate_with_quarantine(
conn: psycopg.Connection,
record: PatronRecord,
quarantine, # object exposing .write(dict)
max_retries: int = 3,
) -> MatchResult | None:
"""Gate one record; retry transient DB errors, quarantine permanent ones."""
if not record.institution_id or record.birth_year <= 0:
quarantine.write({"patron_id": mask_id(record.patron_id),
"reason": "missing or invalid blocking key"})
return None
for attempt in range(1, max_retries + 1):
try:
candidates = fetch_candidates(conn, record)
if not candidates:
return MatchResult("", Decimal("0"), "REJECT", "no candidate in window")
return max(
(gate(c["patron_id"], score_candidate(record, c)) for c in candidates),
key=lambda r: r.score,
)
except psycopg.OperationalError as exc:
wait = 2 ** attempt
logger.warning("candidate_db_retry", patron_id=mask_id(record.patron_id),
attempt=attempt, wait_s=wait, error=str(exc))
conn.rollback()
time.sleep(wait)
quarantine.write({"patron_id": mask_id(record.patron_id),
"reason": "candidate store unreachable after retries"})
return None
Two rules make this safe. First, quarantine is not a dead end — a record that lands there is triaged, its blocking key repaired upstream, and it is replayed, never discarded. Second, transient retries use exponential backoff so a flapping database is not hammered; the same backoff discipline the ingestion layer applies to vendor APIs under ILS REST API Polling & Rate Limiting.
Performance Considerations
Two resources bound the evaluator: heap and the candidate query.
- Memory. The generator in Step 4 keeps exactly one
PatronRecordand its ≤25 candidates resident at a time, so peak heap is independent of batch size. Drive the record iterator from a server-side cursor (conn.cursor(name=...)) rather thanfetchall()so the source side is streamed too. If you fan out across worker processes, partition by a stable hash ofpatron_idso per-patron ordering is preserved and no two workers gate the same identity concurrently. - Query cost. Threshold accuracy is only as good as the index behind the candidate query. Without the composite
(institution_id, last_name_hash, birth_year)index and a GIN trigram index, candidate resolution degrades into full-table scans that blow the sync window and exhaust the connection pool. Confirm the plan withEXPLAIN (ANALYZE, BUFFERS)and watch for aSeq Scanonpatron— that is the signal the blocking keys are not being used. - Index maintenance. High-frequency scoring against unvacuumed tables introduces latency spikes as dead tuples accumulate. Schedule
VACUUM ANALYZE(orpg_repackfor bloated GIN indexes) during off-peak windows so the planner keeps choosing the index scan. - Connection-pool alignment. Match PostgreSQL
max_connectionsand any PgBouncer pool size to the worker concurrency of the evaluator. Over-provisioning surfaces astoo many clientserrors precisely during the peak nightly window when you can least afford them.
Verification & Testing
Tune against a labeled fixture set, never against production data blind. The most important tests exercise the band edges, because that is where a one-line cutoff change flips real decisions.
from decimal import Decimal
def test_band_edges():
# Exactly on the auto cutoff -> AUTO_MERGE
assert gate("c1", Decimal("0.85")).decision == "AUTO_MERGE"
# One tick below -> HITL_REVIEW, not a merge
assert gate("c1", Decimal("0.8499")).decision == "HITL_REVIEW"
# Exactly on the review cutoff -> HITL_REVIEW
assert gate("c1", Decimal("0.65")).decision == "HITL_REVIEW"
# Below review -> REJECT with a reason
r = gate("c1", Decimal("0.6499"))
assert r.decision == "REJECT" and r.reason
def test_source_reliability_demotes_stale_source():
# A 0.90 raw score from a source weighted 0.9 -> 0.81 -> falls to review
assert gate("c1", Decimal("0.90"), source_reliability=Decimal("0.9")).decision == "HITL_REVIEW"
def test_no_candidate_rejects_not_crashes():
result = next(evaluate_stream(FakeConn(rows=[]), iter([SAMPLE_RECORD])))
assert result.decision == "REJECT"
Beyond unit tests, measure two operational signals before promoting a tuning change: the precision of AUTO_MERGE against a hand-labeled sample (target: near-zero false merges), and the AUTO_MERGE : HITL_REVIEW ratio, which sizes the human review queue. A tuning change that halves the review queue but doubles false merges is a regression, not an improvement.
Troubleshooting & Frequently Asked Questions
The AUTO_MERGE rate suddenly spiked overnight. What happened?
Almost always an upstream data change, not a tuning bug: a source system started emitting a new default value (blank barcodes, a constant birth year) that inflates similarity across unrelated patrons. Alert on the AUTO_MERGE : HITL_REVIEW ratio and treat a sudden shift as a data-quality incident. Lower that source’s source_reliability weight until the upstream issue is fixed, so borderline matches demote to review instead of auto-merging.
Two clearly different patrons keep merging. How do I stop it?
A false merge means the band is too loose for that source’s data quality. Do not merely raise auto_cutoff globally — that pushes good matches into the review queue everywhere. Instead lower the offending source’s reliability weight, and check whether a single attribute (usually a reused barcode) is carrying too much weight. Re-run the labeled fixture set after any weight change and confirm AUTO_MERGE precision before promoting.
Candidate resolution is slow and the sync window is blowing out. Why?
Run EXPLAIN (ANALYZE, BUFFERS) on the candidate query. A Seq Scan on patron means the composite blocking index or the GIN trigram index is missing or not selectable — usually because last_name_hash is normalized differently at write time than at query time, so the values never match. Normalize case and diacritics identically on both paths, then confirm an Index Scan in the plan.
Records fail with UnscoreableRecord for patrons that clearly exist. What is wrong?
The inbound payload is missing a blocking key (institution_id) or carries an invalid birth_year (0, or a string that coerced badly upstream). That is intended — the record cannot be safely gated without a blocking key, so it quarantines for repair rather than scoring against the wrong window. Fix the extraction upstream and replay the quarantined record; do not loosen the blocking check.
How do I safely roll out a new threshold?
Never flip the cutoff in place. Run the new bands in shadow mode: gate each record with both the current and candidate thresholds, log both decisions (masked), and diff them over a full cycle. Promote only when the diff shows the intended movement and AUTO_MERGE precision holds on the labeled sample. Tie the change to a change-management ticket so the audit trail records who approved it.
Related
- Patron Validation & Privacy Data Routing — the parent architecture this decision layer sits within.
- PII Masking in Patron Data Exports — the masking salt and token contract this evaluator shares.
- Circulation History Routing & Anonymization — the re-identification constraints a merge must respect.
- Data Retention Policies for Public Libraries — how long audit and quarantine records may be kept.
- Implementing Differential Privacy for Patron Analytics — protecting the aggregate signals used to monitor threshold drift.