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

@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { buildMatchJudgePrompt } from './run.js';
import {
MatchJudgeVerdictSchema,
missionFitFromVerdict,
verdictIsFitViable,
} from './schema.js';
describe('missionFitFromVerdict', () => {
it('maps verdicts deterministically, capped under the embedding max', () => {
expect(missionFitFromVerdict('strong_fit')).toBe(27);
expect(missionFitFromVerdict('plausible')).toBe(18);
expect(missionFitFromVerdict('weak')).toBe(8);
expect(missionFitFromVerdict('mismatch')).toBe(0);
});
it('keeps only strong/plausible queue-viable', () => {
expect(verdictIsFitViable('strong_fit')).toBe(true);
expect(verdictIsFitViable('plausible')).toBe(true);
expect(verdictIsFitViable('weak')).toBe(false);
expect(verdictIsFitViable('mismatch')).toBe(false);
});
});
describe('MatchJudgeVerdictSchema', () => {
it('requires grounding citations', () => {
expect(() =>
MatchJudgeVerdictSchema.parse({
verdict: 'mismatch',
citedOrgEvidence: '',
citedGrantEvidence: 'x',
reasoning: 'y',
}),
).toThrow();
});
});
describe('buildMatchJudgePrompt', () => {
const base = {
org: {
name: 'Camp Tecumseh',
city: 'Moultonborough',
missionStatement: 'Residential summer camp for boys',
programs: [
{ name: 'Summer camp', description: 'sports and outdoors', populationServed: 'boys 8-15' },
],
serviceGeography: 'Lakes Region NH',
profileConfidence: 0.8,
},
grant: {
title: 'Nutrition Obesity Research Centers (NORCs)',
funder: 'NIH',
synopsis: 'Supports research center infrastructure...',
},
};
it('instructs the judge to ignore eligibility breadth', () => {
const prompt = buildMatchJudgePrompt(base);
expect(prompt).toContain('IGNORE eligibility breadth');
expect(prompt).toContain('Camp Tecumseh');
expect(prompt).toContain('Nutrition Obesity Research Centers');
expect(prompt).toContain('- Summer camp — sports and outdoors — serves boys 8-15');
});
it('flags low-confidence stub profiles', () => {
const prompt = buildMatchJudgePrompt({
...base,
org: { ...base.org, programs: [], profileConfidence: 0.2 },
});
expect(prompt).toContain('low-confidence');
expect(prompt).toContain('no researched program list');
});
});

View File

