tywrap 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +42 -22
  2. package/SECURITY.md +38 -0
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/index.js +8 -0
  5. package/dist/config/index.js.map +1 -1
  6. package/dist/core/annotation-parser.d.ts +2 -1
  7. package/dist/core/annotation-parser.d.ts.map +1 -1
  8. package/dist/core/annotation-parser.js +6 -3
  9. package/dist/core/annotation-parser.js.map +1 -1
  10. package/dist/core/generator.d.ts +23 -4
  11. package/dist/core/generator.d.ts.map +1 -1
  12. package/dist/core/generator.js +254 -170
  13. package/dist/core/generator.js.map +1 -1
  14. package/dist/core/mapper.d.ts +3 -2
  15. package/dist/core/mapper.d.ts.map +1 -1
  16. package/dist/core/mapper.js +5 -5
  17. package/dist/core/mapper.js.map +1 -1
  18. package/dist/index.d.ts +7 -5
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/runtime/base-bridge.d.ts +3 -7
  23. package/dist/runtime/base-bridge.d.ts.map +1 -1
  24. package/dist/runtime/base-bridge.js +4 -17
  25. package/dist/runtime/base-bridge.js.map +1 -1
  26. package/dist/runtime/bounded-context.d.ts +3 -22
  27. package/dist/runtime/bounded-context.d.ts.map +1 -1
  28. package/dist/runtime/bounded-context.js +13 -53
  29. package/dist/runtime/bounded-context.js.map +1 -1
  30. package/dist/runtime/bridge-codec.d.ts +3 -3
  31. package/dist/runtime/bridge-codec.d.ts.map +1 -1
  32. package/dist/runtime/bridge-codec.js +129 -62
  33. package/dist/runtime/bridge-codec.js.map +1 -1
  34. package/dist/runtime/errors.d.ts +16 -0
  35. package/dist/runtime/errors.d.ts.map +1 -1
  36. package/dist/runtime/errors.js +17 -0
  37. package/dist/runtime/errors.js.map +1 -1
  38. package/dist/runtime/http-transport.d.ts +1 -1
  39. package/dist/runtime/http-transport.d.ts.map +1 -1
  40. package/dist/runtime/http-transport.js +1 -1
  41. package/dist/runtime/http-transport.js.map +1 -1
  42. package/dist/runtime/index.d.ts +1 -0
  43. package/dist/runtime/index.d.ts.map +1 -1
  44. package/dist/runtime/index.js +1 -0
  45. package/dist/runtime/index.js.map +1 -1
  46. package/dist/runtime/node.d.ts +7 -36
  47. package/dist/runtime/node.d.ts.map +1 -1
  48. package/dist/runtime/node.js +2 -80
  49. package/dist/runtime/node.js.map +1 -1
  50. package/dist/runtime/pooled-transport.d.ts +120 -59
  51. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  52. package/dist/runtime/pooled-transport.js +345 -78
  53. package/dist/runtime/pooled-transport.js.map +1 -1
  54. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  55. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  56. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  57. package/dist/runtime/pyodide-transport.d.ts +1 -1
  58. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  59. package/dist/runtime/pyodide-transport.js +2 -3
  60. package/dist/runtime/pyodide-transport.js.map +1 -1
  61. package/dist/runtime/rpc-client.d.ts +7 -36
  62. package/dist/runtime/rpc-client.d.ts.map +1 -1
  63. package/dist/runtime/rpc-client.js +20 -102
  64. package/dist/runtime/rpc-client.js.map +1 -1
  65. package/dist/runtime/subprocess-transport.d.ts +30 -69
  66. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  67. package/dist/runtime/subprocess-transport.js +179 -236
  68. package/dist/runtime/subprocess-transport.js.map +1 -1
  69. package/dist/runtime/timed-out-request-tracker.d.ts +2 -1
  70. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -1
  71. package/dist/runtime/transport.d.ts +9 -19
  72. package/dist/runtime/transport.d.ts.map +1 -1
  73. package/dist/runtime/transport.js +1 -1
  74. package/dist/runtime/transport.js.map +1 -1
  75. package/dist/runtime/validators.d.ts +50 -0
  76. package/dist/runtime/validators.d.ts.map +1 -1
  77. package/dist/runtime/validators.js +154 -0
  78. package/dist/runtime/validators.js.map +1 -1
  79. package/dist/types/index.d.ts +14 -39
  80. package/dist/types/index.d.ts.map +1 -1
  81. package/dist/tywrap.d.ts +3 -2
  82. package/dist/tywrap.d.ts.map +1 -1
  83. package/dist/tywrap.js +140 -13
  84. package/dist/tywrap.js.map +1 -1
  85. package/dist/utils/cache.d.ts +3 -16
  86. package/dist/utils/cache.d.ts.map +1 -1
  87. package/dist/utils/codec.d.ts +17 -0
  88. package/dist/utils/codec.d.ts.map +1 -1
  89. package/dist/utils/codec.js +500 -85
  90. package/dist/utils/codec.js.map +1 -1
  91. package/dist/utils/ir-cache.d.ts +2 -1
  92. package/dist/utils/ir-cache.d.ts.map +1 -1
  93. package/dist/utils/runtime.d.ts +0 -29
  94. package/dist/utils/runtime.d.ts.map +1 -1
  95. package/dist/utils/runtime.js +16 -107
  96. package/dist/utils/runtime.js.map +1 -1
  97. package/dist/version.d.ts.map +1 -1
  98. package/dist/version.js +1 -1
  99. package/dist/version.js.map +1 -1
  100. package/package.json +7 -6
  101. package/runtime/frame_codec.py +7 -1
  102. package/runtime/python_bridge.py +85 -125
  103. package/runtime/tywrap_bridge_core.py +515 -187
  104. package/src/config/index.ts +11 -0
  105. package/src/core/annotation-parser.ts +7 -4
  106. package/src/core/generator.ts +330 -205
  107. package/src/core/mapper.ts +8 -8
  108. package/src/index.ts +7 -4
  109. package/src/runtime/base-bridge.ts +5 -30
  110. package/src/runtime/bounded-context.ts +12 -67
  111. package/src/runtime/bridge-codec.ts +158 -74
  112. package/src/runtime/errors.ts +21 -0
  113. package/src/runtime/http-transport.ts +6 -1
  114. package/src/runtime/index.ts +6 -0
  115. package/src/runtime/node.ts +9 -120
  116. package/src/runtime/pooled-transport.ts +424 -90
  117. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  118. package/src/runtime/pyodide-transport.ts +7 -3
  119. package/src/runtime/rpc-client.ts +37 -139
  120. package/src/runtime/subprocess-transport.ts +210 -285
  121. package/src/runtime/timed-out-request-tracker.ts +1 -1
  122. package/src/runtime/transport.ts +15 -23
  123. package/src/runtime/validators.ts +212 -0
  124. package/src/types/index.ts +21 -60
  125. package/src/tywrap.ts +157 -22
  126. package/src/utils/cache.ts +3 -3
  127. package/src/utils/codec.ts +749 -112
  128. package/src/utils/ir-cache.ts +1 -1
  129. package/src/utils/runtime.ts +17 -128
  130. package/src/version.ts +1 -1
  131. package/dist/core/discovery.d.ts +0 -103
  132. package/dist/core/discovery.d.ts.map +0 -1
  133. package/dist/core/discovery.js +0 -380
  134. package/dist/core/discovery.js.map +0 -1
  135. package/dist/core/validation.d.ts +0 -102
  136. package/dist/core/validation.d.ts.map +0 -1
  137. package/dist/core/validation.js +0 -490
  138. package/dist/core/validation.js.map +0 -1
  139. package/dist/runtime/base.d.ts +0 -22
  140. package/dist/runtime/base.d.ts.map +0 -1
  141. package/dist/runtime/base.js +0 -23
  142. package/dist/runtime/base.js.map +0 -1
  143. package/dist/runtime/transport-pool.d.ts +0 -196
  144. package/dist/runtime/transport-pool.d.ts.map +0 -1
  145. package/dist/runtime/transport-pool.js +0 -418
  146. package/dist/runtime/transport-pool.js.map +0 -1
  147. package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
  148. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  149. package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
  150. package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
  151. package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
  152. package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
  153. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  154. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  155. package/runtime/safe_codec.py +0 -352
  156. package/src/core/discovery.ts +0 -477
  157. package/src/core/validation.ts +0 -729
  158. package/src/runtime/base.ts +0 -24
  159. package/src/runtime/transport-pool.ts +0 -538
