@actuarial-ts/core 0.10.0 → 0.12.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 (91) hide show
  1. package/README.md +9 -7
  2. package/dist/analysisRecipe.d.ts.map +1 -1
  3. package/dist/analysisRecipe.js +20 -1
  4. package/dist/analysisRecipe.js.map +1 -1
  5. package/dist/canonical.d.ts.map +1 -1
  6. package/dist/canonical.js +64 -5
  7. package/dist/canonical.js.map +1 -1
  8. package/dist/customizationContracts.d.ts +16 -1
  9. package/dist/customizationContracts.d.ts.map +1 -1
  10. package/dist/descriptiveStatistics.js +1 -1
  11. package/dist/descriptiveStatistics.js.map +1 -1
  12. package/dist/diagnosticAggregation.d.ts +26 -0
  13. package/dist/diagnosticAggregation.d.ts.map +1 -1
  14. package/dist/diagnosticAggregation.js +125 -11
  15. package/dist/diagnosticAggregation.js.map +1 -1
  16. package/dist/diagnosticAuditText.d.ts +11 -0
  17. package/dist/diagnosticAuditText.d.ts.map +1 -0
  18. package/dist/diagnosticAuditText.js +179 -0
  19. package/dist/diagnosticAuditText.js.map +1 -0
  20. package/dist/diagnosticFormulas.d.ts.map +1 -1
  21. package/dist/diagnosticFormulas.js +71 -12
  22. package/dist/diagnosticFormulas.js.map +1 -1
  23. package/dist/diagnosticFreeze.d.ts +15 -1
  24. package/dist/diagnosticFreeze.d.ts.map +1 -1
  25. package/dist/diagnosticFreeze.js +82 -31
  26. package/dist/diagnosticFreeze.js.map +1 -1
  27. package/dist/diagnosticKeys.d.ts +9 -0
  28. package/dist/diagnosticKeys.d.ts.map +1 -0
  29. package/dist/diagnosticKeys.js +20 -0
  30. package/dist/diagnosticKeys.js.map +1 -0
  31. package/dist/diagnosticOrdering.d.ts.map +1 -1
  32. package/dist/diagnosticOrdering.js +9 -2
  33. package/dist/diagnosticOrdering.js.map +1 -1
  34. package/dist/diagnosticPeriods.d.ts.map +1 -1
  35. package/dist/diagnosticPeriods.js +19 -1
  36. package/dist/diagnosticPeriods.js.map +1 -1
  37. package/dist/diagnosticPreparation.d.ts +19 -0
  38. package/dist/diagnosticPreparation.d.ts.map +1 -1
  39. package/dist/diagnosticPreparation.js +544 -231
  40. package/dist/diagnosticPreparation.js.map +1 -1
  41. package/dist/diagnosticReview.d.ts.map +1 -1
  42. package/dist/diagnosticReview.js +419 -186
  43. package/dist/diagnosticReview.js.map +1 -1
  44. package/dist/diagnosticReviewSources.d.ts +6 -1
  45. package/dist/diagnosticReviewSources.d.ts.map +1 -1
  46. package/dist/diagnosticReviewSources.js +59 -14
  47. package/dist/diagnosticReviewSources.js.map +1 -1
  48. package/dist/diagnosticReviewStore.d.ts.map +1 -1
  49. package/dist/diagnosticReviewStore.js +48 -7
  50. package/dist/diagnosticReviewStore.js.map +1 -1
  51. package/dist/diagnosticRunner.d.ts.map +1 -1
  52. package/dist/diagnosticRunner.js +112 -37
  53. package/dist/diagnosticRunner.js.map +1 -1
  54. package/dist/diagnosticRuntime.d.ts.map +1 -1
  55. package/dist/diagnosticRuntime.js +11 -1
  56. package/dist/diagnosticRuntime.js.map +1 -1
  57. package/dist/diagnosticSourceOrdering.d.ts +27 -0
  58. package/dist/diagnosticSourceOrdering.d.ts.map +1 -1
  59. package/dist/diagnosticSourceOrdering.js +98 -12
  60. package/dist/diagnosticSourceOrdering.js.map +1 -1
  61. package/dist/mack.d.ts.map +1 -1
  62. package/dist/mack.js +35 -0
  63. package/dist/mack.js.map +1 -1
  64. package/dist/types.d.ts +1 -1
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js +1 -0
  67. package/dist/types.js.map +1 -1
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/package.json +1 -1
  71. package/src/analysisRecipe.ts +18 -1
  72. package/src/canonical.ts +63 -5
  73. package/src/customizationContracts.ts +12 -1
  74. package/src/descriptiveStatistics.ts +1 -1
  75. package/src/diagnosticAggregation.ts +173 -9
  76. package/src/diagnosticAuditText.ts +187 -0
  77. package/src/diagnosticFormulas.ts +77 -27
  78. package/src/diagnosticFreeze.ts +75 -30
  79. package/src/diagnosticKeys.ts +22 -0
  80. package/src/diagnosticOrdering.ts +9 -8
  81. package/src/diagnosticPeriods.ts +21 -1
  82. package/src/diagnosticPreparation.ts +624 -272
  83. package/src/diagnosticReview.ts +566 -268
  84. package/src/diagnosticReviewSources.ts +69 -18
  85. package/src/diagnosticReviewStore.ts +48 -6
  86. package/src/diagnosticRunner.ts +133 -54
  87. package/src/diagnosticRuntime.ts +11 -1
  88. package/src/diagnosticSourceOrdering.ts +115 -11
  89. package/src/mack.ts +41 -0
  90. package/src/types.ts +1 -0
  91. package/src/version.ts +1 -1
