@hmharness/evolution 0.14.21 → 0.14.23
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/cloud-finetune.d.ts +126 -0
- package/dist/cloud-finetune.js +200 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/policy-optimizer.d.ts +76 -0
- package/dist/policy-optimizer.js +101 -0
- package/dist/rl-governance.d.ts +75 -0
- package/dist/rl-governance.js +136 -0
- package/package.json +1 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { DpoPair } from './reward-model.ts';
|
|
2
|
+
/** A fine-tunable model + how its jobs are priced. Prices change - the
|
|
3
|
+
* caller renders the estimate, the catalog only carries what we verified. */
|
|
4
|
+
export interface FineTuneModelInfo {
|
|
5
|
+
id: string;
|
|
6
|
+
dpo: boolean;
|
|
7
|
+
note: string;
|
|
8
|
+
}
|
|
9
|
+
/** Models verified as fine-tunable (guide + live probe 2026-09-25: bare
|
|
10
|
+
* glm-4-air 404s "微调模型不存在", glm-4-flash passes model validation). */
|
|
11
|
+
export declare const FINE_TUNE_CATALOG: FineTuneModelInfo[];
|
|
12
|
+
export declare const DEFAULT_FINE_TUNE_MODEL = "glm-4-flash";
|
|
13
|
+
/**
|
|
14
|
+
* Model auto-selection (user direction 2026-09-25: novices must never pick
|
|
15
|
+
* a fine-tune base). Candidates: the user's MAIN model first - if the API
|
|
16
|
+
* rejects it (live probe 2026-09-25: glm-5.3 answers "微调功能未开放") the
|
|
17
|
+
* ladder silently falls to the next documented fine-tunable base. The probe
|
|
18
|
+
* POSTs {model, training_file:"file-probe-invalid"} - model validation runs
|
|
19
|
+
* BEFORE file binding, so the request always fails without creating
|
|
20
|
+
* anything, and the error text tells us whether the model is usable.
|
|
21
|
+
*/
|
|
22
|
+
export declare function fineTuneModelLadder(mainModel: string): string[];
|
|
23
|
+
/** Zero-side-effect model probe (the request can never create a job - the
|
|
24
|
+
* training_file id is invalid on purpose). "model rejected" vs "reached
|
|
25
|
+
* file validation" is distinguishable in the error text. */
|
|
26
|
+
export declare function probeFineTuneModel(opts: {
|
|
27
|
+
apiKey: string;
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
model: string;
|
|
30
|
+
fetchImpl?: typeof fetch;
|
|
31
|
+
}): Promise<{
|
|
32
|
+
usable: boolean;
|
|
33
|
+
reason?: string;
|
|
34
|
+
}>;
|
|
35
|
+
/** Walk the ladder, return the first usable model plus why earlier ones
|
|
36
|
+
* were skipped (surfaced as one Chinese line - the backend adapts, the
|
|
37
|
+
* user reads, never chooses). */
|
|
38
|
+
export declare function pickFineTuneModel(opts: {
|
|
39
|
+
apiKey: string;
|
|
40
|
+
baseUrl: string;
|
|
41
|
+
mainModel: string;
|
|
42
|
+
log?: (line: string) => void;
|
|
43
|
+
fetchImpl?: typeof fetch;
|
|
44
|
+
}): Promise<string>;
|
|
45
|
+
/**
|
|
46
|
+
* Fine-tuning does not live on the coding endpoint: config.json points the
|
|
47
|
+
* chat route at /api/coding/paas/v4, but the fine_tuning/file APIs sit on
|
|
48
|
+
* /api/paas/v4. Strip the coding segment so the user's existing glm provider
|
|
49
|
+
* entry works unchanged (zero new configuration).
|
|
50
|
+
*/
|
|
51
|
+
export declare function normalizeFineTuneBase(baseUrl: string): string;
|
|
52
|
+
/** Serialize pairs into the provider DPO JSONL row format (pure). Accepts
|
|
53
|
+
* the slim {prompt,chosen,rejected} rows the CLI persists - provenance
|
|
54
|
+
* fields never leave the machine. */
|
|
55
|
+
export declare function toDpoJsonl(pairs: Array<Pick<DpoPair, 'prompt' | 'chosen' | 'rejected'>>): string;
|
|
56
|
+
/** Rough token estimate for a cost preview before upload (chars/4 heuristic;
|
|
57
|
+
* the upload response returns exact per-tokenizer stats which the UI shows
|
|
58
|
+
* alongside - this is only the pre-flight number). */
|
|
59
|
+
export declare function estimateTokens(jsonl: string): number;
|
|
60
|
+
/** Training cost = tokens × epochs × unit price. Pure math, price supplied
|
|
61
|
+
* by the caller (it changes; we never bake it in as truth). */
|
|
62
|
+
export declare function estimateTrainingCost(tokens: number, epochs: number, pricePerKTokens: number): number;
|
|
63
|
+
export interface UploadedFile {
|
|
64
|
+
id: string;
|
|
65
|
+
bytes: number;
|
|
66
|
+
samples: number;
|
|
67
|
+
/** tokens counted by the provider tokenizer (max of the reported stats). */
|
|
68
|
+
tokens: number;
|
|
69
|
+
}
|
|
70
|
+
/** Upload a training/validation JSONL (free operation, verified live). */
|
|
71
|
+
export declare function uploadFineTuneFile(opts: {
|
|
72
|
+
apiKey: string;
|
|
73
|
+
baseUrl: string;
|
|
74
|
+
filename: string;
|
|
75
|
+
jsonl: string;
|
|
76
|
+
fetchImpl?: typeof fetch;
|
|
77
|
+
}): Promise<UploadedFile>;
|
|
78
|
+
export interface FineTuneJob {
|
|
79
|
+
id: string;
|
|
80
|
+
model?: string;
|
|
81
|
+
status?: string;
|
|
82
|
+
fine_tuned_model?: string;
|
|
83
|
+
training_file?: string;
|
|
84
|
+
created_at?: number;
|
|
85
|
+
[k: string]: unknown;
|
|
86
|
+
}
|
|
87
|
+
/** Create a fine-tuning job (PAID - callers must gate behind explicit
|
|
88
|
+
* submit consent; uploads alone are free). */
|
|
89
|
+
export declare function createFineTuneJob(opts: {
|
|
90
|
+
apiKey: string;
|
|
91
|
+
baseUrl: string;
|
|
92
|
+
model: string;
|
|
93
|
+
trainingFile: string;
|
|
94
|
+
validationFile?: string;
|
|
95
|
+
suffix?: string;
|
|
96
|
+
hyperparameters?: {
|
|
97
|
+
batch_size?: number | 'auto';
|
|
98
|
+
learning_rate_multiplier?: number | 'auto';
|
|
99
|
+
n_epochs?: number | 'auto';
|
|
100
|
+
};
|
|
101
|
+
fetchImpl?: typeof fetch;
|
|
102
|
+
}): Promise<FineTuneJob>;
|
|
103
|
+
export declare function listFineTuneJobs(opts: {
|
|
104
|
+
apiKey: string;
|
|
105
|
+
baseUrl: string;
|
|
106
|
+
limit?: number;
|
|
107
|
+
fetchImpl?: typeof fetch;
|
|
108
|
+
}): Promise<FineTuneJob[]>;
|
|
109
|
+
export declare function retrieveFineTuneJob(opts: {
|
|
110
|
+
apiKey: string;
|
|
111
|
+
baseUrl: string;
|
|
112
|
+
jobId: string;
|
|
113
|
+
fetchImpl?: typeof fetch;
|
|
114
|
+
}): Promise<FineTuneJob>;
|
|
115
|
+
export declare function cancelFineTuneJob(opts: {
|
|
116
|
+
apiKey: string;
|
|
117
|
+
baseUrl: string;
|
|
118
|
+
jobId: string;
|
|
119
|
+
fetchImpl?: typeof fetch;
|
|
120
|
+
}): Promise<FineTuneJob>;
|
|
121
|
+
/** Append-only record of jobs this machine submitted (audit trail: what was
|
|
122
|
+
* trained, on which file, when - survives provider console churn). */
|
|
123
|
+
export declare function recordFineTuneJob(home: string, job: FineTuneJob, extra?: Record<string, unknown>): Promise<void>;
|
|
124
|
+
export declare function readLocalFineTuneJobs(home: string): Promise<Array<FineTuneJob & {
|
|
125
|
+
time: string;
|
|
126
|
+
}>>;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud fine-tuning client (2026-09-25, closes the auto-finetune loop).
|
|
3
|
+
*
|
|
4
|
+
* Direction (user, 2026-09-24): users configure ONE api key, the backend
|
|
5
|
+
* adapts everything else; local-GPU training was a developer experiment.
|
|
6
|
+
* This module submits the accumulated DPO preference pairs to the provider's
|
|
7
|
+
* fine-tuning API so "gets smarter as you use it" needs no local stack.
|
|
8
|
+
*
|
|
9
|
+
* Endpoint surface verified live 2026-09-25 against open.bigmodel.cn with a
|
|
10
|
+
* real key (read-only probes + one 85-byte purpose=fine-tune upload):
|
|
11
|
+
* POST /api/paas/v4/files multipart file + purpose=fine-tune
|
|
12
|
+
* POST /api/paas/v4/fine_tuning/jobs {model, training_file, ...}
|
|
13
|
+
* GET /api/paas/v4/fine_tuning/jobs list
|
|
14
|
+
* GET /api/paas/v4/fine_tuning/jobs/{id} retrieve
|
|
15
|
+
* POST /api/paas/v4/fine_tuning/jobs/{id}/cancel
|
|
16
|
+
* GET /api/paas/v4/fine_tuning/jobs/{id}/events
|
|
17
|
+
* Cross-checked field names against zhipuai SDK 2.1.5 source
|
|
18
|
+
* (api_resource/fine_tuning/jobs/jobs.py). The DPO row format follows the
|
|
19
|
+
* official guide: {"input":{messages,tools,parallel_tool_calls},
|
|
20
|
+
* "preferred_output":[...],"non_preferred_output":[...]}.
|
|
21
|
+
*/
|
|
22
|
+
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
/** Models verified as fine-tunable (guide + live probe 2026-09-25: bare
|
|
25
|
+
* glm-4-air 404s "微调模型不存在", glm-4-flash passes model validation). */
|
|
26
|
+
export const FINE_TUNE_CATALOG = [
|
|
27
|
+
{ id: 'glm-4-flash', dpo: true, note: '最便宜,LoRA 需开发者 Pro 权益' },
|
|
28
|
+
{ id: 'glm-4.5-air', dpo: false, note: '全参微调,对所有用户开放(SFT)' },
|
|
29
|
+
{ id: 'glm-4-air-250414', dpo: true, note: '全参微调' },
|
|
30
|
+
{ id: 'glm-4-air-x', dpo: true, note: '全参微调' },
|
|
31
|
+
];
|
|
32
|
+
export const DEFAULT_FINE_TUNE_MODEL = 'glm-4-flash';
|
|
33
|
+
/**
|
|
34
|
+
* Model auto-selection (user direction 2026-09-25: novices must never pick
|
|
35
|
+
* a fine-tune base). Candidates: the user's MAIN model first - if the API
|
|
36
|
+
* rejects it (live probe 2026-09-25: glm-5.3 answers "微调功能未开放") the
|
|
37
|
+
* ladder silently falls to the next documented fine-tunable base. The probe
|
|
38
|
+
* POSTs {model, training_file:"file-probe-invalid"} - model validation runs
|
|
39
|
+
* BEFORE file binding, so the request always fails without creating
|
|
40
|
+
* anything, and the error text tells us whether the model is usable.
|
|
41
|
+
*/
|
|
42
|
+
export function fineTuneModelLadder(mainModel) {
|
|
43
|
+
const ladder = [mainModel, DEFAULT_FINE_TUNE_MODEL, 'glm-4.5-air'];
|
|
44
|
+
return [...new Set(ladder.filter(Boolean))];
|
|
45
|
+
}
|
|
46
|
+
/** Zero-side-effect model probe (the request can never create a job - the
|
|
47
|
+
* training_file id is invalid on purpose). "model rejected" vs "reached
|
|
48
|
+
* file validation" is distinguishable in the error text. */
|
|
49
|
+
export async function probeFineTuneModel(opts) {
|
|
50
|
+
const url = normalizeFineTuneBase(opts.baseUrl) + '/fine_tuning/jobs';
|
|
51
|
+
const res = await (opts.fetchImpl ?? fetch)(url, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { Authorization: `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json' },
|
|
54
|
+
body: JSON.stringify({ model: opts.model, training_file: 'file-probe-invalid' }),
|
|
55
|
+
});
|
|
56
|
+
const text = await res.text();
|
|
57
|
+
if (res.ok)
|
|
58
|
+
return { usable: true }; // unreachable in practice; harmless
|
|
59
|
+
const rejected = /微调模型不存在|微调功能未开放|无权限|not found/i.test(text);
|
|
60
|
+
return { usable: !rejected, reason: rejected ? text.slice(0, 160) : undefined };
|
|
61
|
+
}
|
|
62
|
+
/** Walk the ladder, return the first usable model plus why earlier ones
|
|
63
|
+
* were skipped (surfaced as one Chinese line - the backend adapts, the
|
|
64
|
+
* user reads, never chooses). */
|
|
65
|
+
export async function pickFineTuneModel(opts) {
|
|
66
|
+
for (const m of fineTuneModelLadder(opts.mainModel)) {
|
|
67
|
+
const r = await probeFineTuneModel({ apiKey: opts.apiKey, baseUrl: opts.baseUrl, model: m, fetchImpl: opts.fetchImpl });
|
|
68
|
+
if (r.usable)
|
|
69
|
+
return m;
|
|
70
|
+
opts.log?.(`微调候选 ${m} 不可用(${(r.reason ?? '').replace(/^.*"message":"([^"]*)".*$/, '$1').slice(0, 60) || 'API 拒绝'}),自动改试下一个`);
|
|
71
|
+
}
|
|
72
|
+
throw new Error('所有微调候选模型均不可用——请在智谱控制台确认微调权限(可能需联系客服开放),或用 --model= 指定其他可微调模型');
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Fine-tuning does not live on the coding endpoint: config.json points the
|
|
76
|
+
* chat route at /api/coding/paas/v4, but the fine_tuning/file APIs sit on
|
|
77
|
+
* /api/paas/v4. Strip the coding segment so the user's existing glm provider
|
|
78
|
+
* entry works unchanged (zero new configuration).
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeFineTuneBase(baseUrl) {
|
|
81
|
+
let u = baseUrl.trim().replace(/\/+$/, '');
|
|
82
|
+
u = u.replace(/\/coding\/paas\/v4$/, '/paas/v4');
|
|
83
|
+
if (!/\/api\/paas\/v4$/.test(u)) {
|
|
84
|
+
const m = u.match(/^(https?:\/\/[^/]+)/);
|
|
85
|
+
if (!m)
|
|
86
|
+
throw new Error('baseUrl 需以 http(s):// 开头: ' + baseUrl);
|
|
87
|
+
u = m[1] + '/api/paas/v4';
|
|
88
|
+
}
|
|
89
|
+
return u;
|
|
90
|
+
}
|
|
91
|
+
/** Serialize pairs into the provider DPO JSONL row format (pure). Accepts
|
|
92
|
+
* the slim {prompt,chosen,rejected} rows the CLI persists - provenance
|
|
93
|
+
* fields never leave the machine. */
|
|
94
|
+
export function toDpoJsonl(pairs) {
|
|
95
|
+
const lines = [];
|
|
96
|
+
for (const p of pairs) {
|
|
97
|
+
if (!p.prompt.trim() || !p.chosen.trim() || !p.rejected.trim())
|
|
98
|
+
continue; // quality gate mirrors rl-governance
|
|
99
|
+
const row = {
|
|
100
|
+
input: {
|
|
101
|
+
messages: [{ role: 'user', content: p.prompt }],
|
|
102
|
+
tools: [],
|
|
103
|
+
parallel_tool_calls: false,
|
|
104
|
+
},
|
|
105
|
+
preferred_output: [{ role: 'assistant', content: p.chosen }],
|
|
106
|
+
non_preferred_output: [{ role: 'assistant', content: p.rejected }],
|
|
107
|
+
};
|
|
108
|
+
lines.push(JSON.stringify(row));
|
|
109
|
+
}
|
|
110
|
+
return lines.join('\n') + (lines.length ? '\n' : '');
|
|
111
|
+
}
|
|
112
|
+
/** Rough token estimate for a cost preview before upload (chars/4 heuristic;
|
|
113
|
+
* the upload response returns exact per-tokenizer stats which the UI shows
|
|
114
|
+
* alongside - this is only the pre-flight number). */
|
|
115
|
+
export function estimateTokens(jsonl) {
|
|
116
|
+
return Math.ceil(jsonl.length / 4);
|
|
117
|
+
}
|
|
118
|
+
/** Training cost = tokens × epochs × unit price. Pure math, price supplied
|
|
119
|
+
* by the caller (it changes; we never bake it in as truth). */
|
|
120
|
+
export function estimateTrainingCost(tokens, epochs, pricePerKTokens) {
|
|
121
|
+
return Math.round(((tokens / 1000) * epochs * pricePerKTokens) * 100) / 100;
|
|
122
|
+
}
|
|
123
|
+
async function zhipuFetch(url, apiKey, init, fetchImpl) {
|
|
124
|
+
const res = await fetchImpl(url, {
|
|
125
|
+
...init,
|
|
126
|
+
headers: { Authorization: `Bearer ${apiKey}`, ...init.headers },
|
|
127
|
+
});
|
|
128
|
+
const text = await res.text();
|
|
129
|
+
let body;
|
|
130
|
+
try {
|
|
131
|
+
body = JSON.parse(text);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
body = text;
|
|
135
|
+
}
|
|
136
|
+
if (!res.ok) {
|
|
137
|
+
const err = body;
|
|
138
|
+
throw new Error(`HTTP ${res.status}: ${err?.error?.message ?? String(body).slice(0, 200)}`);
|
|
139
|
+
}
|
|
140
|
+
return body;
|
|
141
|
+
}
|
|
142
|
+
/** Upload a training/validation JSONL (free operation, verified live). */
|
|
143
|
+
export async function uploadFineTuneFile(opts) {
|
|
144
|
+
const url = normalizeFineTuneBase(opts.baseUrl) + '/files';
|
|
145
|
+
const form = new FormData();
|
|
146
|
+
form.append('purpose', 'fine-tune');
|
|
147
|
+
form.append('file', new Blob([opts.jsonl], { type: 'application/jsonl' }), opts.filename);
|
|
148
|
+
const body = await zhipuFetch(url, opts.apiKey, { method: 'POST', body: form }, opts.fetchImpl ?? fetch);
|
|
149
|
+
const o = body;
|
|
150
|
+
const tokens = Math.max(0, ...(o.text_stats ?? []).map((t) => t.tokens ?? 0));
|
|
151
|
+
return { id: o.id, bytes: o.bytes, samples: o.samples ?? 0, tokens };
|
|
152
|
+
}
|
|
153
|
+
/** Create a fine-tuning job (PAID - callers must gate behind explicit
|
|
154
|
+
* submit consent; uploads alone are free). */
|
|
155
|
+
export async function createFineTuneJob(opts) {
|
|
156
|
+
const url = normalizeFineTuneBase(opts.baseUrl) + '/fine_tuning/jobs';
|
|
157
|
+
const payload = { model: opts.model, training_file: opts.trainingFile };
|
|
158
|
+
if (opts.validationFile)
|
|
159
|
+
payload.validation_file = opts.validationFile;
|
|
160
|
+
if (opts.suffix)
|
|
161
|
+
payload.suffix = opts.suffix;
|
|
162
|
+
if (opts.hyperparameters)
|
|
163
|
+
payload.hyperparameters = opts.hyperparameters;
|
|
164
|
+
return (await zhipuFetch(url, opts.apiKey, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'Content-Type': 'application/json' },
|
|
167
|
+
body: JSON.stringify(payload),
|
|
168
|
+
}, opts.fetchImpl ?? fetch));
|
|
169
|
+
}
|
|
170
|
+
export async function listFineTuneJobs(opts) {
|
|
171
|
+
const url = normalizeFineTuneBase(opts.baseUrl) + `/fine_tuning/jobs?limit=${opts.limit ?? 10}`;
|
|
172
|
+
const body = await zhipuFetch(url, opts.apiKey, { method: 'GET' }, opts.fetchImpl ?? fetch);
|
|
173
|
+
return (body.data ?? []);
|
|
174
|
+
}
|
|
175
|
+
export async function retrieveFineTuneJob(opts) {
|
|
176
|
+
const url = normalizeFineTuneBase(opts.baseUrl) + `/fine_tuning/jobs/${opts.jobId}`;
|
|
177
|
+
return (await zhipuFetch(url, opts.apiKey, { method: 'GET' }, opts.fetchImpl ?? fetch));
|
|
178
|
+
}
|
|
179
|
+
export async function cancelFineTuneJob(opts) {
|
|
180
|
+
const url = normalizeFineTuneBase(opts.baseUrl) + `/fine_tuning/jobs/${opts.jobId}/cancel`;
|
|
181
|
+
return (await zhipuFetch(url, opts.apiKey, { method: 'POST' }, opts.fetchImpl ?? fetch));
|
|
182
|
+
}
|
|
183
|
+
/* ---------------- local job ledger ---------------- */
|
|
184
|
+
/** Append-only record of jobs this machine submitted (audit trail: what was
|
|
185
|
+
* trained, on which file, when - survives provider console churn). */
|
|
186
|
+
export async function recordFineTuneJob(home, job, extra = {}) {
|
|
187
|
+
const dir = join(home, 'evolution');
|
|
188
|
+
await mkdir(dir, { recursive: true });
|
|
189
|
+
const row = { time: new Date().toISOString(), ...job, ...extra };
|
|
190
|
+
await appendFile(join(dir, 'finetune-jobs.jsonl'), JSON.stringify(row) + '\n', 'utf8');
|
|
191
|
+
}
|
|
192
|
+
export async function readLocalFineTuneJobs(home) {
|
|
193
|
+
try {
|
|
194
|
+
const text = await readFile(join(home, 'evolution', 'finetune-jobs.jsonl'), 'utf8');
|
|
195
|
+
return text.split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -21,3 +21,6 @@ export * from './external.ts';
|
|
|
21
21
|
export * from './knowledge-graph.ts';
|
|
22
22
|
export * from './router-evolution.ts';
|
|
23
23
|
export * from './context-evolution.ts';
|
|
24
|
+
export * from './rl-governance.ts';
|
|
25
|
+
export * from './policy-optimizer.ts';
|
|
26
|
+
export * from './cloud-finetune.ts';
|
package/dist/index.js
CHANGED
|
@@ -21,3 +21,6 @@ export * from "./external.js";
|
|
|
21
21
|
export * from "./knowledge-graph.js";
|
|
22
22
|
export * from "./router-evolution.js";
|
|
23
23
|
export * from "./context-evolution.js";
|
|
24
|
+
export * from "./rl-governance.js";
|
|
25
|
+
export * from "./policy-optimizer.js";
|
|
26
|
+
export * from "./cloud-finetune.js";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/evolution - Policy Optimization (P3-02)
|
|
3
|
+
*
|
|
4
|
+
* The audit: "把 prompt、skill、tool policy、routing、workflow 全部视为可优化 policy"
|
|
5
|
+
*
|
|
6
|
+
* Provides a unified optimization framework where every configurable aspect
|
|
7
|
+
* of the harness (prompts, skills, tool policies, routing, workflows) is
|
|
8
|
+
* a first-class "Policy" that can be experimented on and improved.
|
|
9
|
+
*/
|
|
10
|
+
export type PolicyKind = 'prompt' | 'skill' | 'tool-policy' | 'routing' | 'workflow' | 'context' | 'model';
|
|
11
|
+
export interface OptimizablePolicy {
|
|
12
|
+
id: string;
|
|
13
|
+
kind: PolicyKind;
|
|
14
|
+
name: string;
|
|
15
|
+
/** current value/configuration */
|
|
16
|
+
value: unknown;
|
|
17
|
+
/** human-readable description of what this policy controls */
|
|
18
|
+
description: string;
|
|
19
|
+
/** how changes to this policy are tested */
|
|
20
|
+
evaluationMethod: string;
|
|
21
|
+
}
|
|
22
|
+
export interface PolicyExperiment {
|
|
23
|
+
id: string;
|
|
24
|
+
policy: OptimizablePolicy;
|
|
25
|
+
/** proposed new value */
|
|
26
|
+
challenger: unknown;
|
|
27
|
+
/** A/B arm results */
|
|
28
|
+
controlScores: number[];
|
|
29
|
+
treatmentScores: number[];
|
|
30
|
+
status: 'proposed' | 'running' | 'promoted' | 'rolled-back' | 'rejected';
|
|
31
|
+
createdAt: string;
|
|
32
|
+
}
|
|
33
|
+
export interface PolicyOptimizationReport {
|
|
34
|
+
totalPolicies: number;
|
|
35
|
+
activeExperiments: number;
|
|
36
|
+
promotedCount: number;
|
|
37
|
+
rolledBackCount: number;
|
|
38
|
+
byKind: Record<PolicyKind, number>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The policy registry - all optimizable policies in one place.
|
|
42
|
+
*/
|
|
43
|
+
export declare class PolicyOptimizer {
|
|
44
|
+
private policies;
|
|
45
|
+
private experiments;
|
|
46
|
+
registerPolicy(policy: OptimizablePolicy): {
|
|
47
|
+
ok: boolean;
|
|
48
|
+
reason?: string;
|
|
49
|
+
};
|
|
50
|
+
getPolicy(id: string): OptimizablePolicy | undefined;
|
|
51
|
+
listPolicies(kind?: PolicyKind): OptimizablePolicy[];
|
|
52
|
+
/**
|
|
53
|
+
* Propose an experiment: change a policy's value.
|
|
54
|
+
*/
|
|
55
|
+
proposeExperiment(policyId: string, challenger: unknown): {
|
|
56
|
+
ok: boolean;
|
|
57
|
+
experimentId?: string;
|
|
58
|
+
reason?: string;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Record a result for an experiment arm.
|
|
62
|
+
*/
|
|
63
|
+
recordResult(experimentId: string, arm: 'control' | 'treatment', score: number): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Evaluate an experiment (simple mean comparison).
|
|
66
|
+
* Pure logic within the method.
|
|
67
|
+
*/
|
|
68
|
+
evaluateExperiment(experimentId: string): {
|
|
69
|
+
decision: string;
|
|
70
|
+
reason: string;
|
|
71
|
+
} | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* Generate a summary report.
|
|
74
|
+
*/
|
|
75
|
+
report(): PolicyOptimizationReport;
|
|
76
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/evolution - Policy Optimization (P3-02)
|
|
3
|
+
*
|
|
4
|
+
* The audit: "把 prompt、skill、tool policy、routing、workflow 全部视为可优化 policy"
|
|
5
|
+
*
|
|
6
|
+
* Provides a unified optimization framework where every configurable aspect
|
|
7
|
+
* of the harness (prompts, skills, tool policies, routing, workflows) is
|
|
8
|
+
* a first-class "Policy" that can be experimented on and improved.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The policy registry - all optimizable policies in one place.
|
|
12
|
+
*/
|
|
13
|
+
export class PolicyOptimizer {
|
|
14
|
+
policies = new Map();
|
|
15
|
+
experiments = new Map();
|
|
16
|
+
registerPolicy(policy) {
|
|
17
|
+
if (this.policies.has(policy.id))
|
|
18
|
+
return { ok: false, reason: `policy ${policy.id} already registered` };
|
|
19
|
+
this.policies.set(policy.id, policy);
|
|
20
|
+
return { ok: true };
|
|
21
|
+
}
|
|
22
|
+
getPolicy(id) {
|
|
23
|
+
return this.policies.get(id);
|
|
24
|
+
}
|
|
25
|
+
listPolicies(kind) {
|
|
26
|
+
const all = [...this.policies.values()];
|
|
27
|
+
return kind ? all.filter(p => p.kind === kind) : all;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Propose an experiment: change a policy's value.
|
|
31
|
+
*/
|
|
32
|
+
proposeExperiment(policyId, challenger) {
|
|
33
|
+
const policy = this.policies.get(policyId);
|
|
34
|
+
if (!policy)
|
|
35
|
+
return { ok: false, reason: `policy ${policyId} not found` };
|
|
36
|
+
const expId = `exp-${Date.now().toString(36)}`;
|
|
37
|
+
this.experiments.set(expId, {
|
|
38
|
+
id: expId, policy, challenger,
|
|
39
|
+
controlScores: [], treatmentScores: [],
|
|
40
|
+
status: 'proposed', createdAt: new Date().toISOString(),
|
|
41
|
+
});
|
|
42
|
+
return { ok: true, experimentId: expId };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Record a result for an experiment arm.
|
|
46
|
+
*/
|
|
47
|
+
recordResult(experimentId, arm, score) {
|
|
48
|
+
const exp = this.experiments.get(experimentId);
|
|
49
|
+
if (!exp)
|
|
50
|
+
return false;
|
|
51
|
+
if (arm === 'control')
|
|
52
|
+
exp.controlScores.push(score);
|
|
53
|
+
else
|
|
54
|
+
exp.treatmentScores.push(score);
|
|
55
|
+
exp.status = 'running';
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Evaluate an experiment (simple mean comparison).
|
|
60
|
+
* Pure logic within the method.
|
|
61
|
+
*/
|
|
62
|
+
evaluateExperiment(experimentId) {
|
|
63
|
+
const exp = this.experiments.get(experimentId);
|
|
64
|
+
if (!exp)
|
|
65
|
+
return undefined;
|
|
66
|
+
const minN = 10;
|
|
67
|
+
if (exp.controlScores.length < minN || exp.treatmentScores.length < minN) {
|
|
68
|
+
return { decision: 'continue', reason: `insufficient samples (ctrl ${exp.controlScores.length}/${minN}, trt ${exp.treatmentScores.length}/${minN})` };
|
|
69
|
+
}
|
|
70
|
+
const ctrlMean = exp.controlScores.reduce((a, b) => a + b, 0) / exp.controlScores.length;
|
|
71
|
+
const trtMean = exp.treatmentScores.reduce((a, b) => a + b, 0) / exp.treatmentScores.length;
|
|
72
|
+
const diff = trtMean - ctrlMean;
|
|
73
|
+
if (diff > 0.02) {
|
|
74
|
+
exp.status = 'promoted';
|
|
75
|
+
exp.policy.value = exp.challenger;
|
|
76
|
+
return { decision: 'promote', reason: `treatment +${diff.toFixed(3)}` };
|
|
77
|
+
}
|
|
78
|
+
if (diff < -0.02) {
|
|
79
|
+
exp.status = 'rolled-back';
|
|
80
|
+
return { decision: 'rollback', reason: `treatment ${diff.toFixed(3)}` };
|
|
81
|
+
}
|
|
82
|
+
return { decision: 'continue', reason: `diff ${diff.toFixed(3)} within noise` };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Generate a summary report.
|
|
86
|
+
*/
|
|
87
|
+
report() {
|
|
88
|
+
const all = [...this.policies.values()];
|
|
89
|
+
const exps = [...this.experiments.values()];
|
|
90
|
+
const byKind = {};
|
|
91
|
+
for (const p of all)
|
|
92
|
+
byKind[p.kind] = (byKind[p.kind] ?? 0) + 1;
|
|
93
|
+
return {
|
|
94
|
+
totalPolicies: all.length,
|
|
95
|
+
activeExperiments: exps.filter(e => e.status === 'running').length,
|
|
96
|
+
promotedCount: exps.filter(e => e.status === 'promoted').length,
|
|
97
|
+
rolledBackCount: exps.filter(e => e.status === 'rolled-back').length,
|
|
98
|
+
byKind: byKind,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/evolution - RL Governance (P3-01 + P3-05)
|
|
3
|
+
*
|
|
4
|
+
* The audit: "只有 trajectory 数据经过过滤、去污染、去泄漏、holdout 后再考虑"
|
|
5
|
+
*
|
|
6
|
+
* Formalizes the governance pipeline that ALL training data must pass
|
|
7
|
+
* before entering model fine-tuning. This is the data quality gate.
|
|
8
|
+
*/
|
|
9
|
+
export type DecontaminationAction = 'pass' | 'quarantine' | 'reject';
|
|
10
|
+
export interface TrajectoryRecord {
|
|
11
|
+
id: string;
|
|
12
|
+
taskId: string;
|
|
13
|
+
prompt: string;
|
|
14
|
+
output: string;
|
|
15
|
+
outcome: 'ok' | 'degraded' | 'timeout' | 'error';
|
|
16
|
+
tokens: number;
|
|
17
|
+
turns: number;
|
|
18
|
+
/** whether this trajectory came from an external task */
|
|
19
|
+
isExternal: boolean;
|
|
20
|
+
/** whether this trajectory is in the holdout set */
|
|
21
|
+
isHoldout: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface DecontaminationResult {
|
|
24
|
+
recordId: string;
|
|
25
|
+
action: DecontaminationAction;
|
|
26
|
+
reasons: string[];
|
|
27
|
+
checks: Array<{
|
|
28
|
+
name: string;
|
|
29
|
+
passed: boolean;
|
|
30
|
+
detail: string;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
/** Types of leakage to check for */
|
|
34
|
+
export type LeakageType = 'holdout-in-train' | 'self-eval' | 'prompt-duplication' | 'external-in-train';
|
|
35
|
+
export interface LeakageCheck {
|
|
36
|
+
type: LeakageType;
|
|
37
|
+
detected: boolean;
|
|
38
|
+
details: string[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Filter trajectories by quality before training.
|
|
42
|
+
* Pure - testable.
|
|
43
|
+
*/
|
|
44
|
+
export declare function filterForTraining(records: TrajectoryRecord[]): {
|
|
45
|
+
eligible: TrajectoryRecord[];
|
|
46
|
+
rejected: Array<{
|
|
47
|
+
record: TrajectoryRecord;
|
|
48
|
+
reason: string;
|
|
49
|
+
}>;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Detect leakage between training and holdout sets.
|
|
53
|
+
* Pure - testable.
|
|
54
|
+
*/
|
|
55
|
+
export declare function detectLeakage(trainingRecords: TrajectoryRecord[], holdoutRecords: TrajectoryRecord[]): LeakageCheck[];
|
|
56
|
+
/**
|
|
57
|
+
* Full decontamination pipeline: run all checks and decide action.
|
|
58
|
+
* Pure - testable.
|
|
59
|
+
*/
|
|
60
|
+
export declare function decontaminate(record: TrajectoryRecord, holdoutPrompts: string[]): DecontaminationResult;
|
|
61
|
+
/**
|
|
62
|
+
* Generate an RL readiness report.
|
|
63
|
+
* Pure - testable.
|
|
64
|
+
*/
|
|
65
|
+
export declare function rlReadinessReport(params: {
|
|
66
|
+
totalTrajectories: number;
|
|
67
|
+
eligibleForTraining: number;
|
|
68
|
+
holdoutSize: number;
|
|
69
|
+
leakageChecks: LeakageCheck[];
|
|
70
|
+
}): {
|
|
71
|
+
ready: boolean;
|
|
72
|
+
score: number;
|
|
73
|
+
blockers: string[];
|
|
74
|
+
warnings: string[];
|
|
75
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/evolution - RL Governance (P3-01 + P3-05)
|
|
3
|
+
*
|
|
4
|
+
* The audit: "只有 trajectory 数据经过过滤、去污染、去泄漏、holdout 后再考虑"
|
|
5
|
+
*
|
|
6
|
+
* Formalizes the governance pipeline that ALL training data must pass
|
|
7
|
+
* before entering model fine-tuning. This is the data quality gate.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Filter trajectories by quality before training.
|
|
11
|
+
* Pure - testable.
|
|
12
|
+
*/
|
|
13
|
+
export function filterForTraining(records) {
|
|
14
|
+
const eligible = [];
|
|
15
|
+
const rejected = [];
|
|
16
|
+
for (const r of records) {
|
|
17
|
+
if (r.isHoldout) {
|
|
18
|
+
rejected.push({ record: r, reason: 'holdout record must never enter training' });
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (r.isExternal) {
|
|
22
|
+
rejected.push({ record: r, reason: 'external task must stay in holdout' });
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!r.prompt.trim()) {
|
|
26
|
+
rejected.push({ record: r, reason: 'empty prompt' });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (!r.output.trim()) {
|
|
30
|
+
rejected.push({ record: r, reason: 'empty output' });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (r.outcome === 'error') {
|
|
34
|
+
rejected.push({ record: r, reason: 'error outcome not suitable for training' });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
eligible.push(r);
|
|
38
|
+
}
|
|
39
|
+
return { eligible, rejected };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Detect leakage between training and holdout sets.
|
|
43
|
+
* Pure - testable.
|
|
44
|
+
*/
|
|
45
|
+
export function detectLeakage(trainingRecords, holdoutRecords) {
|
|
46
|
+
const checks = [];
|
|
47
|
+
// 1. holdout-in-train: any holdout record's prompt appears in training
|
|
48
|
+
const holdoutPrompts = new Set(holdoutRecords.map(r => r.prompt.trim().toLowerCase().slice(0, 100)));
|
|
49
|
+
const holdoutInTrain = trainingRecords.filter(r => holdoutPrompts.has(r.prompt.trim().toLowerCase().slice(0, 100)));
|
|
50
|
+
checks.push({
|
|
51
|
+
type: 'holdout-in-train',
|
|
52
|
+
detected: holdoutInTrain.length > 0,
|
|
53
|
+
details: holdoutInTrain.map(r => `training record ${r.id} matches holdout prompt`),
|
|
54
|
+
});
|
|
55
|
+
// 2. self-eval: check if evaluator score correlates suspiciously with self-report
|
|
56
|
+
const selfEval = trainingRecords.filter(r => r.outcome === 'ok' && r.turns <= 1 && r.tokens < 50);
|
|
57
|
+
checks.push({
|
|
58
|
+
type: 'self-eval',
|
|
59
|
+
detected: selfEval.length > 0 && trainingRecords.length > 0 && selfEval.length / trainingRecords.length > 0.8,
|
|
60
|
+
details: selfEval.length > 0 && selfEval.length / trainingRecords.length > 0.8
|
|
61
|
+
? [`${selfEval.length}/${trainingRecords.length} records are trivially self-reported (1 turn, <50 tokens)`]
|
|
62
|
+
: [],
|
|
63
|
+
});
|
|
64
|
+
// 3. prompt-duplication within training set
|
|
65
|
+
const promptCounts = new Map();
|
|
66
|
+
for (const r of trainingRecords) {
|
|
67
|
+
const key = r.prompt.trim().toLowerCase().slice(0, 100);
|
|
68
|
+
promptCounts.set(key, (promptCounts.get(key) ?? 0) + 1);
|
|
69
|
+
}
|
|
70
|
+
const dupes = [...promptCounts.entries()].filter(([, n]) => n > 5);
|
|
71
|
+
checks.push({
|
|
72
|
+
type: 'prompt-duplication',
|
|
73
|
+
detected: dupes.length > 0,
|
|
74
|
+
details: dupes.map(([k, n]) => `prompt "${k.slice(0, 40)}..." appears ${n} times`),
|
|
75
|
+
});
|
|
76
|
+
// 4. external-in-train
|
|
77
|
+
const externalInTrain = trainingRecords.filter(r => r.isExternal);
|
|
78
|
+
checks.push({
|
|
79
|
+
type: 'external-in-train',
|
|
80
|
+
detected: externalInTrain.length > 0,
|
|
81
|
+
details: externalInTrain.map(r => `external record ${r.id} found in training set`),
|
|
82
|
+
});
|
|
83
|
+
return checks;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Full decontamination pipeline: run all checks and decide action.
|
|
87
|
+
* Pure - testable.
|
|
88
|
+
*/
|
|
89
|
+
export function decontaminate(record, holdoutPrompts) {
|
|
90
|
+
const checks = [];
|
|
91
|
+
const reasons = [];
|
|
92
|
+
// holdout check
|
|
93
|
+
const promptKey = record.prompt.trim().toLowerCase().slice(0, 100);
|
|
94
|
+
const inHoldout = holdoutPrompts.some(p => p.trim().toLowerCase().slice(0, 100) === promptKey);
|
|
95
|
+
checks.push({ name: 'holdout-overlap', passed: !inHoldout, detail: inHoldout ? 'prompt matches holdout' : 'clear' });
|
|
96
|
+
if (inHoldout)
|
|
97
|
+
reasons.push('record overlaps with holdout set');
|
|
98
|
+
// quality check
|
|
99
|
+
const qualityOk = record.prompt.trim().length > 0 && record.output.trim().length > 0;
|
|
100
|
+
checks.push({ name: 'quality', passed: qualityOk, detail: qualityOk ? 'non-empty' : 'empty prompt/output' });
|
|
101
|
+
if (!qualityOk)
|
|
102
|
+
reasons.push('empty prompt or output');
|
|
103
|
+
// outcome check
|
|
104
|
+
const outcomeOk = record.outcome !== 'error';
|
|
105
|
+
checks.push({ name: 'outcome', passed: outcomeOk, detail: `outcome=${record.outcome}` });
|
|
106
|
+
if (!outcomeOk)
|
|
107
|
+
reasons.push('error outcome');
|
|
108
|
+
// external check
|
|
109
|
+
const externalOk = !record.isExternal;
|
|
110
|
+
checks.push({ name: 'external', passed: externalOk, detail: record.isExternal ? 'is external' : 'internal' });
|
|
111
|
+
if (!externalOk)
|
|
112
|
+
reasons.push('external record must stay in holdout');
|
|
113
|
+
const action = reasons.length === 0 ? 'pass' : reasons.some(r => r.includes('holdout') || r.includes('external')) ? 'reject' : 'quarantine';
|
|
114
|
+
return { recordId: record.id, action, reasons, checks };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Generate an RL readiness report.
|
|
118
|
+
* Pure - testable.
|
|
119
|
+
*/
|
|
120
|
+
export function rlReadinessReport(params) {
|
|
121
|
+
const blockers = [];
|
|
122
|
+
const warnings = [];
|
|
123
|
+
if (params.eligibleForTraining < 100)
|
|
124
|
+
blockers.push(`only ${params.eligibleForTraining} eligible trajectories (need >=100)`);
|
|
125
|
+
if (params.holdoutSize < 10)
|
|
126
|
+
blockers.push(`holdout too small (${params.holdoutSize}, need >=10)`);
|
|
127
|
+
const criticalLeakage = params.leakageChecks.filter(c => c.detected && (c.type === 'holdout-in-train' || c.type === 'external-in-train'));
|
|
128
|
+
if (criticalLeakage.length > 0)
|
|
129
|
+
blockers.push(`critical leakage detected: ${criticalLeakage.map(c => c.type).join(', ')}`);
|
|
130
|
+
for (const c of params.leakageChecks) {
|
|
131
|
+
if (c.detected && !blockers.some(b => b.includes(c.type)))
|
|
132
|
+
warnings.push(`${c.type}: ${c.details.join('; ')}`);
|
|
133
|
+
}
|
|
134
|
+
const score = Math.max(0, 100 - blockers.length * 30 - warnings.length * 10);
|
|
135
|
+
return { ready: blockers.length === 0, score, blockers, warnings };
|
|
136
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hmharness/evolution",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.23",
|
|
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",
|