@hmharness/evolution 0.14.5 → 0.14.9

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.
Files changed (40) hide show
  1. package/dist/bench.d.ts.v2bak.v2bak +44 -0
  2. package/dist/bench.js.v2bak.v2bak +156 -0
  3. package/dist/candidates.d.ts.v2bak.v2bak +114 -0
  4. package/dist/candidates.js.v2bak.v2bak +232 -0
  5. package/dist/dataset.d.ts.v2bak.v2bak +64 -0
  6. package/dist/dataset.js.v2bak.v2bak +184 -0
  7. package/dist/evolve.d.ts +14 -0
  8. package/dist/evolve.d.ts.v2bak.v2bak +98 -0
  9. package/dist/evolve.js +36 -15
  10. package/dist/evolve.js.v2bak.v2bak +573 -0
  11. package/dist/impact.d.ts.v2bak.v2bak +77 -0
  12. package/dist/impact.js.v2bak.v2bak +214 -0
  13. package/dist/index.d.ts.v2bak.v2bak +16 -0
  14. package/dist/index.js.v2bak.v2bak +16 -0
  15. package/dist/insights.d.ts.v2bak.v2bak +18 -0
  16. package/dist/insights.js.v2bak.v2bak +80 -0
  17. package/dist/knowledge.d.ts.v2bak.v2bak +15 -0
  18. package/dist/knowledge.js.v2bak.v2bak +145 -0
  19. package/dist/labels.d.ts.v2bak.v2bak +20 -0
  20. package/dist/labels.js.v2bak.v2bak +57 -0
  21. package/dist/memory.d.ts.v2bak.v2bak +32 -0
  22. package/dist/memory.js.v2bak.v2bak +233 -0
  23. package/dist/patches.d.ts.v2bak.v2bak +83 -0
  24. package/dist/patches.js +6 -2
  25. package/dist/patches.js.v2bak.v2bak +253 -0
  26. package/dist/radar.d.ts.v2bak.v2bak +3 -0
  27. package/dist/radar.js.v2bak.v2bak +40 -0
  28. package/dist/ranker.d.ts.v2bak.v2bak +44 -0
  29. package/dist/ranker.js.v2bak.v2bak +55 -0
  30. package/dist/readiness.d.ts.v2bak.v2bak +15 -0
  31. package/dist/readiness.js +14 -2
  32. package/dist/readiness.js.v2bak.v2bak +167 -0
  33. package/dist/skillpayload.d.ts.v2bak.v2bak +1 -0
  34. package/dist/skillpayload.js.v2bak +28 -0
  35. package/dist/skillpayload.js.v2bak.v2bak.v2bak +28 -0
  36. package/dist/skills.d.ts.v2bak.v2bak +54 -0
  37. package/dist/skills.js.v2bak.v2bak +321 -0
  38. package/dist/workflows.d.ts.v2bak.v2bak +28 -0
  39. package/dist/workflows.js.v2bak.v2bak +84 -0
  40. package/package.json +1 -1
@@ -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/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;
@@ -0,0 +1,98 @@
1
+ import { type ProviderConfig } from '@hmharness/kernel';
2
+ import { type BenchCase } from './bench.ts';
3
+ export interface SkillProposal {
4
+ name: string;
5
+ description: string;
6
+ skill_md: string;
7
+ }
8
+ export interface ProposalOutcome {
9
+ name: string;
10
+ action: 'promoted' | 'rejected' | 'error';
11
+ reason: string;
12
+ baseline?: {
13
+ passRate: number;
14
+ cases: string;
15
+ };
16
+ candidate?: {
17
+ passRate: number;
18
+ cases: string;
19
+ };
20
+ holdout?: {
21
+ baselineRate: number;
22
+ candidateRate: number;
23
+ };
24
+ /** P0 lineage ledger: what fed this decision (provenance for impact
25
+ * attribution - which insights produced which skill, with which scores).
26
+ * Evolution-artifact genealogy is the GEPA/DGM archive lesson: decisions
27
+ * without ancestry cannot be audited for objective hacking. */
28
+ lineage?: {
29
+ parentInsights: string[];
30
+ scores: {
31
+ train: number;
32
+ holdout?: number;
33
+ };
34
+ metaModel: string;
35
+ decidedAt: string;
36
+ };
37
+ }
38
+ export interface EvolveReport {
39
+ time: string;
40
+ model: string;
41
+ seededCases: string[];
42
+ insightCount: number;
43
+ noteCount: number;
44
+ proposals: SkillProposal[];
45
+ outcomes: ProposalOutcome[];
46
+ memoryDistilled: string | null;
47
+ /** code-level evolution: proposed patches (DGM bridge) */
48
+ codePatches?: Array<{
49
+ name: string;
50
+ file: string;
51
+ reason: string;
52
+ }>;
53
+ /** code-level outcomes: merged/reverted/error per patch */
54
+ patchOutcomes?: Array<{
55
+ name: string;
56
+ action: string;
57
+ reason: string;
58
+ branch?: string;
59
+ }>;
60
+ /** P0 impact attribution: canary A/B comparison applied this cycle */
61
+ impact?: {
62
+ rows: Array<{
63
+ skill: string;
64
+ exposed: string;
65
+ control: string;
66
+ verdict: string;
67
+ }>;
68
+ applied: string[];
69
+ };
70
+ /** P1 lifecycle: skills moved to dormant this cycle */
71
+ decayed?: string[];
72
+ /** budget state at cycle start (observability) */
73
+ budget?: {
74
+ cyclesToday: number;
75
+ maxCyclesPerDay?: number;
76
+ };
77
+ /** chars/4 estimate of this cycle's meta-call traffic; feeds the daily
78
+ * token budget gate (readBudget sums today's entries) */
79
+ estTokens?: number;
80
+ }
81
+ /** Runs one bench case with the given skills block injected. */
82
+ export type CaseRunner = (c: BenchCase, skillsPrompt: string) => Promise<string>;
83
+ /** Rough token estimate, language-aware: ASCII runs ~4 chars/token, CJK
84
+ * ~1 char/token. A flat chars/4 undercounted Chinese 2-4x, letting verbose
85
+ * zh candidates dodge the cost cap. Used on BOTH sides of every comparison
86
+ * (baseline and candidate), never as the only rejection reason (the
87
+ * pass-rate gate decides; cost-cap only vetoes pass-by-rambling). */
88
+ export declare function estTokens(text: string): number;
89
+ export declare function runEvolution(opts: {
90
+ home: string;
91
+ provider: ProviderConfig;
92
+ runCase: CaseRunner;
93
+ maxProposals?: number;
94
+ /** Skip the meta-model call and evaluate these proposals directly (tests / future UIs). */
95
+ presetProposals?: SkillProposal[];
96
+ log?: (line: string) => void;
97
+ }): Promise<EvolveReport>;
98
+ export declare function screenForPoison(text: string): string | null;
package/dist/evolve.js CHANGED
@@ -167,14 +167,18 @@ export async function runEvolution(opts) {
167
167
  }
