tywrap 0.7.0 → 0.9.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 (165) hide show
  1. package/README.md +36 -11
  2. package/SECURITY.md +39 -0
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/index.js +8 -0
  5. package/dist/config/index.js.map +1 -1
  6. package/dist/core/annotation-parser.d.ts +2 -1
  7. package/dist/core/annotation-parser.d.ts.map +1 -1
  8. package/dist/core/annotation-parser.js +6 -3
  9. package/dist/core/annotation-parser.js.map +1 -1
  10. package/dist/core/emit-call.d.ts.map +1 -1
  11. package/dist/core/emit-call.js +1 -1
  12. package/dist/core/emit-call.js.map +1 -1
  13. package/dist/core/generator.d.ts +23 -4
  14. package/dist/core/generator.d.ts.map +1 -1
  15. package/dist/core/generator.js +243 -170
  16. package/dist/core/generator.js.map +1 -1
  17. package/dist/core/mapper.d.ts +3 -2
  18. package/dist/core/mapper.d.ts.map +1 -1
  19. package/dist/core/mapper.js +5 -5
  20. package/dist/core/mapper.js.map +1 -1
  21. package/dist/dev.d.ts.map +1 -1
  22. package/dist/dev.js +1 -3
  23. package/dist/dev.js.map +1 -1
  24. package/dist/index.d.ts +7 -5
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/runtime/base-bridge.d.ts +3 -7
  29. package/dist/runtime/base-bridge.d.ts.map +1 -1
  30. package/dist/runtime/base-bridge.js +4 -17
  31. package/dist/runtime/base-bridge.js.map +1 -1
  32. package/dist/runtime/bounded-context.d.ts +3 -22
  33. package/dist/runtime/bounded-context.d.ts.map +1 -1
  34. package/dist/runtime/bounded-context.js +13 -53
  35. package/dist/runtime/bounded-context.js.map +1 -1
  36. package/dist/runtime/bridge-codec.d.ts +1 -1
  37. package/dist/runtime/bridge-codec.d.ts.map +1 -1
  38. package/dist/runtime/bridge-codec.js +78 -55
  39. package/dist/runtime/bridge-codec.js.map +1 -1
  40. package/dist/runtime/errors.d.ts +16 -0
  41. package/dist/runtime/errors.d.ts.map +1 -1
  42. package/dist/runtime/errors.js +17 -0
  43. package/dist/runtime/errors.js.map +1 -1
  44. package/dist/runtime/frame-codec.d.ts +111 -0
  45. package/dist/runtime/frame-codec.d.ts.map +1 -0
  46. package/dist/runtime/frame-codec.js +352 -0
  47. package/dist/runtime/frame-codec.js.map +1 -0
  48. package/dist/runtime/http-transport.d.ts +1 -1
  49. package/dist/runtime/http-transport.d.ts.map +1 -1
  50. package/dist/runtime/http-transport.js +1 -1
  51. package/dist/runtime/http-transport.js.map +1 -1
  52. package/dist/runtime/index.d.ts +2 -1
  53. package/dist/runtime/index.d.ts.map +1 -1
  54. package/dist/runtime/index.js +2 -1
  55. package/dist/runtime/index.js.map +1 -1
  56. package/dist/runtime/node.d.ts +7 -23
  57. package/dist/runtime/node.d.ts.map +1 -1
  58. package/dist/runtime/node.js +5 -78
  59. package/dist/runtime/node.js.map +1 -1
  60. package/dist/runtime/pooled-transport.d.ts +120 -59
  61. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  62. package/dist/runtime/pooled-transport.js +345 -78
  63. package/dist/runtime/pooled-transport.js.map +1 -1
  64. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  65. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  66. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  67. package/dist/runtime/pyodide-transport.d.ts +1 -1
  68. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  69. package/dist/runtime/pyodide-transport.js +2 -3
  70. package/dist/runtime/pyodide-transport.js.map +1 -1
  71. package/dist/runtime/rpc-client.d.ts +7 -36
  72. package/dist/runtime/rpc-client.d.ts.map +1 -1
  73. package/dist/runtime/rpc-client.js +37 -72
  74. package/dist/runtime/rpc-client.js.map +1 -1
  75. package/dist/runtime/subprocess-transport.d.ts +134 -8
  76. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  77. package/dist/runtime/subprocess-transport.js +490 -65
  78. package/dist/runtime/subprocess-transport.js.map +1 -1
  79. package/dist/runtime/timed-out-request-tracker.d.ts +2 -1
  80. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -1
  81. package/dist/runtime/transport.d.ts +90 -18
  82. package/dist/runtime/transport.d.ts.map +1 -1
  83. package/dist/runtime/transport.js +21 -1
  84. package/dist/runtime/transport.js.map +1 -1
  85. package/dist/runtime/validators.d.ts +49 -0
  86. package/dist/runtime/validators.d.ts.map +1 -1
  87. package/dist/runtime/validators.js +152 -0
  88. package/dist/runtime/validators.js.map +1 -1
  89. package/dist/types/index.d.ts +14 -15
  90. package/dist/types/index.d.ts.map +1 -1
  91. package/dist/tywrap.d.ts +3 -2
  92. package/dist/tywrap.d.ts.map +1 -1
  93. package/dist/tywrap.js +140 -21
  94. package/dist/tywrap.js.map +1 -1
  95. package/dist/utils/cache.d.ts +3 -16
  96. package/dist/utils/cache.d.ts.map +1 -1
  97. package/dist/utils/codec.d.ts +1 -0
  98. package/dist/utils/codec.d.ts.map +1 -1
  99. package/dist/utils/codec.js +165 -17
  100. package/dist/utils/codec.js.map +1 -1
  101. package/dist/utils/ir-cache.d.ts +2 -1
  102. package/dist/utils/ir-cache.d.ts.map +1 -1
  103. package/dist/utils/runtime.d.ts +0 -29
  104. package/dist/utils/runtime.d.ts.map +1 -1
  105. package/dist/utils/runtime.js +16 -107
  106. package/dist/utils/runtime.js.map +1 -1
  107. package/dist/version.js +1 -1
  108. package/package.json +7 -6
  109. package/runtime/frame_codec.py +430 -0
  110. package/runtime/python_bridge.py +213 -54
  111. package/runtime/tywrap_bridge_core.py +217 -147
  112. package/src/config/index.ts +11 -0
  113. package/src/core/annotation-parser.ts +9 -5
  114. package/src/core/emit-call.ts +1 -7
  115. package/src/core/generator.ts +315 -205
  116. package/src/core/mapper.ts +8 -8
  117. package/src/dev.ts +1 -3
  118. package/src/index.ts +7 -3
  119. package/src/runtime/base-bridge.ts +5 -30
  120. package/src/runtime/bounded-context.ts +12 -67
  121. package/src/runtime/bridge-codec.ts +94 -65
  122. package/src/runtime/errors.ts +21 -0
  123. package/src/runtime/frame-codec.ts +469 -0
  124. package/src/runtime/http-transport.ts +6 -1
  125. package/src/runtime/index.ts +7 -6
  126. package/src/runtime/node.ts +17 -104
  127. package/src/runtime/pooled-transport.ts +424 -90
  128. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  129. package/src/runtime/pyodide-transport.ts +7 -3
  130. package/src/runtime/rpc-client.ts +58 -93
  131. package/src/runtime/subprocess-transport.ts +585 -80
  132. package/src/runtime/timed-out-request-tracker.ts +1 -1
  133. package/src/runtime/transport.ts +112 -22
  134. package/src/runtime/validators.ts +204 -0
  135. package/src/types/index.ts +21 -35
  136. package/src/tywrap.ts +157 -30
  137. package/src/utils/cache.ts +3 -3
  138. package/src/utils/codec.ts +205 -16
  139. package/src/utils/ir-cache.ts +1 -1
  140. package/src/utils/runtime.ts +17 -128
  141. package/src/version.ts +1 -1
  142. package/dist/core/discovery.d.ts +0 -103
  143. package/dist/core/discovery.d.ts.map +0 -1
  144. package/dist/core/discovery.js +0 -380
  145. package/dist/core/discovery.js.map +0 -1
  146. package/dist/core/validation.d.ts +0 -102
  147. package/dist/core/validation.d.ts.map +0 -1
  148. package/dist/core/validation.js +0 -490
  149. package/dist/core/validation.js.map +0 -1
  150. package/dist/runtime/base.d.ts +0 -22
  151. package/dist/runtime/base.d.ts.map +0 -1
  152. package/dist/runtime/base.js +0 -23
  153. package/dist/runtime/base.js.map +0 -1
  154. package/dist/runtime/transport-pool.d.ts +0 -196
  155. package/dist/runtime/transport-pool.d.ts.map +0 -1
  156. package/dist/runtime/transport-pool.js +0 -418
  157. package/dist/runtime/transport-pool.js.map +0 -1
  158. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  159. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  160. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  161. package/runtime/safe_codec.py +0 -352
  162. package/src/core/discovery.ts +0 -477
  163. package/src/core/validation.ts +0 -729
  164. package/src/runtime/base.ts +0 -24
  165. package/src/runtime/transport-pool.ts +0 -538
