@actuarial-ts/core 0.10.0 → 0.11.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 (87) hide show
  1. package/README.md +9 -7
  2. package/dist/canonical.d.ts.map +1 -1
  3. package/dist/canonical.js +64 -5
  4. package/dist/canonical.js.map +1 -1
  5. package/dist/customizationContracts.d.ts +10 -1
  6. package/dist/customizationContracts.d.ts.map +1 -1
  7. package/dist/descriptiveStatistics.js +1 -1
  8. package/dist/descriptiveStatistics.js.map +1 -1
  9. package/dist/diagnosticAggregation.d.ts +26 -0
  10. package/dist/diagnosticAggregation.d.ts.map +1 -1
  11. package/dist/diagnosticAggregation.js +125 -11
  12. package/dist/diagnosticAggregation.js.map +1 -1
  13. package/dist/diagnosticAuditText.d.ts +11 -0
  14. package/dist/diagnosticAuditText.d.ts.map +1 -0
  15. package/dist/diagnosticAuditText.js +179 -0
  16. package/dist/diagnosticAuditText.js.map +1 -0
  17. package/dist/diagnosticFormulas.d.ts.map +1 -1
  18. package/dist/diagnosticFormulas.js +71 -12
  19. package/dist/diagnosticFormulas.js.map +1 -1
  20. package/dist/diagnosticFreeze.d.ts +15 -1
  21. package/dist/diagnosticFreeze.d.ts.map +1 -1
  22. package/dist/diagnosticFreeze.js +82 -31
  23. package/dist/diagnosticFreeze.js.map +1 -1
  24. package/dist/diagnosticKeys.d.ts +9 -0
  25. package/dist/diagnosticKeys.d.ts.map +1 -0
  26. package/dist/diagnosticKeys.js +20 -0
  27. package/dist/diagnosticKeys.js.map +1 -0
  28. package/dist/diagnosticOrdering.d.ts.map +1 -1
  29. package/dist/diagnosticOrdering.js +9 -2
  30. package/dist/diagnosticOrdering.js.map +1 -1
  31. package/dist/diagnosticPeriods.d.ts.map +1 -1
  32. package/dist/diagnosticPeriods.js +19 -1
  33. package/dist/diagnosticPeriods.js.map +1 -1
  34. package/dist/diagnosticPreparation.d.ts +19 -0
  35. package/dist/diagnosticPreparation.d.ts.map +1 -1
  36. package/dist/diagnosticPreparation.js +544 -231
  37. package/dist/diagnosticPreparation.js.map +1 -1
  38. package/dist/diagnosticReview.d.ts.map +1 -1
  39. package/dist/diagnosticReview.js +419 -186
  40. package/dist/diagnosticReview.js.map +1 -1
  41. package/dist/diagnosticReviewSources.d.ts +6 -1
  42. package/dist/diagnosticReviewSources.d.ts.map +1 -1
  43. package/dist/diagnosticReviewSources.js +59 -14
  44. package/dist/diagnosticReviewSources.js.map +1 -1
  45. package/dist/diagnosticReviewStore.d.ts.map +1 -1
  46. package/dist/diagnosticReviewStore.js +48 -7
  47. package/dist/diagnosticReviewStore.js.map +1 -1
  48. package/dist/diagnosticRunner.d.ts.map +1 -1
  49. package/dist/diagnosticRunner.js +112 -37
  50. package/dist/diagnosticRunner.js.map +1 -1
  51. package/dist/diagnosticRuntime.d.ts.map +1 -1
  52. package/dist/diagnosticRuntime.js +11 -1
  53. package/dist/diagnosticRuntime.js.map +1 -1
  54. package/dist/diagnosticSourceOrdering.d.ts +27 -0
  55. package/dist/diagnosticSourceOrdering.d.ts.map +1 -1
  56. package/dist/diagnosticSourceOrdering.js +98 -12
  57. package/dist/diagnosticSourceOrdering.js.map +1 -1
  58. package/dist/mack.d.ts.map +1 -1
  59. package/dist/mack.js +35 -0
  60. package/dist/mack.js.map +1 -1
  61. package/dist/types.d.ts +1 -1
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js +1 -0
  64. package/dist/types.js.map +1 -1
  65. package/dist/version.d.ts +1 -1
  66. package/dist/version.js +1 -1
  67. package/package.json +1 -1
  68. package/src/canonical.ts +63 -5
  69. package/src/customizationContracts.ts +10 -1
  70. package/src/descriptiveStatistics.ts +1 -1
  71. package/src/diagnosticAggregation.ts +173 -9
  72. package/src/diagnosticAuditText.ts +187 -0
  73. package/src/diagnosticFormulas.ts +77 -27
  74. package/src/diagnosticFreeze.ts +75 -30
  75. package/src/diagnosticKeys.ts +22 -0
  76. package/src/diagnosticOrdering.ts +9 -8
  77. package/src/diagnosticPeriods.ts +21 -1
  78. package/src/diagnosticPreparation.ts +624 -272
  79. package/src/diagnosticReview.ts +566 -268
  80. package/src/diagnosticReviewSources.ts +69 -18
  81. package/src/diagnosticReviewStore.ts +48 -6
  82. package/src/diagnosticRunner.ts +133 -54
  83. package/src/diagnosticRuntime.ts +11 -1
  84. package/src/diagnosticSourceOrdering.ts +115 -11
  85. package/src/mack.ts +41 -0
  86. package/src/types.ts +1 -0
  87. package/src/version.ts +1 -1
@@ -2,12 +2,14 @@ import {
2
2
  getCompiledDiagnosticDefinitionInternals,
3
3
  type DiagnosticControlTotalProjection,
4
4
  type DiagnosticDeepReadonly,
5
+ type DiagnosticMeasureStats,
5
6
  type DiagnosticReviewFilter,
6
7
  type DiagnosticReviewRule,
7
8
  type DiagnosticSourceLocation,
8
9
  } from "./diagnosticDefinitions.js";
9
10
  import type { NormalizedDiagnosticReviewFilterIdentity } from "./diagnosticIdentity.js";
10
11
  import { canonicalJson } from "./canonical.js";
12
+ import { canonicalScalarArrayJson } from "./diagnosticKeys.js";
11
13
  import { createDiagnosticReviewSourcePool } from "./diagnosticReviewSources.js";
12
14
  import { createDiagnosticEvidenceInterner } from "./diagnosticEvidenceIntern.js";
13
15
  import { normalizeDiagnosticPeriod } from "./diagnosticPeriods.js";
@@ -15,6 +17,7 @@ import {
15
17
  evaluateDiagnosticMeasureExpression,
16
18
  type FinalizedDiagnosticMeasure,
17
19
  } from "./diagnosticFormulas.js";
