Files
ruvnet__ruflo/plugins/ruflo-graph-intelligence/tests/phase5-portfolio.test.ts
ruv dbb88fdc37 feat(graph-intelligence): #2044 ADR-123 Phases 5+6 — portfolio CG + AIDefence + jujutsu + JL + GOAP-LP
Phase 5 — Wedge 8: PortfolioCovarianceAdapter
  Symmetric PD covariance matrix Σ for mean-variance Σx = μ. CG is the
  upstream 1.7.0 hot path (816 ns @ n=256, 40-60× vs Neumann). Adapter
  symmetrises one-sided entries automatically and applies a ridge
  (default 1e-6) to keep Σ SPD even when empirical covariance is
  rank-deficient. Exposes expectedReturnsVector(matrix) helper aligned
  with the matrix's node order for the b vector.

Phase 6 — Wedges 9/10/11 + JL fix:
  AIDefenceSuspicionAdapter (ruflo-aidefence:call-graph)
    Reverse-direction call graph (callee → caller) so suspicion
    propagates from flagged syscall back through the caller chain.
    Recommends α=0.95 for callers (high decay = suspicion travels far).
  JujutsuBlastRadiusAdapter (ruflo-jujutsu:import-graph)
    File-import graph oriented importee → importer for change
    propagation. PR seeded at changed file returns downstream blast.
  jlEmbed (infrastructure/jl-embed.ts)
    Real Johnson-Lindenstrauss with Achlioptas/Dasgupta-Gupta bound
    (target_dim ≤ n − 1) enforced. Deterministic Gaussian projection
    seeded by content hash so embeddings are reproducible. Replaces
    @claude-flow/embeddings hand-rolled JL (closes ADR-121 Phase 4).
  sublinear/feasibility tool — now ships a Lagrangian-shrink LP
    feasibility heuristic (200-iter pass with stepSize 0.05) instead of
    the Phase 1 stub. Returns witness on success or
    certificateOfInfeasibility on failure. Supports leq/geq/eq kinds.

80/80 tests passing (+17 since Phase 4):
  - phase5-portfolio.test.ts: 4 (symmetrise, CG solve, ridge, end-to-end)
  - phase6-adapters.test.ts: 13 (AIDefence reverse-direction, jujutsu
    blast orientation, JL Achlioptas cap, JL projection, JL MCP tool,
    GOAP feasible+witness+infeasible cases)

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

123 lines
4.6 KiB
TypeScript

/**
* Phase 5 Tests — Portfolio Covariance Adapter (Wedge 8)
*
* Acceptance:
* - Covariance matrix is symmetric after symmetrisation
* - Σx = μ solved via CG to small residual
* - Ridge keeps Σ SPD even when the empirical covariance is rank-1
* - End-to-end via sublinear/solve
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
PortfolioCovarianceAdapter,
portfolioGraphId,
registerPortfolioCovarianceAdapter,
} from '../src/adapters/portfolio-cg-adapter.js';
import { resetRegistry, getRegistry } from '../src/domain/adapter.js';
import { conjugateGradient } from '../src/infrastructure/solver-bridge.js';
import { graphIntelligenceTools } from '../src/mcp-tools/index.js';
describe('PortfolioCovarianceAdapter', () => {
beforeEach(() => resetRegistry());
it('symmetrises one-sided covariance entries', async () => {
const adapter = new PortfolioCovarianceAdapter({
portfolioId: 'p1',
source: {
async listCovarianceEntries() {
return [
{ assetA: 'AAPL', assetB: 'AAPL', covariance: 0.04 },
{ assetA: 'GOOG', assetB: 'GOOG', covariance: 0.05 },
{ assetA: 'AAPL', assetB: 'GOOG', covariance: 0.01 }, // only one side
];
},
async listExpectedReturns() { return { AAPL: 0.08, GOOG: 0.09 }; },
},
});
const m = await adapter.exportAsSparseMatrix();
const aIdx = m.nodeIndex['AAPL'];
const gIdx = m.nodeIndex['GOOG'];
const ag = m.entries.find((e) => e.row === aIdx && e.col === gIdx);
const ga = m.entries.find((e) => e.row === gIdx && e.col === aIdx);
expect(ag?.value).toBeCloseTo(0.01, 6);
expect(ga?.value).toBeCloseTo(0.01, 6);
});
it('CG solves Σx = μ to small residual', async () => {
const adapter = new PortfolioCovarianceAdapter({
portfolioId: 'p1',
ridge: 1e-3,
source: {
async listCovarianceEntries() {
return [
{ assetA: 'AAPL', assetB: 'AAPL', covariance: 0.04 },
{ assetA: 'GOOG', assetB: 'GOOG', covariance: 0.05 },
{ assetA: 'MSFT', assetB: 'MSFT', covariance: 0.03 },
{ assetA: 'AAPL', assetB: 'GOOG', covariance: 0.015 },
{ assetA: 'AAPL', assetB: 'MSFT', covariance: 0.018 },
{ assetA: 'GOOG', assetB: 'MSFT', covariance: 0.020 },
];
},
async listExpectedReturns() { return { AAPL: 0.08, GOOG: 0.09, MSFT: 0.07 }; },
},
});
const m = await adapter.exportAsSparseMatrix();
const mu = await adapter.expectedReturnsVector(m);
const { x, residualNorm, iterations } = conjugateGradient(m, mu, { epsilon: 1e-8, maxIter: 50 });
expect(x).toHaveLength(3);
expect(residualNorm).toBeLessThan(1e-6);
expect(iterations).toBeLessThan(20);
});
it('ridge keeps a rank-deficient matrix solvable', async () => {
// Two perfectly-correlated assets — empirical Σ is rank 1
const adapter = new PortfolioCovarianceAdapter({
portfolioId: 'p-corr',
ridge: 1e-3,
source: {
async listCovarianceEntries() {
return [
{ assetA: 'X', assetB: 'X', covariance: 0.01 },
{ assetA: 'Y', assetB: 'Y', covariance: 0.01 },
{ assetA: 'X', assetB: 'Y', covariance: 0.01 },
];
},
async listExpectedReturns() { return { X: 0.1, Y: 0.1 }; },
},
});
const m = await adapter.exportAsSparseMatrix();
const mu = await adapter.expectedReturnsVector(m);
const { residualNorm } = conjugateGradient(m, mu, { epsilon: 1e-6, maxIter: 100 });
expect(residualNorm).toBeLessThan(1e-4);
});
it('end-to-end via sublinear/solve', async () => {
const registry = getRegistry();
registerPortfolioCovarianceAdapter({
portfolioId: 'p2',
source: {
async listCovarianceEntries() {
return [
{ assetA: 'A', assetB: 'A', covariance: 0.04 },
{ assetA: 'B', assetB: 'B', covariance: 0.05 },
{ assetA: 'A', assetB: 'B', covariance: 0.01 },
];
},
async listExpectedReturns() { return { A: 0.1, B: 0.12 }; },
},
registry,
});
const tool = graphIntelligenceTools.find((t) => t.name === 'sublinear/solve');
const r = (await tool!.handler({
graphId: portfolioGraphId('p2'),
rhs: [0.1, 0.12],
algorithm: 'cg',
maxComplexityClass: 'polynomial',
})) as { success: boolean; result?: { x: number[]; residualNorm: number } };
expect(r.success).toBe(true);
expect(r.result?.x).toHaveLength(2);
expect(r.result?.residualNorm).toBeLessThan(1e-4);
});
});