@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,91 +1,1105 @@
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 { createDiagnosticReviewSourcePool } from "./diagnosticReviewSources.js";
12
+ import { createDiagnosticEvidenceInterner } from "./diagnosticEvidenceIntern.js";
13
+ import { normalizeDiagnosticPeriod } from "./diagnosticPeriods.js";
14
+ import {
15
+ evaluateDiagnosticMeasureExpression,
16
+ type FinalizedDiagnosticMeasure,
17
+ } from "./diagnosticFormulas.js";
18
+ import {
19
+ assertPreparedDiagnosticData,
20
+ assertCompactPreparedDiagnosticData,
21
+ type CompactPreparedDiagnosticData,
22
+ type PreparedDiagnosticDataContent,
23
+ type PreparedDiagnosticData,
24
+ } from "./diagnosticPreparation.js";
25
+ import {
26
+ createCompactReviewBuilder,
27
+ type CompactDiagnosticReviewEvaluations,
28
+ } from "./diagnosticReviewStore.js";
29
+ import {
30
+ classifyDiagnosticComparison,
31
+ diagnosticPredicateMatches,
32
+ type DiagnosticExpressionOverflow,
33
+ type DiagnosticRuleNotEvaluatedReason,
34
+ } from "./diagnosticRules.js";
35
+ import { finalizeDiagnosticContributions } from "./diagnosticAggregation.js";
36
+ import {
37
+ compareDiagnosticSourceLocations,
38
+ normalizeDiagnosticSourceLocations,
39
+ } from "./diagnosticSourceOrdering.js";
40
+
41
+ export interface DiagnosticReviewCoordinate {
42
+ readonly sourceGroup: string;
43
+ readonly origin: string;
44
+ readonly valuation: string;
45
+ readonly developmentAge: number;
46
+ readonly ageUnit: string;
47
+ }
48
+
49
+ export type DiagnosticCellReviewScope = {
50
+ readonly kind: "cell";
51
+ readonly cell: DiagnosticReviewCoordinate;
52
+ readonly sources: readonly DiagnosticSourceLocation[];
53
+ };
54
+ export type DiagnosticValuationPairReviewScope = {
55
+ readonly kind: "valuation-pair";
56
+ readonly previous: DiagnosticReviewCoordinate;
57
+ readonly current: DiagnosticReviewCoordinate;
58
+ readonly sources: readonly DiagnosticSourceLocation[];
59
+ };
60
+ export type DiagnosticControlTotalReviewScope = {
61
+ readonly kind: "control-total";
62
+ readonly projection: DiagnosticDeepReadonly<DiagnosticControlTotalProjection>;
63
+ readonly filter: NormalizedDiagnosticReviewFilterIdentity | null;
64
+ readonly selectedCellCount: number;
65
+ readonly selectedContributionCount: number;
66
+ readonly sources: readonly DiagnosticSourceLocation[];
67
+ };
5
68
 
6
- export interface DiagnosticReviewCoordinate { readonly sourceGroup: string; readonly origin: string; readonly valuation: string; readonly developmentAge: number; readonly ageUnit: string }
7
69
  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 {
70
+ | DiagnosticCellReviewScope
71
+ | DiagnosticValuationPairReviewScope
72
+ | DiagnosticControlTotalReviewScope;
73
+
74
+ export interface DiagnosticReviewExpressionOverflow
75
+ extends DiagnosticExpressionOverflow {
76
+ readonly coordinate: DiagnosticReviewCoordinate | null;
77
+ }
78
+
79
+ export interface DiagnosticReviewRuleEvaluationBase {
12
80
  readonly ruleId: string;
13
- readonly kind: DiagnosticReviewRule["kind"];
81
+ readonly ruleKind: DiagnosticReviewRule["kind"];
14
82
  readonly status: "pass" | "triggered" | "not-evaluated";
15
83
  readonly severity: "warning" | "fail";
16
- readonly triggerReason: "predicate" | "missing-input" | null;
84
+ readonly triggerReason:
85
+ | "predicate"
86
+ | "missing-input"
87
+ | "aggregation-overflow"
88
+ | "expression-overflow"
89
+ | "tolerance-overflow"
90
+ | null;
17
91
  readonly left: number | null;
18
92
  readonly right: number | null;
19
93
  readonly relation: "less" | "equal" | "greater" | null;
20
94
  readonly notEvaluatedReasons: readonly DiagnosticRuleNotEvaluatedReason[];
21
- readonly expressionOverflows: readonly { readonly expressionPath: string; readonly sources: readonly DiagnosticSourceLocation[] }[];
22
- readonly reviewScope: DiagnosticReviewEvaluationScope;
95
+ readonly expressionOverflows: readonly DiagnosticReviewExpressionOverflow[];
96
+ readonly scope: DiagnosticReviewEvaluationScope;
97
+ }
98
+
99
+ export type DiagnosticReviewRuleEvaluation =
100
+ | (DiagnosticReviewRuleEvaluationBase & {
101
+ readonly ruleKind: "compare" | "reconcile";
102
+ readonly scope: DiagnosticCellReviewScope;
103
+ })
104
+ | (DiagnosticReviewRuleEvaluationBase & {
105
+ readonly ruleKind: "monotonic";
106
+ readonly scope: DiagnosticValuationPairReviewScope;
107
+ })
108
+ | (DiagnosticReviewRuleEvaluationBase & {
109
+ readonly ruleKind: "layer-order";
110
+ readonly scope: DiagnosticCellReviewScope;
111
+ readonly comparability: Extract<
112
+ DiagnosticReviewRule,
113
+ { kind: "layer-order" }
114
+ >["comparability"];
115
+ })
116
+ | (DiagnosticReviewRuleEvaluationBase & {
117
+ readonly ruleKind: "control-total";
118
+ readonly scope: DiagnosticControlTotalReviewScope;
119
+ });
120
+
121
+ interface EvaluatedOperand {
122
+ readonly value: number | null;
123
+ readonly reasons: readonly DiagnosticRuleNotEvaluatedReason[];
124
+ readonly overflows: readonly DiagnosticReviewExpressionOverflow[];
125
+ readonly sources: readonly DiagnosticSourceLocation[];
126
+ }
127
+
128
+ const REASON_ORDER: readonly DiagnosticRuleNotEvaluatedReason[] = [
129
+ "missing",
130
+ "imputed",
131
+ "non-finite",
132
+ "structural-ambiguity",
133
+ "aggregation-overflow",
134
+ "expression-overflow",
135
+ "tolerance-overflow",
136
+ ];
137
+
138
+ function codeUnit(left: string, right: string): number {
139
+ return left < right ? -1 : left > right ? 1 : 0;
23
140
  }
24
141
 
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
- }));
142
+ function uniqueReasons(
143
+ values: readonly DiagnosticRuleNotEvaluatedReason[],
144
+ ): DiagnosticRuleNotEvaluatedReason[] {
145
+ return REASON_ORDER.filter((reason) => values.includes(reason));
36
146
  }
