fullstackgtm 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/judge.js ADDED
@@ -0,0 +1,490 @@
1
+ import { mkdirSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
4
+ import { forcedToolCall } from "./llm.js";
5
+ import { scoreProspectAgainstIcp } from "./icp.js";
6
+ import { computeWeights, normalizeAccountDomain } from "./signals.js";
7
+ export function scoreBand(score) {
8
+ if (score >= 80)
9
+ return "strong";
10
+ if (score >= 50)
11
+ return "good";
12
+ if (score >= 20)
13
+ return "weak";
14
+ return "noise";
15
+ }
16
+ // ---------------------------------------------------------------------------
17
+ // Stable hashing (FNV-1a) — duplicated to keep this file importable without the
18
+ // audit engine (the signals.ts/market.ts/enrich.ts precedent).
19
+ function fnv1a(value) {
20
+ let hash = 0x811c9dc5;
21
+ for (let i = 0; i < value.length; i += 1) {
22
+ hash ^= value.charCodeAt(i);
23
+ hash = Math.imul(hash, 0x01000193);
24
+ }
25
+ return (hash >>> 0).toString(16).padStart(8, "0");
26
+ }
27
+ export function judgeRunId(runLabel) {
28
+ return `jdg_${fnv1a(runLabel)}`;
29
+ }
30
+ /**
31
+ * Whitespace/punctuation-spacing-normalized, lowercased match — the EXACT rule
32
+ * `call`/`market` use for verbatim evidence gates (llm.ts normalizeSpan). A
33
+ * local copy keeps this file importable without the LLM engine (the cross-module
34
+ * duplication precedent in signals.ts/market.ts).
35
+ */
36
+ export function normalizeSpan(value) {
37
+ return value
38
+ .replace(/\s+([.,;:!?])/g, "$1")
39
+ .replace(/\s+/g, " ")
40
+ .trim()
41
+ .toLowerCase();
42
+ }
43
+ /** Verbatim evidence gate: `produced` must be a ≥12-char normalized substring of `source`. */
44
+ export function isGroundedSpan(produced, source) {
45
+ const span = normalizeSpan(produced);
46
+ if (span.length < 12)
47
+ return false;
48
+ return normalizeSpan(source).includes(span);
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // Scoring: timing × fit × memory
52
+ const DAY_MS = 24 * 60 * 60 * 1000;
53
+ /** Default freshness/clustering knobs. Editable via the deterministic scorer's opts. */
54
+ const RECENCY_FLOOR = 0.4;
55
+ const RECENCY_WINDOW_DAYS = 30;
56
+ /** Flat timing-norm boost when ≥2 fresh signals cluster (the "strong fit + high intent" band). */
57
+ const CLUSTER_BONUS = 0.25;
58
+ const FRESH_SIGNAL_DAYS = 14;
59
+ /** "Touched recently" memory window — pile-on guard. */
60
+ const TOUCHED_WINDOW_DAYS = 7;
61
+ function recencyFactor(firstSeen, now, windowDays = RECENCY_WINDOW_DAYS) {
62
+ const seen = Date.parse(firstSeen);
63
+ if (!Number.isFinite(seen))
64
+ return 1;
65
+ const ageDays = Math.max(0, (now.getTime() - seen) / DAY_MS);
66
+ if (windowDays <= 0)
67
+ return 1;
68
+ const decayed = 1 - (1 - RECENCY_FLOOR) * Math.min(1, ageDays / windowDays);
69
+ return Math.max(RECENCY_FLOOR, decayed);
70
+ }
71
+ function isFresh(firstSeen, now) {
72
+ const seen = Date.parse(firstSeen);
73
+ if (!Number.isFinite(seen))
74
+ return false;
75
+ return now.getTime() - seen <= FRESH_SIGNAL_DAYS * DAY_MS;
76
+ }
77
+ /**
78
+ * Score one account on timing × fit × memory and map to a 0-100 score + band +
79
+ * decision. Deterministic.
80
+ *
81
+ * - **timing**: each credited signal's bucket weight (learned overlay over
82
+ * config defaults via `computeWeights`) × recency decay × its already-baked
83
+ * repost boost; the account's timing is the MAX credited signal weight, plus a
84
+ * cluster bonus when ≥2 signals are fresh (the article's compounding "fresh
85
+ * round AND a live req" case → 80-100 band).
86
+ * - **fit**: `scoreProspectAgainstIcp(bestContact, icp)` (0..1). No ICP / no
87
+ * contact → a neutral 0.5 so a strong, well-grounded timing signal can still
88
+ * surface (fit then doesn't sink an otherwise-hot account).
89
+ * - **memory** (`recentlyTouched`): touched within `TOUCHED_WINDOW_DAYS` → never
90
+ * pile on: cap the decision at `nurture` (or `skip` if already lukewarm).
91
+ *
92
+ * `decision` from the band: strong→send, good→nurture, weak/noise→skip — then
93
+ * the memory cap applies. `skip` is a first-class output: a lone low-intent
94
+ * signal (e.g. a single `social` blip) lands in `weak`/`noise` and is skipped
95
+ * with a reason, not mailed.
96
+ */
97
+ export function scoreAccount(opts) {
98
+ const now = opts.now ?? new Date();
99
+ const domain = normalizeAccountDomain(opts.accountDomain);
100
+ const signals = opts.signals.filter((s) => normalizeAccountDomain(s.accountDomain) === domain);
101
+ if (signals.length === 0) {
102
+ throw new Error(`scoreAccount: no signals for ${opts.accountDomain}`);
103
+ }
104
+ // Timing: per-signal effective weight = learned bucket weight × recency,
105
+ // scaled by the signal's already-baked repost/recency weight ratio so the
106
+ // store's repost boost still counts. The account's timing is the max.
107
+ let topSignal = signals[0];
108
+ let maxWeight = -Infinity;
109
+ for (const signal of signals) {
110
+ const bucketWeight = opts.weights[signal.bucket] ?? 0;
111
+ const effective = bucketWeight * recencyFactor(signal.firstSeen, now) + repostBoostOf(signal);
112
+ if (effective > maxWeight) {
113
+ maxWeight = effective;
114
+ topSignal = signal;
115
+ }
116
+ }
117
+ const freshCount = signals.filter((s) => isFresh(s.firstSeen, now)).length;
118
+ const isCluster = freshCount >= 2;
119
+ const timing = Math.max(0, maxWeight);
120
+ // Fit: best-matching contact vs ICP, neutral 0.5 when unknown so timing leads.
121
+ const fit = opts.icp && opts.bestContact ? scoreProspectAgainstIcp(opts.bestContact, opts.icp).score : 0.5;
122
+ // Map timing×fit to 0-100 along two axes (the article rubric):
123
+ // - timingNorm (0..1): timing relative to TIMING_REF (~a single strong-bucket
124
+ // fresh signal at funding/demand weight). A lone strong signal lands in the
125
+ // "good" band; a lone low-intent signal (social ≈ 0.4) stays weak/noise.
126
+ // - A fresh ≥2-signal CLUSTER adds a flat boost so "a fresh round AND a live
127
+ // req" reaches the 80-100 strong band — the rubric's compounding case.
128
+ // - fitMultiplier (0.7..1.0): fit SHAPES the score ±30% but never zeroes a
129
+ // well-timed account; unknown fit (0.5) sits in the middle at 0.85.
130
+ const TIMING_REF = 2.0;
131
+ let timingNorm = Math.min(1, timing / TIMING_REF);
132
+ if (isCluster)
133
+ timingNorm = Math.min(1, timingNorm + CLUSTER_BONUS);
134
+ const fitMultiplier = 0.7 + 0.3 * fit;
135
+ const raw = timingNorm * fitMultiplier;
136
+ let score = Math.round(Math.max(0, Math.min(1, raw)) * 100);
137
+ const band = scoreBand(score);
138
+ let decision = band === "strong" ? "send" : band === "good" ? "nurture" : "skip";
139
+ let skippedReason;
140
+ if (decision === "skip") {
141
+ skippedReason =
142
+ band === "noise"
143
+ ? "off-ICP / low-intent noise — no fresh, in-persona trigger"
144
+ : "weak lone signal — one low-intent trigger, not enough to reach out about";
145
+ }
146
+ // Memory cap: touched within the window → never pile on. send→nurture,
147
+ // nurture→skip, skip stays skip.
148
+ const recentlyTouched = Boolean(opts.recentlyTouched);
149
+ if (recentlyTouched && decision !== "skip") {
150
+ if (decision === "send") {
151
+ decision = "nurture";
152
+ }
153
+ else {
154
+ decision = "skip";
155
+ skippedReason = `touched within ${TOUCHED_WINDOW_DAYS}d — already engaged; don't pile on`;
156
+ }
157
+ // Reflect the pile-on guard in the score so ranking de-prioritizes it, but
158
+ // keep the band-decision mapping coherent for nurture (50-79).
159
+ if (decision === "nurture" && score >= 80)
160
+ score = 79;
161
+ }
162
+ // Evidence = the credited cluster (all signals on this account this run).
163
+ const evidence = signals.map((s) => s.id);
164
+ return {
165
+ accountDomain: domain,
166
+ score,
167
+ band,
168
+ decision,
169
+ topSignal,
170
+ evidence,
171
+ fit: Number(fit.toFixed(3)),
172
+ timing: Number(timing.toFixed(4)),
173
+ recentlyTouched,
174
+ ...(skippedReason !== undefined ? { skippedReason } : {}),
175
+ };
176
+ }
177
+ /**
178
+ * The repost boost already baked into a signal's stored weight, recovered as
179
+ * weight − (weight without boost would be) — but since we don't have the
180
+ * un-boosted weight here, we credit a small fixed bump only when the signal is
181
+ * flagged `reposted`. Deterministic, additive, bounded.
182
+ */
183
+ function repostBoostOf(signal) {
184
+ return signal.reposted ? 0.5 : 0;
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // Memory: read account activity from the canonical snapshot
188
+ /**
189
+ * Has this account been touched within `TOUCHED_WINDOW_DAYS`? Reads the snapshot:
190
+ * the account record's `lastActivityAt`, plus any `activities` whose `occurredAt`
191
+ * is recent and that link to the account (directly or via a contact at it). Pure;
192
+ * with no snapshot, returns false (the no-history path — caller passes `false`).
193
+ */
194
+ export function accountRecentlyTouched(accountDomain, snapshot, now = new Date(), windowDays = TOUCHED_WINDOW_DAYS) {
195
+ const domain = normalizeAccountDomain(accountDomain);
196
+ const cutoff = now.getTime() - windowDays * DAY_MS;
197
+ const account = (snapshot.accounts ?? []).find((a) => normalizeAccountDomain(a.domain ?? "") === domain && domain !== "");
198
+ if (account?.lastActivityAt) {
199
+ const t = Date.parse(account.lastActivityAt);
200
+ if (Number.isFinite(t) && t >= cutoff)
201
+ return true;
202
+ }
203
+ if (!account)
204
+ return false;
205
+ const contactIds = new Set((snapshot.contacts ?? []).filter((c) => c.accountId === account.id).map((c) => c.id));
206
+ for (const contact of snapshot.contacts ?? []) {
207
+ if (contact.accountId !== account.id)
208
+ continue;
209
+ if (contact.lastActivityAt) {
210
+ const t = Date.parse(contact.lastActivityAt);
211
+ if (Number.isFinite(t) && t >= cutoff)
212
+ return true;
213
+ }
214
+ }
215
+ for (const activity of snapshot.activities ?? []) {
216
+ const linked = activity.accountId === account.id || (activity.contactId && contactIds.has(activity.contactId));
217
+ if (!linked)
218
+ continue;
219
+ const t = Date.parse(activity.occurredAt);
220
+ if (Number.isFinite(t) && t >= cutoff)
221
+ return true;
222
+ }
223
+ return false;
224
+ }
225
+ /**
226
+ * Find the best-matching contact for an account from the snapshot, for fit
227
+ * scoring: the account's contacts, preferring one with a title (a title is what
228
+ * fit scores on). Returns undefined when the account/contact isn't in snapshot.
229
+ */
230
+ export function bestContactForAccount(accountDomain, snapshot) {
231
+ const domain = normalizeAccountDomain(accountDomain);
232
+ if (!domain)
233
+ return undefined;
234
+ const account = (snapshot.accounts ?? []).find((a) => normalizeAccountDomain(a.domain ?? "") === domain);
235
+ if (!account)
236
+ return undefined;
237
+ const contacts = (snapshot.contacts ?? []).filter((c) => c.accountId === account.id);
238
+ const withTitle = contacts.find((c) => c.title) ?? contacts[0];
239
+ if (!withTitle)
240
+ return undefined;
241
+ return { jobTitle: withTitle.title };
242
+ }
243
+ // ---------------------------------------------------------------------------
244
+ // Deterministic baseline decision
245
+ /**
246
+ * Build a `JudgeDecision` from an `AccountScore` with the deterministic baseline:
247
+ * whyNow taken VERBATIM from the top credited signal's quote (grounded by
248
+ * construction), no `play`, producedBy "deterministic".
249
+ */
250
+ export function deterministicDecision(score) {
251
+ return {
252
+ accountDomain: score.accountDomain,
253
+ score: score.score,
254
+ decision: score.decision,
255
+ whyNow: deterministicWhyNow(score.topSignal),
256
+ play: "",
257
+ evidence: score.evidence,
258
+ ...(score.skippedReason !== undefined ? { skippedReason: score.skippedReason } : {}),
259
+ producedBy: "deterministic",
260
+ };
261
+ }
262
+ /**
263
+ * A grounded why-now drawn verbatim from the signal's quote — capped to a
264
+ * sentence-ish span so it reads as a why-now, not the whole JD. We take the
265
+ * leading clause up to the first sentence boundary (or the whole quote if
266
+ * short), guaranteeing `isGroundedSpan(whyNow, quote)` holds.
267
+ */
268
+ export function deterministicWhyNow(signal) {
269
+ const quote = signal.quote.trim();
270
+ // First sentence/clause boundary; keep ≥12 chars so the gate passes.
271
+ const match = quote.match(/^.{12,}?[.!?](\s|$)/);
272
+ const candidate = match ? match[0].trim() : quote;
273
+ // Cap length but only on a verbatim prefix (still a substring of the quote).
274
+ const capped = candidate.length > 180 ? candidate.slice(0, 180).trimEnd() : candidate;
275
+ // Guarantee groundedness against the full quote.
276
+ return isGroundedSpan(capped, quote) ? capped : quote.slice(0, Math.max(12, Math.min(quote.length, 180)));
277
+ }
278
+ // ---------------------------------------------------------------------------
279
+ // LLM path: forced tool call + verbatim why-now gate, retry-once, then fall back
280
+ export const JUDGE_SCHEMA = {
281
+ type: "object",
282
+ required: ["decision", "whyNow", "play"],
283
+ properties: {
284
+ decision: { type: "string", enum: ["send", "nurture", "skip"] },
285
+ whyNow: {
286
+ type: "string",
287
+ description: "The reason to reach out NOW, quoted VERBATIM from one of the provided signal quotes. " +
288
+ "Copy a span character-for-character from a quote — never paraphrase, never invent. " +
289
+ "If no quote supports a reach-out, set decision to skip and whyNow to the strongest quote span anyway.",
290
+ },
291
+ play: {
292
+ type: "string",
293
+ description: "One line a rep could say out loud, grounded in the trigger. Max 25 words. No em dashes.",
294
+ },
295
+ skippedReason: { type: "string", description: "skip only: one phrase why this is not a send." },
296
+ },
297
+ };
298
+ export const DEFAULT_JUDGE_PROMPT = `You are a GTM judge deciding whether to reach out to an account based on fresh signals.
299
+ Score bands (the operator owns these — edit this prompt to change them):
300
+ - 80-100 (send): strong fit AND high intent — a live demand search, fresh funding, or >=2 clustered signals.
301
+ - 50-79 (nurture): good fit and one solid signal, but not enough to interrupt cold.
302
+ - 20-49 (skip): a weak or lone low-intent signal.
303
+ - 0-19 (skip): off-ICP or noise.
304
+ Rules:
305
+ - whyNow MUST be a verbatim span copied from one of the signal quotes below. If you cannot ground a why-now in a real quote, you cannot send.
306
+ - play is one line a rep could say out loud, tied to the trigger. No greeting, no "Hi {{firstName}}". No em dashes.
307
+ - Saying "skip" is a first-class, valuable answer. A judge that skips a lukewarm account is doing its job.`;
308
+ /**
309
+ * The LLM judge for one account. Forced tool call with `JUDGE_SCHEMA` + an
310
+ * editable prompt; then the verbatim-quote GATE: `whyNow` must be a normalized
311
+ * ≥12-char substring of one of the credited signal quotes. Retry once with
312
+ * corrective feedback (the market classify idiom), then fall back to the
313
+ * deterministic why-now — NEVER store an ungrounded why-now. The deterministic
314
+ * `score`/`band`/`decision`/`evidence` are authoritative; the model contributes
315
+ * `whyNow` (gated) and `play` only — score is not delegated to the model.
316
+ */
317
+ export async function judgeDecisionLlm(score, promptTemplate, llm) {
318
+ const model = llm.model ?? "claude-haiku-4-5";
319
+ const producedBy = `llm:${llm.provider}:${model}`;
320
+ const quotes = score.evidence
321
+ .map((id, i) => {
322
+ const signal = findSignal(score, id);
323
+ return signal ? `[${i + 1}] (${signal.bucket}) "${signal.quote}"` : null;
324
+ })
325
+ .filter((line) => line !== null)
326
+ .join("\n");
327
+ const basePrompt = `${promptTemplate}
328
+
329
+ Account: ${score.accountDomain}
330
+ Computed band: ${score.band} (score ${score.score}/100; fit ${score.fit}, timing ${score.timing}${score.recentlyTouched ? "; touched within 7 days — do not pile on" : ""})
331
+ Signal quotes (your whyNow must be a verbatim span of ONE of these):
332
+ ${quotes}`;
333
+ const attempt = async (feedback) => {
334
+ const result = (await forcedToolCall(feedback ? `${basePrompt}\n${feedback}` : basePrompt, "judge_account", JUDGE_SCHEMA, model, llm));
335
+ const whyNow = (result.whyNow ?? "").trim();
336
+ if (!grounded(score, whyNow))
337
+ return null; // gate failed
338
+ return {
339
+ accountDomain: score.accountDomain,
340
+ // Score/band/decision stay deterministic; memory cap already applied.
341
+ score: score.score,
342
+ decision: score.decision,
343
+ whyNow,
344
+ play: sanitizePlay(result.play ?? ""),
345
+ evidence: score.evidence,
346
+ ...(score.skippedReason !== undefined ? { skippedReason: score.skippedReason } : {}),
347
+ producedBy,
348
+ };
349
+ };
350
+ const first = await attempt("");
351
+ if (first)
352
+ return first;
353
+ const retried = await attempt("\nYour previous whyNow was NOT a verbatim span of any signal quote. " +
354
+ "Copy a span character-for-character from one of the quotes above and answer again in full.");
355
+ if (retried)
356
+ return retried;
357
+ // Both attempts ungrounded — fall back to the deterministic (grounded) why-now,
358
+ // but keep the play empty (we won't ship an ungrounded play either).
359
+ return {
360
+ ...deterministicDecision(score),
361
+ producedBy,
362
+ };
363
+ }
364
+ function grounded(score, whyNow) {
365
+ for (const id of score.evidence) {
366
+ const signal = findSignal(score, id);
367
+ if (signal && isGroundedSpan(whyNow, signal.quote))
368
+ return true;
369
+ }
370
+ return false;
371
+ }
372
+ function findSignal(score, id) {
373
+ if (score.topSignal.id === id)
374
+ return score.topSignal;
375
+ return score._signalsById?.get(id);
376
+ }
377
+ /** Light play hygiene: strip em dashes (voice rule), collapse whitespace, cap length. */
378
+ function sanitizePlay(play) {
379
+ return play
380
+ .replace(/—/g, "-")
381
+ .replace(/\s+/g, " ")
382
+ .trim()
383
+ .slice(0, 240);
384
+ }
385
+ // ---------------------------------------------------------------------------
386
+ // Orchestration: group signals by account → score → decide (det. or LLM)
387
+ /**
388
+ * Judge a batch of signals: group by account, score each, and produce one
389
+ * decision per account (deterministic when no LLM options, gated-LLM otherwise).
390
+ * `--min-score` filtering and the snapshot/ICP wiring happen in the caller; this
391
+ * takes resolved inputs. Returns decisions sorted by score desc (the ranked list).
392
+ */
393
+ export async function judgeSignals(opts) {
394
+ const now = opts.now ?? new Date();
395
+ const signalsById = new Map(opts.signals.map((s) => [s.id, s]));
396
+ const weights = computeWeights(opts.config, opts.outcomes ?? [], signalsById);
397
+ // Group signals by normalized account domain.
398
+ const byAccount = new Map();
399
+ for (const signal of opts.signals) {
400
+ const domain = normalizeAccountDomain(signal.accountDomain);
401
+ if (!domain)
402
+ continue;
403
+ const list = byAccount.get(domain) ?? [];
404
+ list.push(signal);
405
+ byAccount.set(domain, list);
406
+ }
407
+ const decisions = [];
408
+ for (const [domain, signals] of byAccount) {
409
+ const recentlyTouched = opts.withHistory && opts.snapshot ? accountRecentlyTouched(domain, opts.snapshot, now) : false;
410
+ const bestContact = opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
411
+ const score = scoreAccount({
412
+ accountDomain: domain,
413
+ signals,
414
+ weights,
415
+ icp: opts.icp,
416
+ bestContact,
417
+ recentlyTouched,
418
+ now,
419
+ });
420
+ // Attach the signal map so the LLM path can resolve credited quotes.
421
+ score._signalsById = signalsById;
422
+ const decision = opts.llm && opts.promptTemplate
423
+ ? await judgeDecisionLlm(score, opts.promptTemplate, opts.llm)
424
+ : deterministicDecision(score);
425
+ decisions.push(decision);
426
+ }
427
+ return decisions.sort((a, b) => b.score - a.score || a.accountDomain.localeCompare(b.accountDomain));
428
+ }
429
+ // ---------------------------------------------------------------------------
430
+ // Store: per-label JSON judge runs, profile-scoped (mirrors the signal store).
431
+ export function judgeDir(baseDir) {
432
+ return join(baseDir ?? credentialsDir(), "judge");
433
+ }
434
+ export function createFileJudgeStore(directory) {
435
+ const dir = directory ?? judgeDir();
436
+ const runsDir = join(dir, "runs");
437
+ function fileFor(runLabel) {
438
+ if (!/^[\w.-]+$/.test(runLabel))
439
+ throw new Error(`Invalid run label: ${runLabel}`);
440
+ return join(runsDir, `${runLabel}.json`);
441
+ }
442
+ function read(runLabel) {
443
+ try {
444
+ return JSON.parse(readFileSync(fileFor(runLabel), "utf8"));
445
+ }
446
+ catch {
447
+ return null;
448
+ }
449
+ }
450
+ function write(run) {
451
+ // Run files carry account domains + verbatim source quotes; keep them
452
+ // owner-only like plan/enrich/signal files.
453
+ if (!directory)
454
+ ensureSecureHomeDir();
455
+ mkdirSync(runsDir, { recursive: true, mode: 0o700 });
456
+ writeSecureFile(fileFor(run.runLabel), `${JSON.stringify(run, null, 2)}\n`);
457
+ return run;
458
+ }
459
+ function listRunsSync() {
460
+ let names = [];
461
+ try {
462
+ names = readdirSync(runsDir).filter((name) => name.endsWith(".json"));
463
+ }
464
+ catch {
465
+ return [];
466
+ }
467
+ return names
468
+ .map((name) => read(name.slice(0, -".json".length)))
469
+ .filter((run) => run !== null)
470
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
471
+ }
472
+ return {
473
+ async appendRun(run) {
474
+ if (read(run.runLabel)) {
475
+ throw new Error(`Judge run "${run.runLabel}" already exists — runs are append-only; use a new run label`);
476
+ }
477
+ return write(run);
478
+ },
479
+ async getRun(runLabel) {
480
+ return read(runLabel);
481
+ },
482
+ async listRuns() {
483
+ return listRunsSync();
484
+ },
485
+ async latestRun() {
486
+ const runs = listRunsSync();
487
+ return runs.length ? runs[runs.length - 1] : null;
488
+ },
489
+ };
490
+ }
@@ -0,0 +1,141 @@
1
+ import { type JudgeDecision, type JudgeDecisionKind } from "./judge.ts";
2
+ import { type Signal, type SignalOutcome, type SignalsConfig } from "./signals.ts";
3
+ import type { Icp } from "./icp.ts";
4
+ /**
5
+ * The self-grading gate (`icp eval`). The judge (`icp judge`) makes a
6
+ * PROBABILISTIC claim — "this account is hot." Every other layer's correctness
7
+ * is a code review (audit rules are deterministic); the judge's is not. This
8
+ * module is the part every "AI SDR" tool skips: proving the judge is calibrated
9
+ * BEFORE it sends.
10
+ *
11
+ * Two gates, both read-only and free to run offline:
12
+ *
13
+ * 1. `gradeJudge(rows, judgeFn)` — run the judge over a labeled golden set
14
+ * ({signals, history, expectedDecision: send|skip}) and score agreement
15
+ * (accuracy + precision/recall on the binary send-vs-skip call, with a
16
+ * confusion matrix). `nurture` collapses to the non-send class: the live
17
+ * question the gate guards is "do we interrupt this account cold?" — and a
18
+ * nurture is a not-now, i.e. not a send. Ships a `DEFAULT_GOLDEN_SET` inline
19
+ * so `icp eval --golden default` runs with no file and no key.
20
+ *
21
+ * 2. `gradeAgainstOutcomes(decisions, outcomes)` — the EMPIRICAL gate: once real
22
+ * `signals outcome` results exist, do accounts the judge scored hot (>=80)
23
+ * book more than the ones it scored cold? `calibrated` is true iff
24
+ * hotBookRate > coldBookRate. "If it cannot prove hot beats cold, it does not
25
+ * send."
26
+ *
27
+ * The CLI wires both as exit-2 gates (the audit/`diff` convention) so they
28
+ * compose in front of any `apply`. This file is pure grading — no store, no
29
+ * network, no `process.exitCode` (the CLI owns the exit code).
30
+ */
31
+ /** Account CRM memory for a golden row — `touched` caps a hot account to not-now. */
32
+ export type GoldenHistory = {
33
+ /** True if the account was touched within the judge's memory window. */
34
+ recentlyTouched?: boolean;
35
+ };
36
+ /**
37
+ * One labeled example: the signals that fired on an account and the decision a
38
+ * human says the judge SHOULD reach (the binary send-vs-not the gate guards).
39
+ */
40
+ export type GoldenRow = {
41
+ /** Optional human label, for readable failure output. */
42
+ label?: string;
43
+ signals: Signal[];
44
+ history?: GoldenHistory;
45
+ /** The ground-truth call: do we interrupt this account cold, or not? */
46
+ expectedDecision: "send" | "skip";
47
+ };
48
+ /**
49
+ * A judge under test: given a row's signals + history, return the judge's call.
50
+ * Accepts a `JudgeDecision`, a bare `JudgeDecisionKind`, or a `Promise` of
51
+ * either — so the real (async, LLM-capable) judge AND a one-line deterministic
52
+ * stub both fit. `nurture` is treated as not-send when graded.
53
+ */
54
+ export type EvalJudgeFn = (row: GoldenRow) => JudgeDecision | JudgeDecisionKind | Promise<JudgeDecision | JudgeDecisionKind>;
55
+ /** 2x2 confusion matrix on the binary send-vs-skip call. */
56
+ export type Confusion = {
57
+ /** Predicted send, expected send. */
58
+ truePositive: number;
59
+ /** Predicted send, expected skip (a cold mail off a bad target). */
60
+ falsePositive: number;
61
+ /** Predicted skip, expected send (a missed hot account). */
62
+ falseNegative: number;
63
+ /** Predicted skip, expected skip (correctly held). */
64
+ trueNegative: number;
65
+ };
66
+ export type GradeResult = {
67
+ /** (tp + tn) / total — overall agreement. */
68
+ accuracy: number;
69
+ /** tp / (tp + fp) — of the accounts we'd mail, how many should we? 1 when none predicted send. */
70
+ precision: number;
71
+ /** tp / (tp + fn) — of the accounts we should mail, how many did we catch? 1 when none expected send. */
72
+ recall: number;
73
+ confusion: Confusion;
74
+ /** Rows graded (== rows.length). */
75
+ total: number;
76
+ };
77
+ export type OutcomeCalibration = {
78
+ /** Booked-meeting rate among decisions scored hot (>=80). */
79
+ hotBookRate: number;
80
+ /** Booked-meeting rate among decisions scored cold (<80). */
81
+ coldBookRate: number;
82
+ /** True iff hotBookRate > coldBookRate — the "hot beats cold" claim holds. */
83
+ calibrated: boolean;
84
+ /** Decisions in each bucket that had a recorded outcome (the denominators). */
85
+ hotCount: number;
86
+ coldCount: number;
87
+ };
88
+ /** Hot threshold for the empirical gate — mirrors the judge's strong band (>=80). */
89
+ export declare const HOT_SCORE = 80;
90
+ /** Default golden-set pass bar (`--min-accuracy`). */
91
+ export declare const DEFAULT_MIN_ACCURACY = 0.8;
92
+ /**
93
+ * Run `judgeFn` over each golden row and score agreement on the binary
94
+ * send-vs-skip call (nurture counts as not-send). Deterministic given a
95
+ * deterministic `judgeFn`; the only async is the judge itself (so an LLM judge
96
+ * can be graded with the same harness). Empty `rows` → perfect-but-vacuous
97
+ * scores (accuracy 1) — the CLI guards against an empty set separately.
98
+ */
99
+ export declare function gradeJudge(rows: GoldenRow[], judgeFn: EvalJudgeFn): Promise<GradeResult>;
100
+ /**
101
+ * The deterministic judge wrapped as an `EvalJudgeFn`: group a row's signals by
102
+ * account, score the (single, by construction) account on timing × fit × memory
103
+ * via `scoreAccount`, and return the deterministic decision. This is the SAME
104
+ * scorer `icp judge` runs key-free, so `gradeJudge(rows, defaultJudgeFn())`
105
+ * grades the real baseline — not a re-implementation. A row with no signals is
106
+ * a skip (nothing changed → nothing to reach out about).
107
+ */
108
+ export declare function defaultJudgeFn(opts?: {
109
+ config?: SignalsConfig;
110
+ icp?: Icp;
111
+ now?: Date;
112
+ }): EvalJudgeFn;
113
+ /**
114
+ * A starter labeled set covering the four rubric corners, so `icp eval --golden
115
+ * default` runs offline and the deterministic baseline passes it (>= the default
116
+ * min-accuracy). Each row is a single account.
117
+ *
118
+ * - SEND: a fresh clustered account (funding + reposted live req) — strong band.
119
+ * - SEND: a single high-weight demand/funding signal that still clears the bar.
120
+ * - SKIP: a lone low-intent social blip — weak band, first-class no.
121
+ * - SKIP: a single company-news mention with no in-persona trigger — weak.
122
+ * - SKIP: a hot account TOUCHED within the memory window — don't pile on.
123
+ */
124
+ export declare const DEFAULT_GOLDEN_SET: GoldenRow[];
125
+ /**
126
+ * Parse a golden set from JSON or JSONL (one row per line). Validates each row
127
+ * up front and names the offending entry (the enrich/signals parse idiom). The
128
+ * literal "default" resolves to `DEFAULT_GOLDEN_SET` (handled by the CLI before
129
+ * reading a file; this parses an actual file's contents).
130
+ */
131
+ export declare function parseGoldenSet(raw: string): GoldenRow[];
132
+ /**
133
+ * Grade the judge's decisions against accumulated `signals outcome` results: of
134
+ * the decisions scored hot (>=80) vs cold (<80), which books meetings more
135
+ * often? A decision is matched to outcomes by account domain (and an outcome
136
+ * "books" when its result is "meeting"). Accounts with no recorded outcome are
137
+ * excluded from the denominators. `calibrated` is true iff hot strictly beats
138
+ * cold — absent or inverted correlation is NOT calibrated (the CLI exits
139
+ * nonzero on it). Deterministic.
140
+ */
141
+ export declare function gradeAgainstOutcomes(decisions: JudgeDecision[], outcomes: SignalOutcome[]): OutcomeCalibration;