tywrap 0.9.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.
@@ -11,6 +11,45 @@
11
11
 
12
12
  import { tagDecodedShape } from '../runtime/validators.js';
13
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
+
14
53
  // Avoid hard dependency on apache-arrow types at compile time to keep install optional.
15
54
  export type ArrowTable = { readonly numCols?: number; readonly numRows?: number } & Record<
16
55
  string,
@@ -33,6 +72,8 @@ export interface TorchTensor {
33
72
  shape?: readonly number[];
34
73
  dtype?: string;
35
74
  device?: string;
75
+ sourceDtype?: string;
76
+ sourceDevice?: string;
36
77
  }
37
78
 
38
79
  export interface SklearnEstimator {
@@ -93,6 +134,8 @@ export type ValueEnvelope =
93
134
  readonly shape?: readonly number[];
94
135
  readonly dtype?: string;
95
136
  readonly device?: string;
137
+ readonly sourceDtype?: string;
138
+ readonly sourceDevice?: string;
96
139
  }
97
140
  | {
98
141
  readonly __tywrap__: 'sklearn.estimator';
@@ -232,7 +275,15 @@ function isNumberArray(value: unknown): value is number[] {
232
275
  return Array.isArray(value) && value.every(item => typeof item === 'number');
233
276
  }
234
277
 
235
- 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
+ }
236
287
  if (typeof Buffer !== 'undefined') {
237
288
  const buf = Buffer.from(b64, 'base64');
238
289
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.length);
@@ -253,9 +304,11 @@ const ARROW_MISSING_MESSAGE =
253
304
  'TYWRAP_CODEC_FALLBACK=json on the Python side to receive JSON instead ' +
254
305
  '(lossy for dtype/NA fidelity). tywrap never silently downgrades Arrow payloads.';
255
306
 
