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