@actuarial-ts/core 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +12 -3
  2. package/dist/canonical.d.ts +11 -0
  3. package/dist/canonical.d.ts.map +1 -1
  4. package/dist/canonical.js +136 -26
  5. package/dist/canonical.js.map +1 -1
  6. package/dist/capping.d.ts.map +1 -1
  7. package/dist/capping.js +39 -6
  8. package/dist/capping.js.map +1 -1
  9. package/dist/diagnosticAggregation.d.ts +3 -0
  10. package/dist/diagnosticAggregation.d.ts.map +1 -1
  11. package/dist/diagnosticAggregation.js +30 -7
  12. package/dist/diagnosticAggregation.js.map +1 -1
  13. package/dist/diagnosticDefinitions.d.ts +3 -0
  14. package/dist/diagnosticDefinitions.d.ts.map +1 -1
  15. package/dist/diagnosticDefinitions.js +844 -182
  16. package/dist/diagnosticDefinitions.js.map +1 -1
  17. package/dist/diagnosticDerivations.d.ts +23 -0
  18. package/dist/diagnosticDerivations.d.ts.map +1 -1
  19. package/dist/diagnosticDerivations.js +187 -43
  20. package/dist/diagnosticDerivations.js.map +1 -1
  21. package/dist/diagnosticEvidenceIntern.d.ts +16 -0
  22. package/dist/diagnosticEvidenceIntern.d.ts.map +1 -0
  23. package/dist/diagnosticEvidenceIntern.js +139 -0
  24. package/dist/diagnosticEvidenceIntern.js.map +1 -0
  25. package/dist/diagnosticExposure.d.ts +2 -2
  26. package/dist/diagnosticExposure.d.ts.map +1 -1
  27. package/dist/diagnosticExposure.js +319 -21
  28. package/dist/diagnosticExposure.js.map +1 -1
  29. package/dist/diagnosticExpressions.d.ts +1 -1
  30. package/dist/diagnosticExpressions.d.ts.map +1 -1
  31. package/dist/diagnosticExpressions.js +35 -15
  32. package/dist/diagnosticExpressions.js.map +1 -1
  33. package/dist/diagnosticFormulas.d.ts +3 -0
  34. package/dist/diagnosticFormulas.d.ts.map +1 -1
  35. package/dist/diagnosticFormulas.js +160 -26
  36. package/dist/diagnosticFormulas.js.map +1 -1
  37. package/dist/diagnosticIdentity.d.ts +15 -1
  38. package/dist/diagnosticIdentity.d.ts.map +1 -1
  39. package/dist/diagnosticIdentity.js +321 -51
  40. package/dist/diagnosticIdentity.js.map +1 -1
  41. package/dist/diagnosticIdentityStream.d.ts +56 -0
  42. package/dist/diagnosticIdentityStream.d.ts.map +1 -0
  43. package/dist/diagnosticIdentityStream.js +472 -0
  44. package/dist/diagnosticIdentityStream.js.map +1 -0
  45. package/dist/diagnosticOrdering.d.ts +8 -0
  46. package/dist/diagnosticOrdering.d.ts.map +1 -0
  47. package/dist/diagnosticOrdering.js +73 -0
  48. package/dist/diagnosticOrdering.js.map +1 -0
  49. package/dist/diagnosticPeriodAxis.d.ts +9 -0
  50. package/dist/diagnosticPeriodAxis.d.ts.map +1 -0
  51. package/dist/diagnosticPeriodAxis.js +69 -0
  52. package/dist/diagnosticPeriodAxis.js.map +1 -0
  53. package/dist/diagnosticPeriods.d.ts +2 -3
  54. package/dist/diagnosticPeriods.d.ts.map +1 -1
  55. package/dist/diagnosticPeriods.js +20 -68
  56. package/dist/diagnosticPeriods.js.map +1 -1
  57. package/dist/diagnosticPreparation.d.ts +75 -14
  58. package/dist/diagnosticPreparation.d.ts.map +1 -1
  59. package/dist/diagnosticPreparation.js +1606 -112
  60. package/dist/diagnosticPreparation.js.map +1 -1
  61. package/dist/diagnosticReview.d.ts +40 -15
  62. package/dist/diagnosticReview.d.ts.map +1 -1
  63. package/dist/diagnosticReview.js +600 -42
  64. package/dist/diagnosticReview.js.map +1 -1
  65. package/dist/diagnosticReviewSources.d.ts +18 -0
  66. package/dist/diagnosticReviewSources.d.ts.map +1 -0
  67. package/dist/diagnosticReviewSources.js +76 -0
  68. package/dist/diagnosticReviewSources.js.map +1 -0
  69. package/dist/diagnosticReviewStore.d.ts +88 -0
  70. package/dist/diagnosticReviewStore.d.ts.map +1 -0
  71. package/dist/diagnosticReviewStore.js +455 -0
  72. package/dist/diagnosticReviewStore.js.map +1 -0
  73. package/dist/diagnosticRunner.d.ts +27 -2
  74. package/dist/diagnosticRunner.d.ts.map +1 -1
  75. package/dist/diagnosticRunner.js +764 -70
  76. package/dist/diagnosticRunner.js.map +1 -1
  77. package/dist/diagnosticRuntime.d.ts +26 -0
  78. package/dist/diagnosticRuntime.d.ts.map +1 -0
  79. package/dist/diagnosticRuntime.js +243 -0
  80. package/dist/diagnosticRuntime.js.map +1 -0
  81. package/dist/diagnosticSourceOrdering.d.ts +6 -0
  82. package/dist/diagnosticSourceOrdering.d.ts.map +1 -0
  83. package/dist/diagnosticSourceOrdering.js +38 -0
  84. package/dist/diagnosticSourceOrdering.js.map +1 -0
  85. package/dist/fnvAccumulator.d.ts +6 -0
  86. package/dist/fnvAccumulator.d.ts.map +1 -0
  87. package/dist/fnvAccumulator.js +21 -0
  88. package/dist/fnvAccumulator.js.map +1 -0
  89. package/dist/index.d.ts +15 -8
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +10 -4
  92. package/dist/index.js.map +1 -1
  93. package/dist/mack.d.ts +4 -0
  94. package/dist/mack.d.ts.map +1 -1
  95. package/dist/mack.js +32 -4
  96. package/dist/mack.js.map +1 -1
  97. package/dist/triangle.d.ts.map +1 -1
  98. package/dist/triangle.js +59 -9
  99. package/dist/triangle.js.map +1 -1
  100. package/dist/types.d.ts +4 -0
  101. package/dist/types.d.ts.map +1 -1
  102. package/dist/types.js +22 -8
  103. package/dist/types.js.map +1 -1
  104. package/dist/version.d.ts +1 -1
  105. package/dist/version.js +1 -1
  106. package/package.json +3 -2
  107. package/src/canonical.ts +144 -26
  108. package/src/capping.ts +109 -18
  109. package/src/diagnosticAggregation.ts +69 -20
  110. package/src/diagnosticDefinitions.ts +2675 -426
  111. package/src/diagnosticDerivations.ts +281 -49
  112. package/src/diagnosticEvidenceIntern.ts +151 -0
  113. package/src/diagnosticExposure.ts +550 -50
  114. package/src/diagnosticExpressions.ts +99 -17
  115. package/src/diagnosticFormulas.ts +239 -39
  116. package/src/diagnosticIdentity.ts +700 -191
  117. package/src/diagnosticIdentityStream.ts +553 -0
  118. package/src/diagnosticOrdering.ts +106 -0
  119. package/src/diagnosticPeriodAxis.ts +92 -0
  120. package/src/diagnosticPeriods.ts +67 -82
  121. package/src/diagnosticPreparation.ts +2558 -132
  122. package/src/diagnosticReview.ts +1072 -58
  123. package/src/diagnosticReviewSources.ts +85 -0
  124. package/src/diagnosticReviewStore.ts +692 -0
  125. package/src/diagnosticRunner.ts +1273 -72
  126. package/src/diagnosticRuntime.ts +264 -0
  127. package/src/diagnosticSourceOrdering.ts +50 -0
  128. package/src/fnvAccumulator.ts +20 -0
  129. package/src/index.ts +69 -4
  130. package/src/mack.ts +72 -9
  131. package/src/triangle.ts +103 -13
  132. package/src/types.ts +55 -20
  133. package/src/version.ts +1 -1
