Files
ruvnet__ruflo/plugins/ruflo-graph-intelligence/tests/mcp-tools.test.ts
ruv 126c23a0fc feat(graph-intelligence): #2044 ADR-123 Phase 1 — ruflo-graph-intelligence plugin foundation
Scaffolds plugins/ruflo-graph-intelligence/ — the RuFlo Graph Intelligence
Engine. Built on sublinear-time-solver@^1.7.0. Three-layer architecture:
Neural / Graph Intelligence / Complexity.

Phase 1 deliverables:
- package.json @ 0.1.0-alpha.1
- domain/types.ts — SparseMatrix, ComplexityClass (12 tiers + fitsBudget +
  isEdgeSafe), CoherenceReport, PageRankQuery/Result, SolveQuery/Result,
  SolveOnChangeQuery, structured GraphIntelError taxonomy
- domain/adapter.ts — SublinearAdapter interface, AdapterRegistry (singleton)
- infrastructure/solver-bridge.ts — coherenceScore, singleEntryPageRank
  (forward-push), conjugateGradient, neumann, solveOnChange, hashResult,
  observedComplexity, runPageRank/runSolve/runSolveOnChange entry points
- mcp-tools/index.ts — 6 MCP tools under sublinear/*:
  page-rank-entry / solve / solve-on-change / analyze / feasibility / jl-embed
  All threaded with maxComplexityClass + coherenceThreshold budget gates.
- tests: 37/37 passing — solver-bridge (coherence, PR, CG, Neumann,
  solve-on-change, hash determinism), adapter-registry (CRUD + singleton),
  mcp-tools (happy path, graph-not-found, coherence-rejected,
  complexity-budget-exceeded)

CI guard (#2044 follow-up):
- .github/workflows/v3-ci.yml — adds plugins/ruflo-graph-intelligence/**
  to both push and pull_request path filters
- New job: ruflo-graph-intelligence build + test smoke. Installs (npm ci
  with --legacy-peer-deps because the plugin is outside the v3 pnpm
  workspace), typechecks, builds, runs vitest. Cross-platform via Node 22.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-18 21:44:51 -04:00

170 lines
5.4 KiB
TypeScript

/**
* ruflo-graph-intelligence — MCP Tool Tests (ADR-123 Phase 1)
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { graphIntelligenceTools } from '../src/mcp-tools/index.js';
import { getRegistry, resetRegistry } from '../src/domain/adapter.js';
import type { SublinearAdapter } from '../src/domain/adapter.js';
import type { SparseMatrix } from '../src/domain/types.js';
function ddTestAdapter(): SublinearAdapter {
return {
graphId: 'test:dd',
ownerPlugin: 'test',
async exportAsSparseMatrix(): Promise<SparseMatrix> {
const entries = [];
const nodeIndex: Record<string, number> = {};
const indexNode: string[] = [];
const n = 6;
for (let i = 0; i < n; i++) {
nodeIndex[`n${i}`] = i;
indexNode.push(`n${i}`);
entries.push({ row: i, col: i, value: 5 });
if (i > 0) entries.push({ row: i, col: i - 1, value: -1 });
if (i < n - 1) entries.push({ row: i, col: i + 1, value: -1 });
}
return {
graphId: 'test:dd',
size: n,
entries,
nodeIndex,
indexNode,
capturedAt: 't',
};
},
};
}
function findTool(name: string) {
const tool = graphIntelligenceTools.find((t) => t.name === name);
if (!tool) throw new Error(`tool ${name} not found`);
return tool;
}
describe('MCP tools — surface', () => {
it('exports six tools under sublinear/*', () => {
const names = graphIntelligenceTools.map((t) => t.name);
expect(names).toEqual([
'sublinear/page-rank-entry',
'sublinear/solve',
'sublinear/solve-on-change',
'sublinear/analyze',
'sublinear/feasibility',
'sublinear/jl-embed',
]);
});
it('each tool has an input schema with type:object', () => {
for (const t of graphIntelligenceTools) {
expect(t.inputSchema.type).toBe('object');
}
});
});
describe('sublinear/page-rank-entry', () => {
beforeEach(() => {
resetRegistry();
getRegistry().register(ddTestAdapter());
});
it('returns a result on the happy path', async () => {
const tool = findTool('sublinear/page-rank-entry');
// Phase 1 forward-push is a reference impl — observed iterations on small DD
// matrices may exceed the `linear` default budget. Use `polynomial` here to
// exercise the happy path; the budget-exceeded path has its own dedicated test.
const r = (await tool.handler({
graphId: 'test:dd',
nodeId: 'n3',
maxComplexityClass: 'polynomial',
})) as { success: boolean; result?: unknown };
expect(r.success).toBe(true);
expect(r.result).toBeDefined();
});
it('returns complexity-budget-exceeded when budget is too tight', async () => {
const tool = findTool('sublinear/page-rank-entry');
const r = (await tool.handler({
graphId: 'test:dd',
nodeId: 'n3',
maxComplexityClass: 'constant',
})) as { success: boolean; error?: { kind: string } };
expect(r.success).toBe(false);
expect(r.error?.kind).toBe('complexity-budget-exceeded');
});
it('returns graph-not-found for an unknown graphId', async () => {
const tool = findTool('sublinear/page-rank-entry');
const r = (await tool.handler({ graphId: 'missing', nodeId: 'n0' })) as { success: boolean; error?: { kind: string } };
expect(r.success).toBe(false);
expect(r.error?.kind).toBe('graph-not-found');
});
it('returns coherence-rejected when threshold not met', async () => {
const tool = findTool('sublinear/page-rank-entry');
const r = (await tool.handler({
graphId: 'test:dd',
nodeId: 'n0',
coherenceThreshold: 0.99,
})) as { success: boolean; error?: { kind: string } };
expect(r.success).toBe(false);
expect(r.error?.kind).toBe('coherence-rejected');
});
});
describe('sublinear/analyze', () => {
beforeEach(() => {
resetRegistry();
getRegistry().register(ddTestAdapter());
});
it('reports coherence + recommended algorithm', async () => {
const tool = findTool('sublinear/analyze');
const r = (await tool.handler({ graphId: 'test:dd' })) as { success: boolean; result?: Record<string, unknown> };
expect(r.success).toBe(true);
expect(r.result?.size).toBe(6);
expect(r.result?.coherenceScore).toBeGreaterThan(0);
expect(r.result?.recommendedAlgorithm).toBeDefined();
});
});
describe('sublinear/solve', () => {
beforeEach(() => {
resetRegistry();
getRegistry().register(ddTestAdapter());
});
it('solves A·x = b with CG', async () => {
const tool = findTool('sublinear/solve');
const r = (await tool.handler({
graphId: 'test:dd',
rhs: [1, 1, 1, 1, 1, 1],
algorithm: 'cg',
maxComplexityClass: 'polynomial',
})) as { success: boolean; result?: { x: number[]; residualNorm: number } };
expect(r.success).toBe(true);
expect(r.result?.x).toHaveLength(6);
expect(r.result?.residualNorm).toBeLessThan(1e-3);
});
});
describe('sublinear/solve-on-change', () => {
beforeEach(() => {
resetRegistry();
getRegistry().register(ddTestAdapter());
});
it('handles a single-node delta', async () => {
const tool = findTool('sublinear/solve-on-change');
const r = (await tool.handler({
graphId: 'test:dd',
prevSolution: [0, 0, 0, 0, 0, 0],
delta: { indices: [2], values: [0.5] },
algorithm: 'cg',
maxComplexityClass: 'polynomial',
})) as { success: boolean; result?: { x: number[] } };
expect(r.success).toBe(true);
expect(r.result?.x).toHaveLength(6);
});
});