Files
ruvnet__ruflo/scripts/bench-agenticow.mjs
rUv 35ac888ebf feat(mcp): integrate agenticow@~0.2.3 — COW memory branching (4 MCP tools) (#2500)
* feat(mcp): integrate agenticow@~0.2.3 — Copy-On-Write memory branching (4 MCP tools)

Adds 4 MCP tools wrapping agenticow's COW vector primitives:
  - agenticow_branch     fork a base .rvf at ~162 bytes regardless of size
  - agenticow_checkpoint freeze a restore point on a memory file
  - agenticow_rollback   discard edits since most recent checkpoint
  - agenticow_promote    merge a branch's edits into a base

Architectural rule (mirrors ADR-150 metaharness pattern):
  agenticow is in optionalDependencies — never a hard runtime dep.
  Missing package → `{success: true, degraded: true, reason: 'agenticow-not-found'}`.

Motivating use case: the v3.14.4 release uncovered a 3.3 GB tarball-bloat
regression where Darwin loops' git-worktree-per-agent pattern accumulated
full-copy snapshots. Measured agenticow branches are exactly 162 bytes
regardless of base size — the structural fix.

Verified perf vs published claims (docs/agenticow/findings.md):
  ✅ 162-byte branches — confirmed exact at N=1k, 10k, 50k
  ✅ 3,200×–180,000× smaller than full-copy across N
  ❌ "0.5ms branch" — measured ~10ms (fixed cost, not size-proportional)
  ❌ "83× faster" — crossover ≈ N=30k; below that, full-copy wins

Tests: 7/7 in __tests__/agenticow-tools.test.ts
Smoke: 8/8 in scripts/smoke-agenticow.sh (registration + end-to-end cycle)

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* fix(mcp): comply with ADR-112 tool-discoverability lint on agenticow tools

The audit-tool-descriptions.mjs regex only captures the first quoted string
in a `'foo ' + 'bar'` concat, so my multi-line descriptions hid the "Use when"
guidance from the linter. Collapsed all 4 agenticow tool descriptions to single
string literals; ADR-112 audit now reports 345/345 pass.

No behavior change — descriptions are byte-identical at runtime.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni
2026-06-29 11:59:20 -04:00

200 lines
7.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// bench-agenticow.mjs — verify agenticow's published perf claims against ruflo's
// reality. Measures branch latency + size vs full-copy snapshot at N ∈ {1k, 10k, 100k}.
//
// Published claims (agenticow@0.2.3):
// - "Branch in ~0.5ms / 162 bytes regardless of base size"
// - "83x faster, 3000x smaller than full-copy snapshots"
//
// We measure both halves. CLAUDE.md source-of-truth rule: replace unverified
// perf numbers with measured ones; THIS is the measurement.
//
// Usage:
// node scripts/bench-agenticow.mjs # default: 1k,10k,100k
// node scripts/bench-agenticow.mjs --sizes 1000,5000 # custom
// node scripts/bench-agenticow.mjs --dim 384 # custom dim (default 128)
import { open, openBase, AgenticMemory } from 'agenticow';
import { mkdirSync, rmSync, writeFileSync, statSync, copyFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { performance } from 'node:perf_hooks';
import { execSync } from 'node:child_process';
function parseArgs() {
const args = { sizes: [1000, 10000, 100000], dim: 128, runs: 5 };
for (let i = 2; i < process.argv.length; i++) {
const a = process.argv[i];
if (a === '--sizes') args.sizes = process.argv[++i].split(',').map(Number);
else if (a.startsWith('--sizes=')) args.sizes = a.slice(8).split(',').map(Number);
else if (a === '--dim') args.dim = Number(process.argv[++i]);
else if (a.startsWith('--dim=')) args.dim = Number(a.slice(6));
else if (a === '--runs') args.runs = Number(process.argv[++i]);
else if (a.startsWith('--runs=')) args.runs = Number(a.slice(7));
}
return args;
}
function randVec(d) {
const v = new Float32Array(d);
for (let i = 0; i < d; i++) v[i] = Math.random() * 2 - 1;
// L2-normalize (matches typical embedding output)
let s = 0;
for (let i = 0; i < d; i++) s += v[i] * v[i];
s = Math.sqrt(s);
if (s > 0) for (let i = 0; i < d; i++) v[i] /= s;
return v;
}
function dirSize(path) {
// Sum file sizes in a directory tree (where agenticow stores the .rvf bundle)
if (!existsSync(path)) return 0;
const st = statSync(path);
if (st.isFile()) return st.size;
let total = 0;
for (const e of readdirSync(path)) total += dirSize(join(path, e));
return total;
}
const ARGS = parseArgs();
const tmp = join(tmpdir(), `agenticow-bench-${Date.now()}`);
mkdirSync(tmp, { recursive: true });
const results = {
schema: 1,
bench: 'agenticow-vs-full-copy',
agenticowVersion: JSON.parse(
await import('node:fs').then(fs => fs.promises.readFile('node_modules/agenticow/package.json', 'utf8'))
).version,
ts: new Date().toISOString(),
dim: ARGS.dim,
runsPerSize: ARGS.runs,
bySize: {},
};
for (const N of ARGS.sizes) {
console.error(`\n[bench] N=${N} dim=${ARGS.dim}`);
const baseFile = join(tmp, `base-${N}.rvf`);
// --- Build base memory ---
const buildStart = performance.now();
const base = await open(baseFile, { dimension: ARGS.dim });
// Ingest in chunks to avoid huge intermediate arrays
const CHUNK = 1000;
for (let i = 0; i < N; i += CHUNK) {
const chunkSize = Math.min(CHUNK, N - i);
const chunk = [];
for (let j = 0; j < chunkSize; j++) chunk.push({ id: i + j, vector: randVec(ARGS.dim) });
base.ingest(chunk);
}
await base.close?.();
const buildMs = performance.now() - buildStart;
const baseBytes = dirSize(baseFile);
console.error(` base built: ${buildMs.toFixed(0)} ms, ${(baseBytes / 1024 / 1024).toFixed(1)} MB`);
// --- Measure agenticow branch latency + size (RUNS times) ---
// Open base ONCE — measuring fork() alone, not reopen overhead
const cowBranchMs = [];
const cowBranchBytes = [];
const cowQueryMs = [];
const baseForFork = await open(baseFile, { dimension: ARGS.dim });
for (let r = 0; r < ARGS.runs; r++) {
const branchPath = `${baseFile}.cow-${r}.rvf`;
const t0 = performance.now();
const branch = await baseForFork.fork(`branch-${r}`, branchPath);
cowBranchMs.push(performance.now() - t0);
cowBranchBytes.push(dirSize(branchPath));
// Measure query latency through the COW chain (parent ∪ edits, child wins)
const q = randVec(ARGS.dim);
const qt0 = performance.now();
await branch.query(q, 10);
cowQueryMs.push(performance.now() - qt0);
await branch.close?.();
}
await baseForFork.close?.();
// --- Measure full-copy snapshot latency + size (RUNS times) ---
const copyMs = [];
const copyBytes = [];
for (let r = 0; r < ARGS.runs; r++) {
const copyPath = `${baseFile}.copy-${r}.rvf`;
const t0 = performance.now();
// Copy the .rvf bundle/file. If it's a directory, recursive copy.
const st = statSync(baseFile);
if (st.isFile()) {
copyFileSync(baseFile, copyPath);
} else {
// recursive copy
execSync(`cp -R ${JSON.stringify(baseFile)} ${JSON.stringify(copyPath)}`);
}
copyMs.push(performance.now() - t0);
copyBytes.push(dirSize(copyPath));
}
const median = (arr) => {
const s = [...arr].sort((a, b) => a - b);
return s[Math.floor(s.length / 2)];
};
const cowBranchMsMedian = median(cowBranchMs);
const cowBranchBytesMedian = median(cowBranchBytes);
const copyMsMedian = median(copyMs);
const copyBytesMedian = median(copyBytes);
const entry = {
n: N,
baseBuildMs: Math.round(buildMs),
baseBytes,
agenticow: {
branchMsMedian: Number(cowBranchMsMedian.toFixed(3)),
branchMsAll: cowBranchMs.map(x => Number(x.toFixed(3))),
branchBytesMedian: cowBranchBytesMedian,
branchBytesAll: cowBranchBytes,
queryMsMedian: Number(median(cowQueryMs).toFixed(3)),
queryMsAll: cowQueryMs.map(x => Number(x.toFixed(3))),
},
fullCopy: {
copyMsMedian: Number(copyMsMedian.toFixed(3)),
copyBytesMedian: copyBytesMedian,
},
ratios: {
speedup: Number((copyMsMedian / Math.max(cowBranchMsMedian, 0.001)).toFixed(2)),
sizeReduction: Number((copyBytesMedian / Math.max(cowBranchBytesMedian, 1)).toFixed(2)),
},
publishedClaim: {
branchMsTarget: 0.5,
branchBytesTarget: 162,
speedupClaim: 83,
sizeReductionClaim: 3000,
},
verdict: {
branchMsHit: cowBranchMsMedian <= 2.0, // 4x tolerance vs 0.5ms claim
branchBytesHit: cowBranchBytesMedian <= 1024, // 6x tolerance vs 162 byte claim
speedupHit: (copyMsMedian / Math.max(cowBranchMsMedian, 0.001)) >= 10, // 10x is impressive; 83x is the claim
sizeReductionHit: (copyBytesMedian / Math.max(cowBranchBytesMedian, 1)) >= 100, // 100x is impressive; 3000x is the claim
},
};
results.bySize[N] = entry;
console.error(` agenticow branch: ${cowBranchMsMedian.toFixed(2)} ms / ${cowBranchBytesMedian} bytes (claim: 0.5ms / 162B)`);
console.error(` agenticow query: ${entry.agenticow.queryMsMedian} ms (top-10 through chain)`);
console.error(` full-copy: ${copyMsMedian.toFixed(2)} ms / ${copyBytesMedian} bytes`);
console.error(` → speedup ${entry.ratios.speedup}x (claim 83x), size-reduction ${entry.ratios.sizeReduction}x (claim 3000x)`);
}
// Cleanup
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
// Emit machine-readable result
console.log('===AGENTICOW_BENCH_JSON===');
console.log(JSON.stringify(results));
// Also persist a copy to docs/benchmarks/runs/ for repo-level visibility
const runFile = `docs/benchmarks/runs/agenticow-vs-full-copy-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
try {
writeFileSync(runFile, JSON.stringify(results, null, 2));
console.error(`\nWrote ${runFile}`);
} catch (e) { console.error(`\n(could not write run file: ${e.message})`); }