@actuarial-ts/core 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +3 -3
  2. package/dist/capping.d.ts.map +1 -1
  3. package/dist/capping.js +39 -6
  4. package/dist/capping.js.map +1 -1
  5. package/dist/diagnosticAggregation.d.ts +3 -0
  6. package/dist/diagnosticAggregation.d.ts.map +1 -1
  7. package/dist/diagnosticAggregation.js +30 -7
  8. package/dist/diagnosticAggregation.js.map +1 -1
  9. package/dist/diagnosticDefinitions.d.ts +3 -0
  10. package/dist/diagnosticDefinitions.d.ts.map +1 -1
  11. package/dist/diagnosticDefinitions.js +844 -182
  12. package/dist/diagnosticDefinitions.js.map +1 -1
  13. package/dist/diagnosticDerivations.d.ts +23 -0
  14. package/dist/diagnosticDerivations.d.ts.map +1 -1
  15. package/dist/diagnosticDerivations.js +187 -43
  16. package/dist/diagnosticDerivations.js.map +1 -1
  17. package/dist/diagnosticExposure.d.ts +2 -2
  18. package/dist/diagnosticExposure.d.ts.map +1 -1
  19. package/dist/diagnosticExposure.js +228 -20
  20. package/dist/diagnosticExposure.js.map +1 -1
  21. package/dist/diagnosticExpressions.d.ts +1 -1
  22. package/dist/diagnosticExpressions.d.ts.map +1 -1
  23. package/dist/diagnosticExpressions.js +35 -15
  24. package/dist/diagnosticExpressions.js.map +1 -1
  25. package/dist/diagnosticFormulas.d.ts +3 -0
  26. package/dist/diagnosticFormulas.d.ts.map +1 -1
  27. package/dist/diagnosticFormulas.js +160 -26
  28. package/dist/diagnosticFormulas.js.map +1 -1
  29. package/dist/diagnosticIdentity.d.ts +15 -1
  30. package/dist/diagnosticIdentity.d.ts.map +1 -1
  31. package/dist/diagnosticIdentity.js +318 -51
  32. package/dist/diagnosticIdentity.js.map +1 -1
  33. package/dist/diagnosticOrdering.d.ts +8 -0
  34. package/dist/diagnosticOrdering.d.ts.map +1 -0
  35. package/dist/diagnosticOrdering.js +73 -0
  36. package/dist/diagnosticOrdering.js.map +1 -0
  37. package/dist/diagnosticPeriodAxis.d.ts +9 -0
  38. package/dist/diagnosticPeriodAxis.d.ts.map +1 -0
  39. package/dist/diagnosticPeriodAxis.js +69 -0
  40. package/dist/diagnosticPeriodAxis.js.map +1 -0
  41. package/dist/diagnosticPeriods.d.ts +2 -3
  42. package/dist/diagnosticPeriods.d.ts.map +1 -1
  43. package/dist/diagnosticPeriods.js +20 -68
  44. package/dist/diagnosticPeriods.js.map +1 -1
  45. package/dist/diagnosticPreparation.d.ts +47 -14
  46. package/dist/diagnosticPreparation.d.ts.map +1 -1
  47. package/dist/diagnosticPreparation.js +1482 -112
  48. package/dist/diagnosticPreparation.js.map +1 -1
  49. package/dist/diagnosticReview.d.ts +36 -14
  50. package/dist/diagnosticReview.d.ts.map +1 -1
  51. package/dist/diagnosticReview.js +448 -42
  52. package/dist/diagnosticReview.js.map +1 -1
  53. package/dist/diagnosticRunner.d.ts +2 -1
  54. package/dist/diagnosticRunner.d.ts.map +1 -1
  55. package/dist/diagnosticRunner.js +656 -70
  56. package/dist/diagnosticRunner.js.map +1 -1
  57. package/dist/diagnosticRuntime.d.ts +26 -0
  58. package/dist/diagnosticRuntime.d.ts.map +1 -0
  59. package/dist/diagnosticRuntime.js +243 -0
  60. package/dist/diagnosticRuntime.js.map +1 -0
  61. package/dist/diagnosticSourceOrdering.d.ts +6 -0
  62. package/dist/diagnosticSourceOrdering.d.ts.map +1 -0
  63. package/dist/diagnosticSourceOrdering.js +38 -0
  64. package/dist/diagnosticSourceOrdering.js.map +1 -0
  65. package/dist/index.d.ts +8 -4
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +5 -1
  68. package/dist/index.js.map +1 -1
  69. package/dist/mack.d.ts +4 -0
  70. package/dist/mack.d.ts.map +1 -1
  71. package/dist/mack.js +32 -4
  72. package/dist/mack.js.map +1 -1
  73. package/dist/triangle.d.ts.map +1 -1
  74. package/dist/triangle.js +59 -9
  75. package/dist/triangle.js.map +1 -1
  76. package/dist/types.d.ts +4 -0
  77. package/dist/types.d.ts.map +1 -1
  78. package/dist/types.js +22 -8
  79. package/dist/types.js.map +1 -1
  80. package/dist/version.d.ts +1 -1
  81. package/dist/version.js +1 -1
  82. package/package.json +3 -2
  83. package/src/capping.ts +109 -18
  84. package/src/diagnosticAggregation.ts +69 -20
  85. package/src/diagnosticDefinitions.ts +2675 -426
  86. package/src/diagnosticDerivations.ts +281 -49
  87. package/src/diagnosticExposure.ts +441 -50
  88. package/src/diagnosticExpressions.ts +99 -17
  89. package/src/diagnosticFormulas.ts +239 -39
  90. package/src/diagnosticIdentity.ts +691 -191
  91. package/src/diagnosticOrdering.ts +106 -0
  92. package/src/diagnosticPeriodAxis.ts +92 -0
  93. package/src/diagnosticPeriods.ts +67 -82
  94. package/src/diagnosticPreparation.ts +2369 -127
  95. package/src/diagnosticReview.ts +864 -58
  96. package/src/diagnosticRunner.ts +1076 -72
  97. package/src/diagnosticRuntime.ts +264 -0
  98. package/src/diagnosticSourceOrdering.ts +50 -0
  99. package/src/index.ts +31 -3
  100. package/src/mack.ts +72 -9
  101. package/src/triangle.ts +103 -13
  102. package/src/types.ts +55 -20
  103. package/src/version.ts +1 -1
