@actuarial-ts/data 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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/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.
@@ -39,7 +43,6 @@ export interface DataFindingContext {
39
43
  recordId?: string;
40
44
  claimId?: string;
41
45
  exposureKey?: string;
42
- ageMonths?: number;
43
46
  group?: string;
44
47
  sourceFile?: string;
45
48
  sourceRow?: number;
@@ -65,7 +68,12 @@ export interface DataCheck {
65
68
 
66
69
  export interface DataReviewReport {
67
70
  checks: DataCheck[];
68
- summary: { pass: number; warning: number; fail: number; notEvaluated: number };
71
+ summary: {
72
+ pass: number;
73
+ warning: number;
74
+ fail: number;
75
+ notEvaluated: number;
76
+ };
69
77
  }
70
78
 
71
79
  export interface ReviewClaimDataOptions {
@@ -78,7 +86,10 @@ const MAX_DETAILS = 20;
78
86
 
79
87
  function capDetails(items: string[]): string[] {
80
88
  if (items.length <= MAX_DETAILS) return items;
81
- 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
+ ];
82
93
  }
83
94
 
84
95
  function makeCheck(
@@ -96,10 +107,20 @@ function makeCheck(
96
107
  };
97
108
  }
98
109
 
99
- function notEvaluated(id: string, description: string, reason: string): DataCheck {
110
+ function notEvaluated(
111
+ id: string,
112
+ description: string,
113
+ reason: string,
114
+ ): DataCheck {
100
115
  // A check that could not run is REPORTED as such - counting it as "pass"
101
116
  // would overstate the review in the very disclosure this feeds.
102
- return { id, description, status: "not-evaluated", details: [`not evaluated: ${reason}`], findings: [] };
117
+ return {
118
+ id,
119
+ description,
120
+ status: "not-evaluated",
121
+ details: [`not evaluated: ${reason}`],
122
+ findings: [],
123
+ };
103
124
  }
104
125
 
105
126
  function summarize(checks: DataCheck[]): DataReviewReport {
@@ -118,9 +139,13 @@ export function createStructuredDataCheck(
118
139
  statusWhenFound: "warning" | "fail",
119
140
  findings: readonly DataFinding[],
120
141
  ): DataCheck {
121
- 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)];
122
146
  const details = capped.map((finding) => finding.message);
123
- 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`);
124
149
  return {
125
150
  id,
126
151
  description,
@@ -145,13 +170,19 @@ export function createNotEvaluatedDataCheck(
145
170
  }
146
171
 
147
172
  const CLAIM_DESCRIPTIONS = {
148
- "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)",
149
175
  "negative-paid": "Cumulative paid amounts are non-negative",
150
- "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)",
151
178
  "paid-decreasing":
152
179
  "Cumulative paid never decreases across a claim's snapshots ordered by evaluation date",
153
- "date-order": "accident_date <= report_date <= evaluation_date on every snapshot",
154
- "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",
155
186
  "future-dated": "No claim date exceeds the as-of date",
156
187
  "closed-with-case": "Closed claims carry no outstanding case reserve",
157
188
  } as const;
@@ -175,6 +206,16 @@ export function reviewClaimData(
175
206
  // pointed auditors at the wrong line; an identifier we cannot compute is
176
207
  // one we must not print.
177
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
+ }
178
219
  if (c.paidToDate < 0) {
179
220
  negativePaid.push(`${where}: paid_to_date ${c.paidToDate}`);
180
221
  }
@@ -194,17 +235,25 @@ export function reviewClaimData(
194
235
  if (opts.asOfDate !== undefined) {
195
236
  const asOf = opts.asOfDate;
196
237
  if (c.accidentDate > asOf) {
197
- 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
+ );
198
241
  }
199
242
  if (c.reportDate > asOf) {
200
- 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
+ );
201
246
  }
202
247
  if (c.evaluationDate > asOf) {
203
- 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
+ );
204
251
  }
205
252
  }
206
253
  if (c.status === "closed" && c.caseReserve > 0) {
207
- 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
+ );
208
257
  }
209
258
  });
210
259
 
@@ -217,12 +266,24 @@ export function reviewClaimData(
217
266
  }
218
267
  const paidDecreasing: string[] = [];
219
268
  const duplicates: string[] = [];
269
+ const identityConflicts: string[] = [];
220
270
  for (const [claimId, snaps] of byClaim) {
221
- 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
+ );
222
281
  const seenEvals = new Set<string>();
223
282
  for (const s of sorted) {
224
283
  if (seenEvals.has(s.evaluationDate)) {
225
- duplicates.push(`claim ${claimId}: duplicate snapshot at ${s.evaluationDate}`);
284
+ duplicates.push(
285
+ `claim ${claimId}: duplicate snapshot at ${s.evaluationDate}`,
286
+ );
226
287
  }
227
288
  seenEvals.add(s.evaluationDate);
228
289
  }
@@ -242,32 +303,87 @@ export function reviewClaimData(
242
303
  const nonFinite: string[] = [];
243
304
  for (const c of claims) {
244
305
  const where = `claim ${c.claimId}`;
245
- if (!Number.isFinite(c.paidToDate)) nonFinite.push(`${where}: paid_to_date ${String(c.paidToDate)}`);
246
- 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)}`);
247
310
  }
248
311
 
249
312
  const futureCheck =
250
313
  opts.asOfDate === undefined
251
- ? notEvaluated("future-dated", CLAIM_DESCRIPTIONS["future-dated"], "no asOfDate provided")
252
- : 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
+ );
253
325
 
