@actuarial-ts/core 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +12 -3
  2. package/dist/canonical.d.ts +11 -0
  3. package/dist/canonical.d.ts.map +1 -1
  4. package/dist/canonical.js +136 -26
  5. package/dist/canonical.js.map +1 -1
  6. package/dist/capping.d.ts.map +1 -1
  7. package/dist/capping.js +39 -6
  8. package/dist/capping.js.map +1 -1
  9. package/dist/diagnosticAggregation.d.ts +3 -0
  10. package/dist/diagnosticAggregation.d.ts.map +1 -1
  11. package/dist/diagnosticAggregation.js +30 -7
  12. package/dist/diagnosticAggregation.js.map +1 -1
  13. package/dist/diagnosticDefinitions.d.ts +3 -0
  14. package/dist/diagnosticDefinitions.d.ts.map +1 -1
  15. package/dist/diagnosticDefinitions.js +844 -182
  16. package/dist/diagnosticDefinitions.js.map +1 -1
  17. package/dist/diagnosticDerivations.d.ts +23 -0
  18. package/dist/diagnosticDerivations.d.ts.map +1 -1
  19. package/dist/diagnosticDerivations.js +187 -43
  20. package/dist/diagnosticDerivations.js.map +1 -1
  21. package/dist/diagnosticEvidenceIntern.d.ts +16 -0
  22. package/dist/diagnosticEvidenceIntern.d.ts.map +1 -0
  23. package/dist/diagnosticEvidenceIntern.js +139 -0
  24. package/dist/diagnosticEvidenceIntern.js.map +1 -0
  25. package/dist/diagnosticExposure.d.ts +2 -2
  26. package/dist/diagnosticExposure.d.ts.map +1 -1
  27. package/dist/diagnosticExposure.js +319 -21
  28. package/dist/diagnosticExposure.js.map +1 -1
  29. package/dist/diagnosticExpressions.d.ts +1 -1
  30. package/dist/diagnosticExpressions.d.ts.map +1 -1
  31. package/dist/diagnosticExpressions.js +35 -15
  32. package/dist/diagnosticExpressions.js.map +1 -1
  33. package/dist/diagnosticFormulas.d.ts +3 -0
  34. package/dist/diagnosticFormulas.d.ts.map +1 -1
  35. package/dist/diagnosticFormulas.js +160 -26
  36. package/dist/diagnosticFormulas.js.map +1 -1
  37. package/dist/diagnosticIdentity.d.ts +15 -1
  38. package/dist/diagnosticIdentity.d.ts.map +1 -1
  39. package/dist/diagnosticIdentity.js +321 -51
  40. package/dist/diagnosticIdentity.js.map +1 -1
  41. package/dist/diagnosticIdentityStream.d.ts +56 -0
  42. package/dist/diagnosticIdentityStream.d.ts.map +1 -0
  43. package/dist/diagnosticIdentityStream.js +472 -0
  44. package/dist/diagnosticIdentityStream.js.map +1 -0
  45. package/dist/diagnosticOrdering.d.ts +8 -0
  46. package/dist/diagnosticOrdering.d.ts.map +1 -0
  47. package/dist/diagnosticOrdering.js +73 -0
  48. package/dist/diagnosticOrdering.js.map +1 -0
  49. package/dist/diagnosticPeriodAxis.d.ts +9 -0
  50. package/dist/diagnosticPeriodAxis.d.ts.map +1 -0
  51. package/dist/diagnosticPeriodAxis.js +69 -0
  52. package/dist/diagnosticPeriodAxis.js.map +1 -0
  53. package/dist/diagnosticPeriods.d.ts +2 -3
  54. package/dist/diagnosticPeriods.d.ts.map +1 -1
  55. package/dist/diagnosticPeriods.js +20 -68
  56. package/dist/diagnosticPeriods.js.map +1 -1
  57. package/dist/diagnosticPreparation.d.ts +75 -14
  58. package/dist/diagnosticPreparation.d.ts.map +1 -1
  59. package/dist/diagnosticPreparation.js +1606 -112
  60. package/dist/diagnosticPreparation.js.map +1 -1
  61. package/dist/diagnosticReview.d.ts +40 -15
  62. package/dist/diagnosticReview.d.ts.map +1 -1
  63. package/dist/diagnosticReview.js +600 -42
  64. package/dist/diagnosticReview.js.map +1 -1
  65. package/dist/diagnosticReviewSources.d.ts +18 -0
  66. package/dist/diagnosticReviewSources.d.ts.map +1 -0
  67. package/dist/diagnosticReviewSources.js +76 -0
  68. package/dist/diagnosticReviewSources.js.map +1 -0
  69. package/dist/diagnosticReviewStore.d.ts +88 -0
  70. package/dist/diagnosticReviewStore.d.ts.map +1 -0
  71. package/dist/diagnosticReviewStore.js +455 -0
  72. package/dist/diagnosticReviewStore.js.map +1 -0
  73. package/dist/diagnosticRunner.d.ts +27 -2
  74. package/dist/diagnosticRunner.d.ts.map +1 -1
  75. package/dist/diagnosticRunner.js +764 -70
  76. package/dist/diagnosticRunner.js.map +1 -1
  77. package/dist/diagnosticRuntime.d.ts +26 -0
  78. package/dist/diagnosticRuntime.d.ts.map +1 -0
  79. package/dist/diagnosticRuntime.js +243 -0
  80. package/dist/diagnosticRuntime.js.map +1 -0
  81. package/dist/diagnosticSourceOrdering.d.ts +6 -0
  82. package/dist/diagnosticSourceOrdering.d.ts.map +1 -0
  83. package/dist/diagnosticSourceOrdering.js +38 -0
  84. package/dist/diagnosticSourceOrdering.js.map +1 -0
  85. package/dist/fnvAccumulator.d.ts +6 -0
  86. package/dist/fnvAccumulator.d.ts.map +1 -0
  87. package/dist/fnvAccumulator.js +21 -0
  88. package/dist/fnvAccumulator.js.map +1 -0
  89. package/dist/index.d.ts +15 -8
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +10 -4
  92. package/dist/index.js.map +1 -1
  93. package/dist/mack.d.ts +4 -0
  94. package/dist/mack.d.ts.map +1 -1
  95. package/dist/mack.js +32 -4
  96. package/dist/mack.js.map +1 -1
  97. package/dist/triangle.d.ts.map +1 -1
  98. package/dist/triangle.js +59 -9
  99. package/dist/triangle.js.map +1 -1
  100. package/dist/types.d.ts +4 -0
  101. package/dist/types.d.ts.map +1 -1
  102. package/dist/types.js +22 -8
  103. package/dist/types.js.map +1 -1
  104. package/dist/version.d.ts +1 -1
  105. package/dist/version.js +1 -1
  106. package/package.json +3 -2
  107. package/src/canonical.ts +144 -26
  108. package/src/capping.ts +109 -18
  109. package/src/diagnosticAggregation.ts +69 -20
  110. package/src/diagnosticDefinitions.ts +2675 -426
  111. package/src/diagnosticDerivations.ts +281 -49
  112. package/src/diagnosticEvidenceIntern.ts +151 -0
  113. package/src/diagnosticExposure.ts +550 -50
  114. package/src/diagnosticExpressions.ts +99 -17
  115. package/src/diagnosticFormulas.ts +239 -39
  116. package/src/diagnosticIdentity.ts +700 -191
  117. package/src/diagnosticIdentityStream.ts +553 -0
  118. package/src/diagnosticOrdering.ts +106 -0
  119. package/src/diagnosticPeriodAxis.ts +92 -0
  120. package/src/diagnosticPeriods.ts +67 -82
  121. package/src/diagnosticPreparation.ts +2558 -132
  122. package/src/diagnosticReview.ts +1072 -58
  123. package/src/diagnosticReviewSources.ts +85 -0
  124. package/src/diagnosticReviewStore.ts +692 -0
  125. package/src/diagnosticRunner.ts +1273 -72
  126. package/src/diagnosticRuntime.ts +264 -0
  127. package/src/diagnosticSourceOrdering.ts +50 -0
  128. package/src/fnvAccumulator.ts +20 -0
  129. package/src/index.ts +69 -4
  130. package/src/mack.ts +72 -9
  131. package/src/triangle.ts +103 -13
  132. package/src/types.ts +55 -20
  133. package/src/version.ts +1 -1
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  MAX_DIAGNOSTIC_DEFINITION_EXPRESSION_NODES,
3
+ MAX_DIAGNOSTIC_EXPRESSION_NODES,
3
4
  walkDiagnosticExpression,
4
5
  type DiagnosticClaimExpression,
5
6
  type DiagnosticMeasureExpression,
@@ -18,6 +19,14 @@ import {
18
19
  type DiagnosticValidationIssueCode,
19
20
  } from "./types.js";
20
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";
21
30
 
22
31
  export type DiagnosticMeasureKind = "count" | "amount" | "exposure";
23
32
  export type DiagnosticMeasureSource = "loss" | "exposure" | "derived";
@@ -38,12 +47,11 @@ export type JsonValue =
38
47
  | readonly JsonValue[]
39
48
  | { readonly [key: string]: JsonValue };
40
49
 
41
- export type DiagnosticDeepReadonly<T> =
42
- T extends readonly (infer U)[]
43
- ? readonly DiagnosticDeepReadonly<U>[]
44
- : T extends object
45
- ? { readonly [K in keyof T]: DiagnosticDeepReadonly<T[K]> }
46
- : T;
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;
47
55
 
