ruvector/examples/mragent/optimize.mjs
rUv edf96d83ed
feat(mragent): self-reconstructing graph memory over RuVector, evolved by Darwin (ADR-269/270) (#611)
* feat(mragent): MRAgent graph memory over RuVector with Darwin optimization

Add ADR-269 and a runnable reference implementation of MRAgent ("Memory is
Reconstructed, Not Retrieved") on RuVector, optimized by Meta-Harness Darwin
Mode under the "freeze the model, evolve the harness" invariant.

- Frozen model: deterministic Cue-Tag-Content memory substrate mirroring
  RuVector hybrid (RRF) search + bounded-depth Cypher traversal semantics
  (examples/mragent/agent/memory.mjs)
- Evolved harness: 10-gene reconstruction genome (cueK, efSearch, hybridAlpha,
  fusion, traversalDepth, tagFanout, pruneThreshold, maxContent, rerank,
  promptStrategy) in DARWIN_MUTABLE_BLOCK regions (agent/harness.mjs)
- Darwin evolution loop with mapLimit/paretoFront and ADR-150 graceful fallback
  when @metaharness/darwin is absent (optimize.mjs)
- scorePolicy.ts fitness mirroring ADR-266; benchmark + probe + 7 deterministic
  acceptance gates
- eval corpus with chained multi-hop "bridge" tasks so traversal depth, fan-out
  and pruning are genuinely load-bearing

Runs with zero optional deps: baseline 83.3% -> evolved 100% accuracy, faster
and ~33% smaller context. Darwin discovers traversalDepth=3 (LINKED_TO*1..3).

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

* feat(mragent): self-reconstructing graph memory, beyond SOTA (ADR-270)

Extend the MRAgent harness past the paper into calibrated, adaptive,
self-reorganizing memory, co-evolved by Darwin. Also fixes the corpus being
silently excluded by the root .gitignore data/ rule (the example was missing
its eval set).

Beyond-SOTA mechanisms (each a tunable gene Darwin evolves):
- Adaptive depth (haltConfidence): halt traversal once evidence is decisive
- Abstention + risk-adjusted utility (abstainThreshold): refuse on weak
  evidence instead of hallucinating; graded on calibrated utility, not raw acc
- Consolidation/replay (agent/consolidate.mjs): store reorganizes its own
  topology, laying Cue->shortcut->Content edges (RuVector self-learning GNN)

Substrate upgrades:
- Concept layer (agent/concepts.mjs): dense (concept) vs sparse (token) signals
  genuinely decoupled, so hybridAlpha/fusion become load-bearing
- Hardened 24-task corpus, 6 classes (semantic/lexical/hybrid/bridge/
  distractor/unanswerable) synthesized from structured signal specs
- All 12 genes proven load-bearing (some via epistatic interaction)
- Memetic optimizer: GA (mapLimit/paretoFront) + multi-start coordinate-descent
  polish that reliably finds the narrow calibration optimum

Measured (deterministic, zero optional deps): baseline acc 81% / risk 0.708 /
halluc 0.13 -> evolved 100% / risk 1.000 / halluc 0.00; consolidation -25%
hops at 100% accuracy. 11 acceptance gates pass. ADR-150 compliant.

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

* feat(mragent): generalization protocol (train/test/CV) + overfit fixes

Add a held-out evaluation regime that proves the evolved harness GENERALIZES
rather than memorizing the eval set, and fix the overfitting it surfaced.

Protocol:
- Scale corpus to 60 tasks via a deterministic generator (tools/genCorpus.mjs,
  npm run gen-corpus), 10 per class, difficulty-varied (1-hop AND 2-hop bridges,
  1-3 ranking-distractors) so train constrains every gene
- Optimizer evolves on a class-stratified TRAIN split, selects via 3-fold
  cross-validation with a variance penalty (mean - 0.5*range), and reports a
  held-out TEST split it never saw
- Generalization gate = does evolution improve the unseen split

Overfit fixes uncovered by held-out eval:
- Abstention confidence now derives from the answer's RAW relevance, not its
  decay^depth path score, so deep-but-relevant bridge answers aren't mistaken
  for weak ones (b-test confidence 0.39 -> 0.79); abstention generalizes across
  depths. Adaptive-depth halt uses the same raw-relevance signal.
- Larger difficulty-varied corpus + CV variance penalty stop the optimizer
  shaving under-constrained genes (maxContent->1) to train-fragile settings

Result (held-out test, reproducible): baseline ~30% acc / risk 0.25 / halluc
0.17 -> evolved ~65% / 0.81 / 0.04 (+35pt acc, +0.56 risk). Honest ceiling
(~80%) documented: synthetic embedding noise + one global hybridAlpha can't
serve both dense- and sparse-keyed queries. 12 acceptance gates pass.

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

* feat(mragent): GPU LLM write-layer for the Darwin optimizer (local RTX 5080)

Adds the directed-proposal layer the GA lacks (ADR-260 'real Darwin write-layer
proposes leaps from failure traces'): agent/llmMutator.mjs shows a local,
GPU-served code model (qwen2.5-coder via an OpenAI-compatible endpoint) the
current genome + its failing cases and asks for improved genomes. Every proposal
is clamped to the declared gene bounds (coerceGenome) before entering the
population, so untrusted LLM output can only ever be a safe genome — never an
unsafe gene. Wired into optimize.mjs every 3rd generation; folded into the
archive so GPU candidates compete in polish + acceptance.

Fully opt-in + gracefully degrading (ADR-150): MRAGENT_LLM=off or no reachable
endpoint => identical deterministic GA+coordinate-descent run as before. Auto-
detects http://localhost:11434/v1 (ollama) by default; MRAGENT_LLM_URL/MODEL
override.

Measured (RTX 5080, qwen2.5-coder:7b): 8 genomes proposed across gens, bounds-
safe; the deterministic polish still wins on this small synthetic corpus (the
GA+grid already enumerates the optimum), so the write-layer is a no-regression
enhancement that matters on larger corpora the grid can't cover. 14/14 tests
pass (2 new coerceGenome safety tests).

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: ruvnet <ruvnet@gmail.com>
2026-06-27 11:08:26 -04:00

304 lines
16 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.

// MRAgent harness optimizer — Darwin Mode for graph-memory reconstruction.
//
// Principle (Meta-Harness / @metaharness/darwin): "freeze the model, evolve the
// harness." FROZEN MODEL = the RuVector Cue-Tag-Content memory substrate
// (agent/memory.mjs). EVOLVED HARNESS = the reconstruction genome in
// agent/harness.mjs (cue-k, efSearch, RRF alpha, traversal depth, fan-out, prune
// threshold, content limit, GNN rerank, prompt strategy).
//
// We use Darwin's `mapLimit` (bounded-concurrency evaluation) and `paretoFront`
// (multi-objective selection) when @metaharness/darwin is installed, and fall
// back to an equivalent in-process loop when it is not (ADR-150 invariant 3:
// graceful degradation — MODULE_NOT_FOUND must never crash the example).
//
// Run: npm run optimize
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { MemoryStore, baselineGenome, mutate, evaluate, splitByClass, kFoldByClass, runReasoningLoop } from "./agent/harness.mjs";
import { consolidate } from "./agent/consolidate.mjs";
import { detectEndpoint, llmProposeGenomes } from "./agent/llmMutator.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ── ADR-150 graceful degradation: optional Darwin primitives ────────────────
async function loadDarwin() {
try {
const d = await import("@metaharness/darwin");
console.log("[darwin] @metaharness/darwin loaded — using mapLimit + paretoFront");
return { mapLimit: d.mapLimit, paretoFront: d.paretoFront, available: true };
} catch (e) {
if (e.code !== "ERR_MODULE_NOT_FOUND" && e.code !== "MODULE_NOT_FOUND") throw e;
console.warn("[darwin] @metaharness/darwin not installed — using built-in evolution loop");
return { mapLimit: localMapLimit, paretoFront: localParetoFront, available: false };
}
}
// Minimal local stand-ins (identical contracts to the Darwin exports).
async function localMapLimit(items, _limit, fn) {
const out = [];
for (let i = 0; i < items.length; i++) out.push(await fn(items[i], i));
return out;
}
function localParetoFront(items, objFn) {
const objs = items.map(objFn);
return items.filter((_, i) =>
!items.some((_, j) => j !== i && dominates(objs[j], objs[i])));
}
function dominates(a, b) {
let strictly = false;
for (let k = 0; k < a.length; k++) {
if (a[k] < b[k]) return false;
if (a[k] > b[k]) strictly = true;
}
return strictly;
}
// ── Scoring — the Darwin fitness (see harness/scorePolicy.ts for the canonical
// version used by `metaharness evolve`). Helpfulness (accuracy) AND calibration
// (risk-adjusted utility — abstain instead of hallucinate) both dominate;
// reconstruction cost (latency, hops, context) is penalised vs the baseline. ──
const BASE = { latency: 4.0, hops: 2.0, context: 6.0 };
function scalar(m) {
const latTerm = Math.max(0, 1 - m.avgLatencyMs / BASE.latency);
const hopTerm = Math.max(0, 1 - m.avgHops / BASE.hops);
const ctxTerm = Math.max(0, 1 - m.avgContext / BASE.context);
return 0.40 * m.accuracy + 0.30 * m.riskScore + 0.12 * latTerm + 0.10 * ctxTerm + 0.08 * hopTerm;
}
// Pareto maximises every component (negate minimised objectives).
function objectives(m) {
return [m.accuracy, m.riskScore, -m.avgLatencyMs, -m.avgHops, -m.avgContext];
}
// ── Run ─────────────────────────────────────────────────────────────────────
const { mapLimit, paretoFront, available } = await loadDarwin();
// ── GPU LLM write-layer (opt-in): a local code model proposes genome leaps from
// failure traces, the directed-search layer the random GA lacks (ADR-260).
// Disabled with MRAGENT_LLM=off; otherwise auto-detects a local endpoint. ──
const llm = process.env.MRAGENT_LLM === "off" ? null : await detectEndpoint();
if (llm) console.log(`[llm] GPU write-layer: ${llm.model} @ ${llm.url}`);
else console.log("[llm] no local LLM endpoint — GA-only (set MRAGENT_LLM_URL to enable)");
let llmProposed = 0, llmEnteredElite = 0;
const corpus = JSON.parse(fs.readFileSync(path.join(__dirname, "data", "eval-set.json"), "utf8"));
const tasks = corpus.tasks;
// ONE memory holds all nodes (full cross-task cue competition); we evolve on the
// TRAIN queries only and report held-out TEST to prove the genome generalizes.
const store = new MemoryStore(tasks);
const { train, test } = splitByClass(tasks, 0.6);
const folds = kFoldByClass(train, 3); // cross-validation folds over the train pool
// Cross-validated fitness: mean fold score MINUS the fold range. The penalty
// rejects genomes that win on one fold but collapse on another (e.g. a knife-edge
// abstainThreshold), which is exactly the overfit a single split hides.
function cvScore(genome) {
const fs2 = folds.map((f) => scalar(evaluate(genome, store, f)));
const mean = fs2.reduce((a, b) => a + b, 0) / fs2.length;
const range = Math.max(...fs2) - Math.min(...fs2);
return mean - 0.5 * range;
}
// Compact failure trace for the LLM write-layer: tasks the genome gets wrong /
// hallucinates, with its confidence (so the model can reason about thresholds).
function failureTraces(genome, tasks, limit = 6) {
const out = [];
for (const t of tasks) {
if (out.length >= limit) break;
const isAns = t.answerable !== false;
const r = runReasoningLoop(store.queryText(t.id), store, genome, t);
if (isAns && !r.correct) {
out.push(`${t.id}[${t.class ?? "?"}]: ${r.abstained ? "abstained-on-answerable" : "wrong"} conf=${r.confidence.toFixed(2)}`);
} else if (!isAns && !r.abstained) {
out.push(`${t.id}[${t.class ?? "?"}]: hallucinated-on-unanswerable conf=${r.confidence.toFixed(2)}`);
}
}
return out.join("\n") || "none";
}
const llmGenomes = []; // every coerced LLM proposal, for end-of-run attribution
const POP = 16, GENERATIONS = 12, ELITE = 5, CONCURRENCY = 4;
const baseline = baselineGenome();
const baseMetrics = evaluate(baseline, store, train);
let population = [baseline, ...Array.from({ length: POP - 1 }, () => mutate(baseline))];
let best = { genome: baseline, metrics: baseMetrics, score: cvScore(baseline) };
const archive = [];
const history = [];
console.log("== MRAgent · Darwin harness optimizer (v2 — beyond MRAgent) ==");
console.log(`frozen model: RuVector Cue-Tag-Content graph (${tasks.length} tasks) | train ${train.length} / test ${test.length} (held out)`);
console.log(`baseline (train): acc ${(baseMetrics.accuracy * 100).toFixed(1)}% risk ${baseMetrics.riskScore.toFixed(3)} halluc ${baseMetrics.hallucinationRate.toFixed(2)}\n`);
for (let gen = 0; gen < GENERATIONS; gen++) {
const scored = await mapLimit(population, CONCURRENCY, async (genome) => {
const metrics = evaluate(genome, store, train);
return { genome, metrics, score: cvScore(genome) };
});
archive.push(...scored);
const front = paretoFront(scored, (e) => objectives(e.metrics));
const winner = scored.reduce((a, b) => (b.score > a.score ? b : a));
if (winner.score > best.score) best = winner;
history.push({
gen,
best: { accuracy: winner.metrics.accuracy, avgLatencyMs: winner.metrics.avgLatencyMs, score: winner.score },
frontSize: front.length,
});
console.log(
`gen ${gen}: acc ${(winner.metrics.accuracy * 100).toFixed(1)}% risk ${winner.metrics.riskScore.toFixed(3)} ` +
`halluc ${winner.metrics.hallucinationRate.toFixed(2)} lat ${winner.metrics.avgLatencyMs.toFixed(2)}ms hops ${winner.metrics.avgHops.toFixed(2)} ` +
`score ${winner.score.toFixed(4)} · pareto ${front.length}`
);
// Next generation: elites + mutated children + a couple of random restarts to
// keep diversity (the built-in loop has no LLM write-layer to propose leaps).
const elites = [...scored].sort((a, b) => b.score - a.score).slice(0, ELITE).map((e) => e.genome);
const next = [...elites];
// GPU LLM write-layer: every 3rd generation, ask the local code model for
// directed genome leaps from the current winner's failure traces. Proposals
// are bounds-clamped in llmMutator, so they can only ever be safe genomes.
if (llm && gen % 3 === 0) {
const traces = failureTraces(winner.genome, train);
const props = await llmProposeGenomes({ url: llm.url, model: llm.model, baseline, current: winner.genome, failures: traces, n: 2 });
for (const g of props) {
llmGenomes.push(g);
llmProposed++;
if (next.length < POP) next.push(g);
}
if (props.length) console.log(` [llm] gen ${gen}: +${props.length} GPU-proposed genome(s) injected`);
}
const RESTARTS = 2;
for (let r = 0; r < RESTARTS && next.length < POP; r++) {
let g = baseline;
for (let m = 0; m < 6; m++) g = mutate(g); // heavy random walk
next.push(g);
}
while (next.length < POP) next.push(mutate(elites[Math.floor(Math.random() * elites.length)]));
population = next;
}
// Fold GPU-proposed genomes into the archive so they compete in polish +
// acceptance on equal footing with GA candidates.
let llmBest = -Infinity;
for (const g of llmGenomes) {
const e = { genome: g, metrics: evaluate(g, store, train), score: cvScore(g) };
archive.push(e);
if (e.score > llmBest) llmBest = e.score;
if (e.score > best.score) { best = e; llmEnteredElite++; }
}
if (llm) {
console.log(`\n[llm] GPU write-layer: ${llmProposed} genome(s) proposed, best cv-score ${llmBest > -Infinity ? llmBest.toFixed(4) : "n/a"}${llmEnteredElite ? `, became GA-best ${llmEnteredElite}×` : ""}`);
}
// ── Memetic polish — deterministic coordinate descent over each gene ─────────
// The GA explores broadly but the LLM-free fallback struggles with NARROW optima
// (e.g. the abstainThreshold band that catches hallucinations without abstaining
// on correct answers). A final hill-climb over a per-gene candidate grid finds
// them reliably and makes the shipped result reproducible. (The real Darwin
// write-layer proposes such leaps directly from failure traces — ADR-260.)
const GRID = {
cueK: [1, 2, 3, 4, 6, 8],
efSearch: [16, 24, 32, 48, 64, 96, 128],
hybridAlpha: [0, 0.2, 0.35, 0.5, 0.65, 0.8, 1],
fusion: ["rrf", "linear", "dbsf"],
traversalDepth: [1, 2, 3, 4],
tagFanout: [1, 2, 3, 4, 6, 8],
pruneThreshold: [0, 0.05, 0.1, 0.15, 0.2, 0.3, 0.4],
maxContent: [1, 2, 3, 4, 6, 8, 12],
haltConfidence: [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9],
rerank: ["gnn", "none"],
promptStrategy: ["terse", "evidence-first", "prune-explicit"],
abstainThreshold: [0, 0.1, 0.2, 0.3, 0.34, 0.36, 0.38, 0.4, 0.45, 0.5],
};
function localPolish(genome) {
let cur = { ...genome };
let curScore = cvScore(cur); // cross-validated over train folds — never see test
for (let pass = 0; pass < 3; pass++) {
let improved = false;
for (const [gene, candidates] of Object.entries(GRID)) {
for (const v of candidates) {
if (cur[gene] === v) continue;
const cand = { ...cur, [gene]: v };
const s = cvScore(cand);
if (s > curScore + 1e-9) { cur = cand; curScore = s; improved = true; }
}
}
if (!improved) break;
}
return { genome: cur, score: curScore };
}
// Multi-start polish: greedy coordinate descent is start-dependent, so refine from
// several diverse seeds (GA winner + baseline + top archive elites) and keep the
// global best. This makes the calibrated optimum reproducible across runs.
const seeds = [best.genome, baseline, ...[...archive].sort((a, b) => b.score - a.score).slice(0, 4).map((e) => e.genome)];
for (const seed of seeds) {
const polished = localPolish(seed);
if (polished.score > best.score) best = { genome: polished.genome, metrics: evaluate(polished.genome, store, train), score: polished.score };
}
console.log(`\n[polish] multi-start coordinate-descent (train) → score ${best.score.toFixed(4)} (acc ${(best.metrics.accuracy * 100).toFixed(1)}% risk ${best.metrics.riskScore.toFixed(3)} halluc ${best.metrics.hallucinationRate.toFixed(2)})`);
// ── Acceptance gate over the whole archive ──────────────────────────────────
const gate = (m) => {
const accGain = m.accuracy - baseMetrics.accuracy;
const riskGain = m.riskScore - baseMetrics.riskScore;
const noRegress = m.accuracy >= baseMetrics.accuracy - 1e-9 && m.riskScore >= baseMetrics.riskScore - 1e-9;
return { accGain, riskGain, noRegress, passed: noRegress && (accGain >= 0.04 || riskGain >= 0.04) };
};
const passers = [best, ...archive]
.map((e) => ({ e, g: gate(e.metrics) }))
.filter((x) => x.g.passed)
.sort((a, b) => (b.e.score - a.e.score));
const accepted = passers[0]?.e ?? best;
const acc = gate(accepted.metrics);
console.log("\n-- acceptance gate (over archive) --");
console.log(`candidates evaluated: ${archive.length} | gate-passing: ${passers.length}`);
console.log(`accepted: acc ${(accepted.metrics.accuracy * 100).toFixed(1)}% (${acc.accGain >= 0 ? "+" : ""}${(acc.accGain * 100).toFixed(1)}pt) · risk ${accepted.metrics.riskScore.toFixed(3)} (${acc.riskGain >= 0 ? "+" : ""}${acc.riskGain.toFixed(3)}) · halluc ${accepted.metrics.hallucinationRate.toFixed(2)}`);
console.log(passers.length ? "PASS — Pareto-superior harness found (freeze model, evolve harness)" : "no gate-passing variant this run");
// ── Generalization: held-out TEST split (never seen during evolution) ────────
// Generalization criterion = does evolving on TRAIN improve UNSEEN test? (not an
// absolute accuracy bar — the synthetic toy embedding has per-instance noise, and
// a single global hybridAlpha cannot perfectly serve both dense- and sparse-keyed
// queries; the question that matters is whether optimization transfers.)
const baseTest = evaluate(baseline, store, test);
const evoTest = evaluate(accepted.genome, store, test);
const generalizes = evoTest.accuracy >= baseTest.accuracy + 0.10 && evoTest.hallucinationRate <= baseTest.hallucinationRate + 1e-9;
console.log("\n-- generalization (held-out test split, never seen in evolution) --");
console.log(`baseline test: acc ${(baseTest.accuracy * 100).toFixed(1)}% risk ${baseTest.riskScore.toFixed(3)} halluc ${baseTest.hallucinationRate.toFixed(2)}`);
console.log(`evolved test: acc ${(evoTest.accuracy * 100).toFixed(1)}% risk ${evoTest.riskScore.toFixed(3)} halluc ${evoTest.hallucinationRate.toFixed(2)}`);
console.log(`gain: +${((evoTest.accuracy - baseTest.accuracy) * 100).toFixed(1)}pt acc, +${(evoTest.riskScore - baseTest.riskScore).toFixed(3)} risk on unseen tasks`);
console.log(generalizes ? "GENERALIZES — evolution transfers to unseen tasks (not overfit)" : "WARNING — evolved genome does not transfer");
// ── Replay/consolidation pass on the accepted genome (self-reorganizing memory) ─
const memAfter = new MemoryStore(tasks);
const evoMetricsPre = evaluate(accepted.genome, memAfter, tasks);
const consolidation = consolidate(memAfter, tasks, accepted.genome);
const evoMetricsPost = evaluate(accepted.genome, memAfter, tasks);
console.log(`\n-- consolidation (replay) on accepted genome --`);
console.log(`shortcuts laid: ${consolidation.consolidated} | avgHops ${evoMetricsPre.avgHops.toFixed(3)} -> ${evoMetricsPost.avgHops.toFixed(3)} (${(((evoMetricsPre.avgHops - evoMetricsPost.avgHops) / evoMetricsPre.avgHops) * 100).toFixed(1)}% fewer) at acc ${(evoMetricsPost.accuracy * 100).toFixed(1)}%`);
const report = {
tool: "metaharness/darwin",
philosophy: "freeze the model, evolve the harness",
frozenModel: "RuVector Cue-Tag-Content graph memory (agent/memory.mjs)",
darwinAvailable: available,
primitivesUsed: ["mapLimit", "paretoFront"],
gpuWriteLayer: llm
? { endpoint: llm.url, model: llm.model, proposed: llmProposed, bestCvScore: llmBest > -Infinity ? llmBest : null, becameBest: llmEnteredElite }
: { enabled: false },
split: { train: train.length, test: test.length },
baseline: { trainMetrics: baseMetrics, testMetrics: baseTest },
evolved: { genome: accepted.genome, trainMetrics: accepted.metrics, testMetrics: evoTest, score: accepted.score },
generalizes,
consolidation: { shortcuts: consolidation.consolidated, avgHopsBefore: evoMetricsPre.avgHops, avgHopsAfter: evoMetricsPost.avgHops, metricsAfter: evoMetricsPost },
acceptance: acc,
history,
};
fs.writeFileSync(path.join(__dirname, "optimize.report.json"), JSON.stringify(report, null, 2));
console.log(`\nreport -> ${path.join(__dirname, "optimize.report.json")}`);