@@ -9,6 +9,47 @@
9
9
  * Sklearn estimators: { "__tywrap__": "sklearn.estimator", "encoding": "json", ... }
10
10
  */
11
11
 
12
+ import { tagDecodedShape } from '../runtime/validators.js';
13
+
14
+ const SCIENTIFIC_MARKERS = [
15
+ 'dataframe',
16
+ 'series',
17
+ 'ndarray',
18
+ 'scipy.sparse',
19
+ 'torch.tensor',
20
+ 'sklearn.estimator',
21
+ ] as const;
22
+
23
+ export type ScientificMarker = (typeof SCIENTIFIC_MARKERS)[number];
24
+
25
+ export function isScientificMarker(value: unknown): value is ScientificMarker {
26
+ return SCIENTIFIC_MARKERS.includes(value as ScientificMarker);
27
+ }
28
+
29
+ type ScientificDecodeErrorKind = 'arrow' | 'envelope';
30
+ type ScientificDecodeErrorMarker = ScientificMarker | 'unknown';
31
+
32
+ /** @internal Structured failure consumed by BridgeCodec without parsing messages. */
33
+ export class ScientificDecodeError extends Error {
34
+ constructor(
35
+ readonly kind: ScientificDecodeErrorKind,
36
+ readonly marker: ScientificDecodeErrorMarker,
37
+ override readonly cause: unknown,
38
+ message = cause instanceof Error ? cause.message : String(cause)
39
+ ) {
40
+ super(message, { cause });
41
+ }
42
+ }
43
+
44
+ function asScientificDecodeError(
45
+ error: unknown,
46
+ marker: ScientificDecodeErrorMarker
47
+ ): ScientificDecodeError {
48
+ return error instanceof ScientificDecodeError
49
+ ? error
50
+ : new ScientificDecodeError('envelope', marker, error);
51
+ }
52
+
12
53
  // Avoid hard dependency on apache-arrow types at compile time to keep install optional.
13
54
  export type ArrowTable = { readonly numCols?: number; readonly numRows?: number } & Record<
14
55
  string,
@@ -31,6 +72,8 @@ export interface TorchTensor {
31
72
  shape?: readonly number[];
32
73
  dtype?: string;
33
74
  device?: string;
75
+ sourceDtype?: string;
76
+ sourceDevice?: string;
34
77
  }
35
78
 
36
79
  export interface SklearnEstimator {
@@ -68,6 +111,7 @@ export type ValueEnvelope =
68
111
  readonly b64?: string; // when encoding=arrow
69
112
  readonly data?: unknown; // when encoding=json
70
113
  readonly shape?: readonly number[];
114
+ readonly dtype?: string | null;
71
115
  }
72
116
  | {
73
117
  readonly __tywrap__: 'scipy.sparse';
@@ -90,6 +134,8 @@ export type ValueEnvelope =
90
134
  readonly shape?: readonly number[];
91
135
  readonly dtype?: string;
92
136
  readonly device?: string;
137
+ readonly sourceDtype?: string;
138
+ readonly sourceDevice?: string;
93
139
  }