254
326
  return summarize([
255
327
  // First: if the numbers are not numbers, the other verdicts are noise.
256
- makeCheck("non-finite-value", CLAIM_DESCRIPTIONS["non-finite-value"], "fail", nonFinite),
257
- makeCheck("negative-paid", CLAIM_DESCRIPTIONS["negative-paid"], "fail", negativePaid),
258
- makeCheck("negative-case", CLAIM_DESCRIPTIONS["negative-case"], "warning", negativeCase),
259
- makeCheck("paid-decreasing", CLAIM_DESCRIPTIONS["paid-decreasing"], "fail", paidDecreasing),
260
- makeCheck("date-order", CLAIM_DESCRIPTIONS["date-order"], "fail", dateOrder),
261
- 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
+ ),
262
370
  futureCheck,
263
- 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
+ ),
264
377
  ]);
265
378
  }
266
379
 
267
380
  const TRIANGLE_DESCRIPTIONS = {
268
- "non-finite-value": "Every observed cell is a finite number (no NaN or Infinity)",
269
- "shape-mismatch": "Paid and incurred triangles share the same origins and ages",
270
- "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)",
271
387
  "negative-incremental-paid":
272
388
  "Cumulative paid is non-decreasing along each origin row (salvage/subrogation can legitimately violate this)",
273
389
  "negative-incremental-incurred":
@@ -290,7 +406,9 @@ function nonFiniteTriangleFindings(tri: Triangle): string[] {
290
406
  const v = row[j];
291
407
  if (v === null || v === undefined) continue;
292
408
  if (!Number.isFinite(v)) {
293
- 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
+ );
294
412
  }
295
413
  }
296
414
  }
@@ -334,7 +452,9 @@ function interiorMissingFindings(tri: Triangle): string[] {
334
452
  if (first === -1) continue;
335
453
  for (let j = first + 1; j < last; j++) {
336
454
  if (!observed[j]) {
337
- 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
+ );
338
458
  }
339
459
  }
340
460
  }
@@ -342,7 +462,10 @@ function interiorMissingFindings(tri: Triangle): string[] {
342
462
  }
343
463
 
344
464
  /** Reviews a paid/incurred triangle pair for cross-triangle consistency. */
345
- export function reviewTriangles(paid: Triangle, incurred: Triangle): DataReviewReport {
465
+ export function reviewTriangles(
466
+ paid: Triangle,
467
+ incurred: Triangle,
468
+ ): DataReviewReport {
346
469
  const shapeFindings: string[] = [];
347
470
  const sameOrigins =
348
471
  paid.origins.length === incurred.origins.length &&
@@ -364,7 +487,10 @@ export function reviewTriangles(paid: Triangle, incurred: Triangle): DataReviewR
364
487
  "non-finite-value",
365
488
  TRIANGLE_DESCRIPTIONS["non-finite-value"],
366
489
  "fail",
367
- [...nonFiniteTriangleFindings(paid), ...nonFiniteTriangleFindings(incurred)],
490
+ [
491
+ ...nonFiniteTriangleFindings(paid),
492
+ ...nonFiniteTriangleFindings(incurred),
493
+ ],
368
494
  );
369
495
  const shapeCheck = makeCheck(
370
496
  "shape-mismatch",
@@ -379,7 +505,11 @@ export function reviewTriangles(paid: Triangle, incurred: Triangle): DataReviewR
379
505
  return summarize([
380
506
  nonFiniteCheck,
381
507
  shapeCheck,
382
- 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
+ ),
383
513
  notEvaluated(
384
514
  "negative-incremental-paid",
385
515
  TRIANGLE_DESCRIPTIONS["negative-incremental-paid"],
@@ -390,7 +520,11 @@ export function reviewTriangles(paid: Triangle, incurred: Triangle): DataReviewR
390
520
  TRIANGLE_DESCRIPTIONS["negative-incremental-incurred"],
391
521
  reason,
392
522
  ),
393
- notEvaluated("interior-missing", TRIANGLE_DESCRIPTIONS["interior-missing"], reason),
523
+ notEvaluated(
524
+ "interior-missing",
525
+ TRIANGLE_DESCRIPTIONS["interior-missing"],
526
+ reason,
527
+ ),
394
528
  ]);
395
529
  }
396
530
 
@@ -401,10 +535,13 @@ export function reviewTriangles(paid: Triangle, incurred: Triangle): DataReviewR
401
535
  for (let j = 0; j < paidRow.length; j++) {
402
536
  const p = paidRow[j];
403
537
  const inc = incRow[j];
404
- if (p === null || p === undefined || inc === null || inc === undefined) continue;
538
+ if (p === null || p === undefined || inc === null || inc === undefined)
539
+ continue;
405
540
  const tolerance = 1e-9 * Math.max(1, Math.abs(p), Math.abs(inc));
406
541
  if (p - inc > tolerance) {
407
- 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
+ );
408
545
  }
409
546
  }
410
547
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const DATA_PACKAGE_VERSION = "0.6.0";
1
+ export const DATA_PACKAGE_VERSION = "0.7.0";