@actuarial-ts/data 0.2.0 → 0.4.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.
Files changed (43) hide show
  1. package/README.md +86 -4
  2. package/dist/annualDevelopment.d.ts +94 -0
  3. package/dist/annualDevelopment.d.ts.map +1 -0
  4. package/dist/annualDevelopment.js +188 -0
  5. package/dist/annualDevelopment.js.map +1 -0
  6. package/dist/csv.d.ts +25 -3
  7. package/dist/csv.d.ts.map +1 -1
  8. package/dist/csv.js +26 -4
  9. package/dist/csv.js.map +1 -1
  10. package/dist/diagnosticInput.d.ts +14 -0
  11. package/dist/diagnosticInput.d.ts.map +1 -0
  12. package/dist/diagnosticInput.js +51 -0
  13. package/dist/diagnosticInput.js.map +1 -0
  14. package/dist/diagnosticReview.d.ts +68 -0
  15. package/dist/diagnosticReview.d.ts.map +1 -0
  16. package/dist/diagnosticReview.js +370 -0
  17. package/dist/diagnosticReview.js.map +1 -0
  18. package/dist/exposure.d.ts +44 -0
  19. package/dist/exposure.d.ts.map +1 -0
  20. package/dist/exposure.js +113 -0
  21. package/dist/exposure.js.map +1 -0
  22. package/dist/index.d.ts +4 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +4 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/lossRun.d.ts +1 -1
  27. package/dist/lossRun.d.ts.map +1 -1
  28. package/dist/lossRun.js +35 -7
  29. package/dist/lossRun.js.map +1 -1
  30. package/dist/review.d.ts +22 -0
  31. package/dist/review.d.ts.map +1 -1
  32. package/dist/review.js +71 -3
  33. package/dist/review.js.map +1 -1
  34. package/package.json +5 -3
  35. package/src/annualDevelopment.ts +278 -0
  36. package/src/csv.ts +131 -0
  37. package/src/diagnosticInput.ts +79 -0
  38. package/src/diagnosticReview.ts +547 -0
  39. package/src/exposure.ts +136 -0
  40. package/src/index.ts +8 -0
  41. package/src/longFormat.ts +0 -0
  42. package/src/lossRun.ts +176 -0
  43. package/src/review.ts +425 -0