48
56
  export interface DiagnosticMeasureDefinition {
49
57
  id: string;
@@ -238,7 +246,10 @@ export type DiagnosticControlTotalProjection =
238
246
  | { kind: "latest-valuation-per-origin" }
239
247
  | { kind: "all-cells" };
240
248
 
241
- export type DiagnosticReviewFilter = Omit<DiagnosticsFilter, "outputGroups" | "instanceIds">;
249
+ export type DiagnosticReviewFilter = Omit<
250
+ DiagnosticsFilter,
251
+ "outputGroups" | "instanceIds"
252
+ >;
242
253
 
243
254
  export type DiagnosticReviewRule =
244
255
  | (DiagnosticReviewRuleBase & {
@@ -306,7 +317,9 @@ export interface DiagnosticMeasureStats {
306
317
  readonly structural: number;
307
318
  }
308
319
 
309
- const compiledDiagnosticDefinitionBrand: unique symbol = Symbol("compiled-diagnostic-definition");
320
+ const compiledDiagnosticDefinitionBrand: unique symbol = Symbol(
321
+ "compiled-diagnostic-definition",
322
+ );
310
323
 
311
324
  export interface CompiledDiagnosticDefinition {
312
325
  readonly [compiledDiagnosticDefinitionBrand]: true;
@@ -317,21 +330,37 @@ export interface CompiledDiagnosticDefinition {
317
330
  }
318
331
 
319
332
  export interface CompiledDiagnosticDefinitionInternals {
320
- readonly formulasById: ReadonlyMap<string, NormalizedDiagnosticFormulaIdentity>;
333
+ readonly formulasById: ReadonlyMap<
334
+ string,
335
+ NormalizedDiagnosticFormulaIdentity
336
+ >;
321
337
  readonly measuresById: ReadonlyMap<string, DiagnosticMeasureDefinition>;
322
- readonly calculationScopesByInstanceId: ReadonlyMap<string, NormalizedDiagnosticCalculationScope>;
323
- readonly calculationDependenciesByInstanceId: ReadonlyMap<string, readonly string[]>;
324
- readonly evaluationDependenciesByInstanceId: ReadonlyMap<string, readonly string[]>;
325
- readonly derivationsByOutputMeasureId: ReadonlyMap<string, DiagnosticDerivedMeasureDefinition>;
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
+ >;
326
354
  }
327
355
 
328
356
  const authenticCompiledDefinitions = new WeakSet<object>();
329
- const compiledInternals = new WeakMap<object, CompiledDiagnosticDefinitionInternals>();
357
+ const compiledInternals = new WeakMap<
358
+ object,
359
+ CompiledDiagnosticDefinitionInternals
360
+ >();
330
361
 
331
362
  function isPlainRecord(value: unknown): value is Record<string, unknown> {
332
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
333
- const prototype: unknown = Object.getPrototypeOf(value);
334
- return prototype === Object.prototype || prototype === null;
363
+ return isDiagnosticPlainRecord(value);
335
364
  }
336
365
 
337
366
  function propertyPath(parent: string, key: string): string {
@@ -349,17 +378,79 @@ function pushIssue(
349
378
  issues.push({ domain: "definition", code, path, message });
350
379
  }
351
380
 
352
- function hasWellFormedUtf16(value: string): boolean {
353
- for (let index = 0; index < value.length; index++) {
354
- const code = value.charCodeAt(index);
355
- if (code === 0) return false;
356
- if (code >= 0xd800 && code <= 0xdbff) {
357
- const next = value.charCodeAt(index + 1);
358
- if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
359
- index++;
360
- } else if (code >= 0xdc00 && code <= 0xdfff) return false;
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
+ }
361
453
  }
362
- return true;
363
454
  }
364
455
 
365
456
  function validateToken(
@@ -372,12 +463,22 @@ function validateToken(
372
463
  pushIssue(issues, "invalid-type", path, `${label} must be a string`);
373
464
  return false;
374
465
  }
375
- if (!hasWellFormedUtf16(value)) {
376
- pushIssue(issues, "invalid-string", path, `${label} contains an invalid Unicode string`);
466
+ if (!isWellFormedDiagnosticString(value)) {
467
+ pushIssue(
468
+ issues,
469
+ "invalid-string",
470
+ path,
471
+ `${label} contains an invalid Unicode string`,
472
+ );
377
473
  return false;
378
474
  }
379
- if (value.length === 0 || /^[\u0009-\u000d\u0020]|[\u0009-\u000d\u0020]$/.test(value)) {
380
- pushIssue(issues, "invalid-string", path, `${label} must be nonempty without surrounding ASCII whitespace`);
475
+ if (!isDiagnosticToken(value)) {
476
+ pushIssue(
477
+ issues,
478
+ "invalid-string",
479
+ path,
480
+ `${label} must be nonempty without surrounding ASCII whitespace`,
481
+ );
381
482
  return false;
382
483
  }
383
484
  return true;
@@ -396,67 +497,11 @@ function validateFinite(
396
497
  return true;
397
498
  }
398
499
 
399
- interface JsonFrame {
400
- readonly value: unknown;
401
- readonly path: string;
402
- readonly exiting: boolean;
403
- }
404
-
405
- function validateJsonValue(value: unknown, issues: DiagnosticValidationIssue[]): void {
406
- const active = new WeakSet<object>();
407
- const stack: JsonFrame[] = [{ value, path: "$", exiting: false }];
408
- while (stack.length > 0) {
409
- const frame = stack.pop()!;
410
- if (frame.value === null || typeof frame.value === "boolean") continue;
411
- if (typeof frame.value === "string") {
412
- if (!hasWellFormedUtf16(frame.value)) {
413
- pushIssue(issues, "invalid-json-value", frame.path, "String is not well-formed UTF-16 or contains U+0000");
414
- }
415
- continue;
416
- }
417
- if (typeof frame.value === "number") {
418
- if (!Number.isFinite(frame.value)) {
419
- pushIssue(issues, "invalid-json-value", frame.path, "JSON numeric value must be finite");
420
- }
421
- continue;
422
- }
423
- if (typeof frame.value !== "object" || frame.value === undefined) {
424
- pushIssue(issues, "invalid-json-value", frame.path, "Value is not plain JSON data");
425
- continue;
426
- }
427
- const object = frame.value as object;
428
- if (frame.exiting) {
429
- active.delete(object);
430
- continue;
431
- }
432
- if (active.has(object)) {
433
- pushIssue(issues, "cycle", frame.path, "JSON value contains a cycle");
434
- continue;
435
- }
436
- if (!Array.isArray(object) && !isPlainRecord(object)) {
437
- pushIssue(issues, "invalid-json-value", frame.path, "Value must use a plain object or array prototype");
438
- continue;
439
- }
440
- active.add(object);
441
- stack.push({ value: object, path: frame.path, exiting: true });
442
- if (Array.isArray(object)) {
443
- for (let index = object.length - 1; index >= 0; index--) {
444
- stack.push({ value: object[index], path: `${frame.path}[${index}]`, exiting: false });
445
- }
446
- } else {
447
- for (const key of Object.keys(object).sort().reverse()) {
448
- if (!hasWellFormedUtf16(key)) {
449
- pushIssue(issues, "invalid-json-value", propertyPath(frame.path, key), "Object key is not well-formed UTF-16 or contains U+0000");
450
- }
451
- const descriptor = Object.getOwnPropertyDescriptor(object, key);
452
- if (!descriptor || !("value" in descriptor)) {
453
- pushIssue(issues, "invalid-json-value", propertyPath(frame.path, key), "JSON objects may contain only data properties");
454
- continue;
455
- }
456
- stack.push({ value: descriptor.value, path: propertyPath(frame.path, key), exiting: false });
457
- }
458
- }
459
- }
500
+ function validateJsonValue(
501
+ value: unknown,
502
+ issues: DiagnosticValidationIssue[],
503
+ ): void {
504
+ issues.push(...diagnosticJsonPreflight(value, "definition"));
460
505
  }
461
506
 
462
507
  function requireArray(
@@ -489,7 +534,8 @@ function collectCatalog<T extends { readonly id: string }>(
489
534
 
490
535
  function semanticReference(measure: DiagnosticMeasureDefinition): string {
491
536
  if (measure.kind === "amount") return `amount:${measure.basisId ?? ""}`;
492
- if (measure.kind === "count") return `count:${measure.countPopulationId ?? ""}`;
537
+ if (measure.kind === "count")
538
+ return `count:${measure.countPopulationId ?? ""}`;
493
539
  return `exposure:${measure.exposureBasisId ?? ""}`;
494
540
  }
495
541
 
@@ -505,7 +551,10 @@ function transitiveMeasureIds(
505
551
  result.add(id);
506
552
  const derivation = derivations.get(id);
507
553
  if (derivation) {
508
- stack.push(...walkDiagnosticExpression(derivation.expression, "claim", "$", []).dependencies);
554
+ stack.push(
555
+ ...walkDiagnosticExpression(derivation.expression, "claim", "$", [])
556
+ .dependencies,
557
+ );
509
558
  }
510
559
  }
511
560
  return [...result].sort();
@@ -524,11 +573,15 @@ interface ProjectedClaimSemantics {
524
573
  readonly amountBasis?: ProjectedAmountBasis;
525
574
  }
526
575
 
527
- function projectedAmountBasis(basis: AmountBasisDefinition): ProjectedAmountBasis {
576
+ function projectedAmountBasis(
577
+ basis: AmountBasisDefinition,
578
+ ): ProjectedAmountBasis {
528
579
  return {
529
580
  currency: basis.currency,
530
581
  perspective: basis.perspective,
531
- components: [...basis.components].sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0),
582
+ components: [...basis.components].sort((left, right) =>
583
+ left.id < right.id ? -1 : left.id > right.id ? 1 : 0,
584
+ ),
532
585
  };
533
586
  }
534
587
 
@@ -547,23 +600,49 @@ function projectClaimExpression(
547
600
  const measure = measures.get(expression.measureId);
548
601
  if (!measure) return null;
549
602
  if (measure.kind !== "amount") {
550
- return { kind: measure.kind, unit: measure.unit, reference: semanticReference(measure) };
603
+ return {
604
+ kind: measure.kind,
605
+ unit: measure.unit,
606
+ reference: semanticReference(measure),
607
+ };
551
608
  }
552
- const basis = measure.basisId ? amountBases.get(measure.basisId) : undefined;
609
+ const basis = measure.basisId
610
+ ? amountBases.get(measure.basisId)
611
+ : undefined;
553
612
  return basis
554
- ? { kind: "amount", unit: measure.unit, reference: `amount:${measure.basisId}`, amountBasis: projectedAmountBasis(basis) }
613
+ ? {
614
+ kind: "amount",
615
+ unit: measure.unit,
616
+ reference: `amount:${measure.basisId}`,
617
+ amountBasis: projectedAmountBasis(basis),
618
+ }
555
619
  : null;
556
620
  }
557
621
  if (expression.op === "claim-layer") {
558
622
  const measure = measures.get(expression.measureId);
559
623
  if (!measure || measure.kind !== "amount" || !measure.basisId) {
560
- pushIssue(issues, "incompatible-semantics", path, "Claim layers require an amount measure");
624
+ pushIssue(
625
+ issues,
626
+ "incompatible-semantics",
627
+ path,
628
+ "Claim layers require an amount measure",
629
+ );
561
630
  return null;
562
631
  }
563
632
  const basis = amountBases.get(measure.basisId);
564
633
  const component = basis?.components[0];
565
- if (!basis || basis.components.length !== 1 || component?.treatment !== "included" || component.limitation.kind !== "unlimited") {
566
- pushIssue(issues, "incompatible-semantics", path, "Claim layers require one included unlimited source component");
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
+ );
567
646
  return null;
568
647
  }
569
648
  return {
@@ -573,56 +652,97 @@ function projectClaimExpression(
573
652
  amountBasis: {
574
653
  currency: basis.currency,
575
654
  perspective: basis.perspective,
576
- components: [{
577
- id: component.id,
578
- treatment: "included",
579
- limitation: {
580
- kind: "layer",
581
- attachment: expression.attachment,
582
- limit: expression.limit,
583
- application: "claim",
584
- derivation: { kind: "sdk" },
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
+ },
585
666
  },
586
- }],
667
+ ],
587
668
  },
588
669
  };
589
670
  }
590
- const children = expression.op === "add" ? expression.terms : [expression.left, expression.right];
591
- const projected = children.map((child, index) => projectClaimExpression(
592
- child,
593
- expression.op === "add" ? `${path}.terms[${index}]` : `${path}.${index === 0 ? "left" : "right"}`,
594
- measures,
595
- amountBases,
596
- issues,
597
- ));
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
+ );
598
686
  if (projected.some((item) => item === null)) return null;
599
687
  const present = projected as ProjectedClaimSemantics[];
600
688
  if (expression.op === "subtract") {
601
689
  const [left, right] = present;
602
- if (left!.kind !== right!.kind || left!.unit !== right!.unit ||
603
- (left!.kind === "amount"
604
- ? projectedBasisKey(left!.amountBasis!) !== projectedBasisKey(right!.amountBasis!)
605
- : left!.reference !== right!.reference)) {
606
- pushIssue(issues, "incompatible-semantics", path, "Claim subtraction requires one exact semantic basis");
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
+ );
607
704
  return null;
608
705
  }
609
706
  return left!;
610
707
  }
611
708
  const first = present[0]!;
612
- if (present.some((item) => item.kind !== first.kind || item.unit !== first.unit)) {
613
- pushIssue(issues, "incompatible-semantics", path, "Claim addition requires one kind and unit");
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
+ );
614
718
  return null;
615
719
  }
616
720
  if (first.kind !== "amount") {
617
721
  if (present.some((item) => item.reference !== first.reference)) {
618
- pushIssue(issues, "incompatible-semantics", path, "Claim addition requires one exact semantic basis");
722
+ pushIssue(
723
+ issues,
724
+ "incompatible-semantics",
725
+ path,
726
+ "Claim addition requires one exact semantic basis",
727
+ );
619
728
  return null;
620
729
  }
621
730
  return first;
622
731
  }
623
732
  const bases = present.map((item) => item.amountBasis!);
624
- if (bases.some((basis) => basis.currency !== bases[0]!.currency || basis.perspective !== bases[0]!.perspective)) {
625
- pushIssue(issues, "incompatible-semantics", path, "Amount addition cannot mix currency or perspective");
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
+ );
626
746
  return null;
627
747
  }
628
748
  const componentIds = new Set<string>();
@@ -630,7 +750,12 @@ function projectClaimExpression(
630
750
  for (const basis of bases) {
631
751
  for (const component of basis.components) {
632
752
  if (componentIds.has(component.id)) {
633
- pushIssue(issues, "incompatible-semantics", path, `Amount addition overlaps component ${component.id}`);
753
+ pushIssue(
754
+ issues,
755
+ "incompatible-semantics",
756
+ path,
757
+ `Amount addition overlaps component ${component.id}`,
758
+ );
634
759
  return null;
635
760
  }
636
761
  componentIds.add(component.id);
@@ -640,9 +765,16 @@ function projectClaimExpression(
640
765
  const amountBasis = {
641
766
  currency: bases[0]!.currency,
642
767
  perspective: bases[0]!.perspective,
643
- components: components.sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0),
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,
644
777
  };
645
- return { kind: "amount", unit: first.unit, reference: "amount:projected", amountBasis };
646
778
  }
647
779
 
648
780
  function expressionSemantics(
@@ -650,21 +782,40 @@ function expressionSemantics(
650
782
  path: string,
651
783
  measures: ReadonlyMap<string, DiagnosticMeasureDefinition>,
652
784
  issues: DiagnosticValidationIssue[],
653
- ): { readonly measureIds: readonly string[]; readonly signature: string | null } {
654
- const walked = walkDiagnosticExpression(expression, "measure", path, issues);
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
+ );
655
797
  const found = walked.dependencies
656
798
  .map((id) => measures.get(id))
657
- .filter((value): value is DiagnosticMeasureDefinition => value !== undefined);
799
+ .filter(
800
+ (value): value is DiagnosticMeasureDefinition => value !== undefined,
801
+ );
658
802
  for (const id of walked.dependencies) {
659
803
  if (!measures.has(id)) {
660
804
  pushIssue(issues, "unknown-reference", path, `Unknown measure ${id}`);
661
805
  }
662
806
  }
663
- const signatures = new Set(found.map((measure) =>
664
- `${measure.kind}\u0000${measure.unit}\u0000${semanticReference(measure)}`,
665
- ));
807
+ const signatures = new Set(
808
+ found.map((measure) =>
809
+ canonicalJson([measure.kind, measure.unit, semanticReference(measure)]),
810
+ ),
811
+ );
666
812
  if (signatures.size > 1) {
667
- pushIssue(issues, "incompatible-semantics", path, "Expression combines incompatible measure semantics");
813
+ pushIssue(
814
+ issues,
815
+ "incompatible-semantics",
816
+ path,
817
+ "Expression combines incompatible measure semantics",
818
+ );
668
819
  }
669
820
  return {
670
821
  measureIds: walked.dependencies,
@@ -680,60 +831,600 @@ function validateTolerance(
680
831
  for (const key of ["absolute", "relative"] as const) {
681
832
  const component = value?.[key];
682
833
  if (component === undefined) continue;
683
- if (!validateFinite(component, `${path}.${key}`, `${key} tolerance`, issues)) continue;
834
+ if (
835
+ !validateFinite(component, `${path}.${key}`, `${key} tolerance`, issues)
836
+ )
837
+ continue;
684
838
  if (component < 0) {
685
- pushIssue(issues, "invalid-number", `${path}.${key}`, `${key} tolerance must be nonnegative`);
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
+ );
686
928
  }
687
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
+ }
688
964
  }
689
965
 
690
- function validatePeriodAxis(axis: DiagnosticPeriodAxis, issues: DiagnosticValidationIssue[]): void {
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 {
691
1232
  if (!isPlainRecord(axis)) {
692
- pushIssue(issues, "invalid-type", "$.periodAxis", "Period axis must be an object");
1233
+ pushIssue(
1234
+ issues,
1235
+ "invalid-type",
1236
+ "$.periodAxis",
1237
+ "Period axis must be an object",
1238
+ );
693
1239
  return;
694
1240
  }
695
- if (!validateFinite(axis.ageOffset, "$.periodAxis.ageOffset", "Period age offset", issues) ||
696
- !Number.isSafeInteger(axis.ageOffset)) {
697
- pushIssue(issues, "invalid-number", "$.periodAxis.ageOffset", "Period age offset must be a safe integer");
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
+ );
698
1256
  }
699
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
+ );
700
1272
  if (!["month", "quarter", "year"].includes(axis.originCadence)) {
701
- pushIssue(issues, "invalid-period", "$.periodAxis.originCadence", "Unknown origin cadence");
1273
+ pushIssue(
1274
+ issues,
1275
+ "invalid-period",
1276
+ "$.periodAxis.originCadence",
1277
+ "Unknown origin cadence",
1278
+ );
702
1279
  }
703
1280
  if (!["month", "quarter", "year"].includes(axis.valuationCadence)) {
704
- pushIssue(issues, "invalid-period", "$.periodAxis.valuationCadence", "Unknown valuation cadence");
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
+ );
705
1303
  }
706
1304
  if (axis.ageUnit !== "month") {
707
- pushIssue(issues, "invalid-period", "$.periodAxis.ageUnit", "Calendar axes use month age units");
1305
+ pushIssue(
1306
+ issues,
1307
+ "invalid-period",
1308
+ "$.periodAxis.ageUnit",
1309
+ "Calendar axes use month age units",
1310
+ );
708
1311
  }
709
1312
  return;
710
1313
  }
1314
+ validateAllowedKeys(
1315
+ axis,
1316
+ ["kind", "id", "version", "ageUnit", "ageOffset", "origins", "valuations"],
1317
+ "$.periodAxis",
1318
+ issues,
1319
+ );
711
1320
  if (axis.kind !== "ordered") {
712
- pushIssue(issues, "invalid-period", "$.periodAxis.kind", "Unknown period-axis kind");
1321
+ pushIssue(
1322
+ issues,
1323
+ "invalid-period",
1324
+ "$.periodAxis.kind",
1325
+ "Unknown period-axis kind",
1326
+ );
713
1327
  return;
714
1328
  }
715
1329
  validateToken(axis.id, "$.periodAxis.id", "Ordered-axis ID", issues);
716
- validateToken(axis.version, "$.periodAxis.version", "Ordered-axis version", issues);
717
- validateToken(axis.ageUnit, "$.periodAxis.ageUnit", "Ordered-axis age unit", issues);
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
+ );
718
1342
  for (const side of ["origins", "valuations"] as const) {
719
- const coordinates = requireArray(axis[side], `$.periodAxis.${side}`, issues) as readonly DiagnosticPeriodCoordinate[];
1343
+ const coordinates = requireArray(
1344
+ axis[side],
1345
+ `$.periodAxis.${side}`,
1346
+ issues,
1347
+ ) as readonly DiagnosticPeriodCoordinate[];
720
1348
  const names = new Set<string>();
721
1349
  const coordinateValues = new Set<number>();
722
1350
  coordinates.forEach((coordinate, index) => {
723
1351
  const base = `$.periodAxis.${side}[${index}]`;
724
- if (validateToken(coordinate?.label, `${base}.label`, "Period label", issues)) {
725
- if (names.has(coordinate.label)) pushIssue(issues, "duplicate-id", `${base}.label`, `Duplicate period label or alias ${coordinate.label}`);
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
+ );
726
1382
  names.add(coordinate.label);
727
1383
  }
728
- if (!validateFinite(coordinate?.coordinate, `${base}.coordinate`, "Period coordinate", issues) ||
729
- !Number.isSafeInteger(coordinate.coordinate)) {
730
- pushIssue(issues, "invalid-number", `${base}.coordinate`, "Period coordinate must be a safe integer");
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
+ );
731
1399
  } else if (coordinateValues.has(coordinate.coordinate)) {
732
- pushIssue(issues, "duplicate-id", `${base}.coordinate`, `Duplicate period coordinate ${coordinate.coordinate}`);
1400
+ pushIssue(
1401
+ issues,
1402
+ "duplicate-id",
1403
+ `${base}.coordinate`,
1404
+ `Duplicate period coordinate ${coordinate.coordinate}`,
1405
+ );
733
1406
  } else coordinateValues.add(coordinate.coordinate);
734
- for (const [aliasIndex, alias] of (coordinate?.aliases ?? []).entries()) {
735
- if (!validateToken(alias, `${base}.aliases[${aliasIndex}]`, "Period alias", issues)) continue;
736
- if (names.has(alias)) pushIssue(issues, "duplicate-id", `${base}.aliases[${aliasIndex}]`, `Duplicate period label or alias ${alias}`);
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
+ );
737
1428
  names.add(alias);
738
1429
  }
739
1430
  });
@@ -744,37 +1435,121 @@ function validateDefinition(value: unknown): DiagnosticDefinition {
744
1435
  const issues: DiagnosticValidationIssue[] = [];
745
1436
  validateJsonValue(value, issues);
746
1437
  if (!isPlainRecord(value)) {
747
- pushIssue(issues, "invalid-type", "$", "Diagnostic definition must be a plain object");
1438
+ pushIssue(
1439
+ issues,
1440
+ "invalid-type",
1441
+ "$",
1442
+ "Diagnostic definition must be a plain object",
1443
+ );
748
1444
  throw new DiagnosticValidationError(issues);
749
1445
  }
750
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
+ );
751
1467
  if (definition.diagnosticDefinitionVersion !== "1.0.0") {
752
- pushIssue(issues, "invalid-type", "$.diagnosticDefinitionVersion", "Diagnostic definition version must be 1.0.0");
1468
+ pushIssue(
1469
+ issues,
1470
+ "invalid-type",
1471
+ "$.diagnosticDefinitionVersion",
1472
+ "Diagnostic definition version must be 1.0.0",
1473
+ );
753
1474
  }
754
1475
  validateToken(definition.id, "$.id", "Definition ID", issues);
755
1476
  validateToken(definition.version, "$.version", "Definition version", issues);
756
- if (definition.lossRowGrain !== "claim" && definition.lossRowGrain !== "aggregate") {
757
- pushIssue(issues, "invalid-type", "$.lossRowGrain", "Loss row grain must be claim or aggregate");
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
+ );
758
1487
  }
759
1488
 
760
- const measures = requireArray(definition.measures, "$.measures", issues) as readonly DiagnosticMeasureDefinition[];
761
- const populations = requireArray(definition.countPopulations, "$.countPopulations", issues) as readonly DiagnosticCountPopulationDefinition[];
762
- const exposureBases = requireArray(definition.exposureBases, "$.exposureBases", issues) as readonly DiagnosticExposureBasisDefinition[];
763
- const amountBases = requireArray(definition.amountBases, "$.amountBases", issues) as readonly AmountBasisDefinition[];
764
- const derivations = requireArray(definition.derivedMeasures, "$.derivedMeasures", issues) as readonly DiagnosticDerivedMeasureDefinition[];
765
- const formulas = requireArray(definition.formulas, "$.formulas", issues) as readonly DiagnosticFormulaTemplate[];
766
- const instances = requireArray(definition.instances, "$.instances", issues) as readonly DiagnosticMetricInstance[];
767
- const reviewRules = requireArray(definition.reviewRules, "$.reviewRules", issues) as readonly DiagnosticReviewRule[];
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[];
768
1529
 
769
1530
  const measureMap = collectCatalog(measures, "$.measures", issues);
770
- const populationMap = collectCatalog(populations, "$.countPopulations", issues);
771
- const exposureBasisMap = collectCatalog(exposureBases, "$.exposureBases", issues);
1531
+ const populationMap = collectCatalog(
1532
+ populations,
1533
+ "$.countPopulations",
1534
+ issues,
1535
+ );
1536
+ const exposureBasisMap = collectCatalog(
1537
+ exposureBases,
1538
+ "$.exposureBases",
1539
+ issues,
1540
+ );
772
1541
  const amountBasisMap = collectCatalog(amountBases, "$.amountBases", issues);
773
1542
  collectCatalog(derivations, "$.derivedMeasures", issues);
774
- const derivationsByOutput = new Map<string, DiagnosticDerivedMeasureDefinition>();
1543
+ const derivationsByOutput = new Map<
1544
+ string,
1545
+ DiagnosticDerivedMeasureDefinition
1546
+ >();
775
1547
  for (const derivation of derivations) {
776
- if (isPlainRecord(derivation) && typeof derivation.outputMeasureId === "string" &&
777
- !derivationsByOutput.has(derivation.outputMeasureId)) {
1548
+ if (
1549
+ isPlainRecord(derivation) &&
1550
+ typeof derivation.outputMeasureId === "string" &&
1551
+ !derivationsByOutput.has(derivation.outputMeasureId)
1552
+ ) {
778
1553
  derivationsByOutput.set(derivation.outputMeasureId, derivation);
779
1554
  }
780
1555
  }
@@ -784,135 +1559,600 @@ function validateDefinition(value: unknown): DiagnosticDefinition {
784
1559
 
785
1560
  populations.forEach((population, index) => {
786
1561
  const base = `$.countPopulations[${index}]`;
787
- validateToken(population.displayName, `${base}.displayName`, "Population display name", issues);
788
- validateToken(population.description, `${base}.description`, "Population description", issues);
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
+ );
789
1589
  validateToken(population.unit, `${base}.unit`, "Population unit", issues);
790
- if (!["claim", "claimant", "policy", "occurrence", "other", "unknown"].includes(population.subject)) {
791
- pushIssue(issues, "invalid-type", `${base}.subject`, "Unknown count-population subject");
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
+ );
792
1606
  }
1607
+ validateScalarAttributes(
1608
+ population.attributes,
1609
+ `${base}.attributes`,
1610
+ issues,
1611
+ );
793
1612
  });
794
1613
 
795
1614
  exposureBases.forEach((basis, index) => {
796
1615
  const base = `$.exposureBases[${index}]`;
797
- validateToken(basis.displayName, `${base}.displayName`, "Exposure-basis display name", issues);
798
- validateToken(basis.description, `${base}.description`, "Exposure-basis description", issues);
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
+ );
799
1651
  validateToken(basis.unit, `${base}.unit`, "Exposure-basis unit", issues);
800
- if (basis.sourceDescription !== undefined) validateToken(basis.sourceDescription, `${base}.sourceDescription`, "Exposure source description", issues);
801
- if (!["earned", "written", "in-force", "other", "unknown"].includes(basis.basis)) {
802
- pushIssue(issues, "invalid-type", `${base}.basis`, "Unknown exposure basis");
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
+ );
803
1670
  }
1671
+ validateScalarAttributes(basis.attributes, `${base}.attributes`, issues);
804
1672
  });
