@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
@@ -0,0 +1,278 @@
1
+ import type { ClaimSnapshot } from "@actuarial-ts/core";
2
+ import { ReservingError } from "@actuarial-ts/core";
3
+ import { z } from "zod";
4
+
5
+ /**
6
+ * A claim valuation known only to annual precision.
7
+ *
8
+ * `incurredToDate` is deliberately named for the interpretation already made
9
+ * by the caller. Source-specific adapters must document how their fields were
10
+ * mapped to paid and incurred before calling this function.
11
+ */
12
+ export const annualClaimDevelopmentRowSchema = z
13
+ .object({
14
+ claimId: z.string().trim().min(1),
15
+ originYear: z.number().int().min(1).max(9999),
16
+ reportYear: z.number().int().min(1).max(9999).optional(),
17
+ evaluationYear: z.number().int().min(1).max(9999),
18
+ paidToDate: z.number().finite(),
19
+ incurredToDate: z.number().finite(),
20
+ status: z.enum(["open", "closed"]),
21
+ })
22
+ .strict()
23
+ .superRefine((row, ctx) => {
24
+ if (row.evaluationYear < row.originYear) {
25
+ ctx.addIssue({
26
+ code: z.ZodIssueCode.custom,
27
+ path: ["evaluationYear"],
28
+ message: "evaluationYear must not precede originYear",
29
+ });
30
+ }
31
+ if (row.reportYear !== undefined && row.reportYear < row.originYear) {
32
+ ctx.addIssue({
33
+ code: z.ZodIssueCode.custom,
34
+ path: ["reportYear"],
35
+ message: "reportYear must not precede originYear",
36
+ });
37
+ }
38
+ if (row.reportYear !== undefined && row.evaluationYear < row.reportYear) {
39
+ ctx.addIssue({
40
+ code: z.ZodIssueCode.custom,
41
+ path: ["evaluationYear"],
42
+ message: "evaluationYear must not precede reportYear",
43
+ });
44
+ }
45
+ });
46
+
47
+ export type AnnualClaimDevelopmentRow = z.infer<typeof annualClaimDevelopmentRowSchema>;
48
+
49
+ export type AnnualDuplicatePolicy = "reject" | "combine";
50
+
51
+ export interface AnnualDevelopmentOptions {
52
+ /**
53
+ * Same-claim, same-year records are ambiguous. The safe default rejects
54
+ * them. `combine` treats them as components of one dollar stream: paid and
55
+ * incurred are summed and the combined record is open when any component is
56
+ * open. The returned finding makes that judgment visible.
57
+ */
58
+ duplicatePolicy?: AnnualDuplicatePolicy;
59
+ }
60
+
61
+ const annualDevelopmentOptionsSchema = z
62
+ .object({ duplicatePolicy: z.enum(["reject", "combine"]).default("reject") })
63
+ .strict();
64
+
65
+ export type AnnualDevelopmentFindingCode =
66
+ | "annual-dates-derived"
67
+ | "report-year-inferred"
68
+ | "duplicate-snapshots-combined";
69
+
70
+ export interface AnnualDevelopmentFinding {
71
+ code: AnnualDevelopmentFindingCode;
72
+ severity: "disclosure" | "warning";
73
+ message: string;
74
+ affectedClaims?: number;
75
+ affectedGroups?: number;
76
+ affectedRows?: number;
77
+ }
78
+
79
+ export interface AnnualDevelopmentConventions {
80
+ sourceDatePrecision: "year";
81
+ derivedDateConvention: "calendar-year-end";
82
+ missingReportYearConvention: "earliest-evaluation-year";
83
+ duplicatePolicy: AnnualDuplicatePolicy;
84
+ }
85
+
86
+ export interface AnnualDevelopmentConversion {
87
+ claims: ClaimSnapshot[];
88
+ findings: AnnualDevelopmentFinding[];
89
+ conventions: AnnualDevelopmentConventions;
90
+ sourceRowCount: number;
91
+ }
92
+
93
+ interface ClaimGroup {
94
+ originYear: number;
95
+ reportYears: Set<number>;
96
+ rows: AnnualClaimDevelopmentRow[];
97
+ }
98
+
99
+ function yearEnd(year: number): string {
100
+ return `${String(year).padStart(4, "0")}-12-31`;
101
+ }
102
+
103
+ function issueMessage(error: z.ZodError): string {
104
+ return error.issues
105
+ .map((issue) => {
106
+ const path = issue.path.length > 0 ? `${issue.path.join(".")}: ` : "";
107
+ return `${path}${issue.message}`;
108
+ })
109
+ .join("; ");
110
+ }
111
+
112
+ /**
113
+ * Converts year-precision claim valuations into the exact-date
114
+ * `ClaimSnapshot` contract without pretending the source supplied exact
115
+ * dates. Every derived date uses calendar year-end, and the convention is
116
+ * returned alongside the claims for disclosure.
117
+ *
118
+ * Missing report years are inferred as the earliest evaluation year for the
119
+ * claim. Duplicate claim/evaluation-year rows are rejected unless the caller
120
+ * explicitly selects the auditable `combine` policy.
121
+ */
122
+ export function annualDevelopmentToClaimSnapshots(
123
+ input: readonly AnnualClaimDevelopmentRow[],
124
+ options: AnnualDevelopmentOptions = {},
125
+ ): AnnualDevelopmentConversion {
126
+ const parsedOptions = annualDevelopmentOptionsSchema.safeParse(options);
127
+ if (!parsedOptions.success) {
128
+ throw new ReservingError(
129
+ "UNSUPPORTED_VALUE",
130
+ `Invalid annual development options: ${issueMessage(parsedOptions.error)}`,
131
+ );
132
+ }
133
+ const { duplicatePolicy } = parsedOptions.data;
134
+
135
+ const rows: AnnualClaimDevelopmentRow[] = input.map((candidate, inputIndex) => {
136
+ const parsed = annualClaimDevelopmentRowSchema.safeParse(candidate);
137
+ if (!parsed.success) {
138
+ throw new ReservingError(
139
+ "SHAPE",
140
+ `Annual claim development row ${inputIndex + 1}: ${issueMessage(parsed.error)}`,
141
+ );
142
+ }
143
+ return parsed.data;
144
+ });
145
+
146
+ const byClaim = new Map<string, ClaimGroup>();
147
+ for (const row of rows) {
148
+ const existing = byClaim.get(row.claimId);
149
+ if (existing === undefined) {
150
+ byClaim.set(row.claimId, {
151
+ originYear: row.originYear,
152
+ reportYears: new Set(row.reportYear === undefined ? [] : [row.reportYear]),
153
+ rows: [row],
154
+ });
155
+ continue;
156
+ }
157
+ if (existing.originYear !== row.originYear) {
158
+ throw new ReservingError(
159
+ "SHAPE",
160
+ `Claim ${row.claimId} has conflicting origin years ${existing.originYear} and ${row.originYear}`,
161
+ );
162
+ }
163
+ if (row.reportYear !== undefined) existing.reportYears.add(row.reportYear);
164
+ existing.rows.push(row);
165
+ }
166
+
167
+ const findings: AnnualDevelopmentFinding[] = [
168
+ {
169
+ code: "annual-dates-derived",
170
+ severity: "disclosure",
171
+ affectedRows: rows.length,
172
+ message:
173
+ "The source supplies calendar years, not exact dates; accident, report and evaluation dates were derived at calendar year-end.",
174
+ },
175
+ ];
176
+ const claims: ClaimSnapshot[] = [];
177
+ let inferredReportYears = 0;
178
+ let duplicateGroups = 0;
179
+ let duplicateRows = 0;
180
+
181
+ for (const [claimId, group] of byClaim) {
182
+ if (group.reportYears.size > 1) {
183
+ throw new ReservingError(
184
+ "SHAPE",
185
+ `Claim ${claimId} has conflicting report years ${[...group.reportYears].sort((a, b) => a - b).join(", ")}`,
186
+ );
187
+ }
188
+ const firstEvaluationYear = group.rows.reduce(
189
+ (earliest, row) => Math.min(earliest, row.evaluationYear),
190
+ group.rows[0]!.evaluationYear,
191
+ );
192
+ const suppliedReportYear = group.reportYears.values().next().value as number | undefined;
193
+ const reportYear = suppliedReportYear ?? firstEvaluationYear;
194
+ if (suppliedReportYear === undefined) inferredReportYears += 1;
195
+ if (reportYear > firstEvaluationYear) {
196
+ throw new ReservingError(
197
+ "SHAPE",
198
+ `Claim ${claimId} has report year ${reportYear} after its first evaluation year ${firstEvaluationYear}`,
199
+ );
200
+ }
201
+
202
+ const byEvaluationYear = new Map<number, AnnualClaimDevelopmentRow[]>();
203
+ for (const row of group.rows) {
204
+ const sameYear = byEvaluationYear.get(row.evaluationYear);
205
+ if (sameYear === undefined) byEvaluationYear.set(row.evaluationYear, [row]);
206
+ else sameYear.push(row);
207
+ }
208
+
209
+ for (const evaluationYear of [...byEvaluationYear.keys()].sort((a, b) => a - b)) {
210
+ const sameYear = byEvaluationYear.get(evaluationYear)!;
211
+ if (sameYear.length > 1) {
212
+ duplicateGroups += 1;
213
+ duplicateRows += sameYear.length;
214
+ if (duplicatePolicy === "reject") {
215
+ throw new ReservingError(
216
+ "SHAPE",
217
+ `Claim ${claimId} has ${sameYear.length} snapshots in evaluation year ${evaluationYear}; choose duplicatePolicy "combine" only after reviewing the source ambiguity`,
218
+ );
219
+ }
220
+ }
221
+
222
+ const paidToDate = sameYear.reduce((sum, row) => sum + row.paidToDate, 0);
223
+ const incurredToDate = sameYear.reduce((sum, row) => sum + row.incurredToDate, 0);
224
+ const caseReserve = incurredToDate - paidToDate;
225
+ if (
226
+ !Number.isFinite(paidToDate) ||
227
+ !Number.isFinite(incurredToDate) ||
228
+ !Number.isFinite(caseReserve)
229
+ ) {
230
+ throw new ReservingError(
231
+ "SHAPE",
232
+ `Claim ${claimId} evaluation year ${evaluationYear}: combined paid, incurred, and case reserve must remain finite`,
233
+ );
234
+ }
235
+ claims.push({
236
+ claimId,
237
+ accidentDate: yearEnd(group.originYear),
238
+ reportDate: yearEnd(reportYear),
239
+ evaluationDate: yearEnd(evaluationYear),
240
+ paidToDate,
241
+ caseReserve,
242
+ status: sameYear.some((row) => row.status === "open") ? "open" : "closed",
243
+ });
244
+ }
245
+ }
246
+
247
+ if (inferredReportYears > 0) {
248
+ findings.push({
249
+ code: "report-year-inferred",
250
+ severity: "disclosure",
251
+ affectedClaims: inferredReportYears,
252
+ message:
253
+ "Report year was absent and was inferred as each claim's earliest evaluation year.",
254
+ });
255
+ }
256
+ if (duplicateGroups > 0) {
257
+ findings.push({
258
+ code: "duplicate-snapshots-combined",
259
+ severity: "warning",
260
+ affectedGroups: duplicateGroups,
261
+ affectedRows: duplicateRows,
262
+ message:
263
+ "Same-claim, same-year rows were combined by summing paid and incurred; the combined status is open when any component is open.",
264
+ });
265
+ }
266
+
267
+ return {
268
+ claims,
269
+ findings,
270
+ conventions: {
271
+ sourceDatePrecision: "year",
272
+ derivedDateConvention: "calendar-year-end",
273
+ missingReportYearConvention: "earliest-evaluation-year",
274
+ duplicatePolicy,
275
+ },
276
+ sourceRowCount: rows.length,
277
+ };
278
+ }
package/src/csv.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Minimal RFC 4180-subset CSV parser.
3
+ *
4
+ * Supported: comma delimiter, quoted fields, doubled quotes ("") as escaped
5
+ * quotes inside quoted fields, commas and newlines inside quoted fields,
6
+ * CRLF and LF row endings, a leading UTF-8 BOM, and a tolerated trailing
7
+ * newline. Lines that are completely empty outside quotes are skipped —
8
+ * skipped lines still advance the physical line count reported in
9
+ * `rowLines`, so later rows' reported start lines stay correct.
10
+ *
11
+ * Deliberately NOT here: header handling, type coercion, and shape
12
+ * validation. Ragged rows are preserved as-is — validation is the caller's
13
+ * job (see lossRun.ts).
14
+ */
15
+
16
+ export interface CsvParseResult {
17
+ /** The parsed grid (rows x columns). Ragged rows preserved as-is. */
18
+ rows: string[][];
19
+ /**
20
+ * Physical 1-based line number in the input where each row of `rows`
21
+ * STARTS. Parallel to `rows`. Differs from index+1 whenever blank lines
22
+ * were skipped or a quoted field contains newlines — callers reporting
23
+ * row-level problems must use this, not the grid index, or their
24
+ * diagnostics point at the wrong line of the file.
25
+ */
26
+ rowLines: number[];
27
+ /**
28
+ * Structural problems the parser recovered from. Content is still parsed
29
+ * leniently — a warning here means the OUTPUT may not mean what the file's
30
+ * author intended, which the caller must surface rather than swallow: an
31
+ * unterminated quote consumes the remainder of the input into one field,
32
+ * and five good rows silently becoming one is how claims vanish.
33
+ */
34
+ warnings: string[];
35
+ }
36
+
37
+ /** Parses CSV text into a grid of string fields plus structural warnings. */
38
+ export function parseCsv(text: string): CsvParseResult {
39
+ let s = text;
40
+ if (s.length > 0 && s.charCodeAt(0) === 0xfeff) s = s.slice(1);
41
+
42
+ const rows: string[][] = [];
43
+ const rowLines: number[] = [];
44
+ const warnings: string[] = [];
45
+ let row: string[] = [];
46
+ let field = "";
47
+ let inQuotes = false;
48
+ let fieldWasQuoted = false;
49
+ let line = 1;
50
+ let rowStartLine = 1;
51
+ let quoteOpenedAtLine = 0;
52
+
53
+ const endField = (): void => {
54
+ row.push(field);
55
+ field = "";
56
+ fieldWasQuoted = false;
57
+ };
58
+
59
+ const endRow = (): void => {
60
+ // A line with zero characters outside quotes is skipped; a line like
61
+ // `""` or `,` still produces a row.
62
+ if (row.length === 0 && field === "" && !fieldWasQuoted) return;
63
+ endField();
64
+ rows.push(row);
65
+ rowLines.push(rowStartLine);
66
+ row = [];
67
+ };
68
+
69
+ let i = 0;
70
+ while (i < s.length) {
71
+ const ch = s[i]!;
72
+ if (inQuotes) {
73
+ if (ch === '"') {
74
+ if (s[i + 1] === '"') {
75
+ field += '"';
76
+ i += 2;
77
+ continue;
78
+ }
79
+ inQuotes = false;
80
+ i++;
81
+ continue;
82
+ }
83
+ if (ch === "\n") line++;
84
+ field += ch;
85
+ i++;
86
+ continue;
87
+ }
88
+ if (ch === '"' && field === "" && !fieldWasQuoted) {
89
+ inQuotes = true;
90
+ quoteOpenedAtLine = line;
91
+ fieldWasQuoted = true;
92
+ i++;
93
+ continue;
94
+ }
95
+ if (ch === ",") {
96
+ endField();
97
+ i++;
98
+ continue;
99
+ }
100
+ if (ch === "\r") {
101
+ if (s[i + 1] === "\n") i++;
102
+ endRow();
103
+ line++;
104
+ rowStartLine = line;
105
+ i++;
106
+ continue;
107
+ }
108
+ if (ch === "\n") {
109
+ endRow();
110
+ line++;
111
+ rowStartLine = line;
112
+ i++;
113
+ continue;
114
+ }
115
+ field += ch;
116
+ i++;
117
+ }
118
+ // Flush a final row that has no trailing newline. An unterminated quoted
119
+ // field still flushes with whatever it accumulated — content stays lenient —
120
+ // but the structural problem is REPORTED: everything after the stray quote
121
+ // was consumed into one field, and the caller must not present that output
122
+ // as a faithful read of the file.
123
+ if (inQuotes) {
124
+ warnings.push(
125
+ `unterminated quoted field starting at line ${quoteOpenedAtLine}: the remainder of the ` +
126
+ "input was consumed into a single field; check the file for a stray or unescaped quote",
127
+ );
128
+ }
129
+ endRow();
130
+ return { rows, rowLines, warnings };
131
+ }
@@ -0,0 +1,79 @@
1
+ import {
2
+ ReservingError,
3
+ reconcileDiagnosticExposureKeys,
4
+ runMetricDiagnostics,
5
+ type DiagnosticExposureRow,
6
+ type DiagnosticLossRow,
7
+ type MetricDiagnosticsResult,
8
+ type ReconciledDiagnosticExposures,
9
+ type RunMetricDiagnosticsInput,
10
+ } from "@actuarial-ts/core";
11
+ import { z } from "zod";
12
+
13
+ const measuresSchema = z.record(z.number().nullable());
14
+
15
+ const diagnosticLossRowSchema = z.object({
16
+ id: z.string().min(1),
17
+ group: z.string().min(1),
18
+ origin: z.string().min(1),
19
+ valuation: z.string().min(1),
20
+ ageMonths: z.number(),
21
+ policyPeriod: z.string().min(1).optional(),
22
+ dimensions: z.unknown().optional(),
23
+ measures: measuresSchema,
24
+ }).strict();
25
+
26
+ const diagnosticExposureRowSchema = z.object({
27
+ key: z.string().min(1),
28
+ group: z.string().min(1),
29
+ origin: z.string().min(1),
30
+ valuation: z.string().min(1).optional(),
31
+ measures: measuresSchema,
32
+ complete: z.boolean().optional(),
33
+ dimensions: z.unknown().optional(),
34
+ }).strict();
35
+
36
+ const diagnosticDatasetSchema = z.object({
37
+ losses: z.array(diagnosticLossRowSchema),
38
+ exposures: z.array(diagnosticExposureRowSchema).optional(),
39
+ }).strict();
40
+
41
+ export interface ValidatedDiagnosticDataset {
42
+ losses: DiagnosticLossRow[];
43
+ /** Omitted when the caller did not supply exposure data. */
44
+ exposures?: DiagnosticExposureRow[];
45
+ }
46
+
47
+ /** Zod-validates unknown diagnostic rows at the data package boundary. */
48
+ export function validateDiagnosticDataset(value: unknown): ValidatedDiagnosticDataset {
49
+ const parsed = diagnosticDatasetSchema.safeParse(value);
50
+ if (!parsed.success) {
51
+ const details = parsed.error.issues
52
+ .map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "$"}: ${issue.message}`)
53
+ .join("; ");
54
+ throw new ReservingError("SHAPE", `Invalid diagnostic dataset: ${details}`);
55
+ }
56
+ return {
57
+ losses: parsed.data.losses,
58
+ ...(parsed.data.exposures !== undefined ? { exposures: parsed.data.exposures } : {}),
59
+ };
60
+ }
61
+
62
+ /** Validates unknown exposure rows, then applies core's stable-key reconciliation. */
63
+ export function validateAndReconcileDiagnosticExposures(
64
+ value: unknown,
65
+ ): ReconciledDiagnosticExposures {
66
+ const validated = validateDiagnosticDataset({ losses: [], exposures: value });
67
+ return reconcileDiagnosticExposureKeys(validated.exposures ?? []);
68
+ }
69
+
70
+ export type ValidatedMetricDiagnosticsOptions = Omit<RunMetricDiagnosticsInput, "losses" | "exposures">;
71
+
72
+ /** Convenience boundary: validate unknown rows, then run the dependency-free core engine. */
73
+ export function runValidatedMetricDiagnostics(
74
+ dataset: unknown,
75
+ options: ValidatedMetricDiagnosticsOptions,
76
+ ): MetricDiagnosticsResult {
77
+ const validated = validateDiagnosticDataset(dataset);
78
+ return runMetricDiagnostics({ ...options, ...validated });
79
+ }