256
- function requireArrowDecoder(): (bytes: Uint8Array) => ArrowTable | Uint8Array {
307
+ function requireArrowDecoder(
308
+ marker: ScientificMarker
309
+ ): (bytes: Uint8Array) => ArrowTable | Uint8Array {
257
310
  if (!arrowTableFrom) {
258
- throw new Error(ARROW_MISSING_MESSAGE);
311
+ throw new ScientificDecodeError('arrow', marker, new Error(ARROW_MISSING_MESSAGE));
259
312
  }
260
313
  return arrowTableFrom;
261
314
  }
@@ -268,7 +321,9 @@ function requireArrowDecoder(): (bytes: Uint8Array) => ArrowTable | Uint8Array {
268
321
  * for the rest of the process. If apache-arrow is absent we throw a clear, actionable
269
322
  * error rather than silently producing wrong data.
270
323
  */
271
- async function ensureArrowDecoder(): Promise<(bytes: Uint8Array) => ArrowTable | Uint8Array> {
324
+ async function ensureArrowDecoder(
325
+ marker: ScientificMarker
326
+ ): Promise<(bytes: Uint8Array) => ArrowTable | Uint8Array> {
272
327
  if (arrowTableFrom) {
273
328
  return arrowTableFrom;
274
329
  }
@@ -278,21 +333,30 @@ async function ensureArrowDecoder(): Promise<(bytes: Uint8Array) => ArrowTable |
278
333
  );
279
334
  await lazyRegistration;
280
335
  if (!arrowTableFrom) {
281
- throw new Error(ARROW_MISSING_MESSAGE);
336
+ throw new ScientificDecodeError('arrow', marker, new Error(ARROW_MISSING_MESSAGE));
282
337
  }
283
338
  return arrowTableFrom;
284
339
  }
285
340
 
286
- async function tryDecodeArrowTable(bytes: Uint8Array): Promise<ArrowTable | Uint8Array> {
287
- const decoder = await ensureArrowDecoder();
341
+ async function tryDecodeArrowTable(
342
+ bytes: Uint8Array,
343
+ marker: ScientificMarker
344
+ ): Promise<ArrowTable | Uint8Array> {
345
+ const decoder = await ensureArrowDecoder(marker);
288
346
  try {
289
347
  return decoder(bytes);
290
348
  } catch (err) {
291
- 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
+ );
292
355
  }
293
356
  }
294
357
 
295
358
  type MaybePromise<T> = T | Promise<T>;
359
+ type DecodeArrow<T> = (bytes: Uint8Array, marker: ScientificMarker) => MaybePromise<T>;
296
360
 
297
361
  function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
298
362
  return (
@@ -312,7 +376,7 @@ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
312
376
  * @param arr - Typed array or plain array
313
377
  * @returns Plain JavaScript array with values converted (BigInt → Number where safe)
314
378
  */
315
- function typedArrayToPlain(arr: unknown): unknown[] {
379
+ function typedArrayToPlain(arr: unknown): unknown[] | null {
316
380
  if (Array.isArray(arr)) {
317
381
  return arr;
318
382
  }
@@ -333,8 +397,7 @@ function typedArrayToPlain(arr: unknown): unknown[] {
333
397
  if (arr !== null && arr !== undefined && typeof arr === 'object' && Symbol.iterator in arr) {
334
398
  return Array.from(arr as Iterable<unknown>);
335
399
  }
336
- // Non-iterable: return empty array (shouldn't happen with valid Arrow data)
337
- return [];
400
+ return null;
338
401
  }
339
402
 
340
403
  /**
@@ -358,6 +421,30 @@ function extractArrowValues(data: unknown): unknown[] | null {
358
421
  return null;
359
422
  }
360
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
+
361
448
  /**
362
449
  * Reshape a flat array into a multi-dimensional nested array.
363
450
  *
@@ -397,6 +484,168 @@ function reshapeArray(flat: unknown[], shape: readonly number[]): unknown {
397
484
  // Why: decoding needs to reject incompatible envelopes before we attempt to interpret payloads.
398
485
  const CODEC_VERSION = 1;
399
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
+
400
649
  function assertCodecVersion(envelope: { codecVersion?: unknown }, typeTag: string): void {
401
650
  if (!('codecVersion' in envelope)) {
402
651
  return;
@@ -423,7 +672,7 @@ function assertCodecVersion(envelope: { codecVersion?: unknown }, typeTag: strin
423
672
  */
424
673
  type EnvelopeHandler = <T>(
425
674
  value: { [k: string]: unknown },
426
- decodeArrow: (bytes: Uint8Array) => MaybePromise<T>,
675
+ decodeArrow: DecodeArrow<T>,
427
676
  recurse: (value: unknown) => MaybePromise<T | unknown>
428
677
  ) => MaybePromise<T | unknown>;
429
678
 
@@ -435,8 +684,8 @@ type EnvelopeHandler = <T>(
435
684
  */
436
685
  function decodeArrowOrJsonEnvelope<T>(
437
686
  value: { [k: string]: unknown },
438
- decodeArrow: (bytes: Uint8Array) => MaybePromise<T>,
439
- typeTag: string
687
+ decodeArrow: DecodeArrow<T>,
688
+ typeTag: 'dataframe' | 'series'
440
689
  ): MaybePromise<T | unknown> {
441
690
  const encoding = value.encoding;
442
691
  if (encoding === 'arrow') {
@@ -444,17 +693,17 @@ function decodeArrowOrJsonEnvelope<T>(
444
693
  if (typeof b64 !== 'string') {
445
694
  throw new Error(`Invalid ${typeTag} envelope: missing b64`);
446
695
  }
447
- const bytes = fromBase64(b64);
448
- const decoded = decodeArrow(bytes);
696
+ const bytes = fromBase64(b64, isStrictV1Envelope(value) ? typeTag : undefined);
697
+ const decoded = decodeArrow(bytes, typeTag);
449
698
  return isPromiseLike(decoded)
450
- ? decoded.then(item => tagDecodedShape(item, { marker: typeTag as 'dataframe' | 'series' }))
451
- : tagDecodedShape(decoded, { marker: typeTag as 'dataframe' | 'series' });
699
+ ? decoded.then(item => tagDecodedShape(item, { marker: typeTag }))
700
+ : tagDecodedShape(decoded, { marker: typeTag });
452
701
  }
453
702
  if (encoding === 'json') {
454
703
  if (!('data' in value)) {
455
704
  throw new Error(`Invalid ${typeTag} envelope: missing data`);
456
705
  }
457
- return tagDecodedShape(value.data, { marker: typeTag as 'dataframe' | 'series' });
706
+ return tagDecodedShape(value.data, { marker: typeTag });
458
707
  }
459
708
  throw new Error(`Invalid ${typeTag} envelope: unsupported encoding ${String(encoding)}`);
460
709
  }
@@ -465,51 +714,81 @@ const decodeDataframeEnvelope: EnvelopeHandler = (value, decodeArrow) =>
465
714
  const decodeSeriesEnvelope: EnvelopeHandler = (value, decodeArrow) =>
466
715
  decodeArrowOrJsonEnvelope(value, decodeArrow, 'series');
467
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
+
468
739
  const decodeNdarrayEnvelope: EnvelopeHandler = (value, decodeArrow) => {
469
740
  const encoding = value.encoding;
470
- const shapeValue = value.shape;
471
- const shape = isNumberArray(shapeValue) ? shapeValue : undefined;
472
- const dtype = typeof value.dtype === 'string' ? value.dtype : 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);
473
744
  const metadata = { marker: 'ndarray' as const, dims: shape?.length, dtype };
474
745
 
475
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
+ }
476
753
  const b64 = value.b64;
477
754
  if (typeof b64 !== 'string') {
478
- 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
+ );
479
763
  }
480
- const bytes = fromBase64(b64);
481
- 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');
482
770
 
483
771
  // Extract values from Arrow table and reshape if needed
484
772
  // Arrow only handles 1D arrays, so we flatten on encode and reshape here
485
773
  // Reshape for: scalars (shape.length === 0) and multi-dim (shape.length > 1)
486
774
  // Skip reshape for: 1D arrays (shape.length === 1) - return as-is
487
775
  if (isPromiseLike(decoded)) {
488
- return decoded.then(data => {
489
- const values = extractArrowValues(data);
490
- if (!values) {
491
- return tagDecodedShape(data, metadata); // Fallback: keep provenance on raw data.
492
- }
493
- // Reshape scalars and multi-dimensional arrays, but not 1D
494
- return tagDecodedShape(
495
- shape && shape.length !== 1 ? reshapeArray(values, shape) : values,
496
- metadata
497
- );
498
- });
499
- }
500
- const values = extractArrowValues(decoded);
501
- if (!values) {
502
- return tagDecodedShape(decoded, metadata); // Fallback: keep provenance on raw data.
776
+ return decoded.then(data => finishNdarrayDecode(data, strictV1, shape, dtype, metadata));
503
777
  }
504
- // Reshape scalars and multi-dimensional arrays, but not 1D
505
- return tagDecodedShape(
506
- shape && shape.length !== 1 ? reshapeArray(values, shape) : values,
507
- metadata
508
- );
778
+ return finishNdarrayDecode(decoded, strictV1, shape, dtype, metadata);
509
779
  }
510
780
  if (encoding === 'json') {
511
781
  if (!('data' in value)) {
512
- 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);
513
792
  }
514
793
  return tagDecodedShape(value.data, metadata);
515
794
  }
@@ -541,6 +820,53 @@ function assertIndexArrayInRange(arr: readonly unknown[], bound: number, label:
541
820
  }
542
821
  }
543
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
+
544
870
  const decodeScipySparseEnvelope: EnvelopeHandler = value => {
545
871
  const encoding = value.encoding;
546
872
  if (encoding !== 'json') {
@@ -550,7 +876,8 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
550
876
  if (format !== 'csr' && format !== 'csc' && format !== 'coo') {
551
877
  throw new Error(`Invalid scipy.sparse envelope: unsupported format ${String(format)}`);
552
878
  }
553
- const shape = value.shape;
879
+ const strictV1 = isStrictV1Envelope(value);
880
+ const shape = readEnvelopeShape(value, value.shape, 'scipy.sparse', 'shape', value.dtype);
554
881
  if (
555
882
  !Array.isArray(shape) ||
556
883
  shape.length !== 2 ||
@@ -569,8 +896,16 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
569
896
  if (!Array.isArray(data)) {
570
897
  throw new Error('Invalid scipy.sparse envelope: data must be an array');
571
898
  }
572
- const dtypeValue = value.dtype;
573
- 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
+ }
574
909
 
575
910
  if (format === 'coo') {
576
911
  const row = value.row;
@@ -588,14 +923,17 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
588
923
  }
589
924
  assertIndexArrayInRange(row, rows, 'row');
590
925
  assertIndexArrayInRange(col, cols, 'col');
591
- return {
592
- format,
593
- shape,
594
- data,
595
- row,
596
- col,
597
- dtype,
598
- } 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
+ );
599
937
  }
600
938
 
601
939
  const indices = value.indices;
@@ -649,14 +987,17 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
649
987
  );
650
988
  }