805
1673
 
806
1674
  amountBases.forEach((basis, index) => {
807
1675
  const base = `$.amountBases[${index}]`;
808
- validateToken(basis.displayName, `${base}.displayName`, "Amount-basis display name", issues);
809
- validateToken(basis.currency, `${base}.currency`, "Amount-basis currency", issues);
810
- if (basis.sourceDescription !== undefined) validateToken(basis.sourceDescription, `${base}.sourceDescription`, "Amount source description", issues);
811
- if (!["gross", "net", "ceded", "other", "unknown"].includes(basis.perspective)) {
812
- pushIssue(issues, "invalid-type", `${base}.perspective`, "Unknown amount perspective");
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
+ );
813
1722
  }
814
- const components = requireArray(basis.components, `${base}.components`, issues) as readonly AmountBasisComponent[];
815
- if (components.length === 0) pushIssue(issues, "missing-required", `${base}.components`, "Amount basis requires at least one component");
816
- const componentIds = collectCatalog(components, `${base}.components`, issues);
817
- if (componentIds.size > 0 && components.every((component) => component.treatment === "excluded")) {
818
- pushIssue(issues, "incompatible-semantics", `${base}.components`, "Amount basis cannot exclude every component");
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
+ );
819
1751
  }
820
1752
  components.forEach((component, componentIndex) => {
821
1753
  const componentBase = `${base}.components[${componentIndex}]`;
822
1754
  if (!isPlainRecord(component)) {
823
- pushIssue(issues, "invalid-type", componentBase, "Amount-basis component must be an object");
1755
+ pushIssue(
1756
+ issues,
1757
+ "invalid-type",
1758
+ componentBase,
1759
+ "Amount-basis component must be an object",
1760
+ );
824
1761
  return;
825
1762
  }
1763
+ validateAllowedKeys(
1764
+ component,
1765
+ ["id", "treatment", "limitation"],
1766
+ componentBase,
1767
+ issues,
1768
+ );
826
1769
  if (!["included", "excluded", "unknown"].includes(component.treatment)) {
827
- pushIssue(issues, "invalid-type", `${componentBase}.treatment`, "Unknown component treatment");
1770
+ pushIssue(
1771
+ issues,
1772
+ "invalid-type",
1773
+ `${componentBase}.treatment`,
1774
+ "Unknown component treatment",
1775
+ );
828
1776
  }
829
1777
  const limitation = component.limitation;
830
1778
  if (!isPlainRecord(limitation)) {
831
- pushIssue(issues, "invalid-type", `${componentBase}.limitation`, "Amount limitation must be an object");
1779
+ pushIssue(
1780
+ issues,
1781
+ "invalid-type",
1782
+ `${componentBase}.limitation`,
1783
+ "Amount limitation must be an object",
1784
+ );
832
1785
  return;
833
1786
  }
834
1787
  if (limitation.kind === "unknown") {
835
- if (limitation.description !== undefined) validateToken(limitation.description, `${componentBase}.limitation.description`, "Unknown limitation description", issues);
836
- } else if (limitation.kind === "layer" || limitation.kind === "pre-limited") {
837
- if (validateFinite(limitation.attachment, `${componentBase}.limitation.attachment`, "Attachment", issues) && limitation.attachment < 0) {
838
- pushIssue(issues, "invalid-number", `${componentBase}.limitation.attachment`, "Attachment must be nonnegative");
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
+ );
839
1838
  }
840
1839
  if (limitation.limit !== null) {
841
- if (validateFinite(limitation.limit, `${componentBase}.limitation.limit`, "Layer width", issues) && limitation.limit <= 0) {
842
- pushIssue(issues, "invalid-number", `${componentBase}.limitation.limit`, "Layer width must be positive");
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
+ );
843
1855
  }
844
- if (Number.isFinite(limitation.attachment) && Number.isFinite(limitation.limit) && !Number.isFinite(limitation.attachment + limitation.limit)) {
845
- pushIssue(issues, "invalid-number", `${componentBase}.limitation.limit`, "Attachment plus layer width must be finite");
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
+ );
846
1867
  }
847
1868
  }
