@actuarial-ts/core 0.7.0 → 0.8.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 (115) hide show
  1. package/README.md +46 -2
  2. package/dist/analysisRecipe.d.ts +55 -0
  3. package/dist/analysisRecipe.d.ts.map +1 -0
  4. package/dist/analysisRecipe.js +627 -0
  5. package/dist/analysisRecipe.js.map +1 -0
  6. package/dist/classifications.d.ts +30 -0
  7. package/dist/classifications.d.ts.map +1 -0
  8. package/dist/classifications.js +207 -0
  9. package/dist/classifications.js.map +1 -0
  10. package/dist/customizationContracts.d.ts +749 -0
  11. package/dist/customizationContracts.d.ts.map +1 -0
  12. package/dist/customizationContracts.js +19 -0
  13. package/dist/customizationContracts.js.map +1 -0
  14. package/dist/descriptiveStatistics.d.ts +8 -0
  15. package/dist/descriptiveStatistics.d.ts.map +1 -0
  16. package/dist/descriptiveStatistics.js +283 -0
  17. package/dist/descriptiveStatistics.js.map +1 -0
  18. package/dist/diagnosticAggregation.d.ts.map +1 -1
  19. package/dist/diagnosticAggregation.js +16 -0
  20. package/dist/diagnosticAggregation.js.map +1 -1
  21. package/dist/diagnosticFreeze.d.ts +4 -0
  22. package/dist/diagnosticFreeze.d.ts.map +1 -0
  23. package/dist/diagnosticFreeze.js +37 -0
  24. package/dist/diagnosticFreeze.js.map +1 -0
  25. package/dist/diagnosticIdentityStream.d.ts +4 -0
  26. package/dist/diagnosticIdentityStream.d.ts.map +1 -1
  27. package/dist/diagnosticIdentityStream.js +32 -2
  28. package/dist/diagnosticIdentityStream.js.map +1 -1
  29. package/dist/diagnosticPreparation.d.ts +21 -0
  30. package/dist/diagnosticPreparation.d.ts.map +1 -1
  31. package/dist/diagnosticPreparation.js +231 -19
  32. package/dist/diagnosticPreparation.js.map +1 -1
  33. package/dist/diagnosticReview.d.ts.map +1 -1
  34. package/dist/diagnosticReview.js +43 -25
  35. package/dist/diagnosticReview.js.map +1 -1
  36. package/dist/diagnosticReviewSources.d.ts.map +1 -1
  37. package/dist/diagnosticReviewSources.js +25 -4
  38. package/dist/diagnosticReviewSources.js.map +1 -1
  39. package/dist/diagnosticReviewStore.d.ts +2 -0
  40. package/dist/diagnosticReviewStore.d.ts.map +1 -1
  41. package/dist/diagnosticReviewStore.js +110 -36
  42. package/dist/diagnosticReviewStore.js.map +1 -1
  43. package/dist/diagnosticRunner.d.ts.map +1 -1
  44. package/dist/diagnosticRunner.js +1 -8
  45. package/dist/diagnosticRunner.js.map +1 -1
  46. package/dist/exposureEarning.d.ts +12 -0
  47. package/dist/exposureEarning.d.ts.map +1 -0
  48. package/dist/exposureEarning.js +276 -0
  49. package/dist/exposureEarning.js.map +1 -0
  50. package/dist/exposureMovements.d.ts +8 -0
  51. package/dist/exposureMovements.d.ts.map +1 -0
  52. package/dist/exposureMovements.js +70 -0
  53. package/dist/exposureMovements.js.map +1 -0
  54. package/dist/exposureRevisions.d.ts +13 -0
  55. package/dist/exposureRevisions.d.ts.map +1 -0
  56. package/dist/exposureRevisions.js +161 -0
  57. package/dist/exposureRevisions.js.map +1 -0
  58. package/dist/financialPipeline.d.ts +14 -0
  59. package/dist/financialPipeline.d.ts.map +1 -0
  60. package/dist/financialPipeline.js +75 -0
  61. package/dist/financialPipeline.js.map +1 -0
  62. package/dist/financialTerms.d.ts +20 -0
  63. package/dist/financialTerms.d.ts.map +1 -0
  64. package/dist/financialTerms.js +205 -0
  65. package/dist/financialTerms.js.map +1 -0
  66. package/dist/fnvAccumulator.d.ts.map +1 -1
  67. package/dist/fnvAccumulator.js +11 -5
  68. package/dist/fnvAccumulator.js.map +1 -1
  69. package/dist/historyPreparation.d.ts +8 -0
  70. package/dist/historyPreparation.d.ts.map +1 -0
  71. package/dist/historyPreparation.js +557 -0
  72. package/dist/historyPreparation.js.map +1 -0
  73. package/dist/index.d.ts +15 -2
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +15 -2
  76. package/dist/index.js.map +1 -1
  77. package/dist/policyPeriods.d.ts +29 -0
  78. package/dist/policyPeriods.d.ts.map +1 -0
  79. package/dist/policyPeriods.js +322 -0
  80. package/dist/policyPeriods.js.map +1 -0
  81. package/dist/populations.d.ts +11 -0
  82. package/dist/populations.d.ts.map +1 -0
  83. package/dist/populations.js +264 -0
  84. package/dist/populations.js.map +1 -0
  85. package/dist/reservingAdapter.d.ts +16 -0
  86. package/dist/reservingAdapter.d.ts.map +1 -0
  87. package/dist/reservingAdapter.js +74 -0
  88. package/dist/reservingAdapter.js.map +1 -0
  89. package/dist/version.d.ts +1 -1
  90. package/dist/version.js +1 -1
  91. package/package.json +2 -2
  92. package/src/analysisRecipe.ts +696 -0
  93. package/src/classifications.ts +231 -0
  94. package/src/customizationContracts.ts +763 -0
  95. package/src/descriptiveStatistics.ts +320 -0
  96. package/src/diagnosticAggregation.ts +16 -0
  97. package/src/diagnosticFreeze.ts +37 -0
  98. package/src/diagnosticIdentityStream.ts +37 -2
  99. package/src/diagnosticPreparation.ts +259 -26
  100. package/src/diagnosticReview.ts +49 -27
  101. package/src/diagnosticReviewSources.ts +22 -3
  102. package/src/diagnosticReviewStore.ts +139 -54
  103. package/src/diagnosticRunner.ts +1 -11
  104. package/src/exposureEarning.ts +314 -0
  105. package/src/exposureMovements.ts +78 -0
  106. package/src/exposureRevisions.ts +186 -0
  107. package/src/financialPipeline.ts +93 -0
  108. package/src/financialTerms.ts +263 -0
  109. package/src/fnvAccumulator.ts +11 -5
  110. package/src/historyPreparation.ts +645 -0
  111. package/src/index.ts +28 -1
  112. package/src/policyPeriods.ts +375 -0
  113. package/src/populations.ts +307 -0
  114. package/src/reservingAdapter.ts +98 -0
  115. package/src/version.ts +1 -1
