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.
Files changed (73) hide show
  1. package/README.md +15 -5
  2. package/dist/core/annotation-parser.d.ts.map +1 -1
  3. package/dist/core/annotation-parser.js.map +1 -1
  4. package/dist/core/emit-call.d.ts.map +1 -1
  5. package/dist/core/emit-call.js +1 -1
  6. package/dist/core/emit-call.js.map +1 -1
  7. package/dist/dev.d.ts.map +1 -1
  8. package/dist/dev.js +1 -3
  9. package/dist/dev.js.map +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/runtime/frame-codec.d.ts +111 -0
  14. package/dist/runtime/frame-codec.d.ts.map +1 -0
  15. package/dist/runtime/frame-codec.js +352 -0
  16. package/dist/runtime/frame-codec.js.map +1 -0
  17. package/dist/runtime/index.d.ts +1 -1
  18. package/dist/runtime/index.d.ts.map +1 -1
  19. package/dist/runtime/index.js +1 -1
  20. package/dist/runtime/index.js.map +1 -1
  21. package/dist/runtime/node.d.ts +13 -0
  22. package/dist/runtime/node.d.ts.map +1 -1
  23. package/dist/runtime/node.js +5 -0
  24. package/dist/runtime/node.js.map +1 -1
  25. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  26. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  27. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  28. package/dist/runtime/rpc-client.d.ts.map +1 -1
  29. package/dist/runtime/rpc-client.js +53 -6
  30. package/dist/runtime/rpc-client.js.map +1 -1
  31. package/dist/runtime/subprocess-transport.d.ts +172 -7
  32. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  33. package/dist/runtime/subprocess-transport.js +513 -31
  34. package/dist/runtime/subprocess-transport.js.map +1 -1
  35. package/dist/runtime/transport.d.ts +85 -3
  36. package/dist/runtime/transport.d.ts.map +1 -1
  37. package/dist/runtime/transport.js +20 -0
  38. package/dist/runtime/transport.js.map +1 -1
  39. package/dist/types/index.d.ts +24 -0
  40. package/dist/types/index.d.ts.map +1 -1
  41. package/dist/tywrap.js +1 -9
  42. package/dist/tywrap.js.map +1 -1
  43. package/dist/utils/codec.d.ts.map +1 -1
  44. package/dist/utils/codec.js +152 -4
  45. package/dist/utils/codec.js.map +1 -1
  46. package/dist/version.js +1 -1
  47. package/package.json +1 -1
  48. package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
  49. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  50. package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
  51. package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
  52. package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
  53. package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
  54. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  55. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  56. package/runtime/frame_codec.py +424 -0
  57. package/runtime/python_bridge.py +241 -42
  58. package/runtime/tywrap_bridge_core.py +97 -10
  59. package/src/core/annotation-parser.ts +2 -1
  60. package/src/core/emit-call.ts +1 -7
  61. package/src/dev.ts +1 -3
  62. package/src/index.ts +1 -0
  63. package/src/runtime/frame-codec.ts +469 -0
  64. package/src/runtime/index.ts +1 -6
  65. package/src/runtime/node.ts +25 -1
  66. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  67. package/src/runtime/rpc-client.ts +74 -7
  68. package/src/runtime/subprocess-transport.ts +615 -35
  69. package/src/runtime/transport.ts +101 -3
  70. package/src/types/index.ts +25 -0
  71. package/src/tywrap.ts +1 -9
  72. package/src/utils/codec.ts +184 -3
  73. package/src/version.ts +1 -1
@@ -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` and `supportsStreaming` are
122
- * `false` on every backend both are planned for 0.8.0 and no backend implements
123
- * them yet. See docs/transport-capabilities.md for the full matrix.
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. */
@@ -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
  );
@@ -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
- typeof shape[1] !== 'number'
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 number[]');
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
@@ -9,4 +9,4 @@
9
9
  * Regenerate with: node scripts/generate-version.mjs
10
10
  */
11
11
 
12
- export const VERSION: string = "0.7.0";
12
+ export const VERSION: string = "0.8.0";