848
1869
  if (limitation.derivation?.kind === "sdk") {
849
- if (limitation.kind !== "layer" || limitation.application !== "claim") {
850
- pushIssue(issues, "incompatible-semantics", `${componentBase}.limitation.derivation`, "SDK limitations require a claim layer");
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
+ );
851
1887
  }
852
1888
  } else if (limitation.derivation?.kind === "external") {
853
- validateToken(limitation.derivation.transformationRef, `${componentBase}.limitation.derivation.transformationRef`, "Transformation reference", issues);
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
+ );
854
1913
  } else {
855
- pushIssue(issues, "invalid-type", `${componentBase}.limitation.derivation`, "Limited basis requires a derivation record");
1914
+ pushIssue(
1915
+ issues,
1916
+ "invalid-type",
1917
+ `${componentBase}.limitation.derivation`,
1918
+ "Limited basis requires a derivation record",
1919
+ );
856
1920
  }
857
1921
  } else if (limitation.kind !== "unlimited") {
858
- pushIssue(issues, "invalid-type", `${componentBase}.limitation.kind`, "Unknown amount limitation kind");
859
- }
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
+ );
860
1935
  });
861
1936
  });
862
1937
 
863
1938
  measures.forEach((measure, index) => {
864
1939
  const base = `$.measures[${index}]`;
865
- validateToken(measure.displayName, `${base}.displayName`, "Measure display name", issues);
866
- validateToken(measure.description, `${base}.description`, "Measure description", issues);
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
+ );
867
1976
  validateToken(measure.unit, `${base}.unit`, "Measure unit", issues);
868
- if (measure.aggregation !== "sum") pushIssue(issues, "incompatible-semantics", `${base}.aggregation`, "Only sum aggregation is supported");
869
- if (measure.missing !== "unknown" && measure.missing !== "zero") pushIssue(issues, "invalid-type", `${base}.missing`, "Unknown missing-value policy");
870
- if (!["cumulative", "incremental", "point-in-time", "unknown"].includes(measure.developmentSemantics)) {
871
- pushIssue(issues, "invalid-type", `${base}.developmentSemantics`, "Unknown development semantics");
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
+ );
872
2002
  }
