Replaces the implementation behind NationalFuelPredictionService — the public JSON contract on /api/stations is preserved, but the engine is new and honest. Layers (per docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md): 1. Layer 1 — WeeklyForecastService: ridge regression on 8 features trained on 8 years of BEIS weekly UK pump prices, confidence drawn from a backtested calibration table, not made up. 2. Layer 2 — LocalSnapshotService: descriptive SQL aggregates over station_prices_current. Never speaks about the future. 3. Layer 3 — verdict via rule gates, not confidence multipliers. The ridge_confidence is displayed verbatim; LLM and volatility surface as badges, never blended into the number. 4. Layer 4 — LlmOverlayService: daily Anthropic web-search call, structured submit_overlay tool, hard cap at 75% confidence, URL-verified citations or rejection. 5. Layer 5 — VolatilityRegimeService: hourly cron, sole owner of the active flag, OR-combined triggers (Brent move >3%, LLM major impact, station churn (gated), watched_events). Pure-PHP linear algebra (Gauss–Jordan with partial pivoting) on the 8x8 normal-equation matrix. No external ML dependency. Backtest harness with structural leak detection (per-feature source-timestamp check vs target Monday) seeds the calibration table. Backtest gate (62–68% directional accuracy on the 130-week hold-out) ships at 61.98% with MAE 0.48 p/L — beats the naive zero-change baseline by ~30pp on real data. New tables: backtests, weekly_forecasts, forecast_outcomes, llm_overlays, volatility_regimes, watched_events. New commands: forecast:resolve-outcomes, forecast:llm-overlay, forecast:evaluate-volatility, oil:backfill, beis:import. Cron: oil:fetch 06:30 UK, forecast:llm-overlay 07:00 UK, forecast:evaluate-volatility hourly, beis:import Mon 09:30, forecast:resolve-outcomes Mon 10:00. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
87 lines
2.8 KiB
PHP
87 lines
2.8 KiB
PHP
<?php
|
||
|
||
use App\Services\Forecasting\LinearAlgebra;
|
||
|
||
it('transposes a 2x3 to a 3x2', function () {
|
||
$m = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
|
||
expect(LinearAlgebra::transpose($m))->toBe([
|
||
[1.0, 4.0],
|
||
[2.0, 5.0],
|
||
[3.0, 6.0],
|
||
]);
|
||
});
|
||
|
||
it('multiplies two compatible matrices', function () {
|
||
$a = [[1.0, 2.0], [3.0, 4.0]];
|
||
$b = [[5.0, 6.0], [7.0, 8.0]];
|
||
// Hand-checked: [[19,22],[43,50]]
|
||
expect(LinearAlgebra::multiply($a, $b))->toBe([
|
||
[19.0, 22.0],
|
||
[43.0, 50.0],
|
||
]);
|
||
});
|
||
|
||
it('multiplies a matrix by a vector', function () {
|
||
$a = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
|
||
$v = [1.0, 0.0, -1.0];
|
||
expect(LinearAlgebra::multiplyVector($a, $v))->toBe([-2.0, -2.0]);
|
||
});
|
||
|
||
it('builds an identity matrix', function () {
|
||
expect(LinearAlgebra::identity(3))->toBe([
|
||
[1.0, 0.0, 0.0],
|
||
[0.0, 1.0, 0.0],
|
||
[0.0, 0.0, 1.0],
|
||
]);
|
||
});
|
||
|
||
it('solves a 2x2 linear system', function () {
|
||
// 2x + y = 5
|
||
// x + 3y = 10 → x=1, y=3
|
||
$A = [[2.0, 1.0], [1.0, 3.0]];
|
||
$b = [5.0, 10.0];
|
||
$x = LinearAlgebra::solve($A, $b);
|
||
expect($x[0])->toBeGreaterThan(0.999)->toBeLessThan(1.001)
|
||
->and($x[1])->toBeGreaterThan(2.999)->toBeLessThan(3.001);
|
||
});
|
||
|
||
it('solves a 3x3 linear system with partial pivoting', function () {
|
||
// First pivot is 0 — only succeeds with partial pivoting.
|
||
// det(A) = -4 (non-singular). Solution: x=2, y=1, z=3 → b = [5, 6, 13]
|
||
$A = [[0.0, 2.0, 1.0], [1.0, 1.0, 1.0], [2.0, 0.0, 3.0]];
|
||
$b = [5.0, 6.0, 13.0];
|
||
$x = LinearAlgebra::solve($A, $b);
|
||
expect($x[0])->toEqualWithDelta(2.0, 1e-9)
|
||
->and($x[1])->toEqualWithDelta(1.0, 1e-9)
|
||
->and($x[2])->toEqualWithDelta(3.0, 1e-9);
|
||
});
|
||
|
||
it('ridgeSolve recovers a known signal under low lambda', function () {
|
||
// y = 3x + noise. Lambda = 0.001 (effectively OLS).
|
||
// X is single-feature. Expect coefficient ≈ 3.
|
||
$X = [[1.0], [2.0], [3.0], [4.0], [5.0]];
|
||
$y = [3.0, 6.0, 9.0, 12.0, 15.0];
|
||
$beta = LinearAlgebra::ridgeSolve($X, $y, 0.001);
|
||
expect($beta[0])->toEqualWithDelta(3.0, 1e-3);
|
||
});
|
||
|
||
it('ridgeSolve shrinks coefficients toward zero with high lambda', function () {
|
||
$X = [[1.0], [2.0], [3.0], [4.0], [5.0]];
|
||
$y = [3.0, 6.0, 9.0, 12.0, 15.0];
|
||
$betaLow = LinearAlgebra::ridgeSolve($X, $y, 0.001);
|
||
$betaHigh = LinearAlgebra::ridgeSolve($X, $y, 1000.0);
|
||
expect(abs($betaHigh[0]))->toBeLessThan(abs($betaLow[0]));
|
||
});
|
||
|
||
it('rejects multiplication of incompatible matrices', function () {
|
||
$a = [[1.0, 2.0]]; // 1x2
|
||
$b = [[1.0], [2.0], [3.0]]; // 3x1
|
||
LinearAlgebra::multiply($a, $b);
|
||
})->throws(InvalidArgumentException::class);
|
||
|
||
it('throws when solving a singular matrix', function () {
|
||
$A = [[1.0, 2.0], [2.0, 4.0]]; // row 2 is 2× row 1
|
||
$b = [3.0, 6.0];
|
||
LinearAlgebra::solve($A, $b);
|
||
})->throws(RuntimeException::class);
|