@actuarial-ts/core 0.6.0 → 0.6.1

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 (103) hide show
  1. package/README.md +3 -3
  2. package/dist/capping.d.ts.map +1 -1
  3. package/dist/capping.js +39 -6
  4. package/dist/capping.js.map +1 -1
  5. package/dist/diagnosticAggregation.d.ts +3 -0
  6. package/dist/diagnosticAggregation.d.ts.map +1 -1
  7. package/dist/diagnosticAggregation.js +30 -7
  8. package/dist/diagnosticAggregation.js.map +1 -1
  9. package/dist/diagnosticDefinitions.d.ts +3 -0
  10. package/dist/diagnosticDefinitions.d.ts.map +1 -1
  11. package/dist/diagnosticDefinitions.js +844 -182
  12. package/dist/diagnosticDefinitions.js.map +1 -1
  13. package/dist/diagnosticDerivations.d.ts +23 -0
  14. package/dist/diagnosticDerivations.d.ts.map +1 -1
  15. package/dist/diagnosticDerivations.js +187 -43
  16. package/dist/diagnosticDerivations.js.map +1 -1
  17. package/dist/diagnosticExposure.d.ts +2 -2
  18. package/dist/diagnosticExposure.d.ts.map +1 -1
  19. package/dist/diagnosticExposure.js +228 -20
  20. package/dist/diagnosticExposure.js.map +1 -1
  21. package/dist/diagnosticExpressions.d.ts +1 -1
  22. package/dist/diagnosticExpressions.d.ts.map +1 -1
  23. package/dist/diagnosticExpressions.js +35 -15
  24. package/dist/diagnosticExpressions.js.map +1 -1
  25. package/dist/diagnosticFormulas.d.ts +3 -0
  26. package/dist/diagnosticFormulas.d.ts.map +1 -1
  27. package/dist/diagnosticFormulas.js +160 -26
  28. package/dist/diagnosticFormulas.js.map +1 -1
  29. package/dist/diagnosticIdentity.d.ts +15 -1
  30. package/dist/diagnosticIdentity.d.ts.map +1 -1
  31. package/dist/diagnosticIdentity.js +318 -51
  32. package/dist/diagnosticIdentity.js.map +1 -1
  33. package/dist/diagnosticOrdering.d.ts +8 -0
  34. package/dist/diagnosticOrdering.d.ts.map +1 -0
  35. package/dist/diagnosticOrdering.js +73 -0
  36. package/dist/diagnosticOrdering.js.map +1 -0
  37. package/dist/diagnosticPeriodAxis.d.ts +9 -0
  38. package/dist/diagnosticPeriodAxis.d.ts.map +1 -0
  39. package/dist/diagnosticPeriodAxis.js +69 -0
  40. package/dist/diagnosticPeriodAxis.js.map +1 -0
  41. package/dist/diagnosticPeriods.d.ts +2 -3
  42. package/dist/diagnosticPeriods.d.ts.map +1 -1
  43. package/dist/diagnosticPeriods.js +20 -68
  44. package/dist/diagnosticPeriods.js.map +1 -1
  45. package/dist/diagnosticPreparation.d.ts +47 -14
  46. package/dist/diagnosticPreparation.d.ts.map +1 -1
  47. package/dist/diagnosticPreparation.js +1482 -112
  48. package/dist/diagnosticPreparation.js.map +1 -1
  49. package/dist/diagnosticReview.d.ts +36 -14
  50. package/dist/diagnosticReview.d.ts.map +1 -1
  51. package/dist/diagnosticReview.js +448 -42
  52. package/dist/diagnosticReview.js.map +1 -1
  53. package/dist/diagnosticRunner.d.ts +2 -1
  54. package/dist/diagnosticRunner.d.ts.map +1 -1
  55. package/dist/diagnosticRunner.js +656 -70
  56. package/dist/diagnosticRunner.js.map +1 -1
  57. package/dist/diagnosticRuntime.d.ts +26 -0
  58. package/dist/diagnosticRuntime.d.ts.map +1 -0
  59. package/dist/diagnosticRuntime.js +243 -0
  60. package/dist/diagnosticRuntime.js.map +1 -0
  61. package/dist/diagnosticSourceOrdering.d.ts +6 -0
  62. package/dist/diagnosticSourceOrdering.d.ts.map +1 -0
  63. package/dist/diagnosticSourceOrdering.js +38 -0
  64. package/dist/diagnosticSourceOrdering.js.map +1 -0
  65. package/dist/index.d.ts +8 -4
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +5 -1
  68. package/dist/index.js.map +1 -1
  69. package/dist/mack.d.ts +4 -0
  70. package/dist/mack.d.ts.map +1 -1
  71. package/dist/mack.js +32 -4
  72. package/dist/mack.js.map +1 -1
  73. package/dist/triangle.d.ts.map +1 -1
  74. package/dist/triangle.js +59 -9
  75. package/dist/triangle.js.map +1 -1
  76. package/dist/types.d.ts +4 -0
  77. package/dist/types.d.ts.map +1 -1
  78. package/dist/types.js +22 -8
  79. package/dist/types.js.map +1 -1
  80. package/dist/version.d.ts +1 -1
  81. package/dist/version.js +1 -1
  82. package/package.json +3 -2
  83. package/src/capping.ts +109 -18
  84. package/src/diagnosticAggregation.ts +69 -20
  85. package/src/diagnosticDefinitions.ts +2675 -426
  86. package/src/diagnosticDerivations.ts +281 -49
  87. package/src/diagnosticExposure.ts +441 -50
  88. package/src/diagnosticExpressions.ts +99 -17
  89. package/src/diagnosticFormulas.ts +239 -39
  90. package/src/diagnosticIdentity.ts +691 -191
  91. package/src/diagnosticOrdering.ts +106 -0
  92. package/src/diagnosticPeriodAxis.ts +92 -0
  93. package/src/diagnosticPeriods.ts +67 -82
  94. package/src/diagnosticPreparation.ts +2369 -127
  95. package/src/diagnosticReview.ts +864 -58
  96. package/src/diagnosticRunner.ts +1076 -72
  97. package/src/diagnosticRuntime.ts +264 -0
  98. package/src/diagnosticSourceOrdering.ts +50 -0
  99. package/src/index.ts +31 -3
  100. package/src/mack.ts +72 -9
  101. package/src/triangle.ts +103 -13
  102. package/src/types.ts +55 -20
  103. package/src/version.ts +1 -1
@@ -1,20 +1,59 @@
1
1
  import { canonicalJson, fnv1a64 } from "./canonical.js";
2
+ import {
3
+ normalizeDiagnosticsFilterIdentity,
4
+ projectDiagnosticIdentity,
5
+ type DiagnosticIdentityProjection,
6
+ type NormalizedDiagnosticsFilterIdentity,
7
+ type NormalizedDiagnosticSourceLocationIdentity,
8
+ } from "./diagnosticIdentity.js";
9
+ import {
10
+ compareDiagnosticBlockers,
11
+ compareDiagnosticContributions,
12
+ compareDiagnosticFindings,
13
+ } from "./diagnosticOrdering.js";
2
14
  import {
3
15
  assertCompiledDiagnosticDefinition,
4
16
  getCompiledDiagnosticDefinitionInternals,
5
17
  type CompiledDiagnosticDefinition,
6
18
  type DiagnosticDeepReadonly,
19
+ type DiagnosticMeasureStats,
7
20
  type DiagnosticSourceLocation,
8
21
  type DiagnosticsFilter,
9
22
  type JsonValue,
10
23
  } from "./diagnosticDefinitions.js";
11
- import { auditedDiagnosticContribution, finalizeDiagnosticContributions, type DiagnosticMeasureContribution, type DiagnosticStructuralBlocker } from "./diagnosticAggregation.js";
12
- import { deriveDiagnosticClaimMeasures } from "./diagnosticDerivations.js";
13
- import { diagnosticDevelopmentAge, normalizeDiagnosticPeriod } from "./diagnosticPeriods.js";
14
- import { reconcileDiagnosticExposures, type DiagnosticAuditedNumericValue, type DiagnosticExposureObservation, type ReconciledDiagnosticExposure, auditDiagnosticNumber } from "./diagnosticExposure.js";
15
- import type { DiagnosticMeasureStats } from "./diagnosticDefinitions.js";
24
+ import {
25
+ auditedDiagnosticContribution,
26
+ finalizeDiagnosticContributions,
27
+ type DiagnosticMeasureContribution,
28
+ type DiagnosticStructuralBlocker,
29
+ } from "./diagnosticAggregation.js";
30
+ import {
31
+ deriveDiagnosticClaimMeasuresWithAudit,
32
+ type DiagnosticDerivedValueAudit,
33
+ } from "./diagnosticDerivations.js";
34
+ import {
35
+ compareDiagnosticPeriods,
36
+ normalizeDiagnosticPeriod,
37
+ type DiagnosticNormalizedPeriod,
38
+ } from "./diagnosticPeriods.js";
39
+ import {
40
+ auditDiagnosticNumber,
41
+ reconcileDiagnosticExposures,
42
+ type DiagnosticAuditedNumericValue,
43
+ type DiagnosticExposureAuditObservation,
44
+ type DiagnosticExposureObservation,
45
+ type ReconciledDiagnosticExposure,
46
+ } from "./diagnosticExposure.js";
16
47
  import type { DiagnosticMetricFinding } from "./diagnosticFormulas.js";
17
- import { DiagnosticValidationError, type DiagnosticValidationIssue } from "./types.js";
48
+ import {
49
+ DiagnosticValidationError,
50
+ type DiagnosticValidationIssue,
51
+ } from "./types.js";
52
+ import { normalizeDiagnosticSourceLocations } from "./diagnosticSourceOrdering.js";
53
+ import {
54
+ isDiagnosticPlainRecord,
55
+ isDiagnosticToken,
56
+ } from "./diagnosticRuntime.js";
18
57
 