@@ -1,64 +1,136 @@
import { MissionFitVerdictSchema, type MissionFitVerdict } from './schema.js';
import type { OrgProfile } from '../org-profiler/schema.js';
import { getAi } from '../../gemini.js';
import { JUDGE_MODEL } from '../../models.js';
import {
MATCH_JUDGE_VERDICT_JSON_SCHEMA,
MatchJudgeVerdictSchema,
type MatchJudgeVerdict,
} from './schema.js';
export interface RunMissionFitJudgeInput {
/** Extracted profile of the candidate org (from the Org Profiler). */
orgProfile: OrgProfile;
/** Grant program name / title, as scored against by the deterministic SQL gates. */
grantProgramName: string;
/** Funding priorities / eligible-use language pulled from the grant's own source text. */
grantPriorities: string[];
export interface JudgeOrgSide {
readonly name: string;
readonly city: string | null;
/** Researched or NTEE-stub mission text (whatever the profile holds). */
readonly missionStatement: string | null;
/** Researched programs, if the Stage 4 profiler has run for this org. */
readonly programs: Array<{
name: string;
description: string | null;
populationServed: string | null;
}>;
readonly serviceGeography: string | null;
/** Profile confidence — low means the org side is mostly an NTEE guess. */
readonly profileConfidence: number | null;
}
export interface JudgeGrantSide {
readonly title: string;
readonly funder: string;
readonly synopsis: string | null;
}
export interface RunMatchJudgeInput {
readonly org: JudgeOrgSide;
readonly grant: JudgeGrantSide;
}
const MAX_SYNOPSIS_CHARS = 12_000;
/**
* Build the judge prompt for one (org, grant) match candidate. Kept separate
* from `runMissionFitJudge` so it's independently unit-testable once wired up.
* Build the judge prompt for one (org, grant) candidate. Kept separate
* from `runMatchJudge` so it's independently unit-testable.
*
* The core instruction exists because of a concrete failure mode:
* federal (especially NIH) synopses declare near-universal *eligibility*
* while the funded work is highly specific — "nonprofits may apply" put a
* boys' summer camp on an obesity-research center grant. Eligibility
* breadth is therefore explicitly out of scope; the judge reads what the
* program FUNDS and who realistically performs that work.
*/
export function buildMissionFitJudgePrompt(input: RunMissionFitJudgeInput): string {
export function buildMatchJudgePrompt(input: RunMatchJudgeInput): string {
const { org, grant } = input;
const programLines =
org.programs.length > 0
? org.programs
.map((p) =>
[
`- ${p.name}`,
p.description,
p.populationServed == null ? null : `serves ${p.populationServed}`,
]
.filter(Boolean)
.join(' — '),
)
.join('\n')
: '(no researched program list — judge from the mission text alone)';
return [
'You are the final mission-fit judge for a candidate (org, grant) match',
'that has already passed deterministic SQL hard gates (eligibility,',
'geography, award range). Decide whether the org\'s actual programs',
'plausibly fit the grant\'s funding priorities. You MUST cite one',
'concrete org program and one concrete grant priority your verdict is',
'grounded in — a verdict without both citations is invalid. A wrong',
'"fit: true" here can put a real NH nonprofit in front of a funder that',
'will never fund them, so when the fit is unclear, prefer `fit: false`.',
'You judge whether a specific nonprofit is a credible fit for a specific',
'grant program. The pair already passed automated eligibility, geography,',
'and deadline gates; your ONLY question is programmatic mission fit:',
'does the work this org actually does match what this grant actually funds?',
'',
`Grant program: ${input.grantProgramName}`,
`Grant priorities: ${input.grantPriorities.join('; ')}`,
`Org legal name: ${input.orgProfile.legalName.value}`,
`Org mission: ${input.orgProfile.mission.value}`,
`Org program areas: ${input.orgProfile.programAreas.value.join('; ')}`,
'Rules:',
'- IGNORE eligibility breadth entirely. Federal synopses often say any',
' nonprofit may apply while the funded activity is narrow, technical, or',
' institutional (research centers, clinical trials, training programs at',
' universities). Judge what gets FUNDED and who realistically performs',
' that work, not who is allowed to apply.',
'- A research-mechanism grant (center grants, clinical trials, R-series/',
' P-series/U-series NIH mechanisms) fits only orgs that conduct that kind',
' of research.',
'- Ground the verdict in one concrete org activity and one concrete piece',
' of synopsis language; a verdict without both citations is invalid.',
"- A wrong positive verdict wastes a real fundraiser's time and burns our",
' credibility with them. When genuinely torn between two verdicts, pick',
' the lower one.',
'',
'Verdicts:',
"- strong_fit: the org's core work is squarely what the program funds.",
'- plausible: real overlap; a competent grant writer could make the case.',
'- weak: tangential overlap only; the org would be an outlier applicant.',
'- mismatch: the org does not do what this program funds.',
'',
'--- GRANT ---',
`Title: ${grant.title}`,
`Funder: ${grant.funder}`,
`Synopsis: ${(grant.synopsis ?? '(none)').slice(0, MAX_SYNOPSIS_CHARS)}`,
'',
'--- ORGANIZATION ---',
`Name: ${org.name}`,
`Location: ${org.city ?? 'unknown'}, NH`,
`Mission: ${org.missionStatement ?? '(unknown)'}`,
`Service area: ${org.serviceGeography ?? '(unknown)'}`,
'Programs:',
programLines,
...(org.profileConfidence != null && org.profileConfidence < 0.5
? [
'',
'NOTE: this org profile is low-confidence (category-derived, not',
'researched). Judge from the mission category; do not invent programs.',
]
: []),
].join('\n');
}
/**
* NOT IMPLEMENTED — this judge is the veto gate before a match can reach a
* human reviewer (and, downstream, a real prospect via Apollo), so it should
* not go live against real matches until the deterministic hard-gate scoring
* this package doesn't own is wired in ahead of it. Intended production call
* shape, mirroring novelpad-desktop's
* packages/ai/src/agents/grant/section-drafter/run.ts (invokeVertex +
* responseSchema-constrained structured JSON output) — note `JUDGE_MODEL`,
* not `BULK_MODEL`: a wrong verdict here reaches a prospect:
*
* import { getAi } from '../../gemini.js';
* import { JUDGE_MODEL } from '../../models.js';
*
* const result = await getAi().models.generateContent({
* model: JUDGE_MODEL,
* contents: buildMissionFitJudgePrompt(input),
* config: {
* responseMimeType: 'application/json',
* // responseSchema: MISSION_FIT_VERDICT_RESPONSE_SCHEMA — a Type/Schema
* // literal from '@google/genai' hand-mirroring MissionFitVerdictSchema.
* temperature: 0.1,
* },
* });
* const raw = JSON.parse(result.text ?? '{}');
* return MissionFitVerdictSchema.parse(raw);
* One structured judge call. JUDGE_MODEL per the tiering rule in
* models.ts — this verdict gates matches into/out of the review queue.
* `MATCH_JUDGE_MODEL` env overrides for cheap bulk experiments.
*/
export async function runMissionFitJudge(_input: RunMissionFitJudgeInput): Promise<MissionFitVerdict> {
throw new Error('not implemented');
export async function runMatchJudge(
input: RunMatchJudgeInput,
): Promise<{ verdict: MatchJudgeVerdict; model: string }> {
const model = process.env.MATCH_JUDGE_MODEL ?? JUDGE_MODEL;
const result = await getAi().models.generateContent({
model,
contents: buildMatchJudgePrompt(input),
config: {
responseMimeType: 'application/json',
responseJsonSchema: MATCH_JUDGE_VERDICT_JSON_SCHEMA,
temperature: 0.1,
},
});
const raw: unknown = JSON.parse(result.text ?? '{}');
return { verdict: MatchJudgeVerdictSchema.parse(raw), model };
}

View File

@@ -1,20 +1,68 @@
import { z } from 'zod';
/**
* Mission-fit judge verdict for one (org, grant) match candidate. This is
* the last human-facing gate before a match is surfaced for review — the
* judge must ground its verdict in something concrete from each side rather
* than a vibe, so `citedOrgProgram` / `citedGrantPriority` are required, not
* optional summary fields.
* Graded mission-fit verdict for one (org, grant) match candidate that
* already passed the deterministic hard gates and subscore ranking.
*
* The judge names a verdict; deterministic code assigns the number
* (missionFitFromVerdict) and decides queue viability
* (verdictIsFitViable) — the LLM never emits a score directly, so the
* mapping can be re-tuned from review data without re-judging anything.
*
* Citations are required, not optional summary fields: a verdict must be
* grounded in something concrete from each side rather than a vibe.
*/
export const MissionFitVerdictSchema = z.object({
/** Whether the org's mission plausibly fits the grant's funding priorities. */
fit: z.boolean(),
/** The specific org program/activity the verdict is grounded in (from the org profile). */
citedOrgProgram: z.string().min(1),
/** The specific funding priority/eligibility line the verdict is grounded in (from the grant). */
citedGrantPriority: z.string().min(1),
/** Short human-readable justification tying the two citations together. */
export const MatchJudgeVerdictSchema = z.object({
verdict: z.enum(['strong_fit', 'plausible', 'weak', 'mismatch']),
/** The specific org program/activity the verdict is grounded in. */
citedOrgEvidence: z.string().min(1),
/** The specific synopsis language (purpose/priorities) the verdict is grounded in. */
citedGrantEvidence: z.string().min(1),
/** 13 sentence justification tying the two citations together. */
reasoning: z.string().min(1),
});
export type MissionFitVerdict = z.infer<typeof MissionFitVerdictSchema>;
export type MatchJudgeVerdict = z.infer<typeof MatchJudgeVerdictSchema>;
/** Plain JSON Schema mirror of MatchJudgeVerdictSchema for `responseJsonSchema`. */
export const MATCH_JUDGE_VERDICT_JSON_SCHEMA = {
type: 'object',
properties: {
verdict: {
type: 'string',
enum: ['strong_fit', 'plausible', 'weak', 'mismatch'],
},
citedOrgEvidence: { type: 'string', minLength: 1 },
citedGrantEvidence: { type: 'string', minLength: 1 },
reasoning: { type: 'string', minLength: 1 },
},
required: ['verdict', 'citedOrgEvidence', 'citedGrantEvidence', 'reasoning'],
additionalProperties: false,
} as const;
/**
* Deterministic verdict → mission-fit subscore (030 scale, replacing the
* embedding-band value on judged matches). `strong_fit` lands just under
* the embedding maximum — a judge can rescue a good match the embeddings
* missed, but only corroborated similarity reaches 30.
*/
export function missionFitFromVerdict(
verdict: MatchJudgeVerdict['verdict'],
): number {
switch (verdict) {
case 'strong_fit':
return 27;
case 'plausible':
return 18;
case 'weak':
return 8;
case 'mismatch':
return 0;
}
}
/** Whether a judged match stays visible in the pending review queue. */
export function verdictIsFitViable(
verdict: MatchJudgeVerdict['verdict'],
): boolean {
return verdict === 'strong_fit' || verdict === 'plausible';
}

View File

@@ -0,0 +1,8 @@
CREATE TYPE "public"."match_judge_verdict" AS ENUM('strong_fit', 'plausible', 'weak', 'mismatch');--> statement-breakpoint
ALTER TABLE "grants" ADD COLUMN "program_state_peer_award_count" integer;--> statement-breakpoint
ALTER TABLE "matches" ADD COLUMN "fit_viable" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "matches" ADD COLUMN "judge_verdict" "match_judge_verdict";--> statement-breakpoint
ALTER TABLE "matches" ADD COLUMN "judge_mission_fit" integer;--> statement-breakpoint
ALTER TABLE "matches" ADD COLUMN "judge_rationale" text;--> statement-breakpoint
ALTER TABLE "matches" ADD COLUMN "judged_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "matches" ADD COLUMN "judge_model" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "org_profiles" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,20 @@
"when": 1784257140142,
"tag": "1784257140_federal-precedent",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1784295305749,
"tag": "1784295305_match-judge-and-peer-precedent",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1784295739948,
"tag": "1784295739_org-profile-updated-at",
"breakpoints": true
}
]
}

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);
}