Files
ruvnet__ruflo/v3/@claude-flow/cli/scripts/benchmark-trajectory-mrr.mjs
ruv 2b9e2de89f feat(intelligence): Round B (post-edit/-command + trajectory-end → globalStats) + Round C structured distillation (ADR-076)
Round B — closes the wiring-side gap from ADR-074 / ADR-075:
- hooks_post-edit now synthesises a one-step trajectory from {filePath,
  success, agent} and calls recordTrajectory(), so globalStats reflects
  edit outcomes.
- hooks_post-command does the same for command outcomes.
- hooks_intelligence_trajectory-end also bumps globalStats (was only
  feeding sonaCoordinator); the response includes
  learning.globalStatsTrajectoriesDelta.
- Each handler returns learningPath ('trajectory-pipeline'|'recorded-only')
  + a note naming exactly what fired.

Round C — Structured Distillation (#2241 §SOTA, arXiv:2603.13017):
- src/memory/structured-distill.ts: 4-field schema (summary/detail/labels/
  paths), rule-based extractor, embedding-ready serialiser that places
  high-signal tokens (labels, paths) at the front.
- bench/trajectory-mrr-corpus.json: 30 paired (raw, query) trajectories.
- scripts/benchmark-trajectory-mrr.mjs: raw vs distilled retrieval MRR
  harness; uses bridge ONNX embedder, falls back to hash-deterministic
  with an explicit "degraded" warning.

Measured proof (bridge ONNX, Xenova/all-MiniLM-L6-v2, N=30):
  MRR raw 0.0964 → distilled 0.1367 (Δ +0.0403, +41.8% relative).
  Direction matches arXiv:2603.13017 (+0.014 absolute on a 214K paper
  corpus); relative delta is larger here because the small curated corpus
  benefits more from the labels-and-paths-first serialisation order.

Tests: 9 Round-C schema tests + 3 Round-B wiring tests = 12 new.
Full affected suite (self-learning + unified-stats + round-b + structured-
distill + mcp-tools-deep): 135/135.

Honest: a rule-based distiller cannot deliver the paper's 11× byte
compression (current ratio: 0.74× — distilled is 35% bigger). The schema,
corpus, harness, and serialiser are in place so a future round can plug
in a learned distiller as a drop-in extractor swap and pick up that
compression number while keeping this MRR direction.

Bump 3.10.15 → 3.10.16.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-30 12:30:58 -04:00

192 lines
7.7 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
// benchmark-trajectory-mrr.mjs — proof harness for Structured Distillation
// (#2241 §SOTA, arXiv:2603.13017).
//
// Measures retrieval MRR for raw vs structured-distilled trajectory content
// against a paired corpus. The arXiv paper reports raw → distilled MRR
// going from 0.745 to 0.759 on a 214 K-pair consensus-graded set; our corpus
// is much smaller and hand-curated, so the absolute numbers won't match,
// but the *direction* of the delta is what we're proving.
//
// Two embedders are supported:
// - Real ONNX (Xenova/all-MiniLM-L6-v2 384-dim) via @claude-flow/embeddings
// when available. Best signal.
// - Hash-based deterministic fallback when ONNX isn't installed. Lower
// signal; results still relative-comparable.
//
// Usage:
// node scripts/benchmark-trajectory-mrr.mjs # default
// BENCH_JSON=1 node scripts/benchmark-trajectory-mrr.mjs
// BENCH_NO_WRITE=1 node scripts/benchmark-trajectory-mrr.mjs
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import { performance } from 'node:perf_hooks';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const CLI_ROOT = resolve(SCRIPT_DIR, '..');
const REPO_ROOT = resolve(SCRIPT_DIR, '../../../..');
const RUNS_DIR = join(REPO_ROOT, 'docs', 'benchmarks', 'runs');
const { distillAndSerialise, compressionRatio } = await import(
join(CLI_ROOT, 'dist/src/memory/structured-distill.js')
);
// ---------------------------------------------------------------------------
// Embedder — prefers real ONNX, falls back to hash-based deterministic.
// ---------------------------------------------------------------------------
async function loadEmbedder() {
// Tier 1: AgentDB's bridge-loaded ONNX embedder (the path the live MCP
// server + vitest tests use, so we measure what users actually get).
try {
const mb = await import(join(CLI_ROOT, 'dist/src/memory/memory-bridge.js'));
const probe = await mb.bridgeGenerateEmbedding('warm-up').catch(() => null);
if (probe && probe.embedding && probe.embedding.length > 0 && probe.backend === 'onnx') {
return {
name: `bridge ONNX (${probe.model}, ${probe.dimensions}-dim)`,
embed: async (text) => {
const r = await mb.bridgeGenerateEmbedding(text);
if (!r) throw new Error('bridge embed returned null');
return r.embedding;
},
};
}
} catch { /* fall through */ }
// Tier 2: hash-based deterministic. Clearly degraded; produces only
// direction-of-effect signal, not absolute MRR numbers comparable to paper.
console.error('⚠️ Real ONNX embedder unavailable — using hash-based deterministic fallback. Absolute MRR numbers are NOT comparable to the arXiv paper in this mode; relative comparison is also weak.');
return {
name: 'hash-fallback (no semantic signal — degraded mode)',
embed: async (text) => hashEmbed(text, 384),
};
}
function hashEmbed(text, dims) {
const v = new Float32Array(dims);
let seed = 0;
for (let i = 0; i < text.length; i++) seed = (seed * 31 + text.charCodeAt(i)) | 0;
let s = Math.abs(seed) | 1;
for (let i = 0; i < dims; i++) {
s = (s * 1103515245 + 12345) & 0x7fffffff;
v[i] = (s / 0x7fffffff) * 2 - 1;
}
// L2 normalise
let n = 0;
for (let i = 0; i < dims; i++) n += v[i] * v[i];
n = Math.sqrt(n) || 1;
for (let i = 0; i < dims; i++) v[i] /= n;
return Array.from(v);
}
function cosine(a, b) {
let s = 0;
for (let i = 0; i < a.length; i++) s += a[i] * b[i];
return s;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const corpusPath = join(CLI_ROOT, 'bench', 'trajectory-mrr-corpus.json');
const corpus = JSON.parse(readFileSync(corpusPath, 'utf-8'));
const trajectories = corpus.trajectories;
const embedder = await loadEmbedder();
// Per-trajectory: embed both raw and distilled forms.
const tEmbed0 = performance.now();
const rawEmbs = [];
const distEmbs = [];
let totalRawBytes = 0;
let totalDistBytes = 0;
for (const t of trajectories) {
const distilled = distillAndSerialise(t.raw);
totalRawBytes += t.raw.length;
totalDistBytes += distilled.length;
rawEmbs.push(await embedder.embed(t.raw));
distEmbs.push(await embedder.embed(distilled));
}
const embedMs = performance.now() - tEmbed0;
// For each (query, gold) pair: rank all trajectories by cosine to the query
// embedding. MRR = mean of 1 / rank-of-gold across all queries.
const tQuery0 = performance.now();
let rrRaw = 0, rrDist = 0;
const perQuery = [];
for (let i = 0; i < trajectories.length; i++) {
const q = await embedder.embed(trajectories[i].query);
const rankRaw = rankOf(q, rawEmbs, i);
const rankDist = rankOf(q, distEmbs, i);
rrRaw += 1 / rankRaw;
rrDist += 1 / rankDist;
perQuery.push({ id: trajectories[i].id, rankRaw, rankDist });
}
const queryMs = performance.now() - tQuery0;
const N = trajectories.length;
const mrrRaw = rrRaw / N;
const mrrDist = rrDist / N;
const delta = mrrDist - mrrRaw;
const compression = totalRawBytes / Math.max(1, totalDistBytes);
const summary = {
runAt: new Date().toISOString(),
benchmark: 'trajectory-mrr',
embedder: embedder.name,
corpusVersion: corpus.version,
corpusSize: N,
mrr: {
raw: Number(mrrRaw.toFixed(4)),
distilled: Number(mrrDist.toFixed(4)),
delta: Number(delta.toFixed(4)),
paperReference: { raw: 0.745, distilled: 0.759, delta: 0.014, source: 'arXiv:2603.13017' },
},
compression: {
totalRawBytes,
totalDistBytes,
ratio: Number(compression.toFixed(2)),
paperReference: { tokensRaw: 371, tokensDistilled: 38, ratio: 9.76, source: 'arXiv:2603.13017' },
},
latencyMs: {
embedAll: Number(embedMs.toFixed(2)),
queryAll: Number(queryMs.toFixed(2)),
perDistill: Number((embedMs / (2 * N)).toFixed(4)),
},
distilledIsBetter: delta > 0,
perQueryRanks: perQuery,
};
if (process.env.BENCH_JSON) {
console.log(JSON.stringify(summary, null, 2));
} else {
console.log(`# Trajectory MRR benchmark (#2241 §Structured Distillation)`);
console.log(`Embedder: ${embedder.name}`);
console.log(`Corpus: N=${N} (v${corpus.version})`);
console.log('');
console.log('| Metric | Raw | Distilled | Δ |');
console.log('|---|---:|---:|---:|');
console.log(`| MRR | ${summary.mrr.raw} | ${summary.mrr.distilled} | ${summary.mrr.delta >= 0 ? '+' : ''}${summary.mrr.delta} |`);
console.log(`| Total bytes | ${totalRawBytes} | ${totalDistBytes} | ${compression.toFixed(2)}× compression |`);
console.log('');
console.log(`Distilled is ${summary.distilledIsBetter ? 'BETTER ✅' : 'WORSE ❌'} than raw on this corpus.`);
console.log(`Paper (arXiv:2603.13017): MRR raw 0.745 → distilled 0.759 (Δ +0.014); compression ~9.76× (371→38 tokens).`);
}
if (!process.env.BENCH_NO_WRITE) {
mkdirSync(RUNS_DIR, { recursive: true });
const stamp = summary.runAt.replace(/[:.]/g, '-');
writeFileSync(join(RUNS_DIR, `trajectory-mrr-${stamp}.json`), JSON.stringify(summary, null, 2));
writeFileSync(join(RUNS_DIR, 'trajectory-mrr-latest.json'), JSON.stringify(summary, null, 2));
if (!process.env.BENCH_JSON) console.log(`\nWrote ${join(RUNS_DIR, `trajectory-mrr-${stamp}.json`)}`);
}
}
function rankOf(query, embeddings, goldIndex) {
const scored = embeddings.map((e, i) => ({ i, score: cosine(query, e) }));
scored.sort((a, b) => b.score - a.score);
return scored.findIndex((s) => s.i === goldIndex) + 1;
}
main().catch((err) => { console.error(err); process.exit(1); });