37
147
 
38
- function coordinate(cell: PreparedDiagnosticData["cells"][number]): DiagnosticReviewCoordinate { return { sourceGroup: cell.sourceGroup, origin: cell.origin, valuation: cell.valuation, developmentAge: cell.developmentAge, ageUnit: cell.ageUnit } }
148
+ function uniqueSources(
149
+ values: readonly DiagnosticSourceLocation[],
150
+ ): DiagnosticSourceLocation[] {
151
+ return normalizeDiagnosticSourceLocations(values);
152
+ }
153
+
154
+ function expressionMeasureIds(
155
+ expression: import("./diagnosticExpressions.js").DiagnosticMeasureExpression,
156
+ ): string[] {
157
+ if (expression.op === "measure") return [expression.measureId];
158
+ return [
159
+ ...new Set(
160
+ (expression.op === "add"
161
+ ? expression.terms
162
+ : [expression.left, expression.right]
163
+ ).flatMap(expressionMeasureIds),
164
+ ),
165
+ ].sort(codeUnit);
166
+ }
39
167
 
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 };
168
+ function expressionLeafPaths(
169
+ expression: import("./diagnosticExpressions.js").DiagnosticMeasureExpression,
170
+ path: string,
171
+ ): readonly { readonly measureId: string; readonly expressionPath: string }[] {
172
+ if (expression.op === "measure")
173
+ return [{ measureId: expression.measureId, expressionPath: path }];
174
+ if (expression.op === "add")
175
+ return expression.terms.flatMap((term, index) =>
176
+ expressionLeafPaths(term, `${path}/terms/${index}`),
177
+ );
178
+ return [
179
+ ...expressionLeafPaths(expression.left, `${path}/left`),
180
+ ...expressionLeafPaths(expression.right, `${path}/right`),
181
+ ];
182
+ }
183
+
184
+ function states(
185
+ prepared: PreparedDiagnosticDataContent,
186
+ cell: PreparedDiagnosticData["cells"][number],
187
+ ): Record<string, FinalizedDiagnosticMeasure> {
188
+ const internals = getCompiledDiagnosticDefinitionInternals(
189
+ prepared.definition,
190
+ );
191
+ return Object.fromEntries(
192
+ Object.entries(cell.components).map(([id, stats]) => {
193
+ const measure = internals.measuresById.get(id)!;
194
+ const readiness: DiagnosticRuleNotEvaluatedReason[] = [];
195
+ if (stats.missing)
196
+ readiness.push(stats.imputedZero ? "imputed" : "missing");
197
+ if (stats.nonFinite) readiness.push("non-finite");
198
+ if (stats.structural) readiness.push("structural-ambiguity");
199
+ if (stats.sum === null && !stats.nonFinite && !stats.structural)
200
+ readiness.push("aggregation-overflow");
201
+ return [
202
+ id,
203
+ {
204
+ quantity: {
205
+ kind: measure.kind,
206
+ unit: measure.unit,
207
+ ...(measure.basisId ? { basisId: measure.basisId } : {}),
208
+ ...(measure.countPopulationId
209
+ ? { countPopulationId: measure.countPopulationId }
210
+ : {}),
211
+ ...(measure.exposureBasisId
212
+ ? { exposureBasisId: measure.exposureBasisId }
213
+ : {}),
214
+ value: stats.value,
215
+ },
216
+ stats,
217
+ readiness,
218
+ // Cell expressions attach complete leaf-source evidence separately.
219
+ // Per-measure source lists here would be discarded and rebuilt.
220
+ },
221
+ ];
222
+ }),
223
+ );
224
+ }
225
+
226
+ function coordinate(
227
+ cell: PreparedDiagnosticData["cells"][number],
228
+ ): DiagnosticReviewCoordinate {
229
+ return {
230
+ sourceGroup: cell.sourceGroup,
231
+ origin: cell.origin,
232
+ valuation: cell.valuation,
233
+ developmentAge: cell.developmentAge,
234
+ ageUnit: cell.ageUnit,
235
+ };
236
+ }
237
+
238
+ function coordinateFromLabels(
239
+ prepared: PreparedDiagnosticDataContent,
240
+ sourceGroup: string,
241
+ originLabel: string,
242
+ valuationLabel: string,
243
+ ): DiagnosticReviewCoordinate {
244
+ const origin = normalizeDiagnosticPeriod(
245
+ prepared.definition,
246
+ "origin",
247
+ originLabel,
248
+ );
249
+ const valuation = normalizeDiagnosticPeriod(
250
+ prepared.definition,
251
+ "valuation",
252
+ valuationLabel,
253
+ );
254
+ return {
255
+ sourceGroup,
256
+ origin: origin.label,
257
+ valuation: valuation.label,
258
+ developmentAge:
259
+ valuation.coordinate -
260
+ origin.coordinate +
261
+ prepared.definition.definition.periodAxis.ageOffset,
262
+ ageUnit: prepared.definition.definition.periodAxis.ageUnit,
263
+ };
264
+ }
265
+
266
+ function cellSources(
267
+ cell: PreparedDiagnosticData["cells"][number],
268
+ measureIds?: readonly string[],
269
+ ): DiagnosticSourceLocation[] {
270
+ const ids = measureIds ?? Object.keys(cell.contributions);
271
+ return uniqueSources(
272
+ ids.flatMap((measureId) => [
273
+ ...(cell.contributions[measureId] ?? []).flatMap(
274
+ (contribution) => contribution.sources,
275
+ ),
276
+ ...(cell.structuralBlockers[measureId] ?? []).flatMap(
277
+ (blocker) => blocker.sources,
278
+ ),
279
+ ]),
280
+ );
281
+ }
282
+
283
+ function cellSourcesForSelection(
284
+ cells: readonly PreparedDiagnosticData["cells"][number][],
285
+ measureId: string,
286
+ ): DiagnosticSourceLocation[] {
287
+ return uniqueSources(
288
+ cells.flatMap((cell) => [
289
+ ...(cell.contributions[measureId] ?? []).flatMap(
290
+ (contribution) => contribution.sources,
291
+ ),
292
+ ...(cell.structuralBlockers[measureId] ?? []).flatMap(
293
+ (blocker) => blocker.sources,
294
+ ),
295
+ ]),
296
+ );
297
+ }
298
+
299
+ /** Compact-only, invocation-owned caches. Never retain these on a prepared cell. */
300
+ function createCompactCellSourceReader() {
301
+ const pool = createDiagnosticReviewSourcePool();
302
+ const addCellSources = (
303
+ target: ReturnType<typeof pool.collection>,
304
+ cell: PreparedDiagnosticData["cells"][number],
305
+ ids: readonly string[],
306
+ ): void => {
307
+ for (const id of ids) {
308
+ for (const contribution of cell.contributions[id] ?? [])
309
+ for (const source of contribution.sources) target.add(source, true);
310
+ for (const blocker of cell.structuralBlockers[id] ?? [])
311
+ for (const source of blocker.sources) target.add(source, true);
312
+ }
313
+ };
314
+ const forSelection = (
315
+ cells: readonly PreparedDiagnosticData["cells"][number][],
316
+ measureId: string,
317
+ ): readonly DiagnosticSourceLocation[] => {
318
+ const target = pool.collection();
319
+ const ids = [measureId];
320
+ for (const cell of cells) addCellSources(target, cell, ids);
321
+ return target.finish();
322
+ };
323
+ const dependencies = new WeakMap<object, readonly string[]>();
324
+ const dependencyKeys = new WeakMap<object, string>();
325
+ const byCell = new WeakMap<
326
+ PreparedDiagnosticData["cells"][number],
327
+ Map<string, readonly DiagnosticSourceLocation[]>
328
+ >();
329
+ const measureIds = (
330
+ expression: import("./diagnosticExpressions.js").DiagnosticMeasureExpression,
331
+ ): readonly string[] => {
332
+ let ids = dependencies.get(expression);
333
+ if (!ids) {
334
+ ids = Object.freeze(expressionMeasureIds(expression));
335
+ dependencies.set(expression, ids);
336
+ }
337
+ return ids;
338
+ };
339
+ const forCell = (
340
+ cell: PreparedDiagnosticData["cells"][number],
341
+ ids: readonly string[],
342
+ ): readonly DiagnosticSourceLocation[] => {
343
+ let key = dependencyKeys.get(ids);
344
+ if (key === undefined) {
345
+ key = canonicalJson(ids);
346
+ dependencyKeys.set(ids, key);
347
+ }
348
+ let subsets = byCell.get(cell);
349
+ if (!subsets) {
350
+ subsets = new Map();
351
+ byCell.set(cell, subsets);
352
+ }
353
+ let sources = subsets.get(key);
354
+ if (!sources) {
355
+ const target = pool.collection();
356
+ addCellSources(target, cell, ids);
357
+ sources = target.finish();
358
+ subsets.set(key, sources);
359
+ }
360
+ return sources;
361
+ };
362
+ return {
363
+ ...pool,
364
+ measureIds,
365
+ forCell,
366
+ forSelection,
367
+ };
368
+ }
369
+
370
+ function createCellExpressionEvaluator(
371
+ prepared: PreparedDiagnosticDataContent,
372
+ compactSources?: ReturnType<typeof createCompactCellSourceReader>,
373
+ ) {
374
+ // Reuse projections across this review's rules, then release them before the
375
+ // receipt identity is constructed. Retaining a prepared cell must not retain
376
+ // this temporary bookkeeping or reuse another review's state.
377
+ const statesByCell = new WeakMap<
378
+ PreparedDiagnosticData["cells"][number],
379
+ Record<string, FinalizedDiagnosticMeasure>
380
+ >();
381
+ return (
382
+ cell: PreparedDiagnosticData["cells"][number],
383
+ expression: import("./diagnosticExpressions.js").DiagnosticMeasureExpression,
384
+ path: string,
385
+ ): EvaluatedOperand => {
386
+ let measures = statesByCell.get(cell);
387
+ if (!measures) {
388
+ measures = states(prepared, cell);
389
+ statesByCell.set(cell, measures);
390
+ }
391
+ const result = evaluateDiagnosticMeasureExpression(
392
+ expression,
393
+ measures,
394
+ path,
395
+ );
396
+ const sources = compactSources
397
+ ? compactSources.forCell(cell, compactSources.measureIds(expression))
398
+ : cellSources(cell, expressionMeasureIds(expression));
399
+ return {
400
+ value: result.value,
401
+ reasons: result.reasons,
402
+ overflows: result.overflows.map((overflow) => ({
403
+ ...overflow,
404
+ coordinate: coordinate(cell),
405
+ sources,
406
+ })),
407
+ sources,
408
+ };
409
+ };
410
+ }
411
+
412
+ function constant(value: number): EvaluatedOperand {
413
+ return { value, reasons: [], overflows: [], sources: [] };
414
+ }
415
+
416
+ function evaluation(
417
+ prepared: PreparedDiagnosticDataContent,
418
+ rule: DiagnosticReviewRule,
419
+ scope: DiagnosticReviewEvaluationScope,
420
+ left: EvaluatedOperand,
421
+ right: EvaluatedOperand,
422
+ passes: (relation: "less" | "equal" | "greater") => boolean,
423
+ extraReasons: readonly DiagnosticRuleNotEvaluatedReason[] = [],
424
+ ): DiagnosticReviewRuleEvaluation {
425
+ const expressionOverflows = [
426
+ ...new Map(
427
+ [...left.overflows, ...right.overflows].map((item) => [
428
+ canonicalJson(item),
429
+ item,
430
+ ]),
431
+ ).values(),
432
+ ].sort((a, b) => {
433
+ const path = codeUnit(a.expressionPath, b.expressionPath);
434
+ if (path !== 0) return path;
435
+ if (a.coordinate === null) return b.coordinate === null ? 0 : 1;
436
+ if (b.coordinate === null) return -1;
437
+ const coordinate =
438
+ codeUnit(a.coordinate.sourceGroup, b.coordinate.sourceGroup) ||
439
+ normalizeDiagnosticPeriod(
440
+ prepared.definition,
441
+ "origin",
442
+ a.coordinate.origin,
443
+ ).coordinate -
444
+ normalizeDiagnosticPeriod(
445
+ prepared.definition,
446
+ "origin",
447
+ b.coordinate.origin,
448
+ ).coordinate ||
449
+ normalizeDiagnosticPeriod(
450
+ prepared.definition,
451
+ "valuation",
452
+ a.coordinate.valuation,
453
+ ).coordinate -
454
+ normalizeDiagnosticPeriod(
455
+ prepared.definition,
456
+ "valuation",
457
+ b.coordinate.valuation,
458
+ ).coordinate ||
459
+ codeUnit(a.coordinate.origin, b.coordinate.origin) ||
460
+ codeUnit(a.coordinate.valuation, b.coordinate.valuation);
461
+ if (coordinate !== 0) return coordinate;
462
+ for (
463
+ let index = 0;
464
+ index < Math.min(a.sources.length, b.sources.length);
465
+ index++
466
+ ) {
467
+ const source = compareDiagnosticSourceLocations(
468
+ a.sources[index]!,
469
+ b.sources[index]!,
470
+ );
471
+ if (source !== 0) return source;
472
+ }
473
+ return a.sources.length - b.sources.length;
474
+ });
475
+ const reasons = uniqueReasons([
476
+ ...left.reasons,
477
+ ...right.reasons,
478
+ ...extraReasons,
479
+ ...(expressionOverflows.length > 0 ? ["expression-overflow" as const] : []),
480
+ ...((left.value === null || right.value === null) &&
481
+ left.reasons.length === 0 &&
482
+ right.reasons.length === 0 &&
483
+ expressionOverflows.length === 0
484
+ ? ["missing" as const]
485
+ : []),
486
+ ]);
487
+ const missingReason = (): DiagnosticReviewRuleEvaluation["triggerReason"] => {
488
+ if (
489
+ reasons.some((reason) =>
490
+ ["missing", "imputed", "non-finite", "structural-ambiguity"].includes(
491
+ reason,
492
+ ),
493
+ )
494
+ )
495
+ return "missing-input";
496
+ if (reasons.includes("aggregation-overflow")) return "aggregation-overflow";
497
+ if (reasons.includes("expression-overflow")) return "expression-overflow";
498
+ if (reasons.includes("tolerance-overflow")) return "tolerance-overflow";
499
+ return "missing-input";
500
+ };
501
+ const result = (
502
+ value: Omit<
503
+ DiagnosticReviewRuleEvaluationBase,
504
+ "ruleId" | "ruleKind" | "severity" | "scope"
505
+ >,
506
+ ): DiagnosticReviewRuleEvaluation =>
507
+ ({
508
+ ruleId: rule.id,
509
+ ruleKind: rule.kind,
510
+ severity: rule.severity,
511
+ scope,
512
+ ...value,
513
+ ...(rule.kind === "layer-order"
514
+ ? { comparability: rule.comparability }
515
+ : {}),
516
+ }) as DiagnosticReviewRuleEvaluation;
517
+ if (reasons.length > 0)
518
+ return result({
519
+ status: rule.missingInput === "finding" ? "triggered" : "not-evaluated",
520
+ triggerReason: rule.missingInput === "finding" ? missingReason() : null,
521
+ left: left.value,
522
+ right: right.value,
523
+ relation: null,
524
+ notEvaluatedReasons: reasons,
525
+ expressionOverflows,
526
+ });
527
+ const compared = classifyDiagnosticComparison(
528
+ left.value,
529
+ right.value,
530
+ rule.tolerance,
531
+ );
532
+ if (compared.status === "not-evaluated")
533
+ return result({
534
+ status: rule.missingInput === "finding" ? "triggered" : "not-evaluated",
535
+ triggerReason:
536
+ rule.missingInput === "finding"
537
+ ? compared.reason === "tolerance-overflow"
538
+ ? "tolerance-overflow"
539
+ : "missing-input"
540
+ : null,
541
+ left: left.value,
542
+ right: right.value,
543
+ relation: null,
544
+ notEvaluatedReasons: [compared.reason],
545
+ expressionOverflows: [],
546
+ });
547
+ const pass = passes(compared.relation);
548
+ return result({
549
+ status: pass ? "pass" : "triggered",
550
+ triggerReason: pass ? null : "predicate",
551
+ left: left.value,
552
+ right: right.value,
553
+ relation: compared.relation,
554
+ notEvaluatedReasons: [],
555
+ expressionOverflows: [],
556
+ });
557
+ }
558
+
559
+ function filterCells(
560
+ prepared: PreparedDiagnosticDataContent,
561
+ filter: DiagnosticReviewFilter | null | undefined,
562
+ ): readonly PreparedDiagnosticData["cells"][number][] {
563
+ if (!filter) return prepared.cells;
564
+ const originFrom =
565
+ filter.originFrom == null
566
+ ? null
567
+ : normalizeDiagnosticPeriod(
568
+ prepared.definition,
569
+ "origin",
570
+ filter.originFrom,
571
+ ).coordinate;
572
+ const originThrough =
573
+ filter.originThrough == null
574
+ ? null
575
+ : normalizeDiagnosticPeriod(
576
+ prepared.definition,
577
+ "origin",
578
+ filter.originThrough,
579
+ ).coordinate;
580
+ const valuationFrom =
581
+ filter.valuationFrom == null
582
+ ? null
583
+ : normalizeDiagnosticPeriod(
584
+ prepared.definition,
585
+ "valuation",
586
+ filter.valuationFrom,
587
+ ).coordinate;
588
+ const valuationThrough =
589
+ filter.valuationThrough == null
590
+ ? null
591
+ : normalizeDiagnosticPeriod(
592
+ prepared.definition,
593
+ "valuation",
594
+ filter.valuationThrough,
595
+ ).coordinate;
596
+ return prepared.cells.filter((cell) => {
597
+ const origin = normalizeDiagnosticPeriod(
598
+ prepared.definition,
599
+ "origin",
600
+ cell.origin,
601
+ ).coordinate;
602
+ const valuation = normalizeDiagnosticPeriod(
603
+ prepared.definition,
604
+ "valuation",
605
+ cell.valuation,
606
+ ).coordinate;
607
+ return (
608
+ (filter.sourceGroups == null ||
609
+ filter.sourceGroups.includes(cell.sourceGroup)) &&
610
+ (filter.origins == null || filter.origins.includes(cell.origin)) &&
611
+ (originFrom === null || origin >= originFrom) &&
612
+ (originThrough === null || origin <= originThrough) &&
613
+ (filter.valuations == null ||
614
+ filter.valuations.includes(cell.valuation)) &&
615
+ (valuationFrom === null || valuation >= valuationFrom) &&
616
+ (valuationThrough === null || valuation <= valuationThrough) &&
617
+ (filter.minDevelopmentAge == null ||
618
+ cell.developmentAge >= filter.minDevelopmentAge) &&
619
+ (filter.maxDevelopmentAge == null ||
620
+ cell.developmentAge <= filter.maxDevelopmentAge)
621
+ );
622
+ });
623
+ }
624
+
625
+ function projectedCells(
626
+ prepared: PreparedDiagnosticDataContent,
627
+ rule: Extract<DiagnosticReviewRule, { kind: "control-total" }>,
628
+ ): readonly PreparedDiagnosticData["cells"][number][] {
629
+ const selected = filterCells(prepared, rule.filter);
630
+ if (rule.projection.kind === "all-cells") return selected;
631
+ if (rule.projection.kind === "valuation") {
632
+ const valuation = normalizeDiagnosticPeriod(
633
+ prepared.definition,
634
+ "valuation",
635
+ rule.projection.valuation,
636
+ ).label;
637
+ return selected.filter((cell) => cell.valuation === valuation);
638
+ }
639
+ const latest = new Map<string, PreparedDiagnosticData["cells"][number]>();
640
+ for (const cell of selected) {
641
+ const key = canonicalJson([cell.sourceGroup, cell.origin]);
642
+ const previous = latest.get(key);
643
+ if (!previous || cell.developmentAge > previous.developmentAge)
644
+ latest.set(key, cell);
645
+ }
646
+ return [...latest.values()].sort(
647
+ (left, right) =>
648
+ codeUnit(left.sourceGroup, right.sourceGroup) ||
649
+ codeUnit(left.origin, right.origin),
650
+ );
651
+ }
652
+
653
+ /**
654
+ * Finish each evaluation before retaining it. All mutable descendants here
655
+ * were constructed by this review; frozen descendants belong to the compiled
656
+ * definition or an earlier finalized child and must retain their identity.
657
+ * The pool is local to one review and never sees prepared cells or SDK brands.
658
+ */
659
+ function createEvaluationFinalizer(): (
660
+ value: DiagnosticReviewRuleEvaluation,
661
+ ) => DiagnosticReviewRuleEvaluation {
662
+ const sharing = createDiagnosticEvidenceInterner();
663
+ const finalizeChild = (value: unknown, sourceSlot = false): unknown => {
664
+ if (value === null || typeof value !== "object" || Object.isFrozen(value))
665
+ return value;
666
+ const array = Array.isArray(value);
667
+ for (const key of Object.keys(value)) {
668
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
669
+ const child = finalizeChild(
670
+ descriptor.value,
671
+ array ? sourceSlot : key === "source" || key === "sources",
672
+ );
673
+ if (child !== descriptor.value)
674
+ Object.defineProperty(value, key, { ...descriptor, value: child });
675
+ }
676
+ return sharing.internOwned(
677
+ Object.freeze(value),
678
+ sourceSlot ? "source" : "plain",
679
+ );
680
+ };
681
+ return (value) => {
682
+ // Keep every evaluation as its original, distinct record. Only its owned
683
+ // children may share storage; no evaluation or array entry is removed.
684
+ for (const key of ["scope", "notEvaluatedReasons", "expressionOverflows"]) {
685
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
686
+ const child = finalizeChild(descriptor.value);
687
+ if (child !== descriptor.value)
688
+ Object.defineProperty(value, key, { ...descriptor, value: child });
689
+ }
690
+ return Object.freeze(value);
691
+ };
45
692
  }
