@actuarial-ts/core 0.8.0 → 0.9.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 (44) hide show
  1. package/README.md +9 -6
  2. package/dist/analysisComparison.d.ts +6 -0
  3. package/dist/analysisComparison.d.ts.map +1 -0
  4. package/dist/analysisComparison.js +52 -0
  5. package/dist/analysisComparison.js.map +1 -0
  6. package/dist/analysisExpressions.d.ts +24 -0
  7. package/dist/analysisExpressions.d.ts.map +1 -0
  8. package/dist/analysisExpressions.js +256 -0
  9. package/dist/analysisExpressions.js.map +1 -0
  10. package/dist/analysisFinancialUnits.d.ts +4 -0
  11. package/dist/analysisFinancialUnits.d.ts.map +1 -0
  12. package/dist/analysisFinancialUnits.js +62 -0
  13. package/dist/analysisFinancialUnits.js.map +1 -0
  14. package/dist/analysisRecipe.d.ts +2 -2
  15. package/dist/analysisRecipe.d.ts.map +1 -1
  16. package/dist/analysisRecipe.js +55 -59
  17. package/dist/analysisRecipe.js.map +1 -1
  18. package/dist/analysisRecipeStatistics.d.ts +11 -0
  19. package/dist/analysisRecipeStatistics.d.ts.map +1 -0
  20. package/dist/analysisRecipeStatistics.js +49 -0
  21. package/dist/analysisRecipeStatistics.js.map +1 -0
  22. package/dist/customizationContracts.d.ts +47 -1
  23. package/dist/customizationContracts.d.ts.map +1 -1
  24. package/dist/customizationContracts.js +1 -0
  25. package/dist/customizationContracts.js.map +1 -1
  26. package/dist/descriptiveStatistics.d.ts.map +1 -1
  27. package/dist/descriptiveStatistics.js +4 -4
  28. package/dist/descriptiveStatistics.js.map +1 -1
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/version.d.ts +1 -1
  34. package/dist/version.js +1 -1
  35. package/package.json +1 -1
  36. package/src/analysisComparison.ts +71 -0
  37. package/src/analysisExpressions.ts +223 -0
  38. package/src/analysisFinancialUnits.ts +55 -0
  39. package/src/analysisRecipe.ts +54 -63
  40. package/src/analysisRecipeStatistics.ts +59 -0
  41. package/src/customizationContracts.ts +35 -0
  42. package/src/descriptiveStatistics.ts +4 -3
  43. package/src/index.ts +2 -0
  44. package/src/version.ts +1 -1
