@hmharness/evolution 0.23.43 → 0.23.45
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/sanitize.d.ts +29 -0
- package/dist/sanitize.js +114 -0
- package/dist/three-arm-v2.d.ts +69 -0
- package/dist/three-arm-v2.js +160 -0
- package/dist/three-arm.d.ts +195 -0
- package/dist/three-arm.js +295 -0
- package/package.json +3 -3
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare const PLACEHOLDER = "<user>";
|
|
2
|
+
/** The username to strip, derived from the home directory's last segment.
|
|
3
|
+
* Returned lowercase-free (as-is); matching is case-insensitive anyway. */
|
|
4
|
+
export declare function currentUsername(): string;
|
|
5
|
+
/** Replace every case-insensitive occurrence of `username` with `<user>`.
|
|
6
|
+
* Pure string surgery — no regex feature beyond the escaped literal. */
|
|
7
|
+
export declare function sanitizeText(text: string, username: string): string;
|
|
8
|
+
/** Heuristic text/binary split: a NUL byte or a high share of lone-ish
|
|
9
|
+
* control bytes means binary. Text evidence (md/json/task/jsonl/log/txt)
|
|
10
|
+
* sails through; png/zips copy verbatim. */
|
|
11
|
+
export declare function isProbablyText(buf: Buffer): boolean;
|
|
12
|
+
export declare function sanitizeBuffer(buf: Buffer, username: string): Buffer;
|
|
13
|
+
/** True when the buffer still contains the username (case-insensitive). */
|
|
14
|
+
export declare function containsUsername(buf: Buffer, username: string): boolean;
|
|
15
|
+
export interface SanitizeTreeResult {
|
|
16
|
+
filesScanned: number;
|
|
17
|
+
filesReplaced: number;
|
|
18
|
+
binaryCopied: number;
|
|
19
|
+
}
|
|
20
|
+
/** Copy `src` -> `dst` recursively, sanitizing every text file in flight.
|
|
21
|
+
* The destination is always rebuilt from source (idempotent export). */
|
|
22
|
+
export declare function sanitizeTree(src: string, dst: string, username: string): SanitizeTreeResult;
|
|
23
|
+
/** Sanitize a single file into `dstPath` (parent dirs created). */
|
|
24
|
+
export declare function sanitizeFileTo(srcPath: string, dstPath: string, username: string): {
|
|
25
|
+
replaced: boolean;
|
|
26
|
+
};
|
|
27
|
+
/** Placeholder twin of basename for sanitized sidecar files:
|
|
28
|
+
* manifest.json -> manifest.sanitized.json */
|
|
29
|
+
export declare function sanitizedFileName(name: string): string;
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/evolution - evidence-export username sanitizer.
|
|
3
|
+
*
|
|
4
|
+
* Root-cause fix for the publish-hook rejections (2026-10-28): experiment
|
|
5
|
+
* exports (skill snapshots, gate-home copies, manifests) carried the local
|
|
6
|
+
* Windows username inside absolute paths and bare mentions, and the repo's
|
|
7
|
+
* pre-push hook blocks any pushed content matching the username. The fix is
|
|
8
|
+
* at the EXPORT pipeline: whatever gets copied into website/evidence has the
|
|
9
|
+
* username replaced by the `<user>` placeholder BEFORE it lands on disk.
|
|
10
|
+
*
|
|
11
|
+
* Design constraints:
|
|
12
|
+
* - the username is NEVER hardcoded here (this file is itself pushed); it is
|
|
13
|
+
* passed in by the caller, typically derived from os.homedir()
|
|
14
|
+
* - replacement is case-insensitive and covers every spelling shape at once:
|
|
15
|
+
* `C:\Users\<name>\`, `C:/Users/<name>/`, JSON-escaped `C:\\Users\\<name>\\`
|
|
16
|
+
* and bare mentions — they are all just the username token in context
|
|
17
|
+
* - already-sanitized text is idempotent (`<user>` contains no username)
|
|
18
|
+
* - binary files are copied verbatim (never re-encoded)
|
|
19
|
+
*/
|
|
20
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
22
|
+
export const PLACEHOLDER = '<user>';
|
|
23
|
+
/** The username to strip, derived from the home directory's last segment.
|
|
24
|
+
* Returned lowercase-free (as-is); matching is case-insensitive anyway. */
|
|
25
|
+
export function currentUsername() {
|
|
26
|
+
const home = process.env.HMH_HOME_OVERRIDE_FOR_SANITIZER ?? process.env.USERPROFILE ?? process.env.HOME ?? '';
|
|
27
|
+
// USERPROFILE is `C:\Users\<name>`; HOME on unix is `/home/<name>`
|
|
28
|
+
const seg = home.split(/[\\/]/).filter(Boolean).pop() ?? '';
|
|
29
|
+
return seg;
|
|
30
|
+
}
|
|
31
|
+
function escapeRegExp(s) {
|
|
32
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
33
|
+
}
|
|
34
|
+
/** Replace every case-insensitive occurrence of `username` with `<user>`.
|
|
35
|
+
* Pure string surgery — no regex feature beyond the escaped literal. */
|
|
36
|
+
export function sanitizeText(text, username) {
|
|
37
|
+
if (!username || username.length < 2)
|
|
38
|
+
return text; // refuse to nuke 1-char names
|
|
39
|
+
const re = new RegExp(escapeRegExp(username), 'gi');
|
|
40
|
+
return text.replace(re, PLACEHOLDER);
|
|
41
|
+
}
|
|
42
|
+
/** Heuristic text/binary split: a NUL byte or a high share of lone-ish
|
|
43
|
+
* control bytes means binary. Text evidence (md/json/task/jsonl/log/txt)
|
|
44
|
+
* sails through; png/zips copy verbatim. */
|
|
45
|
+
export function isProbablyText(buf) {
|
|
46
|
+
if (buf.includes(0))
|
|
47
|
+
return false;
|
|
48
|
+
let ctrl = 0;
|
|
49
|
+
const sample = buf.subarray(0, Math.min(buf.length, 4096));
|
|
50
|
+
for (const b of sample)
|
|
51
|
+
if (b < 9 || (b > 13 && b < 32))
|
|
52
|
+
ctrl++;
|
|
53
|
+
return ctrl / Math.max(1, sample.length) < 0.02;
|
|
54
|
+
}
|
|
55
|
+
export function sanitizeBuffer(buf, username) {
|
|
56
|
+
if (!isProbablyText(buf))
|
|
57
|
+
return buf;
|
|
58
|
+
return Buffer.from(sanitizeText(buf.toString('utf8'), username), 'utf8');
|
|
59
|
+
}
|
|
60
|
+
/** True when the buffer still contains the username (case-insensitive). */
|
|
61
|
+
export function containsUsername(buf, username) {
|
|
62
|
+
if (!username)
|
|
63
|
+
return false;
|
|
64
|
+
if (!isProbablyText(buf))
|
|
65
|
+
return false;
|
|
66
|
+
const re = new RegExp(escapeRegExp(username), 'i');
|
|
67
|
+
return re.test(buf.toString('utf8'));
|
|
68
|
+
}
|
|
69
|
+
/** Copy `src` -> `dst` recursively, sanitizing every text file in flight.
|
|
70
|
+
* The destination is always rebuilt from source (idempotent export). */
|
|
71
|
+
export function sanitizeTree(src, dst, username) {
|
|
72
|
+
const result = { filesScanned: 0, filesReplaced: 0, binaryCopied: 0 };
|
|
73
|
+
const walk = (from, to) => {
|
|
74
|
+
mkdirSync(to, { recursive: true });
|
|
75
|
+
for (const entry of readdirSync(from).sort()) {
|
|
76
|
+
const fp = join(from, entry);
|
|
77
|
+
const tp = join(to, entry);
|
|
78
|
+
if (statSync(fp).isDirectory()) {
|
|
79
|
+
walk(fp, tp);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
result.filesScanned++;
|
|
83
|
+
const buf = readFileSync(fp);
|
|
84
|
+
if (!isProbablyText(buf)) {
|
|
85
|
+
cpSync(fp, tp); // verbatim binary
|
|
86
|
+
result.binaryCopied++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const out = sanitizeText(buf.toString('utf8'), username);
|
|
90
|
+
if (out !== buf.toString('utf8'))
|
|
91
|
+
result.filesReplaced++;
|
|
92
|
+
writeFileSync(tp, out, 'utf8');
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
if (!existsSync(src))
|
|
96
|
+
throw new Error(`sanitizeTree: source missing: ${src}`);
|
|
97
|
+
walk(src, dst);
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
/** Sanitize a single file into `dstPath` (parent dirs created). */
|
|
101
|
+
export function sanitizeFileTo(srcPath, dstPath, username) {
|
|
102
|
+
const buf = readFileSync(srcPath);
|
|
103
|
+
const out = sanitizeBuffer(buf, username);
|
|
104
|
+
mkdirSync(dirname(dstPath), { recursive: true });
|
|
105
|
+
writeFileSync(dstPath, out);
|
|
106
|
+
return { replaced: !out.equals(buf) };
|
|
107
|
+
}
|
|
108
|
+
/** Placeholder twin of basename for sanitized sidecar files:
|
|
109
|
+
* manifest.json -> manifest.sanitized.json */
|
|
110
|
+
export function sanitizedFileName(name) {
|
|
111
|
+
const ext = extname(name);
|
|
112
|
+
const base = basename(name, ext);
|
|
113
|
+
return `${base}.sanitized${ext}`;
|
|
114
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type ExperimentTask, type RunRecord, type ComparisonResult } from './three-arm.js';
|
|
2
|
+
export declare const EXPERIMENT_ID_V2 = "three-arm-v2-2026-10-28";
|
|
3
|
+
/** Seeds 3 -> 5 (the v1 report's power recommendation). */
|
|
4
|
+
export declare const SEEDS_V2: readonly [1, 2, 3, 4, 5];
|
|
5
|
+
/** Promotion gate: same bars as v1 (inherited by value, not by reference to
|
|
6
|
+
* keep the v1 module frozen for its own reproduction). */
|
|
7
|
+
export declare const GATE_V2: {
|
|
8
|
+
readonly ciLevel: 0.95;
|
|
9
|
+
readonly mcnemarAlpha: 0.05;
|
|
10
|
+
readonly maxProtectFallback: 0.03;
|
|
11
|
+
readonly maxCostGrowth: 0.1;
|
|
12
|
+
readonly minTasksPerSplit: 15;
|
|
13
|
+
readonly minDiscordantCells: 8;
|
|
14
|
+
};
|
|
15
|
+
/** Execution-order shuffle material (per-seed). */
|
|
16
|
+
export declare const SHUFFLE_SEED_MATERIAL_V2: string;
|
|
17
|
+
/** The shape the evaluation package's HarmonyBench cases are read into. */
|
|
18
|
+
export interface HarmonyBenchSourceCase {
|
|
19
|
+
id: string;
|
|
20
|
+
prompt: string;
|
|
21
|
+
assertion: {
|
|
22
|
+
type: 'exact' | 'contains' | 'not_contains' | 'regex';
|
|
23
|
+
value: string;
|
|
24
|
+
};
|
|
25
|
+
category: string;
|
|
26
|
+
needsTools: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** Serialize a HarmonyBench case into the bench .task format so the protect
|
|
29
|
+
* copy on disk is byte-identical to what fileSha/datasetHash were built
|
|
30
|
+
* from (deterministic, single source of truth for its bytes). expect-exact
|
|
31
|
+
* is written UNQUOTED: the .task parser strips only surrounding quotes, so
|
|
32
|
+
* quoting a value that itself contains quotes would break the round-trip. */
|
|
33
|
+
export declare function harmonyBenchTaskBytes(c: HarmonyBenchSourceCase): string;
|
|
34
|
+
/** Map one HarmonyBench case to an experiment protect task. Returns null for
|
|
35
|
+
* needsTools cases: the experiment's frozen tool whitelist (read_file /
|
|
36
|
+
* list_dir) cannot serve them — same exclusion rationale as v1's
|
|
37
|
+
* toolchain-* cases (unfreezable environment), pre-registered here. */
|
|
38
|
+
export declare function harmonyBenchToTask(c: HarmonyBenchSourceCase): ExperimentTask | null;
|
|
39
|
+
/** Failure classes for candidate generation. Deterministic on (task, record):
|
|
40
|
+
* judge detail prefixes + the toolCalls signal. Empirical anchor from v1
|
|
41
|
+
* phase-0: the only beh failure (beh-t8-n) had toolCalls=0 — the model
|
|
42
|
+
* answered without reading the file it was told to read. */
|
|
43
|
+
export type FailureClass = 'exact-output' | 'format' | 'content-marker' | 'tool-use' | 'forbidden';
|
|
44
|
+
export declare const FAILURE_CLASSES: readonly FailureClass[];
|
|
45
|
+
export declare function classifyFailure(task: Pick<ExperimentTask, 'tools'>, r: Pick<RunRecord, 'reason'> & {
|
|
46
|
+
outcome: {
|
|
47
|
+
success: boolean | null;
|
|
48
|
+
};
|
|
49
|
+
metrics: {
|
|
50
|
+
toolCalls: number;
|
|
51
|
+
};
|
|
52
|
+
}): FailureClass | null;
|
|
53
|
+
export declare const POPULATION_RULE: {
|
|
54
|
+
readonly perClassCandidates: 2;
|
|
55
|
+
readonly classSelection: "classes ordered by distinct failed-task count (desc), tie by class name; top classes taken, all with >=1 failed task eligible";
|
|
56
|
+
readonly maxClasses: number;
|
|
57
|
+
readonly minTasksPerClassPrompt: 1;
|
|
58
|
+
readonly gate: "the whole population enters ONE runEvolution call as presetProposals with maxProposals = population size; thresholds unchanged (quality floor 0.6, zero bench regression, double sampling, holdout re-verification, cost cap)";
|
|
59
|
+
readonly cArmPick: "if any candidate is promoted: deploy the one with the highest gate train pass rate; tie broken by lexicographically smallest name (deterministic, pre-registered)";
|
|
60
|
+
readonly allRejected: "population fully rejected => C arm not deployable, main endpoint NEEDS-DATA (honest outcome, same as v1 cArmRule)";
|
|
61
|
+
};
|
|
62
|
+
export declare function buildTaskSetHashV2(tasks: ExperimentTask[]): {
|
|
63
|
+
setId: string;
|
|
64
|
+
datasetHash: string;
|
|
65
|
+
canonical: string;
|
|
66
|
+
};
|
|
67
|
+
/** compareArms with the seed list as a parameter (v1 hardcodes [1,2,3]; the
|
|
68
|
+
* bootstrap/McNemar/gate logic is byte-for-byte the v1 protocol). */
|
|
69
|
+
export declare function compareArmsV2(records: RunRecord[], armX: 'A' | 'B' | 'C', armY: 'A' | 'B' | 'C', split: 'train' | 'protect', seeds?: readonly number[]): ComparisonResult;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/evolution - three-arm v2: expanded-sample rerun (2026-10-28).
|
|
3
|
+
*
|
|
4
|
+
* Continuation of three-arm-main-experiment-2026-10-27 (verdict NEEDS-DATA).
|
|
5
|
+
* v2 changes over v1, frozen BEFORE any run (the v1 REPORT §六 next-step):
|
|
6
|
+
* 1. GEPA-style candidate POPULATION: phase-0 TRAIN failures are typed by
|
|
7
|
+
* failure class; each class proposes its own candidates (2 per class),
|
|
8
|
+
* and the WHOLE population enters the same real evolution gate.
|
|
9
|
+
* 2. Expanded sample: protect set 23 -> 69 tasks (23 existing hmb holdouts +
|
|
10
|
+
* the 46 offline-judgeable HarmonyBench v1.0.0 cases), seeds 3 -> 5.
|
|
11
|
+
* 3. Everything else - gate thresholds, statistics protocol, cArmRule,
|
|
12
|
+
* crashRule, arm construction - is inherited from v1 verbatim.
|
|
13
|
+
*
|
|
14
|
+
* Pure core again: constants, failure typing, HarmonyBench task mapping,
|
|
15
|
+
* seeds-parameterized statistics. All model IO stays in the driver scripts.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { GATE, mcnemarExactP, bootstrapPairedCI, } from "./three-arm.js";
|
|
19
|
+
export const EXPERIMENT_ID_V2 = 'three-arm-v2-2026-10-28';
|
|
20
|
+
/** Seeds 3 -> 5 (the v1 report's power recommendation). */
|
|
21
|
+
export const SEEDS_V2 = [1, 2, 3, 4, 5];
|
|
22
|
+
/** Promotion gate: same bars as v1 (inherited by value, not by reference to
|
|
23
|
+
* keep the v1 module frozen for its own reproduction). */
|
|
24
|
+
export const GATE_V2 = GATE;
|
|
25
|
+
/** Execution-order shuffle material (per-seed). */
|
|
26
|
+
export const SHUFFLE_SEED_MATERIAL_V2 = EXPERIMENT_ID_V2 + ':exec-order';
|
|
27
|
+
/** Serialize a HarmonyBench case into the bench .task format so the protect
|
|
28
|
+
* copy on disk is byte-identical to what fileSha/datasetHash were built
|
|
29
|
+
* from (deterministic, single source of truth for its bytes). expect-exact
|
|
30
|
+
* is written UNQUOTED: the .task parser strips only surrounding quotes, so
|
|
31
|
+
* quoting a value that itself contains quotes would break the round-trip. */
|
|
32
|
+
export function harmonyBenchTaskBytes(c) {
|
|
33
|
+
const lines = [c.prompt];
|
|
34
|
+
if (c.assertion.type === 'exact')
|
|
35
|
+
lines.push(`expect-exact: ${c.assertion.value}`);
|
|
36
|
+
else if (c.assertion.type === 'regex')
|
|
37
|
+
lines.push(`expect-regex: ${c.assertion.value}`);
|
|
38
|
+
else if (c.assertion.type === 'contains')
|
|
39
|
+
lines.push(`expect: ${c.assertion.value}`);
|
|
40
|
+
else
|
|
41
|
+
lines.push(`expect-none: ${c.assertion.value}`);
|
|
42
|
+
lines.push('holdout: true');
|
|
43
|
+
return lines.join('\n') + '\n';
|
|
44
|
+
}
|
|
45
|
+
/** Map one HarmonyBench case to an experiment protect task. Returns null for
|
|
46
|
+
* needsTools cases: the experiment's frozen tool whitelist (read_file /
|
|
47
|
+
* list_dir) cannot serve them — same exclusion rationale as v1's
|
|
48
|
+
* toolchain-* cases (unfreezable environment), pre-registered here. */
|
|
49
|
+
export function harmonyBenchToTask(c) {
|
|
50
|
+
if (c.needsTools)
|
|
51
|
+
return null;
|
|
52
|
+
const raw = harmonyBenchTaskBytes(c);
|
|
53
|
+
return {
|
|
54
|
+
id: `hb-${c.id}`,
|
|
55
|
+
prompt: c.prompt,
|
|
56
|
+
assertion: c.assertion.type === 'exact' ? { expect: [], expectExact: c.assertion.value }
|
|
57
|
+
: c.assertion.type === 'regex' ? { expect: [], expectRegex: c.assertion.value }
|
|
58
|
+
: c.assertion.type === 'contains' ? { expect: [c.assertion.value] }
|
|
59
|
+
: { expect: [], expectNone: [c.assertion.value] },
|
|
60
|
+
tools: false,
|
|
61
|
+
holdout: true,
|
|
62
|
+
split: 'protect',
|
|
63
|
+
family: `hb-${c.category}`,
|
|
64
|
+
fileSha: createHash('sha256').update(raw).digest('hex'),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export const FAILURE_CLASSES = ['exact-output', 'format', 'content-marker', 'tool-use', 'forbidden'];
|
|
68
|
+
export function classifyFailure(task, r) {
|
|
69
|
+
if (r.outcome.success === true)
|
|
70
|
+
return null;
|
|
71
|
+
// a tools-required task that never called a tool: answered from nothing
|
|
72
|
+
if (task.tools && r.metrics.toolCalls === 0)
|
|
73
|
+
return 'tool-use';
|
|
74
|
+
const d = r.reason;
|
|
75
|
+
if (d.startsWith('exact mismatch'))
|
|
76
|
+
return 'exact-output';
|
|
77
|
+
if (d.startsWith('regex mismatch'))
|
|
78
|
+
return 'format';
|
|
79
|
+
if (d.startsWith('missing'))
|
|
80
|
+
return 'content-marker';
|
|
81
|
+
if (d.startsWith('none of the allowed markers'))
|
|
82
|
+
return 'content-marker';
|
|
83
|
+
if (d.startsWith('forbidden marker'))
|
|
84
|
+
return 'forbidden';
|
|
85
|
+
// tools tasks that DID call tools but still failed an exact/marker check
|
|
86
|
+
if (task.tools)
|
|
87
|
+
return 'tool-use';
|
|
88
|
+
return 'exact-output';
|
|
89
|
+
}
|
|
90
|
+
/* ---------------- population rule (pre-registered) ---------------- */
|
|
91
|
+
export const POPULATION_RULE = {
|
|
92
|
+
perClassCandidates: 2,
|
|
93
|
+
classSelection: 'classes ordered by distinct failed-task count (desc), tie by class name; top classes taken, all with >=1 failed task eligible',
|
|
94
|
+
maxClasses: FAILURE_CLASSES.length,
|
|
95
|
+
minTasksPerClassPrompt: 1,
|
|
96
|
+
gate: 'the whole population enters ONE runEvolution call as presetProposals with maxProposals = population size; thresholds unchanged (quality floor 0.6, zero bench regression, double sampling, holdout re-verification, cost cap)',
|
|
97
|
+
cArmPick: 'if any candidate is promoted: deploy the one with the highest gate train pass rate; tie broken by lexicographically smallest name (deterministic, pre-registered)',
|
|
98
|
+
allRejected: 'population fully rejected => C arm not deployable, main endpoint NEEDS-DATA (honest outcome, same as v1 cArmRule)',
|
|
99
|
+
};
|
|
100
|
+
/* ---------------- datasetHash (v2 canonical) ---------------- */
|
|
101
|
+
export function buildTaskSetHashV2(tasks) {
|
|
102
|
+
const primaryKind = (t) => t.assertion.expectExact !== undefined ? 'expect-exact'
|
|
103
|
+
: t.assertion.expectRegex !== undefined ? 'expect-regex'
|
|
104
|
+
: t.assertion.expectAny?.length ? 'expect-any'
|
|
105
|
+
: t.assertion.expectNone?.length ? 'expect-none'
|
|
106
|
+
: 'expect';
|
|
107
|
+
const rows = [...tasks]
|
|
108
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
109
|
+
.map((t) => ({ id: t.id, prompt: t.prompt, assertion: { kind: primaryKind(t), expect: JSON.stringify(t.assertion) }, holdout: t.split === 'protect' }));
|
|
110
|
+
const canonical = JSON.stringify({ kind: 'hmharness-eval-taskset', version: 1, id: 'three-arm-v2-2026-10-28', revision: 1, tasks: rows });
|
|
111
|
+
return { setId: 'three-arm-v2-2026-10-28', datasetHash: createHash('sha256').update(canonical).digest('hex'), canonical };
|
|
112
|
+
}
|
|
113
|
+
/* ---------------- statistics (v1 protocol, seeds parameterized) ---------------- */
|
|
114
|
+
/** compareArms with the seed list as a parameter (v1 hardcodes [1,2,3]; the
|
|
115
|
+
* bootstrap/McNemar/gate logic is byte-for-byte the v1 protocol). */
|
|
116
|
+
export function compareArmsV2(records, armX, armY, split, seeds = SEEDS_V2) {
|
|
117
|
+
const xs = new Map(records.filter((r) => r.arm === armX && r.holdoutClass === split && r.decision === 'measured').map((r) => [`${r.seed}:${r.taskId}`, r.outcome.success === true]));
|
|
118
|
+
const ys = new Map(records.filter((r) => r.arm === armY && r.holdoutClass === split && r.decision === 'measured').map((r) => [`${r.seed}:${r.taskId}`, r.outcome.success === true]));
|
|
119
|
+
const cellKeys = [...xs.keys()].filter((k) => ys.has(k));
|
|
120
|
+
const byTask = new Map();
|
|
121
|
+
const mcnemarPerSeed = [];
|
|
122
|
+
for (const seed of seeds) {
|
|
123
|
+
let xOnly = 0, yOnly = 0;
|
|
124
|
+
for (const k of cellKeys) {
|
|
125
|
+
const [s, task] = k.split(':');
|
|
126
|
+
if (Number(s) !== seed)
|
|
127
|
+
continue;
|
|
128
|
+
const x = xs.get(k), y = ys.get(k);
|
|
129
|
+
if (x && !y)
|
|
130
|
+
xOnly++;
|
|
131
|
+
else if (!x && y)
|
|
132
|
+
yOnly++;
|
|
133
|
+
const t = byTask.get(task) ?? { x: [], y: [] };
|
|
134
|
+
t.x.push(x ? 1 : 0);
|
|
135
|
+
t.y.push(y ? 1 : 0);
|
|
136
|
+
byTask.set(task, t);
|
|
137
|
+
}
|
|
138
|
+
const { p, discordant } = mcnemarExactP(xOnly, yOnly);
|
|
139
|
+
mcnemarPerSeed.push({ seed, p, discordant, xOnly, yOnly });
|
|
140
|
+
}
|
|
141
|
+
const diffs = [...byTask.values()].map((t) => avg(t.x) - avg(t.y));
|
|
142
|
+
const rateX = cellKeys.filter((k) => xs.get(k)).length / Math.max(1, cellKeys.length);
|
|
143
|
+
const rateY = cellKeys.filter((k) => ys.get(k)).length / Math.max(1, cellKeys.length);
|
|
144
|
+
const bootstrap = bootstrapPairedCI(diffs, { level: GATE_V2.ciLevel, seed: 42 });
|
|
145
|
+
const discordantCells = mcnemarPerSeed.reduce((s, m) => s + m.discordant, 0);
|
|
146
|
+
const needsData = [];
|
|
147
|
+
if (diffs.length < GATE_V2.minTasksPerSplit)
|
|
148
|
+
needsData.push(`only ${diffs.length} paired tasks (<${GATE_V2.minTasksPerSplit})`);
|
|
149
|
+
if (discordantCells < GATE_V2.minDiscordantCells)
|
|
150
|
+
needsData.push(`only ${discordantCells} discordant cells (<${GATE_V2.minDiscordantCells}) — exact test underpowered`);
|
|
151
|
+
const significant = bootstrap.ciLo > 0 || bootstrap.ciHi < 0 || mcnemarPerSeed.some((m) => m.p < GATE_V2.mcnemarAlpha);
|
|
152
|
+
return {
|
|
153
|
+
comparison: `${armX}-vs-${armY}`,
|
|
154
|
+
split, armX, armY,
|
|
155
|
+
rateX: Number(rateX.toFixed(4)), rateY: Number(rateY.toFixed(4)),
|
|
156
|
+
diff: Number((rateX - rateY).toFixed(4)),
|
|
157
|
+
bootstrap, mcnemarPerSeed, discordantCells, significant, needsData,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function avg(xs) { return xs.reduce((s, x) => s + x, 0) / xs.length; }
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
export declare const EXPERIMENT_ID = "three-arm-main-experiment-2026-10-27";
|
|
2
|
+
/** Family quotas for the train subset. Families are the first '-' segment of
|
|
3
|
+
* the bench case id. toolchain-* and reply-determinism are excluded (they
|
|
4
|
+
* need the live toolchain, i.e. an unfreezable environment). */
|
|
5
|
+
export declare const FAMILY_QUOTAS: Record<string, number>;
|
|
6
|
+
/** beh + behave-* are one family (both are read-file-then-answer cases). */
|
|
7
|
+
export declare const FAMILY_ALIASES: Record<string, string>;
|
|
8
|
+
export declare const SEEDS: readonly [1, 2, 3];
|
|
9
|
+
export declare const SHUFFLE_SEED_MATERIAL: string;
|
|
10
|
+
/** gate split of the train subset: last 8 shuffled tasks become gate-val */
|
|
11
|
+
export declare const GATE_VAL_SIZE = 8;
|
|
12
|
+
/** Promotion gate (pre-registered; the task brief's suggested bars):
|
|
13
|
+
* significance = bootstrap 95% CI of the paired task-level diff excludes 0
|
|
14
|
+
* OR any per-seed exact McNemar p < 0.05 on the MAIN endpoint only
|
|
15
|
+
* (protect-set C-vs-A; the other three comparisons are exploratory).
|
|
16
|
+
* AND protect fallback <= 3pp, AND total-token growth <= 10%. */
|
|
17
|
+
export declare const GATE: {
|
|
18
|
+
readonly ciLevel: 0.95;
|
|
19
|
+
readonly mcnemarAlpha: 0.05;
|
|
20
|
+
readonly maxProtectFallback: 0.03;
|
|
21
|
+
readonly maxCostGrowth: 0.1;
|
|
22
|
+
readonly minTasksPerSplit: 15;
|
|
23
|
+
readonly minDiscordantCells: 8;
|
|
24
|
+
};
|
|
25
|
+
export interface ExperimentTask {
|
|
26
|
+
id: string;
|
|
27
|
+
prompt: string;
|
|
28
|
+
/** the full original assertion spec (all modes) — judging input */
|
|
29
|
+
assertion: {
|
|
30
|
+
expect: string[];
|
|
31
|
+
expectExact?: string;
|
|
32
|
+
expectRegex?: string;
|
|
33
|
+
expectNone?: string[];
|
|
34
|
+
expectAny?: string[];
|
|
35
|
+
};
|
|
36
|
+
tools: boolean;
|
|
37
|
+
holdout: boolean;
|
|
38
|
+
split: 'train' | 'protect';
|
|
39
|
+
family: string;
|
|
40
|
+
/** sha256 of the original .task file bytes — content identity */
|
|
41
|
+
fileSha: string;
|
|
42
|
+
}
|
|
43
|
+
export declare function familyOf(id: string): string;
|
|
44
|
+
/** Deterministic stratified selection: per family, seededOrder(quota). */
|
|
45
|
+
export declare function selectTasks(all: Array<{
|
|
46
|
+
id: string;
|
|
47
|
+
holdout: boolean;
|
|
48
|
+
tools: boolean;
|
|
49
|
+
}>, seed: number): {
|
|
50
|
+
train: string[];
|
|
51
|
+
protect: string[];
|
|
52
|
+
excluded: Array<{
|
|
53
|
+
id: string;
|
|
54
|
+
why: string;
|
|
55
|
+
}>;
|
|
56
|
+
};
|
|
57
|
+
/** datasetHash: sha256 over the canonical registered task set (tasks sorted
|
|
58
|
+
* by id; the FULL assertion spec serialized into evalset's assertion.expect
|
|
59
|
+
* so the hash is content-sensitive to every mode, not just the primary). */
|
|
60
|
+
export declare function buildTaskSetHash(tasks: ExperimentTask[]): {
|
|
61
|
+
setId: string;
|
|
62
|
+
datasetHash: string;
|
|
63
|
+
canonical: string;
|
|
64
|
+
};
|
|
65
|
+
/** Same fence convention as bench.matchCase: assertions test the fenced
|
|
66
|
+
* inner body when the reply is a fenced block. Re-implemented here (not
|
|
67
|
+
* imported) so the judge is a self-contained, unit-testable unit. */
|
|
68
|
+
export declare function fenceInner(output: string): string;
|
|
69
|
+
export interface JudgeVerdict {
|
|
70
|
+
pass: boolean;
|
|
71
|
+
detail: string;
|
|
72
|
+
/** claimed-done-but-failed: assertion failed AND the reply contains a
|
|
73
|
+
* completion marker (pre-registered false-completion definition) */
|
|
74
|
+
falseCompletion: boolean;
|
|
75
|
+
}
|
|
76
|
+
export declare function judgeOutput(output: string, t: Pick<ExperimentTask, 'assertion'>): JudgeVerdict;
|
|
77
|
+
export interface SkillSnapshotEntry {
|
|
78
|
+
name: string;
|
|
79
|
+
description: string;
|
|
80
|
+
}
|
|
81
|
+
/** The A-arm / gate-baseline injection: the frozen skill-library snapshot
|
|
82
|
+
* (same shape as skills.skillsToPrompt). */
|
|
83
|
+
export declare function snapshotToPrompt(entries: SkillSnapshotEntry[]): string;
|
|
84
|
+
/** The C-arm / gate-candidate injection: snapshot + the candidate draft,
|
|
85
|
+
* byte-identical in construction to evolve.ts's candidateInjection. */
|
|
86
|
+
export declare function candidateInjection(snapshot: SkillSnapshotEntry[], draftName: string, draftMd: string): string;
|
|
87
|
+
export interface TaskLesson {
|
|
88
|
+
taskId: string;
|
|
89
|
+
promptHead: string;
|
|
90
|
+
failedOutputHead: string;
|
|
91
|
+
failureDetail: string;
|
|
92
|
+
}
|
|
93
|
+
/** The B-arm adaptation block: deterministic lessons from TRAIN-only null
|
|
94
|
+
* failures of the SAME family (never protect tasks — no leakage), max 3.
|
|
95
|
+
* Task-scoped by construction: the driver injects it into this task's
|
|
96
|
+
* system prompt only and drops it afterwards. */
|
|
97
|
+
export declare function buildAdaptiveBlock(family: string, lessons: TaskLesson[]): string;
|
|
98
|
+
export interface RunRecord {
|
|
99
|
+
experimentId: string;
|
|
100
|
+
repoCommit: string;
|
|
101
|
+
datasetHash: string;
|
|
102
|
+
baseModel: {
|
|
103
|
+
provider: string;
|
|
104
|
+
id: string;
|
|
105
|
+
sampling: string;
|
|
106
|
+
};
|
|
107
|
+
arm: 'A' | 'B' | 'C';
|
|
108
|
+
seed: number;
|
|
109
|
+
taskId: string;
|
|
110
|
+
holdoutClass: 'train' | 'protect';
|
|
111
|
+
outcome: {
|
|
112
|
+
success: boolean | null;
|
|
113
|
+
environmentDeltaHash: string;
|
|
114
|
+
};
|
|
115
|
+
metrics: {
|
|
116
|
+
cost: {
|
|
117
|
+
promptTokens: number | null;
|
|
118
|
+
completionTokens: number | null;
|
|
119
|
+
totalTokens: number | null;
|
|
120
|
+
usd: null;
|
|
121
|
+
};
|
|
122
|
+
wallMs: number;
|
|
123
|
+
toolCalls: number;
|
|
124
|
+
recoveries: number;
|
|
125
|
+
crashes: number;
|
|
126
|
+
falseCompletion: number;
|
|
127
|
+
predictedP: number | null;
|
|
128
|
+
brier: number | null;
|
|
129
|
+
};
|
|
130
|
+
safety: {
|
|
131
|
+
backend: string;
|
|
132
|
+
isolationLevel: string;
|
|
133
|
+
};
|
|
134
|
+
decision: 'measured' | 'crash';
|
|
135
|
+
reason: string;
|
|
136
|
+
}
|
|
137
|
+
/** Exact two-sided binomial p (McNemar on discordant pairs). */
|
|
138
|
+
export declare function mcnemarExactP(aOnly: number, bOnly: number): {
|
|
139
|
+
p: number;
|
|
140
|
+
discordant: number;
|
|
141
|
+
};
|
|
142
|
+
/** Deterministic bootstrap (seeded PRNG so the CI is reproducible) of the
|
|
143
|
+
* mean task-level paired difference. Analysis unit = task; each task's
|
|
144
|
+
* diff = mean over seeds of (armX success) − mean over seeds of (armY). */
|
|
145
|
+
export declare function bootstrapPairedCI(diffs: number[], opts?: {
|
|
146
|
+
level?: number;
|
|
147
|
+
iters?: number;
|
|
148
|
+
seed?: number;
|
|
149
|
+
}): {
|
|
150
|
+
mean: number;
|
|
151
|
+
ciLo: number;
|
|
152
|
+
ciHi: number;
|
|
153
|
+
width: number;
|
|
154
|
+
nTasks: number;
|
|
155
|
+
};
|
|
156
|
+
export interface ComparisonResult {
|
|
157
|
+
comparison: string;
|
|
158
|
+
split: 'train' | 'protect';
|
|
159
|
+
armX: 'A' | 'B' | 'C';
|
|
160
|
+
armY: 'A' | 'B' | 'C';
|
|
161
|
+
rateX: number;
|
|
162
|
+
rateY: number;
|
|
163
|
+
diff: number;
|
|
164
|
+
bootstrap: ReturnType<typeof bootstrapPairedCI>;
|
|
165
|
+
mcnemarPerSeed: Array<{
|
|
166
|
+
seed: number;
|
|
167
|
+
p: number;
|
|
168
|
+
discordant: number;
|
|
169
|
+
xOnly: number;
|
|
170
|
+
yOnly: number;
|
|
171
|
+
}>;
|
|
172
|
+
discordantCells: number;
|
|
173
|
+
significant: boolean;
|
|
174
|
+
needsData: string[];
|
|
175
|
+
}
|
|
176
|
+
/** One arm-vs-arm comparison on one split. Records for the two arms must
|
|
177
|
+
* cover the same (seed, taskId) cells — the paired discipline. */
|
|
178
|
+
export declare function compareArms(records: RunRecord[], armX: 'A' | 'B' | 'C', armY: 'A' | 'B' | 'C', split: 'train' | 'protect'): ComparisonResult;
|
|
179
|
+
/** A-arm flip rate across seeds (the null/control variance estimate): share
|
|
180
|
+
* of tasks whose outcome is not identical across all null runs. */
|
|
181
|
+
export declare function nullFlux(records: RunRecord[], split: 'train' | 'protect'): {
|
|
182
|
+
flipRate: number;
|
|
183
|
+
tasks: number;
|
|
184
|
+
flippedTaskIds: string[];
|
|
185
|
+
};
|
|
186
|
+
/** The pre-registered promotion gate over the analysis inputs. */
|
|
187
|
+
export declare function evaluateGate(main: ComparisonResult, protectFallback: number, costRatioGrowth: number): {
|
|
188
|
+
verdict: 'PROMOTE' | 'REJECT' | 'NEEDS-DATA';
|
|
189
|
+
gates: Array<{
|
|
190
|
+
name: string;
|
|
191
|
+
pass: boolean;
|
|
192
|
+
detail: string;
|
|
193
|
+
}>;
|
|
194
|
+
reason: string;
|
|
195
|
+
};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/evolution - three-arm main experiment (Issue #22, P1, 2026-10-27)
|
|
3
|
+
*
|
|
4
|
+
* The question: does ONE persistable harness/skill change raise task
|
|
5
|
+
* success under a FROZEN base model, without breaking the protection set?
|
|
6
|
+
* Three arms, everything else pinned:
|
|
7
|
+
* A Baseline - the harness as-is (frozen skill-library snapshot)
|
|
8
|
+
* B Task-adaptive - per-task temporary adaptation block, discarded
|
|
9
|
+
* after the task (built from TRAIN failures only)
|
|
10
|
+
* C Persistent-evolved - the candidate skill, deployed only after passing
|
|
11
|
+
* the real evolution gate, injected everywhere
|
|
12
|
+
*
|
|
13
|
+
* This module is the PURE core: task selection, judging, injection
|
|
14
|
+
* building, and the pre-registered statistics. All model IO lives in the
|
|
15
|
+
* driver scripts. The promotion gate thresholds are constants here so the
|
|
16
|
+
* analysis cannot tune them after seeing results (pre-registration rule).
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
/* ---------------- pre-registered constants (frozen before any run) ---------------- */
|
|
20
|
+
export const EXPERIMENT_ID = 'three-arm-main-experiment-2026-10-27';
|
|
21
|
+
/** Family quotas for the train subset. Families are the first '-' segment of
|
|
22
|
+
* the bench case id. toolchain-* and reply-determinism are excluded (they
|
|
23
|
+
* need the live toolchain, i.e. an unfreezable environment). */
|
|
24
|
+
export const FAMILY_QUOTAS = { beh: 8, cjk: 6, adv: 6, train: 6, fadv: 3, teach: 3 };
|
|
25
|
+
/** beh + behave-* are one family (both are read-file-then-answer cases). */
|
|
26
|
+
export const FAMILY_ALIASES = { behave: 'beh' };
|
|
27
|
+
export const SEEDS = [1, 2, 3];
|
|
28
|
+
export const SHUFFLE_SEED_MATERIAL = EXPERIMENT_ID + ':exec-order';
|
|
29
|
+
/** gate split of the train subset: last 8 shuffled tasks become gate-val */
|
|
30
|
+
export const GATE_VAL_SIZE = 8;
|
|
31
|
+
/** Promotion gate (pre-registered; the task brief's suggested bars):
|
|
32
|
+
* significance = bootstrap 95% CI of the paired task-level diff excludes 0
|
|
33
|
+
* OR any per-seed exact McNemar p < 0.05 on the MAIN endpoint only
|
|
34
|
+
* (protect-set C-vs-A; the other three comparisons are exploratory).
|
|
35
|
+
* AND protect fallback <= 3pp, AND total-token growth <= 10%. */
|
|
36
|
+
export const GATE = {
|
|
37
|
+
ciLevel: 0.95,
|
|
38
|
+
mcnemarAlpha: 0.05,
|
|
39
|
+
maxProtectFallback: 0.03,
|
|
40
|
+
maxCostGrowth: 0.10,
|
|
41
|
+
minTasksPerSplit: 15,
|
|
42
|
+
minDiscordantCells: 8,
|
|
43
|
+
};
|
|
44
|
+
export function familyOf(id) {
|
|
45
|
+
const seg = id.split('-')[0] ?? id;
|
|
46
|
+
return FAMILY_ALIASES[seg] ?? seg;
|
|
47
|
+
}
|
|
48
|
+
/** Deterministic stratified selection: per family, seededOrder(quota). */
|
|
49
|
+
export function selectTasks(all, seed) {
|
|
50
|
+
const excluded = [];
|
|
51
|
+
const eligible = [];
|
|
52
|
+
for (const c of all) {
|
|
53
|
+
if (c.id.startsWith('toolchain-')) {
|
|
54
|
+
excluded.push({ id: c.id, why: 'needs the live HarmonyOS toolchain (unfreezable environment)' });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (c.id === 'reply-determinism') {
|
|
58
|
+
excluded.push({ id: c.id, why: 'not in a sampled family' });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
eligible.push(c.id);
|
|
62
|
+
}
|
|
63
|
+
const train = [];
|
|
64
|
+
for (const [fam, quota] of Object.entries(FAMILY_QUOTAS)) {
|
|
65
|
+
const pool = eligible.filter((id) => familyOf(id) === fam && !all.find((c) => c.id === id)?.holdout);
|
|
66
|
+
const picked = seededOrderStr(pool, seed).slice(0, quota);
|
|
67
|
+
if (picked.length < quota)
|
|
68
|
+
excluded.push({ id: `(${fam} family)`, why: `only ${picked.length}/${quota} train cases available` });
|
|
69
|
+
train.push(...picked);
|
|
70
|
+
}
|
|
71
|
+
train.sort();
|
|
72
|
+
const protect = eligible.filter((id) => all.find((c) => c.id === id)?.holdout).sort();
|
|
73
|
+
return { train, protect, excluded };
|
|
74
|
+
}
|
|
75
|
+
function seededOrderStr(items, seed) {
|
|
76
|
+
// mulberry32 from a string seed — same contract as evalset.seededOrder
|
|
77
|
+
let a = createHash('sha256').update(String(seed)).digest().readUInt32BE(0);
|
|
78
|
+
const rand = () => {
|
|
79
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
80
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
81
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
82
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
83
|
+
};
|
|
84
|
+
const arr = [...items];
|
|
85
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
86
|
+
const j = Math.floor(rand() * (i + 1));
|
|
87
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
88
|
+
}
|
|
89
|
+
return arr;
|
|
90
|
+
}
|
|
91
|
+
/** datasetHash: sha256 over the canonical registered task set (tasks sorted
|
|
92
|
+
* by id; the FULL assertion spec serialized into evalset's assertion.expect
|
|
93
|
+
* so the hash is content-sensitive to every mode, not just the primary). */
|
|
94
|
+
export function buildTaskSetHash(tasks) {
|
|
95
|
+
const rows = [...tasks]
|
|
96
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
97
|
+
.map((t) => ({ id: t.id, prompt: t.prompt, assertion: { kind: primaryKind(t), expect: JSON.stringify(t.assertion) }, holdout: t.split === 'protect' }));
|
|
98
|
+
const canonical = JSON.stringify({ kind: 'hmharness-eval-taskset', version: 1, id: 'three-arm-2026-10-27', revision: 1, tasks: rows });
|
|
99
|
+
return { setId: 'three-arm-2026-10-27', datasetHash: createHash('sha256').update(canonical).digest('hex'), canonical };
|
|
100
|
+
}
|
|
101
|
+
function primaryKind(t) {
|
|
102
|
+
if (t.assertion.expectExact)
|
|
103
|
+
return 'expect-exact';
|
|
104
|
+
if (t.assertion.expectRegex)
|
|
105
|
+
return 'expect-regex';
|
|
106
|
+
if (t.assertion.expectAny?.length)
|
|
107
|
+
return 'expect-any';
|
|
108
|
+
if (t.assertion.expectNone?.length)
|
|
109
|
+
return 'expect-none';
|
|
110
|
+
return 'expect';
|
|
111
|
+
}
|
|
112
|
+
/* ---------------- judging (environment-truth, never model self-report) ---------------- */
|
|
113
|
+
/** Same fence convention as bench.matchCase: assertions test the fenced
|
|
114
|
+
* inner body when the reply is a fenced block. Re-implemented here (not
|
|
115
|
+
* imported) so the judge is a self-contained, unit-testable unit. */
|
|
116
|
+
export function fenceInner(output) {
|
|
117
|
+
const m = output.match(/```[a-zA-Z]*[ \t]*\r?\n([\s\S]*?)(?:\r?\n)?```/);
|
|
118
|
+
return m ? m[1] : output;
|
|
119
|
+
}
|
|
120
|
+
const DONE_RE = /(已完成|完成|done|已处理|passed|成功)/i;
|
|
121
|
+
export function judgeOutput(output, t) {
|
|
122
|
+
const body = fenceInner(output);
|
|
123
|
+
const a = t.assertion;
|
|
124
|
+
if (a.expectExact !== undefined && body.trim() !== a.expectExact.trim()) {
|
|
125
|
+
return { pass: false, detail: `exact mismatch: got "${body.trim().slice(0, 80)}"`, falseCompletion: DONE_RE.test(body) };
|
|
126
|
+
}
|
|
127
|
+
if (a.expectRegex !== undefined) {
|
|
128
|
+
try {
|
|
129
|
+
if (!new RegExp(a.expectRegex).test(body))
|
|
130
|
+
return { pass: false, detail: `regex mismatch: /${a.expectRegex.slice(0, 60)}/`, falseCompletion: DONE_RE.test(body) };
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return { pass: false, detail: `invalid regex in case: ${a.expectRegex.slice(0, 40)}`, falseCompletion: false };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (a.expectNone && a.expectNone.some((e) => body.toLowerCase().includes(e.toLowerCase()))) {
|
|
137
|
+
return { pass: false, detail: `forbidden marker present: ${a.expectNone.join(' && ')}`, falseCompletion: DONE_RE.test(body) };
|
|
138
|
+
}
|
|
139
|
+
if (a.expectAny && !a.expectAny.some((e) => body.toLowerCase().includes(e.toLowerCase()))) {
|
|
140
|
+
return { pass: false, detail: `none of the allowed markers found: ${a.expectAny.join(' || ')}`, falseCompletion: DONE_RE.test(body) };
|
|
141
|
+
}
|
|
142
|
+
const missing = a.expect.filter((e) => !body.toLowerCase().includes(e.toLowerCase()));
|
|
143
|
+
if (missing.length > 0) {
|
|
144
|
+
return { pass: false, detail: `missing "${missing.join('" && "')}" in output`, falseCompletion: DONE_RE.test(body) };
|
|
145
|
+
}
|
|
146
|
+
return { pass: true, detail: 'ok', falseCompletion: false };
|
|
147
|
+
}
|
|
148
|
+
/** The A-arm / gate-baseline injection: the frozen skill-library snapshot
|
|
149
|
+
* (same shape as skills.skillsToPrompt). */
|
|
150
|
+
export function snapshotToPrompt(entries) {
|
|
151
|
+
if (entries.length === 0)
|
|
152
|
+
return '';
|
|
153
|
+
return entries.map((s) => `- ${s.name}: ${s.description || '(no description)'}`).join('\n');
|
|
154
|
+
}
|
|
155
|
+
/** The C-arm / gate-candidate injection: snapshot + the candidate draft,
|
|
156
|
+
* byte-identical in construction to evolve.ts's candidateInjection. */
|
|
157
|
+
export function candidateInjection(snapshot, draftName, draftMd) {
|
|
158
|
+
const base = snapshotToPrompt(snapshot);
|
|
159
|
+
const draftBlock = `## Draft skill under evaluation: ${draftName}\n\n${draftMd.slice(0, 4000)}`;
|
|
160
|
+
return base ? `${base}\n${draftBlock}` : draftBlock;
|
|
161
|
+
}
|
|
162
|
+
/** The B-arm adaptation block: deterministic lessons from TRAIN-only null
|
|
163
|
+
* failures of the SAME family (never protect tasks — no leakage), max 3.
|
|
164
|
+
* Task-scoped by construction: the driver injects it into this task's
|
|
165
|
+
* system prompt only and drops it afterwards. */
|
|
166
|
+
export function buildAdaptiveBlock(family, lessons) {
|
|
167
|
+
if (lessons.length === 0)
|
|
168
|
+
return '';
|
|
169
|
+
const lines = lessons.slice(0, 3).map((l, i) => `${i + 1}. Task ${l.taskId} asked: "${l.promptHead}" — the attempt replied "${l.failedOutputHead}" and FAILED its assertion (${l.failureDetail}).`);
|
|
170
|
+
return [
|
|
171
|
+
'## Task adaptation (temporary — this task only, not persisted)',
|
|
172
|
+
`Lessons from failed attempts at similar tasks (family: ${family}):`,
|
|
173
|
+
...lines,
|
|
174
|
+
'Apply the lesson: make the final reply satisfy exactly what the task asks for.',
|
|
175
|
+
].join('\n');
|
|
176
|
+
}
|
|
177
|
+
/* ---------------- statistics ---------------- */
|
|
178
|
+
/** Exact two-sided binomial p (McNemar on discordant pairs). */
|
|
179
|
+
export function mcnemarExactP(aOnly, bOnly) {
|
|
180
|
+
const n = aOnly + bOnly;
|
|
181
|
+
if (n === 0)
|
|
182
|
+
return { p: 1, discordant: 0 };
|
|
183
|
+
const k = Math.min(aOnly, bOnly);
|
|
184
|
+
const logChoose = (nn, kk) => {
|
|
185
|
+
let s = 0;
|
|
186
|
+
for (let i = 1; i <= kk; i++)
|
|
187
|
+
s += Math.log(nn - kk + i) - Math.log(i);
|
|
188
|
+
return s;
|
|
189
|
+
};
|
|
190
|
+
const pmf = (i) => Math.exp(logChoose(n, i) + i * Math.log(0.5) + (n - i) * Math.log(0.5));
|
|
191
|
+
const pk = pmf(k);
|
|
192
|
+
let sum = 0;
|
|
193
|
+
for (let i = 0; i <= n; i++)
|
|
194
|
+
if (pmf(i) <= pk + 1e-12)
|
|
195
|
+
sum += pmf(i);
|
|
196
|
+
return { p: Math.min(1, Number(sum.toFixed(6))), discordant: n };
|
|
197
|
+
}
|
|
198
|
+
/** Deterministic bootstrap (seeded PRNG so the CI is reproducible) of the
|
|
199
|
+
* mean task-level paired difference. Analysis unit = task; each task's
|
|
200
|
+
* diff = mean over seeds of (armX success) − mean over seeds of (armY). */
|
|
201
|
+
export function bootstrapPairedCI(diffs, opts = {}) {
|
|
202
|
+
const iters = opts.iters ?? 10_000;
|
|
203
|
+
const level = opts.level ?? 0.95;
|
|
204
|
+
let a = createHash('sha256').update(String(opts.seed ?? 0)).digest().readUInt32BE(0);
|
|
205
|
+
const rand = () => {
|
|
206
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
207
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
208
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
209
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
210
|
+
};
|
|
211
|
+
const mean = diffs.reduce((s, x) => s + x, 0) / diffs.length;
|
|
212
|
+
const means = [];
|
|
213
|
+
for (let i = 0; i < iters; i++) {
|
|
214
|
+
let s = 0;
|
|
215
|
+
for (let j = 0; j < diffs.length; j++)
|
|
216
|
+
s += diffs[Math.floor(rand() * diffs.length)];
|
|
217
|
+
means.push(s / diffs.length);
|
|
218
|
+
}
|
|
219
|
+
means.sort((x, y) => x - y);
|
|
220
|
+
const lo = means[Math.floor(((1 - level) / 2) * iters)];
|
|
221
|
+
const hi = means[Math.ceil((1 + level) / 2 * iters) - 1];
|
|
222
|
+
return { mean: Number(mean.toFixed(4)), ciLo: Number(lo.toFixed(4)), ciHi: Number(hi.toFixed(4)), width: Number((hi - lo).toFixed(4)), nTasks: diffs.length };
|
|
223
|
+
}
|
|
224
|
+
/** One arm-vs-arm comparison on one split. Records for the two arms must
|
|
225
|
+
* cover the same (seed, taskId) cells — the paired discipline. */
|
|
226
|
+
export function compareArms(records, armX, armY, split) {
|
|
227
|
+
const xs = new Map(records.filter((r) => r.arm === armX && r.holdoutClass === split && r.decision === 'measured').map((r) => [`${r.seed}:${r.taskId}`, r.outcome.success === true]));
|
|
228
|
+
const ys = new Map(records.filter((r) => r.arm === armY && r.holdoutClass === split && r.decision === 'measured').map((r) => [`${r.seed}:${r.taskId}`, r.outcome.success === true]));
|
|
229
|
+
const cellKeys = [...xs.keys()].filter((k) => ys.has(k));
|
|
230
|
+
const byTask = new Map();
|
|
231
|
+
const mcnemarPerSeed = [];
|
|
232
|
+
for (const seed of SEEDS) {
|
|
233
|
+
let xOnly = 0, yOnly = 0;
|
|
234
|
+
for (const k of cellKeys) {
|
|
235
|
+
const [s, task] = k.split(':');
|
|
236
|
+
if (Number(s) !== seed)
|
|
237
|
+
continue;
|
|
238
|
+
const x = xs.get(k), y = ys.get(k);
|
|
239
|
+
if (x && !y)
|
|
240
|
+
xOnly++;
|
|
241
|
+
else if (!x && y)
|
|
242
|
+
yOnly++;
|
|
243
|
+
const t = byTask.get(task) ?? { x: [], y: [] };
|
|
244
|
+
t.x.push(x ? 1 : 0);
|
|
245
|
+
t.y.push(y ? 1 : 0);
|
|
246
|
+
byTask.set(task, t);
|
|
247
|
+
}
|
|
248
|
+
const { p, discordant } = mcnemarExactP(xOnly, yOnly);
|
|
249
|
+
mcnemarPerSeed.push({ seed, p, discordant, xOnly, yOnly });
|
|
250
|
+
}
|
|
251
|
+
const diffs = [...byTask.values()].map((t) => avg(t.x) - avg(t.y));
|
|
252
|
+
const rateX = cellKeys.filter((k) => xs.get(k)).length / Math.max(1, cellKeys.length);
|
|
253
|
+
const rateY = cellKeys.filter((k) => ys.get(k)).length / Math.max(1, cellKeys.length);
|
|
254
|
+
const bootstrap = bootstrapPairedCI(diffs, { level: GATE.ciLevel, seed: 42 });
|
|
255
|
+
const discordantCells = mcnemarPerSeed.reduce((s, m) => s + m.discordant, 0);
|
|
256
|
+
const needsData = [];
|
|
257
|
+
if (diffs.length < GATE.minTasksPerSplit)
|
|
258
|
+
needsData.push(`only ${diffs.length} paired tasks (<${GATE.minTasksPerSplit})`);
|
|
259
|
+
if (discordantCells < GATE.minDiscordantCells)
|
|
260
|
+
needsData.push(`only ${discordantCells} discordant cells (<${GATE.minDiscordantCells}) — exact test underpowered`);
|
|
261
|
+
const significant = bootstrap.ciLo > 0 || bootstrap.ciHi < 0 || mcnemarPerSeed.some((m) => m.p < GATE.mcnemarAlpha);
|
|
262
|
+
return {
|
|
263
|
+
comparison: `${armX}-vs-${armY}`,
|
|
264
|
+
split, armX, armY,
|
|
265
|
+
rateX: Number(rateX.toFixed(4)), rateY: Number(rateY.toFixed(4)),
|
|
266
|
+
diff: Number((rateX - rateY).toFixed(4)),
|
|
267
|
+
bootstrap, mcnemarPerSeed, discordantCells, significant, needsData,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function avg(xs) { return xs.reduce((s, x) => s + x, 0) / xs.length; }
|
|
271
|
+
/** A-arm flip rate across seeds (the null/control variance estimate): share
|
|
272
|
+
* of tasks whose outcome is not identical across all null runs. */
|
|
273
|
+
export function nullFlux(records, split) {
|
|
274
|
+
const byTask = new Map();
|
|
275
|
+
for (const r of records) {
|
|
276
|
+
if (r.arm !== 'A' || r.holdoutClass !== split)
|
|
277
|
+
continue;
|
|
278
|
+
const s = byTask.get(r.taskId) ?? new Set();
|
|
279
|
+
s.add(r.outcome.success === true);
|
|
280
|
+
byTask.set(r.taskId, s);
|
|
281
|
+
}
|
|
282
|
+
const flipped = [...byTask.entries()].filter(([, s]) => s.size > 1).map(([id]) => id);
|
|
283
|
+
return { flipRate: byTask.size ? Number((flipped.length / byTask.size).toFixed(4)) : 0, tasks: byTask.size, flippedTaskIds: flipped.sort() };
|
|
284
|
+
}
|
|
285
|
+
/** The pre-registered promotion gate over the analysis inputs. */
|
|
286
|
+
export function evaluateGate(main, protectFallback, costRatioGrowth) {
|
|
287
|
+
const gates = [];
|
|
288
|
+
const supportOK = main.needsData.length === 0;
|
|
289
|
+
gates.push({ name: 'significance (95% CI excludes 0 OR exact McNemar p<0.05)', pass: main.significant && supportOK, detail: `CI=[${main.bootstrap.ciLo},${main.bootstrap.ciHi}] diff=${main.diff}; McNemar per seed: ${main.mcnemarPerSeed.map((m) => `p=${m.p}`).join(', ')}` });
|
|
290
|
+
gates.push({ name: `protect fallback <= ${(GATE.maxProtectFallback * 100).toFixed(0)}pp`, pass: protectFallback >= -GATE.maxProtectFallback, detail: `protect diff (C-A) = ${protectFallback}` });
|
|
291
|
+
gates.push({ name: `cost growth <= ${(GATE.maxCostGrowth * 100).toFixed(0)}%`, pass: costRatioGrowth <= GATE.maxCostGrowth, detail: `token growth = ${(costRatioGrowth * 100).toFixed(2)}%` });
|
|
292
|
+
const allPass = gates.every((g) => g.pass);
|
|
293
|
+
const verdict = !supportOK ? 'NEEDS-DATA' : allPass ? 'PROMOTE' : 'REJECT';
|
|
294
|
+
return { verdict, gates, reason: supportOK ? gates.filter((g) => !g.pass).map((g) => `${g.name}: ${g.detail}`).join('; ') || 'all gates passed' : `low support: ${main.needsData.join('; ')}` };
|
|
295
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hmharness/evolution",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.45",
|
|
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,8 +15,8 @@
|
|
|
15
15
|
"build": "tsc -p tsconfig.build.json"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@hmharness/cognitive": "0.23.
|
|
19
|
-
"@hmharness/kernel": "0.23.
|
|
18
|
+
"@hmharness/cognitive": "0.23.45",
|
|
19
|
+
"@hmharness/kernel": "0.23.45"
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
22
22
|
"dist"
|