168
168
  else {
169
169
  proposals = await proposeSkills(provider, signals, say, ancestor ?? undefined, mergeWith ?? undefined);
170
- if (proposals.length === 0) {
171
- try {
172
- const { workflowProposals } = await import("./workflows.js");
173
- proposals = await workflowProposals(provider, home, say);
174
- }
175
- catch (err) {
176
- say(` awm skipped: ${String(err).slice(0, 80)}`);
177
- }
170
+ // AWM is ADDITIVE, not fallback-only (design intent: "higher abstraction
171
+ // than per-mistake reflection"). With batch-produced task archetypes
172
+ // repeating 100+ times/day, the workflow path must get its turn even
173
+ // when the meta-model also proposes - the -workflow naming + gate still
174
+ // decides promotion. Guard: only append when clusters are real (>=5).
175
+ try {
176
+ const { workflowProposals } = await import("./workflows.js");
177
+ const wf = await workflowProposals(provider, home, say);
178
+ proposals = [...proposals, ...wf];
179
+ }
180
+ catch (err) {
181
+ say(` awm skipped: ${String(err).slice(0, 80)}`);
178
182
  }
179
183
  }
180
184
  report.proposals = proposals;
@@ -227,13 +231,9 @@ export async function runEvolution(opts) {
227
231
  const regression = baseResults.some((b) => b.pass && !candResults.find((c) => c.name === b.name)?.pass);
228
232
  // dual-metric veto: a passing case that costs > cost-cap x its
229
233
  // baseline counts as a cost regression (the candidate passed by
230
- // rambling) - only enforced where the case declares a cost-cap
231
- const costRegressions = train.filter((c) => {
232
- const cap = c.costCap ?? 1.3; // default 1.3x for all cases
233
- const base = baseCost[c.name] ?? 0;
234
- const cand = candCost[c.name] ?? 0;
235
- return base > 0 && cand > base * cap;
236
- }).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);
237
237
  const summary = (rs) => rs.map((r) => `${r.name}:${r.pass ? 'pass' : 'FAIL'}`).join(' ');
238
238
  if (regression || candRate < baseRate) {
239
239
  await deleteDraft(home, p.name);
@@ -455,6 +455,7 @@ async function proposeSkills(provider, signals, say, ancestor, mergeWith) {
455
455
  'Your job: read session signals and decide whether any repeatable procedure is worth crystallizing into a skill.',
456
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.',
457
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.',
458
459
  'Respond with ONLY a JSON array: [{"name":"...","description":"...","skill_md":"..."}] - no prose, no code fences.',
459
460
  ].join('\n');
460
461
  // GEPA ancestor context: vary around a past rejection (its reason is the
@@ -507,6 +508,26 @@ async function distillMemory(provider, recentNotes, say) {
507
508
  return null;
508
509
  }
509
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
+ }
510
531
  /** First balanced JSON array in the text; tolerates fences and prose around it. */
511
532
  function parseJsonArray(text) {
512
533
  const cleaned = text.replace(/```(?:json)?/g, '');