mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 12:15:29 +02:00
235 lines
10 KiB
JavaScript
235 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
const { execFileSync } = require('child_process');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..', '..');
|
|
const HOOK_FILE = path.join(REPO_ROOT, 'scripts', 'hooks', 'gateguard-fact-force.js');
|
|
const DEFAULT_BASELINE = 'upstream/main';
|
|
const DEFAULT_RUNS = 30;
|
|
const BOOTSTRAP_ROUNDS = 4000;
|
|
|
|
// --- statistics ---
|
|
|
|
/** Linear-interpolated quantile of a numeric sample. */
|
|
function quantile(values, p) {
|
|
const sorted = values.slice().sort((a, b) => a - b);
|
|
if (sorted.length === 0) return NaN;
|
|
const at = (sorted.length - 1) * p;
|
|
const lo = Math.floor(at);
|
|
const hi = Math.ceil(at);
|
|
return sorted[lo] + (sorted[hi] - sorted[lo]) * (at - lo);
|
|
}
|
|
|
|
function xorshift(seed) {
|
|
let state = seed >>> 0 || 1;
|
|
return n => {
|
|
state ^= state << 13;
|
|
state >>>= 0;
|
|
state ^= state >>> 17;
|
|
state ^= state << 5;
|
|
state >>>= 0;
|
|
return Math.floor((state / 4294967296) * n);
|
|
};
|
|
}
|
|
|
|
/** 95% percentile-bootstrap interval of quantile(b, p) - quantile(a, p); deterministic for a given seed. */
|
|
function bootstrapDifference(a, b, p, { rounds = BOOTSTRAP_ROUNDS, seed = 0x9e3779b9 } = {}) {
|
|
const random = xorshift(seed);
|
|
const resample = values => values.map(() => values[random(values.length)]);
|
|
const differences = [];
|
|
for (let i = 0; i < rounds; i++) differences.push(quantile(resample(b), p) - quantile(resample(a), p));
|
|
differences.sort((x, y) => x - y);
|
|
return [differences[Math.floor(rounds * 0.025)], differences[Math.ceil(rounds * 0.975) - 1]];
|
|
}
|
|
|
|
function verdict([low, high]) {
|
|
if (low > 0) return 'slower';
|
|
if (high < 0) return 'faster';
|
|
return 'no measurable change';
|
|
}
|
|
|
|
// --- fixture ---
|
|
|
|
function buildFixture(workDir) {
|
|
const root = fs.realpathSync(fs.mkdtempSync(path.join(workDir, 'project-')));
|
|
fs.mkdirSync(path.join(root, 'src'));
|
|
fs.writeFileSync(path.join(root, 'src', 'widget.js'), 'function widgetValue(x) {\n return x;\n}\n// note\n'.repeat(50));
|
|
let big = '';
|
|
for (let i = 0; i < 4000; i++) {
|
|
big += `// helper ${i}\nfunction helper${i}(a, b) {\n const s = "value ${i}"; /* note */\n return a + b * ${i};\n}\n`;
|
|
}
|
|
fs.writeFileSync(path.join(root, 'src', 'big.js'), `${big}function target(x) {\n return x;\n}\n`);
|
|
fs.writeFileSync(path.join(root, 'CLAUDE.md'), '# Rules\n\n- be careful\n');
|
|
const transcript = path.join(root, 'session.jsonl');
|
|
const records = [{ type: 'user', uuid: 'u1', promptId: 'p1', message: { role: 'user', content: 'fix the widget' } }];
|
|
for (let i = 0; i < 20; i++) {
|
|
records.push({
|
|
type: 'assistant',
|
|
uuid: `a${i}`,
|
|
message: { id: `m${i}`, role: 'assistant', content: [{ type: 'tool_use', id: `t${i}`, name: 'Bash', input: { command: `rg -g '!*.md' other${i} .` } }] }
|
|
});
|
|
records.push({ type: 'user', uuid: `r${i}`, promptId: 'p1', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: `t${i}`, content: 'ok' }] } });
|
|
}
|
|
fs.writeFileSync(transcript, `${records.map(record => JSON.stringify(record)).join('\n')}\n`);
|
|
const base = { session_id: 'latency', transcript_path: transcript, cwd: root, hook_event_name: 'PreToolUse', tool_use_id: 'pending' };
|
|
const edit = (file, oldString, newString, extra = {}) => ({
|
|
...base,
|
|
tool_name: 'Edit',
|
|
tool_input: { file_path: path.join(root, file), old_string: oldString, new_string: newString, ...extra }
|
|
});
|
|
const bash = command => ({ ...base, tool_name: 'Bash', tool_input: { command } });
|
|
const cases = [
|
|
['Shell command, first of session', bash('npm test'), []],
|
|
['Shell command, routine gate passed', bash('npm run build'), [bash('npm test')]],
|
|
['Edit of a file already checked', edit('src/widget.js', ' return x;', ' return x + 1;'), [edit('src/widget.js', ' return x;', ' return x + 1;')]],
|
|
['First edit, code, denied', edit('src/widget.js', ' return x;', ' return x + 1;'), []],
|
|
['First edit, comment-only pass', edit('src/widget.js', '// note', '// note!', { replace_all: true }), []],
|
|
['First edit, CLAUDE.md, denied', edit('CLAUDE.md', '- be careful', '- be very careful'), []],
|
|
['First edit, 406 KiB file, denied', edit('src/big.js', ' return x;', ' return x + 1;'), []]
|
|
];
|
|
return { root, cases };
|
|
}
|
|
|
|
// --- measurement ---
|
|
|
|
const SETUP_PROBE = 'const hook = require(process.argv[1]); for (const step of JSON.parse(process.argv[2])) hook.run(JSON.stringify(step));';
|
|
const TIMED_PROBE = [
|
|
"const { performance } = require('perf_hooks');",
|
|
'const started = performance.now();',
|
|
'require(process.argv[1]).run(process.argv[2]);',
|
|
'process.stdout.write(String(performance.now() - started));'
|
|
].join('\n');
|
|
|
|
function sampleOnce(hookFile, payload, setup, root, workDir) {
|
|
const stateDir = fs.mkdtempSync(path.join(workDir, 'state-'));
|
|
try {
|
|
const env = { PATH: process.env.PATH || '', HOME: root, USERPROFILE: root, GATEGUARD_STATE_DIR: stateDir, CLAUDE_PROJECT_DIR: root };
|
|
if (process.env.SystemRoot) env.SystemRoot = process.env.SystemRoot;
|
|
if (setup.length > 0) execFileSync(process.execPath, ['-e', SETUP_PROBE, hookFile, JSON.stringify(setup)], { env, stdio: 'ignore' });
|
|
return Number(execFileSync(process.execPath, ['-e', TIMED_PROBE, hookFile, JSON.stringify(payload)], { env, encoding: 'utf8' }));
|
|
} finally {
|
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/** Fresh-process require()+run() samples per call type and hook, interleaved in a shuffled order each round. */
|
|
function measure(hooks, { runs = DEFAULT_RUNS, seed = 7, onRound = () => {} } = {}) {
|
|
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-latency-'));
|
|
try {
|
|
const { root, cases } = buildFixture(workDir);
|
|
const random = xorshift(seed);
|
|
const samples = Object.fromEntries(cases.map(([name]) => [name, Object.fromEntries(hooks.map(hook => [hook.label, []]))]));
|
|
for (let round = 0; round < runs; round++) {
|
|
for (const [name, payload, setup] of cases) {
|
|
const order = hooks.slice();
|
|
for (let i = order.length - 1; i > 0; i--) {
|
|
const j = random(i + 1);
|
|
[order[i], order[j]] = [order[j], order[i]];
|
|
}
|
|
for (const hook of order) samples[name][hook.label].push(sampleOnce(hook.file, payload, setup, root, workDir));
|
|
}
|
|
onRound(round + 1);
|
|
}
|
|
return samples;
|
|
} finally {
|
|
fs.rmSync(workDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/** p50/p90 per hook and the bootstrap interval of each baseline-to-working-tree difference. */
|
|
function summarize(samples, labels) {
|
|
const [working, ...baselines] = labels;
|
|
return Object.entries(samples).map(([name, byHook]) => ({
|
|
name,
|
|
quantiles: Object.fromEntries(labels.map(label => [label, { p50: quantile(byHook[label], 0.5), p90: quantile(byHook[label], 0.9) }])),
|
|
comparisons: baselines.map(baseline => {
|
|
const p50 = bootstrapDifference(byHook[baseline], byHook[working], 0.5);
|
|
const p90 = bootstrapDifference(byHook[baseline], byHook[working], 0.9);
|
|
return { baseline, p50, p90, p50Verdict: verdict(p50), p90Verdict: verdict(p90) };
|
|
})
|
|
}));
|
|
}
|
|
|
|
function renderMarkdown(rows, labels, runs) {
|
|
const fmt = value => value.toFixed(1);
|
|
const lines = [
|
|
`${runs} fresh processes per call type and hook, interleaved; require() + run() in ms; 95% bootstrap intervals of the working tree minus the baseline.`,
|
|
'',
|
|
`| Call | ${labels.map(label => `${label} p50 / p90`).join(' | ')} |`,
|
|
`| --- | ${labels.map(() => '---:').join(' | ')} |`,
|
|
...rows.map(row => `| ${row.name} | ${labels.map(label => `${fmt(row.quantiles[label].p50)} / ${fmt(row.quantiles[label].p90)}`).join(' | ')} |`),
|
|
''
|
|
];
|
|
for (const baseline of labels.slice(1)) {
|
|
lines.push(`| Call | p50 change vs ${baseline} | p90 change vs ${baseline} |`, '| --- | --- | --- |');
|
|
for (const row of rows) {
|
|
const c = row.comparisons.find(entry => entry.baseline === baseline);
|
|
lines.push(`| ${row.name} | [${fmt(c.p50[0])}, ${fmt(c.p50[1])}] ${c.p50Verdict} | [${fmt(c.p90[0])}, ${fmt(c.p90[1])}] ${c.p90Verdict} |`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
return `${lines.join('\n')}`;
|
|
}
|
|
|
|
// --- CLI ---
|
|
|
|
const USAGE = [
|
|
'Usage: node scripts/dev/gateguard-latency.js [--baseline <ref>]... [--runs <n>] [--json]',
|
|
'',
|
|
' --baseline <ref> git ref whose hook is compared with the working tree (repeatable;',
|
|
` default ${DEFAULT_BASELINE})`,
|
|
` --runs <n> fresh processes per call type and hook (default ${DEFAULT_RUNS})`,
|
|
' --json print samples and summary as JSON'
|
|
].join('\n');
|
|
|
|
function parseArgs(argv) {
|
|
const options = { baselines: [], runs: DEFAULT_RUNS, format: 'markdown', help: false };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg === '--json') options.format = 'json';
|
|
else if (arg === '--help' || arg === '-h') options.help = true;
|
|
else if (arg === '--baseline' && i + 1 < argv.length) options.baselines.push(argv[++i]);
|
|
else if (arg === '--runs' && i + 1 < argv.length && /^[1-9]\d*$/.test(argv[i + 1])) options.runs = Number(argv[++i]);
|
|
else throw new Error(`unknown or incomplete argument: ${arg}\n\n${USAGE}`);
|
|
}
|
|
if (options.baselines.length === 0) options.baselines.push(DEFAULT_BASELINE);
|
|
return options;
|
|
}
|
|
|
|
function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
process.stdout.write(`${USAGE}\n`);
|
|
return;
|
|
}
|
|
const { materializeHook } = require('./gateguard-eval');
|
|
const materialized = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-latency-hooks-'));
|
|
try {
|
|
const hooks = [{ label: 'working tree', file: HOOK_FILE }].concat(
|
|
options.baselines.map((ref, index) => ({ label: ref, file: materializeHook(ref, path.join(materialized, String(index))) }))
|
|
);
|
|
const samples = measure(hooks, { runs: options.runs, onRound: round => process.stderr.write(`\rround ${round}/${options.runs}`) });
|
|
process.stderr.write('\n');
|
|
const labels = hooks.map(hook => hook.label);
|
|
const rows = summarize(samples, labels);
|
|
process.stdout.write(options.format === 'json' ? `${JSON.stringify({ runs: options.runs, samples, rows }, null, 2)}\n` : `${renderMarkdown(rows, labels, options.runs)}\n`);
|
|
} finally {
|
|
fs.rmSync(materialized, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
process.stderr.write(`[gateguard-latency] ${error.message}\n`);
|
|
process.exitCode = 2;
|
|
}
|
|
}
|
|
|
|
module.exports = { quantile, bootstrapDifference, verdict, summarize, measure, parseArgs };
|