Skip to content

Commit 8227db7

Browse files
authored
Merge pull request #314 from bbopen/chore/0.10-simplify
refactor(codec): simplification round for the 0.10 series
2 parents d1437bd + 6fe676b commit 8227db7

8 files changed

Lines changed: 272 additions & 221 deletions

File tree

docs/codec-envelopes.md

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,23 @@ Every scientific envelope has these fields:
4040
- `codecVersion` is `1`. Decoders treat a missing version as legacy version `0`.
4141
- `encoding` identifies the payload representation.
4242

43+
Scientific values can appear at the root of a result or inside nested Python
44+
dictionaries, lists, and tuples. The Python bridge replaces each recognized
45+
value with its envelope. The JavaScript codec then walks plain objects and
46+
arrays and decodes recognized envelopes within the traversal bounds. A decoded
47+
envelope is a terminal value, so fields inside its decoded output are not
48+
scanned again. A `torch.tensor` envelope is the exception because its `value`
49+
field is a required nested ndarray envelope.
50+
51+
Serialization accepts a maximum nesting depth of 900 and at most 1,000,000
52+
visited containers. Decoding accepts a maximum depth of 2048 and the same
53+
1,000,000-container limit. Scientific envelopes count as containers, including
54+
the ndarray nested inside a tensor. Primitive leaves and the elements of a
55+
decoded envelope payload do not consume the container limit. The bridge reports
56+
the result path when a serialization bound is exceeded, and the JavaScript
57+
codec does the same for decoding bounds. The bridge also rejects circular
58+
Python containers.
59+
4360
### SciPy sparse
4461

