Job-title validation engine: 78 rules, public data only, 34 markets, single VPS, statistical QA with no human verifiers
Find a file
brahm a4ff0da880 feat: second jurisdiction (France), keyless; isolate resolution from verification
Companies House is dropped: it needs a free API key, and a method that only
works after somebody signs up is not a method anyone can pick up. Every source
in titlegrammar/registries.py is reachable with no key, no account, no
credential -- now Norway (Brønnøysund, NLOD) and France (Recherche
d'entreprises / INPI RNE, Licence Ouverte).

Adapters declare what they CANNOT do. France publishes no per-role
deregistration flag -- officers simply vanish from the list -- so DEPARTED is
not derivable there. Recorded in `capabilities`, not papered over.

France measured, 60 companies, 1,082 cases, free-text roles instead of codes:

  company matched by name   precision 99.1%  recall 91.5%
  company id known          precision 99.1%  recall 99.3%
  trivial baseline                    46.9%  recall 100%

THE NUMBER WORTH HAVING: 7.8 points of recall are lost to company-name
resolution alone. Diagnosed exactly -- of 437 true records, 34 resolved to a
DIFFERENT company sharing the same name, and person-name matching failed zero
times. That is the measured case for "ML earns its keep in entity resolution,
not title classification". verify() now takes company_id to separate the two,
because otherwise resolution failure masquerades as verification failure.

Norway is unchanged at 100%/100%.

Two rules France forced that Norway never would have:

- Containment must not step over a rank modifier. "Vice-Président" contains
  "Président", and mapping it to PRES confirmed a vice-chair as chair. A
  deputy, acting or honorary role is a different role, not a verbose spelling.
- Code tables differ in STRUCTURE, not just language. One Président code
  collapsed three genuinely different French offices -- du conseil
  d'administration, du directoire, du conseil de surveillance -- because France
  separates governance from management boards. Splitting them took false
  accepts from 9 to 4.

Also fixed: `resp.raise_for_status() or resp.json()` caches a Response, because
httpx returns the Response rather than None. Added retry with backoff so free
public APIs timing out does not make the evaluation look like a method failure.

217 tests pass, conformance 66/66.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGY4Qad6trDpppS7itm7FL
2026-07-31 16:15:21 +00:00
conformance feat: portable spec + 66 conformance vectors; strengthen compound heads 2026-07-31 15:07:34 +00:00
data/incoming fix: six company-level rules returned PASS on rows with no company 2026-07-31 10:52:10 +00:00
docs feat: second jurisdiction (France), keyless; isolate resolution from verification 2026-07-31 16:15:21 +00:00
ontology feat: Tier-A oracle, J8 adjudication, and the full QA apparatus 2026-07-31 05:13:12 +00:00
scripts feat: second jurisdiction (France), keyless; isolate resolution from verification 2026-07-31 16:15:21 +00:00
tests feat: second jurisdiction (France), keyless; isolate resolution from verification 2026-07-31 16:15:21 +00:00
titlegrammar feat: second jurisdiction (France), keyless; isolate resolution from verification 2026-07-31 16:15:21 +00:00
titlevalidate fix: six company-level rules returned PASS on rows with no company 2026-07-31 10:52:10 +00:00
.gitignore feat: derive the title grammar instead of writing lexicons 2026-07-31 14:10:28 +00:00
README.md feat: portable spec + 66 conformance vectors; strengthen compound heads 2026-07-31 15:07:34 +00:00
requirements.txt chore: scaffold titlevalidate — capacity, security and data-protection positions first 2026-07-31 01:28:54 +00:00

titlevalidate

Resolve companies once. Validate contacts a billion times.

A 78-rule job-title validation engine in Python, built on free public data, sharded across 34 markets, sized to run on one VPS, and measured — precision and recall — without a single human verifier.

This is the implementation of Part II of the job-title validation design (job-title-validation-implementation.html). The rules themselves (what makes a title string a title at all) come from the sibling project csuite-title-ontology; this repo is the pipeline, the firmographics, the resolution, the oracle and the statistics.

Portable rule set

The rules are specified independently of this codebase in docs/SPEC.md — implementable in any language, with 66 conformance vectors that need no network, no API key and no personal data:

PYTHONPATH=. python conformance/run.py     # -> conformance: 66/66 passed

Three layers, kept deliberately separate:

Layer Question Coverage Measured
L1 Is this string a job title? 100% of rows 0.728 recall over 26 languages on unseen occupations; 0.4-0.5% false accepts
L2 Could it exist at this company? 100% of rows 0 false positives, 100% detection of injected impossibilities
L3 Does this person hold it now? 1-20% of rows 100% precision vs 52.3% trivial baseline (live registry, 1,193 cases)

See docs/GRAMMAR.md for how the vocabulary is derived rather than written, and docs/EVIDENCE_LADDER.md for the source inventory and the rules-vs-ML verdict.

The three ideas everything else follows from

  1. Collapse the cardinality. A billion contact rows point at perhaps 2040M companies. Do the expensive work per company, join it per contact. This is what makes one VPS enough.
  2. Three-valued rules. PASS / FAIL / INDETERMINATE. Free firmographics are patchy; a rule that cannot evaluate must never be scored as a failure, or you systematically destroy SME coverage — which is where the volume is.
  3. Canaries, not clipboards. Recall is measured by injecting known-bad records you fabricated (so ground truth is free). Precision is measured by machine adjudication against filings. Neither needs a person.

Layout

titlevalidate/
  config.py            resource ceilings, paths, politeness defaults
  markets.py           the 34 markets, tiered by registry transparency
  ontology/            ranks, functions, modality, surface forms, false friends, era
  rules/               stage0..stage6 + registry.py (id, severity, owner, version, bit)
  backbone/            GLEIF spine + national registry loaders (J2)
  oracle/              filings/registry/leadership-page crawlers → officer_assertions
  qa/                  canaries, SPC, sampling, latent class, shrinkage, synth data
  pipeline/            J1..J9 jobs, DuckDB session, shard runner
  net/                 hardened HTTP, XML, archive extraction
  cli.py               typer; every job invocable standalone

Quickstart

python3 -m venv ~/venvs/titlevalidate && ~/venvs/titlevalidate/bin/pip install -r requirements.txt
alias tv="~/venvs/titlevalidate/bin/python -m titlevalidate.cli"

tv rules list                       # the 78 rules with bit positions and severities
tv rules show R2.2                  # one rule: inputs, when it is INDETERMINATE, how measured
tv markets                          # 34 markets, tiers, waves
tv sources                          # every data source with its licence and rate limit
tv title "Managing Director" --country GB --industry investment_banking

tv backbone --countries NO,US       # J2: build the company backbone from free sources
tv oracle --country NO              # populate the Tier-A oracle (Brønnøysund roles)
tv demo --rows 6000 --country NO    # synthetic vendors + canaries → J1..J9 → reports/qa.html
tv run --shard all --batch 2026-07  # all 34 shards, sequentially
tv sample-plan                      # the monthly stratified plan and its crawl cost
tv bench                            # Stage 0-1 throughput on this host

python scripts/run_multimarket.py   # the 12-market run behind docs/RESULTS.md
python scripts/check_report_safe.py # refuse to publish a report with non-aggregates

Start here

New to this repo? Read docs/PLAIN_ENGLISH.md — what it does and what it actually caught, without jargon. Then docs/HANDOFF.md for state and next steps.

Documentation

Doc What it settles
docs/CAPACITY.md The plan wants 16 vCPU/64 GB; this box has 4/7.8. What that does and does not change
docs/SECURITY.md Threat model: SSRF, XXE, zip-slip, canary leakage, co-tenant resource exhaustion
docs/DATA_PROTECTION.md Positions taken on personal data, crawl scope, retention, suppression
docs/RULES.md The 78 rules: stage, severity, inputs, what makes each one INDETERMINATE
docs/QA.md The five measurement mechanisms and what each can and cannot prove
docs/RESULTS.md Measured numbers on this host — throughput, catch rates, precision by stratum

Status — measured, not claimed

All nine jobs implemented and running end to end on this VPS against live public data (GLEIF, SEC EDGAR, Brønnøysund). Latest run, 2026-07-31:

Markets processed sequentially 12
Contact rows, J1→J9 51,466 in 35.9 s (1,435 rows/s), peak RSS 0.40 GB
Stage 01 alone ~602,000 rows/s on one core
Canary catch rate 95.0% — but 76% of it is circular; the honest figure is 98.6% on the 7 families where a defect must be derived. See docs/EVALUATION.md
Oracle extractor accuracy precision 1.00, recall 1.00 — on n=12. Wilson CI on 8/8 is [0.68, 1.00]; read as "no observed errors", not "perfect"
Grade mix ~85% D, ~15% F — correct: three FATAL rules have no oracle for these entities, and INDETERMINATE on a FATAL rule caps at D with a coverage code
Rejecting non-titles (74k real titles, independent sources) 99.5% — the result that generalises
Recognising real titles 93.2% US executives · 34.9% general occupations · 0.0% Norwegian (no lexicon)
Tests 177 passing

Full numbers, caveats and the things that are flattered by construction: docs/RESULTS.md.

What is deliberately not done here

  • No real vendor contact data on this host. Contacts are synthetic; company and officer facts are real and public. See docs/DATA_PROTECTION.md.
  • No billion-row run. 1e9 rows is ~120 GB of Parquet; this disk has 35 GB and shares it with seven other services. Per-row cost is measured instead and volume is a wall-clock statement, which is what country sharding buys.
  • No public port. Nothing here listens on a socket; outputs are files.

Known limits, measured

  • Knows 5 languages. Everything else scores near zero.
  • Backbone covers 5.5% of one market (GLEIF has 64,862 Norwegian entities; the national register has 1,170,269). Resolution rate is registry coverage, nothing else.
  • 8 of 78 rules have never decided anything — the data they need isn't loaded.
  • Whether a title is currently true is not measured at all.