20
+ import { hasDiagnosticOwn } from "./diagnosticRuntime.js";
18
21
  import {
19
22
  assertPreparedDiagnosticData,
20
23
  assertCompactPreparedDiagnosticData,
@@ -128,6 +131,12 @@ interface EvaluatedOperand {
128
131
  readonly sources: readonly DiagnosticSourceLocation[];
129
132
  }
130
133
 
134
+ // Shared immutable empties for the overwhelmingly common clean cell: readers
135
+ // only iterate or spread these, and every retained evaluation owns fresh arrays.
136
+ const NO_REASONS: readonly DiagnosticRuleNotEvaluatedReason[] = Object.freeze([]);
137
+ const NO_REVIEW_OVERFLOWS: readonly DiagnosticReviewExpressionOverflow[] = Object.freeze([]);
138
+ const NO_SOURCES: readonly DiagnosticSourceLocation[] = Object.freeze([]);
139
+
131
140
  const REASON_ORDER: readonly DiagnosticRuleNotEvaluatedReason[] = [
132
141
  "missing",
133
142
  "imputed",
@@ -184,46 +193,66 @@ function expressionLeafPaths(
184
193
  ];
185
194
  }
186
195
 
196
+ // A projected state depends only on its measure's definition and the cell's
197
+ // statistics record, and preparation shares equal statistics records across
198
+ // cells, so one projection per (measure, record) serves every cell holding it.
199
+ // The cache lives in one review invocation, so no projection crosses
200
+ // preparations or definitions. Bounded like the interner.
201
+ const MAX_CACHED_STATES = 100_000;
202
+ type StateCache = Map<string, Map<DiagnosticMeasureStats, FinalizedDiagnosticMeasure>>;
203
+
187
204
  function states(
188
205
  prepared: PreparedDiagnosticDataContent,
189
206
  cell: PreparedDiagnosticData["cells"][number],
207
+ cache: StateCache,
190
208
  ): Record<string, FinalizedDiagnosticMeasure> {
191
209
  const internals = getCompiledDiagnosticDefinitionInternals(
192
210
  prepared.definition,
193
211
  );
194
- return Object.fromEntries(
195
- Object.entries(cell.components).map(([id, stats]) => {
212
+ const result: Record<string, FinalizedDiagnosticMeasure> = {};
213
+ const ids = Object.keys(cell.components);
214
+ let cached = 0;
215
+ for (let index = 0; index < ids.length; index++) {
216
+ const id = ids[index]!;
217
+ const stats = cell.components[id]!;
218
+ let byStats = cache.get(id);
219
+ if (byStats === undefined) {
220
+ byStats = new Map();
221
+ cache.set(id, byStats);
222
+ }
223
+ let state = byStats.get(stats);
224
+ if (state === undefined) {
196
225
  const measure = internals.measuresById.get(id)!;
197
- const readiness: DiagnosticRuleNotEvaluatedReason[] = [];
226
+ let readiness: DiagnosticRuleNotEvaluatedReason[] | undefined;
198
227
  if (stats.missing)
199
- readiness.push(stats.imputedZero ? "imputed" : "missing");
200
- if (stats.nonFinite) readiness.push("non-finite");
201
- if (stats.structural) readiness.push("structural-ambiguity");
228
+ (readiness ??= []).push(stats.imputedZero ? "imputed" : "missing");
229
+ if (stats.nonFinite) (readiness ??= []).push("non-finite");
230
+ if (stats.structural) (readiness ??= []).push("structural-ambiguity");
202
231
  if (stats.sum === null && !stats.nonFinite && !stats.structural)
203
- readiness.push("aggregation-overflow");
204
- return [
205
- id,
206
- {
207
- quantity: {
208
- kind: measure.kind,
209
- unit: measure.unit,
210
- ...(measure.basisId ? { basisId: measure.basisId } : {}),
211
- ...(measure.countPopulationId
212
- ? { countPopulationId: measure.countPopulationId }
213
- : {}),
214
- ...(measure.exposureBasisId
215
- ? { exposureBasisId: measure.exposureBasisId }
216
- : {}),
217
- value: stats.value,
218
- },
219
- stats,
220
- readiness,
221
- // Cell expressions attach complete leaf-source evidence separately.
222
- // Per-measure source lists here would be discarded and rebuilt.
223
- },
224
- ];
225
- }),
226
- );
232
+ (readiness ??= []).push("aggregation-overflow");
233
+ const quantity: Record<string, unknown> = {
234
+ kind: measure.kind,
235
+ unit: measure.unit,
236
+ };
237
+ if (measure.basisId) quantity.basisId = measure.basisId;
238
+ if (measure.countPopulationId)
239
+ quantity.countPopulationId = measure.countPopulationId;
240
+ if (measure.exposureBasisId)
241
+ quantity.exposureBasisId = measure.exposureBasisId;
242
+ quantity.value = stats.value;
243
+ state = {
244
+ quantity: quantity as unknown as FinalizedDiagnosticMeasure["quantity"],
245
+ stats,
246
+ readiness: readiness ?? NO_REASONS,
247
+ // Cell expressions attach complete leaf-source evidence separately.
248
+ // Per-measure source lists here would be discarded and rebuilt.
249
+ };
250
+ cached += byStats.size;
251
+ if (cached < MAX_CACHED_STATES) byStats.set(stats, state);
252
+ }
253
+ result[id] = state;
254
+ }
255
+ return result;
227
256
  }
228
257
 
229
258
  function coordinate(
@@ -300,18 +329,32 @@ function cellSourcesForSelection(
300
329
  }
301
330
 
302
331
  /** Compact-only, invocation-owned caches. Never retain these on a prepared cell. */
303
- function createCompactCellSourceReader() {
332
+ function createCompactCellSourceReader(cellCount: number) {
304
333
  const pool = createDiagnosticReviewSourcePool();
305
334
  const addCellSources = (
306
335
  target: ReturnType<typeof pool.collection>,
307
336
  cell: PreparedDiagnosticData["cells"][number],
308
337
  ids: readonly string[],
309
338
  ): void => {
310
- for (const id of ids) {
311
- for (const contribution of cell.contributions[id] ?? [])
312
- for (const source of contribution.sources) target.add(source, true);
313
- for (const blocker of cell.structuralBlockers[id] ?? [])
314
- for (const source of blocker.sources) target.add(source, true);
339
+ // Preparation cites one frozen list per source row across its measures;
340
+ // adding the same list again is a no-op, so skip it by identity.
341
+ let previous: readonly DiagnosticSourceLocation[] | undefined;
342
+ for (let position = 0; position < ids.length; position++) {
343
+ const id = ids[position]!;
344
+ const contributions = cell.contributions[id];
345
+ if (contributions !== undefined)
346
+ for (let index = 0; index < contributions.length; index++) {
347
+ const sources = contributions[index]!.sources;
348
+ if (sources === previous) continue;
349
+ previous = sources;
350
+ for (let at = 0; at < sources.length; at++) target.add(sources[at]!, true);
351
+ }
352
+ const blockers = cell.structuralBlockers[id];
353
+ if (blockers !== undefined)
354
+ for (let index = 0; index < blockers.length; index++) {
355
+ const sources = blockers[index]!.sources;
356
+ for (let at = 0; at < sources.length; at++) target.add(sources[at]!, true);
357
+ }
315
358
  }
316
359
  };
317
360
  const forSelection = (
@@ -324,11 +367,33 @@ function createCompactCellSourceReader() {
324
367
  return target.finish();
325
368
  };
326
369
  const dependencies = new WeakMap<object, readonly string[]>();
327
- const dependencyKeys = new WeakMap<object, string>();
328
- const byCell = new WeakMap<
329
- PreparedDiagnosticData["cells"][number],
330
- Map<string, readonly DiagnosticSourceLocation[]>
331
- >();
370
+ // Distinct dependency subsets are few (one per rule expression); each gets a
371
+ // small index, and equal subsets from different expressions share it.
372
+ const subsetIndexByKey = new Map<string, number>();
373
+ const subsetIndexByIds = new Map<readonly string[], number>();
374
+ // Invocation-scoped like the pool: cached lists live in flat arrays indexed
375
+ // by subset and cell position, released with this reader and never attached
376
+ // to a prepared cell.
377
+ const listsBySubset: ((readonly DiagnosticSourceLocation[] | undefined)[])[] = [];
378
+ const subsetIndex = (ids: readonly string[]): number => {
379
+ let index = subsetIndexByIds.get(ids);
380
+ if (index === undefined) {
381
+ const key = canonicalJson(ids);
382
+ index = subsetIndexByKey.get(key);
383
+ if (index === undefined) {
384
+ index = listsBySubset.length;
385
+ listsBySubset.push(new Array(cellCount));
386
+ subsetIndexByKey.set(key, index);
387
+ }
388
+ subsetIndexByIds.set(ids, index);
389
+ }
390
+ return index;
391
+ };
392
+ // The one citation list most of a cell's subsets resolve to, and its owned list.
393
+ const cellSingletons: (
394
+ | { readonly cited: readonly DiagnosticSourceLocation[]; readonly list: readonly DiagnosticSourceLocation[] }
395
+ | undefined
396
+ )[] = new Array(cellCount);
332
397
  const measureIds = (
333
398
  expression: import("./diagnosticExpressions.js").DiagnosticMeasureExpression,
334
399
  ): readonly string[] => {
@@ -341,24 +406,46 @@ function createCompactCellSourceReader() {
341
406
  };
342
407
  const forCell = (
343
408
  cell: PreparedDiagnosticData["cells"][number],
409
+ cellIndex: number,
344
410
  ids: readonly string[],
345
411
  ): readonly DiagnosticSourceLocation[] => {
346
- let key = dependencyKeys.get(ids);
347
- if (key === undefined) {
348
- key = canonicalJson(ids);
349
- dependencyKeys.set(ids, key);
350
- }
351
- let subsets = byCell.get(cell);
352
- if (!subsets) {
353
- subsets = new Map();
354
- byCell.set(cell, subsets);
355
- }
356
- let sources = subsets.get(key);
412
+ const lists = listsBySubset[subsetIndex(ids)]!;
413
+ let sources = lists[cellIndex];
357
414
  if (!sources) {
358
- const target = pool.collection();
359
- addCellSources(target, cell, ids);
360
- sources = target.finish();
361
- subsets.set(key, sources);
415
+ // Most subsets cite exactly one citation list of one prepared source
416
+ // (the cell's single row) and no blockers: resolve that list directly.
417
+ // Any other shape goes through the general collection.
418
+ let only: readonly DiagnosticSourceLocation[] | null | undefined;
419
+ for (let position = 0; position < ids.length && only !== null; position++) {
420
+ const id = ids[position]!;
421
+ const contributions = cell.contributions[id];
422
+ if (contributions !== undefined)
423
+ for (let index = 0; index < contributions.length; index++) {
424
+ const cited = contributions[index]!.sources;
425
+ if (only === undefined) only = cited;
426
+ else if (only !== cited) {
427
+ only = null;
428
+ break;
429
+ }
430
+ }
431
+ const blockers = cell.structuralBlockers[id];
432
+ if (blockers !== undefined && blockers.length > 0) only = null;
433
+ }
434
+ if (only === undefined) sources = pool.emptyList;
435
+ else if (only !== null && only.length === 1) {
436
+ const cached = cellSingletons[cellIndex];
437
+ if (cached !== undefined && cached.cited === only) sources = cached.list;
438
+ else {
439
+ sources = pool.singletonForPrepared(only[0]!);
440
+ cellSingletons[cellIndex] = { cited: only, list: sources };
441
+ }
442
+ } else if (only !== null && only.length === 0) sources = pool.emptyList;
443
+ else {
444
+ const target = pool.collection();
445
+ addCellSources(target, cell, ids);
446
+ sources = target.finish();
447
+ }
448
+ lists[cellIndex] = sources;
362
449
  }
363
450
  return sources;
364
451
  };
@@ -377,45 +464,174 @@ function createCellExpressionEvaluator(
377
464
  // Reuse projections across this review's rules, then release them before the
378
465
  // receipt identity is constructed. Retaining a prepared cell must not retain
379
466
  // this temporary bookkeeping or reuse another review's state.
380
- const statesByCell = new WeakMap<
381
- PreparedDiagnosticData["cells"][number],
382
- Record<string, FinalizedDiagnosticMeasure>
383
- >();
467
+ const statesByCell: (Record<string, FinalizedDiagnosticMeasure> | undefined)[] =
468
+ new Array(prepared.cells.length);
469
+ const stateCache: StateCache = new Map();
470
+ // Compact mode: the two operands of an evaluation are read and dropped, so
471
+ // one mutable operand per side is reused. The eager path keeps fresh objects.
472
+ const reusable: MutableOperand[] | undefined = compactSources
473
+ ? [
474
+ { value: null, reasons: NO_REASONS, overflows: NO_REVIEW_OVERFLOWS, sources: NO_SOURCES },
475
+ { value: null, reasons: NO_REASONS, overflows: NO_REVIEW_OVERFLOWS, sources: NO_SOURCES },
476
+ ]
477
+ : undefined;
478
+ const operand = (
479
+ slot: number,
480
+ value: number | null,
481
+ reasons: readonly DiagnosticRuleNotEvaluatedReason[],
482
+ overflows: readonly DiagnosticReviewExpressionOverflow[],
483
+ sources: readonly DiagnosticSourceLocation[],
484
+ ): EvaluatedOperand => {
485
+ if (reusable === undefined || slot < 0) return { value, reasons, overflows, sources };
486
+ const target = reusable[slot]!;
487
+ target.value = value;
488
+ target.reasons = reasons;
489
+ target.overflows = overflows;
490
+ target.sources = sources;
491
+ return target;
492
+ };
384
493
  return (
385
494
  cell: PreparedDiagnosticData["cells"][number],
495
+ cellIndex: number,
386
496
  expression: import("./diagnosticExpressions.js").DiagnosticMeasureExpression,
387
497
  path: string,
498
+ slot = -1,
388
499
  ): EvaluatedOperand => {
389
- let measures = statesByCell.get(cell);
500
+ let measures = statesByCell[cellIndex];
390
501
  if (!measures) {
391
- measures = states(prepared, cell);
392
- statesByCell.set(cell, measures);
502
+ measures = states(prepared, cell, stateCache);
503
+ statesByCell[cellIndex] = measures;
504
+ }
505
+ const sources = compactSources
506
+ ? compactSources.forCell(cell, cellIndex, compactSources.measureIds(expression))
507
+ : cellSources(cell, expressionMeasureIds(expression));
508
+ // A measure leaf is read straight from the projected state: the generic
509
+ // evaluator would build the same result and hand it back unchanged.
510
+ if (expression.op === "measure") {
511
+ const state = hasDiagnosticOwn(measures, expression.measureId)
512
+ ? measures[expression.measureId]
513
+ : undefined;
514
+ if (state === undefined)
515
+ return operand(slot, null, ["missing"], NO_REVIEW_OVERFLOWS, sources);
516
+ const overflows = state.expressionOverflows;
517
+ return operand(
518
+ slot,
519
+ state.quantity.value,
520
+ state.readiness,
521
+ overflows === undefined || overflows.length === 0
522
+ ? NO_REVIEW_OVERFLOWS
523
+ : overflows.map((overflow) => ({ ...overflow, coordinate: coordinate(cell), sources })),
524
+ sources,
525
+ );
393
526
  }
394
527
  const result = evaluateDiagnosticMeasureExpression(
395
528
  expression,
396
529
  measures,
397
530
  path,
398
531
  );
399
- const sources = compactSources
400
- ? compactSources.forCell(cell, compactSources.measureIds(expression))
401
- : cellSources(cell, expressionMeasureIds(expression));
402
- return {
403
- value: result.value,
404
- reasons: result.reasons,
405
- overflows: result.overflows.map((overflow) => ({
406
- ...overflow,
407
- coordinate: coordinate(cell),
408
- sources,
409
- })),
532
+ return operand(
533
+ slot,
534
+ result.value,
535
+ result.reasons,
536
+ result.overflows.length === 0
537
+ ? NO_REVIEW_OVERFLOWS
538
+ : result.overflows.map((overflow) => ({
539
+ ...overflow,
540
+ coordinate: coordinate(cell),
541
+ sources,
542
+ })),
410
543
  sources,
411
- };
544
+ );
412
545
  };
413
546
  }
414
547
 
548
+ interface MutableOperand {
549
+ value: number | null;
550
+ reasons: readonly DiagnosticRuleNotEvaluatedReason[];
551
+ overflows: readonly DiagnosticReviewExpressionOverflow[];
552
+ sources: readonly DiagnosticSourceLocation[];
553
+ }
554
+
415
555
  function constant(value: number): EvaluatedOperand {
416
556
  return { value, reasons: [], overflows: [], sources: [] };
417
557
  }
418
558
 
559
+ /**
560
+ * Compact-mode reuse. The compact builder copies every field it keeps from an
561
+ * evaluation record and its scope, so one mutable record, one cell scope and
562
+ * one valuation-pair scope per invocation can carry every evaluation to it.
563
+ * The eager path retains its records and never receives these.
564
+ */
565
+ interface EvaluationScratch {
566
+ readonly record: {
567
+ ruleId: string;
568
+ ruleKind: DiagnosticReviewRule["kind"];
569
+ severity: "warning" | "fail";
570
+ scope: DiagnosticReviewEvaluationScope;
571
+ status: DiagnosticReviewRuleEvaluationBase["status"];
572
+ triggerReason: DiagnosticReviewRuleEvaluationBase["triggerReason"];
573
+ left: number | null;
574
+ right: number | null;
575
+ relation: DiagnosticReviewRuleEvaluationBase["relation"];
576
+ notEvaluatedReasons: readonly DiagnosticRuleNotEvaluatedReason[];
577
+ expressionOverflows: readonly DiagnosticReviewExpressionOverflow[];
578
+ comparability: unknown;
579
+ };
580
+ readonly cellScope: {
581
+ readonly kind: "cell";
582
+ cell: DiagnosticReviewCoordinate;
583
+ sources: readonly DiagnosticSourceLocation[];
584
+ };
585
+ readonly pairScope: {
586
+ readonly kind: "valuation-pair";
587
+ previous: DiagnosticReviewCoordinate;
588
+ current: DiagnosticReviewCoordinate;
589
+ sources: readonly DiagnosticSourceLocation[];
590
+ };
591
+ /** Constant operands by value; they are read, never retained. */
592
+ readonly constants: Map<number, EvaluatedOperand>;
593
+ }
594
+ function createEvaluationScratch(): EvaluationScratch {
595
+ const placeholder: DiagnosticReviewCoordinate = {
596
+ sourceGroup: "",
597
+ origin: "",
598
+ valuation: "",
599
+ developmentAge: 0,
600
+ ageUnit: "",
601
+ };
602
+ return {
603
+ record: {
604
+ ruleId: "",
605
+ ruleKind: "compare",
606
+ severity: "warning",
607
+ scope: { kind: "cell", cell: placeholder, sources: NO_SOURCES },
608
+ status: "pass",
609
+ triggerReason: null,
610
+ left: null,
611
+ right: null,
612
+ relation: null,
613
+ notEvaluatedReasons: NO_REASONS,
614
+ expressionOverflows: NO_REVIEW_OVERFLOWS,
615
+ comparability: undefined,
616
+ },
617
+ cellScope: { kind: "cell", cell: placeholder, sources: NO_SOURCES },
618
+ pairScope: { kind: "valuation-pair", previous: placeholder, current: placeholder, sources: NO_SOURCES },
619
+ constants: new Map(),
620
+ };
621
+ }
622
+
623
+ /** Empty lists owned by one review invocation; its evaluations may share them. */
624
+ interface OwnedEmptyLists {
625
+ readonly reasons: readonly DiagnosticRuleNotEvaluatedReason[];
626
+ readonly overflows: readonly DiagnosticReviewExpressionOverflow[];
627
+ }
628
+ // One empty array serves both roles, exactly as the eager finalizer's interner
629
+ // would merge equal frozen empties into a single shared instance.
630
+ const freshEmptyLists = (): OwnedEmptyLists => {
631
+ const empty = Object.freeze([]) as readonly never[];
632
+ return { reasons: empty, overflows: empty };
633
+ };
634
+
419
635
  function evaluation(
420
636
  prepared: PreparedDiagnosticDataContent,
421
637
  rule: DiagnosticReviewRule,
@@ -423,9 +639,11 @@ function evaluation(
423
639
  left: EvaluatedOperand,
424
640
  right: EvaluatedOperand,
425
641
  passes: (relation: "less" | "equal" | "greater") => boolean,
426
- extraReasons: readonly DiagnosticRuleNotEvaluatedReason[] = [],
642
+ extraReasons: readonly DiagnosticRuleNotEvaluatedReason[] = NO_REASONS,
643
+ empty: OwnedEmptyLists = freshEmptyLists(),
644
+ scratch?: EvaluationScratch,
427
645
  ): DiagnosticReviewRuleEvaluation {
428
- const expressionOverflows = [
646
+ const expressionOverflows = left.overflows.length === 0 && right.overflows.length === 0 ? [] : [
429
647
  ...new Map(
430
648
  [...left.overflows, ...right.overflows].map((item) => [
431
649
  canonicalJson(item),
@@ -475,88 +693,144 @@ function evaluation(
475
693
  }
476
694
  return a.sources.length - b.sources.length;
477
695
  });
478
- const reasons = uniqueReasons([
479
- ...left.reasons,
480
- ...right.reasons,
481
- ...extraReasons,
482
- ...(expressionOverflows.length > 0 ? ["expression-overflow" as const] : []),
483
- ...((left.value === null || right.value === null) &&
696
+ // Clean evaluated operands carry no reasons; skip the spread/filter round trip.
697
+ const reasons: readonly DiagnosticRuleNotEvaluatedReason[] =
484
698
  left.reasons.length === 0 &&
485
699
  right.reasons.length === 0 &&
486
- expressionOverflows.length === 0
487
- ? ["missing" as const]
488
- : []),
489
- ]);
490
- const missingReason = (): DiagnosticReviewRuleEvaluation["triggerReason"] => {
491
- if (
492
- reasons.some((reason) =>
493
- ["missing", "imputed", "non-finite", "structural-ambiguity"].includes(
494
- reason,
495
- ),
496
- )
497
- )
498
- return "missing-input";
499
- if (reasons.includes("aggregation-overflow")) return "aggregation-overflow";
500
- if (reasons.includes("expression-overflow")) return "expression-overflow";
501
- if (reasons.includes("tolerance-overflow")) return "tolerance-overflow";
502
- return "missing-input";
503
- };
504
- const result = (
505
- value: Omit<
506
- DiagnosticReviewRuleEvaluationBase,
507
- "ruleId" | "ruleKind" | "severity" | "scope"
508
- >,
509
- ): DiagnosticReviewRuleEvaluation =>
510
- ({
511
- ruleId: rule.id,
512
- ruleKind: rule.kind,
513
- severity: rule.severity,
514
- scope,
515
- ...value,
516
- ...(rule.kind === "layer-order"
517
- ? { comparability: rule.comparability }
518
- : {}),
519
- }) as DiagnosticReviewRuleEvaluation;
700
+ extraReasons.length === 0 &&
701
+ expressionOverflows.length === 0 &&
702
+ left.value !== null &&
703
+ right.value !== null
704
+ ? []
705
+ : uniqueReasons([
706
+ ...left.reasons,
707
+ ...right.reasons,
708
+ ...extraReasons,
709
+ ...(expressionOverflows.length > 0 ? ["expression-overflow" as const] : []),
710
+ ...((left.value === null || right.value === null) &&
711
+ left.reasons.length === 0 &&
712
+ right.reasons.length === 0 &&
713
+ expressionOverflows.length === 0
714
+ ? ["missing" as const]
715
+ : []),
716
+ ]);
520
717
  if (reasons.length > 0)
521
- return result({
522
- status: rule.missingInput === "finding" ? "triggered" : "not-evaluated",
523
- triggerReason: rule.missingInput === "finding" ? missingReason() : null,
524
- left: left.value,
525
- right: right.value,
526
- relation: null,
527
- notEvaluatedReasons: reasons,
718
+ return evaluationRecord(
719
+ rule,
720
+ scope,
721
+ left,
722
+ right,
723
+ rule.missingInput === "finding" ? "triggered" : "not-evaluated",
724
+ rule.missingInput === "finding" ? missingTriggerReason(reasons) : null,
725
+ null,
726
+ reasons,
528
727
  expressionOverflows,
529
- });
728
+ scratch,
729
+ );
530
730
  const compared = classifyDiagnosticComparison(
531
731
  left.value,
532
732
  right.value,
533
733
  rule.tolerance,
534
734
  );
735
+ // Empty reason and overflow lists are this review's own immutable values;
736
+ // the eager finalizer leaves frozen children as they are and readers only
737
+ // iterate them, and no other invocation ever sees the same arrays.
535
738
  if (compared.status === "not-evaluated")
536
- return result({
537
- status: rule.missingInput === "finding" ? "triggered" : "not-evaluated",
538
- triggerReason:
539
- rule.missingInput === "finding"
540
- ? compared.reason === "tolerance-overflow"
541
- ? "tolerance-overflow"
542
- : "missing-input"
543
- : null,
544
- left: left.value,
545
- right: right.value,
546
- relation: null,
547
- notEvaluatedReasons: [compared.reason],
548
- expressionOverflows: [],
549
- });
739
+ return evaluationRecord(
740
+ rule,
741
+ scope,
742
+ left,
743
+ right,
744
+ rule.missingInput === "finding" ? "triggered" : "not-evaluated",
745
+ rule.missingInput === "finding"
746
+ ? compared.reason === "tolerance-overflow"
747
+ ? "tolerance-overflow"
748
+ : "missing-input"
749
+ : null,
750
+ null,
751
+ [compared.reason],
752
+ empty.overflows,
753
+ scratch,
754
+ );
550
755
  const pass = passes(compared.relation);
551
- return result({
552
- status: pass ? "pass" : "triggered",
553
- triggerReason: pass ? null : "predicate",
756
+ return evaluationRecord(
757
+ rule,
758
+ scope,
759
+ left,
760
+ right,
761
+ pass ? "pass" : "triggered",
762
+ pass ? null : "predicate",
763
+ compared.relation,
764
+ empty.reasons,
765
+ empty.overflows,
766
+ scratch,
767
+ );
768
+ }
769
+
770
+ function missingTriggerReason(
771
+ reasons: readonly DiagnosticRuleNotEvaluatedReason[],
772
+ ): DiagnosticReviewRuleEvaluation["triggerReason"] {
773
+ if (
774
+ reasons.some((reason) =>
775
+ ["missing", "imputed", "non-finite", "structural-ambiguity"].includes(
776
+ reason,
777
+ ),
778
+ )
779
+ )
780
+ return "missing-input";
781
+ if (reasons.includes("aggregation-overflow")) return "aggregation-overflow";
782
+ if (reasons.includes("expression-overflow")) return "expression-overflow";
783
+ if (reasons.includes("tolerance-overflow")) return "tolerance-overflow";
784
+ return "missing-input";
785
+ }
786
+
787
+ /** One literal in the contract's key order; no intermediate spread objects. */
788
+ function evaluationRecord(
789
+ rule: DiagnosticReviewRule,
790
+ scope: DiagnosticReviewEvaluationScope,
791
+ left: EvaluatedOperand,
792
+ right: EvaluatedOperand,
793
+ status: DiagnosticReviewRuleEvaluationBase["status"],
794
+ triggerReason: DiagnosticReviewRuleEvaluationBase["triggerReason"],
795
+ relation: DiagnosticReviewRuleEvaluationBase["relation"],
796
+ notEvaluatedReasons: readonly DiagnosticRuleNotEvaluatedReason[],
797
+ overflows: readonly DiagnosticReviewExpressionOverflow[],
798
+ scratch?: EvaluationScratch,
799
+ ): DiagnosticReviewRuleEvaluation {
800
+ if (scratch !== undefined) {
801
+ const target = scratch.record;
802
+ target.ruleId = rule.id;
803
+ target.ruleKind = rule.kind;
804
+ target.severity = rule.severity;
805
+ target.scope = scope;
806
+ target.status = status;
807
+ target.triggerReason = triggerReason;
808
+ target.left = left.value;
809
+ target.right = right.value;
810
+ target.relation = relation;
811
+ target.notEvaluatedReasons = notEvaluatedReasons;
812
+ target.expressionOverflows = overflows;
813
+ target.comparability = rule.kind === "layer-order" ? rule.comparability : undefined;
814
+ return target as unknown as DiagnosticReviewRuleEvaluation;
815
+ }
816
+ const value = {
817
+ ruleId: rule.id,
818
+ ruleKind: rule.kind,
819
+ severity: rule.severity,
820
+ scope,
821
+ status,
822
+ triggerReason,
554
823
  left: left.value,
555
824
  right: right.value,
556
- relation: compared.relation,
557
- notEvaluatedReasons: [],
558
- expressionOverflows: [],
559
- });
825
+ relation,
826
+ notEvaluatedReasons,
827
+ expressionOverflows: overflows,
828
+ };
829
+ return (
830
+ rule.kind === "layer-order"
831
+ ? { ...value, comparability: rule.comparability }
832
+ : value
833
+ ) as DiagnosticReviewRuleEvaluation;
560
834
  }
561
835
 
562
836
  function filterCells(
@@ -720,7 +994,7 @@ export function evaluateDiagnosticReviewRulesCompact(
720
994
  const invariant = parent
721
995
  ? compactEvaluationCache.get(parent) ?? evaluateDiagnosticReviewRulesCompact(parent)
722
996
  : undefined;
723
- const compactSources = createCompactCellSourceReader();
997
+ const compactSources = createCompactCellSourceReader(prepared.cells.length);
724
998
  const builder = createCompactReviewBuilder(
725
999
  prepared.definition.definition.reviewRules,
726
1000
  compactSources,
@@ -747,10 +1021,21 @@ function visitDiagnosticReviewRules(
747
1021
  prepared,
748
1022
  compactSources,
749
1023
  );
750
- const cellByCoordinate = new Map<
751
- string,
752
- PreparedDiagnosticData["cells"][number]
753
- >();
1024
+ const ownedEmpty = freshEmptyLists();
1025
+ // Only the compact builder, which copies what it keeps, receives reused records.
1026
+ const scratch = compactSources === undefined ? undefined : createEvaluationScratch();
1027
+ const constantOperand = (value: number): EvaluatedOperand => {
1028
+ if (scratch === undefined) return constant(value);
1029
+ let operand = scratch.constants.get(value);
1030
+ if (operand === undefined) {
1031
+ operand = { value, reasons: NO_REASONS, overflows: NO_REVIEW_OVERFLOWS, sources: NO_SOURCES };
1032
+ scratch.constants.set(value, operand);
1033
+ }
1034
+ return operand;
1035
+ };
1036
+ const cells = prepared.cells;
1037
+ // Cell positions, not cell objects, key every per-cell cache in this walk.
1038
+ const cellIndexByCoordinate = new Map<string, number>();
754
1039
  const expectedSourcesByCoordinate = new Map<
755
1040
  string,
756
1041
  DiagnosticSourceLocation[]
@@ -759,8 +1044,29 @@ function visitDiagnosticReviewRules(
759
1044
  sourceGroup: string,
760
1045
  origin: string,
761
1046
  valuation: string,
762
- ) => canonicalJson([sourceGroup, origin, valuation]);
763
- const monotonicGroups: Array<{ sourceGroup: string; origin: string; valuations: string[] }> = [];
1047
+ ) => canonicalScalarArrayJson([sourceGroup, origin, valuation]);
1048
+ // One review coordinate per cell; every rule's scope for that cell cites it.
1049
+ const coordinates: (DiagnosticReviewCoordinate | undefined)[] = new Array(cells.length);
1050
+ const coordinateOf = (
1051
+ cell: PreparedDiagnosticData["cells"][number],
1052
+ cellIndex: number,
1053
+ ): DiagnosticReviewCoordinate => {
1054
+ let value = coordinates[cellIndex];
1055
+ if (!value) {
1056
+ value = coordinate(cell);
1057
+ coordinates[cellIndex] = value;
1058
+ }
1059
+ return value;
1060
+ };
1061
+ // Cells and expected-cell sources are resolved once per group; every
1062
+ // monotonic rule then walks the same ordered valuations by position.
1063
+ const monotonicGroups: Array<{
1064
+ sourceGroup: string;
1065
+ origin: string;
1066
+ valuations: string[];
1067
+ cellIndices: (number | undefined)[];
1068
+ expected: (readonly DiagnosticSourceLocation[])[];
1069
+ }> = [];
764
1070
  // Keep the original traversal and source order; only replace repeated searches.
765
1071
  // Other rule families do not need these indices.
766
1072
  if (
@@ -768,9 +1074,10 @@ function visitDiagnosticReviewRules(
768
1074
  (rule) => rule.kind === "monotonic",
769
1075
  )
770
1076
  ) {
771
- for (const cell of prepared.cells) {
1077
+ for (let index = 0; index < cells.length; index++) {
1078
+ const cell = cells[index]!;
772
1079
  const key = coordinateKey(cell.sourceGroup, cell.origin, cell.valuation);
773
- if (!cellByCoordinate.has(key)) cellByCoordinate.set(key, cell);
1080
+ if (!cellIndexByCoordinate.has(key)) cellIndexByCoordinate.set(key, index);
774
1081
  }
775
1082
  for (const cell of prepared.expectedCells) {
776
1083
  if (!cell.source) continue;
@@ -783,7 +1090,7 @@ function visitDiagnosticReviewRules(
783
1090
  // the rule expression. Build them once for all monotonic rules in this run.
784
1091
  const groups = new Map<string, Set<string>>();
785
1092
  for (const cell of [...prepared.cells, ...prepared.expectedCells]) {
786
- const key = canonicalJson([cell.sourceGroup, cell.origin]);
1093
+ const key = canonicalScalarArrayJson([cell.sourceGroup, cell.origin]);
787
1094
  const values = groups.get(key) ?? new Set<string>();
788
1095
  values.add(cell.valuation);
789
1096
  groups.set(key, values);
@@ -801,7 +1108,15 @@ function visitDiagnosticReviewRules(
801
1108
  const [sourceGroup, origin] = JSON.parse(key) as [string, string];
802
1109
  const valuations = [...values].sort((left, right) =>
803
1110
  valuationCoordinate(left) - valuationCoordinate(right) || codeUnit(left, right));
804
- monotonicGroups.push({ sourceGroup, origin, valuations });
1111
+ monotonicGroups.push({
1112
+ sourceGroup,
1113
+ origin,
1114
+ valuations,
1115
+ cellIndices: valuations.map((valuation) =>
1116
+ cellIndexByCoordinate.get(coordinateKey(sourceGroup, origin, valuation))),
1117
+ expected: valuations.map((valuation) =>
1118
+ expectedSourcesByCoordinate.get(coordinateKey(sourceGroup, origin, valuation)) ?? NO_SOURCES),
1119
+ });
805
1120
  }
806
1121
  }
807
1122
  for (const [ruleIndex, rule] of (
@@ -947,84 +1262,77 @@ function visitDiagnosticReviewRules(
947
1262
  left,
948
1263
  constant(rule.expected),
949
1264
  (relation) => relation === "equal",
1265
+ NO_REASONS,
1266
+ ownedEmpty,
950
1267
  ),
951
1268
  );
952
1269
  continue;
953
1270
  }
954
1271
  if (rule.kind === "monotonic") {
955
- for (const { sourceGroup, origin, valuations: sorted } of monotonicGroups) {
1272
+ const expressionPath = `${basePath}/expression`;
1273
+ const monotonicPasses = (relation: "less" | "equal" | "greater") =>
1274
+ rule.direction === "nondecreasing" ? relation !== "greater" : relation !== "less";
1275
+ for (const { sourceGroup, origin, valuations: sorted, cellIndices, expected } of monotonicGroups) {
956
1276
  for (let index = 1; index < sorted.length; index++) {
957
1277
  const previousValuation = sorted[index - 1]!;
958
1278
  const currentValuation = sorted[index]!;
959
- const previous = cellByCoordinate.get(
960
- coordinateKey(sourceGroup, origin, previousValuation),
961
- );
962
- const current = cellByCoordinate.get(
963
- coordinateKey(sourceGroup, origin, currentValuation),
964
- );
965
- const expectedSources = (valuation: string) =>
966
- expectedSourcesByCoordinate.get(
967
- coordinateKey(sourceGroup, origin, valuation),
968
- ) ?? [];
1279
+ const previousIndex = cellIndices[index - 1];
1280
+ const currentIndex = cellIndices[index];
1281
+ const previous = previousIndex === undefined ? undefined : cells[previousIndex];
1282
+ const current = currentIndex === undefined ? undefined : cells[currentIndex];
969
1283
  const left = previous
970
- ? evaluateExpression(
971
- previous,
972
- rule.expression,
973
- `${basePath}/expression`,
974
- )
1284
+ ? evaluateExpression(previous, previousIndex!, rule.expression, expressionPath, 0)
975
1285
  : {
976
1286
  value: null,
977
1287
  reasons: ["missing" as const],
978
1288
  overflows: [],
979
1289
  sources: compactSources
980
- ? compactSources.forPreparedSources(
981
- expectedSources(previousValuation),
982
- )
983
- : uniqueSources(expectedSources(previousValuation)),
1290
+ ? compactSources.forPreparedSources(expected[index - 1]!)
1291
+ : uniqueSources(expected[index - 1]!),
984
1292
  };
985
1293
  const right = current
986
- ? evaluateExpression(
987
- current,
988
- rule.expression,
989
- `${basePath}/expression`,
990
- )
1294
+ ? evaluateExpression(current, currentIndex!, rule.expression, expressionPath, 1)
991
1295
  : {
992
1296
  value: null,
993
1297
  reasons: ["missing" as const],
994
1298
  overflows: [],
995
1299
  sources: compactSources
996
- ? compactSources.forPreparedSources(
997
- expectedSources(currentValuation),
998
- )
999
- : uniqueSources(expectedSources(currentValuation)),
1300
+ ? compactSources.forPreparedSources(expected[index]!)
1301
+ : uniqueSources(expected[index]!),
1000
1302
  };
1001
- const scope: DiagnosticReviewEvaluationScope = {
1002
- kind: "valuation-pair",
1003
- previous: previous
1004
- ? coordinate(previous)
1005
- : coordinateFromLabels(
1006
- prepared,
1007
- sourceGroup,
1008
- origin,
1009
- previousValuation,
1010
- ),
1011
- current: current
1012
- ? coordinate(current)
1013
- : coordinateFromLabels(
1014
- prepared,
1015
- sourceGroup,
1016
- origin,
1017
- currentValuation,
1018
- ),
1019
- sources: compactSources
1020
- ? compactSources.union([left.sources, right.sources])
1021
- : uniqueSources([...left.sources, ...right.sources]),
1022
- };
1303
+ const previousCoordinate = previous
1304
+ ? coordinateOf(previous, previousIndex!)
1305
+ : coordinateFromLabels(prepared, sourceGroup, origin, previousValuation);
1306
+ const currentCoordinate = current
1307
+ ? coordinateOf(current, currentIndex!)
1308
+ : coordinateFromLabels(prepared, sourceGroup, origin, currentValuation);
1309
+ const pairSources = compactSources
1310
+ ? compactSources.union([left.sources, right.sources])
1311
+ : uniqueSources([...left.sources, ...right.sources]);
1312
+ let scope: DiagnosticReviewEvaluationScope;
1313
+ if (scratch !== undefined) {
1314
+ scratch.pairScope.previous = previousCoordinate;
1315
+ scratch.pairScope.current = currentCoordinate;
1316
+ scratch.pairScope.sources = pairSources;
1317
+ scope = scratch.pairScope;
1318
+ } else
1319
+ scope = {
1320
+ kind: "valuation-pair",
1321
+ previous: previousCoordinate,
1322
+ current: currentCoordinate,
1323
+ sources: pairSources,
1324
+ };
1023
1325
  appendEvaluation(
1024
- evaluation(prepared, rule, scope, left, right, (relation) =>
1025
- rule.direction === "nondecreasing"
1026
- ? relation !== "greater"
1027
- : relation !== "less",
1326
+ evaluation(
1327
+ prepared,
1328
+ rule,
1329
+ scope,
1330
+ left,
1331
+ right,
1332
+ monotonicPasses,
1333
+ NO_REASONS,
1334
+ ownedEmpty,
1335
+ scratch,
1028
1336
  ),
1029
1337
  );
1030
1338
  }
@@ -1051,75 +1359,65 @@ function visitDiagnosticReviewRules(
1051
1359
  ].sort(codeUnit),
1052
1360
  )
1053
1361
  : undefined;
1054
- for (const cell of prepared.cells) {
1055
- const scopeFor = (
1056
- leftSources: readonly DiagnosticSourceLocation[],
1057
- rightSources: readonly DiagnosticSourceLocation[],
1058
- ): DiagnosticCellReviewScope => ({
1362
+ // Operand paths depend only on the rule; build them once, not per cell.
1363
+ const leftPath = `${basePath}/when/left`;
1364
+ const rightPath = `${basePath}/when/right`;
1365
+ const actualPath = `${basePath}/actual`;
1366
+ const expectedPath = `${basePath}/expected`;
1367
+ const narrowerPath = `${basePath}/narrower`;
1368
+ const broaderPath = `${basePath}/broader`;
1369
+ // Per-rule helpers: created once, not once per cell.
1370
+ const scopeFor = (
1371
+ cell: PreparedDiagnosticData["cells"][number],
1372
+ cellIndex: number,
1373
+ leftSources: readonly DiagnosticSourceLocation[],
1374
+ rightSources: readonly DiagnosticSourceLocation[],
1375
+ ): DiagnosticCellReviewScope => {
1376
+ if (scratch !== undefined) {
1377
+ scratch.cellScope.cell = coordinateOf(cell, cellIndex);
1378
+ scratch.cellScope.sources = compactSources!.forCell(cell, cellIndex, scopeDependencies!);
1379
+ return scratch.cellScope;
1380
+ }
1381
+ return {
1059
1382
  kind: "cell",
1060
- cell: coordinate(cell),
1383
+ cell: coordinateOf(cell, cellIndex),
1061
1384
  sources: compactSources
1062
- ? compactSources.forCell(cell, scopeDependencies!)
1385
+ ? compactSources.forCell(cell, cellIndex, scopeDependencies!)
1063
1386
  : uniqueSources([...leftSources, ...rightSources]),
1064
- });
1387
+ };
1388
+ };
1389
+ const comparePasses = (relation: "less" | "equal" | "greater") =>
1390
+ rule.kind === "compare" && !diagnosticPredicateMatches(rule.when.operator, relation);
1391
+ const reconcilePasses = (relation: "less" | "equal" | "greater") => relation === "equal";
1392
+ const layerPasses = (relation: "less" | "equal" | "greater") => relation !== "greater";
1393
+ for (let index = 0; index < cells.length; index++) {
1394
+ const cell = cells[index]!;
1065
1395
  if (rule.kind === "compare") {
1066
- const operand = (item: typeof rule.when.left, path: string) =>
1067
- item.op === "constant"
1068
- ? constant(item.value)
1069
- : evaluateExpression(cell, item, path);
1070
- const left = operand(rule.when.left, `${basePath}/when/left`);
1071
- const right = operand(rule.when.right, `${basePath}/when/right`);
1396
+ const left =
1397
+ rule.when.left.op === "constant"
1398
+ ? constantOperand(rule.when.left.value)
1399
+ : evaluateExpression(cell, index, rule.when.left, leftPath, 0);
1400
+ const right =
1401
+ rule.when.right.op === "constant"
1402
+ ? constantOperand(rule.when.right.value)
1403
+ : evaluateExpression(cell, index, rule.when.right, rightPath, 1);
1072
1404
  appendEvaluation(
1073
- evaluation(
1074
- prepared,
1075
- rule,
1076
- scopeFor(left.sources, right.sources),
1077
- left,
1078
- right,
1079
- (relation) =>
1080
- !diagnosticPredicateMatches(rule.when.operator, relation),
1081
- ),
1405
+ evaluation(prepared, rule, scopeFor(cell, index, left.sources, right.sources), left, right, comparePasses, NO_REASONS, ownedEmpty, scratch),
1082
1406
  );
1083
1407
  } else if (rule.kind === "reconcile") {
1084
- const left = evaluateExpression(
1085
- cell,
1086
- rule.actual,
1087
- `${basePath}/actual`,
1088
- );
1408
+ const left = evaluateExpression(cell, index, rule.actual, actualPath, 0);
1089
1409
  const right =
1090
1410
  rule.expected.op === "constant"
1091
- ? constant(rule.expected.value)
1092
- : evaluateExpression(cell, rule.expected, `${basePath}/expected`);
1411
+ ? constantOperand(rule.expected.value)
1412
+ : evaluateExpression(cell, index, rule.expected, expectedPath, 1);
1093
1413
  appendEvaluation(
1094
- evaluation(
1095
- prepared,
1096
- rule,
1097
- scopeFor(left.sources, right.sources),
1098
- left,
1099
- right,
1100
- (relation) => relation === "equal",
1101
- ),
1414
+ evaluation(prepared, rule, scopeFor(cell, index, left.sources, right.sources), left, right, reconcilePasses, NO_REASONS, ownedEmpty, scratch),
1102
1415
  );
1103
1416
  } else {
1104
- const left = evaluateExpression(
1105
- cell,
1106
- rule.narrower,
1107
- `${basePath}/narrower`,
1108
- );
1109
- const right = evaluateExpression(
1110
- cell,
1111
- rule.broader,
1112
- `${basePath}/broader`,
1113
- );
1417
+ const left = evaluateExpression(cell, index, rule.narrower, narrowerPath, 0);
1418
+ const right = evaluateExpression(cell, index, rule.broader, broaderPath, 1);
1114
1419
  appendEvaluation(
1115
- evaluation(
1116
- prepared,
1117
- rule,
1118
- scopeFor(left.sources, right.sources),
1119
- left,
1120
- right,
1121
- (relation) => relation !== "greater",
1122
- ),
1420
+ evaluation(prepared, rule, scopeFor(cell, index, left.sources, right.sources), left, right, layerPasses, NO_REASONS, ownedEmpty, scratch),
1123
1421
  );
1124
1422
  }
1125
1423
  }