Binary file
package/src/lossRun.ts ADDED
@@ -0,0 +1,176 @@
1
+ import type { ClaimSnapshot } from "@actuarial-ts/core";
2
+ import { ReservingError } from "@actuarial-ts/core";
3
+ import { parseCsv } from "./csv.js";
4
+
5
+ /**
6
+ * Loss-run CSV import. Mirrors the workbench import contract:
7
+ *
8
+ * Required columns (one row per claim per evaluation snapshot):
9
+ * claim_id, accident_date, report_date, evaluation_date,
10
+ * paid_to_date, case_reserve, status
11
+ *
12
+ * Headers are normalized (trimmed, lower-cased, whitespace -> underscores);
13
+ * extra columns are ignored. Missing required columns make the file
14
+ * unparseable and throw ReservingError("SHAPE", ...). Row-level failures are
15
+ * collected — not thrown — so the caller decides whether to abort or load
16
+ * the clean rows; a row with any error contributes no claim.
17
+ *
18
+ * Row numbers in errors are the 1-based PHYSICAL line in the file where the
19
+ * row starts (header included; blank lines and newlines inside quoted fields
20
+ * count), so in a file with no blank lines the first data row is row 2.
21
+ *
22
+ * Unlike the workbench importer, negative paid/case amounts are accepted
23
+ * here: the ASOP 23 review layer (reviewClaimData) flags them, keeping the
24
+ * signal visible instead of silently rejecting salvage/subrogation rows.
25
+ */
26
+
27
+ const REQUIRED_COLUMNS = [
28
+ "claim_id",
29
+ "accident_date",
30
+ "report_date",
31
+ "evaluation_date",
32
+ "paid_to_date",
33
+ "case_reserve",
34
+ "status",
35
+ ] as const;
36
+
37
+ export interface LossRunRowError {
38
+ /** 1-based physical file line where the row starts (first data row = 2 when nothing precedes it). */
39
+ row: number;
40
+ message: string;
41
+ }
42
+
43
+ export interface LossRunParseResult {
44
+ claims: ClaimSnapshot[];
45
+ errors: LossRunRowError[];
46
+ }
47
+
48
+ function normalizeHeader(h: string): string {
49
+ return h.trim().toLowerCase().replace(/\s+/g, "_");
50
+ }
51
+
52
+ /** True when the string is a real calendar date in yyyy-mm-dd form. */
53
+ function isValidIsoDate(value: string): boolean {
54
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
55
+ if (!match) return false;
56
+ const y = Number(match[1]);
57
+ const m = Number(match[2]);
58
+ const d = Number(match[3]);
59
+ if (m < 1 || m > 12 || d < 1) return false;
60
+ // Arithmetic rule, mirroring compliance/src/metadata.ts (this package cannot
61
+ // import compliance — the dependency points the other way). The previous
62
+ // Date.UTC form mapped years 0-99 into the 1900s; the mapping happens to
63
+ // preserve leapness for every year except 0000, so this is a correctness-of-
64
+ // form consolidation rather than a live-bug fix.
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]!;
67
+ return d <= daysInMonth;
68
+ }
69
+
70
+ /** Parses a loss-run CSV into ClaimSnapshots plus per-row validation errors. */
71
+ export function parseLossRunCsv(text: string): LossRunParseResult {
72
+ const { rows: grid, rowLines, warnings: csvWarnings } = parseCsv(text);
73
+ const headers = (grid[0] ?? []).map(normalizeHeader);
74
+ const missing = REQUIRED_COLUMNS.filter((c) => !headers.includes(c));
75
+ if (missing.length > 0) {
76
+ throw new ReservingError(
77
+ "SHAPE",
78
+ `Missing required column(s): ${missing.join(", ")}. Found: ${
79
+ headers.filter(Boolean).join(", ") || "(none)"
80
+ }`,
81
+ );
82
+ }
83
+ const columnIndex = new Map<string, number>();
84
+ headers.forEach((h, idx) => {
85
+ if (!columnIndex.has(h)) columnIndex.set(h, idx);
86
+ });
87
+
88
+ const claims: ClaimSnapshot[] = [];
89
+ const errors: LossRunRowError[] = [];
90
+ for (const warning of csvWarnings) {
91
+ // Structural CSV problems surface through the same channel as row errors:
92
+ // partial loading is intended behavior here, silent partial loading is not.
93
+ const lineMatch = warning.match(/line (\d+)/);
94
+ errors.push({ row: lineMatch ? Number(lineMatch[1]) : 1, message: `CSV structure: ${warning}` });
95
+ }
96
+
97
+ for (let r = 1; r < grid.length; r++) {
98
+ // Physical 1-based line in the file where this row starts — NOT r + 1:
99
+ // blank lines and quoted embedded newlines make grid index and file line
100
+ // diverge, and the structural-warning path above already reports physical
101
+ // lines. One errors array, one numbering scheme.
102
+ const rowNumber = rowLines[r]!;
103
+ const cells = grid[r]!;
104
+ const cell = (name: string): string => (cells[columnIndex.get(name)!] ?? "").trim();
105
+ const rowErrors: string[] = [];
106
+
107
+ const claimId = cell("claim_id");
108
+ if (claimId === "") rowErrors.push("claim_id is required");
109
+
110
+ const date = (name: string): string | null => {
111
+ const value = cell(name);
112
+ if (!isValidIsoDate(value)) {
113
+ rowErrors.push(`${name} must be a real calendar date in yyyy-mm-dd form (got "${value}")`);
114
+ return null;
115
+ }
116
+ return value;
117
+ };
118
+ const accidentDate = date("accident_date");
119
+ const reportDate = date("report_date");
120
+ const evaluationDate = date("evaluation_date");
121
+
122
+ const amount = (name: string): number | null => {
123
+ const value = cell(name);
124
+ // Currency is decimal digits with an optional sign and fraction —
125
+ // Number() also accepts hex (0x2710 -> 10000), binary, octal and
126
+ // scientific notation, which silently changes magnitudes instead of
127
+ // erroring. Formatted amounts are rejected with a pointed message
128
+ // rather than guessed at: "1,234" could be one thousand or 1.234
129
+ // depending on locale, and a loss run is no place to guess.
130
+ if (/[,()]|\s/.test(value.trim()) && value.trim() !== "") {
131
+ rowErrors.push(
132
+ `${name} must be an unformatted decimal (got "${value}"); remove thousands separators, ` +
133
+ "parentheses and spaces",
134
+ );
135
+ return null;
136
+ }
137
+ const n = /^-?\d+(\.\d+)?$/.test(value.trim()) ? Number(value.trim()) : NaN;
138
+ if (!Number.isFinite(n)) {
139
+ rowErrors.push(`${name} must be a finite decimal number (got "${value}")`);
140
+ return null;
141
+ }
142
+ return n;
143
+ };
144
+ const paidToDate = amount("paid_to_date");
145
+ const caseReserve = amount("case_reserve");
146
+
147
+ const statusRaw = cell("status").toLowerCase();
148
+ const status: "open" | "closed" | null =
149
+ statusRaw === "open" || statusRaw === "closed" ? statusRaw : null;
150
+ if (status === null) {
151
+ rowErrors.push(`status must be "open" or "closed" (got "${cell("status")}")`);
152
+ }
153
+
154
+ // 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");
158
+ }
159
+
160
+ if (rowErrors.length > 0) {
161
+ for (const message of rowErrors) errors.push({ row: rowNumber, message });
162
+ continue;
163
+ }
164
+ claims.push({
165
+ claimId,
166
+ accidentDate: accidentDate!,
167
+ reportDate: reportDate!,
168
+ evaluationDate: evaluationDate!,
169
+ paidToDate: paidToDate!,
170
+ caseReserve: caseReserve!,
171
+ status: status!,
172
+ });
173
+ }
174
+
175
+ return { claims, errors };
176
+ }
package/src/review.ts ADDED
@@ -0,0 +1,425 @@
1
+ import type { ClaimSnapshot, Triangle } from "@actuarial-ts/core";
2
+
3
+ /**
4
+ * ASOP No. 23 (Data Quality)-oriented data review.
5
+ *
6
+ * The report lists every check PERFORMED, not just the ones that found
7
+ * something — the actuary's disclosure needs "what was reviewed" as much as
8
+ * "what was found". A check that could not be evaluated (no as-of date; a
9
+ * triangle shape mismatch blocking cell comparisons) stays in the report
10
+ * with an explicit "not evaluated" detail so the disclosure never overstates
11
+ * the review.
12
+ *
13
+ * Severity philosophy:
14
+ * - "fail" = the data is wrong or internally inconsistent (negative paid,
15
+ * dates out of order, duplicate snapshots, paid > incurred).
16
+ * - "warning" = legitimate but rare/reportable (negative case reserves,
17
+ * salvage-driven negative incremental paid, closed claims
18
+ * still carrying case).
19
+ *
20
+ * These utilities are designed to support the actuary's compliance with
21
+ * ASOP No. 23; responsibility for compliance remains with the credentialed
22
+ * actuary.
23
+ */
24
+
25
+ export type DataCheckStatus = "pass" | "warning" | "fail" | "not-evaluated";
26
+
27
+ export interface DataFindingContext {
28
+ origin?: string;
29
+ valuation?: string;
30
+ ageMonths?: number;
31
+ group?: string;
32
+ sourceFile?: string;
33
+ sourceRow?: number;
34
+ }
35
+
36
+ export interface DataFinding {
37
+ /** Stable machine-readable finding code, usually the containing check id. */
38
+ code: string;
39
+ message: string;
40
+ context?: DataFindingContext;
41
+ }
42
+
43
+ export interface DataCheck {
44
+ id: string;
45
+ description: string;
46
+ status: DataCheckStatus;
47
+ details: string[];
48
+ /** Additive structured detail for consumers that should not parse prose. */
49
+ findings?: DataFinding[];
50
+ }
51
+
52
+ export interface DataReviewReport {
53
+ checks: DataCheck[];
54
+ summary: { pass: number; warning: number; fail: number; notEvaluated: number };
55
+ }
56
+
57
+ export interface ReviewClaimDataOptions {
58
+ /** ISO date; when given, any claim date after it fails "future-dated". */
59
+ asOfDate?: string;
60
+ }
61
+
62
+ /** At most this many offending items are listed per check, then "+N more". */
63
+ const MAX_DETAILS = 20;
64
+
65
+ function capDetails(items: string[]): string[] {
66
+ if (items.length <= MAX_DETAILS) return items;
67
+ return [...items.slice(0, MAX_DETAILS), `+${items.length - MAX_DETAILS} more`];
68
+ }
69
+
70
+ function makeCheck(
71
+ id: string,
72
+ description: string,
73
+ statusWhenFound: "warning" | "fail",
74
+ findings: string[],
75
+ ): DataCheck {
76
+ return {
77
+ id,
78
+ description,
79
+ status: findings.length > 0 ? statusWhenFound : "pass",
80
+ details: capDetails(findings),
81
+ };
82
+ }
83
+
84
+ function notEvaluated(id: string, description: string, reason: string): DataCheck {
85
+ // A check that could not run is REPORTED as such - counting it as "pass"
86
+ // would overstate the review in the very disclosure this feeds.
87
+ return { id, description, status: "not-evaluated", details: [`not evaluated: ${reason}`] };
88
+ }
89
+
90
+ function summarize(checks: DataCheck[]): DataReviewReport {
91
+ const summary = { pass: 0, warning: 0, fail: 0, notEvaluated: 0 };
92
+ for (const c of checks) {
93
+ if (c.status === "not-evaluated") summary.notEvaluated++;
94
+ else summary[c.status]++;
95
+ }
96
+ return { checks, summary };
97
+ }
98
+
99
+ /** Builds a structured check while retaining the established status/details contract. */
100
+ export function createStructuredDataCheck(
101
+ id: string,
102
+ description: string,
103
+ statusWhenFound: "warning" | "fail",
104
+ findings: readonly DataFinding[],
105
+ ): DataCheck {
106
+ const capped = findings.length <= MAX_DETAILS ? [...findings] : [...findings.slice(0, MAX_DETAILS)];
107
+ const details = capped.map((finding) => finding.message);
108
+ if (findings.length > MAX_DETAILS) details.push(`+${findings.length - MAX_DETAILS} more`);
109
+ return {
110
+ id,
111
+ description,
112
+ status: findings.length > 0 ? statusWhenFound : "pass",
113
+ details,
114
+ findings: capped,
115
+ };
116
+ }
117
+
118
+ /** Public report summarizer for additive review suites. */
119
+ export function summarizeDataChecks(checks: DataCheck[]): DataReviewReport {
120
+ return summarize(checks);
121
+ }
122
+
123
+ /** Public not-evaluated constructor so additive suites preserve report semantics. */
124
+ export function createNotEvaluatedDataCheck(
125
+ id: string,
126
+ description: string,
127
+ reason: string,
128
+ ): DataCheck {
129
+ return notEvaluated(id, description, reason);
130
+ }
131
+
132
+ const CLAIM_DESCRIPTIONS = {
133
+ "non-finite-value": "Every money field is a finite number (no NaN or Infinity)",
134
+ "negative-paid": "Cumulative paid amounts are non-negative",
135
+ "negative-case": "Case reserves are non-negative (negative case is legitimate but rare)",
136
+ "paid-decreasing":
137
+ "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",
140
+ "future-dated": "No claim date exceeds the as-of date",
141
+ "closed-with-case": "Closed claims carry no outstanding case reserve",
142
+ } as const;
143
+
144
+ /** Reviews claim-level snapshots against the ASOP 23-oriented check suite. */
145
+ export function reviewClaimData(
146
+ claims: ClaimSnapshot[],
147
+ opts: ReviewClaimDataOptions = {},
148
+ ): DataReviewReport {
149
+ const negativePaid: string[] = [];
150
+ const negativeCase: string[] = [];
151
+ const dateOrder: string[] = [];
152
+ const futureDated: string[] = [];
153
+ const closedWithCase: string[] = [];
154
+
155
+ claims.forEach((c) => {
156
+ // Identified by claimId + evaluation date, which the DATA carries. The old
157
+ // label fabricated "row N" from this array's index — but this function
158
+ // receives parsed claims, not the file, and parseLossRunCsv numbers real
159
+ // physical file lines in its own errors. Two different "row" meanings
160
+ // pointed auditors at the wrong line; an identifier we cannot compute is
161
+ // one we must not print.
162
+ const where = `claim ${c.claimId} (eval ${c.evaluationDate})`;
163
+ if (c.paidToDate < 0) {
164
+ negativePaid.push(`${where}: paid_to_date ${c.paidToDate}`);
165
+ }
166
+ if (c.caseReserve < 0) {
167
+ negativeCase.push(`${where}: case_reserve ${c.caseReserve}`);
168
+ }
169
+ if (c.reportDate < c.accidentDate) {
170
+ dateOrder.push(
171
+ `${where}: report_date ${c.reportDate} precedes accident_date ${c.accidentDate}`,
172
+ );
173
+ }
174
+ if (c.evaluationDate < c.reportDate) {
175
+ dateOrder.push(
176
+ `${where}: evaluation_date ${c.evaluationDate} precedes report_date ${c.reportDate}`,
177
+ );
178
+ }
179
+ if (opts.asOfDate !== undefined) {
180
+ const asOf = opts.asOfDate;
181
+ if (c.accidentDate > asOf) {
182
+ futureDated.push(`${where}: accident_date ${c.accidentDate} exceeds as-of ${asOf}`);
183
+ }
184
+ if (c.reportDate > asOf) {
185
+ futureDated.push(`${where}: report_date ${c.reportDate} exceeds as-of ${asOf}`);
186
+ }
187
+ if (c.evaluationDate > asOf) {
188
+ futureDated.push(`${where}: evaluation_date ${c.evaluationDate} exceeds as-of ${asOf}`);
189
+ }
190
+ }
191
+ if (c.status === "closed" && c.caseReserve > 0) {
192
+ closedWithCase.push(`${where}: case_reserve ${c.caseReserve} on a closed claim`);
193
+ }
194
+ });
195
+
196
+ // Per-claim timeline checks: duplicates and decreasing cumulative paid.
197
+ const byClaim = new Map<string, ClaimSnapshot[]>();
198
+ for (const c of claims) {
199
+ const list = byClaim.get(c.claimId);
200
+ if (list) list.push(c);
201
+ else byClaim.set(c.claimId, [c]);
202
+ }
203
+ const paidDecreasing: string[] = [];
204
+ const duplicates: string[] = [];
205
+ for (const [claimId, snaps] of byClaim) {
206
+ const sorted = [...snaps].sort((a, b) => a.evaluationDate.localeCompare(b.evaluationDate));
207
+ const seenEvals = new Set<string>();
208
+ for (const s of sorted) {
209
+ if (seenEvals.has(s.evaluationDate)) {
210
+ duplicates.push(`claim ${claimId}: duplicate snapshot at ${s.evaluationDate}`);
211
+ }
212
+ seenEvals.add(s.evaluationDate);
213
+ }
214
+ for (let k = 1; k < sorted.length; k++) {
215
+ const prev = sorted[k - 1]!;
216
+ const cur = sorted[k]!;
217
+ // Same-date pairs are the duplicate check's finding, not this one's.
218
+ if (cur.evaluationDate === prev.evaluationDate) continue;
219
+ if (cur.paidToDate < prev.paidToDate) {
220
+ paidDecreasing.push(
221
+ `claim ${claimId}: paid_to_date ${prev.paidToDate} -> ${cur.paidToDate} between ${prev.evaluationDate} and ${cur.evaluationDate}`,
222
+ );
223
+ }
224
+ }
225
+ }
226
+
227
+ const nonFinite: string[] = [];
228
+ for (const c of claims) {
229
+ 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)}`);
232
+ }
233
+
234
+ const futureCheck =
235
+ opts.asOfDate === undefined
236
+ ? notEvaluated("future-dated", CLAIM_DESCRIPTIONS["future-dated"], "no asOfDate provided")
237
+ : makeCheck("future-dated", CLAIM_DESCRIPTIONS["future-dated"], "fail", futureDated);
238
+
239
+ return summarize([
240
+ // 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),
247
+ futureCheck,
248
+ makeCheck("closed-with-case", CLAIM_DESCRIPTIONS["closed-with-case"], "warning", closedWithCase),
249
+ ]);
250
+ }
251
+
252
+ 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)",
256
+ "negative-incremental-paid":
257
+ "Cumulative paid is non-decreasing along each origin row (salvage/subrogation can legitimately violate this)",
258
+ "negative-incremental-incurred":
259
+ "Cumulative incurred is non-decreasing along each origin row (case takedowns can legitimately violate this)",
260
+ "interior-missing": "No row has a missing cell between observed cells",
261
+ } as const;
262
+
263
+ /**
264
+ * Findings for NaN/Infinity in observed cells. This check exists because every
265
+ * OTHER check is a relational comparison, and every relational comparison is
266
+ * false for NaN — so without it, a triangle of NaN cells passes the entire
267
+ * review and renders into disclosure Section 3 as clean data. It runs FIRST:
268
+ * if the numbers are not numbers, the other verdicts are noise.
269
+ */
270
+ function nonFiniteTriangleFindings(tri: Triangle): string[] {
271
+ const out: string[] = [];
272
+ for (let i = 0; i < tri.values.length; i++) {
273
+ const row = tri.values[i]!;
274
+ for (let j = 0; j < row.length; j++) {
275
+ const v = row[j];
276
+ if (v === null || v === undefined) continue;
277
+ if (!Number.isFinite(v)) {
278
+ out.push(`${tri.kind} ${tri.origins[i]} age ${tri.ages[j]}: ${String(v)}`);
279
+ }
280
+ }
281
+ }
282
+ return out;
283
+ }
284
+
285
+ /** Findings where a row's cumulative values decrease between observed cells. */
286
+ function negativeIncrementalFindings(tri: Triangle): string[] {
287
+ const out: string[] = [];
288
+ for (let i = 0; i < tri.values.length; i++) {
289
+ const row = tri.values[i]!;
290
+ let prev: number | null = null;
291
+ let prevAge: number | null = null;
292
+ for (let j = 0; j < row.length; j++) {
293
+ const v = row[j];
294
+ if (v === null || v === undefined) continue;
295
+ const age = tri.ages[j]!;
296
+ if (prev !== null && v < prev) {
297
+ out.push(
298
+ `${tri.kind} ${tri.origins[i]} age ${prevAge} -> ${age}: ${prev} -> ${v}`,
299
+ );
300
+ }
301
+ prev = v;
302
+ prevAge = age;
303
+ }
304
+ }
305
+ return out;
306
+ }
307
+
308
+ /** Findings for null cells with observed cells both before and after in the row. */
309
+ function interiorMissingFindings(tri: Triangle): string[] {
310
+ const out: string[] = [];
311
+ for (let i = 0; i < tri.values.length; i++) {
312
+ const row = tri.values[i]!;
313
+ // undefined and null are both absences (a JSON round-trip can produce
314
+ // either); the negative-incremental walk above already treats them
315
+ // identically, and a gap must not change meaning between checks.
316
+ const observed = row.map((v) => v !== null && v !== undefined);
317
+ const first = observed.indexOf(true);
318
+ const last = observed.lastIndexOf(true);
319
+ if (first === -1) continue;
320
+ for (let j = first + 1; j < last; j++) {
321
+ if (!observed[j]) {
322
+ out.push(`${tri.kind} ${tri.origins[i]} age ${tri.ages[j]}: interior cell missing`);
323
+ }
324
+ }
325
+ }
326
+ return out;
327
+ }
328
+
329
+ /** Reviews a paid/incurred triangle pair for cross-triangle consistency. */
330
+ export function reviewTriangles(paid: Triangle, incurred: Triangle): DataReviewReport {
331
+ const shapeFindings: string[] = [];
332
+ const sameOrigins =
333
+ paid.origins.length === incurred.origins.length &&
334
+ paid.origins.every((o, i) => o === incurred.origins[i]);
335
+ const sameAges =
336
+ paid.ages.length === incurred.ages.length &&
337
+ paid.ages.every((a, j) => a === incurred.ages[j]);
338
+ if (!sameOrigins) {
339
+ shapeFindings.push(
340
+ `origins differ: paid [${paid.origins.join(", ")}] vs incurred [${incurred.origins.join(", ")}]`,
341
+ );
342
+ }
343
+ if (!sameAges) {
344
+ shapeFindings.push(
345
+ `ages differ: paid [${paid.ages.join(", ")}] vs incurred [${incurred.ages.join(", ")}]`,
346
+ );
347
+ }
348
+ const nonFiniteCheck = makeCheck(
349
+ "non-finite-value",
350
+ TRIANGLE_DESCRIPTIONS["non-finite-value"],
351
+ "fail",
352
+ [...nonFiniteTriangleFindings(paid), ...nonFiniteTriangleFindings(incurred)],
353
+ );
354
+ const shapeCheck = makeCheck(
355
+ "shape-mismatch",
356
+ TRIANGLE_DESCRIPTIONS["shape-mismatch"],
357
+ "fail",
358
+ shapeFindings,
359
+ );
360
+ if (shapeFindings.length > 0) {
361
+ // Cell-level comparisons are meaningless across mismatched grids; the
362
+ // remaining checks stay listed (disclosure) but are not evaluated.
363
+ const reason = "triangle shapes differ";
364
+ return summarize([
365
+ nonFiniteCheck,
366
+ shapeCheck,
367
+ notEvaluated("paid-exceeds-incurred", TRIANGLE_DESCRIPTIONS["paid-exceeds-incurred"], reason),
368
+ notEvaluated(
369
+ "negative-incremental-paid",
370
+ TRIANGLE_DESCRIPTIONS["negative-incremental-paid"],
371
+ reason,
372
+ ),
373
+ notEvaluated(
374
+ "negative-incremental-incurred",
375
+ TRIANGLE_DESCRIPTIONS["negative-incremental-incurred"],
376
+ reason,
377
+ ),
378
+ notEvaluated("interior-missing", TRIANGLE_DESCRIPTIONS["interior-missing"], reason),
379
+ ]);
380
+ }
381
+
382
+ const paidExceeds: string[] = [];
383
+ for (let i = 0; i < paid.values.length; i++) {
384
+ const paidRow = paid.values[i]!;
385
+ const incRow = incurred.values[i]!;
386
+ for (let j = 0; j < paidRow.length; j++) {
387
+ const p = paidRow[j];
388
+ const inc = incRow[j];
389
+ if (p === null || p === undefined || inc === null || inc === undefined) continue;
390
+ const tolerance = 1e-9 * Math.max(1, Math.abs(p), Math.abs(inc));
391
+ if (p - inc > tolerance) {
392
+ paidExceeds.push(`${paid.origins[i]} age ${paid.ages[j]}: paid ${p} > incurred ${inc}`);
393
+ }
394
+ }
395
+ }
396
+
397
+ return summarize([
398
+ nonFiniteCheck,
399
+ shapeCheck,
400
+ makeCheck(
401
+ "paid-exceeds-incurred",
402
+ TRIANGLE_DESCRIPTIONS["paid-exceeds-incurred"],
403
+ "fail",
404
+ paidExceeds,
405
+ ),
406
+ makeCheck(
407
+ "negative-incremental-paid",
408
+ TRIANGLE_DESCRIPTIONS["negative-incremental-paid"],
409
+ "warning",
410
+ negativeIncrementalFindings(paid),
411
+ ),
412
+ makeCheck(
413
+ "negative-incremental-incurred",
414
+ TRIANGLE_DESCRIPTIONS["negative-incremental-incurred"],
415
+ "warning",
416
+ negativeIncrementalFindings(incurred),
417
+ ),
418
+ makeCheck(
419
+ "interior-missing",
420
+ TRIANGLE_DESCRIPTIONS["interior-missing"],
421
+ "warning",
422
+ [...interiorMissingFindings(paid), ...interiorMissingFindings(incurred)],
423
+ ),
424
+ ]);
425
+ }