873
2003
  if (measure.kind === "amount") {
874
- const basis = measure.basisId ? amountBasisMap.get(measure.basisId) : undefined;
875
- if (!basis) pushIssue(issues, "unknown-reference", `${base}.basisId`, "Amount measure requires an existing amount basis");
876
- else if (measure.unit !== basis.currency) pushIssue(issues, "incompatible-semantics", `${base}.unit`, "Amount measure unit must equal basis currency");
877
- if (measure.countPopulationId !== undefined || measure.exposureBasisId !== undefined || measure.exposureTiming !== undefined) {
878
- pushIssue(issues, "incompatible-semantics", base, "Amount measure has inapplicable population or exposure semantics");
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
+ );
879
2032
  }
880
2033
  } else if (measure.kind === "count") {
881
- const population = measure.countPopulationId ? populationMap.get(measure.countPopulationId) : undefined;
882
- if (!population) pushIssue(issues, "unknown-reference", `${base}.countPopulationId`, "Count measure requires an existing count population");
883
- else if (measure.unit !== population.unit) pushIssue(issues, "incompatible-semantics", `${base}.unit`, "Count measure unit must equal population unit");
884
- if (measure.basisId !== undefined || measure.exposureBasisId !== undefined || measure.exposureTiming !== undefined) {
885
- pushIssue(issues, "incompatible-semantics", base, "Count measure has inapplicable amount or exposure semantics");
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
+ );
886
2062
  }
887
2063
  } else if (measure.kind === "exposure") {
888
- const basis = measure.exposureBasisId ? exposureBasisMap.get(measure.exposureBasisId) : undefined;
889
- if (!basis) pushIssue(issues, "unknown-reference", `${base}.exposureBasisId`, "Exposure measure requires an existing exposure basis");
890
- else if (measure.unit !== basis.unit) pushIssue(issues, "incompatible-semantics", `${base}.unit`, "Exposure measure unit must equal exposure-basis unit");
891
- if (measure.source !== "exposure") pushIssue(issues, "incompatible-semantics", `${base}.source`, "Exposure measure source must be exposure");
892
- if (measure.exposureTiming !== "origin-static" && measure.exposureTiming !== "valuation-specific") {
893
- pushIssue(issues, "missing-required", `${base}.exposureTiming`, "Exposure measure requires timing");
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
+ );
894
2098
  }
895
- if (measure.missing !== "unknown") pushIssue(issues, "incompatible-semantics", `${base}.missing`, "Exposure missingness must remain unknown");
896
- if (measure.basisId !== undefined || measure.countPopulationId !== undefined) {
897
- pushIssue(issues, "incompatible-semantics", base, "Exposure measure has inapplicable amount or population semantics");
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
+ );
898
2116
  }
899
2117
  } else {
900
2118
  pushIssue(issues, "invalid-type", `${base}.kind`, "Unknown measure kind");
901
2119
  }
902
- if (measure.kind !== "exposure" && measure.source !== "loss" && measure.source !== "derived") {
903
- pushIssue(issues, "invalid-type", `${base}.source`, "Non-exposure measure source must be loss or derived");
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
+ );
904
2131
  }
905
2132
  if (measure.kind !== "exposure" && measure.source === "exposure") {
906
- pushIssue(issues, "incompatible-semantics", `${base}.source`, "Only exposure measures may use exposure source");
2133
+ pushIssue(
2134
+ issues,
2135
+ "incompatible-semantics",
2136
+ `${base}.source`,
2137
+ "Only exposure measures may use exposure source",
2138
+ );
907
2139
  }
908
2140
  if (measure.kind === "amount" && measure.basisId) {
909
2141
  const basis = amountBasisMap.get(measure.basisId);
910
- const hasSdkLimitation = basis?.components.some((component) =>
911
- (component.limitation.kind === "layer" || component.limitation.kind === "pre-limited") &&
912
- component.limitation.derivation.kind === "sdk",
913
- ) ?? false;
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;
914
2149
  if (hasSdkLimitation && measure.source !== "derived") {
915
- pushIssue(issues, "incompatible-semantics", `${base}.source`, "SDK-limited amount measures must be derived");
2150
+ pushIssue(
2151
+ issues,
2152
+ "incompatible-semantics",
2153
+ `${base}.source`,
2154
+ "SDK-limited amount measures must be derived",
2155
+ );
916
2156
  }
917
2157
  }
918
2158
  });
@@ -921,89 +2161,308 @@ function validateDefinition(value: unknown): DiagnosticDefinition {
921
2161
  const formulaRoleUsage = new Map<string, readonly string[]>();
922
2162
  formulas.forEach((formula, index) => {
923
2163
  const base = `$.formulas[${index}]`;
924
- validateToken(formula.version, `${base}.version`, "Formula version", issues);
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
+ );
925
2187
  if (formula.denominatorPolicy !== "positive-or-null") {
926
- pushIssue(issues, "incompatible-semantics", `${base}.denominatorPolicy`, "Unknown denominator policy");
2188
+ pushIssue(
2189
+ issues,
2190
+ "incompatible-semantics",
2191
+ `${base}.denominatorPolicy`,
2192
+ "Unknown denominator policy",
2193
+ );
927
2194
  }
928
2195
  if (!isPlainRecord(formula.roles)) {
929
- pushIssue(issues, "invalid-type", `${base}.roles`, "Formula roles must be an object");
2196
+ pushIssue(
2197
+ issues,
2198
+ "invalid-type",
2199
+ `${base}.roles`,
2200
+ "Formula roles must be an object",
2201
+ );
930
2202
  return;
931
2203
  }
932
2204
  for (const [roleName, role] of Object.entries(formula.roles)) {
933
- validateToken(roleName, propertyPath(`${base}.roles`, roleName), "Formula role", issues);
934
- if (!isPlainRecord(role) || !["count", "amount", "exposure"].includes(role.kind)) {
935
- pushIssue(issues, "invalid-type", propertyPath(`${base}.roles`, roleName), "Formula role has an invalid kind");
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
+ }
936
2242
  }
937
- if (role.compatibilityGroup !== undefined) validateToken(role.compatibilityGroup, `${propertyPath(`${base}.roles`, roleName)}.compatibilityGroup`, "Compatibility group", issues);
2243
+ if (role.compatibilityGroup !== undefined)
2244
+ validateToken(
2245
+ role.compatibilityGroup,
2246
+ `${propertyPath(`${base}.roles`, roleName)}.compatibilityGroup`,
2247
+ "Compatibility group",
2248
+ issues,
2249
+ );
938
2250
  }
939
- const numerator = walkDiagnosticExpression(formula.numerator, "role", `${base}.numerator`, issues);
940
- const denominator = walkDiagnosticExpression(formula.denominator, "role", `${base}.denominator`, issues);
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
+ );
941
2263
  definitionExpressionNodes += numerator.nodeCount + denominator.nodeCount;
942
- const used = [...new Set([...numerator.dependencies, ...denominator.dependencies])].sort();
2264
+ const used = [
2265
+ ...new Set([...numerator.dependencies, ...denominator.dependencies]),
2266
+ ].sort();
943
2267
  formulaRoleUsage.set(formula.id, used);
944
2268
  for (const role of used) {
945
- if (!Object.prototype.hasOwnProperty.call(formula.roles, role)) {
946
- pushIssue(issues, "unknown-reference", base, `Formula ${formula.id} references unknown role ${role}`);
2269
+ if (!hasDiagnosticOwn(formula.roles, role)) {
2270
+ pushIssue(
2271
+ issues,
2272
+ "unknown-reference",
2273
+ base,
2274
+ `Formula ${formula.id} references unknown role ${role}`,
2275
+ );
947
2276
  }
948
2277
  }
949
2278
  for (const role of Object.keys(formula.roles)) {
950
- if (!used.includes(role)) pushIssue(issues, "incompatible-semantics", propertyPath(`${base}.roles`, role), `Formula role ${role} is unused`);
2279
+ if (!used.includes(role))
2280
+ pushIssue(
2281
+ issues,
2282
+ "incompatible-semantics",
2283
+ propertyPath(`${base}.roles`, role),
2284
+ `Formula role ${role} is unused`,
2285
+ );
951
2286
  }
952
- for (const [side, walked] of [["numerator", numerator], ["denominator", denominator]] as const) {
953
- const kinds = new Set(walked.dependencies.map((role) => formula.roles[role]?.kind).filter(Boolean));
954
- if (kinds.size > 1) pushIssue(issues, "incompatible-semantics", `${base}.${side}`, "Formula arithmetic combines different role kinds");
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
+ );
955
2303
  }