@@ -1,91 +1,897 @@
1
- import { getCompiledDiagnosticDefinitionInternals, type DiagnosticReviewRule, type DiagnosticSourceLocation } from "./diagnosticDefinitions.js";
2
- import { evaluateDiagnosticMeasureExpression, type FinalizedDiagnosticMeasure } from "./diagnosticFormulas.js";
3
- import { assertPreparedDiagnosticData, type PreparedDiagnosticData } from "./diagnosticPreparation.js";
4
- import { classifyDiagnosticComparison, diagnosticPredicateMatches, type DiagnosticRuleNotEvaluatedReason } from "./diagnosticRules.js";
1
+ import {
2
+ getCompiledDiagnosticDefinitionInternals,
3
+ type DiagnosticControlTotalProjection,
4
+ type DiagnosticDeepReadonly,
5
+ type DiagnosticReviewFilter,
6
+ type DiagnosticReviewRule,
7
+ type DiagnosticSourceLocation,
8
+ } from "./diagnosticDefinitions.js";
9
+ import type { NormalizedDiagnosticReviewFilterIdentity } from "./diagnosticIdentity.js";
10
+ import { canonicalJson } from "./canonical.js";
11
+ import { normalizeDiagnosticPeriod } from "./diagnosticPeriods.js";
12
+ import {
13
+ evaluateDiagnosticMeasureExpression,
14
+ type FinalizedDiagnosticMeasure,
15
+ } from "./diagnosticFormulas.js";
16
+ import {
17
+ assertPreparedDiagnosticData,
18
+ type PreparedDiagnosticData,
19
+ } from "./diagnosticPreparation.js";
20
+ import {
21
+ classifyDiagnosticComparison,
22
+ diagnosticPredicateMatches,
23
+ type DiagnosticExpressionOverflow,
24
+ type DiagnosticRuleNotEvaluatedReason,
25
+ } from "./diagnosticRules.js";
26
+ import { finalizeDiagnosticContributions } from "./diagnosticAggregation.js";
27
+ import {
28
+ compareDiagnosticSourceLocations,
29
+ normalizeDiagnosticSourceLocations,
30
+ } from "./diagnosticSourceOrdering.js";
31
+
32
+ export interface DiagnosticReviewCoordinate {
33
+ readonly sourceGroup: string;
34
+ readonly origin: string;
35
+ readonly valuation: string;
36
+ readonly developmentAge: number;
37
+ readonly ageUnit: string;
38
+ }
39
+
40
+ export type DiagnosticCellReviewScope = {
41
+ readonly kind: "cell";
42
+ readonly cell: DiagnosticReviewCoordinate;
43
+ readonly sources: readonly DiagnosticSourceLocation[];
44
+ };
45
+ export type DiagnosticValuationPairReviewScope = {
46
+ readonly kind: "valuation-pair";
47
+ readonly previous: DiagnosticReviewCoordinate;
48
+ readonly current: DiagnosticReviewCoordinate;
49
+ readonly sources: readonly DiagnosticSourceLocation[];
50
+ };
51
+ export type DiagnosticControlTotalReviewScope = {
52
+ readonly kind: "control-total";
53
+ readonly projection: DiagnosticDeepReadonly<DiagnosticControlTotalProjection>;
54
+ readonly filter: NormalizedDiagnosticReviewFilterIdentity | null;
55
+ readonly selectedCellCount: number;
56
+ readonly selectedContributionCount: number;
57
+ readonly sources: readonly DiagnosticSourceLocation[];
58
+ };
5
59
 
6
- export interface DiagnosticReviewCoordinate { readonly sourceGroup: string; readonly origin: string; readonly valuation: string; readonly developmentAge: number; readonly ageUnit: string }
7
60
  export type DiagnosticReviewEvaluationScope =
