Files
grant-outreach-engine/packages/outreach-core/src/matches/scoring.ts
Croissant Le Doux cbc4512ffa 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>
2026-07-17 10:52:50 -04:00

213 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Deterministic weighted subscores for an (org, grant) pair — Stage 2 of
* the scoring engine (docs/plan.md). Pure functions over plain inputs;
* the match workflow supplies the embedding similarity, everything else
* derives from columns.
*
* `subscores` records each component so weights can be re-tuned from
* review/booking data without re-deriving inputs.
*
* mission fit 30 embedding cosine similarity, scaled
* funder precedent 25 historical giving into the org's state (990-PF)
* capacity fit 15 award ceiling vs org revenue (sweet spot 1075%)
* competition 15 state/NH-restricted pools beat national ones
* effort 10 LOI/short-form beat full federal
* runway 5 310 weeks to deadline is ideal
*/
export interface MatchSubscores {
readonly missionFit: number;
readonly capacityFit: number;
readonly competition: number;
readonly effort: number;
readonly runway: number;
readonly funderPrecedent: number;
}
export interface ScoreMatchInput {
/** Cosine similarity in [-1, 1] between org mission and grant synopsis. */
readonly similarity: number;
readonly orgTotalRevenue: number | null;
readonly awardCeiling: number | null;
readonly geographicScope: string | null;
readonly applicationEffortEstimate:
| 'loi_only'
| 'short_form'
| 'full_federal'
| 'unknown';
readonly closeDate: Date | null;
readonly now: Date;
/**
* Historical grants this funder has paid to recipients in the org's
* 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;
/**
* "Easy win" threshold. With the 990-PF precedent subscore live the scale
* is the plan's full 0100; the plan's >=75 easy-win bar applies, plus its
* precedent floor (see scoreMatch). Thresholds re-tune on review and
* demo-booking data.
*/
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;
export function missionFitSubscore(similarity: number): number {
const clamped = Math.min(
Math.max(similarity, SIMILARITY_FLOOR),
SIMILARITY_CEILING,
);
return Math.round(
((clamped - SIMILARITY_FLOOR) / (SIMILARITY_CEILING - SIMILARITY_FLOOR)) * 30,
);
}
/**
* Sweet spot: award is 1075% of annual revenue (docs/plan.md). A grant
* dwarfing the org's budget is a capacity red flag to federal funders; a
* tiny one isn't worth the email. Unknown revenue scores a neutral 7.
*/
export function capacityFitSubscore(
awardCeiling: number | null,
orgTotalRevenue: number | null,
): number {
if (awardCeiling == null || orgTotalRevenue == null || orgTotalRevenue <= 0) {
return awardCeiling == null ? 0 : 7;
}
const ratio = awardCeiling / orgTotalRevenue;
if (ratio >= 0.1 && ratio <= 0.75) return 15;
if (ratio >= 0.05 && ratio < 0.1) return 10;
if (ratio > 0.75 && ratio <= 1.5) return 8;
if (ratio < 0.05) return 4;
return 2; // > 150% of revenue: real capacity red flag.
}
const STATE_RESTRICTED_PATTERN =
/new hampshire|\bnh\b|state of|statewide|county|municipal/i;
const REGIONAL_PATTERN = /new england|northeast|regional/i;
/**
* Competition proxy until expected-applicant-pool modeling exists:
* geographically restricted pools are dramatically less competitive than
* national ones. Null scope (typical for federal) = national = low score.
*/
export function competitionSubscore(geographicScope: string | null): number {
if (geographicScope == null || geographicScope.trim() === '') return 3;
if (STATE_RESTRICTED_PATTERN.test(geographicScope)) return 15;
if (REGIONAL_PATTERN.test(geographicScope)) return 10;
return 3;
}
export function effortSubscore(
estimate: ScoreMatchInput['applicationEffortEstimate'],
): number {
switch (estimate) {
case 'loi_only':
return 10;
case 'short_form':
return 8;
case 'unknown':
return 4;
case 'full_federal':
return 2;
}
}
/**
* Funder precedent (25): "a foundation that gave to three NH orgs like
* this one is a near-certain match for a fourth" — the plan's strongest
* single predictor. v1 measures repeated giving into the org's state;
* NTEE-level matching arrives when recipient orgs get resolved to EINs.
*/
export function funderPrecedentSubscore(
funderStateGrantCount: number | null,
): number {
if (funderStateGrantCount == null || funderStateGrantCount <= 0) return 0;
if (funderStateGrantCount >= 10) return 25;
if (funderStateGrantCount >= 5) return 20;
if (funderStateGrantCount >= 3) return 15;
return 8;
}
const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000;
/** 310 weeks out is ideal: urgent enough to act on, long enough to apply. */
export function runwaySubscore(closeDate: Date | null, now: Date): number {
if (closeDate == null) return 2; // rolling/unknown deadline: usable, not urgent.
const weeks = (closeDate.getTime() - now.getTime()) / MS_PER_WEEK;
if (weeks < 3) return 0;
if (weeks <= 10) return 5;
if (weeks <= 20) return 3;
return 1;
}
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 {
const subscores: MatchSubscores = {
missionFit: missionFitSubscore(input.similarity),
capacityFit: capacityFitSubscore(input.awardCeiling, input.orgTotalRevenue),
competition: competitionSubscore(input.geographicScope),
effort: effortSubscore(input.applicationEffortEstimate),
runway: runwaySubscore(input.closeDate, input.now),
funderPrecedent: funderPrecedentSubscore(input.funderStateGrantCount),
};
const totalScore =
subscores.missionFit +
subscores.capacityFit +
subscores.competition +
subscores.effort +
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,
};
}