956
2304
  });
957
2305
 
958
2306
  const instanceRuleIds = new Set<string>();
959
2307
  instances.forEach((instance, index) => {
960
2308
  const base = `$.instances[${index}]`;
961
- validateToken(instance.version, `${base}.version`, "Instance version", issues);
962
- validateToken(instance.formulaId, `${base}.formulaId`, "Formula reference", issues);
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
+ );
963
2336
  const formula = formulaMap.get(instance.formulaId);
964
2337
  if (!formula) {
965
- pushIssue(issues, "unknown-reference", `${base}.formulaId`, `Unknown formula ${instance.formulaId}`);
2338
+ pushIssue(
2339
+ issues,
2340
+ "unknown-reference",
2341
+ `${base}.formulaId`,
2342
+ `Unknown formula ${instance.formulaId}`,
2343
+ );
966
2344
  return;
967
2345
  }
968
2346
  if (!isPlainRecord(instance.bindings)) {
969
- pushIssue(issues, "invalid-type", `${base}.bindings`, "Instance bindings must be an object");
2347
+ pushIssue(
2348
+ issues,
2349
+ "invalid-type",
2350
+ `${base}.bindings`,
2351
+ "Instance bindings must be an object",
2352
+ );
970
2353
  return;
971
2354
  }
972
2355
  const usedRoles = formulaRoleUsage.get(formula.id) ?? [];
973
- const bindingSemantics = new Map<string, ReturnType<typeof expressionSemantics>>();
2356
+ const bindingSemantics = new Map<
2357
+ string,
2358
+ ReturnType<typeof expressionSemantics>
2359
+ >();
974
2360
  for (const roleName of usedRoles) {
975
- if (!Object.prototype.hasOwnProperty.call(instance.bindings, roleName)) {
976
- pushIssue(issues, "missing-required", propertyPath(`${base}.bindings`, roleName), `Missing binding for role ${roleName}`);
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
+ );
977
2368
  continue;
978
2369
  }
979
2370
  const expression = instance.bindings[roleName]!;
980
- const semantics = expressionSemantics(expression, propertyPath(`${base}.bindings`, roleName), measureMap, issues);
2371
+ const semantics = expressionSemantics(
2372
+ expression,
2373
+ propertyPath(`${base}.bindings`, roleName),
2374
+ measureMap,
2375
+ issues,
2376
+ );
981
2377
  bindingSemantics.set(roleName, semantics);
982
- definitionExpressionNodes += walkDiagnosticExpression(expression, "measure", propertyPath(`${base}.bindings`, roleName), []).nodeCount;
2378
+ definitionExpressionNodes += walkDiagnosticExpression(
2379
+ expression,
2380
+ "measure",
2381
+ propertyPath(`${base}.bindings`, roleName),
2382
+ [],
2383
+ ).nodeCount;
983
2384
  const role = formula.roles[roleName]!;
984
- const boundMeasures = semantics.measureIds.map((id) => measureMap.get(id)).filter((item): item is DiagnosticMeasureDefinition => item !== undefined);
2385
+ const boundMeasures = semantics.measureIds
2386
+ .map((id) => measureMap.get(id))
2387
+ .filter(
2388
+ (item): item is DiagnosticMeasureDefinition => item !== undefined,
2389
+ );
985
2390
  if (boundMeasures.some((measure) => measure.kind !== role.kind)) {
986
- pushIssue(issues, "incompatible-semantics", propertyPath(`${base}.bindings`, roleName), `Binding kind does not match role ${roleName}`);
2391
+ pushIssue(
2392
+ issues,
2393
+ "incompatible-semantics",
2394
+ propertyPath(`${base}.bindings`, roleName),
2395
+ `Binding kind does not match role ${roleName}`,
2396
+ );
987
2397
  }
988
2398
  if (role.developmentSemantics !== undefined) {
989
- const leaves = transitiveMeasureIds(semantics.measureIds, derivationsByOutput)
2399
+ const leaves = transitiveMeasureIds(
2400
+ semantics.measureIds,
2401
+ derivationsByOutput,
2402
+ )
990
2403
  .map((id) => measureMap.get(id))
991
- .filter((measure): measure is DiagnosticMeasureDefinition => measure !== undefined);
992
- if (leaves.some((measure) => measure.developmentSemantics !== role.developmentSemantics)) {
993
- pushIssue(issues, "incompatible-semantics", propertyPath(`${base}.bindings`, roleName), `Transitive binding development semantics do not match role ${roleName}`);
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
+ );
994
2420
  }
995
2421
  }
996
2422
  }
997
- for (const [side, roleExpression] of [["numerator", formula.numerator], ["denominator", formula.denominator]] as const) {
998
- const roleIds = walkDiagnosticExpression(roleExpression, "role", `${base}.${side}`, []).dependencies;
999
- const signatures = new Set(roleIds.map((roleId) => bindingSemantics.get(roleId)?.signature).filter((signature): signature is string => signature !== null && signature !== undefined));
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
+ );
1000
2449
  if (signatures.size > 1) {
1001
- pushIssue(issues, "incompatible-semantics", `${base}.bindings`, `Bound ${side} arithmetic combines incompatible semantics`);
2450
+ pushIssue(
2451
+ issues,
2452
+ "incompatible-semantics",
2453
+ `${base}.bindings`,
2454
+ `Bound ${side} arithmetic combines incompatible semantics`,
2455
+ );
1002
2456
  }
1003
2457
  }
1004
2458
  for (const binding of Object.keys(instance.bindings)) {
1005
2459
  if (!usedRoles.includes(binding)) {
1006
- pushIssue(issues, "unknown-reference", propertyPath(`${base}.bindings`, binding), `Unexpected binding for role ${binding}`);
2460
+ pushIssue(
2461
+ issues,
2462
+ "unknown-reference",
2463
+ propertyPath(`${base}.bindings`, binding),
2464
+ `Unexpected binding for role ${binding}`,
2465
+ );
1007
2466
  }
1008
2467
  }
1009
2468
  const compatibility = new Map<string, string>();
@@ -1011,67 +2470,366 @@ function validateDefinition(value: unknown): DiagnosticDefinition {
1011
2470
  const role = formula.roles[roleName]!;
1012
2471
  const binding = instance.bindings[roleName];
1013
2472
  if (!role.compatibilityGroup || !binding) continue;
1014
- const semantics = expressionSemantics(binding, propertyPath(`${base}.bindings`, roleName), measureMap, []);
2473
+ const semantics = expressionSemantics(
2474
+ binding,
2475
+ propertyPath(`${base}.bindings`, roleName),
2476
+ measureMap,
2477
+ [],
2478
+ );
1015
2479
  if (semantics.signature === null) continue;
1016
2480
  const previous = compatibility.get(role.compatibilityGroup);
1017
2481
  if (previous !== undefined && previous !== semantics.signature) {
1018
- pushIssue(issues, "incompatible-semantics", propertyPath(`${base}.bindings`, roleName), `Compatibility group ${role.compatibilityGroup} binds different semantics`);
2482
+ pushIssue(
2483
+ issues,
2484
+ "incompatible-semantics",
2485
+ propertyPath(`${base}.bindings`, roleName),
2486
+ `Compatibility group ${role.compatibilityGroup} binds different semantics`,
2487
+ );
1019
2488
  } else compatibility.set(role.compatibilityGroup, semantics.signature);
1020
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
+ );
1021
2511
  for (const [key, text] of [
1022
2512
  ["displayName", instance.presentation?.displayName],
1023
2513
  ["description", instance.presentation?.description],
1024
2514
  ["displayUnit", instance.presentation?.displayUnit],
1025
2515
  ["numeratorLabel", instance.presentation?.numeratorLabel],
1026
2516
  ["denominatorLabel", instance.presentation?.denominatorLabel],
1027
- ] as const) validateToken(text, `${base}.presentation.${key}`, `Presentation ${key}`, issues);
1028
- if (!validateFinite(instance.presentation?.scale, `${base}.presentation.scale`, "Presentation scale", issues) || instance.presentation.scale <= 0) {
1029
- pushIssue(issues, "invalid-number", `${base}.presentation.scale`, "Presentation scale must be positive");
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
+ );
1030
2539
  }
1031
- requireArray(instance.rules, `${base}.rules`, issues).forEach((rawRule, ruleIndex) => {
1032
- const rule = rawRule as DiagnosticComparisonRule;
1033
- const ruleBase = `${base}.rules[${ruleIndex}]`;
1034
- if (validateToken(rule.id, `${ruleBase}.id`, "Metric rule ID", issues)) {
1035
- if (rule.id.startsWith("diagnostic/structural/")) pushIssue(issues, "incompatible-semantics", `${ruleBase}.id`, "Metric rule ID uses the reserved structural prefix");
1036
- if (instanceRuleIds.has(rule.id) || reviewRuleMap.has(rule.id)) pushIssue(issues, "duplicate-id", `${ruleBase}.id`, `Duplicate rule ID ${rule.id}`);
1037
- instanceRuleIds.add(rule.id);
1038
- }
1039
- validateToken(rule.code, `${ruleBase}.code`, "Metric rule code", issues);
1040
- validateToken(rule.message, `${ruleBase}.message`, "Metric rule message", issues);
1041
- validateTolerance(rule.when?.tolerance, `${ruleBase}.when.tolerance`, issues);
1042
- for (const [side, operand] of [["left", rule.when?.left], ["right", rule.when?.right]] as const) {
1043
- const operandPath = `${ruleBase}.when.${side}`;
1044
- if (operand?.source === "measure") {
1045
- const semantic = expressionSemantics(operand.expression, `${operandPath}.expression`, measureMap, issues);
1046
- definitionExpressionNodes += 1 + walkDiagnosticExpression(operand.expression, "measure", `${operandPath}.expression`, []).nodeCount;
1047
- void semantic;
1048
- } else {
1049
- definitionExpressionNodes += 1;
1050
- if (operand?.source === "constant") validateFinite(operand.value, `${operandPath}.value`, "Rule constant", issues);
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;
1051
2551
  }
1052
- }
1053
- });
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
+ );
1054
2740
  });
