@actuarial-ts/core 0.4.0 → 0.6.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 +65 -215
- package/dist/casualtyDiagnostics.d.ts +19 -49
- package/dist/casualtyDiagnostics.d.ts.map +1 -1
- package/dist/casualtyDiagnostics.js +84 -114
- package/dist/casualtyDiagnostics.js.map +1 -1
- package/dist/diagnosticAggregation.d.ts +29 -0
- package/dist/diagnosticAggregation.d.ts.map +1 -0
- package/dist/diagnosticAggregation.js +50 -0
- package/dist/diagnosticAggregation.js.map +1 -0
- package/dist/diagnosticDefinitions.d.ts +286 -0
- package/dist/diagnosticDefinitions.d.ts.map +1 -0
- package/dist/diagnosticDefinitions.js +1026 -0
- package/dist/diagnosticDefinitions.js.map +1 -0
- package/dist/diagnosticDerivations.d.ts +12 -0
- package/dist/diagnosticDerivations.d.ts.map +1 -0
- package/dist/diagnosticDerivations.js +80 -0
- package/dist/diagnosticDerivations.js.map +1 -0
- package/dist/diagnosticExposure.d.ts +51 -0
- package/dist/diagnosticExposure.d.ts.map +1 -0
- package/dist/diagnosticExposure.js +65 -0
- package/dist/diagnosticExposure.js.map +1 -0
- package/dist/diagnosticExpressions.d.ts +56 -0
- package/dist/diagnosticExpressions.d.ts.map +1 -0
- package/dist/diagnosticExpressions.js +104 -0
- package/dist/diagnosticExpressions.js.map +1 -0
- package/dist/diagnosticFormulas.d.ts +195 -0
- package/dist/diagnosticFormulas.d.ts.map +1 -0
- package/dist/diagnosticFormulas.js +85 -0
- package/dist/diagnosticFormulas.js.map +1 -0
- package/dist/diagnosticIdentity.d.ts +237 -0
- package/dist/diagnosticIdentity.d.ts.map +1 -0
- package/dist/diagnosticIdentity.js +454 -0
- package/dist/diagnosticIdentity.js.map +1 -0
- package/dist/diagnosticPeriods.d.ts +16 -0
- package/dist/diagnosticPeriods.d.ts.map +1 -0
- package/dist/diagnosticPeriods.js +98 -0
- package/dist/diagnosticPeriods.js.map +1 -0
- package/dist/diagnosticPreparation.d.ts +98 -0
- package/dist/diagnosticPreparation.d.ts.map +1 -0
- package/dist/diagnosticPreparation.js +169 -0
- package/dist/diagnosticPreparation.js.map +1 -0
- package/dist/diagnosticReview.d.ts +44 -0
- package/dist/diagnosticReview.d.ts.map +1 -0
- package/dist/diagnosticReview.js +84 -0
- package/dist/diagnosticReview.js.map +1 -0
- package/dist/diagnosticRules.d.ts +32 -0
- package/dist/diagnosticRules.d.ts.map +1 -0
- package/dist/diagnosticRules.js +32 -0
- package/dist/diagnosticRules.js.map +1 -0
- package/dist/diagnosticRunner.d.ts +85 -0
- package/dist/diagnosticRunner.d.ts.map +1 -0
- package/dist/diagnosticRunner.js +117 -0
- package/dist/diagnosticRunner.js.map +1 -0
- package/dist/index.d.ts +24 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +114 -0
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/package.json +2 -2
- package/src/casualtyDiagnostics.ts +79 -187
- package/src/diagnosticAggregation.ts +79 -0
- package/src/diagnosticDefinitions.ts +1375 -0
- package/src/diagnosticDerivations.ts +89 -0
- package/src/diagnosticExposure.ts +91 -0
- package/src/diagnosticExpressions.ts +173 -0
- package/src/diagnosticFormulas.ts +150 -0
- package/src/diagnosticIdentity.ts +767 -0
- package/src/diagnosticPeriods.ts +117 -0
- package/src/diagnosticPreparation.ts +233 -0
- package/src/diagnosticReview.ts +91 -0
- package/src/diagnosticRules.ts +69 -0
- package/src/diagnosticRunner.ts +110 -0
- package/src/index.ts +156 -1
- package/src/types.ts +161 -0
- package/src/version.ts +2 -0
- package/dist/metricDiagnostics.d.ts +0 -212
- package/dist/metricDiagnostics.d.ts.map +0 -1
- package/dist/metricDiagnostics.js +0 -627
- package/dist/metricDiagnostics.js.map +0 -1
- package/src/metricDiagnostics.ts +0 -876
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_DIAGNOSTIC_DEFINITION_EXPRESSION_NODES,
|
|
3
|
+
walkDiagnosticExpression,
|
|
4
|
+
type DiagnosticClaimExpression,
|
|
5
|
+
type DiagnosticMeasureExpression,
|
|
6
|
+
type DiagnosticRoleExpression,
|
|
7
|
+
} from "./diagnosticExpressions.js";
|
|
8
|
+
import {
|
|
9
|
+
buildDiagnosticIdentities,
|
|
10
|
+
normalizeDiagnosticDefinition,
|
|
11
|
+
type NormalizedDiagnosticCalculationScope,
|
|
12
|
+
type NormalizedDiagnosticDefinitionIdentity,
|
|
13
|
+
type NormalizedDiagnosticFormulaIdentity,
|
|
14
|
+
} from "./diagnosticIdentity.js";
|
|
15
|
+
import {
|
|
16
|
+
DiagnosticValidationError,
|
|
17
|
+
type DiagnosticValidationIssue,
|
|
18
|
+
type DiagnosticValidationIssueCode,
|
|
19
|
+
} from "./types.js";
|
|
20
|
+
import { canonicalJson } from "./canonical.js";
|
|
21
|
+
|
|
22
|
+
export type DiagnosticMeasureKind = "count" | "amount" | "exposure";
|
|
23
|
+
export type DiagnosticMeasureSource = "loss" | "exposure" | "derived";
|
|
24
|
+
export type DiagnosticMissingPolicy = "unknown" | "zero";
|
|
25
|
+
export type DiagnosticAggregation = "sum";
|
|
26
|
+
export type DiagnosticExposureTiming = "origin-static" | "valuation-specific";
|
|
27
|
+
export type DiagnosticDevelopmentSemantics =
|
|
28
|
+
| "cumulative"
|
|
29
|
+
| "incremental"
|
|
30
|
+
| "point-in-time"
|
|
31
|
+
| "unknown";
|
|
32
|
+
|
|
33
|
+
export type JsonValue =
|
|
34
|
+
| null
|
|
35
|
+
| boolean
|
|
36
|
+
| number
|
|
37
|
+
| string
|
|
38
|
+
| readonly JsonValue[]
|
|
39
|
+
| { readonly [key: string]: JsonValue };
|
|
40
|
+
|
|
41
|
+
export type DiagnosticDeepReadonly<T> =
|
|
42
|
+
T extends readonly (infer U)[]
|
|
43
|
+
? readonly DiagnosticDeepReadonly<U>[]
|
|
44
|
+
: T extends object
|
|
45
|
+
? { readonly [K in keyof T]: DiagnosticDeepReadonly<T[K]> }
|
|
46
|
+
: T;
|
|
47
|
+
|
|
48
|
+
export interface DiagnosticMeasureDefinition {
|
|
49
|
+
id: string;
|
|
50
|
+
displayName: string;
|
|
51
|
+
description: string;
|
|
52
|
+
source: DiagnosticMeasureSource;
|
|
53
|
+
kind: DiagnosticMeasureKind;
|
|
54
|
+
unit: string;
|
|
55
|
+
developmentSemantics: DiagnosticDevelopmentSemantics;
|
|
56
|
+
aggregation: DiagnosticAggregation;
|
|
57
|
+
missing: DiagnosticMissingPolicy;
|
|
58
|
+
basisId?: string;
|
|
59
|
+
countPopulationId?: string;
|
|
60
|
+
exposureBasisId?: string;
|
|
61
|
+
exposureTiming?: DiagnosticExposureTiming;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DiagnosticCountPopulationDefinition {
|
|
65
|
+
id: string;
|
|
66
|
+
displayName: string;
|
|
67
|
+
subject: "claim" | "claimant" | "policy" | "occurrence" | "other" | "unknown";
|
|
68
|
+
unit: string;
|
|
69
|
+
description: string;
|
|
70
|
+
attributes?: Readonly<Record<string, string | number | boolean | null>>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface DiagnosticExposureBasisDefinition {
|
|
74
|
+
id: string;
|
|
75
|
+
displayName: string;
|
|
76
|
+
basis: "earned" | "written" | "in-force" | "other" | "unknown";
|
|
77
|
+
unit: string;
|
|
78
|
+
description: string;
|
|
79
|
+
sourceDescription?: string;
|
|
80
|
+
attributes?: Readonly<Record<string, string | number | boolean | null>>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type AmountPerspective = "gross" | "net" | "ceded" | "other" | "unknown";
|
|
84
|
+
|
|
85
|
+
export type AmountLimitation =
|
|
86
|
+
| { kind: "unlimited" }
|
|
87
|
+
| {
|
|
88
|
+
kind: "layer" | "pre-limited";
|
|
89
|
+
attachment: number;
|
|
90
|
+
limit: number | null;
|
|
91
|
+
application: "claim" | "occurrence" | "policy" | "source-defined";
|
|
92
|
+
derivation:
|
|
93
|
+
| { kind: "sdk" }
|
|
94
|
+
| {
|
|
95
|
+
kind: "external";
|
|
96
|
+
actor: "caller" | "source";
|
|
97
|
+
transformationRef: string;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
| { kind: "unknown"; description?: string };
|
|
101
|
+
|
|
102
|
+
export interface AmountBasisComponent {
|
|
103
|
+
id: string;
|
|
104
|
+
treatment: "included" | "excluded" | "unknown";
|
|
105
|
+
limitation: AmountLimitation;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface AmountBasisDefinition {
|
|
109
|
+
id: string;
|
|
110
|
+
displayName: string;
|
|
111
|
+
currency: string;
|
|
112
|
+
perspective: AmountPerspective;
|
|
113
|
+
components: readonly AmountBasisComponent[];
|
|
114
|
+
sourceDescription?: string;
|
|
115
|
+
attributes?: Readonly<Record<string, string | number | boolean | null>>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface DiagnosticFormulaRole {
|
|
119
|
+
kind: DiagnosticMeasureKind;
|
|
120
|
+
compatibilityGroup?: string;
|
|
121
|
+
developmentSemantics?: DiagnosticDevelopmentSemantics;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface DiagnosticFormulaTemplate {
|
|
125
|
+
id: string;
|
|
126
|
+
version: string;
|
|
127
|
+
roles: Readonly<Record<string, DiagnosticFormulaRole>>;
|
|
128
|
+
numerator: DiagnosticRoleExpression;
|
|
129
|
+
denominator: DiagnosticRoleExpression;
|
|
130
|
+
denominatorPolicy: "positive-or-null";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface DiagnosticMetricPresentation {
|
|
134
|
+
displayName: string;
|
|
135
|
+
description: string;
|
|
136
|
+
displayUnit: string;
|
|
137
|
+
scale: number;
|
|
138
|
+
numeratorLabel: string;
|
|
139
|
+
denominatorLabel: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type DiagnosticRuleOperand =
|
|
143
|
+
| { source: "measure"; expression: DiagnosticMeasureExpression }
|
|
144
|
+
| { source: "calculation"; field: "numerator" | "denominator" }
|
|
145
|
+
| { source: "constant"; value: number };
|
|
146
|
+
|
|
147
|
+
export interface DiagnosticComparisonPredicate {
|
|
148
|
+
left: DiagnosticRuleOperand;
|
|
149
|
+
operator: "lt" | "lte" | "eq" | "neq" | "gte" | "gt";
|
|
150
|
+
right: DiagnosticRuleOperand;
|
|
151
|
+
tolerance?: { absolute?: number; relative?: number };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface DiagnosticComparisonRule {
|
|
155
|
+
id: string;
|
|
156
|
+
code: string;
|
|
157
|
+
message: string;
|
|
158
|
+
severity: "warning" | "fail";
|
|
159
|
+
when: DiagnosticComparisonPredicate;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface DiagnosticMetricInstance {
|
|
163
|
+
id: string;
|
|
164
|
+
version: string;
|
|
165
|
+
formulaId: string;
|
|
166
|
+
bindings: Readonly<Record<string, DiagnosticMeasureExpression>>;
|
|
167
|
+
presentation: DiagnosticMetricPresentation;
|
|
168
|
+
rules: readonly DiagnosticComparisonRule[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface DiagnosticPeriodCoordinate {
|
|
172
|
+
label: string;
|
|
173
|
+
aliases?: readonly string[];
|
|
174
|
+
coordinate: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type DiagnosticPeriodAxis =
|
|
178
|
+
| {
|
|
179
|
+
kind: "calendar";
|
|
180
|
+
originCadence: "month" | "quarter" | "year";
|
|
181
|
+
valuationCadence: "month" | "quarter" | "year";
|
|
182
|
+
originAnchor: "start" | "end";
|
|
183
|
+
valuationAnchor: "start" | "end";
|
|
184
|
+
ageUnit: "month";
|
|
185
|
+
ageOffset: number;
|
|
186
|
+
}
|
|
187
|
+
| {
|
|
188
|
+
kind: "ordered";
|
|
189
|
+
id: string;
|
|
190
|
+
version: string;
|
|
191
|
+
ageUnit: string;
|
|
192
|
+
ageOffset: number;
|
|
193
|
+
origins: readonly DiagnosticPeriodCoordinate[];
|
|
194
|
+
valuations: readonly DiagnosticPeriodCoordinate[];
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export interface DiagnosticDerivedMeasureDefinition {
|
|
198
|
+
id: string;
|
|
199
|
+
outputMeasureId: string;
|
|
200
|
+
expression: DiagnosticClaimExpression;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface DiagnosticsFilter {
|
|
204
|
+
sourceGroups?: readonly string[];
|
|
205
|
+
outputGroups?: readonly string[];
|
|
206
|
+
origins?: readonly string[];
|
|
207
|
+
originFrom?: string;
|
|
208
|
+
originThrough?: string;
|
|
209
|
+
valuations?: readonly string[];
|
|
210
|
+
valuationFrom?: string;
|
|
211
|
+
valuationThrough?: string;
|
|
212
|
+
minDevelopmentAge?: number;
|
|
213
|
+
maxDevelopmentAge?: number;
|
|
214
|
+
instanceIds?: readonly string[];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface DiagnosticReviewRuleBase {
|
|
218
|
+
id: string;
|
|
219
|
+
code: string;
|
|
220
|
+
description: string;
|
|
221
|
+
severity: "warning" | "fail";
|
|
222
|
+
tolerance?: { absolute?: number; relative?: number };
|
|
223
|
+
missingInput: "not-evaluated" | "finding";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export type DiagnosticReviewOperand =
|
|
227
|
+
| DiagnosticMeasureExpression
|
|
228
|
+
| { op: "constant"; value: number };
|
|
229
|
+
|
|
230
|
+
export interface DiagnosticReviewPredicate {
|
|
231
|
+
left: DiagnosticReviewOperand;
|
|
232
|
+
operator: "lt" | "lte" | "eq" | "neq" | "gte" | "gt";
|
|
233
|
+
right: DiagnosticReviewOperand;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export type DiagnosticControlTotalProjection =
|
|
237
|
+
| { kind: "valuation"; valuation: string }
|
|
238
|
+
| { kind: "latest-valuation-per-origin" }
|
|
239
|
+
| { kind: "all-cells" };
|
|
240
|
+
|
|
241
|
+
export type DiagnosticReviewFilter = Omit<DiagnosticsFilter, "outputGroups" | "instanceIds">;
|
|
242
|
+
|
|
243
|
+
export type DiagnosticReviewRule =
|
|
244
|
+
| (DiagnosticReviewRuleBase & {
|
|
245
|
+
kind: "compare";
|
|
246
|
+
when: DiagnosticReviewPredicate;
|
|
247
|
+
})
|
|
248
|
+
| (DiagnosticReviewRuleBase & {
|
|
249
|
+
kind: "reconcile";
|
|
250
|
+
actual: DiagnosticMeasureExpression;
|
|
251
|
+
expected: DiagnosticReviewOperand;
|
|
252
|
+
})
|
|
253
|
+
| (DiagnosticReviewRuleBase & {
|
|
254
|
+
kind: "monotonic";
|
|
255
|
+
expression: DiagnosticMeasureExpression;
|
|
256
|
+
direction: "nondecreasing" | "nonincreasing";
|
|
257
|
+
})
|
|
258
|
+
| (DiagnosticReviewRuleBase & {
|
|
259
|
+
kind: "layer-order";
|
|
260
|
+
narrower: DiagnosticMeasureExpression;
|
|
261
|
+
broader: DiagnosticMeasureExpression;
|
|
262
|
+
comparability:
|
|
263
|
+
| { kind: "compiler-proven" }
|
|
264
|
+
| { kind: "caller-asserted"; rationaleArtifactId: string };
|
|
265
|
+
})
|
|
266
|
+
| (DiagnosticReviewRuleBase & {
|
|
267
|
+
kind: "control-total";
|
|
268
|
+
expression: DiagnosticMeasureExpression;
|
|
269
|
+
expected: number;
|
|
270
|
+
filter?: DiagnosticReviewFilter;
|
|
271
|
+
projection: DiagnosticControlTotalProjection;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
export interface DiagnosticDefinition {
|
|
275
|
+
diagnosticDefinitionVersion: "1.0.0";
|
|
276
|
+
id: string;
|
|
277
|
+
version: string;
|
|
278
|
+
lossRowGrain: "claim" | "aggregate";
|
|
279
|
+
measures: readonly DiagnosticMeasureDefinition[];
|
|
280
|
+
countPopulations: readonly DiagnosticCountPopulationDefinition[];
|
|
281
|
+
exposureBases: readonly DiagnosticExposureBasisDefinition[];
|
|
282
|
+
amountBases: readonly AmountBasisDefinition[];
|
|
283
|
+
derivedMeasures: readonly DiagnosticDerivedMeasureDefinition[];
|
|
284
|
+
formulas: readonly DiagnosticFormulaTemplate[];
|
|
285
|
+
instances: readonly DiagnosticMetricInstance[];
|
|
286
|
+
reviewRules: readonly DiagnosticReviewRule[];
|
|
287
|
+
periodAxis: DiagnosticPeriodAxis;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface DiagnosticSourceLocation {
|
|
291
|
+
readonly artifactId: string;
|
|
292
|
+
readonly sourceFile?: string;
|
|
293
|
+
readonly sourceSheet?: string;
|
|
294
|
+
readonly sourceRow?: number;
|
|
295
|
+
readonly sourceCell?: string;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface DiagnosticMeasureStats {
|
|
299
|
+
readonly value: number | null;
|
|
300
|
+
readonly sum: number | null;
|
|
301
|
+
readonly observed: number;
|
|
302
|
+
readonly missing: number;
|
|
303
|
+
readonly nonFinite: number;
|
|
304
|
+
readonly imputedZero: number;
|
|
305
|
+
readonly deduplicated: number;
|
|
306
|
+
readonly structural: number;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const compiledDiagnosticDefinitionBrand: unique symbol = Symbol("compiled-diagnostic-definition");
|
|
310
|
+
|
|
311
|
+
export interface CompiledDiagnosticDefinition {
|
|
312
|
+
readonly [compiledDiagnosticDefinitionBrand]: true;
|
|
313
|
+
readonly definition: DiagnosticDeepReadonly<NormalizedDiagnosticDefinitionIdentity>;
|
|
314
|
+
readonly formulaFingerprints: Readonly<Record<string, string>>;
|
|
315
|
+
readonly calculationFingerprints: Readonly<Record<string, string>>;
|
|
316
|
+
readonly definitionIntegrity: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface CompiledDiagnosticDefinitionInternals {
|
|
320
|
+
readonly formulasById: ReadonlyMap<string, NormalizedDiagnosticFormulaIdentity>;
|
|
321
|
+
readonly measuresById: ReadonlyMap<string, DiagnosticMeasureDefinition>;
|
|
322
|
+
readonly calculationScopesByInstanceId: ReadonlyMap<string, NormalizedDiagnosticCalculationScope>;
|
|
323
|
+
readonly calculationDependenciesByInstanceId: ReadonlyMap<string, readonly string[]>;
|
|
324
|
+
readonly evaluationDependenciesByInstanceId: ReadonlyMap<string, readonly string[]>;
|
|
325
|
+
readonly derivationsByOutputMeasureId: ReadonlyMap<string, DiagnosticDerivedMeasureDefinition>;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const authenticCompiledDefinitions = new WeakSet<object>();
|
|
329
|
+
const compiledInternals = new WeakMap<object, CompiledDiagnosticDefinitionInternals>();
|
|
330
|
+
|
|
331
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
332
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
333
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
334
|
+
return prototype === Object.prototype || prototype === null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function propertyPath(parent: string, key: string): string {
|
|
338
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
|
|
339
|
+
? `${parent}.${key}`
|
|
340
|
+
: `${parent}[${JSON.stringify(key)}]`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function pushIssue(
|
|
344
|
+
issues: DiagnosticValidationIssue[],
|
|
345
|
+
code: DiagnosticValidationIssueCode,
|
|
346
|
+
path: string,
|
|
347
|
+
message: string,
|
|
348
|
+
): void {
|
|
349
|
+
issues.push({ domain: "definition", code, path, message });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function hasWellFormedUtf16(value: string): boolean {
|
|
353
|
+
for (let index = 0; index < value.length; index++) {
|
|
354
|
+
const code = value.charCodeAt(index);
|
|
355
|
+
if (code === 0) return false;
|
|
356
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
357
|
+
const next = value.charCodeAt(index + 1);
|
|
358
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
|
|
359
|
+
index++;
|
|
360
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) return false;
|
|
361
|
+
}
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function validateToken(
|
|
366
|
+
value: unknown,
|
|
367
|
+
path: string,
|
|
368
|
+
label: string,
|
|
369
|
+
issues: DiagnosticValidationIssue[],
|
|
370
|
+
): value is string {
|
|
371
|
+
if (typeof value !== "string") {
|
|
372
|
+
pushIssue(issues, "invalid-type", path, `${label} must be a string`);
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
if (!hasWellFormedUtf16(value)) {
|
|
376
|
+
pushIssue(issues, "invalid-string", path, `${label} contains an invalid Unicode string`);
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
if (value.length === 0 || /^[\u0009-\u000d\u0020]|[\u0009-\u000d\u0020]$/.test(value)) {
|
|
380
|
+
pushIssue(issues, "invalid-string", path, `${label} must be nonempty without surrounding ASCII whitespace`);
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function validateFinite(
|
|
387
|
+
value: unknown,
|
|
388
|
+
path: string,
|
|
389
|
+
label: string,
|
|
390
|
+
issues: DiagnosticValidationIssue[],
|
|
391
|
+
): value is number {
|
|
392
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
393
|
+
pushIssue(issues, "invalid-number", path, `${label} must be finite`);
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
interface JsonFrame {
|
|
400
|
+
readonly value: unknown;
|
|
401
|
+
readonly path: string;
|
|
402
|
+
readonly exiting: boolean;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function validateJsonValue(value: unknown, issues: DiagnosticValidationIssue[]): void {
|
|
406
|
+
const active = new WeakSet<object>();
|
|
407
|
+
const stack: JsonFrame[] = [{ value, path: "$", exiting: false }];
|
|
408
|
+
while (stack.length > 0) {
|
|
409
|
+
const frame = stack.pop()!;
|
|
410
|
+
if (frame.value === null || typeof frame.value === "boolean") continue;
|
|
411
|
+
if (typeof frame.value === "string") {
|
|
412
|
+
if (!hasWellFormedUtf16(frame.value)) {
|
|
413
|
+
pushIssue(issues, "invalid-json-value", frame.path, "String is not well-formed UTF-16 or contains U+0000");
|
|
414
|
+
}
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (typeof frame.value === "number") {
|
|
418
|
+
if (!Number.isFinite(frame.value)) {
|
|
419
|
+
pushIssue(issues, "invalid-json-value", frame.path, "JSON numeric value must be finite");
|
|
420
|
+
}
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (typeof frame.value !== "object" || frame.value === undefined) {
|
|
424
|
+
pushIssue(issues, "invalid-json-value", frame.path, "Value is not plain JSON data");
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const object = frame.value as object;
|
|
428
|
+
if (frame.exiting) {
|
|
429
|
+
active.delete(object);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (active.has(object)) {
|
|
433
|
+
pushIssue(issues, "cycle", frame.path, "JSON value contains a cycle");
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (!Array.isArray(object) && !isPlainRecord(object)) {
|
|
437
|
+
pushIssue(issues, "invalid-json-value", frame.path, "Value must use a plain object or array prototype");
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
active.add(object);
|
|
441
|
+
stack.push({ value: object, path: frame.path, exiting: true });
|
|
442
|
+
if (Array.isArray(object)) {
|
|
443
|
+
for (let index = object.length - 1; index >= 0; index--) {
|
|
444
|
+
stack.push({ value: object[index], path: `${frame.path}[${index}]`, exiting: false });
|
|
445
|
+
}
|
|
446
|
+
} else {
|
|
447
|
+
for (const key of Object.keys(object).sort().reverse()) {
|
|
448
|
+
if (!hasWellFormedUtf16(key)) {
|
|
449
|
+
pushIssue(issues, "invalid-json-value", propertyPath(frame.path, key), "Object key is not well-formed UTF-16 or contains U+0000");
|
|
450
|
+
}
|
|
451
|
+
const descriptor = Object.getOwnPropertyDescriptor(object, key);
|
|
452
|
+
if (!descriptor || !("value" in descriptor)) {
|
|
453
|
+
pushIssue(issues, "invalid-json-value", propertyPath(frame.path, key), "JSON objects may contain only data properties");
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
stack.push({ value: descriptor.value, path: propertyPath(frame.path, key), exiting: false });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function requireArray(
|
|
463
|
+
value: unknown,
|
|
464
|
+
path: string,
|
|
465
|
+
issues: DiagnosticValidationIssue[],
|
|
466
|
+
): readonly unknown[] {
|
|
467
|
+
if (!Array.isArray(value)) {
|
|
468
|
+
pushIssue(issues, "invalid-type", path, "Expected an array");
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
return value;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function collectCatalog<T extends { readonly id: string }>(
|
|
475
|
+
values: readonly T[],
|
|
476
|
+
path: string,
|
|
477
|
+
issues: DiagnosticValidationIssue[],
|
|
478
|
+
): Map<string, T> {
|
|
479
|
+
const result = new Map<string, T>();
|
|
480
|
+
values.forEach((value, index) => {
|
|
481
|
+
const idPath = `${path}[${index}].id`;
|
|
482
|
+
if (!validateToken(value?.id, idPath, "ID", issues)) return;
|
|
483
|
+
if (result.has(value.id)) {
|
|
484
|
+
pushIssue(issues, "duplicate-id", idPath, `Duplicate ID ${value.id}`);
|
|
485
|
+
} else result.set(value.id, value);
|
|
486
|
+
});
|
|
487
|
+
return result;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function semanticReference(measure: DiagnosticMeasureDefinition): string {
|
|
491
|
+
if (measure.kind === "amount") return `amount:${measure.basisId ?? ""}`;
|
|
492
|
+
if (measure.kind === "count") return `count:${measure.countPopulationId ?? ""}`;
|
|
493
|
+
return `exposure:${measure.exposureBasisId ?? ""}`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function transitiveMeasureIds(
|
|
497
|
+
roots: readonly string[],
|
|
498
|
+
derivations: ReadonlyMap<string, DiagnosticDerivedMeasureDefinition>,
|
|
499
|
+
): readonly string[] {
|
|
500
|
+
const result = new Set<string>();
|
|
501
|
+
const stack = [...roots];
|
|
502
|
+
while (stack.length > 0) {
|
|
503
|
+
const id = stack.pop()!;
|
|
504
|
+
if (result.has(id)) continue;
|
|
505
|
+
result.add(id);
|
|
506
|
+
const derivation = derivations.get(id);
|
|
507
|
+
if (derivation) {
|
|
508
|
+
stack.push(...walkDiagnosticExpression(derivation.expression, "claim", "$", []).dependencies);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return [...result].sort();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
interface ProjectedAmountBasis {
|
|
515
|
+
readonly currency: string;
|
|
516
|
+
readonly perspective: AmountPerspective;
|
|
517
|
+
readonly components: readonly AmountBasisComponent[];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
interface ProjectedClaimSemantics {
|
|
521
|
+
readonly kind: DiagnosticMeasureKind;
|
|
522
|
+
readonly unit: string;
|
|
523
|
+
readonly reference: string;
|
|
524
|
+
readonly amountBasis?: ProjectedAmountBasis;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function projectedAmountBasis(basis: AmountBasisDefinition): ProjectedAmountBasis {
|
|
528
|
+
return {
|
|
529
|
+
currency: basis.currency,
|
|
530
|
+
perspective: basis.perspective,
|
|
531
|
+
components: [...basis.components].sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function projectedBasisKey(basis: ProjectedAmountBasis): string {
|
|
536
|
+
return canonicalJson(basis);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function projectClaimExpression(
|
|
540
|
+
expression: DiagnosticClaimExpression,
|
|
541
|
+
path: string,
|
|
542
|
+
measures: ReadonlyMap<string, DiagnosticMeasureDefinition>,
|
|
543
|
+
amountBases: ReadonlyMap<string, AmountBasisDefinition>,
|
|
544
|
+
issues: DiagnosticValidationIssue[],
|
|
545
|
+
): ProjectedClaimSemantics | null {
|
|
546
|
+
if (expression.op === "measure") {
|
|
547
|
+
const measure = measures.get(expression.measureId);
|
|
548
|
+
if (!measure) return null;
|
|
549
|
+
if (measure.kind !== "amount") {
|
|
550
|
+
return { kind: measure.kind, unit: measure.unit, reference: semanticReference(measure) };
|
|
551
|
+
}
|
|
552
|
+
const basis = measure.basisId ? amountBases.get(measure.basisId) : undefined;
|
|
553
|
+
return basis
|
|
554
|
+
? { kind: "amount", unit: measure.unit, reference: `amount:${measure.basisId}`, amountBasis: projectedAmountBasis(basis) }
|
|
555
|
+
: null;
|
|
556
|
+
}
|
|
557
|
+
if (expression.op === "claim-layer") {
|
|
558
|
+
const measure = measures.get(expression.measureId);
|
|
559
|
+
if (!measure || measure.kind !== "amount" || !measure.basisId) {
|
|
560
|
+
pushIssue(issues, "incompatible-semantics", path, "Claim layers require an amount measure");
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
const basis = amountBases.get(measure.basisId);
|
|
564
|
+
const component = basis?.components[0];
|
|
565
|
+
if (!basis || basis.components.length !== 1 || component?.treatment !== "included" || component.limitation.kind !== "unlimited") {
|
|
566
|
+
pushIssue(issues, "incompatible-semantics", path, "Claim layers require one included unlimited source component");
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
kind: "amount",
|
|
571
|
+
unit: measure.unit,
|
|
572
|
+
reference: "amount:projected",
|
|
573
|
+
amountBasis: {
|
|
574
|
+
currency: basis.currency,
|
|
575
|
+
perspective: basis.perspective,
|
|
576
|
+
components: [{
|
|
577
|
+
id: component.id,
|
|
578
|
+
treatment: "included",
|
|
579
|
+
limitation: {
|
|
580
|
+
kind: "layer",
|
|
581
|
+
attachment: expression.attachment,
|
|
582
|
+
limit: expression.limit,
|
|
583
|
+
application: "claim",
|
|
584
|
+
derivation: { kind: "sdk" },
|
|
585
|
+
},
|
|
586
|
+
}],
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
const children = expression.op === "add" ? expression.terms : [expression.left, expression.right];
|
|
591
|
+
const projected = children.map((child, index) => projectClaimExpression(
|
|
592
|
+
child,
|
|
593
|
+
expression.op === "add" ? `${path}.terms[${index}]` : `${path}.${index === 0 ? "left" : "right"}`,
|
|
594
|
+
measures,
|
|
595
|
+
amountBases,
|
|
596
|
+
issues,
|
|
597
|
+
));
|
|
598
|
+
if (projected.some((item) => item === null)) return null;
|
|
599
|
+
const present = projected as ProjectedClaimSemantics[];
|
|
600
|
+
if (expression.op === "subtract") {
|
|
601
|
+
const [left, right] = present;
|
|
602
|
+
if (left!.kind !== right!.kind || left!.unit !== right!.unit ||
|
|
603
|
+
(left!.kind === "amount"
|
|
604
|
+
? projectedBasisKey(left!.amountBasis!) !== projectedBasisKey(right!.amountBasis!)
|
|
605
|
+
: left!.reference !== right!.reference)) {
|
|
606
|
+
pushIssue(issues, "incompatible-semantics", path, "Claim subtraction requires one exact semantic basis");
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
return left!;
|
|
610
|
+
}
|
|
611
|
+
const first = present[0]!;
|
|
612
|
+
if (present.some((item) => item.kind !== first.kind || item.unit !== first.unit)) {
|
|
613
|
+
pushIssue(issues, "incompatible-semantics", path, "Claim addition requires one kind and unit");
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
if (first.kind !== "amount") {
|
|
617
|
+
if (present.some((item) => item.reference !== first.reference)) {
|
|
618
|
+
pushIssue(issues, "incompatible-semantics", path, "Claim addition requires one exact semantic basis");
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
return first;
|
|
622
|
+
}
|
|
623
|
+
const bases = present.map((item) => item.amountBasis!);
|
|
624
|
+
if (bases.some((basis) => basis.currency !== bases[0]!.currency || basis.perspective !== bases[0]!.perspective)) {
|
|
625
|
+
pushIssue(issues, "incompatible-semantics", path, "Amount addition cannot mix currency or perspective");
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
const componentIds = new Set<string>();
|
|
629
|
+
const components: AmountBasisComponent[] = [];
|
|
630
|
+
for (const basis of bases) {
|
|
631
|
+
for (const component of basis.components) {
|
|
632
|
+
if (componentIds.has(component.id)) {
|
|
633
|
+
pushIssue(issues, "incompatible-semantics", path, `Amount addition overlaps component ${component.id}`);
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
componentIds.add(component.id);
|
|
637
|
+
components.push(component);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const amountBasis = {
|
|
641
|
+
currency: bases[0]!.currency,
|
|
642
|
+
perspective: bases[0]!.perspective,
|
|
643
|
+
components: components.sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0),
|
|
644
|
+
};
|
|
645
|
+
return { kind: "amount", unit: first.unit, reference: "amount:projected", amountBasis };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function expressionSemantics(
|
|
649
|
+
expression: DiagnosticMeasureExpression,
|
|
650
|
+
path: string,
|
|
651
|
+
measures: ReadonlyMap<string, DiagnosticMeasureDefinition>,
|
|
652
|
+
issues: DiagnosticValidationIssue[],
|
|
653
|
+
): { readonly measureIds: readonly string[]; readonly signature: string | null } {
|
|
654
|
+
const walked = walkDiagnosticExpression(expression, "measure", path, issues);
|
|
655
|
+
const found = walked.dependencies
|
|
656
|
+
.map((id) => measures.get(id))
|
|
657
|
+
.filter((value): value is DiagnosticMeasureDefinition => value !== undefined);
|
|
658
|
+
for (const id of walked.dependencies) {
|
|
659
|
+
if (!measures.has(id)) {
|
|
660
|
+
pushIssue(issues, "unknown-reference", path, `Unknown measure ${id}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const signatures = new Set(found.map((measure) =>
|
|
664
|
+
`${measure.kind}\u0000${measure.unit}\u0000${semanticReference(measure)}`,
|
|
665
|
+
));
|
|
666
|
+
if (signatures.size > 1) {
|
|
667
|
+
pushIssue(issues, "incompatible-semantics", path, "Expression combines incompatible measure semantics");
|
|
668
|
+
}
|
|
669
|
+
return {
|
|
670
|
+
measureIds: walked.dependencies,
|
|
671
|
+
signature: signatures.size === 1 ? [...signatures][0]! : null,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function validateTolerance(
|
|
676
|
+
value: { absolute?: number; relative?: number } | undefined,
|
|
677
|
+
path: string,
|
|
678
|
+
issues: DiagnosticValidationIssue[],
|
|
679
|
+
): void {
|
|
680
|
+
for (const key of ["absolute", "relative"] as const) {
|
|
681
|
+
const component = value?.[key];
|
|
682
|
+
if (component === undefined) continue;
|
|
683
|
+
if (!validateFinite(component, `${path}.${key}`, `${key} tolerance`, issues)) continue;
|
|
684
|
+
if (component < 0) {
|
|
685
|
+
pushIssue(issues, "invalid-number", `${path}.${key}`, `${key} tolerance must be nonnegative`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function validatePeriodAxis(axis: DiagnosticPeriodAxis, issues: DiagnosticValidationIssue[]): void {
|
|
691
|
+
if (!isPlainRecord(axis)) {
|
|
692
|
+
pushIssue(issues, "invalid-type", "$.periodAxis", "Period axis must be an object");
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (!validateFinite(axis.ageOffset, "$.periodAxis.ageOffset", "Period age offset", issues) ||
|
|
696
|
+
!Number.isSafeInteger(axis.ageOffset)) {
|
|
697
|
+
pushIssue(issues, "invalid-number", "$.periodAxis.ageOffset", "Period age offset must be a safe integer");
|
|
698
|
+
}
|
|
699
|
+
if (axis.kind === "calendar") {
|
|
700
|
+
if (!["month", "quarter", "year"].includes(axis.originCadence)) {
|
|
701
|
+
pushIssue(issues, "invalid-period", "$.periodAxis.originCadence", "Unknown origin cadence");
|
|
702
|
+
}
|
|
703
|
+
if (!["month", "quarter", "year"].includes(axis.valuationCadence)) {
|
|
704
|
+
pushIssue(issues, "invalid-period", "$.periodAxis.valuationCadence", "Unknown valuation cadence");
|
|
705
|
+
}
|
|
706
|
+
if (axis.ageUnit !== "month") {
|
|
707
|
+
pushIssue(issues, "invalid-period", "$.periodAxis.ageUnit", "Calendar axes use month age units");
|
|
708
|
+
}
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (axis.kind !== "ordered") {
|
|
712
|
+
pushIssue(issues, "invalid-period", "$.periodAxis.kind", "Unknown period-axis kind");
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
validateToken(axis.id, "$.periodAxis.id", "Ordered-axis ID", issues);
|
|
716
|
+
validateToken(axis.version, "$.periodAxis.version", "Ordered-axis version", issues);
|
|
717
|
+
validateToken(axis.ageUnit, "$.periodAxis.ageUnit", "Ordered-axis age unit", issues);
|
|
718
|
+
for (const side of ["origins", "valuations"] as const) {
|
|
719
|
+
const coordinates = requireArray(axis[side], `$.periodAxis.${side}`, issues) as readonly DiagnosticPeriodCoordinate[];
|
|
720
|
+
const names = new Set<string>();
|
|
721
|
+
const coordinateValues = new Set<number>();
|
|
722
|
+
coordinates.forEach((coordinate, index) => {
|
|
723
|
+
const base = `$.periodAxis.${side}[${index}]`;
|
|
724
|
+
if (validateToken(coordinate?.label, `${base}.label`, "Period label", issues)) {
|
|
725
|
+
if (names.has(coordinate.label)) pushIssue(issues, "duplicate-id", `${base}.label`, `Duplicate period label or alias ${coordinate.label}`);
|
|
726
|
+
names.add(coordinate.label);
|
|
727
|
+
}
|
|
728
|
+
if (!validateFinite(coordinate?.coordinate, `${base}.coordinate`, "Period coordinate", issues) ||
|
|
729
|
+
!Number.isSafeInteger(coordinate.coordinate)) {
|
|
730
|
+
pushIssue(issues, "invalid-number", `${base}.coordinate`, "Period coordinate must be a safe integer");
|
|
731
|
+
} else if (coordinateValues.has(coordinate.coordinate)) {
|
|
732
|
+
pushIssue(issues, "duplicate-id", `${base}.coordinate`, `Duplicate period coordinate ${coordinate.coordinate}`);
|
|
733
|
+
} else coordinateValues.add(coordinate.coordinate);
|
|
734
|
+
for (const [aliasIndex, alias] of (coordinate?.aliases ?? []).entries()) {
|
|
735
|
+
if (!validateToken(alias, `${base}.aliases[${aliasIndex}]`, "Period alias", issues)) continue;
|
|
736
|
+
if (names.has(alias)) pushIssue(issues, "duplicate-id", `${base}.aliases[${aliasIndex}]`, `Duplicate period label or alias ${alias}`);
|
|
737
|
+
names.add(alias);
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function validateDefinition(value: unknown): DiagnosticDefinition {
|
|
744
|
+
const issues: DiagnosticValidationIssue[] = [];
|
|
745
|
+
validateJsonValue(value, issues);
|
|
746
|
+
if (!isPlainRecord(value)) {
|
|
747
|
+
pushIssue(issues, "invalid-type", "$", "Diagnostic definition must be a plain object");
|
|
748
|
+
throw new DiagnosticValidationError(issues);
|
|
749
|
+
}
|
|
750
|
+
const definition = value as unknown as DiagnosticDefinition;
|
|
751
|
+
if (definition.diagnosticDefinitionVersion !== "1.0.0") {
|
|
752
|
+
pushIssue(issues, "invalid-type", "$.diagnosticDefinitionVersion", "Diagnostic definition version must be 1.0.0");
|
|
753
|
+
}
|
|
754
|
+
validateToken(definition.id, "$.id", "Definition ID", issues);
|
|
755
|
+
validateToken(definition.version, "$.version", "Definition version", issues);
|
|
756
|
+
if (definition.lossRowGrain !== "claim" && definition.lossRowGrain !== "aggregate") {
|
|
757
|
+
pushIssue(issues, "invalid-type", "$.lossRowGrain", "Loss row grain must be claim or aggregate");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const measures = requireArray(definition.measures, "$.measures", issues) as readonly DiagnosticMeasureDefinition[];
|
|
761
|
+
const populations = requireArray(definition.countPopulations, "$.countPopulations", issues) as readonly DiagnosticCountPopulationDefinition[];
|
|
762
|
+
const exposureBases = requireArray(definition.exposureBases, "$.exposureBases", issues) as readonly DiagnosticExposureBasisDefinition[];
|
|
763
|
+
const amountBases = requireArray(definition.amountBases, "$.amountBases", issues) as readonly AmountBasisDefinition[];
|
|
764
|
+
const derivations = requireArray(definition.derivedMeasures, "$.derivedMeasures", issues) as readonly DiagnosticDerivedMeasureDefinition[];
|
|
765
|
+
const formulas = requireArray(definition.formulas, "$.formulas", issues) as readonly DiagnosticFormulaTemplate[];
|
|
766
|
+
const instances = requireArray(definition.instances, "$.instances", issues) as readonly DiagnosticMetricInstance[];
|
|
767
|
+
const reviewRules = requireArray(definition.reviewRules, "$.reviewRules", issues) as readonly DiagnosticReviewRule[];
|
|
768
|
+
|
|
769
|
+
const measureMap = collectCatalog(measures, "$.measures", issues);
|
|
770
|
+
const populationMap = collectCatalog(populations, "$.countPopulations", issues);
|
|
771
|
+
const exposureBasisMap = collectCatalog(exposureBases, "$.exposureBases", issues);
|
|
772
|
+
const amountBasisMap = collectCatalog(amountBases, "$.amountBases", issues);
|
|
773
|
+
collectCatalog(derivations, "$.derivedMeasures", issues);
|
|
774
|
+
const derivationsByOutput = new Map<string, DiagnosticDerivedMeasureDefinition>();
|
|
775
|
+
for (const derivation of derivations) {
|
|
776
|
+
if (isPlainRecord(derivation) && typeof derivation.outputMeasureId === "string" &&
|
|
777
|
+
!derivationsByOutput.has(derivation.outputMeasureId)) {
|
|
778
|
+
derivationsByOutput.set(derivation.outputMeasureId, derivation);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const formulaMap = collectCatalog(formulas, "$.formulas", issues);
|
|
782
|
+
collectCatalog(instances, "$.instances", issues);
|
|
783
|
+
const reviewRuleMap = collectCatalog(reviewRules, "$.reviewRules", issues);
|
|
784
|
+
|
|
785
|
+
populations.forEach((population, index) => {
|
|
786
|
+
const base = `$.countPopulations[${index}]`;
|
|
787
|
+
validateToken(population.displayName, `${base}.displayName`, "Population display name", issues);
|
|
788
|
+
validateToken(population.description, `${base}.description`, "Population description", issues);
|
|
789
|
+
validateToken(population.unit, `${base}.unit`, "Population unit", issues);
|
|
790
|
+
if (!["claim", "claimant", "policy", "occurrence", "other", "unknown"].includes(population.subject)) {
|
|
791
|
+
pushIssue(issues, "invalid-type", `${base}.subject`, "Unknown count-population subject");
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
exposureBases.forEach((basis, index) => {
|
|
796
|
+
const base = `$.exposureBases[${index}]`;
|
|
797
|
+
validateToken(basis.displayName, `${base}.displayName`, "Exposure-basis display name", issues);
|
|
798
|
+
validateToken(basis.description, `${base}.description`, "Exposure-basis description", issues);
|
|
799
|
+
validateToken(basis.unit, `${base}.unit`, "Exposure-basis unit", issues);
|
|
800
|
+
if (basis.sourceDescription !== undefined) validateToken(basis.sourceDescription, `${base}.sourceDescription`, "Exposure source description", issues);
|
|
801
|
+
if (!["earned", "written", "in-force", "other", "unknown"].includes(basis.basis)) {
|
|
802
|
+
pushIssue(issues, "invalid-type", `${base}.basis`, "Unknown exposure basis");
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
amountBases.forEach((basis, index) => {
|
|
807
|
+
const base = `$.amountBases[${index}]`;
|
|
808
|
+
validateToken(basis.displayName, `${base}.displayName`, "Amount-basis display name", issues);
|
|
809
|
+
validateToken(basis.currency, `${base}.currency`, "Amount-basis currency", issues);
|
|
810
|
+
if (basis.sourceDescription !== undefined) validateToken(basis.sourceDescription, `${base}.sourceDescription`, "Amount source description", issues);
|
|
811
|
+
if (!["gross", "net", "ceded", "other", "unknown"].includes(basis.perspective)) {
|
|
812
|
+
pushIssue(issues, "invalid-type", `${base}.perspective`, "Unknown amount perspective");
|
|
813
|
+
}
|
|
814
|
+
const components = requireArray(basis.components, `${base}.components`, issues) as readonly AmountBasisComponent[];
|
|
815
|
+
if (components.length === 0) pushIssue(issues, "missing-required", `${base}.components`, "Amount basis requires at least one component");
|
|
816
|
+
const componentIds = collectCatalog(components, `${base}.components`, issues);
|
|
817
|
+
if (componentIds.size > 0 && components.every((component) => component.treatment === "excluded")) {
|
|
818
|
+
pushIssue(issues, "incompatible-semantics", `${base}.components`, "Amount basis cannot exclude every component");
|
|
819
|
+
}
|
|
820
|
+
components.forEach((component, componentIndex) => {
|
|
821
|
+
const componentBase = `${base}.components[${componentIndex}]`;
|
|
822
|
+
if (!isPlainRecord(component)) {
|
|
823
|
+
pushIssue(issues, "invalid-type", componentBase, "Amount-basis component must be an object");
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (!["included", "excluded", "unknown"].includes(component.treatment)) {
|
|
827
|
+
pushIssue(issues, "invalid-type", `${componentBase}.treatment`, "Unknown component treatment");
|
|
828
|
+
}
|
|
829
|
+
const limitation = component.limitation;
|
|
830
|
+
if (!isPlainRecord(limitation)) {
|
|
831
|
+
pushIssue(issues, "invalid-type", `${componentBase}.limitation`, "Amount limitation must be an object");
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (limitation.kind === "unknown") {
|
|
835
|
+
if (limitation.description !== undefined) validateToken(limitation.description, `${componentBase}.limitation.description`, "Unknown limitation description", issues);
|
|
836
|
+
} else if (limitation.kind === "layer" || limitation.kind === "pre-limited") {
|
|
837
|
+
if (validateFinite(limitation.attachment, `${componentBase}.limitation.attachment`, "Attachment", issues) && limitation.attachment < 0) {
|
|
838
|
+
pushIssue(issues, "invalid-number", `${componentBase}.limitation.attachment`, "Attachment must be nonnegative");
|
|
839
|
+
}
|
|
840
|
+
if (limitation.limit !== null) {
|
|
841
|
+
if (validateFinite(limitation.limit, `${componentBase}.limitation.limit`, "Layer width", issues) && limitation.limit <= 0) {
|
|
842
|
+
pushIssue(issues, "invalid-number", `${componentBase}.limitation.limit`, "Layer width must be positive");
|
|
843
|
+
}
|
|
844
|
+
if (Number.isFinite(limitation.attachment) && Number.isFinite(limitation.limit) && !Number.isFinite(limitation.attachment + limitation.limit)) {
|
|
845
|
+
pushIssue(issues, "invalid-number", `${componentBase}.limitation.limit`, "Attachment plus layer width must be finite");
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (limitation.derivation?.kind === "sdk") {
|
|
849
|
+
if (limitation.kind !== "layer" || limitation.application !== "claim") {
|
|
850
|
+
pushIssue(issues, "incompatible-semantics", `${componentBase}.limitation.derivation`, "SDK limitations require a claim layer");
|
|
851
|
+
}
|
|
852
|
+
} else if (limitation.derivation?.kind === "external") {
|
|
853
|
+
validateToken(limitation.derivation.transformationRef, `${componentBase}.limitation.derivation.transformationRef`, "Transformation reference", issues);
|
|
854
|
+
} else {
|
|
855
|
+
pushIssue(issues, "invalid-type", `${componentBase}.limitation.derivation`, "Limited basis requires a derivation record");
|
|
856
|
+
}
|
|
857
|
+
} else if (limitation.kind !== "unlimited") {
|
|
858
|
+
pushIssue(issues, "invalid-type", `${componentBase}.limitation.kind`, "Unknown amount limitation kind");
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
measures.forEach((measure, index) => {
|
|
864
|
+
const base = `$.measures[${index}]`;
|
|
865
|
+
validateToken(measure.displayName, `${base}.displayName`, "Measure display name", issues);
|
|
866
|
+
validateToken(measure.description, `${base}.description`, "Measure description", issues);
|
|
867
|
+
validateToken(measure.unit, `${base}.unit`, "Measure unit", issues);
|
|
868
|
+
if (measure.aggregation !== "sum") pushIssue(issues, "incompatible-semantics", `${base}.aggregation`, "Only sum aggregation is supported");
|
|
869
|
+
if (measure.missing !== "unknown" && measure.missing !== "zero") pushIssue(issues, "invalid-type", `${base}.missing`, "Unknown missing-value policy");
|
|
870
|
+
if (!["cumulative", "incremental", "point-in-time", "unknown"].includes(measure.developmentSemantics)) {
|
|
871
|
+
pushIssue(issues, "invalid-type", `${base}.developmentSemantics`, "Unknown development semantics");
|
|
872
|
+
}
|
|
873
|
+
if (measure.kind === "amount") {
|
|
874
|
+
const basis = measure.basisId ? amountBasisMap.get(measure.basisId) : undefined;
|
|
875
|
+
if (!basis) pushIssue(issues, "unknown-reference", `${base}.basisId`, "Amount measure requires an existing amount basis");
|
|
876
|
+
else if (measure.unit !== basis.currency) pushIssue(issues, "incompatible-semantics", `${base}.unit`, "Amount measure unit must equal basis currency");
|
|
877
|
+
if (measure.countPopulationId !== undefined || measure.exposureBasisId !== undefined || measure.exposureTiming !== undefined) {
|
|
878
|
+
pushIssue(issues, "incompatible-semantics", base, "Amount measure has inapplicable population or exposure semantics");
|
|
879
|
+
}
|
|
880
|
+
} else if (measure.kind === "count") {
|
|
881
|
+
const population = measure.countPopulationId ? populationMap.get(measure.countPopulationId) : undefined;
|
|
882
|
+
if (!population) pushIssue(issues, "unknown-reference", `${base}.countPopulationId`, "Count measure requires an existing count population");
|
|
883
|
+
else if (measure.unit !== population.unit) pushIssue(issues, "incompatible-semantics", `${base}.unit`, "Count measure unit must equal population unit");
|
|
884
|
+
if (measure.basisId !== undefined || measure.exposureBasisId !== undefined || measure.exposureTiming !== undefined) {
|
|
885
|
+
pushIssue(issues, "incompatible-semantics", base, "Count measure has inapplicable amount or exposure semantics");
|
|
886
|
+
}
|
|
887
|
+
} else if (measure.kind === "exposure") {
|
|
888
|
+
const basis = measure.exposureBasisId ? exposureBasisMap.get(measure.exposureBasisId) : undefined;
|
|
889
|
+
if (!basis) pushIssue(issues, "unknown-reference", `${base}.exposureBasisId`, "Exposure measure requires an existing exposure basis");
|
|
890
|
+
else if (measure.unit !== basis.unit) pushIssue(issues, "incompatible-semantics", `${base}.unit`, "Exposure measure unit must equal exposure-basis unit");
|
|
891
|
+
if (measure.source !== "exposure") pushIssue(issues, "incompatible-semantics", `${base}.source`, "Exposure measure source must be exposure");
|
|
892
|
+
if (measure.exposureTiming !== "origin-static" && measure.exposureTiming !== "valuation-specific") {
|
|
893
|
+
pushIssue(issues, "missing-required", `${base}.exposureTiming`, "Exposure measure requires timing");
|
|
894
|
+
}
|
|
895
|
+
if (measure.missing !== "unknown") pushIssue(issues, "incompatible-semantics", `${base}.missing`, "Exposure missingness must remain unknown");
|
|
896
|
+
if (measure.basisId !== undefined || measure.countPopulationId !== undefined) {
|
|
897
|
+
pushIssue(issues, "incompatible-semantics", base, "Exposure measure has inapplicable amount or population semantics");
|
|
898
|
+
}
|
|
899
|
+
} else {
|
|
900
|
+
pushIssue(issues, "invalid-type", `${base}.kind`, "Unknown measure kind");
|
|
901
|
+
}
|
|
902
|
+
if (measure.kind !== "exposure" && measure.source !== "loss" && measure.source !== "derived") {
|
|
903
|
+
pushIssue(issues, "invalid-type", `${base}.source`, "Non-exposure measure source must be loss or derived");
|
|
904
|
+
}
|
|
905
|
+
if (measure.kind !== "exposure" && measure.source === "exposure") {
|
|
906
|
+
pushIssue(issues, "incompatible-semantics", `${base}.source`, "Only exposure measures may use exposure source");
|
|
907
|
+
}
|
|
908
|
+
if (measure.kind === "amount" && measure.basisId) {
|
|
909
|
+
const basis = amountBasisMap.get(measure.basisId);
|
|
910
|
+
const hasSdkLimitation = basis?.components.some((component) =>
|
|
911
|
+
(component.limitation.kind === "layer" || component.limitation.kind === "pre-limited") &&
|
|
912
|
+
component.limitation.derivation.kind === "sdk",
|
|
913
|
+
) ?? false;
|
|
914
|
+
if (hasSdkLimitation && measure.source !== "derived") {
|
|
915
|
+
pushIssue(issues, "incompatible-semantics", `${base}.source`, "SDK-limited amount measures must be derived");
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
let definitionExpressionNodes = 0;
|
|
921
|
+
const formulaRoleUsage = new Map<string, readonly string[]>();
|
|
922
|
+
formulas.forEach((formula, index) => {
|
|
923
|
+
const base = `$.formulas[${index}]`;
|
|
924
|
+
validateToken(formula.version, `${base}.version`, "Formula version", issues);
|
|
925
|
+
if (formula.denominatorPolicy !== "positive-or-null") {
|
|
926
|
+
pushIssue(issues, "incompatible-semantics", `${base}.denominatorPolicy`, "Unknown denominator policy");
|
|
927
|
+
}
|
|
928
|
+
if (!isPlainRecord(formula.roles)) {
|
|
929
|
+
pushIssue(issues, "invalid-type", `${base}.roles`, "Formula roles must be an object");
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
for (const [roleName, role] of Object.entries(formula.roles)) {
|
|
933
|
+
validateToken(roleName, propertyPath(`${base}.roles`, roleName), "Formula role", issues);
|
|
934
|
+
if (!isPlainRecord(role) || !["count", "amount", "exposure"].includes(role.kind)) {
|
|
935
|
+
pushIssue(issues, "invalid-type", propertyPath(`${base}.roles`, roleName), "Formula role has an invalid kind");
|
|
936
|
+
}
|
|
937
|
+
if (role.compatibilityGroup !== undefined) validateToken(role.compatibilityGroup, `${propertyPath(`${base}.roles`, roleName)}.compatibilityGroup`, "Compatibility group", issues);
|
|
938
|
+
}
|
|
939
|
+
const numerator = walkDiagnosticExpression(formula.numerator, "role", `${base}.numerator`, issues);
|
|
940
|
+
const denominator = walkDiagnosticExpression(formula.denominator, "role", `${base}.denominator`, issues);
|
|
941
|
+
definitionExpressionNodes += numerator.nodeCount + denominator.nodeCount;
|
|
942
|
+
const used = [...new Set([...numerator.dependencies, ...denominator.dependencies])].sort();
|
|
943
|
+
formulaRoleUsage.set(formula.id, used);
|
|
944
|
+
for (const role of used) {
|
|
945
|
+
if (!Object.prototype.hasOwnProperty.call(formula.roles, role)) {
|
|
946
|
+
pushIssue(issues, "unknown-reference", base, `Formula ${formula.id} references unknown role ${role}`);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
for (const role of Object.keys(formula.roles)) {
|
|
950
|
+
if (!used.includes(role)) pushIssue(issues, "incompatible-semantics", propertyPath(`${base}.roles`, role), `Formula role ${role} is unused`);
|
|
951
|
+
}
|
|
952
|
+
for (const [side, walked] of [["numerator", numerator], ["denominator", denominator]] as const) {
|
|
953
|
+
const kinds = new Set(walked.dependencies.map((role) => formula.roles[role]?.kind).filter(Boolean));
|
|
954
|
+
if (kinds.size > 1) pushIssue(issues, "incompatible-semantics", `${base}.${side}`, "Formula arithmetic combines different role kinds");
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
const instanceRuleIds = new Set<string>();
|
|
959
|
+
instances.forEach((instance, index) => {
|
|
960
|
+
const base = `$.instances[${index}]`;
|
|
961
|
+
validateToken(instance.version, `${base}.version`, "Instance version", issues);
|
|
962
|
+
validateToken(instance.formulaId, `${base}.formulaId`, "Formula reference", issues);
|
|
963
|
+
const formula = formulaMap.get(instance.formulaId);
|
|
964
|
+
if (!formula) {
|
|
965
|
+
pushIssue(issues, "unknown-reference", `${base}.formulaId`, `Unknown formula ${instance.formulaId}`);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (!isPlainRecord(instance.bindings)) {
|
|
969
|
+
pushIssue(issues, "invalid-type", `${base}.bindings`, "Instance bindings must be an object");
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
const usedRoles = formulaRoleUsage.get(formula.id) ?? [];
|
|
973
|
+
const bindingSemantics = new Map<string, ReturnType<typeof expressionSemantics>>();
|
|
974
|
+
for (const roleName of usedRoles) {
|
|
975
|
+
if (!Object.prototype.hasOwnProperty.call(instance.bindings, roleName)) {
|
|
976
|
+
pushIssue(issues, "missing-required", propertyPath(`${base}.bindings`, roleName), `Missing binding for role ${roleName}`);
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
const expression = instance.bindings[roleName]!;
|
|
980
|
+
const semantics = expressionSemantics(expression, propertyPath(`${base}.bindings`, roleName), measureMap, issues);
|
|
981
|
+
bindingSemantics.set(roleName, semantics);
|
|
982
|
+
definitionExpressionNodes += walkDiagnosticExpression(expression, "measure", propertyPath(`${base}.bindings`, roleName), []).nodeCount;
|
|
983
|
+
const role = formula.roles[roleName]!;
|
|
984
|
+
const boundMeasures = semantics.measureIds.map((id) => measureMap.get(id)).filter((item): item is DiagnosticMeasureDefinition => item !== undefined);
|
|
985
|
+
if (boundMeasures.some((measure) => measure.kind !== role.kind)) {
|
|
986
|
+
pushIssue(issues, "incompatible-semantics", propertyPath(`${base}.bindings`, roleName), `Binding kind does not match role ${roleName}`);
|
|
987
|
+
}
|
|
988
|
+
if (role.developmentSemantics !== undefined) {
|
|
989
|
+
const leaves = transitiveMeasureIds(semantics.measureIds, derivationsByOutput)
|
|
990
|
+
.map((id) => measureMap.get(id))
|
|
991
|
+
.filter((measure): measure is DiagnosticMeasureDefinition => measure !== undefined);
|
|
992
|
+
if (leaves.some((measure) => measure.developmentSemantics !== role.developmentSemantics)) {
|
|
993
|
+
pushIssue(issues, "incompatible-semantics", propertyPath(`${base}.bindings`, roleName), `Transitive binding development semantics do not match role ${roleName}`);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
for (const [side, roleExpression] of [["numerator", formula.numerator], ["denominator", formula.denominator]] as const) {
|
|
998
|
+
const roleIds = walkDiagnosticExpression(roleExpression, "role", `${base}.${side}`, []).dependencies;
|
|
999
|
+
const signatures = new Set(roleIds.map((roleId) => bindingSemantics.get(roleId)?.signature).filter((signature): signature is string => signature !== null && signature !== undefined));
|
|
1000
|
+
if (signatures.size > 1) {
|
|
1001
|
+
pushIssue(issues, "incompatible-semantics", `${base}.bindings`, `Bound ${side} arithmetic combines incompatible semantics`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
for (const binding of Object.keys(instance.bindings)) {
|
|
1005
|
+
if (!usedRoles.includes(binding)) {
|
|
1006
|
+
pushIssue(issues, "unknown-reference", propertyPath(`${base}.bindings`, binding), `Unexpected binding for role ${binding}`);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
const compatibility = new Map<string, string>();
|
|
1010
|
+
for (const roleName of usedRoles) {
|
|
1011
|
+
const role = formula.roles[roleName]!;
|
|
1012
|
+
const binding = instance.bindings[roleName];
|
|
1013
|
+
if (!role.compatibilityGroup || !binding) continue;
|
|
1014
|
+
const semantics = expressionSemantics(binding, propertyPath(`${base}.bindings`, roleName), measureMap, []);
|
|
1015
|
+
if (semantics.signature === null) continue;
|
|
1016
|
+
const previous = compatibility.get(role.compatibilityGroup);
|
|
1017
|
+
if (previous !== undefined && previous !== semantics.signature) {
|
|
1018
|
+
pushIssue(issues, "incompatible-semantics", propertyPath(`${base}.bindings`, roleName), `Compatibility group ${role.compatibilityGroup} binds different semantics`);
|
|
1019
|
+
} else compatibility.set(role.compatibilityGroup, semantics.signature);
|
|
1020
|
+
}
|
|
1021
|
+
for (const [key, text] of [
|
|
1022
|
+
["displayName", instance.presentation?.displayName],
|
|
1023
|
+
["description", instance.presentation?.description],
|
|
1024
|
+
["displayUnit", instance.presentation?.displayUnit],
|
|
1025
|
+
["numeratorLabel", instance.presentation?.numeratorLabel],
|
|
1026
|
+
["denominatorLabel", instance.presentation?.denominatorLabel],
|
|
1027
|
+
] as const) validateToken(text, `${base}.presentation.${key}`, `Presentation ${key}`, issues);
|
|
1028
|
+
if (!validateFinite(instance.presentation?.scale, `${base}.presentation.scale`, "Presentation scale", issues) || instance.presentation.scale <= 0) {
|
|
1029
|
+
pushIssue(issues, "invalid-number", `${base}.presentation.scale`, "Presentation scale must be positive");
|
|
1030
|
+
}
|
|
1031
|
+
requireArray(instance.rules, `${base}.rules`, issues).forEach((rawRule, ruleIndex) => {
|
|
1032
|
+
const rule = rawRule as DiagnosticComparisonRule;
|
|
1033
|
+
const ruleBase = `${base}.rules[${ruleIndex}]`;
|
|
1034
|
+
if (validateToken(rule.id, `${ruleBase}.id`, "Metric rule ID", issues)) {
|
|
1035
|
+
if (rule.id.startsWith("diagnostic/structural/")) pushIssue(issues, "incompatible-semantics", `${ruleBase}.id`, "Metric rule ID uses the reserved structural prefix");
|
|
1036
|
+
if (instanceRuleIds.has(rule.id) || reviewRuleMap.has(rule.id)) pushIssue(issues, "duplicate-id", `${ruleBase}.id`, `Duplicate rule ID ${rule.id}`);
|
|
1037
|
+
instanceRuleIds.add(rule.id);
|
|
1038
|
+
}
|
|
1039
|
+
validateToken(rule.code, `${ruleBase}.code`, "Metric rule code", issues);
|
|
1040
|
+
validateToken(rule.message, `${ruleBase}.message`, "Metric rule message", issues);
|
|
1041
|
+
validateTolerance(rule.when?.tolerance, `${ruleBase}.when.tolerance`, issues);
|
|
1042
|
+
for (const [side, operand] of [["left", rule.when?.left], ["right", rule.when?.right]] as const) {
|
|
1043
|
+
const operandPath = `${ruleBase}.when.${side}`;
|
|
1044
|
+
if (operand?.source === "measure") {
|
|
1045
|
+
const semantic = expressionSemantics(operand.expression, `${operandPath}.expression`, measureMap, issues);
|
|
1046
|
+
definitionExpressionNodes += 1 + walkDiagnosticExpression(operand.expression, "measure", `${operandPath}.expression`, []).nodeCount;
|
|
1047
|
+
void semantic;
|
|
1048
|
+
} else {
|
|
1049
|
+
definitionExpressionNodes += 1;
|
|
1050
|
+
if (operand?.source === "constant") validateFinite(operand.value, `${operandPath}.value`, "Rule constant", issues);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
const derivedOutputs = new Map<string, DiagnosticDerivedMeasureDefinition>();
|
|
1057
|
+
derivations.forEach((derivation, index) => {
|
|
1058
|
+
const base = `$.derivedMeasures[${index}]`;
|
|
1059
|
+
validateToken(derivation.outputMeasureId, `${base}.outputMeasureId`, "Derived output measure", issues);
|
|
1060
|
+
const output = measureMap.get(derivation.outputMeasureId);
|
|
1061
|
+
if (!output) pushIssue(issues, "unknown-reference", `${base}.outputMeasureId`, `Unknown derived output ${derivation.outputMeasureId}`);
|
|
1062
|
+
else if (output.source !== "derived") pushIssue(issues, "incompatible-semantics", `${base}.outputMeasureId`, "Derivation output must declare source derived");
|
|
1063
|
+
if (derivedOutputs.has(derivation.outputMeasureId)) pushIssue(issues, "duplicate-id", `${base}.outputMeasureId`, `Duplicate derived output ${derivation.outputMeasureId}`);
|
|
1064
|
+
derivedOutputs.set(derivation.outputMeasureId, derivation);
|
|
1065
|
+
const walked = walkDiagnosticExpression(derivation.expression, "claim", `${base}.expression`, issues);
|
|
1066
|
+
definitionExpressionNodes += walked.nodeCount;
|
|
1067
|
+
for (const id of walked.dependencies) if (!measureMap.has(id)) pushIssue(issues, "unknown-reference", `${base}.expression`, `Unknown derivation measure ${id}`);
|
|
1068
|
+
const transitiveIds = transitiveMeasureIds(walked.dependencies, derivationsByOutput);
|
|
1069
|
+
const transitiveInputs = transitiveIds.map((id) => measureMap.get(id)).filter((item): item is DiagnosticMeasureDefinition => item !== undefined);
|
|
1070
|
+
if (output && transitiveInputs.some((input) => input.developmentSemantics !== output.developmentSemantics)) {
|
|
1071
|
+
pushIssue(issues, "incompatible-semantics", `${base}.expression`, "Derived output and inputs must share development semantics");
|
|
1072
|
+
}
|
|
1073
|
+
if (definition.lossRowGrain !== "claim") {
|
|
1074
|
+
pushIssue(issues, "incompatible-semantics", base, "Derived measures require claim-grain input");
|
|
1075
|
+
}
|
|
1076
|
+
const expressionStack: unknown[] = [derivation.expression];
|
|
1077
|
+
while (expressionStack.length > 0) {
|
|
1078
|
+
const current = expressionStack.pop();
|
|
1079
|
+
if (!isPlainRecord(current)) continue;
|
|
1080
|
+
if (current.op === "claim-layer") {
|
|
1081
|
+
const attachment = current.attachment;
|
|
1082
|
+
const limit = current.limit;
|
|
1083
|
+
const attachmentOk = validateFinite(attachment, `${base}.expression.attachment`, "Claim-layer attachment", issues);
|
|
1084
|
+
if (attachmentOk && attachment < 0) pushIssue(issues, "invalid-number", `${base}.expression.attachment`, "Claim-layer attachment must be nonnegative");
|
|
1085
|
+
if (limit !== null) {
|
|
1086
|
+
const limitOk = validateFinite(limit, `${base}.expression.limit`, "Claim-layer width", issues);
|
|
1087
|
+
if (limitOk && limit <= 0) pushIssue(issues, "invalid-number", `${base}.expression.limit`, "Claim-layer width must be positive");
|
|
1088
|
+
if (attachmentOk && limitOk && !Number.isFinite(attachment + limit)) {
|
|
1089
|
+
pushIssue(issues, "invalid-number", `${base}.expression.limit`, "Attachment plus claim-layer width must be finite");
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
} else if (current.op === "add" && Array.isArray(current.terms)) expressionStack.push(...current.terms);
|
|
1093
|
+
else if (current.op === "subtract") expressionStack.push(current.left, current.right);
|
|
1094
|
+
}
|
|
1095
|
+
const projection = projectClaimExpression(derivation.expression, `${base}.expression`, measureMap, amountBasisMap, issues);
|
|
1096
|
+
if (output && projection) {
|
|
1097
|
+
const expected = output.kind === "amount" && output.basisId
|
|
1098
|
+
? amountBasisMap.get(output.basisId)
|
|
1099
|
+
: undefined;
|
|
1100
|
+
const matches = output.kind === projection.kind && output.unit === projection.unit &&
|
|
1101
|
+
(output.kind === "amount"
|
|
1102
|
+
? expected !== undefined && projectedBasisKey(projectedAmountBasis(expected)) === projectedBasisKey(projection.amountBasis!)
|
|
1103
|
+
: semanticReference(output) === projection.reference);
|
|
1104
|
+
if (!matches) pushIssue(issues, "incompatible-semantics", `${base}.expression`, "Derived expression does not project the output measure semantics");
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
for (const [measureId, measure] of measureMap) {
|
|
1108
|
+
if (measure.source === "derived" && !derivedOutputs.has(measureId)) {
|
|
1109
|
+
pushIssue(issues, "missing-required", "$.derivedMeasures", `Derived measure ${measureId} requires exactly one derivation`);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
const visiting = new Set<string>();
|
|
1113
|
+
const visited = new Set<string>();
|
|
1114
|
+
const visit = (id: string): void => {
|
|
1115
|
+
if (visited.has(id)) return;
|
|
1116
|
+
if (visiting.has(id)) {
|
|
1117
|
+
pushIssue(issues, "cycle", "$.derivedMeasures", `Derived measure graph contains a cycle at ${id}`);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
visiting.add(id);
|
|
1121
|
+
const derivation = derivedOutputs.get(id);
|
|
1122
|
+
if (derivation) {
|
|
1123
|
+
const deps = walkDiagnosticExpression(derivation.expression, "claim", "$.derivedMeasures", []).dependencies;
|
|
1124
|
+
for (const dependency of deps) if (derivedOutputs.has(dependency)) visit(dependency);
|
|
1125
|
+
}
|
|
1126
|
+
visiting.delete(id);
|
|
1127
|
+
visited.add(id);
|
|
1128
|
+
};
|
|
1129
|
+
for (const id of derivedOutputs.keys()) visit(id);
|
|
1130
|
+
|
|
1131
|
+
reviewRules.forEach((rule, index) => {
|
|
1132
|
+
const base = `$.reviewRules[${index}]`;
|
|
1133
|
+
validateToken(rule.code, `${base}.code`, "Review rule code", issues);
|
|
1134
|
+
validateToken(rule.description, `${base}.description`, "Review rule description", issues);
|
|
1135
|
+
if (rule.id.startsWith("diagnostic/structural/")) pushIssue(issues, "incompatible-semantics", `${base}.id`, "Review rule ID uses the reserved structural prefix");
|
|
1136
|
+
validateTolerance(rule.tolerance, `${base}.tolerance`, issues);
|
|
1137
|
+
const roots: DiagnosticMeasureExpression[] = [];
|
|
1138
|
+
if (rule.kind === "compare") {
|
|
1139
|
+
if (rule.when.left.op !== "constant") roots.push(rule.when.left);
|
|
1140
|
+
else definitionExpressionNodes++;
|
|
1141
|
+
if (rule.when.right.op !== "constant") roots.push(rule.when.right);
|
|
1142
|
+
else definitionExpressionNodes++;
|
|
1143
|
+
} else if (rule.kind === "reconcile") {
|
|
1144
|
+
roots.push(rule.actual);
|
|
1145
|
+
if (rule.expected.op !== "constant") roots.push(rule.expected);
|
|
1146
|
+
else definitionExpressionNodes++;
|
|
1147
|
+
} else if (rule.kind === "monotonic") roots.push(rule.expression);
|
|
1148
|
+
else if (rule.kind === "layer-order") roots.push(rule.narrower, rule.broader);
|
|
1149
|
+
else if (rule.kind === "control-total") {
|
|
1150
|
+
roots.push(rule.expression);
|
|
1151
|
+
validateFinite(rule.expected, `${base}.expected`, "Control total", issues);
|
|
1152
|
+
} else pushIssue(issues, "invalid-type", `${base}.kind`, "Unknown review-rule kind");
|
|
1153
|
+
roots.forEach((root, rootIndex) => {
|
|
1154
|
+
expressionSemantics(root, `${base}.expression[${rootIndex}]`, measureMap, issues);
|
|
1155
|
+
definitionExpressionNodes += walkDiagnosticExpression(root, "measure", `${base}.expression[${rootIndex}]`, []).nodeCount;
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
if (definitionExpressionNodes > MAX_DIAGNOSTIC_DEFINITION_EXPRESSION_NODES) {
|
|
1160
|
+
pushIssue(
|
|
1161
|
+
issues,
|
|
1162
|
+
"expression-limit",
|
|
1163
|
+
"$",
|
|
1164
|
+
`Definition expression node count exceeds ${MAX_DIAGNOSTIC_DEFINITION_EXPRESSION_NODES}`,
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
validatePeriodAxis(definition.periodAxis, issues);
|
|
1168
|
+
if (issues.length > 0) throw new DiagnosticValidationError(issues);
|
|
1169
|
+
return definition;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function optionalFromNullable<T>(value: T | null | undefined): T | undefined {
|
|
1173
|
+
return value === null || value === undefined ? undefined : value;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* The normalized wire projection materializes optional semantic fields as
|
|
1178
|
+
* null. Convert only those documented defaults back to the authored view so
|
|
1179
|
+
* both accepted inputs travel through the exact same semantic compiler.
|
|
1180
|
+
*/
|
|
1181
|
+
function authoredView(
|
|
1182
|
+
value: DiagnosticDefinition | NormalizedDiagnosticDefinitionIdentity,
|
|
1183
|
+
): DiagnosticDefinition {
|
|
1184
|
+
const definition = value as NormalizedDiagnosticDefinitionIdentity;
|
|
1185
|
+
const reviewRules = definition.reviewRules.map((rule) => {
|
|
1186
|
+
const tolerance = { ...rule.tolerance };
|
|
1187
|
+
if (rule.kind !== "control-total") return { ...rule, tolerance };
|
|
1188
|
+
const filter = rule.filter === null
|
|
1189
|
+
? undefined
|
|
1190
|
+
: {
|
|
1191
|
+
sourceGroups: optionalFromNullable(rule.filter.sourceGroups),
|
|
1192
|
+
origins: optionalFromNullable(rule.filter.origins),
|
|
1193
|
+
originFrom: optionalFromNullable(rule.filter.originFrom),
|
|
1194
|
+
originThrough: optionalFromNullable(rule.filter.originThrough),
|
|
1195
|
+
valuations: optionalFromNullable(rule.filter.valuations),
|
|
1196
|
+
valuationFrom: optionalFromNullable(rule.filter.valuationFrom),
|
|
1197
|
+
valuationThrough: optionalFromNullable(rule.filter.valuationThrough),
|
|
1198
|
+
minDevelopmentAge: optionalFromNullable(rule.filter.minDevelopmentAge),
|
|
1199
|
+
maxDevelopmentAge: optionalFromNullable(rule.filter.maxDevelopmentAge),
|
|
1200
|
+
};
|
|
1201
|
+
return { ...rule, tolerance, ...(filter === undefined ? {} : { filter }) };
|
|
1202
|
+
}) as readonly DiagnosticReviewRule[];
|
|
1203
|
+
return {
|
|
1204
|
+
diagnosticDefinitionVersion: definition.diagnosticDefinitionVersion,
|
|
1205
|
+
id: definition.id,
|
|
1206
|
+
version: definition.version,
|
|
1207
|
+
lossRowGrain: definition.lossRowGrain,
|
|
1208
|
+
measures: definition.measures.map((measure) => ({
|
|
1209
|
+
id: measure.id,
|
|
1210
|
+
displayName: measure.displayName,
|
|
1211
|
+
description: measure.description,
|
|
1212
|
+
source: measure.source,
|
|
1213
|
+
kind: measure.kind,
|
|
1214
|
+
unit: measure.unit,
|
|
1215
|
+
developmentSemantics: measure.developmentSemantics,
|
|
1216
|
+
aggregation: measure.aggregation,
|
|
1217
|
+
missing: measure.missing,
|
|
1218
|
+
...(measure.basisId === null || measure.basisId === undefined ? {} : { basisId: measure.basisId }),
|
|
1219
|
+
...(measure.countPopulationId === null || measure.countPopulationId === undefined
|
|
1220
|
+
? {}
|
|
1221
|
+
: { countPopulationId: measure.countPopulationId }),
|
|
1222
|
+
...(measure.exposureBasisId === null || measure.exposureBasisId === undefined
|
|
1223
|
+
? {}
|
|
1224
|
+
: { exposureBasisId: measure.exposureBasisId }),
|
|
1225
|
+
...(measure.exposureTiming === null || measure.exposureTiming === undefined
|
|
1226
|
+
? {}
|
|
1227
|
+
: { exposureTiming: measure.exposureTiming }),
|
|
1228
|
+
})),
|
|
1229
|
+
countPopulations: definition.countPopulations.map((population) => ({ ...population })),
|
|
1230
|
+
exposureBases: definition.exposureBases.map((basis) => ({
|
|
1231
|
+
id: basis.id,
|
|
1232
|
+
displayName: basis.displayName,
|
|
1233
|
+
basis: basis.basis,
|
|
1234
|
+
unit: basis.unit,
|
|
1235
|
+
description: basis.description,
|
|
1236
|
+
...(basis.sourceDescription === null || basis.sourceDescription === undefined
|
|
1237
|
+
? {}
|
|
1238
|
+
: { sourceDescription: basis.sourceDescription }),
|
|
1239
|
+
...(basis.attributes === undefined ? {} : { attributes: basis.attributes }),
|
|
1240
|
+
})),
|
|
1241
|
+
amountBases: definition.amountBases.map((basis) => ({
|
|
1242
|
+
id: basis.id,
|
|
1243
|
+
displayName: basis.displayName,
|
|
1244
|
+
currency: basis.currency,
|
|
1245
|
+
perspective: basis.perspective,
|
|
1246
|
+
components: basis.components.map((component) => ({
|
|
1247
|
+
id: component.id,
|
|
1248
|
+
treatment: component.treatment,
|
|
1249
|
+
limitation: component.limitation.kind === "unknown"
|
|
1250
|
+
? {
|
|
1251
|
+
kind: "unknown" as const,
|
|
1252
|
+
...(component.limitation.description === null || component.limitation.description === undefined
|
|
1253
|
+
? {}
|
|
1254
|
+
: { description: component.limitation.description }),
|
|
1255
|
+
}
|
|
1256
|
+
: component.limitation,
|
|
1257
|
+
})),
|
|
1258
|
+
...(basis.sourceDescription === null || basis.sourceDescription === undefined
|
|
1259
|
+
? {}
|
|
1260
|
+
: { sourceDescription: basis.sourceDescription }),
|
|
1261
|
+
...(basis.attributes === undefined ? {} : { attributes: basis.attributes }),
|
|
1262
|
+
})),
|
|
1263
|
+
derivedMeasures: definition.derivedMeasures.map((derivation) => ({ ...derivation })),
|
|
1264
|
+
formulas: definition.formulas.map((formula) => ({
|
|
1265
|
+
id: formula.id,
|
|
1266
|
+
version: formula.version,
|
|
1267
|
+
roles: Object.fromEntries(Object.entries(formula.roles).map(([name, role]) => [name, {
|
|
1268
|
+
kind: role.kind,
|
|
1269
|
+
...(role.compatibilityGroup === null || role.compatibilityGroup === undefined
|
|
1270
|
+
? {}
|
|
1271
|
+
: { compatibilityGroup: role.compatibilityGroup }),
|
|
1272
|
+
...(role.developmentSemantics === null || role.developmentSemantics === undefined
|
|
1273
|
+
? {}
|
|
1274
|
+
: { developmentSemantics: role.developmentSemantics }),
|
|
1275
|
+
}])),
|
|
1276
|
+
numerator: formula.numerator,
|
|
1277
|
+
denominator: formula.denominator,
|
|
1278
|
+
denominatorPolicy: formula.denominatorPolicy,
|
|
1279
|
+
})),
|
|
1280
|
+
instances: definition.instances.map((instance) => ({
|
|
1281
|
+
id: instance.id,
|
|
1282
|
+
version: instance.version,
|
|
1283
|
+
formulaId: instance.formulaId,
|
|
1284
|
+
bindings: instance.bindings,
|
|
1285
|
+
presentation: instance.presentation,
|
|
1286
|
+
rules: instance.rules.map((rule) => ({
|
|
1287
|
+
...rule,
|
|
1288
|
+
when: { ...rule.when, tolerance: { ...rule.when.tolerance } },
|
|
1289
|
+
})),
|
|
1290
|
+
})),
|
|
1291
|
+
reviewRules,
|
|
1292
|
+
periodAxis: definition.periodAxis,
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/** Compiles, validates, normalizes, fingerprints, and freezes a diagnostic definition. */
|
|
1297
|
+
export function compileDiagnosticDefinition(
|
|
1298
|
+
value: DiagnosticDefinition | NormalizedDiagnosticDefinitionIdentity,
|
|
1299
|
+
): CompiledDiagnosticDefinition {
|
|
1300
|
+
const boundaryIssues: DiagnosticValidationIssue[] = [];
|
|
1301
|
+
validateJsonValue(value, boundaryIssues);
|
|
1302
|
+
if (boundaryIssues.length > 0) throw new DiagnosticValidationError(boundaryIssues);
|
|
1303
|
+
let authored: DiagnosticDefinition;
|
|
1304
|
+
try {
|
|
1305
|
+
authored = authoredView(value);
|
|
1306
|
+
} catch {
|
|
1307
|
+
throw new DiagnosticValidationError([{
|
|
1308
|
+
domain: "definition",
|
|
1309
|
+
code: "invalid-type",
|
|
1310
|
+
path: "$",
|
|
1311
|
+
message: "Diagnostic definition does not have the required object and array structure",
|
|
1312
|
+
}]);
|
|
1313
|
+
}
|
|
1314
|
+
let definition: DiagnosticDefinition;
|
|
1315
|
+
try {
|
|
1316
|
+
definition = validateDefinition(authored);
|
|
1317
|
+
} catch (error) {
|
|
1318
|
+
if (error instanceof DiagnosticValidationError) throw error;
|
|
1319
|
+
throw new DiagnosticValidationError([{
|
|
1320
|
+
domain: "definition",
|
|
1321
|
+
code: "invalid-type",
|
|
1322
|
+
path: "$",
|
|
1323
|
+
message: "Diagnostic definition contains a malformed nested value",
|
|
1324
|
+
}]);
|
|
1325
|
+
}
|
|
1326
|
+
const normalized = normalizeDiagnosticDefinition(definition);
|
|
1327
|
+
const identities = buildDiagnosticIdentities(normalized);
|
|
1328
|
+
const compiledBase = {
|
|
1329
|
+
definition: normalized,
|
|
1330
|
+
formulaFingerprints: identities.formulaFingerprints,
|
|
1331
|
+
calculationFingerprints: identities.calculationFingerprints,
|
|
1332
|
+
definitionIntegrity: identities.definitionIntegrity,
|
|
1333
|
+
};
|
|
1334
|
+
Object.defineProperty(compiledBase, compiledDiagnosticDefinitionBrand, {
|
|
1335
|
+
value: true,
|
|
1336
|
+
enumerable: false,
|
|
1337
|
+
configurable: false,
|
|
1338
|
+
writable: false,
|
|
1339
|
+
});
|
|
1340
|
+
const compiled = compiledBase as CompiledDiagnosticDefinition;
|
|
1341
|
+
Object.freeze(compiled);
|
|
1342
|
+
authenticCompiledDefinitions.add(compiled);
|
|
1343
|
+
compiledInternals.set(compiled, {
|
|
1344
|
+
formulasById: new Map(normalized.formulas.map((formula) => [formula.id, formula])),
|
|
1345
|
+
measuresById: new Map(definition.measures.map((measure) => [measure.id, measure])),
|
|
1346
|
+
calculationScopesByInstanceId: identities.calculationScopesByInstanceId,
|
|
1347
|
+
calculationDependenciesByInstanceId: identities.calculationDependenciesByInstanceId,
|
|
1348
|
+
evaluationDependenciesByInstanceId: identities.evaluationDependenciesByInstanceId,
|
|
1349
|
+
derivationsByOutputMeasureId: new Map(
|
|
1350
|
+
normalized.derivedMeasures.map((derivation) => [derivation.outputMeasureId, derivation as DiagnosticDerivedMeasureDefinition]),
|
|
1351
|
+
),
|
|
1352
|
+
});
|
|
1353
|
+
return compiled;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
export function assertCompiledDiagnosticDefinition(
|
|
1357
|
+
value: unknown,
|
|
1358
|
+
): asserts value is CompiledDiagnosticDefinition {
|
|
1359
|
+
if ((typeof value !== "object" && typeof value !== "function") || value === null || !authenticCompiledDefinitions.has(value)) {
|
|
1360
|
+
throw new DiagnosticValidationError([{
|
|
1361
|
+
domain: "definition",
|
|
1362
|
+
code: "invalid-input-relationship",
|
|
1363
|
+
path: "$",
|
|
1364
|
+
message: "Value is not an authentic compiled diagnostic definition",
|
|
1365
|
+
}]);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/** @internal Shared only by core diagnostic runtime modules; not re-exported. */
|
|
1370
|
+
export function getCompiledDiagnosticDefinitionInternals(
|
|
1371
|
+
value: CompiledDiagnosticDefinition,
|
|
1372
|
+
): CompiledDiagnosticDefinitionInternals {
|
|
1373
|
+
assertCompiledDiagnosticDefinition(value);
|
|
1374
|
+
return compiledInternals.get(value)!;
|
|
1375
|
+
}
|