Skip to contents

A completed run has resolved hundreds of thousands of grants. The obvious next question is whether that output is itself a database: take the cases cleared YES, treat them as the thing to match against, and see whether new cases clear.

The answer is yes, with a specific shape and a specific limit. It adds about a point to the clear rate on the benchmark, it is nearly free to build, and it cannot discover an organisation it does not already contain. This article covers what the mechanism is, where in the workflow it runs, what it is worth, and what its measured precision is – which, for half the recoveries, is still unknown.

vignette("matching-dictionaries") puts this pass beside the alternatives it competes with – the active BMF, the augmented BMF, and matchdb’s published tiers – and is the article to read when choosing which database to run against. This one is about the YES tier alone.

1. Two things the question could mean

reading keyed on built by verdict
The BMF rows of the EINs you cleared the BMF’s registered legal name and mailing address subsetting the production reference bundle a diagnostic: it can only remove competitors
The grant renderings you cleared the recipient name and address as the filer wrote them pf_grant_dictionary() the one that ships: it adds keys the BMF does not have

Both were measured, and the difference between them is the whole result. The first can only ever remove competitors – every row in it is already in the production reference, so it reaches no EIN the full reference could not. The second introduces keys the BMF does not hold at all, because filers do not write registered legal names.

Section 2 reports the first, briefly, because it is a useful diagnostic and because it is what the question usually means. Sections 3 onward are the mechanism that ships.

A note on the word tier, which this project overloads. A match tier is YES / MAYBE / NO, the cascade’s verdict on one grant. A matchdb tier is a published, pre-compiled reference. “The YES tier as a dictionary” means the first kind.

2. The subset reference, and what a subset can do

Take every EIN the benchmark’s augmented-BMF arm cleared YES – 2,979 identities over 2,813 distinct EINs – and carve their BMF rows out of the production bundle: 9,253 rows, 0.2385% of 3,880,431. Carved rather than rebuilt, so the keys are byte-identical and the comparison measures competition rather than normalization.

Re-matching the benchmark’s own 4,748 in-scope rows against it:

Full-reference tier (rows) against mini-reference tier (columns)
full reference no candidate YES MAYBE NO
YES (2,979) 5 2,963 3 8
MAYBE (1,044) 642 35 83 284
NO (725) 565 2 3 155

The result worth having is 35 MAYBE to YES, and in 17 of them (48.6%) the subset clears to the same EIN the full reference already held as its own top candidate. On those the production reference had the right answer and the margin gate would not release it, because competitors kept the margin tight. That is 1.6% of the MAYBE tier, and it is direct support for the reference-side duplicate finding – 15.9% of the MAYBE tie band is two BMF rows carrying one name for one body. The other 18 clear to a different EIN, or the full reference had no candidate at all; a subset cannot know better than the full reference, so those are for hand review, not adoption.

Two findings come free with it, and both are worth keeping.

Shrinking a reference is not monotone. 16 of the 2,979 full-YES rows (0.5%) stop clearing against the subset – 11 drop to MAYBE or NO, 5 find no candidate – and 1 clears to a different EIN. Removing rows changes name frequencies and candidate sets, not just the candidate pool.

Running it over fresh grants measures coverage, not accuracy. 50,000 fresh grant rows against the same 9,253-row subset:

fresh grant rows matched 50,000
identities 40,977
YES 1,546 (3.77%)
MAYBE 734 (1.79%)
NO 38,697 (94.44%)
time 6.2 min (0.009 s/identity)
precision control: 400 mini-YES re-matched in full
… full reference also YES, same EIN 352 (88.0%)
… full reference clears a different EIN 16
… full reference does not clear at all 32

So 3.77% of fresh identities go to one of these 2,813 organisations – about 29x enrichment over uniform, since 2,813 is 0.13% of roughly 2.19M distinct recipient names, but far less concentrated than the headline recipients suggest. And the precision control says not to quote 3.77% as accuracy: 88.0% survive contest against the full reference, so about 3.3% is the contest-surviving figure.

The conclusion: new YES cases do surface, but they are new grants to organisations already resolved, not new organisations. A subset reference cannot discover an EIN it does not contain. The finding worth keeping is the 17 competition-blocked cases, and the way to pursue those is the reference-side duplicate work, not a bigger batch.

3. The mechanism that ships: a grant-side dictionary

Everything above keyed on the BMF’s own strings. The asymmetry that makes the YES tier worth keeping is that filers do not write those strings. Foundations write recipient names by a convention of their own; the BMF carries the registered legal name. So a grant the BMF cannot clear may still match a grant that already cleared.

Over the 312,306 cleared rows of the triangulation run that hold both strings:

a cleared row… n share
hits another cleared grant rendering exactly 293,742 94.06%
hits its own BMF name exactly 147,290 47.16%
grant-exact but not BMF-exact 150,286 48.12%
BMF-exact but not grant-exact 3,834 1.23%
neither 14,730 4.72%

