Skip to content

Commit 8d55f35

Browse files
authored
Merge pull request #259 from bbopen/refactor/0.8.0
feat: 0.8.0 — large-payload chunked transport (#231, #233, #234)
2 parents 5e22287 + 1657925 commit 8d55f35

51 files changed

Lines changed: 7526 additions & 239 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,45 @@ jobs:
281281
npm run typecheck
282282
npm run build
283283
284+
data-plane-perf:
285+
# #233 — gate the 0.8.0 large-payload / chunked-transport data plane.
286+
# Dedicated job: pinned Node + Python, TYWRAP_PERF_BUDGETS=1,
287+
# NODE_OPTIONS=--expose-gc, serial Vitest, NO coverage (instrumentation
288+
# skews timings). Budgets are SAME-RUN RELATIVE inside the suite — the
289+
# Apple-Silicon numbers in docs/perf-baselines.md are NOT used as CI truth.
290+
if: github.event_name != 'schedule'
291+
runs-on: ubuntu-latest
292+
steps:
293+
- uses: actions/checkout@v6
294+
- name: Setup Node.js
295+
uses: actions/setup-node@v6
296+
with:
297+
# Pinned: perf comparisons need a stable runtime, not a moving matrix.
298+
node-version: 22
299+
cache: npm
300+
- name: Setup Python
301+
uses: actions/setup-python@v6
302+
with:
303+
# Pinned: the chunked-transport suite spawns runtime/python_bridge.py.
304+
python-version: '3.11'
305+
- name: Install dependencies
306+
run: npm ci --prefer-offline --no-audit
307+
- name: Build
308+
run: npm run build
309+
- name: Run data-plane perf gates (correctness-at-scale + budgets)
310+
env:
311+
NODE_OPTIONS: --expose-gc
312+
# Dedicated flag so the gated suite runs ONLY here, not in the generic
313+
# matrix test jobs (which set TYWRAP_PERF_BUDGETS for other budgets).
314+
TYWRAP_DATA_PLANE_PERF: '1'
315+
# Serial, no coverage. Covers the W7 gate plus the W4/W5 chunked
316+
# large-payload integration suites so release gating includes them.
317+
run: >-
318+
npx vitest run --no-file-parallelism
319+
test/data-plane-perf.test.ts
320+
test/transport-chunking.test.ts
321+
test/transport-request-chunking.test.ts
322+
284323
required:
285324
if: ${{ always() && github.event_name != 'schedule' }}
286325
runs-on: ubuntu-latest
@@ -293,6 +332,7 @@ jobs:
293332
- os
294333
- bun
295334
- node25-smoke
335+
- data-plane-perf
296336
steps:
297337
- name: Check required jobs
298338
run: |
@@ -305,4 +345,5 @@ jobs:
305345
if [ "${{ needs.os.result }}" != "success" ]; then echo "::error::os: ${{ needs.os.result }}"; fail=1; fi
306346
if [ "${{ needs.bun.result }}" != "success" ]; then echo "::error::bun: ${{ needs.bun.result }}"; fail=1; fi
307347
if [ "${{ needs.node25-smoke.result }}" != "success" ]; then echo "::error::node25-smoke: ${{ needs.node25-smoke.result }}"; fail=1; fi
348+
if [ "${{ needs.data-plane-perf.result }}" != "success" ]; then echo "::error::data-plane-perf: ${{ needs.data-plane-perf.result }}"; fail=1; fi
308349
if [ "$fail" -ne 0 ]; then exit 1; fi

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,6 @@ node_modules/.vite/
5555
# VitePress
5656
docs/.vitepress/dist/
5757
docs/.vitepress/cache/
58+
59+
# fx session baseline (local, regenerated per session)
60+
.fx-baseline.ndjson

.prettierignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,6 @@ yarn.lock
2121
pnpm-lock.yaml
2222

2323
# Documentation build
24-
docs/build/
24+
docs/build/
25+
# Generated version constant (built from package.json by scripts/generate-version.mjs)
26+
src/version.ts

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## [0.8.0](https://github.com/bbopen/tywrap/compare/v0.7.0...v0.8.0) (2026-06-01)
4+
5+
The large-payload transport release — the second half of the scientific data plane. A result that exceeds a single JSONL line no longer has to fit on one line: the subprocess bridge splits it into frames and reassembles it byte-for-byte. The wire protocol is unchanged (`tywrap/1`); framing is a separate, additive `tywrap-frame/1` protocol negotiated at startup, so a 0.7.x bridge and a 0.8.0 client still talk, and an oversize payload sent to a bridge that cannot chunk fails loudly instead of silently truncating.
6+
7+
### Features
8+
9+
- **Chunked transport for large payloads (`tywrap-frame/1`, [#231](https://github.com/bbopen/tywrap/issues/231)).** When a request or response exceeds the JSONL line ceiling, the subprocess bridge fragments it into frames and reassembles them. It is negotiated through the `meta` handshake (no protocol-version bump), subprocess-only (HTTP and Pyodide stay single-frame), and slices on UTF-8 codepoint boundaries — no base64 inflation. `NodeBridge` enables chunking by default; it engages only above the frame ceiling, so typical small-payload traffic is unchanged, and raising `codec.maxPayloadBytes` is what carries genuinely large results. Reassembly is bounded — declared and accumulated bytes (default 10 MiB, tracking the codec cap), concurrent streams, and the timed-out-id set — so an oversized or buggy payload fails loud rather than exhausting memory.
10+
- **Scientific envelopes fail clearly ([#234](https://github.com/bbopen/tywrap/issues/234)).** SciPy, Torch, and Sklearn envelopes now reject unsupported cases explicitly — complex/sparse/quantized/meta tensors, non-CPU or non-contiguous tensors without `TYWRAP_TORCH_ALLOW_COPY`, and non-JSON-safe sklearn params — with matching JS-side re-validation. Lossy and device-transfer paths stay opt-in; nothing silently degrades.
11+
12+
### Internal
13+
14+
- Expanded scientific-codec validation and a dedicated `data-plane-perf` CI job that gates the chunked large-payload paths with same-run-relative perf budgets seeded from the 0.7.0 baselines. ([#233](https://github.com/bbopen/tywrap/issues/233))
15+
- `TransportCapabilities.supportsChunking` now reports the **configured** capability (static, like `supportsArrow`); whether the connected bridge actually negotiated framing is a separate runtime fact on `BridgeInfo.transport.supportsChunking`.
16+
- Request cancellation is identity-exact: a timed-out or aborted request is skipped at every write point — including the stdin backpressure queue and mid-burst frames — bound to the exact pending entry, so an abandoned call never executes on the Python side.
17+
318
## [0.7.0](https://github.com/bbopen/tywrap/compare/v0.6.1...v0.7.0) (2026-06-01)
419

520
The foundation half of the scientific data plane. It lands the measurement, capability, and Arrow-ergonomics groundwork the large-payload transport work (0.8.0) builds on, and captures Python class members the IR used to drop. The wire protocol is unchanged, so a 0.6.x bridge and a 0.7.0 client still talk.

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ TypeScript wrapper for Python libraries with full type safety.
2626
- **Multi-Runtime** - Node.js (subprocess) and browsers (Pyodide)
2727
- **Rich Data Types** - numpy, pandas, scipy, torch, sklearn, and stdlib types
2828
- **Efficient Serialization** - Apache Arrow binary format with JSON fallback
29+
- **Large-Payload Transport** - the Node subprocess bridge chunks results that
30+
exceed the JSONL line ceiling and reassembles them, so big payloads aren't
31+
limited to a single line
2932

3033
## Why tywrap?
3134

@@ -105,11 +108,18 @@ new code.
105108

106109
By default, NodeBridge inherits only PATH/PYTHON*/TYWRAP\_* from `process.env`
107110
to keep the subprocess environment minimal. Set `inheritProcessEnv: true` if you
108-
need the full environment. Large JSONL responses are capped by `maxLineLength`
109-
(defaults to `TYWRAP_CODEC_MAX_BYTES` when set, otherwise 1MB).
110-
111-
You can cap payload sizes with `TYWRAP_CODEC_MAX_BYTES` (responses) and
112-
`TYWRAP_REQUEST_MAX_BYTES` (requests) to keep JSONL traffic bounded.
111+
need the full environment.
112+
113+
A request or response larger than the JSONL line ceiling (`maxLineLength`) is
114+
split into `tywrap-frame/1` frames and reassembled — NodeBridge negotiates this
115+
by default, so large payloads aren't limited to one line. Chunking engages only
116+
above the frame ceiling, and reassembly is bounded by the codec payload cap, so
117+
a payload larger than that cap fails loud rather than buffering without limit.
118+
Raise `codec.maxPayloadBytes` to carry genuinely large results. You can still
119+
bound JSONL traffic explicitly with `TYWRAP_CODEC_MAX_BYTES` (responses) and
120+
`TYWRAP_REQUEST_MAX_BYTES` (requests). See the
121+
[transport framing](https://bbopen.github.io/tywrap/transport-framing) and
122+
[capability matrix](https://bbopen.github.io/tywrap/transport-capabilities) docs.
113123

114124
## Development Hot Reload
115125

ROADMAP.md

Lines changed: 32 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,32 @@ The detailed technical appendix for the scientific data plane lives in
88

99
## Recently Shipped
1010

11+
### v0.8.0: large-payload transport
12+
13+
`v0.8.0` is the second half of the scientific data plane (#237). When a request
14+
or response exceeds the single-line JSONL ceiling, the subprocess bridge now
15+
splits it into `tywrap-frame/1` frames and reassembles it byte-for-byte. Framing
16+
is negotiated at startup and additive, so the wire protocol stays `tywrap/1`, a
17+
0.7.x bridge and a 0.8.0 client still talk, and an oversize payload to a bridge
18+
that cannot chunk fails loud. `NodeBridge` enables chunking by default — it
19+
engages only above the frame ceiling, so small-payload traffic is unchanged, and
20+
raising `codec.maxPayloadBytes` is what carries genuinely large results — with
21+
reassembly bounded so a huge payload can't exhaust memory. SciPy/Torch/Sklearn
22+
envelopes now reject unsupported cases explicitly (#234), and a dedicated
23+
`data-plane-perf` CI job gates the chunked paths against the 0.7.0 baselines
24+
(#233). This completes #237.
25+
26+
### v0.7.0: the scientific data plane — foundation
27+
28+
`v0.7.0` is the foundation half of the data plane: measure-first benchmarks that
29+
seed 0.8.0's perf gates, frictionless Arrow auto-registration (#232), a
30+
`TransportCapabilities` descriptor plus a capability matrix across Node, Pyodide,
31+
and HTTP (#235), and capture of the Python member categories the IR used to drop
32+
`@classmethod`, `@property`, `cached_property` via `inspect.classify_class_attrs`
33+
— which bumped the IR schema to `0.3.0` (the one breaking change; regenerate
34+
wrappers). It also added a `tywrap/dev` watch/reload smoke (#228) and folded in
35+
the complexity cleanup deferred from 0.6.1. The wire protocol was unchanged.
36+
1137
### v0.6.1: maintenance (complexity and dedup)
1238

1339
`v0.6.1` is internal-only — no API, behavior, or wire-protocol changes. It
@@ -65,49 +91,17 @@ entrypoint, Node watch sessions that regenerate wrappers and swap the active
6591
bridge, and structured generation failures that keep the last known good output
6692
and bridge live.
6793

68-
## Now (0.7.0): the scientific data plane — foundation
69-
70-
The scientific data plane (tracked under #237) makes large numpy/pandas/Arrow
71-
payloads reliable and first-class. It is large, and the roadmap's own rule is
72-
*measure first: benchmarks land before any perf gate*. The headline chunked
73-
transport (#231) is still undesigned. So the theme ships in two releases.
74-
75-
`0.7.0` is the foundation half — everything buildable today with no
76-
wire-protocol design pass:
77-
78-
- measure first: Arrow round-trip, large-payload decode, size-check overhead,
79-
and pool-throughput benchmarks land against current behavior so 0.8.0's perf
80-
gates have real baselines
81-
- make Arrow registration frictionless — the JS runtime auto-registers an Arrow
82-
decoder when `apache-arrow` is present (#232)
83-
- a `TransportCapabilities` descriptor on each backend, reconciled with the
84-
bridge `meta` report, plus a documented capability matrix across Node,
85-
Pyodide, and HTTP (#235) — the contract #231 chunking keys off
86-
- capture the dropped Python member categories in `tywrap-ir` (`@classmethod`,
87-
`@property`, `cached_property` via `inspect.classify_class_attrs`), bump the IR
88-
schema, and regenerate goldens — the one breaking change
89-
- stabilize the `tywrap/dev` examples with a watch/reload end-to-end smoke (#228)
90-
- fold in the complexity cleanup deferred from 0.6.1 (decompose `generate` and
91-
`fetchPythonIr`; collapse the cross-bridge `call`/`instantiate` boilerplate)
92-
93-
See [docs/codec-roadmap.md](./docs/codec-roadmap.md) for the deeper technical
94-
plan behind this release theme.
94+
## Now
9595

96-
## Next (0.8.0): large-payload transport
96+
The scientific data plane (#237) is complete as of `v0.8.0`. The next release
97+
theme is not yet locked; candidates are drawn from **Later** below.
9798

98-
The design-then-build half, built on 0.7.0's baselines and capability
99-
descriptor:
100-
101-
- design and add a versioned artifact or chunked transport path so large
102-
payloads no longer depend on single-line JSONL (#231)
103-
- expand scientific codec validation and set performance gates from the 0.7.0
104-
baselines (#233)
105-
- harden SciPy, Torch, and Sklearn envelope behavior so supported cases are
106-
explicit and unsupported cases fail clearly (#234)
99+
See [docs/codec-roadmap.md](./docs/codec-roadmap.md) for the deeper technical
100+
appendix behind the data-plane work.
107101

108102
## Later
109103

110-
These items are intentionally not part of `0.7.0` or `0.8.0`:
104+
These items are intentionally not part of the scientific data plane (`0.7.0`/`0.8.0`):
111105

112106
- GPU-native transport such as DLPack or Arrow CUDA
113107
- HTTP server lifecycle management owned by Tywrap

docs/codec-roadmap.md

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,41 @@ This is a forward-looking plan for adding codecs beyond numpy/pandas. The focus
88
- Torch tensors via ndarray envelope (CPU only; explicit copy opt-in).
99
- Sklearn estimators via JSON metadata envelopes.
1010

11+
## Supported vs. Explicit Failure (envelope hardening)
12+
13+
The scientific codecs are deliberately narrow: a known-supported value round-trips,
14+
and everything else fails **loudly** with an actionable message — tywrap never
15+
silently coerces, densifies, pickles, or emits a payload the JS side cannot decode.
16+
The Python serializer (`runtime/tywrap_bridge_core.py`) is the producer; the JS
17+
decoder (`src/utils/codec.ts`) re-validates the envelope it receives as a cheap
18+
second line of defense (it validates only — it never reconstructs a Python object).
19+
20+
### SciPy sparse
21+
22+
| | Behavior |
23+
|---|---|
24+
| **Supported** | `csr` / `csc` / `coo`; int / float / bool dtypes; empty matrices; any shape. dtype + shape preserved. |
25+
| **Explicit failure (Python)** | Any other format (`dia`, `bsr`, `lil`, …) → names the supported set. Complex dtype → rejected (no silent coercion). |
26+
| **Explicit failure (JS re-validation)** | `indptr` length ≠ majorAxis + 1; `indices`/`data` (or COO `row`/`col`/`data`) length mismatch; any index non-integer or out of `[0, minorAxis)`; non-2-item shape. |
27+
28+
### Torch tensors
29+
30+
| | Behavior |
31+
|---|---|
32+
| **Supported** | CPU, contiguous, strided tensors: scalar / 1D / ND; int / float dtypes; Arrow or JSON nested ndarray. shape / dtype / device metadata preserved. |
33+
| **Opt-in (lossy/transfer)** | Non-CPU device **or** non-contiguous layout → rejected **unless** `TYWRAP_TORCH_ALLOW_COPY=1` (then a CPU / contiguous copy is made). Default is rejection. |
34+
| **Explicit failure (Python), NOT opt-in-able** | Sparse (any non-strided layout: COO/CSR/CSC/BSR/BSC), quantized, `meta`, and complex tensors are rejected categorically — `TYWRAP_TORCH_ALLOW_COPY` does **not** bypass them (they have no faithful dense-CPU JSON/Arrow representation). |
35+
| **Explicit failure (JS re-validation)** | `shape` dim negative/non-integer; `shape` element-count disagrees with the nested ndarray `shape`; `device` an empty string; nested `value` not an ndarray envelope. |
36+
37+
### Sklearn estimators
38+
39+
| | Behavior |
40+
|---|---|
41+
| **Supported** | Metadata only: `className`, `module`, `version`, and `get_params(deep=False)` when every param value is plain JSON (primitives / arrays / plain objects). |
42+
| **Explicit failure (Python)** | Any non-JSON param (callable, nested estimator/object, numpy array, NaN/Infinity) → rejected with the **offending param name** and a reminder that estimators are metadata-only (no pickle/joblib). |
43+
| **Explicit failure (JS re-validation)** | `params` not a plain JSON object; any nested param value that is a function / symbol / bigint / class instance / non-finite number. |
44+
| **Never** | `pickle` / `joblib`. Full-model serialization stays an explicit, opt-in follow-up. |
45+
1146
## Goals
1247

1348
- Provide predictable, versioned envelopes for common scientific objects.
@@ -71,9 +106,10 @@ Envelope (current):
71106
```
72107

73108
Notes:
74-
- Only `csr`/`csc`/`coo` formats are supported.
109+
- Only `csr`/`csc`/`coo` formats are supported; any other format is rejected with a message naming the supported set.
75110
- Complex sparse matrices are rejected (explicit failure; no silent coercion).
76111
- No dense fallback: callers should convert explicitly if needed.
112+
- The JS decoder re-validates structural consistency (array lengths vs shape, index ranges) and rejects a corrupt envelope rather than passing it through.
77113

78114
## Torch (tensors)
79115

@@ -106,7 +142,8 @@ Envelope (current):
106142
Notes:
107143
- Default to CPU tensors; require opt-in for `.cpu()` conversion.
108144
- Reject non-contiguous tensors unless explicitly allowed.
109-
- Opt-in copy/transfer via `TYWRAP_TORCH_ALLOW_COPY=1`.
145+
- Opt-in copy/transfer via `TYWRAP_TORCH_ALLOW_COPY=1` (covers non-CPU device + non-contiguous layout ONLY).
146+
- Sparse / quantized / `meta` / complex tensors are rejected categorically and are **not** bypassable by `TYWRAP_TORCH_ALLOW_COPY`; convert explicitly (`to_dense()` / `dequantize()` / materialize / split real-imag) before returning.
110147
- Future: GPU-native transport (DLPack/Arrow CUDA) to avoid implicit device transfers.
111148

112149
## Sklearn (models + outputs)
@@ -132,6 +169,7 @@ Envelope (current):
132169
Notes:
133170
- Avoid `pickle` or `joblib` without explicit opt-in due to security and size.
134171
- Keep model serialization as an advanced, explicit feature.
172+
- Every `get_params(deep=False)` value must be plain JSON; a callable, nested estimator, or other non-JSON param is rejected with the offending param name (no silent drop, no pickle). The JS decoder re-validates `params` is a plain JSON object.
135173

136174
## Implementation Phases
137175

docs/perf-baselines.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,38 @@ illustrate run-to-run variance.
7272

7373
These numbers are stable run-to-run on this machine (single-digit-percent
7474
spread), which is what matters for using them as a regression tripwire.
75+
76+
## Perf gates (0.8.0, #233)
77+
78+
0.8.0 layers actual **perf gates** on top of the measure-first harness above.
79+
They live in [`test/data-plane-perf.test.ts`](../test/data-plane-perf.test.ts)
80+
(also gated behind `TYWRAP_PERF_BUDGETS=1`) and assert budgets, where the
81+
measure-first benchmarks only print. The suite first proves correctness at
82+
scale (chunked 20 MiB + 80 MiB responses and a 20 MiB request echo, all forced
83+
through `tywrap-frame/1` frames against a 1 MiB ceiling), then checks budgets:
84+
85+
| Gate | Budget |
86+
|------|--------|
87+
| Chunked 20 MiB response median vs same-run high-ceiling single frame | calibrated overhead ratio (inherent fragmentation cost, not the doc numbers) |
88+
| `PooledTransport` small-call throughput (4 workers) | >= 70% of a same-run single-worker baseline |
89+
| Arrow ndarray/DataFrame + 100k-row decode | <= 2.0x of a same-run warm baseline |
90+
| Retained heap after an 80 MiB chunked response | <= `payload * 4 + fixed` (no quadratic growth) |
91+
92+
> **CI baselines are stored and compared SEPARATELY from this local doc.** The
93+
> Apple-Silicon numbers in the table above are indicative only and are **not**
94+
> used as CI thresholds. Every gate in `test/data-plane-perf.test.ts` is
95+
> **same-run relative**: it measures a baseline in the same process on the same
96+
> runner (median of 5, after warmup) and compares the subject against *that*,
97+
> never against a hardcoded absolute. This keeps the gates portable across the
98+
> Apple-Silicon dev machine and the pinned Linux CI runner without re-tuning.
99+
100+
The gates run in a dedicated `data-plane-perf` CI job (pinned Node 22 / Python
101+
3.11, `TYWRAP_PERF_BUDGETS=1`, `NODE_OPTIONS=--expose-gc`, serial Vitest, no
102+
coverage so instrumentation does not skew timings). That job is part of the
103+
`required` gate; release publishing additionally re-runs the full suite with
104+
`TYWRAP_PERF_BUDGETS=1`, so the data-plane gates also fence the npm publish.
105+
106+
```bash
107+
TYWRAP_PERF_BUDGETS=1 NODE_OPTIONS=--expose-gc npx vitest run \
108+
test/data-plane-perf.test.ts --reporter=verbose
109+
```

0 commit comments

Comments
 (0)