@@ -1,14 +1,21 @@
1
1
  import { getCompiledDiagnosticDefinitionInternals, } from "./diagnosticDefinitions.js";
2
2
  import { canonicalJson } from "./canonical.js";
3
+ import { canonicalScalarArrayJson } from "./diagnosticKeys.js";
3
4
  import { createDiagnosticReviewSourcePool } from "./diagnosticReviewSources.js";
4
5
  import { createDiagnosticEvidenceInterner } from "./diagnosticEvidenceIntern.js";
5
6
  import { normalizeDiagnosticPeriod } from "./diagnosticPeriods.js";
6
7
  import { evaluateDiagnosticMeasureExpression, } from "./diagnosticFormulas.js";
8
+ import { hasDiagnosticOwn } from "./diagnosticRuntime.js";
7
9
  import { assertPreparedDiagnosticData, assertCompactPreparedDiagnosticData, getCompactSourceSelectionParent, getCompactCoordinateSelectionParent, } from "./diagnosticPreparation.js";
8
10
  import { createCompactReviewBuilder, selectCompactReviewSources, } from "./diagnosticReviewStore.js";
9
11
  import { classifyDiagnosticComparison, diagnosticPredicateMatches, } from "./diagnosticRules.js";
10
12
  import { finalizeDiagnosticContributions } from "./diagnosticAggregation.js";
11
13
  import { compareDiagnosticSourceLocations, normalizeDiagnosticSourceLocations, } from "./diagnosticSourceOrdering.js";
14
+ // Shared immutable empties for the overwhelmingly common clean cell: readers
15
+ // only iterate or spread these, and every retained evaluation owns fresh arrays.
16
+ const NO_REASONS = Object.freeze([]);
17
+ const NO_REVIEW_OVERFLOWS = Object.freeze([]);
18
+ const NO_SOURCES = Object.freeze([]);
12
19
  const REASON_ORDER = [
13
20
  "missing",
14
21
  "imputed",
@@ -46,41 +53,62 @@ function expressionLeafPaths(expression, path) {
46
53
  ...expressionLeafPaths(expression.right, `${path}/right`),
47
54
  ];
48
55
  }
49
- function states(prepared, cell) {
56
+ // A projected state depends only on its measure's definition and the cell's
57
+ // statistics record, and preparation shares equal statistics records across
58
+ // cells, so one projection per (measure, record) serves every cell holding it.
59
+ // The cache lives in one review invocation, so no projection crosses
60
+ // preparations or definitions. Bounded like the interner.
61
+ const MAX_CACHED_STATES = 100_000;
62
+ function states(prepared, cell, cache) {
50
63
  const internals = getCompiledDiagnosticDefinitionInternals(prepared.definition);
51
- return Object.fromEntries(Object.entries(cell.components).map(([id, stats]) => {
52
- const measure = internals.measuresById.get(id);
53
- const readiness = [];
54
- if (stats.missing)
55
- readiness.push(stats.imputedZero ? "imputed" : "missing");
56
- if (stats.nonFinite)
57
- readiness.push("non-finite");
58
- if (stats.structural)
59
- readiness.push("structural-ambiguity");
60
- if (stats.sum === null && !stats.nonFinite && !stats.structural)
61
- readiness.push("aggregation-overflow");
62
- return [
63
- id,
64
- {
65
- quantity: {
66
- kind: measure.kind,
67
- unit: measure.unit,
68
- ...(measure.basisId ? { basisId: measure.basisId } : {}),
69
- ...(measure.countPopulationId
70
- ? { countPopulationId: measure.countPopulationId }
71
- : {}),
72
- ...(measure.exposureBasisId
73
- ? { exposureBasisId: measure.exposureBasisId }
74
- : {}),
75
- value: stats.value,
76
- },
64
+ const result = {};
65
+ const ids = Object.keys(cell.components);
66
+ let cached = 0;
67
+ for (let index = 0; index < ids.length; index++) {
68
+ const id = ids[index];
69
+ const stats = cell.components[id];
70
+ let byStats = cache.get(id);
71
+ if (byStats === undefined) {
72
+ byStats = new Map();
73
+ cache.set(id, byStats);
74
+ }
75
+ let state = byStats.get(stats);
76
+ if (state === undefined) {
77
+ const measure = internals.measuresById.get(id);
78
+ let readiness;
79
+ if (stats.missing)
80
+ (readiness ??= []).push(stats.imputedZero ? "imputed" : "missing");
81
+ if (stats.nonFinite)
82
+ (readiness ??= []).push("non-finite");
83
+ if (stats.structural)
84
+ (readiness ??= []).push("structural-ambiguity");
85
+ if (stats.sum === null && !stats.nonFinite && !stats.structural)
86
+ (readiness ??= []).push("aggregation-overflow");
87
+ const quantity = {
88
+ kind: measure.kind,
89
+ unit: measure.unit,
90
+ };
91
+ if (measure.basisId)
92
+ quantity.basisId = measure.basisId;
93
+ if (measure.countPopulationId)
94
+ quantity.countPopulationId = measure.countPopulationId;
95
+ if (measure.exposureBasisId)
96
+ quantity.exposureBasisId = measure.exposureBasisId;
97
+ quantity.value = stats.value;
98
+ state = {
99
+ quantity: quantity,
77
100
  stats,
78
- readiness,
101
+ readiness: readiness ?? NO_REASONS,
79
102
  // Cell expressions attach complete leaf-source evidence separately.
80
103
  // Per-measure source lists here would be discarded and rebuilt.
81
- },
82
- ];
83
- }));
104
+ };
105
+ cached += byStats.size;
106
+ if (cached < MAX_CACHED_STATES)
107
+ byStats.set(stats, state);
108
+ }
109
+ result[id] = state;
110
+ }
111
+ return result;
84
112
  }
85
113
  function coordinate(cell) {
86
114
  return {
@@ -118,16 +146,31 @@ function cellSourcesForSelection(cells, measureId) {
118
146
  ]));
119
147
  }