94% of cleared rows are exactly some other cleared rendering of the same organisation, against 47% identical to the BMF name – and nearly half are grant-exact while not BMF-exact. That is the gain, and it is a gain of frequency, not similarity: on fuzzy distance the grant side is marginally worse (median 0.960 to the nearest other rendering against 0.967 to the BMF name, and only 31.7% of distinct renderings are closer to a grant than to the BMF). A common rendering repeats, so an arriving grant often hits an existing one exactly. It shows up as exact-key recall, and that distinction governs everything below.

Where it pays hardest is the acronym and the colloquial name, which the legal name does not resemble at all – similarity to the nearest other grant rendering against similarity to the BMF name:

rendering nearest grant BMF
THE YARD 0.933 0.000
USO 0.789 0.000
THE ARC 0.927 0.423

The key must include the address

Keying on the name alone does not work, and the margin is not close:

key distinct keys carrying >1 EIN worst
bare name key 63,310 727 (1.15%) HABITAT FOR HUMANITY, 39 EINs
(name key, ZIP5) 66,425 0 (0.00%) none

RONALD MCDONALD HOUSE carries 31 EINs on a bare name key and BIG BROTHERS BIG SISTERS 29. Adding ZIP5 takes the ambiguity to zero. pf_grant_dictionary() still drops any (name_key, zip5) that resolves to more than one EIN rather than guessing, and reports the count in the ambiguous_dropped attribute – real data never trips it, but a silent collision there would attach one organisation’s EIN to another’s grants, which is the one failure this must not have.

What it costs to build

cleared grant rows in 369,260
distinct (rendering, address, EIN) 88,750
EINs 31,722
ambiguous keys dropped 0
build time 24.4 s
size on disk 63.9 MB
read back 0.9 s

Nearly free, in other words, and it reads back in under a second. At full scale the pass is roughly 12 hours single-stream against roughly 186 hours for the cascade it supplements. Its real cost is not compute; it is the queue in section 6.

4. Where it runs in the workflow

After the cascade, on what the cascade could not clear, and never instead of it. pf_recover_from_dictionary() enforces that by only ever retrying rows whose status is not YES.

pf_prepare()        read, standardize, resolve the two-line name
  pf_dedup()        collapse grant rows to recipient identities
pf_reference()      the database: matchdb active-observed by default
pf_link()           the staged blocking cascade -> YES / MAYBE / NO
  |
  |--- YES rows ------> pf_grant_dictionary()      build from the cleared tier
  |                             |
  '--- MAYBE + NO ------> pf_recover_from_dictionary()
                                |
                                |--> corroborated: status becomes YES
                                '--> uncorroborated: the `queue` attribute
pf_adjudicate()     the MAYBE sheet, and now the queue beside it

In code, after a run:

run  <- pf_run(grants, dir = "data-dev/batches")

# 1. the dictionary, from this run's own YES tier. A list carrying $grants is
#    accepted, so `pf_grant_dictionary(run)` works too.
dict <- pf_grant_dictionary(run$grants, keep = "YES")

#    `exclude` is for the case where the rows you are about to match are rows
#    the dictionary was built from -- a benchmark re-run, say. Without it the
#    dictionary answers those queries with themselves.
#      pf_grant_dictionary(run$grants, exclude = benchmark_ids,
#                          key_col = "grant_id")

# 2. retry only what did not clear, and adopt only what the BMF corroborates
out  <- pf_recover_from_dictionary(run$status, dict, run$recipients,
                                   accept = "agree")

attr(out, "counts")   # retried / dict_yes / agreeing / accepted
attr(out, "queue")    # the uncorroborated claims, for adjudication

Three details of placement are deliberate.

It is a supplement, not a reference. A dictionary from one run holds the EINs that run resolved – 31,721 against the BMF’s roughly 1.9M organisations – and reproduces only 794 of the augmented BMF’s 2,979 YES rows, 26.7%. Run as a reference it is strictly worse, and 37 BMF-YES rows clear to a different EIN against it. It belongs after the cascade or nowhere.

keep = "YES" is the default for a reason. A dictionary seeded from MAYBE would propagate exactly the decisions that are in doubt.

Statistics are not borrowed silently. The IDF of roughly 90k filer renderings is not the IDF of 3.88M BMF names, and which one is right is an open question. stats = "dictionary" computes name frequency and token IDF over the dictionary itself; passing a reference bundle borrows its tables instead. The argument exists so the two effects are never confounded.

It is not yet wired into pf_run(). The step on record is to add it as an optional stage after the cascade, off by default, with the queue surfaced beside the MAYBE sheet rather than merged into it; until then it is an explicit call, as above.

5. What it is worth

On the cases the BMF left unresolved, as exact-key recall. The unit here is the benchmark’s in-scope grant row (4,748 of them: 2,979 YES, 1,044 MAYBE, 725 NO):

BMF tier dictionary clears agreeing with the BMF’s top candidate
MAYBE (1,044) 115 (11.0%) 47 (40.9%)
NO (725) 10 (1.4%)

End to end through the package API, on the same population at the recipient-identity unit (4,683 identities, of which the BMF run cleared 2,865), after a full augmented-BMF run. The two tables are two units, not two answers – that is why the counts differ:

