@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.
@@ -350,12 +350,33 @@ const compactInputByCompletedRun = new WeakMap<
350
350
  CompletedCompactMetricDiagnosticsRun,
351
351
  CompactValidatedDiagnosticRunInput
352
352
  >();
353
- function freeze<T>(value: T, seen = new WeakSet<object>()): DiagnosticDeepReadonly<T> {
354
- if (value === null || typeof value !== "object" || seen.has(value))
355
- return value as DiagnosticDeepReadonly<T>;
356
- seen.add(value);
357
- for (const child of Object.values(value as Record<string, unknown>)) freeze(child, seen);
358
- return Object.freeze(value) as DiagnosticDeepReadonly<T>;
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: { readonly value: unknown; readonly path: string }[] = [{ value, path: "$" }];
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
- for (const [key, child] of Object.entries(current.value)) {
395
- const path = Array.isArray(current.value)
396
- ? `${current.path}[${key}]`
397
- : /^[A-Za-z_$][\w$]*$/.test(key)
398
- ? `${current.path}.${key}`
399
- : `${current.path}[${JSON.stringify(key)}]`;
400
- if (child === undefined)
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 stack.push({ value: child, path });
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
- const undefinedIssues = explicitUndefinedIssues(value);
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[] = parsed.data.losses.flatMap((row, index) =>
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 (parsed.data.exposures ?? []).entries()) {
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: parsed.data.losses,
489
- exposures: parsed.data.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.10.0";
1
+ export const DATA_PACKAGE_VERSION = "0.12.0";