@hmharness/evolution 0.8.3 → 0.8.4
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/candidates.d.ts +108 -0
- package/dist/candidates.js +226 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { BenchCase } from './bench.ts';
|
|
2
|
+
export type CandidateTarget = 'prompt' | 'skill' | 'context' | 'tool_policy' | 'model_router' | 'workflow' | 'harness';
|
|
3
|
+
export declare const CANDIDATE_TARGETS: CandidateTarget[];
|
|
4
|
+
export interface EvolutionCandidate {
|
|
5
|
+
id: string;
|
|
6
|
+
target: CandidateTarget;
|
|
7
|
+
/** the version this candidate is measured against (baseline pointer) */
|
|
8
|
+
baseVersion: string;
|
|
9
|
+
candidateVersion: string;
|
|
10
|
+
hypothesis: string;
|
|
11
|
+
/** metric the experiment reports on, e.g. 'bench.passRate' */
|
|
12
|
+
expectedMetric: string;
|
|
13
|
+
/** target-specific payload: skill markdown, prompt delta, router table… */
|
|
14
|
+
payload?: string;
|
|
15
|
+
/** who proposed it - 'agent' proposals on prompt/harness need human sign-off */
|
|
16
|
+
origin?: string;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function registerCandidate(home: string, cand: Omit<EvolutionCandidate, 'id' | 'createdAt'> & {
|
|
20
|
+
id?: string;
|
|
21
|
+
}): Promise<EvolutionCandidate>;
|
|
22
|
+
export declare function listCandidates(home: string): Promise<EvolutionCandidate[]>;
|
|
23
|
+
export declare function getCandidate(home: string, id: string): Promise<EvolutionCandidate | null>;
|
|
24
|
+
/** Two-proportion two-sided test (treatment vs control pass rates). */
|
|
25
|
+
export declare function twoProportionTest(control: {
|
|
26
|
+
pass: number;
|
|
27
|
+
n: number;
|
|
28
|
+
}, treatment: {
|
|
29
|
+
pass: number;
|
|
30
|
+
n: number;
|
|
31
|
+
}): {
|
|
32
|
+
z: number;
|
|
33
|
+
p: number;
|
|
34
|
+
diff: number;
|
|
35
|
+
};
|
|
36
|
+
export interface ArmResult {
|
|
37
|
+
pass: boolean;
|
|
38
|
+
/** token cost of the arm run (cost-cap honesty, same currency as estTokens) */
|
|
39
|
+
tokens: number;
|
|
40
|
+
}
|
|
41
|
+
/** Injected by the caller: run one bench case under one arm. The runner owns
|
|
42
|
+
* the actual injection point (skills block, memory config, router table…). */
|
|
43
|
+
export type ArmRunner = (c: BenchCase, arm: 'control' | 'treatment') => Promise<ArmResult>;
|
|
44
|
+
export interface ExperimentArm {
|
|
45
|
+
pass: number;
|
|
46
|
+
n: number;
|
|
47
|
+
passRate: number;
|
|
48
|
+
tokens: number;
|
|
49
|
+
}
|
|
50
|
+
export interface ExperimentReport {
|
|
51
|
+
candidateId: string;
|
|
52
|
+
ranAt: string;
|
|
53
|
+
cases: number;
|
|
54
|
+
control: ExperimentArm;
|
|
55
|
+
treatment: ExperimentArm;
|
|
56
|
+
diff: number;
|
|
57
|
+
z: number;
|
|
58
|
+
p: number;
|
|
59
|
+
verdict: 'promote-eligible' | 'reject' | 'needs-data';
|
|
60
|
+
reason: string;
|
|
61
|
+
}
|
|
62
|
+
/** Per-arm minimum before any verdict counts (aligned with impact.ts canary
|
|
63
|
+
* threshold: >=8 sessions, and blueprint "one success is not significance"). */
|
|
64
|
+
export declare const MIN_ARM_N = 8;
|
|
65
|
+
export declare function verdictFor(control: {
|
|
66
|
+
pass: number;
|
|
67
|
+
n: number;
|
|
68
|
+
}, treatment: {
|
|
69
|
+
pass: number;
|
|
70
|
+
n: number;
|
|
71
|
+
}): {
|
|
72
|
+
verdict: ExperimentReport['verdict'];
|
|
73
|
+
reason: string;
|
|
74
|
+
z: number;
|
|
75
|
+
p: number;
|
|
76
|
+
diff: number;
|
|
77
|
+
};
|
|
78
|
+
export declare function runCandidateExperiment(home: string, candidateId: string, opts: {
|
|
79
|
+
runCase: ArmRunner;
|
|
80
|
+
cases: BenchCase[];
|
|
81
|
+
maxCases?: number;
|
|
82
|
+
}): Promise<ExperimentReport>;
|
|
83
|
+
/** Latest experiment report for a candidate (promotion evidence). */
|
|
84
|
+
export declare function latestExperiment(home: string, candidateId: string): Promise<ExperimentReport | null>;
|
|
85
|
+
export interface ActiveVersion {
|
|
86
|
+
target: CandidateTarget;
|
|
87
|
+
version: string;
|
|
88
|
+
candidateId: string;
|
|
89
|
+
since: string;
|
|
90
|
+
reportAt?: string;
|
|
91
|
+
}
|
|
92
|
+
export declare function activeVersions(home: string): Promise<Record<string, ActiveVersion>>;
|
|
93
|
+
export interface PromoteResult {
|
|
94
|
+
ok: boolean;
|
|
95
|
+
error?: string;
|
|
96
|
+
active?: ActiveVersion;
|
|
97
|
+
}
|
|
98
|
+
/** Blueprint gates, enforced: latest report must be promote-eligible, a
|
|
99
|
+
* previous pointer is written BEFORE activation, agent-origin prompt/harness
|
|
100
|
+
* candidates need approvedByHuman. Skill promotions ride the existing
|
|
101
|
+
* promoteSkill canary channel. */
|
|
102
|
+
export declare function promoteCandidate(home: string, candidateId: string, opts?: {
|
|
103
|
+
approvedByHuman?: boolean;
|
|
104
|
+
}): Promise<PromoteResult>;
|
|
105
|
+
export declare function rollbackCandidate(home: string, target: CandidateTarget): Promise<{
|
|
106
|
+
ok: boolean;
|
|
107
|
+
error?: string;
|
|
108
|
+
}>;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/evolution - candidates (V2 M9: Evolution 泛化)
|
|
3
|
+
* Blueprint M9: a typed candidate registry (prompt/skill/context/tool_policy/
|
|
4
|
+
* model_router/workflow/harness), a Control/Treatment experiment runner,
|
|
5
|
+
* statistical comparison, and gated promotion/rollback. See ADR-0003.
|
|
6
|
+
*
|
|
7
|
+
* 红线 (blueprint 14.3, enforced here, not by convention):
|
|
8
|
+
* - 无实验报告(promote-eligible)不晋升 —— no baseline, no improvement claim
|
|
9
|
+
* - 激活前必写 previous 指针 —— no rollback, no promotion
|
|
10
|
+
* - origin=agent 的 prompt/harness 候选需人工标记才可激活 —— agents never
|
|
11
|
+
* self-modify the production prompt
|
|
12
|
+
* - 晋升判据是双比例检验,LLM self-report 无效力 —— M2 证据阶梯双保险
|
|
13
|
+
*/
|
|
14
|
+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { screenForPoison } from "./evolve.js";
|
|
17
|
+
export const CANDIDATE_TARGETS = [
|
|
18
|
+
'prompt', 'skill', 'context', 'tool_policy', 'model_router', 'workflow', 'harness',
|
|
19
|
+
];
|
|
20
|
+
/* ---------------- registry ---------------- */
|
|
21
|
+
function candDir(home) {
|
|
22
|
+
return join(home, 'evolution', 'candidates');
|
|
23
|
+
}
|
|
24
|
+
export async function registerCandidate(home, cand) {
|
|
25
|
+
if (!CANDIDATE_TARGETS.includes(cand.target))
|
|
26
|
+
throw new Error(`unknown candidate target: ${cand.target}`);
|
|
27
|
+
if (!cand.hypothesis.trim() || !cand.candidateVersion.trim())
|
|
28
|
+
throw new Error('candidate needs a hypothesis and a candidateVersion');
|
|
29
|
+
const poison = screenForPoison(cand.hypothesis + '\n' + (cand.payload ?? ''));
|
|
30
|
+
if (poison)
|
|
31
|
+
throw new Error(`candidate rejected by safety screen: ${poison}`);
|
|
32
|
+
const full = {
|
|
33
|
+
...cand,
|
|
34
|
+
id: cand.id ?? `cand_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
35
|
+
createdAt: new Date().toISOString(),
|
|
36
|
+
};
|
|
37
|
+
await mkdir(candDir(home), { recursive: true });
|
|
38
|
+
await writeFile(join(candDir(home), `${full.id}.json`), JSON.stringify(full, null, 2) + '\n', 'utf8');
|
|
39
|
+
return full;
|
|
40
|
+
}
|
|
41
|
+
export async function listCandidates(home) {
|
|
42
|
+
let files = [];
|
|
43
|
+
try {
|
|
44
|
+
files = (await readdir(candDir(home))).filter((f) => f.endsWith('.json'));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const f of files) {
|
|
51
|
+
try {
|
|
52
|
+
out.push(JSON.parse(await readFile(join(candDir(home), f), 'utf8')));
|
|
53
|
+
}
|
|
54
|
+
catch { /* skip torn */ }
|
|
55
|
+
}
|
|
56
|
+
return out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
|
57
|
+
}
|
|
58
|
+
export async function getCandidate(home, id) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(await readFile(join(candDir(home), `${id}.json`), 'utf8'));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/* ---------------- statistics ---------------- */
|
|
67
|
+
/** Standard normal CDF, Zelen & Severo approximation of A&S 26.2.17 (|err|<7.5e-8). */
|
|
68
|
+
function normalCdf(z) {
|
|
69
|
+
const t = 1 / (1 + 0.2316419 * Math.abs(z));
|
|
70
|
+
const d = Math.exp(-z * z / 2) / Math.sqrt(2 * Math.PI);
|
|
71
|
+
const poly = t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
|
|
72
|
+
const upper = d * poly; // P(Z > |z|) tail
|
|
73
|
+
return z >= 0 ? 1 - upper : upper;
|
|
74
|
+
}
|
|
75
|
+
/** Two-proportion two-sided test (treatment vs control pass rates). */
|
|
76
|
+
export function twoProportionTest(control, treatment) {
|
|
77
|
+
if (control.n <= 0 || treatment.n <= 0)
|
|
78
|
+
return { z: 0, p: 1, diff: 0 };
|
|
79
|
+
const pc = control.pass / control.n;
|
|
80
|
+
const pt = treatment.pass / treatment.n;
|
|
81
|
+
const pool = (control.pass + treatment.pass) / (control.n + treatment.n);
|
|
82
|
+
const se = Math.sqrt(pool * (1 - pool) * (1 / control.n + 1 / treatment.n));
|
|
83
|
+
const z = se > 0 ? (pt - pc) / se : 0;
|
|
84
|
+
return { z, p: 2 * (1 - normalCdf(Math.abs(z))), diff: pt - pc };
|
|
85
|
+
}
|
|
86
|
+
/** Per-arm minimum before any verdict counts (aligned with impact.ts canary
|
|
87
|
+
* threshold: >=8 sessions, and blueprint "one success is not significance"). */
|
|
88
|
+
export const MIN_ARM_N = 8;
|
|
89
|
+
export function verdictFor(control, treatment) {
|
|
90
|
+
const { z, p, diff } = twoProportionTest(control, treatment);
|
|
91
|
+
if (control.n < MIN_ARM_N || treatment.n < MIN_ARM_N) {
|
|
92
|
+
return { verdict: 'needs-data', reason: `samples below ${MIN_ARM_N} per arm (control ${control.n}, treatment ${treatment.n})`, z, p, diff };
|
|
93
|
+
}
|
|
94
|
+
if (p < 0.05 && diff >= 0.10)
|
|
95
|
+
return { verdict: 'promote-eligible', reason: `significant lift (p=${p.toFixed(4)}, diff=${(diff * 100).toFixed(1)}%)`, z, p, diff };
|
|
96
|
+
if ((p < 0.05 && diff <= -0.05) || diff <= -0.10) {
|
|
97
|
+
return { verdict: 'reject', reason: `regression (p=${p.toFixed(4)}, diff=${(diff * 100).toFixed(1)}%)`, z, p, diff };
|
|
98
|
+
}
|
|
99
|
+
return { verdict: 'needs-data', reason: `no significant difference (p=${p.toFixed(4)}, diff=${(diff * 100).toFixed(1)}%)`, z, p, diff };
|
|
100
|
+
}
|
|
101
|
+
export async function runCandidateExperiment(home, candidateId, opts) {
|
|
102
|
+
const cand = await getCandidate(home, candidateId);
|
|
103
|
+
if (!cand)
|
|
104
|
+
throw new Error(`no candidate: ${candidateId}`);
|
|
105
|
+
// holdout cases are excluded from promotion gates (bench.ts anti-memorization)
|
|
106
|
+
const gate = opts.cases.filter((c) => !c.holdout).slice(0, Math.max(1, opts.maxCases ?? 12));
|
|
107
|
+
let cPass = 0, cTok = 0, tPass = 0, tTok = 0;
|
|
108
|
+
for (const c of gate) {
|
|
109
|
+
const ctl = await opts.runCase(c, 'control');
|
|
110
|
+
if (ctl.pass)
|
|
111
|
+
cPass++;
|
|
112
|
+
cTok += ctl.tokens;
|
|
113
|
+
const trt = await opts.runCase(c, 'treatment');
|
|
114
|
+
if (trt.pass)
|
|
115
|
+
tPass++;
|
|
116
|
+
tTok += trt.tokens;
|
|
117
|
+
}
|
|
118
|
+
const v = verdictFor({ pass: cPass, n: gate.length }, { pass: tPass, n: gate.length });
|
|
119
|
+
const report = {
|
|
120
|
+
candidateId,
|
|
121
|
+
ranAt: new Date().toISOString(),
|
|
122
|
+
cases: gate.length,
|
|
123
|
+
control: { pass: cPass, n: gate.length, passRate: gate.length ? cPass / gate.length : 0, tokens: cTok },
|
|
124
|
+
treatment: { pass: tPass, n: gate.length, passRate: gate.length ? tPass / gate.length : 0, tokens: tTok },
|
|
125
|
+
...v,
|
|
126
|
+
};
|
|
127
|
+
const dir = join(home, 'evolution', 'experiments', candidateId);
|
|
128
|
+
await mkdir(dir, { recursive: true });
|
|
129
|
+
await writeFile(join(dir, `${report.ranAt.replace(/[:.]/g, '-')}.json`), JSON.stringify(report, null, 2) + '\n', 'utf8');
|
|
130
|
+
return report;
|
|
131
|
+
}
|
|
132
|
+
/** Latest experiment report for a candidate (promotion evidence). */
|
|
133
|
+
export async function latestExperiment(home, candidateId) {
|
|
134
|
+
const dir = join(home, 'evolution', 'experiments', candidateId);
|
|
135
|
+
let files = [];
|
|
136
|
+
try {
|
|
137
|
+
files = (await readdir(dir)).filter((f) => f.endsWith('.json')).sort();
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
if (files.length === 0)
|
|
143
|
+
return null;
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(await readFile(join(dir, files[files.length - 1]), 'utf8'));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const activeDir = (home) => join(home, 'evolution', 'active');
|
|
152
|
+
export async function activeVersions(home) {
|
|
153
|
+
const out = {};
|
|
154
|
+
let files = [];
|
|
155
|
+
try {
|
|
156
|
+
files = await readdir(activeDir(home));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
for (const f of files.filter((f) => f.endsWith('.json') && !f.endsWith('.previous.json'))) {
|
|
162
|
+
try {
|
|
163
|
+
const v = JSON.parse(await readFile(join(activeDir(home), f), 'utf8'));
|
|
164
|
+
out[v.target] = v;
|
|
165
|
+
}
|
|
166
|
+
catch { /* skip torn */ }
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
/** Blueprint gates, enforced: latest report must be promote-eligible, a
|
|
171
|
+
* previous pointer is written BEFORE activation, agent-origin prompt/harness
|
|
172
|
+
* candidates need approvedByHuman. Skill promotions ride the existing
|
|
173
|
+
* promoteSkill canary channel. */
|
|
174
|
+
export async function promoteCandidate(home, candidateId, opts = {}) {
|
|
175
|
+
const cand = await getCandidate(home, candidateId);
|
|
176
|
+
if (!cand)
|
|
177
|
+
return { ok: false, error: `no candidate: ${candidateId}` };
|
|
178
|
+
const report = await latestExperiment(home, candidateId);
|
|
179
|
+
if (!report)
|
|
180
|
+
return { ok: false, error: 'no experiment report - run the experiment first (no baseline, no promotion)' };
|
|
181
|
+
if (report.verdict !== 'promote-eligible') {
|
|
182
|
+
return { ok: false, error: `latest report verdict is ${report.verdict} (${report.reason}) - not promotable` };
|
|
183
|
+
}
|
|
184
|
+
if ((cand.target === 'prompt' || cand.target === 'harness') && cand.origin === 'agent' && !opts.approvedByHuman) {
|
|
185
|
+
return { ok: false, error: 'agent-origin prompt/harness candidate requires approvedByHuman (production prompt is not agent-writable)' };
|
|
186
|
+
}
|
|
187
|
+
await mkdir(activeDir(home), { recursive: true });
|
|
188
|
+
const cur = await activeVersions(home);
|
|
189
|
+
const previous = cur[cand.target];
|
|
190
|
+
if (previous) {
|
|
191
|
+
await writeFile(join(activeDir(home), `${cand.target}.previous.json`), JSON.stringify(previous, null, 2) + '\n', 'utf8');
|
|
192
|
+
}
|
|
193
|
+
if (cand.target === 'skill') {
|
|
194
|
+
// payload = skill name; the audited canary channel stays the only skill path
|
|
195
|
+
const { promoteSkill } = await import("./skills.js");
|
|
196
|
+
if (!cand.payload)
|
|
197
|
+
return { ok: false, error: 'skill candidate has no payload (skill name)' };
|
|
198
|
+
await promoteSkill(home, cand.payload, { canary: true });
|
|
199
|
+
}
|
|
200
|
+
const active = {
|
|
201
|
+
target: cand.target,
|
|
202
|
+
version: cand.candidateVersion,
|
|
203
|
+
candidateId: cand.id,
|
|
204
|
+
since: new Date().toISOString(),
|
|
205
|
+
reportAt: report.ranAt,
|
|
206
|
+
};
|
|
207
|
+
await writeFile(join(activeDir(home), `${cand.target}.json`), JSON.stringify(active, null, 2) + '\n', 'utf8');
|
|
208
|
+
return { ok: true, active };
|
|
209
|
+
}
|
|
210
|
+
export async function rollbackCandidate(home, target) {
|
|
211
|
+
const dir = activeDir(home);
|
|
212
|
+
const prevPath = join(dir, `${target}.previous.json`);
|
|
213
|
+
let previous = null;
|
|
214
|
+
try {
|
|
215
|
+
previous = JSON.parse(await readFile(prevPath, 'utf8'));
|
|
216
|
+
}
|
|
217
|
+
catch { /* no previous */ }
|
|
218
|
+
if (!previous)
|
|
219
|
+
return { ok: false, error: `no previous pointer for ${target} - nothing to roll back to` };
|
|
220
|
+
if (target === 'skill') {
|
|
221
|
+
const { rollbackSkill } = await import("./skills.js");
|
|
222
|
+
await rollbackSkill(home, previous.version);
|
|
223
|
+
}
|
|
224
|
+
await writeFile(join(dir, `${target}.json`), JSON.stringify(previous, null, 2) + '\n', 'utf8');
|
|
225
|
+
return { ok: true };
|
|
226
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hmharness/evolution",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
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",
|