@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,459 @@
1
+ import { ReservingError } from "@actuarial-ts/core";
2
+ import {
3
+ DEFAULT_GENERATOR,
4
+ INTERCHANGE_SPEC_VERSION,
5
+ type GeneratorStamp,
6
+ stampIntegrity,
7
+ verifyIntegrity,
8
+ } from "../envelope.js";
9
+ import {
10
+ type MethodResultDoc,
11
+ methodResultDocSchema,
12
+ } from "../schemas/result.js";
13
+ import {
14
+ type SelectionDoc,
15
+ isValueOnlySelection,
16
+ selectionDocSchema,
17
+ } from "../schemas/selection.js";
18
+ import {
19
+ type CrosscheckBody,
20
+ type CrosscheckReportDoc,
21
+ crosscheckReportDocSchema,
22
+ } from "../schemas/crosscheck.js";
23
+ import { CONVENTION_PROFILES } from "./profiles.js";
24
+
25
+ /**
26
+ * The referee (spec 5): deterministic cross-implementation comparison of
27
+ * two MethodResultDocs. NOT an agent (G5) — no judgment, no persuasion,
28
+ * just tags, deviations, tolerances, and a verdict.
29
+ *
30
+ * - Comparability: same triangle tag; same selection tag or both null;
31
+ * convention profiles must not conflict; origin sets must match.
32
+ * - Tolerance: explicit option > shared known profile > the
33
+ * deterministic-cl default (with a warning). mack1993-vw compares SEs at
34
+ * its own SE tolerance; a profile with SEs out of scope skips them.
35
+ * - requested ≠ effective (`effectiveParameters` present on either side)
36
+ * downgrades the comparison with a comparability warning (spec 3.2).
37
+ * - `verified-by-value`: within tolerance but the selection both results
38
+ * replayed is value-only — the engines applied the same values rather
39
+ * than independently recomputing, and the verdict says so.
40
+ */
41
+
42
+ export interface CrosscheckOptions {
43
+ a: MethodResultDoc;
44
+ b: MethodResultDoc;
45
+ /** Overrides the profile tolerance. A bare number applies to central
46
+ * estimates AND standard errors. */
47
+ tolerance?: number | { central: number; standardError?: number };
48
+ /**
49
+ * Absolute floor for the deviation test, applied as
50
+ * `|x - y| <= absoluteTolerance + rtol * max(|x|, |y|)`. Default 0, which is
51
+ * pure relative comparison.
52
+ *
53
+ * There is deliberately no non-zero default: the referee sees result
54
+ * documents, not the triangle, so it cannot read `units.scale` and any
55
+ * constant it invented would be wrong for somebody's data. A caller who
56
+ * knows the scale should set it — without one, an exact 0 against 1e-11 of
57
+ * float dust reads as a 100% deviation.
58
+ */
59
+ absoluteTolerance?: number;
60
+ /** The selection both results applied, when one exists — enables the
61
+ * verified-by-value classification. */
62
+ selection?: SelectionDoc;
63
+ /** ISO timestamp for the report envelope (caller-supplied; purity rule). */
64
+ createdAt: string;
65
+ generator?: GeneratorStamp;
66
+ }
67
+
68
+ interface ResolvedTolerance {
69
+ central: number;
70
+ standardError: number | null;
71
+ /**
72
+ * True when something AFFIRMATIVELY required standard errors to be compared —
73
+ * a convention profile that scopes them in, or a caller naming
74
+ * `standardError` explicitly. A bare numeric tolerance broadcasts to both
75
+ * metrics as a convenience and does NOT assert that SEs must be present, so
76
+ * it does not make their absence a comparability failure.
77
+ */
78
+ standardErrorRequired: boolean;
79
+ }
80
+
81
+ function relativeDeviation(x: number, y: number): number {
82
+ const scale = Math.max(Math.abs(x), Math.abs(y));
83
+ return scale === 0 ? 0 : Math.abs(x - y) / scale;
84
+ }
85
+
86
+ /**
87
+ * True when x and y differ by more than the caller allows.
88
+ *
89
+ * `|x - y| <= atol + rtol * max(|x|, |y|)` — the standard combined form. With
90
+ * atol 0 this is exactly the relative test, which is the default.
91
+ */
92
+ function exceeds(x: number, y: number, rtol: number, atol: number): boolean {
93
+ return Math.abs(x - y) > atol + rtol * Math.max(Math.abs(x), Math.abs(y));
94
+ }
95
+
96
+ function validateInput(label: "a" | "b", doc: MethodResultDoc): MethodResultDoc {
97
+ const parsed = methodResultDocSchema.safeParse(doc);
98
+ if (!parsed.success) {
99
+ // A stochastic result is a legitimate document that simply belongs to the
100
+ // other referee: comparing two Monte Carlo samples under a deterministic
101
+ // tolerance manufactures `disagree` verdicts out of sampling noise
102
+ // (spec 16). Say so, rather than leaving the caller with a schema dump.
103
+ if ((doc as { kind?: unknown })?.kind === "stochastic-result") {
104
+ throw new ReservingError(
105
+ "BAD_INTERCHANGE",
106
+ `crosscheck input "${label}" is a stochastic-result document. Use crosscheckStochastic(), ` +
107
+ "which compares distributions under a tolerance derived from sampling theory; a " +
108
+ "deterministic tolerance applied to two Monte Carlo samples reports ordinary sampling " +
109
+ "noise as disagreement.",
110
+ );
111
+ }
112
+ throw new ReservingError(
113
+ "BAD_INTERCHANGE",
114
+ `crosscheck input "${label}" is not a valid MethodResultDoc: ${parsed.error.issues
115
+ .map((i) => `${i.path.join(".") || "$"}: ${i.message}`)
116
+ .join("; ")}`,
117
+ );
118
+ }
119
+ const integrity = verifyIntegrity(parsed.data);
120
+ if (!integrity.ok) {
121
+ throw new ReservingError(
122
+ "BAD_INTERCHANGE",
123
+ `crosscheck input "${label}" fails its integrity check: stated ${integrity.actual ?? "(none)"}, ` +
124
+ `semantic body hashes to ${integrity.expected}`,
125
+ );
126
+ }
127
+ return parsed.data;
128
+ }
129
+
130
+ function resolveTolerance(
131
+ a: MethodResultDoc,
132
+ b: MethodResultDoc,
133
+ explicit: CrosscheckOptions["tolerance"],
134
+ warnings: string[],
135
+ ): ResolvedTolerance {
136
+ if (typeof explicit === "number") {
137
+ return { central: explicit, standardError: explicit, standardErrorRequired: false };
138
+ }
139
+ if (explicit !== undefined) {
140
+ return {
141
+ central: explicit.central,
142
+ standardError: explicit.standardError ?? null,
143
+ standardErrorRequired: explicit.standardError !== undefined,
144
+ };
145
+ }
146
+ const profileA = a.result.engine.conventionProfile;
147
+ const profileB = b.result.engine.conventionProfile;
148
+ // A profile stated on ONE side still governs. Falling back to the default
149
+ // when only one engine declares its conventions silently drops standard
150
+ // errors out of scope, which is how a 50% SE divergence used to report
151
+ // `agree`. Use the stated profile and say the other side did not state one.
152
+ const stated = profileA ?? profileB;
153
+ if (profileA !== undefined && profileB !== undefined && profileA !== profileB) {
154
+ // Conflicting profiles are a comparability failure, handled by the caller.
155
+ } else if (stated !== undefined && profileA !== profileB) {
156
+ warnings.push(
157
+ `Only ${profileA !== undefined ? '"a"' : '"b"'} states a convention profile ` +
158
+ `("${stated}"); it is being applied to both. The other engine did not declare ` +
159
+ "its conventions, so alignment is asserted rather than verified.",
160
+ );
161
+ }
162
+ const shared = profileA === profileB ? profileA : stated;
163
+ if (shared !== undefined) {
164
+ const profile = CONVENTION_PROFILES[shared];
165
+ if (profile !== undefined) {
166
+ return {
167
+ ...profile.tolerance,
168
+ standardErrorRequired: profile.tolerance.standardError !== null,
169
+ };
170
+ }
171
+ warnings.push(
172
+ `Convention profile "${shared}" is not in this package's registry; falling back to the ` +
173
+ "deterministic-cl central tolerance",
174
+ );
175
+ } else {
176
+ warnings.push(
177
+ "No shared convention profile is stated on both results; falling back to the " +
178
+ "deterministic-cl central tolerance",
179
+ );
180
+ }
181
+ const fallback = CONVENTION_PROFILES["deterministic-cl"]!;
182
+ return { ...fallback.tolerance, standardErrorRequired: false };
183
+ }
184
+
185
+ export function crosscheck(options: CrosscheckOptions): CrosscheckReportDoc {
186
+ const a = validateInput("a", options.a);
187
+ const b = validateInput("b", options.b);
188
+
189
+ const absoluteTolerance = options.absoluteTolerance ?? 0;
190
+ const warnings: string[] = [];
191
+ const notComparable: string[] = [];
192
+
193
+ // --- comparability: appliesTo tags (spec 5) ---
194
+ const aTo = a.result.appliesTo;
195
+ const bTo = b.result.appliesTo;
196
+ const sameTriangle = aTo.triangleIntegrity === bTo.triangleIntegrity;
197
+ const sameSelection = aTo.selectionIntegrity === bTo.selectionIntegrity;
198
+ if (!sameTriangle) {
199
+ notComparable.push(
200
+ `the results apply to different triangles (${aTo.triangleIntegrity} vs ${bTo.triangleIntegrity})`,
201
+ );
202
+ }
203
+ if (!sameSelection) {
204
+ notComparable.push(
205
+ `the results apply to different selections (${aTo.selectionIntegrity ?? "null"} vs ` +
206
+ `${bTo.selectionIntegrity ?? "null"}); comparability requires the same selection or both null`,
207
+ );
208
+ }
209
+
210
+ // --- comparability: convention profiles ---
211
+ const profileA = a.result.engine.conventionProfile;
212
+ const profileB = b.result.engine.conventionProfile;
213
+ if (profileA !== undefined && profileB !== undefined && profileA !== profileB) {
214
+ notComparable.push(
215
+ `the results claim different convention profiles ("${profileA}" vs "${profileB}")`,
216
+ );
217
+ }
218
+
219
+ // --- the selection both results replayed, when supplied ---
220
+ let selection: SelectionDoc | null = null;
221
+ if (options.selection !== undefined) {
222
+ const parsed = selectionDocSchema.safeParse(options.selection);
223
+ if (!parsed.success) {
224
+ throw new ReservingError(
225
+ "BAD_INTERCHANGE",
226
+ `crosscheck "selection" is not a valid SelectionDoc: ${parsed.error.issues
227
+ .map((i) => `${i.path.join(".") || "$"}: ${i.message}`)
228
+ .join("; ")}`,
229
+ );
230
+ }
231
+ selection = parsed.data;
232
+ if (sameSelection && aTo.selectionIntegrity !== selection.integrity) {
233
+ throw new ReservingError(
234
+ "BAD_INTERCHANGE",
235
+ `The supplied selection's integrity (${selection.integrity}) does not match the results' ` +
236
+ `selectionIntegrity (${aTo.selectionIntegrity ?? "null"})`,
237
+ );
238
+ }
239
+ }
240
+
241
+ // --- comparability: origin sets ---
242
+ const aOrigins = a.result.rows.map((r) => r.origin);
243
+ for (const [label, rows] of [["a", a.result.rows] as const, ["b", b.result.rows] as const]) {
244
+ const seen = new Set<string>();
245
+ for (const row of rows) {
246
+ if (seen.has(row.origin)) {
247
+ throw new ReservingError(
248
+ "BAD_INTERCHANGE",
249
+ `Result "${label}" lists origin "${row.origin}" more than once; per-origin comparison is ill-defined`,
250
+ );
251
+ }
252
+ seen.add(row.origin);
253
+ }
254
+ }
255
+ const bByOrigin = new Map(b.result.rows.map((r) => [r.origin, r]));
256
+ const aOnly = aOrigins.filter((o) => !bByOrigin.has(o));
257
+ const bOnly = b.result.rows.map((r) => r.origin).filter((o) => !a.result.rows.some((r) => r.origin === o));
258
+ if (aOnly.length > 0 || bOnly.length > 0) {
259
+ notComparable.push(
260
+ `the results cover different origin sets (only in a: [${aOnly.join(", ")}]; only in b: [${bOnly.join(", ")}])`,
261
+ );
262
+ }
263
+
264
+ // --- requested vs effective downgrade (spec 3.2 / 5) ---
265
+ for (const [label, doc] of [
266
+ ["a", a],
267
+ ["b", b],
268
+ ] as const) {
269
+ if (doc.result.effectiveParameters !== undefined) {
270
+ warnings.push(
271
+ `Comparability downgrade: engine "${label}" (${doc.result.engine.name}) ran with effective ` +
272
+ `parameters ${JSON.stringify(doc.result.effectiveParameters)} deviating from the requested ` +
273
+ `${JSON.stringify(doc.result.parameters)}`,
274
+ );
275
+ }
276
+ }
277
+
278
+ const tolerance = resolveTolerance(a, b, options.tolerance, warnings);
279
+ const compareSe = tolerance.standardError !== null;
280
+ if (!compareSe) {
281
+ const seOnBoth =
282
+ a.result.rows.some((r) => r.standardError !== undefined) &&
283
+ b.result.rows.some((r) => r.standardError !== undefined);
284
+ if (seOnBoth) {
285
+ warnings.push(
286
+ "Both results carry standard errors but the applied tolerance has SEs out of scope; " +
287
+ "standard errors were not compared",
288
+ );
289
+ }
290
+ }
291
+
292
+ // --- deviations ---
293
+ let comparable = notComparable.length === 0;
294
+ const perOrigin: CrosscheckBody["deviations"]["perOrigin"] = [];
295
+ let maxCentral = 0;
296
+ let maxSe = 0;
297
+ // How many cells each metric was actually compared on. A metric the profile
298
+ // scopes IN but which never got compared on a single cell is the case that
299
+ // used to report `agree` while checking nothing.
300
+ let centralCells = 0;
301
+ let seCells = 0;
302
+ let centralBreached = false;
303
+ let seBreached = false;
304
+ if (comparable) {
305
+ for (const rowA of a.result.rows) {
306
+ const rowB = bByOrigin.get(rowA.origin)!;
307
+ const ultimate = relativeDeviation(rowA.ultimate, rowB.ultimate);
308
+ const unpaid = relativeDeviation(rowA.unpaid, rowB.unpaid);
309
+ const standardError =
310
+ compareSe && rowA.standardError !== undefined && rowB.standardError !== undefined
311
+ ? relativeDeviation(rowA.standardError, rowB.standardError)
312
+ : null;
313
+ centralCells++;
314
+ maxCentral = Math.max(maxCentral, ultimate, unpaid);
315
+ if (
316
+ exceeds(rowA.ultimate, rowB.ultimate, tolerance.central, absoluteTolerance) ||
317
+ exceeds(rowA.unpaid, rowB.unpaid, tolerance.central, absoluteTolerance)
318
+ ) {
319
+ centralBreached = true;
320
+ }
321
+ if (standardError !== null) {
322
+ seCells++;
323
+ maxSe = Math.max(maxSe, standardError);
324
+ if (exceeds(rowA.standardError!, rowB.standardError!, tolerance.standardError!, absoluteTolerance)) {
325
+ seBreached = true;
326
+ }
327
+ }
328
+ perOrigin.push({ origin: rowA.origin, ultimate, unpaid, standardError });
329
+ }
330
+ }
331
+ const totalsA = a.result.totals;
332
+ const totalsB = b.result.totals;
333
+ const totals: CrosscheckBody["deviations"]["totals"] = comparable
334
+ ? {
335
+ ultimate: relativeDeviation(totalsA.ultimate, totalsB.ultimate),
336
+ unpaid: relativeDeviation(totalsA.unpaid, totalsB.unpaid),
337
+ standardError:
338
+ compareSe &&
339
+ totalsA.standardError !== undefined &&
340
+ totalsB.standardError !== undefined
341
+ ? relativeDeviation(totalsA.standardError, totalsB.standardError)
342
+ : null,
343
+ }
344
+ : { ultimate: null, unpaid: null, standardError: null };
345
+ if (comparable) {
346
+ centralCells++;
347
+ maxCentral = Math.max(maxCentral, totals.ultimate ?? 0, totals.unpaid ?? 0);
348
+ if (
349
+ exceeds(totalsA.ultimate, totalsB.ultimate, tolerance.central, absoluteTolerance) ||
350
+ exceeds(totalsA.unpaid, totalsB.unpaid, tolerance.central, absoluteTolerance)
351
+ ) {
352
+ centralBreached = true;
353
+ }
354
+ if (totals.standardError !== null) {
355
+ seCells++;
356
+ maxSe = Math.max(maxSe, totals.standardError);
357
+ if (exceeds(totalsA.standardError!, totalsB.standardError!, tolerance.standardError!, absoluteTolerance)) {
358
+ seBreached = true;
359
+ }
360
+ }
361
+ }
362
+
363
+ // A metric the profile scopes IN but which was never compared on any cell
364
+ // leaves the comparison incomplete. `agree` is read as "these engines agree",
365
+ // so it must not be reachable when the thing the profile asked about was
366
+ // never checked — the referee refuses instead, and says which metric.
367
+ const uncovered: string[] = [];
368
+ if (comparable && centralCells === 0) {
369
+ uncovered.push("central estimates were not compared on any cell");
370
+ }
371
+ if (comparable && compareSe && tolerance.standardErrorRequired && seCells === 0) {
372
+ uncovered.push(
373
+ `the profile scopes standard errors in (tolerance ${tolerance.standardError}) but neither ` +
374
+ "side carries a standard error on any cell, so they were never compared",
375
+ );
376
+ }
377
+
378
+ // --- verdict ---
379
+ let verdict: CrosscheckBody["verdict"];
380
+ if (!comparable) {
381
+ verdict = "not-comparable";
382
+ warnings.push(...notComparable.map((reason) => `Not comparable: ${reason}`));
383
+ } else if (centralBreached || (compareSe && seBreached)) {
384
+ verdict = "disagree";
385
+ } else if (uncovered.length > 0) {
386
+ verdict = "not-comparable";
387
+ warnings.push(
388
+ ...uncovered.map(
389
+ (reason) =>
390
+ `Not comparable: ${reason}. Everything that WAS compared agreed, but a verdict of ` +
391
+ "`agree` would overstate that.",
392
+ ),
393
+ );
394
+ } else if (
395
+ selection !== null &&
396
+ sameSelection &&
397
+ aTo.selectionIntegrity !== null &&
398
+ isValueOnlySelection(selection.selection)
399
+ ) {
400
+ verdict = "verified-by-value";
401
+ warnings.push(
402
+ "The compared results replayed a value-only selection (every intent judgmental/external): " +
403
+ "no independent recomputation occurred; agreement verifies value transport, not methodology",
404
+ );
405
+ } else {
406
+ if (sameSelection && aTo.selectionIntegrity !== null && selection === null) {
407
+ // Whether agreement reflects independent recomputation or a replay of the
408
+ // same values depends on the selection document — and the party supplying
409
+ // it is the one being audited. Silence here let them choose whether the
410
+ // disclosure appeared, so say plainly that the check did not run.
411
+ warnings.push(
412
+ `Both results replayed selection ${aTo.selectionIntegrity}, but the selection document ` +
413
+ "was not supplied, so whether it is value-only could not be checked. If it is, this " +
414
+ "agreement verifies value transport rather than methodology — pass `selection` to " +
415
+ "resolve it.",
416
+ );
417
+ }
418
+ verdict = "agree";
419
+ }
420
+
421
+ const body: CrosscheckBody = {
422
+ engines: { a: a.result.engine, b: b.result.engine },
423
+ appliesTo: sameTriangle && sameSelection ? aTo : null,
424
+ parameters: {
425
+ a: { requested: a.result.parameters, effective: a.result.effectiveParameters ?? null },
426
+ b: { requested: b.result.parameters, effective: b.result.effectiveParameters ?? null },
427
+ },
428
+ tolerance: { central: tolerance.central, standardError: tolerance.standardError },
429
+ deviations: { perOrigin, totals },
430
+ verdict,
431
+ warnings,
432
+ // What was actually examined, so a reader can tell "agreed on everything the
433
+ // profile asked about" from "agreed on what happened to be present".
434
+ coverage: {
435
+ central: { inScope: true, comparedCells: centralCells },
436
+ standardError: { inScope: compareSe, comparedCells: seCells },
437
+ absoluteTolerance,
438
+ },
439
+ } as CrosscheckBody;
440
+
441
+ const doc = stampIntegrity<CrosscheckReportDoc>({
442
+ interchangeVersion: INTERCHANGE_SPEC_VERSION,
443
+ kind: "crosscheck-report",
444
+ generator: options.generator ?? DEFAULT_GENERATOR,
445
+ createdAt: options.createdAt,
446
+ extensions: {},
447
+ report: body,
448
+ });
449
+ const checked = crosscheckReportDocSchema.safeParse(doc);
450
+ if (!checked.success) {
451
+ throw new ReservingError(
452
+ "BAD_INTERCHANGE",
453
+ `crosscheck produced an invalid report: ${checked.error.issues
454
+ .map((i) => `${i.path.join(".") || "$"}: ${i.message}`)
455
+ .join("; ")}`,
456
+ );
457
+ }
458
+ return checked.data;
459
+ }