@hmharness/evolution 0.8.4 → 0.9.0

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.
@@ -0,0 +1,64 @@
1
+ export interface DatasetSample {
2
+ runId: string;
3
+ task: string;
4
+ outcome: string;
5
+ turns: number;
6
+ toolUses: number;
7
+ toolFailRate: number;
8
+ toolsUsed: string[];
9
+ model: string;
10
+ /** evidence rank from the M2 ladder when the run was evaluated (0 = none) */
11
+ evidenceRank: number;
12
+ /** interpretable reward in [0,1]; llm-judged runs never reach 1.0 (M2 cap) */
13
+ reward: number;
14
+ /** evaluation notes / failure snippets attach here (bounded) */
15
+ evidence?: string;
16
+ split: 'train' | 'eval';
17
+ }
18
+ export interface DatasetManifest {
19
+ version: string;
20
+ createdAt: string;
21
+ /** inclusive run-id scan window (first..last seen, lexicographic) */
22
+ runWindow: {
23
+ first: string | null;
24
+ last: string | null;
25
+ };
26
+ filterFingerprint: string;
27
+ splitSeed: number;
28
+ splitRatio: number;
29
+ counts: {
30
+ scanned: number;
31
+ kept: number;
32
+ dropped: number;
33
+ duplicates: number;
34
+ train: number;
35
+ eval: number;
36
+ };
37
+ rewardHistogram: Record<string, number>;
38
+ }
39
+ export interface DatasetBuildResult {
40
+ manifest: DatasetManifest;
41
+ dir: string;
42
+ }
43
+ /** Interpretable reward mapping (ADR-0004): ok runs start at 1.0 and lose
44
+ * 0.1 per 20% tool-failure rate; non-ok runs cap at 0.3. Evidence rank
45
+ * from the M2 ladder nudges confidence but never creates a free perfect. */
46
+ export declare function rewardFor(outcome: string, toolFailRate: number, evidenceRank?: number): number;
47
+ /** Stable fingerprint of the filter config - manifest must be reproducible. */
48
+ export declare function filterFingerprint(opts: {
49
+ keepOutcome?: string[];
50
+ minTurns?: number;
51
+ }): string;
52
+ /** Deterministic hash-based split (same run always lands in the same half). */
53
+ export declare function splitFor(runId: string, seed: number, evalRatio?: number): 'train' | 'eval';
54
+ export interface BuildDatasetOptions {
55
+ version?: string;
56
+ /** outcomes to keep; default keeps everything labeled */
57
+ keepOutcome?: string[];
58
+ minTurns?: number;
59
+ evalRatio?: number;
60
+ splitSeed?: number;
61
+ }
62
+ export declare function buildDataset(home: string, opts?: BuildDatasetOptions): Promise<DatasetBuildResult>;
63
+ export declare function listDatasets(home: string): Promise<DatasetManifest[]>;
64
+ export declare function loadDataset(home: string, version: string, split?: 'train' | 'eval'): Promise<DatasetSample[]>;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * @hmharness/evolution - dataset (V2 M10: Trajectory → Dataset Version)
3
+ * Pure data engineering, zero model calls. Pipeline per blueprint:
4
+ * Trajectory → Filter → Deduplicate → Label → Evidence Attach → Reward
5
+ * → Dataset Version → Train/Eval Split
6
+ * Source of truth: HMH_HOME/runs/<run-id>/ (M1 trajectory recorder's
7
+ * summary.json + trajectory.jsonl). Output: versioned, redacted, split
8
+ * under evolution/datasets/<version>/. See ADR-0004.
9
+ */
10
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
11
+ import { createHash } from 'node:crypto';
12
+ import { join } from 'node:path';
13
+ import { redactSecrets } from "./insights.js";
14
+ const RUNS_DIR = 'runs';
15
+ /** Interpretable reward mapping (ADR-0004): ok runs start at 1.0 and lose
16
+ * 0.1 per 20% tool-failure rate; non-ok runs cap at 0.3. Evidence rank
17
+ * from the M2 ladder nudges confidence but never creates a free perfect. */
18
+ export function rewardFor(outcome, toolFailRate, evidenceRank = 0) {
19
+ let r;
20
+ if (outcome === 'ok') {
21
+ r = 1.0 - Math.min(0.6, Math.round(toolFailRate * 10) / 10);
22
+ }
23
+ else {
24
+ r = Math.max(0, 0.3 - Math.round(toolFailRate * 10) / 10);
25
+ }
26
+ // llm-judge ladder ranks (5+) can never mint a perfect sample
27
+ if (evidenceRank >= 5)
28
+ r = Math.min(r, 0.7);
29
+ return Math.max(0, Math.min(1, r));
30
+ }
31
+ /** Stable fingerprint of the filter config - manifest must be reproducible. */
32
+ export function filterFingerprint(opts) {
33
+ return createHash('sha256').update(JSON.stringify({
34
+ keepOutcome: opts.keepOutcome ?? ['ok', 'turn-budget', 'error'],
35
+ minTurns: opts.minTurns ?? 1,
36
+ redact: true,
37
+ })).digest('hex').slice(0, 12);
38
+ }
39
+ /** Normalize both the recorder schema (outcome object + metrics block) and
40
+ * the flat test/hand-written shape into one view. */
41
+ function normalizeSummary(s) {
42
+ const outcome = typeof s.outcome === 'string'
43
+ ? s.outcome
44
+ : (s.outcome?.success ? 'ok' : String(s.outcome?.reason ?? ''));
45
+ return {
46
+ task: String(s.task ?? ''),
47
+ outcome,
48
+ turns: Number(s.turns ?? s.metrics?.turns ?? 0),
49
+ toolUses: Number(s.toolUses ?? s.metrics?.toolUses ?? 0),
50
+ toolFailures: Number(s.toolFailures ?? s.metrics?.toolFailures ?? 0),
51
+ toolsUsed: s.toolsUsed ?? [],
52
+ model: String(s.model ?? ''),
53
+ rank: Number(s.evidence?.rank ?? 0),
54
+ detail: String(s.evidence?.detail ?? ''),
55
+ };
56
+ }
57
+ async function readRunSummaries(home) {
58
+ const root = join(home, RUNS_DIR);
59
+ let ids = [];
60
+ try {
61
+ ids = (await readdir(root)).filter((d) => !d.startsWith('.'));
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ ids.sort();
67
+ const out = [];
68
+ for (const id of ids) {
69
+ try {
70
+ const s = JSON.parse(await readFile(join(root, id, 'summary.json'), 'utf8'));
71
+ out.push({ runId: id, summary: s });
72
+ }
73
+ catch { /* torn or absent summary - not a dataset candidate */ }
74
+ }
75
+ return out;
76
+ }
77
+ /** Deterministic hash-based split (same run always lands in the same half). */
78
+ export function splitFor(runId, seed, evalRatio = 0.2) {
79
+ const h = createHash('sha256').update(`${seed}:${runId}`).digest();
80
+ const v = h.readUInt32BE(0) / 0xffffffff;
81
+ return v < evalRatio ? 'eval' : 'train';
82
+ }
83
+ export async function buildDataset(home, opts = {}) {
84
+ const keep = opts.keepOutcome ?? ['ok', 'turn-budget', 'error'];
85
+ const minTurns = opts.minTurns ?? 1;
86
+ const evalRatio = opts.evalRatio ?? 0.2;
87
+ const seed = opts.splitSeed ?? 20260913;
88
+ const runs = await readRunSummaries(home);
89
+ const fp = filterFingerprint({ keepOutcome: keep, minTurns });
90
+ const byFingerprint = new Map();
91
+ let dropped = 0;
92
+ let duplicates = 0;
93
+ for (const { runId, summary } of runs) {
94
+ const n = normalizeSummary(summary);
95
+ if (!n.outcome || !keep.includes(n.outcome) || n.turns < minTurns) {
96
+ dropped++;
97
+ continue;
98
+ }
99
+ const fpRun = createHash('sha256').update(`${n.task}|${n.outcome}|${n.turns}|${n.toolsUsed.join(',')}`, 'utf8').digest('hex').slice(0, 16);
100
+ if (byFingerprint.has(fpRun)) {
101
+ duplicates++;
102
+ continue;
103
+ } // keep first (oldest) - newest is a rerun of the same shape
104
+ byFingerprint.set(fpRun, { runId, summary });
105
+ }
106
+ const samples = [];
107
+ const histogram = {};
108
+ for (const { runId, summary } of byFingerprint.values()) {
109
+ const n = normalizeSummary(summary);
110
+ const failRate = n.toolUses > 0 ? n.toolFailures / n.toolUses : 0;
111
+ const reward = rewardFor(n.outcome, failRate, n.rank);
112
+ const bucket = reward.toFixed(1);
113
+ histogram[bucket] = (histogram[bucket] ?? 0) + 1;
114
+ samples.push({
115
+ runId,
116
+ task: redactSecrets(n.task),
117
+ outcome: n.outcome,
118
+ turns: n.turns,
119
+ toolUses: n.toolUses,
120
+ toolFailRate: Math.round(failRate * 100) / 100,
121
+ toolsUsed: n.toolsUsed,
122
+ model: n.model,
123
+ evidenceRank: n.rank,
124
+ reward,
125
+ ...(n.detail ? { evidence: redactSecrets(n.detail).slice(0, 400) } : {}),
126
+ split: splitFor(runId, seed, evalRatio),
127
+ });
128
+ }
129
+ samples.sort((a, b) => (a.runId < b.runId ? -1 : 1));
130
+ const version = opts.version ?? `v-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`;
131
+ const dir = join(home, 'evolution', 'datasets', version);
132
+ await mkdir(dir, { recursive: true });
133
+ await writeFile(join(dir, 'samples.jsonl'), samples.map((s) => JSON.stringify(s)).join('\n') + (samples.length ? '\n' : ''), 'utf8');
134
+ const manifest = {
135
+ version,
136
+ createdAt: new Date().toISOString(),
137
+ runWindow: { first: runs[0]?.runId ?? null, last: runs[runs.length - 1]?.runId ?? null },
138
+ filterFingerprint: fp,
139
+ splitSeed: seed,
140
+ splitRatio: evalRatio,
141
+ counts: {
142
+ scanned: runs.length,
143
+ kept: samples.length,
144
+ dropped,
145
+ duplicates,
146
+ train: samples.filter((s) => s.split === 'train').length,
147
+ eval: samples.filter((s) => s.split === 'eval').length,
148
+ },
149
+ rewardHistogram: histogram,
150
+ };
151
+ await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8');
152
+ return { manifest, dir };
153
+ }
154
+ export async function listDatasets(home) {
155
+ const root = join(home, 'evolution', 'datasets');
156
+ let vers = [];
157
+ try {
158
+ vers = (await readdir(root)).filter((d) => d.startsWith('v-'));
159
+ }
160
+ catch {
161
+ return [];
162
+ }
163
+ vers.sort();
164
+ const out = [];
165
+ for (const v of vers) {
166
+ try {
167
+ out.push(JSON.parse(await readFile(join(root, v, 'manifest.json'), 'utf8')));
168
+ }
169
+ catch { /* skip torn */ }
170
+ }
171
+ return out;
172
+ }
173
+ export async function loadDataset(home, version, split) {
174
+ const file = join(home, 'evolution', 'datasets', version, 'samples.jsonl');
175
+ let text;
176
+ try {
177
+ text = await readFile(file, 'utf8');
178
+ }
179
+ catch {
180
+ return [];
181
+ }
182
+ return text.split('\n').filter(Boolean).map((l) => JSON.parse(l))
183
+ .filter((s) => !split || s.split === split);
184
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from './memory.ts';
2
2
  export * from './ranker.ts';
3
3
  export * from './candidates.ts';
4
+ export * from './dataset.ts';
5
+ export * from './readiness.ts';
4
6
  export * from './insights.ts';
5
7
  export * from './skills.ts';
6
8
  export * from './bench.ts';
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from "./memory.js";
2
2
  export * from "./ranker.js";
3
3
  export * from "./candidates.js";
4
+ export * from "./dataset.js";
5
+ export * from "./readiness.js";
4
6
  export * from "./insights.js";
5
7
  export * from "./skills.js";
6
8
  export * from "./bench.js";
@@ -10,6 +10,7 @@ export interface Insight {
10
10
  * session's system prompt - the join key for canary A/B comparison. */
11
11
  skillsInjected?: string[];
12
12
  }
13
+ export declare function redactSecrets(text: string): string;
13
14
  export declare function recordInsight(home: string, insight: Insight): Promise<void>;
14
15
  /** Read recent insights as structured records (the evolve loop's raw feed). */
15
16
  export declare function readInsights(home: string, limit?: number): Promise<Insight[]>;
package/dist/insights.js CHANGED
@@ -7,10 +7,31 @@
7
7
  */
8
8
  import { appendFile, mkdir, readFile } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
+ /**
11
+ * Secret redaction for everything that leaves the machine (insights feed the
12
+ * PUBLIC evidence page). Users paste API keys into task text ("新增 provider,
13
+ * sk-… 给 hmharness") and the audit trail must never publish them. Cover the
14
+ * shapes seen in the wild: OpenAI-style sk-, VolcEngine ark-<uuid>-<hex>,
15
+ * GitHub ghp_/npm_ tokens. GitHub Push Protection caught this class once
16
+ * (GH013, VolcEngine Ark) - it must be caught here first.
17
+ */
18
+ const SECRET_PATTERNS = [
19
+ { re: /sk-[A-Za-z0-9]{20,}/g, label: 'sk-[REDACTED]' },
20
+ { re: /ark-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-[0-9a-fA-F]{4,}/g, label: 'ark-[REDACTED]' },
21
+ { re: /gh[pousr]_[A-Za-z0-9]{20,}/g, label: 'ghp_[REDACTED]' },
22
+ { re: /npm_[A-Za-z0-9]{20,}/g, label: 'npm_[REDACTED]' },
23
+ ];
24
+ export function redactSecrets(text) {
25
+ let out = text;
26
+ for (const p of SECRET_PATTERNS)
27
+ out = out.replace(p.re, p.label);
28
+ return out;
29
+ }
10
30
  export async function recordInsight(home, insight) {
11
31
  const dir = join(home, 'insights');
12
32
  await mkdir(dir, { recursive: true });
13
- await appendFile(join(dir, 'insights.jsonl'), JSON.stringify(insight) + '\n', 'utf8');
33
+ const clean = { ...insight, task: redactSecrets(insight.task) };
34
+ await appendFile(join(dir, 'insights.jsonl'), JSON.stringify(clean) + '\n', 'utf8');
14
35
  }
15
36
  /** Read recent insights as structured records (the evolve loop's raw feed). */
16
37
  export async function readInsights(home, limit = 40) {
@@ -0,0 +1,15 @@
1
+ export interface ReadinessCondition {
2
+ id: string;
3
+ met: boolean;
4
+ current: number | string | boolean;
5
+ threshold: number | string;
6
+ evidence: string;
7
+ }
8
+ export interface ReadinessReport {
9
+ verdict: 'rl-eligible' | 'optimize-first';
10
+ conditions: ReadinessCondition[];
11
+ /** when optimize-first: which lever to pull first (weakest condition) */
12
+ recommendedLever?: 'data-collection' | 'bench-expansion' | 'reward-calibration' | 'eval-suite-stabilization' | 'version-provenance';
13
+ at: string;
14
+ }
15
+ export declare function rlReadiness(home: string): Promise<ReadinessReport>;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * @hmharness/evolution - readiness (V2 M11: RL 前置条件检查器)
3
+ * The blueprint's gate: RL is allowed ONLY when all six conditions hold.
4
+ * Every condition is measured from real on-disk evidence - nothing is
5
+ * estimated, nothing can be waved through. When the gate is closed the
6
+ * report says exactly which optimization to do instead. See ADR-0005.
7
+ */
8
+ import { readdir, readFile, stat } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ import { listCases } from "./bench.js";
11
+ const QUALITY_MIN_REWARD = 0.5;
12
+ async function countRunTrajectories(home) {
13
+ const root = join(home, 'runs');
14
+ let ids = [];
15
+ try {
16
+ ids = (await readdir(root)).filter((d) => !d.startsWith('.'));
17
+ }
18
+ catch {
19
+ return { total: 0, highQuality: 0 };
20
+ }
21
+ let high = 0;
22
+ for (const id of ids) {
23
+ try {
24
+ const s = JSON.parse(await readFile(join(root, id, 'summary.json'), 'utf8'));
25
+ const outcome = typeof s.outcome === 'string' ? s.outcome : (s.outcome?.success ? 'ok' : String(s.outcome?.reason ?? ''));
26
+ const uses = Number(s.toolUses ?? s.metrics?.toolUses ?? 0);
27
+ const fails = Number(s.toolFailures ?? s.metrics?.toolFailures ?? 0);
28
+ const failRate = uses > 0 ? fails / uses : 0;
29
+ const reward = outcome === 'ok' ? 1 - Math.min(0.6, Math.round(failRate * 10) / 10) : 0.3;
30
+ if (outcome && (s.turns ?? s.metrics?.turns ?? 0) >= 1 && reward >= QUALITY_MIN_REWARD)
31
+ high++;
32
+ }
33
+ catch { /* torn */ }
34
+ }
35
+ return { total: ids.length, highQuality: high };
36
+ }
37
+ export async function rlReadiness(home) {
38
+ const conditions = [];
39
+ // 1. >= 1000 high-quality trajectories
40
+ const runs = await countRunTrajectories(home);
41
+ conditions.push({
42
+ id: 'high-quality-trajectories',
43
+ met: runs.highQuality >= 1000,
44
+ current: runs.highQuality,
45
+ threshold: 1000,
46
+ evidence: `runs/ scan: ${runs.total} trajectories, ${runs.highQuality} with outcome + turns>=1 + reward>=${QUALITY_MIN_REWARD}`,
47
+ });
48
+ // 2. >= 100 stable benchmark tasks
49
+ let cases = 0;
50
+ try {
51
+ cases = (await listCases(home)).length;
52
+ }
53
+ catch { /* none */ }
54
+ conditions.push({
55
+ id: 'stable-benchmark-tasks',
56
+ met: cases >= 100,
57
+ current: cases,
58
+ threshold: 100,
59
+ evidence: `bench/cases/ scan: ${cases} cases (holdout included; stability tracked by bench history)`,
60
+ });
61
+ // 3. reward vs human judgment correlation VERIFIED - no human-labeling
62
+ // channel exists yet, so this is honestly not met (never a guess)
63
+ let humanLabels = 0;
64
+ try {
65
+ const f = await stat(join(home, 'evolution', 'reward-human-labels.jsonl'));
66
+ if (f.isFile())
67
+ humanLabels = (await readFile(join(home, 'evolution', 'reward-human-labels.jsonl'), 'utf8')).split('\n').filter(Boolean).length;
68
+ }
69
+ catch { /* absent */ }
70
+ conditions.push({
71
+ id: 'reward-human-correlation',
72
+ met: humanLabels >= 100,
73
+ current: humanLabels,
74
+ threshold: 100,
75
+ evidence: 'evolution/reward-human-labels.jsonl (human-scored samples; dataset label field reserves the slot)',
76
+ });
77
+ // 4. evaluation regression suite STABLE - last two bench records, no regression
78
+ let suiteStable = false;
79
+ let suiteEvidence = 'no bench history records (evolution/benches/ empty)';
80
+ try {
81
+ const dir = join(home, 'evolution', 'benches');
82
+ const files = (await readdir(dir)).filter((f) => f.endsWith('.json')).sort();
83
+ if (files.length >= 2) {
84
+ const last = JSON.parse(await readFile(join(dir, files[files.length - 1]), 'utf8'));
85
+ const prev = JSON.parse(await readFile(join(dir, files[files.length - 2]), 'utf8'));
86
+ if (typeof last.passRate === 'number' && typeof prev.passRate === 'number') {
87
+ suiteStable = last.passRate >= prev.passRate - 0.05;
88
+ suiteEvidence = `last two bench passRates: ${(prev.passRate * 100).toFixed(0)}% -> ${(last.passRate * 100).toFixed(0)}% (tolerance -5%)`;
89
+ }
90
+ }
91
+ else if (files.length === 1) {
92
+ suiteEvidence = 'only one bench record - stability needs at least two';
93
+ }
94
+ }
95
+ catch { /* absent */ }
96
+ conditions.push({
97
+ id: 'eval-regression-stable',
98
+ met: suiteStable,
99
+ current: suiteStable,
100
+ threshold: 'no >5% passRate regression across last two bench runs',
101
+ evidence: suiteEvidence,
102
+ });
103
+ // 5. model/skill/workflow version provenance
104
+ const provenance = {
105
+ model: true, // every run's summary carries model; rollout session_meta pins it
106
+ skills: false,
107
+ workflows: false,
108
+ };
109
+ try {
110
+ const skills = await readdir(join(home, 'skills'));
111
+ provenance.skills = skills.some((s) => s.endsWith('.md'));
112
+ }
113
+ catch { /* none */ }
114
+ try {
115
+ const wf = await readdir(join(home, 'evolution', 'workflows'));
116
+ provenance.workflows = wf.length > 0;
117
+ }
118
+ catch { /* none */ }
119
+ conditions.push({
120
+ id: 'version-provenance',
121
+ met: provenance.skills && provenance.workflows,
122
+ current: provenance.skills && provenance.workflows,
123
+ threshold: 'skills + workflows under version control (model provenance ships with every run)',
124
+ evidence: `skills dir: ${provenance.skills ? 'versioned .md files' : 'empty'}; workflows dir: ${provenance.workflows ? 'present' : 'empty'}`,
125
+ });
126
+ // 6. offline evaluation + holdout set exist
127
+ let holdout = 0;
128
+ try {
129
+ holdout = (await listCases(home)).filter((c) => c.holdout).length;
130
+ }
131
+ catch { /* none */ }
132
+ conditions.push({
133
+ id: 'offline-eval-holdout',
134
+ met: holdout > 0 && cases > 0,
135
+ current: holdout,
136
+ threshold: '>0 holdout cases in a non-empty bench suite',
137
+ evidence: `bench/cases/: ${cases} total, ${holdout} holdout`,
138
+ });
139
+ const allMet = conditions.every((c) => c.met);
140
+ const weakest = conditions.find((c) => !c.met);
141
+ const leverMap = {
142
+ 'high-quality-trajectories': 'data-collection',
143
+ 'stable-benchmark-tasks': 'bench-expansion',
144
+ 'reward-human-correlation': 'reward-calibration',
145
+ 'eval-regression-stable': 'eval-suite-stabilization',
146
+ 'version-provenance': 'version-provenance',
147
+ 'offline-eval-holdout': 'bench-expansion',
148
+ };
149
+ return {
150
+ verdict: allMet ? 'rl-eligible' : 'optimize-first',
151
+ conditions,
152
+ ...(allMet ? {} : { recommendedLever: weakest ? leverMap[weakest.id] : undefined }),
153
+ at: new Date().toISOString(),
154
+ };
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/evolution",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
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.8.2"
18
+ "@hmharness/kernel": "0.9.0"
19
19
  },
20
20
  "files": [
21
21
  "dist"