19
58
  export interface DiagnosticLossRecordBase {
20
59
  readonly recordId: string;
@@ -25,12 +64,33 @@ export interface DiagnosticLossRecordBase {
25
64
  readonly source?: DiagnosticSourceLocation;
26
65
  readonly measures: Readonly<Record<string, number | null>>;
27
66
  }
28
- export interface DiagnosticClaimObservation extends DiagnosticLossRecordBase { readonly rowType: "claim"; readonly claimId: string }
29
- export interface DiagnosticLossSnapshot extends DiagnosticLossRecordBase { readonly rowType: "aggregate" }
30
- export type DiagnosticLossInput = DiagnosticClaimObservation | DiagnosticLossSnapshot;
31
67
 
32
- export interface DiagnosticCompletePeriodCutoff { readonly sourceGroup: string; readonly originThrough: string | null; readonly valuationThrough: string | null }
33
- export interface DiagnosticExpectedCell { readonly sourceGroup: string; readonly origin: string; readonly valuation: string; readonly source?: DiagnosticSourceLocation }
68
+ export interface DiagnosticClaimObservation extends DiagnosticLossRecordBase {
69
+ readonly rowType: "claim";
70
+ readonly claimId: string;
71
+ }
72
+
73
+ export interface DiagnosticLossSnapshot extends DiagnosticLossRecordBase {
74
+ readonly rowType: "aggregate";
75
+ }
76
+
77
+ export type DiagnosticLossInput =
78
+ | DiagnosticClaimObservation
79
+ | DiagnosticLossSnapshot;
80
+
81
+ export interface DiagnosticCompletePeriodCutoff {
82
+ readonly sourceGroup: string;
83
+ readonly originThrough: string | null;
84
+ readonly valuationThrough: string | null;
85
+ }
86
+
87
+ export interface DiagnosticExpectedCell {
88
+ readonly sourceGroup: string;
89
+ readonly origin: string;
90
+ readonly valuation: string;
91
+ readonly source?: DiagnosticSourceLocation;
92
+ }
93
+
34
94
  export interface PrepareDiagnosticDataInput {
35
95
  readonly definition: CompiledDiagnosticDefinition;
36
96
  readonly losses: readonly DiagnosticLossInput[];
@@ -40,11 +100,58 @@ export interface PrepareDiagnosticDataInput {
40
100
  readonly expectedCells?: readonly DiagnosticExpectedCell[];
41
101
  }
42
102
 
43
- export type DiagnosticInputDisposition = "invalid" | "complete-period-cutoff" | "filter" | "retained";
103
+ export type DiagnosticInputDisposition =
104
+ | "invalid"
105
+ | "complete-period-cutoff"
106
+ | "filter"
107
+ | "retained";
108
+
109
+ export interface DiagnosticLossInputAuditSnapshot {
110
+ readonly recordId: string;
111
+ readonly rowType: "claim" | "aggregate";
112
+ readonly claimId: string | null;
113
+ readonly sourceGroup: string;
114
+ readonly origin: string;
115
+ readonly valuation: string;
116
+ readonly complete: boolean;
117
+ readonly measures: Readonly<Record<string, DiagnosticAuditedNumericValue>>;
118
+ readonly source: DiagnosticSourceLocation | null;
119
+ }
120
+
121
+ export interface DiagnosticExposureInputAuditSnapshot {
122
+ readonly key: string;
123
+ readonly sourceGroup: string;
124
+ readonly origin: string;
125
+ readonly valuation: string | null;
126
+ readonly measureId: string;
127
+ readonly value: DiagnosticAuditedNumericValue;
128
+ readonly complete: boolean;
129
+ readonly source: DiagnosticSourceLocation | null;
130
+ }
131
+
132
+ export interface DiagnosticExpectedCellAuditSnapshot {
133
+ readonly sourceGroup: string;
134
+ readonly origin: string;
135
+ readonly valuation: string;
136
+ readonly source: DiagnosticSourceLocation | null;
137
+ }
138
+
44
139
  export type DiagnosticInputAuditRecord =
45
- | { readonly kind: "loss"; readonly disposition: DiagnosticInputDisposition; readonly record: DiagnosticDeepReadonly<DiagnosticLossInput> }
46
- | { readonly kind: "exposure"; readonly disposition: DiagnosticInputDisposition; readonly record: DiagnosticDeepReadonly<DiagnosticExposureObservation> }
47
- | { readonly kind: "expected-cell"; readonly disposition: Exclude<DiagnosticInputDisposition, "invalid">; readonly record: DiagnosticDeepReadonly<DiagnosticExpectedCell> };
140
+ | {
141
+ readonly kind: "loss";
142
+ readonly disposition: DiagnosticInputDisposition;
143
+ readonly record: DiagnosticLossInputAuditSnapshot;
144
+ }
145
+ | {
146
+ readonly kind: "exposure";
147
+ readonly disposition: DiagnosticInputDisposition;
148
+ readonly record: DiagnosticExposureInputAuditSnapshot;
149
+ }
150
+ | {
151
+ readonly kind: "expected-cell";
152
+ readonly disposition: Exclude<DiagnosticInputDisposition, "invalid">;
153
+ readonly record: DiagnosticExpectedCellAuditSnapshot;
154
+ };
48
155
 
49
156
  export interface PreparedDiagnosticSourceCell {
50
157
  readonly sourceGroup: string;
@@ -53,9 +160,13 @@ export interface PreparedDiagnosticSourceCell {
53
160
  readonly developmentAge: number;
54
161
  readonly ageUnit: string;
55
162
  readonly lossRecordIds: readonly string[];
56
- readonly contributions: Readonly<Record<string, readonly DiagnosticMeasureContribution[]>>;
163
+ readonly contributions: Readonly<
164
+ Record<string, readonly DiagnosticMeasureContribution[]>
165
+ >;
57
166
  readonly components: Readonly<Record<string, DiagnosticMeasureStats>>;
58
- readonly structuralBlockers: Readonly<Record<string, readonly DiagnosticStructuralBlocker[]>>;
167
+ readonly structuralBlockers: Readonly<
168
+ Record<string, readonly DiagnosticStructuralBlocker[]>
169
+ >;
59
170
  readonly findings: readonly DiagnosticMetricFinding[];
60
171
  }
61
172
 
@@ -76,158 +187,2289 @@ export interface PreparedDiagnosticData {
76
187
 
77
188
  export interface NormalizedDiagnosticPreparationIdentity {
78
189
  readonly definitionIntegrity: string;
79
- readonly filter: DiagnosticDeepReadonly<DiagnosticsFilter> | null;
80
- readonly completePeriodCutoffs: readonly DiagnosticCompletePeriodCutoff[];
81
- readonly inputAudit: readonly DiagnosticInputAuditRecord[];
82
- readonly cells: readonly PreparedDiagnosticSourceCell[];
83
- readonly exposures: readonly ReconciledDiagnosticExposure[];
190
+ readonly filter: NormalizedDiagnosticsFilterIdentity | null;
191
+ readonly completePeriodCutoffs: readonly DiagnosticIdentityProjection<DiagnosticCompletePeriodCutoff>[];
192
+ readonly inputAudit: readonly DiagnosticIdentityProjection<DiagnosticInputAuditRecord>[];
193
+ readonly cells: readonly DiagnosticIdentityProjection<PreparedDiagnosticSourceCell>[];
194
+ readonly exposures: readonly DiagnosticIdentityProjection<ReconciledDiagnosticExposure>[];
84
195
  readonly expectedCellsProvided: boolean;
85
- readonly expectedCells: readonly DiagnosticExpectedCell[];
86
- readonly findings: readonly DiagnosticMetricFinding[];
196
+ readonly expectedCells: readonly NormalizedDiagnosticExpectedCellIdentity[];
197
+ readonly findings: readonly DiagnosticIdentityProjection<DiagnosticMetricFinding>[];
198
+ }
199
+
200
+ export interface NormalizedDiagnosticExpectedCellIdentity {
201
+ readonly sourceGroup: string;
202
+ readonly origin: string;
203
+ readonly valuation: string;
204
+ readonly source: NormalizedDiagnosticSourceLocationIdentity | null;
205
+ }
206
+
207
+ interface NormalizedCoordinate {
208
+ readonly origin: DiagnosticNormalizedPeriod;
209
+ readonly valuation: DiagnosticNormalizedPeriod;
210
+ readonly developmentAge: number;
211
+ readonly ageUnit: string;
212
+ }
213
+
214
+ interface LossCandidate {
215
+ row: DiagnosticLossInput;
216
+ snapshot: DiagnosticLossInputAuditSnapshot;
217
+ disposition: DiagnosticInputDisposition;
218
+ coordinate: NormalizedCoordinate | null;
219
+ invalid: boolean;
220
+ }
221
+
222
+ interface ExposureCandidate {
223
+ row: DiagnosticExposureObservation;
224
+ snapshot: DiagnosticExposureInputAuditSnapshot;
225
+ disposition: DiagnosticInputDisposition;
226
+ origin: DiagnosticNormalizedPeriod | null;
227
+ valuation: DiagnosticNormalizedPeriod | null;
228
+ developmentAge: number | null;
229
+ ageUnit: string | null;
230
+ timing: "origin-static" | "valuation-specific" | null;
231
+ invalid: boolean;
232
+ }
233
+
234
+ interface ExpectedCandidate {
235
+ readonly row: DiagnosticExpectedCell;
236
+ readonly snapshot: DiagnosticExpectedCellAuditSnapshot;
237
+ disposition: Exclude<DiagnosticInputDisposition, "invalid">;
238
+ readonly coordinate: NormalizedCoordinate;
239
+ }
240
+
241
+ interface NormalizedCutoff {
242
+ readonly value: DiagnosticCompletePeriodCutoff;
243
+ readonly originThrough: DiagnosticNormalizedPeriod | null;
244
+ readonly valuationThrough: DiagnosticNormalizedPeriod | null;
245
+ }
246
+
247
+ interface PendingBlocker {
248
+ readonly finding: DiagnosticMetricFinding;
249
+ readonly measureIds: readonly string[];
250
+ readonly cellKeys: readonly string[];
251
+ readonly sourceIds: readonly string[];
87
252
  }
88
253
 
89
254
  const authentic = new WeakSet<object>();
90
- const identities = new WeakMap<object, DiagnosticDeepReadonly<NormalizedDiagnosticPreparationIdentity>>();
255
+ const identities = new WeakMap<
256
+ object,
257
+ DiagnosticDeepReadonly<NormalizedDiagnosticPreparationIdentity>
258
+ >();
91
259
 
92
- function deepFreeze<T>(value: T, seen = new WeakSet<object>()): DiagnosticDeepReadonly<T> {
93
- if (value === null || typeof value !== "object" || seen.has(value)) return value as DiagnosticDeepReadonly<T>;
260
+ const STRUCTURAL = {
261
+ "duplicate-loss-record-id": ["Loss record identity is duplicated", "fail"],
262
+ "duplicate-claim-snapshot": ["Claim snapshot identity is duplicated", "fail"],
263
+ "claim-identity-conflict": [
264
+ "Claim identity has conflicting source group or origin",
265
+ "fail",
266
+ ],
267
+ "duplicate-aggregate-snapshot": [
268
+ "Aggregate source-cell identity is duplicated",
269
+ "fail",
270
+ ],
271
+ "duplicate-exposure-identity": ["Exposure identity is duplicated", "fail"],
272
+ "conflicting-exposure-identity": [
273
+ "Exposure identity has conflicting observations",
274
+ "fail",
275
+ ],
276
+ "unknown-origin-period": [
277
+ "Origin period is not declared by the period axis",
278
+ "fail",
279
+ ],
280
+ "unknown-valuation-period": [
281
+ "Valuation period is not declared by the period axis",
282
+ "fail",
283
+ ],
284
+ "valuation-before-origin": ["Valuation precedes origin", "fail"],
285
+ "unsafe-development-age": [
286
+ "Derived development age is not a nonnegative safe integer",
287
+ "fail",
288
+ ],
289
+ "undeclared-loss-measure": [
290
+ "Loss input contains an undeclared measure",
291
+ "fail",
292
+ ],
293
+ "wrong-source-loss-measure": [
294
+ "Loss input contains a measure that is not raw loss input",
295
+ "fail",
296
+ ],
297
+ "undeclared-exposure-measure": [
298
+ "Exposure input contains an undeclared measure",
299
+ "fail",
300
+ ],
301
+ "wrong-source-exposure-measure": [
302
+ "Exposure input names a measure that is not exposure input",
303
+ "fail",
304
+ ],
305
+ "incomplete-loss-record": ["Loss record is marked incomplete", "fail"],
306
+ "missing-exposure-value": [
307
+ "Exposure observation has a missing value",
308
+ "fail",
309
+ ],
310
+ "incomplete-exposure": ["Exposure observation is marked incomplete", "fail"],
311
+ "non-finite-exposure": ["Exposure observation is non-finite", "fail"],
312
+ "loss-without-exposure": [
313
+ "Loss source cell has no matching required exposure",
314
+ "warning",
315
+ ],
316
+ "exposure-without-loss": [
317
+ "Exposure has no matching retained loss source cell",
318
+ "warning",
319
+ ],
320
+ "missing-expected-cell": ["Expected source cell is absent", "fail"],
321
+ } as const;
322
+
323
+ type StructuralCode = keyof typeof STRUCTURAL;
324
+
325
+ function codeUnit(left: string, right: string): number {
326
+ return left < right ? -1 : left > right ? 1 : 0;
327
+ }
328
+
329
+ function own<T>(
330
+ record: Readonly<Record<string, T>>,
331
+ key: string,
332
+ ): T | undefined {
333
+ return Object.prototype.hasOwnProperty.call(record, key)
334
+ ? record[key]
335
+ : undefined;
336
+ }
337
+
338
+ function deepFreeze<T>(
339
+ value: T,
340
+ seen = new WeakSet<object>(),
341
+ ): DiagnosticDeepReadonly<T> {
342
+ if (value === null || typeof value !== "object" || seen.has(value))
343
+ return value as DiagnosticDeepReadonly<T>;
94
344
  seen.add(value);
95
- for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child, seen);
345
+ for (const child of Object.values(value as Record<string, unknown>))
346
+ deepFreeze(child, seen);
96
347
  return Object.freeze(value) as DiagnosticDeepReadonly<T>;
97
348
  }
98
349
 
99
- function codeUnit(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0 }
100
- function own<T>(record: Readonly<Record<string, T>>, key: string): T | undefined { return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined }
350
+ function uniqueSorted(values: readonly string[]): string[] {
351
+ return [...new Set(values)].sort(codeUnit);
352
+ }
353
+
354
+ function normalizeSources(
355
+ values: readonly (DiagnosticSourceLocation | undefined | null)[],
356
+ ): DiagnosticSourceLocation[] {
357
+ return normalizeDiagnosticSourceLocations(values);
358
+ }
359
+
360
+ function structuralFinding(
361
+ code: StructuralCode,
362
+ context: Omit<
363
+ DiagnosticMetricFinding,
364
+ "code" | "message" | "severity" | "category" | "sources"
365
+ > & {
366
+ readonly sources?: readonly DiagnosticSourceLocation[];
367
+ } = {},
368
+ ): DiagnosticMetricFinding {
369
+ const [message, severity] = STRUCTURAL[code];
370
+ return {
371
+ code,
372
+ message,
373
+ severity,
374
+ category: "structural",
375
+ ...context,
376
+ sources: normalizeSources(context.sources ?? []),
377
+ };
378
+ }
379
+
380
+ function aggregationFinding(
381
+ code:
382
+ | "diagnostic-measure-missing"
383
+ | "diagnostic-measure-imputed-zero"
384
+ | "diagnostic-measure-non-finite"
385
+ | "diagnostic-measure-overflow",
386
+ context: Omit<
387
+ DiagnosticMetricFinding,
388
+ "code" | "message" | "severity" | "category" | "sources"
389
+ > & { readonly sources?: readonly DiagnosticSourceLocation[] },
390
+ ): DiagnosticMetricFinding {
391
+ const catalog = {
392
+ "diagnostic-measure-missing": ["Measure input is missing", "warning"],
393
+ "diagnostic-measure-imputed-zero": [
394
+ "Missing measure input was imputed as zero",
395
+ "info",
396
+ ],
397
+ "diagnostic-measure-non-finite": ["Measure input is non-finite", "fail"],
398
+ "diagnostic-measure-overflow": ["Measure aggregation overflowed", "fail"],
399
+ } as const;
400
+ const [message, severity] = catalog[code];
401
+ return {
402
+ code,
403
+ message,
404
+ severity,
405
+ category: "aggregation",
406
+ ...context,
407
+ sources: normalizeSources(context.sources ?? []),
408
+ };
409
+ }
410
+
411
+ function mergeFindings(
412
+ values: readonly DiagnosticMetricFinding[],
413
+ ): DiagnosticMetricFinding[] {
414
+ const merged = new Map<string, DiagnosticMetricFinding>();
415
+ for (const value of values) {
416
+ const key = canonicalJson({ ...value, sources: [] });
417
+ const previous = merged.get(key);
418
+ merged.set(
419
+ key,
420
+ previous === undefined
421
+ ? { ...value, sources: normalizeSources(value.sources) }
422
+ : {
423
+ ...previous,
424
+ sources: normalizeSources([...previous.sources, ...value.sources]),
425
+ },
426
+ );
427
+ }
428
+ return [...merged.values()].sort(compareDiagnosticFindings);
429
+ }
430
+
431
+ function snapshotLoss(
432
+ row: DiagnosticLossInput,
433
+ ): DiagnosticLossInputAuditSnapshot {
434
+ const measures = Object.fromEntries(
435
+ Object.keys(row.measures)
436
+ .sort(codeUnit)
437
+ .map((measureId) => [
438
+ measureId,
439
+ auditDiagnosticNumber(row.measures[measureId] ?? null),
440
+ ]),
441
+ );
442
+ return {
443
+ recordId: row.recordId,
444
+ rowType: row.rowType,
445
+ claimId: row.rowType === "claim" ? row.claimId : null,
446
+ sourceGroup: row.sourceGroup,
447
+ origin: row.origin,
448
+ valuation: row.valuation,
449
+ complete: row.complete,
450
+ measures,
451
+ source: row.source === undefined ? null : { ...row.source },
452
+ };
453
+ }
101
454
 
102
- function normalizeFilter(filter: DiagnosticsFilter | undefined): DiagnosticsFilter | null {
103
- if (filter === undefined) return null;
104
- const set = (values: readonly string[] | undefined) => values === undefined ? undefined : [...new Set(values)].sort(codeUnit);
455
+ function snapshotExposure(
456
+ row: DiagnosticExposureObservation,
457
+ ): DiagnosticExposureInputAuditSnapshot {
105
458
  return {
106
- ...(filter.sourceGroups === undefined ? {} : { sourceGroups: set(filter.sourceGroups) }),
107
- ...(filter.outputGroups === undefined ? {} : { outputGroups: set(filter.outputGroups) }),
108
- ...(filter.origins === undefined ? {} : { origins: set(filter.origins) }),
109
- ...(filter.originFrom === undefined ? {} : { originFrom: filter.originFrom }),
110
- ...(filter.originThrough === undefined ? {} : { originThrough: filter.originThrough }),
111
- ...(filter.valuations === undefined ? {} : { valuations: set(filter.valuations) }),
112
- ...(filter.valuationFrom === undefined ? {} : { valuationFrom: filter.valuationFrom }),
113
- ...(filter.valuationThrough === undefined ? {} : { valuationThrough: filter.valuationThrough }),
114
- ...(filter.minDevelopmentAge === undefined ? {} : { minDevelopmentAge: filter.minDevelopmentAge }),
115
- ...(filter.maxDevelopmentAge === undefined ? {} : { maxDevelopmentAge: filter.maxDevelopmentAge }),
116
- ...(filter.instanceIds === undefined ? {} : { instanceIds: set(filter.instanceIds) }),
459
+ key: row.key,
460
+ sourceGroup: row.sourceGroup,
461
+ origin: row.origin,
462
+ valuation: row.valuation ?? null,
463
+ measureId: row.measureId,
464
+ value: auditDiagnosticNumber(row.value),
465
+ complete: row.complete,
466
+ source: row.source === undefined ? null : { ...row.source },
117
467
  };
118
468
  }
119
469
 
120
- function selected(filter: DiagnosticsFilter | null, group: string, origin: string, valuation: string, age: number): boolean {
121
- return (filter?.sourceGroups === undefined || filter.sourceGroups.includes(group)) &&
122
- (filter?.origins === undefined || filter.origins.includes(origin)) &&
123
- (filter?.valuations === undefined || filter.valuations.includes(valuation)) &&
124
- (filter?.originFrom === undefined || origin >= filter.originFrom) &&
125
- (filter?.originThrough === undefined || origin <= filter.originThrough) &&
126
- (filter?.valuationFrom === undefined || valuation >= filter.valuationFrom) &&
127
- (filter?.valuationThrough === undefined || valuation <= filter.valuationThrough) &&
128
- (filter?.minDevelopmentAge === undefined || age >= filter.minDevelopmentAge) &&
129
- (filter?.maxDevelopmentAge === undefined || age <= filter.maxDevelopmentAge);
470
+ function normalizeConfiguredPeriod(
471
+ definition: CompiledDiagnosticDefinition,
472
+ side: "origin" | "valuation",
473
+ label: string,
474
+ path: string,
475
+ issues: DiagnosticValidationIssue[],
476
+ ): DiagnosticNormalizedPeriod | null {
477
+ try {
478
+ return normalizeDiagnosticPeriod(definition, side, label);
479
+ } catch {
480
+ issues.push({
481
+ domain: "configuration",
482
+ code: "invalid-period",
483
+ path,
484
+ message: `Unknown ${side} period ${JSON.stringify(label)}`,
485
+ });
486
+ return null;
487
+ }
130
488
  }
131
489
 
132
- export function prepareDiagnosticData(input: PrepareDiagnosticDataInput): PreparedDiagnosticData {
133
- assertCompiledDiagnosticDefinition(input.definition);
490
+ function normalizeFilter(
491
+ definition: CompiledDiagnosticDefinition,
492
+ input: DiagnosticsFilter | undefined,
493
+ knownSourceGroups: ReadonlySet<string>,
494
+ issues: DiagnosticValidationIssue[],
495
+ ): {
496
+ readonly value: DiagnosticsFilter | null;
497
+ readonly originCoordinates: ReadonlyMap<string, number>;
498
+ readonly valuationCoordinates: ReadonlyMap<string, number>;
499
+ } {
500
+ if (input === undefined)
501
+ return {
502
+ value: null,
503
+ originCoordinates: new Map(),
504
+ valuationCoordinates: new Map(),
505
+ };
506
+ const checkTokens = (
507
+ values: readonly string[] | undefined,
508
+ path: string,
509
+ ): string[] | undefined => {
510
+ if (values === undefined) return undefined;
511
+ values.forEach((value, index) => {
512
+ if (typeof value !== "string" || value.length === 0)
513
+ issues.push({
514
+ domain: "configuration",
515
+ code: "invalid-string",
516
+ path: `${path}[${index}]`,
517
+ message: "Filter values must be nonempty strings",
518
+ });
519
+ });
520
+ return uniqueSorted(
521
+ values.filter((value) => typeof value === "string" && value.length > 0),
522
+ );
523
+ };
524
+ const normalizeList = (
525
+ values: readonly string[] | undefined,
526
+ side: "origin" | "valuation",
527
+ path: string,
528
+ ) => {
529
+ const checked = checkTokens(values, path);
530
+ if (checked === undefined)
531
+ return { labels: undefined, coordinates: new Map<string, number>() };
532
+ const normalized = checked
533
+ .map((label, index) =>
534
+ normalizeConfiguredPeriod(
535
+ definition,
536
+ side,
537
+ label,
538
+ `${path}[${index}]`,
539
+ issues,
540
+ ),
541
+ )
542
+ .filter((value): value is DiagnosticNormalizedPeriod => value !== null);
543
+ return {
544
+ labels: uniqueSorted(normalized.map((value) => value.label)),
545
+ coordinates: new Map(
546
+ normalized.map((value) => [value.label, value.coordinate]),
547
+ ),
548
+ };
549
+ };
550
+ const origins = normalizeList(input.origins, "origin", "$.filter.origins");
551
+ const valuations = normalizeList(
552
+ input.valuations,
553
+ "valuation",
554
+ "$.filter.valuations",
555
+ );
556
+ const originFrom =
557
+ input.originFrom === undefined
558
+ ? null
559
+ : normalizeConfiguredPeriod(
560
+ definition,
561
+ "origin",
562
+ input.originFrom,
563
+ "$.filter.originFrom",
564
+ issues,
565
+ );
566
+ const originThrough =
567
+ input.originThrough === undefined
568
+ ? null
569
+ : normalizeConfiguredPeriod(
570
+ definition,
571
+ "origin",
572
+ input.originThrough,
573
+ "$.filter.originThrough",
574
+ issues,
575
+ );
576
+ const valuationFrom =
577
+ input.valuationFrom === undefined
578
+ ? null
579
+ : normalizeConfiguredPeriod(
580
+ definition,
581
+ "valuation",
582
+ input.valuationFrom,
583
+ "$.filter.valuationFrom",
584
+ issues,
585
+ );
586
+ const valuationThrough =
587
+ input.valuationThrough === undefined
588
+ ? null
589
+ : normalizeConfiguredPeriod(
590
+ definition,
591
+ "valuation",
592
+ input.valuationThrough,
593
+ "$.filter.valuationThrough",
594
+ issues,
595
+ );
596
+ if (
597
+ originFrom &&
598
+ originThrough &&
599
+ compareDiagnosticPeriods(originFrom, originThrough) > 0
600
+ )
601
+ issues.push({
602
+ domain: "configuration",
603
+ code: "invalid-configuration",
604
+ path: "$.filter",
605
+ message: "Origin range start exceeds end",
606
+ });
607
+ if (
608
+ valuationFrom &&
609
+ valuationThrough &&
610
+ compareDiagnosticPeriods(valuationFrom, valuationThrough) > 0
611
+ )
612
+ issues.push({
613
+ domain: "configuration",
614
+ code: "invalid-configuration",
615
+ path: "$.filter",
616
+ message: "Valuation range start exceeds end",
617
+ });
618
+ for (const [name, bound] of [
619
+ ["minDevelopmentAge", input.minDevelopmentAge],
620
+ ["maxDevelopmentAge", input.maxDevelopmentAge],
621
+ ] as const) {
622
+ if (bound !== undefined && (!Number.isSafeInteger(bound) || bound < 0))
623
+ issues.push({
624
+ domain: "configuration",
625
+ code: "invalid-configuration",
626
+ path: `$.filter.${name}`,
627
+ message: "Development-age bounds must be nonnegative safe integers",
628
+ });
629
+ }
630
+ if (
631
+ input.minDevelopmentAge !== undefined &&
632
+ input.maxDevelopmentAge !== undefined &&
633
+ input.minDevelopmentAge > input.maxDevelopmentAge
634
+ )
635
+ issues.push({
636
+ domain: "configuration",
637
+ code: "invalid-configuration",
638
+ path: "$.filter",
639
+ message: "Minimum development age exceeds maximum",
640
+ });
641
+ const sourceGroups = checkTokens(input.sourceGroups, "$.filter.sourceGroups");
642
+ const outputGroups = checkTokens(input.outputGroups, "$.filter.outputGroups");
643
+ const instanceIds = checkTokens(input.instanceIds, "$.filter.instanceIds");
644
+ const validInstances = new Set(
645
+ definition.definition.instances.map((instance) => instance.id),
646
+ );
647
+ for (const [index, sourceGroup] of (sourceGroups ?? []).entries()) {
648
+ if (!knownSourceGroups.has(sourceGroup))
649
+ issues.push({
650
+ domain: "configuration",
651
+ code: "unknown-reference",
652
+ path: `$.filter.sourceGroups[${index}]`,
653
+ message: `Unknown source group ${sourceGroup}`,
654
+ });
655
+ }
656
+ for (const [index, instanceId] of (instanceIds ?? []).entries())
657
+ if (!validInstances.has(instanceId))
658
+ issues.push({
659
+ domain: "configuration",
660
+ code: "unknown-reference",
661
+ path: `$.filter.instanceIds[${index}]`,
662
+ message: `Unknown metric instance ${instanceId}`,
663
+ });
664
+ const value: DiagnosticsFilter = {
665
+ ...(sourceGroups === undefined ? {} : { sourceGroups }),
666
+ ...(outputGroups === undefined ? {} : { outputGroups }),
667
+ ...(origins.labels === undefined ? {} : { origins: origins.labels }),
668
+ ...(originFrom === null ? {} : { originFrom: originFrom.label }),
669
+ ...(originThrough === null ? {} : { originThrough: originThrough.label }),
670
+ ...(valuations.labels === undefined
671
+ ? {}
672
+ : { valuations: valuations.labels }),
673
+ ...(valuationFrom === null ? {} : { valuationFrom: valuationFrom.label }),
674
+ ...(valuationThrough === null
675
+ ? {}
676
+ : { valuationThrough: valuationThrough.label }),
677
+ ...(input.minDevelopmentAge === undefined
678
+ ? {}
679
+ : { minDevelopmentAge: input.minDevelopmentAge }),
680
+ ...(input.maxDevelopmentAge === undefined
681
+ ? {}
682
+ : { maxDevelopmentAge: input.maxDevelopmentAge }),
683
+ ...(instanceIds === undefined ? {} : { instanceIds }),
684
+ };
685
+ return {
686
+ value,
687
+ originCoordinates: new Map([
688
+ ...origins.coordinates,
689
+ ...(originFrom
690
+ ? [[originFrom.label, originFrom.coordinate] as const]
691
+ : []),
692
+ ...(originThrough
693
+ ? [[originThrough.label, originThrough.coordinate] as const]
694
+ : []),
695
+ ]),
696
+ valuationCoordinates: new Map([
697
+ ...valuations.coordinates,
698
+ ...(valuationFrom
699
+ ? [[valuationFrom.label, valuationFrom.coordinate] as const]
700
+ : []),
701
+ ...(valuationThrough
702
+ ? [[valuationThrough.label, valuationThrough.coordinate] as const]
703
+ : []),
704
+ ]),
705
+ };
706
+ }
707
+
708
+ function normalizeCutoffs(
709
+ definition: CompiledDiagnosticDefinition,
710
+ input: readonly DiagnosticCompletePeriodCutoff[],
711
+ knownSourceGroups: ReadonlySet<string>,
712
+ issues: DiagnosticValidationIssue[],
713
+ ): readonly NormalizedCutoff[] {
714
+ const groups = new Set<string>();
715
+ const normalized: NormalizedCutoff[] = [];
716
+ input.forEach((cutoff, index) => {
717
+ if (!knownSourceGroups.has(cutoff.sourceGroup))
718
+ issues.push({
719
+ domain: "configuration",
720
+ code: "unknown-reference",
721
+ path: `$.completePeriodCutoffs[${index}].sourceGroup`,
722
+ message: `Unknown source group ${cutoff.sourceGroup}`,
723
+ });
724
+ if (groups.has(cutoff.sourceGroup))
725
+ issues.push({
726
+ domain: "configuration",
727
+ code: "duplicate-id",
728
+ path: `$.completePeriodCutoffs[${index}].sourceGroup`,
729
+ message: "Complete-period cutoff source group is duplicated",
730
+ });
731
+ groups.add(cutoff.sourceGroup);
732
+ const origin =
733
+ cutoff.originThrough === null
734
+ ? null
735
+ : normalizeConfiguredPeriod(
736
+ definition,
737
+ "origin",
738
+ cutoff.originThrough,
739
+ `$.completePeriodCutoffs[${index}].originThrough`,
740
+ issues,
741
+ );
742
+ const valuation =
743
+ cutoff.valuationThrough === null
744
+ ? null
745
+ : normalizeConfiguredPeriod(
746
+ definition,
747
+ "valuation",
748
+ cutoff.valuationThrough,
749
+ `$.completePeriodCutoffs[${index}].valuationThrough`,
750
+ issues,
751
+ );
752
+ normalized.push({
753
+ value: {
754
+ sourceGroup: cutoff.sourceGroup,
755
+ originThrough: origin?.label ?? null,
756
+ valuationThrough: valuation?.label ?? null,
757
+ },
758
+ originThrough: origin,
759
+ valuationThrough: valuation,
760
+ });
761
+ });
762
+ return normalized.sort((left, right) =>
763
+ codeUnit(left.value.sourceGroup, right.value.sourceGroup),
764
+ );
765
+ }
766
+
767
+ function coordinateFor(
768
+ definition: CompiledDiagnosticDefinition,
769
+ originLabel: string,
770
+ valuationLabel: string,
771
+ ): {
772
+ readonly coordinate: NormalizedCoordinate | null;
773
+ readonly issue:
774
+ | "unknown-origin-period"
775
+ | "unknown-valuation-period"
776
+ | "valuation-before-origin"
777
+ | "unsafe-development-age"
778
+ | null;
779
+ } {
780
+ let origin: DiagnosticNormalizedPeriod;
781
+ let valuation: DiagnosticNormalizedPeriod;
782
+ try {
783
+ origin = normalizeDiagnosticPeriod(definition, "origin", originLabel);
784
+ } catch {
785
+ return { coordinate: null, issue: "unknown-origin-period" };
786
+ }
787
+ try {
788
+ valuation = normalizeDiagnosticPeriod(
789
+ definition,
790
+ "valuation",
791
+ valuationLabel,
792
+ );
793
+ } catch {
794
+ return { coordinate: null, issue: "unknown-valuation-period" };
795
+ }
796
+ const developmentAge =
797
+ valuation.coordinate -
798
+ origin.coordinate +
799
+ definition.definition.periodAxis.ageOffset;
800
+ if (!Number.isSafeInteger(developmentAge))
801
+ return { coordinate: null, issue: "unsafe-development-age" };
802
+ if (developmentAge < 0)
803
+ return { coordinate: null, issue: "valuation-before-origin" };
804
+ return {
805
+ coordinate: {
806
+ origin,
807
+ valuation,
808
+ developmentAge,
809
+ ageUnit: definition.definition.periodAxis.ageUnit,
810
+ },
811
+ issue: null,
812
+ };
813
+ }
814
+
815
+ function cellKey(
816
+ sourceGroup: string,
817
+ origin: string,
818
+ valuation: string,
819
+ ): string {
820
+ return canonicalJson([sourceGroup, origin, valuation]);
821
+ }
822
+
823
+ function parseCellKey(key: string): readonly [string, string, string] {
824
+ return JSON.parse(key) as [string, string, string];
825
+ }
826
+
827
+ function coordinateContext(candidate: {
828
+ readonly row: { readonly sourceGroup: string };
829
+ readonly coordinate: NormalizedCoordinate | null;
830
+ }): Partial<DiagnosticMetricFinding> {
831
+ if (candidate.coordinate === null) return {};
832
+ return {
833
+ sourceGroup: candidate.row.sourceGroup,
834
+ origin: candidate.coordinate.origin.label,
835
+ valuation: candidate.coordinate.valuation.label,
836
+ developmentAge: candidate.coordinate.developmentAge,
837
+ ageUnit: candidate.coordinate.ageUnit,
838
+ };
839
+ }
840
+
841
+ function selectedBySource(
842
+ filter: DiagnosticsFilter | null,
843
+ sourceGroup: string,
844
+ ): boolean {
845
+ return (
846
+ filter?.sourceGroups === undefined ||
847
+ filter.sourceGroups.includes(sourceGroup)
848
+ );
849
+ }
850
+
851
+ function selectedByOrigin(
852
+ filter: DiagnosticsFilter | null,
853
+ origin: DiagnosticNormalizedPeriod,
854
+ coordinates: ReadonlyMap<string, number>,
855
+ ): boolean {
856
+ const from =
857
+ filter?.originFrom === undefined
858
+ ? null
859
+ : coordinates.get(filter.originFrom)!;
860
+ const through =
861
+ filter?.originThrough === undefined
862
+ ? null
863
+ : coordinates.get(filter.originThrough)!;
864
+ return (
865
+ (filter?.origins === undefined || filter.origins.includes(origin.label)) &&
866
+ (from === null || origin.coordinate >= from) &&
867
+ (through === null || origin.coordinate <= through)
868
+ );
869
+ }
870
+
871
+ function selectedByValuation(
872
+ filter: DiagnosticsFilter | null,
873
+ valuation: DiagnosticNormalizedPeriod,
874
+ coordinates: ReadonlyMap<string, number>,
875
+ ): boolean {
876
+ const from =
877
+ filter?.valuationFrom === undefined
878
+ ? null
879
+ : coordinates.get(filter.valuationFrom)!;
880
+ const through =
881
+ filter?.valuationThrough === undefined
882
+ ? null
883
+ : coordinates.get(filter.valuationThrough)!;
884
+ return (
885
+ (filter?.valuations === undefined ||
886
+ filter.valuations.includes(valuation.label)) &&
887
+ (from === null || valuation.coordinate >= from) &&
888
+ (through === null || valuation.coordinate <= through)
889
+ );
890
+ }
891
+
892
+ function selectedByAge(filter: DiagnosticsFilter | null, age: number): boolean {
893
+ return (
894
+ (filter?.minDevelopmentAge === undefined ||
895
+ age >= filter.minDevelopmentAge) &&
896
+ (filter?.maxDevelopmentAge === undefined || age <= filter.maxDevelopmentAge)
897
+ );
898
+ }
899
+
900
+ function beyondCutoff(
901
+ candidate: {
902
+ readonly row: { readonly sourceGroup: string };
903
+ readonly coordinate: NormalizedCoordinate;
904
+ },
905
+ cutoffByGroup: ReadonlyMap<string, NormalizedCutoff>,
906
+ ): boolean {
907
+ const cutoff = cutoffByGroup.get(candidate.row.sourceGroup);
908
+ return (
909
+ cutoff !== undefined &&
910
+ ((cutoff.originThrough !== null &&
911
+ compareDiagnosticPeriods(
912
+ candidate.coordinate.origin,
913
+ cutoff.originThrough,
914
+ ) > 0) ||
915
+ (cutoff.valuationThrough !== null &&
916
+ compareDiagnosticPeriods(
917
+ candidate.coordinate.valuation,
918
+ cutoff.valuationThrough,
919
+ ) > 0))
920
+ );
921
+ }
922
+
923
+ function addToMap<K, V>(map: Map<K, V[]>, key: K, value: V): void {
924
+ const values = map.get(key) ?? [];
925
+ values.push(value);
926
+ map.set(key, values);
927
+ }
928
+
929
+ function commonCoordinate(
930
+ candidates: readonly LossCandidate[],
931
+ ): Partial<DiagnosticMetricFinding> {
932
+ const keys = uniqueSorted(
933
+ candidates.flatMap((candidate) =>
934
+ candidate.coordinate === null
935
+ ? []
936
+ : [
937
+ cellKey(
938
+ candidate.row.sourceGroup,
939
+ candidate.coordinate.origin.label,
940
+ candidate.coordinate.valuation.label,
941
+ ),
942
+ ],
943
+ ),
944
+ );
945
+ return keys.length === 1 ? coordinateContext(candidates[0]!) : {};
946
+ }
947
+
948
+ function exposureIdentity(candidate: ExposureCandidate): string {
949
+ return canonicalJson(
950
+ candidate.timing === "valuation-specific"
951
+ ? [
952
+ candidate.row.measureId,
953
+ candidate.row.key,
954
+ candidate.row.valuation ?? null,
955
+ ]
956
+ : [candidate.row.measureId, candidate.row.key],
957
+ );
958
+ }
959
+
960
+ function exposureCoordinateContext(
961
+ candidates: readonly ExposureCandidate[],
962
+ ): Partial<DiagnosticMetricFinding> {
963
+ const coherentSource = uniqueSorted(
964
+ candidates.map((candidate) => candidate.row.sourceGroup),
965
+ );
966
+ const coherentOrigin = uniqueSorted(
967
+ candidates.flatMap((candidate) =>
968
+ candidate.origin ? [candidate.origin.label] : [],
969
+ ),
970
+ );
971
+ const coherentValuation = uniqueSorted(
972
+ candidates.flatMap((candidate) =>
973
+ candidate.valuation ? [candidate.valuation.label] : [],
974
+ ),
975
+ );
976
+ if (coherentSource.length !== 1 || coherentOrigin.length !== 1) return {};
977
+ const first = candidates[0]!;
978
+ if (
979
+ first.timing === "valuation-specific" &&
980
+ coherentValuation.length === 1 &&
981
+ first.developmentAge !== null &&
982
+ first.ageUnit !== null
983
+ ) {
984
+ return {
985
+ sourceGroup: coherentSource[0],
986
+ origin: coherentOrigin[0],
987
+ valuation: coherentValuation[0],
988
+ developmentAge: first.developmentAge,
989
+ ageUnit: first.ageUnit,
990
+ };
991
+ }
992
+ return { sourceGroup: coherentSource[0], origin: coherentOrigin[0] };
993
+ }
994
+
995
+ function auditSort(
996
+ left: DiagnosticInputAuditRecord,
997
+ right: DiagnosticInputAuditRecord,
998
+ ): number {
999
+ const rank = { loss: 0, exposure: 1, "expected-cell": 2 } as const;
1000
+ const disposition = {
1001
+ invalid: 0,
1002
+ "complete-period-cutoff": 1,
1003
+ filter: 2,
1004
+ retained: 3,
1005
+ } as const;
1006
+ return (
1007
+ rank[left.kind] - rank[right.kind] ||
1008
+ codeUnit(canonicalJson(left.record), canonicalJson(right.record)) ||
1009
+ disposition[left.disposition] - disposition[right.disposition]
1010
+ );
1011
+ }
1012
+
1013
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
1014
+ return isDiagnosticPlainRecord(value);
1015
+ }
1016
+
1017
+ function preparationBoundaryIssues(
1018
+ value: unknown,
1019
+ ): DiagnosticValidationIssue[] {
1020
+ const issues: DiagnosticValidationIssue[] = [];
1021
+ if (!isPlainRecord(value))
1022
+ return [
1023
+ {
1024
+ domain: "input",
1025
+ code: "invalid-type",
1026
+ path: "$",
1027
+ message: "Preparation input must be an object",
1028
+ },
1029
+ ];
1030
+ const issue = (
1031
+ domain: "input" | "configuration",
1032
+ code: DiagnosticValidationIssue["code"],
1033
+ path: string,
1034
+ message: string,
1035
+ ) => issues.push({ domain, code, path, message });
1036
+ const token = (
1037
+ item: unknown,
1038
+ path: string,
1039
+ domain: "input" | "configuration" = "input",
1040
+ ) => {
1041
+ if (typeof item !== "string")
1042
+ issue(domain, "invalid-type", path, "Expected a string");
1043
+ else if (!isDiagnosticToken(item))
1044
+ issue(
1045
+ domain,
1046
+ "invalid-string",
1047
+ path,
1048
+ "Expected a nonempty token with valid Unicode and no U+0000",
1049
+ );
1050
+ };
1051
+ const exactKeys = (
1052
+ item: Record<string, unknown>,
1053
+ allowed: readonly string[],
1054
+ path: string,
1055
+ domain: "input" | "configuration",
1056
+ ) => {
1057
+ for (const key of Object.keys(item)) {
1058
+ const keyPath = `${path}.${key}`;
1059
+ if (!allowed.includes(key))
1060
+ issue(domain, "unknown-key", keyPath, `Unknown key ${key}`);
1061
+ else if (item[key] === undefined)
1062
+ issue(
1063
+ domain,
1064
+ "invalid-type",
1065
+ keyPath,
1066
+ "Explicit undefined is not allowed",
1067
+ );
1068
+ }
1069
+ };
1070
+ const source = (item: unknown, path: string) => {
1071
+ if (!isPlainRecord(item)) {
1072
+ issue("input", "invalid-type", path, "Source location must be an object");
1073
+ return;
1074
+ }
1075
+ exactKeys(
1076
+ item,
1077
+ ["artifactId", "sourceFile", "sourceSheet", "sourceRow", "sourceCell"],
1078
+ path,
1079
+ "input",
1080
+ );
1081
+ token(item.artifactId, `${path}.artifactId`);
1082
+ for (const key of ["sourceFile", "sourceSheet", "sourceCell"] as const)
1083
+ if (item[key] !== undefined) token(item[key], `${path}.${key}`);
1084
+ if (
1085
+ item.sourceRow !== undefined &&
1086
+ (typeof item.sourceRow !== "number" ||
1087
+ !Number.isSafeInteger(item.sourceRow) ||
1088
+ item.sourceRow < 0)
1089
+ )
1090
+ issue(
1091
+ "input",
1092
+ "invalid-number",
1093
+ `${path}.sourceRow`,
1094
+ "Source row must be a nonnegative safe integer",
1095
+ );
1096
+ };
1097
+ const array = (
1098
+ item: unknown,
1099
+ path: string,
1100
+ domain: "input" | "configuration",
1101
+ ): readonly unknown[] => {
1102
+ if (!Array.isArray(item)) {
1103
+ issue(domain, "invalid-type", path, "Expected an array");
1104
+ return [];
1105
+ }
1106
+ return item;
1107
+ };
1108
+ exactKeys(
1109
+ value,
1110
+ [
1111
+ "definition",
1112
+ "losses",
1113
+ "exposures",
1114
+ "filter",
1115
+ "completePeriodCutoffs",
1116
+ "expectedCells",
1117
+ ],
1118
+ "$",
1119
+ "configuration",
1120
+ );
1121
+ for (const [index, raw] of array(
1122
+ value.losses,
1123
+ "$.losses",
1124
+ "input",
1125
+ ).entries()) {
1126
+ const path = `$.losses[${index}]`;
1127
+ if (!isPlainRecord(raw)) {
1128
+ issue("input", "invalid-type", path, "Loss record must be an object");
1129
+ continue;
1130
+ }
1131
+ exactKeys(
1132
+ raw,
1133
+ [
1134
+ "rowType",
1135
+ "recordId",
1136
+ "claimId",
1137
+ "sourceGroup",
1138
+ "origin",
1139
+ "valuation",
1140
+ "complete",
1141
+ "source",
1142
+ "measures",
1143
+ ],
1144
+ path,
1145
+ "input",
1146
+ );
1147
+ if (raw.rowType !== "claim" && raw.rowType !== "aggregate")
1148
+ issue(
1149
+ "input",
1150
+ "invalid-type",
1151
+ `${path}.rowType`,
1152
+ "Loss row type must be claim or aggregate",
1153
+ );
1154
+ token(raw.recordId, `${path}.recordId`);
1155
+ token(raw.sourceGroup, `${path}.sourceGroup`);
1156
+ token(raw.origin, `${path}.origin`);
1157
+ token(raw.valuation, `${path}.valuation`);
1158
+ if (raw.rowType === "claim") token(raw.claimId, `${path}.claimId`);
1159
+ else if (raw.claimId !== undefined)
1160
+ issue(
1161
+ "input",
1162
+ "unknown-key",
1163
+ `${path}.claimId`,
1164
+ "Aggregate loss rows cannot contain claimId",
1165
+ );
1166
+ if (typeof raw.complete !== "boolean")
1167
+ issue(
1168
+ "input",
1169
+ "invalid-type",
1170
+ `${path}.complete`,
1171
+ "Loss completeness must be boolean",
1172
+ );
1173
+ if (raw.source !== undefined) source(raw.source, `${path}.source`);
1174
+ if (!isPlainRecord(raw.measures))
1175
+ issue(
1176
+ "input",
1177
+ "invalid-type",
1178
+ `${path}.measures`,
1179
+ "Measures must be an object",
1180
+ );
1181
+ else
1182
+ for (const [measureId, amount] of Object.entries(raw.measures)) {
1183
+ token(measureId, `${path}.measures`);
1184
+ if (amount !== null && typeof amount !== "number")
1185
+ issue(
1186
+ "input",
1187
+ "invalid-type",
1188
+ `${path}.measures.${measureId}`,
1189
+ "Measure value must be a number or null",
1190
+ );
1191
+ }
1192
+ }
1193
+ for (const [index, raw] of array(
1194
+ value.exposures,
1195
+ "$.exposures",
1196
+ "input",
1197
+ ).entries()) {
1198
+ const path = `$.exposures[${index}]`;
1199
+ if (!isPlainRecord(raw)) {
1200
+ issue(
1201
+ "input",
1202
+ "invalid-type",
1203
+ path,
1204
+ "Exposure observation must be an object",
1205
+ );
1206
+ continue;
1207
+ }
1208
+ exactKeys(
1209
+ raw,
1210
+ [
1211
+ "key",
1212
+ "sourceGroup",
1213
+ "origin",
1214
+ "valuation",
1215
+ "measureId",
1216
+ "value",
1217
+ "complete",
1218
+ "source",
1219
+ ],
1220
+ path,
1221
+ "input",
1222
+ );
1223
+ token(raw.key, `${path}.key`);
1224
+ token(raw.sourceGroup, `${path}.sourceGroup`);
1225
+ token(raw.origin, `${path}.origin`);
1226
+ token(raw.measureId, `${path}.measureId`);
1227
+ if (raw.valuation !== undefined) token(raw.valuation, `${path}.valuation`);
1228
+ if (raw.value !== null && typeof raw.value !== "number")
1229
+ issue(
1230
+ "input",
1231
+ "invalid-type",
1232
+ `${path}.value`,
1233
+ "Exposure value must be a number or null",
1234
+ );
1235
+ if (typeof raw.complete !== "boolean")
1236
+ issue(
1237
+ "input",
1238
+ "invalid-type",
1239
+ `${path}.complete`,
1240
+ "Exposure completeness must be boolean",
1241
+ );
1242
+ if (raw.source !== undefined) source(raw.source, `${path}.source`);
1243
+ }
1244
+ if (value.filter !== undefined && !isPlainRecord(value.filter))
1245
+ issue(
1246
+ "configuration",
1247
+ "invalid-type",
1248
+ "$.filter",
1249
+ "Filter must be an object",
1250
+ );
1251
+ if (isPlainRecord(value.filter)) {
1252
+ const filter = value.filter;
1253
+ const keys = [
1254
+ "sourceGroups",
1255
+ "outputGroups",
1256
+ "origins",
1257
+ "originFrom",
1258
+ "originThrough",
1259
+ "valuations",
1260
+ "valuationFrom",
1261
+ "valuationThrough",
1262
+ "minDevelopmentAge",
1263
+ "maxDevelopmentAge",
1264
+ "instanceIds",
1265
+ ] as const;
1266
+ exactKeys(filter, keys, "$.filter", "configuration");
1267
+ for (const key of [
1268
+ "sourceGroups",
1269
+ "outputGroups",
1270
+ "origins",
1271
+ "valuations",
1272
+ "instanceIds",
1273
+ ] as const)
1274
+ if (filter[key] !== undefined)
1275
+ array(filter[key], `$.filter.${key}`, "configuration").forEach(
1276
+ (item, index) =>
1277
+ token(item, `$.filter.${key}[${index}]`, "configuration"),
1278
+ );
1279
+ for (const key of [
1280
+ "originFrom",
1281
+ "originThrough",
1282
+ "valuationFrom",
1283
+ "valuationThrough",
1284
+ ] as const)
1285
+ if (filter[key] !== undefined)
1286
+ token(filter[key], `$.filter.${key}`, "configuration");
1287
+ }
1288
+ if (value.completePeriodCutoffs !== undefined)
1289
+ for (const [index, raw] of array(
1290
+ value.completePeriodCutoffs,
1291
+ "$.completePeriodCutoffs",
1292
+ "configuration",
1293
+ ).entries()) {
1294
+ const path = `$.completePeriodCutoffs[${index}]`;
1295
+ if (!isPlainRecord(raw)) {
1296
+ issue(
1297
+ "configuration",
1298
+ "invalid-type",
1299
+ path,
1300
+ "Cutoff must be an object",
1301
+ );
1302
+ continue;
1303
+ }
1304
+ exactKeys(
1305
+ raw,
1306
+ ["sourceGroup", "originThrough", "valuationThrough"],
1307
+ path,
1308
+ "configuration",
1309
+ );
1310
+ token(raw.sourceGroup, `${path}.sourceGroup`, "configuration");
1311
+ for (const key of ["originThrough", "valuationThrough"] as const)
1312
+ if (raw[key] !== null)
1313
+ token(raw[key], `${path}.${key}`, "configuration");
1314
+ }
1315
+ if (value.expectedCells !== undefined)
1316
+ for (const [index, raw] of array(
1317
+ value.expectedCells,
1318
+ "$.expectedCells",
1319
+ "configuration",
1320
+ ).entries()) {
1321
+ const path = `$.expectedCells[${index}]`;
1322
+ if (!isPlainRecord(raw)) {
1323
+ issue(
1324
+ "configuration",
1325
+ "invalid-type",
1326
+ path,
1327
+ "Expected cell must be an object",
1328
+ );
1329
+ continue;
1330
+ }
1331
+ exactKeys(
1332
+ raw,
1333
+ ["sourceGroup", "origin", "valuation", "source"],
1334
+ path,
1335
+ "configuration",
1336
+ );
1337
+ token(raw.sourceGroup, `${path}.sourceGroup`, "configuration");
1338
+ token(raw.origin, `${path}.origin`, "configuration");
1339
+ token(raw.valuation, `${path}.valuation`, "configuration");
1340
+ if (raw.source !== undefined) source(raw.source, `${path}.source`);
1341
+ }
1342
+ return issues;
1343
+ }
1344
+
1345
+ export function prepareDiagnosticData(
1346
+ input: PrepareDiagnosticDataInput,
1347
+ ): PreparedDiagnosticData {
1348
+ assertCompiledDiagnosticDefinition(
1349
+ (input as unknown as { readonly definition?: unknown } | null)?.definition,
1350
+ );
1351
+ const boundaryIssues = preparationBoundaryIssues(input);
1352
+ if (boundaryIssues.length > 0)
1353
+ throw new DiagnosticValidationError(boundaryIssues);
134
1354
  const definition = input.definition;
135
1355
  const internals = getCompiledDiagnosticDefinitionInternals(definition);
136
1356
  const issues: DiagnosticValidationIssue[] = [];
137
- const filter = normalizeFilter(input.filter);
138
- for (const bound of [filter?.minDevelopmentAge, filter?.maxDevelopmentAge]) if (bound !== undefined && (!Number.isSafeInteger(bound) || bound < 0)) issues.push({ domain: "configuration", code: "invalid-configuration", path: "$.filter", message: "Development-age bounds must be nonnegative safe integers" });
139
- if (filter?.minDevelopmentAge !== undefined && filter.maxDevelopmentAge !== undefined && filter.minDevelopmentAge > filter.maxDevelopmentAge) issues.push({ domain: "configuration", code: "invalid-configuration", path: "$.filter", message: "Minimum development age exceeds maximum" });
1357
+ const knownSourceGroups = new Set<string>([
1358
+ ...input.losses.map((row) => row.sourceGroup),
1359
+ ...input.exposures.map((row) => row.sourceGroup),
1360
+ ...(input.expectedCells ?? []).map((row) => row.sourceGroup),
1361
+ ]);
1362
+ const normalizedFilter = normalizeFilter(
1363
+ definition,
1364
+ input.filter,
1365
+ knownSourceGroups,
1366
+ issues,
1367
+ );
1368
+ const normalizedCutoffs = normalizeCutoffs(
1369
+ definition,
1370
+ input.completePeriodCutoffs ?? [],
1371
+ knownSourceGroups,
1372
+ issues,
1373
+ );
1374
+ const cutoffByGroup = new Map(
1375
+ normalizedCutoffs.map((cutoff) => [cutoff.value.sourceGroup, cutoff]),
1376
+ );
1377
+
140
1378
  input.losses.forEach((row, index) => {
141
- if (row.rowType !== definition.definition.lossRowGrain) issues.push({ domain: "input", code: "invalid-input-relationship", path: `$.losses[${index}].rowType`, message: "Loss row type does not match definition grain" });
1379
+ if (row.rowType !== definition.definition.lossRowGrain)
1380
+ issues.push({
1381
+ domain: "input",
1382
+ code: "invalid-input-relationship",
1383
+ path: `$.losses[${index}].rowType`,
1384
+ message: "Loss row type does not match definition grain",
1385
+ });
142
1386
  });
143
1387
  input.exposures.forEach((row, index) => {
144
1388
  const measure = internals.measuresById.get(row.measureId);
145
- if (measure?.exposureTiming === "valuation-specific" && row.valuation === undefined) issues.push({ domain: "input", code: "missing-required", path: `$.exposures[${index}].valuation`, message: "Valuation-specific exposure requires valuation" });
1389
+ if (
1390
+ measure?.exposureTiming === "valuation-specific" &&
1391
+ row.valuation === undefined
1392
+ )
1393
+ issues.push({
1394
+ domain: "input",
1395
+ code: "missing-required",
1396
+ path: `$.exposures[${index}].valuation`,
1397
+ message: "Valuation-specific exposure requires valuation",
1398
+ });
1399
+ });
1400
+
1401
+ const expectedCandidates: ExpectedCandidate[] = [];
1402
+ const expectedKeys = new Set<string>();
1403
+ (input.expectedCells ?? []).forEach((row, index) => {
1404
+ const origin = normalizeConfiguredPeriod(
1405
+ definition,
1406
+ "origin",
1407
+ row.origin,
1408
+ `$.expectedCells[${index}].origin`,
1409
+ issues,
1410
+ );
1411
+ const valuation = normalizeConfiguredPeriod(
1412
+ definition,
1413
+ "valuation",
1414
+ row.valuation,
1415
+ `$.expectedCells[${index}].valuation`,
1416
+ issues,
1417
+ );
1418
+ if (!origin || !valuation) return;
1419
+ const developmentAge =
1420
+ valuation.coordinate -
1421
+ origin.coordinate +
1422
+ definition.definition.periodAxis.ageOffset;
1423
+ if (!Number.isSafeInteger(developmentAge) || developmentAge < 0) {
1424
+ issues.push({
1425
+ domain: "configuration",
1426
+ code: "invalid-period",
1427
+ path: `$.expectedCells[${index}].valuation`,
1428
+ message:
1429
+ "Expected-cell valuation precedes origin or produces an unsafe development age",
1430
+ });
1431
+ return;
1432
+ }
1433
+ const normalized = {
1434
+ ...row,
1435
+ origin: origin.label,
1436
+ valuation: valuation.label,
1437
+ };
1438
+ const key = cellKey(row.sourceGroup, origin.label, valuation.label);
1439
+ if (expectedKeys.has(key))
1440
+ issues.push({
1441
+ domain: "configuration",
1442
+ code: "duplicate-id",
1443
+ path: `$.expectedCells[${index}]`,
1444
+ message:
1445
+ "Expected source cell is duplicated after period normalization",
1446
+ });
1447
+ expectedKeys.add(key);
1448
+ expectedCandidates.push({
1449
+ row: normalized,
1450
+ snapshot: {
1451
+ sourceGroup: row.sourceGroup,
1452
+ origin: origin.label,
1453
+ valuation: valuation.label,
1454
+ source: row.source === undefined ? null : { ...row.source },
1455
+ },
1456
+ disposition: "retained",
1457
+ coordinate: {
1458
+ origin,
1459
+ valuation,
1460
+ developmentAge,
1461
+ ageUnit: definition.definition.periodAxis.ageUnit,
1462
+ },
1463
+ });
146
1464
  });
147
1465
  if (issues.length > 0) throw new DiagnosticValidationError(issues);
148
1466
 
149
- const normalizedLosses: DiagnosticLossInput[] = [];
150
- const audit: DiagnosticInputAuditRecord[] = [];
151
- for (const row of input.losses) {
152
- let disposition: DiagnosticInputDisposition = "retained";
1467
+ const filter = normalizedFilter.value;
1468
+ const findings: DiagnosticMetricFinding[] = [];
1469
+ const pendingBlockers: PendingBlocker[] = [];
1470
+ const lossCandidates: LossCandidate[] = input.losses.map((inputRow) => ({
1471
+ row: {
1472
+ ...inputRow,
1473
+ measures: Object.fromEntries(
1474
+ Object.keys(inputRow.measures)
1475
+ .sort(codeUnit)
1476
+ .map((key) => [key, inputRow.measures[key]!]),
1477
+ ),
1478
+ } as DiagnosticLossInput,
1479
+ snapshot: snapshotLoss(inputRow),
1480
+ disposition: selectedBySource(filter, inputRow.sourceGroup)
1481
+ ? "retained"
1482
+ : "filter",
1483
+ coordinate: null,
1484
+ invalid: false,
1485
+ }));
1486
+
1487
+ for (const candidate of lossCandidates) {
1488
+ if (candidate.disposition !== "retained") continue;
1489
+ let origin: DiagnosticNormalizedPeriod | null = null;
1490
+ let valuation: DiagnosticNormalizedPeriod | null = null;
1491
+ try {
1492
+ origin = normalizeDiagnosticPeriod(
1493
+ definition,
1494
+ "origin",
1495
+ candidate.row.origin,
1496
+ );
1497
+ } catch {
1498
+ findings.push(
1499
+ structuralFinding("unknown-origin-period", {
1500
+ recordId: candidate.row.recordId,
1501
+ sourceGroup: candidate.row.sourceGroup,
1502
+ origin: candidate.row.origin,
1503
+ valuation: candidate.row.valuation,
1504
+ sources: candidate.row.source ? [candidate.row.source] : [],
1505
+ }),
1506
+ );
1507
+ }
1508
+ if (origin !== null)
1509
+ candidate.snapshot = { ...candidate.snapshot, origin: origin.label };
153
1510
  try {
154
- const period = diagnosticDevelopmentAge(definition, row.origin, row.valuation);
155
- const normalized = { ...row, origin: period.origin.label, valuation: period.valuation.label } as DiagnosticLossInput;
156
- if (!selected(filter, row.sourceGroup, normalized.origin, normalized.valuation, period.developmentAge)) disposition = "filter";
157
- if (disposition === "retained") normalizedLosses.push(normalized);
158
- audit.push({ kind: "loss", disposition, record: normalized });
159
- } catch { audit.push({ kind: "loss", disposition: "invalid", record: row }); }
160
- }
161
- const derivedLosses = definition.definition.lossRowGrain === "claim"
162
- ? deriveDiagnosticClaimMeasures(normalizedLosses as DiagnosticClaimObservation[], definition) as readonly DiagnosticLossInput[]
163
- : normalizedLosses;
164
- const cells = new Map<string, DiagnosticLossInput[]>();
165
- for (const row of derivedLosses) {
166
- const key = `${row.sourceGroup}\u0000${row.origin}\u0000${row.valuation}`;
167
- const list = cells.get(key) ?? [];
168
- list.push(row);
169
- cells.set(key, list);
170
- }
171
-
172
- const normalizedExposureInputs: DiagnosticExposureObservation[] = [];
173
- for (const row of input.exposures) {
1511
+ valuation = normalizeDiagnosticPeriod(
1512
+ definition,
1513
+ "valuation",
1514
+ candidate.row.valuation,
1515
+ );
1516
+ } catch {
1517
+ findings.push(
1518
+ structuralFinding("unknown-valuation-period", {
1519
+ recordId: candidate.row.recordId,
1520
+ sourceGroup: candidate.row.sourceGroup,
1521
+ origin: origin?.label ?? candidate.row.origin,
1522
+ valuation: candidate.row.valuation,
1523
+ sources: candidate.row.source ? [candidate.row.source] : [],
1524
+ }),
1525
+ );
1526
+ }
1527
+ if (valuation !== null)
1528
+ candidate.snapshot = {
1529
+ ...candidate.snapshot,
1530
+ valuation: valuation.label,
1531
+ };
1532
+ if (origin === null || valuation === null) {
1533
+ candidate.disposition = "invalid";
1534
+ candidate.invalid = true;
1535
+ continue;
1536
+ }
1537
+ const developmentAge =
1538
+ valuation.coordinate -
1539
+ origin.coordinate +
1540
+ definition.definition.periodAxis.ageOffset;
1541
+ if (!Number.isSafeInteger(developmentAge) || developmentAge < 0) {
1542
+ const code: StructuralCode = Number.isSafeInteger(developmentAge)
1543
+ ? "valuation-before-origin"
1544
+ : "unsafe-development-age";
1545
+ findings.push(
1546
+ structuralFinding(code, {
1547
+ recordId: candidate.row.recordId,
1548
+ sourceGroup: candidate.row.sourceGroup,
1549
+ origin: origin.label,
1550
+ valuation: valuation.label,
1551
+ sources: candidate.row.source ? [candidate.row.source] : [],
1552
+ }),
1553
+ );
1554
+ candidate.disposition = "invalid";
1555
+ candidate.invalid = true;
1556
+ continue;
1557
+ }
1558
+ candidate.coordinate = {
1559
+ origin,
1560
+ valuation,
1561
+ developmentAge,
1562
+ ageUnit: definition.definition.periodAxis.ageUnit,
1563
+ };
1564
+ candidate.row = {
1565
+ ...candidate.row,
1566
+ origin: origin.label,
1567
+ valuation: valuation.label,
1568
+ } as DiagnosticLossInput;
1569
+ candidate.snapshot = {
1570
+ ...candidate.snapshot,
1571
+ origin: candidate.row.origin,
1572
+ valuation: candidate.row.valuation,
1573
+ };
1574
+ if (
1575
+ beyondCutoff(
1576
+ candidate as LossCandidate & { coordinate: NormalizedCoordinate },
1577
+ cutoffByGroup,
1578
+ )
1579
+ )
1580
+ candidate.disposition = "complete-period-cutoff";
1581
+ else if (
1582
+ !selectedByOrigin(filter, origin, normalizedFilter.originCoordinates) ||
1583
+ !selectedByValuation(
1584
+ filter,
1585
+ valuation,
1586
+ normalizedFilter.valuationCoordinates,
1587
+ ) ||
1588
+ !selectedByAge(filter, developmentAge)
1589
+ )
1590
+ candidate.disposition = "filter";
1591
+ }
1592
+
1593
+ const exposureCandidates: ExposureCandidate[] = input.exposures.map(
1594
+ (inputRow) => ({
1595
+ row: { ...inputRow },
1596
+ snapshot: snapshotExposure(inputRow),
1597
+ disposition: selectedBySource(filter, inputRow.sourceGroup)
1598
+ ? "retained"
1599
+ : "filter",
1600
+ origin: null,
1601
+ valuation: null,
1602
+ developmentAge: null,
1603
+ ageUnit: null,
1604
+ timing:
1605
+ internals.measuresById.get(inputRow.measureId)?.exposureTiming ?? null,
1606
+ invalid: false,
1607
+ }),
1608
+ );
1609
+ for (const candidate of exposureCandidates) {
1610
+ if (candidate.disposition !== "retained") continue;
174
1611
  try {
175
- const origin = normalizeDiagnosticPeriod(definition, "origin", row.origin).label;
176
- const valuation = row.valuation === undefined ? undefined : normalizeDiagnosticPeriod(definition, "valuation", row.valuation).label;
177
- const normalized = { ...row, origin, ...(valuation === undefined ? {} : { valuation }) };
178
- const timing = internals.measuresById.get(row.measureId)?.exposureTiming;
179
- const age = valuation === undefined ? 0 : diagnosticDevelopmentAge(definition, origin, valuation).developmentAge;
180
- const keep = timing === "origin-static"
181
- ? (filter?.sourceGroups === undefined || filter.sourceGroups.includes(row.sourceGroup)) && (filter?.origins === undefined || filter.origins.includes(origin))
182
- : selected(filter, row.sourceGroup, origin, valuation ?? "", age);
183
- audit.push({ kind: "exposure", disposition: keep ? "retained" : "filter", record: normalized });
184
- if (keep) normalizedExposureInputs.push(normalized);
185
- } catch { audit.push({ kind: "exposure", disposition: "invalid", record: row }); }
186
- }
187
- const timingByMeasure = Object.fromEntries(definition.definition.measures.filter((measure) => measure.kind === "exposure").map((measure) => [measure.id, measure.exposureTiming!])) as Record<string, "origin-static" | "valuation-specific">;
188
- const exposures = reconcileDiagnosticExposures(normalizedExposureInputs, timingByMeasure);
1612
+ candidate.origin = normalizeDiagnosticPeriod(
1613
+ definition,
1614
+ "origin",
1615
+ candidate.row.origin,
1616
+ );
1617
+ } catch {
1618
+ findings.push(
1619
+ structuralFinding("unknown-origin-period", {
1620
+ measureId: candidate.row.measureId,
1621
+ exposureKey: candidate.row.key,
1622
+ sourceGroup: candidate.row.sourceGroup,
1623
+ origin: candidate.row.origin,
1624
+ sources: candidate.row.source ? [candidate.row.source] : [],
1625
+ }),
1626
+ );
1627
+ }
1628
+ if (candidate.origin !== null)
1629
+ candidate.snapshot = {
1630
+ ...candidate.snapshot,
1631
+ origin: candidate.origin.label,
1632
+ };
1633
+ if (candidate.row.valuation !== undefined) {
1634
+ try {
1635
+ candidate.valuation = normalizeDiagnosticPeriod(
1636
+ definition,
1637
+ "valuation",
1638
+ candidate.row.valuation,
1639
+ );
1640
+ } catch {
1641
+ findings.push(
1642
+ structuralFinding("unknown-valuation-period", {
1643
+ measureId: candidate.row.measureId,
1644
+ exposureKey: candidate.row.key,
1645
+ sourceGroup: candidate.row.sourceGroup,
1646
+ origin: candidate.origin?.label ?? candidate.row.origin,
1647
+ valuation: candidate.row.valuation,
1648
+ sources: candidate.row.source ? [candidate.row.source] : [],
1649
+ }),
1650
+ );
1651
+ }
1652
+ if (candidate.valuation !== null)
1653
+ candidate.snapshot = {
1654
+ ...candidate.snapshot,
1655
+ valuation: candidate.valuation.label,
1656
+ };
1657
+ }
1658
+ if (
1659
+ candidate.origin === null ||
1660
+ (candidate.row.valuation !== undefined && candidate.valuation === null)
1661
+ ) {
1662
+ candidate.disposition = "invalid";
1663
+ candidate.invalid = true;
1664
+ continue;
1665
+ }
1666
+ if (candidate.valuation !== null) {
1667
+ const age =
1668
+ candidate.valuation.coordinate -
1669
+ candidate.origin.coordinate +
1670
+ definition.definition.periodAxis.ageOffset;
1671
+ if (!Number.isSafeInteger(age) || age < 0) {
1672
+ const code: StructuralCode = Number.isSafeInteger(age)
1673
+ ? "valuation-before-origin"
1674
+ : "unsafe-development-age";
1675
+ findings.push(
1676
+ structuralFinding(code, {
1677
+ measureId: candidate.row.measureId,
1678
+ exposureKey: candidate.row.key,
1679
+ sourceGroup: candidate.row.sourceGroup,
1680
+ origin: candidate.origin.label,
1681
+ valuation: candidate.valuation.label,
1682
+ sources: candidate.row.source ? [candidate.row.source] : [],
1683
+ }),
1684
+ );
1685
+ candidate.disposition = "invalid";
1686
+ candidate.invalid = true;
1687
+ continue;
1688
+ }
1689
+ candidate.developmentAge = age;
1690
+ candidate.ageUnit = definition.definition.periodAxis.ageUnit;
1691
+ }
1692
+ candidate.row = {
1693
+ ...candidate.row,
1694
+ origin: candidate.origin.label,
1695
+ ...(candidate.valuation ? { valuation: candidate.valuation.label } : {}),
1696
+ };
1697
+ candidate.snapshot = {
1698
+ ...candidate.snapshot,
1699
+ origin: candidate.origin.label,
1700
+ valuation: candidate.valuation?.label ?? null,
1701
+ };
1702
+ const cutoff = cutoffByGroup.get(candidate.row.sourceGroup);
1703
+ const beyondOrigin =
1704
+ cutoff?.originThrough !== null &&
1705
+ cutoff?.originThrough !== undefined &&
1706
+ compareDiagnosticPeriods(candidate.origin, cutoff.originThrough) > 0;
1707
+ const beyondValuation =
1708
+ candidate.timing === "valuation-specific" &&
1709
+ cutoff?.valuationThrough !== null &&
1710
+ cutoff?.valuationThrough !== undefined &&
1711
+ candidate.valuation !== null &&
1712
+ compareDiagnosticPeriods(candidate.valuation, cutoff.valuationThrough) >
1713
+ 0;
1714
+ if (beyondOrigin || beyondValuation)
1715
+ candidate.disposition = "complete-period-cutoff";
1716
+ else {
1717
+ const originSelected = selectedByOrigin(
1718
+ filter,
1719
+ candidate.origin,
1720
+ normalizedFilter.originCoordinates,
1721
+ );
1722
+ const valuationSelected =
1723
+ candidate.timing === "valuation-specific" &&
1724
+ candidate.valuation !== null
1725
+ ? selectedByValuation(
1726
+ filter,
1727
+ candidate.valuation,
1728
+ normalizedFilter.valuationCoordinates,
1729
+ ) && selectedByAge(filter, candidate.developmentAge!)
1730
+ : true;
1731
+ if (!originSelected || !valuationSelected)
1732
+ candidate.disposition = "filter";
1733
+ }
1734
+ }
1735
+
1736
+ for (const candidate of expectedCandidates) {
1737
+ if (!selectedBySource(filter, candidate.row.sourceGroup))
1738
+ candidate.disposition = "filter";
1739
+ else if (beyondCutoff(candidate, cutoffByGroup))
1740
+ candidate.disposition = "complete-period-cutoff";
1741
+ else if (
1742
+ !selectedByOrigin(
1743
+ filter,
1744
+ candidate.coordinate.origin,
1745
+ normalizedFilter.originCoordinates,
1746
+ ) ||
1747
+ !selectedByValuation(
1748
+ filter,
1749
+ candidate.coordinate.valuation,
1750
+ normalizedFilter.valuationCoordinates,
1751
+ ) ||
1752
+ !selectedByAge(filter, candidate.coordinate.developmentAge)
1753
+ )
1754
+ candidate.disposition = "filter";
1755
+ }
1756
+
1757
+ const provisionalLosses = lossCandidates.filter(
1758
+ (candidate) => candidate.disposition === "retained",
1759
+ );
1760
+ const lossFindings = (
1761
+ code: StructuralCode,
1762
+ candidates: readonly LossCandidate[],
1763
+ extra: Partial<DiagnosticMetricFinding> = {},
1764
+ ) => {
1765
+ const finding = structuralFinding(code, {
1766
+ ...commonCoordinate(candidates),
1767
+ ...extra,
1768
+ sources: normalizeSources(
1769
+ candidates.map((candidate) => candidate.row.source),
1770
+ ),
1771
+ });
1772
+ findings.push(finding);
1773
+ for (const candidate of candidates) candidate.invalid = true;
1774
+ pendingBlockers.push({
1775
+ finding,
1776
+ measureIds: definition.definition.measures
1777
+ .filter((measure) => measure.source !== "exposure")
1778
+ .map((measure) => measure.id),
1779
+ cellKeys: uniqueSorted(
1780
+ candidates.flatMap((candidate) =>
1781
+ candidate.coordinate
1782
+ ? [
1783
+ cellKey(
1784
+ candidate.row.sourceGroup,
1785
+ candidate.coordinate.origin.label,
1786
+ candidate.coordinate.valuation.label,
1787
+ ),
1788
+ ]
1789
+ : [],
1790
+ ),
1791
+ ),
1792
+ sourceIds: uniqueSorted(
1793
+ candidates.map((candidate) => candidate.row.recordId),
1794
+ ),
1795
+ });
1796
+ };
1797
+
1798
+ for (const candidate of provisionalLosses) {
1799
+ const localCodes: {
1800
+ code: StructuralCode;
1801
+ measureId?: string;
1802
+ offendingKey?: string;
1803
+ }[] = [];
1804
+ for (const measureId of Object.keys(candidate.row.measures).sort(
1805
+ codeUnit,
1806
+ )) {
1807
+ const measure = internals.measuresById.get(measureId);
1808
+ if (!measure)
1809
+ localCodes.push({
1810
+ code: "undeclared-loss-measure",
1811
+ measureId,
1812
+ offendingKey: measureId,
1813
+ });
1814
+ else if (measure.source !== "loss")
1815
+ localCodes.push({
1816
+ code: "wrong-source-loss-measure",
1817
+ measureId,
1818
+ offendingKey: measureId,
1819
+ });
1820
+ }
1821
+ if (!candidate.row.complete)
1822
+ localCodes.push({ code: "incomplete-loss-record" });
1823
+ for (const item of localCodes)
1824
+ lossFindings(item.code, [candidate], {
1825
+ recordId: candidate.row.recordId,
1826
+ ...(candidate.row.rowType === "claim"
1827
+ ? { claimId: candidate.row.claimId }
1828
+ : {}),
1829
+ ...(item.measureId ? { measureId: item.measureId } : {}),
1830
+ ...(item.offendingKey ? { offendingKey: item.offendingKey } : {}),
1831
+ });
1832
+ }
1833
+
1834
+ const byRecordId = new Map<string, LossCandidate[]>();
1835
+ for (const candidate of provisionalLosses)
1836
+ addToMap(byRecordId, candidate.row.recordId, candidate);
1837
+ for (const [recordId, cohort] of byRecordId)
1838
+ if (cohort.length > 1)
1839
+ lossFindings("duplicate-loss-record-id", cohort, { recordId });
1840
+ if (definition.definition.lossRowGrain === "aggregate") {
1841
+ const byCell = new Map<string, LossCandidate[]>();
1842
+ for (const candidate of provisionalLosses)
1843
+ addToMap(
1844
+ byCell,
1845
+ cellKey(
1846
+ candidate.row.sourceGroup,
1847
+ candidate.row.origin,
1848
+ candidate.row.valuation,
1849
+ ),
1850
+ candidate,
1851
+ );
1852
+ for (const cohort of byCell.values())
1853
+ if (cohort.length > 1)
1854
+ lossFindings("duplicate-aggregate-snapshot", cohort);
1855
+ } else {
1856
+ const bySnapshot = new Map<string, LossCandidate[]>();
1857
+ const byClaim = new Map<string, LossCandidate[]>();
1858
+ for (const candidate of provisionalLosses) {
1859
+ const claimId = (candidate.row as DiagnosticClaimObservation).claimId;
1860
+ addToMap(
1861
+ bySnapshot,
1862
+ canonicalJson([
1863
+ claimId,
1864
+ candidate.row.sourceGroup,
1865
+ candidate.row.origin,
1866
+ candidate.row.valuation,
1867
+ ]),
1868
+ candidate,
1869
+ );
1870
+ addToMap(byClaim, claimId, candidate);
1871
+ }
1872
+ for (const cohort of bySnapshot.values())
1873
+ if (cohort.length > 1)
1874
+ lossFindings("duplicate-claim-snapshot", cohort, {
1875
+ claimId: (cohort[0]!.row as DiagnosticClaimObservation).claimId,
1876
+ });
1877
+ for (const [claimId, cohort] of byClaim)
1878
+ if (
1879
+ uniqueSorted(
1880
+ cohort.map((candidate) =>
1881
+ canonicalJson([candidate.row.sourceGroup, candidate.row.origin]),
1882
+ ),
1883
+ ).length > 1
1884
+ )
1885
+ lossFindings("claim-identity-conflict", cohort, { claimId });
1886
+ }
1887
+ for (const candidate of provisionalLosses)
1888
+ if (candidate.invalid) candidate.disposition = "invalid";
1889
+
1890
+ const declaredExposureCandidates: ExposureCandidate[] = [];
1891
+ for (const candidate of exposureCandidates.filter(
1892
+ (item) => item.disposition === "retained",
1893
+ )) {
1894
+ const measure = internals.measuresById.get(candidate.row.measureId);
1895
+ if (!measure) {
1896
+ findings.push(
1897
+ structuralFinding("undeclared-exposure-measure", {
1898
+ measureId: candidate.row.measureId,
1899
+ offendingKey: candidate.row.measureId,
1900
+ exposureKey: candidate.row.key,
1901
+ ...(candidate.origin ? { origin: candidate.origin.label } : {}),
1902
+ sources: candidate.row.source ? [candidate.row.source] : [],
1903
+ }),
1904
+ );
1905
+ candidate.invalid = true;
1906
+ candidate.disposition = "invalid";
1907
+ } else if (measure.source !== "exposure") {
1908
+ findings.push(
1909
+ structuralFinding("wrong-source-exposure-measure", {
1910
+ measureId: candidate.row.measureId,
1911
+ offendingKey: candidate.row.measureId,
1912
+ exposureKey: candidate.row.key,
1913
+ ...(candidate.origin ? { origin: candidate.origin.label } : {}),
1914
+ sources: candidate.row.source ? [candidate.row.source] : [],
1915
+ }),
1916
+ );
1917
+ candidate.invalid = true;
1918
+ candidate.disposition = "invalid";
1919
+ } else declaredExposureCandidates.push(candidate);
1920
+ }
1921
+
1922
+ const reconciled = reconcileDiagnosticExposures(
1923
+ declaredExposureCandidates.map((candidate) => candidate.row),
1924
+ Object.fromEntries(
1925
+ definition.definition.measures
1926
+ .filter((measure) => measure.source === "exposure")
1927
+ .map((measure) => [measure.id, measure.exposureTiming!]),
1928
+ ),
1929
+ definition.definition.periodAxis,
1930
+ ) as ReconciledDiagnosticExposure[];
1931
+ const exposureCohorts = new Map<string, ExposureCandidate[]>();
1932
+ for (const candidate of declaredExposureCandidates)
1933
+ addToMap(exposureCohorts, exposureIdentity(candidate), candidate);
1934
+ const invalidExposureBlockers: {
1935
+ finding: DiagnosticMetricFinding;
1936
+ candidates: readonly ExposureCandidate[];
1937
+ }[] = [];
1938
+ for (const exposure of reconciled) {
1939
+ const timing = internals.measuresById.get(
1940
+ exposure.measureId,
1941
+ )?.exposureTiming;
1942
+ const identity = canonicalJson(
1943
+ timing === "valuation-specific"
1944
+ ? [
1945
+ exposure.measureId,
1946
+ exposure.key,
1947
+ exposure.status === "valid"
1948
+ ? (exposure.valuation ?? null)
1949
+ : (exposure.observations[0]?.valuation ?? null),
1950
+ ]
1951
+ : [exposure.measureId, exposure.key],
1952
+ );
1953
+ const cohort = exposureCohorts.get(identity) ?? [];
1954
+ if (exposure.status === "valid") continue;
1955
+ for (const candidate of cohort) {
1956
+ candidate.invalid = true;
1957
+ candidate.disposition = "invalid";
1958
+ }
1959
+ const context = exposureCoordinateContext(cohort);
1960
+ const issueCodes: Record<(typeof exposure.issues)[number], StructuralCode> =
1961
+ {
1962
+ missing: "missing-exposure-value",
1963
+ incomplete: "incomplete-exposure",
1964
+ "non-finite": "non-finite-exposure",
1965
+ duplicate: "duplicate-exposure-identity",
1966
+ conflict: "conflicting-exposure-identity",
1967
+ };
1968
+ for (const issue of exposure.issues) {
1969
+ const finding = structuralFinding(issueCodes[issue], {
1970
+ ...context,
1971
+ measureId: exposure.measureId,
1972
+ exposureKey: exposure.key,
1973
+ sources: normalizeSources(
1974
+ cohort.map((candidate) => candidate.row.source),
1975
+ ),
1976
+ });
1977
+ findings.push(finding);
1978
+ invalidExposureBlockers.push({ finding, candidates: cohort });
1979
+ }
1980
+ }
1981
+
1982
+ const retainedCandidates = lossCandidates.filter(
1983
+ (candidate) => candidate.disposition === "retained",
1984
+ );
1985
+ const retainedRows = retainedCandidates.map((candidate) => candidate.row);
1986
+ const derivedAudits =
1987
+ definition.definition.lossRowGrain === "claim"
1988
+ ? deriveDiagnosticClaimMeasuresWithAudit(
1989
+ retainedRows as readonly DiagnosticClaimObservation[],
1990
+ definition,
1991
+ )
1992
+ : [];
1993
+ const derivedRows =
1994
+ definition.definition.lossRowGrain === "claim"
1995
+ ? derivedAudits.map((item) => item.row as DiagnosticLossInput)
1996
+ : retainedRows;
1997
+ const derivedByRecord = new Map(
1998
+ derivedRows.map((row) => [row.recordId, row]),
1999
+ );
2000
+ const derivedAuditByRecord = new Map(
2001
+ derivedAudits.map((item) => [
2002
+ (item.row as DiagnosticClaimObservation).recordId,
2003
+ item,
2004
+ ]),
2005
+ );
2006
+ const cellRows = new Map<string, DiagnosticLossInput[]>();
2007
+ for (const candidate of retainedCandidates)
2008
+ addToMap(
2009
+ cellRows,
2010
+ cellKey(
2011
+ candidate.row.sourceGroup,
2012
+ candidate.row.origin,
2013
+ candidate.row.valuation,
2014
+ ),
2015
+ derivedByRecord.get(candidate.row.recordId)!,
2016
+ );
2017
+
2018
+ const allMeasureIds = definition.definition.measures
2019
+ .map((measure) => measure.id)
2020
+ .sort(codeUnit);
2021
+ const nonExposureMeasures = definition.definition.measures
2022
+ .filter((measure) => measure.source !== "exposure")
2023
+ .sort((left, right) => codeUnit(left.id, right.id));
2024
+ const exposureMeasures = definition.definition.measures
2025
+ .filter((measure) => measure.source === "exposure")
2026
+ .sort((left, right) => codeUnit(left.id, right.id));
189
2027
  const preparedCells: PreparedDiagnosticSourceCell[] = [];
190
- for (const [key, rows] of cells) {
191
- const [sourceGroup, origin, valuation] = key.split("\u0000") as [string, string, string];
192
- const period = diagnosticDevelopmentAge(definition, origin, valuation);
193
- const contributions: Record<string, DiagnosticMeasureContribution[]> = Object.create(null);
194
- const blockers: Record<string, DiagnosticStructuralBlocker[]> = Object.create(null);
195
- for (const measure of definition.definition.measures) { contributions[measure.id] = []; blockers[measure.id] = []; }
196
- for (const row of rows) {
197
- for (const measure of definition.definition.measures.filter((item) => item.source !== "exposure")) {
198
- contributions[measure.id]!.push(auditedDiagnosticContribution(row.recordId, own(row.measures, measure.id), measure.missing, row.source ? [row.source] : []));
2028
+ const invalidExposureTargets = new Map<string, Set<string>>();
2029
+ for (const item of invalidExposureBlockers) {
2030
+ const targets = new Set<string>();
2031
+ for (const candidate of item.candidates) {
2032
+ if (!candidate.origin) continue;
2033
+ if (candidate.timing === "valuation-specific" && candidate.valuation)
2034
+ targets.add(
2035
+ cellKey(
2036
+ candidate.row.sourceGroup,
2037
+ candidate.origin.label,
2038
+ candidate.valuation.label,
2039
+ ),
2040
+ );
2041
+ else
2042
+ for (const key of cellRows.keys()) {
2043
+ const [sourceGroup, origin] = parseCellKey(key);
2044
+ if (
2045
+ sourceGroup === candidate.row.sourceGroup &&
2046
+ origin === candidate.origin.label
2047
+ )
2048
+ targets.add(key);
2049
+ }
2050
+ }
2051
+ invalidExposureTargets.set(canonicalJson(item.finding), targets);
2052
+ pendingBlockers.push({
2053
+ finding: item.finding,
2054
+ measureIds: [item.finding.measureId!],
2055
+ cellKeys: [...targets],
2056
+ sourceIds: item.finding.exposureKey ? [item.finding.exposureKey] : [],
2057
+ });
2058
+ }
2059
+
2060
+ const validAttachmentKeys = new Map<ReconciledDiagnosticExposure, string[]>();
2061
+ for (const exposure of reconciled) {
2062
+ if (exposure.status !== "valid") continue;
2063
+ const timing = internals.measuresById.get(
2064
+ exposure.measureId,
2065
+ )!.exposureTiming;
2066
+ const keys = [...cellRows.keys()].filter((key) => {
2067
+ const [sourceGroup, origin, valuation] = parseCellKey(key);
2068
+ return (
2069
+ sourceGroup === exposure.sourceGroup &&
2070
+ origin === exposure.origin &&
2071
+ (timing === "origin-static" || valuation === exposure.valuation)
2072
+ );
2073
+ });
2074
+ validAttachmentKeys.set(exposure, keys);
2075
+ if (keys.length === 0)
2076
+ findings.push(
2077
+ structuralFinding("exposure-without-loss", {
2078
+ measureId: exposure.measureId,
2079
+ exposureKey: exposure.key,
2080
+ sourceGroup: exposure.sourceGroup,
2081
+ origin: exposure.origin,
2082
+ ...(exposure.valuation ? { valuation: exposure.valuation } : {}),
2083
+ sources: exposure.sources,
2084
+ }),
2085
+ );
2086
+ }
2087
+
2088
+ for (const [key, rows] of cellRows) {
2089
+ const [sourceGroup, origin, valuation] = parseCellKey(key);
2090
+ const coordinate = coordinateFor(definition, origin, valuation).coordinate!;
2091
+ const contributions = Object.fromEntries(
2092
+ allMeasureIds.map((measureId) => [
2093
+ measureId,
2094
+ [] as DiagnosticMeasureContribution[],
2095
+ ]),
2096
+ ) as Record<string, DiagnosticMeasureContribution[]>;
2097
+ const blockers = Object.fromEntries(
2098
+ allMeasureIds.map((measureId) => [
2099
+ measureId,
2100
+ [] as DiagnosticStructuralBlocker[],
2101
+ ]),
2102
+ ) as Record<string, DiagnosticStructuralBlocker[]>;
2103
+ const cellFindings: DiagnosticMetricFinding[] = [];
2104
+ const derivedContribution = (
2105
+ row: DiagnosticLossInput,
2106
+ missingPolicy: "unknown" | "zero",
2107
+ state: DiagnosticDerivedValueAudit,
2108
+ ): DiagnosticMeasureContribution => {
2109
+ const base = {
2110
+ sourceId: row.recordId,
2111
+ sources: row.source ? [row.source] : [],
2112
+ deduplicated: 0,
2113
+ } as const;
2114
+ if (state.status === "observed")
2115
+ return { ...base, status: "observed", value: state.value };
2116
+ if (state.status === "non-finite")
2117
+ return {
2118
+ ...base,
2119
+ status: "non-finite",
2120
+ value: null,
2121
+ nonFiniteKind: state.nonFiniteKind,
2122
+ };
2123
+ return missingPolicy === "zero"
2124
+ ? { ...base, status: "imputed-zero", value: 0 }
2125
+ : { ...base, status: "missing", value: null };
2126
+ };
2127
+ for (const row of rows)
2128
+ for (const measure of nonExposureMeasures) {
2129
+ if (measure.source === "derived") {
2130
+ const state = derivedAuditByRecord.get(row.recordId)!.derived[
2131
+ measure.id
2132
+ ]!;
2133
+ contributions[measure.id]!.push(
2134
+ derivedContribution(row, measure.missing, state),
2135
+ );
2136
+ for (const overflow of derivedAuditByRecord.get(row.recordId)!
2137
+ .expressionOverflows[measure.id] ?? []) {
2138
+ cellFindings.push({
2139
+ code: "diagnostic-expression-overflow",
2140
+ message: "Measure expression overflowed",
2141
+ severity: "fail",
2142
+ category: "aggregation",
2143
+ measureId: measure.id,
2144
+ expressionPath: overflow.expressionPath,
2145
+ recordId: row.recordId,
2146
+ sourceGroup,
2147
+ origin,
2148
+ valuation,
2149
+ developmentAge: coordinate.developmentAge,
2150
+ ageUnit: coordinate.ageUnit,
2151
+ sources: row.source ? [row.source] : [],
2152
+ });
2153
+ }
2154
+ } else
2155
+ contributions[measure.id]!.push(
2156
+ auditedDiagnosticContribution(
2157
+ row.recordId,
2158
+ own(row.measures, measure.id),
2159
+ measure.missing,
2160
+ row.source ? [row.source] : [],
2161
+ ),
2162
+ );
199
2163
  }
2164
+ for (const exposure of reconciled)
2165
+ if (
2166
+ exposure.status === "valid" &&
2167
+ validAttachmentKeys.get(exposure)?.includes(key)
2168
+ )
2169
+ contributions[exposure.measureId]!.push(
2170
+ auditedDiagnosticContribution(
2171
+ exposure.key,
2172
+ exposure.value,
2173
+ "unknown",
2174
+ exposure.sources,
2175
+ exposure.deduplicated,
2176
+ ),
2177
+ );
2178
+ for (const pending of pendingBlockers) {
2179
+ if (!pending.cellKeys.includes(key)) continue;
2180
+ cellFindings.push(pending.finding);
2181
+ const blocker = {
2182
+ code: pending.finding.code,
2183
+ message: pending.finding.message,
2184
+ sourceIds: uniqueSorted(pending.sourceIds),
2185
+ sources: normalizeSources(pending.finding.sources),
2186
+ finding: pending.finding,
2187
+ };
2188
+ for (const measureId of pending.measureIds)
2189
+ blockers[measureId]!.push(blocker);
200
2190
  }
201
- for (const exposure of exposures) {
202
- if (exposure.status !== "valid" || exposure.sourceGroup !== sourceGroup || exposure.origin !== origin || (exposure.valuation !== undefined && exposure.valuation !== valuation)) continue;
203
- contributions[exposure.measureId]!.push(auditedDiagnosticContribution(exposure.key, exposure.value, "unknown", exposure.sources, exposure.deduplicated));
2191
+ for (const measure of exposureMeasures) {
2192
+ const attempted =
2193
+ [...validAttachmentKeys.entries()].some(
2194
+ ([exposure, keys]) =>
2195
+ exposure.status === "valid" &&
2196
+ exposure.measureId === measure.id &&
2197
+ keys.includes(key),
2198
+ ) ||
2199
+ invalidExposureBlockers.some(
2200
+ (item) =>
2201
+ item.finding.measureId === measure.id &&
2202
+ invalidExposureTargets.get(canonicalJson(item.finding))?.has(key),
2203
+ );
2204
+ if (contributions[measure.id]!.length === 0 && !attempted) {
2205
+ const sources = normalizeSources(rows.map((row) => row.source));
2206
+ const finding = structuralFinding("loss-without-exposure", {
2207
+ measureId: measure.id,
2208
+ sourceGroup,
2209
+ origin,
2210
+ valuation,
2211
+ developmentAge: coordinate.developmentAge,
2212
+ ageUnit: coordinate.ageUnit,
2213
+ sources,
2214
+ });
2215
+ findings.push(finding);
2216
+ cellFindings.push(finding);
2217
+ blockers[measure.id]!.push({
2218
+ code: finding.code,
2219
+ message: finding.message,
2220
+ sourceIds: uniqueSorted(rows.map((row) => row.recordId)),
2221
+ sources,
2222
+ finding,
2223
+ });
2224
+ }
204
2225
  }
205
- for (const measure of definition.definition.measures.filter((item) => item.source === "exposure")) {
206
- if (contributions[measure.id]!.length === 0) blockers[measure.id]!.push({ code: "loss-without-exposure", message: "Loss cell has no applicable exposure observation", sourceIds: rows.map((row) => row.recordId).sort(codeUnit), sources: [] });
2226
+ for (const measureId of allMeasureIds) {
2227
+ blockers[measureId] = [
2228
+ ...new Map(
2229
+ blockers[measureId]!.map((blocker) => [
2230
+ canonicalJson(blocker),
2231
+ blocker,
2232
+ ]),
2233
+ ).values(),
2234
+ ].sort(compareDiagnosticBlockers);
2235
+ contributions[measureId]!.sort(compareDiagnosticContributions);
207
2236
  }
208
- const components = Object.fromEntries(definition.definition.measures.map((measure) => [measure.id, finalizeDiagnosticContributions(contributions[measure.id]!, measure.missing, blockers[measure.id])])) as Record<string, DiagnosticMeasureStats>;
209
- preparedCells.push(deepFreeze({ sourceGroup, origin, valuation, developmentAge: period.developmentAge, ageUnit: period.ageUnit, lossRecordIds: rows.map((row) => row.recordId).sort(codeUnit), contributions, components, structuralBlockers: blockers, findings: [] }));
2237
+ const components = Object.fromEntries(
2238
+ allMeasureIds.map((measureId) => {
2239
+ const measure = internals.measuresById.get(measureId)!;
2240
+ return [
2241
+ measureId,
2242
+ finalizeDiagnosticContributions(
2243
+ contributions[measureId]!,
2244
+ measure.missing,
2245
+ blockers[measureId],
2246
+ ),
2247
+ ];
2248
+ }),
2249
+ ) as Record<string, DiagnosticMeasureStats>;
2250
+ for (const measureId of allMeasureIds) {
2251
+ const measureContributions = contributions[measureId]!;
2252
+ const context = {
2253
+ measureId,
2254
+ sourceGroup,
2255
+ origin,
2256
+ valuation,
2257
+ developmentAge: coordinate.developmentAge,
2258
+ ageUnit: coordinate.ageUnit,
2259
+ };
2260
+ const missing = measureContributions.filter(
2261
+ (item) => item.status === "missing",
2262
+ );
2263
+ const imputed = measureContributions.filter(
2264
+ (item) => item.status === "imputed-zero",
2265
+ );
2266
+ const nonFinite = measureContributions.filter(
2267
+ (item) => item.status === "non-finite",
2268
+ );
2269
+ if (missing.length > 0)
2270
+ cellFindings.push(
2271
+ aggregationFinding("diagnostic-measure-missing", {
2272
+ ...context,
2273
+ sources: missing.flatMap((item) => item.sources),
2274
+ }),
2275
+ );
2276
+ if (imputed.length > 0)
2277
+ cellFindings.push(
2278
+ aggregationFinding("diagnostic-measure-imputed-zero", {
2279
+ ...context,
2280
+ sources: imputed.flatMap((item) => item.sources),
2281
+ }),
2282
+ );
2283
+ if (nonFinite.length > 0)
2284
+ cellFindings.push(
2285
+ aggregationFinding("diagnostic-measure-non-finite", {
2286
+ ...context,
2287
+ sources: nonFinite.flatMap((item) => item.sources),
2288
+ }),
2289
+ );
2290
+ if (
2291
+ components[measureId]!.sum === null &&
2292
+ components[measureId]!.nonFinite === 0 &&
2293
+ components[measureId]!.structural === 0
2294
+ )
2295
+ cellFindings.push(
2296
+ aggregationFinding("diagnostic-measure-overflow", {
2297
+ ...context,
2298
+ sources: measureContributions.flatMap((item) => item.sources),
2299
+ }),
2300
+ );
2301
+ }
2302
+ findings.push(...cellFindings);
2303
+ preparedCells.push({
2304
+ sourceGroup,
2305
+ origin,
2306
+ valuation,
2307
+ developmentAge: coordinate.developmentAge,
2308
+ ageUnit: coordinate.ageUnit,
2309
+ lossRecordIds: uniqueSorted(rows.map((row) => row.recordId)),
2310
+ contributions,
2311
+ components,
2312
+ structuralBlockers: blockers,
2313
+ findings: mergeFindings(cellFindings),
2314
+ });
2315
+ }
2316
+
2317
+ for (const candidate of expectedCandidates.filter(
2318
+ (item) => item.disposition === "retained",
2319
+ )) {
2320
+ const key = cellKey(
2321
+ candidate.row.sourceGroup,
2322
+ candidate.row.origin,
2323
+ candidate.row.valuation,
2324
+ );
2325
+ if (!cellRows.has(key))
2326
+ findings.push(
2327
+ structuralFinding("missing-expected-cell", {
2328
+ sourceGroup: candidate.row.sourceGroup,
2329
+ origin: candidate.row.origin,
2330
+ valuation: candidate.row.valuation,
2331
+ developmentAge: candidate.coordinate.developmentAge,
2332
+ ageUnit: candidate.coordinate.ageUnit,
2333
+ sources: candidate.row.source ? [candidate.row.source] : [],
2334
+ }),
2335
+ );
210
2336
  }
211
- preparedCells.sort((a, b) => codeUnit(a.sourceGroup, b.sourceGroup) || codeUnit(a.origin, b.origin) || codeUnit(a.valuation, b.valuation));
212
- const expectedCells = (input.expectedCells ?? []).map((item) => ({ ...item }));
213
- for (const item of expectedCells) audit.push({ kind: "expected-cell", disposition: "retained", record: item });
214
- const identity = deepFreeze({ definitionIntegrity: definition.definitionIntegrity, filter, completePeriodCutoffs: [...(input.completePeriodCutoffs ?? [])], inputAudit: audit, cells: preparedCells, exposures, expectedCellsProvided: input.expectedCells !== undefined, expectedCells, findings: [] });
2337
+
2338
+ const originCoordinates = new Map<string, number>();
2339
+ const valuationCoordinates = new Map<string, number>();
2340
+ for (const cell of preparedCells) {
2341
+ originCoordinates.set(
2342
+ cell.origin,
2343
+ normalizeDiagnosticPeriod(definition, "origin", cell.origin).coordinate,
2344
+ );
2345
+ valuationCoordinates.set(
2346
+ cell.valuation,
2347
+ normalizeDiagnosticPeriod(definition, "valuation", cell.valuation)
2348
+ .coordinate,
2349
+ );
2350
+ }
2351
+ preparedCells.sort(
2352
+ (left, right) =>
2353
+ codeUnit(left.sourceGroup, right.sourceGroup) ||
2354
+ originCoordinates.get(left.origin)! -
2355
+ originCoordinates.get(right.origin)! ||
2356
+ valuationCoordinates.get(left.valuation)! -
2357
+ valuationCoordinates.get(right.valuation)! ||
2358
+ codeUnit(left.origin, right.origin) ||
2359
+ codeUnit(left.valuation, right.valuation),
2360
+ );
2361
+ const expectedCells = expectedCandidates
2362
+ .filter((candidate) => candidate.disposition === "retained")
2363
+ .sort(
2364
+ (left, right) =>
2365
+ codeUnit(left.row.sourceGroup, right.row.sourceGroup) ||
2366
+ compareDiagnosticPeriods(
2367
+ left.coordinate.origin,
2368
+ right.coordinate.origin,
2369
+ ) ||
2370
+ compareDiagnosticPeriods(
2371
+ left.coordinate.valuation,
2372
+ right.coordinate.valuation,
2373
+ ),
2374
+ )
2375
+ .map((candidate) => candidate.row);
2376
+ const audit: DiagnosticInputAuditRecord[] = [
2377
+ ...lossCandidates.map(
2378
+ (candidate): DiagnosticInputAuditRecord => ({
2379
+ kind: "loss",
2380
+ disposition: candidate.disposition,
2381
+ record: candidate.snapshot,
2382
+ }),
2383
+ ),
2384
+ ...exposureCandidates.map(
2385
+ (candidate): DiagnosticInputAuditRecord => ({
2386
+ kind: "exposure",
2387
+ disposition: candidate.disposition,
2388
+ record: candidate.snapshot,
2389
+ }),
2390
+ ),
2391
+ ...expectedCandidates.map(
2392
+ (candidate): DiagnosticInputAuditRecord => ({
2393
+ kind: "expected-cell",
2394
+ disposition: candidate.disposition,
2395
+ record: candidate.snapshot,
2396
+ }),
2397
+ ),
2398
+ ].sort(auditSort);
2399
+ const normalizedFindings = mergeFindings(findings);
2400
+ const frozenCells = preparedCells.map((cell) => deepFreeze(cell));
2401
+ const cutoffValues = normalizedCutoffs.map((cutoff) => cutoff.value);
2402
+ const ownedData = deepFreeze({
2403
+ definitionIntegrity: definition.definitionIntegrity,
2404
+ filter,
2405
+ completePeriodCutoffs: cutoffValues,
2406
+ inputAudit: audit,
2407
+ cells: frozenCells,
2408
+ exposures: reconciled,
2409
+ expectedCellsProvided: input.expectedCells !== undefined,
2410
+ expectedCells,
2411
+ findings: normalizedFindings,
2412
+ });
2413
+ const identity: NormalizedDiagnosticPreparationIdentity =
2414
+ projectDiagnosticIdentity({
2415
+ ...ownedData,
2416
+ filter: normalizeDiagnosticsFilterIdentity(filter),
2417
+ expectedCells: expectedCells.map((cell) => ({
2418
+ ...cell,
2419
+ source: cell.source ?? null,
2420
+ })),
2421
+ });
215
2422
  const preparationFingerprint = `fnv1a64-jcs-v1:${fnv1a64(canonicalJson({ identityVersion: 1, kind: "diagnostic-preparation", preparation: identity }))}`;
216
- const preparedBase = { definition, preparationFingerprint, filter, completePeriodCutoffs: input.completePeriodCutoffs ?? [], inputAudit: audit, cells: preparedCells, exposures, expectedCellsProvided: input.expectedCells !== undefined, expectedCells, findings: [] };
217
- const prepared = deepFreeze(preparedBase) as unknown as PreparedDiagnosticData;
2423
+ const prepared = deepFreeze({
2424
+ definition,
2425
+ preparationFingerprint,
2426
+ ...ownedData,
2427
+ }) as unknown as PreparedDiagnosticData;
218
2428
  authentic.add(prepared);
219
2429
  identities.set(prepared, identity);
220
2430
  return prepared;
221
2431
  }
222
2432
 
223
- export function assertPreparedDiagnosticData(value: unknown): asserts value is PreparedDiagnosticData {
224
- if (value === null || typeof value !== "object" || !authentic.has(value)) throw new DiagnosticValidationError([{ domain: "input", code: "invalid-input-relationship", path: "$.prepared", message: "Value is not authentic prepared diagnostic data" }]);
2433
+ export function assertPreparedDiagnosticData(
2434
+ value: unknown,
2435
+ ): asserts value is PreparedDiagnosticData {
2436
+ if (value === null || typeof value !== "object" || !authentic.has(value))
2437
+ throw new DiagnosticValidationError([
2438
+ {
2439
+ domain: "input",
2440
+ code: "invalid-input-relationship",
2441
+ path: "$.prepared",
2442
+ message: "Prepared diagnostic data is not authentic",
2443
+ },
2444
+ ]);
225
2445
  }
226
- export function getPreparedDiagnosticDataIdentity(value: PreparedDiagnosticData): DiagnosticDeepReadonly<NormalizedDiagnosticPreparationIdentity> { assertPreparedDiagnosticData(value); return identities.get(value)! }
227
- export function verifyPreparedDiagnosticDataIntegrity(value: PreparedDiagnosticData): void {
2446
+
2447
+ export function getPreparedDiagnosticDataIdentity(
2448
+ value: PreparedDiagnosticData,
2449
+ ): DiagnosticDeepReadonly<NormalizedDiagnosticPreparationIdentity> {
2450
+ assertPreparedDiagnosticData(value);
2451
+ return identities.get(value)!;
2452
+ }
2453
+
2454
+ export function verifyPreparedDiagnosticDataIntegrity(
2455
+ value: PreparedDiagnosticData,
2456
+ ): void {
228
2457
  const identity = getPreparedDiagnosticDataIdentity(value);
229
2458
  const tag = `fnv1a64-jcs-v1:${fnv1a64(canonicalJson({ identityVersion: 1, kind: "diagnostic-preparation", preparation: identity }))}`;
230
- if (tag !== value.preparationFingerprint) throw new DiagnosticValidationError([{ domain: "input", code: "invalid-input-relationship", path: "$.prepared.preparationFingerprint", message: "Prepared diagnostic data integrity does not match its content" }]);
2459
+ if (tag !== value.preparationFingerprint)
2460
+ throw new DiagnosticValidationError([
2461
+ {
2462
+ domain: "input",
2463
+ code: "invalid-input-relationship",
2464
+ path: "$.prepared.preparationFingerprint",
2465
+ message:
2466
+ "Prepared diagnostic data integrity does not match its content",
2467
+ },
2468
+ ]);
231
2469
  }
232
2470
 
233
- export type { JsonValue, DiagnosticAuditedNumericValue };
2471
+ export type {
2472
+ JsonValue,
2473
+ DiagnosticAuditedNumericValue,
2474
+ DiagnosticExposureAuditObservation,
2475
+ };