@actuarial-ts/interchange 0.2.0 → 0.3.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/README.md +28 -1
- package/dist/convert/result.d.ts +4 -4
- package/dist/convert/result.js +3 -3
- package/dist/convert/result.js.map +1 -1
- package/dist/envelope.d.ts +1 -1
- package/dist/envelope.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +49 -0
- package/dist/parse.js.map +1 -1
- package/dist/referee/crosscheck.d.ts +12 -0
- package/dist/referee/crosscheck.d.ts.map +1 -1
- package/dist/referee/crosscheck.js +109 -9
- package/dist/referee/crosscheck.js.map +1 -1
- package/dist/referee/crosscheckStochastic.d.ts +24 -0
- package/dist/referee/crosscheckStochastic.d.ts.map +1 -0
- package/dist/referee/crosscheckStochastic.js +367 -0
- package/dist/referee/crosscheckStochastic.js.map +1 -0
- package/dist/schemas/result.d.ts +552 -0
- package/dist/schemas/result.d.ts.map +1 -1
- package/dist/schemas/result.js +52 -0
- package/dist/schemas/result.js.map +1 -1
- package/package.json +4 -2
- package/src/convert/result.ts +212 -0
- package/src/convert/selection.ts +565 -0
- package/src/convert/triangle.ts +208 -0
- package/src/envelope.ts +193 -0
- package/src/index.ts +15 -0
- package/src/parse.ts +175 -0
- package/src/referee/crosscheck.ts +459 -0
- package/src/referee/crosscheckStochastic.ts +488 -0
- package/src/referee/profiles.ts +113 -0
- package/src/schemas/bundle.ts +64 -0
- package/src/schemas/crosscheck.ts +84 -0
- package/src/schemas/manifest.ts +75 -0
- package/src/schemas/result.ts +180 -0
- package/src/schemas/selection.ts +175 -0
- package/src/schemas/study.ts +98 -0
- package/src/schemas/triangle.ts +138 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ReservingError,
|
|
3
|
+
type Triangle,
|
|
4
|
+
type TriangleKind,
|
|
5
|
+
incrementalToCumulative,
|
|
6
|
+
triangleFromGrid,
|
|
7
|
+
} from "@actuarial-ts/core";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_GENERATOR,
|
|
10
|
+
INTERCHANGE_SPEC_VERSION,
|
|
11
|
+
type GeneratorStamp,
|
|
12
|
+
stampIntegrity,
|
|
13
|
+
} from "../envelope.js";
|
|
14
|
+
import {
|
|
15
|
+
CORE_MEASURES,
|
|
16
|
+
type CoreMeasure,
|
|
17
|
+
type Measure,
|
|
18
|
+
type OriginLengthMonths,
|
|
19
|
+
type TriangleBody,
|
|
20
|
+
type TriangleDoc,
|
|
21
|
+
triangleDocSchema,
|
|
22
|
+
} from "../schemas/triangle.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* core `Triangle` ↔ TriangleDoc.
|
|
26
|
+
*
|
|
27
|
+
* - The seven core TriangleKind values are the interchange measures of the
|
|
28
|
+
* same name, both directions.
|
|
29
|
+
* - Cadence: annual ("2023") and quarterly ("2023Q3") origin labels derive
|
|
30
|
+
* starts and originLengthMonths automatically; anything else needs
|
|
31
|
+
* explicit `originStarts` + `originLengthMonths`. Reading 1- or 6-month
|
|
32
|
+
* cadences parses with a capability warning (spec 3.2).
|
|
33
|
+
* - `createdAt` and `valuationDate` are caller-supplied — this module
|
|
34
|
+
* never reads a clock (purity rule).
|
|
35
|
+
* - Null means unobserved, both directions, always.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export interface TriangleToDocOptions {
|
|
39
|
+
/** ISO timestamp for the envelope (caller-supplied; purity rule). */
|
|
40
|
+
createdAt: string;
|
|
41
|
+
/** ISO yyyy-mm-dd valuation date of the triangle. */
|
|
42
|
+
valuationDate: string;
|
|
43
|
+
/** Interchange measure; defaults to the core kind, which maps verbatim. */
|
|
44
|
+
measure?: Measure;
|
|
45
|
+
/** Whether values are cumulative. Core triangles are; defaults true. */
|
|
46
|
+
cumulative?: boolean;
|
|
47
|
+
/** Override when labels are not annual/quarterly (e.g. semiannual). */
|
|
48
|
+
originLengthMonths?: OriginLengthMonths;
|
|
49
|
+
/** Explicit origin period start dates; required when labels do not parse. */
|
|
50
|
+
originStarts?: string[];
|
|
51
|
+
basis?: TriangleBody["basis"];
|
|
52
|
+
units?: TriangleBody["units"];
|
|
53
|
+
segment?: TriangleBody["segment"];
|
|
54
|
+
extensions?: Record<string, unknown>;
|
|
55
|
+
generator?: GeneratorStamp;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface DerivedOrigin {
|
|
59
|
+
start: string;
|
|
60
|
+
lengthMonths: 12 | 3;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function deriveOrigin(label: string): DerivedOrigin | null {
|
|
64
|
+
const annual = /^(\d{4})$/.exec(label);
|
|
65
|
+
if (annual) return { start: `${annual[1]}-01-01`, lengthMonths: 12 };
|
|
66
|
+
const quarterly = /^(\d{4})[Qq]([1-4])$/.exec(label);
|
|
67
|
+
if (quarterly) {
|
|
68
|
+
const month = (Number(quarterly[2]) - 1) * 3 + 1;
|
|
69
|
+
return {
|
|
70
|
+
start: `${quarterly[1]}-${String(month).padStart(2, "0")}-01`,
|
|
71
|
+
lengthMonths: 3,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function triangleToDoc(tri: Triangle, options: TriangleToDocOptions): TriangleDoc {
|
|
78
|
+
let starts = options.originStarts;
|
|
79
|
+
let lengthMonths = options.originLengthMonths;
|
|
80
|
+
if (starts === undefined || lengthMonths === undefined) {
|
|
81
|
+
const derived = tri.origins.map(deriveOrigin);
|
|
82
|
+
const missing = derived.findIndex((d) => d === null);
|
|
83
|
+
if (missing >= 0) {
|
|
84
|
+
throw new ReservingError(
|
|
85
|
+
"BAD_INTERCHANGE",
|
|
86
|
+
`Origin label "${tri.origins[missing]}" is neither annual ("2023") nor quarterly ` +
|
|
87
|
+
`("2023Q3"); supply originStarts and originLengthMonths explicitly`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const lengths = new Set(derived.map((d) => d!.lengthMonths));
|
|
91
|
+
if (lengths.size > 1) {
|
|
92
|
+
throw new ReservingError(
|
|
93
|
+
"BAD_INTERCHANGE",
|
|
94
|
+
"Origin labels mix annual and quarterly forms; supply originStarts and originLengthMonths explicitly",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
starts ??= derived.map((d) => d!.start);
|
|
98
|
+
lengthMonths ??= derived[0]!.lengthMonths;
|
|
99
|
+
}
|
|
100
|
+
if (starts.length !== tri.origins.length) {
|
|
101
|
+
throw new ReservingError(
|
|
102
|
+
"BAD_INTERCHANGE",
|
|
103
|
+
`originStarts has ${starts.length} entries but the triangle has ${tri.origins.length} origins`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const body: TriangleBody = {
|
|
108
|
+
measure: options.measure ?? tri.kind,
|
|
109
|
+
cumulative: options.cumulative ?? true,
|
|
110
|
+
originLengthMonths: lengthMonths,
|
|
111
|
+
origins: tri.origins.map((label, i) => ({ label, start: starts![i]! })),
|
|
112
|
+
agesMonths: [...tri.ages],
|
|
113
|
+
values: tri.values.map((row) => [...row]),
|
|
114
|
+
valuationDate: options.valuationDate,
|
|
115
|
+
};
|
|
116
|
+
if (options.basis !== undefined) body.basis = options.basis;
|
|
117
|
+
if (options.units !== undefined) body.units = options.units;
|
|
118
|
+
if (options.segment !== undefined) body.segment = options.segment;
|
|
119
|
+
|
|
120
|
+
const doc = stampIntegrity<TriangleDoc>({
|
|
121
|
+
interchangeVersion: INTERCHANGE_SPEC_VERSION,
|
|
122
|
+
kind: "triangle",
|
|
123
|
+
generator: options.generator ?? DEFAULT_GENERATOR,
|
|
124
|
+
createdAt: options.createdAt,
|
|
125
|
+
extensions: options.extensions ?? {},
|
|
126
|
+
triangle: body,
|
|
127
|
+
});
|
|
128
|
+
const checked = triangleDocSchema.safeParse(doc);
|
|
129
|
+
if (!checked.success) {
|
|
130
|
+
throw new ReservingError(
|
|
131
|
+
"BAD_INTERCHANGE",
|
|
132
|
+
`triangleToDoc produced an invalid document: ${checked.error.issues
|
|
133
|
+
.map((i) => i.message)
|
|
134
|
+
.join("; ")}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return checked.data;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface DocToTriangleResult {
|
|
141
|
+
triangle: Triangle;
|
|
142
|
+
warnings: string[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function measureToKind(measure: Measure): TriangleKind | null {
|
|
146
|
+
return (CORE_MEASURES as readonly string[]).includes(measure)
|
|
147
|
+
? (measure as CoreMeasure)
|
|
148
|
+
: null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* TriangleDoc → core `Triangle`. Validates the document SCHEMA (integrity
|
|
153
|
+
* verification is parseDocument's job — pass documents through it first
|
|
154
|
+
* when provenance matters), maps the measure to a core kind, cumulates incremental
|
|
155
|
+
* values through core's `incrementalToCumulative` (with a warning), and
|
|
156
|
+
* channels reader-capability warnings for 1-/6-month cadences.
|
|
157
|
+
*/
|
|
158
|
+
export function docToTriangle(doc: unknown): DocToTriangleResult {
|
|
159
|
+
const parsed = triangleDocSchema.safeParse(doc);
|
|
160
|
+
if (!parsed.success) {
|
|
161
|
+
throw new ReservingError(
|
|
162
|
+
"BAD_INTERCHANGE",
|
|
163
|
+
`Not a valid TriangleDoc: ${parsed.error.issues
|
|
164
|
+
.map((i) => `${i.path.join(".") || "$"}: ${i.message}`)
|
|
165
|
+
.join("; ")}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
const triangleDoc = parsed.data;
|
|
169
|
+
const body = triangleDoc.triangle;
|
|
170
|
+
|
|
171
|
+
const kind = measureToKind(body.measure);
|
|
172
|
+
if (kind === null) {
|
|
173
|
+
throw new ReservingError(
|
|
174
|
+
"BAD_INTERCHANGE",
|
|
175
|
+
`Measure "${body.measure}" has no core triangle kind (core kinds: ${CORE_MEASURES.join(", ")}); ` +
|
|
176
|
+
"premium and custom measures are handled as exposure/reference data, not core triangles",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (body.values === undefined) {
|
|
180
|
+
throw new ReservingError(
|
|
181
|
+
"BAD_INTERCHANGE",
|
|
182
|
+
"This triangle carries only a bulk-lane valuesRef; Phase A converters require inline values",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const warnings: string[] = [];
|
|
187
|
+
if (body.originLengthMonths === 1 || body.originLengthMonths === 6) {
|
|
188
|
+
warnings.push(
|
|
189
|
+
`originLengthMonths ${body.originLengthMonths} parsed successfully, but actuarial-ts ` +
|
|
190
|
+
"computes natively on 12- and 3-month cadences; computation support is limited",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let triangle = triangleFromGrid(
|
|
195
|
+
kind,
|
|
196
|
+
body.origins.map((o) => o.label),
|
|
197
|
+
body.agesMonths,
|
|
198
|
+
body.values,
|
|
199
|
+
);
|
|
200
|
+
if (!body.cumulative) {
|
|
201
|
+
triangle = incrementalToCumulative(triangle);
|
|
202
|
+
warnings.push(
|
|
203
|
+
"Incremental values were cumulated via core incrementalToCumulative (a null stops " +
|
|
204
|
+
"accumulation for its row); core methods run on cumulative triangles",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return { triangle, warnings };
|
|
208
|
+
}
|
package/src/envelope.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { ReservingError, canonicalJson, fnv1a64 } from "@actuarial-ts/core";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The interchange document envelope (spec 3.1 / 3.5).
|
|
6
|
+
*
|
|
7
|
+
* Every interchange document is an envelope around exactly one semantic
|
|
8
|
+
* body. The envelope carries `interchangeVersion`, `kind`, `generator`,
|
|
9
|
+
* `createdAt`, `extensions`, and `integrity`; the semantic body is the ONE
|
|
10
|
+
* kind-named object (`triangle`, `selection`, `result`, `study`,
|
|
11
|
+
* `report` — and for `kind: "bundle"`, per spec 3.2, the two-field
|
|
12
|
+
* object `{ bundle, interchange }` the outer tag is defined over).
|
|
13
|
+
*
|
|
14
|
+
* Ground truth:
|
|
15
|
+
* - `integrity` = fnv1a64(canonicalJson(semantic body)) — NEVER the
|
|
16
|
+
* envelope. Re-exporting a document through another adapter changes the
|
|
17
|
+
* envelope (generator, createdAt), not the tag, so appliesTo-by-tag
|
|
18
|
+
* linkage survives cross-language hops. FNV-1a detects ACCIDENTAL
|
|
19
|
+
* divergence only; tamper evidence requires host-side signing.
|
|
20
|
+
* - `governance` (StudyDoc) sits beside the semantic body: it is neither
|
|
21
|
+
* envelope nor integrity-covered; adapters round-trip it opaquely.
|
|
22
|
+
* - Version acceptance (spec 3.5): wrong-major documents are refused with
|
|
23
|
+
* `UNSUPPORTED_VERSION`; same-major unknown minors are accepted, unknown
|
|
24
|
+
* fields are preserved (schemas are passthrough), and
|
|
25
|
+
* `governance`/`extensions` round-trip opaquely.
|
|
26
|
+
* - `createdAt` is caller-supplied (purity rule) — this module never reads
|
|
27
|
+
* a clock.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The interchange spec version this package writes. */
|
|
31
|
+
export const INTERCHANGE_SPEC_VERSION = "1.0.0";
|
|
32
|
+
|
|
33
|
+
/** The spec major this package accepts (spec 3.5: readers accept same-major). */
|
|
34
|
+
export const INTERCHANGE_SPEC_MAJOR = 1;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* This package's version, stamped into `generator` by default. A sync test
|
|
38
|
+
* asserts it matches package.json so it cannot silently drift.
|
|
39
|
+
*/
|
|
40
|
+
export const INTERCHANGE_PACKAGE_VERSION = "0.3.0";
|
|
41
|
+
|
|
42
|
+
export interface GeneratorStamp {
|
|
43
|
+
name: string;
|
|
44
|
+
version: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Default `generator` stamp for documents authored by this package. */
|
|
48
|
+
export const DEFAULT_GENERATOR: GeneratorStamp = {
|
|
49
|
+
name: "@actuarial-ts/interchange",
|
|
50
|
+
version: INTERCHANGE_PACKAGE_VERSION,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const DOCUMENT_KINDS = [
|
|
54
|
+
"triangle",
|
|
55
|
+
"selection",
|
|
56
|
+
"method-result",
|
|
57
|
+
"stochastic-result",
|
|
58
|
+
"study",
|
|
59
|
+
"bundle",
|
|
60
|
+
"crosscheck-report",
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
63
|
+
export type DocumentKind = (typeof DOCUMENT_KINDS)[number];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* kind → the top-level key(s) holding the semantic body. Single-key kinds
|
|
67
|
+
* hash the named object itself; `bundle` hashes `{ bundle, interchange }`
|
|
68
|
+
* (spec 3.2's outer tag).
|
|
69
|
+
*/
|
|
70
|
+
const SEMANTIC_BODY_KEYS: Record<DocumentKind, readonly string[]> = {
|
|
71
|
+
triangle: ["triangle"],
|
|
72
|
+
selection: ["selection"],
|
|
73
|
+
"method-result": ["result"],
|
|
74
|
+
"stochastic-result": ["result"],
|
|
75
|
+
study: ["study"],
|
|
76
|
+
bundle: ["bundle", "interchange"],
|
|
77
|
+
"crosscheck-report": ["report"],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** A 16-hex-char fnv1a64 tag. */
|
|
81
|
+
export const integritySchema = z.string().regex(/^[0-9a-f]{16}$/);
|
|
82
|
+
|
|
83
|
+
export const generatorSchema = z
|
|
84
|
+
.object({ name: z.string().min(1), version: z.string().min(1) })
|
|
85
|
+
.passthrough();
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The shared envelope fields for a document of the given kind. Doc schemas
|
|
89
|
+
* spread this shape and add their semantic body key(s); every object is
|
|
90
|
+
* passthrough so unknown same-major minor fields are preserved, keeping
|
|
91
|
+
* integrity tags stable across a parse → re-serialize hop.
|
|
92
|
+
*/
|
|
93
|
+
export function envelopeShape<K extends DocumentKind>(kind: K) {
|
|
94
|
+
return {
|
|
95
|
+
interchangeVersion: z.string().regex(/^\d+\.\d+\.\d+$/),
|
|
96
|
+
kind: z.literal(kind),
|
|
97
|
+
generator: generatorSchema,
|
|
98
|
+
createdAt: z.string().datetime({ offset: true }),
|
|
99
|
+
extensions: z.record(z.unknown()).optional(),
|
|
100
|
+
integrity: integritySchema,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Minimal structural view of a document for integrity/version helpers. */
|
|
105
|
+
export interface DocumentLike {
|
|
106
|
+
kind: string;
|
|
107
|
+
[key: string]: unknown;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isKnownKind(kind: string): kind is DocumentKind {
|
|
111
|
+
return (DOCUMENT_KINDS as readonly string[]).includes(kind);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Extracts the semantic body the integrity tag is defined over. Throws
|
|
116
|
+
* BAD_INTERCHANGE for unknown kinds or a missing body key.
|
|
117
|
+
*/
|
|
118
|
+
export function semanticBodyOf(doc: DocumentLike): unknown {
|
|
119
|
+
if (!isKnownKind(doc.kind)) {
|
|
120
|
+
throw new ReservingError(
|
|
121
|
+
"BAD_INTERCHANGE",
|
|
122
|
+
`Unknown document kind "${doc.kind}"; this reader understands: ${DOCUMENT_KINDS.join(", ")}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const keys = SEMANTIC_BODY_KEYS[doc.kind];
|
|
126
|
+
for (const key of keys) {
|
|
127
|
+
if (doc[key] === undefined) {
|
|
128
|
+
throw new ReservingError(
|
|
129
|
+
"BAD_INTERCHANGE",
|
|
130
|
+
`Document of kind "${doc.kind}" is missing its semantic body field "${key}"`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (keys.length === 1) return doc[keys[0]!];
|
|
135
|
+
const body: Record<string, unknown> = {};
|
|
136
|
+
for (const key of keys) body[key] = doc[key];
|
|
137
|
+
return body;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** integrity = fnv1a64(canonicalJson(semantic body)) — spec 3.1. */
|
|
141
|
+
export function computeIntegrity(doc: DocumentLike): string {
|
|
142
|
+
return fnv1a64(canonicalJson(semanticBodyOf(doc)));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Returns the document with a freshly computed integrity tag. */
|
|
146
|
+
export function stampIntegrity<T extends DocumentLike>(
|
|
147
|
+
doc: DocumentLike & Omit<T, "integrity">,
|
|
148
|
+
): T {
|
|
149
|
+
return { ...doc, integrity: computeIntegrity(doc) } as unknown as T;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface IntegrityCheck {
|
|
153
|
+
ok: boolean;
|
|
154
|
+
/** The tag recomputed from the semantic body. */
|
|
155
|
+
expected: string;
|
|
156
|
+
/** The tag the document claims. */
|
|
157
|
+
actual: string | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Recomputes the tag from the semantic body and compares to the stated one. */
|
|
161
|
+
export function verifyIntegrity(doc: DocumentLike): IntegrityCheck {
|
|
162
|
+
const expected = computeIntegrity(doc);
|
|
163
|
+
const actual = typeof doc["integrity"] === "string" ? (doc["integrity"] as string) : null;
|
|
164
|
+
return { ok: actual === expected, expected, actual };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface ParsedVersion {
|
|
168
|
+
major: number;
|
|
169
|
+
minor: number;
|
|
170
|
+
patch: number;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Version acceptance (spec 3.5): parses `interchangeVersion` and refuses
|
|
175
|
+
* wrong-major documents with UNSUPPORTED_VERSION. Unparseable versions are
|
|
176
|
+
* BAD_INTERCHANGE (a malformed document, not a future one).
|
|
177
|
+
*/
|
|
178
|
+
export function acceptVersion(version: unknown): ParsedVersion {
|
|
179
|
+
if (typeof version !== "string" || !/^\d+\.\d+\.\d+$/.test(version)) {
|
|
180
|
+
throw new ReservingError(
|
|
181
|
+
"BAD_INTERCHANGE",
|
|
182
|
+
`interchangeVersion must be a MAJOR.MINOR.PATCH string; got ${JSON.stringify(version)}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
const [major, minor, patch] = version.split(".").map(Number) as [number, number, number];
|
|
186
|
+
if (major !== INTERCHANGE_SPEC_MAJOR) {
|
|
187
|
+
throw new ReservingError(
|
|
188
|
+
"UNSUPPORTED_VERSION",
|
|
189
|
+
`Interchange version ${version} has major ${major}; this reader accepts major ${INTERCHANGE_SPEC_MAJOR} only`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return { major, minor, patch };
|
|
193
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export * from "./envelope.js";
|
|
2
|
+
export * from "./parse.js";
|
|
3
|
+
export * from "./schemas/triangle.js";
|
|
4
|
+
export * from "./schemas/selection.js";
|
|
5
|
+
export * from "./schemas/result.js";
|
|
6
|
+
export * from "./schemas/study.js";
|
|
7
|
+
export * from "./schemas/bundle.js";
|
|
8
|
+
export * from "./schemas/crosscheck.js";
|
|
9
|
+
export * from "./schemas/manifest.js";
|
|
10
|
+
export * from "./convert/triangle.js";
|
|
11
|
+
export * from "./convert/selection.js";
|
|
12
|
+
export * from "./convert/result.js";
|
|
13
|
+
export * from "./referee/profiles.js";
|
|
14
|
+
export * from "./referee/crosscheck.js";
|
|
15
|
+
export * from "./referee/crosscheckStochastic.js";
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { ReservingError } from "@actuarial-ts/core";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
type DocumentKind,
|
|
5
|
+
DOCUMENT_KINDS,
|
|
6
|
+
acceptVersion,
|
|
7
|
+
verifyIntegrity,
|
|
8
|
+
} from "./envelope.js";
|
|
9
|
+
import { INTERCHANGE_SCHEMA_MANIFEST } from "./schemas/manifest.js";
|
|
10
|
+
import type { TriangleDoc } from "./schemas/triangle.js";
|
|
11
|
+
import type { SelectionDoc } from "./schemas/selection.js";
|
|
12
|
+
import type { MethodResultDoc, StochasticResultDoc } from "./schemas/result.js";
|
|
13
|
+
import type { StudyDoc } from "./schemas/study.js";
|
|
14
|
+
import type { BundleDoc } from "./schemas/bundle.js";
|
|
15
|
+
import type { CrosscheckReportDoc } from "./schemas/crosscheck.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `parseDocument` (spec 4.1): version-checked, schema-validated,
|
|
19
|
+
* integrity-verified, warning-channeled entry point for any interchange
|
|
20
|
+
* document.
|
|
21
|
+
*
|
|
22
|
+
* Order of checks:
|
|
23
|
+
* 1. version acceptance (spec 3.5) — wrong major → UNSUPPORTED_VERSION;
|
|
24
|
+
* 2. kind dispatch — unknown kind → BAD_INTERCHANGE (a new kind is a spec
|
|
25
|
+
* minor this reader does not know how to interpret; refusing loudly
|
|
26
|
+
* beats pretending);
|
|
27
|
+
* 3. zod validation (passthrough: unknown same-major minor fields are
|
|
28
|
+
* preserved, so a parse → re-serialize hop keeps the integrity tag);
|
|
29
|
+
* 4. integrity verification — per `strictness`, a mismatched tag either
|
|
30
|
+
* refuses (default; accidental divergence is what the tag exists to
|
|
31
|
+
* catch) or warns.
|
|
32
|
+
*
|
|
33
|
+
* Reader-side capability warnings (spec 3.2): a triangle with
|
|
34
|
+
* originLengthMonths 1 or 6 parses successfully and reports a warning
|
|
35
|
+
* that computation support is limited — a reader capability note, not a
|
|
36
|
+
* format error.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export type InterchangeDocument =
|
|
40
|
+
| TriangleDoc
|
|
41
|
+
| SelectionDoc
|
|
42
|
+
| MethodResultDoc
|
|
43
|
+
| StochasticResultDoc
|
|
44
|
+
| StudyDoc
|
|
45
|
+
| BundleDoc
|
|
46
|
+
| CrosscheckReportDoc;
|
|
47
|
+
|
|
48
|
+
export interface ParseDocumentOptions {
|
|
49
|
+
/** How to treat a failed integrity check. Default "refuse". */
|
|
50
|
+
strictness?: "warn" | "refuse";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ParsedDocument {
|
|
54
|
+
doc: InterchangeDocument;
|
|
55
|
+
warnings: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const SCHEMA_BY_KIND: ReadonlyMap<DocumentKind, z.ZodTypeAny> = new Map(
|
|
59
|
+
INTERCHANGE_SCHEMA_MANIFEST.map((entry) => [entry.kind, entry.schema]),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
function formatZodError(error: z.ZodError): string {
|
|
63
|
+
return error.issues
|
|
64
|
+
.map((issue) => `${issue.path.length > 0 ? `$.${issue.path.join(".")}` : "$"}: ${issue.message}`)
|
|
65
|
+
.join("; ");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Documents embedded inside a study or bundle, as `<path>` -> document.
|
|
70
|
+
*
|
|
71
|
+
* A study carries whole TriangleDocs, SelectionDocs and result documents; a
|
|
72
|
+
* bundle mirrors the same set under `interchange`. Each is a complete document
|
|
73
|
+
* with its OWN integrity tag over its OWN body.
|
|
74
|
+
*/
|
|
75
|
+
function embeddedDocuments(doc: InterchangeDocument): { path: string; value: unknown }[] {
|
|
76
|
+
const found: { path: string; value: unknown }[] = [];
|
|
77
|
+
const collect = (prefix: string, list: unknown): void => {
|
|
78
|
+
if (!Array.isArray(list)) return;
|
|
79
|
+
list.forEach((value, index) => found.push({ path: `${prefix}[${index}]`, value }));
|
|
80
|
+
};
|
|
81
|
+
if (doc.kind === "study") {
|
|
82
|
+
collect("$.study.triangles", doc.study.triangles);
|
|
83
|
+
collect("$.study.selections", doc.study.selections);
|
|
84
|
+
collect("$.study.supportingResults", doc.study.supportingResults);
|
|
85
|
+
} else if (doc.kind === "bundle") {
|
|
86
|
+
const mirror = (doc as { interchange?: Record<string, unknown> }).interchange;
|
|
87
|
+
if (mirror !== undefined) {
|
|
88
|
+
collect("$.interchange.triangles", mirror["triangles"]);
|
|
89
|
+
collect("$.interchange.selections", mirror["selections"]);
|
|
90
|
+
collect("$.interchange.results", mirror["results"]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseDocument(
|
|
97
|
+
value: unknown,
|
|
98
|
+
options: ParseDocumentOptions = {},
|
|
99
|
+
): ParsedDocument {
|
|
100
|
+
const strictness = options.strictness ?? "refuse";
|
|
101
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
102
|
+
throw new ReservingError(
|
|
103
|
+
"BAD_INTERCHANGE",
|
|
104
|
+
"An interchange document must be a JSON object with interchangeVersion and kind",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const raw = value as Record<string, unknown>;
|
|
108
|
+
acceptVersion(raw["interchangeVersion"]);
|
|
109
|
+
|
|
110
|
+
const kind = raw["kind"];
|
|
111
|
+
const schema = typeof kind === "string" ? SCHEMA_BY_KIND.get(kind as DocumentKind) : undefined;
|
|
112
|
+
if (schema === undefined) {
|
|
113
|
+
throw new ReservingError(
|
|
114
|
+
"BAD_INTERCHANGE",
|
|
115
|
+
`Unknown document kind ${JSON.stringify(kind)}; this reader understands: ${DOCUMENT_KINDS.join(", ")}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const parsed = schema.safeParse(raw);
|
|
120
|
+
if (!parsed.success) {
|
|
121
|
+
throw new ReservingError(
|
|
122
|
+
"BAD_INTERCHANGE",
|
|
123
|
+
`Document of kind "${String(kind)}" failed schema validation: ${formatZodError(parsed.error)}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const doc = parsed.data as InterchangeDocument;
|
|
127
|
+
|
|
128
|
+
const warnings: string[] = [];
|
|
129
|
+
const integrity = verifyIntegrity(doc);
|
|
130
|
+
if (!integrity.ok) {
|
|
131
|
+
const message =
|
|
132
|
+
`Integrity tag mismatch on kind "${doc.kind}": document states ` +
|
|
133
|
+
`${integrity.actual ?? "(none)"} but the semantic body hashes to ${integrity.expected}. ` +
|
|
134
|
+
"The document diverged from its stated content after it was stamped.";
|
|
135
|
+
if (strictness === "refuse") {
|
|
136
|
+
throw new ReservingError("BAD_INTERCHANGE", message);
|
|
137
|
+
}
|
|
138
|
+
warnings.push(message);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Embedded documents carry their own tags, and nothing checked them. That
|
|
142
|
+
// matters because `appliesTo.triangleIntegrity` is the linkage primitive the
|
|
143
|
+
// referee relies on: a result claims to apply to a triangle BY TAG, so a
|
|
144
|
+
// nested triangle whose tag no longer matches its own body makes every such
|
|
145
|
+
// claim point at something other than what is actually there.
|
|
146
|
+
for (const { path, value } of embeddedDocuments(doc)) {
|
|
147
|
+
const nested = verifyIntegrity(value as Parameters<typeof verifyIntegrity>[0]);
|
|
148
|
+
if (nested.ok) continue;
|
|
149
|
+
const nestedKind =
|
|
150
|
+
typeof (value as { kind?: unknown })?.kind === "string"
|
|
151
|
+
? (value as { kind: string }).kind
|
|
152
|
+
: "unknown";
|
|
153
|
+
const message =
|
|
154
|
+
`Integrity tag mismatch on the embedded "${nestedKind}" document at ${path}: it states ` +
|
|
155
|
+
`${nested.actual ?? "(none)"} but its own semantic body hashes to ${nested.expected}. ` +
|
|
156
|
+
"The enclosing document's tag is intact, so this diverged before it was embedded.";
|
|
157
|
+
if (strictness === "refuse") {
|
|
158
|
+
throw new ReservingError("BAD_INTERCHANGE", message);
|
|
159
|
+
}
|
|
160
|
+
warnings.push(message);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (doc.kind === "triangle") {
|
|
164
|
+
const cadence = doc.triangle.originLengthMonths;
|
|
165
|
+
if (cadence === 1 || cadence === 6) {
|
|
166
|
+
warnings.push(
|
|
167
|
+
`originLengthMonths ${cadence} (${cadence === 1 ? "monthly" : "semiannual"}) parsed ` +
|
|
168
|
+
"successfully, but actuarial-ts computes natively on 12- and 3-month cadences; " +
|
|
169
|
+
"computation support for this triangle is limited",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { doc, warnings };
|
|
175
|
+
}
|