@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/README.md +2 -2
- package/dist/bundle.d.ts +1 -1
- package/dist/bundle.d.ts.map +1 -1
- package/dist/bundle.js +9 -1
- package/dist/bundle.js.map +1 -1
- package/dist/disclosure.d.ts.map +1 -1
- package/dist/disclosure.js +51 -10
- package/dist/disclosure.js.map +1 -1
- package/package.json +5 -3
- package/src/ave.ts +132 -0
- package/src/bundle.ts +341 -0
- package/src/disclosure.ts +423 -0
- package/src/index.ts +6 -0
- package/src/ledger.ts +140 -0
- package/src/metadata.ts +189 -0
- package/src/modelCards.ts +531 -0
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
|
+
}
|