@actuarial-ts/compliance 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/src/bundle.ts ADDED
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Reproducibility bundle: canonical JSON serialization + a tiny integrity
3
+ * hash, so an analysis can state "these results came from exactly these
4
+ * inputs, parameters, and SDK versions" and a re-run can be byte-verified.
5
+ *
6
+ * `canonicalJson` and `fnv1a64` LIVE IN @actuarial-ts/core (src/canonical.ts)
7
+ * since 0.2.0 so the interchange layer can share them without a package
8
+ * cycle; this module re-exports both, unchanged. One deliberate behavior
9
+ * change rode along: invalid canonicalization input now throws core's
10
+ * ReservingError("UNSUPPORTED_VALUE") instead of ComplianceError - same
11
+ * code, same message shape, different class (CHANGELOG 0.2.0).
12
+ *
13
+ * Ground truth (unchanged):
14
+ * - Timestamps are caller-supplied ISO strings; this module never reads a
15
+ * clock, so identical inputs yield byte-identical bundles.
16
+ * - fnv1a64 is an integrity aid, NOT a security control (see its doc block
17
+ * in core).
18
+ * - Browser-safe: no node builtins.
19
+ *
20
+ * Error style for the whole package: `ComplianceError` with a registered
21
+ * machine code, mirroring core's `ReservingError` idiom (bundle.ts is the
22
+ * package's dependency-root module, so the class lives here).
23
+ *
24
+ * These utilities are designed to support the actuary's compliance with
25
+ * ASOP No. 41 (documentation and reproducibility of the analysis);
26
+ * responsibility for compliance remains with the credentialed actuary.
27
+ */
28
+
29
+ import { canonicalJson, fnv1a64 } from "@actuarial-ts/core";
30
+ import type {
31
+ BundleDoc,
32
+ MethodResultDoc,
33
+ SelectionDoc,
34
+ StochasticResultDoc,
35
+ TriangleDoc,
36
+ } from "@actuarial-ts/interchange";
37
+
38
+ export { canonicalJson, fnv1a64 };
39
+
40
+ /**
41
+ * The wrapped reproducibility bundle: the interchange BundleDoc (spec 3.2),
42
+ * re-exported under a compliance-side name so it cannot be confused with the
43
+ * inner ReproducibilityBundle record. The dependency on
44
+ * `@actuarial-ts/interchange` is TYPE-ONLY (interchange must not import
45
+ * compliance, and it does not; the outer tag is computed here with core's
46
+ * canonicalJson/fnv1a64, matching interchange's semanticBodyOf for
47
+ * kind "bundle" exactly — the wrapped round-trip test parses the emitted doc
48
+ * with the real interchange parser so any drift fails loudly).
49
+ */
50
+ export type WrappedBundleDoc = BundleDoc;
51
+
52
+ /**
53
+ * Every machine-readable code a ComplianceError can carry, across all modules
54
+ * of this package. Add the code here when introducing a new throw.
55
+ * (UNSUPPORTED_VALUE moved to core's registry with canonicalJson in 0.2.0.)
56
+ */
57
+ export const COMPLIANCE_ERROR_CODES = [
58
+ "BAD_BUNDLE",
59
+ "BAD_CDF",
60
+ "MISSING_RATIONALE",
61
+ ] as const;
62
+
63
+ export type ComplianceErrorCode = (typeof COMPLIANCE_ERROR_CODES)[number];
64
+
65
+ /** Thrown for invalid compliance input (bad bundles, judgment without rationale, non-positive CDFs). */
66
+ export class ComplianceError extends Error {
67
+ readonly code: ComplianceErrorCode;
68
+ constructor(code: ComplianceErrorCode, message: string) {
69
+ super(message);
70
+ this.name = "ComplianceError";
71
+ this.code = code;
72
+ }
73
+ }
74
+
75
+
76
+
77
+ /**
78
+ * The interchange version the wrapped form is written under. Kept in literal
79
+ * sync with `@actuarial-ts/interchange`'s INTERCHANGE_SPEC_VERSION (the dep
80
+ * is type-only, so the constant cannot be imported); the wrapped round-trip
81
+ * test parses the emitted doc with the real interchange parser, which
82
+ * refuses a wrong-major version — drift fails loudly.
83
+ */
84
+ export const WRAPPED_BUNDLE_INTERCHANGE_VERSION = "1.0.0";
85
+
86
+ /**
87
+ * This package's version, stamped into a wrapped bundle's `generator`
88
+ * envelope field. A sync test asserts it matches package.json so it cannot
89
+ * silently drift (mirroring interchange's INTERCHANGE_PACKAGE_VERSION
90
+ * discipline).
91
+ */
92
+ export const COMPLIANCE_PACKAGE_VERSION = "0.3.0";
93
+
94
+ /**
95
+ * The interchange mirror for a wrapped bundle (spec 3.2): the triangles the
96
+ * analysis consumed, the selections it applied, and the results it produced,
97
+ * each as an interchange document. Results are INCLUDED so a non-TS consumer
98
+ * (`load_bundle`) can honor its contract without ever parsing the TS-native
99
+ * canonical payload.
100
+ */
101
+ export interface BundleWrapInput {
102
+ triangles: TriangleDoc[];
103
+ selections: SelectionDoc[];
104
+ results: (MethodResultDoc | StochasticResultDoc)[];
105
+ }
106
+
107
+ export interface CreateBundleInput {
108
+ /** The data the analysis consumed (triangles, claims, exposures — anything canonicalizable). */
109
+ inputs: unknown;
110
+ /** The parameters/selections the analysis used (LDF selections, a-prioris, trends…). */
111
+ parameters: unknown;
112
+ /** The results the analysis produced; this is the segment verifyBundle re-checks. */
113
+ results: unknown;
114
+ /** Package versions the run used, e.g. { "@actuarial-ts/core": "0.1.0" }. */
115
+ sdkVersions: Record<string, string>;
116
+ /** Explicit seeds for any stochastic method (purity: no ambient randomness). */
117
+ seeds?: unknown;
118
+ /** Caller-supplied ISO timestamp (purity: no clock reads). */
119
+ createdAt: string;
120
+ /**
121
+ * Optional interchange mirror (spec 3.2). When provided, the bundle is
122
+ * ALSO emitted as a wrapped BundleDoc whose OUTER integrity tag is defined
123
+ * over `{ bundle, interchange }`. Never enters the inner payload: the
124
+ * unwrapped bundle is byte-identical with or without `wrap`.
125
+ */
126
+ wrap?: BundleWrapInput;
127
+ /**
128
+ * Overrides the wrapped document's `generator` stamp. Defaults to this
129
+ * package at its current version, which is what a real analysis wants.
130
+ *
131
+ * Exists for authoring FROZEN corpora (the cross-engine conformance
132
+ * fixtures), where every byte must reproduce forever and so a stamp that
133
+ * tracks the live build cannot be used — the same reason `createdAt` is
134
+ * caller-supplied rather than read from the clock. Matches the
135
+ * `generator?` option the interchange document builders already accept.
136
+ * Wrapped-mode only; the inner payload never carries it.
137
+ */
138
+ generator?: { name: string; version: string };
139
+ }
140
+
141
+ export interface ReproducibilityBundle {
142
+ /** Canonical JSON of the full bundle body; the reproducibility record itself. */
143
+ payload: string;
144
+ /** fnv1a64(payload) — integrity aid, not a security control. */
145
+ hash: string;
146
+ }
147
+
148
+ /** A ReproducibilityBundle plus its wrapped interchange form (spec 3.2). */
149
+ export interface WrappedBundleResult extends ReproducibilityBundle {
150
+ /**
151
+ * The wrapped BundleDoc: `{ bundle: { payload, hash }, interchange }` under
152
+ * an interchange envelope, with the OUTER integrity tag over the two-field
153
+ * semantic body `{ bundle, interchange }`.
154
+ */
155
+ wrapped: WrappedBundleDoc;
156
+ }
157
+
158
+ /**
159
+ * Packages an analysis run into a reproducibility bundle. The payload is
160
+ * canonical, so two runs with structurally equal inputs produce byte-identical
161
+ * payloads (and therefore identical hashes) regardless of key insertion order.
162
+ * `seeds` is included only when provided (undefined would be unrepresentable).
163
+ *
164
+ * With `wrap` (the interchange mirror, spec 3.2) the same inner bundle is
165
+ * ALSO returned as a wrapped BundleDoc; the unwrapped `{ payload, hash }` is
166
+ * byte-identical either way (the v0.1.x compat fixture pins this).
167
+ */
168
+ export function createBundle(input: CreateBundleInput & { wrap: BundleWrapInput }): WrappedBundleResult;
169
+ export function createBundle(input: CreateBundleInput): ReproducibilityBundle;
170
+ export function createBundle(input: CreateBundleInput): ReproducibilityBundle | WrappedBundleResult {
171
+ const body: Record<string, unknown> = {
172
+ createdAt: input.createdAt,
173
+ inputs: input.inputs,
174
+ parameters: input.parameters,
175
+ results: input.results,
176
+ sdkVersions: input.sdkVersions,
177
+ };
178
+ if (input.seeds !== undefined) body["seeds"] = input.seeds;
179
+ const payload = canonicalJson(body);
180
+ const inner: ReproducibilityBundle = { payload, hash: fnv1a64(payload) };
181
+ if (input.wrap === undefined) return inner;
182
+
183
+ // The inner record is carried opaquely as the `bundle` segment; the outer
184
+ // tag is fnv1a64(canonicalJson({ bundle, interchange })) — exactly
185
+ // interchange's semanticBodyOf for kind "bundle" (spec 3.2).
186
+ const bundleSegment: Record<string, unknown> = { hash: inner.hash, payload: inner.payload };
187
+ const interchange = {
188
+ triangles: [...input.wrap.triangles],
189
+ selections: [...input.wrap.selections],
190
+ results: [...input.wrap.results],
191
+ };
192
+ const wrapped: WrappedBundleDoc = {
193
+ interchangeVersion: WRAPPED_BUNDLE_INTERCHANGE_VERSION,
194
+ kind: "bundle",
195
+ generator: input.generator
196
+ ? { ...input.generator }
197
+ : { name: "@actuarial-ts/compliance", version: COMPLIANCE_PACKAGE_VERSION },
198
+ createdAt: input.createdAt,
199
+ bundle: bundleSegment,
200
+ interchange,
201
+ integrity: fnv1a64(canonicalJson({ bundle: bundleSegment, interchange })),
202
+ };
203
+ return { ...inner, wrapped };
204
+ }
205
+
206
+ /** Outer-tag verification detail (wrapped mode only; spec 3.2). */
207
+ export interface OuterIntegrityCheck {
208
+ ok: boolean;
209
+ /** The tag recomputed from the `{ bundle, interchange }` semantic body. */
210
+ expected: string;
211
+ /** The tag the wrapped document claims. */
212
+ actual: string | null;
213
+ }
214
+
215
+ export interface VerifyBundleResult {
216
+ reproduced: boolean;
217
+ /**
218
+ * First differing path (deterministic depth-first walk, object keys in
219
+ * sorted order), e.g. "$.rows[1].ultimate". Present only when not
220
+ * reproduced. A missing/extra object key reports the key's path; an array
221
+ * length mismatch reports the first index past the shared prefix; a type
222
+ * mismatch reports the node itself. In wrapped mode, an outer-tag failure
223
+ * reports "$.integrity" (the divergent tag itself; see `outerIntegrity`
224
+ * for the expected/actual values).
225
+ */
226
+ mismatchPath?: string;
227
+ /** Present in wrapped mode only: the outer-tag check (spec 3.2). */
228
+ outerIntegrity?: OuterIntegrityCheck;
229
+ }
230
+
231
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
232
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
233
+ const proto: unknown = Object.getPrototypeOf(value);
234
+ return proto === Object.prototype || proto === null;
235
+ }
236
+
237
+ /** First differing path between two canonicalizable structures, or null when equal. */
238
+ function firstDifference(stored: unknown, rerun: unknown, path: string): string | null {
239
+ if (isPlainObject(stored) && isPlainObject(rerun)) {
240
+ const keys = [...new Set([...Object.keys(stored), ...Object.keys(rerun)])].sort();
241
+ for (const key of keys) {
242
+ const keyPath = `${path}.${key}`;
243
+ if (!(key in stored) || !(key in rerun)) return keyPath;
244
+ const diff = firstDifference(stored[key], rerun[key], keyPath);
245
+ if (diff !== null) return diff;
246
+ }
247
+ return null;
248
+ }
249
+ if (Array.isArray(stored) && Array.isArray(rerun)) {
250
+ const shared = Math.min(stored.length, rerun.length);
251
+ for (let i = 0; i < shared; i++) {
252
+ const diff = firstDifference(stored[i], rerun[i], `${path}[${i}]`);
253
+ if (diff !== null) return diff;
254
+ }
255
+ return stored.length === rerun.length ? null : `${path}[${shared}]`;
256
+ }
257
+ return canonicalJson(stored) === canonicalJson(rerun) ? null : path;
258
+ }
259
+
260
+ /** Inner verification — the pre-wrapped behavior, unchanged byte for byte. */
261
+ function verifyUnwrapped(bundle: ReproducibilityBundle, rerunResults: unknown): VerifyBundleResult {
262
+ // The bundle's own hash first. Without this, the attestation ("these results
263
+ // came from exactly these inputs, parameters, and SDK versions") checked the
264
+ // results segment only — a bundle with rewritten inputs and hash "deadbeef"
265
+ // verified. The wrapped path always recomputed its tag; the unwrapped one
266
+ // must too.
267
+ if (fnv1a64(bundle.payload) !== bundle.hash) {
268
+ return { reproduced: false, mismatchPath: "$.hash" };
269
+ }
270
+ let stored: unknown;
271
+ try {
272
+ stored = JSON.parse(bundle.payload);
273
+ } catch {
274
+ throw new ComplianceError("BAD_BUNDLE", "bundle payload is not valid JSON");
275
+ }
276
+ if (!isPlainObject(stored) || !("results" in stored)) {
277
+ throw new ComplianceError("BAD_BUNDLE", 'bundle payload has no "results" segment');
278
+ }
279
+ const storedResults = stored["results"];
280
+ const rerunCanonical = canonicalJson(rerunResults);
281
+ const storedCanonical = canonicalJson(storedResults);
282
+ if (rerunCanonical === storedCanonical) return { reproduced: true };
283
+ return { reproduced: false, mismatchPath: firstDifference(storedResults, rerunResults, "$") ?? "$" };
284
+ }
285
+
286
+ function isWrappedBundleDoc(value: ReproducibilityBundle | WrappedBundleDoc): value is WrappedBundleDoc {
287
+ return (value as { kind?: unknown }).kind === "bundle";
288
+ }
289
+
290
+ /** Wrapped mode: the outer tag (spec 3.2) AND the inner bundle exactly as today. */
291
+ function verifyWrapped(doc: WrappedBundleDoc, rerunResults: unknown): VerifyBundleResult {
292
+ const innerRaw: unknown = doc.bundle;
293
+ if (
294
+ !isPlainObject(innerRaw) ||
295
+ typeof innerRaw["payload"] !== "string" ||
296
+ typeof innerRaw["hash"] !== "string"
297
+ ) {
298
+ throw new ComplianceError(
299
+ "BAD_BUNDLE",
300
+ 'wrapped bundle\'s "bundle" segment must carry the inner { payload, hash } record',
301
+ );
302
+ }
303
+ if (doc.interchange === undefined || doc.interchange === null) {
304
+ throw new ComplianceError("BAD_BUNDLE", 'wrapped bundle is missing its "interchange" mirror');
305
+ }
306
+ // OUTER tag over the two-field semantic body { bundle, interchange } —
307
+ // exactly interchange's semanticBodyOf for kind "bundle" (spec 3.2).
308
+ const expected = fnv1a64(canonicalJson({ bundle: doc.bundle, interchange: doc.interchange }));
309
+ const actual = typeof doc.integrity === "string" ? doc.integrity : null;
310
+ const outerIntegrity: OuterIntegrityCheck = { ok: expected === actual, expected, actual };
311
+ if (!outerIntegrity.ok) {
312
+ return { reproduced: false, mismatchPath: "$.integrity", outerIntegrity };
313
+ }
314
+ const inner: ReproducibilityBundle = { payload: innerRaw["payload"], hash: innerRaw["hash"] };
315
+ return { ...verifyUnwrapped(inner, rerunResults), outerIntegrity };
316
+ }
317
+
318
+ /**
319
+ * Verifies a re-run against a bundle: canonicalizes `rerunResults` and
320
+ * byte-compares it with the bundle's stored `results` segment. On mismatch,
321
+ * reports the FIRST differing path (see VerifyBundleResult.mismatchPath).
322
+ *
323
+ * Wrapped mode (a BundleDoc with `kind: "bundle"`, spec 3.2): additionally
324
+ * recomputes the OUTER integrity tag over `{ bundle, interchange }` and
325
+ * refuses on divergence BEFORE the inner check — a drifted interchange
326
+ * mirror fails verification with mismatchPath "$.integrity" and the
327
+ * expected/actual tags in `outerIntegrity`, even when the inner bundle is
328
+ * untouched. When the outer tag holds, the inner bundle is verified exactly
329
+ * as in unwrapped mode.
330
+ *
331
+ * Throws ComplianceError("BAD_BUNDLE") when the payload is not a valid bundle
332
+ * body, and propagates ReservingError("UNSUPPORTED_VALUE") when
333
+ * `rerunResults` itself is not canonicalizable — a result that cannot be
334
+ * serialized cannot be verified.
335
+ */
336
+ export function verifyBundle(
337
+ bundle: ReproducibilityBundle | WrappedBundleDoc,
338
+ rerunResults: unknown,
339
+ ): VerifyBundleResult {
340
+ return isWrappedBundleDoc(bundle) ? verifyWrapped(bundle, rerunResults) : verifyUnwrapped(bundle, rerunResults);
341
+ }