@hmharness/evolution 0.14.7 → 0.14.10

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/evolve.d.ts CHANGED
@@ -95,4 +95,18 @@ export declare function runEvolution(opts: {
95
95
  presetProposals?: SkillProposal[];
96
96
  log?: (line: string) => void;
97
97
  }): Promise<EvolveReport>;
98
+ /** Cost-regression veto (pass-by-rambling detector). Multiplicative cap
99
+ * alone is a HAIR TRIGGER on microscopic outputs: a case whose expected
100
+ * answer is ~4 tokens (cjk-ex-9: 等待中……) allows ~1 token of slack at
101
+ * 1.3x, so ANY skill that perturbs output by one character vetoes the
102
+ * candidate — ten consecutive candidates died exactly there. An absolute
103
+ * slack floor (+8 tokens) keeps the intent (real rambling is 2-10x) while
104
+ * letting tiny-output cases tolerate needle-scale perturbation. */
105
+ export declare function isCostRegression(base: number, cand: number, cap?: number): boolean;
106
+ /** Dual-metric veto helper: names of cases where the candidate passed but
107
+ * cost more than its baseline cap (rambling). Pure - unit-tested. */
108
+ export declare function costRegressionNames(train: Array<{
109
+ name: string;
110
+ costCap?: number;
111
+ }>, baseCost: Record<string, number>, candCost: Record<string, number>): string[];
98
112
  export declare function screenForPoison(text: string): string | null;