@@ -0,0 +1,627 @@
1
+ import { canonicalJson, fnv1a64 } from "./canonical.js";
2
+ import { calculateAnalysisStatistic } from "./descriptiveStatistics.js";
3
+ import { isDiagnosticToken, snapshotDiagnosticJson } from "./diagnosticRuntime.js";
4
+ import { earnAndAllocateExposure } from "./exposureEarning.js";
5
+ import { selectExposureRevisions, selectExposureValuation } from "./exposureRevisions.js";
6
+ import { applyFinancialTerms } from "./financialTerms.js";
7
+ import { applyFinancialPipeline } from "./financialPipeline.js";
8
+ import { prepareHistoricalDataset } from "./historyPreparation.js";
9
+ import { assignAnalysisOrigin } from "./policyPeriods.js";
10
+ import { evaluateAnalysisScope } from "./populations.js";
11
+ import { DiagnosticValidationError } from "./types.js";
12
+ function key(observation) {
13
+ return canonicalJson([observation.sourceNamespace, observation.claimId]);
14
+ }
15
+ function recipeIssues(recipe, definition, history) {
16
+ const issues = [];
17
+ if (recipe.analysisDefinitionId !== definition.id)
18
+ issues.push({ domain: "configuration", code: "invalid-input-relationship", path: "$.recipe.analysisDefinitionId", message: "Recipe and analysis definition IDs do not match" });
19
+ if (recipe.steps[0]?.kind !== "prepare-history")
20
+ issues.push({ domain: "configuration", code: "invalid-configuration", path: "$.recipe.steps", message: "Recipe must begin with prepare-history" });
21
+ const supported = new Set(["prepare-history", "assign-periods", "earn-exposure", "apply-financial-terms", "apply-financial-pipeline", "calculate"]);
22
+ let priorOrder = -1;
23
+ let hasCalculate = false;
24
+ const stepOrder = {
25
+ "prepare-history": 0,
26
+ "assign-periods": 1,
27
+ "earn-exposure": 2,
28
+ "apply-financial-terms": 3,
29
+ "apply-financial-pipeline": 3,
30
+ calculate: 4,
31
+ };
32
+ recipe.steps.forEach((step, index) => {
33
+ if (!supported.has(step.kind))
34
+ issues.push({ domain: "configuration", code: "invalid-configuration", path: `$.recipe.steps[${index}]`, message: `Recipe step ${step.kind} is not executable by this runner` });
35
+ const order = stepOrder[step.kind];
36
+ if (order !== undefined && order < priorOrder)
37
+ issues.push({ domain: "configuration", code: "invalid-configuration", path: `$.recipe.steps[${index}]`, message: "Recipe steps are out of execution order" });
38
+ if (order !== undefined)
39
+ priorOrder = order;
40
+ if (step.kind === "prepare-history" && step.historyContractId !== history.datasetId)
41
+ issues.push({ domain: "configuration", code: "unknown-reference", path: `$.recipe.steps[${index}].historyContractId`, message: "prepare-history references another history contract" });
42
+ if (step.kind === "assign-periods" && step.analysisDefinitionId !== definition.id)
43
+ issues.push({ domain: "configuration", code: "unknown-reference", path: `$.recipe.steps[${index}].analysisDefinitionId`, message: "assign-periods references another definition" });
44
+ if (step.kind === "calculate")
45
+ hasCalculate = true;
46
+ if (step.kind === "calculate")
47
+ step.measureIds.forEach((id) => {
48
+ if (!definition.measures.some((measure) => measure.id === id))
49
+ issues.push({ domain: "configuration", code: "unknown-reference", path: `$.recipe.steps[${index}].measureIds`, message: `Unknown measure ${id}` });
50
+ });
51
+ if (step.kind === "earn-exposure")
52
+ step.exposureMeasureIds.forEach((id) => {
53
+ if (!definition.exposures.some((exposure) => exposure.id === id))
54
+ issues.push({ domain: "configuration", code: "unknown-reference", path: `$.recipe.steps[${index}].exposureMeasureIds`, message: `Unknown exposure measure ${id}` });
55
+ });
56
+ if (step.kind === "apply-financial-terms")
57
+ step.termIds.forEach((id) => {
58
+ if (!definition.financialTerms.some((term) => term.id === id))
59
+ issues.push({ domain: "configuration", code: "unknown-reference", path: `$.recipe.steps[${index}].termIds`, message: `Unknown financial term ${id}` });
60
+ });
61
+ if (step.kind === "apply-financial-pipeline") {
62
+ const stageIds = new Set([...(definition.financialAdjustments ?? []).map((item) => item.id), ...definition.financialTerms.map((item) => item.id)]);
63
+ step.stageIds.forEach((id) => {
64
+ if (!stageIds.has(id))
65
+ issues.push({ domain: "configuration", code: "unknown-reference", path: `$.recipe.steps[${index}].stageIds`, message: `Unknown financial pipeline stage ${id}` });
66
+ });
67
+ }
68
+ });
69
+ if (recipe.steps.filter((step) => step.kind === "apply-financial-terms" || step.kind === "apply-financial-pipeline").length > 1)
70
+ issues.push({ domain: "configuration", code: "invalid-configuration", path: "$.recipe.steps", message: "Recipe permits one financial application step" });
71
+ if (!hasCalculate)
72
+ issues.push({ domain: "configuration", code: "missing-required", path: "$.recipe.steps", message: "Recipe requires a calculate step" });
73
+ return issues;
74
+ }
75
+ function selectedAt(observations, evaluationDate, mode) {
76
+ if (mode === "exact")
77
+ return observations.filter((observation) => observation.evaluationDate === evaluationDate);
78
+ const selected = new Map();
79
+ for (const observation of observations) {
80
+ if (observation.evaluationDate > evaluationDate)
81
+ continue;
82
+ const observationKey = key(observation);
83
+ const prior = selected.get(observationKey);
84
+ if (prior === undefined || prior.evaluationDate < observation.evaluationDate)
85
+ selected.set(observationKey, observation);
86
+ }
87
+ return [...selected.values()];
88
+ }
89
+ function histories(observations) {
90
+ const result = new Map();
91
+ for (const observation of observations) {
92
+ const observationKey = key(observation);
93
+ const existing = result.get(observationKey);
94
+ if (existing === undefined)
95
+ result.set(observationKey, [observation]);
96
+ else
97
+ existing.push(observation);
98
+ }
99
+ return result;
100
+ }
101
+ function selectScope(definition, scope, observations, historyByClaim) {
102
+ const matches = [];
103
+ const findings = [];
104
+ for (const observation of observations) {
105
+ const membership = evaluateAnalysisScope({
106
+ scope,
107
+ observation,
108
+ claimHistory: historyByClaim.get(key(observation)) ?? [],
109
+ dimensions: definition.dimensions,
110
+ });
111
+ if (membership.outcome === "match")
112
+ matches.push(observation);
113
+ else if (membership.outcome === "unknown" && membership.finding !== null)
114
+ findings.push(membership.finding);
115
+ }
116
+ return { matches, findings };
117
+ }
118
+ function maxEvaluationDate(observations) {
119
+ return observations.reduce((latest, observation) => observation.evaluationDate > latest ? observation.evaluationDate : latest, "0000-01-01");
120
+ }
121
+ /** Executes the closed, deterministic recipe vocabulary over loss history and exposure inputs. */
122
+ export function runAnalysisRecipe(input) {
123
+ const issues = recipeIssues(input.recipe, input.definition, input.history);
124
+ if (issues.length > 0)
125
+ throw new DiagnosticValidationError(issues);
126
+ const prepared = prepareHistoricalDataset(input.history);
127
+ const evaluationDate = input.evaluationDate ?? maxEvaluationDate(prepared.observations);
128
+ const selected = selectedAt(prepared.observations, evaluationDate, input.definition.period.observationSelection);
129
+ const byHistory = histories(prepared.observations);
130
+ const financialSelection = selectScope(input.definition, input.definition.scopes.financial, selected, byHistory);
131
+ const reportingSelection = selectScope(input.definition, input.definition.scopes.reporting, selected, byHistory);
132
+ const financialPopulation = financialSelection.matches;
133
+ const reportingPopulation = reportingSelection.matches;
134
+ const reportingKeys = new Set(reportingPopulation.map(key));
135
+ let effective = financialPopulation;
136
+ let financialExecutionFindings = [...financialSelection.findings];
137
+ const reportingExecutionFindings = [...reportingSelection.findings];
138
+ const financialStep = input.recipe.steps.find((step) => step.kind === "apply-financial-terms");
139
+ if (financialStep?.kind === "apply-financial-terms") {
140
+ const terms = financialStep.termIds.map((id) => input.definition.financialTerms.find((term) => term.id === id));
141
+ const applied = applyFinancialTerms({
142
+ observations: financialPopulation,
143
+ terms,
144
+ completeFinancialPopulation: true,
145
+ measureDefinitions: input.definition.measures,
146
+ reportingClaims: reportingPopulation.map((observation) => ({ sourceNamespace: observation.sourceNamespace, claimId: observation.claimId })),
147
+ });
148
+ if (applied.status === "available") {
149
+ const allocated = new Map(applied.claims.map((claim) => [canonicalJson([claim.sourceNamespace, claim.claimId]), claim]));
150
+ const sourceMeasureIds = new Set(terms.flatMap((term) => {
151
+ const measure = input.definition.measures.find((candidate) => candidate.id === term.measureId);
152
+ return measure?.statistic.kind === "sum" ? [measure.statistic.measureId] : [];
153
+ }));
154
+ effective = financialPopulation.map((observation) => {
155
+ const result = allocated.get(key(observation));
156
+ if (result === undefined)
157
+ return observation;
158
+ const measures = { ...observation.measures };
159
+ sourceMeasureIds.forEach((id) => { measures[id] = result.outputAmount; });
160
+ return snapshotDiagnosticJson({ ...observation, measures });
161
+ });
162
+ }
163
+ else
164
+ financialExecutionFindings = [...financialExecutionFindings, ...applied.findings];
165
+ }
166
+ const pipelineStep = input.recipe.steps.find((step) => step.kind === "apply-financial-pipeline");
167
+ if (pipelineStep?.kind === "apply-financial-pipeline") {
168
+ const stages = pipelineStep.stageIds.map((id) => {
169
+ const adjustment = input.definition.financialAdjustments?.find((candidate) => candidate.id === id);
170
+ return adjustment === undefined
171
+ ? { kind: "terms", id, terms: [input.definition.financialTerms.find((candidate) => candidate.id === id)] }
172
+ : { kind: "adjustment", adjustment };
173
+ });
174
+ const applied = applyFinancialPipeline({
175
+ observations: financialPopulation,
176
+ amountMeasureId: pipelineStep.amountMeasureId,
177
+ inputUnit: input.definition.measures.find((measure) => measure.statistic.kind === "sum" && measure.statistic.measureId === pipelineStep.amountMeasureId)?.unit ?? "unknown-unit",
178
+ stages,
179
+ completeFinancialPopulation: true,
180
+ reportingClaims: reportingPopulation.map((observation) => ({ sourceNamespace: observation.sourceNamespace, claimId: observation.claimId })),
181
+ });
182
+ if (applied.status === "available") {
183
+ const amounts = new Map(applied.claims.map((claim) => [canonicalJson([claim.sourceNamespace, claim.claimId]), claim.outputAmount]));
184
+ effective = financialPopulation.map((observation) => {
185
+ const outputAmount = amounts.get(key(observation));
186
+ return outputAmount === undefined
187
+ ? observation
188
+ : snapshotDiagnosticJson({ ...observation, measures: { ...observation.measures, [pipelineStep.amountMeasureId]: outputAmount } });
189
+ });
190
+ }
191
+ else
192
+ financialExecutionFindings = [...financialExecutionFindings, ...applied.findings];
193
+ }
194
+ const exposedByTarget = new Map();
195
+ const exposureFindingsByMeasure = new Map();
196
+ const exposureCalculationRevisions = [];
197
+ const exposureLineageRevisions = [];
198
+ for (const step of input.recipe.steps) {
199
+ if (step.kind !== "earn-exposure")
200
+ continue;
201
+ for (const id of step.exposureMeasureIds) {
202
+ const definition = input.definition.exposures.find((candidate) => candidate.id === id);
203
+ if (definition === undefined)
204
+ continue;
205
+ const scope = input.definition.scopes.exposure.find((candidate) => candidate.id === definition.scopeId);
206
+ const measureFindings = exposureFindingsByMeasure.get(id) ?? [];
207
+ const valuation = selectExposureValuation({ observations: (input.exposureObservations ?? []).filter((candidate) => candidate.measureId === id), scope, evaluationDate });
208
+ measureFindings.push(...valuation.findings);
209
+ const revisions = selectExposureRevisions({
210
+ observations: valuation.observations,
211
+ selection: input.exposureRevisionSelection ?? { revisionView: { kind: "restated" }, revisionPrecedence: "sequence" },
212
+ });
213
+ measureFindings.push(...revisions.findings);
214
+ exposureCalculationRevisions.push(`${id}:${revisions.calculationRevisionId}`);
215
+ exposureLineageRevisions.push(`${id}:${revisions.lineageRevisionId}`);
216
+ for (const observation of revisions.observations) {
217
+ const result = earnAndAllocateExposure({ definition, scope, observation, targets: input.exposureTargets ?? [] });
218
+ measureFindings.push(...result.findings);
219
+ for (const allocation of result.allocations) {
220
+ const measures = exposedByTarget.get(allocation.targetId) ?? new Map();
221
+ measures.set(id, (measures.get(id) ?? 0) + allocation.value);
222
+ exposedByTarget.set(allocation.targetId, measures);
223
+ }
224
+ }
225
+ exposureFindingsByMeasure.set(id, measureFindings);
226
+ }
227
+ }
228
+ const groups = new Map();
229
+ const originFailures = [];
230
+ for (const observation of effective) {
231
+ const origin = assignAnalysisOrigin(observation, input.definition.period, input.definition.policySchedule);
232
+ if (origin.status !== "assigned" || origin.originId === null || origin.label === null) {
233
+ if (origin.finding !== null)
234
+ originFailures.push({ observation, finding: origin.finding });
235
+ continue;
236
+ }
237
+ const group = groups.get(origin.originId);
238
+ if (group === undefined)
239
+ groups.set(origin.originId, { label: origin.label, observations: [observation] });
240
+ else
241
+ group.observations.push(observation);
242
+ }
243
+ const measureIds = new Set(input.recipe.steps.filter((step) => step.kind === "calculate").flatMap((step) => step.measureIds));
244
+ const values = [];
245
+ for (const [originId, group] of [...groups].sort(([left], [right]) => left.localeCompare(right))) {
246
+ for (const measure of input.definition.measures.filter((candidate) => measureIds.has(candidate.id))) {
247
+ let measureObservations = measure.populationScopeId === input.definition.scopes.financial.id
248
+ ? group.observations
249
+ : group.observations.filter((observation) => reportingKeys.has(key(observation)));
250
+ const conditionFindings = [];
251
+ if (measure.condition !== undefined)
252
+ measureObservations = measureObservations.filter((observation) => {
253
+ const membership = evaluateAnalysisScope({
254
+ scope: { id: `measure-${measure.id}`, subject: "claim", predicate: measure.condition, classification: { kind: "as-observed" } },
255
+ observation,
256
+ claimHistory: byHistory.get(key(observation)) ?? [],
257
+ dimensions: input.definition.dimensions,
258
+ });
259
+ if (membership.outcome === "unknown" && membership.finding !== null)
260
+ conditionFindings.push(membership.finding);
261
+ return membership.outcome === "match";
262
+ });
263
+ const exposureFindings = measure.statistic.kind === "ratio"
264
+ ? exposureFindingsByMeasure.get(measure.statistic.denominatorMeasureId) ?? []
265
+ : [];
266
+ const scopeFindings = measure.populationScopeId === input.definition.scopes.financial.id
267
+ ? financialExecutionFindings
268
+ : [...financialExecutionFindings, ...reportingExecutionFindings];
269
+ const blockingFindings = [...scopeFindings, ...conditionFindings, ...exposureFindings];
270
+ if (blockingFindings.length > 0) {
271
+ values.push({
272
+ measureId: measure.id,
273
+ unit: measure.unit,
274
+ coordinates: { originId, originLabel: group.label, evaluationDate },
275
+ value: null,
276
+ status: "unavailable",
277
+ contributingObservations: 0,
278
+ excludedObservations: measureObservations.length,
279
+ exact: true,
280
+ numerator: null,
281
+ denominator: null,
282
+ findings: blockingFindings,
283
+ });
284
+ continue;
285
+ }
286
+ let statistic;
287
+ if (measure.statistic.kind === "ratio" && exposedByTarget.get(originId)?.has(measure.statistic.denominatorMeasureId)) {
288
+ const ratio = measure.statistic;
289
+ const numerator = measureObservations.reduce((total, observation) => {
290
+ const value = observation.measures[ratio.numeratorMeasureId];
291
+ return total + (typeof value === "number" ? value : 0);
292
+ }, 0);
293
+ const denominator = exposedByTarget.get(originId).get(ratio.denominatorMeasureId);
294
+ const seed = measureObservations[0];
295
+ if (seed === undefined) {
296
+ statistic = calculateAnalysisStatistic({ measure, observations: [], selection: { kind: "all-observations" } });
297
+ }
298
+ else {
299
+ const synthetic = snapshotDiagnosticJson({ ...seed, measures: {
300
+ [ratio.numeratorMeasureId]: numerator,
301
+ [ratio.denominatorMeasureId]: denominator,
302
+ } });
303
+ statistic = calculateAnalysisStatistic({ measure, observations: [synthetic], selection: { kind: "all-observations" } });
304
+ }
305
+ }
306
+ else {
307
+ statistic = calculateAnalysisStatistic({
308
+ measure,
309
+ observations: measureObservations,
310
+ selection: { kind: "evaluation-date", evaluationDate, observationSelection: "exact" },
311
+ });
312
+ }
313
+ values.push({
314
+ measureId: measure.id,
315
+ unit: measure.unit,
316
+ coordinates: { originId, originLabel: group.label, evaluationDate },
317
+ value: statistic.value,
318
+ status: statistic.status,
319
+ contributingObservations: statistic.contributingObservations,
320
+ excludedObservations: statistic.excludedObservations,
321
+ exact: true,
322
+ numerator: statistic.numerator,
323
+ denominator: statistic.denominator,
324
+ findings: statistic.findings,
325
+ });
326
+ }
327
+ }
328
+ if (originFailures.length > 0) {
329
+ for (const measure of input.definition.measures.filter((candidate) => measureIds.has(candidate.id))) {
330
+ const originFindings = originFailures
331
+ .filter(({ observation }) => measure.populationScopeId === input.definition.scopes.financial.id || reportingKeys.has(key(observation)))
332
+ .map(({ finding }) => finding);
333
+ if (originFindings.length === 0)
334
+ continue;
335
+ values.push({
336
+ measureId: measure.id,
337
+ unit: measure.unit,
338
+ coordinates: { originId: "unassigned", originLabel: "Unavailable origin", evaluationDate },
339
+ value: null,
340
+ status: "unavailable",
341
+ contributingObservations: 0,
342
+ excludedObservations: originFailures.length,
343
+ exact: true,
344
+ numerator: null,
345
+ denominator: null,
346
+ findings: originFindings,
347
+ });
348
+ }
349
+ }
350
+ const calculationInput = {
351
+ historyRevisionId: prepared.calculationRevisionId,
352
+ definition: input.definition,
353
+ recipe: input.recipe,
354
+ evaluationDate,
355
+ exposureRevisionIds: exposureCalculationRevisions.sort(),
356
+ };
357
+ const lineageInput = {
358
+ historyRevisionId: prepared.lineageRevisionId,
359
+ exposureRevisionIds: exposureLineageRevisions.sort(),
360
+ calculationIdentity: `fnv1a64-jcs-v1:${fnv1a64(canonicalJson(calculationInput))}`,
361
+ };
362
+ return snapshotDiagnosticJson({
363
+ resultContractVersion: "actuarial-result/1",
364
+ datasetRevisionId: prepared.datasetRevisionId,
365
+ analysisDefinitionId: input.definition.id,
366
+ recipeId: input.recipe.id,
367
+ calculationIdentity: lineageInput.calculationIdentity,
368
+ lineageIdentity: `fnv1a64-jcs-v1:${fnv1a64(canonicalJson(lineageInput))}`,
369
+ values,
370
+ });
371
+ }
372
+ export function applyAnalysisScenario(definition, scenario) {
373
+ const scenarioIssues = [];
374
+ if (!isDiagnosticToken(scenario.id))
375
+ scenarioIssues.push({ domain: "configuration", code: "invalid-string", path: "$.scenario.id", message: "Scenario id is invalid" });
376
+ for (const [index, change] of scenario.changes.entries()) {
377
+ if (change.kind !== "reporting-predicate" && !definition.financialTerms.some((term) => term.id === change.termId))
378
+ scenarioIssues.push({ domain: "configuration", code: "unknown-reference", path: `$.scenario.changes[${index}].termId`, message: `Unknown financial term ${change.termId}` });
379
+ if (change.kind === "financial-term-limit" && change.limit !== null && (!(change.limit > 0) || !Number.isFinite(change.limit)))
380
+ scenarioIssues.push({ domain: "configuration", code: "invalid-number", path: `$.scenario.changes[${index}].limit`, message: "Scenario term limit must be positive finite or null" });
381
+ if (change.kind === "financial-term-attachment" && (!(change.amount >= 0) || !Number.isFinite(change.amount)))
382
+ scenarioIssues.push({ domain: "configuration", code: "invalid-number", path: `$.scenario.changes[${index}].amount`, message: "Scenario attachment must be nonnegative finite" });
383
+ }
384
+ if (scenarioIssues.length > 0)
385
+ throw new DiagnosticValidationError(scenarioIssues);
386
+ let reportingPredicate = definition.scopes.reporting.predicate;
387
+ const terms = definition.financialTerms.map((term) => {
388
+ let current = term;
389
+ for (const change of scenario.changes) {
390
+ if (change.kind === "reporting-predicate")
391
+ reportingPredicate = change.predicate;
392
+ else if (change.termId === term.id && change.kind === "financial-term-limit")
393
+ current = { ...current, limit: change.limit };
394
+ else if (change.termId === term.id && change.kind === "financial-term-attachment")
395
+ current = current.kind === "policy-aggregate" ? { ...current, deductible: change.amount } : { ...current, attachment: change.amount };
396
+ }
397
+ return current;
398
+ });
399
+ return snapshotDiagnosticJson({
400
+ ...definition,
401
+ version: `${definition.version}+scenario.${scenario.id}`,
402
+ scopes: { ...definition.scopes, reporting: { ...definition.scopes.reporting, predicate: reportingPredicate } },
403
+ financialTerms: terms,
404
+ });
405
+ }
406
+ export function compareAnalysisResults(baseline, scenario) {
407
+ const scenarioValues = new Map(scenario.values.map((value) => [canonicalJson([value.measureId, value.coordinates]), value]));
408
+ return snapshotDiagnosticJson(baseline.values.map((value) => {
409
+ const compared = scenarioValues.get(canonicalJson([value.measureId, value.coordinates]));
410
+ if (compared !== undefined && compared.unit !== value.unit)
411
+ throw new DiagnosticValidationError([{
412
+ domain: "configuration",
413
+ code: "incompatible-semantics",
414
+ path: `$.values.${value.measureId}.unit`,
415
+ message: `Cannot compare ${value.unit} with ${compared.unit}`,
416
+ }]);
417
+ const baselineValue = value.value;
418
+ const scenarioValue = compared?.value ?? null;
419
+ const absoluteDifference = baselineValue === null || scenarioValue === null ? null : scenarioValue - baselineValue;
420
+ const percentageDifference = absoluteDifference === null || baselineValue === null || baselineValue === 0
421
+ ? null
422
+ : absoluteDifference / baselineValue;
423
+ return {
424
+ measureId: value.measureId,
425
+ unit: value.unit,
426
+ coordinates: value.coordinates,
427
+ baselineValue,
428
+ scenarioValue,
429
+ absoluteDifference,
430
+ percentageDifference,
431
+ };
432
+ }));
433
+ }
434
+ /** Runs a bounded list of controlled scenarios against one immutable baseline. */
435
+ export function runAnalysisScenarios(input) {
436
+ const maximumActiveScenarios = maximumActiveScenarioLimit(input.limits);
437
+ if (input.scenarios.length > maximumActiveScenarios)
438
+ throw new DiagnosticValidationError([{
439
+ domain: "configuration",
440
+ code: "expression-limit",
441
+ path: "$.scenarios",
442
+ message: `Scenario count exceeds maximumActiveScenarios ${maximumActiveScenarios}`,
443
+ }]);
444
+ if (new Set(input.scenarios.map((scenario) => scenario.id)).size !== input.scenarios.length)
445
+ throw new DiagnosticValidationError([{
446
+ domain: "configuration",
447
+ code: "duplicate-id",
448
+ path: "$.scenarios",
449
+ message: "Scenario IDs must be unique",
450
+ }]);
451
+ const common = {
452
+ history: input.history,
453
+ recipe: input.recipe,
454
+ ...(input.exposureObservations === undefined ? {} : { exposureObservations: input.exposureObservations }),
455
+ ...(input.exposureTargets === undefined ? {} : { exposureTargets: input.exposureTargets }),
456
+ ...(input.exposureRevisionSelection === undefined ? {} : { exposureRevisionSelection: input.exposureRevisionSelection }),
457
+ ...(input.evaluationDate === undefined ? {} : { evaluationDate: input.evaluationDate }),
458
+ };
459
+ const baseline = runAnalysisRecipe({ ...common, definition: input.definition });
460
+ return snapshotDiagnosticJson(input.scenarios.map((scenario) => {
461
+ const result = runAnalysisRecipe({ ...common, definition: applyAnalysisScenario(input.definition, scenario) });
462
+ return { scenarioId: scenario.id, result, differences: compareAnalysisResults(baseline, result) };
463
+ }));
464
+ }
465
+ function scenarioChangeTarget(change) {
466
+ if (change.kind === "reporting-predicate")
467
+ return "reporting-predicate";
468
+ return `${change.kind}:${change.termId}`;
469
+ }
470
+ function maximumActiveScenarioLimit(limits) {
471
+ const maximum = limits?.maximumActiveScenarios;
472
+ if (!Number.isSafeInteger(maximum) || maximum < 1)
473
+ throw new DiagnosticValidationError([{
474
+ domain: "configuration",
475
+ code: "expression-limit",
476
+ path: "$.limits.maximumActiveScenarios",
477
+ message: "maximumActiveScenarios must be a positive safe integer",
478
+ }]);
479
+ return maximum;
480
+ }
481
+ /** Expands a closed Cartesian parameter grid after proving its product is bounded. */
482
+ export function expandAnalysisScenarioGrid(grid, limits) {
483
+ const issues = [];
484
+ let maximumActiveScenarios;
485
+ try {
486
+ maximumActiveScenarios = maximumActiveScenarioLimit(limits);
487
+ }
488
+ catch (error) {
489
+ if (error instanceof DiagnosticValidationError)
490
+ issues.push(...error.issues);
491
+ maximumActiveScenarios = 0;
492
+ }
493
+ if (!isDiagnosticToken(grid.id))
494
+ issues.push({ domain: "configuration", code: "invalid-string", path: "$.grid.id", message: "Scenario grid id is invalid" });
495
+ if (grid.axes.length === 0)
496
+ issues.push({ domain: "configuration", code: "missing-required", path: "$.grid.axes", message: "Scenario grid requires at least one axis" });
497
+ if (new Set(grid.axes.map((axis) => axis.id)).size !== grid.axes.length)
498
+ issues.push({ domain: "configuration", code: "duplicate-id", path: "$.grid.axes", message: "Scenario grid axis IDs must be unique" });
499
+ const axisTargets = new Set();
500
+ let product = 1;
501
+ grid.axes.forEach((axis, axisIndex) => {
502
+ const path = `$.grid.axes[${axisIndex}]`;
503
+ if (!isDiagnosticToken(axis.id))
504
+ issues.push({ domain: "configuration", code: "invalid-string", path: `${path}.id`, message: "Scenario grid axis id is invalid" });
505
+ if (axis.options.length === 0)
506
+ issues.push({ domain: "configuration", code: "missing-required", path: `${path}.options`, message: "Scenario grid axis requires at least one option" });
507
+ if (new Set(axis.options.map((option) => option.id)).size !== axis.options.length)
508
+ issues.push({ domain: "configuration", code: "duplicate-id", path: `${path}.options`, message: "Scenario grid option IDs must be unique within an axis" });
509
+ axis.options.forEach((option, optionIndex) => {
510
+ if (!isDiagnosticToken(option.id))
511
+ issues.push({ domain: "configuration", code: "invalid-string", path: `${path}.options[${optionIndex}].id`, message: "Scenario grid option id is invalid" });
512
+ });
513
+ const targets = new Set(axis.options.flatMap((option) => option.change === null ? [] : [scenarioChangeTarget(option.change)]));
514
+ if (targets.size > 1)
515
+ issues.push({ domain: "configuration", code: "incompatible-semantics", path: `${path}.options`, message: "Every non-baseline option on one grid axis must change the same parameter" });
516
+ const target = [...targets][0];
517
+ if (target !== undefined && axisTargets.has(target))
518
+ issues.push({ domain: "configuration", code: "incompatible-semantics", path, message: `Multiple grid axes change ${target}` });
519
+ if (target !== undefined)
520
+ axisTargets.add(target);
521
+ if (axis.options.length > 0) {
522
+ if (product > Math.floor(maximumActiveScenarios / axis.options.length))
523
+ product = maximumActiveScenarios + 1;
524
+ else
525
+ product *= axis.options.length;
526
+ }
527
+ });
528
+ if (maximumActiveScenarios > 0 && product > maximumActiveScenarios)
529
+ issues.push({ domain: "configuration", code: "expression-limit", path: "$.grid.axes", message: `Scenario grid product exceeds maximumActiveScenarios ${maximumActiveScenarios}` });
530
+ if (issues.length > 0)
531
+ throw new DiagnosticValidationError(issues);
532
+ let combinations = [{ ids: [], changes: [] }];
533
+ for (const axis of grid.axes) {
534
+ combinations = combinations.flatMap((combination) => axis.options.map((option) => ({
535
+ ids: [...combination.ids, `${axis.id}-${option.id}`],
536
+ changes: option.change === null ? combination.changes : [...combination.changes, option.change],
537
+ })));
538
+ }
539
+ const scenarios = combinations.map((combination) => ({
540
+ id: `${grid.id}-${combination.ids.join("-")}`,
541
+ changes: combination.changes,
542
+ }));
543
+ if (scenarios.some((scenario) => !isDiagnosticToken(scenario.id)))
544
+ throw new DiagnosticValidationError([{
545
+ domain: "configuration",
546
+ code: "invalid-string",
547
+ path: "$.grid",
548
+ message: "Expanded scenario grid IDs must be valid diagnostic tokens",
549
+ }]);
550
+ return snapshotDiagnosticJson(scenarios);
551
+ }
552
+ /** Expands and runs a bounded parameter grid against one immutable baseline. */
553
+ export function runAnalysisScenarioGrid(input) {
554
+ const { grid, ...common } = input;
555
+ return runAnalysisScenarios({
556
+ ...common,
557
+ scenarios: expandAnalysisScenarioGrid(grid, input.limits),
558
+ });
559
+ }
560
+ /** Complete dependency identity for exact full-result reuse, including lineage. */
561
+ export function analysisRecipeDependencyIdentity(input) {
562
+ return `fnv1a64-jcs-v1:${fnv1a64(canonicalJson({ kind: "analysis-recipe-dependencies", input }))}`;
563
+ }
564
+ /** Uses caller-owned cache storage; a hit is returned only for the complete dependency identity. */
565
+ export function runAnalysisRecipeCached(input) {
566
+ const { cache, ...runInput } = input;
567
+ const dependencyIdentity = analysisRecipeDependencyIdentity(runInput);
568
+ const existing = cache.get(dependencyIdentity);
569
+ if (existing !== undefined)
570
+ return snapshotDiagnosticJson({ cacheStatus: "hit", dependencyIdentity, result: existing });
571
+ const result = runAnalysisRecipe(runInput);
572
+ cache.set(dependencyIdentity, result);
573
+ return snapshotDiagnosticJson({ cacheStatus: "miss", dependencyIdentity, result });
574
+ }
575
+ /** Preserves the prior result and recipe while executing a later immutable valuation. */
576
+ export function rollForwardAnalysisRecipe(input) {
577
+ if (input.currentEvaluationDate <= input.priorEvaluationDate)
578
+ throw new DiagnosticValidationError([{
579
+ domain: "configuration",
580
+ code: "invalid-period",
581
+ path: "$.currentEvaluationDate",
582
+ message: "Roll-forward currentEvaluationDate must be after priorEvaluationDate",
583
+ }]);
584
+ const { priorEvaluationDate, currentEvaluationDate, ...common } = input;
585
+ const priorResult = runAnalysisRecipe({ ...common, evaluationDate: priorEvaluationDate });
586
+ const currentResult = runAnalysisRecipe({ ...common, evaluationDate: currentEvaluationDate });
587
+ return snapshotDiagnosticJson({
588
+ kind: "roll-forward",
589
+ priorEvaluationDate,
590
+ currentEvaluationDate,
591
+ priorResult,
592
+ currentResult,
593
+ differences: compareAnalysisResults(priorResult, currentResult),
594
+ });
595
+ }
596
+ /** Applies explicitly ordered, one-change scenario steps and reports each incremental bridge. */
597
+ export function runAnalysisScenarioBridge(input) {
598
+ const maximumActiveScenarios = maximumActiveScenarioLimit(input.limits);
599
+ if (input.scenarios.length > maximumActiveScenarios)
600
+ throw new DiagnosticValidationError([{
601
+ domain: "configuration", code: "expression-limit", path: "$.scenarios",
602
+ message: `Scenario bridge exceeds maximumActiveScenarios ${maximumActiveScenarios}`,
603
+ }]);
604
+ const { scenarios, limits: _limits, ...common } = input;
605
+ const baseline = runAnalysisRecipe(common);
606
+ let prior = baseline;
607
+ let effectiveDefinition = input.definition;
608
+ const steps = [];
609
+ for (const scenario of scenarios) {
610
+ if (scenario.changes.length !== 1)
611
+ throw new DiagnosticValidationError([{
612
+ domain: "configuration", code: "invalid-configuration", path: `$.scenarios.${scenario.id}.changes`,
613
+ message: "Each bridge scenario must contain exactly one change",
614
+ }]);
615
+ effectiveDefinition = applyAnalysisScenario(effectiveDefinition, scenario);
616
+ const result = runAnalysisRecipe({ ...common, definition: effectiveDefinition });
617
+ steps.push({
618
+ scenarioId: scenario.id,
619
+ fromCalculationIdentity: prior.calculationIdentity,
620
+ result,
621
+ differences: compareAnalysisResults(prior, result),
622
+ });
623
+ prior = result;
624
+ }
625
+ return snapshotDiagnosticJson({ baseline, steps });
626
+ }
627
+ //# sourceMappingURL=analysisRecipe.js.map