| import math |
| import sys |
| import unittest |
| from pathlib import Path |
|
|
| SERVICE_ROOT = Path(__file__).resolve().parents[1] |
| sys.path.insert(0, str(SERVICE_ROOT)) |
|
|
| import ensemble |
|
|
|
|
| def _blend(member_tf, member_ch, qtype="event", prices=None, horizon=5): |
| """Blend two flat member forecasts. `prices` defaults to a flat history at the TimesFM |
| member's level so `last` (the base-rate anchor) matches the forecast level.""" |
| if prices is None: |
| prices = [member_tf] * 15 |
| return ensemble.blend( |
| prices=prices, |
| timesfm_fc=[member_tf] * horizon, |
| chronos_fc=[member_ch] * horizon, |
| question_type=qtype, |
| horizon=horizon, |
| ) |
|
|
|
|
| class EnsembleCoherenceTests(unittest.TestCase): |
| """The ensemble must be a COMBINATION of its members: its level stays within/near the member |
| range. Regression tests for the 2026-07-23 live audit, where the extremise step drove a market |
| at 3.4% to 0% and one at 17% to 7% — an "ensemble" that no longer combined its members and that |
| the Next.js client now has to withhold (app/lib/ensemble.ts guard). TimesFM and Chronos-2 |
| forecast the SAME market price series, so extremising their aggregate away from 0.5 is unsound. |
| """ |
|
|
| def test_confident_low_event_forecast_is_not_collapsed_to_zero(self): |
| fc = _blend(0.0342, 0.034)["ensemble"]["forecast"] |
| |
| self.assertGreater(min(fc), 0.0) |
| for p in fc: |
| self.assertAlmostEqual(p, 0.034, delta=0.01) |
|
|
| def test_confident_mid_low_event_forecast_is_not_halved(self): |
| out = _blend(0.17, 0.17) |
| fc = out["ensemble"]["forecast"] |
| |
| for p in fc: |
| self.assertAlmostEqual(p, 0.17, delta=0.01) |
| self.assertEqual(out["ensemble"]["trend"], "flat") |
|
|
| def test_confident_high_event_forecast_is_not_clamped_to_one(self): |
| fc = _blend(0.9, 0.9)["ensemble"]["forecast"] |
| for p in fc: |
| self.assertAlmostEqual(p, 0.9, delta=0.01) |
|
|
| def test_ensemble_level_stays_within_member_band_across_the_range(self): |
| |
| |
| for p in [0.01, 0.03, 0.05, 0.1, 0.17, 0.3, 0.45, 0.55, 0.7, 0.9, 0.97, 0.99]: |
| fc = _blend(p, p)["ensemble"]["forecast"] |
| mean_fc = sum(fc) / len(fc) |
| self.assertGreaterEqual(mean_fc, p - 0.05, f"underflow at p={p}: {mean_fc}") |
| self.assertLessEqual(mean_fc, p + 0.05, f"overflow at p={p}: {mean_fc}") |
|
|
| def test_brier_estimate_reflects_the_coherent_probability(self): |
| |
| brier = _blend(0.0342, 0.034)["ensemble"]["brier_estimate"] |
| self.assertAlmostEqual(brier, 0.03, delta=0.01) |
|
|
|
|
| class EnsembleKeptBehaviourTests(unittest.TestCase): |
| """Behaviour the fix must NOT regress.""" |
|
|
| def test_base_rate_prior_still_pulls_coin_flip_toward_half(self): |
| |
| fc = _blend(0.45, 0.45)["ensemble"]["forecast"] |
| mean_fc = sum(fc) / len(fc) |
| self.assertGreater(mean_fc, 0.45) |
| self.assertLess(mean_fc, 0.50) |
| self.assertAlmostEqual(mean_fc, 0.4575, delta=0.005) |
|
|
| def test_numeric_path_is_a_weighted_blend(self): |
| |
| fc = _blend(0.10, 0.20, qtype="numeric")["ensemble"]["forecast"] |
| for p in fc: |
| self.assertAlmostEqual(p, 0.4 * 0.10 + 0.6 * 0.20, delta=1e-6) |
|
|
| def test_forecast_values_are_bounded_and_finite(self): |
| for p in [0.0, 0.02, 0.5, 0.98, 1.0]: |
| for x in _blend(p, p)["ensemble"]["forecast"]: |
| self.assertTrue(math.isfinite(x)) |
| self.assertGreaterEqual(x, 0.0) |
| self.assertLessEqual(x, 1.0) |
|
|
|
|
| if __name__ == "__main__": |
| unittest.main() |
|
|