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.
@@ -9,6 +9,34 @@
9
9
  * Sklearn estimators: { "__tywrap__": "sklearn.estimator", "encoding": "json", ... }
10
10
  */
11
11
  import { tagDecodedShape } from '../runtime/validators.js';
12
+ const SCIENTIFIC_MARKERS = [
13
+ 'dataframe',
14
+ 'series',
15
+ 'ndarray',
16
+ 'scipy.sparse',
17
+ 'torch.tensor',
18
+ 'sklearn.estimator',
19
+ ];
20
+ export function isScientificMarker(value) {
21
+ return SCIENTIFIC_MARKERS.includes(value);
22
+ }
23
+ /** @internal Structured failure consumed by BridgeCodec without parsing messages. */
24
+ export class ScientificDecodeError extends Error {
25
+ kind;
26
+ marker;
27
+ cause;
28
+ constructor(kind, marker, cause, message = cause instanceof Error ? cause.message : String(cause)) {
29
+ super(message, { cause });
30
+ this.kind = kind;
31
+ this.marker = marker;
32
+ this.cause = cause;
33
+ }
34
+ }
35
+ function asScientificDecodeError(error, marker) {
36
+ return error instanceof ScientificDecodeError
37
+ ? error
38
+ : new ScientificDecodeError('envelope', marker, error);
39
+ }
12
40
  let arrowTableFrom;
13
41
  // Why: lazy auto-registration (on first Arrow decode) imports apache-arrow at most
14
42
  // once per process. We cache the in-flight/settled attempt so concurrent decodes
