@actuarial-ts/core 0.6.0 → 0.7.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/README.md +12 -3
- package/dist/canonical.d.ts +11 -0
- package/dist/canonical.d.ts.map +1 -1
- package/dist/canonical.js +136 -26
- package/dist/canonical.js.map +1 -1
- package/dist/capping.d.ts.map +1 -1
- package/dist/capping.js +39 -6
- package/dist/capping.js.map +1 -1
- package/dist/diagnosticAggregation.d.ts +3 -0
- package/dist/diagnosticAggregation.d.ts.map +1 -1
- package/dist/diagnosticAggregation.js +30 -7
- package/dist/diagnosticAggregation.js.map +1 -1
- package/dist/diagnosticDefinitions.d.ts +3 -0
- package/dist/diagnosticDefinitions.d.ts.map +1 -1
- package/dist/diagnosticDefinitions.js +844 -182
- package/dist/diagnosticDefinitions.js.map +1 -1
- package/dist/diagnosticDerivations.d.ts +23 -0
- package/dist/diagnosticDerivations.d.ts.map +1 -1
- package/dist/diagnosticDerivations.js +187 -43
- package/dist/diagnosticDerivations.js.map +1 -1
- package/dist/diagnosticEvidenceIntern.d.ts +16 -0
- package/dist/diagnosticEvidenceIntern.d.ts.map +1 -0
- package/dist/diagnosticEvidenceIntern.js +139 -0
- package/dist/diagnosticEvidenceIntern.js.map +1 -0
- package/dist/diagnosticExposure.d.ts +2 -2
- package/dist/diagnosticExposure.d.ts.map +1 -1
- package/dist/diagnosticExposure.js +319 -21
- package/dist/diagnosticExposure.js.map +1 -1
- package/dist/diagnosticExpressions.d.ts +1 -1
- package/dist/diagnosticExpressions.d.ts.map +1 -1
- package/dist/diagnosticExpressions.js +35 -15
- package/dist/diagnosticExpressions.js.map +1 -1
- package/dist/diagnosticFormulas.d.ts +3 -0
- package/dist/diagnosticFormulas.d.ts.map +1 -1
- package/dist/diagnosticFormulas.js +160 -26
- package/dist/diagnosticFormulas.js.map +1 -1
- package/dist/diagnosticIdentity.d.ts +15 -1
- package/dist/diagnosticIdentity.d.ts.map +1 -1
- package/dist/diagnosticIdentity.js +321 -51
- package/dist/diagnosticIdentity.js.map +1 -1
- package/dist/diagnosticIdentityStream.d.ts +56 -0
- package/dist/diagnosticIdentityStream.d.ts.map +1 -0
- package/dist/diagnosticIdentityStream.js +472 -0
- package/dist/diagnosticIdentityStream.js.map +1 -0
- package/dist/diagnosticOrdering.d.ts +8 -0
- package/dist/diagnosticOrdering.d.ts.map +1 -0
- package/dist/diagnosticOrdering.js +73 -0
- package/dist/diagnosticOrdering.js.map +1 -0
- package/dist/diagnosticPeriodAxis.d.ts +9 -0
- package/dist/diagnosticPeriodAxis.d.ts.map +1 -0
- package/dist/diagnosticPeriodAxis.js +69 -0
- package/dist/diagnosticPeriodAxis.js.map +1 -0
- package/dist/diagnosticPeriods.d.ts +2 -3
- package/dist/diagnosticPeriods.d.ts.map +1 -1
- package/dist/diagnosticPeriods.js +20 -68
- package/dist/diagnosticPeriods.js.map +1 -1
- package/dist/diagnosticPreparation.d.ts +75 -14
- package/dist/diagnosticPreparation.d.ts.map +1 -1
- package/dist/diagnosticPreparation.js +1606 -112
- package/dist/diagnosticPreparation.js.map +1 -1
- package/dist/diagnosticReview.d.ts +40 -15
- package/dist/diagnosticReview.d.ts.map +1 -1
- package/dist/diagnosticReview.js +600 -42
- package/dist/diagnosticReview.js.map +1 -1
- package/dist/diagnosticReviewSources.d.ts +18 -0
- package/dist/diagnosticReviewSources.d.ts.map +1 -0
- package/dist/diagnosticReviewSources.js +76 -0
- package/dist/diagnosticReviewSources.js.map +1 -0
- package/dist/diagnosticReviewStore.d.ts +88 -0
- package/dist/diagnosticReviewStore.d.ts.map +1 -0
- package/dist/diagnosticReviewStore.js +455 -0
- package/dist/diagnosticReviewStore.js.map +1 -0
- package/dist/diagnosticRunner.d.ts +27 -2
- package/dist/diagnosticRunner.d.ts.map +1 -1
- package/dist/diagnosticRunner.js +764 -70
- package/dist/diagnosticRunner.js.map +1 -1
- package/dist/diagnosticRuntime.d.ts +26 -0
- package/dist/diagnosticRuntime.d.ts.map +1 -0
- package/dist/diagnosticRuntime.js +243 -0
- package/dist/diagnosticRuntime.js.map +1 -0
- package/dist/diagnosticSourceOrdering.d.ts +6 -0
- package/dist/diagnosticSourceOrdering.d.ts.map +1 -0
- package/dist/diagnosticSourceOrdering.js +38 -0
- package/dist/diagnosticSourceOrdering.js.map +1 -0
- package/dist/fnvAccumulator.d.ts +6 -0
- package/dist/fnvAccumulator.d.ts.map +1 -0
- package/dist/fnvAccumulator.js +21 -0
- package/dist/fnvAccumulator.js.map +1 -0
- package/dist/index.d.ts +15 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/mack.d.ts +4 -0
- package/dist/mack.d.ts.map +1 -1
- package/dist/mack.js +32 -4
- package/dist/mack.js.map +1 -1
- package/dist/triangle.d.ts.map +1 -1
- package/dist/triangle.js +59 -9
- package/dist/triangle.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +22 -8
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/src/canonical.ts +144 -26
- package/src/capping.ts +109 -18
- package/src/diagnosticAggregation.ts +69 -20
- package/src/diagnosticDefinitions.ts +2675 -426
- package/src/diagnosticDerivations.ts +281 -49
- package/src/diagnosticEvidenceIntern.ts +151 -0
- package/src/diagnosticExposure.ts +550 -50
- package/src/diagnosticExpressions.ts +99 -17
- package/src/diagnosticFormulas.ts +239 -39
- package/src/diagnosticIdentity.ts +700 -191
- package/src/diagnosticIdentityStream.ts +553 -0
- package/src/diagnosticOrdering.ts +106 -0
- package/src/diagnosticPeriodAxis.ts +92 -0
- package/src/diagnosticPeriods.ts +67 -82
- package/src/diagnosticPreparation.ts +2558 -132
- package/src/diagnosticReview.ts +1072 -58
- package/src/diagnosticReviewSources.ts +85 -0
- package/src/diagnosticReviewStore.ts +692 -0
- package/src/diagnosticRunner.ts +1273 -72
- package/src/diagnosticRuntime.ts +264 -0
- package/src/diagnosticSourceOrdering.ts +50 -0
- package/src/fnvAccumulator.ts +20 -0
- package/src/index.ts +69 -4
- package/src/mack.ts +72 -9
- package/src/triangle.ts +103 -13
- package/src/types.ts +55 -20
- package/src/version.ts +1 -1
package/src/canonical.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ReservingError } from "./types.js";
|
|
2
|
+
import { createFnvAccumulator } from "./fnvAccumulator.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Canonical JSON serialization and the FNV-1a integrity hash — the SDK's
|
|
@@ -34,18 +35,35 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
34
35
|
return proto === Object.prototype || proto === null;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
interface CanonicalSink {
|
|
39
|
+
write(text: string): void;
|
|
40
|
+
quote(text: string): void;
|
|
41
|
+
startContainer(token: "[" | "{"): void;
|
|
42
|
+
endContainer(token: "]" | "}"): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One traversal owns validation and property access order for both outputs. */
|
|
46
|
+
function canonicalize(value: unknown, path: string, seen: Set<object>, sink: CanonicalSink): void {
|
|
47
|
+
if (value === null) {
|
|
48
|
+
sink.write("null");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
39
51
|
switch (typeof value) {
|
|
40
52
|
case "string":
|
|
41
|
-
|
|
53
|
+
sink.quote(value);
|
|
54
|
+
return;
|
|
42
55
|
case "boolean":
|
|
43
|
-
|
|
56
|
+
sink.write(value ? "true" : "false");
|
|
57
|
+
return;
|
|
44
58
|
case "number": {
|
|
45
59
|
if (!Number.isFinite(value)) {
|
|
46
|
-
throw new ReservingError(
|
|
60
|
+
throw new ReservingError(
|
|
61
|
+
"UNSUPPORTED_VALUE",
|
|
62
|
+
`non-finite number (${String(value)}) at ${path}`,
|
|
63
|
+
);
|
|
47
64
|
}
|
|
48
|
-
|
|
65
|
+
sink.write(Object.is(value, -0) ? "0" : String(value));
|
|
66
|
+
return;
|
|
49
67
|
}
|
|
50
68
|
case "undefined":
|
|
51
69
|
throw new ReservingError("UNSUPPORTED_VALUE", `undefined at ${path}`);
|
|
@@ -62,20 +80,24 @@ function canonicalize(value: unknown, path: string, seen: Set<object>): string {
|
|
|
62
80
|
throw new ReservingError("UNSUPPORTED_VALUE", `circular reference at ${path}`);
|
|
63
81
|
}
|
|
64
82
|
seen.add(obj);
|
|
65
|
-
let out: string;
|
|
66
83
|
if (Array.isArray(obj)) {
|
|
67
|
-
|
|
84
|
+
sink.startContainer("[");
|
|
68
85
|
for (let i = 0; i < obj.length; i++) {
|
|
69
|
-
|
|
86
|
+
if (i > 0) sink.write(",");
|
|
87
|
+
canonicalize(obj[i], `${path}[${i}]`, seen, sink);
|
|
70
88
|
}
|
|
71
|
-
|
|
89
|
+
sink.endContainer("]");
|
|
72
90
|
} else if (isPlainObject(obj)) {
|
|
73
91
|
const keys = Object.keys(obj).sort();
|
|
74
|
-
|
|
75
|
-
for (
|
|
76
|
-
|
|
92
|
+
sink.startContainer("{");
|
|
93
|
+
for (let i = 0; i < keys.length; i++) {
|
|
94
|
+
if (i > 0) sink.write(",");
|
|
95
|
+
const key = keys[i]!;
|
|
96
|
+
sink.quote(key);
|
|
97
|
+
sink.write(":");
|
|
98
|
+
canonicalize(obj[key], `${path}.${key}`, seen, sink);
|
|
77
99
|
}
|
|
78
|
-
|
|
100
|
+
sink.endContainer("}");
|
|
79
101
|
} else {
|
|
80
102
|
const name = (obj.constructor as { name?: string } | undefined)?.name ?? "unknown";
|
|
81
103
|
throw new ReservingError(
|
|
@@ -84,7 +106,6 @@ function canonicalize(value: unknown, path: string, seen: Set<object>): string {
|
|
|
84
106
|
);
|
|
85
107
|
}
|
|
86
108
|
seen.delete(obj);
|
|
87
|
-
return out;
|
|
88
109
|
}
|
|
89
110
|
|
|
90
111
|
/**
|
|
@@ -95,12 +116,59 @@ function canonicalize(value: unknown, path: string, seen: Set<object>): string {
|
|
|
95
116
|
* "$.rows[2].ultimate" — for any value JSON cannot faithfully represent.
|
|
96
117
|
*/
|
|
97
118
|
export function canonicalJson(value: unknown): string {
|
|
98
|
-
|
|
119
|
+
// Join finished subtrees as before, rather than retaining a flat token array
|
|
120
|
+
// for the entire input graph alongside the final serialized string.
|
|
121
|
+
const frames: string[][] = [[]];
|
|
122
|
+
const write = (text: string) => {
|
|
123
|
+
frames[frames.length - 1]!.push(text);
|
|
124
|
+
};
|
|
125
|
+
canonicalize(value, "$", new Set(), {
|
|
126
|
+
write,
|
|
127
|
+
quote(text) {
|
|
128
|
+
write(JSON.stringify(text));
|
|
129
|
+
},
|
|
130
|
+
startContainer(token) {
|
|
131
|
+
frames.push([token]);
|
|
132
|
+
},
|
|
133
|
+
endContainer(token) {
|
|
134
|
+
const parts = frames.pop()!;
|
|
135
|
+
parts.push(token);
|
|
136
|
+
write(parts.join(""));
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
return frames[0]!.join("");
|
|
99
140
|
}
|
|
100
141
|
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
142
|
+
const UTF8_CHUNK_CODE_UNITS = 16_384;
|
|
143
|
+
const QUOTED_CHUNK_CODE_UNITS = 4_096;
|
|
144
|
+
|
|
145
|
+
/** A valid surrogate pair must reach native escaping/encoding together. */
|
|
146
|
+
function chunkEnd(text: string, start: number, limit: number): number {
|
|
147
|
+
let end = Math.min(start + limit, text.length);
|
|
148
|
+
const before = text.charCodeAt(end - 1);
|
|
149
|
+
const after = text.charCodeAt(end);
|
|
150
|
+
if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) end -= 1;
|
|
151
|
+
return end;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Reuse at most 48 KiB for long strings instead of duplicating all UTF-8 bytes. */
|
|
155
|
+
function* utf8Chunks(text: string): Generator<Uint8Array> {
|
|
156
|
+
const encoder = new TextEncoder();
|
|
157
|
+
// Retain TextEncoder's runtime coercion for untyped JavaScript callers.
|
|
158
|
+
if (typeof text !== "string" || text.length <= UTF8_CHUNK_CODE_UNITS) {
|
|
159
|
+
yield encoder.encode(text);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// A UTF-16 code unit needs at most three UTF-8 bytes (a surrogate pair
|
|
163
|
+
// needs four bytes for two code units), so encodeInto always consumes a chunk.
|
|
164
|
+
const buffer = new Uint8Array(UTF8_CHUNK_CODE_UNITS * 3);
|
|
165
|
+
for (let start = 0; start < text.length; ) {
|
|
166
|
+
const end = chunkEnd(text, start, UTF8_CHUNK_CODE_UNITS);
|
|
167
|
+
const { written } = encoder.encodeInto(text.slice(start, end), buffer);
|
|
168
|
+
yield buffer.subarray(0, written);
|
|
169
|
+
start = end;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
104
172
|
|
|
105
173
|
/**
|
|
106
174
|
* FNV-1a 64-bit hash over the UTF-8 bytes of `text`, returned as a 16-hex-char
|
|
@@ -112,11 +180,61 @@ const MASK_64 = 0xffffffffffffffffn;
|
|
|
112
180
|
* needing tamper evidence must sign or cryptographically hash the payload.
|
|
113
181
|
*/
|
|
114
182
|
export function fnv1a64(text: string): string {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
183
|
+
const hash = createFnvAccumulator();
|
|
184
|
+
for (const bytes of utf8Chunks(text)) hash.update(bytes);
|
|
185
|
+
return hash.digest();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The exact integrity tag `fnv1a64(canonicalJson(value))`, without constructing
|
|
190
|
+
* the complete canonical string or UTF-8 byte array. Validation, key ordering,
|
|
191
|
+
* escaping and unsupported-value error paths match `canonicalJson`.
|
|
192
|
+
*
|
|
193
|
+
* Text and byte buffers are bounded, including for a single large string/key;
|
|
194
|
+
* object-key sorting, ancestor tracking and paths still scale with input shape.
|
|
195
|
+
* The caller's input remains in memory. This is not a constant-memory dataset
|
|
196
|
+
* pipeline, a collision-resistant hash, or a substitute for exact equality.
|
|
197
|
+
*/
|
|
198
|
+
export function canonicalFnv1a64(value: unknown): string {
|
|
199
|
+
const hash = createFnvAccumulator();
|
|
200
|
+
const encoder = new TextEncoder();
|
|
201
|
+
const bytes = new Uint8Array(UTF8_CHUNK_CODE_UNITS * 3);
|
|
202
|
+
let buffered = "";
|
|
203
|
+
const flush = () => {
|
|
204
|
+
if (!buffered.length) return;
|
|
205
|
+
// Three bytes per UTF-16 code unit always fit; pairs need only four for two.
|
|
206
|
+
const { written } = encoder.encodeInto(buffered, bytes);
|
|
207
|
+
hash.update(bytes.subarray(0, written));
|
|
208
|
+
buffered = "";
|
|
209
|
+
};
|
|
210
|
+
const write = (text: string) => {
|
|
211
|
+
for (let start = 0; start < text.length; ) {
|
|
212
|
+
const end = chunkEnd(text, start, UTF8_CHUNK_CODE_UNITS - buffered.length);
|
|
213
|
+
if (end === start) {
|
|
214
|
+
flush();
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
buffered += text.slice(start, end);
|
|
218
|
+
start = end;
|
|
219
|
+
if (buffered.length >= UTF8_CHUNK_CODE_UNITS - 1) flush();
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
canonicalize(value, "$", new Set(), {
|
|
223
|
+
write,
|
|
224
|
+
startContainer: write,
|
|
225
|
+
endContainer: write,
|
|
226
|
+
quote(text) {
|
|
227
|
+
write('"');
|
|
228
|
+
for (let start = 0; start < text.length; ) {
|
|
229
|
+
const end = chunkEnd(text, start, QUOTED_CHUNK_CODE_UNITS);
|
|
230
|
+
// Bounded native escaping retains minimal JSON escape spelling and lone
|
|
231
|
+
// surrogate escapes without allocating one full escaped string atom.
|
|
232
|
+
write(JSON.stringify(text.slice(start, end)).slice(1, -1));
|
|
233
|
+
start = end;
|
|
234
|
+
}
|
|
235
|
+
write('"');
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
flush();
|
|
239
|
+
return hash.digest();
|
|
122
240
|
}
|
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(
|
|
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(
|
|
60
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|
|
109
|
+
for (const snap of claims)
|
|
110
|
+
baseYear = Math.max(baseYear, accidentYear(snap));
|
|
75
111
|
}
|
|
76
|
-
if (!isNum(baseYear) || !Number.
|
|
77
|
-
throw new ReservingError(
|
|
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(
|
|
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)
|
|
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(
|
|
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(
|
|
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)
|
|
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) => ({
|
|
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 =
|
|
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(
|
|
293
|
-
.
|
|
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 {
|
|
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 & {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 = {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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: "
|
|
36
|
-
value:
|
|
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 +=
|
|
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(
|
|
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 =
|
|
64
|
-
|
|
65
|
-
const
|
|
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 =
|
|
116
|
+
const value =
|
|
117
|
+
sum === null || (missingPolicy === "unknown" && missing > 0) ? null : sum;
|
|
69
118
|
return Object.freeze({
|
|
70
119
|
value,
|
|
71
120
|
sum,
|