- Python 100%
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 |
||
|---|---|---|
| conformance | ||
| data/incoming | ||
| docs | ||
| ontology | ||
| scripts | ||
| tests | ||
| titlegrammar | ||
| titlevalidate | ||
| .gitignore | ||
| README.md | ||
| requirements.txt | ||
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
- Collapse the cardinality. A billion contact rows point at perhaps 20–40M companies. Do the expensive work per company, join it per contact. This is what makes one VPS enough.
- 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. - 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 0–1 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.