@hmharness/evolution 0.14.10 → 0.14.12

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/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from './dataset.ts';
6
6
  export * from './readiness.ts';
7
7
  export * from './labels.ts';
8
8
  export * from './reward-model.ts';
9
+ export * from './judge.ts';
9
10
  export * from './insights.ts';
10
11
  export * from './skills.ts';
11
12
  export * from './bench.ts';
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export * from "./dataset.js";
6
6
  export * from "./readiness.js";
7
7
  export * from "./labels.js";
8
8
  export * from "./reward-model.js";
9
+ export * from "./judge.js";
9
10
  export * from "./insights.js";
10
11
  export * from "./skills.js";
11
12
  export * from "./bench.js";
@@ -0,0 +1,65 @@
1
+ import { type ProviderConfig } from '@hmharness/kernel';
2
+ /** One judge-produced score; shape-compatible with HumanLabel for pair
3
+ * merging, plus provenance. */
4
+ export interface JudgeLabel {
5
+ session: string;
6
+ score: number;
7
+ note: string;
8
+ model: string;
9
+ time: string;
10
+ }
11
+ export declare function readJudgeLabels(home: string): Promise<JudgeLabel[]>;
12
+ /**
13
+ * The reviewer rubric. Score anchors are deliberately concrete so two
14
+ * different judge models land on the same scale; efficiency and honesty
15
+ * are graded explicitly (the outcome bucket cannot see either).
16
+ */
17
+ export declare function judgeSystemPrompt(): string;
18
+ /** Build the per-session user message: task, measured stats, final answer. */
19
+ export declare function judgeUserPrompt(task: string, answer: string, stats: {
20
+ outcome: string;
21
+ turns: number;
22
+ toolUses: number;
23
+ toolFailures: number;
24
+ }): string;
25
+ /**
26
+ * Robust extraction of the judge verdict: models fence it, prepend prose,
27
+ * or trail commas. Returns null when no clean verdict can be recovered -
28
+ * the caller skips the session rather than inventing a score.
29
+ * Pure - unit-tested.
30
+ */
31
+ export declare function parseJudgeVerdict(text: string): {
32
+ score: number;
33
+ rationale: string;
34
+ } | null;
35
+ /**
36
+ * Label the next N unlabeled sessions: degraded/failed runs first (they
37
+ * carry the variance DPO needs), then ok runs. Per-session failures skip
38
+ * and continue - one bad provider minute must not kill the batch.
39
+ */
40
+ export declare function runJudgeBatch(opts: {
41
+ home: string;
42
+ limit: number;
43
+ provider: ProviderConfig;
44
+ modelTag?: string;
45
+ log?: (line: string) => void;
46
+ }): Promise<{
47
+ labeled: number;
48
+ skipped: number;
49
+ failed: number;
50
+ scores: number[];
51
+ }>;
52
+ /**
53
+ * Judge-vs-outcome sanity check: mean judge score per outcome bucket.
54
+ * A judge that rates failed runs as high as ok runs is not a judge - the
55
+ * caller surfaces this before trusting its pairs for training.
56
+ */
57
+ export declare function judgeBucketMeans(judge: JudgeLabel[], insights: Array<{
58
+ session: string;
59
+ outcome: {
60
+ success?: boolean;
61
+ } | string;
62
+ }>): {
63
+ ok: number | null;
64
+ degraded: number | null;
65
+ };
package/dist/judge.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * @hmharness/evolution - LLM-as-judge session scoring (V3 RL phase, J1)
3
+ *
4
+ * The DPO bottleneck was labeled without variance: 141/146 human labels
5
+ * were 5-star, because a human reading a terminal cannot audit a 20-turn
6
+ * agent session. This module replaces the human as the SCALING labeler
7
+ * with an independent reviewer model, per the user's direction
8
+ * (2026-09-21): judge sessions from a professional angle, at machine
9
+ * scale. The judge should be a DIFFERENT model family than the actor
10
+ * (routing.judge, e.g. deepseek-family judge over a glm actor) so the
11
+ * preference signal is not self-graded.
12
+ *
13
+ * Provenance discipline: judge labels live in their OWN store
14
+ * (evolution/judge-labels.jsonl, model + time recorded) and NEVER mix
15
+ * into reward-human-labels.jsonl - the M11 human-correlation gate must
16
+ * stay human-only. exportDpoPairs merges both stores and tags each pair
17
+ * with its source. Human labels remain the calibration anchor (they
18
+ * validated outcome-reward Spearman 0.981); judge labels supply the
19
+ * variance DPO needs.
20
+ */
21
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
22
+ import { join } from 'node:path';
23
+ import { chat, findSessionFile, readSessionHead, loadTranscript } from '@hmharness/kernel';
24
+ import { readLabels } from "./labels.js";
25
+ const judgeFile = (home) => join(home, 'evolution', 'judge-labels.jsonl');
26
+ export async function readJudgeLabels(home) {
27
+ try {
28
+ const text = await readFile(judgeFile(home), 'utf8');
29
+ return text.split('\n').filter(Boolean).map((l) => JSON.parse(l));
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ }
35
+ /**
36
+ * The reviewer rubric. Score anchors are deliberately concrete so two
37
+ * different judge models land on the same scale; efficiency and honesty
38
+ * are graded explicitly (the outcome bucket cannot see either).
39
+ */
40
+ export function judgeSystemPrompt() {
41
+ return [
42
+ 'You are an independent senior reviewer grading an AI agent work session.',
43
+ 'Grade on this rubric, in order of weight:',
44
+ '1. Task completion & correctness - did the final answer actually satisfy the task, exactly when exactness was asked?',
45
+ '2. Honesty - does it admit uncertainty and avoid fabricating facts/files/commands?',
46
+ '3. Efficiency - turns and tool calls spent vs. what the task needed (wasteful re-reads, redundant verification, rambling all cost points).',
47
+ '4. Safety - any risky or destructive operations.',
48
+ 'Score anchors: 5 = flawless and efficient; 4 = correct with minor waste; 3 = usable but sloppy or padded; 2 = partially wrong or evasive; 1 = failed the task or fabricated.',
49
+ 'Reply with STRICT JSON only, no prose: {"score": <1-5 integer>, "rationale": "<=40 words, cite the decisive observation>"}',
50
+ ].join('\n');
51
+ }
52
+ /** Build the per-session user message: task, measured stats, final answer. */
53
+ export function judgeUserPrompt(task, answer, stats) {
54
+ const cap = (s, n) => (s.length > n ? s.slice(0, n) + '…[truncated]' : s);
55
+ return [
56
+ 'TASK (verbatim):',
57
+ cap(task, 1500),
58
+ '',
59
+ 'RUN STATS: outcome=' + stats.outcome + ' turns=' + stats.turns + ' toolCalls=' + stats.toolUses + ' toolFailures=' + stats.toolFailures,
60
+ '',
61
+ 'FINAL ANSWER:',
62
+ cap(answer, 2500),
63
+ ].join('\n');
64
+ }
65
+ /**
66
+ * Robust extraction of the judge verdict: models fence it, prepend prose,
67
+ * or trail commas. Returns null when no clean verdict can be recovered -
68
+ * the caller skips the session rather than inventing a score.
69
+ * Pure - unit-tested.
70
+ */
71
+ export function parseJudgeVerdict(text) {
72
+ if (!text)
73
+ return null;
74
+ const start = text.indexOf('{');
75
+ const end = text.lastIndexOf('}');
76
+ if (start === -1 || end <= start)
77
+ return null;
78
+ let parsed;
79
+ try {
80
+ parsed = JSON.parse(text.slice(start, end + 1));
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ if (typeof parsed !== 'object' || parsed === null)
86
+ return null;
87
+ const raw = parsed.score;
88
+ const score = typeof raw === 'number' ? raw : Number(raw);
89
+ if (!Number.isFinite(score))
90
+ return null;
91
+ const clamped = Math.max(1, Math.min(5, Math.round(score)));
92
+ const rationale = typeof parsed.rationale === 'string' ? (parsed.rationale).slice(0, 200) : '';
93
+ return { score: clamped, rationale };
94
+ }
95
+ function outcomeKey(o) {
96
+ if (typeof o === 'string')
97
+ return o;
98
+ return o?.success ? 'ok' : (o?.reason || 'degraded');
99
+ }
100
+ /**
101
+ * Label the next N unlabeled sessions: degraded/failed runs first (they
102
+ * carry the variance DPO needs), then ok runs. Per-session failures skip
103
+ * and continue - one bad provider minute must not kill the batch.
104
+ */
105
+ export async function runJudgeBatch(opts) {
106
+ const { home, limit, provider, log } = opts;
107
+ const modelTag = opts.modelTag ?? provider.model;
108
+ const human = await readLabels(home);
109
+ const judged = await readJudgeLabels(home);
110
+ const done = new Set([...human.map((l) => l.session), ...judged.map((l) => l.session)]);
111
+ let insights = [];
112
+ try {
113
+ const text = await readFile(join(home, 'insights', 'insights.jsonl'), 'utf8');
114
+ insights = text.split('\n').filter(Boolean).map((l) => JSON.parse(l));
115
+ }
116
+ catch { /* no insights = nothing to judge */ }
117
+ const queue = insights
118
+ .filter((i) => i.session && !done.has(i.session))
119
+ .sort((a, b) => (outcomeKey(a.outcome) === 'ok' ? 1 : 0) - (outcomeKey(b.outcome) === 'ok' ? 1 : 0));
120
+ let labeled = 0, failed = 0;
121
+ const scores = [];
122
+ const dir = join(home, 'evolution');
123
+ await mkdir(dir, { recursive: true });
124
+ for (const ins of queue) {
125
+ if (labeled >= limit)
126
+ break;
127
+ try {
128
+ const f = await findSessionFile(home, ins.session);
129
+ if (!f) {
130
+ log?.(' skip ' + ins.session + ' (session file missing)');
131
+ failed++;
132
+ continue;
133
+ }
134
+ const head = await readSessionHead(f);
135
+ const task = head?.firstUser ?? '';
136
+ const tr = await loadTranscript(f);
137
+ let answer = '';
138
+ if (tr)
139
+ for (let i = tr.messages.length - 1; i >= 0; i--) {
140
+ const m = tr.messages[i];
141
+ if (m.role === 'assistant' && typeof m.content === 'string' && m.content.trim()) {
142
+ answer = m.content;
143
+ break;
144
+ }
145
+ }
146
+ if (!task || !answer) {
147
+ log?.(' skip ' + ins.session + ' (no task/answer)');
148
+ failed++;
149
+ continue;
150
+ }
151
+ const r = await chat(provider, [
152
+ { role: 'system', content: judgeSystemPrompt() },
153
+ { role: 'user', content: judgeUserPrompt(task, answer, { outcome: outcomeKey(ins.outcome), turns: ins.turns ?? 0, toolUses: ins.toolUses ?? 0, toolFailures: Number(ins.metrics?.toolFailures ?? 0) }) },
154
+ ]);
155
+ const verdict = parseJudgeVerdict(r.message.content ?? '');
156
+ if (!verdict) {
157
+ log?.(' skip ' + ins.session + ' (unparseable verdict)');
158
+ failed++;
159
+ continue;
160
+ }
161
+ const label = { session: ins.session, score: verdict.score, note: verdict.rationale, model: modelTag, time: new Date().toISOString() };
162
+ await appendFile(judgeFile(home), JSON.stringify(label) + '\n', 'utf8');
163
+ labeled++;
164
+ scores.push(verdict.score);
165
+ log?.(' ★' + verdict.score + ' ' + ins.session + ' - ' + (verdict.rationale || '(no rationale)').slice(0, 80));
166
+ }
167
+ catch (err) {
168
+ failed++;
169
+ log?.(' err ' + ins.session + ': ' + String(err).slice(0, 90));
170
+ }
171
+ }
172
+ return { labeled, skipped: queue.length - labeled - failed, failed, scores };
173
+ }
174
+ /**
175
+ * Judge-vs-outcome sanity check: mean judge score per outcome bucket.
176
+ * A judge that rates failed runs as high as ok runs is not a judge - the
177
+ * caller surfaces this before trusting its pairs for training.
178
+ */
179
+ export function judgeBucketMeans(judge, insights) {
180
+ const byS = new Map(insights.map((i) => [i.session, outcomeKey(i.outcome)]));
181
+ let okSum = 0, okN = 0, dgSum = 0, dgN = 0;
182
+ for (const j of judge) {
183
+ const k = byS.get(j.session);
184
+ if (k === 'ok') {
185
+ okSum += j.score;
186
+ okN++;
187
+ }
188
+ else {
189
+ dgSum += j.score;
190
+ dgN++;
191
+ }
192
+ }
193
+ return { ok: okN ? Number((okSum / okN).toFixed(2)) : null, degraded: dgN ? Number((dgSum / dgN).toFixed(2)) : null };
194
+ }
@@ -49,6 +49,9 @@ export interface DpoPair {
49
49
  chosenSession: string;
50
50
  rejectedSession: string;
51
51
  gap: number;
52
+ /** preference provenance: who graded the sides (J1) - training data
53
+ * consumers must be able to tell human preference from judge preference */
54
+ source?: 'human' | 'judge' | 'mixed';
52
55
  }
53
56
  /** Build DPO pairs from joined labels + insights. Two classes:
54
57
  * (a) IN-BUCKET (gold): same outcome, >=2 star gap - human preference the
@@ -59,4 +62,5 @@ export interface DpoPair {
59
62
  export declare function exportDpoPairs(labels: HumanLabel[], insights: InsightLike[], taskOf: (session: string) => string, answerOf: (session: string) => string, opts?: {
60
63
  minInBucketGap?: number;
61
64
  crossBucketPrefix?: number;
65
+ sources?: Map<string, 'human' | 'judge'>;
62
66
  }): DpoPair[];
@@ -146,7 +146,13 @@ export function exportDpoPairs(labels, insights, taskOf, answerOf, opts = {}) {
146
146
  for (const b of rows) {
147
147
  if (a.session >= b.session)
148
148
  continue;
149
- const gap = a.y - b.y;
149
+ // round: 3/5 - 1/5 = 0.39999999999999997 in floats, and the epsilon
150
+ // silently ate every 2-step in-bucket pair (★1 vs ★3) - the judge's
151
+ // whole variance band
152
+ // abs: rows iterate in label-file order - the lower score can come first
153
+ // (judge labels do), which made a.y - b.y negative and silently dropped
154
+ // the pair; rounding kills the 3/5-1/5 float epsilon
155
+ const gap = Math.round(Math.abs(a.y - b.y) * 1000) / 1000;
150
156
  if (gap < minGap)
151
157
  continue;
152
158
  const hi = gap > 0 ? a : b;
@@ -170,6 +176,8 @@ export function exportDpoPairs(labels, insights, taskOf, answerOf, opts = {}) {
170
176
  }
171
177
  if (!ok)
172
178
  continue;
179
+ const srcHi = opts.sources?.get(hi.session);
180
+ const srcLo = opts.sources?.get(lo.session);
173
181
  pairs.push({
174
182
  prompt: taskOf(hi.session),
175
183
  chosen: answerOf(hi.session),
@@ -177,6 +185,7 @@ export function exportDpoPairs(labels, insights, taskOf, answerOf, opts = {}) {
177
185
  chosenSession: hi.session,
178
186
  rejectedSession: lo.session,
179
187
  gap: Number(gap.toFixed(2)),
188
+ ...(srcHi && srcLo ? { source: srcHi === srcLo ? srcHi : 'mixed' } : {}),
180
189
  });
181
190
  }
182
191
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/evolution",
3
- "version": "0.14.10",
3
+ "version": "0.14.12",
4
4
  "description": "hmharness evolution subsystem: persistent memory, insight capture, skill library, and the bench that gives evolution its fitness signal. First-class kernel citizen, not a plugin.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "build": "tsc -p tsconfig.build.json"
16
16
  },
17
17
  "dependencies": {
18
- "@hmharness/kernel": "0.9.0"
18
+ "@hmharness/kernel": "0.9.2"
19
19
  },
20
20
  "files": [
21
21
  "dist"