@actuarial-ts/core 0.8.1 → 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.
- package/README.md +9 -6
- package/dist/analysisExpressions.d.ts +24 -0
- package/dist/analysisExpressions.d.ts.map +1 -0
- package/dist/analysisExpressions.js +256 -0
- package/dist/analysisExpressions.js.map +1 -0
- package/dist/analysisFinancialUnits.d.ts +4 -0
- package/dist/analysisFinancialUnits.d.ts.map +1 -0
- package/dist/analysisFinancialUnits.js +62 -0
- package/dist/analysisFinancialUnits.js.map +1 -0
- package/dist/analysisRecipe.d.ts.map +1 -1
- package/dist/analysisRecipe.js +25 -4
- package/dist/analysisRecipe.js.map +1 -1
- package/dist/customizationContracts.d.ts +47 -1
- package/dist/customizationContracts.d.ts.map +1 -1
- package/dist/customizationContracts.js +1 -0
- package/dist/customizationContracts.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/analysisExpressions.ts +223 -0
- package/src/analysisFinancialUnits.ts +55 -0
- package/src/analysisRecipe.ts +25 -4
- package/src/customizationContracts.ts +35 -0
- package/src/index.ts +2 -0
- 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
|
+
}
|
package/src/analysisRecipe.ts
CHANGED
|
@@ -23,6 +23,8 @@ import type {
|
|
|
23
23
|
} from "./customizationContracts.js";
|
|
24
24
|
import { calculateAnalysisStatistic } from "./descriptiveStatistics.js";
|
|
25
25
|
import { calculateRecipeExposureRatio } from "./analysisRecipeStatistics.js";
|
|
26
|
+
import { compileAnalysisExpressions, analysisExpressionDependencies, evaluateAnalysisExpressions } from "./analysisExpressions.js";
|
|
27
|
+
import { resolveAnalysisFinancialUnits } from "./analysisFinancialUnits.js";
|
|
26
28
|
import { compareAnalysisEvaluations, compareAnalysisResults } from "./analysisComparison.js";
|
|
27
29
|
export { compareAnalysisResults } from "./analysisComparison.js";
|
|
28
30
|
import { isDiagnosticToken, snapshotDiagnosticJson } from "./diagnosticRuntime.js";
|
|
@@ -75,7 +77,7 @@ function recipeIssues(
|
|
|
75
77
|
hasCalculate = true;
|
|
76
78
|
if (step.kind === "calculate")
|
|
77
79
|
step.measureIds.forEach((id) => {
|
|
78
|
-
if (!definition.measures.some((measure) => measure.id === id))
|
|
80
|
+
if (![...definition.measures, ...(definition.derivedMeasures ?? [])].some((measure) => measure.id === id))
|
|
79
81
|
issues.push({ domain: "configuration", code: "unknown-reference", path: `$.recipe.steps[${index}].measureIds`, message: `Unknown measure ${id}` });
|
|
80
82
|
});
|
|
81
83
|
if (step.kind === "earn-exposure")
|
|
@@ -166,6 +168,8 @@ export function runAnalysisRecipe(input: {
|
|
|
166
168
|
}): AnalysisResult {
|
|
167
169
|
const issues = recipeIssues(input.recipe, input.definition, input.history);
|
|
168
170
|
if (issues.length > 0) throw new DiagnosticValidationError(issues);
|
|
171
|
+
const outputDefinition = resolveAnalysisFinancialUnits(input.definition, input.recipe);
|
|
172
|
+
const expressions = compileAnalysisExpressions(outputDefinition);
|
|
169
173
|
const prepared = prepareHistoricalDataset(input.history);
|
|
170
174
|
const evaluationDate = input.evaluationDate ?? maxEvaluationDate(prepared.observations);
|
|
171
175
|
const selected = selectedAt(prepared.observations, evaluationDate, input.definition.period.observationSelection);
|
|
@@ -280,16 +284,18 @@ export function runAnalysisRecipe(input: {
|
|
|
280
284
|
if (group === undefined) groups.set(origin.originId, { label: origin.label, observations: [observation] });
|
|
281
285
|
else group.observations.push(observation);
|
|
282
286
|
}
|
|
283
|
-
const
|
|
287
|
+
const requestedMeasureIds = new Set(
|
|
284
288
|
input.recipe.steps.filter((step) => step.kind === "calculate").flatMap((step) => step.measureIds),
|
|
285
289
|
);
|
|
290
|
+
const measureIds = analysisExpressionDependencies(expressions, requestedMeasureIds);
|
|
286
291
|
// When preparation excluded every observation, retain visible unavailable
|
|
287
292
|
// output instead of hiding the preparation findings in an empty result.
|
|
288
293
|
if (groups.size === 0 && prepared.findings.length > 0)
|
|
289
294
|
groups.set("unassigned", { label: "Unavailable history", observations: [] });
|
|
290
295
|
const values: AnalysisResultValue[] = [];
|
|
291
296
|
for (const [originId, group] of [...groups].sort(([left], [right]) => left.localeCompare(right))) {
|
|
292
|
-
|
|
297
|
+
const start = values.length;
|
|
298
|
+
for (const measure of outputDefinition.measures.filter((candidate) => measureIds.has(candidate.id))) {
|
|
293
299
|
let measureObservations = measure.populationScopeId === input.definition.scopes.financial.id
|
|
294
300
|
? group.observations
|
|
295
301
|
: group.observations.filter((observation) => reportingKeys.has(key(observation)));
|
|
@@ -357,9 +363,18 @@ export function runAnalysisRecipe(input: {
|
|
|
357
363
|
findings: statistic.findings,
|
|
358
364
|
});
|
|
359
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
|
+
}));
|
|
360
374
|
}
|
|
361
375
|
if (originFailures.length > 0) {
|
|
362
|
-
|
|
376
|
+
const start = values.length;
|
|
377
|
+
for (const measure of outputDefinition.measures.filter((candidate) => measureIds.has(candidate.id))) {
|
|
363
378
|
const originFindings = originFailures
|
|
364
379
|
.filter(({ observation }) => measure.populationScopeId === input.definition.scopes.financial.id || reportingKeys.has(key(observation)))
|
|
365
380
|
.map(({ finding }) => finding);
|
|
@@ -378,6 +393,12 @@ export function runAnalysisRecipe(input: {
|
|
|
378
393
|
findings: originFindings,
|
|
379
394
|
});
|
|
380
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
|
+
}));
|
|
381
402
|
}
|
|
382
403
|
const calculationInput = {
|
|
383
404
|
corePackageVersion: CORE_PACKAGE_VERSION,
|
|
@@ -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 {
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,8 @@ export * from "./reservingAdapter.js";
|
|
|
12
12
|
export * from "./financialTerms.js";
|
|
13
13
|
export * from "./financialPipeline.js";
|
|
14
14
|
export * from "./analysisRecipe.js";
|
|
15
|
+
export * from "./analysisExpressions.js";
|
|
16
|
+
export * from "./analysisFinancialUnits.js";
|
|
15
17
|
export * from "./util.js";
|
|
16
18
|
export * from "./canonical.js";
|
|
17
19
|
export {
|
package/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** This package's runtime version. A test pins it to package.json. */
|
|
2
|
-
export const CORE_PACKAGE_VERSION = "0.
|
|
2
|
+
export const CORE_PACKAGE_VERSION = "0.9.0";
|