1055
2741
 
1056
2742
  const derivedOutputs = new Map<string, DiagnosticDerivedMeasureDefinition>();
1057
2743
  derivations.forEach((derivation, index) => {
1058
2744
  const base = `$.derivedMeasures[${index}]`;
1059
- validateToken(derivation.outputMeasureId, `${base}.outputMeasureId`, "Derived output measure", issues);
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
+ );
1060
2766
  const output = measureMap.get(derivation.outputMeasureId);
1061
- if (!output) pushIssue(issues, "unknown-reference", `${base}.outputMeasureId`, `Unknown derived output ${derivation.outputMeasureId}`);
1062
- else if (output.source !== "derived") pushIssue(issues, "incompatible-semantics", `${base}.outputMeasureId`, "Derivation output must declare source derived");
1063
- if (derivedOutputs.has(derivation.outputMeasureId)) pushIssue(issues, "duplicate-id", `${base}.outputMeasureId`, `Duplicate derived output ${derivation.outputMeasureId}`);
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
+ );
1064
2788
  derivedOutputs.set(derivation.outputMeasureId, derivation);
1065
- const walked = walkDiagnosticExpression(derivation.expression, "claim", `${base}.expression`, issues);
2789
+ const walked = walkDiagnosticExpression(
2790
+ derivation.expression,
2791
+ "claim",
2792
+ `${base}.expression`,
2793
+ issues,
2794
+ );
1066
2795
  definitionExpressionNodes += walked.nodeCount;
1067
- for (const id of walked.dependencies) if (!measureMap.has(id)) pushIssue(issues, "unknown-reference", `${base}.expression`, `Unknown derivation measure ${id}`);
1068
- const transitiveIds = transitiveMeasureIds(walked.dependencies, derivationsByOutput);
1069
- const transitiveInputs = transitiveIds.map((id) => measureMap.get(id)).filter((item): item is DiagnosticMeasureDefinition => item !== undefined);
1070
- if (output && transitiveInputs.some((input) => input.developmentSemantics !== output.developmentSemantics)) {
1071
- pushIssue(issues, "incompatible-semantics", `${base}.expression`, "Derived output and inputs must share development semantics");
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
+ );
1072
2825
  }
1073
2826
  if (definition.lossRowGrain !== "claim") {
1074
- pushIssue(issues, "incompatible-semantics", base, "Derived measures require claim-grain input");
2827
+ pushIssue(
2828
+ issues,
2829
+ "incompatible-semantics",
2830
+ base,
2831
+ "Derived measures require claim-grain input",
2832
+ );
1075
2833
  }
1076
2834
  const expressionStack: unknown[] = [derivation.expression];
1077
2835
  while (expressionStack.length > 0) {
@@ -1080,33 +2838,84 @@ function validateDefinition(value: unknown): DiagnosticDefinition {
1080
2838
  if (current.op === "claim-layer") {
1081
2839
  const attachment = current.attachment;
1082
2840
  const limit = current.limit;
1083
- const attachmentOk = validateFinite(attachment, `${base}.expression.attachment`, "Claim-layer attachment", issues);
1084
- if (attachmentOk && attachment < 0) pushIssue(issues, "invalid-number", `${base}.expression.attachment`, "Claim-layer attachment must be nonnegative");
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
+ );
1085
2854
  if (limit !== null) {
1086
- const limitOk = validateFinite(limit, `${base}.expression.limit`, "Claim-layer width", issues);
1087
- if (limitOk && limit <= 0) pushIssue(issues, "invalid-number", `${base}.expression.limit`, "Claim-layer width must be positive");
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
+ );
1088
2868
  if (attachmentOk && limitOk && !Number.isFinite(attachment + limit)) {
1089
- pushIssue(issues, "invalid-number", `${base}.expression.limit`, "Attachment plus claim-layer width must be finite");
2869
+ pushIssue(
2870
+ issues,
2871
+ "invalid-number",
2872
+ `${base}.expression.limit`,
2873
+ "Attachment plus claim-layer width must be finite",
2874
+ );
1090
2875
  }
1091
2876
  }
1092
- } else if (current.op === "add" && Array.isArray(current.terms)) expressionStack.push(...current.terms);
1093
- else if (current.op === "subtract") expressionStack.push(current.left, current.right);
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);
1094
2881
  }
1095
- const projection = projectClaimExpression(derivation.expression, `${base}.expression`, measureMap, amountBasisMap, issues);
2882
+ const projection = projectClaimExpression(
2883
+ derivation.expression,
2884
+ `${base}.expression`,
2885
+ measureMap,
2886
+ amountBasisMap,
2887
+ issues,
2888
+ );
1096
2889
  if (output && projection) {
1097
- const expected = output.kind === "amount" && output.basisId
1098
- ? amountBasisMap.get(output.basisId)
1099
- : undefined;
1100
- const matches = output.kind === projection.kind && output.unit === projection.unit &&
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 &&
1101
2897
  (output.kind === "amount"
1102
- ? expected !== undefined && projectedBasisKey(projectedAmountBasis(expected)) === projectedBasisKey(projection.amountBasis!)
2898
+ ? expected !== undefined &&
2899
+ projectedBasisKey(projectedAmountBasis(expected)) ===
2900
+ projectedBasisKey(projection.amountBasis!)
1103
2901
  : semanticReference(output) === projection.reference);
1104
- if (!matches) pushIssue(issues, "incompatible-semantics", `${base}.expression`, "Derived expression does not project the output measure semantics");
2902
+ if (!matches)
2903
+ pushIssue(
2904
+ issues,
2905
+ "incompatible-semantics",
2906
+ `${base}.expression`,
2907
+ "Derived expression does not project the output measure semantics",
2908
+ );
1105
2909
  }
1106
2910
  });
1107
2911
  for (const [measureId, measure] of measureMap) {
1108
2912
  if (measure.source === "derived" && !derivedOutputs.has(measureId)) {
1109
- pushIssue(issues, "missing-required", "$.derivedMeasures", `Derived measure ${measureId} requires exactly one derivation`);
2913
+ pushIssue(
2914
+ issues,
2915
+ "missing-required",
2916
+ "$.derivedMeasures",
2917
+ `Derived measure ${measureId} requires exactly one derivation`,
2918
+ );
1110
2919
  }
1111
2920
  }
1112
2921
  const visiting = new Set<string>();
@@ -1114,14 +2923,25 @@ function validateDefinition(value: unknown): DiagnosticDefinition {
1114
2923
  const visit = (id: string): void => {
1115
2924
  if (visited.has(id)) return;
1116
2925
  if (visiting.has(id)) {
1117
- pushIssue(issues, "cycle", "$.derivedMeasures", `Derived measure graph contains a cycle at ${id}`);
2926
+ pushIssue(
2927
+ issues,
2928
+ "cycle",
2929
+ "$.derivedMeasures",
2930
+ `Derived measure graph contains a cycle at ${id}`,
2931
+ );
1118
2932
  return;
1119
2933
  }
1120
2934
  visiting.add(id);
1121
2935
  const derivation = derivedOutputs.get(id);
1122
2936
  if (derivation) {
1123
- const deps = walkDiagnosticExpression(derivation.expression, "claim", "$.derivedMeasures", []).dependencies;
1124
- for (const dependency of deps) if (derivedOutputs.has(dependency)) visit(dependency);
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);
1125
2945
  }
1126
2946
  visiting.delete(id);
1127
2947
  visited.add(id);
@@ -1130,29 +2950,360 @@ function validateDefinition(value: unknown): DiagnosticDefinition {
1130
2950
 
1131
2951
  reviewRules.forEach((rule, index) => {
1132
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
+ }
1133
2995
  validateToken(rule.code, `${base}.code`, "Review rule code", issues);
1134
- validateToken(rule.description, `${base}.description`, "Review rule description", issues);
1135
- if (rule.id.startsWith("diagnostic/structural/")) pushIssue(issues, "incompatible-semantics", `${base}.id`, "Review rule ID uses the reserved structural prefix");
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
+ );
1136
3029
  validateTolerance(rule.tolerance, `${base}.tolerance`, issues);
1137
- const roots: DiagnosticMeasureExpression[] = [];
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
+ };
1138
3042
  if (rule.kind === "compare") {
1139
- if (rule.when.left.op !== "constant") roots.push(rule.when.left);
1140
- else definitionExpressionNodes++;
1141
- if (rule.when.right.op !== "constant") roots.push(rule.when.right);
1142
- else definitionExpressionNodes++;
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
+ }
1143
3069
  } else if (rule.kind === "reconcile") {
1144
- roots.push(rule.actual);
1145
- if (rule.expected.op !== "constant") roots.push(rule.expected);
1146
- else definitionExpressionNodes++;
1147
- } else if (rule.kind === "monotonic") roots.push(rule.expression);
1148
- else if (rule.kind === "layer-order") roots.push(rule.narrower, rule.broader);
1149
- else if (rule.kind === "control-total") {
1150
- roots.push(rule.expression);
1151
- validateFinite(rule.expected, `${base}.expected`, "Control total", issues);
1152
- } else pushIssue(issues, "invalid-type", `${base}.kind`, "Unknown review-rule kind");
1153
- roots.forEach((root, rootIndex) => {
1154
- expressionSemantics(root, `${base}.expression[${rootIndex}]`, measureMap, issues);
1155
- definitionExpressionNodes += walkDiagnosticExpression(root, "measure", `${base}.expression[${rootIndex}]`, []).nodeCount;
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
+ }
1156
3307
  });
1157
3308
  });
1158
3309
 
@@ -1169,10 +3320,33 @@ function validateDefinition(value: unknown): DiagnosticDefinition {
1169
3320
  return definition;
1170
3321
  }
1171
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
+
1172
3337
  function optionalFromNullable<T>(value: T | null | undefined): T | undefined {
1173
3338
  return value === null || value === undefined ? undefined : value;
1174
3339
  }
