@actuarial-ts/core 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +65 -226
  2. package/dist/capping.d.ts.map +1 -1
  3. package/dist/capping.js +39 -6
  4. package/dist/capping.js.map +1 -1
  5. package/dist/casualtyDiagnostics.d.ts +19 -49
  6. package/dist/casualtyDiagnostics.d.ts.map +1 -1
  7. package/dist/casualtyDiagnostics.js +84 -116
  8. package/dist/casualtyDiagnostics.js.map +1 -1
  9. package/dist/diagnosticAggregation.d.ts +32 -0
  10. package/dist/diagnosticAggregation.d.ts.map +1 -0
  11. package/dist/diagnosticAggregation.js +73 -0
  12. package/dist/diagnosticAggregation.js.map +1 -0
  13. package/dist/diagnosticDefinitions.d.ts +289 -0
  14. package/dist/diagnosticDefinitions.d.ts.map +1 -0
  15. package/dist/diagnosticDefinitions.js +1688 -0
  16. package/dist/diagnosticDefinitions.js.map +1 -0
  17. package/dist/diagnosticDerivations.d.ts +35 -0
  18. package/dist/diagnosticDerivations.d.ts.map +1 -0
  19. package/dist/diagnosticDerivations.js +224 -0
  20. package/dist/diagnosticDerivations.js.map +1 -0
  21. package/dist/diagnosticExposure.d.ts +51 -0
  22. package/dist/diagnosticExposure.d.ts.map +1 -0
  23. package/dist/diagnosticExposure.js +273 -0
  24. package/dist/diagnosticExposure.js.map +1 -0
  25. package/dist/diagnosticExpressions.d.ts +56 -0
  26. package/dist/diagnosticExpressions.d.ts.map +1 -0
  27. package/dist/diagnosticExpressions.js +124 -0
  28. package/dist/diagnosticExpressions.js.map +1 -0
  29. package/dist/diagnosticFormulas.d.ts +198 -0
  30. package/dist/diagnosticFormulas.d.ts.map +1 -0
  31. package/dist/diagnosticFormulas.js +219 -0
  32. package/dist/diagnosticFormulas.js.map +1 -0
  33. package/dist/diagnosticIdentity.d.ts +251 -0
  34. package/dist/diagnosticIdentity.d.ts.map +1 -0
  35. package/dist/diagnosticIdentity.js +721 -0
  36. package/dist/diagnosticIdentity.js.map +1 -0
  37. package/dist/diagnosticOrdering.d.ts +8 -0
  38. package/dist/diagnosticOrdering.d.ts.map +1 -0
  39. package/dist/diagnosticOrdering.js +73 -0
  40. package/dist/diagnosticOrdering.js.map +1 -0
  41. package/dist/diagnosticPeriodAxis.d.ts +9 -0
  42. package/dist/diagnosticPeriodAxis.d.ts.map +1 -0
  43. package/dist/diagnosticPeriodAxis.js +69 -0
  44. package/dist/diagnosticPeriodAxis.js.map +1 -0
  45. package/dist/diagnosticPeriods.d.ts +15 -0
  46. package/dist/diagnosticPeriods.d.ts.map +1 -0
  47. package/dist/diagnosticPeriods.js +50 -0
  48. package/dist/diagnosticPeriods.js.map +1 -0
  49. package/dist/diagnosticPreparation.d.ts +131 -0
  50. package/dist/diagnosticPreparation.d.ts.map +1 -0
  51. package/dist/diagnosticPreparation.js +1539 -0
  52. package/dist/diagnosticPreparation.js.map +1 -0
  53. package/dist/diagnosticReview.d.ts +66 -0
  54. package/dist/diagnosticReview.d.ts.map +1 -0
  55. package/dist/diagnosticReview.js +490 -0
  56. package/dist/diagnosticReview.js.map +1 -0
  57. package/dist/diagnosticRules.d.ts +32 -0
  58. package/dist/diagnosticRules.d.ts.map +1 -0
  59. package/dist/diagnosticRules.js +32 -0
  60. package/dist/diagnosticRules.js.map +1 -0
  61. package/dist/diagnosticRunner.d.ts +86 -0
  62. package/dist/diagnosticRunner.d.ts.map +1 -0
  63. package/dist/diagnosticRunner.js +703 -0
  64. package/dist/diagnosticRunner.js.map +1 -0
  65. package/dist/diagnosticRuntime.d.ts +26 -0
  66. package/dist/diagnosticRuntime.d.ts.map +1 -0
  67. package/dist/diagnosticRuntime.js +243 -0
  68. package/dist/diagnosticRuntime.js.map +1 -0
  69. package/dist/diagnosticSourceOrdering.d.ts +6 -0
  70. package/dist/diagnosticSourceOrdering.d.ts.map +1 -0
  71. package/dist/diagnosticSourceOrdering.js +38 -0
  72. package/dist/diagnosticSourceOrdering.js.map +1 -0
  73. package/dist/index.d.ts +28 -1
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +16 -1
  76. package/dist/index.js.map +1 -1
  77. package/dist/mack.d.ts +4 -0
  78. package/dist/mack.d.ts.map +1 -1
  79. package/dist/mack.js +32 -4
  80. package/dist/mack.js.map +1 -1
  81. package/dist/triangle.d.ts.map +1 -1
  82. package/dist/triangle.js +59 -9
  83. package/dist/triangle.js.map +1 -1
  84. package/dist/types.d.ts +21 -0
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/types.js +128 -0
  87. package/dist/types.js.map +1 -1
  88. package/dist/version.d.ts +3 -0
  89. package/dist/version.d.ts.map +1 -0
  90. package/dist/version.js +3 -0
  91. package/dist/version.js.map +1 -0
  92. package/package.json +4 -3
  93. package/src/capping.ts +109 -18
  94. package/src/casualtyDiagnostics.ts +79 -189
  95. package/src/diagnosticAggregation.ts +128 -0
  96. package/src/diagnosticDefinitions.ts +3624 -0
  97. package/src/diagnosticDerivations.ts +321 -0
  98. package/src/diagnosticExposure.ts +482 -0
  99. package/src/diagnosticExpressions.ts +255 -0
  100. package/src/diagnosticFormulas.ts +350 -0
  101. package/src/diagnosticIdentity.ts +1267 -0
  102. package/src/diagnosticOrdering.ts +106 -0
  103. package/src/diagnosticPeriodAxis.ts +92 -0
  104. package/src/diagnosticPeriods.ts +102 -0
  105. package/src/diagnosticPreparation.ts +2475 -0
  106. package/src/diagnosticReview.ts +897 -0
  107. package/src/diagnosticRules.ts +69 -0
  108. package/src/diagnosticRunner.ts +1114 -0
  109. package/src/diagnosticRuntime.ts +264 -0
  110. package/src/diagnosticSourceOrdering.ts +50 -0
  111. package/src/index.ts +184 -1
  112. package/src/mack.ts +72 -9
  113. package/src/triangle.ts +103 -13
  114. package/src/types.ts +196 -0
  115. package/src/version.ts +2 -0
  116. package/dist/metricDiagnostics.d.ts +0 -212
  117. package/dist/metricDiagnostics.d.ts.map +0 -1
  118. package/dist/metricDiagnostics.js +0 -627
  119. package/dist/metricDiagnostics.js.map +0 -1
  120. package/src/metricDiagnostics.ts +0 -876
