/** * 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 10–75%) * competition 15 state/NH-restricted pools beat national ones * effort 10 LOI/short-form beat full federal * runway 5 3–10 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 0–100; 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 = 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 10–75% 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; /** 3–10 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, }; }