Spaces:
Paused
Paused
File size: 9,356 Bytes
22a56cb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | // Scenario simulation under explicit annual growth assumptions. These draws describe the
// specified model; sample count is not evidence of real-world forecast calibration.
import { forecastUnit, convertForecastValue } from "./forecastUnits.ts";
export type ForecastResult = {
ok: true;
engine: "monte_carlo_fermi";
probability: number;
median: number;
ci_low: number;
ci_high: number;
threshold: number;
threshold_dir: string;
horizon_years: number;
base_value: number;
n_samples: number;
histogram: { lo: number; hi: number; counts: number[]; peak: number };
};
export type Support = "count" | "fraction" | "positive" | "real";
export type ForecastSpec = {
question?: string;
base_value: number;
horizon_years: number;
g_mean?: number;
g_sd?: number;
decel?: number;
threshold: number;
threshold_dir?: string;
ci_unit?: string;
support?: Support;
base_unit?: string;
threshold_unit?: string;
seed?: number;
n?: number;
};
const MAX_HORIZON = 100;
const MAX_SAMPLES = 200_000;
function finite(value: unknown, name: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${name} must be a finite number`);
return value;
}
/** Validate before provider work and normalize inputs into the declared output unit. */
export function validateForecastSpec(spec: ForecastSpec): ForecastSpec {
if (!spec || typeof spec !== "object" || Array.isArray(spec)) throw new Error("forecast specification must be an object");
for (const name of ["ci_unit", "base_unit", "threshold_unit"] as const) {
if (spec[name] !== undefined && typeof spec[name] !== "string") throw new Error(`${name} must be a unit string`);
}
const unit = spec.ci_unit ?? "";
const base = convertForecastValue(finite(spec.base_value, "base_value"), spec.base_unit ?? unit, unit);
const threshold = convertForecastValue(finite(spec.threshold, "threshold"), spec.threshold_unit ?? unit, unit);
const horizon = finite(spec.horizon_years, "horizon_years");
const mean = spec.g_mean === undefined ? 1 : finite(spec.g_mean, "g_mean");
const sd = spec.g_sd === undefined ? .1 : finite(spec.g_sd, "g_sd");
const decel = spec.decel === undefined ? 0 : finite(spec.decel, "decel");
const n = spec.n === undefined ? 80_000 : finite(spec.n, "n");
if (horizon < 0 || horizon > MAX_HORIZON) throw new Error(`horizon_years must be between 0 and ${MAX_HORIZON}`);
if (mean <= 0 || sd < 0 || decel < 0) throw new Error("g_mean must be positive; g_sd and decel must be nonnegative");
if (mean - decel * Math.max(0, Math.ceil(horizon) - 1) <= 0) throw new Error("annual growth multiplier must remain positive over the horizon");
if (!Number.isInteger(n) || n < 1 || n > MAX_SAMPLES) throw new Error(`n must be an integer between 1 and ${MAX_SAMPLES}`);
if (spec.threshold_dir !== undefined && spec.threshold_dir !== ">=" && spec.threshold_dir !== "<=") throw new Error("threshold_dir must be >= or <=");
if (spec.seed !== undefined && (!Number.isInteger(spec.seed) || spec.seed < 0 || spec.seed > 0xffffffff)) throw new Error("seed must be an unsigned 32-bit integer");
if (spec.question !== undefined && typeof spec.question !== "string") throw new Error("question must be a string");
const u = forecastUnit(unit);
const support = spec.support ?? u.kind;
if (!["count", "fraction", "positive"].includes(support)) throw new Error("unsupported support: this growth model requires a count, fraction, or positive quantity");
if (support === "count" && u.kind !== "count" && unit) throw new Error("count support requires a discrete count unit");
if (support === "fraction" && u.kind !== "fraction" && unit) throw new Error("fraction support requires fraction or percent units");
if (u.kind === "fraction" && support !== "fraction") throw new Error("fraction units require bounded fraction support");
if (base < 0 || threshold < 0) throw new Error("base_value and threshold must be nonnegative for this support");
if (support === "fraction" && (base * u.scale > 1 || threshold * u.scale > 1)) throw new Error("fraction base_value and threshold must lie within their unit's 0–1 range");
if (![base * u.scale, threshold * u.scale].every(Number.isFinite)) throw new Error("canonical unit conversion overflow");
return {...spec, base_value: base, threshold, base_unit: unit, threshold_unit: unit, horizon_years: horizon,
g_mean: mean, g_sd: sd, decel, n, support, threshold_dir: spec.threshold_dir ?? ">="};
}
/** Deterministic 32-bit seed from the question (matches the intent of the Python sha256 seed). */
export function seedFromQuestion(s: string): number {
let h = 2166136261 >>> 0; // FNV-1a
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
function mulberry32(seed: number) {
let a = seed >>> 0;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Standard-normal sampler (Box-Muller) over a uniform RNG. */
function makeGauss(rand: () => number) {
return (mean: number, sd: number) => {
let u = 0;
let v = 0;
while (u === 0) u = rand();
while (v === 0) v = rand();
const z = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
return mean + sd * z;
};
}
function histogram(sorted: number[], bins = 28) {
const n = sorted.length;
const lo = sorted[Math.floor(0.02 * n)];
const hi = sorted[Math.floor(0.98 * n)];
if (hi <= lo) return {lo, hi: lo, counts: [n], peak: n};
const width = (hi - lo) / bins;
const counts = new Array(bins).fill(0);
for (const value of sorted) {
if (value < lo || value > hi) continue;
const idx = Math.min(bins - 1, Math.floor((value - lo) / width));
counts[idx] += 1;
}
const peak = Math.max(...counts) || 1;
return { lo, hi, counts, peak };
}
export function runForecast(input: ForecastSpec): ForecastResult {
const spec = validateForecastSpec(input);
const baseVal = spec.base_value;
const horizonYears = spec.horizon_years;
const gMean = spec.g_mean!;
const gSd = spec.g_sd!;
const decel = spec.decel!;
const threshold = spec.threshold;
const direction = spec.threshold_dir!;
const n = spec.n!;
// A default seed independent of display units makes equivalent representations comparable.
const seed = spec.seed ?? seedFromQuestion("forecast");
const support = spec.support!;
const scale = forecastUnit(spec.ci_unit ?? "").scale;
const canonicalBase = baseVal * scale;
const rand = mulberry32(seed);
const gauss = makeGauss(rand);
// Lognormal annual multipliers match the supplied mean and standard deviation. Partial
// years scale log drift by dt and log variance by dt. Deceleration changes each year's
// mean; it cannot make the multiplier negative. This also supports declines below 15%.
const steps: {drift: number; sigma: number}[] = [];
for (let year = 0; year < Math.ceil(horizonYears); year++) {
const dt = Math.min(1, horizonYears - year);
const mean = gMean - decel * year;
const variance = Math.log1p((gSd / mean) ** 2);
const drift = (Math.log(mean) - variance / 2) * dt;
const sigma = Math.sqrt(variance * dt);
if (!Number.isFinite(drift) || !Number.isFinite(sigma)) throw new Error("growth parameters exceed the supported numeric range");
steps.push({drift, sigma});
}
const out = new Array<number>(n);
const canonicalThreshold = threshold * scale;
let beyond = 0;
for (let i = 0; i < n; i++) {
let logGrowth = 0;
for (const step of steps) logGrowth += step.sigma ? gauss(step.drift, step.sigma) : step.drift;
let v = canonicalBase;
if (horizonYears > 0) {
if (support === "fraction") {
if (v > 0 && v < 1 && logGrowth !== 0) {
const logOdds = Math.log(v / (1 - v)) + logGrowth;
v = logOdds >= 0 ? 1 / (1 + Math.exp(-logOdds)) : Math.exp(logOdds) / (1 + Math.exp(logOdds));
}
} else {
const growth = Math.exp(logGrowth);
v = canonicalBase === 0 ? 0 : Number.isFinite(growth)
? canonicalBase * growth : Math.exp(Math.log(canonicalBase) + logGrowth);
if (support === "count") {
// Count noise is in individual units, never in thousands or physical capacity units.
v = Math.max(0, gauss(v, Math.sqrt(v * Math.min(horizonYears, 1))));
}
}
}
const displayed = horizonYears === 0 ? baseVal : v / scale;
if (!Number.isFinite(displayed)) throw new Error("forecast samples exceed the supported numeric range");
out[i] = displayed;
// Compare in canonical units, before display conversion can round a boundary value.
if (direction === "<=" ? v <= canonicalThreshold : v >= canonicalThreshold) beyond++;
}
out.sort((a, b) => a - b);
const pct = (p: number) => out[Math.floor(p * out.length)];
return {
ok: true,
engine: "monte_carlo_fermi",
probability: Math.round((beyond / out.length) * 1000) / 1000,
median: pct(0.5),
ci_low: pct(0.1),
ci_high: pct(0.9),
threshold,
threshold_dir: direction,
horizon_years: horizonYears,
base_value: baseVal,
n_samples: out.length,
histogram: histogram(out),
};
}
|