@actuarial-ts/core 0.6.0 → 0.6.1

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.
Files changed (103) hide show
  1. package/README.md +3 -3
  2. package/dist/capping.d.ts.map +1 -1
  3. package/dist/capping.js +39 -6
  4. package/dist/capping.js.map +1 -1
  5. package/dist/diagnosticAggregation.d.ts +3 -0
  6. package/dist/diagnosticAggregation.d.ts.map +1 -1
  7. package/dist/diagnosticAggregation.js +30 -7
  8. package/dist/diagnosticAggregation.js.map +1 -1
  9. package/dist/diagnosticDefinitions.d.ts +3 -0
  10. package/dist/diagnosticDefinitions.d.ts.map +1 -1
  11. package/dist/diagnosticDefinitions.js +844 -182
  12. package/dist/diagnosticDefinitions.js.map +1 -1
  13. package/dist/diagnosticDerivations.d.ts +23 -0
  14. package/dist/diagnosticDerivations.d.ts.map +1 -1
  15. package/dist/diagnosticDerivations.js +187 -43
  16. package/dist/diagnosticDerivations.js.map +1 -1
  17. package/dist/diagnosticExposure.d.ts +2 -2
  18. package/dist/diagnosticExposure.d.ts.map +1 -1
  19. package/dist/diagnosticExposure.js +228 -20
  20. package/dist/diagnosticExposure.js.map +1 -1
  21. package/dist/diagnosticExpressions.d.ts +1 -1
  22. package/dist/diagnosticExpressions.d.ts.map +1 -1
  23. package/dist/diagnosticExpressions.js +35 -15
  24. package/dist/diagnosticExpressions.js.map +1 -1
  25. package/dist/diagnosticFormulas.d.ts +3 -0
  26. package/dist/diagnosticFormulas.d.ts.map +1 -1
  27. package/dist/diagnosticFormulas.js +160 -26
  28. package/dist/diagnosticFormulas.js.map +1 -1
  29. package/dist/diagnosticIdentity.d.ts +15 -1
  30. package/dist/diagnosticIdentity.d.ts.map +1 -1
  31. package/dist/diagnosticIdentity.js +318 -51
  32. package/dist/diagnosticIdentity.js.map +1 -1
  33. package/dist/diagnosticOrdering.d.ts +8 -0
  34. package/dist/diagnosticOrdering.d.ts.map +1 -0
  35. package/dist/diagnosticOrdering.js +73 -0
  36. package/dist/diagnosticOrdering.js.map +1 -0
  37. package/dist/diagnosticPeriodAxis.d.ts +9 -0
  38. package/dist/diagnosticPeriodAxis.d.ts.map +1 -0
  39. package/dist/diagnosticPeriodAxis.js +69 -0
  40. package/dist/diagnosticPeriodAxis.js.map +1 -0
  41. package/dist/diagnosticPeriods.d.ts +2 -3
  42. package/dist/diagnosticPeriods.d.ts.map +1 -1
  43. package/dist/diagnosticPeriods.js +20 -68
  44. package/dist/diagnosticPeriods.js.map +1 -1
  45. package/dist/diagnosticPreparation.d.ts +47 -14
  46. package/dist/diagnosticPreparation.d.ts.map +1 -1
  47. package/dist/diagnosticPreparation.js +1482 -112
  48. package/dist/diagnosticPreparation.js.map +1 -1
  49. package/dist/diagnosticReview.d.ts +36 -14
  50. package/dist/diagnosticReview.d.ts.map +1 -1
  51. package/dist/diagnosticReview.js +448 -42
  52. package/dist/diagnosticReview.js.map +1 -1
  53. package/dist/diagnosticRunner.d.ts +2 -1
  54. package/dist/diagnosticRunner.d.ts.map +1 -1
  55. package/dist/diagnosticRunner.js +656 -70
  56. package/dist/diagnosticRunner.js.map +1 -1
  57. package/dist/diagnosticRuntime.d.ts +26 -0
  58. package/dist/diagnosticRuntime.d.ts.map +1 -0
  59. package/dist/diagnosticRuntime.js +243 -0
  60. package/dist/diagnosticRuntime.js.map +1 -0
  61. package/dist/diagnosticSourceOrdering.d.ts +6 -0
  62. package/dist/diagnosticSourceOrdering.d.ts.map +1 -0
  63. package/dist/diagnosticSourceOrdering.js +38 -0
  64. package/dist/diagnosticSourceOrdering.js.map +1 -0
  65. package/dist/index.d.ts +8 -4
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +5 -1
  68. package/dist/index.js.map +1 -1
  69. package/dist/mack.d.ts +4 -0
  70. package/dist/mack.d.ts.map +1 -1
  71. package/dist/mack.js +32 -4
  72. package/dist/mack.js.map +1 -1
  73. package/dist/triangle.d.ts.map +1 -1
  74. package/dist/triangle.js +59 -9
  75. package/dist/triangle.js.map +1 -1
  76. package/dist/types.d.ts +4 -0
  77. package/dist/types.d.ts.map +1 -1
  78. package/dist/types.js +22 -8
  79. package/dist/types.js.map +1 -1
  80. package/dist/version.d.ts +1 -1
  81. package/dist/version.js +1 -1
  82. package/package.json +3 -2
  83. package/src/capping.ts +109 -18
  84. package/src/diagnosticAggregation.ts +69 -20
  85. package/src/diagnosticDefinitions.ts +2675 -426
  86. package/src/diagnosticDerivations.ts +281 -49
  87. package/src/diagnosticExposure.ts +441 -50
  88. package/src/diagnosticExpressions.ts +99 -17
  89. package/src/diagnosticFormulas.ts +239 -39
  90. package/src/diagnosticIdentity.ts +691 -191
  91. package/src/diagnosticOrdering.ts +106 -0
  92. package/src/diagnosticPeriodAxis.ts +92 -0
  93. package/src/diagnosticPeriods.ts +67 -82
  94. package/src/diagnosticPreparation.ts +2369 -127
  95. package/src/diagnosticReview.ts +864 -58
  96. package/src/diagnosticRunner.ts +1076 -72
  97. package/src/diagnosticRuntime.ts +264 -0
  98. package/src/diagnosticSourceOrdering.ts +50 -0
  99. package/src/index.ts +31 -3
  100. package/src/mack.ts +72 -9
  101. package/src/triangle.ts +103 -13
  102. package/src/types.ts +55 -20
  103. package/src/version.ts +1 -1
