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.
- package/README.md +20 -15
- package/SECURITY.md +5 -6
- package/dist/core/generator.d.ts.map +1 -1
- package/dist/core/generator.js +11 -0
- package/dist/core/generator.js.map +1 -1
- package/dist/runtime/bridge-codec.d.ts +2 -2
- package/dist/runtime/bridge-codec.d.ts.map +1 -1
- package/dist/runtime/bridge-codec.js +51 -7
- package/dist/runtime/bridge-codec.js.map +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
- package/dist/runtime/validators.d.ts +3 -2
- package/dist/runtime/validators.d.ts.map +1 -1
- package/dist/runtime/validators.js +9 -7
- package/dist/runtime/validators.js.map +1 -1
- package/dist/utils/codec.d.ts +16 -0
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +493 -78
- package/dist/utils/codec.js.map +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
- package/runtime/tywrap_bridge_core.py +394 -49
- package/src/core/generator.ts +15 -0
- package/src/runtime/bridge-codec.ts +64 -9
- package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
- package/src/runtime/validators.ts +17 -9
- package/src/utils/codec.ts +743 -114
- package/src/version.ts +1 -1
package/src/utils/codec.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
287
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
439
|
-
typeTag:
|
|
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
|
|
451
|
-
: tagDecodedShape(decoded, { marker: typeTag
|
|
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
|
|
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
|
|
471
|
-
const shape =
|
|
472
|
-
const dtype =
|
|
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
|
-
|
|
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(
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
573
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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:
|
|
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
|
|
684
|
-
const shape =
|
|
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 =
|
|
702
|
-
|
|
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:
|
|
705
|
-
|
|
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
|
-
|
|
710
|
-
|
|
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(
|
|
1110
|
+
return decoded.then(finishTensorDecode) as Promise<T | unknown>;
|
|
722
1111
|
}
|
|
723
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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<
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
844
|
-
|
|
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>(
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
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
|
|
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);
|