4562
Supported targets are `scipy.sparse.csr_matrix`, `csc_matrix`, and
@@ -90,7 +107,8 @@ The target is `torch.Tensor`. CPU tensors work by default. Schema sketch:
90107
"encoding": "arrow" | "json",
91108
"b64": "...", // when encoding="arrow"
92109
"data": [ ... ], // when encoding="json"
93-
"shape": [ ... ]
110+
"shape": [ ... ],
111+
"dtype": "float32"
94112
}
95113
}
96114
```
@@ -128,8 +146,24 @@ pyarrow is not available in WASM.
128146

129147
If an Arrow payload arrives without the dependency, decoding fails with an
130148
installation hint. Set `TYWRAP_CODEC_FALLBACK=json` on the Python side when a
131-
JSON fallback is acceptable. JSON fallback is lossy for dtype and missing-value
132-
fidelity.
149+
JSON representation is acceptable.
150+
151+
An ndarray JSON envelope includes `shape`, `dtype`, and plain JSON `data`. The
152+
decoder checks that the data matches the declared shape and supported dtype
153+
domain. It returns plain JavaScript arrays, or a scalar for a zero-dimensional
154+
array. The `dtype` field records provenance for validation; it does not create a
155+
typed JavaScript array. JSON encoding rejects ndarray dtypes and values that it
156+
cannot represent safely, including complex, structured, object, byte-string,
157+
unicode, datetime, timedelta, big-endian, and unsafe integer cases.
158+
159+
DataFrame and Series JSON envelopes are values-only representations. Before
160+
encoding, the bridge requires an unnamed `RangeIndex` starting at 0 with step 1.
161+
It rejects a `MultiIndex`, categorical dtype, unsupported cell values, unsafe
162+
integers, non-finite floats, duplicate DataFrame column labels, and labels that
163+
collide after JSON object-key coercion. `None`, `pd.NA`, and `pd.NaT` become
164+
JSON `null`. Supported booleans, numbers, strings, empty values, and nulls keep
165+
their value semantics, but pandas dtype and index metadata are not retained as
166+
they are with Arrow.
133167

134168
## Size limits and transport framing
135169

@@ -154,7 +188,8 @@ their promises resolve. A result that does not match its declared return type
154188
throws `BridgeValidationError` with the declared type, received shape, and call
155189
site.
156190

157-
Columnar values retain their decoded provenance. Validation checks the envelope
158-
marker, dimensions, and dtype for DataFrame, Series, and ndarray returns
159-
without walking Arrow data. Returns declared as `unknown`, `void`, or `Any` do
160-
not receive a runtime check.
191+
Decoded scientific values retain their envelope provenance. Return validation
192+
recognizes all six markers: `ndarray`, `dataframe`, `series`, `scipy.sparse`,
193+
`torch.tensor`, and `sklearn.estimator`. It checks the marker and, when present
194+
in the return contract, dimensions and dtype without walking Arrow data. Returns
195+
declared as `unknown`, `void`, or `Any` do not receive a runtime check.

runtime/tywrap_bridge_core.py

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
CODEC_VERSION = 1
6161
MAX_SERIALIZE_DEPTH = 900
6262
MAX_SERIALIZE_NODES = 1_000_000
63+
JS_SAFE_INTEGER_MAX = 2**53 - 1
6364
_SERIALIZE_PATH_IDENTIFIER = re.compile(r'^[A-Za-z_$][\w$]*$', re.ASCII)
6465

6566

@@ -414,9 +415,8 @@ def serialize_ndarray_json(obj):
414415
if dtype.kind in ('i', 'u'):
415416
# JSON numbers become JavaScript Number values. Scan only on this explicit
416417
# fallback path and reject before tolist() can silently round an integer.
417-
js_safe_integer_max = 2**53 - 1
418418
if obj.size and (
419-
(obj < -js_safe_integer_max).any() or (obj > js_safe_integer_max).any()
419+
(obj < -JS_SAFE_INTEGER_MAX).any() or (obj > JS_SAFE_INTEGER_MAX).any()
420420
):
421421
raise RuntimeError(
422422
f'JSON ndarray encoding cannot safely represent dtype={dtype_label} values '
@@ -525,7 +525,7 @@ def serialize_dataframe_json(obj):
525525
import pandas as pd # type: ignore
526526

527527
_validate_pandas_json_index(obj.index, pd, 'DataFrame')
528-
json_columns = [_pandas_json_object_key(column) for column in obj.columns]
528+
json_columns = [_json_object_key(column) for column in obj.columns]
529529
supported_json_columns = [column for column in json_columns if column is not None]
530530
if not obj.columns.is_unique or len(set(supported_json_columns)) != len(
531531
supported_json_columns
@@ -645,7 +645,7 @@ def _validate_pandas_json_index(index, pd, container_name):
645645
)
646646

647647

648-
def _pandas_json_object_key(value):
648+
def _json_object_key(value):
649649
"""Return json.dumps' object-key spelling, or None for unsupported keys."""
650650
try:
651651
encoded = json.dumps({value: None})
@@ -664,8 +664,7 @@ def _normalize_pandas_json_scalar(value, location, pd):
664664
if type(value) is bool:
665665
return value
666666
if type(value) is int:
667-
js_safe_integer_max = 2**53 - 1
668-
if value < -js_safe_integer_max or value > js_safe_integer_max:
667+
if value < -JS_SAFE_INTEGER_MAX or value > JS_SAFE_INTEGER_MAX:
669668
raise RuntimeError(
670669
f'JSON pandas encoding cannot safely represent {location} integer values '
671670
'outside the JavaScript safe integer range; use Arrow encoding or '
@@ -759,8 +758,8 @@ def serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):
759758
Rejection order is significant: the categorical rejections (sparse / quantized
760759
/ meta / complex) are checked BEFORE the device/contiguous opt-in branch so
761760
they fail with a clear, specific message and are NOT bypassable by
762-
TYWRAP_TORCH_ALLOW_COPY. The opt-in only governs the lossy-but-lossless device
763-
transfer and contiguous copy, never an unrepresentable layout/dtype.
761+
TYWRAP_TORCH_ALLOW_COPY. The opt-in only governs the device transfer and
762+
contiguous copy, never an unrepresentable layout/dtype.
764763
"""
765764
import torch # already importable: is_torch_tensor() gated the dispatch
766765

@@ -1053,7 +1052,7 @@ def serialize(obj, *, force_json_markers, torch_allow_copy=False):
10531052
f'keys must be str, int, float, bool or None, not '
10541053
f'{type(item_key).__name__} at {invalid_path}'
10551054
)
1056-
child_key = next(iter(json.loads(json.dumps({item_key: None}))))
1055+
child_key = _json_object_key(item_key)
10571056
child_path = _serialize_path(path, child_key)
10581057
if _needs_serialize_visit(item):
10591058
stack.append(('visit', item, depth + 1, child_path, output, item_key))
@@ -1198,20 +1197,9 @@ def default_encoder(obj):
11981197
if isinstance(obj, pd.Timedelta):
11991198
return obj.total_seconds()
12001199

1201-
if isinstance(obj, dt.datetime):
1202-
return obj.isoformat()
1203-
if isinstance(obj, dt.date):
1204-
return obj.isoformat()
1205-
if isinstance(obj, dt.time):
1206-
return obj.isoformat()
1207-
if isinstance(obj, dt.timedelta):
1208-
return obj.total_seconds()
1209-
if isinstance(obj, decimal.Decimal):
1210-
return str(obj)
1211-
if isinstance(obj, uuid.UUID):
1212-
return str(obj)
1213-
if isinstance(obj, (Path, PurePath)):
1214-
return str(obj)
1200+
stdlib_value = serialize_stdlib(obj)
1201+
if stdlib_value is not None:
1202+
return stdlib_value
12151203

12161204
if isinstance(obj, (bytes, bytearray)):
12171205
return {

src/runtime/bridge-codec.ts

Lines changed: 24 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010

1111
import { BridgeCodecError, BridgeProtocolError, BridgeExecutionError } from './errors.js';
1212
import { containsSpecialFloat } from './validators.js';
13-
import { decodeValueAsync as decodeArrowValue } from '../utils/codec.js';
13+
import {
14+
decodeValueAsync as decodeScientificValue,
15+
isScientificMarker,
16+
ScientificDecodeError,
17+
} from '../utils/codec.js';
1418
import { PROTOCOL_ID } from './transport.js';
1519

1620
// ═══════════════════════════════════════════════════════════════════════════
@@ -91,26 +95,6 @@ function buildPath(basePath: string, key: string | number): string {
9195
return typeof key === 'number' ? `${basePath}[${key}]` : `${basePath}.${key}`;
9296
}
9397

94-
const SCIENTIFIC_MARKERS = new Set([
95-
'dataframe',
96-
'series',
97-
'ndarray',
98-
'scipy.sparse',
99-
'torch.tensor',
100-
'sklearn.estimator',
101-
]);
102-
103-
function scientificMarkerFrom(value: unknown, errorMessage: string): string | undefined {
104-
const match = /^(?:Invalid|Unsupported) ([a-z.]+) envelope/.exec(errorMessage);
105-
if (match?.[1] && SCIENTIFIC_MARKERS.has(match[1])) {
106-
return match[1];
107-
}
108-
if (isPlainObject(value) && typeof value.__tywrap__ === 'string') {
109-
return SCIENTIFIC_MARKERS.has(value.__tywrap__) ? value.__tywrap__ : undefined;
110-
}
111-
return undefined;
112-
}
113-
11498
function containsScientificEnvelope(value: unknown): boolean {
11599
const pending: unknown[] = [value];
116100
while (pending.length > 0) {
@@ -125,7 +109,7 @@ function containsScientificEnvelope(value: unknown): boolean {
125109
continue;
126110
}
127111
if (typeof current.__tywrap__ === 'string') {
128-
return SCIENTIFIC_MARKERS.has(current.__tywrap__);
112+
return isScientificMarker(current.__tywrap__);
129113
}
130114
for (const item of Object.values(current)) {
131115
pending.push(item);
@@ -669,8 +653,8 @@ export class BridgeCodec {
669653
}
670654

671655
/**
672-
* Async version that applies Arrow decoders.
673-
* Use this when the response may contain encoded DataFrames or ndarrays.
656+
* Async version that decodes scientific envelopes.
657+
* Use this when the response may contain encoded scientific values.
674658
*
675659
* @param payload - The JSON string received from Python
676660
* @returns Decoded and validated result with Arrow decoding applied
@@ -681,25 +665,31 @@ export class BridgeCodec {
681665
async decodeResponseAsync<T>(payload: string): Promise<T> {
682666
const result = this.parseResponseResult(payload);
683667

684-
// Apply Arrow decoding to the result
668+
// Decode scientific envelopes in the result.
685669
let decoded: unknown;
686670
try {
687-
decoded = await decodeArrowValue(result);
671+
decoded = await decodeScientificValue(result);
688672
} catch (err) {
689673
const errorMessage = err instanceof Error ? err.message : String(err);
690-
const marker = scientificMarkerFrom(result, errorMessage);
691-
const genuineArrowError =
692-
errorMessage.startsWith('Arrow decode failed:') ||
693-
errorMessage.startsWith(
694-
'Received an Arrow-encoded payload but no Arrow decoder is available.'
695-
);
674+
const decodeError = err instanceof ScientificDecodeError ? err : undefined;
675+
const marker =
676+
decodeError?.marker ??
677+
(isPlainObject(result) && isScientificMarker(result.__tywrap__)
678+
? result.__tywrap__
679+
: 'unknown');
680+
const genuineArrowError = decodeError?.kind === 'arrow';
681+
const valueType = genuineArrowError
682+
? 'arrow'
683+
: marker === 'unknown'
684+
? 'scientific-envelope'
685+
: marker;
696686
throw new BridgeCodecError(
697687
genuineArrowError
698688
? `Arrow decoding failed: ${errorMessage}`
699-
: `Scientific envelope decoding failed (${marker ?? 'unknown'}): ${errorMessage}`,
689+
: `Scientific envelope decoding failed (${marker}): ${errorMessage}`,
700690
{
701691
codecPhase: 'decode',
702-
valueType: genuineArrowError ? 'arrow' : (marker ?? 'scientific-envelope'),
692+
valueType,
703693
}
704694
);
705695
}

src/runtime/pyodide-bootstrap-core.generated.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

src/runtime/validators.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BridgeValidationError } from './errors.js';
2+
import type { ScientificMarker } from '../utils/codec.js';
23

34
/**
45
* Pure validation functions for runtime value checking.
@@ -36,27 +37,15 @@ export type ReturnSchema =
3637
| { kind: 'ref'; name: string }
3738
| {
3839
kind: 'marker';
39-
marker:
40-
| 'dataframe'
41-
| 'series'
42-
| 'ndarray'
43-
| 'scipy.sparse'
44-
| 'torch.tensor'
45-
| 'sklearn.estimator';
40+
marker: ScientificMarker;
4641
dims?: number;
4742
dtype?: string;
4843
};
4944

5045
export type ReturnValidator<T = unknown> = (result: T) => T;
5146

5247
export interface DecodedShapeMetadata {
53-
marker:
54-
| 'dataframe'
55-
| 'series'
56-
| 'ndarray'
57-
| 'scipy.sparse'
58-
| 'torch.tensor'
59-
| 'sklearn.estimator';
48+
marker: ScientificMarker;
6049
dims?: number;
6150
dtype?: string;
6251
}

0 commit comments

Comments
 (0)