|
1 | 1 | """Differential parity for fitted transformers referenced as {ref} in a t-string.""" |
2 | 2 |
|
| 3 | +import math |
| 4 | + |
3 | 5 | import numpy as np |
4 | 6 | import pandas as pd |
5 | 7 | import pyarrow as pa |
|
15 | 17 | ) |
16 | 18 |
|
17 | 19 |
|
| 20 | +def _assert_close(a, b, path=""): |
| 21 | + """Recursively compare two structures (dict/list/scalar), floats within a |
| 22 | + tolerance, everything else exactly. |
| 23 | +
|
| 24 | + transform (DataFusion) and infer_batch (Rust) take different numerical |
| 25 | + paths -- transform stacks the whole batch into one matrix, infer_batch runs |
| 26 | + row-at-a-time -- so their floats can differ in the last bit or two (pure |
| 27 | + ULP noise, e.g. a non-collinear PCA fixture measured at ~2e-16). That is |
| 28 | + not the divergence class these tests exist to catch: the bugs this branch |
| 29 | + fixed were whole-feature swaps and hard planning failures, orders of |
| 30 | + magnitude above any float tolerance. Exact equality would flake on the |
| 31 | + next fixture that isn't lucky enough to land on exactly-representable |
| 32 | + arithmetic. |
| 33 | + """ |
| 34 | + if isinstance(a, dict) and isinstance(b, dict): |
| 35 | + assert a.keys() == b.keys(), f"{path}: key mismatch {a.keys()} != {b.keys()}" |
| 36 | + for k in a: |
| 37 | + _assert_close(a[k], b[k], f"{path}.{k}") |
| 38 | + elif isinstance(a, list) and isinstance(b, list): |
| 39 | + assert len(a) == len(b), f"{path}: length mismatch {a!r} != {b!r}" |
| 40 | + for i, (x, y) in enumerate(zip(a, b, strict=True)): |
| 41 | + _assert_close(x, y, f"{path}[{i}]") |
| 42 | + elif isinstance(a, float) and isinstance(b, float): |
| 43 | + assert math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-12), ( |
| 44 | + f"{path}: {a!r} != {b!r}" |
| 45 | + ) |
| 46 | + else: |
| 47 | + assert a == b, f"{path}: {a!r} != {b!r}" |
| 48 | + |
| 49 | + |
18 | 50 | def _both_engines(t, test_df): |
19 | | - """transform (DataFusion) and infer (Rust) as plain dicts; assert equal.""" |
| 51 | + """transform (DataFusion) and infer (Rust) as plain dicts; assert close.""" |
20 | 52 | batch = t.transform(pa.Table.from_pandas(test_df)).to_pylist() |
21 | 53 | infer = [r.model_dump() for r in t.infer_batch(test_df.to_dict("records"))] |
22 | | - assert infer == batch, (infer, batch) |
| 54 | + _assert_close(infer, batch) |
23 | 55 | return batch |
24 | 56 |
|
25 | 57 |
|
@@ -395,7 +427,10 @@ def test_transformer_alongside_partitioned_window_agg(): |
395 | 427 |
|
396 | 428 | batch = t.transform(pa.Table.from_pandas(train)).to_pylist() |
397 | 429 | infer = [r.model_dump() for r in t.infer_batch(train.to_dict("records"))] |
398 | | - assert batch == infer |
| 430 | + # `o` is StandardScaler output -- same batch-column_stack-vs-row-at-a-time |
| 431 | + # risk class as PCA -- so compare with the tolerance helper, not exact |
| 432 | + # equality, even though this particular fixture happens to land exact. |
| 433 | + _assert_close(infer, batch) |
399 | 434 | assert [r["m"] for r in batch] == [15.0, 15.0, 35.0, 35.0] |
400 | 435 |
|
401 | 436 |
|
@@ -459,6 +494,24 @@ def test_named_fit_call_order_is_free(): |
459 | 494 | assert np.allclose(got, sc.transform(train)) |
460 | 495 |
|
461 | 496 |
|
| 497 | +def test_call_order_free_for_a_validating_transformer(): |
| 498 | + # The fit-time probe must feed .transform in feature_names_in_ order, the |
| 499 | + # way both engines feed it at runtime. Probing in call order ran the |
| 500 | + # transform on feature-swapped data, so a transformer that VALIDATES its |
| 501 | + # input (OrdinalEncoder with handle_unknown="error") rejected the probe and |
| 502 | + # fit() raised -- for a query both engines execute correctly. |
| 503 | + df = pd.DataFrame({"cat": ["a", "b", "a", "b"], "grp": ["x", "y", "x", "y"]}) |
| 504 | + oe = OrdinalEncoder(handle_unknown="error").fit(df) # names: [cat, grp] |
| 505 | + |
| 506 | + t = SQLTransform(t"SELECT {oe}(grp, cat) AS o FROM __THIS__").fit( |
| 507 | + pa.Table.from_pandas(df) |
| 508 | + ) # CALL order reversed |
| 509 | + batch = _both_engines(t, df) |
| 510 | + expected = oe.transform(df) |
| 511 | + got = np.array([[r["o"]["cat"], r["o"]["grp"]] for r in batch]) |
| 512 | + assert np.allclose(got, expected) |
| 513 | + |
| 514 | + |
462 | 515 | def test_nested_outer_fitted_in_permuted_order_parity(): |
463 | 516 | # The outer's fitted order is a PERMUTATION of the inner's output names. The |
464 | 517 | # struct the outer actually receives is the inner's output, so in_schema must |
|
0 commit comments