@actuarial-ts/data 0.5.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 (50) hide show
  1. package/README.md +45 -127
  2. package/dist/casualtyDiagnosticReview.d.ts +46 -0
  3. package/dist/casualtyDiagnosticReview.d.ts.map +1 -0
  4. package/dist/casualtyDiagnosticReview.js +23 -0
  5. package/dist/casualtyDiagnosticReview.js.map +1 -0
  6. package/dist/diagnosticDefinition.d.ts +4 -0
  7. package/dist/diagnosticDefinition.d.ts.map +1 -0
  8. package/dist/diagnosticDefinition.js +6 -0
  9. package/dist/diagnosticDefinition.js.map +1 -0
  10. package/dist/diagnosticInput.d.ts +97 -13
  11. package/dist/diagnosticInput.d.ts.map +1 -1
  12. package/dist/diagnosticInput.js +403 -43
  13. package/dist/diagnosticInput.js.map +1 -1
  14. package/dist/diagnosticPreparedReview.d.ts +46 -0
  15. package/dist/diagnosticPreparedReview.d.ts.map +1 -0
  16. package/dist/diagnosticPreparedReview.js +449 -0
  17. package/dist/diagnosticPreparedReview.js.map +1 -0
  18. package/dist/exposure.d.ts.map +1 -1
  19. package/dist/exposure.js +25 -9
  20. package/dist/exposure.js.map +1 -1
  21. package/dist/index.d.ts +4 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/lossRun.d.ts.map +1 -1
  26. package/dist/lossRun.js +31 -8
  27. package/dist/lossRun.js.map +1 -1
  28. package/dist/review.d.ts +16 -3
  29. package/dist/review.d.ts.map +1 -1
  30. package/dist/review.js +35 -5
  31. package/dist/review.js.map +1 -1
  32. package/dist/version.d.ts +2 -0
  33. package/dist/version.d.ts.map +1 -0
  34. package/dist/version.js +2 -0
  35. package/dist/version.js.map +1 -0
  36. package/package.json +5 -4
  37. package/src/casualtyDiagnosticReview.ts +17 -0
  38. package/src/diagnosticDefinition.ts +6 -0
  39. package/src/diagnosticInput.ts +600 -57
  40. package/src/diagnosticPreparedReview.ts +653 -0
  41. package/src/exposure.ts +61 -16
  42. package/src/index.ts +4 -1
  43. package/src/lossRun.ts +55 -13
  44. package/src/review.ts +194 -42
  45. package/src/version.ts +1 -0
  46. package/dist/diagnosticReview.d.ts +0 -68
  47. package/dist/diagnosticReview.d.ts.map +0 -1
  48. package/dist/diagnosticReview.js +0 -370
  49. package/dist/diagnosticReview.js.map +0 -1
  50. package/src/diagnosticReview.ts +0 -547
@@ -1,79 +1,622 @@
1
1
  import {
2
- ReservingError,
3
- reconcileDiagnosticExposureKeys,
2
+ DiagnosticValidationError,
3
+ compileDiagnosticDefinition,
4
+ prepareDiagnosticData,
4
5
  runMetricDiagnostics,
5
- type DiagnosticExposureRow,
6
- type DiagnosticLossRow,
6
+ validateDiagnosticGroupingConfiguration,
7
+ type CompiledDiagnosticDefinition,
8
+ type DiagnosticCompletePeriodCutoff,
9
+ type DiagnosticDeepReadonly,
10
+ type DiagnosticDefinition,
11
+ type DiagnosticExpectedCell,
12
+ type DiagnosticExposureObservation,
13
+ type DiagnosticLossInput,
14
+ type DiagnosticsFilter,
15
+ type JsonValue,
7
16
  type MetricDiagnosticsResult,
8
- type ReconciledDiagnosticExposures,
9
- type RunMetricDiagnosticsInput,
17
+ type DiagnosticValidationIssue,
18
+ diagnosticRecord,
19
+ isDiagnosticToken,
20
+ isWellFormedDiagnosticString,
10
21
  } from "@actuarial-ts/core";
11
22
  import { z } from "zod";