@@ -0,0 +1,223 @@
1
+ import type {
2
+ AnalysisDefinition, AnalysisDerivedMeasureDefinition, AnalysisMeasureExpression,
3
+ AnalysisResultValue, AnalysisDerivedComponent, CustomizationCapabilityFinding,
4
+ } from "./customizationContracts.js";
5
+ import { canonicalJson } from "./canonical.js";
6
+ import { isDiagnosticPlainRecord, isDiagnosticToken, snapshotDiagnosticJson } from "./diagnosticRuntime.js";
7
+ import { DiagnosticValidationError } from "./types.js";
8
+
9
+ type Factors = Readonly<Record<string, number>>;
10
+ export type CompiledAnalysisExpression = { readonly definition: AnalysisDerivedMeasureDefinition; readonly unit: string; readonly unitFactors: Factors; readonly dependencies: readonly string[] };
11
+ const invalid = (message: string, path = "$.definition.derivedMeasures", code: "invalid-configuration" | "unknown-reference" | "incompatible-semantics" | "expression-limit" | "cycle" = "invalid-configuration"): never => {
12
+ throw new DiagnosticValidationError([{ domain: "definition", code, path, message }]);
13
+ };
14
+
15
+ function normalizedFactors(raw: unknown): Factors {
16
+ if (!isDiagnosticPlainRecord(raw) || Object.keys(raw).length > 32) return invalid("Unit factors require a bounded record of integer powers");
17
+ const result: [string, number][] = [];
18
+ for (const [unit, power] of Object.entries(raw)) {
19
+ if (!isDiagnosticToken(unit) || typeof power !== "number" || !Number.isSafeInteger(power) || Math.abs(power) > 32)
20
+ return invalid("Unit factors require valid unit names and integer powers between -32 and 32");
21
+ if (power !== 0) result.push([unit, power]);
22
+ }
23
+ return Object.fromEntries(result.sort(([a], [b]) => a.localeCompare(b)));
24
+ }
25
+ function combine(left: Factors, right: Factors, direction: 1 | -1): Factors {
26
+ const result = new Map(Object.entries(left));
27
+ for (const [unit, power] of Object.entries(right)) result.set(unit, (result.get(unit) ?? 0) + direction * power);
28
+ return normalizedFactors(Object.fromEntries(result));
29
+ }
30
+ export function formatAnalysisUnit(factors: Factors): string {
31
+ const normalized = normalizedFactors(factors);
32
+ const part = (sign: 1 | -1) => Object.entries(normalized).filter(([, power]) => power * sign > 0).map(([unit, power]) => {
33
+ const atom = /[*/^()[\]{}]/.test(unit) ? `[${unit}]` : unit;
34
+ return Math.abs(power) === 1 ? atom : `${atom}^${Math.abs(power)}`;
35
+ }).join("*");
36
+ const numerator = part(1) || "1";
37
+ const denominator = part(-1);
38
+ return denominator ? `${numerator}/${denominator.includes("*") ? `(${denominator})` : denominator}` : numerator;
39
+ }
40
+
41
+ /** Validates a bounded dependency graph and resolves dimensions without evaluating any values. */
42
+ export function compileAnalysisExpressions(definition: AnalysisDefinition): readonly CompiledAnalysisExpression[] {
43
+ const derived = definition.derivedMeasures ?? [];
44
+ if (derived.length > 64) return invalid("At most 64 derived measures are supported", undefined, "expression-limit");
45
+ const base = new Map(definition.measures.map((m) => [m.id, m]));
46
+ const declarations = new Map(derived.map((m) => [m.id, m]));
47
+ if (base.size !== definition.measures.length || declarations.size !== derived.length || derived.some((m) => !isDiagnosticToken(m.id) || base.has(m.id)))
48
+ return invalid("Base and derived measure IDs must be distinct valid tokens");
49
+ const compiled = new Map<string, CompiledAnalysisExpression>();
50
+ const visiting = new Set<string>();
51
+ let totalNodes = 0;
52
+ const resolve = (id: string): Factors => {
53
+ const raw = base.get(id);
54
+ if (raw) return normalizedFactors(raw.unitFactors ?? { [raw.unit]: 1 });
55
+ const prior = compiled.get(id);
56
+ if (prior) return prior.unitFactors;
57
+ const measure = declarations.get(id);
58
+ if (!measure) return invalid(`Unknown measure ${id}`, undefined, "unknown-reference");
59
+ if (visiting.has(id)) return invalid(`Derived measure cycle at ${id}`, undefined, "cycle");
60
+ visiting.add(id);
61
+ const dependencies = new Set<string>();
62
+ const walk = (raw: unknown, depth: number, ancestors: ReadonlySet<object>): Factors => {
63
+ if (++totalNodes > 1024 || depth > 16) return invalid("Derived expressions exceed 1024 total nodes or 16 nested levels", undefined, "expression-limit");
64
+ if (!isDiagnosticPlainRecord(raw)) return invalid("Expression nodes must be objects");
65
+ if (ancestors.has(raw)) return invalid("Expression contains an object cycle", undefined, "cycle");
66
+ const next = new Set(ancestors).add(raw);
67
+ const keys: Record<string, string[]> = { measure: ["op", "measureId"], exposure: ["op", "exposureId"], constant: ["op", "value", "unitFactors"], add: ["op", "terms"], subtract: ["op", "left", "right"], multiply: ["op", "left", "right"], divide: ["op", "numerator", "denominator"] };
68
+ const allowed = typeof raw.op === "string" && Object.hasOwn(keys, raw.op) ? keys[raw.op] : undefined;
69
+ if (!allowed || Object.keys(raw).some((key) => !allowed.includes(key))) return invalid("Unknown expression operator or field");
70
+ if (raw.op === "measure") {
71
+ if (!isDiagnosticToken(raw.measureId)) return invalid("Invalid measure reference");
72
+ dependencies.add(raw.measureId);
73
+ return resolve(raw.measureId);
74
+ }
75
+ if (raw.op === "exposure") {
76
+ const exposure = definition.exposures.find((item) => item.id === raw.exposureId);
77
+ if (!exposure) return invalid(`Unknown exposure ${String(raw.exposureId)}`, undefined, "unknown-reference");
78
+ return normalizedFactors({ [exposure.unit]: 1 });
79
+ }
80
+ if (raw.op === "constant") {
81
+ if (typeof raw.value !== "number" || !Number.isFinite(raw.value)) return invalid("Constants must be finite numbers");
82
+ return normalizedFactors(raw.unitFactors);
83
+ }
84
+ if (raw.op === "add") {
85
+ if (!Array.isArray(raw.terms) || raw.terms.length < 1 || raw.terms.length > 32) return invalid("Addition requires 1 to 32 terms");
86
+ const terms = raw.terms.map((term) => walk(term, depth + 1, next));
87
+ if (terms.some((term) => canonicalJson(term) !== canonicalJson(terms[0]))) return invalid("Addition requires matching units", undefined, "incompatible-semantics");
88
+ return terms[0]!;
89
+ }
90
+ const left = walk(raw.op === "divide" ? raw.numerator : raw.left, depth + 1, next);
91
+ const right = walk(raw.op === "divide" ? raw.denominator : raw.right, depth + 1, next);
92
+ if (raw.op === "subtract") {
93
+ if (canonicalJson(left) !== canonicalJson(right)) return invalid("Subtraction requires matching units", undefined, "incompatible-semantics");
94
+ return left;
95
+ }
96
+ return combine(left, right, raw.op === "divide" ? -1 : 1);
97
+ };
98
+ const unitFactors = walk(measure.expression, 0, new Set());
99
+ if (measure.developmentSemantics !== "point-in-time") {
100
+ const dimensionlessConstant = (expression: AnalysisMeasureExpression) => expression.op === "constant" && Object.keys(normalizedFactors(expression.unitFactors)).length === 0;
101
+ const checkStage = (expression: AnalysisMeasureExpression): void => {
102
+ if (expression.op === "constant") return;
103
+ if (expression.op === "measure") {
104
+ const referenced = base.get(expression.measureId) ?? declarations.get(expression.measureId)!;
105
+ if (referenced.developmentSemantics !== measure.developmentSemantics)
106
+ invalid("Cumulative/incremental expressions require components with matching development meaning", undefined, "incompatible-semantics");
107
+ return;
108
+ }
109
+ if (expression.op === "add") { expression.terms.forEach(checkStage); return; }
110
+ if (expression.op === "subtract") { checkStage(expression.left); checkStage(expression.right); return; }
111
+ if (expression.op === "multiply" && dimensionlessConstant(expression.left)) { checkStage(expression.right); return; }
112
+ if (expression.op === "multiply" && dimensionlessConstant(expression.right)) { checkStage(expression.left); return; }
113
+ if (expression.op === "divide" && dimensionlessConstant(expression.denominator)) { checkStage(expression.numerator); return; }
114
+ invalid("Ratios, products and exposure expressions require point-in-time development meaning unless scaling by a dimensionless constant", undefined, "incompatible-semantics");
115
+ };
116
+ checkStage(measure.expression);
117
+ }
118
+ compiled.set(id, { definition: measure, unit: formatAnalysisUnit(unitFactors), unitFactors, dependencies: [...dependencies].sort() });
119
+ visiting.delete(id);
120
+ return unitFactors;
121
+ };
122
+ for (const measure of derived) resolve(measure.id);
123
+ return snapshotDiagnosticJson([...compiled.values()]);
124
+ }
125
+
126
+ /** Resolves requested outputs and their transitive statistic dependencies. */
127
+ export function analysisExpressionDependencies(compiled: readonly CompiledAnalysisExpression[], requested: ReadonlySet<string>): ReadonlySet<string> {
128
+ const dependencies = new Set(requested);
129
+ const pending = [...requested];
130
+ while (pending.length) {
131
+ const id = pending.pop()!;
132
+ for (const dependency of compiled.find((item) => item.definition.id === id)?.dependencies ?? []) {
133
+ if (!dependencies.has(dependency)) { dependencies.add(dependency); pending.push(dependency); }
134
+ }
135
+ }
136
+ return dependencies;
137
+ }
138
+
139
+ /** Executes post-aggregation expressions on one exact origin/evaluation coordinate. */
140
+ export function evaluateAnalysisExpressions(input: {
141
+ readonly definition: AnalysisDefinition;
142
+ readonly values: readonly AnalysisResultValue[];
143
+ readonly coordinates: AnalysisResultValue["coordinates"];
144
+ readonly exposures: ReadonlyMap<string, number>;
145
+ readonly exposureFindings: ReadonlyMap<string, readonly CustomizationCapabilityFinding[]>;
146
+ readonly requested: ReadonlySet<string>;
147
+ }): readonly AnalysisResultValue[] {
148
+ const compiled = compileAnalysisExpressions(input.definition);
149
+ if (compiled.length === 0) return [];
150
+ for (const id of input.requested)
151
+ if (!input.definition.measures.some((measure) => measure.id === id) && !compiled.some((item) => item.definition.id === id))
152
+ return invalid(`Unknown requested measure ${id}`, undefined, "unknown-reference");
153
+ const required = analysisExpressionDependencies(compiled, input.requested);
154
+ for (const value of input.values) {
155
+ if (canonicalJson(value.coordinates) !== canonicalJson(input.coordinates))
156
+ return invalid("Expression components must share the exact origin/evaluation coordinate", undefined, "incompatible-semantics");
157
+ const measure = input.definition.measures.find((m) => m.id === value.measureId);
158
+ if (!measure || value.unit !== measure.unit || (value.value !== null && !Number.isFinite(value.value)))
159
+ return invalid("Expression components must match declared base measures, units and finite values", undefined, "incompatible-semantics");
160
+ }
161
+ if (new Set(input.values.map((value) => value.measureId)).size !== input.values.length)
162
+ return invalid("Expression components contain duplicate measure results");
163
+ const values = new Map(input.values.map((value) => [value.measureId, value]));
164
+ const output: AnalysisResultValue[] = [];
165
+ for (const item of compiled) {
166
+ if (!required.has(item.definition.id)) continue;
167
+ const components = new Map<string, AnalysisDerivedComponent>();
168
+ const findings: CustomizationCapabilityFinding[] = [];
169
+ const unavailable = (message: string): null => {
170
+ findings.push({ code: "missing-prerequisite", capability: "derived-analysis-measures", path: `$.derivedMeasures.${item.definition.id}`, message });
171
+ return null;
172
+ };
173
+ let numerator: number | null = null;
174
+ let denominator: number | null = null;
175
+ const evaluate = (expression: AnalysisMeasureExpression, root = false): number | null => {
176
+ if (expression.op === "constant") return expression.value;
177
+ if (expression.op === "measure") {
178
+ const value = values.get(expression.measureId);
179
+ if (value) {
180
+ components.set(`measure:${expression.measureId}`, { kind: "measure", id: expression.measureId, unit: value.unit, value: value.value, status: value.status, contributingObservations: value.contributingObservations, excludedObservations: value.excludedObservations });
181
+ findings.push(...value.findings);
182
+ } else {
183
+ const unit = input.definition.measures.find((m) => m.id === expression.measureId)?.unit ?? compiled.find((m) => m.definition.id === expression.measureId)!.unit;
184
+ components.set(`measure:${expression.measureId}`, { kind: "measure", id: expression.measureId, unit, value: null, status: "unavailable", contributingObservations: 0, excludedObservations: 0 });
185
+ }
186
+ return !value || value.status !== "available" || value.value === null || value.excludedObservations > 0
187
+ ? unavailable(`Measure ${expression.measureId} requires a complete available result`) : value.value;
188
+ }
189
+ if (expression.op === "exposure") {
190
+ const value = input.exposures.get(expression.exposureId);
191
+ const exposureFindings = input.exposureFindings.get(expression.exposureId) ?? [];
192
+ findings.push(...exposureFindings);
193
+ const available = value !== undefined && Number.isFinite(value) && exposureFindings.length === 0;
194
+ components.set(`exposure:${expression.exposureId}`, { kind: "exposure", id: expression.exposureId, unit: input.definition.exposures.find((e) => e.id === expression.exposureId)!.unit, value: available ? value : null, status: available ? "available" : "unavailable", contributingObservations: 0, excludedObservations: 0 });
195
+ return available ? value : unavailable(`Exposure ${expression.exposureId} requires an available allocated result`);
196
+ }
197
+ let value: number;
198
+ if (expression.op === "add") {
199
+ const terms = expression.terms.map((term) => evaluate(term));
200
+ if (terms.some((term) => term === null)) return null;
201
+ value = terms.reduce<number>((sum, term) => sum + term!, 0);
202
+ } else {
203
+ const left = evaluate(expression.op === "divide" ? expression.numerator : expression.left);
204
+ const right = evaluate(expression.op === "divide" ? expression.denominator : expression.right);
205
+ if (root && expression.op === "divide") { numerator = left; denominator = right; }
206
+ if (left === null || right === null) return null;
207
+ if (expression.op === "divide" && right <= 0) return unavailable("Division requires a strictly positive denominator");
208
+ value = expression.op === "subtract" ? left - right : expression.op === "multiply" ? left * right : left / right;
209
+ }
210
+ return Number.isFinite(value) ? value : unavailable("Expression arithmetic overflowed; no finite result is available");
211
+ };
212
+ const value = evaluate(item.definition.expression, true);
213
+ const result: AnalysisResultValue = {
214
+ measureId: item.definition.id, unit: item.unit, coordinates: input.coordinates, value,
215
+ status: value === null ? "unavailable" : "available", contributingObservations: 0, excludedObservations: 0, exact: true,
216
+ numerator, denominator, findings: [...new Map(findings.map((finding) => [canonicalJson(finding), finding])).values()],
217
+ derivation: { stage: "post-aggregation", unitFactors: item.unitFactors, components: [...components.values()] },
218
+ };
219
+ values.set(result.measureId, result);
220
+ output.push(result);
221
+ }
222
+ return snapshotDiagnosticJson(output);
223
+ }
@@ -0,0 +1,55 @@
1
+ import type { AnalysisDefinition, AnalysisRecipe } from "./customizationContracts.js";
2
+ import { formatAnalysisUnit } from "./analysisExpressions.js";
3
+ import { DiagnosticValidationError } from "./types.js";
4
+
5
+ const incompatible = (message: string): never => {
6
+ throw new DiagnosticValidationError([{ domain: "configuration", code: "incompatible-semantics", path: "$.recipe.steps", message }]);
7
+ };
8
+
9
+ /** Resolves the declared output units of an ordered financial pipeline before calculating results. */
10
+ export function resolveAnalysisFinancialUnits(definition: AnalysisDefinition, recipe: AnalysisRecipe): AnalysisDefinition {
11
+ const step = recipe.steps.find((item) => item.kind === "apply-financial-pipeline");
12
+ if (step?.kind !== "apply-financial-pipeline") return definition;
13
+ const stages = step.stageIds.map((id) => definition.financialAdjustments?.find((item) => item.id === id));
14
+ if (!stages.some((stage) => stage?.kind === "currency")) return definition;
15
+ const rawUnits = new Map<string, Set<string>>();
16
+ for (const measure of definition.measures) {
17
+ if (measure.statistic.kind !== "sum") continue;
18
+ const id = measure.statistic.measureId;
19
+ const units = rawUnits.get(id) ?? new Set<string>();
20
+ units.add(measure.unit);
21
+ rawUnits.set(id, units);
22
+ }
23
+ const rawUnit = (id: string): string => {
24
+ const units = rawUnits.get(id);
25
+ if (!units || units.size !== 1) return incompatible(`Currency conversion needs one declared sum unit for ${id}`);
26
+ return [...units][0]!;
27
+ };
28
+ const initialUnit = rawUnit(step.amountMeasureId);
29
+ let outputUnit = initialUnit;
30
+ for (const stage of stages) {
31
+ if (!stage) continue;
32
+ if (stage.kind === "currency") {
33
+ if (stage.fromUnit !== outputUnit) return incompatible(`Currency stage ${stage.id} expects ${stage.fromUnit}, not ${outputUnit}`);
34
+ outputUnit = stage.toUnit;
35
+ } else if (stage.kind === "include-expense" || stage.kind === "exclude-expense" || stage.kind === "net-recovery") {
36
+ const id = stage.kind === "net-recovery" ? stage.recoveryMeasureId : stage.expenseMeasureId;
37
+ if (rawUnit(id) !== outputUnit) return incompatible(`Financial component ${id} must use the current pipeline unit ${outputUnit}`);
38
+ }
39
+ }
40
+ const effectiveUnit = (id: string): string => id === step.amountMeasureId ? outputUnit : definition.exposures.find((e) => e.id === id)?.unit ?? rawUnit(id);
41
+ const measures = definition.measures.map((measure) => {
42
+ const statistic = measure.statistic;
43
+ if (statistic.kind === "ratio") {
44
+ if (statistic.numeratorMeasureId !== step.amountMeasureId && statistic.denominatorMeasureId !== step.amountMeasureId) return measure;
45
+ const numerator = effectiveUnit(statistic.numeratorMeasureId);
46
+ const denominator = effectiveUnit(statistic.denominatorMeasureId);
47
+ const factors = numerator === denominator ? {} : { [numerator]: 1, [denominator]: -1 };
48
+ return { ...measure, unit: numerator === denominator ? "ratio" : formatAnalysisUnit(factors), unitFactors: factors };
49
+ }
50
+ if ("measureId" in statistic && statistic.measureId === step.amountMeasureId)
51
+ return { ...measure, unit: outputUnit, unitFactors: { [outputUnit]: 1 } };
52
+ return measure;
53
+ });
54
+ return { ...definition, measures };
55
+ }
@@ -1,4 +1,5 @@
1
1
  import { canonicalJson, fnv1a64 } from "./canonical.js";
