tywrap 0.1.0 → 0.1.2

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 (63) hide show
  1. package/README.md +54 -221
  2. package/dist/cli.js +70 -3
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/index.js +56 -12
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/core/generator.d.ts.map +1 -1
  8. package/dist/core/generator.js +6 -2
  9. package/dist/core/generator.js.map +1 -1
  10. package/dist/core/mapper.d.ts.map +1 -1
  11. package/dist/core/mapper.js +20 -5
  12. package/dist/core/mapper.js.map +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/runtime/bridge-core.d.ts +64 -0
  18. package/dist/runtime/bridge-core.d.ts.map +1 -0
  19. package/dist/runtime/bridge-core.js +344 -0
  20. package/dist/runtime/bridge-core.js.map +1 -0
  21. package/dist/runtime/node.d.ts +5 -6
  22. package/dist/runtime/node.d.ts.map +1 -1
  23. package/dist/runtime/node.js +95 -193
  24. package/dist/runtime/node.js.map +1 -1
  25. package/dist/runtime/optimized-node.d.ts +4 -7
  26. package/dist/runtime/optimized-node.d.ts.map +1 -1
  27. package/dist/runtime/optimized-node.js +117 -186
  28. package/dist/runtime/optimized-node.js.map +1 -1
  29. package/dist/runtime/timed-out-request-tracker.d.ts +20 -0
  30. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -0
  31. package/dist/runtime/timed-out-request-tracker.js +48 -0
  32. package/dist/runtime/timed-out-request-tracker.js.map +1 -0
  33. package/dist/types/index.d.ts +3 -0
  34. package/dist/types/index.d.ts.map +1 -1
  35. package/dist/tywrap.d.ts +17 -4
  36. package/dist/tywrap.d.ts.map +1 -1
  37. package/dist/tywrap.js +68 -35
  38. package/dist/tywrap.js.map +1 -1
  39. package/dist/utils/codec.d.ts +22 -0
  40. package/dist/utils/codec.d.ts.map +1 -1
  41. package/dist/utils/codec.js +245 -55
  42. package/dist/utils/codec.js.map +1 -1
  43. package/dist/utils/logger.d.ts.map +1 -1
  44. package/dist/utils/logger.js +7 -4
  45. package/dist/utils/logger.js.map +1 -1
  46. package/dist/utils/python.d.ts.map +1 -1
  47. package/dist/utils/python.js.map +1 -1
  48. package/package.json +11 -2
  49. package/runtime/python_bridge.py +450 -69
  50. package/src/cli.ts +81 -5
  51. package/src/config/index.ts +67 -16
  52. package/src/core/generator.ts +6 -2
  53. package/src/core/mapper.ts +21 -8
  54. package/src/index.ts +2 -1
  55. package/src/runtime/bridge-core.ts +454 -0
  56. package/src/runtime/node.ts +117 -224
  57. package/src/runtime/optimized-node.ts +160 -235
  58. package/src/runtime/timed-out-request-tracker.ts +58 -0
  59. package/src/types/index.ts +3 -0
  60. package/src/tywrap.ts +91 -36
  61. package/src/utils/codec.ts +306 -83
  62. package/src/utils/logger.ts +11 -7
  63. package/src/utils/python.ts +0 -1
package/src/tywrap.ts CHANGED
@@ -72,16 +72,50 @@ expectType<Record<string, unknown>>(mod);
72
72
  await writeFile(filePath, content, 'utf-8');
73
73
  }
74
74
 