23
+ import {
24
+ reviewPreparedDiagnosticData,
25
+ validateDiagnosticReviewEvidence,
26
+ type DiagnosticReviewEvidence,
27
+ type DiagnosticReviewReceipt,
28
+ } from "./diagnosticPreparedReview.js";
12
29
 
13
- const measuresSchema = z.record(z.number().nullable());
30
+ // Zod 3 validates but drops the literal __proto__ key while assembling records.
31
+ // Encode every key reversibly during validation, then restore owned data keys.
32
+ // The same adapter is exercised by the shared three-shore prototype-key corpus.
33
+ function recordSchema<T extends z.ZodTypeAny>(value: T) {
34
+ return z
35
+ .record(
36
+ z.string().transform((key) => `:${key}`),
37
+ value,
38
+ )
39
+ .transform(
40
+ (record) =>
41
+ Object.fromEntries(
42
+ Object.entries(record).map(([key, item]) => [key.slice(1), item]),
43
+ ) as Record<string, z.output<T>>,
44
+ );
45
+ }
14
46
 
15
- const diagnosticLossRowSchema = z.object({
16
- id: z.string().min(1),
17
- group: z.string().min(1),
18
- origin: z.string().min(1),
19
- valuation: z.string().min(1),
20
- ageMonths: z.number(),
21
- policyPeriod: z.string().min(1).optional(),
22
- dimensions: z.unknown().optional(),
47
+ const tokenSchema = z
48
+ .string()
49
+ .refine(
50
+ isDiagnosticToken,
51
+ "Expected a nonempty token with valid Unicode and no U+0000",
52
+ );
53
+ const jsonStringSchema = z
54
+ .string()
55
+ .refine(
56
+ isWellFormedDiagnosticString,
57
+ "Expected valid Unicode without U+0000",
58
+ );
59
+ const sourceSchema = z
60
+ .object({
61
+ artifactId: tokenSchema,
62
+ sourceFile: tokenSchema.optional(),
63
+ sourceSheet: tokenSchema.optional(),
64
+ sourceRow: z.number().int().nonnegative().safe().optional(),
65
+ sourceCell: tokenSchema.optional(),
66
+ })
67
+ .strict();
68
+ const rawNumberSchema = z.custom<number>(
69
+ (value) => typeof value === "number",
70
+ "Expected number",
71
+ );
72
+ const measuresSchema = recordSchema(z.union([rawNumberSchema, z.null()]));
73
+ const lossBase = {
74
+ recordId: tokenSchema,
75
+ sourceGroup: tokenSchema,
76
+ origin: tokenSchema,
77
+ valuation: tokenSchema,
78
+ complete: z.boolean(),
79
+ source: sourceSchema.optional(),
23
80
  measures: measuresSchema,
24
- }).strict();
81
+ };
82
+ const lossSchema = z.discriminatedUnion("rowType", [
83
+ z
84
+ .object({ ...lossBase, rowType: z.literal("claim"), claimId: tokenSchema })
85
+ .strict(),
86
+ z.object({ ...lossBase, rowType: z.literal("aggregate") }).strict(),
87
+ ]);
88
+ const exposureSchema = z
89
+ .object({
90
+ key: tokenSchema,
91
+ sourceGroup: tokenSchema,
92
+ origin: tokenSchema,
93
+ valuation: tokenSchema.optional(),
94
+ measureId: tokenSchema,
95
+ value: z.union([rawNumberSchema, z.null()]),
96
+ complete: z.boolean(),
97
+ source: sourceSchema.optional(),
98
+ })
99
+ .strict();
100
+ const filterSchema = z
101
+ .object({
102
+ sourceGroups: z.array(tokenSchema).optional(),
103
+ outputGroups: z.array(tokenSchema).optional(),
104
+ origins: z.array(tokenSchema).optional(),
105
+ originFrom: tokenSchema.optional(),
106
+ originThrough: tokenSchema.optional(),
107
+ valuations: z.array(tokenSchema).optional(),
108
+ valuationFrom: tokenSchema.optional(),
109
+ valuationThrough: tokenSchema.optional(),
110
+ minDevelopmentAge: z.number().int().nonnegative().safe().optional(),
111
+ maxDevelopmentAge: z.number().int().nonnegative().safe().optional(),
112
+ instanceIds: z.array(tokenSchema).optional(),
113
+ })
114
+ .strict();
115
+ const cutoffSchema = z
116
+ .object({
117
+ sourceGroup: tokenSchema,
118
+ originThrough: tokenSchema.nullable(),
119
+ valuationThrough: tokenSchema.nullable(),
120
+ })
121
+ .strict();
122
+ const expectedSchema = z
123
+ .object({
124
+ sourceGroup: tokenSchema,
125
+ origin: tokenSchema,
126
+ valuation: tokenSchema,
127
+ source: sourceSchema.optional(),
128
+ })
129
+ .strict();
130
+ const jsonSchema: z.ZodType<JsonValue> = z.lazy(() =>
131
+ z.union([
132
+ z.null(),
133
+ z.boolean(),
134
+ z.number().finite(),
135
+ jsonStringSchema,
136
+ z.array(jsonSchema),
137
+ recordSchema(jsonSchema),
138
+ ]),
139
+ );
140
+ const policySchema = z
141
+ .object({
142
+ allowedReviewStatuses: z
143
+ .array(z.enum(["pass", "warning", "not-evaluated", "fail"]))
144
+ .optional(),
145
+ allowedMetricFindingSeverities: z
146
+ .array(z.enum(["info", "warning", "fail"]))
147
+ .optional(),
148
+ rationaleRef: tokenSchema.optional(),
149
+ })
150
+ .strict();
151
+ const runSchema = z
152
+ .object({
153
+ definition: z.unknown(),
154
+ losses: z.array(lossSchema),
155
+ exposures: z.array(exposureSchema).optional(),
156
+ filter: filterSchema.optional(),
157
+ completePeriodCutoffs: z.array(cutoffSchema).optional(),
158
+ expectedCells: z.array(expectedSchema).optional(),
159
+ reviewEvidence: z.unknown().nullable().optional(),
160
+ runPresetId: tokenSchema.optional(),
161
+ datasetArtifactId: tokenSchema.optional(),
162
+ groupMap: recordSchema(tokenSchema).optional(),
163
+ groupDimensions: recordSchema(jsonSchema).optional(),
164
+ policy: policySchema.optional(),
165
+ })
166
+ .strict();
25
167
 