@@ -1,4 +1,131 @@
1
- import type { DiagnosticSourceLocation } from "./diagnosticDefinitions.js";
1
+ import type {
2
+ DiagnosticPeriodAxis,
3
+ DiagnosticSourceLocation,
4
+ } from "./diagnosticDefinitions.js";
5
+ import { canonicalJson } from "./canonical.js";
6
+ import { normalizeDiagnosticSourceLocations } from "./diagnosticSourceOrdering.js";
7
+ import { compareDiagnosticSourceLocations } from "./diagnosticSourceOrdering.js";
8
+ import { compareDiagnosticIdentityValues } from "./diagnosticOrdering.js";
9
+ import { normalizeDiagnosticPeriodWithAxis } from "./diagnosticPeriodAxis.js";
10
+ import { validateDiagnosticPeriodAxisInput } from "./diagnosticDefinitions.js";
11
+ import {
12
+ DiagnosticValidationError,
13
+ type DiagnosticValidationIssue,
14
+ } from "./types.js";
15
+ import {
16
+ diagnosticJsonPreflight,
17
+ diagnosticRecord,
18
+ hasDiagnosticOwn,
19
+ isDiagnosticPlainRecord,
20
+ isDiagnosticToken,
21
+ snapshotDiagnosticJson,
22
+ MAX_DIAGNOSTIC_JSON_DEPTH,
23
+ } from "./diagnosticRuntime.js";
24
+
25
+ // Bound the collection separately from each untrusted record. The generic
26
+ // million-node JSON cap otherwise rejects legitimate files at ~71k–100k rows,
27
+ // depending only on how much source provenance each observation carries.
28
+ const MAX_EXPOSURE_OBSERVATIONS = 250_000;
29
+
30
+ // JSON enumeration skips hidden properties, but the strict exposure schema
31
+ // reads recognized own fields. Refuse hidden accessors before those reads.
32
+ function hiddenExposureAccessorIssues(
33
+ value: unknown,
34
+ path: string,
35
+ ): DiagnosticValidationIssue[] {
36
+ if (!isDiagnosticPlainRecord(value)) return [];
37
+ const issues: DiagnosticValidationIssue[] = [];
38
+ for (const key of Object.getOwnPropertyNames(value)) {
39
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
40
+ if (!descriptor.enumerable && !("value" in descriptor))
41
+ issues.push({
42
+ domain: "input",
43
+ code: "invalid-json-value",
44
+ path: propertyPath(path, key),
45
+ message: "JSON objects may contain only data properties",
46
+ });
47
+ }
48
+ return issues;
49
+ }
50
+
51
+ function exposureCollectionPreflight(
52
+ value: unknown,
53
+ ): readonly DiagnosticValidationIssue[] {
54
+ if (!Array.isArray(value)) return diagnosticJsonPreflight(value, "input");
55
+ if (Object.getPrototypeOf(value) !== Array.prototype)
56
+ return [
57
+ {
58
+ domain: "input",
59
+ code: "invalid-json-value",
60
+ path: "$",
61
+ message: "Value must use a plain object or array prototype",
62
+ },
63
+ ];
64
+ if (value.length > MAX_EXPOSURE_OBSERVATIONS)
65
+ return [
66
+ {
67
+ domain: "input",
68
+ code: "expression-limit",
69
+ path: "$",
70
+ message: `Exposure observation count exceeds ${MAX_EXPOSURE_OBSERVATIONS}`,
71
+ },
72
+ ];
73
+ const issues: DiagnosticValidationIssue[] = [];
74
+ for (const key of Reflect.ownKeys(value)) {
75
+ if (key === "length") continue;
76
+ const index = typeof key === "string" ? Number(key) : Number.NaN;
77
+ if (
78
+ Number.isInteger(index) &&
79
+ index >= 0 &&
80
+ index < value.length &&
81
+ String(index) === key
82
+ )
83
+ continue;
84
+ issues.push({
85
+ domain: "input",
86
+ code: "invalid-json-value",
87
+ path: typeof key === "symbol" ? "$" : propertyPath("$", key),
88
+ message: "JSON arrays may contain only indexed data properties",
89
+ });
90
+ }
91
+ for (let index = 0; index < value.length; index++) {
92
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
93
+ if (!descriptor || !("value" in descriptor)) {
94
+ issues.push({
95
+ domain: "input",
96
+ code: "invalid-json-value",
97
+ path: `$[${index}]`,
98
+ message: "JSON arrays may contain only indexed data properties",
99
+ });
100
+ continue;
101
+ }
102
+ issues.push(
103
+ ...hiddenExposureAccessorIssues(descriptor.value, `$[${index}]`),
104
+ );
105
+ if (isDiagnosticPlainRecord(descriptor.value)) {
106
+ const source = Object.getOwnPropertyDescriptor(
107
+ descriptor.value,
108
+ "source",
109
+ );
110
+ if (source && "value" in source)
111
+ issues.push(
112
+ ...hiddenExposureAccessorIssues(source.value, `$[${index}].source`),
113
+ );
114
+ }
115
+ // The outer collection used to occupy depth 1: keep the exact depth
116
+ // contract, and retain the default million-node guard for each record.
117
+ for (const issue of diagnosticJsonPreflight(descriptor.value, "input", {
118
+ maxDepth: MAX_DIAGNOSTIC_JSON_DEPTH - 1,
119
+ }))
120
+ issues.push({ ...issue, path: `$[${index}]${issue.path.slice(1)}` });
121
+ }
122
+ return issues;
123
+ }
124
+
125
+ /** Only for new SDK-owned objects whose leaves have already been validated. */
126
+ function freezeExposureRecord<T extends object>(value: T): T {
127
+ return Object.freeze(Object.assign(diagnosticRecord<unknown>(), value)) as T;
128
+ }
2
129
 