1175
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
+
1176
3350
  /**
1177
3351
  * The normalized wire projection materializes optional semantic fields as
1178
3352
  * null. Convert only those documented defaults back to the authored view so
@@ -1185,27 +3359,47 @@ function authoredView(
1185
3359
  const reviewRules = definition.reviewRules.map((rule) => {
1186
3360
  const tolerance = { ...rule.tolerance };
1187
3361
  if (rule.kind !== "control-total") return { ...rule, tolerance };
1188
- const filter = rule.filter === null
1189
- ? undefined
1190
- : {
1191
- sourceGroups: optionalFromNullable(rule.filter.sourceGroups),
1192
- origins: optionalFromNullable(rule.filter.origins),
1193
- originFrom: optionalFromNullable(rule.filter.originFrom),
1194
- originThrough: optionalFromNullable(rule.filter.originThrough),
1195
- valuations: optionalFromNullable(rule.filter.valuations),
1196
- valuationFrom: optionalFromNullable(rule.filter.valuationFrom),
1197
- valuationThrough: optionalFromNullable(rule.filter.valuationThrough),
1198
- minDevelopmentAge: optionalFromNullable(rule.filter.minDevelopmentAge),
1199
- maxDevelopmentAge: optionalFromNullable(rule.filter.maxDevelopmentAge),
1200
- };
1201
- return { ...rule, tolerance, ...(filter === undefined ? {} : { filter }) };
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;
1202
3389
  }) as readonly DiagnosticReviewRule[];
1203
3390
  return {
3391
+ ...definition,
1204
3392
  diagnosticDefinitionVersion: definition.diagnosticDefinitionVersion,
1205
3393
  id: definition.id,
1206
3394
  version: definition.version,
1207
3395
  lossRowGrain: definition.lossRowGrain,
1208
3396
  measures: definition.measures.map((measure) => ({
3397
+ ...omitProperties(measure, [
3398
+ "basisId",
3399
+ "countPopulationId",
3400
+ "exposureBasisId",
3401
+ "exposureTiming",
3402
+ ]),
1209
3403
  id: measure.id,
1210
3404
  displayName: measure.displayName,
1211
3405
  description: measure.description,
@@ -1215,69 +3409,103 @@ function authoredView(
1215
3409
  developmentSemantics: measure.developmentSemantics,
1216
3410
  aggregation: measure.aggregation,
1217
3411
  missing: measure.missing,
1218
- ...(measure.basisId === null || measure.basisId === undefined ? {} : { basisId: measure.basisId }),
1219
- ...(measure.countPopulationId === null || measure.countPopulationId === undefined
3412
+ ...(measure.basisId === null || measure.basisId === undefined
3413
+ ? {}
3414
+ : { basisId: measure.basisId }),
3415
+ ...(measure.countPopulationId === null ||
3416
+ measure.countPopulationId === undefined
1220
3417
  ? {}
1221
3418
  : { countPopulationId: measure.countPopulationId }),
1222
- ...(measure.exposureBasisId === null || measure.exposureBasisId === undefined
3419
+ ...(measure.exposureBasisId === null ||
3420
+ measure.exposureBasisId === undefined
1223
3421
  ? {}
1224
3422
  : { exposureBasisId: measure.exposureBasisId }),
1225
- ...(measure.exposureTiming === null || measure.exposureTiming === undefined
3423
+ ...(measure.exposureTiming === null ||
3424
+ measure.exposureTiming === undefined
1226
3425
  ? {}
1227
3426
  : { exposureTiming: measure.exposureTiming }),
1228
3427
  })),
1229
- countPopulations: definition.countPopulations.map((population) => ({ ...population })),
3428
+ countPopulations: definition.countPopulations.map((population) => ({
3429
+ ...population,
3430
+ })),
1230
3431
  exposureBases: definition.exposureBases.map((basis) => ({
3432
+ ...omitProperties(basis, ["sourceDescription"]),
1231
3433
  id: basis.id,
1232
3434
  displayName: basis.displayName,
1233
3435
  basis: basis.basis,
1234
3436
  unit: basis.unit,
1235
3437
  description: basis.description,
1236
- ...(basis.sourceDescription === null || basis.sourceDescription === undefined
3438
+ ...(basis.sourceDescription === null ||
3439
+ basis.sourceDescription === undefined
1237
3440
  ? {}
1238
3441
  : { sourceDescription: basis.sourceDescription }),
1239
- ...(basis.attributes === undefined ? {} : { attributes: basis.attributes }),
3442
+ ...(basis.attributes === undefined
3443
+ ? {}
3444
+ : { attributes: basis.attributes }),
1240
3445
  })),
1241
3446
  amountBases: definition.amountBases.map((basis) => ({
3447
+ ...omitProperties(basis, ["sourceDescription"]),
1242
3448
  id: basis.id,
1243
3449
  displayName: basis.displayName,
1244
3450
  currency: basis.currency,
1245
3451
  perspective: basis.perspective,
1246
3452
  components: basis.components.map((component) => ({
3453
+ ...component,
1247
3454
  id: component.id,
1248
3455
  treatment: component.treatment,
1249
- limitation: component.limitation.kind === "unknown"
1250
- ? {
1251
- kind: "unknown" as const,
1252
- ...(component.limitation.description === null || component.limitation.description === undefined
1253
- ? {}
1254
- : { description: component.limitation.description }),
1255
- }
1256
- : component.limitation,
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,
1257
3467
  })),
1258
- ...(basis.sourceDescription === null || basis.sourceDescription === undefined
3468
+ ...(basis.sourceDescription === null ||
3469
+ basis.sourceDescription === undefined
1259
3470
  ? {}
1260
3471
  : { sourceDescription: basis.sourceDescription }),
1261
- ...(basis.attributes === undefined ? {} : { attributes: basis.attributes }),
3472
+ ...(basis.attributes === undefined
3473
+ ? {}
3474
+ : { attributes: basis.attributes }),
3475
+ })),
3476
+ derivedMeasures: definition.derivedMeasures.map((derivation) => ({
3477
+ ...derivation,
1262
3478
  })),
1263
- derivedMeasures: definition.derivedMeasures.map((derivation) => ({ ...derivation })),
1264
3479
  formulas: definition.formulas.map((formula) => ({
3480
+ ...formula,
1265
3481
  id: formula.id,
1266
3482
  version: formula.version,
1267
- roles: Object.fromEntries(Object.entries(formula.roles).map(([name, role]) => [name, {
1268
- kind: role.kind,
1269
- ...(role.compatibilityGroup === null || role.compatibilityGroup === undefined
1270
- ? {}
1271
- : { compatibilityGroup: role.compatibilityGroup }),
1272
- ...(role.developmentSemantics === null || role.developmentSemantics === undefined
1273
- ? {}
1274
- : { developmentSemantics: role.developmentSemantics }),
1275
- }])),
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
+ ),
1276
3503
  numerator: formula.numerator,
1277
3504
  denominator: formula.denominator,
1278
3505
  denominatorPolicy: formula.denominatorPolicy,
1279
3506
  })),
1280
3507
  instances: definition.instances.map((instance) => ({
3508
+ ...instance,
1281
3509
  id: instance.id,
1282
3510
  version: instance.version,
1283
3511
  formulaId: instance.formulaId,
@@ -1299,29 +3527,35 @@ export function compileDiagnosticDefinition(
1299
3527
  ): CompiledDiagnosticDefinition {
1300
3528
  const boundaryIssues: DiagnosticValidationIssue[] = [];
1301
3529
  validateJsonValue(value, boundaryIssues);
1302
- if (boundaryIssues.length > 0) throw new DiagnosticValidationError(boundaryIssues);
3530
+ if (boundaryIssues.length > 0)
3531
+ throw new DiagnosticValidationError(boundaryIssues);
1303
3532
  let authored: DiagnosticDefinition;
1304
3533
  try {
1305
3534
  authored = authoredView(value);
1306
3535
  } catch {
1307
- throw new DiagnosticValidationError([{
1308
- domain: "definition",
1309
- code: "invalid-type",
1310
- path: "$",
1311
- message: "Diagnostic definition does not have the required object and array structure",
1312
- }]);
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
+ ]);
1313
3545
  }
1314
3546
  let definition: DiagnosticDefinition;
1315
3547
  try {
1316
3548
  definition = validateDefinition(authored);
1317
3549
  } catch (error) {
1318
3550
  if (error instanceof DiagnosticValidationError) throw error;
1319
- throw new DiagnosticValidationError([{
1320
- domain: "definition",
1321
- code: "invalid-type",
1322
- path: "$",
1323
- message: "Diagnostic definition contains a malformed nested value",
1324
- }]);
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
+ ]);
1325
3559
  }
1326
3560
  const normalized = normalizeDiagnosticDefinition(definition);
1327
3561
  const identities = buildDiagnosticIdentities(normalized);
@@ -1341,13 +3575,22 @@ export function compileDiagnosticDefinition(
1341
3575
  Object.freeze(compiled);
1342
3576
  authenticCompiledDefinitions.add(compiled);
1343
3577
  compiledInternals.set(compiled, {
1344
- formulasById: new Map(normalized.formulas.map((formula) => [formula.id, formula])),
1345
- measuresById: new Map(definition.measures.map((measure) => [measure.id, measure])),
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
+ ),
1346
3584
  calculationScopesByInstanceId: identities.calculationScopesByInstanceId,
1347
- calculationDependenciesByInstanceId: identities.calculationDependenciesByInstanceId,
1348
- evaluationDependenciesByInstanceId: identities.evaluationDependenciesByInstanceId,
3585
+ calculationDependenciesByInstanceId:
3586
+ identities.calculationDependenciesByInstanceId,
3587
+ evaluationDependenciesByInstanceId:
3588
+ identities.evaluationDependenciesByInstanceId,
1349
3589
  derivationsByOutputMeasureId: new Map(
1350
- normalized.derivedMeasures.map((derivation) => [derivation.outputMeasureId, derivation as DiagnosticDerivedMeasureDefinition]),
3590
+ normalized.derivedMeasures.map((derivation) => [
3591
+ derivation.outputMeasureId,
3592
+ derivation as DiagnosticDerivedMeasureDefinition,
3593
+ ]),
1351
3594
  ),
1352
3595
  });
1353
3596
  return compiled;
@@ -1356,13 +3599,19 @@ export function compileDiagnosticDefinition(
1356
3599
  export function assertCompiledDiagnosticDefinition(
1357
3600
  value: unknown,
1358
3601
  ): asserts value is CompiledDiagnosticDefinition {
1359
- if ((typeof value !== "object" && typeof value !== "function") || value === null || !authenticCompiledDefinitions.has(value)) {
1360
- throw new DiagnosticValidationError([{
1361
- domain: "definition",
1362
- code: "invalid-input-relationship",
1363
- path: "$",
1364
- message: "Value is not an authentic compiled diagnostic definition",
1365
- }]);
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
+ ]);
1366
3615
  }
1367
3616
  }
1368
3617