651
989
  assertIndexArrayInRange(indices, minorAxis, 'indices');
652
- return {
653
- format,
654
- shape,
655
- data,
656
- indices,
657
- indptr,
658
- dtype,
659
- } 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
+ );
660
1001
  };
661
1002
 
662
1003
  /** Product of a shape's dimensions (the element count). [] (scalar) -> 1. */
@@ -666,7 +1007,7 @@ function shapeProduct(shape: readonly number[]): number {
666
1007
 
667
1008
  const decodeTorchTensorEnvelope: EnvelopeHandler = <T>(
668
1009
  value: { [k: string]: unknown },
669
- _decodeArrow: (bytes: Uint8Array) => MaybePromise<T>,
1010
+ _decodeArrow: DecodeArrow<T>,
670
1011
  recurse: (value: unknown) => MaybePromise<T | unknown>
671
1012
  ): MaybePromise<T | unknown> => {
672
1013
  const encoding = value.encoding;
@@ -680,34 +1021,49 @@ const decodeTorchTensorEnvelope: EnvelopeHandler = <T>(
680
1021
  if (!isObject(nested) || (nested as { __tywrap__?: unknown }).__tywrap__ !== 'ndarray') {
681
1022
  throw new Error('Invalid torch.tensor envelope: value must be an ndarray envelope');
682
1023
  }
683
- const shapeValue = value.shape;
684
- const shape = isNumberArray(shapeValue) ? shapeValue : undefined;
685
- // The tensor shape must be a non-negative-integer dimension list. A negative or
686
- // non-integer dim is a corrupt envelope, not a valid tensor.
687
- if (shape) {
688
- for (let i = 0; i < shape.length; i += 1) {
689
- const dim = shape[i] as number;
690
- if (!Number.isInteger(dim) || dim < 0) {
691
- throw new Error(
692
- `Invalid torch.tensor envelope: shape[${i}]=${dim} must be a non-negative integer`
693
- );
694
- }
695
- }
696
- }
697
- // Cross-check the tensor shape's element count against the nested ndarray's
698
- // declared shape (metadata only — no decode needed). A mismatch means the two
699
- // 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);
700
1026
  const nestedShapeValue = (nested as { shape?: unknown }).shape;
701
- const nestedShape = isNumberArray(nestedShapeValue) ? nestedShapeValue : undefined;
702
- 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) {
703
1059
  throw new Error(
704
- `Invalid torch.tensor envelope: shape ${JSON.stringify(shape)} ` +
705
- `(product ${shapeProduct(shape)}) disagrees with nested ndarray shape ` +
706
- `${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`
707
1062
  );
708
1063
  }
709
- const dtypeValue = value.dtype;
710
- const dtype = typeof dtypeValue === 'string' ? dtypeValue : undefined;
1064
+ if (strictV1 && 'dtype' in nested) {
1065
+ assertOptionalNonEmptyDtype(nested.dtype, 'torch.tensor', 'value.dtype', nestedShape);
1066
+ }
711
1067
  const deviceValue = value.device;
712
1068
  if (deviceValue !== undefined && (typeof deviceValue !== 'string' || deviceValue.length === 0)) {
713
1069
  throw new Error(
@@ -715,12 +1071,45 @@ const decodeTorchTensorEnvelope: EnvelopeHandler = <T>(
715
1071
  );
716
1072
  }
717
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
+ );
718
1107
 
719
1108
  const decoded = recurse(nested);
720
1109
  if (isPromiseLike(decoded)) {
721
- return decoded.then(data => ({ data, shape, dtype, device })) as Promise<T | unknown>;
1110
+ return decoded.then(finishTensorDecode) as Promise<T | unknown>;
722
1111
  }
723
- return { data: decoded, shape, dtype, device } satisfies TorchTensor;
1112
+ return finishTensorDecode(decoded);
724
1113
  };
725
1114
 
726
1115
  /**
@@ -778,9 +1167,17 @@ const decodeSklearnEstimatorEnvelope: EnvelopeHandler = value => {
778
1167
  const className = value.className;
779
1168
  const module = value.module;
780
1169
  const params = value.params;
781
- 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
+ ) {
782
1177
  throw new Error(
783
- '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)}`
784
1181
  );
785
1182
  }
786
1183
  // params must be a PLAIN JSON object end to end — metadata-only estimators never
@@ -801,19 +1198,22 @@ const decodeSklearnEstimatorEnvelope: EnvelopeHandler = value => {
801
1198
  throw new Error('Invalid sklearn.estimator envelope: version must be a string when provided');
802
1199
  }
803
1200
  const version = typeof versionValue === 'string' ? versionValue : undefined;
804
- return {
805
- className,
806
- module,
807
- version,
808
- params,
809
- } satisfies SklearnEstimator;
1201
+ return tagDecodedShape(
1202
+ {
1203
+ className,
1204
+ module,
1205
+ version,
1206
+ params,
1207
+ } satisfies SklearnEstimator,
1208
+ { marker: 'sklearn.estimator' }
1209
+ );
810
1210
  };
811
1211
 
812
1212
  // Why: dispatch over the __tywrap__ typeTag instead of a long if-chain so each type's
813
1213
  // decode logic lives in one focused handler. The typeTag strings are the on-the-wire keys
814
1214
  // emitted by the Python bridge and MUST stay byte-identical. A Map keyed by the typeTag
815
1215
  // avoids prototype-chain lookups when dispatching on attacker-controlled typeTag strings.
816
- const ENVELOPE_HANDLERS: ReadonlyMap<string, EnvelopeHandler> = new Map([
1216
+ const ENVELOPE_HANDLERS: ReadonlyMap<ScientificMarker, EnvelopeHandler> = new Map([
817
1217
  ['dataframe', decodeDataframeEnvelope],
818
1218
  ['series', decodeSeriesEnvelope],
819
1219
  ['ndarray', decodeNdarrayEnvelope],
@@ -822,16 +1222,38 @@ const ENVELOPE_HANDLERS: ReadonlyMap<string, EnvelopeHandler> = new Map([
822
1222
  ['sklearn.estimator', decodeSklearnEstimatorEnvelope],
823
1223
  ]);
824
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
+
825
1247
  function decodeEnvelopeCore<T>(
826
1248
  value: unknown,
827
- decodeArrow: (bytes: Uint8Array) => MaybePromise<T>,
1249
+ decodeArrow: DecodeArrow<T>,
828
1250
  recurse: (value: unknown) => MaybePromise<T | unknown>
829
1251
  ): MaybePromise<T | unknown> {
830
1252
  if (!isObject(value)) {
831
1253
  return value;
832
1254
  }
833
1255
  const typeTag = (value as { __tywrap__?: unknown }).__tywrap__;
834
- if (typeof typeTag !== 'string') {
1256
+ if (!isScientificMarker(typeTag)) {
835
1257
  return value as unknown;
836
1258
  }
837
1259
 
@@ -840,11 +1262,23 @@ function decodeEnvelopeCore<T>(
840
1262
  return value as unknown;
841
1263
  }
842
1264
 
843
- assertCodecVersion(value as { codecVersion?: unknown }, typeTag);
844
- 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
+ }
845
1276
  }
846
1277
 
847
- 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 {
848
1282
  const recurse: (value: unknown) => MaybePromise<T | unknown> = v =>
849
1283
  decodeEnvelopeCore(v, decodeArrow, recurse);
850
1284
  const decoded = decodeEnvelopeCore(value, decodeArrow, recurse);
@@ -856,30 +1290,225 @@ function decodeEnvelope<T>(value: unknown, decodeArrow: (bytes: Uint8Array) => T
856
1290
 
857
1291
  async function decodeEnvelopeAsync<T>(
858
1292
  value: unknown,
859
- decodeArrow: (bytes: Uint8Array) => Promise<T>
1293
+ decodeArrow: (bytes: Uint8Array, marker: ScientificMarker) => Promise<T>
860
1294
  ): Promise<T | unknown> {
861
- const recurse: (value: unknown) => MaybePromise<T | unknown> = v =>
862
- decodeEnvelopeCore(v, decodeArrow, recurse);
863
- 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;
864
1484
  }
865
1485
 
866
1486
  /**
867
1487
  * Decode values produced by the Python bridge.
868
1488
  */
869
1489
  export async function decodeValueAsync(value: unknown): Promise<DecodedValue> {
870
- return await decodeEnvelopeAsync(value, tryDecodeArrowTable);
1490
+ try {
1491
+ return await decodeEnvelopeAsync(value, tryDecodeArrowTable);
1492
+ } catch (error) {
1493
+ throw asScientificDecodeError(error, 'unknown');
1494
+ }
871
1495
  }
872
1496
 
873
1497
  /**
874
1498
  * Synchronous decode. Arrow decoding requires a registered decoder.
875
1499
  */
876
1500
  export function decodeValue(value: unknown): DecodedValue {
877
- const decodeArrow = (bytes: Uint8Array): DecodedValue => {
878
- const decoder = requireArrowDecoder();
1501
+ const decodeArrow = (bytes: Uint8Array, marker: ScientificMarker): DecodedValue => {
1502
+ const decoder = requireArrowDecoder(marker);
879
1503
  try {
880
1504
  return decoder(bytes);
881
1505
  } catch (err) {
882
- 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
+ );
883
1512
  }
884
1513
  };
885
1514
  return decodeEnvelope(value, decodeArrow);