@actuarial-ts/data 0.5.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.
- package/README.md +45 -127
- package/dist/casualtyDiagnosticReview.d.ts +46 -0
- package/dist/casualtyDiagnosticReview.d.ts.map +1 -0
- package/dist/casualtyDiagnosticReview.js +23 -0
- package/dist/casualtyDiagnosticReview.js.map +1 -0
- package/dist/diagnosticDefinition.d.ts +4 -0
- package/dist/diagnosticDefinition.d.ts.map +1 -0
- package/dist/diagnosticDefinition.js +6 -0
- package/dist/diagnosticDefinition.js.map +1 -0
- package/dist/diagnosticInput.d.ts +97 -13
- package/dist/diagnosticInput.d.ts.map +1 -1
- package/dist/diagnosticInput.js +403 -43
- package/dist/diagnosticInput.js.map +1 -1
- package/dist/diagnosticPreparedReview.d.ts +46 -0
- package/dist/diagnosticPreparedReview.d.ts.map +1 -0
- package/dist/diagnosticPreparedReview.js +449 -0
- package/dist/diagnosticPreparedReview.js.map +1 -0
- package/dist/exposure.d.ts.map +1 -1
- package/dist/exposure.js +25 -9
- package/dist/exposure.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/lossRun.d.ts.map +1 -1
- package/dist/lossRun.js +31 -8
- package/dist/lossRun.js.map +1 -1
- package/dist/review.d.ts +16 -3
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +35 -5
- package/dist/review.js.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +5 -4
- package/src/casualtyDiagnosticReview.ts +17 -0
- package/src/diagnosticDefinition.ts +6 -0
- package/src/diagnosticInput.ts +600 -57
- package/src/diagnosticPreparedReview.ts +653 -0
- package/src/exposure.ts +61 -16
- package/src/index.ts +4 -1
- package/src/lossRun.ts +55 -13
- package/src/review.ts +194 -42
- package/src/version.ts +1 -0
- package/dist/diagnosticReview.d.ts +0 -68
- package/dist/diagnosticReview.d.ts.map +0 -1
- package/dist/diagnosticReview.js +0 -370
- package/dist/diagnosticReview.js.map +0 -1
- package/src/diagnosticReview.ts +0 -547
package/src/exposure.ts
CHANGED
|
@@ -11,9 +11,12 @@ export const exposureRecordSchema = z
|
|
|
11
11
|
exposureUnits: z.number().finite().nullable(),
|
|
12
12
|
})
|
|
13
13
|
.strict()
|
|
14
|
-
.refine(
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
.refine(
|
|
15
|
+
(record) => record.earnedPremium !== null || record.exposureUnits !== null,
|
|
16
|
+
{
|
|
17
|
+
message: "earnedPremium and exposureUnits cannot both be null",
|
|
18
|
+
},
|
|
19
|
+
);
|
|
17
20
|
|
|
18
21
|
export interface ExposureRowError {
|
|
19
22
|
/** 1-based physical file line where the row starts. */
|
|
@@ -45,7 +48,9 @@ function optionalAmount(
|
|
|
45
48
|
}
|
|
46
49
|
const parsed = Number(value);
|
|
47
50
|
if (!Number.isFinite(parsed)) {
|
|
48
|
-
errors.push(
|
|
51
|
+
errors.push(
|
|
52
|
+
`${name} must be blank or a finite decimal number (got "${raw}")`,
|
|
53
|
+
);
|
|
49
54
|
return null;
|
|
50
55
|
}
|
|
51
56
|
return parsed;
|
|
@@ -63,13 +68,29 @@ function optionalAmount(
|
|
|
63
68
|
export function parseExposureCsv(text: string): ExposureParseResult {
|
|
64
69
|
const { rows: grid, rowLines, warnings } = parseCsv(text);
|
|
65
70
|
const headers = (grid[0] ?? []).map(normalizeHeader);
|
|
71
|
+
const duplicateHeaders = [
|
|
72
|
+
...new Set(
|
|
73
|
+
headers.filter(
|
|
74
|
+
(header, index) => header !== "" && headers.indexOf(header) !== index,
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
].sort();
|
|
78
|
+
if (duplicateHeaders.length > 0) {
|
|
79
|
+
throw new ReservingError(
|
|
80
|
+
"SHAPE",
|
|
81
|
+
`Duplicate normalized column(s): ${duplicateHeaders.join(", ")}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
66
84
|
if (!headers.includes("origin")) {
|
|
67
85
|
throw new ReservingError(
|
|
68
86
|
"SHAPE",
|
|
69
87
|
`Missing required column: origin. Found: ${headers.filter(Boolean).join(", ") || "(none)"}`,
|
|
70
88
|
);
|
|
71
89
|
}
|
|
72
|
-
if (
|
|
90
|
+
if (
|
|
91
|
+
!headers.includes("earned_premium") &&
|
|
92
|
+
!headers.includes("exposure_units")
|
|
93
|
+
) {
|
|
73
94
|
throw new ReservingError(
|
|
74
95
|
"SHAPE",
|
|
75
96
|
"Exposure CSV must include earned_premium, exposure_units, or both",
|
|
@@ -77,12 +98,13 @@ export function parseExposureCsv(text: string): ExposureParseResult {
|
|
|
77
98
|
}
|
|
78
99
|
|
|
79
100
|
const columnIndex = new Map<string, number>();
|
|
80
|
-
headers.forEach((header, index) =>
|
|
81
|
-
if (!columnIndex.has(header)) columnIndex.set(header, index);
|
|
82
|
-
});
|
|
101
|
+
headers.forEach((header, index) => columnIndex.set(header, index));
|
|
83
102
|
const errors: ExposureRowError[] = warnings.map((warning) => {
|
|
84
103
|
const line = /line (\d+)/.exec(warning)?.[1];
|
|
85
|
-
return {
|
|
104
|
+
return {
|
|
105
|
+
row: line === undefined ? 1 : Number(line),
|
|
106
|
+
message: `CSV structure: ${warning}`,
|
|
107
|
+
};
|
|
86
108
|
});
|
|
87
109
|
const parsedRows: { exposure: ExposureRecord; row: number }[] = [];
|
|
88
110
|
|
|
@@ -96,16 +118,32 @@ export function parseExposureCsv(text: string): ExposureParseResult {
|
|
|
96
118
|
const rowErrors: string[] = [];
|
|
97
119
|
const origin = cell("origin");
|
|
98
120
|
if (origin === "") rowErrors.push("origin is required");
|
|
99
|
-
const earnedPremium = optionalAmount(
|
|
100
|
-
|
|
101
|
-
|
|
121
|
+
const earnedPremium = optionalAmount(
|
|
122
|
+
"earned_premium",
|
|
123
|
+
cell("earned_premium"),
|
|
124
|
+
rowErrors,
|
|
125
|
+
);
|
|
126
|
+
const exposureUnits = optionalAmount(
|
|
127
|
+
"exposure_units",
|
|
128
|
+
cell("exposure_units"),
|
|
129
|
+
rowErrors,
|
|
130
|
+
);
|
|
131
|
+
if (
|
|
132
|
+
earnedPremium === null &&
|
|
133
|
+
exposureUnits === null &&
|
|
134
|
+
rowErrors.length === 0
|
|
135
|
+
) {
|
|
102
136
|
rowErrors.push("earned_premium and exposure_units cannot both be blank");
|
|
103
137
|
}
|
|
104
138
|
if (rowErrors.length > 0) {
|
|
105
139
|
for (const message of rowErrors) errors.push({ row: rowNumber, message });
|
|
106
140
|
continue;
|
|
107
141
|
}
|
|
108
|
-
const validated = exposureRecordSchema.safeParse({
|
|
142
|
+
const validated = exposureRecordSchema.safeParse({
|
|
143
|
+
origin,
|
|
144
|
+
earnedPremium,
|
|
145
|
+
exposureUnits,
|
|
146
|
+
});
|
|
109
147
|
if (!validated.success) {
|
|
110
148
|
for (const issue of validated.error.issues) {
|
|
111
149
|
errors.push({ row: rowNumber, message: issue.message });
|
|
@@ -115,17 +153,24 @@ export function parseExposureCsv(text: string): ExposureParseResult {
|
|
|
115
153
|
parsedRows.push({ exposure: validated.data, row: rowNumber });
|
|
116
154
|
}
|
|
117
155
|
|
|
118
|
-
const rowsByOrigin = new Map<
|
|
156
|
+
const rowsByOrigin = new Map<
|
|
157
|
+
string,
|
|
158
|
+
{ exposure: ExposureRecord; row: number }[]
|
|
159
|
+
>();
|
|
119
160
|
for (const parsed of parsedRows) {
|
|
120
161
|
const sameOrigin = rowsByOrigin.get(parsed.exposure.origin);
|
|
121
|
-
if (sameOrigin === undefined)
|
|
162
|
+
if (sameOrigin === undefined)
|
|
163
|
+
rowsByOrigin.set(parsed.exposure.origin, [parsed]);
|
|
122
164
|
else sameOrigin.push(parsed);
|
|
123
165
|
}
|
|
124
166
|
const exposures: ExposureRecord[] = [];
|
|
125
167
|
for (const [origin, sameOrigin] of rowsByOrigin) {
|
|
126
168
|
if (sameOrigin.length > 1) {
|
|
127
169
|
for (const duplicate of sameOrigin) {
|
|
128
|
-
errors.push({
|
|
170
|
+
errors.push({
|
|
171
|
+
row: duplicate.row,
|
|
172
|
+
message: `duplicate origin "${origin}"`,
|
|
173
|
+
});
|
|
129
174
|
}
|
|
130
175
|
} else {
|
|
131
176
|
exposures.push(sameOrigin[0]!.exposure);
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,10 @@ export * from "./csv.js";
|
|
|
2
2
|
export * from "./annualDevelopment.js";
|
|
3
3
|
export * from "./exposure.js";
|
|
4
4
|
export * from "./diagnosticInput.js";
|
|
5
|
-
export * from "./
|
|
5
|
+
export * from "./diagnosticDefinition.js";
|
|
6
|
+
export * from "./version.js";
|
|
7
|
+
export * from "./diagnosticPreparedReview.js";
|
|
8
|
+
export * from "./casualtyDiagnosticReview.js";
|
|
6
9
|
export * from "./lossRun.js";
|
|
7
10
|
export * from "./longFormat.js";
|
|
8
11
|
export * from "./review.js";
|
package/src/lossRun.ts
CHANGED
|
@@ -63,7 +63,20 @@ function isValidIsoDate(value: string): boolean {
|
|
|
63
63
|
// preserve leapness for every year except 0000, so this is a correctness-of-
|
|
64
64
|
// form consolidation rather than a live-bug fix.
|
|
65
65
|
const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
|
|
66
|
-
const daysInMonth = [
|
|
66
|
+
const daysInMonth = [
|
|
67
|
+
31,
|
|
68
|
+
leap ? 29 : 28,
|
|
69
|
+
31,
|
|
70
|
+
30,
|
|
71
|
+
31,
|
|
72
|
+
30,
|
|
73
|
+
31,
|
|
74
|
+
31,
|
|
75
|
+
30,
|
|
76
|
+
31,
|
|
77
|
+
30,
|
|
78
|
+
31,
|
|
79
|
+
][m - 1]!;
|
|
67
80
|
return d <= daysInMonth;
|
|
68
81
|
}
|
|
69
82
|
|
|
@@ -71,6 +84,19 @@ function isValidIsoDate(value: string): boolean {
|
|
|
71
84
|
export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
72
85
|
const { rows: grid, rowLines, warnings: csvWarnings } = parseCsv(text);
|
|
73
86
|
const headers = (grid[0] ?? []).map(normalizeHeader);
|
|
87
|
+
const duplicateHeaders = [
|
|
88
|
+
...new Set(
|
|
89
|
+
headers.filter(
|
|
90
|
+
(header, index) => header !== "" && headers.indexOf(header) !== index,
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
].sort();
|
|
94
|
+
if (duplicateHeaders.length > 0) {
|
|
95
|
+
throw new ReservingError(
|
|
96
|
+
"SHAPE",
|
|
97
|
+
`Duplicate normalized column(s): ${duplicateHeaders.join(", ")}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
74
100
|
const missing = REQUIRED_COLUMNS.filter((c) => !headers.includes(c));
|
|
75
101
|
if (missing.length > 0) {
|
|
76
102
|
throw new ReservingError(
|
|
@@ -81,9 +107,7 @@ export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
|
81
107
|
);
|
|
82
108
|
}
|
|
83
109
|
const columnIndex = new Map<string, number>();
|
|
84
|
-
headers.forEach((h, idx) =>
|
|
85
|
-
if (!columnIndex.has(h)) columnIndex.set(h, idx);
|
|
86
|
-
});
|
|
110
|
+
headers.forEach((h, idx) => columnIndex.set(h, idx));
|
|
87
111
|
|
|
88
112
|
const claims: ClaimSnapshot[] = [];
|
|
89
113
|
const errors: LossRunRowError[] = [];
|
|
@@ -91,7 +115,10 @@ export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
|
91
115
|
// Structural CSV problems surface through the same channel as row errors:
|
|
92
116
|
// partial loading is intended behavior here, silent partial loading is not.
|
|
93
117
|
const lineMatch = warning.match(/line (\d+)/);
|
|
94
|
-
errors.push({
|
|
118
|
+
errors.push({
|
|
119
|
+
row: lineMatch ? Number(lineMatch[1]) : 1,
|
|
120
|
+
message: `CSV structure: ${warning}`,
|
|
121
|
+
});
|
|
95
122
|
}
|
|
96
123
|
|
|
97
124
|
for (let r = 1; r < grid.length; r++) {
|
|
@@ -101,7 +128,8 @@ export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
|
101
128
|
// lines. One errors array, one numbering scheme.
|
|
102
129
|
const rowNumber = rowLines[r]!;
|
|
103
130
|
const cells = grid[r]!;
|
|
104
|
-
const cell = (name: string): string =>
|
|
131
|
+
const cell = (name: string): string =>
|
|
132
|
+
(cells[columnIndex.get(name)!] ?? "").trim();
|
|
105
133
|
const rowErrors: string[] = [];
|
|
106
134
|
|
|
107
135
|
const claimId = cell("claim_id");
|
|
@@ -110,7 +138,9 @@ export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
|
110
138
|
const date = (name: string): string | null => {
|
|
111
139
|
const value = cell(name);
|
|
112
140
|
if (!isValidIsoDate(value)) {
|
|
113
|
-
rowErrors.push(
|
|
141
|
+
rowErrors.push(
|
|
142
|
+
`${name} must be a real calendar date in yyyy-mm-dd form (got "${value}")`,
|
|
143
|
+
);
|
|
114
144
|
return null;
|
|
115
145
|
}
|
|
116
146
|
return value;
|
|
@@ -134,9 +164,13 @@ export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
|
134
164
|
);
|
|
135
165
|
return null;
|
|
136
166
|
}
|
|
137
|
-
const n = /^-?\d+(\.\d+)?$/.test(value.trim())
|
|
167
|
+
const n = /^-?\d+(\.\d+)?$/.test(value.trim())
|
|
168
|
+
? Number(value.trim())
|
|
169
|
+
: NaN;
|
|
138
170
|
if (!Number.isFinite(n)) {
|
|
139
|
-
rowErrors.push(
|
|
171
|
+
rowErrors.push(
|
|
172
|
+
`${name} must be a finite decimal number (got "${value}")`,
|
|
173
|
+
);
|
|
140
174
|
return null;
|
|
141
175
|
}
|
|
142
176
|
return n;
|
|
@@ -148,13 +182,21 @@ export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
|
148
182
|
const status: "open" | "closed" | null =
|
|
149
183
|
statusRaw === "open" || statusRaw === "closed" ? statusRaw : null;
|
|
150
184
|
if (status === null) {
|
|
151
|
-
rowErrors.push(
|
|
185
|
+
rowErrors.push(
|
|
186
|
+
`status must be "open" or "closed" (got "${cell("status")}")`,
|
|
187
|
+
);
|
|
152
188
|
}
|
|
153
189
|
|
|
154
190
|
// Cross-field checks need all three dates to be individually valid.
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
191
|
+
if (
|
|
192
|
+
accidentDate !== null &&
|
|
193
|
+
reportDate !== null &&
|
|
194
|
+
evaluationDate !== null
|
|
195
|
+
) {
|
|
196
|
+
if (reportDate < accidentDate)
|
|
197
|
+
rowErrors.push("report_date precedes accident_date");
|
|
198
|
+
if (evaluationDate < reportDate)
|
|
199
|
+
rowErrors.push("evaluation_date precedes report_date");
|
|
158
200
|
}
|
|
159
201
|
|
|
160
202
|
if (rowErrors.length > 0) {
|
package/src/review.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
isRealIsoDate,
|
|
3
|
+
type ClaimSnapshot,
|
|
4
|
+
type Triangle,
|
|
5
|
+
} from "@actuarial-ts/core";
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* ASOP No. 23 (Data Quality)-oriented data review.
|
|
@@ -25,12 +29,25 @@ import type { ClaimSnapshot, Triangle } from "@actuarial-ts/core";
|
|
|
25
29
|
export type DataCheckStatus = "pass" | "warning" | "fail" | "not-evaluated";
|
|
26
30
|
|
|
27
31
|
export interface DataFindingContext {
|
|
32
|
+
ruleId?: string;
|
|
33
|
+
measureId?: string;
|
|
34
|
+
expressionPath?: string;
|
|
35
|
+
offendingKey?: string;
|
|
36
|
+
groupingKey?: string;
|
|
37
|
+
cachedEvidenceId?: string;
|
|
38
|
+
sourceGroup?: string;
|
|
28
39
|
origin?: string;
|
|
29
40
|
valuation?: string;
|
|
30
|
-
|
|
41
|
+
developmentAge?: number;
|
|
42
|
+
ageUnit?: string;
|
|
43
|
+
recordId?: string;
|
|
44
|
+
claimId?: string;
|
|
45
|
+
exposureKey?: string;
|
|
31
46
|
group?: string;
|
|
32
47
|
sourceFile?: string;
|
|
33
48
|
sourceRow?: number;
|
|
49
|
+
sources?: readonly import("@actuarial-ts/core").DiagnosticSourceLocation[];
|
|
50
|
+
reviewScope?: import("@actuarial-ts/core").DiagnosticReviewEvaluationScope;
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
export interface DataFinding {
|
|
@@ -46,12 +63,17 @@ export interface DataCheck {
|
|
|
46
63
|
status: DataCheckStatus;
|
|
47
64
|
details: string[];
|
|
48
65
|
/** Additive structured detail for consumers that should not parse prose. */
|
|
49
|
-
findings
|
|
66
|
+
findings: DataFinding[];
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
export interface DataReviewReport {
|
|
53
70
|
checks: DataCheck[];
|
|
54
|
-
summary: {
|
|
71
|
+
summary: {
|
|
72
|
+
pass: number;
|
|
73
|
+
warning: number;
|
|
74
|
+
fail: number;
|
|
75
|
+
notEvaluated: number;
|
|
76
|
+
};
|
|
55
77
|
}
|
|
56
78
|
|
|
57
79
|
export interface ReviewClaimDataOptions {
|
|
@@ -64,7 +86,10 @@ const MAX_DETAILS = 20;
|
|
|
64
86
|
|
|
65
87
|
function capDetails(items: string[]): string[] {
|
|
66
88
|
if (items.length <= MAX_DETAILS) return items;
|
|
67
|
-
return [
|
|
89
|
+
return [
|
|
90
|
+
...items.slice(0, MAX_DETAILS),
|
|
91
|
+
`+${items.length - MAX_DETAILS} more`,
|
|
92
|
+
];
|
|
68
93
|
}
|
|
69
94
|
|
|
70
95
|
function makeCheck(
|
|
@@ -78,13 +103,24 @@ function makeCheck(
|
|
|
78
103
|
description,
|
|
79
104
|
status: findings.length > 0 ? statusWhenFound : "pass",
|
|
80
105
|
details: capDetails(findings),
|
|
106
|
+
findings: [],
|
|
81
107
|
};
|
|
82
108
|
}
|
|
83
109
|
|
|
84
|
-
function notEvaluated(
|
|
110
|
+
function notEvaluated(
|
|
111
|
+
id: string,
|
|
112
|
+
description: string,
|
|
113
|
+
reason: string,
|
|
114
|
+
): DataCheck {
|
|
85
115
|
// A check that could not run is REPORTED as such - counting it as "pass"
|
|
86
116
|
// would overstate the review in the very disclosure this feeds.
|
|
87
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
id,
|
|
119
|
+
description,
|
|
120
|
+
status: "not-evaluated",
|
|
121
|
+
details: [`not evaluated: ${reason}`],
|
|
122
|
+
findings: [],
|
|
123
|
+
};
|
|
88
124
|
}
|
|
89
125
|
|
|
90
126
|
function summarize(checks: DataCheck[]): DataReviewReport {
|
|
@@ -103,15 +139,19 @@ export function createStructuredDataCheck(
|
|
|
103
139
|
statusWhenFound: "warning" | "fail",
|
|
104
140
|
findings: readonly DataFinding[],
|
|
105
141
|
): DataCheck {
|
|
106
|
-
const capped =
|
|
142
|
+
const capped =
|
|
143
|
+
findings.length <= MAX_DETAILS
|
|
144
|
+
? [...findings]
|
|
145
|
+
: [...findings.slice(0, MAX_DETAILS)];
|
|
107
146
|
const details = capped.map((finding) => finding.message);
|
|
108
|
-
if (findings.length > MAX_DETAILS)
|
|
147
|
+
if (findings.length > MAX_DETAILS)
|
|
148
|
+
details.push(`+${findings.length - MAX_DETAILS} more`);
|
|
109
149
|
return {
|
|
110
150
|
id,
|
|
111
151
|
description,
|
|
112
152
|
status: findings.length > 0 ? statusWhenFound : "pass",
|
|
113
153
|
details,
|
|
114
|
-
findings:
|
|
154
|
+
findings: [...findings],
|
|
115
155
|
};
|
|
116
156
|
}
|
|
117
157
|
|
|
@@ -130,13 +170,19 @@ export function createNotEvaluatedDataCheck(
|
|
|
130
170
|
}
|
|
131
171
|
|
|
132
172
|
const CLAIM_DESCRIPTIONS = {
|
|
133
|
-
"non-finite-value":
|
|
173
|
+
"non-finite-value":
|
|
174
|
+
"Every money field is a finite number (no NaN or Infinity)",
|
|
134
175
|
"negative-paid": "Cumulative paid amounts are non-negative",
|
|
135
|
-
"negative-case":
|
|
176
|
+
"negative-case":
|
|
177
|
+
"Case reserves are non-negative (negative case is legitimate but rare)",
|
|
136
178
|
"paid-decreasing":
|
|
137
179
|
"Cumulative paid never decreases across a claim's snapshots ordered by evaluation date",
|
|
138
|
-
"date-order":
|
|
139
|
-
|
|
180
|
+
"date-order":
|
|
181
|
+
"accident_date <= report_date <= evaluation_date on every snapshot",
|
|
182
|
+
"duplicate-snapshot":
|
|
183
|
+
"No claim has two snapshots at the same evaluation date",
|
|
184
|
+
"claim-identity":
|
|
185
|
+
"Every snapshot for one claim has the same accident and report identity",
|
|
140
186
|
"future-dated": "No claim date exceeds the as-of date",
|
|
141
187
|
"closed-with-case": "Closed claims carry no outstanding case reserve",
|
|
142
188
|
} as const;
|
|
@@ -160,6 +206,16 @@ export function reviewClaimData(
|
|
|
160
206
|
// pointed auditors at the wrong line; an identifier we cannot compute is
|
|
161
207
|
// one we must not print.
|
|
162
208
|
const where = `claim ${c.claimId} (eval ${c.evaluationDate})`;
|
|
209
|
+
for (const [name, value] of [
|
|
210
|
+
["accident_date", c.accidentDate],
|
|
211
|
+
["report_date", c.reportDate],
|
|
212
|
+
["evaluation_date", c.evaluationDate],
|
|
213
|
+
] as const) {
|
|
214
|
+
if (!isRealIsoDate(value))
|
|
215
|
+
dateOrder.push(
|
|
216
|
+
`${where}: ${name} is not a real yyyy-mm-dd calendar date`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
163
219
|
if (c.paidToDate < 0) {
|
|
164
220
|
negativePaid.push(`${where}: paid_to_date ${c.paidToDate}`);
|
|
165
221
|
}
|
|
@@ -179,17 +235,25 @@ export function reviewClaimData(
|
|
|
179
235
|
if (opts.asOfDate !== undefined) {
|
|
180
236
|
const asOf = opts.asOfDate;
|
|
181
237
|
if (c.accidentDate > asOf) {
|
|
182
|
-
futureDated.push(
|
|
238
|
+
futureDated.push(
|
|
239
|
+
`${where}: accident_date ${c.accidentDate} exceeds as-of ${asOf}`,
|
|
240
|
+
);
|
|
183
241
|
}
|
|
184
242
|
if (c.reportDate > asOf) {
|
|
185
|
-
futureDated.push(
|
|
243
|
+
futureDated.push(
|
|
244
|
+
`${where}: report_date ${c.reportDate} exceeds as-of ${asOf}`,
|
|
245
|
+
);
|
|
186
246
|
}
|
|
187
247
|
if (c.evaluationDate > asOf) {
|
|
188
|
-
futureDated.push(
|
|
248
|
+
futureDated.push(
|
|
249
|
+
`${where}: evaluation_date ${c.evaluationDate} exceeds as-of ${asOf}`,
|
|
250
|
+
);
|
|
189
251
|
}
|
|
190
252
|
}
|
|
191
253
|
if (c.status === "closed" && c.caseReserve > 0) {
|
|
192
|
-
closedWithCase.push(
|
|
254
|
+
closedWithCase.push(
|
|
255
|
+
`${where}: case_reserve ${c.caseReserve} on a closed claim`,
|
|
256
|
+
);
|
|
193
257
|
}
|
|
194
258
|
});
|
|
195
259
|
|
|
@@ -202,12 +266,24 @@ export function reviewClaimData(
|
|
|
202
266
|
}
|
|
203
267
|
const paidDecreasing: string[] = [];
|
|
204
268
|
const duplicates: string[] = [];
|
|
269
|
+
const identityConflicts: string[] = [];
|
|
205
270
|
for (const [claimId, snaps] of byClaim) {
|
|
206
|
-
const
|
|
271
|
+
const identities = new Set(
|
|
272
|
+
snaps.map((snap) => `${snap.accidentDate}|${snap.reportDate}`),
|
|
273
|
+
);
|
|
274
|
+
if (identities.size > 1)
|
|
275
|
+
identityConflicts.push(
|
|
276
|
+
`claim ${claimId}: snapshots contain conflicting accident_date or report_date values`,
|
|
277
|
+
);
|
|
278
|
+
const sorted = [...snaps].sort((a, b) =>
|
|
279
|
+
a.evaluationDate.localeCompare(b.evaluationDate),
|
|
280
|
+
);
|
|
207
281
|
const seenEvals = new Set<string>();
|
|
208
282
|
for (const s of sorted) {
|
|
209
283
|
if (seenEvals.has(s.evaluationDate)) {
|
|
210
|
-
duplicates.push(
|
|
284
|
+
duplicates.push(
|
|
285
|
+
`claim ${claimId}: duplicate snapshot at ${s.evaluationDate}`,
|
|
286
|
+
);
|
|
211
287
|
}
|
|
212
288
|
seenEvals.add(s.evaluationDate);
|
|
213
289
|
}
|
|
@@ -227,32 +303,87 @@ export function reviewClaimData(
|
|
|
227
303
|
const nonFinite: string[] = [];
|
|
228
304
|
for (const c of claims) {
|
|
229
305
|
const where = `claim ${c.claimId}`;
|
|
230
|
-
if (!Number.isFinite(c.paidToDate))
|
|
231
|
-
|
|
306
|
+
if (!Number.isFinite(c.paidToDate))
|
|
307
|
+
nonFinite.push(`${where}: paid_to_date ${String(c.paidToDate)}`);
|
|
308
|
+
if (!Number.isFinite(c.caseReserve))
|
|
309
|
+
nonFinite.push(`${where}: case_reserve ${String(c.caseReserve)}`);
|
|
232
310
|
}
|
|
233
311
|
|
|
234
312
|
const futureCheck =
|
|
235
313
|
opts.asOfDate === undefined
|
|
236
|
-
? notEvaluated(
|
|
237
|
-
|
|
314
|
+
? notEvaluated(
|
|
315
|
+
"future-dated",
|
|
316
|
+
CLAIM_DESCRIPTIONS["future-dated"],
|
|
317
|
+
"no asOfDate provided",
|
|
318
|
+
)
|
|
319
|
+
: makeCheck(
|
|
320
|
+
"future-dated",
|
|
321
|
+
CLAIM_DESCRIPTIONS["future-dated"],
|
|
322
|
+
"fail",
|
|
323
|
+
futureDated,
|
|
324
|
+
);
|
|
238
325
|
|
|
239
326
|
return summarize([
|
|
240
327
|
// First: if the numbers are not numbers, the other verdicts are noise.
|
|
241
|
-
makeCheck(
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
328
|
+
makeCheck(
|
|
329
|
+
"non-finite-value",
|
|
330
|
+
CLAIM_DESCRIPTIONS["non-finite-value"],
|
|
331
|
+
"fail",
|
|
332
|
+
nonFinite,
|
|
333
|
+
),
|
|
334
|
+
makeCheck(
|
|
335
|
+
"negative-paid",
|
|
336
|
+
CLAIM_DESCRIPTIONS["negative-paid"],
|
|
337
|
+
"fail",
|
|
338
|
+
negativePaid,
|
|
339
|
+
),
|
|
340
|
+
makeCheck(
|
|
341
|
+
"negative-case",
|
|
342
|
+
CLAIM_DESCRIPTIONS["negative-case"],
|
|
343
|
+
"warning",
|
|
344
|
+
negativeCase,
|
|
345
|
+
),
|
|
346
|
+
makeCheck(
|
|
347
|
+
"paid-decreasing",
|
|
348
|
+
CLAIM_DESCRIPTIONS["paid-decreasing"],
|
|
349
|
+
"fail",
|
|
350
|
+
paidDecreasing,
|
|
351
|
+
),
|
|
352
|
+
makeCheck(
|
|
353
|
+
"date-order",
|
|
354
|
+
CLAIM_DESCRIPTIONS["date-order"],
|
|
355
|
+
"fail",
|
|
356
|
+
dateOrder,
|
|
357
|
+
),
|
|
358
|
+
makeCheck(
|
|
359
|
+
"duplicate-snapshot",
|
|
360
|
+
CLAIM_DESCRIPTIONS["duplicate-snapshot"],
|
|
361
|
+
"fail",
|
|
362
|
+
duplicates,
|
|
363
|
+
),
|
|
364
|
+
makeCheck(
|
|
365
|
+
"claim-identity",
|
|
366
|
+
CLAIM_DESCRIPTIONS["claim-identity"],
|
|
367
|
+
"fail",
|
|
368
|
+
identityConflicts,
|
|
369
|
+
),
|
|
247
370
|
futureCheck,
|
|
248
|
-
makeCheck(
|
|
371
|
+
makeCheck(
|
|
372
|
+
"closed-with-case",
|
|
373
|
+
CLAIM_DESCRIPTIONS["closed-with-case"],
|
|
374
|
+
"warning",
|
|
375
|
+
closedWithCase,
|
|
376
|
+
),
|
|
249
377
|
]);
|
|
250
378
|
}
|
|
251
379
|
|
|
252
380
|
const TRIANGLE_DESCRIPTIONS = {
|
|
253
|
-
"non-finite-value":
|
|
254
|
-
|
|
255
|
-
"
|
|
381
|
+
"non-finite-value":
|
|
382
|
+
"Every observed cell is a finite number (no NaN or Infinity)",
|
|
383
|
+
"shape-mismatch":
|
|
384
|
+
"Paid and incurred triangles share the same origins and ages",
|
|
385
|
+
"paid-exceeds-incurred":
|
|
386
|
+
"Paid never exceeds incurred in any cell (1e-9 relative tolerance)",
|
|
256
387
|
"negative-incremental-paid":
|
|
257
388
|
"Cumulative paid is non-decreasing along each origin row (salvage/subrogation can legitimately violate this)",
|
|
258
389
|
"negative-incremental-incurred":
|
|
@@ -275,7 +406,9 @@ function nonFiniteTriangleFindings(tri: Triangle): string[] {
|
|
|
275
406
|
const v = row[j];
|
|
276
407
|
if (v === null || v === undefined) continue;
|
|
277
408
|
if (!Number.isFinite(v)) {
|
|
278
|
-
out.push(
|
|
409
|
+
out.push(
|
|
410
|
+
`${tri.kind} ${tri.origins[i]} age ${tri.ages[j]}: ${String(v)}`,
|
|
411
|
+
);
|
|
279
412
|
}
|
|
280
413
|
}
|
|
281
414
|
}
|
|
@@ -319,7 +452,9 @@ function interiorMissingFindings(tri: Triangle): string[] {
|
|
|
319
452
|
if (first === -1) continue;
|
|
320
453
|
for (let j = first + 1; j < last; j++) {
|
|
321
454
|
if (!observed[j]) {
|
|
322
|
-
out.push(
|
|
455
|
+
out.push(
|
|
456
|
+
`${tri.kind} ${tri.origins[i]} age ${tri.ages[j]}: interior cell missing`,
|
|
457
|
+
);
|
|
323
458
|
}
|
|
324
459
|
}
|
|
325
460
|
}
|
|
@@ -327,7 +462,10 @@ function interiorMissingFindings(tri: Triangle): string[] {
|
|
|
327
462
|
}
|
|
328
463
|
|
|
329
464
|
/** Reviews a paid/incurred triangle pair for cross-triangle consistency. */
|
|
330
|
-
export function reviewTriangles(
|
|
465
|
+
export function reviewTriangles(
|
|
466
|
+
paid: Triangle,
|
|
467
|
+
incurred: Triangle,
|
|
468
|
+
): DataReviewReport {
|
|
331
469
|
const shapeFindings: string[] = [];
|
|
332
470
|
const sameOrigins =
|
|
333
471
|
paid.origins.length === incurred.origins.length &&
|
|
@@ -349,7 +487,10 @@ export function reviewTriangles(paid: Triangle, incurred: Triangle): DataReviewR
|
|
|
349
487
|
"non-finite-value",
|
|
350
488
|
TRIANGLE_DESCRIPTIONS["non-finite-value"],
|
|
351
489
|
"fail",
|
|
352
|
-
[
|
|
490
|
+
[
|
|
491
|
+
...nonFiniteTriangleFindings(paid),
|
|
492
|
+
...nonFiniteTriangleFindings(incurred),
|
|
493
|
+
],
|
|
353
494
|
);
|
|
354
495
|
const shapeCheck = makeCheck(
|
|
355
496
|
"shape-mismatch",
|
|
@@ -364,7 +505,11 @@ export function reviewTriangles(paid: Triangle, incurred: Triangle): DataReviewR
|
|
|
364
505
|
return summarize([
|
|
365
506
|
nonFiniteCheck,
|
|
366
507
|
shapeCheck,
|
|
367
|
-
notEvaluated(
|
|
508
|
+
notEvaluated(
|
|
509
|
+
"paid-exceeds-incurred",
|
|
510
|
+
TRIANGLE_DESCRIPTIONS["paid-exceeds-incurred"],
|
|
511
|
+
reason,
|
|
512
|
+
),
|
|
368
513
|
notEvaluated(
|
|
369
514
|
"negative-incremental-paid",
|
|
370
515
|
TRIANGLE_DESCRIPTIONS["negative-incremental-paid"],
|
|
@@ -375,7 +520,11 @@ export function reviewTriangles(paid: Triangle, incurred: Triangle): DataReviewR
|
|
|
375
520
|
TRIANGLE_DESCRIPTIONS["negative-incremental-incurred"],
|
|
376
521
|
reason,
|
|
377
522
|
),
|
|
378
|
-
notEvaluated(
|
|
523
|
+
notEvaluated(
|
|
524
|
+
"interior-missing",
|
|
525
|
+
TRIANGLE_DESCRIPTIONS["interior-missing"],
|
|
526
|
+
reason,
|
|
527
|
+
),
|
|
379
528
|
]);
|
|
380
529
|
}
|
|
381
530
|
|
|
@@ -386,10 +535,13 @@ export function reviewTriangles(paid: Triangle, incurred: Triangle): DataReviewR
|
|
|
386
535
|
for (let j = 0; j < paidRow.length; j++) {
|
|
387
536
|
const p = paidRow[j];
|
|
388
537
|
const inc = incRow[j];
|
|
389
|
-
if (p === null || p === undefined || inc === null || inc === undefined)
|
|
538
|
+
if (p === null || p === undefined || inc === null || inc === undefined)
|
|
539
|
+
continue;
|
|
390
540
|
const tolerance = 1e-9 * Math.max(1, Math.abs(p), Math.abs(inc));
|
|
391
541
|
if (p - inc > tolerance) {
|
|
392
|
-
paidExceeds.push(
|
|
542
|
+
paidExceeds.push(
|
|
543
|
+
`${paid.origins[i]} age ${paid.ages[j]}: paid ${p} > incurred ${inc}`,
|
|
544
|
+
);
|
|
393
545
|
}
|
|
394
546
|
}
|
|
395
547
|
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DATA_PACKAGE_VERSION = "0.6.1";
|