@actuarial-ts/data 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/dist/customizationContracts.d.ts +37 -9
- package/dist/customizationContracts.d.ts.map +1 -1
- package/dist/customizationContracts.js +11 -2
- package/dist/customizationContracts.js.map +1 -1
- package/dist/diagnosticInput.d.ts.map +1 -1
- package/dist/diagnosticInput.js +236 -23
- package/dist/diagnosticInput.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/src/customizationContracts.ts +10 -4
- package/src/diagnosticInput.ts +226 -21
- package/src/version.ts +1 -1
package/src/diagnosticInput.ts
CHANGED
|
@@ -350,12 +350,33 @@ const compactInputByCompletedRun = new WeakMap<
|
|
|
350
350
|
CompletedCompactMetricDiagnosticsRun,
|
|
351
351
|
CompactValidatedDiagnosticRunInput
|
|
352
352
|
>();
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
353
|
+
// Validated inputs are JSON-shaped trees (the schema copies every row), so a
|
|
354
|
+
// shared node is a small leaf visited once per reference rather than tracked in
|
|
355
|
+
// a table of every node. Ancestors are recorded only past this depth, which a
|
|
356
|
+
// cycle in a caller-supplied graph must exceed before it is cut.
|
|
357
|
+
const UNGUARDED_FREEZE_DEPTH = 64;
|
|
358
|
+
function freeze<T>(value: T, skip?: WeakSet<object>): DiagnosticDeepReadonly<T> {
|
|
359
|
+
let ancestors: Set<object> | undefined;
|
|
360
|
+
const visit = (node: unknown, depth: number): void => {
|
|
361
|
+
if (node === null || typeof node !== "object" || skip?.has(node)) return;
|
|
362
|
+
const guarded = depth > UNGUARDED_FREEZE_DEPTH;
|
|
363
|
+
if (guarded) {
|
|
364
|
+
ancestors ??= new Set();
|
|
365
|
+
if (ancestors.has(node)) return;
|
|
366
|
+
ancestors.add(node);
|
|
367
|
+
}
|
|
368
|
+
if (Array.isArray(node)) {
|
|
369
|
+
for (let index = 0; index < node.length; index++) visit(node[index], depth + 1);
|
|
370
|
+
} else {
|
|
371
|
+
const record = node as Record<string, unknown>;
|
|
372
|
+
const keys = Object.keys(record);
|
|
373
|
+
for (let index = 0; index < keys.length; index++) visit(record[keys[index]!], depth + 1);
|
|
374
|
+
}
|
|
375
|
+
if (guarded) ancestors!.delete(node);
|
|
376
|
+
Object.freeze(node);
|
|
377
|
+
};
|
|
378
|
+
visit(value, 0);
|
|
379
|
+
return value as DiagnosticDeepReadonly<T>;
|
|
359
380
|
}
|
|
360
381
|
function issues(error: z.ZodError): DiagnosticValidationError {
|
|
361
382
|
return new DiagnosticValidationError(
|
|
@@ -382,22 +403,39 @@ function sortedRecord<T>(value: Readonly<Record<string, T>>): Readonly<Record<st
|
|
|
382
403
|
return result;
|
|
383
404
|
}
|
|
384
405
|
|
|
406
|
+
interface UndefinedScanFrame {
|
|
407
|
+
readonly value: unknown;
|
|
408
|
+
readonly parent: UndefinedScanFrame | null;
|
|
409
|
+
readonly key: string | null;
|
|
410
|
+
}
|
|
411
|
+
/** Renders the JSON path of a frame only when an issue must cite it. */
|
|
412
|
+
function scanFramePath(frame: UndefinedScanFrame): string {
|
|
413
|
+
if (frame.parent === null || frame.key === null) return "$";
|
|
414
|
+
const parentPath = scanFramePath(frame.parent);
|
|
415
|
+
return Array.isArray(frame.parent.value)
|
|
416
|
+
? `${parentPath}[${frame.key}]`
|
|
417
|
+
: /^[A-Za-z_$][\w$]*$/.test(frame.key)
|
|
418
|
+
? `${parentPath}.${frame.key}`
|
|
419
|
+
: `${parentPath}[${JSON.stringify(frame.key)}]`;
|
|
420
|
+
}
|
|
385
421
|
function explicitUndefinedIssues(value: unknown): DiagnosticValidationIssue[] {
|
|
386
422
|
const found: DiagnosticValidationIssue[] = [];
|
|
387
|
-
const stack:
|
|
423
|
+
const stack: UndefinedScanFrame[] = [{ value, parent: null, key: null }];
|
|
388
424
|
const seen = new WeakSet<object>();
|
|
389
425
|
while (stack.length > 0) {
|
|
390
426
|
const current = stack.pop()!;
|
|
391
427
|
if (current.value === null || typeof current.value !== "object" || seen.has(current.value))
|
|
392
428
|
continue;
|
|
393
429
|
seen.add(current.value);
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
430
|
+
const record = current.value as Record<string, unknown>;
|
|
431
|
+
// Large inputs are ordinary: build no path text or entry pairs on the
|
|
432
|
+
// common path, only the frame links needed to render a path on demand.
|
|
433
|
+
const keys = Object.keys(record);
|
|
434
|
+
for (let index = 0; index < keys.length; index++) {
|
|
435
|
+
const key = keys[index]!;
|
|
436
|
+
const child = record[key];
|
|
437
|
+
if (child === undefined) {
|
|
438
|
+
const path = scanFramePath({ value: child, parent: current, key });
|
|
401
439
|
found.push({
|
|
402
440
|
domain: path.startsWith("$.definition")
|
|
403
441
|
? "definition"
|
|
@@ -410,21 +448,188 @@ function explicitUndefinedIssues(value: unknown): DiagnosticValidationIssue[] {
|
|
|
410
448
|
path,
|
|
411
449
|
message: "Explicit undefined is not allowed",
|
|
412
450
|
});
|
|
413
|
-
else
|
|
451
|
+
} else if (child !== null && typeof child === "object")
|
|
452
|
+
stack.push({ value: child, parent: current, key });
|
|
414
453
|
}
|
|
415
454
|
}
|
|
416
455
|
return found;
|
|
417
456
|
}
|
|
418
457
|
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
// Large-input fast path for the two row arrays. Zod validates and copies every
|
|
460
|
+
// row through generic machinery, which on 100,000-row inputs allocates more
|
|
461
|
+
// than the preparation itself. Rows are accepted here only under exactly the
|
|
462
|
+
// schema's rules (plain records, strict keys, token strings, boolean flags,
|
|
463
|
+
// safe non-negative integer source rows, raw number-or-null measures) and are
|
|
464
|
+
// copied in the schema's key order, so the owned rows are indistinguishable
|
|
465
|
+
// from the schema's output. Anything the check does not accept, including any
|
|
466
|
+
// non-plain prototype, is handed to the schema unchanged, so every rejection
|
|
467
|
+
// and every error message still comes from zod.
|
|
468
|
+
type FastLossRow = z.output<typeof lossSchema>;
|
|
469
|
+
type FastExposureRow = z.output<typeof exposureSchema>;
|
|
470
|
+
const isPlainRecord = (value: unknown): value is Record<string, unknown> => {
|
|
471
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
472
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
473
|
+
return prototype === Object.prototype || prototype === null;
|
|
474
|
+
};
|
|
475
|
+
const hasOwn = (value: object, key: string): boolean => Object.prototype.hasOwnProperty.call(value, key);
|
|
476
|
+
function fastSource(value: unknown): z.output<typeof sourceSchema> | undefined {
|
|
477
|
+
if (!isPlainRecord(value)) return undefined;
|
|
478
|
+
const artifactId = value.artifactId;
|
|
479
|
+
if (!isDiagnosticToken(artifactId)) return undefined;
|
|
480
|
+
const out: Record<string, unknown> = { artifactId };
|
|
481
|
+
let matched = 1;
|
|
482
|
+
for (const field of ["sourceFile", "sourceSheet"] as const)
|
|
483
|
+
if (hasOwn(value, field)) {
|
|
484
|
+
const item = value[field];
|
|
485
|
+
if (!isDiagnosticToken(item)) return undefined;
|
|
486
|
+
out[field] = item;
|
|
487
|
+
matched++;
|
|
488
|
+
}
|
|
489
|
+
if (hasOwn(value, "sourceRow")) {
|
|
490
|
+
const item = value.sourceRow;
|
|
491
|
+
if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return undefined;
|
|
492
|
+
out.sourceRow = item;
|
|
493
|
+
matched++;
|
|
494
|
+
}
|
|
495
|
+
if (hasOwn(value, "sourceCell")) {
|
|
496
|
+
const item = value.sourceCell;
|
|
497
|
+
if (!isDiagnosticToken(item)) return undefined;
|
|
498
|
+
out.sourceCell = item;
|
|
499
|
+
matched++;
|
|
500
|
+
}
|
|
501
|
+
if (Object.keys(value).length !== matched) return undefined;
|
|
502
|
+
return out as z.output<typeof sourceSchema>;
|
|
503
|
+
}
|
|
504
|
+
function fastMeasures(value: unknown): Record<string, number | null> | undefined {
|
|
505
|
+
if (!isPlainRecord(value)) return undefined;
|
|
506
|
+
const out: Record<string, number | null> = {};
|
|
507
|
+
const keys = Object.keys(value);
|
|
508
|
+
for (let index = 0; index < keys.length; index++) {
|
|
509
|
+
const key = keys[index]!;
|
|
510
|
+
const item = value[key];
|
|
511
|
+
if (item !== null && typeof item !== "number") return undefined;
|
|
512
|
+
// The schema's key round trip keeps a literal "__proto__" as own data.
|
|
513
|
+
if (key === "__proto__")
|
|
514
|
+
Object.defineProperty(out, key, { value: item, enumerable: true, writable: true, configurable: true });
|
|
515
|
+
else out[key] = item;
|
|
516
|
+
}
|
|
517
|
+
return out;
|
|
518
|
+
}
|
|
519
|
+
function fastLossRow(value: unknown): FastLossRow | undefined {
|
|
520
|
+
if (!isPlainRecord(value)) return undefined;
|
|
521
|
+
const { recordId, sourceGroup, origin, valuation, complete, rowType } = value;
|
|
522
|
+
if (
|
|
523
|
+
!isDiagnosticToken(recordId) ||
|
|
524
|
+
!isDiagnosticToken(sourceGroup) ||
|
|
525
|
+
!isDiagnosticToken(origin) ||
|
|
526
|
+
!isDiagnosticToken(valuation) ||
|
|
527
|
+
typeof complete !== "boolean"
|
|
528
|
+
)
|
|
529
|
+
return undefined;
|
|
530
|
+
const out: Record<string, unknown> = { recordId, sourceGroup, origin, valuation, complete };
|
|
531
|
+
let expected = 7;
|
|
532
|
+
if (hasOwn(value, "source")) {
|
|
533
|
+
const source = fastSource(value.source);
|
|
534
|
+
if (source === undefined) return undefined;
|
|
535
|
+
out.source = source;
|
|
536
|
+
expected++;
|
|
537
|
+
}
|
|
538
|
+
const measures = fastMeasures(value.measures);
|
|
539
|
+
if (measures === undefined) return undefined;
|
|
540
|
+
out.measures = measures;
|
|
541
|
+
if (rowType === "claim") {
|
|
542
|
+
const claimId = value.claimId;
|
|
543
|
+
if (!hasOwn(value, "claimId") || !isDiagnosticToken(claimId)) return undefined;
|
|
544
|
+
out.rowType = "claim";
|
|
545
|
+
out.claimId = claimId;
|
|
546
|
+
expected++;
|
|
547
|
+
} else if (rowType === "aggregate") out.rowType = "aggregate";
|
|
548
|
+
else return undefined;
|
|
549
|
+
if (Object.keys(value).length !== expected) return undefined;
|
|
550
|
+
return out as FastLossRow;
|
|
551
|
+
}
|
|
552
|
+
function fastExposureRow(value: unknown): FastExposureRow | undefined {
|
|
553
|
+
if (!isPlainRecord(value)) return undefined;
|
|
554
|
+
const { key, sourceGroup, origin, measureId, complete } = value;
|
|
555
|
+
const measure = value.value;
|
|
556
|
+
if (
|
|
557
|
+
!isDiagnosticToken(key) ||
|
|
558
|
+
!isDiagnosticToken(sourceGroup) ||
|
|
559
|
+
!isDiagnosticToken(origin) ||
|
|
560
|
+
!isDiagnosticToken(measureId) ||
|
|
561
|
+
(measure !== null && typeof measure !== "number") ||
|
|
562
|
+
typeof complete !== "boolean"
|
|
563
|
+
)
|
|
564
|
+
return undefined;
|
|
565
|
+
const out: Record<string, unknown> = { key, sourceGroup, origin };
|
|
566
|
+
let expected = 6;
|
|
567
|
+
if (hasOwn(value, "valuation")) {
|
|
568
|
+
const valuation = value.valuation;
|
|
569
|
+
if (!isDiagnosticToken(valuation)) return undefined;
|
|
570
|
+
out.valuation = valuation;
|
|
571
|
+
expected++;
|
|
572
|
+
}
|
|
573
|
+
out.measureId = measureId;
|
|
574
|
+
out.value = measure;
|
|
575
|
+
out.complete = complete;
|
|
576
|
+
if (hasOwn(value, "source")) {
|
|
577
|
+
const source = fastSource(value.source);
|
|
578
|
+
if (source === undefined) return undefined;
|
|
579
|
+
out.source = source;
|
|
580
|
+
expected++;
|
|
581
|
+
}
|
|
582
|
+
if (Object.keys(value).length !== expected) return undefined;
|
|
583
|
+
return out as FastExposureRow;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Validates and copies the row arrays directly when every row is schema-shaped.
|
|
587
|
+
* Returns the owned rows plus a shell of the input with empty row arrays for
|
|
588
|
+
* the schema to validate everything else; undefined defers the whole input.
|
|
589
|
+
*/
|
|
590
|
+
function fastSourceRows(
|
|
591
|
+
value: unknown,
|
|
592
|
+
): { shell: Record<string, unknown>; losses: FastLossRow[]; exposures: FastExposureRow[] | undefined } | undefined {
|
|
593
|
+
if (!isPlainRecord(value) || !Array.isArray(value.losses)) return undefined;
|
|
594
|
+
const losses: FastLossRow[] = [];
|
|
595
|
+
for (let index = 0; index < value.losses.length; index++) {
|
|
596
|
+
const row = fastLossRow(value.losses[index]);
|
|
597
|
+
if (row === undefined) return undefined;
|
|
598
|
+
losses.push(row);
|
|
599
|
+
}
|
|
600
|
+
let exposures: FastExposureRow[] | undefined;
|
|
601
|
+
if (hasOwn(value, "exposures")) {
|
|
602
|
+
if (!Array.isArray(value.exposures)) return undefined;
|
|
603
|
+
exposures = [];
|
|
604
|
+
for (let index = 0; index < value.exposures.length; index++) {
|
|
605
|
+
const row = fastExposureRow(value.exposures[index]);
|
|
606
|
+
if (row === undefined) return undefined;
|
|
607
|
+
exposures.push(row);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
shell: { ...value, losses: [], ...(exposures === undefined ? {} : { exposures: [] }) },
|
|
612
|
+
losses,
|
|
613
|
+
exposures,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
419
617
|
// Both public gateways share the same full validation/ownership boundary.
|
|
420
618
|
// Selecting compact storage never invokes the eager preparation first.
|
|
421
619
|
function validateRunInputContent(value: unknown): DiagnosticRunInputContent {
|
|
422
|
-
|
|
620
|
+
// A row the fast check accepts holds no explicit undefined anywhere: every
|
|
621
|
+
// field it reads must be a token, boolean, or number-or-null, and any other
|
|
622
|
+
// value (including undefined) makes it decline the whole input. So when it
|
|
623
|
+
// accepts, scanning its row-free shell reports exactly the same issues.
|
|
624
|
+
const fast = fastSourceRows(value);
|
|
625
|
+
const undefinedIssues = explicitUndefinedIssues(fast === undefined ? value : fast.shell);
|
|
423
626
|
if (undefinedIssues.length > 0) throw new DiagnosticValidationError(undefinedIssues);
|
|
424
|
-
const parsed = runSchema.safeParse(value);
|
|
627
|
+
const parsed = runSchema.safeParse(fast === undefined ? value : fast.shell);
|
|
425
628
|
if (!parsed.success) throw issues(parsed.error);
|
|
629
|
+
const losses = fast === undefined ? parsed.data.losses : fast.losses;
|
|
630
|
+
const exposures = (fast === undefined ? parsed.data.exposures : fast.exposures) ?? [];
|
|
426
631
|
const definition = compileDiagnosticDefinition(parsed.data.definition as DiagnosticDefinition);
|
|
427
|
-
const relationIssues: DiagnosticValidationIssue[] =
|
|
632
|
+
const relationIssues: DiagnosticValidationIssue[] = losses.flatMap((row, index) =>
|
|
428
633
|
row.rowType === definition.definition.lossRowGrain
|
|
429
634
|
? []
|
|
430
635
|
: [
|
|
@@ -436,7 +641,7 @@ function validateRunInputContent(value: unknown): DiagnosticRunInputContent {
|
|
|
436
641
|
},
|
|
437
642
|
],
|
|
438
643
|
);
|
|
439
|
-
for (const [index, row] of
|
|
644
|
+
for (const [index, row] of exposures.entries()) {
|
|
440
645
|
const measure = definition.definition.measures.find((item) => item.id === row.measureId);
|
|
441
646
|
if (measure?.exposureTiming === "valuation-specific" && row.valuation === undefined)
|
|
442
647
|
relationIssues.push({
|
|
@@ -485,8 +690,8 @@ function validateRunInputContent(value: unknown): DiagnosticRunInputContent {
|
|
|
485
690
|
]);
|
|
486
691
|
const result = freeze({
|
|
487
692
|
definition,
|
|
488
|
-
losses
|
|
489
|
-
exposures
|
|
693
|
+
losses,
|
|
694
|
+
exposures,
|
|
490
695
|
filter: parsed.data.filter ?? null,
|
|
491
696
|
completePeriodCutoffs: parsed.data.completePeriodCutoffs ?? [],
|
|
492
697
|
expectedCells: parsed.data.expectedCells ?? null,
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const DATA_PACKAGE_VERSION = "0.
|
|
1
|
+
export const DATA_PACKAGE_VERSION = "0.12.0";
|