75
+ export interface GenerateRunOptions {
76
+ /**
77
+ * If true, do not write files; instead compare generated output to what's on disk.
78
+ * Intended for CI to ensure wrappers are checked in and up to date.
79
+ */
80
+ check?: boolean;
81
+ }
82
+
83
+ export interface GenerateResult {
84
+ written: string[];
85
+ warnings: string[];
86
+ /**
87
+ * Only set when `GenerateRunOptions.check === true`.
88
+ * Lists files that are missing or differ from what would be generated.
89
+ */
90
+ outOfDate?: string[];
91
+ }
92
+
93
+ function normalizeForComparison(text: string): string {
94
+ // Avoid false positives from CRLF vs LF (e.g. Windows checkouts)
95
+ return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
96
+ }
97
+
98
+ async function safeReadFile(path: string): Promise<string | null> {
99
+ try {
100
+ return await fsUtils.readFile(path);
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
75
106
  /**
76
107
  * Generate TypeScript wrappers for configured Python modules.
77
108
  * Minimal MVP implementation: resolves module file, analyzes, generates TS, writes to output.dir
78
109
  */
79
110
  export async function generate(
80
- options: Partial<TywrapOptions>
81
- ): Promise<{ written: string[]; warnings: string[] }> {
111
+ options: Partial<TywrapOptions>,
112
+ runOptions: GenerateRunOptions = {}
113
+ ): Promise<GenerateResult> {
114
+ const checkMode = runOptions.check === true;
82
115
  const resolvedOptions = createConfig(options);
83
116
  const instance = await tywrap(resolvedOptions);
84
117
  const written: string[] = [];
118
+ const outOfDate: string[] = [];
85
119
  const warnings: string[] = [];
86
120
  const outputDir = resolvedOptions.output.dir;
87
121
  const caching = resolvedOptions.performance.caching;
@@ -92,11 +126,13 @@ export async function generate(
92
126
  });
93
127
 
94
128
  // Ensure directory exists (Node-only best-effort)
95
- try {
96
- const modFs = await import('fs/promises');
97
- await modFs.mkdir(outputDir, { recursive: true });
98
- } catch {
99
- // ignore in non-node or if already exists
129
+ if (!checkMode) {
130
+ try {
131
+ const modFs = await import('fs/promises');
132
+ await modFs.mkdir(outputDir, { recursive: true });
133
+ } catch {
134
+ // ignore in non-node or if already exists
135
+ }
100
136
  }
101
137
 
102
138
  const modules = resolvedOptions.pythonModules ?? {};
@@ -109,10 +145,6 @@ export async function generate(
109
145
  let ir: unknown | null = null;
110
146
  let irError: string | undefined;
111
147
  if (caching && fsUtils.isAvailable()) {
112
- try {
113
- const modFs = await import('fs/promises');
114
- await modFs.mkdir(cacheDir, { recursive: true });
115
- } catch {}
116
148
  try {
117
149
  const cached = await fsUtils.readFile(pathUtils.join(cacheDir, cacheKey));
118
150
  ir = JSON.parse(cached);
@@ -124,7 +156,7 @@ export async function generate(
124
156
  const fetchResult = await fetchPythonIr(moduleKey, pythonPath);
125
157
  ir = fetchResult.ir;
126
158
  irError = fetchResult.error;
127
- if (ir && caching && fsUtils.isAvailable()) {
159
+ if (ir && caching && fsUtils.isAvailable() && !checkMode) {
128
160
  try {
129
161
  await fsUtils.writeFile(pathUtils.join(cacheDir, cacheKey), JSON.stringify(ir));
130
162
  } catch {}
@@ -141,26 +173,43 @@ export async function generate(
141
173
  const annotatedJSDoc = Boolean(resolvedOptions.output?.annotatedJSDoc);
142
174
  const gen = instance.generator.generateModuleDefinition(moduleModel, annotatedJSDoc);
143
175
 
144
- // Write file
145
176
  const baseName = moduleModel.name || 'module';
146
- const outPath = pathUtils.join(outputDir, `${baseName}.generated.ts`);
147
- await fsUtils.writeFile(outPath, gen.typescript);
148
- written.push(outPath);
177
+ const filesToEmit: Array<{ path: string; content: string }> = [
178
+ { path: pathUtils.join(outputDir, `${baseName}.generated.ts`), content: gen.typescript },
179
+ ];
149
180
 
150
181
  // Optional .d.ts emission (header-only declarations mirroring exports)
151
182
  if (resolvedOptions.output?.declaration) {
152
- const dtsPath = pathUtils.join(outputDir, `${baseName}.generated.d.ts`);
153
- const dts = renderDts(gen.typescript);
154
- await fsUtils.writeFile(dtsPath, dts);
155
- written.push(dtsPath);
183
+ filesToEmit.push({
184
+ path: pathUtils.join(outputDir, `${baseName}.generated.d.ts`),
185
+ content: renderDts(gen.typescript),
186
+ });
156
187
  }
157
188
 
158
189
  // Optional source map emission (placeholder mapping for now)
159
190
  if (resolvedOptions.output?.sourceMap) {
160
- const mapPath = pathUtils.join(outputDir, `${baseName}.generated.ts.map`);
161
- const map = renderSourceMapPlaceholder(moduleModel.name);
162
- await fsUtils.writeFile(mapPath, map);
163
- written.push(mapPath);
191
+ filesToEmit.push({
192
+ path: pathUtils.join(outputDir, `${baseName}.generated.ts.map`),
193
+ content: renderSourceMapPlaceholder(moduleModel.name),
194
+ });
195
+ }
196
+
197
+ if (checkMode) {
198
+ for (const file of filesToEmit) {
199
+ const existing = await safeReadFile(file.path);
200
+ if (existing === null) {
201
+ outOfDate.push(file.path);
202
+ continue;
203
+ }
204
+ if (normalizeForComparison(existing) !== normalizeForComparison(file.content)) {
205
+ outOfDate.push(file.path);
206
+ }
207
+ }
208
+ } else {
209
+ for (const file of filesToEmit) {
210
+ await fsUtils.writeFile(file.path, file.content);
211
+ written.push(file.path);
212
+ }
164
213
  }
165
214
 
166
215
  // Emit warning summary of unknown typing constructs (best-effort)
@@ -174,22 +223,28 @@ export async function generate(
174
223
  `Module ${baseName}: unknown typing constructs encountered: ${unkList}${entries.length > 25 ? '…' : ''}`
175
224
  );
176
225
  // Write JSON report
177
- try {
178
- const reportsDir = pathUtils.join('.tywrap', 'reports');
179
- await fsUtils.writeFile(
180
- pathUtils.join(reportsDir, `${baseName}.json`),
181
- JSON.stringify({
182
- module: baseName,
183
- unknowns: Object.fromEntries(entries),
184
- generatedAt: new Date().toISOString(),
185
- })
186
- );
187
- } catch {
188
- // ignore
226
+ if (!checkMode) {
227
+ try {
228
+ const reportsDir = pathUtils.join('.tywrap', 'reports');
229
+ await fsUtils.writeFile(
230
+ pathUtils.join(reportsDir, `${baseName}.json`),
231
+ JSON.stringify({
232
+ module: baseName,
233
+ unknowns: Object.fromEntries(entries),
234
+ generatedAt: new Date().toISOString(),
235
+ })
236
+ );
237
+ } catch {
238
+ // ignore
239
+ }
189
240
  }
190
241
  }
191
242
  }
192
243
 
244
+ if (checkMode) {
245
+ return { written: [], warnings, outOfDate };
246
+ }
247
+
193
248
  return { written, warnings };
194
249
  }
195
250
 
@@ -43,11 +43,19 @@ export interface SklearnEstimator {
43
43
  export type CodecEnvelope =
44
44
  | {
45
45
  readonly __tywrap__: 'dataframe';
46
+ readonly codecVersion?: number;
46
47
  readonly encoding: 'arrow';
47
48
  readonly b64: string;
48
49
  }
50
+ | {
51
+ readonly __tywrap__: 'dataframe';
52
+ readonly codecVersion?: number;
53
+ readonly encoding: 'json';
54
+ readonly data: unknown;
55
+ }
49
56
  | {
50
57
  readonly __tywrap__: 'series';
58
+ readonly codecVersion?: number;
51
59
  readonly encoding: 'arrow' | 'json';
52
60
  readonly b64?: string;
53
61
  readonly data?: unknown;
@@ -55,6 +63,7 @@ export type CodecEnvelope =
55
63
  }
56
64
  | {
57
65
  readonly __tywrap__: 'ndarray';
66
+ readonly codecVersion?: number;
58
67
  readonly encoding: 'arrow' | 'json';
59
68
  readonly b64?: string; // when encoding=arrow
60
69
  readonly data?: unknown; // when encoding=json
@@ -62,6 +71,7 @@ export type CodecEnvelope =
62
71
  }
63
72
  | {
64
73
  readonly __tywrap__: 'scipy.sparse';
74
+ readonly codecVersion?: number;
65
75
  readonly encoding: 'json';
66
76
  readonly format: 'csr' | 'csc' | 'coo';
67
77
  readonly shape: readonly number[];
@@ -74,6 +84,7 @@ export type CodecEnvelope =
74
84
  }
75
85
  | {
76
86
  readonly __tywrap__: 'torch.tensor';
87
+ readonly codecVersion?: number;
77
88
  readonly encoding: 'ndarray';
78
89
  readonly value: unknown;
79
90
  readonly shape?: readonly number[];
@@ -82,6 +93,7 @@ export type CodecEnvelope =
82
93
  }
83
94
  | {
84
95
  readonly __tywrap__: 'sklearn.estimator';
96
+ readonly codecVersion?: number;
85
97
  readonly encoding: 'json';
86
98
  readonly className: string;
87
99
  readonly module: string;
@@ -89,7 +101,13 @@ export type CodecEnvelope =
89
101
  readonly params: Record<string, unknown>;
90
102
  };
91
103
 
92
- export type DecodedValue = ArrowTable | Uint8Array | SparseMatrix | TorchTensor | SklearnEstimator | unknown;
104
+ export type DecodedValue =
105
+ | ArrowTable
106
+ | Uint8Array
107
+ | SparseMatrix
108
+ | TorchTensor
109
+ | SklearnEstimator
110
+ | unknown;
93
111
 
94
112
  let arrowTableFrom: ((bytes: Uint8Array) => ArrowTable | Uint8Array) | undefined;
95
113
 
@@ -110,10 +128,79 @@ export function hasArrowDecoder(): boolean {
110
128
  return typeof arrowTableFrom === 'function';
111
129
  }
112
130
 
131
+ type ArrowModuleLoader = () => unknown | Promise<unknown>;
132
+
133
+ /**
134
+ * Detect Node.js runtime capabilities without hard dependencies.
135
+ *
136
+ * Why: keep browser/bundler builds safe while still enabling Node-only paths.
137
+ */
138
+ function isNodeRuntime(): boolean {
139
+ return (
140
+ typeof process !== 'undefined' &&
141
+ typeof (process as { versions?: { node?: string } }).versions?.node === 'string'
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Validate the Arrow module shape and register its IPC decoder.
147
+ *
148
+ * Why: centralize tableFromIPC checks so callers get consistent errors and can
149
+ * rely on a single registration path.
150
+ */
151
+ function registerArrowDecoderFromModule(module: { tableFromIPC?: unknown }): void {
152
+ const tableFromIPC = module.tableFromIPC;
153
+ if (typeof tableFromIPC !== 'function') {
154
+ throw new Error('apache-arrow does not export tableFromIPC');
155
+ }
156
+ registerArrowDecoder((bytes: Uint8Array) => tableFromIPC(bytes));
157
+ }
158
+
159
+ /**
160
+ * Attempt to lazily register an Arrow decoder at runtime.
161
+ *
162
+ * Why: keep apache-arrow optional while letting NodeBridge (or callers) enable
163
+ * Arrow decoding when the module is present.
164
+ */
165
+ export async function autoRegisterArrowDecoder(
166
+ options: { loader?: ArrowModuleLoader } = {}
167
+ ): Promise<boolean> {
168
+ if (hasArrowDecoder()) {
169
+ return true;
170
+ }
171
+ const loader: ArrowModuleLoader | undefined =
172
+ options.loader ??
173
+ (isNodeRuntime()
174
+ ? async (): Promise<unknown> => {
175
+ try {
176
+ const nodeModule = await import('node:module');
177
+ const require = nodeModule.createRequire(import.meta.url);
178
+ return require('apache-arrow') as unknown;
179
+ } catch {
180
+ return await import('apache-arrow');
181
+ }
182
+ }
183
+ : undefined);
184
+ if (!loader) {
185
+ return false;
186
+ }
187
+ try {
188
+ const arrowModule = await loader();
189
+ registerArrowDecoderFromModule(arrowModule as { tableFromIPC?: unknown });
190
+ return true;
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
113
196
  function isObject(value: unknown): value is { [k: string]: unknown } {
114
197
  return typeof value === 'object' && value !== null;
115
198
  }
116
199
 
200
+ function isNumberArray(value: unknown): value is number[] {
201
+ return Array.isArray(value) && value.every(item => typeof item === 'number');
202
+ }
203
+
117
204
  function fromBase64(b64: string): Uint8Array {
118
205
  if (typeof Buffer !== 'undefined') {
119
206
  const buf = Buffer.from(b64, 'base64');
@@ -145,118 +232,254 @@ async function tryDecodeArrowTable(bytes: Uint8Array): Promise<ArrowTable | Uint
145
232
  }
146
233
  }
147
234
 
148
- function decodeEnvelope<T>(value: unknown, decodeArrow: (bytes: Uint8Array) => T): T | unknown {
235
+ type MaybePromise<T> = T | Promise<T>;
236
+
237
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
238
+ return (
239
+ typeof value === 'object' &&
240
+ value !== null &&
241
+ 'then' in value &&
242
+ typeof (value as { then?: unknown }).then === 'function'
243
+ );
244
+ }
245
+
246
+ // Why: decoding needs to reject incompatible envelopes before we attempt to interpret payloads.
247
+ const CODEC_VERSION = 1;
248
+
249
+ function assertCodecVersion(envelope: { codecVersion?: unknown }, marker: string): void {
250
+ if (!('codecVersion' in envelope)) {
251
+ return;
252
+ }
253
+ const version = envelope.codecVersion;
254
+ if (version === undefined) {
255
+ return;
256
+ }
257
+ if (typeof version !== 'number' || !Number.isFinite(version)) {
258
+ throw new Error(`Invalid ${marker} envelope: codecVersion must be a number`);
259
+ }
260
+ if (version !== CODEC_VERSION) {
261
+ throw new Error(`Unsupported ${marker} envelope codecVersion: ${version}`);
262
+ }
263
+ }
264
+
265
+ function decodeEnvelopeCore<T>(
266
+ value: unknown,
267
+ decodeArrow: (bytes: Uint8Array) => MaybePromise<T>,
268
+ recurse: (value: unknown) => MaybePromise<T | unknown>
269
+ ): MaybePromise<T | unknown> {
149
270
  if (!isObject(value)) {
150
271
  return value;
151
272
  }
152
273
  const marker = (value as { __tywrap__?: unknown }).__tywrap__;
153
- if (
154
- (marker === 'dataframe' || marker === 'series') &&
155
- (value as { encoding?: unknown }).encoding === 'arrow' &&
156
- typeof (value as { b64?: unknown }).b64 === 'string'
157
- ) {
158
- const bytes = fromBase64(String((value as { b64: string }).b64));
159
- return decodeArrow(bytes);
274
+ if (typeof marker !== 'string') {
275
+ return value as unknown;
160
276
  }
277
+
161
278
  if (
162
- marker === 'dataframe' &&
163
- (value as { encoding?: unknown }).encoding === 'json' &&
164
- 'data' in (value as object)
279
+ marker === 'dataframe' ||
280
+ marker === 'series' ||
281
+ marker === 'ndarray' ||
282
+ marker === 'scipy.sparse' ||
283
+ marker === 'torch.tensor' ||
284
+ marker === 'sklearn.estimator'
165
285
  ) {
166
- return (value as { data: unknown }).data;
286
+ assertCodecVersion(value as { codecVersion?: unknown }, marker);
167
287
  }
168
- if (
169
- marker === 'series' &&
170
- (value as { encoding?: unknown }).encoding === 'json' &&
171
- 'data' in (value as object)
172
- ) {
173
- return (value as { data: unknown }).data;
288
+
289
+ if (marker === 'dataframe') {
290
+ const encoding = (value as { encoding?: unknown }).encoding;
291
+ if (encoding === 'arrow') {
292
+ const b64 = (value as { b64?: unknown }).b64;
293
+ if (typeof b64 !== 'string') {
294
+ throw new Error('Invalid dataframe envelope: missing b64');
295
+ }
296
+ const bytes = fromBase64(b64);
297
+ return decodeArrow(bytes);
298
+ }
299
+ if (encoding === 'json') {
300
+ if (!('data' in (value as object))) {
301
+ throw new Error('Invalid dataframe envelope: missing data');
302
+ }
303
+ return (value as { data: unknown }).data;
304
+ }
305
+ throw new Error(`Invalid dataframe envelope: unsupported encoding ${String(encoding)}`);
306
+ }
307
+
308
+ if (marker === 'series') {
309
+ const encoding = (value as { encoding?: unknown }).encoding;
310
+ if (encoding === 'arrow') {
311
+ const b64 = (value as { b64?: unknown }).b64;
312
+ if (typeof b64 !== 'string') {
313
+ throw new Error('Invalid series envelope: missing b64');
314
+ }
315
+ const bytes = fromBase64(b64);
316
+ return decodeArrow(bytes);
317
+ }
318
+ if (encoding === 'json') {
319
+ if (!('data' in (value as object))) {
320
+ throw new Error('Invalid series envelope: missing data');
321
+ }
322
+ return (value as { data: unknown }).data;
323
+ }
324
+ throw new Error(`Invalid series envelope: unsupported encoding ${String(encoding)}`);
174
325
  }
326
+
175
327
  if (marker === 'ndarray') {
176
- if (
177
- (value as { encoding?: unknown }).encoding === 'arrow' &&
178
- typeof (value as { b64?: unknown }).b64 === 'string'
179
- ) {
180
- const bytes = fromBase64(String((value as { b64: string }).b64));
328
+ const encoding = (value as { encoding?: unknown }).encoding;
329
+ if (encoding === 'arrow') {
330
+ const b64 = (value as { b64?: unknown }).b64;
331
+ if (typeof b64 !== 'string') {
332
+ throw new Error('Invalid ndarray envelope: missing b64');
333
+ }
334
+ const bytes = fromBase64(b64);
181
335
  return decodeArrow(bytes);
182
336
  }
183
- if ((value as { encoding?: unknown }).encoding === 'json' && 'data' in (value as object)) {
337
+ if (encoding === 'json') {
338
+ if (!('data' in (value as object))) {
339
+ throw new Error('Invalid ndarray envelope: missing data');
340
+ }
184
341
  return (value as { data: unknown }).data;
185
342
  }
343
+ throw new Error(`Invalid ndarray envelope: unsupported encoding ${String(encoding)}`);
186
344
  }
187
- if (
188
- marker === 'scipy.sparse' &&
189
- (value as { encoding?: unknown }).encoding === 'json' &&
190
- typeof (value as { format?: unknown }).format === 'string' &&
191
- Array.isArray((value as { shape?: unknown }).shape) &&
192
- Array.isArray((value as { data?: unknown }).data)
193
- ) {
194
- const sparse = value as {
195
- format: 'csr' | 'csc' | 'coo';
196
- shape: readonly number[];
197
- data: readonly unknown[];
198
- indices?: readonly number[];
199
- indptr?: readonly number[];
200
- row?: readonly number[];
201
- col?: readonly number[];
202
- dtype?: string;
203
- };
345
+
346
+ if (marker === 'scipy.sparse') {
347
+ const encoding = (value as { encoding?: unknown }).encoding;
348
+ if (encoding !== 'json') {
349
+ throw new Error(`Invalid scipy.sparse envelope: unsupported encoding ${String(encoding)}`);
350
+ }
351
+ const format = (value as { format?: unknown }).format;
352
+ if (format !== 'csr' && format !== 'csc' && format !== 'coo') {
353
+ throw new Error(`Invalid scipy.sparse envelope: unsupported format ${String(format)}`);
354
+ }
355
+ const shape = (value as { shape?: unknown }).shape;
356
+ if (
357
+ !Array.isArray(shape) ||
358
+ shape.length !== 2 ||
359
+ typeof shape[0] !== 'number' ||
360
+ typeof shape[1] !== 'number'
361
+ ) {
362
+ throw new Error('Invalid scipy.sparse envelope: shape must be a 2-item number[]');
363
+ }
364
+ const data = (value as { data?: unknown }).data;
365
+ if (!Array.isArray(data)) {
366
+ throw new Error('Invalid scipy.sparse envelope: data must be an array');
367
+ }
368
+ const dtypeValue = (value as { dtype?: unknown }).dtype;
369
+ const dtype = typeof dtypeValue === 'string' ? dtypeValue : undefined;
370
+
371
+ if (format === 'coo') {
372
+ const row = (value as { row?: unknown }).row;
373
+ const col = (value as { col?: unknown }).col;
374
+ if (!Array.isArray(row) || !Array.isArray(col)) {
375
+ throw new Error('Invalid scipy.sparse envelope: coo requires row and col arrays');
376
+ }
377
+ return {
378
+ format,
379
+ shape,
380
+ data,
381
+ row,
382
+ col,
383
+ dtype,
384
+ } satisfies SparseMatrix;
385
+ }
386
+
387
+ const indices = (value as { indices?: unknown }).indices;
388
+ const indptr = (value as { indptr?: unknown }).indptr;
389
+ if (!Array.isArray(indices) || !Array.isArray(indptr)) {
390
+ throw new Error('Invalid scipy.sparse envelope: csr/csc requires indices and indptr arrays');
391
+ }
204
392
  return {
205
- format: sparse.format,
206
- shape: sparse.shape,
207
- data: sparse.data,
208
- indices: sparse.indices,
209
- indptr: sparse.indptr,
210
- row: sparse.row,
211
- col: sparse.col,
212
- dtype: sparse.dtype,
393
+ format,
394
+ shape,
395
+ data,
396
+ indices,
397
+ indptr,
398
+ dtype,
213
399
  } satisfies SparseMatrix;
214
400
  }
215
- if (marker === 'torch.tensor' && (value as { encoding?: unknown }).encoding === 'ndarray') {
216
- const torchValue = value as {
217
- value?: unknown;
218
- shape?: readonly number[];
219
- dtype?: string;
220
- device?: string;
221
- };
222
- if ('value' in (torchValue as object)) {
223
- const decoded = decodeEnvelope(torchValue.value, decodeArrow);
224
- return {
225
- data: decoded,
226
- shape: torchValue.shape,
227
- dtype: torchValue.dtype,
228
- device: torchValue.device,
229
- } satisfies TorchTensor;
401
+
402
+ if (marker === 'torch.tensor') {
403
+ const encoding = (value as { encoding?: unknown }).encoding;
404
+ if (encoding !== 'ndarray') {
405
+ throw new Error(`Invalid torch.tensor envelope: unsupported encoding ${String(encoding)}`);
406
+ }
407
+ if (!('value' in (value as object))) {
408
+ throw new Error('Invalid torch.tensor envelope: missing value');
230
409
  }
410
+ const nested = (value as { value: unknown }).value;
411
+ if (!isObject(nested) || (nested as { __tywrap__?: unknown }).__tywrap__ !== 'ndarray') {
412
+ throw new Error('Invalid torch.tensor envelope: value must be an ndarray envelope');
413
+ }
414
+ const decoded = recurse(nested);
415
+ const shapeValue = (value as { shape?: unknown }).shape;
416
+ const shape = isNumberArray(shapeValue) ? shapeValue : undefined;
417
+ const dtypeValue = (value as { dtype?: unknown }).dtype;
418
+ const dtype = typeof dtypeValue === 'string' ? dtypeValue : undefined;
419
+ const deviceValue = (value as { device?: unknown }).device;
420
+ const device = typeof deviceValue === 'string' ? deviceValue : undefined;
421
+
422
+ if (isPromiseLike(decoded)) {
423
+ return decoded.then(data => ({ data, shape, dtype, device })) as Promise<T | unknown>;
424
+ }
425
+ return { data: decoded, shape, dtype, device } satisfies TorchTensor;
231
426
  }
232
- if (
233
- marker === 'sklearn.estimator' &&
234
- (value as { encoding?: unknown }).encoding === 'json' &&
235
- typeof (value as { className?: unknown }).className === 'string' &&
236
- typeof (value as { module?: unknown }).module === 'string' &&
237
- isObject((value as { params?: unknown }).params)
238
- ) {
239
- const estimator = value as {
240
- className: string;
241
- module: string;
242
- version?: string;
243
- params: Record<string, unknown>;
244
- };
427
+
428
+ if (marker === 'sklearn.estimator') {
429
+ const encoding = (value as { encoding?: unknown }).encoding;
430
+ if (encoding !== 'json') {
431
+ throw new Error(
432
+ `Invalid sklearn.estimator envelope: unsupported encoding ${String(encoding)}`
433
+ );
434
+ }
435
+ const className = (value as { className?: unknown }).className;
436
+ const module = (value as { module?: unknown }).module;
437
+ const params = (value as { params?: unknown }).params;
438
+ if (typeof className !== 'string' || typeof module !== 'string' || !isObject(params)) {
439
+ throw new Error(
440
+ 'Invalid sklearn.estimator envelope: expected className/module strings + params object'
441
+ );
442
+ }
443
+ const versionValue = (value as { version?: unknown }).version;
444
+ if (versionValue !== undefined && typeof versionValue !== 'string') {
445
+ throw new Error('Invalid sklearn.estimator envelope: version must be a string when provided');
446
+ }
447
+ const version = typeof versionValue === 'string' ? versionValue : undefined;
245
448
  return {
246
- className: estimator.className,
247
- module: estimator.module,
248
- version: estimator.version,
249
- params: estimator.params,
449
+ className,
450
+ module,
451
+ version,
452
+ params,
250
453
  } satisfies SklearnEstimator;
251
454
  }
455
+
252
456
  return value as unknown;
253
457
  }
254
458
 
459
+ function decodeEnvelope<T>(value: unknown, decodeArrow: (bytes: Uint8Array) => T): T | unknown {
460
+ const recurse: (value: unknown) => MaybePromise<T | unknown> = v =>
461
+ decodeEnvelopeCore(v, decodeArrow, recurse);
462
+ const decoded = decodeEnvelopeCore(value, decodeArrow, recurse);
463
+ if (isPromiseLike(decoded)) {
464
+ throw new Error('Unexpected Promise return from decodeValue; use decodeValueAsync instead.');
465
+ }
466
+ return decoded;
467
+ }
468
+
469
+ async function decodeEnvelopeAsync<T>(
470
+ value: unknown,
471
+ decodeArrow: (bytes: Uint8Array) => Promise<T>
472
+ ): Promise<T | unknown> {
473
+ const recurse: (value: unknown) => MaybePromise<T | unknown> = v =>
474
+ decodeEnvelopeCore(v, decodeArrow, recurse);
475
+ return await decodeEnvelopeCore(value, decodeArrow, recurse);
476
+ }
477
+
255
478
  /**
256
479
  * Decode values produced by the Python bridge.
257
480
  */
258
481
  export async function decodeValueAsync(value: unknown): Promise<DecodedValue> {
259
- return await decodeEnvelope(value, tryDecodeArrowTable);
482
+ return await decodeEnvelopeAsync(value, tryDecodeArrowTable);
260
483
  }
261
484
 
262
485
  /**