@actuarial-ts/core 0.4.0 → 0.6.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 +65 -215
- package/dist/casualtyDiagnostics.d.ts +19 -49
- package/dist/casualtyDiagnostics.d.ts.map +1 -1
- package/dist/casualtyDiagnostics.js +84 -114
- package/dist/casualtyDiagnostics.js.map +1 -1
- package/dist/diagnosticAggregation.d.ts +29 -0
- package/dist/diagnosticAggregation.d.ts.map +1 -0
- package/dist/diagnosticAggregation.js +50 -0
- package/dist/diagnosticAggregation.js.map +1 -0
- package/dist/diagnosticDefinitions.d.ts +286 -0
- package/dist/diagnosticDefinitions.d.ts.map +1 -0
- package/dist/diagnosticDefinitions.js +1026 -0
- package/dist/diagnosticDefinitions.js.map +1 -0
- package/dist/diagnosticDerivations.d.ts +12 -0
- package/dist/diagnosticDerivations.d.ts.map +1 -0
- package/dist/diagnosticDerivations.js +80 -0
- package/dist/diagnosticDerivations.js.map +1 -0
- package/dist/diagnosticExposure.d.ts +51 -0
- package/dist/diagnosticExposure.d.ts.map +1 -0
- package/dist/diagnosticExposure.js +65 -0
- package/dist/diagnosticExposure.js.map +1 -0
- package/dist/diagnosticExpressions.d.ts +56 -0
- package/dist/diagnosticExpressions.d.ts.map +1 -0
- package/dist/diagnosticExpressions.js +104 -0
- package/dist/diagnosticExpressions.js.map +1 -0
- package/dist/diagnosticFormulas.d.ts +195 -0
- package/dist/diagnosticFormulas.d.ts.map +1 -0
- package/dist/diagnosticFormulas.js +85 -0
- package/dist/diagnosticFormulas.js.map +1 -0
- package/dist/diagnosticIdentity.d.ts +237 -0
- package/dist/diagnosticIdentity.d.ts.map +1 -0
- package/dist/diagnosticIdentity.js +454 -0
- package/dist/diagnosticIdentity.js.map +1 -0
- package/dist/diagnosticPeriods.d.ts +16 -0
- package/dist/diagnosticPeriods.d.ts.map +1 -0
- package/dist/diagnosticPeriods.js +98 -0
- package/dist/diagnosticPeriods.js.map +1 -0
- package/dist/diagnosticPreparation.d.ts +98 -0
- package/dist/diagnosticPreparation.d.ts.map +1 -0
- package/dist/diagnosticPreparation.js +169 -0
- package/dist/diagnosticPreparation.js.map +1 -0
- package/dist/diagnosticReview.d.ts +44 -0
- package/dist/diagnosticReview.d.ts.map +1 -0
- package/dist/diagnosticReview.js +84 -0
- package/dist/diagnosticReview.js.map +1 -0
- package/dist/diagnosticRules.d.ts +32 -0
- package/dist/diagnosticRules.d.ts.map +1 -0
- package/dist/diagnosticRules.js +32 -0
- package/dist/diagnosticRules.js.map +1 -0
- package/dist/diagnosticRunner.d.ts +85 -0
- package/dist/diagnosticRunner.d.ts.map +1 -0
- package/dist/diagnosticRunner.js +117 -0
- package/dist/diagnosticRunner.js.map +1 -0
- package/dist/index.d.ts +24 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +114 -0
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/package.json +2 -2
- package/src/casualtyDiagnostics.ts +79 -187
- package/src/diagnosticAggregation.ts +79 -0
- package/src/diagnosticDefinitions.ts +1375 -0
- package/src/diagnosticDerivations.ts +89 -0
- package/src/diagnosticExposure.ts +91 -0
- package/src/diagnosticExpressions.ts +173 -0
- package/src/diagnosticFormulas.ts +150 -0
- package/src/diagnosticIdentity.ts +767 -0
- package/src/diagnosticPeriods.ts +117 -0
- package/src/diagnosticPreparation.ts +233 -0
- package/src/diagnosticReview.ts +91 -0
- package/src/diagnosticRules.ts +69 -0
- package/src/diagnosticRunner.ts +110 -0
- package/src/index.ts +156 -1
- package/src/types.ts +161 -0
- package/src/version.ts +2 -0
- package/dist/metricDiagnostics.d.ts +0 -212
- package/dist/metricDiagnostics.d.ts.map +0 -1
- package/dist/metricDiagnostics.js +0 -627
- package/dist/metricDiagnostics.js.map +0 -1
- package/src/metricDiagnostics.ts +0 -876
package/src/metricDiagnostics.ts
DELETED
|
@@ -1,876 +0,0 @@
|
|
|
1
|
-
import { compareQuarterPeriods, parseQuarterPeriod } from "./periods.js";
|
|
2
|
-
import { ReservingError, type DiagnosticFinding } from "./types.js";
|
|
3
|
-
import { safeRatio } from "./util.js";
|
|
4
|
-
|
|
5
|
-
function nullRecord<T>(): Record<string, T> {
|
|
6
|
-
return Object.create(null) as Record<string, T>;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function copyOwnRecord<T>(source: Readonly<Record<string, T>>): Record<string, T> {
|
|
10
|
-
const copy = nullRecord<T>();
|
|
11
|
-
for (const key of Object.keys(source)) copy[key] = source[key]!;
|
|
12
|
-
return copy;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function nullRecordForKeys<T>(keys: Iterable<string>, value: T): Record<string, T> {
|
|
16
|
-
const record = nullRecord<T>();
|
|
17
|
-
for (const key of keys) record[key] = value;
|
|
18
|
-
return record;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function ownRecordValue<T>(
|
|
22
|
-
record: Readonly<Record<string, T>> | undefined,
|
|
23
|
-
key: string,
|
|
24
|
-
): T | undefined {
|
|
25
|
-
return record !== undefined && Object.prototype.hasOwnProperty.call(record, key)
|
|
26
|
-
? record[key]
|
|
27
|
-
: undefined;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function compareCodeUnits(a: string, b: string): number {
|
|
31
|
-
if (a === b) return 0;
|
|
32
|
-
return a < b ? -1 : 1;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Stable natural ordering without the host's locale or ICU data. ASCII digit
|
|
37
|
-
* runs compare by numeric magnitude; all other text compares by UTF-16 code
|
|
38
|
-
* units, with the original spelling as a total-order tie breaker.
|
|
39
|
-
*/
|
|
40
|
-
function compareDeterministicStrings(a: string, b: string): number {
|
|
41
|
-
if (a === b) return 0;
|
|
42
|
-
const left = a.match(/\d+|\D+/g) ?? [""];
|
|
43
|
-
const right = b.match(/\d+|\D+/g) ?? [""];
|
|
44
|
-
const count = Math.min(left.length, right.length);
|
|
45
|
-
for (let index = 0; index < count; index++) {
|
|
46
|
-
const leftPart = left[index]!;
|
|
47
|
-
const rightPart = right[index]!;
|
|
48
|
-
const leftNumeric = /^\d+$/.test(leftPart);
|
|
49
|
-
const rightNumeric = /^\d+$/.test(rightPart);
|
|
50
|
-
if (leftNumeric && rightNumeric) {
|
|
51
|
-
const leftSignificant = leftPart.replace(/^0+(?=\d)/, "");
|
|
52
|
-
const rightSignificant = rightPart.replace(/^0+(?=\d)/, "");
|
|
53
|
-
if (leftSignificant.length !== rightSignificant.length) {
|
|
54
|
-
return leftSignificant.length - rightSignificant.length;
|
|
55
|
-
}
|
|
56
|
-
const magnitude = compareCodeUnits(leftSignificant, rightSignificant);
|
|
57
|
-
if (magnitude !== 0) return magnitude;
|
|
58
|
-
if (leftPart.length !== rightPart.length) return leftPart.length - rightPart.length;
|
|
59
|
-
} else {
|
|
60
|
-
const text = compareCodeUnits(leftPart, rightPart);
|
|
61
|
-
if (text !== 0) return text;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
if (left.length !== right.length) return left.length - right.length;
|
|
65
|
-
return compareCodeUnits(a, b);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export type SparseValuePolicy = "preserve-null" | "zero-fill";
|
|
69
|
-
export type DiagnosticMeasureMap = Readonly<Record<string, number | null | undefined>>;
|
|
70
|
-
|
|
71
|
-
export type MeasureExpression =
|
|
72
|
-
| { op: "measure"; measure: string }
|
|
73
|
-
| { op: "add"; terms: readonly MeasureExpression[] }
|
|
74
|
-
| { op: "subtract"; left: MeasureExpression; right: MeasureExpression };
|
|
75
|
-
|
|
76
|
-
export interface DiagnosticWarning {
|
|
77
|
-
code:
|
|
78
|
-
| "MISSING_COMPONENT"
|
|
79
|
-
| "NON_FINITE_COMPONENT"
|
|
80
|
-
| "NON_FINITE_NUMERATOR"
|
|
81
|
-
| "NON_FINITE_RESULT"
|
|
82
|
-
| "INVALID_DENOMINATOR"
|
|
83
|
-
| "INCOMPLETE_EXPOSURE"
|
|
84
|
-
| "CONFLICTING_EXPOSURE"
|
|
85
|
-
| "DUPLICATE_EXPOSURE_KEY"
|
|
86
|
-
| "PAID_EXCEEDS_INCURRED"
|
|
87
|
-
| (string & {});
|
|
88
|
-
message: string;
|
|
89
|
-
component?: string;
|
|
90
|
-
exposureKey?: string;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Adapts structured metric warnings to the existing core diagnostic finding vocabulary. */
|
|
94
|
-
export function diagnosticWarningToFinding(warning: DiagnosticWarning): DiagnosticFinding {
|
|
95
|
-
return { severity: "warning", code: warning.code, message: warning.message };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface MetricWarningRule {
|
|
99
|
-
code: "PAID_EXCEEDS_INCURRED";
|
|
100
|
-
when: "numerator-greater-than-denominator";
|
|
101
|
-
message: string;
|
|
102
|
-
tolerance?: number;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export interface MetricDefinition {
|
|
106
|
-
id: string;
|
|
107
|
-
version: string;
|
|
108
|
-
displayName: string;
|
|
109
|
-
description: string;
|
|
110
|
-
unit: "count-per-million" | "ratio" | "currency-per-exposure" | "currency-per-claim" | (string & {});
|
|
111
|
-
scale: number;
|
|
112
|
-
numerator: MeasureExpression;
|
|
113
|
-
denominator: MeasureExpression;
|
|
114
|
-
numeratorLabel: string;
|
|
115
|
-
denominatorLabel: string;
|
|
116
|
-
basis: string;
|
|
117
|
-
requiredComponents: readonly string[];
|
|
118
|
-
warningRules?: readonly MetricWarningRule[];
|
|
119
|
-
/** Optional pure caller rule for domain warnings beyond the built-in comparisons. */
|
|
120
|
-
evaluateWarnings?: (context: MetricWarningContext) => readonly DiagnosticWarning[];
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export interface MetricWarningContext {
|
|
124
|
-
definition: MetricDefinition;
|
|
125
|
-
components: Readonly<Record<string, number | null>>;
|
|
126
|
-
rawNumerator: number | null;
|
|
127
|
-
rawDenominator: number | null;
|
|
128
|
-
value: number | null;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export interface MetricEvaluation {
|
|
132
|
-
metricId: string;
|
|
133
|
-
metricVersion: string;
|
|
134
|
-
value: number | null;
|
|
135
|
-
rawNumerator: number | null;
|
|
136
|
-
rawDenominator: number | null;
|
|
137
|
-
numeratorLabel: string;
|
|
138
|
-
denominatorLabel: string;
|
|
139
|
-
unit: MetricDefinition["unit"];
|
|
140
|
-
scale: number;
|
|
141
|
-
basis: string;
|
|
142
|
-
rawComponents: Record<string, number | null>;
|
|
143
|
-
warnings: DiagnosticWarning[];
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export interface MeasureAggregateCell {
|
|
147
|
-
sum: number;
|
|
148
|
-
observed: number;
|
|
149
|
-
missing: number;
|
|
150
|
-
nonFinite: number;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Mergeable sufficient statistics: grouping in stages gives the same result. */
|
|
154
|
-
export interface MeasureAggregate {
|
|
155
|
-
components: Record<string, MeasureAggregateCell>;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export function aggregateMeasures(rows: readonly DiagnosticMeasureMap[]): MeasureAggregate {
|
|
159
|
-
const components = nullRecord<MeasureAggregateCell>();
|
|
160
|
-
const names = new Set(rows.flatMap((row) => Object.keys(row)));
|
|
161
|
-
for (const name of names) components[name] = { sum: 0, observed: 0, missing: 0, nonFinite: 0 };
|
|
162
|
-
for (const row of rows) {
|
|
163
|
-
for (const name of names) {
|
|
164
|
-
const cell = components[name]!;
|
|
165
|
-
const value = ownRecordValue(row, name);
|
|
166
|
-
if (value === null || value === undefined) cell.missing++;
|
|
167
|
-
else if (!Number.isFinite(value)) cell.nonFinite++;
|
|
168
|
-
else {
|
|
169
|
-
cell.sum += value;
|
|
170
|
-
cell.observed++;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
return { components };
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export function mergeMeasureAggregates(aggregates: readonly MeasureAggregate[]): MeasureAggregate {
|
|
178
|
-
const components = nullRecord<MeasureAggregateCell>();
|
|
179
|
-
for (const aggregate of aggregates) {
|
|
180
|
-
for (const [name, value] of Object.entries(aggregate.components)) {
|
|
181
|
-
const target = components[name] ?? (components[name] = { sum: 0, observed: 0, missing: 0, nonFinite: 0 });
|
|
182
|
-
target.sum += value.sum;
|
|
183
|
-
target.observed += value.observed;
|
|
184
|
-
target.missing += value.missing;
|
|
185
|
-
target.nonFinite += value.nonFinite;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return { components };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export interface FinalizedMeasures {
|
|
192
|
-
measures: Record<string, number | null>;
|
|
193
|
-
warnings: DiagnosticWarning[];
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
export function finalizeMeasureAggregate(
|
|
197
|
-
aggregate: MeasureAggregate,
|
|
198
|
-
sparsePolicy: SparseValuePolicy = "preserve-null",
|
|
199
|
-
): FinalizedMeasures {
|
|
200
|
-
const measures = nullRecord<number | null>();
|
|
201
|
-
const warnings: DiagnosticWarning[] = [];
|
|
202
|
-
for (const [name, cell] of Object.entries(aggregate.components)) {
|
|
203
|
-
if (cell.nonFinite > 0) {
|
|
204
|
-
measures[name] = null;
|
|
205
|
-
warnings.push({
|
|
206
|
-
code: "NON_FINITE_COMPONENT",
|
|
207
|
-
component: name,
|
|
208
|
-
message: `${name} contains ${cell.nonFinite} non-finite value(s)`,
|
|
209
|
-
});
|
|
210
|
-
} else if (!Number.isFinite(cell.sum)) {
|
|
211
|
-
measures[name] = null;
|
|
212
|
-
warnings.push({
|
|
213
|
-
code: "NON_FINITE_COMPONENT",
|
|
214
|
-
component: name,
|
|
215
|
-
message: `${name} aggregate overflowed or is non-finite`,
|
|
216
|
-
});
|
|
217
|
-
} else if (cell.missing > 0 && sparsePolicy === "preserve-null") {
|
|
218
|
-
measures[name] = null;
|
|
219
|
-
warnings.push({
|
|
220
|
-
code: "MISSING_COMPONENT",
|
|
221
|
-
component: name,
|
|
222
|
-
message: `${name} is incomplete: ${cell.missing} contributing value(s) are missing`,
|
|
223
|
-
});
|
|
224
|
-
} else {
|
|
225
|
-
measures[name] = cell.sum;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
return { measures, warnings };
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export function measureExpressionComponents(expression: MeasureExpression): string[] {
|
|
232
|
-
if (expression.op === "measure") return [expression.measure];
|
|
233
|
-
if (expression.op === "subtract") {
|
|
234
|
-
return [...measureExpressionComponents(expression.left), ...measureExpressionComponents(expression.right)];
|
|
235
|
-
}
|
|
236
|
-
return expression.terms.flatMap(measureExpressionComponents);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function evaluateExpression(expression: MeasureExpression, measures: DiagnosticMeasureMap): number | null {
|
|
240
|
-
if (expression.op === "measure") {
|
|
241
|
-
const value = ownRecordValue(measures, expression.measure);
|
|
242
|
-
return value !== null && value !== undefined && Number.isFinite(value) ? value : null;
|
|
243
|
-
}
|
|
244
|
-
if (expression.op === "subtract") {
|
|
245
|
-
const left = evaluateExpression(expression.left, measures);
|
|
246
|
-
const right = evaluateExpression(expression.right, measures);
|
|
247
|
-
return left === null || right === null ? null : left - right;
|
|
248
|
-
}
|
|
249
|
-
let total = 0;
|
|
250
|
-
for (const term of expression.terms) {
|
|
251
|
-
const value = evaluateExpression(term, measures);
|
|
252
|
-
if (value === null) return null;
|
|
253
|
-
total += value;
|
|
254
|
-
}
|
|
255
|
-
return total;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
export function evaluateMetric(
|
|
259
|
-
definition: MetricDefinition,
|
|
260
|
-
components: DiagnosticMeasureMap,
|
|
261
|
-
inheritedWarnings: readonly DiagnosticWarning[] = [],
|
|
262
|
-
): MetricEvaluation {
|
|
263
|
-
if (!Number.isFinite(definition.scale)) {
|
|
264
|
-
throw new ReservingError("BAD_RATIO", `Metric ${definition.id} scale must be finite`);
|
|
265
|
-
}
|
|
266
|
-
const required = new Set([
|
|
267
|
-
...definition.requiredComponents,
|
|
268
|
-
...measureExpressionComponents(definition.numerator),
|
|
269
|
-
...measureExpressionComponents(definition.denominator),
|
|
270
|
-
]);
|
|
271
|
-
const rawComponents = nullRecord<number | null>();
|
|
272
|
-
const warnings = inheritedWarnings.filter(
|
|
273
|
-
(warning) =>
|
|
274
|
-
warning.component === undefined ||
|
|
275
|
-
required.has(warning.component) ||
|
|
276
|
-
warning.code === "INCOMPLETE_EXPOSURE" ||
|
|
277
|
-
warning.code === "CONFLICTING_EXPOSURE",
|
|
278
|
-
);
|
|
279
|
-
for (const name of required) {
|
|
280
|
-
const value = ownRecordValue(components, name);
|
|
281
|
-
rawComponents[name] = value !== null && value !== undefined && Number.isFinite(value) ? value : null;
|
|
282
|
-
if (value === null || value === undefined) {
|
|
283
|
-
if (!warnings.some((w) => w.code === "MISSING_COMPONENT" && w.component === name)) {
|
|
284
|
-
warnings.push({ code: "MISSING_COMPONENT", component: name, message: `${name} is missing` });
|
|
285
|
-
}
|
|
286
|
-
} else if (!Number.isFinite(value)) {
|
|
287
|
-
if (!warnings.some((w) => w.code === "NON_FINITE_COMPONENT" && w.component === name)) {
|
|
288
|
-
warnings.push({ code: "NON_FINITE_COMPONENT", component: name, message: `${name} is not finite` });
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
const evaluatedNumerator = evaluateExpression(definition.numerator, components);
|
|
293
|
-
const numerator = evaluatedNumerator !== null && Number.isFinite(evaluatedNumerator)
|
|
294
|
-
? evaluatedNumerator
|
|
295
|
-
: null;
|
|
296
|
-
if (evaluatedNumerator !== null && !Number.isFinite(evaluatedNumerator)) {
|
|
297
|
-
warnings.push({
|
|
298
|
-
code: "NON_FINITE_NUMERATOR",
|
|
299
|
-
message: `${definition.numeratorLabel} overflowed or is non-finite`,
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
const evaluatedDenominator = evaluateExpression(definition.denominator, components);
|
|
303
|
-
const denominator = evaluatedDenominator !== null && Number.isFinite(evaluatedDenominator)
|
|
304
|
-
? evaluatedDenominator
|
|
305
|
-
: null;
|
|
306
|
-
let value: number | null = null;
|
|
307
|
-
if (denominator === null || !Number.isFinite(denominator) || denominator <= 0) {
|
|
308
|
-
warnings.push({
|
|
309
|
-
code: "INVALID_DENOMINATOR",
|
|
310
|
-
message: `${definition.denominatorLabel} must be finite and greater than zero`,
|
|
311
|
-
});
|
|
312
|
-
} else if (numerator !== null && Number.isFinite(numerator)) {
|
|
313
|
-
const ratio = safeRatio(numerator, denominator);
|
|
314
|
-
if (ratio === null) {
|
|
315
|
-
warnings.push({
|
|
316
|
-
code: "NON_FINITE_RESULT",
|
|
317
|
-
message: `${definition.displayName} ratio overflowed or is non-finite`,
|
|
318
|
-
});
|
|
319
|
-
} else {
|
|
320
|
-
const scaled = ratio * definition.scale;
|
|
321
|
-
if (Number.isFinite(scaled)) value = scaled;
|
|
322
|
-
else {
|
|
323
|
-
warnings.push({
|
|
324
|
-
code: "NON_FINITE_RESULT",
|
|
325
|
-
message: `${definition.displayName} scaled result overflowed or is non-finite`,
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
for (const rule of definition.warningRules ?? []) {
|
|
331
|
-
if (rule.when === "numerator-greater-than-denominator" && numerator !== null && denominator !== null) {
|
|
332
|
-
const tolerance = rule.tolerance ?? 0;
|
|
333
|
-
if (numerator - denominator > tolerance * Math.max(1, Math.abs(numerator), Math.abs(denominator))) {
|
|
334
|
-
warnings.push({ code: rule.code, message: rule.message });
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
if (definition.evaluateWarnings) {
|
|
339
|
-
warnings.push(...definition.evaluateWarnings({
|
|
340
|
-
definition,
|
|
341
|
-
components: rawComponents,
|
|
342
|
-
rawNumerator: numerator,
|
|
343
|
-
rawDenominator: denominator,
|
|
344
|
-
value,
|
|
345
|
-
}));
|
|
346
|
-
}
|
|
347
|
-
return {
|
|
348
|
-
metricId: definition.id,
|
|
349
|
-
metricVersion: definition.version,
|
|
350
|
-
value,
|
|
351
|
-
rawNumerator: numerator,
|
|
352
|
-
rawDenominator: denominator,
|
|
353
|
-
numeratorLabel: definition.numeratorLabel,
|
|
354
|
-
denominatorLabel: definition.denominatorLabel,
|
|
355
|
-
unit: definition.unit,
|
|
356
|
-
scale: definition.scale,
|
|
357
|
-
basis: definition.basis,
|
|
358
|
-
rawComponents,
|
|
359
|
-
warnings,
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
export type LayerExpression =
|
|
364
|
-
| { op: "measure"; measure: string }
|
|
365
|
-
| { op: "add"; terms: readonly LayerExpression[] }
|
|
366
|
-
| { op: "claim-cap"; measure: string; limit: number };
|
|
367
|
-
|
|
368
|
-
export interface AmountLayerDefinition {
|
|
369
|
-
id: string;
|
|
370
|
-
displayName: string;
|
|
371
|
-
paidMeasure: string;
|
|
372
|
-
incurredMeasure: string;
|
|
373
|
-
paid: LayerExpression;
|
|
374
|
-
incurred: LayerExpression;
|
|
375
|
-
/** Documents whether inputs are already limited or this layer caps claim rows. */
|
|
376
|
-
basis: "pre-capped-additive" | "claim-level-cap" | "unlimited-additive";
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
export interface DiagnosticClaimRow<T = unknown> {
|
|
380
|
-
dimensions: T;
|
|
381
|
-
measures: DiagnosticMeasureMap;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
function evaluateLayerExpression(expression: LayerExpression, measures: DiagnosticMeasureMap): number | null {
|
|
385
|
-
if (expression.op === "measure") {
|
|
386
|
-
const value = ownRecordValue(measures, expression.measure);
|
|
387
|
-
return value !== null && value !== undefined && Number.isFinite(value) ? value : null;
|
|
388
|
-
}
|
|
389
|
-
if (expression.op === "claim-cap") {
|
|
390
|
-
if (!Number.isFinite(expression.limit) || expression.limit <= 0) {
|
|
391
|
-
throw new ReservingError("BAD_CAP", `Claim-level layer cap must be positive; got ${expression.limit}`);
|
|
392
|
-
}
|
|
393
|
-
const value = ownRecordValue(measures, expression.measure);
|
|
394
|
-
return value !== null && value !== undefined && Number.isFinite(value)
|
|
395
|
-
? Math.min(value, expression.limit)
|
|
396
|
-
: null;
|
|
397
|
-
}
|
|
398
|
-
let total = 0;
|
|
399
|
-
for (const term of expression.terms) {
|
|
400
|
-
const value = evaluateLayerExpression(term, measures);
|
|
401
|
-
if (value === null) return null;
|
|
402
|
-
total += value;
|
|
403
|
-
}
|
|
404
|
-
return total;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/** Applies claim caps before aggregation; this function intentionally accepts claim rows, not aggregates. */
|
|
408
|
-
export function deriveAmountLayers<T>(
|
|
409
|
-
rows: readonly DiagnosticClaimRow<T>[],
|
|
410
|
-
layers: readonly AmountLayerDefinition[],
|
|
411
|
-
): DiagnosticClaimRow<T>[] {
|
|
412
|
-
return rows.map((row) => {
|
|
413
|
-
const measures = copyOwnRecord(row.measures);
|
|
414
|
-
for (const layer of layers) {
|
|
415
|
-
measures[layer.paidMeasure] = evaluateLayerExpression(layer.paid, row.measures);
|
|
416
|
-
measures[layer.incurredMeasure] = evaluateLayerExpression(layer.incurred, row.measures);
|
|
417
|
-
}
|
|
418
|
-
return { dimensions: row.dimensions, measures };
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
export interface DiagnosticLossRow<TDimensions = unknown> {
|
|
423
|
-
id: string;
|
|
424
|
-
group: string;
|
|
425
|
-
origin: string;
|
|
426
|
-
valuation: string;
|
|
427
|
-
ageMonths: number;
|
|
428
|
-
/** Optional caller-mapped policy/fiscal period (for example a Q3-Q2 year). */
|
|
429
|
-
policyPeriod?: string;
|
|
430
|
-
/** Optional caller-owned grouping metadata; `group` remains the stable key. */
|
|
431
|
-
dimensions?: TDimensions;
|
|
432
|
-
measures: DiagnosticMeasureMap;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
export interface DiagnosticExposureRow<TDimensions = unknown> {
|
|
436
|
-
/** Stable exposure identity. Repeated snapshots with the same key count once. */
|
|
437
|
-
key: string;
|
|
438
|
-
group: string;
|
|
439
|
-
origin: string;
|
|
440
|
-
valuation?: string;
|
|
441
|
-
measures: DiagnosticMeasureMap;
|
|
442
|
-
complete?: boolean;
|
|
443
|
-
dimensions?: TDimensions;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
export interface DiagnosticsFilter {
|
|
447
|
-
groups?: readonly string[];
|
|
448
|
-
origins?: readonly string[];
|
|
449
|
-
originFrom?: string;
|
|
450
|
-
originThrough?: string;
|
|
451
|
-
valuations?: readonly string[];
|
|
452
|
-
valuationFrom?: string;
|
|
453
|
-
valuationThrough?: string;
|
|
454
|
-
policyPeriods?: readonly string[];
|
|
455
|
-
minAgeMonths?: number;
|
|
456
|
-
maxAgeMonths?: number;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
export interface DiagnosticEmergencePoint<TDimensions = unknown> {
|
|
460
|
-
group: string;
|
|
461
|
-
dimensions?: TDimensions;
|
|
462
|
-
origin: string;
|
|
463
|
-
valuation: string;
|
|
464
|
-
ageMonths: number;
|
|
465
|
-
components: Record<string, number | null>;
|
|
466
|
-
componentWarnings: DiagnosticWarning[];
|
|
467
|
-
metrics: Record<string, MetricEvaluation>;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
export interface DiagnosticMetricTriangle {
|
|
471
|
-
group: string;
|
|
472
|
-
metricId: string;
|
|
473
|
-
origins: string[];
|
|
474
|
-
ages: number[];
|
|
475
|
-
values: (number | null)[][];
|
|
476
|
-
cells: (MetricEvaluation | null)[][];
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
export interface MetricDiagnosticsResult<TDimensions = unknown> {
|
|
480
|
-
emergence: DiagnosticEmergencePoint<TDimensions>[];
|
|
481
|
-
triangles: DiagnosticMetricTriangle[];
|
|
482
|
-
latestDiagonal: DiagnosticEmergencePoint<TDimensions>[];
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
export interface RunMetricDiagnosticsInput<TDimensions = unknown> {
|
|
486
|
-
losses: readonly DiagnosticLossRow<TDimensions>[];
|
|
487
|
-
exposures?: readonly DiagnosticExposureRow<TDimensions>[];
|
|
488
|
-
metrics: readonly MetricDefinition[];
|
|
489
|
-
sparsePolicy?: SparseValuePolicy;
|
|
490
|
-
filter?: DiagnosticsFilter;
|
|
491
|
-
/** Maps source group keys to requested aggregate keys before summation. */
|
|
492
|
-
groupMap?: Readonly<Record<string, string>>;
|
|
493
|
-
/** Metadata for mapped/combined output groups. */
|
|
494
|
-
groupDimensions?: Readonly<Record<string, TDimensions>>;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
function rowIncluded<TDimensions>(row: DiagnosticLossRow<TDimensions>, filter: DiagnosticsFilter | undefined): boolean {
|
|
498
|
-
if (!filter) return true;
|
|
499
|
-
if (filter.groups && !filter.groups.includes(row.group)) return false;
|
|
500
|
-
if (filter.origins && !filter.origins.includes(row.origin)) return false;
|
|
501
|
-
if (filter.originFrom && periodCompare(row.origin, filter.originFrom) < 0) return false;
|
|
502
|
-
if (filter.originThrough && periodCompare(row.origin, filter.originThrough) > 0) return false;
|
|
503
|
-
if (filter.valuations && !filter.valuations.includes(row.valuation)) return false;
|
|
504
|
-
if (filter.valuationFrom && periodCompare(row.valuation, filter.valuationFrom) < 0) return false;
|
|
505
|
-
if (filter.valuationThrough && periodCompare(row.valuation, filter.valuationThrough) > 0) return false;
|
|
506
|
-
if (filter.policyPeriods && (row.policyPeriod === undefined || !filter.policyPeriods.includes(row.policyPeriod))) return false;
|
|
507
|
-
if (filter.minAgeMonths !== undefined && row.ageMonths < filter.minAgeMonths) return false;
|
|
508
|
-
if (filter.maxAgeMonths !== undefined && row.ageMonths > filter.maxAgeMonths) return false;
|
|
509
|
-
return true;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
function sameMeasures(a: DiagnosticMeasureMap, b: DiagnosticMeasureMap): boolean {
|
|
513
|
-
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
514
|
-
for (const key of keys) {
|
|
515
|
-
if (!Object.is(ownRecordValue(a, key) ?? null, ownRecordValue(b, key) ?? null)) return false;
|
|
516
|
-
}
|
|
517
|
-
return true;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
export interface DiagnosticExposureReconciliationFinding {
|
|
521
|
-
code: "DUPLICATE_EXPOSURE_KEY" | "CONFLICTING_EXPOSURE";
|
|
522
|
-
exposureKey: string;
|
|
523
|
-
group: string;
|
|
524
|
-
origin: string;
|
|
525
|
-
message: string;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
export interface ReconciledDiagnosticExposures<TDimensions = unknown> {
|
|
529
|
-
exposures: DiagnosticExposureRow<TDimensions>[];
|
|
530
|
-
findings: DiagnosticExposureReconciliationFinding[];
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/**
|
|
534
|
-
* Reconciles stable exposure keys before aggregation. Identical valuation
|
|
535
|
-
* copies collapse to one row. Conflicting copies become one incomplete/null
|
|
536
|
-
* row so exposure-based metrics fail closed rather than using a partial base.
|
|
537
|
-
*/
|
|
538
|
-
export function reconcileDiagnosticExposureKeys<TDimensions = unknown>(
|
|
539
|
-
rows: readonly DiagnosticExposureRow<TDimensions>[],
|
|
540
|
-
): ReconciledDiagnosticExposures<TDimensions> {
|
|
541
|
-
const seen = new Map<string, DiagnosticExposureRow<TDimensions>>();
|
|
542
|
-
const findings: DiagnosticExposureReconciliationFinding[] = [];
|
|
543
|
-
for (const row of rows) {
|
|
544
|
-
const previous = seen.get(row.key);
|
|
545
|
-
if (!previous) {
|
|
546
|
-
seen.set(row.key, { ...row, measures: copyOwnRecord(row.measures) });
|
|
547
|
-
continue;
|
|
548
|
-
}
|
|
549
|
-
const consistent =
|
|
550
|
-
previous.group === row.group &&
|
|
551
|
-
previous.origin === row.origin &&
|
|
552
|
-
sameMeasures(previous.measures, row.measures) &&
|
|
553
|
-
(previous.complete ?? true) === (row.complete ?? true);
|
|
554
|
-
if (consistent) {
|
|
555
|
-
findings.push({
|
|
556
|
-
code: "DUPLICATE_EXPOSURE_KEY",
|
|
557
|
-
exposureKey: row.key,
|
|
558
|
-
group: row.group,
|
|
559
|
-
origin: row.origin,
|
|
560
|
-
message: `Exposure key ${row.key} repeats; identical copies were counted once`,
|
|
561
|
-
});
|
|
562
|
-
continue;
|
|
563
|
-
}
|
|
564
|
-
const names = new Set([...Object.keys(previous.measures), ...Object.keys(row.measures)]);
|
|
565
|
-
seen.set(row.key, {
|
|
566
|
-
...previous,
|
|
567
|
-
complete: false,
|
|
568
|
-
measures: nullRecordForKeys(names, null),
|
|
569
|
-
});
|
|
570
|
-
if (!findings.some((finding) =>
|
|
571
|
-
finding.code === "CONFLICTING_EXPOSURE" && finding.exposureKey === row.key
|
|
572
|
-
)) {
|
|
573
|
-
findings.push({
|
|
574
|
-
code: "CONFLICTING_EXPOSURE",
|
|
575
|
-
exposureKey: row.key,
|
|
576
|
-
group: previous.group,
|
|
577
|
-
origin: previous.origin,
|
|
578
|
-
message: `Exposure key ${row.key} repeats with inconsistent attributes or measures`,
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
return { exposures: [...seen.values()], findings };
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
function periodCompare(a: string, b: string): number {
|
|
586
|
-
try {
|
|
587
|
-
parseQuarterPeriod(a);
|
|
588
|
-
parseQuarterPeriod(b);
|
|
589
|
-
return compareQuarterPeriods(a, b);
|
|
590
|
-
} catch {
|
|
591
|
-
return compareDeterministicStrings(a, b);
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
interface ExposureBucket {
|
|
596
|
-
aggregate: MeasureAggregate;
|
|
597
|
-
warnings: DiagnosticWarning[];
|
|
598
|
-
/** Exposure components that must fail closed even when loss sparsity is zero-filled. */
|
|
599
|
-
nullComponents: ReadonlySet<string>;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
type ExpectedExposureSources = ReadonlyMap<string, ReadonlySet<string>>;
|
|
603
|
-
|
|
604
|
-
function aggregateExposures<TDimensions>(
|
|
605
|
-
rows: readonly DiagnosticExposureRow<TDimensions>[],
|
|
606
|
-
filter: DiagnosticsFilter | undefined,
|
|
607
|
-
groupMap: Readonly<Record<string, string>> | undefined,
|
|
608
|
-
expectedSources: ExpectedExposureSources,
|
|
609
|
-
): Map<string, ExposureBucket> {
|
|
610
|
-
const eligible = rows.filter((row) =>
|
|
611
|
-
(!filter?.groups || filter.groups.includes(row.group)) &&
|
|
612
|
-
(!filter?.origins || filter.origins.includes(row.origin)) &&
|
|
613
|
-
(!filter?.originFrom || periodCompare(row.origin, filter.originFrom) >= 0) &&
|
|
614
|
-
(!filter?.originThrough || periodCompare(row.origin, filter.originThrough) <= 0) &&
|
|
615
|
-
(row.valuation === undefined || !filter?.valuations || filter.valuations.includes(row.valuation)) &&
|
|
616
|
-
(row.valuation === undefined || !filter?.valuationFrom || periodCompare(row.valuation, filter.valuationFrom) >= 0) &&
|
|
617
|
-
(row.valuation === undefined || !filter?.valuationThrough || periodCompare(row.valuation, filter.valuationThrough) <= 0)
|
|
618
|
-
);
|
|
619
|
-
const reconciled = reconcileDiagnosticExposureKeys(eligible);
|
|
620
|
-
const findingsByKey = new Map<string, DiagnosticExposureReconciliationFinding[]>();
|
|
621
|
-
for (const finding of reconciled.findings) {
|
|
622
|
-
const list = findingsByKey.get(finding.exposureKey) ?? [];
|
|
623
|
-
list.push(finding);
|
|
624
|
-
findingsByKey.set(finding.exposureKey, list);
|
|
625
|
-
}
|
|
626
|
-
const byCell = new Map<string, DiagnosticExposureRow<TDimensions>[]>();
|
|
627
|
-
const sourceKeysByCell = new Map<string, Set<string>>();
|
|
628
|
-
for (const row of reconciled.exposures) {
|
|
629
|
-
const group = ownRecordValue(groupMap, row.group) ?? row.group;
|
|
630
|
-
const key = `${group}\u0000${row.origin}`;
|
|
631
|
-
const list = byCell.get(key) ?? [];
|
|
632
|
-
list.push(row);
|
|
633
|
-
byCell.set(key, list);
|
|
634
|
-
const sourceKeys = sourceKeysByCell.get(key) ?? new Set<string>();
|
|
635
|
-
sourceKeys.add(`${row.group}\u0000${row.origin}`);
|
|
636
|
-
sourceKeysByCell.set(key, sourceKeys);
|
|
637
|
-
}
|
|
638
|
-
const result = new Map<string, ExposureBucket>();
|
|
639
|
-
const cellKeys = new Set([...byCell.keys(), ...expectedSources.keys()]);
|
|
640
|
-
for (const key of cellKeys) {
|
|
641
|
-
const cellRows = byCell.get(key) ?? [];
|
|
642
|
-
const warnings: DiagnosticWarning[] = [];
|
|
643
|
-
const nullComponents = new Set<string>();
|
|
644
|
-
const safeRows = cellRows.map((row) => {
|
|
645
|
-
warnings.push(...(findingsByKey.get(row.key) ?? []).map((finding) => ({
|
|
646
|
-
code: finding.code,
|
|
647
|
-
exposureKey: finding.exposureKey,
|
|
648
|
-
message: finding.message,
|
|
649
|
-
})));
|
|
650
|
-
if (row.complete === false) {
|
|
651
|
-
const componentNames = Object.keys(row.measures);
|
|
652
|
-
for (const name of componentNames) nullComponents.add(name);
|
|
653
|
-
warnings.push({
|
|
654
|
-
code: "INCOMPLETE_EXPOSURE",
|
|
655
|
-
exposureKey: row.key,
|
|
656
|
-
message: `Exposure key ${row.key} is marked incomplete`,
|
|
657
|
-
});
|
|
658
|
-
return nullRecordForKeys(componentNames, null);
|
|
659
|
-
}
|
|
660
|
-
return row.measures;
|
|
661
|
-
});
|
|
662
|
-
const missingSourceKeys = [...(expectedSources.get(key) ?? [])]
|
|
663
|
-
.filter((sourceKey) => !sourceKeysByCell.get(key)?.has(sourceKey));
|
|
664
|
-
if (missingSourceKeys.length > 0) {
|
|
665
|
-
for (const sourceKey of missingSourceKeys) {
|
|
666
|
-
const [sourceGroup, origin] = sourceKey.split("\u0000");
|
|
667
|
-
warnings.push({
|
|
668
|
-
code: "INCOMPLETE_EXPOSURE",
|
|
669
|
-
message: `No exposure row was supplied for source group ${sourceGroup} and origin ${origin}`,
|
|
670
|
-
});
|
|
671
|
-
}
|
|
672
|
-
const exposureMeasures = new Set(cellRows.flatMap((row) => Object.keys(row.measures)));
|
|
673
|
-
if (exposureMeasures.size > 0) {
|
|
674
|
-
for (const name of exposureMeasures) nullComponents.add(name);
|
|
675
|
-
safeRows.push(nullRecordForKeys(exposureMeasures, null));
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
const aggregate = aggregateMeasures(safeRows);
|
|
679
|
-
for (const [name, cell] of Object.entries(aggregate.components)) {
|
|
680
|
-
if (cell.missing > 0) nullComponents.add(name);
|
|
681
|
-
}
|
|
682
|
-
result.set(key, { aggregate, warnings, nullComponents });
|
|
683
|
-
}
|
|
684
|
-
return result;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
/**
|
|
688
|
-
* Generic quarterly diagnostics engine. Loss rows aggregate by
|
|
689
|
-
* group/origin/valuation; exposure rows aggregate once by stable exposure key;
|
|
690
|
-
* every metric then divides the aggregated expressions exactly once.
|
|
691
|
-
*/
|
|
692
|
-
export function runMetricDiagnostics<TDimensions = unknown>(
|
|
693
|
-
input: RunMetricDiagnosticsInput<TDimensions>,
|
|
694
|
-
): MetricDiagnosticsResult<TDimensions> {
|
|
695
|
-
const sparsePolicy = input.sparsePolicy ?? "preserve-null";
|
|
696
|
-
const lossIds = new Set<string>();
|
|
697
|
-
for (const row of input.losses) {
|
|
698
|
-
if (lossIds.has(row.id)) throw new ReservingError("BAD_TABLE", `Duplicate loss row id ${row.id}`);
|
|
699
|
-
lossIds.add(row.id);
|
|
700
|
-
}
|
|
701
|
-
const metricIds = new Set<string>();
|
|
702
|
-
for (const metric of input.metrics) {
|
|
703
|
-
if (metricIds.has(metric.id)) throw new ReservingError("BAD_TABLE", `Duplicate metric id ${metric.id}`);
|
|
704
|
-
metricIds.add(metric.id);
|
|
705
|
-
}
|
|
706
|
-
const lossBuckets = new Map<string, {
|
|
707
|
-
group: string;
|
|
708
|
-
dimensions?: TDimensions;
|
|
709
|
-
origin: string;
|
|
710
|
-
valuation: string;
|
|
711
|
-
ageMonths: number;
|
|
712
|
-
rows: DiagnosticMeasureMap[];
|
|
713
|
-
}>();
|
|
714
|
-
const expectedExposureSources = new Map<string, Set<string>>();
|
|
715
|
-
for (const row of input.losses) {
|
|
716
|
-
if (!rowIncluded(row, input.filter)) continue;
|
|
717
|
-
if (!Number.isFinite(row.ageMonths) || row.ageMonths < 0) {
|
|
718
|
-
throw new ReservingError("BAD_DATE", `Loss row ${row.id} has invalid development age ${row.ageMonths}`);
|
|
719
|
-
}
|
|
720
|
-
const group = ownRecordValue(input.groupMap, row.group) ?? row.group;
|
|
721
|
-
if (input.exposures !== undefined) {
|
|
722
|
-
const exposureCellKey = `${group}\u0000${row.origin}`;
|
|
723
|
-
const sourceKeys = expectedExposureSources.get(exposureCellKey) ?? new Set<string>();
|
|
724
|
-
sourceKeys.add(`${row.group}\u0000${row.origin}`);
|
|
725
|
-
expectedExposureSources.set(exposureCellKey, sourceKeys);
|
|
726
|
-
}
|
|
727
|
-
const key = `${group}\u0000${row.origin}\u0000${row.valuation}`;
|
|
728
|
-
const bucket = lossBuckets.get(key);
|
|
729
|
-
if (bucket) {
|
|
730
|
-
if (bucket.ageMonths !== row.ageMonths) {
|
|
731
|
-
throw new ReservingError(
|
|
732
|
-
"BAD_DATE",
|
|
733
|
-
`Aggregate snapshot ${group}/${row.origin}/${row.valuation} has inconsistent ages ${bucket.ageMonths} and ${row.ageMonths}`,
|
|
734
|
-
);
|
|
735
|
-
}
|
|
736
|
-
bucket.rows.push(row.measures);
|
|
737
|
-
} else {
|
|
738
|
-
const dimensions = ownRecordValue(input.groupDimensions, group) ?? (group === row.group ? row.dimensions : undefined);
|
|
739
|
-
lossBuckets.set(key, {
|
|
740
|
-
group,
|
|
741
|
-
...(dimensions !== undefined ? { dimensions } : {}),
|
|
742
|
-
origin: row.origin,
|
|
743
|
-
valuation: row.valuation,
|
|
744
|
-
ageMonths: row.ageMonths,
|
|
745
|
-
rows: [row.measures],
|
|
746
|
-
});
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
const exposures = aggregateExposures(
|
|
750
|
-
input.exposures ?? [],
|
|
751
|
-
input.filter,
|
|
752
|
-
input.groupMap,
|
|
753
|
-
expectedExposureSources,
|
|
754
|
-
);
|
|
755
|
-
const emergence: DiagnosticEmergencePoint<TDimensions>[] = [];
|
|
756
|
-
for (const bucket of lossBuckets.values()) {
|
|
757
|
-
const loss = aggregateMeasures(bucket.rows);
|
|
758
|
-
const exposure = exposures.get(`${bucket.group}\u0000${bucket.origin}`);
|
|
759
|
-
const aggregate = exposure ? mergeMeasureAggregates([loss, exposure.aggregate]) : loss;
|
|
760
|
-
const finalized = finalizeMeasureAggregate(aggregate, sparsePolicy);
|
|
761
|
-
const componentWarnings = [...finalized.warnings, ...(exposure?.warnings ?? [])];
|
|
762
|
-
for (const name of exposure?.nullComponents ?? []) {
|
|
763
|
-
finalized.measures[name] = null;
|
|
764
|
-
if (!componentWarnings.some((warning) => warning.code === "MISSING_COMPONENT" && warning.component === name)) {
|
|
765
|
-
componentWarnings.push({
|
|
766
|
-
code: "MISSING_COMPONENT",
|
|
767
|
-
component: name,
|
|
768
|
-
message: `${name} exposure is incomplete and remains null`,
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
const metrics = nullRecord<MetricEvaluation>();
|
|
773
|
-
for (const definition of input.metrics) {
|
|
774
|
-
metrics[definition.id] = evaluateMetric(definition, finalized.measures, componentWarnings);
|
|
775
|
-
}
|
|
776
|
-
emergence.push({
|
|
777
|
-
group: bucket.group,
|
|
778
|
-
...(bucket.dimensions !== undefined ? { dimensions: bucket.dimensions } : {}),
|
|
779
|
-
origin: bucket.origin,
|
|
780
|
-
valuation: bucket.valuation,
|
|
781
|
-
ageMonths: bucket.ageMonths,
|
|
782
|
-
components: finalized.measures,
|
|
783
|
-
componentWarnings,
|
|
784
|
-
metrics,
|
|
785
|
-
});
|
|
786
|
-
}
|
|
787
|
-
emergence.sort((a, b) =>
|
|
788
|
-
compareDeterministicStrings(a.group, b.group) || periodCompare(a.origin, b.origin) || a.ageMonths - b.ageMonths || periodCompare(a.valuation, b.valuation),
|
|
789
|
-
);
|
|
790
|
-
|
|
791
|
-
const triangles: DiagnosticMetricTriangle[] = [];
|
|
792
|
-
for (const group of [...new Set(emergence.map((point) => point.group))].sort(compareDeterministicStrings)) {
|
|
793
|
-
const groupPoints = emergence.filter((point) => point.group === group);
|
|
794
|
-
for (const metric of input.metrics) triangles.push(metricTriangleFromEmergence(groupPoints, metric.id));
|
|
795
|
-
}
|
|
796
|
-
const latestByOrigin = new Map<string, DiagnosticEmergencePoint<TDimensions>>();
|
|
797
|
-
for (const point of emergence) {
|
|
798
|
-
const key = `${point.group}\u0000${point.origin}`;
|
|
799
|
-
const previous = latestByOrigin.get(key);
|
|
800
|
-
if (!previous || point.ageMonths > previous.ageMonths) latestByOrigin.set(key, point);
|
|
801
|
-
}
|
|
802
|
-
return {
|
|
803
|
-
emergence,
|
|
804
|
-
triangles,
|
|
805
|
-
latestDiagonal: [...latestByOrigin.values()],
|
|
806
|
-
};
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
export function metricTriangleFromEmergence(
|
|
810
|
-
points: readonly DiagnosticEmergencePoint<unknown>[],
|
|
811
|
-
metricId: string,
|
|
812
|
-
): DiagnosticMetricTriangle {
|
|
813
|
-
const groups = new Set(points.map((point) => point.group));
|
|
814
|
-
if (groups.size > 1) throw new ReservingError("SHAPE", "A metric triangle can contain only one group");
|
|
815
|
-
const origins = [...new Set(points.map((point) => point.origin))].sort(periodCompare);
|
|
816
|
-
const ages = [...new Set(points.map((point) => point.ageMonths))].sort((a, b) => a - b);
|
|
817
|
-
const originIndex = new Map(origins.map((origin, index) => [origin, index]));
|
|
818
|
-
const ageIndex = new Map(ages.map((age, index) => [age, index]));
|
|
819
|
-
const cells: (MetricEvaluation | null)[][] = origins.map(() => ages.map(() => null));
|
|
820
|
-
for (const point of points) {
|
|
821
|
-
const metric = ownRecordValue(point.metrics, metricId);
|
|
822
|
-
if (!metric) throw new ReservingError("BAD_TABLE", `Emergence point has no metric ${metricId}`);
|
|
823
|
-
const i = originIndex.get(point.origin)!;
|
|
824
|
-
const j = ageIndex.get(point.ageMonths)!;
|
|
825
|
-
if (cells[i]![j] !== null) {
|
|
826
|
-
throw new ReservingError(
|
|
827
|
-
"SHAPE",
|
|
828
|
-
`Duplicate diagnostic cell after aggregation for group ${point.group}, origin ${point.origin}, age ${point.ageMonths}`,
|
|
829
|
-
);
|
|
830
|
-
}
|
|
831
|
-
cells[i]![j] = metric;
|
|
832
|
-
}
|
|
833
|
-
return {
|
|
834
|
-
group: points[0]?.group ?? "",
|
|
835
|
-
metricId,
|
|
836
|
-
origins,
|
|
837
|
-
ages,
|
|
838
|
-
values: cells.map((row) => row.map((cell) => cell?.value ?? null)),
|
|
839
|
-
cells,
|
|
840
|
-
};
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
export function sameMaturity<TDimensions>(
|
|
844
|
-
result: MetricDiagnosticsResult<TDimensions>,
|
|
845
|
-
ageMonths: number,
|
|
846
|
-
groups?: readonly string[],
|
|
847
|
-
): DiagnosticEmergencePoint<TDimensions>[] {
|
|
848
|
-
return result.emergence.filter(
|
|
849
|
-
(point) => point.ageMonths === ageMonths && (!groups || groups.includes(point.group)),
|
|
850
|
-
);
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
export interface CommonMaturityResult<TDimensions = unknown> {
|
|
854
|
-
ageMonths: number | null;
|
|
855
|
-
points: DiagnosticEmergencePoint<TDimensions>[];
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
/** Latest maturity present in every selected group; selection is caller-defined. */
|
|
859
|
-
export function commonMaturity<TDimensions>(
|
|
860
|
-
result: MetricDiagnosticsResult<TDimensions>,
|
|
861
|
-
groups: readonly string[],
|
|
862
|
-
): CommonMaturityResult<TDimensions> {
|
|
863
|
-
if (groups.length === 0) return { ageMonths: null, points: [] };
|
|
864
|
-
let common: Set<number> | null = null;
|
|
865
|
-
for (const group of groups) {
|
|
866
|
-
const ages = new Set(result.emergence.filter((point) => point.group === group).map((point) => point.ageMonths));
|
|
867
|
-
if (common === null) common = ages;
|
|
868
|
-
else {
|
|
869
|
-
const intersection = new Set<number>();
|
|
870
|
-
for (const age of common) if (ages.has(age)) intersection.add(age);
|
|
871
|
-
common = intersection;
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
const ageMonths = common && common.size > 0 ? Math.max(...common) : null;
|
|
875
|
-
return { ageMonths, points: ageMonths === null ? [] : sameMaturity(result, ageMonths, groups) };
|
|
876
|
-
}
|