feat(scoring): v4 — peer precedent, mission-fit floor, LLM match judge

Fixes the federal mismatch class (boys' camp × NIH research center):

- Peer precedent: federalPrecedent paginates USASpending (≤500 awards/
  program) and name-matches every recipient against primary-ICP NH
  registry orgs (shared normalizeOrgNameForMatching, also used by the
  self-match gate). The 25-pt precedent tiers now key off
  program_state_peer_award_count — Dartmouth renewals and SBIR LLCs no
  longer grant precedent to community nonprofits. Raw count + peer-
  annotated award list stay as review evidence (peer badges, peers-first).
- Mission-fit floor (12/30, grants_gov only): below it a match is stored
  with fit_viable=false and hidden from the pending queue, hero selection,
  and easy-win. Foundation-synthesized grants exempt (generic synopses).
- Mission-fit judge live (judgeMatches, 06:15, 200/night best-first):
  JUDGE_MODEL reads the synopsis against the org profile with an explicit
  ignore-eligibility-breadth instruction; graded verdict with required
  citations; deterministic verdict→points map (27/18/8/0) sets missionFit,
  total, easy-win, and viability. Verdicts survive nightly re-scores via
  an upsert splice and re-enter the judge queue when the org profile is
  re-researched (org_profiles.updated_at).

First sweep: 81/149 programs have NH history, only 6 have peer history;
queue-head judging zeroes the research-mechanism garbage (mismatch) while
surfacing genuine strong fits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-17 10:52:50 -04:00
parent 7cefbbbfa1
commit cbc4512ffa
37 changed files with 4379 additions and 163 deletions

View File

@@ -81,6 +81,13 @@ export const matchReviewStatusEnum = pgEnum('match_review_status', [
'edited',
]);
export const matchJudgeVerdictEnum = pgEnum('match_judge_verdict', [
'strong_fit',
'plausible',
'weak',
'mismatch',
]);
export const matchRejectReasonEnum = pgEnum('match_reject_reason', [
'wrong_eligibility',
'wrong_geography',
@@ -151,7 +158,14 @@ export const grants = pgTable(
// refreshed by the federalPrecedent workflow. Null for non-federal
// sources and never touched by ingest upserts.
programStateAwardCount: integer('program_state_award_count'),
// Sample recipients backing the count — review-page evidence.
// Awards (within the fetched window) whose recipient name-matches a
// registered NH nonprofit in our primary ICP band — "orgs like ours
// win this program", the count the precedent subscore actually uses.
// Dartmouth/UNH/hospital systems/LLCs inflate the raw count above but
// never this one.
programStatePeerAwardCount: integer('program_state_peer_award_count'),
// Sample recipients backing the count — review-page evidence. Each
// entry carries `isPeer` since the peer-precedent pass.
programStateAwards: jsonb('program_state_awards'),
status: grantStatusEnum('status').notNull().default('open'),
synopsisEmbedding: vector('synopsis_embedding', { dimensions: 1536 }),
@@ -240,6 +254,9 @@ export const orgProfiles = pgTable(
confidence: real('confidence'),
profileEmbedding: vector('profile_embedding', { dimensions: 1536 }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
// Bumped whenever the profile is re-researched — the match judge
// re-judges matches whose judged_at predates this.
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
},
(t) => [
// Latest-profile semantics: one row per org, refreshed in place (the
@@ -304,6 +321,20 @@ export const matches = pgTable(
easyWin: boolean('easy_win').notNull().default(false),
// LLM-produced citations backing the score/subscores.
rationale: jsonb('rationale'),
// Embedding fit can't veto (non-mission subscores sum to 50); this
// flag can: false = below the source-aware mission-fit floor or judged
// 'weak'/'mismatch', and the pending queue hides the row. Approved/
// rejected views ignore it (a human decision always displays).
fitViable: boolean('fit_viable').notNull().default(true),
// Mission-fit judge (LLM over grant synopsis × researched org profile;
// deterministic verdict→score mapping — the judge names the verdict,
// code assigns the number). Preserved across nightly re-scores;
// invalidated when the org profile is re-researched after judged_at.
judgeVerdict: matchJudgeVerdictEnum('judge_verdict'),
judgeMissionFit: integer('judge_mission_fit'),
judgeRationale: text('judge_rationale'),
judgedAt: timestamp('judged_at', { withTimezone: true }),
judgeModel: text('judge_model'),
reviewStatus: matchReviewStatusEnum('review_status')
.notNull()
.default('pending'),

View File

@@ -5,15 +5,20 @@ import { schema } from '#~/db/db.js';
export interface AlnPrecedent {
readonly aln: string;
/** Raw NH award count for the program (context/evidence only). */
readonly awardCount: number;
/** Awards won by ICP-peer nonprofits — drives the precedent subscore. */
readonly peerAwardCount: number;
/** Peer-annotated award list (see classifyPeerAwards). */
readonly samples: unknown;
}
/**
* Applies per-program USASpending precedent onto every open federal grant
* carrying that ALN. Grants with multiple ALNs keep the HIGHEST count
* seen (a grant reachable through any strongly-NH program inherits that
* program's precedent), and samples follow whichever count won.
* carrying that ALN. Grants with multiple ALNs keep the strongest program
* seen, where "strongest" is the PEER count — ten Dartmouth renewals must
* not outrank three community-nonprofit wins — with raw count and samples
* following whichever peer count won.
*/
export async function serverSetFederalPrecedent(
db: NpOutreachDatabase | NpOutreachTransaction,
@@ -22,9 +27,14 @@ export async function serverSetFederalPrecedent(
await db
.update(schema.grants)
.set({
programStateAwardCount: sql`GREATEST(COALESCE(${schema.grants.programStateAwardCount}, 0), ${precedent.awardCount})`,
programStatePeerAwardCount: sql`GREATEST(COALESCE(${schema.grants.programStatePeerAwardCount}, 0), ${precedent.peerAwardCount})`,
programStateAwardCount: sql`CASE
WHEN COALESCE(${schema.grants.programStatePeerAwardCount}, 0) <= ${precedent.peerAwardCount}
THEN ${precedent.awardCount}
ELSE ${schema.grants.programStateAwardCount}
END`,
programStateAwards: sql`CASE
WHEN COALESCE(${schema.grants.programStateAwardCount}, 0) <= ${precedent.awardCount}
WHEN COALESCE(${schema.grants.programStatePeerAwardCount}, 0) <= ${precedent.peerAwardCount}
THEN ${JSON.stringify(precedent.samples)}::jsonb
ELSE ${schema.grants.programStateAwards}
END`,
@@ -45,6 +55,10 @@ export async function serverResetFederalPrecedent(
): Promise<void> {
await db
.update(schema.grants)
.set({ programStateAwardCount: null, programStateAwards: null })
.set({
programStateAwardCount: null,
programStatePeerAwardCount: null,
programStateAwards: null,
})
.where(eq(schema.grants.source, 'grants_gov'));
}

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { normalizeOrgNameForMatching } from '../orgs/org-name.js';
import { buildPeerNameSet, classifyPeerAwards } from './peer-precedent.js';
describe('normalizeOrgNameForMatching', () => {
it('collapses case, punctuation, and org-form suffixes', () => {
expect(normalizeOrgNameForMatching('The Community Kitchen, Inc.')).toBe(
normalizeOrgNameForMatching('COMMUNITY KITCHEN INC'),
);
expect(normalizeOrgNameForMatching('Smith Charitable Trust')).toBe(
normalizeOrgNameForMatching('SMITH TRUST'),
);
});
it('does not strip for-profit suffixes', () => {
expect(normalizeOrgNameForMatching('CELDARA MEDICAL, LLC')).not.toBe(
normalizeOrgNameForMatching('CELDARA MEDICAL'),
);
});
it('only strips suffixes as whole words', () => {
// TRUSTEES must not lose an embedded TRUSTEE/TRUST.
expect(
normalizeOrgNameForMatching('TRUSTEES OF DARTMOUTH COLLEGE'),
).toContain('TRUSTEES');
});
});
describe('classifyPeerAwards', () => {
const peers = buildPeerNameSet([
'The Community Kitchen, Inc.',
'Granite Backcountry Alliance',
]);
it('counts only registry-matched recipients as peers', () => {
const { peerAwardCount, awards } = classifyPeerAwards(
[
{ recipientName: 'COMMUNITY KITCHEN INC', amount: 50_000, startDate: '2024-01-01' },
{ recipientName: 'TRUSTEES OF DARTMOUTH COLLEGE', amount: 3_000_000, startDate: '2024-05-01' },
{ recipientName: 'CELDARA MEDICAL, LLC', amount: 2_000_000, startDate: '2022-09-22' },
{ recipientName: 'GRANITE BACKCOUNTRY ALLIANCE', amount: 25_000, startDate: '2023-06-01' },
],
peers,
);
expect(peerAwardCount).toBe(2);
expect(awards.map((a) => a.isPeer)).toEqual([true, false, false, true]);
});
it('is empty-safe', () => {
expect(classifyPeerAwards([], peers).peerAwardCount).toBe(0);
expect(
classifyPeerAwards(
[{ recipientName: 'ANYONE', amount: null, startDate: null }],
new Set<string>(),
).peerAwardCount,
).toBe(0);
});
});

View File

@@ -0,0 +1,53 @@
import { normalizeOrgNameForMatching } from '../orgs/org-name.js';
/** One USASpending award row as stored in `grants.program_state_awards`. */
export interface StateAwardSample {
readonly recipientName: string;
readonly amount: number | null;
readonly startDate: string | null;
}
export interface PeerAnnotatedAward extends StateAwardSample {
/** Recipient name-matches a registered NH nonprofit in the primary ICP band. */
readonly isPeer: boolean;
}
export interface PeerPrecedentResult {
/** Awards (within the fetched window) won by ICP-peer nonprofits. */
readonly peerAwardCount: number;
readonly awards: PeerAnnotatedAward[];
}
/** Builds the normalized peer-name set from registry org names. */
export function buildPeerNameSet(peerOrgNames: readonly string[]): Set<string> {
const set = new Set<string>();
for (const name of peerOrgNames) {
const normalized = normalizeOrgNameForMatching(name);
if (normalized !== '') set.add(normalized);
}
return set;
}
/**
* Splits a program's state award history into peer and non-peer awards.
* "10 NH awards" is meaningless when all ten went to Dartmouth and two
* biotech LLCs; the precedent subscore keys off `peerAwardCount` — awards
* to orgs shaped like our candidates — and the annotated list becomes the
* review-page evidence table so a human can see WHY precedent is high or
* low. Conservative by construction: an unmatched recipient (out-of-
* registry, unenriched, or name drift) counts as non-peer, and missed
* precedent ranks a real match lower rather than pitching a false one.
*/
export function classifyPeerAwards(
awards: readonly StateAwardSample[],
peerNames: ReadonlySet<string>,
): PeerPrecedentResult {
const annotated = awards.map((award) => ({
...award,
isPeer: peerNames.has(normalizeOrgNameForMatching(award.recipientName)),
}));
return {
peerAwardCount: annotated.filter((a) => a.isPeer).length,
awards: annotated,
};
}

View File

@@ -18,8 +18,15 @@ export interface EligibleGrantWithSimilarity {
| 'unknown';
applicationFormSupported: boolean;
funderEin: string | null;
source: string;
similarity: number;
/** Funder's historical grant count into the org's state (990-PF index); null when the grant has no linked funder. */
/**
* Precedent count for the subscore: the funder's historical grant count
* into the org's state (990-PF index) for foundation grants, or the
* program's PEER award count (USASpending recipients name-matched to
* primary-ICP NH orgs) for federal grants — never the raw state count,
* which Dartmouth renewals inflate.
*/
funderStateGrantCount: number | null;
}
@@ -61,6 +68,7 @@ export async function serverListEligibleGrantsForOrg(
applicationEffortEstimate: schema.grants.applicationEffortEstimate,
applicationFormSupported: schema.grants.applicationFormSupported,
funderEin: schema.grants.funderEin,
source: schema.grants.source,
similarity: sql<number>`1 - (${schema.grants.synopsisEmbedding} <=> ${vector}::vector)`,
funderStateGrantCount: sql<number | null>`CASE
WHEN ${schema.grants.funderEin} IS NOT NULL THEN (
@@ -69,7 +77,7 @@ export async function serverListEligibleGrantsForOrg(
WHERE f.ein = ${schema.grants.funderEin}
AND fg.recipient_state = ${filters.orgState}
)
ELSE ${schema.grants.programStateAwardCount}
ELSE ${schema.grants.programStatePeerAwardCount}
END`,
})
.from(schema.grants)

View File

@@ -10,3 +10,5 @@ export * from './matches/hard-gates.js';
export * from './orgs/icp-band.js';
export * from './matches/scoring.js';
export * from './orgs/ntee.js';
export * from './orgs/org-name.js';
export * from './grants/peer-precedent.js';

View File

@@ -0,0 +1,48 @@
import { sql } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
import { EASY_WIN_MIN_PRECEDENT, EASY_WIN_THRESHOLD } from '../scoring.js';
export interface MatchJudgmentInput {
readonly matchId: string;
readonly verdict: 'strong_fit' | 'plausible' | 'weak' | 'mismatch';
/** Deterministic 030 mapping of the verdict (missionFitFromVerdict). */
readonly judgedMissionFit: number;
readonly fitViable: boolean;
readonly rationale: string;
readonly model: string;
}
/**
* Applies a judge verdict to one match in a single UPDATE: stores the
* judgment, splices the judged mission fit into `subscores`, and
* recomputes total score, easy-win, and queue viability from it. All SET
* expressions read the OLD row (Postgres semantics), so the splice math
* is safe on re-judgment too — the old missionFit (embedding-band or a
* previous verdict's) is subtracted, the new one added.
*/
export async function serverApplyMatchJudgment(
db: NpOutreachDatabase | NpOutreachTransaction,
judgment: MatchJudgmentInput,
): Promise<void> {
const newTotal = sql`${schema.matches.totalScore} - COALESCE((${schema.matches.subscores}->>'missionFit')::int, 0) + ${judgment.judgedMissionFit}`;
await db
.update(schema.matches)
.set({
judgeVerdict: judgment.verdict,
judgeMissionFit: judgment.judgedMissionFit,
judgeRationale: judgment.rationale,
judgeModel: judgment.model,
judgedAt: sql`now()`,
totalScore: newTotal,
subscores: sql`jsonb_set(COALESCE(${schema.matches.subscores}, '{}'::jsonb), '{missionFit}', to_jsonb(${judgment.judgedMissionFit}::int))`,
fitViable: judgment.fitViable,
easyWin: sql`(${judgment.fitViable}
AND ${newTotal} >= ${EASY_WIN_THRESHOLD}
AND COALESCE((${schema.matches.subscores}->>'funderPrecedent')::int, 0) >= ${EASY_WIN_MIN_PRECEDENT})`,
updatedAt: sql`now()`,
})
.where(sql`${schema.matches.id} = ${judgment.matchId}`);
}

View File

@@ -24,6 +24,7 @@ export async function serverAssignHeroMatch(
WHERE org_id = ${orgId}
AND review_status != 'rejected'
AND hard_gates_passed = true
AND fit_viable = true
ORDER BY easy_win DESC, total_score DESC, created_at ASC
LIMIT 1
)

View File

@@ -2,3 +2,4 @@ export * from './insert-match.server.js';
export * from './set-match-review.server.js';
export * from './upsert-match-score.server.js';
export * from './assign-hero-matches.server.js';
export * from './apply-match-judgment.server.js';

View File

@@ -2,7 +2,11 @@ import { sql } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
import type { MatchSubscores } from '../scoring.js';
import {
EASY_WIN_MIN_PRECEDENT,
EASY_WIN_THRESHOLD,
type MatchSubscores,
} from '../scoring.js';
export interface MatchScoreInput {
readonly orgId: string;
@@ -11,6 +15,7 @@ export interface MatchScoreInput {
readonly subscores: MatchSubscores;
readonly hardGatesPassed: boolean;
readonly easyWin: boolean;
readonly fitViable: boolean;
/** Gate failures, similarity, and any judge citations — audit trail. */
readonly rationale: unknown;
}
@@ -22,11 +27,23 @@ export interface MatchScoreInput {
* nightly re-score moves the numbers — resurfacing rejected matches would
* erode the review queue's trust, and re-approving approved ones is
* pointless churn.
*
* Judge fields survive re-scores the same way, and while a judgment is
* on the row its mission-fit verdict KEEPS overriding the incoming
* embedding-based numbers: missionFit is spliced with judge_mission_fit,
* total/easy-win recomputed from the spliced value, and fit_viable kept
* from the verdict. Otherwise the nightly embedding re-score would revert
* every judged match until the judge's next pass re-found it. Staleness
* is handled at selection time (re-judge when the org profile is newer
* than judged_at), not by dropping the judgment here.
*/
export async function serverUpsertMatchScore(
db: NpOutreachDatabase | NpOutreachTransaction,
match: MatchScoreInput,
): Promise<void> {
const judged = sql`${schema.matches.judgedAt} IS NOT NULL AND ${schema.matches.judgeMissionFit} IS NOT NULL`;
const judgedTotal = sql`excluded.total_score - COALESCE((excluded.subscores->>'missionFit')::int, 0) + ${schema.matches.judgeMissionFit}`;
await db
.insert(schema.matches)
.values({
@@ -36,16 +53,24 @@ export async function serverUpsertMatchScore(
subscores: match.subscores,
hardGatesPassed: match.hardGatesPassed,
easyWin: match.easyWin,
fitViable: match.fitViable,
rationale: match.rationale,
reviewStatus: 'pending',
})
.onConflictDoUpdate({
target: [schema.matches.orgId, schema.matches.grantId],
set: {
totalScore: sql`excluded.total_score`,
subscores: sql`excluded.subscores`,
totalScore: sql`CASE WHEN ${judged} THEN ${judgedTotal} ELSE excluded.total_score END`,
subscores: sql`CASE WHEN ${judged}
THEN jsonb_set(excluded.subscores, '{missionFit}', to_jsonb(${schema.matches.judgeMissionFit}))
ELSE excluded.subscores END`,
hardGatesPassed: sql`excluded.hard_gates_passed`,
easyWin: sql`excluded.easy_win`,
easyWin: sql`CASE WHEN ${judged}
THEN (${schema.matches.fitViable}
AND ${judgedTotal} >= ${EASY_WIN_THRESHOLD}
AND COALESCE((excluded.subscores->>'funderPrecedent')::int, 0) >= ${EASY_WIN_MIN_PRECEDENT})
ELSE excluded.easy_win END`,
fitViable: sql`CASE WHEN ${judged} THEN ${schema.matches.fitViable} ELSE excluded.fit_viable END`,
rationale: sql`excluded.rationale`,
updatedAt: sql`now()`,
},

View File

@@ -11,6 +11,10 @@ export interface MatchDetail {
rationale: unknown;
easyWin: boolean;
isHero: boolean;
fitViable: boolean;
judgeVerdict: 'strong_fit' | 'plausible' | 'weak' | 'mismatch' | null;
judgeRationale: string | null;
judgedAt: Date | null;
reviewStatus: 'pending' | 'approved' | 'rejected' | 'edited';
};
org: {
@@ -56,6 +60,9 @@ export interface MatchDetail {
amount: number | null;
purpose: string | null;
taxYear: number;
/** Federal evidence rows only: recipient name-matched a primary-ICP
* NH nonprofit (the peer-precedent basis). Null for 990-PF rows. */
isPeer: boolean | null;
}>;
}
@@ -71,6 +78,10 @@ export async function serverGetMatchDetail(
rationale: schema.matches.rationale,
easyWin: schema.matches.easyWin,
isHero: schema.matches.isHero,
fitViable: schema.matches.fitViable,
judgeVerdict: schema.matches.judgeVerdict,
judgeRationale: schema.matches.judgeRationale,
judgedAt: schema.matches.judgedAt,
reviewStatus: schema.matches.reviewStatus,
orgId: schema.orgs.id,
orgName: schema.orgs.name,
@@ -123,19 +134,29 @@ export async function serverGetMatchDetail(
if (row.grantFunderEin == null && Array.isArray(row.grantProgramAwards)) {
// Federal grants: USASpending program awards fill the same evidence
// table (recipient/amount/year) the 990-PF history uses.
// Peers first (the actual precedent evidence), newest within each
// group; capped so a 500-award program doesn't flood the page.
funderGivingHistory = (
row.grantProgramAwards as Array<{
recipientName?: string;
amount?: number | null;
startDate?: string | null;
isPeer?: boolean;
}>
).map((a) => ({
recipientName: a.recipientName ?? 'Unknown recipient',
recipientCity: null,
amount: a.amount ?? null,
purpose: null,
taxYear: a.startDate != null ? Number(a.startDate.slice(0, 4)) : 0,
}));
)
.map((a) => ({
recipientName: a.recipientName ?? 'Unknown recipient',
recipientCity: null,
amount: a.amount ?? null,
purpose: null,
taxYear: a.startDate != null ? Number(a.startDate.slice(0, 4)) : 0,
isPeer: a.isPeer ?? null,
}))
.sort((a, b) => {
const peerRank = Number(b.isPeer === true) - Number(a.isPeer === true);
return peerRank !== 0 ? peerRank : b.taxYear - a.taxYear;
})
.slice(0, 40);
}
if (row.grantFunderEin != null) {
const funderRow = await db
@@ -170,7 +191,7 @@ export async function serverGetMatchDetail(
return aIn - bIn;
})
.slice(0, 40)
.map(({ recipientState: _s, ...rest }) => rest);
.map(({ recipientState: _s, ...rest }) => ({ ...rest, isPeer: null }));
}
return {
@@ -181,6 +202,10 @@ export async function serverGetMatchDetail(
rationale: row.rationale,
easyWin: row.easyWin,
isHero: row.isHero,
fitViable: row.fitViable,
judgeVerdict: row.judgeVerdict,
judgeRationale: row.judgeRationale,
judgedAt: row.judgedAt,
reviewStatus: row.reviewStatus,
},
org: {

View File

@@ -1,2 +1,3 @@
export * from './list-pending-review-matches.server.js';
export * from './get-match-detail.server.js';
export * from './list-matches-needing-judgment.server.js';

View File

@@ -0,0 +1,74 @@
import { sql } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
export interface MatchNeedingJudgment {
matchId: string;
orgName: string;
orgCity: string | null;
missionStatement: string | null;
/** Researched programs jsonb (Stage 4 shape) — null/[] for stub profiles. */
programs: unknown;
serviceGeography: string | null;
profileConfidence: number | null;
grantTitle: string;
grantFunder: string;
grantSynopsis: string | null;
}
/**
* Federal pending matches awaiting an LLM mission-fit verdict, best-first
* — the head of the review queue gets verified before a human ever reads
* it. A match re-enters when its org profile has been re-researched since
* it was judged (better profile → the verdict may flip either way).
*
* Federal-only (`grants_gov`) by design: foundation-synthesized grants
* have generic synopses ("grants for NH nonprofits"), so there is no
* grant-side text for a judge to read — precedent evidence carries those.
* Queue-viable rows only: the mission-fit floor already buried the clear
* garbage; judge tokens go to rows a human would otherwise see next.
*/
export async function serverListMatchesNeedingJudgment(
db: NpOutreachDatabase | NpOutreachTransaction,
{ limit }: { limit: number },
): Promise<MatchNeedingJudgment[]> {
const rows = (await db.execute(sql`
SELECT
m.id AS match_id,
o.name AS org_name,
o.city AS org_city,
p.mission_statement,
p.programs,
p.service_geography,
p.confidence AS profile_confidence,
g.title AS grant_title,
g.funder AS grant_funder,
g.synopsis AS grant_synopsis
FROM matches m
JOIN orgs o ON o.id = m.org_id
JOIN grants g ON g.id = m.grant_id
LEFT JOIN org_profiles p ON p.org_id = m.org_id
WHERE m.review_status = 'pending'
AND m.hard_gates_passed = true
AND m.fit_viable = true
AND g.source = 'grants_gov'
AND g.status = 'open'
AND (m.judged_at IS NULL OR m.judged_at < p.updated_at)
ORDER BY m.easy_win DESC, m.total_score DESC
LIMIT ${limit}
`)) as unknown as { rows: Array<Record<string, unknown>> };
return rows.rows.map((r) => ({
matchId: r.match_id as string,
orgName: r.org_name as string,
orgCity: (r.org_city as string | null) ?? null,
missionStatement: (r.mission_statement as string | null) ?? null,
programs: r.programs ?? null,
serviceGeography: (r.service_geography as string | null) ?? null,
profileConfidence: (r.profile_confidence as number | null) ?? null,
grantTitle: r.grant_title as string,
grantFunder: r.grant_funder as string,
grantSynopsis: (r.grant_synopsis as string | null) ?? null,
}));
}

View File

@@ -44,12 +44,16 @@ export async function serverListPendingReviewMatches(
.innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id))
.innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id))
.where(
source == null
? eq(schema.matches.reviewStatus, status)
: and(
eq(schema.matches.reviewStatus, status),
eq(schema.grants.source, source as never),
),
and(
eq(schema.matches.reviewStatus, status),
// The pending queue hides fit-non-viable rows (below the mission-
// fit floor or judged weak/mismatch); a human's approve/reject
// always displays regardless.
...(status === 'pending' ? [eq(schema.matches.fitViable, true)] : []),
...(source == null
? []
: [eq(schema.grants.source, source as never)]),
),
)
// Reviewers see the best candidates first: heroes, then easy wins,
// then raw score. Capped — nightly re-scoring generates thousands of

View File

@@ -6,6 +6,7 @@ import {
competitionSubscore,
EASY_WIN_THRESHOLD,
effortSubscore,
MISSION_FIT_VIABLE_MIN,
missionFitSubscore,
runwaySubscore,
scoreMatch,
@@ -102,6 +103,7 @@ describe('scoreMatch', () => {
closeDate: weeksFromNow(6),
now: NOW,
funderStateGrantCount: null,
grantSource: 'irs_990pf',
});
// 30+15+15+10+5 = 75 — over the threshold but no precedent floor.
expect(result.totalScore).toBe(75);
@@ -118,6 +120,7 @@ describe('scoreMatch', () => {
closeDate: weeksFromNow(6),
now: NOW,
funderStateGrantCount: 6,
grantSource: 'irs_990pf',
});
// 30 fit + 20 precedent + 15 capacity + 15 competition + 8 effort + 5 runway
expect(result.totalScore).toBe(93);
@@ -135,8 +138,81 @@ describe('scoreMatch', () => {
closeDate: weeksFromNow(2),
now: NOW,
funderStateGrantCount: null,
grantSource: 'grants_gov',
});
expect(result.totalScore).toBeLessThan(EASY_WIN_THRESHOLD);
expect(result.easyWin).toBe(false);
});
it('marks federal matches below the mission-fit floor non-viable', () => {
// similarity 0.55 → fit 10 < MISSION_FIT_VIABLE_MIN (12)
const result = scoreMatch({
similarity: 0.55,
orgTotalRevenue: 1_000_000,
awardCeiling: 200_000,
geographicScope: null,
applicationEffortEstimate: 'full_federal',
closeDate: weeksFromNow(6),
now: NOW,
funderStateGrantCount: 20,
grantSource: 'grants_gov',
});
expect(result.subscores.missionFit).toBeLessThan(MISSION_FIT_VIABLE_MIN);
expect(result.fitViable).toBe(false);
expect(result.easyWin).toBe(false);
});
it('keeps federal matches at/above the floor viable', () => {
// similarity 0.57 → fit 12 = floor
const result = scoreMatch({
similarity: 0.57,
orgTotalRevenue: 1_000_000,
awardCeiling: 200_000,
geographicScope: null,
applicationEffortEstimate: 'full_federal',
closeDate: weeksFromNow(6),
now: NOW,
funderStateGrantCount: 20,
grantSource: 'grants_gov',
});
expect(result.subscores.missionFit).toBeGreaterThanOrEqual(
MISSION_FIT_VIABLE_MIN,
);
expect(result.fitViable).toBe(true);
});
it('exempts foundation-synthesized grants from the fit floor', () => {
// Generic 990-PF synopses carry no embedding signal — precedent
// evidence is the case for those matches, so low fit stays viable.
const result = scoreMatch({
similarity: 0.5,
orgTotalRevenue: 1_000_000,
awardCeiling: 200_000,
geographicScope: 'New Hampshire',
applicationEffortEstimate: 'loi_only',
closeDate: weeksFromNow(6),
now: NOW,
funderStateGrantCount: 12,
grantSource: 'irs_990pf',
});
expect(result.subscores.missionFit).toBeLessThan(MISSION_FIT_VIABLE_MIN);
expect(result.fitViable).toBe(true);
});
it('gates easy-win on fit viability, not just score and precedent', () => {
const belowFloor = scoreMatch({
similarity: 0.55,
orgTotalRevenue: 1_000_000,
awardCeiling: 200_000,
geographicScope: 'New Hampshire',
applicationEffortEstimate: 'loi_only',
closeDate: weeksFromNow(6),
now: NOW,
funderStateGrantCount: 20,
grantSource: 'grants_gov',
});
// 10+25+15+15+10+5 = 80 ≥ threshold, precedent 25 ≥ floor — but not viable.
expect(belowFloor.totalScore).toBeGreaterThanOrEqual(EASY_WIN_THRESHOLD);
expect(belowFloor.easyWin).toBe(false);
});
});

View File

@@ -39,12 +39,15 @@ export interface ScoreMatchInput {
readonly now: Date;
/**
* Historical grants this funder has paid to recipients in the org's
* state (from the 990-PF index). Null = no precedent data for this
* grant's funder (e.g. federal agencies) — scores 0, not neutral: the
* plan weights precedent as the strongest single predictor, and absence
* of evidence should rank below presence.
* state (990-PF index for foundations; USASpending PEER award count for
* federal programs — recipients name-matched to primary-ICP NH orgs).
* Null = no precedent data — scores 0, not neutral: the plan weights
* precedent as the strongest single predictor, and absence of evidence
* should rank below presence.
*/
readonly funderStateGrantCount: number | null;
/** Grant source (`grants.source`) — drives the source-aware fit floor. */
readonly grantSource: string;
}
export const ACHIEVABLE_MAX_SCORE = 100;
@@ -57,6 +60,18 @@ export const ACHIEVABLE_MAX_SCORE = 100;
export const EASY_WIN_THRESHOLD = 65;
export const EASY_WIN_MIN_PRECEDENT = 12;
/**
* Mission-fit floor (federal RFPs only): below 12/30 (≈ cosine 0.57) the
* match is stored but NOT review-viable — non-mission subscores sum to 50,
* so without a floor a boys' camp scores 60 on an NIH obesity-research
* center grant purely on precedent + capacity. Foundation-synthesized
* grants are exempt: their synopses are generic by construction ("grants
* for NH nonprofits"), so embedding fit carries no signal there and the
* precedent evidence IS the case for the match.
*/
export const MISSION_FIT_VIABLE_MIN = 12;
const FIT_FLOOR_SOURCES: ReadonlySet<string> = new Set(['grants_gov']);
/** Similarity below this scores 0 fit; above the ceiling scores full fit. */
const SIMILARITY_FLOOR = 0.45;
const SIMILARITY_CEILING = 0.75;
@@ -154,6 +169,13 @@ export interface ScoredMatch {
readonly totalScore: number;
readonly subscores: MatchSubscores;
readonly easyWin: boolean;
/**
* False when mission fit is below the source-aware floor — the match is
* recorded (audit trail, re-scoring continuity) but hidden from the
* pending review queue. The LLM match judge may later override in
* either direction.
*/
readonly fitViable: boolean;
}
export function scoreMatch(input: ScoreMatchInput): ScoredMatch {
@@ -174,11 +196,17 @@ export function scoreMatch(input: ScoreMatchInput): ScoredMatch {
subscores.runway +
subscores.funderPrecedent;
const fitViable =
!FIT_FLOOR_SOURCES.has(input.grantSource) ||
subscores.missionFit >= MISSION_FIT_VIABLE_MIN;
return {
totalScore,
subscores,
easyWin:
fitViable &&
totalScore >= EASY_WIN_THRESHOLD &&
subscores.funderPrecedent >= EASY_WIN_MIN_PRECEDENT,
fitViable,
};
}

View File

@@ -52,6 +52,9 @@ export async function serverApplyResearchedProfile(
sources: sql`excluded.sources`,
confidence: sql`excluded.confidence`,
profileEmbedding: sql`excluded.profile_embedding`,
// Staleness signal for the match judge: judgments older than this
// re-enter the judge queue.
updatedAt: sql`now()`,
},
});
}

View File

@@ -0,0 +1,22 @@
/**
* Case/punctuation/suffix-insensitive org-name equality, shared by the
* self-match gate (a foundation's synthesized grant must never match the
* foundation's own org row) and USASpending peer matching (a federal
* award recipient counts as a "peer" only if it name-matches a registered
* NH nonprofit). Both sides of any comparison must go through this same
* function — the guarantees are only as good as the normalization being
* identical.
*
* Deliberately does NOT strip LLC/LTD/CORP: a for-profit "ACME LLC"
* collapsing onto a nonprofit "ACME" would grant peer precedent to
* exactly the recipient type this matching exists to exclude.
*/
export function normalizeOrgNameForMatching(name: string): string {
return name
.toUpperCase()
.replace(
/\b(THE|INC|INCORPORATED|TTEE|TRUSTEE|FUND|FOUNDATION|CHARITABLE|TRUST)\b/g,
'',
)
.replace(/[^A-Z0-9]/g, '');
}

View File

@@ -2,3 +2,4 @@ export * from './list-orgs-in-icp-band.server.js';
export * from './list-orgs-needing-enrichment.server.js';
export * from './list-match-candidate-orgs.server.js';
export * from './list-orgs-needing-profile.server.js';
export * from './list-peer-org-names.server.js';

View File

@@ -0,0 +1,24 @@
import { and, eq } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
/**
* Names of registered NH nonprofits in the primary ICP band ($100K$5M
* revenue, enriched) — the "orgs like ours" universe that USASpending
* award recipients are matched against for federal peer precedent.
*
* Primary-band-only is deliberate: registry membership alone would count
* Dartmouth College (registered, revenue in the billions) as a peer, and
* unenriched rows can't prove they're in band. Under-counting demotes;
* over-counting pitches a camp on an NIH center grant.
*/
export async function serverListPeerOrgNames(
db: NpOutreachDatabase | NpOutreachTransaction,
): Promise<string[]> {
const rows = await db
.select({ name: schema.orgs.name })
.from(schema.orgs)
.where(and(eq(schema.orgs.state, 'NH'), eq(schema.orgs.icpBand, 'primary')));
return rows.map((r) => r.name);
}