feat(scoring): 990-PF funder-precedent index — the 25-point subscore goes live
New funders/funder_grants schema + ingest990pf monthly workflow: IRS BMF state file discovers NH private foundations (747), e-file index CSVs select their latest 990-PF filings, batch ZIPs stream through fflate (4/run cap, most-hits-first, deferred logged), grants-paid rows land in funder_grants, and funders with >=2 NH grants synthesize rolling grant rows (source irs_990pf, funder_ein linked) that flow through the existing embed+match pipeline. Scoring v2: funderPrecedentSubscore tiers repeated in-state giving (1/3/5/10 -> 8/15/20/25); easy win = >=65 total AND >=12 precedent (plan's precedent floor); scale is the full 0-100. Rolling deadlines pass the runway gate. Retrieval computes per-funder in-state counts and exposes funder_ein. Lead-quality gates from the first precedent run's failures: candidate orgs exclude NTEE T* grantmakers; self-matches gated by EIN + normalized name (NHDOJ registers foundations as charities, several without resolved EINs — the first run's top 'leads' were foundations matched to themselves). Live: ~6.5GB of IRS batches processed, 2,766 grants-paid rows, 123 synthesized foundation grants, 89 easy wins across 27 orgs, credible top-10 (AIDS Response-Seacoast -> Foundation for Seacoast Health, 25/25 precedent). 153 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -108,8 +108,10 @@ const ensureOrgEmbeddingStep = DBOS.registerStep(ensureOrgEmbedding, {
|
||||
async function retrieveGrants(
|
||||
db: OutreachDb,
|
||||
embedding: number[],
|
||||
orgState: string,
|
||||
): Promise<EligibleGrantWithSimilarity[]> {
|
||||
return serverListEligibleGrantsForOrg(db, embedding, {
|
||||
orgState,
|
||||
minDaysToDeadline: MIN_DAYS_TO_DEADLINE,
|
||||
minAwardCeiling: MIN_AWARD_CEILING,
|
||||
limit: GRANTS_PER_ORG,
|
||||
@@ -121,6 +123,14 @@ const retrieveGrantsStep = DBOS.registerStep(retrieveGrants, {
|
||||
maxAttempts: 3,
|
||||
});
|
||||
|
||||
/** Case/punctuation/suffix-insensitive equality for self-match detection. */
|
||||
function normalizeSelfMatchName(name: string): string {
|
||||
return name
|
||||
.toUpperCase()
|
||||
.replace(/\b(INC|INCORPORATED|TTEE|TRUSTEE|FUND|FOUNDATION|CHARITABLE|TRUST)\b/g, '')
|
||||
.replace(/[^A-Z0-9]/g, '');
|
||||
}
|
||||
|
||||
async function scoreAndStoreOrgMatches(
|
||||
db: OutreachDb,
|
||||
org: MatchCandidateOrg,
|
||||
@@ -131,6 +141,22 @@ async function scoreAndStoreOrgMatches(
|
||||
let gated = 0;
|
||||
|
||||
for (const grant of grants) {
|
||||
// A foundation's synthesized grant must never match the foundation's
|
||||
// own org row ("we found you $50K — from yourself"). EIN when both
|
||||
// sides have one; normalized-name fallback because many NHDOJ org rows
|
||||
// haven't resolved an EIN yet — a self-match is worse than a missed
|
||||
// match here.
|
||||
const isSelfByEin =
|
||||
grant.funderEin != null &&
|
||||
org.ein != null &&
|
||||
grant.funderEin === org.ein;
|
||||
const isSelfByName =
|
||||
grant.funderEin != null &&
|
||||
normalizeSelfMatchName(grant.funder) === normalizeSelfMatchName(org.name);
|
||||
if (isSelfByEin || isSelfByName) {
|
||||
gated++;
|
||||
continue;
|
||||
}
|
||||
const gates = evaluateHardGates(
|
||||
{ entityType: ASSUMED_ENTITY_TYPE, state: org.state },
|
||||
{
|
||||
@@ -153,6 +179,7 @@ async function scoreAndStoreOrgMatches(
|
||||
|
||||
const scored = scoreMatch({
|
||||
similarity: grant.similarity,
|
||||
funderStateGrantCount: grant.funderStateGrantCount,
|
||||
orgTotalRevenue: org.totalRevenue,
|
||||
awardCeiling: grant.awardCeiling,
|
||||
geographicScope: grant.geographicScope,
|
||||
@@ -173,7 +200,7 @@ async function scoreAndStoreOrgMatches(
|
||||
gateFailures: gates.failures,
|
||||
ignoredGates: [...IGNORED_GATES],
|
||||
scoredAt: now.toISOString(),
|
||||
scoringVersion: 'v1-no-precedent',
|
||||
scoringVersion: 'v2-state-precedent',
|
||||
},
|
||||
});
|
||||
stored++;
|
||||
@@ -203,7 +230,7 @@ async function runMatchGrants(): Promise<void> {
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
const embedding = await ensureOrgEmbeddingStep(db, org);
|
||||
const grants = await retrieveGrantsStep(db, embedding);
|
||||
const grants = await retrieveGrantsStep(db, embedding, org.state);
|
||||
const { stored, gated } = await scoreAndStoreOrgMatchesStep(
|
||||
db,
|
||||
org,
|
||||
|
||||
Reference in New Issue
Block a user