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),
  };
}