crewhaus 0.1.8 → 0.2.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.
Files changed (136) hide show
  1. package/README.md +10 -3
  2. package/dist/advice-apply.d.ts +182 -0
  3. package/dist/advice-apply.js +286 -0
  4. package/dist/advise-rules.d.ts +348 -0
  5. package/dist/advise-rules.js +905 -0
  6. package/dist/alert-sink.d.ts +48 -0
  7. package/dist/alert-sink.js +86 -0
  8. package/dist/approval-gate.d.ts +127 -0
  9. package/dist/approval-gate.js +254 -0
  10. package/dist/audit-verify.d.ts +69 -0
  11. package/dist/audit-verify.js +97 -0
  12. package/dist/autodistill.d.ts +113 -0
  13. package/dist/autodistill.js +256 -0
  14. package/dist/channel-provision.d.ts +360 -0
  15. package/dist/channel-provision.js +881 -0
  16. package/dist/ci-scaffold.d.ts +31 -0
  17. package/dist/ci-scaffold.js +343 -0
  18. package/dist/compile-check.d.ts +90 -0
  19. package/dist/compile-check.js +285 -0
  20. package/dist/compliance-schedule.d.ts +35 -0
  21. package/dist/compliance-schedule.js +36 -0
  22. package/dist/context-pressure.d.ts +80 -0
  23. package/dist/context-pressure.js +166 -0
  24. package/dist/dataset-mine.d.ts +172 -0
  25. package/dist/dataset-mine.js +403 -0
  26. package/dist/datasets.d.ts +124 -0
  27. package/dist/datasets.js +260 -0
  28. package/dist/deploy-canary.d.ts +83 -0
  29. package/dist/deploy-canary.js +87 -0
  30. package/dist/doctor-checks.d.ts +33 -0
  31. package/dist/doctor-checks.js +92 -0
  32. package/dist/doctor-detect.d.ts +108 -0
  33. package/dist/doctor-detect.js +214 -0
  34. package/dist/doctor-fix.d.ts +81 -0
  35. package/dist/doctor-fix.js +164 -0
  36. package/dist/egress-triage.d.ts +121 -0
  37. package/dist/egress-triage.js +261 -0
  38. package/dist/eval-bridge.d.ts +114 -0
  39. package/dist/eval-bridge.js +158 -0
  40. package/dist/eval-coverage.d.ts +140 -0
  41. package/dist/eval-coverage.js +428 -0
  42. package/dist/eval-history.d.ts +48 -0
  43. package/dist/eval-history.js +157 -0
  44. package/dist/eval-matrix.d.ts +80 -0
  45. package/dist/eval-matrix.js +182 -0
  46. package/dist/eval-sentinel.d.ts +65 -0
  47. package/dist/eval-sentinel.js +132 -0
  48. package/dist/faq.d.ts +68 -0
  49. package/dist/faq.js +168 -0
  50. package/dist/feedback.d.ts +9 -2
  51. package/dist/feedback.js +17 -7
  52. package/dist/fewshot.d.ts +83 -0
  53. package/dist/fewshot.js +158 -0
  54. package/dist/fleet.d.ts +207 -0
  55. package/dist/fleet.js +488 -0
  56. package/dist/flywheel.d.ts +193 -0
  57. package/dist/flywheel.js +519 -0
  58. package/dist/graders-suggest.d.ts +186 -0
  59. package/dist/graders-suggest.js +658 -0
  60. package/dist/incident.d.ts +99 -0
  61. package/dist/incident.js +217 -0
  62. package/dist/index.d.ts +9 -1
  63. package/dist/index.js +11601 -964
  64. package/dist/init-interactive.d.ts +105 -0
  65. package/dist/init-interactive.js +208 -0
  66. package/dist/intents.d.ts +105 -0
  67. package/dist/intents.js +292 -0
  68. package/dist/judge-calibrate.d.ts +137 -0
  69. package/dist/judge-calibrate.js +247 -0
  70. package/dist/justification-calibrate.d.ts +150 -0
  71. package/dist/justification-calibrate.js +262 -0
  72. package/dist/justification-gate.d.ts +27 -6
  73. package/dist/justification-gate.js +30 -6
  74. package/dist/knowledge-sync.d.ts +179 -0
  75. package/dist/knowledge-sync.js +551 -0
  76. package/dist/lessons.d.ts +87 -0
  77. package/dist/lessons.js +207 -0
  78. package/dist/lint.d.ts +127 -0
  79. package/dist/lint.js +226 -0
  80. package/dist/loadtest.d.ts +114 -0
  81. package/dist/loadtest.js +196 -0
  82. package/dist/marketplace-cli.d.ts +110 -0
  83. package/dist/marketplace-cli.js +250 -0
  84. package/dist/mcp-doctor.d.ts +121 -0
  85. package/dist/mcp-doctor.js +249 -0
  86. package/dist/model-scan.d.ts +116 -0
  87. package/dist/model-scan.js +226 -0
  88. package/dist/onchain-tune.d.ts +164 -0
  89. package/dist/onchain-tune.js +346 -0
  90. package/dist/permissions-suggest.d.ts +126 -0
  91. package/dist/permissions-suggest.js +333 -0
  92. package/dist/pii-tune.d.ts +107 -0
  93. package/dist/pii-tune.js +122 -0
  94. package/dist/propose.d.ts +117 -0
  95. package/dist/propose.js +184 -0
  96. package/dist/refresh-goldens.d.ts +82 -0
  97. package/dist/refresh-goldens.js +221 -0
  98. package/dist/regression-pin.d.ts +160 -0
  99. package/dist/regression-pin.js +281 -0
  100. package/dist/retention.d.ts +193 -0
  101. package/dist/retention.js +607 -0
  102. package/dist/retire.d.ts +118 -0
  103. package/dist/retire.js +291 -0
  104. package/dist/right-size.d.ts +100 -0
  105. package/dist/right-size.js +123 -0
  106. package/dist/scaffold-evals.d.ts +138 -0
  107. package/dist/scaffold-evals.js +410 -0
  108. package/dist/scope-audit-drift.d.ts +139 -0
  109. package/dist/scope-audit-drift.js +260 -0
  110. package/dist/security-corpus.d.ts +237 -0
  111. package/dist/security-corpus.js +516 -0
  112. package/dist/security-digest.d.ts +173 -0
  113. package/dist/security-digest.js +650 -0
  114. package/dist/sessions-index.d.ts +27 -0
  115. package/dist/sessions-index.js +51 -0
  116. package/dist/slo-doctor.d.ts +67 -0
  117. package/dist/slo-doctor.js +119 -0
  118. package/dist/slo-sink.d.ts +96 -0
  119. package/dist/slo-sink.js +107 -0
  120. package/dist/spec-changelog.d.ts +102 -0
  121. package/dist/spec-changelog.js +237 -0
  122. package/dist/state-backup.d.ts +223 -0
  123. package/dist/state-backup.js +648 -0
  124. package/dist/tools-cli.d.ts +170 -0
  125. package/dist/tools-cli.js +298 -0
  126. package/dist/triage.d.ts +202 -0
  127. package/dist/triage.js +403 -0
  128. package/dist/upgrade.d.ts +57 -0
  129. package/dist/upgrade.js +113 -0
  130. package/dist/version.d.ts +6 -0
  131. package/dist/version.js +27 -0
  132. package/dist/voice-eval.d.ts +138 -0
  133. package/dist/voice-eval.js +309 -0
  134. package/dist/watch.d.ts +58 -0
  135. package/dist/watch.js +97 -0
  136. package/package.json +89 -65
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Item 67 — `crewhaus intents`: end-user intent analytics. What are people
3
+ * actually asking the harness, and how well is it serving them?
4
+ *
5
+ * Clusters `user_message` inputs across sessions with the SAME deterministic
6
+ * greedy-Jaccard token clustering the `faq` / `graders suggest` / coverage
7
+ * features use (no model call), then ranks each intent cluster by:
8
+ *
9
+ * - FREQUENCY: total occurrences + distinct sessions.
10
+ * - SATISFACTION: mean normalized rating of the cluster's rated turns.
11
+ * - FAILURE RATE: share of the cluster's turns that hit a struggle signal
12
+ * (error / tool-error / loop / retry — mined by the same
13
+ * `dataset mine` signals), i.e. an UNMET intent.
14
+ * - RECENCY: first vs second half of the session window → a "rising"
15
+ * intent asked much more in the recent half.
16
+ *
17
+ * From those it surfaces four views: top intents, rising intents, low-
18
+ * satisfaction intents (down-rated / high-error), and unmet intents (the agent
19
+ * couldn't answer — errors/loops/retries). This is the product-analytics feed
20
+ * for `dataset mine` / `faq distill` / few-shot harvest.
21
+ *
22
+ * Everything here is pure + deterministic (the CLI entry file reads sessions +
23
+ * feedback and PII/secret-redacts the rendered examples via a passed-in
24
+ * callback). No credential/secret text enters a rendered example un-redacted.
25
+ */
26
+ import { mergeFeedback, normalizeRating } from "./feedback";
27
+ import { normalizeEvidenceTokens } from "./graders-suggest";
28
+ /** Thrown on malformed inputs / bad flags. The CLI routes it through `die()`. */
29
+ export class IntentsError extends Error {
30
+ name = "IntentsError";
31
+ }
32
+ function jaccard(a, b) {
33
+ let intersection = 0;
34
+ for (const t of a)
35
+ if (b.has(t))
36
+ intersection += 1;
37
+ const union = a.size + b.size - intersection;
38
+ return union === 0 ? 0 : intersection / union;
39
+ }
40
+ /** session#turn composite key — `#` never collides (session ids are hex,
41
+ * turnNumber an int), so no control/NUL byte enters a map key. */
42
+ function turnKey(sessionId, turnNumber) {
43
+ return `${sessionId}#${turnNumber}`;
44
+ }
45
+ /**
46
+ * Cluster ordered user turns into ranked intents. Deterministic: turns are
47
+ * clustered greedily by normalized-token Jaccard overlap in `order` sequence;
48
+ * each cluster's representative is the highest-token-frequency question (ties →
49
+ * shortest, then lexicographic). The recency split is the median `order`.
50
+ */
51
+ export function clusterIntents(turns, feedback, failedTurnKeys, opts = {}) {
52
+ const threshold = opts.threshold ?? 0.5;
53
+ const lowBelow = opts.lowSatisfactionBelow ?? 0.5;
54
+ const unmetAtLeast = opts.unmetFailureAtLeast ?? 0.34;
55
+ const topN = opts.topN ?? 5;
56
+ const examplesPer = opts.examplesPerIntent ?? 3;
57
+ const ratingByKey = new Map();
58
+ for (const fb of mergeFeedback(feedback)) {
59
+ const r = normalizeRating(fb);
60
+ if (r !== undefined)
61
+ ratingByKey.set(turnKey(fb.sessionId, fb.turnNumber), r);
62
+ }
63
+ const failedKeys = new Set(failedTurnKeys.map((s) => turnKey(s.sessionId, s.turnNumber)));
64
+ const records = turns
65
+ .filter((t) => t.input.trim() !== "")
66
+ .map((t) => ({
67
+ sessionId: t.sessionId,
68
+ turnNumber: t.turnNumber,
69
+ question: t.input.trim(),
70
+ order: t.order,
71
+ rating: ratingByKey.get(turnKey(t.sessionId, t.turnNumber)),
72
+ failed: failedKeys.has(turnKey(t.sessionId, t.turnNumber)),
73
+ }))
74
+ // Stable clustering order: chronological, tie-broken deterministically.
75
+ .sort((a, b) => a.order - b.order || a.sessionId.localeCompare(b.sessionId) || a.turnNumber - b.turnNumber);
76
+ const totalSessions = new Set(records.map((r) => r.sessionId)).size;
77
+ // Median order for the rising split (older half < median ≤ recent half).
78
+ const orders = records.map((r) => r.order).sort((a, b) => a - b);
79
+ const medianOrder = orders.length === 0 ? 0 : orders[Math.floor(orders.length / 2)];
80
+ const clusters = [];
81
+ for (const rec of records) {
82
+ const tokens = new Set(normalizeEvidenceTokens(rec.question));
83
+ if (tokens.size === 0)
84
+ continue;
85
+ let bestIdx = -1;
86
+ let bestOverlap = 0;
87
+ for (let i = 0; i < clusters.length; i += 1) {
88
+ const overlap = jaccard(tokens, clusters[i].seed);
89
+ if (overlap > bestOverlap) {
90
+ bestOverlap = overlap;
91
+ bestIdx = i;
92
+ }
93
+ }
94
+ if (bestIdx >= 0 && bestOverlap >= threshold) {
95
+ const c = clusters[bestIdx];
96
+ c.items.push(rec);
97
+ for (const t of tokens)
98
+ c.tokenFreq.set(t, (c.tokenFreq.get(t) ?? 0) + 1);
99
+ }
100
+ else {
101
+ const tokenFreq = new Map();
102
+ for (const t of tokens)
103
+ tokenFreq.set(t, 1);
104
+ clusters.push({ seed: tokens, items: [rec], tokenFreq });
105
+ }
106
+ }
107
+ const intents = clusters.map((c) => {
108
+ const occurrences = c.items.length;
109
+ const sessionCount = new Set(c.items.map((r) => r.sessionId)).size;
110
+ const rated = c.items.filter((r) => r.rating !== undefined);
111
+ const meanRating = rated.length === 0
112
+ ? undefined
113
+ : rated.reduce((acc, r) => acc + r.rating, 0) / rated.length;
114
+ const failedTurns = c.items.filter((r) => r.failed).length;
115
+ const earlyCount = c.items.filter((r) => r.order < medianOrder).length;
116
+ const recentCount = occurrences - earlyCount;
117
+ // Representative: score each question by summed token frequency; higher is
118
+ // more central. Ties → shorter question, then lexicographic (deterministic).
119
+ const scored = c.items
120
+ .map((r) => {
121
+ const toks = normalizeEvidenceTokens(r.question);
122
+ const freq = toks.reduce((acc, t) => acc + (c.tokenFreq.get(t) ?? 0), 0);
123
+ return { question: r.question, freq };
124
+ })
125
+ .sort((a, b) => b.freq - a.freq ||
126
+ a.question.length - b.question.length ||
127
+ a.question.localeCompare(b.question));
128
+ const representative = scored[0]?.question ?? "";
129
+ // Distinct example questions, most-central first.
130
+ const examples = [];
131
+ for (const s of scored) {
132
+ if (!examples.includes(s.question))
133
+ examples.push(s.question);
134
+ if (examples.length >= examplesPer)
135
+ break;
136
+ }
137
+ return {
138
+ representative,
139
+ occurrences,
140
+ sessionCount,
141
+ meanRating,
142
+ failedTurns,
143
+ failureRate: occurrences === 0 ? 0 : failedTurns / occurrences,
144
+ earlyCount,
145
+ recentCount,
146
+ examples,
147
+ };
148
+ });
149
+ // Rank: frequency-first for the master list.
150
+ const byFrequency = [...intents].sort((a, b) => b.occurrences - a.occurrences ||
151
+ b.sessionCount - a.sessionCount ||
152
+ a.representative.localeCompare(b.representative));
153
+ const topIntents = byFrequency.slice(0, topN);
154
+ // Rising: recentCount > earlyCount, ranked by the delta (then frequency).
155
+ const risingIntents = [...intents]
156
+ .filter((i) => i.recentCount > i.earlyCount)
157
+ .sort((a, b) => b.recentCount - b.earlyCount - (a.recentCount - a.earlyCount) ||
158
+ b.occurrences - a.occurrences ||
159
+ a.representative.localeCompare(b.representative))
160
+ .slice(0, topN);
161
+ // Low satisfaction: rated AND below the threshold, worst first (then frequency).
162
+ const lowSatisfactionIntents = [...intents]
163
+ .filter((i) => i.meanRating !== undefined && i.meanRating < lowBelow)
164
+ .sort((a, b) => a.meanRating - b.meanRating ||
165
+ b.occurrences - a.occurrences ||
166
+ a.representative.localeCompare(b.representative))
167
+ .slice(0, topN);
168
+ // Unmet: failure rate at/above the threshold, highest first (then frequency).
169
+ const unmetIntents = [...intents]
170
+ .filter((i) => i.failureRate >= unmetAtLeast && i.failedTurns > 0)
171
+ .sort((a, b) => b.failureRate - a.failureRate ||
172
+ b.occurrences - a.occurrences ||
173
+ a.representative.localeCompare(b.representative))
174
+ .slice(0, topN);
175
+ return {
176
+ totalTurns: records.length,
177
+ totalSessions,
178
+ intents: byFrequency,
179
+ topIntents,
180
+ risingIntents,
181
+ lowSatisfactionIntents,
182
+ unmetIntents,
183
+ };
184
+ }
185
+ /** Build ordered turns from per-session events, oldest session first. The
186
+ * caller passes sessions already in chronological order (oldest→newest). */
187
+ export function orderedTurnsFromSessions(perSession, deriveTurns) {
188
+ const out = [];
189
+ let order = 0;
190
+ for (const { sessionId, events } of perSession) {
191
+ for (const t of deriveTurns(events)) {
192
+ out.push({ ...t, sessionId, order });
193
+ order += 1;
194
+ }
195
+ }
196
+ return out;
197
+ }
198
+ // ---------------------------------------------------------------------------
199
+ // Rendering — text / json / html. `redact` is applied to every example string.
200
+ // ---------------------------------------------------------------------------
201
+ function redactExamples(intent, redact) {
202
+ return {
203
+ ...intent,
204
+ representative: redact(intent.representative),
205
+ examples: intent.examples.map(redact),
206
+ };
207
+ }
208
+ /** Apply the redactor to every rendered string in the digest. */
209
+ export function redactDigest(digest, redact) {
210
+ const map = (list) => list.map((i) => redactExamples(i, redact));
211
+ return {
212
+ ...digest,
213
+ intents: map(digest.intents),
214
+ topIntents: map(digest.topIntents),
215
+ risingIntents: map(digest.risingIntents),
216
+ lowSatisfactionIntents: map(digest.lowSatisfactionIntents),
217
+ unmetIntents: map(digest.unmetIntents),
218
+ };
219
+ }
220
+ function fmtRating(r) {
221
+ return r === undefined ? "n/a" : `${(r * 100).toFixed(0)}%`;
222
+ }
223
+ /** Render the digest as plain text. */
224
+ export function renderIntentsText(digest) {
225
+ const lines = [];
226
+ lines.push(`intent analytics: ${digest.totalTurns} user turns across ${digest.totalSessions} sessions, ${digest.intents.length} intent(s)`);
227
+ const section = (title, list, detail) => {
228
+ lines.push("");
229
+ lines.push(title);
230
+ if (list.length === 0) {
231
+ lines.push(" (none)");
232
+ return;
233
+ }
234
+ for (const i of list)
235
+ lines.push(` - ${i.representative} — ${detail(i)}`);
236
+ };
237
+ section("TOP INTENTS", digest.topIntents, (i) => `${i.occurrences}× in ${i.sessionCount} session(s), rating ${fmtRating(i.meanRating)}`);
238
+ section("RISING INTENTS", digest.risingIntents, (i) => `${i.earlyCount} → ${i.recentCount} (older → recent)`);
239
+ section("LOW SATISFACTION", digest.lowSatisfactionIntents, (i) => `rating ${fmtRating(i.meanRating)} over ${i.occurrences} turn(s)`);
240
+ section("UNMET (errors/loops/retries)", digest.unmetIntents, (i) => `${(i.failureRate * 100).toFixed(0)}% failed (${i.failedTurns}/${i.occurrences})`);
241
+ return `${lines.join("\n")}\n`;
242
+ }
243
+ /** Render the digest as pretty JSON. */
244
+ export function renderIntentsJson(digest) {
245
+ return `${JSON.stringify(digest, null, 2)}\n`;
246
+ }
247
+ function esc(s) {
248
+ return s
249
+ .replace(/&/g, "&amp;")
250
+ .replace(/</g, "&lt;")
251
+ .replace(/>/g, "&gt;")
252
+ .replace(/"/g, "&quot;");
253
+ }
254
+ /** Render the digest as a self-contained HTML report. */
255
+ export function renderIntentsHtml(digest) {
256
+ const rows = (list, cols) => list.length === 0
257
+ ? '<tr><td class="empty">(none)</td></tr>'
258
+ : list
259
+ .map((i) => `<tr><td>${esc(i.representative)}</td>${cols(i)
260
+ .map((c) => `<td>${esc(c)}</td>`)
261
+ .join("")}</tr>`)
262
+ .join("");
263
+ return `<!doctype html>
264
+ <html><head><meta charset="utf-8"><title>Intent analytics</title>
265
+ <style>
266
+ body{font:14px system-ui,sans-serif;margin:2rem;color:#111}
267
+ h1{font-size:1.4rem} h2{font-size:1.05rem;margin-top:1.5rem}
268
+ table{border-collapse:collapse;width:100%;margin-top:.5rem}
269
+ th,td{border:1px solid #ddd;padding:.4rem .6rem;text-align:left;vertical-align:top}
270
+ th{background:#f5f5f5} td.empty{color:#999;font-style:italic}
271
+ </style></head><body>
272
+ <h1>Intent analytics</h1>
273
+ <p>${digest.totalTurns} user turns across ${digest.totalSessions} sessions, ${digest.intents.length} intent(s).</p>
274
+ <h2>Top intents</h2>
275
+ <table><tr><th>Intent</th><th>Occurrences</th><th>Sessions</th><th>Rating</th></tr>
276
+ ${rows(digest.topIntents, (i) => [String(i.occurrences), String(i.sessionCount), fmtRating(i.meanRating)])}
277
+ </table>
278
+ <h2>Rising intents</h2>
279
+ <table><tr><th>Intent</th><th>Older</th><th>Recent</th></tr>
280
+ ${rows(digest.risingIntents, (i) => [String(i.earlyCount), String(i.recentCount)])}
281
+ </table>
282
+ <h2>Low satisfaction</h2>
283
+ <table><tr><th>Intent</th><th>Rating</th><th>Turns</th></tr>
284
+ ${rows(digest.lowSatisfactionIntents, (i) => [fmtRating(i.meanRating), String(i.occurrences)])}
285
+ </table>
286
+ <h2>Unmet (errors / loops / retries)</h2>
287
+ <table><tr><th>Intent</th><th>Failure rate</th><th>Failed / total</th></tr>
288
+ ${rows(digest.unmetIntents, (i) => [`${(i.failureRate * 100).toFixed(0)}%`, `${i.failedTurns}/${i.occurrences}`])}
289
+ </table>
290
+ </body></html>
291
+ `;
292
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Item 8 — `crewhaus judge calibrate`: measure how well an llm_judge grader
3
+ * agrees with real human ratings, and recommend the score cut that best
4
+ * separates the answers users liked from the ones they didn't.
5
+ *
6
+ * The command pairs, for each turn that carries BOTH a human `user_feedback`
7
+ * rating AND can be judged, the (human rating, llm_judge score). The judge
8
+ * call itself is a model-dependent seam that lives in the CLI entry file
9
+ * (re-running the judge over the rated transcript turn); this module owns the
10
+ * PURE statistics over the resulting pairs so they are unit-testable without a
11
+ * provider:
12
+ * - agreement: Pearson correlation + a confusion matrix at the current cut.
13
+ * - systematic bias: judge mean − human mean (both normalized to [0,1]).
14
+ * - the ROC-optimal cut: the judge score threshold maximizing Youden's J
15
+ * (TPR − FPR) at separating up- from down-rated turns.
16
+ * - per-rubric disagreement flags: rubrics whose judge-vs-human disagreement
17
+ * exceeds a threshold, naming the specific exemplars to re-anchor.
18
+ *
19
+ * `--apply` writes the per-spec calibrated `--min-score` default to
20
+ * `.crewhaus/judge-calibration.json` (a small documented file distill/optimize
21
+ * could later consult). Kept side-effect-free (the CLI entry file runs an argv
22
+ * switch on import) mirroring `feedback.ts` / `graders-suggest.ts`; all
23
+ * filesystem access + the judge model call live in `apps/cli/src/index.ts`.
24
+ */
25
+ /** Thrown on malformed flags / unusable inputs. The CLI entry file routes it
26
+ * through `die()`; tests assert on `.message`. */
27
+ export declare class JudgeCalibrateError extends Error {
28
+ readonly name = "JudgeCalibrateError";
29
+ }
30
+ /** The [0,1] score at/above which a turn is treated as "up-rated" (positive)
31
+ * for confusion + ROC — the same 0.5 midpoint normalizeRating maps a thumbs
32
+ * up/down onto. */
33
+ export declare const POSITIVE_THRESHOLD = 0.5;
34
+ /** Default judge cut (normalized [0,1]) the confusion matrix reports against,
35
+ * matching distill/eval's default --min-score 0.7 → passing_score 3/5. */
36
+ export declare const DEFAULT_JUDGE_CUT = 0.5;
37
+ /** One paired observation: a human rating and the llm_judge's score for the
38
+ * same turn. Both are recorded in native units; helpers normalize. */
39
+ export type CalibrationPair = {
40
+ readonly sessionId: string;
41
+ readonly turnNumber: number;
42
+ /** Human rating normalized to [0,1] (thumbs up=1/down=0, stars (n-1)/4, …). */
43
+ readonly human: number;
44
+ /** Raw 1–5 llm_judge score. */
45
+ readonly judge: number;
46
+ /** Per-criterion 1–5 judge scores, when the rubric had named criteria. */
47
+ readonly criterionScores?: Readonly<Record<string, number>>;
48
+ };
49
+ /** Normalize a 1–5 judge score to [0,1] — the same (n-1)/4 map GradeResult uses. */
50
+ export declare function normalizeJudge(score: number): number;
51
+ export type ConfusionMatrix = {
52
+ /** judge-pass & human-up. */
53
+ readonly tp: number;
54
+ /** judge-pass & human-down. */
55
+ readonly fp: number;
56
+ /** judge-fail & human-down. */
57
+ readonly tn: number;
58
+ /** judge-fail & human-up. */
59
+ readonly fn: number;
60
+ };
61
+ /** Confusion of judge (normalized ≥ `judgeCut`) vs human (≥ POSITIVE_THRESHOLD). */
62
+ export declare function confusionAt(pairs: ReadonlyArray<CalibrationPair>, judgeCut: number): ConfusionMatrix;
63
+ /** Accuracy of the judge's pass/fail vs the human up/down at a cut. */
64
+ export declare function accuracyAt(pairs: ReadonlyArray<CalibrationPair>, judgeCut: number): number;
65
+ /**
66
+ * Pearson correlation between the human ratings and the normalized judge
67
+ * scores. Returns 0 when there is no variance in either series (a degenerate
68
+ * "all the same" set correlates with nothing).
69
+ */
70
+ export declare function pearson(pairs: ReadonlyArray<CalibrationPair>): number;
71
+ /** Systematic bias: mean(normalized judge) − mean(human). Positive ⇒ the judge
72
+ * is more generous than users; negative ⇒ harsher. */
73
+ export declare function judgeBias(pairs: ReadonlyArray<CalibrationPair>): number;
74
+ export type RocPoint = {
75
+ readonly cut: number;
76
+ readonly tpr: number;
77
+ readonly fpr: number;
78
+ readonly youdenJ: number;
79
+ };
80
+ /**
81
+ * Sweep candidate judge cut points (the 5 normalized anchors 0, .25, .5, .75, 1
82
+ * plus midpoints between observed scores) and return the one that maximizes
83
+ * Youden's J = TPR − FPR at separating up-rated (human ≥ 0.5) from down-rated
84
+ * turns. Ties break toward the LOWER cut (more inclusive), then the midpoint
85
+ * default. Returns undefined when the set has no positives or no negatives (a
86
+ * ROC point is undefined without both classes).
87
+ */
88
+ export declare function rocOptimalCut(pairs: ReadonlyArray<CalibrationPair>): RocPoint | undefined;
89
+ export type RubricDisagreement = {
90
+ readonly criterion: string;
91
+ /** Mean |normalized judge criterion − human| for this criterion. */
92
+ readonly meanAbsError: number;
93
+ /** The sessionId#turn exemplars where they disagreed most (up to 3). */
94
+ readonly exemplars: ReadonlyArray<string>;
95
+ };
96
+ /**
97
+ * Flag rubric criteria whose judge-vs-human disagreement exceeds `threshold`
98
+ * (mean absolute error on [0,1]). For each flagged criterion the worst
99
+ * exemplars are named so the user knows exactly which transcripts to look at
100
+ * when re-anchoring the rubric. Criteria absent from the pairs are skipped.
101
+ */
102
+ export declare function flagDisagreements(pairs: ReadonlyArray<CalibrationPair>, threshold?: number): RubricDisagreement[];
103
+ export type CalibrationCard = {
104
+ readonly specName?: string;
105
+ readonly model?: string;
106
+ readonly pairCount: number;
107
+ readonly correlation: number;
108
+ readonly bias: number;
109
+ readonly accuracyAtDefault: number;
110
+ readonly confusionAtDefault: ConfusionMatrix;
111
+ readonly recommendedCut?: RocPoint;
112
+ readonly disagreements: ReadonlyArray<RubricDisagreement>;
113
+ };
114
+ /** Compute the full calibration card from the paired data. */
115
+ export declare function buildCalibrationCard(pairs: ReadonlyArray<CalibrationPair>, opts?: {
116
+ specName?: string;
117
+ model?: string;
118
+ }): CalibrationCard;
119
+ /** The on-disk `.crewhaus/judge-calibration.json` schema `--apply` writes. */
120
+ export type JudgeCalibrationFile = {
121
+ readonly version: 1;
122
+ /** Per spec name → the calibrated recommended --min-score cut ([0,1]). */
123
+ readonly calibrations: Record<string, {
124
+ readonly minScore: number;
125
+ readonly model?: string;
126
+ readonly correlation: number;
127
+ readonly bias: number;
128
+ readonly pairCount: number;
129
+ readonly updatedAt: string;
130
+ }>;
131
+ };
132
+ /** Build/merge the persisted calibration entry for a spec. Existing entries for
133
+ * OTHER specs are preserved. Returns the file object to write. The recommended
134
+ * cut is the ROC-optimal cut when available, else the default. */
135
+ export declare function buildCalibrationFile(existing: JudgeCalibrationFile | undefined, card: CalibrationCard, now: string): JudgeCalibrationFile;
136
+ /** Render the calibration card as a terminal report. */
137
+ export declare function renderCalibrationCard(card: CalibrationCard): string;
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Item 8 — `crewhaus judge calibrate`: measure how well an llm_judge grader
3
+ * agrees with real human ratings, and recommend the score cut that best
4
+ * separates the answers users liked from the ones they didn't.
5
+ *
6
+ * The command pairs, for each turn that carries BOTH a human `user_feedback`
7
+ * rating AND can be judged, the (human rating, llm_judge score). The judge
8
+ * call itself is a model-dependent seam that lives in the CLI entry file
9
+ * (re-running the judge over the rated transcript turn); this module owns the
10
+ * PURE statistics over the resulting pairs so they are unit-testable without a
11
+ * provider:
12
+ * - agreement: Pearson correlation + a confusion matrix at the current cut.
13
+ * - systematic bias: judge mean − human mean (both normalized to [0,1]).
14
+ * - the ROC-optimal cut: the judge score threshold maximizing Youden's J
15
+ * (TPR − FPR) at separating up- from down-rated turns.
16
+ * - per-rubric disagreement flags: rubrics whose judge-vs-human disagreement
17
+ * exceeds a threshold, naming the specific exemplars to re-anchor.
18
+ *
19
+ * `--apply` writes the per-spec calibrated `--min-score` default to
20
+ * `.crewhaus/judge-calibration.json` (a small documented file distill/optimize
21
+ * could later consult). Kept side-effect-free (the CLI entry file runs an argv
22
+ * switch on import) mirroring `feedback.ts` / `graders-suggest.ts`; all
23
+ * filesystem access + the judge model call live in `apps/cli/src/index.ts`.
24
+ */
25
+ /** Thrown on malformed flags / unusable inputs. The CLI entry file routes it
26
+ * through `die()`; tests assert on `.message`. */
27
+ export class JudgeCalibrateError extends Error {
28
+ name = "JudgeCalibrateError";
29
+ }
30
+ /** The [0,1] score at/above which a turn is treated as "up-rated" (positive)
31
+ * for confusion + ROC — the same 0.5 midpoint normalizeRating maps a thumbs
32
+ * up/down onto. */
33
+ export const POSITIVE_THRESHOLD = 0.5;
34
+ /** Default judge cut (normalized [0,1]) the confusion matrix reports against,
35
+ * matching distill/eval's default --min-score 0.7 → passing_score 3/5. */
36
+ export const DEFAULT_JUDGE_CUT = 0.5;
37
+ /** Normalize a 1–5 judge score to [0,1] — the same (n-1)/4 map GradeResult uses. */
38
+ export function normalizeJudge(score) {
39
+ return clamp01((score - 1) / 4);
40
+ }
41
+ function clamp01(n) {
42
+ return n < 0 ? 0 : n > 1 ? 1 : n;
43
+ }
44
+ /** Confusion of judge (normalized ≥ `judgeCut`) vs human (≥ POSITIVE_THRESHOLD). */
45
+ export function confusionAt(pairs, judgeCut) {
46
+ let tp = 0;
47
+ let fp = 0;
48
+ let tn = 0;
49
+ let fn = 0;
50
+ for (const p of pairs) {
51
+ const judgePass = normalizeJudge(p.judge) >= judgeCut;
52
+ const humanUp = p.human >= POSITIVE_THRESHOLD;
53
+ if (judgePass && humanUp)
54
+ tp += 1;
55
+ else if (judgePass && !humanUp)
56
+ fp += 1;
57
+ else if (!judgePass && !humanUp)
58
+ tn += 1;
59
+ else
60
+ fn += 1;
61
+ }
62
+ return { tp, fp, tn, fn };
63
+ }
64
+ /** Accuracy of the judge's pass/fail vs the human up/down at a cut. */
65
+ export function accuracyAt(pairs, judgeCut) {
66
+ if (pairs.length === 0)
67
+ return 0;
68
+ const { tp, fp, tn, fn } = confusionAt(pairs, judgeCut);
69
+ return (tp + tn) / (tp + fp + tn + fn);
70
+ }
71
+ /**
72
+ * Pearson correlation between the human ratings and the normalized judge
73
+ * scores. Returns 0 when there is no variance in either series (a degenerate
74
+ * "all the same" set correlates with nothing).
75
+ */
76
+ export function pearson(pairs) {
77
+ const n = pairs.length;
78
+ if (n < 2)
79
+ return 0;
80
+ const xs = pairs.map((p) => p.human);
81
+ const ys = pairs.map((p) => normalizeJudge(p.judge));
82
+ const mx = mean(xs);
83
+ const my = mean(ys);
84
+ let cov = 0;
85
+ let vx = 0;
86
+ let vy = 0;
87
+ for (let i = 0; i < n; i += 1) {
88
+ const dx = xs[i] - mx;
89
+ const dy = ys[i] - my;
90
+ cov += dx * dy;
91
+ vx += dx * dx;
92
+ vy += dy * dy;
93
+ }
94
+ if (vx === 0 || vy === 0)
95
+ return 0;
96
+ return cov / Math.sqrt(vx * vy);
97
+ }
98
+ /** Systematic bias: mean(normalized judge) − mean(human). Positive ⇒ the judge
99
+ * is more generous than users; negative ⇒ harsher. */
100
+ export function judgeBias(pairs) {
101
+ if (pairs.length === 0)
102
+ return 0;
103
+ return mean(pairs.map((p) => normalizeJudge(p.judge))) - mean(pairs.map((p) => p.human));
104
+ }
105
+ function mean(xs) {
106
+ return xs.length === 0 ? 0 : xs.reduce((a, b) => a + b, 0) / xs.length;
107
+ }
108
+ /**
109
+ * Sweep candidate judge cut points (the 5 normalized anchors 0, .25, .5, .75, 1
110
+ * plus midpoints between observed scores) and return the one that maximizes
111
+ * Youden's J = TPR − FPR at separating up-rated (human ≥ 0.5) from down-rated
112
+ * turns. Ties break toward the LOWER cut (more inclusive), then the midpoint
113
+ * default. Returns undefined when the set has no positives or no negatives (a
114
+ * ROC point is undefined without both classes).
115
+ */
116
+ export function rocOptimalCut(pairs) {
117
+ const positives = pairs.filter((p) => p.human >= POSITIVE_THRESHOLD);
118
+ const negatives = pairs.filter((p) => p.human < POSITIVE_THRESHOLD);
119
+ if (positives.length === 0 || negatives.length === 0)
120
+ return undefined;
121
+ const scores = [...new Set(pairs.map((p) => normalizeJudge(p.judge)))].sort((a, b) => a - b);
122
+ const candidateCuts = new Set([0, 0.25, 0.5, 0.75, 1]);
123
+ for (let i = 0; i < scores.length; i += 1) {
124
+ const s = scores[i];
125
+ candidateCuts.add(s);
126
+ const next = scores[i + 1];
127
+ if (next !== undefined)
128
+ candidateCuts.add((s + next) / 2);
129
+ }
130
+ let best;
131
+ for (const cut of [...candidateCuts].sort((a, b) => a - b)) {
132
+ let tp = 0;
133
+ let fp = 0;
134
+ for (const p of positives)
135
+ if (normalizeJudge(p.judge) >= cut)
136
+ tp += 1;
137
+ for (const p of negatives)
138
+ if (normalizeJudge(p.judge) >= cut)
139
+ fp += 1;
140
+ const tpr = tp / positives.length;
141
+ const fpr = fp / negatives.length;
142
+ const youdenJ = tpr - fpr;
143
+ if (best === undefined || youdenJ > best.youdenJ) {
144
+ best = { cut, tpr, fpr, youdenJ };
145
+ }
146
+ }
147
+ return best;
148
+ }
149
+ /**
150
+ * Flag rubric criteria whose judge-vs-human disagreement exceeds `threshold`
151
+ * (mean absolute error on [0,1]). For each flagged criterion the worst
152
+ * exemplars are named so the user knows exactly which transcripts to look at
153
+ * when re-anchoring the rubric. Criteria absent from the pairs are skipped.
154
+ */
155
+ export function flagDisagreements(pairs, threshold = 0.34) {
156
+ const byCriterion = new Map();
157
+ for (const p of pairs) {
158
+ if (p.criterionScores === undefined)
159
+ continue;
160
+ for (const [name, raw] of Object.entries(p.criterionScores)) {
161
+ const err = Math.abs(normalizeJudge(raw) - p.human);
162
+ const bucket = byCriterion.get(name) ?? [];
163
+ bucket.push({ ref: `${p.sessionId}#${p.turnNumber}`, err });
164
+ byCriterion.set(name, bucket);
165
+ }
166
+ }
167
+ const out = [];
168
+ for (const [criterion, errs] of byCriterion) {
169
+ const meanAbsError = mean(errs.map((e) => e.err));
170
+ if (meanAbsError < threshold)
171
+ continue;
172
+ const exemplars = [...errs]
173
+ .sort((a, b) => b.err - a.err || a.ref.localeCompare(b.ref))
174
+ .slice(0, 3)
175
+ .map((e) => e.ref);
176
+ out.push({ criterion, meanAbsError, exemplars });
177
+ }
178
+ return out.sort((a, b) => b.meanAbsError - a.meanAbsError || a.criterion.localeCompare(b.criterion));
179
+ }
180
+ /** Compute the full calibration card from the paired data. */
181
+ export function buildCalibrationCard(pairs, opts = {}) {
182
+ const roc = rocOptimalCut(pairs);
183
+ return {
184
+ ...(opts.specName !== undefined ? { specName: opts.specName } : {}),
185
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
186
+ pairCount: pairs.length,
187
+ correlation: round(pearson(pairs)),
188
+ bias: round(judgeBias(pairs)),
189
+ accuracyAtDefault: round(accuracyAt(pairs, DEFAULT_JUDGE_CUT)),
190
+ confusionAtDefault: confusionAt(pairs, DEFAULT_JUDGE_CUT),
191
+ ...(roc !== undefined ? { recommendedCut: roc } : {}),
192
+ disagreements: flagDisagreements(pairs),
193
+ };
194
+ }
195
+ function round(n) {
196
+ return Math.round(n * 1000) / 1000;
197
+ }
198
+ /** Build/merge the persisted calibration entry for a spec. Existing entries for
199
+ * OTHER specs are preserved. Returns the file object to write. The recommended
200
+ * cut is the ROC-optimal cut when available, else the default. */
201
+ export function buildCalibrationFile(existing, card, now) {
202
+ const specKey = card.specName ?? "default";
203
+ const minScore = card.recommendedCut?.cut ?? DEFAULT_JUDGE_CUT;
204
+ return {
205
+ version: 1,
206
+ calibrations: {
207
+ ...(existing?.calibrations ?? {}),
208
+ [specKey]: {
209
+ minScore: round(minScore),
210
+ ...(card.model !== undefined ? { model: card.model } : {}),
211
+ correlation: card.correlation,
212
+ bias: card.bias,
213
+ pairCount: card.pairCount,
214
+ updatedAt: now,
215
+ },
216
+ },
217
+ };
218
+ }
219
+ // -------- rendering --------
220
+ /** Render the calibration card as a terminal report. */
221
+ export function renderCalibrationCard(card) {
222
+ const lines = [];
223
+ const forSpec = card.specName !== undefined ? ` for "${card.specName}"` : "";
224
+ const withModel = card.model !== undefined ? ` (judge: ${card.model})` : "";
225
+ lines.push(`judge calibration${forSpec}${withModel}`);
226
+ lines.push(` paired ratings: ${card.pairCount}`);
227
+ lines.push(` correlation: ${card.correlation.toFixed(3)} (human vs judge)`);
228
+ lines.push(` systematic bias: ${card.bias >= 0 ? "+" : ""}${card.bias.toFixed(3)} (${card.bias > 0.05 ? "judge too GENEROUS" : card.bias < -0.05 ? "judge too HARSH" : "well-centered"})`);
229
+ const c = card.confusionAtDefault;
230
+ lines.push(` at cut ${DEFAULT_JUDGE_CUT}: accuracy ${(card.accuracyAtDefault * 100).toFixed(0)}% ` +
231
+ `(tp ${c.tp} / fp ${c.fp} / tn ${c.tn} / fn ${c.fn})`);
232
+ if (card.recommendedCut !== undefined) {
233
+ const r = card.recommendedCut;
234
+ lines.push(` ROC-optimal cut: ${r.cut.toFixed(3)} (TPR ${(r.tpr * 100).toFixed(0)}%, FPR ${(r.fpr * 100).toFixed(0)}%, Youden J ${r.youdenJ.toFixed(3)})`);
235
+ }
236
+ else {
237
+ lines.push(" ROC-optimal cut: n/a (need both up- and down-rated turns)");
238
+ }
239
+ if (card.disagreements.length > 0) {
240
+ lines.push("");
241
+ lines.push(` ${card.disagreements.length} rubric criterion/criteria disagree with users — re-anchor:`);
242
+ for (const d of card.disagreements) {
243
+ lines.push(` - "${d.criterion}" (mean abs error ${d.meanAbsError.toFixed(3)}) e.g. ${d.exemplars.join(", ")}`);
244
+ }
245
+ }
246
+ return `${lines.join("\n")}\n`;
247
+ }