@hmharness/evolution 0.14.9 → 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 +2 -0
- package/dist/index.js +2 -0
- package/dist/judge.d.ts +65 -0
- package/dist/judge.js +194 -0
- package/dist/reward-model.d.ts +66 -0
- package/dist/reward-model.js +187 -0
- package/package.json +2 -2
- package/dist/bench.d.ts.v2bak.v2bak +0 -44
- package/dist/bench.js.v2bak.v2bak +0 -156
- package/dist/candidates.d.ts.v2bak.v2bak +0 -114
- package/dist/candidates.js.v2bak.v2bak +0 -232
- package/dist/dataset.d.ts.v2bak.v2bak +0 -64
- package/dist/dataset.js.v2bak.v2bak +0 -184
- package/dist/evolve.d.ts.v2bak.v2bak +0 -98
- package/dist/evolve.js.v2bak.v2bak +0 -573
- package/dist/impact.d.ts.v2bak.v2bak +0 -77
- package/dist/impact.js.v2bak.v2bak +0 -214
- package/dist/index.d.ts.v2bak.v2bak +0 -16
- package/dist/index.js.v2bak.v2bak +0 -16
- package/dist/insights.d.ts.v2bak.v2bak +0 -18
- package/dist/insights.js.v2bak.v2bak +0 -80
- package/dist/knowledge.d.ts.v2bak.v2bak +0 -15
- package/dist/knowledge.js.v2bak.v2bak +0 -145
- package/dist/labels.d.ts.v2bak.v2bak +0 -20
- package/dist/labels.js.v2bak.v2bak +0 -57
- package/dist/memory.d.ts.v2bak.v2bak +0 -32
- package/dist/memory.js.v2bak.v2bak +0 -233
- package/dist/patches.d.ts.v2bak.v2bak +0 -83
- package/dist/patches.js.v2bak.v2bak +0 -253
- package/dist/radar.d.ts.v2bak.v2bak +0 -3
- package/dist/radar.js.v2bak.v2bak +0 -40
- package/dist/ranker.d.ts.v2bak.v2bak +0 -44
- package/dist/ranker.js.v2bak.v2bak +0 -55
- package/dist/readiness.d.ts.v2bak.v2bak +0 -15
- package/dist/readiness.js.v2bak.v2bak +0 -167
- package/dist/skillpayload.d.ts.v2bak.v2bak +0 -1
- package/dist/skillpayload.js.v2bak +0 -28
- package/dist/skillpayload.js.v2bak.v2bak.v2bak +0 -28
- package/dist/skills.d.ts.v2bak.v2bak +0 -54
- package/dist/skills.js.v2bak.v2bak +0 -321
- package/dist/workflows.d.ts.v2bak.v2bak +0 -28
- package/dist/workflows.js.v2bak.v2bak +0 -84
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export * from './skillpayload.ts';
|
|
|
5
5
|
export * from './dataset.ts';
|
|
6
6
|
export * from './readiness.ts';
|
|
7
7
|
export * from './labels.ts';
|
|
8
|
+
export * from './reward-model.ts';
|
|
9
|
+
export * from './judge.ts';
|
|
8
10
|
export * from './insights.ts';
|
|
9
11
|
export * from './skills.ts';
|
|
10
12
|
export * from './bench.ts';
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,8 @@ export * from "./skillpayload.js";
|
|
|
5
5
|
export * from "./dataset.js";
|
|
6
6
|
export * from "./readiness.js";
|
|
7
7
|
export * from "./labels.js";
|
|
8
|
+
export * from "./reward-model.js";
|
|
9
|
+
export * from "./judge.js";
|
|
8
10
|
export * from "./insights.js";
|
|
9
11
|
export * from "./skills.js";
|
|
10
12
|
export * from "./bench.js";
|
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
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { HumanLabel } from './labels.ts';
|
|
2
|
+
export type { HumanLabel };
|
|
3
|
+
export interface RewardFeatures {
|
|
4
|
+
/** outcome bucket: 2=ok, 1=turn-budget, 0=error */
|
|
5
|
+
outcome: number;
|
|
6
|
+
/** failed tool calls / total tool calls, 0..1 */
|
|
7
|
+
toolFailRate: number;
|
|
8
|
+
/** agent turns used, raw count */
|
|
9
|
+
turns: number;
|
|
10
|
+
/** tool calls issued, raw count */
|
|
11
|
+
toolUses: number;
|
|
12
|
+
}
|
|
13
|
+
export interface RewardWeights {
|
|
14
|
+
/** logistic weights over [outcome, toolFailRate, log1p(turns), log1p(toolUses), bias] */
|
|
15
|
+
w: [number, number, number, number, number];
|
|
16
|
+
trainedAt: string;
|
|
17
|
+
nSamples: number;
|
|
18
|
+
trainRmse: number;
|
|
19
|
+
/** holdout RMSE when n >= 20 (80/20 split, deterministic) */
|
|
20
|
+
holdoutRmse?: number;
|
|
21
|
+
}
|
|
22
|
+
/** Human label record: re-used from labels.ts (single source). */
|
|
23
|
+
/** Insight record fields the model joins on. */
|
|
24
|
+
export interface InsightLike {
|
|
25
|
+
session: string;
|
|
26
|
+
outcome: string;
|
|
27
|
+
turns?: number;
|
|
28
|
+
toolUses?: number;
|
|
29
|
+
}
|
|
30
|
+
/** Join labels with insights and map to training rows. Pure - testable. */
|
|
31
|
+
export declare function trainingRows(labels: HumanLabel[], insights: InsightLike[]): Array<{
|
|
32
|
+
f: RewardFeatures;
|
|
33
|
+
y: number;
|
|
34
|
+
session: string;
|
|
35
|
+
}>;
|
|
36
|
+
/** Fit the reward model on joined human labels. Deterministic. */
|
|
37
|
+
export declare function fitRewardModel(labels: HumanLabel[], insights: InsightLike[]): RewardWeights;
|
|
38
|
+
/** Score a run's features with fitted weights -> reward in [0,1]. */
|
|
39
|
+
export declare function scoreFeatures(w: RewardWeights['w'], f: RewardFeatures): number;
|
|
40
|
+
/** Persistence: HMH_HOME/evolution/reward-model.json. */
|
|
41
|
+
export declare function saveRewardModel(home: string, m: RewardWeights): Promise<void>;
|
|
42
|
+
export declare function loadRewardModel(home: string): Promise<RewardWeights | null>;
|
|
43
|
+
/** DPO preference pair for external fine-tuning. */
|
|
44
|
+
export interface DpoPair {
|
|
45
|
+
prompt: string;
|
|
46
|
+
chosen: string;
|
|
47
|
+
rejected: string;
|
|
48
|
+
/** provenance for auditability */
|
|
49
|
+
chosenSession: string;
|
|
50
|
+
rejectedSession: string;
|
|
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';
|
|
55
|
+
}
|
|
56
|
+
/** Build DPO pairs from joined labels + insights. Two classes:
|
|
57
|
+
* (a) IN-BUCKET (gold): same outcome, >=2 star gap - human preference the
|
|
58
|
+
* outcome reward cannot see (4-vs-5 star distinctions);
|
|
59
|
+
* (b) CROSS-BUCKET (classic): ok-vs-degraded completions of the SAME task
|
|
60
|
+
* template (task prefix match) - the textbook chosen/rejected pair.
|
|
61
|
+
* Pure - testable. */
|
|
62
|
+
export declare function exportDpoPairs(labels: HumanLabel[], insights: InsightLike[], taskOf: (session: string) => string, answerOf: (session: string) => string, opts?: {
|
|
63
|
+
minInBucketGap?: number;
|
|
64
|
+
crossBucketPrefix?: number;
|
|
65
|
+
sources?: Map<string, 'human' | 'judge'>;
|
|
66
|
+
}): DpoPair[];
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/evolution - reward-model (V3 RL phase, ADR-0010)
|
|
3
|
+
* A LEARNED reward model over run features, fit on the human star labels -
|
|
4
|
+
* the blueprint's "Reward -> RL" step after the M11 gate opened.
|
|
5
|
+
*
|
|
6
|
+
* Design (grounded in the Agent Lightning lesson: the app defines the task
|
|
7
|
+
* and the reward; the harness already produces rollouts):
|
|
8
|
+
* - features are what the harness already measures per session: outcome
|
|
9
|
+
* bucket, tool-failure rate, turns, tool uses (all z-free, reproducible)
|
|
10
|
+
* - the model is a tiny logistic regressor (zero deps, deterministic
|
|
11
|
+
* gradient descent) predicting the HUMAN score in [0,1]
|
|
12
|
+
* - the calibrated outcome-based reward (Spearman 0.981 vs human) is the
|
|
13
|
+
* prior; the learned model adds the fine, in-bucket distinctions the
|
|
14
|
+
* outcome signal cannot see (4-star vs 5-star runs)
|
|
15
|
+
* - weights persist to HMH_HOME/evolution/reward-model.json; refit as
|
|
16
|
+
* labels accumulate
|
|
17
|
+
*
|
|
18
|
+
* What this is NOT: a fine-tuned LLM. The blueprint explicitly defers
|
|
19
|
+
* model fine-tuning; DPO pairs for an external trainer are exported
|
|
20
|
+
* separately (exportDpoPairs).
|
|
21
|
+
*/
|
|
22
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
const FEATURES = [
|
|
25
|
+
(f) => f.outcome / 2, // 0..1
|
|
26
|
+
(f) => f.toolFailRate, // 0..1
|
|
27
|
+
(f) => Math.log1p(Math.min(f.turns, 200)) / Math.log(201), // 0..1
|
|
28
|
+
(f) => Math.log1p(Math.min(f.toolUses, 200)) / Math.log(201), // 0..1
|
|
29
|
+
];
|
|
30
|
+
function x(f) {
|
|
31
|
+
return [...FEATURES.map((fn) => fn(f)), 1];
|
|
32
|
+
}
|
|
33
|
+
function sigmoid(z) {
|
|
34
|
+
return 1 / (1 + Math.exp(-z));
|
|
35
|
+
}
|
|
36
|
+
/** Deterministic 80/20 split by index (no RNG - reproducible fits). */
|
|
37
|
+
function split(rows) {
|
|
38
|
+
const train = [];
|
|
39
|
+
const hold = [];
|
|
40
|
+
rows.forEach((r, i) => (i % 5 === 4 ? hold : train).push(r));
|
|
41
|
+
return { train, hold };
|
|
42
|
+
}
|
|
43
|
+
function fitLinear(rows, epochs = 4000, lr = 0.05, l2 = 0.01) {
|
|
44
|
+
const d = 5;
|
|
45
|
+
let w = new Array(d).fill(0);
|
|
46
|
+
const xs = rows.map((r) => x(r.f));
|
|
47
|
+
if (xs.length === 0)
|
|
48
|
+
return w;
|
|
49
|
+
for (let e = 0; e < epochs; e++) {
|
|
50
|
+
const grad = new Array(d).fill(0);
|
|
51
|
+
for (let i = 0; i < rows.length; i++) {
|
|
52
|
+
let z = 0;
|
|
53
|
+
for (let k = 0; k < d; k++)
|
|
54
|
+
z += w[k] * xs[i][k];
|
|
55
|
+
const err = sigmoid(z) - rows[i].y; // logistic loss gradient
|
|
56
|
+
for (let k = 0; k < d; k++)
|
|
57
|
+
grad[k] += err * xs[i][k];
|
|
58
|
+
}
|
|
59
|
+
for (let k = 0; k < d; k++)
|
|
60
|
+
w[k] -= lr * (grad[k] / rows.length + l2 * w[k]);
|
|
61
|
+
}
|
|
62
|
+
return w;
|
|
63
|
+
}
|
|
64
|
+
function rmse(w, rows) {
|
|
65
|
+
if (rows.length === 0)
|
|
66
|
+
return 0;
|
|
67
|
+
let s = 0;
|
|
68
|
+
for (const r of rows) {
|
|
69
|
+
let z = 0;
|
|
70
|
+
const xv = x(r.f);
|
|
71
|
+
for (let k = 0; k < w.length; k++)
|
|
72
|
+
z += w[k] * xv[k];
|
|
73
|
+
s += (sigmoid(z) - r.y) ** 2;
|
|
74
|
+
}
|
|
75
|
+
return Math.sqrt(s / rows.length);
|
|
76
|
+
}
|
|
77
|
+
/** Join labels with insights and map to training rows. Pure - testable. */
|
|
78
|
+
export function trainingRows(labels, insights) {
|
|
79
|
+
const bySession = new Map();
|
|
80
|
+
for (const i of insights)
|
|
81
|
+
if (!bySession.has(i.session))
|
|
82
|
+
bySession.set(i.session, i);
|
|
83
|
+
const rows = [];
|
|
84
|
+
for (const l of labels) {
|
|
85
|
+
const ins = bySession.get(l.session);
|
|
86
|
+
if (!ins)
|
|
87
|
+
continue;
|
|
88
|
+
const uses = Number(ins.toolUses ?? 0);
|
|
89
|
+
const outcome = ins.outcome === 'ok' ? 2 : ins.outcome === 'turn-budget' ? 1 : 0;
|
|
90
|
+
rows.push({
|
|
91
|
+
f: { outcome, toolFailRate: 0, turns: Number(ins.turns ?? 0), toolUses: uses },
|
|
92
|
+
y: l.score / 5,
|
|
93
|
+
session: l.session,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
/** Fit the reward model on joined human labels. Deterministic. */
|
|
99
|
+
export function fitRewardModel(labels, insights) {
|
|
100
|
+
const rows = trainingRows(labels, insights);
|
|
101
|
+
const { train, hold } = split(rows);
|
|
102
|
+
const w = fitLinear(train);
|
|
103
|
+
return {
|
|
104
|
+
w: w,
|
|
105
|
+
trainedAt: new Date().toISOString(),
|
|
106
|
+
nSamples: rows.length,
|
|
107
|
+
trainRmse: Number(rmse(w, train).toFixed(4)),
|
|
108
|
+
...(hold.length >= 4 ? { holdoutRmse: Number(rmse(w, hold).toFixed(4)) } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Score a run's features with fitted weights -> reward in [0,1]. */
|
|
112
|
+
export function scoreFeatures(w, f) {
|
|
113
|
+
const xv = x(f);
|
|
114
|
+
let z = 0;
|
|
115
|
+
for (let k = 0; k < w.length; k++)
|
|
116
|
+
z += w[k] * xv[k];
|
|
117
|
+
return sigmoid(z);
|
|
118
|
+
}
|
|
119
|
+
/** Persistence: HMH_HOME/evolution/reward-model.json. */
|
|
120
|
+
export async function saveRewardModel(home, m) {
|
|
121
|
+
const dir = join(home, 'evolution');
|
|
122
|
+
await mkdir(dir, { recursive: true });
|
|
123
|
+
await writeFile(join(dir, 'reward-model.json'), JSON.stringify(m, null, 2) + '\n', 'utf8');
|
|
124
|
+
}
|
|
125
|
+
export async function loadRewardModel(home) {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(await readFile(join(home, 'evolution', 'reward-model.json'), 'utf8'));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Build DPO pairs from joined labels + insights. Two classes:
|
|
134
|
+
* (a) IN-BUCKET (gold): same outcome, >=2 star gap - human preference the
|
|
135
|
+
* outcome reward cannot see (4-vs-5 star distinctions);
|
|
136
|
+
* (b) CROSS-BUCKET (classic): ok-vs-degraded completions of the SAME task
|
|
137
|
+
* template (task prefix match) - the textbook chosen/rejected pair.
|
|
138
|
+
* Pure - testable. */
|
|
139
|
+
export function exportDpoPairs(labels, insights, taskOf, answerOf, opts = {}) {
|
|
140
|
+
const minGap = opts.minInBucketGap ?? 0.4;
|
|
141
|
+
const prefixLen = opts.crossBucketPrefix ?? 60;
|
|
142
|
+
const rows = trainingRows(labels, insights);
|
|
143
|
+
const insOf = new Map(insights.map((i) => [i.session, i]));
|
|
144
|
+
const pairs = [];
|
|
145
|
+
for (const a of rows) {
|
|
146
|
+
for (const b of rows) {
|
|
147
|
+
if (a.session >= b.session)
|
|
148
|
+
continue;
|
|
149
|
+
const gap = a.y - b.y;
|
|
150
|
+
if (gap < minGap)
|
|
151
|
+
continue;
|
|
152
|
+
const hi = gap > 0 ? a : b;
|
|
153
|
+
const lo = gap > 0 ? b : a;
|
|
154
|
+
const hiIns = insOf.get(hi.session);
|
|
155
|
+
const loIns = insOf.get(lo.session);
|
|
156
|
+
if (!hiIns || !loIns)
|
|
157
|
+
continue;
|
|
158
|
+
const sameOutcome = hiIns.outcome === loIns.outcome;
|
|
159
|
+
let ok = false;
|
|
160
|
+
if (sameOutcome) {
|
|
161
|
+
ok = true; // in-bucket gold
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
// cross-bucket: only pair completions of the SAME task template
|
|
165
|
+
// (bench templates share long prefixes) - otherwise they teach
|
|
166
|
+
// nothing transferable
|
|
167
|
+
const ta = taskOf(hi.session);
|
|
168
|
+
const tb = taskOf(lo.session);
|
|
169
|
+
ok = ta.length >= prefixLen && ta.slice(0, prefixLen) === tb.slice(0, prefixLen);
|
|
170
|
+
}
|
|
171
|
+
if (!ok)
|
|
172
|
+
continue;
|
|
173
|
+
const srcHi = opts.sources?.get(hi.session);
|
|
174
|
+
const srcLo = opts.sources?.get(lo.session);
|
|
175
|
+
pairs.push({
|
|
176
|
+
prompt: taskOf(hi.session),
|
|
177
|
+
chosen: answerOf(hi.session),
|
|
178
|
+
rejected: answerOf(lo.session),
|
|
179
|
+
chosenSession: hi.session,
|
|
180
|
+
rejectedSession: lo.session,
|
|
181
|
+
gap: Number(gap.toFixed(2)),
|
|
182
|
+
...(srcHi && srcLo ? { source: srcHi === srcLo ? srcHi : 'mixed' } : {}),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return pairs;
|
|
187
|
+
}
|
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"
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
export interface BenchCase {
|
|
2
|
-
name: string;
|
|
3
|
-
prompt: string;
|
|
4
|
-
/** All substrings (split on &&) must appear in the output, case-insensitive. */
|
|
5
|
-
expect: string[];
|
|
6
|
-
/** Output must equal this string exactly (both trimmed). */
|
|
7
|
-
expectExact?: string;
|
|
8
|
-
/** Output must match this regex (whole output, case-sensitive). */
|
|
9
|
-
expectRegex?: string;
|
|
10
|
-
/** Substrings that must NOT appear (failure-marker exclusion). */
|
|
11
|
-
expectNone?: string[];
|
|
12
|
-
/** At least ONE of these substrings must appear. */
|
|
13
|
-
expectAny?: string[];
|
|
14
|
-
/** When true the case runs through the full agent loop with tools. */
|
|
15
|
-
tools: boolean;
|
|
16
|
-
/** Holdout cases are excluded from the promotion gate and re-verify after promotion (anti-memorization, GDPevo style). */
|
|
17
|
-
holdout: boolean;
|
|
18
|
-
/** Candidate token-cost ceiling as a multiple of the baseline run. */
|
|
19
|
-
costCap?: number;
|
|
20
|
-
}
|
|
21
|
-
export interface BenchResult {
|
|
22
|
-
name: string;
|
|
23
|
-
pass: boolean;
|
|
24
|
-
detail: string;
|
|
25
|
-
}
|
|
26
|
-
export declare function matchExpect(output: string, expect: string[]): boolean;
|
|
27
|
-
/** Fence convention: when the reply is a fenced block, assertions test the
|
|
28
|
-
* fence INNER content (day-55 finding: models preserve literals verbatim
|
|
29
|
-
* inside code fences while normalizing them in prose - the fence is the
|
|
30
|
-
* reply convention that makes exactness attainable for habit-prone models).
|
|
31
|
-
* Tolerates a trailing space after the opening fence markers. */
|
|
32
|
-
export declare function fenceInner(output: string): string;
|
|
33
|
-
/** Full structured assertion: every declared mode must hold. */
|
|
34
|
-
export declare function matchCase(output: string, c: Pick<BenchCase, 'expect' | 'expectExact' | 'expectRegex' | 'expectNone' | 'expectAny'>): {
|
|
35
|
-
pass: boolean;
|
|
36
|
-
detail: string;
|
|
37
|
-
};
|
|
38
|
-
export declare function listCases(home: string): Promise<BenchCase[]>;
|
|
39
|
-
export declare function runBench(home: string, run: (c: BenchCase) => Promise<string>): Promise<{
|
|
40
|
-
results: BenchResult[];
|
|
41
|
-
passRate: number;
|
|
42
|
-
}>;
|
|
43
|
-
/** Starter cases so the evolve gate has a signal from day one. */
|
|
44
|
-
export declare function seedCases(home: string): Promise<string[]>;
|