@actuarial-ts/core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ilf.ts ADDED
@@ -0,0 +1,567 @@
1
+ import type { ClaimSnapshot } from "./types.js";
2
+ import { ReservingError } from "./types.js";
3
+ import { isNum } from "./util.js";
4
+
5
+ /**
6
+ * Increased-limits machinery: severity distributions, limited expected
7
+ * values, and the uncap factor E[X] / E[X ∧ cap] that restores developed
8
+ * capped ultimates to total limits.
9
+ *
10
+ * Severities are fitted at the cap's BASE-YEAR cost level (each claim
11
+ * deflated by the layer's index), so an indexed cap - a constant layer in
12
+ * real terms - yields one uncap factor for all origin years. Under a flat
13
+ * cap with real severity trend the factor truly varies by year; the fit
14
+ * warns in that case, and per-year refinement arrives with the trend module.
15
+ */
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Normal CDF (Abramowitz & Stegun 7.1.26; |error| < 1.5e-7)
19
+
20
+ function erf(x: number): number {
21
+ const sign = x < 0 ? -1 : 1;
22
+ const ax = Math.abs(x);
23
+ const t = 1 / (1 + 0.3275911 * ax);
24
+ const y =
25
+ 1 -
26
+ (((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t +
27
+ 0.254829592) *
28
+ t) *
29
+ Math.exp(-ax * ax);
30
+ return sign * y;
31
+ }
32
+
33
+ export function normalCdf(z: number): number {
34
+ return 0.5 * (1 + erf(z / Math.SQRT2));
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Severity distributions
39
+
40
+ export type SeverityDistribution =
41
+ | { kind: "lognormal"; mu: number; sigma: number }
42
+ | { kind: "pareto"; theta: number; alpha: number };
43
+
44
+ /** Mean E[X]; null when infinite (Pareto alpha <= 1). */
45
+ export function severityMean(dist: SeverityDistribution): number | null {
46
+ if (dist.kind === "lognormal") {
47
+ return Math.exp(dist.mu + (dist.sigma * dist.sigma) / 2);
48
+ }
49
+ if (dist.alpha <= 1) return null;
50
+ return dist.theta / (dist.alpha - 1);
51
+ }
52
+
53
+ /** Limited expected value E[X ∧ c]. */
54
+ export function limitedExpectedValue(dist: SeverityDistribution, c: number): number {
55
+ if (!isNum(c) || c <= 0) {
56
+ throw new ReservingError("BAD_LIMIT", "The limit for a LEV must be a positive number");
57
+ }
58
+ if (dist.kind === "lognormal") {
59
+ const { mu, sigma } = dist;
60
+ const z1 = (Math.log(c) - mu - sigma * sigma) / sigma;
61
+ const z2 = (Math.log(c) - mu) / sigma;
62
+ return Math.exp(mu + (sigma * sigma) / 2) * normalCdf(z1) + c * (1 - normalCdf(z2));
63
+ }
64
+ const { theta, alpha } = dist;
65
+ if (Math.abs(alpha - 1) < 1e-9) {
66
+ // alpha -> 1 limit of the closed form: theta * ln((c + theta)/theta)
67
+ return theta * Math.log((c + theta) / theta);
68
+ }
69
+ return (theta / (alpha - 1)) * (1 - Math.pow(theta / (c + theta), alpha - 1));
70
+ }
71
+
72
+ /**
73
+ * The uncap factor to a target limit: E[X ∧ target] / E[X ∧ cap], with
74
+ * target null meaning unlimited (E[X] / E[X ∧ cap]). Throws when the
75
+ * distribution has no finite mean and target is unlimited.
76
+ */
77
+ export function uncapFactor(
78
+ dist: SeverityDistribution,
79
+ cap: number,
80
+ target: number | null,
81
+ ): number {
82
+ const denominator = limitedExpectedValue(dist, cap);
83
+ if (!(denominator > 0)) {
84
+ throw new ReservingError("BAD_LIMIT", "E[X ∧ cap] is not positive; check the fit and cap");
85
+ }
86
+ if (target === null) {
87
+ const mean = severityMean(dist);
88
+ if (mean === null) {
89
+ throw new ReservingError(
90
+ "INFINITE_MEAN",
91
+ "This Pareto fit has alpha <= 1 (infinite mean); an unlimited restoration is undefined - pick a finite target limit or a different curve",
92
+ );
93
+ }
94
+ return mean / denominator;
95
+ }
96
+ if (target < cap) {
97
+ throw new ReservingError("BAD_LIMIT", "The restoration target must be at or above the cap");
98
+ }
99
+ const factor = limitedExpectedValue(dist, target) / denominator;
100
+ if (!Number.isFinite(factor)) {
101
+ throw new ReservingError(
102
+ "BAD_FIT",
103
+ "The fitted distribution produced a non-finite limited expected value (degenerate parameters); the fit is not usable for restoration",
104
+ );
105
+ }
106
+ return factor;
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Censoring-aware MLE (closed claims exact, open claims right-censored)
111
+
112
+ export interface SeverityObservation {
113
+ /** Severity at the claim's latest evaluation, at base-year cost level. */
114
+ value: number;
115
+ /**
116
+ * True when the claim is still open. Its reported incurred is then treated
117
+ * as a right-censoring floor on ultimate severity - a CASE-ADEQUACY
118
+ * ASSUMPTION, not a fact: redundant case reserves overstate the fitted
119
+ * tail, deficient ones understate it. Fits must carry this caveat.
120
+ */
121
+ censored: boolean;
122
+ }
123
+
124
+ export interface SeverityFit {
125
+ distribution: SeverityDistribution;
126
+ logLikelihood: number;
127
+ nExact: number;
128
+ nCensored: number;
129
+ nExcludedNonPositive: number;
130
+ valid: boolean;
131
+ warnings: string[];
132
+ /**
133
+ * Kaplan-Meier (censoring-adjusted) empirical quantiles vs fitted
134
+ * unconditional quantiles. Closed-claims-only quantiles are biased small
135
+ * (large claims stay open), so the empirical side uses the product-limit
136
+ * estimator; null where censoring exhausts the observable range.
137
+ */
138
+ quantileCheck: { p: number; empirical: number | null; fitted: number }[];
139
+ }
140
+
141
+ const QUANTILE_PROBES = [0.5, 0.75, 0.9, 0.95, 0.99];
142
+
143
+ function lognormalQuantile(mu: number, sigma: number, p: number): number {
144
+ // Inverse normal via Acklam's rational approximation (sufficient here).
145
+ const q = inverseNormalCdf(p);
146
+ return Math.exp(mu + sigma * q);
147
+ }
148
+
149
+ function paretoQuantile(theta: number, alpha: number, p: number): number {
150
+ return theta * (Math.pow(1 - p, -1 / alpha) - 1);
151
+ }
152
+
153
+ function inverseNormalCdf(p: number): number {
154
+ // Acklam's algorithm; |relative error| < 1.15e-9 over (0,1).
155
+ if (p <= 0 || p >= 1) throw new ReservingError("BAD_LIMIT", "quantile p must be in (0,1)");
156
+ const a = [-39.6968302866538, 220.946098424521, -275.928510446969, 138.357751867269,
157
+ -30.6647980661472, 2.50662827745924];
158
+ const b = [-54.4760987982241, 161.585836858041, -155.698979859887, 66.8013118877197,
159
+ -13.2806815528857];
160
+ const c = [-0.00778489400243029, -0.322396458041136, -2.40075827716184, -2.54973253934373,
161
+ 4.37466414146497, 2.93816398269878];
162
+ const d = [0.00778469570904146, 0.32246712907004, 2.445134137143, 3.75440866190742];
163
+ const pLow = 0.02425;
164
+ let q: number;
165
+ let r: number;
166
+ if (p < pLow) {
167
+ q = Math.sqrt(-2 * Math.log(p));
168
+ return (((((c[0]! * q + c[1]!) * q + c[2]!) * q + c[3]!) * q + c[4]!) * q + c[5]!) /
169
+ ((((d[0]! * q + d[1]!) * q + d[2]!) * q + d[3]!) * q + 1);
170
+ }
171
+ if (p <= 1 - pLow) {
172
+ q = p - 0.5;
173
+ r = q * q;
174
+ return ((((((a[0]! * r + a[1]!) * r + a[2]!) * r + a[3]!) * r + a[4]!) * r + a[5]!) * q) /
175
+ (((((b[0]! * r + b[1]!) * r + b[2]!) * r + b[3]!) * r + b[4]!) * r + 1);
176
+ }
177
+ q = Math.sqrt(-2 * Math.log(1 - p));
178
+ return -(((((c[0]! * q + c[1]!) * q + c[2]!) * q + c[3]!) * q + c[4]!) * q + c[5]!) /
179
+ ((((d[0]! * q + d[1]!) * q + d[2]!) * q + d[3]!) * q + 1);
180
+ }
181
+
182
+ /** Deterministic Nelder-Mead in 2D (fixed iteration budget, no randomness). */
183
+ function nelderMead2(
184
+ f: (p: [number, number]) => number,
185
+ start: [number, number],
186
+ scale: [number, number],
187
+ iterations = 400,
188
+ ): [number, number] {
189
+ type Pt = { x: [number, number]; v: number };
190
+ const mk = (x: [number, number]): Pt => ({ x, v: f(x) });
191
+ let simplex: Pt[] = [
192
+ mk(start),
193
+ mk([start[0] + scale[0], start[1]]),
194
+ mk([start[0], start[1] + scale[1]]),
195
+ ];
196
+ for (let it = 0; it < iterations; it++) {
197
+ simplex.sort((p, q) => p.v - q.v);
198
+ const [best, mid, worst] = simplex as [Pt, Pt, Pt];
199
+ const centroid: [number, number] = [
200
+ (best.x[0] + mid.x[0]) / 2,
201
+ (best.x[1] + mid.x[1]) / 2,
202
+ ];
203
+ const reflect = mk([
204
+ centroid[0] + (centroid[0] - worst.x[0]),
205
+ centroid[1] + (centroid[1] - worst.x[1]),
206
+ ]);
207
+ if (reflect.v < best.v) {
208
+ const expand = mk([
209
+ centroid[0] + 2 * (centroid[0] - worst.x[0]),
210
+ centroid[1] + 2 * (centroid[1] - worst.x[1]),
211
+ ]);
212
+ simplex[2] = expand.v < reflect.v ? expand : reflect;
213
+ } else if (reflect.v < mid.v) {
214
+ simplex[2] = reflect;
215
+ } else {
216
+ const contract = mk([
217
+ centroid[0] + 0.5 * (worst.x[0] - centroid[0]),
218
+ centroid[1] + 0.5 * (worst.x[1] - centroid[1]),
219
+ ]);
220
+ if (contract.v < worst.v) {
221
+ simplex[2] = contract;
222
+ } else {
223
+ // Shrink toward the best point.
224
+ simplex = [
225
+ best,
226
+ mk([(best.x[0] + mid.x[0]) / 2, (best.x[1] + mid.x[1]) / 2]),
227
+ mk([(best.x[0] + worst.x[0]) / 2, (best.x[1] + worst.x[1]) / 2]),
228
+ ];
229
+ }
230
+ }
231
+ }
232
+ simplex.sort((p, q) => p.v - q.v);
233
+ return simplex[0]!.x;
234
+ }
235
+
236
+ const MIN_OBSERVATIONS = 10;
237
+ const THIN_OBSERVATIONS = 30;
238
+
239
+ /**
240
+ * Fits a severity distribution by maximum likelihood with right-censoring:
241
+ * closed claims contribute the density, open claims the survival at their
242
+ * reported incurred. Treating reported incurred as a floor on ultimate
243
+ * severity is a CASE-ADEQUACY assumption - on books with redundant reserves
244
+ * the fitted tail (and any uncap factor from it) is overstated. Fits are
245
+ * additionally gated on censored share and parameter sanity: a likelihood
246
+ * dominated by open claims can push the tail arbitrarily heavy while looking
247
+ * converged.
248
+ */
249
+ export function fitSeverity(
250
+ observations: SeverityObservation[],
251
+ kind: SeverityDistribution["kind"],
252
+ ): SeverityFit {
253
+ const warnings: string[] = [];
254
+ const usable = observations.filter((o) => isNum(o.value) && o.value > 0);
255
+ const nExcluded = observations.length - usable.length;
256
+ const exact = usable.filter((o) => !o.censored).map((o) => o.value);
257
+ const censored = usable.filter((o) => o.censored).map((o) => o.value);
258
+
259
+ const invalid = (message: string): SeverityFit => ({
260
+ distribution:
261
+ kind === "lognormal"
262
+ ? { kind: "lognormal", mu: 0, sigma: 1 }
263
+ : { kind: "pareto", theta: 1, alpha: 2 },
264
+ logLikelihood: NaN,
265
+ nExact: exact.length,
266
+ nCensored: censored.length,
267
+ nExcludedNonPositive: nExcluded,
268
+ valid: false,
269
+ warnings: [...warnings, message],
270
+ quantileCheck: [],
271
+ });
272
+
273
+ if (usable.length < MIN_OBSERVATIONS) {
274
+ return invalid(
275
+ `Only ${usable.length} usable claim severities; at least ${MIN_OBSERVATIONS} are needed for a credible fit`,
276
+ );
277
+ }
278
+ if (exact.length < 5) {
279
+ return invalid(
280
+ `Only ${exact.length} closed (uncensored) claims; the likelihood is dominated by censoring and the fit is not credible`,
281
+ );
282
+ }
283
+ const censoredShare = censored.length / usable.length;
284
+ if (censoredShare > 0.8) {
285
+ return invalid(
286
+ `${Math.round(censoredShare * 100)}% of usable claims are open (right-censored): the likelihood is censoring-dominated and the fitted tail is not credible`,
287
+ );
288
+ }
289
+ if (censoredShare > 0.5) {
290
+ warnings.push(
291
+ `${Math.round(censoredShare * 100)}% of usable claims are open: the fit leans heavily on the case-adequacy assumption; treat the tail with caution`,
292
+ );
293
+ }
294
+ if (usable.length < THIN_OBSERVATIONS) {
295
+ warnings.push(
296
+ `Only ${usable.length} usable claim severities; treat the fitted tail with caution`,
297
+ );
298
+ }
299
+ if (nExcluded > 0) {
300
+ warnings.push(`${nExcluded} zero/negative severities excluded from the fit`);
301
+ }
302
+
303
+ let dist: SeverityDistribution;
304
+ let logLik: number;
305
+
306
+ if (kind === "lognormal") {
307
+ const negLogLik = (p: [number, number]): number => {
308
+ const mu = p[0];
309
+ const sigma = Math.exp(p[1]); // positivity via log-space
310
+ let ll = 0;
311
+ for (const x of exact) {
312
+ const z = (Math.log(x) - mu) / sigma;
313
+ ll += -Math.log(sigma * x) - 0.5 * Math.log(2 * Math.PI) - 0.5 * z * z;
314
+ }
315
+ for (const x of censored) {
316
+ const s = 1 - normalCdf((Math.log(x) - mu) / sigma);
317
+ ll += Math.log(Math.max(s, 1e-300));
318
+ }
319
+ return -ll;
320
+ };
321
+ // Method-of-moments start on log(exact).
322
+ const logs = exact.map((x) => Math.log(x));
323
+ const m = logs.reduce((a, v) => a + v, 0) / logs.length;
324
+ const sd = Math.sqrt(
325
+ Math.max(1e-6, logs.reduce((a, v) => a + (v - m) ** 2, 0) / Math.max(1, logs.length - 1)),
326
+ );
327
+ const best = nelderMead2(negLogLik, [m, Math.log(sd)], [0.5, 0.5]);
328
+ dist = { kind: "lognormal", mu: best[0], sigma: Math.exp(best[1]) };
329
+ logLik = -negLogLik(best);
330
+ } else {
331
+ const negLogLik = (p: [number, number]): number => {
332
+ const theta = Math.exp(p[0]);
333
+ const alpha = Math.exp(p[1]);
334
+ let ll = 0;
335
+ for (const x of exact) {
336
+ ll += Math.log(alpha) + alpha * Math.log(theta) - (alpha + 1) * Math.log(x + theta);
337
+ }
338
+ for (const x of censored) {
339
+ ll += alpha * (Math.log(theta) - Math.log(x + theta));
340
+ }
341
+ return -ll;
342
+ };
343
+ const mean = exact.reduce((a, v) => a + v, 0) / exact.length;
344
+ const best = nelderMead2(negLogLik, [Math.log(mean), Math.log(2)], [1, 0.5]);
345
+ dist = { kind: "pareto", theta: Math.exp(best[0]), alpha: Math.exp(best[1]) };
346
+ logLik = -negLogLik(best);
347
+ }
348
+
349
+ // Parameter-sanity gates: an optimizer that "converged" onto a degenerate
350
+ // or implausibly heavy tail is a failed fit for restoration purposes.
351
+ const problems: string[] = [];
352
+ if (!Number.isFinite(logLik)) {
353
+ problems.push("The likelihood did not converge to a finite value");
354
+ }
355
+ if (dist.kind === "lognormal") {
356
+ if (dist.sigma < 1e-6) {
357
+ problems.push("Fitted sigma is ~0 (degenerate point mass); the fit is not usable");
358
+ } else if (dist.sigma > 3.5) {
359
+ problems.push(
360
+ `Fitted lognormal sigma ${dist.sigma.toFixed(2)} implies an implausibly heavy tail - typically a censoring-dominated likelihood; the fit is not usable for restoration`,
361
+ );
362
+ } else if (dist.sigma > 2.5) {
363
+ warnings.push(
364
+ `Fitted lognormal sigma ${dist.sigma.toFixed(2)} is very heavy for casualty severity; verify against the claim-size distribution before restoring with it`,
365
+ );
366
+ }
367
+ } else {
368
+ if (dist.alpha <= 1) {
369
+ problems.push(
370
+ `Fitted Pareto alpha ${dist.alpha.toFixed(3)} <= 1 (infinite mean): the fit is not usable for restoration at ANY target`,
371
+ );
372
+ } else if (dist.alpha < 1.2) {
373
+ warnings.push(
374
+ `Fitted Pareto alpha ${dist.alpha.toFixed(3)} is an extremely heavy tail; verify before restoring with it`,
375
+ );
376
+ }
377
+ }
378
+
379
+ const kmQuantiles = kaplanMeierQuantiles(exact, censored, QUANTILE_PROBES);
380
+ const quantileCheck = QUANTILE_PROBES.map((p, i) => ({
381
+ p,
382
+ empirical: kmQuantiles[i]!,
383
+ fitted:
384
+ dist.kind === "lognormal"
385
+ ? lognormalQuantile(dist.mu, dist.sigma, p)
386
+ : paretoQuantile(dist.theta, dist.alpha, p),
387
+ }));
388
+
389
+ return {
390
+ distribution: dist,
391
+ logLikelihood: logLik,
392
+ nExact: exact.length,
393
+ nCensored: censored.length,
394
+ nExcludedNonPositive: nExcluded,
395
+ valid: problems.length === 0,
396
+ warnings: [...warnings, ...problems],
397
+ quantileCheck,
398
+ };
399
+ }
400
+
401
+ /**
402
+ * Kaplan-Meier product-limit quantiles over exact + right-censored values:
403
+ * the censoring-adjusted empirical benchmark (closed-only quantiles are
404
+ * biased small because large claims stay open). Returns null for probes the
405
+ * survival curve cannot reach (censoring exhausts the observable range).
406
+ * Ties convention: events precede censorings at the same value.
407
+ */
408
+ export function kaplanMeierQuantiles(
409
+ exact: number[],
410
+ censored: number[],
411
+ probes: number[],
412
+ ): (number | null)[] {
413
+ const events = [...exact].sort((a, b) => a - b);
414
+ const all = [
415
+ ...exact.map((v) => ({ v, event: true })),
416
+ ...censored.map((v) => ({ v, event: false })),
417
+ ];
418
+ const n = all.length;
419
+ if (n === 0 || events.length === 0) return probes.map(() => null);
420
+
421
+ // Survival steps at each distinct event value.
422
+ const steps: { value: number; survival: number }[] = [];
423
+ let survival = 1;
424
+ const distinct = [...new Set(events)];
425
+ for (const t of distinct) {
426
+ const atRisk = all.filter((o) => o.v >= t).length; // censored ties still at risk
427
+ const d = events.filter((v) => v === t).length;
428
+ if (atRisk <= 0) break;
429
+ survival *= 1 - d / atRisk;
430
+ steps.push({ value: t, survival });
431
+ }
432
+
433
+ return probes.map((p) => {
434
+ const threshold = 1 - p;
435
+ for (const step of steps) {
436
+ if (step.survival <= threshold + 1e-12) return step.value;
437
+ }
438
+ return null; // curve never falls this far: censored beyond observable range
439
+ });
440
+ }
441
+
442
+ /**
443
+ * Structural validation shared by every ILF-table write path (import route,
444
+ * workspace patch) and by interpolation: >= 2 rows, positive limits and
445
+ * factors, unique limits, factors non-decreasing in the limit. Throws
446
+ * ReservingError so garbage is rejected at the door, identically everywhere.
447
+ */
448
+ export function validateIlfTable(table: IlfTableRow[]): IlfTableRow[] {
449
+ if (!Array.isArray(table) || table.length < 2) {
450
+ throw new ReservingError("BAD_TABLE", "An ILF table needs at least two limit/factor rows");
451
+ }
452
+ const rows = [...table].sort((a, b) => a.limit - b.limit);
453
+ for (const row of rows) {
454
+ if (!isNum(row.limit) || row.limit <= 0 || !isNum(row.factor) || row.factor <= 0) {
455
+ throw new ReservingError("BAD_TABLE", "ILF rows must have positive limits and factors");
456
+ }
457
+ }
458
+ for (let i = 1; i < rows.length; i++) {
459
+ if (rows[i]!.limit === rows[i - 1]!.limit) {
460
+ throw new ReservingError(
461
+ "BAD_TABLE",
462
+ `Duplicate limit ${rows[i]!.limit.toLocaleString("en-US")} in the ILF table`,
463
+ );
464
+ }
465
+ if (rows[i]!.factor < rows[i - 1]!.factor) {
466
+ throw new ReservingError("BAD_TABLE", "ILF factors must be non-decreasing in the limit");
467
+ }
468
+ }
469
+ return rows;
470
+ }
471
+
472
+ /**
473
+ * Base-year-level severity observations from a loss run: each claim's latest
474
+ * evaluation on or before asOfDate, deflated by the cap index, open claims
475
+ * censored at reported incurred.
476
+ */
477
+ export function severityObservations(
478
+ claims: ClaimSnapshot[],
479
+ options: { asOfDate?: string; indexRate?: number; baseYear: number },
480
+ ): SeverityObservation[] {
481
+ const asOf = options.asOfDate ?? "9999-12-31";
482
+ const indexRate = options.indexRate ?? 0;
483
+ const latest = new Map<string, ClaimSnapshot>();
484
+ for (const snap of claims) {
485
+ if (snap.evaluationDate > asOf) continue;
486
+ const prev = latest.get(snap.claimId);
487
+ if (!prev || snap.evaluationDate > prev.evaluationDate) latest.set(snap.claimId, snap);
488
+ }
489
+ return [...latest.values()].map((snap) => {
490
+ const year = Number(snap.accidentDate.slice(0, 4));
491
+ const incurred = snap.paidToDate + snap.caseReserve;
492
+ return {
493
+ value: incurred / Math.pow(1 + indexRate, year - options.baseYear),
494
+ censored: snap.status === "open",
495
+ };
496
+ });
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // ILF tables (imported) and illustrative curves
501
+
502
+ export interface IlfTableRow {
503
+ limit: number;
504
+ factor: number;
505
+ }
506
+
507
+ /**
508
+ * Log-log interpolated ILF at a limit. Exact at knots; throws when the
509
+ * table does not bracket the requested limit (extrapolating an ILF table is
510
+ * how excess layers get silently mispriced).
511
+ */
512
+ export function interpolateIlf(table: IlfTableRow[], limit: number): number {
513
+ const rows = validateIlfTable(table);
514
+ if (limit < rows[0]!.limit || limit > rows[rows.length - 1]!.limit) {
515
+ throw new ReservingError(
516
+ "TABLE_RANGE",
517
+ `The table covers limits ${rows[0]!.limit.toLocaleString("en-US")} to ${rows[rows.length - 1]!.limit.toLocaleString("en-US")}; ${limit.toLocaleString("en-US")} is outside it`,
518
+ );
519
+ }
520
+ for (let i = 1; i < rows.length; i++) {
521
+ const lo = rows[i - 1]!;
522
+ const hi = rows[i]!;
523
+ if (limit === lo.limit) return lo.factor;
524
+ if (limit === hi.limit) return hi.factor;
525
+ if (limit > lo.limit && limit < hi.limit) {
526
+ const t = (Math.log(limit) - Math.log(lo.limit)) / (Math.log(hi.limit) - Math.log(lo.limit));
527
+ return Math.exp(Math.log(lo.factor) + t * (Math.log(hi.factor) - Math.log(lo.factor)));
528
+ }
529
+ }
530
+ // Unreachable: the range check above guarantees the loop returns.
531
+ throw new ReservingError("TABLE_RANGE", "ILF interpolation failed to bracket a validated limit");
532
+ }
533
+
534
+ /** Table-based uncap factor: ILF(target)/ILF(cap). */
535
+ export function tableUncapFactor(table: IlfTableRow[], cap: number, target: number): number {
536
+ if (target < cap) {
537
+ throw new ReservingError("BAD_LIMIT", "The restoration target must be at or above the cap");
538
+ }
539
+ return interpolateIlf(table, target) / interpolateIlf(table, cap);
540
+ }
541
+
542
+ /**
543
+ * Illustrative severity curves for when no licensed table and no credible
544
+ * own-data fit exist. Parameters are textbook-plausible shapes, NOT ISO or
545
+ * NCCI factors - the UI must say so loudly.
546
+ */
547
+ export const ILLUSTRATIVE_CURVES: {
548
+ id: string;
549
+ label: string;
550
+ distribution: SeverityDistribution;
551
+ }[] = [
552
+ {
553
+ id: "casualty-lognormal-moderate",
554
+ label: "Illustrative casualty severity - lognormal, moderate tail",
555
+ distribution: { kind: "lognormal", mu: 9.2, sigma: 1.4 },
556
+ },
557
+ {
558
+ id: "casualty-lognormal-heavy",
559
+ label: "Illustrative casualty severity - lognormal, heavy tail",
560
+ distribution: { kind: "lognormal", mu: 9.0, sigma: 1.8 },
561
+ },
562
+ {
563
+ id: "liability-pareto",
564
+ label: "Illustrative liability severity - Pareto (theta 40k, alpha 1.8)",
565
+ distribution: { kind: "pareto", theta: 40_000, alpha: 1.8 },
566
+ },
567
+ ];
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ export * from "./types.js";
2
+ export * from "./util.js";
3
+ export * from "./canonical.js";
4
+ export * from "./triangle.js";
5
+ export * from "./factors.js";
6
+ export * from "./chainladder.js";
7
+ export * from "./bf.js";
8
+ export * from "./benktander.js";
9
+ export * from "./freqSev.js";
10
+ export * from "./stochastic.js";
11
+ export * from "./triangleAlgebra.js";
12
+ export * from "./odpBootstrap.js";
13
+ export * from "./tail.js";
14
+ export * from "./berquist.js";
15
+ export * from "./clark.js";
16
+ export * from "./diagnostics.js";
17
+ export * from "./mack.js";
18
+ export * from "./capping.js";
19
+ export * from "./ilf.js";
20
+ export * from "./trend.js";
21
+ export * from "./onlevel.js";
22
+ export * from "./elrMethods.js";
23
+ export * from "./merzWuthrich.js";
24
+ export * from "./munichChainLadder.js";
25
+ export * from "./ulae.js";
26
+ export * from "./discounting.js";
27
+ export * from "./caseOutstanding.js";
28
+ export * from "./fisherLange.js";
29
+ export * from "./salvageSubro.js";