46
693
 
47
694
  /** Evaluates definition-owned semantic review rules over authenticated prepared cells. */
48
- export function evaluateDiagnosticReviewRules(prepared: PreparedDiagnosticData): readonly DiagnosticReviewRuleEvaluation[] {
695
+ export function evaluateDiagnosticReviewRules(
696
+ prepared: PreparedDiagnosticData,
697
+ ): readonly DiagnosticReviewRuleEvaluation[] {
49
698
  assertPreparedDiagnosticData(prepared);
50
699
  const results: DiagnosticReviewRuleEvaluation[] = [];
51
- for (const rule of prepared.definition.definition.reviewRules as readonly DiagnosticReviewRule[]) {
700
+ const finalizeEvaluation = createEvaluationFinalizer();
701
+ visitDiagnosticReviewRules(prepared, (value) => {
702
+ results.push(finalizeEvaluation(value));
703
+ });
704
+ return Object.freeze(results);
705
+ }
706
+
707
+ /** Keeps every evaluation in compact owned storage; no identity is computed. */
708
+ export function evaluateDiagnosticReviewRulesCompact(
709
+ prepared: CompactPreparedDiagnosticData,
710
+ ): CompactDiagnosticReviewEvaluations {
711
+ assertCompactPreparedDiagnosticData(prepared);
712
+ const compactSources = createCompactCellSourceReader();
713
+ const builder = createCompactReviewBuilder(
714
+ prepared.definition.definition.reviewRules,
715
+ compactSources,
716
+ );
717
+ visitDiagnosticReviewRules(prepared, builder.append, compactSources);
718
+ return builder.finish();
719
+ }
720
+
721
+ /** One traversal and one formula implementation serve both storage contracts. */
722
+ function visitDiagnosticReviewRules(
723
+ prepared: PreparedDiagnosticDataContent,
724
+ appendEvaluation: (value: DiagnosticReviewRuleEvaluation) => void,
725
+ compactSources?: ReturnType<typeof createCompactCellSourceReader>,
726
+ ): void {
727
+ const evaluateExpression = createCellExpressionEvaluator(
728
+ prepared,
729
+ compactSources,
730
+ );
731
+ const cellByCoordinate = new Map<
732
+ string,
733
+ PreparedDiagnosticData["cells"][number]
734
+ >();
735
+ const expectedSourcesByCoordinate = new Map<
736
+ string,
737
+ DiagnosticSourceLocation[]
738
+ >();
739
+ const coordinateKey = (
740
+ sourceGroup: string,
741
+ origin: string,
742
+ valuation: string,
743
+ ) => canonicalJson([sourceGroup, origin, valuation]);
744
+ // Keep the original traversal and source order; only replace repeated searches.
745
+ // Other rule families do not need these indices.
746
+ if (
747
+ prepared.definition.definition.reviewRules.some(
748
+ (rule) => rule.kind === "monotonic",
749
+ )
750
+ ) {
751
+ for (const cell of prepared.cells) {
752
+ const key = coordinateKey(cell.sourceGroup, cell.origin, cell.valuation);
753
+ if (!cellByCoordinate.has(key)) cellByCoordinate.set(key, cell);
754
+ }
755
+ for (const cell of prepared.expectedCells) {
756
+ if (!cell.source) continue;
757
+ const key = coordinateKey(cell.sourceGroup, cell.origin, cell.valuation);
758
+ const sources = expectedSourcesByCoordinate.get(key) ?? [];
759
+ sources.push(cell.source);
760
+ expectedSourcesByCoordinate.set(key, sources);
761
+ }
762
+ }
763
+ for (const [ruleIndex, rule] of (
764
+ prepared.definition.definition
765
+ .reviewRules as unknown as readonly DiagnosticReviewRule[]
766
+ ).entries()) {
767
+ const basePath = `/reviewRules/${ruleIndex}`;
52
768
  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"));
769
+ const cells = projectedCells(prepared, rule);
770
+ const internals = getCompiledDiagnosticDefinitionInternals(
771
+ prepared.definition,
772
+ );
773
+ const measureIds = expressionMeasureIds(rule.expression);
774
+ const sourceByMeasure = Object.fromEntries(
775
+ measureIds.map((measureId) => [
776
+ measureId,
777
+ compactSources
778
+ ? compactSources.forSelection(cells, measureId)
779
+ : cellSourcesForSelection(cells, measureId),
780
+ ]),
781
+ );
782
+ const globalStates: Record<string, FinalizedDiagnosticMeasure> =
783
+ Object.create(null);
784
+ const leaves = expressionLeafPaths(
785
+ rule.expression,
786
+ `/reviewRules/${ruleIndex}/expression`,
787
+ );
788
+ for (const measureId of measureIds) {
789
+ const measure = internals.measuresById.get(measureId)!;
790
+ const contributions = cells.flatMap(
791
+ (cell) => cell.contributions[measureId] ?? [],
792
+ );
793
+ const blockers = [
794
+ ...new Map(
795
+ cells
796
+ .flatMap((cell) => cell.structuralBlockers[measureId] ?? [])
797
+ .map((blocker) => [canonicalJson(blocker), blocker]),
798
+ ).entries(),
799
+ ]
800
+ .sort(([left], [right]) => codeUnit(left, right))
801
+ .map(([, blocker]) => blocker);
802
+ const stats = finalizeDiagnosticContributions(
803
+ contributions,
804
+ measure.missing,
805
+ blockers,
806
+ );
807
+ const sourceCellOverflow = cells.some((cell) => {
808
+ const component = cell.components[measureId];
809
+ return (
810
+ component !== undefined &&
811
+ component.sum === null &&
812
+ component.nonFinite === 0 &&
813
+ component.structural === 0
814
+ );
815
+ });
816
+ const selectionOverflow =
817
+ stats.sum === null && stats.nonFinite === 0 && stats.structural === 0;
818
+ const emptySelection = cells.length === 0;
819
+ const expressionOverflows =
820
+ selectionOverflow && !sourceCellOverflow
821
+ ? leaves
822
+ .filter((leaf) => leaf.measureId === measureId)
823
+ .map((leaf) => ({
824
+ expressionPath: leaf.expressionPath,
825
+ sources: sourceByMeasure[measureId]!,
826
+ }))
827
+ : [];
828
+ const reasons: DiagnosticRuleNotEvaluatedReason[] = [];
829
+ if (emptySelection || stats.missing)
830
+ reasons.push(stats.imputedZero ? "imputed" : "missing");
831
+ if (stats.nonFinite) reasons.push("non-finite");
832
+ if (stats.structural) reasons.push("structural-ambiguity");
833
+ if (sourceCellOverflow) reasons.push("aggregation-overflow");
834
+ else if (selectionOverflow) reasons.push("expression-overflow");
835
+ globalStates[measureId] = {
836
+ quantity: {
837
+ kind: measure.kind,
838
+ unit: measure.unit,
839
+ ...(measure.basisId ? { basisId: measure.basisId } : {}),
840
+ ...(measure.countPopulationId
841
+ ? { countPopulationId: measure.countPopulationId }
842
+ : {}),
843
+ ...(measure.exposureBasisId
844
+ ? { exposureBasisId: measure.exposureBasisId }
845
+ : {}),
846
+ value:
847
+ emptySelection || sourceCellOverflow || selectionOverflow
848
+ ? null
849
+ : stats.value,
850
+ },
851
+ stats,
852
+ readiness: reasons,
853
+ expressionOverflows,
854
+ sources: sourceByMeasure[measureId],
855
+ };
856
+ }
857
+ const evaluated = evaluateDiagnosticMeasureExpression(
858
+ rule.expression,
859
+ globalStates,
860
+ `/reviewRules/${ruleIndex}/expression`,
861
+ );
862
+ const sources = compactSources
863
+ ? compactSources.union(
864
+ measureIds.map((measureId) => sourceByMeasure[measureId] ?? []),
865
+ )
866
+ : uniqueSources(
867
+ measureIds.flatMap((measureId) => sourceByMeasure[measureId] ?? []),
868
+ );
869
+ const left: EvaluatedOperand = {
870
+ value: evaluated.value,
871
+ reasons: evaluated.reasons,
872
+ overflows: evaluated.overflows.map((overflow) => ({
873
+ ...overflow,
874
+ coordinate: null,
875
+ sources: overflow.sources.length > 0 ? overflow.sources : sources,
876
+ })),
877
+ sources,
878
+ };
879
+ const scope: DiagnosticReviewEvaluationScope = {
880
+ kind: "control-total",
881
+ projection: rule.projection,
882
+ filter: (rule.filter ??
883
+ null) as NormalizedDiagnosticReviewFilterIdentity | null,
884
+ selectedCellCount: cells.length,
885
+ selectedContributionCount: cells.reduce(
886
+ (sum, cell) =>
887
+ sum +
888
+ measureIds.reduce(
889
+ (count, measureId) =>
890
+ count + (cell.contributions[measureId]?.length ?? 0),
891
+ 0,
892
+ ),
893
+ 0,
894
+ ),
895
+ sources,
896
+ };
897
+ appendEvaluation(
898
+ evaluation(
899
+ prepared,
900
+ rule,
901
+ scope,
902
+ left,
903
+ constant(rule.expected),
904
+ (relation) => relation === "equal",
905
+ ),
906
+ );
58
907
  continue;
59
908
  }
60
909
  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));
910
+ const groups = new Map<string, Set<string>>();
911
+ for (const cell of prepared.cells) {
912
+ const key = canonicalJson([cell.sourceGroup, cell.origin]);
913
+ const values = groups.get(key) ?? new Set<string>();
914
+ values.add(cell.valuation);
915
+ groups.set(key, values);
916
+ }
917
+ for (const expected of prepared.expectedCells) {
918
+ const key = canonicalJson([expected.sourceGroup, expected.origin]);
919
+ const values = groups.get(key) ?? new Set<string>();
920
+ values.add(expected.valuation);
921
+ groups.set(key, values);
922
+ }
923
+ for (const [key, valuations] of [...groups.entries()].sort(
924
+ ([left], [right]) => codeUnit(left, right),
925
+ )) {
926
+ const [sourceGroup, origin] = JSON.parse(key) as [string, string];
927
+ const sorted = [...valuations].sort(
928
+ (left, right) =>
929
+ normalizeDiagnosticPeriod(prepared.definition, "valuation", left)
930
+ .coordinate -
931
+ normalizeDiagnosticPeriod(prepared.definition, "valuation", right)
932
+ .coordinate || codeUnit(left, right),
933
+ );
934
+ for (let index = 1; index < sorted.length; index++) {
935
+ const previousValuation = sorted[index - 1]!;
936
+ const currentValuation = sorted[index]!;
937
+ const previous = cellByCoordinate.get(
938
+ coordinateKey(sourceGroup, origin, previousValuation),
939
+ );
940
+ const current = cellByCoordinate.get(
941
+ coordinateKey(sourceGroup, origin, currentValuation),
942
+ );
943
+ const expectedSources = (valuation: string) =>
944
+ expectedSourcesByCoordinate.get(
945
+ coordinateKey(sourceGroup, origin, valuation),
946
+ ) ?? [];
947
+ const left = previous
948
+ ? evaluateExpression(
949
+ previous,
950
+ rule.expression,
951
+ `${basePath}/expression`,
952
+ )
953
+ : {
954
+ value: null,
955
+ reasons: ["missing" as const],
956
+ overflows: [],
957
+ sources: compactSources
958
+ ? compactSources.forPreparedSources(
959
+ expectedSources(previousValuation),
960
+ )
961
+ : uniqueSources(expectedSources(previousValuation)),
962
+ };
963
+ const right = current
964
+ ? evaluateExpression(
965
+ current,
966
+ rule.expression,
967
+ `${basePath}/expression`,
968
+ )
969
+ : {
970
+ value: null,
971
+ reasons: ["missing" as const],
972
+ overflows: [],
973
+ sources: compactSources
974
+ ? compactSources.forPreparedSources(
975
+ expectedSources(currentValuation),
976
+ )
977
+ : uniqueSources(expectedSources(currentValuation)),
978
+ };
979
+ const scope: DiagnosticReviewEvaluationScope = {
980
+ kind: "valuation-pair",
981
+ previous: previous
982
+ ? coordinate(previous)
983
+ : coordinateFromLabels(
984
+ prepared,
985
+ sourceGroup,
986
+ origin,
987
+ previousValuation,
988
+ ),
989
+ current: current
990
+ ? coordinate(current)
991
+ : coordinateFromLabels(
992
+ prepared,
993
+ sourceGroup,
994
+ origin,
995
+ currentValuation,
996
+ ),
997
+ sources: compactSources
998
+ ? compactSources.union([left.sources, right.sources])
999
+ : uniqueSources([...left.sources, ...right.sources]),
1000
+ };
1001
+ appendEvaluation(
1002
+ evaluation(prepared, rule, scope, left, right, (relation) =>
1003
+ rule.direction === "nondecreasing"
1004
+ ? relation !== "greater"
1005
+ : relation !== "less",
1006
+ ),
1007
+ );
70
1008
  }
71
1009
  }
72
1010
  continue;
73
1011
  }
