parrot-codec 0.1.0__tar.gz

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.
@@ -0,0 +1,227 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "equivalent"
7
+ version = "1.0.2"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
10
+
11
+ [[package]]
12
+ name = "hashbrown"
13
+ version = "0.17.1"
14
+ source = "registry+https://github.com/rust-lang/crates.io-index"
15
+ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
16
+
17
+ [[package]]
18
+ name = "heck"
19
+ version = "0.5.0"
20
+ source = "registry+https://github.com/rust-lang/crates.io-index"
21
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
22
+
23
+ [[package]]
24
+ name = "indexmap"
25
+ version = "2.14.0"
26
+ source = "registry+https://github.com/rust-lang/crates.io-index"
27
+ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
28
+ dependencies = [
29
+ "equivalent",
30
+ "hashbrown",
31
+ ]
32
+
33
+ [[package]]
34
+ name = "itoa"
35
+ version = "1.0.18"
36
+ source = "registry+https://github.com/rust-lang/crates.io-index"
37
+ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
38
+
39
+ [[package]]
40
+ name = "libc"
41
+ version = "0.2.189"
42
+ source = "registry+https://github.com/rust-lang/crates.io-index"
43
+ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
44
+
45
+ [[package]]
46
+ name = "memchr"
47
+ version = "2.8.3"
48
+ source = "registry+https://github.com/rust-lang/crates.io-index"
49
+ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
50
+
51
+ [[package]]
52
+ name = "once_cell"
53
+ version = "1.21.4"
54
+ source = "registry+https://github.com/rust-lang/crates.io-index"
55
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
56
+
57
+ [[package]]
58
+ name = "parrot_codec"
59
+ version = "0.1.0"
60
+ dependencies = [
61
+ "pyo3",
62
+ "serde_json",
63
+ ]
64
+
65
+ [[package]]
66
+ name = "portable-atomic"
67
+ version = "1.14.0"
68
+ source = "registry+https://github.com/rust-lang/crates.io-index"
69
+ checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
70
+
71
+ [[package]]
72
+ name = "proc-macro2"
73
+ version = "1.0.107"
74
+ source = "registry+https://github.com/rust-lang/crates.io-index"
75
+ checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
76
+ dependencies = [
77
+ "unicode-ident",
78
+ ]
79
+
80
+ [[package]]
81
+ name = "pyo3"
82
+ version = "0.29.0"
83
+ source = "registry+https://github.com/rust-lang/crates.io-index"
84
+ checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c"
85
+ dependencies = [
86
+ "libc",
87
+ "once_cell",
88
+ "portable-atomic",
89
+ "pyo3-build-config",
90
+ "pyo3-ffi",
91
+ "pyo3-macros",
92
+ ]
93
+
94
+ [[package]]
95
+ name = "pyo3-build-config"
96
+ version = "0.29.0"
97
+ source = "registry+https://github.com/rust-lang/crates.io-index"
98
+ checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078"
99
+ dependencies = [
100
+ "target-lexicon",
101
+ ]
102
+
103
+ [[package]]
104
+ name = "pyo3-ffi"
105
+ version = "0.29.0"
106
+ source = "registry+https://github.com/rust-lang/crates.io-index"
107
+ checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b"
108
+ dependencies = [
109
+ "libc",
110
+ "pyo3-build-config",
111
+ ]
112
+
113
+ [[package]]
114
+ name = "pyo3-macros"
115
+ version = "0.29.0"
116
+ source = "registry+https://github.com/rust-lang/crates.io-index"
117
+ checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771"
118
+ dependencies = [
119
+ "proc-macro2",
120
+ "pyo3-macros-backend",
121
+ "quote",
122
+ "syn 2.0.119",
123
+ ]
124
+
125
+ [[package]]
126
+ name = "pyo3-macros-backend"
127
+ version = "0.29.0"
128
+ source = "registry+https://github.com/rust-lang/crates.io-index"
129
+ checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362"
130
+ dependencies = [
131
+ "heck",
132
+ "proc-macro2",
133
+ "quote",
134
+ "syn 2.0.119",
135
+ ]
136
+
137
+ [[package]]
138
+ name = "quote"
139
+ version = "1.0.47"
140
+ source = "registry+https://github.com/rust-lang/crates.io-index"
141
+ checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
142
+ dependencies = [
143
+ "proc-macro2",
144
+ ]
145
+
146
+ [[package]]
147
+ name = "serde"
148
+ version = "1.0.229"
149
+ source = "registry+https://github.com/rust-lang/crates.io-index"
150
+ checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
151
+ dependencies = [
152
+ "serde_core",
153
+ ]
154
+
155
+ [[package]]
156
+ name = "serde_core"
157
+ version = "1.0.229"
158
+ source = "registry+https://github.com/rust-lang/crates.io-index"
159
+ checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
160
+ dependencies = [
161
+ "serde_derive",
162
+ ]
163
+
164
+ [[package]]
165
+ name = "serde_derive"
166
+ version = "1.0.229"
167
+ source = "registry+https://github.com/rust-lang/crates.io-index"
168
+ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
169
+ dependencies = [
170
+ "proc-macro2",
171
+ "quote",
172
+ "syn 3.0.3",
173
+ ]
174
+
175
+ [[package]]
176
+ name = "serde_json"
177
+ version = "1.0.151"
178
+ source = "registry+https://github.com/rust-lang/crates.io-index"
179
+ checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
180
+ dependencies = [
181
+ "indexmap",
182
+ "itoa",
183
+ "memchr",
184
+ "serde",
185
+ "serde_core",
186
+ "zmij",
187
+ ]
188
+
189
+ [[package]]
190
+ name = "syn"
191
+ version = "2.0.119"
192
+ source = "registry+https://github.com/rust-lang/crates.io-index"
193
+ checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
194
+ dependencies = [
195
+ "proc-macro2",
196
+ "quote",
197
+ "unicode-ident",
198
+ ]
199
+
200
+ [[package]]
201
+ name = "syn"
202
+ version = "3.0.3"
203
+ source = "registry+https://github.com/rust-lang/crates.io-index"
204
+ checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
205
+ dependencies = [
206
+ "proc-macro2",
207
+ "quote",
208
+ "unicode-ident",
209
+ ]
210
+
211
+ [[package]]
212
+ name = "target-lexicon"
213
+ version = "0.13.5"
214
+ source = "registry+https://github.com/rust-lang/crates.io-index"
215
+ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
216
+
217
+ [[package]]
218
+ name = "unicode-ident"
219
+ version = "1.0.24"
220
+ source = "registry+https://github.com/rust-lang/crates.io-index"
221
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
222
+
223
+ [[package]]
224
+ name = "zmij"
225
+ version = "1.0.23"
226
+ source = "registry+https://github.com/rust-lang/crates.io-index"
227
+ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,22 @@
1
+ [package]
2
+ name = "parrot_codec"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "Optional Rust columnar-transform extension for parrot.tools.compression (FEAT-380)"
6
+
7
+ [lib]
8
+ name = "parrot_codec"
9
+ crate-type = ["cdylib"]
10
+
11
+ [dependencies]
12
+ pyo3 = { version = "0.29", features = ["extension-module"] }
13
+ # `preserve_order` keeps object-key insertion order matching the JSON text
14
+ # order (the Python side's dict order) — required for the "first-seen
15
+ # order across rows" determinism guarantee (G4); without it serde_json's
16
+ # default Map is a sorted BTreeMap, which would silently reorder columns.
17
+ serde_json = { version = "1.0", features = ["preserve_order"] }
18
+
19
+ [profile.release]
20
+ opt-level = 3
21
+ lto = true
22
+ codegen-units = 1
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: parrot_codec
3
+ Version: 0.1.0
4
+ Classifier: Programming Language :: Rust
5
+ Classifier: Programming Language :: Python :: Implementation :: CPython
6
+ Summary: Optional Rust columnar-transform extension for parrot.tools.compression (FEAT-380)
7
+ Requires-Python: >=3.8
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["maturin>=1.0,<2.0"]
3
+ build-backend = "maturin"
4
+
5
+ [project]
6
+ name = "parrot_codec"
7
+ version = "0.1.0"
8
+ description = "Optional Rust columnar-transform extension for parrot.tools.compression (FEAT-380)"
9
+ requires-python = ">=3.8"
10
+ classifiers = [
11
+ "Programming Language :: Rust",
12
+ "Programming Language :: Python :: Implementation :: CPython",
13
+ ]
14
+
15
+ [tool.maturin]
16
+ module-name = "parrot_codec"
17
+ bindings = "pyo3"
18
+ features = ["pyo3/extension-module"]
@@ -0,0 +1,300 @@
1
+ //! File: codec-rs/src/lib.rs
2
+ //!
3
+ //! Optional Rust acceleration for the `columnar` compression codec
4
+ //! (FEAT-380, TASK-1955). A single FFI crossing: JSON bytes/str in ->
5
+ //! parse -> transform -> JSON bytes out, with the transform itself run
6
+ //! under `py.allow_threads()` so the GIL is released for the duration.
7
+ //!
8
+ //! This module implements ONLY the core columnarization algorithm — the
9
+ //! exact counterpart of `ColumnarCodec._columnarize()` in
10
+ //! `parrot/tools/compression/codecs/columnar.py` (the Python
11
+ //! implementation is the executable specification this must match
12
+ //! byte-for-byte). All of the *outer* concerns (min_rows / heterogeneity /
13
+ //! nested-value guards, QueryResult unwrapping, size measurement,
14
+ //! `CompressionOutcome` construction) stay in Python — by the time this
15
+ //! function is called, the Python side has already confirmed the input is
16
+ //! eligible for columnarization.
17
+ use std::panic::{catch_unwind, AssertUnwindSafe};
18
+
19
+ use pyo3::exceptions::PyRuntimeError;
20
+ use pyo3::prelude::*;
21
+ use serde_json::{Map, Value};
22
+
23
+ /// Python-`==`-compatible equality for `serde_json::Value`.
24
+ ///
25
+ /// `serde_json::Value`'s derived `PartialEq` treats `Number(1)` (parsed
26
+ /// from a JSON integer literal) and `Number(1.0)` (parsed from a JSON
27
+ /// float literal) as UNEQUAL, whereas Python's `==` treats `1 == 1.0` as
28
+ /// `True`. Left unfixed, a numerically-constant column mixing int/float
29
+ /// literals across rows (e.g. `1` in row 0, `1.0` in row 1) would factor
30
+ /// out as a constant in the Python reference implementation but NOT here
31
+ /// — a byte-for-byte parity break (code-review fix, TASK-1955's parity
32
+ /// contract). Compare as `f64` whenever both sides parse as numbers;
33
+ /// fall back to `==` otherwise.
34
+ fn values_equal_like_python(a: &Value, b: &Value) -> bool {
35
+ match (a, b) {
36
+ (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
37
+ (Some(fx), Some(fy)) => fx == fy,
38
+ _ => x == y,
39
+ },
40
+ _ => a == b,
41
+ }
42
+ }
43
+
44
+ /// Split row-oriented JSON (`[{...}, {...}, ...]`) into the columnar
45
+ /// `{"columns": [...], "rows": [[...], ...], "constants": {...}}` form.
46
+ ///
47
+ /// Column order is derived from the data (first-seen order across rows,
48
+ /// in each row's own key order) — never sorted, matching the Python
49
+ /// reference implementation's determinism guarantee (G4).
50
+ ///
51
+ /// `min_rows` is accepted for parity with the Python codec's signature,
52
+ /// but the min-rows guard is evaluated by the PYTHON caller before this
53
+ /// function is ever invoked; it is not re-checked here (harmless no-op
54
+ /// when the input already satisfies it).
55
+ fn columnarize_json(payload: &[u8], _min_rows: usize) -> Result<Vec<u8>, String> {
56
+ let rows: Vec<Map<String, Value>> = serde_json::from_slice::<Vec<Value>>(payload)
57
+ .map_err(|e| format!("invalid row-oriented JSON: {e}"))?
58
+ .into_iter()
59
+ .map(|v| match v {
60
+ Value::Object(m) => Ok(m),
61
+ other => Err(format!("expected a JSON object row, got: {other}")),
62
+ })
63
+ .collect::<Result<Vec<_>, String>>()?;
64
+
65
+ // -- column extraction: first-seen order across rows ------------------
66
+ let mut columns: Vec<String> = Vec::new();
67
+ {
68
+ let mut seen = std::collections::HashSet::new();
69
+ for row in &rows {
70
+ for key in row.keys() {
71
+ if seen.insert(key.clone()) {
72
+ columns.push(key.clone());
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ // -- null-column elision: a column that is null/absent in EVERY row --
79
+ let null_cols: Vec<String> = columns
80
+ .iter()
81
+ .filter(|c| {
82
+ rows.iter()
83
+ .all(|row| matches!(row.get(*c), None | Some(Value::Null)))
84
+ })
85
+ .cloned()
86
+ .collect();
87
+ columns.retain(|c| !null_cols.contains(c));
88
+
89
+ // -- constant-column factoring: only when there is more than one row --
90
+ let mut constants: Map<String, Value> = Map::new();
91
+ if rows.len() > 1 {
92
+ let mut factored: Vec<String> = Vec::new();
93
+ for col in columns.iter() {
94
+ let first = rows[0].get(col).cloned().unwrap_or(Value::Null);
95
+ let all_equal = rows.iter().all(|row| {
96
+ values_equal_like_python(&row.get(col).cloned().unwrap_or(Value::Null), &first)
97
+ });
98
+ if all_equal {
99
+ constants.insert(col.clone(), first);
100
+ factored.push(col.clone());
101
+ }
102
+ }
103
+ columns.retain(|c| !factored.contains(c));
104
+ }
105
+
106
+ // -- build positional rows, aligned to the final `columns` list -------
107
+ let out_rows: Vec<Vec<Value>> = rows
108
+ .iter()
109
+ .map(|row| {
110
+ columns
111
+ .iter()
112
+ .map(|c| row.get(c).cloned().unwrap_or(Value::Null))
113
+ .collect()
114
+ })
115
+ .collect();
116
+
117
+ let mut result = Map::new();
118
+ result.insert(
119
+ "columns".to_string(),
120
+ Value::Array(columns.into_iter().map(Value::String).collect()),
121
+ );
122
+ result.insert(
123
+ "rows".to_string(),
124
+ Value::Array(out_rows.into_iter().map(Value::Array).collect()),
125
+ );
126
+ result.insert("constants".to_string(), Value::Object(constants));
127
+
128
+ serde_json::to_vec(&Value::Object(result)).map_err(|e| format!("serialization error: {e}"))
129
+ }
130
+
131
+ /// Columnarize a row-oriented JSON payload (bytes or str), GIL released
132
+ /// for the duration of the transform.
133
+ ///
134
+ /// Args:
135
+ /// payload: UTF-8 JSON bytes/str encoding a `list[dict]` of rows.
136
+ /// min_rows: Accepted for parity with the Python codec's signature;
137
+ /// not re-checked here (the Python caller already applied the
138
+ /// guard before calling this function).
139
+ ///
140
+ /// Returns:
141
+ /// UTF-8 JSON bytes encoding `{"columns", "rows", "constants"}`.
142
+ ///
143
+ /// Panic safety: `columnarize_json` is run inside `catch_unwind` so an
144
+ /// unexpected panic (e.g. an unforeseen indexing bug) surfaces as an
145
+ /// ordinary `PyRuntimeError` instead of PyO3's `PanicException`.
146
+ /// `PanicException` derives from `BaseException`, not `Exception` — it
147
+ /// would silently bypass `columnar.py`'s `except Exception:` guard and
148
+ /// break the "a broken codec must never break a tool call" contract
149
+ /// (code-review fix). This does NOT protect against a stack overflow,
150
+ /// which aborts the process unconditionally in Rust and cannot be caught
151
+ /// by any mechanism — a residual, documented limitation.
152
+ #[pyfunction]
153
+ #[pyo3(signature = (payload, min_rows=20))]
154
+ fn columnarize(py: Python<'_>, payload: &[u8], min_rows: usize) -> PyResult<Vec<u8>> {
155
+ let owned = payload.to_vec();
156
+ // pyo3 0.29 renamed `Python::allow_threads` -> `Python::detach`
157
+ // (verified against this crate's pinned pyo3 version, `Cargo.toml`);
158
+ // functionally identical GIL-release mechanism (spec's own "Pattern
159
+ // to Follow" used the older `allow_threads` name).
160
+ let outcome = py.detach(move || {
161
+ catch_unwind(AssertUnwindSafe(|| columnarize_json(&owned, min_rows)))
162
+ });
163
+ let result = match outcome {
164
+ Ok(inner) => inner,
165
+ Err(panic_payload) => {
166
+ let msg = panic_payload
167
+ .downcast_ref::<&str>()
168
+ .map(|s| s.to_string())
169
+ .or_else(|| panic_payload.downcast_ref::<String>().cloned())
170
+ .unwrap_or_else(|| "unknown panic in columnarize_json".to_string());
171
+ Err(format!("internal panic during columnarization: {msg}"))
172
+ }
173
+ };
174
+ result.map_err(PyRuntimeError::new_err)
175
+ }
176
+
177
+ #[pymodule]
178
+ fn parrot_codec(m: &Bound<'_, PyModule>) -> PyResult<()> {
179
+ m.add_function(wrap_pyfunction!(columnarize, m)?)?;
180
+ Ok(())
181
+ }
182
+
183
+ #[cfg(test)]
184
+ mod tests {
185
+ use super::*;
186
+
187
+ fn run(input: &str) -> Value {
188
+ let out = columnarize_json(input.as_bytes(), 20).expect("transform failed");
189
+ serde_json::from_slice(&out).expect("invalid output JSON")
190
+ }
191
+
192
+ #[test]
193
+ fn column_order_is_first_seen_not_sorted() {
194
+ let out = run(r#"[{"z":1,"a":2},{"z":3,"a":4}]"#);
195
+ assert_eq!(out["columns"], serde_json::json!(["z", "a"]));
196
+ }
197
+
198
+ #[test]
199
+ fn null_columns_are_elided() {
200
+ let out = run(r#"[{"a":1,"b":null},{"a":2,"b":null}]"#);
201
+ assert_eq!(out["columns"], serde_json::json!(["a"]));
202
+ }
203
+
204
+ #[test]
205
+ fn missing_key_counts_as_null_for_elision() {
206
+ // "b" is entirely absent from every row -> same as always-null.
207
+ let out = run(r#"[{"a":1},{"a":2}]"#);
208
+ assert_eq!(out["columns"], serde_json::json!(["a"]));
209
+ }
210
+
211
+ #[test]
212
+ fn constant_columns_are_factored_when_more_than_one_row() {
213
+ let out = run(r#"[{"a":1,"region":"south"},{"a":2,"region":"south"}]"#);
214
+ assert_eq!(out["columns"], serde_json::json!(["a"]));
215
+ assert_eq!(out["constants"], serde_json::json!({"region": "south"}));
216
+ }
217
+
218
+ #[test]
219
+ fn single_row_never_factors_constants() {
220
+ let out = run(r#"[{"a":1,"b":2}]"#);
221
+ assert_eq!(out["constants"], serde_json::json!({}));
222
+ assert_eq!(out["columns"], serde_json::json!(["a", "b"]));
223
+ }
224
+
225
+ #[test]
226
+ fn rows_are_positionally_aligned_with_null_padding_for_missing_keys() {
227
+ let out = run(r#"[{"a":1,"c":3},{"a":2}]"#);
228
+ // "c" appears in row 0 only, and is not all-null (present+non-null
229
+ // in row 0) so it stays a column; row 1 is missing it -> null.
230
+ assert_eq!(out["columns"], serde_json::json!(["a", "c"]));
231
+ assert_eq!(out["rows"], serde_json::json!([[1, 3], [2, Value::Null]]));
232
+ }
233
+
234
+ #[test]
235
+ fn determinism_same_input_same_output() {
236
+ let input = r#"[{"a":1,"b":null,"region":"south"},{"a":2,"b":null,"region":"south"}]"#;
237
+ let first = run(input);
238
+ for _ in 0..25 {
239
+ assert_eq!(run(input), first);
240
+ }
241
+ }
242
+
243
+ #[test]
244
+ fn rejects_non_array_input() {
245
+ let err = columnarize_json(br#"{"not": "an array"}"#, 20);
246
+ assert!(err.is_err());
247
+ }
248
+
249
+ #[test]
250
+ fn rejects_non_object_rows() {
251
+ let err = columnarize_json(br#"[1, 2, 3]"#, 20);
252
+ assert!(err.is_err());
253
+ }
254
+
255
+ #[test]
256
+ fn values_equal_like_python_matches_python_semantics() {
257
+ assert!(values_equal_like_python(
258
+ &serde_json::json!(1),
259
+ &serde_json::json!(1.0)
260
+ ));
261
+ assert!(!values_equal_like_python(
262
+ &serde_json::json!(1),
263
+ &serde_json::json!(2)
264
+ ));
265
+ assert!(values_equal_like_python(
266
+ &serde_json::json!("a"),
267
+ &serde_json::json!("a")
268
+ ));
269
+ assert!(!values_equal_like_python(
270
+ &serde_json::json!("a"),
271
+ &serde_json::json!("b")
272
+ ));
273
+ }
274
+
275
+ #[test]
276
+ fn int_and_float_constants_are_treated_as_equal_like_python() {
277
+ // Mirrors Python's `1 == 1.0` (`True`) — a column holding `1` in
278
+ // one row and `1.0` in another must still factor out as a
279
+ // constant, matching `ColumnarCodec._columnarize()`'s Python `==`
280
+ // semantics (code-review fix: previously `Number(1) !=
281
+ // Number(1.0)` under serde_json's derived `PartialEq` broke this
282
+ // parity contract).
283
+ let out = run(r#"[{"a":1,"n":1},{"a":2,"n":1.0}]"#);
284
+ assert_eq!(out["columns"], serde_json::json!(["a"]));
285
+ assert_eq!(out["constants"]["n"], serde_json::json!(1));
286
+ }
287
+
288
+ #[test]
289
+ fn catch_unwind_around_transform_captures_panics() {
290
+ // Not a test of the `columnarize()` pyfunction itself (that needs
291
+ // a `Python` GIL token) — verifies the SAME `catch_unwind` +
292
+ // `AssertUnwindSafe` pattern used inside it correctly converts a
293
+ // panic into an `Err` instead of unwinding past the FFI boundary
294
+ // (code-review fix).
295
+ let outcome = catch_unwind(AssertUnwindSafe(|| -> Result<(), String> {
296
+ panic!("synthetic panic for test coverage");
297
+ }));
298
+ assert!(outcome.is_err());
299
+ }
300
+ }