crewhaus 0.1.7 → 0.1.8

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.
@@ -0,0 +1,602 @@
1
+ /**
2
+ * Response-feedback core — the pure, side-effect-free half of the
3
+ * `crewhaus rate` / `crewhaus feedback` / `crewhaus distill` subcommands.
4
+ *
5
+ * A `FeedbackRecord` is one human rating placed on one assistant turn (a
6
+ * thumbs up/down, a 1–5 star vote, an arbitrary scale vote, and/or a
7
+ * free-text comment or correction). The capture surfaces write it durably as
8
+ * a `user_feedback` event-log line (payload = FeedbackRecord); the web-UI host
9
+ * writes bare records to `.crewhaus/feedback/*.jsonl`. `distill` reads both,
10
+ * pairs each rating to a conversation turn, and emits the two artifacts the
11
+ * R-eval stack already consumes: a `Sample[]` dataset (`@crewhaus/eval-dataset`)
12
+ * and a `graders.yaml` (`@crewhaus/eval-grader`).
13
+ *
14
+ * Everything here is pure so it is unit-testable; all filesystem access lives
15
+ * in `apps/cli/src/index.ts` (mirrors `doctor-checks.ts` / `scope-audit.ts`).
16
+ *
17
+ * turnNumber convention: the 1-based ordinal of USER-TEXT turns in a session.
18
+ * A `user_message` whose content is a string (or an array containing a `text`
19
+ * block and no `tool_result` block) starts a new turn; a `user_message` that
20
+ * carries only `tool_result` blocks is a tool-result echo, and one marked
21
+ * `synthetic: true` (a runtime-injected loop/continue/tombstone nudge) is NOT a
22
+ * turn. This is the same count the CLI `--turn` flag, the web-UI (prompts
23
+ * sent), and the runtime's runContext.turnNumber all compute, so a rating keys
24
+ * back to the exact exchange it rated even after a recovery fires mid-session.
25
+ */
26
+ /** The event-log EventKind under which a FeedbackRecord is persisted. */
27
+ export const FEEDBACK_EVENT_KIND = "user_feedback";
28
+ export const FEEDBACK_SCHEMA_VERSION = 1;
29
+ const SESSION_ID_REGEX = /^sess_[0-9a-f]{16}$/;
30
+ /** Max stored length for a free-text comment/correction. Untrusted input (a
31
+ * hand-edited or web-UI-supplied `.crewhaus/feedback` line) flows into
32
+ * expected_output/metadata and the optimizer meta-prompt, so it is bounded at
33
+ * ingestion and stripped of control chars (newline/tab kept). */
34
+ export const MAX_FEEDBACK_TEXT = 8192;
35
+ export function clipFeedbackText(s) {
36
+ // Drop C0/C1 control chars and DEL (keeping tab, newline, carriage return),
37
+ // then bound the length. Built by code point so the source carries no
38
+ // literal control characters.
39
+ let out = "";
40
+ for (const ch of s) {
41
+ const c = ch.codePointAt(0) ?? 0;
42
+ const printable = c === 9 || c === 10 || c === 13 || (c >= 0x20 && c !== 0x7f && !(c >= 0x80 && c <= 0x9f));
43
+ if (printable)
44
+ out += ch;
45
+ if (out.length >= MAX_FEEDBACK_TEXT)
46
+ break;
47
+ }
48
+ return out.slice(0, MAX_FEEDBACK_TEXT);
49
+ }
50
+ function isValidRating(rating) {
51
+ const { thumbs, stars, scale } = rating;
52
+ if (thumbs !== undefined && thumbs !== "up" && thumbs !== "down")
53
+ return false;
54
+ if (stars !== undefined) {
55
+ if (typeof stars !== "number" || !Number.isInteger(stars) || stars < 1 || stars > 5)
56
+ return false;
57
+ }
58
+ if (scale !== undefined) {
59
+ if (typeof scale !== "object" || scale === null)
60
+ return false;
61
+ const s = scale;
62
+ if (!["value", "min", "max"].every((k) => typeof s[k] === "number" && Number.isFinite(s[k]))) {
63
+ return false;
64
+ }
65
+ }
66
+ return true;
67
+ }
68
+ /** Narrow an arbitrary parsed JSON value to a FeedbackRecord. Tolerant of the
69
+ * optional fields; rejects anything missing the required core OR carrying a
70
+ * malformed rating (so a corrupt/crafted line can't coerce a NaN/spurious
71
+ * score downstream). */
72
+ export function isFeedbackRecord(value) {
73
+ if (typeof value !== "object" || value === null)
74
+ return false;
75
+ const v = value;
76
+ if (v["schemaVersion"] !== 1)
77
+ return false;
78
+ if (typeof v["id"] !== "string")
79
+ return false;
80
+ if (typeof v["sessionId"] !== "string" || !SESSION_ID_REGEX.test(v["sessionId"])) {
81
+ return false;
82
+ }
83
+ if (typeof v["turnNumber"] !== "number" || !Number.isFinite(v["turnNumber"]))
84
+ return false;
85
+ const modality = v["modality"];
86
+ if (modality !== "binary" &&
87
+ modality !== "stars" &&
88
+ modality !== "scale" &&
89
+ modality !== "comment") {
90
+ return false;
91
+ }
92
+ if (typeof v["rating"] !== "object" || v["rating"] === null)
93
+ return false;
94
+ if (!isValidRating(v["rating"]))
95
+ return false;
96
+ if (typeof v["source"] !== "string")
97
+ return false;
98
+ if (typeof v["ts"] !== "string")
99
+ return false;
100
+ return true;
101
+ }
102
+ /**
103
+ * Assemble + validate a FeedbackRecord from capture-surface inputs. The
104
+ * modality is inferred from which rating fields are present (thumbs → binary,
105
+ * stars → stars, score → scale, else → comment). Throws a plain Error (the CLI
106
+ * routes it through `die`) when no rating signal at all is supplied.
107
+ */
108
+ export function buildFeedbackRecord(input) {
109
+ const rating = {};
110
+ let modality;
111
+ if (input.thumbs !== undefined) {
112
+ rating.thumbs = input.thumbs;
113
+ modality = "binary";
114
+ }
115
+ if (input.stars !== undefined) {
116
+ if (!Number.isInteger(input.stars) || input.stars < 1 || input.stars > 5) {
117
+ throw new Error(`--stars must be an integer 1–5 (got ${input.stars})`);
118
+ }
119
+ rating.stars = input.stars;
120
+ modality = "stars";
121
+ }
122
+ if (input.score !== undefined) {
123
+ if (!Number.isFinite(input.score) || input.score < 0 || input.score > 1) {
124
+ throw new Error(`--score must be a number in [0,1] (got ${input.score})`);
125
+ }
126
+ rating.scale = { value: input.score, min: 0, max: 1 };
127
+ modality = "scale";
128
+ }
129
+ if (modality === undefined) {
130
+ if (input.comment === undefined && input.correction === undefined) {
131
+ throw new Error("no rating supplied — give --thumbs, --stars, --score, --text, or --correction");
132
+ }
133
+ modality = "comment";
134
+ }
135
+ return {
136
+ schemaVersion: FEEDBACK_SCHEMA_VERSION,
137
+ id: input.id,
138
+ sessionId: input.sessionId,
139
+ ...(input.runId !== undefined ? { runId: input.runId } : {}),
140
+ turnNumber: input.turnNumber,
141
+ ...(input.targetSpanId !== undefined ? { targetSpanId: input.targetSpanId } : {}),
142
+ modality,
143
+ rating,
144
+ ...(input.comment !== undefined ? { comment: clipFeedbackText(input.comment) } : {}),
145
+ ...(input.correction !== undefined ? { correction: clipFeedbackText(input.correction) } : {}),
146
+ source: input.source,
147
+ ...(input.rater !== undefined ? { rater: input.rater } : {}),
148
+ ts: input.ts,
149
+ };
150
+ }
151
+ /**
152
+ * Normalize a rating to [0,1] — the same convention `GradeResult.score` uses
153
+ * ((n-1)/4 for a 1–5 judge score). Returns `undefined` for a comment-only
154
+ * record (no numeric signal). Thumbs: up→1, down→0. Stars n→(n-1)/4. Scale
155
+ * value→(value-min)/(max-min), clamped.
156
+ */
157
+ export function normalizeRating(r) {
158
+ const { thumbs, stars, scale } = r.rating;
159
+ if (thumbs !== undefined)
160
+ return thumbs === "up" ? 1 : 0;
161
+ if (stars !== undefined)
162
+ return clamp01((stars - 1) / 4);
163
+ if (scale !== undefined) {
164
+ const span = scale.max - scale.min;
165
+ if (span <= 0)
166
+ return undefined;
167
+ return clamp01((scale.value - scale.min) / span);
168
+ }
169
+ return undefined;
170
+ }
171
+ function clamp01(n) {
172
+ return n < 0 ? 0 : n > 1 ? 1 : n;
173
+ }
174
+ function turnKey(sessionId, turnNumber) {
175
+ return `${sessionId}#${turnNumber}`;
176
+ }
177
+ function contentBlocks(payload) {
178
+ const content = payload?.content;
179
+ if (typeof content === "string")
180
+ return { blocks: [], text: content };
181
+ if (Array.isArray(content))
182
+ return { blocks: content };
183
+ return { blocks: [] };
184
+ }
185
+ /** The text when a `user_message` is a real user-text turn (string content, or
186
+ * an array with ≥1 text block and no tool_result block); undefined otherwise.
187
+ * Runtime-injected nudges (loop warnings, "continue"/tombstone recovery) carry
188
+ * `synthetic: true` and are NOT turns — skipping them keeps deriveTurns's turn
189
+ * ordinal aligned with the runtime's runContext.turnNumber and the web UI. */
190
+ function userTurnText(payload) {
191
+ if (payload !== null &&
192
+ typeof payload === "object" &&
193
+ payload.synthetic === true) {
194
+ return undefined;
195
+ }
196
+ const { blocks, text } = contentBlocks(payload);
197
+ if (text !== undefined)
198
+ return text;
199
+ const hasToolResult = blocks.some((b) => b.type === "tool_result");
200
+ if (hasToolResult)
201
+ return undefined;
202
+ const texts = blocks
203
+ .filter((b) => b.type === "text" && typeof b.text === "string")
204
+ .map((b) => b.text);
205
+ return texts.length > 0 ? texts.join("\n") : undefined;
206
+ }
207
+ function assistantText(payload) {
208
+ const { blocks, text } = contentBlocks(payload);
209
+ if (text !== undefined)
210
+ return text;
211
+ return blocks
212
+ .filter((b) => b.type === "text" && typeof b.text === "string")
213
+ .map((b) => b.text)
214
+ .join("\n");
215
+ }
216
+ function assistantToolNames(payload) {
217
+ const { blocks } = contentBlocks(payload);
218
+ return blocks
219
+ .filter((b) => b.type === "tool_use" && typeof b.name === "string")
220
+ .map((b) => b.name);
221
+ }
222
+ /**
223
+ * Reconstruct conversation turns from a session transcript. Each user-text
224
+ * message opens a turn; the assistant's messages until the next user-text
225
+ * message form its response. `output` is the LAST assistant text (the answer);
226
+ * `toolNames` are the unique tool names the assistant called, in first-seen
227
+ * order.
228
+ */
229
+ export function deriveTurns(events) {
230
+ const turns = [];
231
+ let current;
232
+ let counter = 0;
233
+ const finalize = () => {
234
+ if (!current)
235
+ return;
236
+ turns.push({
237
+ turnNumber: current.turnNumber,
238
+ input: current.input,
239
+ output: current.texts.length > 0 ? current.texts[current.texts.length - 1] : "",
240
+ toolNames: current.tools,
241
+ });
242
+ };
243
+ for (const ev of events) {
244
+ if (ev.kind === "user_message") {
245
+ const text = userTurnText(ev.payload);
246
+ if (text !== undefined) {
247
+ finalize();
248
+ counter += 1;
249
+ current = { turnNumber: counter, input: text, texts: [], tools: [], toolSeen: new Set() };
250
+ }
251
+ }
252
+ else if (ev.kind === "assistant_message" && current) {
253
+ const t = assistantText(ev.payload);
254
+ if (t !== "")
255
+ current.texts.push(t);
256
+ for (const name of assistantToolNames(ev.payload)) {
257
+ if (!current.toolSeen.has(name)) {
258
+ current.toolSeen.add(name);
259
+ current.tools.push(name);
260
+ }
261
+ }
262
+ }
263
+ }
264
+ finalize();
265
+ return turns;
266
+ }
267
+ /** Extract FeedbackRecords from parsed JSON objects. Accepts both event-log
268
+ * envelopes (`{ kind: "user_feedback", payload }`) and bare records (the
269
+ * web-UI host's `.crewhaus/feedback/*.jsonl`). */
270
+ export function extractFeedbackRecords(objects) {
271
+ const out = [];
272
+ for (const obj of objects) {
273
+ if (obj && typeof obj === "object" && obj.kind === FEEDBACK_EVENT_KIND) {
274
+ const payload = obj.payload;
275
+ if (isFeedbackRecord(payload))
276
+ out.push(payload);
277
+ }
278
+ else if (isFeedbackRecord(obj)) {
279
+ out.push(obj);
280
+ }
281
+ }
282
+ return out;
283
+ }
284
+ /**
285
+ * Collapse records to one per (sessionId, turnNumber). Rather than pure
286
+ * last-write-wins — which would let a later comment-only `feedback` erase an
287
+ * earlier `rate` on the same turn — fields are merged chronologically: the
288
+ * newest value of each field wins, but a later comment-only record keeps the
289
+ * earlier record's rating. So `rate --thumbs up` then `feedback --text "…"`
290
+ * yields one record carrying BOTH.
291
+ */
292
+ export function mergeFeedback(records) {
293
+ const sorted = [...records].sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
294
+ const byKey = new Map();
295
+ for (const r of sorted) {
296
+ const key = turnKey(r.sessionId, r.turnNumber);
297
+ const existing = byKey.get(key);
298
+ byKey.set(key, existing === undefined ? r : foldRecords(existing, r));
299
+ }
300
+ return [...byKey.values()];
301
+ }
302
+ function hasRating(r) {
303
+ return (r.rating.thumbs !== undefined || r.rating.stars !== undefined || r.rating.scale !== undefined);
304
+ }
305
+ /** Merge an earlier + later record for the same turn (later ts wins per field;
306
+ * a comment-only later record does not erase the earlier rating). */
307
+ function foldRecords(earlier, later) {
308
+ const ratingRecord = hasRating(later) ? later : hasRating(earlier) ? earlier : later;
309
+ const comment = later.comment ?? earlier.comment;
310
+ const correction = later.correction ?? earlier.correction;
311
+ const rater = later.rater ?? earlier.rater;
312
+ const runId = later.runId ?? earlier.runId;
313
+ const targetSpanId = later.targetSpanId ?? earlier.targetSpanId;
314
+ return {
315
+ schemaVersion: FEEDBACK_SCHEMA_VERSION,
316
+ id: later.id,
317
+ sessionId: later.sessionId,
318
+ turnNumber: later.turnNumber,
319
+ modality: hasRating(ratingRecord) ? ratingRecord.modality : "comment",
320
+ rating: ratingRecord.rating,
321
+ source: later.source,
322
+ ts: later.ts,
323
+ ...(runId !== undefined ? { runId } : {}),
324
+ ...(targetSpanId !== undefined ? { targetSpanId } : {}),
325
+ ...(comment !== undefined ? { comment } : {}),
326
+ ...(correction !== undefined ? { correction } : {}),
327
+ ...(rater !== undefined ? { rater } : {}),
328
+ };
329
+ }
330
+ /**
331
+ * Tag-all distillation. Every rated turn becomes a Sample; positively-rated
332
+ * turns (normalized ≥ minScore) — and any turn with a `correction` — become
333
+ * GOLD (expected_output set), the rest carry a low `metadata.user_rating` and
334
+ * no expected_output so they feed the optimizer's failure channel. A single
335
+ * deterministic grader is synthesized from the positive turns' behavior
336
+ * (exactly one, to avoid the hard-AND collapse in `all(...)`).
337
+ */
338
+ export function distill(turns, feedback, opts) {
339
+ const turnByKey = new Map();
340
+ for (const t of turns)
341
+ turnByKey.set(turnKey(t.sessionId, t.turnNumber), t);
342
+ const merged = mergeFeedback(feedback);
343
+ const samples = [];
344
+ const positiveTurns = [];
345
+ const positiveComments = [];
346
+ const negativeComments = [];
347
+ const warnings = [];
348
+ let matched = 0;
349
+ let unmatched = 0;
350
+ let positives = 0;
351
+ let negatives = 0;
352
+ for (const fb of merged) {
353
+ const turn = turnByKey.get(turnKey(fb.sessionId, fb.turnNumber));
354
+ if (turn === undefined) {
355
+ unmatched += 1;
356
+ warnings.push(`rating for ${fb.sessionId} turn ${fb.turnNumber} has no matching turn in the transcript — skipped`);
357
+ continue;
358
+ }
359
+ matched += 1;
360
+ const score = normalizeRating(fb);
361
+ const isPositive = (score !== undefined && score >= opts.minScore) || fb.correction !== undefined;
362
+ // Gold = the user's correction, else the assistant's answer for a positive
363
+ // turn. Omit an empty answer (e.g. a turn that errored before replying) so
364
+ // we never emit a meaningless empty-string gold.
365
+ const goldCandidate = fb.correction ?? (isPositive ? turn.output : undefined);
366
+ const expectedOutput = goldCandidate !== undefined && goldCandidate.trim() !== "" ? goldCandidate : undefined;
367
+ const metadata = {
368
+ sessionId: fb.sessionId,
369
+ turnNumber: fb.turnNumber,
370
+ source: fb.source,
371
+ raw_rating: fb.rating,
372
+ };
373
+ if (score !== undefined)
374
+ metadata["user_rating"] = score;
375
+ if (fb.comment !== undefined)
376
+ metadata["comment"] = fb.comment;
377
+ if (fb.correction !== undefined)
378
+ metadata["correction"] = fb.correction;
379
+ if (fb.rater !== undefined)
380
+ metadata["rater"] = fb.rater;
381
+ const sample = { id: `${fb.sessionId}_t${fb.turnNumber}`, input: turn.input, metadata };
382
+ if (expectedOutput !== undefined)
383
+ sample.expected_output = expectedOutput;
384
+ if (isPositive && turn.toolNames.length > 0)
385
+ sample.expected_tools = turn.toolNames;
386
+ samples.push(sample);
387
+ if (isPositive) {
388
+ positives += 1;
389
+ positiveTurns.push(turn);
390
+ if (fb.comment !== undefined)
391
+ positiveComments.push(fb.comment);
392
+ }
393
+ else {
394
+ negatives += 1;
395
+ if (fb.comment !== undefined)
396
+ negativeComments.push(fb.comment);
397
+ }
398
+ }
399
+ return {
400
+ samples,
401
+ graders: opts.judge === true
402
+ ? { graders: [buildJudgeRubricGrader(positiveComments, negativeComments, opts.judgeModel)] }
403
+ : synthesizeGraders(positiveTurns, warnings),
404
+ stats: {
405
+ totalFeedback: merged.length,
406
+ matchedTurns: matched,
407
+ unmatchedFeedback: unmatched,
408
+ positives,
409
+ negatives,
410
+ },
411
+ warnings,
412
+ };
413
+ }
414
+ const STOPWORDS = new Set([
415
+ "the",
416
+ "and",
417
+ "that",
418
+ "this",
419
+ "with",
420
+ "from",
421
+ "your",
422
+ "have",
423
+ "will",
424
+ "here",
425
+ "there",
426
+ "what",
427
+ "when",
428
+ "which",
429
+ "would",
430
+ "could",
431
+ "should",
432
+ "about",
433
+ "into",
434
+ "then",
435
+ "than",
436
+ "them",
437
+ "they",
438
+ "you",
439
+ "for",
440
+ "are",
441
+ "was",
442
+ "were",
443
+ "its",
444
+ ]);
445
+ /**
446
+ * Pick the single deterministic grader that best captures the up-rated
447
+ * behavior. Prefers a `tool_call_sequence` over the tools every tool-using
448
+ * positive turn shared (falling back to the most common tool); if the positive
449
+ * turns used no tools, a `contains` over a distinctive token common to their
450
+ * answers; failing that, a non-empty-answer floor (with a warning).
451
+ */
452
+ export function synthesizeGraders(positiveTurns, warnings = []) {
453
+ const toolCounts = new Map();
454
+ let turnsWithTools = 0;
455
+ for (const t of positiveTurns) {
456
+ const uniq = [...new Set(t.toolNames)];
457
+ if (uniq.length > 0)
458
+ turnsWithTools += 1;
459
+ for (const name of uniq)
460
+ toolCounts.set(name, (toolCounts.get(name) ?? 0) + 1);
461
+ }
462
+ if (turnsWithTools > 0 && toolCounts.size > 0) {
463
+ const inEvery = [...toolCounts.entries()]
464
+ .filter(([, c]) => c === turnsWithTools)
465
+ .map(([n]) => n);
466
+ let expected = inEvery;
467
+ if (expected.length === 0) {
468
+ const top = [...toolCounts.entries()].sort((a, b) => b[1] - a[1])[0];
469
+ expected = top ? [top[0]] : [];
470
+ }
471
+ if (expected.length > 0) {
472
+ return {
473
+ graders: [{ name: "preferred_tools", type: "tool_call_sequence", expected, mode: "set" }],
474
+ };
475
+ }
476
+ }
477
+ const token = commonToken(positiveTurns.map((t) => t.output));
478
+ if (token !== undefined) {
479
+ return {
480
+ graders: [
481
+ { name: "preferred_phrase", type: "contains", substring: token, case_insensitive: true },
482
+ ],
483
+ };
484
+ }
485
+ warnings.push("no consistent tool or phrase signal in the up-rated turns — emitted a non-empty-answer floor grader; add ratings or edit graders.yaml for a sharper eval");
486
+ return { graders: [{ name: "non_empty_answer", type: "regex", pattern: "\\S" }] };
487
+ }
488
+ /**
489
+ * Build a single `llm_judge` grader seeded from the aggregated comment themes.
490
+ * One criterion ("user_preference") with fixed 1–5 anchors and a description
491
+ * folding a short, quoted, clipped summary of what users praised vs criticized.
492
+ * Emitted as the SOLE grader so it is never hard-ANDed with a deterministic one
493
+ * (the `all(...)` min-collapse). The untrusted comment text is bounded and
494
+ * quoted-as-data; the rubric is trusted context sent verbatim to the judge.
495
+ */
496
+ export function buildJudgeRubricGrader(positiveComments, negativeComments, model) {
497
+ const praised = summarizeComments(positiveComments);
498
+ const criticized = summarizeComments(negativeComments);
499
+ const praisedNote = praised !== undefined ? ` Users praised responses like: '${praised}'.` : "";
500
+ const criticizedNote = criticized !== undefined ? ` Users criticized responses like: '${criticized}'.` : "";
501
+ const description = `Judge how well the response matches the qualities real users prefer, learned from their ratings.${praisedNote}${criticizedNote}`;
502
+ return {
503
+ name: "user_preference",
504
+ type: "llm_judge",
505
+ rubric: {
506
+ criteria: [
507
+ {
508
+ name: "user_preference",
509
+ description,
510
+ anchors: {
511
+ "1": "Strongly conflicts with what users liked; clearly exhibits the criticized traits.",
512
+ "2": "Mostly misses the qualities users preferred.",
513
+ "3": "Partially matches user preferences; a mixed response.",
514
+ "4": "Largely matches the qualities users praised.",
515
+ "5": "Strongly matches the user-preferred style and avoids the criticized traits.",
516
+ },
517
+ },
518
+ ],
519
+ passing_score: 3,
520
+ },
521
+ ...(model !== undefined ? { model } : {}),
522
+ };
523
+ }
524
+ /** Dedupe, join, and clip a batch of comments into one short quoted summary for
525
+ * a rubric description (~400 chars). Undefined when there are no comments. */
526
+ function summarizeComments(comments) {
527
+ const cleaned = [...new Set(comments.map((c) => c.trim()).filter((c) => c !== ""))];
528
+ if (cleaned.length === 0)
529
+ return undefined;
530
+ const joined = clipFeedbackText(cleaned.slice(0, 8).join("; ")).replace(/'/g, "");
531
+ return joined.length > 400 ? `${joined.slice(0, 400)}…` : joined;
532
+ }
533
+ /** The most frequent distinctive token (alnum, length ≥ 4, non-stopword)
534
+ * appearing in at least half of the answers. Undefined when none qualifies. */
535
+ function commonToken(answers) {
536
+ const nonEmpty = answers.filter((a) => a.trim() !== "");
537
+ if (nonEmpty.length === 0)
538
+ return undefined;
539
+ const docFreq = new Map();
540
+ for (const answer of nonEmpty) {
541
+ const seen = new Set();
542
+ for (const raw of answer.toLowerCase().split(/[^a-z0-9]+/)) {
543
+ if (raw.length < 4 || STOPWORDS.has(raw) || seen.has(raw))
544
+ continue;
545
+ seen.add(raw);
546
+ docFreq.set(raw, (docFreq.get(raw) ?? 0) + 1);
547
+ }
548
+ }
549
+ const threshold = Math.ceil(nonEmpty.length / 2);
550
+ const ranked = [...docFreq.entries()]
551
+ .filter(([, c]) => c >= threshold)
552
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
553
+ return ranked.length > 0 ? ranked[0]?.[0] : undefined;
554
+ }
555
+ // -------- serialization --------
556
+ /** One SampleSchema-valid JSON object per line (round-trips through loadJsonl). */
557
+ export function samplesToJsonl(samples) {
558
+ return `${samples.map((s) => JSON.stringify(s)).join("\n")}\n`;
559
+ }
560
+ /** Emit a GradersConfigSchema-valid graders.yaml. Scalars are JSON-encoded so
561
+ * arbitrary substrings/tool names stay YAML-safe (JSON is a YAML subset). */
562
+ export function gradersConfigToYaml(config) {
563
+ const lines = [
564
+ "# Synthesized by `crewhaus distill` from user ratings.",
565
+ "# Exactly one grader — stacking graders hard-ANDs their scores (see eval-grader `all`).",
566
+ "graders:",
567
+ ];
568
+ for (const g of config.graders) {
569
+ lines.push(` - name: ${JSON.stringify(g.name)}`);
570
+ lines.push(` type: ${g.type}`);
571
+ if (g.type === "tool_call_sequence") {
572
+ lines.push(` expected: ${JSON.stringify(g.expected)}`);
573
+ lines.push(` mode: ${g.mode}`);
574
+ }
575
+ else if (g.type === "contains") {
576
+ lines.push(` substring: ${JSON.stringify(g.substring)}`);
577
+ if (g.case_insensitive !== undefined)
578
+ lines.push(` case_insensitive: ${g.case_insensitive}`);
579
+ }
580
+ else if (g.type === "llm_judge") {
581
+ lines.push(" rubric:");
582
+ lines.push(" criteria:");
583
+ for (const c of g.rubric.criteria) {
584
+ lines.push(` - name: ${JSON.stringify(c.name)}`);
585
+ lines.push(` description: ${JSON.stringify(c.description)}`);
586
+ lines.push(" anchors:");
587
+ for (const k of ["1", "2", "3", "4", "5"]) {
588
+ lines.push(` ${JSON.stringify(k)}: ${JSON.stringify(c.anchors[k])}`);
589
+ }
590
+ }
591
+ if (g.rubric.passing_score !== undefined) {
592
+ lines.push(` passing_score: ${g.rubric.passing_score}`);
593
+ }
594
+ if (g.model !== undefined)
595
+ lines.push(` model: ${JSON.stringify(g.model)}`);
596
+ }
597
+ else {
598
+ lines.push(` pattern: ${JSON.stringify(g.pattern)}`);
599
+ }
600
+ }
601
+ return `${lines.join("\n")}\n`;
602
+ }