1012
+ // Scope evidence is the union of this rule's expression leaves, not every
1013
+ // measure in the cell. Constants have no source dependency.
1014
+ const scopeDependencies = compactSources
1015
+ ? Object.freeze(
1016
+ [
1017
+ ...new Set(
1018
+ (rule.kind === "compare"
1019
+ ? [rule.when.left, rule.when.right]
1020
+ : rule.kind === "reconcile"
1021
+ ? [rule.actual, rule.expected]
1022
+ : [rule.narrower, rule.broader]
1023
+ ).flatMap((expression) =>
1024
+ expression.op === "constant"
1025
+ ? []
1026
+ : compactSources.measureIds(expression),
1027
+ ),
1028
+ ),
1029
+ ].sort(codeUnit),
1030
+ )
1031
+ : undefined;
74
1032
  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"));
1033
+ const scopeFor = (
1034
+ leftSources: readonly DiagnosticSourceLocation[],
1035
+ rightSources: readonly DiagnosticSourceLocation[],
1036
+ ): DiagnosticCellReviewScope => ({
1037
+ kind: "cell",
1038
+ cell: coordinate(cell),
1039
+ sources: compactSources
1040
+ ? compactSources.forCell(cell, scopeDependencies!)
1041
+ : uniqueSources([...leftSources, ...rightSources]),
1042
+ });
1043
+ if (rule.kind === "compare") {
1044
+ const operand = (item: typeof rule.when.left, path: string) =>
1045
+ item.op === "constant"
1046
+ ? constant(item.value)
1047
+ : evaluateExpression(cell, item, path);
1048
+ const left = operand(rule.when.left, `${basePath}/when/left`);
1049
+ const right = operand(rule.when.right, `${basePath}/when/right`);
1050
+ appendEvaluation(
1051
+ evaluation(
1052
+ prepared,
1053
+ rule,
1054
+ scopeFor(left.sources, right.sources),
1055
+ left,
1056
+ right,
1057
+ (relation) =>
1058
+ !diagnosticPredicateMatches(rule.when.operator, relation),
1059
+ ),
1060
+ );
1061
+ } else if (rule.kind === "reconcile") {
1062
+ const left = evaluateExpression(
1063
+ cell,
1064
+ rule.actual,
1065
+ `${basePath}/actual`,
1066
+ );
1067
+ const right =
1068
+ rule.expected.op === "constant"
1069
+ ? constant(rule.expected.value)
1070
+ : evaluateExpression(cell, rule.expected, `${basePath}/expected`);
1071
+ appendEvaluation(
1072
+ evaluation(
1073
+ prepared,
1074
+ rule,
1075
+ scopeFor(left.sources, right.sources),
1076
+ left,
1077
+ right,
1078
+ (relation) => relation === "equal",
1079
+ ),
1080
+ );
84
1081
  } 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));
1082
+ const left = evaluateExpression(
1083
+ cell,
1084
+ rule.narrower,
1085
+ `${basePath}/narrower`,
1086
+ );
1087
+ const right = evaluateExpression(
1088
+ cell,
1089
+ rule.broader,
1090
+ `${basePath}/broader`,
1091
+ );
1092
+ appendEvaluation(
1093
+ evaluation(
1094
+ prepared,
1095
+ rule,
1096
+ scopeFor(left.sources, right.sources),
1097
+ left,
1098
+ right,
1099
+ (relation) => relation !== "greater",
1100
+ ),
1101
+ );
87
1102
  }
88
1103
  }
89
1104
  }
90
- return Object.freeze(results.map((item) => Object.freeze(item)));
91
1105
  }