package/src/capping.ts CHANGED
@@ -39,7 +39,10 @@ function accidentYear(snap: ClaimSnapshot): number {
39
39
  * diverge from what the exhibits report; services should resolve it once via
40
40
  * this helper and pass baseYear explicitly to both.
41
41
  */
42
- export function latestAccidentYear(claims: ClaimSnapshot[], asOfDate?: string): number {
42
+ export function latestAccidentYear(
43
+ claims: ClaimSnapshot[],
44
+ asOfDate?: string,
45
+ ): number {
43
46
  const asOf = asOfDate ?? "9999-12-31";
44
47
  let year = -Infinity;
45
48
  for (const snap of claims) {
@@ -56,25 +59,61 @@ export function latestAccidentYear(claims: ClaimSnapshot[], asOfDate?: string):
56
59
  }
57
60
 
58
61
  /** The effective cap for one accident year under the index. */
59
- export function effectiveCap(options: Required<CapOptions>, year: number): number {
60
- return options.cap * Math.pow(1 + options.indexRate, year - options.baseYear);
62
+ export function effectiveCap(
63
+ options: Required<CapOptions>,
64
+ year: number,
65
+ ): number {
66
+ if (
67
+ !isNum(options.cap) ||
68
+ options.cap <= 0 ||
69
+ !isNum(options.indexRate) ||
70
+ options.indexRate <= -1 ||
71
+ !Number.isSafeInteger(options.baseYear) ||
72
+ !Number.isSafeInteger(year)
73
+ ) {
74
+ throw new ReservingError(
75
+ "BAD_CAP",
76
+ "Cap, index rate, base year, and accident year must define a finite positive layer",
77
+ );
78
+ }
79
+ const value =
80
+ options.cap * Math.pow(1 + options.indexRate, year - options.baseYear);
81
+ if (!Number.isFinite(value) || value <= 0)
82
+ throw new ReservingError(
83
+ "BAD_CAP",
84
+ "Indexed effective cap must be finite and positive",
85
+ );
86
+ return value;
61
87
  }
62
88
 
63
- function resolveOptions(claims: ClaimSnapshot[], options: CapOptions): Required<CapOptions> {
89
+ function resolveOptions(
90
+ claims: ClaimSnapshot[],
91
+ options: CapOptions,
92
+ ): Required<CapOptions> {
64
93
  if (!isNum(options.cap) || options.cap <= 0) {
65
- throw new ReservingError("BAD_CAP", "The per-occurrence cap must be a positive number");
94
+ throw new ReservingError(
95
+ "BAD_CAP",
96
+ "The per-occurrence cap must be a positive number",
97
+ );
66
98
  }
67
99
  const indexRate = options.indexRate ?? 0;
68
100
  if (!isNum(indexRate) || indexRate <= -1) {
69
- throw new ReservingError("BAD_CAP", "The cap index rate must be a number greater than -100%");
101
+ throw new ReservingError(
102
+ "BAD_CAP",
103
+ "The cap index rate must be a number greater than -100%",
104
+ );
70
105
  }
71
106
  let baseYear = options.baseYear ?? null;
72
107
  if (baseYear === null) {
73
108
  baseYear = -Infinity;
74
- for (const snap of claims) baseYear = Math.max(baseYear, accidentYear(snap));
109
+ for (const snap of claims)
110
+ baseYear = Math.max(baseYear, accidentYear(snap));
75
111
  }
76
- if (!isNum(baseYear) || !Number.isFinite(baseYear)) {
77
- throw new ReservingError("BAD_CAP", "Could not resolve a base year for the cap index");
112
+ if (!isNum(baseYear) || !Number.isSafeInteger(baseYear)) {
113
+ throw new ReservingError(
114
+ "BAD_CAP",
115
+ "Could not resolve a base year for the cap index",
116
+ );
78
117
  }
79
118
  return { cap: options.cap, indexRate, baseYear };
80
119
  }
@@ -86,14 +125,18 @@ function resolveOptions(claims: ClaimSnapshot[], options: CapOptions): Required<
86
125
  * negative by construction). Claim counts and statuses are untouched - the
87
126
  * cap limits dollars, not claims.
88
127
  */
89
- export function capClaims(claims: ClaimSnapshot[], options: CapOptions): ClaimSnapshot[] {
128
+ export function capClaims(
129
+ claims: ClaimSnapshot[],
130
+ options: CapOptions,
131
+ ): ClaimSnapshot[] {
90
132
  const resolved = resolveOptions(claims, options);
91
133
  return claims.map((snap) => {
92
134
  const capY = effectiveCap(resolved, accidentYear(snap));
93
135
  const incurred = snap.paidToDate + snap.caseReserve;
94
136
  const cappedIncurred = Math.min(incurred, capY);
95
137
  const cappedPaid = Math.min(snap.paidToDate, capY);
96
- if (cappedIncurred === incurred && cappedPaid === snap.paidToDate) return snap;
138
+ if (cappedIncurred === incurred && cappedPaid === snap.paidToDate)
139
+ return snap;
97
140
  return {
98
141
  ...snap,
99
142
  paidToDate: cappedPaid,
@@ -161,7 +204,10 @@ const PERCENTILES = [0.5, 0.75, 0.9, 0.95, 0.99];
161
204
  // interpolated refinement in stochastic.ts's percentileOfSorted buys nothing.
162
205
  function percentile(sorted: number[], p: number): number {
163
206
  if (sorted.length === 0) return 0;
164
- const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1));
207
+ const idx = Math.min(
208
+ sorted.length - 1,
209
+ Math.max(0, Math.ceil(p * sorted.length) - 1),
210
+ );
165
211
  return sorted[idx]!;
166
212
  }
167
213
 
@@ -183,7 +229,10 @@ export function claimSizeDiagnostics(
183
229
  options: ClaimSizeDiagnosticsOptions = {},
184
230
  ): ClaimSizeDiagnostics {
185
231
  if (claims.length === 0) {
186
- throw new ReservingError("NO_CLAIMS", "Cannot analyze claim sizes with no claims");
232
+ throw new ReservingError(
233
+ "NO_CLAIMS",
234
+ "Cannot analyze claim sizes with no claims",
235
+ );
187
236
  }
188
237
  const asOf = options.asOfDate ?? "9999-12-31";
189
238
  // Latest evaluation per claim on or before the analysis date.
@@ -191,20 +240,48 @@ export function claimSizeDiagnostics(
191
240
  for (const snap of claims) {
192
241
  if (snap.evaluationDate > asOf) continue;
193
242
  const prev = latest.get(snap.claimId);
194
- if (!prev || snap.evaluationDate > prev.evaluationDate) latest.set(snap.claimId, snap);
243
+ if (!prev || snap.evaluationDate > prev.evaluationDate)
244
+ latest.set(snap.claimId, snap);
195
245
  }
196
246
 
197
247
  const byYear = new Map<number, number[]>();
198
248
  for (const snap of latest.values()) {
199
249
  const year = accidentYear(snap);
200
250
  const incurred = snap.paidToDate + snap.caseReserve;
251
+ if (
252
+ !Number.isSafeInteger(year) ||
253
+ year < 1 ||
254
+ year > 9999 ||
255
+ !Number.isFinite(incurred) ||
256
+ incurred < 0
257
+ ) {
258
+ throw new ReservingError(
259
+ "BAD_LOSSES",
260
+ "Claim-size diagnostics require valid accident years and finite nonnegative incurred amounts",
261
+ );
262
+ }
201
263
  let arr = byYear.get(year);
202
264
  if (!arr) byYear.set(year, (arr = []));
203
265
  arr.push(incurred);
204
266
  }
205
267
  const years = [...byYear.keys()].sort((a, b) => a - b);
206
268
  const indexRate = options.indexRate ?? 0;
269
+ if (years.length === 0)
270
+ throw new ReservingError(
271
+ "NO_CLAIMS",
272
+ "No claim snapshots on or before the analysis date; cannot analyze claim sizes",
273
+ );
274
+ if (!isNum(indexRate) || indexRate <= -1)
275
+ throw new ReservingError(
276
+ "BAD_CAP",
277
+ "The cap index rate must be finite and greater than -100%",
278
+ );
207
279
  const baseYear = options.baseYear ?? years[years.length - 1]!;
280
+ if (!Number.isSafeInteger(baseYear))
281
+ throw new ReservingError(
282
+ "BAD_CAP",
283
+ "The cap base year must be a safe integer",
284
+ );
208
285
 
209
286
  const yearRows: ClaimSizeYearRow[] = years.map((year) => {
210
287
  const sorted = [...byYear.get(year)!].sort((a, b) => a - b);
@@ -213,7 +290,10 @@ export function claimSizeDiagnostics(
213
290
  claimCount: sorted.length,
214
291
  totalIncurred: sorted.reduce((a, v) => a + v, 0),
215
292
  maxClaim: sorted[sorted.length - 1] ?? 0,
216
- percentiles: PERCENTILES.map((p) => ({ p, value: percentile(sorted, p) })),
293
+ percentiles: PERCENTILES.map((p) => ({
294
+ p,
295
+ value: percentile(sorted, p),
296
+ })),
217
297
  };
218
298
  });
219
299
 
@@ -223,6 +303,16 @@ export function claimSizeDiagnostics(
223
303
  // incurred is deflated to that level before taking percentiles - otherwise
224
304
  // a nominal anchor re-indexed forward overshoots the whole distribution
225
305
  // and every derived candidate reads near-zero pierce.
306
+ for (const [label, values] of [
307
+ ["candidateCaps", options.candidateCaps],
308
+ ["extraCaps", options.extraCaps],
309
+ ] as const) {
310
+ if (values?.some((value) => !isNum(value) || value <= 0))
311
+ throw new ReservingError(
312
+ "BAD_CAP",
313
+ `${label} must contain only finite positive caps`,
314
+ );
315
+ }
226
316
  let candidateCaps = options.candidateCaps;
227
317
  if (!candidateCaps || candidateCaps.length === 0) {
228
318
  const pooled = [...latest.values()]
@@ -251,7 +341,7 @@ export function claimSizeDiagnostics(
251
341
  let totalExcess = 0;
252
342
  let totalIncurred = 0;
253
343
  const cells: CapCandidateYearCell[] = years.map((year) => {
254
- const capY = cap * Math.pow(1 + resolved.indexRate, year - resolved.baseYear);
344
+ const capY = effectiveCap({ ...resolved, cap }, year);
255
345
  const values = byYear.get(year)!;
256
346
  let pierce = 0;
257
347
  let excess = 0;
@@ -289,7 +379,8 @@ export function claimSizeDiagnostics(
289
379
  candidates,
290
380
  baseYear,
291
381
  indexRate,
292
- nonZeroClaimCount: [...latest.values()].filter((s) => s.paidToDate + s.caseReserve > 0)
293
- .length,
382
+ nonZeroClaimCount: [...latest.values()].filter(
383
+ (s) => s.paidToDate + s.caseReserve > 0,
384
+ ).length,
294
385
  };
295
386
  }
@@ -1,10 +1,18 @@
1
- import type { DiagnosticMeasureStats, DiagnosticMissingPolicy, DiagnosticSourceLocation } from "./diagnosticDefinitions.js";
1
+ import type {
2
+ DiagnosticMeasureStats,
3
+ DiagnosticMissingPolicy,
4
+ DiagnosticSourceLocation,
5
+ } from "./diagnosticDefinitions.js";
6
+ import type { DiagnosticMetricFinding } from "./diagnosticFormulas.js";
7
+ import { compareDiagnosticContributions } from "./diagnosticOrdering.js";
2
8
 
3
9
  export interface DiagnosticStructuralBlocker {
4
10
  readonly code: string;
5
11
  readonly message: string;
6
12
  readonly sourceIds: readonly string[];
7
13
  readonly sources: readonly DiagnosticSourceLocation[];
14
+ /** Exact preparation finding whose dependency this blocker represents. */
15
+ readonly finding?: DiagnosticMetricFinding;
8
16
  }
9
17
 
10
18
  export interface DiagnosticMeasureContributionBase {
@@ -14,10 +22,23 @@ export interface DiagnosticMeasureContributionBase {
14
22
  }
15
23
 
16
24
  export type DiagnosticMeasureContribution =
17
- | (DiagnosticMeasureContributionBase & { readonly status: "observed"; readonly value: number })
18
- | (DiagnosticMeasureContributionBase & { readonly status: "imputed-zero"; readonly value: 0 })
19
- | (DiagnosticMeasureContributionBase & { readonly status: "missing"; readonly value: null })
20
- | (DiagnosticMeasureContributionBase & { readonly status: "non-finite"; readonly value: null; readonly nonFiniteKind: "nan" | "positive-infinity" | "negative-infinity" });
25
+ | (DiagnosticMeasureContributionBase & {
26
+ readonly status: "observed";
27
+ readonly value: number;
28
+ })
29
+ | (DiagnosticMeasureContributionBase & {
30
+ readonly status: "imputed-zero";
31
+ readonly value: 0;
32
+ })
33
+ | (DiagnosticMeasureContributionBase & {
34
+ readonly status: "missing";
35
+ readonly value: null;
36
+ })
37
+ | (DiagnosticMeasureContributionBase & {
38
+ readonly status: "non-finite";
39
+ readonly value: null;
40
+ readonly nonFiniteKind: "nan" | "positive-infinity" | "negative-infinity";
41
+ });
21
42
 
22
43
  export function auditedDiagnosticContribution(
23
44
  sourceId: string,
@@ -26,17 +47,35 @@ export function auditedDiagnosticContribution(
26
47
  sources: readonly DiagnosticSourceLocation[] = [],
27
48
  deduplicated = 0,
28
49
  ): DiagnosticMeasureContribution {
29
- const base = { sourceId, sources: Object.freeze([...sources]), deduplicated } as const;
30
- if (value === null || value === undefined) return missing === "zero"
31
- ? Object.freeze({ ...base, status: "imputed-zero" as const, value: 0 as const })
32
- : Object.freeze({ ...base, status: "missing" as const, value: null });
33
- if (!Number.isFinite(value)) return Object.freeze({
50
+ const base = {
51
+ sourceId,
52
+ sources: Object.freeze([...sources]),
53
+ deduplicated,
54
+ } as const;
55
+ if (value === null || value === undefined)
56
+ return missing === "zero"
57
+ ? Object.freeze({
58
+ ...base,
59
+ status: "imputed-zero" as const,
60
+ value: 0 as const,
61
+ })
62
+ : Object.freeze({ ...base, status: "missing" as const, value: null });
63
+ if (!Number.isFinite(value))
64
+ return Object.freeze({
65
+ ...base,
66
+ status: "non-finite" as const,
67
+ value: null,
68
+ nonFiniteKind: Number.isNaN(value)
69
+ ? ("nan" as const)
70
+ : value > 0
71
+ ? ("positive-infinity" as const)
72
+ : ("negative-infinity" as const),
73
+ });
74
+ return Object.freeze({
34
75
  ...base,
35
- status: "non-finite" as const,
36
- value: null,
37
- nonFiniteKind: Number.isNaN(value) ? "nan" as const : value > 0 ? "positive-infinity" as const : "negative-infinity" as const,
76
+ status: "observed" as const,
77
+ value: Object.is(value, -0) ? 0 : value,
38
78
  });
39
- return Object.freeze({ ...base, status: "observed" as const, value: Object.is(value, -0) ? 0 : value });
40
79
  }
41
80
 
42
81
  function neumaier(values: readonly number[]): number | null {
@@ -44,7 +83,10 @@ function neumaier(values: readonly number[]): number | null {
44
83
  let correction = 0;
45
84
  for (const value of values) {
46
85
  const next = sum + value;
47
- correction += Math.abs(sum) >= Math.abs(value) ? (sum - next) + value : (value - next) + sum;
86
+ correction +=
87
+ Math.abs(sum) >= Math.abs(value)
88
+ ? sum - next + value
89
+ : value - next + sum;
48
90
  sum = next;
49
91
  if (!Number.isFinite(sum) || !Number.isFinite(correction)) return null;
50
92
  }
@@ -57,15 +99,22 @@ export function finalizeDiagnosticContributions(
57
99
  missingPolicy: DiagnosticMissingPolicy,
58
100
  blockers: readonly DiagnosticStructuralBlocker[] = [],
59
101
  ): DiagnosticMeasureStats {
60
- const ordered = [...contributions].sort((left, right) => left.sourceId < right.sourceId ? -1 : left.sourceId > right.sourceId ? 1 : 0);
102
+ const ordered = [...contributions].sort(compareDiagnosticContributions);
61
103
  const observed = ordered.filter((item) => item.status === "observed");
62
104
  const imputed = ordered.filter((item) => item.status === "imputed-zero");
63
- const missing = ordered.filter((item) => item.status === "missing").length + imputed.length;
64
- const nonFinite = ordered.filter((item) => item.status === "non-finite").length;
65
- const finiteSum = neumaier([...observed.map((item) => item.value), ...imputed.map(() => 0)]);
105
+ const missing =
106
+ ordered.filter((item) => item.status === "missing").length + imputed.length;
107
+ const nonFinite = ordered.filter(
108
+ (item) => item.status === "non-finite",
109
+ ).length;
110
+ const finiteSum = neumaier([
111
+ ...observed.map((item) => item.value),
112
+ ...imputed.map(() => 0),
113
+ ]);
66
114
  const blocked = blockers.length > 0;
67
115
  const sum = nonFinite > 0 || blocked || finiteSum === null ? null : finiteSum;
68
- const value = sum === null || (missingPolicy === "unknown" && missing > 0) ? null : sum;
116
+ const value =
117
+ sum === null || (missingPolicy === "unknown" && missing > 0) ? null : sum;
69
118
  return Object.freeze({
70
119
  value,
71
120
  sum,