26
- const diagnosticExposureRowSchema = z.object({
27
- key: z.string().min(1),
28
- group: z.string().min(1),
29
- origin: z.string().min(1),
30
- valuation: z.string().min(1).optional(),
31
- measures: measuresSchema,
32
- complete: z.boolean().optional(),
33
- dimensions: z.unknown().optional(),
34
- }).strict();
168
+ export type DiagnosticAllowedReviewStatus =
169
+ | "pass"
170
+ | "warning"
171
+ | "not-evaluated"
172
+ | "fail";
173
+ export interface DiagnosticExecutionPolicyInput {
174
+ readonly allowedReviewStatuses?: readonly DiagnosticAllowedReviewStatus[];
175
+ readonly allowedMetricFindingSeverities?: readonly (
176
+ | "info"
177
+ | "warning"
178
+ | "fail"
179
+ )[];
180
+ readonly rationaleRef?: string;
181
+ }
182
+ export interface DiagnosticRunInput {
183
+ readonly definition: DiagnosticDefinition;
184
+ readonly losses: readonly DiagnosticLossInput[];
185
+ readonly exposures?: readonly DiagnosticExposureObservation[];
186
+ readonly filter?: DiagnosticsFilter;
187
+ readonly completePeriodCutoffs?: readonly DiagnosticCompletePeriodCutoff[];
188
+ readonly expectedCells?: readonly DiagnosticExpectedCell[];
189
+ readonly reviewEvidence?: DiagnosticReviewEvidence | null;
190
+ readonly runPresetId?: string;
191
+ readonly datasetArtifactId?: string;
192
+ readonly groupMap?: Readonly<Record<string, string>>;
193
+ readonly groupDimensions?: Readonly<Record<string, JsonValue>>;
194
+ readonly policy?: DiagnosticExecutionPolicyInput;
195
+ }
196
+ declare const validatedDiagnosticRunInputBrand: unique symbol;
197
+ export interface ValidatedDiagnosticRunInput {
198
+ readonly [validatedDiagnosticRunInputBrand]: true;
199
+ readonly definition: CompiledDiagnosticDefinition;
200
+ readonly losses: readonly DiagnosticDeepReadonly<DiagnosticLossInput>[];
201
+ readonly exposures: readonly DiagnosticDeepReadonly<DiagnosticExposureObservation>[];
202
+ readonly filter: DiagnosticDeepReadonly<DiagnosticsFilter> | null;
203
+ readonly completePeriodCutoffs: readonly DiagnosticCompletePeriodCutoff[];
204
+ readonly expectedCells: readonly DiagnosticExpectedCell[] | null;
205
+ readonly reviewEvidence: DiagnosticDeepReadonly<DiagnosticReviewEvidence> | null;
206
+ readonly runPresetId: string | null;
207
+ readonly datasetArtifactId: string | null;
208
+ readonly groupMap: Readonly<Record<string, string>>;
209
+ readonly groupDimensions: Readonly<Record<string, JsonValue>>;
210
+ readonly policy: {
211
+ readonly allowedReviewStatuses: readonly DiagnosticAllowedReviewStatus[];
212
+ readonly allowedMetricFindingSeverities: readonly (
213
+ | "info"
214
+ | "warning"
215
+ | "fail"
216
+ )[];
217
+ readonly rationaleRef: string | null;
218
+ };
219
+ }
220
+ export interface DiagnosticExecutionGateReceipt {
221
+ readonly allowedReviewStatuses: readonly DiagnosticAllowedReviewStatus[];
222
+ readonly allowedMetricFindingSeverities: readonly (
223
+ | "info"
224
+ | "warning"
225
+ | "fail"
226
+ )[];
227
+ readonly rationaleRef: string | null;
228
+ readonly reviewGate: "passed" | "blocked";
229
+ readonly metricGate: "not-run" | "passed" | "blocked";
230
+ }
231
+ export interface CompletedValidatedMetricDiagnosticsRun {
232
+ readonly status: "completed";
233
+ readonly prepared: import("@actuarial-ts/core").PreparedDiagnosticData;
234
+ readonly review: DiagnosticReviewReceipt;
235
+ readonly result: DiagnosticDeepReadonly<MetricDiagnosticsResult>;
236
+ readonly runPresetId: string | null;
237
+ readonly datasetArtifactId: string | null;
238
+ readonly groupMap: Readonly<Record<string, string>>;
239
+ readonly groupDimensions: Readonly<Record<string, JsonValue>>;
240
+ readonly gate: DiagnosticExecutionGateReceipt & {
241
+ readonly reviewGate: "passed";
242
+ readonly metricGate: "passed";
243
+ };
244
+ }
245
+ export type ValidatedMetricDiagnosticsOutcome =
246
+ | CompletedValidatedMetricDiagnosticsRun
247
+ | {
248
+ readonly status: "blocked";
249
+ readonly stage: "review";
250
+ readonly prepared: import("@actuarial-ts/core").PreparedDiagnosticData;
251
+ readonly review: DiagnosticReviewReceipt;
252
+ readonly result: null;
253
+ readonly runPresetId: string | null;
254
+ readonly datasetArtifactId: string | null;
255
+ readonly groupMap: Readonly<Record<string, string>>;
256
+ readonly groupDimensions: Readonly<Record<string, JsonValue>>;
257
+ readonly gate: DiagnosticExecutionGateReceipt & {
258
+ readonly reviewGate: "blocked";
259
+ readonly metricGate: "not-run";
260
+ };
261
+ }
262
+ | {
263
+ readonly status: "blocked";
264
+ readonly stage: "metric";
265
+ readonly prepared: import("@actuarial-ts/core").PreparedDiagnosticData;
266
+ readonly review: DiagnosticReviewReceipt;
267
+ readonly result: DiagnosticDeepReadonly<MetricDiagnosticsResult>;
268
+ readonly runPresetId: string | null;
269
+ readonly datasetArtifactId: string | null;
270
+ readonly groupMap: Readonly<Record<string, string>>;
271
+ readonly groupDimensions: Readonly<Record<string, JsonValue>>;
272
+ readonly gate: DiagnosticExecutionGateReceipt & {
273
+ readonly reviewGate: "passed";
274
+ readonly metricGate: "blocked";
275
+ };
276
+ };
35
277
 
