/**
 * Forecasting Scoring Engine — Unit Tests
 *
 * Tests for brierScore, logScore, calibrationBins, isotonicCalibrate,
 * averageBrier, averageLogScore, formatForecastDiff.
 *
 * All functions are pure — no Convex, no SQLite, no network.
 */
import { describe, it, expect } from "vitest";
import { brierScore, logScore, calibrationBins, averageBrier, averageLogScore, isotonicCalibrate, formatForecastDiff, } from "../../../../convex/domains/forecasting/scoringEngine";
// ─── Brier Score ────────────────────────────────────────────────────────────
describe("brierScore", () => {
    it("perfect prediction — yes at 100%", () => {
        expect(brierScore(1.0, "yes")).toBe(0);
    });
    it("perfect prediction — no at 0%", () => {
        expect(brierScore(0.0, "no")).toBe(0);
    });
    it("worst prediction — yes at 0%", () => {
        expect(brierScore(0.0, "yes")).toBe(1);
    });
    it("worst prediction — no at 100%", () => {
        expect(brierScore(1.0, "no")).toBe(1);
    });
    it("coin flip — 50% on yes", () => {
        expect(brierScore(0.5, "yes")).toBe(0.25);
    });
    it("coin flip — 50% on no", () => {
        expect(brierScore(0.5, "no")).toBe(0.25);
    });
    it("65% on yes outcome", () => {
        expect(brierScore(0.65, "yes")).toBeCloseTo(0.1225, 4);
    });
    it("80% on no outcome", () => {
        expect(brierScore(0.8, "no")).toBeCloseTo(0.64, 4);
    });
});
// ─── Log Score ──────────────────────────────────────────────────────────────
describe("logScore", () => {
    it("perfect prediction — yes at ~100%", () => {
        expect(logScore(0.999, "yes")).toBeCloseTo(0.001, 2);
    });
    it("perfect prediction — no at ~0%", () => {
        expect(logScore(0.001, "no")).toBeCloseTo(0.001, 2);
    });
    it("worst prediction — yes at ~0%", () => {
        // -log(0.001) ≈ 6.9
        expect(logScore(0.001, "yes")).toBeCloseTo(6.908, 1);
    });
    it("coin flip — 50% on yes", () => {
        // -log(0.5) ≈ 0.693
        expect(logScore(0.5, "yes")).toBeCloseTo(0.693, 2);
    });
    it("clamps extreme values to avoid -Infinity", () => {
        const score = logScore(0.0, "yes");
        expect(Number.isFinite(score)).toBe(true);
        expect(score).toBeGreaterThan(0);
    });
    it("clamps 1.0 for no outcome", () => {
        const score = logScore(1.0, "no");
        expect(Number.isFinite(score)).toBe(true);
        expect(score).toBeGreaterThan(0);
    });
});
// ─── Calibration Bins ───────────────────────────────────────────────────────
describe("calibrationBins", () => {
    it("returns 10 bins", () => {
        const bins = calibrationBins([]);
        expect(bins).toHaveLength(10);
    });
    it("first bin is 0-10%", () => {
        const bins = calibrationBins([]);
        expect(bins[0].binLabel).toBe("0-10%");
        expect(bins[0].predictedProb).toBe(0.05);
    });
    it("last bin is 90-100%", () => {
        const bins = calibrationBins([]);
        expect(bins[9].binLabel).toBe("90-100%");
        expect(bins[9].predictedProb).toBe(0.95);
    });
    it("empty bins have count 0 and observedFreq 0", () => {
        const bins = calibrationBins([]);
        for (const bin of bins) {
            expect(bin.count).toBe(0);
            expect(bin.observedFreq).toBe(0);
        }
    });
    it("correctly bins a single forecast", () => {
        const bins = calibrationBins([{ probability: 0.75, outcome: "yes" }]);
        const bin70 = bins[7]; // 70-80%
        expect(bin70.count).toBe(1);
        expect(bin70.observedFreq).toBe(1); // 1/1 = yes
    });
    it("correctly computes observed frequency", () => {
        const forecasts = [
            { probability: 0.55, outcome: "yes" },
            { probability: 0.52, outcome: "no" },
            { probability: 0.58, outcome: "yes" },
            { probability: 0.51, outcome: "no" },
        ];
        const bins = calibrationBins(forecasts);
        const bin50 = bins[5]; // 50-60%
        expect(bin50.count).toBe(4);
        expect(bin50.observedFreq).toBe(0.5); // 2/4
    });
    it("boundary value 1.0 goes in 90-100% bin", () => {
        const bins = calibrationBins([{ probability: 1.0, outcome: "yes" }]);
        expect(bins[9].count).toBe(1);
    });
    it("boundary value 0.0 goes in 0-10% bin", () => {
        const bins = calibrationBins([{ probability: 0.0, outcome: "no" }]);
        expect(bins[0].count).toBe(1);
    });
});
// ─── Average Brier ──────────────────────────────────────────────────────────
describe("averageBrier", () => {
    it("returns 0 for empty array", () => {
        expect(averageBrier([])).toBe(0);
    });
    it("returns single score for single forecast", () => {
        expect(averageBrier([{ probability: 0.7, outcome: "yes" }])).toBeCloseTo(0.09, 2);
    });
    it("averages multiple forecasts", () => {
        const forecasts = [
            { probability: 1.0, outcome: "yes" }, // 0
            { probability: 0.0, outcome: "yes" }, // 1
        ];
        expect(averageBrier(forecasts)).toBe(0.5);
    });
});
// ─── Average Log Score ──────────────────────────────────────────────────────
describe("averageLogScore", () => {
    it("returns 0 for empty array", () => {
        expect(averageLogScore([])).toBe(0);
    });
    it("lower for better-calibrated forecasts", () => {
        const good = [
            { probability: 0.9, outcome: "yes" },
            { probability: 0.1, outcome: "no" },
        ];
        const bad = [
            { probability: 0.1, outcome: "yes" },
            { probability: 0.9, outcome: "no" },
        ];
        expect(averageLogScore(good)).toBeLessThan(averageLogScore(bad));
    });
});
// ─── Isotonic Calibration ───────────────────────────────────────────────────
describe("isotonicCalibrate", () => {
    it("returns raw probability with fewer than 3 non-empty bins", () => {
        const sparse = calibrationBins([
            { probability: 0.15, outcome: "yes" },
            { probability: 0.85, outcome: "no" },
        ]);
        expect(isotonicCalibrate(0.5, sparse)).toBe(0.5);
    });
    it("returns a value in [0, 1]", () => {
        // Create bins with enough data
        const forecasts = Array.from({ length: 50 }, (_, i) => ({
            probability: (i + 0.5) / 50,
            outcome: (Math.random() > 0.5 ? "yes" : "no"),
        }));
        const bins = calibrationBins(forecasts);
        const calibrated = isotonicCalibrate(0.7, bins);
        expect(calibrated).toBeGreaterThanOrEqual(0);
        expect(calibrated).toBeLessThanOrEqual(1);
    });
    it("produces monotonically non-decreasing output for ordered inputs", () => {
        // Create well-populated bins
        const forecasts = [];
        for (let i = 0; i < 10; i++) {
            for (let j = 0; j < 10; j++) {
                const p = (i * 10 + j + 0.5) / 100;
                // Roughly calibrated: higher p → more "yes"
                const outcome = Math.random() < p ? "yes" : "no";
                forecasts.push({ probability: p, outcome: outcome });
            }
        }
        const bins = calibrationBins(forecasts);
        const inputs = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
        const outputs = inputs.map((p) => isotonicCalibrate(p, bins));
        for (let i = 0; i < outputs.length - 1; i++) {
            expect(outputs[i]).toBeLessThanOrEqual(outputs[i + 1] + 0.05); // allow for random data noise in PAV
        }
    });
});
// ─── Format Forecast Diff ───────────────────────────────────────────────────
describe("formatForecastDiff", () => {
    it("formats increase correctly", () => {
        const diff = formatForecastDiff(0.35, 0.55, "New evidence from Fed minutes");
        expect(diff).toBe("35% → 55% (+20pp): New evidence from Fed minutes");
    });
    it("formats decrease correctly", () => {
        const diff = formatForecastDiff(0.8, 0.6, "CEO resignation");
        expect(diff).toBe("80% → 60% (-20pp): CEO resignation");
    });
    it("formats no change", () => {
        const diff = formatForecastDiff(0.5, 0.5, "No new evidence");
        expect(diff).toBe("50% → 50% (+0pp): No new evidence");
    });
});
//# sourceMappingURL=forecastingScoring.test.js.map