@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.
Files changed (50) hide show
  1. package/README.md +45 -127
  2. package/dist/casualtyDiagnosticReview.d.ts +46 -0
  3. package/dist/casualtyDiagnosticReview.d.ts.map +1 -0
  4. package/dist/casualtyDiagnosticReview.js +23 -0
  5. package/dist/casualtyDiagnosticReview.js.map +1 -0
  6. package/dist/diagnosticDefinition.d.ts +4 -0
  7. package/dist/diagnosticDefinition.d.ts.map +1 -0
  8. package/dist/diagnosticDefinition.js +6 -0
  9. package/dist/diagnosticDefinition.js.map +1 -0
  10. package/dist/diagnosticInput.d.ts +97 -13
  11. package/dist/diagnosticInput.d.ts.map +1 -1
  12. package/dist/diagnosticInput.js +403 -43
  13. package/dist/diagnosticInput.js.map +1 -1
  14. package/dist/diagnosticPreparedReview.d.ts +46 -0
  15. package/dist/diagnosticPreparedReview.d.ts.map +1 -0
  16. package/dist/diagnosticPreparedReview.js +449 -0
  17. package/dist/diagnosticPreparedReview.js.map +1 -0
  18. package/dist/exposure.d.ts.map +1 -1
  19. package/dist/exposure.js +25 -9
  20. package/dist/exposure.js.map +1 -1
  21. package/dist/index.d.ts +4 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/lossRun.d.ts.map +1 -1
  26. package/dist/lossRun.js +31 -8
  27. package/dist/lossRun.js.map +1 -1
  28. package/dist/review.d.ts +16 -3
  29. package/dist/review.d.ts.map +1 -1
  30. package/dist/review.js +35 -5
  31. package/dist/review.js.map +1 -1
  32. package/dist/version.d.ts +2 -0
  33. package/dist/version.d.ts.map +1 -0
  34. package/dist/version.js +2 -0
  35. package/dist/version.js.map +1 -0
  36. package/package.json +5 -4
  37. package/src/casualtyDiagnosticReview.ts +17 -0
  38. package/src/diagnosticDefinition.ts +6 -0
  39. package/src/diagnosticInput.ts +600 -57
  40. package/src/diagnosticPreparedReview.ts +653 -0
  41. package/src/exposure.ts +61 -16
  42. package/src/index.ts +4 -1
  43. package/src/lossRun.ts +55 -13
  44. package/src/review.ts +194 -42
  45. package/src/version.ts +1 -0
  46. package/dist/diagnosticReview.d.ts +0 -68
  47. package/dist/diagnosticReview.d.ts.map +0 -1
  48. package/dist/diagnosticReview.js +0 -370
  49. package/dist/diagnosticReview.js.map +0 -1
  50. 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((record) => record.earnedPremium !== null || record.exposureUnits !== null, {
15
- message: "earnedPremium and exposureUnits cannot both be null",
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(`${name} must be blank or a finite decimal number (got "${raw}")`);
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 (!headers.includes("earned_premium") && !headers.includes("exposure_units")) {
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 { row: line === undefined ? 1 : Number(line), message: `CSV structure: ${warning}` };
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("earned_premium", cell("earned_premium"), rowErrors);
100
- const exposureUnits = optionalAmount("exposure_units", cell("exposure_units"), rowErrors);
101
- if (earnedPremium === null && exposureUnits === null && rowErrors.length === 0) {
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({ origin, earnedPremium, exposureUnits });
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<string, { exposure: ExposureRecord; row: number }[]>();
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) rowsByOrigin.set(parsed.exposure.origin, [parsed]);
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({ row: duplicate.row, message: `duplicate origin "${origin}"` });
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 "./diagnosticReview.js";
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 = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1]!;
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({ row: lineMatch ? Number(lineMatch[1]) : 1, message: `CSV structure: ${warning}` });
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 => (cells[columnIndex.get(name)!] ?? "").trim();
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(`${name} must be a real calendar date in yyyy-mm-dd form (got "${value}")`);
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()) ? Number(value.trim()) : NaN;
167
+ const n = /^-?\d+(\.\d+)?$/.test(value.trim())
168
+ ? Number(value.trim())
169
+ : NaN;
138
170
  if (!Number.isFinite(n)) {
139
- rowErrors.push(`${name} must be a finite decimal number (got "${value}")`);
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(`status must be "open" or "closed" (got "${cell("status")}")`);
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 (accidentDate !== null && reportDate !== null && evaluationDate !== null) {
156
- if (reportDate < accidentDate) rowErrors.push("report_date precedes accident_date");
157
- if (evaluationDate < reportDate) rowErrors.push("evaluation_date precedes report_date");
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 type { ClaimSnapshot, Triangle } from "@actuarial-ts/core";
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
- ageMonths?: number;
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?: DataFinding[];
66
+ findings: DataFinding[];
50
67
  }
51
68
 
52
69
  export interface DataReviewReport {
53
70
  checks: DataCheck[];
54
- summary: { pass: number; warning: number; fail: number; notEvaluated: number };
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 [...items.slice(0, MAX_DETAILS), `+${items.length - MAX_DETAILS} more`];
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(id: string, description: string, reason: string): DataCheck {
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 { id, description, status: "not-evaluated", details: [`not evaluated: ${reason}`] };
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 = findings.length <= MAX_DETAILS ? [...findings] : [...findings.slice(0, MAX_DETAILS)];
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) details.push(`+${findings.length - MAX_DETAILS} more`);
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: capped,
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": "Every money field is a finite number (no NaN or Infinity)",
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": "Case reserves are non-negative (negative case is legitimate but rare)",
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": "accident_date <= report_date <= evaluation_date on every snapshot",
139
- "duplicate-snapshot": "No claim has two snapshots at the same evaluation date",
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(`${where}: accident_date ${c.accidentDate} exceeds as-of ${asOf}`);
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(`${where}: report_date ${c.reportDate} exceeds as-of ${asOf}`);
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(`${where}: evaluation_date ${c.evaluationDate} exceeds as-of ${asOf}`);
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(`${where}: case_reserve ${c.caseReserve} on a closed claim`);
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 sorted = [...snaps].sort((a, b) => a.evaluationDate.localeCompare(b.evaluationDate));
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(`claim ${claimId}: duplicate snapshot at ${s.evaluationDate}`);
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)) nonFinite.push(`${where}: paid_to_date ${String(c.paidToDate)}`);
231
- if (!Number.isFinite(c.caseReserve)) nonFinite.push(`${where}: case_reserve ${String(c.caseReserve)}`);
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("future-dated", CLAIM_DESCRIPTIONS["future-dated"], "no asOfDate provided")
237
- : makeCheck("future-dated", CLAIM_DESCRIPTIONS["future-dated"], "fail", futureDated);
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("non-finite-value", CLAIM_DESCRIPTIONS["non-finite-value"], "fail", nonFinite),
242
- makeCheck("negative-paid", CLAIM_DESCRIPTIONS["negative-paid"], "fail", negativePaid),
243
- makeCheck("negative-case", CLAIM_DESCRIPTIONS["negative-case"], "warning", negativeCase),
244
- makeCheck("paid-decreasing", CLAIM_DESCRIPTIONS["paid-decreasing"], "fail", paidDecreasing),
245
- makeCheck("date-order", CLAIM_DESCRIPTIONS["date-order"], "fail", dateOrder),
246
- makeCheck("duplicate-snapshot", CLAIM_DESCRIPTIONS["duplicate-snapshot"], "fail", duplicates),
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("closed-with-case", CLAIM_DESCRIPTIONS["closed-with-case"], "warning", closedWithCase),
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": "Every observed cell is a finite number (no NaN or Infinity)",
254
- "shape-mismatch": "Paid and incurred triangles share the same origins and ages",
255
- "paid-exceeds-incurred": "Paid never exceeds incurred in any cell (1e-9 relative tolerance)",
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(`${tri.kind} ${tri.origins[i]} age ${tri.ages[j]}: ${String(v)}`);
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(`${tri.kind} ${tri.origins[i]} age ${tri.ages[j]}: interior cell missing`);
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(paid: Triangle, incurred: Triangle): DataReviewReport {
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
- [...nonFiniteTriangleFindings(paid), ...nonFiniteTriangleFindings(incurred)],
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("paid-exceeds-incurred", TRIANGLE_DESCRIPTIONS["paid-exceeds-incurred"], reason),
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("interior-missing", TRIANGLE_DESCRIPTIONS["interior-missing"], reason),
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) continue;
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(`${paid.origins[i]} age ${paid.ages[j]}: paid ${p} > incurred ${inc}`);
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";