@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,488 @@
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 StochasticResultDoc,
11
+ stochasticResultDocSchema,
12
+ } from "../schemas/result.js";
13
+ import {
14
+ type CrosscheckBody,
15
+ type CrosscheckReportDoc,
16
+ crosscheckReportDocSchema,
17
+ } from "../schemas/crosscheck.js";
18
+
19
+ /**
20
+ * The stochastic referee (spec 5 / 16): cross-implementation comparison of two
21
+ * StochasticResultDocs. Like `crosscheck`, it is NOT an agent — no judgment,
22
+ * no persuasion, just tags, deviations, a derived tolerance, and a verdict.
23
+ *
24
+ * WHY THIS IS A SEPARATE ENTRY POINT, not an overload of `crosscheck`:
25
+ *
26
+ * A deterministic comparison asks "did two engines DERIVE the same number?" and
27
+ * any deviation beyond float noise is a real disagreement. A stochastic
28
+ * comparison usually asks something different — "did two engines DRAW from the
29
+ * same distribution?" — and there the expected disagreement is nonzero and
30
+ * governed by sampling theory. Applying a deterministic tolerance to two Monte
31
+ * Carlo samples manufactures `disagree` verdicts out of ordinary sampling
32
+ * noise. (We learned this the hard way: a sidecar test asserting 1%/5%
33
+ * agreement between two bootstrap runs flaked ~4 times in 5, because both
34
+ * bounds sat at or below the noise floor. See docs/interop/reproducibility.md.)
35
+ *
36
+ * THE TOLERANCE IS DERIVED, NOT DECLARED. For n simulations with coefficient
37
+ * of variation CV:
38
+ *
39
+ * relative MC standard error of the mean ~= CV / sqrt(n)
40
+ * relative MC standard error of a sample sd ~= 1 / sqrt(2n)
41
+ *
42
+ * Two independent runs differ by sqrt(2) times those, and the bound is `sigmas`
43
+ * of that (default 4). So the referee's strictness scales correctly with n:
44
+ * more simulations, tighter bound, automatically.
45
+ *
46
+ * STRICTNESS ADAPTS TO THE REPRODUCIBILITY CLASS (spec 16). If BOTH results
47
+ * declare `seeded-reproducible` and carry the SAME seed, they are claiming
48
+ * byte-reproducibility — sampling noise is not an excuse and the MC allowance
49
+ * is NOT granted; they are held to `exactTolerance`. The allowance exists for
50
+ * genuinely independent draws, not as a blanket loosening.
51
+ */
52
+
53
+ /** Relative MC standard error of a sample mean, given the CV. */
54
+ function meanMcSe(cv: number, n: number): number {
55
+ return cv / Math.sqrt(n);
56
+ }
57
+
58
+ /** Relative MC standard error of a sample standard deviation. */
59
+ function sdMcSe(n: number): number {
60
+ return 1 / Math.sqrt(2 * n);
61
+ }
62
+
63
+ function relativeDeviation(x: number, y: number): number {
64
+ const scale = Math.max(Math.abs(x), Math.abs(y));
65
+ return scale === 0 ? 0 : Math.abs(x - y) / scale;
66
+ }
67
+
68
+ export interface CrosscheckStochasticOptions {
69
+ a: StochasticResultDoc;
70
+ b: StochasticResultDoc;
71
+ /**
72
+ * Sigmas of Monte Carlo headroom for the derived tolerance. Default 4.
73
+ * Lower it only with a reason: at 2 sigma roughly 1 comparison in 20 of two
74
+ * genuinely-equivalent engines will be called `disagree`.
75
+ */
76
+ sigmas?: number;
77
+ /**
78
+ * The bound applied when both sides claim `seeded-reproducible` with the
79
+ * same seed, i.e. when they assert byte-reproducibility and the MC allowance
80
+ * is withheld. Default 1e-9 (float noise only).
81
+ */
82
+ exactTolerance?: number;
83
+ /** ISO timestamp for the report envelope (caller-supplied; purity rule). */
84
+ createdAt: string;
85
+ generator?: GeneratorStamp;
86
+ }
87
+
88
+ function validateInput(label: "a" | "b", doc: StochasticResultDoc): StochasticResultDoc {
89
+ const parsed = stochasticResultDocSchema.safeParse(doc);
90
+ if (!parsed.success) {
91
+ throw new ReservingError(
92
+ "BAD_INTERCHANGE",
93
+ `crosscheckStochastic input "${label}" is not a valid StochasticResultDoc: ${parsed.error.issues
94
+ .map((i) => `${i.path.join(".") || "$"}: ${i.message}`)
95
+ .join("; ")}`,
96
+ );
97
+ }
98
+ const integrity = verifyIntegrity(parsed.data);
99
+ if (!integrity.ok) {
100
+ throw new ReservingError(
101
+ "BAD_INTERCHANGE",
102
+ `crosscheckStochastic input "${label}" fails its integrity check: stated ` +
103
+ `${integrity.actual ?? "(none)"}, semantic body hashes to ${integrity.expected}`,
104
+ );
105
+ }
106
+ return parsed.data;
107
+ }
108
+
109
+ /** `summary.mean` / `summary.sd` as numbers, or null when unusable. */
110
+ function summaryStat(doc: StochasticResultDoc, key: "mean" | "sd"): number | null {
111
+ const value = (doc.result.summary as Record<string, unknown>)[key];
112
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
113
+ }
114
+
115
+ function originStat(row: Record<string, unknown>, key: "mean" | "sd"): number | null {
116
+ const value = row[key];
117
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
118
+ }
119
+
120
+ export function crosscheckStochastic(
121
+ options: CrosscheckStochasticOptions,
122
+ ): CrosscheckReportDoc {
123
+ const a = validateInput("a", options.a);
124
+ const b = validateInput("b", options.b);
125
+
126
+ const sigmas = options.sigmas ?? 4;
127
+ if (!(sigmas > 0)) {
128
+ throw new ReservingError("BAD_INTERCHANGE", "crosscheckStochastic 'sigmas' must be positive");
129
+ }
130
+ const exactTolerance = options.exactTolerance ?? 1e-9;
131
+ if (!(exactTolerance > 0)) {
132
+ // The report schema requires positive tolerances; catching it here hands
133
+ // the caller their own mistake instead of an internal validation dump.
134
+ throw new ReservingError(
135
+ "BAD_INTERCHANGE",
136
+ "crosscheckStochastic 'exactTolerance' must be positive",
137
+ );
138
+ }
139
+
140
+ const warnings: string[] = [];
141
+ const notComparable: string[] = [];
142
+
143
+ // --- comparability: the same tags the deterministic referee requires ---
144
+ const aTo = a.result.appliesTo;
145
+ const bTo = b.result.appliesTo;
146
+ const sameTriangle = aTo.triangleIntegrity === bTo.triangleIntegrity;
147
+ const sameSelection = aTo.selectionIntegrity === bTo.selectionIntegrity;
148
+ if (!sameTriangle) {
149
+ notComparable.push(
150
+ `the results apply to different triangles (${aTo.triangleIntegrity} vs ${bTo.triangleIntegrity})`,
151
+ );
152
+ }
153
+ if (!sameSelection) {
154
+ notComparable.push(
155
+ `the results apply to different selections (${aTo.selectionIntegrity ?? "null"} vs ` +
156
+ `${bTo.selectionIntegrity ?? "null"}); comparability requires the same selection or both null`,
157
+ );
158
+ }
159
+
160
+ const profileA = a.result.engine.conventionProfile;
161
+ const profileB = b.result.engine.conventionProfile;
162
+ if (profileA !== undefined && profileB !== undefined && profileA !== profileB) {
163
+ notComparable.push(
164
+ `the results claim different convention profiles ("${profileA}" vs "${profileB}")`,
165
+ );
166
+ }
167
+
168
+ // --- comparability: origin sets ---
169
+ const aRows = a.result.byOrigin as Array<Record<string, unknown>>;
170
+ const bRows = b.result.byOrigin as Array<Record<string, unknown>>;
171
+ for (const [label, rows] of [["a", aRows] as const, ["b", bRows] as const]) {
172
+ const seen = new Set<string>();
173
+ for (const row of rows) {
174
+ const origin = String(row["origin"]);
175
+ if (seen.has(origin)) {
176
+ throw new ReservingError(
177
+ "BAD_INTERCHANGE",
178
+ `Result "${label}" lists origin "${origin}" more than once; per-origin comparison is ill-defined`,
179
+ );
180
+ }
181
+ seen.add(origin);
182
+ }
183
+ }
184
+ const bByOrigin = new Map(bRows.map((r) => [String(r["origin"]), r]));
185
+ const aOrigins = aRows.map((r) => String(r["origin"]));
186
+ const aOnly = aOrigins.filter((o) => !bByOrigin.has(o));
187
+ const bOnly = bRows.map((r) => String(r["origin"])).filter((o) => !aOrigins.includes(o));
188
+ if (aOnly.length > 0 || bOnly.length > 0) {
189
+ notComparable.push(
190
+ `the results cover different origin sets (only in a: [${aOnly.join(", ")}]; only in b: [${bOnly.join(", ")}])`,
191
+ );
192
+ }
193
+
194
+ const comparable = notComparable.length === 0;
195
+
196
+ // --- requested vs effective downgrade (spec 3.2 / 5) ---
197
+ for (const [label, doc] of [
198
+ ["a", a],
199
+ ["b", b],
200
+ ] as const) {
201
+ if (doc.result.effectiveParameters !== undefined) {
202
+ warnings.push(
203
+ `Comparability downgrade: engine "${label}" (${doc.result.engine.name}) ran with effective ` +
204
+ `parameters ${JSON.stringify(doc.result.effectiveParameters)} deviating from the requested ` +
205
+ `${JSON.stringify(doc.result.parameters)}`,
206
+ );
207
+ }
208
+ }
209
+
210
+ // --- reproducibility class drives how strict we are (spec 16) ---
211
+ const classA = a.result.reproducibility;
212
+ const classB = b.result.reproducibility;
213
+ const witnessed = classA === "witnessed" || classB === "witnessed";
214
+ const sameSeed = a.result.seed !== undefined && a.result.seed === b.result.seed;
215
+ const bothSeededReproducible =
216
+ classA === "seeded-reproducible" && classB === "seeded-reproducible";
217
+ // The MC allowance is for genuinely independent draws. Two results that BOTH
218
+ // claim byte-reproducibility at the SAME seed are asserting they must match
219
+ // exactly, so sampling noise is not available to them as an excuse.
220
+ // ...and the SAME simulation count. A 1,000-sim and a 10,000-sim run at seed
221
+ // 42 are legitimately different samples; holding them to float noise would
222
+ // report that difference as a broken reproducibility promise.
223
+ const sameNSims = a.result.nSims === b.result.nSims;
224
+ const holdToExact = bothSeededReproducible && sameSeed && sameNSims;
225
+
226
+ for (const [label, cls] of [
227
+ ["a", classA],
228
+ ["b", classB],
229
+ ] as const) {
230
+ if (cls === undefined) {
231
+ warnings.push(
232
+ `Result "${label}" does not state a reproducibility class; it is being compared as if ` +
233
+ "independently drawn (the Monte Carlo allowance is granted). An unstated class is " +
234
+ "unknown, not a guarantee (spec 16).",
235
+ );
236
+ }
237
+ }
238
+ if (witnessed) {
239
+ warnings.push(
240
+ "At least one input is WITNESSED: that engine is not byte-reproducible even at a fixed " +
241
+ "seed, so re-running this comparison will NOT reproduce these exact numbers. Agreement " +
242
+ "here is distributional and attests that the engines drew from the same distribution; it " +
243
+ "is not a replay a reviewer can regenerate (spec 16).",
244
+ );
245
+ }
246
+ if (holdToExact) {
247
+ warnings.push(
248
+ `Both inputs claim seeded-reproducible at the same seed (${a.result.seed}), so the Monte ` +
249
+ `Carlo allowance is WITHHELD and they are held to ${exactTolerance}. Two engines that ` +
250
+ "both promise byte-reproducibility at one seed must agree exactly.",
251
+ );
252
+ } else if (bothSeededReproducible && sameSeed && !sameNSims) {
253
+ warnings.push(
254
+ `Both inputs claim seeded-reproducible at seed ${a.result.seed}, but they ran different ` +
255
+ `simulation counts (${a.result.nSims} vs ${b.result.nSims}), so they are different draws ` +
256
+ "and the Monte Carlo allowance is granted.",
257
+ );
258
+ } else if (comparable && sameSeed && witnessed) {
259
+ warnings.push(
260
+ `Both inputs carry seed ${a.result.seed}, but a witnessed engine does not reproduce under ` +
261
+ "a fixed seed, so the shared seed does not imply the samples are identical.",
262
+ );
263
+ }
264
+
265
+ // --- the derived tolerance ---
266
+ const meanA = summaryStat(a, "mean");
267
+ const meanB = summaryStat(b, "mean");
268
+ const sdA = summaryStat(a, "sd");
269
+ const sdB = summaryStat(b, "sd");
270
+
271
+ // Conservative: the smaller sample governs the noise, the larger CV governs it.
272
+ const nEffective = Math.min(a.result.nSims, b.result.nSims);
273
+ const cvCandidates: number[] = [];
274
+ for (const [mean, sd] of [
275
+ [meanA, sdA],
276
+ [meanB, sdB],
277
+ ] as const) {
278
+ if (mean !== null && sd !== null && mean !== 0) cvCandidates.push(Math.abs(sd / mean));
279
+ }
280
+ const cv = cvCandidates.length > 0 ? Math.max(...cvCandidates) : null;
281
+
282
+ let central: number;
283
+ let standardError: number;
284
+ if (holdToExact) {
285
+ central = exactTolerance;
286
+ standardError = exactTolerance;
287
+ } else {
288
+ // sqrt(2) because we compare TWO independent samples, not one against truth.
289
+ const meanBound =
290
+ cv !== null ? meanMcSe(cv, nEffective) * Math.SQRT2 * sigmas : Number.NaN;
291
+ const sdBound = sdMcSe(nEffective) * Math.SQRT2 * sigmas;
292
+ if (cv === null) {
293
+ warnings.push(
294
+ "Neither result carries a usable summary mean and sd, so the Monte Carlo bound on the " +
295
+ "central estimate could not be derived; the standard-error bound is used for both.",
296
+ );
297
+ }
298
+ central = Number.isFinite(meanBound) ? meanBound : sdBound;
299
+ standardError = sdBound;
300
+ warnings.push(
301
+ `Tolerance DERIVED from sampling theory at n=${nEffective}` +
302
+ (cv !== null ? `, CV=${cv.toFixed(4)}` : "") +
303
+ `, ${sigmas} sigma: central ${central.toExponential(3)}, standard error ` +
304
+ `${standardError.toExponential(3)}. It is not a declared constant — it tightens as n grows.`,
305
+ );
306
+ }
307
+
308
+ // --- deviations, each cell judged against ITS OWN derived bound ---
309
+ //
310
+ // `unpaid` carries the central estimate of the reserve (the bootstrap's mean)
311
+ // and `standardError` its dispersion (the bootstrap sd IS the estimated
312
+ // prediction error). `ultimate` has no distributional analogue here.
313
+ //
314
+ // The central bound is derived PER CELL, not once from the total. A single
315
+ // origin is far more volatile than the diversified total — on a realistic
316
+ // Taylor/Ashe bootstrap the per-origin CV runs 0.35-0.45 against a total CV
317
+ // near 0.15 — so judging origins by the total's bound holds them to roughly
318
+ // 3x too tight a standard and manufactures `disagree` verdicts out of
319
+ // ordinary sampling noise. The sd bound (1/sqrt(2n)) has no CV term, so it is
320
+ // common to every cell.
321
+ const centralBoundFor = (cv: number | null): number =>
322
+ holdToExact
323
+ ? exactTolerance
324
+ : cv !== null
325
+ ? meanMcSe(cv, nEffective) * Math.SQRT2 * sigmas
326
+ : standardError;
327
+
328
+ const cvOfCell = (mean: number | null, sd: number | null): number | null =>
329
+ mean !== null && sd !== null && mean !== 0 ? Math.abs(sd / mean) : null;
330
+
331
+ interface JudgedCell {
332
+ origin: string;
333
+ ultimate: null;
334
+ unpaid: number | null;
335
+ standardError: number | null;
336
+ /** The central bound this cell was judged against (passthrough disclosure). */
337
+ centralBound: number;
338
+ }
339
+
340
+ const perOrigin: JudgedCell[] = aOrigins.map((origin) => {
341
+ const rowA = aRows.find((r) => String(r["origin"]) === origin)!;
342
+ const rowB = bByOrigin.get(origin);
343
+ if (rowB === undefined) {
344
+ return { origin, ultimate: null, unpaid: null, standardError: null, centralBound: central };
345
+ }
346
+ const oMeanA = originStat(rowA, "mean");
347
+ const oMeanB = originStat(rowB, "mean");
348
+ const oSdA = originStat(rowA, "sd");
349
+ const oSdB = originStat(rowB, "sd");
350
+ // Conservative: the more volatile of the two engines governs this cell.
351
+ const cvs = [cvOfCell(oMeanA, oSdA), cvOfCell(oMeanB, oSdB)].filter(
352
+ (v): v is number => v !== null,
353
+ );
354
+ return {
355
+ origin,
356
+ ultimate: null,
357
+ unpaid: oMeanA !== null && oMeanB !== null ? relativeDeviation(oMeanA, oMeanB) : null,
358
+ standardError: oSdA !== null && oSdB !== null ? relativeDeviation(oSdA, oSdB) : null,
359
+ centralBound: centralBoundFor(cvs.length > 0 ? Math.max(...cvs) : null),
360
+ };
361
+ });
362
+
363
+ // A stochastic body MAY carry point estimates (`rows`/`totals`) beside the
364
+ // distribution. Ignoring them lets two results whose point ultimates differ
365
+ // 10x still return `agree` because the distribution summaries happen to
366
+ // match — a hole an adversarial review found. Compare them.
367
+ const pointStat = (
368
+ totalsRecord: Record<string, unknown> | undefined,
369
+ key: "ultimate" | "unpaid",
370
+ ): number | null => {
371
+ const value = totalsRecord?.[key];
372
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
373
+ };
374
+ const pointA = pointStat(a.result.totals as Record<string, unknown> | undefined, "ultimate");
375
+ const pointB = pointStat(b.result.totals as Record<string, unknown> | undefined, "ultimate");
376
+ const comparedPointEstimates = pointA !== null && pointB !== null;
377
+ if (comparedPointEstimates) {
378
+ warnings.push(
379
+ "Both results carry POINT estimates beside the distribution; their ultimates were compared " +
380
+ "and are reported in deviations.totals.ultimate. Note they are held to the DISTRIBUTIONAL " +
381
+ "bound, which is loose for a deterministic quantity — run crosscheck() on the " +
382
+ "corresponding method-result documents for a strict point-estimate comparison.",
383
+ );
384
+ } else if (pointA !== null || pointB !== null) {
385
+ warnings.push(
386
+ "Only one result carries point estimates beside its distribution; point ultimates were not " +
387
+ "compared.",
388
+ );
389
+ }
390
+
391
+ const totals: {
392
+ ultimate: number | null;
393
+ unpaid: number | null;
394
+ standardError: number | null;
395
+ [k: string]: unknown;
396
+ } = {
397
+ ultimate: comparedPointEstimates ? relativeDeviation(pointA, pointB) : null,
398
+ unpaid: meanA !== null && meanB !== null ? relativeDeviation(meanA, meanB) : null,
399
+ standardError: sdA !== null && sdB !== null ? relativeDeviation(sdA, sdB) : null,
400
+ };
401
+
402
+ // --- verdict ---
403
+ let verdict: CrosscheckBody["verdict"];
404
+ let breached: string | null = null;
405
+ if (!comparable) {
406
+ verdict = "not-comparable";
407
+ warnings.push(...notComparable.map((reason) => `Not comparable: ${reason}`));
408
+ } else {
409
+ for (const cell of perOrigin) {
410
+ if (cell.unpaid !== null && cell.unpaid > cell.centralBound) {
411
+ breached = `origin ${cell.origin} central deviation ${cell.unpaid.toExponential(3)} exceeds its derived bound ${cell.centralBound.toExponential(3)}`;
412
+ break;
413
+ }
414
+ if (cell.standardError !== null && cell.standardError > standardError) {
415
+ breached = `origin ${cell.origin} standard-error deviation ${cell.standardError.toExponential(3)} exceeds the derived bound ${standardError.toExponential(3)}`;
416
+ break;
417
+ }
418
+ }
419
+ if (breached === null && totals.ultimate !== null && totals.ultimate > central) {
420
+ breached = `total point-estimate ultimate deviation ${totals.ultimate.toExponential(3)} exceeds the derived bound ${central.toExponential(3)}`;
421
+ }
422
+ if (breached === null && totals.unpaid !== null && totals.unpaid > central) {
423
+ breached = `total central deviation ${totals.unpaid.toExponential(3)} exceeds the derived bound ${central.toExponential(3)}`;
424
+ }
425
+ if (breached === null && totals.standardError !== null && totals.standardError > standardError) {
426
+ breached = `total standard-error deviation ${totals.standardError.toExponential(3)} exceeds the derived bound ${standardError.toExponential(3)}`;
427
+ }
428
+ if (breached !== null) {
429
+ verdict = "disagree";
430
+ warnings.push(`Disagreement: ${breached}.`);
431
+ } else {
432
+ verdict = "agree";
433
+ }
434
+ }
435
+
436
+ const body: CrosscheckBody = {
437
+ engines: { a: a.result.engine, b: b.result.engine },
438
+ appliesTo: sameTriangle && sameSelection ? aTo : null,
439
+ parameters: {
440
+ a: { requested: a.result.parameters, effective: a.result.effectiveParameters ?? null },
441
+ b: { requested: b.result.parameters, effective: b.result.effectiveParameters ?? null },
442
+ },
443
+ tolerance: { central, standardError },
444
+ deviations: { perOrigin, totals },
445
+ verdict,
446
+ warnings,
447
+ // Passthrough: what a reader needs to interpret a distributional verdict.
448
+ comparison: {
449
+ kind: "distributional",
450
+ nSims: { a: a.result.nSims, b: b.result.nSims },
451
+ seed: { a: a.result.seed ?? null, b: b.result.seed ?? null },
452
+ reproducibility: { a: classA ?? null, b: classB ?? null },
453
+ monteCarloAllowance: !holdToExact,
454
+ sigmas,
455
+ /**
456
+ * `tolerance.central` above is the bound applied to the TOTAL. Each
457
+ * origin is judged against its OWN bound, derived from that origin's
458
+ * coefficient of variation, because a single origin is materially more
459
+ * volatile than the diversified total. Those bounds are on each
460
+ * `deviations.perOrigin[].centralBound`.
461
+ */
462
+ centralBoundIsPerCell: !holdToExact,
463
+ },
464
+ // Cast through unknown: the body carries passthrough extras (`comparison`,
465
+ // per-cell `centralBound`) whose zod-inferred output type is not
466
+ // structurally assignable. crosscheckReportDocSchema.safeParse below is the
467
+ // real check — an invalid body throws rather than escaping.
468
+ } as unknown as CrosscheckBody;
469
+
470
+ const doc = stampIntegrity<CrosscheckReportDoc>({
471
+ interchangeVersion: INTERCHANGE_SPEC_VERSION,
472
+ kind: "crosscheck-report",
473
+ generator: options.generator ?? DEFAULT_GENERATOR,
474
+ createdAt: options.createdAt,
475
+ extensions: {},
476
+ report: body,
477
+ });
478
+ const checked = crosscheckReportDocSchema.safeParse(doc);
479
+ if (!checked.success) {
480
+ throw new ReservingError(
481
+ "BAD_INTERCHANGE",
482
+ `crosscheckStochastic produced an invalid report: ${checked.error.issues
483
+ .map((i) => `${i.path.join(".") || "$"}: ${i.message}`)
484
+ .join("; ")}`,
485
+ );
486
+ }
487
+ return checked.data;
488
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Convention profiles (spec 5): normative alignment requirements as DATA,
3
+ * executable by the conformance suite. A profile names what every engine
4
+ * must pin so their numbers are comparable at the stated tolerances;
5
+ * changing an existing profile's requirements is a spec MAJOR (spec 11).
6
+ */
7
+
8
+ export interface ProfileTolerance {
9
+ /** Relative tolerance on central estimates (ultimates, unpaid/reserves). */
10
+ central: number;
11
+ /** Relative tolerance on standard errors; null = SEs out of profile scope. */
12
+ standardError: number | null;
13
+ }
14
+
15
+ /** What one engine must run for its results to claim this profile. */
16
+ export interface EngineAlignment {
17
+ /** Human-readable entry point (estimator/function/method family). */
18
+ entryPoint: string;
19
+ /** The exact parameters that must be pinned. */
20
+ parameters: Record<string, unknown>;
21
+ /** Traps the research flagged; honesty notes the referee/report surface. */
22
+ notes?: string[];
23
+ }
24
+
25
+ export interface ConventionProfile {
26
+ id: string;
27
+ description: string;
28
+ tolerance: ProfileTolerance;
29
+ alignment: {
30
+ "actuarial-ts": EngineAlignment;
31
+ "chainladder-python": EngineAlignment;
32
+ "r-chainladder": EngineAlignment;
33
+ };
34
+ }
35
+
36
+ export const DETERMINISTIC_CL_PROFILE: ConventionProfile = {
37
+ id: "deterministic-cl",
38
+ description:
39
+ "Factor + projection point estimates: all three engines agree to ~1e-9 under identical " +
40
+ "selections; standard errors are out of scope.",
41
+ tolerance: { central: 1e-6, standardError: null },
42
+ alignment: {
43
+ "actuarial-ts": {
44
+ entryPoint: "runChainLadder",
45
+ parameters: { selections: "identical LdfSelections (values + tail)" },
46
+ },
47
+ "chainladder-python": {
48
+ entryPoint: "Chainladder",
49
+ parameters: {
50
+ development:
51
+ "Development(...) per the intent equivalence table, or DevelopmentConstant for value-only selections",
52
+ },
53
+ },
54
+ "r-chainladder": {
55
+ entryPoint: "chainladder / ata-based projection",
56
+ parameters: {
57
+ factors: "identical selected factors (CLFMdelta injection where feasible)",
58
+ },
59
+ notes: [
60
+ "CLFMdelta returns per-element foundSolution; a failed injection makes the run not-comparable, never agreement",
61
+ ],
62
+ },
63
+ },
64
+ };
65
+
66
+ export const MACK_1993_VW_PROFILE: ConventionProfile = {
67
+ id: "mack1993-vw",
68
+ description:
69
+ "Volume-weighted all-period factors, Mack sigma with Mack's last-column extrapolation. " +
70
+ "Central estimates at 1e-6 relative; standard errors at 0.5% relative.",
71
+ tolerance: { central: 1e-6, standardError: 0.005 },
72
+ alignment: {
73
+ "actuarial-ts": {
74
+ entryPoint: "runMack",
75
+ parameters: {
76
+ selected: "omitted (volume-weighted per Mack 1993)",
77
+ sigma: "Mack last-column extrapolation (built in)",
78
+ },
79
+ },
80
+ "chainladder-python": {
81
+ entryPoint: "MackChainladder",
82
+ parameters: {
83
+ average: "volume",
84
+ n_periods: -1,
85
+ sigma_interpolation: "mack",
86
+ },
87
+ notes: [
88
+ 'The DEFAULT sigma_interpolation ("log-linear") does NOT match this profile; "mack" must be pinned',
89
+ ],
90
+ },
91
+ "r-chainladder": {
92
+ entryPoint: "MackChainLadder",
93
+ parameters: {
94
+ alpha: 1,
95
+ "est.sigma": "Mack",
96
+ },
97
+ notes: [
98
+ 'The DEFAULT est.sigma ("log-linear") does NOT match this profile',
99
+ 'R silently falls back from est.sigma="log-linear" to "Mack" on poor regression fit; ' +
100
+ "the adapter must record the EFFECTIVE method in effectiveParameters — the referee " +
101
+ "downgrades requested≠effective comparisons",
102
+ "MackChainLadder(alpha) semantics: alpha=1 volume-weighted, alpha=0 simple, alpha=2 regression; " +
103
+ "the lower-level chainladder(delta) uses alpha = 2 − delta — never conflate the two",
104
+ ],
105
+ },
106
+ },
107
+ };
108
+
109
+ /** The Phase A profile registry (odp-bootstrap-distribution arrives in Phase C). */
110
+ export const CONVENTION_PROFILES: Readonly<Record<string, ConventionProfile>> = {
111
+ [DETERMINISTIC_CL_PROFILE.id]: DETERMINISTIC_CL_PROFILE,
112
+ [MACK_1993_VW_PROFILE.id]: MACK_1993_VW_PROFILE,
113
+ };
@@ -0,0 +1,64 @@
1
+ import { z } from "zod";
2
+ import { envelopeShape, type GeneratorStamp } from "../envelope.js";
3
+ import { type TriangleDoc, triangleDocSchema } from "./triangle.js";
4
+ import { type SelectionDoc, selectionDocSchema } from "./selection.js";
5
+ import {
6
+ type MethodResultDoc,
7
+ type StochasticResultDoc,
8
+ methodResultDocSchema,
9
+ stochasticResultDocSchema,
10
+ } from "./result.js";
11
+
12
+ /**
13
+ * BundleDoc (spec 3.2) — the wrapped reproducibility bundle.
14
+ *
15
+ * - `bundle` is the host's existing canonical payload, carried opaquely
16
+ * (this package must not depend on @actuarial-ts/compliance; compliance
17
+ * depends on interchange for the wrapper, not the reverse).
18
+ * - `interchange` mirrors the bundle's triangles, selections, and results
19
+ * as interchange documents so a non-TS consumer (`load_bundle`) never
20
+ * parses the TS-native blob.
21
+ * - The OUTER integrity tag is defined over `{ bundle, interchange }`
22
+ * (spec 3.2), so the mirror cannot drift from the wrapped payload
23
+ * unnoticed. `verifyBundle`'s wrapped mode (Phase B, in compliance)
24
+ * checks the inner bundle exactly as today AND this outer tag.
25
+ *
26
+ * Interfaces are written out for the same declaration-emit reason as
27
+ * study.ts; the schema annotations keep them honest.
28
+ */
29
+
30
+ export interface BundleInterchange {
31
+ triangles: TriangleDoc[];
32
+ selections: SelectionDoc[];
33
+ results: (MethodResultDoc | StochasticResultDoc)[];
34
+ [key: string]: unknown;
35
+ }
36
+
37
+ export interface BundleDoc {
38
+ interchangeVersion: string;
39
+ kind: "bundle";
40
+ generator: GeneratorStamp & { [key: string]: unknown };
41
+ createdAt: string;
42
+ extensions?: Record<string, unknown>;
43
+ integrity: string;
44
+ /** The host's existing canonical bundle payload, opaque at this layer. */
45
+ bundle: Record<string, unknown>;
46
+ interchange: BundleInterchange;
47
+ [key: string]: unknown;
48
+ }
49
+
50
+ export const bundleInterchangeSchema: z.ZodType<BundleInterchange> = z
51
+ .object({
52
+ triangles: z.array(triangleDocSchema),
53
+ selections: z.array(selectionDocSchema),
54
+ results: z.array(z.union([methodResultDocSchema, stochasticResultDocSchema])),
55
+ })
56
+ .passthrough();
57
+
58
+ export const bundleDocSchema: z.ZodType<BundleDoc> = z
59
+ .object({
60
+ ...envelopeShape("bundle"),
61
+ bundle: z.record(z.unknown()),
62
+ interchange: bundleInterchangeSchema,
63
+ })
64
+ .passthrough();