accepted clear rate queued precision
BMF run alone
61.18%
measured elsewhere
accept = "agree" (default) 42 62.08% (+0.90) 64 inherited, corroborated
accept = "all" 106 63.44% (+2.26) 0 inherited, unknown

1,818 unresolved identities were retried and 106 cleared. 42 of those agreed with the EIN the BMF reference had already surfaced as its own top candidate – recoverable all along, with the margin gate refusing to release them.

The honest framing of +0.90 points: it is below the “every remaining lever moves less than 1% of grants” bar the project set for itself, and the trustworthy half only just reaches it. What makes it worth running anyway is the absolute count at scale. Roughly 37% of 17.25M grant rows go unresolved, so one point of clear rate is on the order of 170,000 grants – a large number from a rate that does not move.

6. The acceptance rule, and why there is one

Every EIN in the dictionary came from this pipeline. Resolving against it propagates the pipeline’s own decisions, errors included, so it is a recall device whose precision is inherited rather than established. That is what accept is for.

  • "agree" (default) promotes a row only where the dictionary’s EIN is the one the BMF run already held as its own top candidate. NA counts as a disagreement, not an agreement: a row the BMF could not name a candidate for has nothing to corroborate the dictionary.
  • "all" promotes every dictionary YES. For measurement, not production – its precision is the unknown that separates +0.90 from +2.26.
  • "none" annotates and promotes nothing.

The disagreements are not a new failure mode. They concentrate in the known affiliate problem – an organisation against its own foundation or alumni fund (YALE UNIVERSITY to YALE UNIVERSITY ALUMNI FUND), a chapter network picking the wrong chapter (ANY BABY CAN to a different chapter). That is the same population the dyad and affiliation work addresses, so the queue lands in work already underway rather than opening a new front.

Adjudicating one queue is the open step that matters: 64 uncorroborated claims per 4,748 rows are the only way to turn an inherited precision into a measured one. The route is the same adjudication the MAYBE tier uses, described in vignette("adjudicating-maybe").

7. It does not get better with scale – in rate terms

The expectation was large gains once millions of cases have cleared: a 3M-row dictionary being a substantive database. That was tested directly, with one fixed query set – the benchmark’s 1,769 unresolved rows – against nested random dictionaries from 2% to 100% of the 369,160-row cleared pool.

share cleared rows renderings EINs rend/EIN dict YES agreeing points agreeing per 100k new rows
2% 7,383 6,240 4,933 1.265 66 23 0.484
5% 18,458 13,500 9,267 1.457 77 31 0.653 72.23
10% 36,916 22,985 13,845 1.660 85 34 0.716 16.25
25% 92,290 42,613 21,155 2.014 109 42 0.885 14.45
50% 184,580 63,254 26,725 2.367 126 48 1.011 6.50
100% 369,160 88,744 31,721 2.798 126 50 1.053 1.08

A fifty-fold increase in dictionary rows roughly doubles the recoveries, 23 to 50, and the last doubling of the dictionary bought two. Raw dict YES saturated outright: 126 at half the pool and 126 at all of it.

The mechanism explains it. Recovery needs a cleared rendering of the same organisation, so the quantity that matters is renderings per organisation – and that grows logarithmically, 1.265 to 2.798 across a fifty-fold increase in rows. The tenth foundation to write USO adds nothing.

Two corollaries, and the first is easy to get backwards. The rate saturates at about 1.05 points and is reached by half of a single run, so more cleared data will not raise the percentage. But at full scale the query side grows too – about 6.4M unresolved candidates rather than 1,769 – so the absolute yield is large. Build it for the volume, not for the percentage. And because the rate saturates so early, the pass can be developed and tuned against a small dictionary without waiting for a full run.

8. What none of this establishes

The precision of accept = "all". Unknown, and it is exactly the difference between +0.90 and +2.26 points. The 64 queued cases per 4,748 rows are the measurement that would settle it.

That the dictionary’s own YES rows are right. They are the pipeline’s output. Nothing in this pass validates them; it propagates them.

Which seed a production dictionary should use. Three candidates, and the choice is not obvious: the triangulation run’s cleared rows (what was measured here, but drawn from multi-rendering addresses, so it over-represents shared addresses and probably chapters – likely why the disagreements skew to affiliates); a corpus-representative run’s cleared rows; or the hand-adjudicated ground truth only, which is tiny but carries no inherited error. The quantity to choose on is the disagreement rate by seed.

That the 3.77% coverage figure in section 2 is accuracy. It is not; 88% of it survives contest.

Reproducing

# the subset-reference arms of section 2
#   PFMATCH_DIR=. PF_EXP_ARM=R Rscript dev/experiment-head-reference.R
#   PFMATCH_DIR=. PF_EXP_ARM=C Rscript dev/experiment-head-reference.R
#
# the pass, end to end through the package API
#   Rscript dev/experiment-grant-dictionary.R
#
# the scaling curve of section 7
#   Rscript dev/experiment-dictionary-curve.R