tywrap 0.7.0 → 0.8.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 +15 -5
- package/dist/core/annotation-parser.d.ts.map +1 -1
- package/dist/core/annotation-parser.js.map +1 -1
- package/dist/core/emit-call.d.ts.map +1 -1
- package/dist/core/emit-call.js +1 -1
- package/dist/core/emit-call.js.map +1 -1
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +1 -3
- package/dist/dev.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime/frame-codec.d.ts +111 -0
- package/dist/runtime/frame-codec.d.ts.map +1 -0
- package/dist/runtime/frame-codec.js +352 -0
- package/dist/runtime/frame-codec.js.map +1 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/node.d.ts +13 -0
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +5 -0
- package/dist/runtime/node.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/rpc-client.d.ts.map +1 -1
- package/dist/runtime/rpc-client.js +53 -6
- package/dist/runtime/rpc-client.js.map +1 -1
- package/dist/runtime/subprocess-transport.d.ts +172 -7
- package/dist/runtime/subprocess-transport.d.ts.map +1 -1
- package/dist/runtime/subprocess-transport.js +513 -31
- package/dist/runtime/subprocess-transport.js.map +1 -1
- package/dist/runtime/transport.d.ts +85 -3
- package/dist/runtime/transport.d.ts.map +1 -1
- package/dist/runtime/transport.js +20 -0
- package/dist/runtime/transport.js.map +1 -1
- package/dist/types/index.d.ts +24 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/tywrap.js +1 -9
- package/dist/tywrap.js.map +1 -1
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +152 -4
- package/dist/utils/codec.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
- package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
- package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
- package/runtime/frame_codec.py +424 -0
- package/runtime/python_bridge.py +241 -42
- package/runtime/tywrap_bridge_core.py +97 -10
- package/src/core/annotation-parser.ts +2 -1
- package/src/core/emit-call.ts +1 -7
- package/src/dev.ts +1 -3
- package/src/index.ts +1 -0
- package/src/runtime/frame-codec.ts +469 -0
- package/src/runtime/index.ts +1 -6
- package/src/runtime/node.ts +25 -1
- package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
- package/src/runtime/rpc-client.ts +74 -7
- package/src/runtime/subprocess-transport.ts +615 -35
- package/src/runtime/transport.ts +101 -3
- package/src/types/index.ts +25 -0
- package/src/tywrap.ts +1 -9
- package/src/utils/codec.ts +184 -3
- package/src/version.ts +1 -1
package/src/runtime/transport.ts
CHANGED
|
@@ -26,6 +26,28 @@ export const PROTOCOL_ID = 'tywrap/1';
|
|
|
26
26
|
*/
|
|
27
27
|
export const TYWRAP_PROTOCOL_VERSION = Number.parseInt(PROTOCOL_ID.split('/')[1] ?? '', 10);
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Framing protocol identifier for chunked large-payload transport.
|
|
31
|
+
*
|
|
32
|
+
* This is DISTINCT from {@link PROTOCOL_ID}: the logical RPC stays `tywrap/1`,
|
|
33
|
+
* while `tywrap-frame/1` describes a separate layer (below {@link Transport.send})
|
|
34
|
+
* that fragments one logical message across multiple wire frames and reassembles
|
|
35
|
+
* it. An old bridge rejects any non-`tywrap/1` request, so the logical protocol
|
|
36
|
+
* must NOT be bumped to negotiate chunking — a separate framing protocol,
|
|
37
|
+
* advertised through a `tywrap/1` `meta` extension, is used instead.
|
|
38
|
+
*
|
|
39
|
+
* Subprocess-only for 0.8.0 (it is the only backend with a real frame ceiling —
|
|
40
|
+
* the JSONL line-length limit). See docs/transport-framing.md.
|
|
41
|
+
*/
|
|
42
|
+
export const FRAME_PROTOCOL_ID = 'tywrap-frame/1';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Numeric framing-protocol version. Derived from the trailing number of
|
|
46
|
+
* {@link FRAME_PROTOCOL_ID} so the two cannot drift — bump FRAME_PROTOCOL_ID
|
|
47
|
+
* alone and this follows (same pattern as {@link TYWRAP_PROTOCOL_VERSION}).
|
|
48
|
+
*/
|
|
49
|
+
export const FRAME_PROTOCOL_VERSION = Number.parseInt(FRAME_PROTOCOL_ID.split('/')[1] ?? '', 10);
|
|
50
|
+
|
|
29
51
|
// =============================================================================
|
|
30
52
|
// PROTOCOL TYPES
|
|
31
53
|
// =============================================================================
|
|
@@ -102,6 +124,78 @@ export interface ProtocolResponse {
|
|
|
102
124
|
};
|
|
103
125
|
}
|
|
104
126
|
|
|
127
|
+
// =============================================================================
|
|
128
|
+
// CHUNK FRAMING (tywrap-frame/1)
|
|
129
|
+
// =============================================================================
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Per-frame encoding for a {@link ChunkFrame}'s `data` field.
|
|
133
|
+
*
|
|
134
|
+
* - `utf8-slice` (the chosen default for 0.8.0): `data` is a raw substring of the
|
|
135
|
+
* complete logical JSON message, split on UTF-8 codepoint boundaries. Because
|
|
136
|
+
* the logical payload is already valid-UTF-8 JSON, the slices reassemble by
|
|
137
|
+
* simple concatenation — no inflation, no extra decode. See
|
|
138
|
+
* docs/transport-framing.md for the rationale (decision #6).
|
|
139
|
+
* - `utf8-base64`: `data` is a base64-encoded chunk of the UTF-8 bytes, safe for
|
|
140
|
+
* arbitrary byte splits but ~33% larger on the wire with a memory-amplification
|
|
141
|
+
* cost. Reserved as an alternative; not emitted by tywrap in 0.8.0.
|
|
142
|
+
*/
|
|
143
|
+
export type ChunkFrameEncoding = 'utf8-base64' | 'utf8-slice';
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A single wire frame of the `tywrap-frame/1` framing protocol.
|
|
147
|
+
*
|
|
148
|
+
* A frame envelope is DISTINCT from the logical {@link ProtocolMessage} /
|
|
149
|
+
* {@link ProtocolResponse}: it carries a slice of the bytes of ONE complete
|
|
150
|
+
* logical JSON message (a request or a response), fragmented because the payload
|
|
151
|
+
* exceeds the transport's frame ceiling. The framing layer reassembles all
|
|
152
|
+
* frames for a given {@link ChunkFrame.id} back into the single logical message
|
|
153
|
+
* before the JSON/codec path ever sees it.
|
|
154
|
+
*
|
|
155
|
+
* Correlation reuses the existing RPC `id`. `seq` is zero-based; `total` and
|
|
156
|
+
* `totalBytes` are repeated on every frame so the receiver can validate the
|
|
157
|
+
* stream is complete (no missing/duplicate `seq`, exact frame count, exact
|
|
158
|
+
* reassembled byte length) before decoding.
|
|
159
|
+
*
|
|
160
|
+
* @see docs/transport-framing.md
|
|
161
|
+
*/
|
|
162
|
+
export interface ChunkFrame {
|
|
163
|
+
/**
|
|
164
|
+
* Frame-envelope discriminator.
|
|
165
|
+
* - `'chunk'`: a normal data-carrying frame.
|
|
166
|
+
* - `'error'`: a framing-layer error (e.g. the sender could not continue the
|
|
167
|
+
* stream); carries no further data frames for this `id`.
|
|
168
|
+
*/
|
|
169
|
+
__tywrap_frame__: 'chunk' | 'error';
|
|
170
|
+
|
|
171
|
+
/** Framing protocol identifier (must equal {@link FRAME_PROTOCOL_ID}). */
|
|
172
|
+
frameProtocol: string;
|
|
173
|
+
|
|
174
|
+
/** Which logical stream this frame belongs to. */
|
|
175
|
+
stream: 'request' | 'response';
|
|
176
|
+
|
|
177
|
+
/** RPC correlation id, shared with the logical {@link ProtocolMessage.id}. */
|
|
178
|
+
id: number;
|
|
179
|
+
|
|
180
|
+
/** Zero-based sequence index of this frame within its stream. */
|
|
181
|
+
seq: number;
|
|
182
|
+
|
|
183
|
+
/** Total number of frames in this stream (repeated on every frame). */
|
|
184
|
+
total: number;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Total byte length of the complete reassembled logical message (repeated on
|
|
188
|
+
* every frame). Used to validate the reassembled payload exactly.
|
|
189
|
+
*/
|
|
190
|
+
totalBytes: number;
|
|
191
|
+
|
|
192
|
+
/** Per-frame payload encoding (see {@link ChunkFrameEncoding}). */
|
|
193
|
+
encoding: ChunkFrameEncoding;
|
|
194
|
+
|
|
195
|
+
/** This frame's slice of the logical message, encoded per {@link encoding}. */
|
|
196
|
+
data: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
105
199
|
// =============================================================================
|
|
106
200
|
// TRANSPORT CAPABILITIES
|
|
107
201
|
// =============================================================================
|
|
@@ -118,9 +212,13 @@ export interface ProtocolResponse {
|
|
|
118
212
|
* authoritative for transport-level flags; the meta report is authoritative for
|
|
119
213
|
* library availability.
|
|
120
214
|
*
|
|
121
|
-
* Honest for TODAY's behavior: `supportsChunking`
|
|
122
|
-
*
|
|
123
|
-
*
|
|
215
|
+
* Honest for TODAY's behavior: as of 0.8.0 `supportsChunking` is implemented for
|
|
216
|
+
* the subprocess backend and reports the *configured* `tywrap-frame/1` path
|
|
217
|
+
* (`enableChunking`) — static and lifecycle-independent, like `supportsArrow`;
|
|
218
|
+
* the per-bridge negotiated fact lives on {@link BridgeInfo}'s `transport` block.
|
|
219
|
+
* HTTP and Pyodide stay `false`. `supportsStreaming` is `false` on every backend
|
|
220
|
+
* (not implemented in 0.8.0). See docs/transport-capabilities.md for the full
|
|
221
|
+
* matrix.
|
|
124
222
|
*/
|
|
125
223
|
export interface TransportCapabilities {
|
|
126
224
|
/** Which backend this transport drives. */
|
package/src/types/index.ts
CHANGED
|
@@ -413,6 +413,25 @@ export interface TypeMappingConfig {
|
|
|
413
413
|
/** Known bridge backends. Each speaks the identical "tywrap/1" protocol. */
|
|
414
414
|
export type BridgeBackend = 'python-subprocess' | 'pyodide' | 'http';
|
|
415
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Optional chunked-transport negotiation block in {@link BridgeInfo}.
|
|
418
|
+
*
|
|
419
|
+
* Reported by a bridge that understands the `tywrap-frame/1` framing protocol
|
|
420
|
+
* (subprocess only, 0.8.0). It lets the JS side learn, via the `meta` probe,
|
|
421
|
+
* whether the bridge can reassemble chunked frames and the maximum single-frame
|
|
422
|
+
* size it will accept. Absent on old bridges (and on HTTP/Pyodide, which stay
|
|
423
|
+
* single-frame in 0.8.0) — absence means "no chunking", which is backward
|
|
424
|
+
* compatible. See docs/transport-framing.md.
|
|
425
|
+
*/
|
|
426
|
+
export interface BridgeTransportInfo {
|
|
427
|
+
/** Framing protocol the bridge speaks (e.g. `'tywrap-frame/1'`). */
|
|
428
|
+
frameProtocol: string;
|
|
429
|
+
/** Whether the bridge can fragment/reassemble chunked frames. */
|
|
430
|
+
supportsChunking: boolean;
|
|
431
|
+
/** Maximum size, in bytes, of a single wire frame the bridge will accept. */
|
|
432
|
+
maxFrameBytes: number;
|
|
433
|
+
}
|
|
434
|
+
|
|
416
435
|
export interface BridgeInfo {
|
|
417
436
|
protocol: string;
|
|
418
437
|
protocolVersion: number;
|
|
@@ -426,6 +445,12 @@ export interface BridgeInfo {
|
|
|
426
445
|
torchAvailable: boolean;
|
|
427
446
|
sklearnAvailable: boolean;
|
|
428
447
|
instances: number;
|
|
448
|
+
/**
|
|
449
|
+
* Optional chunked-transport negotiation block. Present only when the bridge
|
|
450
|
+
* advertises `tywrap-frame/1` framing; absent on old bridges and on
|
|
451
|
+
* HTTP/Pyodide (single-frame in 0.8.0). See {@link BridgeTransportInfo}.
|
|
452
|
+
*/
|
|
453
|
+
transport?: BridgeTransportInfo;
|
|
429
454
|
}
|
|
430
455
|
|
|
431
456
|
// Analysis and generation results
|
package/src/tywrap.ts
CHANGED
|
@@ -441,15 +441,7 @@ async function fetchPythonIr(
|
|
|
441
441
|
try {
|
|
442
442
|
const primary = await execAndParseIr(
|
|
443
443
|
pythonPath,
|
|
444
|
-
[
|
|
445
|
-
'-m',
|
|
446
|
-
'tywrap_ir',
|
|
447
|
-
'--module',
|
|
448
|
-
moduleName,
|
|
449
|
-
'--ir-version',
|
|
450
|
-
TYWRAP_IR_VERSION,
|
|
451
|
-
'--no-pretty',
|
|
452
|
-
],
|
|
444
|
+
['-m', 'tywrap_ir', '--module', moduleName, '--ir-version', TYWRAP_IR_VERSION, '--no-pretty'],
|
|
453
445
|
execOptions,
|
|
454
446
|
'tywrap_ir output'
|
|
455
447
|
);
|
package/src/utils/codec.ts
CHANGED
|
@@ -508,6 +508,31 @@ const decodeNdarrayEnvelope: EnvelopeHandler = (value, decodeArrow) => {
|
|
|
508
508
|
throw new Error(`Invalid ndarray envelope: unsupported encoding ${String(encoding)}`);
|
|
509
509
|
};
|
|
510
510
|
|
|
511
|
+
/**
|
|
512
|
+
* Assert an array holds only integer indices within [0, bound). Used to re-validate
|
|
513
|
+
* scipy.sparse index arrays on the JS side so a corrupt/oversized index can never
|
|
514
|
+
* silently address out of the declared shape.
|
|
515
|
+
*
|
|
516
|
+
* Why: the JS decoder never reconstructs a Python object, but it IS the boundary a
|
|
517
|
+
* downstream consumer trusts — validating index ranges here turns a corrupt payload
|
|
518
|
+
* into a clear, early failure instead of a confusing downstream error.
|
|
519
|
+
*/
|
|
520
|
+
function assertIndexArrayInRange(arr: readonly unknown[], bound: number, label: string): void {
|
|
521
|
+
for (let i = 0; i < arr.length; i += 1) {
|
|
522
|
+
const idx = arr[i];
|
|
523
|
+
if (typeof idx !== 'number' || !Number.isInteger(idx)) {
|
|
524
|
+
throw new Error(
|
|
525
|
+
`Invalid scipy.sparse envelope: ${label}[${i}] must be an integer, got ${String(idx)}`
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (idx < 0 || idx >= bound) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`Invalid scipy.sparse envelope: ${label}[${i}]=${idx} is out of range [0, ${bound})`
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
511
536
|
const decodeScipySparseEnvelope: EnvelopeHandler = value => {
|
|
512
537
|
const encoding = value.encoding;
|
|
513
538
|
if (encoding !== 'json') {
|
|
@@ -522,10 +547,16 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
|
|
|
522
547
|
!Array.isArray(shape) ||
|
|
523
548
|
shape.length !== 2 ||
|
|
524
549
|
typeof shape[0] !== 'number' ||
|
|
525
|
-
|
|
550
|
+
!Number.isInteger(shape[0]) ||
|
|
551
|
+
shape[0] < 0 ||
|
|
552
|
+
typeof shape[1] !== 'number' ||
|
|
553
|
+
!Number.isInteger(shape[1]) ||
|
|
554
|
+
shape[1] < 0
|
|
526
555
|
) {
|
|
527
|
-
throw new Error('Invalid scipy.sparse envelope: shape must be a 2-item
|
|
556
|
+
throw new Error('Invalid scipy.sparse envelope: shape must be a 2-item non-negative integer[]');
|
|
528
557
|
}
|
|
558
|
+
const rows = shape[0];
|
|
559
|
+
const cols = shape[1];
|
|
529
560
|
const data = value.data;
|
|
530
561
|
if (!Array.isArray(data)) {
|
|
531
562
|
throw new Error('Invalid scipy.sparse envelope: data must be an array');
|
|
@@ -539,6 +570,16 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
|
|
|
539
570
|
if (!Array.isArray(row) || !Array.isArray(col)) {
|
|
540
571
|
throw new Error('Invalid scipy.sparse envelope: coo requires row and col arrays');
|
|
541
572
|
}
|
|
573
|
+
// COO: one (row, col, value) triple per stored entry, so all three arrays
|
|
574
|
+
// share a length; row/col index into [0, rows)/[0, cols) respectively.
|
|
575
|
+
if (row.length !== data.length || col.length !== data.length) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
`Invalid scipy.sparse envelope: coo row/col/data lengths must match ` +
|
|
578
|
+
`(data=${data.length}, row=${row.length}, col=${col.length})`
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
assertIndexArrayInRange(row, rows, 'row');
|
|
582
|
+
assertIndexArrayInRange(col, cols, 'col');
|
|
542
583
|
return {
|
|
543
584
|
format,
|
|
544
585
|
shape,
|
|
@@ -554,6 +595,52 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
|
|
|
554
595
|
if (!Array.isArray(indices) || !Array.isArray(indptr)) {
|
|
555
596
|
throw new Error('Invalid scipy.sparse envelope: csr/csc requires indices and indptr arrays');
|
|
556
597
|
}
|
|
598
|
+
// CSR/CSC: one column-index (CSR) or row-index (CSC) per stored value, so
|
|
599
|
+
// indices and data share a length. indptr partitions indices into one segment
|
|
600
|
+
// per major axis (rows for CSR, cols for CSC), so indptr has majorAxis + 1
|
|
601
|
+
// entries; the inner indices address the minor axis.
|
|
602
|
+
if (indices.length !== data.length) {
|
|
603
|
+
throw new Error(
|
|
604
|
+
`Invalid scipy.sparse envelope: ${format} indices/data lengths must match ` +
|
|
605
|
+
`(data=${data.length}, indices=${indices.length})`
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
const majorAxis = format === 'csr' ? rows : cols;
|
|
609
|
+
const minorAxis = format === 'csr' ? cols : rows;
|
|
610
|
+
if (indptr.length !== majorAxis + 1) {
|
|
611
|
+
throw new Error(
|
|
612
|
+
`Invalid scipy.sparse envelope: ${format} indptr length must be ${majorAxis + 1} ` +
|
|
613
|
+
`(${format === 'csr' ? 'rows' : 'cols'}+1), got ${indptr.length}`
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
// indptr must be a valid CSR/CSC pointer array: integers that start at 0, end
|
|
617
|
+
// at data.length, and never decrease — otherwise a structurally impossible
|
|
618
|
+
// matrix (e.g. [0, 99, 1]) would slip through the length check above.
|
|
619
|
+
for (let i = 0; i < indptr.length; i += 1) {
|
|
620
|
+
const ptr = indptr[i];
|
|
621
|
+
if (typeof ptr !== 'number' || !Number.isInteger(ptr)) {
|
|
622
|
+
throw new Error(
|
|
623
|
+
`Invalid scipy.sparse envelope: indptr[${i}] must be an integer, got ${String(ptr)}`
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
if (ptr < 0 || ptr > data.length) {
|
|
627
|
+
throw new Error(
|
|
628
|
+
`Invalid scipy.sparse envelope: indptr[${i}]=${ptr} is out of range [0, ${data.length}]`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
if (i > 0 && ptr < (indptr[i - 1] as number)) {
|
|
632
|
+
throw new Error('Invalid scipy.sparse envelope: indptr must be non-decreasing');
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (indptr[0] !== 0) {
|
|
636
|
+
throw new Error('Invalid scipy.sparse envelope: indptr must start at 0');
|
|
637
|
+
}
|
|
638
|
+
if (indptr[indptr.length - 1] !== data.length) {
|
|
639
|
+
throw new Error(
|
|
640
|
+
`Invalid scipy.sparse envelope: indptr must end at data.length (${data.length})`
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
assertIndexArrayInRange(indices, minorAxis, 'indices');
|
|
557
644
|
return {
|
|
558
645
|
format,
|
|
559
646
|
shape,
|
|
@@ -564,6 +651,11 @@ const decodeScipySparseEnvelope: EnvelopeHandler = value => {
|
|
|
564
651
|
} satisfies SparseMatrix;
|
|
565
652
|
};
|
|
566
653
|
|
|
654
|
+
/** Product of a shape's dimensions (the element count). [] (scalar) -> 1. */
|
|
655
|
+
function shapeProduct(shape: readonly number[]): number {
|
|
656
|
+
return shape.reduce((acc, dim) => acc * dim, 1);
|
|
657
|
+
}
|
|
658
|
+
|
|
567
659
|
const decodeTorchTensorEnvelope: EnvelopeHandler = <T>(
|
|
568
660
|
value: { [k: string]: unknown },
|
|
569
661
|
_decodeArrow: (bytes: Uint8Array) => MaybePromise<T>,
|
|
@@ -580,20 +672,96 @@ const decodeTorchTensorEnvelope: EnvelopeHandler = <T>(
|
|
|
580
672
|
if (!isObject(nested) || (nested as { __tywrap__?: unknown }).__tywrap__ !== 'ndarray') {
|
|
581
673
|
throw new Error('Invalid torch.tensor envelope: value must be an ndarray envelope');
|
|
582
674
|
}
|
|
583
|
-
const decoded = recurse(nested);
|
|
584
675
|
const shapeValue = value.shape;
|
|
585
676
|
const shape = isNumberArray(shapeValue) ? shapeValue : undefined;
|
|
677
|
+
// The tensor shape must be a non-negative-integer dimension list. A negative or
|
|
678
|
+
// non-integer dim is a corrupt envelope, not a valid tensor.
|
|
679
|
+
if (shape) {
|
|
680
|
+
for (let i = 0; i < shape.length; i += 1) {
|
|
681
|
+
const dim = shape[i] as number;
|
|
682
|
+
if (!Number.isInteger(dim) || dim < 0) {
|
|
683
|
+
throw new Error(
|
|
684
|
+
`Invalid torch.tensor envelope: shape[${i}]=${dim} must be a non-negative integer`
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// Cross-check the tensor shape's element count against the nested ndarray's
|
|
690
|
+
// declared shape (metadata only — no decode needed). A mismatch means the two
|
|
691
|
+
// shapes disagree about how many elements the payload holds.
|
|
692
|
+
const nestedShapeValue = (nested as { shape?: unknown }).shape;
|
|
693
|
+
const nestedShape = isNumberArray(nestedShapeValue) ? nestedShapeValue : undefined;
|
|
694
|
+
if (shape && nestedShape && shapeProduct(shape) !== shapeProduct(nestedShape)) {
|
|
695
|
+
throw new Error(
|
|
696
|
+
`Invalid torch.tensor envelope: shape ${JSON.stringify(shape)} ` +
|
|
697
|
+
`(product ${shapeProduct(shape)}) disagrees with nested ndarray shape ` +
|
|
698
|
+
`${JSON.stringify(nestedShape)} (product ${shapeProduct(nestedShape)})`
|
|
699
|
+
);
|
|
700
|
+
}
|
|
586
701
|
const dtypeValue = value.dtype;
|
|
587
702
|
const dtype = typeof dtypeValue === 'string' ? dtypeValue : undefined;
|
|
588
703
|
const deviceValue = value.device;
|
|
704
|
+
if (deviceValue !== undefined && (typeof deviceValue !== 'string' || deviceValue.length === 0)) {
|
|
705
|
+
throw new Error(
|
|
706
|
+
'Invalid torch.tensor envelope: device must be a non-empty string when provided'
|
|
707
|
+
);
|
|
708
|
+
}
|
|
589
709
|
const device = typeof deviceValue === 'string' ? deviceValue : undefined;
|
|
590
710
|
|
|
711
|
+
const decoded = recurse(nested);
|
|
591
712
|
if (isPromiseLike(decoded)) {
|
|
592
713
|
return decoded.then(data => ({ data, shape, dtype, device })) as Promise<T | unknown>;
|
|
593
714
|
}
|
|
594
715
|
return { data: decoded, shape, dtype, device } satisfies TorchTensor;
|
|
595
716
|
};
|
|
596
717
|
|
|
718
|
+
/**
|
|
719
|
+
* Recursively assert a value is plain JSON (null | boolean | number | string |
|
|
720
|
+
* JSON array | plain object of JSON). Rejects functions, symbols, bigints, class
|
|
721
|
+
* instances, and any non-finite number — the things a metadata-only sklearn
|
|
722
|
+
* envelope must never carry. This validates; it never reconstructs.
|
|
723
|
+
*/
|
|
724
|
+
function assertPlainJson(value: unknown, path: string): void {
|
|
725
|
+
if (value === null) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const t = typeof value;
|
|
729
|
+
if (t === 'string' || t === 'boolean') {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (t === 'number') {
|
|
733
|
+
if (!Number.isFinite(value as number)) {
|
|
734
|
+
throw new Error(
|
|
735
|
+
`Invalid sklearn.estimator envelope: ${path} must be a finite JSON number, got ${String(value)}`
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (Array.isArray(value)) {
|
|
741
|
+
value.forEach((item, i) => assertPlainJson(item, `${path}[${i}]`));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (t === 'object') {
|
|
745
|
+
// Reject exotic objects (class instances, Map/Set, etc.): a JSON object is a
|
|
746
|
+
// plain object whose prototype is Object.prototype or null.
|
|
747
|
+
const proto = Object.getPrototypeOf(value as object);
|
|
748
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
749
|
+
throw new Error(
|
|
750
|
+
`Invalid sklearn.estimator envelope: ${path} must be a plain JSON object, ` +
|
|
751
|
+
`got ${(value as object).constructor?.name ?? 'object'}`
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
755
|
+
assertPlainJson(v, `${path}.${k}`);
|
|
756
|
+
}
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
// function | symbol | bigint | undefined
|
|
760
|
+
throw new Error(
|
|
761
|
+
`Invalid sklearn.estimator envelope: ${path} is not JSON-serializable (type ${t})`
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
|
|
597
765
|
const decodeSklearnEstimatorEnvelope: EnvelopeHandler = value => {
|
|
598
766
|
const encoding = value.encoding;
|
|
599
767
|
if (encoding !== 'json') {
|
|
@@ -607,6 +775,19 @@ const decodeSklearnEstimatorEnvelope: EnvelopeHandler = value => {
|
|
|
607
775
|
'Invalid sklearn.estimator envelope: expected className/module strings + params object'
|
|
608
776
|
);
|
|
609
777
|
}
|
|
778
|
+
// params must be a PLAIN JSON object end to end — metadata-only estimators never
|
|
779
|
+
// carry callables, class instances, or nested non-JSON values. Validate (do not
|
|
780
|
+
// reconstruct) so a corrupt envelope fails clearly instead of leaking a function
|
|
781
|
+
// or exotic object to a downstream consumer.
|
|
782
|
+
if (
|
|
783
|
+
Object.getPrototypeOf(params) !== Object.prototype &&
|
|
784
|
+
Object.getPrototypeOf(params) !== null
|
|
785
|
+
) {
|
|
786
|
+
throw new Error('Invalid sklearn.estimator envelope: params must be a plain JSON object');
|
|
787
|
+
}
|
|
788
|
+
for (const [k, v] of Object.entries(params)) {
|
|
789
|
+
assertPlainJson(v, `params.${k}`);
|
|
790
|
+
}
|
|
610
791
|
const versionValue = value.version;
|
|
611
792
|
if (versionValue !== undefined && typeof versionValue !== 'string') {
|
|
612
793
|
throw new Error('Invalid sklearn.estimator envelope: version must be a string when provided');
|
package/src/version.ts
CHANGED