94
140
  | {
95
141
  readonly __tywrap__: 'sklearn.estimator';
@@ -229,7 +275,15 @@ function isNumberArray(value: unknown): value is number[] {
229
275
  return Array.isArray(value) && value.every(item => typeof item === 'number');
230
276
  }
231
277
 
232
- function fromBase64(b64: string): Uint8Array {
278
+ const STRICT_BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
279
+
280
+ function fromBase64(b64: string, marker?: string, context?: string): Uint8Array {
281
+ if (marker && !STRICT_BASE64_PATTERN.test(b64)) {
282
+ throw new Error(
283
+ `Invalid ${marker} envelope: b64 at path b64 must be well-formed base64; ` +
284
+ `${context ? `${context}; ` : ''}actual count unknown, actual type string with length ${b64.length}`
285
+ );
286
+ }
233
287
  if (typeof Buffer !== 'undefined') {
234
288
  const buf = Buffer.from(b64, 'base64');
235
289
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.length);
@@ -250,9 +304,11 @@ const ARROW_MISSING_MESSAGE =
250
304
  'TYWRAP_CODEC_FALLBACK=json on the Python side to receive JSON instead ' +
251
305
  '(lossy for dtype/NA fidelity). tywrap never silently downgrades Arrow payloads.';
252
306
 
253
- function requireArrowDecoder(): (bytes: Uint8Array) => ArrowTable | Uint8Array {
307
+ function requireArrowDecoder(
308
+ marker: ScientificMarker
309
+ ): (bytes: Uint8Array) => ArrowTable | Uint8Array {
254
310
  if (!arrowTableFrom) {
255
- throw new Error(ARROW_MISSING_MESSAGE);
311
+ throw new ScientificDecodeError('arrow', marker, new Error(ARROW_MISSING_MESSAGE));
256
312
  }
257
313
  return arrowTableFrom;
258
314
  }
@@ -265,7 +321,9 @@ function requireArrowDecoder(): (bytes: Uint8Array) => ArrowTable | Uint8Array {
265
321
  * for the rest of the process. If apache-arrow is absent we throw a clear, actionable
266
322
  * error rather than silently producing wrong data.
267
323
  */
268
- async function ensureArrowDecoder(): Promise<(bytes: Uint8Array) => ArrowTable | Uint8Array> {
324
+ async function ensureArrowDecoder(
325
+ marker: ScientificMarker
326
+ ): Promise<(bytes: Uint8Array) => ArrowTable | Uint8Array> {
269
327
  if (arrowTableFrom) {
270
328
  return arrowTableFrom;
271
329
  }
@@ -275,21 +333,30 @@ async function ensureArrowDecoder(): Promise<(bytes: Uint8Array) => ArrowTable |
275
333
  );
276
334
  await lazyRegistration;
277
335
  if (!arrowTableFrom) {
278
- throw new Error(ARROW_MISSING_MESSAGE);
336
+ throw new ScientificDecodeError('arrow', marker, new Error(ARROW_MISSING_MESSAGE));
279
337
  }
280
338
  return arrowTableFrom;
281
339
  }
282
340
 
283
- async function tryDecodeArrowTable(bytes: Uint8Array): Promise<ArrowTable | Uint8Array> {
284
- const decoder = await ensureArrowDecoder();
341
+ async function tryDecodeArrowTable(
342
+ bytes: Uint8Array,
343
+ marker: ScientificMarker
344
+ ): Promise<ArrowTable | Uint8Array> {
345
+ const decoder = await ensureArrowDecoder(marker);
285
346
  try {
286
347
  return decoder(bytes);
287
348
  } catch (err) {
288
- throw new Error(`Arrow decode failed: ${err instanceof Error ? err.message : String(err)}`);
349
+ throw new ScientificDecodeError(
350
+ 'arrow',
351
+ marker,
352
+ err,
353
+ `Arrow decode failed: ${err instanceof Error ? err.message : String(err)}`
354
+ );
289
355
  }
290
356
  }
291
357
 
292
358
  type MaybePromise<T> = T | Promise<T>;
359
+ type DecodeArrow<T> = (bytes: Uint8Array, marker: ScientificMarker) => MaybePromise<T>;
293
360
 
294
361
  function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
295
362
  return (
@@ -309,7 +376,7 @@ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
309
376
  * @param arr - Typed array or plain array
310
377
  * @returns Plain JavaScript array with values converted (BigInt → Number where safe)
311
378
  */
312
- function typedArrayToPlain(arr: unknown): unknown[] {
379
+ function typedArrayToPlain(arr: unknown): unknown[] | null {
313
380
  if (Array.isArray(arr)) {
314
381
  return arr;
315
382
  }
@@ -330,8 +397,7 @@ function typedArrayToPlain(arr: unknown): unknown[] {
330
397
  if (arr !== null && arr !== undefined && typeof arr === 'object' && Symbol.iterator in arr) {
331
398
  return Array.from(arr as Iterable<unknown>);
332
399
  }
333
- // Non-iterable: return empty array (shouldn't happen with valid Arrow data)
334
- return [];
400
+ return null;
335
401
  }
336
402
 
337
403
  /**
@@ -355,6 +421,30 @@ function extractArrowValues(data: unknown): unknown[] | null {
355
421
  return null;
356
422
  }
357
423
 
424
+ function extractNdarrayArrowValues(
425
+ data: unknown,
426
+ shape: readonly number[] | undefined,
427
+ dtype: string | undefined
428
+ ): unknown[] {
429
+ try {
430
+ const values = extractArrowValues(data);
431
+ if (values) {
432
+ return values;
433
+ }
434
+ } catch (error) {
435
+ throw new Error(
436
+ `Invalid ndarray envelope: b64 decoded data extraction failed at path b64 for declared ` +
437
+ `shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; actual count unknown, ` +
438
+ `actual type ${actualType(data)} (${error instanceof Error ? error.message : String(error)})`
439
+ );
440
+ }
441
+ throw new Error(
442
+ `Invalid ndarray envelope: b64 decoded data at path b64 could not extract Arrow values ` +
443
+ `for declared shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; ` +
444
+ `actual count unknown, actual type ${actualType(data)}`
445
+ );
446
+ }
447
+
358
448
  /**
359
449
  * Reshape a flat array into a multi-dimensional nested array.
360
450
  *
@@ -394,6 +484,168 @@ function reshapeArray(flat: unknown[], shape: readonly number[]): unknown {
394
484
  // Why: decoding needs to reject incompatible envelopes before we attempt to interpret payloads.
395
485
  const CODEC_VERSION = 1;
396
486
 
487
+ function isStrictV1Envelope(envelope: { codecVersion?: unknown }): boolean {
488
+ return envelope.codecVersion === CODEC_VERSION;
489
+ }
490
+
491
+ function actualType(value: unknown): string {
492
+ if (Array.isArray(value)) {
493
+ return `array(length=${value.length})`;
494
+ }
495
+ if (value === null) {
496
+ return 'null';
497
+ }
498
+ if (typeof value === 'number' && !Number.isFinite(value)) {
499
+ return `number(${String(value)})`;
500
+ }
501
+ return typeof value;
502
+ }
503
+
504
+ function assertShape(
505
+ shapeValue: unknown,
506
+ marker: 'ndarray' | 'torch.tensor' | 'scipy.sparse',
507
+ field = 'shape',
508
+ dtype?: unknown
509
+ ): number[] {
510
+ if (!Array.isArray(shapeValue)) {
511
+ throw new Error(
512
+ `Invalid ${marker} envelope: ${field} at path ${field} must be a list of ` +
513
+ `non-negative safe integers; declared shape ${String(shapeValue)} and dtype ` +
514
+ `${JSON.stringify(dtype)}, actual count unknown, actual type ${actualType(shapeValue)}`
515
+ );
516
+ }
517
+ for (let i = 0; i < shapeValue.length; i += 1) {
518
+ const dim = shapeValue[i];
519
+ if (typeof dim !== 'number' || !Number.isSafeInteger(dim) || dim < 0) {
520
+ throw new Error(
521
+ `Invalid ${marker} envelope: ${field}[${i}]=${String(dim)} must be a non-negative integer ` +
522
+ `within the safe range for declared shape ${JSON.stringify(shapeValue)}; ` +
523
+ `declared dtype ${JSON.stringify(dtype)}, actual count unknown, ` +
524
+ `actual type ${actualType(dim)} with value ${String(dim)}`
525
+ );
526
+ }
527
+ }
528
+ return shapeValue as number[];
529
+ }
530
+
531
+ function assertOptionalNonEmptyDtype(
532
+ value: unknown,
533
+ marker: 'ndarray' | 'torch.tensor' | 'scipy.sparse',
534
+ field = 'dtype',
535
+ shape?: unknown
536
+ ): string | undefined {
537
+ if (value === undefined) {
538
+ return undefined;
539
+ }
540
+ if (typeof value !== 'string' || value.length === 0) {
541
+ throw new Error(
542
+ `Invalid ${marker} envelope: ${field} at path ${field} must be a non-empty string; ` +
543
+ `declared shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(value)}, ` +
544
+ `actual count unknown, actual type ${actualType(value)}`
545
+ );
546
+ }
547
+ return value;
548
+ }
549
+
550
+ type ShapedScientificMarker = 'ndarray' | 'torch.tensor' | 'scipy.sparse';
551
+
552
+ function readEnvelopeShape(
553
+ envelope: { codecVersion?: unknown },
554
+ shapeValue: unknown,
555
+ marker: ShapedScientificMarker,
556
+ field = 'shape',
557
+ dtype?: unknown
558
+ ): number[] | undefined {
559
+ return isStrictV1Envelope(envelope)
560
+ ? assertShape(shapeValue, marker, field, dtype)
561
+ : isNumberArray(shapeValue)
562
+ ? shapeValue
563
+ : undefined;
564
+ }
565
+
566
+ function readEnvelopeDtype(
567
+ envelope: { codecVersion?: unknown },
568
+ dtypeValue: unknown,
569
+ marker: ShapedScientificMarker,
570
+ field = 'dtype',
571
+ shape?: unknown
572
+ ): string | undefined {
573
+ return isStrictV1Envelope(envelope)
574
+ ? assertOptionalNonEmptyDtype(dtypeValue, marker, field, shape)
575
+ : typeof dtypeValue === 'string'
576
+ ? dtypeValue
577
+ : undefined;
578
+ }
579
+
580
+ function countJsonLeaves(value: unknown): number {
581
+ if (!Array.isArray(value)) {
582
+ return 1;
583
+ }
584
+ return value.reduce((count, item) => count + countJsonLeaves(item), 0);
585
+ }
586
+
587
+ function assertJsonMatchesShape(
588
+ data: unknown,
589
+ shape: readonly number[],
590
+ dtype: string | undefined
591
+ ): void {
592
+ const declaredCount = shapeProduct(shape);
593
+ const strictLeaves = dtype !== undefined;
594
+ const actualCount = strictLeaves ? countJsonLeaves(data) : undefined;
595
+ const visit = (value: unknown, depth: number, path: string): void => {
596
+ if (depth === shape.length) {
597
+ if (strictLeaves && Array.isArray(value)) {
598
+ throw new Error(
599
+ `Invalid ndarray envelope: data at path ${path} exceeds nesting depth ` +
600
+ `${shape.length} for declared shape ${JSON.stringify(shape)} and dtype ` +
601
+ `${JSON.stringify(dtype)}; actual count ${actualCount}, actual type ${actualType(value)}`
602
+ );
603
+ }
604
+ return;
605
+ }
606
+ if (!Array.isArray(value)) {
607
+ throw new Error(
608
+ `Invalid ndarray envelope: data at path ${path} must be an array at depth ${depth} ` +
609
+ `for declared shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; ` +
610
+ `actual count ${actualCount ?? 'unknown'}, actual type ${actualType(value)}`
611
+ );
612
+ }
613
+ const expectedLength = shape[depth] as number;
614
+ if (value.length !== expectedLength) {
615
+ throw new Error(
616
+ `Invalid ndarray envelope: data at path ${path} has length ${value.length}, expected ` +
617
+ `${expectedLength} for declared shape ${JSON.stringify(shape)} and dtype ` +
618
+ `${JSON.stringify(dtype)}; declared count ${declaredCount}, actual count ${actualCount ?? 'unknown'}, ` +
619
+ `actual type ${actualType(value)}`
620
+ );
621
+ }
622
+ value.forEach((item, index) => visit(item, depth + 1, `${path}[${index}]`));
623
+ };
624
+ visit(data, 0, 'data');
625
+ if (strictLeaves && actualCount !== declaredCount) {
626
+ throw new Error(
627
+ `Invalid ndarray envelope: data at path data has leaf count ${actualCount}, expected ` +
628
+ `${declaredCount} for declared shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; ` +
629
+ `actual type ${actualType(data)}`
630
+ );
631
+ }
632
+ }
633
+
634
+ function assertArrowCount(
635
+ values: readonly unknown[],
636
+ shape: readonly number[],
637
+ dtype: string
638
+ ): void {
639
+ const expected = shapeProduct(shape);
640
+ if (values.length !== expected) {
641
+ throw new Error(
642
+ `Invalid ndarray envelope: b64 decoded data at path b64 has element count ${values.length}, ` +
643
+ `expected ${expected} for declared shape ${JSON.stringify(shape)} and dtype ` +
644
+ `${JSON.stringify(dtype)}; actual type array(length=${values.length})`
645
+ );
646
+ }
647
+ }
648
+
397
649
  function assertCodecVersion(envelope: { codecVersion?: unknown }, typeTag: string): void {
398
650
  if (!('codecVersion' in envelope)) {
399
651
  return;
@@ -420,7 +672,7 @@ function assertCodecVersion(envelope: { codecVersion?: unknown }, typeTag: strin
420
672
  */
421
673
  type EnvelopeHandler = <T>(
422
674
  value: { [k: string]: unknown },
423
- decodeArrow: (bytes: Uint8Array) => MaybePromise<T>,
675
+ decodeArrow: DecodeArrow<T>,
424
676
  recurse: (value: unknown) => MaybePromise<T | unknown>
425
677
  ) => MaybePromise<T | unknown>;
426
678
 
@@ -432,8 +684,8 @@ type EnvelopeHandler = <T>(
432
684
  */
433
685
  function decodeArrowOrJsonEnvelope<T>(
434
686
  value: { [k: string]: unknown },
435
- decodeArrow: (bytes: Uint8Array) => MaybePromise<T>,
436
- typeTag: string
687
+ decodeArrow: DecodeArrow<T>,
688
+ typeTag: 'dataframe' | 'series'
437
689
  ): MaybePromise<T | unknown> {
438
690
  const encoding = value.encoding;
439
691
  if (encoding === 'arrow') {
@@ -441,14 +693,17 @@ function decodeArrowOrJsonEnvelope<T>(
441
693
  if (typeof b64 !== 'string') {
442
694
  throw new Error(`Invalid ${typeTag} envelope: missing b64`);
443
695
  }
444
- const bytes = fromBase64(b64);
445
- return decodeArrow(bytes);
696
+ const bytes = fromBase64(b64, isStrictV1Envelope(value) ? typeTag : undefined);
697
+ const decoded = decodeArrow(bytes, typeTag);
698
+ return isPromiseLike(decoded)
699
+ ? decoded.then(item => tagDecodedShape(item, { marker: typeTag }))
700
+ : tagDecodedShape(decoded, { marker: typeTag });
446
701
  }
447
702
  if (encoding === 'json') {
448
703
  if (!('data' in value)) {
449
704
  throw new Error(`Invalid ${typeTag} envelope: missing data`);
450
705
  }
451
- return value.data;
706
+ return tagDecodedShape(value.data, { marker: typeTag });
452
707
  }
453
708
  throw new Error(`Invalid ${typeTag} envelope: unsupported encoding ${String(encoding)}`);
454
709
  }
@@ -459,51 +714,83 @@ const decodeDataframeEnvelope: EnvelopeHandler = (value, decodeArrow) =>
459
714
  const decodeSeriesEnvelope: EnvelopeHandler = (value, decodeArrow) =>
460
715
  decodeArrowOrJsonEnvelope(value, decodeArrow, 'series');
461
716
 
717
+ function finishNdarrayDecode(
718
+ data: unknown,
719
+ strictV1: boolean,
720
+ shape: readonly number[] | undefined,
721
+ dtype: string | undefined,
722
+ metadata: { marker: 'ndarray'; dims: number | undefined; dtype: string | undefined }
723
+ ): unknown {
724
+ const values = strictV1
725
+ ? extractNdarrayArrowValues(data, shape, dtype)
726
+ : extractArrowValues(data);
727
+ if (!values) {
728
+ return tagDecodedShape(data, metadata);
729
+ }
730
+ if (strictV1) {
731
+ assertArrowCount(values, shape as number[], dtype as string);
732
+ }
733
+ return tagDecodedShape(
734
+ shape && shape.length !== 1 ? reshapeArray(values, shape) : values,
735
+ metadata
736
+ );
737
+ }
738
+
462
739
  const decodeNdarrayEnvelope: EnvelopeHandler = (value, decodeArrow) => {
463
740
  const encoding = value.encoding;
464
- const shapeValue = value.shape;
465
- const shape = isNumberArray(shapeValue) ? shapeValue : undefined;
741
+ const strictV1 = isStrictV1Envelope(value);
742
+ const shape = readEnvelopeShape(value, value.shape, 'ndarray', 'shape', value.dtype);
743
+ const dtype = readEnvelopeDtype(value, value.dtype, 'ndarray', 'dtype', shape);
744
+ const metadata = { marker: 'ndarray' as const, dims: shape?.length, dtype };
466
745
 
467
746
  if (encoding === 'arrow') {
747
+ if (strictV1 && dtype === undefined) {
748
+ throw new Error(
749
+ `Invalid ndarray envelope: dtype at path dtype is required for Arrow encoding; ` +
750
+ `declared shape ${JSON.stringify(shape)} and dtype undefined, actual count unknown, actual type undefined`
751
+ );
752
+ }
468
753
  const b64 = value.b64;
469
754
  if (typeof b64 !== 'string') {
470
- throw new Error('Invalid ndarray envelope: missing b64');
755
+ if (!strictV1) {
756
+ throw new Error('Invalid ndarray envelope: missing b64');
757
+ }
758
+ throw new Error(
759
+ `Invalid ndarray envelope: b64 at path b64 must be a base64 string for declared ` +
760
+ `shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; actual count unknown, ` +
761
+ `actual type ${actualType(b64)}`
762
+ );
471
763
  }
472
- const bytes = fromBase64(b64);
473
- const decoded = decodeArrow(bytes);
764
+ const bytes = fromBase64(
765
+ b64,
766
+ strictV1 ? 'ndarray' : undefined,
767
+ `declared shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}`
768
+ );
769
+ const decoded = decodeArrow(bytes, 'ndarray');
474
770
 
475
771
  // Extract values from Arrow table and reshape if needed
476
772
  // Arrow only handles 1D arrays, so we flatten on encode and reshape here
477
773
  // Reshape for: scalars (shape.length === 0) and multi-dim (shape.length > 1)
478
774
  // Skip reshape for: 1D arrays (shape.length === 1) - return as-is
479
775
  if (isPromiseLike(decoded)) {
480
- return decoded.then(data => {
481
- const values = extractArrowValues(data);
482
- if (!values) {
483
- return data; // Fallback: return raw data if extraction fails
484
- }
485
- // Reshape scalars and multi-dimensional arrays, but not 1D
486
- if (shape && shape.length !== 1) {
487
- return reshapeArray(values, shape);
488
- }
489
- return values;
490
- });
491
- }
492
- const values = extractArrowValues(decoded);
493
- if (!values) {
494
- return decoded; // Fallback: return raw data if extraction fails
776
+ return decoded.then(data => finishNdarrayDecode(data, strictV1, shape, dtype, metadata));
495
777
  }
496
- // Reshape scalars and multi-dimensional arrays, but not 1D
497
- if (shape && shape.length !== 1) {
498
- return reshapeArray(values, shape);
499
- }
500
- return values;
778
+ return finishNdarrayDecode(decoded, strictV1, shape, dtype, metadata);
501
779
  }
502
780
  if (encoding === 'json') {
503
781
  if (!('data' in value)) {
504
- throw new Error('Invalid ndarray envelope: missing data');
782
+ if (!strictV1) {
783
+ throw new Error('Invalid ndarray envelope: missing data');
784
+ }
785
+ throw new Error(
786
+ `Invalid ndarray envelope: data at path data is required for declared shape ` +
787
+ `${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; actual count 0, actual type undefined`
788
+ );
789
+ }
790
+ if (strictV1) {
791
+ assertJsonMatchesShape(value.data, shape as number[], dtype);
505
792
  }
506
- return value.data;
793
+ return tagDecodedShape(value.data, metadata);
507
794
  }
508
795
  throw new Error(`Invalid ndarray envelope: unsupported encoding ${String(encoding)}`);
509
796
  };
@@ -533,6 +820,53 @@ function assertIndexArrayInRange(arr: readonly unknown[], bound: number, label:
533
820
  }
534
821
  }
535
822
 
823
+ function assertSparseDataDomain(
824
+ data: readonly unknown[],
825
+ dtype: string,
826
+ shape: readonly number[]
827
+ ): void {
828
+ const expectsBoolean = dtype === 'bool' || dtype === 'bool_';
829
+ const integerMatch = /^(u?int)(\d*)$/.exec(dtype);
830
+ const expectsInteger = integerMatch !== null;
831
+ const expectsFloat = /^float\d*$/.test(dtype);
832
+ const width = integerMatch?.[2] ? Number(integerMatch[2]) : undefined;
833
+ const unsignedInteger = integerMatch?.[1] === 'uint';
834
+ let integerMin: number | undefined;
835
+ let integerMax: number | undefined;
836
+ if (width === 8 || width === 16 || width === 32) {
837
+ integerMin = unsignedInteger ? 0 : -(2 ** (width - 1));
838
+ integerMax = unsignedInteger ? 2 ** width - 1 : 2 ** (width - 1) - 1;
839
+ }
840
+
841
+ for (let i = 0; i < data.length; i += 1) {
842
+ const item = data[i];
843
+ if (typeof item === 'number' && !Number.isFinite(item)) {
844
+ throw new Error(
845
+ `Invalid scipy.sparse envelope: data[${i}] at path data[${i}] must be finite for ` +
846
+ `declared shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; ` +
847
+ `actual count ${data.length}, actual type number with value ${String(item)}`
848
+ );
849
+ }
850
+ const valid = expectsBoolean
851
+ ? typeof item === 'boolean'
852
+ : expectsInteger
853
+ ? typeof item === 'number' &&
854
+ Number.isInteger(item) &&
855
+ (integerMin === undefined || item >= integerMin) &&
856
+ (integerMax === undefined || item <= integerMax)
857
+ : expectsFloat
858
+ ? typeof item === 'number'
859
+ : true;
860
+ if (!valid) {
861
+ throw new Error(
862
+ `Invalid scipy.sparse envelope: data[${i}] at path data[${i}] is incompatible with ` +
863
+ `declared shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; ` +
864
+ `actual count ${data.length}, actual type ${actualType(item)} with value ${String(item)}`
865
+ );
866
+ }
867
+ }
868
+ }
869
+
536
870
  const decodeScipySparseEnvelope: EnvelopeHandler = value => {
537
871
  const encoding = value.encoding;
538
872
  if (encoding !== 'json') {
@@ -542,7 +876,8 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
542
876
  if (format !== 'csr' && format !== 'csc' && format !== 'coo') {
543
877
  throw new Error(`Invalid scipy.sparse envelope: unsupported format ${String(format)}`);
544
878
  }
545
- const shape = value.shape;
879
+ const strictV1 = isStrictV1Envelope(value);
880
+ const shape = readEnvelopeShape(value, value.shape, 'scipy.sparse', 'shape', value.dtype);
546
881
  if (
547
882
  !Array.isArray(shape) ||
548
883
  shape.length !== 2 ||
@@ -561,8 +896,16 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
561
896
  if (!Array.isArray(data)) {
562
897
  throw new Error('Invalid scipy.sparse envelope: data must be an array');
563
898
  }
564
- const dtypeValue = value.dtype;
565
- const dtype = typeof dtypeValue === 'string' ? dtypeValue : undefined;
899
+ const dtype = readEnvelopeDtype(value, value.dtype, 'scipy.sparse', 'dtype', shape);
900
+ if (strictV1 && dtype === undefined) {
901
+ throw new Error(
902
+ `Invalid scipy.sparse envelope: dtype at path dtype is required for declared shape ` +
903
+ `${JSON.stringify(shape)}; actual count ${data.length}, actual type undefined`
904
+ );
905
+ }
906
+ if (strictV1) {
907
+ assertSparseDataDomain(data, dtype as string, shape);
908
+ }
566
909
 
567
910
  if (format === 'coo') {
568
911
  const row = value.row;
@@ -580,14 +923,17 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
580
923
  }
581
924
  assertIndexArrayInRange(row, rows, 'row');
582
925
  assertIndexArrayInRange(col, cols, 'col');
583
- return {
584
- format,
585
- shape,
586
- data,
587
- row,
588
- col,
589
- dtype,
590
- } satisfies SparseMatrix;
926
+ return tagDecodedShape(
927
+ {
928
+ format,
929
+ shape,
930
+ data,
931
+ row,
932
+ col,
933
+ dtype,
934
+ } satisfies SparseMatrix,
935
+ { marker: 'scipy.sparse', dims: shape.length, dtype }
936
+ );
591
937
  }
592
938
 
593
939
  const indices = value.indices;
@@ -641,14 +987,17 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
641
987
  );
642
988
  }
643
989
  assertIndexArrayInRange(indices, minorAxis, 'indices');
644
- return {
645
- format,
646
- shape,
647
- data,
648
- indices,
649
- indptr,
650
- dtype,
651
- } satisfies SparseMatrix;
990
+ return tagDecodedShape(
991
+ {
992
+ format,
993
+ shape,
994
+ data,
995
+ indices,
996
+ indptr,
997
+ dtype,
998
+ } satisfies SparseMatrix,
999
+ { marker: 'scipy.sparse', dims: shape.length, dtype }
1000
+ );
652
1001
  };
653
1002
 
654
1003
  /** Product of a shape's dimensions (the element count). [] (scalar) -> 1. */
@@ -658,7 +1007,7 @@ function shapeProduct(shape: readonly number[]): number {
658
1007
 
659
1008
  const decodeTorchTensorEnvelope: EnvelopeHandler = <T>(
660
1009
  value: { [k: string]: unknown },
661
- _decodeArrow: (bytes: Uint8Array) => MaybePromise<T>,
1010
+ _decodeArrow: DecodeArrow<T>,
662
1011
  recurse: (value: unknown) => MaybePromise<T | unknown>
663
1012
  ): MaybePromise<T | unknown> => {
664
1013
  const encoding = value.encoding;
@@ -672,34 +1021,49 @@ const decodeTorchTensorEnvelope: EnvelopeHandler = <T>(
672
1021
  if (!isObject(nested) || (nested as { __tywrap__?: unknown }).__tywrap__ !== 'ndarray') {
673
1022
  throw new Error('Invalid torch.tensor envelope: value must be an ndarray envelope');
674
1023
  }
675
- const shapeValue = value.shape;
676
- const shape = isNumberArray(shapeValue) ? shapeValue : undefined;
677
- // The tensor shape must be a non-negative-integer dimension list. A negative or
678
- // non-integer dim is a corrupt envelope, not a valid tensor.
679
- if (shape) {
680
- for (let i = 0; i < shape.length; i += 1) {
681
- const dim = shape[i] as number;
682
- if (!Number.isInteger(dim) || dim < 0) {
683
- throw new Error(
684
- `Invalid torch.tensor envelope: shape[${i}]=${dim} must be a non-negative integer`
685
- );
686
- }
687
- }
688
- }
689
- // Cross-check the tensor shape's element count against the nested ndarray's
690
- // declared shape (metadata only — no decode needed). A mismatch means the two
691
- // shapes disagree about how many elements the payload holds.
1024
+ const strictV1 = isStrictV1Envelope(value);
1025
+ const shape = readEnvelopeShape(value, value.shape, 'torch.tensor', 'shape', value.dtype);
692
1026
  const nestedShapeValue = (nested as { shape?: unknown }).shape;
693
- const nestedShape = isNumberArray(nestedShapeValue) ? nestedShapeValue : undefined;
694
- if (shape && nestedShape && shapeProduct(shape) !== shapeProduct(nestedShape)) {
1027
+ const nestedShape = readEnvelopeShape(
1028
+ value,
1029
+ nestedShapeValue,
1030
+ 'torch.tensor',
1031
+ 'value.shape',
1032
+ nested.dtype
1033
+ );
1034
+ const scalarNormalization =
1035
+ shape !== undefined &&
1036
+ nestedShape !== undefined &&
1037
+ ((shape.length === 0 && nestedShape.length === 1 && nestedShape[0] === 1) ||
1038
+ (nestedShape.length === 0 && shape.length === 1 && shape[0] === 1));
1039
+ const shapesEqual =
1040
+ shape !== undefined &&
1041
+ nestedShape !== undefined &&
1042
+ shape.length === nestedShape.length &&
1043
+ shape.every((dim, index) => dim === nestedShape[index]);
1044
+ const shapesDisagree = strictV1
1045
+ ? !shapesEqual && !scalarNormalization
1046
+ : shape !== undefined &&
1047
+ nestedShape !== undefined &&
1048
+ shapeProduct(shape) !== shapeProduct(nestedShape);
1049
+ if (shape && nestedShape && shapesDisagree) {
1050
+ throw new Error(
1051
+ `Invalid torch.tensor envelope: shape at path shape ${JSON.stringify(shape)} disagrees ` +
1052
+ `with nested ndarray shape at path value.shape ${JSON.stringify(nestedShape)}; exact shapes are required ` +
1053
+ `(except scalar []/[1]), declared dtypes ${JSON.stringify(value.dtype)}/${JSON.stringify(nested.dtype)}, ` +
1054
+ `actual counts ${shapeProduct(shape)} and ${shapeProduct(nestedShape)}`
1055
+ );
1056
+ }
1057
+ const dtype = readEnvelopeDtype(value, value.dtype, 'torch.tensor', 'dtype', shape);
1058
+ if (strictV1 && dtype === undefined) {
695
1059
  throw new Error(
696
- `Invalid torch.tensor envelope: shape ${JSON.stringify(shape)} ` +
697
- `(product ${shapeProduct(shape)}) disagrees with nested ndarray shape ` +
698
- `${JSON.stringify(nestedShape)} (product ${shapeProduct(nestedShape)})`
1060
+ `Invalid torch.tensor envelope: dtype at path dtype is required for declared shape ` +
1061
+ `${JSON.stringify(shape)}; actual count ${shapeProduct(shape as number[])}, actual type undefined`
699
1062
  );
700
1063
  }
701
- const dtypeValue = value.dtype;
702
- const dtype = typeof dtypeValue === 'string' ? dtypeValue : undefined;
1064
+ if (strictV1 && 'dtype' in nested) {
1065
+ assertOptionalNonEmptyDtype(nested.dtype, 'torch.tensor', 'value.dtype', nestedShape);
1066
+ }
703
1067
  const deviceValue = value.device;
704
1068
  if (deviceValue !== undefined && (typeof deviceValue !== 'string' || deviceValue.length === 0)) {
705
1069
  throw new Error(
@@ -707,12 +1071,45 @@ const decodeTorchTensorEnvelope: EnvelopeHandler = <T>(
707
1071
  );
708
1072
  }
709
1073
  const device = typeof deviceValue === 'string' ? deviceValue : undefined;
1074
+ const sourceDtypeValue = value.sourceDtype;
1075
+ if (
1076
+ sourceDtypeValue !== undefined &&
1077
+ (typeof sourceDtypeValue !== 'string' || sourceDtypeValue.length === 0)
1078
+ ) {
1079
+ throw new Error(
1080
+ 'Invalid torch.tensor envelope: sourceDtype must be a non-empty string when provided'
1081
+ );
1082
+ }
1083
+ const sourceDtype = typeof sourceDtypeValue === 'string' ? sourceDtypeValue : undefined;
1084
+ const sourceDeviceValue = value.sourceDevice;
1085
+ if (
1086
+ sourceDeviceValue !== undefined &&
1087
+ (typeof sourceDeviceValue !== 'string' || sourceDeviceValue.length === 0)
1088
+ ) {
1089
+ throw new Error(
1090
+ 'Invalid torch.tensor envelope: sourceDevice must be a non-empty string when provided'
1091
+ );
1092
+ }
1093
+ const sourceDevice = typeof sourceDeviceValue === 'string' ? sourceDeviceValue : undefined;
1094
+
1095
+ const finishTensorDecode = (data: unknown): TorchTensor =>
1096
+ tagDecodedShape(
1097
+ {
1098
+ data,
1099
+ shape,
1100
+ dtype,
1101
+ device,
1102
+ ...(sourceDtype === undefined ? {} : { sourceDtype }),
1103
+ ...(sourceDevice === undefined ? {} : { sourceDevice }),
1104
+ },
1105
+ { marker: 'torch.tensor', dims: shape?.length, dtype }
1106
+ );
710
1107
 
711
1108
  const decoded = recurse(nested);
712
1109
  if (isPromiseLike(decoded)) {
713
- return decoded.then(data => ({ data, shape, dtype, device })) as Promise<T | unknown>;
1110
+ return decoded.then(finishTensorDecode) as Promise<T | unknown>;
714
1111
  }
715
- return { data: decoded, shape, dtype, device } satisfies TorchTensor;
1112
+ return finishTensorDecode(decoded);
716
1113
  };
717
1114
 
718
1115
  /**
@@ -770,9 +1167,17 @@ const decodeSklearnEstimatorEnvelope: EnvelopeHandler = value => {
770
1167
  const className = value.className;
771
1168
  const module = value.module;
772
1169
  const params = value.params;
773
- if (typeof className !== 'string' || typeof module !== 'string' || !isObject(params)) {
1170
+ const strictV1 = isStrictV1Envelope(value);
1171
+ if (
1172
+ typeof className !== 'string' ||
1173
+ typeof module !== 'string' ||
1174
+ (strictV1 && (className.length === 0 || module.length === 0)) ||
1175
+ !isObject(params)
1176
+ ) {
774
1177
  throw new Error(
775
- 'Invalid sklearn.estimator envelope: expected className/module strings + params object'
1178
+ `Invalid sklearn.estimator envelope: expected className/module strings + params object; ` +
1179
+ `className at path className and module at path module must be non-empty; declared class ${JSON.stringify(className)}, ` +
1180
+ `declared module ${JSON.stringify(module)}, actual types ${actualType(className)}/${actualType(module)}`
776
1181
  );
777
1182
  }
778
1183
  // params must be a PLAIN JSON object end to end — metadata-only estimators never
@@ -793,19 +1198,22 @@ const decodeSklearnEstimatorEnvelope: EnvelopeHandler = value => {
793
1198
  throw new Error('Invalid sklearn.estimator envelope: version must be a string when provided');
794
1199
  }
795
1200
  const version = typeof versionValue === 'string' ? versionValue : undefined;
796
- return {
797
- className,
798
- module,
799
- version,
800
- params,
801
- } satisfies SklearnEstimator;
1201
+ return tagDecodedShape(
1202
+ {
1203
+ className,
1204
+ module,
1205
+ version,
1206
+ params,
1207
+ } satisfies SklearnEstimator,
1208
+ { marker: 'sklearn.estimator' }
1209
+ );
802
1210
  };
803
1211
 
804
1212
  // Why: dispatch over the __tywrap__ typeTag instead of a long if-chain so each type's
805
1213
  // decode logic lives in one focused handler. The typeTag strings are the on-the-wire keys
806
1214
  // emitted by the Python bridge and MUST stay byte-identical. A Map keyed by the typeTag
807
1215
  // avoids prototype-chain lookups when dispatching on attacker-controlled typeTag strings.
808
- const ENVELOPE_HANDLERS: ReadonlyMap<string, EnvelopeHandler> = new Map([
1216
+ const ENVELOPE_HANDLERS: ReadonlyMap<ScientificMarker, EnvelopeHandler> = new Map([
809
1217
  ['dataframe', decodeDataframeEnvelope],
810
1218
  ['series', decodeSeriesEnvelope],
811
1219
  ['ndarray', decodeNdarrayEnvelope],
@@ -814,16 +1222,38 @@ const ENVELOPE_HANDLERS: ReadonlyMap<string, EnvelopeHandler> = new Map([
814
1222
  ['sklearn.estimator', decodeSklearnEstimatorEnvelope],
815
1223
  ]);
816
1224
 
1225
+ const MAX_DECODE_DEPTH = 2048;
1226
+ const MAX_DECODE_NODES = 1_000_000;
1227
+
1228
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
1229
+ if (!isObject(value)) {
1230
+ return false;
1231
+ }
1232
+ const proto = Object.getPrototypeOf(value);
1233
+ return proto === Object.prototype || proto === null;
1234
+ }
1235
+
1236
+ function isPlainArray(value: unknown): value is unknown[] {
1237
+ return Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype;
1238
+ }
1239
+
1240
+ function decodePath(base: string, key: string | number): string {
1241
+ if (typeof key === 'number') {
1242
+ return `${base}[${key}]`;
1243
+ }
1244
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? `${base}.${key}` : `${base}[${JSON.stringify(key)}]`;
1245
+ }
1246
+
817
1247
  function decodeEnvelopeCore<T>(
818
1248
  value: unknown,
819
- decodeArrow: (bytes: Uint8Array) => MaybePromise<T>,
1249
+ decodeArrow: DecodeArrow<T>,
820
1250
  recurse: (value: unknown) => MaybePromise<T | unknown>
821
1251
  ): MaybePromise<T | unknown> {
822
1252
  if (!isObject(value)) {
823
1253
  return value;
824
1254
  }
825
1255
  const typeTag = (value as { __tywrap__?: unknown }).__tywrap__;
826
- if (typeof typeTag !== 'string') {
1256
+ if (!isScientificMarker(typeTag)) {
827
1257
  return value as unknown;
828
1258
  }
829
1259
 
@@ -832,11 +1262,23 @@ function decodeEnvelopeCore<T>(
832
1262
  return value as unknown;
833
1263
  }
834
1264
 
835
- assertCodecVersion(value as { codecVersion?: unknown }, typeTag);
836
- return handler(value, decodeArrow, recurse);
1265
+ try {
1266
+ assertCodecVersion(value as { codecVersion?: unknown }, typeTag);
1267
+ const decoded = handler(value, decodeArrow, recurse);
1268
+ return isPromiseLike(decoded)
1269
+ ? Promise.resolve(decoded).catch(error => {
1270
+ throw asScientificDecodeError(error, typeTag);
1271
+ })
1272
+ : decoded;
1273
+ } catch (error) {
1274
+ throw asScientificDecodeError(error, typeTag);
1275
+ }
837
1276
  }
838
1277
 
839
- function decodeEnvelope<T>(value: unknown, decodeArrow: (bytes: Uint8Array) => T): T | unknown {
1278
+ function decodeEnvelope<T>(
1279
+ value: unknown,
1280
+ decodeArrow: (bytes: Uint8Array, marker: ScientificMarker) => T
1281
+ ): T | unknown {
840
1282
  const recurse: (value: unknown) => MaybePromise<T | unknown> = v =>
841
1283
  decodeEnvelopeCore(v, decodeArrow, recurse);
842
1284
  const decoded = decodeEnvelopeCore(value, decodeArrow, recurse);
@@ -848,30 +1290,225 @@ function decodeEnvelope<T>(value: unknown, decodeArrow: (bytes: Uint8Array) => T
848
1290
 
849
1291
  async function decodeEnvelopeAsync<T>(
850
1292
  value: unknown,
851
- decodeArrow: (bytes: Uint8Array) => Promise<T>
1293
+ decodeArrow: (bytes: Uint8Array, marker: ScientificMarker) => Promise<T>
852
1294
  ): Promise<T | unknown> {
853
- const recurse: (value: unknown) => MaybePromise<T | unknown> = v =>
854
- decodeEnvelopeCore(v, decodeArrow, recurse);
855
- return await decodeEnvelopeCore(value, decodeArrow, recurse);
1295
+ let visitedNodes = 0;
1296
+
1297
+ type DecodeTarget =
1298
+ | { container: unknown[]; key: number }
1299
+ | { container: Record<string, unknown>; key: string };
1300
+
1301
+ interface ArrayFrame {
1302
+ kind: 'array';
1303
+ container: unknown[];
1304
+ depth: number;
1305
+ path: string;
1306
+ nextIndex: number;
1307
+ }
1308
+
1309
+ interface ObjectFrame {
1310
+ kind: 'object';
1311
+ container: Record<string, unknown>;
1312
+ depth: number;
1313
+ path: string;
1314
+ entries: [string, unknown][];
1315
+ nextIndex: number;
1316
+ }
1317
+
1318
+ type DecodeFrame = ArrayFrame | ObjectFrame;
1319
+
1320
+ const recordVisit = (depth: number, path: string): void => {
1321
+ visitedNodes += 1;
1322
+ if (visitedNodes > MAX_DECODE_NODES) {
1323
+ throw new Error(
1324
+ `Scientific envelope decode maximum visited nodes ${MAX_DECODE_NODES} exceeded at ${path}`
1325
+ );
1326
+ }
1327
+ if (depth > MAX_DECODE_DEPTH) {
1328
+ throw new Error(
1329
+ `Scientific envelope decode maximum depth ${MAX_DECODE_DEPTH} exceeded at ${path}`
1330
+ );
1331
+ }
1332
+ };
1333
+
1334
+ const prefixHandlerError = (error: unknown, path: string): never => {
1335
+ if (path === 'result') {
1336
+ throw error;
1337
+ }
1338
+ throw new ScientificDecodeError(
1339
+ 'envelope',
1340
+ 'unknown',
1341
+ error,
1342
+ `Scientific envelope decode failed at ${path}: ${error instanceof Error ? error.message : String(error)}`
1343
+ );
1344
+ };
1345
+
1346
+ const assignDecoded = (target: DecodeTarget, original: unknown, decoded: unknown): void => {
1347
+ if (decoded !== original) {
1348
+ if (Array.isArray(target.container)) {
1349
+ target.container[target.key as number] = decoded;
1350
+ } else {
1351
+ target.container[target.key as string] = decoded;
1352
+ }
1353
+ }
1354
+ };
1355
+
1356
+ const settleDecoded = async (
1357
+ decoded: PromiseLike<T | unknown>,
1358
+ original: unknown,
1359
+ target: DecodeTarget,
1360
+ path: string
1361
+ ): Promise<void> => {
1362
+ let resolved: T | unknown;
1363
+ try {
1364
+ resolved = await decoded;
1365
+ } catch (error) {
1366
+ return prefixHandlerError(error, path);
1367
+ }
1368
+ assignDecoded(target, original, resolved);
1369
+ };
1370
+
1371
+ const decodeSingle = (item: unknown, depth: number, path: string): MaybePromise<T | unknown> => {
1372
+ // Handlers sometimes decode a required child envelope themselves (currently
1373
+ // torch.tensor.value). Keep that single-envelope recursion unchanged.
1374
+ const recurseSingle: (nested: unknown) => MaybePromise<T | unknown> = nested => {
1375
+ const nestedPath = decodePath(path, 'value');
1376
+ recordVisit(depth + 1, nestedPath);
1377
+ return decodeEnvelopeCore(nested, decodeArrow, recurseSingle);
1378
+ };
1379
+ return decodeEnvelopeCore(item, decodeArrow, recurseSingle);
1380
+ };
1381
+
1382
+ const frames: DecodeFrame[] = [];
1383
+ const pending: Promise<void>[] = [];
1384
+
1385
+ const visit = (current: unknown, depth: number, path: string, target?: DecodeTarget): void => {
1386
+ const plainArray = isPlainArray(current);
1387
+ const plainObject = isPlainObject(current);
1388
+
1389
+ // Only containers consume the traversal budget. Primitive leaves are already
1390
+ // bounded by the payload byte cap, and counting them would reject large plain
1391
+ // arrays that decoded fine before recursion existed.
1392
+ if (plainArray || plainObject) {
1393
+ recordVisit(depth, path);
1394
+ }
1395
+
1396
+ if (plainObject && typeof current.__tywrap__ === 'string') {
1397
+ if (!isScientificMarker(current.__tywrap__)) {
1398
+ return;
1399
+ }
1400
+
1401
+ let decoded: MaybePromise<T | unknown>;
1402
+ try {
1403
+ decoded = decodeSingle(current, depth, path);
1404
+ } catch (error) {
1405
+ return prefixHandlerError(error, path);
1406
+ }
1407
+ if (isPromiseLike(decoded)) {
1408
+ if (target) {
1409
+ pending.push(settleDecoded(decoded, current, target, path));
1410
+ } else {
1411
+ pending.push(settleDecoded(decoded, current, { container: root, key: 'value' }, path));
1412
+ }
1413
+ } else if (target) {
1414
+ assignDecoded(target, current, decoded);
1415
+ } else if (decoded !== current) {
1416
+ root.value = decoded;
1417
+ }
1418
+ return;
1419
+ }
1420
+
1421
+ if (plainArray) {
1422
+ frames.push({ kind: 'array', container: current, depth, path, nextIndex: 0 });
1423
+ } else if (plainObject) {
1424
+ frames.push({
1425
+ kind: 'object',
1426
+ container: current,
1427
+ depth,
1428
+ path,
1429
+ entries: Object.entries(current),
1430
+ nextIndex: 0,
1431
+ });
1432
+ }
1433
+ };
1434
+
1435
+ /**
1436
+ * Values have copy semantics across the bridge: repeated Python references are
1437
+ * serialized independently, and this decoder does not restore alias identity.
1438
+ */
1439
+ const root: { value: unknown } = { value };
1440
+ visit(value, 0, 'result');
1441
+
1442
+ const visitChild = (
1443
+ item: unknown,
1444
+ key: string | number,
1445
+ target: DecodeTarget,
1446
+ frame: DecodeFrame
1447
+ ): void => {
1448
+ try {
1449
+ visit(item, frame.depth + 1, decodePath(frame.path, key), target);
1450
+ } catch (error) {
1451
+ pending.push(Promise.reject(error));
1452
+ }
1453
+ };
1454
+
1455
+ while (frames.length > 0) {
1456
+ const frame = frames[frames.length - 1];
1457
+ if (!frame) {
1458
+ break;
1459
+ }
1460
+ if (
1461
+ frame.nextIndex >= (frame.kind === 'array' ? frame.container.length : frame.entries.length)
1462
+ ) {
1463
+ frames.pop();
1464
+ continue;
1465
+ }
1466
+
1467
+ const index = frame.nextIndex;
1468
+ frame.nextIndex += 1;
1469
+ if (frame.kind === 'array') {
1470
+ visitChild(frame.container[index], index, { container: frame.container, key: index }, frame);
1471
+ } else {
1472
+ const entry = frame.entries[index];
1473
+ if (entry) {
1474
+ const [key, item] = entry;
1475
+ visitChild(item, key, { container: frame.container, key }, frame);
1476
+ }
1477
+ }
1478
+ }
1479
+
1480
+ if (pending.length > 0) {
1481
+ await Promise.all(pending);
1482
+ }
1483
+ return root.value;
856
1484
  }
857
1485
 
858
1486
  /**
859
1487
  * Decode values produced by the Python bridge.
860
1488
  */
861
1489
  export async function decodeValueAsync(value: unknown): Promise<DecodedValue> {
862
- return await decodeEnvelopeAsync(value, tryDecodeArrowTable);
1490
+ try {
1491
+ return await decodeEnvelopeAsync(value, tryDecodeArrowTable);
1492
+ } catch (error) {
1493
+ throw asScientificDecodeError(error, 'unknown');
1494
+ }
863
1495
  }
864
1496
 
865
1497
  /**
866
1498
  * Synchronous decode. Arrow decoding requires a registered decoder.
867
1499
  */
868
1500
  export function decodeValue(value: unknown): DecodedValue {
869
- const decodeArrow = (bytes: Uint8Array): DecodedValue => {
870
- const decoder = requireArrowDecoder();
1501
+ const decodeArrow = (bytes: Uint8Array, marker: ScientificMarker): DecodedValue => {
1502
+ const decoder = requireArrowDecoder(marker);
871
1503
  try {
872
1504
  return decoder(bytes);
873
1505
  } catch (err) {
874
- throw new Error(`Arrow decode failed: ${err instanceof Error ? err.message : String(err)}`);
1506
+ throw new ScientificDecodeError(
1507
+ 'arrow',
1508
+ marker,
1509
+ err,
1510
+ `Arrow decode failed: ${err instanceof Error ? err.message : String(err)}`
1511
+ );
875
1512
  }
876
1513
  };
877
1514
  return decodeEnvelope(value, decodeArrow);