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

@@ -35,6 +35,7 @@ import { setEnrichOrgsDeps } from './workflows/enrich-orgs.js';
import { setExpireGrantsDeps } from './workflows/expire-grants.js';
import { setFederalPrecedentDeps } from './workflows/federal-precedent.js';
import { setIngestGrantsDeps } from './workflows/ingest-grants.js';
import { setJudgeMatchesDeps } from './workflows/judge-matches.js';
import { setIngest990pfDeps } from './workflows/ingest-990pf.js';
import { setIngestNhdojOrgsDeps } from './workflows/ingest-nhdoj-orgs.js';
import { setMatchGrantsDeps } from './workflows/match-grants.js';
@@ -75,6 +76,7 @@ async function main() {
setIngest990pfDeps({ db });
setFederalPrecedentDeps({ db });
setProfileOrgsDeps({ db });
setJudgeMatchesDeps({ db });
DBOS.setConfig({
name: 'helmdocs-outreach-worker',

View File

@@ -44,6 +44,10 @@ import {
runIngestNhdojOrgsNow,
setIngestNhdojOrgsDeps,
} from './workflows/ingest-nhdoj-orgs.js';
import {
runJudgeMatchesNow,
setJudgeMatchesDeps,
} from './workflows/judge-matches.js';
import {
runMatchGrantsNow,
setMatchGrantsDeps,
@@ -68,6 +72,7 @@ const RUNNERS: Record<string, () => Promise<void>> = {
ingest990pf: runIngest990PfNow,
federalPrecedent: runFederalPrecedentNow,
profileOrgs: runProfileOrgsNow,
judgeMatches: runJudgeMatchesNow,
};
const FIRST_RUN_ORDER = [
@@ -81,6 +86,7 @@ const FIRST_RUN_ORDER = [
'embedGrants',
'matchGrants',
'profileOrgs',
'judgeMatches',
];
if (process.env.DATABASE_URL == null) {
@@ -120,6 +126,7 @@ async function main() {
setIngest990pfDeps({ db });
setFederalPrecedentDeps({ db });
setProfileOrgsDeps({ db });
setJudgeMatchesDeps({ db });
DBOS.setConfig({
name: 'helmdocs-outreach-worker',

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import { fetchProgramStateAwards } from './client.js';
function pageResponse(
names: string[],
total: number,
hasNext: boolean,
): Response {
return new Response(
JSON.stringify({
results: names.map((n) => ({
'Recipient Name': n,
'Award Amount': 1000.4,
'Start Date': '2024-01-01',
})),
page_metadata: { total, hasNext },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
describe('fetchProgramStateAwards', () => {
it('paginates until hasNext is false and keeps every recipient', async () => {
const pages = [
pageResponse(Array.from({ length: 100 }, (_, i) => `ORG ${i}`), 130, true),
pageResponse(Array.from({ length: 30 }, (_, i) => `ORG ${100 + i}`), 130, false),
];
const bodies: number[] = [];
const fetchImpl: typeof fetch = async (_url, init) => {
const body = JSON.parse(String(init?.body)) as { page: number };
bodies.push(body.page);
return pages[body.page - 1]!;
};
const result = await fetchProgramStateAwards(
'93.847',
'NH',
{ startDate: '2023-01-01', endDate: '2026-01-01' },
{ fetchImpl, politenessDelayMs: 0 },
);
expect(bodies).toEqual([1, 2]);
expect(result.awardCount).toBe(130);
expect(result.samples).toHaveLength(130);
expect(result.samples[0]!.amount).toBe(1000);
expect(result.samples.at(-1)!.recipientName).toBe('ORG 129');
});
it('stops at the fetch cap even when more pages exist', async () => {
const fetchImpl: typeof fetch = async (_url, init) => {
const body = JSON.parse(String(init?.body)) as { page: number };
return pageResponse(
Array.from({ length: 100 }, (_, i) => `ORG ${(body.page - 1) * 100 + i}`),
900,
true,
);
};
const result = await fetchProgramStateAwards(
'93.847',
'NH',
{ startDate: '2023-01-01', endDate: '2026-01-01' },
{ fetchImpl, politenessDelayMs: 0, maxAwardsFetched: 250 },
);
expect(result.samples).toHaveLength(300); // 3 full pages, cap crossed on page 3
expect(result.awardCount).toBe(900);
});
it('throws on a failed page', async () => {
const fetchImpl: typeof fetch = async () =>
new Response('nope', { status: 502, statusText: 'Bad Gateway' });
await expect(
fetchProgramStateAwards(
'93.847',
'NH',
{ startDate: '2023-01-01', endDate: '2026-01-01' },
{ fetchImpl, politenessDelayMs: 0 },
),
).rejects.toThrow(/502/);
});
});

View File

@@ -12,6 +12,15 @@ const GRANT_AWARD_TYPE_CODES = ['02', '03', '04', '05'];
const DEFAULT_POLITENESS_DELAY_MS = 300;
const PAGE_SIZE = 100;
/**
* Recipient-list fetch cap. Peer classification runs over the fetched
* window, so a program with >500 NH awards gets its peer count computed
* on the first 500 — an undercount for the very largest programs, which
* is the conservative direction (see classifyPeerAwards in outreach-core).
*/
const MAX_AWARDS_FETCHED = 500;
export interface ProgramStateAwardSample {
readonly recipientName: string;
readonly amount: number | null;
@@ -20,24 +29,38 @@ export interface ProgramStateAwardSample {
export interface ProgramStateAwards {
readonly aln: string;
/** Total matching awards per the API (may exceed `samples.length`). */
readonly awardCount: number;
/** Every fetched award (up to MAX_AWARDS_FETCHED), not a 10-row teaser. */
readonly samples: ProgramStateAwardSample[];
}
export interface UsaSpendingClientOptions {
readonly fetchImpl?: typeof fetch;
readonly politenessDelayMs?: number;
/** Test hook; production uses MAX_AWARDS_FETCHED. */
readonly maxAwardsFetched?: number;
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
interface SpendingByAwardResponse {
results?: Array<{
'Recipient Name'?: string | null;
'Award Amount'?: number | null;
'Start Date'?: string | null;
}>;
page_metadata?: { total?: number | null; hasNext?: boolean | null };
}
/**
* Recent grant awards under one Assistance Listing to recipients in one
* state: total count plus a handful of sample recipients (review-page
* evidence — lets a human spot "one incumbent's renewals" vs "spread
* across orgs like ours").
* state: total count plus the full fetched recipient list. The list feeds
* two consumers — the review-page evidence table, and peer classification
* ("did orgs like ours win this, or just Dartmouth?"), which needs every
* recipient, hence pagination instead of the old single 10-row page.
*/
export async function fetchProgramStateAwards(
aln: string,
@@ -46,48 +69,52 @@ export async function fetchProgramStateAwards(
options: UsaSpendingClientOptions = {},
): Promise<ProgramStateAwards> {
const fetchImpl = options.fetchImpl ?? fetch;
const maxAwards = options.maxAwardsFetched ?? MAX_AWARDS_FETCHED;
const res = await fetchImpl(SEARCH_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filters: {
award_type_codes: GRANT_AWARD_TYPE_CODES,
recipient_locations: [{ country: 'USA', state }],
time_period: [{ start_date: startDate, end_date: endDate }],
program_numbers: [aln],
},
fields: ['Award ID', 'Recipient Name', 'Award Amount', 'Start Date'],
limit: 10,
page: 1,
}),
});
if (!res.ok) {
throw new Error(
`USASpending search failed for ALN ${aln} (${state}): ${res.status} ${res.statusText}`,
);
const samples: ProgramStateAwardSample[] = [];
let total = 0;
let page = 1;
for (;;) {
const res = await fetchImpl(SEARCH_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filters: {
award_type_codes: GRANT_AWARD_TYPE_CODES,
recipient_locations: [{ country: 'USA', state }],
time_period: [{ start_date: startDate, end_date: endDate }],
program_numbers: [aln],
},
fields: ['Award ID', 'Recipient Name', 'Award Amount', 'Start Date'],
limit: PAGE_SIZE,
page,
}),
});
if (!res.ok) {
throw new Error(
`USASpending search failed for ALN ${aln} (${state}) page ${page}: ${res.status} ${res.statusText}`,
);
}
const body = (await res.json()) as SpendingByAwardResponse;
const results = body.results ?? [];
for (const r of results) {
samples.push({
recipientName: r['Recipient Name'] ?? 'Unknown recipient',
amount: r['Award Amount'] == null ? null : Math.round(r['Award Amount']),
startDate: r['Start Date'] ?? null,
});
}
total = Math.max(body.page_metadata?.total ?? 0, samples.length);
await wait(options.politenessDelayMs ?? DEFAULT_POLITENESS_DELAY_MS);
const hasNext =
body.page_metadata?.hasNext ?? results.length === PAGE_SIZE;
if (!hasNext || results.length === 0 || samples.length >= maxAwards) break;
page++;
}
const body = (await res.json()) as {
results?: Array<{
'Recipient Name'?: string | null;
'Award Amount'?: number | null;
'Start Date'?: string | null;
}>;
page_metadata?: { total?: number | null };
};
await wait(options.politenessDelayMs ?? DEFAULT_POLITENESS_DELAY_MS);
const samples: ProgramStateAwardSample[] = (body.results ?? []).map((r) => ({
recipientName: r['Recipient Name'] ?? 'Unknown recipient',
amount: r['Award Amount'] == null ? null : Math.round(r['Award Amount']),
startDate: r['Start Date'] ?? null,
}));
return {
aln,
awardCount: body.page_metadata?.total ?? samples.length,
samples,
};
return { aln, awardCount: total, samples };
}

View File

@@ -2,20 +2,28 @@
* Nightly federal-precedent workflow — USASpending award history per
* Assistance Listing (ALN/CFDA), the federal analog of the 990-PF index.
*
* For each distinct ALN across open federal grants: count grant awards to
* recipients in the target state over the last ~3 fiscal years, stamp the
* count + sample recipients onto every open grant carrying that ALN.
* Match retrieval feeds the count into the same 25-point precedent
* subscore foundations use — "this program funded N NH orgs recently" and
* For each distinct ALN across open federal grants: fetch grant awards to
* recipients in the target state over the last ~3 fiscal years, classify
* each recipient as peer/non-peer against the primary-ICP NH registry
* (classifyPeerAwards — Dartmouth renewals and SBIR LLCs are not
* precedent for a community nonprofit), and stamp raw count, PEER count,
* and the annotated award list onto every open grant carrying that ALN.
* Match retrieval feeds the peer count into the same 25-point precedent
* subscore foundations use — "this program funded N orgs like ours" and
* "this foundation funded N NH orgs recently" are the same signal.
*
* Runs at 04:45 — after ingest (03:00, which refreshes ALNs) and before
* matching (05:15). Registration follows `ingest-grants.ts` exactly.
*/
import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk';
import type { schema } from '@novelpad/outreach-core';
import {
buildPeerNameSet,
classifyPeerAwards,
type schema,
} from '@novelpad/outreach-core';
import {
serverListOpenFederalAlns,
serverListPeerOrgNames,
serverResetFederalPrecedent,
serverSetFederalPrecedent,
} from '@novelpad/outreach-core/server';
@@ -60,6 +68,15 @@ const listAlnsStep = DBOS.registerStep(listAlns, {
maxAttempts: 3,
});
async function listPeerNames(db: OutreachDb): Promise<string[]> {
return serverListPeerOrgNames(db);
}
const listPeerNamesStep = DBOS.registerStep(listPeerNames, {
name: 'listPeerOrgNames',
retriesAllowed: true,
maxAttempts: 3,
});
async function fetchAlnAwards(
aln: string,
startDate: string,
@@ -78,11 +95,12 @@ const fetchAlnAwardsStep = DBOS.registerStep(fetchAlnAwards, {
async function applyPrecedent(
db: OutreachDb,
precedent: ProgramStateAwards,
precedent: ProgramStateAwards & { peerAwardCount: number; samples: unknown },
): Promise<void> {
await serverSetFederalPrecedent(db, {
aln: precedent.aln,
awardCount: precedent.awardCount,
peerAwardCount: precedent.peerAwardCount,
samples: precedent.samples,
});
}
@@ -112,6 +130,11 @@ async function runFederalPrecedent(): Promise<void> {
return;
}
const peerNames = buildPeerNameSet(await listPeerNamesStep(db));
console.log(
`[federal-precedent] peer universe: ${peerNames.size} primary-ICP NH orgs`,
);
const end = new Date();
const start = new Date(end);
start.setFullYear(start.getFullYear() - LOOKBACK_YEARS);
@@ -121,13 +144,25 @@ async function runFederalPrecedent(): Promise<void> {
await resetPrecedentStep(db);
let programsWithHistory = 0;
let programsWithPeers = 0;
let failed = 0;
for (const aln of alns) {
try {
const precedent = await fetchAlnAwardsStep(aln, startDate, endDate);
if (precedent.awardCount > 0) {
await applyPrecedentStep(db, precedent);
const fetched = await fetchAlnAwardsStep(aln, startDate, endDate);
if (fetched.awardCount > 0) {
// Pure classification over step outputs — deterministic on replay,
// so it doesn't need to be a step itself.
const { peerAwardCount, awards } = classifyPeerAwards(
fetched.samples,
peerNames,
);
await applyPrecedentStep(db, {
...fetched,
peerAwardCount,
samples: awards,
});
programsWithHistory++;
if (peerAwardCount > 0) programsWithPeers++;
}
} catch (err) {
failed++;
@@ -136,7 +171,7 @@ async function runFederalPrecedent(): Promise<void> {
}
console.log(
`[federal-precedent] programs=${alns.length} withNhHistory=${programsWithHistory} failed=${failed}`,
`[federal-precedent] programs=${alns.length} withNhHistory=${programsWithHistory} withPeerHistory=${programsWithPeers} failed=${failed}`,
);
if (failed > 0 && failed / alns.length > 0.2) {
throw new Error(

View File

@@ -0,0 +1,230 @@
/**
* Nightly mission-fit judge workflow — the LLM verification layer over
* the embedding-ranked federal queue.
*
* Embedding similarity + deterministic subscores get a match NEAR the
* truth; they cannot read an NIH synopsis and notice that "any nonprofit
* may apply" is hiding a research-center program no summer camp will ever
* run. The judge reads the actual synopsis against the org's (researched
* or stub) profile and issues a graded verdict; deterministic code maps
* verdict → mission-fit points, recomputes total/easy-win, and flips
* queue viability (see apply-match-judgment). Judged rows keep their
* verdict across nightly re-scores (see upsert-match-score) and re-enter
* this queue only when their org profile is re-researched.
*
* Cost bound: `JUDGE_MATCHES_PER_RUN` (default 200) × one JUDGE_MODEL
* call, best-scoring matches first — the head of the human review queue
* is always verified before a human reads it.
*
* Runs at 06:15, after matchGrants (05:15) and profileOrgs (05:45).
* Registration follows `ingest-grants.ts` exactly.
*/
import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk';
import {
missionFitFromVerdict,
runMatchJudge,
verdictIsFitViable,
type MatchJudgeVerdict,
} from '@novelpad/outreach-ai';
import type { schema } from '@novelpad/outreach-core';
import {
serverApplyMatchJudgment,
serverListMatchesNeedingJudgment,
type MatchNeedingJudgment,
} from '@novelpad/outreach-core/server';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
export type OutreachDb = NodePgDatabase<typeof schema>;
const DEFAULT_MATCHES_PER_RUN = 200;
export interface JudgeMatchesDeps {
readonly db: OutreachDb;
}
let registeredDeps: JudgeMatchesDeps | null = null;
export function setJudgeMatchesDeps(deps: JudgeMatchesDeps): void {
registeredDeps = deps;
}
function getJudgeMatchesDeps(): JudgeMatchesDeps {
if (registeredDeps == null) {
throw new Error(
'JudgeMatchesDeps not registered. Call setJudgeMatchesDeps() before DBOS.launch().',
);
}
return registeredDeps;
}
/** Coerces the org_profiles.programs jsonb (Stage 4 shape) into judge input. */
export function programsFromProfileJson(
programs: unknown,
): Array<{ name: string; description: string | null; populationServed: string | null }> {
if (!Array.isArray(programs)) return [];
const out: Array<{
name: string;
description: string | null;
populationServed: string | null;
}> = [];
for (const entry of programs) {
if (entry == null || typeof entry !== 'object') continue;
const p = entry as Record<string, unknown>;
if (typeof p.name !== 'string' || p.name === '') continue;
out.push({
name: p.name,
description: typeof p.description === 'string' ? p.description : null,
populationServed:
typeof p.populationServed === 'string' ? p.populationServed : null,
});
}
return out;
}
async function listCandidates(
db: OutreachDb,
limit: number,
): Promise<MatchNeedingJudgment[]> {
return serverListMatchesNeedingJudgment(db, { limit });
}
const listCandidatesStep = DBOS.registerStep(listCandidates, {
name: 'listMatchesNeedingJudgment',
retriesAllowed: true,
maxAttempts: 3,
});
async function judgeOne(
candidate: MatchNeedingJudgment,
): Promise<{ verdict: MatchJudgeVerdict; model: string }> {
return runMatchJudge({
org: {
name: candidate.orgName,
city: candidate.orgCity,
missionStatement: candidate.missionStatement,
programs: programsFromProfileJson(candidate.programs),
serviceGeography: candidate.serviceGeography,
profileConfidence: candidate.profileConfidence,
},
grant: {
title: candidate.grantTitle,
funder: candidate.grantFunder,
synopsis: candidate.grantSynopsis,
},
});
}
const judgeOneStep = DBOS.registerStep(judgeOne, {
name: 'judgeMatch',
retriesAllowed: true,
maxAttempts: 2,
});
async function applyJudgment(
db: OutreachDb,
matchId: string,
judged: { verdict: MatchJudgeVerdict; model: string },
): Promise<void> {
await serverApplyMatchJudgment(db, {
matchId,
verdict: judged.verdict.verdict,
judgedMissionFit: missionFitFromVerdict(judged.verdict.verdict),
fitViable: verdictIsFitViable(judged.verdict.verdict),
rationale: [
judged.verdict.reasoning,
`Org evidence: ${judged.verdict.citedOrgEvidence}`,
`Grant evidence: ${judged.verdict.citedGrantEvidence}`,
].join('\n'),
model: judged.model,
});
}
const applyJudgmentStep = DBOS.registerStep(applyJudgment, {
name: 'applyMatchJudgment',
retriesAllowed: true,
maxAttempts: 3,
});
async function runJudgeMatches(): Promise<void> {
const { db } = getJudgeMatchesDeps();
const limit = Number(
process.env.JUDGE_MATCHES_PER_RUN ?? DEFAULT_MATCHES_PER_RUN,
);
const candidates = await listCandidatesStep(db, limit);
if (candidates.length === 0) {
console.log('[judge-matches] queue head fully judged');
return;
}
const byVerdict: Record<string, number> = {};
let failed = 0;
for (const candidate of candidates) {
try {
const judged = await judgeOneStep(candidate);
await applyJudgmentStep(db, candidate.matchId, judged);
byVerdict[judged.verdict.verdict] =
(byVerdict[judged.verdict.verdict] ?? 0) + 1;
} catch (err) {
failed++;
console.error(
`[judge-matches] "${candidate.orgName}" × "${candidate.grantTitle}" failed:`,
err,
);
}
}
console.log(
`[judge-matches] judged=${candidates.length - failed} failed=${failed} verdicts=${JSON.stringify(byVerdict)}`,
);
if (failed > 0 && failed / candidates.length > 0.2) {
throw new Error(
`[judge-matches] systemic failure: ${failed}/${candidates.length} matches failed`,
);
}
}
const g = globalThis as unknown as {
__outreachJudgeMatchesRegistered?: boolean;
__outreachJudgeMatchesHandle?: (
scheduledTime: Date,
startedAt: Date,
) => Promise<void>;
};
if (!g.__outreachJudgeMatchesRegistered) {
g.__outreachJudgeMatchesRegistered = true;
const judgeMatches = async (_scheduledTime: Date, _startedAt: Date) => {
try {
await runJudgeMatches();
} catch (err) {
console.error('[judge-matches] pass failed:', err);
throw err;
}
};
// Must be registered as BOTH a workflow and a scheduled function,
// referencing the same function object — see ingest-grants.ts.
g.__outreachJudgeMatchesHandle = DBOS.registerWorkflow(judgeMatches, {
name: 'judgeMatches',
});
DBOS.registerScheduled(judgeMatches, {
crontab: '15 6 * * *',
name: 'judgeMatches',
mode: SchedulerMode.ExactlyOncePerInterval,
});
}
/**
* Starts one durable run of this workflow immediately through DBOS —
* the exact production path (workflow + checkpointed steps), used by
* `run-once.ts` for supervised/manual passes. Requires deps injected and
* `DBOS.launch()` completed.
*/
export function runJudgeMatchesNow(): Promise<void> {
const handle = g.__outreachJudgeMatchesHandle;
if (handle == null) {
throw new Error(
'judgeMatches is not registered; was this module imported before DBOS.launch()?',
);
}
return handle(new Date(), new Date());
}

View File

@@ -27,6 +27,7 @@ import { generateQueryEmbedding } from '@novelpad/outreach-ai';
import {
buildOrgMissionText,
evaluateHardGates,
normalizeOrgNameForMatching,
scoreMatch,
type HardGateFailureReason,
type schema,
@@ -123,14 +124,6 @@ const retrieveGrantsStep = DBOS.registerStep(retrieveGrants, {
maxAttempts: 3,
});
/** Case/punctuation/suffix-insensitive equality for self-match detection. */
function normalizeSelfMatchName(name: string): string {
return name
.toUpperCase()
.replace(/\b(INC|INCORPORATED|TTEE|TRUSTEE|FUND|FOUNDATION|CHARITABLE|TRUST)\b/g, '')
.replace(/[^A-Z0-9]/g, '');
}
async function scoreAndStoreOrgMatches(
db: OutreachDb,
org: MatchCandidateOrg,
@@ -152,7 +145,8 @@ async function scoreAndStoreOrgMatches(
grant.funderEin === org.ein;
const isSelfByName =
grant.funderEin != null &&
normalizeSelfMatchName(grant.funder) === normalizeSelfMatchName(org.name);
normalizeOrgNameForMatching(grant.funder) ===
normalizeOrgNameForMatching(org.name);
if (isSelfByEin || isSelfByName) {
gated++;
continue;
@@ -186,6 +180,7 @@ async function scoreAndStoreOrgMatches(
applicationEffortEstimate: grant.applicationEffortEstimate,
closeDate: grant.closeDate,
now,
grantSource: grant.source,
});
await serverUpsertMatchScore(db, {
@@ -195,12 +190,13 @@ async function scoreAndStoreOrgMatches(
subscores: scored.subscores,
hardGatesPassed,
easyWin: scored.easyWin,
fitViable: scored.fitViable,
rationale: {
similarity: grant.similarity,
gateFailures: gates.failures,
ignoredGates: [...IGNORED_GATES],
scoredAt: now.toISOString(),
scoringVersion: 'v2-state-precedent',
scoringVersion: 'v3-peer-precedent',
},
});
stored++;