Changelog
Source:NEWS.md
npmatch (development version)
The normalizer now comes from npnormalize. The next release is 0.2.0.
-
What moved. npmatch’s normalizer moved, unchanged, into the new npnormalize package, which npmatch now imports. That covers:
- the rule tables;
-
np_normalize(),np_name_core()andnp_name_placeholder(); - the corpus statistics (
np_name_freq(),np_token_idf(),np_addr_freq(),np_stopwords()); - the blocking tokenizer;
-
np_normalize_signature().
-
Nothing changes for callers. npmatch re-exports those nine functions, so
npmatch::np_normalize(), scripts, pfmatch and networked990 work as before. -
The move itself kept every stored artifact valid, because
np_normalize_signature()still reportedv1-3e7b9f9d59bbb19eon both sides of it — npmatch 0.1.1 and npnormalize 0.1.0 and 0.1.1. -
npnormalize has since moved the signature on purpose, more than once. 0.1.2 made
BUILDINGa unit designator (npnormalize#7), and 0.1.3 made a line naming several units keep the most specific one and the house number a whole token —v1-592912060053c4c3at that point. Storedstreet_unit,street_unit_id,street_numandstreet_namevalues from before that must be rebuilt — matchdb’s database, bundles and tranches included (matchdb#29). npmatch carries a version floor on npnormalize, so an install cannot quietly compute keys with an older normalizer than the one it is pinned to. -
The floor is
npnormalize (>= 0.1.7), and the four releases it reaches for have all landed on npnormalize’smain. 0.1.4 addedstreet_has_box— whether a street carries a box component, whichis_po_boxcannot answer because it is the gate that blanks the address key (npnormalize#9); 0.1.5 published one box vocabulary for the four packages that had each kept their own, which also made a lettered box (PO BOX A) a box (npnormalize#12); and 0.1.6 widened that vocabulary to the long-tail spellings (PO BX,PB,P BOX,POSTAL DRAWER,POSTAL OFC BOX,APDO), made a mail-only line blank the address key rather than key it — general delivery, military mail, a bare drawer, none of which is a building two organizations share — and addedmail_key, the box plus its ZIP, which is the one co-location signal a blanked box still carries (npnormalize#13); and 0.1.7 made a hyphen inside a leading house number close up rather than become a space, so the two filed spellings of one Queens or Hawaii address reach onestreet_keyandstreet_numstops truncating at the hyphen, and folded eleven further spellings ofFOUNDATION— three of them the word broken across two tokens by a line break, which is whynp_name_rules()now returns asplit_wordstable that a port must apply beforeabbrev(npnormalize#16, npnormalize#17). Each move changed the signature, sotests/testthat/test-signature.Rnow pinsv1-59c6e84c5f38e9f5, read off npnormalize’s mergedmainrather than off a value copied out of a branch; the registry comment beside it keeps every earlier value as part of the record. Stored keys and the statistics built on them need rebuilding once more, matchdb’s published data included. - Why. Both sides of a match depend on the normalizer: matchdb stores its keys, and npmatch computes them for each query. With the normalizer in its own package, matchdb no longer needs npmatch, and npmatch can depend on matchdb in turn.
-
Where things are now:
- The normalizer’s tests live in npnormalize. npmatch keeps the tests that block, compare or score on top of it, and adds one checking that its signature and re-exported functions are npnormalize’s own.
- The five name comparators that sat beside the normalizer moved to
R/compare.R, since they feednp_compare()and precompute nothing. - npmatch’s blocking and comparison call npnormalize’s exported
np_tokenize(),np_doc_freq(),np_addr_key()andnp_state_abb().
-
Dependencies: stringi is no longer imported (only the normalizer used it); npnormalize is, from GitHub (
Remotes:). -
street_has_boxis read by nothing, on purpose. npnormalize 0.1.4 names npmatch as the column’s intended consumer, and the measurement says npmatch has no use for it: on the 304,274-pair labeled benchmark,street_has_boxdisagreeing across a pair whilegeo_stnum == 1selects 29 pairs, and they are exactly the 29 thatis_po_box— which npmatch already reads, asgeo_pobox— already selects. All 29 fail.np_hier_score()’sstreet_sim >= 0.9floor, sostreet_hitcannot fire on one. The reason is that the same release anchored.np_parse_street()’s gate, so a street that merely carries a box keeps its house number: across the 3,687,435-row unified BMF,street_numholds a box number on 0 rows whereis_po_boxisFALSE. (Measured under both 0.1.4 and the 0.1.5 that landed during the work, which only narrows the slice where the two columns differ, from 11,982 rows to 10,984.) A veto on a disagreeing flag would also be wrong, not idle — it refuses211 EMMET ST S PO BOXagainst211 EMMET ST S, one building whose house number is genuinely 211 on both sides.tests/testthat/test-box-component.Rpins all of that, including the must-still-match pair, so a regression in npnormalize’s parse fails npmatch’s suite instead of passing quietly;dev/STREET-HAS-BOX.mdcarries the full measurement and the two conditions that would reopen it. Nothing in the compared field set, the scorer or the veto rules changed, soattr(pairs, "cmp_cols")and stored models are untouched.
matchdb references and precomputed address counts
-
np_cascade()andnp_run_batches()take a matchdb reference directly. Pass the objectmatchdb::md_compile()ormatchdb::md_reference()returns asreference, and npmatch uses its frame plus whichever ofname_freq,token_idf,ref_indexandaddr_freqit carries, instead of recomputing them. Explicit arguments still take precedence. A reference whose normalizer signature differs from the installed one is refused. npmatch recognises the object by its shape, so matchdb is not a dependency, not even a suggested one. -
Both take an
addr_freqargument, a precomputednp_addr_freq()table.np_cascade()used to recount it on every call, over whatever reference it was given. Pass one counted over the full reference when matching against part of it, so crowded addresses are not under-counted. matchdb’s bundles ship one counted over exactly the bundle’s rows. -
np_run_batches()counts addresses once for the whole run and keeps the table in itscache =file. A cache written before this version gains it on first use, as an older cache already gainedref_index.
The suite a reviewer is shown
A bare
#now agrees withPMBingeo_unit_same_kind, as well as with the room-level designators. Since npnormalize 0.1.3 a line naming several units keeps the most specific, so a mail store’sSTE 204 # 22915keeps the customer’s box as# 22915; against a record writtenPMB 22915, the reviewer was told “not the same kind of unit” about one mailbox.#is either a door or a box, so it agrees with both, whilePMB 136againstSTE 136andFL 2againstSTE 2still do not. No score moves.-
A reviewer is no longer told that a floor is the same suite as a suite (#23, reported from pfmatch via npnormalize#7).
geo_unitcompares npnormalize’sstreet_unit_id, which drops the designator — that is what makesSTE 320and# 320agree, one office written two ways, and it is right. The same drop madeFL 2agree withSTE 2, andPMB 136withSTE 136:query reference geo_unitprompt, before prompt, now FL 2STE 21same suite not the same kind of unit PMB 136STE 1361same suite not the same kind of unit STE 320# 3201same suite same suite np_compare()now also returnsgeo_unit_same_kind—1both sides name the same kind of place,0they do not,NAeither side names no unit — andnp_as_prompt()requires it before it says the suites are the same. The room-level designators (STEAPTUNITRMSPCTRLRLOT#) are one kind; a floor, a mailbox, a department, a building and a basement are each their own. A review frame saved before the column existed reads correctly too, because the canonical values are in the frame. No score moves.
np_score()readsgeo_unitand nothing else, so the 0.97 suite credit and its multi-tenant gate are exactly as they were; a test asserts the scores are identical with the new column dropped. The grouping deliberately stays in npmatch: which designators count as the same door is a matching policy, while npnormalize’s tables feed the normalizer signature that stamps every published artifact. ## Documentation reframed around the six tiersnpmatch is documented as a tiered workflow, not as a SAM-to-BMF matcher. The README, the landing page, the package help and the articles now name the six steps the package manages — alias enumeration (matchdb), input standardization (npnormalize), data pre-prep (tokenization, inverse document frequency, name and address frequency), candidate generation by blocking and fuzzy comparison (reclin2), tiered adjudication, and packaging for human or LLM review — and present federal registrants against the IRS Business Master File as the deployment npmatch ships wired for rather than as what it is for.
np_map_sam()/np_map_bmf()and the SAM helpers are described as that deployment’s conveniences; pfmatch is named as another deployment.The README and landing page carry a dependency diagram of npmatch, npnormalize, matchdb and reclin2, with a table saying what each owns and why it is separate. pkgdown now renders mermaid blocks on the home page.
matchdb is documented as the default reference path. The quick start downloads a published bundle and passes it straight to the cascade; building a reference from raw NCCS files is kept as the path for a vintage, slice or schema matchdb does not publish. The Workflow article leads with it.
The README no longer restates npnormalize’s rules. The subroutine-level walkthrough of the normalizer is replaced by the columns it produces and a pointer to npnormalize’s own reference, which is authoritative. The tier-3 corpus statistics get a section of their own, including the warning against counting them over a slice.
The prose quotes no normalizer signature. The entry above names the values deliberately, as the record of what moved, and
tests/testthat/test-signature.Rpins the current one so an unintended move cannot pass unnoticed. Prose that merely asserts a signature is unchanged goes stale instead, which is what had happened here: the README and the Workflow article now say to read the value fromnp_normalize_signature()and to record what a run reported.The nine normalizer topics collapsed into one
reexportspage when the normalizer moved, so_pkgdown.ymlredirects their old URLs to it, andnp_cluster_rollup()/np_pair_links()are in the reference index for the first time.dev/BENCHMARK-RANK-DEFICIENCY.mdrecords the long-standing rank-deficient-fit warning in thenp_benchmark()test — which fold degenerates and why, thatnp_train()’s blanketsuppressWarnings()is the part worth fixing, and a plan.dev/probe-rank-deficiency.Rreproduces it.
npmatch 0.1.1
Tagged v0.1.1. This is the last npmatch that carries its own normalizer. In the next version the normalizer moves, unchanged, into the npnormalize package, which npmatch then imports and re-exports. Pin this release (in renv, or with @v0.1.1) for anything that should reproduce results from before that move.
-
The normalizer signature at this release is
v1-3e7b9f9d59bbb19e. It is the signature matchdb’s 2026-10-26 release was built with, and the one npnormalize 0.1.0 reproduces. - Everything since 0.1.0 is listed below: address and suite classification, the suite identifier, and the name-normalization fixes.
Address and suite normalization
np_addr_freq()classifies each shared address, because what a shared address is decides what its evidence is worth, and counts alone cannot tell the kinds apart. It now takesname_keysand addsn_names,top_name_share,unit_coverageandclass:federated(many records, one name dominating — a national office its chapters file from, where the address matches every chapter equally and the chapter number is the only discriminator),shared(many records, many names — a fiscal sponsor or registered agent),building(many records across several recorded suites — the suite discriminates) orcluster(a handful, usually an org with its foundation). Over the unified BMF: 1,188 federated addresses holding 60,064 records, 1,319 shared holding 29,910, 1,910 buildings holding 36,327, and 134,212 clusters.np_compare()carriesaddr_class,addr_top_name_shareandaddr_unit_coverageper pair and states the class inaddr_note, which reaches the review frame, the stage 2 packet andnp_as_prompt().The suite credit is additionally gated on
unit_coverage(default 0.5): at the BMF addresses holding 10 or more records the share of records naming a suite averages 0.52 and 1,601 of them name none, so “two or more distinct suites here” can describe the data rather than the building. This withholds the credit at 445 of the 23,310 addresses listing two or more suites (1.9%).Damping the street credit at low-discrimination addresses was measured and rejected, against an earlier recommendation of our own. With the street evidence zeroed at crowded addresses, the 304,274 labeled pairs give AUC 0.9981 to 0.9981, precision 0.5669 to 0.5669, recall 0.9212 to 0.9212 and 1,898 YES-band pairs either way — not one decision changes. The geo terms combine with
max()and the top rungs sit 0.05 apart, so removing one moves a total score by 0.02; and the two crowded archetypes want opposite treatment, since at a sponsor address the name separates cleanly and damping would only pull the true match down. Seedev/MATCHDB-ADDRESS-UNIQUENESS.mdsection 9.np_addr_freq()is covered by the normalizer signature, so these changes move it again. Read the value withnp_normalize_signature()on the commit you are building from, rather than from any value quoted here. The signature is computed over the whole registered normalize path, so a change merging alongside this one moves it too: this entry previously named a value measured on the feature branch, and a one-line.np_replace_tokens()fix that landed in between made it wrong before the merge was a day old. A literal is only safe in a tagged release note, where the commit is pinned.The suite number is parsed properly, carried through to review, and used as evidence where it means something. Previously it was extracted, stripped from
street_key, and then dropped: nothing downstream saw it, so neither a human reviewer nor the stage 2 adjudicator could tell two tenants of one building apart.np_normalize()addsstreet_unit_id, the identifier alone with separators dropped (STE D-2->D2). Records are compared on it, so a BMFSTE 300and a source# 300are recognised as one office, while 320 and 380 are simply different — a suite is an identifier, and a fuzzy score between two of them means nothing.street_unitis now read from theunitcolumn (a second address line) when the street line names no suite. Ignoring it was a penalty rather than a gap: SAM keeps the suite inphysical_address_line_2,np_detect_geo()selected thestreet_unit_fullprofile on that column’s coverage, and the compared column was thenNAon every row — a unit similarity of 0 on every pair, fired precisely because the data was present.A profile requiring
unitis no longer auto-selected, because the per-profile weights are a weighted sum, where a field earns its weight only by matching and an unrecorded suite is therefore a silent penalty. Suite evidence now entersnp_score(method = "hier"), where the geo terms combine withmax()so an absent, differing or crowded suite costs a pair nothing.street_unit_fullremains available via an explicitprofile =.New
np_addr_freq()counts, over the reference, the records at each address, at each address + suite, and the distinct suites per address.np_compare()annotates every pair withgeo_unit(1 same suite / 0 different /NAunknown),addr_shared,addr_units,addr_unit_sharedand a reviewer-facingaddr_note, andnp_default_geo_weights()gainsunit = 0.97. A matching suite is credited only where it discriminates: several distinct suites on record at that address (a multi-tenant building, where an exact-address match surfaces every tenant) and that suite not itself shared by more thanshare_minrecords (a fiscal sponsor or registered agent, where a shared suite says nothing). The crowded cases are reported to the reviewer instead — “12 records share this address and suite” — in the review frame, the stage 2 packet andnp_as_prompt().The unit pattern is rebuilt from a
.np_unit_designatorstable and now requires a word boundary and, before an alphabetic id, a separator. Measured on the 1.92M-row unified BMF (dev/scan-unit-designators.R), the old pattern produced a false unit on 27,954 rows (11.3% of all extractions) by matching inside ordinary words —SHERMAN WAYgave room “AN WAY” andINDIGO BROOM LOOPkeyed as10317 INDIGO B— and in 3,636 of them the false match also masked the real unit:259 N RADNOR CHESTER RD STE 150extractedSTER, from inside CHESTER.Designators added, by volume in the BMF:
PMB,FL/FLOOR(including the postfix form,2ND FLOOR->FL 2),SPC/SPACE,LOT,TRLR,DEPT,STOP,LBBY,HNGR,BSMT,NUM/NUMBER. USPS C2’sFRNT,REAR,LOWR,UPPR,OFC,PH,PIER,KEY,SIDEandSLIPare left out because in this corpus they match street names, andNObecause it is North far more often than Number.The
#form is stripped fromstreet_keyat last: extraction allowed#and the key strip did not, so123 MAIN ST # 300recorded unit# 300and keyed as123 MAIN ST 300. Both now run off one pattern, before cleaning, which is the only point at which the#still exists.np_as_prompt()could not run at all: it addressed the pipeline column names (score,name_sim) on a review frame that renames them (total_score,name_similarity), soorder(-NULL)errored. Its test had been skipping past the call for the same reason — the id column is renamed too — and now runs.
Name normalization
- Letters that are not an accented form of another letter now fold to their ASCII spelling:
ß→SS,Ø→O,Ł→L,Đ→D,Æ→AE,Œ→OE,Þ→TH. De-accenting cannot reach them, and an unmapped letter was replaced by a space, which split the word it sat in:Straße Fundnormalized toSTRA E FUND. Folding now runs before the upper-casing, which is what let the eszett through — R’stoupper()is per-character, so it left a letter whose upper case is two letters alone. This is the list matchdb’smd_light_key()folds, so a cached reference key and a run-time query key agree character for character;np_name_rules()returns it asspecial_letters.np_normalize_signature()changes with it, so any cachedname_key/street_keyartifact must be rebuilt. - 16 more abbreviations expand, taken from networked990’s 990 name resolver and checked against the BMF: FDN / FND / FNDTN / FDTN (FOUNDATION), HOSP, MED, HLTH, HLTHCARE, MGMT, REG / RGNL (REGIONAL), CNTR, SYS / SYST and SRVCS / SRVS. PHYS is deliberately left alone (in the BMF it is mostly physical education). Because this changes
name_key, it can change which EINs stage 1 matches, andnp_normalize_signature()changes with it. -
np_normalize()addsname_core, an exact-lookup key that drops connector words and legal forms anywhere in the name but keeps FOUNDATION, TRUST and FUND, so a charity and its foundation get different keys.np_name_core()computes it for raw strings. -
np_normalize()addsname_placeholder,TRUEfor names that are not names (“SEE SCHEDULE O”, “N/A”, no letters).np_block()gives such query rows no candidates.np_name_placeholder()checks raw strings. -
np_name_rules()returns the normalization tables, for porting the rules to another engine such as a DuckDB SQL macro.
npmatch 0.1.0 (v0)
First tagged release (git tag v0.1.0). It pins the code that produced the combined 2025NOV + 2026MAY SAM-to-BMF crosswalk, so that crosswalk can be reproduced and cited against a fixed version of the package.
Results of the pinned run
Of 175,053 SAM nonprofit registrants, 141,420 (80.8%) are matched to an IRS BMF EIN:
| matched by | matches | share of matches |
|---|---|---|
| stage 1 — probabilistic cascade | 121,972 | 86.2% |
| stage 2 — LLM adjudication of MAYBEs | 12,443 | 8.8% |
| stage 3 — LLM research of NOs | 7,005 | 5.0% |
The 33,633 unmatched registrants are mostly explained by stage 3: 12,892 are not nonprofits, 18,770 are nonprofits absent from this BMF vintage (mostly churches and foreign organizations), and 1,971 could not be determined.
What is in this release
Matching engine
- A full record-linkage pipeline against the BMF:
np_query()/np_reference()→np_normalize()→np_block()→np_compare()→np_score()→np_veto()→np_select()→np_tier()→np_route(), orchestrated bynp_cascade()(multi-pass) ornp_match()(single pass). - Nonprofit-specific normalization: abbreviation expansion, legal-suffix stripping, legal-form capture, and extraction of embedded numbers, ordinals and directions.
np_normalize_signature()lets a cached artifact detect when normalization has drifted. - Progressive blocking passes: four exact name/DBA passes, then IDF-weighted name-token blocking within a state, a de-spaced-compound pass, and a cross-state pass.
np_ref_index()tokenizes the reference once for reuse across passes and batches. - Hierarchical scoring (
method = "hier"): name similarity plus the strongest confirmed geographic granularity, with a state-aware promotion for rare exact names. - A do-not-match veto engine (
np_default_rules()): hard vetoes for number, ordinal and direction conflicts and for-profit legal forms; soft vetoes (cap at MAYBE) for affiliate-suffix mismatches and government entities. - YES / MAYBE / NO tiering with a near-tie margin and soft-veto demotion.
Running a project
- Per-run project scaffolding:
np_project_init(),np_project_use(),np_project_path()andnp_project_status(). - Stage drivers that share one outcome schema (
np_stage_schema()):np_stage1_run()(batched cascade vianp_run_batches()),np_stage2_run()(MAYBE adjudication hand-off),np_stage3_run()(NO research, with thenp_entity_gate()screen) andnp_final_run()(crosswalk, evaluation frame and funnel report). -
np_merge_decisions()joins stage-2 adjudications back onto the review frame.
Review, labeling and training
- Candidate-level review frames and LLM prompts:
np_route(),np_as_prompt()/np_as_prompts(),np_review_report()(static HTML review report),np_label_frame(),np_calibration_frame()andnp_veto_audit(). - Model fitting and evaluation:
np_train(),np_benchmark(),np_auc(),np_evaluate()andnp_tune_thresholds(). - Bundled data:
npmatch_evalandnpmatch_groundtruth, a hand-labeled 1,502-organization benchmark.
Data management
- A data store with a manifest (
np_data_init(),np_manifest()), source fetchers for the BMF and SAM extracts (np_fetch_bmf(),np_fetch_sam(),np_prepare_sam()), incremental diffs (np_diff_unmatched()) and run reports (np_run_report()). - Geography helpers: city aliases, HUD crosswalks, RUCA urbanicity and ZIP lookups.
Documentation
- Articles: Workflow, Reviewer guide, Candidate evaluation frame, Training dataset and Building a classifier, published with the pkgdown site.
- Reviewer workbook builds under
dev/review-workbooks/: per-state themed evaluation workbooks, plus the stage-3 secondary review workbooks (all states, per state and zipped).
Known issues
-
Blocking misses. Of the 4,925 stage-3 matches that stage 1 never surfaced, 3,189 were never generated by blocking, 1,507 were scored but missed the shortlist, and 229 were hard-vetoed.
final_ein_in_candset = 0therefore mixes blocking, scoring and veto misses rather than marking blocking failures alone; the stage-3 secondary review separates them instage1_pair_status. - Word-order sensitivity. Name similarity is Jaro-Winkler on the normalized name, so a reordered name (e.g. “BERNARDINO TELACU HOUSING SAN II” vs “TELACU HOUSING SAN BERNARDINO II”) can fall below the 0.85 floor. The order-insensitive overlap recovery only runs when the address already matches.
-
Fully vetoed registrants. For 5,226 registrants, stage 1 scored candidate pairs but the hard veto removed every one. They are flagged
fully_vetoedin the secondary review for inspection. Among the 229 vetoed stage-3 matches, the for-profit legal-form rule accounts for 183. -
Rollup anomaly. 126
stage3_researchrows in the combined evaluation frame areNO_MATCHwith an EIN but a blankfinal_ein; synthetic rows should only exist for matched registrants. -
candidate_source = "none"rows come from the combined-run build, which is not part of the package; that value is not yet documented in the reviewer data dictionary.