36
- const diagnosticDatasetSchema = z.object({
37
- losses: z.array(diagnosticLossRowSchema),
38
- exposures: z.array(diagnosticExposureRowSchema).optional(),
39
- }).strict();
278
+ const authentic = new WeakSet<object>();
279
+ function freeze<T>(
280
+ value: T,
281
+ seen = new WeakSet<object>(),
282
+ ): DiagnosticDeepReadonly<T> {
283
+ if (value === null || typeof value !== "object" || seen.has(value))
284
+ return value as DiagnosticDeepReadonly<T>;
285
+ seen.add(value);
286
+ for (const child of Object.values(value as Record<string, unknown>))
287
+ freeze(child, seen);
288
+ return Object.freeze(value) as DiagnosticDeepReadonly<T>;
289
+ }
290
+ function issues(error: z.ZodError): DiagnosticValidationError {
291
+ return new DiagnosticValidationError(
292
+ error.issues.map((issue) => ({
293
+ domain: (issue.path[0] === "definition"
294
+ ? "definition"
295
+ : issue.path[0] === "losses" ||
296
+ issue.path[0] === "exposures" ||
297
+ issue.path[0] === "reviewEvidence"
298
+ ? "input"
299
+ : "configuration") as "definition" | "input" | "configuration",
300
+ code: issue.code === "unrecognized_keys" ? "unknown-key" : "invalid-type",
301
+ path: `$${issue.path.map((part) => (typeof part === "number" ? `[${part}]` : /^[A-Za-z_$][\w$]*$/.test(part) ? `.${part}` : `[${JSON.stringify(part)}]`)).join("")}`,
302
+ message: issue.message,
303
+ })),
304
+ );
305
+ }
306
+ function codeUnit(left: string, right: string): number {
307
+ return left < right ? -1 : left > right ? 1 : 0;
308
+ }
309
+ function sortedRecord<T>(
310
+ value: Readonly<Record<string, T>>,
311
+ ): Readonly<Record<string, T>> {
312
+ const result = diagnosticRecord<T>();
313
+ for (const key of Object.keys(value).sort(codeUnit))
314
+ result[key] = value[key]!;
315
+ return result;
316
+ }
40
317
 
