@hmharness/evolution 0.14.10 → 0.14.11
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 +1 -0
- package/dist/index.js +1 -0
- package/dist/judge.d.ts +65 -0
- package/dist/judge.js +194 -0
- package/dist/reward-model.d.ts +4 -0
- package/dist/reward-model.js +3 -0
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/judge.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/reward-model.d.ts
CHANGED
|
@@ -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[];
|
package/dist/reward-model.js
CHANGED
|
@@ -170,6 +170,8 @@ export function exportDpoPairs(labels, insights, taskOf, answerOf, opts = {}) {
|
|
|
170
170
|
}
|
|
171
171
|
if (!ok)
|
|
172
172
|
continue;
|
|
173
|
+
const srcHi = opts.sources?.get(hi.session);
|
|
174
|
+
const srcLo = opts.sources?.get(lo.session);
|
|
173
175
|
pairs.push({
|
|
174
176
|
prompt: taskOf(hi.session),
|
|
175
177
|
chosen: answerOf(hi.session),
|
|
@@ -177,6 +179,7 @@ export function exportDpoPairs(labels, insights, taskOf, answerOf, opts = {}) {
|
|
|
177
179
|
chosenSession: hi.session,
|
|
178
180
|
rejectedSession: lo.session,
|
|
179
181
|
gap: Number(gap.toFixed(2)),
|
|
182
|
+
...(srcHi && srcLo ? { source: srcHi === srcLo ? srcHi : 'mixed' } : {}),
|
|
180
183
|
});
|
|
181
184
|
}
|
|
182
185
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hmharness/evolution",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.11",
|
|
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.
|
|
18
|
+
"@hmharness/kernel": "0.9.2"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"dist"
|