feat(scoring): federal funder precedent via USASpending (ALN-level)

Precedent was always a valid criterion for federal grants — the index
just didn't cover them. Now: ingest captures ALN/CFDA numbers from
fetchOpportunity (grants.alns); nightly federalPrecedent workflow
(04:45) queries USASpending award search per distinct program (free
official API, 3-year NH lookback) and stamps program_state_award_count
+ sample recipients onto open grants (multi-ALN keeps highest). Match
retrieval feeds the same funderStateGrantCount input and 25-point tiers
foundations use; detail page shows the recipients-evidence table with
an incumbent-renewal caution.

Also: detail refresh now rotates oldest-verified-first
(serverMapGrantVerification) — the Set-based partition re-fetched the
same 200 every pass, leaving 365/565 grants ALN-less.

Live: 564/564 grants ALN-tagged, 156 programs swept, 85 with NH
history, 468 grants carrying precedent, first federal easy-wins (66pts,
25/25 precedent, Aug-24 deadline). 158 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-16 23:18:02 -04:00
parent 4fa0bb1c32
commit 88affbeb4f
22 changed files with 1995 additions and 23 deletions

View File

@@ -290,11 +290,15 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
{nhHistory.length > 0 && (
<section className="rounded border p-4">
<h2 className="mb-1 text-lg font-semibold">
Funder giving history (from 990-PF filings)
{grant.source === 'irs_990pf'
? 'Funder giving history (from 990-PF filings)'
: 'Recent program awards to NH recipients (USASpending)'}
</h2>
<p className="mb-3 text-sm text-gray-600">
The concrete evidence behind the precedent score — who this
funder actually paid, sorted in-state first.
The concrete evidence behind the precedent score —{' '}
{grant.source === 'irs_990pf'
? 'who this funder actually paid, sorted in-state first.'
: 'who this federal program actually funded in NH recently. Watch for one incumbent recapturing renewals vs. genuine spread across orgs.'}
</p>
<table className="w-full text-sm">
<thead>

View File

@@ -33,6 +33,7 @@ import pg from 'pg';
import { setEmbedGrantsDeps } from './workflows/embed-grants.js';
import { setEnrichOrgsDeps } from './workflows/enrich-orgs.js';
import { setExpireGrantsDeps } from './workflows/expire-grants.js';
import { setFederalPrecedentDeps } from './workflows/federal-precedent.js';
import { setIngestGrantsDeps } from './workflows/ingest-grants.js';
import { setIngest990pfDeps } from './workflows/ingest-990pf.js';
import { setIngestNhdojOrgsDeps } from './workflows/ingest-nhdoj-orgs.js';
@@ -71,6 +72,7 @@ async function main() {
setEmbedGrantsDeps({ db });
setMatchGrantsDeps({ db });
setIngest990pfDeps({ db });
setFederalPrecedentDeps({ db });
DBOS.setConfig({
name: 'helmdocs-outreach-worker',

View File

@@ -28,6 +28,10 @@ import {
runExpireGrantsNow,
setExpireGrantsDeps,
} from './workflows/expire-grants.js';
import {
runFederalPrecedentNow,
setFederalPrecedentDeps,
} from './workflows/federal-precedent.js';
import {
runIngestGrantsNow,
setIngestGrantsDeps,
@@ -58,6 +62,7 @@ const RUNNERS: Record<string, () => Promise<void>> = {
embedGrants: runEmbedGrantsNow,
matchGrants: runMatchGrantsNow,
ingest990pf: runIngest990PfNow,
federalPrecedent: runFederalPrecedentNow,
};
const FIRST_RUN_ORDER = [
@@ -67,6 +72,7 @@ const FIRST_RUN_ORDER = [
'expireGrants',
'enrichOrgs',
'ingest990pf',
'federalPrecedent',
'embedGrants',
'matchGrants',
];
@@ -106,6 +112,7 @@ async function main() {
setEmbedGrantsDeps({ db });
setMatchGrantsDeps({ db });
setIngest990pfDeps({ db });
setFederalPrecedentDeps({ db });
DBOS.setConfig({
name: 'helmdocs-outreach-worker',

View File

@@ -82,6 +82,11 @@ export interface GrantsGovOpportunityDetail {
readonly agencyCode?: string | null;
} | null;
readonly synopsis: GrantsGovSynopsis | null;
/** Assistance Listing (CFDA) entries, e.g. [{ cfdaNumber: '93.243' }]. */
readonly cfdas?: ReadonlyArray<{
readonly cfdaNumber?: string | null;
readonly programTitle?: string | null;
}> | null;
}
interface GrantsGovDetailResponseEnvelope {

View File

@@ -9,6 +9,7 @@ import {
searchHitFixture,
} from '#~/sources/grants-gov/fixtures.js';
import {
normalizeAlns,
normalizeApplicantTypes,
normalizeGrantsGovOpportunity,
parseCount,
@@ -232,3 +233,23 @@ describe('normalizeApplicantTypes', () => {
expect(normalizeApplicantTypes([])).toBeNull();
});
});
describe('normalizeAlns', () => {
it('extracts distinct valid ALN numbers', () => {
expect(
normalizeAlns([
{ cfdaNumber: '93.243' },
{ cfdaNumber: ' 93.243 ' },
{ cfdaNumber: '16.888' },
{ cfdaNumber: 'garbage' },
{ cfdaNumber: null },
]),
).toEqual(['93.243', '16.888']);
});
it('returns null for empty or missing lists', () => {
expect(normalizeAlns(null)).toBeNull();
expect(normalizeAlns([])).toBeNull();
expect(normalizeAlns([{ cfdaNumber: 'x' }])).toBeNull();
});
});

View File

@@ -98,6 +98,24 @@ export function stripHtml(html: string | null | undefined): string | null {
}
/** Extracts non-empty eligibility descriptions from `synopsis.applicantTypes`. */
/** Distinct, trimmed ALN/CFDA numbers ('93.243') from the detail's cfdas list. */
export function normalizeAlns(
cfdas:
| ReadonlyArray<{ readonly cfdaNumber?: string | null }>
| null
| undefined,
): string[] | null {
if (cfdas == null) return null;
const nums = [
...new Set(
cfdas
.map((c) => c.cfdaNumber?.trim())
.filter((v): v is string => v != null && /^\d{2}\.\d{3}$/.test(v)),
),
];
return nums.length > 0 ? nums : null;
}
export function normalizeApplicantTypes(
applicantTypes: ReadonlyArray<GrantsGovApplicantType> | null | undefined,
): string[] | null {
@@ -162,6 +180,7 @@ export function normalizeGrantsGovOpportunity(
title: detail.opportunityTitle ?? hit.title,
funder: resolveFunder(hit, detail),
synopsis: stripHtml(synopsis?.synopsisDesc),
alns: normalizeAlns(detail.cfdas),
eligibilityEntityTypes: normalizeApplicantTypes(
synopsis?.applicantTypes,
),

View File

@@ -0,0 +1,93 @@
/**
* USASpending.gov award-search client — the federal analog of the 990-PF
* grants-paid index. Official API, free, no key.
* Docs: https://api.usaspending.gov/docs/endpoints
*/
const SEARCH_URL =
'https://api.usaspending.gov/api/v2/search/spending_by_award/';
/** Grant-shaped assistance award type codes (block/formula/project/coop). */
const GRANT_AWARD_TYPE_CODES = ['02', '03', '04', '05'];
const DEFAULT_POLITENESS_DELAY_MS = 300;
export interface ProgramStateAwardSample {
readonly recipientName: string;
readonly amount: number | null;
readonly startDate: string | null;
}
export interface ProgramStateAwards {
readonly aln: string;
readonly awardCount: number;
readonly samples: ProgramStateAwardSample[];
}
export interface UsaSpendingClientOptions {
readonly fetchImpl?: typeof fetch;
readonly politenessDelayMs?: number;
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Recent grant awards under one Assistance Listing to recipients in one
* state: total count plus a handful of sample recipients (review-page
* evidence — lets a human spot "one incumbent's renewals" vs "spread
* across orgs like ours").
*/
export async function fetchProgramStateAwards(
aln: string,
state: string,
{ startDate, endDate }: { startDate: string; endDate: string },
options: UsaSpendingClientOptions = {},
): Promise<ProgramStateAwards> {
const fetchImpl = options.fetchImpl ?? fetch;
const res = await fetchImpl(SEARCH_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filters: {
award_type_codes: GRANT_AWARD_TYPE_CODES,
recipient_locations: [{ country: 'USA', state }],
time_period: [{ start_date: startDate, end_date: endDate }],
program_numbers: [aln],
},
fields: ['Award ID', 'Recipient Name', 'Award Amount', 'Start Date'],
limit: 10,
page: 1,
}),
});
if (!res.ok) {
throw new Error(
`USASpending search failed for ALN ${aln} (${state}): ${res.status} ${res.statusText}`,
);
}
const body = (await res.json()) as {
results?: Array<{
'Recipient Name'?: string | null;
'Award Amount'?: number | null;
'Start Date'?: string | null;
}>;
page_metadata?: { total?: number | null };
};
await wait(options.politenessDelayMs ?? DEFAULT_POLITENESS_DELAY_MS);
const samples: ProgramStateAwardSample[] = (body.results ?? []).map((r) => ({
recipientName: r['Recipient Name'] ?? 'Unknown recipient',
amount: r['Award Amount'] == null ? null : Math.round(r['Award Amount']),
startDate: r['Start Date'] ?? null,
}));
return {
aln,
awardCount: body.page_metadata?.total ?? samples.length,
samples,
};
}

View File

@@ -0,0 +1,194 @@
/**
* Nightly federal-precedent workflow — USASpending award history per
* Assistance Listing (ALN/CFDA), the federal analog of the 990-PF index.
*
* For each distinct ALN across open federal grants: count grant awards to
* recipients in the target state over the last ~3 fiscal years, stamp the
* count + sample recipients onto every open grant carrying that ALN.
* Match retrieval feeds the count into the same 25-point precedent
* subscore foundations use — "this program funded N NH orgs recently" and
* "this foundation funded N NH orgs recently" are the same signal.
*
* Runs at 04:45 — after ingest (03:00, which refreshes ALNs) and before
* matching (05:15). Registration follows `ingest-grants.ts` exactly.
*/
import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk';
import type { schema } from '@novelpad/outreach-core';
import {
serverListOpenFederalAlns,
serverResetFederalPrecedent,
serverSetFederalPrecedent,
} from '@novelpad/outreach-core/server';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import {
fetchProgramStateAwards,
type ProgramStateAwards,
} from '#~/sources/usaspending/client.js';
export type OutreachDb = NodePgDatabase<typeof schema>;
const RECIPIENT_STATE = 'NH';
/** ~3 fiscal years of history — recent enough to predict, long enough to see a pattern. */
const LOOKBACK_YEARS = 3;
export interface FederalPrecedentDeps {
readonly db: OutreachDb;
}
let registeredDeps: FederalPrecedentDeps | null = null;
export function setFederalPrecedentDeps(deps: FederalPrecedentDeps): void {
registeredDeps = deps;
}
function getFederalPrecedentDeps(): FederalPrecedentDeps {
if (registeredDeps == null) {
throw new Error(
'FederalPrecedentDeps not registered. Call setFederalPrecedentDeps() before DBOS.launch().',
);
}
return registeredDeps;
}
async function listAlns(db: OutreachDb): Promise<string[]> {
return serverListOpenFederalAlns(db);
}
const listAlnsStep = DBOS.registerStep(listAlns, {
name: 'listOpenFederalAlns',
retriesAllowed: true,
maxAttempts: 3,
});
async function fetchAlnAwards(
aln: string,
startDate: string,
endDate: string,
): Promise<ProgramStateAwards> {
return fetchProgramStateAwards(aln, RECIPIENT_STATE, {
startDate,
endDate,
});
}
const fetchAlnAwardsStep = DBOS.registerStep(fetchAlnAwards, {
name: 'fetchAlnStateAwards',
retriesAllowed: true,
maxAttempts: 3,
});
async function applyPrecedent(
db: OutreachDb,
precedent: ProgramStateAwards,
): Promise<void> {
await serverSetFederalPrecedent(db, {
aln: precedent.aln,
awardCount: precedent.awardCount,
samples: precedent.samples,
});
}
const applyPrecedentStep = DBOS.registerStep(applyPrecedent, {
name: 'applyFederalPrecedent',
retriesAllowed: true,
maxAttempts: 3,
});
async function resetPrecedent(db: OutreachDb): Promise<void> {
await serverResetFederalPrecedent(db);
}
const resetPrecedentStep = DBOS.registerStep(resetPrecedent, {
name: 'resetFederalPrecedent',
retriesAllowed: true,
maxAttempts: 3,
});
async function runFederalPrecedent(): Promise<void> {
const { db } = getFederalPrecedentDeps();
const alns = await listAlnsStep(db);
if (alns.length === 0) {
console.log(
'[federal-precedent] no open federal grants carry ALNs yet (ingest refresh pending?)',
);
return;
}
const end = new Date();
const start = new Date(end);
start.setFullYear(start.getFullYear() - LOOKBACK_YEARS);
const startDate = start.toISOString().slice(0, 10);
const endDate = end.toISOString().slice(0, 10);
await resetPrecedentStep(db);
let programsWithHistory = 0;
let failed = 0;
for (const aln of alns) {
try {
const precedent = await fetchAlnAwardsStep(aln, startDate, endDate);
if (precedent.awardCount > 0) {
await applyPrecedentStep(db, precedent);
programsWithHistory++;
}
} catch (err) {
failed++;
console.error(`[federal-precedent] ALN ${aln} failed:`, err);
}
}
console.log(
`[federal-precedent] programs=${alns.length} withNhHistory=${programsWithHistory} failed=${failed}`,
);
if (failed > 0 && failed / alns.length > 0.2) {
throw new Error(
`[federal-precedent] systemic failure: ${failed}/${alns.length} programs failed`,
);
}
}
const g = globalThis as unknown as {
__outreachFederalPrecedentRegistered?: boolean;
__outreachFederalPrecedentHandle?: (
scheduledTime: Date,
startedAt: Date,
) => Promise<void>;
};
if (!g.__outreachFederalPrecedentRegistered) {
g.__outreachFederalPrecedentRegistered = true;
const federalPrecedent = async (_scheduledTime: Date, _startedAt: Date) => {
try {
await runFederalPrecedent();
} catch (err) {
console.error('[federal-precedent] pass failed:', err);
throw err;
}
};
// Must be registered as BOTH a workflow and a scheduled function,
// referencing the same function object — see ingest-grants.ts.
g.__outreachFederalPrecedentHandle = DBOS.registerWorkflow(federalPrecedent, {
name: 'federalPrecedent',
});
DBOS.registerScheduled(federalPrecedent, {
crontab: '45 4 * * *',
name: 'federalPrecedent',
mode: SchedulerMode.ExactlyOncePerInterval,
});
}
/**
* Starts one durable run of this workflow immediately through DBOS —
* the exact production path (workflow + checkpointed steps), used by
* `run-once.ts` for supervised/manual passes. Requires deps injected and
* `DBOS.launch()` completed.
*/
export function runFederalPrecedentNow(): Promise<void> {
const handle = g.__outreachFederalPrecedentHandle;
if (handle == null) {
throw new Error(
'federalPrecedent is not registered; was this module imported before DBOS.launch()?',
);
}
return handle(new Date(), new Date());
}

View File

@@ -16,7 +16,7 @@ import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk';
import type { schema } from '@novelpad/outreach-core';
import {
serverInsertGrants,
serverListGrantSourceUrls,
serverMapGrantVerification,
type NewGrantInput,
} from '@novelpad/outreach-core/server';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
@@ -100,11 +100,19 @@ async function fetchGrantDetails(
// hits.slice(0, cap) re-fetched the same head of the search results
// every night and never drained the backlog. Known opportunities fill
// any remaining budget (refreshing close dates / lastVerifiedAt).
const known = await serverListGrantSourceUrls(db, 'grants_gov');
const isKnown = (hit: GrantsGovSearchHit) =>
known.has(`https://www.grants.gov/search-results-detail/${hit.id}`);
const fresh = hits.filter((h) => !isKnown(h));
const refresh = hits.filter(isKnown);
const known = await serverMapGrantVerification(db, 'grants_gov');
const urlOf = (hit: GrantsGovSearchHit) =>
`https://www.grants.gov/search-results-detail/${hit.id}`;
const fresh = hits.filter((h) => !known.has(urlOf(h)));
// Oldest-verified first so refreshes rotate through the whole corpus
// instead of re-fetching the same head of the search results.
const refresh = hits
.filter((h) => known.has(urlOf(h)))
.sort(
(a, b) =>
(known.get(urlOf(a))?.getTime() ?? 0) -
(known.get(urlOf(b))?.getTime() ?? 0),
);
const toFetch = [...fresh, ...refresh].slice(0, DETAIL_FETCH_CAP);
console.log(
`[ingest-grants] detail budget ${DETAIL_FETCH_CAP}: ${Math.min(fresh.length, DETAIL_FETCH_CAP)} new, ${Math.max(0, Math.min(DETAIL_FETCH_CAP - fresh.length, refresh.length))} refresh, backlog remaining ${Math.max(0, fresh.length - DETAIL_FETCH_CAP)}`,