@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.
Files changed (42) hide show
  1. package/README.md +28 -1
  2. package/dist/convert/result.d.ts +4 -4
  3. package/dist/convert/result.js +3 -3
  4. package/dist/convert/result.js.map +1 -1
  5. package/dist/envelope.d.ts +1 -1
  6. package/dist/envelope.js +1 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/parse.d.ts.map +1 -1
  12. package/dist/parse.js +49 -0
  13. package/dist/parse.js.map +1 -1
  14. package/dist/referee/crosscheck.d.ts +12 -0
  15. package/dist/referee/crosscheck.d.ts.map +1 -1
  16. package/dist/referee/crosscheck.js +109 -9
  17. package/dist/referee/crosscheck.js.map +1 -1
  18. package/dist/referee/crosscheckStochastic.d.ts +24 -0
  19. package/dist/referee/crosscheckStochastic.d.ts.map +1 -0
  20. package/dist/referee/crosscheckStochastic.js +367 -0
  21. package/dist/referee/crosscheckStochastic.js.map +1 -0
  22. package/dist/schemas/result.d.ts +552 -0
  23. package/dist/schemas/result.d.ts.map +1 -1
  24. package/dist/schemas/result.js +52 -0
  25. package/dist/schemas/result.js.map +1 -1
  26. package/package.json +4 -2
  27. package/src/convert/result.ts +212 -0
  28. package/src/convert/selection.ts +565 -0
  29. package/src/convert/triangle.ts +208 -0
  30. package/src/envelope.ts +193 -0
  31. package/src/index.ts +15 -0
  32. package/src/parse.ts +175 -0
  33. package/src/referee/crosscheck.ts +459 -0
  34. package/src/referee/crosscheckStochastic.ts +488 -0
  35. package/src/referee/profiles.ts +113 -0
  36. package/src/schemas/bundle.ts +64 -0
  37. package/src/schemas/crosscheck.ts +84 -0
  38. package/src/schemas/manifest.ts +75 -0
  39. package/src/schemas/result.ts +180 -0
  40. package/src/schemas/selection.ts +175 -0
  41. package/src/schemas/study.ts +98 -0
  42. 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
+ }
@@ -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
+ }