@@ -0,0 +1,3624 @@
1
+ import {
2
+ MAX_DIAGNOSTIC_DEFINITION_EXPRESSION_NODES,
3
+ MAX_DIAGNOSTIC_EXPRESSION_NODES,
4
+ walkDiagnosticExpression,
5
+ type DiagnosticClaimExpression,
6
+ type DiagnosticMeasureExpression,
7
+ type DiagnosticRoleExpression,
8
+ } from "./diagnosticExpressions.js";
9
+ import {
10
+ buildDiagnosticIdentities,
11
+ normalizeDiagnosticDefinition,
12
+ type NormalizedDiagnosticCalculationScope,
13
+ type NormalizedDiagnosticDefinitionIdentity,
14
+ type NormalizedDiagnosticFormulaIdentity,
15
+ } from "./diagnosticIdentity.js";
16
+ import {
17
+ DiagnosticValidationError,
18
+ type DiagnosticValidationIssue,
19
+ type DiagnosticValidationIssueCode,
20
+ } from "./types.js";
21
+ import { canonicalJson } from "./canonical.js";
22
+ import { normalizeDiagnosticPeriodWithAxis } from "./diagnosticPeriodAxis.js";
23
+ import {
24
+ diagnosticJsonPreflight,
25
+ hasDiagnosticOwn,
26
+ isDiagnosticPlainRecord,
27
+ isDiagnosticToken,
28
+ isWellFormedDiagnosticString,
29
+ } from "./diagnosticRuntime.js";
30
+
31
+ export type DiagnosticMeasureKind = "count" | "amount" | "exposure";
32
+ export type DiagnosticMeasureSource = "loss" | "exposure" | "derived";
33
+ export type DiagnosticMissingPolicy = "unknown" | "zero";
34
+ export type DiagnosticAggregation = "sum";
35
+ export type DiagnosticExposureTiming = "origin-static" | "valuation-specific";
36
+ export type DiagnosticDevelopmentSemantics =
37
+ | "cumulative"
38
+ | "incremental"
39
+ | "point-in-time"
40
+ | "unknown";
41
+
42
+ export type JsonValue =
43
+ | null
44
+ | boolean
45
+ | number
46
+ | string
47
+ | readonly JsonValue[]
48
+ | { readonly [key: string]: JsonValue };
49
+
50
+ export type DiagnosticDeepReadonly<T> = T extends readonly (infer U)[]
51
+ ? readonly DiagnosticDeepReadonly<U>[]
52
+ : T extends object
53
+ ? { readonly [K in keyof T]: DiagnosticDeepReadonly<T[K]> }
54
+ : T;
55
+
56
+ export interface DiagnosticMeasureDefinition {
57
+ id: string;
58
+ displayName: string;
59
+ description: string;
60
+ source: DiagnosticMeasureSource;
61
+ kind: DiagnosticMeasureKind;
62
+ unit: string;
63
+ developmentSemantics: DiagnosticDevelopmentSemantics;
64
+ aggregation: DiagnosticAggregation;
65
+ missing: DiagnosticMissingPolicy;
66
+ basisId?: string;
67
+ countPopulationId?: string;
68
+ exposureBasisId?: string;
69
+ exposureTiming?: DiagnosticExposureTiming;
70
+ }
71
+
72
+ export interface DiagnosticCountPopulationDefinition {
73
+ id: string;
74
+ displayName: string;
75
+ subject: "claim" | "claimant" | "policy" | "occurrence" | "other" | "unknown";
76
+ unit: string;
77
+ description: string;
78
+ attributes?: Readonly<Record<string, string | number | boolean | null>>;
79
+ }
80
+
81
+ export interface DiagnosticExposureBasisDefinition {
82
+ id: string;
83
+ displayName: string;
84
+ basis: "earned" | "written" | "in-force" | "other" | "unknown";
85
+ unit: string;
86
+ description: string;
87
+ sourceDescription?: string;
88
+ attributes?: Readonly<Record<string, string | number | boolean | null>>;
89
+ }
90
+
91
+ export type AmountPerspective = "gross" | "net" | "ceded" | "other" | "unknown";
92
+
93
+ export type AmountLimitation =
94
+ | { kind: "unlimited" }
95
+ | {
96
+ kind: "layer" | "pre-limited";
97
+ attachment: number;
98
+ limit: number | null;
99
+ application: "claim" | "occurrence" | "policy" | "source-defined";
100
+ derivation:
101
+ | { kind: "sdk" }
102
+ | {
103
+ kind: "external";
104
+ actor: "caller" | "source";
105
+ transformationRef: string;
106
+ };
107
+ }
108
+ | { kind: "unknown"; description?: string };
109
+
110
+ export interface AmountBasisComponent {
111
+ id: string;
112
+ treatment: "included" | "excluded" | "unknown";
113
+ limitation: AmountLimitation;
114
+ }
115
+
116
+ export interface AmountBasisDefinition {
117
+ id: string;
118
+ displayName: string;
119
+ currency: string;
120
+ perspective: AmountPerspective;
121
+ components: readonly AmountBasisComponent[];
122
+ sourceDescription?: string;
123
+ attributes?: Readonly<Record<string, string | number | boolean | null>>;
124
+ }
125
+
126
+ export interface DiagnosticFormulaRole {
127
+ kind: DiagnosticMeasureKind;
128
+ compatibilityGroup?: string;
129
+ developmentSemantics?: DiagnosticDevelopmentSemantics;
130
+ }
131
+
132
+ export interface DiagnosticFormulaTemplate {
133
+ id: string;
134
+ version: string;
135
+ roles: Readonly<Record<string, DiagnosticFormulaRole>>;
136
+ numerator: DiagnosticRoleExpression;
137
+ denominator: DiagnosticRoleExpression;
138
+ denominatorPolicy: "positive-or-null";
139
+ }
140
+
141
+ export interface DiagnosticMetricPresentation {
142
+ displayName: string;
143
+ description: string;
144
+ displayUnit: string;
145
+ scale: number;
146
+ numeratorLabel: string;
147
+ denominatorLabel: string;
148
+ }
149
+
150
+ export type DiagnosticRuleOperand =
151
+ | { source: "measure"; expression: DiagnosticMeasureExpression }
152
+ | { source: "calculation"; field: "numerator" | "denominator" }
153
+ | { source: "constant"; value: number };
154
+
155
+ export interface DiagnosticComparisonPredicate {
156
+ left: DiagnosticRuleOperand;
157
+ operator: "lt" | "lte" | "eq" | "neq" | "gte" | "gt";
158
+ right: DiagnosticRuleOperand;
159
+ tolerance?: { absolute?: number; relative?: number };
160
+ }
161
+
162
+ export interface DiagnosticComparisonRule {
163
+ id: string;
164
+ code: string;
165
+ message: string;
166
+ severity: "warning" | "fail";
167
+ when: DiagnosticComparisonPredicate;
168
+ }
169
+
170
+ export interface DiagnosticMetricInstance {
171
+ id: string;
172
+ version: string;
173
+ formulaId: string;
174
+ bindings: Readonly<Record<string, DiagnosticMeasureExpression>>;
175
+ presentation: DiagnosticMetricPresentation;
176
+ rules: readonly DiagnosticComparisonRule[];
177
+ }
178
+
179
+ export interface DiagnosticPeriodCoordinate {
180
+ label: string;
181
+ aliases?: readonly string[];
182
+ coordinate: number;
183
+ }
184
+
185
+ export type DiagnosticPeriodAxis =
186
+ | {
187
+ kind: "calendar";
188
+ originCadence: "month" | "quarter" | "year";
189
+ valuationCadence: "month" | "quarter" | "year";
190
+ originAnchor: "start" | "end";
191
+ valuationAnchor: "start" | "end";
192
+ ageUnit: "month";
193
+ ageOffset: number;
194
+ }
195
+ | {
196
+ kind: "ordered";
197
+ id: string;
198
+ version: string;
199
+ ageUnit: string;
200
+ ageOffset: number;
201
+ origins: readonly DiagnosticPeriodCoordinate[];
202
+ valuations: readonly DiagnosticPeriodCoordinate[];
203
+ };
204
+
205
+ export interface DiagnosticDerivedMeasureDefinition {
206
+ id: string;
207
+ outputMeasureId: string;
208
+ expression: DiagnosticClaimExpression;
209
+ }
210
+
211
+ export interface DiagnosticsFilter {
212
+ sourceGroups?: readonly string[];
213
+ outputGroups?: readonly string[];
214
+ origins?: readonly string[];
215
+ originFrom?: string;
216
+ originThrough?: string;
217
+ valuations?: readonly string[];
218
+ valuationFrom?: string;
219
+ valuationThrough?: string;
220
+ minDevelopmentAge?: number;
221
+ maxDevelopmentAge?: number;
222
+ instanceIds?: readonly string[];
223
+ }
224
+
225
+ interface DiagnosticReviewRuleBase {
226
+ id: string;
227
+ code: string;
228
+ description: string;
229
+ severity: "warning" | "fail";
230
+ tolerance?: { absolute?: number; relative?: number };
231
+ missingInput: "not-evaluated" | "finding";
232
+ }
233
+
234
+ export type DiagnosticReviewOperand =
235
+ | DiagnosticMeasureExpression
236
+ | { op: "constant"; value: number };
237
+
238
+ export interface DiagnosticReviewPredicate {
239
+ left: DiagnosticReviewOperand;
240
+ operator: "lt" | "lte" | "eq" | "neq" | "gte" | "gt";
241
+ right: DiagnosticReviewOperand;
242
+ }
243
+
244
+ export type DiagnosticControlTotalProjection =
245
+ | { kind: "valuation"; valuation: string }
246
+ | { kind: "latest-valuation-per-origin" }
247
+ | { kind: "all-cells" };
248
+
249
+ export type DiagnosticReviewFilter = Omit<
250
+ DiagnosticsFilter,
251
+ "outputGroups" | "instanceIds"
252
+ >;
253
+
254
+ export type DiagnosticReviewRule =
255
+ | (DiagnosticReviewRuleBase & {
256
+ kind: "compare";
257
+ when: DiagnosticReviewPredicate;
258
+ })
259
+ | (DiagnosticReviewRuleBase & {
260
+ kind: "reconcile";
261
+ actual: DiagnosticMeasureExpression;
262
+ expected: DiagnosticReviewOperand;
263
+ })
264
+ | (DiagnosticReviewRuleBase & {
265
+ kind: "monotonic";
266
+ expression: DiagnosticMeasureExpression;
267
+ direction: "nondecreasing" | "nonincreasing";
268
+ })
269
+ | (DiagnosticReviewRuleBase & {
270
+ kind: "layer-order";
271
+ narrower: DiagnosticMeasureExpression;
272
+ broader: DiagnosticMeasureExpression;
273
+ comparability:
274
+ | { kind: "compiler-proven" }
275
+ | { kind: "caller-asserted"; rationaleArtifactId: string };
276
+ })
277
+ | (DiagnosticReviewRuleBase & {
278
+ kind: "control-total";
279
+ expression: DiagnosticMeasureExpression;
280
+ expected: number;
281
+ filter?: DiagnosticReviewFilter;
282
+ projection: DiagnosticControlTotalProjection;
283
+ });
284
+
285
+ export interface DiagnosticDefinition {
286
+ diagnosticDefinitionVersion: "1.0.0";
287
+ id: string;
288
+ version: string;
289
+ lossRowGrain: "claim" | "aggregate";
290
+ measures: readonly DiagnosticMeasureDefinition[];
291
+ countPopulations: readonly DiagnosticCountPopulationDefinition[];
292
+ exposureBases: readonly DiagnosticExposureBasisDefinition[];
293
+ amountBases: readonly AmountBasisDefinition[];
294
+ derivedMeasures: readonly DiagnosticDerivedMeasureDefinition[];
295
+ formulas: readonly DiagnosticFormulaTemplate[];
296
+ instances: readonly DiagnosticMetricInstance[];
297
+ reviewRules: readonly DiagnosticReviewRule[];
298
+ periodAxis: DiagnosticPeriodAxis;
299
+ }
300
+
301
+ export interface DiagnosticSourceLocation {
302
+ readonly artifactId: string;
303
+ readonly sourceFile?: string;
304
+ readonly sourceSheet?: string;
305
+ readonly sourceRow?: number;
306
+ readonly sourceCell?: string;
307
+ }
308
+
309
+ export interface DiagnosticMeasureStats {
310
+ readonly value: number | null;
311
+ readonly sum: number | null;
312
+ readonly observed: number;
313
+ readonly missing: number;
314
+ readonly nonFinite: number;
315
+ readonly imputedZero: number;
316
+ readonly deduplicated: number;
317
+ readonly structural: number;
318
+ }
319
+
320
+ const compiledDiagnosticDefinitionBrand: unique symbol = Symbol(
321
+ "compiled-diagnostic-definition",
322
+ );
323
+
324
+ export interface CompiledDiagnosticDefinition {
325
+ readonly [compiledDiagnosticDefinitionBrand]: true;
326
+ readonly definition: DiagnosticDeepReadonly<NormalizedDiagnosticDefinitionIdentity>;
327
+ readonly formulaFingerprints: Readonly<Record<string, string>>;
328
+ readonly calculationFingerprints: Readonly<Record<string, string>>;
329
+ readonly definitionIntegrity: string;
330
+ }
331
+
332
+ export interface CompiledDiagnosticDefinitionInternals {
333
+ readonly formulasById: ReadonlyMap<
334
+ string,
335
+ NormalizedDiagnosticFormulaIdentity
336
+ >;
337
+ readonly measuresById: ReadonlyMap<string, DiagnosticMeasureDefinition>;
338
+ readonly calculationScopesByInstanceId: ReadonlyMap<
339
+ string,
340
+ NormalizedDiagnosticCalculationScope
341
+ >;
342
+ readonly calculationDependenciesByInstanceId: ReadonlyMap<
343
+ string,
344
+ readonly string[]
345
+ >;
346
+ readonly evaluationDependenciesByInstanceId: ReadonlyMap<
347
+ string,
348
+ readonly string[]
349
+ >;
350
+ readonly derivationsByOutputMeasureId: ReadonlyMap<
351
+ string,
352
+ DiagnosticDerivedMeasureDefinition
353
+ >;
354
+ }
355
+
356
+ const authenticCompiledDefinitions = new WeakSet<object>();
357
+ const compiledInternals = new WeakMap<
358
+ object,
359
+ CompiledDiagnosticDefinitionInternals
360
+ >();
361
+
362
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
363
+ return isDiagnosticPlainRecord(value);
364
+ }
365
+
366
+ function propertyPath(parent: string, key: string): string {
367
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
368
+ ? `${parent}.${key}`
369
+ : `${parent}[${JSON.stringify(key)}]`;
370
+ }
371
+
372
+ function pushIssue(
373
+ issues: DiagnosticValidationIssue[],
374
+ code: DiagnosticValidationIssueCode,
375
+ path: string,
376
+ message: string,
377
+ ): void {
378
+ issues.push({ domain: "definition", code, path, message });
379
+ }
380
+
381
+ function validateAllowedKeys(
382
+ value: Record<string, unknown>,
383
+ allowed: readonly string[],
384
+ path: string,
385
+ issues: DiagnosticValidationIssue[],
386
+ ): void {
387
+ for (const key of Object.keys(value)) {
388
+ if (!allowed.includes(key)) {
389
+ pushIssue(
390
+ issues,
391
+ "unknown-key",
392
+ propertyPath(path, key),
393
+ `Unknown key ${key}`,
394
+ );
395
+ } else if (value[key] === undefined) {
396
+ pushIssue(
397
+ issues,
398
+ "invalid-type",
399
+ propertyPath(path, key),
400
+ "Explicit undefined is not allowed",
401
+ );
402
+ }
403
+ }
404
+ }
405
+
406
+ function validateScalarAttributes(
407
+ value: unknown,
408
+ path: string,
409
+ issues: DiagnosticValidationIssue[],
410
+ ): void {
411
+ if (value === undefined) return;
412
+ if (!isPlainRecord(value)) {
413
+ pushIssue(
414
+ issues,
415
+ "invalid-type",
416
+ path,
417
+ "Attributes must be a plain object",
418
+ );
419
+ return;
420
+ }
421
+ for (const [key, scalar] of Object.entries(value)) {
422
+ validateToken(key, propertyPath(path, key), "Attribute key", issues);
423
+ if (
424
+ scalar !== null &&
425
+ typeof scalar !== "string" &&
426
+ typeof scalar !== "number" &&
427
+ typeof scalar !== "boolean"
428
+ ) {
429
+ pushIssue(
430
+ issues,
431
+ "invalid-type",
432
+ propertyPath(path, key),
433
+ "Attribute value must be a string, finite number, boolean, or null",
434
+ );
435
+ } else if (typeof scalar === "number") {
436
+ validateFinite(
437
+ scalar,
438
+ propertyPath(path, key),
439
+ "Attribute number",
440
+ issues,
441
+ );
442
+ } else if (
443
+ typeof scalar === "string" &&
444
+ !isWellFormedDiagnosticString(scalar)
445
+ ) {
446
+ pushIssue(
447
+ issues,
448
+ "invalid-string",
449
+ propertyPath(path, key),
450
+ "Attribute string contains invalid Unicode",
451
+ );
452
+ }
453
+ }
454
+ }
455
+
456
+ function validateToken(
457
+ value: unknown,
458
+ path: string,
459
+ label: string,
460
+ issues: DiagnosticValidationIssue[],
461
+ ): value is string {
462
+ if (typeof value !== "string") {
463
+ pushIssue(issues, "invalid-type", path, `${label} must be a string`);
464
+ return false;
465
+ }
466
+ if (!isWellFormedDiagnosticString(value)) {
467
+ pushIssue(
468
+ issues,
469
+ "invalid-string",
470
+ path,
471
+ `${label} contains an invalid Unicode string`,
472
+ );
473
+ return false;
474
+ }
475
+ if (!isDiagnosticToken(value)) {
476
+ pushIssue(
477
+ issues,
478
+ "invalid-string",
479
+ path,
480
+ `${label} must be nonempty without surrounding ASCII whitespace`,
481
+ );
482
+ return false;
483
+ }
484
+ return true;
485
+ }
486
+
487
+ function validateFinite(
488
+ value: unknown,
489
+ path: string,
490
+ label: string,
491
+ issues: DiagnosticValidationIssue[],
492
+ ): value is number {
493
+ if (typeof value !== "number" || !Number.isFinite(value)) {
494
+ pushIssue(issues, "invalid-number", path, `${label} must be finite`);
495
+ return false;
496
+ }
497
+ return true;
498
+ }
499
+
500
+ function validateJsonValue(
501
+ value: unknown,
502
+ issues: DiagnosticValidationIssue[],
503
+ ): void {
504
+ issues.push(...diagnosticJsonPreflight(value, "definition"));
505
+ }
506
+
507
+ function requireArray(
508
+ value: unknown,
509
+ path: string,
510
+ issues: DiagnosticValidationIssue[],
511
+ ): readonly unknown[] {
512
+ if (!Array.isArray(value)) {
513
+ pushIssue(issues, "invalid-type", path, "Expected an array");
514
+ return [];
515
+ }
516
+ return value;
517
+ }
518
+
519
+ function collectCatalog<T extends { readonly id: string }>(
520
+ values: readonly T[],
521
+ path: string,
522
+ issues: DiagnosticValidationIssue[],
523
+ ): Map<string, T> {
524
+ const result = new Map<string, T>();
525
+ values.forEach((value, index) => {
526
+ const idPath = `${path}[${index}].id`;
527
+ if (!validateToken(value?.id, idPath, "ID", issues)) return;
528
+ if (result.has(value.id)) {
529
+ pushIssue(issues, "duplicate-id", idPath, `Duplicate ID ${value.id}`);
530
+ } else result.set(value.id, value);
531
+ });
532
+ return result;
533
+ }
534
+
535
+ function semanticReference(measure: DiagnosticMeasureDefinition): string {
536
+ if (measure.kind === "amount") return `amount:${measure.basisId ?? ""}`;
537
+ if (measure.kind === "count")
538
+ return `count:${measure.countPopulationId ?? ""}`;
539
+ return `exposure:${measure.exposureBasisId ?? ""}`;
540
+ }
541
+
542
+ function transitiveMeasureIds(
543
+ roots: readonly string[],
544
+ derivations: ReadonlyMap<string, DiagnosticDerivedMeasureDefinition>,
545
+ ): readonly string[] {
546
+ const result = new Set<string>();
547
+ const stack = [...roots];
548
+ while (stack.length > 0) {
549
+ const id = stack.pop()!;
550
+ if (result.has(id)) continue;
551
+ result.add(id);
552
+ const derivation = derivations.get(id);
553
+ if (derivation) {
554
+ stack.push(
555
+ ...walkDiagnosticExpression(derivation.expression, "claim", "$", [])
556
+ .dependencies,
557
+ );
558
+ }
559
+ }
560
+ return [...result].sort();
561
+ }
562
+
563
+ interface ProjectedAmountBasis {
564
+ readonly currency: string;
565
+ readonly perspective: AmountPerspective;
566
+ readonly components: readonly AmountBasisComponent[];
567
+ }
568
+
569
+ interface ProjectedClaimSemantics {
570
+ readonly kind: DiagnosticMeasureKind;
571
+ readonly unit: string;
572
+ readonly reference: string;
573
+ readonly amountBasis?: ProjectedAmountBasis;
574
+ }
575
+
576
+ function projectedAmountBasis(
577
+ basis: AmountBasisDefinition,
578
+ ): ProjectedAmountBasis {
579
+ return {
580
+ currency: basis.currency,
581
+ perspective: basis.perspective,
582
+ components: [...basis.components].sort((left, right) =>
583
+ left.id < right.id ? -1 : left.id > right.id ? 1 : 0,
584
+ ),
585
+ };
586
+ }
587
+
588
+ function projectedBasisKey(basis: ProjectedAmountBasis): string {
589
+ return canonicalJson(basis);
590
+ }
591
+
592
+ function projectClaimExpression(
593
+ expression: DiagnosticClaimExpression,
594
+ path: string,
595
+ measures: ReadonlyMap<string, DiagnosticMeasureDefinition>,
596
+ amountBases: ReadonlyMap<string, AmountBasisDefinition>,
597
+ issues: DiagnosticValidationIssue[],
598
+ ): ProjectedClaimSemantics | null {
599
+ if (expression.op === "measure") {
600
+ const measure = measures.get(expression.measureId);
601
+ if (!measure) return null;
602
+ if (measure.kind !== "amount") {
603
+ return {
604
+ kind: measure.kind,
605
+ unit: measure.unit,
606
+ reference: semanticReference(measure),
607
+ };
608
+ }
609
+ const basis = measure.basisId
610
+ ? amountBases.get(measure.basisId)
611
+ : undefined;
612
+ return basis
613
+ ? {
614
+ kind: "amount",
615
+ unit: measure.unit,
616
+ reference: `amount:${measure.basisId}`,
617
+ amountBasis: projectedAmountBasis(basis),
618
+ }
619
+ : null;
620
+ }
621
+ if (expression.op === "claim-layer") {
622
+ const measure = measures.get(expression.measureId);
623
+ if (!measure || measure.kind !== "amount" || !measure.basisId) {
624
+ pushIssue(
625
+ issues,
626
+ "incompatible-semantics",
627
+ path,
628
+ "Claim layers require an amount measure",
629
+ );
630
+ return null;
631
+ }
632
+ const basis = amountBases.get(measure.basisId);
633
+ const component = basis?.components[0];
634
+ if (
635
+ !basis ||
636
+ basis.components.length !== 1 ||
637
+ component?.treatment !== "included" ||
638
+ component.limitation.kind !== "unlimited"
639
+ ) {
640
+ pushIssue(
641
+ issues,
642
+ "incompatible-semantics",
643
+ path,
644
+ "Claim layers require one included unlimited source component",
645
+ );
646
+ return null;
647
+ }
648
+ return {
649
+ kind: "amount",
650
+ unit: measure.unit,
651
+ reference: "amount:projected",
652
+ amountBasis: {
653
+ currency: basis.currency,
654
+ perspective: basis.perspective,
655
+ components: [
656
+ {
657
+ id: component.id,
658
+ treatment: "included",
659
+ limitation: {
660
+ kind: "layer",
661
+ attachment: expression.attachment,
662
+ limit: expression.limit,
663
+ application: "claim",
664
+ derivation: { kind: "sdk" },
665
+ },
666
+ },
667
+ ],
668
+ },
669
+ };
670
+ }
671
+ const children =
672
+ expression.op === "add"
673
+ ? expression.terms
674
+ : [expression.left, expression.right];
675
+ const projected = children.map((child, index) =>
676
+ projectClaimExpression(
677
+ child,
678
+ expression.op === "add"
679
+ ? `${path}.terms[${index}]`
680
+ : `${path}.${index === 0 ? "left" : "right"}`,
681
+ measures,
682
+ amountBases,
683
+ issues,
684
+ ),
685
+ );
686
+ if (projected.some((item) => item === null)) return null;
687
+ const present = projected as ProjectedClaimSemantics[];
688
+ if (expression.op === "subtract") {
689
+ const [left, right] = present;
690
+ if (
691
+ left!.kind !== right!.kind ||
692
+ left!.unit !== right!.unit ||
693
+ (left!.kind === "amount"
694
+ ? projectedBasisKey(left!.amountBasis!) !==
695
+ projectedBasisKey(right!.amountBasis!)
696
+ : left!.reference !== right!.reference)
697
+ ) {
698
+ pushIssue(
699
+ issues,
700
+ "incompatible-semantics",
701
+ path,
702
+ "Claim subtraction requires one exact semantic basis",
703
+ );
704
+ return null;
705
+ }
706
+ return left!;
707
+ }
708
+ const first = present[0]!;
709
+ if (
710
+ present.some((item) => item.kind !== first.kind || item.unit !== first.unit)
711
+ ) {
712
+ pushIssue(
713
+ issues,
714
+ "incompatible-semantics",
715
+ path,
716
+ "Claim addition requires one kind and unit",
717
+ );
718
+ return null;
719
+ }
720
+ if (first.kind !== "amount") {
721
+ if (present.some((item) => item.reference !== first.reference)) {
722
+ pushIssue(
723
+ issues,
724
+ "incompatible-semantics",
725
+ path,
726
+ "Claim addition requires one exact semantic basis",
727
+ );
728
+ return null;
729
+ }
730
+ return first;
731
+ }
732
+ const bases = present.map((item) => item.amountBasis!);
733
+ if (
734
+ bases.some(
735
+ (basis) =>
736
+ basis.currency !== bases[0]!.currency ||
737
+ basis.perspective !== bases[0]!.perspective,
738
+ )
739
+ ) {
740
+ pushIssue(
741
+ issues,
742
+ "incompatible-semantics",
743
+ path,
744
+ "Amount addition cannot mix currency or perspective",
745
+ );
746
+ return null;
747
+ }
748
+ const componentIds = new Set<string>();
749
+ const components: AmountBasisComponent[] = [];
750
+ for (const basis of bases) {
751
+ for (const component of basis.components) {
752
+ if (componentIds.has(component.id)) {
753
+ pushIssue(
754
+ issues,
755
+ "incompatible-semantics",
756
+ path,
757
+ `Amount addition overlaps component ${component.id}`,
758
+ );
759
+ return null;
760
+ }
761
+ componentIds.add(component.id);
762
+ components.push(component);
763
+ }
764
+ }
765
+ const amountBasis = {
766
+ currency: bases[0]!.currency,
767
+ perspective: bases[0]!.perspective,
768
+ components: components.sort((left, right) =>
769
+ left.id < right.id ? -1 : left.id > right.id ? 1 : 0,
770
+ ),
771
+ };
772
+ return {
773
+ kind: "amount",
774
+ unit: first.unit,
775
+ reference: "amount:projected",
776
+ amountBasis,
777
+ };
778
+ }
779
+
780
+ function expressionSemantics(
781
+ expression: DiagnosticMeasureExpression,
782
+ path: string,
783
+ measures: ReadonlyMap<string, DiagnosticMeasureDefinition>,
784
+ issues: DiagnosticValidationIssue[],
785
+ initialDepth = 1,
786
+ ): {
787
+ readonly measureIds: readonly string[];
788
+ readonly signature: string | null;
789
+ } {
790
+ const walked = walkDiagnosticExpression(
791
+ expression,
792
+ "measure",
793
+ path,
794
+ issues,
795
+ initialDepth,
796
+ );
797
+ const found = walked.dependencies
798
+ .map((id) => measures.get(id))
799
+ .filter(
800
+ (value): value is DiagnosticMeasureDefinition => value !== undefined,
801
+ );
802
+ for (const id of walked.dependencies) {
803
+ if (!measures.has(id)) {
804
+ pushIssue(issues, "unknown-reference", path, `Unknown measure ${id}`);
805
+ }
806
+ }
807
+ const signatures = new Set(
808
+ found.map((measure) =>
809
+ canonicalJson([measure.kind, measure.unit, semanticReference(measure)]),
810
+ ),
811
+ );
812
+ if (signatures.size > 1) {
813
+ pushIssue(
814
+ issues,
815
+ "incompatible-semantics",
816
+ path,
817
+ "Expression combines incompatible measure semantics",
818
+ );
819
+ }
820
+ return {
821
+ measureIds: walked.dependencies,
822
+ signature: signatures.size === 1 ? [...signatures][0]! : null,
823
+ };
824
+ }
825
+
826
+ function validateTolerance(
827
+ value: { absolute?: number; relative?: number } | undefined,
828
+ path: string,
829
+ issues: DiagnosticValidationIssue[],
830
+ ): void {
831
+ for (const key of ["absolute", "relative"] as const) {
832
+ const component = value?.[key];
833
+ if (component === undefined) continue;
834
+ if (
835
+ !validateFinite(component, `${path}.${key}`, `${key} tolerance`, issues)
836
+ )
837
+ continue;
838
+ if (component < 0) {
839
+ pushIssue(
840
+ issues,
841
+ "invalid-number",
842
+ `${path}.${key}`,
843
+ `${key} tolerance must be nonnegative`,
844
+ );
845
+ }
846
+ }
847
+ }
848
+
849
+ function validateReviewFilter(
850
+ value: unknown,
851
+ path: string,
852
+ axis: DiagnosticPeriodAxis,
853
+ issues: DiagnosticValidationIssue[],
854
+ ): void {
855
+ if (!isPlainRecord(value)) {
856
+ pushIssue(issues, "invalid-type", path, "Review filter must be an object");
857
+ return;
858
+ }
859
+ const periodCoordinates = new Map<string, number>();
860
+ const validatePeriods = (
861
+ key: "origins" | "valuations",
862
+ side: "origin" | "valuation",
863
+ ) => {
864
+ const raw = value[key];
865
+ if (raw === undefined) return;
866
+ const entries = requireArray(raw, `${path}.${key}`, issues);
867
+ entries.forEach((entry, index) => {
868
+ const entryPath = `${path}.${key}[${index}]`;
869
+ if (!validateToken(entry, entryPath, `${side} period`, issues)) return;
870
+ const normalized = normalizeDiagnosticPeriodWithAxis(axis, side, entry);
871
+ if (!normalized)
872
+ pushIssue(
873
+ issues,
874
+ "invalid-period",
875
+ entryPath,
876
+ `Unknown ${side} period ${JSON.stringify(entry)}`,
877
+ );
878
+ });
879
+ };
880
+ const validateEndpoint = (
881
+ key: "originFrom" | "originThrough" | "valuationFrom" | "valuationThrough",
882
+ side: "origin" | "valuation",
883
+ ) => {
884
+ const raw = value[key];
885
+ if (raw === undefined) return;
886
+ if (!validateToken(raw, `${path}.${key}`, `${side} period`, issues)) return;
887
+ const normalized = normalizeDiagnosticPeriodWithAxis(axis, side, raw);
888
+ if (!normalized)
889
+ pushIssue(
890
+ issues,
891
+ "invalid-period",
892
+ `${path}.${key}`,
893
+ `Unknown ${side} period ${JSON.stringify(raw)}`,
894
+ );
895
+ else periodCoordinates.set(key, normalized.coordinate);
896
+ };
897
+ if (value.sourceGroups !== undefined)
898
+ requireArray(value.sourceGroups, `${path}.sourceGroups`, issues).forEach(
899
+ (item, index) => {
900
+ validateToken(
901
+ item,
902
+ `${path}.sourceGroups[${index}]`,
903
+ "Source group",
904
+ issues,
905
+ );
906
+ },
907
+ );
908
+ validatePeriods("origins", "origin");
909
+ validateEndpoint("originFrom", "origin");
910
+ validateEndpoint("originThrough", "origin");
911
+ validatePeriods("valuations", "valuation");
912
+ validateEndpoint("valuationFrom", "valuation");
913
+ validateEndpoint("valuationThrough", "valuation");
914
+ for (const key of ["minDevelopmentAge", "maxDevelopmentAge"] as const) {
915
+ const raw = value[key];
916
+ if (raw === undefined) continue;
917
+ if (
918
+ !validateFinite(raw, `${path}.${key}`, key, issues) ||
919
+ !Number.isSafeInteger(raw) ||
920
+ raw < 0
921
+ ) {
922
+ pushIssue(
923
+ issues,
924
+ "invalid-number",
925
+ `${path}.${key}`,
926
+ `${key} must be a nonnegative safe integer`,
927
+ );
928
+ }
929
+ }
930
+ if (
931
+ (periodCoordinates.get("originFrom") ?? -Infinity) >
932
+ (periodCoordinates.get("originThrough") ?? Infinity)
933
+ ) {
934
+ pushIssue(
935
+ issues,
936
+ "invalid-input-relationship",
937
+ path,
938
+ "originFrom must not follow originThrough",
939
+ );
940
+ }
941
+ if (
942
+ (periodCoordinates.get("valuationFrom") ?? -Infinity) >
943
+ (periodCoordinates.get("valuationThrough") ?? Infinity)
944
+ ) {
945
+ pushIssue(
946
+ issues,
947
+ "invalid-input-relationship",
948
+ path,
949
+ "valuationFrom must not follow valuationThrough",
950
+ );
951
+ }
952
+ if (
953
+ typeof value.minDevelopmentAge === "number" &&
954
+ typeof value.maxDevelopmentAge === "number" &&
955
+ value.minDevelopmentAge > value.maxDevelopmentAge
956
+ ) {
957
+ pushIssue(
958
+ issues,
959
+ "invalid-input-relationship",
960
+ path,
961
+ "minDevelopmentAge must not exceed maxDevelopmentAge",
962
+ );
963
+ }
964
+ }
965
+
966
+ function measureExpressionBasis(
967
+ expression: DiagnosticMeasureExpression,
968
+ measures: ReadonlyMap<string, DiagnosticMeasureDefinition>,
969
+ amountBases: ReadonlyMap<string, AmountBasisDefinition>,
970
+ ): AmountBasisDefinition | null {
971
+ const ids = walkDiagnosticExpression(
972
+ expression,
973
+ "measure",
974
+ "$",
975
+ [],
976
+ ).dependencies;
977
+ const found = ids.map((id) => measures.get(id));
978
+ if (
979
+ found.length === 0 ||
980
+ found.some((measure) => measure?.kind !== "amount" || !measure.basisId)
981
+ )
982
+ return null;
983
+ const basisIds = new Set(found.map((measure) => measure!.basisId!));
984
+ return basisIds.size === 1
985
+ ? (amountBases.get([...basisIds][0]!) ?? null)
986
+ : null;
987
+ }
988
+
989
+ interface LayerComponentTrace {
990
+ readonly rawMeasureId: string;
991
+ readonly hasLayer: boolean;
992
+ }
993
+
994
+ function traceLayerComponents(
995
+ measureId: string,
996
+ measures: ReadonlyMap<string, DiagnosticMeasureDefinition>,
997
+ amountBases: ReadonlyMap<string, AmountBasisDefinition>,
998
+ derivations: ReadonlyMap<string, DiagnosticDerivedMeasureDefinition>,
999
+ seen = new Set<string>(),
1000
+ ): ReadonlyMap<string, LayerComponentTrace> | null {
1001
+ if (seen.has(measureId)) return null;
1002
+ const derivation = derivations.get(measureId);
1003
+ if (!derivation) {
1004
+ const measure = measures.get(measureId);
1005
+ const basis =
1006
+ measure?.kind === "amount" && measure.basisId
1007
+ ? amountBases.get(measure.basisId)
1008
+ : undefined;
1009
+ return basis
1010
+ ? new Map(
1011
+ basis.components.map((component) => [
1012
+ component.id,
1013
+ { rawMeasureId: measureId, hasLayer: false },
1014
+ ]),
1015
+ )
1016
+ : null;
1017
+ }
1018
+ const nextSeen = new Set(seen).add(measureId);
1019
+ const visit = (
1020
+ expression: DiagnosticClaimExpression,
1021
+ ): ReadonlyMap<string, LayerComponentTrace> | null => {
1022
+ if (expression.op === "measure")
1023
+ return traceLayerComponents(
1024
+ expression.measureId,
1025
+ measures,
1026
+ amountBases,
1027
+ derivations,
1028
+ nextSeen,
1029
+ );
1030
+ if (expression.op === "claim-layer") {
1031
+ const nested = traceLayerComponents(
1032
+ expression.measureId,
1033
+ measures,
1034
+ amountBases,
1035
+ derivations,
1036
+ nextSeen,
1037
+ );
1038
+ if (!nested || nested.size !== 1) return null;
1039
+ return new Map(
1040
+ [...nested].map(([componentId, trace]) => [
1041
+ componentId,
1042
+ { ...trace, hasLayer: true },
1043
+ ]),
1044
+ );
1045
+ }
1046
+ if (expression.op === "subtract") return null;
1047
+ const result = new Map<string, LayerComponentTrace>();
1048
+ for (const term of expression.terms) {
1049
+ const traced = visit(term);
1050
+ if (!traced) return null;
1051
+ for (const [componentId, trace] of traced) {
1052
+ if (result.has(componentId)) return null;
1053
+ result.set(componentId, trace);
1054
+ }
1055
+ }
1056
+ return result;
1057
+ };
1058
+ return visit(derivation.expression);
1059
+ }
1060
+
1061
+ function limitationInterval(limitation: AmountLimitation): {
1062
+ readonly attachment: number;
1063
+ readonly exhaustion: number;
1064
+ readonly application: "claim" | "occurrence" | "policy" | null;
1065
+ } | null {
1066
+ if (limitation.kind === "unlimited")
1067
+ return { attachment: 0, exhaustion: Infinity, application: null };
1068
+ if (
1069
+ limitation.kind !== "layer" ||
1070
+ limitation.derivation.kind !== "sdk" ||
1071
+ limitation.application === "source-defined"
1072
+ )
1073
+ return null;
1074
+ return {
1075
+ attachment: limitation.attachment,
1076
+ exhaustion:
1077
+ limitation.limit === null
1078
+ ? Infinity
1079
+ : limitation.attachment + limitation.limit,
1080
+ application: limitation.application,
1081
+ };
1082
+ }
1083
+
1084
+ function validateLayerComparability(
1085
+ rule: Extract<DiagnosticReviewRule, { kind: "layer-order" }>,
1086
+ path: string,
1087
+ measures: ReadonlyMap<string, DiagnosticMeasureDefinition>,
1088
+ amountBases: ReadonlyMap<string, AmountBasisDefinition>,
1089
+ derivations: ReadonlyMap<string, DiagnosticDerivedMeasureDefinition>,
1090
+ issues: DiagnosticValidationIssue[],
1091
+ ): void {
1092
+ const narrowerBasis = measureExpressionBasis(
1093
+ rule.narrower,
1094
+ measures,
1095
+ amountBases,
1096
+ );
1097
+ const broaderBasis = measureExpressionBasis(
1098
+ rule.broader,
1099
+ measures,
1100
+ amountBases,
1101
+ );
1102
+ if (
1103
+ !narrowerBasis ||
1104
+ !broaderBasis ||
1105
+ narrowerBasis.currency !== broaderBasis.currency
1106
+ ) {
1107
+ pushIssue(
1108
+ issues,
1109
+ "incompatible-semantics",
1110
+ path,
1111
+ "Layer-order operands must be amounts with the same currency",
1112
+ );
1113
+ return;
1114
+ }
1115
+ if (rule.comparability.kind === "caller-asserted") return;
1116
+ if (narrowerBasis.perspective !== broaderBasis.perspective) {
1117
+ pushIssue(
1118
+ issues,
1119
+ "incompatible-semantics",
1120
+ path,
1121
+ "Compiler-proven layer order requires the same amount perspective",
1122
+ );
1123
+ return;
1124
+ }
1125
+ const proveMeasures = (
1126
+ narrowerMeasureId: string,
1127
+ broaderMeasureId: string,
1128
+ ): { readonly valid: boolean; readonly hasLayer: boolean } => {
1129
+ const narrowerMeasure = measures.get(narrowerMeasureId);
1130
+ const broaderMeasure = measures.get(broaderMeasureId);
1131
+ const narrowBasis =
1132
+ narrowerMeasure?.kind === "amount" && narrowerMeasure.basisId
1133
+ ? amountBases.get(narrowerMeasure.basisId)
1134
+ : undefined;
1135
+ const broadBasis =
1136
+ broaderMeasure?.kind === "amount" && broaderMeasure.basisId
1137
+ ? amountBases.get(broaderMeasure.basisId)
1138
+ : undefined;
1139
+ if (
1140
+ !narrowBasis ||
1141
+ !broadBasis ||
1142
+ narrowBasis.currency !== broadBasis.currency ||
1143
+ narrowBasis.perspective !== broadBasis.perspective
1144
+ )
1145
+ return { valid: false, hasLayer: false };
1146
+ const narrowComponents = [...narrowBasis.components].sort((a, b) =>
1147
+ a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
1148
+ );
1149
+ const broadComponents = [...broadBasis.components].sort((a, b) =>
1150
+ a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
1151
+ );
1152
+ const narrowTraces = traceLayerComponents(
1153
+ narrowerMeasureId,
1154
+ measures,
1155
+ amountBases,
1156
+ derivations,
1157
+ );
1158
+ const broadTraces = traceLayerComponents(
1159
+ broaderMeasureId,
1160
+ measures,
1161
+ amountBases,
1162
+ derivations,
1163
+ );
1164
+ if (
1165
+ !narrowTraces ||
1166
+ !broadTraces ||
1167
+ narrowComponents.length !== broadComponents.length
1168
+ )
1169
+ return { valid: false, hasLayer: false };
1170
+ let hasLayer = false;
1171
+ for (let index = 0; index < narrowComponents.length; index++) {
1172
+ const narrower = narrowComponents[index]!;
1173
+ const broader = broadComponents[index]!;
1174
+ const narrowerInterval = limitationInterval(narrower.limitation);
1175
+ const broaderInterval = limitationInterval(broader.limitation);
1176
+ const narrowerTrace = narrowTraces.get(narrower.id);
1177
+ const broaderTrace = broadTraces.get(broader.id);
1178
+ if (
1179
+ narrower.id !== broader.id ||
1180
+ narrower.treatment !== broader.treatment ||
1181
+ narrower.treatment === "unknown" ||
1182
+ !narrowerInterval ||
1183
+ !broaderInterval ||
1184
+ !narrowerTrace ||
1185
+ !broaderTrace ||
1186
+ narrowerTrace.rawMeasureId !== broaderTrace.rawMeasureId ||
1187
+ (broaderInterval.application !== null &&
1188
+ narrowerInterval.application !== broaderInterval.application) ||
1189
+ narrowerInterval.attachment < broaderInterval.attachment ||
1190
+ narrowerInterval.exhaustion > broaderInterval.exhaustion
1191
+ )
1192
+ return { valid: false, hasLayer: false };
1193
+ hasLayer ||= narrowerTrace.hasLayer || broaderTrace.hasLayer;
1194
+ }
1195
+ return { valid: true, hasLayer };
1196
+ };
1197
+ const proveExpressions = (
1198
+ narrower: DiagnosticMeasureExpression,
1199
+ broader: DiagnosticMeasureExpression,
1200
+ ): { readonly valid: boolean; readonly hasLayer: boolean } => {
1201
+ if (narrower.op === "measure" && broader.op === "measure")
1202
+ return proveMeasures(narrower.measureId, broader.measureId);
1203
+ if (
1204
+ narrower.op !== "add" ||
1205
+ broader.op !== "add" ||
1206
+ narrower.terms.length !== broader.terms.length
1207
+ )
1208
+ return { valid: false, hasLayer: false };
1209
+ const children = narrower.terms.map((term, index) =>
1210
+ proveExpressions(term, broader.terms[index]!),
1211
+ );
1212
+ return {
1213
+ valid: children.every((item) => item.valid),
1214
+ hasLayer: children.some((item) => item.hasLayer),
1215
+ };
1216
+ };
1217
+ const proof = proveExpressions(rule.narrower, rule.broader);
1218
+ if (!proof.valid || !proof.hasLayer) {
1219
+ pushIssue(
1220
+ issues,
1221
+ "incompatible-semantics",
1222
+ path,
1223
+ "Compiler metadata does not prove that the narrower layer is contained by the broader layer",
1224
+ );
1225
+ }
1226
+ }
1227
+
1228
+ function validatePeriodAxis(
1229
+ axis: DiagnosticPeriodAxis,
1230
+ issues: DiagnosticValidationIssue[],
1231
+ ): void {
1232
+ if (!isPlainRecord(axis)) {
1233
+ pushIssue(
1234
+ issues,
1235
+ "invalid-type",
1236
+ "$.periodAxis",
1237
+ "Period axis must be an object",
1238
+ );
1239
+ return;
1240
+ }
1241
+ if (
1242
+ !validateFinite(
1243
+ axis.ageOffset,
1244
+ "$.periodAxis.ageOffset",
1245
+ "Period age offset",
1246
+ issues,
1247
+ ) ||
1248
+ !Number.isSafeInteger(axis.ageOffset)
1249
+ ) {
1250
+ pushIssue(
1251
+ issues,
1252
+ "invalid-number",
1253
+ "$.periodAxis.ageOffset",
1254
+ "Period age offset must be a safe integer",
1255
+ );
1256
+ }
1257
+ if (axis.kind === "calendar") {
1258
+ validateAllowedKeys(
1259
+ axis,
1260
+ [
1261
+ "kind",
1262
+ "originCadence",
1263
+ "valuationCadence",
1264
+ "originAnchor",
1265
+ "valuationAnchor",
1266
+ "ageUnit",
1267
+ "ageOffset",
1268
+ ],
1269
+ "$.periodAxis",
1270
+ issues,
1271
+ );
1272
+ if (!["month", "quarter", "year"].includes(axis.originCadence)) {
1273
+ pushIssue(
1274
+ issues,
1275
+ "invalid-period",
1276
+ "$.periodAxis.originCadence",
1277
+ "Unknown origin cadence",
1278
+ );
1279
+ }
1280
+ if (!["month", "quarter", "year"].includes(axis.valuationCadence)) {
1281
+ pushIssue(
1282
+ issues,
1283
+ "invalid-period",
1284
+ "$.periodAxis.valuationCadence",
1285
+ "Unknown valuation cadence",
1286
+ );
1287
+ }
1288
+ if (axis.originAnchor !== "start" && axis.originAnchor !== "end") {
1289
+ pushIssue(
1290
+ issues,
1291
+ "invalid-period",
1292
+ "$.periodAxis.originAnchor",
1293
+ "Unknown origin anchor",
1294
+ );
1295
+ }
1296
+ if (axis.valuationAnchor !== "start" && axis.valuationAnchor !== "end") {
1297
+ pushIssue(
1298
+ issues,
1299
+ "invalid-period",
1300
+ "$.periodAxis.valuationAnchor",
1301
+ "Unknown valuation anchor",
1302
+ );
1303
+ }
1304
+ if (axis.ageUnit !== "month") {
1305
+ pushIssue(
1306
+ issues,
1307
+ "invalid-period",
1308
+ "$.periodAxis.ageUnit",
1309
+ "Calendar axes use month age units",
1310
+ );
1311
+ }
1312
+ return;
1313
+ }
1314
+ validateAllowedKeys(
1315
+ axis,
1316
+ ["kind", "id", "version", "ageUnit", "ageOffset", "origins", "valuations"],
1317
+ "$.periodAxis",
1318
+ issues,
1319
+ );
1320
+ if (axis.kind !== "ordered") {
1321
+ pushIssue(
1322
+ issues,
1323
+ "invalid-period",
1324
+ "$.periodAxis.kind",
1325
+ "Unknown period-axis kind",
1326
+ );
1327
+ return;
1328
+ }
1329
+ validateToken(axis.id, "$.periodAxis.id", "Ordered-axis ID", issues);
1330
+ validateToken(
1331
+ axis.version,
1332
+ "$.periodAxis.version",
1333
+ "Ordered-axis version",
1334
+ issues,
1335
+ );
1336
+ validateToken(
1337
+ axis.ageUnit,
1338
+ "$.periodAxis.ageUnit",
1339
+ "Ordered-axis age unit",
1340
+ issues,
1341
+ );
1342
+ for (const side of ["origins", "valuations"] as const) {
1343
+ const coordinates = requireArray(
1344
+ axis[side],
1345
+ `$.periodAxis.${side}`,
1346
+ issues,
1347
+ ) as readonly DiagnosticPeriodCoordinate[];
1348
+ const names = new Set<string>();
1349
+ const coordinateValues = new Set<number>();
1350
+ coordinates.forEach((coordinate, index) => {
1351
+ const base = `$.periodAxis.${side}[${index}]`;
1352
+ if (!isPlainRecord(coordinate)) {
1353
+ pushIssue(
1354
+ issues,
1355
+ "invalid-type",
1356
+ base,
1357
+ "Period coordinate must be an object",
1358
+ );
1359
+ return;
1360
+ }
1361
+ validateAllowedKeys(
1362
+ coordinate,
1363
+ ["label", "aliases", "coordinate"],
1364
+ base,
1365
+ issues,
1366
+ );
1367
+ if (
1368
+ validateToken(
1369
+ coordinate?.label,
1370
+ `${base}.label`,
1371
+ "Period label",
1372
+ issues,
1373
+ )
1374
+ ) {
1375
+ if (names.has(coordinate.label))
1376
+ pushIssue(
1377
+ issues,
1378
+ "duplicate-id",
1379
+ `${base}.label`,
1380
+ `Duplicate period label or alias ${coordinate.label}`,
1381
+ );
1382
+ names.add(coordinate.label);
1383
+ }
1384
+ if (
1385
+ !validateFinite(
1386
+ coordinate?.coordinate,
1387
+ `${base}.coordinate`,
1388
+ "Period coordinate",
1389
+ issues,
1390
+ ) ||
1391
+ !Number.isSafeInteger(coordinate.coordinate)
1392
+ ) {
1393
+ pushIssue(
1394
+ issues,
1395
+ "invalid-number",
1396
+ `${base}.coordinate`,
1397
+ "Period coordinate must be a safe integer",
1398
+ );
1399
+ } else if (coordinateValues.has(coordinate.coordinate)) {
1400
+ pushIssue(
1401
+ issues,
1402
+ "duplicate-id",
1403
+ `${base}.coordinate`,
1404
+ `Duplicate period coordinate ${coordinate.coordinate}`,
1405
+ );
1406
+ } else coordinateValues.add(coordinate.coordinate);
1407
+ const aliases =
1408
+ coordinate.aliases === undefined
1409
+ ? []
1410
+ : requireArray(coordinate.aliases, `${base}.aliases`, issues);
1411
+ for (const [aliasIndex, alias] of aliases.entries()) {
1412
+ if (
1413
+ !validateToken(
1414
+ alias,
1415
+ `${base}.aliases[${aliasIndex}]`,
1416
+ "Period alias",
1417
+ issues,
1418
+ )
1419
+ )
1420
+ continue;
1421
+ if (names.has(alias))
1422
+ pushIssue(
1423
+ issues,
1424
+ "duplicate-id",
1425
+ `${base}.aliases[${aliasIndex}]`,
1426
+ `Duplicate period label or alias ${alias}`,
1427
+ );
1428
+ names.add(alias);
1429
+ }
1430
+ });
1431
+ }
1432
+ }
1433
+
1434
+ function validateDefinition(value: unknown): DiagnosticDefinition {
1435
+ const issues: DiagnosticValidationIssue[] = [];
1436
+ validateJsonValue(value, issues);
1437
+ if (!isPlainRecord(value)) {
1438
+ pushIssue(
1439
+ issues,
1440
+ "invalid-type",
1441
+ "$",
1442
+ "Diagnostic definition must be a plain object",
1443
+ );
1444
+ throw new DiagnosticValidationError(issues);
1445
+ }
1446
+ const definition = value as unknown as DiagnosticDefinition;
1447
+ validateAllowedKeys(
1448
+ value,
1449
+ [
1450
+ "diagnosticDefinitionVersion",
1451
+ "id",
1452
+ "version",
1453
+ "lossRowGrain",
1454
+ "measures",
1455
+ "countPopulations",
1456
+ "exposureBases",
1457
+ "amountBases",
1458
+ "derivedMeasures",
1459
+ "formulas",
1460
+ "instances",
1461
+ "reviewRules",
1462
+ "periodAxis",
1463
+ ],
1464
+ "$",
1465
+ issues,
1466
+ );
1467
+ if (definition.diagnosticDefinitionVersion !== "1.0.0") {
1468
+ pushIssue(
1469
+ issues,
1470
+ "invalid-type",
1471
+ "$.diagnosticDefinitionVersion",
1472
+ "Diagnostic definition version must be 1.0.0",
1473
+ );
1474
+ }
1475
+ validateToken(definition.id, "$.id", "Definition ID", issues);
1476
+ validateToken(definition.version, "$.version", "Definition version", issues);
1477
+ if (
1478
+ definition.lossRowGrain !== "claim" &&
1479
+ definition.lossRowGrain !== "aggregate"
1480
+ ) {
1481
+ pushIssue(
1482
+ issues,
1483
+ "invalid-type",
1484
+ "$.lossRowGrain",
1485
+ "Loss row grain must be claim or aggregate",
1486
+ );
1487
+ }
1488
+
1489
+ const measures = requireArray(
1490
+ definition.measures,
1491
+ "$.measures",
1492
+ issues,
1493
+ ) as readonly DiagnosticMeasureDefinition[];
1494
+ const populations = requireArray(
1495
+ definition.countPopulations,
1496
+ "$.countPopulations",
1497
+ issues,
1498
+ ) as readonly DiagnosticCountPopulationDefinition[];
1499
+ const exposureBases = requireArray(
1500
+ definition.exposureBases,
1501
+ "$.exposureBases",
1502
+ issues,
1503
+ ) as readonly DiagnosticExposureBasisDefinition[];
1504
+ const amountBases = requireArray(
1505
+ definition.amountBases,
1506
+ "$.amountBases",
1507
+ issues,
1508
+ ) as readonly AmountBasisDefinition[];
1509
+ const derivations = requireArray(
1510
+ definition.derivedMeasures,
1511
+ "$.derivedMeasures",
1512
+ issues,
1513
+ ) as readonly DiagnosticDerivedMeasureDefinition[];
1514
+ const formulas = requireArray(
1515
+ definition.formulas,
1516
+ "$.formulas",
1517
+ issues,
1518
+ ) as readonly DiagnosticFormulaTemplate[];
1519
+ const instances = requireArray(
1520
+ definition.instances,
1521
+ "$.instances",
1522
+ issues,
1523
+ ) as readonly DiagnosticMetricInstance[];
1524
+ const reviewRules = requireArray(
1525
+ definition.reviewRules,
1526
+ "$.reviewRules",
1527
+ issues,
1528
+ ) as readonly DiagnosticReviewRule[];
1529
+
1530
+ const measureMap = collectCatalog(measures, "$.measures", issues);
1531
+ const populationMap = collectCatalog(
1532
+ populations,
1533
+ "$.countPopulations",
1534
+ issues,
1535
+ );
1536
+ const exposureBasisMap = collectCatalog(
1537
+ exposureBases,
1538
+ "$.exposureBases",
1539
+ issues,
1540
+ );
1541
+ const amountBasisMap = collectCatalog(amountBases, "$.amountBases", issues);
1542
+ collectCatalog(derivations, "$.derivedMeasures", issues);
1543
+ const derivationsByOutput = new Map<
1544
+ string,
1545
+ DiagnosticDerivedMeasureDefinition
1546
+ >();
1547
+ for (const derivation of derivations) {
1548
+ if (
1549
+ isPlainRecord(derivation) &&
1550
+ typeof derivation.outputMeasureId === "string" &&
1551
+ !derivationsByOutput.has(derivation.outputMeasureId)
1552
+ ) {
1553
+ derivationsByOutput.set(derivation.outputMeasureId, derivation);
1554
+ }
1555
+ }
1556
+ const formulaMap = collectCatalog(formulas, "$.formulas", issues);
1557
+ collectCatalog(instances, "$.instances", issues);
1558
+ const reviewRuleMap = collectCatalog(reviewRules, "$.reviewRules", issues);
1559
+
1560
+ populations.forEach((population, index) => {
1561
+ const base = `$.countPopulations[${index}]`;
1562
+ if (!isPlainRecord(population)) {
1563
+ pushIssue(
1564
+ issues,
1565
+ "invalid-type",
1566
+ base,
1567
+ "Count population must be an object",
1568
+ );
1569
+ return;
1570
+ }
1571
+ validateAllowedKeys(
1572
+ population,
1573
+ ["id", "displayName", "subject", "unit", "description", "attributes"],
1574
+ base,
1575
+ issues,
1576
+ );
1577
+ validateToken(
1578
+ population.displayName,
1579
+ `${base}.displayName`,
1580
+ "Population display name",
1581
+ issues,
1582
+ );
1583
+ validateToken(
1584
+ population.description,
1585
+ `${base}.description`,
1586
+ "Population description",
1587
+ issues,
1588
+ );
1589
+ validateToken(population.unit, `${base}.unit`, "Population unit", issues);
1590
+ if (
1591
+ ![
1592
+ "claim",
1593
+ "claimant",
1594
+ "policy",
1595
+ "occurrence",
1596
+ "other",
1597
+ "unknown",
1598
+ ].includes(population.subject)
1599
+ ) {
1600
+ pushIssue(
1601
+ issues,
1602
+ "invalid-type",
1603
+ `${base}.subject`,
1604
+ "Unknown count-population subject",
1605
+ );
1606
+ }
1607
+ validateScalarAttributes(
1608
+ population.attributes,
1609
+ `${base}.attributes`,
1610
+ issues,
1611
+ );
1612
+ });
1613
+
1614
+ exposureBases.forEach((basis, index) => {
1615
+ const base = `$.exposureBases[${index}]`;
1616
+ if (!isPlainRecord(basis)) {
1617
+ pushIssue(
1618
+ issues,
1619
+ "invalid-type",
1620
+ base,
1621
+ "Exposure basis must be an object",
1622
+ );
1623
+ return;
1624
+ }
1625
+ validateAllowedKeys(
1626
+ basis,
1627
+ [
1628
+ "id",
1629
+ "displayName",
1630
+ "basis",
1631
+ "unit",
1632
+ "description",
1633
+ "sourceDescription",
1634
+ "attributes",
1635
+ ],
1636
+ base,
1637
+ issues,
1638
+ );
1639
+ validateToken(
1640
+ basis.displayName,
1641
+ `${base}.displayName`,
1642
+ "Exposure-basis display name",
1643
+ issues,
1644
+ );
1645
+ validateToken(
1646
+ basis.description,
1647
+ `${base}.description`,
1648
+ "Exposure-basis description",
1649
+ issues,
1650
+ );
1651
+ validateToken(basis.unit, `${base}.unit`, "Exposure-basis unit", issues);
1652
+ if (basis.sourceDescription !== undefined)
1653
+ validateToken(
1654
+ basis.sourceDescription,
1655
+ `${base}.sourceDescription`,
1656
+ "Exposure source description",
1657
+ issues,
1658
+ );
1659
+ if (
1660
+ !["earned", "written", "in-force", "other", "unknown"].includes(
1661
+ basis.basis,
1662
+ )
1663
+ ) {
1664
+ pushIssue(
1665
+ issues,
1666
+ "invalid-type",
1667
+ `${base}.basis`,
1668
+ "Unknown exposure basis",
1669
+ );
1670
+ }
1671
+ validateScalarAttributes(basis.attributes, `${base}.attributes`, issues);
1672
+ });
1673
+
1674
+ amountBases.forEach((basis, index) => {
1675
+ const base = `$.amountBases[${index}]`;
1676
+ if (!isPlainRecord(basis)) {
1677
+ pushIssue(issues, "invalid-type", base, "Amount basis must be an object");
1678
+ return;
1679
+ }
1680
+ validateAllowedKeys(
1681
+ basis,
1682
+ [
1683
+ "id",
1684
+ "displayName",
1685
+ "currency",
1686
+ "perspective",
1687
+ "components",
1688
+ "sourceDescription",
1689
+ "attributes",
1690
+ ],
1691
+ base,
1692
+ issues,
1693
+ );
1694
+ validateToken(
1695
+ basis.displayName,
1696
+ `${base}.displayName`,
1697
+ "Amount-basis display name",
1698
+ issues,
1699
+ );
1700
+ validateToken(
1701
+ basis.currency,
1702
+ `${base}.currency`,
1703
+ "Amount-basis currency",
1704
+ issues,
1705
+ );
1706
+ if (basis.sourceDescription !== undefined)
1707
+ validateToken(
1708
+ basis.sourceDescription,
1709
+ `${base}.sourceDescription`,
1710
+ "Amount source description",
1711
+ issues,
1712
+ );
1713
+ if (
1714
+ !["gross", "net", "ceded", "other", "unknown"].includes(basis.perspective)
1715
+ ) {
1716
+ pushIssue(
1717
+ issues,
1718
+ "invalid-type",
1719
+ `${base}.perspective`,
1720
+ "Unknown amount perspective",
1721
+ );
1722
+ }
1723
+ validateScalarAttributes(basis.attributes, `${base}.attributes`, issues);
1724
+ const components = requireArray(
1725
+ basis.components,
1726
+ `${base}.components`,
1727
+ issues,
1728
+ ) as readonly AmountBasisComponent[];
1729
+ if (components.length === 0)
1730
+ pushIssue(
1731
+ issues,
1732
+ "missing-required",
1733
+ `${base}.components`,
1734
+ "Amount basis requires at least one component",
1735
+ );
1736
+ const componentIds = collectCatalog(
1737
+ components,
1738
+ `${base}.components`,
1739
+ issues,
1740
+ );
1741
+ if (
1742
+ componentIds.size > 0 &&
1743
+ components.every((component) => component.treatment === "excluded")
1744
+ ) {
1745
+ pushIssue(
1746
+ issues,
1747
+ "incompatible-semantics",
1748
+ `${base}.components`,
1749
+ "Amount basis cannot exclude every component",
1750
+ );
1751
+ }
1752
+ components.forEach((component, componentIndex) => {
1753
+ const componentBase = `${base}.components[${componentIndex}]`;
1754
+ if (!isPlainRecord(component)) {
1755
+ pushIssue(
1756
+ issues,
1757
+ "invalid-type",
1758
+ componentBase,
1759
+ "Amount-basis component must be an object",
1760
+ );
1761
+ return;
1762
+ }
1763
+ validateAllowedKeys(
1764
+ component,
1765
+ ["id", "treatment", "limitation"],
1766
+ componentBase,
1767
+ issues,
1768
+ );
1769
+ if (!["included", "excluded", "unknown"].includes(component.treatment)) {
1770
+ pushIssue(
1771
+ issues,
1772
+ "invalid-type",
1773
+ `${componentBase}.treatment`,
1774
+ "Unknown component treatment",
1775
+ );
1776
+ }
1777
+ const limitation = component.limitation;
1778
+ if (!isPlainRecord(limitation)) {
1779
+ pushIssue(
1780
+ issues,
1781
+ "invalid-type",
1782
+ `${componentBase}.limitation`,
1783
+ "Amount limitation must be an object",
1784
+ );
1785
+ return;
1786
+ }
1787
+ if (limitation.kind === "unknown") {
1788
+ validateAllowedKeys(
1789
+ limitation,
1790
+ ["kind", "description"],
1791
+ `${componentBase}.limitation`,
1792
+ issues,
1793
+ );
1794
+ if (limitation.description !== undefined)
1795
+ validateToken(
1796
+ limitation.description,
1797
+ `${componentBase}.limitation.description`,
1798
+ "Unknown limitation description",
1799
+ issues,
1800
+ );
1801
+ } else if (
1802
+ limitation.kind === "layer" ||
1803
+ limitation.kind === "pre-limited"
1804
+ ) {
1805
+ validateAllowedKeys(
1806
+ limitation,
1807
+ ["kind", "attachment", "limit", "application", "derivation"],
1808
+ `${componentBase}.limitation`,
1809
+ issues,
1810
+ );
1811
+ if (
1812
+ !["claim", "occurrence", "policy", "source-defined"].includes(
1813
+ limitation.application,
1814
+ )
1815
+ ) {
1816
+ pushIssue(
1817
+ issues,
1818
+ "invalid-type",
1819
+ `${componentBase}.limitation.application`,
1820
+ "Unknown limitation application",
1821
+ );
1822
+ }
1823
+ if (
1824
+ validateFinite(
1825
+ limitation.attachment,
1826
+ `${componentBase}.limitation.attachment`,
1827
+ "Attachment",
1828
+ issues,
1829
+ ) &&
1830
+ limitation.attachment < 0
1831
+ ) {
1832
+ pushIssue(
1833
+ issues,
1834
+ "invalid-number",
1835
+ `${componentBase}.limitation.attachment`,
1836
+ "Attachment must be nonnegative",
1837
+ );
1838
+ }
1839
+ if (limitation.limit !== null) {
1840
+ if (
1841
+ validateFinite(
1842
+ limitation.limit,
1843
+ `${componentBase}.limitation.limit`,
1844
+ "Layer width",
1845
+ issues,
1846
+ ) &&
1847
+ limitation.limit <= 0
1848
+ ) {
1849
+ pushIssue(
1850
+ issues,
1851
+ "invalid-number",
1852
+ `${componentBase}.limitation.limit`,
1853
+ "Layer width must be positive",
1854
+ );
1855
+ }
1856
+ if (
1857
+ Number.isFinite(limitation.attachment) &&
1858
+ Number.isFinite(limitation.limit) &&
1859
+ !Number.isFinite(limitation.attachment + limitation.limit)
1860
+ ) {
1861
+ pushIssue(
1862
+ issues,
1863
+ "invalid-number",
1864
+ `${componentBase}.limitation.limit`,
1865
+ "Attachment plus layer width must be finite",
1866
+ );
1867
+ }
1868
+ }
1869
+ if (limitation.derivation?.kind === "sdk") {
1870
+ if (isPlainRecord(limitation.derivation))
1871
+ validateAllowedKeys(
1872
+ limitation.derivation,
1873
+ ["kind"],
1874
+ `${componentBase}.limitation.derivation`,
1875
+ issues,
1876
+ );
1877
+ if (
1878
+ limitation.kind !== "layer" ||
1879
+ limitation.application !== "claim"
1880
+ ) {
1881
+ pushIssue(
1882
+ issues,
1883
+ "incompatible-semantics",
1884
+ `${componentBase}.limitation.derivation`,
1885
+ "SDK limitations require a claim layer",
1886
+ );
1887
+ }
1888
+ } else if (limitation.derivation?.kind === "external") {
1889
+ if (isPlainRecord(limitation.derivation))
1890
+ validateAllowedKeys(
1891
+ limitation.derivation,
1892
+ ["kind", "actor", "transformationRef"],
1893
+ `${componentBase}.limitation.derivation`,
1894
+ issues,
1895
+ );
1896
+ if (
1897
+ limitation.derivation.actor !== "caller" &&
1898
+ limitation.derivation.actor !== "source"
1899
+ ) {
1900
+ pushIssue(
1901
+ issues,
1902
+ "invalid-type",
1903
+ `${componentBase}.limitation.derivation.actor`,
1904
+ "External derivation actor must be caller or source",
1905
+ );
1906
+ }
1907
+ validateToken(
1908
+ limitation.derivation.transformationRef,
1909
+ `${componentBase}.limitation.derivation.transformationRef`,
1910
+ "Transformation reference",
1911
+ issues,
1912
+ );
1913
+ } else {
1914
+ pushIssue(
1915
+ issues,
1916
+ "invalid-type",
1917
+ `${componentBase}.limitation.derivation`,
1918
+ "Limited basis requires a derivation record",
1919
+ );
1920
+ }
1921
+ } else if (limitation.kind !== "unlimited") {
1922
+ pushIssue(
1923
+ issues,
1924
+ "invalid-type",
1925
+ `${componentBase}.limitation.kind`,
1926
+ "Unknown amount limitation kind",
1927
+ );
1928
+ } else
1929
+ validateAllowedKeys(
1930
+ limitation,
1931
+ ["kind"],
1932
+ `${componentBase}.limitation`,
1933
+ issues,
1934
+ );
1935
+ });
1936
+ });
1937
+
1938
+ measures.forEach((measure, index) => {
1939
+ const base = `$.measures[${index}]`;
1940
+ if (!isPlainRecord(measure)) {
1941
+ pushIssue(issues, "invalid-type", base, "Measure must be an object");
1942
+ return;
1943
+ }
1944
+ validateAllowedKeys(
1945
+ measure,
1946
+ [
1947
+ "id",
1948
+ "displayName",
1949
+ "description",
1950
+ "source",
1951
+ "kind",
1952
+ "unit",
1953
+ "developmentSemantics",
1954
+ "aggregation",
1955
+ "missing",
1956
+ "basisId",
1957
+ "countPopulationId",
1958
+ "exposureBasisId",
1959
+ "exposureTiming",
1960
+ ],
1961
+ base,
1962
+ issues,
1963
+ );
1964
+ validateToken(
1965
+ measure.displayName,
1966
+ `${base}.displayName`,
1967
+ "Measure display name",
1968
+ issues,
1969
+ );
1970
+ validateToken(
1971
+ measure.description,
1972
+ `${base}.description`,
1973
+ "Measure description",
1974
+ issues,
1975
+ );
1976
+ validateToken(measure.unit, `${base}.unit`, "Measure unit", issues);
1977
+ if (measure.aggregation !== "sum")
1978
+ pushIssue(
1979
+ issues,
1980
+ "incompatible-semantics",
1981
+ `${base}.aggregation`,
1982
+ "Only sum aggregation is supported",
1983
+ );
1984
+ if (measure.missing !== "unknown" && measure.missing !== "zero")
1985
+ pushIssue(
1986
+ issues,
1987
+ "invalid-type",
1988
+ `${base}.missing`,
1989
+ "Unknown missing-value policy",
1990
+ );
1991
+ if (
1992
+ !["cumulative", "incremental", "point-in-time", "unknown"].includes(
1993
+ measure.developmentSemantics,
1994
+ )
1995
+ ) {
1996
+ pushIssue(
1997
+ issues,
1998
+ "invalid-type",
1999
+ `${base}.developmentSemantics`,
2000
+ "Unknown development semantics",
2001
+ );
2002
+ }
2003
+ if (measure.kind === "amount") {
2004
+ const basis = measure.basisId
2005
+ ? amountBasisMap.get(measure.basisId)
2006
+ : undefined;
2007
+ if (!basis)
2008
+ pushIssue(
2009
+ issues,
2010
+ "unknown-reference",
2011
+ `${base}.basisId`,
2012
+ "Amount measure requires an existing amount basis",
2013
+ );
2014
+ else if (measure.unit !== basis.currency)
2015
+ pushIssue(
2016
+ issues,
2017
+ "incompatible-semantics",
2018
+ `${base}.unit`,
2019
+ "Amount measure unit must equal basis currency",
2020
+ );
2021
+ if (
2022
+ measure.countPopulationId !== undefined ||
2023
+ measure.exposureBasisId !== undefined ||
2024
+ measure.exposureTiming !== undefined
2025
+ ) {
2026
+ pushIssue(
2027
+ issues,
2028
+ "incompatible-semantics",
2029
+ base,
2030
+ "Amount measure has inapplicable population or exposure semantics",
2031
+ );
2032
+ }
2033
+ } else if (measure.kind === "count") {
2034
+ const population = measure.countPopulationId
2035
+ ? populationMap.get(measure.countPopulationId)
2036
+ : undefined;
2037
+ if (!population)
2038
+ pushIssue(
2039
+ issues,
2040
+ "unknown-reference",
2041
+ `${base}.countPopulationId`,
2042
+ "Count measure requires an existing count population",
2043
+ );
2044
+ else if (measure.unit !== population.unit)
2045
+ pushIssue(
2046
+ issues,
2047
+ "incompatible-semantics",
2048
+ `${base}.unit`,
2049
+ "Count measure unit must equal population unit",
2050
+ );
2051
+ if (
2052
+ measure.basisId !== undefined ||
2053
+ measure.exposureBasisId !== undefined ||
2054
+ measure.exposureTiming !== undefined
2055
+ ) {
2056
+ pushIssue(
2057
+ issues,
2058
+ "incompatible-semantics",
2059
+ base,
2060
+ "Count measure has inapplicable amount or exposure semantics",
2061
+ );
2062
+ }
2063
+ } else if (measure.kind === "exposure") {
2064
+ const basis = measure.exposureBasisId
2065
+ ? exposureBasisMap.get(measure.exposureBasisId)
2066
+ : undefined;
2067
+ if (!basis)
2068
+ pushIssue(
2069
+ issues,
2070
+ "unknown-reference",
2071
+ `${base}.exposureBasisId`,
2072
+ "Exposure measure requires an existing exposure basis",
2073
+ );
2074
+ else if (measure.unit !== basis.unit)
2075
+ pushIssue(
2076
+ issues,
2077
+ "incompatible-semantics",
2078
+ `${base}.unit`,
2079
+ "Exposure measure unit must equal exposure-basis unit",
2080
+ );
2081
+ if (measure.source !== "exposure")
2082
+ pushIssue(
2083
+ issues,
2084
+ "incompatible-semantics",
2085
+ `${base}.source`,
2086
+ "Exposure measure source must be exposure",
2087
+ );
2088
+ if (
2089
+ measure.exposureTiming !== "origin-static" &&
2090
+ measure.exposureTiming !== "valuation-specific"
2091
+ ) {
2092
+ pushIssue(
2093
+ issues,
2094
+ "missing-required",
2095
+ `${base}.exposureTiming`,
2096
+ "Exposure measure requires timing",
2097
+ );
2098
+ }
2099
+ if (measure.missing !== "unknown")
2100
+ pushIssue(
2101
+ issues,
2102
+ "incompatible-semantics",
2103
+ `${base}.missing`,
2104
+ "Exposure missingness must remain unknown",
2105
+ );
2106
+ if (
2107
+ measure.basisId !== undefined ||
2108
+ measure.countPopulationId !== undefined
2109
+ ) {
2110
+ pushIssue(
2111
+ issues,
2112
+ "incompatible-semantics",
2113
+ base,
2114
+ "Exposure measure has inapplicable amount or population semantics",
2115
+ );
2116
+ }
2117
+ } else {
2118
+ pushIssue(issues, "invalid-type", `${base}.kind`, "Unknown measure kind");
2119
+ }
2120
+ if (
2121
+ measure.kind !== "exposure" &&
2122
+ measure.source !== "loss" &&
2123
+ measure.source !== "derived"
2124
+ ) {
2125
+ pushIssue(
2126
+ issues,
2127
+ "invalid-type",
2128
+ `${base}.source`,
2129
+ "Non-exposure measure source must be loss or derived",
2130
+ );
2131
+ }
2132
+ if (measure.kind !== "exposure" && measure.source === "exposure") {
2133
+ pushIssue(
2134
+ issues,
2135
+ "incompatible-semantics",
2136
+ `${base}.source`,
2137
+ "Only exposure measures may use exposure source",
2138
+ );
2139
+ }
2140
+ if (measure.kind === "amount" && measure.basisId) {
2141
+ const basis = amountBasisMap.get(measure.basisId);
2142
+ const hasSdkLimitation =
2143
+ basis?.components.some(
2144
+ (component) =>
2145
+ (component.limitation.kind === "layer" ||
2146
+ component.limitation.kind === "pre-limited") &&
2147
+ component.limitation.derivation.kind === "sdk",
2148
+ ) ?? false;
2149
+ if (hasSdkLimitation && measure.source !== "derived") {
2150
+ pushIssue(
2151
+ issues,
2152
+ "incompatible-semantics",
2153
+ `${base}.source`,
2154
+ "SDK-limited amount measures must be derived",
2155
+ );
2156
+ }
2157
+ }
2158
+ });
2159
+
2160
+ let definitionExpressionNodes = 0;
2161
+ const formulaRoleUsage = new Map<string, readonly string[]>();
2162
+ formulas.forEach((formula, index) => {
2163
+ const base = `$.formulas[${index}]`;
2164
+ if (!isPlainRecord(formula)) {
2165
+ pushIssue(issues, "invalid-type", base, "Formula must be an object");
2166
+ return;
2167
+ }
2168
+ validateAllowedKeys(
2169
+ formula,
2170
+ [
2171
+ "id",
2172
+ "version",
2173
+ "roles",
2174
+ "numerator",
2175
+ "denominator",
2176
+ "denominatorPolicy",
2177
+ ],
2178
+ base,
2179
+ issues,
2180
+ );
2181
+ validateToken(
2182
+ formula.version,
2183
+ `${base}.version`,
2184
+ "Formula version",
2185
+ issues,
2186
+ );
2187
+ if (formula.denominatorPolicy !== "positive-or-null") {
2188
+ pushIssue(
2189
+ issues,
2190
+ "incompatible-semantics",
2191
+ `${base}.denominatorPolicy`,
2192
+ "Unknown denominator policy",
2193
+ );
2194
+ }
2195
+ if (!isPlainRecord(formula.roles)) {
2196
+ pushIssue(
2197
+ issues,
2198
+ "invalid-type",
2199
+ `${base}.roles`,
2200
+ "Formula roles must be an object",
2201
+ );
2202
+ return;
2203
+ }
2204
+ for (const [roleName, role] of Object.entries(formula.roles)) {
2205
+ validateToken(
2206
+ roleName,
2207
+ propertyPath(`${base}.roles`, roleName),
2208
+ "Formula role",
2209
+ issues,
2210
+ );
2211
+ if (
2212
+ !isPlainRecord(role) ||
2213
+ !["count", "amount", "exposure"].includes(role.kind)
2214
+ ) {
2215
+ pushIssue(
2216
+ issues,
2217
+ "invalid-type",
2218
+ propertyPath(`${base}.roles`, roleName),
2219
+ "Formula role has an invalid kind",
2220
+ );
2221
+ } else {
2222
+ const rolePath = propertyPath(`${base}.roles`, roleName);
2223
+ validateAllowedKeys(
2224
+ role,
2225
+ ["kind", "compatibilityGroup", "developmentSemantics"],
2226
+ rolePath,
2227
+ issues,
2228
+ );
2229
+ if (
2230
+ role.developmentSemantics !== undefined &&
2231
+ !["cumulative", "incremental", "point-in-time", "unknown"].includes(
2232
+ role.developmentSemantics as string,
2233
+ )
2234
+ ) {
2235
+ pushIssue(
2236
+ issues,
2237
+ "invalid-type",
2238
+ `${rolePath}.developmentSemantics`,
2239
+ "Unknown formula-role development semantics",
2240
+ );
2241
+ }
2242
+ }
2243
+ if (role.compatibilityGroup !== undefined)
2244
+ validateToken(
2245
+ role.compatibilityGroup,
2246
+ `${propertyPath(`${base}.roles`, roleName)}.compatibilityGroup`,
2247
+ "Compatibility group",
2248
+ issues,
2249
+ );
2250
+ }
2251
+ const numerator = walkDiagnosticExpression(
2252
+ formula.numerator,
2253
+ "role",
2254
+ `${base}.numerator`,
2255
+ issues,
2256
+ );
2257
+ const denominator = walkDiagnosticExpression(
2258
+ formula.denominator,
2259
+ "role",
2260
+ `${base}.denominator`,
2261
+ issues,
2262
+ );
2263
+ definitionExpressionNodes += numerator.nodeCount + denominator.nodeCount;
2264
+ const used = [
2265
+ ...new Set([...numerator.dependencies, ...denominator.dependencies]),
2266
+ ].sort();
2267
+ formulaRoleUsage.set(formula.id, used);
2268
+ for (const role of used) {
2269
+ if (!hasDiagnosticOwn(formula.roles, role)) {
2270
+ pushIssue(
2271
+ issues,
2272
+ "unknown-reference",
2273
+ base,
2274
+ `Formula ${formula.id} references unknown role ${role}`,
2275
+ );
2276
+ }
2277
+ }
2278
+ for (const role of Object.keys(formula.roles)) {
2279
+ if (!used.includes(role))
2280
+ pushIssue(
2281
+ issues,
2282
+ "incompatible-semantics",
2283
+ propertyPath(`${base}.roles`, role),
2284
+ `Formula role ${role} is unused`,
2285
+ );
2286
+ }
2287
+ for (const [side, walked] of [
2288
+ ["numerator", numerator],
2289
+ ["denominator", denominator],
2290
+ ] as const) {
2291
+ const kinds = new Set(
2292
+ walked.dependencies
2293
+ .map((role) => formula.roles[role]?.kind)
2294
+ .filter(Boolean),
2295
+ );
2296
+ if (kinds.size > 1)
2297
+ pushIssue(
2298
+ issues,
2299
+ "incompatible-semantics",
2300
+ `${base}.${side}`,
2301
+ "Formula arithmetic combines different role kinds",
2302
+ );
2303
+ }
2304
+ });
2305
+
2306
+ const instanceRuleIds = new Set<string>();
2307
+ instances.forEach((instance, index) => {
2308
+ const base = `$.instances[${index}]`;
2309
+ if (!isPlainRecord(instance)) {
2310
+ pushIssue(
2311
+ issues,
2312
+ "invalid-type",
2313
+ base,
2314
+ "Metric instance must be an object",
2315
+ );
2316
+ return;
2317
+ }
2318
+ validateAllowedKeys(
2319
+ instance,
2320
+ ["id", "version", "formulaId", "bindings", "presentation", "rules"],
2321
+ base,
2322
+ issues,
2323
+ );
2324
+ validateToken(
2325
+ instance.version,
2326
+ `${base}.version`,
2327
+ "Instance version",
2328
+ issues,
2329
+ );
2330
+ validateToken(
2331
+ instance.formulaId,
2332
+ `${base}.formulaId`,
2333
+ "Formula reference",
2334
+ issues,
2335
+ );
2336
+ const formula = formulaMap.get(instance.formulaId);
2337
+ if (!formula) {
2338
+ pushIssue(
2339
+ issues,
2340
+ "unknown-reference",
2341
+ `${base}.formulaId`,
2342
+ `Unknown formula ${instance.formulaId}`,
2343
+ );
2344
+ return;
2345
+ }
2346
+ if (!isPlainRecord(instance.bindings)) {
2347
+ pushIssue(
2348
+ issues,
2349
+ "invalid-type",
2350
+ `${base}.bindings`,
2351
+ "Instance bindings must be an object",
2352
+ );
2353
+ return;
2354
+ }
2355
+ const usedRoles = formulaRoleUsage.get(formula.id) ?? [];
2356
+ const bindingSemantics = new Map<
2357
+ string,
2358
+ ReturnType<typeof expressionSemantics>
2359
+ >();
2360
+ for (const roleName of usedRoles) {
2361
+ if (!hasDiagnosticOwn(instance.bindings, roleName)) {
2362
+ pushIssue(
2363
+ issues,
2364
+ "missing-required",
2365
+ propertyPath(`${base}.bindings`, roleName),
2366
+ `Missing binding for role ${roleName}`,
2367
+ );
2368
+ continue;
2369
+ }
2370
+ const expression = instance.bindings[roleName]!;
2371
+ const semantics = expressionSemantics(
2372
+ expression,
2373
+ propertyPath(`${base}.bindings`, roleName),
2374
+ measureMap,
2375
+ issues,
2376
+ );
2377
+ bindingSemantics.set(roleName, semantics);
2378
+ definitionExpressionNodes += walkDiagnosticExpression(
2379
+ expression,
2380
+ "measure",
2381
+ propertyPath(`${base}.bindings`, roleName),
2382
+ [],
2383
+ ).nodeCount;
2384
+ const role = formula.roles[roleName]!;
2385
+ const boundMeasures = semantics.measureIds
2386
+ .map((id) => measureMap.get(id))
2387
+ .filter(
2388
+ (item): item is DiagnosticMeasureDefinition => item !== undefined,
2389
+ );
2390
+ if (boundMeasures.some((measure) => measure.kind !== role.kind)) {
2391
+ pushIssue(
2392
+ issues,
2393
+ "incompatible-semantics",
2394
+ propertyPath(`${base}.bindings`, roleName),
2395
+ `Binding kind does not match role ${roleName}`,
2396
+ );
2397
+ }
2398
+ if (role.developmentSemantics !== undefined) {
2399
+ const leaves = transitiveMeasureIds(
2400
+ semantics.measureIds,
2401
+ derivationsByOutput,
2402
+ )
2403
+ .map((id) => measureMap.get(id))
2404
+ .filter(
2405
+ (measure): measure is DiagnosticMeasureDefinition =>
2406
+ measure !== undefined,
2407
+ );
2408
+ if (
2409
+ leaves.some(
2410
+ (measure) =>
2411
+ measure.developmentSemantics !== role.developmentSemantics,
2412
+ )
2413
+ ) {
2414
+ pushIssue(
2415
+ issues,
2416
+ "incompatible-semantics",
2417
+ propertyPath(`${base}.bindings`, roleName),
2418
+ `Transitive binding development semantics do not match role ${roleName}`,
2419
+ );
2420
+ }
2421
+ }
2422
+ }
2423
+ const calculationSignatures = new Map<
2424
+ "numerator" | "denominator",
2425
+ string | null
2426
+ >();
2427
+ for (const [side, roleExpression] of [
2428
+ ["numerator", formula.numerator],
2429
+ ["denominator", formula.denominator],
2430
+ ] as const) {
2431
+ const roleIds = walkDiagnosticExpression(
2432
+ roleExpression,
2433
+ "role",
2434
+ `${base}.${side}`,
2435
+ [],
2436
+ ).dependencies;
2437
+ const signatures = new Set(
2438
+ roleIds
2439
+ .map((roleId) => bindingSemantics.get(roleId)?.signature)
2440
+ .filter(
2441
+ (signature): signature is string =>
2442
+ signature !== null && signature !== undefined,
2443
+ ),
2444
+ );
2445
+ calculationSignatures.set(
2446
+ side,
2447
+ signatures.size === 1 ? [...signatures][0]! : null,
2448
+ );
2449
+ if (signatures.size > 1) {
2450
+ pushIssue(
2451
+ issues,
2452
+ "incompatible-semantics",
2453
+ `${base}.bindings`,
2454
+ `Bound ${side} arithmetic combines incompatible semantics`,
2455
+ );
2456
+ }
2457
+ }
2458
+ for (const binding of Object.keys(instance.bindings)) {
2459
+ if (!usedRoles.includes(binding)) {
2460
+ pushIssue(
2461
+ issues,
2462
+ "unknown-reference",
2463
+ propertyPath(`${base}.bindings`, binding),
2464
+ `Unexpected binding for role ${binding}`,
2465
+ );
2466
+ }
2467
+ }
2468
+ const compatibility = new Map<string, string>();
2469
+ for (const roleName of usedRoles) {
2470
+ const role = formula.roles[roleName]!;
2471
+ const binding = instance.bindings[roleName];
2472
+ if (!role.compatibilityGroup || !binding) continue;
2473
+ const semantics = expressionSemantics(
2474
+ binding,
2475
+ propertyPath(`${base}.bindings`, roleName),
2476
+ measureMap,
2477
+ [],
2478
+ );
2479
+ if (semantics.signature === null) continue;
2480
+ const previous = compatibility.get(role.compatibilityGroup);
2481
+ if (previous !== undefined && previous !== semantics.signature) {
2482
+ pushIssue(
2483
+ issues,
2484
+ "incompatible-semantics",
2485
+ propertyPath(`${base}.bindings`, roleName),
2486
+ `Compatibility group ${role.compatibilityGroup} binds different semantics`,
2487
+ );
2488
+ } else compatibility.set(role.compatibilityGroup, semantics.signature);
2489
+ }
2490
+ if (!isPlainRecord(instance.presentation)) {
2491
+ pushIssue(
2492
+ issues,
2493
+ "invalid-type",
2494
+ `${base}.presentation`,
2495
+ "Metric presentation must be an object",
2496
+ );
2497
+ } else
2498
+ validateAllowedKeys(
2499
+ instance.presentation,
2500
+ [
2501
+ "displayName",
2502
+ "description",
2503
+ "displayUnit",
2504
+ "scale",
2505
+ "numeratorLabel",
2506
+ "denominatorLabel",
2507
+ ],
2508
+ `${base}.presentation`,
2509
+ issues,
2510
+ );
2511
+ for (const [key, text] of [
2512
+ ["displayName", instance.presentation?.displayName],
2513
+ ["description", instance.presentation?.description],
2514
+ ["displayUnit", instance.presentation?.displayUnit],
2515
+ ["numeratorLabel", instance.presentation?.numeratorLabel],
2516
+ ["denominatorLabel", instance.presentation?.denominatorLabel],
2517
+ ] as const)
2518
+ validateToken(
2519
+ text,
2520
+ `${base}.presentation.${key}`,
2521
+ `Presentation ${key}`,
2522
+ issues,
2523
+ );
2524
+ if (
2525
+ !validateFinite(
2526
+ instance.presentation?.scale,
2527
+ `${base}.presentation.scale`,
2528
+ "Presentation scale",
2529
+ issues,
2530
+ ) ||
2531
+ instance.presentation.scale <= 0
2532
+ ) {
2533
+ pushIssue(
2534
+ issues,
2535
+ "invalid-number",
2536
+ `${base}.presentation.scale`,
2537
+ "Presentation scale must be positive",
2538
+ );
2539
+ }
2540
+ requireArray(instance.rules, `${base}.rules`, issues).forEach(
2541
+ (rawRule, ruleIndex) => {
2542
+ const ruleBase = `${base}.rules[${ruleIndex}]`;
2543
+ if (!isPlainRecord(rawRule)) {
2544
+ pushIssue(
2545
+ issues,
2546
+ "invalid-type",
2547
+ ruleBase,
2548
+ "Metric rule must be an object",
2549
+ );
2550
+ return;
2551
+ }
2552
+ const rule = rawRule as unknown as DiagnosticComparisonRule;
2553
+ validateAllowedKeys(
2554
+ rawRule,
2555
+ ["id", "code", "message", "severity", "when"],
2556
+ ruleBase,
2557
+ issues,
2558
+ );
2559
+ if (
2560
+ validateToken(rule.id, `${ruleBase}.id`, "Metric rule ID", issues)
2561
+ ) {
2562
+ if (rule.id.startsWith("diagnostic/structural/"))
2563
+ pushIssue(
2564
+ issues,
2565
+ "incompatible-semantics",
2566
+ `${ruleBase}.id`,
2567
+ "Metric rule ID uses the reserved structural prefix",
2568
+ );
2569
+ if (instanceRuleIds.has(rule.id) || reviewRuleMap.has(rule.id))
2570
+ pushIssue(
2571
+ issues,
2572
+ "duplicate-id",
2573
+ `${ruleBase}.id`,
2574
+ `Duplicate rule ID ${rule.id}`,
2575
+ );
2576
+ instanceRuleIds.add(rule.id);
2577
+ }
2578
+ validateToken(
2579
+ rule.code,
2580
+ `${ruleBase}.code`,
2581
+ "Metric rule code",
2582
+ issues,
2583
+ );
2584
+ validateToken(
2585
+ rule.message,
2586
+ `${ruleBase}.message`,
2587
+ "Metric rule message",
2588
+ issues,
2589
+ );
2590
+ if (rule.severity !== "warning" && rule.severity !== "fail")
2591
+ pushIssue(
2592
+ issues,
2593
+ "invalid-type",
2594
+ `${ruleBase}.severity`,
2595
+ "Metric rule severity must be warning or fail",
2596
+ );
2597
+ if (!isPlainRecord(rule.when)) {
2598
+ pushIssue(
2599
+ issues,
2600
+ "invalid-type",
2601
+ `${ruleBase}.when`,
2602
+ "Metric rule predicate must be an object",
2603
+ );
2604
+ return;
2605
+ }
2606
+ validateAllowedKeys(
2607
+ rule.when,
2608
+ ["left", "operator", "right", "tolerance"],
2609
+ `${ruleBase}.when`,
2610
+ issues,
2611
+ );
2612
+ if (rule.when.tolerance !== undefined) {
2613
+ if (!isPlainRecord(rule.when.tolerance))
2614
+ pushIssue(
2615
+ issues,
2616
+ "invalid-type",
2617
+ `${ruleBase}.when.tolerance`,
2618
+ "Tolerance must be an object",
2619
+ );
2620
+ else
2621
+ validateAllowedKeys(
2622
+ rule.when.tolerance,
2623
+ ["absolute", "relative"],
2624
+ `${ruleBase}.when.tolerance`,
2625
+ issues,
2626
+ );
2627
+ }
2628
+ if (
2629
+ !["lt", "lte", "eq", "neq", "gte", "gt"].includes(rule.when.operator)
2630
+ )
2631
+ pushIssue(
2632
+ issues,
2633
+ "invalid-type",
2634
+ `${ruleBase}.when.operator`,
2635
+ "Unknown metric comparison operator",
2636
+ );
2637
+ validateTolerance(
2638
+ rule.when?.tolerance,
2639
+ `${ruleBase}.when.tolerance`,
2640
+ issues,
2641
+ );
2642
+ const operandSignatures = new Map<
2643
+ "left" | "right",
2644
+ string | null | undefined
2645
+ >();
2646
+ for (const [side, operand] of [
2647
+ ["left", rule.when?.left],
2648
+ ["right", rule.when?.right],
2649
+ ] as const) {
2650
+ const operandPath = `${ruleBase}.when.${side}`;
2651
+ if (isPlainRecord(operand)) {
2652
+ const allowed =
2653
+ operand.source === "measure"
2654
+ ? ["source", "expression"]
2655
+ : operand.source === "calculation"
2656
+ ? ["source", "field"]
2657
+ : operand.source === "constant"
2658
+ ? ["source", "value"]
2659
+ : ["source"];
2660
+ validateAllowedKeys(operand, allowed, operandPath, issues);
2661
+ }
2662
+ if (operand?.source === "measure") {
2663
+ const semantic = expressionSemantics(
2664
+ operand.expression,
2665
+ `${operandPath}.expression`,
2666
+ measureMap,
2667
+ issues,
2668
+ 2,
2669
+ );
2670
+ const operandNodes =
2671
+ 1 +
2672
+ walkDiagnosticExpression(
2673
+ operand.expression,
2674
+ "measure",
2675
+ `${operandPath}.expression`,
2676
+ [],
2677
+ ).nodeCount;
2678
+ definitionExpressionNodes += operandNodes;
2679
+ if (operandNodes > MAX_DIAGNOSTIC_EXPRESSION_NODES) {
2680
+ pushIssue(
2681
+ issues,
2682
+ "expression-limit",
2683
+ operandPath,
2684
+ `Metric rule operand node count exceeds ${MAX_DIAGNOSTIC_EXPRESSION_NODES}`,
2685
+ );
2686
+ }
2687
+ operandSignatures.set(side, semantic.signature);
2688
+ } else if (operand?.source === "calculation") {
2689
+ definitionExpressionNodes += 1;
2690
+ if (
2691
+ operand.field !== "numerator" &&
2692
+ operand.field !== "denominator"
2693
+ )
2694
+ pushIssue(
2695
+ issues,
2696
+ "invalid-type",
2697
+ `${operandPath}.field`,
2698
+ "Unknown calculation field",
2699
+ );
2700
+ else
2701
+ operandSignatures.set(
2702
+ side,
2703
+ calculationSignatures.get(operand.field),
2704
+ );
2705
+ } else if (operand?.source === "constant") {
2706
+ definitionExpressionNodes += 1;
2707
+ operandSignatures.set(side, undefined);
2708
+ validateFinite(
2709
+ operand.value,
2710
+ `${operandPath}.value`,
2711
+ "Rule constant",
2712
+ issues,
2713
+ );
2714
+ } else
2715
+ pushIssue(
2716
+ issues,
2717
+ "invalid-type",
2718
+ operandPath,
2719
+ "Unknown metric rule operand",
2720
+ );
2721
+ }
2722
+ const leftSignature = operandSignatures.get("left");
2723
+ const rightSignature = operandSignatures.get("right");
2724
+ if (
2725
+ leftSignature !== undefined &&
2726
+ rightSignature !== undefined &&
2727
+ leftSignature !== null &&
2728
+ rightSignature !== null &&
2729
+ leftSignature !== rightSignature
2730
+ ) {
2731
+ pushIssue(
2732
+ issues,
2733
+ "incompatible-semantics",
2734
+ `${ruleBase}.when`,
2735
+ "Metric comparison operands must have identical quantity semantics",
2736
+ );
2737
+ }
2738
+ },
2739
+ );
2740
+ });
2741
+
2742
+ const derivedOutputs = new Map<string, DiagnosticDerivedMeasureDefinition>();
2743
+ derivations.forEach((derivation, index) => {
2744
+ const base = `$.derivedMeasures[${index}]`;
2745
+ if (!isPlainRecord(derivation)) {
2746
+ pushIssue(
2747
+ issues,
2748
+ "invalid-type",
2749
+ base,
2750
+ "Derived measure must be an object",
2751
+ );
2752
+ return;
2753
+ }
2754
+ validateAllowedKeys(
2755
+ derivation,
2756
+ ["id", "outputMeasureId", "expression"],
2757
+ base,
2758
+ issues,
2759
+ );
2760
+ validateToken(
2761
+ derivation.outputMeasureId,
2762
+ `${base}.outputMeasureId`,
2763
+ "Derived output measure",
2764
+ issues,
2765
+ );
2766
+ const output = measureMap.get(derivation.outputMeasureId);
2767
+ if (!output)
2768
+ pushIssue(
2769
+ issues,
2770
+ "unknown-reference",
2771
+ `${base}.outputMeasureId`,
2772
+ `Unknown derived output ${derivation.outputMeasureId}`,
2773
+ );
2774
+ else if (output.source !== "derived")
2775
+ pushIssue(
2776
+ issues,
2777
+ "incompatible-semantics",
2778
+ `${base}.outputMeasureId`,
2779
+ "Derivation output must declare source derived",
2780
+ );
2781
+ if (derivedOutputs.has(derivation.outputMeasureId))
2782
+ pushIssue(
2783
+ issues,
2784
+ "duplicate-id",
2785
+ `${base}.outputMeasureId`,
2786
+ `Duplicate derived output ${derivation.outputMeasureId}`,
2787
+ );
2788
+ derivedOutputs.set(derivation.outputMeasureId, derivation);
2789
+ const walked = walkDiagnosticExpression(
2790
+ derivation.expression,
2791
+ "claim",
2792
+ `${base}.expression`,
2793
+ issues,
2794
+ );
2795
+ definitionExpressionNodes += walked.nodeCount;
2796
+ for (const id of walked.dependencies)
2797
+ if (!measureMap.has(id))
2798
+ pushIssue(
2799
+ issues,
2800
+ "unknown-reference",
2801
+ `${base}.expression`,
2802
+ `Unknown derivation measure ${id}`,
2803
+ );
2804
+ const transitiveIds = transitiveMeasureIds(
2805
+ walked.dependencies,
2806
+ derivationsByOutput,
2807
+ );
2808
+ const transitiveInputs = transitiveIds
2809
+ .map((id) => measureMap.get(id))
2810
+ .filter(
2811
+ (item): item is DiagnosticMeasureDefinition => item !== undefined,
2812
+ );
2813
+ if (
2814
+ output &&
2815
+ transitiveInputs.some(
2816
+ (input) => input.developmentSemantics !== output.developmentSemantics,
2817
+ )
2818
+ ) {
2819
+ pushIssue(
2820
+ issues,
2821
+ "incompatible-semantics",
2822
+ `${base}.expression`,
2823
+ "Derived output and inputs must share development semantics",
2824
+ );
2825
+ }
2826
+ if (definition.lossRowGrain !== "claim") {
2827
+ pushIssue(
2828
+ issues,
2829
+ "incompatible-semantics",
2830
+ base,
2831
+ "Derived measures require claim-grain input",
2832
+ );
2833
+ }
2834
+ const expressionStack: unknown[] = [derivation.expression];
2835
+ while (expressionStack.length > 0) {
2836
+ const current = expressionStack.pop();
2837
+ if (!isPlainRecord(current)) continue;
2838
+ if (current.op === "claim-layer") {
2839
+ const attachment = current.attachment;
2840
+ const limit = current.limit;
2841
+ const attachmentOk = validateFinite(
2842
+ attachment,
2843
+ `${base}.expression.attachment`,
2844
+ "Claim-layer attachment",
2845
+ issues,
2846
+ );
2847
+ if (attachmentOk && attachment < 0)
2848
+ pushIssue(
2849
+ issues,
2850
+ "invalid-number",
2851
+ `${base}.expression.attachment`,
2852
+ "Claim-layer attachment must be nonnegative",
2853
+ );
2854
+ if (limit !== null) {
2855
+ const limitOk = validateFinite(
2856
+ limit,
2857
+ `${base}.expression.limit`,
2858
+ "Claim-layer width",
2859
+ issues,
2860
+ );
2861
+ if (limitOk && limit <= 0)
2862
+ pushIssue(
2863
+ issues,
2864
+ "invalid-number",
2865
+ `${base}.expression.limit`,
2866
+ "Claim-layer width must be positive",
2867
+ );
2868
+ if (attachmentOk && limitOk && !Number.isFinite(attachment + limit)) {
2869
+ pushIssue(
2870
+ issues,
2871
+ "invalid-number",
2872
+ `${base}.expression.limit`,
2873
+ "Attachment plus claim-layer width must be finite",
2874
+ );
2875
+ }
2876
+ }
2877
+ } else if (current.op === "add" && Array.isArray(current.terms))
2878
+ expressionStack.push(...current.terms);
2879
+ else if (current.op === "subtract")
2880
+ expressionStack.push(current.left, current.right);
2881
+ }
2882
+ const projection = projectClaimExpression(
2883
+ derivation.expression,
2884
+ `${base}.expression`,
2885
+ measureMap,
2886
+ amountBasisMap,
2887
+ issues,
2888
+ );
2889
+ if (output && projection) {
2890
+ const expected =
2891
+ output.kind === "amount" && output.basisId
2892
+ ? amountBasisMap.get(output.basisId)
2893
+ : undefined;
2894
+ const matches =
2895
+ output.kind === projection.kind &&
2896
+ output.unit === projection.unit &&
2897
+ (output.kind === "amount"
2898
+ ? expected !== undefined &&
2899
+ projectedBasisKey(projectedAmountBasis(expected)) ===
2900
+ projectedBasisKey(projection.amountBasis!)
2901
+ : semanticReference(output) === projection.reference);
2902
+ if (!matches)
2903
+ pushIssue(
2904
+ issues,
2905
+ "incompatible-semantics",
2906
+ `${base}.expression`,
2907
+ "Derived expression does not project the output measure semantics",
2908
+ );
2909
+ }
2910
+ });
2911
+ for (const [measureId, measure] of measureMap) {
2912
+ if (measure.source === "derived" && !derivedOutputs.has(measureId)) {
2913
+ pushIssue(
2914
+ issues,
2915
+ "missing-required",
2916
+ "$.derivedMeasures",
2917
+ `Derived measure ${measureId} requires exactly one derivation`,
2918
+ );
2919
+ }
2920
+ }
2921
+ const visiting = new Set<string>();
2922
+ const visited = new Set<string>();
2923
+ const visit = (id: string): void => {
2924
+ if (visited.has(id)) return;
2925
+ if (visiting.has(id)) {
2926
+ pushIssue(
2927
+ issues,
2928
+ "cycle",
2929
+ "$.derivedMeasures",
2930
+ `Derived measure graph contains a cycle at ${id}`,
2931
+ );
2932
+ return;
2933
+ }
2934
+ visiting.add(id);
2935
+ const derivation = derivedOutputs.get(id);
2936
+ if (derivation) {
2937
+ const deps = walkDiagnosticExpression(
2938
+ derivation.expression,
2939
+ "claim",
2940
+ "$.derivedMeasures",
2941
+ [],
2942
+ ).dependencies;
2943
+ for (const dependency of deps)
2944
+ if (derivedOutputs.has(dependency)) visit(dependency);
2945
+ }
2946
+ visiting.delete(id);
2947
+ visited.add(id);
2948
+ };
2949
+ for (const id of derivedOutputs.keys()) visit(id);
2950
+
2951
+ reviewRules.forEach((rule, index) => {
2952
+ const base = `$.reviewRules[${index}]`;
2953
+ if (!isPlainRecord(rule)) {
2954
+ pushIssue(issues, "invalid-type", base, "Review rule must be an object");
2955
+ return;
2956
+ }
2957
+ const commonKeys = [
2958
+ "kind",
2959
+ "id",
2960
+ "code",
2961
+ "description",
2962
+ "severity",
2963
+ "tolerance",
2964
+ "missingInput",
2965
+ ];
2966
+ const branchKeys =
2967
+ rule.kind === "compare"
2968
+ ? ["when"]
2969
+ : rule.kind === "reconcile"
2970
+ ? ["actual", "expected"]
2971
+ : rule.kind === "monotonic"
2972
+ ? ["expression", "direction"]
2973
+ : rule.kind === "layer-order"
2974
+ ? ["narrower", "broader", "comparability"]
2975
+ : rule.kind === "control-total"
2976
+ ? ["expression", "expected", "filter", "projection"]
2977
+ : [];
2978
+ validateAllowedKeys(rule, [...commonKeys, ...branchKeys], base, issues);
2979
+ if (rule.tolerance !== undefined) {
2980
+ if (!isPlainRecord(rule.tolerance))
2981
+ pushIssue(
2982
+ issues,
2983
+ "invalid-type",
2984
+ `${base}.tolerance`,
2985
+ "Tolerance must be an object",
2986
+ );
2987
+ else
2988
+ validateAllowedKeys(
2989
+ rule.tolerance,
2990
+ ["absolute", "relative"],
2991
+ `${base}.tolerance`,
2992
+ issues,
2993
+ );
2994
+ }
2995
+ validateToken(rule.code, `${base}.code`, "Review rule code", issues);
2996
+ validateToken(
2997
+ rule.description,
2998
+ `${base}.description`,
2999
+ "Review rule description",
3000
+ issues,
3001
+ );
3002
+ if (
3003
+ typeof rule.id === "string" &&
3004
+ rule.id.startsWith("diagnostic/structural/")
3005
+ )
3006
+ pushIssue(
3007
+ issues,
3008
+ "incompatible-semantics",
3009
+ `${base}.id`,
3010
+ "Review rule ID uses the reserved structural prefix",
3011
+ );
3012
+ if (rule.severity !== "warning" && rule.severity !== "fail")
3013
+ pushIssue(
3014
+ issues,
3015
+ "invalid-type",
3016
+ `${base}.severity`,
3017
+ "Review severity must be warning or fail",
3018
+ );
3019
+ if (
3020
+ rule.missingInput !== "not-evaluated" &&
3021
+ rule.missingInput !== "finding"
3022
+ )
3023
+ pushIssue(
3024
+ issues,
3025
+ "invalid-type",
3026
+ `${base}.missingInput`,
3027
+ "Review missing-input policy must be not-evaluated or finding",
3028
+ );
3029
+ validateTolerance(rule.tolerance, `${base}.tolerance`, issues);
3030
+ const roots: {
3031
+ readonly expression: DiagnosticMeasureExpression;
3032
+ readonly path: string;
3033
+ }[] = [];
3034
+ const operand = (value: unknown, path: string): void => {
3035
+ if (isPlainRecord(value) && value.op === "constant") {
3036
+ validateAllowedKeys(value, ["op", "value"], path, issues);
3037
+ validateFinite(value.value, `${path}.value`, "Review constant", issues);
3038
+ definitionExpressionNodes++;
3039
+ } else
3040
+ roots.push({ expression: value as DiagnosticMeasureExpression, path });
3041
+ };
3042
+ if (rule.kind === "compare") {
3043
+ if (!isPlainRecord(rule.when))
3044
+ pushIssue(
3045
+ issues,
3046
+ "invalid-type",
3047
+ `${base}.when`,
3048
+ "Compare rule predicate must be an object",
3049
+ );
3050
+ else {
3051
+ validateAllowedKeys(
3052
+ rule.when,
3053
+ ["left", "operator", "right"],
3054
+ `${base}.when`,
3055
+ issues,
3056
+ );
3057
+ operand(rule.when.left, `${base}.when.left`);
3058
+ operand(rule.when.right, `${base}.when.right`);
3059
+ if (
3060
+ !["lt", "lte", "eq", "neq", "gte", "gt"].includes(rule.when.operator)
3061
+ )
3062
+ pushIssue(
3063
+ issues,
3064
+ "invalid-type",
3065
+ `${base}.when.operator`,
3066
+ "Unknown review comparison operator",
3067
+ );
3068
+ }
3069
+ } else if (rule.kind === "reconcile") {
3070
+ roots.push({ expression: rule.actual, path: `${base}.actual` });
3071
+ operand(rule.expected, `${base}.expected`);
3072
+ } else if (rule.kind === "monotonic") {
3073
+ roots.push({ expression: rule.expression, path: `${base}.expression` });
3074
+ if (
3075
+ rule.direction !== "nondecreasing" &&
3076
+ rule.direction !== "nonincreasing"
3077
+ )
3078
+ pushIssue(
3079
+ issues,
3080
+ "invalid-type",
3081
+ `${base}.direction`,
3082
+ "Unknown monotonic direction",
3083
+ );
3084
+ } else if (rule.kind === "layer-order") {
3085
+ roots.push(
3086
+ { expression: rule.narrower, path: `${base}.narrower` },
3087
+ { expression: rule.broader, path: `${base}.broader` },
3088
+ );
3089
+ if (
3090
+ !isPlainRecord(rule.comparability) ||
3091
+ (rule.comparability.kind !== "compiler-proven" &&
3092
+ rule.comparability.kind !== "caller-asserted")
3093
+ ) {
3094
+ pushIssue(
3095
+ issues,
3096
+ "invalid-type",
3097
+ `${base}.comparability`,
3098
+ "Unknown layer comparability evidence",
3099
+ );
3100
+ } else if (rule.comparability.kind === "caller-asserted") {
3101
+ validateAllowedKeys(
3102
+ rule.comparability,
3103
+ ["kind", "rationaleArtifactId"],
3104
+ `${base}.comparability`,
3105
+ issues,
3106
+ );
3107
+ validateToken(
3108
+ rule.comparability.rationaleArtifactId,
3109
+ `${base}.comparability.rationaleArtifactId`,
3110
+ "Rationale artifact ID",
3111
+ issues,
3112
+ );
3113
+ }
3114
+ if (
3115
+ isPlainRecord(rule.comparability) &&
3116
+ rule.comparability.kind === "compiler-proven"
3117
+ )
3118
+ validateAllowedKeys(
3119
+ rule.comparability,
3120
+ ["kind"],
3121
+ `${base}.comparability`,
3122
+ issues,
3123
+ );
3124
+ if (
3125
+ isPlainRecord(rule.comparability) &&
3126
+ (rule.comparability.kind === "compiler-proven" ||
3127
+ rule.comparability.kind === "caller-asserted")
3128
+ ) {
3129
+ validateLayerComparability(
3130
+ rule,
3131
+ `${base}.comparability`,
3132
+ measureMap,
3133
+ amountBasisMap,
3134
+ derivationsByOutput,
3135
+ issues,
3136
+ );
3137
+ }
3138
+ } else if (rule.kind === "control-total") {
3139
+ roots.push({ expression: rule.expression, path: `${base}.expression` });
3140
+ validateFinite(
3141
+ rule.expected,
3142
+ `${base}.expected`,
3143
+ "Control total",
3144
+ issues,
3145
+ );
3146
+ if (rule.filter !== undefined)
3147
+ validateReviewFilter(
3148
+ rule.filter,
3149
+ `${base}.filter`,
3150
+ definition.periodAxis,
3151
+ issues,
3152
+ );
3153
+ if (
3154
+ !isPlainRecord(rule.projection) ||
3155
+ !["valuation", "latest-valuation-per-origin", "all-cells"].includes(
3156
+ rule.projection.kind,
3157
+ )
3158
+ ) {
3159
+ pushIssue(
3160
+ issues,
3161
+ "invalid-type",
3162
+ `${base}.projection`,
3163
+ "Unknown control-total projection",
3164
+ );
3165
+ } else if (rule.projection.kind === "valuation") {
3166
+ validateAllowedKeys(
3167
+ rule.projection,
3168
+ ["kind", "valuation"],
3169
+ `${base}.projection`,
3170
+ issues,
3171
+ );
3172
+ if (
3173
+ validateToken(
3174
+ rule.projection.valuation,
3175
+ `${base}.projection.valuation`,
3176
+ "Control-total valuation",
3177
+ issues,
3178
+ ) &&
3179
+ !normalizeDiagnosticPeriodWithAxis(
3180
+ definition.periodAxis,
3181
+ "valuation",
3182
+ rule.projection.valuation,
3183
+ )
3184
+ ) {
3185
+ pushIssue(
3186
+ issues,
3187
+ "invalid-period",
3188
+ `${base}.projection.valuation`,
3189
+ `Unknown valuation period ${JSON.stringify(rule.projection.valuation)}`,
3190
+ );
3191
+ }
3192
+ } else
3193
+ validateAllowedKeys(
3194
+ rule.projection,
3195
+ ["kind"],
3196
+ `${base}.projection`,
3197
+ issues,
3198
+ );
3199
+ } else
3200
+ pushIssue(
3201
+ issues,
3202
+ "invalid-type",
3203
+ `${base}.kind`,
3204
+ "Unknown review-rule kind",
3205
+ );
3206
+ const compatibleOperands = (
3207
+ left: DiagnosticReviewOperand,
3208
+ right: DiagnosticReviewOperand,
3209
+ path: string,
3210
+ ): void => {
3211
+ if (!isPlainRecord(left) || !isPlainRecord(right)) return;
3212
+ if (left.op === "constant" || right.op === "constant") return;
3213
+ const leftSignature = expressionSemantics(
3214
+ left,
3215
+ `${path}.left`,
3216
+ measureMap,
3217
+ [],
3218
+ ).signature;
3219
+ const rightSignature = expressionSemantics(
3220
+ right,
3221
+ `${path}.right`,
3222
+ measureMap,
3223
+ [],
3224
+ ).signature;
3225
+ if (
3226
+ leftSignature !== null &&
3227
+ rightSignature !== null &&
3228
+ leftSignature !== rightSignature
3229
+ ) {
3230
+ pushIssue(
3231
+ issues,
3232
+ "incompatible-semantics",
3233
+ path,
3234
+ "Review comparison operands must have identical quantity semantics",
3235
+ );
3236
+ }
3237
+ };
3238
+ if (rule.kind === "compare" && isPlainRecord(rule.when)) {
3239
+ compatibleOperands(rule.when.left, rule.when.right, `${base}.when`);
3240
+ } else if (rule.kind === "reconcile") {
3241
+ compatibleOperands(rule.actual, rule.expected, base);
3242
+ }
3243
+ roots.forEach(({ expression, path }) => {
3244
+ const semantics = expressionSemantics(
3245
+ expression,
3246
+ path,
3247
+ measureMap,
3248
+ issues,
3249
+ );
3250
+ definitionExpressionNodes += walkDiagnosticExpression(
3251
+ expression,
3252
+ "measure",
3253
+ path,
3254
+ [],
3255
+ ).nodeCount;
3256
+ if (rule.kind === "monotonic") {
3257
+ const leaves = transitiveMeasureIds(
3258
+ semantics.measureIds,
3259
+ derivationsByOutput,
3260
+ )
3261
+ .map((id) => measureMap.get(id))
3262
+ .filter(
3263
+ (item): item is DiagnosticMeasureDefinition => item !== undefined,
3264
+ );
3265
+ if (
3266
+ leaves.some(
3267
+ (measure) =>
3268
+ measure.developmentSemantics !== "cumulative" &&
3269
+ measure.developmentSemantics !== "point-in-time",
3270
+ )
3271
+ ) {
3272
+ pushIssue(
3273
+ issues,
3274
+ "incompatible-semantics",
3275
+ path,
3276
+ "Monotonic review requires cumulative or point-in-time leaves",
3277
+ );
3278
+ }
3279
+ }
3280
+ if (
3281
+ rule.kind === "control-total" &&
3282
+ rule.projection?.kind === "all-cells"
3283
+ ) {
3284
+ const leaves = transitiveMeasureIds(
3285
+ semantics.measureIds,
3286
+ derivationsByOutput,
3287
+ )
3288
+ .map((id) => measureMap.get(id))
3289
+ .filter(
3290
+ (item): item is DiagnosticMeasureDefinition => item !== undefined,
3291
+ );
3292
+ if (
3293
+ leaves.some(
3294
+ (measure) =>
3295
+ measure.developmentSemantics === "cumulative" ||
3296
+ measure.exposureTiming === "origin-static",
3297
+ )
3298
+ ) {
3299
+ pushIssue(
3300
+ issues,
3301
+ "incompatible-semantics",
3302
+ `${base}.projection`,
3303
+ "All-cells control totals cannot repeat cumulative or origin-static measures",
3304
+ );
3305
+ }
3306
+ }
3307
+ });
3308
+ });
3309
+
3310
+ if (definitionExpressionNodes > MAX_DIAGNOSTIC_DEFINITION_EXPRESSION_NODES) {
3311
+ pushIssue(
3312
+ issues,
3313
+ "expression-limit",
3314
+ "$",
3315
+ `Definition expression node count exceeds ${MAX_DIAGNOSTIC_DEFINITION_EXPRESSION_NODES}`,
3316
+ );
3317
+ }
3318
+ validatePeriodAxis(definition.periodAxis, issues);
3319
+ if (issues.length > 0) throw new DiagnosticValidationError(issues);
3320
+ return definition;
3321
+ }
3322
+
3323
+ /** Internal boundary reuse: direct helpers use the compiler's exact axis contract. */
3324
+ export function validateDiagnosticPeriodAxisInput(
3325
+ value: unknown,
3326
+ ): readonly DiagnosticValidationIssue[] {
3327
+ const preflight = diagnosticJsonPreflight(value, "configuration").map((issue) => ({
3328
+ ...issue,
3329
+ path: `$.periodAxis${issue.path.slice(1)}`,
3330
+ }));
3331
+ if (preflight.length > 0) return preflight;
3332
+ const issues: DiagnosticValidationIssue[] = [];
3333
+ validatePeriodAxis(value as DiagnosticPeriodAxis, issues);
3334
+ return issues.map((issue) => ({ ...issue, domain: "configuration" as const }));
3335
+ }
3336
+
3337
+ function optionalFromNullable<T>(value: T | null | undefined): T | undefined {
3338
+ return value === null || value === undefined ? undefined : value;
3339
+ }
3340
+
3341
+ function omitProperties<T extends object>(
3342
+ value: T,
3343
+ keys: readonly string[],
3344
+ ): Record<string, unknown> {
3345
+ const result = { ...value } as Record<string, unknown>;
3346
+ for (const key of keys) delete result[key];
3347
+ return result;
3348
+ }
3349
+
3350
+ /**
3351
+ * The normalized wire projection materializes optional semantic fields as
3352
+ * null. Convert only those documented defaults back to the authored view so
3353
+ * both accepted inputs travel through the exact same semantic compiler.
3354
+ */
3355
+ function authoredView(
3356
+ value: DiagnosticDefinition | NormalizedDiagnosticDefinitionIdentity,
3357
+ ): DiagnosticDefinition {
3358
+ const definition = value as NormalizedDiagnosticDefinitionIdentity;
3359
+ const reviewRules = definition.reviewRules.map((rule) => {
3360
+ const tolerance = { ...rule.tolerance };
3361
+ if (rule.kind !== "control-total") return { ...rule, tolerance };
3362
+ const filter =
3363
+ rule.filter === null || rule.filter === undefined
3364
+ ? undefined
3365
+ : (Object.fromEntries(
3366
+ Object.entries({
3367
+ sourceGroups: optionalFromNullable(rule.filter.sourceGroups),
3368
+ origins: optionalFromNullable(rule.filter.origins),
3369
+ originFrom: optionalFromNullable(rule.filter.originFrom),
3370
+ originThrough: optionalFromNullable(rule.filter.originThrough),
3371
+ valuations: optionalFromNullable(rule.filter.valuations),
3372
+ valuationFrom: optionalFromNullable(rule.filter.valuationFrom),
3373
+ valuationThrough: optionalFromNullable(
3374
+ rule.filter.valuationThrough,
3375
+ ),
3376
+ minDevelopmentAge: optionalFromNullable(
3377
+ rule.filter.minDevelopmentAge,
3378
+ ),
3379
+ maxDevelopmentAge: optionalFromNullable(
3380
+ rule.filter.maxDevelopmentAge,
3381
+ ),
3382
+ }).filter(([, item]) => item !== undefined),
3383
+ ) as DiagnosticReviewFilter);
3384
+ return {
3385
+ ...omitProperties(rule, ["filter"]),
3386
+ tolerance,
3387
+ ...(filter === undefined ? {} : { filter }),
3388
+ } as DiagnosticReviewRule;
3389
+ }) as readonly DiagnosticReviewRule[];
3390
+ return {
3391
+ ...definition,
3392
+ diagnosticDefinitionVersion: definition.diagnosticDefinitionVersion,
3393
+ id: definition.id,
3394
+ version: definition.version,
3395
+ lossRowGrain: definition.lossRowGrain,
3396
+ measures: definition.measures.map((measure) => ({
3397
+ ...omitProperties(measure, [
3398
+ "basisId",
3399
+ "countPopulationId",
3400
+ "exposureBasisId",
3401
+ "exposureTiming",
3402
+ ]),
3403
+ id: measure.id,
3404
+ displayName: measure.displayName,
3405
+ description: measure.description,
3406
+ source: measure.source,
3407
+ kind: measure.kind,
3408
+ unit: measure.unit,
3409
+ developmentSemantics: measure.developmentSemantics,
3410
+ aggregation: measure.aggregation,
3411
+ missing: measure.missing,
3412
+ ...(measure.basisId === null || measure.basisId === undefined
3413
+ ? {}
3414
+ : { basisId: measure.basisId }),
3415
+ ...(measure.countPopulationId === null ||
3416
+ measure.countPopulationId === undefined
3417
+ ? {}
3418
+ : { countPopulationId: measure.countPopulationId }),
3419
+ ...(measure.exposureBasisId === null ||
3420
+ measure.exposureBasisId === undefined
3421
+ ? {}
3422
+ : { exposureBasisId: measure.exposureBasisId }),
3423
+ ...(measure.exposureTiming === null ||
3424
+ measure.exposureTiming === undefined
3425
+ ? {}
3426
+ : { exposureTiming: measure.exposureTiming }),
3427
+ })),
3428
+ countPopulations: definition.countPopulations.map((population) => ({
3429
+ ...population,
3430
+ })),
3431
+ exposureBases: definition.exposureBases.map((basis) => ({
3432
+ ...omitProperties(basis, ["sourceDescription"]),
3433
+ id: basis.id,
3434
+ displayName: basis.displayName,
3435
+ basis: basis.basis,
3436
+ unit: basis.unit,
3437
+ description: basis.description,
3438
+ ...(basis.sourceDescription === null ||
3439
+ basis.sourceDescription === undefined
3440
+ ? {}
3441
+ : { sourceDescription: basis.sourceDescription }),
3442
+ ...(basis.attributes === undefined
3443
+ ? {}
3444
+ : { attributes: basis.attributes }),
3445
+ })),
3446
+ amountBases: definition.amountBases.map((basis) => ({
3447
+ ...omitProperties(basis, ["sourceDescription"]),
3448
+ id: basis.id,
3449
+ displayName: basis.displayName,
3450
+ currency: basis.currency,
3451
+ perspective: basis.perspective,
3452
+ components: basis.components.map((component) => ({
3453
+ ...component,
3454
+ id: component.id,
3455
+ treatment: component.treatment,
3456
+ limitation:
3457
+ component.limitation.kind === "unknown"
3458
+ ? {
3459
+ ...omitProperties(component.limitation, ["description"]),
3460
+ kind: "unknown" as const,
3461
+ ...(component.limitation.description === null ||
3462
+ component.limitation.description === undefined
3463
+ ? {}
3464
+ : { description: component.limitation.description }),
3465
+ }
3466
+ : component.limitation,
3467
+ })),
3468
+ ...(basis.sourceDescription === null ||
3469
+ basis.sourceDescription === undefined
3470
+ ? {}
3471
+ : { sourceDescription: basis.sourceDescription }),
3472
+ ...(basis.attributes === undefined
3473
+ ? {}
3474
+ : { attributes: basis.attributes }),
3475
+ })),
3476
+ derivedMeasures: definition.derivedMeasures.map((derivation) => ({
3477
+ ...derivation,
3478
+ })),
3479
+ formulas: definition.formulas.map((formula) => ({
3480
+ ...formula,
3481
+ id: formula.id,
3482
+ version: formula.version,
3483
+ roles: Object.fromEntries(
3484
+ Object.entries(formula.roles).map(([name, role]) => [
3485
+ name,
3486
+ {
3487
+ ...omitProperties(role, [
3488
+ "compatibilityGroup",
3489
+ "developmentSemantics",
3490
+ ]),
3491
+ kind: role.kind,
3492
+ ...(role.compatibilityGroup === null ||
3493
+ role.compatibilityGroup === undefined
3494
+ ? {}
3495
+ : { compatibilityGroup: role.compatibilityGroup }),
3496
+ ...(role.developmentSemantics === null ||
3497
+ role.developmentSemantics === undefined
3498
+ ? {}
3499
+ : { developmentSemantics: role.developmentSemantics }),
3500
+ },
3501
+ ]),
3502
+ ),
3503
+ numerator: formula.numerator,
3504
+ denominator: formula.denominator,
3505
+ denominatorPolicy: formula.denominatorPolicy,
3506
+ })),
3507
+ instances: definition.instances.map((instance) => ({
3508
+ ...instance,
3509
+ id: instance.id,
3510
+ version: instance.version,
3511
+ formulaId: instance.formulaId,
3512
+ bindings: instance.bindings,
3513
+ presentation: instance.presentation,
3514
+ rules: instance.rules.map((rule) => ({
3515
+ ...rule,
3516
+ when: { ...rule.when, tolerance: { ...rule.when.tolerance } },
3517
+ })),
3518
+ })),
3519
+ reviewRules,
3520
+ periodAxis: definition.periodAxis,
3521
+ };
3522
+ }
3523
+
3524
+ /** Compiles, validates, normalizes, fingerprints, and freezes a diagnostic definition. */
3525
+ export function compileDiagnosticDefinition(
3526
+ value: DiagnosticDefinition | NormalizedDiagnosticDefinitionIdentity,
3527
+ ): CompiledDiagnosticDefinition {
3528
+ const boundaryIssues: DiagnosticValidationIssue[] = [];
3529
+ validateJsonValue(value, boundaryIssues);
3530
+ if (boundaryIssues.length > 0)
3531
+ throw new DiagnosticValidationError(boundaryIssues);
3532
+ let authored: DiagnosticDefinition;
3533
+ try {
3534
+ authored = authoredView(value);
3535
+ } catch {
3536
+ throw new DiagnosticValidationError([
3537
+ {
3538
+ domain: "definition",
3539
+ code: "invalid-type",
3540
+ path: "$",
3541
+ message:
3542
+ "Diagnostic definition does not have the required object and array structure",
3543
+ },
3544
+ ]);
3545
+ }
3546
+ let definition: DiagnosticDefinition;
3547
+ try {
3548
+ definition = validateDefinition(authored);
3549
+ } catch (error) {
3550
+ if (error instanceof DiagnosticValidationError) throw error;
3551
+ throw new DiagnosticValidationError([
3552
+ {
3553
+ domain: "definition",
3554
+ code: "invalid-type",
3555
+ path: "$",
3556
+ message: "Diagnostic definition contains a malformed nested value",
3557
+ },
3558
+ ]);
3559
+ }
3560
+ const normalized = normalizeDiagnosticDefinition(definition);
3561
+ const identities = buildDiagnosticIdentities(normalized);
3562
+ const compiledBase = {
3563
+ definition: normalized,
3564
+ formulaFingerprints: identities.formulaFingerprints,
3565
+ calculationFingerprints: identities.calculationFingerprints,
3566
+ definitionIntegrity: identities.definitionIntegrity,
3567
+ };
3568
+ Object.defineProperty(compiledBase, compiledDiagnosticDefinitionBrand, {
3569
+ value: true,
3570
+ enumerable: false,
3571
+ configurable: false,
3572
+ writable: false,
3573
+ });
3574
+ const compiled = compiledBase as CompiledDiagnosticDefinition;
3575
+ Object.freeze(compiled);
3576
+ authenticCompiledDefinitions.add(compiled);
3577
+ compiledInternals.set(compiled, {
3578
+ formulasById: new Map(
3579
+ normalized.formulas.map((formula) => [formula.id, formula]),
3580
+ ),
3581
+ measuresById: new Map(
3582
+ definition.measures.map((measure) => [measure.id, measure]),
3583
+ ),
3584
+ calculationScopesByInstanceId: identities.calculationScopesByInstanceId,
3585
+ calculationDependenciesByInstanceId:
3586
+ identities.calculationDependenciesByInstanceId,
3587
+ evaluationDependenciesByInstanceId:
3588
+ identities.evaluationDependenciesByInstanceId,
3589
+ derivationsByOutputMeasureId: new Map(
3590
+ normalized.derivedMeasures.map((derivation) => [
3591
+ derivation.outputMeasureId,
3592
+ derivation as DiagnosticDerivedMeasureDefinition,
3593
+ ]),
3594
+ ),
3595
+ });
3596
+ return compiled;
3597
+ }
3598
+
3599
+ export function assertCompiledDiagnosticDefinition(
3600
+ value: unknown,
3601
+ ): asserts value is CompiledDiagnosticDefinition {
3602
+ if (
3603
+ (typeof value !== "object" && typeof value !== "function") ||
3604
+ value === null ||
3605
+ !authenticCompiledDefinitions.has(value)
3606
+ ) {
3607
+ throw new DiagnosticValidationError([
3608
+ {
3609
+ domain: "definition",
3610
+ code: "invalid-input-relationship",
3611
+ path: "$",
3612
+ message: "Value is not an authentic compiled diagnostic definition",
3613
+ },
3614
+ ]);
3615
+ }
3616
+ }
3617
+
3618
+ /** @internal Shared only by core diagnostic runtime modules; not re-exported. */
3619
+ export function getCompiledDiagnosticDefinitionInternals(
3620
+ value: CompiledDiagnosticDefinition,
3621
+ ): CompiledDiagnosticDefinitionInternals {
3622
+ assertCompiledDiagnosticDefinition(value);
3623
+ return compiledInternals.get(value)!;
3624
+ }