120
148
  /** Compact-only, invocation-owned caches. Never retain these on a prepared cell. */
121
- function createCompactCellSourceReader() {
149
+ function createCompactCellSourceReader(cellCount) {
122
150
  const pool = createDiagnosticReviewSourcePool();
123
151
  const addCellSources = (target, cell, ids) => {
124
- for (const id of ids) {
125
- for (const contribution of cell.contributions[id] ?? [])
126
- for (const source of contribution.sources)
127
- target.add(source, true);
128
- for (const blocker of cell.structuralBlockers[id] ?? [])
129
- for (const source of blocker.sources)
130
- target.add(source, true);
152
+ // Preparation cites one frozen list per source row across its measures;
153
+ // adding the same list again is a no-op, so skip it by identity.
154
+ let previous;
155
+ for (let position = 0; position < ids.length; position++) {
156
+ const id = ids[position];
157
+ const contributions = cell.contributions[id];
158
+ if (contributions !== undefined)
159
+ for (let index = 0; index < contributions.length; index++) {
160
+ const sources = contributions[index].sources;
161
+ if (sources === previous)
162
+ continue;
163
+ previous = sources;
164
+ for (let at = 0; at < sources.length; at++)
165
+ target.add(sources[at], true);
166
+ }
167
+ const blockers = cell.structuralBlockers[id];
168
+ if (blockers !== undefined)
169
+ for (let index = 0; index < blockers.length; index++) {
170
+ const sources = blockers[index].sources;
171
+ for (let at = 0; at < sources.length; at++)
172
+ target.add(sources[at], true);
173
+ }
131
174
  }
132
175
  };
133
176
  const forSelection = (cells, measureId) => {
@@ -138,8 +181,30 @@ function createCompactCellSourceReader() {
138
181
  return target.finish();
139
182
  };
140
183
  const dependencies = new WeakMap();
141
- const dependencyKeys = new WeakMap();
142
- const byCell = new WeakMap();
184
+ // Distinct dependency subsets are few (one per rule expression); each gets a
185
+ // small index, and equal subsets from different expressions share it.
186
+ const subsetIndexByKey = new Map();
187
+ const subsetIndexByIds = new Map();
188
+ // Invocation-scoped like the pool: cached lists live in flat arrays indexed
189
+ // by subset and cell position, released with this reader and never attached
190
+ // to a prepared cell.
191
+ const listsBySubset = [];
192
+ const subsetIndex = (ids) => {
193
+ let index = subsetIndexByIds.get(ids);
194
+ if (index === undefined) {
195
+ const key = canonicalJson(ids);
196
+ index = subsetIndexByKey.get(key);
197
+ if (index === undefined) {
198
+ index = listsBySubset.length;
199
+ listsBySubset.push(new Array(cellCount));
200
+ subsetIndexByKey.set(key, index);
201
+ }
202
+ subsetIndexByIds.set(ids, index);
203
+ }
204
+ return index;
205
+ };
206
+ // The one citation list most of a cell's subsets resolve to, and its owned list.
207
+ const cellSingletons = new Array(cellCount);
143
208
  const measureIds = (expression) => {
144
209
  let ids = dependencies.get(expression);
145
210
  if (!ids) {
@@ -148,23 +213,50 @@ function createCompactCellSourceReader() {
148
213
  }
149
214
  return ids;
150
215
  };
151
- const forCell = (cell, ids) => {
152
- let key = dependencyKeys.get(ids);
153
- if (key === undefined) {
154
- key = canonicalJson(ids);
155
- dependencyKeys.set(ids, key);
156
- }
157
- let subsets = byCell.get(cell);
158
- if (!subsets) {
159
- subsets = new Map();
160
- byCell.set(cell, subsets);
161
- }
162
- let sources = subsets.get(key);
216
+ const forCell = (cell, cellIndex, ids) => {
217
+ const lists = listsBySubset[subsetIndex(ids)];
218
+ let sources = lists[cellIndex];
163
219
  if (!sources) {
164
- const target = pool.collection();
165
- addCellSources(target, cell, ids);
166
- sources = target.finish();
167
- subsets.set(key, sources);
220
+ // Most subsets cite exactly one citation list of one prepared source
221
+ // (the cell's single row) and no blockers: resolve that list directly.
222
+ // Any other shape goes through the general collection.
223
+ let only;
224
+ for (let position = 0; position < ids.length && only !== null; position++) {
225
+ const id = ids[position];
226
+ const contributions = cell.contributions[id];
227
+ if (contributions !== undefined)
228
+ for (let index = 0; index < contributions.length; index++) {
229
+ const cited = contributions[index].sources;
230
+ if (only === undefined)
231
+ only = cited;
232
+ else if (only !== cited) {
233
+ only = null;
234
+ break;
235
+ }
236
+ }
237
+ const blockers = cell.structuralBlockers[id];
238
+ if (blockers !== undefined && blockers.length > 0)
239
+ only = null;
240
+ }
241
+ if (only === undefined)
242
+ sources = pool.emptyList;
243
+ else if (only !== null && only.length === 1) {
244
+ const cached = cellSingletons[cellIndex];
245
+ if (cached !== undefined && cached.cited === only)
246
+ sources = cached.list;
247
+ else {
248
+ sources = pool.singletonForPrepared(only[0]);
249
+ cellSingletons[cellIndex] = { cited: only, list: sources };
250
+ }
251
+ }
252
+ else if (only !== null && only.length === 0)
253
+ sources = pool.emptyList;
254
+ else {
255
+ const target = pool.collection();
256
+ addCellSources(target, cell, ids);
257
+ sources = target.finish();
258
+ }
259
+ lists[cellIndex] = sources;
168
260
  }
169
261
  return sources;
170
262
  };
@@ -179,34 +271,97 @@ function createCellExpressionEvaluator(prepared, compactSources) {
179
271
  // Reuse projections across this review's rules, then release them before the
180
272
  // receipt identity is constructed. Retaining a prepared cell must not retain
181
273
  // this temporary bookkeeping or reuse another review's state.
182
- const statesByCell = new WeakMap();
183
- return (cell, expression, path) => {
184
- let measures = statesByCell.get(cell);
274
+ const statesByCell = new Array(prepared.cells.length);
275
+ const stateCache = new Map();
276
+ // Compact mode: the two operands of an evaluation are read and dropped, so
277
+ // one mutable operand per side is reused. The eager path keeps fresh objects.
278
+ const reusable = compactSources
279
+ ? [
280
+ { value: null, reasons: NO_REASONS, overflows: NO_REVIEW_OVERFLOWS, sources: NO_SOURCES },
281
+ { value: null, reasons: NO_REASONS, overflows: NO_REVIEW_OVERFLOWS, sources: NO_SOURCES },
282
+ ]
283
+ : undefined;
284
+ const operand = (slot, value, reasons, overflows, sources) => {
285
+ if (reusable === undefined || slot < 0)
286
+ return { value, reasons, overflows, sources };
287
+ const target = reusable[slot];
288
+ target.value = value;
289
+ target.reasons = reasons;
290
+ target.overflows = overflows;
291
+ target.sources = sources;
292
+ return target;
293
+ };
294
+ return (cell, cellIndex, expression, path, slot = -1) => {
295
+ let measures = statesByCell[cellIndex];
185
296
  if (!measures) {
186
- measures = states(prepared, cell);
187
- statesByCell.set(cell, measures);
297
+ measures = states(prepared, cell, stateCache);
298
+ statesByCell[cellIndex] = measures;
188
299
  }
189
- const result = evaluateDiagnosticMeasureExpression(expression, measures, path);
190
300
  const sources = compactSources
191
- ? compactSources.forCell(cell, compactSources.measureIds(expression))
301
+ ? compactSources.forCell(cell, cellIndex, compactSources.measureIds(expression))
192
302
  : cellSources(cell, expressionMeasureIds(expression));
193
- return {
194
- value: result.value,
195
- reasons: result.reasons,
196
- overflows: result.overflows.map((overflow) => ({
303
+ // A measure leaf is read straight from the projected state: the generic
304
+ // evaluator would build the same result and hand it back unchanged.
305
+ if (expression.op === "measure") {
306
+ const state = hasDiagnosticOwn(measures, expression.measureId)
307
+ ? measures[expression.measureId]
308
+ : undefined;
309
+ if (state === undefined)
310
+ return operand(slot, null, ["missing"], NO_REVIEW_OVERFLOWS, sources);
311
+ const overflows = state.expressionOverflows;
312
+ return operand(slot, state.quantity.value, state.readiness, overflows === undefined || overflows.length === 0
313
+ ? NO_REVIEW_OVERFLOWS
314
+ : overflows.map((overflow) => ({ ...overflow, coordinate: coordinate(cell), sources })), sources);
315
+ }
316
+ const result = evaluateDiagnosticMeasureExpression(expression, measures, path);
317
+ return operand(slot, result.value, result.reasons, result.overflows.length === 0
318
+ ? NO_REVIEW_OVERFLOWS
319
+ : result.overflows.map((overflow) => ({
197
320
  ...overflow,
198
321
  coordinate: coordinate(cell),
199
322
  sources,
200
- })),
201
- sources,
202
- };
323
+ })), sources);
203
324
  };
204
325
  }
205
326
  function constant(value) {
206
327
  return { value, reasons: [], overflows: [], sources: [] };
207
328
  }
208
- function evaluation(prepared, rule, scope, left, right, passes, extraReasons = []) {
209
- const expressionOverflows = [
329
+ function createEvaluationScratch() {
330
+ const placeholder = {
331
+ sourceGroup: "",
332
+ origin: "",
333
+ valuation: "",
334
+ developmentAge: 0,
335
+ ageUnit: "",
336
+ };
337
+ return {
338
+ record: {
339
+ ruleId: "",
340
+ ruleKind: "compare",
341
+ severity: "warning",
342
+ scope: { kind: "cell", cell: placeholder, sources: NO_SOURCES },
343
+ status: "pass",
344
+ triggerReason: null,
345
+ left: null,
346
+ right: null,
347
+ relation: null,
348
+ notEvaluatedReasons: NO_REASONS,
349
+ expressionOverflows: NO_REVIEW_OVERFLOWS,
350
+ comparability: undefined,
351
+ },
352
+ cellScope: { kind: "cell", cell: placeholder, sources: NO_SOURCES },
353
+ pairScope: { kind: "valuation-pair", previous: placeholder, current: placeholder, sources: NO_SOURCES },
354
+ constants: new Map(),
355
+ };
356
+ }
357
+ // One empty array serves both roles, exactly as the eager finalizer's interner
358
+ // would merge equal frozen empties into a single shared instance.
359
+ const freshEmptyLists = () => {
360
+ const empty = Object.freeze([]);
361
+ return { reasons: empty, overflows: empty };
362
+ };
363
+ function evaluation(prepared, rule, scope, left, right, passes, extraReasons = NO_REASONS, empty = freshEmptyLists(), scratch) {
364
+ const expressionOverflows = left.overflows.length === 0 && right.overflows.length === 0 ? [] : [
210
365
  ...new Map([...left.overflows, ...right.overflows].map((item) => [
211
366
  canonicalJson(item),
212
367
  item,
@@ -235,74 +390,86 @@ function evaluation(prepared, rule, scope, left, right, passes, extraReasons = [
235
390
  }
236
391
  return a.sources.length - b.sources.length;
237
392
  });
238
- const reasons = uniqueReasons([
239
- ...left.reasons,
240
- ...right.reasons,
241
- ...extraReasons,
242
- ...(expressionOverflows.length > 0 ? ["expression-overflow"] : []),
243
- ...((left.value === null || right.value === null) &&
244
- left.reasons.length === 0 &&
245
- right.reasons.length === 0 &&
246
- expressionOverflows.length === 0
247
- ? ["missing"]
248
- : []),
249
- ]);
250
- const missingReason = () => {
251
- if (reasons.some((reason) => ["missing", "imputed", "non-finite", "structural-ambiguity"].includes(reason)))
252
- return "missing-input";
253
- if (reasons.includes("aggregation-overflow"))
254
- return "aggregation-overflow";
255
- if (reasons.includes("expression-overflow"))
256
- return "expression-overflow";
257
- if (reasons.includes("tolerance-overflow"))
258
- return "tolerance-overflow";
393
+ // Clean evaluated operands carry no reasons; skip the spread/filter round trip.
394
+ const reasons = left.reasons.length === 0 &&
395
+ right.reasons.length === 0 &&
396
+ extraReasons.length === 0 &&
397
+ expressionOverflows.length === 0 &&
398
+ left.value !== null &&
399
+ right.value !== null
400
+ ? []
401
+ : uniqueReasons([
402
+ ...left.reasons,
403
+ ...right.reasons,
404
+ ...extraReasons,
405
+ ...(expressionOverflows.length > 0 ? ["expression-overflow"] : []),
406
+ ...((left.value === null || right.value === null) &&
407
+ left.reasons.length === 0 &&
408
+ right.reasons.length === 0 &&
409
+ expressionOverflows.length === 0
410
+ ? ["missing"]
411
+ : []),
412
+ ]);
413
+ if (reasons.length > 0)
414
+ return evaluationRecord(rule, scope, left, right, rule.missingInput === "finding" ? "triggered" : "not-evaluated", rule.missingInput === "finding" ? missingTriggerReason(reasons) : null, null, reasons, expressionOverflows, scratch);
415
+ const compared = classifyDiagnosticComparison(left.value, right.value, rule.tolerance);
416
+ // Empty reason and overflow lists are this review's own immutable values;
417
+ // the eager finalizer leaves frozen children as they are and readers only
418
+ // iterate them, and no other invocation ever sees the same arrays.
419
+ if (compared.status === "not-evaluated")
420
+ return evaluationRecord(rule, scope, left, right, rule.missingInput === "finding" ? "triggered" : "not-evaluated", rule.missingInput === "finding"
421
+ ? compared.reason === "tolerance-overflow"
422
+ ? "tolerance-overflow"
423
+ : "missing-input"
424
+ : null, null, [compared.reason], empty.overflows, scratch);
425
+ const pass = passes(compared.relation);
426
+ return evaluationRecord(rule, scope, left, right, pass ? "pass" : "triggered", pass ? null : "predicate", compared.relation, empty.reasons, empty.overflows, scratch);
427
+ }
428
+ function missingTriggerReason(reasons) {
429
+ if (reasons.some((reason) => ["missing", "imputed", "non-finite", "structural-ambiguity"].includes(reason)))
259
430
  return "missing-input";
260
- };
261
- const result = (value) => ({
431
+ if (reasons.includes("aggregation-overflow"))
432
+ return "aggregation-overflow";
433
+ if (reasons.includes("expression-overflow"))
434
+ return "expression-overflow";
435
+ if (reasons.includes("tolerance-overflow"))
436
+ return "tolerance-overflow";
437
+ return "missing-input";
438
+ }
439
+ /** One literal in the contract's key order; no intermediate spread objects. */
440
+ function evaluationRecord(rule, scope, left, right, status, triggerReason, relation, notEvaluatedReasons, overflows, scratch) {
441
+ if (scratch !== undefined) {
442
+ const target = scratch.record;
443
+ target.ruleId = rule.id;
444
+ target.ruleKind = rule.kind;
445
+ target.severity = rule.severity;
446
+ target.scope = scope;
447
+ target.status = status;
448
+ target.triggerReason = triggerReason;
449
+ target.left = left.value;
450
+ target.right = right.value;
451
+ target.relation = relation;
452
+ target.notEvaluatedReasons = notEvaluatedReasons;
453
+ target.expressionOverflows = overflows;
454
+ target.comparability = rule.kind === "layer-order" ? rule.comparability : undefined;
455
+ return target;
456
+ }
457
+ const value = {
262
458
  ruleId: rule.id,
263
459
  ruleKind: rule.kind,
264
460
  severity: rule.severity,
265
461
  scope,
266
- ...value,
267
- ...(rule.kind === "layer-order"
268
- ? { comparability: rule.comparability }
269
- : {}),
270
- });
271
- if (reasons.length > 0)
272
- return result({
273
- status: rule.missingInput === "finding" ? "triggered" : "not-evaluated",
274
- triggerReason: rule.missingInput === "finding" ? missingReason() : null,
275
- left: left.value,
276
- right: right.value,
277
- relation: null,
278
- notEvaluatedReasons: reasons,
279
- expressionOverflows,
280
- });
281
- const compared = classifyDiagnosticComparison(left.value, right.value, rule.tolerance);
282
- if (compared.status === "not-evaluated")
283
- return result({
284
- status: rule.missingInput === "finding" ? "triggered" : "not-evaluated",
285
- triggerReason: rule.missingInput === "finding"
286
- ? compared.reason === "tolerance-overflow"
287
- ? "tolerance-overflow"
288
- : "missing-input"
289
- : null,
290
- left: left.value,
291
- right: right.value,
292
- relation: null,
293
- notEvaluatedReasons: [compared.reason],
294
- expressionOverflows: [],
295
- });
296
- const pass = passes(compared.relation);
297
- return result({
298
- status: pass ? "pass" : "triggered",
299
- triggerReason: pass ? null : "predicate",
462
+ status,
463
+ triggerReason,
300
464
  left: left.value,
301
465
  right: right.value,
302
- relation: compared.relation,
303
- notEvaluatedReasons: [],
304
- expressionOverflows: [],
305
- });
466
+ relation,
467
+ notEvaluatedReasons,
468
+ expressionOverflows: overflows,
469
+ };
470
+ return (rule.kind === "layer-order"
471
+ ? { ...value, comparability: rule.comparability }
472
+ : value);
306
473
  }
307
474
  function filterCells(prepared, filter) {
308
475
  if (!filter)
@@ -407,7 +574,7 @@ export function evaluateDiagnosticReviewRulesCompact(prepared) {
407
574
  const invariant = parent
408
575
  ? compactEvaluationCache.get(parent) ?? evaluateDiagnosticReviewRulesCompact(parent)
409
576
  : undefined;
410
- const compactSources = createCompactCellSourceReader();
577
+ const compactSources = createCompactCellSourceReader(prepared.cells.length);
411
578
  const builder = createCompactReviewBuilder(prepared.definition.definition.reviewRules, compactSources);
412
579
  visitDiagnosticReviewRules(prepared, builder.append, compactSources, invariant !== undefined, coordinateParent !== undefined);
413
580
  const evaluated = builder.finish();
@@ -418,17 +585,45 @@ export function evaluateDiagnosticReviewRulesCompact(prepared) {
418
585
  /** One traversal and one formula implementation serve both storage contracts. */
419
586
  function visitDiagnosticReviewRules(prepared, appendEvaluation, compactSources, controlsOnly = false, includeMonotonic = false) {
420
587
  const evaluateExpression = createCellExpressionEvaluator(prepared, compactSources);
421
- const cellByCoordinate = new Map();
588
+ const ownedEmpty = freshEmptyLists();
589
+ // Only the compact builder, which copies what it keeps, receives reused records.
590
+ const scratch = compactSources === undefined ? undefined : createEvaluationScratch();
591
+ const constantOperand = (value) => {
592
+ if (scratch === undefined)
593
+ return constant(value);
594
+ let operand = scratch.constants.get(value);
595
+ if (operand === undefined) {
596
+ operand = { value, reasons: NO_REASONS, overflows: NO_REVIEW_OVERFLOWS, sources: NO_SOURCES };
597
+ scratch.constants.set(value, operand);
598
+ }
599
+ return operand;
600
+ };
601
+ const cells = prepared.cells;
602
+ // Cell positions, not cell objects, key every per-cell cache in this walk.
603
+ const cellIndexByCoordinate = new Map();
422
604
  const expectedSourcesByCoordinate = new Map();
423
- const coordinateKey = (sourceGroup, origin, valuation) => canonicalJson([sourceGroup, origin, valuation]);
605
+ const coordinateKey = (sourceGroup, origin, valuation) => canonicalScalarArrayJson([sourceGroup, origin, valuation]);
606
+ // One review coordinate per cell; every rule's scope for that cell cites it.
607
+ const coordinates = new Array(cells.length);
608
+ const coordinateOf = (cell, cellIndex) => {
609
+ let value = coordinates[cellIndex];
610
+ if (!value) {
611
+ value = coordinate(cell);
612
+ coordinates[cellIndex] = value;
613
+ }
614
+ return value;
615
+ };
616
+ // Cells and expected-cell sources are resolved once per group; every
617
+ // monotonic rule then walks the same ordered valuations by position.
424
618
  const monotonicGroups = [];
425
619
  // Keep the original traversal and source order; only replace repeated searches.
426
620
  // Other rule families do not need these indices.
427
621
  if ((!controlsOnly || includeMonotonic) && prepared.definition.definition.reviewRules.some((rule) => rule.kind === "monotonic")) {
428
- for (const cell of prepared.cells) {
622
+ for (let index = 0; index < cells.length; index++) {
623
+ const cell = cells[index];
429
624
  const key = coordinateKey(cell.sourceGroup, cell.origin, cell.valuation);
430
- if (!cellByCoordinate.has(key))
431
- cellByCoordinate.set(key, cell);
625
+ if (!cellIndexByCoordinate.has(key))
626
+ cellIndexByCoordinate.set(key, index);
432
627
  }
433
628
  for (const cell of prepared.expectedCells) {
434
629
  if (!cell.source)
@@ -442,7 +637,7 @@ function visitDiagnosticReviewRules(prepared, appendEvaluation, compactSources,
442
637
  // the rule expression. Build them once for all monotonic rules in this run.
443
638
  const groups = new Map();
444
639
  for (const cell of [...prepared.cells, ...prepared.expectedCells]) {
445
- const key = canonicalJson([cell.sourceGroup, cell.origin]);
640
+ const key = canonicalScalarArrayJson([cell.sourceGroup, cell.origin]);
446
641
  const values = groups.get(key) ?? new Set();
447
642
  values.add(cell.valuation);
448
643
  groups.set(key, values);
@@ -459,7 +654,13 @@ function visitDiagnosticReviewRules(prepared, appendEvaluation, compactSources,
459
654
  for (const [key, values] of [...groups.entries()].sort(([left], [right]) => codeUnit(left, right))) {
460
655
  const [sourceGroup, origin] = JSON.parse(key);
461
656
  const valuations = [...values].sort((left, right) => valuationCoordinate(left) - valuationCoordinate(right) || codeUnit(left, right));
462
- monotonicGroups.push({ sourceGroup, origin, valuations });
657
+ monotonicGroups.push({
658
+ sourceGroup,
659
+ origin,
660
+ valuations,
661
+ cellIndices: valuations.map((valuation) => cellIndexByCoordinate.get(coordinateKey(sourceGroup, origin, valuation))),
662
+ expected: valuations.map((valuation) => expectedSourcesByCoordinate.get(coordinateKey(sourceGroup, origin, valuation)) ?? NO_SOURCES),
663
+ });
463
664
  }
464
665
  }
465
666
  for (const [ruleIndex, rule] of prepared.definition.definition
@@ -563,52 +764,64 @@ function visitDiagnosticReviewRules(prepared, appendEvaluation, compactSources,
563
764
  measureIds.reduce((count, measureId) => count + (cell.contributions[measureId]?.length ?? 0), 0), 0),
564
765
  sources,
565
766
  };
566
- appendEvaluation(evaluation(prepared, rule, scope, left, constant(rule.expected), (relation) => relation === "equal"));
767
+ appendEvaluation(evaluation(prepared, rule, scope, left, constant(rule.expected), (relation) => relation === "equal", NO_REASONS, ownedEmpty));
567
768
  continue;
568
769
  }
569
770
  if (rule.kind === "monotonic") {
570
- for (const { sourceGroup, origin, valuations: sorted } of monotonicGroups) {
771
+ const expressionPath = `${basePath}/expression`;
772
+ const monotonicPasses = (relation) => rule.direction === "nondecreasing" ? relation !== "greater" : relation !== "less";
773
+ for (const { sourceGroup, origin, valuations: sorted, cellIndices, expected } of monotonicGroups) {
571
774
  for (let index = 1; index < sorted.length; index++) {
572
775
  const previousValuation = sorted[index - 1];
573
776
  const currentValuation = sorted[index];
574
- const previous = cellByCoordinate.get(coordinateKey(sourceGroup, origin, previousValuation));
575
- const current = cellByCoordinate.get(coordinateKey(sourceGroup, origin, currentValuation));
576
- const expectedSources = (valuation) => expectedSourcesByCoordinate.get(coordinateKey(sourceGroup, origin, valuation)) ?? [];
777
+ const previousIndex = cellIndices[index - 1];
778
+ const currentIndex = cellIndices[index];
779
+ const previous = previousIndex === undefined ? undefined : cells[previousIndex];
780
+ const current = currentIndex === undefined ? undefined : cells[currentIndex];
577
781
  const left = previous
578
- ? evaluateExpression(previous, rule.expression, `${basePath}/expression`)
782
+ ? evaluateExpression(previous, previousIndex, rule.expression, expressionPath, 0)
579
783
  : {
580
784
  value: null,
581
785
  reasons: ["missing"],
582
786
  overflows: [],
583
787
  sources: compactSources
584
- ? compactSources.forPreparedSources(expectedSources(previousValuation))
585
- : uniqueSources(expectedSources(previousValuation)),
788
+ ? compactSources.forPreparedSources(expected[index - 1])
789
+ : uniqueSources(expected[index - 1]),
586
790
  };
587
791
  const right = current
588
- ? evaluateExpression(current, rule.expression, `${basePath}/expression`)
792
+ ? evaluateExpression(current, currentIndex, rule.expression, expressionPath, 1)
589
793
  : {
590
794
  value: null,
591
795
  reasons: ["missing"],
592
796
  overflows: [],
593
797
  sources: compactSources
594
- ? compactSources.forPreparedSources(expectedSources(currentValuation))
595
- : uniqueSources(expectedSources(currentValuation)),
798
+ ? compactSources.forPreparedSources(expected[index])
799
+ : uniqueSources(expected[index]),
596
800
  };
597
- const scope = {
598
- kind: "valuation-pair",
599
- previous: previous
600
- ? coordinate(previous)
601
- : coordinateFromLabels(prepared, sourceGroup, origin, previousValuation),
602
- current: current
603
- ? coordinate(current)
604
- : coordinateFromLabels(prepared, sourceGroup, origin, currentValuation),
605
- sources: compactSources
606
- ? compactSources.union([left.sources, right.sources])
607
- : uniqueSources([...left.sources, ...right.sources]),
608
- };
609
- appendEvaluation(evaluation(prepared, rule, scope, left, right, (relation) => rule.direction === "nondecreasing"
610
- ? relation !== "greater"
611
- : relation !== "less"));
801
+ const previousCoordinate = previous
802
+ ? coordinateOf(previous, previousIndex)
803
+ : coordinateFromLabels(prepared, sourceGroup, origin, previousValuation);
804
+ const currentCoordinate = current
805
+ ? coordinateOf(current, currentIndex)
806
+ : coordinateFromLabels(prepared, sourceGroup, origin, currentValuation);
807
+ const pairSources = compactSources
808
+ ? compactSources.union([left.sources, right.sources])
809
+ : uniqueSources([...left.sources, ...right.sources]);
810
+ let scope;
811
+ if (scratch !== undefined) {
812
+ scratch.pairScope.previous = previousCoordinate;
813
+ scratch.pairScope.current = currentCoordinate;
814
+ scratch.pairScope.sources = pairSources;
815
+ scope = scratch.pairScope;
816
+ }
817
+ else
818
+ scope = {
819
+ kind: "valuation-pair",
820
+ previous: previousCoordinate,
821
+ current: currentCoordinate,
822
+ sources: pairSources,
823
+ };
824
+ appendEvaluation(evaluation(prepared, rule, scope, left, right, monotonicPasses, NO_REASONS, ownedEmpty, scratch));
612
825
  }
613
826
  }
614
827
  continue;
@@ -626,33 +839,53 @@ function visitDiagnosticReviewRules(prepared, appendEvaluation, compactSources,
626
839
  : compactSources.measureIds(expression))),
627
840
  ].sort(codeUnit))
628
841
  : undefined;
629
- for (const cell of prepared.cells) {
630
- const scopeFor = (leftSources, rightSources) => ({
842
+ // Operand paths depend only on the rule; build them once, not per cell.
843
+ const leftPath = `${basePath}/when/left`;
844
+ const rightPath = `${basePath}/when/right`;
845
+ const actualPath = `${basePath}/actual`;
846
+ const expectedPath = `${basePath}/expected`;
847
+ const narrowerPath = `${basePath}/narrower`;
848
+ const broaderPath = `${basePath}/broader`;
849
+ // Per-rule helpers: created once, not once per cell.
850
+ const scopeFor = (cell, cellIndex, leftSources, rightSources) => {
851
+ if (scratch !== undefined) {
852
+ scratch.cellScope.cell = coordinateOf(cell, cellIndex);
853
+ scratch.cellScope.sources = compactSources.forCell(cell, cellIndex, scopeDependencies);
854
+ return scratch.cellScope;
855
+ }
856
+ return {
631
857
  kind: "cell",
632
- cell: coordinate(cell),
858
+ cell: coordinateOf(cell, cellIndex),
633
859
  sources: compactSources
634
- ? compactSources.forCell(cell, scopeDependencies)
860
+ ? compactSources.forCell(cell, cellIndex, scopeDependencies)
635
861
  : uniqueSources([...leftSources, ...rightSources]),
636
- });
862
+ };
863
+ };
864
+ const comparePasses = (relation) => rule.kind === "compare" && !diagnosticPredicateMatches(rule.when.operator, relation);
865
+ const reconcilePasses = (relation) => relation === "equal";
866
+ const layerPasses = (relation) => relation !== "greater";
867
+ for (let index = 0; index < cells.length; index++) {
868
+ const cell = cells[index];
637
869
  if (rule.kind === "compare") {
638
- const operand = (item, path) => item.op === "constant"
639
- ? constant(item.value)
640
- : evaluateExpression(cell, item, path);
641
- const left = operand(rule.when.left, `${basePath}/when/left`);
642
- const right = operand(rule.when.right, `${basePath}/when/right`);
643
- appendEvaluation(evaluation(prepared, rule, scopeFor(left.sources, right.sources), left, right, (relation) => !diagnosticPredicateMatches(rule.when.operator, relation)));
870
+ const left = rule.when.left.op === "constant"
871
+ ? constantOperand(rule.when.left.value)
872
+ : evaluateExpression(cell, index, rule.when.left, leftPath, 0);
873
+ const right = rule.when.right.op === "constant"
874
+ ? constantOperand(rule.when.right.value)
875
+ : evaluateExpression(cell, index, rule.when.right, rightPath, 1);
876
+ appendEvaluation(evaluation(prepared, rule, scopeFor(cell, index, left.sources, right.sources), left, right, comparePasses, NO_REASONS, ownedEmpty, scratch));
644
877
  }
645
878
  else if (rule.kind === "reconcile") {
646
- const left = evaluateExpression(cell, rule.actual, `${basePath}/actual`);
879
+ const left = evaluateExpression(cell, index, rule.actual, actualPath, 0);
647
880
  const right = rule.expected.op === "constant"
648
- ? constant(rule.expected.value)
649
- : evaluateExpression(cell, rule.expected, `${basePath}/expected`);
650
- appendEvaluation(evaluation(prepared, rule, scopeFor(left.sources, right.sources), left, right, (relation) => relation === "equal"));
881
+ ? constantOperand(rule.expected.value)
882
+ : evaluateExpression(cell, index, rule.expected, expectedPath, 1);
883
+ appendEvaluation(evaluation(prepared, rule, scopeFor(cell, index, left.sources, right.sources), left, right, reconcilePasses, NO_REASONS, ownedEmpty, scratch));
651
884
  }
652
885
  else {
653
- const left = evaluateExpression(cell, rule.narrower, `${basePath}/narrower`);
654
- const right = evaluateExpression(cell, rule.broader, `${basePath}/broader`);
655
- appendEvaluation(evaluation(prepared, rule, scopeFor(left.sources, right.sources), left, right, (relation) => relation !== "greater"));
886
+ const left = evaluateExpression(cell, index, rule.narrower, narrowerPath, 0);
887
+ const right = evaluateExpression(cell, index, rule.broader, broaderPath, 1);
888
+ appendEvaluation(evaluation(prepared, rule, scopeFor(cell, index, left.sources, right.sources), left, right, layerPasses, NO_REASONS, ownedEmpty, scratch));
656
889
  }
657
890
  }
658
891
  }