3
130
  export interface DiagnosticExposureObservation {
4
131
  readonly key: string;
@@ -14,7 +141,11 @@ export interface DiagnosticExposureObservation {
14
141
  export type DiagnosticAuditedNumericValue =
15
142
  | { readonly status: "observed"; readonly value: number }
16
143
  | { readonly status: "missing"; readonly value: null }
17
- | { readonly status: "non-finite"; readonly value: null; readonly nonFiniteKind: "nan" | "positive-infinity" | "negative-infinity" };
144
+ | {
145
+ readonly status: "non-finite";
146
+ readonly value: null;
147
+ readonly nonFiniteKind: "nan" | "positive-infinity" | "negative-infinity";
148
+ };
18
149
 
19
150
  export interface DiagnosticExposureAuditObservation {
20
151
  readonly sourceGroup: string;
@@ -26,66 +157,435 @@ export interface DiagnosticExposureAuditObservation {
26
157
  }
27
158
 
28
159
  export type ReconciledDiagnosticExposure =
29
- | { readonly measureId: string; readonly key: string; readonly status: "valid"; readonly sourceGroup: string; readonly origin: string; readonly valuation?: string; readonly value: number; readonly deduplicated: number; readonly sources: readonly DiagnosticSourceLocation[] }
30
- | { readonly measureId: string; readonly key: string; readonly status: "invalid"; readonly issues: readonly ("missing" | "incomplete" | "non-finite" | "duplicate" | "conflict")[]; readonly value: null; readonly observations: readonly DiagnosticExposureAuditObservation[] };
160
+ | {
161
+ readonly measureId: string;
162
+ readonly key: string;
163
+ readonly status: "valid";
164
+ readonly sourceGroup: string;
165
+ readonly origin: string;
166
+ readonly valuation?: string;
167
+ readonly value: number;
168
+ readonly deduplicated: number;
169
+ readonly sources: readonly DiagnosticSourceLocation[];
170
+ }
171
+ | {
172
+ readonly measureId: string;
173
+ readonly key: string;
174
+ readonly status: "invalid";
175
+ readonly issues: readonly (
176
+ "missing" | "incomplete" | "non-finite" | "duplicate" | "conflict"
177
+ )[];
178
+ readonly value: null;
179
+ readonly observations: readonly DiagnosticExposureAuditObservation[];
180
+ };
31
181
 
32
- export function auditDiagnosticNumber(value: number | null): DiagnosticAuditedNumericValue {
33
- if (value === null) return { status: "missing", value: null };
34
- if (!Number.isFinite(value)) return { status: "non-finite", value: null, nonFiniteKind: Number.isNaN(value) ? "nan" : value > 0 ? "positive-infinity" : "negative-infinity" };
35
- return { status: "observed", value: Object.is(value, -0) ? 0 : value };
182
+ export function auditDiagnosticNumber(
183
+ value: number | null,
184
+ ): DiagnosticAuditedNumericValue {
185
+ if (value !== null && typeof value !== "number")
186
+ throw new DiagnosticValidationError([
187
+ {
188
+ domain: "input",
189
+ code: "invalid-type",
190
+ path: "$",
191
+ message: "Numeric input must be a number or null",
192
+ },
193
+ ]);
194
+ if (value === null) return Object.freeze({ status: "missing", value: null });
195
+ if (!Number.isFinite(value))
196
+ return Object.freeze({
197
+ status: "non-finite",
198
+ value: null,
199
+ nonFiniteKind: Number.isNaN(value)
200
+ ? "nan"
201
+ : value > 0
202
+ ? "positive-infinity"
203
+ : "negative-infinity",
204
+ });
205
+ return Object.freeze({
206
+ status: "observed",
207
+ value: Object.is(value, -0) ? 0 : value,
208
+ });
209
+ }
210
+
211
+ function propertyPath(path: string, key: string): string {
212
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
213
+ ? `${path}.${key}`
214
+ : `${path}[${JSON.stringify(key)}]`;
215
+ }
216
+
217
+ function validateExposureArguments(
218
+ observations: unknown,
219
+ timingByMeasure: unknown,
220
+ periodAxis: DiagnosticPeriodAxis | undefined,
221
+ ): void {
222
+ // Nonfinite exposure amounts are supported audited inputs. They are the
223
+ // only non-JSON numbers permitted here; metadata still must be finite.
224
+ const issues: DiagnosticValidationIssue[] = [
225
+ ...exposureCollectionPreflight(observations)
226
+ .filter(
227
+ (issue) =>
228
+ !(
229
+ issue.code === "invalid-json-value" &&
230
+ issue.message === "JSON numeric value must be finite" &&
231
+ /^\$\[\d+\]\.value$/.test(issue.path)
232
+ ),
233
+ )
234
+ .map((issue) => ({
235
+ ...issue,
236
+ path: `$.observations${issue.path.slice(1)}`,
237
+ })),
238
+ ...diagnosticJsonPreflight(timingByMeasure, "configuration").map(
239
+ (issue) => ({
240
+ ...issue,
241
+ path: `$.timingByMeasure${issue.path.slice(1)}`,
242
+ }),
243
+ ),
244
+ ...(periodAxis === undefined
245
+ ? []
246
+ : validateDiagnosticPeriodAxisInput(periodAxis)),
247
+ ];
248
+ if (issues.length > 0) throw new DiagnosticValidationError(issues);
249
+ const issue = (
250
+ domain: DiagnosticValidationIssue["domain"],
251
+ code: DiagnosticValidationIssue["code"],
252
+ path: string,
253
+ message: string,
254
+ ) => issues.push({ domain, code, path, message });
255
+ const token = (
256
+ value: unknown,
257
+ path: string,
258
+ domain: DiagnosticValidationIssue["domain"] = "input",
259
+ ) => {
260
+ if (!isDiagnosticToken(value))
261
+ issue(
262
+ domain,
263
+ typeof value === "string" ? "invalid-string" : "invalid-type",
264
+ path,
265
+ "Expected a nonempty token with valid Unicode and no U+0000",
266
+ );
267
+ };
268
+ const exactKeys = (
269
+ value: Record<string, unknown>,
270
+ allowed: readonly string[],
271
+ path: string,
272
+ ) => {
273
+ for (const key of Object.keys(value))
274
+ if (!allowed.includes(key))
275
+ issue(
276
+ "input",
277
+ "unknown-key",
278
+ propertyPath(path, key),
279
+ `Unknown key ${key}`,
280
+ );
281
+ };
282
+ if (!Array.isArray(observations))
283
+ issue(
284
+ "input",
285
+ "invalid-type",
286
+ "$.observations",
287
+ "Exposure observations must be an array",
288
+ );
289
+ if (!isDiagnosticPlainRecord(timingByMeasure))
290
+ issue(
291
+ "configuration",
292
+ "invalid-type",
293
+ "$.timingByMeasure",
294
+ "Exposure timings must be a plain record",
295
+ );
296
+ if (issues.length > 0) throw new DiagnosticValidationError(issues);
297
+ const timings = timingByMeasure as Record<string, unknown>;
298
+ for (const [measureId, timing] of Object.entries(timings)) {
299
+ const path = propertyPath("$.timingByMeasure", measureId);
300
+ token(measureId, path, "configuration");
301
+ if (timing !== "origin-static" && timing !== "valuation-specific")
302
+ issue(
303
+ "configuration",
304
+ "invalid-type",
305
+ path,
306
+ "Exposure timing must be origin-static or valuation-specific",
307
+ );
308
+ }
309
+ for (const [index, observation] of (observations as unknown[]).entries()) {
310
+ const path = `$.observations[${index}]`;
311
+ if (!isDiagnosticPlainRecord(observation)) {
312
+ issue(
313
+ "input",
314
+ "invalid-type",
315
+ path,
316
+ "Exposure observation must be an object",
317
+ );
318
+ continue;
319
+ }
320
+ exactKeys(
321
+ observation,
322
+ [
323
+ "key",
324
+ "sourceGroup",
325
+ "origin",
326
+ "valuation",
327
+ "measureId",
328
+ "value",
329
+ "complete",
330
+ "source",
331
+ ],
332
+ path,
333
+ );
334
+ for (const key of ["key", "sourceGroup", "origin", "measureId"])
335
+ token(observation[key], `${path}.${key}`);
336
+ if (hasDiagnosticOwn(observation, "valuation"))
337
+ token(observation.valuation, `${path}.valuation`);
338
+ if (typeof observation.complete !== "boolean")
339
+ issue(
340
+ "input",
341
+ "invalid-type",
342
+ `${path}.complete`,
343
+ "Exposure completeness must be boolean",
344
+ );
345
+ if (observation.value !== null && typeof observation.value !== "number")
346
+ issue(
347
+ "input",
348
+ "invalid-type",
349
+ `${path}.value`,
350
+ "Exposure value must be a number or null",
351
+ );
352
+ if (isDiagnosticToken(observation.measureId)) {
353
+ if (!hasDiagnosticOwn(timings, observation.measureId))
354
+ issue(
355
+ "configuration",
356
+ "unknown-reference",
357
+ `${path}.measureId`,
358
+ "Exposure measure has no declared timing",
359
+ );
360
+ else if (
361
+ timings[observation.measureId] === "valuation-specific" &&
362
+ !hasDiagnosticOwn(observation, "valuation")
363
+ )
364
+ issue(
365
+ "input",
366
+ "missing-required",
367
+ `${path}.valuation`,
368
+ "Valuation-specific exposure requires a valuation",
369
+ );
370
+ }
371
+ if (hasDiagnosticOwn(observation, "source")) {
372
+ const source = observation.source;
373
+ if (!isDiagnosticPlainRecord(source))
374
+ issue(
375
+ "input",
376
+ "invalid-type",
377
+ `${path}.source`,
378
+ "Source location must be an object",
379
+ );
380
+ else {
381
+ exactKeys(
382
+ source,
383
+ [
384
+ "artifactId",
385
+ "sourceFile",
386
+ "sourceSheet",
387
+ "sourceRow",
388
+ "sourceCell",
389
+ ],
390
+ `${path}.source`,
391
+ );
392
+ token(source.artifactId, `${path}.source.artifactId`);
393
+ for (const key of ["sourceFile", "sourceSheet", "sourceCell"])
394
+ if (hasDiagnosticOwn(source, key))
395
+ token(source[key], `${path}.source.${key}`);
396
+ if (
397
+ hasDiagnosticOwn(source, "sourceRow") &&
398
+ (typeof source.sourceRow !== "number" ||
399
+ !Number.isSafeInteger(source.sourceRow) ||
400
+ source.sourceRow < 0)
401
+ )
402
+ issue(
403
+ "input",
404
+ "invalid-number",
405
+ `${path}.source.sourceRow`,
406
+ "Source row must be a nonnegative safe integer",
407
+ );
408
+ }
409
+ }
410
+ if (periodAxis !== undefined)
411
+ for (const side of ["origin", "valuation"] as const) {
412
+ if (
413
+ isDiagnosticToken(observation[side]) &&
414
+ normalizeDiagnosticPeriodWithAxis(
415
+ periodAxis,
416
+ side,
417
+ observation[side],
418
+ ) === null
419
+ )
420
+ issue(
421
+ "input",
422
+ "invalid-period",
423
+ `${path}.${side}`,
424
+ `Unknown ${side} period ${JSON.stringify(observation[side])}`,
425
+ );
426
+ }
427
+ }
428
+ if (issues.length > 0) throw new DiagnosticValidationError(issues);
36
429
  }
37
430
 
38
- function equalAudit(left: DiagnosticExposureAuditObservation, right: DiagnosticExposureAuditObservation): boolean {
39
- return left.sourceGroup === right.sourceGroup && left.origin === right.origin && left.complete === right.complete &&
40
- JSON.stringify(left.value) === JSON.stringify(right.value);
431
+ function equalAudit(
432
+ left: DiagnosticExposureAuditObservation,
433
+ right: DiagnosticExposureAuditObservation,
434
+ ): boolean {
435
+ return (
436
+ left.sourceGroup === right.sourceGroup &&
437
+ left.origin === right.origin &&
438
+ left.complete === right.complete &&
439
+ JSON.stringify(left.value) === JSON.stringify(right.value)
440
+ );
41
441
  }
42
442
 
43
443
  export function reconcileDiagnosticExposures(
44
444
  observations: readonly DiagnosticExposureObservation[],
45
- timingByMeasure: Readonly<Record<string, "origin-static" | "valuation-specific">>,
445
+ timingByMeasure: Readonly<
446
+ Record<string, "origin-static" | "valuation-specific">
447
+ >,
448
+ periodAxis?: DiagnosticPeriodAxis,
46
449
  ): readonly ReconciledDiagnosticExposure[] {
450
+ validateExposureArguments(observations, timingByMeasure, periodAxis);
451
+ const periodValue = (
452
+ role: "origin" | "valuation",
453
+ label: string | undefined,
454
+ ) =>
455
+ label === undefined || periodAxis === undefined
456
+ ? label
457
+ : (normalizeDiagnosticPeriodWithAxis(periodAxis, role, label)
458
+ ?.coordinate ?? label);
459
+ const observationOrder = (
460
+ left: DiagnosticExposureAuditObservation,
461
+ right: DiagnosticExposureAuditObservation,
462
+ ) =>
463
+ compareDiagnosticIdentityValues(
464
+ [
465
+ left.sourceGroup,
466
+ periodValue("origin", left.origin),
467
+ periodValue("valuation", left.valuation),
468
+ left.value.status,
469
+ left.value.status === "non-finite"
470
+ ? left.value.nonFiniteKind
471
+ : left.value.value,
472
+ left.complete,
473
+ ],
474
+ [
475
+ right.sourceGroup,
476
+ periodValue("origin", right.origin),
477
+ periodValue("valuation", right.valuation),
478
+ right.value.status,
479
+ right.value.status === "non-finite"
480
+ ? right.value.nonFiniteKind
481
+ : right.value.value,
482
+ right.complete,
483
+ ],
484
+ ) ||
485
+ (left.source === undefined || right.source === undefined
486
+ ? compareDiagnosticIdentityValues(left.source, right.source)
487
+ : compareDiagnosticSourceLocations(left.source, right.source));
47
488
  const cohorts = new Map<string, DiagnosticExposureObservation[]>();
48
489
  for (const observation of observations) {
49
- const timing = timingByMeasure[observation.measureId];
50
- const identity = timing === "valuation-specific"
51
- ? `${observation.measureId}\u0000${observation.key}\u0000${observation.valuation ?? ""}`
52
- : `${observation.measureId}\u0000${observation.key}`;
490
+ const timing = hasDiagnosticOwn(timingByMeasure, observation.measureId)
491
+ ? timingByMeasure[observation.measureId]
492
+ : undefined;
493
+ const identity = canonicalJson(
494
+ timing === "valuation-specific"
495
+ ? [
496
+ observation.measureId,
497
+ observation.key,
498
+ observation.valuation ?? null,
499
+ ]
500
+ : [observation.measureId, observation.key],
501
+ );
53
502
  const cohort = cohorts.get(identity) ?? [];
54
503
  cohort.push(observation);
55
504
  cohorts.set(identity, cohort);
56
505
  }
57
- return Object.freeze([...cohorts.values()].map((cohort): ReconciledDiagnosticExposure => {
58
- const first = cohort[0]!;
59
- const timing = timingByMeasure[first.measureId];
60
- const audited = cohort.map((item): DiagnosticExposureAuditObservation => ({
61
- sourceGroup: item.sourceGroup,
62
- origin: item.origin,
63
- ...(item.valuation === undefined ? {} : { valuation: item.valuation }),
64
- value: auditDiagnosticNumber(item.value),
65
- complete: item.complete,
66
- ...(item.source === undefined ? {} : { source: item.source }),
67
- }));
68
- const issues: ("missing" | "incomplete" | "non-finite" | "duplicate" | "conflict")[] = [];
69
- if (audited.some((item) => item.value.status === "missing")) issues.push("missing");
70
- if (audited.some((item) => !item.complete)) issues.push("incomplete");
71
- if (audited.some((item) => item.value.status === "non-finite")) issues.push("non-finite");
72
- if (timing === "valuation-specific" && audited.length > 1) issues.push("duplicate");
73
- if (audited.slice(1).some((item) => !equalAudit(audited[0]!, item))) issues.push("conflict");
74
- const validStaticCopies = timing === "origin-static" && issues.length === 0;
75
- if (issues.length > 0) return Object.freeze({ measureId: first.measureId, key: first.key, status: "invalid", issues: Object.freeze(issues), value: null, observations: Object.freeze(audited) });
76
- const value = audited[0]!.value;
77
- if (value.status !== "observed") throw new Error("unreachable invalid exposure state");
78
- const sources = audited.flatMap((item) => item.source ? [item.source] : []).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
79
- return Object.freeze({
80
- measureId: first.measureId,
81
- key: first.key,
82
- status: "valid",
83
- sourceGroup: first.sourceGroup,
84
- origin: first.origin,
85
- ...(timing === "valuation-specific" ? { valuation: first.valuation! } : {}),
86
- value: value.value,
87
- deduplicated: validStaticCopies ? audited.length - 1 : 0,
88
- sources: Object.freeze(sources),
89
- });
90
- }).sort((left, right) => `${left.measureId}\u0000${left.key}` < `${right.measureId}\u0000${right.key}` ? -1 : 1));
506
+ return Object.freeze(
507
+ [...cohorts.values()]
508
+ .map((cohort): ReconciledDiagnosticExposure => {
509
+ const first = cohort[0]!;
510
+ const timing = hasDiagnosticOwn(timingByMeasure, first.measureId)
511
+ ? timingByMeasure[first.measureId]
512
+ : undefined;
513
+ const audited = cohort
514
+ .map((item): DiagnosticExposureAuditObservation =>
515
+ snapshotDiagnosticJson({
516
+ sourceGroup: item.sourceGroup,
517
+ origin: item.origin,
518
+ ...(item.valuation === undefined
519
+ ? {}
520
+ : { valuation: item.valuation }),
521
+ value: auditDiagnosticNumber(item.value),
522
+ complete: item.complete,
523
+ ...(item.source === undefined
524
+ ? {}
525
+ : { source: Object.freeze({ ...item.source }) }),
526
+ }),
527
+ )
528
+ .sort(observationOrder);
529
+ const issues: (
530
+ "missing" | "incomplete" | "non-finite" | "duplicate" | "conflict"
531
+ )[] = [];
532
+ if (audited.some((item) => item.value.status === "missing"))
533
+ issues.push("missing");
534
+ if (audited.some((item) => !item.complete)) issues.push("incomplete");
535
+ if (audited.some((item) => item.value.status === "non-finite"))
536
+ issues.push("non-finite");
537
+ if (timing === "valuation-specific" && audited.length > 1)
538
+ issues.push("duplicate");
539
+ if (audited.slice(1).some((item) => !equalAudit(audited[0]!, item)))
540
+ issues.push("conflict");
541
+ const validStaticCopies =
542
+ timing === "origin-static" && issues.length === 0;
543
+ if (issues.length > 0)
544
+ return freezeExposureRecord({
545
+ measureId: first.measureId,
546
+ key: first.key,
547
+ status: "invalid",
548
+ issues: Object.freeze(issues),
549
+ value: null,
550
+ observations: Object.freeze(audited),
551
+ });
552
+ const value = audited[0]!.value;
553
+ if (value.status !== "observed")
554
+ throw new Error("unreachable invalid exposure state");
555
+ const sources = normalizeDiagnosticSourceLocations(
556
+ audited.map((item) => item.source),
557
+ ).map((source) => snapshotDiagnosticJson(source));
558
+ return freezeExposureRecord({
559
+ measureId: first.measureId,
560
+ key: first.key,
561
+ status: "valid",
562
+ sourceGroup: first.sourceGroup,
563
+ origin: first.origin,
564
+ ...(timing === "valuation-specific"
565
+ ? { valuation: first.valuation! }
566
+ : {}),
567
+ value: value.value,
568
+ deduplicated: validStaticCopies ? audited.length - 1 : 0,
569
+ sources: Object.freeze(sources),
570
+ });
571
+ })
572
+ .sort((left, right) => {
573
+ return (
574
+ compareDiagnosticIdentityValues(
575
+ [
576
+ left.measureId,
577
+ left.key,
578
+ left.status,
579
+ left.status === "invalid" ? left.issues : undefined,
580
+ ],
581
+ [
582
+ right.measureId,
583
+ right.key,
584
+ right.status,
585
+ right.status === "invalid" ? right.issues : undefined,
586
+ ],
587
+ ) || compareDiagnosticIdentityValues(left, right)
588
+ );
589
+ }),
590
+ );
91
591
  }