package/dist/evolve.js CHANGED
@@ -231,13 +231,9 @@ export async function runEvolution(opts) {
231
231
  const regression = baseResults.some((b) => b.pass && !candResults.find((c) => c.name === b.name)?.pass);
232
232
  // dual-metric veto: a passing case that costs > cost-cap x its
233
233
  // baseline counts as a cost regression (the candidate passed by
234
- // rambling) - only enforced where the case declares a cost-cap
235
- const costRegressions = train.filter((c) => {
236
- const cap = c.costCap ?? 1.3; // default 1.3x for all cases
237
- const base = baseCost[c.name] ?? 0;
238
- const cand = candCost[c.name] ?? 0;
239
- return base > 0 && cand > base * cap;
240
- }).map((c) => c.name);
234
+ // rambling); isCostRegression carries the absolute-slack floor that
235
+ // keeps microscopic-output cases from hair-triggering
236
+ const costRegressions = costRegressionNames(train, baseCost, candCost);
241
237
  const summary = (rs) => rs.map((r) => `${r.name}:${r.pass ? 'pass' : 'FAIL'}`).join(' ');
242
238
  if (regression || candRate < baseRate) {
243
239
  await deleteDraft(home, p.name);
@@ -459,6 +455,7 @@ async function proposeSkills(provider, signals, say, ancestor, mergeWith) {
459
455
  'Your job: read session signals and decide whether any repeatable procedure is worth crystallizing into a skill.',
460
456
  'A skill is a markdown how-to document the agent reads on demand. Topics must be limited to: HarmonyOS toolchain usage (hdc/hvigorw/ohpm/DevEco), this framework\'s tools (list_dir/read_file/write_file/run_command/remember/harmony_*), and reusable task workflows observed in the signals.',
461
457
  'Rules: name is kebab-case; description is one line; skill_md is at most 60 lines with concrete steps and example commands; do NOT propose skills about security config, approval policy, or anything outside the topics; if nothing is genuinely reusable, return an empty array.',
458
+ 'COST GATE (learned from repeated rejections): every candidate runs against an output-cost cap - a skill whose text makes the agent MORE verbose on exact-output cases (it restates, adds preambles, or explains around the answer) is auto-rejected even when correctness improves. Write TERSE imperative steps (target under 25 lines): checklists and example commands only; never instruct the agent to add explanations, context, or restatements; the skill itself must teach doing LESS talking, not more.',
462
459
  'Respond with ONLY a JSON array: [{"name":"...","description":"...","skill_md":"..."}] - no prose, no code fences.',
463
460
  ].join('\n');
464
461
  // GEPA ancestor context: vary around a past rejection (its reason is the
@@ -511,6 +508,26 @@ async function distillMemory(provider, recentNotes, say) {
511
508
  return null;
512
509
  }
513
510
  }
511
+ /** Cost-regression veto (pass-by-rambling detector). Multiplicative cap
512
+ * alone is a HAIR TRIGGER on microscopic outputs: a case whose expected
513
+ * answer is ~4 tokens (cjk-ex-9: 等待中……) allows ~1 token of slack at
514
+ * 1.3x, so ANY skill that perturbs output by one character vetoes the
515
+ * candidate — ten consecutive candidates died exactly there. An absolute
516
+ * slack floor (+8 tokens) keeps the intent (real rambling is 2-10x) while
517
+ * letting tiny-output cases tolerate needle-scale perturbation. */
518
+ export function isCostRegression(base, cand, cap = 1.3) {
519
+ if (base <= 0)
520
+ return false;
521
+ return cand > Math.max(base * cap, base + 8);
522
+ }
523
+ /** Dual-metric veto helper: names of cases where the candidate passed but
524
+ * cost more than its baseline cap (rambling). Pure - unit-tested. */
525
+ export function costRegressionNames(train, baseCost, candCost) {
526
+ return train.filter((c) => {
527
+ const cap = c.costCap ?? 1.3;
528
+ return isCostRegression(baseCost[c.name] ?? 0, candCost[c.name] ?? 0, cap);
529
+ }).map((c) => c.name);
530
+ }
514
531
  /** First balanced JSON array in the text; tolerates fences and prose around it. */
515
532
  function parseJsonArray(text) {
516
533
  const cleaned = text.replace(/```(?:json)?/g, '');
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ 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';
8
9
  export * from './insights.ts';
9
10
  export * from './skills.ts';
10
11
  export * from './bench.ts';
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ 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";
8
9
  export * from "./insights.js";
9
10
  export * from "./skills.js";
10
11
  export * from "./bench.js";
package/dist/patches.js CHANGED
@@ -105,9 +105,13 @@ export async function mergeSandbox(repoRoot, branch) {
105
105
  }
106
106
  /** Run the bench gate on the current branch. Returns pass rate (0-1) or -1 on build failure. */
107
107
  export async function sandboxBench(repoRoot, runCase, cases) {
108
- // rebuild all packages (the patch may affect any layer)
108
+ // rebuild all packages (the patch may affect any layer). npm on Windows is
109
+ // a .cmd shim and modern Node refuses to execFile-spawn .cmd without a
110
+ // shell (CVE-2024-27980) - the sandbox build silently "failed" (and every
111
+ // patch reverted) on CI runners while looking fine where npm resolved to
112
+ // an executable. shell:true resolves the shim on win32, no-op elsewhere.
109
113
  try {
110
- await execCb('npm', ['run', 'build'], { cwd: repoRoot, timeout: 120_000, windowsHide: true });
114
+ await execCb('npm', ['run', 'build'], { cwd: repoRoot, timeout: 120_000, windowsHide: true, ...(process.platform === 'win32' ? { shell: true } : {}) });
111
115
  }
112
116
  catch {
113
117
  return -1; // build failed = automatic reject
package/dist/readiness.js CHANGED
@@ -107,8 +107,20 @@ export async function rlReadiness(home) {
107
107
  workflows: false,
108
108
  };
109
109
  try {
110
- const skills = await readdir(join(home, 'skills'));
111
- provenance.skills = skills.some((s) => s.endsWith('.md'));
110
+ // skills layout has BOTH shapes in the wild: legacy flat `skills/<name>.md`
111
+ // and current `skills/active/<name>/` directories (plus `skills/drafts/*.md`).
112
+ // A top-level-only .md check silently fails every modern install.
113
+ const entries = await readdir(join(home, 'skills'), { withFileTypes: true });
114
+ const activeDir = entries.find((e) => e.isDirectory() && e.name === 'active');
115
+ const draftsDir = entries.find((e) => e.isDirectory() && e.name === 'drafts');
116
+ const flatMd = entries.some((e) => e.isFile() && e.name.endsWith('.md'));
117
+ let active = 0;
118
+ if (activeDir)
119
+ active = (await readdir(join(home, 'skills', 'active'))).length;
120
+ let draftMd = 0;
121
+ if (draftsDir)
122
+ draftMd = (await readdir(join(home, 'skills', 'drafts'))).filter((f) => f.endsWith('.md')).length;
123
+ provenance.skills = flatMd || active > 0 || draftMd > 0;
112
124
  }
113
125
  catch { /* none */ }
114
126
  try {
@@ -0,0 +1,62 @@
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
+ }
53
+ /** Build DPO pairs from joined labels + insights. Two classes:
54
+ * (a) IN-BUCKET (gold): same outcome, >=2 star gap - human preference the
55
+ * outcome reward cannot see (4-vs-5 star distinctions);
56
+ * (b) CROSS-BUCKET (classic): ok-vs-degraded completions of the SAME task
57
+ * template (task prefix match) - the textbook chosen/rejected pair.
58
+ * Pure - testable. */
59
+ export declare function exportDpoPairs(labels: HumanLabel[], insights: InsightLike[], taskOf: (session: string) => string, answerOf: (session: string) => string, opts?: {
60
+ minInBucketGap?: number;
61
+ crossBucketPrefix?: number;
62
+ }): DpoPair[];
@@ -0,0 +1,184 @@
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
+ pairs.push({
174
+ prompt: taskOf(hi.session),
175
+ chosen: answerOf(hi.session),
176
+ rejected: answerOf(lo.session),
177
+ chosenSession: hi.session,
178
+ rejectedSession: lo.session,
179
+ gap: Number(gap.toFixed(2)),
180
+ });
181
+ }
182
+ }
183
+ return pairs;
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/evolution",
3
- "version": "0.14.7",
3
+ "version": "0.14.10",
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",