@@ -110,7 +138,12 @@ function isObject(value) {
110
138
  function isNumberArray(value) {
111
139
  return Array.isArray(value) && value.every(item => typeof item === 'number');
112
140
  }
113
- function fromBase64(b64) {
141
+ const STRICT_BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
142
+ function fromBase64(b64, marker, context) {
143
+ if (marker && !STRICT_BASE64_PATTERN.test(b64)) {
144
+ throw new Error(`Invalid ${marker} envelope: b64 at path b64 must be well-formed base64; ` +
145
+ `${context ? `${context}; ` : ''}actual count unknown, actual type string with length ${b64.length}`);
146
+ }
114
147
  if (typeof Buffer !== 'undefined') {
115
148
  const buf = Buffer.from(b64, 'base64');
116
149
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.length);
@@ -128,9 +161,9 @@ const ARROW_MISSING_MESSAGE = 'Received an Arrow-encoded payload but no Arrow de
128
161
  'Install the optional dependency with `npm install apache-arrow`, or set ' +
129
162
  'TYWRAP_CODEC_FALLBACK=json on the Python side to receive JSON instead ' +
130
163
  '(lossy for dtype/NA fidelity). tywrap never silently downgrades Arrow payloads.';
131
- function requireArrowDecoder() {
164
+ function requireArrowDecoder(marker) {
132
165
  if (!arrowTableFrom) {
133
- throw new Error(ARROW_MISSING_MESSAGE);
166
+ throw new ScientificDecodeError('arrow', marker, new Error(ARROW_MISSING_MESSAGE));
134
167
  }
135
168
  return arrowTableFrom;
136
169
  }
@@ -142,7 +175,7 @@ function requireArrowDecoder() {
142
175
  * for the rest of the process. If apache-arrow is absent we throw a clear, actionable
143
176
  * error rather than silently producing wrong data.
144
177
  */
145
- async function ensureArrowDecoder() {
178
+ async function ensureArrowDecoder(marker) {
146
179
  if (arrowTableFrom) {
147
180
  return arrowTableFrom;
148
181
  }
@@ -150,17 +183,17 @@ async function ensureArrowDecoder() {
150
183
  lazyRegistration ??= autoRegisterArrowDecoder(lazyArrowLoaderOverride ? { loader: lazyArrowLoaderOverride } : {});
151
184
  await lazyRegistration;
152
185
  if (!arrowTableFrom) {
153
- throw new Error(ARROW_MISSING_MESSAGE);
186
+ throw new ScientificDecodeError('arrow', marker, new Error(ARROW_MISSING_MESSAGE));
154
187
  }
155
188
  return arrowTableFrom;
156
189
  }
157
- async function tryDecodeArrowTable(bytes) {
158
- const decoder = await ensureArrowDecoder();
190
+ async function tryDecodeArrowTable(bytes, marker) {
191
+ const decoder = await ensureArrowDecoder(marker);
159
192
  try {
160
193
  return decoder(bytes);
161
194
  }
162
195
  catch (err) {
163
- throw new Error(`Arrow decode failed: ${err instanceof Error ? err.message : String(err)}`);
196
+ throw new ScientificDecodeError('arrow', marker, err, `Arrow decode failed: ${err instanceof Error ? err.message : String(err)}`);
164
197
  }
165
198
  }
166
199
  function isPromiseLike(value) {
@@ -199,8 +232,7 @@ function typedArrayToPlain(arr) {
199
232
  if (arr !== null && arr !== undefined && typeof arr === 'object' && Symbol.iterator in arr) {
200
233
  return Array.from(arr);
201
234
  }
202
- // Non-iterable: return empty array (shouldn't happen with valid Arrow data)
203
- return [];
235
+ return null;
204
236
  }
205
237
  /**
206
238
  * Extract values from an Arrow table as a plain JavaScript array.
@@ -222,6 +254,22 @@ function extractArrowValues(data) {
222
254
  }
223
255
  return null;
224
256
  }
257
+ function extractNdarrayArrowValues(data, shape, dtype) {
258
+ try {
259
+ const values = extractArrowValues(data);
260
+ if (values) {
261
+ return values;
262
+ }
263
+ }
264
+ catch (error) {
265
+ throw new Error(`Invalid ndarray envelope: b64 decoded data extraction failed at path b64 for declared ` +
266
+ `shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; actual count unknown, ` +
267
+ `actual type ${actualType(data)} (${error instanceof Error ? error.message : String(error)})`);
268
+ }
269
+ throw new Error(`Invalid ndarray envelope: b64 decoded data at path b64 could not extract Arrow values ` +
270
+ `for declared shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; ` +
271
+ `actual count unknown, actual type ${actualType(data)}`);
272
+ }
225
273
  /**
226
274
  * Reshape a flat array into a multi-dimensional nested array.
227
275
  *
@@ -256,6 +304,111 @@ function reshapeArray(flat, shape) {
256
304
  }
257
305
  // Why: decoding needs to reject incompatible envelopes before we attempt to interpret payloads.
258
306
  const CODEC_VERSION = 1;
307
+ function isStrictV1Envelope(envelope) {
308
+ return envelope.codecVersion === CODEC_VERSION;
309
+ }
310
+ function actualType(value) {
311
+ if (Array.isArray(value)) {
312
+ return `array(length=${value.length})`;
313
+ }
314
+ if (value === null) {
315
+ return 'null';
316
+ }
317
+ if (typeof value === 'number' && !Number.isFinite(value)) {
318
+ return `number(${String(value)})`;
319
+ }
320
+ return typeof value;
321
+ }
322
+ function assertShape(shapeValue, marker, field = 'shape', dtype) {
323
+ if (!Array.isArray(shapeValue)) {
324
+ throw new Error(`Invalid ${marker} envelope: ${field} at path ${field} must be a list of ` +
325
+ `non-negative safe integers; declared shape ${String(shapeValue)} and dtype ` +
326
+ `${JSON.stringify(dtype)}, actual count unknown, actual type ${actualType(shapeValue)}`);
327
+ }
328
+ for (let i = 0; i < shapeValue.length; i += 1) {
329
+ const dim = shapeValue[i];
330
+ if (typeof dim !== 'number' || !Number.isSafeInteger(dim) || dim < 0) {
331
+ throw new Error(`Invalid ${marker} envelope: ${field}[${i}]=${String(dim)} must be a non-negative integer ` +
332
+ `within the safe range for declared shape ${JSON.stringify(shapeValue)}; ` +
333
+ `declared dtype ${JSON.stringify(dtype)}, actual count unknown, ` +
334
+ `actual type ${actualType(dim)} with value ${String(dim)}`);
335
+ }
336
+ }
337
+ return shapeValue;
338
+ }
339
+ function assertOptionalNonEmptyDtype(value, marker, field = 'dtype', shape) {
340
+ if (value === undefined) {
341
+ return undefined;
342
+ }
343
+ if (typeof value !== 'string' || value.length === 0) {
344
+ throw new Error(`Invalid ${marker} envelope: ${field} at path ${field} must be a non-empty string; ` +
345
+ `declared shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(value)}, ` +
346
+ `actual count unknown, actual type ${actualType(value)}`);
347
+ }
348
+ return value;
349
+ }
350
+ function readEnvelopeShape(envelope, shapeValue, marker, field = 'shape', dtype) {
351
+ return isStrictV1Envelope(envelope)
352
+ ? assertShape(shapeValue, marker, field, dtype)
353
+ : isNumberArray(shapeValue)
354
+ ? shapeValue
355
+ : undefined;
356
+ }
357
+ function readEnvelopeDtype(envelope, dtypeValue, marker, field = 'dtype', shape) {
358
+ return isStrictV1Envelope(envelope)
359
+ ? assertOptionalNonEmptyDtype(dtypeValue, marker, field, shape)
360
+ : typeof dtypeValue === 'string'
361
+ ? dtypeValue
362
+ : undefined;
363
+ }
364
+ function countJsonLeaves(value) {
365
+ if (!Array.isArray(value)) {
366
+ return 1;
367
+ }
368
+ return value.reduce((count, item) => count + countJsonLeaves(item), 0);
369
+ }
370
+ function assertJsonMatchesShape(data, shape, dtype) {
371
+ const declaredCount = shapeProduct(shape);
372
+ const strictLeaves = dtype !== undefined;
373
+ const actualCount = strictLeaves ? countJsonLeaves(data) : undefined;
374
+ const visit = (value, depth, path) => {
375
+ if (depth === shape.length) {
376
+ if (strictLeaves && Array.isArray(value)) {
377
+ throw new Error(`Invalid ndarray envelope: data at path ${path} exceeds nesting depth ` +
378
+ `${shape.length} for declared shape ${JSON.stringify(shape)} and dtype ` +
379
+ `${JSON.stringify(dtype)}; actual count ${actualCount}, actual type ${actualType(value)}`);
380
+ }
381
+ return;
382
+ }
383
+ if (!Array.isArray(value)) {
384
+ throw new Error(`Invalid ndarray envelope: data at path ${path} must be an array at depth ${depth} ` +
385
+ `for declared shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; ` +
386
+ `actual count ${actualCount ?? 'unknown'}, actual type ${actualType(value)}`);
387
+ }
388
+ const expectedLength = shape[depth];
389
+ if (value.length !== expectedLength) {
390
+ throw new Error(`Invalid ndarray envelope: data at path ${path} has length ${value.length}, expected ` +
391
+ `${expectedLength} for declared shape ${JSON.stringify(shape)} and dtype ` +
392
+ `${JSON.stringify(dtype)}; declared count ${declaredCount}, actual count ${actualCount ?? 'unknown'}, ` +
393
+ `actual type ${actualType(value)}`);
394
+ }
395
+ value.forEach((item, index) => visit(item, depth + 1, `${path}[${index}]`));
396
+ };
397
+ visit(data, 0, 'data');
398
+ if (strictLeaves && actualCount !== declaredCount) {
399
+ throw new Error(`Invalid ndarray envelope: data at path data has leaf count ${actualCount}, expected ` +
400
+ `${declaredCount} for declared shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; ` +
401
+ `actual type ${actualType(data)}`);
402
+ }
403
+ }
404
+ function assertArrowCount(values, shape, dtype) {
405
+ const expected = shapeProduct(shape);
406
+ if (values.length !== expected) {
407
+ throw new Error(`Invalid ndarray envelope: b64 decoded data at path b64 has element count ${values.length}, ` +
408
+ `expected ${expected} for declared shape ${JSON.stringify(shape)} and dtype ` +
409
+ `${JSON.stringify(dtype)}; actual type array(length=${values.length})`);
410
+ }
411
+ }
259
412
  function assertCodecVersion(envelope, typeTag) {
260
413
  if (!('codecVersion' in envelope)) {
261
414
  return;
@@ -284,8 +437,8 @@ function decodeArrowOrJsonEnvelope(value, decodeArrow, typeTag) {
284
437
  if (typeof b64 !== 'string') {
285
438
  throw new Error(`Invalid ${typeTag} envelope: missing b64`);
286
439
  }
287
- const bytes = fromBase64(b64);
288
- const decoded = decodeArrow(bytes);
440
+ const bytes = fromBase64(b64, isStrictV1Envelope(value) ? typeTag : undefined);
441
+ const decoded = decodeArrow(bytes, typeTag);
289
442
  return isPromiseLike(decoded)
290
443
  ? decoded.then(item => tagDecodedShape(item, { marker: typeTag }))
291
444
  : tagDecodedShape(decoded, { marker: typeTag });
@@ -300,43 +453,59 @@ function decodeArrowOrJsonEnvelope(value, decodeArrow, typeTag) {
300
453
  }
301
454
  const decodeDataframeEnvelope = (value, decodeArrow) => decodeArrowOrJsonEnvelope(value, decodeArrow, 'dataframe');
302
455
  const decodeSeriesEnvelope = (value, decodeArrow) => decodeArrowOrJsonEnvelope(value, decodeArrow, 'series');
456
+ function finishNdarrayDecode(data, strictV1, shape, dtype, metadata) {
457
+ const values = strictV1
458
+ ? extractNdarrayArrowValues(data, shape, dtype)
459
+ : extractArrowValues(data);
460
+ if (!values) {
461
+ return tagDecodedShape(data, metadata);
462
+ }
463
+ if (strictV1) {
464
+ assertArrowCount(values, shape, dtype);
465
+ }
466
+ return tagDecodedShape(shape && shape.length !== 1 ? reshapeArray(values, shape) : values, metadata);
467
+ }
303
468
  const decodeNdarrayEnvelope = (value, decodeArrow) => {
304
469
  const encoding = value.encoding;
305
- const shapeValue = value.shape;
306
- const shape = isNumberArray(shapeValue) ? shapeValue : undefined;
307
- const dtype = typeof value.dtype === 'string' ? value.dtype : undefined;
470
+ const strictV1 = isStrictV1Envelope(value);
471
+ const shape = readEnvelopeShape(value, value.shape, 'ndarray', 'shape', value.dtype);
472
+ const dtype = readEnvelopeDtype(value, value.dtype, 'ndarray', 'dtype', shape);
308
473
  const metadata = { marker: 'ndarray', dims: shape?.length, dtype };
309
474
  if (encoding === 'arrow') {
475
+ if (strictV1 && dtype === undefined) {
476
+ throw new Error(`Invalid ndarray envelope: dtype at path dtype is required for Arrow encoding; ` +
477
+ `declared shape ${JSON.stringify(shape)} and dtype undefined, actual count unknown, actual type undefined`);
478
+ }
310
479
  const b64 = value.b64;
311
480
  if (typeof b64 !== 'string') {
312
- throw new Error('Invalid ndarray envelope: missing b64');
481
+ if (!strictV1) {
482
+ throw new Error('Invalid ndarray envelope: missing b64');
483
+ }
484
+ throw new Error(`Invalid ndarray envelope: b64 at path b64 must be a base64 string for declared ` +
485
+ `shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; actual count unknown, ` +
486
+ `actual type ${actualType(b64)}`);
313
487
  }
314
- const bytes = fromBase64(b64);
315
- const decoded = decodeArrow(bytes);
488
+ const bytes = fromBase64(b64, strictV1 ? 'ndarray' : undefined, `declared shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}`);
489
+ const decoded = decodeArrow(bytes, 'ndarray');
316
490
  // Extract values from Arrow table and reshape if needed
317
491
  // Arrow only handles 1D arrays, so we flatten on encode and reshape here
318
492
  // Reshape for: scalars (shape.length === 0) and multi-dim (shape.length > 1)
319
493
  // Skip reshape for: 1D arrays (shape.length === 1) - return as-is
320
494
  if (isPromiseLike(decoded)) {
321
- return decoded.then(data => {
322
- const values = extractArrowValues(data);
323
- if (!values) {
324
- return tagDecodedShape(data, metadata); // Fallback: keep provenance on raw data.
325
- }
326
- // Reshape scalars and multi-dimensional arrays, but not 1D
327
- return tagDecodedShape(shape && shape.length !== 1 ? reshapeArray(values, shape) : values, metadata);
328
- });
495
+ return decoded.then(data => finishNdarrayDecode(data, strictV1, shape, dtype, metadata));
329
496
  }
330
- const values = extractArrowValues(decoded);
331
- if (!values) {
332
- return tagDecodedShape(decoded, metadata); // Fallback: keep provenance on raw data.
333
- }
334
- // Reshape scalars and multi-dimensional arrays, but not 1D
335
- return tagDecodedShape(shape && shape.length !== 1 ? reshapeArray(values, shape) : values, metadata);
497
+ return finishNdarrayDecode(decoded, strictV1, shape, dtype, metadata);
336
498
  }
337
499
  if (encoding === 'json') {
338
500
  if (!('data' in value)) {
339
- throw new Error('Invalid ndarray envelope: missing data');
501
+ if (!strictV1) {
502
+ throw new Error('Invalid ndarray envelope: missing data');
503
+ }
504
+ throw new Error(`Invalid ndarray envelope: data at path data is required for declared shape ` +
505
+ `${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; actual count 0, actual type undefined`);
506
+ }
507
+ if (strictV1) {
508
+ assertJsonMatchesShape(value.data, shape, dtype);
340
509
  }
341
510
  return tagDecodedShape(value.data, metadata);
342
511
  }
@@ -362,6 +531,43 @@ function assertIndexArrayInRange(arr, bound, label) {
362
531
  }
363
532
  }
364
533
  }
534
+ function assertSparseDataDomain(data, dtype, shape) {
535
+ const expectsBoolean = dtype === 'bool' || dtype === 'bool_';
536
+ const integerMatch = /^(u?int)(\d*)$/.exec(dtype);
537
+ const expectsInteger = integerMatch !== null;
538
+ const expectsFloat = /^float\d*$/.test(dtype);
539
+ const width = integerMatch?.[2] ? Number(integerMatch[2]) : undefined;
540
+ const unsignedInteger = integerMatch?.[1] === 'uint';
541
+ let integerMin;
542
+ let integerMax;
543
+ if (width === 8 || width === 16 || width === 32) {
544
+ integerMin = unsignedInteger ? 0 : -(2 ** (width - 1));
545
+ integerMax = unsignedInteger ? 2 ** width - 1 : 2 ** (width - 1) - 1;
546
+ }
547
+ for (let i = 0; i < data.length; i += 1) {
548
+ const item = data[i];
549
+ if (typeof item === 'number' && !Number.isFinite(item)) {
550
+ throw new Error(`Invalid scipy.sparse envelope: data[${i}] at path data[${i}] must be finite for ` +
551
+ `declared shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; ` +
552
+ `actual count ${data.length}, actual type number with value ${String(item)}`);
553
+ }
554
+ const valid = expectsBoolean
555
+ ? typeof item === 'boolean'
556
+ : expectsInteger
557
+ ? typeof item === 'number' &&
558
+ Number.isInteger(item) &&
559
+ (integerMin === undefined || item >= integerMin) &&
560
+ (integerMax === undefined || item <= integerMax)
561
+ : expectsFloat
562
+ ? typeof item === 'number'
563
+ : true;
564
+ if (!valid) {
565
+ throw new Error(`Invalid scipy.sparse envelope: data[${i}] at path data[${i}] is incompatible with ` +
566
+ `declared shape ${JSON.stringify(shape)} and dtype ${JSON.stringify(dtype)}; ` +
567
+ `actual count ${data.length}, actual type ${actualType(item)} with value ${String(item)}`);
568
+ }
569
+ }
570
+ }
365
571
  const decodeScipySparseEnvelope = value => {
366
572
  const encoding = value.encoding;
367
573
  if (encoding !== 'json') {
@@ -371,7 +577,8 @@ const decodeScipySparseEnvelope = value => {
371
577
  if (format !== 'csr' && format !== 'csc' && format !== 'coo') {
372
578
  throw new Error(`Invalid scipy.sparse envelope: unsupported format ${String(format)}`);
373
579
  }
374
- const shape = value.shape;
580
+ const strictV1 = isStrictV1Envelope(value);
581
+ const shape = readEnvelopeShape(value, value.shape, 'scipy.sparse', 'shape', value.dtype);
375
582
  if (!Array.isArray(shape) ||
376
583
  shape.length !== 2 ||
377
584
  typeof shape[0] !== 'number' ||
@@ -388,8 +595,14 @@ const decodeScipySparseEnvelope = value => {
388
595
  if (!Array.isArray(data)) {
389
596
  throw new Error('Invalid scipy.sparse envelope: data must be an array');
390
597
  }
391
- const dtypeValue = value.dtype;
392
- const dtype = typeof dtypeValue === 'string' ? dtypeValue : undefined;
598
+ const dtype = readEnvelopeDtype(value, value.dtype, 'scipy.sparse', 'dtype', shape);
599
+ if (strictV1 && dtype === undefined) {
600
+ throw new Error(`Invalid scipy.sparse envelope: dtype at path dtype is required for declared shape ` +
601
+ `${JSON.stringify(shape)}; actual count ${data.length}, actual type undefined`);
602
+ }
603
+ if (strictV1) {
604
+ assertSparseDataDomain(data, dtype, shape);
605
+ }
393
606
  if (format === 'coo') {
394
607
  const row = value.row;
395
608
  const col = value.col;
@@ -404,14 +617,14 @@ const decodeScipySparseEnvelope = value => {
404
617
  }
405
618
  assertIndexArrayInRange(row, rows, 'row');
406
619
  assertIndexArrayInRange(col, cols, 'col');
407
- return {
620
+ return tagDecodedShape({
408
621
  format,
409
622
  shape,
410
623
  data,
411
624
  row,
412
625
  col,
413
626
  dtype,
414
- };
627
+ }, { marker: 'scipy.sparse', dims: shape.length, dtype });
415
628
  }
416
629
  const indices = value.indices;
417
630
  const indptr = value.indptr;
@@ -454,14 +667,14 @@ const decodeScipySparseEnvelope = value => {
454
667
  throw new Error(`Invalid scipy.sparse envelope: indptr must end at data.length (${data.length})`);
455
668
  }
456
669
  assertIndexArrayInRange(indices, minorAxis, 'indices');
457
- return {
670
+ return tagDecodedShape({
458
671
  format,
459
672
  shape,
460
673
  data,
461
674
  indices,
462
675
  indptr,
463
676
  dtype,
464
- };
677
+ }, { marker: 'scipy.sparse', dims: shape.length, dtype });
465
678
  };
466
679
  /** Product of a shape's dimensions (the element count). [] (scalar) -> 1. */
467
680
  function shapeProduct(shape) {
@@ -479,40 +692,67 @@ const decodeTorchTensorEnvelope = (value, _decodeArrow, recurse) => {
479
692
  if (!isObject(nested) || nested.__tywrap__ !== 'ndarray') {
480
693
  throw new Error('Invalid torch.tensor envelope: value must be an ndarray envelope');
481
694
  }
482
- const shapeValue = value.shape;
483
- const shape = isNumberArray(shapeValue) ? shapeValue : undefined;
484
- // The tensor shape must be a non-negative-integer dimension list. A negative or
485
- // non-integer dim is a corrupt envelope, not a valid tensor.
486
- if (shape) {
487
- for (let i = 0; i < shape.length; i += 1) {
488
- const dim = shape[i];
489
- if (!Number.isInteger(dim) || dim < 0) {
490
- throw new Error(`Invalid torch.tensor envelope: shape[${i}]=${dim} must be a non-negative integer`);
491
- }
492
- }
493
- }
494
- // Cross-check the tensor shape's element count against the nested ndarray's
495
- // declared shape (metadata only — no decode needed). A mismatch means the two
496
- // shapes disagree about how many elements the payload holds.
695
+ const strictV1 = isStrictV1Envelope(value);
696
+ const shape = readEnvelopeShape(value, value.shape, 'torch.tensor', 'shape', value.dtype);
497
697
  const nestedShapeValue = nested.shape;
498
- const nestedShape = isNumberArray(nestedShapeValue) ? nestedShapeValue : undefined;
499
- if (shape && nestedShape && shapeProduct(shape) !== shapeProduct(nestedShape)) {
500
- throw new Error(`Invalid torch.tensor envelope: shape ${JSON.stringify(shape)} ` +
501
- `(product ${shapeProduct(shape)}) disagrees with nested ndarray shape ` +
502
- `${JSON.stringify(nestedShape)} (product ${shapeProduct(nestedShape)})`);
503
- }
504
- const dtypeValue = value.dtype;
505
- const dtype = typeof dtypeValue === 'string' ? dtypeValue : undefined;
698
+ const nestedShape = readEnvelopeShape(value, nestedShapeValue, 'torch.tensor', 'value.shape', nested.dtype);
699
+ const scalarNormalization = shape !== undefined &&
700
+ nestedShape !== undefined &&
701
+ ((shape.length === 0 && nestedShape.length === 1 && nestedShape[0] === 1) ||
702
+ (nestedShape.length === 0 && shape.length === 1 && shape[0] === 1));
703
+ const shapesEqual = shape !== undefined &&
704
+ nestedShape !== undefined &&
705
+ shape.length === nestedShape.length &&
706
+ shape.every((dim, index) => dim === nestedShape[index]);
707
+ const shapesDisagree = strictV1
708
+ ? !shapesEqual && !scalarNormalization
709
+ : shape !== undefined &&
710
+ nestedShape !== undefined &&
711
+ shapeProduct(shape) !== shapeProduct(nestedShape);
712
+ if (shape && nestedShape && shapesDisagree) {
713
+ throw new Error(`Invalid torch.tensor envelope: shape at path shape ${JSON.stringify(shape)} disagrees ` +
714
+ `with nested ndarray shape at path value.shape ${JSON.stringify(nestedShape)}; exact shapes are required ` +
715
+ `(except scalar []/[1]), declared dtypes ${JSON.stringify(value.dtype)}/${JSON.stringify(nested.dtype)}, ` +
716
+ `actual counts ${shapeProduct(shape)} and ${shapeProduct(nestedShape)}`);
717
+ }
718
+ const dtype = readEnvelopeDtype(value, value.dtype, 'torch.tensor', 'dtype', shape);
719
+ if (strictV1 && dtype === undefined) {
720
+ throw new Error(`Invalid torch.tensor envelope: dtype at path dtype is required for declared shape ` +
721
+ `${JSON.stringify(shape)}; actual count ${shapeProduct(shape)}, actual type undefined`);
722
+ }
723
+ if (strictV1 && 'dtype' in nested) {
724
+ assertOptionalNonEmptyDtype(nested.dtype, 'torch.tensor', 'value.dtype', nestedShape);
725
+ }
506
726
  const deviceValue = value.device;
507
727
  if (deviceValue !== undefined && (typeof deviceValue !== 'string' || deviceValue.length === 0)) {
508
728
  throw new Error('Invalid torch.tensor envelope: device must be a non-empty string when provided');
509
729
  }
510
730
  const device = typeof deviceValue === 'string' ? deviceValue : undefined;
731
+ const sourceDtypeValue = value.sourceDtype;
732
+ if (sourceDtypeValue !== undefined &&
733
+ (typeof sourceDtypeValue !== 'string' || sourceDtypeValue.length === 0)) {
734
+ throw new Error('Invalid torch.tensor envelope: sourceDtype must be a non-empty string when provided');
735
+ }
736
+ const sourceDtype = typeof sourceDtypeValue === 'string' ? sourceDtypeValue : undefined;
737
+ const sourceDeviceValue = value.sourceDevice;
738
+ if (sourceDeviceValue !== undefined &&
739
+ (typeof sourceDeviceValue !== 'string' || sourceDeviceValue.length === 0)) {
740
+ throw new Error('Invalid torch.tensor envelope: sourceDevice must be a non-empty string when provided');
741
+ }
742
+ const sourceDevice = typeof sourceDeviceValue === 'string' ? sourceDeviceValue : undefined;
743
+ const finishTensorDecode = (data) => tagDecodedShape({
744
+ data,
745
+ shape,
746
+ dtype,
747
+ device,
748
+ ...(sourceDtype === undefined ? {} : { sourceDtype }),
749
+ ...(sourceDevice === undefined ? {} : { sourceDevice }),
750
+ }, { marker: 'torch.tensor', dims: shape?.length, dtype });
511
751
  const decoded = recurse(nested);
512
752
  if (isPromiseLike(decoded)) {
513
- return decoded.then(data => ({ data, shape, dtype, device }));
753
+ return decoded.then(finishTensorDecode);
514
754
  }
515
- return { data: decoded, shape, dtype, device };
755
+ return finishTensorDecode(decoded);
516
756
  };
517
757
  /**
518
758
  * Recursively assert a value is plain JSON (null | boolean | number | string |
@@ -562,8 +802,14 @@ const decodeSklearnEstimatorEnvelope = value => {
562
802
  const className = value.className;
563
803
  const module = value.module;
564
804
  const params = value.params;
565
- if (typeof className !== 'string' || typeof module !== 'string' || !isObject(params)) {
566
- throw new Error('Invalid sklearn.estimator envelope: expected className/module strings + params object');
805
+ const strictV1 = isStrictV1Envelope(value);
806
+ if (typeof className !== 'string' ||
807
+ typeof module !== 'string' ||
808
+ (strictV1 && (className.length === 0 || module.length === 0)) ||
809
+ !isObject(params)) {
810
+ throw new Error(`Invalid sklearn.estimator envelope: expected className/module strings + params object; ` +
811
+ `className at path className and module at path module must be non-empty; declared class ${JSON.stringify(className)}, ` +
812
+ `declared module ${JSON.stringify(module)}, actual types ${actualType(className)}/${actualType(module)}`);
567
813
  }
568
814
  // params must be a PLAIN JSON object end to end — metadata-only estimators never
569
815
  // carry callables, class instances, or nested non-JSON values. Validate (do not
@@ -581,12 +827,12 @@ const decodeSklearnEstimatorEnvelope = value => {
581
827
  throw new Error('Invalid sklearn.estimator envelope: version must be a string when provided');
582
828
  }
583
829
  const version = typeof versionValue === 'string' ? versionValue : undefined;
584
- return {
830
+ return tagDecodedShape({
585
831
  className,
586
832
  module,
587
833
  version,
588
834
  params,
589
- };
835
+ }, { marker: 'sklearn.estimator' });
590
836
  };
591
837
  // Why: dispatch over the __tywrap__ typeTag instead of a long if-chain so each type's
592
838
  // decode logic lives in one focused handler. The typeTag strings are the on-the-wire keys
@@ -600,20 +846,48 @@ const ENVELOPE_HANDLERS = new Map([
600
846
  ['torch.tensor', decodeTorchTensorEnvelope],
601
847
  ['sklearn.estimator', decodeSklearnEstimatorEnvelope],
602
848
  ]);
849
+ const MAX_DECODE_DEPTH = 2048;
850
+ const MAX_DECODE_NODES = 1_000_000;
851
+ function isPlainObject(value) {
852
+ if (!isObject(value)) {
853
+ return false;
854
+ }
855
+ const proto = Object.getPrototypeOf(value);
856
+ return proto === Object.prototype || proto === null;
857
+ }
858
+ function isPlainArray(value) {
859
+ return Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype;
860
+ }
861
+ function decodePath(base, key) {
862
+ if (typeof key === 'number') {
863
+ return `${base}[${key}]`;
864
+ }
865
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? `${base}.${key}` : `${base}[${JSON.stringify(key)}]`;
866
+ }
603
867
  function decodeEnvelopeCore(value, decodeArrow, recurse) {
604
868
  if (!isObject(value)) {
605
869
  return value;
606
870
  }
607
871
  const typeTag = value.__tywrap__;
608
- if (typeof typeTag !== 'string') {
872
+ if (!isScientificMarker(typeTag)) {
609
873
  return value;
610
874
  }
611
875
  const handler = ENVELOPE_HANDLERS.get(typeTag);
612
876
  if (!handler) {
613
877
  return value;
614
878
  }
615
- assertCodecVersion(value, typeTag);
616
- return handler(value, decodeArrow, recurse);
879
+ try {
880
+ assertCodecVersion(value, typeTag);
881
+ const decoded = handler(value, decodeArrow, recurse);
882
+ return isPromiseLike(decoded)
883
+ ? Promise.resolve(decoded).catch(error => {
884
+ throw asScientificDecodeError(error, typeTag);
885
+ })
886
+ : decoded;
887
+ }
888
+ catch (error) {
889
+ throw asScientificDecodeError(error, typeTag);
890
+ }
617
891
  }
618
892
  function decodeEnvelope(value, decodeArrow) {
619
893
  const recurse = v => decodeEnvelopeCore(v, decodeArrow, recurse);
@@ -624,26 +898,167 @@ function decodeEnvelope(value, decodeArrow) {
624
898
  return decoded;
625
899
  }
626
900
  async function decodeEnvelopeAsync(value, decodeArrow) {
627
- const recurse = v => decodeEnvelopeCore(v, decodeArrow, recurse);
628
- return await decodeEnvelopeCore(value, decodeArrow, recurse);
901
+ let visitedNodes = 0;
902
+ const recordVisit = (depth, path) => {
903
+ visitedNodes += 1;
904
+ if (visitedNodes > MAX_DECODE_NODES) {
905
+ throw new Error(`Scientific envelope decode maximum visited nodes ${MAX_DECODE_NODES} exceeded at ${path}`);
906
+ }
907
+ if (depth > MAX_DECODE_DEPTH) {
908
+ throw new Error(`Scientific envelope decode maximum depth ${MAX_DECODE_DEPTH} exceeded at ${path}`);
909
+ }
910
+ };
911
+ const prefixHandlerError = (error, path) => {
912
+ if (path === 'result') {
913
+ throw error;
914
+ }
915
+ throw new ScientificDecodeError('envelope', 'unknown', error, `Scientific envelope decode failed at ${path}: ${error instanceof Error ? error.message : String(error)}`);
916
+ };
917
+ const assignDecoded = (target, original, decoded) => {
918
+ if (decoded !== original) {
919
+ if (Array.isArray(target.container)) {
920
+ target.container[target.key] = decoded;
921
+ }
922
+ else {
923
+ target.container[target.key] = decoded;
924
+ }
925
+ }
926
+ };
927
+ const settleDecoded = async (decoded, original, target, path) => {
928
+ let resolved;
929
+ try {
930
+ resolved = await decoded;
931
+ }
932
+ catch (error) {
933
+ return prefixHandlerError(error, path);
934
+ }
935
+ assignDecoded(target, original, resolved);
936
+ };
937
+ const decodeSingle = (item, depth, path) => {
938
+ // Handlers sometimes decode a required child envelope themselves (currently
939
+ // torch.tensor.value). Keep that single-envelope recursion unchanged.
940
+ const recurseSingle = nested => {
941
+ const nestedPath = decodePath(path, 'value');
942
+ recordVisit(depth + 1, nestedPath);
943
+ return decodeEnvelopeCore(nested, decodeArrow, recurseSingle);
944
+ };
945
+ return decodeEnvelopeCore(item, decodeArrow, recurseSingle);
946
+ };
947
+ const frames = [];
948
+ const pending = [];
949
+ const visit = (current, depth, path, target) => {
950
+ const plainArray = isPlainArray(current);
951
+ const plainObject = isPlainObject(current);
952
+ // Only containers consume the traversal budget. Primitive leaves are already
953
+ // bounded by the payload byte cap, and counting them would reject large plain
954
+ // arrays that decoded fine before recursion existed.
955
+ if (plainArray || plainObject) {
956
+ recordVisit(depth, path);
957
+ }
958
+ if (plainObject && typeof current.__tywrap__ === 'string') {
959
+ if (!isScientificMarker(current.__tywrap__)) {
960
+ return;
961
+ }
962
+ let decoded;
963
+ try {
964
+ decoded = decodeSingle(current, depth, path);
965
+ }
966
+ catch (error) {
967
+ return prefixHandlerError(error, path);
968
+ }
969
+ if (isPromiseLike(decoded)) {
970
+ if (target) {
971
+ pending.push(settleDecoded(decoded, current, target, path));
972
+ }
973
+ else {
974
+ pending.push(settleDecoded(decoded, current, { container: root, key: 'value' }, path));
975
+ }
976
+ }
977
+ else if (target) {
978
+ assignDecoded(target, current, decoded);
979
+ }
980
+ else if (decoded !== current) {
981
+ root.value = decoded;
982
+ }
983
+ return;
984
+ }
985
+ if (plainArray) {
986
+ frames.push({ kind: 'array', container: current, depth, path, nextIndex: 0 });
987
+ }
988
+ else if (plainObject) {
989
+ frames.push({
990
+ kind: 'object',
991
+ container: current,
992
+ depth,
993
+ path,
994
+ entries: Object.entries(current),
995
+ nextIndex: 0,
996
+ });
997
+ }
998
+ };
999
+ /**
1000
+ * Values have copy semantics across the bridge: repeated Python references are
1001
+ * serialized independently, and this decoder does not restore alias identity.
1002
+ */
1003
+ const root = { value };
1004
+ visit(value, 0, 'result');
1005
+ const visitChild = (item, key, target, frame) => {
1006
+ try {
1007
+ visit(item, frame.depth + 1, decodePath(frame.path, key), target);
1008
+ }
1009
+ catch (error) {
1010
+ pending.push(Promise.reject(error));
1011
+ }
1012
+ };
1013
+ while (frames.length > 0) {
1014
+ const frame = frames[frames.length - 1];
1015
+ if (!frame) {
1016
+ break;
1017
+ }
1018
+ if (frame.nextIndex >= (frame.kind === 'array' ? frame.container.length : frame.entries.length)) {
1019
+ frames.pop();
1020
+ continue;
1021
+ }
1022
+ const index = frame.nextIndex;
1023
+ frame.nextIndex += 1;
1024
+ if (frame.kind === 'array') {
1025
+ visitChild(frame.container[index], index, { container: frame.container, key: index }, frame);
1026
+ }
1027
+ else {
1028
+ const entry = frame.entries[index];
1029
+ if (entry) {
1030
+ const [key, item] = entry;
1031
+ visitChild(item, key, { container: frame.container, key }, frame);
1032
+ }
1033
+ }
1034
+ }
1035
+ if (pending.length > 0) {
1036
+ await Promise.all(pending);
1037
+ }
1038
+ return root.value;
629
1039
  }
630
1040
  /**
631
1041
  * Decode values produced by the Python bridge.
632
1042
  */
633
1043
  export async function decodeValueAsync(value) {
634
- return await decodeEnvelopeAsync(value, tryDecodeArrowTable);
1044
+ try {
1045
+ return await decodeEnvelopeAsync(value, tryDecodeArrowTable);
1046
+ }
1047
+ catch (error) {
1048
+ throw asScientificDecodeError(error, 'unknown');
1049
+ }
635
1050
  }
636
1051
  /**
637
1052
  * Synchronous decode. Arrow decoding requires a registered decoder.
638
1053
  */
639
1054
  export function decodeValue(value) {
640
- const decodeArrow = (bytes) => {
641
- const decoder = requireArrowDecoder();
1055
+ const decodeArrow = (bytes, marker) => {
1056
+ const decoder = requireArrowDecoder(marker);
642
1057
  try {
643
1058
  return decoder(bytes);
644
1059
  }
645
1060
  catch (err) {
646
- throw new Error(`Arrow decode failed: ${err instanceof Error ? err.message : String(err)}`);
1061
+ throw new ScientificDecodeError('arrow', marker, err, `Arrow decode failed: ${err instanceof Error ? err.message : String(err)}`);
647
1062
  }
648
1063
  };
649
1064
  return decodeEnvelope(value, decodeArrow);