@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
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import type { CrosscheckReportDoc } from "@actuarial-ts/interchange";
|
|
2
|
+
import type { EstimateMetadata } from "./metadata.js";
|
|
3
|
+
import type { AssumptionLedger, ChangedAssumptions } from "./ledger.js";
|
|
4
|
+
import { MODEL_CARDS, type MethodId } from "./modelCards.js";
|
|
5
|
+
import { canonicalJson, fnv1a64 } from "./bundle.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ASOP No. 41 (Actuarial Communications) disclosure generator.
|
|
9
|
+
*
|
|
10
|
+
* ASOP 41 requires disclosure of the methods, procedures, assumptions, and
|
|
11
|
+
* data underlying the findings with enough clarity that another qualified
|
|
12
|
+
* actuary could objectively appraise the reasonableness of the work. The
|
|
13
|
+
* SDK already knows every method invoked, every parameter, and (through the
|
|
14
|
+
* assumption ledger) which values were machine defaults versus human or
|
|
15
|
+
* agent judgment — so it can render that appendix instead of the actuary
|
|
16
|
+
* reconstructing it by hand.
|
|
17
|
+
*
|
|
18
|
+
* DETERMINISM CONTRACT: identical DisclosureInput yields byte-identical
|
|
19
|
+
* markdown. No clock reads, no randomness — `generatedAt` is an input.
|
|
20
|
+
*
|
|
21
|
+
* The generated document is a DRAFT SUPPORT DOCUMENT for the responsible
|
|
22
|
+
* actuary to review, edit, and adopt; generating it is not compliance.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface MethodResultSummary {
|
|
26
|
+
ultimate?: number;
|
|
27
|
+
ibnr?: number;
|
|
28
|
+
unpaid?: number;
|
|
29
|
+
standardError?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MethodUse {
|
|
33
|
+
methodId: MethodId;
|
|
34
|
+
/** e.g. "paid", "incurred", "capped layer". */
|
|
35
|
+
basisLabel?: string;
|
|
36
|
+
/** The parameters the method actually ran with (JSON-serializable). */
|
|
37
|
+
parameters?: unknown;
|
|
38
|
+
resultSummary?: MethodResultSummary;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Structural stand-in for @actuarial-ts/data's DataReviewReport (no package
|
|
43
|
+
* dependency; anything with this shape renders).
|
|
44
|
+
*/
|
|
45
|
+
export interface DataReviewLike {
|
|
46
|
+
checks: { id: string; description: string; status: string; details: string[] }[];
|
|
47
|
+
summary: { pass: number; warning: number; fail: number; notEvaluated?: number };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PriorComparison {
|
|
51
|
+
priorLabel?: string;
|
|
52
|
+
changes: ChangedAssumptions;
|
|
53
|
+
priorReserve?: number;
|
|
54
|
+
currentReserve?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DisclosureInput {
|
|
58
|
+
title?: string;
|
|
59
|
+
/** Identification of the responsible actuary (ASOP 41 section 3.1.4). */
|
|
60
|
+
preparedBy?: string;
|
|
61
|
+
metadata: EstimateMetadata;
|
|
62
|
+
methods: MethodUse[];
|
|
63
|
+
ledger?: AssumptionLedger;
|
|
64
|
+
dataReview?: DataReviewLike;
|
|
65
|
+
priorComparison?: PriorComparison;
|
|
66
|
+
reliances?: string[];
|
|
67
|
+
limitations?: string[];
|
|
68
|
+
/**
|
|
69
|
+
* Cross-implementation referee reports (interchange spec 5), rendered as
|
|
70
|
+
* "## 4b. Cross-implementation verification" after Section 4. Omitted or
|
|
71
|
+
* empty = no Section 4b (the disclosure never claims a verification that
|
|
72
|
+
* was not performed).
|
|
73
|
+
*/
|
|
74
|
+
crossImplementation?: CrosscheckReportDoc[];
|
|
75
|
+
sdkVersion: string;
|
|
76
|
+
/** Caller-supplied ISO timestamp (determinism: the generator never reads a clock). */
|
|
77
|
+
generatedAt: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const fmtNumber = (v: number): string =>
|
|
81
|
+
Number.isInteger(v) ? v.toLocaleString("en-US") : v.toLocaleString("en-US", { maximumFractionDigits: 2 });
|
|
82
|
+
|
|
83
|
+
function describeMeasure(m: EstimateMetadata["intendedMeasure"]): string {
|
|
84
|
+
switch (m.kind) {
|
|
85
|
+
case "central-estimate":
|
|
86
|
+
return "central estimate (actuarial central estimate / expected value)";
|
|
87
|
+
case "high-estimate":
|
|
88
|
+
return "high estimate";
|
|
89
|
+
case "low-estimate":
|
|
90
|
+
return "low estimate";
|
|
91
|
+
case "specified-percentile":
|
|
92
|
+
return `specified percentile (${m.percentile ?? "unstated"})`;
|
|
93
|
+
case "range":
|
|
94
|
+
return "range of reasonable estimates";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function describeBasis(b: EstimateMetadata["basis"]): string {
|
|
99
|
+
const gross = {
|
|
100
|
+
gross: "gross of reinsurance",
|
|
101
|
+
"net-of-reinsurance": "net of reinsurance",
|
|
102
|
+
"net-of-salvage-subro": "net of salvage and subrogation",
|
|
103
|
+
"net-all": "net of reinsurance, salvage, and subrogation",
|
|
104
|
+
}[b.grossNet];
|
|
105
|
+
const lae = {
|
|
106
|
+
"excluding-lae": "excluding loss adjustment expenses",
|
|
107
|
+
"including-all-lae": "including all loss adjustment expenses",
|
|
108
|
+
"dcc-only": "including defense and cost containment (DCC) expenses only",
|
|
109
|
+
"aao-only": "including adjusting and other (A&O) expenses only",
|
|
110
|
+
}[b.laeTreatment];
|
|
111
|
+
return `${gross}, ${lae}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Neutralizes document-sourced free text before it is interpolated into the
|
|
116
|
+
* disclosure. Ledger rationales and sources, crosscheck warnings and data
|
|
117
|
+
* review details all originate in DOCUMENTS — and a study's narrative flows
|
|
118
|
+
* into the ledger source with no human edit box in between (the promotion
|
|
119
|
+
* chain writes sourceRef verbatim). Unescaped, a crafted value breaks out of
|
|
120
|
+
* its context and renders as body prose: the demonstrated attack fabricated
|
|
121
|
+
* a certification paragraph in an ASOP 41 disclosure.
|
|
122
|
+
*
|
|
123
|
+
* Division of labour, so nothing is escaped twice: this helper neutralizes
|
|
124
|
+
* CONTENT constructs — backticks (code spans), `<` (raw HTML), newlines
|
|
125
|
+
* (paragraph/bullet breaks) — at the sites where document text is
|
|
126
|
+
* interpolated. mdTable below owns the TABLE-structural escape (pipes) for
|
|
127
|
+
* every cell. Deliberately not a markdown stripper: bold or italics in a
|
|
128
|
+
* rationale render harmlessly inline; only constructs that change document
|
|
129
|
+
* structure are neutralized.
|
|
130
|
+
*/
|
|
131
|
+
function renderUntrusted(text: string): string {
|
|
132
|
+
return text
|
|
133
|
+
.replace(/`/g, "\\`")
|
|
134
|
+
.replace(/</g, "<")
|
|
135
|
+
.replace(/\r?\n/g, " ");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function mdTable(header: string[], rows: string[][]): string[] {
|
|
139
|
+
// Structural escaping for EVERY cell, trusted or not: a pipe or newline in
|
|
140
|
+
// any cell breaks the table for every cell after it. GFM renders \| as a
|
|
141
|
+
// literal pipe, including inside code spans, so code-span cells built from
|
|
142
|
+
// canonicalJson survive intact. Pipes are escaped HERE and only here;
|
|
143
|
+
// renderUntrusted never touches them, so nothing double-escapes.
|
|
144
|
+
const cell = (value: string): string => value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
145
|
+
const out: string[] = [];
|
|
146
|
+
out.push(`| ${header.join(" | ")} |`);
|
|
147
|
+
out.push(`|${header.map(() => "---").join("|")}|`);
|
|
148
|
+
for (const r of rows) out.push(`| ${r.map(cell).join(" | ")} |`);
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parameterLines(parameters: unknown): string {
|
|
153
|
+
if (parameters === undefined || parameters === null) return "(engine defaults)";
|
|
154
|
+
const json = canonicalJson(parameters);
|
|
155
|
+
return json.length > 400 ? `${json.slice(0, 400)}… (truncated; full value in the reproducibility bundle)` : json;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** REQUIRED Section 4b boilerplate — verbatim per interchange spec section 5. */
|
|
159
|
+
const CROSS_IMPLEMENTATION_BOILERPLATE =
|
|
160
|
+
"Agreement between independent implementations supports, but does not by itself constitute, the model validation contemplated by ASOP No. 56; model appropriateness to the book remains a separate professional judgment.";
|
|
161
|
+
|
|
162
|
+
/** Relative deviations/tolerances render in scientific notation (they live at 1e-9…1e-2 scale). */
|
|
163
|
+
const fmtDeviation = (v: number | null): string =>
|
|
164
|
+
v === null ? "—" : v === 0 ? "0" : v.toExponential(2);
|
|
165
|
+
|
|
166
|
+
type CrosscheckDeviationCell = { ultimate: number | null; unpaid: number | null; standardError: number | null };
|
|
167
|
+
|
|
168
|
+
function maxAbsDeviation(
|
|
169
|
+
report: CrosscheckReportDoc["report"],
|
|
170
|
+
pick: (cell: CrosscheckDeviationCell) => (number | null)[],
|
|
171
|
+
): number | null {
|
|
172
|
+
const cells: CrosscheckDeviationCell[] = [...report.deviations.perOrigin, report.deviations.totals];
|
|
173
|
+
const values = cells.flatMap((cell) => pick(cell).filter((v): v is number => v !== null).map(Math.abs));
|
|
174
|
+
return values.length === 0 ? null : Math.max(...values);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const engineLabel = (e: { name: string; version: string }): string => `${e.name} v${e.version}`;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `verified-by-value` renders as exactly "verified by value (no independent
|
|
181
|
+
* recomputation)" — spec 5: the disclosure must not overstate what was
|
|
182
|
+
* checked when the selection was value-only.
|
|
183
|
+
*/
|
|
184
|
+
function verdictLabel(verdict: CrosscheckReportDoc["report"]["verdict"]): string {
|
|
185
|
+
return verdict === "verified-by-value" ? "verified by value (no independent recomputation)" : verdict;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Renders the ASOP 41-oriented methods-assumptions-and-data appendix. */
|
|
189
|
+
export function generateDisclosure(input: DisclosureInput): string {
|
|
190
|
+
const L: string[] = [];
|
|
191
|
+
const m = input.metadata;
|
|
192
|
+
|
|
193
|
+
L.push(`# ${input.title ?? "Actuarial analysis: methods, assumptions, and data disclosure"}`);
|
|
194
|
+
L.push("");
|
|
195
|
+
L.push(
|
|
196
|
+
"> Draft support document generated by the actuarial-ts SDK for the responsible actuary's review and adoption. It is designed to support disclosures under ASOP Nos. 41, 43, 23, and 56; responsibility for the actuarial communication and for compliance with the ASOPs remains with the credentialed actuary.",
|
|
197
|
+
);
|
|
198
|
+
L.push("");
|
|
199
|
+
|
|
200
|
+
// 1. Identification, purpose, users, measure.
|
|
201
|
+
L.push("## 1. Identification and intended purpose");
|
|
202
|
+
L.push("");
|
|
203
|
+
if (input.preparedBy) L.push(`- **Responsible actuary / preparer:** ${input.preparedBy}`);
|
|
204
|
+
L.push(`- **Intended purpose:** ${m.intendedPurpose}`);
|
|
205
|
+
if (m.intendedUsers && m.intendedUsers.length > 0) {
|
|
206
|
+
L.push(`- **Intended users:** ${m.intendedUsers.join("; ")}`);
|
|
207
|
+
}
|
|
208
|
+
L.push(`- **Intended measure:** ${describeMeasure(m.intendedMeasure)}`);
|
|
209
|
+
L.push(`- **Generated:** ${input.generatedAt} — actuarial-ts SDK v${input.sdkVersion}`);
|
|
210
|
+
L.push("");
|
|
211
|
+
|
|
212
|
+
// 2. Scope, dates, basis.
|
|
213
|
+
L.push("## 2. Scope, dates, and basis");
|
|
214
|
+
L.push("");
|
|
215
|
+
L.push(`- **Accounting date:** ${m.accountingDate}`);
|
|
216
|
+
L.push(`- **Valuation date:** ${m.valuationDate}`);
|
|
217
|
+
if (m.reviewDate) L.push(`- **Review date:** ${m.reviewDate}`);
|
|
218
|
+
L.push(`- **Basis:** ${describeBasis(m.basis)}`);
|
|
219
|
+
if (m.currency) L.push(`- **Currency:** ${m.currency}`);
|
|
220
|
+
if (m.scopeNotes) L.push(`- **Scope notes:** ${m.scopeNotes}`);
|
|
221
|
+
L.push("");
|
|
222
|
+
|
|
223
|
+
// 3. Data review (ASOP 23).
|
|
224
|
+
L.push("## 3. Data and data review (ASOP No. 23)");
|
|
225
|
+
L.push("");
|
|
226
|
+
if (input.dataReview) {
|
|
227
|
+
const r = input.dataReview;
|
|
228
|
+
const notEvaluated = r.summary.notEvaluated ?? 0;
|
|
229
|
+
L.push(
|
|
230
|
+
`A data review was performed: ${r.checks.length} checks (${r.summary.pass} pass, ${r.summary.warning} warning, ${r.summary.fail} fail${notEvaluated > 0 ? `, ${notEvaluated} not evaluated` : ""}). The checks performed, and their findings, were:`,
|
|
231
|
+
);
|
|
232
|
+
L.push("");
|
|
233
|
+
L.push(
|
|
234
|
+
...mdTable(
|
|
235
|
+
["Check", "Description", "Status", "Findings"],
|
|
236
|
+
r.checks.map((c) => [
|
|
237
|
+
c.id,
|
|
238
|
+
c.description,
|
|
239
|
+
c.status.toUpperCase(),
|
|
240
|
+
// The <br> is OURS (one cell, many findings); the details are NOT.
|
|
241
|
+
c.details.length === 0 ? "none" : c.details.map(renderUntrusted).join("<br>"),
|
|
242
|
+
]),
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
} else {
|
|
246
|
+
L.push(
|
|
247
|
+
"No automated data review report was attached. ASOP No. 23 requires the actuary to review data for reasonableness, consistency, and completeness, or to disclose that no such review was performed and the limitations that imposes.",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
L.push("");
|
|
251
|
+
|
|
252
|
+
// 4. Methods and models (ASOP 56).
|
|
253
|
+
L.push("## 4. Methods and models (ASOP No. 56)");
|
|
254
|
+
L.push("");
|
|
255
|
+
for (const use of input.methods) {
|
|
256
|
+
const card = MODEL_CARDS[use.methodId];
|
|
257
|
+
L.push(`### ${card.title}${use.basisLabel ? ` — ${use.basisLabel}` : ""}`);
|
|
258
|
+
L.push("");
|
|
259
|
+
L.push(`- **Intended use:** ${card.intendedUse}`);
|
|
260
|
+
L.push(`- **Specification:** ${card.specification}`);
|
|
261
|
+
L.push(`- **Key assumptions:** ${card.keyAssumptions.join(" ")}`);
|
|
262
|
+
L.push(`- **Known weaknesses:** ${card.weaknesses.join(" ")}`);
|
|
263
|
+
L.push(`- **Primary sensitivities:** ${card.sensitivities.join(" ")}`);
|
|
264
|
+
L.push(`- **Literature:** ${card.literature.join("; ")}`);
|
|
265
|
+
L.push(`- **Parameters this analysis ran with:** \`${parameterLines(use.parameters)}\``);
|
|
266
|
+
if (use.resultSummary) {
|
|
267
|
+
const s = use.resultSummary;
|
|
268
|
+
const bits: string[] = [];
|
|
269
|
+
if (s.ultimate !== undefined) bits.push(`ultimate ${fmtNumber(s.ultimate)}`);
|
|
270
|
+
if (s.ibnr !== undefined) bits.push(`IBNR ${fmtNumber(s.ibnr)}`);
|
|
271
|
+
if (s.unpaid !== undefined) bits.push(`unpaid ${fmtNumber(s.unpaid)}`);
|
|
272
|
+
if (s.standardError !== undefined) bits.push(`standard error ${fmtNumber(s.standardError)}`);
|
|
273
|
+
if (bits.length > 0) L.push(`- **Indicated:** ${bits.join(", ")}`);
|
|
274
|
+
}
|
|
275
|
+
L.push("");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 4b. Cross-implementation verification (interchange spec 5) — rendered
|
|
279
|
+
// only when referee reports were actually provided.
|
|
280
|
+
if (input.crossImplementation !== undefined && input.crossImplementation.length > 0) {
|
|
281
|
+
L.push("## 4b. Cross-implementation verification");
|
|
282
|
+
L.push("");
|
|
283
|
+
L.push(
|
|
284
|
+
"The computations were cross-checked against independent implementations via the actuarial-interchange referee. Each row is one referee report (relative deviations; SE = standard error):",
|
|
285
|
+
);
|
|
286
|
+
L.push("");
|
|
287
|
+
L.push(
|
|
288
|
+
...mdTable(
|
|
289
|
+
[
|
|
290
|
+
"Engine A",
|
|
291
|
+
"Engine B",
|
|
292
|
+
"Profile",
|
|
293
|
+
"Max deviation (central)",
|
|
294
|
+
"Max deviation (SE)",
|
|
295
|
+
"Tolerance (central / SE)",
|
|
296
|
+
"Verdict",
|
|
297
|
+
],
|
|
298
|
+
input.crossImplementation.map((doc) => {
|
|
299
|
+
const r = doc.report;
|
|
300
|
+
return [
|
|
301
|
+
engineLabel(r.engines.a),
|
|
302
|
+
engineLabel(r.engines.b),
|
|
303
|
+
r.engines.a.conventionProfile ?? r.engines.b.conventionProfile ?? "—",
|
|
304
|
+
fmtDeviation(maxAbsDeviation(r, (cell) => [cell.ultimate, cell.unpaid])),
|
|
305
|
+
fmtDeviation(maxAbsDeviation(r, (cell) => [cell.standardError])),
|
|
306
|
+
`${fmtDeviation(r.tolerance.central)} / ${fmtDeviation(r.tolerance.standardError)}`,
|
|
307
|
+
verdictLabel(r.verdict),
|
|
308
|
+
];
|
|
309
|
+
}),
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
for (const doc of input.crossImplementation) {
|
|
313
|
+
const r = doc.report;
|
|
314
|
+
if (r.warnings.length === 0) continue;
|
|
315
|
+
L.push("");
|
|
316
|
+
L.push(`**Warnings — ${engineLabel(r.engines.a)} vs ${engineLabel(r.engines.b)}:**`);
|
|
317
|
+
L.push("");
|
|
318
|
+
for (const w of r.warnings) L.push(`- ${renderUntrusted(w)}`);
|
|
319
|
+
}
|
|
320
|
+
L.push("");
|
|
321
|
+
L.push(CROSS_IMPLEMENTATION_BOILERPLATE);
|
|
322
|
+
L.push("");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 5. Assumptions and judgments.
|
|
326
|
+
L.push("## 5. Assumptions and judgments");
|
|
327
|
+
L.push("");
|
|
328
|
+
if (input.ledger && input.ledger.entries.length > 0) {
|
|
329
|
+
L.push(
|
|
330
|
+
"Every recorded assumption, distinguishing machine defaults from human or agent judgment (judgment entries carry their rationale and source):",
|
|
331
|
+
);
|
|
332
|
+
L.push("");
|
|
333
|
+
L.push(
|
|
334
|
+
...mdTable(
|
|
335
|
+
["#", "When", "Actor", "Assumption", "Value", "Rationale / source"],
|
|
336
|
+
input.ledger.entries.map((e) => [
|
|
337
|
+
String(e.seq),
|
|
338
|
+
e.timestamp,
|
|
339
|
+
e.actor,
|
|
340
|
+
e.field,
|
|
341
|
+
`\`${canonicalJson(e.value)}\``,
|
|
342
|
+
[
|
|
343
|
+
e.rationale === undefined ? "" : renderUntrusted(e.rationale),
|
|
344
|
+
e.source ? `(source: ${renderUntrusted(e.source)})` : "",
|
|
345
|
+
]
|
|
346
|
+
.filter(Boolean)
|
|
347
|
+
.join(" ") || "—",
|
|
348
|
+
]),
|
|
349
|
+
),
|
|
350
|
+
);
|
|
351
|
+
} else {
|
|
352
|
+
L.push("No assumption ledger was attached; assumptions are visible only through Section 4's parameters.");
|
|
353
|
+
}
|
|
354
|
+
L.push("");
|
|
355
|
+
|
|
356
|
+
// 6. Changes from the prior analysis.
|
|
357
|
+
L.push("## 6. Changes from the prior analysis");
|
|
358
|
+
L.push("");
|
|
359
|
+
if (input.priorComparison) {
|
|
360
|
+
const pc = input.priorComparison;
|
|
361
|
+
if (pc.priorLabel) L.push(`Prior analysis: ${pc.priorLabel}.`);
|
|
362
|
+
if (pc.priorReserve !== undefined && pc.currentReserve !== undefined) {
|
|
363
|
+
L.push(
|
|
364
|
+
`Carried indication moved from ${fmtNumber(pc.priorReserve)} to ${fmtNumber(pc.currentReserve)} (${fmtNumber(pc.currentReserve - pc.priorReserve)}).`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
const { added, removed, changed } = pc.changes;
|
|
368
|
+
if (added.length === 0 && removed.length === 0 && changed.length === 0) {
|
|
369
|
+
L.push("No assumption or method changes were recorded against the prior analysis.");
|
|
370
|
+
} else {
|
|
371
|
+
if (changed.length > 0) {
|
|
372
|
+
L.push("");
|
|
373
|
+
L.push(
|
|
374
|
+
...mdTable(
|
|
375
|
+
["Assumption", "Prior", "Current"],
|
|
376
|
+
changed.map((c) => [c.field, `\`${canonicalJson(c.priorValue)}\``, `\`${canonicalJson(c.currentValue)}\``]),
|
|
377
|
+
),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (added.length > 0) L.push(`- **New assumptions:** ${added.join(", ")}`);
|
|
381
|
+
if (removed.length > 0) L.push(`- **Removed assumptions:** ${removed.join(", ")}`);
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
L.push("No prior-analysis comparison was attached.");
|
|
385
|
+
}
|
|
386
|
+
L.push("");
|
|
387
|
+
|
|
388
|
+
// 7. Reliances and limitations.
|
|
389
|
+
L.push("## 7. Reliances and limitations");
|
|
390
|
+
L.push("");
|
|
391
|
+
if (input.reliances && input.reliances.length > 0) {
|
|
392
|
+
for (const r of input.reliances) L.push(`- **Reliance:** ${r}`);
|
|
393
|
+
} else {
|
|
394
|
+
L.push("- No reliances on data or analyses supplied by others were recorded.");
|
|
395
|
+
}
|
|
396
|
+
if (input.limitations && input.limitations.length > 0) {
|
|
397
|
+
for (const lim of input.limitations) L.push(`- **Limitation:** ${lim}`);
|
|
398
|
+
}
|
|
399
|
+
L.push("");
|
|
400
|
+
|
|
401
|
+
// 8. Reproducibility.
|
|
402
|
+
L.push("## 8. Reproducibility");
|
|
403
|
+
L.push("");
|
|
404
|
+
// The tag covers the WHOLE disclosed input, not a subset. It previously
|
|
405
|
+
// hashed only metadata/methods/ledger, so fabricating the entire data-review
|
|
406
|
+
// section or swapping the named preparer did not move it — while this
|
|
407
|
+
// sentence presented it as certifying the document. Excluded on purpose:
|
|
408
|
+
// generatedAt (a timestamp is not content) and sdkVersion (stated in clear
|
|
409
|
+
// text beside the tag, so a reader checks it directly).
|
|
410
|
+
const { generatedAt: _generatedAt, sdkVersion: _sdkVersion, ...disclosed } = input;
|
|
411
|
+
// canonicalJson refuses undefined on purpose; an absent section and an
|
|
412
|
+
// explicitly-undefined one are the same disclosure, so both hash identically.
|
|
413
|
+
const hashable = Object.fromEntries(
|
|
414
|
+
Object.entries(disclosed).filter(([, value]) => value !== undefined),
|
|
415
|
+
);
|
|
416
|
+
const inputHash = fnv1a64(canonicalJson({ ...hashable, ledger: input.ledger ?? null }));
|
|
417
|
+
L.push(
|
|
418
|
+
`This disclosure derives deterministically from its inputs (integrity tag \`${inputHash}\` over every disclosed section, actuarial-ts v${input.sdkVersion}). Rerunning the same inputs on the same SDK version reproduces the analysis and this document byte for byte; pair with a reproducibility bundle for auditor/examiner requests under ASOP No. 21.`,
|
|
419
|
+
);
|
|
420
|
+
L.push("");
|
|
421
|
+
|
|
422
|
+
return L.join("\n");
|
|
423
|
+
}
|
package/src/index.ts
ADDED
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assumption ledger: an append-only record of every assumption an analysis
|
|
3
|
+
* used, separating machine defaults from actuarial/agent judgment, feeding
|
|
4
|
+
* the ASOP 41 assumptions-and-judgments and changes-from-prior disclosures.
|
|
5
|
+
*
|
|
6
|
+
* Ground truth:
|
|
7
|
+
* - The ledger is IMMUTABLE: `recordAssumption` returns a NEW frozen ledger;
|
|
8
|
+
* the input ledger, its entries array, and every recorded entry are frozen.
|
|
9
|
+
* Nothing is ever edited or deleted — a corrected assumption is a new entry
|
|
10
|
+
* for the same field, and the LATEST entry per field is the live value.
|
|
11
|
+
* - `seq` is assigned by the ledger (entries.length + 1), never by the caller.
|
|
12
|
+
* - `rationale` is REQUIRED (throws MISSING_RATIONALE) whenever
|
|
13
|
+
* actor !== "default": undocumented judgment is exactly what the ledger
|
|
14
|
+
* exists to prevent. Machine defaults need no rationale — the method's
|
|
15
|
+
* model card documents them.
|
|
16
|
+
* - `changedAssumptions` diffs the LATEST entry per field on each side and
|
|
17
|
+
* compares values by canonical JSON, so key insertion order never causes a
|
|
18
|
+
* false "changed". Output field lists are lexicographically sorted for
|
|
19
|
+
* deterministic disclosure rendering.
|
|
20
|
+
* - Timestamps are caller-supplied ISO strings (purity: no clock reads).
|
|
21
|
+
*
|
|
22
|
+
* These utilities are designed to support the actuary's compliance with
|
|
23
|
+
* ASOP No. 41; responsibility for compliance remains with the credentialed
|
|
24
|
+
* actuary.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { canonicalJson, ComplianceError } from "./bundle.js";
|
|
28
|
+
|
|
29
|
+
/** Who set the assumption: the machine default, a credentialed actuary, or an AI agent. */
|
|
30
|
+
export type AssumptionActor = "default" | "actuary" | "agent";
|
|
31
|
+
|
|
32
|
+
/** JSON-representable assumption value (the ledger stores data, not behavior). */
|
|
33
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
34
|
+
|
|
35
|
+
export interface AssumptionEntry {
|
|
36
|
+
/** 1-based position in the ledger; assigned by recordAssumption, never by the caller. */
|
|
37
|
+
seq: number;
|
|
38
|
+
/** Caller-supplied ISO timestamp (purity: no clock reads). */
|
|
39
|
+
timestamp: string;
|
|
40
|
+
actor: AssumptionActor;
|
|
41
|
+
/** Dotted path identifying the assumption, e.g. "chainLadder.tailFactor". */
|
|
42
|
+
field: string;
|
|
43
|
+
value: JsonValue;
|
|
44
|
+
/** The value this entry superseded, when the caller knows it. */
|
|
45
|
+
previousValue?: JsonValue;
|
|
46
|
+
/** Where the value came from (e.g. "Friedland Table 15", "rate filing 2024-07"). */
|
|
47
|
+
source?: string;
|
|
48
|
+
/** Why the value was chosen. REQUIRED when actor !== "default". */
|
|
49
|
+
rationale?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A not-yet-recorded entry: everything but the ledger-assigned seq. */
|
|
53
|
+
export type NewAssumptionEntry = Omit<AssumptionEntry, "seq">;
|
|
54
|
+
|
|
55
|
+
export interface AssumptionLedger {
|
|
56
|
+
readonly entries: readonly AssumptionEntry[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** An empty, frozen ledger. */
|
|
60
|
+
export function createLedger(): AssumptionLedger {
|
|
61
|
+
return Object.freeze({ entries: Object.freeze([]) as readonly AssumptionEntry[] });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Appends an entry, returning a NEW frozen ledger; the input ledger is
|
|
66
|
+
* untouched. Assigns seq = entries.length + 1. Throws
|
|
67
|
+
* ComplianceError("MISSING_RATIONALE") when actor !== "default" and rationale
|
|
68
|
+
* is missing or blank.
|
|
69
|
+
*/
|
|
70
|
+
export function recordAssumption(ledger: AssumptionLedger, entry: NewAssumptionEntry): AssumptionLedger {
|
|
71
|
+
if (entry.actor !== "default" && (entry.rationale === undefined || entry.rationale.trim() === "")) {
|
|
72
|
+
throw new ComplianceError(
|
|
73
|
+
"MISSING_RATIONALE",
|
|
74
|
+
`assumption "${entry.field}" set by actor "${entry.actor}" requires a rationale; only actor "default" may omit one`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const recorded: AssumptionEntry = Object.freeze({ ...entry, seq: ledger.entries.length + 1 });
|
|
78
|
+
return Object.freeze({ entries: Object.freeze([...ledger.entries, recorded]) });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Entries that represent judgment (actor !== "default"), in ledger order. */
|
|
82
|
+
export function judgmentEntries(ledger: AssumptionLedger): AssumptionEntry[] {
|
|
83
|
+
return ledger.entries.filter((e) => e.actor !== "default");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface AssumptionValueChange {
|
|
87
|
+
field: string;
|
|
88
|
+
priorValue: JsonValue;
|
|
89
|
+
currentValue: JsonValue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The ASOP 41 change-disclosure diff between two analyses' ledgers. */
|
|
93
|
+
export interface ChangedAssumptions {
|
|
94
|
+
/** Fields set in current but never set in prior. */
|
|
95
|
+
added: string[];
|
|
96
|
+
/** Fields set in prior but never set in current. */
|
|
97
|
+
removed: string[];
|
|
98
|
+
/** Fields set on both sides whose latest values differ (canonical-JSON inequality). */
|
|
99
|
+
changed: AssumptionValueChange[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Latest entry per field; ledger order is seq order, so the last write wins. */
|
|
103
|
+
function latestByField(ledger: AssumptionLedger): Map<string, AssumptionEntry> {
|
|
104
|
+
const latest = new Map<string, AssumptionEntry>();
|
|
105
|
+
for (const entry of ledger.entries) latest.set(entry.field, entry);
|
|
106
|
+
return latest;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Diffs the LATEST entry per field between a prior analysis's ledger and the
|
|
111
|
+
* current one. Values compare by canonical JSON (key order never matters).
|
|
112
|
+
* All output lists are sorted by field for deterministic rendering.
|
|
113
|
+
*/
|
|
114
|
+
export function changedAssumptions(
|
|
115
|
+
prior: AssumptionLedger,
|
|
116
|
+
current: AssumptionLedger,
|
|
117
|
+
): ChangedAssumptions {
|
|
118
|
+
const priorLatest = latestByField(prior);
|
|
119
|
+
const currentLatest = latestByField(current);
|
|
120
|
+
const added: string[] = [];
|
|
121
|
+
const removed: string[] = [];
|
|
122
|
+
const changed: AssumptionValueChange[] = [];
|
|
123
|
+
const fields = [...new Set([...priorLatest.keys(), ...currentLatest.keys()])].sort();
|
|
124
|
+
for (const field of fields) {
|
|
125
|
+
const before = priorLatest.get(field);
|
|
126
|
+
const after = currentLatest.get(field);
|
|
127
|
+
if (before === undefined) {
|
|
128
|
+
if (after !== undefined) added.push(field);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (after === undefined) {
|
|
132
|
+
removed.push(field);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (canonicalJson(before.value) !== canonicalJson(after.value)) {
|
|
136
|
+
changed.push({ field, priorValue: before.value, currentValue: after.value });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { added, removed, changed };
|
|
140
|
+
}
|