8
- | { readonly kind: "cell"; readonly coordinate: DiagnosticReviewCoordinate; readonly sources: readonly DiagnosticSourceLocation[] }
9
- | { readonly kind: "valuation-pair"; readonly previous: DiagnosticReviewCoordinate; readonly current: DiagnosticReviewCoordinate; readonly sources: readonly DiagnosticSourceLocation[] }
10
- | { readonly kind: "control-total"; readonly selectedCellCount: number; readonly selectedContributionCount: number; readonly sources: readonly DiagnosticSourceLocation[] };
11
- export interface DiagnosticReviewRuleEvaluation {
61
+ | DiagnosticCellReviewScope
62
+ | DiagnosticValuationPairReviewScope
63
+ | DiagnosticControlTotalReviewScope;
64
+
65
+ export interface DiagnosticReviewExpressionOverflow
66
+ extends DiagnosticExpressionOverflow {
67
+ readonly coordinate: DiagnosticReviewCoordinate | null;
68
+ }
69
+
70
+ export interface DiagnosticReviewRuleEvaluationBase {
12
71
  readonly ruleId: string;
13
- readonly kind: DiagnosticReviewRule["kind"];
72
+ readonly ruleKind: DiagnosticReviewRule["kind"];
14
73
  readonly status: "pass" | "triggered" | "not-evaluated";
15
74
  readonly severity: "warning" | "fail";
16
- readonly triggerReason: "predicate" | "missing-input" | null;
75
+ readonly triggerReason:
76
+ | "predicate"
77
+ | "missing-input"
78
+ | "aggregation-overflow"
79
+ | "expression-overflow"
80
+ | "tolerance-overflow"
81
+ | null;
17
82
  readonly left: number | null;
18
83
  readonly right: number | null;
19
84
  readonly relation: "less" | "equal" | "greater" | null;
20
85
  readonly notEvaluatedReasons: readonly DiagnosticRuleNotEvaluatedReason[];
21
- readonly expressionOverflows: readonly { readonly expressionPath: string; readonly sources: readonly DiagnosticSourceLocation[] }[];
22
- readonly reviewScope: DiagnosticReviewEvaluationScope;
86
+ readonly expressionOverflows: readonly DiagnosticReviewExpressionOverflow[];
87
+ readonly scope: DiagnosticReviewEvaluationScope;
88
+ }
89
+
90
+ export type DiagnosticReviewRuleEvaluation =
91
+ | (DiagnosticReviewRuleEvaluationBase & {
92
+ readonly ruleKind: "compare" | "reconcile";
93
+ readonly scope: DiagnosticCellReviewScope;
94
+ })
95
+ | (DiagnosticReviewRuleEvaluationBase & {
96
+ readonly ruleKind: "monotonic";
97
+ readonly scope: DiagnosticValuationPairReviewScope;
98
+ })
99
+ | (DiagnosticReviewRuleEvaluationBase & {
100
+ readonly ruleKind: "layer-order";
101
+ readonly scope: DiagnosticCellReviewScope;
102
+ readonly comparability: Extract<
103
+ DiagnosticReviewRule,
104
+ { kind: "layer-order" }
105
+ >["comparability"];
106
+ })
107
+ | (DiagnosticReviewRuleEvaluationBase & {
108
+ readonly ruleKind: "control-total";
109
+ readonly scope: DiagnosticControlTotalReviewScope;
110
+ });
111
+
112
+ interface EvaluatedOperand {
113
+ readonly value: number | null;
114
+ readonly reasons: readonly DiagnosticRuleNotEvaluatedReason[];
115
+ readonly overflows: readonly DiagnosticReviewExpressionOverflow[];
116
+ readonly sources: readonly DiagnosticSourceLocation[];
117
+ }
118
+
119
+ const REASON_ORDER: readonly DiagnosticRuleNotEvaluatedReason[] = [
120
+ "missing",
121
+ "imputed",
122
+ "non-finite",
123
+ "structural-ambiguity",
124
+ "aggregation-overflow",
125
+ "expression-overflow",
126
+ "tolerance-overflow",
127
+ ];
128
+
129
+ function codeUnit(left: string, right: string): number {
130
+ return left < right ? -1 : left > right ? 1 : 0;
131
+ }
132
+
133
+ function uniqueReasons(
134
+ values: readonly DiagnosticRuleNotEvaluatedReason[],
135
+ ): DiagnosticRuleNotEvaluatedReason[] {
136
+ return REASON_ORDER.filter((reason) => values.includes(reason));
23
137
  }
24
138
 
25
- function states(prepared: PreparedDiagnosticData, components: typeof prepared.cells[number]["components"]): Record<string, FinalizedDiagnosticMeasure> {
26
- const internals = getCompiledDiagnosticDefinitionInternals(prepared.definition);
27
- return Object.fromEntries(Object.entries(components).map(([id, stats]) => {
28
- const measure = internals.measuresById.get(id)!;
29
- const readiness: DiagnosticRuleNotEvaluatedReason[] = [];
30
- if (stats.missing) readiness.push(stats.imputedZero ? "imputed" : "missing");
31
- if (stats.nonFinite) readiness.push("non-finite");
32
- if (stats.structural) readiness.push("structural-ambiguity");
33
- if (stats.sum === null && !stats.nonFinite && !stats.structural) readiness.push("aggregation-overflow");
34
- return [id, { quantity: { kind: measure.kind, unit: measure.unit, value: stats.value }, stats, readiness }];
35
- }));
139
+ function uniqueSources(
140
+ values: readonly DiagnosticSourceLocation[],
141
+ ): DiagnosticSourceLocation[] {
142
+ return normalizeDiagnosticSourceLocations(values);
36
143
  }
37
144
 
38
- function coordinate(cell: PreparedDiagnosticData["cells"][number]): DiagnosticReviewCoordinate { return { sourceGroup: cell.sourceGroup, origin: cell.origin, valuation: cell.valuation, developmentAge: cell.developmentAge, ageUnit: cell.ageUnit } }
145
+ function expressionMeasureIds(
146
+ expression: import("./diagnosticExpressions.js").DiagnosticMeasureExpression,
147
+ ): string[] {
148
+ if (expression.op === "measure") return [expression.measureId];
149
+ return [
150
+ ...new Set(
151
+ (expression.op === "add"
152
+ ? expression.terms
153
+ : [expression.left, expression.right]
154
+ ).flatMap(expressionMeasureIds),
155
+ ),
156
+ ].sort(codeUnit);
157
+ }
158
+
159
+ function expressionLeafPaths(
160
+ expression: import("./diagnosticExpressions.js").DiagnosticMeasureExpression,
161
+ path: string,
162
+ ): readonly { readonly measureId: string; readonly expressionPath: string }[] {
163
+ if (expression.op === "measure")
164
+ return [{ measureId: expression.measureId, expressionPath: path }];
165
+ if (expression.op === "add")
166
+ return expression.terms.flatMap((term, index) =>
167
+ expressionLeafPaths(term, `${path}/terms/${index}`),
168
+ );
169
+ return [
170
+ ...expressionLeafPaths(expression.left, `${path}/left`),
171
+ ...expressionLeafPaths(expression.right, `${path}/right`),
172
+ ];
173
+ }
39
174
 
40
- function evaluation(rule: DiagnosticReviewRule, scope: DiagnosticReviewEvaluationScope, left: number|null, right: number|null, asserted: boolean, reasons: readonly DiagnosticRuleNotEvaluatedReason[] = []): DiagnosticReviewRuleEvaluation {
41
- if (left === null || right === null || reasons.length) return { ruleId: rule.id, kind: rule.kind, status: rule.missingInput === "finding" ? "triggered" : "not-evaluated", severity: rule.severity, triggerReason: rule.missingInput === "finding" ? "missing-input" : null, left, right, relation: null, notEvaluatedReasons: reasons.length ? reasons : ["missing"], expressionOverflows: [], reviewScope: scope };
42
- const compared = classifyDiagnosticComparison(left, right, rule.tolerance);
43
- if (compared.status === "not-evaluated") return { ruleId: rule.id, kind: rule.kind, status: "not-evaluated", severity: rule.severity, triggerReason: null, left, right, relation: null, notEvaluatedReasons: [compared.reason], expressionOverflows: [], reviewScope: scope };
44
- return { ruleId: rule.id, kind: rule.kind, status: asserted ? "pass" : "triggered", severity: rule.severity, triggerReason: asserted ? null : "predicate", left, right, relation: compared.relation, notEvaluatedReasons: [], expressionOverflows: [], reviewScope: scope };
175
+ function states(
176
+ prepared: PreparedDiagnosticData,
177
+ cell: PreparedDiagnosticData["cells"][number],
178
+ ): Record<string, FinalizedDiagnosticMeasure> {
179
+ const internals = getCompiledDiagnosticDefinitionInternals(
180
+ prepared.definition,
181
+ );
182
+ return Object.fromEntries(
183
+ Object.entries(cell.components).map(([id, stats]) => {
184
+ const measure = internals.measuresById.get(id)!;
185
+ const readiness: DiagnosticRuleNotEvaluatedReason[] = [];
186
+ if (stats.missing)
187
+ readiness.push(stats.imputedZero ? "imputed" : "missing");
188
+ if (stats.nonFinite) readiness.push("non-finite");
189
+ if (stats.structural) readiness.push("structural-ambiguity");
190
+ if (stats.sum === null && !stats.nonFinite && !stats.structural)
191
+ readiness.push("aggregation-overflow");
192
+ return [
193
+ id,
194
+ {
195
+ quantity: {
196
+ kind: measure.kind,
197
+ unit: measure.unit,
198
+ ...(measure.basisId ? { basisId: measure.basisId } : {}),
199
+ ...(measure.countPopulationId
200
+ ? { countPopulationId: measure.countPopulationId }
201
+ : {}),
202
+ ...(measure.exposureBasisId
203
+ ? { exposureBasisId: measure.exposureBasisId }
204
+ : {}),
205
+ value: stats.value,
206
+ },
207
+ stats,
208
+ readiness,
209
+ sources: cellSources(cell, [id]),
210
+ },
211
+ ];
212
+ }),
213
+ );
214
+ }
215
+
216
+ function coordinate(
217
+ cell: PreparedDiagnosticData["cells"][number],
218
+ ): DiagnosticReviewCoordinate {
219
+ return {
220
+ sourceGroup: cell.sourceGroup,
221
+ origin: cell.origin,
222
+ valuation: cell.valuation,
223
+ developmentAge: cell.developmentAge,
224
+ ageUnit: cell.ageUnit,
225
+ };
226
+ }
227
+
228
+ function coordinateFromLabels(
229
+ prepared: PreparedDiagnosticData,
230
+ sourceGroup: string,
231
+ originLabel: string,
232
+ valuationLabel: string,
233
+ ): DiagnosticReviewCoordinate {
234
+ const origin = normalizeDiagnosticPeriod(
235
+ prepared.definition,
236
+ "origin",
237
+ originLabel,
238
+ );
239
+ const valuation = normalizeDiagnosticPeriod(
240
+ prepared.definition,
241
+ "valuation",
242
+ valuationLabel,
243
+ );
244
+ return {
245
+ sourceGroup,
246
+ origin: origin.label,
247
+ valuation: valuation.label,
248
+ developmentAge:
249
+ valuation.coordinate -
250
+ origin.coordinate +
251
+ prepared.definition.definition.periodAxis.ageOffset,
252
+ ageUnit: prepared.definition.definition.periodAxis.ageUnit,
253
+ };
254
+ }
255
+
256
+ function cellSources(
257
+ cell: PreparedDiagnosticData["cells"][number],
258
+ measureIds?: readonly string[],
259
+ ): DiagnosticSourceLocation[] {
260
+ const ids = measureIds ?? Object.keys(cell.contributions);
261
+ return uniqueSources(
262
+ ids.flatMap((measureId) => [
263
+ ...(cell.contributions[measureId] ?? []).flatMap(
264
+ (contribution) => contribution.sources,
265
+ ),
266
+ ...(cell.structuralBlockers[measureId] ?? []).flatMap(
267
+ (blocker) => blocker.sources,
268
+ ),
269
+ ]),
270
+ );
271
+ }
272
+
273
+ function cellSourcesForSelection(
274
+ cells: readonly PreparedDiagnosticData["cells"][number][],
275
+ measureId: string,
276
+ ): DiagnosticSourceLocation[] {
277
+ return uniqueSources(
278
+ cells.flatMap((cell) => [
279
+ ...(cell.contributions[measureId] ?? []).flatMap(
280
+ (contribution) => contribution.sources,
281
+ ),
282
+ ...(cell.structuralBlockers[measureId] ?? []).flatMap(
283
+ (blocker) => blocker.sources,
284
+ ),
285
+ ]),
286
+ );
287
+ }
288
+
289
+ function evaluateExpression(
290
+ prepared: PreparedDiagnosticData,
291
+ cell: PreparedDiagnosticData["cells"][number],
292
+ expression: import("./diagnosticExpressions.js").DiagnosticMeasureExpression,
293
+ path: string,
294
+ ): EvaluatedOperand {
295
+ const result = evaluateDiagnosticMeasureExpression(
296
+ expression,
297
+ states(prepared, cell),
298
+ path,
299
+ );
300
+ const sources = cellSources(cell, expressionMeasureIds(expression));
301
+ return {
302
+ value: result.value,
303
+ reasons: result.reasons,
304
+ overflows: result.overflows.map((overflow) => ({
305
+ ...overflow,
306
+ coordinate: coordinate(cell),
307
+ sources,
308
+ })),
309
+ sources,
310
+ };
311
+ }
312
+
313
+ function constant(value: number): EvaluatedOperand {
314
+ return { value, reasons: [], overflows: [], sources: [] };
315
+ }
316
+
317
+ function evaluation(
318
+ prepared: PreparedDiagnosticData,
319
+ rule: DiagnosticReviewRule,
320
+ scope: DiagnosticReviewEvaluationScope,
321
+ left: EvaluatedOperand,
322
+ right: EvaluatedOperand,
323
+ passes: (relation: "less" | "equal" | "greater") => boolean,
324
+ extraReasons: readonly DiagnosticRuleNotEvaluatedReason[] = [],
325
+ ): DiagnosticReviewRuleEvaluation {
326
+ const expressionOverflows = [
327
+ ...new Map(
328
+ [...left.overflows, ...right.overflows].map((item) => [
329
+ canonicalJson(item),
330
+ item,
331
+ ]),
332
+ ).values(),
333
+ ].sort((a, b) => {
334
+ const path = codeUnit(a.expressionPath, b.expressionPath);
335
+ if (path !== 0) return path;
336
+ if (a.coordinate === null) return b.coordinate === null ? 0 : 1;
337
+ if (b.coordinate === null) return -1;
338
+ const coordinate =
339
+ codeUnit(a.coordinate.sourceGroup, b.coordinate.sourceGroup) ||
340
+ normalizeDiagnosticPeriod(
341
+ prepared.definition,
342
+ "origin",
343
+ a.coordinate.origin,
344
+ ).coordinate -
345
+ normalizeDiagnosticPeriod(
346
+ prepared.definition,
347
+ "origin",
348
+ b.coordinate.origin,
349
+ ).coordinate ||
350
+ normalizeDiagnosticPeriod(
351
+ prepared.definition,
352
+ "valuation",
353
+ a.coordinate.valuation,
354
+ ).coordinate -
355
+ normalizeDiagnosticPeriod(
356
+ prepared.definition,
357
+ "valuation",
358
+ b.coordinate.valuation,
359
+ ).coordinate ||
360
+ codeUnit(a.coordinate.origin, b.coordinate.origin) ||
361
+ codeUnit(a.coordinate.valuation, b.coordinate.valuation);
362
+ if (coordinate !== 0) return coordinate;
363
+ for (
364
+ let index = 0;
365
+ index < Math.min(a.sources.length, b.sources.length);
366
+ index++
367
+ ) {
368
+ const source = compareDiagnosticSourceLocations(
369
+ a.sources[index]!,
370
+ b.sources[index]!,
371
+ );
372
+ if (source !== 0) return source;
373
+ }
374
+ return a.sources.length - b.sources.length;
375
+ });
376
+ const reasons = uniqueReasons([
377
+ ...left.reasons,
378
+ ...right.reasons,
379
+ ...extraReasons,
380
+ ...(expressionOverflows.length > 0 ? ["expression-overflow" as const] : []),
381
+ ...((left.value === null || right.value === null) &&
382
+ left.reasons.length === 0 &&
383
+ right.reasons.length === 0 &&
384
+ expressionOverflows.length === 0
385
+ ? ["missing" as const]
386
+ : []),
387
+ ]);
388
+ const missingReason = (): DiagnosticReviewRuleEvaluation["triggerReason"] => {
389
+ if (
390
+ reasons.some((reason) =>
391
+ ["missing", "imputed", "non-finite", "structural-ambiguity"].includes(
392
+ reason,
393
+ ),
394
+ )
395
+ )
396
+ return "missing-input";
397
+ if (reasons.includes("aggregation-overflow")) return "aggregation-overflow";
398
+ if (reasons.includes("expression-overflow")) return "expression-overflow";
399
+ if (reasons.includes("tolerance-overflow")) return "tolerance-overflow";
400
+ return "missing-input";
401
+ };
402
+ const result = (
403
+ value: Omit<
404
+ DiagnosticReviewRuleEvaluationBase,
405
+ "ruleId" | "ruleKind" | "severity" | "scope"
406
+ >,
407
+ ): DiagnosticReviewRuleEvaluation =>
408
+ ({
409
+ ruleId: rule.id,
410
+ ruleKind: rule.kind,
411
+ severity: rule.severity,
412
+ scope,
413
+ ...value,
414
+ ...(rule.kind === "layer-order"
415
+ ? { comparability: rule.comparability }
416
+ : {}),
417
+ }) as DiagnosticReviewRuleEvaluation;
418
+ if (reasons.length > 0)
419
+ return result({
420
+ status: rule.missingInput === "finding" ? "triggered" : "not-evaluated",
421
+ triggerReason: rule.missingInput === "finding" ? missingReason() : null,
422
+ left: left.value,
423
+ right: right.value,
424
+ relation: null,
425
+ notEvaluatedReasons: reasons,
426
+ expressionOverflows,
427
+ });
428
+ const compared = classifyDiagnosticComparison(
429
+ left.value,
430
+ right.value,
431
+ rule.tolerance,
432
+ );
433
+ if (compared.status === "not-evaluated")
434
+ return result({
435
+ status: rule.missingInput === "finding" ? "triggered" : "not-evaluated",
436
+ triggerReason:
437
+ rule.missingInput === "finding"
438
+ ? compared.reason === "tolerance-overflow"
439
+ ? "tolerance-overflow"
440
+ : "missing-input"
441
+ : null,
442
+ left: left.value,
443
+ right: right.value,
444
+ relation: null,
445
+ notEvaluatedReasons: [compared.reason],
446
+ expressionOverflows: [],
447
+ });
448
+ const pass = passes(compared.relation);
449
+ return result({
450
+ status: pass ? "pass" : "triggered",
451
+ triggerReason: pass ? null : "predicate",
452
+ left: left.value,
453
+ right: right.value,
454
+ relation: compared.relation,
455
+ notEvaluatedReasons: [],
456
+ expressionOverflows: [],
457
+ });
458
+ }
459
+
460
+ function filterCells(
461
+ prepared: PreparedDiagnosticData,
462
+ filter: DiagnosticReviewFilter | null | undefined,
463
+ ): readonly PreparedDiagnosticData["cells"][number][] {
464
+ if (!filter) return prepared.cells;
465
+ const originFrom =
466
+ filter.originFrom == null
467
+ ? null
468
+ : normalizeDiagnosticPeriod(
469
+ prepared.definition,
470
+ "origin",
471
+ filter.originFrom,
472
+ ).coordinate;
473
+ const originThrough =
474
+ filter.originThrough == null
475
+ ? null
476
+ : normalizeDiagnosticPeriod(
477
+ prepared.definition,
478
+ "origin",
479
+ filter.originThrough,
480
+ ).coordinate;
481
+ const valuationFrom =
482
+ filter.valuationFrom == null
483
+ ? null
484
+ : normalizeDiagnosticPeriod(
485
+ prepared.definition,
486
+ "valuation",
487
+ filter.valuationFrom,
488
+ ).coordinate;
489
+ const valuationThrough =
490
+ filter.valuationThrough == null
491
+ ? null
492
+ : normalizeDiagnosticPeriod(
493
+ prepared.definition,
494
+ "valuation",
495
+ filter.valuationThrough,
496
+ ).coordinate;
497
+ return prepared.cells.filter((cell) => {
498
+ const origin = normalizeDiagnosticPeriod(
499
+ prepared.definition,
500
+ "origin",
501
+ cell.origin,
502
+ ).coordinate;
503
+ const valuation = normalizeDiagnosticPeriod(
504
+ prepared.definition,
505
+ "valuation",
506
+ cell.valuation,
507
+ ).coordinate;
508
+ return (
509
+ (filter.sourceGroups == null ||
510
+ filter.sourceGroups.includes(cell.sourceGroup)) &&
511
+ (filter.origins == null || filter.origins.includes(cell.origin)) &&
512
+ (originFrom === null || origin >= originFrom) &&
513
+ (originThrough === null || origin <= originThrough) &&
514
+ (filter.valuations == null ||
515
+ filter.valuations.includes(cell.valuation)) &&
516
+ (valuationFrom === null || valuation >= valuationFrom) &&
517
+ (valuationThrough === null || valuation <= valuationThrough) &&
518
+ (filter.minDevelopmentAge == null ||
519
+ cell.developmentAge >= filter.minDevelopmentAge) &&
520
+ (filter.maxDevelopmentAge == null ||
521
+ cell.developmentAge <= filter.maxDevelopmentAge)
522
+ );
523
+ });
524
+ }
525
+
526
+ function projectedCells(
527
+ prepared: PreparedDiagnosticData,
528
+ rule: Extract<DiagnosticReviewRule, { kind: "control-total" }>,
529
+ ): readonly PreparedDiagnosticData["cells"][number][] {
530
+ const selected = filterCells(prepared, rule.filter);
531
+ if (rule.projection.kind === "all-cells") return selected;
532
+ if (rule.projection.kind === "valuation") {
533
+ const valuation = normalizeDiagnosticPeriod(
534
+ prepared.definition,
535
+ "valuation",
536
+ rule.projection.valuation,
537
+ ).label;
538
+ return selected.filter((cell) => cell.valuation === valuation);
539
+ }
540
+ const latest = new Map<string, PreparedDiagnosticData["cells"][number]>();
541
+ for (const cell of selected) {
542
+ const key = canonicalJson([cell.sourceGroup, cell.origin]);
543
+ const previous = latest.get(key);
544
+ if (!previous || cell.developmentAge > previous.developmentAge)
545
+ latest.set(key, cell);
546
+ }
547
+ return [...latest.values()].sort(
548
+ (left, right) =>
549
+ codeUnit(left.sourceGroup, right.sourceGroup) ||
550
+ codeUnit(left.origin, right.origin),
551
+ );
45
552
  }
46
553
 
47
554
  /** Evaluates definition-owned semantic review rules over authenticated prepared cells. */
48
- export function evaluateDiagnosticReviewRules(prepared: PreparedDiagnosticData): readonly DiagnosticReviewRuleEvaluation[] {
555
+ export function evaluateDiagnosticReviewRules(
556
+ prepared: PreparedDiagnosticData,
557
+ ): readonly DiagnosticReviewRuleEvaluation[] {
49
558
  assertPreparedDiagnosticData(prepared);
50
559
  const results: DiagnosticReviewRuleEvaluation[] = [];
51
- for (const rule of prepared.definition.definition.reviewRules as readonly DiagnosticReviewRule[]) {
560
+ for (const [ruleIndex, rule] of (
561
+ prepared.definition.definition
562
+ .reviewRules as unknown as readonly DiagnosticReviewRule[]
563
+ ).entries()) {
564
+ const basePath = `/reviewRules/${ruleIndex}`;
52
565
  if (rule.kind === "control-total") {
53
- const values = prepared.cells.map((cell) => evaluateDiagnosticMeasureExpression(rule.expression, states(prepared, cell.components), "$.reviewRules.control-total").value);
54
- const left = values.some((value) => value === null) ? null : values.reduce<number>((sum, value) => sum + value!, 0);
55
- const scope = { kind: "control-total" as const, selectedCellCount: prepared.cells.length, selectedContributionCount: prepared.cells.reduce((sum, cell) => sum + Object.values(cell.contributions).reduce((n, items) => n + items.length, 0), 0), sources: [] };
56
- const classified = classifyDiagnosticComparison(left, rule.expected, rule.tolerance);
57
- results.push(evaluation(rule, scope, left, rule.expected, classified.status === "evaluated" && classified.relation === "equal"));
566
+ const cells = projectedCells(prepared, rule);
567
+ const internals = getCompiledDiagnosticDefinitionInternals(
568
+ prepared.definition,
569
+ );
570
+ const measureIds = expressionMeasureIds(rule.expression);
571
+ const sourceByMeasure = Object.fromEntries(
572
+ measureIds.map((measureId) => [
573
+ measureId,
574
+ cellSourcesForSelection(cells, measureId),
575
+ ]),
576
+ );
577
+ const globalStates: Record<string, FinalizedDiagnosticMeasure> =
578
+ Object.create(null);
579
+ const leaves = expressionLeafPaths(
580
+ rule.expression,
581
+ `/reviewRules/${ruleIndex}/expression`,
582
+ );
583
+ for (const measureId of measureIds) {
584
+ const measure = internals.measuresById.get(measureId)!;
585
+ const contributions = cells.flatMap(
586
+ (cell) => cell.contributions[measureId] ?? [],
587
+ );
588
+ const blockers = [
589
+ ...new Map(
590
+ cells
591
+ .flatMap((cell) => cell.structuralBlockers[measureId] ?? [])
592
+ .map((blocker) => [canonicalJson(blocker), blocker]),
593
+ ).entries(),
594
+ ]
595
+ .sort(([left], [right]) => codeUnit(left, right))
596
+ .map(([, blocker]) => blocker);
597
+ const stats = finalizeDiagnosticContributions(
598
+ contributions,
599
+ measure.missing,
600
+ blockers,
601
+ );
602
+ const sourceCellOverflow = cells.some((cell) => {
603
+ const component = cell.components[measureId];
604
+ return (
605
+ component !== undefined &&
606
+ component.sum === null &&
607
+ component.nonFinite === 0 &&
608
+ component.structural === 0
609
+ );
610
+ });
611
+ const selectionOverflow =
612
+ stats.sum === null && stats.nonFinite === 0 && stats.structural === 0;
613
+ const emptySelection = cells.length === 0;
614
+ const expressionOverflows =
615
+ selectionOverflow && !sourceCellOverflow
616
+ ? leaves
617
+ .filter((leaf) => leaf.measureId === measureId)
618
+ .map((leaf) => ({
619
+ expressionPath: leaf.expressionPath,
620
+ sources: sourceByMeasure[measureId]!,
621
+ }))
622
+ : [];
623
+ const reasons: DiagnosticRuleNotEvaluatedReason[] = [];
624
+ if (emptySelection || stats.missing)
625
+ reasons.push(stats.imputedZero ? "imputed" : "missing");
626
+ if (stats.nonFinite) reasons.push("non-finite");
627
+ if (stats.structural) reasons.push("structural-ambiguity");
628
+ if (sourceCellOverflow) reasons.push("aggregation-overflow");
629
+ else if (selectionOverflow) reasons.push("expression-overflow");
630
+ globalStates[measureId] = {
631
+ quantity: {
632
+ kind: measure.kind,
633
+ unit: measure.unit,
634
+ ...(measure.basisId ? { basisId: measure.basisId } : {}),
635
+ ...(measure.countPopulationId
636
+ ? { countPopulationId: measure.countPopulationId }
637
+ : {}),
638
+ ...(measure.exposureBasisId
639
+ ? { exposureBasisId: measure.exposureBasisId }
640
+ : {}),
641
+ value:
642
+ emptySelection || sourceCellOverflow || selectionOverflow
643
+ ? null
644
+ : stats.value,
645
+ },
646
+ stats,
647
+ readiness: reasons,
648
+ expressionOverflows,
649
+ sources: sourceByMeasure[measureId],
650
+ };
651
+ }
652
+ const evaluated = evaluateDiagnosticMeasureExpression(
653
+ rule.expression,
654
+ globalStates,
655
+ `/reviewRules/${ruleIndex}/expression`,
656
+ );
657
+ const sources = uniqueSources(
658
+ measureIds.flatMap((measureId) => sourceByMeasure[measureId] ?? []),
659
+ );
660
+ const left: EvaluatedOperand = {
661
+ value: evaluated.value,
662
+ reasons: evaluated.reasons,
663
+ overflows: evaluated.overflows.map((overflow) => ({
664
+ ...overflow,
665
+ coordinate: null,
666
+ sources: overflow.sources.length > 0 ? overflow.sources : sources,
667
+ })),
668
+ sources,
669
+ };
670
+ const scope: DiagnosticReviewEvaluationScope = {
671
+ kind: "control-total",
672
+ projection: rule.projection,
673
+ filter: (rule.filter ??
674
+ null) as NormalizedDiagnosticReviewFilterIdentity | null,
675
+ selectedCellCount: cells.length,
676
+ selectedContributionCount: cells.reduce(
677
+ (sum, cell) =>
678
+ sum +
679
+ measureIds.reduce(
680
+ (count, measureId) =>
681
+ count + (cell.contributions[measureId]?.length ?? 0),
682
+ 0,
683
+ ),
684
+ 0,
685
+ ),
686
+ sources,
687
+ };
688
+ results.push(
689
+ evaluation(
690
+ prepared,
691
+ rule,
692
+ scope,
693
+ left,
694
+ constant(rule.expected),
695
+ (relation) => relation === "equal",
696
+ ),
697
+ );
58
698
  continue;
59
699
  }
60
700
  if (rule.kind === "monotonic") {
61
- const groups = new Map<string, typeof prepared.cells>();
62
- for (const cell of prepared.cells) { const key=`${cell.sourceGroup}\0${cell.origin}`; groups.set(key,[...(groups.get(key)??[]),cell]); }
63
- for (const cells of groups.values()) {
64
- const sorted=[...cells].sort((a,b)=>a.developmentAge-b.developmentAge);
65
- for(let index=1;index<sorted.length;index++){
66
- const previous=sorted[index-1]!,current=sorted[index]!;
67
- const left=evaluateDiagnosticMeasureExpression(rule.expression,states(prepared,previous.components),"$.reviewRules.monotonic").value;
68
- const right=evaluateDiagnosticMeasureExpression(rule.expression,states(prepared,current.components),"$.reviewRules.monotonic").value;
69
- results.push(evaluation(rule,{kind:"valuation-pair",previous:coordinate(previous),current:coordinate(current),sources:[]},left,right,rule.direction==="nondecreasing"?left!==null&&right!==null&&left<=right:left!==null&&right!==null&&left>=right));
701
+ const groups = new Map<string, Set<string>>();
702
+ for (const cell of prepared.cells) {
703
+ const key = canonicalJson([cell.sourceGroup, cell.origin]);
704
+ const values = groups.get(key) ?? new Set<string>();
705
+ values.add(cell.valuation);
706
+ groups.set(key, values);
707
+ }
708
+ for (const expected of prepared.expectedCells) {
709
+ const key = canonicalJson([expected.sourceGroup, expected.origin]);
710
+ const values = groups.get(key) ?? new Set<string>();
711
+ values.add(expected.valuation);
712
+ groups.set(key, values);
713
+ }
714
+ for (const [key, valuations] of [...groups.entries()].sort(
715
+ ([left], [right]) => codeUnit(left, right),
716
+ )) {
717
+ const [sourceGroup, origin] = JSON.parse(key) as [string, string];
718
+ const sorted = [...valuations].sort(
719
+ (left, right) =>
720
+ normalizeDiagnosticPeriod(prepared.definition, "valuation", left)
721
+ .coordinate -
722
+ normalizeDiagnosticPeriod(prepared.definition, "valuation", right)
723
+ .coordinate || codeUnit(left, right),
724
+ );
725
+ for (let index = 1; index < sorted.length; index++) {
726
+ const previousValuation = sorted[index - 1]!;
727
+ const currentValuation = sorted[index]!;
728
+ const previous = prepared.cells.find(
729
+ (cell) =>
730
+ cell.sourceGroup === sourceGroup &&
731
+ cell.origin === origin &&
732
+ cell.valuation === previousValuation,
733
+ );
734
+ const current = prepared.cells.find(
735
+ (cell) =>
736
+ cell.sourceGroup === sourceGroup &&
737
+ cell.origin === origin &&
738
+ cell.valuation === currentValuation,
739
+ );
740
+ const expectedSources = (valuation: string) =>
741
+ prepared.expectedCells
742
+ .filter(
743
+ (cell) =>
744
+ cell.sourceGroup === sourceGroup &&
745
+ cell.origin === origin &&
746
+ cell.valuation === valuation,
747
+ )
748
+ .flatMap((cell) => (cell.source ? [cell.source] : []));
749
+ const left = previous
750
+ ? evaluateExpression(
751
+ prepared,
752
+ previous,
753
+ rule.expression,
754
+ `${basePath}/expression`,
755
+ )
756
+ : {
757
+ value: null,
758
+ reasons: ["missing" as const],
759
+ overflows: [],
760
+ sources: uniqueSources(expectedSources(previousValuation)),
761
+ };
762
+ const right = current
763
+ ? evaluateExpression(
764
+ prepared,
765
+ current,
766
+ rule.expression,
767
+ `${basePath}/expression`,
768
+ )
769
+ : {
770
+ value: null,
771
+ reasons: ["missing" as const],
772
+ overflows: [],
773
+ sources: uniqueSources(expectedSources(currentValuation)),
774
+ };
775
+ const scope: DiagnosticReviewEvaluationScope = {
776
+ kind: "valuation-pair",
777
+ previous: previous
778
+ ? coordinate(previous)
779
+ : coordinateFromLabels(
780
+ prepared,
781
+ sourceGroup,
782
+ origin,
783
+ previousValuation,
784
+ ),
785
+ current: current
786
+ ? coordinate(current)
787
+ : coordinateFromLabels(
788
+ prepared,
789
+ sourceGroup,
790
+ origin,
791
+ currentValuation,
792
+ ),
793
+ sources: uniqueSources([...left.sources, ...right.sources]),
794
+ };
795
+ results.push(
796
+ evaluation(prepared, rule, scope, left, right, (relation) =>
797
+ rule.direction === "nondecreasing"
798
+ ? relation !== "greater"
799
+ : relation !== "less",
800
+ ),
801
+ );
70
802
  }
71
803
  }
72
804
  continue;
73
805
  }
74
806
  for (const cell of prepared.cells) {
75
- const state=states(prepared,cell.components); const scope={kind:"cell" as const,coordinate:coordinate(cell),sources:[]};
76
- if(rule.kind==="compare"){
77
- const operand=(item:typeof rule.when.left)=>item.op==="constant"?item.value:evaluateDiagnosticMeasureExpression(item,state,"$.reviewRules.compare").value;
78
- const left=operand(rule.when.left),right=operand(rule.when.right);const classified=classifyDiagnosticComparison(left,right,rule.tolerance);
79
- results.push(evaluation(rule,scope,left,right,classified.status==="evaluated"&&!diagnosticPredicateMatches(rule.when.operator,classified.relation)));
80
- } else if(rule.kind==="reconcile"){
81
- const left=evaluateDiagnosticMeasureExpression(rule.actual,state,"$.reviewRules.reconcile.actual").value;
82
- const right=rule.expected.op==="constant"?rule.expected.value:evaluateDiagnosticMeasureExpression(rule.expected,state,"$.reviewRules.reconcile.expected").value;
83
- const classified=classifyDiagnosticComparison(left,right,rule.tolerance);results.push(evaluation(rule,scope,left,right,classified.status==="evaluated"&&classified.relation==="equal"));
807
+ const scopeFor = (
808
+ sources: readonly DiagnosticSourceLocation[],
809
+ ): DiagnosticCellReviewScope => ({
810
+ kind: "cell",
811
+ cell: coordinate(cell),
812
+ sources: uniqueSources(sources),
813
+ });
814
+ if (rule.kind === "compare") {
815
+ const operand = (item: typeof rule.when.left, path: string) =>
816
+ item.op === "constant"
817
+ ? constant(item.value)
818
+ : evaluateExpression(prepared, cell, item, path);
819
+ const left = operand(rule.when.left, `${basePath}/when/left`);
820
+ const right = operand(rule.when.right, `${basePath}/when/right`);
821
+ results.push(
822
+ evaluation(
823
+ prepared,
824
+ rule,
825
+ scopeFor([...left.sources, ...right.sources]),
826
+ left,
827
+ right,
828
+ (relation) =>
829
+ !diagnosticPredicateMatches(rule.when.operator, relation),
830
+ ),
831
+ );
832
+ } else if (rule.kind === "reconcile") {
833
+ const left = evaluateExpression(
834
+ prepared,
835
+ cell,
836
+ rule.actual,
837
+ `${basePath}/actual`,
838
+ );
839
+ const right =
840
+ rule.expected.op === "constant"
841
+ ? constant(rule.expected.value)
842
+ : evaluateExpression(
843
+ prepared,
844
+ cell,
845
+ rule.expected,
846
+ `${basePath}/expected`,
847
+ );
848
+ results.push(
849
+ evaluation(
850
+ prepared,
851
+ rule,
852
+ scopeFor([...left.sources, ...right.sources]),
853
+ left,
854
+ right,
855
+ (relation) => relation === "equal",
856
+ ),
857
+ );
84
858
  } else {
85
- const left=evaluateDiagnosticMeasureExpression(rule.narrower,state,"$.reviewRules.layer.narrower").value;const right=evaluateDiagnosticMeasureExpression(rule.broader,state,"$.reviewRules.layer.broader").value;
86
- results.push(evaluation(rule,scope,left,right,left!==null&&right!==null&&left<=right));
859
+ const left = evaluateExpression(
860
+ prepared,
861
+ cell,
862
+ rule.narrower,
863
+ `${basePath}/narrower`,
864
+ );
865
+ const right = evaluateExpression(
866
+ prepared,
867
+ cell,
868
+ rule.broader,
869
+ `${basePath}/broader`,
870
+ );
871
+ results.push(
872
+ evaluation(
873
+ prepared,
874
+ rule,
875
+ scopeFor([...left.sources, ...right.sources]),
876
+ left,
877
+ right,
878
+ (relation) => relation !== "greater",
879
+ ),
880
+ );
87
881
  }
88
882
  }
89
883
  }
90
- return Object.freeze(results.map((item) => Object.freeze(item)));
884
+ return deepFreeze(results);
885
+ }
886
+
887
+ function deepFreeze<T>(
888
+ value: T,
889
+ seen = new WeakSet<object>(),
890
+ ): DiagnosticDeepReadonly<T> {
891
+ if (value === null || typeof value !== "object" || seen.has(value))
892
+ return value as DiagnosticDeepReadonly<T>;
893
+ seen.add(value);
894
+ for (const child of Object.values(value as Record<string, unknown>))
895
+ deepFreeze(child, seen);
896
+ return Object.freeze(value) as DiagnosticDeepReadonly<T>;
91
897
  }