@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
@@ -47,22 +47,49 @@ export function auditedDiagnosticContribution(
47
47
  sources: readonly DiagnosticSourceLocation[] = [],
48
48
  deduplicated = 0,
49
49
  ): DiagnosticMeasureContribution {
50
- const base = {
50
+ return ownedDiagnosticContribution(
51
51
  sourceId,
52
- sources: Object.freeze([...sources]),
52
+ value,
53
+ missing,
54
+ Object.freeze([...sources]),
53
55
  deduplicated,
54
- } as const;
56
+ );
57
+ }
58
+
59
+ /**
60
+ * `auditedDiagnosticContribution` over a citation list the caller already owns
61
+ * and froze. Preparation cites one frozen list per source row for every measure
62
+ * of that row instead of copying it per contribution. Field order matches the
63
+ * public constructor exactly.
64
+ */
65
+ export function ownedDiagnosticContribution(
66
+ sourceId: string,
67
+ value: number | null | undefined,
68
+ missing: DiagnosticMissingPolicy,
69
+ sources: readonly DiagnosticSourceLocation[],
70
+ deduplicated = 0,
71
+ ): DiagnosticMeasureContribution {
55
72
  if (value === null || value === undefined)
56
73
  return missing === "zero"
57
74
  ? Object.freeze({
58
- ...base,
75
+ sourceId,
76
+ sources,
77
+ deduplicated,
59
78
  status: "imputed-zero" as const,
60
79
  value: 0 as const,
61
80
  })
62
- : Object.freeze({ ...base, status: "missing" as const, value: null });
81
+ : Object.freeze({
82
+ sourceId,
83
+ sources,
84
+ deduplicated,
85
+ status: "missing" as const,
86
+ value: null,
87
+ });
63
88
  if (!Number.isFinite(value))
64
89
  return Object.freeze({
65
- ...base,
90
+ sourceId,
91
+ sources,
92
+ deduplicated,
66
93
  status: "non-finite" as const,
67
94
  value: null,
68
95
  nonFiniteKind: Number.isNaN(value)
@@ -72,12 +99,105 @@ export function auditedDiagnosticContribution(
72
99
  : ("negative-infinity" as const),
73
100
  });
74
101
  return Object.freeze({
75
- ...base,
102
+ sourceId,
103
+ sources,
104
+ deduplicated,
76
105
  status: "observed" as const,
77
106
  value: Object.is(value, -0) ? 0 : value,
78
107
  });
79
108
  }
80
109
 
110
+ /**
111
+ * Private, invocation-scoped sharing of finalized statistics. Two frozen
112
+ * `finalizeDiagnosticContributions` results are interchangeable exactly when
113
+ * every field is `Object.is`-equal: each finite number has one shortest
114
+ * round-trip text, signed zero and null stay distinct, and non-finite values
115
+ * are declined. The pool is bounded and released with the preparation.
116
+ */
117
+ export function createDiagnosticStatsInterner(maxEntries = 100_000): {
118
+ intern(stats: DiagnosticMeasureStats): DiagnosticMeasureStats;
119
+ /** Looks the eight fields up first and allocates the frozen record only when absent. */
120
+ internFields(
121
+ value: number | null,
122
+ sum: number | null,
123
+ observed: number,
124
+ missing: number,
125
+ nonFinite: number,
126
+ imputedZero: number,
127
+ deduplicated: number,
128
+ structural: number,
129
+ ): DiagnosticMeasureStats;
130
+ } {
131
+ const pool = new Map<string, DiagnosticMeasureStats>();
132
+ const text = (value: number | null): string =>
133
+ value === null ? "null" : Object.is(value, -0) ? "-0" : String(value);
134
+ const finite = (value: number | null): boolean =>
135
+ value === null || Number.isFinite(value);
136
+ const keyOf = (
137
+ value: number | null,
138
+ sum: number | null,
139
+ observed: number,
140
+ missing: number,
141
+ nonFinite: number,
142
+ imputedZero: number,
143
+ deduplicated: number,
144
+ structural: number,
145
+ ): string | undefined => {
146
+ if (
147
+ !finite(value) ||
148
+ !finite(sum) ||
149
+ !Number.isFinite(observed) ||
150
+ !Number.isFinite(missing) ||
151
+ !Number.isFinite(nonFinite) ||
152
+ !Number.isFinite(imputedZero) ||
153
+ !Number.isFinite(deduplicated) ||
154
+ !Number.isFinite(structural)
155
+ )
156
+ return undefined;
157
+ // A single clean observation is the overwhelmingly common shape; its key
158
+ // needs only the value. The prefix cannot occur in the eight-field form.
159
+ return sum === value &&
160
+ observed === 1 &&
161
+ missing === 0 &&
162
+ nonFinite === 0 &&
163
+ imputedZero === 0 &&
164
+ deduplicated === 0 &&
165
+ structural === 0
166
+ ? `${text(value)}`
167
+ : `${text(value)}|${text(sum)}|${text(observed)}|${text(missing)}|${text(nonFinite)}|${text(imputedZero)}|${text(deduplicated)}|${text(structural)}`;
168
+ };
169
+ return {
170
+ intern(stats) {
171
+ if (!Object.isFrozen(stats)) return stats;
172
+ const key = keyOf(
173
+ stats.value, stats.sum, stats.observed, stats.missing,
174
+ stats.nonFinite, stats.imputedZero, stats.deduplicated, stats.structural,
175
+ );
176
+ if (key === undefined) return stats;
177
+ const existing = pool.get(key);
178
+ if (existing !== undefined) return existing;
179
+ if (pool.size < maxEntries) pool.set(key, stats);
180
+ return stats;
181
+ },
182
+ internFields(value, sum, observed, missing, nonFinite, imputedZero, deduplicated, structural) {
183
+ const key = keyOf(value, sum, observed, missing, nonFinite, imputedZero, deduplicated, structural);
184
+ const existing = key === undefined ? undefined : pool.get(key);
185
+ if (existing !== undefined) return existing;
186
+ const stats: DiagnosticMeasureStats = Object.freeze({
187
+ value, sum, observed, missing, nonFinite, imputedZero, deduplicated, structural,
188
+ });
189
+ if (key !== undefined && pool.size < maxEntries) pool.set(key, stats);
190
+ return stats;
191
+ },
192
+ };
193
+ }
194
+
195
+ /** Exactly `neumaier([value])` without the array: one finite value sums to itself, signed zero normalized. */
196
+ function singleValueSum(value: number): number | null {
197
+ if (!Number.isFinite(value)) return null;
198
+ return Object.is(value, -0) ? 0 : value;
199
+ }
200
+
81
201
  function neumaier(values: readonly number[]): number | null {
82
202
  let sum = 0;
83
203
  let correction = 0;
@@ -107,7 +227,7 @@ export function finalizeDiagnosticContributions(
107
227
  const imputedZero = item.status === "imputed-zero" ? 1 : 0;
108
228
  const missing = item.status === "missing" ? 1 : imputedZero;
109
229
  const nonFinite = item.status === "non-finite" ? 1 : 0;
110
- const finiteSum = item.status === "observed" ? neumaier([item.value]) : 0;
230
+ const finiteSum = item.status === "observed" ? singleValueSum(item.value) : 0;
111
231
  const sum = nonFinite > 0 || blockers.length > 0 ? null : finiteSum;
112
232
  return Object.freeze({
113
233
  value: sum === null || (missingPolicy === "unknown" && missing > 0) ? null : sum,
@@ -115,7 +235,51 @@ export function finalizeDiagnosticContributions(
115
235
  deduplicated: 0 + item.deduplicated, structural: blockers.length,
116
236
  });
117
237
  }
118
- const ordered = [...contributions].sort(compareDiagnosticContributions);
238
+ return finalizeManyContributions(contributions, missingPolicy, blockers);
239
+ }
240
+
241
+ /**
242
+ * `finalizeDiagnosticContributions` whose result is shared through `pool`:
243
+ * for the single-leaf case the eight fields are known before any allocation,
244
+ * so an equal frozen record already in the pool is returned without creating
245
+ * a new one. Preparation uses this for every measure of every cell.
246
+ */
247
+ export function finalizeDiagnosticContributionsInterned(
248
+ contributions: readonly DiagnosticMeasureContribution[],
249
+ missingPolicy: DiagnosticMissingPolicy,
250
+ blockers: readonly DiagnosticStructuralBlocker[],
251
+ pool: ReturnType<typeof createDiagnosticStatsInterner>,
252
+ ): DiagnosticMeasureStats {
253
+ if (contributions.length === 1) {
254
+ const item = contributions[0]!;
255
+ const observed = item.status === "observed" ? 1 : 0;
256
+ const imputedZero = item.status === "imputed-zero" ? 1 : 0;
257
+ const missing = item.status === "missing" ? 1 : imputedZero;
258
+ const nonFinite = item.status === "non-finite" ? 1 : 0;
259
+ const finiteSum = item.status === "observed" ? singleValueSum(item.value) : 0;
260
+ const sum = nonFinite > 0 || blockers.length > 0 ? null : finiteSum;
261
+ const value = sum === null || (missingPolicy === "unknown" && missing > 0) ? null : sum;
262
+ const deduplicated = 0 + item.deduplicated;
263
+ const structural = blockers.length;
264
+ return pool.internFields(
265
+ value, sum, observed, missing, nonFinite, imputedZero, deduplicated, structural,
266
+ );
267
+ }
268
+ return pool.intern(finalizeManyContributions(contributions, missingPolicy, blockers));
269
+ }
270
+
271
+ function finalizeManyContributions(
272
+ contributions: readonly DiagnosticMeasureContribution[],
273
+ missingPolicy: DiagnosticMissingPolicy,
274
+ blockers: readonly DiagnosticStructuralBlocker[],
275
+ ): DiagnosticMeasureStats {
276
+ // Contributions gathered from cells in prepared order are usually already in
277
+ // contract order; copying and sorting is needed only when they are not.
278
+ let sorted = true;
279
+ for (let index = 1; index < contributions.length && sorted; index++)
280
+ if (compareDiagnosticContributions(contributions[index - 1]!, contributions[index]!) > 0)
281
+ sorted = false;
282
+ const ordered = sorted ? contributions : [...contributions].sort(compareDiagnosticContributions);
119
283
  const observed = ordered.filter((item) => item.status === "observed");
120
284
  const imputed = ordered.filter((item) => item.status === "imputed-zero");
121
285
  const missing =
@@ -0,0 +1,187 @@
1
+ import { canonicalJson } from "./canonical.js";
2
+ import type { DiagnosticSourceLocation } from "./diagnosticDefinitions.js";
3
+ import type { DiagnosticAuditedNumericValue } from "./diagnosticExposure.js";
4
+ import type { DiagnosticInputAuditRecord } from "./diagnosticPreparation.js";
5
+
6
+ /**
7
+ * Canonical JSON text of an input audit snapshot, byte-identical to
8
+ * `canonicalJson(record)`. Snapshots have three fixed shapes whose sorted key
9
+ * order is known, so each is rendered directly with the same string escaping
10
+ * and number rules. Any snapshot that does not match its shape exactly (an
11
+ * unexpected key, prototype or value type) defers to `canonicalJson`, so the
12
+ * text and every error stay those of the generic serializer.
13
+ */
14
+ export function auditRecordCanonicalJson(
15
+ record: DiagnosticInputAuditRecord["record"],
16
+ ): string {
17
+ return snapshotText(record) ?? canonicalJson(record);
18
+ }
19
+
20
+ type Snapshot = Record<string, unknown>;
21
+
22
+ /**
23
+ * `JSON.stringify` for a string. Printable ASCII other than `"` and `\` is
24
+ * never escaped, so such a string is quoted directly; everything else (control
25
+ * characters, `"`, `\`, and any non-ASCII, including lone surrogates) goes to
26
+ * the native serializer, which owns the escape spelling.
27
+ */
28
+ const quote = (value: string): string => {
29
+ for (let index = 0; index < value.length; index++) {
30
+ const code = value.charCodeAt(index);
31
+ if (code < 0x20 || code > 0x7e || code === 0x22 || code === 0x5c)
32
+ return JSON.stringify(value);
33
+ }
34
+ return `"${value}"`;
35
+ };
36
+ const number = (value: number): string | undefined =>
37
+ Number.isFinite(value) ? (Object.is(value, -0) ? "0" : String(value)) : undefined;
38
+ const hasOwn = (value: object, key: string): boolean =>
39
+ Object.prototype.hasOwnProperty.call(value, key);
40
+ const plain = (value: unknown): value is Snapshot =>
41
+ value !== null &&
42
+ typeof value === "object" &&
43
+ !Array.isArray(value) &&
44
+ Object.getPrototypeOf(value) === Object.prototype;
45
+
46
+ function stringOrNull(value: unknown): string | undefined {
47
+ return value === null ? "null" : typeof value === "string" ? quote(value) : undefined;
48
+ }
49
+
50
+ /** Source keys in canonical order: artifactId, sourceCell, sourceFile, sourceRow, sourceSheet. */
51
+ function sourceText(value: unknown): string | undefined {
52
+ if (value === null) return "null";
53
+ if (!plain(value)) return undefined;
54
+ const source = value as Partial<DiagnosticSourceLocation>;
55
+ if (typeof source.artifactId !== "string") return undefined;
56
+ let text = `{"artifactId":${quote(source.artifactId)}`;
57
+ let count = 1;
58
+ for (const field of ["sourceCell", "sourceFile"] as const)
59
+ if (hasOwn(source, field)) {
60
+ const item = source[field];
61
+ if (typeof item !== "string") return undefined;
62
+ text += `,"${field}":${quote(item)}`;
63
+ count++;
64
+ }
65
+ if (hasOwn(source, "sourceRow")) {
66
+ const item = source.sourceRow;
67
+ const rendered = typeof item === "number" ? number(item) : undefined;
68
+ if (rendered === undefined) return undefined;
69
+ text += `,"sourceRow":${rendered}`;
70
+ count++;
71
+ }
72
+ if (hasOwn(source, "sourceSheet")) {
73
+ const item = source.sourceSheet;
74
+ if (typeof item !== "string") return undefined;
75
+ text += `,"sourceSheet":${quote(item)}`;
76
+ count++;
77
+ }
78
+ if (Object.keys(source).length !== count) return undefined;
79
+ return `${text}}`;
80
+ }
81
+
82
+ /**
83
+ * Audited numeric records are immutable SDK-owned values that preparation
84
+ * shares across rows, so each distinct record's text is built once. Only a
85
+ * frozen record is remembered, and its text depends on nothing else.
86
+ */
87
+ const auditedTexts = new WeakMap<object, string>();
88
+
89
+ function auditedText(value: unknown): string | undefined {
90
+ if (!plain(value)) return undefined;
91
+ const cached = auditedTexts.get(value);
92
+ if (cached !== undefined) return cached;
93
+ const text = auditedTextOf(value);
94
+ if (text !== undefined && Object.isFrozen(value)) auditedTexts.set(value, text);
95
+ return text;
96
+ }
97
+
98
+ function auditedTextOf(value: Snapshot): string | undefined {
99
+ const audited = value as Partial<DiagnosticAuditedNumericValue>;
100
+ const keys = Object.keys(audited).length;
101
+ if (audited.status === "observed") {
102
+ const rendered = typeof audited.value === "number" ? number(audited.value) : undefined;
103
+ return keys === 2 && rendered !== undefined
104
+ ? `{"status":"observed","value":${rendered}}`
105
+ : undefined;
106
+ }
107
+ if (audited.status === "missing")
108
+ return keys === 2 && audited.value === null ? '{"status":"missing","value":null}' : undefined;
109
+ if (audited.status === "non-finite")
110
+ return keys === 3 && audited.value === null && typeof audited.nonFiniteKind === "string"
111
+ ? `{"nonFiniteKind":${quote(audited.nonFiniteKind)},"status":"non-finite","value":null}`
112
+ : undefined;
113
+ return undefined;
114
+ }
115
+
116
+ function measuresText(value: unknown): string | undefined {
117
+ if (!plain(value)) return undefined;
118
+ const keys = Object.keys(value);
119
+ for (let index = 1; index < keys.length; index++)
120
+ if (keys[index - 1]! > keys[index]!) {
121
+ keys.sort();
122
+ break;
123
+ }
124
+ let text = "{";
125
+ for (let index = 0; index < keys.length; index++) {
126
+ const key = keys[index]!;
127
+ const rendered = auditedText(value[key]);
128
+ if (rendered === undefined) return undefined;
129
+ text += `${index > 0 ? "," : ""}${quote(key)}:${rendered}`;
130
+ }
131
+ return `${text}}`;
132
+ }
133
+
134
+ function snapshotText(record: unknown): string | undefined {
135
+ if (!plain(record)) return undefined;
136
+ const keys = Object.keys(record).length;
137
+ if (hasOwn(record, "rowType")) {
138
+ // Loss: claimId, complete, measures, origin, recordId, rowType, source, sourceGroup, valuation.
139
+ if (keys !== 9) return undefined;
140
+ const claimId = stringOrNull(record.claimId);
141
+ const measures = measuresText(record.measures);
142
+ const source = sourceText(record.source);
143
+ if (
144
+ claimId === undefined ||
145
+ typeof record.complete !== "boolean" ||
146
+ measures === undefined ||
147
+ typeof record.origin !== "string" ||
148
+ typeof record.recordId !== "string" ||
149
+ typeof record.rowType !== "string" ||
150
+ source === undefined ||
151
+ typeof record.sourceGroup !== "string" ||
152
+ typeof record.valuation !== "string"
153
+ )
154
+ return undefined;
155
+ return `{"claimId":${claimId},"complete":${record.complete},"measures":${measures},"origin":${quote(record.origin)},"recordId":${quote(record.recordId)},"rowType":${quote(record.rowType)},"source":${source},"sourceGroup":${quote(record.sourceGroup)},"valuation":${quote(record.valuation)}}`;
156
+ }
157
+ if (hasOwn(record, "measureId")) {
158
+ // Exposure: complete, key, measureId, origin, source, sourceGroup, valuation, value.
159
+ if (keys !== 8) return undefined;
160
+ const source = sourceText(record.source);
161
+ const valuation = stringOrNull(record.valuation);
162
+ const audited = auditedText(record.value);
163
+ if (
164
+ typeof record.complete !== "boolean" ||
165
+ typeof record.key !== "string" ||
166
+ typeof record.measureId !== "string" ||
167
+ typeof record.origin !== "string" ||
168
+ source === undefined ||
169
+ typeof record.sourceGroup !== "string" ||
170
+ valuation === undefined ||
171
+ audited === undefined
172
+ )
173
+ return undefined;
174
+ return `{"complete":${record.complete},"key":${quote(record.key)},"measureId":${quote(record.measureId)},"origin":${quote(record.origin)},"source":${source},"sourceGroup":${quote(record.sourceGroup)},"valuation":${valuation},"value":${audited}}`;
175
+ }
176
+ // Expected cell: origin, source, sourceGroup, valuation.
177
+ if (keys !== 4) return undefined;
178
+ const source = sourceText(record.source);
179
+ if (
180
+ typeof record.origin !== "string" ||
181
+ source === undefined ||
182
+ typeof record.sourceGroup !== "string" ||
183
+ typeof record.valuation !== "string"
184
+ )
185
+ return undefined;
186
+ return `{"origin":${quote(record.origin)},"source":${source},"sourceGroup":${quote(record.sourceGroup)},"valuation":${quote(record.valuation)}}`;
187
+ }
@@ -188,10 +188,50 @@ function pointer(path: string, segment: string | number): string {
188
188
  return `${path}/${escaped}`;
189
189
  }
190
190
 
191
+ // Shared immutable empties for clean operands; every consumer copies or iterates.
192
+ const NO_REASONS: readonly DiagnosticRuleNotEvaluatedReason[] = Object.freeze([]);
193
+ const NO_OVERFLOWS: readonly DiagnosticExpressionOverflow[] = Object.freeze([]);
194
+ const NO_SOURCES: readonly import("./diagnosticDefinitions.js").DiagnosticSourceLocation[] =
195
+ Object.freeze([]);
196
+
197
+ // Path segments below the caller's root, pushed and popped during one
198
+ // synchronous evaluation. Rendering happens only when an overflow must cite a
199
+ // path, so the common case allocates nothing. A re-entrant call while the
200
+ // shared stack is in use gets its own stack.
201
+ let sharedSegments: (string | number)[] | null = [];
202
+ // A measure leaf never renders a path; it may evaluate against a fixed empty stack.
203
+ const LEAF_SEGMENTS: (string | number)[] = [];
204
+
191
205
  export function evaluateDiagnosticMeasureExpression(
192
206
  expression: DiagnosticDeepReadonly<DiagnosticMeasureExpression>,
193
207
  measures: Readonly<Record<string, FinalizedDiagnosticMeasure>>,
194
208
  path: string,
209
+ ): ExpressionResult {
210
+ if (expression.op === "measure")
211
+ return evaluateMeasureExpression(expression, measures, path, LEAF_SEGMENTS);
212
+ const segments = sharedSegments ?? [];
213
+ sharedSegments = null;
214
+ segments.length = 0;
215
+ try {
216
+ return evaluateMeasureExpression(expression, measures, path, segments);
217
+ } finally {
218
+ sharedSegments = segments;
219
+ }
220
+ }
221
+
222
+ function renderPath(root: string, segments: readonly (string | number)[]): string {
223
+ let path = root;
224
+ for (let index = 0; index < segments.length; index++)
225
+ path = pointer(path, segments[index]!);
226
+ return path;
227
+ }
228
+
229
+ /** Expression paths are only rendered when an overflow has to cite one. */
230
+ function evaluateMeasureExpression(
231
+ expression: DiagnosticDeepReadonly<DiagnosticMeasureExpression>,
232
+ measures: Readonly<Record<string, FinalizedDiagnosticMeasure>>,
233
+ root: string,
234
+ segments: (string | number)[],
195
235
  ): ExpressionResult {
196
236
  if (expression.op === "measure") {
197
237
  const value = hasDiagnosticOwn(measures, expression.measureId)
@@ -201,34 +241,44 @@ export function evaluateDiagnosticMeasureExpression(
201
241
  ? {
202
242
  value: value.quantity.value,
203
243
  reasons: value.readiness,
204
- overflows: value.expressionOverflows ?? [],
205
- sources: value.sources ?? [],
244
+ overflows: value.expressionOverflows ?? NO_OVERFLOWS,
245
+ sources: value.sources ?? NO_SOURCES,
206
246
  }
207
- : { value: null, reasons: ["missing"], overflows: [], sources: [] };
247
+ : {
248
+ value: null,
249
+ reasons: ["missing"],
250
+ overflows: NO_OVERFLOWS,
251
+ sources: NO_SOURCES,
252
+ };
208
253
  }
209
- const children =
210
- expression.op === "add"
211
- ? expression.terms.map((term, index) =>
212
- evaluateDiagnosticMeasureExpression(
213
- term,
214
- measures,
215
- pointer(pointer(path, "terms"), index),
216
- ),
217
- )
218
- : [
219
- evaluateDiagnosticMeasureExpression(
220
- expression.left,
221
- measures,
222
- pointer(path, "left"),
223
- ),
224
- evaluateDiagnosticMeasureExpression(
225
- expression.right,
226
- measures,
227
- pointer(path, "right"),
228
- ),
229
- ];
230
- const reasons = uniqueReasons(children.flatMap((child) => child.reasons));
231
- const overflows = children.flatMap((child) => child.overflows);
254
+ let children: ExpressionResult[];
255
+ if (expression.op === "add") {
256
+ children = new Array(expression.terms.length);
257
+ segments.push("terms", 0);
258
+ try {
259
+ for (let index = 0; index < expression.terms.length; index++) {
260
+ segments[segments.length - 1] = index;
261
+ children[index] = evaluateMeasureExpression(expression.terms[index]!, measures, root, segments);
262
+ }
263
+ } finally {
264
+ segments.length -= 2;
265
+ }
266
+ } else {
267
+ segments.push("left");
268
+ try {
269
+ const left = evaluateMeasureExpression(expression.left, measures, root, segments);
270
+ segments[segments.length - 1] = "right";
271
+ children = [left, evaluateMeasureExpression(expression.right, measures, root, segments)];
272
+ } finally {
273
+ segments.length -= 1;
274
+ }
275
+ }
276
+ const reasons = children.every((child) => child.reasons.length === 0)
277
+ ? NO_REASONS
278
+ : uniqueReasons(children.flatMap((child) => child.reasons));
279
+ const overflows = children.every((child) => child.overflows.length === 0)
280
+ ? NO_OVERFLOWS
281
+ : children.flatMap((child) => child.overflows);
232
282
  const sources = normalizeDiagnosticSourceLocations(
233
283
  children.flatMap((child) => child.sources),
234
284
  );
@@ -242,7 +292,7 @@ export function evaluateDiagnosticMeasureExpression(
242
292
  return {
243
293
  value: null,
244
294
  reasons: uniqueReasons([...reasons, "expression-overflow"]),
245
- overflows: [...overflows, { expressionPath: path, sources }],
295
+ overflows: [...overflows, { expressionPath: renderPath(root, segments), sources }],
246
296
  sources,
247
297
  };
248
298
  }
@@ -1,37 +1,82 @@
1
1
  import type { DiagnosticDeepReadonly } from "./diagnosticDefinitions.js";
2
2
 
3
- // Private knowledge of graphs traversed by this freezer. Object.isFrozen alone
4
- // is not evidence that caller-supplied children are immutable. Weak membership
5
- // does not keep released diagnostic datasets alive.
6
- let completedGraphs = new WeakSet<object>();
7
- let completedCount = 0;
8
- const MAX_COMPLETED_MEMBERS = 100_000;
3
+ // Owned diagnostic graphs are JSON-shaped: shallow trees whose shared nodes are
4
+ // small leaves (source locations, statistics, findings). Visiting a shared leaf
5
+ // once per reference is far cheaper than remembering millions of visited nodes,
6
+ // so ancestors are tracked only past this depth. A cycle must exceed it and is
7
+ // then cut exactly where a visited-set walk would cut it.
8
+ const UNGUARDED_DEPTH = 64;
9
9
 
10
- /** Freeze owned plain-data snapshots, visiting shared subgraphs only once. */
11
- export function freezeDiagnosticGraph<T>(value: T): DiagnosticDeepReadonly<T> {
12
- // This walk already retains its nodes until success; a strong local set avoids
13
- // a second large weak table and is released when the invocation returns.
14
- const seen = new Set<object>();
15
- const visit = (node: unknown): void => {
16
- if (node === null || typeof node !== "object" || seen.has(node) || completedGraphs.has(node)) return;
17
- seen.add(node);
18
- for (const child of Object.values(node as Record<string, unknown>)) visit(child);
10
+ /** True when Object.prototype carries an enumerable key, so for-in needs an own-key guard. */
11
+ function objectPrototypeEnumerates(): boolean {
12
+ for (const key in {}) return key !== undefined;
13
+ return false;
14
+ }
15
+
16
+ function walk(value: unknown, skipFrozen: boolean): void {
17
+ const guardInherited = objectPrototypeEnumerates();
18
+ let ancestors: Set<object> | undefined;
19
+ const visit = (node: unknown, depth: number): void => {
20
+ if (node === null || typeof node !== "object") return;
21
+ if (skipFrozen && Object.isFrozen(node)) return;
22
+ const guarded = depth > UNGUARDED_DEPTH;
23
+ if (guarded) {
24
+ ancestors ??= new Set();
25
+ if (ancestors.has(node)) return;
26
+ ancestors.add(node);
27
+ }
28
+ // Scalars are checked inline: most children are numbers and strings, and a
29
+ // call per scalar would dominate the walk.
30
+ if (Array.isArray(node)) {
31
+ for (let index = 0; index < node.length; index++) {
32
+ const child: unknown = node[index];
33
+ if (child !== null && typeof child === "object") visit(child, depth + 1);
34
+ }
35
+ } else {
36
+ // for-in visits own enumerable string keys in the same order as
37
+ // Object.keys without allocating a key array per node. Plain records
38
+ // inherit nothing enumerable unless Object.prototype was extended.
39
+ const record = node as Record<string, unknown>;
40
+ const prototype: unknown = Object.getPrototypeOf(record);
41
+ if (!guardInherited && (prototype === Object.prototype || prototype === null)) {
42
+ for (const key in record) {
43
+ const child = record[key];
44
+ if (child !== null && typeof child === "object") visit(child, depth + 1);
45
+ }
46
+ } else {
47
+ for (const key in record) {
48
+ if (!Object.hasOwn(record, key)) continue;
49
+ const child = record[key];
50
+ if (child !== null && typeof child === "object") visit(child, depth + 1);
51
+ }
52
+ }
53
+ }
54
+ if (guarded) ancestors!.delete(node);
19
55
  Object.freeze(node);
20
56
  };
21
- visit(value);
22
- // Mark only after the whole walk succeeds: a cyclic child must not become
23
- // trusted while pointing at a parent whose freeze failed.
24
- for (const node of seen) {
25
- // This is a traversal shortcut, never an authentication receipt. Forgetting
26
- // old membership is safe: a later visit traverses and freezes that graph
27
- // again. Bound the weak-table bookkeeping even when millions of source
28
- // objects are still strongly retained by a large diagnostic dataset.
29
- if (completedCount >= MAX_COMPLETED_MEMBERS) {
30
- completedGraphs = new WeakSet<object>();
31
- completedCount = 0;
32
- }
33
- completedGraphs.add(node);
34
- completedCount++;
35
- }
57
+ visit(value, 0);
58
+ }
59
+
60
+ /**
61
+ * Freeze an owned plain-data snapshot deeply. Every reachable child is visited
62
+ * and frozen, including children of an already frozen node, because a frozen
63
+ * root is no evidence that a caller-supplied child is immutable. The walk keeps
64
+ * no state between calls and allocates nothing for ordinary acyclic graphs.
65
+ */
66
+ export function freezeDiagnosticGraph<T>(value: T): DiagnosticDeepReadonly<T> {
67
+ walk(value, false);
68
+ return value as DiagnosticDeepReadonly<T>;
69
+ }
70
+
71
+ /**
72
+ * Freeze a graph that the SDK built entirely itself, where every node that is
73
+ * already frozen was frozen complete: preparation attaches contributions,
74
+ * statistics, citation lists and shared empties only after freezing them and
75
+ * every child below them. Such nodes are skipped instead of re-walked once per
76
+ * reference. Never use this on a graph that may hold a caller-frozen node with
77
+ * mutable children; `freezeDiagnosticGraph` exists for that case.
78
+ */
79
+ export function freezeOwnedDiagnosticGraph<T>(value: T): DiagnosticDeepReadonly<T> {
80
+ walk(value, true);
36
81
  return value as DiagnosticDeepReadonly<T>;
37
82
  }