41
- export interface ValidatedDiagnosticDataset {
42
- losses: DiagnosticLossRow[];
43
- /** Omitted when the caller did not supply exposure data. */
44
- exposures?: DiagnosticExposureRow[];
318
+ function explicitUndefinedIssues(value: unknown): DiagnosticValidationIssue[] {
319
+ const found: DiagnosticValidationIssue[] = [];
320
+ const stack: { readonly value: unknown; readonly path: string }[] = [
321
+ { value, path: "$" },
322
+ ];
323
+ const seen = new WeakSet<object>();
324
+ while (stack.length > 0) {
325
+ const current = stack.pop()!;
326
+ if (
327
+ current.value === null ||
328
+ typeof current.value !== "object" ||
329
+ seen.has(current.value)
330
+ )
331
+ continue;
332
+ seen.add(current.value);
333
+ for (const [key, child] of Object.entries(current.value)) {
334
+ const path = Array.isArray(current.value)
335
+ ? `${current.path}[${key}]`
336
+ : /^[A-Za-z_$][\w$]*$/.test(key)
337
+ ? `${current.path}.${key}`
338
+ : `${current.path}[${JSON.stringify(key)}]`;
339
+ if (child === undefined)
340
+ found.push({
341
+ domain: path.startsWith("$.definition")
342
+ ? "definition"
343
+ : path.startsWith("$.losses") ||
344
+ path.startsWith("$.exposures") ||
345
+ path.startsWith("$.reviewEvidence")
346
+ ? "input"
347
+ : "configuration",
348
+ code: "invalid-type",
349
+ path,
350
+ message: "Explicit undefined is not allowed",
351
+ });
352
+ else stack.push({ value: child, path });
353
+ }
354
+ }
355
+ return found;
45
356
  }
46
357
 
47
- /** Zod-validates unknown diagnostic rows at the data package boundary. */
48
- export function validateDiagnosticDataset(value: unknown): ValidatedDiagnosticDataset {
49
- const parsed = diagnosticDatasetSchema.safeParse(value);
50
- if (!parsed.success) {
51
- const details = parsed.error.issues
52
- .map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "$"}: ${issue.message}`)
53
- .join("; ");
54
- throw new ReservingError("SHAPE", `Invalid diagnostic dataset: ${details}`);
358
+ export function validateDiagnosticRunInput(
359
+ value: unknown,
360
+ ): ValidatedDiagnosticRunInput {
361
+ const undefinedIssues = explicitUndefinedIssues(value);
362
+ if (undefinedIssues.length > 0)
363
+ throw new DiagnosticValidationError(undefinedIssues);
364
+ const parsed = runSchema.safeParse(value);
365
+ if (!parsed.success) throw issues(parsed.error);
366
+ const definition = compileDiagnosticDefinition(
367
+ parsed.data.definition as DiagnosticDefinition,
368
+ );
369
+ const relationIssues: DiagnosticValidationIssue[] =
370
+ parsed.data.losses.flatMap((row, index) =>
371
+ row.rowType === definition.definition.lossRowGrain
372
+ ? []
373
+ : [
374
+ {
375
+ domain: "input" as const,
376
+ code: "invalid-input-relationship" as const,
377
+ path: `$.losses[${index}].rowType`,
378
+ message: "Loss row type does not match definition grain",
379
+ },
380
+ ],
381
+ );
382
+ for (const [index, row] of (parsed.data.exposures ?? []).entries()) {
383
+ const measure = definition.definition.measures.find(
384
+ (item) => item.id === row.measureId,
385
+ );
386
+ if (
387
+ measure?.exposureTiming === "valuation-specific" &&
388
+ row.valuation === undefined
389
+ )
390
+ relationIssues.push({
391
+ domain: "input",
392
+ code: "missing-required",
393
+ path: `$.exposures[${index}].valuation`,
394
+ message: "Valuation-specific exposure requires valuation",
395
+ });
55
396
  }
56
- return {
397
+ if (relationIssues.length)
398
+ throw new DiagnosticValidationError(relationIssues);
399
+ const review = parsed.data.policy?.allowedReviewStatuses ?? [
400
+ "pass",
401
+ "warning",
402
+ "not-evaluated",
403
+ ];
404
+ const metric = parsed.data.policy?.allowedMetricFindingSeverities ?? [
405
+ "info",
406
+ "warning",
407
+ ];
408
+ const rationale = parsed.data.policy?.rationaleRef ?? null;
409
+ if (
410
+ (review.includes("fail") || metric.includes("fail")) &&
411
+ rationale === null
412
+ )
413
+ throw new DiagnosticValidationError([
414
+ {
415
+ domain: "configuration",
416
+ code: "missing-required",
417
+ path: "$.policy.rationaleRef",
418
+ message: "A rationale is required when fail outcomes are allowed",
419
+ },
420
+ ]);
421
+ const reviewEvidence =
422
+ parsed.data.reviewEvidence === undefined ||
423
+ parsed.data.reviewEvidence === null
424
+ ? null
425
+ : validateDiagnosticReviewEvidence(
426
+ parsed.data.reviewEvidence,
427
+ "$.reviewEvidence",
428
+ );
429
+ const reviewOrder: readonly DiagnosticAllowedReviewStatus[] = [
430
+ "pass",
431
+ "warning",
432
+ "not-evaluated",
433
+ "fail",
434
+ ];
435
+ const metricOrder: readonly ("info" | "warning" | "fail")[] = [
436
+ "info",
437
+ "warning",
438
+ "fail",
439
+ ];
440
+ for (const key of [
441
+ ...Object.keys(parsed.data.groupMap ?? {}),
442
+ ...Object.keys(parsed.data.groupDimensions ?? {}),
443
+ ])
444
+ if (!isDiagnosticToken(key))
445
+ throw new DiagnosticValidationError([
446
+ {
447
+ domain: "configuration",
448
+ code: "invalid-string",
449
+ path: `$.groupMap[${JSON.stringify(key)}]`,
450
+ message:
451
+ "Group key must be a nonempty token with valid Unicode and no U+0000",
452
+ },
453
+ ]);
454
+ const result = freeze({
455
+ definition,
57
456
  losses: parsed.data.losses,
58
- ...(parsed.data.exposures !== undefined ? { exposures: parsed.data.exposures } : {}),
59
- };
457
+ exposures: parsed.data.exposures ?? [],
458
+ filter: parsed.data.filter ?? null,
459
+ completePeriodCutoffs: parsed.data.completePeriodCutoffs ?? [],
460
+ expectedCells: parsed.data.expectedCells ?? null,
461
+ reviewEvidence,
462
+ runPresetId: parsed.data.runPresetId ?? null,
463
+ datasetArtifactId: parsed.data.datasetArtifactId ?? null,
464
+ groupMap: sortedRecord(parsed.data.groupMap ?? diagnosticRecord()),
465
+ groupDimensions: sortedRecord(
466
+ parsed.data.groupDimensions ?? diagnosticRecord(),
467
+ ),
468
+ policy: {
469
+ allowedReviewStatuses: reviewOrder.filter((status) =>
470
+ review.includes(status),
471
+ ),
472
+ allowedMetricFindingSeverities: metricOrder.filter((severity) =>
473
+ metric.includes(severity),
474
+ ),
475
+ rationaleRef: rationale,
476
+ },
477
+ }) as unknown as ValidatedDiagnosticRunInput;
478
+ const prepared = prepareDiagnosticData({
479
+ definition: result.definition,
480
+ losses: result.losses,
481
+ exposures: result.exposures,
482
+ ...(result.filter === null ? {} : { filter: result.filter }),
483
+ completePeriodCutoffs: result.completePeriodCutoffs,
484
+ ...(result.expectedCells === null
485
+ ? {}
486
+ : { expectedCells: result.expectedCells }),
487
+ });
488
+ validateDiagnosticGroupingConfiguration({
489
+ prepared,
490
+ groupMap: result.groupMap,
491
+ groupDimensions: result.groupDimensions,
492
+ });
493
+ authentic.add(result);
494
+ return result;
60
495
  }
61
496
 
62
- /** Validates unknown exposure rows, then applies core's stable-key reconciliation. */
63
- export function validateAndReconcileDiagnosticExposures(
497
+ export function assertValidatedDiagnosticRunInput(
64
498
  value: unknown,
65
- ): ReconciledDiagnosticExposures {
66
- const validated = validateDiagnosticDataset({ losses: [], exposures: value });
67
- return reconcileDiagnosticExposureKeys(validated.exposures ?? []);
499
+ ): asserts value is ValidatedDiagnosticRunInput {
500
+ if (value === null || typeof value !== "object" || !authentic.has(value))
501
+ throw new DiagnosticValidationError([
502
+ {
503
+ domain: "input",
504
+ code: "invalid-input-relationship",
505
+ path: "$",
506
+ message: "Value is not an authentic validated diagnostic run input",
507
+ },
508
+ ]);
68
509
  }
69
510
 
70
- export type ValidatedMetricDiagnosticsOptions = Omit<RunMetricDiagnosticsInput, "losses" | "exposures">;
71
-
72
- /** Convenience boundary: validate unknown rows, then run the dependency-free core engine. */
511
+ const completed = new WeakSet<object>();
73
512
  export function runValidatedMetricDiagnostics(
74
- dataset: unknown,
75
- options: ValidatedMetricDiagnosticsOptions,
76
- ): MetricDiagnosticsResult {
77
- const validated = validateDiagnosticDataset(dataset);
78
- return runMetricDiagnostics({ ...options, ...validated });
513
+ input: ValidatedDiagnosticRunInput,
514
+ ): ValidatedMetricDiagnosticsOutcome {
515
+ assertValidatedDiagnosticRunInput(input);
516
+ const prepared = prepareDiagnosticData({
517
+ definition: input.definition,
518
+ losses: input.losses,
519
+ exposures: input.exposures,
520
+ ...(input.filter === null ? {} : { filter: input.filter }),
521
+ completePeriodCutoffs: input.completePeriodCutoffs,
522
+ ...(input.expectedCells === null
523
+ ? {}
524
+ : { expectedCells: input.expectedCells }),
525
+ });
526
+ validateDiagnosticGroupingConfiguration({
527
+ prepared,
528
+ groupMap: input.groupMap,
529
+ groupDimensions: input.groupDimensions,
530
+ });
531
+ const review = reviewPreparedDiagnosticData({
532
+ prepared,
533
+ evidence: input.reviewEvidence as DiagnosticReviewEvidence | null,
534
+ });
535
+ const evaluationStatus = (
536
+ evaluation: DiagnosticReviewReceipt["evaluations"][number],
537
+ ): DiagnosticAllowedReviewStatus =>
538
+ evaluation.expressionOverflows.length > 0
539
+ ? "fail"
540
+ : evaluation.status === "not-evaluated"
541
+ ? "not-evaluated"
542
+ : evaluation.status === "triggered"
543
+ ? evaluation.severity
544
+ : "pass";
545
+ const disallowedReview =
546
+ review.report.checks.some(
547
+ (check) => !input.policy.allowedReviewStatuses.includes(check.status),
548
+ ) ||
549
+ review.evaluations.some(
550
+ (evaluation) =>
551
+ !input.policy.allowedReviewStatuses.includes(
552
+ evaluationStatus(evaluation),
553
+ ),
554
+ );
555
+ const base = {
556
+ prepared,
557
+ review,
558
+ runPresetId: input.runPresetId,
559
+ datasetArtifactId: input.datasetArtifactId,
560
+ groupMap: input.groupMap,
561
+ groupDimensions: input.groupDimensions,
562
+ };
563
+ if (disallowedReview)
564
+ return freeze({
565
+ ...base,
566
+ status: "blocked" as const,
567
+ stage: "review" as const,
568
+ result: null,
569
+ gate: {
570
+ ...input.policy,
571
+ reviewGate: "blocked" as const,
572
+ metricGate: "not-run" as const,
573
+ },
574
+ }) as ValidatedMetricDiagnosticsOutcome;
575
+ const result = runMetricDiagnostics({
576
+ prepared,
577
+ groupMap: input.groupMap,
578
+ groupDimensions: input.groupDimensions,
579
+ });
580
+ const disallowedMetric = result.findings.some(
581
+ (finding) =>
582
+ finding.category !== "structural" &&
583
+ !input.policy.allowedMetricFindingSeverities.includes(finding.severity),
584
+ );
585
+ if (disallowedMetric)
586
+ return freeze({
587
+ ...base,
588
+ status: "blocked" as const,
589
+ stage: "metric" as const,
590
+ result,
591
+ gate: {
592
+ ...input.policy,
593
+ reviewGate: "passed" as const,
594
+ metricGate: "blocked" as const,
595
+ },
596
+ }) as ValidatedMetricDiagnosticsOutcome;
597
+ const outcome = freeze({
598
+ ...base,
599
+ status: "completed" as const,
600
+ result,
601
+ gate: {
602
+ ...input.policy,
603
+ reviewGate: "passed" as const,
604
+ metricGate: "passed" as const,
605
+ },
606
+ }) as unknown as CompletedValidatedMetricDiagnosticsRun;
607
+ completed.add(outcome);
608
+ return outcome;
609
+ }
610
+ export function assertCompletedValidatedMetricDiagnosticsRun(
611
+ value: unknown,
612
+ ): asserts value is CompletedValidatedMetricDiagnosticsRun {
613
+ if (value === null || typeof value !== "object" || !completed.has(value))
614
+ throw new DiagnosticValidationError([
615
+ {
616
+ domain: "input",
617
+ code: "invalid-input-relationship",
618
+ path: "$",
619
+ message: "Value is not an authentic completed diagnostic run",
620
+ },
621
+ ]);
79
622
  }