2
+ import { CORE_PACKAGE_VERSION } from "./version.js";
2
3
  import type {
3
4
  AnalysisDefinition,
4
5
  AnalysisPredicate,
@@ -8,7 +9,6 @@ import type {
8
9
  AnalysisScenario,
9
10
  AnalysisScenarioChange,
10
11
  AnalysisScenarioGrid,
11
- AnalysisScenarioDifference,
12
12
  AnalysisScenarioRun,
13
13
  AnalysisRollForward,
14
14
  AnalysisScenarioBridgeStep,
@@ -22,6 +22,11 @@ import type {
22
22
  PreparedHistoricalObservation,
23
23
  } from "./customizationContracts.js";
24
24
  import { calculateAnalysisStatistic } from "./descriptiveStatistics.js";
25
+ import { calculateRecipeExposureRatio } from "./analysisRecipeStatistics.js";
26
+ import { compileAnalysisExpressions, analysisExpressionDependencies, evaluateAnalysisExpressions } from "./analysisExpressions.js";
27
+ import { resolveAnalysisFinancialUnits } from "./analysisFinancialUnits.js";
28
+ import { compareAnalysisEvaluations, compareAnalysisResults } from "./analysisComparison.js";
29
+ export { compareAnalysisResults } from "./analysisComparison.js";
25
30
  import { isDiagnosticToken, snapshotDiagnosticJson } from "./diagnosticRuntime.js";
26
31
  import { earnAndAllocateExposure } from "./exposureEarning.js";
27
32
  import { selectExposureRevisions, selectExposureValuation } from "./exposureRevisions.js";
@@ -72,7 +77,7 @@ function recipeIssues(
72
77
  hasCalculate = true;
73
78
  if (step.kind === "calculate")
74
79
  step.measureIds.forEach((id) => {
75
- if (!definition.measures.some((measure) => measure.id === id))
80
+ if (![...definition.measures, ...(definition.derivedMeasures ?? [])].some((measure) => measure.id === id))
76
81
  issues.push({ domain: "configuration", code: "unknown-reference", path: `$.recipe.steps[${index}].measureIds`, message: `Unknown measure ${id}` });
77
82
  });
78
83
  if (step.kind === "earn-exposure")
@@ -163,6 +168,8 @@ export function runAnalysisRecipe(input: {
163
168
  }): AnalysisResult {
164
169
  const issues = recipeIssues(input.recipe, input.definition, input.history);
165
170
  if (issues.length > 0) throw new DiagnosticValidationError(issues);
171
+ const outputDefinition = resolveAnalysisFinancialUnits(input.definition, input.recipe);
172
+ const expressions = compileAnalysisExpressions(outputDefinition);
166
173
  const prepared = prepareHistoricalDataset(input.history);
167
174
  const evaluationDate = input.evaluationDate ?? maxEvaluationDate(prepared.observations);
168
175
  const selected = selectedAt(prepared.observations, evaluationDate, input.definition.period.observationSelection);
@@ -173,7 +180,9 @@ export function runAnalysisRecipe(input: {
173
180
  const reportingPopulation = reportingSelection.matches;
174
181
  const reportingKeys = new Set(reportingPopulation.map(key));
175
182
  let effective = financialPopulation;
176
- let financialExecutionFindings: readonly CustomizationCapabilityFinding[] = [...financialSelection.findings];
183
+ // A conflicted or incomplete history cannot authorize arithmetic over the
184
+ // surviving subset as though it were the complete financial population.
185
+ let financialExecutionFindings: readonly CustomizationCapabilityFinding[] = [...prepared.findings, ...financialSelection.findings];
177
186
  const reportingExecutionFindings: readonly CustomizationCapabilityFinding[] = [...reportingSelection.findings];
178
187
 
179
188
  const financialStep = input.recipe.steps.find((step) => step.kind === "apply-financial-terms");
@@ -182,7 +191,7 @@ export function runAnalysisRecipe(input: {
182
191
  const applied = applyFinancialTerms({
183
192
  observations: financialPopulation,
184
193
  terms,
185
- completeFinancialPopulation: true,
194
+ completeFinancialPopulation: financialExecutionFindings.length === 0,
186
195
  measureDefinitions: input.definition.measures,
187
196
  reportingClaims: reportingPopulation.map((observation) => ({ sourceNamespace: observation.sourceNamespace, claimId: observation.claimId })),
188
197
  });
@@ -216,7 +225,7 @@ export function runAnalysisRecipe(input: {
216
225
  amountMeasureId: pipelineStep.amountMeasureId,
217
226
  inputUnit: input.definition.measures.find((measure) => measure.statistic.kind === "sum" && measure.statistic.measureId === pipelineStep.amountMeasureId)?.unit ?? "unknown-unit",
218
227
  stages,
219
- completeFinancialPopulation: true,
228
+ completeFinancialPopulation: financialExecutionFindings.length === 0,
220
229
  reportingClaims: reportingPopulation.map((observation) => ({ sourceNamespace: observation.sourceNamespace, claimId: observation.claimId })),
221
230
  });
222
231
  if (applied.status === "available") {
@@ -275,12 +284,18 @@ export function runAnalysisRecipe(input: {
275
284
  if (group === undefined) groups.set(origin.originId, { label: origin.label, observations: [observation] });
276
285
  else group.observations.push(observation);
277
286
  }
278
- const measureIds = new Set(
287
+ const requestedMeasureIds = new Set(
279
288
  input.recipe.steps.filter((step) => step.kind === "calculate").flatMap((step) => step.measureIds),
280
289
  );
290
+ const measureIds = analysisExpressionDependencies(expressions, requestedMeasureIds);
291
+ // When preparation excluded every observation, retain visible unavailable
292
+ // output instead of hiding the preparation findings in an empty result.
293
+ if (groups.size === 0 && prepared.findings.length > 0)
294
+ groups.set("unassigned", { label: "Unavailable history", observations: [] });
281
295
  const values: AnalysisResultValue[] = [];
282
296
  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))) {
297
+ const start = values.length;
298
+ for (const measure of outputDefinition.measures.filter((candidate) => measureIds.has(candidate.id))) {
284
299
  let measureObservations = measure.populationScopeId === input.definition.scopes.financial.id
285
300
  ? group.observations
286
301
  : group.observations.filter((observation) => reportingKeys.has(key(observation)));
@@ -321,27 +336,17 @@ export function runAnalysisRecipe(input: {
321
336
  }
322
337
  let statistic;
323
338
  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
- }
339
+ statistic = calculateRecipeExposureRatio({
340
+ measure,
341
+ observations: measureObservations,
342
+ selection: { kind: "evaluation-date", evaluationDate, observationSelection: input.definition.period.observationSelection },
343
+ exposure: exposedByTarget.get(originId)!.get(measure.statistic.denominatorMeasureId)!,
344
+ });
340
345
  } else {
341
346
  statistic = calculateAnalysisStatistic({
342
347
  measure,
343
348
  observations: measureObservations,
344
- selection: { kind: "evaluation-date", evaluationDate, observationSelection: "exact" },
349
+ selection: { kind: "evaluation-date", evaluationDate, observationSelection: input.definition.period.observationSelection },
345
350
  });
346
351
  }
347
352
  values.push({
@@ -358,9 +363,18 @@ export function runAnalysisRecipe(input: {
358
363
  findings: statistic.findings,
359
364
  });
360
365
  }
366
+ values.push(...evaluateAnalysisExpressions({
367
+ definition: outputDefinition,
368
+ values: values.slice(start),
369
+ coordinates: { originId, originLabel: group.label, evaluationDate },
370
+ exposures: exposedByTarget.get(originId) ?? new Map(),
371
+ exposureFindings: exposureFindingsByMeasure,
372
+ requested: requestedMeasureIds,
373
+ }));
361
374
  }
362
375
  if (originFailures.length > 0) {
363
- for (const measure of input.definition.measures.filter((candidate) => measureIds.has(candidate.id))) {
376
+ const start = values.length;
377
+ for (const measure of outputDefinition.measures.filter((candidate) => measureIds.has(candidate.id))) {
364
378
  const originFindings = originFailures
365
379
  .filter(({ observation }) => measure.populationScopeId === input.definition.scopes.financial.id || reportingKeys.has(key(observation)))
366
380
  .map(({ finding }) => finding);
@@ -379,13 +393,22 @@ export function runAnalysisRecipe(input: {
379
393
  findings: originFindings,
380
394
  });
381
395
  }
396
+ values.push(...evaluateAnalysisExpressions({
397
+ definition: outputDefinition,
398
+ values: values.slice(start),
399
+ coordinates: { originId: "unassigned", originLabel: "Unavailable origin", evaluationDate },
400
+ exposures: new Map(), exposureFindings: exposureFindingsByMeasure, requested: requestedMeasureIds,
401
+ }));
382
402
  }
383
403
  const calculationInput = {
404
+ corePackageVersion: CORE_PACKAGE_VERSION,
384
405
  historyRevisionId: prepared.calculationRevisionId,
406
+ historyFindings: prepared.findings,
385
407
  definition: input.definition,
386
408
  recipe: input.recipe,
387
409
  evaluationDate,
388
410
  exposureRevisionIds: exposureCalculationRevisions.sort(),
411
+ exposureTargets: input.exposureTargets ?? [],
389
412
  };
390
413
  const lineageInput = {
391
414
  historyRevisionId: prepared.lineageRevisionId,
@@ -420,12 +443,13 @@ export function applyAnalysisScenario(
420
443
  }
421
444
  if (scenarioIssues.length > 0) throw new DiagnosticValidationError(scenarioIssues);
422
445
  let reportingPredicate: AnalysisPredicate = definition.scopes.reporting.predicate;
446
+ for (const change of scenario.changes)
447
+ if (change.kind === "reporting-predicate") reportingPredicate = change.predicate;
423
448
  const terms = definition.financialTerms.map((term) => {
424
449
  let current = term;
425
450
  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")
451
+ if (change.kind === "financial-term-limit" && change.termId === term.id) current = { ...current, limit: change.limit };
452
+ else if (change.kind === "financial-term-attachment" && change.termId === term.id)
429
453
  current = current.kind === "policy-aggregate" ? { ...current, deductible: change.amount } : { ...current, attachment: change.amount };
430
454
  }
431
455
  return current;
@@ -438,39 +462,6 @@ export function applyAnalysisScenario(
438
462
  });
439
463
  }
440
464
 
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
465
  /** Runs a bounded list of controlled scenarios against one immutable baseline. */
475
466
  export function runAnalysisScenarios(input: {
476
467
  readonly history: HistoricalDatasetInput;
@@ -618,7 +609,7 @@ export function runAnalysisScenarioGrid(
618
609
 
619
610
  /** Complete dependency identity for exact full-result reuse, including lineage. */
620
611
  export function analysisRecipeDependencyIdentity(input: Parameters<typeof runAnalysisRecipe>[0]): string {
621
- return `fnv1a64-jcs-v1:${fnv1a64(canonicalJson({ kind: "analysis-recipe-dependencies", input }))}`;
612
+ return `fnv1a64-jcs-v1:${fnv1a64(canonicalJson({ kind: "analysis-recipe-dependencies", corePackageVersion: CORE_PACKAGE_VERSION, input }))}`;
622
613
  }
623
614
 
624
615
  /** Uses caller-owned cache storage; a hit is returned only for the complete dependency identity. */
@@ -656,7 +647,7 @@ export function rollForwardAnalysisRecipe(input: Parameters<typeof runAnalysisRe
656
647
  currentEvaluationDate,
657
648
  priorResult,
658
649
  currentResult,
659
- differences: compareAnalysisResults(priorResult, currentResult),
650
+ differences: compareAnalysisEvaluations(priorResult, currentResult, priorEvaluationDate, currentEvaluationDate),
660
651
  });
661
652
  }
662
653
 
@@ -0,0 +1,59 @@
1
+ import type {
2
+ AnalysisMeasureDefinitionV1,
3
+ AnalysisObservationSelection,
4
+ AnalysisStatisticResult,
5
+ PreparedHistoricalObservation,
6
+ } from "./customizationContracts.js";
7
+ import { calculateAnalysisStatistic } from "./descriptiveStatistics.js";
8
+ import { snapshotDiagnosticJson } from "./diagnosticRuntime.js";
9
+
10
+ /** A separate exposure population contributes its denominator once per origin. */
11
+ export function calculateRecipeExposureRatio(input: {
12
+ readonly measure: AnalysisMeasureDefinitionV1;
13
+ readonly observations: readonly PreparedHistoricalObservation[];
14
+ readonly selection: Extract<AnalysisObservationSelection, { kind: "evaluation-date" }>;
15
+ readonly exposure: number;
16
+ }): AnalysisStatisticResult {
17
+ const { measure, observations, selection, exposure } = input;
18
+ if (measure.statistic.kind !== "ratio") throw new Error("An exposure ratio requires a ratio statistic");
19
+ const numerator = calculateAnalysisStatistic({
20
+ measure: { ...measure, statistic: { kind: "sum", measureId: measure.statistic.numeratorMeasureId } },
21
+ observations,
22
+ selection,
23
+ });
24
+ // Unknown amounts cannot be treated as zeros while the complete exposure
25
+ // population still contributes to the denominator.
26
+ if (numerator.status !== "available" || numerator.excludedObservations > 0) {
27
+ return snapshotDiagnosticJson({
28
+ ...numerator,
29
+ value: null,
30
+ status: "unavailable" as const,
31
+ numerator: numerator.value,
32
+ denominator: exposure,
33
+ findings: [...numerator.findings, {
34
+ capability: "descriptive-statistics" as const,
35
+ code: "missing-prerequisite" as const,
36
+ path: "$.measure.statistic.numeratorMeasureId",
37
+ message: "An exposure ratio requires observed numerator amounts for the selected population",
38
+ }],
39
+ });
40
+ }
41
+ const aggregate: PreparedHistoricalObservation = {
42
+ ...observations[0]!,
43
+ evaluationDate: selection.evaluationDate,
44
+ measures: {
45
+ [measure.statistic.numeratorMeasureId]: numerator.value,
46
+ [measure.statistic.denominatorMeasureId]: exposure,
47
+ },
48
+ };
49
+ const ratio = calculateAnalysisStatistic({
50
+ measure,
51
+ observations: [aggregate],
52
+ selection: { ...selection, observationSelection: "exact" },
53
+ });
54
+ return snapshotDiagnosticJson({
55
+ ...ratio,
56
+ contributingObservations: numerator.contributingObservations,
57
+ excludedObservations: numerator.excludedObservations,
58
+ });
59
+ }
@@ -13,6 +13,7 @@ export const CUSTOMIZATION_CAPABILITIES = [
13
13
  "exposure-earning",
14
14
  "typed-populations",
15
15
  "descriptive-statistics",
16
+ "derived-analysis-measures",
16
17
  "reserving-triangle-adapter",
17
18
  "bounded-execution",
18
19
  "shared-financial-terms",
@@ -426,12 +427,39 @@ export type AnalysisStatisticDefinition =
426
427
  export interface AnalysisMeasureDefinitionV1 {
427
428
  readonly id: string;
428
429
  readonly unit: string;
430
+ /** Optional explicit dimensions for composing already-derived units. Unit labels are otherwise opaque atoms. */
431
+ readonly unitFactors?: Readonly<Record<string, number>>;
429
432
  readonly developmentSemantics: "cumulative" | "incremental" | "point-in-time";
430
433
  readonly statistic: AnalysisStatisticDefinition;
431
434
  readonly populationScopeId: string;
432
435
  readonly condition?: AnalysisPredicate;
433
436
  }
434
437
 
438
+ export type AnalysisMeasureExpression =
439
+ | { readonly op: "measure"; readonly measureId: string }
440
+ | { readonly op: "exposure"; readonly exposureId: string }
441
+ | { readonly op: "constant"; readonly value: number; readonly unitFactors: Readonly<Record<string, number>> }
442
+ | { readonly op: "add"; readonly terms: readonly AnalysisMeasureExpression[] }
443
+ | { readonly op: "subtract" | "multiply"; readonly left: AnalysisMeasureExpression; readonly right: AnalysisMeasureExpression }
444
+ | { readonly op: "divide"; readonly numerator: AnalysisMeasureExpression; readonly denominator: AnalysisMeasureExpression };
445
+
446
+ /** Post-aggregation arithmetic. Each referenced statistic retains its own population and condition. */
447
+ export interface AnalysisDerivedMeasureDefinition {
448
+ readonly id: string;
449
+ readonly expression: AnalysisMeasureExpression;
450
+ readonly developmentSemantics: "point-in-time" | "cumulative" | "incremental";
451
+ }
452
+
453
+ export interface AnalysisDerivedComponent {
454
+ readonly kind: "measure" | "exposure";
455
+ readonly id: string;
456
+ readonly unit: string;
457
+ readonly value: number | null;
458
+ readonly status: "available" | "unavailable";
459
+ readonly contributingObservations: number;
460
+ readonly excludedObservations: number;
461
+ }
462
+
435
463
  export type AnalysisObservationSelection =
436
464
  | {
437
465
  readonly kind: "evaluation-date";
@@ -562,6 +590,7 @@ export interface AnalysisDefinition {
562
590
  readonly period: AnalysisPeriodDefinition;
563
591
  readonly exposures: readonly ExposureMeasureDefinitionV1[];
564
592
  readonly measures: readonly AnalysisMeasureDefinitionV1[];
593
+ readonly derivedMeasures?: readonly AnalysisDerivedMeasureDefinition[];
565
594
  readonly financialTerms: readonly FinancialTermDefinition[];
566
595
  readonly financialAdjustments?: readonly FinancialAmountAdjustment[];
567
596
  }
@@ -672,6 +701,12 @@ export interface AnalysisResultValue {
672
701
  readonly numerator: number | null;
673
702
  readonly denominator: number | null;
674
703
  readonly findings: readonly CustomizationCapabilityFinding[];
704
+ /** Counts on a derived value are not additive population counts; inspect each component instead. */
705
+ readonly derivation?: {
706
+ readonly stage: "post-aggregation";
707
+ readonly unitFactors: Readonly<Record<string, number>>;
708
+ readonly components: readonly AnalysisDerivedComponent[];
709
+ };
675
710
  }
676
711
 
677
712
  export interface AnalysisResult {