feat(scoring): match-generation workflow — scoring engine v1 (Stage 2, step 2)

matchGrants (nightly 05:15): per candidate org (NH + good standing +
primary ICP) ensure a v0 NTEE-derived profile embedding (RETRIEVAL_QUERY,
confidence 0.2 stub the Stage 4 profiler upgrades in place), retrieve
top-50 open grants by pgvector cosine with SQL-enforced cheap gates
(deadline >=21d, ceiling >=10K), run entity/geography gates in TS,
score deterministically, upsert pair-keyed matches, reassign hero.

Scoring: pure scoreMatch (mission fit 30 / capacity 15 / competition 15
/ effort 10 / runway 5; precedent's 25 reserved until the 990-PF index;
easy win >= 50/75). Eligibility gate now pattern-matches Grants.gov
applicantTypes prose, conservatively (ambiguous entries do not admit).
application_form_supported ignored for pass/fail per the manual-first
decision, still recorded in rationale.

Schema: unique (org_id, grant_id) on matches; unique org_id on
org_profiles (latest-profile semantics). Review queue query now ordered
hero > easy-win > score and capped at 100.

Live run: 64 orgs -> 3,200 matches in 28s, 0 easy wins / max 39 — the
honest result of an NIH-heavy 200-grant corpus vs NH service nonprofits;
engine mechanics verified, corpus breadth is the next lever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-16 16:55:10 -04:00
parent a532f0bebf
commit 0ee478ec3d
25 changed files with 2213 additions and 4 deletions

View File

@@ -0,0 +1,158 @@
/**
* 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.
*
* v1 weights (funder precedent's 25 points are NOT yet awarded — the
* 990-PF index is a later deliverable, so the achievable maximum is 75,
* not 100). `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
* 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;
/** Not yet computed — reserved so the jsonb shape is stable. */
readonly funderPrecedent: 0;
}
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;
}
export const ACHIEVABLE_MAX_SCORE = 75;
/**
* "Easy win" threshold, v1: two-thirds of the achievable maximum. The
* plan's full definition also requires a funder-precedent floor — that
* gate returns when the 990-PF index lands; thresholds re-tune on review
* and demo-booking data regardless.
*/
export const EASY_WIN_THRESHOLD = 50;
/** 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;
}
}
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;
}
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: 0,
};
const totalScore =
subscores.missionFit +
subscores.capacityFit +
subscores.competition +
subscores.effort +
subscores.runway;
return {
totalScore,
subscores,
easyWin: totalScore >= EASY_WIN_THRESHOLD,
};
}