@@ -1,4 +1,4 @@
1
- export interface TimedOutRequestTrackerOptions {
1
+ interface TimedOutRequestTrackerOptions {
2
2
  ttlMs: number;
3
3
  maxSize?: number;
4
4
  }
@@ -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
  // =============================================================================
@@ -35,9 +57,6 @@ export const TYWRAP_PROTOCOL_VERSION = Number.parseInt(PROTOCOL_ID.split('/')[1]
35
57
  *
36
58
  * Each method corresponds to a BridgeProtocol operation:
37
59
  * - `call`: Invoke a module-level function
38
- * - `instantiate`: Create a new class instance
39
- * - `call_method`: Invoke a method on an existing instance
40
- * - `dispose_instance`: Release an instance handle
41
60
  * - `meta`: Get bridge metadata
42
61
  */
43
62
  export interface ProtocolMessage {
@@ -48,25 +67,16 @@ export interface ProtocolMessage {
48
67
  protocol: typeof PROTOCOL_ID;
49
68
 
50
69
  /** The method to invoke */
51
- method: 'call' | 'instantiate' | 'call_method' | 'dispose_instance' | 'meta';
70
+ method: 'call' | 'meta';
52
71
 
53
72
  /** Method parameters */
54
73
  params: {
55
- /** Python module path (for call and instantiate) */
74
+ /** Python module path (for call) */
56
75
  module?: string;
57
76
 
58
77
  /** Function name (for call) */
59
78
  functionName?: string;
60
79
 
61
- /** Class name (for instantiate) */
62
- className?: string;
63
-
64
- /** Instance handle (for call_method and dispose_instance) */
65
- handle?: string;
66
-
67
- /** Method name (for call_method) */
68
- methodName?: string;
69
-
70
80
  /** Positional arguments */
71
81
  args?: unknown[];
72
82
 
@@ -102,6 +112,78 @@ export interface ProtocolResponse {
102
112
  };
103
113
  }
104
114
 
115
+ // =============================================================================
116
+ // CHUNK FRAMING (tywrap-frame/1)
117
+ // =============================================================================
118
+
119
+ /**
120
+ * Per-frame encoding for a {@link ChunkFrame}'s `data` field.
121
+ *
122
+ * - `utf8-slice` (the chosen default for 0.8.0): `data` is a raw substring of the
123
+ * complete logical JSON message, split on UTF-8 codepoint boundaries. Because
124
+ * the logical payload is already valid-UTF-8 JSON, the slices reassemble by
125
+ * simple concatenation — no inflation, no extra decode. See
126
+ * docs/transport-framing.md for the rationale (decision #6).
127
+ * - `utf8-base64`: `data` is a base64-encoded chunk of the UTF-8 bytes, safe for
128
+ * arbitrary byte splits but ~33% larger on the wire with a memory-amplification
129
+ * cost. Reserved as an alternative; not emitted by tywrap in 0.8.0.
130
+ */
131
+ export type ChunkFrameEncoding = 'utf8-base64' | 'utf8-slice';
132
+
133
+ /**
134
+ * A single wire frame of the `tywrap-frame/1` framing protocol.
135
+ *
136
+ * A frame envelope is DISTINCT from the logical {@link ProtocolMessage} /
137
+ * {@link ProtocolResponse}: it carries a slice of the bytes of ONE complete
138
+ * logical JSON message (a request or a response), fragmented because the payload
139
+ * exceeds the transport's frame ceiling. The framing layer reassembles all
140
+ * frames for a given {@link ChunkFrame.id} back into the single logical message
141
+ * before the JSON/codec path ever sees it.
142
+ *
143
+ * Correlation reuses the existing RPC `id`. `seq` is zero-based; `total` and
144
+ * `totalBytes` are repeated on every frame so the receiver can validate the
145
+ * stream is complete (no missing/duplicate `seq`, exact frame count, exact
146
+ * reassembled byte length) before decoding.
147
+ *
148
+ * @see docs/transport-framing.md
149
+ */
150
+ export interface ChunkFrame {
151
+ /**
152
+ * Frame-envelope discriminator.
153
+ * - `'chunk'`: a normal data-carrying frame.
154
+ * - `'error'`: a framing-layer error (e.g. the sender could not continue the
155
+ * stream); carries no further data frames for this `id`.
156
+ */
157
+ __tywrap_frame__: 'chunk' | 'error';
158
+
159
+ /** Framing protocol identifier (must equal {@link FRAME_PROTOCOL_ID}). */
160
+ frameProtocol: string;
161
+
162
+ /** Which logical stream this frame belongs to. */
163
+ stream: 'request' | 'response';
164
+
165
+ /** RPC correlation id, shared with the logical {@link ProtocolMessage.id}. */
166
+ id: number;
167
+
168
+ /** Zero-based sequence index of this frame within its stream. */
169
+ seq: number;
170
+
171
+ /** Total number of frames in this stream (repeated on every frame). */
172
+ total: number;
173
+
174
+ /**
175
+ * Total byte length of the complete reassembled logical message (repeated on
176
+ * every frame). Used to validate the reassembled payload exactly.
177
+ */
178
+ totalBytes: number;
179
+
180
+ /** Per-frame payload encoding (see {@link ChunkFrameEncoding}). */
181
+ encoding: ChunkFrameEncoding;
182
+
183
+ /** This frame's slice of the logical message, encoded per {@link encoding}. */
184
+ data: string;
185
+ }
186
+
105
187
  // =============================================================================
106
188
  // TRANSPORT CAPABILITIES
107
189
  // =============================================================================
@@ -118,9 +200,11 @@ export interface ProtocolResponse {
118
200
  * authoritative for transport-level flags; the meta report is authoritative for
119
201
  * library availability.
120
202
  *
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.
203
+ * `supportsChunking` is always true for the subprocess backend because the
204
+ * packaged Python bridge and JS transport always speak `tywrap-frame/1`.
205
+ * HTTP and Pyodide stay `false`. `supportsStreaming` is `false` on every backend
206
+ * (not implemented in 0.8.0). See docs/transport-capabilities.md for the full
207
+ * matrix.
124
208
  */
125
209
  export interface TransportCapabilities {
126
210
  /** Which backend this transport drives. */
@@ -142,8 +226,8 @@ export interface TransportCapabilities {
142
226
 
143
227
  /**
144
228
  * Whether the transport splits a single logical message across multiple wire
145
- * frames. Not implemented on any backend yet (planned for 0.8.0) — always
146
- * `false`.
229
+ * frames. This is always `true` for the packaged subprocess transport and
230
+ * `false` for HTTP and Pyodide.
147
231
  */
148
232
  readonly supportsChunking: boolean;
149
233
 
@@ -157,7 +241,8 @@ export interface TransportCapabilities {
157
241
  * Maximum size, in bytes, of a single wire frame the transport will accept.
158
242
  * `Number.POSITIVE_INFINITY` means the transport imposes no frame ceiling of
159
243
  * its own (a higher layer — e.g. the codec's payload limit — may still cap the
160
- * size). For the subprocess backend this is the JSONL line-length limit.
244
+ * size). For the subprocess backend this is the always-on framing ceiling for
245
+ * each JSONL wire frame.
161
246
  */
162
247
  readonly maxFrameBytes: number;
163
248
  }
@@ -228,7 +313,12 @@ export interface Transport extends Disposable {
228
313
  * @throws BridgeProtocolError if the message format is invalid
229
314
  * @throws BridgeError for other transport-level failures
230
315
  */
231
- send(message: string, timeoutMs: number, signal?: AbortSignal): Promise<string>;
316
+ send(
317
+ message: string,
318
+ timeoutMs: number,
319
+ signal?: AbortSignal,
320
+ requestId?: number
321
+ ): Promise<string>;
232
322
 
233
323
  /**
234
324
  * Whether the transport is ready to send messages.
@@ -320,7 +410,7 @@ export function isProtocolMessage(value: unknown): value is ProtocolMessage {
320
410
  typeof msg.id === 'number' &&
321
411
  msg.protocol === PROTOCOL_ID &&
322
412
  typeof msg.method === 'string' &&
323
- ['call', 'instantiate', 'call_method', 'dispose_instance', 'meta'].includes(msg.method) &&
413
+ (msg.method === 'call' || msg.method === 'meta') &&
324
414
  typeof msg.params === 'object' &&
325
415
  msg.params !== null
326
416
  );
@@ -1,3 +1,5 @@
1
+ import { BridgeValidationError } from './errors.js';
2
+
1
3
  /**
2
4
  * Pure validation functions for runtime value checking.
3
5
  *
@@ -15,6 +17,208 @@ export class ValidationError extends Error {
15
17
  }
16
18
  }
17
19
 
20
+ /** A serializable return-contract description emitted by the code generator. */
21
+ export type ReturnSchema =
22
+ | { kind: 'any' }
23
+ | {
24
+ kind: 'primitive';
25
+ type: 'number' | 'string' | 'boolean' | 'null' | 'undefined' | 'Uint8Array' | 'object';
26
+ }
27
+ | { kind: 'literal'; value: string | number | boolean | null }
28
+ | { kind: 'array'; element: ReturnSchema }
29
+ | { kind: 'tuple'; elements: ReturnSchema[] }
30
+ | {
31
+ kind: 'record';
32
+ fields?: Record<string, { schema: ReturnSchema; optional?: boolean }>;
33
+ values?: ReturnSchema;
34
+ }
35
+ | { kind: 'union'; options: ReturnSchema[] }
36
+ | { kind: 'ref'; name: string }
37
+ | { kind: 'marker'; marker: 'dataframe' | 'series' | 'ndarray'; dims?: number; dtype?: string };
38
+
39
+ export type ReturnValidator<T = unknown> = (result: T) => T;
40
+
41
+ export interface DecodedShapeMetadata {
42
+ marker: 'dataframe' | 'series' | 'ndarray';
43
+ dims?: number;
44
+ dtype?: string;
45
+ }
46
+
47
+ const decodedShapeMetadata = new WeakMap<object, DecodedShapeMetadata>();
48
+
49
+ /** @internal Preserve wire provenance after codec decoding changes the JS shape. */
50
+ export function tagDecodedShape<T>(value: T, metadata: DecodedShapeMetadata): T {
51
+ if (value !== null && (typeof value === 'object' || typeof value === 'function')) {
52
+ decodedShapeMetadata.set(value as object, metadata);
53
+ }
54
+ return value;
55
+ }
56
+
57
+ function isObjectLike(value: unknown): value is object {
58
+ return value !== null && (typeof value === 'object' || typeof value === 'function');
59
+ }
60
+
61
+ /** A short, bounded description suitable for an error message. */
62
+ export function describeReceivedShape(value: unknown): string {
63
+ if (value === null) {
64
+ return 'null';
65
+ }
66
+ if (value === undefined) {
67
+ return 'undefined';
68
+ }
69
+ if (value instanceof Uint8Array) {
70
+ return `Uint8Array(${value.byteLength})`;
71
+ }
72
+ if (Array.isArray(value)) {
73
+ return `array(${value.length})`;
74
+ }
75
+ if (typeof value === 'object') {
76
+ const marker = decodedShapeMetadata.get(value);
77
+ if (marker) {
78
+ const details = [marker.dims === undefined ? undefined : `${marker.dims}d`, marker.dtype]
79
+ .filter(Boolean)
80
+ .join(', ');
81
+ return `${marker.marker}${details ? ` (${details})` : ''}`;
82
+ }
83
+ const name = (value as { constructor?: { name?: unknown } }).constructor?.name;
84
+ return typeof name === 'string' && name !== 'Object' ? name : 'object';
85
+ }
86
+ return typeof value;
87
+ }
88
+
89
+ function renderSchema(schema: ReturnSchema): string {
90
+ switch (schema.kind) {
91
+ case 'any':
92
+ return 'unknown';
93
+ case 'primitive':
94
+ return schema.type;
95
+ case 'literal':
96
+ return JSON.stringify(schema.value);
97
+ case 'array':
98
+ return `${renderSchema(schema.element)}[]`;
99
+ case 'tuple':
100
+ return `[${schema.elements.map(renderSchema).join(', ')}]`;
101
+ case 'record':
102
+ return 'record';
103
+ case 'union':
104
+ return schema.options.map(renderSchema).join(' | ');
105
+ case 'ref':
106
+ return schema.name;
107
+ case 'marker':
108
+ return schema.marker;
109
+ }
110
+ }
111
+
112
+ interface CheckState {
113
+ readonly definitions: Readonly<Record<string, ReturnSchema>>;
114
+ readonly pairs: WeakMap<object, Set<string>>;
115
+ }
116
+
117
+ function check(schema: ReturnSchema, value: unknown, state: CheckState): boolean {
118
+ if (schema.kind === 'any') {
119
+ return true;
120
+ }
121
+
122
+ if (schema.kind === 'ref') {
123
+ const definition = state.definitions[schema.name];
124
+ if (!definition) {
125
+ return true;
126
+ } // unresolved/erased types deliberately degrade to unknown.
127
+ if (isObjectLike(value)) {
128
+ const seen = state.pairs.get(value) ?? new Set<string>();
129
+ if (seen.has(schema.name)) {
130
+ return true;
131
+ }
132
+ seen.add(schema.name);
133
+ state.pairs.set(value, seen);
134
+ }
135
+ return check(definition, value, state);
136
+ }
137
+
138
+ switch (schema.kind) {
139
+ case 'primitive':
140
+ if (schema.type === 'null') {
141
+ return value === null;
142
+ }
143
+ if (schema.type === 'undefined') {
144
+ return value === undefined;
145
+ }
146
+ if (schema.type === 'Uint8Array') {
147
+ return value instanceof Uint8Array;
148
+ }
149
+ if (schema.type === 'object') {
150
+ return isPlainObject(value);
151
+ }
152
+ return typeof value === schema.type;
153
+ case 'literal':
154
+ return Object.is(value, schema.value);
155
+ case 'array':
156
+ return Array.isArray(value) && value.every(item => check(schema.element, item, state));
157
+ case 'tuple':
158
+ return (
159
+ Array.isArray(value) &&
160
+ value.length === schema.elements.length &&
161
+ schema.elements.every((entry, index) => check(entry, value[index], state))
162
+ );
163
+ case 'record': {
164
+ if (!isPlainObject(value)) {
165
+ return false;
166
+ }
167
+ if (schema.fields) {
168
+ for (const [key, field] of Object.entries(schema.fields)) {
169
+ if (!(key in value)) {
170
+ if (field.optional) {
171
+ continue;
172
+ }
173
+ return false;
174
+ }
175
+ if (!check(field.schema, value[key], state)) {
176
+ return false;
177
+ }
178
+ }
179
+ }
180
+ return (
181
+ !schema.values ||
182
+ Object.values(value).every(item => check(schema.values as ReturnSchema, item, state))
183
+ );
184
+ }
185
+ case 'union':
186
+ return schema.options.some(option => check(option, value, state));
187
+ case 'marker': {
188
+ const metadata = isObjectLike(value) ? decodedShapeMetadata.get(value) : undefined;
189
+ if (metadata?.marker !== schema.marker) {
190
+ return false;
191
+ }
192
+ return (
193
+ (schema.dims === undefined || metadata.dims === schema.dims) &&
194
+ (schema.dtype === undefined || metadata.dtype === schema.dtype)
195
+ );
196
+ }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Construct a cycle-safe structural validator for one generated callable.
202
+ * Unknown/Any/void schemas intentionally validate nothing.
203
+ */
204
+ export function createReturnValidator<T = unknown>(
205
+ schema: ReturnSchema,
206
+ callSite: string,
207
+ definitions: Readonly<Record<string, ReturnSchema>> = {}
208
+ ): ReturnValidator<T> {
209
+ const declaredType = renderSchema(schema);
210
+ return (result: T): T => {
211
+ if (!check(schema, result, { definitions, pairs: new WeakMap<object, Set<string>>() })) {
212
+ throw new BridgeValidationError({
213
+ declaredType,
214
+ receivedShape: describeReceivedShape(result),
215
+ callSite,
216
+ });
217
+ }
218
+ return result;
219
+ };
220
+ }
221
+
18
222
  // ═══════════════════════════════════════════════════════════════════════════
19
223
  // TYPE GUARDS
20
224
  // ═══════════════════════════════════════════════════════════════════════════
@@ -13,6 +13,13 @@ export interface PythonModule {
13
13
  exports: string[];
14
14
  }
15
15
 
16
+ /** A pinned, versioned JSON IR artifact emitted alongside generated wrappers. */
17
+ export interface IrContract {
18
+ ir_version: string;
19
+ module: string;
20
+ [key: string]: unknown;
21
+ }
22
+
16
23
  /**
17
24
  * How a callable is bound on its owning class.
18
25
  *
@@ -261,7 +268,8 @@ export interface TSPrimitiveType {
261
268
  | 'void'
262
269
  | 'unknown'
263
270
  | 'never'
264
- | 'object';
271
+ | 'object'
272
+ | 'Uint8Array';
265
273
  }
266
274
 
267
275
  export interface TSArrayType {
@@ -314,6 +322,7 @@ export interface TSParameter {
314
322
  export interface TSGenericType {
315
323
  kind: 'generic';
316
324
  name: string;
325
+ module?: string;
317
326
  typeArgs: TypescriptType[];
318
327
  }
319
328
 
@@ -338,6 +347,11 @@ export interface TywrapOptions {
338
347
  * and discovery (IR extraction). Useful for local modules not installed in site-packages.
339
348
  */
340
349
  pythonImportPath?: string[];
350
+ /**
351
+ * Read one pinned IR contract instead of spawning Python. A string applies to
352
+ * every configured module; a record selects a contract path per module.
353
+ */
354
+ contractInput?: string | Record<string, string>;
341
355
  output: OutputConfig;
342
356
  runtime: RuntimeConfig;
343
357
  performance: PerformanceConfig;
@@ -404,6 +418,8 @@ export interface PerformanceConfig {
404
418
  compression: 'auto' | 'gzip' | 'brotli' | 'none';
405
419
  }
406
420
 
421
+ // `numpy` is intentionally accepted as a no-op in 0.9: decoded ndarrays are JS
422
+ // arrays, and a faithful static shape awaits #268 return validation.
407
423
  export type TypePreset = 'numpy' | 'pandas' | 'pydantic' | 'stdlib' | 'scipy' | 'torch' | 'sklearn';
408
424
 
409
425
  export interface TypeMappingConfig {
@@ -474,7 +490,7 @@ export interface GenerationMetadata {
474
490
  optimizations: string[];
475
491
  }
476
492
 
477
- // PythonRuntime — the four cross-boundary RPC methods generated wrappers call.
493
+ // PythonRuntime — the call-only cross-boundary RPC method generated wrappers call.
478
494
  // This is the contract that, after the composition rework, is implemented ONLY
479
495
  // by the bridge facades (NodeBridge/HttpBridge/PyodideBridge); the facades
480
496
  // satisfy it by delegating to an owned RpcClient. It deliberately carries NO
@@ -486,45 +502,15 @@ export interface PythonRuntime {
486
502
  module: string,
487
503
  functionName: string,
488
504
  args: unknown[],
489
- kwargs?: Record<string, unknown>
490
- ): Promise<T>;
491
-
492
- instantiate<T = unknown>(
493
- module: string,
494
- className: string,
495
- args: unknown[],
496
- kwargs?: Record<string, unknown>
505
+ kwargs?: Record<string, unknown>,
506
+ validate?: (result: T) => void
497
507
  ): Promise<T>;
498
-
499
- callMethod<T = unknown>(
500
- handle: string,
501
- methodName: string,
502
- args: unknown[],
503
- kwargs?: Record<string, unknown>
504
- ): Promise<T>;
505
-
506
- disposeInstance(handle: string): Promise<void>;
507
508
  }
508
509
 
509
- // Runtime bridge interface — the four RPC methods plus lifecycle dispose().
510
+ // Runtime bridge interface — the call RPC method plus lifecycle dispose().
510
511
  // Kept as PythonRuntime + dispose() so getRuntimeBridge() and every existing
511
512
  // `RuntimeExecution` reference (registry, dev.ts) compile with zero churn. The
512
513
  // RuntimeExecution -> PythonRuntime symbol rename is deferred to the T9 pass.
513
514
  export interface RuntimeExecution extends PythonRuntime {
514
515
  dispose(): Promise<void>;
515
516
  }
516
-
517
- // Utility types
518
- export type DeepReadonly<T> = {
519
- readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
520
- };
521
-
522
- export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
523
-
524
- export type RequiredKeys<T> = {
525
- [K in keyof T]-?: {} extends Pick<T, K> ? never : K;
526
- }[keyof T];
527
-
528
- export type OptionalKeys<T> = {
529
- [K in keyof T]-?: {} extends Pick<T, K> ? K : never;
530
- }[keyof T];